A typed, async .NET client for the Salt Project REST API
(rest_cherrypy), built for
reading and applying Linux patches safely.
Salt's REST API runs every command through one endpoint, POST /, and an account with full rights can run any
function on any minion, including cmd.run as root. This package puts the safety in the client:
- an exact read-only allow-list, checked twice before anything is sent;
- per-minion results that never mistake a failed or missing minion for a clean one;
- a real patch apply that cannot be issued by accident.
dotnet add package Salt.Apiusing Salt.Api;
using Salt.Api.Models;
using var client = new SaltClient(new SaltClientOptions
{
BaseUrl = "https://salt.example.com",
Username = "api-user",
Password = passwordFromYourSecretStore, // never from source code
ReadOnly = true, // recommended for anything that only reads
});
// Patch status of two minions
var status = await client.GetPatchStatusAsync(MinionTarget.List("web-01", "web-02"), cancellationToken);
foreach (var minion in status.Values)
{
Console.WriteLine(minion.Succeeded
? $"{minion.MinionId}: {minion.Value!.PendingCount} pending, reboot required: {minion.Value.RebootRequired}"
: $"{minion.MinionId}: FAILED ({minion.FailureKind}): {minion.FailureText}");
}
// Which minions are connected (cheap; prefer this to GET /minions)
IReadOnlyList<string> up = await client.GetMinionsUpAsync(cancellationToken);Create one SaltClient per Salt master and reuse it. It logs in once, reuses the token, and logs in again only when
the token is about to expire or Salt answers HTTP 401.
| Method | Salt call | Read-only mode |
|---|---|---|
PingAsync |
test.ping |
✅ |
GetPatchStatusAsync |
patchreport.status (custom module, see below) |
✅ |
GetGrainAsync |
grains.get |
✅ |
GetMinionsUpAsync |
runner manage.up |
✅ |
GetMinionStatusAsync |
runner manage.status |
✅ |
GetKeysAsync |
GET /keys |
✅ |
GetMinionAsync |
GET /minions/{id} (one minion's grains) |
✅ |
GetJobsAsync, GetJobAsync |
GET /jobs, GET /jobs/{jid} |
✅ |
SubmitAsync, WaitForJobAsync |
local_async + polling GET /jobs/{jid} |
✅ for allow-listed functions |
PatchDryRunAsync |
state.apply patch.apply test=True, as a job |
❌ |
PatchApplyAsync |
state.apply patch.apply, as a job (real apply) |
❌ |
ExecuteAsync<T>, ExecuteOnMinionsAsync<T>, ExecuteAsync(lowstates) |
any Lowstate |
✅ if every lowstate is allow-listed |
LogoutAsync |
POST /logout |
✅ |
There is deliberately no method for GET /minions: it returns every grain of every minion (about 100 KB for six
minions) and grows with the estate.
GetPatchStatusAsync calls patchreport.status by default, a small custom execution module that is not part of Salt
(see Salt content this package expects). Its result maps to PatchStatus:
PendingUpgrades, PendingCount, KeptBack, Held, KernelPackageNeedsReboot, RebootRequiredFile,
LastUpgradeEpoch and LastUpgradeSource, plus RebootRequired, PatchingNeeded and NeedsAttention. A minion
without the module returns a string, which is reported as a failure.
ReadOnly defaults to false. Set it to true for every consumer that only reads, such as dashboards and
reports. In read-only mode the client permits only this list, matched exactly and case-sensitively. Anything else
throws SaltReadOnlyViolationException before any request, including the login, is sent:
| Endpoint or client | Permitted |
|---|---|
POST / with local or local_async |
test.ping, patchreport.status, pkg.list_upgrades (only with refresh absent or false), grains.get, grains.items |
POST / with runner |
manage.up, manage.status |
POST / with wheel |
key.list_all |
GET |
/jobs, /jobs/{jid}, /keys, /keys/{id}, /minions, /minions/{id}, /events |
POST |
/login, /logout |
Refused, however the call is dressed:
state.apply, even withtest=True: a dry run refreshes apt and takes the apt lock.cmd.*.pkg.install,pkg.upgrade,pkg.refresh_dband the like.- Any other wheel function, such as
key.acceptorkey.delete. - Any other runner.
/runand/hook.- An unknown
client. - A lowstate with a missing or empty
client,funortgt. - A lowstate key outside
client,tgt,tgt_type,fun,arg,kwargandtimeout. For exampleretis refused, because it sends results to a returner. - A positional argument containing
=, because Salt would turn it into a keyword argument.
One refused element refuses a whole multi-command request.
The allow-list is enforced in two places:
- The request builder.
Lowstatehas static factories, such asLowstate.PingandLowstate.PatchStatus, and every public factory exceptLowstate.Rawbuilds an allow-listed call. A unit test finds the factories by reflection, so a new factory that breaks this fails the build.Lowstate.Rawbuilds anything, but needsAllowRawLowstate = true(defaultfalse). - The HTTP handler. It checks the exact bytes about to be sent, so a request built any other way is refused too.
The options are copied when the client is built, so turning off ReadOnly afterwards has no effect on that client.
Salt answers HTTP 200 even when minions fail. Per-minion calls return a MinionResultDictionary<T> of
MinionResult<T>, where each result is either Succeeded with a Value, or a failure with a FailureKind and
FailureText:
| What Salt returned | Result |
|---|---|
| The expected value | Succeeded |
A string, e.g. 'no.such.function' is not available. or Minion did not return. [No response] |
StringResponse failure |
false |
FalseResponse failure |
| A value of the wrong shape | UnexpectedShape failure |
Nothing for an id you listed in MinionTarget.List |
NotReturned failure |
{} because the target matched nothing |
NoMinionsMatched = true, and AllSucceeded is false |
The client never throws for one minion's failure. It throws only for HTTP-level problems.
using var client = new SaltClient(new SaltClientOptions { /* ... */ ReadOnly = false });
var ids = new[] { "web-01", "web-02" };
// 1. Dry run: reports what would change. Runs as an async job and polls until done.
var dryRun = await client.PatchDryRunAsync(new PatchDryRunRequest { Target = MinionTarget.List(ids) }, ct);
foreach (var minion in dryRun.Minions.Values.Where(m => m.Succeeded))
{
foreach (var (package, change) in minion.Value!.PackageChanges)
{
Console.WriteLine($"{minion.MinionId}: {package} {change.Old} -> {change.New}");
}
}
// 2. Real apply: only after a human has seen the dry run.
var apply = await client.PatchApplyAsync(new PatchApplyRequest
{
MinionIds = ids, // exact ids only, no globs
ChangeReference = "CHG-12345", // your approved change
ConfirmRealApply = true,
}, ct);
// 3. Salt never reboots. Read the patch status and arrange any reboot separately.
var after = await client.GetPatchStatusAsync(MinionTarget.List(ids), ct);PatchApplyAsync refuses the request with SaltPatchGuardException, before anything is sent, unless:
ReadOnlyis false;ConfirmRealApplyis true;ChangeReferenceis set;- the minion ids are exact, with no glob, comma or whitespace;
- there are at most 8 ids, unless
AllowMoreThanEightMinionsis set; - this client ran a successful dry run with no failed state for each id within
DryRunValidityMinutes(default 60); - no patch dry run or apply is still running on those minions, because two runs contend for the apt lock.
These guards apply the same way to every estate: the client never infers "test" or "production" from the URL. The submit is never retried once it may have reached Salt.
Workflow rules the client cannot check for you:
- A production apply needs an approved change.
- Do not apply during a node drain or storage recovery.
- The dry run is not an exact preview: it can list kept-back packages that the real apply will not install.
PingAsync, the grains, key, job and minion calls work against any Salt master. The patch methods need content on
your Salt master that Salt does not ship. Three options name it; the defaults are the names this package was built
against.
| Option | Default | What it must be |
|---|---|---|
PatchStatusFunction |
patchreport.status |
An execution function (module.function) that changes nothing and returns, per minion, an object with pending_upgrades (package to version), pending_count, kept_back, held, kernelpkg_needs_reboot (bool or null), reboot_required_file, last_upgrade_epoch (epoch seconds or null) and last_upgrade_source. |
PatchStateName |
patch.apply |
The SLS applied by PatchDryRunAsync and PatchApplyAsync. It must work with test=True. |
PackageStateId |
patch-apply |
The __id__ of the pkg state in that SLS whose changes list the upgrades as {"<package>": {"old": "...", "new": "..."}}, for example a pkg.uptodate state. It is exposed as PatchStateRun.PackageState. |
The read-only allow-list is fixed and cannot be extended by configuration. A custom PatchStatusFunction therefore
works only with ReadOnly = false. In read-only mode it throws SaltReadOnlyViolationException before any request
is sent.
These were measured against a live Salt 3008 rest_cherrypy deployment behind HAProxy.
Accept: application/jsonon every request. Without it, a failed login returns HTTP 200 with the SaltGUI HTML page and no token. A login is accepted only if the JSON body has a non-emptyreturn[0].tokenand non-emptyperms, whatever the status code.- Error bodies are HTML, not JSON. The client branches on the status code and includes the start of the text in
SaltApiException. - Request bodies are sent with a Content-Length.
rest_cherrypyanswers a chunked JSON body with HTTP 500. - Sessions live in memory and are lost when salt-api restarts. On HTTP 401 the client logs in again once and
retries once; a second 401 throws
SaltAuthenticationException. Logins are serialised, so concurrent first calls log in once. - Tokens last 8 hours by default. The client renews the token
TokenRefreshMarginSeconds(60) beforeexpire. - HTTP 429 (a proxy rate limit on
/login): back-off starts at 5 seconds, doubles each time, is capped at 30 seconds and has jitter. - Read-only requests are also retried on 502, 503 and 504. A request that could change state is retried only on 401 and 429, which mean it was refused before it ran.
- Long operations run as jobs. A synchronous call blocks until the minions answer, and a proxy may close it. The
patch methods use
local_asyncand pollGET /jobs/{jid}, everyJobPollIntervalSeconds(5), until every targeted minion has returned or the deadline passes.
| Option | Default | Notes |
|---|---|---|
BaseUrl |
(required) | https only; no default estate |
Username, Password |
(required) | or PasswordProvider, asked at each login |
Eauth |
file |
|
ReadOnly |
false |
set true for read-only consumers |
AllowRawLowstate |
false |
needed for Lowstate.Raw |
HttpClientTimeoutSeconds |
150 | per request; must exceed DefaultMinionTimeoutSeconds |
DefaultMinionTimeoutSeconds |
120 | lowstate timeout for local calls |
JobPollIntervalSeconds |
5 | |
MaxAttemptCount |
5 | for 429, and 5xx on read-only calls |
InitialBackOffDelaySeconds, BackOffDelayFactor, MaxBackOffDelaySeconds |
5, 2.0, 30 | |
TokenRefreshMarginSeconds |
60 | |
DryRunValidityMinutes |
60 | |
PatchStatusFunction, PatchStateName, PackageStateId |
patchreport.status, patch.apply, patch-apply |
see Salt content this package expects |
UserAgent |
Salt.Api/{version} |
|
RequestLogLevel |
Debug |
method, path, status and duration only |
Passwords, tokens and request or response bodies are never logged. Certificates are always validated; there is no option to turn that off.
- A real apply over HTTP has not been captured against a live server. Its result is expected to have the same
shape as the dry run, with
Resulttrue and the changes filled in. Unit tests cover it with a fake server only. - A still-running job lookup (
GET /jobs/{jid}before every minion has returned) and theMinion did not return. [No response]string have not been captured over HTTP. The client handles both shapes as documented by Salt. - Behaviour during a salt-api restart under load has not been tested live. The 401 re-login path is unit-tested only.
/stats,/run,/hookand/wsare not used.GET /eventsis permitted in read-only mode but has no method yet.- Only a service account with
eauth: filehas been used live. Other accounts and eauth backends have not. - The "no patch run in progress" check reads the 20 most recent
state.apply patch.applyjobs. A run older than the master's job cache is not seen.
TreatWarningsAsErrors, nullable reference types, and XML documentation on every public member.- Unit tests: xUnit v3, with skipped tests failing the run, and about 90% line coverage reported to Codacy from CI.
They replay real responses captured from a live Salt API (with host and account names replaced), and cover:
- the allow-list, in both directions;
- "refused before any request";
- the 401 re-login;
- the HTML failed login;
- the per-minion failure shapes;
- 429 back-off;
- every patch apply guard.
- Integration tests (
Salt.Api.IntegrationTest) run read-only calls and one dry run against a test Salt API. They readSALT_API_BASE_URL,SALT_API_USERNAMEandSALT_API_PASSWORD. A missing variable fails the test with instructions; tests are never skipped. They never perform a real apply. - Changes that touch the allow-list, the apply guards or retries follow docs/SAFETY.md, alongside CONTRIBUTING.md.
- NuGet: https://www.nuget.org/packages/Salt.Api
- Source: https://github.com/panoramicdata/Salt.Api
- Issues: https://github.com/panoramicdata/Salt.Api/issues
MIT. See LICENSE. Salt and the Salt Project are trademarks of their respective owners; this package is not affiliated with them.