diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b1f0f7b4..4ea55e1f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added the ability to upload and download sas content using the context menu ([#547](https://github.com/sassoftware/vscode-sas-extension/issues/547)) - Added the ability to download results as an html file ([#546](https://github.com/sassoftware/vscode-sas-extension/issues/546)) +- Added sas 9.4 remote connection support via ITC and the IOM Bridge protocol ([#592](https://github.com/sassoftware/vscode-sas-extension/pull/592)) ## [v1.5.0] - 2023-10-27 diff --git a/README.md b/README.md index e5538a6d0..98b659de2 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,14 @@ Welcome to the SAS Extension for Visual Studio Code! This extension provides sup - [Code Folding and Code Outline](#code-folding-and-code-outline) - [Configuring the SAS Extension](#configuring-the-sas-extension) - [Profiles](/connect-and-run.md/#profiles) - - [Profile Anatomy (Viya)](/connect-and-run.md/#profile-anatomy-sas-viya) + - [Profile Details (Viya)](/connect-and-run.md/#profile-sas-viya) - [Add New SAS Viya Profile](/connect-and-run.md#add-new-sas-viya-profile) - - [Profile Anatomy (SAS 9.4 Remote)](/connect-and-run.md/#profile-anatomy-sas-94-remote) - - [Add New SAS 9.4 Remote Profile](/connect-and-run.md#add-new-sas-94-remote-profile) - - [Profile Anatomy (SAS 9.4 Local)](/connect-and-run.md/#profile-anatomy-sas-94-local) + - [Profile Details (SAS 9.4 Remote SSH)](/connect-and-run.md/#profile-sas-94-remote---ssh) + - [Add New SAS 9.4 Remote SSH Profile](/connect-and-run.md#add-new-sas-94-remote---ssh-profile) + - [Profile Details (SAS 9.4 Local)](/connect-and-run.md/#profile-sas-94-local) - [Add New SAS 9.4 Local Profile](/connect-and-run.md/#add-new-sas-94-local-profile) + - [Profile Details (SAS 9.4 Remote IOM)](/connect-and-run.md/#profile-sas-94-remote---iom) + - [Add New SAS 9.4 Remote IOM Profile](/connect-and-run.md/#add-new-sas-94-remote---iom-profile) - [Delete SAS Profile](/connect-and-run.md#delete-connection-profile) - [Switch Current SAS Profile](/connect-and-run.md#switch-current-connection-profile) - [Update SAS Profile](/connect-and-run.md#update-connection-profile) diff --git a/client/src/components/AuthProvider.ts b/client/src/components/AuthProvider.ts index 538c8f0e2..c9cb12503 100644 --- a/client/src/components/AuthProvider.ts +++ b/client/src/components/AuthProvider.ts @@ -8,7 +8,6 @@ import { Disposable, Event, EventEmitter, - SecretStorage, commands, workspace, } from "vscode"; @@ -17,6 +16,7 @@ import { profileConfig } from "../commands/profile"; import { ConnectionType } from "../components/profile"; import { getTokens, refreshToken } from "../connection/rest/auth"; import { getCurrentUser } from "../connection/rest/identities"; +import { getSecretStorage } from "./ExtensionContext"; const SECRET_KEY = "SASAuth"; @@ -27,6 +27,7 @@ interface SASAuthSession extends AuthenticationSession { export class SASAuthProvider implements AuthenticationProvider, Disposable { static id = "SAS"; + private readonly secretStorage; private _disposables: Disposable[]; private _lastSession: SASAuthSession | undefined; private _onDidChangeSessions = @@ -35,7 +36,7 @@ export class SASAuthProvider implements AuthenticationProvider, Disposable { return this._onDidChangeSessions.event; } - constructor(private readonly secretStorage: SecretStorage) { + constructor() { this._disposables = [ this._onDidChangeSessions, workspace.onDidChangeConfiguration((event: ConfigurationChangeEvent) => { @@ -52,6 +53,7 @@ export class SASAuthProvider implements AuthenticationProvider, Disposable { } }), ]; + this.secretStorage = getSecretStorage(SECRET_KEY); } dispose(): void { @@ -80,7 +82,7 @@ export class SASAuthProvider implements AuthenticationProvider, Disposable { } private async _getSessions(): Promise { - const sessions = await this.getStoredSessions(); + const sessions = await this.secretStorage.getNamespaceData(); if (!sessions) { return []; } @@ -152,18 +154,11 @@ export class SASAuthProvider implements AuthenticationProvider, Disposable { } private async writeSession(session: SASAuthSession): Promise { - const storedSessions = await this.getStoredSessions(); - - const sessions = { - ...(storedSessions || {}), - [session.id]: session, - }; - - await this.secretStorage.store(SECRET_KEY, JSON.stringify(sessions)); + this.secretStorage.store(session.id, session); } async removeSession(sessionId: string, silent?: boolean): Promise { - const sessions = await this.getStoredSessions(); + const sessions = await this.secretStorage.getNamespaceData(); if (!sessions) { return; } @@ -178,14 +173,14 @@ export class SASAuthProvider implements AuthenticationProvider, Disposable { if (!silent) { // Triggered by user sign out from the Accounts menu // VS Code will sign out all sessions by this account - Object.values(sessions).forEach((s) => { + Object.values(sessions).forEach((s: SASAuthSession) => { if (s.account.id === session.account.id) { delete sessions[s.id]; } }); } - await this.secretStorage.store(SECRET_KEY, JSON.stringify(sessions)); + await this.secretStorage.setNamespaceData(sessions); this._lastSession = undefined; this._onDidChangeSessions.fire({ @@ -198,15 +193,4 @@ export class SASAuthProvider implements AuthenticationProvider, Disposable { commands.executeCommand("setContext", "SAS.authorized", false); } } - - private async getStoredSessions(): Promise< - Record | undefined - > { - const storedSessionData = await this.secretStorage.get(SECRET_KEY); - if (!storedSessionData) { - return; - } - - return JSON.parse(storedSessionData); - } } diff --git a/client/src/components/ExtensionContext.ts b/client/src/components/ExtensionContext.ts index 1fd52e836..360ad0d70 100644 --- a/client/src/components/ExtensionContext.ts +++ b/client/src/components/ExtensionContext.ts @@ -1,6 +1,6 @@ // Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { ExtensionContext } from "vscode"; +import { ExtensionContext, Uri } from "vscode"; let context: ExtensionContext; @@ -26,3 +26,41 @@ export async function getContextValue( ): Promise { return context.workspaceState.get(key); } + +export function getGlobalStorageUri(): Uri { + return context.globalStorageUri; +} + +export function getSecretStorage(namespace: string) { + const getNamespaceData = async (): Promise | undefined> => { + const storedSessionData = await context.secrets.get(namespace); + if (!storedSessionData) { + return; + } + + return JSON.parse(storedSessionData); + }; + const setNamespaceData = async (data: Record) => { + await context.secrets.store(namespace, JSON.stringify(data)); + }; + + const get = async (key: string): Promise => { + const data = await getNamespaceData(); + if (!data) { + return; + } + + return data[key]; + }; + + const store = async (key: string, value: T) => { + const data = await getNamespaceData(); + const newData = { + ...(data || {}), + [key]: value, + }; + await context.secrets.store(namespace, JSON.stringify(newData)); + }; + + return { setNamespaceData, getNamespaceData, get, store }; +} diff --git a/client/src/components/profile.ts b/client/src/components/profile.ts index 96ab349a0..9ef9766d4 100644 --- a/client/src/components/profile.ts +++ b/client/src/components/profile.ts @@ -17,14 +17,16 @@ export const EXTENSION_PROFILES_CONFIG_KEY = "profiles"; export const EXTENSION_ACTIVE_PROFILE_CONFIG_KEY = "activeProfile"; enum ConnectionOptions { + SAS9COM = "SAS 9.4 (local)", + SAS9IOM = "SAS 9.4 (remote - IOM)", + SAS9SSH = "SAS 9.4 (remote - SSH)", SASViya = "SAS Viya", - SAS94Remote = "SAS 9.4 (remote)", - SAS9COM = "SAS 9.4 (local - COM)", } const CONNECTION_PICK_OPTS: string[] = [ ConnectionOptions.SASViya, - ConnectionOptions.SAS94Remote, + ConnectionOptions.SAS9SSH, + ConnectionOptions.SAS9IOM, ConnectionOptions.SAS9COM, ]; @@ -33,6 +35,7 @@ const CONNECTION_PICK_OPTS: string[] = [ */ export const DEFAULT_COMPUTE_CONTEXT = "SAS Job Execution compute context"; export const DEFAULT_SSH_PORT = "22"; +export const DEFAULT_IOM_PORT = "8591"; /** * Dictionary is a type that maps a generic object with a string key. @@ -53,9 +56,10 @@ export enum AuthType { * Enum that represents the connection type for a profile. */ export enum ConnectionType { + COM = "com", + IOM = "iom", Rest = "rest", SSH = "ssh", - COM = "com", } /** @@ -91,7 +95,14 @@ export interface COMProfile extends BaseProfile { host: string; } -export type Profile = ViyaProfile | SSHProfile | COMProfile; +export interface IOMProfile extends BaseProfile { + connectionType: ConnectionType.IOM; + host: string; + username: string; + port: number; +} + +export type Profile = ViyaProfile | SSHProfile | COMProfile | IOMProfile; export enum AutoExecType { File = "file", @@ -573,6 +584,32 @@ export class ProfileConfig { } else if (profileClone.connectionType === ConnectionType.COM) { profileClone.sasOptions = []; profileClone.host = "localhost"; //once remote support rolls out this should be set via prompting + await this.upsertProfile(name, profileClone); + } else if (profileClone.connectionType === ConnectionType.IOM) { + profileClone.sasOptions = []; + profileClone.host = await createInputTextBox( + ProfilePromptType.Host, + profileClone.host, + ); + if (!profileClone.host) { + return; + } + + profileClone.port = parseInt( + await createInputTextBox(ProfilePromptType.Port, DEFAULT_IOM_PORT), + ); + if (isNaN(profileClone.port)) { + return; + } + + profileClone.username = await createInputTextBox( + ProfilePromptType.Username, + profileClone.username, + ); + if (profileClone.username === undefined) { + return; + } + await this.upsertProfile(name, profileClone); } } @@ -588,6 +625,7 @@ export class ProfileConfig { switch (activeProfile.connectionType) { case ConnectionType.SSH: case ConnectionType.COM: + case ConnectionType.IOM: return activeProfile.host; case ConnectionType.Rest: return activeProfile.endpoint; @@ -737,9 +775,9 @@ const input: ProfilePromptInput = { description: l10n.t("Select a Connection Type."), }, [ProfilePromptType.Host]: { - title: l10n.t("SAS 9 SSH Server"), + title: l10n.t("SAS 9 Server"), placeholder: l10n.t("Enter the server name"), - description: l10n.t("Enter the name of the SAS 9 SSH server."), + description: l10n.t("Enter the name of the SAS 9 server."), }, [ProfilePromptType.SASPath]: { title: l10n.t("Server Path"), @@ -772,10 +810,12 @@ function mapQuickPickToEnum(connectionTypePickInput: string): ConnectionType { switch (connectionTypePickInput) { case ConnectionOptions.SASViya: return ConnectionType.Rest; - case ConnectionOptions.SAS94Remote: + case ConnectionOptions.SAS9SSH: return ConnectionType.SSH; case ConnectionOptions.SAS9COM: return ConnectionType.COM; + case ConnectionOptions.SAS9IOM: + return ConnectionType.IOM; default: return undefined; } diff --git a/client/src/components/utils/sasCode.ts b/client/src/components/utils/sasCode.ts index c9599a0be..12effbf78 100644 --- a/client/src/components/utils/sasCode.ts +++ b/client/src/components/utils/sasCode.ts @@ -3,6 +3,7 @@ import { ColorThemeKind, l10n, window, workspace } from "vscode"; import { isAbsolute } from "path"; +import { v4 } from "uuid"; import { getHtmlStyle, isOutputHtmlEnabled } from "./settings"; @@ -48,7 +49,7 @@ export function wrapCodeWithOutputHtml(code: string): string { const htmlStyleOption = generateHtmlStyleOption(); return `title;footnote;ods _all_ close; ods graphics on; -ods html5${htmlStyleOption} options(bitmap_mode='inline' svg_mode='inline'); +ods html5${htmlStyleOption} options(bitmap_mode='inline' svg_mode='inline') body="${v4()}.htm"; ${code} ;*';*";*/;run;quit;ods html5 close;`; } else { @@ -56,6 +57,15 @@ ${code} } } +export function extractOutputHtmlFileName( + line: string, + defaultValue: string, +): string { + return ( + line.match(/body="(.{8}-.{4}-.{4}-.{4}-.{12}).htm"/)?.[1] ?? defaultValue + ); +} + export async function wrapCodeWithPreambleAndPostamble( code: string, preamble?: string, diff --git a/client/src/connection/com/index.ts b/client/src/connection/com/index.ts deleted file mode 100644 index 4b9c983d2..000000000 --- a/client/src/connection/com/index.ts +++ /dev/null @@ -1,277 +0,0 @@ -// Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { ChildProcessWithoutNullStreams, spawn } from "child_process"; -import { readFileSync } from "fs"; -import { resolve } from "path"; - -import { BaseConfig, RunResult } from ".."; -import { updateStatusBarItem } from "../../components/StatusBarItem"; -import { Session } from "../session"; -import { scriptContent } from "./script"; - -const endCode = "--vscode-sas-extension-submit-end--"; -let sessionInstance: COMSession; - -/** - * Configuration parameters for this connection provider - */ -export interface Config extends BaseConfig { - host: string; -} - -export class COMSession extends Session { - private _config: Config; - private _shellProcess: ChildProcessWithoutNullStreams; - private _html5FileName: string; - private _runResolve: ((value?) => void) | undefined; - private _runReject: ((reason?) => void) | undefined; - private _workDirectory: string; - - constructor() { - super(); - } - - public set config(value: Config) { - this._config = value; - } - - /** - * Initialization logic that should be performed prior to execution. - * @returns void promise. - */ - public setup = async (): Promise => { - return new Promise((resolve, reject) => { - this._runResolve = resolve; - this._runReject = reject; - - if (this._shellProcess && !this._shellProcess.killed) { - resolve(); - return; //manually terminate to avoid executing the code below - } - - this._shellProcess = spawn("powershell.exe /nologo -Command -", { - shell: true, - env: process.env, - }); - this._shellProcess.stdout.on("data", this.onShellStdOut); - this._shellProcess.stderr.on("data", this.onShellStdErr); - this._shellProcess.stdin.write( - scriptContent + "\n", - this.onWriteComplete, - ); - this._shellProcess.stdin.write( - "$runner = New-Object -TypeName SASRunner\n", - this.onWriteComplete, - ); - - /* - There are cases where the higher level run command will invoke setup multiple times. - Avoid re-initializing the session when this happens. In a first run scenario a work dir - will not exist. The work dir should only be deleted when close is invoked. - */ - if (!this._workDirectory) { - this._shellProcess.stdin.write( - `$profileHost = "${this._config.host}"\n`, - ); - this._shellProcess.stdin.write( - "$runner.Setup($profileHost)\n", - this.onWriteComplete, - ); - this._shellProcess.stdin.write( - "$runner.ResolveSystemVars()\n", - this.onWriteComplete, - ); - - if (this._config.sasOptions?.length > 0) { - const sasOptsInput = `$sasOpts=${this.formatSASOptions( - this._config.sasOptions, - )}\n`; - this._shellProcess.stdin.write(sasOptsInput, this.onWriteComplete); - this._shellProcess.stdin.write( - `$runner.SetOptions($sasOpts)\n`, - this.onWriteComplete, - ); - } - } - - // free objects in the scripting env - process.on("exit", async () => { - close(); - }); - }); - }; - - /** - * Executes the given input code. - * @param code A string of SAS code to execute. - * @param onLog A callback handler responsible for marshalling log lines back to the higher level extension API. - * @returns A promise that eventually resolves to contain the given {@link RunResult} for the input code execution. - */ - public run = async (code: string): Promise => { - return new Promise((resolve, reject) => { - this._runResolve = resolve; - this._runReject = reject; - - //write ODS output to work so that the session cleans up after itself - const codeWithODSPath = code.replace( - "ods html5;", - `ods html5 path="${this._workDirectory}";`, - ); - - //write an end mnemonic so that the handler knows when execution has finished - const codeWithEnd = `${codeWithODSPath}\n%put ${endCode};`; - const codeToRun = `$code=\n@'\n${codeWithEnd}\n'@\n`; - - this._shellProcess.stdin.write(codeToRun); - this._shellProcess.stdin.write(`$runner.Run($code)\n`, async (error) => { - if (error) { - this._runReject(error); - } - - await this.fetchLog(); - }); - }); - }; - - /** - * Cleans up resources for the given local SAS session. - * @returns void promise. - */ - public close = async (): Promise => { - return new Promise((resolve) => { - if (this._shellProcess) { - this._shellProcess.stdin.write( - "$runner.Close()\n", - this.onWriteComplete, - ); - this._shellProcess.kill(); - this._shellProcess = undefined; - - this._workDirectory = undefined; - this._runReject = undefined; - this._runResolve = undefined; - } - resolve(); - updateStatusBarItem(false); - }); - }; - - /** - * Formats the SAS Options provided in the profile into a format - * that the shell process can understand. - * @param sasOptions SAS Options array from the connection profile. - * @returns a string denoting powershell syntax for an array literal. - */ - private formatSASOptions = (sasOptions: string[]): string => { - const optionsVariable = `@("${sasOptions.join(`","`)}")`; - return optionsVariable; - }; - - /** - * Flushes the SAS log in chunks of [chunkSize] length, - * writing each chunk to stdout. - */ - private fetchLog = async (): Promise => { - this._shellProcess.stdin.write( - ` -do { - $chunkSize = 32768 - $log = $runner.FlushLog($chunkSize) - Write-Host $log -} while ($log.Length -gt 0)\n - `, - this.onWriteComplete, - ); - }; - - /** - * Handles stderr output from the powershell child process. - * @param chunk a buffer of stderr output from the child process. - */ - private onShellStdErr = (chunk: Buffer): void => { - const msg = chunk.toString(); - console.warn("shellProcess stderr: " + msg); - this._runReject( - new Error( - "There was an error executing the SAS Program.\nSee console log for more details.", - ), - ); - }; - - /** - * Handles stdout output from the powershell child process. - * @param data a buffer of stdout output from the child process. - */ - private onShellStdOut = (data: Buffer): void => { - const output = data.toString().trimEnd(); - const outputLines = output.split(/\n|\r\n/); - - outputLines.forEach((line: string) => { - if (!line) { - return; - } - if (!this._workDirectory && line.startsWith("WORKDIR=")) { - const parts = line.split("WORKDIR="); - this._workDirectory = parts[1].trim(); - this._runResolve(); - updateStatusBarItem(true); - return; - } - if (line.endsWith(endCode)) { - // run completed - this.fetchResults(); - } else { - this._html5FileName = - line.match(/NOTE: .+ HTML5.* Body.+: (.+)\.htm/)?.[1] ?? - this._html5FileName; - this._onLogFn?.([{ type: "normal", line }]); - } - }); - }; - - /** - * Generic call for use on stdin write completion. - * @param err The error encountered on the write attempt. Undefined if no error occurred. - */ - private onWriteComplete = (err: Error): void => { - if (err) { - this._runReject?.(err); - } - }; - - /** - * Not implemented. - */ - public sessionId = (): string => { - throw new Error("Not Implemented"); - }; - - /** - * Fetches the ODS output results for the latest html results file. - */ - private fetchResults = () => { - const htmlResults = readFileSync( - resolve(this._workDirectory, this._html5FileName + ".htm"), - { encoding: "utf-8" }, - ); - const runResult: RunResult = {}; - if (htmlResults.search('<*id="IDX*.+">') !== -1) { - runResult.html5 = htmlResults; - runResult.title = "Result"; - } - this._runResolve(runResult); - }; -} - -/** - * Creates a new SAS 9 Local Session. - * @param c Instance denoting configuration parameters for this connection profile. - * @returns created COM session. - */ -export const getSession = (c: Config): Session => { - if (!sessionInstance) { - sessionInstance = new COMSession(); - } - sessionInstance.config = c; - return sessionInstance; -}; diff --git a/client/src/connection/index.ts b/client/src/connection/index.ts index d9a9a12fd..c7dcc4372 100644 --- a/client/src/connection/index.ts +++ b/client/src/connection/index.ts @@ -9,7 +9,7 @@ import { ViyaProfile, toAutoExecLines, } from "../components/profile"; -import { getSession as getCOMSession } from "./com"; +import { ITCProtocol, getSession as getITCSession } from "./itc"; import { Config as RestConfig, getSession as getRestSession } from "./rest"; import { Error2 as ComputeError, @@ -54,7 +54,9 @@ export function getSession(): Session { case ConnectionType.SSH: return getSSHSession(validProfile.profile); case ConnectionType.COM: - return getCOMSession(validProfile.profile); + return getITCSession(validProfile.profile, ITCProtocol.COM); + case ConnectionType.IOM: + return getITCSession(validProfile.profile, ITCProtocol.IOMBridge); default: throw new Error( l10n.t("Invalid connectionType. Check Profile settings."), diff --git a/client/src/connection/itc/index.ts b/client/src/connection/itc/index.ts new file mode 100644 index 000000000..00884f7c5 --- /dev/null +++ b/client/src/connection/itc/index.ts @@ -0,0 +1,446 @@ +// Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { Uri, env, l10n, window, workspace } from "vscode"; + +import { ChildProcessWithoutNullStreams, spawn } from "child_process"; +import { resolve } from "path"; + +import { BaseConfig, RunResult } from ".."; +import { + getGlobalStorageUri, + getSecretStorage, +} from "../../components/ExtensionContext"; +import { updateStatusBarItem } from "../../components/StatusBarItem"; +import { extractOutputHtmlFileName } from "../../components/utils/sasCode"; +import { Session } from "../session"; +import { scriptContent } from "./script"; +import { LineCodes } from "./types"; + +const SECRET_STORAGE_NAMESPACE = "ITC_SECRET_STORAGE"; + +let sessionInstance: ITCSession; + +export enum ITCProtocol { + COM = 0, + IOMBridge = 2, +} + +/** + * Configuration parameters for this connection provider + */ +export interface Config extends BaseConfig { + host: string; + port: number; + username: string; + protocol: ITCProtocol; +} + +export class ITCSession extends Session { + private _config: Config; + private _shellProcess: ChildProcessWithoutNullStreams; + private _html5FileName: string; + private _runResolve: ((value?) => void) | undefined; + private _runReject: ((reason?) => void) | undefined; + private _workDirectory: string; + private _password: string; + private _secretStorage; + private _passwordKey: string; + private _pollingForLogResults: boolean; + + constructor() { + super(); + this._password = ""; + this._secretStorage = getSecretStorage(SECRET_STORAGE_NAMESPACE); + this._pollingForLogResults = false; + } + + public set config(value: Config) { + this._config = value; + this._passwordKey = `${value.host}${value.protocol}${value.username}`; + } + + /** + * Initialization logic that should be performed prior to execution. + * @returns void promise. + */ + public setup = async (): Promise => { + const setupPromise = new Promise((resolve, reject) => { + this._runResolve = resolve; + this._runReject = reject; + }); + + if (this._shellProcess && !this._shellProcess.killed) { + this._runResolve(); + return; // manually terminate to avoid executing the code below + } + + this._shellProcess = spawn( + "chcp 65001 >NUL & powershell.exe -NonInteractive -NoProfile -Command -", + { + shell: true, + env: process.env, + }, + ); + this._shellProcess.stdout.on("data", this.onShellStdOut); + this._shellProcess.stderr.on("data", this.onShellStdErr); + this._shellProcess.stdin.write(scriptContent + "\n", this.onWriteComplete); + this._shellProcess.stdin.write( + "$runner = New-Object -TypeName SASRunner\n", + this.onWriteComplete, + ); + + /* + * There are cases where the higher level run command will invoke setup multiple times. + * Avoid re-initializing the session when this happens. In a first run scenario a work dir + * will not exist. The work dir should only be deleted when close is invoked. + */ + if (!this._workDirectory) { + const { host, port, protocol, username } = this._config; + this._shellProcess.stdin.write(`$profileHost = "${host}"\n`); + this._shellProcess.stdin.write(`$port = ${port}\n`); + this._shellProcess.stdin.write(`$protocol = ${protocol}\n`); + this._shellProcess.stdin.write(`$username = "${username}"\n`); + const password = await this.fetchPassword(); + this._shellProcess.stdin.write(`$password = "${password}"\n`); + this._shellProcess.stdin.write( + `$serverName = "${ + protocol === ITCProtocol.COM ? "ITC Local" : "ITC IOM Bridge" + }"\n`, + ); + this._shellProcess.stdin.write(`$displayLang = "${env.language}"\n`); + this._shellProcess.stdin.write( + `$runner.Setup($profileHost,$username,$password,$port,$protocol,$serverName,$displayLang)\n`, + this.onWriteComplete, + ); + this._shellProcess.stdin.write( + "$runner.ResolveSystemVars()\n", + this.onWriteComplete, + ); + + if (this._config.sasOptions?.length > 0) { + const sasOptsInput = `$sasOpts=${this.formatSASOptions( + this._config.sasOptions, + )}\n`; + this._shellProcess.stdin.write(sasOptsInput, this.onWriteComplete); + this._shellProcess.stdin.write( + `$runner.SetOptions($sasOpts)\n`, + this.onWriteComplete, + ); + } + } + + // free objects in the scripting env + process.on("exit", async () => { + close(); + }); + + return setupPromise; + }; + + private storePassword = async () => + await this._secretStorage.store(this._passwordKey, this._password); + + private clearPassword = async () => { + await this._secretStorage.store(this._passwordKey, ""); + this._password = ""; + }; + + private fetchPassword = async (): Promise => { + if (this._config.protocol === ITCProtocol.COM) { + return ""; + } + + const storedPassword = await this._secretStorage.get(this._passwordKey); + if (storedPassword) { + this._password = storedPassword; + return storedPassword; + } + + this._password = + (await window.showInputBox({ + ignoreFocusOut: true, + password: true, + prompt: l10n.t("Enter your password for this connection."), + title: l10n.t("Enter your password"), + })) || ""; + + return this._password; + }; + + /** + * Executes the given input code. + * @param code A string of SAS code to execute. + * @param onLog A callback handler responsible for marshalling log lines back to the higher level extension API. + * @returns A promise that eventually resolves to contain the given {@link RunResult} for the input code execution. + */ + public run = async (code: string): Promise => { + const runPromise = new Promise((resolve, reject) => { + this._runResolve = resolve; + this._runReject = reject; + }); + + //write ODS output to work so that the session cleans up after itself + const codeWithODSPath = code.replace( + "ods html5;", + `ods html5 path="${this._workDirectory}";`, + ); + + //write an end mnemonic so that the handler knows when execution has finished + const codeWithEnd = `${codeWithODSPath}\n%put ${LineCodes.RunEndCode};`; + const codeToRun = `$code=\n@'\n${codeWithEnd}\n'@\n`; + + this._html5FileName = ""; + this._shellProcess.stdin.write(codeToRun); + this._pollingForLogResults = true; + this._shellProcess.stdin.write(`$runner.Run($code)\n`, async (error) => { + if (error) { + this._runReject(error); + } + + await this.fetchLog(); + }); + + return runPromise; + }; + + /** + * Cleans up resources for the given SAS session. + * @returns void promise. + */ + public close = async (): Promise => { + return new Promise((resolve) => { + if (this._shellProcess) { + this._shellProcess.stdin.write( + "$runner.Close()\n", + this.onWriteComplete, + ); + this._shellProcess.kill(); + this._shellProcess = undefined; + + this._workDirectory = undefined; + this._runReject = undefined; + this._runResolve = undefined; + } + this.clearPassword(); + resolve(); + updateStatusBarItem(false); + }); + }; + + /** + * Cancels a running SAS program + */ + public cancel = async () => { + this._pollingForLogResults = false; + this._shellProcess.stdin.write("$runner.Cancel()\n", async (error) => { + if (error) { + this._runReject(error); + } + + await this.fetchLog(); + }); + }; + + /** + * Formats the SAS Options provided in the profile into a format + * that the shell process can understand. + * @param sasOptions SAS Options array from the connection profile. + * @returns a string denoting powershell syntax for an array literal. + */ + private formatSASOptions = (sasOptions: string[]): string => { + const optionsVariable = `@("${sasOptions.join(`","`)}")`; + return optionsVariable; + }; + + /** + * Flushes the SAS log in chunks of [chunkSize] length, + * writing each chunk to stdout. + */ + private fetchLog = async (): Promise => { + const pollingInterval = setInterval(() => { + if (!this._pollingForLogResults) { + clearInterval(pollingInterval); + } + this._shellProcess.stdin.write( + ` + do { + $chunkSize = 32768 + $log = $runner.FlushLog($chunkSize) + Write-Host $log + } while ($log.Length -gt 0)\n + `, + this.onWriteComplete, + ); + }, 2 * 1000); + }; + + /** + * Handles stderr output from the powershell child process. + * @param chunk a buffer of stderr output from the child process. + */ + private onShellStdErr = (chunk: Buffer): void => { + const msg = chunk.toString(); + console.warn("shellProcess stderr: " + msg); + this.clearPassword(); + this._runReject( + new Error( + "There was an error executing the SAS Program.\nSee console log for more details.", + ), + ); + // If we encountered an error in setup, we need to go through everything again + if (/Setup error/.test(msg)) { + this._shellProcess.kill(); + this._workDirectory = undefined; + } + }; + + /** + * Handles stdout output from the powershell child process. + * @param data a buffer of stdout output from the child process. + */ + private onShellStdOut = (data: Buffer): void => { + const output = data.toString().trimEnd(); + const outputLines = output.split(/\n|\r\n/); + + outputLines.forEach((line: string) => { + if (!line) { + return; + } + + if (!this._workDirectory && line.startsWith("WORKDIR=")) { + const parts = line.split("WORKDIR="); + this._workDirectory = parts[1].trim(); + this._runResolve(); + updateStatusBarItem(true); + return; + } + if (!this.processLineCodes(line)) { + this._html5FileName = extractOutputHtmlFileName( + line, + this._html5FileName, + ); + this._onLogFn?.([{ type: "normal", line }]); + } + }); + }; + + private processLineCodes(line: string): boolean { + if (line.endsWith(LineCodes.RunEndCode)) { + // run completed + this.fetchResults(); + return true; + } + + if (line.includes(LineCodes.SessionCreatedCode)) { + this.storePassword(); + return true; + } + + if (line.includes(LineCodes.ResultsFetchedCode)) { + this.displayResults(); + return true; + } + + if (line.includes(LineCodes.RunCancelledCode)) { + this._runResolve({}); + return true; + } + + return false; + } + + /** + * Generic call for use on stdin write completion. + * @param err The error encountered on the write attempt. Undefined if no error occurred. + */ + private onWriteComplete = (err: Error): void => { + if (err) { + this._runReject?.(err); + } + }; + + /** + * Not implemented. + */ + public sessionId = (): string => { + throw new Error("Not Implemented"); + }; + + /** + * Fetches the ODS output results for the latest html results file. + */ + private fetchResults = async () => { + if (!this._html5FileName) { + return this._runResolve({}); + } + + const globalStorageUri = getGlobalStorageUri(); + try { + await workspace.fs.readDirectory(globalStorageUri); + } catch (e) { + await workspace.fs.createDirectory(globalStorageUri); + } + + this._pollingForLogResults = false; + const outputFileUri = Uri.joinPath( + globalStorageUri, + `${this._html5FileName}.htm`, + ); + const directorySeparator = + this._workDirectory.lastIndexOf("/") !== -1 ? "/" : "\\"; + const filePath = + this._config.protocol === ITCProtocol.COM + ? resolve(this._workDirectory, this._html5FileName + ".htm") + : `${this._workDirectory}${directorySeparator}${this._html5FileName}.htm`; + this._shellProcess.stdin.write( + `$filePath = "${filePath}" +$outputFile = "${outputFileUri.fsPath}" +$runner.FetchResultsFile($filePath, $outputFile)\n`, + this.onWriteComplete, + ); + }; + + private displayResults = async () => { + const globalStorageUri = getGlobalStorageUri(); + const outputFileUri = Uri.joinPath( + globalStorageUri, + `${this._html5FileName}.htm`, + ); + const file = await workspace.fs.readFile(outputFileUri); + + const htmlResults = (file || "").toString(); + if (file) { + workspace.fs.delete(outputFileUri); + } + + const runResult: RunResult = {}; + if (htmlResults.search('<*id="IDX*.+">') !== -1) { + runResult.html5 = htmlResults; + runResult.title = "Result"; + } + this._runResolve(runResult); + }; +} + +/** + * Creates a new SAS 9 Session. + * @param c Instance denoting configuration parameters for this connection profile. + * @returns created COM session. + */ +export const getSession = ( + c: Partial, + protocol: ITCProtocol, +): Session => { + const defaults = { + host: "localhost", + port: 0, + username: "", + protocol, + }; + + if (!sessionInstance) { + sessionInstance = new ITCSession(); + } + sessionInstance.config = { ...defaults, ...c }; + return sessionInstance; +}; diff --git a/client/src/connection/com/script.ts b/client/src/connection/itc/script.ts similarity index 57% rename from client/src/connection/com/script.ts rename to client/src/connection/itc/script.ts index e3ab02b39..6b20533de 100644 --- a/client/src/connection/com/script.ts +++ b/client/src/connection/itc/script.ts @@ -1,5 +1,6 @@ // Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { LineCodes } from "./types"; export const scriptContent = ` class SASRunner{ @@ -17,27 +18,33 @@ class SASRunner{ $varLogs = $this.FlushLog(4096) Write-Host $varLogs } - [void]Setup([string]$profileHost) { + [void]Setup([string]$profileHost, [string]$username, [string]$password, [int]$port, [int]$protocol, [string]$serverName, [string]$displayLang) { try { + # Set Encoding for input and output + $OutputEncoding = [Console]::InputEncoding = [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding + # create the Integration Technologies objects $objFactory = New-Object -ComObject SASObjectManager.ObjectFactoryMulti2 $objServerDef = New-Object -ComObject SASObjectManager.ServerDef $objServerDef.MachineDNSName = $profileHost # SAS Workspace node - $objServerDef.Port = 0 # workspace server port - $objServerDef.Protocol = 0 # 0 = COM protocol + $objServerDef.Port = $port # workspace server port + $objServerDef.Protocol = $protocol # 0 = COM protocol + $sasLocale = $displayLang -replace '-', '_' + $objServerDef.ExtraNameValuePairs="LOCALE=$sasLocale" # set the correct locale # Class Identifier for SAS Workspace $objServerDef.ClassIdentifier = "440196d4-90f0-11d0-9f41-00a024bb830c" # create and connect to the SAS session $this.objSAS = $objFactory.CreateObjectByServer( - "Local", # server name + $serverName, # server name $true, $objServerDef, # built server definition - "", # user ID - "" # password + $username, + $password ) + Write-Host "${LineCodes.SessionCreatedCode}" } catch { throw "Setup error" } @@ -75,6 +82,8 @@ class SASRunner{ [void]Run([string]$code) { try{ + $this.objSAS.LanguageService.Reset() + $this.objSAS.LanguageService.Async = $true $this.objSAS.LanguageService.Submit($code) }catch{ Write-Error $_.ScriptStackTrace @@ -90,6 +99,15 @@ class SASRunner{ } } + [void]Cancel(){ + try{ + $this.objSAS.LanguageService.Cancel() + Write-Host "${LineCodes.RunCancelledCode}" + }catch{ + throw "Cancel error" + } + } + [String]FlushLog([int]$chunkSize) { try{ return $this.objSAS.LanguageService.FlushLog($chunkSize) @@ -106,6 +124,31 @@ class SASRunner{ Write-Host $log } while ($log.Length -gt 0) } -} + [void]FetchResultsFile([string]$filePath, [string]$outputFile) { + $fileRef = "" + $objFile = $this.objSAS.FileService.AssignFileref("outfile", "DISK", $filePath, "", [ref] $fileRef) + $objStream = $objFile.OpenBinaryStream(1); + [Byte[]] $bytes = 0x0 + + $endOfFile = $false + $byteCount = 0 + $outStream = New-Object System.IO.FileStream($outputFile, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::Write) + try { + do + { + $objStream.Read(8192, [ref] $bytes) + $outStream.Write($bytes, 0, $bytes.length) + $endOfFile = $bytes.Length -lt 8192 + $byteCount = $byteCount + $bytes.Length + } while (-not $endOfFile) + } finally { + $objStream.Close() + $outStream.Close() + $this.objSAS.FileService.DeassignFileref($objFile.FilerefName) + } + + Write-Host "${LineCodes.ResultsFetchedCode}" + } +} `; diff --git a/client/src/connection/itc/types.ts b/client/src/connection/itc/types.ts new file mode 100644 index 000000000..d784601ed --- /dev/null +++ b/client/src/connection/itc/types.ts @@ -0,0 +1,9 @@ +// Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export enum LineCodes { + ResultsFetchedCode = "--vscode-sas-extension-results-fetched--", + RunCancelledCode = "--vscode-sas-extension-run-cancelled--", + RunEndCode = "--vscode-sas-extension-submit-end--", + SessionCreatedCode = "--vscode-sas-extension-session-created--", +} diff --git a/client/src/connection/ssh/index.ts b/client/src/connection/ssh/index.ts index 6fb157197..91adf4e03 100644 --- a/client/src/connection/ssh/index.ts +++ b/client/src/connection/ssh/index.ts @@ -6,6 +6,7 @@ import { Client, ClientChannel, ConnectConfig } from "ssh2"; import { BaseConfig, RunResult } from ".."; import { updateStatusBarItem } from "../../components/StatusBarItem"; +import { extractOutputHtmlFileName } from "../../components/utils/sasCode"; import { Session } from "../session"; const endCode = "--vscode-sas-extension-submit-end--"; @@ -192,9 +193,10 @@ export class SSHSession extends Session { this.getResult(); } if (!(line.endsWith("?") || line.endsWith(">"))) { - this.html5FileName = - line.match(/NOTE: .+ HTML5.* Body .+: (.+)\.htm/)?.[1] ?? - this.html5FileName; + this.html5FileName = extractOutputHtmlFileName( + line, + this.html5FileName, + ); this._onLogFn?.([{ type: "normal", line }]); } }); diff --git a/client/src/node/extension.ts b/client/src/node/extension.ts index 87af091f9..74c99a4ed 100644 --- a/client/src/node/extension.ts +++ b/client/src/node/extension.ts @@ -54,8 +54,11 @@ import { SAS_TASK_TYPE } from "../components/tasks/SasTasks"; let client: LanguageClient; +export let extensionContext: ExtensionContext | undefined; + export function activate(context: ExtensionContext): void { // The server is implemented in node + extensionContext = context; const serverModule = context.asAbsolutePath( path.join("server", "dist", "node", "server.js"), ); @@ -125,7 +128,7 @@ export function activate(context: ExtensionContext): void { authentication.registerAuthenticationProvider( SASAuthProvider.id, "SAS", - new SASAuthProvider(context.secrets), + new SASAuthProvider(), ), languages.registerDocumentSemanticTokensProvider( { language: "sas-log" }, diff --git a/client/test/components/profile/profile.test.ts b/client/test/components/profile/profile.test.ts index 9f8c4baee..3ef72a670 100644 --- a/client/test/components/profile/profile.test.ts +++ b/client/test/components/profile/profile.test.ts @@ -944,8 +944,8 @@ describe("Profiles", async function () { { name: "Host", prompt: ProfilePromptType.Host, - wantTitle: "SAS 9 SSH Server", - wantDescription: "Enter the name of the SAS 9 SSH server.", + wantTitle: "SAS 9 Server", + wantDescription: "Enter the name of the SAS 9 server.", wantPlaceHolder: "Enter the server name", }, { diff --git a/client/test/connection/com/index.test.ts b/client/test/connection/itc/index.test.ts similarity index 56% rename from client/test/connection/com/index.test.ts rename to client/test/connection/itc/index.test.ts index ac314e80e..4fa0d37dd 100644 --- a/client/test/connection/com/index.test.ts +++ b/client/test/connection/itc/index.test.ts @@ -1,13 +1,21 @@ +import * as vscode from "vscode"; + import { expect } from "chai"; import proc from "child_process"; -import fs from "fs"; +import { unlinkSync, writeFileSync } from "fs"; +import { join } from "path"; import { SinonSandbox, SinonStub, createSandbox } from "sinon"; +import { stubInterface } from "ts-sinon"; +import { v4 } from "uuid"; -import { getSession } from "../../../src/connection/com"; -import { scriptContent } from "../../../src/connection/com/script"; +import { setContext } from "../../../src/components/ExtensionContext"; +import { ITCProtocol, getSession } from "../../../src/connection/itc"; +import { scriptContent } from "../../../src/connection/itc/script"; +import { LineCodes } from "../../../src/connection/itc/types"; import { Session } from "../../../src/connection/session"; +import { extensionContext } from "../../../src/node/extension"; -describe("COM connection", () => { +describe("ITC connection", () => { let sandbox: SinonSandbox; let spawnStub: SinonStub; let stdoutStub: SinonStub; @@ -20,9 +28,7 @@ describe("COM connection", () => { let onDataCallback; beforeEach(() => { - sandbox = createSandbox({ - useFakeTimers: { shouldClearNativeTimers: true }, - }); + sandbox = createSandbox({}); spawnStub = sandbox.stub(proc, "spawn"); @@ -52,7 +58,16 @@ describe("COM connection", () => { host: "localhost", }; - session = getSession(config); + const secretStore = stubInterface(); + const stubbedExtensionContext: vscode.ExtensionContext = { + ...extensionContext, + globalStorageUri: vscode.Uri.from({ scheme: "file", path: __dirname }), + secrets: secretStore, + }; + + setContext(stubbedExtensionContext); + + session = getSession(config, ITCProtocol.COM); session.onLogFn = () => { return; }; @@ -73,8 +88,11 @@ describe("COM connection", () => { await setupPromise; - expect(spawnStub.calledWith("powershell.exe /nologo -Command -")).to.be - .true; + expect( + spawnStub.calledWith( + "chcp 65001 >NUL & powershell.exe -NonInteractive -NoProfile -Command -", + ), + ).to.be.true; //using args here allows use of deep equal, that generates a concise diff in the test output on failures expect(stdinStub.args[0][0]).to.deep.equal(scriptContent + "\n"); @@ -85,53 +103,68 @@ describe("COM connection", () => { expect(stdinStub.args[2][0]).to.deep.equal( `$profileHost = "localhost"\n`, ); - expect(stdinStub.args[3][0]).to.deep.equal( - "$runner.Setup($profileHost)\n", + expect(stdinStub.args[3][0]).to.deep.equal(`$port = 0\n`); + expect(stdinStub.args[4][0]).to.deep.equal(`$protocol = 0\n`); + expect(stdinStub.args[5][0]).to.deep.equal(`$username = ""\n`); + expect(stdinStub.args[6][0]).to.deep.equal(`$password = ""\n`); + expect(stdinStub.args[7][0]).to.deep.equal(`$serverName = "ITC Local"\n`); + expect(stdinStub.args[8][0]).to.deep.equal(`$displayLang = "en"\n`); + expect(stdinStub.args[9][0]).to.deep.equal( + "$runner.Setup($profileHost,$username,$password,$port,$protocol,$serverName,$displayLang)\n", ); - expect(stdinStub.args[4][0]).to.deep.equal( + expect(stdinStub.args[10][0]).to.deep.equal( "$runner.ResolveSystemVars()\n", ); - expect(stdinStub.args[5][0]).to.deep.equal( + expect(stdinStub.args[11][0]).to.deep.equal( `$sasOpts=@("-PAGESIZE=MAX")\n`, ); - expect(stdinStub.args[6][0]).to.deep.equal( + expect(stdinStub.args[12][0]).to.deep.equal( `$runner.SetOptions($sasOpts)\n`, ); }); }); describe("run", () => { - let fsStub: SinonStub; + const html5 = '
'; + const htmlLocation = v4(); + const tempHtmlPath = join(__dirname, `${htmlLocation}.htm`); beforeEach(async () => { - fsStub = sandbox.stub(fs, "readFileSync"); - + writeFileSync(tempHtmlPath, html5); const setupPromise = session.setup(); onDataCallback(Buffer.from(`WORKDIR=/work/dir`)); await setupPromise; }); + afterEach(() => { + try { + unlinkSync(tempHtmlPath); + } catch (e) { + // Intentionally blank + } + }); it("calls run function from script", async () => { - const html5 = '
'; - fsStub.returns(html5); - const runPromise = session.run( - "ods html5;\nproc print data=sashelp.cars;\nrun;", + `ods html5;\nproc print data=sashelp.cars;\nrun;`, ); //simulate log message for body file - onDataCallback(Buffer.from("NOTE: Writing HTML5 Body file: sashtml.htm")); + onDataCallback(Buffer.from(`ods html5 body="${htmlLocation}.htm"`)); //simulate end of submission - onDataCallback(Buffer.from("--vscode-sas-extension-submit-end--")); + onDataCallback(Buffer.from(LineCodes.RunEndCode)); + onDataCallback(Buffer.from(LineCodes.ResultsFetchedCode)); const runResult = await runPromise; expect(runResult.html5).to.equal(html5); expect(runResult.title).to.equal("Result"); - expect(stdinStub.args[7][0]).to.deep.equal( + expect(stdinStub.args[13][0]).to.deep.equal( `$code=\n@'\nods html5 path="/work/dir";\nproc print data=sashelp.cars;\nrun;\n%put --vscode-sas-extension-submit-end--;\n'@\n`, ); - expect(stdinStub.args[8][0]).to.deep.equal(`$runner.Run($code)\n`); + expect(stdinStub.args[14][0]).to.deep.equal(`$runner.Run($code)\n`); + expect(stdinStub.args[15][0]).to.contain(`$outputFile = "${tempHtmlPath}" +$runner.FetchResultsFile($filePath, $outputFile) +`); }); }); diff --git a/client/test/runTest.ts b/client/test/runTest.ts index 3c2c824b7..889097fc6 100644 --- a/client/test/runTest.ts +++ b/client/test/runTest.ts @@ -16,7 +16,7 @@ async function main() { await runTests({ extensionDevelopmentPath, extensionTestsPath, - launchArgs: ["--disable-extensions"], + launchArgs: ["--disable-extensions", "--locale en-US"], }); } catch (err) { console.error("Failed to run tests"); diff --git a/connect-and-run.md b/connect-and-run.md index 1b5131081..bb731b498 100644 --- a/connect-and-run.md +++ b/connect-and-run.md @@ -15,7 +15,9 @@ The following commands are supported for profiles: | `SAS.updateProfile` | SAS: Update Connection profile | | `SAS.deleteProfile` | SAS: Delete Connection profile | -## Profile Anatomy (SAS Viya) +## Profile: SAS Viya + +### Profile Anatomy The parameters listed below make up the profile settings for configuring a connection to SAS Viya. @@ -36,10 +38,26 @@ Open the command palette (`F1`, or `Ctrl+Shift+P` on Windows or Linux, or `Shift For more information about Client IDs and the authentication process, please see the blog post [Authentication to SAS Viya: a couple of approaches](https://blogs.sas.com/content/sgf/2021/09/24/authentication-to-sas-viya/). A SAS administrator can follow the Steps 1 and 2 in the post to register a new client. -## Profile Anatomy (SAS 9.4 Remote) +## Profile: SAS 9.4 (remote - SSH) For a secure connection to SAS 9.4 remote server, a public / private ssh key pair is required. The socket defined in the environment variable `SSH_AUTH_SOCK` is used to communicate with ssh-agent to authenticate the ssh session. The private key must be registered with the ssh-agent. The steps for configuring ssh follow. +### Profile Anatomy + +The parameters listed below make up the profile settings for configuring a connection to a remote SAS 9.4 instance. + +| Name | Description | Additional Notes | +| ------------ | ------------------------------------ | -------------------------------------------------------------------- | +| **Name** | Name of the profile | This will display on the status bar | +| **Host** | SSH Server Host | This will appear when hovering over the status bar | +| **Username** | SSH Server Username | A username to use when establishing the SSH connection to the server | +| **Port** | SSH Server Port | The ssh port of the SSH server. Default value is 22 | +| **SAS Path** | Path to SAS Executable on the server | Must be a fully qualified path on the SSH server to a SAS executable | + +### Add New SAS 9.4 (remote - SSH) Profile + +Open the command palette (`F1`, or `Ctrl+Shift+P` on Windows or Linux, or `Shift+CMD+P` on OSX). After executing the `SAS.addProfile` command, select the SAS 9.4 (remote - SSH) connection type and complete the prompts (using values from the preceeding table) to create a new profile. + ### Required setup for connection to SAS 9.4 In order to configure the connection between VS Code and SAS 9, you must configure OpenSSH. Follow the steps below to complete the setup. @@ -48,7 +66,7 @@ In order to configure the connection between VS Code and SAS 9, you must configu 1. Enable openssh client optional feature; [instructions found here](https://learn.microsoft.com/en-us/windows-server/administration/openssh/openssh_install_firstuse?tabs=gui). -2. [Create an environment variable](https://phoenixnap.com/kb/windows-set-environment-variable) SSH_AUTH_SOCK with value //./pipe/openssh-ssh-agent (windows uses a named pipe for the auth sock). +2. [Create an environment variable](https://phoenixnap.com/kb/windows-set-environment-variable) SSH_AUTH_SOCK with value //./pipe/openssh-ssh-agent (windows uses a named pipe for the auth sock). **Note**: An attempt to create the varible using Powershell command line did not register; suggest using these GUI instructions. 3. Ensure ssh-agent service is running and set startup type to automatic; commands found in [this link](https://dev.to/aka_anoop/how-to-enable-openssh-agent-to-access-your-github-repositories-on-windows-powershell-1ab8) @@ -114,7 +132,7 @@ Note: the default path to the SAS executable (saspath) is /opt/sasinside/SASHome 4. Add the private key to ssh-agent: ssh-add /path/to/private/key/with/passphrase -5. Define a connection profile in settings.json for a remote server (see detailed instructions below in the [Add New SAS 9.4 Remote Profile](#add-new-sas-94-remote-profile) section): +5. Define a connection profile in settings.json for a remote server (see detailed instructions below in the [Add New SAS 9.4 Remote (Via SSH) Profile](#add-new-sas-94-remote-via-ssh-profile) section): ``` "ssh_test": { @@ -128,26 +146,12 @@ Note: the default path to the SAS executable (saspath) is /opt/sasinside/SASHome 6. Add the public part of the keypair to the SAS server. Add the contents of the key file to the ~/.ssh/authorized_keys file. -### Profile Anatomy (SAS 9.4 Remote) - -The parameters listed below make up the profile settings for configuring a connection to a remote SAS 9.4 instance. - -| Name | Description | Additional Notes | -| ------------ | ------------------------------------ | -------------------------------------------------------------------- | -| **Name** | Name of the profile | This will display on the status bar | -| **Host** | SSH Server Host | This will appear when hovering over the status bar | -| **Username** | SSH Server Username | A username to use when establishing the SSH connection to the server | -| **Port** | SSH Server Port | The ssh port of the SSH server. Default value is 22 | -| **SAS Path** | Path to SAS Executable on the server | Must be a fully qualified path on the SSH server to a SAS executable | - -## Add New SAS 9.4 Remote Profile - -Open the command palette (`F1`, or `Ctrl+Shift+P` on Windows or Linux, or `Shift+CMD+P` on OSX). After executing the `SAS.addProfile` command, select the SAS 9.4 (remote) connection type and complete the prompts (using values from the preceeding table) to create a new profile. - -## Profile Anatomy (SAS 9.4 local) +## Profile: SAS 9.4 (local) On Windows, during the install of SAS 9.4, make sure that "Integration Technologies Client" checkbox is checked. If using an order that doesn't provide this option, make sure the ITC is installed by visiting the following [link](https://support.sas.com/downloads/browse.htm?fil=&cat=56). Make sure to download the 9.4m8 option. +### Profile Anatomy + The parameters listed below make up the profile settings for configuring a connection to a local SAS 9.4 instance. | Name | Description | Additional Notes | @@ -155,9 +159,28 @@ The parameters listed below make up the profile settings for configuring a conne | **Name** | Name of the profile | This will display on the status bar | | **Host** | Indicates SAS 9.4 local instance | Defaults to localhost for com | -## Add New SAS 9.4 Local Profile +### Add New SAS 9.4 (local) Profile + +Open the command palette (`F1`, or `Ctrl+Shift+P` on Windows). After executing the `SAS.addProfile` command, select the SAS 9.4 (local) connection type to create a new profile. + +## Profile: SAS 9.4 (remote - IOM) + +In order to use this option, you'll need to have "Integration Technologies Client" (ITC) installed on the machine this extension runs on. You can do this by making sure the "Integration Technologies Client" checkbox is checked when installing SAS 9.4, or by visiting the following [link](https://support.sas.com/downloads/browse.htm?fil=&cat=56) (Make sure to download the 9.4m8 option). + +### Profile Anatomy + +The parameters listed below make up the profile settings for configuring a connection to a remote SAS 9.4 instance. + +| Name | Description | Additional Notes | +| ------------ | ------------------- | -------------------------------------------------------------------- | +| **Name** | Name of the profile | This will display on the status bar | +| **Host** | IOM Server Host | This will appear when hovering over the status bar | +| **Username** | IOM Server Username | A username to use when establishing the IOM connection to the server | +| **Port** | IOM Server Port | The port of the IOM server. Default value is 8591 | + +### Add New SAS 9.4 (remote - IOM) Profile -Open the command palette (`F1`, or `Ctrl+Shift+P` on Windows). After executing the `SAS.addProfile` command, select the SAS 9.4 (local - COM) connection type to create a new profile. +Open the command palette (`F1`, or `Ctrl+Shift+P` on Windows). After executing the `SAS.addProfile` command, select the SAS 9.4 (remote - IOM) connection type to create a new profile. ## Additional settings in a profile diff --git a/doc/scripts/itc-connection-test.ps1 b/doc/scripts/itc-connection-test.ps1 new file mode 100644 index 000000000..3df7a4e76 --- /dev/null +++ b/doc/scripts/itc-connection-test.ps1 @@ -0,0 +1,177 @@ +<# + Use this script to test itc connections. This roughly mimics the code in `client/src/connection/itc` + and aids in debugging issues with ITC connections. To test, replace any values wrapped in brackets + (i.e. ``) and run with Windows Powershell ISE. +#> +class SASRunner{ + [System.__ComObject] $objSAS + + [void]ResolveSystemVars(){ + $code = +@' + %let workDir = %sysfunc(pathname(work)); + %put &=workDir; + %let rc = %sysfunc(dlgcdir("&workDir")); + run; +'@ + $this.Run($code) + $varLogs = $this.FlushLog(4096) + Write-Host $varLogs + } + [void]Setup([string]$profileHost, [string]$username, [string]$password, [int]$port, [int]$protocol, [string]$serverName, [string]$displayLang) { + try { + # Set Encoding for input and output + $null = cmd /c '' + $Global:OutputEncoding = [Console]::InputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new() + + # create the Integration Technologies objects + $objFactory = New-Object -ComObject SASObjectManager.ObjectFactoryMulti2 + $objServerDef = New-Object -ComObject SASObjectManager.ServerDef + $objServerDef.MachineDNSName = $profileHost # SAS Workspace node + $objServerDef.Port = $port # workspace server port + $objServerDef.Protocol = $protocol # 0 = COM protocol + $sasLocale = $displayLang -replace '-', '_' + $objServerDef.ExtraNameValuePairs="LOCALE=$sasLocale" # set the correct locale + + # Class Identifier for SAS Workspace + $objServerDef.ClassIdentifier = "440196d4-90f0-11d0-9f41-00a024bb830c" + + # create and connect to the SAS session + $this.objSAS = $objFactory.CreateObjectByServer( + $serverName, # server name + $true, + $objServerDef, # built server definition + $username, + $password + ) + + Write-Host "Session created" + } catch { + Write-Error $_ + throw "Setup error" + } + } + + [void]SetOptions([array] $sasOptions) { + $names = [string[]]@() + $values = [string[]]@() + + foreach ($item in $sasOptions) { + $parts = $item -split '=| ' + $names += $parts[0] -replace '^-' + if ($parts.Length -gt 1) { + $values += $parts[1] + } else { + $values += "" + } + } + + [ref]$errorIndices = [int[]]::new($names.Length) + [ref]$errors = [string[]]::new($names.Length) + [ref]$errorCodes = [int[]]::new($names.Length) + + try{ + $this.objSAS.Utilities.OptionService.SetOptions([string[]]$names, [string[]]$values, $errorIndices, $errorCodes, $errors) + + $errVals = $errors.Value + if($errVals.Length -gt 0){ + throw $errVals + } + } catch{ + Write-Error $Error[0].Exception.Message + } + } + + [void]Run([string]$code) { + try{ + $this.objSAS.LanguageService.Reset() + $this.objSAS.LanguageService.Async = $true + $this.objSAS.LanguageService.Submit($code) + }catch{ + Write-Error $_.ScriptStackTrace + throw "Run error" + } + } + + [void]Close(){ + try{ + $this.objSAS.Close() + }catch{ + throw "Close error" + } + } + + [void]Cancel(){ + try{ + $this.objSAS.LanguageService.Cancel() + Write-Host "Run Cancelled" + }catch{ + throw "Cancel error" + } + } + + [String]FlushLog([int]$chunkSize) { + try{ + return $this.objSAS.LanguageService.FlushLog($chunkSize) + } catch{ + throw "FlushLog error" + } + return "" + } + + [void]FlushCompleteLog(){ + do { + $chunkSize = 32768 + $log = $this.FlushLog($chunkSize) + Write-Host $log + } while ($log.Length -gt 0) + } + + [void]FetchResultsFile([string]$filePath, [string]$outputFile) { + $fileRef = "" + $objFile = $this.objSAS.FileService.AssignFileref("outfile", "DISK", $filePath, "", [ref] $fileRef) + $objStream = $objFile.OpenBinaryStream(1); + [Byte[]] $bytes = 0x0 + + $endOfFile = $false + $byteCount = 0 + $outStream = [System.IO.StreamWriter] $outputFile + do + { + $objStream.Read(1024, [ref] $bytes) + $outStream.Write([System.Text.Encoding]::UTF8.GetString($bytes)) + $endOfFile = $bytes.Length -lt 1024 + $byteCount = $byteCount + $bytes.Length + } while (-not $endOfFile) + + $objStream.Close() + $outStream.Close() + $this.objSAS.FileService.DeassignFileref($objFile.FilerefName) + + Write-Host "Results Fetched" + } +} + +$runner = New-Object -TypeName SASRunner +$profileHost = "" +$port = 8591 # Specify IOM port +$protocol = 2 # IOMBridge +$username = "" +$password = "" +$serverName = "ITC IOM Bridge" +$displayLang = "" # EN_US, ZH_CN, etc +$runner.Setup($profileHost,$username,$password,$port,$protocol,$serverName,$displayLang) +$runner.ResolveSystemVars() + +# Uncomment the following line to set any options +# $runner.SetOptions("VALIDVARNAME=ANY") + +# Adjust the value of $code as needed +$code = "proc options group=languagecontrol;" +$runner.Run($code) + +do { + $chunkSize = 32768 + $log = $runner.FlushLog($chunkSize) + Write-Host $log +} while ($log.Length -gt 0) diff --git a/package.json b/package.json index 7427a2456..32449b019 100644 --- a/package.json +++ b/package.json @@ -170,7 +170,8 @@ "enum": [ "rest", "ssh", - "com" + "com", + "iom" ] }, "sasOptions": { @@ -323,6 +324,41 @@ } } }, + { + "if": { + "properties": { + "connectionType": { + "const": "iom" + } + } + }, + "then": { + "required": [ + "host", + "username", + "port" + ], + "properties": { + "host": { + "type": "string", + "default": "", + "description": "%configuration.SAS.connectionProfiles.profiles.iom.host%" + }, + "username": { + "type": "string", + "default": "", + "description": "%configuration.SAS.connectionProfiles.profiles.iom.username%" + }, + "port": { + "type": "number", + "default": 8591, + "description": "%configuration.SAS.connectionProfiles.profiles.iom.port%", + "exclusiveMinimum": 1, + "exclusiveMaximum": 65535 + } + } + } + }, { "if": { "properties": { diff --git a/package.nls.json b/package.nls.json index 68098fc98..cb41e7c4a 100644 --- a/package.nls.json +++ b/package.nls.json @@ -39,6 +39,9 @@ "configuration.SAS.connectionProfiles.profiles.connectionType": "SAS Profile Connection Type", "configuration.SAS.connectionProfiles.profiles.context": "SAS Viya Context", "configuration.SAS.connectionProfiles.profiles.endpoint": "SAS Viya Connection Profile Endpoint", + "configuration.SAS.connectionProfiles.profiles.iom.host": "SAS IOM Connection Host", + "configuration.SAS.connectionProfiles.profiles.iom.port": "SAS IOM Connection port", + "configuration.SAS.connectionProfiles.profiles.iom.username": "SAS IOM Connection username", "configuration.SAS.connectionProfiles.profiles.name": "SAS Connection Profile Name", "configuration.SAS.connectionProfiles.profiles.sasOptions": "SAS Connection SAS options", "configuration.SAS.connectionProfiles.profiles.ssh.host": "SAS SSH Connection SSH Host",