@truenas/api-client 4.0.0 → 4.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -0
- package/dist/index.cjs +182 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +228 -17
- package/dist/index.d.ts +228 -17
- package/dist/index.js +182 -22
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -183,6 +183,65 @@ discovery route that resolves against whatever the appliance turned out to be.
|
|
|
183
183
|
On the named route it cannot: the client class is picked from the version you
|
|
184
184
|
stated, so `ops` is that version's mappings whether or not the server agrees.
|
|
185
185
|
|
|
186
|
+
`ops` is deliberately flat — `OperationMappings`, not parameterized by the
|
|
187
|
+
directory — so every operation is callable whether or not you pinned a version.
|
|
188
|
+
That is what lets an operation paper over a difference the type system would
|
|
189
|
+
otherwise force the caller to handle.
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
// The same call on every supported version.
|
|
193
|
+
const sessions = await firstValueFrom(
|
|
194
|
+
client.ops.smbStatus({ infoLevel: 'SESSIONS' })
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// A client count is this composed, not a second operation.
|
|
198
|
+
const count = await firstValueFrom(
|
|
199
|
+
client.ops.smbStatus({
|
|
200
|
+
infoLevel: 'SESSIONS',
|
|
201
|
+
options: { count: true },
|
|
202
|
+
statusOptions: { fast: true },
|
|
203
|
+
})
|
|
204
|
+
);
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`smbStatus` is worth singling out, because it is the first operation whose
|
|
208
|
+
v25.10 leg is invisible to `middlewared --dump-api`. `smb.status` is public on
|
|
209
|
+
v26+ and generated like any other method; on v25.10 it is the same method taking
|
|
210
|
+
the same four positional arguments and returning the same `list | dict | int`,
|
|
211
|
+
but middleware declares it `private=True`, so the dump omits it and no generated
|
|
212
|
+
v25.10 type mentions it. The v25.10 client therefore *asserts* the method exists
|
|
213
|
+
rather than reading it from the directory — a single narrow declaration admitting
|
|
214
|
+
one method name, one argument tuple and one return type, checked against
|
|
215
|
+
middleware source instead of against the dump.
|
|
216
|
+
|
|
217
|
+
Three consequences worth knowing before you reach for it.
|
|
218
|
+
|
|
219
|
+
**It needs a full-admin session on v25.10.** Being private there decides
|
|
220
|
+
authorization, not just documentation. `smb.status` declares no roles on
|
|
221
|
+
v25.10, and middleware role-registers a method only `if roles:` — so it is in no
|
|
222
|
+
role's allowlist, and only a **non-STIG full-admin** session, whose allowlist is
|
|
223
|
+
the wildcard `{ method: '*', resource: '*' }`, reaches it. A session holding
|
|
224
|
+
exactly `SHARING_SMB_READ` is refused with `EACCES` on v25.10 and succeeds on
|
|
225
|
+
v26+, where the method carries that role. Under STIG, full admin is expanded to
|
|
226
|
+
the union of its roles' allowlists rather than the wildcard, so it is refused on
|
|
227
|
+
v25.10 as well. The client does not pre-empt any of this: you get middleware's
|
|
228
|
+
own error, on the version where it applies.
|
|
229
|
+
|
|
230
|
+
**It logs server-side on every dispatch**, which matters if you poll it. v25.10
|
|
231
|
+
is in maintenance and that cost is accepted there, but it is not a pattern to
|
|
232
|
+
extend.
|
|
233
|
+
|
|
234
|
+
**The result is the union middleware sends** — which arm arrives is decided by
|
|
235
|
+
the options, not the info level — so callers narrow it themselves.
|
|
236
|
+
|
|
237
|
+
These types are deliberately not the generated ones. `SmbStatusOptions` is not
|
|
238
|
+
v26's `SMBStatusOptions`, and `SmbStatusResponse` is not that method's generated
|
|
239
|
+
response: the generated types describe one version's dump, while these describe
|
|
240
|
+
the contract both versions honour. They are named `Smb…` rather than `SMB…` so
|
|
241
|
+
the two cannot be confused at a call site — and `SmbStatusOptions` is the one
|
|
242
|
+
pair where the names sit closest, since it is the fourth positional argument on
|
|
243
|
+
both versions.
|
|
244
|
+
|
|
186
245
|
## Documentation
|
|
187
246
|
|
|
188
247
|
The API reference is generated from the TSDoc comments in the source with
|
package/dist/index.cjs
CHANGED
|
@@ -2389,8 +2389,11 @@ var apiVersionConfig = {
|
|
|
2389
2389
|
* - DNS lookup failure
|
|
2390
2390
|
* - Server unreachable
|
|
2391
2391
|
*
|
|
2392
|
-
*
|
|
2393
|
-
*
|
|
2392
|
+
* Which is why it is no longer reached on all of them. A discovery failure is
|
|
2393
|
+
* now put to a reachability probe and a second attempt first, and only an
|
|
2394
|
+
* appliance that answers the probe and still will not serve `/api/versions`
|
|
2395
|
+
* is assumed to be this version. One that answers nothing is reported as
|
|
2396
|
+
* unreachable rather than pinned to a version nobody confirmed.
|
|
2394
2397
|
*/
|
|
2395
2398
|
FALLBACK_VERSION: "v25.10.0"
|
|
2396
2399
|
};
|
|
@@ -3446,6 +3449,16 @@ function toAppState(state) {
|
|
|
3446
3449
|
}
|
|
3447
3450
|
}
|
|
3448
3451
|
|
|
3452
|
+
// src/utils/smb-status.utils.ts
|
|
3453
|
+
function toSmbStatusParams(request = {}) {
|
|
3454
|
+
return [
|
|
3455
|
+
request.infoLevel ?? "ALL",
|
|
3456
|
+
request.filters ?? [],
|
|
3457
|
+
request.options ?? {},
|
|
3458
|
+
request.statusOptions ?? {}
|
|
3459
|
+
];
|
|
3460
|
+
}
|
|
3461
|
+
|
|
3449
3462
|
// src/client/truenas-api-client-v25-10.ts
|
|
3450
3463
|
var TrueNasApiClientV2510 = class extends TrueNasApiClient {
|
|
3451
3464
|
/**
|
|
@@ -3485,6 +3498,42 @@ var TrueNasApiClientV2510 = class extends TrueNasApiClient {
|
|
|
3485
3498
|
);
|
|
3486
3499
|
}
|
|
3487
3500
|
return this.api.job("virt.instance.delete", [id]);
|
|
3501
|
+
},
|
|
3502
|
+
// The private leg. See `PrivateSmbStatusCall` for why this is a cast and
|
|
3503
|
+
// not a directory lookup.
|
|
3504
|
+
//
|
|
3505
|
+
// v25.10 answers rather than refusing, for a caller with the authority.
|
|
3506
|
+
// Being private does not stop the method running: `ws_handler/rpc.py`
|
|
3507
|
+
// logs a warning for a private call over websocket and dispatches it
|
|
3508
|
+
// anyway — the `# FIXME: Eventually, prohibit this` above that branch is
|
|
3509
|
+
// still unresolved on master.
|
|
3510
|
+
//
|
|
3511
|
+
// It does decide who may run it. `smb.status` carries no `roles=` here,
|
|
3512
|
+
// and middleware role-registers a method only `if roles:`, so it is in no
|
|
3513
|
+
// role's allowlist and only the non-STIG full-admin wildcard reaches it.
|
|
3514
|
+
// That is documented on `OperationMappings.smbStatus`; this leg does not
|
|
3515
|
+
// pre-empt it. A caller without the authority gets middleware's `EACCES`,
|
|
3516
|
+
// which is the honest answer and the server's to give.
|
|
3517
|
+
//
|
|
3518
|
+
// The server-side warning is per dispatch, and a client count is exactly
|
|
3519
|
+
// the sort of thing a caller polls. Reported at debug rather than warn,
|
|
3520
|
+
// because nothing here is degraded or unhonoured — the request is served
|
|
3521
|
+
// in full — and left to the caller to weigh.
|
|
3522
|
+
//
|
|
3523
|
+
// Future tense, and for the same reason as `containerDelete` above: this
|
|
3524
|
+
// runs when the operation is built, and `api.call` is cold, so no request
|
|
3525
|
+
// has gone out yet and may never. One built observable subscribed twice
|
|
3526
|
+
// dispatches twice; this line is what will happen, not a record that it
|
|
3527
|
+
// did.
|
|
3528
|
+
smbStatus: (request) => {
|
|
3529
|
+
this.logger.debug(
|
|
3530
|
+
"smbStatus: smb.status is private on v25.10; each dispatch will log a warning server-side and needs a non-STIG full-admin session",
|
|
3531
|
+
{ method: "smb.status", infoLevel: request?.infoLevel ?? "ALL" }
|
|
3532
|
+
);
|
|
3533
|
+
return this.api.call(
|
|
3534
|
+
"smb.status",
|
|
3535
|
+
toSmbStatusParams(request)
|
|
3536
|
+
);
|
|
3488
3537
|
}
|
|
3489
3538
|
};
|
|
3490
3539
|
}
|
|
@@ -3572,7 +3621,16 @@ var TrueNasApiClientV26 = class extends TrueNasApiClient {
|
|
|
3572
3621
|
containerDelete: (id, options) => this.api.job(
|
|
3573
3622
|
"container.delete",
|
|
3574
3623
|
options ? [parseInt(id, 10), options] : [parseInt(id, 10)]
|
|
3575
|
-
)
|
|
3624
|
+
),
|
|
3625
|
+
// `smb.status` is public here and gated on `SHARING_SMB_READ`, so it is
|
|
3626
|
+
// an ordinary `call` read straight out of the generated directory — the
|
|
3627
|
+
// one leg of this operation that needs no assertion about the server.
|
|
3628
|
+
//
|
|
3629
|
+
// Not a query verb: middleware returns the same `list | dict | int`
|
|
3630
|
+
// polymorphism a `.query` does, but the generator did not mark the entry
|
|
3631
|
+
// with an `entity`, so `api.query` does not accept it and the union is
|
|
3632
|
+
// handed to the caller to narrow.
|
|
3633
|
+
smbStatus: (request) => this.api.call("smb.status", toSmbStatusParams(request))
|
|
3576
3634
|
};
|
|
3577
3635
|
}
|
|
3578
3636
|
};
|
|
@@ -3653,7 +3711,16 @@ var TrueNasApiClientV27 = class extends TrueNasApiClient {
|
|
|
3653
3711
|
containerDelete: (id, options) => this.api.job(
|
|
3654
3712
|
"container.delete",
|
|
3655
3713
|
options ? [parseInt(id, 10), options] : [parseInt(id, 10)]
|
|
3656
|
-
)
|
|
3714
|
+
),
|
|
3715
|
+
// `smb.status` is public here and gated on `SHARING_SMB_READ`, so it is
|
|
3716
|
+
// an ordinary `call` read straight out of the generated directory — the
|
|
3717
|
+
// one leg of this operation that needs no assertion about the server.
|
|
3718
|
+
//
|
|
3719
|
+
// Not a query verb: middleware returns the same `list | dict | int`
|
|
3720
|
+
// polymorphism a `.query` does, but the generator did not mark the entry
|
|
3721
|
+
// with an `entity`, so `api.query` does not accept it and the union is
|
|
3722
|
+
// handed to the caller to narrow.
|
|
3723
|
+
smbStatus: (request) => this.api.call("smb.status", toSmbStatusParams(request))
|
|
3657
3724
|
};
|
|
3658
3725
|
}
|
|
3659
3726
|
};
|
|
@@ -3753,6 +3820,12 @@ var InvalidVersionResponseError = class extends VersionDiscoveryError {
|
|
|
3753
3820
|
}
|
|
3754
3821
|
};
|
|
3755
3822
|
var discoveryTimeoutMs = 5e3;
|
|
3823
|
+
var probeTimeoutMs = 5e3;
|
|
3824
|
+
function corsIsEnforced() {
|
|
3825
|
+
const hasDocument = typeof window !== "undefined" && typeof window.document !== "undefined";
|
|
3826
|
+
const inWorker = typeof globalThis.WorkerGlobalScope !== "undefined";
|
|
3827
|
+
return hasDocument || inWorker;
|
|
3828
|
+
}
|
|
3756
3829
|
function hasErrorName(error, expected) {
|
|
3757
3830
|
return typeof error === "object" && error !== null && "name" in error && error.name === expected;
|
|
3758
3831
|
}
|
|
@@ -3765,6 +3838,42 @@ var VersionDiscovery = class {
|
|
|
3765
3838
|
versionsUrl(hostname) {
|
|
3766
3839
|
return `${httpScheme(this.protocol)}//${hostname}/api/versions`;
|
|
3767
3840
|
}
|
|
3841
|
+
/**
|
|
3842
|
+
* Whether the appliance answers at all, asked in a way CORS cannot block.
|
|
3843
|
+
*
|
|
3844
|
+
* A `no-cors` request yields a response the page may not read, but its
|
|
3845
|
+
* *arrival* is the whole answer here: something served it. That separates the
|
|
3846
|
+
* two failures `fetch` reports identically — a box that refused to share its
|
|
3847
|
+
* versions with this origin, and a box that is not there.
|
|
3848
|
+
*
|
|
3849
|
+
* `cannot-ask` where CORS is not enforced: nothing is blocking anything, so a
|
|
3850
|
+
* failed fetch already means unreachable and a probe would only repeat what
|
|
3851
|
+
* discovery just found. It is not a quieter way of saying "no".
|
|
3852
|
+
*/
|
|
3853
|
+
async probeReachable(hostname) {
|
|
3854
|
+
if (!corsIsEnforced()) return "cannot-ask";
|
|
3855
|
+
const url = this.versionsUrl(hostname);
|
|
3856
|
+
const controller = new AbortController();
|
|
3857
|
+
const timer2 = setTimeout(() => controller.abort(), probeTimeoutMs);
|
|
3858
|
+
try {
|
|
3859
|
+
await fetch(url, {
|
|
3860
|
+
mode: "no-cors",
|
|
3861
|
+
cache: "no-store",
|
|
3862
|
+
signal: controller.signal
|
|
3863
|
+
});
|
|
3864
|
+
this.logger.info("Reachability probe answered", { hostname, url });
|
|
3865
|
+
return "reachable";
|
|
3866
|
+
} catch (error) {
|
|
3867
|
+
this.logger.warn("Reachability probe got no answer", {
|
|
3868
|
+
hostname,
|
|
3869
|
+
url,
|
|
3870
|
+
error
|
|
3871
|
+
});
|
|
3872
|
+
return "silent";
|
|
3873
|
+
} finally {
|
|
3874
|
+
clearTimeout(timer2);
|
|
3875
|
+
}
|
|
3876
|
+
}
|
|
3768
3877
|
/**
|
|
3769
3878
|
* Discovers the API version for a given hostname.
|
|
3770
3879
|
*
|
|
@@ -4024,30 +4133,81 @@ async function createTrueNasClient(opts) {
|
|
|
4024
4133
|
});
|
|
4025
4134
|
throw error;
|
|
4026
4135
|
}
|
|
4027
|
-
const
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
logger.error("Invalid fallback version configuration", {
|
|
4031
|
-
uuid: uuid.slice(0, 8),
|
|
4032
|
-
hostnames: hostnames.join(", "),
|
|
4033
|
-
fallbackVersion: fallbackVersionString
|
|
4034
|
-
});
|
|
4035
|
-
throw error;
|
|
4136
|
+
const reachable = await probeAnyHostname(hostnames, versionDiscovery);
|
|
4137
|
+
if (reachable === "silent") {
|
|
4138
|
+
await sleep(unreachableRetryDelayMs);
|
|
4036
4139
|
}
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4140
|
+
try {
|
|
4141
|
+
const retryWinner = await discoverVersionFromAnyHostname(
|
|
4142
|
+
hostnames,
|
|
4143
|
+
versionDiscovery
|
|
4144
|
+
);
|
|
4145
|
+
logger.info("Version discovery succeeded on retry", {
|
|
4040
4146
|
uuid: uuid.slice(0, 8),
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4147
|
+
hostname: retryWinner.hostname,
|
|
4148
|
+
version: retryWinner.version.version,
|
|
4149
|
+
firstError: errorMessage
|
|
4150
|
+
});
|
|
4151
|
+
version = retryWinner.version;
|
|
4152
|
+
} catch (retryError) {
|
|
4153
|
+
if (!(retryError instanceof VersionDiscoveryNetworkError)) {
|
|
4154
|
+
throw retryError;
|
|
4045
4155
|
}
|
|
4046
|
-
|
|
4047
|
-
|
|
4156
|
+
if (reachable !== "reachable") {
|
|
4157
|
+
logger.error(
|
|
4158
|
+
"Version discovery failed and the appliance did not answer a reachability probe; not assuming a version",
|
|
4159
|
+
{
|
|
4160
|
+
uuid: uuid.slice(0, 8),
|
|
4161
|
+
hostnames: hostnames.join(", "),
|
|
4162
|
+
probe: reachable === "silent" ? "no answer" : "not applicable",
|
|
4163
|
+
error: errorMessageOrDefault(retryError, "Unknown error")
|
|
4164
|
+
}
|
|
4165
|
+
);
|
|
4166
|
+
throw retryError;
|
|
4167
|
+
}
|
|
4168
|
+
const fallbackVersionString = apiVersionConfig.FALLBACK_VERSION;
|
|
4169
|
+
const fallbackVersion = parseApiVersion(fallbackVersionString);
|
|
4170
|
+
if (!fallbackVersion) {
|
|
4171
|
+
logger.error("Invalid fallback version configuration", {
|
|
4172
|
+
uuid: uuid.slice(0, 8),
|
|
4173
|
+
hostnames: hostnames.join(", "),
|
|
4174
|
+
fallbackVersion: fallbackVersionString
|
|
4175
|
+
});
|
|
4176
|
+
throw retryError;
|
|
4177
|
+
}
|
|
4178
|
+
logger.warn(
|
|
4179
|
+
"Appliance is reachable but version discovery is still blocked; falling back to assumed version",
|
|
4180
|
+
{
|
|
4181
|
+
uuid: uuid.slice(0, 8),
|
|
4182
|
+
hostnames: hostnames.join(", "),
|
|
4183
|
+
fallbackVersion: fallbackVersionString,
|
|
4184
|
+
originalError: errorMessage,
|
|
4185
|
+
warning: "The appliance answered a probe but not /api/versions, which is what v25.10.0 looks like from a browser. If it is newer than that, this client is now pinned to the wrong surface."
|
|
4186
|
+
}
|
|
4187
|
+
);
|
|
4188
|
+
version = fallbackVersion;
|
|
4189
|
+
}
|
|
4048
4190
|
}
|
|
4049
4191
|
return instantiateClientForVersion(version, opts, logger);
|
|
4050
4192
|
}
|
|
4193
|
+
var unreachableRetryDelayMs = 2500;
|
|
4194
|
+
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
4195
|
+
async function probeAnyHostname(hostnames, versionDiscovery) {
|
|
4196
|
+
const probes = hostnames.map(
|
|
4197
|
+
(hostname) => versionDiscovery.probeReachable(hostname)
|
|
4198
|
+
);
|
|
4199
|
+
const remaining = new Set(probes);
|
|
4200
|
+
let sawSilence = false;
|
|
4201
|
+
while (remaining.size > 0) {
|
|
4202
|
+
const settled = await Promise.race(
|
|
4203
|
+
[...remaining].map((probe) => probe.then((result) => ({ probe, result })))
|
|
4204
|
+
);
|
|
4205
|
+
if (settled.result === "reachable") return "reachable";
|
|
4206
|
+
if (settled.result === "silent") sawSilence = true;
|
|
4207
|
+
remaining.delete(settled.probe);
|
|
4208
|
+
}
|
|
4209
|
+
return sawSilence ? "silent" : "cannot-ask";
|
|
4210
|
+
}
|
|
4051
4211
|
async function discoverVersionFromAnyHostname(hostnames, versionDiscovery) {
|
|
4052
4212
|
const attempts = hostnames.map(
|
|
4053
4213
|
(hostname) => rxjs.firstValueFrom(versionDiscovery.discoverVersion(hostname)).then(
|