UNPKG

@microsoft/windows-admin-center-sdk

Version:

Microsoft - Windows Admin Center Shell

700 lines (698 loc) 28.5 kB
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