@microsoft/windows-admin-center-sdk
Version:
Microsoft - Windows Admin Center Shell
700 lines (698 loc) • 28.5 kB
JavaScript
import { EMPTY, Observable, of, throwError } from 'rxjs';
import { catchError, expand, map } from 'rxjs/operators';
import { LogLevel } from '../diagnostics/log-level';
import { Logging } from '../diagnostics/logging';
import { SmeWebTelemetry } from '../diagnostics/sme-web-telemetry';
import { TelemetryEventStates } from '../diagnostics/sme-web-telemetry-models';
import { Disposer } from './disposable';
import { HttpMethod } from './http';
import { Net } from './net';
import { PowerShell } from './powershell';
/**
* PowerShell runspace session state.
*/
var RunspaceSessionState;
(function (RunspaceSessionState) {
/**
* Runspace still active.
*/
RunspaceSessionState[RunspaceSessionState["Active"] = 0] = "Active";
/**
* Runspace already expired.
*/
RunspaceSessionState[RunspaceSessionState["Expired"] = 1] = "Expired";
/**
* No runsapce available for the give node.
* Either this is the first call, or the previous call error out
* or previous runspace was deleted due to expiry.
*/
RunspaceSessionState[RunspaceSessionState["Unavailable"] = 2] = "Unavailable";
})(RunspaceSessionState || (RunspaceSessionState = {}));
/**
* The PowerShellBatchSession class.
*/
export class PowerShellBatchSession {
powerShellBatch;
lifetime;
constructor(powerShellBatch, lifetime) {
this.powerShellBatch = powerShellBatch;
this.lifetime = lifetime;
}
/**
* Dispose the session object.
*/
dispose() {
if (this.lifetime) {
this.lifetime.dispose();
}
}
}
/**
* Class containing methods related to PowerShell runspaces creation/deletion/command using PowerShell Raw API plugin during batch run.
* - It's auto holding the runspace as long as it's used within last 3 minutes.
*/
class PowerShellBatchRaw {
batchConnection;
context;
// 3 minutes session holding time.
static maxDeltaTimeInMs = 3 * 60 * 1000;
nodesToSessionIdsMap = {};
timestampInMs = 0;
markDelete = false;
internalActive = false;
requestedNodesList;
/**
* Initializes a new instance of the PowerShellBatchRaw class.
*
* @param batchConnection The batch connection service.
* @param context The PowerShell batch session Context.
*/
constructor(batchConnection, context) {
this.batchConnection = batchConnection;
this.context = context;
}
/**
* Gets active status of PowerShell execution.
*/
get active() {
return this.internalActive;
}
/**
* Dispose the runspace.
*/
dispose() {
if (!this.active) {
// only close sessions that have been created.
// If a result was cached a component may not
// execute a command and still dispose the session
// when the component is destroyed.
if (Object.keys(this.nodesToSessionIdsMap).length > 0) {
this.close().subscribe();
}
}
else {
this.markDelete = true;
}
}
/**
* Runs the given batch command, and try followup Get calls if all nodes don't complete during the initial batch call.
*
* @param nodesList The nodes list to run batch against.
* @param commandList The list of command body, corresponding to nodesList.
*/
runCommand(nodesList, commandList) {
// take the timestamp only success/healthy case.
// error session would be auto-deleted after expiration time.
this.internalActive = true;
this.requestedNodesList = nodesList;
return this.command(nodesList, commandList)
.pipe(catchError((error) => {
this.internalActive = false;
SmeWebTelemetry.tracePowershellBatchEvent(commandList, TelemetryEventStates.Error, error);
return throwError(() => error);
}), expand((response) => {
this.timestampInMs = Date.now();
const psBatchResponse = this.convertBatchResponseToPowerShellBatchResponse(response);
const incompleteNodes = this.getIncompleteNodes(psBatchResponse);
if (incompleteNodes.length === 0) {
this.internalActive = false;
return EMPTY;
}
// create list of Get URLs for incomplete nodes.
const incompleteNodesUrlList = this.createRelativeUrlListSingleMethod(incompleteNodes, HttpMethod.Get);
// update requested Nodes list, so we can parse the response correctly.
this.requestedNodesList = incompleteNodesUrlList;
return this.batchConnection.get(incompleteNodes, incompleteNodesUrlList, this.context.requestOptions);
}), map((response) => {
const psBatchResponse = this.convertBatchResponseToPowerShellBatchResponse(response);
const errorResponses = psBatchResponse.filter((psResponse) => psResponse.error || psResponse.errors);
if (errorResponses.length > 0) {
SmeWebTelemetry.tracePowershellBatchEvent(commandList, TelemetryEventStates.Error, { response: errorResponses });
}
return psBatchResponse;
}));
}
/**
* Close/Delete the session / runspace map.
*/
close() {
if (Object.keys(this.nodesToSessionIdsMap).length > 0) {
const nodeList = [];
for (const node in this.nodesToSessionIdsMap) {
if (this.nodesToSessionIdsMap.hasOwnProperty(node)) {
nodeList.push(node);
}
}
const nodeUrls = this.createRelativeUrlListSingleMethod(nodeList, HttpMethod.Delete);
return this.batchConnection.delete(nodeList, nodeUrls, this.context.requestOptions)
.pipe(map((responseData) => {
this.nodesToSessionIdsMap = {};
const psBatchResponse = this.convertBatchResponseToPowerShellBatchResponse(responseData);
return psBatchResponse;
}));
}
Logging.log({
level: LogLevel.Warning,
source: 'PowerShellBatch/close',
message: MsftSme.getStrings().MsftSmeShell.Core.Error.PowerShellUnableSessionClose.message
});
return of(null);
}
/**
* Cancel the command.
*/
cancelCommand() {
if (Object.keys(this.nodesToSessionIdsMap).length > 0) {
const nodeList = [];
for (const node in this.nodesToSessionIdsMap) {
if (this.nodesToSessionIdsMap.hasOwnProperty(node)) {
nodeList.push(node);
}
}
const nodeUrls = this.createRelativeUrlListSingleMethod(nodeList, 'CANCEL');
return this.batchConnection.put(nodeList, nodeUrls)
.pipe(map((responseData) => {
this.nodesToSessionIdsMap = {};
const psBatchResponse = this.convertBatchResponseToPowerShellBatchResponse(responseData);
return psBatchResponse;
}));
}
Logging.log({
level: LogLevel.Warning,
source: 'PowerShell',
message: MsftSme.getStrings().MsftSmeShell.Core.Error.PowerShellUnableCancelCommand.message
});
return of(null);
}
/**
* Parse the response array for multi-part response and convert to PowerShellBatchResponse list.
*
* @param responseList The BatchResponse array received for the powershell batch call.
*/
convertBatchResponseToPowerShellBatchResponse(responseList) {
const powershellBatchResponse = [];
for (let itemId = 0; itemId < responseList.length; itemId++) {
const responseItem = responseList[itemId].response;
const nodeName = responseList[itemId].nodeName;
const sequenceNumber = responseList[itemId].sequenceNumber;
const jsonResponse = responseItem.response;
const status = responseItem.status;
const properties = Net.getItemProperties(jsonResponse);
if (status < 400) {
powershellBatchResponse.push({ sequenceNumber, status, nodeName, properties });
}
else {
const responseData = responseItem.response;
if (responseData.error) {
const error = responseData.error;
powershellBatchResponse.push({ sequenceNumber, status, nodeName, properties, error });
Logging.log({
source: 'Batch PowerShell',
level: LogLevel.Error,
message: MsftSme.getStrings().MsftSmeShell.Core.Error.BatchConnection.message
.format(status, error.code, Net.getPowerShellErrorMessage(responseItem.response))
});
}
else if (responseData.errors) {
const errors = responseData.errors;
powershellBatchResponse.push({ sequenceNumber, status, nodeName, properties, errors });
Logging.log({
source: 'Batch PowerShell',
level: LogLevel.Error,
message: Net.getPowerShellErrorMessage(responseItem.response)
});
}
}
}
return powershellBatchResponse;
}
/**
* Initiate command execution. It auto recycles old sessions.
*
* @param nodesList The list of nodes to run commands against
* @param commandList The command body list corresponding to nodesList.
*/
command(nodesList, commandList) {
const nodesSessionStateMap = this.getSessionsStateForNodesList(nodesList);
const methodsList = [];
for (let index = 0; index < nodesList.length; index++) {
const nodeName = nodesList[index];
if (nodesSessionStateMap[nodeName] === RunspaceSessionState.Expired) {
// Delete item from map.
delete this.nodesToSessionIdsMap[nodeName];
}
if (nodesSessionStateMap[nodeName] === RunspaceSessionState.Active) {
// Post method
methodsList.push(HttpMethod.Post);
}
else {
// Put method
methodsList.push(HttpMethod.Put);
}
}
const nodeUrls = this.createRelativeUrlList(nodesList, methodsList);
return this.batchConnection.mixed(nodesList, nodeUrls, commandList, methodsList, this.context.requestOptions);
}
/**
* Check if a valid/non-expired sesison exists for each node in the list.
*
* @param nodesList The nodes list to check valid existing sesion for.
*/
getSessionsStateForNodesList(nodesList) {
const runspaceSessionsState = {};
for (let index = 0; index < nodesList.length; index++) {
const savedSession = this.nodesToSessionIdsMap[nodesList[index]];
if (!savedSession) {
runspaceSessionsState[nodesList[index]] = RunspaceSessionState.Unavailable;
}
else if (this.isSessionEntryExpired(savedSession)) {
runspaceSessionsState[nodesList[index]] = RunspaceSessionState.Expired;
}
else {
runspaceSessionsState[nodesList[index]] = RunspaceSessionState.Active;
}
}
return runspaceSessionsState;
}
/**
* Create a relative url list for PowerShell Post batch call, based on nodes and methods list.
*
* @param nodesList The list of nodes to generate urls for
* @param methodList The http method types map corresponding to nodesList.
*/
createRelativeUrlList(nodesList, methodList) {
const responseUrlList = [];
for (let index = 0; index < nodesList.length; index++) {
const nodeName = nodesList[index];
const method = methodList[index];
// try to get session ids for given Node from the stored map.
const savedSession = this.nodesToSessionIdsMap[nodeName];
const sessionId = (savedSession && !this.isSessionEntryExpired(savedSession)) ? savedSession.sessionId : MsftSme.newGuid();
responseUrlList.push(this.createRelativeUrl(method, sessionId));
}
return responseUrlList;
}
/**
* Create a relative url list for PowerShell batch call, based on provided method.
*
* @param nodesList The list of nodes to generate urls for
* @param method The http method type
*/
createRelativeUrlListSingleMethod(nodesList, method) {
const responseUrlList = [];
for (let index = 0; index < nodesList.length; index++) {
const nodeName = nodesList[index];
// try to get session ids for given Node from the stored map.
const savedSession = this.nodesToSessionIdsMap[nodeName];
const sessionId = (savedSession && !this.isSessionEntryExpired(savedSession)) ? savedSession.sessionId : MsftSme.newGuid();
responseUrlList.push(this.createRelativeUrl(method, sessionId));
}
return responseUrlList;
}
/**
* Create a relative url for the given method and session Id.
*
* @param method The Http method to use for call.
* @param sessionId The PS runspace session Id.
*/
createRelativeUrl(method, sessionId) {
let relativeUrl = '';
if (method === HttpMethod.Delete) {
relativeUrl = Net.powerShellApiSessions.format(sessionId);
}
else if (method === HttpMethod.Put) {
relativeUrl = Net.powerShellApiSessions.format(sessionId);
}
else if (method === HttpMethod.Post) {
relativeUrl = Net.powerShellApiExecuteCommand.format(sessionId);
}
else if (method === HttpMethod.Get) {
relativeUrl = Net.powerShellApiRetrieveOutput.format(sessionId);
}
else if (method === 'CANCEL') {
relativeUrl = Net.powerShellApiCancelCommand.format(sessionId);
}
return relativeUrl;
}
/**
* Check if all indivudual nodes have returned 'completed' result and return the list of nodes which returned 'completed=false'
*
* @param responseArray The response from a PowerShell batch call.
* @return incompleteNodes The incompleteNodes array populated with nodes not completed yet.
*/
getIncompleteNodes(responseArray) {
const incompleteNodes = [];
for (const responseItem of responseArray) {
const properties = responseItem.properties;
// skip the error cases.
if (!properties) {
continue;
}
const sessionId = properties.sessionId;
const creationTimestamp = Date.now();
if (sessionId) {
// keep the PS session GUID
this.nodesToSessionIdsMap[responseItem.nodeName] = { sessionId, creationTimestamp };
}
if (properties.completed && properties.completed.toLowerCase() !== 'true') {
incompleteNodes.push(responseItem.nodeName);
}
}
return incompleteNodes;
}
/**
* Checks if a stored runSpace session for a specific node is expired.
*
* @param rsSessionContext runspace session context.
*/
isSessionEntryExpired(rsSessionContext) {
const now = Date.now();
return rsSessionContext.creationTimestamp !== 0 && (now - rsSessionContext.creationTimestamp) > PowerShellBatchRaw.maxDeltaTimeInMs;
}
}
/**
* The PowerShellbatch class.
*
* - Single instance of PowerShell batch class manages a single single nodes-runspaces map, with a runspace corresponding to each node.
* - It queues coming requests and process one at a time sequentially.
* - If a command is slow and causing with multiple responses, it aggregates response into single Q result.
* - A PowerShellBatch instance should be created through create() function, and it's statically stored/managed into _map collection.
* - Once all lifetime references are gone, it deletes the runspaces map.
* - To dispose the PowerShellBatch instance, it can use lifetime.dispose().
*/
export class PowerShellBatch {
/**
* Static collection of PowerShellbatch objects.
*/
static map = {};
/**
* The context of PowerShellBatch object.
*/
context;
/**
* The queue of PowerShell command requests.
*/
queue = [];
/**
* The reference to PowerShellRaw class object.
*/
raw;
/**
* Current data to return to caller.
*/
currentData = [];
/**
* Current data map to aggregate partial data parts from multiple data responses.
*/
currentDataMap = {};
/**
* Timestamp when last command started.
*/
timestamp;
static create(nodesList, batchConnection, key, lifetime, requestOptions) {
let ps;
if (key && lifetime) {
ps = PowerShellBatch.map[PowerShellBatch.indexName(nodesList, key)];
if (ps) {
ps.addLifetime(lifetime);
return ps;
}
}
ps = new PowerShellBatch(nodesList, batchConnection, key, lifetime, requestOptions);
if (key && lifetime) {
PowerShellBatch.map[PowerShellBatch.indexName(nodesList, key)] = ps;
}
return ps;
}
/**
* Find existing PowerShellBatch object. Create call must be called before to create the PowerShellBatch instance.
*
* @param nodeName The node name.
* @param key The shared key to queue the requests to use the single runspace.
*/
static find(nodesList, key) {
return PowerShellBatch.map[PowerShellBatch.indexName(nodesList, key)];
}
/**
* Create the index name in map collection.
*
* @param nodesList The nodes list targeted by this PowerShellBatch object.
* @param key The shared key to queue the requests to use the single runspace.
*/
static indexName(nodesList, key) {
return nodesList.join(':') + ':' + key;
}
/**
* Initializes a new instance of the PowerShellBatch class.
* (private constructor which shouldn't be called directly.)
*
* @param nodeList The nodes list targeted by this PowerShellBatch object.
* @param batchConnection The batch connection service.
* @param key The shared key to queue the requests to use the single runspace map.
* @param lifetime The lifetime container.
*/
constructor(nodeList, batchConnection, key, lifetime, options) {
this.context = {
key: key,
nodesList: nodeList,
lifetimes: [],
requestOptions: PowerShell.newEndpointOptions(options)
};
this.timestamp = 0;
this.raw = new PowerShellBatchRaw(batchConnection, this.context);
if (key && lifetime) {
lifetime.registerForDispose(new Disposer(() => this.lifetimeDisposer(lifetime)));
this.context.lifetimes.push(lifetime);
}
}
/**
* Run PowerShellBatch command.
*
* @param command The command to run against all nodes in nodesList.
* @param options The options.
* @return observable The result of PowerShell batch command.
*/
runSingleCommand(command, options) {
const commandsList = [];
for (let i = 0; i < this.context.nodesList.length; i++) {
commandsList.push(command);
}
return this.run(commandsList, options);
}
/**
* Run PowerShellBatch command list.
*
* @param commandsList The commands to run against given nodesList.
* @param options The options.
* @return observable The result of PowerShell batch command.
*/
run(commandsList, options) {
if (commandsList.length !== this.context.nodesList.length) {
return EMPTY;
}
const commandBodyList = [];
// wrap command in properties.
for (const command of commandsList) {
commandBodyList.push(Net.createPropertiesJSONString(command));
}
if (this.context.lifetimes.length === 0) {
// no disposer is assigned, force to close the session after every query.
const timeoutMs = options && options.timeoutMs;
if (options) {
options.timeoutMs = timeoutMs;
options.close = true;
}
else {
options = { timeoutMs: timeoutMs, close: true };
}
}
// queue the request.
const observable = this.enqueue(this.context.nodesList, commandBodyList, options);
return observable;
}
/**
* Cancel PowerShellBatch command.
*/
cancel() {
return this.raw.cancelCommand();
}
/**
* Enqueue a command request.
*
* @param nodesList: the node list.
* @param commandBodyList The command.
* @param options The options.
*/
enqueue(nodesList, commandBodyList, options) {
return new Observable((observer) => {
this.queue.push({ nodesList, commandList: commandBodyList, observer, options });
this.dequeue();
});
}
/**
* Dequeue a command request.
*/
dequeue() {
if (this.raw.active) {
return false;
}
const item = this.queue.shift();
if (item) {
this.currentData = [];
this.currentDataMap = {};
this.timestamp = Date.now();
this.raw.runCommand(item.nodesList, item.commandList).subscribe({
next: data => {
this.collect(data, item.options && item.options.timeoutMs, item.options && item.options.partial ? item.observer : null);
},
error: error => {
if (item.options && item.options.close) {
this.raw.close().subscribe();
}
item.observer.error(error);
this.timestamp = 0;
this.dequeue();
},
complete: () => {
if (item.options && item.options.close) {
this.raw.close().subscribe();
}
if (!item.options || !item.options.partial) {
item.observer.next(this.currentData);
}
item.observer.complete();
this.timestamp = 0;
this.dequeue();
}
});
return true;
}
return false;
}
/**
* Collect response results for batch call and aggregate into single object.
*
* @param properties The properties of response object.
* @param timeoutMs The timeout to cancel command.
* @param observer The observer of powershell results.
*/
collect(psResponseList, timeoutMs, observer) {
if (timeoutMs && this.timestamp && (Date.now() - this.timestamp > timeoutMs)) {
// force to cancel the command because of unexpected longer execution.
this.raw.cancelCommand();
this.timestamp = 0;
return;
}
if (observer) {
// return partial data if observer is not null.
observer.next(psResponseList);
this.currentData = psResponseList;
return;
}
// Merge responses from calls which didn't complete in one go.
for (const item of psResponseList) {
// Check if we have saved record from previous call to add to.
if (this.currentDataMap[item.nodeName]) {
// Aggregate Results: If the newly received has results, aggregate them with saved data.
if (item.properties.results) {
let array;
// if any previously received results, use them in aggregation.
if (this.currentDataMap[item.nodeName].properties && this.currentDataMap[item.nodeName].properties.results) {
if (MsftSme.getTypeOf(this.currentDataMap[item.nodeName].properties.results) === 'array') {
array = this.currentDataMap[item.nodeName].properties.results;
}
else {
array = [this.currentDataMap[item.nodeName].properties.results];
}
}
else {
array = [];
}
// Add results from currently received data.
if (MsftSme.getTypeOf(item.properties.results) === 'array') {
item.properties.results.forEach((x) => {
array.push(x);
});
}
else {
array.push(item.properties.results);
}
// Update saved map with the new aggregated data
this.currentDataMap[item.nodeName].properties.results = array;
}
// Aggregate Errors: If the newly received response has errors field, aggregate them with saved data.
if (item.errors) {
let errorsArray;
// if any previously received errors, use them in aggregation.
if (this.currentDataMap[item.nodeName].errors) {
errorsArray = this.currentDataMap[item.nodeName].errors;
}
else {
errorsArray = [];
}
// Add results from currently received data.
item.errors.forEach((x) => {
errorsArray.push(x);
});
// Update saved map with the new aggregated data
this.currentDataMap[item.nodeName].errors = errorsArray;
}
}
else {
// first response/ no saved response. Simply add to map.
this.currentDataMap[item.nodeName] = item;
}
}
this.currentData = this.convertResponseMapDataToList(this.currentDataMap);
}
/**
* Helper method to convert a map data to list
*
* @param nodeMap The map of nodenames to PowerShellBatchResponseItem. Used to track different calls in a batch.
* @return The response data for the calls in a list.
*/
convertResponseMapDataToList(nodeMap) {
const responseList = [];
for (const key in nodeMap) {
if (nodeMap.hasOwnProperty(key)) {
responseList.push(nodeMap[key]);
}
}
return responseList;
}
/**
* Attach lifetime object to disposer when disposing.
*
* @param lifetime The lifetime object.
*/
addLifetime(lifetime) {
const found = MsftSme.find(this.context.lifetimes, (value) => value === lifetime);
if (!found) {
this.context.lifetimes.push(lifetime);
lifetime.registerForDispose(new Disposer(() => this.lifetimeDisposer(lifetime)));
}
}
/**
* Callback when disposing the container of view model.
* If none, reference the PowerShell object. Dispose it. (Delete runspace)
*
* @param lifetime The lifetime object.
*/
lifetimeDisposer(lifetime) {
const found = MsftSme.find(this.context.lifetimes, (value) => value === lifetime);
if (found) {
MsftSme.remove(this.context.lifetimes, lifetime);
if (this.context.lifetimes.length === 0) {
// cancel queue command requests.
this.queue.forEach((value) => {
value.observer.next(null);
value.observer.complete();
});
// delete from the map collection and delete the runspace/session.
delete PowerShellBatch.map[PowerShellBatch.indexName(this.context.nodesList, this.context.key)];
this.raw.dispose();
}
}
}
}
//# sourceMappingURL=powershell-batch.js.map