diff --git a/patched-vscode/build/gulpfile.extensions.js b/patched-vscode/build/gulpfile.extensions.js index 8d8347e6a..9296656c9 100644 --- a/patched-vscode/build/gulpfile.extensions.js +++ b/patched-vscode/build/gulpfile.extensions.js @@ -62,10 +62,11 @@ const compilations = [ 'extensions/simple-browser/tsconfig.json', 'extensions/sagemaker-extension/tsconfig.json', 'extensions/sagemaker-idle-extension/tsconfig.json', + 'extensions/sagemaker-extensions-sync/tsconfig.json', 'extensions/sagemaker-terminal-crash-mitigation/tsconfig.json', 'extensions/sagemaker-open-notebook-extension/tsconfig.json', 'extensions/sagemaker-ui-dark-theme/tsconfig.json', - 'extensions/post-startup-notifications/tsconfig.json', + 'extensions/post-startup-notifications/tsconfig.json', 'extensions/tunnel-forwarding/tsconfig.json', 'extensions/typescript-language-features/test-workspace/tsconfig.json', 'extensions/typescript-language-features/web/tsconfig.json', diff --git a/patched-vscode/build/npm/dirs.js b/patched-vscode/build/npm/dirs.js index e57280801..ec8679a59 100644 --- a/patched-vscode/build/npm/dirs.js +++ b/patched-vscode/build/npm/dirs.js @@ -40,11 +40,12 @@ const dirs = [ 'extensions/php-language-features', 'extensions/references-view', 'extensions/sagemaker-extension', + 'extensions/sagemaker-extensions-sync', 'extensions/sagemaker-idle-extension', 'extensions/sagemaker-terminal-crash-mitigation', 'extensions/sagemaker-open-notebook-extension', 'extensions/sagemaker-ui-dark-theme', - 'extensions/post-startup-notifications', + 'extensions/post-startup-notifications', 'extensions/search-result', 'extensions/simple-browser', 'extensions/tunnel-forwarding', diff --git a/patched-vscode/extensions/post-startup-notifications/.vscode/extensions.json b/patched-vscode/extensions/post-startup-notifications/.vscode/extensions.json index ca69d8f94..dd01eb355 100644 --- a/patched-vscode/extensions/post-startup-notifications/.vscode/extensions.json +++ b/patched-vscode/extensions/post-startup-notifications/.vscode/extensions.json @@ -1,9 +1,5 @@ { - // See http://go.microsoft.com/fwlink/?LinkId=827846 - // for the documentation about the extensions.json format - "recommendations": [ - "dbaeumer.vscode-eslint", - "amodio.tsl-problem-matcher", - "ms-vscode.extension-test-runner" - ] -} \ No newline at end of file + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": ["dbaeumer.vscode-eslint", "amodio.tsl-problem-matcher", "ms-vscode.extension-test-runner"] +} diff --git a/patched-vscode/extensions/sagemaker-extensions-sync/.vscodeignore b/patched-vscode/extensions/sagemaker-extensions-sync/.vscodeignore new file mode 100644 index 000000000..56b78554c --- /dev/null +++ b/patched-vscode/extensions/sagemaker-extensions-sync/.vscodeignore @@ -0,0 +1,12 @@ +.vscode/** +.vscode-test/** +out/test/** +out/** +test/** +src/** +tsconfig.json +out/test/** +out/** +cgmanifest.json +yarn.lock +preview-src/** diff --git a/patched-vscode/extensions/sagemaker-extensions-sync/README.md b/patched-vscode/extensions/sagemaker-extensions-sync/README.md new file mode 100644 index 000000000..b8dd030e4 --- /dev/null +++ b/patched-vscode/extensions/sagemaker-extensions-sync/README.md @@ -0,0 +1,3 @@ +# SageMaker Code Editor Extensions Sync + +Notifies users if the extensions directory is missing pre-packaged extensions from SageMaker Distribution and give them the option to sync them. \ No newline at end of file diff --git a/patched-vscode/extensions/sagemaker-extensions-sync/extension-browser.webpack.config.js b/patched-vscode/extensions/sagemaker-extensions-sync/extension-browser.webpack.config.js new file mode 100644 index 000000000..68271e0e9 --- /dev/null +++ b/patched-vscode/extensions/sagemaker-extensions-sync/extension-browser.webpack.config.js @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright Amazon.com Inc. or its affiliates. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//@ts-check + +'use strict'; + +const withBrowserDefaults = require('../shared.webpack.config').browser; + +module.exports = withBrowserDefaults({ + context: __dirname, + entry: { + extension: './src/extension.ts' + }, +}); diff --git a/patched-vscode/extensions/sagemaker-extensions-sync/extension.webpack.config.js b/patched-vscode/extensions/sagemaker-extensions-sync/extension.webpack.config.js new file mode 100644 index 000000000..598526267 --- /dev/null +++ b/patched-vscode/extensions/sagemaker-extensions-sync/extension.webpack.config.js @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright Amazon.com Inc. or its affiliates. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//@ts-check + +'use strict'; + +const withDefaults = require('../shared.webpack.config'); + +module.exports = withDefaults({ + context: __dirname, + resolve: { + mainFields: ['module', 'main'] + }, + entry: { + extension: './src/extension.ts', + } +}); diff --git a/patched-vscode/extensions/sagemaker-extensions-sync/package.json b/patched-vscode/extensions/sagemaker-extensions-sync/package.json new file mode 100644 index 000000000..a0761fa03 --- /dev/null +++ b/patched-vscode/extensions/sagemaker-extensions-sync/package.json @@ -0,0 +1,44 @@ +{ + "name": "sagemaker-extensions-sync", + "displayName": "SageMaker Extensions Sync", + "description": "Sync pre-packaged extensions from SageMaker Distribution", + "extensionKind": [ + "workspace" + ], + "version": "1.0.0", + "publisher": "sagemaker", + "license": "MIT", + "engines": { + "vscode": "^1.70.0" + }, + "main": "./out/extension", + "categories": [ + "Other" + ], + "activationEvents": [ + "*" + ], + "capabilities": { + "virtualWorkspaces": true, + "untrustedWorkspaces": { + "supported": true + } + }, + "contributes": { + "commands": [ + { + "command": "extensions-sync.syncExtensions", + "title": "Sync Extensions from SageMaker Distribution", + "category": "Extensions Sync" + } + ] + }, + "scripts": { + "compile": "gulp compile-extension:sagemaker-extensions-sync", + "watch": "npm run build-preview && gulp watch-extension:sagemaker-extensions-sync", + "vscode:prepublish": "npm run build-ext", + "build-ext": "node ../../node_modules/gulp/bin/gulp.js --gulpfile ../../build/gulpfile.extensions.js compile-extension:sagemaker-idle-extension ./tsconfig.json" + }, + "dependencies": {}, + "repository": {} +} diff --git a/patched-vscode/extensions/sagemaker-extensions-sync/src/constants.ts b/patched-vscode/extensions/sagemaker-extensions-sync/src/constants.ts new file mode 100644 index 000000000..1a7fdcb84 --- /dev/null +++ b/patched-vscode/extensions/sagemaker-extensions-sync/src/constants.ts @@ -0,0 +1,21 @@ +// constants +export const PERSISTENT_VOLUME_EXTENSIONS_DIR = "/home/sagemaker-user/sagemaker-code-editor-server-data/extensions"; +export const IMAGE_EXTENSIONS_DIR = "/opt/amazon/sagemaker/sagemaker-code-editor-server-data/extensions"; +export const LOG_PREFIX = "[sagemaker-extensions-sync]"; + +export class ExtensionInfo { + constructor( + public name: string, + public publisher: string, + public version: string, + public path: string | null + ) {} + + get identifier(): string { + return `${this.publisher}.${this.name}@${this.version}`; + } + + toString(): string { + return `ExtensionInfo: ${this.identifier} (${this.path})`; + } +} diff --git a/patched-vscode/extensions/sagemaker-extensions-sync/src/extension.ts b/patched-vscode/extensions/sagemaker-extensions-sync/src/extension.ts new file mode 100644 index 000000000..f9f44fd56 --- /dev/null +++ b/patched-vscode/extensions/sagemaker-extensions-sync/src/extension.ts @@ -0,0 +1,100 @@ +import * as process from "process"; +import * as vscode from 'vscode'; + +import { + ExtensionInfo, + IMAGE_EXTENSIONS_DIR, + LOG_PREFIX, + PERSISTENT_VOLUME_EXTENSIONS_DIR, +} from "./constants" + +import { + getExtensionsFromDirectory, + getInstalledExtensions, + installExtension, + refreshExtensionsMetadata } from "./utils" + +export async function activate() { + + // this extension will only activate within a sagemaker app + const isSageMakerApp = !!process.env?.SAGEMAKER_APP_TYPE_LOWERCASE; + if (!isSageMakerApp) { + return; + } + + // get installed extensions. this could be different from pvExtensions b/c vscode sometimes doesn't delete the assets + // for an old extension when uninstalling or changing versions + const installedExtensions = new Set(await getInstalledExtensions()); + console.log(`${LOG_PREFIX} Found installed extensions: `, Array.from(installedExtensions)); + + const prePackagedExtensions: ExtensionInfo[] = await getExtensionsFromDirectory(IMAGE_EXTENSIONS_DIR); + const prePackagedExtensionsById: Record = {}; + prePackagedExtensions.forEach(extension => { + prePackagedExtensionsById[extension.identifier] = extension; + }); + + console.log(`${LOG_PREFIX} Found pre-packaged extensions: `, prePackagedExtensions); + + const pvExtensions = await getExtensionsFromDirectory(PERSISTENT_VOLUME_EXTENSIONS_DIR); + const pvExtensionsByName: Record = {}; + const pvExtensionsById: Record = {}; + pvExtensions.forEach(extension => { + if (installedExtensions.has(extension.identifier)) { // only index extensions that are installed + pvExtensionsByName[extension.name] = extension; + pvExtensionsById[extension.identifier] = extension; + } + }); + console.log(`${LOG_PREFIX} Found installed extensions in persistent volume: `, pvExtensionsById); + + // check each pre-packaged extension, record if it is not in installed extensions or version mismatch + // store unsynced extensions as {identifier pre-packaged ext: currently installed version} + const unsyncedExtensions: Record = {} + prePackagedExtensions.forEach(extension => { + const id = extension.identifier; + if (!(installedExtensions.has(id))){ + unsyncedExtensions[id] = pvExtensionsByName[extension.name]?.version ?? null; + } + }); + console.log(`${LOG_PREFIX} Unsynced extensions: `, unsyncedExtensions); + + if (Object.keys(unsyncedExtensions).length !== 0) { + const selection = await vscode.window.showWarningMessage( + 'Warning: You have unsynchronized extensions from SageMaker Distribution \ + which could result in incompatibilities with Code Editor. Do you want to install them?', + "Synchronize Extensions", "Dismiss"); + + if (selection === "Synchronize Extensions") { + const quickPick = vscode.window.createQuickPick(); + quickPick.items = Object.keys(unsyncedExtensions).map(extensionId => ({ + label: extensionId, + description: unsyncedExtensions[extensionId] ? `Currently installed version: ${unsyncedExtensions[extensionId]}` : undefined, + })); + quickPick.placeholder = 'Select extensions to install'; + quickPick.canSelectMany = true; + quickPick.ignoreFocusOut = true; + + quickPick.onDidAccept(async () => { + const selectedExtensions = quickPick.selectedItems.map(item => item.label); + + for (const extensionId of selectedExtensions) { + const extensionName = prePackagedExtensionsById[extensionId].name; + await installExtension(prePackagedExtensionsById[extensionId], pvExtensionsByName[extensionName]); + } + await refreshExtensionsMetadata(); + + quickPick.hide(); + await vscode.window.showInformationMessage( + 'Extensions have been installed. \nWould you like to reload the window?', + { modal: true }, + 'Reload' + ).then(selection => { + if (selection === 'Reload') { + vscode.commands.executeCommand('workbench.action.reloadWindow'); + } + }); + }); + + quickPick.show(); + } + } +} \ No newline at end of file diff --git a/patched-vscode/extensions/sagemaker-extensions-sync/src/utils.ts b/patched-vscode/extensions/sagemaker-extensions-sync/src/utils.ts new file mode 100644 index 000000000..e2d34fe06 --- /dev/null +++ b/patched-vscode/extensions/sagemaker-extensions-sync/src/utils.ts @@ -0,0 +1,152 @@ +import * as fs from "fs/promises"; +import * as path from "path"; +import * as vscode from 'vscode'; +import { execFile } from "child_process"; +import { promisify } from "util"; + +import { + ExtensionInfo, + LOG_PREFIX, + PERSISTENT_VOLUME_EXTENSIONS_DIR, +} from "./constants" + +export async function getExtensionsFromDirectory(directoryPath: string): Promise { + const results: ExtensionInfo[] = []; + try { + const items = await fs.readdir(directoryPath); + + for (const item of items) { + const itemPath = path.join(directoryPath, item); + try { + const stats = await fs.stat(itemPath); + + if (stats.isDirectory()) { + const packageJsonPath = path.join(itemPath, "package.json"); + + const packageData = JSON.parse(await fs.readFile(packageJsonPath, "utf8")); + + if (packageData.name && packageData.publisher && packageData.version) { + results.push(new ExtensionInfo( + packageData.name, + packageData.publisher, + packageData.version, + itemPath, + )); + } + } + } catch (error) { + // fs.stat will break on dangling simlinks. Just skip to the next file + console.error(`${LOG_PREFIX} Error reading package.json in ${itemPath}:`, error); + } + } + } catch (error) { + console.error(`${LOG_PREFIX} Error reading directory ${directoryPath}:`, error); + } + return results; +} + +export async function getInstalledExtensions(): Promise { + const command = "sagemaker-code-editor"; + const args = ["--list-extensions", "--show-versions", "--extensions-dir", PERSISTENT_VOLUME_EXTENSIONS_DIR]; + + const execFileAsync = promisify(execFile); + try { + const { stdout, stderr } = await execFileAsync(command, args); + if (stderr) { + throw new Error("stderr"); + } + return stdout.split("\n").filter(line => line.trim() !== ""); + } catch (error) { + console.error(`${LOG_PREFIX} Error getting list of installed extensions:`, error); + throw error; + } +} + +export async function refreshExtensionsMetadata(): Promise { + const metaDataFile = path.join(PERSISTENT_VOLUME_EXTENSIONS_DIR, "extensions.json"); + try { + await fs.unlink(metaDataFile); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error(`${LOG_PREFIX} Error removing metadata file:`, error); + } + } +} + +export async function installExtension( + prePackagedExtensionInfo: ExtensionInfo, installedExtensionInfo?: ExtensionInfo | undefined +): Promise { + if (installedExtensionInfo) { + console.log(`${LOG_PREFIX} Upgrading extension from ${installedExtensionInfo.identifier} to ${prePackagedExtensionInfo.identifier}`); + } else { + console.log(`${LOG_PREFIX} Installing extension ${prePackagedExtensionInfo.identifier}`); + } + try { + if (!prePackagedExtensionInfo.path) { + throw new Error(`Extension path missing for ${prePackagedExtensionInfo.identifier}`); + } + + const targetPath = path.join(PERSISTENT_VOLUME_EXTENSIONS_DIR, path.basename(prePackagedExtensionInfo.path)); + + // Remove existing symlink or directory if it exists + try { + console.log(`${LOG_PREFIX} Removing existing folder ${targetPath}`); + await fs.unlink(targetPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error(`${LOG_PREFIX} Error removing existing extension:`, error); + throw error; + } + // if file already doesn't exist then keep going + } + + // Create new symlink + try { + console.log(`${LOG_PREFIX} Adding extension to persistent volume directory`); + await fs.symlink(prePackagedExtensionInfo.path, targetPath, 'dir'); + } catch (error) { + console.error(`${LOG_PREFIX} Error adding extension to persistent volume directory:`, error); + throw error; + } + + // Handle .obsolete file + const OBSOLETE_FILE = path.join(PERSISTENT_VOLUME_EXTENSIONS_DIR, '.obsolete'); + let obsoleteData: Record = {}; + + try { + const obsoleteContent = await fs.readFile(OBSOLETE_FILE, 'utf-8'); + console.log(`${LOG_PREFIX} .obsolete file found`); + obsoleteData = JSON.parse(obsoleteContent); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + console.log(`${LOG_PREFIX} .obsolete file not found. Creating a new one.`); + } else { + console.warn(`${LOG_PREFIX} Error reading .obsolete file:`, error); + // Backup malformed file + const backupPath = `${OBSOLETE_FILE}.bak`; + await fs.rename(OBSOLETE_FILE, backupPath); + console.log(`${LOG_PREFIX} Backed up malformed .obsolete file to ${backupPath}`); + } + } + + if (installedExtensionInfo?.path) { + const obsoleteBasename = path.basename(installedExtensionInfo.path); + obsoleteData[obsoleteBasename] = true; + } + const obsoleteBasenamePrepackaged = path.basename(prePackagedExtensionInfo.path); + obsoleteData[obsoleteBasenamePrepackaged] = false; + + try { + console.log(`${LOG_PREFIX} Writing to .obsolete file.`); + await fs.writeFile(OBSOLETE_FILE, JSON.stringify(obsoleteData, null, 2)); + } catch (error) { + console.error(`${LOG_PREFIX} Error writing .obsolete file:`, error); + throw error; + } + + console.log(`${LOG_PREFIX} Installed ${prePackagedExtensionInfo.identifier}`); + } catch (error) { + vscode.window.showErrorMessage(`Could not install extension ${prePackagedExtensionInfo.identifier}`); + console.error(`${LOG_PREFIX} ${error}`); + } +} \ No newline at end of file diff --git a/patched-vscode/extensions/sagemaker-extensions-sync/tsconfig.json b/patched-vscode/extensions/sagemaker-extensions-sync/tsconfig.json new file mode 100644 index 000000000..e474d9a56 --- /dev/null +++ b/patched-vscode/extensions/sagemaker-extensions-sync/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./out" + }, + "include": [ + "../sagemaker-extensions-sync/src/**/*", + "../../src/vscode-dts/vscode.d.ts" + ] +} diff --git a/patched-vscode/extensions/sagemaker-extensions-sync/yarn.lock b/patched-vscode/extensions/sagemaker-extensions-sync/yarn.lock new file mode 100644 index 000000000..fb57ccd13 --- /dev/null +++ b/patched-vscode/extensions/sagemaker-extensions-sync/yarn.lock @@ -0,0 +1,4 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + diff --git a/patched-vscode/extensions/sagemaker-open-notebook-extension/src/extension.ts b/patched-vscode/extensions/sagemaker-open-notebook-extension/src/extension.ts index 024d5504a..3ded71c77 100644 --- a/patched-vscode/extensions/sagemaker-open-notebook-extension/src/extension.ts +++ b/patched-vscode/extensions/sagemaker-open-notebook-extension/src/extension.ts @@ -89,12 +89,3 @@ function downloadFile(url: string): Promise { response.on('data', (chunk) => { data += chunk; }); - response.on('end', () => { - resolve(data); - }); - }).on('error', (error) => { - reject(error); - }); - }); -} -export function deactivate() {} diff --git a/patched-vscode/src/vs/server/node/webClientServer.ts b/patched-vscode/src/vs/server/node/webClientServer.ts index 744e112e0..1200080d2 100644 --- a/patched-vscode/src/vs/server/node/webClientServer.ts +++ b/patched-vscode/src/vs/server/node/webClientServer.ts @@ -470,7 +470,7 @@ export class WebClientServer { } /** - * Handles API requests to retrieve the last activity timestamp. + * Handles API requests to retrieve the last activity timestamp. */ private async _handleIdle(req: http.IncomingMessage, res: http.ServerResponse): Promise { try { @@ -494,40 +494,40 @@ export class WebClientServer { } } - /** - * Handles API requests to run the post-startup script in SMD. - */ - private async _handlePostStartupScriptInvocation(req: http.IncomingMessage, res: http.ServerResponse): Promise { - const postStartupScriptPath = '/etc/sagemaker-ui/sagemaker_ui_post_startup.sh' - const logPath = '/var/log/apps/post_startup_default.log'; - const logStream = fs.createWriteStream(logPath, { flags: 'a' }); - - // Only trigger post-startup script invocation for SageMakerUnifiedStudio app. - if (process.env['SERVICE_NAME'] != ServiceName.SAGEMAKER_UNIFIED_STUDIO) { - return serveError(req, res, 403, 'Forbidden'); - } else { - //If postStartupScriptFile doesn't exist, it will throw FileNotFoundError (404) - //If exists, it will start the execution and add the execution logs in logFile. - try { - if (fs.existsSync(postStartupScriptPath)) { - // Adding 0o755 to make script file executable - fs.chmodSync(postStartupScriptPath, 0o755); - - const subprocess = spawn('bash', [`${postStartupScriptPath}`], { cwd: '/' }); - subprocess.stdout.pipe(logStream); - subprocess.stderr.pipe(logStream); - - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ 'success': 'true' })); - } else { - serveError(req, res, 500, 'Poststartup script file not found at ' + postStartupScriptPath); - } - } catch (error) { - serveError(req, res, 500, error.message); - } - } - } + /** + * Handles API requests to run the post-startup script in SMD. + */ + private async _handlePostStartupScriptInvocation(req: http.IncomingMessage, res: http.ServerResponse): Promise { + const postStartupScriptPath = '/etc/sagemaker-ui/sagemaker_ui_post_startup.sh' + const logPath = '/var/log/apps/post_startup_default.log'; + const logStream = fs.createWriteStream(logPath, { flags: 'a' }); + + // Only trigger post-startup script invocation for SageMakerUnifiedStudio app. + if (process.env['SERVICE_NAME'] != ServiceName.SAGEMAKER_UNIFIED_STUDIO) { + return serveError(req, res, 403, 'Forbidden'); + } else { + //If postStartupScriptFile doesn't exist, it will throw FileNotFoundError (404) + //If exists, it will start the execution and add the execution logs in logFile. + try { + if (fs.existsSync(postStartupScriptPath)) { + // Adding 0o755 to make script file executable + fs.chmodSync(postStartupScriptPath, 0o755); + + const subprocess = spawn('bash', [`${postStartupScriptPath}`], { cwd: '/' }); + subprocess.stdout.pipe(logStream); + subprocess.stderr.pipe(logStream); + + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ 'success': 'true' })); + } else { + serveError(req, res, 500, 'Poststartup script file not found at ' + postStartupScriptPath); + } + } catch (error) { + serveError(req, res, 500, error.message); + } + } + } } /** diff --git a/patches/sagemaker-extensions-sync.patch b/patches/sagemaker-extensions-sync.patch new file mode 100644 index 000000000..2d6e6315b --- /dev/null +++ b/patches/sagemaker-extensions-sync.patch @@ -0,0 +1,460 @@ +Index: sagemaker-code-editor/vscode/build/gulpfile.extensions.js +=================================================================== +--- sagemaker-code-editor.orig/vscode/build/gulpfile.extensions.js ++++ sagemaker-code-editor/vscode/build/gulpfile.extensions.js +@@ -62,6 +62,7 @@ const compilations = [ + 'extensions/simple-browser/tsconfig.json', + 'extensions/sagemaker-extension/tsconfig.json', + 'extensions/sagemaker-idle-extension/tsconfig.json', ++ 'extensions/sagemaker-extensions-sync/tsconfig.json', + 'extensions/sagemaker-terminal-crash-mitigation/tsconfig.json', + 'extensions/sagemaker-open-notebook-extension/tsconfig.json', + 'extensions/tunnel-forwarding/tsconfig.json', +Index: sagemaker-code-editor/vscode/build/npm/dirs.js +=================================================================== +--- sagemaker-code-editor.orig/vscode/build/npm/dirs.js ++++ sagemaker-code-editor/vscode/build/npm/dirs.js +@@ -40,6 +40,7 @@ const dirs = [ + 'extensions/php-language-features', + 'extensions/references-view', + 'extensions/sagemaker-extension', ++ 'extensions/sagemaker-extensions-sync', + 'extensions/sagemaker-idle-extension', + 'extensions/sagemaker-terminal-crash-mitigation', + 'extensions/sagemaker-open-notebook-extension', +Index: sagemaker-code-editor/vscode/extensions/sagemaker-extensions-sync/.vscodeignore +=================================================================== +--- /dev/null ++++ sagemaker-code-editor/vscode/extensions/sagemaker-extensions-sync/.vscodeignore +@@ -0,0 +1,12 @@ ++.vscode/** ++.vscode-test/** ++out/test/** ++out/** ++test/** ++src/** ++tsconfig.json ++out/test/** ++out/** ++cgmanifest.json ++yarn.lock ++preview-src/** +Index: sagemaker-code-editor/vscode/extensions/sagemaker-extensions-sync/README.md +=================================================================== +--- /dev/null ++++ sagemaker-code-editor/vscode/extensions/sagemaker-extensions-sync/README.md +@@ -0,0 +1,3 @@ ++# SageMaker Code Editor Extensions Sync ++ ++Notifies users if the extensions directory is missing pre-packaged extensions from SageMaker Distribution and give them the option to sync them. +\ No newline at end of file +Index: sagemaker-code-editor/vscode/extensions/sagemaker-extensions-sync/extension-browser.webpack.config.js +=================================================================== +--- /dev/null ++++ sagemaker-code-editor/vscode/extensions/sagemaker-extensions-sync/extension-browser.webpack.config.js +@@ -0,0 +1,17 @@ ++/*--------------------------------------------------------------------------------------------- ++ * Copyright Amazon.com Inc. or its affiliates. All rights reserved. ++ * Licensed under the MIT License. See License.txt in the project root for license information. ++ *--------------------------------------------------------------------------------------------*/ ++ ++//@ts-check ++ ++'use strict'; ++ ++const withBrowserDefaults = require('../shared.webpack.config').browser; ++ ++module.exports = withBrowserDefaults({ ++ context: __dirname, ++ entry: { ++ extension: './src/extension.ts' ++ }, ++}); +Index: sagemaker-code-editor/vscode/extensions/sagemaker-extensions-sync/extension.webpack.config.js +=================================================================== +--- /dev/null ++++ sagemaker-code-editor/vscode/extensions/sagemaker-extensions-sync/extension.webpack.config.js +@@ -0,0 +1,20 @@ ++/*--------------------------------------------------------------------------------------------- ++ * Copyright Amazon.com Inc. or its affiliates. All rights reserved. ++ * Licensed under the MIT License. See License.txt in the project root for license information. ++ *--------------------------------------------------------------------------------------------*/ ++ ++//@ts-check ++ ++'use strict'; ++ ++const withDefaults = require('../shared.webpack.config'); ++ ++module.exports = withDefaults({ ++ context: __dirname, ++ resolve: { ++ mainFields: ['module', 'main'] ++ }, ++ entry: { ++ extension: './src/extension.ts', ++ } ++}); +Index: sagemaker-code-editor/vscode/extensions/sagemaker-extensions-sync/package.json +=================================================================== +--- /dev/null ++++ sagemaker-code-editor/vscode/extensions/sagemaker-extensions-sync/package.json +@@ -0,0 +1,44 @@ ++{ ++ "name": "sagemaker-extensions-sync", ++ "displayName": "SageMaker Extensions Sync", ++ "description": "Sync pre-packaged extensions from SageMaker Distribution", ++ "extensionKind": [ ++ "workspace" ++ ], ++ "version": "1.0.0", ++ "publisher": "sagemaker", ++ "license": "MIT", ++ "engines": { ++ "vscode": "^1.70.0" ++ }, ++ "main": "./out/extension", ++ "categories": [ ++ "Other" ++ ], ++ "activationEvents": [ ++ "*" ++ ], ++ "capabilities": { ++ "virtualWorkspaces": true, ++ "untrustedWorkspaces": { ++ "supported": true ++ } ++ }, ++ "contributes": { ++ "commands": [ ++ { ++ "command": "extensions-sync.syncExtensions", ++ "title": "Sync Extensions from SageMaker Distribution", ++ "category": "Extensions Sync" ++ } ++ ] ++ }, ++ "scripts": { ++ "compile": "gulp compile-extension:sagemaker-extensions-sync", ++ "watch": "npm run build-preview && gulp watch-extension:sagemaker-extensions-sync", ++ "vscode:prepublish": "npm run build-ext", ++ "build-ext": "node ../../node_modules/gulp/bin/gulp.js --gulpfile ../../build/gulpfile.extensions.js compile-extension:sagemaker-idle-extension ./tsconfig.json" ++ }, ++ "dependencies": {}, ++ "repository": {} ++} +Index: sagemaker-code-editor/vscode/extensions/sagemaker-extensions-sync/src/constants.ts +=================================================================== +--- /dev/null ++++ sagemaker-code-editor/vscode/extensions/sagemaker-extensions-sync/src/constants.ts +@@ -0,0 +1,21 @@ ++// constants ++export const PERSISTENT_VOLUME_EXTENSIONS_DIR = "/home/sagemaker-user/sagemaker-code-editor-server-data/extensions"; ++export const IMAGE_EXTENSIONS_DIR = "/opt/amazon/sagemaker/sagemaker-code-editor-server-data/extensions"; ++export const LOG_PREFIX = "[sagemaker-extensions-sync]"; ++ ++export class ExtensionInfo { ++ constructor( ++ public name: string, ++ public publisher: string, ++ public version: string, ++ public path: string | null ++ ) {} ++ ++ get identifier(): string { ++ return `${this.publisher}.${this.name}@${this.version}`; ++ } ++ ++ toString(): string { ++ return `ExtensionInfo: ${this.identifier} (${this.path})`; ++ } ++} +Index: sagemaker-code-editor/vscode/extensions/sagemaker-extensions-sync/src/extension.ts +=================================================================== +--- /dev/null ++++ sagemaker-code-editor/vscode/extensions/sagemaker-extensions-sync/src/extension.ts +@@ -0,0 +1,100 @@ ++import * as process from "process"; ++import * as vscode from 'vscode'; ++ ++import { ++ ExtensionInfo, ++ IMAGE_EXTENSIONS_DIR, ++ LOG_PREFIX, ++ PERSISTENT_VOLUME_EXTENSIONS_DIR, ++} from "./constants" ++ ++import { ++ getExtensionsFromDirectory, ++ getInstalledExtensions, ++ installExtension, ++ refreshExtensionsMetadata } from "./utils" ++ ++export async function activate() { ++ ++ // this extension will only activate within a sagemaker app ++ const isSageMakerApp = !!process.env?.SAGEMAKER_APP_TYPE_LOWERCASE; ++ if (!isSageMakerApp) { ++ return; ++ } ++ ++ // get installed extensions. this could be different from pvExtensions b/c vscode sometimes doesn't delete the assets ++ // for an old extension when uninstalling or changing versions ++ const installedExtensions = new Set(await getInstalledExtensions()); ++ console.log(`${LOG_PREFIX} Found installed extensions: `, Array.from(installedExtensions)); ++ ++ const prePackagedExtensions: ExtensionInfo[] = await getExtensionsFromDirectory(IMAGE_EXTENSIONS_DIR); ++ const prePackagedExtensionsById: Record = {}; ++ prePackagedExtensions.forEach(extension => { ++ prePackagedExtensionsById[extension.identifier] = extension; ++ }); ++ ++ console.log(`${LOG_PREFIX} Found pre-packaged extensions: `, prePackagedExtensions); ++ ++ const pvExtensions = await getExtensionsFromDirectory(PERSISTENT_VOLUME_EXTENSIONS_DIR); ++ const pvExtensionsByName: Record = {}; ++ const pvExtensionsById: Record = {}; ++ pvExtensions.forEach(extension => { ++ if (installedExtensions.has(extension.identifier)) { // only index extensions that are installed ++ pvExtensionsByName[extension.name] = extension; ++ pvExtensionsById[extension.identifier] = extension; ++ } ++ }); ++ console.log(`${LOG_PREFIX} Found installed extensions in persistent volume: `, pvExtensionsById); ++ ++ // check each pre-packaged extension, record if it is not in installed extensions or version mismatch ++ // store unsynced extensions as {identifier pre-packaged ext: currently installed version} ++ const unsyncedExtensions: Record = {} ++ prePackagedExtensions.forEach(extension => { ++ const id = extension.identifier; ++ if (!(installedExtensions.has(id))){ ++ unsyncedExtensions[id] = pvExtensionsByName[extension.name]?.version ?? null; ++ } ++ }); ++ console.log(`${LOG_PREFIX} Unsynced extensions: `, unsyncedExtensions); ++ ++ if (Object.keys(unsyncedExtensions).length !== 0) { ++ const selection = await vscode.window.showWarningMessage( ++ 'Warning: You have unsynchronized extensions from SageMaker Distribution \ ++ which could result in incompatibilities with Code Editor. Do you want to install them?', ++ "Synchronize Extensions", "Dismiss"); ++ ++ if (selection === "Synchronize Extensions") { ++ const quickPick = vscode.window.createQuickPick(); ++ quickPick.items = Object.keys(unsyncedExtensions).map(extensionId => ({ ++ label: extensionId, ++ description: unsyncedExtensions[extensionId] ? `Currently installed version: ${unsyncedExtensions[extensionId]}` : undefined, ++ })); ++ quickPick.placeholder = 'Select extensions to install'; ++ quickPick.canSelectMany = true; ++ quickPick.ignoreFocusOut = true; ++ ++ quickPick.onDidAccept(async () => { ++ const selectedExtensions = quickPick.selectedItems.map(item => item.label); ++ ++ for (const extensionId of selectedExtensions) { ++ const extensionName = prePackagedExtensionsById[extensionId].name; ++ await installExtension(prePackagedExtensionsById[extensionId], pvExtensionsByName[extensionName]); ++ } ++ await refreshExtensionsMetadata(); ++ ++ quickPick.hide(); ++ await vscode.window.showInformationMessage( ++ 'Extensions have been installed. \nWould you like to reload the window?', ++ { modal: true }, ++ 'Reload' ++ ).then(selection => { ++ if (selection === 'Reload') { ++ vscode.commands.executeCommand('workbench.action.reloadWindow'); ++ } ++ }); ++ }); ++ ++ quickPick.show(); ++ } ++ } ++} +\ No newline at end of file +Index: sagemaker-code-editor/vscode/extensions/sagemaker-extensions-sync/tsconfig.json +=================================================================== +--- /dev/null ++++ sagemaker-code-editor/vscode/extensions/sagemaker-extensions-sync/tsconfig.json +@@ -0,0 +1,10 @@ ++{ ++ "extends": "../tsconfig.base.json", ++ "compilerOptions": { ++ "outDir": "./out" ++ }, ++ "include": [ ++ "../sagemaker-extensions-sync/src/**/*", ++ "../../src/vscode-dts/vscode.d.ts" ++ ] ++} +Index: sagemaker-code-editor/vscode/extensions/sagemaker-extensions-sync/yarn.lock +=================================================================== +--- /dev/null ++++ sagemaker-code-editor/vscode/extensions/sagemaker-extensions-sync/yarn.lock +@@ -0,0 +1,4 @@ ++# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. ++# yarn lockfile v1 ++ ++ +Index: sagemaker-code-editor/vscode/extensions/sagemaker-extensions-sync/src/utils.ts +=================================================================== +--- /dev/null ++++ sagemaker-code-editor/vscode/extensions/sagemaker-extensions-sync/src/utils.ts +@@ -0,0 +1,152 @@ ++import * as fs from "fs/promises"; ++import * as path from "path"; ++import * as vscode from 'vscode'; ++import { execFile } from "child_process"; ++import { promisify } from "util"; ++ ++import { ++ ExtensionInfo, ++ LOG_PREFIX, ++ PERSISTENT_VOLUME_EXTENSIONS_DIR, ++} from "./constants" ++ ++export async function getExtensionsFromDirectory(directoryPath: string): Promise { ++ const results: ExtensionInfo[] = []; ++ try { ++ const items = await fs.readdir(directoryPath); ++ ++ for (const item of items) { ++ const itemPath = path.join(directoryPath, item); ++ try { ++ const stats = await fs.stat(itemPath); ++ ++ if (stats.isDirectory()) { ++ const packageJsonPath = path.join(itemPath, "package.json"); ++ ++ const packageData = JSON.parse(await fs.readFile(packageJsonPath, "utf8")); ++ ++ if (packageData.name && packageData.publisher && packageData.version) { ++ results.push(new ExtensionInfo( ++ packageData.name, ++ packageData.publisher, ++ packageData.version, ++ itemPath, ++ )); ++ } ++ } ++ } catch (error) { ++ // fs.stat will break on dangling simlinks. Just skip to the next file ++ console.error(`${LOG_PREFIX} Error reading package.json in ${itemPath}:`, error); ++ } ++ } ++ } catch (error) { ++ console.error(`${LOG_PREFIX} Error reading directory ${directoryPath}:`, error); ++ } ++ return results; ++} ++ ++export async function getInstalledExtensions(): Promise { ++ const command = "sagemaker-code-editor"; ++ const args = ["--list-extensions", "--show-versions", "--extensions-dir", PERSISTENT_VOLUME_EXTENSIONS_DIR]; ++ ++ const execFileAsync = promisify(execFile); ++ try { ++ const { stdout, stderr } = await execFileAsync(command, args); ++ if (stderr) { ++ throw new Error("stderr"); ++ } ++ return stdout.split("\n").filter(line => line.trim() !== ""); ++ } catch (error) { ++ console.error(`${LOG_PREFIX} Error getting list of installed extensions:`, error); ++ throw error; ++ } ++} ++ ++export async function refreshExtensionsMetadata(): Promise { ++ const metaDataFile = path.join(PERSISTENT_VOLUME_EXTENSIONS_DIR, "extensions.json"); ++ try { ++ await fs.unlink(metaDataFile); ++ } catch (error) { ++ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { ++ console.error(`${LOG_PREFIX} Error removing metadata file:`, error); ++ } ++ } ++} ++ ++export async function installExtension( ++ prePackagedExtensionInfo: ExtensionInfo, installedExtensionInfo?: ExtensionInfo | undefined ++): Promise { ++ if (installedExtensionInfo) { ++ console.log(`${LOG_PREFIX} Upgrading extension from ${installedExtensionInfo.identifier} to ${prePackagedExtensionInfo.identifier}`); ++ } else { ++ console.log(`${LOG_PREFIX} Installing extension ${prePackagedExtensionInfo.identifier}`); ++ } ++ try { ++ if (!prePackagedExtensionInfo.path) { ++ throw new Error(`Extension path missing for ${prePackagedExtensionInfo.identifier}`); ++ } ++ ++ const targetPath = path.join(PERSISTENT_VOLUME_EXTENSIONS_DIR, path.basename(prePackagedExtensionInfo.path)); ++ ++ // Remove existing symlink or directory if it exists ++ try { ++ console.log(`${LOG_PREFIX} Removing existing folder ${targetPath}`); ++ await fs.unlink(targetPath); ++ } catch (error) { ++ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { ++ console.error(`${LOG_PREFIX} Error removing existing extension:`, error); ++ throw error; ++ } ++ // if file already doesn't exist then keep going ++ } ++ ++ // Create new symlink ++ try { ++ console.log(`${LOG_PREFIX} Adding extension to persistent volume directory`); ++ await fs.symlink(prePackagedExtensionInfo.path, targetPath, 'dir'); ++ } catch (error) { ++ console.error(`${LOG_PREFIX} Error adding extension to persistent volume directory:`, error); ++ throw error; ++ } ++ ++ // Handle .obsolete file ++ const OBSOLETE_FILE = path.join(PERSISTENT_VOLUME_EXTENSIONS_DIR, '.obsolete'); ++ let obsoleteData: Record = {}; ++ ++ try { ++ const obsoleteContent = await fs.readFile(OBSOLETE_FILE, 'utf-8'); ++ console.log(`${LOG_PREFIX} .obsolete file found`); ++ obsoleteData = JSON.parse(obsoleteContent); ++ } catch (error) { ++ if ((error as NodeJS.ErrnoException).code === 'ENOENT') { ++ console.log(`${LOG_PREFIX} .obsolete file not found. Creating a new one.`); ++ } else { ++ console.warn(`${LOG_PREFIX} Error reading .obsolete file:`, error); ++ // Backup malformed file ++ const backupPath = `${OBSOLETE_FILE}.bak`; ++ await fs.rename(OBSOLETE_FILE, backupPath); ++ console.log(`${LOG_PREFIX} Backed up malformed .obsolete file to ${backupPath}`); ++ } ++ } ++ ++ if (installedExtensionInfo?.path) { ++ const obsoleteBasename = path.basename(installedExtensionInfo.path); ++ obsoleteData[obsoleteBasename] = true; ++ } ++ const obsoleteBasenamePrepackaged = path.basename(prePackagedExtensionInfo.path); ++ obsoleteData[obsoleteBasenamePrepackaged] = false; ++ ++ try { ++ console.log(`${LOG_PREFIX} Writing to .obsolete file.`); ++ await fs.writeFile(OBSOLETE_FILE, JSON.stringify(obsoleteData, null, 2)); ++ } catch (error) { ++ console.error(`${LOG_PREFIX} Error writing .obsolete file:`, error); ++ throw error; ++ } ++ ++ console.log(`${LOG_PREFIX} Installed ${prePackagedExtensionInfo.identifier}`); ++ } catch (error) { ++ vscode.window.showErrorMessage(`Could not install extension ${prePackagedExtensionInfo.identifier}`); ++ console.error(`${LOG_PREFIX} ${error}`); ++ } ++} +\ No newline at end of file diff --git a/patches/series b/patches/series index 1d2786424..0ff39bec3 100644 --- a/patches/series +++ b/patches/series @@ -16,3 +16,4 @@ sagemaker-ui-dark-theme.patch sagemaker-ui-post-startup.patch sagemaker-extension-smus-support.patch post-startup-notifications.patch +sagemaker-extensions-sync.patch \ No newline at end of file