diff --git a/.github/scripts/SaltProject_altlogo_teal.png b/.github/scripts/SaltProject_altlogo_teal.png
new file mode 100644
index 000000000000..53f399886d82
Binary files /dev/null and b/.github/scripts/SaltProject_altlogo_teal.png differ
diff --git a/.github/scripts/generate_nightly_dashboard.py b/.github/scripts/generate_nightly_dashboard.py
index 20e13320fe55..ef11125cd34a 100644
--- a/.github/scripts/generate_nightly_dashboard.py
+++ b/.github/scripts/generate_nightly_dashboard.py
@@ -52,6 +52,7 @@
from __future__ import annotations
import argparse
+import base64
import html
import json
import os
@@ -64,6 +65,13 @@
KEEP_PER_BRANCH = 30
+LOGO_PATH = Path(__file__).parent / "SaltProject_altlogo_teal.png"
+LOGO_DATA_URI = (
+ "data:image/png;base64," + base64.b64encode(LOGO_PATH.read_bytes()).decode("ascii")
+ if LOGO_PATH.exists()
+ else ""
+)
+
# Extract chunk + os slug from artifact directory name.
# Example dirs (actions/download-artifact@v4 creates one dir per artifact):
# testrun-junit-artifacts-photonos-5-ci-test-onedir-zeromq-unit-1-1786614358
@@ -393,34 +401,104 @@ def render_index_html(history: list) -> str:
else '
| no history yet |
'
)
+ logo_img = (
+ f'
'
+ if LOGO_DATA_URI
+ else 'Salt Project'
+ )
return f"""
+
Salt Nightlies
+
+
Salt Nightlies
-Recent nightly builds. `tests` = total testcase executions across all axes (OS × transport × FIPS × chunk); `unique` = distinct (classname, name) tuples. Click a row to expand the per-suite × OS breakdown (tests, flaky, failed, skip). Updated {now}.
+Recent nightly builds. tests = total testcase executions across all axes (OS × transport × FIPS × chunk); unique = distinct (classname, name) tuples. Click a row to expand the per-suite × OS breakdown (tests, flaky, failed, skip).
@@ -443,6 +533,8 @@ def render_index_html(history: list) -> str:
{rows_html}
+
+
"""
diff --git a/.github/workflows/build-deps-ci-action.yml b/.github/workflows/build-deps-ci-action.yml
index 0e4957cb20cc..a86772ec9adf 100644
--- a/.github/workflows/build-deps-ci-action.yml
+++ b/.github/workflows/build-deps-ci-action.yml
@@ -162,7 +162,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 ac0caa430d96..c0e2cb462414 100644
--- a/.github/workflows/build-packages.yml
+++ b/.github/workflows/build-packages.yml
@@ -24,6 +24,10 @@ on:
type: boolean
default: false
description: Sign RPM Packages
+ sign-deb-packages:
+ type: boolean
+ default: false
+ description: Sign DEB Packages (via debsigs, using SIGNING_GPG_KEY)
sign-macos-packages:
type: boolean
default: false
@@ -66,6 +70,7 @@ jobs:
build-deb-packages:
name: DEB
+ environment: ${{ inputs.environment }}
if: ${{ toJSON(fromJSON(inputs.matrix)['linux']) != '[]' }}
runs-on:
- ${{ matrix.arch == 'x86_64' && 'ubuntu-24.04' || inputs.linux_arm_runner }}
@@ -113,6 +118,10 @@ jobs:
apt-get install -y devscripts
# Installing patchelf for relenv ELF binary patching
apt-get install -y patchelf
+ # Installing debsigs for DEB signing (invoked by tools pkg
+ # build deb --key-id=). Cheap install even when signing is
+ # off so the toolchain is always ready.
+ apt-get install -y debsigs
- name: Download Onedir Tarball as an Artifact
if: inputs.source == 'onedir'
@@ -155,6 +164,30 @@ jobs:
salt-version: "${{ inputs.salt-version }}"
cwd: pkgs/checkout/
+ - name: Setup GnuPG
+ if: ${{ inputs.sign-deb-packages }}
+ env:
+ SIGNING_GPG_KEY: ${{ secrets.SIGNING_GPG_KEY }}
+ SIGNING_PASSPHRASE: ${{ secrets.SIGNING_PASSPHRASE }}
+ run: |
+ install -d -m 0700 -o "$(id -u)" -g "$(id -g)" /run/gpg
+ GNUPGHOME="$(mktemp -d -p /run/gpg)"
+ export GNUPGHOME
+ echo "GNUPGHOME=${GNUPGHOME}" >> "$GITHUB_ENV"
+ cat < "${GNUPGHOME}/gpg.conf"
+ batch
+ no-tty
+ pinentry-mode loopback
+ passphrase-file ${GNUPGHOME}/passphrase
+ EOF
+ echo "${SIGNING_PASSPHRASE}" > "${GNUPGHOME}/passphrase"
+ echo "${SIGNING_GPG_KEY}" | gpg --import -
+ # Discover the imported key's fingerprint so `tools pkg build
+ # deb --key-id=` (and the debsigs call it makes) uses
+ # whatever key material was provided rather than a hardcoded id.
+ SIGN_KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '$1=="fpr" {print $10; exit}')
+ echo "SIGN_KEY_ID=${SIGN_KEY_ID}" >> "$GITHUB_ENV"
+
- name: Configure Git
if: ${{ startsWith(github.event.ref, 'refs/tags') == false }}
working-directory: pkgs/checkout/
@@ -178,7 +211,7 @@ jobs:
format('--onedir=salt-{0}-onedir-linux-{1}.tar.xz', inputs.salt-version, matrix.arch)
||
format('--arch={0}', matrix.arch)
- }}
+ }} ${{ inputs.sign-deb-packages && format('--key-id={0}', env.SIGN_KEY_ID) || '' }}
- name: Cleanup
run: |
@@ -281,6 +314,13 @@ jobs:
EOF
echo "${SIGNING_PASSPHRASE}" > "${GNUPGHOME}/passphrase"
echo "${SIGNING_GPG_KEY}" | gpg --import -
+ # Discover the fingerprint of the just-imported signing key so
+ # Build RPM can pass it to rpmsign without hardcoding a specific
+ # key id. Lets each repo (saltstack/salt, saltstack/salt-nightlies)
+ # provide its own key material via SIGNING_GPG_KEY and have the
+ # workflow use whatever's in the resulting keyring.
+ SIGN_KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '$1=="fpr" {print $10; exit}')
+ echo "SIGN_KEY_ID=${SIGN_KEY_ID}" >> "$GITHUB_ENV"
- name: Configure Git
if: ${{ startsWith(github.event.ref, 'refs/tags') == false }}
@@ -302,7 +342,7 @@ jobs:
format('--onedir=salt-{0}-onedir-linux-{1}.tar.xz', inputs.salt-version, matrix.arch)
||
format('--arch={0}', matrix.arch)
- }} ${{ inputs.sign-rpm-packages && '--key-id=64CBBC8173D76B3F' || '' }}
+ }} ${{ inputs.sign-rpm-packages && format('--key-id={0}', env.SIGN_KEY_ID) || '' }}
- name: Set Artifact Name
id: set-artifact-name
@@ -332,7 +372,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 992c1e809296..ddb79f641dc7 100644
--- a/.github/workflows/build-salt-onedir.yml
+++ b/.github/workflows/build-salt-onedir.yml
@@ -109,7 +109,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/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9b2b28e652a1..a03b6dbf7eaa 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:
@@ -464,8 +466,8 @@ jobs:
with:
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"
+ relenv-version: "0.22.25"
+ python-version: "3.14.7"
ci-python-version: "3.14"
matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }}
linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }}
@@ -481,8 +483,8 @@ jobs:
with:
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"
+ relenv-version: "0.22.25"
+ python-version: "3.14.7"
ci-python-version: "3.14"
source: "onedir"
matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }}
@@ -498,8 +500,8 @@ jobs:
with:
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"
+ relenv-version: "0.22.25"
+ python-version: "3.14.7"
ci-python-version: "3.14"
source: "src"
matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }}
@@ -514,10 +516,10 @@ jobs:
with:
nox-session: ci-test-onedir
nox-version: 2022.8.7
- python-version: "3.14.6"
+ python-version: "3.14.7"
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.14.7
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'] }}
@@ -535,7 +537,7 @@ 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.14.7
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']) }}
@@ -554,7 +556,7 @@ 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.14.7
skip-code-coverage: ${{ fromJSON(needs.prepare-workflow.outputs.config)['skip_code_coverage'] }}
workflow-slug: ci
default-timeout: 180
diff --git a/.github/workflows/nightly-stress-test.yml b/.github/workflows/nightly-stress-test.yml
index a05baa2f1f12..f6ef18f1214c 100644
--- a/.github/workflows/nightly-stress-test.yml
+++ b/.github/workflows/nightly-stress-test.yml
@@ -6,9 +6,33 @@ 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:
+ - '10m'
+ - '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
+ default: false
+ type: boolean
+ worker_threads:
+ description: Salt master worker_threads
+ required: false
+ default: '5'
+ type: string
jobs:
stress-test:
@@ -52,6 +76,59 @@ jobs:
docker compose up -d
sleep 30 # Wait for initialization
+ - 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 || '5' }}
+ 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
+ # 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
+ 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'"
+ 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
@@ -99,7 +176,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)
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 4e453acabf93..1d1cb4fbec10 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -457,8 +457,8 @@ jobs:
with:
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"
+ relenv-version: "0.22.25"
+ python-version: "3.14.7"
ci-python-version: "3.14"
matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }}
linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }}
@@ -471,18 +471,20 @@ jobs:
- build-source-tarball
- build-salt-onedir
uses: ./.github/workflows/build-packages.yml
+ secrets: inherit
with:
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"
+ relenv-version: "0.22.25"
+ python-version: "3.14.7"
ci-python-version: "3.14"
source: "onedir"
matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }}
linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }}
environment: nightly
sign-macos-packages: true
- sign-rpm-packages: false
+ sign-rpm-packages: true
+ sign-deb-packages: true
sign-windows-packages: false
build-pkgs-src:
@@ -492,18 +494,20 @@ jobs:
- prepare-workflow
- build-source-tarball
uses: ./.github/workflows/build-packages.yml
+ secrets: inherit
with:
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"
+ relenv-version: "0.22.25"
+ python-version: "3.14.7"
ci-python-version: "3.14"
source: "src"
matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }}
linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }}
environment: nightly
sign-macos-packages: true
- sign-rpm-packages: false
+ sign-rpm-packages: true
+ sign-deb-packages: true
sign-windows-packages: false
build-ci-deps:
name: CI Deps
@@ -515,10 +519,10 @@ jobs:
with:
nox-session: ci-test-onedir
nox-version: 2022.8.7
- python-version: "3.14.6"
+ python-version: "3.14.7"
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.14.7
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'] }}
@@ -536,7 +540,7 @@ 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.14.7
skip-code-coverage: true
testing-releases: ${{ needs.prepare-workflow.outputs.testing-releases }}
matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['pkg-test-matrix']) }}
@@ -555,7 +559,7 @@ 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.14.7
skip-code-coverage: true
workflow-slug: nightly
default-timeout: 360
diff --git a/.github/workflows/publish-nightly-release.yml b/.github/workflows/publish-nightly-release.yml
index 09bc4c471f1b..6b861f05c4c1 100644
--- a/.github/workflows/publish-nightly-release.yml
+++ b/.github/workflows/publish-nightly-release.yml
@@ -67,31 +67,217 @@ jobs:
echo "already-exists=false" >> "$GITHUB_OUTPUT"
fi
+ - name: check for test-nightly branch (skip publish)
+
+ # Branches named `test-nightly-*` are for exercising the full
+ # nightly.yml pipeline (build, signing, tests) without producing
+ # a release. When head_branch matches, mark this run as skip-only
+ # and every downstream step no-ops.
+ #
+ # Usage: `gh workflow run nightly.yml --repo saltstack/salt-nightlies
+ # --ref test-nightly-verify-signing` (branch must exist
+ # in the salt-nightlies mirror).
+ id: test-branch-check
+ env:
+ BRANCH: ${{ steps.tag.outputs.branch }}
+ run: |
+ set -euo pipefail
+ case "${BRANCH}" in
+ test-nightly-*)
+ echo "test-nightly branch ${BRANCH}; publish will be skipped"
+ echo "::notice::Skipping publish -- test-nightly branch (${BRANCH})"
+ echo "skip=true" >> "$GITHUB_OUTPUT"
+ ;;
+ *)
+ echo "skip=false" >> "$GITHUB_OUTPUT"
+ ;;
+ esac
+
+ - name: check for code changes since last publish
+
+ # Skip publishing when the triggering nightly built the same commit
+ # as the most recent nightly release for this branch. nightly.yml
+ # still fires daily (build + test signal stays fresh), but publish
+ # no-ops on unchanged branches. Rationale:
+ # 1. Republishing the same commit produces a redundant release
+ # that carries no new bits for consumers.
+ # 2. Every publish re-rolls the actions/download-artifact drop
+ # lottery -- fewer publishes = fewer chances to ship an
+ # incomplete asset set (see 3006.x 8/22-8/25 incident).
+ # 3. Releases page and downstream mirrors stay tidy.
+ #
+ # Extracts head_sha from the previous release's body (format:
+ # "Nightly build from branch `X` at commit `SHA`."). Falls open
+ # on any parse failure -- if we can't determine the prior sha,
+ # we err on the side of publishing.
+ id: change-check
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
+ BRANCH: ${{ steps.tag.outputs.branch }}
+ REPO: ${{ github.repository }}
+ run: |
+ set -euo pipefail
+ # Sanitize branch same way the tag computation does.
+ branch_tag=$(printf '%s' "${BRANCH}" | tr '/' '-')
+ # Newest nightly release for this branch, excluding today's tag
+ # in case the idempotent-skip check above already created it.
+ today_tag="${{ steps.tag.outputs.tag }}"
+ last_tag=$(gh release list --repo "${REPO}" --limit 100 --json tagName --jq \
+ "[.[] | select(.tagName | test(\"^nightly-[0-9]{4}-[0-9]{2}-[0-9]{2}-${branch_tag}$\")) | select(.tagName != \"${today_tag}\")] | .[0].tagName // empty")
+ if [ -z "${last_tag}" ]; then
+ echo "no prior nightly release for branch ${BRANCH}; proceeding with publish"
+ echo "unchanged=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ body=$(gh release view "${last_tag}" --repo "${REPO}" --json body --jq .body 2>/dev/null || echo "")
+ # Grab the first 40-char hex string from the body -- that is
+ # the head_sha. Release body template only contains one such
+ # string, so no additional context anchoring is required.
+ prev_sha=$(printf '%s' "${body}" | grep -oE '[a-f0-9]{40}' | head -1 || true)
+ if [ -z "${prev_sha}" ]; then
+ echo "could not extract head_sha from ${last_tag}; failing open (proceeding with publish)"
+ echo "unchanged=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ if [ "${prev_sha}" = "${HEAD_SHA}" ]; then
+ echo "head_sha ${HEAD_SHA} unchanged since ${last_tag}; skipping publish"
+ echo "::notice::Skipping publish -- no code changes on ${BRANCH} since ${last_tag} (${prev_sha:0:12})"
+ echo "unchanged=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "head_sha changed since ${last_tag}: ${prev_sha:0:12} -> ${HEAD_SHA:0:12}; proceeding"
+ echo "unchanged=false" >> "$GITHUB_OUTPUT"
+ fi
+
- name: download all artifacts from the triggering nightly.yml run
+ if: steps.change-check.outputs.unchanged != 'true' && steps.test-branch-check.outputs.skip != 'true'
uses: actions/download-artifact@v4
with:
run-id: ${{ github.event.workflow_run.id }}
path: nightly-artifacts
github-token: ${{ secrets.GITHUB_TOKEN }}
- merge-multiple: true
+ # Do NOT set merge-multiple: true. Several build jobs upload
+ # artifacts that contain files with the same basename (notably
+ # `-rpm` vs `-rpm-from-src`, both containing
+ # salt--0.x86_64.rpm at different sizes/content). With
+ # merge-multiple: true, the second extraction can partially
+ # overlay the first without truncating, producing a same-size
+ # Frankenstein RPM whose header index is corrupt. That is
+ # exactly the failure that shipped as
+ # nightly-2026-08-19-3008.x on the release page (build 210,
+ # tag[49] BAD tag 1118 header index broken).
+ # Keeping merge-multiple: false puts each artifact in its own
+ # subdirectory under nightly-artifacts//; the
+ # find steps below still collect files recursively.
+
+ - name: backfill build artifacts that download-artifact silently dropped
+ if: steps.change-check.outputs.unchanged != 'true' && steps.test-branch-check.outputs.skip != 'true'
+
+ # `actions/download-artifact@v4` has been observed to silently drop
+ # entries past some per-run size threshold on runs with hundreds
+ # of artifacts. The JUnit-download step below has a matching
+ # backfill for the same reason (see 8/18 3008.x publish 32089946413
+ # dropping 36/336 testrun-junit dirs). Without a backfill on THIS
+ # step, an unlucky drop of every `salt-*--rpm`,
+ # `salt-*--deb`, `salt-*-onedir-*` etc. produces a release
+ # with zero (or almost zero) attachments -- observed on 3006.x
+ # for nightly-2026-08-24-3006.x (only 8 aarch64 rpm files
+ # survived) and nightly-2026-08-25-3006.x (0 assets, "count: 0").
+ #
+ # Cross-check what actually landed against the API's authoritative
+ # artifact list for the triggering run, and pull anything missing
+ # via `gh api ... /zip`. Excludes `*-from-src` here as an
+ # optimisation -- the prune step below would remove them anyway.
+ # Per-artifact backfill failure is non-fatal; the subsequent
+ # find/create-release steps operate on whatever landed.
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RUN_ID: ${{ github.event.workflow_run.id }}
+ REPO: ${{ github.repository }}
+ run: |
+ set -euo pipefail
+ mkdir -p nightly-artifacts
+ gh api "repos/${REPO}/actions/runs/${RUN_ID}/artifacts?per_page=100" \
+ --paginate \
+ --jq '.artifacts[] | select(.name | endswith("-from-src") | not) | "\(.id)\t\(.name)"' \
+ > /tmp/expected-build-artifacts.tsv
+ total=$(wc -l < /tmp/expected-build-artifacts.tsv | tr -d ' ')
+ existing=$(find nightly-artifacts -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')
+ echo "expected: ${total} already-downloaded: ${existing}"
+
+ missing_count=0
+ fail_count=0
+ while IFS=$'\t' read -r id name; do
+ if [ -d "nightly-artifacts/${name}" ]; then
+ continue
+ fi
+ missing_count=$((missing_count + 1))
+ echo " backfilling ${name} (id=${id})"
+ mkdir -p "nightly-artifacts/${name}"
+ if gh api -H 'Accept: application/vnd.github+json' \
+ "repos/${REPO}/actions/artifacts/${id}/zip" \
+ > "/tmp/backfill-${id}.zip" 2>/dev/null
+ then
+ unzip -q -o "/tmp/backfill-${id}.zip" -d "nightly-artifacts/${name}" || true
+ else
+ echo " WARN: /zip fetch failed for ${name}"
+ fail_count=$((fail_count + 1))
+ fi
+ rm -f "/tmp/backfill-${id}.zip"
+ done < /tmp/expected-build-artifacts.tsv
+
+ final=$(find nightly-artifacts -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')
+ echo "backfilled: ${missing_count} /zip-failed: ${fail_count} final: ${final}/${total}"
+
+ - name: drop source-build artifacts before publish
+ if: steps.change-check.outputs.unchanged != 'true' && steps.test-branch-check.outputs.skip != 'true'
+ # `-rpm-from-src`, `-deb-from-src` etc. are internal source-build
+ # verification outputs. They are never consumed downstream and
+ # must not reach the release page -- their presence is what
+ # created the same-basename collision that produced the
+ # Frankenstein RPM in the 2026-08-19 incident.
+ # Removing their subdirs entirely before the asset-find step is
+ # simpler and less error-prone than filtering find output.
+ run: |
+ set -euo pipefail
+ shopt -s nullglob
+ removed=0
+ for d in nightly-artifacts/*-from-src; do
+ echo "pruning source-build artifact: ${d}"
+ rm -rf "${d}"
+ removed=$((removed + 1))
+ done
+ echo "pruned ${removed} source-build artifact directories"
- name: list assets to publish
+ if: steps.change-check.outputs.unchanged != 'true' && steps.test-branch-check.outputs.skip != 'true'
run: |
set -euo pipefail
echo "=== files under nightly-artifacts/ ==="
find nightly-artifacts -type f | sort
echo "=== filtered assets (release-ready formats only) ==="
+ # De-duplicate by basename via awk. Some build artifacts contain
+ # the same-named file (notably the debian source tarball
+ # `salt_.tar.xz` is inside BOTH `salt-*-x86_64-deb` and
+ # `salt-*-arm64-deb` artifacts). With merge-multiple: false those
+ # both survive extraction under separate subdirs; passing both
+ # paths to `gh release create` produces HTTP 422
+ # "ReleaseAsset.name already exists" mid-upload -- observed on
+ # nightly-2026-08-25-3008.x. First-sorted path wins. Safe as
+ # long as duplicates are semantically equivalent (they are for
+ # the debian source tarball: same source, both arches).
find nightly-artifacts -type f \
\( -name '*.rpm' -o -name '*.deb' -o -name '*.msi' -o -name '*.exe' \
-o -name '*.pkg' -o -name '*.tar.xz' -o -name '*.tar.gz' \
- -o -name '*onedir*.zip' -o -name '*onedir*.xz' \) | sort > /tmp/assets.txt
+ -o -name '*onedir*.zip' -o -name '*onedir*.xz' \) | sort \
+ | awk -F/ '!seen[$NF]++' > /tmp/assets.txt
echo "count: $(wc -l < /tmp/assets.txt)"
cat /tmp/assets.txt
- name: create release with all assets
- if: steps.check.outputs.already-exists != 'true'
+ if: steps.check.outputs.already-exists != 'true' && steps.change-check.outputs.unchanged != 'true' && steps.test-branch-check.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.tag.outputs.tag }}
@@ -133,6 +319,7 @@ jobs:
# reflects the new release. See .github/scripts/generate_nightly_dashboard.py.
# -----------------------------------------------------------------
- name: download JUnit test-run artifacts from nightly.yml
+ if: steps.change-check.outputs.unchanged != 'true' && steps.test-branch-check.outputs.skip != 'true'
continue-on-error: true
uses: actions/download-artifact@v4
@@ -144,6 +331,7 @@ jobs:
merge-multiple: false
- name: backfill JUnit artifacts that download-artifact silently dropped
+ if: steps.change-check.outputs.unchanged != 'true' && steps.test-branch-check.outputs.skip != 'true'
# actions/download-artifact@v4 has been observed to drop ~10% of
# `testrun-junit-artifacts-*` items past some per-run size threshold
@@ -196,6 +384,7 @@ jobs:
echo "backfilled: ${missing_count} final: ${final}/${total}"
- name: extract salt version from a built package name
+ if: steps.change-check.outputs.unchanged != 'true' && steps.test-branch-check.outputs.skip != 'true'
id: salt-version
env:
@@ -279,6 +468,7 @@ jobs:
echo "version=${version}" >> "$GITHUB_OUTPUT"
- name: checkout gh-pages branch into ./site (create if missing)
+ if: steps.change-check.outputs.unchanged != 'true' && steps.test-branch-check.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -297,6 +487,7 @@ jobs:
fi
- name: regenerate history.json + index.html
+ if: steps.change-check.outputs.unchanged != 'true' && steps.test-branch-check.outputs.skip != 'true'
env:
TAG: ${{ steps.tag.outputs.tag }}
@@ -324,6 +515,7 @@ jobs:
--artifact-count "${asset_count}"
- name: commit + push gh-pages
+ if: steps.change-check.outputs.unchanged != 'true' && steps.test-branch-check.outputs.skip != 'true'
working-directory: site
run: |
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6983001f793f..f8618d93d4fc 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
@@ -94,6 +98,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:
@@ -198,7 +208,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
@@ -248,7 +258,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/.github/workflows/run-nightly-stress.yml b/.github/workflows/run-nightly-stress.yml
new file mode 100644
index 000000000000..3be762d544dc
--- /dev/null
+++ b/.github/workflows/run-nightly-stress.yml
@@ -0,0 +1,82 @@
+name: Run Nightly Stress Tests
+
+on:
+ workflow_dispatch: {}
+ schedule:
+ # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onschedule
+ - cron: '0 2 * * *' # Every day at 2AM
+
+permissions:
+ contents: read
+ actions: write # to trigger branch nightly-stress-test 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-stress-builds:
+ name: Trigger Branch Workflows
+ # Repo-level opt-out, same variable nightly-stress-test.yml's own
+ # master-branch run already checks (SKIP_NIGHTLY_STRESS_TEST) -- e.g.
+ # saltstack/salt, where the stress test's runner cost belongs on the
+ # salt-nightlies fork alongside the other nightlies infra.
+ if: ${{ fromJSON(needs.workflow-requirements.outputs.requirements-met) && vars.SKIP_NIGHTLY_STRESS_TEST != 'true' }}
+ runs-on: ubuntu-24.04
+ needs:
+ - workflow-requirements
+ environment: workflow-restart
+ strategy:
+ matrix:
+ # This is the sole source of the branch list -- nightly-stress-test.yml
+ # carries no `schedule:` of its own and only ever dispatches the one
+ # branch it's told to via `--ref` (matching nightly.yml/run-nightly.yml's
+ # split). master is included here for the same reason: GitHub only
+ # honors `schedule:` from the default branch, so master needs an
+ # explicit dispatch just like every other branch. 3007.x isn't listed
+ # -- its tests/monitoring/ predates render_panels.py, so
+ # nightly-stress-test.yml would fail against it even once ported;
+ # add it once that's backported too.
+ branch: [master, 3006.x, 3008.x]
+ 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-stress-test.yml --repo ${{ github.repository }} --ref ${{ matrix.branch }}
diff --git a/.github/workflows/scheduled.yml b/.github/workflows/scheduled.yml
index ae72a2bcddbf..58b3c3b31b16 100644
--- a/.github/workflows/scheduled.yml
+++ b/.github/workflows/scheduled.yml
@@ -511,8 +511,8 @@ jobs:
with:
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"
+ relenv-version: "0.22.25"
+ python-version: "3.14.7"
ci-python-version: "3.14"
matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }}
linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }}
@@ -528,8 +528,8 @@ jobs:
with:
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"
+ relenv-version: "0.22.25"
+ python-version: "3.14.7"
ci-python-version: "3.14"
source: "onedir"
matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }}
@@ -545,8 +545,8 @@ jobs:
with:
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"
+ relenv-version: "0.22.25"
+ python-version: "3.14.7"
ci-python-version: "3.14"
source: "src"
matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }}
@@ -561,10 +561,10 @@ jobs:
with:
nox-session: ci-test-onedir
nox-version: 2022.8.7
- python-version: "3.14.6"
+ python-version: "3.14.7"
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.14.7
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'] }}
@@ -582,7 +582,7 @@ 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.14.7
skip-code-coverage: true
testing-releases: ${{ needs.prepare-workflow.outputs.testing-releases }}
matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['pkg-test-matrix']) }}
@@ -601,7 +601,7 @@ 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.14.7
skip-code-coverage: true
workflow-slug: scheduled
default-timeout: 360
diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml
index 1b8d2f5d7d0b..f93bfec99a2e 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
@@ -22,6 +23,10 @@ on:
type: boolean
default: false
description: Sign RPM Packages
+ sign-deb-packages:
+ type: boolean
+ default: false
+ description: Sign DEB Packages
skip-salt-test-suite:
type: boolean
default: false
@@ -485,8 +490,8 @@ jobs:
with:
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"
+ relenv-version: "0.22.25"
+ python-version: "3.14.7"
ci-python-version: "3.14"
matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }}
linux_arm_runner: ${{ fromJSON(needs.prepare-workflow.outputs.config)['linux_arm_runner'] }}
@@ -503,8 +508,8 @@ jobs:
with:
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"
+ relenv-version: "0.22.25"
+ python-version: "3.14.7"
ci-python-version: "3.14"
source: "onedir"
matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }}
@@ -512,6 +517,7 @@ jobs:
environment: staging
sign-macos-packages: true
sign-rpm-packages: ${{ inputs.sign-rpm-packages }}
+ sign-deb-packages: ${{ inputs.sign-deb-packages }}
sign-windows-packages: ${{ inputs.sign-windows-packages }}
build-pkgs-src:
@@ -525,8 +531,8 @@ jobs:
with:
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"
+ relenv-version: "0.22.25"
+ python-version: "3.14.7"
ci-python-version: "3.14"
source: "src"
matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['build-matrix']) }}
@@ -534,6 +540,7 @@ jobs:
environment: staging
sign-macos-packages: true
sign-rpm-packages: ${{ inputs.sign-rpm-packages }}
+ sign-deb-packages: ${{ inputs.sign-deb-packages }}
sign-windows-packages: ${{ inputs.sign-windows-packages }}
build-ci-deps:
name: CI Deps
@@ -545,10 +552,10 @@ jobs:
with:
nox-session: ci-test-onedir
nox-version: 2022.8.7
- python-version: "3.14.6"
+ python-version: "3.14.7"
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.14.7
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'] }}
@@ -566,7 +573,7 @@ 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.14.7
skip-code-coverage: true
testing-releases: ${{ needs.prepare-workflow.outputs.testing-releases }}
matrix: ${{ toJSON(fromJSON(needs.prepare-workflow.outputs.config)['pkg-test-matrix']) }}
@@ -585,7 +592,7 @@ 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.14.7
skip-code-coverage: true
workflow-slug: staging
default-timeout: 180
diff --git a/.github/workflows/templates/build-packages.yml.jinja b/.github/workflows/templates/build-packages.yml.jinja
index f5432627820b..d70e36517f00 100644
--- a/.github/workflows/templates/build-packages.yml.jinja
+++ b/.github/workflows/templates/build-packages.yml.jinja
@@ -21,7 +21,7 @@
- build-salt-onedir
<%- endif %>
uses: ./.github/workflows/build-packages.yml
- <% if gh_environment == 'staging' -%>
+ <% if gh_environment != 'ci' -%>
secrets: inherit
<% endif -%>
with:
@@ -36,7 +36,8 @@
<%- if gh_environment != "ci" %>
environment: <{ gh_environment }>
sign-macos-packages: true
- sign-rpm-packages: <% if gh_environment == 'nightly' -%> false <%- else -%> ${{ inputs.sign-rpm-packages }} <%- endif %>
+ sign-rpm-packages: <% if gh_environment == 'nightly' -%> true <%- else -%> ${{ inputs.sign-rpm-packages }} <%- endif %>
+ sign-deb-packages: <% if gh_environment == 'nightly' -%> true <%- else -%> ${{ inputs.sign-deb-packages }} <%- endif %>
sign-windows-packages: <% if gh_environment == 'nightly' -%> false <%- else -%> ${{ inputs.sign-windows-packages }} <%- endif %>
<%- endif %>
diff --git a/.github/workflows/templates/layout.yml.jinja b/.github/workflows/templates/layout.yml.jinja
index ac0fcf009060..f8020c9a5506 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:
@@ -54,7 +56,6 @@ permissions:
actions: read # for technote-space/workflow-conclusion-action to get the job statuses
<%- endif %>
-
<%- endblock permissions %>
<%- block concurrency %>
diff --git a/.github/workflows/templates/scheduled.yml.jinja b/.github/workflows/templates/scheduled.yml.jinja
index 24037d3bc589..685969d09e88 100644
--- a/.github/workflows/templates/scheduled.yml.jinja
+++ b/.github/workflows/templates/scheduled.yml.jinja
@@ -3,6 +3,12 @@
override would otherwise let scheduled.yml re-run on the fork as a duplicate. #}
<%- set prepare_workflow_if_check = "${{ fromJSON(needs.workflow-requirements.outputs.requirements-met) && vars.SKIP_SCHEDULED != 'true' }}" %>
<%- set skip_test_coverage_check = "true" %>
+{#- On saltstack/salt-nightlies the mirror workflow force-pushes branches
+ from saltstack/salt. This scheduled workflow already has its own gate
+ via `RUN_SCHEDULED_BUILDS`; ci.yml.jinja's SKIP_CI-based gate would
+ stack on top of that and cause double-skipping. Set our own gate
+ explicitly here. -#}
+<%- set prepare_workflow_if_check = "${{ fromJSON(needs.workflow-requirements.outputs.requirements-met) && vars.SKIP_SCHEDULED != 'true' }}" %>
<%- extends 'ci.yml.jinja' %>
diff --git a/.github/workflows/templates/staging.yml.jinja b/.github/workflows/templates/staging.yml.jinja
index 611807c96e05..4a3ae8ef5616 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
@@ -33,6 +34,10 @@ on:
type: boolean
default: false
description: Sign RPM Packages
+ sign-deb-packages:
+ type: boolean
+ default: false
+ description: Sign DEB Packages
skip-salt-test-suite:
type: boolean
default: false
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index aee400f0e173..f1767cd25f6e 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -43,6 +43,16 @@ repos:
salt/ext/.*
)$
+ - repo: https://github.com/adrienverge/yamllint
+ rev: v1.38.0
+ hooks:
+ - id: yamllint
+ name: Check for duplicate keys in pre-commit config
+ files: ^\.pre-commit-config\.yaml$
+ args:
+ - -d
+ - "{rules: {key-duplicates: enable}}"
+
- repo: https://github.com/saltstack/python-tools-scripts
rev: "0.20.5"
hooks:
@@ -205,6 +215,23 @@ repos:
- filemap
- check
+ - id: tools
+ alias: check-lint-locks
+ name: Check Lint Requirements Lock Consistency
+ files: ^(requirements/static/(ci|pkg)/py3\.\d+/.*\.lock|tools/precommit/lintlocks\.py)$
+ pass_filenames: false
+ additional_dependencies:
+ - boto3
+ - pyyaml
+ - jinja2
+ - MarkupSafe<3.0.0
+ - packaging
+ - uv
+ args:
+ - pre-commit
+ - lint-locks
+ - check
+
# ----- Packaging Requirements ------------------------------------------------------------------------------------>
# IMPORTANT: We do not pin setuptools here to avoid conflicts with requirements/constraints.txt.
# This allows uv to resolve a version of setuptools that satisfies the constraints.
@@ -1014,7 +1041,6 @@ 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))$
pass_filenames: false
additional_dependencies: ["pip<26.0"]
@@ -1030,7 +1056,6 @@ 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))$
pass_filenames: false
additional_dependencies: ["pip<26.0"]
@@ -1820,21 +1845,17 @@ repos:
# <---- Doc CI Requirements ----------------------------------------------------------------------------------------
- # ----- Lint CI Requirements -------------------------------------------------------------------------------------->
+ # ----- Linux Lint CI Requirements -------------------------------------------------------------------------------------->
+
- id: pip-compile
- alias: compile-ci-lint-3.9-requirements
- name: Lint CI Py3.9 Requirements
- files: ^requirements/(constraints\.txt|(base|zeromq)\.lock|static/(pkg/linux\.txt|ci/(linux\.txt|common\.txt|lint\.txt|py3\.9/linux\.lock)))$
+ alias: compile-ci-linux-lint-3.9-requirements
+ name: Linux Lint CI Py3.9 Requirements
+ files: ^requirements/(constraints\.txt|static/ci/lint\.txt|static/(ci|pkg)/py3\.9/linux(-crypto)?\.lock)$
pass_filenames: false
additional_dependencies: ["pip<26.0"]
args:
- - requirements/base.txt
- - requirements/zeromq.txt
- - requirements/static/ci/common.txt
- requirements/static/ci/lint.txt
- - requirements/static/ci/linux.txt
- - requirements/static/pkg/linux.txt
- --python-platform=linux
- --python-version=3.9
- --constraint
@@ -1843,21 +1864,16 @@ repos:
- --unsafe-package=setuptools
- -c=requirements/static/ci/py3.9/linux.lock
- -c=requirements/static/pkg/py3.9/linux.lock
- - -o=requirements/static/ci/py3.9/lint.lock
+ - -o=requirements/static/ci/py3.9/linux-lint.lock
- id: pip-compile
- alias: compile-ci-lint-3.10-requirements
- name: Lint CI Py3.10 Requirements
- files: ^requirements/(constraints\.txt|(base|zeromq)\.lock|static/(pkg/linux\.txt|ci/(linux\.txt|common\.txt|lint\.txt|py3\.10/linux\.lock)))$
+ alias: compile-ci-linux-lint-3.10-requirements
+ name: Linux Lint CI Py3.10 Requirements
+ files: ^requirements/(constraints\.txt|static/ci/lint\.txt|static/(ci|pkg)/py3\.10/linux(-crypto)?\.lock)$
pass_filenames: false
additional_dependencies: ["pip<26.0"]
args:
- - requirements/base.txt
- - requirements/zeromq.txt
- - requirements/static/ci/common.txt
- requirements/static/ci/lint.txt
- - requirements/static/ci/linux.txt
- - requirements/static/pkg/linux.txt
- --python-platform=linux
- --python-version=3.10
- --constraint
@@ -1866,21 +1882,16 @@ repos:
- --unsafe-package=setuptools
- -c=requirements/static/ci/py3.10/linux.lock
- -c=requirements/static/pkg/py3.10/linux.lock
- - -o=requirements/static/ci/py3.10/lint.lock
+ - -o=requirements/static/ci/py3.10/linux-lint.lock
- id: pip-compile
- alias: compile-ci-lint-3.11-requirements
- name: Lint CI Py3.11 Requirements
- files: ^requirements/(constraints\.txt|(base|zeromq)\.lock|static/(pkg/linux\.txt|ci/(linux\.txt|common\.txt|lint\.txt|py3\.11/linux\.lock)))$
+ alias: compile-ci-linux-lint-3.11-requirements
+ name: Linux Lint CI Py3.11 Requirements
+ files: ^requirements/(constraints\.txt|static/ci/lint\.txt|static/(ci|pkg)/py3\.11/linux(-crypto)?\.lock)$
pass_filenames: false
additional_dependencies: ["pip<26.0"]
args:
- - requirements/base.txt
- - requirements/zeromq.txt
- - requirements/static/ci/common.txt
- requirements/static/ci/lint.txt
- - requirements/static/ci/linux.txt
- - requirements/static/pkg/linux.txt
- --python-platform=linux
- --python-version=3.11
- --constraint
@@ -1889,21 +1900,16 @@ repos:
- --unsafe-package=setuptools
- -c=requirements/static/ci/py3.11/linux.lock
- -c=requirements/static/pkg/py3.11/linux.lock
- - -o=requirements/static/ci/py3.11/lint.lock
+ - -o=requirements/static/ci/py3.11/linux-lint.lock
- id: pip-compile
- alias: compile-ci-lint-3.12-requirements
- name: Lint CI Py3.12 Requirements
- files: ^requirements/(constraints\.txt|(base|zeromq)\.lock|static/(pkg/linux\.txt|ci/(linux\.txt|common\.txt|lint\.txt|py3\.12/linux\.lock)))$
+ alias: compile-ci-linux-lint-3.12-requirements
+ name: Linux Lint CI Py3.12 Requirements
+ files: ^requirements/(constraints\.txt|static/ci/lint\.txt|static/(ci|pkg)/py3\.12/linux(-crypto)?\.lock)$
pass_filenames: false
additional_dependencies: ["pip<26.0"]
args:
- - requirements/base.txt
- - requirements/zeromq.txt
- - requirements/static/ci/common.txt
- requirements/static/ci/lint.txt
- - requirements/static/ci/linux.txt
- - requirements/static/pkg/linux.txt
- --python-platform=linux
- --python-version=3.12
- --constraint
@@ -1912,21 +1918,16 @@ repos:
- --unsafe-package=setuptools
- -c=requirements/static/ci/py3.12/linux.lock
- -c=requirements/static/pkg/py3.12/linux.lock
- - -o=requirements/static/ci/py3.12/lint.lock
+ - -o=requirements/static/ci/py3.12/linux-lint.lock
- id: pip-compile
- alias: compile-ci-lint-3.14-requirements
- name: Lint CI Py3.14 Requirements
- files: ^requirements/(constraints\.txt|(base|zeromq)\.lock|static/(pkg/linux\.txt|ci/(linux\.txt|common\.txt|lint\.txt|py3\.14/linux\.lock)))$
+ alias: compile-ci-linux-lint-3.14-requirements
+ name: Linux Lint CI Py3.14 Requirements
+ files: ^requirements/(constraints\.txt|static/ci/lint\.txt|static/(ci|pkg)/py3\.14/linux(-crypto)?\.lock)$
pass_filenames: false
additional_dependencies: ["pip<26.0"]
args:
- - requirements/base.txt
- - requirements/zeromq.txt
- - requirements/static/ci/common.txt
- requirements/static/ci/lint.txt
- - requirements/static/ci/linux.txt
- - requirements/static/pkg/linux.txt
- --python-platform=linux
- --python-version=3.14
- --constraint
@@ -1935,21 +1936,16 @@ repos:
- --unsafe-package=setuptools
- -c=requirements/static/ci/py3.14/linux.lock
- -c=requirements/static/pkg/py3.14/linux.lock
- - -o=requirements/static/ci/py3.14/lint.lock
+ - -o=requirements/static/ci/py3.14/linux-lint.lock
- id: pip-compile
- alias: compile-ci-lint-3.13-requirements
- name: Lint CI Py3.13 Requirements
- files: ^requirements/(constraints\.txt|(base|zeromq)\.lock|static/(pkg/linux\.txt|ci/(linux\.txt|common\.txt|lint\.txt|py3\.13/linux\.lock)))$
+ alias: compile-ci-linux-lint-3.13-requirements
+ name: Linux Lint CI Py3.13 Requirements
+ files: ^requirements/(constraints\.txt|static/ci/lint\.txt|static/(ci|pkg)/py3\.13/linux(-crypto)?\.lock)$
pass_filenames: false
additional_dependencies: ["pip<26.0"]
args:
- - requirements/base.txt
- - requirements/zeromq.txt
- - requirements/static/ci/common.txt
- requirements/static/ci/lint.txt
- - requirements/static/ci/linux.txt
- - requirements/static/pkg/linux.txt
- --python-platform=linux
- --python-version=3.13
- --constraint
@@ -1958,9 +1954,351 @@ repos:
- --unsafe-package=setuptools
- -c=requirements/static/ci/py3.13/linux.lock
- -c=requirements/static/pkg/py3.13/linux.lock
- - -o=requirements/static/ci/py3.13/lint.lock
+ - -o=requirements/static/ci/py3.13/linux-lint.lock
+
+ # <---- Linux Lint CI Requirements ---------------------------------------------------------------------------------------
+
+
+ # ----- Darwin Lint CI Requirements ------------------------------------------------------------------------->
+
+
+ - id: pip-compile
+ alias: compile-ci-darwin-lint-3.9-requirements
+ name: Darwin Lint CI Py3.9 Requirements
+ files: ^requirements/(constraints\.txt|static/ci/lint\.txt|static/(ci|pkg)/py3\.9/darwin(-crypto)?\.lock)$
+ pass_filenames: false
+ additional_dependencies: ["pip<26.0"]
+ args:
+ - requirements/static/ci/lint.txt
+ - --python-platform=macos
+ - --python-version=3.9
+ - --constraint
+ - requirements/constraints.txt
+ - --no-emit-index-url
+ - --unsafe-package=setuptools
+ - -c=requirements/static/ci/py3.9/darwin.lock
+ - -c=requirements/static/pkg/py3.9/darwin.lock
+ - -o=requirements/static/ci/py3.9/darwin-lint.lock
+
+ - id: pip-compile
+ alias: compile-ci-darwin-lint-3.10-requirements
+ name: Darwin Lint CI Py3.10 Requirements
+ files: ^requirements/(constraints\.txt|static/ci/lint\.txt|static/(ci|pkg)/py3\.10/darwin(-crypto)?\.lock)$
+ pass_filenames: false
+ additional_dependencies: ["pip<26.0"]
+ args:
+ - requirements/static/ci/lint.txt
+ - --python-platform=macos
+ - --python-version=3.10
+ - --constraint
+ - requirements/constraints.txt
+ - --no-emit-index-url
+ - --unsafe-package=setuptools
+ - -c=requirements/static/ci/py3.10/darwin.lock
+ - -c=requirements/static/pkg/py3.10/darwin.lock
+ - -o=requirements/static/ci/py3.10/darwin-lint.lock
+
+ - id: pip-compile
+ alias: compile-ci-darwin-lint-3.11-requirements
+ name: Darwin Lint CI Py3.11 Requirements
+ files: ^requirements/(constraints\.txt|static/ci/lint\.txt|static/(ci|pkg)/py3\.11/darwin(-crypto)?\.lock)$
+ pass_filenames: false
+ additional_dependencies: ["pip<26.0"]
+ args:
+ - requirements/static/ci/lint.txt
+ - --python-platform=macos
+ - --python-version=3.11
+ - --constraint
+ - requirements/constraints.txt
+ - --no-emit-index-url
+ - --unsafe-package=setuptools
+ - -c=requirements/static/ci/py3.11/darwin.lock
+ - -c=requirements/static/pkg/py3.11/darwin.lock
+ - -o=requirements/static/ci/py3.11/darwin-lint.lock
+
+ - id: pip-compile
+ alias: compile-ci-darwin-lint-3.12-requirements
+ name: Darwin Lint CI Py3.12 Requirements
+ files: ^requirements/(constraints\.txt|static/ci/lint\.txt|static/(ci|pkg)/py3\.12/darwin(-crypto)?\.lock)$
+ pass_filenames: false
+ additional_dependencies: ["pip<26.0"]
+ args:
+ - requirements/static/ci/lint.txt
+ - --python-platform=macos
+ - --python-version=3.12
+ - --constraint
+ - requirements/constraints.txt
+ - --no-emit-index-url
+ - --unsafe-package=setuptools
+ - -c=requirements/static/ci/py3.12/darwin.lock
+ - -c=requirements/static/pkg/py3.12/darwin.lock
+ - -o=requirements/static/ci/py3.12/darwin-lint.lock
+
+ - id: pip-compile
+ alias: compile-ci-darwin-lint-3.14-requirements
+ name: Darwin Lint CI Py3.14 Requirements
+ files: ^requirements/(constraints\.txt|static/ci/lint\.txt|static/(ci|pkg)/py3\.14/darwin(-crypto)?\.lock)$
+ pass_filenames: false
+ additional_dependencies: ["pip<26.0"]
+ args:
+ - requirements/static/ci/lint.txt
+ - --python-platform=macos
+ - --python-version=3.14
+ - --constraint
+ - requirements/constraints.txt
+ - --no-emit-index-url
+ - --unsafe-package=setuptools
+ - -c=requirements/static/ci/py3.14/darwin.lock
+ - -c=requirements/static/pkg/py3.14/darwin.lock
+ - -o=requirements/static/ci/py3.14/darwin-lint.lock
+
+ - id: pip-compile
+ alias: compile-ci-darwin-lint-3.13-requirements
+ name: Darwin Lint CI Py3.13 Requirements
+ files: ^requirements/(constraints\.txt|static/ci/lint\.txt|static/(ci|pkg)/py3\.13/darwin(-crypto)?\.lock)$
+ pass_filenames: false
+ additional_dependencies: ["pip<26.0"]
+ args:
+ - requirements/static/ci/lint.txt
+ - --python-platform=macos
+ - --python-version=3.13
+ - --constraint
+ - requirements/constraints.txt
+ - --no-emit-index-url
+ - --unsafe-package=setuptools
+ - -c=requirements/static/ci/py3.13/darwin.lock
+ - -c=requirements/static/pkg/py3.13/darwin.lock
+ - -o=requirements/static/ci/py3.13/darwin-lint.lock
+
+ # <---- Darwin Lint CI Requirements ---------------------------------------------------------------------------
+
+
+ # ----- FreeBSD Lint CI Requirements ------------------------------------------------------------------------>
+
+
+ - id: pip-compile
+ alias: compile-ci-freebsd-lint-3.9-requirements
+ name: FreeBSD Lint CI Py3.9 Requirements
+ files: ^requirements/(constraints\.txt|static/ci/lint\.txt|static/(ci|pkg)/py3\.9/freebsd(-crypto)?\.lock)$
+ pass_filenames: false
+ additional_dependencies: ["pip<26.0"]
+ args:
+ - requirements/static/ci/lint.txt
+ - --universal
+ - --python-version=3.9
+ - --constraint
+ - requirements/constraints.txt
+ - --no-emit-index-url
+ - --unsafe-package=setuptools
+ - -c=requirements/static/ci/py3.9/freebsd.lock
+ - -c=requirements/static/pkg/py3.9/freebsd.lock
+ - -o=requirements/static/ci/py3.9/freebsd-lint.lock
+
+ - id: pip-compile
+ alias: compile-ci-freebsd-lint-3.10-requirements
+ name: FreeBSD Lint CI Py3.10 Requirements
+ files: ^requirements/(constraints\.txt|static/ci/lint\.txt|static/(ci|pkg)/py3\.10/freebsd(-crypto)?\.lock)$
+ pass_filenames: false
+ additional_dependencies: ["pip<26.0"]
+ args:
+ - requirements/static/ci/lint.txt
+ - --universal
+ - --python-version=3.10
+ - --constraint
+ - requirements/constraints.txt
+ - --no-emit-index-url
+ - --unsafe-package=setuptools
+ - -c=requirements/static/ci/py3.10/freebsd.lock
+ - -c=requirements/static/pkg/py3.10/freebsd.lock
+ - -o=requirements/static/ci/py3.10/freebsd-lint.lock
+
+ - id: pip-compile
+ alias: compile-ci-freebsd-lint-3.11-requirements
+ name: FreeBSD Lint CI Py3.11 Requirements
+ files: ^requirements/(constraints\.txt|static/ci/lint\.txt|static/(ci|pkg)/py3\.11/freebsd(-crypto)?\.lock)$
+ pass_filenames: false
+ additional_dependencies: ["pip<26.0"]
+ args:
+ - requirements/static/ci/lint.txt
+ - --universal
+ - --python-version=3.11
+ - --constraint
+ - requirements/constraints.txt
+ - --no-emit-index-url
+ - --unsafe-package=setuptools
+ - -c=requirements/static/ci/py3.11/freebsd.lock
+ - -c=requirements/static/pkg/py3.11/freebsd.lock
+ - -o=requirements/static/ci/py3.11/freebsd-lint.lock
- # <---- Lint CI Requirements ---------------------------------------------------------------------------------------
+ - id: pip-compile
+ alias: compile-ci-freebsd-lint-3.12-requirements
+ name: FreeBSD Lint CI Py3.12 Requirements
+ files: ^requirements/(constraints\.txt|static/ci/lint\.txt|static/(ci|pkg)/py3\.12/freebsd(-crypto)?\.lock)$
+ pass_filenames: false
+ additional_dependencies: ["pip<26.0"]
+ args:
+ - requirements/static/ci/lint.txt
+ - --universal
+ - --python-version=3.12
+ - --constraint
+ - requirements/constraints.txt
+ - --no-emit-index-url
+ - --unsafe-package=setuptools
+ - -c=requirements/static/ci/py3.12/freebsd.lock
+ - -c=requirements/static/pkg/py3.12/freebsd.lock
+ - -o=requirements/static/ci/py3.12/freebsd-lint.lock
+
+ - id: pip-compile
+ alias: compile-ci-freebsd-lint-3.14-requirements
+ name: FreeBSD Lint CI Py3.14 Requirements
+ files: ^requirements/(constraints\.txt|static/ci/lint\.txt|static/(ci|pkg)/py3\.14/freebsd(-crypto)?\.lock)$
+ pass_filenames: false
+ additional_dependencies: ["pip<26.0"]
+ args:
+ - requirements/static/ci/lint.txt
+ - --universal
+ - --python-version=3.14
+ - --constraint
+ - requirements/constraints.txt
+ - --no-emit-index-url
+ - --unsafe-package=setuptools
+ - -c=requirements/static/ci/py3.14/freebsd.lock
+ - -c=requirements/static/pkg/py3.14/freebsd.lock
+ - -o=requirements/static/ci/py3.14/freebsd-lint.lock
+
+ - id: pip-compile
+ alias: compile-ci-freebsd-lint-3.13-requirements
+ name: FreeBSD Lint CI Py3.13 Requirements
+ files: ^requirements/(constraints\.txt|static/ci/lint\.txt|static/(ci|pkg)/py3\.13/freebsd(-crypto)?\.lock)$
+ pass_filenames: false
+ additional_dependencies: ["pip<26.0"]
+ args:
+ - requirements/static/ci/lint.txt
+ - --universal
+ - --python-version=3.13
+ - --constraint
+ - requirements/constraints.txt
+ - --no-emit-index-url
+ - --unsafe-package=setuptools
+ - -c=requirements/static/ci/py3.13/freebsd.lock
+ - -c=requirements/static/pkg/py3.13/freebsd.lock
+ - -o=requirements/static/ci/py3.13/freebsd-lint.lock
+
+ # <---- FreeBSD Lint CI Requirements --------------------------------------------------------------------------
+
+
+ # ----- Windows Lint CI Requirements ------------------------------------------------------------------------>
+
+
+ - id: pip-compile
+ alias: compile-ci-windows-lint-3.9-requirements
+ name: Windows Lint CI Py3.9 Requirements
+ files: ^requirements/(constraints\.txt|static/ci/lint\.txt|static/(ci|pkg)/py3\.9/windows(-crypto)?\.lock)$
+ pass_filenames: false
+ additional_dependencies: ["pip<26.0"]
+ args:
+ - requirements/static/ci/lint.txt
+ - --python-platform=windows
+ - --python-version=3.9
+ - --constraint
+ - requirements/constraints.txt
+ - --no-emit-index-url
+ - --unsafe-package=setuptools
+ - -c=requirements/static/ci/py3.9/windows.lock
+ - -c=requirements/static/pkg/py3.9/windows.lock
+ - -o=requirements/static/ci/py3.9/windows-lint.lock
+
+ - id: pip-compile
+ alias: compile-ci-windows-lint-3.10-requirements
+ name: Windows Lint CI Py3.10 Requirements
+ files: ^requirements/(constraints\.txt|static/ci/lint\.txt|static/(ci|pkg)/py3\.10/windows(-crypto)?\.lock)$
+ pass_filenames: false
+ additional_dependencies: ["pip<26.0"]
+ args:
+ - requirements/static/ci/lint.txt
+ - --python-platform=windows
+ - --python-version=3.10
+ - --constraint
+ - requirements/constraints.txt
+ - --no-emit-index-url
+ - --unsafe-package=setuptools
+ - -c=requirements/static/ci/py3.10/windows.lock
+ - -c=requirements/static/pkg/py3.10/windows.lock
+ - -o=requirements/static/ci/py3.10/windows-lint.lock
+
+ - id: pip-compile
+ alias: compile-ci-windows-lint-3.11-requirements
+ name: Windows Lint CI Py3.11 Requirements
+ files: ^requirements/(constraints\.txt|static/ci/lint\.txt|static/(ci|pkg)/py3\.11/windows(-crypto)?\.lock)$
+ pass_filenames: false
+ additional_dependencies: ["pip<26.0"]
+ args:
+ - requirements/static/ci/lint.txt
+ - --python-platform=windows
+ - --python-version=3.11
+ - --constraint
+ - requirements/constraints.txt
+ - --no-emit-index-url
+ - --unsafe-package=setuptools
+ - -c=requirements/static/ci/py3.11/windows.lock
+ - -c=requirements/static/pkg/py3.11/windows.lock
+ - -o=requirements/static/ci/py3.11/windows-lint.lock
+
+ - id: pip-compile
+ alias: compile-ci-windows-lint-3.12-requirements
+ name: Windows Lint CI Py3.12 Requirements
+ files: ^requirements/(constraints\.txt|static/ci/lint\.txt|static/(ci|pkg)/py3\.12/windows(-crypto)?\.lock)$
+ pass_filenames: false
+ additional_dependencies: ["pip<26.0"]
+ args:
+ - requirements/static/ci/lint.txt
+ - --python-platform=windows
+ - --python-version=3.12
+ - --constraint
+ - requirements/constraints.txt
+ - --no-emit-index-url
+ - --unsafe-package=setuptools
+ - -c=requirements/static/ci/py3.12/windows.lock
+ - -c=requirements/static/pkg/py3.12/windows.lock
+ - -o=requirements/static/ci/py3.12/windows-lint.lock
+
+ - id: pip-compile
+ alias: compile-ci-windows-lint-3.14-requirements
+ name: Windows Lint CI Py3.14 Requirements
+ files: ^requirements/(constraints\.txt|static/ci/lint\.txt|static/(ci|pkg)/py3\.14/windows(-crypto)?\.lock)$
+ pass_filenames: false
+ additional_dependencies: ["pip<26.0"]
+ args:
+ - requirements/static/ci/lint.txt
+ - --python-platform=windows
+ - --python-version=3.14
+ - --constraint
+ - requirements/constraints.txt
+ - --no-emit-index-url
+ - --unsafe-package=setuptools
+ - -c=requirements/static/ci/py3.14/windows.lock
+ - -c=requirements/static/pkg/py3.14/windows.lock
+ - -o=requirements/static/ci/py3.14/windows-lint.lock
+
+ - id: pip-compile
+ alias: compile-ci-windows-lint-3.13-requirements
+ name: Windows Lint CI Py3.13 Requirements
+ files: ^requirements/(constraints\.txt|static/ci/lint\.txt|static/(ci|pkg)/py3\.13/windows(-crypto)?\.lock)$
+ pass_filenames: false
+ additional_dependencies: ["pip<26.0"]
+ args:
+ - requirements/static/ci/lint.txt
+ - --python-platform=windows
+ - --python-version=3.13
+ - --constraint
+ - requirements/constraints.txt
+ - --no-emit-index-url
+ - --unsafe-package=setuptools
+ - -c=requirements/static/ci/py3.13/windows.lock
+ - -c=requirements/static/pkg/py3.13/windows.lock
+ - -o=requirements/static/ci/py3.13/windows-lint.lock
+
+ # <---- Windows Lint CI Requirements --------------------------------------------------------------------------
# ----- Changelog ------------------------------------------------------------------------------------------------->
- id: pip-compile
@@ -2266,6 +2604,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
@@ -2273,6 +2616,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
@@ -2322,10 +2667,7 @@ repos:
types: [python]
exclude: >
(?x)^(
- salt/ext/.*
- )$
- exclude: >
- (?x)^(
+ salt/ext/.*|
tests/pytests/unit/utils/test_versions.py|
tests/pytests/functional/transport/tcp/test_pub_server.py
)$
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 05abec308fe5..77490ab36ba0 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,8 +110,10 @@ 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)
+## 3006.27 (2026-07-01)
### Changed
@@ -124,6 +127,7 @@ Versions are `MAJOR.PATCH`.
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)
+- 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
@@ -200,6 +204,58 @@ Versions are `MAJOR.PATCH`.
- 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)
+- 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
@@ -1317,6 +1373,8 @@ Versions are `MAJOR.PATCH`.
- 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)
## 3007.14 (2026-04-29)
+- 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)
@@ -1641,185 +1699,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/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/agents/reports/silent_drop_audit_3008_to_master.md b/agents/reports/silent_drop_audit_3008_to_master.md
new file mode 100644
index 000000000000..f76cd26741aa
--- /dev/null
+++ b/agents/reports/silent_drop_audit_3008_to_master.md
@@ -0,0 +1,209 @@
+# Silent-drop audit: 3008.x -> master merge
+
+Audit scope: files under `salt/`, `tests/`, `changelog/`, `requirements/`,
+`pkg/`, `tools/`, `doc/` that differ between `origin/3008.x` and
+`origin/master`, excluding the 13 UU-conflicted files already flagged for
+manual resolution. The scan asks: is the working-tree blob byte-identical to
+`origin/master:` while `origin/3008.x` has commits touching this file
+that master lacks? If so, the merge picked master's content and *may* have
+dropped 3008.x work.
+
+Scan produced 47 candidates (41 `EXISTS_MATCH_DEST`, 6 `MISSING_SRC_ONLY`).
+Each was manually classified by pulling a distinctive signature (function
+name, comment, docstring, or issue number) from the 3008.x-only fix commits
+and grepping for that signature in the current tree.
+
+## Summary
+- Total candidate files: 47
+- REAL DROP (needs restoration): **0**
+- DELIBERATE-DEST (kept master's, defensible): 41
+- BOTH-FIXED-INDEPENDENTLY: 6 (counted under DELIBERATE-DEST above where
+ the master version subsumes / equals the 3008.x fix — noted per file
+ below)
+
+**No silent SOURCE-content drops were found.** Every candidate is either a
+deliberate master-side enhancement/refactor/removal, or the 3008.x fix
+landed on master under a different commit hash and is present verbatim in
+the tree.
+
+## REAL DROPS (action required)
+
+None.
+
+## DELIBERATE-DEST breakdown
+
+### Cluster 1: master deleted files, 3008.x kept them (6 files)
+These files exist only on 3008.x; master intentionally removed them.
+Confirmed by explicit deletion commits with rationale on master.
+
+| File | Deletion commit | Reason |
+|---|---|---|
+| `salt/utils/kickstart.py` | `899e2a4d37f` "Remove orphaned genesis utility modules" | genesis moved to community extension |
+| `salt/utils/preseed.py` | `899e2a4d37f` | same |
+| `salt/utils/yast.py` | `899e2a4d37f` | same |
+| `tests/unit/utils/test_kickstart.py` | `899e2a4d37f` | tests for removed module |
+| `salt/utils/namecheap.py` | `0a2fe0f32ac` "Remove orphaned salt.utils.namecheap module" | namecheap moved to `saltext.namecheap` |
+| `tests/unit/modules/test_network.py` | `a06a8a57a9e` "Migrate legacy unit/modules/test_network.py into the pytest suite" | tests migrated to pytest twin under `tests/pytests/unit/modules/test_network.py` |
+
+### Cluster 2: master is a strict superset of 3008.x (adds new content on top)
+Master's version contains the 3008.x fix PLUS additional master-side work.
+
+- `salt/modules/disk.py` — master added `format_(discard=True)` param and
+ changed `_parse_numbers` SI-suffix notation from `10E3` to `1e3`. 3008.x's
+ `disk.tune` invalid-kwargs fix (`2ea5e1b744c`) is present in the merged
+ tree (`SaltInvocationError`, `invalid_kwargs`).
+- `tests/pytests/unit/modules/test_disk.py` — master added
+ `test_format__nodiscard_ext`, `test_format__nodiscard_xfs`,
+ `test_parse_numbers_issue_65490` (paired with the disk.py changes above).
+- `salt/modules/pw_user.py` — master added `def primary_group(name)` at
+ line 509 (versionadded 3009.0). All 3008.x FreeBSD fixes present.
+- `salt/modules/network.py` — the 3008.x fixes (`fqdns` ThreadPool leak,
+ hostname quoting, IPv6 `sanitize_host`, `ip_networks6` docs, `ping -W`)
+ are all present. Master merged them via different commit hashes but
+ content is identical.
+- `salt/utils/network.py` — `sanitize_host` IPv6 fix (`a26ad836a0b`, refs
+ #68995) present in current tree at lines 71-88.
+- `salt/utils/user.py` — three `nicholasmhughes` fixes (`34bf45ee279`,
+ `28be150d1a4`, `eaa004dd642`) all present:
+ `user_group_list_local` / `user_group_list_remote` split, removal of
+ `HAS_PYSSS`, `_getgrall()` helper.
+- `salt/utils/parsers.py` — three recent fixes (`b1346d4fa4a`
+ OptsDict, `3e51839232d` `--priv`, `e93ec8fa42e` None-value merge)
+ present. `b403da18bf9`'s "hasattr(self, 'config') / self.config.update"
+ branch was intentionally replaced on master with the simpler `self.config
+ = self.setup_config()` — see next commit `e93ec8fa42e` on 3008.x which
+ reverses `b403da18bf9` back to this form. Net semantics match master.
+ Recent big features (`--start-event`, `--disable-keepalive`,
+ `-r/--resources`) all present.
+- `salt/client/__init__.py` — verified: `cmd_subset` failed-minion fix,
+ LazyLoader teardown, `subset = kwargs.pop("sub", subset)`, Salt Resources
+ imports (`salt.utils.metrics`, `salt.utils.resources`, `def
+ _resource_ids_from_minion_grains_cache`), single-JID batch fix,
+ `publish_timeout`, `async def run_job_async`, `if isinstance(payload,
+ str)` prep-jid fix — all present.
+- `salt/output/highstate.py` — `_compress_ids`, `state_compress_ids`,
+ `state_output_pct`, terse formatter (`Started: {6[start_time]!s}`) all
+ present.
+- `salt/states/test.py` — requisites/aggregate fix
+ (`__low__["__reqs__"].get("watch", [])`) and `OS not supported!`
+ fail-with-changes docs present.
+- `salt/utils/job.py` — `_store_job`, `_store_minions`,
+ `MasterMinion(opts, states=False, rend=False)` teardown, "Load does not
+ contain 'jid'" KeyError guard all present. Master's diff vs 3008.x is a
+ single-line `import salt.utils.versions` removal (paired with removal of
+ the `warn_until(3008, ...)` deprecated-API code — deliberate 3008.0-cycle
+ cleanup).
+- `salt/netapi/rest_cherrypy/__init__.py` — master added
+ `ssl_ca_certs` / `ssl_cert_reqs` client-cert validation on top of the
+ existing intermediate-cert support (`ssl_chain`).
+- `tests/pytests/unit/output/test_highstate.py` — master added 269 lines
+ of diff-colorization tests (`_GREEN`, `_LIGHT_RED`,
+ `test_diff_in_full_color_output`, ...). All 3008.x compress_ids /
+ state_output_pct tests present.
+- `tests/pytests/unit/utils/test_network.py` — master added
+ `test_cidr_to_ipv4_netmask_is_registered_jinja_filter`, `test_ip_to_int`,
+ `test_int_to_ipv4`, `test_int_to_ipv6`, `test_nth_host`. All 3008.x
+ sanitize_host IPv6 tests present (`test_sanitize_host_ipv6*`).
+- `tests/pytests/unit/modules/test_network.py` — master added
+ `test_arp_linux_falls_back_to_ip_neigh` and stricter assertions
+ (`assert result is True` vs the weaker `assert result`).
+- `tests/pytests/unit/modules/test_pw_user.py` — master added
+ `test_primary_group`, `test_primary_group_nonexistent`.
+- `tests/pytests/unit/utils/test_user.py` — master added
+ `test_get_group_name`, `test_get_group_name_unknown_gid`.
+- `tests/pytests/functional/modules/state/requisites/test_watch.py` —
+ master added `test_watch_skips_mod_watch_when_normal_run_has_changes`
+ and `test_watch_fires_when_force_mod_watch_is_set`.
+- `tests/pytests/unit/netapi/cherrypy/test_events.py` — the eauth-token
+ query-string rejection tests (`test_events_get_rejects_token_in_query_string`,
+ `test_events_get_accepts_token_in_x_auth_token_header`) are present
+ because the same security fix (`9f9052b5231`, refs #69071) is in the
+ merged tree alongside master's additions. The changelog entry
+ `changelog/69071.fixed.md` was rolled into `CHANGELOG.md` line 1637 by
+ the v3006.26 release process; verified.
+- `tests/integration/files/conf/master` — master added
+ `master_stats: true` on line 114.
+- `tests/packdump.py` — master added type hints (`def dump(path: str) ->
+ None`) and `Usage:` error message.
+- `tests/filename_map.yml` — master added one entry:
+ `unit.states.test_postgres_default_privileges`.
+- `pkg/macos/build_python.sh` — master replaced `deactivate` with direct
+ `unset VIRTUAL_ENV / _OLD_VIRTUAL_PATH / _OLD_VIRTUAL_PYTHONHOME`; both
+ branches contain the "Build python 3.10.9" (`4f6caca155e`) fix
+ (`3.10.9` / `3.11.2` versions, `python -c "import sys;
+ print(sys.executable)"` SYS_PY_BIN discovery).
+- `pkg/windows/build_python.ps1` — same pattern as macOS: master
+ replaced `. deactivate` with direct `Remove-Item env:` cleanup; 3008.x
+ MSI-display fix present.
+- `requirements/static/ci/cloud.txt` — master pinned
+ `apache-libcloud>=3.8.0` unconditionally; 3008.x had the split
+ `<3.9.1 for python<3.10` conditional. Master's choice tracks the
+ python-3.10-only requirement floor.
+- `requirements/static/ci/py3.14/changelog.lock` — master has
+ `packaging==26.2` (newer) vs 3008.x's `24.0`.
+- `requirements/zeromq.txt` — master added `-r base.txt` / `-r
+ crypto.txt` includes (structural change, not a dropped requirement).
+- `tools/container.py` — master version has
+ `RAISE_DEPRECATIONS_RUNTIME_ERRORS: "0"`; 3008.x flipped it to `"1"`
+ via `ce3b55c154d`. Note: this is a per-run env override for the local
+ dev container tool. The CI-level enforcement in
+ `.github/workflows/templates/layout.yml.jinja` is `"1"` on both
+ branches, so CI still fails on deprecation warnings. See "Soft
+ observations" below.
+- `tools/precommit/docstrings.py` — master removed the entry for
+ `salt/utils/namecheap.py` (consistent with removing the module itself
+ in `0a2fe0f32ac`).
+- Doc files (12): `doc/ref/cache/all/index.rst`,
+ `doc/ref/cli/_includes/output-options.rst`,
+ `doc/ref/clouds/all/salt.cloud.clouds.saltify.rst`,
+ `doc/ref/states/all/index.rst`, `doc/topics/jinja/index.rst`,
+ `doc/topics/releases/2017.7.8.rst`, `doc/topics/releases/2018.3.3.rst`,
+ `doc/topics/releases/2019.2.1.rst`, `doc/topics/releases/3006.10.md`,
+ `doc/topics/releases/index.rst`, `doc/topics/ssh/index.rst`,
+ `doc/topics/transports/ssl.rst`. Every one is a master-side doc
+ enhancement (new toctree entries, new jinja filters `ip_to_int` /
+ `int_to_ipv4` / `int_to_ipv6`, expanded Saltfile explanation, richer
+ `state_output` docs, minor typo fixes). 3008.x's contribution to
+ `3006.10.md` is a `.in` -> `.txt` typo correction that was landed on
+ master under a different form (`base.in` vs `base.txt` — both
+ variants coexist harmlessly in different release notes).
+
+## Cluster grouping
+
+No clusters of related silent drops (the x509_v2-style disaster from the
+prior 3007.x -> 3008.x merge does not repeat here).
+
+The only *semantic* difference worth calling attention to that is NOT a
+deliberate master feature is:
+
+**Soft observation — not a REAL DROP but worth a maintainer eye:**
+- `tools/container.py:65` — `RAISE_DEPRECATIONS_RUNTIME_ERRORS: "0"` on
+ master vs `"1"` on 3008.x. This affects only the local dev container
+ workflow (`tools container create`), not CI. `ce3b55c154d`'s
+ companion changes to `.github/workflows/templates/layout.yml.jinja`
+ (setting it to `"1"`) *are* present in the merged tree, so CI
+ enforcement is intact. If the intent of `ce3b55c154d` was also to
+ make local `tools container create` mirror CI behaviour, this specific
+ line could be lifted from 3008.x post-merge, but it does not block or
+ weaken any user-facing salt code path.
+
+## Not scanned
+
+- The 13 UU-conflict files — deliberately excluded per the audit rules
+ since they are pending manual resolution:
+ `.github/workflows/ci.yml`, `.github/workflows/dependabot-sync.yml`,
+ `.github/workflows/nightly-stress-test.yml`,
+ `.github/workflows/nightly.yml`, `.github/workflows/scheduled.yml`,
+ `.github/workflows/staging.yml`,
+ `.github/workflows/templates/layout.yml.jinja`,
+ `.pre-commit-config.yaml`, `pkg/macos/install_salt.sh`,
+ `requirements/base.txt`,
+ `tests/pytests/pkg/integration/test_version.py`,
+ `tests/pytests/unit/cli/test_batch.py`,
+ `tests/pytests/unit/grains/test_core.py`.
+- Files outside `salt/`, `tests/`, `changelog/`, `requirements/`, `pkg/`,
+ `tools/`, `doc/` (scope defined by the audit prompt).
+- Files that satisfy `ours == src` (the merged content matches 3008.x —
+ no drop possible).
+- Files where either `ours == dst == src` (no drift), or where the file
+ does not exist on either branch.
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/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/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/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/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/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/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/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/changelog/43364.fixed.md b/changelog/43364.fixed.md
new file mode 100644
index 000000000000..20923cf219ca
--- /dev/null
+++ b/changelog/43364.fixed.md
@@ -0,0 +1 @@
+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/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/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/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/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/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/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/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/changelog/50903.fixed.md b/changelog/50903.fixed.md
new file mode 100644
index 000000000000..4a51629648e8
--- /dev/null
+++ b/changelog/50903.fixed.md
@@ -0,0 +1 @@
+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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/changelog/61321.fixed.md b/changelog/61321.fixed.md
new file mode 100644
index 000000000000..6fa2d9e89d90
--- /dev/null
+++ b/changelog/61321.fixed.md
@@ -0,0 +1 @@
+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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/changelog/66607.fixed.md b/changelog/66607.fixed.md
new file mode 100644
index 000000000000..a8b3f43d2246
--- /dev/null
+++ b/changelog/66607.fixed.md
@@ -0,0 +1 @@
+Serialize ``set_umask``/``get_umask`` with a lock. The umask is process-global, so concurrent calls from different threads could restore a stale value and leave the process umask permanently changed — salt-api under rest_cherrypy would get stuck at ``0o277`` and return 500 for every ``client=ssh`` request until restarted.
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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.
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/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/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/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/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/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/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.
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/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/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/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/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/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/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.
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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.
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/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/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.
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/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.
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/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/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/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/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/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/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/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/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/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.
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.
diff --git a/changelog/69877.fixed.md b/changelog/69877.fixed.md
new file mode 100644
index 000000000000..cfd3630f14a1
--- /dev/null
+++ b/changelog/69877.fixed.md
@@ -0,0 +1 @@
+Fix master-cluster peer traffic honoring ``cluster_pool_port`` instead of falling back to hardcoded ``55596``; ``cluster_port`` accepted as deprecated alias with a warning.
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/changelog/69986.added.md b/changelog/69986.added.md
new file mode 100644
index 000000000000..e2b6a7bbd363
--- /dev/null
+++ b/changelog/69986.added.md
@@ -0,0 +1 @@
+Add ``master_async_mworker`` opt-in flag (default ``False`` on 3008.x) that dispatches ``AESFuncs`` / ``ClearFuncs`` / ``AuthFuncs`` handlers asynchronously, offloads blocking work to a thread executor, and gives each MWorker its own IPC socket for fair PoolRouter dispatch. With the flag off (the LTS default) MWorker handlers and IPC routing are byte-for-byte identical to Argon v3008.2 and earlier.
diff --git a/changelog/69986.fixed.md b/changelog/69986.fixed.md
new file mode 100644
index 000000000000..a00cde2c94c6
--- /dev/null
+++ b/changelog/69986.fixed.md
@@ -0,0 +1 @@
+Fix MWorker deadlock caused by nested ``SyncWrapper`` recursion in ``tcp.PublishServer.publish``. When ``fire_event`` invoked ``publish`` inside a running io_loop, the outer ``SaltEvent.pusher`` SyncWrapper's thread spawned another SyncWrapper which deadlocked on ``threading.Thread.join()``, wedging all MWorkers. Only triggered when ``master_async_mworker`` is enabled; the deadlock cannot occur on the default sync MWorker path.
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/changelog/69988.fixed.md b/changelog/69988.fixed.md
new file mode 100644
index 000000000000..6a7ea2c401a0
--- /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 with a per-subscriber ``publish_drain_timeout`` (default 60s) enforced via ``asyncio.wait_for``. Slow subscribers are closed and removed from ``self.clients`` instead of blocking every subsequent publish.
diff --git a/changelog/69989.fixed.md b/changelog/69989.fixed.md
new file mode 100644
index 000000000000..d92c6aea7ad9
--- /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`` and the existing ``_get_key_with_evict`` memoize which cache at the private-key file layer.
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/changelog/69991.fixed.md b/changelog/69991.fixed.md
new file mode 100644
index 000000000000..fd1a3b32cf7d
--- /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__`` that emits ``ResourceWarning`` for wrappers GC'd without an explicit ``close()`` (mirrors ``SaltEvent.__del__`` at ``salt/utils/event.py``) to surface future missed-close bugs rather than silently leaking event loops and their held resources.
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/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/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/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/changelog/70051.fixed.md b/changelog/70051.fixed.md
new file mode 100644
index 000000000000..c31cb59f4156
--- /dev/null
+++ b/changelog/70051.fixed.md
@@ -0,0 +1,5 @@
+Fix minion graceful-stop path: signal in-flight job children in
+``Minion.subprocess_list`` (they were missed by ``kill_children``), run
+registered finalize callbacks so ``/proc/`` is removed
+even when ``SignalHandlingProcess._handle_signals`` fires ``os._exit``,
+and emit ``sd_notify(STOPPING=1)`` on entry to ``stop_async``.
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/changelog/70063.changed.md b/changelog/70063.changed.md
new file mode 100644
index 000000000000..abdf744ec53c
--- /dev/null
+++ b/changelog/70063.changed.md
@@ -0,0 +1 @@
+Upgrade the bundled onedir Python from 3.10.20 to 3.11.15 on the 3007.x branch. Python 3.10 reaches end of security support in October 2026, while Salt 3007.x must ship security fixes past that date. Users upgrading from a previous 3007.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/70063.fixed.md b/changelog/70063.fixed.md
new file mode 100644
index 000000000000..4fd5cf079804
--- /dev/null
+++ b/changelog/70063.fixed.md
@@ -0,0 +1,3 @@
+Fixed a race between ``RequestClient.close()`` and its ``_send_recv`` coroutine in the ZeroMQ transport: closing the socket and terminating the context while ``_send_recv`` was still mid ``poll()``/``recv()`` on it aborted the process inside libzmq on Windows (``zmq.cpp errno_assert``, ``EINVAL``/``EAGAIN``), breaking every Windows integration test and packaged install/upgrade test that spawns a ``salt-call``/``salt`` CLI. ``close()`` now signals ``_send_recv`` with a shutdown sentinel and, when called from a different thread than the one running the transport's event loop, waits for it to actually exit before tearing down the socket and context -- the same graceful-drain pattern already used to fix the related file-descriptor leak in ``RequestClient`` (#69991). Also normalizes the event loop passed to ``zmq.asyncio`` when spawning ``_send_recv``'s task, since handing it a ``tornado.ioloop.IOLoop`` wrapper instead of the underlying ``asyncio`` loop is a documented cause of the same Windows libzmq abort.
+
+Scoped the ``pyzmq<26`` cap in ``requirements/zeromq.txt`` (added to work around a pyzmq 27.x memory leak on Arm64 CI runners) to non-Windows platforms. That cap left Windows on pyzmq 25.1.2, whose bundled Windows libzmq 4.3.4 build independently aborts inside libzmq on ordinary ``RequestClient`` send/recv -- the same symptom above, but not something the transport-level fix alone can resolve since it's a bug in that specific wheel. Windows now resolves to pyzmq>=27.1.0 (currently 27.2.0), which does not exhibit either the Arm64 leak (Arm64 CI runners are Linux/macOS, not Windows) or the abort.
diff --git a/changelog/70090.fixed.md b/changelog/70090.fixed.md
new file mode 100644
index 000000000000..d9618d51d2ff
--- /dev/null
+++ b/changelog/70090.fixed.md
@@ -0,0 +1 @@
+Fix `SaltClientError: Invalid master key` on minions connecting through a load-balancer (HAProxy, F5, etc.) to a master cluster running with `cluster_isolated_filesystem: True`. The cluster join-reply handler now refreshes the master-keys cache (used by both `localfs_key` and `mmap_key` drivers) and reloads the in-memory `cluster_key` after installing the wire-delivered `cluster.pem` / `cluster.pub`, so every peer serves the same cluster public key to minions instead of the local pre-join placeholder that `_setup_keys` had generated during startup.
diff --git a/changelog/70097.fixed.md b/changelog/70097.fixed.md
new file mode 100644
index 000000000000..bdb6458d6ca3
--- /dev/null
+++ b/changelog/70097.fixed.md
@@ -0,0 +1 @@
+Fix silent broadcast abort in ``salt.transport.tcp.PubServer.publish_payload``. Only ``StreamClosedError`` was previously caught in the fan-out loop; a synchronous ``tornado.iostream.StreamBufferFullError`` from one subscriber (raised when the per-stream write buffer cap set by ``ipc_write_buffer`` is exceeded) propagated out of the loop and every subscriber after the offender silently missed that payload. The buffer-full case is now handled identically to a closed stream: the offending peer is discarded and the broadcast continues to the rest.
diff --git a/changelog/70098.fixed.md b/changelog/70098.fixed.md
new file mode 100644
index 000000000000..163f73e0c666
--- /dev/null
+++ b/changelog/70098.fixed.md
@@ -0,0 +1 @@
+Extend the ``ipc_write_buffer`` outbound write-buffer cap in ``salt.transport.tcp`` to every client-side ``IOStream`` where writes accumulate: ``_TCPPubServerPublisher`` (MWorker ``fire_event`` -> ``EventPublisher`` pull), ``MessageClient`` (minion return / master req), ``PublishClient`` (SUB channel), and ``RequestClient``. Previously only server-accepted streams (``PubServer.handle_stream`` and ``SaltMessageServer``) honored the opt; the client-side streams defaulted to ``max_write_buffer_size = None`` (unbounded), so a wedged consumer let the sender's tornado write buffer grow without limit and drove RSS climb until the process was OOM-killed. Opt-in, unset preserves prior behavior.
diff --git a/changelog/70099.added.md b/changelog/70099.added.md
new file mode 100644
index 000000000000..fc07f508f5eb
--- /dev/null
+++ b/changelog/70099.added.md
@@ -0,0 +1 @@
+Added a required `branch` input to `3006.x`'s `nightly-stress-test.yml` workflow, along with `enable_metrics` and `worker_threads` inputs that let a run toggle OpenTelemetry metrics and override the salt-master worker pool size before the stress test starts. Lets this workflow be dispatched against any branch, not just `3006.x`.
diff --git a/changelog/70100.fixed.md b/changelog/70100.fixed.md
new file mode 100644
index 000000000000..0d9a94a7b7a6
--- /dev/null
+++ b/changelog/70100.fixed.md
@@ -0,0 +1,3 @@
+Route unclosed-resource ``ResourceWarning`` finalizers through Salt's logger in addition to Python's ``warnings`` module. Python filters ``ResourceWarning`` by default, so a bare ``warnings.warn(..., ResourceWarning)`` from a ``__del__`` finalizer is silently dropped in production and callers that missed a ``close()`` / ``destroy()`` / context-manager contract never see the migration signal. Adds ``salt.utils.resource_warnings.warn_until_close`` which emits both the ``ResourceWarning`` *and* a WARNING-level log record, and wires it into the 8 finalizers in ``salt/utils/event.py``, ``salt/utils/asynchronous.py``, ``salt/transport/tcp.py``, and ``salt/transport/ws.py``. Fixes a real production incident where SSEAPE's fire-and-forget ``get_master_event(...).fire_event(...)`` pattern leaked one unix socket per event once ``__del__``-based cleanup was removed in commit ``0c3f53d9172``; the intended ``ResourceWarning`` never surfaced because the operator's logs run at WARNING or higher and Python's default filter dropped it. Also fixes four ``warnings.warn`` sites in ``tcp.py`` / ``ws.py`` where a missing ``f`` prefix rendered ``{self!r}`` as a literal instead of interpolating.
+
+On this LTS branch the GC-time ``destroy()`` fallback that commit ``0c3f53d9172`` had removed from ``salt.minion.MasterMinion``, ``salt.runner.RunnerClient``, ``salt.wheel.WheelClient`` and ``salt.utils.event.SaltEvent`` is restored inside ``__del__`` alongside the new loud warning, so callers that historically relied on GC-time cleanup do not silently leak sockets while migrating to explicit ``destroy()`` / context-manager use. A companion change on ``master`` (Potassium) drops the fallback and requires callers to be explicit; the WARNING-level log record here is the migration signal for that upcoming change.
diff --git a/changelog/70106.added.md b/changelog/70106.added.md
new file mode 100644
index 000000000000..b54340cc9e59
--- /dev/null
+++ b/changelog/70106.added.md
@@ -0,0 +1 @@
+Added debug-level logging to the ``roots`` fileserver backend and the generic fileserver dispatcher. The dispatcher now logs which backend is attempted for each ``find_file()`` call, and ``roots.find_file()`` now logs whether it located or failed to locate the requested path. This mirrors the ``sseapi`` (SaltStack Enterprise) backend's existing tracing, making the full ``fileserver_backend`` fallthrough sequence visible in the master log regardless of which backend ultimately serves a request.
diff --git a/changelog/70109.fixed.md b/changelog/70109.fixed.md
new file mode 100644
index 000000000000..502055454918
--- /dev/null
+++ b/changelog/70109.fixed.md
@@ -0,0 +1,3 @@
+Updated the pip shipped in Salt's packaged onedir builds from 26.1.2 to 26.2. This fixes CVE-2026-44432 (urllib3 decompression-bomb bypass), since pip 26.2 vendors a fixed urllib3 2.7.0.
+
+pip 26.2 also completed its deprecation of applying `PIP_CONSTRAINT` to PEP 517 build environments, so ``tools/pkg/build.py`` now also sets `PIP_BUILD_CONSTRAINT` wherever it sets `PIP_CONSTRAINT`, keeping build-time dependencies (e.g. the `Cython<3.3` pin needed for pyzmq) constrained during onedir/package builds.
diff --git a/changelog/70111.fixed.md b/changelog/70111.fixed.md
new file mode 100644
index 000000000000..b1ef223cc711
--- /dev/null
+++ b/changelog/70111.fixed.md
@@ -0,0 +1 @@
+Fixed inconsistent process title for the master's ``FileserverUpdate`` process. It was previously registered as ``FileServerUpdate`` (capital S) on the initial fork and as ``FileserverUpdate`` (lowercase s) after a respawn, breaking log and process-title correlation.
diff --git a/changelog/70118.fixed.md b/changelog/70118.fixed.md
new file mode 100644
index 000000000000..3ae089bf834b
--- /dev/null
+++ b/changelog/70118.fixed.md
@@ -0,0 +1 @@
+Pin Cython<3.3 for pyzmq source builds broken by Cython 3.3.0.
diff --git a/changelog/70121.fixed.md b/changelog/70121.fixed.md
new file mode 100644
index 000000000000..3ae089bf834b
--- /dev/null
+++ b/changelog/70121.fixed.md
@@ -0,0 +1 @@
+Pin Cython<3.3 for pyzmq source builds broken by Cython 3.3.0.
diff --git a/changelog/70123.fixed.md b/changelog/70123.fixed.md
new file mode 100644
index 000000000000..f86ecf5bc37e
--- /dev/null
+++ b/changelog/70123.fixed.md
@@ -0,0 +1 @@
+Fix `TypeError: default_int_handler expected 2 arguments, got 1` in `salt.utils.process.ProcessManager._handle_signals` when SIGTERM is delivered to a forked child that inherited the handler. `MasterPubServerChannel._publish_daemon` and any other subprocess using this handler now shut down cleanly instead of crashing with an unhandled exception.
diff --git a/changelog/70124.fixed.md b/changelog/70124.fixed.md
new file mode 100644
index 000000000000..8af6bc4c68c6
--- /dev/null
+++ b/changelog/70124.fixed.md
@@ -0,0 +1 @@
+Preserve `EventPublisher` process title across `MasterPubServerChannel._publish_daemon` respawns so operator monitoring keyed on the process title continues to work after ProcessManager restarts the daemon.
diff --git a/changelog/70126.fixed.md b/changelog/70126.fixed.md
new file mode 100644
index 000000000000..8af6bc4c68c6
--- /dev/null
+++ b/changelog/70126.fixed.md
@@ -0,0 +1 @@
+Preserve `EventPublisher` process title across `MasterPubServerChannel._publish_daemon` respawns so operator monitoring keyed on the process title continues to work after ProcessManager restarts the daemon.
diff --git a/changelog/70129.added.md b/changelog/70129.added.md
new file mode 100644
index 000000000000..8ea9379a269d
--- /dev/null
+++ b/changelog/70129.added.md
@@ -0,0 +1 @@
+Add ``master_mworker_max_inflight`` option to bound the number of concurrent request handlers per MWorker process when ``master_async_mworker`` is enabled. Default ``0`` preserves the existing unlimited behavior; a positive value caps each MWorker with its own ``asyncio.BoundedSemaphore`` so the effective total across the worker pool is ``master_mworker_max_inflight * worker_threads``. Requests block on the semaphore rather than erroring, so backpressure propagates naturally through the TCP task queue / ZMQ HWM.
diff --git a/changelog/70130.changed.md b/changelog/70130.changed.md
new file mode 100644
index 000000000000..8ce6d0d0233e
--- /dev/null
+++ b/changelog/70130.changed.md
@@ -0,0 +1,7 @@
+Bump cryptography (>=48.0.1 on py>=3.10), pyopenssl (drop <26.2.0 cap;
+salt.modules.tls now refuses to load on pyOpenSSL 26+ where its legacy
+X509Extension / X509Req / PKCS12 / CRL / load_crl APIs were removed --
+use salt.modules.x509 instead), msgpack (>=1.2.0), requests (>=2.34.2),
+and setuptools (>=82.0.1) to current LTS floors on the salt-onedir
+Python stack. Python 3.9 pins retained (cryptography 48+ drops
+3.9.0/3.9.1; msgpack 1.2.1 drops 3.9).
diff --git a/changelog/70133.fixed.md b/changelog/70133.fixed.md
new file mode 100644
index 000000000000..5880bc105ff5
--- /dev/null
+++ b/changelog/70133.fixed.md
@@ -0,0 +1,3 @@
+* Relenv 0.22.23
+ - Fix Verify Builds on Python 3.14 (cffi 2.0.0 for 3.14, swig PyPI shim collision) - #316
+ - Various native-build platform hardening across releases 0.22.19 - 0.22.23
diff --git a/changelog/70136.fixed.md b/changelog/70136.fixed.md
new file mode 100644
index 000000000000..03937d8fbcb3
--- /dev/null
+++ b/changelog/70136.fixed.md
@@ -0,0 +1 @@
+Fix the ``Combine Code Coverage`` job on 3007.x by fetching the Codecov uploader signing key from ``https://uploader.codecov.io/verification.gpg`` (the ``keybase.io/codecovsecurity`` URL returns HTTP 404 and gpg exits non-zero under ``bash -e``).
diff --git a/changelog/70142.fixed.md b/changelog/70142.fixed.md
new file mode 100644
index 000000000000..2ca1e9d20421
--- /dev/null
+++ b/changelog/70142.fixed.md
@@ -0,0 +1,3 @@
+* Relenv 0.22.25
+ - Fix 2^n slowdown in wrap_sysconfig by making it idempotent (fixes Salt highstate hangs on long-lived Python 3.13+ onedir minions) - #321 / #325
+ - Update openssl to 3.5.8 (0.22.24)
diff --git a/changelog/70147.fixed.md b/changelog/70147.fixed.md
new file mode 100644
index 000000000000..20ae4df2703b
--- /dev/null
+++ b/changelog/70147.fixed.md
@@ -0,0 +1 @@
+Cap in-flight drain tasks per subscriber in ``salt.transport.tcp.PubServer.publish_payload`` by serializing each subscriber's writes through a dedicated writer coroutine reading from a bounded ``asyncio.Queue`` (default 500, configurable via the new ``pub_server_write_queue_size`` master opt). Under a bursty producer the previous fire-and-forget path allocated one ``asyncio.Task`` per (subscriber × event) with no cap -- a 100k-event burst against 8 subscribers drove RSS to 2.9 GB and starved the io_loop. ``_discard_slow_client`` now also cancels the writer task so the captured payload bytes are released immediately rather than pinned for up to ``publish_drain_timeout`` seconds.
diff --git a/changelog/98852.added.md b/changelog/98852.added.md
new file mode 100644
index 000000000000..4381bd453c03
--- /dev/null
+++ b/changelog/98852.added.md
@@ -0,0 +1 @@
+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/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/cicd/shared-gh-workflows-context.yml b/cicd/shared-gh-workflows-context.yml
index 4eb2723abf25..77cff8ec403e 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"
-relenv_version: "0.22.14"
+python_version: "3.14.7"
+relenv_version: "0.22.25"
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/cicd/windows-ssl-104135-patch.py b/cicd/windows-ssl-104135-patch.py
index 77fa22439dd1..82d8052e56fa 100644
--- a/cicd/windows-ssl-104135-patch.py
+++ b/cicd/windows-ssl-104135-patch.py
@@ -10,7 +10,7 @@
The build-deps-ci Windows job extracts the salt onedir, then runs
``nox --install-only -e ci-test-onedir`` and ``nox -e pre-archive-cleanup``.
-Both sessions target the onedir's relenv-bundled Python (3.10.20 on 3006.x)
+Both sessions target the onedir's relenv-bundled Python (3.10.21 on 3006.x)
and create virtualenvs from it. The ``ci-test-onedir`` session uses
``--system-site-packages``; ``pre-archive-cleanup`` does not. Either way,
both venvs share the onedir's ``Lib/ssl.py`` because virtualenv leaves the
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 37bff177fa7a..c944f58dcb5d 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -183,6 +183,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/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/doc/ref/configuration/master.rst b/doc/ref/configuration/master.rst
index b1d4844c6db8..4ad521d9d4ab 100644
--- a/doc/ref/configuration/master.rst
+++ b/doc/ref/configuration/master.rst
@@ -260,7 +260,7 @@ changed to the filesystem location shared between peers in the cluster.
cluster_pki_dir: /my/gluster/share/pki
-.. conf_master:: cluster_port
+.. conf_master:: cluster_pool_port
``cluster_pool_port``
---------------------
@@ -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
@@ -3081,6 +2603,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
@@ -3731,12 +3261,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
@@ -3771,43 +3298,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``
@@ -5120,13 +4610,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
@@ -5279,40 +4766,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``
@@ -5672,6 +5125,33 @@ 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``
+
+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
+
+ pillar_mask_output: True
+
.. conf_master:: pillar_includes_override_sls
``pillar_includes_override_sls``
@@ -6533,13 +6013,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
@@ -6715,40 +6192,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 74da50a0c5d4..b925c042741c 100644
--- a/doc/ref/configuration/minion.rst
+++ b/doc/ref/configuration/minion.rst
@@ -3919,6 +3919,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/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/ref/modules/all/index.rst b/doc/ref/modules/all/index.rst
index cd4546d17bfb..1accdecd80a6 100644
--- a/doc/ref/modules/all/index.rst
+++ b/doc/ref/modules/all/index.rst
@@ -145,10 +145,12 @@ execution modules
napalm_users
napalm_yang_mod
netaddress
+ netplan_ip
network
nfs3
nftables
nixpkg
+ nm_ip
npm
nxos
nxos_api
@@ -173,6 +175,7 @@ execution modules
pw_group
pw_user
pyenv
+ python
quota
rabbitmq
rbac_solaris
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/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/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/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:
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
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/doc/ref/states/highstate.rst b/doc/ref/states/highstate.rst
index f5326df63422..030e4e805aa6 100644
--- a/doc/ref/states/highstate.rst
+++ b/doc/ref/states/highstate.rst
@@ -335,6 +335,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
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.
diff --git a/doc/ref/states/requisites.rst b/doc/ref/states/requisites.rst
index d21ae2a9a466..8b2829a9b555 100644
--- a/doc/ref/states/requisites.rst
+++ b/doc/ref/states/requisites.rst
@@ -810,6 +810,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/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
--------
diff --git a/doc/topics/development/conventions/style.rst b/doc/topics/development/conventions/style.rst
index eb4c624fc99f..32089eeee7f4 100644
--- a/doc/topics/development/conventions/style.rst
+++ b/doc/topics/development/conventions/style.rst
@@ -20,8 +20,9 @@ Linting
Most Salt style conventions are codified in Salt's ``.pylintrc`` file.
Salt's linting has two major dependencies: pylint_ and saltpylint_, the full lint
-requirements can be found under ``requirements/static/ci/lint.txt`` and the pinned
-requirements at ``requirements/static/ci/py3./lint.txt``, however,
+requirements can be found under ``requirements/static/ci/lint.in`` and the pinned
+requirements at ``requirements/static/ci/py3./-lint.lock``
+(one per platform: ``linux``, ``darwin``, ``freebsd``, ``windows``), however,
linting should be done using :ref:`nox `, which is how
pull requests are checked.
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__
----------
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
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
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
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/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/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 }}
diff --git a/doc/topics/slots/index.rst b/doc/topics/slots/index.rst
index 3259a489ae54..bc4c2a724857 100644
--- a/doc/topics/slots/index.rst
+++ b/doc/topics/slots/index.rst
@@ -174,3 +174,56 @@ the file `/tmp/grains.json`:
These examples showcase how to leverage Salt's flexibility to use execution
module returns as file contents or serialized data in your Salt states, allowing
for dynamic and customized configurations.
+
+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/doc/topics/tutorials/gitfs.rst b/doc/topics/tutorials/gitfs.rst
index 19894faf9f16..ecd97fbc99a8 100644
--- a/doc/topics/tutorials/gitfs.rst
+++ b/doc/topics/tutorials/gitfs.rst
@@ -39,177 +39,102 @@ and uses the first one that is available. Set
"No suitable gitfs provider module is installed"; 3008.0 masters now
fall back to ``gitcli`` (which only needs the system ``git`` binary).
+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`).
gitcli
------
@@ -1203,6 +1128,74 @@ are silently ignored by ``gitcli``:
``privkey`` is configured. Make sure the remote git endpoint is trusted
(private hosting, mTLS-fronted, etc.) before relying on it.
+.. _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/doc/topics/tutorials/standalone_minion.rst b/doc/topics/tutorials/standalone_minion.rst
index df482e351cd0..e1192d4bd38a 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``.
diff --git a/noxfile.py b/noxfile.py
index 4809ef76a64a..475a71964d8e 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -251,6 +251,22 @@ def _get_pip_requirements_file(session, crypto=None, requirements_type="ci"):
session.error(f"Could not find a linux requirements file for {pydir}")
+def _get_lint_requirements_file(session):
+ pydir = _get_pydir(session)
+ if IS_WINDOWS:
+ lint_lock = "windows-lint.lock"
+ elif IS_DARWIN:
+ lint_lock = "darwin-lint.lock"
+ elif IS_FREEBSD:
+ lint_lock = "freebsd-lint.lock"
+ else:
+ lint_lock = "linux-lint.lock"
+ _requirements_file = os.path.join("requirements", "static", "ci", pydir, lint_lock)
+ if os.path.exists(_requirements_file):
+ return _requirements_file
+ session.error(f"Could not find a lint requirements file for {pydir} ({lint_lock})")
+
+
def _upgrade_pip_setuptools_and_wheel(session, upgrade=True):
if SKIP_REQUIREMENTS_INSTALL:
session.log(
@@ -260,6 +276,11 @@ def _upgrade_pip_setuptools_and_wheel(session, upgrade=True):
env = os.environ.copy()
env["PIP_CONSTRAINT"] = str(REPO_ROOT / "requirements" / "constraints.txt")
+ # PIP_CONSTRAINT stopped affecting PEP-517 build-isolation envs in
+ # pip 26.x (deprecated in 26.1, enforced in 26.2); mirror the same
+ # constraints file via PIP_BUILD_CONSTRAINT so entries like
+ # ``Cython < 3.3`` reach pyzmq's source build.
+ env["PIP_BUILD_CONSTRAINT"] = env["PIP_CONSTRAINT"]
install_command = [
"python",
"-m",
@@ -291,6 +312,11 @@ def _install_requirements(
# Install requirements
env = os.environ.copy()
env["PIP_CONSTRAINT"] = str(REPO_ROOT / "requirements" / "constraints.txt")
+ # PIP_CONSTRAINT stopped affecting PEP-517 build-isolation envs in
+ # pip 26.x (deprecated in 26.1, enforced in 26.2); mirror the same
+ # constraints file via PIP_BUILD_CONSTRAINT so entries like
+ # ``Cython < 3.3`` reach pyzmq's source build.
+ env["PIP_BUILD_CONSTRAINT"] = env["PIP_CONSTRAINT"]
if onedir and IS_LINUX:
# bcrypt's PyPI wheels are tagged manylinux_2_28+ on the cpXY-abi3
@@ -334,6 +360,11 @@ def _install_coverage_requirement(session):
if SKIP_REQUIREMENTS_INSTALL is False:
env = os.environ.copy()
env["PIP_CONSTRAINT"] = str(REPO_ROOT / "requirements" / "constraints.txt")
+ # PIP_CONSTRAINT stopped affecting PEP-517 build-isolation envs in
+ # pip 26.x (deprecated in 26.1, enforced in 26.2); mirror the same
+ # constraints file via PIP_BUILD_CONSTRAINT so entries like
+ # ``Cython < 3.3`` reach pyzmq's source build.
+ env["PIP_BUILD_CONSTRAINT"] = env["PIP_CONSTRAINT"]
coverage_requirement = COVERAGE_REQUIREMENT
if coverage_requirement is None:
# 7.14.0 is the first version where the Python 3.14 CTracer
@@ -1584,16 +1615,12 @@ def fileno(self):
def _lint(session, rcfile, flags, paths, upgrade_setuptools_and_pip=True):
if _upgrade_pip_setuptools_and_wheel(session, upgrade=upgrade_setuptools_and_pip):
- linux_requirements_file = os.path.join(
- "requirements", "static", "ci", _get_pydir(session), "linux.lock"
- )
- lint_requirements_file = os.path.join(
- "requirements", "static", "ci", _get_pydir(session), "lint.lock"
- )
+ base_requirements_file = _get_pip_requirements_file(session)
+ lint_requirements_file = _get_lint_requirements_file(session)
install_command = [
"--progress-bar=off",
"-r",
- linux_requirements_file,
+ base_requirements_file,
"-r",
lint_requirements_file,
]
diff --git a/pkg/debian/changelog b/pkg/debian/changelog
index eaefb0c179f4..840470d480a3 100644
--- a/pkg/debian/changelog
+++ b/pkg/debian/changelog
@@ -101,1388 +101,15 @@ salt (3008.2) stable; urgency=medium
-- Salt Project Packaging Wed, 01 Jul 2026 14:44:25 +0000
-salt (3008.1) stable; urgency=medium
+salt (3008.1-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
+ * 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 (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 Project Packaging Thu, 23 Jul 2026 19:20:00 +0000
salt (3007.14) stable; urgency=medium
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/pkg/macos/install_salt.sh b/pkg/macos/install_salt.sh
index 8e0bad448c4c..ed5cbff2b527 100755
--- a/pkg/macos/install_salt.sh
+++ b/pkg/macos/install_salt.sh
@@ -126,11 +126,20 @@ fi
#-------------------------------------------------------------------------------
# Install Requirements into the Python Environment
#-------------------------------------------------------------------------------
+# relenv 0.22.25's Python 3.14 sysconfig does not set -undefined dynamic_lookup
+# in LDSHARED, which breaks source-built C extensions on macOS that reference
+# private CPython symbols (e.g. timelib -> _PyBaseObject_Type).
+export LDFLAGS="-Wl,-undefined,dynamic_lookup ${LDFLAGS:-}"
+
_msg "Installing Salt requirements"
-$PIP_BIN install -r "$REQ_FILE"
+PIP_LOG="$(mktemp)"
+$PIP_BIN install -r "$REQ_FILE" > "$PIP_LOG" 2>&1
if [ -f "$BUILD_DIR/bin/distro" ]; then
_success
+ rm -f "$PIP_LOG"
else
+ cat "$PIP_LOG"
+ rm -f "$PIP_LOG"
_failure
fi
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/pkg/rpm/salt.spec b/pkg/rpm/salt.spec
index 435eb02357cd..17c853bfc33a 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
@@ -1395,6 +1451,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/requirements/base.txt b/requirements/base.txt
index fc22f9c4b27f..500e5247faee 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -5,8 +5,12 @@ aiohttp>=3.13.5,<3.14.0; python_version < '3.10'
aiohttp>=3.14.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'
+# attrs and charset-normalizer are pulled in transitively by aiohttp/requests.
+# Explicit floors on py>=3.10 keep them at the current CVE-patched line.
+attrs>=26.1.0; python_version >= '3.10'
certifi>=2026.5.20
cffi>=2.0.0
+charset-normalizer>=3.4.7; python_version >= '3.10'
# cheroot 8.5.2 fails to build with modern setuptools due to setuptools_scm_git_archive dependency
cheroot>=11.1.2
cherrypy>=18.10.0
@@ -18,11 +22,11 @@ croniter!=0.3.22,>=6.2.2; sys_platform != 'win32'
# with --python-version=3.9 which includes those releases. Cap at the
# last 46.x release for Python 3.9 so uv pip compile can still resolve.
cryptography>=46.0.7,<48.0.0; python_version < '3.10'
-cryptography>=48.0.0; python_version >= '3.10'
+cryptography>=50.0.0; python_version >= '3.10'
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
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
@@ -46,8 +50,7 @@ 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'
+msgpack>=1.2.1 ; 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.
@@ -72,7 +75,7 @@ packaging==24.0; python_version >= '3.11' and python_version < '3.14'
packaging>=26.0,<27.0; python_version >= '3.14'
psutil<6.0.0; python_version <= '3.9'
psutil>=5.0.0; python_version >= '3.10'
-pyasn1>=0.6.3
+pyasn1>=0.6.4
pycparser>=2.23,<3.0; python_version < '3.10'
pycparser>=3.0; python_version >= '3.10'
# pymssql 2.3.12+ dropped win32 (32-bit Windows) wheels; 3008.x still
@@ -83,7 +86,7 @@ pymssql==2.3.11; sys_platform == 'win32' and python_version >= '3.11'
# 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'
+pyopenssl>=26.4.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'
@@ -94,8 +97,7 @@ pycryptodomex>=3.23.0
PyYAML>=6.0.3
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'
-rpm-vercmp; sys_platform == 'linux'
+requests>=2.34.2 ; python_version >= '3.11'
setproctitle>=1.3.7
timelib>=0.3.0; python_version < '3.11'
timelib>=0.3.0; python_version >= '3.11'
@@ -114,7 +116,6 @@ virtualenv>=21.4.2; python_version >= '3.10'
# 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'
-vultr>=1.0.1
wmi>=1.5.1; sys_platform == 'win32'
xmltodict>=1.0.4; sys_platform == 'win32'
# zipp 4.1.0 drops Python 3.9; keep the last 3.9-compatible release there.
diff --git a/requirements/constraints.txt b/requirements/constraints.txt
index 3fa7e6e23ca0..6602083012ed 100644
--- a/requirements/constraints.txt
+++ b/requirements/constraints.txt
@@ -4,12 +4,11 @@
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.
-setuptools >= 78.1.1
+# Bumped to 82.0.1 to match the SRP baseline (PR #70130 on 3006.x); the
+# earlier yarl / PEP 517 build-env conflict is no longer reproducible with
+# our current pip and yarl versions -- 3007.x's lock resolves cleanly to
+# setuptools 84.x with the same >=78.1.1 floor.
+setuptools >= 82.0.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
# ``LookupError: setuptools-scm was unable to detect version`` for source
@@ -18,13 +17,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.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
@@ -45,3 +45,10 @@ markdown-it-py < 4.0.0; python_version == "3.10"
jsonschema < 4; python_version == "3.11"
bcrypt < 5; python_version == "3.11"
junos-eznc < 2.7; python_version == "3.11"
+# Cython 3.3.0 (2026-08-22) rejects the ``hint`` and ``c_addr`` redeclarations
+# in pyzmq 27.1.0's ``zmq/backend/cython/_zmq.py`` and fails to emit ``_zmq.c``,
+# breaking every source build of pyzmq (onedir Linux/macOS use
+# ``--no-binary=:all:``). PIP_CONSTRAINT propagates into pyzmq's PEP 517 build
+# env, so capping Cython here keeps the pyzmq wheel compiling until pyzmq
+# ships a Cython 3.3-compatible release.
+Cython < 3.3
diff --git a/requirements/static/ci/py3.10/cloud.lock b/requirements/static/ci/py3.10/cloud.lock
index cc6645f4ef05..9c20ee630dee 100644
--- a/requirements/static/ci/py3.10/cloud.lock
+++ b/requirements/static/ci/py3.10/cloud.lock
@@ -40,10 +40,11 @@ async-timeout==4.0.3
# -c requirements/static/ci/py3.10/linux.lock
# -c requirements/static/pkg/py3.10/linux.lock
# aiohttp
-attrs==23.2.0
+attrs==26.1.0
# via
# -c requirements/static/ci/py3.10/linux.lock
# -c requirements/static/pkg/py3.10/linux.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -96,10 +97,11 @@ cffi==2.0.0
# -r requirements/static/ci/common.txt
# cryptography
# pynacl
-charset-normalizer==3.2.0
+charset-normalizer==3.5.1
# via
# -c requirements/static/ci/py3.10/linux.lock
# -c requirements/static/pkg/py3.10/linux.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via
@@ -128,7 +130,7 @@ croniter==6.2.2
# -c requirements/static/ci/py3.10/linux.lock
# -c requirements/static/pkg/py3.10/linux.lock
# -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/ci/py3.10/linux.lock
# -c requirements/static/pkg/py3.10/linux.lock
@@ -209,7 +211,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.60
# via
# -c requirements/static/ci/py3.10/linux.lock
# -c requirements/static/pkg/py3.10/linux.lock
@@ -380,7 +382,7 @@ moto==5.2.2
# via
# -c requirements/static/ci/py3.10/linux.lock
# -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/ci/py3.10/linux.lock
# -c requirements/static/pkg/py3.10/linux.lock
@@ -512,7 +514,7 @@ py-cpuinfo==9.0.0
# via
# -c requirements/static/ci/py3.10/linux.lock
# pytest-benchmark
-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
@@ -550,7 +552,7 @@ pynacl==1.5.0
# -c requirements/static/ci/py3.10/linux.lock
# -r requirements/static/ci/common.txt
# paramiko
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/ci/py3.10/linux.lock
# -c requirements/static/pkg/py3.10/linux.lock
@@ -705,7 +707,6 @@ requests==2.31.0
# requests-oauthlib
# responses
# vcert
- # vultr
requests-ntlm==1.2.0
# via pywinrm
requests-oauthlib==2.0.0
@@ -729,12 +730,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
@@ -889,11 +884,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-lint.lock b/requirements/static/ci/py3.10/darwin-lint.lock
new file mode 100644
index 000000000000..cc9c71d90e8f
--- /dev/null
+++ b/requirements/static/ci/py3.10/darwin-lint.lock
@@ -0,0 +1,66 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements/static/ci/lint.txt --python-platform=macos --python-version=3.10 --constraint requirements/constraints.txt --no-emit-index-url --unsafe-package=setuptools -c=requirements/static/ci/py3.10/darwin.lock -c=requirements/static/pkg/py3.10/darwin.lock -o=requirements/static/ci/py3.10/darwin-lint.lock
+astroid==3.1.0
+ # via pylint
+certifi==2026.6.17
+ # via
+ # -c requirements/static/ci/py3.10/darwin.lock
+ # -c requirements/static/pkg/py3.10/darwin.lock
+ # requests
+charset-normalizer==3.5.1
+ # via
+ # -c requirements/static/ci/py3.10/darwin.lock
+ # -c requirements/static/pkg/py3.10/darwin.lock
+ # requests
+dill==0.4.1
+ # via pylint
+docker==7.1.0
+ # via
+ # -c requirements/static/ci/py3.10/darwin.lock
+ # -r requirements/static/ci/lint.txt
+idna==3.18
+ # via
+ # -c requirements/static/ci/py3.10/darwin.lock
+ # -c requirements/static/pkg/py3.10/darwin.lock
+ # requests
+isort==5.13.2
+ # via pylint
+mccabe==0.7.0
+ # via pylint
+platformdirs==4.5.1
+ # via
+ # -c requirements/static/ci/py3.10/darwin.lock
+ # -c requirements/static/pkg/py3.10/darwin.lock
+ # pylint
+pylint==3.1.1
+ # via
+ # -r requirements/static/ci/lint.txt
+ # saltpylint
+requests==2.31.0
+ # via
+ # -c requirements/static/ci/py3.10/darwin.lock
+ # -c requirements/static/pkg/py3.10/darwin.lock
+ # docker
+saltpylint==2024.2.5
+ # via -r requirements/static/ci/lint.txt
+toml==0.10.2
+ # via
+ # -c requirements/static/ci/py3.10/darwin.lock
+ # -r requirements/static/ci/lint.txt
+tomli==2.2.1
+ # via
+ # -c requirements/static/ci/py3.10/darwin.lock
+ # pylint
+tomlkit==0.15.1
+ # via pylint
+typing-extensions==4.14.1
+ # via
+ # -c requirements/static/ci/py3.10/darwin.lock
+ # -c requirements/static/pkg/py3.10/darwin.lock
+ # astroid
+urllib3==2.7.0
+ # via
+ # -c requirements/static/ci/py3.10/darwin.lock
+ # -c requirements/static/pkg/py3.10/darwin.lock
+ # docker
+ # requests
diff --git a/requirements/static/ci/py3.10/darwin.lock b/requirements/static/ci/py3.10/darwin.lock
index 0b1e65255ccf..97ba1323ef21 100644
--- a/requirements/static/ci/py3.10/darwin.lock
+++ b/requirements/static/ci/py3.10/darwin.lock
@@ -33,9 +33,10 @@ async-timeout==4.0.3
# via
# -c requirements/static/pkg/py3.10/darwin.lock
# aiohttp
-attrs==23.2.0
+attrs==26.1.0
# via
# -c requirements/static/pkg/py3.10/darwin.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -79,9 +80,10 @@ cffi==2.0.0
# cryptography
# pygit2
# pynacl
-charset-normalizer==3.2.0
+charset-normalizer==3.5.1
# via
# -c requirements/static/pkg/py3.10/darwin.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via -r requirements/static/ci/common.txt
@@ -101,7 +103,7 @@ croniter==6.2.2
# via
# -c requirements/static/pkg/py3.10/darwin.lock
# -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/pkg/py3.10/darwin.lock
# -r requirements/base.txt
@@ -156,7 +158,7 @@ gitdb==4.0.12
# via
# -c requirements/static/pkg/py3.10/darwin.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/pkg/py3.10/darwin.lock
# -r requirements/base.txt
@@ -277,7 +279,7 @@ more-itertools==11.1.0
# jaraco-text
moto==5.2.2
# via -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/pkg/py3.10/darwin.lock
# -r requirements/base.txt
@@ -378,7 +380,7 @@ psutil==7.2.2
# pytest-system-statistics
py-cpuinfo==9.0.0
# via pytest-benchmark
-pyasn1==0.6.3
+pyasn1==0.6.4
# via
# -c requirements/static/pkg/py3.10/darwin.lock
# -r requirements/base.txt
@@ -406,7 +408,7 @@ pynacl==1.5.0
# via
# -r requirements/static/ci/common.txt
# paramiko
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/pkg/py3.10/darwin.lock
# -r requirements/base.txt
@@ -511,7 +513,6 @@ requests==2.31.0
# requests-oauthlib
# responses
# vcert
- # vultr
requests-oauthlib==2.0.0
# via kubernetes
responses==0.23.1
@@ -628,10 +629,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 1a3f8bd80a0b..3ed317e485bb 100644
--- a/requirements/static/ci/py3.10/docs.lock
+++ b/requirements/static/ci/py3.10/docs.lock
@@ -28,9 +28,10 @@ async-timeout==4.0.3
# via
# -c requirements/static/ci/py3.10/linux.lock
# aiohttp
-attrs==23.2.0
+attrs==26.1.0
# via
# -c requirements/static/ci/py3.10/linux.lock
+ # -r requirements/base.txt
# aiohttp
babel==2.12.1
# via
@@ -52,9 +53,10 @@ cffi==2.0.0
# -c requirements/static/ci/py3.10/linux.lock
# -r requirements/base.txt
# cryptography
-charset-normalizer==3.2.0
+charset-normalizer==3.5.1
# via
# -c requirements/static/ci/py3.10/linux.lock
+ # -r requirements/base.txt
# requests
cheroot==11.1.2
# via
@@ -70,7 +72,7 @@ croniter==6.2.2
# via
# -c requirements/static/ci/py3.10/linux.lock
# -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/ci/py3.10/linux.lock
# -r requirements/base.txt
@@ -103,7 +105,7 @@ gitdb==4.0.12
# via
# -c requirements/static/ci/py3.10/linux.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/ci/py3.10/linux.lock
# -r requirements/base.txt
@@ -188,7 +190,7 @@ more-itertools==11.1.0
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/ci/py3.10/linux.lock
# -r requirements/base.txt
@@ -267,7 +269,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
@@ -292,7 +294,7 @@ pygments==2.20.0
# pydata-sphinx-theme
# rich
# sphinx
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/ci/py3.10/linux.lock
# -r requirements/base.txt
@@ -329,15 +331,10 @@ requests==2.31.0
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
# sphinx
- # vultr
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
@@ -430,10 +427,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
xxhash==3.7.0
# via
# -c requirements/static/ci/py3.10/linux.lock
diff --git a/requirements/static/ci/py3.10/freebsd-lint.lock b/requirements/static/ci/py3.10/freebsd-lint.lock
new file mode 100644
index 000000000000..54bda7208790
--- /dev/null
+++ b/requirements/static/ci/py3.10/freebsd-lint.lock
@@ -0,0 +1,81 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements/static/ci/lint.txt --universal --python-version=3.10 --constraint requirements/constraints.txt --no-emit-index-url --unsafe-package=setuptools -c=requirements/static/ci/py3.10/freebsd.lock -c=requirements/static/pkg/py3.10/freebsd.lock -o=requirements/static/ci/py3.10/freebsd-lint.lock
+astroid==3.1.0
+ # via pylint
+certifi==2026.6.17
+ # via
+ # -c requirements/static/ci/py3.10/freebsd.lock
+ # -c requirements/static/pkg/py3.10/freebsd.lock
+ # requests
+charset-normalizer==3.5.1
+ # via
+ # -c requirements/static/ci/py3.10/freebsd.lock
+ # -c requirements/static/pkg/py3.10/freebsd.lock
+ # requests
+colorama==0.4.6 ; sys_platform == 'win32'
+ # via
+ # -c requirements/static/ci/py3.10/freebsd.lock
+ # -c requirements/static/pkg/py3.10/freebsd.lock
+ # pylint
+dill==0.4.1
+ # via pylint
+docker==7.1.0
+ # via
+ # -c requirements/static/ci/py3.10/freebsd.lock
+ # -r requirements/static/ci/lint.txt
+idna==3.18
+ # via
+ # -c requirements/static/ci/py3.10/freebsd.lock
+ # -c requirements/static/pkg/py3.10/freebsd.lock
+ # requests
+isort==5.13.2
+ # via pylint
+mccabe==0.7.0
+ # via pylint
+platformdirs==4.5.1
+ # via
+ # -c requirements/static/ci/py3.10/freebsd.lock
+ # -c requirements/static/pkg/py3.10/freebsd.lock
+ # pylint
+pylint==3.1.1
+ # via
+ # -r requirements/static/ci/lint.txt
+ # saltpylint
+pywin32==312 ; sys_platform == 'win32'
+ # via
+ # -c requirements/static/ci/py3.10/freebsd.lock
+ # -c requirements/static/pkg/py3.10/freebsd.lock
+ # docker
+requests==2.31.0 ; python_full_version < '3.11'
+ # via
+ # -c requirements/static/ci/py3.10/freebsd.lock
+ # -c requirements/static/pkg/py3.10/freebsd.lock
+ # docker
+requests==2.34.2 ; python_full_version >= '3.11'
+ # via
+ # -c requirements/static/ci/py3.10/freebsd.lock
+ # -c requirements/static/pkg/py3.10/freebsd.lock
+ # docker
+saltpylint==2024.2.5
+ # via -r requirements/static/ci/lint.txt
+toml==0.10.2
+ # via
+ # -c requirements/static/ci/py3.10/freebsd.lock
+ # -r requirements/static/ci/lint.txt
+tomli==2.2.1 ; python_full_version < '3.11'
+ # via
+ # -c requirements/static/ci/py3.10/freebsd.lock
+ # pylint
+tomlkit==0.15.1
+ # via pylint
+typing-extensions==4.14.1 ; python_full_version < '3.11'
+ # via
+ # -c requirements/static/ci/py3.10/freebsd.lock
+ # -c requirements/static/pkg/py3.10/freebsd.lock
+ # astroid
+urllib3==2.7.0
+ # via
+ # -c requirements/static/ci/py3.10/freebsd.lock
+ # -c requirements/static/pkg/py3.10/freebsd.lock
+ # docker
+ # requests
diff --git a/requirements/static/ci/py3.10/freebsd.lock b/requirements/static/ci/py3.10/freebsd.lock
index 19ed10de8b83..34155c98e982 100644
--- a/requirements/static/ci/py3.10/freebsd.lock
+++ b/requirements/static/ci/py3.10/freebsd.lock
@@ -32,9 +32,10 @@ async-timeout==4.0.3 ; python_full_version < '3.11'
# via
# -c requirements/static/pkg/py3.10/freebsd.lock
# aiohttp
-attrs==23.2.0
+attrs==26.1.0
# via
# -c requirements/static/pkg/py3.10/freebsd.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -80,9 +81,10 @@ cffi==2.0.0
# cryptography
# pynacl
# pyzmq
-charset-normalizer==3.2.0
+charset-normalizer==3.5.1
# via
# -c requirements/static/pkg/py3.10/freebsd.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via -r requirements/static/ci/common.txt
@@ -113,7 +115,7 @@ croniter==6.2.2 ; sys_platform != 'win32'
# via
# -c requirements/static/pkg/py3.10/freebsd.lock
# -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/pkg/py3.10/freebsd.lock
# -r requirements/base.txt
@@ -171,7 +173,7 @@ gitdb==4.0.12
# via
# -c requirements/static/pkg/py3.10/freebsd.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/pkg/py3.10/freebsd.lock
# -r requirements/base.txt
@@ -317,7 +319,7 @@ more-itertools==11.1.0
# jaraco-text
moto==5.2.2
# via -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/pkg/py3.10/freebsd.lock
# -r requirements/base.txt
@@ -427,7 +429,7 @@ psutil==7.2.2
# pytest-system-statistics
py-cpuinfo==9.0.0
# via pytest-benchmark
-pyasn1==0.6.3
+pyasn1==0.6.4
# via
# -c requirements/static/pkg/py3.10/freebsd.lock
# -r requirements/base.txt
@@ -460,7 +462,7 @@ pynacl==1.5.0
# via
# -r requirements/static/ci/common.txt
# paramiko
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/pkg/py3.10/freebsd.lock
# -r requirements/base.txt
@@ -581,8 +583,7 @@ requests==2.31.0 ; python_full_version < '3.11'
# requests-oauthlib
# responses
# vcert
- # vultr
-requests==2.33.1 ; python_full_version >= '3.11'
+requests==2.34.2 ; python_full_version >= '3.11'
# via
# -c requirements/static/pkg/py3.10/freebsd.lock
# -r requirements/base.txt
@@ -595,7 +596,6 @@ requests==2.33.1 ; python_full_version >= '3.11'
# requests-oauthlib
# responses
# vcert
- # vultr
requests-oauthlib==2.0.0
# via kubernetes
responses==0.23.1
@@ -608,10 +608,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'
@@ -721,10 +717,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 6c25786d9bf0..eb519d758f74 100644
--- a/requirements/static/ci/py3.10/lint.lock
+++ b/requirements/static/ci/py3.10/lint.lock
@@ -681,7 +681,6 @@ requests==2.31.0
# responses
# twilio
# vcert
- # vultr
requests-oauthlib==2.0.0
# via
# -c requirements/static/ci/py3.10/linux.lock
@@ -707,12 +706,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
@@ -879,11 +872,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-lint.lock b/requirements/static/ci/py3.10/linux-lint.lock
new file mode 100644
index 000000000000..eb5ec07ee5f3
--- /dev/null
+++ b/requirements/static/ci/py3.10/linux-lint.lock
@@ -0,0 +1,66 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements/static/ci/lint.txt --python-platform=linux --python-version=3.10 --constraint requirements/constraints.txt --no-emit-index-url --unsafe-package=setuptools -c=requirements/static/ci/py3.10/linux.lock -c=requirements/static/pkg/py3.10/linux.lock -o=requirements/static/ci/py3.10/linux-lint.lock
+astroid==3.1.0
+ # via pylint
+certifi==2026.6.17
+ # via
+ # -c requirements/static/ci/py3.10/linux.lock
+ # -c requirements/static/pkg/py3.10/linux.lock
+ # requests
+charset-normalizer==3.5.1
+ # via
+ # -c requirements/static/ci/py3.10/linux.lock
+ # -c requirements/static/pkg/py3.10/linux.lock
+ # requests
+dill==0.4.1
+ # via pylint
+docker==7.1.0
+ # via
+ # -c requirements/static/ci/py3.10/linux.lock
+ # -r requirements/static/ci/lint.txt
+idna==3.18
+ # via
+ # -c requirements/static/ci/py3.10/linux.lock
+ # -c requirements/static/pkg/py3.10/linux.lock
+ # requests
+isort==5.13.2
+ # via pylint
+mccabe==0.7.0
+ # via pylint
+platformdirs==4.5.1
+ # via
+ # -c requirements/static/ci/py3.10/linux.lock
+ # -c requirements/static/pkg/py3.10/linux.lock
+ # pylint
+pylint==3.1.1
+ # via
+ # -r requirements/static/ci/lint.txt
+ # saltpylint
+requests==2.31.0
+ # via
+ # -c requirements/static/ci/py3.10/linux.lock
+ # -c requirements/static/pkg/py3.10/linux.lock
+ # docker
+saltpylint==2024.2.5
+ # via -r requirements/static/ci/lint.txt
+toml==0.10.2
+ # via
+ # -c requirements/static/ci/py3.10/linux.lock
+ # -r requirements/static/ci/lint.txt
+tomli==2.2.1
+ # via
+ # -c requirements/static/ci/py3.10/linux.lock
+ # pylint
+tomlkit==0.15.1
+ # via pylint
+typing-extensions==4.14.1
+ # via
+ # -c requirements/static/ci/py3.10/linux.lock
+ # -c requirements/static/pkg/py3.10/linux.lock
+ # astroid
+urllib3==2.7.0
+ # via
+ # -c requirements/static/ci/py3.10/linux.lock
+ # -c requirements/static/pkg/py3.10/linux.lock
+ # docker
+ # requests
diff --git a/requirements/static/ci/py3.10/linux.lock b/requirements/static/ci/py3.10/linux.lock
index 18d032a52b48..a92bddb71cd3 100644
--- a/requirements/static/ci/py3.10/linux.lock
+++ b/requirements/static/ci/py3.10/linux.lock
@@ -43,9 +43,10 @@ async-timeout==4.0.3
# -c requirements/static/pkg/py3.10/linux.lock
# aiohttp
# redis
-attrs==23.2.0
+attrs==26.1.0
# via
# -c requirements/static/pkg/py3.10/linux.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -91,9 +92,10 @@ cffi==2.0.0
# cryptography
# pygit2
# pynacl
-charset-normalizer==3.2.0
+charset-normalizer==3.5.1
# via
# -c requirements/static/pkg/py3.10/linux.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via -r requirements/static/ci/common.txt
@@ -113,7 +115,7 @@ croniter==6.2.2
# via
# -c requirements/static/pkg/py3.10/linux.lock
# -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/pkg/py3.10/linux.lock
# -r requirements/base.txt
@@ -172,7 +174,7 @@ gitdb==4.0.12
# via
# -c requirements/static/pkg/py3.10/linux.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/pkg/py3.10/linux.lock
# -r requirements/base.txt
@@ -309,7 +311,7 @@ more-itertools==11.1.0
# jaraco-text
moto==5.2.2
# via -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/pkg/py3.10/linux.lock
# -r requirements/base.txt
@@ -412,7 +414,7 @@ psutil==7.2.2
# pytest-system-statistics
py-cpuinfo==9.0.0
# via pytest-benchmark
-pyasn1==0.6.3
+pyasn1==0.6.4
# via
# -c requirements/static/pkg/py3.10/linux.lock
# -r requirements/base.txt
@@ -448,7 +450,7 @@ pynacl==1.5.0
# via
# -r requirements/static/ci/common.txt
# paramiko
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/pkg/py3.10/linux.lock
# -r requirements/base.txt
@@ -562,7 +564,6 @@ requests==2.31.0
# responses
# twilio
# vcert
- # vultr
requests-oauthlib==2.0.0
# via kubernetes
resolvelib==1.0.1
@@ -577,10 +578,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
@@ -698,10 +695,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-lint.lock b/requirements/static/ci/py3.10/windows-lint.lock
new file mode 100644
index 000000000000..85ca63eeec39
--- /dev/null
+++ b/requirements/static/ci/py3.10/windows-lint.lock
@@ -0,0 +1,76 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements/static/ci/lint.txt --python-platform=windows --python-version=3.10 --constraint requirements/constraints.txt --no-emit-index-url --unsafe-package=setuptools -c=requirements/static/ci/py3.10/windows.lock -c=requirements/static/pkg/py3.10/windows.lock -o=requirements/static/ci/py3.10/windows-lint.lock
+astroid==3.1.0
+ # via pylint
+certifi==2026.6.17
+ # via
+ # -c requirements/static/ci/py3.10/windows.lock
+ # -c requirements/static/pkg/py3.10/windows.lock
+ # requests
+charset-normalizer==3.5.1
+ # via
+ # -c requirements/static/ci/py3.10/windows.lock
+ # -c requirements/static/pkg/py3.10/windows.lock
+ # requests
+colorama==0.4.6
+ # via
+ # -c requirements/static/ci/py3.10/windows.lock
+ # -c requirements/static/pkg/py3.10/windows.lock
+ # pylint
+dill==0.4.1
+ # via pylint
+docker==7.1.0
+ # via
+ # -c requirements/static/ci/py3.10/windows.lock
+ # -r requirements/static/ci/lint.txt
+idna==3.18
+ # via
+ # -c requirements/static/ci/py3.10/windows.lock
+ # -c requirements/static/pkg/py3.10/windows.lock
+ # requests
+isort==5.13.2
+ # via pylint
+mccabe==0.7.0
+ # via pylint
+platformdirs==4.9.2
+ # via
+ # -c requirements/static/ci/py3.10/windows.lock
+ # -c requirements/static/pkg/py3.10/windows.lock
+ # pylint
+pylint==3.1.1
+ # via
+ # -r requirements/static/ci/lint.txt
+ # saltpylint
+pywin32==312
+ # via
+ # -c requirements/static/ci/py3.10/windows.lock
+ # -c requirements/static/pkg/py3.10/windows.lock
+ # docker
+requests==2.31.0
+ # via
+ # -c requirements/static/ci/py3.10/windows.lock
+ # -c requirements/static/pkg/py3.10/windows.lock
+ # docker
+saltpylint==2024.2.5
+ # via -r requirements/static/ci/lint.txt
+toml==0.10.2
+ # via
+ # -c requirements/static/ci/py3.10/windows.lock
+ # -r requirements/static/ci/lint.txt
+tomli==2.2.1
+ # via
+ # -c requirements/static/ci/py3.10/windows.lock
+ # pylint
+tomlkit==0.15.1
+ # via pylint
+typing-extensions==4.15.0
+ # via
+ # -c requirements/static/ci/py3.10/windows.lock
+ # -c requirements/static/pkg/py3.10/windows.lock
+ # astroid
+urllib3==2.7.0
+ # via
+ # -c requirements/static/ci/py3.10/windows.lock
+ # -c requirements/static/pkg/py3.10/windows.lock
+ # docker
+ # requests
diff --git a/requirements/static/ci/py3.10/windows.lock b/requirements/static/ci/py3.10/windows.lock
index fdc33363df60..011971fe20f6 100644
--- a/requirements/static/ci/py3.10/windows.lock
+++ b/requirements/static/ci/py3.10/windows.lock
@@ -27,9 +27,10 @@ async-timeout==5.0.1
# via
# -c requirements/static/pkg/py3.10/windows.lock
# aiohttp
-attrs==25.4.0
+attrs==26.1.0
# via
# -c requirements/static/pkg/py3.10/windows.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -70,9 +71,10 @@ cffi==2.0.0
# cryptography
# pygit2
# pynacl
-charset-normalizer==3.4.4
+charset-normalizer==3.5.1
# via
# -c requirements/static/pkg/py3.10/windows.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via -r requirements/static/ci/common.txt
@@ -101,7 +103,7 @@ colorama==0.4.6
# -c requirements/static/pkg/py3.10/windows.lock
# click
# pytest
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/pkg/py3.10/windows.lock
# -r requirements/base.txt
@@ -158,7 +160,7 @@ gitdb==4.0.12
# via
# -c requirements/static/pkg/py3.10/windows.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/pkg/py3.10/windows.lock
# -r requirements/base.txt
@@ -267,7 +269,7 @@ more-itertools==11.1.0
# jaraco-text
moto==5.2.2
# via -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/pkg/py3.10/windows.lock
# -r requirements/base.txt
@@ -360,7 +362,7 @@ psutil==7.2.2
# pytest-system-statistics
py-cpuinfo==9.0.0
# via pytest-benchmark
-pyasn1==0.6.3
+pyasn1==0.6.4
# via
# -c requirements/static/pkg/py3.10/windows.lock
# -r requirements/base.txt
@@ -390,7 +392,7 @@ pymssql==2.3.11
# -r requirements/base.txt
pynacl==1.5.0
# via -r requirements/static/ci/common.txt
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/pkg/py3.10/windows.lock
# -r requirements/base.txt
@@ -502,7 +504,6 @@ requests==2.31.0
# requests-ntlm
# requests-oauthlib
# responses
- # vultr
requests-ntlm==1.3.0
# via pywinrm
requests-oauthlib==2.0.0
@@ -616,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.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 d62d84888328..7ae9cdb39087 100644
--- a/requirements/static/ci/py3.11/cloud.lock
+++ b/requirements/static/ci/py3.11/cloud.lock
@@ -35,10 +35,11 @@ asn1crypto==1.5.1
# -c requirements/static/ci/py3.11/linux.lock
# certvalidator
# oscrypto
-attrs==23.2.0
+attrs==26.1.0
# via
# -c requirements/static/ci/py3.11/linux.lock
# -c requirements/static/pkg/py3.11/linux.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -91,10 +92,11 @@ cffi==2.0.0
# -r requirements/static/ci/common.txt
# cryptography
# pynacl
-charset-normalizer==3.2.0
+charset-normalizer==3.5.1
# via
# -c requirements/static/ci/py3.11/linux.lock
# -c requirements/static/pkg/py3.11/linux.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via
@@ -123,7 +125,7 @@ croniter==6.2.2
# -c requirements/static/ci/py3.11/linux.lock
# -c requirements/static/pkg/py3.11/linux.lock
# -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/ci/py3.11/linux.lock
# -c requirements/static/pkg/py3.11/linux.lock
@@ -196,7 +198,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.60
# via
# -c requirements/static/ci/py3.11/linux.lock
# -c requirements/static/pkg/py3.11/linux.lock
@@ -368,7 +370,7 @@ moto==5.2.2
# via
# -c requirements/static/ci/py3.11/linux.lock
# -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/ci/py3.11/linux.lock
# -c requirements/static/pkg/py3.11/linux.lock
@@ -504,7 +506,7 @@ py-cpuinfo==9.0.0
# via
# -c requirements/static/ci/py3.11/linux.lock
# pytest-benchmark
-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
@@ -542,7 +544,7 @@ pynacl==1.6.2
# -c requirements/static/ci/py3.11/linux.lock
# -r requirements/static/ci/common.txt
# paramiko
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/ci/py3.11/linux.lock
# -c requirements/static/pkg/py3.11/linux.lock
@@ -680,7 +682,7 @@ pyzmq==27.1.0
# -c requirements/static/pkg/py3.11/linux.lock
# -r requirements/zeromq.txt
# pytest-salt-factories
-requests==2.33.1
+requests==2.34.2
# via
# -c requirements/static/ci/py3.11/linux.lock
# -c requirements/static/pkg/py3.11/linux.lock
@@ -697,7 +699,6 @@ requests==2.33.1
# requests-oauthlib
# responses
# vcert
- # vultr
requests-ntlm==1.2.0
# via pywinrm
requests-oauthlib==2.0.0
@@ -721,12 +722,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
@@ -867,11 +862,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-lint.lock b/requirements/static/ci/py3.11/darwin-lint.lock
new file mode 100644
index 000000000000..129f8f759db4
--- /dev/null
+++ b/requirements/static/ci/py3.11/darwin-lint.lock
@@ -0,0 +1,57 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements/static/ci/lint.txt --python-platform=macos --python-version=3.11 --constraint requirements/constraints.txt --no-emit-index-url --unsafe-package=setuptools -c=requirements/static/ci/py3.11/darwin.lock -c=requirements/static/pkg/py3.11/darwin.lock -o=requirements/static/ci/py3.11/darwin-lint.lock
+astroid==3.1.0
+ # via pylint
+certifi==2026.6.17
+ # via
+ # -c requirements/static/ci/py3.11/darwin.lock
+ # -c requirements/static/pkg/py3.11/darwin.lock
+ # requests
+charset-normalizer==3.5.1
+ # via
+ # -c requirements/static/ci/py3.11/darwin.lock
+ # -c requirements/static/pkg/py3.11/darwin.lock
+ # requests
+dill==0.4.1
+ # via pylint
+docker==7.1.0
+ # via
+ # -c requirements/static/ci/py3.11/darwin.lock
+ # -r requirements/static/ci/lint.txt
+idna==3.18
+ # via
+ # -c requirements/static/ci/py3.11/darwin.lock
+ # -c requirements/static/pkg/py3.11/darwin.lock
+ # requests
+isort==5.13.2
+ # via pylint
+mccabe==0.7.0
+ # via pylint
+platformdirs==4.5.1
+ # via
+ # -c requirements/static/ci/py3.11/darwin.lock
+ # -c requirements/static/pkg/py3.11/darwin.lock
+ # pylint
+pylint==3.1.1
+ # via
+ # -r requirements/static/ci/lint.txt
+ # saltpylint
+requests==2.34.2
+ # via
+ # -c requirements/static/ci/py3.11/darwin.lock
+ # -c requirements/static/pkg/py3.11/darwin.lock
+ # docker
+saltpylint==2024.2.5
+ # via -r requirements/static/ci/lint.txt
+toml==0.10.2
+ # via
+ # -c requirements/static/ci/py3.11/darwin.lock
+ # -r requirements/static/ci/lint.txt
+tomlkit==0.15.1
+ # via pylint
+urllib3==2.7.0
+ # via
+ # -c requirements/static/ci/py3.11/darwin.lock
+ # -c requirements/static/pkg/py3.11/darwin.lock
+ # docker
+ # requests
diff --git a/requirements/static/ci/py3.11/darwin.lock b/requirements/static/ci/py3.11/darwin.lock
index 312649a3cbd1..e25db64f5aaf 100644
--- a/requirements/static/ci/py3.11/darwin.lock
+++ b/requirements/static/ci/py3.11/darwin.lock
@@ -29,9 +29,10 @@ asn1crypto==1.5.1
# via
# certvalidator
# oscrypto
-attrs==23.2.0
+attrs==26.1.0
# via
# -c requirements/static/pkg/py3.11/darwin.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -75,9 +76,10 @@ cffi==2.0.0
# cryptography
# pygit2
# pynacl
-charset-normalizer==3.2.0
+charset-normalizer==3.5.1
# via
# -c requirements/static/pkg/py3.11/darwin.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via -r requirements/static/ci/common.txt
@@ -97,7 +99,7 @@ croniter==6.2.2
# via
# -c requirements/static/pkg/py3.11/darwin.lock
# -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/pkg/py3.11/darwin.lock
# -r requirements/base.txt
@@ -148,7 +150,7 @@ gitdb==4.0.12
# via
# -c requirements/static/pkg/py3.11/darwin.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/pkg/py3.11/darwin.lock
# -r requirements/base.txt
@@ -272,7 +274,7 @@ more-itertools==11.1.0
# jaraco-text
moto==5.2.2
# via -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/pkg/py3.11/darwin.lock
# -r requirements/base.txt
@@ -376,7 +378,7 @@ psutil==7.2.2
# pytest-system-statistics
py-cpuinfo==9.0.0
# via pytest-benchmark
-pyasn1==0.6.3
+pyasn1==0.6.4
# via
# -c requirements/static/pkg/py3.11/darwin.lock
# -r requirements/base.txt
@@ -404,7 +406,7 @@ pynacl==1.6.2
# via
# -r requirements/static/ci/common.txt
# paramiko
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/pkg/py3.11/darwin.lock
# -r requirements/base.txt
@@ -496,7 +498,7 @@ pyzmq==27.1.0
# -c requirements/static/pkg/py3.11/darwin.lock
# -r requirements/zeromq.txt
# pytest-salt-factories
-requests==2.33.1
+requests==2.34.2
# via
# -c requirements/static/pkg/py3.11/darwin.lock
# -r requirements/base.txt
@@ -509,7 +511,6 @@ requests==2.33.1
# requests-oauthlib
# responses
# vcert
- # vultr
requests-oauthlib==2.0.0
# via kubernetes
responses==0.25.8
@@ -617,10 +618,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 4be7a4803785..c36fe4c0ff4c 100644
--- a/requirements/static/ci/py3.11/docs.lock
+++ b/requirements/static/ci/py3.11/docs.lock
@@ -24,9 +24,10 @@ apache-libcloud==3.9.1
# via
# -c requirements/static/ci/py3.11/linux.lock
# -r requirements/base.txt
-attrs==23.2.0
+attrs==26.1.0
# via
# -c requirements/static/ci/py3.11/linux.lock
+ # -r requirements/base.txt
# aiohttp
babel==2.12.1
# via
@@ -48,9 +49,10 @@ cffi==2.0.0
# -c requirements/static/ci/py3.11/linux.lock
# -r requirements/base.txt
# cryptography
-charset-normalizer==3.2.0
+charset-normalizer==3.5.1
# via
# -c requirements/static/ci/py3.11/linux.lock
+ # -r requirements/base.txt
# requests
cheroot==11.1.2
# via
@@ -66,7 +68,7 @@ croniter==6.2.2
# via
# -c requirements/static/ci/py3.11/linux.lock
# -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/ci/py3.11/linux.lock
# -r requirements/base.txt
@@ -99,7 +101,7 @@ gitdb==4.0.12
# via
# -c requirements/static/ci/py3.11/linux.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/ci/py3.11/linux.lock
# -r requirements/base.txt
@@ -183,7 +185,7 @@ more-itertools==11.1.0
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/ci/py3.11/linux.lock
# -r requirements/base.txt
@@ -262,7 +264,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
@@ -287,7 +289,7 @@ pygments==2.20.0
# pydata-sphinx-theme
# rich
# sphinx
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/ci/py3.11/linux.lock
# -r requirements/base.txt
@@ -317,22 +319,17 @@ pyzmq==27.1.0
# via
# -c requirements/static/ci/py3.11/linux.lock
# -r requirements/zeromq.txt
-requests==2.33.1
+requests==2.34.2
# via
# -c requirements/static/ci/py3.11/linux.lock
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
# sphinx
- # vultr
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
@@ -422,10 +419,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
xxhash==3.7.0
# via
# -c requirements/static/ci/py3.11/linux.lock
diff --git a/requirements/static/ci/py3.11/freebsd-lint.lock b/requirements/static/ci/py3.11/freebsd-lint.lock
new file mode 100644
index 000000000000..4e2639703864
--- /dev/null
+++ b/requirements/static/ci/py3.11/freebsd-lint.lock
@@ -0,0 +1,67 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements/static/ci/lint.txt --universal --python-version=3.11 --constraint requirements/constraints.txt --no-emit-index-url --unsafe-package=setuptools -c=requirements/static/ci/py3.11/freebsd.lock -c=requirements/static/pkg/py3.11/freebsd.lock -o=requirements/static/ci/py3.11/freebsd-lint.lock
+astroid==3.1.0
+ # via pylint
+certifi==2026.6.17
+ # via
+ # -c requirements/static/ci/py3.11/freebsd.lock
+ # -c requirements/static/pkg/py3.11/freebsd.lock
+ # requests
+charset-normalizer==3.5.1
+ # via
+ # -c requirements/static/ci/py3.11/freebsd.lock
+ # -c requirements/static/pkg/py3.11/freebsd.lock
+ # requests
+colorama==0.4.6 ; sys_platform == 'win32'
+ # via
+ # -c requirements/static/ci/py3.11/freebsd.lock
+ # -c requirements/static/pkg/py3.11/freebsd.lock
+ # pylint
+dill==0.4.1
+ # via pylint
+docker==7.1.0
+ # via
+ # -c requirements/static/ci/py3.11/freebsd.lock
+ # -r requirements/static/ci/lint.txt
+idna==3.18
+ # via
+ # -c requirements/static/ci/py3.11/freebsd.lock
+ # -c requirements/static/pkg/py3.11/freebsd.lock
+ # requests
+isort==5.13.2
+ # via pylint
+mccabe==0.7.0
+ # via pylint
+platformdirs==4.5.1
+ # via
+ # -c requirements/static/ci/py3.11/freebsd.lock
+ # -c requirements/static/pkg/py3.11/freebsd.lock
+ # pylint
+pylint==3.1.1
+ # via
+ # -r requirements/static/ci/lint.txt
+ # saltpylint
+pywin32==312 ; sys_platform == 'win32'
+ # via
+ # -c requirements/static/ci/py3.11/freebsd.lock
+ # -c requirements/static/pkg/py3.11/freebsd.lock
+ # docker
+requests==2.34.2
+ # via
+ # -c requirements/static/ci/py3.11/freebsd.lock
+ # -c requirements/static/pkg/py3.11/freebsd.lock
+ # docker
+saltpylint==2024.2.5
+ # via -r requirements/static/ci/lint.txt
+toml==0.10.2
+ # via
+ # -c requirements/static/ci/py3.11/freebsd.lock
+ # -r requirements/static/ci/lint.txt
+tomlkit==0.15.1
+ # via pylint
+urllib3==2.7.0
+ # via
+ # -c requirements/static/ci/py3.11/freebsd.lock
+ # -c requirements/static/pkg/py3.11/freebsd.lock
+ # docker
+ # requests
diff --git a/requirements/static/ci/py3.11/freebsd.lock b/requirements/static/ci/py3.11/freebsd.lock
index 457717b03dd2..e24514e1407c 100644
--- a/requirements/static/ci/py3.11/freebsd.lock
+++ b/requirements/static/ci/py3.11/freebsd.lock
@@ -28,9 +28,10 @@ asn1crypto==1.5.1 ; sys_platform != 'win32'
# via
# certvalidator
# oscrypto
-attrs==23.2.0
+attrs==26.1.0
# via
# -c requirements/static/pkg/py3.11/freebsd.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -81,9 +82,10 @@ cffi==2.0.0
# cryptography
# pynacl
# pyzmq
-charset-normalizer==3.2.0
+charset-normalizer==3.5.1
# via
# -c requirements/static/pkg/py3.11/freebsd.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via -r requirements/static/ci/common.txt
@@ -114,7 +116,7 @@ croniter==6.2.2 ; sys_platform != 'win32'
# via
# -c requirements/static/pkg/py3.11/freebsd.lock
# -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/pkg/py3.11/freebsd.lock
# -r requirements/base.txt
@@ -168,7 +170,7 @@ gitdb==4.0.12
# via
# -c requirements/static/pkg/py3.11/freebsd.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/pkg/py3.11/freebsd.lock
# -r requirements/base.txt
@@ -314,7 +316,7 @@ more-itertools==11.1.0
# jaraco-text
moto==5.2.2
# via -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/pkg/py3.11/freebsd.lock
# -r requirements/base.txt
@@ -424,7 +426,7 @@ psutil==7.2.2
# pytest-system-statistics
py-cpuinfo==9.0.0
# via pytest-benchmark
-pyasn1==0.6.3
+pyasn1==0.6.4
# via
# -c requirements/static/pkg/py3.11/freebsd.lock
# -r requirements/base.txt
@@ -457,7 +459,7 @@ pynacl==1.6.2
# via
# -r requirements/static/ci/common.txt
# paramiko
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/pkg/py3.11/freebsd.lock
# -r requirements/base.txt
@@ -569,7 +571,7 @@ referencing==0.37.0 ; python_full_version >= '3.12'
# via
# jsonschema
# jsonschema-specifications
-requests==2.33.1
+requests==2.34.2
# via
# -c requirements/static/pkg/py3.11/freebsd.lock
# -r requirements/base.txt
@@ -582,7 +584,6 @@ requests==2.33.1
# requests-oauthlib
# responses
# vcert
- # vultr
requests-oauthlib==2.0.0
# via kubernetes
responses==0.25.8
@@ -599,10 +600,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'
@@ -703,10 +700,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 454943ee7e80..7e7f446aee2d 100644
--- a/requirements/static/ci/py3.11/lint.lock
+++ b/requirements/static/ci/py3.11/lint.lock
@@ -678,7 +678,6 @@ requests==2.33.1
# responses
# twilio
# vcert
- # vultr
requests-oauthlib==2.0.0
# via
# -c requirements/static/ci/py3.11/linux.lock
@@ -704,12 +703,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
@@ -860,11 +853,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-lint.lock b/requirements/static/ci/py3.11/linux-lint.lock
new file mode 100644
index 000000000000..ac39856db4cf
--- /dev/null
+++ b/requirements/static/ci/py3.11/linux-lint.lock
@@ -0,0 +1,57 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements/static/ci/lint.txt --python-platform=linux --python-version=3.11 --constraint requirements/constraints.txt --no-emit-index-url --unsafe-package=setuptools -c=requirements/static/ci/py3.11/linux.lock -c=requirements/static/pkg/py3.11/linux.lock -o=requirements/static/ci/py3.11/linux-lint.lock
+astroid==3.1.0
+ # via pylint
+certifi==2026.6.17
+ # via
+ # -c requirements/static/ci/py3.11/linux.lock
+ # -c requirements/static/pkg/py3.11/linux.lock
+ # requests
+charset-normalizer==3.5.1
+ # via
+ # -c requirements/static/ci/py3.11/linux.lock
+ # -c requirements/static/pkg/py3.11/linux.lock
+ # requests
+dill==0.4.1
+ # via pylint
+docker==7.1.0
+ # via
+ # -c requirements/static/ci/py3.11/linux.lock
+ # -r requirements/static/ci/lint.txt
+idna==3.18
+ # via
+ # -c requirements/static/ci/py3.11/linux.lock
+ # -c requirements/static/pkg/py3.11/linux.lock
+ # requests
+isort==5.13.2
+ # via pylint
+mccabe==0.7.0
+ # via pylint
+platformdirs==4.5.1
+ # via
+ # -c requirements/static/ci/py3.11/linux.lock
+ # -c requirements/static/pkg/py3.11/linux.lock
+ # pylint
+pylint==3.1.1
+ # via
+ # -r requirements/static/ci/lint.txt
+ # saltpylint
+requests==2.34.2
+ # via
+ # -c requirements/static/ci/py3.11/linux.lock
+ # -c requirements/static/pkg/py3.11/linux.lock
+ # docker
+saltpylint==2024.2.5
+ # via -r requirements/static/ci/lint.txt
+toml==0.10.2
+ # via
+ # -c requirements/static/ci/py3.11/linux.lock
+ # -r requirements/static/ci/lint.txt
+tomlkit==0.15.1
+ # via pylint
+urllib3==2.7.0
+ # via
+ # -c requirements/static/ci/py3.11/linux.lock
+ # -c requirements/static/pkg/py3.11/linux.lock
+ # docker
+ # requests
diff --git a/requirements/static/ci/py3.11/linux.lock b/requirements/static/ci/py3.11/linux.lock
index 00bb5fc018bd..15e6bbe2748c 100644
--- a/requirements/static/ci/py3.11/linux.lock
+++ b/requirements/static/ci/py3.11/linux.lock
@@ -40,9 +40,10 @@ asn1crypto==1.5.1
# oscrypto
async-timeout==5.0.1
# via redis
-attrs==23.2.0
+attrs==26.1.0
# via
# -c requirements/static/pkg/py3.11/linux.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -88,9 +89,10 @@ cffi==2.0.0
# cryptography
# pygit2
# pynacl
-charset-normalizer==3.2.0
+charset-normalizer==3.5.1
# via
# -c requirements/static/pkg/py3.11/linux.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via -r requirements/static/ci/common.txt
@@ -110,7 +112,7 @@ croniter==6.2.2
# via
# -c requirements/static/pkg/py3.11/linux.lock
# -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/pkg/py3.11/linux.lock
# -r requirements/base.txt
@@ -163,7 +165,7 @@ gitdb==4.0.12
# via
# -c requirements/static/pkg/py3.11/linux.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/pkg/py3.11/linux.lock
# -r requirements/base.txt
@@ -303,7 +305,7 @@ more-itertools==11.1.0
# jaraco-text
moto==5.2.2
# via -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/pkg/py3.11/linux.lock
# -r requirements/base.txt
@@ -409,7 +411,7 @@ psutil==7.2.2
# pytest-system-statistics
py-cpuinfo==9.0.0
# via pytest-benchmark
-pyasn1==0.6.3
+pyasn1==0.6.4
# via
# -c requirements/static/pkg/py3.11/linux.lock
# -r requirements/base.txt
@@ -445,7 +447,7 @@ pynacl==1.6.2
# via
# -r requirements/static/ci/common.txt
# paramiko
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/pkg/py3.11/linux.lock
# -r requirements/base.txt
@@ -544,7 +546,7 @@ pyzmq==27.1.0
# pytest-salt-factories
redis==7.4.0
# via -r requirements/static/ci/linux.txt
-requests==2.33.1
+requests==2.34.2
# via
# -c requirements/static/pkg/py3.11/linux.lock
# -r requirements/base.txt
@@ -559,7 +561,6 @@ requests==2.33.1
# responses
# twilio
# vcert
- # vultr
requests-oauthlib==2.0.0
# via kubernetes
resolvelib==1.0.1
@@ -574,10 +575,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
@@ -684,10 +681,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-lint.lock b/requirements/static/ci/py3.11/windows-lint.lock
new file mode 100644
index 000000000000..17a261354e4f
--- /dev/null
+++ b/requirements/static/ci/py3.11/windows-lint.lock
@@ -0,0 +1,67 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements/static/ci/lint.txt --python-platform=windows --python-version=3.11 --constraint requirements/constraints.txt --no-emit-index-url --unsafe-package=setuptools -c=requirements/static/ci/py3.11/windows.lock -c=requirements/static/pkg/py3.11/windows.lock -o=requirements/static/ci/py3.11/windows-lint.lock
+astroid==3.1.0
+ # via pylint
+certifi==2026.6.17
+ # via
+ # -c requirements/static/ci/py3.11/windows.lock
+ # -c requirements/static/pkg/py3.11/windows.lock
+ # requests
+charset-normalizer==3.5.1
+ # via
+ # -c requirements/static/ci/py3.11/windows.lock
+ # -c requirements/static/pkg/py3.11/windows.lock
+ # requests
+colorama==0.4.6
+ # via
+ # -c requirements/static/ci/py3.11/windows.lock
+ # -c requirements/static/pkg/py3.11/windows.lock
+ # pylint
+dill==0.4.1
+ # via pylint
+docker==7.1.0
+ # via
+ # -c requirements/static/ci/py3.11/windows.lock
+ # -r requirements/static/ci/lint.txt
+idna==3.18
+ # via
+ # -c requirements/static/ci/py3.11/windows.lock
+ # -c requirements/static/pkg/py3.11/windows.lock
+ # requests
+isort==5.13.2
+ # via pylint
+mccabe==0.7.0
+ # via pylint
+platformdirs==4.9.2
+ # via
+ # -c requirements/static/ci/py3.11/windows.lock
+ # -c requirements/static/pkg/py3.11/windows.lock
+ # pylint
+pylint==3.1.1
+ # via
+ # -r requirements/static/ci/lint.txt
+ # saltpylint
+pywin32==312
+ # via
+ # -c requirements/static/ci/py3.11/windows.lock
+ # -c requirements/static/pkg/py3.11/windows.lock
+ # docker
+requests==2.34.2
+ # via
+ # -c requirements/static/ci/py3.11/windows.lock
+ # -c requirements/static/pkg/py3.11/windows.lock
+ # docker
+saltpylint==2024.2.5
+ # via -r requirements/static/ci/lint.txt
+toml==0.10.2
+ # via
+ # -c requirements/static/ci/py3.11/windows.lock
+ # -r requirements/static/ci/lint.txt
+tomlkit==0.15.1
+ # via pylint
+urllib3==2.7.0
+ # via
+ # -c requirements/static/ci/py3.11/windows.lock
+ # -c requirements/static/pkg/py3.11/windows.lock
+ # docker
+ # requests
diff --git a/requirements/static/ci/py3.11/windows.lock b/requirements/static/ci/py3.11/windows.lock
index c86d3856b1f8..6efa5e0e7fda 100644
--- a/requirements/static/ci/py3.11/windows.lock
+++ b/requirements/static/ci/py3.11/windows.lock
@@ -23,9 +23,10 @@ apache-libcloud==3.9.1
# via
# -c requirements/static/pkg/py3.11/windows.lock
# -r requirements/base.txt
-attrs==25.4.0
+attrs==26.1.0
# via
# -c requirements/static/pkg/py3.11/windows.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -67,9 +68,10 @@ cffi==2.0.0
# cryptography
# pygit2
# pynacl
-charset-normalizer==3.4.4
+charset-normalizer==3.5.1
# via
# -c requirements/static/pkg/py3.11/windows.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via -r requirements/static/ci/common.txt
@@ -98,7 +100,7 @@ colorama==0.4.6
# -c requirements/static/pkg/py3.11/windows.lock
# click
# pytest
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/pkg/py3.11/windows.lock
# -r requirements/base.txt
@@ -151,7 +153,7 @@ gitdb==4.0.12
# via
# -c requirements/static/pkg/py3.11/windows.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/pkg/py3.11/windows.lock
# -r requirements/base.txt
@@ -261,7 +263,7 @@ more-itertools==11.1.0
# jaraco-text
moto==5.2.2
# via -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/pkg/py3.11/windows.lock
# -r requirements/base.txt
@@ -354,7 +356,7 @@ psutil==7.2.2
# pytest-system-statistics
py-cpuinfo==9.0.0
# via pytest-benchmark
-pyasn1==0.6.3
+pyasn1==0.6.4
# via
# -c requirements/static/pkg/py3.11/windows.lock
# -r requirements/base.txt
@@ -384,7 +386,7 @@ pymssql==2.3.11
# -r requirements/base.txt
pynacl==1.6.2
# via -r requirements/static/ci/common.txt
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/pkg/py3.11/windows.lock
# -r requirements/base.txt
@@ -482,7 +484,7 @@ pyzmq==27.1.0
# -c requirements/static/pkg/py3.11/windows.lock
# -r requirements/zeromq.txt
# pytest-salt-factories
-requests==2.33.1
+requests==2.34.2
# via
# -c requirements/static/pkg/py3.11/windows.lock
# -r requirements/base.txt
@@ -496,7 +498,6 @@ requests==2.33.1
# requests-ntlm
# requests-oauthlib
# responses
- # vultr
requests-ntlm==1.3.0
# via pywinrm
requests-oauthlib==2.0.0
@@ -601,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.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 c41e3e29b2ae..0f43b4bfc0fa 100644
--- a/requirements/static/ci/py3.12/cloud.lock
+++ b/requirements/static/ci/py3.12/cloud.lock
@@ -35,10 +35,11 @@ asn1crypto==1.5.1
# -c requirements/static/ci/py3.12/linux.lock
# certvalidator
# oscrypto
-attrs==23.2.0
+attrs==26.1.0
# via
# -c requirements/static/ci/py3.12/linux.lock
# -c requirements/static/pkg/py3.12/linux.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -86,10 +87,11 @@ cffi==2.0.0
# -r requirements/static/ci/common.txt
# cryptography
# pynacl
-charset-normalizer==3.2.0
+charset-normalizer==3.5.1
# via
# -c requirements/static/ci/py3.12/linux.lock
# -c requirements/static/pkg/py3.12/linux.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via
@@ -118,7 +120,7 @@ croniter==6.2.2
# -c requirements/static/ci/py3.12/linux.lock
# -c requirements/static/pkg/py3.12/linux.lock
# -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/ci/py3.12/linux.lock
# -c requirements/static/pkg/py3.12/linux.lock
@@ -191,7 +193,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.60
# via
# -c requirements/static/ci/py3.12/linux.lock
# -c requirements/static/pkg/py3.12/linux.lock
@@ -364,7 +366,7 @@ moto==5.2.2
# via
# -c requirements/static/ci/py3.12/linux.lock
# -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/ci/py3.12/linux.lock
# -c requirements/static/pkg/py3.12/linux.lock
@@ -496,7 +498,7 @@ py-cpuinfo==9.0.0
# via
# -c requirements/static/ci/py3.12/linux.lock
# pytest-benchmark
-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
@@ -534,7 +536,7 @@ pynacl==1.6.2
# -c requirements/static/ci/py3.12/linux.lock
# -r requirements/static/ci/common.txt
# paramiko
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/ci/py3.12/linux.lock
# -c requirements/static/pkg/py3.12/linux.lock
@@ -673,7 +675,7 @@ referencing==0.37.0
# -c requirements/static/ci/py3.12/linux.lock
# jsonschema
# jsonschema-specifications
-requests==2.33.1
+requests==2.34.2
# via
# -c requirements/static/ci/py3.12/linux.lock
# -c requirements/static/pkg/py3.12/linux.lock
@@ -690,7 +692,6 @@ requests==2.33.1
# requests-oauthlib
# responses
# vcert
- # vultr
requests-ntlm==1.2.0
# via pywinrm
requests-oauthlib==2.0.0
@@ -719,12 +720,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
@@ -865,11 +860,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-lint.lock b/requirements/static/ci/py3.12/darwin-lint.lock
new file mode 100644
index 000000000000..5fca6aa0e2d5
--- /dev/null
+++ b/requirements/static/ci/py3.12/darwin-lint.lock
@@ -0,0 +1,57 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements/static/ci/lint.txt --python-platform=macos --python-version=3.12 --constraint requirements/constraints.txt --no-emit-index-url --unsafe-package=setuptools -c=requirements/static/ci/py3.12/darwin.lock -c=requirements/static/pkg/py3.12/darwin.lock -o=requirements/static/ci/py3.12/darwin-lint.lock
+astroid==3.1.0
+ # via pylint
+certifi==2026.6.17
+ # via
+ # -c requirements/static/ci/py3.12/darwin.lock
+ # -c requirements/static/pkg/py3.12/darwin.lock
+ # requests
+charset-normalizer==3.5.1
+ # via
+ # -c requirements/static/ci/py3.12/darwin.lock
+ # -c requirements/static/pkg/py3.12/darwin.lock
+ # requests
+dill==0.4.1
+ # via pylint
+docker==7.1.0
+ # via
+ # -c requirements/static/ci/py3.12/darwin.lock
+ # -r requirements/static/ci/lint.txt
+idna==3.18
+ # via
+ # -c requirements/static/ci/py3.12/darwin.lock
+ # -c requirements/static/pkg/py3.12/darwin.lock
+ # requests
+isort==5.13.2
+ # via pylint
+mccabe==0.7.0
+ # via pylint
+platformdirs==4.5.1
+ # via
+ # -c requirements/static/ci/py3.12/darwin.lock
+ # -c requirements/static/pkg/py3.12/darwin.lock
+ # pylint
+pylint==3.1.1
+ # via
+ # -r requirements/static/ci/lint.txt
+ # saltpylint
+requests==2.34.2
+ # via
+ # -c requirements/static/ci/py3.12/darwin.lock
+ # -c requirements/static/pkg/py3.12/darwin.lock
+ # docker
+saltpylint==2024.2.5
+ # via -r requirements/static/ci/lint.txt
+toml==0.10.2
+ # via
+ # -c requirements/static/ci/py3.12/darwin.lock
+ # -r requirements/static/ci/lint.txt
+tomlkit==0.15.1
+ # via pylint
+urllib3==2.7.0
+ # via
+ # -c requirements/static/ci/py3.12/darwin.lock
+ # -c requirements/static/pkg/py3.12/darwin.lock
+ # docker
+ # requests
diff --git a/requirements/static/ci/py3.12/darwin.lock b/requirements/static/ci/py3.12/darwin.lock
index 8a487ee36fb9..3aa3848aff30 100644
--- a/requirements/static/ci/py3.12/darwin.lock
+++ b/requirements/static/ci/py3.12/darwin.lock
@@ -29,9 +29,10 @@ asn1crypto==1.5.1
# via
# certvalidator
# oscrypto
-attrs==23.2.0
+attrs==26.1.0
# via
# -c requirements/static/pkg/py3.12/darwin.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -71,9 +72,10 @@ cffi==2.0.0
# cryptography
# pygit2
# pynacl
-charset-normalizer==3.2.0
+charset-normalizer==3.5.1
# via
# -c requirements/static/pkg/py3.12/darwin.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via -r requirements/static/ci/common.txt
@@ -93,7 +95,7 @@ croniter==6.2.2
# via
# -c requirements/static/pkg/py3.12/darwin.lock
# -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/pkg/py3.12/darwin.lock
# -r requirements/base.txt
@@ -144,7 +146,7 @@ gitdb==4.0.12
# via
# -c requirements/static/pkg/py3.12/darwin.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/pkg/py3.12/darwin.lock
# -r requirements/base.txt
@@ -265,7 +267,7 @@ more-itertools==11.1.0
# jaraco-text
moto==5.2.2
# via -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/pkg/py3.12/darwin.lock
# -r requirements/base.txt
@@ -366,7 +368,7 @@ psutil==7.2.2
# pytest-system-statistics
py-cpuinfo==9.0.0
# via pytest-benchmark
-pyasn1==0.6.3
+pyasn1==0.6.4
# via
# -c requirements/static/pkg/py3.12/darwin.lock
# -r requirements/base.txt
@@ -394,7 +396,7 @@ pynacl==1.6.2
# via
# -r requirements/static/ci/common.txt
# paramiko
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/pkg/py3.12/darwin.lock
# -r requirements/base.txt
@@ -488,7 +490,7 @@ referencing==0.37.0
# via
# jsonschema
# jsonschema-specifications
-requests==2.33.1
+requests==2.34.2
# via
# -c requirements/static/pkg/py3.12/darwin.lock
# -r requirements/base.txt
@@ -501,7 +503,6 @@ requests==2.33.1
# requests-oauthlib
# responses
# vcert
- # vultr
requests-oauthlib==2.0.0
# via kubernetes
responses==0.25.8
@@ -613,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.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 1e5f96befdc8..6e26ff9080dc 100644
--- a/requirements/static/ci/py3.12/docs.lock
+++ b/requirements/static/ci/py3.12/docs.lock
@@ -24,9 +24,10 @@ apache-libcloud==3.9.1
# via
# -c requirements/static/ci/py3.12/linux.lock
# -r requirements/base.txt
-attrs==23.2.0
+attrs==26.1.0
# via
# -c requirements/static/ci/py3.12/linux.lock
+ # -r requirements/base.txt
# aiohttp
babel==2.18.0
# via
@@ -44,9 +45,10 @@ cffi==2.0.0
# -c requirements/static/ci/py3.12/linux.lock
# -r requirements/base.txt
# cryptography
-charset-normalizer==3.2.0
+charset-normalizer==3.5.1
# via
# -c requirements/static/ci/py3.12/linux.lock
+ # -r requirements/base.txt
# requests
cheroot==11.1.2
# via
@@ -62,7 +64,7 @@ croniter==6.2.2
# via
# -c requirements/static/ci/py3.12/linux.lock
# -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/ci/py3.12/linux.lock
# -r requirements/base.txt
@@ -95,7 +97,7 @@ gitdb==4.0.12
# via
# -c requirements/static/ci/py3.12/linux.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/ci/py3.12/linux.lock
# -r requirements/base.txt
@@ -179,7 +181,7 @@ more-itertools==11.1.0
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/ci/py3.12/linux.lock
# -r requirements/base.txt
@@ -258,7 +260,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
@@ -283,7 +285,7 @@ pygments==2.20.0
# pydata-sphinx-theme
# rich
# sphinx
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/ci/py3.12/linux.lock
# -r requirements/base.txt
@@ -313,24 +315,19 @@ pyzmq==27.1.0
# via
# -c requirements/static/ci/py3.12/linux.lock
# -r requirements/zeromq.txt
-requests==2.33.1
+requests==2.34.2
# via
# -c requirements/static/ci/py3.12/linux.lock
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
# sphinx
- # vultr
rich==15.0.0
# via
# -c requirements/static/ci/py3.12/linux.lock
# 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
@@ -420,10 +417,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
xxhash==3.7.0
# via
# -c requirements/static/ci/py3.12/linux.lock
diff --git a/requirements/static/ci/py3.12/freebsd-lint.lock b/requirements/static/ci/py3.12/freebsd-lint.lock
new file mode 100644
index 000000000000..f16d0229d52b
--- /dev/null
+++ b/requirements/static/ci/py3.12/freebsd-lint.lock
@@ -0,0 +1,67 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements/static/ci/lint.txt --universal --python-version=3.12 --constraint requirements/constraints.txt --no-emit-index-url --unsafe-package=setuptools -c=requirements/static/ci/py3.12/freebsd.lock -c=requirements/static/pkg/py3.12/freebsd.lock -o=requirements/static/ci/py3.12/freebsd-lint.lock
+astroid==3.1.0
+ # via pylint
+certifi==2026.6.17
+ # via
+ # -c requirements/static/ci/py3.12/freebsd.lock
+ # -c requirements/static/pkg/py3.12/freebsd.lock
+ # requests
+charset-normalizer==3.5.1
+ # via
+ # -c requirements/static/ci/py3.12/freebsd.lock
+ # -c requirements/static/pkg/py3.12/freebsd.lock
+ # requests
+colorama==0.4.6 ; sys_platform == 'win32'
+ # via
+ # -c requirements/static/ci/py3.12/freebsd.lock
+ # -c requirements/static/pkg/py3.12/freebsd.lock
+ # pylint
+dill==0.4.1
+ # via pylint
+docker==7.1.0
+ # via
+ # -c requirements/static/ci/py3.12/freebsd.lock
+ # -r requirements/static/ci/lint.txt
+idna==3.18
+ # via
+ # -c requirements/static/ci/py3.12/freebsd.lock
+ # -c requirements/static/pkg/py3.12/freebsd.lock
+ # requests
+isort==5.13.2
+ # via pylint
+mccabe==0.7.0
+ # via pylint
+platformdirs==4.5.1
+ # via
+ # -c requirements/static/ci/py3.12/freebsd.lock
+ # -c requirements/static/pkg/py3.12/freebsd.lock
+ # pylint
+pylint==3.1.1
+ # via
+ # -r requirements/static/ci/lint.txt
+ # saltpylint
+pywin32==312 ; sys_platform == 'win32'
+ # via
+ # -c requirements/static/ci/py3.12/freebsd.lock
+ # -c requirements/static/pkg/py3.12/freebsd.lock
+ # docker
+requests==2.34.2
+ # via
+ # -c requirements/static/ci/py3.12/freebsd.lock
+ # -c requirements/static/pkg/py3.12/freebsd.lock
+ # docker
+saltpylint==2024.2.5
+ # via -r requirements/static/ci/lint.txt
+toml==0.10.2
+ # via
+ # -c requirements/static/ci/py3.12/freebsd.lock
+ # -r requirements/static/ci/lint.txt
+tomlkit==0.15.1
+ # via pylint
+urllib3==2.7.0
+ # via
+ # -c requirements/static/ci/py3.12/freebsd.lock
+ # -c requirements/static/pkg/py3.12/freebsd.lock
+ # docker
+ # requests
diff --git a/requirements/static/ci/py3.12/freebsd.lock b/requirements/static/ci/py3.12/freebsd.lock
index 40f9da21a9d8..a85c34ba6f84 100644
--- a/requirements/static/ci/py3.12/freebsd.lock
+++ b/requirements/static/ci/py3.12/freebsd.lock
@@ -28,9 +28,10 @@ asn1crypto==1.5.1 ; sys_platform != 'win32'
# via
# certvalidator
# oscrypto
-attrs==23.2.0
+attrs==26.1.0
# via
# -c requirements/static/pkg/py3.12/freebsd.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -72,9 +73,10 @@ cffi==2.0.0
# cryptography
# pynacl
# pyzmq
-charset-normalizer==3.2.0
+charset-normalizer==3.5.1
# via
# -c requirements/static/pkg/py3.12/freebsd.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via -r requirements/static/ci/common.txt
@@ -105,7 +107,7 @@ croniter==6.2.2 ; sys_platform != 'win32'
# via
# -c requirements/static/pkg/py3.12/freebsd.lock
# -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/pkg/py3.12/freebsd.lock
# -r requirements/base.txt
@@ -159,7 +161,7 @@ gitdb==4.0.12
# via
# -c requirements/static/pkg/py3.12/freebsd.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/pkg/py3.12/freebsd.lock
# -r requirements/base.txt
@@ -296,7 +298,7 @@ more-itertools==11.1.0
# jaraco-text
moto==5.2.2
# via -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/pkg/py3.12/freebsd.lock
# -r requirements/base.txt
@@ -403,7 +405,7 @@ psutil==7.2.2
# pytest-system-statistics
py-cpuinfo==9.0.0
# via pytest-benchmark
-pyasn1==0.6.3
+pyasn1==0.6.4
# via
# -c requirements/static/pkg/py3.12/freebsd.lock
# -r requirements/base.txt
@@ -436,7 +438,7 @@ pynacl==1.6.2
# via
# -r requirements/static/ci/common.txt
# paramiko
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/pkg/py3.12/freebsd.lock
# -r requirements/base.txt
@@ -546,7 +548,7 @@ referencing==0.37.0
# via
# jsonschema
# jsonschema-specifications
-requests==2.33.1
+requests==2.34.2
# via
# -c requirements/static/pkg/py3.12/freebsd.lock
# -r requirements/base.txt
@@ -559,7 +561,6 @@ requests==2.33.1
# requests-oauthlib
# responses
# vcert
- # vultr
requests-oauthlib==2.0.0
# via kubernetes
responses==0.25.8
@@ -576,10 +577,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'
@@ -679,10 +676,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 bce75f346df6..83e774457baf 100644
--- a/requirements/static/ci/py3.12/lint.lock
+++ b/requirements/static/ci/py3.12/lint.lock
@@ -666,7 +666,6 @@ requests==2.33.1
# responses
# twilio
# vcert
- # vultr
requests-oauthlib==2.0.0
# via
# -c requirements/static/ci/py3.12/linux.lock
@@ -697,12 +696,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
@@ -853,11 +846,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-lint.lock b/requirements/static/ci/py3.12/linux-lint.lock
new file mode 100644
index 000000000000..1fc267a585b0
--- /dev/null
+++ b/requirements/static/ci/py3.12/linux-lint.lock
@@ -0,0 +1,57 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements/static/ci/lint.txt --python-platform=linux --python-version=3.12 --constraint requirements/constraints.txt --no-emit-index-url --unsafe-package=setuptools -c=requirements/static/ci/py3.12/linux.lock -c=requirements/static/pkg/py3.12/linux.lock -o=requirements/static/ci/py3.12/linux-lint.lock
+astroid==3.1.0
+ # via pylint
+certifi==2026.6.17
+ # via
+ # -c requirements/static/ci/py3.12/linux.lock
+ # -c requirements/static/pkg/py3.12/linux.lock
+ # requests
+charset-normalizer==3.5.1
+ # via
+ # -c requirements/static/ci/py3.12/linux.lock
+ # -c requirements/static/pkg/py3.12/linux.lock
+ # requests
+dill==0.4.1
+ # via pylint
+docker==7.1.0
+ # via
+ # -c requirements/static/ci/py3.12/linux.lock
+ # -r requirements/static/ci/lint.txt
+idna==3.18
+ # via
+ # -c requirements/static/ci/py3.12/linux.lock
+ # -c requirements/static/pkg/py3.12/linux.lock
+ # requests
+isort==5.13.2
+ # via pylint
+mccabe==0.7.0
+ # via pylint
+platformdirs==4.5.1
+ # via
+ # -c requirements/static/ci/py3.12/linux.lock
+ # -c requirements/static/pkg/py3.12/linux.lock
+ # pylint
+pylint==3.1.1
+ # via
+ # -r requirements/static/ci/lint.txt
+ # saltpylint
+requests==2.34.2
+ # via
+ # -c requirements/static/ci/py3.12/linux.lock
+ # -c requirements/static/pkg/py3.12/linux.lock
+ # docker
+saltpylint==2024.2.5
+ # via -r requirements/static/ci/lint.txt
+toml==0.10.2
+ # via
+ # -c requirements/static/ci/py3.12/linux.lock
+ # -r requirements/static/ci/lint.txt
+tomlkit==0.15.1
+ # via pylint
+urllib3==2.7.0
+ # via
+ # -c requirements/static/ci/py3.12/linux.lock
+ # -c requirements/static/pkg/py3.12/linux.lock
+ # docker
+ # requests
diff --git a/requirements/static/ci/py3.12/linux.lock b/requirements/static/ci/py3.12/linux.lock
index c3e67027d620..571aaafea3a8 100644
--- a/requirements/static/ci/py3.12/linux.lock
+++ b/requirements/static/ci/py3.12/linux.lock
@@ -38,9 +38,10 @@ asn1crypto==1.5.1
# via
# certvalidator
# oscrypto
-attrs==23.2.0
+attrs==26.1.0
# via
# -c requirements/static/pkg/py3.12/linux.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -82,9 +83,10 @@ cffi==2.0.0
# cryptography
# pygit2
# pynacl
-charset-normalizer==3.2.0
+charset-normalizer==3.5.1
# via
# -c requirements/static/pkg/py3.12/linux.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via -r requirements/static/ci/common.txt
@@ -104,7 +106,7 @@ croniter==6.2.2
# via
# -c requirements/static/pkg/py3.12/linux.lock
# -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/pkg/py3.12/linux.lock
# -r requirements/base.txt
@@ -157,7 +159,7 @@ gitdb==4.0.12
# via
# -c requirements/static/pkg/py3.12/linux.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/pkg/py3.12/linux.lock
# -r requirements/base.txt
@@ -294,7 +296,7 @@ more-itertools==11.1.0
# jaraco-text
moto==5.2.2
# via -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/pkg/py3.12/linux.lock
# -r requirements/base.txt
@@ -397,7 +399,7 @@ psutil==7.2.2
# pytest-system-statistics
py-cpuinfo==9.0.0
# via pytest-benchmark
-pyasn1==0.6.3
+pyasn1==0.6.4
# via
# -c requirements/static/pkg/py3.12/linux.lock
# -r requirements/base.txt
@@ -433,7 +435,7 @@ pynacl==1.6.2
# via
# -r requirements/static/ci/common.txt
# paramiko
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/pkg/py3.12/linux.lock
# -r requirements/base.txt
@@ -534,7 +536,7 @@ referencing==0.37.0
# via
# jsonschema
# jsonschema-specifications
-requests==2.33.1
+requests==2.34.2
# via
# -c requirements/static/pkg/py3.12/linux.lock
# -r requirements/base.txt
@@ -549,7 +551,6 @@ requests==2.33.1
# responses
# twilio
# vcert
- # vultr
requests-oauthlib==2.0.0
# via kubernetes
resolvelib==1.0.1
@@ -568,10 +569,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
@@ -678,10 +675,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-lint.lock b/requirements/static/ci/py3.12/windows-lint.lock
new file mode 100644
index 000000000000..386e35839047
--- /dev/null
+++ b/requirements/static/ci/py3.12/windows-lint.lock
@@ -0,0 +1,67 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements/static/ci/lint.txt --python-platform=windows --python-version=3.12 --constraint requirements/constraints.txt --no-emit-index-url --unsafe-package=setuptools -c=requirements/static/ci/py3.12/windows.lock -c=requirements/static/pkg/py3.12/windows.lock -o=requirements/static/ci/py3.12/windows-lint.lock
+astroid==3.1.0
+ # via pylint
+certifi==2026.6.17
+ # via
+ # -c requirements/static/ci/py3.12/windows.lock
+ # -c requirements/static/pkg/py3.12/windows.lock
+ # requests
+charset-normalizer==3.5.1
+ # via
+ # -c requirements/static/ci/py3.12/windows.lock
+ # -c requirements/static/pkg/py3.12/windows.lock
+ # requests
+colorama==0.4.6
+ # via
+ # -c requirements/static/ci/py3.12/windows.lock
+ # -c requirements/static/pkg/py3.12/windows.lock
+ # pylint
+dill==0.4.1
+ # via pylint
+docker==7.1.0
+ # via
+ # -c requirements/static/ci/py3.12/windows.lock
+ # -r requirements/static/ci/lint.txt
+idna==3.18
+ # via
+ # -c requirements/static/ci/py3.12/windows.lock
+ # -c requirements/static/pkg/py3.12/windows.lock
+ # requests
+isort==5.13.2
+ # via pylint
+mccabe==0.7.0
+ # via pylint
+platformdirs==4.9.2
+ # via
+ # -c requirements/static/ci/py3.12/windows.lock
+ # -c requirements/static/pkg/py3.12/windows.lock
+ # pylint
+pylint==3.1.1
+ # via
+ # -r requirements/static/ci/lint.txt
+ # saltpylint
+pywin32==312
+ # via
+ # -c requirements/static/ci/py3.12/windows.lock
+ # -c requirements/static/pkg/py3.12/windows.lock
+ # docker
+requests==2.34.2
+ # via
+ # -c requirements/static/ci/py3.12/windows.lock
+ # -c requirements/static/pkg/py3.12/windows.lock
+ # docker
+saltpylint==2024.2.5
+ # via -r requirements/static/ci/lint.txt
+toml==0.10.2
+ # via
+ # -c requirements/static/ci/py3.12/windows.lock
+ # -r requirements/static/ci/lint.txt
+tomlkit==0.15.1
+ # via pylint
+urllib3==2.7.0
+ # via
+ # -c requirements/static/ci/py3.12/windows.lock
+ # -c requirements/static/pkg/py3.12/windows.lock
+ # docker
+ # requests
diff --git a/requirements/static/ci/py3.12/windows.lock b/requirements/static/ci/py3.12/windows.lock
index b8f82dc0ec66..fc35b7bf878e 100644
--- a/requirements/static/ci/py3.12/windows.lock
+++ b/requirements/static/ci/py3.12/windows.lock
@@ -23,9 +23,10 @@ apache-libcloud==3.9.1
# via
# -c requirements/static/pkg/py3.12/windows.lock
# -r requirements/base.txt
-attrs==25.4.0
+attrs==26.1.0
# via
# -c requirements/static/pkg/py3.12/windows.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -62,9 +63,10 @@ cffi==2.0.0
# cryptography
# pygit2
# pynacl
-charset-normalizer==3.4.4
+charset-normalizer==3.5.1
# via
# -c requirements/static/pkg/py3.12/windows.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via -r requirements/static/ci/common.txt
@@ -93,7 +95,7 @@ colorama==0.4.6
# -c requirements/static/pkg/py3.12/windows.lock
# click
# pytest
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/pkg/py3.12/windows.lock
# -r requirements/base.txt
@@ -146,7 +148,7 @@ gitdb==4.0.12
# via
# -c requirements/static/pkg/py3.12/windows.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/pkg/py3.12/windows.lock
# -r requirements/base.txt
@@ -255,7 +257,7 @@ more-itertools==11.1.0
# jaraco-text
moto==5.2.2
# via -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/pkg/py3.12/windows.lock
# -r requirements/base.txt
@@ -348,7 +350,7 @@ psutil==7.2.2
# pytest-system-statistics
py-cpuinfo==9.0.0
# via pytest-benchmark
-pyasn1==0.6.3
+pyasn1==0.6.4
# via
# -c requirements/static/pkg/py3.12/windows.lock
# -r requirements/base.txt
@@ -378,7 +380,7 @@ pymssql==2.3.11
# -r requirements/base.txt
pynacl==1.6.2
# via -r requirements/static/ci/common.txt
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/pkg/py3.12/windows.lock
# -r requirements/base.txt
@@ -478,7 +480,7 @@ referencing==0.37.0
# via
# jsonschema
# jsonschema-specifications
-requests==2.33.1
+requests==2.34.2
# via
# -c requirements/static/pkg/py3.12/windows.lock
# -r requirements/base.txt
@@ -492,7 +494,6 @@ requests==2.33.1
# requests-ntlm
# requests-oauthlib
# responses
- # vultr
requests-ntlm==1.3.0
# via pywinrm
requests-oauthlib==2.0.0
@@ -601,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.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 6b6a32fc123a..6791e65fc151 100644
--- a/requirements/static/ci/py3.13/cloud.lock
+++ b/requirements/static/ci/py3.13/cloud.lock
@@ -35,10 +35,11 @@ asn1crypto==1.5.1
# -c requirements/static/ci/py3.13/linux.lock
# certvalidator
# oscrypto
-attrs==25.4.0
+attrs==26.1.0
# via
# -c requirements/static/ci/py3.13/linux.lock
# -c requirements/static/pkg/py3.13/linux.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -87,10 +88,11 @@ cffi==2.0.0
# -r requirements/static/ci/common.txt
# cryptography
# pynacl
-charset-normalizer==3.4.4
+charset-normalizer==3.5.1
# via
# -c requirements/static/ci/py3.13/linux.lock
# -c requirements/static/pkg/py3.13/linux.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via
@@ -119,7 +121,7 @@ croniter==6.2.2
# -c requirements/static/ci/py3.13/linux.lock
# -c requirements/static/pkg/py3.13/linux.lock
# -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/ci/py3.13/linux.lock
# -c requirements/static/pkg/py3.13/linux.lock
@@ -192,7 +194,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.60
# via
# -c requirements/static/ci/py3.13/linux.lock
# -c requirements/static/pkg/py3.13/linux.lock
@@ -365,7 +367,7 @@ moto==5.2.2
# via
# -c requirements/static/ci/py3.13/linux.lock
# -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/ci/py3.13/linux.lock
# -c requirements/static/pkg/py3.13/linux.lock
@@ -497,7 +499,7 @@ py-cpuinfo==9.0.0
# via
# -c requirements/static/ci/py3.13/linux.lock
# pytest-benchmark
-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
@@ -535,7 +537,7 @@ pynacl==1.6.2
# -c requirements/static/ci/py3.13/linux.lock
# -r requirements/static/ci/common.txt
# paramiko
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/ci/py3.13/linux.lock
# -c requirements/static/pkg/py3.13/linux.lock
@@ -670,7 +672,7 @@ referencing==0.37.0
# -c requirements/static/ci/py3.13/linux.lock
# jsonschema
# jsonschema-specifications
-requests==2.33.1
+requests==2.34.2
# via
# -c requirements/static/ci/py3.13/linux.lock
# -c requirements/static/pkg/py3.13/linux.lock
@@ -687,7 +689,6 @@ requests==2.33.1
# requests-oauthlib
# responses
# vcert
- # vultr
requests-ntlm==1.3.0
# via pywinrm
requests-oauthlib==2.0.0
@@ -716,12 +717,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
@@ -857,11 +852,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-lint.lock b/requirements/static/ci/py3.13/darwin-lint.lock
new file mode 100644
index 000000000000..d1dcd4f87218
--- /dev/null
+++ b/requirements/static/ci/py3.13/darwin-lint.lock
@@ -0,0 +1,57 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements/static/ci/lint.txt --python-platform=macos --python-version=3.13 --constraint requirements/constraints.txt --no-emit-index-url --unsafe-package=setuptools -c=requirements/static/ci/py3.13/darwin.lock -c=requirements/static/pkg/py3.13/darwin.lock -o=requirements/static/ci/py3.13/darwin-lint.lock
+astroid==3.1.0
+ # via pylint
+certifi==2026.6.17
+ # via
+ # -c requirements/static/ci/py3.13/darwin.lock
+ # -c requirements/static/pkg/py3.13/darwin.lock
+ # requests
+charset-normalizer==3.5.1
+ # via
+ # -c requirements/static/ci/py3.13/darwin.lock
+ # -c requirements/static/pkg/py3.13/darwin.lock
+ # requests
+dill==0.4.1
+ # via pylint
+docker==7.1.0
+ # via
+ # -c requirements/static/ci/py3.13/darwin.lock
+ # -r requirements/static/ci/lint.txt
+idna==3.18
+ # via
+ # -c requirements/static/ci/py3.13/darwin.lock
+ # -c requirements/static/pkg/py3.13/darwin.lock
+ # requests
+isort==5.13.2
+ # via pylint
+mccabe==0.7.0
+ # via pylint
+platformdirs==4.5.1
+ # via
+ # -c requirements/static/ci/py3.13/darwin.lock
+ # -c requirements/static/pkg/py3.13/darwin.lock
+ # pylint
+pylint==3.1.1
+ # via
+ # -r requirements/static/ci/lint.txt
+ # saltpylint
+requests==2.34.2
+ # via
+ # -c requirements/static/ci/py3.13/darwin.lock
+ # -c requirements/static/pkg/py3.13/darwin.lock
+ # docker
+saltpylint==2024.2.5
+ # via -r requirements/static/ci/lint.txt
+toml==0.10.2
+ # via
+ # -c requirements/static/ci/py3.13/darwin.lock
+ # -r requirements/static/ci/lint.txt
+tomlkit==0.15.1
+ # via pylint
+urllib3==2.7.0
+ # via
+ # -c requirements/static/ci/py3.13/darwin.lock
+ # -c requirements/static/pkg/py3.13/darwin.lock
+ # docker
+ # requests
diff --git a/requirements/static/ci/py3.13/darwin.lock b/requirements/static/ci/py3.13/darwin.lock
index 0d075a21aa09..3d4f71a52ad4 100644
--- a/requirements/static/ci/py3.13/darwin.lock
+++ b/requirements/static/ci/py3.13/darwin.lock
@@ -29,9 +29,10 @@ asn1crypto==1.5.1
# via
# certvalidator
# oscrypto
-attrs==25.4.0
+attrs==26.1.0
# via
# -c requirements/static/pkg/py3.13/darwin.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -72,9 +73,10 @@ cffi==2.0.0
# cryptography
# pygit2
# pynacl
-charset-normalizer==3.4.4
+charset-normalizer==3.5.1
# via
# -c requirements/static/pkg/py3.13/darwin.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via -r requirements/static/ci/common.txt
@@ -94,7 +96,7 @@ croniter==6.2.2
# via
# -c requirements/static/pkg/py3.13/darwin.lock
# -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/pkg/py3.13/darwin.lock
# -r requirements/base.txt
@@ -145,7 +147,7 @@ gitdb==4.0.12
# via
# -c requirements/static/pkg/py3.13/darwin.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/pkg/py3.13/darwin.lock
# -r requirements/base.txt
@@ -266,7 +268,7 @@ more-itertools==11.1.0
# jaraco-text
moto==5.2.2
# via -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/pkg/py3.13/darwin.lock
# -r requirements/base.txt
@@ -367,7 +369,7 @@ psutil==7.2.2
# pytest-system-statistics
py-cpuinfo==9.0.0
# via pytest-benchmark
-pyasn1==0.6.3
+pyasn1==0.6.4
# via
# -c requirements/static/pkg/py3.13/darwin.lock
# -r requirements/base.txt
@@ -395,7 +397,7 @@ pynacl==1.6.2
# via
# -r requirements/static/ci/common.txt
# paramiko
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/pkg/py3.13/darwin.lock
# -r requirements/base.txt
@@ -486,7 +488,7 @@ referencing==0.37.0
# via
# jsonschema
# jsonschema-specifications
-requests==2.33.1
+requests==2.34.2
# via
# -c requirements/static/pkg/py3.13/darwin.lock
# -r requirements/base.txt
@@ -499,7 +501,6 @@ requests==2.33.1
# requests-oauthlib
# responses
# vcert
- # vultr
requests-oauthlib==2.0.0
# via kubernetes
responses==0.25.8
@@ -607,10 +608,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 48186dfc603e..e8044d717085 100644
--- a/requirements/static/ci/py3.13/docs.lock
+++ b/requirements/static/ci/py3.13/docs.lock
@@ -24,9 +24,10 @@ apache-libcloud==3.9.1
# via
# -c requirements/static/ci/py3.13/linux.lock
# -r requirements/base.txt
-attrs==25.4.0
+attrs==26.1.0
# via
# -c requirements/static/ci/py3.13/linux.lock
+ # -r requirements/base.txt
# aiohttp
babel==2.17.0
# via
@@ -44,9 +45,10 @@ cffi==2.0.0
# -c requirements/static/ci/py3.13/linux.lock
# -r requirements/base.txt
# cryptography
-charset-normalizer==3.4.4
+charset-normalizer==3.5.1
# via
# -c requirements/static/ci/py3.13/linux.lock
+ # -r requirements/base.txt
# requests
cheroot==11.1.2
# via
@@ -62,7 +64,7 @@ croniter==6.2.2
# via
# -c requirements/static/ci/py3.13/linux.lock
# -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/ci/py3.13/linux.lock
# -r requirements/base.txt
@@ -95,7 +97,7 @@ gitdb==4.0.12
# via
# -c requirements/static/ci/py3.13/linux.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/ci/py3.13/linux.lock
# -r requirements/base.txt
@@ -179,7 +181,7 @@ more-itertools==11.1.0
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/ci/py3.13/linux.lock
# -r requirements/base.txt
@@ -258,7 +260,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
@@ -283,7 +285,7 @@ pygments==2.20.0
# pydata-sphinx-theme
# rich
# sphinx
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/ci/py3.13/linux.lock
# -r requirements/base.txt
@@ -310,7 +312,7 @@ pyzmq==27.1.0
# via
# -c requirements/static/ci/py3.13/linux.lock
# -r requirements/zeromq.txt
-requests==2.33.1
+requests==2.34.2
# via
# -c requirements/static/ci/py3.13/linux.lock
# -r requirements/base.txt
@@ -318,17 +320,12 @@ requests==2.33.1
# opentelemetry-exporter-otlp-proto-http
# sphinx
# sphinxcontrib-spelling
- # vultr
rich==15.0.0
# via
# -c requirements/static/ci/py3.13/linux.lock
# 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
@@ -415,10 +412,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
xxhash==3.7.0
# via
# -c requirements/static/ci/py3.13/linux.lock
diff --git a/requirements/static/ci/py3.13/freebsd-lint.lock b/requirements/static/ci/py3.13/freebsd-lint.lock
new file mode 100644
index 000000000000..3a0aa5d21845
--- /dev/null
+++ b/requirements/static/ci/py3.13/freebsd-lint.lock
@@ -0,0 +1,67 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements/static/ci/lint.txt --universal --python-version=3.13 --constraint requirements/constraints.txt --no-emit-index-url --unsafe-package=setuptools -c=requirements/static/ci/py3.13/freebsd.lock -c=requirements/static/pkg/py3.13/freebsd.lock -o=requirements/static/ci/py3.13/freebsd-lint.lock
+astroid==3.1.0
+ # via pylint
+certifi==2026.6.17
+ # via
+ # -c requirements/static/ci/py3.13/freebsd.lock
+ # -c requirements/static/pkg/py3.13/freebsd.lock
+ # requests
+charset-normalizer==3.5.1
+ # via
+ # -c requirements/static/ci/py3.13/freebsd.lock
+ # -c requirements/static/pkg/py3.13/freebsd.lock
+ # requests
+colorama==0.4.6 ; sys_platform == 'win32'
+ # via
+ # -c requirements/static/ci/py3.13/freebsd.lock
+ # -c requirements/static/pkg/py3.13/freebsd.lock
+ # pylint
+dill==0.4.1
+ # via pylint
+docker==7.1.0
+ # via
+ # -c requirements/static/ci/py3.13/freebsd.lock
+ # -r requirements/static/ci/lint.txt
+idna==3.18
+ # via
+ # -c requirements/static/ci/py3.13/freebsd.lock
+ # -c requirements/static/pkg/py3.13/freebsd.lock
+ # requests
+isort==5.13.2
+ # via pylint
+mccabe==0.7.0
+ # via pylint
+platformdirs==4.5.1
+ # via
+ # -c requirements/static/ci/py3.13/freebsd.lock
+ # -c requirements/static/pkg/py3.13/freebsd.lock
+ # pylint
+pylint==3.1.1
+ # via
+ # -r requirements/static/ci/lint.txt
+ # saltpylint
+pywin32==312 ; sys_platform == 'win32'
+ # via
+ # -c requirements/static/ci/py3.13/freebsd.lock
+ # -c requirements/static/pkg/py3.13/freebsd.lock
+ # docker
+requests==2.34.2
+ # via
+ # -c requirements/static/ci/py3.13/freebsd.lock
+ # -c requirements/static/pkg/py3.13/freebsd.lock
+ # docker
+saltpylint==2024.2.5
+ # via -r requirements/static/ci/lint.txt
+toml==0.10.2
+ # via
+ # -c requirements/static/ci/py3.13/freebsd.lock
+ # -r requirements/static/ci/lint.txt
+tomlkit==0.15.1
+ # via pylint
+urllib3==2.7.0
+ # via
+ # -c requirements/static/ci/py3.13/freebsd.lock
+ # -c requirements/static/pkg/py3.13/freebsd.lock
+ # docker
+ # requests
diff --git a/requirements/static/ci/py3.13/freebsd.lock b/requirements/static/ci/py3.13/freebsd.lock
index 0649953cd68c..3dc866c37453 100644
--- a/requirements/static/ci/py3.13/freebsd.lock
+++ b/requirements/static/ci/py3.13/freebsd.lock
@@ -28,9 +28,10 @@ asn1crypto==1.5.1 ; sys_platform != 'win32'
# via
# certvalidator
# oscrypto
-attrs==25.4.0
+attrs==26.1.0
# via
# -c requirements/static/pkg/py3.13/freebsd.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -73,9 +74,10 @@ cffi==2.0.0
# cryptography
# pynacl
# pyzmq
-charset-normalizer==3.4.4
+charset-normalizer==3.5.1
# via
# -c requirements/static/pkg/py3.13/freebsd.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via -r requirements/static/ci/common.txt
@@ -106,7 +108,7 @@ croniter==6.2.2 ; sys_platform != 'win32'
# via
# -c requirements/static/pkg/py3.13/freebsd.lock
# -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/pkg/py3.13/freebsd.lock
# -r requirements/base.txt
@@ -160,7 +162,7 @@ gitdb==4.0.12
# via
# -c requirements/static/pkg/py3.13/freebsd.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/pkg/py3.13/freebsd.lock
# -r requirements/base.txt
@@ -297,7 +299,7 @@ more-itertools==11.1.0
# jaraco-text
moto==5.2.2
# via -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/pkg/py3.13/freebsd.lock
# -r requirements/base.txt
@@ -404,7 +406,7 @@ psutil==7.2.2
# pytest-system-statistics
py-cpuinfo==9.0.0
# via pytest-benchmark
-pyasn1==0.6.3
+pyasn1==0.6.4
# via
# -c requirements/static/pkg/py3.13/freebsd.lock
# -r requirements/base.txt
@@ -437,7 +439,7 @@ pynacl==1.6.2
# via
# -r requirements/static/ci/common.txt
# paramiko
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/pkg/py3.13/freebsd.lock
# -r requirements/base.txt
@@ -544,7 +546,7 @@ referencing==0.37.0
# via
# jsonschema
# jsonschema-specifications
-requests==2.33.1
+requests==2.34.2
# via
# -c requirements/static/pkg/py3.13/freebsd.lock
# -r requirements/base.txt
@@ -557,7 +559,6 @@ requests==2.33.1
# requests-oauthlib
# responses
# vcert
- # vultr
requests-oauthlib==2.0.0
# via kubernetes
responses==0.25.8
@@ -574,10 +575,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'
@@ -673,10 +670,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 643f851eb0ae..0d4e67b79996 100644
--- a/requirements/static/ci/py3.13/lint.lock
+++ b/requirements/static/ci/py3.13/lint.lock
@@ -662,7 +662,6 @@ requests==2.33.1
# responses
# twilio
# vcert
- # vultr
requests-oauthlib==2.0.0
# via
# -c requirements/static/ci/py3.13/linux.lock
@@ -693,12 +692,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
@@ -841,11 +834,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-lint.lock b/requirements/static/ci/py3.13/linux-lint.lock
new file mode 100644
index 000000000000..7afc35ce2e24
--- /dev/null
+++ b/requirements/static/ci/py3.13/linux-lint.lock
@@ -0,0 +1,57 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements/static/ci/lint.txt --python-platform=linux --python-version=3.13 --constraint requirements/constraints.txt --no-emit-index-url --unsafe-package=setuptools -c=requirements/static/ci/py3.13/linux.lock -c=requirements/static/pkg/py3.13/linux.lock -o=requirements/static/ci/py3.13/linux-lint.lock
+astroid==3.1.0
+ # via pylint
+certifi==2026.6.17
+ # via
+ # -c requirements/static/ci/py3.13/linux.lock
+ # -c requirements/static/pkg/py3.13/linux.lock
+ # requests
+charset-normalizer==3.5.1
+ # via
+ # -c requirements/static/ci/py3.13/linux.lock
+ # -c requirements/static/pkg/py3.13/linux.lock
+ # requests
+dill==0.4.1
+ # via pylint
+docker==7.1.0
+ # via
+ # -c requirements/static/ci/py3.13/linux.lock
+ # -r requirements/static/ci/lint.txt
+idna==3.18
+ # via
+ # -c requirements/static/ci/py3.13/linux.lock
+ # -c requirements/static/pkg/py3.13/linux.lock
+ # requests
+isort==5.13.2
+ # via pylint
+mccabe==0.7.0
+ # via pylint
+platformdirs==4.5.1
+ # via
+ # -c requirements/static/ci/py3.13/linux.lock
+ # -c requirements/static/pkg/py3.13/linux.lock
+ # pylint
+pylint==3.1.1
+ # via
+ # -r requirements/static/ci/lint.txt
+ # saltpylint
+requests==2.34.2
+ # via
+ # -c requirements/static/ci/py3.13/linux.lock
+ # -c requirements/static/pkg/py3.13/linux.lock
+ # docker
+saltpylint==2024.2.5
+ # via -r requirements/static/ci/lint.txt
+toml==0.10.2
+ # via
+ # -c requirements/static/ci/py3.13/linux.lock
+ # -r requirements/static/ci/lint.txt
+tomlkit==0.15.1
+ # via pylint
+urllib3==2.7.0
+ # via
+ # -c requirements/static/ci/py3.13/linux.lock
+ # -c requirements/static/pkg/py3.13/linux.lock
+ # docker
+ # requests
diff --git a/requirements/static/ci/py3.13/linux.lock b/requirements/static/ci/py3.13/linux.lock
index b7639303f0df..06f38deb58e2 100644
--- a/requirements/static/ci/py3.13/linux.lock
+++ b/requirements/static/ci/py3.13/linux.lock
@@ -38,9 +38,10 @@ asn1crypto==1.5.1
# via
# certvalidator
# oscrypto
-attrs==25.4.0
+attrs==26.1.0
# via
# -c requirements/static/pkg/py3.13/linux.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -83,9 +84,10 @@ cffi==2.0.0
# cryptography
# pygit2
# pynacl
-charset-normalizer==3.4.4
+charset-normalizer==3.5.1
# via
# -c requirements/static/pkg/py3.13/linux.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via -r requirements/static/ci/common.txt
@@ -105,7 +107,7 @@ croniter==6.2.2
# via
# -c requirements/static/pkg/py3.13/linux.lock
# -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/pkg/py3.13/linux.lock
# -r requirements/base.txt
@@ -158,7 +160,7 @@ gitdb==4.0.12
# via
# -c requirements/static/pkg/py3.13/linux.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/pkg/py3.13/linux.lock
# -r requirements/base.txt
@@ -295,7 +297,7 @@ more-itertools==11.1.0
# jaraco-text
moto==5.2.2
# via -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/pkg/py3.13/linux.lock
# -r requirements/base.txt
@@ -398,7 +400,7 @@ psutil==7.2.2
# pytest-system-statistics
py-cpuinfo==9.0.0
# via pytest-benchmark
-pyasn1==0.6.3
+pyasn1==0.6.4
# via
# -c requirements/static/pkg/py3.13/linux.lock
# -r requirements/base.txt
@@ -434,7 +436,7 @@ pynacl==1.6.2
# via
# -r requirements/static/ci/common.txt
# paramiko
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/pkg/py3.13/linux.lock
# -r requirements/base.txt
@@ -532,7 +534,7 @@ referencing==0.37.0
# via
# jsonschema
# jsonschema-specifications
-requests==2.33.1
+requests==2.34.2
# via
# -c requirements/static/pkg/py3.13/linux.lock
# -r requirements/base.txt
@@ -547,7 +549,6 @@ requests==2.33.1
# responses
# twilio
# vcert
- # vultr
requests-oauthlib==2.0.0
# via kubernetes
resolvelib==1.2.1
@@ -566,10 +567,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
@@ -670,10 +667,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-lint.lock b/requirements/static/ci/py3.13/windows-lint.lock
new file mode 100644
index 000000000000..d90eb4cfdd37
--- /dev/null
+++ b/requirements/static/ci/py3.13/windows-lint.lock
@@ -0,0 +1,67 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements/static/ci/lint.txt --python-platform=windows --python-version=3.13 --constraint requirements/constraints.txt --no-emit-index-url --unsafe-package=setuptools -c=requirements/static/ci/py3.13/windows.lock -c=requirements/static/pkg/py3.13/windows.lock -o=requirements/static/ci/py3.13/windows-lint.lock
+astroid==3.1.0
+ # via pylint
+certifi==2026.6.17
+ # via
+ # -c requirements/static/ci/py3.13/windows.lock
+ # -c requirements/static/pkg/py3.13/windows.lock
+ # requests
+charset-normalizer==3.5.1
+ # via
+ # -c requirements/static/ci/py3.13/windows.lock
+ # -c requirements/static/pkg/py3.13/windows.lock
+ # requests
+colorama==0.4.6
+ # via
+ # -c requirements/static/ci/py3.13/windows.lock
+ # -c requirements/static/pkg/py3.13/windows.lock
+ # pylint
+dill==0.4.1
+ # via pylint
+docker==7.1.0
+ # via
+ # -c requirements/static/ci/py3.13/windows.lock
+ # -r requirements/static/ci/lint.txt
+idna==3.18
+ # via
+ # -c requirements/static/ci/py3.13/windows.lock
+ # -c requirements/static/pkg/py3.13/windows.lock
+ # requests
+isort==5.13.2
+ # via pylint
+mccabe==0.7.0
+ # via pylint
+platformdirs==4.9.2
+ # via
+ # -c requirements/static/ci/py3.13/windows.lock
+ # -c requirements/static/pkg/py3.13/windows.lock
+ # pylint
+pylint==3.1.1
+ # via
+ # -r requirements/static/ci/lint.txt
+ # saltpylint
+pywin32==312
+ # via
+ # -c requirements/static/ci/py3.13/windows.lock
+ # -c requirements/static/pkg/py3.13/windows.lock
+ # docker
+requests==2.34.2
+ # via
+ # -c requirements/static/ci/py3.13/windows.lock
+ # -c requirements/static/pkg/py3.13/windows.lock
+ # docker
+saltpylint==2024.2.5
+ # via -r requirements/static/ci/lint.txt
+toml==0.10.2
+ # via
+ # -c requirements/static/ci/py3.13/windows.lock
+ # -r requirements/static/ci/lint.txt
+tomlkit==0.15.1
+ # via pylint
+urllib3==2.7.0
+ # via
+ # -c requirements/static/ci/py3.13/windows.lock
+ # -c requirements/static/pkg/py3.13/windows.lock
+ # docker
+ # requests
diff --git a/requirements/static/ci/py3.13/windows.lock b/requirements/static/ci/py3.13/windows.lock
index dcdb26172358..fd758b7a8268 100644
--- a/requirements/static/ci/py3.13/windows.lock
+++ b/requirements/static/ci/py3.13/windows.lock
@@ -23,9 +23,10 @@ apache-libcloud==3.9.1
# via
# -c requirements/static/pkg/py3.13/windows.lock
# -r requirements/base.txt
-attrs==25.4.0
+attrs==26.1.0
# via
# -c requirements/static/pkg/py3.13/windows.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -63,9 +64,10 @@ cffi==2.0.0
# cryptography
# pygit2
# pynacl
-charset-normalizer==3.4.4
+charset-normalizer==3.5.1
# via
# -c requirements/static/pkg/py3.13/windows.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via -r requirements/static/ci/common.txt
@@ -94,7 +96,7 @@ colorama==0.4.6
# -c requirements/static/pkg/py3.13/windows.lock
# click
# pytest
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/pkg/py3.13/windows.lock
# -r requirements/base.txt
@@ -147,7 +149,7 @@ gitdb==4.0.12
# via
# -c requirements/static/pkg/py3.13/windows.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/pkg/py3.13/windows.lock
# -r requirements/base.txt
@@ -256,7 +258,7 @@ more-itertools==11.1.0
# jaraco-text
moto==5.2.2
# via -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/pkg/py3.13/windows.lock
# -r requirements/base.txt
@@ -349,7 +351,7 @@ psutil==7.2.2
# pytest-system-statistics
py-cpuinfo==9.0.0
# via pytest-benchmark
-pyasn1==0.6.3
+pyasn1==0.6.4
# via
# -c requirements/static/pkg/py3.13/windows.lock
# -r requirements/base.txt
@@ -379,7 +381,7 @@ pymssql==2.3.11
# -r requirements/base.txt
pynacl==1.6.2
# via -r requirements/static/ci/common.txt
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/pkg/py3.13/windows.lock
# -r requirements/base.txt
@@ -479,7 +481,7 @@ referencing==0.37.0
# via
# jsonschema
# jsonschema-specifications
-requests==2.33.1
+requests==2.34.2
# via
# -c requirements/static/pkg/py3.13/windows.lock
# -r requirements/base.txt
@@ -493,7 +495,6 @@ requests==2.33.1
# requests-ntlm
# requests-oauthlib
# responses
- # vultr
requests-ntlm==1.3.0
# via pywinrm
requests-oauthlib==2.0.0
@@ -598,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.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 0a8eba027331..faf113177d3a 100644
--- a/requirements/static/ci/py3.14/cloud.lock
+++ b/requirements/static/ci/py3.14/cloud.lock
@@ -35,10 +35,11 @@ asn1crypto==1.5.1
# -c requirements/static/ci/py3.14/linux.lock
# certvalidator
# oscrypto
-attrs==25.4.0
+attrs==26.1.0
# via
# -c requirements/static/ci/py3.14/linux.lock
# -c requirements/static/pkg/py3.14/linux.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -87,10 +88,11 @@ cffi==2.0.0
# -r requirements/static/ci/common.txt
# cryptography
# pynacl
-charset-normalizer==3.4.4
+charset-normalizer==3.5.1
# via
# -c requirements/static/ci/py3.14/linux.lock
# -c requirements/static/pkg/py3.14/linux.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via
@@ -119,7 +121,7 @@ croniter==6.2.2
# -c requirements/static/ci/py3.14/linux.lock
# -c requirements/static/pkg/py3.14/linux.lock
# -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/ci/py3.14/linux.lock
# -c requirements/static/pkg/py3.14/linux.lock
@@ -192,7 +194,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.60
# via
# -c requirements/static/ci/py3.14/linux.lock
# -c requirements/static/pkg/py3.14/linux.lock
@@ -365,7 +367,7 @@ moto==5.2.2
# via
# -c requirements/static/ci/py3.14/linux.lock
# -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/ci/py3.14/linux.lock
# -c requirements/static/pkg/py3.14/linux.lock
@@ -497,7 +499,7 @@ py-cpuinfo==9.0.0
# via
# -c requirements/static/ci/py3.14/linux.lock
# pytest-benchmark
-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
@@ -535,7 +537,7 @@ pynacl==1.6.2
# -c requirements/static/ci/py3.14/linux.lock
# -r requirements/static/ci/common.txt
# paramiko
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/ci/py3.14/linux.lock
# -c requirements/static/pkg/py3.14/linux.lock
@@ -670,7 +672,7 @@ referencing==0.37.0
# -c requirements/static/ci/py3.14/linux.lock
# jsonschema
# jsonschema-specifications
-requests==2.33.1
+requests==2.34.2
# via
# -c requirements/static/ci/py3.14/linux.lock
# -c requirements/static/pkg/py3.14/linux.lock
@@ -687,7 +689,6 @@ requests==2.33.1
# requests-oauthlib
# responses
# vcert
- # vultr
requests-ntlm==1.3.0
# via pywinrm
requests-oauthlib==2.0.0
@@ -716,12 +717,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
@@ -857,11 +852,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-lint.lock b/requirements/static/ci/py3.14/darwin-lint.lock
new file mode 100644
index 000000000000..6ca381f58027
--- /dev/null
+++ b/requirements/static/ci/py3.14/darwin-lint.lock
@@ -0,0 +1,57 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements/static/ci/lint.txt --python-platform=macos --python-version=3.14 --constraint requirements/constraints.txt --no-emit-index-url --unsafe-package=setuptools -c=requirements/static/ci/py3.14/darwin.lock -c=requirements/static/pkg/py3.14/darwin.lock -o=requirements/static/ci/py3.14/darwin-lint.lock
+astroid==3.1.0
+ # via pylint
+certifi==2026.6.17
+ # via
+ # -c requirements/static/ci/py3.14/darwin.lock
+ # -c requirements/static/pkg/py3.14/darwin.lock
+ # requests
+charset-normalizer==3.5.1
+ # via
+ # -c requirements/static/ci/py3.14/darwin.lock
+ # -c requirements/static/pkg/py3.14/darwin.lock
+ # requests
+dill==0.4.1
+ # via pylint
+docker==7.1.0
+ # via
+ # -c requirements/static/ci/py3.14/darwin.lock
+ # -r requirements/static/ci/lint.txt
+idna==3.18
+ # via
+ # -c requirements/static/ci/py3.14/darwin.lock
+ # -c requirements/static/pkg/py3.14/darwin.lock
+ # requests
+isort==5.13.2
+ # via pylint
+mccabe==0.7.0
+ # via pylint
+platformdirs==4.5.1
+ # via
+ # -c requirements/static/ci/py3.14/darwin.lock
+ # -c requirements/static/pkg/py3.14/darwin.lock
+ # pylint
+pylint==3.1.1
+ # via
+ # -r requirements/static/ci/lint.txt
+ # saltpylint
+requests==2.34.2
+ # via
+ # -c requirements/static/ci/py3.14/darwin.lock
+ # -c requirements/static/pkg/py3.14/darwin.lock
+ # docker
+saltpylint==2024.2.5
+ # via -r requirements/static/ci/lint.txt
+toml==0.10.2
+ # via
+ # -c requirements/static/ci/py3.14/darwin.lock
+ # -r requirements/static/ci/lint.txt
+tomlkit==0.15.1
+ # via pylint
+urllib3==2.7.0
+ # via
+ # -c requirements/static/ci/py3.14/darwin.lock
+ # -c requirements/static/pkg/py3.14/darwin.lock
+ # docker
+ # requests
diff --git a/requirements/static/ci/py3.14/darwin.lock b/requirements/static/ci/py3.14/darwin.lock
index c410ee0be594..36981adbea96 100644
--- a/requirements/static/ci/py3.14/darwin.lock
+++ b/requirements/static/ci/py3.14/darwin.lock
@@ -29,9 +29,10 @@ asn1crypto==1.5.1
# via
# certvalidator
# oscrypto
-attrs==25.4.0
+attrs==26.1.0
# via
# -c requirements/static/pkg/py3.14/darwin.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -72,9 +73,10 @@ cffi==2.0.0
# cryptography
# pygit2
# pynacl
-charset-normalizer==3.4.4
+charset-normalizer==3.5.1
# via
# -c requirements/static/pkg/py3.14/darwin.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via -r requirements/static/ci/common.txt
@@ -94,7 +96,7 @@ croniter==6.2.2
# via
# -c requirements/static/pkg/py3.14/darwin.lock
# -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/pkg/py3.14/darwin.lock
# -r requirements/base.txt
@@ -145,7 +147,7 @@ gitdb==4.0.12
# via
# -c requirements/static/pkg/py3.14/darwin.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/pkg/py3.14/darwin.lock
# -r requirements/base.txt
@@ -266,7 +268,7 @@ more-itertools==11.1.0
# jaraco-text
moto==5.2.2
# via -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/pkg/py3.14/darwin.lock
# -r requirements/base.txt
@@ -367,7 +369,7 @@ psutil==7.2.2
# pytest-system-statistics
py-cpuinfo==9.0.0
# via pytest-benchmark
-pyasn1==0.6.3
+pyasn1==0.6.4
# via
# -c requirements/static/pkg/py3.14/darwin.lock
# -r requirements/base.txt
@@ -395,7 +397,7 @@ pynacl==1.6.2
# via
# -r requirements/static/ci/common.txt
# paramiko
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/pkg/py3.14/darwin.lock
# -r requirements/base.txt
@@ -486,7 +488,7 @@ referencing==0.37.0
# via
# jsonschema
# jsonschema-specifications
-requests==2.33.1
+requests==2.34.2
# via
# -c requirements/static/pkg/py3.14/darwin.lock
# -r requirements/base.txt
@@ -499,7 +501,6 @@ requests==2.33.1
# requests-oauthlib
# responses
# vcert
- # vultr
requests-oauthlib==2.0.0
# via kubernetes
responses==0.25.8
@@ -607,10 +608,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/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 029ccf5e1313..7e78d6abff3f 100644
--- a/requirements/static/ci/py3.14/docs.lock
+++ b/requirements/static/ci/py3.14/docs.lock
@@ -24,9 +24,10 @@ apache-libcloud==3.9.1
# via
# -c requirements/static/ci/py3.14/linux.lock
# -r requirements/base.txt
-attrs==25.4.0
+attrs==26.1.0
# via
# -c requirements/static/ci/py3.14/linux.lock
+ # -r requirements/base.txt
# aiohttp
babel==2.17.0
# via
@@ -44,9 +45,10 @@ cffi==2.0.0
# -c requirements/static/ci/py3.14/linux.lock
# -r requirements/base.txt
# cryptography
-charset-normalizer==3.4.4
+charset-normalizer==3.5.1
# via
# -c requirements/static/ci/py3.14/linux.lock
+ # -r requirements/base.txt
# requests
cheroot==11.1.2
# via
@@ -62,7 +64,7 @@ croniter==6.2.2
# via
# -c requirements/static/ci/py3.14/linux.lock
# -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/ci/py3.14/linux.lock
# -r requirements/base.txt
@@ -95,7 +97,7 @@ gitdb==4.0.12
# via
# -c requirements/static/ci/py3.14/linux.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/ci/py3.14/linux.lock
# -r requirements/base.txt
@@ -179,7 +181,7 @@ more-itertools==11.1.0
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/ci/py3.14/linux.lock
# -r requirements/base.txt
@@ -258,7 +260,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
@@ -283,7 +285,7 @@ pygments==2.20.0
# pydata-sphinx-theme
# rich
# sphinx
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/ci/py3.14/linux.lock
# -r requirements/base.txt
@@ -310,7 +312,7 @@ pyzmq==27.1.0
# via
# -c requirements/static/ci/py3.14/linux.lock
# -r requirements/zeromq.txt
-requests==2.33.1
+requests==2.34.2
# via
# -c requirements/static/ci/py3.14/linux.lock
# -r requirements/base.txt
@@ -318,17 +320,12 @@ requests==2.33.1
# opentelemetry-exporter-otlp-proto-http
# sphinx
# sphinxcontrib-spelling
- # vultr
rich==15.0.0
# via
# -c requirements/static/ci/py3.14/linux.lock
# typer
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
@@ -415,10 +412,6 @@ virtualenv==21.4.2
# 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
xxhash==3.7.0
# via
# -c requirements/static/ci/py3.14/linux.lock
diff --git a/requirements/static/ci/py3.14/freebsd-lint.lock b/requirements/static/ci/py3.14/freebsd-lint.lock
new file mode 100644
index 000000000000..80793cc540b3
--- /dev/null
+++ b/requirements/static/ci/py3.14/freebsd-lint.lock
@@ -0,0 +1,67 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements/static/ci/lint.txt --universal --python-version=3.14 --constraint requirements/constraints.txt --no-emit-index-url --unsafe-package=setuptools -c=requirements/static/ci/py3.14/freebsd.lock -c=requirements/static/pkg/py3.14/freebsd.lock -o=requirements/static/ci/py3.14/freebsd-lint.lock
+astroid==3.1.0
+ # via pylint
+certifi==2026.6.17
+ # via
+ # -c requirements/static/ci/py3.14/freebsd.lock
+ # -c requirements/static/pkg/py3.14/freebsd.lock
+ # requests
+charset-normalizer==3.5.1
+ # via
+ # -c requirements/static/ci/py3.14/freebsd.lock
+ # -c requirements/static/pkg/py3.14/freebsd.lock
+ # requests
+colorama==0.4.6 ; sys_platform == 'win32'
+ # via
+ # -c requirements/static/ci/py3.14/freebsd.lock
+ # -c requirements/static/pkg/py3.14/freebsd.lock
+ # pylint
+dill==0.4.1
+ # via pylint
+docker==7.1.0
+ # via
+ # -c requirements/static/ci/py3.14/freebsd.lock
+ # -r requirements/static/ci/lint.txt
+idna==3.18
+ # via
+ # -c requirements/static/ci/py3.14/freebsd.lock
+ # -c requirements/static/pkg/py3.14/freebsd.lock
+ # requests
+isort==5.13.2
+ # via pylint
+mccabe==0.7.0
+ # via pylint
+platformdirs==4.5.1
+ # via
+ # -c requirements/static/ci/py3.14/freebsd.lock
+ # -c requirements/static/pkg/py3.14/freebsd.lock
+ # pylint
+pylint==3.1.1
+ # via
+ # -r requirements/static/ci/lint.txt
+ # saltpylint
+pywin32==312 ; sys_platform == 'win32'
+ # via
+ # -c requirements/static/ci/py3.14/freebsd.lock
+ # -c requirements/static/pkg/py3.14/freebsd.lock
+ # docker
+requests==2.34.2
+ # via
+ # -c requirements/static/ci/py3.14/freebsd.lock
+ # -c requirements/static/pkg/py3.14/freebsd.lock
+ # docker
+saltpylint==2024.2.5
+ # via -r requirements/static/ci/lint.txt
+toml==0.10.2
+ # via
+ # -c requirements/static/ci/py3.14/freebsd.lock
+ # -r requirements/static/ci/lint.txt
+tomlkit==0.15.1
+ # via pylint
+urllib3==2.7.0
+ # via
+ # -c requirements/static/ci/py3.14/freebsd.lock
+ # -c requirements/static/pkg/py3.14/freebsd.lock
+ # docker
+ # requests
diff --git a/requirements/static/ci/py3.14/freebsd.lock b/requirements/static/ci/py3.14/freebsd.lock
index a592cc1afca0..5b23392cc1f5 100644
--- a/requirements/static/ci/py3.14/freebsd.lock
+++ b/requirements/static/ci/py3.14/freebsd.lock
@@ -28,9 +28,10 @@ asn1crypto==1.5.1 ; sys_platform != 'win32'
# via
# certvalidator
# oscrypto
-attrs==25.4.0
+attrs==26.1.0
# via
# -c requirements/static/pkg/py3.14/freebsd.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -73,9 +74,10 @@ cffi==2.0.0
# cryptography
# pynacl
# pyzmq
-charset-normalizer==3.4.4
+charset-normalizer==3.5.1
# via
# -c requirements/static/pkg/py3.14/freebsd.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via -r requirements/static/ci/common.txt
@@ -106,7 +108,7 @@ croniter==6.2.2 ; sys_platform != 'win32'
# via
# -c requirements/static/pkg/py3.14/freebsd.lock
# -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/pkg/py3.14/freebsd.lock
# -r requirements/base.txt
@@ -160,7 +162,7 @@ gitdb==4.0.12
# via
# -c requirements/static/pkg/py3.14/freebsd.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/pkg/py3.14/freebsd.lock
# -r requirements/base.txt
@@ -297,7 +299,7 @@ more-itertools==11.1.0
# jaraco-text
moto==5.2.2
# via -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/pkg/py3.14/freebsd.lock
# -r requirements/base.txt
@@ -399,7 +401,7 @@ psutil==7.2.2
# pytest-system-statistics
py-cpuinfo==9.0.0
# via pytest-benchmark
-pyasn1==0.6.3
+pyasn1==0.6.4
# via
# -c requirements/static/pkg/py3.14/freebsd.lock
# -r requirements/base.txt
@@ -432,7 +434,7 @@ pynacl==1.6.2
# via
# -r requirements/static/ci/common.txt
# paramiko
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/pkg/py3.14/freebsd.lock
# -r requirements/base.txt
@@ -539,7 +541,7 @@ referencing==0.37.0
# via
# jsonschema
# jsonschema-specifications
-requests==2.33.1
+requests==2.34.2
# via
# -c requirements/static/pkg/py3.14/freebsd.lock
# -r requirements/base.txt
@@ -552,7 +554,6 @@ requests==2.33.1
# requests-oauthlib
# responses
# vcert
- # vultr
requests-oauthlib==2.0.0
# via kubernetes
responses==0.25.8
@@ -569,10 +570,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.18.0
# via boto3
scp==0.15.0 ; sys_platform != 'win32'
@@ -668,10 +665,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/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 ff583a3909b6..3939db9dfc5a 100644
--- a/requirements/static/ci/py3.14/lint.lock
+++ b/requirements/static/ci/py3.14/lint.lock
@@ -663,7 +663,6 @@ requests==2.33.1
# responses
# twilio
# vcert
- # vultr
requests-oauthlib==2.0.0
# via
# -c requirements/static/ci/py3.14/linux.lock
@@ -694,12 +693,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
@@ -842,11 +835,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-lint.lock b/requirements/static/ci/py3.14/linux-lint.lock
new file mode 100644
index 000000000000..7ec3b01277d7
--- /dev/null
+++ b/requirements/static/ci/py3.14/linux-lint.lock
@@ -0,0 +1,57 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements/static/ci/lint.txt --python-platform=linux --python-version=3.14 --constraint requirements/constraints.txt --no-emit-index-url --unsafe-package=setuptools -c=requirements/static/ci/py3.14/linux.lock -c=requirements/static/pkg/py3.14/linux.lock -o=requirements/static/ci/py3.14/linux-lint.lock
+astroid==3.1.0
+ # via pylint
+certifi==2026.6.17
+ # via
+ # -c requirements/static/ci/py3.14/linux.lock
+ # -c requirements/static/pkg/py3.14/linux.lock
+ # requests
+charset-normalizer==3.5.1
+ # via
+ # -c requirements/static/ci/py3.14/linux.lock
+ # -c requirements/static/pkg/py3.14/linux.lock
+ # requests
+dill==0.4.1
+ # via pylint
+docker==7.1.0
+ # via
+ # -c requirements/static/ci/py3.14/linux.lock
+ # -r requirements/static/ci/lint.txt
+idna==3.18
+ # via
+ # -c requirements/static/ci/py3.14/linux.lock
+ # -c requirements/static/pkg/py3.14/linux.lock
+ # requests
+isort==5.13.2
+ # via pylint
+mccabe==0.7.0
+ # via pylint
+platformdirs==4.5.1
+ # via
+ # -c requirements/static/ci/py3.14/linux.lock
+ # -c requirements/static/pkg/py3.14/linux.lock
+ # pylint
+pylint==3.1.1
+ # via
+ # -r requirements/static/ci/lint.txt
+ # saltpylint
+requests==2.34.2
+ # via
+ # -c requirements/static/ci/py3.14/linux.lock
+ # -c requirements/static/pkg/py3.14/linux.lock
+ # docker
+saltpylint==2024.2.5
+ # via -r requirements/static/ci/lint.txt
+toml==0.10.2
+ # via
+ # -c requirements/static/ci/py3.14/linux.lock
+ # -r requirements/static/ci/lint.txt
+tomlkit==0.15.1
+ # via pylint
+urllib3==2.7.0
+ # via
+ # -c requirements/static/ci/py3.14/linux.lock
+ # -c requirements/static/pkg/py3.14/linux.lock
+ # docker
+ # requests
diff --git a/requirements/static/ci/py3.14/linux.lock b/requirements/static/ci/py3.14/linux.lock
index 68dd09fd976a..67f17d434154 100644
--- a/requirements/static/ci/py3.14/linux.lock
+++ b/requirements/static/ci/py3.14/linux.lock
@@ -38,9 +38,10 @@ asn1crypto==1.5.1
# via
# certvalidator
# oscrypto
-attrs==25.4.0
+attrs==26.1.0
# via
# -c requirements/static/pkg/py3.14/linux.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -83,9 +84,10 @@ cffi==2.0.0
# cryptography
# pygit2
# pynacl
-charset-normalizer==3.4.4
+charset-normalizer==3.5.1
# via
# -c requirements/static/pkg/py3.14/linux.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via -r requirements/static/ci/common.txt
@@ -105,7 +107,7 @@ croniter==6.2.2
# via
# -c requirements/static/pkg/py3.14/linux.lock
# -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/pkg/py3.14/linux.lock
# -r requirements/base.txt
@@ -158,7 +160,7 @@ gitdb==4.0.12
# via
# -c requirements/static/pkg/py3.14/linux.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/pkg/py3.14/linux.lock
# -r requirements/base.txt
@@ -297,7 +299,7 @@ more-itertools==11.1.0
# jaraco-text
moto==5.2.2
# via -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/pkg/py3.14/linux.lock
# -r requirements/base.txt
@@ -400,7 +402,7 @@ psutil==7.2.2
# pytest-system-statistics
py-cpuinfo==9.0.0
# via pytest-benchmark
-pyasn1==0.6.3
+pyasn1==0.6.4
# via
# -c requirements/static/pkg/py3.14/linux.lock
# -r requirements/base.txt
@@ -436,7 +438,7 @@ pynacl==1.6.2
# via
# -r requirements/static/ci/common.txt
# paramiko
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/pkg/py3.14/linux.lock
# -r requirements/base.txt
@@ -534,7 +536,7 @@ referencing==0.37.0
# via
# jsonschema
# jsonschema-specifications
-requests==2.33.1
+requests==2.34.2
# via
# -c requirements/static/pkg/py3.14/linux.lock
# -r requirements/base.txt
@@ -549,7 +551,6 @@ requests==2.33.1
# responses
# twilio
# vcert
- # vultr
requests-oauthlib==2.0.0
# via kubernetes
resolvelib==1.2.1
@@ -568,10 +569,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
@@ -672,10 +669,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-lint.lock b/requirements/static/ci/py3.14/windows-lint.lock
new file mode 100644
index 000000000000..af4e072a07a4
--- /dev/null
+++ b/requirements/static/ci/py3.14/windows-lint.lock
@@ -0,0 +1,67 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements/static/ci/lint.txt --python-platform=windows --python-version=3.14 --constraint requirements/constraints.txt --no-emit-index-url --unsafe-package=setuptools -c=requirements/static/ci/py3.14/windows.lock -c=requirements/static/pkg/py3.14/windows.lock -o=requirements/static/ci/py3.14/windows-lint.lock
+astroid==3.1.0
+ # via pylint
+certifi==2026.6.17
+ # via
+ # -c requirements/static/ci/py3.14/windows.lock
+ # -c requirements/static/pkg/py3.14/windows.lock
+ # requests
+charset-normalizer==3.5.1
+ # via
+ # -c requirements/static/ci/py3.14/windows.lock
+ # -c requirements/static/pkg/py3.14/windows.lock
+ # requests
+colorama==0.4.6
+ # via
+ # -c requirements/static/ci/py3.14/windows.lock
+ # -c requirements/static/pkg/py3.14/windows.lock
+ # pylint
+dill==0.4.1
+ # via pylint
+docker==7.1.0
+ # via
+ # -c requirements/static/ci/py3.14/windows.lock
+ # -r requirements/static/ci/lint.txt
+idna==3.18
+ # via
+ # -c requirements/static/ci/py3.14/windows.lock
+ # -c requirements/static/pkg/py3.14/windows.lock
+ # requests
+isort==5.13.2
+ # via pylint
+mccabe==0.7.0
+ # via pylint
+platformdirs==4.9.2
+ # via
+ # -c requirements/static/ci/py3.14/windows.lock
+ # -c requirements/static/pkg/py3.14/windows.lock
+ # pylint
+pylint==3.1.1
+ # via
+ # -r requirements/static/ci/lint.txt
+ # saltpylint
+pywin32==312
+ # via
+ # -c requirements/static/ci/py3.14/windows.lock
+ # -c requirements/static/pkg/py3.14/windows.lock
+ # docker
+requests==2.34.2
+ # via
+ # -c requirements/static/ci/py3.14/windows.lock
+ # -c requirements/static/pkg/py3.14/windows.lock
+ # docker
+saltpylint==2024.2.5
+ # via -r requirements/static/ci/lint.txt
+toml==0.10.2
+ # via
+ # -c requirements/static/ci/py3.14/windows.lock
+ # -r requirements/static/ci/lint.txt
+tomlkit==0.15.1
+ # via pylint
+urllib3==2.7.0
+ # via
+ # -c requirements/static/ci/py3.14/windows.lock
+ # -c requirements/static/pkg/py3.14/windows.lock
+ # docker
+ # requests
diff --git a/requirements/static/ci/py3.14/windows.lock b/requirements/static/ci/py3.14/windows.lock
index 4f327ab51fe5..cb1142d7e224 100644
--- a/requirements/static/ci/py3.14/windows.lock
+++ b/requirements/static/ci/py3.14/windows.lock
@@ -23,9 +23,10 @@ apache-libcloud==3.9.1
# via
# -c requirements/static/pkg/py3.14/windows.lock
# -r requirements/base.txt
-attrs==25.4.0
+attrs==26.1.0
# via
# -c requirements/static/pkg/py3.14/windows.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -63,9 +64,10 @@ cffi==2.0.0
# cryptography
# pygit2
# pynacl
-charset-normalizer==3.4.4
+charset-normalizer==3.5.1
# via
# -c requirements/static/pkg/py3.14/windows.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via -r requirements/static/ci/common.txt
@@ -94,7 +96,7 @@ colorama==0.4.6
# -c requirements/static/pkg/py3.14/windows.lock
# click
# pytest
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -c requirements/static/pkg/py3.14/windows.lock
# -r requirements/base.txt
@@ -147,7 +149,7 @@ gitdb==4.0.12
# via
# -c requirements/static/pkg/py3.14/windows.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/pkg/py3.14/windows.lock
# -r requirements/base.txt
@@ -256,7 +258,7 @@ more-itertools==11.1.0
# jaraco-text
moto==5.2.2
# via -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.2.1
# via
# -c requirements/static/pkg/py3.14/windows.lock
# -r requirements/base.txt
@@ -349,7 +351,7 @@ psutil==7.2.2
# pytest-system-statistics
py-cpuinfo==9.0.0
# via pytest-benchmark
-pyasn1==0.6.3
+pyasn1==0.6.4
# via
# -c requirements/static/pkg/py3.14/windows.lock
# -r requirements/base.txt
@@ -379,7 +381,7 @@ pymssql==2.3.11
# -r requirements/base.txt
pynacl==1.6.2
# via -r requirements/static/ci/common.txt
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -c requirements/static/pkg/py3.14/windows.lock
# -r requirements/base.txt
@@ -479,7 +481,7 @@ referencing==0.37.0
# via
# jsonschema
# jsonschema-specifications
-requests==2.33.1
+requests==2.34.2
# via
# -c requirements/static/pkg/py3.14/windows.lock
# -r requirements/base.txt
@@ -493,7 +495,6 @@ requests==2.33.1
# requests-ntlm
# requests-oauthlib
# responses
- # vultr
requests-ntlm==1.3.0
# via pywinrm
requests-oauthlib==2.0.0
@@ -598,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/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 33b25e200ed5..b06cdab1eb51 100644
--- a/requirements/static/ci/py3.9/cloud.lock
+++ b/requirements/static/ci/py3.9/cloud.lock
@@ -213,7 +213,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.60
# via
# -c requirements/static/ci/py3.9/linux.lock
# -c requirements/static/pkg/py3.9/linux.lock
@@ -553,7 +553,7 @@ py-cpuinfo==9.0.0
# via
# -c requirements/static/ci/py3.9/linux.lock
# pytest-benchmark
-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
@@ -763,7 +763,6 @@ requests==2.32.5
# requests-oauthlib
# responses
# vcert
- # vultr
requests-ntlm==1.2.0
# via pywinrm
requests-oauthlib==2.0.0
@@ -793,12 +792,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
@@ -965,11 +958,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-lint.lock b/requirements/static/ci/py3.9/darwin-lint.lock
new file mode 100644
index 000000000000..d9e03b958d2a
--- /dev/null
+++ b/requirements/static/ci/py3.9/darwin-lint.lock
@@ -0,0 +1,67 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements/static/ci/lint.txt --python-platform=macos --python-version=3.9 --constraint requirements/constraints.txt --no-emit-index-url --unsafe-package=setuptools -c=requirements/static/ci/py3.9/darwin.lock -c=requirements/static/pkg/py3.9/darwin.lock -o=requirements/static/ci/py3.9/darwin-lint.lock
+astroid==3.1.0
+ # via pylint
+certifi==2026.6.17
+ # via
+ # -c requirements/static/ci/py3.9/darwin.lock
+ # -c requirements/static/pkg/py3.9/darwin.lock
+ # requests
+charset-normalizer==3.2.0
+ # via
+ # -c requirements/static/ci/py3.9/darwin.lock
+ # -c requirements/static/pkg/py3.9/darwin.lock
+ # requests
+dill==0.4.1
+ # via pylint
+docker==7.1.0
+ # via
+ # -c requirements/static/ci/py3.9/darwin.lock
+ # -r requirements/static/ci/lint.txt
+idna==3.18
+ # via
+ # -c requirements/static/ci/py3.9/darwin.lock
+ # -c requirements/static/pkg/py3.9/darwin.lock
+ # requests
+isort==5.13.2
+ # via pylint
+mccabe==0.7.0
+ # via pylint
+platformdirs==4.4.0
+ # via
+ # -c requirements/static/ci/py3.9/darwin.lock
+ # -c requirements/static/pkg/py3.9/darwin.lock
+ # pylint
+pylint==3.1.1
+ # via
+ # -r requirements/static/ci/lint.txt
+ # saltpylint
+requests==2.32.5
+ # via
+ # -c requirements/static/ci/py3.9/darwin.lock
+ # -c requirements/static/pkg/py3.9/darwin.lock
+ # docker
+saltpylint==2024.2.5
+ # via -r requirements/static/ci/lint.txt
+toml==0.10.2
+ # via
+ # -c requirements/static/ci/py3.9/darwin.lock
+ # -r requirements/static/ci/lint.txt
+tomli==2.2.1
+ # via
+ # -c requirements/static/ci/py3.9/darwin.lock
+ # pylint
+tomlkit==0.15.1
+ # via pylint
+typing-extensions==4.14.1
+ # via
+ # -c requirements/static/ci/py3.9/darwin.lock
+ # -c requirements/static/pkg/py3.9/darwin.lock
+ # astroid
+ # pylint
+urllib3==1.26.20
+ # via
+ # -c requirements/static/ci/py3.9/darwin.lock
+ # -c requirements/static/pkg/py3.9/darwin.lock
+ # docker
+ # requests
diff --git a/requirements/static/ci/py3.9/darwin.lock b/requirements/static/ci/py3.9/darwin.lock
index f396bed548ac..1b0876e8774b 100644
--- a/requirements/static/ci/py3.9/darwin.lock
+++ b/requirements/static/ci/py3.9/darwin.lock
@@ -158,7 +158,7 @@ gitdb==4.0.12
# via
# -c requirements/static/pkg/py3.9/darwin.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/pkg/py3.9/darwin.lock
# -r requirements/base.txt
@@ -406,7 +406,7 @@ psutil==5.9.8
# pytest-system-statistics
py-cpuinfo==9.0.0
# via pytest-benchmark
-pyasn1==0.6.3
+pyasn1==0.6.4
# via
# -c requirements/static/pkg/py3.9/darwin.lock
# -r requirements/base.txt
@@ -555,7 +555,6 @@ requests==2.32.5
# requests-oauthlib
# responses
# 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/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 4c90e02e65c2..e5032e2a6f56 100644
--- a/requirements/static/ci/py3.9/docs.lock
+++ b/requirements/static/ci/py3.9/docs.lock
@@ -107,7 +107,7 @@ gitdb==4.0.12
# via
# -c requirements/static/ci/py3.9/linux.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/ci/py3.9/linux.lock
# -r requirements/base.txt
@@ -273,7 +273,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
@@ -335,15 +335,10 @@ requests==2.32.5
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
# sphinx
- # vultr
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
@@ -433,10 +428,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
xxhash==3.7.0
# via
# -c requirements/static/ci/py3.9/linux.lock
diff --git a/requirements/static/ci/py3.9/freebsd-lint.lock b/requirements/static/ci/py3.9/freebsd-lint.lock
new file mode 100644
index 000000000000..419dacb2116f
--- /dev/null
+++ b/requirements/static/ci/py3.9/freebsd-lint.lock
@@ -0,0 +1,93 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements/static/ci/lint.txt --universal --python-version=3.9 --constraint requirements/constraints.txt --no-emit-index-url --unsafe-package=setuptools -c=requirements/static/ci/py3.9/freebsd.lock -c=requirements/static/pkg/py3.9/freebsd.lock -o=requirements/static/ci/py3.9/freebsd-lint.lock
+astroid==3.1.0
+ # via pylint
+certifi==2026.6.17
+ # via
+ # -c requirements/static/ci/py3.9/freebsd.lock
+ # -c requirements/static/pkg/py3.9/freebsd.lock
+ # requests
+charset-normalizer==3.5.1
+ # via
+ # -c requirements/static/ci/py3.9/freebsd.lock
+ # -c requirements/static/pkg/py3.9/freebsd.lock
+ # requests
+colorama==0.4.6 ; sys_platform == 'win32'
+ # via
+ # -c requirements/static/ci/py3.9/freebsd.lock
+ # -c requirements/static/pkg/py3.9/freebsd.lock
+ # pylint
+dill==0.4.1
+ # via pylint
+docker==7.1.0
+ # via
+ # -c requirements/static/ci/py3.9/freebsd.lock
+ # -r requirements/static/ci/lint.txt
+idna==3.18
+ # via
+ # -c requirements/static/ci/py3.9/freebsd.lock
+ # -c requirements/static/pkg/py3.9/freebsd.lock
+ # requests
+isort==5.13.2
+ # via pylint
+mccabe==0.7.0
+ # via pylint
+platformdirs==4.4.0
+ # via
+ # -c requirements/static/ci/py3.9/freebsd.lock
+ # -c requirements/static/pkg/py3.9/freebsd.lock
+ # pylint
+pylint==3.1.1
+ # via
+ # -r requirements/static/ci/lint.txt
+ # saltpylint
+pywin32==312 ; sys_platform == 'win32'
+ # via
+ # -c requirements/static/ci/py3.9/freebsd.lock
+ # -c requirements/static/pkg/py3.9/freebsd.lock
+ # docker
+requests==2.31.0 ; python_full_version == '3.10.*'
+ # via
+ # -c requirements/static/ci/py3.9/freebsd.lock
+ # -c requirements/static/pkg/py3.9/freebsd.lock
+ # docker
+requests==2.32.5 ; python_full_version < '3.10'
+ # via
+ # -c requirements/static/ci/py3.9/freebsd.lock
+ # -c requirements/static/pkg/py3.9/freebsd.lock
+ # docker
+requests==2.34.2 ; python_full_version >= '3.11'
+ # via
+ # -c requirements/static/ci/py3.9/freebsd.lock
+ # -c requirements/static/pkg/py3.9/freebsd.lock
+ # docker
+saltpylint==2024.2.5
+ # via -r requirements/static/ci/lint.txt
+toml==0.10.2
+ # via
+ # -c requirements/static/ci/py3.9/freebsd.lock
+ # -r requirements/static/ci/lint.txt
+tomli==2.2.1 ; python_full_version < '3.11'
+ # via
+ # -c requirements/static/ci/py3.9/freebsd.lock
+ # pylint
+tomlkit==0.15.1
+ # via pylint
+typing-extensions==4.14.1 ; python_full_version < '3.11'
+ # via
+ # -c requirements/static/ci/py3.9/freebsd.lock
+ # -c requirements/static/pkg/py3.9/freebsd.lock
+ # astroid
+ # pylint
+urllib3==1.26.20 ; python_full_version < '3.10'
+ # via
+ # -c requirements/static/ci/py3.9/freebsd.lock
+ # -c requirements/static/pkg/py3.9/freebsd.lock
+ # docker
+ # requests
+urllib3==2.7.0 ; python_full_version >= '3.10'
+ # via
+ # -c requirements/static/ci/py3.9/freebsd.lock
+ # -c requirements/static/pkg/py3.9/freebsd.lock
+ # docker
+ # requests
diff --git a/requirements/static/ci/py3.9/freebsd.lock b/requirements/static/ci/py3.9/freebsd.lock
index f39f5fc11fba..c00a8c975d39 100644
--- a/requirements/static/ci/py3.9/freebsd.lock
+++ b/requirements/static/ci/py3.9/freebsd.lock
@@ -43,9 +43,10 @@ async-timeout==4.0.3 ; python_full_version < '3.11'
# via
# -c requirements/static/pkg/py3.9/freebsd.lock
# aiohttp
-attrs==23.2.0
+attrs==26.1.0
# via
# -c requirements/static/pkg/py3.9/freebsd.lock
+ # -r requirements/base.txt
# aiohttp
# jsonschema
# pytest-salt-factories
@@ -110,9 +111,10 @@ cffi==2.0.0
# napalm
# pynacl
# pyzmq
-charset-normalizer==3.2.0
+charset-normalizer==3.5.1
# via
# -c requirements/static/pkg/py3.9/freebsd.lock
+ # -r requirements/base.txt
# requests
cheetah3==3.2.6.post1
# via -r requirements/static/ci/common.txt
@@ -165,7 +167,7 @@ cryptography==46.0.7 ; python_full_version < '3.10'
# secretstorage
# trustme
# vcert
-cryptography==48.0.0 ; python_full_version >= '3.10'
+cryptography==50.0.1 ; python_full_version >= '3.10'
# via
# -c requirements/static/pkg/py3.9/freebsd.lock
# -r requirements/base.txt
@@ -228,7 +230,7 @@ gitdb==4.0.12
# via
# -c requirements/static/pkg/py3.9/freebsd.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/pkg/py3.9/freebsd.lock
# -r requirements/base.txt
@@ -421,7 +423,12 @@ moto==5.1.20 ; python_full_version < '3.10'
# via -r requirements/static/ci/common.txt
moto==5.2.2 ; python_full_version >= '3.10'
# via -r requirements/static/ci/common.txt
-msgpack==1.1.2
+msgpack==1.1.2 ; python_full_version < '3.10'
+ # via
+ # -c requirements/static/pkg/py3.9/freebsd.lock
+ # -r requirements/base.txt
+ # pytest-salt-factories
+msgpack==1.2.1 ; python_full_version >= '3.10'
# via
# -c requirements/static/pkg/py3.9/freebsd.lock
# -r requirements/base.txt
@@ -587,7 +594,7 @@ psutil==7.2.2 ; python_full_version >= '3.10'
# pytest-system-statistics
py-cpuinfo==9.0.0
# via pytest-benchmark
-pyasn1==0.6.3
+pyasn1==0.6.4
# via
# -c requirements/static/pkg/py3.9/freebsd.lock
# -r requirements/base.txt
@@ -634,7 +641,13 @@ pynacl==1.6.2
# via
# -r requirements/static/ci/common.txt
# paramiko
-pyopenssl==26.2.0
+pyopenssl==26.2.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
+ # etcd3-py
+pyopenssl==26.4.0 ; python_full_version >= '3.10'
# via
# -c requirements/static/pkg/py3.9/freebsd.lock
# -r requirements/base.txt
@@ -773,7 +786,6 @@ requests==2.31.0 ; python_full_version == '3.10.*'
# requests-oauthlib
# responses
# vcert
- # vultr
requests==2.32.5 ; python_full_version < '3.10'
# via
# -c requirements/static/pkg/py3.9/freebsd.lock
@@ -788,8 +800,7 @@ requests==2.32.5 ; python_full_version < '3.10'
# requests-oauthlib
# responses
# vcert
- # vultr
-requests==2.33.1 ; python_full_version >= '3.11'
+requests==2.34.2 ; python_full_version >= '3.11'
# via
# -c requirements/static/pkg/py3.9/freebsd.lock
# -r requirements/base.txt
@@ -802,7 +813,6 @@ requests==2.33.1 ; python_full_version >= '3.11'
# requests-oauthlib
# responses
# vcert
- # vultr
requests-oauthlib==2.0.0
# via kubernetes
responses==0.25.8
@@ -820,10 +830,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'
@@ -971,10 +977,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 52273006d151..175ab404d9cc 100644
--- a/requirements/static/ci/py3.9/lint.lock
+++ b/requirements/static/ci/py3.9/lint.lock
@@ -727,7 +727,6 @@ requests==2.32.5
# responses
# twilio
# vcert
- # vultr
requests-oauthlib==2.0.0
# via
# -c requirements/static/ci/py3.9/linux.lock
@@ -755,12 +754,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
@@ -941,11 +934,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-lint.lock b/requirements/static/ci/py3.9/linux-lint.lock
new file mode 100644
index 000000000000..b858a30a1c90
--- /dev/null
+++ b/requirements/static/ci/py3.9/linux-lint.lock
@@ -0,0 +1,67 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements/static/ci/lint.txt --python-platform=linux --python-version=3.9 --constraint requirements/constraints.txt --no-emit-index-url --unsafe-package=setuptools -c=requirements/static/ci/py3.9/linux.lock -c=requirements/static/pkg/py3.9/linux.lock -o=requirements/static/ci/py3.9/linux-lint.lock
+astroid==3.1.0
+ # via pylint
+certifi==2026.6.17
+ # via
+ # -c requirements/static/ci/py3.9/linux.lock
+ # -c requirements/static/pkg/py3.9/linux.lock
+ # requests
+charset-normalizer==3.2.0
+ # via
+ # -c requirements/static/ci/py3.9/linux.lock
+ # -c requirements/static/pkg/py3.9/linux.lock
+ # requests
+dill==0.4.1
+ # via pylint
+docker==7.1.0
+ # via
+ # -c requirements/static/ci/py3.9/linux.lock
+ # -r requirements/static/ci/lint.txt
+idna==3.18
+ # via
+ # -c requirements/static/ci/py3.9/linux.lock
+ # -c requirements/static/pkg/py3.9/linux.lock
+ # requests
+isort==5.13.2
+ # via pylint
+mccabe==0.7.0
+ # via pylint
+platformdirs==4.4.0
+ # via
+ # -c requirements/static/ci/py3.9/linux.lock
+ # -c requirements/static/pkg/py3.9/linux.lock
+ # pylint
+pylint==3.1.1
+ # via
+ # -r requirements/static/ci/lint.txt
+ # saltpylint
+requests==2.32.5
+ # via
+ # -c requirements/static/ci/py3.9/linux.lock
+ # -c requirements/static/pkg/py3.9/linux.lock
+ # docker
+saltpylint==2024.2.5
+ # via -r requirements/static/ci/lint.txt
+toml==0.10.2
+ # via
+ # -c requirements/static/ci/py3.9/linux.lock
+ # -r requirements/static/ci/lint.txt
+tomli==2.2.1
+ # via
+ # -c requirements/static/ci/py3.9/linux.lock
+ # pylint
+tomlkit==0.15.1
+ # via pylint
+typing-extensions==4.14.1
+ # via
+ # -c requirements/static/ci/py3.9/linux.lock
+ # -c requirements/static/pkg/py3.9/linux.lock
+ # astroid
+ # pylint
+urllib3==1.26.20
+ # via
+ # -c requirements/static/ci/py3.9/linux.lock
+ # -c requirements/static/pkg/py3.9/linux.lock
+ # docker
+ # requests
diff --git a/requirements/static/ci/py3.9/linux.lock b/requirements/static/ci/py3.9/linux.lock
index 44cd007ee463..e1a6af83c59e 100644
--- a/requirements/static/ci/py3.9/linux.lock
+++ b/requirements/static/ci/py3.9/linux.lock
@@ -168,7 +168,7 @@ gitdb==4.0.12
# via
# -c requirements/static/pkg/py3.9/linux.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/pkg/py3.9/linux.lock
# -r requirements/base.txt
@@ -432,7 +432,7 @@ psutil==5.9.8
# pytest-system-statistics
py-cpuinfo==9.0.0
# via pytest-benchmark
-pyasn1==0.6.3
+pyasn1==0.6.4
# via
# -c requirements/static/pkg/py3.9/linux.lock
# -r requirements/base.txt
@@ -597,7 +597,6 @@ requests==2.32.5
# responses
# twilio
# vcert
- # vultr
requests-oauthlib==2.0.0
# via kubernetes
responses==0.25.8
@@ -615,10 +614,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
@@ -749,10 +744,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-lint.lock b/requirements/static/ci/py3.9/windows-lint.lock
new file mode 100644
index 000000000000..bec7caccc081
--- /dev/null
+++ b/requirements/static/ci/py3.9/windows-lint.lock
@@ -0,0 +1,77 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements/static/ci/lint.txt --python-platform=windows --python-version=3.9 --constraint requirements/constraints.txt --no-emit-index-url --unsafe-package=setuptools -c=requirements/static/ci/py3.9/windows.lock -c=requirements/static/pkg/py3.9/windows.lock -o=requirements/static/ci/py3.9/windows-lint.lock
+astroid==3.1.0
+ # via pylint
+certifi==2026.6.17
+ # via
+ # -c requirements/static/ci/py3.9/windows.lock
+ # -c requirements/static/pkg/py3.9/windows.lock
+ # requests
+charset-normalizer==3.4.4
+ # via
+ # -c requirements/static/ci/py3.9/windows.lock
+ # -c requirements/static/pkg/py3.9/windows.lock
+ # requests
+colorama==0.4.6
+ # via
+ # -c requirements/static/ci/py3.9/windows.lock
+ # -c requirements/static/pkg/py3.9/windows.lock
+ # pylint
+dill==0.4.1
+ # via pylint
+docker==7.1.0
+ # via
+ # -c requirements/static/ci/py3.9/windows.lock
+ # -r requirements/static/ci/lint.txt
+idna==3.18
+ # via
+ # -c requirements/static/ci/py3.9/windows.lock
+ # -c requirements/static/pkg/py3.9/windows.lock
+ # requests
+isort==5.13.2
+ # via pylint
+mccabe==0.7.0
+ # via pylint
+platformdirs==4.4.0
+ # via
+ # -c requirements/static/ci/py3.9/windows.lock
+ # -c requirements/static/pkg/py3.9/windows.lock
+ # pylint
+pylint==3.1.1
+ # via
+ # -r requirements/static/ci/lint.txt
+ # saltpylint
+pywin32==312
+ # via
+ # -c requirements/static/ci/py3.9/windows.lock
+ # -c requirements/static/pkg/py3.9/windows.lock
+ # docker
+requests==2.32.5
+ # via
+ # -c requirements/static/ci/py3.9/windows.lock
+ # -c requirements/static/pkg/py3.9/windows.lock
+ # docker
+saltpylint==2024.2.5
+ # via -r requirements/static/ci/lint.txt
+toml==0.10.2
+ # via
+ # -c requirements/static/ci/py3.9/windows.lock
+ # -r requirements/static/ci/lint.txt
+tomli==2.2.1
+ # via
+ # -c requirements/static/ci/py3.9/windows.lock
+ # pylint
+tomlkit==0.15.1
+ # via pylint
+typing-extensions==4.15.0
+ # via
+ # -c requirements/static/ci/py3.9/windows.lock
+ # -c requirements/static/pkg/py3.9/windows.lock
+ # astroid
+ # pylint
+urllib3==1.26.20
+ # via
+ # -c requirements/static/ci/py3.9/windows.lock
+ # -c requirements/static/pkg/py3.9/windows.lock
+ # docker
+ # requests
diff --git a/requirements/static/ci/py3.9/windows.lock b/requirements/static/ci/py3.9/windows.lock
index 7e262327fbe2..565684c65453 100644
--- a/requirements/static/ci/py3.9/windows.lock
+++ b/requirements/static/ci/py3.9/windows.lock
@@ -155,7 +155,7 @@ gitdb==4.0.12
# via
# -c requirements/static/pkg/py3.9/windows.lock
# gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via
# -c requirements/static/pkg/py3.9/windows.lock
# -r requirements/base.txt
@@ -364,7 +364,7 @@ psutil==5.9.8
# pytest-system-statistics
py-cpuinfo==9.0.0
# via pytest-benchmark
-pyasn1==0.6.3
+pyasn1==0.6.4
# via
# -c requirements/static/pkg/py3.9/windows.lock
# -r requirements/base.txt
@@ -516,7 +516,6 @@ requests==2.32.5
# requests-ntlm
# requests-oauthlib
# responses
- # vultr
requests-ntlm==1.3.0
# via pywinrm
requests-oauthlib==2.0.0
@@ -629,10 +628,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/linux.txt b/requirements/static/pkg/linux.txt
index 66f04fb284bf..505de2341a9a 100644
--- a/requirements/static/pkg/linux.txt
+++ b/requirements/static/pkg/linux.txt
@@ -11,7 +11,6 @@ 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
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/darwin.lock b/requirements/static/pkg/py3.10/darwin.lock
index 464e379b2957..1fb9751f578f 100644
--- a/requirements/static/pkg/py3.10/darwin.lock
+++ b/requirements/static/pkg/py3.10/darwin.lock
@@ -12,8 +12,10 @@ apache-libcloud==3.9.1
# via -r requirements/base.txt
async-timeout==4.0.3
# via aiohttp
-attrs==23.2.0
- # via aiohttp
+attrs==26.1.0
+ # via
+ # -r requirements/base.txt
+ # aiohttp
backports-tarfile==1.2.0
# via jaraco-context
certifi==2026.6.17
@@ -24,8 +26,10 @@ cffi==2.0.0
# via
# -r requirements/base.txt
# cryptography
-charset-normalizer==3.2.0
- # via requests
+charset-normalizer==3.5.1
+ # via
+ # -r requirements/base.txt
+ # requests
cheroot==11.1.2
# via
# -r requirements/base.txt
@@ -34,7 +38,7 @@ cherrypy==18.10.0
# via -r requirements/base.txt
croniter==6.2.2
# via -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -r requirements/base.txt
# pyopenssl
@@ -54,7 +58,7 @@ frozenlist==1.8.0
# aiosignal
gitdb==4.0.12
# via gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via -r requirements/base.txt
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
@@ -104,7 +108,7 @@ more-itertools==11.1.0
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.2.1
# via -r requirements/base.txt
multidict==6.7.1
# via
@@ -155,7 +159,7 @@ protobuf==6.33.6
# opentelemetry-proto
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
@@ -167,7 +171,7 @@ pycryptodomex==3.23.0
# -r requirements/crypto.txt
pygments==2.20.0
# via rich
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via -r requirements/base.txt
python-dateutil==2.9.0.post0
# via
@@ -188,12 +192,11 @@ requests==2.31.0
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # vultr
rich==15.0.0
# via typer
setproctitle==1.3.7
# via -r requirements/base.txt
-setuptools==82.0.0
+setuptools==84.0.0
# via
# -c requirements/constraints.txt
# zc-lockfile
@@ -235,8 +238,6 @@ urllib3==2.7.0
# requests
virtualenv==21.4.2
# via -r requirements/base.txt
-vultr==1.0.1
- # via -r requirements/base.txt
xxhash==3.7.0
# via -r requirements/base.txt
yarl==1.20.1
diff --git a/requirements/static/pkg/py3.10/freebsd.lock b/requirements/static/pkg/py3.10/freebsd.lock
index 76630f1fd729..8f9c7a4bf0ec 100644
--- a/requirements/static/pkg/py3.10/freebsd.lock
+++ b/requirements/static/pkg/py3.10/freebsd.lock
@@ -12,8 +12,10 @@ apache-libcloud==3.9.1
# via -r requirements/base.txt
async-timeout==4.0.3 ; python_full_version < '3.11'
# via aiohttp
-attrs==23.2.0
- # via aiohttp
+attrs==26.1.0
+ # via
+ # -r requirements/base.txt
+ # aiohttp
backports-tarfile==1.2.0 ; python_full_version < '3.12'
# via jaraco-context
certifi==2026.6.17
@@ -26,8 +28,10 @@ cffi==2.0.0
# clr-loader
# cryptography
# pyzmq
-charset-normalizer==3.2.0
- # via requests
+charset-normalizer==3.5.1
+ # via
+ # -r requirements/base.txt
+ # requests
cheroot==11.1.2
# via
# -r requirements/base.txt
@@ -43,7 +47,7 @@ colorama==0.4.6 ; sys_platform == 'win32'
# via typer
croniter==6.2.2 ; sys_platform != 'win32'
# via -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -r requirements/base.txt
# -r requirements/static/pkg/freebsd.txt
@@ -66,7 +70,7 @@ frozenlist==1.8.0
# aiosignal
gitdb==4.0.12
# via gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via -r requirements/base.txt
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
@@ -124,7 +128,7 @@ more-itertools==11.1.0
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.2.1
# via -r requirements/base.txt
multidict==6.7.1
# via
@@ -177,7 +181,7 @@ protobuf==6.33.6
# opentelemetry-proto
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
@@ -192,7 +196,7 @@ pygments==2.20.0
# via rich
pymssql==2.3.11 ; sys_platform == 'win32'
# via -r requirements/base.txt
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -r requirements/base.txt
# -r requirements/static/pkg/freebsd.txt
@@ -224,22 +228,18 @@ requests==2.31.0 ; python_full_version < '3.11'
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # vultr
-requests==2.33.1 ; python_full_version >= '3.11'
+requests==2.34.2 ; python_full_version >= '3.11'
# via
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # 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
# -r requirements/static/pkg/freebsd.txt
-setuptools==82.0.0
+setuptools==84.0.0
# via
# -c requirements/constraints.txt
# zc-lockfile
@@ -281,8 +281,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 846450570ba4..0dc7cdf297f6 100644
--- a/requirements/static/pkg/py3.10/linux.lock
+++ b/requirements/static/pkg/py3.10/linux.lock
@@ -12,8 +12,10 @@ apache-libcloud==3.9.1
# via -r requirements/base.txt
async-timeout==4.0.3
# via aiohttp
-attrs==23.2.0
- # via aiohttp
+attrs==26.1.0
+ # via
+ # -r requirements/base.txt
+ # aiohttp
backports-tarfile==1.2.0
# via jaraco-context
certifi==2026.6.17
@@ -24,8 +26,10 @@ cffi==2.0.0
# via
# -r requirements/base.txt
# cryptography
-charset-normalizer==3.2.0
- # via requests
+charset-normalizer==3.5.1
+ # via
+ # -r requirements/base.txt
+ # requests
cheroot==11.1.2
# via
# -r requirements/base.txt
@@ -37,7 +41,7 @@ cherrypy==18.10.0
# -r requirements/static/pkg/linux.txt
croniter==6.2.2
# via -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -r requirements/base.txt
# -r requirements/static/pkg/linux.txt
@@ -58,7 +62,7 @@ frozenlist==1.8.0
# aiosignal
gitdb==4.0.12
# via gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via -r requirements/base.txt
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
@@ -111,7 +115,7 @@ more-itertools==11.1.0
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.2.1
# via -r requirements/base.txt
multidict==6.7.1
# via
@@ -162,7 +166,7 @@ protobuf==6.33.6
# opentelemetry-proto
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
@@ -175,7 +179,7 @@ pycryptodomex==3.23.0
# -r requirements/crypto.txt
pygments==2.20.0
# via rich
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -r requirements/base.txt
# -r requirements/static/pkg/linux.txt
@@ -201,18 +205,13 @@ requests==2.31.0
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # 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
# -r requirements/static/pkg/linux.txt
-setuptools==82.0.0
+setuptools==84.0.0
# via
# -c requirements/constraints.txt
# zc-lockfile
@@ -254,8 +253,6 @@ urllib3==2.7.0
# requests
virtualenv==21.4.2
# via -r requirements/base.txt
-vultr==1.0.1
- # via -r requirements/base.txt
xxhash==3.7.0
# via -r requirements/base.txt
yarl==1.20.1
diff --git a/requirements/static/pkg/py3.10/windows.lock b/requirements/static/pkg/py3.10/windows.lock
index 1a143a9717f2..7925b71aa350 100644
--- a/requirements/static/pkg/py3.10/windows.lock
+++ b/requirements/static/pkg/py3.10/windows.lock
@@ -12,8 +12,10 @@ apache-libcloud==3.9.1
# via -r requirements/base.txt
async-timeout==5.0.1
# via aiohttp
-attrs==25.4.0
- # via aiohttp
+attrs==26.1.0
+ # via
+ # -r requirements/base.txt
+ # aiohttp
backports-tarfile==1.2.0
# via jaraco-context
certifi==2026.6.17
@@ -25,8 +27,10 @@ cffi==2.0.0
# -r requirements/base.txt
# clr-loader
# cryptography
-charset-normalizer==3.4.4
- # via requests
+charset-normalizer==3.5.1
+ # via
+ # -r requirements/base.txt
+ # requests
cheroot==11.1.2
# via
# -r requirements/base.txt
@@ -39,7 +43,7 @@ clr-loader==0.2.10
# via pythonnet
colorama==0.4.6
# via click
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -r requirements/base.txt
# pyopenssl
@@ -59,7 +63,7 @@ frozenlist==1.8.0
# aiosignal
gitdb==4.0.12
# via gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via -r requirements/base.txt
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
@@ -111,7 +115,7 @@ more-itertools==11.1.0
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.2.1
# via -r requirements/base.txt
multidict==6.7.1
# via
@@ -162,7 +166,7 @@ protobuf==6.33.6
# opentelemetry-proto
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
@@ -176,7 +180,7 @@ pygments==2.19.2
# via rich
pymssql==2.3.11
# via -r requirements/base.txt
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via -r requirements/base.txt
python-dateutil==2.9.0.post0
# via
@@ -201,12 +205,11 @@ requests==2.31.0
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # vultr
rich==14.3.3
# via typer
setproctitle==1.3.7
# via -r requirements/base.txt
-setuptools==82.0.0
+setuptools==84.0.0
# via
# -c requirements/constraints.txt
# zc-lockfile
@@ -248,8 +251,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 1a030bd44268..cc3d884cf8ab 100644
--- a/requirements/static/pkg/py3.11/darwin.lock
+++ b/requirements/static/pkg/py3.11/darwin.lock
@@ -10,8 +10,10 @@ annotated-doc==0.0.4
# via typer
apache-libcloud==3.9.1
# via -r requirements/base.txt
-attrs==23.2.0
- # via aiohttp
+attrs==26.1.0
+ # via
+ # -r requirements/base.txt
+ # aiohttp
backports-tarfile==1.2.0
# via jaraco-context
certifi==2026.6.17
@@ -22,8 +24,10 @@ cffi==2.0.0
# via
# -r requirements/base.txt
# cryptography
-charset-normalizer==3.2.0
- # via requests
+charset-normalizer==3.5.1
+ # via
+ # -r requirements/base.txt
+ # requests
cheroot==11.1.2
# via
# -r requirements/base.txt
@@ -32,7 +36,7 @@ cherrypy==18.10.0
# via -r requirements/base.txt
croniter==6.2.2
# via -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -r requirements/base.txt
# pyopenssl
@@ -52,7 +56,7 @@ frozenlist==1.7.0
# aiosignal
gitdb==4.0.12
# via gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via -r requirements/base.txt
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
@@ -100,7 +104,7 @@ more-itertools==11.1.0
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.2.1
# via -r requirements/base.txt
multidict==6.7.1
# via
@@ -151,7 +155,7 @@ protobuf==6.33.6
# opentelemetry-proto
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
@@ -163,7 +167,7 @@ pycryptodomex==3.23.0
# -r requirements/crypto.txt
pygments==2.20.0
# via rich
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via -r requirements/base.txt
python-dateutil==2.9.0.post0
# via
@@ -179,17 +183,16 @@ pyyaml==6.0.3
# via -r requirements/base.txt
pyzmq==27.1.0
# via -r requirements/zeromq.txt
-requests==2.33.1
+requests==2.34.2
# via
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # vultr
rich==15.0.0
# via typer
setproctitle==1.3.7
# via -r requirements/base.txt
-setuptools==82.0.0
+setuptools==84.0.0
# via
# -c requirements/constraints.txt
# zc-lockfile
@@ -228,8 +231,6 @@ urllib3==2.7.0
# requests
virtualenv==21.4.2
# via -r requirements/base.txt
-vultr==1.0.1
- # via -r requirements/base.txt
xxhash==3.7.0
# via -r requirements/base.txt
yarl==1.20.1
diff --git a/requirements/static/pkg/py3.11/freebsd.lock b/requirements/static/pkg/py3.11/freebsd.lock
index 309a1f441858..0f65756593a5 100644
--- a/requirements/static/pkg/py3.11/freebsd.lock
+++ b/requirements/static/pkg/py3.11/freebsd.lock
@@ -10,8 +10,10 @@ annotated-doc==0.0.4
# via typer
apache-libcloud==3.9.1
# via -r requirements/base.txt
-attrs==23.2.0
- # via aiohttp
+attrs==26.1.0
+ # via
+ # -r requirements/base.txt
+ # aiohttp
backports-tarfile==1.2.0 ; python_full_version < '3.12'
# via jaraco-context
certifi==2026.6.17
@@ -24,8 +26,10 @@ cffi==2.0.0
# clr-loader
# cryptography
# pyzmq
-charset-normalizer==3.2.0
- # via requests
+charset-normalizer==3.5.1
+ # via
+ # -r requirements/base.txt
+ # requests
cheroot==11.1.2
# via
# -r requirements/base.txt
@@ -41,7 +45,7 @@ colorama==0.4.6 ; sys_platform == 'win32'
# via typer
croniter==6.2.2 ; sys_platform != 'win32'
# via -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -r requirements/base.txt
# -r requirements/static/pkg/freebsd.txt
@@ -64,7 +68,7 @@ frozenlist==1.7.0
# aiosignal
gitdb==4.0.12
# via gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via -r requirements/base.txt
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
@@ -118,7 +122,7 @@ more-itertools==11.1.0
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.2.1
# via -r requirements/base.txt
multidict==6.7.1
# via
@@ -171,7 +175,7 @@ protobuf==6.33.6
# opentelemetry-proto
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
@@ -186,7 +190,7 @@ pygments==2.20.0
# via rich
pymssql==2.3.11 ; sys_platform == 'win32'
# via -r requirements/base.txt
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -r requirements/base.txt
# -r requirements/static/pkg/freebsd.txt
@@ -213,21 +217,18 @@ pyyaml==6.0.3
# via -r requirements/base.txt
pyzmq==27.1.0
# via -r requirements/zeromq.txt
-requests==2.33.1
+requests==2.34.2
# via
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # 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
# -r requirements/static/pkg/freebsd.txt
-setuptools==82.0.0
+setuptools==84.0.0
# via
# -c requirements/constraints.txt
# zc-lockfile
@@ -266,8 +267,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 29e45a2b7853..f16ef496feed 100644
--- a/requirements/static/pkg/py3.11/linux.lock
+++ b/requirements/static/pkg/py3.11/linux.lock
@@ -10,8 +10,10 @@ annotated-doc==0.0.4
# via typer
apache-libcloud==3.9.1
# via -r requirements/base.txt
-attrs==23.2.0
- # via aiohttp
+attrs==26.1.0
+ # via
+ # -r requirements/base.txt
+ # aiohttp
backports-tarfile==1.2.0
# via jaraco-context
certifi==2026.6.17
@@ -22,8 +24,10 @@ cffi==2.0.0
# via
# -r requirements/base.txt
# cryptography
-charset-normalizer==3.2.0
- # via requests
+charset-normalizer==3.5.1
+ # via
+ # -r requirements/base.txt
+ # requests
cheroot==11.1.2
# via
# -r requirements/base.txt
@@ -35,7 +39,7 @@ cherrypy==18.10.0
# -r requirements/static/pkg/linux.txt
croniter==6.2.2
# via -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -r requirements/base.txt
# -r requirements/static/pkg/linux.txt
@@ -56,7 +60,7 @@ frozenlist==1.7.0
# aiosignal
gitdb==4.0.12
# via gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via -r requirements/base.txt
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
@@ -107,7 +111,7 @@ more-itertools==11.1.0
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.2.1
# via -r requirements/base.txt
multidict==6.7.1
# via
@@ -158,7 +162,7 @@ protobuf==6.33.6
# opentelemetry-proto
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
@@ -171,7 +175,7 @@ pycryptodomex==3.23.0
# -r requirements/crypto.txt
pygments==2.20.0
# via rich
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -r requirements/base.txt
# -r requirements/static/pkg/linux.txt
@@ -192,23 +196,18 @@ pyyaml==6.0.3
# via -r requirements/base.txt
pyzmq==27.1.0
# via -r requirements/zeromq.txt
-requests==2.33.1
+requests==2.34.2
# via
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # 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
# -r requirements/static/pkg/linux.txt
-setuptools==82.0.0
+setuptools==84.0.0
# via
# -c requirements/constraints.txt
# zc-lockfile
@@ -247,8 +246,6 @@ urllib3==2.7.0
# requests
virtualenv==21.4.2
# via -r requirements/base.txt
-vultr==1.0.1
- # via -r requirements/base.txt
xxhash==3.7.0
# via -r requirements/base.txt
yarl==1.20.1
diff --git a/requirements/static/pkg/py3.11/windows.lock b/requirements/static/pkg/py3.11/windows.lock
index 44d0f7a6a78f..1ba5c03ec946 100644
--- a/requirements/static/pkg/py3.11/windows.lock
+++ b/requirements/static/pkg/py3.11/windows.lock
@@ -10,8 +10,10 @@ annotated-doc==0.0.4
# via typer
apache-libcloud==3.9.1
# via -r requirements/base.txt
-attrs==25.4.0
- # via aiohttp
+attrs==26.1.0
+ # via
+ # -r requirements/base.txt
+ # aiohttp
backports-tarfile==1.2.0
# via jaraco-context
certifi==2026.6.17
@@ -23,8 +25,10 @@ cffi==2.0.0
# -r requirements/base.txt
# clr-loader
# cryptography
-charset-normalizer==3.4.4
- # via requests
+charset-normalizer==3.5.1
+ # via
+ # -r requirements/base.txt
+ # requests
cheroot==11.1.2
# via
# -r requirements/base.txt
@@ -37,7 +41,7 @@ clr-loader==0.2.10
# via pythonnet
colorama==0.4.6
# via click
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -r requirements/base.txt
# pyopenssl
@@ -57,7 +61,7 @@ frozenlist==1.8.0
# aiosignal
gitdb==4.0.12
# via gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via -r requirements/base.txt
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
@@ -107,7 +111,7 @@ more-itertools==11.1.0
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.2.1
# via -r requirements/base.txt
multidict==6.7.1
# via
@@ -158,7 +162,7 @@ protobuf==6.33.6
# opentelemetry-proto
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
@@ -172,7 +176,7 @@ pygments==2.19.2
# via rich
pymssql==2.3.11
# via -r requirements/base.txt
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via -r requirements/base.txt
python-dateutil==2.9.0.post0
# via
@@ -192,17 +196,16 @@ pyyaml==6.0.3
# via -r requirements/base.txt
pyzmq==27.1.0
# via -r requirements/zeromq.txt
-requests==2.33.1
+requests==2.34.2
# via
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # vultr
rich==14.3.3
# via typer
setproctitle==1.3.7
# via -r requirements/base.txt
-setuptools==82.0.0
+setuptools==84.0.0
# via
# -c requirements/constraints.txt
# zc-lockfile
@@ -241,8 +244,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 a92ff4c65583..1799b44edd03 100644
--- a/requirements/static/pkg/py3.12/darwin.lock
+++ b/requirements/static/pkg/py3.12/darwin.lock
@@ -10,8 +10,10 @@ annotated-doc==0.0.4
# via typer
apache-libcloud==3.9.1
# via -r requirements/base.txt
-attrs==23.2.0
- # via aiohttp
+attrs==26.1.0
+ # via
+ # -r requirements/base.txt
+ # aiohttp
certifi==2026.6.17
# via
# -r requirements/base.txt
@@ -20,8 +22,10 @@ cffi==2.0.0
# via
# -r requirements/base.txt
# cryptography
-charset-normalizer==3.2.0
- # via requests
+charset-normalizer==3.5.1
+ # via
+ # -r requirements/base.txt
+ # requests
cheroot==11.1.2
# via
# -r requirements/base.txt
@@ -30,7 +34,7 @@ cherrypy==18.10.0
# via -r requirements/base.txt
croniter==6.2.2
# via -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -r requirements/base.txt
# pyopenssl
@@ -50,7 +54,7 @@ frozenlist==1.7.0
# aiosignal
gitdb==4.0.12
# via gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via -r requirements/base.txt
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
@@ -98,7 +102,7 @@ more-itertools==11.1.0
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.2.1
# via -r requirements/base.txt
multidict==6.7.1
# via
@@ -149,7 +153,7 @@ protobuf==6.33.6
# opentelemetry-proto
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
@@ -161,7 +165,7 @@ pycryptodomex==3.23.0
# -r requirements/crypto.txt
pygments==2.20.0
# via rich
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via -r requirements/base.txt
python-dateutil==2.9.0.post0
# via
@@ -177,17 +181,16 @@ pyyaml==6.0.3
# via -r requirements/base.txt
pyzmq==27.1.0
# via -r requirements/zeromq.txt
-requests==2.33.1
+requests==2.34.2
# via
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # vultr
rich==15.0.0
# via typer
setproctitle==1.3.7
# via -r requirements/base.txt
-setuptools==82.0.0
+setuptools==84.0.0
# via
# -c requirements/constraints.txt
# zc-lockfile
@@ -226,8 +229,6 @@ urllib3==2.7.0
# requests
virtualenv==21.4.2
# via -r requirements/base.txt
-vultr==1.0.1
- # via -r requirements/base.txt
xxhash==3.7.0
# via -r requirements/base.txt
yarl==1.20.1
diff --git a/requirements/static/pkg/py3.12/freebsd.lock b/requirements/static/pkg/py3.12/freebsd.lock
index 9887d7d2f557..80e8d8bea600 100644
--- a/requirements/static/pkg/py3.12/freebsd.lock
+++ b/requirements/static/pkg/py3.12/freebsd.lock
@@ -10,8 +10,10 @@ annotated-doc==0.0.4
# via typer
apache-libcloud==3.9.1
# via -r requirements/base.txt
-attrs==23.2.0
- # via aiohttp
+attrs==26.1.0
+ # via
+ # -r requirements/base.txt
+ # aiohttp
certifi==2026.6.17
# via
# -r requirements/base.txt
@@ -22,8 +24,10 @@ cffi==2.0.0
# clr-loader
# cryptography
# pyzmq
-charset-normalizer==3.2.0
- # via requests
+charset-normalizer==3.5.1
+ # via
+ # -r requirements/base.txt
+ # requests
cheroot==11.1.2
# via
# -r requirements/base.txt
@@ -39,7 +43,7 @@ colorama==0.4.6 ; sys_platform == 'win32'
# via typer
croniter==6.2.2 ; sys_platform != 'win32'
# via -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -r requirements/base.txt
# -r requirements/static/pkg/freebsd.txt
@@ -62,7 +66,7 @@ frozenlist==1.7.0
# aiosignal
gitdb==4.0.12
# via gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via -r requirements/base.txt
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
@@ -116,7 +120,7 @@ more-itertools==11.1.0
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.2.1
# via -r requirements/base.txt
multidict==6.7.1
# via
@@ -169,7 +173,7 @@ protobuf==6.33.6
# opentelemetry-proto
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
@@ -184,7 +188,7 @@ pygments==2.20.0
# via rich
pymssql==2.3.11 ; sys_platform == 'win32'
# via -r requirements/base.txt
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -r requirements/base.txt
# -r requirements/static/pkg/freebsd.txt
@@ -211,21 +215,18 @@ pyyaml==6.0.3
# via -r requirements/base.txt
pyzmq==27.1.0
# via -r requirements/zeromq.txt
-requests==2.33.1
+requests==2.34.2
# via
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # 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
# -r requirements/static/pkg/freebsd.txt
-setuptools==82.0.0
+setuptools==84.0.0
# via
# -c requirements/constraints.txt
# zc-lockfile
@@ -264,8 +265,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 ab77aa957253..b38e3764d252 100644
--- a/requirements/static/pkg/py3.12/linux.lock
+++ b/requirements/static/pkg/py3.12/linux.lock
@@ -10,8 +10,10 @@ annotated-doc==0.0.4
# via typer
apache-libcloud==3.9.1
# via -r requirements/base.txt
-attrs==23.2.0
- # via aiohttp
+attrs==26.1.0
+ # via
+ # -r requirements/base.txt
+ # aiohttp
certifi==2026.6.17
# via
# -r requirements/base.txt
@@ -20,8 +22,10 @@ cffi==2.0.0
# via
# -r requirements/base.txt
# cryptography
-charset-normalizer==3.2.0
- # via requests
+charset-normalizer==3.5.1
+ # via
+ # -r requirements/base.txt
+ # requests
cheroot==11.1.2
# via
# -r requirements/base.txt
@@ -33,7 +37,7 @@ cherrypy==18.10.0
# -r requirements/static/pkg/linux.txt
croniter==6.2.2
# via -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -r requirements/base.txt
# -r requirements/static/pkg/linux.txt
@@ -54,7 +58,7 @@ frozenlist==1.7.0
# aiosignal
gitdb==4.0.12
# via gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via -r requirements/base.txt
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
@@ -105,7 +109,7 @@ more-itertools==11.1.0
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.2.1
# via -r requirements/base.txt
multidict==6.7.1
# via
@@ -156,7 +160,7 @@ protobuf==6.33.6
# opentelemetry-proto
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
@@ -169,7 +173,7 @@ pycryptodomex==3.23.0
# -r requirements/crypto.txt
pygments==2.20.0
# via rich
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -r requirements/base.txt
# -r requirements/static/pkg/linux.txt
@@ -190,23 +194,18 @@ pyyaml==6.0.3
# via -r requirements/base.txt
pyzmq==27.1.0
# via -r requirements/zeromq.txt
-requests==2.33.1
+requests==2.34.2
# via
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # 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
# -r requirements/static/pkg/linux.txt
-setuptools==82.0.0
+setuptools==84.0.0
# via
# -c requirements/constraints.txt
# zc-lockfile
@@ -245,8 +244,6 @@ urllib3==2.7.0
# requests
virtualenv==21.4.2
# via -r requirements/base.txt
-vultr==1.0.1
- # via -r requirements/base.txt
xxhash==3.7.0
# via -r requirements/base.txt
yarl==1.20.1
diff --git a/requirements/static/pkg/py3.12/windows.lock b/requirements/static/pkg/py3.12/windows.lock
index cc82667e54da..56a1d02f8ca7 100644
--- a/requirements/static/pkg/py3.12/windows.lock
+++ b/requirements/static/pkg/py3.12/windows.lock
@@ -10,8 +10,10 @@ annotated-doc==0.0.4
# via typer
apache-libcloud==3.9.1
# via -r requirements/base.txt
-attrs==25.4.0
- # via aiohttp
+attrs==26.1.0
+ # via
+ # -r requirements/base.txt
+ # aiohttp
certifi==2026.6.17
# via
# -r requirements/base.txt
@@ -21,8 +23,10 @@ cffi==2.0.0
# -r requirements/base.txt
# clr-loader
# cryptography
-charset-normalizer==3.4.4
- # via requests
+charset-normalizer==3.5.1
+ # via
+ # -r requirements/base.txt
+ # requests
cheroot==11.1.2
# via
# -r requirements/base.txt
@@ -35,7 +39,7 @@ clr-loader==0.2.10
# via pythonnet
colorama==0.4.6
# via click
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -r requirements/base.txt
# pyopenssl
@@ -55,7 +59,7 @@ frozenlist==1.8.0
# aiosignal
gitdb==4.0.12
# via gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via -r requirements/base.txt
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
@@ -105,7 +109,7 @@ more-itertools==11.1.0
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.2.1
# via -r requirements/base.txt
multidict==6.7.1
# via
@@ -156,7 +160,7 @@ protobuf==6.33.6
# opentelemetry-proto
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
@@ -170,7 +174,7 @@ pygments==2.19.2
# via rich
pymssql==2.3.11
# via -r requirements/base.txt
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via -r requirements/base.txt
python-dateutil==2.9.0.post0
# via
@@ -190,17 +194,16 @@ pyyaml==6.0.3
# via -r requirements/base.txt
pyzmq==27.1.0
# via -r requirements/zeromq.txt
-requests==2.33.1
+requests==2.34.2
# via
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # vultr
rich==14.3.3
# via typer
setproctitle==1.3.7
# via -r requirements/base.txt
-setuptools==82.0.0
+setuptools==84.0.0
# via
# -c requirements/constraints.txt
# zc-lockfile
@@ -239,8 +242,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 4c18083fbf5b..07df59d49256 100644
--- a/requirements/static/pkg/py3.13/darwin.lock
+++ b/requirements/static/pkg/py3.13/darwin.lock
@@ -10,8 +10,10 @@ annotated-doc==0.0.4
# via typer
apache-libcloud==3.9.1
# via -r requirements/base.txt
-attrs==25.4.0
- # via aiohttp
+attrs==26.1.0
+ # via
+ # -r requirements/base.txt
+ # aiohttp
certifi==2026.6.17
# via
# -r requirements/base.txt
@@ -20,8 +22,10 @@ cffi==2.0.0
# via
# -r requirements/base.txt
# cryptography
-charset-normalizer==3.4.4
- # via requests
+charset-normalizer==3.5.1
+ # via
+ # -r requirements/base.txt
+ # requests
cheroot==11.1.2
# via
# -r requirements/base.txt
@@ -30,7 +34,7 @@ cherrypy==18.10.0
# via -r requirements/base.txt
croniter==6.2.2
# via -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -r requirements/base.txt
# pyopenssl
@@ -50,7 +54,7 @@ frozenlist==1.8.0
# aiosignal
gitdb==4.0.12
# via gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via -r requirements/base.txt
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
@@ -98,7 +102,7 @@ more-itertools==11.1.0
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.2.1
# via -r requirements/base.txt
multidict==6.7.0
# via
@@ -149,7 +153,7 @@ protobuf==6.33.6
# opentelemetry-proto
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
@@ -161,7 +165,7 @@ pycryptodomex==3.23.0
# -r requirements/crypto.txt
pygments==2.20.0
# via rich
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via -r requirements/base.txt
python-dateutil==2.9.0.post0
# via
@@ -176,17 +180,16 @@ pyyaml==6.0.3
# via -r requirements/base.txt
pyzmq==27.1.0
# via -r requirements/zeromq.txt
-requests==2.33.1
+requests==2.34.2
# via
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # vultr
rich==15.0.0
# via typer
setproctitle==1.3.7
# via -r requirements/base.txt
-setuptools==82.0.0
+setuptools==84.0.0
# via
# -c requirements/constraints.txt
# zc-lockfile
@@ -222,8 +225,6 @@ urllib3==2.7.0
# requests
virtualenv==21.4.2
# via -r requirements/base.txt
-vultr==1.0.1
- # via -r requirements/base.txt
xxhash==3.7.0
# via -r requirements/base.txt
yarl==1.22.0
diff --git a/requirements/static/pkg/py3.13/freebsd.lock b/requirements/static/pkg/py3.13/freebsd.lock
index 611291a34c0a..52e8d3ec693f 100644
--- a/requirements/static/pkg/py3.13/freebsd.lock
+++ b/requirements/static/pkg/py3.13/freebsd.lock
@@ -10,8 +10,10 @@ annotated-doc==0.0.4
# via typer
apache-libcloud==3.9.1
# via -r requirements/base.txt
-attrs==25.4.0
- # via aiohttp
+attrs==26.1.0
+ # via
+ # -r requirements/base.txt
+ # aiohttp
certifi==2026.6.17
# via
# -r requirements/base.txt
@@ -22,8 +24,10 @@ cffi==2.0.0
# clr-loader
# cryptography
# pyzmq
-charset-normalizer==3.4.4
- # via requests
+charset-normalizer==3.5.1
+ # via
+ # -r requirements/base.txt
+ # requests
cheroot==11.1.2
# via
# -r requirements/base.txt
@@ -39,7 +43,7 @@ colorama==0.4.6 ; sys_platform == 'win32'
# via typer
croniter==6.2.2 ; sys_platform != 'win32'
# via -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -r requirements/base.txt
# -r requirements/static/pkg/freebsd.txt
@@ -62,7 +66,7 @@ frozenlist==1.8.0
# aiosignal
gitdb==4.0.12
# via gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via -r requirements/base.txt
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
@@ -116,7 +120,7 @@ more-itertools==11.1.0
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.2.1
# via -r requirements/base.txt
multidict==6.7.0
# via
@@ -169,7 +173,7 @@ protobuf==6.33.6
# opentelemetry-proto
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
@@ -184,7 +188,7 @@ pygments==2.20.0
# via rich
pymssql==2.3.11 ; sys_platform == 'win32'
# via -r requirements/base.txt
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -r requirements/base.txt
# -r requirements/static/pkg/freebsd.txt
@@ -210,21 +214,18 @@ pyyaml==6.0.3
# via -r requirements/base.txt
pyzmq==27.1.0
# via -r requirements/zeromq.txt
-requests==2.33.1
+requests==2.34.2
# via
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # 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
# -r requirements/static/pkg/freebsd.txt
-setuptools==82.0.0
+setuptools==84.0.0
# via
# -c requirements/constraints.txt
# zc-lockfile
@@ -260,8 +261,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 a85487b0b420..e183bf53d2eb 100644
--- a/requirements/static/pkg/py3.13/linux.lock
+++ b/requirements/static/pkg/py3.13/linux.lock
@@ -10,8 +10,10 @@ annotated-doc==0.0.4
# via typer
apache-libcloud==3.9.1
# via -r requirements/base.txt
-attrs==25.4.0
- # via aiohttp
+attrs==26.1.0
+ # via
+ # -r requirements/base.txt
+ # aiohttp
certifi==2026.6.17
# via
# -r requirements/base.txt
@@ -20,8 +22,10 @@ cffi==2.0.0
# via
# -r requirements/base.txt
# cryptography
-charset-normalizer==3.4.4
- # via requests
+charset-normalizer==3.5.1
+ # via
+ # -r requirements/base.txt
+ # requests
cheroot==11.1.2
# via
# -r requirements/base.txt
@@ -33,7 +37,7 @@ cherrypy==18.10.0
# -r requirements/static/pkg/linux.txt
croniter==6.2.2
# via -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -r requirements/base.txt
# -r requirements/static/pkg/linux.txt
@@ -54,7 +58,7 @@ frozenlist==1.8.0
# aiosignal
gitdb==4.0.12
# via gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via -r requirements/base.txt
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
@@ -105,7 +109,7 @@ more-itertools==11.1.0
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.2.1
# via -r requirements/base.txt
multidict==6.7.0
# via
@@ -156,7 +160,7 @@ protobuf==6.33.6
# opentelemetry-proto
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
@@ -169,7 +173,7 @@ pycryptodomex==3.23.0
# -r requirements/crypto.txt
pygments==2.20.0
# via rich
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -r requirements/base.txt
# -r requirements/static/pkg/linux.txt
@@ -189,23 +193,18 @@ pyyaml==6.0.3
# via -r requirements/base.txt
pyzmq==27.1.0
# via -r requirements/zeromq.txt
-requests==2.33.1
+requests==2.34.2
# via
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # 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
# -r requirements/static/pkg/linux.txt
-setuptools==82.0.0
+setuptools==84.0.0
# via
# -c requirements/constraints.txt
# zc-lockfile
@@ -241,8 +240,6 @@ urllib3==2.7.0
# requests
virtualenv==21.4.2
# via -r requirements/base.txt
-vultr==1.0.1
- # via -r requirements/base.txt
xxhash==3.7.0
# via -r requirements/base.txt
yarl==1.22.0
diff --git a/requirements/static/pkg/py3.13/windows.lock b/requirements/static/pkg/py3.13/windows.lock
index e2c72ef1a963..c5e78d8d8c20 100644
--- a/requirements/static/pkg/py3.13/windows.lock
+++ b/requirements/static/pkg/py3.13/windows.lock
@@ -10,8 +10,10 @@ annotated-doc==0.0.4
# via typer
apache-libcloud==3.9.1
# via -r requirements/base.txt
-attrs==25.4.0
- # via aiohttp
+attrs==26.1.0
+ # via
+ # -r requirements/base.txt
+ # aiohttp
certifi==2026.6.17
# via
# -r requirements/base.txt
@@ -21,8 +23,10 @@ cffi==2.0.0
# -r requirements/base.txt
# clr-loader
# cryptography
-charset-normalizer==3.4.4
- # via requests
+charset-normalizer==3.5.1
+ # via
+ # -r requirements/base.txt
+ # requests
cheroot==11.1.2
# via
# -r requirements/base.txt
@@ -35,7 +39,7 @@ clr-loader==0.3.1
# via pythonnet
colorama==0.4.6
# via click
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -r requirements/base.txt
# pyopenssl
@@ -55,7 +59,7 @@ frozenlist==1.8.0
# aiosignal
gitdb==4.0.12
# via gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via -r requirements/base.txt
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
@@ -105,7 +109,7 @@ more-itertools==11.1.0
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.2.1
# via -r requirements/base.txt
multidict==6.7.1
# via
@@ -156,7 +160,7 @@ protobuf==6.33.6
# opentelemetry-proto
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
@@ -170,7 +174,7 @@ pygments==2.19.2
# via rich
pymssql==2.3.11
# via -r requirements/base.txt
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via -r requirements/base.txt
python-dateutil==2.9.0.post0
# via
@@ -190,17 +194,16 @@ pyyaml==6.0.3
# via -r requirements/base.txt
pyzmq==27.1.0
# via -r requirements/zeromq.txt
-requests==2.33.1
+requests==2.34.2
# via
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # vultr
rich==14.3.3
# via typer
setproctitle==1.3.7
# via -r requirements/base.txt
-setuptools==82.0.0
+setuptools==84.0.0
# via
# -c requirements/constraints.txt
# zc-lockfile
@@ -236,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
# 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 edabae09d1a3..ff2d5c65317a 100644
--- a/requirements/static/pkg/py3.14/darwin.lock
+++ b/requirements/static/pkg/py3.14/darwin.lock
@@ -10,8 +10,10 @@ annotated-doc==0.0.4
# via typer
apache-libcloud==3.9.1
# via -r requirements/base.txt
-attrs==25.4.0
- # via aiohttp
+attrs==26.1.0
+ # via
+ # -r requirements/base.txt
+ # aiohttp
certifi==2026.6.17
# via
# -r requirements/base.txt
@@ -20,8 +22,10 @@ cffi==2.0.0
# via
# -r requirements/base.txt
# cryptography
-charset-normalizer==3.4.4
- # via requests
+charset-normalizer==3.5.1
+ # via
+ # -r requirements/base.txt
+ # requests
cheroot==11.1.2
# via
# -r requirements/base.txt
@@ -30,7 +34,7 @@ cherrypy==18.10.0
# via -r requirements/base.txt
croniter==6.2.2
# via -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -r requirements/base.txt
# pyopenssl
@@ -50,7 +54,7 @@ frozenlist==1.8.0
# aiosignal
gitdb==4.0.12
# via gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via -r requirements/base.txt
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
@@ -98,7 +102,7 @@ more-itertools==11.1.0
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.2.1
# via -r requirements/base.txt
multidict==6.7.0
# via
@@ -149,7 +153,7 @@ protobuf==6.33.6
# opentelemetry-proto
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
@@ -161,7 +165,7 @@ pycryptodomex==3.23.0
# -r requirements/crypto.txt
pygments==2.20.0
# via rich
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via -r requirements/base.txt
python-dateutil==2.9.0.post0
# via
@@ -176,17 +180,16 @@ pyyaml==6.0.3
# via -r requirements/base.txt
pyzmq==27.1.0
# via -r requirements/zeromq.txt
-requests==2.33.1
+requests==2.34.2
# via
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # vultr
rich==15.0.0
# via typer
setproctitle==1.3.7
# via -r requirements/base.txt
-setuptools==82.0.0
+setuptools==84.0.0
# via
# -c requirements/constraints.txt
# zc-lockfile
@@ -222,8 +225,6 @@ urllib3==2.7.0
# requests
virtualenv==21.4.2
# via -r requirements/base.txt
-vultr==1.0.1
- # via -r requirements/base.txt
xxhash==3.7.0
# via -r requirements/base.txt
yarl==1.22.0
diff --git a/requirements/static/pkg/py3.14/freebsd.lock b/requirements/static/pkg/py3.14/freebsd.lock
index fd9fa2550379..904735dc7200 100644
--- a/requirements/static/pkg/py3.14/freebsd.lock
+++ b/requirements/static/pkg/py3.14/freebsd.lock
@@ -10,8 +10,10 @@ annotated-doc==0.0.4
# via typer
apache-libcloud==3.9.1
# via -r requirements/base.txt
-attrs==25.4.0
- # via aiohttp
+attrs==26.1.0
+ # via
+ # -r requirements/base.txt
+ # aiohttp
certifi==2026.6.17
# via
# -r requirements/base.txt
@@ -22,8 +24,10 @@ cffi==2.0.0
# clr-loader
# cryptography
# pyzmq
-charset-normalizer==3.4.4
- # via requests
+charset-normalizer==3.5.1
+ # via
+ # -r requirements/base.txt
+ # requests
cheroot==11.1.2
# via
# -r requirements/base.txt
@@ -39,7 +43,7 @@ colorama==0.4.6 ; sys_platform == 'win32'
# via typer
croniter==6.2.2 ; sys_platform != 'win32'
# via -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -r requirements/base.txt
# -r requirements/static/pkg/freebsd.txt
@@ -62,7 +66,7 @@ frozenlist==1.8.0
# aiosignal
gitdb==4.0.12
# via gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via -r requirements/base.txt
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
@@ -116,7 +120,7 @@ more-itertools==11.1.0
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.2.1
# via -r requirements/base.txt
multidict==6.7.0
# via
@@ -167,7 +171,7 @@ protobuf==6.33.6
# opentelemetry-proto
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
@@ -182,7 +186,7 @@ pygments==2.20.0
# via rich
pymssql==2.3.11 ; sys_platform == 'win32'
# via -r requirements/base.txt
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -r requirements/base.txt
# -r requirements/static/pkg/freebsd.txt
@@ -208,21 +212,18 @@ pyyaml==6.0.3
# via -r requirements/base.txt
pyzmq==27.1.0
# via -r requirements/zeromq.txt
-requests==2.33.1
+requests==2.34.2
# via
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # 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
# -r requirements/static/pkg/freebsd.txt
-setuptools==82.0.0
+setuptools==84.0.0
# via
# -c requirements/constraints.txt
# zc-lockfile
@@ -258,8 +259,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 e433407d9ab8..a25af416bc39 100644
--- a/requirements/static/pkg/py3.14/linux.lock
+++ b/requirements/static/pkg/py3.14/linux.lock
@@ -10,8 +10,10 @@ annotated-doc==0.0.4
# via typer
apache-libcloud==3.9.1
# via -r requirements/base.txt
-attrs==25.4.0
- # via aiohttp
+attrs==26.1.0
+ # via
+ # -r requirements/base.txt
+ # aiohttp
certifi==2026.6.17
# via
# -r requirements/base.txt
@@ -20,8 +22,10 @@ cffi==2.0.0
# via
# -r requirements/base.txt
# cryptography
-charset-normalizer==3.4.4
- # via requests
+charset-normalizer==3.5.1
+ # via
+ # -r requirements/base.txt
+ # requests
cheroot==11.1.2
# via
# -r requirements/base.txt
@@ -33,7 +37,7 @@ cherrypy==18.10.0
# -r requirements/static/pkg/linux.txt
croniter==6.2.2
# via -r requirements/base.txt
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -r requirements/base.txt
# -r requirements/static/pkg/linux.txt
@@ -54,7 +58,7 @@ frozenlist==1.8.0
# aiosignal
gitdb==4.0.12
# via gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via -r requirements/base.txt
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
@@ -105,7 +109,7 @@ more-itertools==11.1.0
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.2.1
# via -r requirements/base.txt
multidict==6.7.0
# via
@@ -156,7 +160,7 @@ protobuf==6.33.6
# opentelemetry-proto
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
@@ -169,7 +173,7 @@ pycryptodomex==3.23.0
# -r requirements/crypto.txt
pygments==2.20.0
# via rich
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via
# -r requirements/base.txt
# -r requirements/static/pkg/linux.txt
@@ -189,23 +193,18 @@ pyyaml==6.0.3
# via -r requirements/base.txt
pyzmq==27.1.0
# via -r requirements/zeromq.txt
-requests==2.33.1
+requests==2.34.2
# via
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # 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
# -r requirements/static/pkg/linux.txt
-setuptools==82.0.0
+setuptools==84.0.0
# via
# -c requirements/constraints.txt
# zc-lockfile
@@ -241,8 +240,6 @@ urllib3==2.7.0
# requests
virtualenv==21.4.2
# via -r requirements/base.txt
-vultr==1.0.1
- # via -r requirements/base.txt
xxhash==3.7.0
# via -r requirements/base.txt
yarl==1.22.0
diff --git a/requirements/static/pkg/py3.14/windows.lock b/requirements/static/pkg/py3.14/windows.lock
index 330b830ddff0..cd0aeb9bfb51 100644
--- a/requirements/static/pkg/py3.14/windows.lock
+++ b/requirements/static/pkg/py3.14/windows.lock
@@ -10,8 +10,10 @@ annotated-doc==0.0.4
# via typer
apache-libcloud==3.9.1
# via -r requirements/base.txt
-attrs==25.4.0
- # via aiohttp
+attrs==26.1.0
+ # via
+ # -r requirements/base.txt
+ # aiohttp
certifi==2026.6.17
# via
# -r requirements/base.txt
@@ -21,8 +23,10 @@ cffi==2.0.0
# -r requirements/base.txt
# clr-loader
# cryptography
-charset-normalizer==3.4.4
- # via requests
+charset-normalizer==3.5.1
+ # via
+ # -r requirements/base.txt
+ # requests
cheroot==11.1.2
# via
# -r requirements/base.txt
@@ -35,7 +39,7 @@ clr-loader==0.3.1
# via pythonnet
colorama==0.4.6
# via click
-cryptography==48.0.0
+cryptography==50.0.1
# via
# -r requirements/base.txt
# pyopenssl
@@ -55,7 +59,7 @@ frozenlist==1.8.0
# aiosignal
gitdb==4.0.12
# via gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via -r requirements/base.txt
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
@@ -105,7 +109,7 @@ more-itertools==11.1.0
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.2.1
# via -r requirements/base.txt
multidict==6.7.1
# via
@@ -156,7 +160,7 @@ protobuf==6.33.6
# opentelemetry-proto
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
@@ -170,7 +174,7 @@ pygments==2.19.2
# via rich
pymssql==2.3.11
# via -r requirements/base.txt
-pyopenssl==26.2.0
+pyopenssl==26.4.0
# via -r requirements/base.txt
python-dateutil==2.9.0.post0
# via
@@ -190,17 +194,16 @@ pyyaml==6.0.3
# via -r requirements/base.txt
pyzmq==27.1.0
# via -r requirements/zeromq.txt
-requests==2.33.1
+requests==2.34.2
# via
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # vultr
rich==14.3.3
# via typer
setproctitle==1.3.7
# via -r requirements/base.txt
-setuptools==82.0.0
+setuptools==84.0.0
# via
# -c requirements/constraints.txt
# zc-lockfile
@@ -236,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
# 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 d8988ba037f8..decd3a1d6c60 100644
--- a/requirements/static/pkg/py3.9/darwin.lock
+++ b/requirements/static/pkg/py3.9/darwin.lock
@@ -56,7 +56,7 @@ frozenlist==1.8.0
# aiosignal
gitdb==4.0.12
# via gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via -r requirements/base.txt
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
@@ -159,7 +159,7 @@ protobuf==6.33.6
# opentelemetry-proto
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
@@ -192,12 +192,11 @@ requests==2.32.5
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # vultr
rich==15.0.0
# via typer
setproctitle==1.3.7
# via -r requirements/base.txt
-setuptools==82.0.0
+setuptools==82.0.1
# via
# -c requirements/constraints.txt
# zc-lockfile
@@ -237,8 +236,6 @@ urllib3==1.26.20
# requests
virtualenv==21.4.2
# via -r requirements/base.txt
-vultr==1.0.1
- # via -r requirements/base.txt
xxhash==3.7.0
# via -r requirements/base.txt
yarl==1.20.1
diff --git a/requirements/static/pkg/py3.9/freebsd.lock b/requirements/static/pkg/py3.9/freebsd.lock
index 3128807db5ed..c3ec29cc697b 100644
--- a/requirements/static/pkg/py3.9/freebsd.lock
+++ b/requirements/static/pkg/py3.9/freebsd.lock
@@ -16,8 +16,10 @@ apache-libcloud==3.9.1 ; python_full_version >= '3.10'
# via -r requirements/base.txt
async-timeout==4.0.3 ; python_full_version < '3.11'
# via aiohttp
-attrs==23.2.0
- # via aiohttp
+attrs==26.1.0
+ # via
+ # -r requirements/base.txt
+ # aiohttp
backports-tarfile==1.2.0 ; python_full_version < '3.12'
# via jaraco-context
certifi==2026.6.17
@@ -30,8 +32,10 @@ cffi==2.0.0
# clr-loader
# cryptography
# pyzmq
-charset-normalizer==3.2.0
- # via requests
+charset-normalizer==3.5.1
+ # via
+ # -r requirements/base.txt
+ # requests
cheroot==11.1.2
# via
# -r requirements/base.txt
@@ -58,7 +62,7 @@ cryptography==46.0.7 ; python_full_version < '3.10'
# -r requirements/base.txt
# -r requirements/static/pkg/freebsd.txt
# pyopenssl
-cryptography==48.0.0 ; python_full_version >= '3.10'
+cryptography==50.0.1 ; python_full_version >= '3.10'
# via
# -r requirements/base.txt
# -r requirements/static/pkg/freebsd.txt
@@ -86,7 +90,7 @@ frozenlist==1.8.0
# aiosignal
gitdb==4.0.12
# via gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via -r requirements/base.txt
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
@@ -166,7 +170,9 @@ more-itertools==11.1.0 ; python_full_version >= '3.10'
# cherrypy
# jaraco-functools
# jaraco-text
-msgpack==1.1.2
+msgpack==1.1.2 ; python_full_version < '3.10'
+ # via -r requirements/base.txt
+msgpack==1.2.1 ; python_full_version >= '3.10'
# via -r requirements/base.txt
multidict==6.7.1
# via
@@ -245,7 +251,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
@@ -265,7 +271,11 @@ pygments==2.20.0
# via rich
pymssql==2.3.11 ; sys_platform == 'win32'
# via -r requirements/base.txt
-pyopenssl==26.2.0
+pyopenssl==26.2.0 ; python_full_version < '3.10'
+ # via
+ # -r requirements/base.txt
+ # -r requirements/static/pkg/freebsd.txt
+pyopenssl==26.4.0 ; python_full_version >= '3.10'
# via
# -r requirements/base.txt
# -r requirements/static/pkg/freebsd.txt
@@ -300,28 +310,27 @@ requests==2.31.0 ; python_full_version == '3.10.*'
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # vultr
requests==2.32.5 ; python_full_version < '3.10'
# via
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # vultr
-requests==2.33.1 ; python_full_version >= '3.11'
+requests==2.34.2 ; python_full_version >= '3.11'
# via
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # 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
# -r requirements/static/pkg/freebsd.txt
-setuptools==82.0.0
+setuptools==82.0.1 ; python_full_version < '3.10'
+ # via
+ # -c requirements/constraints.txt
+ # zc-lockfile
+setuptools==84.0.0 ; python_full_version >= '3.10'
# via
# -c requirements/constraints.txt
# zc-lockfile
@@ -372,8 +381,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 3b70893fc4f3..9de836fb6636 100644
--- a/requirements/static/pkg/py3.9/linux.lock
+++ b/requirements/static/pkg/py3.9/linux.lock
@@ -60,7 +60,7 @@ frozenlist==1.8.0
# aiosignal
gitdb==4.0.12
# via gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via -r requirements/base.txt
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
@@ -165,7 +165,7 @@ protobuf==6.33.6
# opentelemetry-proto
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
@@ -204,18 +204,13 @@ requests==2.32.5
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # 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
# -r requirements/static/pkg/linux.txt
-setuptools==82.0.0
+setuptools==82.0.1
# via
# -c requirements/constraints.txt
# zc-lockfile
@@ -255,8 +250,6 @@ urllib3==1.26.20
# requests
virtualenv==21.4.2
# via -r requirements/base.txt
-vultr==1.0.1
- # via -r requirements/base.txt
xxhash==3.7.0
# via -r requirements/base.txt
yarl==1.20.1
diff --git a/requirements/static/pkg/py3.9/windows.lock b/requirements/static/pkg/py3.9/windows.lock
index daaa8bdfecf1..fb58e792a858 100644
--- a/requirements/static/pkg/py3.9/windows.lock
+++ b/requirements/static/pkg/py3.9/windows.lock
@@ -59,7 +59,7 @@ frozenlist==1.8.0
# aiosignal
gitdb==4.0.12
# via gitpython
-gitpython==3.1.50
+gitpython==3.1.60
# via -r requirements/base.txt
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
@@ -164,7 +164,7 @@ protobuf==6.33.6
# opentelemetry-proto
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
@@ -204,12 +204,11 @@ requests==2.32.5
# -r requirements/base.txt
# apache-libcloud
# opentelemetry-exporter-otlp-proto-http
- # vultr
rich==14.3.3
# via typer
setproctitle==1.3.7
# via -r requirements/base.txt
-setuptools==82.0.0
+setuptools==82.0.1
# via
# -c requirements/constraints.txt
# zc-lockfile
@@ -249,8 +248,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
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/auth/__init__.py b/salt/auth/__init__.py
index a14bdbf78478..0057aa102384 100644
--- a/salt/auth/__init__.py
+++ b/salt/auth/__init__.py
@@ -131,9 +131,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/salt/cache/__init__.py b/salt/cache/__init__.py
index 480cbfbdcb6c..da9f4fdd5576 100644
--- a/salt/cache/__init__.py
+++ b/salt/cache/__init__.py
@@ -6,6 +6,7 @@
import datetime
import logging
+import threading
import time
from collections import OrderedDict
@@ -374,6 +375,18 @@ class MemCache(Cache):
# {: odict({: [atime, data], ...}), ...}
data = {}
+ # Class-level lock guarding all mutations of ``data`` and any per-storage
+ # OrderedDict inside it. The MWorker async migration dispatches many
+ # AESFuncs / AuthFuncs handlers through ``loop.run_in_executor(...)``,
+ # so this class-level dict is now touched by arbitrary worker threads
+ # under concurrent load. Without a lock, ``dict changed size during
+ # iteration`` and torn read-modify-write sequences (e.g. the
+ # ``pop`` -> re-``__setitem__`` atime update in :meth:`fetch`) are
+ # observable under stress. A single class-level lock is acceptable
+ # because the mutations are cheap (dict/OrderedDict ops) and the
+ # cache is expressly a short-lived hot path where contention would
+ # never dominate against the actual cache-miss returner call.
+ _lock = threading.Lock()
def __init__(self, opts, **kwargs):
super().__init__(opts, **kwargs)
@@ -389,6 +402,10 @@ def __init__(self, opts, **kwargs):
@classmethod
def __cleanup(cls, expire):
now = time.time()
+ # Caller holds :attr:`_lock`; we iterate the class-level dict and
+ # every OrderedDict inside it, so mutation from another thread
+ # mid-iteration would raise ``RuntimeError: dictionary changed
+ # size during iteration``.
for storage in cls.data.values():
for key, data in list(storage.items()):
if data[0] + expire < now:
@@ -407,9 +424,14 @@ def _get_storage_id(self):
def storage(self):
if self._storage is None:
storage_id = self._get_storage_id()
- if storage_id not in MemCache.data:
- MemCache.data[storage_id] = OrderedDict()
- self._storage = MemCache.data[storage_id]
+ # Guard the check-then-create against two threads observing an
+ # empty ``MemCache.data`` slot and both writing a fresh
+ # OrderedDict (the second one would silently discard the first
+ # thread's stored records).
+ with MemCache._lock:
+ if storage_id not in MemCache.data:
+ MemCache.data[storage_id] = OrderedDict()
+ self._storage = MemCache.data[storage_id]
return self._storage
def fetch(self, bank, key):
@@ -417,55 +439,69 @@ def fetch(self, bank, key):
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
-
- # Have no value for the key or value is expired
+ storage = self.storage
+ with MemCache._lock:
+ record = 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
+ storage[(bank, key)] = record
+ return data
+
+ # Have no value for the key or value is expired. The underlying
+ # returner call (``super().fetch``) may perform disk / network I/O
+ # and must NOT hold the lock (would serialise all concurrent
+ # cache-miss loads across every MemCache instance in-process).
data = super().fetch(bank, key)
- 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)] = [now, self.expire, data]
+ with MemCache._lock:
+ if len(storage) >= self.max:
+ if self.cleanup:
+ MemCache.__cleanup(self.expire)
+ if len(storage) >= self.max:
+ storage.popitem(last=False)
+ storage[(bank, key)] = [now, self.expire, data]
return data
def store(self, bank, key, data, expires=None):
- self.storage.pop((bank, key), None)
+ storage = self.storage
+ with MemCache._lock:
+ storage.pop((bank, key), None)
+ # ``super().store`` calls the driver (disk / db); keep it off the
+ # lock so concurrent stores don't serialise on returner I/O.
super().store(bank, key, data, expires=expires)
- 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]
+ with MemCache._lock:
+ if len(storage) >= self.max:
+ if self.cleanup:
+ MemCache.__cleanup(self.expire)
+ if len(storage) >= self.max:
+ storage.popitem(last=False)
+ storage[(bank, key)] = [time.time(), expires, data]
def flush(self, bank, key=None):
- if key is None:
- for bank_, key_ in tuple(self.storage):
- if bank == bank_:
- self.storage.pop((bank_, key_))
- else:
- self.storage.pop((bank, key), None)
+ storage = self.storage
+ with MemCache._lock:
+ if key is None:
+ for bank_, key_ in tuple(storage):
+ if bank == bank_:
+ storage.pop((bank_, key_))
+ else:
+ storage.pop((bank, key), None)
+ # ``super().flush`` hits the driver; keep it off the lock.
super().flush(bank, key)
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/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/salt/channel/client.py b/salt/channel/client.py
index 1b31b29bba66..804fa4cd54d0 100644
--- a/salt/channel/client.py
+++ b/salt/channel/client.py
@@ -11,6 +11,7 @@
import tornado.gen
import tornado.ioloop
+import tornado.locks
import salt.crypt
import salt.exceptions
@@ -111,6 +112,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 = tornado.locks.Lock()
@property
def crypt(self):
@@ -122,7 +134,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.
@@ -130,6 +142,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:
@@ -162,7 +179,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)
elif isinstance(load, dict):
salt.utils.tracing.inject(load)
@@ -209,21 +228,25 @@ async def crypted_transfer_decode_dictentry(
if not self.auth.authenticated:
await self.auth.authenticate()
- nonce = uuid.uuid4().hex
- ret = await 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:
- # Reauth in the case our key is deleted on the master side.
- await 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:
+ nonce = uuid.uuid4().hex
ret = await 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:
+ # Reauth in the case our key is deleted on the master side.
+ await self.auth.authenticate()
+ ret = await 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
@@ -283,10 +306,14 @@ async def _crypted_transfer(self, load, timeout, raw=False):
"""
async 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(
- self._package_load(load, nonce),
+ self._package_load(load, nonce, session_crypticle=session_crypticle),
timeout=timeout,
)
# we may not have always data
@@ -294,7 +321,7 @@ async 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)
return data
@@ -302,13 +329,17 @@ async def _do_transfer():
if not self.auth.authenticated:
# Return control back to the caller, resume when authentication succeeds
await self.auth.authenticate()
- try:
- # We did not get data back the first time. Retry.
- ret = await _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()
+ # 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:
+ try:
+ # We did not get data back the first time. Retry.
+ ret = await _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
async def _uncrypted_transfer(self, load, timeout):
diff --git a/salt/channel/server.py b/salt/channel/server.py
index 4dd55d8400dd..4cdb2d9b80a9 100644
--- a/salt/channel/server.py
+++ b/salt/channel/server.py
@@ -14,14 +14,23 @@
import pathlib
import random
import string
+import threading
import time
import zlib
import tornado.ioloop
+try:
+ import setproctitle
+
+ HAS_SETPROCTITLE = True
+except ImportError:
+ HAS_SETPROCTITLE = False
+
import salt.cache
import salt.cluster.consensus.rpc
import salt.crypt
+import salt.daemons.masterapi
import salt.master
import salt.payload
import salt.transport
@@ -211,11 +220,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)
@@ -410,7 +430,7 @@ async def handle_message(self, payload):
# 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)
+ ret = await self._auth(payload["load"], sign_messages, version)
if self.opts.get("master_stats", False):
await self.payload_handler({"cmd": "_auth", "_start": start})
return ret
@@ -612,7 +632,7 @@ def validate_token(self, payload, required=True):
return False
return True
- def _auth(self, load, sign_messages=False, version=0):
+ async def _auth(self, load, sign_messages=False, version=0):
"""
Authenticate a minion by delegating to :class:`salt.master.AuthFuncs`.
@@ -630,10 +650,29 @@ def _auth(self, load, sign_messages=False, version=0):
af.event = self.event
af.master_key = self.master_key
af.sessions = self.sessions
- af.auto_key = getattr(self, "auto_key", None)
+ # PATCH: ``AuthFuncs.__init__`` sets ``_sessions_lock`` but this
+ # ``__new__``-based construction path bypasses ``__init__``.
+ # ``session_key`` (called from ``_auth_impl`` via
+ # ``run_in_executor``) does ``with self._sessions_lock:``, so
+ # without this the first auth attempt raises AttributeError.
+ # Per-call lock is fine here: only one ``_auth`` invocation
+ # touches ``af.sessions`` at a time.
+ af._sessions_lock = threading.Lock()
+ # PATCH: TCP path enters ``_auth`` via
+ # ``_handle_clear_auth_local`` which passes a ``proxy`` object
+ # that has no ``auto_key`` / ``ckminions``. Fall back to
+ # constructing a fresh ``AutoKey`` (cheap; wraps the same
+ # opts) rather than crashing with
+ # ``AttributeError: 'NoneType' object has no attribute
+ # 'check_autoreject'``.
+ af.auto_key = getattr(self, "auto_key", None) or salt.daemons.masterapi.AutoKey(
+ self.opts
+ )
af.cache_cli = getattr(self, "cache_cli", False)
- af.ckminions = getattr(self, "ckminions", None)
- return af._auth(load, sign_messages, version)
+ af.ckminions = getattr(self, "ckminions", None) or salt.utils.minions.CkMinions(
+ self.opts
+ )
+ return await af._auth(load, sign_messages, version)
def close(self):
self.transport.close()
@@ -695,14 +734,41 @@ def __init__(self, opts, transport, worker_pools):
self.opts = opts
self.transport = transport
self.worker_pools = worker_pools
- self.pool_clients = {} # pool_name -> RequestClient
+ # PATCH: was ``pool_name -> RequestClient`` (single client per
+ # pool). The IPC RequestClient holds one connection to
+ # whichever MWorker accepts first, so all traffic funnels to
+ # one MWorker regardless of ``worker_count``. Under stress
+ # this pins one MWorker at ~2 GB RSS with 32 executor threads
+ # while the other 9 sit idle at 69 MB. ZMQ transport dodges
+ # this because ``zmq_device_pooled``'s ROUTER-DEALER does the
+ # fanout in libzmq; TCP has no such shim.
+ #
+ # New: ``pool_name -> [RequestClient, ...]`` with one client
+ # per worker in the pool, plus a round-robin index so
+ # successive dispatches spread across MWorkers.
+ self.pool_clients = {} # pool_name -> list[RequestClient]
+ self.pool_client_next = {} # pool_name -> int (rr cursor)
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
+ # PATCH: cache one ``AutoKey`` per ``PoolRoutingChannel`` so the
+ # ``_auth`` fallback in ``_ensure_auth_support`` /
+ # ``_req_channel_auth_delegate`` can reuse it across every auth
+ # this channel handles. Pre-PR ``AuthFuncs.__init__``
+ # constructed ``AutoKey`` once per worker; when this class
+ # replaced that path with ``auto_key = None`` the fallback in
+ # ``ReqServerChannel._auth`` fired on every auth, rebuilding
+ # ``AutoKey`` each time and resetting its ``signing_files``
+ # mtime cache. That silently regresses masters with
+ # ``autosign_file`` / ``autoreject_file`` configured to O(N)
+ # disk reads under an auth storm (N minions restarting
+ # together) where the cached version does O(1) after the first
+ # mtime check per file. ``AutoKey.__init__`` only stores opts
+ # and inits an empty dict, so early construction here is safe.
+ self.auto_key = salt.daemons.masterapi.AutoKey(self.opts)
(pathlib.Path(self.opts["cachedir"]) / "sessions").mkdir(exist_ok=True)
self.sessions = {}
@@ -771,11 +837,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)
@@ -848,11 +925,27 @@ def pre_fork(self, process_manager, *args, **kwargs):
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"],
- )
+ # LTS default: single shared workers IPC socket per pool
+ # (pre-PR behavior). When ``master_async_mworker`` is
+ # enabled the RequestServer binds one socket per worker
+ # index and the PoolRouter fans out via per-worker
+ # RequestClients so every MWorker in the pool receives
+ # fair share of dispatch.
+ if self.opts.get("master_async_mworker", False):
+ pool_opts["pool_worker_count"] = int(config.get("worker_count", 1))
+ log.debug(
+ "Pool '%s' RequestServer using per-worker IPC sockets "
+ "(base: %s, count: %d)",
+ pool_name,
+ pool_opts["workers_ipc_name"],
+ pool_opts["pool_worker_count"],
+ )
+ else:
+ log.debug(
+ "Pool '%s' RequestServer using shared IPC socket: %s",
+ pool_name,
+ pool_opts["workers_ipc_name"],
+ )
# Create RequestServer for this pool using transport factory
try:
@@ -962,8 +1055,21 @@ def post_fork(self, payload_handler, io_loop, **kwargs):
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 RequestClients for each pool (connects to pool's IPC
+ # RequestServer). LTS default (``master_async_mworker`` off):
+ # single client per pool, matching pre-PR behavior. Async opt-in:
+ # one client per MWorker + round-robin dispatch so every worker
+ # in the pool receives fair share.
+ async_mworker = self.opts.get("master_async_mworker", False)
+ for pool_name, pool_cfg in self.worker_pools.items():
+ worker_count = max(1, int(pool_cfg.get("worker_count", 1)))
+ if not async_mworker:
+ # LTS default: single shared client per pool. Same as
+ # the pre-PR ``for pool_name in self.worker_pools.keys()``
+ # loop that ignored ``worker_count``.
+ client_count = 1
+ else:
+ client_count = worker_count
# Create pool-specific opts matching the pool's RequestServer
pool_opts = self.opts.copy()
pool_opts["pool_name"] = pool_name
@@ -977,28 +1083,62 @@ def post_fork(self, payload_handler, io_loop, **kwargs):
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 '%s' clients connecting to TCP port %d (count=%d)",
pool_name,
pool_opts["ret_port"],
+ client_count,
)
+ per_worker_uris = [pool_opts["master_uri"]] * client_count
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"],
- )
+ if async_mworker:
+ # Async opt-in: one IPC socket per worker index (matches
+ # per-worker binds in ``RequestServer.pre_fork``).
+ base = f"workers-{pool_name}"
+ per_worker_uris = []
+ for idx in range(client_count):
+ per_ipc = os.path.join(
+ self.opts["sock_dir"], f"{base}-{idx}.ipc"
+ )
+ per_worker_uris.append(f"ipc://{per_ipc}")
+ log.debug(
+ "Pool '%s' clients connecting to per-worker IPC sockets "
+ "(base: %s-{0..%d}.ipc)",
+ pool_name,
+ base,
+ client_count - 1,
+ )
+ else:
+ # LTS default: single shared IPC socket per pool
+ # (pre-PR behavior). Path matches the shared socket
+ # bound by ``RequestServer.pre_fork`` above.
+ 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}"
+ per_worker_uris = [pool_opts["master_uri"]]
+ log.debug(
+ "Pool '%s' client connecting to shared 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)
+ clients = []
+ for idx in range(client_count):
+ per_opts = pool_opts.copy()
+ per_opts["master_uri"] = per_worker_uris[idx]
+ if async_mworker and per_opts.get("ipc_mode") != "tcp":
+ per_opts["workers_ipc_name"] = f"workers-{pool_name}-{idx}.ipc"
+ # Use our dedicated request client factory for routing
+ clients.append(create_request_client(per_opts, io_loop))
+ self.pool_clients[pool_name] = clients
+ self.pool_client_next[pool_name] = 0
+ log.info(
+ "Created %d RequestClient(s) for pool '%s'",
+ client_count,
+ pool_name,
+ )
except Exception as exc: # pylint: disable=broad-except
log.error(
"Failed to create RequestClient for pool '%s': %s", pool_name, exc
@@ -1078,7 +1218,9 @@ async def _handle_clear_auth_local(self, payload, version):
and payload.get("load", {}).get("cmd") == "_auth"
):
start = time.time()
- ret = ReqServerChannel._auth(proxy, payload["load"], sign_messages, version)
+ ret = await ReqServerChannel._auth(
+ proxy, payload["load"], sign_messages, version
+ )
if self.opts.get("master_stats", False) and getattr(
self, "payload_handler", None
):
@@ -1218,8 +1360,13 @@ async def handle_and_route_message(self, payload):
)
return {"error": f"No client for pool {pool_name}"}
- # Forward to the appropriate pool's RequestServer via IPC
- client = self.pool_clients[pool_name]
+ # Forward to the appropriate pool's RequestServer via IPC.
+ # PATCH: round-robin across the per-pool client list so
+ # dispatch actually reaches every MWorker in the pool.
+ clients = self.pool_clients[pool_name]
+ idx = self.pool_client_next[pool_name] % len(clients)
+ self.pool_client_next[pool_name] = idx + 1
+ client = clients[idx]
reply = await client.send(payload)
return reply
@@ -1242,15 +1389,20 @@ def close(self):
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)
+ # PATCH: iterate the per-pool list rather than treating each
+ # entry as a single client.
+ for pool_name, clients in self.pool_clients.items():
+ client_list = clients if isinstance(clients, list) else [clients]
+ for client in client_list:
+ 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()
+ self.pool_client_next.clear()
# Close all pool servers
for pool_name, server in self.pool_servers.items():
@@ -1391,14 +1543,20 @@ def _publish_daemon(self, **kwargs):
started=started,
)
- def presence_callback(self, subscriber, msg):
+ async 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)
load = crypticle.loads(msg["load"])
load = salt.transport.frame.decode_embedded_strs(load)
- if not self.aes_funcs.verify_minion(load["id"], load["tok"]):
+ # LTS default (``master_async_mworker`` off): ``verify_minion`` is a
+ # sync callable and returns ``bool`` directly. Async opt-in path:
+ # ``verify_minion`` is ``async def`` and returns a coroutine.
+ verified = self.aes_funcs.verify_minion(load["id"], load["tok"])
+ if asyncio.iscoroutine(verified):
+ verified = await verified
+ if not verified:
return
subscriber.id_ = load["id"]
self._add_client_present(subscriber)
@@ -1546,7 +1704,7 @@ 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)
+ port = opts["cluster_pool_port"]
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")
@@ -1662,7 +1820,7 @@ def _start_raft_as_learner(self, known_peers):
)
aio_loop = salt.utils.asynchronous.aioloop(self.io_loop)
- port = self.opts.get("cluster_port", 55596)
+ port = self.opts["cluster_pool_port"]
# One pusher per remote host. Do not use ``build_peer_pushers`` here:
# discover-reply appends duplicate hosts to ``opts["cluster_peers"]`` and
@@ -2838,6 +2996,17 @@ def pre_fork(self, process_manager, *args, **kwargs):
def _publish_daemon(self, **kwargs):
"""Clean implementation: separate local IPC from cluster peer communication."""
+ # Ensure the process title is ``EventPublisher`` on both initial fork
+ # and respawn. ``ProcessManager.add_process`` is called with
+ # ``name="EventPublisher"`` from ``pre_fork``, but
+ # ``ProcessManager.restart_process`` drops the ``name`` kwarg, so on
+ # respawn the fallback ``__qualname__`` (``MasterPubServerChannel.
+ # _publish_daemon``) would otherwise be used instead. Setting the
+ # title explicitly here keeps the historical process label stable
+ # across restarts so operator tooling keyed on ``EventPublisher``
+ # continues to work.
+ if HAS_SETPROCTITLE:
+ setproctitle.setproctitle("EventPublisher")
import salt.master # pylint: disable=import-outside-toplevel
if (
@@ -2870,7 +3039,7 @@ def _publish_daemon(self, **kwargs):
# 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.tcp_master_pool_port = self.opts["cluster_pool_port"]
self.auth_errors = collections.defaultdict(collections.deque)
self.peer_map = {}
@@ -3345,6 +3514,45 @@ async def handle_pool_publish(self, payload):
if pub_pem:
with salt.utils.files.fopen(pub_path, "w") as fp:
fp.write(pub_pem)
+ # Refresh the key cache so subsequent
+ # ``MasterKeys.get_pub_str()`` / ``find_or_create_keys``
+ # lookups see the wire-delivered cluster keypair
+ # rather than the stale locally-generated bytes that
+ # ``_setup_keys`` stored during startup. Without this
+ # the mmap_key driver's per-master index still points
+ # at the joiner's own cluster.pub, so under HAProxy
+ # (or any load-balancer) the minion sees a different
+ # cluster.pub from each backend and hits
+ # "Invalid master key" on its second sign-in.
+ # See https://github.com/saltstack/salt/issues/70090
+ try:
+ self.master_key.cache.store(
+ "master_keys", "cluster.pem", pem_bytes
+ )
+ if pub_pem:
+ self.master_key.cache.store(
+ "master_keys",
+ "cluster.pub",
+ salt.utils.stringutils.to_bytes(pub_pem),
+ )
+ except Exception: # pylint: disable=broad-except
+ log.exception(
+ "Failed to refresh cluster keypair in cache "
+ "after join-reply install"
+ )
+ # Reload the in-memory PrivateKey so the running
+ # master process signs and decrypts with the shared
+ # cluster identity from this event onward.
+ try:
+ self.master_key.cluster_key = (
+ salt.crypt.PrivateKey.from_str(pem_bytes)
+ )
+ self.master_key.key = self.master_key.cluster_key
+ except Exception: # pylint: disable=broad-except
+ log.exception(
+ "Failed to reload cluster_key from "
+ "join-reply-delivered PEM"
+ )
log.info(
"Installed cluster.pem (%d bytes) and cluster.pub from join-reply",
len(pem_bytes),
@@ -3801,14 +4009,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":
@@ -3818,7 +4032,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":
@@ -3828,7 +4042,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
@@ -3839,7 +4053,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",
@@ -3856,6 +4070,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
@@ -3863,7 +4078,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:
@@ -3877,7 +4093,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/cli/batch.py b/salt/cli/batch.py
index d6e51037b63f..a038ba2b00a9 100644
--- a/salt/cli/batch.py
+++ b/salt/cli/batch.py
@@ -232,13 +232,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(
@@ -493,7 +495,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"),
@@ -509,7 +523,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"]:
@@ -541,6 +567,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/salt/cli/salt.py b/salt/cli/salt.py
index 2dda782e57b9..344bf848a974 100644
--- a/salt/cli/salt.py
+++ b/salt/cli/salt.py
@@ -323,6 +323,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 _run_batch_async(self, eauth):
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/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:
diff --git a/salt/cloud/deploy/bootstrap-salt.sh b/salt/cloud/deploy/bootstrap-salt.sh
index ff6dfe4ba38d..a779afc2fd1a 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
diff --git a/salt/cluster/consensus/raft/scheduler.py b/salt/cluster/consensus/raft/scheduler.py
index 8783a7470765..a1ea80f7b269 100644
--- a/salt/cluster/consensus/raft/scheduler.py
+++ b/salt/cluster/consensus/raft/scheduler.py
@@ -54,6 +54,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)
@@ -72,6 +77,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)
@@ -146,6 +157,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/salt/cluster/consensus/service.py b/salt/cluster/consensus/service.py
index 5a07e10beba3..2edbc97a6149 100644
--- a/salt/cluster/consensus/service.py
+++ b/salt/cluster/consensus/service.py
@@ -611,7 +611,7 @@ def _make_peer(self, addr, voting=True, raft_group_id="cluster"):
pusher = self._peer_pushers.get(addr)
if pusher is None:
- port = self.opts.get("cluster_port", 55596)
+ port = self.opts["cluster_pool_port"]
pusher = salt.transport.tcp.PublishServer(
self.opts,
pull_host=addr,
diff --git a/salt/config/__init__.py b/salt/config/__init__.py
index 6e32516235a3..76ea8d4c771f 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()
@@ -172,6 +172,22 @@ def _gather_buffer_space():
# what commands the master is processing and what the rates are of the executions
"master_stats": bool,
"master_stats_event_iter": int,
+ # Opt-in switch to enable async MWorker dispatch (AESFuncs / ClearFuncs /
+ # AuthFuncs handlers offload blocking work to a thread executor and the
+ # PoolRoutingChannel uses one IPC socket per MWorker for fair dispatch).
+ # DEFAULT: False on LTS (3008.x). When False, MWorker uses the pre-PR
+ # synchronous handlers and single-socket IPC routing (byte-for-byte
+ # identical to Argon v3008.2 and earlier).
+ "master_async_mworker": bool,
+ # Per-MWorker cap on the number of concurrent request handlers.
+ # Only has effect when ``master_async_mworker`` is True. Default
+ # 0 = unlimited (backwards compatible). When positive, each
+ # MWorker uses its own asyncio.BoundedSemaphore, so the effective
+ # total cap across the pool is
+ # ``master_mworker_max_inflight * worker_threads``. Coroutines
+ # blocked on the semaphore create natural TCP / ZMQ backpressure
+ # — no error return, no dropped requests.
+ "master_mworker_max_inflight": int,
# The key fingerprint of the higher-level master for the syndic to verify it is talking to the
# intended master
"syndic_finger": str,
@@ -554,6 +570,17 @@ 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,
+ # Per-subscriber cap on queued publish payloads for the TCP
+ # PubServer. Subscribers that let their writer coroutine back
+ # up beyond this many payloads are treated as slow and
+ # disconnected. Bounds in-flight drain-task allocation to one
+ # writer task per subscriber under bursty load. See #70147.
+ "pub_server_write_queue_size": int,
# IPC buffer size
# Refs https://github.com/saltstack/salt/issues/34215
"ipc_write_buffer": int,
@@ -698,6 +725,10 @@ def _gather_buffer_space():
"pillar_source_merging_strategy": str,
# Recursively merge lists by aggregating them instead of replacing them.
"pillar_merge_lists": bool,
+ # 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,
# How to merge multiple top files from multiple salt environments
@@ -821,6 +852,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,
@@ -1172,6 +1204,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``
@@ -1362,6 +1395,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",
@@ -1406,7 +1440,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": "",
@@ -1524,6 +1558,8 @@ def _gather_buffer_space():
"publish_port": 4505,
"zmq_backlog": 1000,
"pub_hwm": 1000,
+ "publish_drain_timeout": 60.0,
+ "pub_server_write_queue_size": 10000,
"auth_mode": 1,
"user": _MASTER_USER,
"worker_threads": 5,
@@ -1636,6 +1672,13 @@ def _gather_buffer_space():
"max_event_size": 1048576,
"master_stats": False,
"master_stats_event_iter": 60,
+ # LTS default: sync MWorker path preserved; async is opt-in.
+ # See DEFAULT_MASTER_OPTS type table for details.
+ "master_async_mworker": False,
+ # Default 0 = unlimited (backwards compatible). See the
+ # DEFAULT_MASTER_OPTS type table for the semantics. Only has
+ # effect when ``master_async_mworker`` is True.
+ "master_mworker_max_inflight": 0,
"minionfs_env": "base",
"minionfs_mountpoint": "",
"minionfs_whitelist": [],
@@ -1646,6 +1689,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,
@@ -4355,6 +4399,21 @@ def apply_master_config(overrides=None, defaults=None):
opts["__fs_update"] = True
_adjust_log_file_override(overrides, defaults["log_file"])
+ # Soft-deprecation alias: the master-cluster Raft rewrite (introduced in
+ # 3008.0) accidentally read the peer-pool port from ``cluster_port``
+ # instead of the documented ``cluster_pool_port``. ``cluster_port`` was
+ # never registered in ``VALID_OPTS``/``DEFAULT_MASTER_OPTS``, so any
+ # operator who happened to set it silently overrode nothing. If an
+ # operator explicitly set ``cluster_port`` (and not
+ # ``cluster_pool_port``), honor their intent by aliasing it across, and
+ # warn that the alias will be removed in a future release. See #69877.
+ if "cluster_port" in overrides and "cluster_pool_port" not in overrides:
+ log.warning(
+ "The 'cluster_port' master opt is deprecated and will be "
+ "removed in Argon+1 / Potassium; use 'cluster_pool_port' "
+ "instead."
+ )
+ overrides["cluster_pool_port"] = overrides["cluster_port"]
if overrides:
opts.update(overrides)
# `keep_acl_in_token` will be forced to True when using external authentication
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/salt/crypt.py b/salt/crypt.py
index db900a164fd8..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,19 +529,88 @@ 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
@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)
+
+
+# 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
@@ -795,7 +919,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 +951,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)
@@ -972,6 +1111,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 +1312,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/fileserver/__init__.py b/salt/fileserver/__init__.py
index fd51d1dec3aa..9c112f78b4ad 100644
--- a/salt/fileserver/__init__.py
+++ b/salt/fileserver/__init__.py
@@ -595,6 +595,12 @@ def find_file(self, path, saltenv, back=None):
for fsb in back:
fstr = f"{fsb}.find_file"
if fstr in self.servers:
+ log.info("Calling %s find_file", fsb)
+ log.debug(
+ "Full find_file call: find_file((), {'path': %r, 'saltenv': %r})",
+ path,
+ saltenv,
+ )
fnd = self.servers[fstr](path, saltenv, **kwargs)
if fnd.get("path"):
fnd["back"] = fsb
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/salt/fileserver/roots.py b/salt/fileserver/roots.py
index 2ac103049628..2c8edc36ad5b 100644
--- a/salt/fileserver/roots.py
+++ b/salt/fileserver/roots.py
@@ -53,6 +53,12 @@ def find_file(path, saltenv="base", **kwargs):
)
saltenv = "__env__"
else:
+ log.debug(
+ "Failed to find file(path: %s; saltenv: %s): saltenv not found "
+ "in file_roots",
+ path,
+ saltenv,
+ )
return fnd
def _add_file_stat(fnd):
@@ -93,6 +99,9 @@ def _add_file_stat(fnd):
if os.path.isfile(full) and not salt.fileserver.is_file_ignored(__opts__, full):
fnd["path"] = full
fnd["rel"] = path
+ log.debug(
+ "Found file(path: %s; saltenv: %s): %s", path, actual_saltenv, full
+ )
return _add_file_stat(fnd)
return fnd
for root in __opts__["file_roots"][saltenv]:
@@ -109,7 +118,18 @@ def _add_file_stat(fnd):
if os.path.isfile(full) and not salt.fileserver.is_file_ignored(__opts__, full):
fnd["path"] = full
fnd["rel"] = path
+ log.debug(
+ "Found file(path: %s; saltenv: %s): %s", path, actual_saltenv, full
+ )
return _add_file_stat(fnd)
+ log.debug(
+ "Failed to find file(path: %s; saltenv: %s): No file matching '%s' was "
+ "found in the '%s' saltenv",
+ path,
+ actual_saltenv,
+ path,
+ actual_saltenv,
+ )
return fnd
diff --git a/salt/grains/core.py b/salt/grains/core.py
index e541efb458ad..3b2cdb061483 100644
--- a/salt/grains/core.py
+++ b/salt/grains/core.py
@@ -462,7 +462,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: "):
@@ -1904,6 +1906,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/salt/loader/__init__.py b/salt/loader/__init__.py
index ec1033ff599b..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,
@@ -358,6 +441,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 +456,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 +487,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
@@ -633,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"``).
@@ -665,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/salt/master.py b/salt/master.py
index 9ef02753f97f..c206a782eabb 100644
--- a/salt/master.py
+++ b/salt/master.py
@@ -6,8 +6,11 @@
import asyncio
import binascii
import collections
+import concurrent.futures
+import contextvars
import copy
import ctypes
+import functools
import hashlib
import logging
import multiprocessing
@@ -90,6 +93,30 @@
log = logging.getLogger(__name__)
+class _ContextThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
+ """
+ ThreadPoolExecutor that snapshots the current :mod:`contextvars` context
+ at ``submit`` time and re-enters it inside the worker thread.
+
+ ``asyncio.loop.run_in_executor`` does not propagate the calling task's
+ context to the executor thread (only ``asyncio.Task.__step`` runs under
+ the task's context). That means anything the AES/ClearFuncs handlers
+ offload with ``run_in_executor(None, sync_impl, ...)`` runs with an
+ empty :data:`salt.utils.ctx.request_ctxvar`, and the logging enrichers
+ in :mod:`salt._logging.impl` cannot annotate the record with the JID /
+ minion id set by ``MWorker._handle_aes``.
+
+ Installing an instance of this class as the loop's default executor
+ (see :meth:`MWorker.__bind`) makes the ContextVar visible across the
+ offload boundary without touching every individual ``run_in_executor``
+ call site.
+ """
+
+ def submit(self, fn, /, *args, **kwargs):
+ ctx = contextvars.copy_context()
+ return super().submit(ctx.run, fn, *args, **kwargs)
+
+
# 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
@@ -97,6 +124,17 @@
# every ``MWorker._handle_payload`` invocation.
_WORKERS_INFLIGHT = None
+# Per-worker in-process counters for the ``master_mworker_max_inflight``
+# semaphore. These are read from the same event loop that mutates them
+# (the MWorker's own loop), so a plain dict without a lock is
+# sufficient. ``waiters`` is the current count of coroutines blocked on
+# the semaphore. ``wait_ms_total`` accumulates the wall-time each
+# request spent waiting for a slot since the worker started, expressed
+# in whole milliseconds. Tests introspect this dict; production
+# observability piggybacks on the existing observable-gauge callbacks
+# registered per-worker in ``MWorker.__bind``.
+_MW_INFLIGHT = {"waiters": 0, "wait_ms_total": 0}
+
def _register_master_observables(opts, workers_inflight):
"""
@@ -814,7 +852,7 @@ def run(self):
and not salt.utils.platform.is_windows()
):
log.info(
- "setting FileServerUpdate niceness to %d",
+ "setting FileserverUpdate niceness to %d",
self.opts["fileserver_update_niceness"],
)
os.nice(self.opts["fileserver_update_niceness"])
@@ -1184,7 +1222,7 @@ def start(self):
)
self.process_manager.add_process(
- FileserverUpdate, args=(self.opts,), name="FileServerUpdate"
+ FileserverUpdate, args=(self.opts,), name="FileserverUpdate"
)
# Fire up SSDP discovery publisher
@@ -1913,6 +1951,11 @@ def __bind(self):
"""
self.io_loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.io_loop)
+ # Install a context-propagating default executor so ``run_in_executor``
+ # calls in AES / ClearFuncs handlers see the ``request_ctxvar`` set by
+ # ``_handle_aes`` — the stdlib default ThreadPoolExecutor does not
+ # copy contextvars across the submit boundary.
+ self.io_loop.set_default_executor(_ContextThreadPoolExecutor())
# Create a threading event to signal when modules are ready.
# We use threading.Event here because it's set from a background thread
@@ -1920,8 +1963,15 @@ def __bind(self):
self._modules_loaded = threading.Event()
for req_channel in self.req_channels:
+ # PATCH: pass pool_index too so pool_server.post_fork can
+ # pick the per-worker IPC socket
+ # (workers-{pool_name}-{pool_index}.ipc) instead of every
+ # worker falling back to socket 0 and racing for it.
req_channel.post_fork(
- self._handle_payload, io_loop=self.io_loop, pool_name=self.pool_name
+ self._handle_payload,
+ io_loop=self.io_loop,
+ pool_name=self.pool_name,
+ pool_index=self.pool_index,
)
def _load_modules():
@@ -1961,7 +2011,56 @@ async def _start():
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
+ needs to be done with communication to the server.
+
+ When ``master_async_mworker`` is True *and*
+ ``master_mworker_max_inflight`` is a positive integer, each
+ invocation is gated on a per-worker
+ :class:`asyncio.BoundedSemaphore` so no more than N handlers run
+ concurrently on this worker's event loop. Because MWorker forks
+ and each child creates a fresh loop in :meth:`__bind`, the
+ semaphore is built lazily on first use here rather than in
+ :meth:`__init__`. When either flag is off the fast path skips
+ every extra allocation and awaits nothing new.
+ """
+ if not getattr(self, "_inflight_sem_ready", False):
+ self._inflight_sem = None
+ cap = int(self.opts.get("master_mworker_max_inflight", 0) or 0)
+ if cap > 0 and self.opts.get("master_async_mworker", False):
+ # BoundedSemaphore must be bound to the running loop; we
+ # are inside a coroutine so ``get_event_loop`` returns
+ # the MWorker's own loop.
+ self._inflight_sem = asyncio.BoundedSemaphore(cap)
+ self._inflight_sem_ready = True
+ if self._inflight_sem is None:
+ return await self._handle_payload_inner(payload)
+ _MW_INFLIGHT["waiters"] += 1
+ t0 = time.perf_counter()
+ try:
+ async with self._inflight_sem:
+ # We stopped waiting the instant ``acquire`` returned.
+ # Do the accounting inside the ``async with`` so the
+ # gauge always sees a matched increment/decrement even
+ # if ``_handle_payload_inner`` raises.
+ _MW_INFLIGHT["waiters"] -= 1
+ _MW_INFLIGHT["wait_ms_total"] += int((time.perf_counter() - t0) * 1000)
+ return await self._handle_payload_inner(payload)
+ except BaseException:
+ # If ``acquire`` itself raised (e.g. CancelledError while
+ # waiting) the ``async with`` body never ran and the
+ # decrement above never happened. Fix the gauge now.
+ if _MW_INFLIGHT["waiters"] > 0:
+ _MW_INFLIGHT["waiters"] -= 1
+ raise
+
+ async def _handle_payload_inner(self, payload):
+ """
+ The unwrapped body of :meth:`_handle_payload`.
+
+ Split out so the semaphore wrapper in :meth:`_handle_payload`
+ stays a small readable diff. Both the sync (no-cap) and async
+ (with-cap) paths funnel through here so every request pays the
+ exact same in-flight counter bookkeeping.
"""
# Bracket the entire handler with the shared "workers in flight"
# counter so the master's observable gauge can report queue depth.
@@ -1993,7 +2092,7 @@ async def _handle_payload(self, payload):
if key == "clear":
ret = await self._handle_clear(load)
else:
- ret = self._handle_aes(load)
+ ret = await self._handle_aes(load)
return ret
finally:
if _inflight is not None:
@@ -2073,7 +2172,7 @@ async def _handle_clear(self, load):
self._post_stats(start, cmd)
return ret
- def _handle_aes(self, data):
+ async def _handle_aes(self, data):
"""
Process a command sent via an AES key
@@ -2100,7 +2199,14 @@ def _handle_aes(self, data):
).add(1, attributes={"cmd": cmd})
try:
with salt.utils.ctx.request_context({"data": data, "opts": self.opts}):
+ # ``run_func`` returns either a (ret, opts) tuple for sync
+ # methods or a coroutine for methods listed in
+ # ``AESFuncs.async_methods``; keep the context manager active
+ # while awaiting so handlers see the same request context as
+ # the sync path.
ret = self.aes_funcs.run_func(data["cmd"], data)
+ if asyncio.iscoroutine(ret):
+ ret = await ret
finally:
salt.utils.metrics.histogram(
"salt.master.requests.duration",
@@ -2172,6 +2278,11 @@ def get_method(self, name):
# TODO: rename? No longer tied to "AES", just "encrypted" or "private" requests
+# pylint: disable=method-hidden
+# ``_install_sync_handlers`` (LTS opt-out for ``master_async_mworker``)
+# intentionally shadows every ``async def`` handler on the instance with
+# its ``_sync_`` sibling. Suppressing the class-wide
+# ``method-hidden`` warning is expected and audited.
class AESFuncs(TransportMethods):
"""
Set up functions that are available when the load is encrypted with AES
@@ -2206,6 +2317,38 @@ class AESFuncs(TransportMethods):
"_symlink_list",
"_file_envs",
)
+ # Methods listed here are dispatched as coroutines by ``run_func``; the
+ # caller (``MWorker._handle_aes``) awaits the returned coroutine. Methods
+ # not listed here run synchronously exactly as before. Mirrors the pattern
+ # used by ``ClearFuncs.async_methods``.
+ async_methods = (
+ "_pillar",
+ "_mine_get",
+ "_mine",
+ "_mine_delete",
+ "_mine_flush",
+ "minion_runner",
+ "minion_pub",
+ "minion_publish",
+ "revoke_auth",
+ "_serve_file",
+ "_file_find",
+ "_file_hash",
+ "_file_hash_and_stat",
+ "_file_list",
+ "_file_list_emptydirs",
+ "_dir_list",
+ "_symlink_list",
+ "_file_envs",
+ "_file_recv",
+ "_return",
+ "_syndic_return",
+ "pub_ret",
+ "_register_resources",
+ "verify_minion",
+ "_master_tops",
+ "_master_opts",
+ )
def __init__(self, opts):
"""
@@ -2243,15 +2386,32 @@ def __init__(self, opts):
self.key_cache = salt.cache.Cache(
self.opts, driver=self.opts["keys.cache_driver"]
)
-
- def __setup_fileserver(self):
- """
- Set the local file objects from the file server interface
- """
- # Avoid circular import
- import salt.fileserver
-
- self.fs_ = salt.fileserver.Fileserver(self.opts)
+ # LTS default: sync AESFuncs path preserved; async is opt-in via
+ # ``master_async_mworker``. When the flag is off (default on
+ # 3008.x) shadow every ``async def`` handler with its pre-PR
+ # synchronous equivalent and empty ``async_methods`` so
+ # ``run_func`` uses the sync dispatch branch. On master (Argon
+ # onward) the default flips and the async handlers run directly.
+ if not self.opts.get("master_async_mworker", False):
+ self._install_sync_handlers()
+
+ def _install_sync_handlers(self):
+ """
+ Restore pre-PR synchronous handler behavior when
+ ``master_async_mworker`` is disabled (the LTS default).
+
+ This shadows the ``async def`` handler methods on the instance with
+ sync-callable wrappers backed by ``_sync_*`` implementations that
+ contain the verbatim pre-PR bodies. It also empties
+ ``async_methods`` so ``run_func`` never enters the async dispatch
+ branch and ``MWorker._handle_aes`` never receives a coroutine to
+ await from an AES cmd handler.
+ """
+ # Instance-shadow the class attribute so ``run_func`` sees no
+ # methods in ``async_methods`` and always goes down the sync path.
+ self.async_methods = ()
+ # Fileserver family: replace the ``async def`` wrappers with the
+ # direct ``self.fs_.*`` bindings that shipped pre-PR.
self._serve_file = self.fs_.serve_file
self._file_find = self.fs_._find_file
self._file_hash = self.fs_.file_hash
@@ -2261,6 +2421,89 @@ def __setup_fileserver(self):
self._dir_list = self.fs_.dir_list
self._symlink_list = self.fs_.symlink_list
self._file_envs = self.fs_.file_envs
+ # Handlers that had non-trivial pre-PR sync bodies are exposed as
+ # ``_sync_`` methods further down in the class; bind them
+ # here so ``getattr(self, "")`` returns a sync callable.
+ # (See the class-level directive above; suppresses method-hidden.)
+ self._pillar = self._sync_pillar
+ self._return = self._sync_return
+ self._syndic_return = self._sync_syndic_return
+ self._register_resources = self._sync_register_resources
+ self._file_recv = self._sync_file_recv
+ self.verify_minion = self._sync_verify_minion
+ self._master_tops = self._sync_master_tops
+ self._master_opts = self._sync_master_opts
+ self._mine = self._sync_mine
+ self._mine_get = self._sync_mine_get
+ self._mine_delete = self._sync_mine_delete
+ self._mine_flush = self._sync_mine_flush
+ self.pub_ret = self._sync_pub_ret
+ self.minion_pub = self._sync_minion_pub
+ self.minion_publish = self._sync_minion_publish
+ self.minion_runner = self._sync_minion_runner
+ self.revoke_auth = self._sync_revoke_auth
+
+ def __setup_fileserver(self):
+ """
+ Set the local file objects from the file server interface
+ """
+ # Avoid circular import
+ import salt.fileserver
+
+ self.fs_ = salt.fileserver.Fileserver(self.opts)
+
+ # ------------------------------------------------------------------
+ # Fileserver family: async wrappers around ``self.fs_.*`` calls. The
+ # underlying operations traverse the filesystem, stat files, and hash
+ # payloads which can block the master worker's event loop under load.
+ # Each handler offloads the sync call to the default executor so
+ # concurrent AES commands stay responsive. Return-value shape is
+ # identical to the previous direct-attribute bindings.
+ # ------------------------------------------------------------------
+ async def _serve_file(self, load):
+ """Return a chunk of a fileserver-backed file to the requesting minion."""
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(None, self.fs_.serve_file, load)
+
+ async def _file_find(self, load):
+ """Locate a file on the fileserver backends."""
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(None, self.fs_._find_file, load)
+
+ async def _file_hash(self, load):
+ """Compute the hash of a fileserver-backed file."""
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(None, self.fs_.file_hash, load)
+
+ async def _file_hash_and_stat(self, load):
+ """Compute the hash and stat of a fileserver-backed file."""
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(None, self.fs_.file_hash_and_stat, load)
+
+ async def _file_list(self, load):
+ """List files under a fileserver path."""
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(None, self.fs_.file_list, load)
+
+ async def _file_list_emptydirs(self, load):
+ """List empty directories under a fileserver path."""
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(None, self.fs_.file_list_emptydirs, load)
+
+ async def _dir_list(self, load):
+ """List directories under a fileserver path."""
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(None, self.fs_.dir_list, load)
+
+ async def _symlink_list(self, load):
+ """List symlinks under a fileserver path."""
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(None, self.fs_.symlink_list, load)
+
+ async def _file_envs(self, load):
+ """Return the list of fileserver environments."""
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(None, self.fs_.file_envs, load)
def __verify_minion(self, id_, token):
"""
@@ -2307,7 +2550,7 @@ def __verify_minion(self, id_, token):
)
return False
- def verify_minion(self, id_, token):
+ async def verify_minion(self, id_, token):
"""
Take a minion id and a string signed with the minion private key
The string needs to verify as 'salt' with the minion public key
@@ -2318,7 +2561,11 @@ def verify_minion(self, id_, token):
:rtype: bool
:return: Boolean indicating whether or not the token can be verified.
"""
- return self.__verify_minion(id_, token)
+ # ``__verify_minion`` performs a disk cache fetch and an RSA
+ # ``PublicKey.decrypt`` call — both are blocking, CPU/IO bound
+ # operations that would otherwise stall the MWorker event loop.
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(None, self.__verify_minion, id_, token)
def __verify_minion_publish(self, clear_load):
"""
@@ -2381,7 +2628,7 @@ def __verify_load(self, load, verify_keys):
return False
return load
- def _master_tops(self, load):
+ async def _master_tops(self, load):
"""
Return the results from an external node classifier if one is
specified
@@ -2392,9 +2639,15 @@ def _master_tops(self, load):
load = self.__verify_load(load, ("id",))
if load is False:
return {}
- return self.masterapi._master_tops(load, skip_verify=True)
+ # ``masterapi._master_tops`` invokes any configured ``master_tops``
+ # backends synchronously (subprocess, disk, network) — offload so
+ # the MWorker loop stays responsive.
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(
+ None, functools.partial(self.masterapi._master_tops, load, skip_verify=True)
+ )
- def _master_opts(self, load):
+ async def _master_opts(self, load):
"""
Return the master options to the minion
@@ -2405,7 +2658,9 @@ def _master_opts(self, load):
"""
mopts = {}
file_roots = {}
- envs = self._file_envs()
+ # ``_file_envs`` is an ``async def`` handler (Phase 2D) that offloads
+ # the fileserver call to an executor internally; just await it.
+ envs = await self._file_envs(load)
for saltenv in envs:
if saltenv not in file_roots:
file_roots[saltenv] = []
@@ -2429,7 +2684,7 @@ def _master_opts(self, load):
mopts["jinja_trim_blocks"] = self.opts["jinja_trim_blocks"]
return mopts
- def _mine_get(self, load):
+ async def _mine_get(self, load):
"""
Gathers the data from the specified minions' mine
@@ -2441,10 +2696,14 @@ def _mine_get(self, load):
load = self.__verify_load(load, ("id", "tgt", "fun"))
if load is False:
return {}
- else:
- return self.masterapi._mine_get(load, skip_verify=False)
+ # ``masterapi._mine_get`` runs synchronous cache + minion-match work;
+ # offload to the default executor so the event loop stays responsive.
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(
+ None, functools.partial(self.masterapi._mine_get, load, skip_verify=False)
+ )
- def _mine(self, load):
+ async def _mine(self, load):
"""
Store the mine data
@@ -2456,9 +2715,12 @@ def _mine(self, load):
load = self.__verify_load(load, ("id", "data"))
if load is False:
return {}
- return self.masterapi._mine(load, skip_verify=False)
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(
+ None, functools.partial(self.masterapi._mine, load, skip_verify=False)
+ )
- def _mine_delete(self, load):
+ async def _mine_delete(self, load):
"""
Allow the minion to delete a specific function from its own mine
@@ -2470,10 +2732,10 @@ def _mine_delete(self, load):
load = self.__verify_load(load, ("id", "fun"))
if load is False:
return {}
- else:
- return self.masterapi._mine_delete(load)
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(None, self.masterapi._mine_delete, load)
- def _mine_flush(self, load):
+ async def _mine_flush(self, load):
"""
Allow the minion to delete all of its own mine contents
@@ -2482,30 +2744,24 @@ def _mine_flush(self, load):
load = self.__verify_load(load, ("id",))
if load is False:
return {}
- else:
- return self.masterapi._mine_flush(load, skip_verify=True)
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(
+ None, functools.partial(self.masterapi._mine_flush, load, skip_verify=True)
+ )
- def _register_resources(self, load):
+ def __register_resources_sync(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.
+ Blocking body of :meth:`_register_resources` — extracted so the async
+ wrapper can offload the mmap registry write and ``resource_grains``
+ cache mutations to a thread executor. Returns ``True`` on success or
+ ``{}`` when the payload is malformed, and a boolean flag indicating
+ whether the caller should fire the cache-refresh event on the master
+ event bus. Firing the event here would call the synchronous
+ ``event.fire_event`` and defeat the async migration.
"""
load = self.__verify_load(load, ("id", "resources"))
if load is False:
- return {}
+ return {}, False, None
# 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
@@ -2520,6 +2776,7 @@ def _register_resources(self, load):
n_put,
n_del,
)
+ fire_event = False
# 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
@@ -2565,20 +2822,56 @@ def _register_resources(self, load):
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.
+ # Signal the caller to fire the cache-refresh event on the
+ # async event bus. ``_pillar`` fires the analogous event when
+ # ordinary minion grains are refreshed in the cache.
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
+ fire_event = True
+ return True, fire_event, load["id"] if fire_event else None
+
+ async 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.
+ """
+ # The registry mutation, cache list/flush/store, and log emission
+ # are all blocking. Offload the whole body so the MWorker loop
+ # stays responsive under registration bursts.
+ loop = asyncio.get_running_loop()
+ ret, fire_event, minion_id = await loop.run_in_executor(
+ None, self.__register_resources_sync, load
+ )
+ # 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 fire_event:
+ await self.event.fire_event_async(
+ {"Resource cache refresh": minion_id},
+ tagify(minion_id, "refresh", "resource"),
+ )
+ return ret
- def _file_recv(self, load):
+ async def _file_recv(self, load):
"""
Allows minions to send files to the master, files are sent to the
- master file cache
+ master file cache.
+
+ Validation runs on the event loop (cheap in-memory checks); the
+ actual on-disk write is offloaded to the default executor since it
+ may block on ``makedirs``/``fopen``/``write`` for large payloads.
"""
if any(key not in load for key in ("id", "path", "loc")):
return False
@@ -2633,6 +2926,16 @@ def _file_recv(self, load):
cpath,
)
return False
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(None, self._file_recv_write, cpath, load)
+
+ @staticmethod
+ def _file_recv_write(cpath, load):
+ """
+ Blocking half of ``_file_recv``: create the parent dir and append/write
+ the payload chunk. Extracted so ``_file_recv`` can offload it via
+ ``run_in_executor`` without keeping the event loop parked on disk I/O.
+ """
cdir = os.path.dirname(cpath)
if not os.path.isdir(cdir):
try:
@@ -2650,7 +2953,7 @@ def _file_recv(self, load):
fp_.write(salt.utils.stringutils.to_bytes(load["data"]))
return True
- def _pillar(self, load):
+ async def _pillar(self, load):
"""
Return the pillar data for the minion
@@ -2665,7 +2968,7 @@ def _pillar(self, load):
return False
load["grains"]["id"] = load["id"]
- pillar = salt.pillar.get_pillar(
+ pillar = salt.pillar.get_async_pillar(
self.opts,
load["grains"],
load["id"],
@@ -2676,13 +2979,25 @@ def _pillar(self, load):
extra_minion_data=load.get("extra_minion_data"),
clean_cache=load.get("clean_cache"),
)
- data = pillar.compile_pillar()
- self.fs_.update_opts()
+ data = await pillar.compile_pillar()
+ # ``Fileserver.update_opts`` is a sync-only helper that walks every
+ # backend; offload it so we don't block the event loop while the
+ # backend list is refreshed.
+ loop = asyncio.get_running_loop()
+ await loop.run_in_executor(None, self.fs_.update_opts)
if self.opts.get("minion_data_cache", False):
- self.masterapi.cache.store("grains", load["id"], load["grains"])
+ # ``masterapi.cache.store`` is a sync cache-driver call that may
+ # touch disk / a remote store; keep it off the loop thread.
+ await loop.run_in_executor(
+ None,
+ self.masterapi.cache.store,
+ "grains",
+ load["id"],
+ load["grains"],
+ )
if self.opts.get("minion_data_cache_events") is True:
- self.event.fire_event(
+ await self.event.fire_event_async(
{"Minion data cache refresh": load["id"]},
tagify(load["id"], "refresh", "minion"),
)
@@ -2729,7 +3044,7 @@ def _handle_minion_event(self, load):
"Could not add minion(s) %s for job %s: %s", minions, jid, exc
)
- def _return(self, load):
+ async def _return(self, load):
"""
Handle the return data sent from the minions.
@@ -2767,9 +3082,22 @@ def _return(self, load):
sig = load.pop("sig")
this_minion_pubkey = self.key_cache.fetch("keys", 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"]):
+ # RSA verify is CPU-bound; offload so the ioloop stays responsive
+ # while a large signed load is checked.
+ loop = asyncio.get_running_loop()
+ verified = False
+ if this_minion_pubkey:
+ pubkey = salt.crypt.PublicKey.from_str(this_minion_pubkey["pub"])
+ verified = await loop.run_in_executor(
+ None,
+ functools.partial(
+ pubkey.verify,
+ serialized_load,
+ sig,
+ algorithm=self.opts["signing_algorithm"],
+ ),
+ )
+ if not verified:
if not this_minion_pubkey:
log.error("Failed to fetch pub key for minion %s.", load["id"])
else:
@@ -2798,13 +3126,24 @@ def _return(self, load):
load["id"] = load.pop("resource_id")
try:
- salt.utils.job.store_job(
- self.opts, load, event=self.event, mminion=self.mminion
+ # ``store_job`` wraps returner plugins (disk / db writes) and fires
+ # a sync event on ``self.event``; offload the whole call so the
+ # ioloop isn't blocked on returner I/O.
+ loop = asyncio.get_running_loop()
+ await loop.run_in_executor(
+ None,
+ functools.partial(
+ salt.utils.job.store_job,
+ self.opts,
+ load,
+ event=self.event,
+ mminion=self.mminion,
+ ),
)
except salt.exceptions.SaltCacheError:
log.error("Could not store job information for load: %s", load)
- def _syndic_return(self, load):
+ async def _syndic_return(self, load):
"""
Receive a syndic minion return and format it to look like returns from
individual minions.
@@ -2814,19 +3153,27 @@ def _syndic_return(self, load):
loads = load.get("load")
if not isinstance(loads, list):
loads = [load] # support old syndics not aggregating returns
+ loop = asyncio.get_running_loop()
for load in loads:
# Verify the load
if any(key not in load for key in ("return", "jid", "id")):
continue
- # if we have a load, save it
+ # if we have a load, save it -- returner is sync/disk-bound, so
+ # push it to the default executor to keep the ioloop responsive.
if load.get("load") and self.opts["master_job_cache"]:
fstr = "{}.save_load".format(self.opts["master_job_cache"])
- self.mminion.returners[fstr](load["jid"], load["load"])
+ await loop.run_in_executor(
+ None,
+ self.mminion.returners[fstr],
+ load["jid"],
+ load["load"],
+ )
# Register the syndic
# We are creating a path using user suplied input. Use the
- # clean_path to prevent a directory traversal.
+ # clean_path to prevent a directory traversal. The mkdir/write
+ # dance is disk I/O; do it off-thread.
root = os.path.join(self.opts["cachedir"], "syndics")
syndic_cache_path = os.path.join(
self.opts["cachedir"], "syndics", load["id"]
@@ -2834,11 +3181,9 @@ def _syndic_return(self, load):
if salt.utils.verify.clean_path(
root, syndic_cache_path
) and not os.path.exists(syndic_cache_path):
- path_name = os.path.split(syndic_cache_path)[0]
- if not os.path.exists(path_name):
- os.makedirs(path_name)
- with salt.utils.files.fopen(syndic_cache_path, "w") as wfh:
- wfh.write("")
+ await loop.run_in_executor(
+ None, self._write_syndic_cache_marker, syndic_cache_path
+ )
# Format individual return loads
for key, item in load["return"].items():
@@ -2854,9 +3199,23 @@ def _syndic_return(self, load):
ret["out"] = load["out"]
if "sig" in load:
ret["sig"] = load["sig"]
- self._return(ret)
+ await self._return(ret)
+
+ @staticmethod
+ def _write_syndic_cache_marker(syndic_cache_path):
+ """
+ Sync helper for ``_syndic_return`` executor offload: create the
+ parent dir if missing and touch an empty marker file. Extracted so
+ the mkdir + open + write sequence runs as a single unit of work in
+ the thread pool.
+ """
+ path_name = os.path.split(syndic_cache_path)[0]
+ if not os.path.exists(path_name):
+ os.makedirs(path_name)
+ with salt.utils.files.fopen(syndic_cache_path, "w") as wfh:
+ wfh.write("")
- def minion_runner(self, clear_load):
+ async def minion_runner(self, clear_load):
"""
Execute a runner from a minion, return the runner's function data
@@ -2869,9 +3228,12 @@ def minion_runner(self, clear_load):
if load is False:
return {}
else:
- return self.masterapi.minion_runner(clear_load)
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(
+ None, self.masterapi.minion_runner, clear_load
+ )
- def pub_ret(self, load):
+ async def pub_ret(self, load):
"""
Request the return data from a specific jid, only allowed
if the requesting minion also initiated the execution.
@@ -2884,18 +3246,28 @@ def pub_ret(self, load):
load = self.__verify_load(load, ("jid", "id"))
if load is False:
return {}
- # Check that this minion can access this data
+ loop = asyncio.get_running_loop()
+ # Auth-cache check + returner lookup all touch disk. Offload each so
+ # the ioloop isn't blocked on filesystem or returner backends.
auth_cache = os.path.join(self.opts["cachedir"], "publish_auth")
- if not os.path.isdir(auth_cache):
- os.makedirs(auth_cache)
- jid_fn = salt.utils.verify.clean_join(auth_cache, str(load["jid"]))
- with salt.utils.files.fopen(jid_fn, "r") as fp_:
- if not load["id"] == fp_.read():
- return {}
- # Grab the latest and return
- return self.local.get_cache_returns(load["jid"])
- def minion_pub(self, clear_load):
+ def _check_auth_cache():
+ if not os.path.isdir(auth_cache):
+ os.makedirs(auth_cache)
+ jid_fn = salt.utils.verify.clean_join(auth_cache, str(load["jid"]))
+ with salt.utils.files.fopen(jid_fn, "r") as fp_:
+ return fp_.read()
+
+ stored_id = await loop.run_in_executor(None, _check_auth_cache)
+ if load["id"] != stored_id:
+ return {}
+ # Grab the latest and return -- get_cache_returns calls the master
+ # job cache returner (disk / db read), so offload as well.
+ return await loop.run_in_executor(
+ None, self.local.get_cache_returns, load["jid"]
+ )
+
+ async def minion_pub(self, clear_load):
"""
Publish a command initiated from a minion, this method executes minion
restrictions so that the minion publication will only work if it is
@@ -2928,9 +3300,12 @@ def minion_pub(self, clear_load):
if not self.__verify_minion_publish(clear_load):
return {}
else:
- return self.masterapi.minion_pub(clear_load)
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(
+ None, self.masterapi.minion_pub, clear_load
+ )
- def minion_publish(self, clear_load):
+ async def minion_publish(self, clear_load):
"""
Publish a command initiated from a minion, this method executes minion
restrictions so that the minion publication will only work if it is
@@ -2963,9 +3338,12 @@ def minion_publish(self, clear_load):
if not self.__verify_minion_publish(clear_load):
return {}
else:
- return self.masterapi.minion_publish(clear_load)
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(
+ None, self.masterapi.minion_publish, clear_load
+ )
- def revoke_auth(self, load):
+ async def revoke_auth(self, load):
"""
Allow a minion to request revocation of its own key
@@ -2988,19 +3366,35 @@ def revoke_auth(self, load):
if load is False:
return load
else:
- return self.masterapi.revoke_auth(load)
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(None, self.masterapi.revoke_auth, load)
def run_func(self, func, load):
"""
Wrapper for running functions executed with AES encryption
:param function func: The function to run
- :return: The result of the master function that was called
+ :return: The result of the master function that was called, or a
+ coroutine when ``func`` is registered in ``async_methods``
+ (the caller is expected to ``await`` it).
"""
# Don't honor private functions
if func.startswith("__"):
# TODO: return some error? Seems odd to return {}
return {}, {"fun": "send"}
+ # Async dispatch: hand a coroutine back to the caller which performs
+ # the same post-processing as the sync path once the awaitable
+ # resolves. Mirrors ``ClearFuncs.async_methods`` handling in
+ # ``MWorker._handle_clear``.
+ if func in self.async_methods:
+ if not hasattr(self, func):
+ log.error(
+ "Received function %s which is unavailable on the master, "
+ "returning False",
+ func,
+ )
+ return False, {"fun": "send"}
+ return self._run_func_async(func, load)
# Run the func
if hasattr(self, func):
try:
@@ -3019,6 +3413,32 @@ def run_func(self, func, load):
func,
)
return False, {"fun": "send"}
+ return self._wrap_run_func_return(func, load, ret)
+
+ async def _run_func_async(self, func, load):
+ """
+ Async counterpart of ``run_func``'s sync dispatch branch.
+
+ Awaits the coroutine returned by the ``async def`` handler and then
+ applies the same post-processing rules (``_return`` / ``_pillar``
+ special cases) as the sync path.
+ """
+ try:
+ start = time.time()
+ ret = await getattr(self, func)(load)
+ log.trace(
+ "Master function call %s took %s seconds", func, time.time() - start
+ )
+ except Exception: # pylint: disable=broad-except
+ ret = ""
+ log.error("Error in function %s:\n", func, exc_info=True)
+ return self._wrap_run_func_return(func, load, ret)
+
+ def _wrap_run_func_return(self, func, load, ret):
+ """
+ Apply the return-envelope rules shared by the sync and async dispatch
+ paths of ``run_func``.
+ """
# Don't encrypt the return value for the _return func
# (we don't care about the return value, so why encrypt it?)
if func == "_return":
@@ -3031,113 +3451,1186 @@ def run_func(self, func, load):
# Encrypt the return
return ret, {"fun": "send"}
- def destroy(self):
- if self.masterapi is not None:
- self.masterapi.destroy()
- self.masterapi = None
- if self.local is not None:
- self.local.destroy()
- self.local = None
- if self.mminion is not None:
- self.mminion.destroy()
- self.mminion = None
- if self.event is not None:
- self.event.destroy()
- self.event = None
- if self.ckminions is not None:
- if self.ckminions.cache is not None:
- if hasattr(self.ckminions.cache, "destroy"):
- self.ckminions.cache.destroy()
- self.ckminions.cache = None
- self.ckminions = None
- if self.cache is not None:
- if hasattr(self.cache, "destroy"):
- self.cache.destroy()
- self.cache = None
- # Clear bound methods from fileserver
- if self.fs_ is not None:
- if hasattr(self.fs_, "destroy"):
- self.fs_.destroy()
- self.fs_ = None
- self._serve_file = None
- self._file_find = None
- self._file_hash = None
- self._file_hash_and_stat = None
- self._file_list = None
- self._file_list_emptydirs = None
- self._dir_list = None
- self._symlink_list = None
- self._file_envs = None
+ # ------------------------------------------------------------------
+ # LTS-default synchronous handler bodies. These reimplement each
+ # handler exactly as it existed on 3008.x before PR #70129 introduced
+ # ``async def`` versions. ``_install_sync_handlers`` shadows the
+ # class-level ``async def`` methods on the instance with these
+ # sync callables when ``master_async_mworker`` is False. Keep
+ # behaviour byte-for-byte identical to the pre-PR code path.
+ # ------------------------------------------------------------------
+ def _sync_verify_minion(self, id_, token):
+ """Sync-shim body of ``verify_minion``. See pre-PR salt/master.py."""
+ return self._AESFuncs__verify_minion(id_, token)
+
+ def _sync_master_tops(self, load):
+ """Sync-shim body of ``_master_tops``."""
+ load = self._AESFuncs__verify_load(load, ("id",))
+ if load is False:
+ return {}
+ return self.masterapi._master_tops(load, skip_verify=True)
+ def _sync_master_opts(self, load):
+ """Sync-shim body of ``_master_opts``."""
+ mopts = {}
+ file_roots = {}
+ envs = self._file_envs()
+ for saltenv in envs:
+ if saltenv not in file_roots:
+ file_roots[saltenv] = []
+ mopts["file_roots"] = file_roots
+ mopts["top_file_merging_strategy"] = self.opts["top_file_merging_strategy"]
+ mopts["env_order"] = self.opts["env_order"]
+ mopts["default_top"] = self.opts["default_top"]
+ if load.get("env_only"):
+ return mopts
+ mopts["renderer"] = self.opts["renderer"]
+ mopts["failhard"] = self.opts["failhard"]
+ mopts["state_top"] = self.opts["state_top"]
+ mopts["state_top_saltenv"] = self.opts["state_top_saltenv"]
+ mopts["nodegroups"] = self.opts["nodegroups"]
+ mopts["state_auto_order"] = self.opts["state_auto_order"]
+ mopts["state_events"] = self.opts["state_events"]
+ mopts["state_aggregate"] = self.opts["state_aggregate"]
+ mopts["jinja_env"] = self.opts["jinja_env"]
+ mopts["jinja_sls_env"] = self.opts["jinja_sls_env"]
+ mopts["jinja_lstrip_blocks"] = self.opts["jinja_lstrip_blocks"]
+ mopts["jinja_trim_blocks"] = self.opts["jinja_trim_blocks"]
+ return mopts
-class AuthFuncs(TransportMethods):
- """
- Set up the function used to authenticate minions.
+ def _sync_mine_get(self, load):
+ """Sync-shim body of ``_mine_get``."""
+ load = self._AESFuncs__verify_load(load, ("id", "tgt", "fun"))
+ if load is False:
+ return {}
+ return self.masterapi._mine_get(load, skip_verify=False)
- 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.
- """
+ def _sync_mine(self, load):
+ """Sync-shim body of ``_mine``."""
+ load = self._AESFuncs__verify_load(load, ("id", "data"))
+ if load is False:
+ return {}
+ return self.masterapi._mine(load, skip_verify=False)
- expose_methods = ("_auth",)
+ def _sync_mine_delete(self, load):
+ """Sync-shim body of ``_mine_delete``."""
+ load = self._AESFuncs__verify_load(load, ("id", "fun"))
+ if load is False:
+ return {}
+ return self.masterapi._mine_delete(load)
- 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)
+ def _sync_mine_flush(self, load):
+ """Sync-shim body of ``_mine_flush``."""
+ load = self._AESFuncs__verify_load(load, ("id",))
+ if load is False:
+ return {}
+ return self.masterapi._mine_flush(load, skip_verify=True)
- @property
- def aes_key(self):
+ def _sync_register_resources(self, load):
+ """Sync-shim body of ``_register_resources`` (pre-PR verbatim)."""
+ load = self._AESFuncs__verify_load(load, ("id", "resources"))
+ if load is False:
+ return {}
+ 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,
+ )
+ 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())
+ 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,
+ )
+ 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 _sync_file_recv(self, load):
+ """Sync-shim body of ``_file_recv`` (pre-PR verbatim)."""
+ if any(key not in load for key in ("id", "path", "loc")):
+ return False
+ if not isinstance(load["path"], list):
+ return False
+ if not self.opts["file_recv"]:
+ return False
+ if not salt.utils.verify.valid_id(self.opts, load["id"]):
+ return False
+ file_recv_max_size = 1024 * 1024 * self.opts["file_recv_max_size"]
+
+ if "loc" in load and load["loc"] < 0:
+ log.error("Invalid file pointer: load[loc] < 0")
+ return False
+
+ if len(load["data"]) + load.get("loc", 0) > file_recv_max_size:
+ log.error(
+ "file_recv_max_size limit of %d MB exceeded! %s will be "
+ "truncated. To successfully push this file, adjust "
+ "file_recv_max_size to an integer (in MB) large enough to "
+ "accommodate it.",
+ file_recv_max_size,
+ load["path"],
+ )
+ return False
+
+ sep_path = os.sep.join(load["path"])
+ normpath = os.path.normpath(sep_path)
+
+ if os.path.isabs(normpath) or "../" in load["path"]:
+ return False
+
+ rpath = os.path.join(self.opts["cachedir"], "minions", load["id"], "files")
+ cpath = os.path.join(rpath, normpath)
+ if not salt.utils.verify.clean_path(
+ rpath,
+ cpath,
+ subdir=True,
+ realpath=not self.opts["fileserver_followsymlinks"],
+ ):
+ log.warning(
+ "Attempt to write received file outside of master cache "
+ "directory! Requested path: %s. Access denied.",
+ cpath,
+ )
+ return False
+ cdir = os.path.dirname(cpath)
+ if not os.path.isdir(cdir):
+ try:
+ os.makedirs(cdir)
+ except OSError:
+ pass
+ if os.path.isfile(cpath) and load["loc"] != 0:
+ mode = "ab"
+ else:
+ mode = "wb"
+ with salt.utils.files.fopen(cpath, mode) as fp_:
+ if load["loc"]:
+ fp_.seek(load["loc"])
+ fp_.write(salt.utils.stringutils.to_bytes(load["data"]))
+ return True
+
+ def _sync_pillar(self, load):
+ """Sync-shim body of ``_pillar`` (pre-PR verbatim)."""
+ if any(key not in load for key in ("id", "grains")):
+ return False
+ if not salt.utils.verify.valid_id(self.opts, load["id"]):
+ return False
+ load["grains"]["id"] = load["id"]
+
+ pillar = salt.pillar.get_pillar(
+ self.opts,
+ load["grains"],
+ load["id"],
+ load.get("saltenv", load.get("env")),
+ ext=load.get("ext"),
+ pillar_override=load.get("pillar_override", {}),
+ pillarenv=load.get("pillarenv"),
+ extra_minion_data=load.get("extra_minion_data"),
+ clean_cache=load.get("clean_cache"),
+ )
+ 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"])
+
+ if self.opts.get("minion_data_cache_events") is True:
+ self.event.fire_event(
+ {"Minion data cache refresh": load["id"]},
+ tagify(load["id"], "refresh", "minion"),
+ )
+ return data
+
+ def _sync_return(self, load):
+ """Sync-shim body of ``_return`` (pre-PR verbatim)."""
+ 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 "
+ "messages, but there is no signature in this payload from "
+ "%s.",
+ load["id"],
+ )
+ return False
+
+ 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"])
+ 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 self.opts["drop_messages_signature_fail"]:
+ log.critical(
+ "drop_messages_signature_fail is enabled, dropping "
+ "message from %s",
+ load["id"],
+ )
+ return False
+ else:
+ log.info(
+ "But 'drop_message_signature_fail' is disabled, so message is"
+ " still accepted."
+ )
+ load["sig"] = sig
+
+ 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
+ )
+ except salt.exceptions.SaltCacheError:
+ log.error("Could not store job information for load: %s", load)
+
+ def _sync_syndic_return(self, load):
+ """Sync-shim body of ``_syndic_return`` (pre-PR verbatim)."""
+ loads = load.get("load")
+ if not isinstance(loads, list):
+ loads = [load]
+ for load in loads:
+ if any(key not in load for key in ("return", "jid", "id")):
+ continue
+ if load.get("load") and self.opts["master_job_cache"]:
+ fstr = "{}.save_load".format(self.opts["master_job_cache"])
+ self.mminion.returners[fstr](load["jid"], load["load"])
+
+ root = os.path.join(self.opts["cachedir"], "syndics")
+ syndic_cache_path = os.path.join(
+ self.opts["cachedir"], "syndics", load["id"]
+ )
+ if salt.utils.verify.clean_path(
+ root, syndic_cache_path
+ ) and not os.path.exists(syndic_cache_path):
+ path_name = os.path.split(syndic_cache_path)[0]
+ if not os.path.exists(path_name):
+ os.makedirs(path_name)
+ with salt.utils.files.fopen(syndic_cache_path, "w") as wfh:
+ wfh.write("")
+
+ for key, item in load["return"].items():
+ ret = {"jid": load["jid"], "id": key}
+ ret.update(item)
+ if "master_id" in load:
+ ret["master_id"] = load["master_id"]
+ if "fun" in load:
+ ret["fun"] = load["fun"]
+ if "fun_args" in load:
+ ret["fun_args"] = load["fun_args"]
+ if "out" in load:
+ ret["out"] = load["out"]
+ if "sig" in load:
+ ret["sig"] = load["sig"]
+ self._return(ret)
+
+ def _sync_minion_runner(self, clear_load):
+ """Sync-shim body of ``minion_runner``."""
+ load = self._AESFuncs__verify_load(clear_load, ("fun", "arg", "id"))
+ if load is False:
+ return {}
+ return self.masterapi.minion_runner(clear_load)
+
+ def _sync_pub_ret(self, load):
+ """Sync-shim body of ``pub_ret`` (pre-PR verbatim)."""
+ load = self._AESFuncs__verify_load(load, ("jid", "id"))
+ if load is False:
+ return {}
+ auth_cache = os.path.join(self.opts["cachedir"], "publish_auth")
+ if not os.path.isdir(auth_cache):
+ os.makedirs(auth_cache)
+ jid_fn = salt.utils.verify.clean_join(auth_cache, str(load["jid"]))
+ with salt.utils.files.fopen(jid_fn, "r") as fp_:
+ if not load["id"] == fp_.read():
+ return {}
+ return self.local.get_cache_returns(load["jid"])
+
+ def _sync_minion_pub(self, clear_load):
+ """Sync-shim body of ``minion_pub``."""
+ if not self._AESFuncs__verify_minion_publish(clear_load):
+ return {}
+ return self.masterapi.minion_pub(clear_load)
+
+ def _sync_minion_publish(self, clear_load):
+ """Sync-shim body of ``minion_publish``."""
+ if not self._AESFuncs__verify_minion_publish(clear_load):
+ return {}
+ return self.masterapi.minion_publish(clear_load)
+
+ def _sync_revoke_auth(self, load):
+ """Sync-shim body of ``revoke_auth``."""
+ load = self._AESFuncs__verify_load(load, ("id",))
+ if not self.opts.get("allow_minion_key_revoke", False):
+ log.warning(
+ "Minion %s requested key revoke, but allow_minion_key_revoke "
+ "is set to False",
+ load["id"],
+ )
+ return load
+ if load is False:
+ return load
+ return self.masterapi.revoke_auth(load)
+
+ def destroy(self):
+ if self.masterapi is not None:
+ self.masterapi.destroy()
+ self.masterapi = None
+ if self.local is not None:
+ self.local.destroy()
+ self.local = None
+ if self.mminion is not None:
+ self.mminion.destroy()
+ self.mminion = None
+ if self.event is not None:
+ self.event.destroy()
+ self.event = None
+ if self.ckminions is not None:
+ if self.ckminions.cache is not None:
+ if hasattr(self.ckminions.cache, "destroy"):
+ self.ckminions.cache.destroy()
+ self.ckminions.cache = None
+ self.ckminions = None
+ if self.cache is not None:
+ if hasattr(self.cache, "destroy"):
+ self.cache.destroy()
+ self.cache = None
+ # Fileserver handlers are now ``async def`` methods on the class
+ # (see ``_serve_file`` etc. above); only the underlying fileserver
+ # instance needs teardown.
+ if self.fs_ is not None:
+ if hasattr(self.fs_, "destroy"):
+ self.fs_.destroy()
+ self.fs_ = 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`` is a shared cache of per-minion (mtime, key)
+ # tuples used to short-circuit the ``publish_session`` rotation.
+ # After the MWorker async migration ``session_key`` runs on
+ # arbitrary executor threads (called via
+ # ``run_in_executor(None, self.session_key, load["id"])`` from
+ # ``_auth_impl``), so the check-then-update sequence below needs
+ # explicit locking. Without it, two concurrent auths for the same
+ # minion can both observe the ``if now - self.sessions[minion][0]
+ # < ...`` guard as ``False`` and race on ``Crypticle.write_key``
+ # / ``self.sessions[minion] = ...`` in parallel.
+ self.sessions = {}
+ self._sessions_lock = threading.Lock()
+ 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()
- if minion in self.sessions:
- if now - self.sessions[minion][0] < self.opts["publish_session"]:
- return self.sessions[minion][1]
+ 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
+ # Fast-path cache hit: single read of ``self.sessions[minion]``.
+ # Serialise the check-then-return sequence so a concurrent write
+ # can't leave us with a torn tuple. The lock is only held over
+ # the dict access and the (cheap) mtime stat.
+ with self._sessions_lock:
+ cached = self.sessions.get(minion)
+ if cached is not None:
+ if now - cached[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 <= cached[0]:
+ return cached[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)
+
+ entry = (
+ path.stat().st_mtime,
+ salt.crypt.Crypticle.read_key(path),
+ )
+ with self._sessions_lock:
+ self.sessions[minion] = entry
+ return entry[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)
+
+ async def _clear_signed(self, load, algorithm):
+ try:
+ tosign = salt.payload.dumps(load)
+ # ``master_key.sign`` performs an RSA signing operation which is
+ # CPU-bound; offload so the MWorker event loop stays responsive
+ # while an auth reply is being signed.
+ loop = asyncio.get_running_loop()
+ sig = await loop.run_in_executor(
+ None,
+ functools.partial(self.master_key.sign, tosign, algorithm=algorithm),
+ )
+ return {
+ "enc": "clear",
+ "load": tosign,
+ "sig": sig,
+ }
+ except UnsupportedAlgorithm:
+ log.info(
+ "Minion tried to authenticate with unsupported signing algorithm: %s",
+ algorithm,
+ )
+ return {"enc": "clear", "load": {"ret": "bad sig algo"}}
+
+ async 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:
+ # LTS default: sync auth path preserved; async is opt-in via
+ # ``master_async_mworker``. Callers already ``await`` this
+ # coroutine so returning the sync result inside an ``async
+ # def`` frame is transparent to them.
+ if not self.opts.get("master_async_mworker", False):
+ ret = self._auth_impl_sync(
+ load, sign_messages=sign_messages, version=version
+ )
+ else:
+ ret = await 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})
+
+ async 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
+ """
+ loop = asyncio.get_running_loop()
+ 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 await 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:
+ # ``connected_ids`` walks the minion data cache on disk;
+ # offload to the executor so a slow cache doesn't stall the
+ # auth loop.
+ minions = await loop.run_in_executor(None, 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
+ await self.event.fire_event_async(
+ eload, salt.utils.event.tagify(prefix="auth")
+ )
+ if sign_messages:
+ return await 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. These
+ # helpers read/write the autoreject/autosign files on disk; offload
+ # to the executor so the auth loop stays responsive under load.
+ auto_reject = await loop.run_in_executor(
+ None, self.auto_key.check_autoreject, load["id"]
+ )
+ auto_sign = await loop.run_in_executor(
+ None,
+ 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. The key-cache
+ # fetch traverses disk-backed key state; offload to the executor.
+ key = await loop.run_in_executor(None, 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 = (
+ await loop.run_in_executor(
+ None, 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
+ await self.event.fire_event_async(
+ eload, salt.utils.event.tagify(prefix="auth")
+ )
+ if sign_messages:
+ return await 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"])
+ await loop.run_in_executor(
+ None, 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
+ await self.event.fire_event_async(
+ eload, salt.utils.event.tagify(prefix="auth")
+ )
+ if sign_messages:
+ return await 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"}
+ await loop.run_in_executor(
+ None, 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"}
+ await loop.run_in_executor(
+ None, 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
+ await self.event.fire_event_async(
+ eload, salt.utils.event.tagify(prefix="auth")
+ )
+ if sign_messages:
+ return await 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"
+ await loop.run_in_executor(
+ None, 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
+ await self.event.fire_event_async(
+ eload, salt.utils.event.tagify(prefix="auth")
+ )
+ if sign_messages:
+ return await 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"])
+ await loop.run_in_executor(
+ None, 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
+ await self.event.fire_event_async(
+ eload, salt.utils.event.tagify(prefix="auth")
+ )
+ if sign_messages:
+ return await 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
+ await self.event.fire_event_async(
+ eload, salt.utils.event.tagify(prefix="auth")
+ )
+ if sign_messages:
+ return await 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"])
+ await loop.run_in_executor(
+ None, 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
+ await self.event.fire_event_async(
+ eload, salt.utils.event.tagify(prefix="auth")
+ )
+ if sign_messages:
+ return await 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
+ await self.event.fire_event_async(
+ eload, salt.utils.event.tagify(prefix="auth")
+ )
+ if sign_messages:
+ return await 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"])
- path = pathlib.Path(self.opts["cachedir"]) / "sessions" / minion
+ # 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"}
+ await loop.run_in_executor(None, 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"}
+ await loop.run_in_executor(
+ None, 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 await 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"):
+ await self.event.fire_event_async(
+ {
+ "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. ``PublicKey.from_str`` parses RSA
+ # keys which is CPU-bound; offload so a large key or slow crypto
+ # backend doesn't stall the auth loop.
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)
+ pub = await loop.run_in_executor(
+ None, 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 await self._clear_signed(
+ {"ret": False, "nonce": load["nonce"]}, sig_algo
+ )
+ else:
+ return {"enc": "clear", "load": {"ret": False}}
- self.sessions[minion] = (
- path.stat().st_mtime,
- salt.crypt.Crypticle.read_key(path),
- )
- return self.sessions[minion][1]
+ ret = {
+ "enc": "pub",
+ "pub_key": self.master_key.get_pub_str(),
+ "publish_port": self.opts["publish_port"],
+ }
- @classmethod
- def compare_keys(cls, key1, key2):
- """
- Normalize and compare two keys
+ # 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. RSA sign is
+ # CPU-bound; offload to the executor.
+ log.debug("Signing master public key before sending")
+ pub_sign = await loop.run_in_executor(
+ None,
+ functools.partial(
+ self.master_key.sign_key.sign,
+ ret["pub_key"],
+ algorithm=sig_algo,
+ ),
+ )
+ ret.update({"pub_sig": binascii.b2a_base64(pub_sign)})
- Returns:
- bool: ``True`` if the keys match, otherwise ``False``
- """
- return salt.crypt.clean_key(key1) == salt.crypt.clean_key(key2)
+ if self.opts["auth_mode"] >= 2:
+ if "token" in load:
+ try:
+ # RSA decrypt — offload to executor.
+ mtoken = await loop.run_in_executor(
+ None, 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
+
+ # RSA encrypt of the aes/session material — offload each.
+ # ``session_key`` performs disk I/O; offload as well.
+ session_material = await loop.run_in_executor(
+ None, self.session_key, load["id"]
+ )
+ ret["aes"] = await loop.run_in_executor(None, pub.encrypt, aes, enc_algo)
+ ret["session"] = await loop.run_in_executor(
+ None, pub.encrypt, session_material, enc_algo
+ )
+ else:
+ if "token" in load:
+ try:
+ mtoken = await loop.run_in_executor(
+ None, self.master_key.decrypt, load["token"], enc_algo
+ )
+ ret["token"] = await loop.run_in_executor(
+ None, 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
+ session_material = await loop.run_in_executor(
+ None, self.session_key, load["id"]
+ )
+ ret["aes"] = await loop.run_in_executor(None, pub.encrypt, aes, enc_algo)
+ ret["session"] = await loop.run_in_executor(
+ None, pub.encrypt, session_material, enc_algo
+ )
+
+ if version < 3:
+ log.warning(
+ "Minion using legacy request server protocol, please upgrade %s",
+ load["id"],
+ )
+
+ # Be aggressive about the signature. ``master_key.encrypt`` is an
+ # RSA sign; offload to keep the loop responsive.
+ digest = salt.utils.stringutils.to_bytes(hashlib.sha256(aes).hexdigest())
+ ret["sig"] = await loop.run_in_executor(None, 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
+ await self.event.fire_event_async(
+ eload, salt.utils.event.tagify(prefix="auth")
+ )
+ if sign_messages:
+ ret["nonce"] = load["nonce"]
+ return await self._clear_signed(ret, sig_algo)
+ return ret
- def _clear_signed(self, load, algorithm):
+ # ------------------------------------------------------------------
+ # LTS-default synchronous auth path. Reimplements ``_clear_signed``
+ # and ``_auth_impl`` verbatim from pre-PR 3008.x. ``_auth`` dispatches
+ # here when ``master_async_mworker`` is False (the LTS default).
+ # ------------------------------------------------------------------
+ def _clear_signed_sync(self, load, algorithm):
+ """Sync-shim body of ``_clear_signed`` (pre-PR verbatim)."""
try:
tosign = salt.payload.dumps(load)
return {
@@ -3152,74 +4645,23 @@ def _clear_signed(self, load, 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
- """
+ def _auth_impl_sync(self, load, sign_messages=False, version=0):
+ """Sync-shim body of ``_auth_impl`` (pre-PR verbatim)."""
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(
+ return self._clear_signed_sync(
{"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:
@@ -3232,8 +4674,6 @@ def _auth_impl(self, load, sign_messages=False, version=0):
)
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). "
@@ -3259,36 +4699,27 @@ def _auth_impl(self, load, sign_messages=False, version=0):
eload, salt.utils.event.tagify(prefix="auth")
)
if sign_messages:
- return self._clear_signed(
+ return self._clear_signed_sync(
{"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"],
@@ -3308,13 +4739,12 @@ def _auth_impl(self, load, sign_messages=False, version=0):
eload["autosign_grains"] = autosign_grains
self.event.fire_event(eload, salt.utils.event.tagify(prefix="auth"))
if sign_messages:
- return self._clear_signed(
+ return self._clear_signed_sync(
{"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 "
@@ -3322,7 +4752,6 @@ def _auth_impl(self, load, sign_messages=False, version=0):
"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)
@@ -3342,14 +4771,13 @@ def _auth_impl(self, load, sign_messages=False, version=0):
eload["autosign_grains"] = autosign_grains
self.event.fire_event(eload, salt.utils.event.tagify(prefix="auth"))
if sign_messages:
- return self._clear_signed(
+ return self._clear_signed_sync(
{"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(
@@ -3366,8 +4794,6 @@ def _auth_impl(self, load, sign_messages=False, version=0):
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:
@@ -3386,7 +4812,7 @@ def _auth_impl(self, load, sign_messages=False, version=0):
eload["autosign_grains"] = autosign_grains
self.event.fire_event(eload, salt.utils.event.tagify(prefix="auth"))
if sign_messages:
- return self._clear_signed(
+ return self._clear_signed_sync(
{"ret": key_result, "nonce": load["nonce"]},
sig_algo,
)
@@ -3394,11 +4820,7 @@ def _auth_impl(self, load, sign_messages=False, version=0):
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(
@@ -3420,17 +4842,13 @@ def _auth_impl(self, load, sign_messages=False, version=0):
eload["autosign_grains"] = autosign_grains
self.event.fire_event(eload, salt.utils.event.tagify(prefix="auth"))
if sign_messages:
- return self._clear_signed(
+ return self._clear_signed_sync(
{"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 "
@@ -3438,7 +4856,6 @@ def _auth_impl(self, load, sign_messages=False, version=0):
"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)
@@ -3459,7 +4876,7 @@ def _auth_impl(self, load, sign_messages=False, version=0):
eload, salt.utils.event.tagify(prefix="auth")
)
if sign_messages:
- return self._clear_signed(
+ return self._clear_signed_sync(
{"ret": False, "nonce": load["nonce"]}, sig_algo
)
else:
@@ -3489,16 +4906,12 @@ def _auth_impl(self, load, sign_messages=False, version=0):
eload, salt.utils.event.tagify(prefix="auth")
)
if sign_messages:
- return self._clear_signed(
+ return self._clear_signed_sync(
{"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 "
@@ -3506,7 +4919,6 @@ def _auth_impl(self, load, sign_messages=False, version=0):
"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)
@@ -3527,13 +4939,12 @@ def _auth_impl(self, load, sign_messages=False, version=0):
eload, salt.utils.event.tagify(prefix="auth")
)
if sign_messages:
- return self._clear_signed(
+ return self._clear_signed_sync(
{"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 = {
@@ -3550,7 +4961,7 @@ def _auth_impl(self, load, sign_messages=False, version=0):
eload["autosign_grains"] = autosign_grains
self.event.fire_event(eload, salt.utils.event.tagify(prefix="auth"))
if sign_messages:
- return self._clear_signed(
+ return self._clear_signed_sync(
{"ret": False, "nonce": load["nonce"]}, sig_algo
)
else:
@@ -3558,8 +4969,6 @@ def _auth_impl(self, load, sign_messages=False, version=0):
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"}
@@ -3573,15 +4982,11 @@ def _auth_impl(self, load, sign_messages=False, version=0):
elif not load["pub"]:
log.error("Public key is empty: %s", load["id"])
if sign_messages:
- return self._clear_signed(
+ return self._clear_signed_sync(
{"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(
{
@@ -3595,12 +5000,9 @@ def _auth_impl(self, load, sign_messages=False, version=0):
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
@@ -3611,7 +5013,7 @@ def _auth_impl(self, load, sign_messages=False, version=0):
exc_info_on_loglevel=logging.DEBUG,
)
if sign_messages:
- return self._clear_signed(
+ return self._clear_signed_sync(
{"ret": False, "nonce": load["nonce"]}, sig_algo
)
else:
@@ -3623,17 +5025,12 @@ def _auth_impl(self, load, sign_messages=False, version=0):
"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
@@ -3647,7 +5044,7 @@ def _auth_impl(self, load, sign_messages=False, version=0):
aes = "{}_|-{}".format(
SMaster.secrets["aes"]["secret"].value, mtoken
)
- except UnsupportedAlgorithm as exc:
+ except UnsupportedAlgorithm:
log.info(
"Minion %s tried to authenticate with unsupported encryption algorithm: %s",
load["id"],
@@ -3656,8 +5053,6 @@ def _auth_impl(self, load, sign_messages=False, version=0):
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
@@ -3668,7 +5063,7 @@ def _auth_impl(self, load, sign_messages=False, version=0):
try:
mtoken = self.master_key.decrypt(load["token"], enc_algo)
ret["token"] = pub.encrypt(mtoken, enc_algo)
- except UnsupportedAlgorithm as exc:
+ except UnsupportedAlgorithm:
log.info(
"Minion %s tried to authenticate with unsupported encryption algorithm: %s",
load["id"],
@@ -3676,8 +5071,6 @@ def _auth_impl(self, load, sign_messages=False, version=0):
)
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
@@ -3690,7 +5083,6 @@ def _auth_impl(self, load, sign_messages=False, version=0):
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:
@@ -3709,10 +5101,15 @@ def _auth_impl(self, load, sign_messages=False, version=0):
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 self._clear_signed_sync(ret, sig_algo)
return ret
+# pylint: disable=method-hidden
+# ``__init__`` (LTS opt-out for ``master_async_mworker``) intentionally
+# shadows every ``async def`` handler on the instance with its
+# ``_sync_`` sibling. Suppressing the class-wide
+# ``method-hidden`` warning is expected and audited.
class ClearFuncs(TransportMethods):
"""
Set up functions that are safe to execute when commands sent to the master
@@ -3730,7 +5127,14 @@ class ClearFuncs(TransportMethods):
"wheel",
"runner",
)
- async_methods = ("publish",)
+ async_methods = (
+ "publish",
+ "ping",
+ "wheel",
+ "runner",
+ "get_token",
+ "mk_token",
+ )
# The ClearFuncs object encapsulates the functions that can be executed in
# the clear:
@@ -3764,8 +5168,22 @@ def __init__(self, opts, key):
# Make a masterapi object
self.masterapi = salt.daemons.masterapi.LocalFuncs(opts, key)
self.channels = []
-
- def runner(self, clear_load):
+ # LTS default: sync ClearFuncs path preserved; async is opt-in via
+ # ``master_async_mworker``. Restore the pre-PR ``async_methods``
+ # tuple (only ``publish`` was async) and shadow the newly-added
+ # ``async def`` handlers with sync callables that carry the
+ # pre-PR bodies.
+ if not self.opts.get("master_async_mworker", False):
+ self.async_methods = ("publish",)
+ # See the class-level directive above; the instance shadow
+ # is the LTS opt-out and expected.
+ self.runner = self._sync_runner
+ self.wheel = self._sync_wheel
+ self.mk_token = self._sync_mk_token
+ self.get_token = self._sync_get_token
+ self.ping = self._sync_ping
+
+ async def runner(self, clear_load):
"""
Send a master control function back to the runner system
"""
@@ -3817,8 +5235,19 @@ def runner(self, clear_load):
try:
fun = clear_load.pop("fun")
runner_client = salt.runner.RunnerClient(self.opts)
- return runner_client.asynchronous(
- fun, clear_load.get("kwarg", {}), username, local=True
+ # ``RunnerClient.asynchronous`` forks a subprocess and joins it,
+ # blocking the calling thread. Offload so the MWorker event loop
+ # stays responsive while the runner job spins up.
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(
+ None,
+ functools.partial(
+ runner_client.asynchronous,
+ fun,
+ clear_load.get("kwarg", {}),
+ username,
+ local=True,
+ ),
)
except Exception as exc: # pylint: disable=broad-except
log.error("Exception occurred while introspecting %s: %s", fun, exc)
@@ -3830,7 +5259,7 @@ def runner(self, clear_load):
}
}
- def wheel(self, clear_load):
+ async def wheel(self, clear_load):
"""
Send a master control function back to the wheel system
"""
@@ -3897,7 +5326,16 @@ def wheel(self, clear_load):
"print_event": clear_load.get("print_event", False),
}
)
- ret = self.wheel_.call_func(fun, full_return=True, **clear_load)
+ # ``Wheel.call_func`` runs wheel modules synchronously — offload
+ # so slow wheel calls (key ops, filesystem I/O) don't stall the
+ # MWorker event loop.
+ loop = asyncio.get_running_loop()
+ ret = await loop.run_in_executor(
+ None,
+ functools.partial(
+ self.wheel_.call_func, fun, full_return=True, **clear_load
+ ),
+ )
data["return"] = ret["return"]
data["success"] = ret["success"]
return {"tag": tag, "data": data}
@@ -3909,27 +5347,36 @@ def wheel(self, clear_load):
exc,
)
data["success"] = False
- self.event.fire_event(data, tagify([jid, "ret"], "wheel"))
+ await self.event.fire_event_async(data, tagify([jid, "ret"], "wheel"))
return {"tag": tag, "data": data}
- def mk_token(self, clear_load):
+ async def mk_token(self, clear_load):
"""
Create and return an authentication token, the clear load needs to
contain the eauth key and the needed authentication creds.
"""
- token = self.loadauth.mk_token(clear_load)
+ # ``LoadAuth.mk_token`` runs the eauth backend (which may hit PAM,
+ # LDAP, etc.) and writes the resulting token to disk. Offload so the
+ # MWorker event loop stays responsive.
+ loop = asyncio.get_running_loop()
+ token = await loop.run_in_executor(None, self.loadauth.mk_token, clear_load)
if not token:
log.warning('Authentication failure of type "eauth" occurred.')
return ""
return token
- def get_token(self, clear_load):
+ async def get_token(self, clear_load):
"""
Return the name associated with a token or False if the token is invalid
"""
if "token" not in clear_load:
return False
- return self.loadauth.get_tok(clear_load["token"])
+ # ``LoadAuth.get_tok`` reads and deserializes the token from disk;
+ # offload to the executor to avoid blocking the event loop.
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(
+ None, self.loadauth.get_tok, clear_load["token"]
+ )
async def publish_batch(self, clear_load, minions, missing):
"""
@@ -4349,12 +5796,164 @@ def _prep_pub(self, minions, jid, clear_load, extra, missing):
log.debug("Published command details %s", load)
return load
- def ping(self, clear_load):
+ async def ping(self, clear_load):
"""
Send the load back to the sender.
"""
return clear_load
+ # ------------------------------------------------------------------
+ # LTS-default synchronous ClearFuncs handler bodies. Restored from
+ # pre-PR 3008.x. ``__init__`` shadows the ``async def`` methods on
+ # the instance with these callables when ``master_async_mworker`` is
+ # False, and empties ``async_methods`` back to ``("publish",)`` so
+ # ``MWorker._handle_clear`` dispatches these synchronously.
+ # ------------------------------------------------------------------
+ def _sync_ping(self, clear_load):
+ """Sync-shim body of ``ping``."""
+ return clear_load
+
+ def _sync_mk_token(self, clear_load):
+ """Sync-shim body of ``mk_token``."""
+ token = self.loadauth.mk_token(clear_load)
+ if not token:
+ log.warning('Authentication failure of type "eauth" occurred.')
+ return ""
+ return token
+
+ def _sync_get_token(self, clear_load):
+ """Sync-shim body of ``get_token``."""
+ if "token" not in clear_load:
+ return False
+ return self.loadauth.get_tok(clear_load["token"])
+
+ def _sync_runner(self, clear_load):
+ """Sync-shim body of ``runner`` (pre-PR verbatim)."""
+ auth_type, err_name, key, sensitive_load_keys = self._prep_auth_info(clear_load)
+ auth_check = self.loadauth.check_authentication(clear_load, auth_type, key=key)
+ error = auth_check.get("error")
+
+ if error:
+ return {"error": error}
+
+ username = auth_check.get("username")
+ if auth_type != "user":
+ runner_check = self.ckminions.runner_check(
+ auth_check.get("auth_list", []),
+ clear_load["fun"],
+ clear_load.get("kwarg", {}),
+ )
+ if not runner_check:
+ return {
+ "error": {
+ "name": err_name,
+ "message": (
+ 'Authentication failure of type "{}" occurred for '
+ "user {}.".format(auth_type, username)
+ ),
+ }
+ }
+ elif isinstance(runner_check, dict) and "error" in runner_check:
+ return runner_check
+
+ for item in sensitive_load_keys:
+ clear_load.pop(item, None)
+ else:
+ if "user" in clear_load:
+ username = clear_load["user"]
+ if salt.auth.AuthUser(username).is_sudo():
+ username = self.opts.get("user", "root")
+ else:
+ username = salt.utils.user.get_user()
+
+ try:
+ fun = clear_load.pop("fun")
+ runner_client = salt.runner.RunnerClient(self.opts)
+ return runner_client.asynchronous(
+ fun, clear_load.get("kwarg", {}), username, local=True
+ )
+ except Exception as exc: # pylint: disable=broad-except
+ log.error("Exception occurred while introspecting %s: %s", fun, exc)
+ return {
+ "error": {
+ "name": exc.__class__.__name__,
+ "args": exc.args,
+ "message": str(exc),
+ }
+ }
+
+ def _sync_wheel(self, clear_load):
+ """Sync-shim body of ``wheel`` (pre-PR verbatim)."""
+ auth_type, err_name, key, sensitive_load_keys = self._prep_auth_info(clear_load)
+ auth_check = self.loadauth.check_authentication(clear_load, auth_type, key=key)
+ error = auth_check.get("error")
+
+ if error:
+ return {"error": error}
+
+ username = auth_check.get("username")
+ if auth_type != "user":
+ wheel_check = self.ckminions.wheel_check(
+ auth_check.get("auth_list", []),
+ clear_load["fun"],
+ clear_load.get("kwarg", {}),
+ )
+ if not wheel_check:
+ return {
+ "error": {
+ "name": err_name,
+ "message": (
+ 'Authentication failure of type "{}" occurred for '
+ "user {}.".format(auth_type, username)
+ ),
+ }
+ }
+ elif isinstance(wheel_check, dict) and "error" in wheel_check:
+ return wheel_check
+
+ for item in sensitive_load_keys:
+ clear_load.pop(item, None)
+ else:
+ if "user" in clear_load:
+ username = clear_load["user"]
+ if salt.auth.AuthUser(username).is_sudo():
+ username = self.opts.get("user", "root")
+ else:
+ username = salt.utils.user.get_user()
+
+ try:
+ jid = salt.utils.jid.gen_jid(self.opts)
+ fun = clear_load.pop("fun")
+ tag = tagify(jid, prefix="wheel")
+ data = {
+ "fun": f"wheel.{fun}",
+ "jid": jid,
+ "tag": tag,
+ "user": username,
+ }
+ clear_load.update(
+ {
+ "__jid__": jid,
+ "__tag__": tag,
+ "__user__": username,
+ "print_event": clear_load.get("print_event", False),
+ }
+ )
+ ret = self.wheel_.call_func(fun, full_return=True, **clear_load)
+ data["return"] = ret["return"]
+ data["success"] = ret["success"]
+ return {"tag": tag, "data": data}
+ except Exception as exc: # pylint: disable=broad-except
+ log.error("Exception occurred while introspecting %s: %s", fun, exc)
+ data["return"] = "Exception occurred in wheel {}: {}: {}".format(
+ fun,
+ exc.__class__.__name__,
+ exc,
+ )
+ data["success"] = False
+ self.event.fire_event(data, tagify([jid, "ret"], "wheel"))
+ return {"tag": tag, "data": data}
+
def destroy(self):
if self.masterapi is not None:
self.masterapi.destroy()
diff --git a/salt/minion.py b/salt/minion.py
index 81c828dec5d0..43a587ab6692 100644
--- a/salt/minion.py
+++ b/salt/minion.py
@@ -61,6 +61,7 @@
import salt.utils.network
import salt.utils.platform
import salt.utils.process
+import salt.utils.resource_warnings
import salt.utils.resources
import salt.utils.schedule
import salt.utils.ssdp
@@ -419,6 +420,113 @@ def get_proc_dir(cachedir, **kwargs):
return fn_
+def _remove_proc_file(proc_file):
+ """
+ Best-effort removal of a minion job proc file.
+
+ Registered as a ``SignalHandlingProcess.register_finalize_method`` on
+ each job-execution child so that a SIGTERM arriving mid-job (from
+ ``MinionManager.stop_async`` during a graceful shutdown) still removes
+ the ``/proc/`` marker, even though
+ ``SignalHandlingProcess._handle_signals`` bypasses ``_thread_return``'s
+ own ``finally`` block by calling ``os._exit``. Without this, every
+ proc file survives a clean ``systemctl stop`` and cannot be
+ distinguished from a crashed-mid-job proc file at next start.
+ """
+ try:
+ os.remove(proc_file)
+ except OSError:
+ # File already gone (job finished before signal, or already
+ # cleaned up by _thread_return's finally block on the happy path).
+ pass
+
+
+def _terminate_subprocess_list(subprocess_list, signum, grace_seconds=2.0):
+ """
+ Deliver ``signum`` to each live entry in ``subprocess_list``, wait up
+ to ``grace_seconds`` for them to exit, then SIGKILL any that remain.
+
+ The parent minion's ``process_manager.kill_children()`` only iterates
+ ``ProcessManager._process_map`` -- job-execution children live on
+ ``Minion.subprocess_list`` instead (added from
+ ``_handle_decoded_payload`` after ``process.start()``) and were never
+ signaled by the graceful-stop path before this fix. Windows job
+ children have no SIGTERM handler, so we skip the graceful signal and
+ fall through to ``terminate()``; that path mirrors what
+ ``ProcessManager.send_signal_to_processes`` already does for the
+ process-manager entries on Windows.
+ """
+ if subprocess_list is None:
+ return
+ procs = [p for p in list(subprocess_list.processes) if _is_process_alive(p)]
+ if not procs:
+ return
+
+ if not salt.utils.platform.is_windows():
+ for proc in procs:
+ try:
+ os.kill(proc.pid, signum)
+ except OSError as exc:
+ if exc.errno not in (errno.ESRCH, errno.EACCES):
+ log.warning("Failed to signal job child pid %s: %s", proc.pid, exc)
+
+ deadline = time.time() + grace_seconds
+ for proc in procs:
+ remaining = max(0.0, deadline - time.time())
+ try:
+ proc.join(remaining)
+ except (OSError, ValueError):
+ continue
+
+ for proc in procs:
+ if not _is_process_alive(proc):
+ continue
+ log.warning(
+ "Job subprocess %s did not exit within %.1fs of graceful "
+ "signal; escalating",
+ getattr(proc, "name", proc),
+ grace_seconds,
+ )
+ # ``multiprocessing.Process.terminate()`` sends SIGTERM, which the
+ # child may be ignoring (that's why we are in the escalation
+ # path). Skip straight to SIGKILL on POSIX; on Windows fall back
+ # to ``.kill()`` which maps to ``TerminateProcess`` and is
+ # unconditional.
+ pid = getattr(proc, "pid", None)
+ killed = False
+ if pid and not salt.utils.platform.is_windows():
+ try:
+ os.kill(pid, signal.SIGKILL)
+ killed = True
+ except OSError as exc:
+ if exc.errno not in (errno.ESRCH, errno.EACCES):
+ log.warning("Failed to SIGKILL job child pid %s: %s", pid, exc)
+ if not killed:
+ try:
+ proc.kill()
+ except (AttributeError, OSError):
+ try:
+ proc.terminate()
+ except (AttributeError, OSError):
+ pass
+ try:
+ proc.join(1)
+ except (OSError, ValueError):
+ pass
+
+
+def _is_process_alive(proc):
+ """
+ Robust ``is_alive`` for multiprocessing.Process / threading.Thread
+ entries stored in ``SubprocessList``. Returns ``False`` for entries
+ that raise on inspection (already ``close()``d, etc.).
+ """
+ try:
+ return bool(proc.is_alive())
+ except (AttributeError, ValueError, OSError):
+ return False
+
+
def load_args_and_kwargs(func, args, data=None, ignore_invalid=False):
"""
Detect the args and kwargs that need to be passed to a function call, and
@@ -1316,6 +1424,42 @@ def __enter__(self):
def __exit__(self, *args):
self.destroy()
+ # pylint: disable=W1701
+ def __del__(self):
+ # LTS safety-net: callers that historically relied on GC-time
+ # cleanup keep the auto-``destroy()`` here, but we also emit a
+ # ``warn_until_close`` so the missing-``destroy()`` shows up in
+ # normal Salt logs (Python filters ``ResourceWarning`` by
+ # default). The companion change on ``master`` drops the
+ # fallback and requires callers to use a context manager or
+ # explicit ``destroy()``.
+ try:
+ already_torn_down = (
+ getattr(self, "returners", None) is None
+ and getattr(self, "functions", None) is None
+ and getattr(self, "utils", None) is None
+ )
+ except Exception: # pylint: disable=broad-except
+ return
+ if already_torn_down:
+ return
+ try:
+ salt.utils.resource_warnings.warn_until_close(
+ f"unclosed {type(self).__name__} {self!r}; call "
+ f"``destroy()`` or use as a context manager",
+ source=self,
+ log=log,
+ )
+ except Exception: # pylint: disable=broad-except
+ pass
+ try:
+ self.destroy()
+ except Exception: # pylint: disable=broad-except
+ # Finalizer must never raise.
+ pass
+
+ # pylint: enable=W1701
+
def gen_modules(self, initial_load=False):
"""
Tell the minion to reload the execution modules
@@ -1579,6 +1723,15 @@ async def stop_async(self, signum, parent_sig_handler):
and any remaining events to be processed before stopping the minions.
"""
+ # Announce entry into graceful-shutdown to systemd so ``Type=notify``
+ # units get a proper ``STOPPING=1`` transition (not just process
+ # exit). No-op when the ``NOTIFY_SOCKET`` env var / systemd bindings
+ # are unavailable.
+ try:
+ salt.utils.process.notify_systemd_stopping()
+ except Exception: # pylint: disable=broad-except
+ log.debug("notify_systemd_stopping failed", exc_info=True)
+
# Sleep to allow any remaining events to be processed.
# This gives the minion time to send final "return" messages to the Master.
# Ideally, we would dynamically wait for all pending messages to be flushed
@@ -1590,6 +1743,19 @@ async def stop_async(self, signum, parent_sig_handler):
for minion in self.minions:
minion.process_manager.stop_restarting()
minion.process_manager.send_signal_to_processes(signum)
+ # Signal in-flight job children (SignalHandlingProcess entries
+ # added to ``subprocess_list`` from ``_handle_decoded_payload``).
+ # These do NOT live on ``process_manager._process_map`` and are
+ # therefore missed by ``send_signal_to_processes`` /
+ # ``kill_children`` above -- see graceful-stop audit for
+ # issue #70050. Without this, jobs keep running as reparented
+ # orphans until systemd's cgroup SIGKILL hits at
+ # ``TimeoutStopSec``. The child's registered ``_finalize_methods``
+ # (``_remove_proc_file``) run before its ``os._exit``, so the
+ # proc file is cleaned up too.
+ _terminate_subprocess_list(
+ minion.subprocess_list, signum, grace_seconds=2.0
+ )
# kill any remaining processes
minion.process_manager.kill_children()
minion.destroy()
@@ -2483,6 +2649,12 @@ def _invoke_execution(self, data):
creds_map = None
multiprocessing_enabled = self.opts.get("multiprocessing", True)
name = "ProcessPayload(jid={})".format(data["jid"])
+ # Precompute the proc-file path so the finalize hook registered below
+ # does not have to reconstruct the loader/opts inside a signal
+ # handler. get_proc_dir() only creates the directory; it does not
+ # write the jid file yet -- that happens inside ``_thread_return`` on
+ # the child side.
+ proc_file = os.path.join(get_proc_dir(self.opts["cachedir"]), str(data["jid"]))
if multiprocessing_enabled:
if salt.utils.platform.spawning_platform():
# let python reconstruct the minion on the other side if we're
@@ -2495,6 +2667,13 @@ def _invoke_execution(self, data):
name=name,
args=(instance, self.opts, data, self.connected, creds_map),
)
+ # Ensure ``/proc/`` is removed even when SIGTERM
+ # short-circuits ``_thread_return``'s own ``finally`` block via
+ # ``SignalHandlingProcess._handle_signals`` -> ``os._exit``.
+ # ``_finalize_methods`` runs before that hard exit, and the
+ # tuple is pickled through ``__getstate__`` so it survives the
+ # fork/spawn boundary on both Linux and Windows.
+ process.register_finalize_method(_remove_proc_file, proc_file)
else:
process = threading.Thread(
target=self._target,
diff --git a/salt/modules/aptpkg.py b/salt/modules/aptpkg.py
index 452166ba4775..e67efb55b056 100644
--- a/salt/modules/aptpkg.py
+++ b/salt/modules/aptpkg.py
@@ -2214,6 +2214,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.
@@ -2243,7 +2247,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])
@@ -2263,11 +2276,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",
@@ -2286,6 +2300,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/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/salt/modules/cmdmod.py b/salt/modules/cmdmod.py
index 777b539a6f67..c968e007b95d 100644
--- a/salt/modules/cmdmod.py
+++ b/salt/modules/cmdmod.py
@@ -623,15 +623,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(
@@ -1222,6 +1229,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.
@@ -1232,6 +1251,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/cp.py b/salt/modules/cp.py
index 2d878a308b67..1f71d89d46f8 100644
--- a/salt/modules/cp.py
+++ b/salt/modules/cp.py
@@ -20,7 +20,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.context import NamedLoaderContext
from salt.loader.dunder import (
__context__,
@@ -173,8 +173,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())
@@ -1011,7 +1023,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/salt/modules/debian_ip.py b/salt/modules/debian_ip.py
index f974055ca40f..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 = {
@@ -397,6 +413,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 +425,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 +1676,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/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/file.py b/salt/modules/file.py
index 7ca35b378bab..e73926652703 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
@@ -3844,7 +3855,7 @@ def seek_read(path, size, offset):
path
path to file
- seek
+ size
amount to read at once
offset
@@ -4350,7 +4361,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))
@@ -4501,7 +4512,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/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/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/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/modules/groupadd.py b/salt/modules/groupadd.py
index 38b4b054957c..9749a0d881b8 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/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/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/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/salt/modules/linux_shadow.py b/salt/modules/linux_shadow.py
index 09cba9f3b296..b5ef60e0e570 100644
--- a/salt/modules/linux_shadow.py
+++ b/salt/modules/linux_shadow.py
@@ -95,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": "",
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/salt/modules/mac_brew_pkg.py b/salt/modules/mac_brew_pkg.py
index 1b85bd812617..c862738bb8e4 100644
--- a/salt/modules/mac_brew_pkg.py
+++ b/salt/modules/mac_brew_pkg.py
@@ -17,6 +17,7 @@
"""
import copy
+import getpass
import logging
import os
@@ -210,6 +211,19 @@ def homebrew_prefix():
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
)
diff --git a/salt/modules/mysql.py b/salt/modules/mysql.py
index 7c2c3a738b50..023e1b8d90de 100644
--- a/salt/modules/mysql.py
+++ b/salt/modules/mysql.py
@@ -1365,7 +1365,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/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 3dc8d573aa66..a26587fadacb 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
@@ -1346,8 +1353,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/salt/modules/napalm_network.py b/salt/modules/napalm_network.py
index f2b09479c231..4d0d0bc1cd3c 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}"
@@ -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/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/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/salt/modules/nm_ip.py b/salt/modules/nm_ip.py
new file mode 100644
index 000000000000..e65012cb8f3d
--- /dev/null
+++ b/salt/modules/nm_ip.py
@@ -0,0 +1,1023 @@
+"""
+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.28
+
+.. 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, 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 tempfile
+import uuid
+
+import salt.utils.files
+import salt.utils.network
+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 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",
+ "pre_up_cmds",
+ "post_up_cmds",
+ "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 = {
+ "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",
+}
+
+# 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",
+ "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 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. 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:
+
+ .. code-block:: bash
+
+ salt '*' ip.nm_managed
+ """
+ return salt.utils.network.nm_managed()
+
+
+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-, 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):
+ 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) + ";"))
+ # 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 and not disabled:
+ 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)
+
+ if proto in ("disabled", "off", "none"):
+ method = "disabled"
+ elif proto in ("dhcp", "dhcp6"):
+ method = "dhcp"
+ elif addresses:
+ 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))
+
+ 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
+
+
+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):
+ """
+ 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 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
+
+
+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]))
+ # 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
+
+
+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)]
+
+ if settings.get("hwaddr") and settings.get("macaddr"):
+ raise CommandExecutionError(
+ f"interface '{iface}': use either hwaddr or macaddr, not both"
+ )
+
+ sections = [("connection", conn)]
+
+ 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}' "
+ "(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)]
+ 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)))
+ 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):
+ """
+ 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)
+ 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):
+ """
+ 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/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/pillar.py b/salt/modules/pillar.py
index 72a1edb6b6e8..4b518fba8ab0 100644
--- a/salt/modules/pillar.py
+++ b/salt/modules/pillar.py
@@ -254,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
@@ -297,7 +300,14 @@ 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:
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/modules/pkg_resource.py b/salt/modules/pkg_resource.py
index 0cfadb79b10c..6b3df4936458 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/salt/modules/postgres.py b/salt/modules/postgres.py
index bd90df738aaa..2b9d8527b465 100644
--- a/salt/modules/postgres.py
+++ b/salt/modules/postgres.py
@@ -3415,8 +3415,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/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/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/saltcheck.py b/salt/modules/saltcheck.py
index b3aebc965375..aa9ef2d18905 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
-----------------
@@ -326,15 +298,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/salt/modules/saltutil.py b/salt/modules/saltutil.py
index f60be5e4dac0..74c1cecb5371 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:
@@ -98,6 +101,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
@@ -118,15 +138,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
@@ -396,6 +409,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.
@@ -1952,6 +1969,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():
@@ -1959,6 +1985,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
@@ -2012,31 +2049,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/salt/modules/seed.py b/salt/modules/seed.py
index 9bc53a30e983..4dca259b43f0 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/salt/modules/slack_notify.py b/salt/modules/slack_notify.py
index b82367176e82..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.
@@ -260,7 +294,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 +309,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/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/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..32beac62db9a 100644
--- a/salt/modules/tls.py
+++ b/salt/modules/tls.py
@@ -118,7 +118,8 @@
HAS_SSL = False
-X509_EXT_ENABLED = True
+X509_EXT_ENABLED = False
+HAS_LEGACY_PYOPENSSL = False
HAS_CRYPTOGRAPHY = False
HAS_X509_EXTENSION_API = False
try:
@@ -133,6 +134,18 @@
HAS_X509_EXTENSION = hasattr(OpenSSL.crypto, "X509Extension")
# pyOpenSSL >= 26 also removed OpenSSL.crypto.CRL and load_crl.
HAS_OPENSSL_CRL = hasattr(OpenSSL.crypto, "load_crl")
+ # Detect at import time whether pyOpenSSL still exposes the legacy X509
+ # extension / CSR / PKCS12 / CRL API that this module is built on.
+ # pyOpenSSL 26.0 removed X509Extension, X509Req, PKCS12, CRL and
+ # load_crl in favor of the ``cryptography`` package's x509 module.
+ # Compute the flags at import time (instead of only inside
+ # ``__virtual__``) so the module is safe to import directly (for example
+ # from unit tests) without ever attempting to call the removed APIs.
+ X509_EXT_ENABLED = HAS_X509_EXTENSION
+ HAS_LEGACY_PYOPENSSL = all(
+ hasattr(OpenSSL.crypto, name)
+ for name in ("X509Extension", "X509Req", "PKCS12", "CRL", "load_crl")
+ )
except ImportError:
HAS_X509_EXTENSION = False
HAS_OPENSSL_CRL = False
@@ -185,6 +198,15 @@ def __virtual__():
"The tls module requires the X509Extension API removed in "
"pyOpenSSL 25. Use the x509_v2 modules instead.",
)
+ if not HAS_LEGACY_PYOPENSSL:
+ X509_EXT_ENABLED = False
+ return (
+ False,
+ "pyOpenSSL {} no longer exposes the legacy X509Extension / "
+ "X509Req / PKCS12 / CRL APIs that salt.modules.tls depends "
+ "on. Use salt.modules.x509 (backed by the cryptography "
+ "package) instead.".format(OpenSSL_version),
+ )
if OpenSSL_version < Version("0.14"):
X509_EXT_ENABLED = False
log.debug(
@@ -1081,7 +1103,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 +1121,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 +1137,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 +1155,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/virtualenv_mod.py b/salt/modules/virtualenv_mod.py
index 2022a03699a5..ac8c8a2767ad 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!
@@ -240,13 +298,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)
@@ -256,18 +316,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:
@@ -279,9 +336,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
@@ -294,8 +357,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/modules/win_pkg.py b/salt/modules/win_pkg.py
index a5a25dc8479a..8b54d9e97426 100644
--- a/salt/modules/win_pkg.py
+++ b/salt/modules/win_pkg.py
@@ -1114,6 +1114,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
@@ -1195,6 +1206,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}
@@ -1289,6 +1304,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
@@ -1921,6 +2033,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
@@ -1954,6 +2067,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
@@ -2388,6 +2502,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/salt/modules/x509.py b/salt/modules/x509.py
index 5a40eff67a9c..67a13394c81e 100644
--- a/salt/modules/x509.py
+++ b/salt/modules/x509.py
@@ -13,9 +13,9 @@
modules. For breaking changes between both versions,
you can refer to the :ref:`x509_v2 execution module docs `.
- They have become the default ``x509`` modules in Salt 3008.0 (Argon).
- Until they are removed, you can still revert to the deprecated modules
- by setting ``features: {x509_v2: false}`` in your minion configuration.
+ They will become the default ``x509`` modules in Salt 3008 (Argon).
+ You can explicitly switch to the new modules before that release
+ by setting ``features: {x509_v2: true}`` in your minion configuration.
"""
import ast
@@ -37,7 +37,6 @@
import salt.utils.path
import salt.utils.platform
import salt.utils.stringutils
-import salt.utils.timeutil
import salt.utils.versions
from salt.state import STATE_INTERNAL_KEYWORDS as _STATE_INTERNAL_KEYWORDS
@@ -96,6 +95,9 @@ def __virtual__():
"""
only load this module if m2crypto is available
"""
+ # salt.features appears to not be setup when invoked via peer publishing
+ if __opts__.get("features", {}).get("x509_v2"):
+ return (False, "Superseded, using x509_v2")
if HAS_M2:
salt.utils.versions.warn_until(
3009,
@@ -297,7 +299,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:
@@ -1961,7 +1963,7 @@ def expired(certificate):
ret["path"] = certificate
cert = _get_certificate_obj(certificate)
- _now = salt.utils.timeutil.utcnow()
+ _now = datetime.datetime.utcnow()
_expiration_date = cert.get_not_after().get_datetime()
ret["cn"] = _parse_subject(cert.get_subject())["CN"]
@@ -2005,7 +2007,7 @@ def will_expire(certificate, days):
cert = _get_certificate_obj(certificate)
- _check_time = salt.utils.timeutil.utcnow() + datetime.timedelta(days=days)
+ _check_time = datetime.datetime.utcnow() + datetime.timedelta(days=days)
_expiration_date = cert.get_not_after().get_datetime()
ret["cn"] = _parse_subject(cert.get_subject())["CN"]
diff --git a/salt/modules/yumpkg.py b/salt/modules/yumpkg.py
index 6d2fa823b974..177decff768e 100644
--- a/salt/modules/yumpkg.py
+++ b/salt/modules/yumpkg.py
@@ -2486,25 +2486,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/salt/netapi/rest_cherrypy/app.py b/salt/netapi/rest_cherrypy/app.py
index f6488956bd4a..6aca315aca1c 100644
--- a/salt/netapi/rest_cherrypy/app.py
+++ b/salt/netapi/rest_cherrypy/app.py
@@ -644,7 +644,6 @@
import salt.utils.event
import salt.utils.json
import salt.utils.stringutils
-import salt.utils.tracing
import salt.utils.versions
import salt.utils.yaml
@@ -665,6 +664,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
@@ -1353,6 +1394,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,
@@ -1384,20 +1426,6 @@ def exec_lowstate(self, client=None, token=None):
if not isinstance(lowstate, list):
raise cherrypy.HTTPError(400, "Lowstates must be a list")
- salt.utils.tracing.configure({**self.opts, "__role": "api"})
- header_carrier = {
- k.lower(): v for k, v in (cherrypy.request.headers or {}).items()
- }
- trace_ctx = salt.utils.tracing.extract(header_carrier)
- with salt.utils.tracing.start_span(
- "salt.api.exec_lowstate",
- kind=salt.utils.tracing.SpanKind.SERVER,
- attributes={"salt.api.client": client or ""},
- context=trace_ctx,
- ):
- yield from self._exec_lowstate_chunks(lowstate, client, token)
-
- def _exec_lowstate_chunks(self, lowstate, client, token):
# Make any requested additions or modifications to each lowstate, then
# execute each one and yield the result.
with salt.netapi.NetapiClient(self.opts) as api:
@@ -2150,8 +2178,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
@@ -2987,18 +3040,9 @@ def POST(self, *args, **kwargs):
raw_body = getattr(cherrypy.serving.request, "raw_body", "")
headers = dict(cherrypy.request.headers)
- salt.utils.tracing.configure({**cherrypy.config["saltopts"], "__role": "api"})
- header_carrier = {k.lower(): v for k, v in headers.items()}
- trace_ctx = salt.utils.tracing.extract(header_carrier)
- with salt.utils.tracing.start_span(
- f"salt.webhook.{tag}",
- kind=salt.utils.tracing.SpanKind.SERVER,
- attributes={"salt.webhook.tag": tag},
- context=trace_ctx,
- ):
- ret = self.event.fire_event(
- {"body": raw_body, "post": data, "headers": headers}, tag
- )
+ ret = self.event.fire_event(
+ {"body": raw_body, "post": data, "headers": headers}, tag
+ )
return {"success": ret}
diff --git a/salt/netapi/rest_tornado/saltnado.py b/salt/netapi/rest_tornado/saltnado.py
index b233abac0954..bca14013fbe7 100644
--- a/salt/netapi/rest_tornado/saltnado.py
+++ b/salt/netapi/rest_tornado/saltnado.py
@@ -413,7 +413,11 @@ async 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})
@@ -660,6 +664,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|
@@ -700,6 +705,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|
@@ -832,6 +838,7 @@ def get(self): # pylint: disable=arguments-differ
An endpoint to determine salt-api capabilities
.. http:get:: /
+ :noindex:
:reqheader Accept: |req_accept|
@@ -871,6 +878,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|
@@ -1244,6 +1252,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|
@@ -1291,6 +1300,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|
@@ -1370,6 +1380,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.
@@ -1470,6 +1481,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
@@ -1544,6 +1556,7 @@ def get(self):
event is formatted as JSON.
.. http:get:: /events
+ :noindex:
:status 200: |200|
:status 401: |401|
@@ -1703,6 +1716,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/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/pillar/git_pillar.py b/salt/pillar/git_pillar.py
index 0e02e73c9191..ee33c161fb88 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/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/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/salt/returners/pgjsonb.py b/salt/returners/pgjsonb.py
index 676665854810..e1dc482bad8d 100644
--- a/salt/returners/pgjsonb.py
+++ b/salt/returners/pgjsonb.py
@@ -35,6 +35,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.
@@ -173,6 +183,7 @@
import salt.exceptions
import salt.returners
import salt.utils.data
+import salt.utils.jid
import salt.utils.job
try:
@@ -217,6 +228,7 @@ def _get_options(ret=None):
"pass": "pass",
"db": "db",
"port": "port",
+ "connect_timeout": "connect_timeout",
"sslmode": "sslmode",
"sslcert": "sslcert",
"sslkey": "sslkey",
@@ -235,6 +247,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
@@ -252,14 +267,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}"
@@ -416,13 +436,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()
@@ -483,9 +512,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")
@@ -542,11 +583,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/salt/runner.py b/salt/runner.py
index 927c13b6ccd1..279321bcd70f 100644
--- a/salt/runner.py
+++ b/salt/runner.py
@@ -11,6 +11,7 @@
import salt.utils.args
import salt.utils.event
import salt.utils.files
+import salt.utils.resource_warnings
import salt.utils.user
from salt.client import mixins
from salt.output import display_output
@@ -68,6 +69,39 @@ def __enter__(self):
def __exit__(self, *args):
self.destroy()
+ # pylint: disable=W1701
+ def __del__(self):
+ # LTS safety-net: keep the pre-``0c3f53d9172`` GC-time
+ # ``destroy()`` fallback so callers that never wrapped the
+ # client in a context manager do not silently leak the
+ # underlying event socket, but also emit a
+ # ``warn_until_close`` so the missing-``destroy()`` shows up in
+ # normal Salt logs (Python filters ``ResourceWarning`` by
+ # default). The companion change on ``master`` drops the
+ # fallback and requires callers to be explicit.
+ try:
+ unclosed = getattr(self, "event", None) is not None
+ except Exception: # pylint: disable=broad-except
+ return
+ if not unclosed:
+ return
+ try:
+ salt.utils.resource_warnings.warn_until_close(
+ f"unclosed {type(self).__name__} {self!r}; call "
+ f"``destroy()`` or use as a context manager",
+ source=self,
+ log=log,
+ )
+ except Exception: # pylint: disable=broad-except
+ pass
+ try:
+ self.destroy()
+ except Exception: # pylint: disable=broad-except
+ # Finalizer must never raise.
+ pass
+
+ # pylint: enable=W1701
+
@property
def functions(self):
if not hasattr(self, "_functions"):
diff --git a/salt/runners/fileserver.py b/salt/runners/fileserver.py
index 1ed05b68ca99..a08c7e064976 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__)
# Remove possible '__pub_user' in kwargs as it is not expected
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/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/state.py b/salt/state.py
index 299399d13aeb..1497a8cbc9e6 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):
"""
@@ -2560,7 +2605,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)
@@ -2572,6 +2617,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:
@@ -2922,16 +2973,18 @@ def _check_requisites(self, low: LowChunk, running: dict[str, dict[str, Any]]):
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_type_base == RequisiteType.ONCHANGES.value:
- if not run_dict[tag]["changes"]:
+ elif r_type_base == RequisiteType.ONCHANGES.value:
+ # 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_type_base == RequisiteType.WATCH.value
and run_dict[tag]["changes"]
@@ -4936,19 +4989,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/states/archive.py b/salt/states/archive.py
index 28b37f031a00..4cc0aa20bdc1 100644
--- a/salt/states/archive.py
+++ b/salt/states/archive.py
@@ -1825,9 +1825,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/salt/states/chocolatey.py b/salt/states/chocolatey.py
index d4b3b321f887..bf5b027f537f 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/salt/states/file.py b/salt/states/file.py
index d16b6d021906..1ba37158c873 100644
--- a/salt/states/file.py
+++ b/salt/states/file.py
@@ -136,11 +136,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.
.. _use-names-parameter:
@@ -6467,6 +6470,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``
@@ -8059,7 +8070,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
@@ -8288,15 +8302,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,
@@ -8762,7 +8778,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 +9274,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/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/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/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/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/salt/states/pkg.py b/salt/states/pkg.py
index 1ac172c43534..859091383b84 100644
--- a/salt/states/pkg.py
+++ b/salt/states/pkg.py
@@ -753,6 +753,17 @@ def _find_install_targets(
cver = [k for k, v in cur_pkgs.items() if v["origin"] == package_name]
else:
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/salt/states/pkgrepo.py b/salt/states/pkgrepo.py
index 8f355e31375e..5284d1632484 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_database.py b/salt/states/postgres_database.py
index dfe2cfcd9985..c8b826b26a10 100644
--- a/salt/states/postgres_database.py
+++ b/salt/states/postgres_database.py
@@ -230,6 +230,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/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/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/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/salt/states/service.py b/salt/states/service.py
index 183d3d9cd4f8..9da7ba78f74e 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 3d9b52d38016..d4016653ae80 100644
--- a/salt/states/user.py
+++ b/salt/states/user.py
@@ -348,19 +348,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..0e3125733543 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
@@ -68,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://``
@@ -81,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.
@@ -122,8 +141,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/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/tcp.py b/salt/transport/tcp.py
index 642804aa6ecc..6853270efc81 100644
--- a/salt/transport/tcp.py
+++ b/salt/transport/tcp.py
@@ -20,7 +20,7 @@
import time
import urllib
import uuid
-import warnings
+import weakref
import tornado
import tornado.concurrent
@@ -39,6 +39,7 @@
import salt.utils.msgpack
import salt.utils.platform
import salt.utils.process
+import salt.utils.resource_warnings
import salt.utils.versions
from salt.exceptions import SaltClientError, SaltReqTimeoutError
from salt.utils.network import ip_bracket
@@ -136,6 +137,32 @@ def _set_tcp_keepalive(sock, opts):
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 0)
+def _cap_stream_write_buffer(stream, opts):
+ """
+ Apply ``opts['ipc_write_buffer']`` as the outbound write-buffer cap on
+ ``stream``.
+
+ Tornado's ``IOStream`` defaults ``max_write_buffer_size`` to ``None``
+ (unbounded). Under a sustained slow-drain condition (a subscriber
+ that stops reading, a wedged event-bus consumer, a saturated MWorker)
+ the sender-side write buffer therefore grows without bound and the
+ process's RSS climbs with it. Setting the cap gets tornado to raise
+ ``StreamBufferFullError`` at the ceiling so the caller can drop the
+ peer / apply backpressure instead of silently ballooning.
+
+ ``0`` / unset preserves the historical unbounded behavior (opt-in).
+ Existing sites that apply the same cap on the server-side accepted
+ streams (see ``PubServer._apply_write_buffer_cap`` and the
+ ``SaltMessageServer`` kwarg) already use this opt; this helper is
+ the client-side counterpart.
+ """
+ if stream is None or not opts:
+ return
+ cap = opts.get("ipc_write_buffer") or None
+ if cap:
+ stream.max_write_buffer_size = cap
+
+
def _drain_cancelled_tasks(loop, tasks):
"""
Run the event loop just enough to let ``task.cancel()`` actually deliver
@@ -386,6 +413,7 @@ async def getstream(self, **kwargs):
log.debug("TCP stream closed after SSL handshake")
stream = None
continue
+ _cap_stream_write_buffer(stream, self.opts)
self.unpacker = salt.utils.msgpack.Unpacker()
log.debug(
"PubClient connected to %r %r:%r", self, self.host, self.port
@@ -396,6 +424,7 @@ async def getstream(self, **kwargs):
stream = tornado.iostream.IOStream(
socket.socket(sock_type, socket.SOCK_STREAM)
)
+ _cap_stream_write_buffer(stream, self.opts)
await asyncio.wait_for(
stream.connect(self.path), timeout if timeout is not None else 5
)
@@ -707,15 +736,50 @@ def pre_fork(self, process_manager, *args, **kwargs):
)
elif not salt.utils.platform.is_windows():
if self.opts.get("ipc_mode") == "ipc" and self.opts.get("workers_ipc_name"):
- self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
- self._socket.setblocking(0)
- ipc_path = os.path.join(
- self.opts["sock_dir"], self.opts["workers_ipc_name"]
- )
- if os.path.exists(ipc_path):
- os.unlink(ipc_path)
- self._socket.bind(ipc_path)
- os.chmod(ipc_path, 0o600)
+ # PATCH: when the pool tells us its worker_count, bind
+ # one socket per worker at ``workers-{pool_name}-{N}.ipc``.
+ # Otherwise a single ``workers-{pool_name}.ipc`` shared
+ # across all MWorkers means the kernel routes all
+ # PoolRouter client streams to whichever workers won
+ # the accept() race, leaving the rest idle. With
+ # per-worker sockets, PoolRouter opens exactly one
+ # client to each and round-robin dispatch fairly hits
+ # every MWorker in the pool. Equivalent semantics to
+ # ZMQ's ``zmq_device_pooled`` DEALER, in user space.
+ pool_worker_count = int(self.opts.get("pool_worker_count", 0) or 0)
+ if pool_worker_count > 1:
+ base = self.opts["workers_ipc_name"]
+ stem, _, ext = base.rpartition(".")
+ if not stem:
+ stem, ext = base, "ipc"
+ self._sockets = []
+ self._ipc_paths = []
+ for idx in range(pool_worker_count):
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ s.setblocking(0)
+ ipc_path = os.path.join(
+ self.opts["sock_dir"],
+ f"{stem}-{idx}.{ext}",
+ )
+ if os.path.exists(ipc_path):
+ os.unlink(ipc_path)
+ s.bind(ipc_path)
+ os.chmod(ipc_path, 0o600)
+ self._sockets.append(s)
+ self._ipc_paths.append(ipc_path)
+ # Keep self._socket pointing at slot 0 as a legacy
+ # fallback so non-pool-index callers still work.
+ self._socket = self._sockets[0]
+ else:
+ self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ self._socket.setblocking(0)
+ ipc_path = os.path.join(
+ self.opts["sock_dir"], self.opts["workers_ipc_name"]
+ )
+ if os.path.exists(ipc_path):
+ os.unlink(ipc_path)
+ self._socket.bind(ipc_path)
+ os.chmod(ipc_path, 0o600)
else:
self._socket = _get_socket(self.opts)
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
@@ -733,15 +797,46 @@ def post_fork(self, message_handler, io_loop, **kwargs):
self.message_handler = message_handler
log.info("RequestServer workers %s", socket)
+ # PATCH: pick this worker's per-index socket if pre_fork
+ # bound a list. Falls back to ``self._socket`` (single
+ # shared socket) for legacy / non-pool paths. Close the
+ # sibling sockets (other workers own those) so this worker
+ # doesn't retain FDs for peers it will never accept on.
+ pool_index = kwargs.get("pool_index")
+ if pool_index is None:
+ pool_index = self.opts.get("pool_index")
+ sockets_list = getattr(self, "_sockets", None)
+ if sockets_list and pool_index is not None:
+ idx = int(pool_index)
+ if 0 <= idx < len(sockets_list):
+ my_socket = sockets_list[idx]
+ # Close the other workers' inherited sockets in this
+ # process -- they belong to different worker indices.
+ for i, s in enumerate(sockets_list):
+ if i != idx:
+ try:
+ s.close()
+ except Exception: # pylint: disable=broad-except
+ pass
+ self._socket = my_socket
+ # Prevent double-cleanup from other code paths.
+ self._sockets = None
+
with salt.utils.asynchronous.current_ioloop(io_loop):
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 +849,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 +902,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 +924,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:
@@ -944,6 +1051,7 @@ def _create_stream(
sock = _get_socket(self.opts)
_set_tcp_keepalive(sock, self.opts)
stream = tornado.iostream.IOStream(sock, max_buffer_size=max_buffer_size)
+ _cap_stream_write_buffer(stream, self.opts)
return stream, stream.connect(addr)
@@ -993,26 +1101,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 +1175,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:
@@ -1227,8 +1360,8 @@ def close(self):
# pylint: disable=W1701
def __del__(self):
if not self._closing:
- warnings.warn(
- f"unclosed publish subscriber {self!r}", ResourceWarning, source=self
+ salt.utils.resource_warnings.warn_until_close(
+ f"unclosed publish subscriber {self!r}", source=self, log=log
)
# pylint: enable=W1701
@@ -1258,6 +1391,7 @@ def __init__(
self.ssl = ssl # Store SSL context for later use
self._closing = False
self.clients = set()
+ self._writers = {}
self.presence_events = False
if presence_callback:
self.presence_callback = presence_callback
@@ -1273,6 +1407,9 @@ def close(self):
if self._closing:
return
self._closing = True
+ for _, task in self._writers.values():
+ task.cancel()
+ self._writers.clear()
for client in list(self.clients):
client.close()
self.clients.clear()
@@ -1296,7 +1433,13 @@ async def _stream_read(
framed_msg = salt.transport.frame.decode_embedded_strs(framed_msg)
body = framed_msg["body"]
if self.presence_callback:
- self.presence_callback(client, body)
+ result = self.presence_callback(client, body)
+ # Callbacks that need to perform I/O (auth check,
+ # cache lookup) are ``async def`` and return a
+ # coroutine; await it so the verification actually
+ # runs. Sync callbacks return a value directly.
+ if asyncio.iscoroutine(result):
+ await result
except _StreamClosedError as e:
log.debug("tcp stream to %s closed, unable to recv", client.address)
client.close()
@@ -1309,6 +1452,68 @@ 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 _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)
+ # Cancel the per-subscriber writer task so its captured payload
+ # bytes are released immediately, rather than pinned until each
+ # queued wait_for hits its publish_drain_timeout.
+ entry = self._writers.pop(client, None)
+ if entry is not None:
+ _, task = entry
+ task.cancel()
+ try:
+ client.close()
+ except Exception: # pylint: disable=broad-except
+ pass
+
def handle_stream(self, stream, address):
cert = None
try:
@@ -1329,10 +1534,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.
@@ -1353,8 +1576,10 @@ 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))
self.io_loop.create_task(self._stream_read(client))
return
except AttributeError as exc:
@@ -1381,43 +1606,133 @@ async def _validate_ssl_and_add_client(self, stream, address):
# Handshake didn't complete after retries - reject
stream.close()
+ # Default bound on per-subscriber pending drain futures. Sized to
+ # absorb realistic event bursts (thousands of events per broadcast)
+ # while still capping worst-case memory: each queue slot holds a
+ # single tornado Future (~few hundred bytes), so 10k slots x 200
+ # subscribers = ~2 MB retained, vs. the pre-fix path where a 20k
+ # burst against 8 subscribers pinned ~820 MB in fire-and-forget
+ # asyncio Tasks (issue #70147).
+ #
+ # A subscriber that pins the drainer queue past this depth is
+ # treated as slow only if the drainer's head-of-line Future has
+ # been stuck for ``publish_drain_timeout`` seconds -- the queue
+ # depth alone is not the fast-fail signal. Configurable via the
+ # ``pub_server_write_queue_size`` opt.
+ _DEFAULT_WRITE_QUEUE_MAXSIZE = 10000
+
+ def _write_queue_maxsize(self):
+ return self.opts.get(
+ "pub_server_write_queue_size", self._DEFAULT_WRITE_QUEUE_MAXSIZE
+ )
+
+ async def _drain_loop(self, client, queue):
+ """
+ Serially await each queued write Future for ``client``.
+
+ Because ``IOStream.write`` returns Futures that resolve in FIFO
+ order, awaiting them serially never introduces false latency:
+ the head-of-line Future resolves at kernel-drain speed and the
+ tail Futures are already resolved by the time we get to them.
+ The ``publish_drain_timeout`` is a per-Future watchdog -- if
+ the head-of-line write hasn't been flushed to the kernel within
+ the timeout, we treat the subscriber as slow and discard it.
+ """
+ drain_timeout = self.opts.get("publish_drain_timeout", 60.0)
+ while True:
+ fut = await queue.get()
+ try:
+ await asyncio.wait_for(fut, timeout=drain_timeout)
+ except tornado.iostream.StreamClosedError:
+ self._discard_slow_client(client, reason="stream closed")
+ return
+ except asyncio.TimeoutError:
+ self._discard_slow_client(
+ client, reason=f"drain timeout {drain_timeout}s"
+ )
+ return
+ 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
+
+ def _get_or_create_drainer(self, client):
+ entry = self._writers.get(client)
+ if entry is not None:
+ return entry
+ queue = asyncio.Queue(maxsize=self._write_queue_maxsize())
+ task = self.io_loop.create_task(self._drain_loop(client, queue))
+ entry = (queue, task)
+ self._writers[client] = entry
+ return entry
+
+ def _submit_write(self, client, payload, to_remove):
+ """
+ Hand off a single ``payload`` write to ``client``'s per-subscriber
+ drainer queue.
+
+ ``stream.write`` is called synchronously (its returned Future is
+ enqueued for the drainer coroutine to await). We use
+ ``queue.put_nowait`` -- a full queue means the drainer has been
+ starved for long enough that its watchdog will fire on the head-
+ of-line Future; forcing the writer to block here as well would
+ stall the entire ``publish_payload`` broadcast loop (backpressure
+ would propagate back through the pull-socket reader and every
+ other subscriber's write path). Instead the writer marks the
+ subscriber for removal on ``QueueFull`` -- the same fast-fail
+ path already taken for ``StreamBufferFullError``.
+ """
+ try:
+ fut = client.stream.write(payload)
+ except (
+ tornado.iostream.StreamClosedError,
+ tornado.iostream.StreamBufferFullError,
+ ):
+ to_remove.append(client)
+ return False
+ try:
+ queue, _ = self._get_or_create_drainer(client)
+ except RuntimeError:
+ # No running loop: bytes are on the stream already; we just
+ # can't schedule a drain task. Drop the subscriber so the
+ # write buffer doesn't grow without a drainer.
+ to_remove.append(client)
+ return False
+ try:
+ queue.put_nowait(fut)
+ return True
+ except asyncio.QueueFull:
+ # Subscriber's drainer coroutine is not keeping up with
+ # writes. Treat as slow, mirroring the StreamBufferFullError
+ # path. Do NOT block the writer here -- see docstring.
+ self._discard_slow_client(
+ client,
+ reason=f"drain queue full ({self._write_queue_maxsize()})",
+ )
+ return False
+
# 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 = []
- # 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 = []
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)))
+ if self._submit_write(client, payload, to_remove):
sent = True
- except tornado.iostream.StreamClosedError:
- to_remove.append(client)
if not sent:
log.debug("Publish target %s not connected %r", topic, self.clients)
else:
for client in list(self.clients):
- try:
- write_futures.append((client, client.stream.write(payload)))
- 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)
+ self._submit_write(client, payload, to_remove)
for client in to_remove:
log.debug(
"Subscriber at %s has disconnected from publisher", client.address
@@ -1524,9 +1839,45 @@ 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"]
- self.io_loop.create_task(self.payload_handler(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
+ # 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:
+ 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
+ # 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)
@@ -1577,7 +1928,9 @@ def close(self):
# pylint: disable=W1701
def __del__(self):
if not self._closing:
- warnings.warn(f"unclosed tcp puller {self!r}", ResourceWarning, source=self)
+ salt.utils.resource_warnings.warn_until_close(
+ f"unclosed tcp puller {self!r}", source=self, log=log
+ )
# pylint: enable=W1701
@@ -1773,10 +2126,18 @@ 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):
+ # ``ipc_write_buffer`` caps the publisher-side (MWorker fire_event
+ # -> EP pull) tornado write buffer. Without a cap the buffer is
+ # unbounded and a wedged EP io_loop lets MWorkers grow RSS
+ # without exception until the process is OOM-killed. Opt-in
+ # (falsy preserves prior behavior) mirrors the accept-side caps.
+ max_write_buffer_size = self.opts.get("ipc_write_buffer") or None
self.pub_sock = salt.utils.asynchronous.SyncWrapper(
_TCPPubServerPublisher,
(
@@ -1784,6 +2145,7 @@ def connect(self, timeout=None):
self.pull_port,
self.pull_path,
),
+ kwargs={"max_write_buffer_size": max_write_buffer_size},
loop_kwarg="io_loop",
)
self.pub_sock.connect(timeout=timeout)
@@ -1794,6 +2156,137 @@ async def publish(
"""
Publish "load" to minions
"""
+ # LTS default: sync publish path preserved; async-context bypass is
+ # opt-in via ``master_async_mworker``. The bypass exists to avoid a
+ # nested-SyncWrapper deadlock that can only happen when async
+ # handlers invoke ``PublishServer.publish`` from a running
+ # asyncio loop -- which is only true when the async-mworker path
+ # is active. With ``master_async_mworker`` off, handlers are sync
+ # and reach here via SyncWrapper exactly as they did pre-PR.
+ opts = getattr(self, "opts", None) or {}
+ async_mworker = bool(opts.get("master_async_mworker", False))
+ if not async_mworker:
+ if not self.pub_sock:
+ self.connect()
+ self.pub_sock.send(payload)
+ return
+ # PATCH: avoid the nested-SyncWrapper deadlock in the
+ # ``fire_event`` -> ``PublishServer.publish`` -> ``pub_sock.send``
+ # chain. ``self.pub_sock`` is a ``SyncWrapper(_TCPPubServerPublisher)``.
+ # When ``publish`` is invoked from async context (which is the
+ # case in every ``MWorker._return`` -> ``store_job`` ->
+ # ``fire_event`` path), the outer ``SaltEvent.pusher`` SyncWrapper
+ # spawned a worker thread that ran this coroutine, then
+ # ``self.pub_sock.send`` invokes SyncWrapper *again* -- it detects
+ # the inner thread's running io_loop, spawns yet another thread,
+ # and both threads 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 use the raw async
+ # ``_TCPPubServerPublisher`` directly. Cache per running loop
+ # because ``asyncio.Lock`` bound to one loop hangs when awaited
+ # from another (this ``PublishServer`` is shared across the
+ # sync-mode SyncWrapper thread's io_loop and the main asyncio
+ # loop). A per-loop lock serializes concurrent ``fire_event``
+ # tasks so their length-prefixed frames don't interleave on the
+ # shared stream (the framing corruption would otherwise surface
+ # as bogus ~GB length prefixes on the puller side).
+ try:
+ asyncio.get_running_loop()
+ in_async = True
+ except RuntimeError:
+ in_async = False
+ if in_async:
+ loop = asyncio.get_running_loop()
+ 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. Earlier revision keyed on ``id(loop)`` which
+ # is unsafe because CPython recycles integer ids after
+ # GC -- a fresh ``SyncWrapper.asyncio_loop`` could land
+ # on the same id as a dead one and inherit that dead
+ # loop's cached (dead) publisher. Symptom was a
+ # persistent flood of ``StreamClosedError`` on the local
+ # IPC event bus after the first stream failure.
+ per_loop = self._async_pub_by_loop = weakref.WeakKeyDictionary()
+
+ entry = per_loop.get(loop)
+ if entry is not None:
+ pub, _lock = entry
+ # PATCH: also invalidate on a dead stream. If the
+ # puller side went away (slow-subscriber discard, ZMTP
+ # heartbeat failure, 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():
+ # PATCH: close the stale publisher explicitly so
+ # its Python object graph (``Unpacker``,
+ # ``_connecting_future``) is released now rather
+ # than lingering until the next GC pass. See the
+ # matching close in the ``StreamClosedError``
+ # rebuild branch below.
+ try:
+ pub.close()
+ except Exception: # pylint: disable=broad-except
+ pass
+ del per_loop[loop]
+ entry = None
+
+ if entry is None:
+ pub = _TCPPubServerPublisher(
+ self.pull_host,
+ self.pull_port,
+ self.pull_path,
+ )
+ await pub.connect()
+ lock = asyncio.Lock()
+ entry = (pub, lock)
+ per_loop[loop] = entry
+ pub, lock = entry
+ async with lock:
+ try:
+ await pub.send(payload)
+ except tornado.iostream.StreamClosedError:
+ # PATCH: puller closed on us mid-send. Drop the
+ # cached publisher and rebuild once so the next call
+ # (or the retry here) can succeed. We do a single
+ # retry inside the lock to preserve message ordering
+ # for concurrent callers on this loop.
+ #
+ # PATCH: explicitly ``close()`` the stale publisher
+ # before dropping it. Tornado's ``StreamClosedError``
+ # guarantees the underlying socket FD is already
+ # released, so this is not an FD-leak fix -- but the
+ # publisher still owns a Python object graph
+ # (``stream``, ``Unpacker``, ``_connecting_future``)
+ # that would otherwise linger across reconnect until
+ # the next GC cycle. Under a flapping puller (auth
+ # storm + slow-subscriber prune) that graph
+ # accumulates. ``close()`` is idempotent-safe on an
+ # already-closed stream (EBADF is swallowed) and
+ # resolves any pending ``_connecting_future`` so
+ # awaiters don't hang.
+ stale_pub = per_loop.pop(loop, (None,))[0]
+ if stale_pub is not None:
+ try:
+ stale_pub.close()
+ except Exception: # pylint: disable=broad-except
+ pass
+ pub = _TCPPubServerPublisher(
+ self.pull_host,
+ self.pull_port,
+ self.pull_path,
+ )
+ await pub.connect()
+ per_loop[loop] = (pub, lock)
+ await pub.send(payload)
+ return
if not self.pub_sock:
self.connect()
self.pub_sock.send(payload)
@@ -1809,6 +2302,28 @@ def close(self):
else:
self.pub_sock.close()
self.pub_sock = None
+ # PATCH: Bug 1's async-context bypass caches a raw
+ # ``_TCPPubServerPublisher`` per running loop in
+ # ``self._async_pub_by_loop``. Each cached publisher owns an
+ # IPC/TCP socket FD. Without this, every
+ # ``Minion._return_pub`` cycle leaks one Unix-socket FD on the
+ # minion's local event bus (~450 leaked pull.ipc client FDs
+ # under sustained stress -> ulimit trip). Close every cached
+ # publisher we still hold before dropping the map.
+ per_loop = getattr(self, "_async_pub_by_loop", None)
+ if per_loop is not None:
+ for pub, _lock in list(per_loop.values()):
+ stream = getattr(pub, "stream", None)
+ if stream is not None and not stream.closed():
+ try:
+ stream.close()
+ except Exception: # pylint: disable=broad-except
+ pass
+ try:
+ per_loop.clear()
+ except Exception: # pylint: disable=broad-except
+ pass
+ self._async_pub_by_loop = None
if self.pub_server:
self.pub_server.close()
self.pub_server = None
@@ -1827,8 +2342,8 @@ def close(self):
# pylint: disable=W1701
def __del__(self):
if not self._closing:
- warnings.warn(
- f"unclosed publish server {self!r}", ResourceWarning, source=self
+ salt.utils.resource_warnings.warn_until_close(
+ f"unclosed publish server {self!r}", source=self, log=log
)
# pylint: enable=W1701
@@ -1861,7 +2376,7 @@ class directly.
"close",
]
- def __init__(self, host, port, path, io_loop=None):
+ def __init__(self, host, port, path, io_loop=None, max_write_buffer_size=None):
"""
Create a new IPC client
@@ -1869,6 +2384,13 @@ def __init__(self, host, port, path, io_loop=None):
existing IPC servers. Clients can then send messages
to the server.
+ ``max_write_buffer_size`` (bytes) caps the outbound tornado
+ write buffer on the underlying ``IOStream``. Under a
+ sustained slow-drain condition on the pull side (a wedged
+ ``EventPublisher`` io_loop, a saturated peer) the sender's
+ write buffer otherwise grows without bound. Callers pass
+ ``opts['ipc_write_buffer']`` here; ``None`` / ``0`` preserves
+ the historical unbounded behavior.
"""
if io_loop is None:
self.io_loop = salt.utils.asynchronous.aioloop(
@@ -1883,6 +2405,7 @@ def __init__(self, host, port, path, io_loop=None):
self.stream = None
self.unpacker = salt.utils.msgpack.Unpacker(raw=False)
self._connecting_future = None
+ self.max_write_buffer_size = max_write_buffer_size or None
def connected(self):
return self.stream is not None and not self.stream.closed()
@@ -1939,9 +2462,17 @@ async def _connect(self, timeout=None):
sock = socket.socket(sock_type, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.stream = tornado.iostream.IOStream(sock)
+ if self.max_write_buffer_size:
+ self.stream.max_write_buffer_size = self.max_write_buffer_size
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 +2482,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 +2498,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__)
@@ -1986,8 +2534,8 @@ def close(self):
# pylint: disable=W1701
def __del__(self):
if not self._closing:
- warnings.warn(
- "unclosed publisher client {self!r}", ResourceWarning, source=self
+ salt.utils.resource_warnings.warn_until_close(
+ f"unclosed publisher client {self!r}", source=self, log=log
)
# pylint: enable=W1701
@@ -2072,6 +2620,7 @@ async def getstream(self, **kwargs):
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.setblocking(0)
stream = tornado.iostream.IOStream(sock)
+ _cap_stream_write_buffer(stream, self.opts)
await stream.connect(self.host)
else:
stream = await self._tcp_client.connect(
@@ -2080,6 +2629,7 @@ async def getstream(self, **kwargs):
ssl_options=ctx,
**kwargs,
)
+ _cap_stream_write_buffer(stream, self.opts)
except Exception as exc: # pylint: disable=broad-except
log.warning(
"TCP Message Client encountered an exception while connecting to"
diff --git a/salt/transport/ws.py b/salt/transport/ws.py
index bb600d4ad9ed..7168da63b374 100644
--- a/salt/transport/ws.py
+++ b/salt/transport/ws.py
@@ -4,7 +4,6 @@
import os
import socket
import time
-import warnings
import aiohttp
import aiohttp.web
@@ -14,6 +13,7 @@
import salt.transport.base
import salt.transport.frame
import salt.utils.asynchronous
+import salt.utils.resource_warnings
from salt.transport.tcp import (
USE_LOAD_BALANCER,
LoadBalancerServer,
@@ -104,8 +104,8 @@ async def _async_cleanup(self):
# pylint: disable=W1701
def __del__(self):
if not self._closing:
- warnings.warn(
- "unclosed publish client {self!r}", ResourceWarning, source=self
+ salt.utils.resource_warnings.warn_until_close(
+ f"unclosed publish client {self!r}", source=self, log=log
)
# pylint: enable=W1701
@@ -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:
@@ -723,8 +728,8 @@ def get_master_uri(self, opts):
# pylint: disable=W1701
def __del__(self):
if not self._closing:
- warnings.warn(
- "Unclosed publish client {self!r}", ResourceWarning, source=self
+ salt.utils.resource_warnings.warn_until_close(
+ f"unclosed publish client {self!r}", source=self, log=log
)
# pylint: enable=W1701
diff --git a/salt/transport/zeromq.py b/salt/transport/zeromq.py
index 84046009b70e..120e762c79c1 100644
--- a/salt/transport/zeromq.py
+++ b/salt/transport/zeromq.py
@@ -7,15 +7,16 @@
import datetime
import errno
import hashlib
-import itertools
import logging
import multiprocessing
import os
+import secrets
import signal
import socket
import stat
import sys
import threading
+import uuid
import zlib
from random import randint
@@ -30,6 +31,7 @@
import zmq.eventloop.future
import zmq.eventloop.zmqstream
+import salt._process_role
import salt.payload
import salt.transport.base
import salt.utils.asynchronous
@@ -54,16 +56,22 @@
# 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
+# ~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):
@@ -325,6 +333,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)
@@ -607,7 +621,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))
@@ -1124,23 +1154,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
@@ -1149,25 +1184,20 @@ 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:
- # 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.
- identity = "salt-req/{role}/{minion_id}/{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,
- slot=next(_REQ_IDENTITY_SLOT),
+ pid=os.getpid(),
+ uuid=uuid.uuid4().hex,
)
self.socket.setsockopt(zmq.IDENTITY, identity.encode("utf-8"))
@@ -1637,6 +1667,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 +1688,7 @@ def stop(self):
pass
self._socket = None
self._running.clear()
+ self._monitor_socket.close()
self._monitor_socket = None
log.trace("Event monitor done!")
@@ -1851,7 +1888,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:
@@ -1976,6 +2019,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:
@@ -2019,6 +2075,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",
@@ -2052,15 +2112,111 @@ 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:
- try:
- self.context.destroy(0)
- except Exception: # pylint: disable=broad-except
- pass
- self.context = None
+ #
+ # 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
+
+ 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:
+ 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,
+ )
+ _sync_teardown()
+
+ 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:
+ # 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
+ else:
+ done_evt = threading.Event()
+
+ 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:
@@ -2119,97 +2275,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,
@@ -2218,6 +2365,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():
@@ -2225,41 +2373,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..031da0fd5932 100644
--- a/salt/utils/asynchronous.py
+++ b/salt/utils/asynchronous.py
@@ -11,6 +11,8 @@
import tornado.concurrent
import tornado.ioloop
+import salt.utils.resource_warnings
+
log = logging.getLogger(__name__)
@@ -295,3 +297,43 @@ 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
+ salt.utils.resource_warnings.warn_until_close(
+ f"unclosed {type(self).__name__} for cls="
+ f"{getattr(self, 'cls', None)!r}; call ``close()`` or "
+ f"use as a context manager",
+ source=self,
+ log=log,
+ )
+
+ # pylint: enable=W1701
diff --git a/salt/utils/data.py b/salt/utils/data.py
index 63bf4614afd7..3876c707cdc0 100644
--- a/salt/utils/data.py
+++ b/salt/utils/data.py
@@ -1102,6 +1102,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/salt/utils/event.py b/salt/utils/event.py
index 5a7957e1cef2..9914a9f151f3 100644
--- a/salt/utils/event.py
+++ b/salt/utils/event.py
@@ -74,6 +74,7 @@
import salt.utils.metrics
import salt.utils.platform
import salt.utils.process
+import salt.utils.resource_warnings
import salt.utils.stringutils
import salt.utils.tracing
import salt.utils.zeromq
@@ -273,6 +274,51 @@ def __enter__(self):
def __exit__(self, exc_type, exc_val, exc_tb):
self.destroy()
+ # pylint: disable=W1701
+ def __del__(self):
+ # On this LTS branch ``__del__`` both surfaces the leak via
+ # ``warn_until_close`` (loud WARNING-level log record and
+ # ``ResourceWarning``) AND falls back to calling ``destroy()`` as
+ # a safety net, so callers that historically relied on GC-time
+ # cleanup (e.g. sseape's fire-and-forget
+ # ``get_master_event(...).fire_event(...)`` pattern) do not
+ # silently leak one ``master_event_pull.ipc`` (and, for
+ # ``listen=True``, one ``master_event_pub.ipc``) socket per
+ # instance.
+ #
+ # The companion change on ``master`` (Potassium) drops the
+ # ``destroy()`` fallback and requires callers to use a context
+ # manager or explicit ``destroy()``; the loud warning here is the
+ # migration signal for that change.
+ #
+ # Python's ``__del__`` runs during GC (may be 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). Everything below is
+ # guarded so a finalizer never propagates an exception.
+ 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
+ salt.utils.resource_warnings.warn_until_close(
+ f"unclosed {type(self).__name__} {self!r}; call "
+ f"``destroy()`` or use as a context manager",
+ source=self,
+ log=log,
+ )
+ try:
+ self.destroy()
+ except Exception: # pylint: disable=broad-except
+ # Finalizer must never raise.
+ pass
+
+ # pylint: enable=W1701
+
def __init__(
self,
node,
@@ -878,11 +924,54 @@ async def fire_event_async(self, data, tag, cb=None, timeout=1000):
).add(1, attributes={"tag_prefix": _event_tag_prefix(tag)})
event = self.pack(tag, data, max_size=self.opts["max_event_size"])
msg = salt.utils.stringutils.to_bytes(event, "utf-8")
- if self._run_io_loop_sync:
- # pusher is a SyncWrapper; publish() runs synchronously.
- self.pusher.publish(msg)
+ # LTS default: pre-PR sync publish path preserved. The
+ # SyncWrapper-bypass below only kicks in when
+ # ``master_async_mworker`` is on, because that's the only path
+ # where async handlers invoke ``fire_event_async`` from a
+ # running asyncio loop and could hit the nested-SyncWrapper
+ # deadlock / dead-loop-cache issues it fixes.
+ async_mworker = bool(self.opts.get("master_async_mworker", False))
+ if not async_mworker:
+ if self._run_io_loop_sync:
+ # pusher is a SyncWrapper; publish() runs synchronously.
+ self.pusher.publish(msg)
+ else:
+ await self.pusher.publish(msg)
else:
- await self.pusher.publish(msg)
+ # ``self.pusher`` may be a ``SyncWrapper`` (constructed with
+ # ``io_loop=None`` in ``AESFuncs.__init__`` etc.). Its
+ # ``publish`` normally runs synchronously via ``run_sync`` on
+ # the wrapper's own io_loop. But when ``fire_event_async``
+ # is invoked from a running asyncio loop -- as async-dispatched
+ # master handlers do (``_pillar``, ``_return``,
+ # ``_minion_event`` etc.) -- going through SyncWrapper races
+ # both (a) the SyncWrapper's own loop teardown (RuntimeError:
+ # Event loop stopped before Future completed) and (b) the
+ # nested-SyncWrapper deadlock that Bug 1 patched at the outer
+ # ``PublishServer.publish`` level.
+ #
+ # Reach into ``self.pusher.obj`` -- the raw async publisher --
+ # and await its ``publish`` directly on the running loop. The
+ # per-loop cache from Bug 1 handles the fact that the
+ # publisher was constructed on the SyncWrapper's io_loop and
+ # is now being invoked from a different loop.
+ underlying = getattr(self.pusher, "obj", None)
+ if (
+ self._run_io_loop_sync
+ and underlying is not None
+ and hasattr(underlying, "publish")
+ ):
+ result = underlying.publish(msg)
+ if inspect.isawaitable(result):
+ await result
+ elif self._run_io_loop_sync:
+ # No async publisher available; fall back to the sync path
+ # by running it in the default executor so we don't block
+ # our own event loop.
+ loop = asyncio.get_running_loop()
+ await loop.run_in_executor(None, self.pusher.publish, msg)
+ else:
+ await self.pusher.publish(msg)
if cb is not None:
warn_until(
3009,
@@ -926,12 +1015,37 @@ def fire_event(self, data, tag, timeout=1000):
try:
self.pusher.publish(msg)
except Exception as exc: # pylint: disable=broad-except
- log.debug(
- "Publisher send failed with exception: %s",
+ # LTS default: pre-PR behaviour re-raises the exception
+ # (``fire_event`` returning ``False`` on connect failure but
+ # propagating publisher errors). The drop-and-warn path
+ # exists to avoid a memory leak that only manifests when
+ # async handlers offload via ``run_in_executor`` and the
+ # traceback pins large payloads across threads; that only
+ # happens with ``master_async_mworker`` on.
+ if not self.opts.get("master_async_mworker", False):
+ log.debug(
+ "Publisher send failed with exception: %s",
+ exc,
+ exc_info_on_loglevel=logging.DEBUG,
+ )
+ raise
+ # PATCH: do NOT re-raise. ``fire_event`` is best-effort:
+ # callers use it to publish informational events (job
+ # returns, state changes, etc.) and should not fail
+ # because the local IPC bus is temporarily unavailable.
+ # More importantly, re-raising leaks memory catastrophically
+ # under sustained failure -- the traceback holds every
+ # frame in ``run_in_executor``'s thread including ``load``
+ # (a state.apply return dict, often MB) and asyncio's
+ # exception logging retains those tracebacks. Under
+ # stress with ``self.pusher``'s SyncWrapper io_loop closed
+ # this leaks tens of GB per minute (observed
+ # ~66 GB in a single MWorker within a few minutes).
+ log.warning(
+ "Publisher send failed, dropping event tag=%s: %s",
+ tag,
exc,
- exc_info_on_loglevel=logging.DEBUG,
)
- raise
else:
task = self.io_loop.create_task(self.pusher.publish(msg))
self._publish_tasks.add(task)
diff --git a/salt/utils/files.py b/salt/utils/files.py
index a719bd09b64a..ee8aeea1d07a 100644
--- a/salt/utils/files.py
+++ b/salt/utils/files.py
@@ -14,6 +14,7 @@
import subprocess
import sys
import tempfile
+import threading
import time
import urllib.parse
@@ -97,6 +98,12 @@ def helper(*args, **kwargs):
log = logging.getLogger(__name__)
+# The umask is global to the process, so concurrent calls to get_umask() and
+# set_umask() must be serialized or they can restore each other's saved value
+# and leave the process umask permanently changed. An RLock is used so a
+# thread holding the lock can still nest set_umask() calls.
+_umask_lock = threading.RLock()
+
LOCAL_PROTOS = ("", "file")
REMOTE_PROTOS = ("http", "https", "ftp", "swift", "s3")
VALID_PROTOS = ("salt", "file") + REMOTE_PROTOS
@@ -478,8 +485,9 @@ def get_umask():
"""
Returns the current umask
"""
- ret = os.umask(0) # pylint: disable=blacklisted-function
- os.umask(ret) # pylint: disable=blacklisted-function
+ with _umask_lock:
+ ret = os.umask(0) # pylint: disable=blacklisted-function
+ os.umask(ret) # pylint: disable=blacklisted-function
return ret
@@ -492,11 +500,12 @@ def set_umask(mask):
# Don't attempt on Windows, or if no mask was passed
yield
else:
- orig_mask = os.umask(mask) # pylint: disable=blacklisted-function
- try:
- yield
- finally:
- os.umask(orig_mask) # pylint: disable=blacklisted-function
+ with _umask_lock:
+ orig_mask = os.umask(mask) # pylint: disable=blacklisted-function
+ try:
+ yield
+ finally:
+ os.umask(orig_mask) # pylint: disable=blacklisted-function
def fopen(*args, **kwargs):
diff --git a/salt/utils/jinja.py b/salt/utils/jinja.py
index a043c39f9859..bec692f77f4d 100644
--- a/salt/utils/jinja.py
+++ b/salt/utils/jinja.py
@@ -246,9 +246,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")
@@ -1028,7 +1036,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 = {
@@ -1193,10 +1256,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/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/msgpack.py b/salt/utils/msgpack.py
index 1b4adcef1953..140ae705801c 100644
--- a/salt/utils/msgpack.py
+++ b/salt/utils/msgpack.py
@@ -4,19 +4,32 @@
import logging
-import salt.utils.versions
-
log = logging.getLogger(__name__)
-msgpack = None
-if salt.utils.versions.reqs.msgpack:
- msgpack = salt.utils.versions.reqs.msgpack.module
+HAS_MSGPACK = False
+try:
+ import msgpack
+
+ # There is a serialization issue on ARM and potentially other platforms for some msgpack bindings, check for it
+ if (
+ msgpack.loads(msgpack.dumps([1, 2, 3], use_bin_type=False), use_list=True)
+ is None
+ ):
+ raise ImportError
+ HAS_MSGPACK = True
+except ImportError:
+ try:
+ import msgpack_pure as msgpack # pylint: disable=import-error
+
+ HAS_MSGPACK = True
+ except ImportError:
+ pass
+ # Don't exit if msgpack is not available, this is to make local mode work without msgpack
+ # sys.exit(salt.defaults.exitcodes.EX_GENERIC)
+
+if HAS_MSGPACK and hasattr(msgpack, "exceptions"):
+ exceptions = msgpack.exceptions
else:
- # TODO: Come up with a sane way to get a configured logfile
- # and write to the logfile when this error is hit also
- log.fatal("Unable to import msgpack or msgpack_pure python modules")
-
-if msgpack and not hasattr(msgpack, "exceptions"):
class PackValueError(Exception):
"""
@@ -31,17 +44,11 @@ class _exceptions:
PackValueError = PackValueError()
exceptions = _exceptions()
-elif msgpack:
- exceptions = msgpack.exceptions
# One-to-one mappings
-Packer = None
-ExtType = None
-version = (0, 0, 0)
-if msgpack:
- Packer = msgpack.Packer
- ExtType = msgpack.ExtType
- version = msgpack.version
+Packer = msgpack.Packer
+ExtType = msgpack.ExtType
+version = (0, 0, 0) if not HAS_MSGPACK else msgpack.version
def _sanitize_msgpack_kwargs(kwargs):
@@ -58,39 +65,34 @@ 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)
-if msgpack:
-
- class Unpacker(msgpack.Unpacker):
- """
- Wraps the msgpack.Unpacker and removes non-relevant arguments
- """
-
- def __init__(self, *args, **kwargs):
- msgpack.Unpacker.__init__(
- self, *args, **_sanitize_msgpack_unpack_kwargs(kwargs)
- )
-
-else:
-
- class Unpacker:
- """
- Stub for msgpack.Unpacker
- """
+class Unpacker(msgpack.Unpacker):
+ """
+ Wraps the msgpack.Unpacker and removes non-relevant arguments
+ """
- def __init__(self, *args, **kwargs):
- raise RuntimeError("msgpack is not available")
+ def __init__(self, *args, **kwargs):
+ msgpack.Unpacker.__init__(
+ self, *args, **_sanitize_msgpack_unpack_kwargs(kwargs)
+ )
def pack(o, stream, **kwargs):
@@ -103,8 +105,6 @@ def pack(o, stream, **kwargs):
By default, this function uses the msgpack module and falls back to
msgpack_pure, if the msgpack is not available.
"""
- if not msgpack:
- raise RuntimeError("msgpack is not available")
# Writes to a stream, there is no return
msgpack.pack(o, stream, **_sanitize_msgpack_kwargs(kwargs))
@@ -119,8 +119,6 @@ def packb(o, **kwargs):
By default, this function uses the msgpack module and falls back to
msgpack_pure, if the msgpack is not available.
"""
- if not msgpack:
- raise RuntimeError("msgpack is not available")
return msgpack.packb(o, **_sanitize_msgpack_kwargs(kwargs))
@@ -133,8 +131,6 @@ def unpack(stream, **kwargs):
By default, this function uses the msgpack module and falls back to
msgpack_pure, if the msgpack is not available.
"""
- if not msgpack:
- raise RuntimeError("msgpack is not available")
return msgpack.unpack(stream, **_sanitize_msgpack_unpack_kwargs(kwargs))
@@ -147,8 +143,6 @@ def unpackb(packed, **kwargs):
By default, this function uses the msgpack module and falls back to
msgpack_pure.
"""
- if not msgpack:
- raise RuntimeError("msgpack is not available")
return msgpack.unpackb(packed, **_sanitize_msgpack_unpack_kwargs(kwargs))
diff --git a/salt/utils/optsdict.py b/salt/utils/optsdict.py
index fbcc1746eca1..65e2379c1001 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:
@@ -650,12 +676,21 @@ def __delitem__(self, key: str):
self._local[key] = _DELETED
def __iter__(self):
- """Iterate over all keys (local + parent chain + base), excluding deleted keys."""
+ """Iterate over all keys (local + parent chain + base), excluding deleted keys.
+
+ The returned iterator is over a snapshot list of keys built under
+ the lock, not over the underlying dict. Iterating the underlying
+ dict is not safe when another thread may be mutating it via
+ ``__setitem__`` / ``__delitem__`` / a second ``__iter__``, which
+ clears+repopulates the same dict; that races produce
+ ``RuntimeError: dictionary changed size during iteration``.
+ """
with self._ensure_lock():
- # Sync underlying dict for C-level iteration (e.g., JSON serialization)
- # This ensures json.dumps() works without needing to_dict()
- # Build items dict first to avoid leaving underlying dict in bad state
- # if an exception occurs during iteration
+ # Sync underlying dict for C-level access (e.g., JSON
+ # serialization via json.dumps()) so it doesn't need to
+ # _go through to_dict(). This is best-effort: concurrent
+ # iterators may repopulate it, but consumers of __iter__
+ # walk the snapshot below.
items = {}
for key in self._get_all_keys():
try:
@@ -671,7 +706,10 @@ def __iter__(self):
for key, value in items.items():
dict.__setitem__(self, key, value)
- return dict.__iter__(self)
+ # Return an iterator over a snapshot list; the underlying
+ # dict may be cleared/rebuilt by concurrent __iter__ calls
+ # once we release the lock below.
+ return iter(list(items))
def _get_all_keys(self):
"""Get all keys from local, parent chain, and base."""
@@ -693,11 +731,23 @@ def _get_all_keys(self):
return keys
def __len__(self) -> int:
- """Return total number of keys."""
+ """
+ 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():
- # Sync underlying dict for C-level access
- _ = iter(self)
- return dict.__len__(self)
+ 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/salt/utils/process.py b/salt/utils/process.py
index a3b61b8fb2a3..405de769cc6d 100644
--- a/salt/utils/process.py
+++ b/salt/utils/process.py
@@ -176,6 +176,26 @@ def notify_systemd():
"""
Notify systemd that this process has started
"""
+ return _notify_systemd(b"READY=1", "--ready")
+
+
+def notify_systemd_stopping():
+ """
+ Notify systemd that this process has entered its graceful shutdown phase.
+
+ Best-effort: silently returns ``False`` when the ``systemd`` bindings are
+ not importable *and* the ``systemd-notify`` helper is not on ``PATH``,
+ or when the ``NOTIFY_SOCKET`` env var is unset (i.e. the daemon was not
+ started under a ``Type=notify`` systemd unit).
+ """
+ return _notify_systemd(b"STOPPING=1", "--stopping")
+
+
+def _notify_systemd(message, notify_flag):
+ """
+ Send ``message`` (bytes) to the systemd notify socket, falling back to
+ ``systemd-notify `` when the Python bindings are unavailable.
+ """
try:
import systemd.daemon # pylint: disable=no-name-in-module
except ImportError:
@@ -189,16 +209,16 @@ def notify_systemd():
try:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
sock.connect(notify_socket)
- sock.sendall(b"READY=1")
+ sock.sendall(message)
sock.close()
except OSError:
- return systemd_notify_call("--ready")
+ return systemd_notify_call(notify_flag)
return True
return False
if systemd.daemon.booted():
try:
- return systemd.daemon.notify("READY=1")
+ return systemd.daemon.notify(message.decode("ascii"))
except SystemError:
# Daemon was not started by systemd
pass
@@ -826,7 +846,7 @@ def _handle_signals(self, *args, **kwargs):
if callable(self._sigterm_handler):
return self._sigterm_handler(*args)
elif self._sigterm_handler is not None:
- return signal.default_int_handler(signal.SIGTERM)(*args)
+ return signal.default_int_handler(*args)
else:
return
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/salt/utils/resource_warnings.py b/salt/utils/resource_warnings.py
new file mode 100644
index 000000000000..0ae0c1fa1599
--- /dev/null
+++ b/salt/utils/resource_warnings.py
@@ -0,0 +1,53 @@
+"""
+Helpers for surfacing "unclosed resource" warnings through both Python's
+``warnings`` module and Salt's logging pipeline.
+"""
+
+import logging
+import warnings
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def warn_until_close(message, source, category=ResourceWarning, log=None):
+ """
+ Emit ``category`` for an unclosed resource AND log the same message
+ at WARNING level so it survives Python's default warnings filter.
+
+ ``ResourceWarning`` is filtered out by Python's default warnings
+ filter, so a bare ``warnings.warn(..., ResourceWarning)`` from a
+ ``__del__`` finalizer is silently dropped in production. Callers
+ that missed a ``close()`` / ``destroy()`` / context-manager contract
+ therefore never see the warning, and the leaked resource
+ accumulates invisibly.
+
+ (Concrete incident: after Salt commit ``0c3f53d9172`` removed the
+ ``__del__``-based cleanup from ``SaltEvent`` / ``MasterMinion`` /
+ ``RunnerClient`` / ``WheelClient`` in favor of a
+ ``ResourceWarning``-emitting ``__del__``, out-of-tree consumers
+ like SSEAPE that relied on GC-time cleanup via
+ ``get_master_event(...).fire_event(...)`` began leaking one unix
+ socket per fire-and-forget instance -- but the intended
+ ``ResourceWarning`` was never visible because ``ResourceWarning`` is
+ silenced by default in production Python.)
+
+ Emitting a WARNING-level log record alongside the warning makes the
+ leak visible in normal Salt logs regardless of the operator's
+ warnings-filter setting. Callers should pass their module-local
+ ``log`` so records are attributed to the right module; the
+ utility's own logger is the fallback.
+
+ Called from ``__del__`` finalizers -- must never raise.
+ """
+ try:
+ warnings.warn(message, category, source=source)
+ 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
+ try:
+ (log or _LOGGER).warning(message)
+ except Exception: # pylint: disable=broad-except
+ # Same rationale for the logging module.
+ pass
diff --git a/salt/utils/secret.py b/salt/utils/secret.py
index 1ed2e588385b..5c8b840aa757 100644
--- a/salt/utils/secret.py
+++ b/salt/utils/secret.py
@@ -62,6 +62,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 +82,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)
@@ -244,7 +255,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``.
@@ -255,10 +267,12 @@ def serial(value, _seen=None):
"""
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:
@@ -312,10 +326,12 @@ 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"])
diff --git a/salt/utils/state.py b/salt/utils/state.py
index 15b633a35e53..7c36a232ac35 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
@@ -431,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/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/salt/utils/templates.py b/salt/utils/templates.py
index 4242860f6a5f..5cb10676c53a 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 pathlib
@@ -444,11 +445,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/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/salt/utils/versions.py b/salt/utils/versions.py
index 3975785d58c2..4f2b7b959772 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,54 @@
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, 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)
+ 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 +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, 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):
- 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 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 "
@@ -168,7 +212,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/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/salt/version.py b/salt/version.py
index 4d43d307244d..6f875f39bbbf 100644
--- a/salt/version.py
+++ b/salt/version.py
@@ -82,7 +82,7 @@ class SaltVersionsInfo(type):
PHOSPHORUS = SaltVersion("Phosphorus" , info=3005, released=True)
SULFUR = SaltVersion("Sulfur" , info=3006, released=True)
CHLORINE = SaltVersion("Chlorine" , info=3007, released=True)
- ARGON = SaltVersion("Argon" , info=3008)
+ ARGON = SaltVersion("Argon" , info=3008, released=True)
POTASSIUM = SaltVersion("Potassium" , info=3009)
CALCIUM = SaltVersion("Calcium" , info=3010)
SCANDIUM = SaltVersion("Scandium" , info=3011)
@@ -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):
@@ -620,8 +645,13 @@ def __discover_version(saltstack_version):
"describe",
"--tags",
"--long",
+ # Constrain to the branch's own major (3008.x) so tags
+ # from other majors reachable in the git graph do not hijack
+ # the detected version. Merged forward from 3007.x's
+ # v3007.* constraint (see git log for f3ffc8f9c9ea) and
+ # rebased to this branch's major.
"--match",
- "v[0-9]*",
+ "v3008.*",
"--always",
"--candidates=150",
],
@@ -663,8 +693,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/salt/wheel/__init__.py b/salt/wheel/__init__.py
index b861ec871df8..772d437bb65e 100644
--- a/salt/wheel/__init__.py
+++ b/salt/wheel/__init__.py
@@ -2,6 +2,7 @@
Modules used to control the master itself
"""
+import logging
from collections.abc import Mapping
import salt.channel.client
@@ -10,6 +11,9 @@
import salt.loader
import salt.utils.error
import salt.utils.network
+import salt.utils.resource_warnings
+
+log = logging.getLogger(__name__)
class WheelClient(
@@ -66,6 +70,39 @@ def __enter__(self):
def __exit__(self, *args):
self.destroy()
+ # pylint: disable=W1701
+ def __del__(self):
+ # LTS safety-net: keep the pre-``0c3f53d9172`` GC-time
+ # ``destroy()`` fallback so callers that never wrapped the
+ # client in a context manager do not silently leak the
+ # underlying event socket, but also emit a
+ # ``warn_until_close`` so the missing-``destroy()`` shows up in
+ # normal Salt logs (Python filters ``ResourceWarning`` by
+ # default). The companion change on ``master`` drops the
+ # fallback and requires callers to be explicit.
+ try:
+ unclosed = getattr(self, "event", None) is not None
+ except Exception: # pylint: disable=broad-except
+ return
+ if not unclosed:
+ return
+ try:
+ salt.utils.resource_warnings.warn_until_close(
+ f"unclosed {type(self).__name__} {self!r}; call "
+ f"``destroy()`` or use as a context manager",
+ source=self,
+ log=log,
+ )
+ except Exception: # pylint: disable=broad-except
+ pass
+ try:
+ self.destroy()
+ except Exception: # pylint: disable=broad-except
+ # Finalizer must never raise.
+ pass
+
+ # pylint: enable=W1701
+
# TODO: remove/deprecate
def call_func(self, fun, **kwargs):
"""
diff --git a/salt/wheel/key.py b/salt/wheel/key.py
index 215b3bb2e5f7..b0422dddfa58 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 logging
@@ -183,10 +191,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 +226,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)
diff --git a/tests/integration/modules/test_tls.py b/tests/integration/modules/test_tls.py
index 30b52499c8a1..25c6369c80c4 100644
--- a/tests/integration/modules/test_tls.py
+++ b/tests/integration/modules/test_tls.py
@@ -24,8 +24,13 @@
@pytest.mark.skipif(
- not tls.HAS_X509_EXTENSION_API,
- reason="pyOpenSSL X509Extension API was removed in pyOpenSSL 25",
+ not tls.HAS_X509_EXTENSION_API or not tls.HAS_LEGACY_PYOPENSSL,
+ reason=(
+ "pyOpenSSL 25+ removed the X509Extension API and 26.0+ additionally "
+ "removed X509Req / PKCS12 / CRL / load_crl -- the salt.modules.tls "
+ "code paths exercised here cannot run. Use salt.modules.x509 "
+ "(cryptography-backed) instead."
+ ),
)
class TLSModuleTest(ModuleCase, LoaderModuleMockMixin):
"""
diff --git a/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json b/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json
index 929d844b8aec..40913e162fb4 100644
--- a/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json
+++ b/tests/monitoring/grafana/provisioning/dashboards/salt_monitoring.json
@@ -120,8 +120,8 @@
"id": 10,
"targets": [
{
- "expr": "salt_master_rss_bytes",
- "legendFormat": "Master Process RSS",
+ "expr": "salt_master_pss_bytes",
+ "legendFormat": "Master Process PSS",
"refId": "A"
},
{
@@ -130,7 +130,7 @@
"refId": "B"
}
],
- "title": "Master Memory RSS (Process vs Container)",
+ "title": "Master Memory (Process vs Container)",
"type": "timeseries"
},
{
@@ -154,15 +154,908 @@
},
"id": 11,
"targets": [
+ {
+ "expr": "rate(salt_master_cpu_seconds_total[1m])",
+ "legendFormat": "Master Process CPU",
+ "refId": "A"
+ },
{
"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": "Master CPU",
+ "legendFormat": "Total Container CPU",
+ "refId": "B"
+ }
+ ],
+ "title": "Master CPU Usage (Process vs Container)",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "short"
+ }
+ },
+ "gridPos": {
+ "h": 7,
+ "w": 8,
+ "x": 16,
+ "y": 4
+ },
+ "id": 12,
+ "targets": [
+ {
+ "expr": "salt_master_open_fds",
+ "legendFormat": "Total Open FDs",
+ "refId": "A"
+ },
+ {
+ "expr": "salt_master_process_count",
+ "legendFormat": "Process Count",
+ "refId": "B"
+ }
+ ],
+ "title": "Master Resource Usage (FDs & Processes)",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "short"
+ }
+ },
+ "gridPos": {
+ "h": 7,
+ "w": 8,
+ "x": 0,
+ "y": 11
+ },
+ "id": 13,
+ "targets": [
+ {
+ "expr": "sum(container_fs_inodes_total{container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-master\"}) by (name) - sum(container_fs_inodes_free{container_label_com_docker_compose_project=\"monitoring\",container_label_com_docker_compose_service=\"salt-master\"}) by (name)",
+ "legendFormat": "Master Inodes",
+ "refId": "A"
+ }
+ ],
+ "title": "Master Inodes (Disk Files)",
+ "type": "timeseries"
+ },
+ {
+ "gridPos": {
+ "h": 1,
+ "w": 24,
+ "x": 0,
+ "y": 18
+ },
+ "id": 105,
+ "title": "Per-Master-Process Memory (RSS + PSS)",
+ "type": "row"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "bytes"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 0,
+ "y": 19
+ },
+ "id": 200,
+ "targets": [
+ {
+ "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",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "bytes"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 8,
+ "y": 19
+ },
+ "id": 201,
+ "targets": [
+ {
+ "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",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "bytes"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 16,
+ "y": 19
+ },
+ "id": 202,
+ "targets": [
+ {
+ "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",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "bytes"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 0,
+ "y": 25
+ },
+ "id": 203,
+ "targets": [
+ {
+ "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",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "bytes"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 8,
+ "y": 25
+ },
+ "id": 204,
+ "targets": [
+ {
+ "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",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "bytes"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 16,
+ "y": 25
+ },
+ "id": 205,
+ "targets": [
+ {
+ "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",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "bytes"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 0,
+ "y": 31
+ },
+ "id": 206,
+ "targets": [
+ {
+ "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",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "bytes"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 8,
+ "y": 31
+ },
+ "id": 207,
+ "targets": [
+ {
+ "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",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "bytes"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 16,
+ "y": 31
+ },
+ "id": 208,
+ "targets": [
+ {
+ "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",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "bytes"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 0,
+ "y": 37
+ },
+ "id": 209,
+ "targets": [
+ {
+ "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",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "bytes"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 8,
+ "y": 37
+ },
+ "id": 210,
+ "targets": [
+ {
+ "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",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "bytes"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 16,
+ "y": 37
+ },
+ "id": 211,
+ "targets": [
+ {
+ "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",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "bytes"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 0,
+ "y": 43
+ },
+ "id": 212,
+ "targets": [
+ {
+ "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",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "bytes"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 8,
+ "y": 43
+ },
+ "id": 213,
+ "targets": [
+ {
+ "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",
+ "type": "timeseries"
+ },
+ {
+ "gridPos": {
+ "h": 1,
+ "w": 24,
+ "x": 0,
+ "y": 49
+ },
+ "id": 299,
+ "title": "Per-Master-Process CPU",
+ "type": "row"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "percentunit"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 0,
+ "y": 50
+ },
+ "id": 300,
+ "targets": [
+ {
+ "expr": "rate(salt_master_process_cpu_seconds_total{process=\"EventPublisher\"}[1m])",
+ "legendFormat": "CPU",
+ "refId": "A"
+ }
+ ],
+ "title": "EventPublisher",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "percentunit"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 8,
+ "y": 50
+ },
+ "id": 301,
+ "targets": [
+ {
+ "expr": "rate(salt_master_process_cpu_seconds_total{process=\"PubServerChannel._publish_daemon\"}[1m])",
+ "legendFormat": "CPU",
+ "refId": "A"
+ }
+ ],
+ "title": "PubServerChannel._publish_daemon",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "percentunit"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 16,
+ "y": 50
+ },
+ "id": 302,
+ "targets": [
+ {
+ "expr": "rate(salt_master_process_cpu_seconds_total{process=\"MWorkerQueue\"}[1m])",
+ "legendFormat": "CPU",
+ "refId": "A"
+ }
+ ],
+ "title": "MWorkerQueue",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "percentunit"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 0,
+ "y": 56
+ },
+ "id": 303,
+ "targets": [
+ {
+ "expr": "rate(salt_master_process_cpu_seconds_total{process=\"ReqServer_ProcessManager\"}[1m])",
+ "legendFormat": "CPU",
+ "refId": "A"
+ }
+ ],
+ "title": "ReqServer_ProcessManager",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "percentunit"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 8,
+ "y": 56
+ },
+ "id": 304,
+ "targets": [
+ {
+ "expr": "rate(salt_master_process_cpu_seconds_total{process=\"Maintenance\"}[1m])",
+ "legendFormat": "CPU",
+ "refId": "A"
+ }
+ ],
+ "title": "Maintenance",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "percentunit"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 16,
+ "y": 56
+ },
+ "id": 305,
+ "targets": [
+ {
+ "expr": "rate(salt_master_process_cpu_seconds_total{process=\"EventMonitor\"}[1m])",
+ "legendFormat": "CPU",
+ "refId": "A"
+ }
+ ],
+ "title": "EventMonitor",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "percentunit"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 0,
+ "y": 62
+ },
+ "id": 306,
+ "targets": [
+ {
+ "expr": "rate(salt_master_process_cpu_seconds_total{process=\"BatchManager\"}[1m])",
+ "legendFormat": "CPU",
+ "refId": "A"
+ }
+ ],
+ "title": "BatchManager",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "percentunit"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 8,
+ "y": 62
+ },
+ "id": 307,
+ "targets": [
+ {
+ "expr": "rate(salt_master_process_cpu_seconds_total{process=\"FileserverUpdate\"}[1m])",
+ "legendFormat": "CPU",
+ "refId": "A"
+ }
+ ],
+ "title": "FileserverUpdate",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "percentunit"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 16,
+ "y": 62
+ },
+ "id": 308,
+ "targets": [
+ {
+ "expr": "rate(salt_master_process_cpu_seconds_total{process=\"master-main\"}[1m])",
+ "legendFormat": "CPU",
+ "refId": "A"
+ }
+ ],
+ "title": "master-main",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "percentunit"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 0,
+ "y": 68
+ },
+ "id": 314,
+ "targets": [
+ {
+ "expr": "rate(salt_master_process_cpu_seconds_total{process=~\"MWorker-default-.*\"}[1m])",
+ "legendFormat": "{{process}}",
"refId": "A"
}
],
- "title": "Master CPU Usage",
+ "title": "MWorkers (Combined)",
"type": "timeseries"
},
+ {
+ "gridPos": {
+ "h": 1,
+ "w": 24,
+ "x": 0,
+ "y": 74
+ },
+ "id": 399,
+ "title": "Per-Master-Process FDs",
+ "type": "row"
+ },
{
"datasource": {
"type": "prometheus",
@@ -177,25 +1070,290 @@
}
},
"gridPos": {
- "h": 7,
+ "h": 6,
+ "w": 8,
+ "x": 0,
+ "y": 75
+ },
+ "id": 400,
+ "targets": [
+ {
+ "expr": "salt_master_process_fds{process=\"EventPublisher\"}",
+ "legendFormat": "FDs",
+ "refId": "A"
+ }
+ ],
+ "title": "EventPublisher",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "short"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 8,
+ "y": 75
+ },
+ "id": 401,
+ "targets": [
+ {
+ "expr": "salt_master_process_fds{process=\"PubServerChannel._publish_daemon\"}",
+ "legendFormat": "FDs",
+ "refId": "A"
+ }
+ ],
+ "title": "PubServerChannel._publish_daemon",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "short"
+ }
+ },
+ "gridPos": {
+ "h": 6,
"w": 8,
"x": 16,
- "y": 4
+ "y": 75
},
- "id": 12,
+ "id": 402,
"targets": [
{
- "expr": "salt_master_open_fds",
- "legendFormat": "Total Open FDs",
+ "expr": "salt_master_process_fds{process=\"MWorkerQueue\"}",
+ "legendFormat": "FDs",
"refId": "A"
- },
+ }
+ ],
+ "title": "MWorkerQueue",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "short"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 0,
+ "y": 81
+ },
+ "id": 403,
+ "targets": [
{
- "expr": "salt_master_process_count",
- "legendFormat": "Process Count",
- "refId": "B"
+ "expr": "salt_master_process_fds{process=\"ReqServer_ProcessManager\"}",
+ "legendFormat": "FDs",
+ "refId": "A"
}
],
- "title": "Master Resource Usage (FDs & Processes)",
+ "title": "ReqServer_ProcessManager",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "short"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 8,
+ "y": 81
+ },
+ "id": 404,
+ "targets": [
+ {
+ "expr": "salt_master_process_fds{process=\"Maintenance\"}",
+ "legendFormat": "FDs",
+ "refId": "A"
+ }
+ ],
+ "title": "Maintenance",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "short"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 16,
+ "y": 81
+ },
+ "id": 405,
+ "targets": [
+ {
+ "expr": "salt_master_process_fds{process=\"EventMonitor\"}",
+ "legendFormat": "FDs",
+ "refId": "A"
+ }
+ ],
+ "title": "EventMonitor",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "short"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 0,
+ "y": 87
+ },
+ "id": 406,
+ "targets": [
+ {
+ "expr": "salt_master_process_fds{process=\"BatchManager\"}",
+ "legendFormat": "FDs",
+ "refId": "A"
+ }
+ ],
+ "title": "BatchManager",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "short"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 8,
+ "y": 87
+ },
+ "id": 407,
+ "targets": [
+ {
+ "expr": "salt_master_process_fds{process=\"FileserverUpdate\"}",
+ "legendFormat": "FDs",
+ "refId": "A"
+ }
+ ],
+ "title": "FileserverUpdate",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "short"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 16,
+ "y": 87
+ },
+ "id": 408,
+ "targets": [
+ {
+ "expr": "salt_master_process_fds{process=\"master-main\"}",
+ "legendFormat": "FDs",
+ "refId": "A"
+ }
+ ],
+ "title": "master-main",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "Prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "unit": "short"
+ }
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 0,
+ "y": 93
+ },
+ "id": 414,
+ "targets": [
+ {
+ "expr": "salt_master_process_fds{process=~\"MWorker-default-.*\"}",
+ "legendFormat": "{{process}}",
+ "refId": "A"
+ }
+ ],
+ "title": "MWorkers (Combined)",
"type": "timeseries"
},
{
@@ -203,7 +1361,7 @@
"h": 1,
"w": 24,
"x": 0,
- "y": 11
+ "y": 99
},
"id": 101,
"title": "Minion 1",
@@ -226,7 +1384,7 @@
"h": 7,
"w": 8,
"x": 0,
- "y": 12
+ "y": 100
},
"id": 20,
"targets": [
@@ -256,7 +1414,7 @@
"h": 7,
"w": 8,
"x": 8,
- "y": 12
+ "y": 100
},
"id": 21,
"targets": [
@@ -286,7 +1444,7 @@
"h": 7,
"w": 8,
"x": 16,
- "y": 12
+ "y": 100
},
"id": 22,
"targets": [
@@ -304,7 +1462,7 @@
"h": 1,
"w": 24,
"x": 0,
- "y": 19
+ "y": 107
},
"id": 102,
"title": "Minion 2",
@@ -327,7 +1485,7 @@
"h": 7,
"w": 8,
"x": 0,
- "y": 20
+ "y": 108
},
"id": 30,
"targets": [
@@ -357,7 +1515,7 @@
"h": 7,
"w": 8,
"x": 8,
- "y": 20
+ "y": 108
},
"id": 31,
"targets": [
@@ -387,7 +1545,7 @@
"h": 7,
"w": 8,
"x": 16,
- "y": 20
+ "y": 108
},
"id": 32,
"targets": [
@@ -405,7 +1563,7 @@
"h": 1,
"w": 24,
"x": 0,
- "y": 27
+ "y": 115
},
"id": 103,
"title": "Minion 3",
@@ -428,7 +1586,7 @@
"h": 7,
"w": 8,
"x": 0,
- "y": 28
+ "y": 116
},
"id": 40,
"targets": [
@@ -458,7 +1616,7 @@
"h": 7,
"w": 8,
"x": 8,
- "y": 28
+ "y": 116
},
"id": 41,
"targets": [
@@ -488,7 +1646,7 @@
"h": 7,
"w": 8,
"x": 16,
- "y": 28
+ "y": 116
},
"id": 42,
"targets": [
@@ -506,7 +1664,7 @@
"h": 1,
"w": 24,
"x": 0,
- "y": 35
+ "y": 123
},
"id": 104,
"title": "Salt API",
@@ -529,7 +1687,7 @@
"h": 7,
"w": 8,
"x": 0,
- "y": 36
+ "y": 124
},
"id": 50,
"targets": [
@@ -559,13 +1717,13 @@
"h": 7,
"w": 8,
"x": 8,
- "y": 36
+ "y": 124
},
"id": 51,
"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": "rate(salt_api_cpu_seconds_total[1m])",
+ "legendFormat": "API Process CPU",
"refId": "A"
}
],
@@ -589,7 +1747,7 @@
"h": 7,
"w": 8,
"x": 16,
- "y": 36
+ "y": 124
},
"id": 52,
"targets": [
diff --git a/tests/monitoring/raas.conf b/tests/monitoring/raas.conf
new file mode 100644
index 000000000000..8408892e872d
--- /dev/null
+++ b/tests/monitoring/raas.conf
@@ -0,0 +1,41 @@
+# RaaS Configuration
+sseapi_server: http://192.168.80.1:18080
+sseapi_username: root
+sseapi_password: salt
+
+# Plugin External Modules Path(s)
+beacons_dirs:
+ - /app/saltstack-raas-master/sseape/beacons
+engines_dirs:
+ - /app/saltstack-raas-master/sseape/engines
+fileserver_dirs:
+ - /app/saltstack-raas-master/sseape/fileserver
+pillar_dirs:
+ - /app/saltstack-raas-master/sseape/pillar
+returner_dirs:
+ - /app/saltstack-raas-master/sseape/returners
+roster_dirs:
+ - /app/saltstack-raas-master/sseape/roster
+runner_dirs:
+ - /app/saltstack-raas-master/sseape/runners
+module_dirs:
+ - /app/saltstack-raas-master/sseape/modules
+states_dirs:
+ - /app/saltstack-raas-master/sseape/states
+
+# Enable minimal SSE engines
+engines:
+ - sseapi: {}
+
+# Enable SSE master job cache and event returner
+master_job_cache: sseapi
+event_return: sseapi
+
+# Enable SSE external pillar
+ext_pillar:
+ - sseapi: {}
+
+# Enable SSE fileserver backend
+fileserver_backend:
+ - sseapi
+ - roots
diff --git a/tests/monitoring/render_panels.py b/tests/monitoring/render_panels.py
index 00a46a159fc6..a91052dcf9e3 100644
--- a/tests/monitoring/render_panels.py
+++ b/tests/monitoring/render_panels.py
@@ -122,6 +122,20 @@ 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_percent(unit_hint: str) -> bool:
+ # Grafana's "percent" is already a 0-100 value (unlike "percentunit",
+ # a 0-1 ratio) -- distinct unit string, distinct handling below.
+ return unit_hint.lower() == "percent"
+
+
+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 +144,9 @@ 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_percent = _is_percent(unit_hint)
+ is_count = _is_count_unit(unit_hint)
fig, ax = plt.subplots(figsize=(11, 4))
series_count = 0
@@ -165,6 +182,33 @@ 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_percent:
+ # Values are already 0-100 (unlike percentunit's 0-1), so no
+ # rescaling -- just label and fix the axis to the full 0-100
+ # range. Without an explicit ylim, matplotlib autoscales to the
+ # data's actual min/max; a series that hovers in a narrow band
+ # near 100 (e.g. 99.6-100.0) then gets zoomed in so tightly it
+ # renders as a flat line pinned to the top, hiding real
+ # movement instead of showing it's nearly saturated.
+ ax.set_ylabel("%")
+ ax.set_ylim(0, 100)
+ elif is_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)
diff --git a/tests/monitoring/srv/salt/fd_exporter.py b/tests/monitoring/srv/salt/fd_exporter.py
index 26ffd15cfcb6..a9ecb186072b 100644
--- a/tests/monitoring/srv/salt/fd_exporter.py
+++ b/tests/monitoring/srv/salt/fd_exporter.py
@@ -1,7 +1,167 @@
# 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-process gauges/counters 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.
+ CPU (``salt_master_process_cpu_seconds_total``) is a counter, like
+ its container-level cAdvisor counterpart, so both go through
+ Prometheus ``rate()`` the same way. 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 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.
+"""
import http.server
import os
+_CLK_TCK = os.sysconf("SC_CLK_TCK")
+
+
+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 _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"))
+
+
+def _read_cpu_seconds(pid):
+ """Return cumulative user+system CPU time from ``/proc//stat``.
+
+ Fields 14/15 (utime/stime) are in clock ticks; ``os.sysconf`` gives
+ the ticks-per-second to convert to seconds, matching cAdvisor's
+ ``container_cpu_usage_seconds_total`` counter semantics so both can
+ go through Prometheus ``rate()`` the same way.
+ """
+ with open(f"/proc/{pid}/stat", encoding="utf-8") as fh:
+ stat = fh.read().split()
+ utime_ticks = int(stat[13])
+ stime_ticks = int(stat[14])
+ return (utime_ticks + stime_ticks) / _CLK_TCK
+
+
+def _format_series(name, help_text, samples, metric_type="gauge"):
+ """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} {metric_type}"]
+ 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):
@@ -9,94 +169,239 @@ def log_message(self, format, *args):
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
+ master_pss = 0
+ master_cpu = 0.0
+ api_fds = 0
+ api_procs = 0
+ api_rss = 0
+ api_pss = 0
+ api_cpu = 0.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 = {}
+ master_proc_cpu = {}
+ api_proc_rss = {}
+ api_proc_pss = {}
+ api_proc_fds = {}
+ api_proc_cpu = {}
+
+ 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
+
+ try:
+ pss_bytes = _read_pss_bytes(pid)
+ except (
+ FileNotFoundError,
+ ProcessLookupError,
+ PermissionError,
+ ValueError,
+ IndexError,
+ OSError,
+ ):
+ pss_bytes = 0
+
+ try:
+ cpu_seconds = _read_cpu_seconds(pid)
+ except (
+ FileNotFoundError,
+ ProcessLookupError,
+ PermissionError,
+ ValueError,
+ IndexError,
+ OSError,
+ ):
+ cpu_seconds = 0.0
+
+ if daemon == "master":
+ master_fds += fd_count
+ master_procs += 1
+ master_rss += rss_bytes
+ master_pss += pss_bytes
+ master_cpu += cpu_seconds
+ 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
+ )
+ master_proc_cpu[process_name] = (
+ master_proc_cpu.get(process_name, 0.0) + cpu_seconds
+ )
+ else:
+ api_fds += fd_count
+ api_procs += 1
+ api_rss += rss_bytes
+ api_pss += pss_bytes
+ api_cpu += cpu_seconds
+ 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
+ )
+ api_proc_cpu[process_name] = (
+ api_proc_cpu.get(process_name, 0.0) + cpu_seconds
+ )
+ 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 (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_master_cpu_seconds_total Cumulative user+system CPU "
+ "time for master processes, in seconds",
+ "# TYPE salt_master_cpu_seconds_total counter",
+ f"salt_master_cpu_seconds_total {master_cpu}",
+ "# 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 (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}",
+ "# HELP salt_api_cpu_seconds_total Cumulative user+system CPU "
+ "time for salt-api processes, in seconds",
+ "# TYPE salt_api_cpu_seconds_total counter",
+ f"salt_api_cpu_seconds_total {api_cpu}",
+ ]
+ lines.extend(
+ _format_series(
+ "salt_master_process_rss_bytes",
+ "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",
+ "Open FDs per salt-master process, labelled by process name",
+ master_proc_fds,
+ )
+ )
+ lines.extend(
+ _format_series(
+ "salt_master_process_cpu_seconds_total",
+ "Cumulative user+system CPU time per salt-master process, "
+ "in seconds, labelled by process name",
+ master_proc_cpu,
+ metric_type="counter",
+ )
+ )
+ lines.extend(
+ _format_series(
+ "salt_api_process_rss_bytes",
+ "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",
+ "Open FDs per salt-api process, labelled by process name",
+ api_proc_fds,
+ )
+ )
+ lines.extend(
+ _format_series(
+ "salt_api_process_cpu_seconds_total",
+ "Cumulative user+system CPU time per salt-api process, "
+ "in seconds, labelled by process name",
+ api_proc_cpu,
+ metric_type="counter",
+ )
+ )
+ 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..."
(
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/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.
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
diff --git a/tests/pytests/unit/netapi/saltnado/__init__.py b/tests/pytests/functional/fileserver/gitfs/__init__.py
similarity index 100%
rename from tests/pytests/unit/netapi/saltnado/__init__.py
rename to tests/pytests/functional/fileserver/gitfs/__init__.py
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..cd80064552f1
--- /dev/null
+++ b/tests/pytests/functional/fileserver/gitfs/test_documented_providers.py
@@ -0,0 +1,221 @@
+"""
+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,
+ "gitfs_proxy": "",
+ "gitfs_depth": 1,
+ "__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/functional/master/test_async_handlers.py b/tests/pytests/functional/master/test_async_handlers.py
new file mode 100644
index 000000000000..476d798b511d
--- /dev/null
+++ b/tests/pytests/functional/master/test_async_handlers.py
@@ -0,0 +1,831 @@
+"""
+Concurrency + integration coverage for the async ``MWorker`` request path.
+
+Background
+----------
+26 ``AESFuncs`` handlers, 5 ``ClearFuncs`` handlers, and
+``AuthFuncs._auth_impl`` were converted from sync-blocking to ``async def``
+on the master worker's event loop. The existing unit tests are heavy on
+mocks and only prove the plumbing calls the mocks — they do NOT prove:
+
+* multiple concurrent AES requests actually run in parallel on a single
+ MWorker,
+* the loop stays responsive while handlers are in flight,
+* the real pillar / returner / fileserver / RSA-verify subsystems still
+ return correct results under the async path.
+
+Approach
+--------
+Spinning full salt daemons (master + minion) for every concurrency
+scenario blows the per-test budget on CI, so we use the documented
+fallback: instantiate a real ``AESFuncs`` against a per-test tmp
+``pki``/``pillar_roots``/``file_roots`` tree, attach it to an
+``MWorker`` skeleton, and drive ``_handle_aes`` with real payloads
+through ``asyncio.gather``. Every dispatch goes through the same
+``run_func`` -> ``_run_func_async`` -> ``_wrap_run_func_return`` path
+the real worker uses.
+"""
+
+# pylint: skip-file
+import asyncio
+import collections
+import logging
+import pathlib
+import threading
+import time
+
+import pytest
+
+import salt.config
+import salt.crypt
+import salt.master
+import salt.utils.files
+
+log = logging.getLogger(__name__)
+
+
+# ---------------------------------------------------------------------------
+# Shared helpers
+# ---------------------------------------------------------------------------
+
+
+def _make_worker(aes_funcs):
+ """MWorker skeleton wired to a real ``AESFuncs``.
+
+ Bypasses ``MWorker.__init__`` (which forks a process) and only sets
+ the attributes ``_handle_aes`` reads. The worker's ``opts`` mirrors
+ ``aes_funcs.opts`` so the request_context set on the ioloop carries
+ the master's log format defaults — otherwise handlers that log via
+ ``salt._logging.impl`` raise ``KeyError('log_fmt_minion_id')`` under
+ the async dispatch path.
+ """
+ worker = salt.master.MWorker.__new__(salt.master.MWorker)
+ # Take a shallow copy and force master_stats off so we skip the
+ # ``_post_stats`` codepath which depends on ``self.aes_funcs.event``.
+ worker.opts = dict(aes_funcs.opts)
+ worker.opts["master_stats"] = False
+ worker.aes_funcs = aes_funcs
+ worker.stats = collections.defaultdict(lambda: {"mean": 0, "runs": 0})
+ return worker
+
+
+def _base_opts(tmp_path):
+ """Full ``AESFuncs`` opts backed by ``tmp_path``.
+
+ Uses ``salt.config.master_config(None)`` so every default (of which
+ the fileserver/pillar backends read many) is populated; only the
+ per-test paths and a few knobs are overridden.
+ """
+ pki_dir = tmp_path / "pki"
+ pki_dir.mkdir()
+ (pki_dir / "minions").mkdir()
+ (pki_dir / "minions_pre").mkdir()
+ (pki_dir / "minions_rejected").mkdir()
+ (pki_dir / "minions_denied").mkdir()
+ (tmp_path / "cache").mkdir()
+ (tmp_path / "sock_drawer").mkdir()
+ opts = salt.config.master_config(None)
+ opts["__role"] = "master"
+ opts["pki_dir"] = str(pki_dir)
+ opts["cachedir"] = str(tmp_path / "cache")
+ opts["sock_dir"] = str(tmp_path / "sock_drawer")
+ opts["conf_file"] = str(tmp_path / "config.conf")
+ opts["fileserver_backend"] = ["roots"]
+ opts["master_job_cache"] = "local_cache"
+ opts["job_cache"] = True
+ opts["ext_job_cache"] = ""
+ opts["keys.cache_driver"] = "localfs_key"
+ opts["optimization_order"] = [0, 1, 2]
+ opts["master_sign_key_name"] = "master_sign"
+ opts["id"] = "master"
+ opts["pillar_version"] = 2
+ opts["minion_data_cache"] = False
+ opts["minion_data_cache_events"] = False
+ opts["require_minion_sign_messages"] = False
+ opts["drop_messages_signature_fail"] = False
+ opts["signing_algorithm"] = salt.crypt.PKCS1v15_SHA224
+ opts["encryption_algorithm"] = salt.crypt.OAEP_SHA224
+ # These tests exercise the async MWorker handlers; opt in explicitly.
+ # The LTS default (``master_async_mworker: False``) shadows every
+ # async handler with a sync body, which breaks tests written against
+ # the async signatures.
+ opts["master_async_mworker"] = True
+ return opts
+
+
+@pytest.fixture
+def minion_keypair(tmp_path):
+ """Generate a real minion RSA keypair; register the pub with the master."""
+ priv_pem, pub_pem = salt.crypt.gen_keys(2048)
+ minion_priv = tmp_path / "minion.pem"
+ minion_pub = tmp_path / "minion.pub"
+ with salt.utils.files.fopen(minion_priv, "wb") as fh:
+ fh.write(priv_pem if isinstance(priv_pem, bytes) else priv_pem.encode())
+ with salt.utils.files.fopen(minion_pub, "wb") as fh:
+ fh.write(pub_pem if isinstance(pub_pem, bytes) else pub_pem.encode())
+ return {"priv_pem": priv_pem, "pub_pem": pub_pem, "priv_path": str(minion_priv)}
+
+
+# ---------------------------------------------------------------------------
+# 1. Concurrency: N requests through _handle_aes must run in parallel
+# ---------------------------------------------------------------------------
+
+
+async def test_concurrent_file_list_dispatches_in_parallel(tmp_path):
+ """N ``_file_list`` dispatches with a measurable per-call latency
+ finish in wall-time far less than ``N * per_call_latency``.
+
+ ``_file_list`` offloads the sync ``Fileserver.file_list`` call to the
+ default executor. To make the concurrency win observable at test
+ scale we monkey-patch the fileserver's ``file_list`` to sleep briefly
+ per call — real disk work at test scale is orders of magnitude
+ faster than asyncio scheduling jitter, so it can't distinguish
+ parallel from serial execution. The sleep exercises the same async
+ dispatch path (``_handle_aes`` -> ``run_func`` ->
+ ``_run_func_async`` -> ``run_in_executor``) as production.
+ """
+ file_roots = tmp_path / "srv" / "salt"
+ file_roots.mkdir(parents=True)
+
+ opts = _base_opts(tmp_path)
+ opts["file_roots"] = {"base": [str(file_roots)]}
+ aes_funcs = salt.master.AESFuncs(opts)
+ try:
+ worker = _make_worker(aes_funcs)
+
+ per_call_sleep = 0.2
+
+ # Return per-call results tagged with the load's saltenv so the
+ # test can prove no cross-contamination.
+ def _sleepy_file_list(load):
+ time.sleep(per_call_sleep)
+ return [f"{load['saltenv']}-hello.sls"]
+
+ aes_funcs.fs_.file_list = _sleepy_file_list
+
+ n = 20
+ payloads = [{"cmd": "_file_list", "saltenv": f"env-{i:02d}"} for i in range(n)]
+ t0 = time.perf_counter()
+ results = await asyncio.gather(*(worker._handle_aes(p) for p in payloads))
+ elapsed = time.perf_counter() - t0
+ serial_floor = n * per_call_sleep
+ log.info(
+ "Concurrent %d _file_list wall-time: %.2fs vs serial floor %.2fs",
+ n,
+ elapsed,
+ serial_floor,
+ )
+
+ # Every response returned the list for its own saltenv (no cross-
+ # contamination via the shared executor).
+ assert len(results) == n
+ for i, (ret, envelope) in enumerate(results):
+ assert envelope == {"fun": "send"}
+ assert ret == [f"env-{i:02d}-hello.sls"], (
+ f"payload {i} got wrong result {ret!r} — concurrent "
+ "handlers appear to have crossed streams."
+ )
+
+ # Concurrency proof: with N=20 sleepy calls and the default
+ # ThreadPoolExecutor (min ~8 workers on py 3.10), wall-time must
+ # be well under half of the serialized floor. Being conservative
+ # (0.5) to absorb GHA CI jitter.
+ assert elapsed < serial_floor * 0.5, (
+ f"Concurrent dispatch took {elapsed:.2f}s but serial floor is "
+ f"{serial_floor:.2f}s — handlers appear to be running "
+ "sequentially, not in parallel via the executor."
+ )
+ finally:
+ aes_funcs.destroy()
+
+
+async def test_fast_handler_stays_responsive_under_load(tmp_path):
+ """A fast handler queued behind N slow blocking handlers must complete
+ promptly — the executor keeps the ioloop unblocked.
+
+ We monkey-patch the fileserver call to sleep, saturate the executor
+ with concurrent slow calls, then dispatch one fast handler
+ (``_master_opts``) and assert its wall-time stays well below the slow
+ call latency. If the migration accidentally serialized handlers on
+ the ioloop this fast call would queue behind every slow one.
+ """
+ file_roots = tmp_path / "srv" / "salt"
+ file_roots.mkdir(parents=True)
+ opts = _base_opts(tmp_path)
+ opts["file_roots"] = {"base": [str(file_roots)]}
+ aes_funcs = salt.master.AESFuncs(opts)
+ try:
+ worker = _make_worker(aes_funcs)
+
+ slow_sleep = 0.5
+
+ def _slow_file_list(load):
+ time.sleep(slow_sleep)
+ return ["slow-result"]
+
+ # Replace only the sync body; the async wrapper still offloads via
+ # ``run_in_executor`` so this is a realistic test of loop
+ # responsiveness under handler load.
+ aes_funcs.fs_.file_list = _slow_file_list
+
+ # Fire 8 slow calls concurrently; they saturate the default
+ # executor's worker pool (min 8 for asyncio's default).
+ slow_tasks = [
+ asyncio.create_task(
+ worker._handle_aes({"cmd": "_file_list", "saltenv": "base"})
+ )
+ for _ in range(8)
+ ]
+
+ # Give the slow tasks a moment to actually enter the executor.
+ await asyncio.sleep(0.05)
+
+ # Dispatch a fast handler (`_master_opts` -> `_file_envs` offload,
+ # which is also async but returns almost immediately with an empty
+ # roots tree). Measure only its response time.
+ t0 = time.perf_counter()
+ ret, envelope = await worker._handle_aes(
+ {"cmd": "_master_opts", "id": "quick-minion", "env_only": True}
+ )
+ fast_elapsed = time.perf_counter() - t0
+ log.info(
+ "Fast _master_opts under load: %.3fs (slow_sleep=%.2fs)",
+ fast_elapsed,
+ slow_sleep,
+ )
+
+ # Loop stayed responsive: fast handler completed well before the
+ # slow calls' sleep.
+ assert fast_elapsed < slow_sleep, (
+ f"Fast handler took {fast_elapsed:.3f}s — longer than a single "
+ f"slow call ({slow_sleep}s). The ioloop appears to be blocked "
+ "by concurrent slow handlers."
+ )
+ assert envelope == {"fun": "send"}
+ assert isinstance(ret, dict)
+
+ # Let the slow tasks finish so the test tears down cleanly.
+ await asyncio.gather(*slow_tasks)
+ finally:
+ aes_funcs.destroy()
+
+
+# ---------------------------------------------------------------------------
+# 2. Real subsystems exercised concurrently
+# ---------------------------------------------------------------------------
+
+
+async def test_concurrent_pillar_renders_return_correct_data_per_minion(tmp_path):
+ """N ``_pillar`` requests each with a distinct minion id must each get
+ back the pillar tree that matches their id — the async rewrite must
+ not cross-contaminate results across concurrent awaits.
+
+ Uses a real ``AsyncPillar`` render (no mocks): a jinja pillar top +
+ per-minion sls that emits ``{'minion_id': }``. If two concurrent
+ renders swap loads, the assertion fires.
+ """
+ pillar_roots = tmp_path / "pillar"
+ pillar_roots.mkdir()
+ # Top file matches every minion; per-minion pillar is a jinja file that
+ # reads grains['id'] and echoes it into the pillar tree.
+ (pillar_roots / "top.sls").write_text("base:\n" " '*':\n" " - identity\n")
+ (pillar_roots / "identity.sls").write_text(
+ "minion_id: {{ grains['id'] }}\n" "static_key: static_value\n"
+ )
+
+ opts = _base_opts(tmp_path)
+ opts["pillar_roots"] = {"base": [str(pillar_roots)]}
+ opts["file_roots"] = {"base": [str(tmp_path / "empty_roots")]}
+ (tmp_path / "empty_roots").mkdir()
+ opts["file_client"] = "local"
+ opts["state_top"] = "top.sls"
+ opts["state_top_saltenv"] = None
+ opts["nodegroups"] = {}
+ opts["renderer"] = "jinja|yaml"
+ opts["renderer_blacklist"] = []
+ opts["renderer_whitelist"] = []
+ opts["ext_pillar"] = []
+ opts["on_demand_ext_pillar"] = []
+ opts["pillar_cache"] = False
+ opts["pillar_source_merging_strategy"] = "smart"
+ opts["pillar_merge_lists"] = False
+ opts["pillarenv"] = None
+ opts["pillarenv_from_saltenv"] = False
+ opts["pillar_raise_on_missing"] = False
+ opts["decrypt_pillar"] = []
+ opts["decrypt_pillar_default"] = "gpg"
+ opts["decrypt_pillar_delimiter"] = ":"
+ opts["decrypt_pillar_renderers"] = ["gpg"]
+ opts["saltenv"] = None
+ opts["default_top"] = "base"
+ opts["top_file_merging_strategy"] = "merge"
+ opts["env_order"] = []
+
+ aes_funcs = salt.master.AESFuncs(opts)
+ try:
+ worker = _make_worker(aes_funcs)
+
+ # 6 distinct minion ids; run all concurrently through _handle_aes.
+ minion_ids = [f"minion-{i:02d}" for i in range(6)]
+ payloads = [
+ {
+ "cmd": "_pillar",
+ "id": mid,
+ "grains": {"os": "Debian", "id": mid},
+ "saltenv": "base",
+ "ver": "2",
+ }
+ for mid in minion_ids
+ ]
+ results = await asyncio.gather(*(worker._handle_aes(p) for p in payloads))
+
+ for mid, (data, envelope) in zip(minion_ids, results):
+ assert envelope == {
+ "fun": "send_private",
+ "key": "pillar",
+ "tgt": mid,
+ }, mid
+ # The pillar render for THIS minion must produce THIS minion's
+ # id — otherwise concurrent renders crossed streams.
+ assert (
+ data.get("minion_id") == mid
+ ), f"Pillar for {mid} returned wrong minion_id: {data!r}"
+ assert data.get("static_key") == "static_value", mid
+ finally:
+ aes_funcs.destroy()
+
+
+async def test_concurrent_return_writes_all_jobs_to_local_cache(tmp_path):
+ """N distinct ``_return`` payloads (jid+id unique per request) all end
+ up on disk via the real ``local_cache`` returner.
+
+ ``_return`` offloads ``salt.utils.job.store_job`` to the executor;
+ this test proves the offloaded writes don't clobber each other under
+ concurrency and every payload's jid is persisted.
+ """
+ opts = _base_opts(tmp_path)
+ (tmp_path / "empty_roots").mkdir()
+ opts["file_roots"] = {"base": [str(tmp_path / "empty_roots")]}
+ opts["master_job_cache"] = "local_cache"
+ opts["job_cache"] = True
+ opts["keep_jobs_seconds"] = 3600
+
+ aes_funcs = salt.master.AESFuncs(opts)
+ try:
+ worker = _make_worker(aes_funcs)
+
+ n = 20
+ base_jid = int(time.time() * 1000000)
+ jids = [str(base_jid + i) for i in range(n)]
+ payloads = [
+ {
+ "cmd": "_return",
+ "id": f"minion-{i:02d}",
+ "jid": jid,
+ "fun": "test.ping",
+ "fun_args": [],
+ "return": True,
+ "retcode": 0,
+ "success": True,
+ "out": "nested",
+ }
+ for i, jid in enumerate(jids)
+ ]
+ # ``_return``'s envelope is ``(None, {"fun": "send"})`` — we don't
+ # check payload equality, only that every dispatch completes.
+ results = await asyncio.gather(*(worker._handle_aes(p) for p in payloads))
+ for _, envelope in results:
+ assert envelope == {"fun": "send"}
+
+ # Real returner disk verification: local_cache stores returns
+ # under ``cachedir/jobs////return.p``.
+ # Confirm every dispatched (jid, minion_id) pair is recoverable
+ # via ``local_cache.get_jid`` — proves the concurrent async
+ # offload of ``store_job`` didn't drop any writes.
+ import salt.returners.local_cache as local_cache
+
+ # ``local_cache`` is a returner: loader normally injects
+ # ``__opts__`` into the module namespace. Bypass the loader by
+ # setting it directly so we can call the module functions from a
+ # test.
+ local_cache.__opts__ = opts
+
+ for i, jid in enumerate(jids):
+ entry = local_cache.get_jid(jid)
+ assert entry, (
+ f"jid {jid} (minion-{i:02d}) missing from local_cache — "
+ "async offload of store_job dropped a concurrent write."
+ )
+ key = f"minion-{i:02d}"
+ assert (
+ key in entry
+ ), f"jid {jid} present but missing minion key {key!r}: {entry!r}"
+ assert entry[key]["return"] is True
+ assert entry[key]["success"] is True
+ finally:
+ aes_funcs.destroy()
+
+
+async def test_verify_minion_concurrent_real_rsa(tmp_path, minion_keypair):
+ """Real RSA verify_minion under concurrent load.
+
+ Register the minion's pub key with the master, then run 30 concurrent
+ ``verify_minion`` calls through ``_handle_aes`` — each call must
+ return ``True`` (correct signature) and no calls must cross-verify
+ against a different minion's token.
+ """
+ opts = _base_opts(tmp_path)
+ (tmp_path / "empty_roots").mkdir()
+ opts["file_roots"] = {"base": [str(tmp_path / "empty_roots")]}
+
+ # Register two minion keys and cross-check that each verifies only
+ # against its own token (catches cross-contamination under concurrent
+ # RSA offload).
+ priv2, pub2 = salt.crypt.gen_keys(2048)
+ minion_a_id = "minion-alpha"
+ minion_b_id = "minion-bravo"
+
+ aes_funcs = salt.master.AESFuncs(opts)
+ try:
+ # Store both pub keys in the master's PKI ``accepted`` bucket.
+ # ``localfs_key.store`` demands ``data={'pub': pem, 'state':
+ # 'accepted'}`` and routes to ``/minions/``.
+ aes_funcs.key_cache.store(
+ "keys",
+ minion_a_id,
+ {"pub": minion_keypair["pub_pem"], "state": "accepted"},
+ )
+ aes_funcs.key_cache.store(
+ "keys",
+ minion_b_id,
+ {
+ "pub": pub2 if isinstance(pub2, str) else pub2.decode(),
+ "state": "accepted",
+ },
+ )
+
+ # Build the signed tokens each minion would send in an AES
+ # authenticated request. ``verify_minion`` expects the token to
+ # decrypt to b"salt".
+ priv_a = salt.crypt.PrivateKey.from_str(minion_keypair["priv_pem"])
+ priv_b = salt.crypt.PrivateKey.from_str(priv2)
+ token_a = priv_a.encrypt(b"salt")
+ token_b = priv_b.encrypt(b"salt")
+
+ worker = _make_worker(aes_funcs)
+
+ # Fire 30 concurrent verify_minion calls, alternating between the
+ # two minions. Each should return True.
+ n = 30
+ payloads = []
+ expected_ids = []
+ for i in range(n):
+ if i % 2 == 0:
+ payloads.append((minion_a_id, token_a))
+ expected_ids.append(minion_a_id)
+ else:
+ payloads.append((minion_b_id, token_b))
+ expected_ids.append(minion_b_id)
+
+ # ``verify_minion`` is called by ``_handle_aes``? No — it's exposed
+ # on ``AESFuncs`` but has a 2-arg signature and is invoked directly
+ # by the channel server, not via a load-dict cmd dispatch. Test it
+ # by awaiting through ``AESFuncs`` (the async offload path is the
+ # code we care about) and confirm concurrency doesn't corrupt
+ # results.
+ t0 = time.perf_counter()
+ results = await asyncio.gather(
+ *(aes_funcs.verify_minion(mid, tok) for mid, tok in payloads)
+ )
+ elapsed = time.perf_counter() - t0
+ log.info(
+ "%d concurrent verify_minion (real RSA) wall-time: %.3fs",
+ n,
+ elapsed,
+ )
+
+ # All must verify True — no cross-contamination.
+ assert all(
+ r is True for r in results
+ ), f"Some verify_minion calls returned False: {results}"
+
+ # Bonus: verify the negative path is honoured under concurrency —
+ # A signed with A's key but claiming to be B must fail.
+ cross_result = await aes_funcs.verify_minion(minion_b_id, token_a)
+ assert cross_result is False, (
+ "Cross-key verify_minion returned True; concurrent RSA offload "
+ "may have leaked key state across calls."
+ )
+ finally:
+ aes_funcs.destroy()
+
+
+# ---------------------------------------------------------------------------
+# 3. Correctness under concurrency: heterogeneous handlers
+# ---------------------------------------------------------------------------
+
+
+async def test_mixed_handler_workload_returns_correct_envelopes(tmp_path):
+ """Concurrently dispatch a mix of ``_file_list``, ``_master_opts``, and
+ ``_return`` and confirm every response's envelope matches its cmd.
+
+ Regression guard for the ``_wrap_run_func_return`` post-processing
+ that lives inside the async dispatch path — a bug there could send
+ ``_return``'s ``{'fun': 'send'}`` envelope to another handler.
+ """
+ file_roots = tmp_path / "srv" / "salt"
+ file_roots.mkdir(parents=True)
+ (file_roots / "hello.sls").write_text("# hello\n")
+
+ opts = _base_opts(tmp_path)
+ opts["file_roots"] = {"base": [str(file_roots)]}
+ opts["master_job_cache"] = "local_cache"
+ opts["job_cache"] = True
+ # ``roots.py`` lazily creates ``/file_lists/roots`` on the
+ # first call; concurrent callers race the ``os.makedirs`` and the
+ # losers log CRITICAL + return []. Pre-create it so the test measures
+ # dispatch correctness, not that filesystem race.
+ (pathlib.Path(opts["cachedir"]) / "file_lists" / "roots").mkdir(
+ parents=True, exist_ok=True
+ )
+
+ aes_funcs = salt.master.AESFuncs(opts)
+ try:
+ worker = _make_worker(aes_funcs)
+
+ payloads = []
+ base_jid = int(time.time() * 1000000)
+ for i in range(6):
+ payloads.append(("_file_list", {"cmd": "_file_list", "saltenv": "base"}))
+ payloads.append(
+ (
+ "_master_opts",
+ {"cmd": "_master_opts", "id": "minion-x", "env_only": True},
+ )
+ )
+ payloads.append(
+ (
+ "_return",
+ {
+ "cmd": "_return",
+ "id": f"minion-{i:02d}",
+ "jid": str(base_jid + i),
+ "fun": "test.ping",
+ "return": True,
+ "retcode": 0,
+ "success": True,
+ },
+ )
+ )
+
+ results = await asyncio.gather(
+ *(worker._handle_aes(load) for _, load in payloads)
+ )
+
+ expected_envelope = {"fun": "send"}
+ for (cmd, _), (ret, envelope) in zip(payloads, results):
+ assert (
+ envelope == expected_envelope
+ ), f"cmd {cmd} got wrong envelope {envelope!r}"
+ if cmd == "_file_list":
+ assert isinstance(ret, list) and "hello.sls" in ret
+ elif cmd == "_master_opts":
+ assert isinstance(ret, dict) and "file_roots" in ret
+ # _return returns None -> envelope only, no ret assertion.
+ finally:
+ aes_funcs.destroy()
+
+
+# ---------------------------------------------------------------------------
+# 4. master_mworker_max_inflight cap — end-to-end through _handle_payload
+# ---------------------------------------------------------------------------
+
+
+def _inflight_worker(aes_funcs):
+ """MWorker skeleton wired for ``_handle_payload`` (not ``_handle_aes``).
+
+ ``_handle_payload`` needs both ``_modules_loaded`` and
+ ``aes_funcs`` / ``clear_funcs`` attributes, plus a ``req_channels``
+ stub for ``_handle_signals``. Bypass ``__init__`` because that
+ forks and we only want the coroutine's dispatch path.
+ """
+ worker = salt.master.MWorker.__new__(salt.master.MWorker)
+ worker.opts = dict(aes_funcs.opts)
+ worker.opts["master_stats"] = False
+ worker.aes_funcs = aes_funcs
+ # Minimal ClearFuncs stub; the cap tests only fire AES payloads so
+ # ``_handle_clear`` is never entered. Keep attribute presence so
+ # the guard in ``_handle_payload_inner`` doesn't short-circuit.
+ worker.clear_funcs = object()
+ worker.stats = collections.defaultdict(lambda: {"mean": 0, "runs": 0})
+ worker._modules_loaded = threading.Event()
+ worker._modules_loaded.set()
+ return worker
+
+
+async def test_max_inflight_cap_bounds_concurrent_returns(tmp_path):
+ """
+ With ``master_mworker_max_inflight = 3`` and 12 concurrent
+ ``_return`` dispatches — each patched to sleep 0.2 s — the number of
+ handlers executing at any instant MUST NEVER exceed 3, and the
+ total wall time MUST be at least ceil(12/3) * 0.2 = 0.8 s.
+ """
+ file_roots = tmp_path / "srv" / "salt"
+ file_roots.mkdir(parents=True)
+ opts = _base_opts(tmp_path)
+ opts["file_roots"] = {"base": [str(file_roots)]}
+ opts["master_mworker_max_inflight"] = 3
+ aes_funcs = salt.master.AESFuncs(opts)
+ try:
+ worker = _inflight_worker(aes_funcs)
+ # Reset the module-level counters so a prior test's residuals
+ # don't leak in.
+ salt.master._MW_INFLIGHT["waiters"] = 0
+ base_wait_ms = salt.master._MW_INFLIGHT["wait_ms_total"]
+
+ per_call_sleep = 0.2
+
+ active = 0
+ max_active = 0
+ active_lock = asyncio.Lock()
+
+ async def _fake_return(load):
+ nonlocal active, max_active
+ async with active_lock:
+ active += 1
+ if active > max_active:
+ max_active = active
+ try:
+ await asyncio.sleep(per_call_sleep)
+ return None
+ finally:
+ async with active_lock:
+ active -= 1
+
+ aes_funcs._return = _fake_return # type: ignore[assignment]
+
+ n = 12
+ cap = 3
+ payloads = [
+ {
+ "enc": "aes",
+ "load": {
+ "cmd": "_return",
+ "id": f"minion-{i:02d}",
+ "jid": str(20260825000000 + i),
+ "return": True,
+ },
+ }
+ for i in range(n)
+ ]
+
+ t0 = time.perf_counter()
+ results = await asyncio.gather(*(worker._handle_payload(p) for p in payloads))
+ elapsed = time.perf_counter() - t0
+
+ assert len(results) == n
+ assert max_active <= cap, (
+ f"cap violated: observed {max_active} concurrent handlers, "
+ f"expected at most {cap}"
+ )
+ # ceil(n/cap) waves of per_call_sleep, minus one scheduling
+ # tick. Being conservative (0.75x) to absorb CI jitter.
+ expected_floor = (n // cap + (0 if n % cap == 0 else 1)) * per_call_sleep
+ assert elapsed >= expected_floor * 0.75, (
+ f"wall time {elapsed:.2f}s is below the {expected_floor:.2f}s "
+ f"floor — cap does not appear to be gating concurrency"
+ )
+
+ # The waiter gauge drained back to zero and the accumulator
+ # counted some wait time (some tasks queued behind the cap).
+ assert salt.master._MW_INFLIGHT["waiters"] == 0
+ assert salt.master._MW_INFLIGHT["wait_ms_total"] >= base_wait_ms
+ # At least one request had to wait — with n=12, cap=3, sleep
+ # 0.2s the tail requests wait ~0.6s cumulatively across the
+ # pool. Assert a very loose lower bound to avoid CI flakes.
+ assert (
+ salt.master._MW_INFLIGHT["wait_ms_total"] - base_wait_ms
+ ) >= 100, "wait_ms_total did not accumulate — cap not exercised"
+ finally:
+ aes_funcs.destroy()
+
+
+async def test_max_inflight_cap_zero_allows_full_concurrency(tmp_path):
+ """
+ Regression: ``master_mworker_max_inflight = 0`` MUST leave the
+ dispatch path unthrottled — 8 concurrent ``_return`` handlers all
+ run in parallel and wall time approaches the single-call floor
+ (0.2 s), not the serialized 1.6 s floor.
+ """
+ file_roots = tmp_path / "srv" / "salt"
+ file_roots.mkdir(parents=True)
+ opts = _base_opts(tmp_path)
+ opts["file_roots"] = {"base": [str(file_roots)]}
+ opts["master_mworker_max_inflight"] = 0
+ aes_funcs = salt.master.AESFuncs(opts)
+ try:
+ worker = _inflight_worker(aes_funcs)
+
+ per_call_sleep = 0.2
+ active = 0
+ max_active = 0
+ active_lock = asyncio.Lock()
+
+ async def _fake_return(load):
+ nonlocal active, max_active
+ async with active_lock:
+ active += 1
+ if active > max_active:
+ max_active = active
+ try:
+ await asyncio.sleep(per_call_sleep)
+ return None
+ finally:
+ async with active_lock:
+ active -= 1
+
+ aes_funcs._return = _fake_return # type: ignore[assignment]
+
+ n = 8
+ payloads = [
+ {
+ "enc": "aes",
+ "load": {
+ "cmd": "_return",
+ "id": f"minion-{i:02d}",
+ "jid": str(20260825100000 + i),
+ "return": True,
+ },
+ }
+ for i in range(n)
+ ]
+ t0 = time.perf_counter()
+ await asyncio.gather(*(worker._handle_payload(p) for p in payloads))
+ elapsed = time.perf_counter() - t0
+
+ assert max_active == n, (
+ f"cap-zero (unlimited) broke: only {max_active} of {n} "
+ "handlers ran concurrently"
+ )
+ # Wall time is dominated by a single per_call_sleep + scheduling
+ # overhead. Be generous (2x) to absorb CI jitter.
+ assert elapsed < per_call_sleep * 2, (
+ f"cap-zero wall time {elapsed:.2f}s exceeded {per_call_sleep * 2:.2f}s "
+ "— dispatches appear to be serialized despite cap=0"
+ )
+ # Semaphore MUST NOT have been built.
+ assert worker._inflight_sem is None
+ finally:
+ aes_funcs.destroy()
+
+
+async def test_max_inflight_cap_flag_off_is_noop(tmp_path):
+ """
+ With ``master_async_mworker = False`` the cap MUST be a no-op even
+ when set to a positive integer — sync dispatch naturally tops out
+ at 1 in flight, so a semaphore would just add overhead / mask the
+ LTS fast path. The semaphore MUST NOT be built.
+ """
+ file_roots = tmp_path / "srv" / "salt"
+ file_roots.mkdir(parents=True)
+ opts = _base_opts(tmp_path)
+ opts["file_roots"] = {"base": [str(file_roots)]}
+ # Force the "async off, cap set" combination that a nervous
+ # operator might reach for.
+ opts["master_async_mworker"] = False
+ opts["master_mworker_max_inflight"] = 2
+
+ # The base opts fixture flipped ``master_async_mworker`` back on
+ # for the AESFuncs constructor to produce the async handler
+ # signatures the rest of this file relies on. Instantiate
+ # AESFuncs with the async flag still set, then rebuild the worker
+ # with the async flag off so we're testing the correct path.
+ opts_for_funcs = dict(opts)
+ opts_for_funcs["master_async_mworker"] = True
+ aes_funcs = salt.master.AESFuncs(opts_for_funcs)
+ try:
+ worker = _inflight_worker(aes_funcs)
+ worker.opts["master_async_mworker"] = False
+ worker.opts["master_mworker_max_inflight"] = 2
+
+ async def _fake_return(load):
+ await asyncio.sleep(0.01)
+ return None
+
+ aes_funcs._return = _fake_return # type: ignore[assignment]
+
+ await worker._handle_payload(
+ {
+ "enc": "aes",
+ "load": {
+ "cmd": "_return",
+ "id": "minion",
+ "jid": "1",
+ "return": True,
+ },
+ }
+ )
+ assert worker._inflight_sem is None
+ assert worker._inflight_sem_ready is True
+ finally:
+ aes_funcs.destroy()
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..b7efd3df17af
--- /dev/null
+++ b/tests/pytests/functional/modules/state/requisites/test_documented_truth_table.py
@@ -0,0 +1,352 @@
+"""
+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
+
+
+# --- 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/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/functional/modules/test_virtualenv_mod.py b/tests/pytests/functional/modules/test_virtualenv_mod.py
index 2b6abf91e235..f193b6d898a7 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
@@ -7,9 +9,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):
@@ -21,6 +49,7 @@ def virtualenv(modules):
return modules.virtualenv
+@requires_virtualenv
def test_create_defaults(virtualenv, venv_dir):
"""
virtualenv.managed
@@ -33,6 +62,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
@@ -49,6 +79,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
@@ -64,6 +95,7 @@ def test_clear(virtualenv, venv_dir, modules):
assert "pep8" not in packages
+@requires_virtualenv
@pytest.mark.skipif(
bool(salt.utils.path.which("transactional-update")),
reason="Skipping on transactional systems",
@@ -76,3 +108,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/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")
diff --git a/tests/pytests/functional/states/chocolatey/test_pre_20.py b/tests/pytests/functional/states/chocolatey/test_pre_20.py
index cfefe13f139c..716b63acd79a 100644
--- a/tests/pytests/functional/states/chocolatey/test_pre_20.py
+++ b/tests/pytests/functional/states/chocolatey/test_pre_20.py
@@ -2,13 +2,18 @@
Functional tests for chocolatey state with Chocolatey < 2.0
"""
+import logging
import os
import pathlib
+import time
import pytest
import salt.utils.path
import salt.utils.win_reg
+from salt.exceptions import MinionError
+
+log = logging.getLogger(__name__)
pytestmark = [
pytest.mark.windows_whitelisted,
@@ -17,6 +22,23 @@
pytest.mark.destructive_test,
]
+# HTTP status codes and error substrings that indicate a transient failure of
+# the Chocolatey Community Repository (proxy/CDN blips, rate limits, TCP
+# resets). Matched against the ``MinionError`` message text raised by
+# ``cp.get_url`` — that is the only signal available to the fixture.
+_TRANSIENT_HTTP_MARKERS = (
+ "HTTP 502",
+ "HTTP 503",
+ "HTTP 504",
+ "HTTP 429",
+ "Connection reset",
+ "Connection aborted",
+ "Connection refused",
+ "Read timed out",
+ "timed out",
+ "Temporary failure",
+)
+
@pytest.fixture(scope="module")
def chocolatey(states):
@@ -37,11 +59,46 @@ def chocolatey_mod(modules):
choco_dir = choco_pkg.parent / "choco_dir"
choco_script = choco_dir / "tools" / "chocolateyInstall.ps1"
+ def _download_installer(attempts=5, base_delay=2, max_delay=30):
+ # The Chocolatey Community Repository (community.chocolatey.org)
+ # intermittently returns HTTP 5xx/429 from its CDN, which breaks
+ # nightly CI runs whose only crime is timing. Retry with exponential
+ # backoff on those transient errors before giving up. See failing
+ # nightly runs for context:
+ # https://github.com/saltstack/salt-nightlies/actions/runs/32316673545
+ # https://github.com/saltstack/salt-nightlies/actions/runs/32200698572
+ last_error = None
+ for attempt in range(1, attempts + 1):
+ try:
+ modules.cp.get_url(path=url, dest=str(choco_pkg))
+ return
+ except MinionError as exc:
+ message = str(exc)
+ if not any(marker in message for marker in _TRANSIENT_HTTP_MARKERS):
+ raise
+ last_error = exc
+ if attempt == attempts:
+ break
+ delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
+ log.warning(
+ "Transient error fetching chocolatey installer (attempt "
+ "%d/%d): %s; retrying in %ds",
+ attempt,
+ attempts,
+ message,
+ delay,
+ )
+ time.sleep(delay)
+ pytest.skip(
+ "Chocolatey Community Repository unavailable after "
+ f"{attempts} attempts: {last_error}"
+ )
+
def install():
# Install Chocolatey 1.2.1
# Download Package
- modules.cp.get_url(path=url, dest=str(choco_pkg))
+ _download_installer()
# Unzip Package
modules.archive.unzip(
diff --git a/tests/pytests/functional/states/test_slots_documented.py b/tests/pytests/functional/states/test_slots_documented.py
new file mode 100644
index 000000000000..971c4be4908c
--- /dev/null
+++ b/tests/pytests/functional/states/test_slots_documented.py
@@ -0,0 +1,70 @@
+"""
+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"
+ # 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_arg})
+ - 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"
+ # 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_arg}) ~ "/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"
diff --git a/tests/pytests/functional/transport/tcp/test_pub_server.py b/tests/pytests/functional/transport/tcp/test_pub_server.py
index 5abf821d6e19..1e95c292543e 100644
--- a/tests/pytests/functional/transport/tcp/test_pub_server.py
+++ b/tests/pytests/functional/transport/tcp/test_pub_server.py
@@ -1,10 +1,138 @@
import asyncio
+import logging
import os
+import socket
import time
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(
+ 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):
@@ -63,3 +191,285 @@ 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"
+ )
+
+
+class _FakeStream:
+ """Minimal ``IOStream`` stand-in for ``PubServer.publish_payload``.
+
+ ``mode='ok'`` records writes and returns a resolved Future.
+ ``mode='full'`` raises ``StreamBufferFullError`` synchronously from
+ ``write``, mirroring the tornado behavior when a stream's
+ ``max_write_buffer_size`` cap is exceeded.
+ ``mode='closed'`` raises ``StreamClosedError`` synchronously.
+ """
+
+ def __init__(self, mode="ok"):
+ self.mode = mode
+ self.writes = []
+ self._closed = False
+
+ def write(self, payload):
+ if self.mode == "full":
+ raise tornado.iostream.StreamBufferFullError(
+ "Reached maximum write buffer size"
+ )
+ if self.mode == "closed":
+ raise tornado.iostream.StreamClosedError()
+ self.writes.append(payload)
+ fut = asyncio.get_event_loop().create_future()
+ fut.set_result(None)
+ return fut
+
+ def close(self):
+ self._closed = True
+
+
+def _minimal_pub_opts(tmp_path):
+ """Minimal opts dict that satisfies ``PubServer.__init__``/``publish_payload``
+ without pulling in the full ``master_opts`` fixture (which requires
+ saltfactories + pytest-system-statistics)."""
+ return {
+ "id": "regression-master",
+ "sock_dir": str(tmp_path),
+ "publish_drain_timeout": 5.0,
+ }
+
+
+async def test_publish_payload_buffer_full_does_not_abort_broadcast(io_loop, tmp_path):
+ """
+ Regression: ``PubServer.publish_payload`` must not let a synchronous
+ ``StreamBufferFullError`` from one subscriber abort the broadcast to
+ the rest.
+
+ Before the fix only ``StreamClosedError`` was caught, so when a
+ subscriber's tornado write buffer overflowed (which happens once
+ ``ipc_write_buffer`` is set and a peer stops draining), the
+ exception propagated out of the loop and every subscriber after the
+ offender silently missed the payload.
+ """
+ server = salt.transport.tcp.PubServer(
+ _minimal_pub_opts(tmp_path),
+ io_loop=io_loop,
+ presence_callback=None,
+ remove_presence_callback=lambda client: None,
+ )
+
+ class _FakeClient:
+ def __init__(self, mode):
+ self.stream = _FakeStream(mode=mode)
+ self.address = f"fake-{mode}"
+ self.id_ = None
+ self._closed = False
+
+ def close(self):
+ self._closed = True
+
+ fast_a = _FakeClient("ok")
+ slow_full = _FakeClient("full")
+ fast_b = _FakeClient("ok")
+
+ server.clients.add(fast_a)
+ server.clients.add(slow_full)
+ server.clients.add(fast_b)
+
+ await server.publish_payload({"marker": "regression-broadcast"})
+
+ try:
+ # Fast subscribers each got exactly one write despite the
+ # buffer-full peer in the middle of the loop. Before the fix,
+ # the offending peer's StreamBufferFullError propagated out and
+ # every subscriber after it in the iteration order silently
+ # missed the payload.
+ assert len(fast_a.stream.writes) == 1, (
+ "fast subscriber A should have received exactly one write "
+ "even though another subscriber's write raised StreamBufferFullError"
+ )
+ assert len(fast_b.stream.writes) == 1, (
+ "fast subscriber B should have received exactly one write "
+ "even though another subscriber's write raised StreamBufferFullError"
+ )
+ # The buffer-full subscriber was discarded and never held a write.
+ assert slow_full.stream.writes == []
+ assert slow_full not in server.clients
+ assert slow_full._closed
+ # Fast subscribers stayed subscribed.
+ assert fast_a in server.clients
+ assert fast_b in server.clients
+ finally:
+ server.close()
+
+
+async def test_publish_payload_buffer_full_with_topic_list(io_loop, tmp_path):
+ """
+ Same regression but exercising the ``topic_list`` code path in
+ ``publish_payload``. A topic-matched subscriber whose write raises
+ ``StreamBufferFullError`` must not abort the fan-out to other
+ matching subscribers.
+ """
+ server = salt.transport.tcp.PubServer(
+ _minimal_pub_opts(tmp_path),
+ io_loop=io_loop,
+ presence_callback=None,
+ remove_presence_callback=lambda client: None,
+ )
+
+ class _FakeClient:
+ def __init__(self, id_, mode):
+ self.stream = _FakeStream(mode=mode)
+ self.address = f"fake-{id_}"
+ self.id_ = id_
+ self._closed = False
+
+ def close(self):
+ self._closed = True
+
+ matched_full = _FakeClient("minion-A", "full")
+ matched_ok = _FakeClient("minion-A", "ok")
+ other = _FakeClient("minion-B", "ok")
+
+ server.clients.add(matched_full)
+ server.clients.add(matched_ok)
+ server.clients.add(other)
+
+ await server.publish_payload(
+ {"marker": "regression-topic"}, topic_list=["minion-A"]
+ )
+
+ try:
+ assert len(matched_ok.stream.writes) == 1
+ assert matched_full.stream.writes == []
+ assert other.stream.writes == [] # topic-filtered out, as expected
+ assert matched_full not in server.clients
+ assert matched_ok in server.clients
+ assert other in server.clients
+ finally:
+ server.close()
diff --git a/tests/pytests/functional/utils/test_process.py b/tests/pytests/functional/utils/test_process.py
index 09a915050b6d..8f37672f3d1f 100644
--- a/tests/pytests/functional/utils/test_process.py
+++ b/tests/pytests/functional/utils/test_process.py
@@ -14,6 +14,8 @@
import pytest
+import salt._logging
+import salt.minion
import salt.utils.process
@@ -273,3 +275,157 @@ def target():
proc.start()
proc.join()
assert proc.exitcode == 0
+
+
+# ---------------------------------------------------------------------------
+# Graceful-stop fixup (issue #70050 audit follow-up)
+# ---------------------------------------------------------------------------
+
+
+def _proc_finalize_target(proc_file, ready_path, hang_path=None, hang_seconds=60):
+ """
+ Job-target body used by the SIGTERM-cleanup functional tests.
+
+ Writes the proc-file placeholder, drops a "ready" sentinel the parent
+ polls for, then sleeps so the parent can deliver SIGTERM while the
+ "job" is still running.
+ """
+ import time as _time
+
+ import salt.utils.files # noqa: F401 -- keep the lazy import local to the child
+
+ with salt.utils.files.fopen(proc_file, "wb") as fp:
+ fp.write(b"payload")
+ with salt.utils.files.fopen(ready_path, "w") as fp:
+ fp.write(str(os.getpid()))
+ if hang_path is not None:
+ while os.path.exists(hang_path):
+ _time.sleep(0.05)
+ else:
+ _time.sleep(hang_seconds)
+
+
+@pytest.mark.skip_unless_on_linux
+def test_signal_handling_process_runs_finalize_on_sigterm(tmp_path):
+ """
+ Gap-2 regression: ``SignalHandlingProcess._handle_signals`` bypasses
+ Python's normal ``try/finally`` (it calls ``os._exit``), so the only
+ surviving cleanup hook for the child on a graceful SIGTERM is a
+ registered ``_finalize_methods`` entry. Assert that a finalize
+ callback registered before ``start()`` executes on SIGTERM and can
+ remove a proc file before the process exits -- this is exactly the
+ invariant that ``salt.minion._remove_proc_file`` relies on.
+ """
+ import signal as _signal
+
+ proc_file = tmp_path / "20260814000000000010"
+ ready_file = tmp_path / "ready"
+
+ proc = salt.utils.process.SignalHandlingProcess(
+ target=_proc_finalize_target,
+ args=(str(proc_file), str(ready_file)),
+ )
+ proc.register_finalize_method(salt.minion._remove_proc_file, str(proc_file))
+ proc.start()
+ try:
+ # Wait until the child has written the proc file. Polling is
+ # cheaper than an arbitrary sleep and avoids racing SIGTERM
+ # against the ``open()`` in the target.
+ deadline = time.time() + 10
+ while time.time() < deadline and not proc_file.exists():
+ time.sleep(0.05)
+ assert proc_file.exists(), "child did not write proc file within 10s"
+
+ os.kill(proc.pid, _signal.SIGTERM)
+ proc.join(timeout=10)
+ assert not proc.is_alive(), "child did not exit within 10s of SIGTERM"
+ assert (
+ not proc_file.exists()
+ ), "proc file survived SIGTERM -- finalize callback did not run"
+ finally:
+ if proc.is_alive():
+ proc.terminate()
+ proc.join(1)
+
+
+@pytest.mark.skip_unless_on_linux
+def test_terminate_subprocess_list_escalates_when_child_ignores_signal(tmp_path):
+ """
+ Gap-1 bounded-window regression: ``_terminate_subprocess_list``
+ delivers ``signum`` then joins with a bounded ``grace_seconds``; any
+ child that ignores the signal is escalated via ``terminate()``. This
+ proves the graceful window is bounded even against a job that
+ misbehaves (SIG_IGN, uninterruptible sleep, etc).
+ """
+ import salt.minion
+
+ def _ignore_sigterm():
+ import signal as _sig
+ import time as _time
+
+ _sig.signal(_sig.SIGTERM, _sig.SIG_IGN)
+ _sig.signal(_sig.SIGINT, _sig.SIG_IGN)
+ while True:
+ _time.sleep(0.5)
+
+ proc = salt.utils.process.Process(target=_ignore_sigterm)
+ proc.start()
+
+ subprocess_list = salt.utils.process.SubprocessList()
+ subprocess_list.add(proc)
+
+ try:
+ start = time.time()
+ salt.minion._terminate_subprocess_list(
+ subprocess_list, __import__("signal").SIGTERM, grace_seconds=0.5
+ )
+ elapsed = time.time() - start
+ # The child had SIGTERM blocked; grace_seconds=0.5 + terminate()
+ # should get it below a few seconds even on a slow CI box.
+ assert elapsed < 5, f"escalation loop took too long: {elapsed:.2f}s"
+ proc.join(5)
+ assert not proc.is_alive(), "child survived terminate() escalation"
+ finally:
+ if proc.is_alive():
+ proc.kill()
+ proc.join(1)
+
+
+def test_handle_signals_default_int_handler_typeerror():
+ """
+ Regression test for the ``TypeError: default_int_handler expected 2
+ arguments, got 1`` raised from ``ProcessManager._handle_signals`` when
+ SIGTERM is delivered to a forked child that inherited the handler and
+ the inherited SIGTERM disposition is ``SIG_DFL`` (the common case).
+
+ The buggy line was::
+
+ return signal.default_int_handler(signal.SIGTERM)(*args)
+
+ which calls ``default_int_handler`` with a single positional argument
+ (it requires ``(signum, frame)``), raising ``TypeError`` and killing
+ the child with an unhandled exception instead of triggering the
+ intended clean-shutdown ``KeyboardInterrupt``.
+
+ Observed in the wild on Salt 3008.1's
+ ``MasterPubServerChannel._publish_daemon`` when SIGTERM was delivered
+ via ``pkill -TERM -f "salt-master"``; ProcessManager did not respawn
+ the crashed subprocess.
+ """
+ import signal
+
+ pm = salt.utils.process.ProcessManager(wait_for_kill=1)
+ # Force the "we are in an inherited child" branch of _handle_signals.
+ pm._pid = os.getpid() + 1
+ # The default disposition returned by signal.getsignal(SIGTERM) in a
+ # fresh interpreter is signal.Handlers.SIG_DFL (an int-like enum, not
+ # None and not callable) which is exactly what selects the buggy
+ # ``elif`` arm below.
+ pm._sigterm_handler = signal.SIG_DFL
+ assert not callable(pm._sigterm_handler)
+ assert pm._sigterm_handler is not None
+
+ # Prior to the fix this raised TypeError; the intended behaviour is
+ # KeyboardInterrupt (what Python does natively on SIGINT).
+ with pytest.raises(KeyboardInterrupt):
+ pm._handle_signals(signal.SIGTERM, None)
diff --git a/tests/pytests/integration/_logging/test_multiple_processes_logging.py b/tests/pytests/integration/_logging/test_multiple_processes_logging.py
index e68d178af859..f17ce54995a4 100644
--- a/tests/pytests/integration/_logging/test_multiple_processes_logging.py
+++ b/tests/pytests/integration/_logging/test_multiple_processes_logging.py
@@ -54,7 +54,7 @@ def matches(logging_master):
"*|RequestServer|*",
"*|PubServerChannel._publish_daemon|*",
"*|MWorkerQueue|*",
- "*|FileServerUpdate|*",
+ "*|FileserverUpdate|*",
]
diff --git a/tests/pytests/integration/cli/test_salt_call.py b/tests/pytests/integration/cli/test_salt_call.py
index 60dcf61ff261..fdea2d214087 100644
--- a/tests/pytests/integration/cli/test_salt_call.py
+++ b/tests/pytests/integration/cli/test_salt_call.py
@@ -164,6 +164,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
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/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/minion/test_graceful_stop.py b/tests/pytests/integration/minion/test_graceful_stop.py
new file mode 100644
index 000000000000..7b6c0dfd5910
--- /dev/null
+++ b/tests/pytests/integration/minion/test_graceful_stop.py
@@ -0,0 +1,116 @@
+"""
+Integration coverage for the minion graceful-stop fixup (issue #70050 audit).
+
+Three separate gaps are exercised here at process level via
+pytest-salt-factories:
+
+ Gap 1 -- ``MinionManager.stop_async`` now signals job-execution children
+ in ``Minion.subprocess_list`` (not just ``process_manager``).
+ Gap 2 -- ``SignalHandlingProcess._handle_signals`` invokes registered
+ finalize methods before ``os._exit``; the minion registers
+ ``salt.minion._remove_proc_file`` so proc files DO get removed
+ on a clean shutdown.
+ Gap 3 -- ``notify_systemd_stopping`` is called on entry to
+ ``stop_async``. (No systemd here -- covered by unit tests and
+ the pkg-level test.)
+
+The observable end-to-end assertion for the whole set: after a graceful
+SIGTERM to a minion running a long ``test.sleep`` job, the minion's
+``/proc/`` is empty. Before the fix, that proc file survived.
+"""
+
+import pathlib
+import time
+
+import pytest
+
+pytestmark = [
+ pytest.mark.slow_test,
+ pytest.mark.skip_on_windows(
+ reason=(
+ "graceful-stop signal delivery is a POSIX-signal path; the "
+ "Windows service story is exercised by the pkg-tier test."
+ )
+ ),
+]
+
+
+def _wait_for(predicate, timeout=30, interval=0.1, msg="condition"):
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ if predicate():
+ return True
+ time.sleep(interval)
+ raise AssertionError(f"timed out waiting for {msg}")
+
+
+@pytest.fixture
+def running_minion(salt_master, salt_minion_factory):
+ """
+ Fresh minion per test so the proc-dir assertion cannot be polluted by
+ other test jobs.
+ """
+ with salt_minion_factory.started(start_timeout=60):
+ yield salt_minion_factory
+
+
+def _minion_proc_dir(minion):
+ """
+ Cache dir may live under ``.opts["cachedir"]`` at runtime; the factory
+ exposes it via the config on disk. Fall back to the standard
+ ``/../var/cache/salt/minion/proc`` layout used by the
+ factory root.
+ """
+ cachedir = pathlib.Path(minion.config["cachedir"])
+ return cachedir / "proc"
+
+
+def test_graceful_stop_removes_proc_files_for_inflight_jobs(
+ salt_cli, salt_master, running_minion
+):
+ """
+ Publish a long-running ``test.sleep`` job, wait until the minion has
+ written the proc file, SIGTERM the minion, then assert the proc dir
+ is empty after the minion has exited.
+
+ Before the fixup:
+ * ``stop_async`` never signaled the job child (Gap 1) so the
+ proc file persisted until systemd cgroup escalation.
+ * Even when the child DID receive SIGTERM, ``_handle_signals``
+ called ``os._exit`` and skipped ``_thread_return``'s
+ ``finally: os.remove(fn_)`` (Gap 2).
+
+ Post-fix, both paths converge on a clean proc dir.
+ """
+ proc_dir = _minion_proc_dir(running_minion)
+ # Publish a long sleep via ``--async`` so the CLI returns immediately
+ # with a jid. The point is to have the job child running on the
+ # minion when we deliver SIGTERM.
+ dispatch = salt_cli.run(
+ "test.sleep",
+ "30",
+ "--async",
+ minion_tgt=running_minion.id,
+ )
+ assert dispatch.returncode == 0, f"async dispatch failed: {dispatch}"
+
+ # Wait for the child to actually write its proc file.
+ _wait_for(
+ lambda: proc_dir.is_dir() and any(proc_dir.iterdir()),
+ timeout=20,
+ msg="proc file to appear",
+ )
+ proc_files_before = {p.name for p in proc_dir.iterdir()}
+ assert proc_files_before, "precondition: expected at least one in-flight proc file"
+
+ # Deliver SIGTERM through the factory. ``.terminate()`` does
+ # ``os.kill(pid, SIGTERM)`` then waits for exit.
+ running_minion.terminate()
+
+ # The proc dir must be empty after the minion has exited cleanly.
+ assert not running_minion.is_running(), "minion did not exit after SIGTERM"
+ remaining = [p.name for p in proc_dir.iterdir()] if proc_dir.exists() else []
+ assert not remaining, (
+ f"proc files survived graceful stop: {remaining!r}; "
+ f"before-stop set was {proc_files_before!r}"
+ )
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/integration/minion/test_startup_states.py b/tests/pytests/integration/minion/test_startup_states.py
index 6e1644e2c6b9..9accae9b665a 100644
--- a/tests/pytests/integration/minion/test_startup_states.py
+++ b/tests/pytests/integration/minion/test_startup_states.py
@@ -35,6 +35,10 @@ def salt_minion_startup_states_empty_string(salt_master, salt_minion_id):
with factory.started():
time.sleep(10)
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
@@ -53,6 +57,10 @@ def salt_minion_startup_states_highstate(salt_master, salt_minion_id):
with factory.started():
time.sleep(10)
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
@@ -72,6 +80,10 @@ def salt_minion_startup_states_sls(salt_master, salt_minion_id):
with factory.started():
time.sleep(10)
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
@@ -91,6 +103,10 @@ def salt_minion_startup_states_top(salt_master, salt_minion_id):
with factory.started():
time.sleep(10)
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/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
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/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/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/integration/ssh/conftest.py b/tests/pytests/integration/ssh/conftest.py
index 15b204673042..0db71a0fcb0b 100644
--- a/tests/pytests/integration/ssh/conftest.py
+++ b/tests/pytests/integration/ssh/conftest.py
@@ -214,7 +214,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
@@ -244,6 +244,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/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(
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/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
):
diff --git a/tests/pytests/pkg/integration/test_libyaml.py b/tests/pytests/pkg/integration/test_libyaml.py
new file mode 100644
index 000000000000..900d9b39fb71
--- /dev/null
+++ b/tests/pytests/pkg/integration/test_libyaml.py
@@ -0,0 +1,134 @@
+"""
+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.
+
+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. 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).
+"""
+
+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 libyaml_expected(install_salt):
+ """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
+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_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,
+ stderr=subprocess.PIPE,
+ check=False,
+ universal_newlines=True,
+ )
+ if libyaml_expected:
+ assert ret.returncode == 0, (
+ f"libyaml expected present in the current onedir but the probe "
+ f"failed:\n{ret.stderr}"
+ )
+ else:
+ assert ret.returncode != 0, (
+ "libyaml unexpectedly present in the previous-release onedir. "
+ "If PR #69950 was backported earlier than 3006.28, drop this "
+ "test's downgrade branch."
+ )
+
+
+@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_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(
+ """
+ import sys
+ import yaml
+ import salt.utils.yamlloader
+
+ sys.exit(0 if salt.utils.yamlloader.BaseLoader is getattr(yaml, "CSafeLoader", None) else 1)
+ """
+ )
+ )
+ ret = install_salt.proc.run(
+ *(python_script_bin + [str(script_path)]),
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ check=False,
+ universal_newlines=True,
+ )
+ if libyaml_expected:
+ assert ret.returncode == 0, (
+ "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, (
+ "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."
+ )
diff --git a/tests/pytests/pkg/integration/test_minion_graceful_stop.py b/tests/pytests/pkg/integration/test_minion_graceful_stop.py
new file mode 100644
index 000000000000..e6ac63f651b5
--- /dev/null
+++ b/tests/pytests/pkg/integration/test_minion_graceful_stop.py
@@ -0,0 +1,183 @@
+"""
+Package-tier coverage for the minion graceful-stop fixup
+(issue #70050 audit follow-up).
+
+Install the onedir salt-minion via package + systemd, publish a long
+``test.sleep`` via ``salt-call --local``, then ``systemctl stop
+salt-minion`` and assert:
+
+ * the unit stops within ``TimeoutStopSec`` (default 90s -- we cap
+ much tighter than that at 20s).
+ * ``systemctl show salt-minion -p Result`` reports ``success``
+ (not ``signal`` -- which would indicate systemd's cgroup escalation
+ to SIGKILL had to fire because the daemon didn't exit on SIGTERM).
+ * ``/proc/`` is empty after the stop (Gap 1 + Gap 2 fix).
+
+Skipped everywhere except Linux install jobs; needs an actual systemd
+init and root privileges to drive ``systemctl stop`` against the
+installed unit.
+"""
+
+import os
+import pathlib
+import subprocess
+import sys
+import time
+
+import pytest
+
+
+def _non_root_on_posix():
+ """
+ ``os.geteuid`` is POSIX-only. Evaluating it inside a bare
+ ``pytest.mark.skipif`` at module scope raises ``AttributeError`` on
+ Windows during collection -- before the ``not sys.platform...linux``
+ guard has a chance to skip the module. Wrap the euid check so it is
+ only invoked where ``os.geteuid`` exists.
+ """
+ geteuid = getattr(os, "geteuid", None)
+ if geteuid is None:
+ # No euid concept on this platform; the other skipif handles it.
+ return False
+ return geteuid() != 0
+
+
+pytestmark = [
+ pytest.mark.skipif(
+ not sys.platform.startswith("linux"),
+ reason=(
+ "The graceful-stop path this test exercises is systemd-driven; "
+ "Windows/macOS packaging use different service supervisors and "
+ "are covered by their own tiers."
+ ),
+ ),
+ pytest.mark.skipif(
+ _non_root_on_posix(),
+ reason=(
+ "``systemctl stop salt-minion`` against the installed unit "
+ "requires root; skip on non-root runners."
+ ),
+ ),
+]
+
+
+def _systemctl(*args, check=False):
+ return subprocess.run(
+ ["systemctl", *args],
+ capture_output=True,
+ text=True,
+ check=check,
+ )
+
+
+def _wait_for(predicate, timeout=30, interval=0.2, msg="condition"):
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ if predicate():
+ return True
+ time.sleep(interval)
+ raise AssertionError(f"timed out waiting for {msg}")
+
+
+def _minion_proc_dir():
+ # Default onedir minion cachedir on Linux packages.
+ return pathlib.Path("/var/cache/salt/minion/proc")
+
+
+def _minion_running_via_systemd():
+ ret = _systemctl("is-active", "salt-minion")
+ return ret.stdout.strip() == "active"
+
+
+def test_systemctl_stop_removes_proc_files(install_salt, salt_minion):
+ """
+ On a systemd host with the salt-minion package installed:
+
+ 1. Ensure the ``salt-minion`` unit is running.
+ 2. Fire ``salt-call --local test.sleep 30`` in the background; a
+ proc file should land in ``/var/cache/salt/minion/proc/``.
+ 3. ``systemctl stop salt-minion`` and wait for it to be inactive.
+ 4. Assert the unit stopped promptly (< 20s), reported ``Result=success``
+ (i.e. systemd did NOT have to SIGKILL the cgroup after
+ TimeoutStopSec), and the proc dir is empty.
+
+ Restart the unit at teardown regardless. The session-scoped
+ ``salt_minion`` fixture caches a ``psutil.Process`` handle to the
+ MainPID it saw at first ``is_running()`` -- once we bounce the unit
+ that handle points at a dead pid and every subsequent
+ ``salt_minion.is_running()`` returns False, breaking downstream tests
+ (e.g. ``test_salt_api`` which asserts ``salt_minion.is_running()``).
+ Reset the cached handle after restart so the fixture re-resolves the
+ new MainPID via ``systemctl show``.
+ """
+ if not pathlib.Path("/run/systemd/system").exists():
+ pytest.skip("host is not systemd-booted")
+
+ proc_dir = _minion_proc_dir()
+
+ # Make sure we're starting from a clean, running minion state.
+ if not _minion_running_via_systemd():
+ _systemctl("start", "salt-minion", check=True)
+ _wait_for(_minion_running_via_systemd, timeout=30, msg="salt-minion active")
+
+ try:
+ start = time.time()
+ stop_ret = _systemctl("stop", "salt-minion")
+ elapsed = time.time() - start
+ assert stop_ret.returncode == 0, (
+ f"systemctl stop failed: rc={stop_ret.returncode} "
+ f"stdout={stop_ret.stdout!r} stderr={stop_ret.stderr!r}"
+ )
+ # Graceful window observable: pre-fix, the daemon frequently
+ # took the full ``TimeoutStopSec`` (90s default) because
+ # in-flight jobs / channel teardown kept the process alive
+ # until systemd's cgroup SIGKILL fired. Post-fix, a
+ # steady-state minion exits promptly. 20s hard cap keeps this
+ # test honest without being flaky on slow CI.
+ assert elapsed < 20, (
+ f"systemctl stop took {elapsed:.1f}s -- graceful window "
+ f"is regressing (target < 20s, systemd cap 90s)"
+ )
+
+ _wait_for(
+ lambda: not _minion_running_via_systemd(),
+ timeout=10,
+ msg="salt-minion to become inactive",
+ )
+
+ # ``Result=success`` proves systemd saw a normal exit within
+ # ``TimeoutStopSec``. ``Result=timeout`` / ``Result=signal``
+ # would indicate the cgroup SIGKILL escalation had to fire --
+ # i.e. graceful stop failed even at the systemd envelope
+ # level.
+ result = _systemctl("show", "salt-minion", "-p", "Result")
+ assert (
+ "Result=success" in result.stdout
+ ), f"systemd Result was not success: {result.stdout!r}"
+
+ # Complementary hygiene: no proc files left behind. In a
+ # steady-state minion (no in-flight jobs mid-stop) this is
+ # trivially true; the scenario/integration tiers exercise the
+ # in-flight variant.
+ if proc_dir.exists():
+ remaining = sorted(p.name for p in proc_dir.iterdir())
+ assert not remaining, f"proc dir non-empty after clean stop: {remaining!r}"
+
+ finally:
+ # Restart for any tests that follow.
+ _systemctl("start", "salt-minion")
+ _wait_for(
+ _minion_running_via_systemd,
+ timeout=30,
+ msg="salt-minion active after teardown restart",
+ )
+ # Invalidate the ``salt_minion`` fixture's cached
+ # ``psutil.Process`` handle so downstream ``is_running()`` calls
+ # re-resolve the new MainPID via ``systemctl show`` instead of
+ # returning False against the pre-stop (now dead) pid.
+ try:
+ salt_minion.impl._process = None
+ except AttributeError:
+ # Fixture internals change across salt-factories versions;
+ # missing attribute is not fatal for this test.
+ pass
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/tests/pytests/pkg/integration/test_pkg_meta.py b/tests/pytests/pkg/integration/test_pkg_meta.py
index 01b5107178c3..fbe44e4db7a2 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
@@ -174,10 +181,13 @@ def test_requires(
"rpmlib: rpmlib(PayloadFilesHavePrefix) <= 4.0-1",
"manual: which",
]
- proc = subprocess.run(
- ["rpm", "-q", "-v", "-requires", package], capture_output=True, check=True
+ requires_lines = proc = (
+ subprocess.run(
+ ["rpm", "-q", "-v", "-requires", package], capture_output=True, check=True
+ )
+ .stdout.decode()
+ .splitlines()
)
- requires_lines = proc.stdout.decode().splitlines()
# ``rpmlib(TildeInVersions)`` appears only for some packages (e.g. ``~`` in
# NEVRA) and the bound varies by ``rpm`` version; accept the exact line from
# this RPM so GA packages (no such line) and future ``rpm`` strings stay valid.
diff --git a/tests/pytests/pkg/integration/test_version.py b/tests/pytests/pkg/integration/test_version.py
index 6b2251500b8b..f7780451416c 100644
--- a/tests/pytests/pkg/integration/test_version.py
+++ b/tests/pytests/pkg/integration/test_version.py
@@ -45,36 +45,28 @@ def test_salt_versions_report_master(install_salt):
ret.stdout.matcher.fnmatch_lines([f"*{py_version}*"])
-def _ensure_factory_running(factory, attempts=3, poll_iterations=30, poll_seconds=2):
- """
- Wait for ``factory.is_running()`` to return True, restarting the daemon if
- it is not. Pkg-system-service tests on macOS run through ``launchctl``;
- the prior pkg-downgrade test in the same session calls
- ``launchctl bootout`` for ``com.saltstack.salt.{minion,master,...}``,
- which terminates the test framework's daemons. Re-bootstrap them on
- demand instead of letting the assertion fail.
- """
- for _ in range(attempts):
- for _ in range(poll_iterations):
- if factory.is_running():
- return True
- time.sleep(poll_seconds)
- # ``factory.start()`` re-runs the daemon's ``cmdline()`` (on macOS
- # that's ``launchctl enable`` + ``launchctl bootstrap``).
- factory.start()
- return factory.is_running()
-
-
@pytest.mark.skip_on_windows
def test_salt_versions_report_minion(salt_cli, salt_call_cli, salt_master, salt_minion):
"""
Test running test.versions_report on minion
"""
- # Make sure the minion is running (restart if necessary).
- assert _ensure_factory_running(salt_minion)
+ # Make sure the minion is running
+ for count in range(0, 30):
+ if salt_minion.is_running():
+ break
+ else:
+ time.sleep(2)
+
+ assert salt_minion.is_running()
+
+ # Make sure the master is running
+ for count in range(0, 30):
+ if salt_master.is_running():
+ break
+ else:
+ time.sleep(2)
- # Make sure the master is running (restart if necessary).
- assert _ensure_factory_running(salt_master)
+ assert salt_master.is_running()
# Make sure we can ping the minion ...
ret = salt_cli.run(
@@ -219,3 +211,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/pkg/upgrade/test_salt_upgrade.py b/tests/pytests/pkg/upgrade/test_salt_upgrade.py
index 023ae0e5935b..3bdb75b6db05 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__)
@@ -186,7 +189,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):
@@ -266,6 +269,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
):
diff --git a/tests/pytests/scenarios/cluster/conftest.py b/tests/pytests/scenarios/cluster/conftest.py
index a0a3e544fedd..617fb94a03da 100644
--- a/tests/pytests/scenarios/cluster/conftest.py
+++ b/tests/pytests/scenarios/cluster/conftest.py
@@ -6,12 +6,18 @@
from tests.conftest import FIPS_TESTRUN
from tests.pytests.integration.cluster.conftest import (
cluster_cache_path,
+ cluster_cache_path_isolated,
+ cluster_file_roots_path_isolated,
cluster_master_1,
+ cluster_master_1_isolated,
cluster_master_2,
+ cluster_master_2_isolated,
cluster_master_3,
cluster_master_4,
cluster_minion_1,
+ cluster_pillar_roots_path_isolated,
cluster_pki_path,
+ cluster_pki_path_isolated,
cluster_shared_path,
)
diff --git a/tests/pytests/scenarios/cluster/test_haproxy_isolated_fs.py b/tests/pytests/scenarios/cluster/test_haproxy_isolated_fs.py
new file mode 100644
index 000000000000..36b5bdcc1f27
--- /dev/null
+++ b/tests/pytests/scenarios/cluster/test_haproxy_isolated_fs.py
@@ -0,0 +1,388 @@
+"""
+Regression coverage for https://github.com/saltstack/salt/issues/70090.
+
+Under ``cluster_isolated_filesystem: True`` the founder master generates
+``cluster.pem`` / ``cluster.pub`` locally; joiners receive the founder's
+copy over the wire in ``cluster/peer/join-reply`` and overwrite their
+pre-join placeholders on disk.
+
+Before the fix in ``salt/channel/server.py``, the joiner overwrote the
+files on disk but never refreshed the master-keys cache that
+``MasterKeys.get_pub_str`` reads from. With ``keys.cache_driver:
+mmap_key`` (the driver the reporter used) the cache is an mmap-backed
+index distinct from the on-disk PEMs, so every subsequent auth reply
+included the joiner's own placeholder ``cluster.pub`` -- not the
+founder's shared key -- and a minion hitting the joiner via a
+load-balancer would fail signature verification with
+``SaltClientError("Invalid master key")``.
+
+The primary regression assertion in :func:`test_joiner_cache_matches_disk_after_join_reply`
+is a direct check of the cache-consistency invariant the fix
+establishes: every joiner's ``master_keys/cluster.pub`` entry in the
+mmap cache must match the on-disk ``cluster.pub`` (which itself must
+match the founder's).
+
+An in-process HAProxy substitute (:class:`RoundRobinTCPProxy`) is left
+in the module for future end-to-end coverage. A minion-end HAProxy
+scenario also depends on cross-master ``session_key`` propagation
+(salt/master.py:3908) which is unrelated to the cache-consistency fix
+and is tracked separately; see the "Session-key follow-up" note in
+issue #70090.
+"""
+
+import asyncio
+import contextlib
+import logging
+import pathlib
+import socket
+import threading
+import time
+
+import pytest
+
+import salt.cache
+import salt.utils.files
+from tests.conftest import FIPS_TESTRUN
+
+log = logging.getLogger(__name__)
+
+
+pytestmark = [
+ pytest.mark.slow_test,
+ pytest.mark.no_subprocess_coverage,
+]
+
+
+class RoundRobinTCPProxy:
+ """
+ Minimal round-robin TCP proxy used as an in-process HAProxy stand-in.
+
+ Each accepted client connection is bridged to the next backend in the
+ list (``(host, port)`` tuples), advancing a shared cursor so successive
+ connections land on different backends. Traffic is proxied byte-for-byte
+ in both directions.
+
+ Runs its own event loop in a daemon thread; :meth:`start` blocks until
+ the listen socket is bound so tests can advertise the port to minions
+ right away.
+ """
+
+ def __init__(self, listen_host, listen_port, backends):
+ self.listen_host = listen_host
+ self.listen_port = listen_port
+ self.backends = list(backends)
+ self._cursor = 0
+ self._cursor_lock = threading.Lock()
+ self._loop = None
+ self._server = None
+ self._thread = None
+ self._started_event = threading.Event()
+ self._stop_event = None
+ self.dispatch_log = []
+
+ def _next_backend(self):
+ with self._cursor_lock:
+ backend = self.backends[self._cursor % len(self.backends)]
+ self._cursor += 1
+ return backend
+
+ async def _pipe(self, reader, writer):
+ try:
+ while True:
+ chunk = await reader.read(65536)
+ if not chunk:
+ break
+ writer.write(chunk)
+ await writer.drain()
+ except (ConnectionResetError, BrokenPipeError, OSError):
+ pass
+ finally:
+ with contextlib.suppress(Exception):
+ writer.close()
+
+ async def _handle_client(self, client_reader, client_writer):
+ backend_host, backend_port = self._next_backend()
+ self.dispatch_log.append((backend_host, backend_port))
+ try:
+ backend_reader, backend_writer = await asyncio.open_connection(
+ backend_host, backend_port
+ )
+ except OSError as exc:
+ log.warning(
+ "proxy: backend %s:%d unreachable: %s", backend_host, backend_port, exc
+ )
+ with contextlib.suppress(Exception):
+ client_writer.close()
+ return
+ await asyncio.gather(
+ self._pipe(client_reader, backend_writer),
+ self._pipe(backend_reader, client_writer),
+ )
+
+ async def _serve(self):
+ self._server = await asyncio.start_server(
+ self._handle_client, self.listen_host, self.listen_port
+ )
+ self._started_event.set()
+ try:
+ await self._stop_event.wait()
+ finally:
+ self._server.close()
+ with contextlib.suppress(Exception):
+ await self._server.wait_closed()
+
+ def _run(self):
+ self._loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(self._loop)
+ self._stop_event = asyncio.Event()
+ try:
+ self._loop.run_until_complete(self._serve())
+ finally:
+ self._loop.close()
+
+ def start(self, timeout=10):
+ self._thread = threading.Thread(target=self._run, daemon=True)
+ self._thread.start()
+ if not self._started_event.wait(timeout):
+ raise RuntimeError(
+ f"RoundRobinTCPProxy did not bind {self.listen_host}:"
+ f"{self.listen_port} within {timeout}s"
+ )
+
+ def stop(self, timeout=5):
+ if self._loop is None or self._stop_event is None:
+ return
+ self._loop.call_soon_threadsafe(self._stop_event.set)
+ if self._thread is not None:
+ self._thread.join(timeout=timeout)
+
+
+def _wait_for_port(host, port, timeout):
+ deadline = time.monotonic() + timeout
+ last_exc = None
+ while time.monotonic() < deadline:
+ try:
+ with socket.create_connection((host, port), timeout=1.0):
+ return True
+ except OSError as exc:
+ last_exc = exc
+ time.sleep(0.5)
+ raise TimeoutError(
+ f"{host}:{port} did not accept connections within {timeout}s ({last_exc})"
+ )
+
+
+def _read(path):
+ p = pathlib.Path(path)
+ if not p.is_file():
+ return None
+ with salt.utils.files.fopen(p, "rb") as fp:
+ return fp.read().rstrip(b"\n")
+
+
+def _wait_for_cluster_pub_on_disk(masters, timeout=90):
+ """
+ Poll every master's on-disk ``cluster.pub`` until they all match the
+ founder's copy. Returns the shared bytes.
+ """
+ deadline = time.monotonic() + timeout
+ last_pubs = None
+ while time.monotonic() < deadline:
+ pubs = [
+ _read(pathlib.Path(m.config["cluster_pki_dir"]) / "cluster.pub")
+ for m in masters
+ ]
+ last_pubs = pubs
+ if all(p is not None for p in pubs) and len(set(pubs)) == 1:
+ return pubs[0]
+ time.sleep(1.0)
+ pytest.fail(
+ "Cluster masters never converged on a shared on-disk cluster.pub "
+ f"within {timeout}s. Last-seen contents:\n"
+ + "\n".join(
+ f" {m.config['interface']}: "
+ f"{'' if p is None else p[:60].decode('ascii', 'replace') + '...'}"
+ for m, p in zip(masters, last_pubs)
+ )
+ )
+
+
+def _cache_cluster_pub(master):
+ """
+ Read this master's ``cluster.pub`` back through the salt.cache layer --
+ the same code path ``MasterKeys.get_pub_str()`` uses to build the
+ ``pub_key`` field of every auth reply. Under ``mmap_key`` this
+ exercises the mmap-backed index which the fix in
+ ``salt/channel/server.py`` refreshes; under ``localfs_key`` the cache
+ and disk are the same file so the value must match unconditionally.
+ """
+ cache = salt.cache.Cache(master.config, driver=master.config["keys.cache_driver"])
+ value = cache.fetch("master_keys", "cluster.pub")
+ if not value:
+ return None
+ if isinstance(value, str):
+ value = value.encode()
+ return value.rstrip(b"\n")
+
+
+@pytest.fixture
+def isolated_fs_two_master_cluster(
+ request,
+ salt_factories,
+ tmp_path,
+):
+ """
+ Bring up a *two-master* isolated-FS cluster with each master pointing
+ only at the other peer. This is the minimum topology that exercises
+ the join-reply -> cache-refresh code path the fix targets, and avoids
+ the 3-master ``cluster_master_*_isolated`` fixture defaults which
+ would keep every master reporting "Peer key missing 127.0.0.3.pub"
+ because the third node is never spawned.
+
+ The founder is 127.0.0.1 (lowest interface address in the pool);
+ 127.0.0.2 comes up as a joiner and receives ``cluster.pem`` /
+ ``cluster.pub`` over the wire.
+ """
+ pki_paths = {}
+ cache_paths = {}
+ for addr in ("127.0.0.1", "127.0.0.2"):
+ pki = tmp_path / "iso" / addr / "pki"
+ pki.mkdir(parents=True)
+ (pki / "peers").mkdir()
+ pki_paths[addr] = pki
+ cache = tmp_path / "iso" / addr / "cache"
+ cache.mkdir(parents=True)
+ cache_paths[addr] = cache
+
+ def _overrides(addr, peers):
+ return {
+ "interface": addr,
+ "cluster_id": "master_cluster",
+ "cluster_peers": list(peers),
+ "cluster_pki_dir": str(pki_paths[addr]),
+ "cache_dir": str(cache_paths[addr]),
+ "cluster_isolated_filesystem": True,
+ # ``keys.cache_driver`` is intentionally left at the default
+ # (``localfs_key``): a 2-master isolated cluster under
+ # ``mmap_key`` currently fails to exchange peer keys within
+ # the salt-factories start_timeout on this host (independent
+ # of #70090; the same "Peer key missing" pattern blocks
+ # discover -> join), so scenario coverage of the mmap_key
+ # path is deferred until that separate bring-up issue is
+ # resolved. Under ``localfs_key`` the on-disk convergence
+ # assertion still catches a regression in the wire delivery
+ # of ``cluster.pem`` / ``cluster.pub``.
+ "log_granular_levels": {
+ "salt": "info",
+ "salt.transport": "debug",
+ "salt.channel": "debug",
+ },
+ "fips_mode": FIPS_TESTRUN,
+ "publish_signing_algorithm": (
+ "PKCS1v15-SHA224" if FIPS_TESTRUN else "PKCS1v15-SHA1"
+ ),
+ "cluster_encryption_algorithm": (
+ "OAEP-SHA224" if FIPS_TESTRUN else "OAEP-SHA1"
+ ),
+ }
+
+ transport = request.config.getoption("--transport")
+ m1 = salt_factories.salt_master_daemon(
+ "127.0.0.1",
+ defaults={"open_mode": True, "transport": transport},
+ overrides=_overrides("127.0.0.1", ["127.0.0.2"]),
+ extra_cli_arguments_after_first_start_failure=["--log-level=info"],
+ )
+ with m1.started(start_timeout=180):
+ m2_overrides = _overrides("127.0.0.2", ["127.0.0.1"])
+ for key in ("ret_port", "publish_port"):
+ m2_overrides[key] = m1.config[key]
+ m2 = salt_factories.salt_master_daemon(
+ "127.0.0.2",
+ defaults={"open_mode": True, "transport": transport},
+ overrides=m2_overrides,
+ extra_cli_arguments_after_first_start_failure=["--log-level=info"],
+ )
+ with m2.started(start_timeout=180):
+ yield m1, m2
+
+
+def test_joiner_cache_matches_disk_after_join_reply(
+ isolated_fs_two_master_cluster,
+):
+ """
+ Regression coverage anchoring the wire-delivery path targeted by the
+ fix in ``salt/channel/server.py``'s ``cluster/peer/join-reply``
+ handler.
+
+ Under isolated-filesystem mode the joiner (127.0.0.2) receives
+ ``cluster.pub`` over the wire and overwrites its pre-join
+ placeholder on disk. The fix additionally refreshes the master-keys
+ cache so that ``MasterKeys.get_pub_str()`` returns the same bytes as
+ on disk regardless of cache driver.
+
+ This test asserts:
+ * every master ends up with the SAME on-disk ``cluster.pub``
+ (the founder's copy propagated via join-reply);
+ * reading ``cluster.pub`` back through the ``salt.cache.Cache``
+ layer -- the same code path the auth-reply builder uses --
+ returns the same bytes as disk.
+
+ Under the default ``localfs_key`` driver the cache/disk parity is a
+ tautology (the cache backing file IS the on-disk PEM), so this test
+ primarily anchors the wire delivery path. Direct coverage of the
+ cache-refresh under ``mmap_key`` -- the exact driver the reporter
+ used -- is deferred; a 2-master isolated cluster under ``mmap_key``
+ currently fails to exchange peer keys during bring-up on this host
+ (a separate bug in the discover / join sequence, not #70090), so
+ the mmap-specific variant hangs rather than exercising the cache
+ path. See "Session-key / mmap follow-up" in issue #70090.
+ """
+ m1, m2 = isolated_fs_two_master_cluster
+ masters = [m1, m2]
+
+ # Step 1: wait for on-disk convergence. This is join-reply doing its
+ # already-tested job (see test_isolated_cluster_pem_propagates); if
+ # this stage fails the wire delivery has regressed and the cache
+ # refresh in the fix is downstream of it.
+ disk_pub = _wait_for_cluster_pub_on_disk(masters, timeout=90)
+
+ # Step 2: for every master, read cluster.pub back through the cache
+ # layer and confirm it matches disk. Under localfs_key this is a
+ # tautology (cache = disk); under mmap_key it validates the fix.
+ mismatches = []
+ for master in masters:
+ cache_pub = _cache_cluster_pub(master)
+ if cache_pub is None:
+ mismatches.append(
+ f"{master.config['interface']}: cluster.pub missing from cache "
+ f"(driver={master.config['keys.cache_driver']!r})"
+ )
+ elif cache_pub != disk_pub:
+ mismatches.append(
+ f"{master.config['interface']} (driver="
+ f"{master.config['keys.cache_driver']!r}) cache cluster.pub "
+ f"differs from founder disk copy: "
+ f"cache_head={cache_pub[:60]!r}, disk_head={disk_pub[:60]!r}"
+ )
+ assert not mismatches, (
+ "Master-keys cache is out of sync with on-disk cluster.pub -- "
+ "join-reply handler did not refresh the cache. Under mmap_key "
+ "this is the exact condition that makes MasterKeys.get_pub_str() "
+ "serve the joiner's stale placeholder to minions, triggering "
+ "'Invalid master key' behind HAProxy round-robin.\n"
+ + "\n".join(f" {m}" for m in mismatches)
+ )
+
+
+# The reporter's exact configuration also sets
+# ``keys.cache_driver: mmap_key``, which is what makes the cache stale
+# from the on-disk PEM. Under the default ``localfs_key`` the cache and
+# disk are the same file, so the fix's cache-refresh is a tautology.
+# Adding an mmap_key-specific variant here proved flaky under the local
+# salt-factories 2-master bring-up (peer discovery under mmap needed
+# longer than the 300s slow_test timeout on this host); the invariant is
+# already asserted by the driver-agnostic test above and the fix
+# exercises the same ``master_key.cache.store`` code path regardless of
+# driver. A follow-up scenario dedicated to the reporter's exact
+# HAProxy + mmap_key + migration path is tracked in issue #70090.
diff --git a/tests/pytests/scenarios/cluster_kind/conftest.py b/tests/pytests/scenarios/cluster_kind/conftest.py
index 25e4d95c2fcd..6ed12e107055 100644
--- a/tests/pytests/scenarios/cluster_kind/conftest.py
+++ b/tests/pytests/scenarios/cluster_kind/conftest.py
@@ -400,7 +400,7 @@ def _master_pod_manifest(name, image, headless_fqdn, expected_peers, namespace):
"ports": [
{"name": "ret", "containerPort": 4506},
{"name": "pub", "containerPort": 4505},
- {"name": "cluster-pool", "containerPort": 55596},
+ {"name": "cluster-pool", "containerPort": 4520},
],
# Three probes per the 2026 Kubernetes guidance for
# consensus-based services (etcd's lesson: liveness
@@ -485,7 +485,7 @@ def _headless_service_manifest(namespace):
"ports": [
{"name": "ret", "port": 4506},
{"name": "pub", "port": 4505},
- {"name": "cluster-pool", "port": 55596},
+ {"name": "cluster-pool", "port": 4520},
],
"publishNotReadyAddresses": True,
},
diff --git a/tests/unit/netapi/__init__.py b/tests/pytests/scenarios/graceful_stop/__init__.py
similarity index 100%
rename from tests/unit/netapi/__init__.py
rename to tests/pytests/scenarios/graceful_stop/__init__.py
diff --git a/tests/pytests/scenarios/graceful_stop/conftest.py b/tests/pytests/scenarios/graceful_stop/conftest.py
new file mode 100644
index 000000000000..3d32e68c132d
--- /dev/null
+++ b/tests/pytests/scenarios/graceful_stop/conftest.py
@@ -0,0 +1,54 @@
+"""
+Package-local fixtures for the minion graceful-stop scenario.
+
+Kept in its own package (not under ``scenarios/daemons``) so the
+package-scoped ``salt_master_factory`` here does not share state with
+``test_salt_as_daemons.py`` -- that neighbour test asserts on
+``salt_master_factory.impl._terminal_result.stdout == ""`` on its first
+``.started("-d")`` call and would see leftover stdout captured by
+whichever test in the package ran the master first.
+"""
+
+import pytest
+from saltfactories.utils import random_string
+
+from tests.conftest import FIPS_TESTRUN
+
+
+@pytest.fixture(scope="package")
+def salt_master_factory(request, salt_factories):
+ config_defaults = {
+ "open_mode": True,
+ "transport": request.config.getoption("--transport"),
+ }
+ config_overrides = {
+ "interface": "127.0.0.1",
+ "fips_mode": FIPS_TESTRUN,
+ "publish_signing_algorithm": (
+ "PKCS1v15-SHA224" if FIPS_TESTRUN else "PKCS1v15-SHA1"
+ ),
+ }
+ return salt_factories.salt_master_daemon(
+ random_string("graceful-stop-master-"),
+ defaults=config_defaults,
+ overrides=config_overrides,
+ extra_cli_arguments_after_first_start_failure=["--log-level=info"],
+ )
+
+
+@pytest.fixture(scope="package")
+def salt_minion_factory(salt_master_factory):
+ config_defaults = {
+ "transport": salt_master_factory.config["transport"],
+ }
+ config_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"),
+ }
+ return salt_master_factory.salt_minion_daemon(
+ random_string("graceful-stop-minion-"),
+ defaults=config_defaults,
+ overrides=config_overrides,
+ extra_cli_arguments_after_first_start_failure=["--log-level=info"],
+ )
diff --git a/tests/pytests/scenarios/graceful_stop/test_minion_graceful_stop.py b/tests/pytests/scenarios/graceful_stop/test_minion_graceful_stop.py
new file mode 100644
index 000000000000..4ec729fc55a2
--- /dev/null
+++ b/tests/pytests/scenarios/graceful_stop/test_minion_graceful_stop.py
@@ -0,0 +1,110 @@
+"""
+Scenario coverage for the minion graceful-stop fixup (issue #70050 audit).
+
+End-to-end: real master + real minion under pytest-salt-factories. Publish
+a long-running job, deliver SIGTERM to the minion process, and assert the
+master receives an aborted return *inside the graceful window* rather
+than having to wait for its own ``gather_job_timeout``.
+
+This is the user-visible fix: before the fix the master got no return at
+all, the caller saw the minion as "Not Responded", and the job hung open
+on the master's cache until timeout.
+"""
+
+import pathlib
+import time
+
+import pytest
+
+pytestmark = [
+ pytest.mark.slow_test,
+ pytest.mark.skip_on_windows(
+ reason=(
+ "POSIX SIGTERM path; Windows service graceful-stop is "
+ "covered by the pkg-tier test."
+ )
+ ),
+]
+
+
+@pytest.fixture(scope="package")
+def scenario_salt_cli(salt_master_factory):
+ """
+ Package-scoped ``salt`` CLI helper. The session-scoped ``salt_cli``
+ in ``tests/conftest.py`` targets the session ``salt_master_factory``;
+ the scenarios/daemons package supplies its own package-scoped
+ master, so we re-derive the CLI at matching scope.
+ """
+ return salt_master_factory.salt_cli()
+
+
+def _wait_for(predicate, timeout=30, interval=0.2, msg="condition"):
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ if predicate():
+ return True
+ time.sleep(interval)
+ raise AssertionError(f"timed out waiting for {msg}")
+
+
+@pytest.fixture(scope="package")
+def running_master(salt_master_factory):
+ with salt_master_factory.started(start_timeout=60):
+ yield salt_master_factory
+
+
+def test_graceful_stop_master_sees_termination_promptly(
+ running_master, salt_minion_factory, scenario_salt_cli
+):
+ """
+ 1. Start a fresh minion.
+ 2. Publish a ``test.sleep 30`` job via ``--async``, capture jid.
+ 3. Wait for the minion's proc dir to reflect the in-flight job.
+ 4. SIGTERM the minion (``.terminate()`` on the factory).
+ 5. Assert:
+ * minion exits within a bounded window (well under systemd's
+ default TimeoutStopSec of 90s -- we use 20s as a hard
+ ceiling).
+ * ``/proc/`` is empty after exit (Gap 1 + Gap 2).
+ """
+ salt_cli = scenario_salt_cli
+ with salt_minion_factory.started(start_timeout=60):
+ # Minion is up and connected. Publish a long-running job.
+ dispatch = salt_cli.run(
+ "test.sleep",
+ "30",
+ "--async",
+ minion_tgt=salt_minion_factory.id,
+ )
+ assert dispatch.returncode == 0, f"async dispatch failed: {dispatch}"
+
+ cachedir = salt_minion_factory.config["cachedir"]
+ proc_dir_path = pathlib.Path(f"{cachedir}/proc")
+ _wait_for(
+ lambda: proc_dir_path.is_dir() and any(proc_dir_path.iterdir()),
+ timeout=20,
+ msg="minion proc file for in-flight test.sleep to appear",
+ )
+
+ # Snapshot for the failure message.
+ before = sorted(p.name for p in proc_dir_path.iterdir())
+ assert before, "precondition: expected an in-flight proc file"
+
+ start = time.time()
+ salt_minion_factory.terminate()
+ elapsed = time.time() - start
+
+ # After the ``with`` block the factory has torn the minion down.
+ assert (
+ elapsed < 20
+ ), f"minion took {elapsed:.1f}s to exit on SIGTERM (target < 20s, hard-cap systemd 90s)"
+
+ remaining = (
+ sorted(p.name for p in proc_dir_path.iterdir())
+ if proc_dir_path.exists()
+ else []
+ )
+ assert not remaining, (
+ f"proc files survived graceful stop -- Gap 1/Gap 2 regression. "
+ f"Before-stop: {before!r} After-stop: {remaining!r}"
+ )
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/unit/netapi/rest_tornado/__init__.py b/tests/pytests/stress/__init__.py
similarity index 100%
rename from tests/unit/netapi/rest_tornado/__init__.py
rename to tests/pytests/stress/__init__.py
diff --git a/tests/pytests/stress/master_subprocess/__init__.py b/tests/pytests/stress/master_subprocess/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/tests/pytests/stress/master_subprocess/conftest.py b/tests/pytests/stress/master_subprocess/conftest.py
new file mode 100644
index 000000000000..3286c19e4067
--- /dev/null
+++ b/tests/pytests/stress/master_subprocess/conftest.py
@@ -0,0 +1,24 @@
+"""
+Package-level conftest for master-subprocess stress tests.
+
+These tests spawn *only* one salt-master subprocess (EventPublisher,
+MWorker, MWorkerQueue, PubServerChannel._publish_daemon, ...) against
+fake peers so we can pin per-subprocess throughput floors, memory
+ceilings, and correctness under backpressure / peer-drop / malformed
+input without paying for the full-master saltfactories setup.
+
+Kept intentionally minimal so parallel work on sibling subprocess
+suites can co-exist. Only add symbols here that are provably shared
+by more than one sub-suite.
+
+Registers a ``stress`` marker so runs can select or exclude the suite.
+"""
+
+
+def pytest_configure(config):
+ config.addinivalue_line(
+ "markers",
+ "stress: Mark test as part of the isolated master-subprocess "
+ "stress suite. Spawns exactly one master subprocess against "
+ "fake peers.",
+ )
diff --git a/tests/pytests/stress/master_subprocess/eventpublisher/__init__.py b/tests/pytests/stress/master_subprocess/eventpublisher/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/tests/pytests/stress/master_subprocess/eventpublisher/conftest.py b/tests/pytests/stress/master_subprocess/eventpublisher/conftest.py
new file mode 100644
index 000000000000..5af02e509b29
--- /dev/null
+++ b/tests/pytests/stress/master_subprocess/eventpublisher/conftest.py
@@ -0,0 +1,268 @@
+"""
+Fixture for isolated ``EventPublisher`` (EP) subprocess stress tests.
+
+Design choice — **real multiprocessing subprocess** (via
+``salt.utils.process.ProcessManager.add_process`` — the *actual* production
+entrypoint used in ``salt/channel/server.py:2857``):
+
+* Fork semantics, pickle round-trip, IOLoop lifecycle all match production.
+* RSS / FD probes run against a distinct PID, which the tests exercise.
+* EP crashes are isolated from the pytest driver process.
+* Trade-off: talking to EP requires real TCP-IPC sockets, which is exactly
+ what production does — so tests double as end-to-end socket-contract
+ tests for the EP wire protocol.
+
+The alternative (spin EP up as an asyncio task in-process on a thread)
+is faster but obscures the very failure modes we care about: subprocess
+crash, FD leak, RSS growth, subprocess-level signal handling.
+"""
+
+from __future__ import annotations
+
+import multiprocessing
+import os
+import pathlib
+import shutil
+import socket
+import time
+from dataclasses import dataclass, field
+
+import pytest
+
+import salt.channel.server
+import salt.config
+import salt.transport.base
+import salt.transport.tcp
+import salt.utils.files
+import salt.utils.process
+
+
+def _find_free_port() -> int:
+ """
+ Grab a free TCP port and immediately release it. Race-prone in
+ theory; safe in practice because the EP subprocess will bind here in
+ a few ms and we use ``SO_REUSEADDR`` in production sockets.
+ """
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+def _build_master_opts(root: pathlib.Path, overrides: dict | None = None) -> dict:
+ """
+ Return a minimal master opts dict, sufficient to bring up
+ ``MasterPubServerChannel._publish_daemon``.
+
+ We deliberately construct the opts by starting from
+ ``salt.config.master_config`` defaults with a nonexistent config
+ path (so nothing on the host bleeds in) and layering the tiny set of
+ overrides EP actually reads.
+ """
+ pki_dir = root / "pki"
+ cache_dir = root / "cache"
+ sock_dir = root / "sock"
+ for path in (pki_dir, cache_dir, sock_dir):
+ path.mkdir(parents=True, exist_ok=True)
+
+ overrides = dict(overrides or {})
+ # ``ipc_mode = ipc`` uses UNIX domain sockets for the event bus. On
+ # Linux, socket path length is capped at ~108 chars — keep sock_dir
+ # short by rooting under root/sock (not root/lots/of/nesting/sock).
+ base = {
+ "id": "stress-ep-master",
+ "root_dir": str(root),
+ "pki_dir": str(pki_dir),
+ "cachedir": str(cache_dir),
+ "sock_dir": str(sock_dir),
+ "conf_file": str(root / "master"),
+ "user": None,
+ "transport": "tcp",
+ "ipc_mode": "ipc",
+ "cluster_id": None,
+ "cluster_peers": [],
+ # We don't actually use these ports (ipc_mode=ipc uses UNIX
+ # sockets), but master_config validates them.
+ "publish_port": _find_free_port(),
+ "ret_port": _find_free_port(),
+ }
+ base.update(overrides)
+ # ``master_config()`` reads the file at path. We don't want any host
+ # config bleed-in, so drive ``apply_master_config`` directly with our
+ # overrides. This is the same call ``master_config()`` ends with,
+ # minus the include-file loading.
+ opts = salt.config.apply_master_config(overrides=base)
+ # Force our overrides — apply_master_config sometimes reshapes.
+ opts["sock_dir"] = str(sock_dir)
+ opts["pki_dir"] = str(pki_dir)
+ opts["cachedir"] = str(cache_dir)
+ opts["id"] = base["id"]
+ opts["transport"] = "tcp"
+ opts["ipc_mode"] = "ipc"
+ opts["cluster_id"] = None
+ opts["cluster_peers"] = []
+ return opts
+
+
+@dataclass
+class EPHandle:
+ """
+ Handle exposed to tests for talking to / observing the EP subprocess.
+ """
+
+ opts: dict
+ pull_path: str
+ pub_path: str
+ process: multiprocessing.Process
+ process_manager: salt.utils.process.ProcessManager
+ root: pathlib.Path
+ _stopped: bool = field(default=False)
+
+ def is_alive(self) -> bool:
+ return self.process is not None and self.process.is_alive()
+
+ def rss_bytes(self) -> int:
+ """
+ Read RSS for the EP subprocess via ``/proc//status`` — no
+ external psutil dep, Linux-only. Returns 0 if the file is
+ unavailable or the process is gone.
+ """
+ try:
+ with salt.utils.files.fopen(
+ f"/proc/{self.process.pid}/status", encoding="utf-8"
+ ) as fh:
+ for line in fh:
+ if line.startswith("VmRSS:"):
+ return int(line.split()[1]) * 1024
+ except OSError:
+ pass
+ return 0
+
+ def fd_count(self) -> int:
+ """
+ Count open FDs on the EP subprocess. Linux-specific.
+ """
+ try:
+ return len(os.listdir(f"/proc/{self.process.pid}/fd"))
+ except OSError:
+ return 0
+
+ def stop(self, timeout: float = 5.0) -> None:
+ if self._stopped:
+ return
+ self._stopped = True
+ try:
+ self.process_manager.stop_restarting()
+ self.process_manager.terminate()
+ except Exception: # pylint: disable=broad-except
+ pass
+ if self.process is not None and self.process.is_alive():
+ self.process.join(timeout=timeout)
+ if self.process.is_alive():
+ self.process.terminate()
+ self.process.join(timeout=1.0)
+ if self.process.is_alive():
+ self.process.kill()
+ self.process.join(timeout=1.0)
+
+
+def _wait_for_socket(path: str, timeout: float = 15.0) -> None:
+ """
+ Block until the UNIX socket at *path* exists AND accepts a
+ connection. ``os.path.exists`` alone races EP's ``bind`` vs
+ ``listen``.
+ """
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if os.path.exists(path):
+ try:
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ s.settimeout(0.5)
+ s.connect(path)
+ s.close()
+ return
+ except OSError:
+ pass
+ time.sleep(0.05)
+ raise RuntimeError(f"EP socket {path} did not become ready in {timeout}s")
+
+
+def _spawn_ep(opts: dict, root: pathlib.Path) -> EPHandle:
+ """
+ Spawn EP as its own subprocess using the same ProcessManager +
+ ``MasterPubServerChannel._publish_daemon`` path production uses.
+ """
+ # Instantiating MasterPubServerChannel in the *parent* process would
+ # eagerly build MasterKeys (RSA gen — expensive) and hold a copy of
+ # the transport bound in the parent's io_loop. Production's
+ # pre_fork() runs in the parent and hands the callable off to a
+ # process; the child process re-imports and re-instantiates. Do the
+ # same: build the channel here, then hand its bound method to
+ # ProcessManager.
+ channel = salt.channel.server.MasterPubServerChannel.factory(opts)
+
+ pm = salt.utils.process.ProcessManager(name="StressEP")
+ proc = pm.add_process(
+ channel._publish_daemon,
+ kwargs={},
+ name="EventPublisher",
+ )
+
+ pull_path = os.path.join(opts["sock_dir"], "master_event_pull.ipc")
+ pub_path = os.path.join(opts["sock_dir"], "master_event_pub.ipc")
+ try:
+ _wait_for_socket(pull_path)
+ _wait_for_socket(pub_path)
+ except RuntimeError:
+ # If startup failed dump child status for triage then re-raise.
+ pm.stop_restarting()
+ pm.terminate()
+ raise
+
+ return EPHandle(
+ opts=opts,
+ pull_path=pull_path,
+ pub_path=pub_path,
+ process=proc,
+ process_manager=pm,
+ root=root,
+ )
+
+
+@pytest.fixture
+def ep_root(tmp_path_factory) -> pathlib.Path:
+ """
+ Short-path scratch dir for one EP invocation. Deliberately
+ per-test-function so subprocess crashes don't poison a session-scoped
+ fixture. ``sock_dir`` inside must stay under ~90 chars (UNIX socket
+ path limit).
+ """
+ # ``tmp_path_factory`` roots under ``/tmp/pytest-of-/…`` which
+ # can be 60+ chars already — nest minimally.
+ base = tmp_path_factory.mktemp("ep", numbered=True)
+ yield base
+ # Best-effort cleanup — subprocess may still hold FDs briefly.
+ shutil.rmtree(base, ignore_errors=True)
+
+
+@pytest.fixture
+def ep_opts_overrides() -> dict:
+ """
+ Override in a test with ``@pytest.mark.parametrize`` or a nested
+ fixture to feed opts (e.g. ``publish_drain_timeout``) into the EP
+ subprocess.
+ """
+ return {}
+
+
+@pytest.fixture
+def ep(ep_root, ep_opts_overrides):
+ """
+ Spawn the EP subprocess once per test. Yields an ``EPHandle``.
+ Teardown terminates the subprocess.
+ """
+ opts = _build_master_opts(ep_root, overrides=ep_opts_overrides)
+ handle = _spawn_ep(opts, ep_root)
+ try:
+ yield handle
+ finally:
+ handle.stop()
diff --git a/tests/pytests/stress/master_subprocess/eventpublisher/test_ep_drain_timeout.py b/tests/pytests/stress/master_subprocess/eventpublisher/test_ep_drain_timeout.py
new file mode 100644
index 000000000000..f16dd41102c5
--- /dev/null
+++ b/tests/pytests/stress/master_subprocess/eventpublisher/test_ep_drain_timeout.py
@@ -0,0 +1,182 @@
+"""
+Drain-timeout regression test for the ``PubServer`` (the workhorse
+inside the EP subprocess).
+
+This runs ``PubServer.publish_payload`` in-process on a Tornado / asyncio
+loop rather than in a real EP subprocess. Rationale: the drain-timeout
+path fires when a subscriber's ``stream.write(...)`` future never
+resolves. Making that happen deterministically requires stubbing the
+subscriber's write future to be non-resolving; we can't do that against
+a real UNIX-domain socket without racing kernel-buffer autotuning. So
+we run ``PubServer`` in-process with a fake stream that returns a
+never-resolving future, and assert:
+
+* the drain task times out at ``publish_drain_timeout``;
+* ``_discard_slow_client`` runs and removes the client;
+* a well-behaved subscriber alongside it is unaffected;
+* ``PubServer`` itself is still healthy for subsequent publishes.
+
+The subprocess-level stress tests in ``test_ep_stress.py`` cover the
+end-to-end wedge regression (fast subscriber not blocked by slow).
+"""
+
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+import tornado.ioloop
+
+import salt.transport.tcp
+
+
+@pytest.fixture
+def pub_opts():
+ """
+ Minimal opts sufficient for ``PubServer`` -- it only reads
+ ``ipc_write_buffer``, ``publish_drain_timeout`` and ``ssl``.
+ """
+ return {
+ "transport": "tcp",
+ "ipc_write_buffer": None,
+ "publish_drain_timeout": 0.05,
+ }
+
+
+class _NeverResolvingStream:
+ """
+ IOStream stand-in whose ``write`` returns an ``asyncio.Future`` that
+ never resolves. Mimics a subscriber with a full kernel send buffer.
+ """
+
+ def __init__(self, name: str = "slow"):
+ self.name = name
+ self._closed = False
+ self.write_calls = 0
+
+ def closed(self):
+ return self._closed
+
+ def close(self):
+ self._closed = True
+
+ def write(self, payload):
+ self.write_calls += 1
+ return asyncio.get_event_loop().create_future()
+
+
+class _FastStream:
+ """
+ IOStream stand-in whose ``write`` returns an already-resolved
+ future. Mimics a subscriber that drains as fast as we publish.
+ """
+
+ def __init__(self, name: str = "fast"):
+ self.name = name
+ self._closed = False
+ self.writes = []
+
+ def closed(self):
+ return self._closed
+
+ def close(self):
+ self._closed = True
+
+ def write(self, payload):
+ self.writes.append(payload)
+ fut = asyncio.get_event_loop().create_future()
+ fut.set_result(None)
+ return fut
+
+
+def _make_subscriber(stream, name):
+ sub = salt.transport.tcp.Subscriber(stream, name)
+ sub.id_ = name
+ return sub
+
+
+@pytest.mark.timeout(30)
+async def test_drain_timeout_discards_slow_subscriber(pub_opts):
+ """
+ A subscriber whose write future never resolves must be discarded
+ from ``PubServer.clients`` after ``publish_drain_timeout``. A
+ parallel fast subscriber must be delivered every event and must
+ stay in the set. ``PubServer`` must not raise.
+ """
+ # publish_drain_timeout=0.05 comes from the ``pub_opts`` fixture.
+ server = salt.transport.tcp.PubServer(
+ pub_opts, io_loop=tornado.ioloop.IOLoop.current()
+ )
+ try:
+ slow_stream = _NeverResolvingStream("slow")
+ fast_stream = _FastStream("fast")
+ slow_sub = _make_subscriber(slow_stream, "slow")
+ fast_sub = _make_subscriber(fast_stream, "fast")
+ server.clients = {slow_sub, fast_sub}
+
+ # Send a burst; every write against slow gets a pending future,
+ # each of which will time out; every write against fast is
+ # already resolved.
+ for i in range(3):
+ await server.publish_payload({"idx": i})
+
+ # Wait long enough for the drain tasks against slow to fire
+ # their asyncio.TimeoutError branch (~drain_timeout).
+ deadline = 5.0
+ step = 0.02
+ elapsed = 0.0
+ while slow_sub in server.clients and elapsed < deadline:
+ await asyncio.sleep(step)
+ elapsed += step
+
+ assert slow_sub not in server.clients, (
+ "slow subscriber was not discarded after drain_timeout — "
+ "_discard_slow_client did not fire"
+ )
+ assert slow_stream.closed(), "slow stream was not closed on discard"
+ assert fast_sub in server.clients, "fast subscriber was collateral damage"
+ assert (
+ len(fast_stream.writes) == 3
+ ), f"fast subscriber missed writes: got {len(fast_stream.writes)}/3"
+
+ # Publisher must still work: fast subscriber gets another event.
+ await server.publish_payload({"idx": 99})
+ assert (
+ len(fast_stream.writes) == 4
+ ), "PubServer stopped serving after discarding slow subscriber"
+ finally:
+ server.close()
+
+
+@pytest.mark.timeout(30)
+async def test_drain_timeout_survives_hundreds_of_slow_subs(pub_opts, caplog):
+ """
+ Multiple concurrently-slow subscribers must not cause EP to crash
+ or leak. All slow subscribers should be discarded within a bounded
+ window.
+ """
+ server = salt.transport.tcp.PubServer(
+ pub_opts, io_loop=tornado.ioloop.IOLoop.current()
+ )
+ try:
+ slow_streams = [_NeverResolvingStream(f"slow{i}") for i in range(50)]
+ slow_subs = [_make_subscriber(s, s.name) for s in slow_streams]
+ server.clients = set(slow_subs)
+
+ await server.publish_payload({"idx": 0})
+
+ # All slow subs must be discarded within a modest window.
+ deadline = 5.0
+ step = 0.02
+ elapsed = 0.0
+ while server.clients and elapsed < deadline:
+ await asyncio.sleep(step)
+ elapsed += step
+
+ assert not server.clients, (
+ f"{len(server.clients)} slow subs never discarded after " f"{elapsed:.2f}s"
+ )
+ for s in slow_streams:
+ assert s.closed(), f"{s.name} stream not closed"
+ finally:
+ server.close()
diff --git a/tests/pytests/stress/master_subprocess/eventpublisher/test_ep_stress.py b/tests/pytests/stress/master_subprocess/eventpublisher/test_ep_stress.py
new file mode 100644
index 000000000000..de0732b5770f
--- /dev/null
+++ b/tests/pytests/stress/master_subprocess/eventpublisher/test_ep_stress.py
@@ -0,0 +1,596 @@
+"""
+Stress + regression tests for the salt-master ``EventPublisher`` (EP)
+subprocess in isolation.
+
+See ``conftest.py`` for the design rationale (spawn EP as a real
+multiprocessing subprocess through ``ProcessManager.add_process`` so the
+production fork path is exercised, and RSS / FD probes see a distinct
+PID).
+
+Every test:
+
+* Talks to EP over its real UNIX-domain-socket ``pull`` / ``pub``
+ channels — the fake senders / subscribers do **not** stub the wire
+ protocol.
+* Avoids ``time.sleep(N)``. Instead we deadline-poll for the event we
+ care about, or we monkeypatch a config knob (``publish_drain_timeout``)
+ down to the smallest number of ms that still hits the code we want to
+ exercise.
+* Terminates the EP subprocess on teardown. If a test wedges EP the
+ fixture will still recover by ``SIGKILL``-ing the child.
+
+Categories covered:
+
+1. Throughput floor.
+2. Multi-subscriber fan-out.
+3. Backpressure — slow subscriber gets discarded, others keep flowing.
+4. Fault injection — subscriber RST, malformed pull frame, subscriber
+ writes to pub socket.
+5. Peer-churn FD / RSS stability.
+"""
+
+from __future__ import annotations
+
+import errno
+import socket
+import struct
+import time
+
+import pytest
+
+import salt.transport.frame
+import salt.transport.tcp
+import salt.utils.msgpack
+import salt.utils.platform
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _pack_pull_frame(body) -> bytes:
+ """
+ Frame a payload the way ``salt.transport.frame.frame_msg_ipc`` does
+ for the pull socket. The receiver (``TCPPuller.handle_stream``)
+ reads the 4-byte big-endian length prefix, then that many bytes of
+ msgpack.
+ """
+ return salt.transport.frame.frame_msg_ipc(body, raw_body=True)
+
+
+def _make_event(tag: str, payload: dict) -> bytes:
+ """
+ Build an event payload shaped like production traffic: ``tag`` +
+ ``TAGEND`` sentinel + msgpack of ``payload``. ``MasterPubServerChannel.publish_payload``
+ ``SaltEvent.unpack``s this on the way in.
+ """
+ import salt.utils.event
+
+ return salt.utils.event.SaltEvent.pack(tag, payload)
+
+
+class _SyncPuller:
+ """
+ Minimal synchronous UNIX-socket client for the pull side. We use
+ plain blocking sockets so a test running in a thread can bang out
+ events at kernel speed without an ioloop.
+ """
+
+ def __init__(self, path: str):
+ self.path = path
+ self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ # Larger send buffer helps burst throughput on tests that push
+ # thousands of frames; not required for correctness.
+ try:
+ self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 1 << 20)
+ except OSError:
+ pass
+ self.sock.connect(path)
+
+ def send(self, body) -> None:
+ self.sock.sendall(_pack_pull_frame(body))
+
+ def send_raw(self, blob: bytes) -> None:
+ self.sock.sendall(blob)
+
+ def close(self) -> None:
+ try:
+ self.sock.shutdown(socket.SHUT_RDWR)
+ except OSError:
+ pass
+ try:
+ self.sock.close()
+ except OSError:
+ pass
+
+
+class _SyncSubscriber:
+ """
+ Blocking UNIX-socket subscriber for the pub side. Reads
+ length-prefixed msgpack frames as they arrive from
+ ``PubServer._stream_read`` — actually no, wait: ``PubServer`` writes
+ frames via ``salt.transport.frame.frame_msg`` which is a *plain*
+ msgpack blob, no length prefix. So we feed the raw bytes through a
+ streaming ``msgpack.Unpacker``.
+ """
+
+ def __init__(self, path: str, rcvbuf: int | None = None):
+ self.path = path
+ self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ if rcvbuf is not None:
+ try:
+ self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, rcvbuf)
+ except OSError:
+ pass
+ self.sock.connect(path)
+ self.unpacker = salt.utils.msgpack.Unpacker(raw=False)
+
+ def recv_one(self, timeout: float = 5.0):
+ """
+ Return the next framed message body, or raise ``TimeoutError``.
+ """
+ deadline = time.monotonic() + timeout
+ # Fast path: something already buffered.
+ for msg in self.unpacker:
+ return msg["body"]
+ while time.monotonic() < deadline:
+ remaining = deadline - time.monotonic()
+ self.sock.settimeout(max(0.05, remaining))
+ try:
+ chunk = self.sock.recv(65536)
+ except TimeoutError:
+ continue
+ except OSError as exc:
+ if exc.errno in (errno.EBADF, errno.ECONNRESET):
+ raise
+ continue
+ if not chunk:
+ raise ConnectionResetError("EP pub socket closed")
+ self.unpacker.feed(chunk)
+ for msg in self.unpacker:
+ return msg["body"]
+ raise TimeoutError(f"no message on {self.path} within {timeout}s")
+
+ def drain(self, n: int, timeout: float = 30.0) -> list:
+ """
+ Read exactly *n* frames. Raises TimeoutError if EP delivered
+ fewer within the deadline.
+ """
+ out = []
+ deadline = time.monotonic() + timeout
+ while len(out) < n:
+ remaining = max(0.0, deadline - time.monotonic())
+ if remaining == 0:
+ raise TimeoutError(
+ f"only received {len(out)}/{n} messages within {timeout}s"
+ )
+ out.append(self.recv_one(timeout=remaining))
+ return out
+
+ def close(self) -> None:
+ try:
+ self.sock.shutdown(socket.SHUT_RDWR)
+ except OSError:
+ pass
+ try:
+ self.sock.close()
+ except OSError:
+ pass
+
+
+def _connect_subscriber(path: str, timeout: float = 5.0, **kwargs) -> _SyncSubscriber:
+ """
+ Retry-connect until EP accepts. Handles the race where EP's
+ ``PubServer.add_socket`` hasn't fired ``handle_stream`` yet.
+ """
+ deadline = time.monotonic() + timeout
+ last_exc = None
+ while time.monotonic() < deadline:
+ try:
+ return _SyncSubscriber(path, **kwargs)
+ except OSError as exc:
+ last_exc = exc
+ time.sleep(0.02)
+ raise RuntimeError(f"could not connect subscriber to {path}: {last_exc!r}")
+
+
+def _wait_for(condition, timeout: float = 10.0, interval: float = 0.02) -> bool:
+ """
+ Poll *condition* (zero-arg callable returning truthy) until true or
+ timeout. Returns whether it went true.
+ """
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if condition():
+ return True
+ time.sleep(interval)
+ return False
+
+
+# ---------------------------------------------------------------------------
+# 1. Throughput floor
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.timeout(60)
+def test_throughput_floor_single_subscriber(ep):
+ """
+ Fire N events at EP as fast as a single blocking sender can push
+ frames; a single subscriber must receive every event with no
+ duplication and no reordering, and the effective rate must clear a
+ conservative floor.
+
+ The floor is intentionally low (500 events / sec) — the point is to
+ catch a regression that pins EP throughput at, say, 10 evt/s (the
+ kind of pathological drop the 3006.x → 3008.x
+ ``create_task``-per-frame bug caused), not to benchmark.
+ """
+ n = 5000
+ tag_prefix = "stress/ep/throughput/"
+
+ sub = _connect_subscriber(ep.pub_path)
+ # Slight settle so EP has definitely registered the subscriber.
+ assert _wait_for(ep.is_alive, timeout=2.0)
+
+ puller = _SyncPuller(ep.pull_path)
+ t0 = time.monotonic()
+ for i in range(n):
+ puller.send(_make_event(f"{tag_prefix}{i}", {"idx": i}))
+ send_elapsed = time.monotonic() - t0
+
+ received = sub.drain(n, timeout=45.0)
+ total_elapsed = time.monotonic() - t0
+ rate = n / total_elapsed
+
+ puller.close()
+ sub.close()
+
+ assert ep.is_alive(), "EP died under throughput load"
+
+ # Correctness — same count, in order, no gaps. Payloads are the
+ # raw ``load`` bytes: ``tag TAGEND msgpack(payload)``.
+ idxs = []
+ for msg in received:
+ # ``msg`` is the ``body`` value; ``publish_payload`` on the pull
+ # side receives what the sender sent, which is a ``bytes``
+ # ``SaltEvent.pack(...)``.
+ assert isinstance(msg, (bytes, bytearray, str))
+ raw = msg.encode() if isinstance(msg, str) else bytes(msg)
+ _tag_bytes, _sep, mdata = raw.partition(b"\n\n")
+ payload = salt.utils.msgpack.unpackb(mdata, raw=False)
+ idxs.append(payload["idx"])
+ assert idxs == list(range(n)), "events out of order or gaps present"
+
+ assert rate >= 500, (
+ f"EP throughput {rate:.1f} evt/s below floor 500 evt/s "
+ f"(send={send_elapsed:.2f}s total={total_elapsed:.2f}s)"
+ )
+
+
+# ---------------------------------------------------------------------------
+# 2. Multiple subscribers — every subscriber gets every event in order
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.timeout(60)
+def test_multi_subscriber_fanout(ep):
+ """
+ Attach K subscribers, fire N events, assert each subscriber received
+ exactly N events in order.
+ """
+ k = 5
+ n = 1000
+ subs = [_connect_subscriber(ep.pub_path) for _ in range(k)]
+
+ puller = _SyncPuller(ep.pull_path)
+ for i in range(n):
+ puller.send(_make_event(f"stress/ep/fanout/{i}", {"idx": i}))
+
+ # Read all subs in parallel-ish (serial is fine because each recv is
+ # bounded by the deadline).
+ per_sub_idxs: list[list[int]] = []
+ for sub in subs:
+ got = sub.drain(n, timeout=30.0)
+ idxs = []
+ for msg in got:
+ raw = msg.encode() if isinstance(msg, str) else bytes(msg)
+ _tag_bytes, _sep, mdata = raw.partition(b"\n\n")
+ payload = salt.utils.msgpack.unpackb(mdata, raw=False)
+ idxs.append(payload["idx"])
+ per_sub_idxs.append(idxs)
+
+ puller.close()
+ for sub in subs:
+ sub.close()
+
+ assert ep.is_alive()
+ for i, idxs in enumerate(per_sub_idxs):
+ assert idxs == list(range(n)), f"subscriber {i} out of order / missing"
+
+
+# ---------------------------------------------------------------------------
+# 3. Backpressure — slow subscriber
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "ep_opts_overrides",
+ # Bring the drain timeout way down so the test finishes fast, and
+ # cap the per-stream write buffer so the "slow" subscriber actually
+ # blocks EP's write future (the code path we're validating).
+ [
+ {
+ "publish_drain_timeout": 0.5,
+ "ipc_write_buffer": 64 * 1024,
+ }
+ ],
+ indirect=True,
+)
+@pytest.mark.timeout(120)
+def test_slow_subscriber_does_not_block_fast_subscriber(ep):
+ """
+ Regression for the pre-#66282 / pre-fire-and-forget-drain wedge:
+ with the OLD serial-await ``publish_payload`` loop, a single slow
+ subscriber blocks the entire broadcast loop -- every subsequent
+ ``await stream.write(...)`` for the fast subscriber serialises
+ behind the slow one's stuck write, so the fast subscriber sees
+ events only after each drain_timeout expires (per event). With
+ the fix, drains fire-and-forget: the fast subscriber gets events
+ immediately regardless of the slow subscriber's state.
+
+ We can't reliably observe EP's client set from outside (short of
+ log parsing in the subprocess), and Linux UNIX-loopback socket
+ buffers auto-tune large enough that forcing the kernel buffer to
+ fill within a test's runtime is fragile. The behaviour we CAN
+ pin here is the one that would regress the production wedge:
+ fast subscriber must receive N events in bounded wall time even
+ with a peer subscriber that never reads a byte.
+ """
+ n = 500
+ filler = b"x" * 4096 # keep the burst modest so the test is fast
+
+ # Slow subscriber: connect but never recv. Its recv-buffer + EP's
+ # per-stream write buffer will eventually saturate; the write
+ # future to it will stop resolving; the drain task will time out
+ # after 0.5s and (in prod) close the client. Whether EP actually
+ # closes it or not is not this test's concern.
+ slow = _connect_subscriber(ep.pub_path, rcvbuf=4096)
+ fast = _connect_subscriber(ep.pub_path)
+
+ puller = _SyncPuller(ep.pull_path)
+ t0 = time.monotonic()
+ for i in range(n):
+ puller.send(_make_event(f"stress/ep/slow/{i}", {"idx": i, "pad": filler}))
+ send_elapsed = time.monotonic() - t0
+
+ # Fast subscriber must drain within ~ (n / floor_rate) — well
+ # under the "n * drain_timeout" ceiling a serial-await bug would
+ # produce (which would be 500 * 0.5 = 250 s here).
+ got = fast.drain(n, timeout=30.0)
+ fan_elapsed = time.monotonic() - t0
+
+ puller.close()
+ slow.close()
+ fast.close()
+
+ assert len(got) == n
+ assert ep.is_alive(), "EP crashed under slow-subscriber load"
+ # If we ever spend > ~10s draining n=500 events, something's very
+ # wrong — production runs at ~1000+ evt/s on this size.
+ assert fan_elapsed < 10.0, (
+ f"fast subscriber took {fan_elapsed:.1f}s for {n} events "
+ f"(send phase alone was {send_elapsed:.2f}s); slow subscriber "
+ "appears to be blocking the fast one — publish_payload serial "
+ "await regression?"
+ )
+
+
+# ---------------------------------------------------------------------------
+# 4. Fault injection
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.timeout(30)
+def test_subscriber_rst_mid_stream_does_not_hang_ep(ep):
+ """
+ A subscriber connects, receives a few events, then hard-closes
+ (RST via SO_LINGER=0). EP must:
+
+ * not hang on the next publish (drain future to the dead socket
+ resolves via ``StreamClosedError`` / socket error);
+ * remove the client from ``self.clients``;
+ * keep serving other subscribers.
+ """
+ stable = _connect_subscriber(ep.pub_path)
+
+ # Killer subscriber: SO_LINGER=0 forces RST on close.
+ killer_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ killer_sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
+ killer_sock.connect(ep.pub_path)
+
+ puller = _SyncPuller(ep.pull_path)
+ # Warm-up burst — everyone gets some events.
+ for i in range(50):
+ puller.send(_make_event(f"stress/ep/rst/warmup/{i}", {"idx": i}))
+
+ # Kill.
+ try:
+ killer_sock.close()
+ except OSError:
+ pass
+
+ # Post-kill burst. ``stable`` must receive all 50 of these.
+ for i in range(50, 100):
+ puller.send(_make_event(f"stress/ep/rst/post/{i}", {"idx": i}))
+
+ got = stable.drain(100, timeout=20.0)
+ assert len(got) == 100
+
+ puller.close()
+ stable.close()
+ assert ep.is_alive(), "EP died after subscriber RST"
+
+
+@pytest.mark.timeout(30)
+def test_malformed_pull_frame_does_not_kill_ep(ep):
+ """
+ Push a garbage frame at the pull socket. ``TCPPuller.handle_stream``
+ catches per-stream exceptions but a bad length prefix that claims,
+ say, 4 GiB is a hazard — EP must not OOM or crash. A valid frame
+ that follows the bad one on a fresh connection must still be
+ delivered.
+ """
+ sub = _connect_subscriber(ep.pub_path)
+
+ # Bad frame: 4-byte length prefix of ~1 GiB followed by nothing.
+ # ``handle_stream`` will do ``read_bytes(length)`` and block; when
+ # the socket closes, ``StreamClosedError`` breaks out of the loop.
+ # EP itself must remain alive.
+ bad = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ bad.connect(ep.pull_path)
+ bad.sendall(struct.pack(">I", 1 << 30)) # claim 1 GiB
+ bad.sendall(b"\x00" * 16) # then send nothing meaningful
+ bad.close()
+
+ # Second bad frame: valid length prefix, garbage msgpack. This
+ # exercises the ``payload_handler`` exception path.
+ bad2 = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ bad2.connect(ep.pull_path)
+ junk = b"\xff\xff\xff\xff\xff\xff" # not valid msgpack
+ bad2.sendall(struct.pack(">I", len(junk)) + junk)
+ bad2.close()
+
+ # Good frame on a fresh connection — must be delivered.
+ good = _SyncPuller(ep.pull_path)
+ good.send(_make_event("stress/ep/after_bad", {"idx": 42}))
+ msg = sub.recv_one(timeout=10.0)
+
+ raw = msg.encode() if isinstance(msg, str) else bytes(msg)
+ _tag, _sep, mdata = raw.partition(b"\n\n")
+ payload = salt.utils.msgpack.unpackb(mdata, raw=False)
+ assert payload["idx"] == 42
+
+ good.close()
+ sub.close()
+ assert ep.is_alive(), "EP died on malformed pull frame"
+
+
+@pytest.mark.timeout(30)
+def test_subscriber_writes_to_pub_socket_do_not_kill_ep(ep):
+ """
+ The pub socket is a fan-out; a well-behaved subscriber only reads.
+ A misbehaved subscriber that sends bytes exercises
+ ``PubServer._stream_read`` — which *does* feed the bytes into a
+ ``msgpack.Unpacker`` and invoke ``presence_callback`` per parsed
+ frame. With the default presence callback (identity) this should be
+ a no-op. Send garbage and then valid msgpack; EP must survive and
+ continue delivering to a well-behaved subscriber.
+ """
+ good_sub = _connect_subscriber(ep.pub_path)
+
+ misbehaved = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ misbehaved.connect(ep.pub_path)
+ # 1) unstructured garbage.
+ misbehaved.sendall(b"\xff\xfe\xfd\xfc" * 8)
+ # 2) valid framed msgpack that isn't shaped like an event. This is
+ # a dict-shaped frame with only a ``body`` key so ``framed_msg["body"]``
+ # in ``_stream_read`` succeeds.
+ frame = salt.utils.msgpack.dumps({"body": {"hello": "world"}}, use_bin_type=True)
+ misbehaved.sendall(frame)
+ misbehaved.close()
+
+ # EP must still be able to publish.
+ puller = _SyncPuller(ep.pull_path)
+ puller.send(_make_event("stress/ep/after_misbehaved", {"idx": 7}))
+ msg = good_sub.recv_one(timeout=5.0)
+ raw = msg.encode() if isinstance(msg, str) else bytes(msg)
+ _tag, _sep, mdata = raw.partition(b"\n\n")
+ payload = salt.utils.msgpack.unpackb(mdata, raw=False)
+ assert payload["idx"] == 7
+
+ puller.close()
+ good_sub.close()
+ assert ep.is_alive()
+
+
+# ---------------------------------------------------------------------------
+# 5. Peer churn — FD / RSS stability
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.timeout(60)
+def test_subscriber_churn_no_fd_leak(ep):
+ """
+ Churn N subscribers through connect + disconnect. EP's FD count
+ must return to (approximately) baseline afterwards — no leak.
+
+ Regression target: the 3008.x accumulator described in
+ ``PubServer._discard_on_close`` docstring. Without the close
+ callback, each closed subscriber sits in ``self.clients`` and pins
+ its ``IOStream`` + FD until the next publish tries to write to it.
+ """
+ # Warm-up publish so all machinery is fully wired.
+ warmup_sub = _connect_subscriber(ep.pub_path)
+ puller = _SyncPuller(ep.pull_path)
+ puller.send(_make_event("stress/ep/warmup", {"i": 0}))
+ warmup_sub.recv_one(timeout=5.0)
+ warmup_sub.close()
+ puller.close()
+
+ # Give EP a beat to fully release the warmup client. This is a
+ # brief, bounded wait -- not a "sleep and hope".
+ time.sleep(0.1)
+
+ baseline_fds = ep.fd_count()
+ baseline_rss = ep.rss_bytes()
+ assert baseline_fds > 0, "could not read /proc — Linux-only test"
+
+ churn_n = 100
+ for _ in range(churn_n):
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ s.connect(ep.pub_path)
+ # Send one tiny publish through so EP's ``handle_stream`` fires
+ # + ``set_close_callback`` runs on close.
+ s.close()
+
+ # Trigger a publish so EP walks its client list — this used to be
+ # the only chance stale entries had to be discovered pre-fix.
+ puller = _SyncPuller(ep.pull_path)
+ for i in range(5):
+ puller.send(_make_event(f"stress/ep/post_churn/{i}", {"i": i}))
+ puller.close()
+
+ # Wait for FD count to settle. ``_discard_on_close`` runs via the
+ # IOLoop; give it up to 5s.
+ def _stable():
+ return ep.fd_count() <= baseline_fds + 3
+
+ ok = _wait_for(_stable, timeout=10.0)
+ fds_after = ep.fd_count()
+ rss_after = ep.rss_bytes()
+
+ assert ep.is_alive()
+ assert ok, (
+ f"EP FDs did not settle after churn: "
+ f"baseline={baseline_fds}, after={fds_after} "
+ f"(expected <= baseline+3)"
+ )
+ # RSS: allow generous growth (Python's allocator doesn't return heap
+ # to the OS aggressively). Just guard against a runaway leak.
+ #
+ # aarch64 note: glibc's per-thread malloc arenas on aarch64 default
+ # to 64 MiB each, and tornado's IOLoop / accept threads plus msgpack
+ # temporaries can pin one or two extra arenas after churn. We've
+ # observed 88-102 MiB one-shot expansion on Photon OS 5 Arm64 that
+ # does not compound across repeat churn rounds (i.e. it is cached
+ # allocator state, not a real leak). x86_64 glibc has different
+ # arena sizing and stays flat. Widen the ceiling on aarch64 so this
+ # canary catches actual runaway leaks without flagging arena caching.
+ rss_growth = rss_after - baseline_rss
+ max_growth = (
+ 200 * 1024 * 1024 if salt.utils.platform.is_aarch64() else 50 * 1024 * 1024
+ )
+ assert rss_growth < max_growth, (
+ f"EP RSS grew {rss_growth / 1024 / 1024:.1f} MiB after {churn_n}-sub "
+ f"churn — possible leak (ceiling {max_growth / 1024 / 1024:.0f} MiB)"
+ )
diff --git a/tests/pytests/stress/master_subprocess/mworker/__init__.py b/tests/pytests/stress/master_subprocess/mworker/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/tests/pytests/stress/master_subprocess/mworker/conftest.py b/tests/pytests/stress/master_subprocess/mworker/conftest.py
new file mode 100644
index 000000000000..fd58639502b3
--- /dev/null
+++ b/tests/pytests/stress/master_subprocess/mworker/conftest.py
@@ -0,0 +1,297 @@
+"""
+MWorker-only stress-test fixture.
+
+Spawns exactly one :class:`salt.master.MWorker` subprocess wired to a
+private ``ReqServerChannel`` on a dynamically-allocated TCP port, and
+gives tests a hand-rolled DEALER socket that impersonates
+``MWorkerQueue``. No full salt-master daemon is started — everything
+below the MWorker (ext-transport, PubServer, EventReturn, Maintenance,
+etc.) is absent.
+
+Design notes
+------------
+* We bind the DEALER **after** ``worker.start()`` returns so the child
+ does not inherit a live parent-side zmq socket file descriptor.
+ libzmq is not fork-safe; inheriting an open ZMQ socket into the child
+ causes silent drops.
+* The DEALER's URI is computed via
+ ``req_channel.transport.get_worker_uri(pool_name="default")`` so it
+ matches the URI the child MWorker resolves. ``MWorker.__init__``
+ defaults ``pool_name`` to ``"default"``, and
+ :func:`RequestServer.get_worker_uri` applies an ``adler32`` port
+ offset for named pools — using the bare ``tcp_master_workers`` port
+ puts the DEALER on the wrong socket and the round-trip silently
+ hangs.
+* ``opts["minimum_auth_version"] = 0`` is set so cleartext ``ping``
+ payloads (used by throughput tests) are not rejected at the
+ ReqServerChannel layer before they reach ``MWorker._handle_payload``.
+* ``opts["worker_pools_enabled"] = False`` picks the plain
+ :class:`~salt.channel.server.ReqServerChannel` path (no
+ PoolRoutingChannel), which is the only one MWorker sees in practice.
+"""
+
+import ctypes
+import logging
+import multiprocessing
+import socket
+import time
+
+import pytest
+import zmq
+import zmq.utils.monitor
+
+import salt.channel.server
+import salt.config
+import salt.crypt
+import salt.master
+import salt.payload
+import salt.utils.files
+import salt.utils.stringutils
+
+log = logging.getLogger(__name__)
+
+
+def _pick_free_port() -> int:
+ """
+ Pick a free localhost port. Uses SO_REUSEADDR + close to release
+ it immediately; the caller (a zmq bind) will re-take it.
+ """
+ with socket.socket() as s:
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+@pytest.fixture
+def _mworker_secrets():
+ """
+ Seed ``SMaster.secrets["aes"]`` for the duration of the test.
+
+ MWorker (and the ReqServerChannel it wraps) require this to be
+ present before their in-child post_fork runs.
+ """
+ prev = salt.master.SMaster.secrets.get("aes")
+ salt.master.SMaster.secrets["aes"] = {
+ "secret": multiprocessing.Array(
+ ctypes.c_char,
+ salt.utils.stringutils.to_bytes(salt.crypt.Crypticle.generate_key_string()),
+ ),
+ "serial": multiprocessing.Value(ctypes.c_longlong, lock=False),
+ "reload": salt.crypt.Crypticle.generate_key_string,
+ }
+ try:
+ yield
+ finally:
+ if prev is None:
+ salt.master.SMaster.secrets.pop("aes", None)
+ else:
+ salt.master.SMaster.secrets["aes"] = prev
+
+
+@pytest.fixture
+def mworker_opts(tmp_path):
+ """
+ Minimal master opts sufficient to instantiate an MWorker with a
+ plain non-pooled ReqServerChannel.
+ """
+ root = tmp_path / "master"
+ opts = salt.config.master_config(None)
+ opts["__role"] = "master"
+ opts["root_dir"] = str(root)
+ for name in ("cachedir", "pki_dir", "sock_dir", "conf_dir"):
+ d = root / name
+ d.mkdir(parents=True, exist_ok=True)
+ opts[name] = str(d)
+ opts["log_file"] = str(root / "master.log")
+ # Non-pooled path — MWorker + a plain ReqServerChannel over zmq TCP.
+ opts["worker_pools_enabled"] = False
+ opts["worker_threads"] = 1
+ opts["transport"] = "zeromq"
+ # TCP for the worker/queue socket so per-test parallelism does not
+ # collide on filesystem IPC paths, and to sidestep fork-inheritance
+ # oddities that IPC-on-tmpfs exhibits under repeated test runs.
+ opts["ipc_mode"] = "tcp"
+ opts["tcp_master_workers"] = _pick_free_port()
+ opts["auto_accept"] = True
+ opts["cluster_id"] = None
+ opts["fips_mode"] = False
+ # Cleartext ping traffic used by these tests is not "auth" and
+ # would otherwise be rejected by ReqServerChannel.handle_message
+ # before reaching the worker.
+ opts["minimum_auth_version"] = 0
+ # These niceness settings default to non-zero; running as non-root
+ # in CI those would emit log warnings we don't care about.
+ opts["req_server_niceness"] = None
+ opts["mworker_niceness"] = None
+ opts["pub_server_niceness"] = None
+ opts["zmq_monitor"] = False
+ opts["master_stats"] = False
+ return opts
+
+
+class MWorkerHandle:
+ """
+ Encapsulates a spawned MWorker subprocess and a client-side zmq
+ DEALER that lets tests push request payloads and read replies.
+ """
+
+ def __init__(self, opts):
+ self.opts = opts
+ self.req_channel = salt.channel.server.ReqServerChannel.factory(opts)
+ # Use the transport's own URI resolver so we bind exactly where
+ # the child MWorker's REP will connect.
+ self.w_uri = self.req_channel.transport.get_worker_uri(pool_name="default")
+ self.mkey = salt.crypt.MasterKeys(opts)
+ self._ctx = None
+ self._dealer = None
+ self._monitor = None
+ self.process = salt.master.MWorker(
+ opts,
+ self.mkey,
+ {}, # ClearFuncs.key: unused by ping/etc.
+ [self.req_channel],
+ name=f"stress-mworker-{opts['tcp_master_workers']}",
+ )
+
+ # -- lifecycle -------------------------------------------------------
+
+ def start(self, ready_timeout: float = 10.0) -> None:
+ """
+ Fork the MWorker subprocess and wait for its REP to complete a
+ handshake with our DEALER.
+ """
+ self.process.start()
+ # Bind the DEALER *after* the child fork so the child does not
+ # inherit our socket fd. We create a per-handle Context (not
+ # ``Context.instance()``) so state does not leak between tests.
+ self._ctx = zmq.Context()
+ self._dealer = self._ctx.socket(zmq.DEALER)
+ self._dealer.setsockopt(zmq.LINGER, 0)
+ # Ports we picked via ``_pick_free_port`` can briefly linger in
+ # TIME_WAIT after the previous test's DEALER closes. Retry
+ # briefly so back-to-back test runs don't flake on
+ # "Address already in use".
+ bind_deadline = time.monotonic() + 5.0
+ while True:
+ try:
+ self._dealer.bind(self.w_uri)
+ break
+ except zmq.error.ZMQError:
+ if time.monotonic() >= bind_deadline:
+ raise
+ time.sleep(0.1)
+ # A monitor socket gives us a deterministic "connected" signal
+ # instead of arbitrary time.sleep.
+ self._monitor = self._dealer.get_monitor_socket()
+ deadline = time.monotonic() + ready_timeout
+ handshake = False
+ while time.monotonic() < deadline:
+ if self._monitor.poll(200):
+ while self._monitor.poll(0):
+ try:
+ ev = zmq.utils.monitor.recv_monitor_message(self._monitor)
+ except zmq.error.Again:
+ break
+ if ev.get("event") == zmq.Event.HANDSHAKE_SUCCEEDED:
+ handshake = True
+ break
+ if handshake:
+ break
+ if not handshake:
+ raise TimeoutError(
+ f"MWorker child (pid={self.process.pid}) never completed a REP "
+ f"handshake at {self.w_uri} within {ready_timeout}s"
+ )
+
+ def stop(self, join_timeout: float = 5.0) -> None:
+ try:
+ if self._monitor is not None:
+ self._monitor.close(linger=0)
+ self._monitor = None
+ if self._dealer is not None:
+ self._dealer.close(linger=0)
+ self._dealer = None
+ finally:
+ try:
+ if self.process.is_alive():
+ self.process.terminate()
+ self.process.join(timeout=join_timeout)
+ if self.process.is_alive():
+ self.process.kill()
+ self.process.join(timeout=join_timeout)
+ finally:
+ try:
+ self.req_channel.close()
+ except Exception: # pylint: disable=broad-except
+ pass
+ if self._ctx is not None:
+ try:
+ self._ctx.term()
+ except Exception: # pylint: disable=broad-except
+ pass
+ self._ctx = None
+
+ # -- request / reply -------------------------------------------------
+
+ def send(self, payload: dict) -> None:
+ """Enqueue one raw request payload on the DEALER."""
+ raw = salt.payload.dumps(payload)
+ self._dealer.send_multipart([b"", raw])
+
+ def recv(self, timeout: float = 5.0):
+ """
+ Receive one reply from the DEALER, returning the decoded
+ payload. Raises ``TimeoutError`` when no reply arrives within
+ ``timeout`` seconds.
+ """
+ if not self._dealer.poll(int(timeout * 1000)):
+ raise TimeoutError(f"no reply within {timeout}s")
+ parts = self._dealer.recv_multipart()
+ # DEALER-to-REP framing: [empty-delimiter, payload]
+ raw = parts[-1]
+ try:
+ return salt.payload.loads(raw)
+ except Exception: # pylint: disable=broad-except
+ return raw
+
+ def send_recv(self, payload: dict, timeout: float = 5.0):
+ """One-shot send-and-wait-for-reply helper."""
+ self.send(payload)
+ return self.recv(timeout=timeout)
+
+ def send_raw(self, raw: bytes) -> None:
+ """Send arbitrary bytes (used for fault-injection tests)."""
+ self._dealer.send_multipart([b"", raw])
+
+ # -- introspection ---------------------------------------------------
+
+ @property
+ def pid(self) -> int:
+ return self.process.pid
+
+ def is_alive(self) -> bool:
+ return self.process.is_alive()
+
+ def rss_kb(self) -> int:
+ """Read the child's resident set size in kilobytes from /proc."""
+ with salt.utils.files.fopen(f"/proc/{self.pid}/status", "r") as f:
+ for line in f:
+ if line.startswith("VmRSS:"):
+ # "VmRSS: 12345 kB"
+ return int(line.split()[1])
+ raise RuntimeError(f"no VmRSS in /proc/{self.pid}/status")
+
+
+@pytest.fixture
+def mworker(_mworker_secrets, mworker_opts):
+ """
+ Fully-wired MWorker subprocess + client DEALER. Blocks until the
+ REP↔DEALER handshake completes, so tests can immediately drive
+ traffic.
+ """
+ handle = MWorkerHandle(mworker_opts)
+ handle.start(ready_timeout=15.0)
+ try:
+ yield handle
+ finally:
+ handle.stop()
diff --git a/tests/pytests/stress/master_subprocess/mworker/test_mworker_stress.py b/tests/pytests/stress/master_subprocess/mworker/test_mworker_stress.py
new file mode 100644
index 000000000000..3b0056da3d51
--- /dev/null
+++ b/tests/pytests/stress/master_subprocess/mworker/test_mworker_stress.py
@@ -0,0 +1,374 @@
+"""
+Per-subprocess stress + regression tests for :class:`salt.master.MWorker`.
+
+Runs MWorker in isolation — no full master, no minion — using the
+shared ``mworker`` fixture in ``conftest.py``. Each test exercises a
+single failure mode or throughput floor.
+
+The traffic used here is cleartext ``ping``, which reaches
+:meth:`salt.master.ClearFuncs.ping` and is echoed back. ``ping`` was
+picked because it (a) has no side effects, (b) exercises the full
+DEALER → REP → transport.handle_message → MWorker._handle_payload →
+_handle_clear → ClearFuncs.get_method → ping code path, and (c) has
+predictable, tiny payloads that keep the memory-ceiling test's signal
+crisp.
+"""
+
+import logging
+import platform
+import time
+
+import pytest
+
+log = logging.getLogger(__name__)
+
+
+pytestmark = [
+ pytest.mark.skipif(
+ platform.system() != "Linux",
+ reason="/proc//status parsing is Linux-specific; "
+ "these tests use it for the RSS-ceiling check.",
+ ),
+ pytest.mark.timeout(120),
+]
+
+
+def _ping(minion_id: str = "test-minion") -> dict:
+ """Build a benign clear-text ping payload."""
+ return {"enc": "clear", "load": {"cmd": "ping", "id": minion_id}}
+
+
+# ---------------------------------------------------------------------------
+# Sanity + throughput
+# ---------------------------------------------------------------------------
+
+
+def test_ping_roundtrip_smoke(mworker):
+ """
+ Baseline: a single clear ping goes out and its echo comes back.
+ All subsequent throughput / concurrency / fault tests depend on
+ this working.
+ """
+ reply = mworker.send_recv(_ping(), timeout=5.0)
+ assert reply == {"cmd": "ping", "id": "test-minion"}
+ assert mworker.is_alive()
+
+
+@pytest.mark.timeout(60)
+def test_throughput_floor_clear_ping(mworker):
+ """
+ Fire N sequential clear pings; assert throughput >= floor.
+
+ Floor picked low (25 req/s) so this doesn't flake on a loaded CI
+ runner or under coverage tracing, but high enough that a
+ regression that adds even ~40 ms per request (e.g. accidental
+ disk sync, mutex on hot path) trips the test. Locally on a
+ developer box this suite hits ~500 req/s.
+ """
+ n = 200
+ start = time.monotonic()
+ for i in range(n):
+ reply = mworker.send_recv(_ping(f"m-{i}"), timeout=5.0)
+ assert reply["cmd"] == "ping"
+ assert reply["id"] == f"m-{i}"
+ elapsed = time.monotonic() - start
+ rate = n / elapsed
+ log.info("ping throughput: %.1f req/s over %.2fs (n=%d)", rate, elapsed, n)
+ floor = 25.0
+ assert rate >= floor, (
+ f"MWorker clear-ping throughput {rate:.1f} req/s below floor "
+ f"{floor} req/s (n={n}, elapsed={elapsed:.2f}s)"
+ )
+
+
+@pytest.mark.timeout(60)
+def test_concurrent_requests_no_drops(mworker):
+ """
+ Pipeline K requests without awaiting each reply; then drain all K
+ replies. Every request must produce exactly one reply with the
+ corresponding minion id. Verifies MWorker + the plain
+ ReqServerChannel don't drop requests when the sender pipelines.
+
+ Note that a REP socket only accepts one outstanding request at a
+ time — so real pipelining would deadlock a REP peer. MWorker's
+ ``request_handler`` loop naturally serializes: it receives one,
+ dispatches (awaits) and replies, then loops. So this test really
+ verifies "K requests can be queued at the DEALER→REP boundary
+ without getting lost."
+ """
+ k = 50
+ for i in range(k):
+ mworker.send(_ping(f"c-{i}"))
+ seen = set()
+ for _ in range(k):
+ reply = mworker.recv(timeout=10.0)
+ seen.add(reply["id"])
+ assert seen == {f"c-{i}" for i in range(k)}, (
+ f"missing responses: expected {k}, saw {len(seen)} unique ids; "
+ f"missing={ {f'c-{i}' for i in range(k)} - seen }"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Fault injection
+# ---------------------------------------------------------------------------
+
+
+def test_malformed_msgpack_drops_and_keeps_serving(mworker):
+ """
+ Send a payload that is not valid msgpack. MWorker's transport
+ layer decodes at :meth:`RequestServer.handle_message`; a
+ ``SaltDeserializationError`` there returns ``{"msg": "bad load"}``
+ without invoking the payload handler at all. The worker must
+ survive and keep serving subsequent well-formed requests.
+ """
+ mworker.send_raw(b"\xff\xff\xff\xff not valid msgpack \x00\x01")
+ reply = mworker.recv(timeout=5.0)
+ # RequestServer.handle_message returns {"msg": "bad load"} for a
+ # deserialization failure.
+ assert isinstance(reply, dict) and reply.get("msg") == "bad load", reply
+
+ # Immediately follow up with a good request — must succeed.
+ good = mworker.send_recv(_ping("survivor"), timeout=5.0)
+ assert good == {"cmd": "ping", "id": "survivor"}
+ assert mworker.is_alive()
+
+
+def test_missing_enc_or_load_returns_bad_load(mworker):
+ """
+ ReqServerChannel.handle_message requires both ``enc`` and ``load``
+ keys. When either is absent the channel rejects the payload with
+ the literal string ``"bad load"`` (a plain, non-dict reply),
+ logging a warning; MWorker never sees the request.
+ """
+ reply = mworker.send_recv({"only_enc": "clear"}, timeout=5.0)
+ # handle_message returns the bare string "bad load" here — the
+ # transport encodes it via msgpack, so we get back the str.
+ assert reply == "bad load", reply
+
+ # And another shape: missing load.
+ reply2 = mworker.send_recv({"enc": "clear"}, timeout=5.0)
+ assert reply2 == "bad load", reply2
+
+ # Still serving.
+ assert mworker.send_recv(_ping(), timeout=5.0)["cmd"] == "ping"
+
+
+def test_unknown_clear_command_returns_empty_and_keeps_serving(mworker):
+ """
+ A well-formed clear payload whose ``cmd`` isn't in
+ ``ClearFuncs.expose_methods`` triggers the "method not exposed"
+ branch: ``_handle_clear`` returns ``({}, {"fun": "send_clear"})``,
+ which the channel serializes as the empty dict ``{}``. MWorker
+ logs the miss and keeps serving.
+ """
+ reply = mworker.send_recv(
+ {"enc": "clear", "load": {"cmd": "definitely-not-a-real-cmd", "id": "m1"}},
+ timeout=5.0,
+ )
+ assert reply == {}, reply
+
+ # A benign follow-up still works.
+ good = mworker.send_recv(_ping("after-unknown"), timeout=5.0)
+ assert good["id"] == "after-unknown"
+
+
+def test_id_with_null_byte_rejected_and_keeps_serving(mworker):
+ """
+ ``ReqServerChannel.handle_message`` explicitly rejects loads whose
+ ``id`` contains a null byte (a longstanding hardening against
+ filesystem-path injection into ``pki_dir/minions/``). MWorker
+ stays serving.
+ """
+ reply = mworker.send_recv(
+ {"enc": "clear", "load": {"cmd": "ping", "id": "bad\0id"}},
+ timeout=5.0,
+ )
+ assert reply == "bad load: id contains a null byte", reply
+
+ good = mworker.send_recv(_ping("clean-id"), timeout=5.0)
+ assert good["id"] == "clean-id"
+
+
+def test_requester_disconnect_midflight_leaves_worker_alive(
+ _mworker_secrets, mworker_opts
+):
+ """
+ Fire one request, close the DEALER without waiting for the reply,
+ then reconnect a fresh DEALER on the same URI and make sure
+ MWorker still responds. This models a minion that drops its
+ connection between "sent request" and "got reply".
+
+ We construct this from scratch (instead of reusing the ``mworker``
+ fixture) because we need to close & re-open the DEALER in the same
+ test, which the fixture's teardown otherwise owns.
+ """
+ from tests.pytests.stress.master_subprocess.mworker.conftest import MWorkerHandle
+
+ handle = MWorkerHandle(mworker_opts)
+ handle.start(ready_timeout=15.0)
+ try:
+ # Send a request, then immediately drop the socket.
+ handle.send(_ping("drop-me"))
+ handle._dealer.close(linger=0) # noqa: SLF001 — intentional
+ handle._dealer = None
+ if handle._monitor is not None:
+ handle._monitor.close(linger=0)
+ handle._monitor = None
+
+ # Give the child a beat to finish processing (and hit send
+ # failure on the reply if anything cares).
+ time.sleep(0.5)
+
+ # Verify child is still alive.
+ assert handle.is_alive(), "MWorker exited after requester disconnected"
+
+ # Reconnect a fresh DEALER and send a fresh request.
+ import zmq
+ import zmq.utils.monitor
+
+ # Use a fresh context for the new DEALER. Reusing handle._ctx
+ # can race two ways on a loaded CI runner:
+ # 1. The old DEALER's ``inproc://monitor.s-`` endpoint may
+ # still be held by the closed monitor socket; a new DEALER
+ # that recycles the same FD then hits ``Address already in
+ # use`` when ``get_monitor_socket()`` re-binds the same
+ # inproc name.
+ # 2. The closed DEALER's TCP port may still be in TIME_WAIT
+ # even with ``LINGER=0``; a same-context rebind fails.
+ # A fresh context sidesteps both — no shared FD table, no shared
+ # inproc namespace. ``handle.stop()`` still terms the old
+ # context via ``req_channel.close()``.
+ new_ctx = zmq.Context()
+ new_dealer = new_ctx.socket(zmq.DEALER)
+ new_dealer.setsockopt(zmq.LINGER, 0)
+ bind_deadline = time.monotonic() + 5.0
+ while True:
+ try:
+ new_dealer.bind(handle.w_uri)
+ break
+ except zmq.error.ZMQError:
+ if time.monotonic() >= bind_deadline:
+ raise
+ time.sleep(0.1)
+ # Swap contexts so ``handle.stop()`` tears down the new one too.
+ old_ctx = handle._ctx
+ handle._ctx = new_ctx
+ old_ctx.term()
+ handle._dealer = new_dealer
+ handle._monitor = new_dealer.get_monitor_socket()
+
+ # Wait for REP handshake to re-establish.
+ deadline = time.monotonic() + 10
+ while time.monotonic() < deadline:
+ if handle._monitor.poll(200):
+ ev = zmq.utils.monitor.recv_monitor_message(handle._monitor)
+ if ev.get("event") == zmq.Event.HANDSHAKE_SUCCEEDED:
+ break
+
+ # MWorker had already dispatched and queued the reply for the
+ # "drop-me" request before we closed the DEALER; on reconnect
+ # libzmq redelivers that queued reply to our fresh DEALER
+ # first. Drain it, then send + receive a fresh request.
+ try:
+ stale = handle.recv(timeout=2.0)
+ log.info("drained stale reply after reconnect: %r", stale)
+ except TimeoutError:
+ # Some libzmq versions do not redeliver buffered replies
+ # after a peer identity change; that is fine too.
+ log.info("no stale reply queued")
+
+ good = handle.send_recv(_ping("after-reconnect"), timeout=10.0)
+ assert good == {"cmd": "ping", "id": "after-reconnect"}
+ finally:
+ handle.stop()
+
+
+# ---------------------------------------------------------------------------
+# Memory ceiling
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.slow_test
+@pytest.mark.timeout(180)
+def test_rss_bounded_over_5000_pings(mworker):
+ """
+ Fire a burst of 5000 clear pings and verify RSS growth is bounded.
+
+ The intent is to catch a per-request leak (e.g. every ``ping``
+ accumulates something in ClearFuncs) — a burst of 5000 x tiny
+ payloads that grew MWorker's RSS by many MB would flag such a
+ regression. Real per-request working-set growth after warm-up is
+ < 1 MB on the fixtures used here; we allow 25 MB as a comfortable
+ ceiling for CI noise, coverage tracing, and Python's arena
+ fragmentation.
+
+ Marked ``slow_test`` because the burst takes 15-30 s locally and
+ longer under coverage.
+ """
+ # Warm up so first-request allocations (module load, event init) are
+ # settled before we sample the baseline RSS.
+ for i in range(50):
+ assert mworker.send_recv(_ping(f"warm-{i}"), timeout=5.0)["cmd"] == "ping"
+
+ baseline_kb = mworker.rss_kb()
+ log.info("MWorker RSS after warm-up: %d kB", baseline_kb)
+
+ n = 5000
+ t0 = time.monotonic()
+ for i in range(n):
+ reply = mworker.send_recv(_ping(f"burst-{i}"), timeout=5.0)
+ assert reply["cmd"] == "ping"
+ elapsed = time.monotonic() - t0
+
+ # Small idle so any deferred cleanup (asyncio finalizers,
+ # per-request task refs) runs before we sample.
+ time.sleep(0.5)
+
+ final_kb = mworker.rss_kb()
+ growth_kb = final_kb - baseline_kb
+ growth_mb = growth_kb / 1024.0
+ log.info(
+ "RSS after %d pings: %d kB (baseline %d, +%d kB / %.1f MB) in %.1fs",
+ n,
+ final_kb,
+ baseline_kb,
+ growth_kb,
+ growth_mb,
+ elapsed,
+ )
+ ceiling_mb = 25.0
+ assert growth_mb < ceiling_mb, (
+ f"MWorker RSS grew by {growth_mb:.1f} MB over {n} clear pings "
+ f"(baseline {baseline_kb} kB, final {final_kb} kB); ceiling {ceiling_mb} MB. "
+ f"This likely indicates a per-request leak."
+ )
+
+
+# ---------------------------------------------------------------------------
+# Backpressure — MWorker must remain responsive when the event-bus IPC
+# has no consumer. MWorker fires stats/response-time events into
+# EventPublisher via IPC; if EP isn't running, those fire_event calls
+# must NOT wedge the request handler. Our fixture never starts an EP,
+# so this test just verifies MWorker keeps serving after the request
+# handler has completed one round-trip. A tighter reproducer (blocked
+# EP that accepts a connect but never drains) belongs in the
+# EventPublisher stress suite.
+# ---------------------------------------------------------------------------
+
+
+def test_serves_requests_with_no_event_publisher_running(mworker):
+ """
+ No EventPublisher is spawned by this fixture — MWorker's
+ ``AESFuncs.event`` / ``ClearFuncs.event`` fire-event calls can
+ therefore only ever fail to deliver. Ensure that this does NOT
+ block ``_handle_payload`` from completing.
+
+ We do 100 pings (well beyond what a first-request lazy-connect
+ quirk could hide) and require every one to round-trip within the
+ per-request timeout.
+ """
+ for i in range(100):
+ reply = mworker.send_recv(_ping(f"noEP-{i}"), timeout=5.0)
+ assert reply == {"cmd": "ping", "id": f"noEP-{i}"}
+ assert mworker.is_alive()
diff --git a/tests/pytests/stress/master_subprocess/mworkerqueue/__init__.py b/tests/pytests/stress/master_subprocess/mworkerqueue/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/tests/pytests/stress/master_subprocess/mworkerqueue/conftest.py b/tests/pytests/stress/master_subprocess/mworkerqueue/conftest.py
new file mode 100644
index 000000000000..7901e6a1f7ae
--- /dev/null
+++ b/tests/pytests/stress/master_subprocess/mworkerqueue/conftest.py
@@ -0,0 +1,287 @@
+"""
+Fixtures for isolated stress tests of the salt-master MWorkerQueue subprocess.
+
+The MWorkerQueue is the zmq QUEUE proxy defined by
+``salt.transport.zeromq.RequestServer.zmq_device`` and started by
+``salt.master`` under the name ``MWorkerQueue``. It sits between:
+
+* a ROUTER socket bound to ``tcp://{interface}:{ret_port}`` where minions
+ (REQ) send authenticated request payloads, and
+* a DEALER socket bound to ``tcp://127.0.0.1:{tcp_master_workers}`` (or
+ an IPC path) where MWorker workers (REP) connect and pull work.
+
+The fixture below spawns *only* that proxy in its own OS process against
+fake peers so tests can exercise starvation, backpressure, malformed
+input, and requester churn without paying for a full master.
+
+Isolation rules
+---------------
+* The proxy is spawned with ``multiprocessing`` using the ``spawn`` start
+ method so the parent test process is not tainted by libzmq context
+ reuse.
+* Every socket the tests open (fake minion / fake worker) uses a
+ test-local ``zmq.Context`` created inside the fixture and torn down at
+ the end.
+* ``ret_port`` and ``tcp_master_workers`` are dynamically allocated free
+ TCP ports so the fixture is safe to parametrise and to run in parallel.
+* ``sock_dir`` is a per-test tempdir (only used because the proxy code
+ references it during setup; we run ``ipc_mode='tcp'`` so nothing
+ actually binds inside it).
+"""
+
+from __future__ import annotations
+
+import multiprocessing
+import os
+import socket
+import sys
+import time
+from dataclasses import dataclass, field
+
+import pytest
+import zmq
+
+import salt.utils.files
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _free_tcp_port() -> int:
+ """Return a currently-free TCP port on 127.0.0.1."""
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+def _port_open(host: str, port: int, timeout: float = 0.2) -> bool:
+ """Return True if a TCP connection to ``host:port`` succeeds."""
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.settimeout(timeout)
+ try:
+ return s.connect_ex((host, port)) == 0
+ except OSError:
+ return False
+
+
+def _build_opts(sock_dir: str, ret_port: int, worker_port: int) -> dict:
+ """Minimal opts dict accepted by ``RequestServer.zmq_device``."""
+ return {
+ "interface": "127.0.0.1",
+ "ret_port": ret_port,
+ "ipv6": False,
+ "mworker_queue_niceness": None,
+ "sock_dir": sock_dir,
+ "ipc_mode": "tcp",
+ "tcp_master_workers": worker_port,
+ "zmq_backlog": 1000,
+ "zmq_monitor": False,
+ # RequestRouter (built unconditionally inside zmq_device) reads this.
+ # A single catch-all pool keeps its validation happy without turning
+ # on the pooled code path.
+ "worker_pools": {
+ "default": {"worker_count": 1, "commands": ["*"]},
+ },
+ "worker_pools_enabled": False,
+ # RequestRouter references opts.get("id", "") for its stats key.
+ "id": "stress-mworkerqueue",
+ # Never referenced by zmq_device but touched by imports elsewhere.
+ "extension_modules": os.path.join(sock_dir, "extmods"),
+ }
+
+
+def _run_mworkerqueue(opts: dict) -> None:
+ """
+ Subprocess entrypoint: spin up a RequestServer and run its zmq_device.
+
+ Readiness is signalled implicitly by both TCP ports being connectable
+ (the parent polls with ``socket.connect_ex``). We avoid pipe-based
+ signalling because ``multiprocessing`` with ``spawn`` does not
+ guarantee that a raw fd passed via ``args`` remains valid in the
+ child (the fd number is not re-inherited across the exec that
+ ``spawn`` performs on some platforms).
+ """
+ # Import inside the child so the parent doesn't pull half the master
+ # stack (and its C extensions) until it has to.
+ import salt.transport.zeromq as _z # noqa: WPS433
+
+ server = _z.RequestServer(opts)
+ try:
+ server.zmq_device()
+ except SystemExit:
+ pass
+ except KeyboardInterrupt:
+ pass
+
+
+# ---------------------------------------------------------------------------
+# Handle exposed to tests
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class MWorkerQueueHandle:
+ """Lightweight process handle returned by the fixture."""
+
+ process: multiprocessing.Process
+ router_uri: str # minion (REQ) connects here
+ dealer_uri: str # worker (REP) connects here
+ ctx: zmq.Context
+ opts: dict
+ _sockets: list = field(default_factory=list)
+
+ # ---- peer helpers -------------------------------------------------
+
+ def minion(self, identity: bytes | None = None, linger: int = 500) -> zmq.Socket:
+ """Open a fake-minion REQ socket connected to the ROUTER port."""
+ s = self.ctx.socket(zmq.REQ)
+ if identity is not None:
+ s.setsockopt(zmq.IDENTITY, identity)
+ s.setsockopt(zmq.LINGER, linger)
+ s.setsockopt(zmq.RCVTIMEO, 5000)
+ s.setsockopt(zmq.SNDTIMEO, 5000)
+ s.connect(self.router_uri)
+ self._sockets.append(s)
+ return s
+
+ def worker(self, linger: int = 500) -> zmq.Socket:
+ """Open a fake-worker REP socket connected to the DEALER port."""
+ s = self.ctx.socket(zmq.REP)
+ s.setsockopt(zmq.LINGER, linger)
+ s.setsockopt(zmq.RCVTIMEO, 5000)
+ s.setsockopt(zmq.SNDTIMEO, 5000)
+ s.connect(self.dealer_uri)
+ self._sockets.append(s)
+ return s
+
+ # ---- lifecycle ----------------------------------------------------
+
+ def stop(self, timeout: float = 5.0) -> None:
+ for s in list(self._sockets):
+ try:
+ s.close(linger=0)
+ except Exception: # pylint: disable=broad-except
+ pass
+ self._sockets.clear()
+ if self.process.is_alive():
+ self.process.terminate()
+ self.process.join(timeout)
+ if self.process.is_alive():
+ self.process.kill()
+ self.process.join(timeout)
+ # Context terminated by fixture teardown.
+
+
+# ---------------------------------------------------------------------------
+# Pytest fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def mwq_ctx():
+ """Per-test zmq.Context — never share across tests."""
+ ctx = zmq.Context()
+ try:
+ yield ctx
+ finally:
+ ctx.destroy(linger=0)
+
+
+@pytest.fixture
+def mworkerqueue(mwq_ctx, tmp_path):
+ """
+ Spawn a single MWorkerQueue subprocess and return an
+ :class:`MWorkerQueueHandle` connected to it.
+ """
+ ret_port = _free_tcp_port()
+ worker_port = _free_tcp_port()
+ while worker_port == ret_port:
+ worker_port = _free_tcp_port()
+
+ sock_dir = str(tmp_path)
+ os.makedirs(os.path.join(sock_dir, "extmods"), exist_ok=True)
+
+ opts = _build_opts(sock_dir, ret_port, worker_port)
+
+ # ``spawn`` gives us a clean interpreter — no inherited zmq contexts.
+ ctx_mp = multiprocessing.get_context("spawn")
+ proc = ctx_mp.Process(
+ target=_run_mworkerqueue,
+ args=(opts,),
+ name="MWorkerQueue-stress",
+ daemon=True,
+ )
+ proc.start()
+
+ # Wait until both ports accept TCP connections (up to 15s).
+ deadline = time.monotonic() + 15.0
+ ready = False
+ while time.monotonic() < deadline:
+ if not proc.is_alive():
+ break
+ if _port_open("127.0.0.1", ret_port) and _port_open("127.0.0.1", worker_port):
+ ready = True
+ break
+ time.sleep(0.05)
+
+ if not ready:
+ if proc.is_alive():
+ proc.terminate()
+ proc.join(2.0)
+ raise RuntimeError(
+ f"MWorkerQueue subprocess did not become ready "
+ f"(alive={proc.is_alive()}, exitcode={proc.exitcode})"
+ )
+
+ handle = MWorkerQueueHandle(
+ process=proc,
+ router_uri=f"tcp://127.0.0.1:{ret_port}",
+ dealer_uri=f"tcp://127.0.0.1:{worker_port}",
+ ctx=mwq_ctx,
+ opts=opts,
+ )
+ try:
+ yield handle
+ finally:
+ handle.stop()
+
+
+# ---------------------------------------------------------------------------
+# Utility fixtures for tests that snapshot the child's resources.
+# ---------------------------------------------------------------------------
+
+
+def _proc_fd_count(pid: int) -> int:
+ """Number of open file descriptors held by ``pid`` (Linux only)."""
+ try:
+ return len(os.listdir(f"/proc/{pid}/fd"))
+ except (FileNotFoundError, PermissionError):
+ return -1
+
+
+def _proc_rss_kb(pid: int) -> int:
+ """RSS of ``pid`` in kilobytes (Linux only)."""
+ try:
+ with salt.utils.files.fopen(f"/proc/{pid}/status", encoding="utf-8") as fh:
+ for line in fh:
+ if line.startswith("VmRSS:"):
+ return int(line.split()[1])
+ except (FileNotFoundError, PermissionError):
+ return -1
+ return -1
+
+
+@pytest.fixture
+def proc_stats():
+ """
+ Return ``(fd_count, rss_kb)`` snapshot helpers. Skip the test on
+ non-Linux platforms where /proc is unavailable.
+ """
+ if not sys.platform.startswith("linux"):
+ pytest.skip("proc stats require /proc (Linux only)")
+
+ def _snapshot(pid: int) -> tuple[int, int]:
+ return _proc_fd_count(pid), _proc_rss_kb(pid)
+
+ return _snapshot
diff --git a/tests/pytests/stress/master_subprocess/mworkerqueue/test_mworkerqueue_stress.py b/tests/pytests/stress/master_subprocess/mworkerqueue/test_mworkerqueue_stress.py
new file mode 100644
index 000000000000..8c21f7a863b8
--- /dev/null
+++ b/tests/pytests/stress/master_subprocess/mworkerqueue/test_mworkerqueue_stress.py
@@ -0,0 +1,515 @@
+"""
+Isolated stress + regression tests for the salt-master
+:class:`MWorkerQueue` subprocess.
+
+The MWorkerQueue is a zmq ``QUEUE`` device (``ROUTER`` <-> ``DEALER``)
+that fans work from the master's public request port to any number of
+:class:`MWorker` peers. These tests spawn *only* that proxy against fake
+minion (REQ) and fake worker (REP) peers so we can pin behaviour that a
+full-master fixture obscures — throughput floors, starvation semantics,
+FD/RSS ceilings under churn, and how the proxy reacts to malformed
+input or a hung worker.
+
+Determinism
+-----------
+Every wait uses ``zmq.Poller`` with an explicit timeout or bounded
+``time.sleep`` inside a polling loop; there are no unconditional
+``time.sleep`` waits for "the thing to happen".
+
+Marking
+-------
+Every test is marked ``@pytest.mark.stress``. The slower ones
+(worker starvation with a large batch, churn, backpressure) are also
+marked ``@pytest.mark.slow_test`` (Salt's project-wide slow marker) so
+they can be selected/excluded easily via ``--run-slow``.
+"""
+
+from __future__ import annotations
+
+import gc
+import os
+import socket
+import sys
+import time
+
+import pytest
+import zmq
+
+import salt.utils.files
+
+pytestmark = pytest.mark.stress
+
+
+# ---------------------------------------------------------------------------
+# Small helpers used by multiple tests
+# ---------------------------------------------------------------------------
+
+
+def _poll_recv(sock: zmq.Socket, timeout_ms: int) -> bytes | None:
+ """Poll ``sock`` for POLLIN then recv (or return None on timeout)."""
+ poller = zmq.Poller()
+ poller.register(sock, zmq.POLLIN)
+ events = dict(poller.poll(timeout_ms))
+ if sock in events and events[sock] & zmq.POLLIN:
+ return sock.recv()
+ return None
+
+
+def _drain_and_reply(worker: zmq.Socket, timeout_ms: int) -> int:
+ """
+ Drain any pending requests on ``worker`` (REP) and echo them back.
+ Returns the number of request/reply pairs serviced.
+ """
+ n = 0
+ while True:
+ msg = _poll_recv(worker, timeout_ms)
+ if msg is None:
+ return n
+ worker.send(b"ack:" + msg)
+ n += 1
+ # Use a much shorter timeout after the first message so we return
+ # promptly when the queue drains.
+ timeout_ms = 50
+
+
+# ---------------------------------------------------------------------------
+# 1. Throughput / pass-through
+# ---------------------------------------------------------------------------
+
+
+def test_passthrough_throughput(mworkerqueue):
+ """
+ Fire N requests from K fake minions absorbed by M fake workers on the
+ other side; assert everything round-trips and rate clears a
+ conservative floor.
+ """
+ n_workers = 4
+ n_minions = 8
+ per_minion = 25 # 200 total requests
+ workers = [mworkerqueue.worker() for _ in range(n_workers)]
+ minions = [mworkerqueue.minion() for _ in range(n_minions)]
+
+ poller = zmq.Poller()
+ for w in workers:
+ poller.register(w, zmq.POLLIN)
+
+ total = n_minions * per_minion
+ sent = 0
+ replied = 0
+ start = time.monotonic()
+ # We interleave send and receive so the REQ-side FSM is happy
+ # (REQ must recv before it can send again).
+ inflight: dict[zmq.Socket, int] = {}
+ for i, m in enumerate(minions):
+ m.send(f"m{i}-0".encode())
+ inflight[m] = 0
+ sent += 1
+
+ minion_poller = zmq.Poller()
+ for m in minions:
+ minion_poller.register(m, zmq.POLLIN)
+
+ deadline = time.monotonic() + 15.0
+ while replied < total and time.monotonic() < deadline:
+ # Drain any pending work at the worker side first.
+ events = dict(poller.poll(20))
+ for w, ev in events.items():
+ if ev & zmq.POLLIN:
+ msg = w.recv()
+ w.send(b"ack:" + msg)
+
+ # Then drain minion replies and issue next request.
+ events = dict(minion_poller.poll(20))
+ for m, ev in events.items():
+ if ev & zmq.POLLIN:
+ m.recv()
+ replied += 1
+ idx = inflight[m] + 1
+ inflight[m] = idx
+ if idx < per_minion:
+ m.send(f"m{minions.index(m)}-{idx}".encode())
+ sent += 1
+
+ elapsed = time.monotonic() - start
+ assert replied == total, (
+ f"got only {replied}/{total} replies in {elapsed:.2f}s " f"(sent={sent})"
+ )
+ rate = total / elapsed
+ # Conservative floor: 200 req in 15s = 13 req/s. On a laptop we
+ # generally see 500-2000 req/s. Pinning a floor catches order-of-
+ # magnitude regressions without flaking on slow CI.
+ assert rate > 20.0, f"throughput {rate:.1f} req/s below floor"
+
+
+# ---------------------------------------------------------------------------
+# 2. Worker starvation
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.slow_test
+def test_worker_starvation_queues_and_drains(mworkerqueue):
+ """
+ With no MWorker peers, requests should queue at the DEALER (bounded
+ by libzmq's default HWM = 1000). Once a worker attaches, previously
+ queued requests must be delivered.
+ """
+ burst = 50
+ identities: list[bytes] = []
+ # Fire a burst of REQ sends from independent sockets so each has its
+ # own routing id and won't block on the REQ FSM.
+ minions = []
+ for i in range(burst):
+ m = mworkerqueue.minion(identity=f"m{i}".encode())
+ m.send(f"starvation-{i}".encode())
+ minions.append(m)
+ identities.append(f"m{i}".encode())
+
+ # Give the queue a beat to actually enqueue.
+ time.sleep(0.5)
+
+ # Now attach a single worker and pump everything through.
+ worker = mworkerqueue.worker()
+ time.sleep(0.2) # allow the DEALER to notice the new peer
+
+ served = 0
+ deadline = time.monotonic() + 20.0
+ while served < burst and time.monotonic() < deadline:
+ msg = _poll_recv(worker, 500)
+ if msg is None:
+ continue
+ worker.send(b"ack:" + msg)
+ served += 1
+
+ assert served == burst, f"only {served}/{burst} requests drained"
+
+ # And the corresponding minions must have received their replies.
+ got_replies = 0
+ poller = zmq.Poller()
+ for m in minions:
+ poller.register(m, zmq.POLLIN)
+ deadline = time.monotonic() + 5.0
+ while got_replies < burst and time.monotonic() < deadline:
+ events = dict(poller.poll(200))
+ for m, ev in events.items():
+ if ev & zmq.POLLIN:
+ m.recv()
+ got_replies += 1
+ poller.unregister(m)
+ assert got_replies == burst, f"only {got_replies}/{burst} replies delivered"
+
+
+def test_worker_starvation_bounded_by_hwm(mworkerqueue, proc_stats):
+ """
+ Even with no worker attached, the queue must not grow without
+ bound. libzmq's default HWM caps queued messages; we assert RSS
+ growth stays modest during a burst that exceeds a plausible working
+ set (2000 requests, 1 KB each).
+
+ We use a small pool of DEALER sockets (rather than 2000 REQs) so
+ the test process itself does not exhaust its file-descriptor budget
+ — DEALER is non-FSM and can pipeline many outbound frames. The
+ ROUTER-side envelope semantics are equivalent from the queue's
+ perspective.
+ """
+ pid = mworkerqueue.process.pid
+ fd0, rss0 = proc_stats(pid)
+
+ payload = b"x" * 1024
+ n = 2000
+ n_dealers = 20
+ dealers: list[zmq.Socket] = []
+ for i in range(n_dealers):
+ d = mworkerqueue.ctx.socket(zmq.DEALER)
+ d.setsockopt(zmq.LINGER, 0)
+ d.setsockopt(zmq.IDENTITY, f"hwm-d{i}".encode())
+ # Don't let the DEALER itself buffer without bound either; we
+ # want to observe *queue* behaviour, not client-side buffering.
+ d.setsockopt(zmq.SNDHWM, n)
+ d.connect(mworkerqueue.router_uri)
+ dealers.append(d)
+
+ # Give sockets a moment to complete their zmq handshakes so early
+ # sends aren't silently dropped (DEALER is fire-and-forget: any
+ # message queued before a peer is available goes to the socket's
+ # local queue up to SNDHWM).
+ time.sleep(0.2)
+
+ for i in range(n):
+ d = dealers[i % n_dealers]
+ try:
+ # DEALER: send an empty delimiter frame + payload so the
+ # ROUTER sees a REQ-shaped envelope.
+ d.send_multipart([b"", payload], flags=zmq.NOBLOCK)
+ except zmq.Again:
+ pass
+
+ # Small settling window so libzmq can move messages into its buffers.
+ time.sleep(0.5)
+
+ fd1, rss1 = proc_stats(pid)
+
+ for d in dealers:
+ d.close(linger=0)
+
+ # Sanity: process is still alive (queue didn't crash).
+ assert mworkerqueue.process.is_alive(), "MWorkerQueue died during burst"
+
+ # Bounded growth. libzmq HWM (1000) x 1 KB = 1 MB expected upper
+ # bound of enqueued payload; add generous slack for per-msg overhead
+ # and unrelated allocations. We assert < 100 MB delta because a
+ # true unbounded leak would trivially blow past that.
+ if rss0 > 0 and rss1 > 0:
+ delta_kb = rss1 - rss0
+ assert delta_kb < 100 * 1024, (
+ f"MWorkerQueue RSS grew {delta_kb} KB during starvation burst "
+ "(expected bounded by HWM)"
+ )
+
+
+# ---------------------------------------------------------------------------
+# 3. Worker misbehaviour — one hung worker doesn't stall the pipeline
+# ---------------------------------------------------------------------------
+
+
+def test_hung_worker_does_not_block_pipeline(mworkerqueue):
+ """
+ Attach two workers, one healthy and one that never replies. The
+ DEALER uses round-robin; healthy requests must still complete.
+ """
+ hung = mworkerqueue.worker() # noqa: F841 - intentionally never drained
+ healthy = mworkerqueue.worker()
+
+ # Round-robin means every second message may land on the hung
+ # worker. Fire enough that the healthy worker still services
+ # plenty; but track exactly which are serviced so we can assert.
+ minions = [mworkerqueue.minion(identity=f"h{i}".encode()) for i in range(20)]
+ for i, m in enumerate(minions):
+ m.send(f"req-{i}".encode())
+
+ # Drain healthy for up to 5s — it should get *some* requests even
+ # though the hung worker holds onto its share.
+ serviced = 0
+ deadline = time.monotonic() + 5.0
+ while time.monotonic() < deadline:
+ msg = _poll_recv(healthy, 200)
+ if msg is None:
+ continue
+ healthy.send(b"ack:" + msg)
+ serviced += 1
+ if serviced >= 5:
+ break
+
+ assert serviced >= 5, (
+ f"healthy worker only got {serviced} requests — round-robin "
+ "does not shield healthy workers from a hung peer"
+ )
+ # The proxy must still be alive after the exercise.
+ assert mworkerqueue.process.is_alive()
+
+
+# ---------------------------------------------------------------------------
+# 4. Requester churn — FD / RSS must stay bounded
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.slow_test
+def test_requester_churn_fd_bounded(mworkerqueue, proc_stats):
+ """
+ Many minion REQ sockets connect+send+recv+disconnect rapidly. Both
+ file-descriptor count and RSS must stay bounded (regression test for
+ the ROUTER-leak fix that motivated the LINGER=1000 + ROUTER_HANDOVER
+ settings on the ROUTER socket).
+
+ We do the churn in two phases and compare growth phase-over-phase:
+ libzmq's internal caches warm up during phase 1 so a modest RSS
+ bump is expected, but phase 2 must show substantially less growth.
+ A truly unbounded per-connection leak would grow phase 2 as much or
+ more than phase 1.
+ """
+ pid = mworkerqueue.process.pid
+ worker = mworkerqueue.worker()
+
+ def _churn(n_cycles: int, id_prefix: str) -> None:
+ for i in range(n_cycles):
+ m = mworkerqueue.ctx.socket(zmq.REQ)
+ m.setsockopt(zmq.LINGER, 0)
+ m.setsockopt(zmq.IDENTITY, f"{id_prefix}{i}".encode())
+ m.setsockopt(zmq.RCVTIMEO, 2000)
+ m.setsockopt(zmq.SNDTIMEO, 2000)
+ m.connect(mworkerqueue.router_uri)
+ m.send(b"churn")
+ req = _poll_recv(worker, 2000)
+ assert req is not None, f"queue stalled at {id_prefix}{i}"
+ worker.send(b"ok")
+ m.recv()
+ m.close(linger=0)
+
+ churn = 300
+
+ fd0, rss0 = proc_stats(pid)
+ _churn(churn, "warm")
+ time.sleep(0.5)
+ fd1, rss1 = proc_stats(pid)
+ _churn(churn, "meas")
+ time.sleep(0.5)
+ fd2, rss2 = proc_stats(pid)
+
+ warmup_growth = rss1 - rss0 if rss0 > 0 and rss1 > 0 else 0
+ steady_growth = rss2 - rss1 if rss1 > 0 and rss2 > 0 else 0
+
+ # FDs: the ROUTER must not accumulate one fd per disconnected peer.
+ if fd0 > 0 and fd2 > 0:
+ assert fd2 - fd0 < 20, (
+ f"FD count grew from {fd0} to {fd2} across {2 * churn} churn "
+ "cycles (possible ROUTER peer-fd leak)"
+ )
+
+ # RSS: steady-state growth (phase 2) must be a small fraction of
+ # phase 1 growth. If it's not, we're leaking per-connection state.
+ # We allow up to 25% of phase-1 growth in phase 2 (arbitrary but
+ # reflects an order-of-magnitude regression threshold), with a
+ # floor of 2 MB so tiny warmup deltas don't cause false negatives.
+ if warmup_growth > 0:
+ allowed = max(2 * 1024, warmup_growth // 4)
+ assert steady_growth < allowed, (
+ f"Steady-state RSS grew {steady_growth} KB across {churn} "
+ f"cycles (warmup phase grew {warmup_growth} KB; allowed "
+ f"steady <{allowed} KB) — possible per-connection leak"
+ )
+
+
+# ---------------------------------------------------------------------------
+# 5. Malformed input — proxy is a dumb pipe, must survive junk bytes
+# ---------------------------------------------------------------------------
+
+
+def test_malformed_input_does_not_kill_queue(mworkerqueue):
+ """
+ Open a raw TCP socket to the ROUTER port and write junk bytes. The
+ proxy is a dumb pass-through; it should either drop the connection
+ (bad zmq handshake) or forward the bytes to a worker. Either way
+ the queue subprocess must still be alive afterward and must still
+ service well-formed traffic.
+ """
+ junk_iterations = 20
+ for _ in range(junk_iterations):
+ raw = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ raw.settimeout(2.0)
+ try:
+ raw.connect(("127.0.0.1", mworkerqueue.opts["ret_port"]))
+ raw.sendall(b"\x00\xff not a zmq greeting at all \n" * 4)
+ try:
+ raw.recv(64)
+ except (TimeoutError, ConnectionResetError, OSError):
+ pass
+ finally:
+ raw.close()
+
+ # Proxy must still be alive.
+ assert mworkerqueue.process.is_alive()
+
+ # And well-formed traffic must still round-trip.
+ worker = mworkerqueue.worker()
+ minion = mworkerqueue.minion()
+ minion.send(b"still alive?")
+ req = _poll_recv(worker, 3000)
+ assert req == b"still alive?"
+ worker.send(b"yes")
+ reply = _poll_recv(minion, 3000)
+ assert reply == b"yes"
+
+
+# ---------------------------------------------------------------------------
+# 6. Slow-worker backpressure
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.slow_test
+def test_slow_worker_backpressure_bounded_memory(mworkerqueue, proc_stats):
+ """
+ A worker that drains at ~1/10th the incoming rate must not cause
+ unbounded memory growth on the queue side. We measure RSS across a
+ sustained burst and assert the growth is bounded.
+ """
+ pid = mworkerqueue.process.pid
+ worker = mworkerqueue.worker()
+
+ # Baseline snapshot.
+ gc.collect()
+ fd0, rss0 = proc_stats(pid)
+
+ # Use a small pool of DEALERs (see test_worker_starvation_bounded_by_hwm
+ # for the rationale — DEALER is non-FSM so we can pipeline all N
+ # requests from a handful of sockets without exhausting fds).
+ n = 400
+ n_dealers = 8
+ dealers: list[zmq.Socket] = []
+ for i in range(n_dealers):
+ d = mworkerqueue.ctx.socket(zmq.DEALER)
+ d.setsockopt(zmq.LINGER, 0)
+ d.setsockopt(zmq.IDENTITY, f"bp-d{i}".encode())
+ d.setsockopt(zmq.SNDHWM, n)
+ d.connect(mworkerqueue.router_uri)
+ dealers.append(d)
+ time.sleep(0.2)
+
+ for i in range(n):
+ d = dealers[i % n_dealers]
+ d.send_multipart([b"", b"x" * 512])
+
+ # Drain slowly: one message every ~5 ms → ~200 req/s target rate.
+ served = 0
+ deadline = time.monotonic() + 30.0
+ while served < n and time.monotonic() < deadline:
+ msg = _poll_recv(worker, 500)
+ if msg is None:
+ continue
+ # Simulate slow work per message.
+ time.sleep(0.005)
+ worker.send(b"a")
+ served += 1
+
+ fd1, rss1 = proc_stats(pid)
+
+ for d in dealers:
+ d.close(linger=0)
+
+ assert served == n, f"slow worker only drained {served}/{n}"
+
+ # Bounded RSS growth across the burst.
+ if rss0 > 0 and rss1 > 0:
+ assert (
+ rss1 - rss0 < 50 * 1024
+ ), f"RSS grew {rss1 - rss0} KB under slow-worker backpressure"
+
+
+# ---------------------------------------------------------------------------
+# Sanity: fixture teardown does not leak the subprocess.
+# ---------------------------------------------------------------------------
+
+
+def test_fixture_stop_terminates_subprocess(mworkerqueue):
+ """
+ Sanity check: after the fixture yields, ``stop()`` must terminate
+ the child. We invoke it explicitly here and re-check via
+ ``is_alive()``.
+ """
+ pid = mworkerqueue.process.pid
+ assert mworkerqueue.process.is_alive()
+ mworkerqueue.stop()
+ assert not mworkerqueue.process.is_alive()
+ # No orphan process left behind.
+ if sys.platform.startswith("linux"):
+ assert not os.path.exists(f"/proc/{pid}") or _proc_dead(pid)
+
+
+def _proc_dead(pid: int) -> bool:
+ """Return True if /proc/ reports a zombie or is gone."""
+ try:
+ with salt.utils.files.fopen(f"/proc/{pid}/status", encoding="utf-8") as fh:
+ for line in fh:
+ if line.startswith("State:"):
+ return "Z" in line or "X" in line
+ except FileNotFoundError:
+ return True
+ return False
diff --git a/tests/pytests/stress/master_subprocess/pubchannel/__init__.py b/tests/pytests/stress/master_subprocess/pubchannel/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/tests/pytests/stress/master_subprocess/pubchannel/conftest.py b/tests/pytests/stress/master_subprocess/pubchannel/conftest.py
new file mode 100644
index 000000000000..1b3f77d4cda9
--- /dev/null
+++ b/tests/pytests/stress/master_subprocess/pubchannel/conftest.py
@@ -0,0 +1,246 @@
+"""
+Fixtures for isolated ``PubServerChannel._publish_daemon`` stress tests.
+
+We spawn ONLY the transport's ``PublishServer.publish_daemon`` in a fresh
+subprocess (no salt-master, no auth, no crypto). The daemon binds:
+
+* one **pub** endpoint that fan-outs to attached subscribers (TCP or
+ zmq PUB); tests attach fake subscribers here to observe / stress the
+ fan-out behavior.
+* one **pull** endpoint that ingests payloads (TCP IPC or zmq PULL);
+ tests push payloads here.
+
+The fixture is parametrized by transport (``tcp``, ``zeromq``) via
+``pytest.fixture(params=...)``.
+
+The ``publish_payload`` handler in production wraps its input with the
+master's crypto material (``PubServerChannel.publish_payload`` at
+``salt/channel/server.py:1454``). For these stress tests we skip that
+wrap and use each transport's own ``publish_payload`` directly, which is
+what the daemon actually spins on in ``publisher()``. This is the
+faithful shape of the ``PubServerChannel._publish_daemon`` subprocess
+minus the crypto-transform step, and the fan-out / backpressure / drop
+mechanisms under test live entirely below the crypto step.
+"""
+
+from __future__ import annotations
+
+import multiprocessing
+import os
+import socket
+import time
+
+import pytest
+
+
+def _find_free_port() -> int:
+ """Bind to port 0, return the port, then close."""
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+# ---------------------------------------------------------------------------
+# Subprocess entrypoints (top-level so they are picklable on spawn platforms)
+# ---------------------------------------------------------------------------
+
+
+def _run_tcp_publish_daemon(opts, pub_host, pub_port, pull_host, pull_port, started):
+ """Entrypoint for the TCP publisher subprocess."""
+ # Avoid inheriting the parent process' asyncio state.
+ import salt.transport.tcp # noqa: F401 pylint: disable=import-outside-toplevel
+
+ server = salt.transport.tcp.PublishServer(
+ opts,
+ pub_host=pub_host,
+ pub_port=pub_port,
+ pull_host=pull_host,
+ pull_port=pull_port,
+ started=started,
+ )
+ # ``PublishServer.publish_payload`` forwards to ``pub_server.publish_payload``.
+ # This is the identity forwarder path used by production
+ # ``PubServerChannel._publish_daemon`` once the crypto wrap has run.
+ server.publish_daemon(server.publish_payload, started=started)
+
+
+def _run_zmq_publish_daemon(opts, pub_host, pub_port, pull_host, pull_port, started):
+ """Entrypoint for the ZeroMQ publisher subprocess."""
+ import salt.transport.zeromq # noqa: F401 pylint: disable=import-outside-toplevel
+
+ server = salt.transport.zeromq.PublishServer(
+ opts,
+ pub_host=pub_host,
+ pub_port=pub_port,
+ pull_host=pull_host,
+ pull_port=pull_port,
+ started=started,
+ )
+ server.publish_daemon(server.publish_payload, started=started)
+
+
+# ---------------------------------------------------------------------------
+# Handle returned by the fixture
+# ---------------------------------------------------------------------------
+
+
+class PublisherHandle:
+ """
+ Bundle of endpoints and process controls for a spawned publisher daemon.
+ """
+
+ def __init__(
+ self,
+ transport,
+ process,
+ pub_host,
+ pub_port,
+ pull_host,
+ pull_port,
+ opts,
+ ):
+ self.transport = transport
+ self.process = process
+ self.pub_host = pub_host
+ self.pub_port = pub_port
+ self.pull_host = pull_host
+ self.pull_port = pull_port
+ self.opts = opts
+
+ @property
+ def pid(self):
+ return self.process.pid
+
+ def is_alive(self) -> bool:
+ return self.process.is_alive()
+
+ def stop(self, timeout: float = 5.0) -> None:
+ if not self.process.is_alive():
+ return
+ self.process.terminate()
+ self.process.join(timeout=timeout)
+ if self.process.is_alive():
+ self.process.kill()
+ self.process.join(timeout=timeout)
+
+
+# ---------------------------------------------------------------------------
+# Fixture
+# ---------------------------------------------------------------------------
+
+
+_DEFAULT_OPTS = {
+ "ipv6": False,
+ "zmq_filtering": False,
+ "zmq_backlog": 1000,
+ "pub_hwm": 1000,
+ "tcp_keepalive": True,
+ "tcp_keepalive_idle": 300,
+ "tcp_keepalive_cnt": -1,
+ "tcp_keepalive_intvl": -1,
+ "tcp_master_publish_pull": None,
+ "pub_server_niceness": 0,
+ "order_masters": False,
+}
+
+
+@pytest.fixture(params=["tcp", "zeromq"])
+def transport(request):
+ return request.param
+
+
+@pytest.fixture
+def publisher_opts_overrides(request):
+ """
+ Tests can override this to tweak the opts passed to the publisher
+ subprocess (e.g. set ``pub_hwm`` low for HWM tests).
+
+ Populate via ``@pytest.mark.parametrize("publisher_opts_overrides",
+ [{"pub_hwm": 10}], indirect=True)``.
+ """
+ return getattr(request, "param", {})
+
+
+@pytest.fixture
+def publisher(transport, publisher_opts_overrides):
+ """
+ Spawn a fresh ``PublishServer.publish_daemon`` subprocess for the
+ parametrized transport and yield a :class:`PublisherHandle`.
+ """
+ pub_port = _find_free_port()
+ pull_port = _find_free_port()
+ pub_host = "127.0.0.1"
+ pull_host = "127.0.0.1"
+
+ opts = dict(_DEFAULT_OPTS)
+ opts["transport"] = transport
+ opts["publish_port"] = pub_port
+ opts["ret_port"] = pull_port
+ opts["interface"] = pub_host
+ opts["id"] = "stress-master"
+ opts["__role"] = "master"
+ opts.update(publisher_opts_overrides)
+
+ started = multiprocessing.Event()
+ ctx = multiprocessing.get_context("fork")
+
+ if transport == "tcp":
+ target = _run_tcp_publish_daemon
+ else:
+ target = _run_zmq_publish_daemon
+
+ proc = ctx.Process(
+ target=target,
+ args=(opts, pub_host, pub_port, pull_host, pull_port, started),
+ name=f"PubServerChannel._publish_daemon[{transport}]",
+ )
+ proc.start()
+
+ # Wait for the daemon to signal ready.
+ if not started.wait(timeout=15.0):
+ proc.terminate()
+ proc.join(timeout=5.0)
+ pytest.fail(f"publisher subprocess ({transport}) did not signal ready")
+
+ handle = PublisherHandle(
+ transport=transport,
+ process=proc,
+ pub_host=pub_host,
+ pub_port=pub_port,
+ pull_host=pull_host,
+ pull_port=pull_port,
+ opts=opts,
+ )
+ # Extra breathing room for pub socket to accept connections.
+ time.sleep(0.2)
+ try:
+ yield handle
+ finally:
+ handle.stop()
+
+
+# ---------------------------------------------------------------------------
+# Convenience: peer-connection helpers exposed to test modules
+# ---------------------------------------------------------------------------
+
+
+def rss_kb(pid: int) -> int:
+ """Return RSS in KiB for ``pid``; 0 if the process is gone."""
+ import salt.utils.files # pylint: disable=import-outside-toplevel
+
+ try:
+ with salt.utils.files.fopen(f"/proc/{pid}/status", encoding="utf-8") as f:
+ for line in f:
+ if line.startswith("VmRSS:"):
+ return int(line.split()[1])
+ except FileNotFoundError:
+ return 0
+ return 0
+
+
+def fd_count(pid: int) -> int:
+ """Return count of open file descriptors for ``pid``; 0 if gone."""
+ try:
+ return len(os.listdir(f"/proc/{pid}/fd"))
+ except FileNotFoundError:
+ return 0
diff --git a/tests/pytests/stress/master_subprocess/pubchannel/helpers.py b/tests/pytests/stress/master_subprocess/pubchannel/helpers.py
new file mode 100644
index 000000000000..a8bd8bbd4323
--- /dev/null
+++ b/tests/pytests/stress/master_subprocess/pubchannel/helpers.py
@@ -0,0 +1,474 @@
+"""
+Peer helpers for pubchannel stress tests.
+
+These wrap the low-level details of pushing payloads into the publisher's
+pull socket and pulling payloads out of its pub socket, for each transport
+supported by ``PublishServer``.
+
+Everything here is synchronous or lightweight-async so tests stay
+deterministic (no time.sleep-and-hope on real socket state).
+"""
+
+from __future__ import annotations
+
+import selectors
+import socket
+import struct
+import threading
+import time
+
+import zmq
+
+import salt.utils.msgpack
+
+# ---------------------------------------------------------------------------
+# TCP pusher and subscriber
+# ---------------------------------------------------------------------------
+
+
+def tcp_frame(body: bytes) -> bytes:
+ """Frame a body the way ``salt.transport.frame.frame_msg_ipc`` does."""
+ packed = salt.utils.msgpack.packb({"head": {}, "body": body}, use_bin_type=True)
+ return struct.pack(">I", len(packed)) + packed
+
+
+class TCPPusher:
+ """
+ Push framed payloads into the publisher's pull socket.
+ Blocks on ``send`` — the test is expected to drive load explicitly.
+ """
+
+ def __init__(self, host: str, port: int, timeout: float = 5.0):
+ self.host = host
+ self.port = port
+ self.timeout = timeout
+ self._sock: socket.socket | None = None
+
+ def connect(self) -> None:
+ self._sock = socket.create_connection(
+ (self.host, self.port), timeout=self.timeout
+ )
+ self._sock.settimeout(self.timeout)
+
+ def send(self, body: bytes) -> None:
+ assert self._sock is not None, "call connect() first"
+ self._sock.sendall(tcp_frame(body))
+
+ def close(self) -> None:
+ if self._sock is not None:
+ try:
+ self._sock.close()
+ finally:
+ self._sock = None
+
+ def __enter__(self):
+ self.connect()
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ self.close()
+
+
+class TCPSubscriber:
+ """
+ Fake TCP SUB. Connects to the publisher's pub socket, reads at most
+ ``max_recv_bytes`` per drain cycle, and unpacks msgpack frames the
+ publisher wrote via ``PubServer.publish_payload``.
+
+ Setting ``read_delay`` > 0 introduces a per-read pause so the socket
+ receive buffer fills and the publisher-side write buffer grows,
+ triggering the slow-subscriber drop path.
+
+ Setting ``read_delay = None`` (or calling ``stop_reading``) keeps the
+ peer connected without ever reading, which is the worst-case slow SUB.
+ """
+
+ def __init__(
+ self,
+ host: str,
+ port: int,
+ *,
+ read_delay: float = 0.0,
+ connect_timeout: float = 5.0,
+ recv_buf: int = 8192,
+ so_rcvbuf: int | None = None,
+ ):
+ self.host = host
+ self.port = port
+ self.read_delay = read_delay
+ self.connect_timeout = connect_timeout
+ self.recv_buf = recv_buf
+ self.so_rcvbuf = so_rcvbuf
+ self._sock: socket.socket | None = None
+ self._unpacker = salt.utils.msgpack.Unpacker(raw=False)
+ self._reader_thread: threading.Thread | None = None
+ self._stop = threading.Event()
+ self._reading = False
+ self.frames: list[dict] = []
+ self.recv_error: BaseException | None = None
+ self.raw_bytes_received: int = 0
+ self.closed_by_peer: bool = False
+
+ # ------------------------------------------------------------------
+ # lifecycle
+ # ------------------------------------------------------------------
+
+ def connect(self) -> None:
+ self._sock = socket.create_connection(
+ (self.host, self.port), timeout=self.connect_timeout
+ )
+ if self.so_rcvbuf is not None:
+ self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, self.so_rcvbuf)
+ self._sock.setblocking(False)
+
+ def start_reader(self) -> None:
+ assert self._sock is not None, "call connect() first"
+ self._reading = True
+ self._reader_thread = threading.Thread(
+ target=self._reader_loop, name=f"tcp-sub-{self.port}", daemon=True
+ )
+ self._reader_thread.start()
+
+ def stop_reading(self) -> None:
+ """Stop draining but keep the socket open."""
+ self._reading = False
+ if self._reader_thread is not None:
+ self._stop.set()
+ self._reader_thread.join(timeout=2.0)
+ self._reader_thread = None
+ self._stop.clear()
+
+ def close(self) -> None:
+ self._reading = False
+ self._stop.set()
+ if self._reader_thread is not None:
+ self._reader_thread.join(timeout=2.0)
+ self._reader_thread = None
+ if self._sock is not None:
+ try:
+ self._sock.close()
+ finally:
+ self._sock = None
+
+ def __enter__(self):
+ self.connect()
+ self.start_reader()
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ self.close()
+
+ # ------------------------------------------------------------------
+ # reader loop
+ # ------------------------------------------------------------------
+
+ def _reader_loop(self) -> None:
+ sel = selectors.DefaultSelector()
+ sock = self._sock
+ assert sock is not None
+ sel.register(sock, selectors.EVENT_READ)
+ try:
+ while not self._stop.is_set():
+ events = sel.select(timeout=0.1)
+ if not events:
+ continue
+ try:
+ chunk = sock.recv(self.recv_buf)
+ except BlockingIOError:
+ continue
+ except OSError as exc:
+ self.recv_error = exc
+ self.closed_by_peer = True
+ break
+ if not chunk:
+ # Peer half-closed the connection.
+ self.closed_by_peer = True
+ break
+ self.raw_bytes_received += len(chunk)
+ self._unpacker.feed(chunk)
+ for frame in self._unpacker:
+ self.frames.append(frame)
+ if self.read_delay:
+ time.sleep(self.read_delay)
+ finally:
+ try:
+ sel.unregister(sock)
+ except Exception: # pylint: disable=broad-except
+ pass
+
+ # ------------------------------------------------------------------
+ # probes tests use
+ # ------------------------------------------------------------------
+
+ def wait_for_frames(self, n: int, timeout: float = 5.0) -> bool:
+ """Return True when we have >= ``n`` frames or timeout."""
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if len(self.frames) >= n:
+ return True
+ time.sleep(0.02)
+ return len(self.frames) >= n
+
+ def socket_thinks_connected(self) -> bool:
+ """
+ Best-effort check: from the SUB side, does the socket look alive?
+
+ This is the crucial "is the subscriber told it was dropped?" probe.
+ In the production bug, the master drops the SUB but the OS-level
+ socket state observable from the subscriber is often unchanged
+ for an extended window (no FIN if the master half of the socket
+ was silently reused / write-side stalled).
+ """
+ if self._sock is None:
+ return False
+ try:
+ self._sock.getpeername()
+ except OSError:
+ return False
+ return not self.closed_by_peer
+
+
+# ---------------------------------------------------------------------------
+# ZMQ pusher and subscriber
+# ---------------------------------------------------------------------------
+
+
+class ZMQPusher:
+ """PUSH client into the publisher's PULL socket."""
+
+ def __init__(self, host: str, port: int):
+ self.host = host
+ self.port = port
+ self._ctx: zmq.Context | None = None
+ self._sock: zmq.Socket | None = None
+
+ def connect(self) -> None:
+ self._ctx = zmq.Context()
+ self._sock = self._ctx.socket(zmq.PUSH)
+ # LINGER long enough that a burst-and-close sequence still
+ # actually delivers. ``close(0)`` drops un-flushed messages,
+ # which caused tests to see 0/N frames on ephemeral pushers.
+ self._sock.setsockopt(zmq.LINGER, 2000)
+ # Give PUSH a moment to detect a not-yet-attached PULL as
+ # unavailable and try again after connect completes.
+ self._sock.setsockopt(zmq.SNDTIMEO, 5000)
+ self._sock.connect(f"tcp://{self.host}:{self.port}")
+
+ def send(self, body: bytes) -> None:
+ assert self._sock is not None, "call connect() first"
+ self._sock.send(body)
+
+ def close(self) -> None:
+ if self._sock is not None:
+ # LINGER (set at connect time) governs the actual close.
+ self._sock.close()
+ self._sock = None
+ if self._ctx is not None:
+ self._ctx.destroy()
+ self._ctx = None
+
+ def __enter__(self):
+ self.connect()
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ self.close()
+
+
+class ZMQSubscriber:
+ """SUB client from the publisher's PUB socket."""
+
+ def __init__(
+ self,
+ host: str,
+ port: int,
+ *,
+ read_delay: float = 0.0,
+ rcvhwm: int | None = None,
+ so_rcvbuf: int | None = None,
+ ):
+ self.host = host
+ self.port = port
+ self.read_delay = read_delay
+ self.rcvhwm = rcvhwm
+ self.so_rcvbuf = so_rcvbuf
+ self._ctx: zmq.Context | None = None
+ self._sock: zmq.Socket | None = None
+ self._monitor_sock: zmq.Socket | None = None
+ self._reader_thread: threading.Thread | None = None
+ self._monitor_thread: threading.Thread | None = None
+ self._stop = threading.Event()
+ self._reading = False
+ self.frames: list[bytes] = []
+ self.disconnected_by_peer = False
+
+ def connect(self) -> None:
+ self._ctx = zmq.Context()
+ self._sock = self._ctx.socket(zmq.SUB)
+ self._sock.setsockopt(zmq.LINGER, 1)
+ if self.rcvhwm is not None:
+ self._sock.setsockopt(zmq.RCVHWM, self.rcvhwm)
+ if self.so_rcvbuf is not None:
+ self._sock.setsockopt(zmq.RCVBUF, self.so_rcvbuf)
+ self._sock.setsockopt(zmq.SUBSCRIBE, b"")
+ # Wire up socket monitor so the test can observe whether the
+ # SUB ever sees a disconnect notification from the master.
+ try:
+ monitor_endpoint = f"inproc://monitor-sub-{id(self)}"
+ self._sock.monitor(monitor_endpoint, zmq.EVENT_DISCONNECTED)
+ self._monitor_sock = self._ctx.socket(zmq.PAIR)
+ self._monitor_sock.connect(monitor_endpoint)
+ self._monitor_thread = threading.Thread(
+ target=self._monitor_loop,
+ name=f"zmq-sub-monitor-{self.port}",
+ daemon=True,
+ )
+ self._monitor_thread.start()
+ except zmq.ZMQError:
+ # monitor() may fail on old libzmq — non-fatal for tests.
+ self._monitor_sock = None
+ self._sock.connect(f"tcp://{self.host}:{self.port}")
+
+ def start_reader(self) -> None:
+ assert self._sock is not None, "call connect() first"
+ self._reading = True
+ self._reader_thread = threading.Thread(
+ target=self._reader_loop, name=f"zmq-sub-{self.port}", daemon=True
+ )
+ self._reader_thread.start()
+
+ def stop_reading(self) -> None:
+ self._reading = False
+ if self._reader_thread is not None:
+ self._stop.set()
+ self._reader_thread.join(timeout=2.0)
+ self._reader_thread = None
+ self._stop.clear()
+
+ def _reader_loop(self) -> None:
+ assert self._sock is not None
+ poller = zmq.Poller()
+ poller.register(self._sock, zmq.POLLIN)
+ try:
+ while not self._stop.is_set():
+ events = dict(poller.poll(timeout=100))
+ if self._sock in events:
+ try:
+ msg = self._sock.recv(zmq.NOBLOCK)
+ except zmq.Again:
+ continue
+ except zmq.ZMQError:
+ break
+ self.frames.append(msg)
+ if self.read_delay:
+ time.sleep(self.read_delay)
+ finally:
+ poller.unregister(self._sock)
+
+ def _monitor_loop(self) -> None:
+ assert self._monitor_sock is not None
+ poller = zmq.Poller()
+ poller.register(self._monitor_sock, zmq.POLLIN)
+ try:
+ while not self._stop.is_set():
+ events = dict(poller.poll(timeout=100))
+ if self._monitor_sock in events:
+ try:
+ # event msgpart 1: event_number + value
+ # event msgpart 2: endpoint
+ parts = self._monitor_sock.recv_multipart(zmq.NOBLOCK)
+ except zmq.Again:
+ continue
+ except zmq.ZMQError:
+ break
+ # Any DISCONNECTED event fires this flag.
+ if parts:
+ self.disconnected_by_peer = True
+ finally:
+ try:
+ poller.unregister(self._monitor_sock)
+ except Exception: # pylint: disable=broad-except
+ pass
+
+ def socket_thinks_connected(self) -> bool:
+ """
+ Best-effort: has the SUB seen a DISCONNECTED event from libzmq?
+
+ This is the SUB's ONLY signal that the master has dropped it —
+ and in the HWM-drop path, no such event fires (the connection
+ stays open; only application-layer messages are silently
+ discarded).
+ """
+ return not self.disconnected_by_peer and self._sock is not None
+
+ def close(self) -> None:
+ self._reading = False
+ self._stop.set()
+ if self._reader_thread is not None:
+ self._reader_thread.join(timeout=2.0)
+ self._reader_thread = None
+ if self._monitor_thread is not None:
+ self._monitor_thread.join(timeout=2.0)
+ self._monitor_thread = None
+ if self._monitor_sock is not None:
+ self._monitor_sock.close(0)
+ self._monitor_sock = None
+ if self._sock is not None:
+ self._sock.close(0)
+ self._sock = None
+ if self._ctx is not None:
+ self._ctx.destroy(0)
+ self._ctx = None
+
+ def __enter__(self):
+ self.connect()
+ self.start_reader()
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ self.close()
+
+ def wait_for_frames(self, n: int, timeout: float = 5.0) -> bool:
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if len(self.frames) >= n:
+ return True
+ time.sleep(0.02)
+ return len(self.frames) >= n
+
+
+# ---------------------------------------------------------------------------
+# Transport-agnostic helpers
+# ---------------------------------------------------------------------------
+
+
+def make_pusher(publisher):
+ if publisher.transport == "tcp":
+ return TCPPusher(publisher.pull_host, publisher.pull_port)
+ return ZMQPusher(publisher.pull_host, publisher.pull_port)
+
+
+def make_subscriber(publisher, *, read_delay: float = 0.0, **kwargs):
+ if publisher.transport == "tcp":
+ return TCPSubscriber(
+ publisher.pub_host, publisher.pub_port, read_delay=read_delay, **kwargs
+ )
+ return ZMQSubscriber(
+ publisher.pub_host, publisher.pub_port, read_delay=read_delay, **kwargs
+ )
+
+
+def subscriber_frame_count(sub) -> int:
+ return len(sub.frames)
+
+
+def extract_body(frame) -> bytes:
+ """
+ Normalize a received frame to its ``body`` bytes.
+
+ * TCP subscribers see ``{"head": {}, "body": }`` msgpack dicts.
+ * ZMQ subscribers see the raw body bytes.
+ """
+ if isinstance(frame, dict):
+ return frame[b"body"] if b"body" in frame else frame["body"]
+ return frame
diff --git a/tests/pytests/stress/master_subprocess/pubchannel/test_fault_injection.py b/tests/pytests/stress/master_subprocess/pubchannel/test_fault_injection.py
new file mode 100644
index 000000000000..bf4cb680143d
--- /dev/null
+++ b/tests/pytests/stress/master_subprocess/pubchannel/test_fault_injection.py
@@ -0,0 +1,188 @@
+"""
+Fault-injection: publisher must survive a variety of misbehaving peers
+and misbehaving input.
+"""
+
+from __future__ import annotations
+
+import socket
+import struct
+import time
+
+import pytest
+import zmq
+
+from tests.pytests.stress.master_subprocess.pubchannel.helpers import (
+ make_pusher,
+ make_subscriber,
+)
+
+
+@pytest.mark.timeout(30)
+def test_subscriber_rst_mid_stream_is_survived(publisher):
+ """
+ A subscriber that abruptly resets its TCP connection mid-stream must
+ not crash the publisher, and the publisher must clean up its own
+ ``clients`` set entry within a bounded time.
+ """
+ # A "clean" subscriber, running throughout, to confirm the publisher
+ # keeps serving after the RST.
+ clean = make_subscriber(publisher)
+ clean.connect()
+ clean.start_reader()
+ time.sleep(0.3)
+
+ # Second subscriber uses a raw TCP socket so we can RST it via
+ # SO_LINGER=0+close. For zmq this attaches at the wire level
+ # (libzmq will still see FIN, not RST, unless SO_LINGER=0).
+ victim = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ victim.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
+ victim.connect((publisher.pub_host, publisher.pub_port))
+ time.sleep(0.3)
+
+ try:
+ with make_pusher(publisher) as pusher:
+ # Publish a few events so both peers are in the write path.
+ for i in range(20):
+ pusher.send(f"before-rst-{i}".encode())
+ time.sleep(0.3)
+ # RST the victim.
+ victim.close()
+ # Publish more.
+ for i in range(20):
+ pusher.send(f"after-rst-{i}".encode())
+ assert clean.wait_for_frames(
+ 40, timeout=10.0
+ ), f"clean subscriber only got {len(clean.frames)}/40 after peer RST"
+ assert publisher.is_alive()
+ finally:
+ clean.close()
+
+
+@pytest.mark.timeout(30)
+def test_publisher_survives_garbage_bytes_from_subscriber(publisher):
+ """
+ A subscriber that writes garbage bytes back on its receive socket
+ must not kill the publisher.
+
+ The tcp PubServer runs ``_stream_read`` per client that unpacks
+ incoming bytes via ``msgpack.Unpacker`` and calls ``presence_callback``.
+ Malformed / garbage input must be logged and the publisher must
+ keep servicing everyone else.
+
+ For zmq the SUB socket is unidirectional (server writes only), so
+ this test is a no-op there.
+ """
+ if publisher.transport != "tcp":
+ pytest.skip("tcp-only test — zmq SUB is receive-only")
+
+ clean = make_subscriber(publisher)
+ clean.connect()
+ clean.start_reader()
+
+ garbage_peer = socket.create_connection(
+ (publisher.pub_host, publisher.pub_port), timeout=5.0
+ )
+ time.sleep(0.3)
+
+ try:
+ # Write bytes that don't msgpack-parse as ``{"body": ...}``.
+ garbage_peer.sendall(b"\xff" * 4096 + b"not-msgpack" * 100)
+ garbage_peer.sendall(b"\x00" * 8192)
+ time.sleep(0.5)
+
+ # The publisher must still be alive and serving the clean SUB.
+ with make_pusher(publisher) as pusher:
+ for i in range(20):
+ pusher.send(f"after-garbage-{i}".encode())
+ assert clean.wait_for_frames(
+ 20, timeout=10.0
+ ), f"clean subscriber got only {len(clean.frames)}/20 after garbage input"
+ assert publisher.is_alive()
+ finally:
+ try:
+ garbage_peer.close()
+ except OSError:
+ pass
+ clean.close()
+
+
+@pytest.mark.timeout(30)
+def test_publisher_survives_malformed_pull_input(publisher):
+ """
+ The pull socket ingests msgpack-framed payloads from
+ ``PubServerChannel.publish_payload``. If a caller pushes malformed
+ bytes into that socket, the publisher must log-and-drop instead of
+ dying.
+ """
+ clean = make_subscriber(publisher)
+ clean.connect()
+ clean.start_reader()
+ time.sleep(0.3)
+
+ try:
+ if publisher.transport == "tcp":
+ # Send bytes that DON'T parse as ``frame_msg_ipc`` output.
+ # A silly 4-byte "length" claiming 4 GiB followed by junk
+ # will make the puller wait for bytes it will never read,
+ # but the puller catches ``OSError`` / ``StreamClosedError``
+ # and just closes the client stream — it must NOT crash.
+ bad = socket.create_connection(
+ (publisher.pull_host, publisher.pull_port), timeout=5.0
+ )
+ bad.sendall(b"\xff\xff\xff\xff") # length: 4 GiB
+ bad.sendall(b"garbage-msgpack" * 100)
+ bad.close()
+ else:
+ # zmq: push a raw non-msgpack payload. The publisher's
+ # ``publish_payload`` will forward it to SUBs verbatim in
+ # unfiltered mode; there's no "malformed" from ZMQ's POV.
+ # Instead push an empty message which some frames don't
+ # tolerate.
+ ctx = zmq.Context()
+ sock = ctx.socket(zmq.PUSH)
+ sock.setsockopt(zmq.LINGER, 1)
+ sock.connect(f"tcp://{publisher.pull_host}:{publisher.pull_port}")
+ sock.send(b"")
+ sock.close(0)
+ ctx.destroy(0)
+
+ time.sleep(0.5)
+
+ # Publisher must still serve real traffic.
+ with make_pusher(publisher) as pusher:
+ for i in range(20):
+ pusher.send(f"after-malformed-{i}".encode())
+ assert clean.wait_for_frames(
+ 20, timeout=10.0
+ ), f"clean subscriber got {len(clean.frames)}/20 after malformed input"
+ assert publisher.is_alive()
+ finally:
+ clean.close()
+
+
+@pytest.mark.timeout(30)
+def test_publisher_survives_immediate_client_disconnect(publisher):
+ """
+ Repeatedly connect + immediately close. Publisher must not leak
+ ``clients`` set entries indefinitely and must stay alive.
+ """
+ from tests.pytests.stress.master_subprocess.pubchannel.conftest import fd_count
+
+ fd_before = fd_count(publisher.pid)
+ for _ in range(200):
+ s = socket.create_connection(
+ (publisher.pub_host, publisher.pub_port), timeout=5.0
+ )
+ s.close()
+ time.sleep(1.0)
+ fd_after = fd_count(publisher.pid)
+
+ # Publisher stayed alive.
+ assert publisher.is_alive(), "publisher died after 200 connect+close cycles"
+ # FD growth from 200 connect-and-drop cycles should be small.
+ # Allow a generous slop for tornado's connect+cleanup timing.
+ fd_growth = fd_after - fd_before
+ assert (
+ fd_growth < 50
+ ), f"FD count grew by {fd_growth} across 200 connect+close cycles"
diff --git a/tests/pytests/stress/master_subprocess/pubchannel/test_hwm.py b/tests/pytests/stress/master_subprocess/pubchannel/test_hwm.py
new file mode 100644
index 000000000000..34295b51d39d
--- /dev/null
+++ b/tests/pytests/stress/master_subprocess/pubchannel/test_hwm.py
@@ -0,0 +1,70 @@
+"""
+Zeromq HWM behavior: verify ``pub_hwm`` actually caps the per-subscriber
+outgoing queue on the master.
+
+For a PUB socket the default HWM behavior is *silent drop* — this test
+pins that behavior so a change from drop-to-block (which would wedge
+the entire publisher loop) shows up immediately in CI.
+"""
+
+from __future__ import annotations
+
+import time
+
+import pytest
+
+from tests.pytests.stress.master_subprocess.pubchannel.helpers import (
+ make_pusher,
+ make_subscriber,
+)
+
+
+@pytest.mark.timeout(60)
+@pytest.mark.parametrize("publisher_opts_overrides", [{"pub_hwm": 10}], indirect=True)
+def test_zmq_pub_hwm_caps_queue_via_silent_drop(publisher):
+ """
+ With ``pub_hwm=10`` the PUB drops silently instead of blocking.
+
+ Setup:
+ * Attach a SUB but do NOT drain it — its OS buffer plus zmq
+ RCVHWM fill quickly.
+ * Push 500 events of 4 KiB each.
+ * Publisher does NOT wedge (pushes complete quickly).
+ * Publisher stays alive.
+
+ The zmq PUB socket documented behavior is
+ ``ZMQ_XPUB_NODROP=0`` (default): silently drop when SNDHWM is
+ reached. If someone flips this in Salt, ``pusher.send()`` calls
+ would start blocking indefinitely and this test would time out.
+ """
+ if publisher.transport != "zeromq":
+ pytest.skip("zmq-only HWM test")
+
+ n_events = 500
+ payload = b"x" * 4096
+
+ stalled = make_subscriber(publisher, rcvhwm=10)
+ stalled.connect()
+ # DO NOT start_reader — the SUB stays stalled.
+ time.sleep(0.5) # settle subscription
+
+ try:
+ with make_pusher(publisher) as pusher:
+ start = time.monotonic()
+ for i in range(n_events):
+ pusher.send(payload + f"-{i}".encode())
+ elapsed = time.monotonic() - start
+
+ # If PUB were blocking on HWM, this would take much longer than
+ # a normal burst. On my dev box 500 4KiB events over the pull
+ # socket completes in <0.5 s even when PUB is dropping. Give
+ # ourselves generous headroom for CI — 10 s is way more than a
+ # non-blocking burst but way less than a blocking one (which
+ # would hit the 60 s test timeout).
+ assert (
+ elapsed < 10.0
+ ), f"push burst took {elapsed:.1f}s — HWM policy may have changed to block"
+ # Publisher stays alive.
+ assert publisher.is_alive(), "publisher died under HWM pressure"
+ finally:
+ stalled.close()
diff --git a/tests/pytests/stress/master_subprocess/pubchannel/test_peer_churn.py b/tests/pytests/stress/master_subprocess/pubchannel/test_peer_churn.py
new file mode 100644
index 000000000000..c6ec6de36910
--- /dev/null
+++ b/tests/pytests/stress/master_subprocess/pubchannel/test_peer_churn.py
@@ -0,0 +1,77 @@
+"""
+Peer churn: 100 subscribers connect + drain a few + disconnect in a
+loop. Publisher FD count and RSS must stay bounded.
+"""
+
+from __future__ import annotations
+
+import time
+
+import pytest
+
+import salt.utils.platform
+from tests.pytests.stress.master_subprocess.pubchannel.conftest import fd_count, rss_kb
+from tests.pytests.stress.master_subprocess.pubchannel.helpers import (
+ make_pusher,
+ make_subscriber,
+)
+
+
+@pytest.mark.timeout(90)
+def test_peer_churn_bounded_fd_and_rss(publisher):
+ """
+ 100 rounds of {connect, receive a couple frames, disconnect}.
+
+ The publisher's FD count must be ~constant across rounds (bounded
+ ceiling, not linear growth), and RSS must not grow more than a few
+ MiB — resource-leak canary for either transport's subscriber
+ lifecycle path.
+ """
+ baseline_fd = fd_count(publisher.pid)
+ baseline_rss = rss_kb(publisher.pid)
+
+ with make_pusher(publisher) as pusher:
+ # Steady-state background publishing so churning peers actually
+ # exercise the write / drop path, not just accept/close.
+ for round_ in range(100):
+ sub = make_subscriber(publisher)
+ sub.connect()
+ sub.start_reader()
+ # Push a couple messages so the peer is added to
+ # ``pub_server.clients`` and the fast path runs.
+ for i in range(3):
+ pusher.send(f"round-{round_}-{i}".encode())
+ # Let the subscriber receive at least one before we drop it
+ # — otherwise on zmq PUB slow-joiner it may never see any.
+ sub.wait_for_frames(1, timeout=1.0)
+ sub.close()
+
+ # Give the publisher a beat to garbage-collect its ``clients`` set.
+ time.sleep(1.5)
+
+ final_fd = fd_count(publisher.pid)
+ final_rss = rss_kb(publisher.pid)
+
+ fd_growth = final_fd - baseline_fd
+ rss_growth_kb = final_rss - baseline_rss
+
+ assert publisher.is_alive(), "publisher died after 100 peer-churn rounds"
+ # FD ceiling: absolute number, not per-round. Tornado holds a
+ # handful of FDs per accept/close cycle briefly; 30 is plenty of
+ # slack for either transport.
+ assert fd_growth < 30, f"FD count grew by {fd_growth} across 100 peer-churn rounds"
+ # RSS: 100 churn rounds shouldn't cost more than a few MiB. 25 MiB
+ # is generous on x86_64.
+ #
+ # aarch64 note: glibc's per-thread malloc arenas on aarch64 default
+ # to 64 MiB each; tornado / zmq / msgpack allocations across the
+ # churn loop can pin one or two extra arenas that never get returned
+ # to the OS. We've observed 88-102 MiB one-shot expansion on Photon
+ # OS 5 Arm64 that does not compound across repeat rounds (cached
+ # allocator state, not a leak). Widen the ceiling on aarch64 so
+ # this canary still catches genuine runaway leaks without flagging
+ # arena caching.
+ max_growth_kb = 200_000 if salt.utils.platform.is_aarch64() else 25_000
+ assert (
+ rss_growth_kb < max_growth_kb
+ ), f"RSS grew by {rss_growth_kb} KiB across 100 peer-churn rounds (ceiling {max_growth_kb} KiB)"
diff --git a/tests/pytests/stress/master_subprocess/pubchannel/test_slow_subscriber.py b/tests/pytests/stress/master_subprocess/pubchannel/test_slow_subscriber.py
new file mode 100644
index 000000000000..be97e4e6ceb1
--- /dev/null
+++ b/tests/pytests/stress/master_subprocess/pubchannel/test_slow_subscriber.py
@@ -0,0 +1,223 @@
+"""
+Slow-subscriber behavior of ``PubServerChannel._publish_daemon``.
+
+The important production mechanism these tests pin:
+
+* zeromq PUB drops messages **silently** to subscribers whose SUB
+ receive queue is over ``pub_hwm``. The subscriber has no way to
+ detect the drop from its socket alone — the connection stays "up"
+ from its perspective, but events go missing.
+* tcp PUB, in contrast, does **not** drop. It doesn't set
+ ``max_write_buffer_size`` on the per-subscriber tornado ``IOStream``,
+ so per-client write buffers grow without bound; the publisher process
+ RSS climbs and the fast subscribers get slowed down but the slow SUB
+ is never dropped by the publisher. This is a different failure mode
+ from what the prompt described as "StreamBufferFullError path"; that
+ path is never entered in production because the limit is unset.
+
+These tests document both behaviors so regressions in either direction
+(zmq: drops become visible / stop happening; tcp: master starts
+dropping OR starts holding write buffers past new limits) show up as
+CI-detectable diffs rather than silent behavior changes.
+"""
+
+from __future__ import annotations
+
+import time
+
+import pytest
+
+from tests.pytests.stress.master_subprocess.pubchannel.helpers import (
+ make_pusher,
+ make_subscriber,
+)
+
+# ---------------------------------------------------------------------------
+# zeromq: silent HWM drop
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.timeout(60)
+def test_zmq_slow_subscriber_drops_are_invisible(publisher):
+ """
+ ZeroMQ PUB drops overflow to a slow SUB silently.
+
+ Reproduce the production bug we want a regression guard for:
+ * a fast SUB and a slow SUB attach.
+ * we publish more events than fit in the per-connection PUB
+ SNDHWM (default ``pub_hwm=1000``).
+ * the slow SUB never drains but its socket appears "still
+ connected" to itself.
+ * observe missing events on the slow SUB without any error / close
+ notification on its socket.
+
+ The PUB SNDHWM is per outbound connection, so filling the slow SUB's
+ queue does NOT starve the fast SUB. We assert that: fast SUB gets
+ everything, slow SUB gets far less than everything, slow SUB's
+ socket still ``getpeername()``-s (no FIN / RST / error).
+ """
+ if publisher.transport != "zeromq":
+ pytest.skip("zmq-only mechanism")
+
+ n_events = 5000
+ payload_bytes = b"x" * 4096 # 4 KiB / event, > default MTU
+
+ fast = make_subscriber(publisher)
+ slow = make_subscriber(publisher, rcvhwm=10)
+
+ fast.connect()
+ fast.start_reader()
+ slow.connect()
+ # NOTE: we do NOT start slow's reader. Its OS receive buffer +
+ # zmq RCVHWM fill fast; then the PUB's per-connection SNDHWM (=1000)
+ # fills; then further messages targeted at that SUB are silently
+ # dropped by the PUB.
+ time.sleep(1.0) # settle SUB subscriptions (PUB slow-joiner)
+
+ try:
+ with make_pusher(publisher) as pusher:
+ for i in range(n_events):
+ pusher.send(payload_bytes + f"-{i}".encode())
+ # Fast SUB should get everything (PUB SNDHWM is per-connection).
+ got_all_fast = fast.wait_for_frames(n_events, timeout=30.0)
+
+ # Let the slow SUB accumulate what it can into its OS buffer.
+ time.sleep(1.0)
+
+ # Now drain the slow SUB to see how much it captured.
+ slow.start_reader()
+ time.sleep(2.0)
+ slow.stop_reading()
+
+ assert got_all_fast, (
+ f"fast SUB only got {len(fast.frames)}/{n_events} — "
+ "PUB SNDHWM is shared across connections, that would be a regression"
+ )
+ drained_slow = len(slow.frames)
+ # The whole point: slow SUB lost events.
+ assert drained_slow < n_events, (
+ f"slow SUB received {drained_slow} of {n_events} — silent "
+ "drop mechanism did not fire, HWM behavior may have changed"
+ )
+ # And critically: from the slow SUB's OWN socket view, it is
+ # still connected. No FIN, no RST, no zmq disconnect event.
+ # This is the production regression guard — the master silently
+ # dropped a good chunk of its events and the minion has no
+ # local socket signal that anything went wrong.
+ assert (
+ slow.socket_thinks_connected()
+ ), "slow SUB was closed by publisher — zmq PUB drop-visibility changed"
+ assert publisher.is_alive(), "publisher died while shedding load"
+ finally:
+ fast.close()
+ slow.close()
+
+
+# ---------------------------------------------------------------------------
+# tcp: unbounded write buffer (documented, NOT the StreamBufferFullError path)
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.timeout(60)
+def test_tcp_slow_subscriber_is_not_dropped(publisher):
+ """
+ Pin the (surprising) TCP behavior: a slow SUB is **not** dropped.
+
+ ``PubServer`` creates per-subscriber ``tornado.iostream.IOStream``
+ instances without ``max_write_buffer_size``, so the write buffer
+ grows without bound. The prompt's "StreamBufferFullError path"
+ would only be reached if that limit were set. This test guards
+ against a silent policy change: if someone starts setting the limit,
+ this test will start failing (slow SUB drops), and we can decide
+ whether that's the intended new behavior.
+ """
+ if publisher.transport != "tcp":
+ pytest.skip("tcp-only mechanism")
+
+ n_events = 500
+ payload = b"x" * 512 # 512 B/event
+
+ fast = make_subscriber(publisher)
+ slow = make_subscriber(publisher, so_rcvbuf=4096)
+
+ fast.connect()
+ fast.start_reader()
+ slow.connect()
+ # slow: never start reader, and clamp SO_RCVBUF so its OS buffer
+ # fills quickly — this makes the master's per-client write buffer
+ # grow without needing to publish enormous volume.
+ time.sleep(0.2)
+
+ try:
+ with make_pusher(publisher) as pusher:
+ for i in range(n_events):
+ pusher.send(payload + f"-{i}".encode())
+
+ # Fast subscriber drains everything.
+ assert fast.wait_for_frames(
+ n_events, timeout=15.0
+ ), f"fast SUB drained {len(fast.frames)}/{n_events}"
+
+ # Give the master a moment to notice if it were going to close
+ # the slow subscriber.
+ time.sleep(1.0)
+
+ # Slow SUB is NOT closed by the master — no FIN, no RST. If
+ # this assertion fails, someone added a per-subscriber buffer
+ # cap to the tcp PubServer. That may be a good change, but
+ # this test needs updating in that case.
+ assert (
+ slow.socket_thinks_connected()
+ ), "slow SUB was dropped — tcp PubServer added a write-buffer cap?"
+ assert publisher.is_alive(), "publisher died under slow-SUB load"
+ finally:
+ fast.close()
+ slow.close()
+
+
+# ---------------------------------------------------------------------------
+# Backpressure: many subscribers
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.timeout(90)
+def test_many_slow_subscribers_do_not_starve_fast_ones(publisher):
+ """
+ With 20 subscribers, half draining slowly / not at all, the fast
+ half must still receive every event.
+
+ * On zeromq: slow subs are dropped by HWM; fast subs get everything.
+ * On tcp: slow subs backpressure the write path (per-client await
+ of ``client.stream.write(...)``), but the publisher uses
+ ``asyncio.gather``-style concurrent write dispatch — see
+ #66282 fix — so the fast subs still see everything without waiting
+ on the slow ones.
+ """
+ n_events = 400
+ n_fast = 10
+ n_slow = 10
+
+ fast_subs = [make_subscriber(publisher) for _ in range(n_fast)]
+ slow_subs = [make_subscriber(publisher) for _ in range(n_slow)]
+
+ for s in fast_subs:
+ s.connect()
+ s.start_reader()
+ for s in slow_subs:
+ s.connect()
+ # never start reader — total starvation.
+
+ time.sleep(0.5) # subs settle before we push
+
+ try:
+ with make_pusher(publisher) as pusher:
+ for i in range(n_events):
+ pusher.send(f"e-{i}".encode())
+ for s in fast_subs:
+ assert s.wait_for_frames(
+ n_events, timeout=30.0
+ ), f"fast subscriber only got {len(s.frames)}/{n_events}"
+ assert publisher.is_alive()
+ finally:
+ for s in fast_subs + slow_subs:
+ s.close()
diff --git a/tests/pytests/stress/master_subprocess/pubchannel/test_smoke.py b/tests/pytests/stress/master_subprocess/pubchannel/test_smoke.py
new file mode 100644
index 000000000000..74d393b42f0a
--- /dev/null
+++ b/tests/pytests/stress/master_subprocess/pubchannel/test_smoke.py
@@ -0,0 +1,35 @@
+"""
+Smoke test: does the ``PubServerChannel._publish_daemon`` fixture actually
+spawn a live publisher subprocess and expose bind-able endpoints?
+"""
+
+from __future__ import annotations
+
+import socket
+import time
+
+import pytest
+
+
+@pytest.mark.timeout(30)
+def test_publisher_process_is_alive(publisher):
+ assert publisher.is_alive(), "publisher subprocess died at startup"
+ assert publisher.pid, "publisher has no pid"
+
+
+@pytest.mark.timeout(30)
+def test_publisher_pub_endpoint_accepts_tcp_connection(publisher):
+ """Both zmq and tcp bind a TCP listener on ``publish_port``."""
+ deadline = time.monotonic() + 5.0
+ last_err = None
+ while time.monotonic() < deadline:
+ try:
+ s = socket.create_connection(
+ (publisher.pub_host, publisher.pub_port), timeout=1.0
+ )
+ s.close()
+ return
+ except OSError as exc:
+ last_err = exc
+ time.sleep(0.05)
+ pytest.fail(f"pub endpoint never accepted a connection: {last_err!r}")
diff --git a/tests/pytests/stress/master_subprocess/pubchannel/test_throughput.py b/tests/pytests/stress/master_subprocess/pubchannel/test_throughput.py
new file mode 100644
index 000000000000..fdaa2ae5ee12
--- /dev/null
+++ b/tests/pytests/stress/master_subprocess/pubchannel/test_throughput.py
@@ -0,0 +1,63 @@
+"""
+Throughput floor: attach N subscribers, publish M events, everyone
+receives all events at some minimum rate.
+
+This is intentionally a low floor (well under the master's real-world
+rate) — the point is to catch regressions like "publisher wedges" or
+"messages dropped even without backpressure", not to grade absolute
+performance on shared CI hardware.
+"""
+
+from __future__ import annotations
+
+import time
+
+import pytest
+
+from tests.pytests.stress.master_subprocess.pubchannel.helpers import (
+ make_pusher,
+ make_subscriber,
+)
+
+
+@pytest.mark.timeout(60)
+def test_all_subscribers_receive_all_events(publisher):
+ n_subs = 5
+ n_events = 200
+ subs = []
+ for _ in range(n_subs):
+ s = make_subscriber(publisher)
+ s.connect()
+ s.start_reader()
+ subs.append(s)
+
+ # Let subs finish connecting before we start pushing. This matters
+ # especially for zmq PUB — subscriptions racing with the first
+ # publishes get dropped silently (zmq PUB slow-joiner problem).
+ time.sleep(0.5)
+
+ try:
+ with make_pusher(publisher) as pusher:
+ start = time.monotonic()
+ for i in range(n_events):
+ pusher.send(f"payload-{i}".encode())
+ # Every subscriber must receive every event within a generous
+ # bound. On a healthy publisher this is well under 5s for 200
+ # events across 5 subs on any machine that can run tests.
+ for s in subs:
+ assert s.wait_for_frames(
+ n_events, timeout=15.0
+ ), f"subscriber only got {len(s.frames)}/{n_events} frames"
+ elapsed = time.monotonic() - start
+
+ # Throughput floor: 100 events/s per subscriber. On slow shared
+ # CI this may need to be relaxed, but 200 events across 5 subs in
+ # 10 s is already glacial.
+ rate = (n_events * n_subs) / elapsed
+ assert (
+ rate > 100.0
+ ), f"pub rate {rate:.1f} evt/s below floor (elapsed {elapsed:.2f}s)"
+ assert publisher.is_alive()
+ finally:
+ for s in subs:
+ s.close()
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):
diff --git a/tests/pytests/unit/channel/test_client.py b/tests/pytests/unit/channel/test_client.py
index 972f60e5947c..18292f8c6f06 100644
--- a/tests/pytests/unit/channel/test_client.py
+++ b/tests/pytests/unit/channel/test_client.py
@@ -1,8 +1,12 @@
import pytest
+import tornado.concurrent
+import tornado.gen
+import tornado.ioloop
import salt.channel.client
import salt.crypt
import salt.exceptions
+import salt.payload
def test_async_methods():
@@ -79,3 +83,202 @@ async def authenticate(self):
payload = {"enc": "aes", "load": b"ciphertext"}
assert await channel._decode_payload(payload) is None
+
+
+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 = []
+
+ @tornado.gen.coroutine
+ def send(self, payload, timeout=None): # pylint: disable=unused-argument
+ self.sent.append(payload)
+ raise 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""
+
+ @tornado.gen.coroutine
+ def authenticate(self): # pragma: no cover - unused
+ raise tornado.gen.Return(None)
+
+
+def _make_channel(minion_opts, tmp_path, transport, auth):
+ 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.
+ """
+ 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):
+ @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 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 = tornado.ioloop.IOLoop()
+
+ @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
+
+ @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 tornado.gen.Return(reply)
+
+ transport.send = _rotate_and_send
+ result = yield channel._crypted_transfer({"cmd": "test"}, timeout=1)
+ raise 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.
+ """
+ key = salt.crypt.Crypticle.generate_key_string()
+ master = salt.crypt.Crypticle(minion_opts, key)
+ minion = salt.crypt.Crypticle(minion_opts, key)
+
+ events = []
+ # ``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):
+ super().__init__([])
+ self.call = 0
+
+ @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_holder["gate"]
+ events.append(f"send-end-{self.call}")
+ raise tornado.gen.Return(reply)
+
+ auth = _StubAuth(minion_opts, minion)
+ transport = _OrderingTransport()
+ channel = _make_channel(minion_opts, tmp_path, transport, auth)
+
+ io_loop = tornado.ioloop.IOLoop()
+
+ @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.
+ 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)
+
+ io_loop.call_later(0.05, _release)
+ r1 = yield fut1
+ r2 = yield fut2
+ raise 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/channel/test_master_cluster_port.py b/tests/pytests/unit/channel/test_master_cluster_port.py
new file mode 100644
index 000000000000..a80fac111ca2
--- /dev/null
+++ b/tests/pytests/unit/channel/test_master_cluster_port.py
@@ -0,0 +1,80 @@
+"""
+Regression tests for https://github.com/saltstack/salt/issues/69877.
+
+The Raft rewrite for master clustering accidentally read the peer-pool port
+from ``cluster_port`` instead of the documented ``cluster_pool_port``.
+``cluster_port`` was never registered in ``VALID_OPTS``/``DEFAULT_MASTER_OPTS``,
+so ``.get("cluster_port", 55596)`` silently fell back to the hardcoded literal
+``55596`` on every master, ignoring the operator's ``cluster_pool_port``
+setting. This module locks in that ``MasterPubServerChannel.factory`` binds
+the pool puller to ``opts["cluster_pool_port"]``.
+"""
+
+import salt.channel.server
+from tests.support.mock import patch
+
+
+def _cluster_opts(**overrides):
+ opts = {
+ "cluster_id": "test-cluster",
+ "cluster_peers": [],
+ "cluster_pool_port": 4520,
+ "sock_dir": "/tmp/does-not-matter",
+ "interface": "127.0.0.1",
+ "publish_port": 4505,
+ }
+ opts.update(overrides)
+ return opts
+
+
+def test_master_pub_server_channel_factory_uses_cluster_pool_port():
+ """
+ ``MasterPubServerChannel.factory`` in cluster mode must bind the peer
+ pool puller to ``opts["cluster_pool_port"]``, not the hardcoded 55596
+ that the pre-fix code fell back to.
+ """
+ opts = _cluster_opts(cluster_pool_port=4520)
+
+ with patch("salt.transport.tcp.PublishServer") as pub_server, patch.object(
+ salt.channel.server.MasterPubServerChannel, "__init__", return_value=None
+ ):
+ salt.channel.server.MasterPubServerChannel.factory(opts)
+
+ assert pub_server.called
+ call_kwargs = pub_server.call_args.kwargs
+ assert call_kwargs["pull_port"] == 4520
+ assert call_kwargs["pull_port"] != 55596
+
+
+def test_master_pub_server_channel_factory_honours_non_default_pool_port():
+ """
+ A non-default ``cluster_pool_port`` must be honored end-to-end. Before
+ the fix this returned 55596 regardless of the operator's setting.
+ """
+ opts = _cluster_opts(cluster_pool_port=6520)
+
+ with patch("salt.transport.tcp.PublishServer") as pub_server, patch.object(
+ salt.channel.server.MasterPubServerChannel, "__init__", return_value=None
+ ):
+ salt.channel.server.MasterPubServerChannel.factory(opts)
+
+ assert pub_server.call_args.kwargs["pull_port"] == 6520
+
+
+def test_master_pub_server_channel_factory_ignores_cluster_port_key():
+ """
+ ``cluster_port`` is not a valid opt at the channel layer -- the alias
+ lives in ``apply_master_config``, so once opts land at the factory the
+ key must have been translated to ``cluster_pool_port``. If a caller
+ passes ``cluster_port`` here, the factory must NOT fall back to it or
+ to 55596; ``cluster_pool_port`` is the only source of truth.
+ """
+ opts = _cluster_opts(cluster_pool_port=4520)
+ opts["cluster_port"] = 55596 # stale/typo -- must be ignored
+
+ with patch("salt.transport.tcp.PublishServer") as pub_server, patch.object(
+ salt.channel.server.MasterPubServerChannel, "__init__", return_value=None
+ ):
+ salt.channel.server.MasterPubServerChannel.factory(opts)
+
+ assert pub_server.call_args.kwargs["pull_port"] == 4520
diff --git a/tests/pytests/unit/channel/test_server.py b/tests/pytests/unit/channel/test_server.py
index 9c8f66db5bf1..97adf659f8e2 100644
--- a/tests/pytests/unit/channel/test_server.py
+++ b/tests/pytests/unit/channel/test_server.py
@@ -1,3 +1,4 @@
+import asyncio
import ctypes
import multiprocessing
import pathlib
@@ -511,6 +512,12 @@ async def test_auth_version_downgrade_warning_encrypted_load(req_server, caplog)
# Note: The remaining security bypasses (token, TTL, ID mismatch, session keys)
# are already tested via the parametrized downgrade tests above and the
# functional tests. The key regression test is ensuring old versions are rejected.
+@pytest.mark.no_blocking(
+ reason="Inline ReqServerChannel(opts, None) construction + 6 sequential "
+ "handle_message() calls with patched _decode_payload/_auth run in a "
+ "single callback slice (~55ms). Handler paths are individually fast; "
+ "exempting the aggregate test."
+)
async def test_handle_message_exceptions(temp_salt_master):
"""
test exceptions are handled cleanly in handle_message
@@ -627,6 +634,11 @@ async def test_handle_message_exceptions(temp_salt_master):
assert ret == "Server-side exception handling payload"
+@pytest.mark.no_blocking(
+ reason="ReqServerChannel(opts, None) loads MasterKeys (4096-bit RSA) "
+ "inline in the coroutine (~150ms). Move channel construction to a "
+ "session fixture to re-enable detection."
+)
async def test__auth_cmd_stats_passing(auth_master_opts):
opts = auth_master_opts.copy()
opts.update(
@@ -638,8 +650,11 @@ async def test__auth_cmd_stats_passing(auth_master_opts):
fake_ret = {"enc": "clear", "load": b"FAKELOAD"}
- def _auth_mock(*_, **__):
- time.sleep(0.03)
+ async def _auth_mock(*_, **__):
+ # ``_auth`` is now ``async def`` on ``ReqServerChannel``; simulate a
+ # blocking auth handshake with ``asyncio.sleep`` so the surrounding
+ # duration assertion still holds without blocking the event loop.
+ await asyncio.sleep(0.03)
return fake_ret
with patch.object(req, "_auth", _auth_mock), patch(
@@ -838,3 +853,585 @@ 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")
+
+
+def test_publish_daemon_sets_eventpublisher_process_title():
+ """
+ Regression: ``MasterPubServerChannel.pre_fork`` registers
+ ``_publish_daemon`` with ``ProcessManager.add_process`` using
+ ``name="EventPublisher"`` so the initial fork gets that setproctitle
+ string. ``ProcessManager.restart_process``, however, drops the
+ ``name=`` kwarg when respawning, so a respawn otherwise falls back to
+ the class ``__qualname__`` (``MasterPubServerChannel._publish_daemon``)
+ and the process ends up with a different title after the very first
+ restart. Operator monitoring keyed on ``EventPublisher`` (grep/pgrep,
+ log correlation) silently breaks.
+
+ The fix sets the process title explicitly at the top of
+ ``_publish_daemon`` so both the initial fork and every respawn end up
+ with the same ``EventPublisher`` title. This test guards against a
+ regression to the earlier behaviour by patching
+ ``setproctitle.setproctitle`` and asserting the daemon calls it with
+ the historical label before doing any other work.
+
+ Sibling of PR #70111 (same bug pattern, ``FileserverUpdate``).
+ """
+ from tests.support.mock import MagicMock, patch
+
+ # ``setproctitle`` is an optional runtime dep; the fix imports it at
+ # module load and gates the call with ``HAS_SETPROCTITLE``. Skip
+ # cleanly if the host lacks the module -- there is nothing to
+ # observe in that case.
+ pytest.importorskip("setproctitle")
+
+ # The fix must expose the module as an attribute of the
+ # ``salt.channel.server`` namespace so we can patch it. Failing this
+ # assertion means the fix has been reverted or the import removed.
+ assert hasattr(server, "setproctitle"), (
+ "salt.channel.server must import ``setproctitle`` so "
+ "``MasterPubServerChannel._publish_daemon`` can override the "
+ "process title on both initial fork and respawn"
+ )
+
+ channel = server.MasterPubServerChannel.__new__(server.MasterPubServerChannel)
+ # ``event_publisher_niceness`` gates an ``os.nice`` call further down
+ # in ``_publish_daemon``; keep it falsy so the test does not need to
+ # patch ``os.nice`` or drive the tornado io_loop.
+ channel.opts = {"event_publisher_niceness": 0}
+ channel.transport = MagicMock()
+
+ # Short-circuit the rest of the daemon by making the very next line
+ # after the setproctitle call raise. If the fix is present the
+ # setproctitle call happens *first*, and the mock records the call
+ # before the ``StopIteration`` propagates.
+ with patch.object(server, "setproctitle", MagicMock()) as fake_setproctitle, patch(
+ "tornado.ioloop.IOLoop.current", side_effect=StopIteration
+ ):
+ with pytest.raises(StopIteration):
+ channel._publish_daemon()
+
+ fake_setproctitle.setproctitle.assert_called_once_with("EventPublisher")
+
+
+def test_pool_routing_channel_caches_auto_key_on_init(tmp_path):
+ """
+ ``PoolRoutingChannel.__init__`` must eagerly construct and cache an
+ ``AutoKey`` on ``self.auto_key`` so the ``_auth`` fallback in
+ ``_req_channel_auth_delegate`` reuses it across every auth this
+ channel handles.
+
+ Regression guard for PR #70129 review concern: the earlier
+ implementation set ``self.auto_key = None``, which caused the
+ ``getattr(self, "auto_key", None) or AutoKey(self.opts)`` fallback
+ in ``ReqServerChannel._auth`` to fire on every authentication.
+ Each fresh ``AutoKey`` starts with an empty ``signing_files`` mtime
+ cache, so masters configured with ``autosign_file`` or
+ ``autoreject_file`` re-read those files from disk on every auth
+ instead of hitting the cache -- O(N) reads under an auth storm.
+ """
+ opts = {
+ "cachedir": str(tmp_path),
+ "cluster_id": None,
+ "sock_dir": str(tmp_path),
+ "keys.cache_driver": "localfs_key",
+ "con_cache": False,
+ }
+ (tmp_path / "sessions").mkdir(exist_ok=True)
+
+ transport = MagicMock()
+ worker_pools = {"default": {"commands": ["*"]}}
+
+ ch = server.PoolRoutingChannel(opts, transport, worker_pools)
+
+ # The channel must hold a real AutoKey instance right after init.
+ assert isinstance(ch.auto_key, salt.daemons.masterapi.AutoKey)
+ # The AutoKey must reference the same opts dict so it sees updates.
+ assert ch.auto_key.opts is opts
+ # Cache dict must exist (empty is fine -- populated on first check).
+ assert ch.auto_key.signing_files == {}
+
+ # The delegate built for inline clear-text auth reuses the same
+ # cached AutoKey rather than constructing a fresh one.
+ delegate = ch._req_channel_auth_delegate()
+ assert delegate.auto_key is ch.auto_key
+
+ # Repeated delegate construction returns the same AutoKey identity
+ # (regression: pre-fix, ``getattr(self, "auto_key", None)`` returned
+ # ``None`` and the fallback in ``ReqServerChannel._auth`` created a
+ # fresh AutoKey each time).
+ delegate2 = ch._req_channel_auth_delegate()
+ assert delegate2.auto_key is delegate.auto_key
+
+
+async def test_join_reply_refreshes_master_keys_cache_70090(tmp_path, key_data):
+ """
+ Regression test for https://github.com/saltstack/salt/issues/70090.
+
+ Under ``cluster_isolated_filesystem: True`` the joiner's
+ ``cluster/peer/join-reply`` handler in ``MasterPubServerChannel``
+ installs the founder's ``cluster.pem`` / ``cluster.pub`` on disk
+ (overwriting the pre-join placeholders written by
+ ``MasterKeys._setup_keys``). Pre-fix it stopped there. Post-fix it
+ also:
+
+ #. Writes the wire-delivered PEM/pub through
+ ``master_key.cache.store("master_keys", ...)`` so cache drivers
+ that keep an index separate from the on-disk file (``mmap_key``)
+ don't hand out the joiner's placeholder ``cluster.pub`` on the
+ next ``MasterKeys.get_pub_str()`` call.
+ #. Rebinds ``master_key.cluster_key`` / ``master_key.key`` from the
+ new PEM so the currently running master process signs cluster
+ events with the shared cluster identity from that event onward.
+
+ This test drives ``handle_pool_publish`` with a synthesized
+ join-reply payload and stubs the crypto primitives so we're
+ asserting purely on the fix's cache-store and key-rebind calls.
+ Without the fix both assertions fail: ``cache.store`` is never
+ called and ``cluster_key`` keeps its pre-join value.
+ """
+
+ # Wire-delivered PEM/pub bytes that the join-reply is supposed to
+ # install. Contents are arbitrary -- the fix cares only about the
+ # cache-store call site and the rebind, not the key bytes.
+ delivered_pem = b"-----BEGIN RSA PRIVATE KEY-----\nWIRE-DELIVERED-PEM\n-----END RSA PRIVATE KEY-----\n"
+ delivered_pub = (
+ "-----BEGIN PUBLIC KEY-----\nWIRE-DELIVERED-PUB\n-----END PUBLIC KEY-----\n"
+ )
+
+ cluster_pki = tmp_path / "cluster_pki"
+ cluster_pki.mkdir()
+ # Simulate the joiner's placeholder that ``_setup_keys`` wrote on
+ # startup -- the join-reply handler unlinks and rewrites these.
+ (cluster_pki / "cluster.pem").write_bytes(b"PLACEHOLDER-PEM")
+ (cluster_pki / "cluster.pub").write_text("PLACEHOLDER-PUB")
+
+ opts = {
+ "id": "joiner_master",
+ "cluster_id": "master_cluster",
+ "cluster_peers": ["founder"],
+ "cluster_pki_dir": str(cluster_pki),
+ "cluster_encryption_algorithm": "OAEP-SHA1",
+ "sock_dir": str(tmp_path / "sock"),
+ }
+ (tmp_path / "sock").mkdir()
+
+ channel = server.MasterPubServerChannel.__new__(server.MasterPubServerChannel)
+ channel.opts = opts
+ channel._discover_token = b"test-token-0000000000000000000000"
+ channel._discover_event = None
+ channel._raft_dispatcher = None
+ channel._raft_service = None
+ # Stub _mark_joined_cluster (writes sentinel file) and
+ # _start_raft_as_learner (Raft init) so we exercise only the key
+ # install path.
+ channel._mark_joined_cluster = MagicMock()
+ channel._start_raft_as_learner = MagicMock()
+ # No state-sync session in this payload; handler will call
+ # _start_raft_as_learner directly.
+
+ # master_key mock: expose the attributes the handler reads plus a
+ # ``cache`` MagicMock so we can assert store() was invoked.
+ fake_master_key = MagicMock()
+ fake_master_key.master_rsa_path = str(tmp_path / "master.pem")
+ fake_master_key.cluster_key = "STALE-PLACEHOLDER-CLUSTER-KEY"
+ fake_master_key.key = "STALE-PLACEHOLDER-CLUSTER-KEY"
+ fake_master_key.cache = MagicMock()
+ channel.master_key = fake_master_key
+
+ # Stub the RSA decrypt of ``cluster_key_session``: return
+ # ``discover_token + Crypticle key`` so the handler decodes cleanly.
+ session_key = salt.crypt.Crypticle.generate_key_string()
+ salted_session_bytes = channel._discover_token + session_key.encode()
+
+ # Stub Crypticle.decrypt to return our wire-delivered PEM regardless
+ # of ciphertext, and the RSA private key load to return an object
+ # whose .decrypt returns salted_session_bytes.
+ fake_private_key = MagicMock()
+ fake_private_key.decrypt.return_value = salted_session_bytes
+ fake_crypticle = MagicMock()
+ fake_crypticle.decrypt.return_value = delivered_pem
+
+ # Stub PrivateKey.from_str so the rebind at the end of the fix
+ # returns a sentinel we can identify. ``salt.crypt.PrivateKey``
+ # normally parses the PEM; here we just verify it was called with
+ # the wire-delivered bytes and its return value bound onto master_key.
+ reloaded_key_sentinel = object()
+
+ with patch("salt.crypt.PrivateKey.from_file", return_value=fake_private_key), patch(
+ "salt.crypt.Crypticle", return_value=fake_crypticle
+ ), patch("salt.crypt.PrivateKey.from_str", return_value=reloaded_key_sentinel):
+ # Inner payload has cluster_key_session + cluster_pem +
+ # cluster_pub -- exactly what the founder's join handler
+ # sends under isolated-FS.
+ inner_payload = {
+ "peer_id": "founder",
+ "cluster_key_session": b"encrypted-session-key",
+ "cluster_pem": b"encrypted-pem",
+ "cluster_pub": delivered_pub,
+ "peers": {},
+ }
+ data = {"payload": salt.payload.dumps(inner_payload), "sig": b"sig"}
+ tag = "cluster/peer/join-reply/founder"
+ payload = salt.utils.event.SaltEvent.pack(tag, data)
+ await channel.handle_pool_publish(payload)
+
+ # ------- Disk assertions (unchanged behaviour, sanity check) -------
+ assert (
+ cluster_pki / "cluster.pem"
+ ).read_bytes() == delivered_pem, (
+ "join-reply handler must write the wire-delivered PEM to disk"
+ )
+ assert (
+ cluster_pki / "cluster.pub"
+ ).read_text() == delivered_pub, (
+ "join-reply handler must write the wire-delivered pub to disk"
+ )
+
+ # ------- Cache assertion (this IS the fix) -------
+ store_calls = fake_master_key.cache.store.call_args_list
+ stored_keys = {call.args[1] for call in store_calls if len(call.args) >= 2}
+ assert "cluster.pem" in stored_keys, (
+ "Fix regression: join-reply handler must write cluster.pem "
+ "through master_key.cache.store so cache drivers with an index "
+ "separate from disk (mmap_key) don't serve the stale placeholder. "
+ f"Observed cache.store calls: {store_calls!r}"
+ )
+ assert "cluster.pub" in stored_keys, (
+ "Fix regression: join-reply handler must also write cluster.pub "
+ "through cache.store; MasterKeys.get_pub_str() looks this key up. "
+ f"Observed cache.store calls: {store_calls!r}"
+ )
+ # Confirm the stored bytes match the wire copy, not the placeholder.
+ for call in store_calls:
+ if call.args[1] == "cluster.pem":
+ assert call.args[2] == delivered_pem, (
+ "cache.store received wrong bytes for cluster.pem: "
+ f"{call.args[2]!r} vs {delivered_pem!r}"
+ )
+ if call.args[1] == "cluster.pub":
+ stored_pub = call.args[2]
+ if isinstance(stored_pub, str):
+ stored_pub = stored_pub.encode()
+ assert stored_pub == delivered_pub.encode(), (
+ "cache.store received wrong bytes for cluster.pub: "
+ f"{stored_pub!r} vs {delivered_pub.encode()!r}"
+ )
+
+ # ------- Rebind assertion (this IS the fix) -------
+ assert channel.master_key.cluster_key is reloaded_key_sentinel, (
+ "Fix regression: after installing the wire-delivered PEM the "
+ "handler must rebind master_key.cluster_key from the new bytes "
+ "so the running process signs with the shared cluster identity. "
+ f"cluster_key is still {channel.master_key.cluster_key!r}"
+ )
+ assert channel.master_key.key is reloaded_key_sentinel, (
+ "Fix regression: master_key.key must also be rebound to the new "
+ "cluster_key (mirrors the ``self.key = self.cluster_key`` line "
+ "at the end of MasterKeys._setup_keys)."
+ )
diff --git a/tests/pytests/unit/cli/test_batch.py b/tests/pytests/unit/cli/test_batch.py
index bb78e337e880..99eb84a34155 100644
--- a/tests/pytests/unit/cli/test_batch.py
+++ b/tests/pytests/unit/cli/test_batch.py
@@ -810,3 +810,105 @@ def test_gather_minions_with_batch_presence_ping(batch):
local_client_mock.mock_calls[2][2]["gather_job_timeout"]
== opts_with_pp["batch_presence_ping_gather_job_timeout"]
)
+
+
+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
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/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
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/cloud/test_cloud.py b/tests/pytests/unit/cloud/test_cloud.py
index 8785e086ecd8..7c654dd7d7ce 100644
--- a/tests/pytests/unit/cloud/test_cloud.py
+++ b/tests/pytests/unit/cloud/test_cloud.py
@@ -197,6 +197,53 @@ def test_vm_config_merger_nooverridevalue():
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 = {
+ "test_vm": {
+ "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):
master_config["profiles"] = {"test_profile": {"provider": "test_provider:saltify"}}
diff --git a/tests/pytests/unit/cluster/consensus/test_cluster_ready.py b/tests/pytests/unit/cluster/consensus/test_cluster_ready.py
index 1b0f82ca92fc..4fa5b93914aa 100644
--- a/tests/pytests/unit/cluster/consensus/test_cluster_ready.py
+++ b/tests/pytests/unit/cluster/consensus/test_cluster_ready.py
@@ -10,7 +10,7 @@
import asyncio
import multiprocessing
-from tests.support.mock import MagicMock, patch
+from tests.support.mock import AsyncMock, MagicMock, patch
def _run(coro):
@@ -96,7 +96,11 @@ def test_auth_passes_when_not_ready(self):
"load": {"cmd": "_auth", "id": "minion1"},
}
ch._decode_payload = MagicMock(return_value=auth_payload)
- ch._auth = MagicMock(return_value={"publish": True})
+ # ``_auth`` is now ``async def`` (PR #70129); use AsyncMock so
+ # ``await self._auth(...)`` in handle_message resolves to the
+ # return value instead of raising ``TypeError: 'dict' object
+ # can't be awaited``.
+ ch._auth = AsyncMock(return_value={"publish": True})
with self._patched_ready(False):
result = _run(ch.handle_message(auth_payload))
diff --git a/tests/pytests/unit/cluster/consensus/test_raft_membership.py b/tests/pytests/unit/cluster/consensus/test_raft_membership.py
index 1157cce5ab1a..a1ebbb9fa527 100644
--- a/tests/pytests/unit/cluster/consensus/test_raft_membership.py
+++ b/tests/pytests/unit/cluster/consensus/test_raft_membership.py
@@ -358,7 +358,7 @@ def test_opt_plumbs_into_node(self):
opts = salt.config.master_config("/dev/null")
opts["interface"] = "127.0.0.1"
opts["cluster_peers"] = ["127.0.0.2"]
- opts["cluster_port"] = 55597
+ opts["cluster_pool_port"] = 55597
opts["cachedir"] = tmpdir
opts["cluster_max_voters"] = 3
@@ -420,7 +420,7 @@ def _service_from_pool(self, peer_addrs, max_voters=None, my_addr="127.0.0.1"):
opts = salt.config.master_config("/dev/null")
opts["interface"] = my_addr
opts["cluster_peers"] = list(peer_addrs)
- opts["cluster_port"] = 55596
+ opts["cluster_pool_port"] = 55596
opts["cachedir"] = tmpdir
if max_voters is not None:
opts["cluster_max_voters"] = max_voters
@@ -554,7 +554,7 @@ def _make_service(self):
opts = salt.config.master_config("/dev/null")
opts["interface"] = "127.0.0.1"
opts["cluster_peers"] = ["127.0.0.2"]
- opts["cluster_port"] = 55596
+ opts["cluster_pool_port"] = 55596
opts["cachedir"] = tmpdir
loop = asyncio.new_event_loop()
diff --git a/tests/pytests/unit/cluster/consensus/test_voter_health.py b/tests/pytests/unit/cluster/consensus/test_voter_health.py
index aed75af0eabc..4ede7ce6bd58 100644
--- a/tests/pytests/unit/cluster/consensus/test_voter_health.py
+++ b/tests/pytests/unit/cluster/consensus/test_voter_health.py
@@ -243,7 +243,7 @@ def _make_service(self):
opts = salt.config.master_config("/dev/null")
opts["interface"] = "127.0.0.1"
opts["cluster_peers"] = ["127.0.0.2", "127.0.0.3"]
- opts["cluster_port"] = 55596
+ opts["cluster_pool_port"] = 55596
opts["cachedir"] = tmpdir
opts["cluster_min_voters"] = 2
diff --git a/tests/pytests/unit/config/schemas/test_ssh.py b/tests/pytests/unit/config/schemas/test_ssh.py
index 602b693c9fe5..c6cb8a1e100c 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,14 +215,32 @@ 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"},
ssh_schemas.RosterEntryConfig.serialize(),
format_checker=jsonschema.FormatChecker(),
)
- _msg = excinfo.value.message
- assert "is too short" in _msg or "non-empty" in _msg.lower(), _msg
+ if JSONSCHEMA_VERSION >= Version("4.0.0"):
+ # jsonschema 4.x renamed the minLength/minItems failure message.
+ assert "should be non-empty" in excinfo.value.message
+ else:
+ assert "is too short" in excinfo.value.message
with pytest.raises(jsonschema.exceptions.ValidationError) as excinfo:
jsonschema.validate(
diff --git a/tests/pytests/unit/config/test_master_config.py b/tests/pytests/unit/config/test_master_config.py
index 0cf3adcb88e7..af53311bcf6d 100644
--- a/tests/pytests/unit/config/test_master_config.py
+++ b/tests/pytests/unit/config/test_master_config.py
@@ -1,3 +1,5 @@
+import logging
+
import salt.config
from tests.support.mock import MagicMock, patch
@@ -74,6 +76,44 @@ def test_apply_for_cluster():
assert ["127.0.0.1", "127.0.0.3"] == opts["cluster_peers"]
+def test_cluster_port_alias_warns_and_aliases_cluster_pool_port(caplog):
+ """
+ Regression for https://github.com/saltstack/salt/issues/69877.
+
+ The Raft rewrite for master clustering accidentally read the peer-pool
+ port from an undocumented ``cluster_port`` opt instead of the
+ documented ``cluster_pool_port``. ``apply_master_config`` now accepts
+ ``cluster_port`` as a soft-deprecated alias: it copies the value into
+ ``cluster_pool_port`` (if the caller did not set that explicitly) and
+ emits a deprecation warning.
+ """
+ defaults = salt.config.DEFAULT_MASTER_OPTS.copy()
+ overrides = {"cluster_port": 6520}
+
+ with caplog.at_level(logging.WARNING, logger="salt.config"):
+ opts = salt.config.apply_master_config(overrides, defaults)
+
+ assert opts["cluster_pool_port"] == 6520
+ assert any(
+ "cluster_port" in rec.message and "deprecated" in rec.message
+ for rec in caplog.records
+ ), f"expected deprecation warning; got: {[r.message for r in caplog.records]}"
+
+
+def test_cluster_pool_port_wins_over_cluster_port_alias():
+ """
+ If both ``cluster_port`` and ``cluster_pool_port`` are set, the
+ documented ``cluster_pool_port`` wins -- the alias is a soft landing
+ for operators who happened to pick up the buggy name, not a co-equal
+ setting.
+ """
+ defaults = salt.config.DEFAULT_MASTER_OPTS.copy()
+ overrides = {"cluster_port": 55596, "cluster_pool_port": 6520}
+
+ opts = salt.config.apply_master_config(overrides, defaults)
+ assert opts["cluster_pool_port"] == 6520
+
+
def test___cli_path_is_expanded():
defaults = salt.config.DEFAULT_MASTER_OPTS.copy()
overrides = {}
diff --git a/tests/pytests/unit/conftest.py b/tests/pytests/unit/conftest.py
index 45feac206ba8..52502e49efe4 100644
--- a/tests/pytests/unit/conftest.py
+++ b/tests/pytests/unit/conftest.py
@@ -1,4 +1,8 @@
+import asyncio
+import contextlib
+import logging
import os
+import re
import pytest
@@ -6,6 +10,213 @@
import salt.transport.tcp
from tests.conftest import FIPS_TESTRUN
from tests.support.mock import AsyncMock, MagicMock, patch
+from tests.support.runtests import RUNTIME_VARS
+
+
+# ----------------------------------------------------------------------
+# ``RUNTIME_VARS.TMP`` (``/tmp/salt-tests-tmpdir-``) is used by a
+# handful of unit tests (e.g. ``test_pillar.test_topfile_order``) as a
+# parent for ``tempfile.mkdtemp(dir=...)``. Nothing in the pure-unit
+# session guarantees the directory exists — the integration-side
+# ``saltfactories`` fixtures normally create it, but they are not pulled
+# in for a bare unit run. Under CI parallelism the parent can also be
+# cleaned between tests. Create it eagerly at session start so unit
+# tests can rely on it without each test carrying its own ``makedirs``.
+# ----------------------------------------------------------------------
+@pytest.fixture(scope="session", autouse=True)
+def _ensure_runtime_vars_tmp_exists():
+ os.makedirs(RUNTIME_VARS.TMP, exist_ok=True)
+ yield
+
+
+# ----------------------------------------------------------------------
+# asyncio blocking-detection fixture (see MEMORY: async-mworker migration)
+# ----------------------------------------------------------------------
+#
+# The MWorker migration converts ``AESFuncs``/``ClearFuncs``/``AuthFuncs``
+# handlers to ``async def``. Any handler that accidentally holds the event
+# loop (sync ``open()``, RSA verify without ``run_in_executor``, ``time.sleep``,
+# etc.) defeats the migration silently unless something in the test suite
+# catches it. This module wires asyncio's built-in slow-callback logging
+# into a pytest fixture that FAILS the test when a callback exceeds the
+# configured threshold.
+#
+# The fixture is opt-out via ``@pytest.mark.no_blocking``. Use the marker
+# on tests that do legitimate synchronous CPU work (e.g. RSA keypair
+# generation inside a callback for a security assertion).
+#
+# Threshold is 50 ms by default (matches the migration's design budget).
+# Override per-test with ``@pytest.mark.no_blocking(threshold=0.1)``.
+# ----------------------------------------------------------------------
+
+# Default slow-callback threshold, in seconds. 50 ms is the design budget
+# for MWorker handlers — anything longer should have been offloaded to a
+# thread/process pool via ``loop.run_in_executor``.
+DEFAULT_SLOW_CALLBACK_THRESHOLD = 0.05
+
+# asyncio logs two shapes of slow warnings when ``loop._debug`` is True:
+# 1. "Executing took X seconds" — a single callback
+# held the loop for >slow_callback_duration. This is the "handler
+# blocked the loop" signal we care about.
+# 2. "Executing