diff --git a/packages/amazonq/.changes/next-release/Bug Fix-d95abc3d-f40d-481d-bbc2-523aa572f5cd.json b/packages/amazonq/.changes/next-release/Bug Fix-d95abc3d-f40d-481d-bbc2-523aa572f5cd.json new file mode 100644 index 00000000000..3729e3b525d --- /dev/null +++ b/packages/amazonq/.changes/next-release/Bug Fix-d95abc3d-f40d-481d-bbc2-523aa572f5cd.json @@ -0,0 +1,4 @@ +{ + "type": "Bug Fix", + "description": "Amazon Q support web/container environments running Ubuntu/Linux, even when the host machine is Amazon Linux 2." +} diff --git a/packages/core/src/shared/vscode/env.ts b/packages/core/src/shared/vscode/env.ts index abd9c58ae2d..1ddb042e415 100644 --- a/packages/core/src/shared/vscode/env.ts +++ b/packages/core/src/shared/vscode/env.ts @@ -6,12 +6,12 @@ import * as semver from 'semver' import * as vscode from 'vscode' import * as packageJson from '../../../package.json' -import * as os from 'os' import { getLogger } from '../logger/logger' import { onceChanged } from '../utilities/functionUtils' import { ChildProcess } from '../utilities/processUtils' import globals, { isWeb } from '../extensionGlobals' import * as devConfig from '../../dev/config' +import * as os from 'os' /** * Returns true if the current build is running on CI (build server). @@ -124,6 +124,35 @@ export function isRemoteWorkspace(): boolean { return vscode.env.remoteName === 'ssh-remote' } +/** + * Parses an os-release file according to the freedesktop.org standard. + * + * @param content The content of the os-release file + * @returns A record of key-value pairs from the os-release file + * + * @see https://www.freedesktop.org/software/systemd/man/latest/os-release.html + */ +function parseOsRelease(content: string): Record { + const result: Record = {} + + for (let line of content.split('\n')) { + line = line.trim() + // Skip empty lines and comments + if (!line || line.startsWith('#')) { + continue + } + + const eqIndex = line.indexOf('=') + if (eqIndex > 0) { + const key = line.slice(0, eqIndex) + const value = line.slice(eqIndex + 1).replace(/^["']|["']$/g, '') + result[key] = value + } + } + + return result +} + /** * Checks if the current environment has SageMaker-specific environment variables * @returns true if SageMaker environment variables are detected @@ -146,36 +175,83 @@ export function hasSageMakerEnvVars(): boolean { /** * Checks if the current environment is running on Amazon Linux 2. * - * This function attempts to detect if we're running in a container on an AL2 host - * by checking both the OS release and container-specific indicators. + * This function detects the container/runtime OS, not the host OS. + * In containerized environments, we check the container's OS identity. + * + * Detection Process (in order): + * 1. Returns false for web environments (browser-based) + * 2. Returns false for SageMaker environments (even if container is AL2) + * 3. Checks `/etc/os-release` with fallback to `/usr/lib/os-release` + * - Standard Linux OS identification files per freedesktop.org spec + * - Looks for `ID="amzn"` and `VERSION_ID="2"` for AL2 + * - This correctly identifies AL2 containers regardless of host OS + * + * This approach ensures correct detection in: + * - Containerized environments (detects container OS, not host) + * - AL2 containers on any host OS (Ubuntu, AL2023, etc.) + * - Web/browser environments (returns false) + * - SageMaker environments (returns false) * - * Example: `5.10.220-188.869.amzn2int.x86_64` or `5.10.236-227.928.amzn2.x86_64` (Cloud Dev Machine) + * Note: We intentionally do NOT check kernel version as it reflects the host OS, + * not the container OS. AL2 containers should be treated as AL2 environments + * regardless of whether they run on AL2, Ubuntu, or other host kernels. + * + * References: + * - https://docs.aws.amazon.com/linux/al2/ug/ident-amazon-linux-specific.html + * - https://docs.aws.amazon.com/linux/al2/ug/ident-os-release.html + * - https://www.freedesktop.org/software/systemd/man/latest/os-release.html */ export function isAmazonLinux2() { + // Skip AL2 detection for web environments + // In web mode, we're running in a browser, not on AL2 + if (isWeb()) { + return false + } + // First check if we're in a SageMaker environment, which should not be treated as AL2 - // even if the underlying host is AL2 + // even if the underlying container is AL2 if (hasSageMakerEnvVars()) { return false } - // Check if we're in a container environment that's not AL2 - if (process.env.container === 'docker' || process.env.DOCKER_HOST || process.env.DOCKER_BUILDKIT) { - // Additional check for container OS - if we can determine it's not AL2 - try { - const fs = require('fs') - if (fs.existsSync('/etc/os-release')) { - const osRelease = fs.readFileSync('/etc/os-release', 'utf8') - if (!osRelease.includes('Amazon Linux 2') && !osRelease.includes('amzn2')) { - return false + // Only proceed with file checks on Linux platforms + if (process.platform !== 'linux') { + return false + } + + // Check the container/runtime OS identity via os-release files + // This correctly identifies AL2 containers regardless of host OS + try { + const fs = require('fs') + // Check /etc/os-release with fallback to /usr/lib/os-release as per freedesktop.org spec + const osReleasePaths = ['/etc/os-release', '/usr/lib/os-release'] + + for (const osReleasePath of osReleasePaths) { + if (fs.existsSync(osReleasePath)) { + try { + const osReleaseContent = fs.readFileSync(osReleasePath, 'utf8') + const osRelease = parseOsRelease(osReleaseContent) + + // Check if this is Amazon Linux 2 + // We trust os-release as the authoritative source for container OS identity + return osRelease.VERSION_ID === '2' && osRelease.ID === 'amzn' + } catch (e) { + // Continue to next path if parsing fails + getLogger().error(`Parsing os-release file ${osReleasePath} failed: ${e}`) } } - } catch (e) { - // If we can't read the file, fall back to the os.release() check } + } catch (e) { + // If we can't read the files, we cannot determine AL2 status + getLogger().error(`Checking os-release files failed: ${e}`) } - // Standard check for AL2 in the OS release string - return (os.release().includes('.amzn2int.') || os.release().includes('.amzn2.')) && process.platform === 'linux' + // Fall back to kernel version check if os-release files are unavailable or failed + // This is needed for environments where os-release might not be accessible + const kernelRelease = os.release() + const hasAL2Kernel = kernelRelease.includes('.amzn2int.') || kernelRelease.includes('.amzn2.') + + return hasAL2Kernel } /** @@ -217,9 +293,9 @@ export function getExtRuntimeContext(): { extensionHost: ExtensionHostLocation } { const extensionHost = - // taken from https://github.com/microsoft/vscode/blob/7c9e4bb23992c63f20cd86bbe7a52a3aa4bed89d/extensions/github-authentication/src/githubServer.ts#L121 to help determine which auth flows - // should be used - typeof navigator === 'undefined' + // Check if we're in a Node.js environment (desktop/remote) vs web worker + // Updated to be compatible with Node.js v22 which includes navigator global + typeof process === 'object' && process.versions?.node ? globals.context.extension.extensionKind === vscode.ExtensionKind.UI ? 'local' : 'remote' diff --git a/packages/core/src/test/shared/vscode/env.test.ts b/packages/core/src/test/shared/vscode/env.test.ts index cf09d085e68..a71aca33e8d 100644 --- a/packages/core/src/test/shared/vscode/env.test.ts +++ b/packages/core/src/test/shared/vscode/env.test.ts @@ -5,13 +5,21 @@ import assert from 'assert' import path from 'path' -import { isCloudDesktop, getEnvVars, getServiceEnvVarConfig, isAmazonLinux2, isBeta } from '../../../shared/vscode/env' +import { + isCloudDesktop, + getEnvVars, + getServiceEnvVarConfig, + isAmazonLinux2, + isBeta, + hasSageMakerEnvVars, +} from '../../../shared/vscode/env' import { ChildProcess } from '../../../shared/utilities/processUtils' import * as sinon from 'sinon' import os from 'os' import fs from '../../../shared/fs/fs' import vscode from 'vscode' import { getComputeEnvType } from '../../../shared/telemetry/util' +import * as globals from '../../../shared/extensionGlobals' describe('env', function () { // create a sinon sandbox instance and instantiate in a beforeEach @@ -97,22 +105,355 @@ describe('env', function () { assert.strictEqual(isBeta(), expected) }) - it('isAmazonLinux2', function () { - sandbox.stub(process, 'platform').value('linux') - const versionStub = stubOsVersion('5.10.220-188.869.amzn2int.x86_64') - assert.strictEqual(isAmazonLinux2(), true) + describe('isAmazonLinux2', function () { + let fsExistsStub: sinon.SinonStub + let fsReadFileStub: sinon.SinonStub + let isWebStub: sinon.SinonStub + let platformStub: sinon.SinonStub + let osReleaseStub: sinon.SinonStub + let moduleLoadStub: sinon.SinonStub + + beforeEach(function () { + // Default stubs + platformStub = sandbox.stub(process, 'platform').value('linux') + osReleaseStub = stubOsVersion('5.10.220-188.869.amzn2int.x86_64') + isWebStub = sandbox.stub(globals, 'isWeb').returns(false) + + // Mock fs module + const fsMock = { + existsSync: sandbox.stub().returns(false), + readFileSync: sandbox.stub().returns(''), + } + fsExistsStub = fsMock.existsSync + fsReadFileStub = fsMock.readFileSync + + // Stub Module._load to intercept require calls + const Module = require('module') + moduleLoadStub = sandbox.stub(Module, '_load').callThrough() + moduleLoadStub.withArgs('fs').returns(fsMock) + }) + + it('returns false in web environment', function () { + isWebStub.returns(true) + assert.strictEqual(isAmazonLinux2(), false) + }) + + it('returns false in SageMaker environment with SAGEMAKER_APP_TYPE', function () { + const originalValue = process.env.SAGEMAKER_APP_TYPE + process.env.SAGEMAKER_APP_TYPE = 'JupyterLab' + try { + assert.strictEqual(isAmazonLinux2(), false) + } finally { + if (originalValue === undefined) { + delete process.env.SAGEMAKER_APP_TYPE + } else { + process.env.SAGEMAKER_APP_TYPE = originalValue + } + } + }) + + it('returns false in SageMaker environment with SM_APP_TYPE', function () { + const originalValue = process.env.SM_APP_TYPE + process.env.SM_APP_TYPE = 'JupyterLab' + try { + assert.strictEqual(isAmazonLinux2(), false) + } finally { + if (originalValue === undefined) { + delete process.env.SM_APP_TYPE + } else { + process.env.SM_APP_TYPE = originalValue + } + } + }) + + it('returns false in SageMaker environment with SERVICE_NAME', function () { + const originalValue = process.env.SERVICE_NAME + process.env.SERVICE_NAME = 'SageMakerUnifiedStudio' + try { + assert.strictEqual(isAmazonLinux2(), false) + } finally { + if (originalValue === undefined) { + delete process.env.SERVICE_NAME + } else { + process.env.SERVICE_NAME = originalValue + } + } + }) + + it('returns false when /etc/os-release indicates Ubuntu in container', function () { + fsExistsStub.returns(true) + fsReadFileStub.returns(` +NAME="Ubuntu" +VERSION="20.04.6 LTS (Focal Fossa)" +ID=ubuntu +ID_LIKE=debian +PRETTY_NAME="Ubuntu 20.04.6 LTS" +VERSION_ID="20.04" + `) + + // Even with AL2 kernel (host is AL2), should return false (container is Ubuntu) + assert.strictEqual(isAmazonLinux2(), false) + }) + + it('returns false when /etc/os-release indicates Amazon Linux 2023', function () { + fsExistsStub.returns(true) + fsReadFileStub.returns(` +NAME="Amazon Linux" +VERSION="2023" +ID="amzn" +ID_LIKE="fedora" +VERSION_ID="2023" +PLATFORM_ID="platform:al2023" +PRETTY_NAME="Amazon Linux 2023" + `) + + assert.strictEqual(isAmazonLinux2(), false) + }) + + it('returns true when /etc/os-release indicates Amazon Linux 2', function () { + fsExistsStub.returns(true) + fsReadFileStub.returns(` +NAME="Amazon Linux 2" +VERSION="2" +ID="amzn" +ID_LIKE="centos rhel fedora" +VERSION_ID="2" +PRETTY_NAME="Amazon Linux 2" + `) + + assert.strictEqual(isAmazonLinux2(), true) + }) + + it('returns true when /etc/os-release has ID="amzn" and VERSION_ID="2"', function () { + fsExistsStub.returns(true) + fsReadFileStub.returns(` +NAME="Amazon Linux" +VERSION="2" +ID="amzn" +VERSION_ID="2" + `) + + assert.strictEqual(isAmazonLinux2(), true) + }) + + it('returns false when /etc/os-release indicates CentOS', function () { + fsExistsStub.returns(true) + fsReadFileStub.returns(` +NAME="CentOS Linux" +VERSION="7 (Core)" +ID="centos" +ID_LIKE="rhel fedora" +VERSION_ID="7" + `) + + // Even with AL2 kernel + assert.strictEqual(isAmazonLinux2(), false) + }) + + it('falls back to kernel check when /etc/os-release does not exist', function () { + fsExistsStub.returns(false) + + // Test with AL2 kernel + assert.strictEqual(isAmazonLinux2(), true) + + // Test with non-AL2 kernel + osReleaseStub.returns('5.10.220-188.869.NOT_INTERNAL.x86_64') + assert.strictEqual(isAmazonLinux2(), false) + }) + + it('falls back to kernel check when /etc/os-release read fails', function () { + fsExistsStub.returns(true) + fsReadFileStub.throws(new Error('Permission denied')) + + // Should fall back to kernel check + assert.strictEqual(isAmazonLinux2(), true) + }) + + it('returns true with .amzn2. kernel pattern', function () { + fsExistsStub.returns(false) + osReleaseStub.returns('5.10.236-227.928.amzn2.x86_64') + assert.strictEqual(isAmazonLinux2(), true) + }) + + it('returns true with .amzn2int. kernel pattern', function () { + fsExistsStub.returns(false) + osReleaseStub.returns('5.10.220-188.869.amzn2int.x86_64') + assert.strictEqual(isAmazonLinux2(), true) + }) + + it('returns false with non-AL2 kernel', function () { + fsExistsStub.returns(false) + osReleaseStub.returns('5.15.0-91-generic') + assert.strictEqual(isAmazonLinux2(), false) + }) + + it('returns false on non-Linux platforms', function () { + platformStub.value('darwin') + fsExistsStub.returns(false) + assert.strictEqual(isAmazonLinux2(), false) + + platformStub.value('win32') + assert.strictEqual(isAmazonLinux2(), false) + }) - versionStub.returns('5.10.236-227.928.amzn2.x86_64') - assert.strictEqual(isAmazonLinux2(), true) + it('returns false when container OS is different from host OS', function () { + // Scenario: Host is AL2 (kernel shows AL2) but container is Ubuntu + fsExistsStub.returns(true) + fsReadFileStub.returns(` +NAME="Ubuntu" +VERSION="22.04" +ID=ubuntu +VERSION_ID="22.04" + `) + osReleaseStub.returns('5.10.220-188.869.amzn2int.x86_64') // AL2 kernel from host - versionStub.returns('5.10.220-188.869.NOT_INTERNAL.x86_64') - assert.strictEqual(isAmazonLinux2(), false) + // Should trust container OS over kernel + assert.strictEqual(isAmazonLinux2(), false) + }) + + it('handles os-release with comments correctly', function () { + fsExistsStub.returns(true) + fsReadFileStub.returns(` +# This is a comment with VERSION_ID="2023" that should be ignored +NAME="Amazon Linux 2" +VERSION="2" +ID="amzn" +# Another comment with PLATFORM_ID="platform:al2023" +VERSION_ID="2" +PRETTY_NAME="Amazon Linux 2" + `) + + // Should correctly identify as AL2 despite comments containing AL2023 identifiers + assert.strictEqual(isAmazonLinux2(), true) + }) + + it('handles os-release with quoted values correctly', function () { + fsExistsStub.returns(true) + fsReadFileStub.returns(` +NAME="Amazon Linux 2" +VERSION='2' +ID=amzn +VERSION_ID="2" +PRETTY_NAME='Amazon Linux 2' + `) + + // Should correctly parse both single and double quoted values + assert.strictEqual(isAmazonLinux2(), true) + }) + + it('handles os-release with empty lines and whitespace', function () { + fsExistsStub.returns(true) + fsReadFileStub.returns(` + +NAME="Amazon Linux 2" + +VERSION="2" + ID="amzn" +VERSION_ID="2" + +PRETTY_NAME="Amazon Linux 2" + + `) + + // Should correctly parse despite empty lines and whitespace + assert.strictEqual(isAmazonLinux2(), true) + }) + + it('rejects Amazon Linux 2023 even with misleading comments', function () { + fsExistsStub.returns(true) + fsReadFileStub.returns(` +# This comment mentions Amazon Linux 2 but should not affect parsing +NAME="Amazon Linux" +VERSION="2023" +ID="amzn" +# Comment with VERSION_ID="2" should be ignored +VERSION_ID="2023" +PLATFORM_ID="platform:al2023" +PRETTY_NAME="Amazon Linux 2023" + `) + + // Should correctly identify as AL2023 (not AL2) despite misleading comments + assert.strictEqual(isAmazonLinux2(), false) + }) + + it('handles malformed os-release lines gracefully', function () { + fsExistsStub.returns(true) + fsReadFileStub.returns(` +NAME="Amazon Linux 2" +VERSION="2" +ID="amzn" +INVALID_LINE_WITHOUT_EQUALS +=INVALID_LINE_STARTING_WITH_EQUALS +VERSION_ID="2" +PRETTY_NAME="Amazon Linux 2" + `) + + // Should correctly parse valid lines and ignore malformed ones + assert.strictEqual(isAmazonLinux2(), true) + }) + }) + + describe('hasSageMakerEnvVars', function () { + afterEach(function () { + // Clean up environment variables + delete process.env.SAGEMAKER_APP_TYPE + delete process.env.SAGEMAKER_INTERNAL_IMAGE_URI + delete process.env.STUDIO_LOGGING_DIR + delete process.env.SM_APP_TYPE + delete process.env.SM_INTERNAL_IMAGE_URI + delete process.env.SERVICE_NAME + }) + + it('returns true when SAGEMAKER_APP_TYPE is set', function () { + process.env.SAGEMAKER_APP_TYPE = 'JupyterLab' + assert.strictEqual(hasSageMakerEnvVars(), true) + }) + + it('returns true when SM_APP_TYPE is set', function () { + process.env.SM_APP_TYPE = 'JupyterLab' + assert.strictEqual(hasSageMakerEnvVars(), true) + }) + + it('returns true when SERVICE_NAME is SageMakerUnifiedStudio', function () { + process.env.SERVICE_NAME = 'SageMakerUnifiedStudio' + assert.strictEqual(hasSageMakerEnvVars(), true) + }) + + it('returns true when STUDIO_LOGGING_DIR contains /var/log/studio', function () { + process.env.STUDIO_LOGGING_DIR = '/var/log/studio/logs' + assert.strictEqual(hasSageMakerEnvVars(), true) + }) + + it('returns false when no SageMaker env vars are set', function () { + assert.strictEqual(hasSageMakerEnvVars(), false) + }) + + it('returns false when SERVICE_NAME is set but not SageMakerUnifiedStudio', function () { + process.env.SERVICE_NAME = 'SomeOtherService' + assert.strictEqual(hasSageMakerEnvVars(), false) + }) }) it('isCloudDesktop', async function () { + // Mock fs module for isAmazonLinux2() calls + const fsMock = { + existsSync: sandbox.stub().returns(false), + readFileSync: sandbox.stub().returns(''), + } + const fsExistsStub = fsMock.existsSync + + // Stub Module._load to intercept require calls + const Module = require('module') + const moduleLoadStub = sandbox.stub(Module, '_load').callThrough() + moduleLoadStub.withArgs('fs').returns(fsMock) + sandbox.stub(process, 'platform').value('linux') + sandbox.stub(globals, 'isWeb').returns(false) stubOsVersion('5.10.220-188.869.amzn2int.x86_64') + // Mock fs to return false so it falls back to kernel check (which should return true for AL2) + fsExistsStub.returns(false) + const runStub = sandbox.stub(ChildProcess.prototype, 'run').resolves({ exitCode: 0 } as any) assert.strictEqual(await isCloudDesktop(), true) @@ -121,29 +462,58 @@ describe('env', function () { }) describe('getComputeEnvType', async function () { + let fsExistsStub: sinon.SinonStub + let moduleLoadStub: sinon.SinonStub + + beforeEach(function () { + // Mock fs module for isAmazonLinux2() calls + const fsMock = { + existsSync: sandbox.stub().returns(false), + readFileSync: sandbox.stub().returns(''), + } + fsExistsStub = fsMock.existsSync + + // Stub Module._load to intercept require calls + const Module = require('module') + moduleLoadStub = sandbox.stub(Module, '_load').callThrough() + moduleLoadStub.withArgs('fs').returns(fsMock) + }) + it('cloudDesktop', async function () { sandbox.stub(process, 'platform').value('linux') sandbox.stub(vscode.env, 'remoteName').value('ssh-remote') + sandbox.stub(globals, 'isWeb').returns(false) stubOsVersion('5.10.220-188.869.amzn2int.x86_64') sandbox.stub(ChildProcess.prototype, 'run').resolves({ exitCode: 0 } as any) + // Mock fs to return false so it falls back to kernel check (which should return true for AL2) + fsExistsStub.returns(false) + assert.deepStrictEqual(await getComputeEnvType(), 'cloudDesktop-amzn') }) it('ec2-internal', async function () { sandbox.stub(process, 'platform').value('linux') sandbox.stub(vscode.env, 'remoteName').value('ssh-remote') + sandbox.stub(globals, 'isWeb').returns(false) stubOsVersion('5.10.220-188.869.amzn2int.x86_64') sandbox.stub(ChildProcess.prototype, 'run').resolves({ exitCode: 1 } as any) + // Mock fs to return false so it falls back to kernel check (which should return true for AL2) + fsExistsStub.returns(false) + assert.deepStrictEqual(await getComputeEnvType(), 'ec2-amzn') }) it('ec2', async function () { sandbox.stub(process, 'platform').value('linux') sandbox.stub(vscode.env, 'remoteName').value('ssh-remote') + sandbox.stub(globals, 'isWeb').returns(false) stubOsVersion('5.10.220-188.869.NOT_INTERNAL.x86_64') + // Mock fs to return false so it falls back to kernel check (which should return false for non-AL2) + fsExistsStub.returns(false) + assert.deepStrictEqual(await getComputeEnvType(), 'ec2') }) })