From e274abc0943e8d3fde89ad865fdea0908a44ea5d Mon Sep 17 00:00:00 2001 From: Danil Lobachev Date: Tue, 2 Apr 2024 14:25:50 +0600 Subject: [PATCH 01/12] update content_templates --- .../correlation_rules/Windows_Logon/rule.co | 1 + .../Windows_Network_Connect/rule.co | 4 ++-- .../Windows_Process_Start_or_Powershell/rule.co | 10 ++++++---- .../Windows_Registry_modification/rule.co | 15 +++++---------- .../Windows_Tasks_actions/rule.co | 2 +- 5 files changed, 15 insertions(+), 17 deletions(-) diff --git a/content_templates/correlation_rules/Windows_Logon/rule.co b/content_templates/correlation_rules/Windows_Logon/rule.co index 393ec015..2972ce6e 100644 --- a/content_templates/correlation_rules/Windows_Logon/rule.co +++ b/content_templates/correlation_rules/Windows_Logon/rule.co @@ -68,6 +68,7 @@ rule Windows_Logon: User_Login $alert.key = $alert.context = + } emit { $correlation_type = "" diff --git a/content_templates/correlation_rules/Windows_Network_Connect/rule.co b/content_templates/correlation_rules/Windows_Network_Connect/rule.co index 1639dec5..2c72afaa 100644 --- a/content_templates/correlation_rules/Windows_Network_Connect/rule.co +++ b/content_templates/correlation_rules/Windows_Network_Connect/rule.co @@ -67,8 +67,8 @@ rule Windows_Network_Connect: Network_Connect emit { $correlation_type = "" - $subject = "process" - $action = "open" + $subject = "rule" + $action = "detect" $object = "connection" $status = "success" diff --git a/content_templates/correlation_rules/Windows_Process_Start_or_Powershell/rule.co b/content_templates/correlation_rules/Windows_Process_Start_or_Powershell/rule.co index c6e80e77..9be8ee62 100644 --- a/content_templates/correlation_rules/Windows_Process_Start_or_Powershell/rule.co +++ b/content_templates/correlation_rules/Windows_Process_Start_or_Powershell/rule.co @@ -42,7 +42,8 @@ rule Windows_Process_Start_or_Powershell: Process_Start or Powershell_Pipeline_E } on Process_Start { - $object = object + $action = action # start + $object = object # process $subject.account.name = subject.account.name $subject.account.domain = subject.account.domain @@ -112,7 +113,8 @@ rule Windows_Process_Start_or_Powershell: Process_Start or Powershell_Pipeline_E } on Powershell_Pipeline_Execute { - $object = object + $action = action # execute + $object = object # command $subject.account.name = subject.account.name $subject.account.domain = subject.account.domain @@ -163,7 +165,8 @@ rule Windows_Process_Start_or_Powershell: Process_Start or Powershell_Pipeline_E } on Powershell_Command_Execute { - $object = object + $action = action # execute + $object = object # command $subject.account.name = subject.account.name $subject.account.domain = subject.account.domain @@ -206,7 +209,6 @@ emit { $correlation_type = "" $subject = "account" - $action = "execute" $status = "success" $importance = "" diff --git a/content_templates/correlation_rules/Windows_Registry_modification/rule.co b/content_templates/correlation_rules/Windows_Registry_modification/rule.co index d72be4ea..eb984974 100644 --- a/content_templates/correlation_rules/Windows_Registry_modification/rule.co +++ b/content_templates/correlation_rules/Windows_Registry_modification/rule.co @@ -6,14 +6,9 @@ event Registry_Action: and ( ( - ( - msgid == "13" - or - msgid == "12" - ) - and event_src.title == "sysmon" - ) - or ( + in_list(["12", "13"], msgid) + and event_src.title == "sysmon" + ) or ( msgid == "4657" and event_src.title == "windows" ) @@ -29,8 +24,8 @@ rule Windows_Registry_modification: Registry_Action } on Registry_Action { - $action = action - $status = status + $action = action # modify, remove, create + $status = status # success, failure $subject.account.id = subject.account.id $subject.account.domain = subject.account.domain diff --git a/content_templates/correlation_rules/Windows_Tasks_actions/rule.co b/content_templates/correlation_rules/Windows_Tasks_actions/rule.co index 6ec15e20..48d75f5a 100644 --- a/content_templates/correlation_rules/Windows_Tasks_actions/rule.co +++ b/content_templates/correlation_rules/Windows_Tasks_actions/rule.co @@ -31,7 +31,7 @@ rule Windows_Tasks_actions : Task_Action } on Task_Action { - $action = action + $action = action # modify, create, delete $subject.account.id = subject.account.id $subject.account.name = subject.account.name From d74f8b7e4434b97eb26f01a2188b4f48af989324 Mon Sep 17 00:00:00 2001 From: Danil Lobachev Date: Tue, 9 Jun 2026 23:30:22 +0600 Subject: [PATCH 02/12] Add macOS Docker backend support --- README.en.md | 34 ++ README.md | 34 ++ client/src/extension.ts | 26 +- client/src/helpers/processHelper.ts | 12 +- client/src/helpers/taxonomyHelper.ts | 5 +- client/src/models/command/commonCommands.ts | 13 + .../configureMacOSContainerBackendCommand.ts | 23 + client/src/models/configuration.ts | 309 ++++++++-- .../src/models/siemj/setKBTVersionCommand.ts | 2 +- client/src/models/siemj/siemjConfigBuilder.ts | 20 +- client/src/models/siemj/siemjManager.ts | 18 +- .../tests/correlationUnitTestsRunner.ts | 6 +- .../correlationUnitTestsRunnerViaEvtTests.ts | 6 +- .../toolsHelper.\321\201onfiguration.test.ts" | 10 +- client/src/test/tools/pathMapper.test.ts | 59 ++ client/src/test/tools/toolRunner.test.ts | 44 ++ client/src/tools/kbtInstaller.ts | 205 +++++++ client/src/tools/macosContainerSetup.ts | 560 ++++++++++++++++++ client/src/tools/pathMapper.ts | 157 +++++ client/src/tools/sdkUtilitiesWrappers.ts | 4 +- client/src/tools/toolRunner.ts | 349 +++++++++++ .../actions/packSIEMAllPackagesAction.ts | 25 +- .../commands/buildLocalizationsCommand.ts | 5 +- .../contentTree/commands/buildWldCommand.ts | 6 +- .../contentTree/commands/packKbCommand.ts | 5 +- .../contentTree/commands/unpackKbCommand.ts | 6 +- client/src/views/corrGraphRunner.ts | 4 +- package.json | 64 ++ package.nls.json | 12 +- package.nls.ru.json | 3 +- 30 files changed, 1912 insertions(+), 114 deletions(-) create mode 100644 client/src/models/command/configureMacOSContainerBackendCommand.ts create mode 100644 client/src/test/tools/pathMapper.test.ts create mode 100644 client/src/test/tools/toolRunner.test.ts create mode 100644 client/src/tools/kbtInstaller.ts create mode 100644 client/src/tools/macosContainerSetup.ts create mode 100644 client/src/tools/pathMapper.ts create mode 100644 client/src/tools/toolRunner.ts diff --git a/README.en.md b/README.en.md index 6d192e52..35ca5f35 100644 --- a/README.en.md +++ b/README.en.md @@ -33,6 +33,40 @@ Expansion options: You can easily get a ready-made development environment for XP if you use the [VSCode XP Workspace](https://github.com/Security-Experts-Community/vscode-xp-workspace) project. Everything in it is collected in a single Docker container, and editing occurs through the web version of VSCode. Details in the project repository. +### macOS Hybrid Mode + +On macOS, the extension runs locally in normal VS Code: UI, tree views, editors, language features, webviews, commands and knowledgebase file access stay on the host filesystem. Only operations that require `xp-kbt`, `siemj`, `normalizer-cli` or build tools are executed inside a Docker container. + +The backend can be any Docker container that has XP tools installed. [vscode-xp-workspace](https://github.com/g4n8g/vscode-xp-workspace) is a ready-made preset, but it is not required. The container must bind mount the same knowledgebase directory that is opened in local VS Code, for example: + +- host: `/Users/alice/Work/knowledgebase` +- container: `/workspaces/knowledgebase` + +When the extension starts in local macOS VS Code, it offers to configure the container backend. The wizard selects the local knowledgebase path, checks Docker, selects a running container or creates a new tools container, detects the container mount path, detects or asks for the KBT path, and saves settings. If xp-kbt is not found in the selected container, the wizard can download the latest `vxcontrol/xp-kbt` release, let you choose one of the recent releases, or use a manually entered KBT path. A new container is created from `mcr.microsoft.com/dotnet/sdk:8.0`, bind mounts the selected knowledgebase at `/workspaces/knowledgebase`, and then continues through the same KBT installation step. + +If the automatic notification was dismissed or you want to rerun setup later, use the `XP: Configure macOS Container Backend` command from the Command Palette. + +Temporary extension artifacts are stored in `tmp/xp-output` inside the local knowledgebase by default. Inside the container, the matching path is `xpConfig.docker.outputDirectoryPath`, which defaults to `/workspaces/knowledgebase/tmp/xp-output`. + +Relevant settings: + +- `xpConfig.toolExecutionMode`: `auto`, `local`, or `docker` +- `xpConfig.docker.containerName` +- `xpConfig.docker.workspaceHostPath` +- `xpConfig.docker.workspaceContainerPath` +- `xpConfig.docker.kbtBaseDirectory` +- `xpConfig.docker.outputDirectoryPath` +- `xpConfig.macos.showContainerSetupPrompt` + +While the container is being created and KBT is being installed, the extension writes detailed progress to the `eXtraction and Processing` output channel. The setup notification includes a direct action to open that output. + +Troubleshooting: + +- Docker not installed: install and start Docker Desktop. +- Container not running: start a container with XP tools, choose an already running container in the wizard, or rerun the setup wizard and choose to create a new container. +- Path mapping failed: check that `workspaceHostPath` points to the local knowledgebase and `workspaceContainerPath` matches the bind mount in the container. +- Tool not found in container: run the setup wizard again and choose `Download latest xp-kbt`, `Choose xp-kbt version`, or check `xpConfig.docker.kbtBaseDirectory`. + ## Event normalization To write correlation rules, in general, you will need event normalization formulas. In our [open expertise repository](https://github.com/Security-Experts-Community/open-xp-rules) you can find basic normalization formulas. In the future there will be other types of rules in the XP language created by the community. diff --git a/README.md b/README.md index 8590aee0..7210e771 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,40 @@ Вы можете легко получить готовое окружение для разработки на XP, если воспользуетесь проектом [VSCode XP Workspace](https://github.com/Security-Experts-Community/vscode-xp-workspace). В нём всё собрано в единый Docker-контейнер, а редактирование происходит через веб-версию VSCode. Подробности в репозитории проекта. +### Гибридный режим macOS + +На macOS расширение запускается локально в обычном VS Code: UI, tree view, редакторы, language features, webviews, команды VS Code и чтение файлов knowledgebase остаются на файловой системе хоста. Только операции, которым нужны `xp-kbt`, `siemj`, `normalizer-cli` или build tools, выполняются внутри Docker-контейнера. + +Backend может быть любым Docker-контейнером, в котором установлены XP tools. [vscode-xp-workspace](https://github.com/g4n8g/vscode-xp-workspace) остаётся готовым preset-примером, но не является обязательной зависимостью. Контейнер должен видеть ту же knowledgebase через bind mount, например: + +- host: `/Users/alice/Work/knowledgebase` +- container: `/workspaces/knowledgebase` + +При запуске в локальном VS Code на macOS расширение предлагает настроить контейнерный backend. Wizard выбирает локальный путь к knowledgebase, проверяет Docker, выбирает запущенный контейнер или создаёт новый tools-контейнер, определяет container mount path, определяет или запрашивает путь к KBT и сохраняет настройки. Если xp-kbt не найден в выбранном контейнере, wizard может скачать latest release `vxcontrol/xp-kbt`, дать выбрать одну из доступных версий релизов или использовать вручную введённый путь к KBT. Новый контейнер создаётся на базе `mcr.microsoft.com/dotnet/sdk:8.0`, монтирует выбранную knowledgebase в `/workspaces/knowledgebase` и после создания проходит тот же шаг установки KBT. + +Если автоматическое уведомление было скрыто или настройку нужно повторить позже, используйте команду `XP: Настроить контейнерный backend для macOS` из палитры команд. + +Временные артефакты расширения по умолчанию сохраняются в `tmp/xp-output` внутри локальной knowledgebase. В контейнере этому пути соответствует `xpConfig.docker.outputDirectoryPath`, по умолчанию `/workspaces/knowledgebase/tmp/xp-output`. + +Основные настройки: + +- `xpConfig.toolExecutionMode`: `auto`, `local` или `docker` +- `xpConfig.docker.containerName` +- `xpConfig.docker.workspaceHostPath` +- `xpConfig.docker.workspaceContainerPath` +- `xpConfig.docker.kbtBaseDirectory` +- `xpConfig.docker.outputDirectoryPath` +- `xpConfig.macos.showContainerSetupPrompt` + +Во время создания контейнера и установки KBT расширение пишет подробный прогресс в канал вывода `eXtraction and Processing`. Из уведомления setup wizard можно сразу открыть этот канал через ссылку на output. + +Troubleshooting: + +- Docker not installed: установите и запустите Docker Desktop. +- Container not running: запустите контейнер с XP tools, выберите уже запущенный контейнер в wizard или повторите setup wizard и выберите создание нового контейнера. +- Path mapping failed: проверьте, что `workspaceHostPath` указывает на локальную knowledgebase, а `workspaceContainerPath` совпадает с bind mount в контейнере. +- Tool not found in container: запустите setup wizard ещё раз и выберите `Download latest xp-kbt`, `Choose xp-kbt version`, либо проверьте `xpConfig.docker.kbtBaseDirectory`. + ## Нормализация событий Для написания правил корреляции, в общем случае, Вам потребуются формулы нормализации событий. В нашем [открытом репозитории с экспертизой](https://github.com/Security-Experts-Community/open-xp-rules) вы сможете найти базовые формулы нормализации. В будущем там появятся другие виды правил на языке XP, созданные сообществом. diff --git a/client/src/extension.ts b/client/src/extension.ts index 6ff1e2b4..e8449cf0 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -39,6 +39,7 @@ import { LocalizationEditorViewProvider } from './views/localization/localizatio import { CommonCommands } from './models/command/commonCommands'; import { ToolsManager } from './models/content/toolsManager'; import { SetKBTVersionCommand } from './models/siemj/setKBTVersionCommand'; +import { MacOSContainerSetup } from './tools/macosContainerSetup'; export let Log: Logger; let client: LanguageClient; @@ -71,12 +72,14 @@ export async function activate(context: ExtensionContext): Promise { await UserSettingsManager.init(config); // await ToolsManager.init(config); - // Ensure automatic KBT selection happens early - try { - // This will trigger auto-selection if needed - const kbtBaseDirectory = config.getKbtBaseDirectoryOld(); - } catch (error) { - Log.warn(`Error during KBT auto-selection: ${error.message}`); + if (!config.shouldUseDockerToolRunner()) { + // Ensure automatic KBT selection happens early for local tool execution. + try { + // This will trigger auto-selection if needed + const kbtBaseDirectory = config.getKbtBaseDirectoryOld(); + } catch (error) { + Log.warn(`Error during KBT auto-selection: ${error.message}`); + } } // Automatically set configuration options if not already set @@ -94,6 +97,8 @@ export async function activate(context: ExtensionContext): Promise { ExceptionHelper.show(error); } + await MacOSContainerSetup.maybePrompt(config); + // Конфигурирование LSP. await configureLSPClient(client, context, config); @@ -134,7 +139,14 @@ export async function activate(context: ExtensionContext): Promise { RetroCorrelationViewController.init(config); CommonCommands.init(config); - config.setSIEMJVersion(); + try { + await config.setSIEMJVersion(); + } catch (error) { + Log.warn(`Failed to determine SIEMJ version: ${error.message}`); + if (!config.isLocalMacOS()) { + throw error; + } + } siemCustomPackingTaskProvider = vscode.tasks.registerTaskProvider( XPPackingTaskProvider.Type, diff --git a/client/src/helpers/processHelper.ts b/client/src/helpers/processHelper.ts index ba4c9941..4a523bf2 100644 --- a/client/src/helpers/processHelper.ts +++ b/client/src/helpers/processHelper.ts @@ -9,6 +9,7 @@ export interface ExecutionProcessOptions { encoding?: EncodingType; outputChannel?: vscode.OutputChannel; cancellationToken?: vscode.CancellationToken; + cwd?: string; /** * Проверяет выполнимость команды (например, отсутствия нужного модуля в директориях PATH). * Если команда не выполнима, будет прошено исключение. @@ -80,7 +81,10 @@ export class ProcessHelper { throw result.error; } } - child = child_process.spawn(command, params); + child = child_process.spawn(command, params, { + cwd: options.cwd, + env: process.env + }); } catch (error) { reject(error); return; @@ -106,7 +110,7 @@ export class ProcessHelper { options.outputChannel.append(encodedData); } - if (options.cancellationToken.isCancellationRequested) { + if (options.cancellationToken?.isCancellationRequested) { child.kill(); executionResult.exitCode = child.exitCode; executionResult.isInterrupted = true; @@ -122,7 +126,7 @@ export class ProcessHelper { options.outputChannel.append(encodedData); } - if (options.cancellationToken.isCancellationRequested) { + if (options.cancellationToken?.isCancellationRequested) { child.kill(); executionResult.exitCode = child.exitCode; executionResult.isInterrupted = true; @@ -138,7 +142,7 @@ export class ProcessHelper { options.outputChannel.append(encodedData); } - if (options.cancellationToken.isCancellationRequested) { + if (options.cancellationToken?.isCancellationRequested) { child.kill(); executionResult.exitCode = child.exitCode; executionResult.isInterrupted = true; diff --git a/client/src/helpers/taxonomyHelper.ts b/client/src/helpers/taxonomyHelper.ts index 9b58de1d..264c1346 100644 --- a/client/src/helpers/taxonomyHelper.ts +++ b/client/src/helpers/taxonomyHelper.ts @@ -2,7 +2,6 @@ import * as vscode from 'vscode'; import { Configuration } from '../models/configuration'; import { TaxonomyFieldDetails } from '../providers/taxonomyFieldDetails'; -import { FileSystemHelper } from './fileSystemHelper'; import { YamlHelper } from './yamlHelper'; import { TaxonomyLocalePathLocator } from '../models/locator/taxonomyLocalePathLocator'; @@ -10,7 +9,7 @@ export class TaxonomyHelper { public static async getTaxonomySignaturesPlain(configuration: Configuration): Promise { // Считываем поля таксономии. const taxonomyFilePath = configuration.getTaxonomyFullPath(); - const taxonomyFileContent = await FileSystemHelper.readContentFile(taxonomyFilePath); + const taxonomyFileContent = await configuration.readTextFile(taxonomyFilePath); const taxonomySignaturesPlain = JSON.parse(taxonomyFileContent); return taxonomySignaturesPlain; } @@ -26,7 +25,7 @@ export class TaxonomyHelper { configuration.getTaxonomyDirPath() ); const taxonomyRuLocalizationFilePath = lfpl.getLocaleFilePath(); - const taxonomyRuLocalizationFileContent = await FileSystemHelper.readContentFile( + const taxonomyRuLocalizationFileContent = await configuration.readTextFile( taxonomyRuLocalizationFilePath ); const ruLocalizationPlain = YamlHelper.parse(taxonomyRuLocalizationFileContent); diff --git a/client/src/models/command/commonCommands.ts b/client/src/models/command/commonCommands.ts index f2a97cae..28ffa892 100644 --- a/client/src/models/command/commonCommands.ts +++ b/client/src/models/command/commonCommands.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; +import { ConfigureMacOSContainerBackendCommand } from './configureMacOSContainerBackendCommand'; import { Configuration } from '../configuration'; import { ShowExtensionOutputChannelCommand } from './showExtensionOutputChannelCommand'; import { ShowExtensionSettingsCommand } from './showExtensionSettingsCommand'; @@ -19,8 +20,20 @@ export class CommonCommands { command.execute(); }) ); + + config.getContext().subscriptions.push( + vscode.commands.registerCommand( + CommonCommands.CONFIGURE_MACOS_CONTAINER_BACKEND_COMMAND, + async () => { + const command = new ConfigureMacOSContainerBackendCommand(config); + command.execute(); + } + ) + ); } public static SHOW_OUTPUT_CHANNEL_COMMAND = 'xp.commonCommands.showOutputChannel'; public static SHOW_EXTENSION_SETTINGS_COMMAND = 'xp.commonCommands.showExtensionSettings'; + public static CONFIGURE_MACOS_CONTAINER_BACKEND_COMMAND = + 'xp.commonCommands.configureMacOSContainerBackend'; } diff --git a/client/src/models/command/configureMacOSContainerBackendCommand.ts b/client/src/models/command/configureMacOSContainerBackendCommand.ts new file mode 100644 index 00000000..a43d82b8 --- /dev/null +++ b/client/src/models/command/configureMacOSContainerBackendCommand.ts @@ -0,0 +1,23 @@ +import * as vscode from 'vscode'; + +import { MacOSContainerSetup } from '../../tools/macosContainerSetup'; +import { Configuration } from '../configuration'; +import { Command } from './command'; + +export class ConfigureMacOSContainerBackendCommand extends Command { + constructor(private config: Configuration) { + super(); + } + + public async execute(): Promise { + if (!this.config.isLocalMacOS()) { + vscode.window.showInformationMessage( + 'XP container backend setup is intended for local macOS VS Code.' + ); + return false; + } + + await MacOSContainerSetup.configure(this.config); + return true; + } +} diff --git a/client/src/models/configuration.ts b/client/src/models/configuration.ts index c1da62a1..debea775 100644 --- a/client/src/models/configuration.ts +++ b/client/src/models/configuration.ts @@ -4,7 +4,6 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { Guid } from 'guid-typescript'; -import { FileSystemException } from './fileSystemException'; import { XpException as XpException } from './xpException'; import { ContentType } from '../contentType/contentType'; import { Localization } from './content/localization'; @@ -15,9 +14,15 @@ import { FileDiagnostics } from './siemj/siemJOutputParser'; import { LocalizationService } from '../l10n/localizationService'; import { Origin } from './content/userSettingsManager'; import { DialogHelper } from '../helpers/dialogHelper'; +import { FileSystemHelper } from '../helpers/fileSystemHelper'; import { LogLevel } from '../logger'; import { Log } from '../extension'; -import { ProcessHelper } from '../helpers/processHelper'; +import { + DockerToolRunner, + ToolExecutionMode, + ToolRunner, + ToolRunnerFactory +} from '../tools/toolRunner'; export type EncodingType = 'windows-1251' | 'utf-8' | 'utf-16'; @@ -74,16 +79,12 @@ export class Configuration { return this.SIEMJVersion; } - public setSIEMJVersion(): void { - let result = ProcessHelper.readProcessArgsOutputSync( - this.getSiemjPath(), - ['-v'], - 'utf8' - ).trim(); - if (result.match(/siemj(:?\.exe)? 1\./)) { + public async setSIEMJVersion(): Promise { + const result = (await this.getToolRunner().runSiemj(['-v'], { encoding: 'utf-8' })).output.trim(); + if (result.match(/siemj(?:\.real|\.exe)?\s+1\./)) { this.SIEMJVersion = '1'; } else { - if (result.match(/siemj(:?\.exe)? 2\./)) { + if (result.match(/siemj(?:\.real|\.exe)?\s+2\./)) { this.SIEMJVersion = '2'; } else { throw new XpException(`Unexpected SIEMJ version: ${result}`); @@ -97,7 +98,7 @@ export class Configuration { } public craftLSPTaxonomyPath(): string { - return path.join(this.getKbtBaseDirectory(), '/knowledgebase/contracts/taxonomy/taxonomy.json'); + return this.getTaxonomyFullPath(); } public getLSPi18nTaxonomyPath(): string { @@ -106,7 +107,7 @@ export class Configuration { } public craftLSPi18nTaxonomyPath(): string { - return path.join(this.getKbtBaseDirectory(), 'knowledgebase/contracts/taxonomy/i18n/'); + return path.join(this.getTaxonomyDirPath(), 'i18n'); } public async updateLSPTaxonomyPath(): Promise { @@ -217,6 +218,46 @@ export class Configuration { return this.context.extensionMode; } + public getToolExecutionMode(): ToolExecutionMode { + const mode = this.getWorkspaceConfiguration().get('toolExecutionMode'); + return mode ?? 'auto'; + } + + public isLocalMacOS(): boolean { + return process.platform === 'darwin' && !vscode.env.remoteName; + } + + public shouldUseDockerToolRunner(): boolean { + return ToolRunnerFactory.resolveMode(this.getToolExecutionMode()) === 'docker'; + } + + public getToolRunner(): ToolRunner { + return ToolRunnerFactory.create({ + mode: this.getToolExecutionMode(), + kbtBaseDirectory: this.tryGetKbtBaseDirectoryForLocalRunner(), + outputDirectoryPath: this.getBaseOutputDirectoryPath(), + docker: { + containerName: this.getWorkspaceConfiguration().get('docker.containerName'), + composeFile: this.getWorkspaceConfiguration().get('docker.composeFile'), + serviceName: this.getWorkspaceConfiguration().get('docker.serviceName'), + workspaceHostPath: this.getWorkspaceConfiguration().get('docker.workspaceHostPath'), + workspaceContainerPath: this.getWorkspaceConfiguration().get( + 'docker.workspaceContainerPath' + ), + kbtBaseDirectory: this.getWorkspaceConfiguration().get('docker.kbtBaseDirectory'), + outputDirectoryPath: this.getDockerOutputDirectoryPath() + } + }); + } + + public getMacOSShowContainerSetupPrompt(): boolean { + return this.getWorkspaceConfiguration().get('macos.showContainerSetupPrompt') ?? true; + } + + public async setMacOSShowContainerSetupPrompt(value: boolean): Promise { + await this.getWorkspaceConfiguration().update('macos.showContainerSetupPrompt', value, true); + } + public getContext(): vscode.ExtensionContext { return this.context; } @@ -269,6 +310,13 @@ export class Configuration { * @returns путь к директории со всеми SDK утилитами. */ public getKbtBaseDirectory(): string { + if (this.shouldUseDockerToolRunner()) { + return ( + this.getWorkspaceConfiguration().get('docker.kbtBaseDirectory') || + '/home/coder/xp-kbt' + ); + } + const kbtVersionsDirectory = this.getKbtVersionsDirectory(); if (!kbtVersionsDirectory) { return this.getKbtBaseDirectoryOld(); @@ -424,6 +472,10 @@ export class Configuration { } public getSiemjPath(): string { + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath('extra-tools/siemj/siemj'); + } + let appName = ''; switch (this.getOsType()) { case OsType.Windows: @@ -446,6 +498,10 @@ export class Configuration { } public getSiemkbTestsPath(): string { + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath('build-tools/siemkb_tests'); + } + let appName = ''; switch (this.getOsType()) { case OsType.Windows: @@ -468,6 +524,10 @@ export class Configuration { } public getRccCli(): string { + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath('xp-sdk/cli/rcc'); + } + let appName = ''; switch (this.getOsType()) { case OsType.Windows: @@ -490,6 +550,10 @@ export class Configuration { } public getMkTablesPath(): string { + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath('build-tools/mktables'); + } + let appName = ''; switch (this.getOsType()) { case OsType.Windows: @@ -512,6 +576,10 @@ export class Configuration { } public getFPTAFillerPath(): string { + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath('xp-sdk/fpta_filler'); + } + let appName = ''; switch (this.getOsType()) { case OsType.Windows: @@ -534,6 +602,10 @@ export class Configuration { } public getLocalizationBuilder(): string { + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath('build-tools/build_l10n_rules'); + } + let appName = ''; switch (this.getOsType()) { case OsType.Windows: @@ -556,6 +628,10 @@ export class Configuration { } public getSiemKBTests(): string { + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath('build-tools/siemkb_tests'); + } + let appName = ''; switch (this.getOsType()) { case OsType.Windows: @@ -578,6 +654,10 @@ export class Configuration { } public getNormalizerCli(): string { + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath('xp-sdk/cli/normalizer-cli'); + } + let appName = ''; switch (this.getOsType()) { case OsType.Windows: @@ -600,6 +680,10 @@ export class Configuration { } public getNormalizer(): string { + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath('build-tools/normalize'); + } + let appName = ''; switch (this.getOsType()) { case OsType.Windows: @@ -623,6 +707,10 @@ export class Configuration { public getKbPackFullPath(): string { const appName = 'kbpack.dll'; + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath(path.posix.join('extra-tools', 'kbpack', appName)); + } + const fullPath = path.join(this.getKbtBaseDirectory(), 'extra-tools', 'kbpack', appName); this.checkKbtSingleToolPath(fullPath); @@ -649,6 +737,10 @@ export class Configuration { } public getEcatestFullPath(): string { + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath('build-tools/ecatest'); + } + let appName = ''; switch (this.getOsType()) { case OsType.Windows: @@ -671,6 +763,10 @@ export class Configuration { } public getEvtTestsFullPath(): string { + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath('xp-sdk/cli/evt-tests'); + } + let appName = ''; switch (this.getOsType()) { case OsType.Windows: @@ -693,6 +789,10 @@ export class Configuration { } public getKBTLSPFullPath(): string { + if (this.shouldUseDockerToolRunner()) { + return null; + } + let appName = ''; switch (this.getOsType()) { case OsType.Windows: @@ -934,6 +1034,12 @@ export class Configuration { * @returns путь к папке с директориями контрактов. */ private getContractsDirectory(): string { + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath( + path.posix.join('knowledgebase', Configuration.CONTRACTS_DIR_NAME) + ); + } + return path.join(this.getKbtBaseDirectory(), 'knowledgebase', Configuration.CONTRACTS_DIR_NAME); } @@ -948,7 +1054,7 @@ export class Configuration { Configuration.TAXONOMY_DIR_NAME, taxonomyFileName ); - this.checkKbtSingleToolPath(fullPath); + this.checkReadablePath(fullPath); return fullPath; } @@ -959,7 +1065,7 @@ export class Configuration { */ public getTaxonomyDirPath(): string { const fullPath = path.join(this.getContractsDirectory(), Configuration.TAXONOMY_DIR_NAME); - this.checkKbtSingleToolPath(fullPath); + this.checkReadablePath(fullPath); return fullPath; } @@ -976,7 +1082,7 @@ export class Configuration { public getAppendixFullPath(): string { const appendixFileName = 'appendix.xp'; const fullPath = path.join(this.getContractsDirectory(), 'xp_appendix', appendixFileName); - this.checkKbtSingleToolPath(fullPath); + this.checkReadablePath(fullPath); return fullPath; } @@ -992,7 +1098,7 @@ export class Configuration { 'tabular_lists', tabularContractsFileName ); - this.checkKbtSingleToolPath(fullPath); + this.checkReadablePath(fullPath); return fullPath; } @@ -1042,6 +1148,10 @@ export class Configuration { * Automatically sets kbtVersionsDirectory if not already set */ public autoSetKbtVersionsDirectory(): void { + if (this.shouldUseDockerToolRunner()) { + return; + } + const configuration = this.getWorkspaceConfiguration(); const kbtVersionsDirectory = configuration.get('kbtVersionsDirectory'); @@ -1063,6 +1173,10 @@ export class Configuration { * Automatically sets kbtBaseDirectory if not already set */ public autoSetKbtBaseDirectory(): void { + if (this.shouldUseDockerToolRunner()) { + return; + } + const configuration = this.getWorkspaceConfiguration(); const kbtBaseDirectory = configuration.get('kbtBaseDirectory'); @@ -1080,6 +1194,11 @@ export class Configuration { * Automatically sets lspServerExecutablePath if not already set */ public autoSetLspServerExecutablePath(): void { + if (this.shouldUseDockerToolRunner()) { + Log.info('Skipping local KBT LSP auto-detection in Docker tool execution mode'); + return; + } + const configuration = this.getWorkspaceConfiguration(); const lspServerExecutablePath = configuration.get('lspServerExecutablePath'); @@ -1128,23 +1247,10 @@ export class Configuration { public getBaseOutputDirectoryPath(): string { const extensionSettings = this.getWorkspaceConfiguration(); - const outputDirectoryPath = extensionSettings.get('outputDirectoryPath'); - - if (!outputDirectoryPath || outputDirectoryPath === '') { - throw new FileSystemException( - this.getMessage('Error.OutputDirectoryPathIsNotSet'), - outputDirectoryPath - ); - } - - if (!fs.existsSync(outputDirectoryPath)) { - throw new FileSystemException( - this.getMessage('Error.IncorrectOutputDirectoryPath', outputDirectoryPath), - outputDirectoryPath - ); - } - - return outputDirectoryPath; + const configuredPath = extensionSettings.get('outputDirectoryPath'); + return this.normalizeLegacyHostOutputDirectoryPath( + configuredPath || this.getDefaultBaseOutputDirectoryPath() + ); } /** @@ -1164,12 +1270,142 @@ export class Configuration { return ruLocalizationFilePath; } + public async readTextFile(fullPath: string): Promise { + if (this.shouldUseDockerToolRunner() && this.isDockerContainerPath(fullPath)) { + return ( + await this.getToolRunner().runTool('cat', [fullPath], { + encoding: 'utf-8' + }) + ).output; + } + + return FileSystemHelper.readContentFile(fullPath); + } + + public mapPathForExecution(fullPath: string): string { + if (!this.shouldUseDockerToolRunner()) { + return fullPath; + } + + const toolRunner = this.getToolRunner(); + if (toolRunner instanceof DockerToolRunner) { + return toolRunner.getPathMapper().mapCommandArgument(fullPath); + } + + return fullPath; + } + + public getDockerOutputDirectoryPath(): string { + const configuredPath = this.getWorkspaceConfiguration().get('docker.outputDirectoryPath'); + return this.normalizeLegacyDockerOutputDirectoryPath( + configuredPath || this.getDefaultDockerOutputDirectoryPath() + ); + } + private checkKbtSingleToolPath(fullPath: string): void { + if (this.shouldUseDockerToolRunner() && this.isDockerContainerPath(fullPath)) { + return; + } + if (!fs.existsSync(fullPath)) { throw new XpException(this.getMessage('Error.UtilityPathIsIncorrect', fullPath)); } } + private checkReadablePath(fullPath: string): void { + if (this.shouldUseDockerToolRunner() && this.isDockerContainerPath(fullPath)) { + return; + } + + if (!fs.existsSync(fullPath)) { + throw new XpException(`Required XP file was not found: ${fullPath}`); + } + } + + private getDockerKbtToolPath(relativePath: string): string { + const dockerKbtBaseDirectory = + this.getWorkspaceConfiguration().get('docker.kbtBaseDirectory') || + '/home/coder/xp-kbt'; + return path.posix.join(dockerKbtBaseDirectory, relativePath.replace(/\\/g, '/')); + } + + private getDockerWorkspaceHostPath(): string | undefined { + const configuredPath = this.getWorkspaceConfiguration().get('docker.workspaceHostPath'); + if (configuredPath) { + return configuredPath; + } + + return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + } + + private getWorkspaceRootPath(): string | undefined { + return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + } + + private getDefaultBaseOutputDirectoryPath(): string { + const workspaceRootPath = this.getWorkspaceRootPath(); + if (workspaceRootPath) { + return path.join(workspaceRootPath, 'tmp', 'xp-output'); + } + + return path.join(os.tmpdir(), Configuration.getExtensionDirectoryName(), 'tmp', 'xp-output'); + } + + private getDefaultDockerOutputDirectoryPath(): string { + const workspaceContainerPath = + this.getWorkspaceConfiguration().get('docker.workspaceContainerPath') || + '/workspaces/knowledgebase'; + return path.posix.join(workspaceContainerPath, 'tmp', 'xp-output'); + } + + private normalizeLegacyHostOutputDirectoryPath(outputDirectoryPath: string): string { + const workspaceRootPath = this.getWorkspaceRootPath(); + if (!workspaceRootPath) { + return outputDirectoryPath; + } + + const legacyPath = path.join(workspaceRootPath, '.xp-output'); + if (path.resolve(outputDirectoryPath) === path.resolve(legacyPath)) { + return this.getDefaultBaseOutputDirectoryPath(); + } + + return outputDirectoryPath; + } + + private normalizeLegacyDockerOutputDirectoryPath(outputDirectoryPath: string): string { + const workspaceContainerPath = + this.getWorkspaceConfiguration().get('docker.workspaceContainerPath') || + '/workspaces/knowledgebase'; + const legacyPath = path.posix.join(workspaceContainerPath, '.xp-output'); + if (outputDirectoryPath.replace(/\\/g, '/') === legacyPath) { + return this.getDefaultDockerOutputDirectoryPath(); + } + + return outputDirectoryPath; + } + + public isDockerContainerPath(fullPath: string): boolean { + const workspaceHostPath = this.getDockerWorkspaceHostPath(); + if (workspaceHostPath && fullPath.startsWith(workspaceHostPath)) { + return false; + } + + return fullPath.startsWith('/'); + } + + private tryGetKbtBaseDirectoryForLocalRunner(): string | undefined { + if (this.shouldUseDockerToolRunner()) { + return undefined; + } + + try { + return this.getKbtBaseDirectory(); + } catch (error) { + Log.warn(`Failed to resolve local KBT directory: ${error.message}`); + return undefined; + } + } + public async checkUserSetting(): Promise { const extensionConfig = this.getWorkspaceConfiguration(); @@ -1192,12 +1428,7 @@ export class Configuration { private async checkAndCreateOutputDirectory( extensionConfig: vscode.WorkspaceConfiguration ): Promise { - const outputDirectoryPath = extensionConfig.get('outputDirectoryPath'); - - if (!outputDirectoryPath) { - DialogHelper.showError(this.getMessage('Error.OutputDirectoryPathIsNotSet')); - return; - } + const outputDirectoryPath = this.getBaseOutputDirectoryPath(); try { await fs.promises.mkdir(outputDirectoryPath, { recursive: true }); diff --git a/client/src/models/siemj/setKBTVersionCommand.ts b/client/src/models/siemj/setKBTVersionCommand.ts index b0f42fb0..36010db8 100644 --- a/client/src/models/siemj/setKBTVersionCommand.ts +++ b/client/src/models/siemj/setKBTVersionCommand.ts @@ -125,7 +125,7 @@ export class SetKBTVersionCommand { } } - config.setSIEMJVersion(); + await config.setSIEMJVersion(); item.text = kbtVersion; // Подсказка при наведении. diff --git a/client/src/models/siemj/siemjConfigBuilder.ts b/client/src/models/siemj/siemjConfigBuilder.ts index 36eb194b..7f38d7fb 100644 --- a/client/src/models/siemj/siemjConfigBuilder.ts +++ b/client/src/models/siemj/siemjConfigBuilder.ts @@ -600,10 +600,10 @@ out=${output} ); const test_pipeline_config = `{ "graphs": { - "normalization": ${JSON.stringify(formulas)}, - "correlation": ${JSON.stringify(corrules)}, - "aggregation": ${JSON.stringify(argrules)}, - "enrichment": ${JSON.stringify(enrules)} + "normalization": ${JSON.stringify(this.config.mapPathForExecution(formulas))}, + "correlation": ${JSON.stringify(this.config.mapPathForExecution(corrules))}, + "aggregation": ${JSON.stringify(this.config.mapPathForExecution(argrules))}, + "enrichment": ${JSON.stringify(this.config.mapPathForExecution(enrules))} }, "fields": { "exclude-non-taxonomy-fields": true, @@ -618,12 +618,12 @@ out=${output} "aggregation_name" ] }, - "root": ${JSON.stringify(testsRuleFullPath)}, - "rules-filters": ${JSON.stringify(this.config.getRulesDirFilters())}, - "fpta-defaults": ${JSON.stringify(table_list_defaults)}, - "taxonomy": ${JSON.stringify(this.config.getTaxonomyFullPath())}, - "schema": ${JSON.stringify(table_list_schema)}, - "appendix": ${JSON.stringify(this.config.getAppendixFullPath())} + "root": ${JSON.stringify(this.config.mapPathForExecution(testsRuleFullPath))}, + "rules-filters": ${JSON.stringify(this.config.mapPathForExecution(this.config.getRulesDirFilters()))}, + "fpta-defaults": ${JSON.stringify(this.config.mapPathForExecution(table_list_defaults))}, + "taxonomy": ${JSON.stringify(this.config.mapPathForExecution(this.config.getTaxonomyFullPath()))}, + "schema": ${JSON.stringify(this.config.mapPathForExecution(table_list_schema))}, + "appendix": ${JSON.stringify(this.config.mapPathForExecution(this.config.getAppendixFullPath()))} } `; diff --git a/client/src/models/siemj/siemjManager.ts b/client/src/models/siemj/siemjManager.ts index 214ebbac..5c46b276 100644 --- a/client/src/models/siemj/siemjManager.ts +++ b/client/src/models/siemj/siemjManager.ts @@ -4,7 +4,7 @@ import * as os from 'os'; import * as vscode from 'vscode'; import { FileSystemHelper } from '../../helpers/fileSystemHelper'; -import { ExecutionResult, ProcessHelper } from '../../helpers/processHelper'; +import { ExecutionResult } from '../../helpers/processHelper'; import { Configuration } from '../configuration'; import { XpException } from '../xpException'; import { RuleBaseItem } from '../content/ruleBaseItem'; @@ -25,7 +25,7 @@ export enum SIEMJVersion { } export function GetRawSIEMJVersion(config: Configuration): string { - return ProcessHelper.readProcessArgsOutputSync(config.getSiemjPath(), ['-v'], 'utf8').trim(); + return config.getCurrentSIEMJVersion(); } export function GetSIEMJVersion(config: Configuration): SIEMJVersion { @@ -221,14 +221,13 @@ export class SiemjManager { const siemjConfigPath = this.config.getTmpSiemjConfigPath(contentRootFolder); await SiemjConfigHelper.saveSiemjConfig(siemjConfContent, siemjConfigPath); - const siemjExePath = this.config.getSiemjPath(); - // Типовая команда выглядит так: // "C:\\PTSIEMSDK_GUI.4.0.0.738\\tools\\siemj.exe" -c C:\\PTSIEMSDK_GUI.4.0.0.738\\temp\\siemj.conf main"); - const result = await ProcessHelper.execute(siemjExePath, ['-c', siemjConfigPath, 'main'], { + const result = await this.config.getToolRunner().runSiemj(['-c', siemjConfigPath, 'main'], { encoding: this.config.getSiemjOutputEncoding(), outputChannel: this.config.getOutputChannel(), - cancellationToken: this.token + cancellationToken: this.token, + allowNonZeroExitCode: true }); if (result.isInterrupted) { @@ -260,14 +259,13 @@ export class SiemjManager { const siemjConfigPath = this.config.getTmpSiemjConfigPath(contentRootFolder); await SiemjConfigHelper.saveSiemjConfig(siemjConfContent, siemjConfigPath); - const siemjExePath = this.config.getSiemjPath(); - // Типовая команда выглядит так: // "C:\\PTSIEMSDK_GUI.4.0.0.738\\tools\\siemj.exe" -c C:\\PTSIEMSDK_GUI.4.0.0.738\\temp\\siemj.conf main"); - const result = await ProcessHelper.execute(siemjExePath, ['-c', siemjConfigPath, 'main'], { + const result = await this.config.getToolRunner().runSiemj(['-c', siemjConfigPath, 'main'], { encoding: this.config.getSiemjOutputEncoding(), outputChannel: this.config.getOutputChannel(), - cancellationToken: this.token + cancellationToken: this.token, + allowNonZeroExitCode: true }); return result; } diff --git a/client/src/models/tests/correlationUnitTestsRunner.ts b/client/src/models/tests/correlationUnitTestsRunner.ts index 6803377d..5272e35e 100644 --- a/client/src/models/tests/correlationUnitTestsRunner.ts +++ b/client/src/models/tests/correlationUnitTestsRunner.ts @@ -2,7 +2,6 @@ import * as vscode from 'vscode'; import * as fs from 'fs'; import * as path from 'path'; -import { ProcessHelper } from '../../helpers/processHelper'; import { TestHelper } from '../../helpers/testHelper'; import { DialogHelper } from '../../helpers/dialogHelper'; import { Configuration } from '../configuration'; @@ -65,7 +64,6 @@ export class CorrelationUnitTestsRunner implements UnitTestRunner { const ruleFilePath = test.getRuleFullPath(); // const ecaTestParam = `C:\\Tools\\0.22.774\\any\\any\\win\\ecatest.exe`; - const ecaTestParam = this.config.getEcatestFullPath(); const sdkDirPath = this.config.getSiemSdkDirectoryPath(); const taxonomyFilePath = this.config.getTaxonomyFullPath(); const testFilepath = test.getTestExpectationPath(); @@ -73,8 +71,8 @@ export class CorrelationUnitTestsRunner implements UnitTestRunner { const schemaFilePath = this.config.getSchemaFullPath(rootFolder); const ruleFiltersDirPath = this.config.getRulesDirFilters(); - const output = await ProcessHelper.execute( - ecaTestParam, + const output = await this.config.getToolRunner().runTool( + this.config.getEcatestFullPath(), [ '--sdk', sdkDirPath, diff --git a/client/src/models/tests/correlationUnitTestsRunnerViaEvtTests.ts b/client/src/models/tests/correlationUnitTestsRunnerViaEvtTests.ts index 679a8ce0..1638c18b 100644 --- a/client/src/models/tests/correlationUnitTestsRunnerViaEvtTests.ts +++ b/client/src/models/tests/correlationUnitTestsRunnerViaEvtTests.ts @@ -2,7 +2,6 @@ import * as vscode from 'vscode'; import * as fs from 'fs'; import * as path from 'path'; -import { ProcessHelper } from '../../helpers/processHelper'; import { TestHelper } from '../../helpers/testHelper'; import { DialogHelper } from '../../helpers/dialogHelper'; import { Configuration } from '../configuration'; @@ -60,15 +59,14 @@ export class CorrelationUnitTestsRunnerViaEvtTests implements UnitTestRunner { // Очищаем и показываем окно Output const ruleFilePath = test.getRuleFullPath(); - const evt_tests = this.config.getEvtTestsFullPath(); const taxonomyFilePath = this.config.getTaxonomyFullPath(); const testFilepath = test.getTestExpectationPath(); const fptDefaults = this.config.getCorrelationDefaultsFilePath(rootFolder); const schemaFilePath = this.config.getSchemaFullPath(rootFolder); const ruleFiltersDirPath = this.config.getRulesDirFilters(); - const output = await ProcessHelper.execute( - evt_tests, + const output = await this.config.getToolRunner().runTool( + this.config.getEvtTestsFullPath(), [ 'run', 'correlate', diff --git "a/client/src/test/helpers/toolsHelper.\321\201onfiguration.test.ts" "b/client/src/test/helpers/toolsHelper.\321\201onfiguration.test.ts" index 0237a94a..713d4d38 100644 --- "a/client/src/test/helpers/toolsHelper.\321\201onfiguration.test.ts" +++ "b/client/src/test/helpers/toolsHelper.\321\201onfiguration.test.ts" @@ -6,8 +6,9 @@ import { Configuration } from '../../models/configuration'; suite('Configuration', () => { test('Наличие ecatest', () => { - const fullPath = Configuration.get().getEcatestFullPath(); - assert.ok(fs.existsSync(fullPath)); + const config = Configuration.get(); + const fullPath = config.getEcatestFullPath(); + assert.ok(config.shouldUseDockerToolRunner() ? fullPath.includes('/ecatest') : fs.existsSync(fullPath)); }); test('Наличие таксономии', async () => { @@ -16,8 +17,9 @@ suite('Configuration', () => { }); test('Наличие siemj', async () => { - const fullPath = Configuration.get().getSiemjPath(); - assert.ok(fs.existsSync(fullPath)); + const config = Configuration.get(); + const fullPath = config.getSiemjPath(); + assert.ok(config.shouldUseDockerToolRunner() ? fullPath.includes('/siemj') : fs.existsSync(fullPath)); }); test('Создание временной директории', async () => { diff --git a/client/src/test/tools/pathMapper.test.ts b/client/src/test/tools/pathMapper.test.ts new file mode 100644 index 00000000..a3ca241a --- /dev/null +++ b/client/src/test/tools/pathMapper.test.ts @@ -0,0 +1,59 @@ +import * as assert from 'assert'; + +import { PathMapper } from '../../tools/pathMapper'; + +suite('PathMapper', () => { + test('maps host paths to container paths', () => { + const mapper = new PathMapper({ + workspaceHostPath: '/Users/alice/Work/knowledgebase', + workspaceContainerPath: '/workspaces/knowledgebase' + }); + + assert.strictEqual( + mapper.hostToContainer('/Users/alice/Work/knowledgebase/packages/demo/rule.co'), + '/workspaces/knowledgebase/packages/demo/rule.co' + ); + }); + + test('maps container paths to host paths', () => { + const mapper = new PathMapper({ + workspaceHostPath: '/Users/alice/Work/knowledgebase', + workspaceContainerPath: '/workspaces/knowledgebase' + }); + + assert.strictEqual( + mapper.containerToHost('/workspaces/knowledgebase/packages/demo/rule.co'), + '/Users/alice/Work/knowledgebase/packages/demo/rule.co' + ); + }); + + test('maps --name=/path arguments', () => { + const mapper = new PathMapper({ + workspaceHostPath: '/Users/alice/Work/knowledgebase', + workspaceContainerPath: '/workspaces/knowledgebase' + }); + + assert.strictEqual( + mapper.mapCommandArgument('--schema=/Users/alice/Work/knowledgebase/tmp/xp-output/schema.json'), + '--schema=/workspaces/knowledgebase/tmp/xp-output/schema.json' + ); + }); + + test('does not map non-path command arguments', () => { + const mapper = new PathMapper({ + workspaceHostPath: '/Users/alice/Work/knowledgebase', + workspaceContainerPath: '/workspaces/knowledgebase' + }); + + assert.strictEqual(mapper.mapCommandArgument('main'), 'main'); + assert.strictEqual(mapper.mapCommandArgument('--lang'), '--lang'); + }); + + test('reports missing mapping', () => { + const mapper = new PathMapper({}); + const validation = mapper.validateMapping(); + + assert.strictEqual(validation.isValid, false); + assert.ok(validation.message.includes('workspaceHostPath')); + }); +}); diff --git a/client/src/test/tools/toolRunner.test.ts b/client/src/test/tools/toolRunner.test.ts new file mode 100644 index 00000000..2197de6a --- /dev/null +++ b/client/src/test/tools/toolRunner.test.ts @@ -0,0 +1,44 @@ +import * as assert from 'assert'; + +import { DockerToolRunner, ToolRunnerFactory } from '../../tools/toolRunner'; + +suite('ToolRunnerFactory', () => { + test('uses explicit local mode', () => { + assert.strictEqual(ToolRunnerFactory.resolveMode('local'), 'local'); + }); + + test('uses explicit docker mode', () => { + assert.strictEqual(ToolRunnerFactory.resolveMode('docker'), 'docker'); + }); +}); + +suite('DockerToolRunner', () => { + test('builds docker exec arguments without shell command string', () => { + const runner = new DockerToolRunner({ + containerName: 'xp-kbt', + workspaceHostPath: '/Users/alice/Work/knowledgebase', + workspaceContainerPath: '/workspaces/knowledgebase', + kbtBaseDirectory: '/opt/xp-kbt' + }); + + assert.deepStrictEqual( + runner.buildDockerExecArgs( + 'xp-kbt', + '/opt/xp-kbt/extra-tools/siemj/siemj', + ['-c', '/workspaces/knowledgebase/tmp/xp-output/siemj.conf', 'main'], + { cwd: '/Users/alice/Work/knowledgebase' } + ), + [ + 'exec', + '-i', + '-w', + '/workspaces/knowledgebase', + 'xp-kbt', + '/opt/xp-kbt/extra-tools/siemj/siemj', + '-c', + '/workspaces/knowledgebase/tmp/xp-output/siemj.conf', + 'main' + ] + ); + }); +}); diff --git a/client/src/tools/kbtInstaller.ts b/client/src/tools/kbtInstaller.ts new file mode 100644 index 00000000..b784733a --- /dev/null +++ b/client/src/tools/kbtInstaller.ts @@ -0,0 +1,205 @@ +import * as https from 'https'; +import * as vscode from 'vscode'; + +import { ProcessHelper } from '../helpers/processHelper'; +import { Log } from '../extension'; + +interface GitHubReleaseAsset { + name: string; + browser_download_url: string; +} + +interface GitHubRelease { + tag_name: string; + assets: GitHubReleaseAsset[]; +} + +export class KbtInstaller { + private static readonly LatestReleaseUrl = + 'https://api.github.com/repos/vxcontrol/xp-kbt/releases/latest'; + private static readonly ReleasesUrl = + 'https://api.github.com/repos/vxcontrol/xp-kbt/releases?per_page=30'; + + public static async installLatestIntoContainer( + containerName: string, + targetDirectory = '/home/coder/xp-kbt', + outputChannel?: vscode.OutputChannel + ): Promise { + const release = await this.getLatestRelease(); + return this.installReleaseIntoContainer(containerName, release, targetDirectory, outputChannel); + } + + public static async listReleases(): Promise { + return this.getJson(this.ReleasesUrl); + } + + public static async installReleaseIntoContainer( + containerName: string, + release: GitHubRelease, + targetDirectory = '/home/coder/xp-kbt', + outputChannel?: vscode.OutputChannel + ): Promise { + const asset = this.pickLinuxAsset(release); + + if (!asset) { + throw new Error( + `Could not find a Linux xp-kbt asset in release ${release.tag_name}. Open https://github.com/vxcontrol/xp-kbt/releases/latest and set xpConfig.docker.kbtBaseDirectory manually.` + ); + } + + this.showOutputLinkedNotification( + `Installing xp-kbt ${release.tag_name} into container '${containerName}'. Details are available in the extension output.`, + outputChannel + ); + Log.info(`XP container setup: installing xp-kbt ${release.tag_name}`); + Log.info(`XP container setup: target ${containerName}:${targetDirectory}`); + Log.info(`XP container setup: asset ${asset.name}`); + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + cancellable: false, + title: `Downloading xp-kbt ${release.tag_name}` + }, + async (progress) => { + Log.progress(progress, `Installing into ${containerName}:${targetDirectory}`); + + const archivePath = `/tmp/${asset.name}`; + const quotedTargetDirectory = this.shellQuote(targetDirectory); + const quotedArchivePath = this.shellQuote(archivePath); + const quotedDownloadUrl = this.shellQuote(asset.browser_download_url); + const command = [ + 'set -e', + this.echoCommand('XP :: Ensuring container prerequisites'), + this.getInstallPrerequisitesCommand(), + this.echoCommand(`XP :: Creating KBT target directory ${targetDirectory}`), + `mkdir -p ${quotedTargetDirectory}`, + this.echoCommand(`XP :: Downloading ${asset.name}`), + `curl -fL ${quotedDownloadUrl} -o ${quotedArchivePath}`, + this.echoCommand('XP :: Extracting KBT archive'), + this.getExtractCommand(archivePath, targetDirectory), + this.echoCommand('XP :: Removing temporary archive'), + `rm -f ${quotedArchivePath}`, + this.echoCommand('XP :: Verifying KBT tools'), + `test -x ${this.shellQuote( + `${targetDirectory}/extra-tools/siemj/siemj` + )} -o -x ${this.shellQuote(`${targetDirectory}/build-tools/normalize`)}` + ].join(' && '); + + const result = await ProcessHelper.execute( + 'docker', + ['exec', containerName, 'sh', '-lc', command], + { + encoding: 'utf-8', + outputChannel + } + ); + + if (result.exitCode !== 0) { + throw new Error( + `Failed to install xp-kbt in container '${containerName}'. ${result.output}` + ); + } + + Log.progress(progress, `xp-kbt ${release.tag_name} installed`); + } + ); + + return targetDirectory; + } + + private static showOutputLinkedNotification( + message: string, + outputChannel?: vscode.OutputChannel + ): void { + if (!outputChannel) { + void vscode.window.showInformationMessage(message); + return; + } + + void vscode.window.showInformationMessage(message, 'Show Output').then((answer) => { + if (answer === 'Show Output') { + outputChannel.show(); + } + }); + } + + private static getInstallPrerequisitesCommand(): string { + return [ + 'if ! command -v curl >/dev/null 2>&1 || ! command -v tar >/dev/null 2>&1 || ! command -v unzip >/dev/null 2>&1; then', + 'if command -v apt-get >/dev/null 2>&1; then', + 'apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl tar unzip;', + 'elif command -v apk >/dev/null 2>&1; then', + 'apk add --no-cache ca-certificates curl tar unzip;', + 'else', + 'echo "Container must have curl, tar and unzip, or use a Debian/Alpine-based image."; exit 127;', + 'fi;', + 'fi' + ].join(' '); + } + + private static getExtractCommand(archivePath: string, targetDirectory: string): string { + const quotedArchivePath = this.shellQuote(archivePath); + const quotedTargetDirectory = this.shellQuote(targetDirectory); + + if (archivePath.endsWith('.zip')) { + return `unzip -q -o ${quotedArchivePath} -d ${quotedTargetDirectory}`; + } + + return `tar -xzf ${quotedArchivePath} -C ${quotedTargetDirectory} --strip-components=1`; + } + + private static pickLinuxAsset(release: GitHubRelease): GitHubReleaseAsset | undefined { + return release.assets.find((asset) => { + const name = asset.name.toLowerCase(); + return ( + /linux|gnu|ubuntu|debian/.test(name) && + (name.endsWith('.tar.gz') || name.endsWith('.tgz') || name.endsWith('.zip')) + ); + }); + } + + private static async getLatestRelease(): Promise { + return this.getJson(this.LatestReleaseUrl); + } + + private static async getJson(url: string): Promise { + return new Promise((resolve, reject) => { + https + .get( + url, + { + headers: { + 'User-Agent': 'SecurityExpertsCommunity.xp' + } + }, + (response) => { + if (response.statusCode < 200 || response.statusCode >= 300) { + reject(new Error(`GitHub release request failed with ${response.statusCode}`)); + return; + } + + let data = ''; + response.setEncoding('utf8'); + response.on('data', (chunk) => (data += chunk)); + response.on('end', () => { + try { + resolve(JSON.parse(data) as T); + } catch (error) { + reject(error); + } + }); + } + ) + .on('error', reject); + }); + } + + private static shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; + } + + private static echoCommand(message: string): string { + return `echo ${this.shellQuote(message)}`; + } +} diff --git a/client/src/tools/macosContainerSetup.ts b/client/src/tools/macosContainerSetup.ts new file mode 100644 index 00000000..8e45e90e --- /dev/null +++ b/client/src/tools/macosContainerSetup.ts @@ -0,0 +1,560 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; + +import { ProcessHelper } from '../helpers/processHelper'; +import { Configuration } from '../models/configuration'; +import { KbtInstaller } from './kbtInstaller'; +import { Log } from '../extension'; + +interface ContainerSelection { + name: string; + created: boolean; + workspaceContainerPath?: string; +} + +export class MacOSContainerSetup { + private static readonly DEFAULT_TOOLS_IMAGE = 'mcr.microsoft.com/dotnet/sdk:8.0'; + private static readonly DEFAULT_KNOWLEDGEBASE_CONTAINER_PATH = '/workspaces/knowledgebase'; + private static readonly KBT_PATH_CANDIDATES = [ + '/home/coder/xp-kbt', + '/opt/xp-kbt', + '/usr/local/xp-kbt', + '/workspaces/xp-kbt' + ]; + + public static async maybePrompt(config: Configuration): Promise { + if (!config.isLocalMacOS()) { + return; + } + + const setupIssue = this.getMacOSDockerSetupIssue(config); + if (!config.getMacOSShowContainerSetupPrompt() && !setupIssue) { + return; + } + + const buttons = setupIssue + ? ['Configure', 'Show Output', 'Open README', 'Keep current'] + : ['Configure', 'Open README', 'Do not show again']; + const message = setupIssue + ? `XP Docker backend needs configuration: ${setupIssue}. Configure it now?` + : 'XP tools are not available natively on macOS. Configure a container backend?'; + + const answer = await vscode.window.showInformationMessage( + message, + ...buttons + ); + + switch (answer) { + case 'Configure': + try { + await this.configure(config); + } catch (error) { + vscode.window.showErrorMessage(error.message); + } + break; + case 'Open README': + await vscode.env.openExternal( + vscode.Uri.parse( + 'https://github.com/Security-Experts-Community/vscode-xp#macos-hybrid-mode' + ) + ); + break; + case 'Show Output': + config.getOutputChannel().show(); + break; + case 'Do not show again': + await config.setMacOSShowContainerSetupPrompt(false); + break; + } + } + + private static getMacOSDockerSetupIssue(config: Configuration): string | undefined { + if (!config.shouldUseDockerToolRunner()) { + return undefined; + } + + const xpConfig = config.getWorkspaceConfiguration(); + const containerName = xpConfig.get('docker.containerName'); + const workspaceHostPath = xpConfig.get('docker.workspaceHostPath'); + const workspaceContainerPath = xpConfig.get('docker.workspaceContainerPath'); + const kbtBaseDirectory = xpConfig.get('docker.kbtBaseDirectory'); + + if (!containerName) { + return 'container is not selected'; + } + + if (!workspaceHostPath) { + return 'local knowledgebase path is not configured'; + } + + if (!fs.existsSync(workspaceHostPath)) { + return `local knowledgebase path does not exist: ${workspaceHostPath}`; + } + + if (!workspaceContainerPath) { + return 'container knowledgebase mount path is not configured'; + } + + if (!kbtBaseDirectory) { + return 'container KBT path is not configured'; + } + + if (!this.looksLikeKnowledgebaseRoot(workspaceHostPath)) { + return 'selected host path does not look like a knowledgebase root'; + } + + return undefined; + } + + public static async configure(config: Configuration): Promise { + const knowledgebaseFolder = await vscode.window.showOpenDialog({ + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: false, + title: 'Select local knowledgebase folder' + }); + + if (!knowledgebaseFolder?.[0]) { + return; + } + + const knowledgebaseHostPath = await this.resolveKnowledgebaseHostPath( + knowledgebaseFolder[0].fsPath + ); + await this.ensureDockerAvailable(); + const container = await this.pickContainer(knowledgebaseHostPath, config.getOutputChannel()); + if (!container) { + return; + } + + const containerName = container.name; + const workspaceContainerPath = + container.workspaceContainerPath ?? + (await this.detectContainerMountPath(containerName, knowledgebaseHostPath)) ?? + (await this.askContainerPath( + 'Knowledgebase container path', + this.DEFAULT_KNOWLEDGEBASE_CONTAINER_PATH + )); + + if (!workspaceContainerPath) { + return; + } + + let kbtBaseDirectory = await this.detectKbtBaseDirectory(containerName); + + if (!kbtBaseDirectory) { + kbtBaseDirectory = await this.resolveMissingKbt(containerName, config.getOutputChannel()); + } + + if (!kbtBaseDirectory) { + return; + } + + const xpConfig = config.getWorkspaceConfiguration(); + const hostOutputDirectory = path.join(knowledgebaseHostPath, 'tmp', 'xp-output'); + await fs.promises.mkdir(hostOutputDirectory, { recursive: true }); + + await xpConfig.update('toolExecutionMode', 'docker', vscode.ConfigurationTarget.Workspace); + await xpConfig.update( + 'outputDirectoryPath', + hostOutputDirectory, + vscode.ConfigurationTarget.Workspace + ); + await xpConfig.update( + 'docker.workspaceHostPath', + knowledgebaseHostPath, + vscode.ConfigurationTarget.Workspace + ); + await xpConfig.update( + 'docker.workspaceContainerPath', + workspaceContainerPath, + vscode.ConfigurationTarget.Workspace + ); + await xpConfig.update( + 'docker.kbtBaseDirectory', + kbtBaseDirectory, + vscode.ConfigurationTarget.Workspace + ); + await xpConfig.update( + 'docker.outputDirectoryPath', + path.posix.join(workspaceContainerPath, 'tmp', 'xp-output'), + vscode.ConfigurationTarget.Workspace + ); + + await xpConfig.update( + 'docker.containerName', + containerName, + vscode.ConfigurationTarget.Workspace + ); + + await config.setMacOSShowContainerSetupPrompt(false); + + vscode.window.showInformationMessage( + `XP container backend configured for '${containerName}'.` + ); + } + + private static async ensureDockerAvailable(): Promise { + const result = await ProcessHelper.execute('docker', ['version'], { + encoding: 'utf-8', + checkCommandBeforeExecution: true + }); + + if (result.exitCode !== 0) { + throw new Error('Docker is not available. Install Docker Desktop and start it first.'); + } + } + + private static async pickContainer( + knowledgebaseHostPath: string, + outputChannel: vscode.OutputChannel + ): Promise { + const result = await ProcessHelper.execute( + 'docker', + ['ps', '--format', '{{.Names}}'], + { encoding: 'utf-8' } + ); + + const containerNames = result.output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line); + + const createContainerLabel = 'Create new XP tools container'; + const items = [ + { + label: createContainerLabel, + description: 'Bind mount the selected knowledgebase and install xp-kbt' + }, + ...containerNames.map((name) => ({ + label: name, + description: 'Use existing running container' + })) + ]; + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: + containerNames.length === 0 + ? 'No running XP backend containers found' + : 'Select or create XP backend container' + }); + + if (!selected) { + return undefined; + } + + if (selected.label === createContainerLabel) { + return this.createToolsContainer(knowledgebaseHostPath, outputChannel); + } + + return { + name: selected.label, + created: false + }; + } + + private static async createToolsContainer( + knowledgebaseHostPath: string, + outputChannel: vscode.OutputChannel + ): Promise { + const defaultName = this.makeDefaultContainerName(knowledgebaseHostPath); + const containerName = await vscode.window.showInputBox({ + prompt: 'New Docker container name', + value: defaultName, + ignoreFocusOut: true, + validateInput: (input) => + /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(input) + ? undefined + : 'Use letters, numbers, dots, underscores and hyphens' + }); + + if (!containerName) { + return undefined; + } + + const workspaceContainerPath = + (await this.askContainerPath( + 'Knowledgebase container path', + this.DEFAULT_KNOWLEDGEBASE_CONTAINER_PATH + )) ?? this.DEFAULT_KNOWLEDGEBASE_CONTAINER_PATH; + + this.showOutputLinkedNotification( + `Creating XP tools container '${containerName}'. Details are available in the extension output.`, + outputChannel + ); + Log.info(`XP container setup: creating container '${containerName}'`); + Log.info(`XP container setup: image ${this.DEFAULT_TOOLS_IMAGE}`); + Log.info(`XP container setup: platform linux/amd64`); + Log.info(`XP container setup: mounting ${knowledgebaseHostPath} -> ${workspaceContainerPath}`); + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + cancellable: false, + title: `Creating XP tools container '${containerName}'` + }, + async (progress) => { + Log.progress(progress, `Pulling/starting Docker image ${this.DEFAULT_TOOLS_IMAGE}`); + const result = await ProcessHelper.execute( + 'docker', + [ + 'run', + '-d', + '--platform', + 'linux/amd64', + '--name', + containerName, + '-v', + `${knowledgebaseHostPath}:${workspaceContainerPath}`, + '-w', + workspaceContainerPath, + this.DEFAULT_TOOLS_IMAGE, + 'sleep', + 'infinity' + ], + { + encoding: 'utf-8', + outputChannel + } + ); + + if (result.exitCode !== 0) { + throw new Error( + `Failed to create XP tools container. ${result.output || 'Check Docker Desktop and image pull permissions.'}` + ); + } + + Log.progress(progress, `XP tools container '${containerName}' is running`); + } + ); + + return { + name: containerName, + created: true, + workspaceContainerPath + }; + } + + private static makeDefaultContainerName(knowledgebaseHostPath: string): string { + const folderName = path.basename(path.resolve(knowledgebaseHostPath)) || 'knowledgebase'; + const safeFolderName = folderName.replace(/[^a-zA-Z0-9_.-]/g, '-').replace(/^-+/, ''); + const suffix = new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 12); + return `xp-tools-${safeFolderName || 'kb'}-${suffix}`.slice(0, 64); + } + + private static showOutputLinkedNotification( + message: string, + outputChannel: vscode.OutputChannel + ): void { + void vscode.window.showInformationMessage(message, 'Show Output').then((answer) => { + if (answer === 'Show Output') { + outputChannel.show(); + } + }); + } + + private static async resolveKnowledgebaseHostPath(selectedPath: string): Promise { + const detectedPath = this.findKnowledgebaseRoot(selectedPath); + + if (!detectedPath || detectedPath === selectedPath) { + return selectedPath; + } + + const answer = await vscode.window.showInformationMessage( + `Knowledgebase root was detected at '${detectedPath}'. Use it for Docker bind mount?`, + 'Use detected root', + 'Use selected folder' + ); + + return answer === 'Use selected folder' ? selectedPath : detectedPath; + } + + private static findKnowledgebaseRoot(startPath: string): string | undefined { + let currentPath = path.resolve(startPath); + + while (true) { + if (this.looksLikeKnowledgebaseRoot(currentPath)) { + return currentPath; + } + + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) { + return undefined; + } + + currentPath = parentPath; + } + } + + private static looksLikeKnowledgebaseRoot(candidatePath: string): boolean { + const packagesPath = path.join(candidatePath, 'packages'); + const contentTypesPath = path.join(candidatePath, 'content_types'); + const rulesPath = path.join(candidatePath, 'rules'); + const metadataPath = path.join(candidatePath, 'metainfo.yaml'); + + return ( + fs.existsSync(packagesPath) || + fs.existsSync(contentTypesPath) || + fs.existsSync(rulesPath) || + fs.existsSync(metadataPath) + ); + } + + private static async detectContainerMountPath( + containerName: string, + hostPath: string + ): Promise { + const result = await ProcessHelper.execute( + 'docker', + ['inspect', '--format', '{{json .Mounts}}', containerName], + { encoding: 'utf-8' } + ); + + if (result.exitCode !== 0) { + return undefined; + } + + try { + const mounts = JSON.parse(result.output.trim()) as Array<{ + Source?: string; + Destination?: string; + }>; + + const normalizedHostPath = path.resolve(hostPath); + const matchingMount = mounts + .filter((mount) => mount.Source && mount.Destination) + .sort((a, b) => b.Source.length - a.Source.length) + .find((mount) => { + const source = path.resolve(mount.Source); + return normalizedHostPath === source || normalizedHostPath.startsWith(source + path.sep); + }); + + if (!matchingMount) { + return undefined; + } + + const relativePath = path.relative(path.resolve(matchingMount.Source), normalizedHostPath); + return relativePath + ? path.posix.join(matchingMount.Destination, relativePath.split(path.sep).join('/')) + : matchingMount.Destination; + } catch { + return undefined; + } + } + + private static async detectKbtBaseDirectory( + containerName: string + ): Promise { + for (const candidate of this.KBT_PATH_CANDIDATES) { + const result = await ProcessHelper.execute( + 'docker', + [ + 'exec', + containerName, + 'sh', + '-lc', + `test -x '${candidate}/extra-tools/siemj/siemj' -o -x '${candidate}/build-tools/normalize'` + ], + { encoding: 'utf-8' } + ); + + if (result.exitCode === 0) { + return candidate; + } + } + + return undefined; + } + + private static async resolveMissingKbt( + containerName: string, + outputChannel: vscode.OutputChannel + ): Promise { + const action = await vscode.window.showQuickPick( + [ + { + label: 'Download latest xp-kbt', + description: 'Recommended' + }, + { + label: 'Choose xp-kbt version', + description: 'Select one of the recent GitHub releases' + }, + { + label: 'Enter path manually', + description: 'Use already installed KBT in the container' + } + ], + { + placeHolder: `xp-kbt was not found in container '${containerName}'` + } + ); + + switch (action?.label) { + case 'Download latest xp-kbt': + return KbtInstaller.installLatestIntoContainer( + containerName, + '/home/coder/xp-kbt', + outputChannel + ); + case 'Choose xp-kbt version': + return this.chooseAndInstallKbtVersion(containerName, outputChannel); + case 'Enter path manually': + return this.askContainerPath('KBT base directory in container', '/home/coder/xp-kbt'); + default: + return undefined; + } + } + + private static async chooseAndInstallKbtVersion( + containerName: string, + outputChannel: vscode.OutputChannel + ): Promise { + const releases = await KbtInstaller.listReleases(); + const selectedRelease = await vscode.window.showQuickPick( + releases.map((release) => ({ + label: release.tag_name, + description: release.assets.map((asset) => asset.name).join(', '), + release + })), + { + placeHolder: 'Select xp-kbt release' + } + ); + + if (!selectedRelease) { + return undefined; + } + + const targetDirectory = await this.askContainerPath( + 'KBT install directory in container', + `/home/coder/xp-kbt-${selectedRelease.release.tag_name}` + ); + + if (!targetDirectory) { + return undefined; + } + + return KbtInstaller.installReleaseIntoContainer( + containerName, + selectedRelease.release, + targetDirectory, + outputChannel + ); + } + + private static async askContainerPath( + prompt: string, + value: string + ): Promise { + const result = await vscode.window.showInputBox({ + prompt, + value, + ignoreFocusOut: true, + validateInput: (input) => (input.startsWith('/') ? undefined : 'Use an absolute Linux path') + }); + + return result?.replace(/\/+$/, ''); + } +} diff --git a/client/src/tools/pathMapper.ts b/client/src/tools/pathMapper.ts new file mode 100644 index 00000000..929a9316 --- /dev/null +++ b/client/src/tools/pathMapper.ts @@ -0,0 +1,157 @@ +import * as path from 'path'; + +export interface PathMapping { + hostPath: string; + containerPath: string; +} + +export interface PathMapperOptions { + workspaceHostPath?: string; + workspaceContainerPath?: string; + extraMappings?: PathMapping[]; +} + +export interface PathMappingValidationResult { + isValid: boolean; + message?: string; +} + +export class PathMapper { + private readonly mappings: PathMapping[]; + + constructor(options: PathMapperOptions) { + this.mappings = []; + + if (options.workspaceHostPath && options.workspaceContainerPath) { + this.mappings.push({ + hostPath: PathMapper.normalizeHostPath(options.workspaceHostPath), + containerPath: PathMapper.normalizeContainerPath(options.workspaceContainerPath) + }); + } + + for (const mapping of options.extraMappings ?? []) { + if (!mapping.hostPath || !mapping.containerPath) { + continue; + } + + this.mappings.push({ + hostPath: PathMapper.normalizeHostPath(mapping.hostPath), + containerPath: PathMapper.normalizeContainerPath(mapping.containerPath) + }); + } + + this.mappings.sort((a, b) => b.hostPath.length - a.hostPath.length); + } + + public hostToContainer(inputPath: string): string { + return this.mapPath(inputPath, 'hostToContainer'); + } + + public containerToHost(inputPath: string): string { + return this.mapPath(inputPath, 'containerToHost'); + } + + public resolveWorkspaceMount(): PathMapping | undefined { + return this.mappings[0]; + } + + public validateMapping(): PathMappingValidationResult { + if (this.mappings.length === 0) { + return { + isValid: false, + message: + 'Path mapping is not configured. Set xpConfig.docker.workspaceHostPath and xpConfig.docker.workspaceContainerPath.' + }; + } + + return { isValid: true }; + } + + public mapCommandArgument(argument: string): string { + if (!argument) { + return argument; + } + + const prefixedPath = /^([^=]+)=(.+)$/.exec(argument); + if (prefixedPath) { + if (!this.shouldMapInput(prefixedPath[2])) { + return argument; + } + + return `${prefixedPath[1]}=${this.hostToContainer(prefixedPath[2])}`; + } + + if (!this.shouldMapInput(argument)) { + return argument; + } + + return this.hostToContainer(argument); + } + + public mapText(text: string): string { + let result = text; + for (const mapping of this.mappings) { + result = result.split(mapping.hostPath).join(mapping.containerPath); + } + + return result; + } + + private mapPath(inputPath: string, direction: 'hostToContainer' | 'containerToHost'): string { + if (!this.shouldMapInput(inputPath)) { + return inputPath; + } + + const normalizedInput = + direction === 'hostToContainer' + ? PathMapper.normalizeHostPath(inputPath) + : PathMapper.normalizeContainerPath(inputPath); + + for (const mapping of this.mappings) { + const from = direction === 'hostToContainer' ? mapping.hostPath : mapping.containerPath; + const to = direction === 'hostToContainer' ? mapping.containerPath : mapping.hostPath; + + if (normalizedInput === from) { + return to; + } + + if (normalizedInput.startsWith(from + path.sep) || normalizedInput.startsWith(from + '/')) { + const relativePath = normalizedInput.substring(from.length).replace(/^[\\/]/, ''); + return PathMapper.joinContainerAware(to, relativePath); + } + } + + return inputPath; + } + + private static joinContainerAware(basePath: string, relativePath: string): string { + if (!relativePath) { + return basePath; + } + + if (basePath.includes('\\')) { + return path.win32.join(basePath, relativePath); + } + + return path.posix.join(basePath, relativePath.split(path.sep).join('/')); + } + + private static normalizeHostPath(inputPath: string): string { + return path.resolve(inputPath); + } + + private static normalizeContainerPath(inputPath: string): string { + return inputPath.replace(/\\/g, '/').replace(/\/+$/, ''); + } + + private shouldMapInput(inputPath: string): boolean { + if (path.isAbsolute(inputPath) || inputPath.startsWith('/')) { + return true; + } + + return this.mappings.some( + (mapping) => + inputPath.startsWith(mapping.hostPath) || inputPath.startsWith(mapping.containerPath) + ); + } +} diff --git a/client/src/tools/sdkUtilitiesWrappers.ts b/client/src/tools/sdkUtilitiesWrappers.ts index 25b06ee2..d97f968d 100644 --- a/client/src/tools/sdkUtilitiesWrappers.ts +++ b/client/src/tools/sdkUtilitiesWrappers.ts @@ -6,7 +6,6 @@ import { Configuration } from '../models/configuration'; import { Normalization } from '../models/content/normalization'; import { NormalizationUnitTest } from '../models/tests/normalizationUnitTest'; // import { RuleFileDiagnostics } from '../views/integrationTests/ruleFileDiagnostics'; -import { ProcessHelper } from '../helpers/processHelper'; import { DialogHelper } from '../helpers/dialogHelper'; import { ExceptionHelper } from '../helpers/exceptionHelper'; import { FileSystemHelper } from '../helpers/fileSystemHelper'; @@ -53,7 +52,6 @@ export class SDKUtilitiesWrappers { */ // Формируем параметры запуска утилиты - const normalizeExePath = this.config.getNormalizer(); const sdkPath = this.config.getSiemSdkDirectoryPath(); const rootPath = rule.getContentRootPath(this.config); const rootFolder = path.basename(rootPath); @@ -87,7 +85,7 @@ export class SDKUtilitiesWrappers { normalizerParams.push('-e'); - const executeResult = await ProcessHelper.execute(normalizeExePath, normalizerParams, { + const executeResult = await this.config.getToolRunner().runNormalizer(normalizerParams, { outputChannel: this.config.getOutputChannel() }); diff --git a/client/src/tools/toolRunner.ts b/client/src/tools/toolRunner.ts new file mode 100644 index 00000000..1611396a --- /dev/null +++ b/client/src/tools/toolRunner.ts @@ -0,0 +1,349 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +import { + ExecutionProcessOptions, + ExecutionResult, + ProcessHelper +} from '../helpers/processHelper'; +import { XpException } from '../models/xpException'; +import { PathMapper, PathMapping } from './pathMapper'; + +export type ToolExecutionMode = 'auto' | 'local' | 'docker'; + +export interface ToolRunOptions extends ExecutionProcessOptions { + cwd?: string; + allowNonZeroExitCode?: boolean; +} + +export interface ToolRunner { + runTool(command: string, args: string[], options?: ToolRunOptions): Promise; + runKbt(args: string[], options?: ToolRunOptions): Promise; + runSiemj(args: string[], options?: ToolRunOptions): Promise; + runNormalizer(args: string[], options?: ToolRunOptions): Promise; +} + +export interface ToolRunnerFactoryOptions { + mode: ToolExecutionMode; + kbtBaseDirectory?: string; + outputDirectoryPath?: string; + docker: DockerToolRunnerOptions; +} + +export interface DockerToolRunnerOptions { + containerName?: string; + composeFile?: string; + serviceName?: string; + workspaceHostPath?: string; + workspaceContainerPath?: string; + kbtBaseDirectory?: string; + outputDirectoryPath?: string; + extraMappings?: PathMapping[]; +} + +export class LocalToolRunner implements ToolRunner { + constructor(private readonly kbtBaseDirectory?: string) {} + + public runTool( + command: string, + args: string[], + options: ToolRunOptions = {} + ): Promise { + return ProcessHelper.execute(command, args, options); + } + + public runKbt(args: string[], options?: ToolRunOptions): Promise { + return this.runTool(this.resolveKbtTool('kbtools'), args, options); + } + + public runSiemj(args: string[], options?: ToolRunOptions): Promise { + return this.runTool(this.resolveKbtTool(path.join('extra-tools', 'siemj', this.exe('siemj'))), args, options); + } + + public runNormalizer(args: string[], options?: ToolRunOptions): Promise { + return this.runTool(this.resolveKbtTool(path.join('build-tools', this.exe('normalize'))), args, options); + } + + private resolveKbtTool(relativePath: string): string { + if (!this.kbtBaseDirectory) { + throw new XpException('KBT base directory is not configured.'); + } + + return path.join(this.kbtBaseDirectory, relativePath); + } + + private exe(command: string): string { + return process.platform === 'win32' ? `${command}.exe` : command; + } +} + +export class DockerToolRunner implements ToolRunner { + private readonly pathMapper: PathMapper; + + constructor(private readonly options: DockerToolRunnerOptions) { + this.pathMapper = new PathMapper({ + workspaceHostPath: options.workspaceHostPath, + workspaceContainerPath: options.workspaceContainerPath, + extraMappings: options.extraMappings + }); + } + + public async runTool( + command: string, + args: string[], + options: ToolRunOptions = {} + ): Promise { + await this.ensureDockerAvailable(); + const containerName = await this.getContainerName(); + await this.ensureContainerRunning(containerName); + + const mappingValidation = this.pathMapper.validateMapping(); + if (!mappingValidation.isValid) { + throw new XpException(`Path mapping failed. ${mappingValidation.message}`); + } + + const mappedCommand = this.mapCommand(command); + const mappedArgs = await this.mapArgs(args); + const dockerArgs = this.buildDockerExecArgs(containerName, mappedCommand, mappedArgs, options); + + const result = await ProcessHelper.execute('docker', dockerArgs, { + ...options, + encoding: options.encoding ?? 'utf-8' + }); + + if (result.exitCode === 127 || result.output.includes('executable file not found')) { + throw new XpException( + `Tool not found in container '${containerName}'. Check xpConfig.docker.kbtBaseDirectory and make sure the container has XP tools installed. Missing command: '${mappedCommand}'.` + ); + } + + if (result.exitCode !== 0 && !result.isInterrupted && !options.allowNonZeroExitCode) { + if ( + process.platform === 'darwin' && + (result.exitCode === 132 || result.exitCode === 133) + ) { + throw new XpException( + `Command exited with code ${result.exitCode}. The XP tool binary is likely incompatible with the container CPU architecture. Re-run the macOS setup wizard and create a new XP tools container; it will use linux/amd64 for xp-kbt compatibility.` + ); + } + + throw new XpException( + `Command exited with non-zero code ${result.exitCode}. Check XP output for details.` + ); + } + + return result; + } + + public runKbt(args: string[], options?: ToolRunOptions): Promise { + return this.runTool(this.getContainerKbtTool('kbtools'), args, options); + } + + public runSiemj(args: string[], options?: ToolRunOptions): Promise { + return this.runTool(this.getContainerKbtTool('extra-tools/siemj/siemj'), args, options); + } + + public runNormalizer(args: string[], options?: ToolRunOptions): Promise { + return this.runTool(this.getContainerKbtTool('build-tools/normalize'), args, options); + } + + public getPathMapper(): PathMapper { + return this.pathMapper; + } + + public buildDockerExecArgs( + containerName: string, + command: string, + args: string[], + options: ToolRunOptions = {} + ): string[] { + const dockerArgs = ['exec', '-i']; + + if (options.cwd) { + dockerArgs.push('-w', this.pathMapper.hostToContainer(options.cwd)); + } + + dockerArgs.push(containerName, command, ...args); + return dockerArgs; + } + + private async ensureDockerAvailable(): Promise { + const result = await ProcessHelper.execute('docker', ['version'], { + encoding: 'utf-8', + checkCommandBeforeExecution: true + }); + + if (result.exitCode !== 0) { + throw new XpException( + 'Docker is not installed or is not available in PATH. Install Docker Desktop and start it, then retry the XP command.' + ); + } + } + + private async ensureContainerRunning(containerName: string): Promise { + const result = await ProcessHelper.execute( + 'docker', + ['inspect', '-f', '{{.State.Running}}', containerName], + { encoding: 'utf-8' } + ); + + if (result.exitCode !== 0 || !result.output.trim().includes('true')) { + throw new XpException( + `Container '${containerName}' is not running. Start a container with XP tools, then retry.` + ); + } + } + + private async getContainerName(): Promise { + if (this.options.containerName) { + return this.options.containerName; + } + + const discovered = await this.discoverContainer(); + if (discovered) { + return discovered; + } + + throw new XpException( + 'Container not running. Set xpConfig.docker.containerName or start/select a container with XP tools.' + ); + } + + private async discoverContainer(): Promise { + const containers = await ProcessHelper.execute( + 'docker', + ['ps', '--format', '{{.Names}}'], + { encoding: 'utf-8' } + ); + + if (containers.exitCode !== 0) { + return undefined; + } + + const names = containers.output + .split(/\r?\n/) + .map((name) => name.trim()) + .filter((name) => name); + + const xpNamed = names.find((name) => /vscode.*xp|xp.*workspace|knowledgebase|kbt/i.test(name)); + if (xpNamed) { + return xpNamed; + } + + const mounted = await this.discoverContainerByInspect(names); + if (mounted) { + return mounted; + } + + if (names.length === 1) { + return names[0]; + } + + return undefined; + } + + private async discoverContainerByInspect(names: string[]): Promise { + for (const name of names) { + const result = await ProcessHelper.execute( + 'docker', + [ + 'inspect', + '--format', + '{{json .Config.Labels}} {{json .Mounts}}', + name + ], + { encoding: 'utf-8' } + ); + + if (result.exitCode !== 0) { + continue; + } + + if (this.containerMatchesConfiguration(result.output)) { + return name; + } + } + + return undefined; + } + + private containerMatchesConfiguration(inspectOutput: string): boolean { + const workspaceHostPath = this.options.workspaceHostPath; + const workspaceContainerPath = this.options.workspaceContainerPath; + + if (!workspaceHostPath && !workspaceContainerPath) { + return false; + } + + return [workspaceHostPath, workspaceContainerPath] + .filter((value) => value) + .some((value) => inspectOutput.includes(value)); + } + + private mapCommand(command: string): string { + return this.pathMapper.hostToContainer(command); + } + + private async mapArgs(args: string[]): Promise { + const mappedArgs: string[] = []; + + for (const arg of args) { + if (arg.endsWith('.conf') && fs.existsSync(arg)) { + mappedArgs.push(await this.createMappedConfig(arg)); + } else { + mappedArgs.push(this.pathMapper.mapCommandArgument(arg)); + } + } + + return mappedArgs; + } + + private async createMappedConfig(configPath: string): Promise { + const content = await fs.promises.readFile(configPath, 'utf-8'); + const mappedContent = this.pathMapper.mapText(content); + const mappedConfigPath = `${configPath}.container`; + await fs.promises.writeFile(mappedConfigPath, mappedContent, 'utf-8'); + return this.pathMapper.hostToContainer(mappedConfigPath); + } + + private getContainerKbtTool(relativePath: string): string { + const baseDirectory = this.options.kbtBaseDirectory || '/home/coder/xp-kbt'; + return path.posix.join(baseDirectory, relativePath); + } +} + +export class ToolRunnerFactory { + public static create(options: ToolRunnerFactoryOptions): ToolRunner { + const mode = this.resolveMode(options.mode); + + if (mode === 'docker') { + const extraMappings: PathMapping[] = [...(options.docker.extraMappings ?? [])]; + if (options.outputDirectoryPath && options.docker.outputDirectoryPath) { + extraMappings.push({ + hostPath: options.outputDirectoryPath, + containerPath: options.docker.outputDirectoryPath + }); + } + + return new DockerToolRunner({ + ...options.docker, + extraMappings + }); + } + + return new LocalToolRunner(options.kbtBaseDirectory); + } + + public static resolveMode(mode: ToolExecutionMode): 'local' | 'docker' { + if (mode === 'docker') { + return 'docker'; + } + + if (mode === 'local') { + return 'local'; + } + + return process.platform === 'darwin' && !vscode.env.remoteName ? 'docker' : 'local'; + } +} diff --git a/client/src/views/contentTree/actions/packSIEMAllPackagesAction.ts b/client/src/views/contentTree/actions/packSIEMAllPackagesAction.ts index 96f6d1c0..401e8801 100644 --- a/client/src/views/contentTree/actions/packSIEMAllPackagesAction.ts +++ b/client/src/views/contentTree/actions/packSIEMAllPackagesAction.ts @@ -7,7 +7,6 @@ import * as vscode from 'vscode'; import { DialogHelper } from '../../../helpers/dialogHelper'; import { FileSystemHelper } from '../../../helpers/fileSystemHelper'; import { KbHelper } from '../../../helpers/kbHelper'; -import { ProcessHelper } from '../../../helpers/processHelper'; import { Configuration } from '../../../models/configuration'; import { ExceptionHelper } from '../../../helpers/exceptionHelper'; import { ContentTreeBaseItem } from '../../../models/content/contentTreeBaseItem'; @@ -52,7 +51,7 @@ export class PackSIEMAllPackagesAction { // Проверка наличия утилиты сборки kb-файлов. const knowledgeBasePackagerCli = this.config.getKbPackFullPath(); - if (!fs.existsSync(knowledgeBasePackagerCli)) { + if (!this.config.shouldUseDockerToolRunner() && !fs.existsSync(knowledgeBasePackagerCli)) { DialogHelper.showError( 'Путь к утилите сборки пакетов экспертизы задан не верно. Измените его [в настройках расширения](command:workbench.action.openSettings?["xpConfig.kbtBaseDirectory"]) и повторите попытку' ); @@ -104,11 +103,25 @@ export class PackSIEMAllPackagesAction { emitter.fire( `\n\nXP:: Промежуточный статус: Запущена команда архивации файлов, это может занимать длительное время!\n\n` ); - const output = await ProcessHelper.executeWithArgsWithRealtimeEmmiterOutput( - 'dotnet', - [knowledgeBasePackagerCli, 'pack', '-s', tmpSubDirectoryPath, '-o', unpackKbFilePath], - emitter + emitter.fire( + `\n\nXP :: Run command: dotnet ${[ + knowledgeBasePackagerCli, + 'pack', + '-s', + tmpSubDirectoryPath, + '-o', + unpackKbFilePath + ].join(' ')}\n` ); + const result = await this.config + .getToolRunner() + .runTool( + 'dotnet', + [knowledgeBasePackagerCli, 'pack', '-s', tmpSubDirectoryPath, '-o', unpackKbFilePath], + { encoding: 'utf-8' } + ); + const output = result.output; + emitter.fire(output); emitter.fire(`\n\nXP:: Промежуточный статус: Команда выполнена!\n\n`); if (output.includes('Knowledge base package creation completed successfully')) { DialogHelper.showInfo(`Пакет '${packageName}' успешно собран.`); diff --git a/client/src/views/contentTree/commands/buildLocalizationsCommand.ts b/client/src/views/contentTree/commands/buildLocalizationsCommand.ts index 4a3e3e71..a607e3e6 100644 --- a/client/src/views/contentTree/commands/buildLocalizationsCommand.ts +++ b/client/src/views/contentTree/commands/buildLocalizationsCommand.ts @@ -2,7 +2,6 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; -import { ProcessHelper } from '../../../helpers/processHelper'; import { SiemjConfigHelper } from '../../../models/siemj/siemjConfigHelper'; import { SiemJOutputParser } from '../../../models/siemj/siemJOutputParser'; import { Configuration } from '../../../models/configuration'; @@ -68,9 +67,7 @@ export class BuildLocalizationsCommand extends ViewCommand { // Типовая команда выглядит так: // "C:\\PTSIEMSDK_GUI.4.0.0.738\\tools\\siemj.exe" -c C:\\PTSIEMSDK_GUI.4.0.0.738\\temp\\siemj.conf main - const siemjExePath = this.config.getSiemjPath(); - const siemJOutput = await ProcessHelper.execute( - siemjExePath, + const siemJOutput = await this.config.getToolRunner().runSiemj( ['-c', siemjConfigPath, 'main'], { encoding: this.config.getSiemjOutputEncoding(), diff --git a/client/src/views/contentTree/commands/buildWldCommand.ts b/client/src/views/contentTree/commands/buildWldCommand.ts index 41e64d35..6306602d 100644 --- a/client/src/views/contentTree/commands/buildWldCommand.ts +++ b/client/src/views/contentTree/commands/buildWldCommand.ts @@ -2,7 +2,6 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; -import { ProcessHelper } from '../../../helpers/processHelper'; import { SiemjConfigHelper } from '../../../models/siemj/siemjConfigHelper'; import { SiemJOutputParser } from '../../../models/siemj/siemJOutputParser'; import { Configuration } from '../../../models/configuration'; @@ -66,13 +65,12 @@ export class BuildWldCommand extends ViewCommand { // Типовая команда выглядит так: // .\ptsiem-sdk\release-26.0\26.0.11839\vc150\x86_64\win\cli/rcc.exe --lang w --taxonomy=.\taxonomy\release-26.0\26.0.215\any\any\any/taxonomy.json --schema=.\gui_output/schema.json -o c:\tmp\whitelisting_graph.json .\knowledgebase\packages - const rccCli = this.config.getRccCli(); const taxonomyPath = this.config.getTaxonomyFullPath(); const schemaPath = this.config.getSchemaFullPath(rootFolder); const whitelistingPath = this.config.getWhitelistingPath(rootFolder); const rootPath = siemjConfContentEntity['rootPath']; - const executionResult = await ProcessHelper.execute( - rccCli, + const executionResult = await this.config.getToolRunner().runTool( + this.config.getRccCli(), [ // --lang w '--lang', diff --git a/client/src/views/contentTree/commands/packKbCommand.ts b/client/src/views/contentTree/commands/packKbCommand.ts index f77d4f9f..c9da0f6a 100644 --- a/client/src/views/contentTree/commands/packKbCommand.ts +++ b/client/src/views/contentTree/commands/packKbCommand.ts @@ -7,7 +7,6 @@ import * as vscode from 'vscode'; import { DialogHelper } from '../../../helpers/dialogHelper'; import { FileSystemHelper } from '../../../helpers/fileSystemHelper'; import { KbHelper } from '../../../helpers/kbHelper'; -import { ProcessHelper } from '../../../helpers/processHelper'; import { Configuration } from '../../../models/configuration'; import { ExceptionHelper } from '../../../helpers/exceptionHelper'; import { ContentTreeBaseItem } from '../../../models/content/contentTreeBaseItem'; @@ -40,7 +39,7 @@ export class PackKbCommand extends ViewCommand { // Проверка наличия утилиты сборки kb-файлов. const knowledgeBasePackagerCli = this.config.getKbPackFullPath(); - if (!fs.existsSync(knowledgeBasePackagerCli)) { + if (!this.config.shouldUseDockerToolRunner() && !fs.existsSync(knowledgeBasePackagerCli)) { DialogHelper.showError( `Путь к утилите сборки kb-файла задан не верно. Проверьте корректность [пути к KBT](command:workbench.action.openSettings?["xpConfig.kbtBaseDirectory"]) или загрузите актуальную версию [отсюда](https://github.com/vxcontrol/xp-kbt/releases), распакуйте и задайте путь к директории [в настройках](command:workbench.action.openSettings?["xpConfig.kbtBaseDirectory"])` ); @@ -130,7 +129,7 @@ export class PackKbCommand extends ViewCommand { // Типовая команда выглядит так: // dotnet kbpack.dll pack -s "c:\tmp\pack" -o "c:\tmp\pack\Esc.kb" Log.info('TmpPackageDirectoryPath: ', tmpPackageDirectoryPath); - const output = await ProcessHelper.execute( + const output = await this.config.getToolRunner().runTool( 'dotnet', [ knowledgeBasePackagerCli, diff --git a/client/src/views/contentTree/commands/unpackKbCommand.ts b/client/src/views/contentTree/commands/unpackKbCommand.ts index 244a85da..c131acd2 100644 --- a/client/src/views/contentTree/commands/unpackKbCommand.ts +++ b/client/src/views/contentTree/commands/unpackKbCommand.ts @@ -5,7 +5,7 @@ import * as os from 'os'; import * as vscode from 'vscode'; import { DialogHelper } from '../../../helpers/dialogHelper'; -import { ExecutionResult, ProcessHelper } from '../../../helpers/processHelper'; +import { ExecutionResult } from '../../../helpers/processHelper'; import { VsCodeApiHelper } from '../../../helpers/vsCodeApiHelper'; import { Configuration } from '../../../models/configuration'; import { ContentTreeProvider } from '../contentTreeProvider'; @@ -31,7 +31,7 @@ export class UnpackKbCommand extends ViewCommand { public async execute(): Promise { // Проверка наличия утилиты сборки kb-файлов. const knowledgeBasePackagerCli = this.config.getKbPackFullPath(); - if (!fs.existsSync(knowledgeBasePackagerCli)) { + if (!this.config.shouldUseDockerToolRunner() && !fs.existsSync(knowledgeBasePackagerCli)) { DialogHelper.showError( 'Путь к утилите сборке kb-файла задан не верно. Измените его в настройках и повторите попытку' ); @@ -95,7 +95,7 @@ export class UnpackKbCommand extends ViewCommand { const cmd = 'dotnet'; let executeResult: ExecutionResult; try { - executeResult = await ProcessHelper.execute(cmd, params, { + executeResult = await this.config.getToolRunner().runTool(cmd, params, { encoding: 'utf-8', outputChannel: this.config.getOutputChannel(), checkCommandBeforeExecution: true, diff --git a/client/src/views/corrGraphRunner.ts b/client/src/views/corrGraphRunner.ts index 21e61ec4..d999f97b 100644 --- a/client/src/views/corrGraphRunner.ts +++ b/client/src/views/corrGraphRunner.ts @@ -3,7 +3,6 @@ import * as fs from 'fs'; import * as path from 'path'; import { FileSystemHelper } from '../helpers/fileSystemHelper'; -import { ProcessHelper } from '../helpers/processHelper'; import { Configuration } from '../models/configuration'; import { XpException } from '../models/xpException'; import { Log } from '../extension'; @@ -76,7 +75,6 @@ export class CorrGraphRunner { // Сохраняем конфигурационный файл для siemj. const siemjConfigPath = path.join(randTmpDir, Configuration.SIEMJ_CONFIG_FILENAME); - const siemjExePath = this.options.config.getSiemjPath(); await FileSystemHelper.writeContentFile(siemjConfigPath, siemjConfContent); // Без удаления базы возникали странные ошибки filler-а, но это не точно. @@ -93,7 +91,7 @@ export class CorrGraphRunner { // Типовая команда выглядит так: // "C:\\PTSIEMSDK_GUI.4.0.0.738\\tools\\siemj.exe" -c C:\\PTSIEMSDK_GUI.4.0.0.738\\temp\\siemj.conf main"); - await ProcessHelper.execute(siemjExePath, ['-c', siemjConfigPath, 'main'], { + await this.options.config.getToolRunner().runSiemj(['-c', siemjConfigPath, 'main'], { encoding: this.options.config.getSiemjOutputEncoding(), outputChannel: this.options.config.getOutputChannel(), cancellationToken: this.options.cancellationToken diff --git a/package.json b/package.json index f66fc525..76dc594f 100644 --- a/package.json +++ b/package.json @@ -443,6 +443,11 @@ "title": "%xp.commonCommands.showOutputChannel%", "category": "%xp.commonCommandCategory%" }, + { + "command": "xp.commonCommands.configureMacOSContainerBackend", + "title": "%xp.commonCommands.configureMacOSContainerBackend%", + "category": "%xp.commonCommandCategory%" + }, { "command": "xp.retroCorrelationShow", "title": "%xp.views.knowledgebaseTree.showRetroCorrelationCommand%", @@ -655,6 +660,65 @@ "scope": "window", "type": "string", "markdownDescription": "%xp.xpConfig.outputDirectoryPathDescription%" + }, + "xpConfig.toolExecutionMode": { + "scope": "window", + "type": "string", + "enum": [ + "auto", + "local", + "docker" + ], + "default": "auto", + "markdownDescription": "%xp.xpConfig.toolExecutionModeDescription%" + }, + "xpConfig.docker.containerName": { + "scope": "window", + "type": "string", + "default": "", + "markdownDescription": "%xp.xpConfig.docker.containerNameDescription%" + }, + "xpConfig.docker.composeFile": { + "scope": "window", + "type": "string", + "default": "", + "markdownDescription": "%xp.xpConfig.docker.composeFileDescription%" + }, + "xpConfig.docker.serviceName": { + "scope": "window", + "type": "string", + "default": "", + "markdownDescription": "%xp.xpConfig.docker.serviceNameDescription%" + }, + "xpConfig.docker.workspaceHostPath": { + "scope": "window", + "type": "string", + "default": "", + "markdownDescription": "%xp.xpConfig.docker.workspaceHostPathDescription%" + }, + "xpConfig.docker.workspaceContainerPath": { + "scope": "window", + "type": "string", + "default": "/workspaces/knowledgebase", + "markdownDescription": "%xp.xpConfig.docker.workspaceContainerPathDescription%" + }, + "xpConfig.docker.kbtBaseDirectory": { + "scope": "window", + "type": "string", + "default": "/home/coder/xp-kbt", + "markdownDescription": "%xp.xpConfig.docker.kbtBaseDirectoryDescription%" + }, + "xpConfig.docker.outputDirectoryPath": { + "scope": "window", + "type": "string", + "default": "/workspaces/knowledgebase/tmp/xp-output", + "markdownDescription": "%xp.xpConfig.docker.outputDirectoryPathDescription%" + }, + "xpConfig.macos.showContainerSetupPrompt": { + "scope": "window", + "type": "boolean", + "default": true, + "markdownDescription": "%xp.xpConfig.macos.showContainerSetupPromptDescription%" }, "xpConfig.lspServerExecutablePath": { "type": "string", diff --git a/package.nls.json b/package.nls.json index ed059254..03f7a051 100644 --- a/package.nls.json +++ b/package.nls.json @@ -3,6 +3,8 @@ "xp.commonCommands.showOutputChannel": "Show Output Data", + "xp.commonCommands.configureMacOSContainerBackend": "Configure macOS Container Backend", + "xp.commonCommands.showExtensionSettings" : "Extension Settings", "xp.views.knowledgebaseTree.title": "Object Tree", @@ -44,6 +46,15 @@ "xp.xpConfig.kbtBaseDirectoryDescription": "Path of the folder with the [Knowledge Base Toolkit](https://github.com/vxcontrol/xp-kbt/releases)", "xp.xpConfig.outputDirectoryPathDescription": "Path of the folder where temporary objects are saved. The folder is emptied automatically every time you start the extension.", + "xp.xpConfig.toolExecutionModeDescription": "How XP external tools are executed. Auto keeps local execution on Linux/Windows and uses Docker for local macOS VS Code.", + "xp.xpConfig.docker.containerNameDescription": "Name of the running Docker container with xp-kbt and related XP tools.", + "xp.xpConfig.docker.composeFileDescription": "Optional docker-compose.yml path for a container backend preset.", + "xp.xpConfig.docker.serviceNameDescription": "Optional Docker Compose service name for the XP backend.", + "xp.xpConfig.docker.workspaceHostPathDescription": "Host path to the mounted knowledgebase.", + "xp.xpConfig.docker.workspaceContainerPathDescription": "Container path where the knowledgebase is mounted.", + "xp.xpConfig.docker.kbtBaseDirectoryDescription": "Container path to the xp-kbt base directory.", + "xp.xpConfig.docker.outputDirectoryPathDescription": "Container path corresponding to xpConfig.outputDirectoryPath.", + "xp.xpConfig.macos.showContainerSetupPromptDescription": "Show the macOS container backend setup prompt on extension activation.", "xp.xpConfig.correlatorTimeoutDescription": "Max correlation time in seconds. If the time is exceeded, the correlation will be canceled.", "xp.xpConfig.lspServerExecutablePathDescription": "Path of the LSP server executable", @@ -56,4 +67,3 @@ "xp.xpConfig.logLevelDescription": "Log level", "xp.xpConfig.defaultAuthor": "Default author name" } - diff --git a/package.nls.ru.json b/package.nls.ru.json index 181f6ac9..1239f1b3 100644 --- a/package.nls.ru.json +++ b/package.nls.ru.json @@ -3,6 +3,8 @@ "xp.commonCommands.showOutputChannel": "Показать выходные данные", + "xp.commonCommands.configureMacOSContainerBackend": "Настроить контейнерный backend для macOS", + "xp.commonCommands.showExtensionSettings" : "Параметры расширения", "xp.views.knowledgebaseTree.title": "Дерево объектов", @@ -56,4 +58,3 @@ "xp.xpConfig.logLevelDescription": "Уровень журналирования", "xp.xpConfig.defaultAuthor": "Имя автора по умолчанию" } - From bb2071175a8f90e01d1f1d467ca8310103984e06 Mon Sep 17 00:00:00 2001 From: Danil Lobachev Date: Tue, 7 Jul 2026 13:55:20 +0600 Subject: [PATCH 03/12] Add hybrid Docker backend and editor fixes for macOS --- .vscodeignore | 11 +- client/src/extension.ts | 254 ++++++++++++++---- client/src/helpers/exceptionHelper.ts | 50 +++- client/src/helpers/regExpHelper.ts | 30 +++ client/src/models/configuration.ts | 69 ++++- client/src/models/content/ruleBaseItem.ts | 16 +- .../src/models/siemj/setKBTVersionCommand.ts | 8 +- client/src/models/siemj/siemjManager.ts | 2 +- .../providers/xpDocumentFormattingProvider.ts | 79 ++++++ client/src/tools/lspDockerProxy.ts | 195 ++++++++++++++ client/src/tools/macosContainerSetup.ts | 73 ++++- .../command/saveAllCommand.ts | 39 ++- .../command/showActualEventCommand.ts | 36 +-- .../command/showTestResultsDiffCommand.ts | 37 +-- .../integrationTestEditorViewProvider.ts | 104 ++++--- .../localization/checkLocalizationsCommand.ts | 13 +- client/templates/IntegrationTestEditor.html | 43 ++- client/templates/LocalizationEditor.html | 12 +- client/templates/js/meta.js | 8 +- client/templates/js/unittest/code.js | 8 +- esbuild.js | 17 +- package.json | 13 +- package.nls.json | 1 + package.nls.ru.json | 1 + 24 files changed, 953 insertions(+), 166 deletions(-) create mode 100644 client/src/providers/xpDocumentFormattingProvider.ts create mode 100644 client/src/tools/lspDockerProxy.ts diff --git a/.vscodeignore b/.vscodeignore index d3a10449..0bd70402 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -21,7 +21,9 @@ !content_templates/** !mitre_attack/data/parsed/** +!client/out/ !client/out/**/*.js +!server/out/ !server/out/**/*.js !out/**/*.js @@ -30,10 +32,11 @@ !client/templates/shared/** !client/templates/styles/** +!client/webview/out/ !client/webview/out/** !client/webview/node_modules/monaco-editor/min/vs/** -!client/webview/node_modules/@vscode/codicons/dist +!client/webview/node_modules/@vscode/codicons/dist/** -!client/node_modules/prettier/package.json -!client/node_modules/prettier/*.{js,cjs,mjs} -!client/node_modules/prettier/plugins/yaml.mjs +!node_modules/ +!node_modules/prettier/ +!node_modules/prettier/** diff --git a/client/src/extension.ts b/client/src/extension.ts index 299c81ef..adffe28c 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -2,11 +2,11 @@ import * as path from 'path'; import * as vscode from 'vscode'; import * as fs from 'fs'; import * as os from 'os'; -import { format } from 'prettier'; import { workspace, ExtensionContext } from 'vscode'; import { + InitializeParams, LanguageClient, LanguageClientOptions, ServerOptions, @@ -40,15 +40,44 @@ import { CommonCommands } from './models/command/commonCommands'; import { ToolsManager } from './models/content/toolsManager'; import { SetKBTVersionCommand } from './models/siemj/setKBTVersionCommand'; import { MacOSContainerSetup } from './tools/macosContainerSetup'; +import { XpDocumentFormattingProvider } from './providers/xpDocumentFormattingProvider'; export let Log: Logger; let client: LanguageClient; let siemCustomPackingTaskProvider: vscode.Disposable | undefined; +interface LspStartupResult { + client: LanguageClient; + usesKbtFormatter: boolean; +} + +class XpLanguageClient extends LanguageClient { + protected fillInitializeParams(params: InitializeParams): void { + super.fillInitializeParams(params); + + const capabilities = (params.capabilities ??= {} as InitializeParams['capabilities']); + const workspaceCapabilities = ((capabilities as any).workspace ??= {}); + if (workspaceCapabilities.workspaceFolders === undefined) { + workspaceCapabilities.workspaceFolders = true; + } + + const textDocumentCapabilities = ((capabilities as any).textDocument ??= {}); + const semanticTokens = (textDocumentCapabilities.semanticTokens ??= {}); + semanticTokens.dynamicRegistration ??= false; + semanticTokens.requests ??= { range: false, full: { delta: false } }; + semanticTokens.tokenTypes ??= []; + semanticTokens.tokenModifiers ??= []; + semanticTokens.formats ??= ['relative']; + semanticTokens.multilineTokenSupport ??= true; + semanticTokens.overlappingTokenSupport ??= true; + } +} + export async function activate(context: ExtensionContext): Promise { try { // Инициализация реестр глобальных параметров. const config = await Configuration.init(context); + Log = Logger.init(config); let extensionVersion; try { @@ -59,7 +88,6 @@ export async function activate(context: ExtensionContext): Promise { Log.warn(`Failed to get the extension version`, error); } - Log = Logger.init(config); Log.info( `Extension activation ${extensionVersion ?? ''} has started '${Configuration.getExtensionDisplayName()}'` ); @@ -100,7 +128,8 @@ export async function activate(context: ExtensionContext): Promise { await MacOSContainerSetup.maybePrompt(config); // Конфигурирование LSP. - await configureLSPClient(client, context, config); + const lspStartupResult = await configureLSPClient(context, config); + client = lspStartupResult.client; const rootPath = vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0 @@ -115,12 +144,7 @@ export async function activate(context: ExtensionContext): Promise { quotingType: "'" }, undefined, - (text: string) => - format(text, { - parser: 'yaml', - singleQuote: true, - tabWidth: 2 - }) + async (text: string) => text ); ContentTreeProvider.init(config, rootPath); @@ -130,8 +154,10 @@ export async function activate(context: ExtensionContext): Promise { MetainfoViewProvider.init(config); RunningCorrelationGraphProvider.init(config); TableListsEditorViewProvider.init(config); - const kbtVersionsDirectory = config.getKbtVersionsDirectory(); - if (kbtVersionsDirectory) { + const kbtVersionsDirectory = config.shouldUseDockerToolRunner() + ? undefined + : config.getKbtVersionsDirectory(); + if (kbtVersionsDirectory && !config.shouldUseDockerToolRunner()) { await SetKBTVersionCommand.init(config); } else { try { @@ -161,6 +187,12 @@ export async function activate(context: ExtensionContext): Promise { // Автодополнение функций. await XpCompletionItemProvider.init(config); await XpEnumValuesCompletionItemProvider.init(config); + if (lspStartupResult.usesKbtFormatter) { + Log.info('Skipping fallback XP formatter registration because KBT LSP formatting is active.'); + } else { + XpDocumentFormattingProvider.init(config); + Log.info('Registered fallback XP formatter because KBT LSP formatting is not active.'); + } context.subscriptions.push( vscode.languages.registerRenameProvider( @@ -223,29 +255,40 @@ export async function deactivate(): Promise | undefined { } async function configureLSPClient( - client: LanguageClient, context: vscode.ExtensionContext, config: Configuration -) { - try { - const lspServerExecutablePath = config.getKBTLSPFullPath(); +): Promise { + const documentSelector = [ + { scheme: 'file', language: 'xp' }, + { scheme: 'file', language: 'en' }, + { scheme: 'file', language: 'agr' }, + { scheme: 'file', language: 'co' }, + { scheme: 'file', language: 'flt' } + ]; - if (lspServerExecutablePath) { - Log.info(config.getMessage('LSPServer.ServerExecutableFoundAt', lspServerExecutablePath)); - const command = lspServerExecutablePath; - const args: string[] = []; + const initializationOptions = await buildKbtLspInitializationOptions(config); - const documentSelector = [ - { scheme: 'file', language: 'xp' }, - { scheme: 'file', language: 'en' }, - { scheme: 'file', language: 'agr' }, - { scheme: 'file', language: 'co' }, - { scheme: 'file', language: 'flt' } - ]; + try { + if (config.getLSPMode() === 'legacy') { + Log.info('Skipping external KBT LSP startup because xpConfig.lspMode=legacy.'); + } else if (config.shouldUseDockerToolRunner()) { + const proxyModule = context.asAbsolutePath(path.join('client', 'out', 'lspDockerProxy.js')); + const extensionConfig = config.getWorkspaceConfiguration(); + const proxyConfig = { + containerName: extensionConfig.get('docker.containerName'), + workspaceHostPath: extensionConfig.get('docker.workspaceHostPath'), + workspaceContainerPath: extensionConfig.get('docker.workspaceContainerPath'), + kbtBaseDirectory: + extensionConfig.get('docker.kbtBaseDirectory') || '/home/coder/xp-kbt', + outputHostPath: extensionConfig.get('outputDirectoryPath'), + outputContainerPath: config.getDockerOutputDirectoryPath() + }; + const encodedConfig = Buffer.from(JSON.stringify(proxyConfig), 'utf-8').toString('base64'); const serverOptions: ServerOptions = { - command, - args, + command: process.execPath, + args: [proxyModule, encodedConfig], + transport: TransportKind.stdio, options: { cwd: __dirname } @@ -256,30 +299,74 @@ async function configureLSPClient( synchronize: { configurationSection: [config.getExtensionSettingsPrefix(), 'xplang_ls'] }, - initializationOptions: { - locale: vscode.env.language - } + initializationOptions }; - client = new LanguageClient(command, 'XP Language Server', serverOptions, clientOptions); - - return client - .start() - .then(() => { - const serverProcess = client['_serverProcess']; - if (serverProcess) { - const pid = serverProcess.pid; - Log.info(config.getMessage('LSPServer.ServerHasStarted', pid)); - vscode.window.showInformationMessage( - config.getMessage('LSPServer.ServerHasStarted', pid) - ); + const dockerClient = new XpLanguageClient( + 'xpDockerLanguageServer', + 'XP Docker Language Server', + serverOptions, + clientOptions + ); + + await dockerClient.start(); + notifyAboutStartedLspServer(config, dockerClient, 'Docker-backed XPLang LSP proxy'); + Log.info( + `Docker-backed XPLang LSP proxy has started using '${proxyModule}' for container '${proxyConfig.containerName}'.` + ); + return { + client: dockerClient, + usesKbtFormatter: true + }; + } else { + const lspServerExecutablePath = config.getResolvedLSPServerExecutablePath(); + + if (lspServerExecutablePath) { + Log.info(config.getMessage('LSPServer.ServerExecutableFoundAt', lspServerExecutablePath)); + const command = lspServerExecutablePath; + const args: string[] = []; + + const serverOptions: ServerOptions = { + command, + args, + transport: TransportKind.stdio, + options: { + cwd: __dirname } - }) - .catch((error) => { - vscode.window.showErrorMessage( - 'Failed to start XPLang Language Server: ' + error.message - ); - }); + }; + + const clientOptions: LanguageClientOptions = { + documentSelector, + synchronize: { + configurationSection: [config.getExtensionSettingsPrefix(), 'xplang_ls'] + }, + initializationOptions + }; + + const externalClient = new XpLanguageClient( + command, + 'XP Language Server', + serverOptions, + clientOptions + ); + + return externalClient + .start() + .then(() => { + notifyAboutStartedLspServer(config, externalClient, 'KBT XPLang LSP server'); + + return { + client: externalClient, + usesKbtFormatter: true + }; + }) + .catch((error) => { + vscode.window.showErrorMessage( + 'Failed to start XPLang Language Server: ' + error.message + ); + throw error; + }); + } } } catch (e) { // if error just use legacy server @@ -322,6 +409,14 @@ async function configureLSPClient( { scheme: 'file', language: 'en' + }, + { + scheme: 'file', + language: 'agr' + }, + { + scheme: 'file', + language: 'flt' } ], synchronize: { @@ -331,6 +426,63 @@ async function configureLSPClient( }; // Создаем клиент, запускаем его и сервер. - client = new LanguageClient('languageServer', 'Language Server', serverOptions, clientOptions); - client.start(); + const legacyClient = new LanguageClient('languageServer', 'Language Server', serverOptions, clientOptions); + try { + await legacyClient.start(); + notifyAboutStartedLspServer(config, legacyClient, 'Legacy XPLang LSP server'); + Log.info(`Legacy XPLang LSP server has started from '${serverModule}'.`); + } catch (error) { + Log.error(`Failed to start legacy XPLang LSP server from '${serverModule}'.`, error); + throw error; + } + + return { + client: legacyClient, + usesKbtFormatter: false + }; +} + +async function buildKbtLspInitializationOptions(config: Configuration): Promise> { + const initializationOptions: Record = { + locale: vscode.env.language + }; + + try { + const taxonomyPath = config.craftLSPTaxonomyPath(); + const taxonomyI18nPath = config.craftLSPi18nTaxonomyPath(); + initializationOptions.taxonomy_path = taxonomyPath; + initializationOptions.taxonomy_i18n_path = taxonomyI18nPath; + + await config.updateLSPTaxonomyPath(); + await config.updateLSPi18nTaxonomyPath(); + + const schemaPath = config.getKBTLSPConfiguration().get('schema_path'); + if (schemaPath) { + initializationOptions.schema_path = config.mapPathForExecution(schemaPath); + } + } catch (error) { + Log.warn(`Failed to prepare KBT LSP initialization options: ${error.message}`); + } + + return initializationOptions; +} + +function notifyAboutStartedLspServer( + config: Configuration, + startedClient: LanguageClient, + serverKind: string +): void { + const serverProcess = startedClient['_serverProcess']; + const pid = serverProcess?.pid; + + if (pid) { + const message = config.getMessage('LSPServer.ServerHasStarted', pid); + Log.info(message); + void vscode.window.showInformationMessage(message); + return; + } + + const message = `${serverKind} has started.`; + Log.info(message); + void vscode.window.showInformationMessage(message); } diff --git a/client/src/helpers/exceptionHelper.ts b/client/src/helpers/exceptionHelper.ts index 24660d89..ab524a9a 100644 --- a/client/src/helpers/exceptionHelper.ts +++ b/client/src/helpers/exceptionHelper.ts @@ -24,7 +24,7 @@ export class ExceptionHelper { case 'OperationCanceledException': { const typedError = error as XpException; - Log.info(null, typedError); + ExceptionHelper.writeInfo(outputChannel, typedError.message ?? ''); vscode.window.showInformationMessage(typedError.message); break; } @@ -48,8 +48,8 @@ export class ExceptionHelper { vscode.window.showErrorMessage(userMessage); // Пишем stack в output. - Log.error(resultDefaultMessage); - Log.error(error.message, error); + ExceptionHelper.writeError(outputChannel, resultDefaultMessage); + ExceptionHelper.writeError(outputChannel, error.message, error); outputChannel.show(); } } @@ -62,12 +62,52 @@ export class ExceptionHelper { // Есть вложенные исключения. if (error instanceof XpException && error.getInnerException()) { // Пишем текущие исключение. - Log.error(error.message, error); + ExceptionHelper.writeError(outputChannel, error.message, error); // Пишем вложенное. ExceptionHelper.recursiveWriteXpExceptionToOutput(error.getInnerException(), outputChannel); } else { - Log.error(error.message, error); + ExceptionHelper.writeError(outputChannel, error.message, error); } } + + private static writeInfo(outputChannel: vscode.OutputChannel, message: string): void { + if (Log) { + Log.info(message); + return; + } + + outputChannel.appendLine(`${ExceptionHelper.timestamp()} [Info] ${message}`); + } + + private static writeError( + outputChannel: vscode.OutputChannel, + message: string, + error?: Error | unknown + ): void { + if (Log) { + if (error) { + Log.error(message, error); + } else { + Log.error(message); + } + return; + } + + outputChannel.appendLine(`${ExceptionHelper.timestamp()} [Error] ${message ?? ''}`); + if (error) { + outputChannel.appendLine(String((error as Error)?.stack ?? error)); + } + } + + private static timestamp(): string { + const date = new Date(); + const yyyy = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + return `[${day}.${month}.${yyyy} ${hours}:${minutes}:${seconds}]`; + } } diff --git a/client/src/helpers/regExpHelper.ts b/client/src/helpers/regExpHelper.ts index 98144c32..ec79d38e 100644 --- a/client/src/helpers/regExpHelper.ts +++ b/client/src/helpers/regExpHelper.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import { FileSystemHelper } from './fileSystemHelper'; export interface IResultTestFiles { detailedReportFilePath: string; @@ -70,6 +71,35 @@ export class RegExpHelper { return resulults; } + public static getEnrichedCorrTestEventsFileNameFromDirectory( + directoryPath: string + ): Map { + const results = new Map(); + const files = FileSystemHelper.getRecursiveFilesSync(directoryPath); + + for (const filePath of files) { + const detailedReportMatch = /test_conds_(\d+)_result\.txt$/i.exec(filePath); + if (detailedReportMatch) { + const testNumber = detailedReportMatch[1]; + if (!results.has(testNumber)) { + results.set(testNumber, { detailedReportFilePath: '', actualEventsFilePath: '' }); + } + results.get(testNumber).detailedReportFilePath = filePath; + } + + const actualEventsMatch = /test_conds_(\d+)_events\.txt$/i.exec(filePath); + if (actualEventsMatch) { + const testNumber = actualEventsMatch[1]; + if (!results.has(testNumber)) { + results.set(testNumber, { detailedReportFilePath: '', actualEventsFilePath: '' }); + } + results.get(testNumber).actualEventsFilePath = filePath; + } + } + + return results; + } + public static getCorrTestEventsFileName(ruleName: string, testNumber?: number): RegExp { let regExpTemplate: string; if (testNumber) { diff --git a/client/src/models/configuration.ts b/client/src/models/configuration.ts index debea775..ec2e5004 100644 --- a/client/src/models/configuration.ts +++ b/client/src/models/configuration.ts @@ -76,6 +76,11 @@ export class Configuration { } public getCurrentSIEMJVersion(): string { + if (this.SIEMJVersion) { + return this.SIEMJVersion; + } + + this.SIEMJVersion = this.detectSIEMJVersionSync(); return this.SIEMJVersion; } @@ -92,6 +97,11 @@ export class Configuration { } } + public getLSPMode(): 'auto' | 'legacy' | 'kbt' { + const mode = this.getWorkspaceConfiguration().get<'auto' | 'legacy' | 'kbt'>('lspMode'); + return mode ?? 'auto'; + } + public getLSPTaxonomyPath(): string { const configuration = this.getKBTLSPConfiguration(); return configuration.get('taxonomy_path'); @@ -1144,6 +1154,28 @@ export class Configuration { return lspServerExecutablePath; } + public getResolvedLSPServerExecutablePath(): string | undefined { + if (this.getLSPMode() === 'legacy') { + return undefined; + } + + if (this.shouldUseDockerToolRunner()) { + return undefined; + } + + const configuredPath = this.getDirectLSPServerExecutablePath(); + if (configuredPath) { + if (!fs.existsSync(configuredPath)) { + Log.warn(`Configured LSP server executable was not found: '${configuredPath}'.`); + return undefined; + } + + return configuredPath; + } + + return this.getKBTLSPFullPath() ?? undefined; + } + /** * Automatically sets kbtVersionsDirectory if not already set */ @@ -1194,8 +1226,15 @@ export class Configuration { * Automatically sets lspServerExecutablePath if not already set */ public autoSetLspServerExecutablePath(): void { + if (this.getLSPMode() === 'legacy') { + Log.info('Skipping KBT LSP auto-detection because xpConfig.lspMode=legacy.'); + return; + } + if (this.shouldUseDockerToolRunner()) { - Log.info('Skipping local KBT LSP auto-detection in Docker tool execution mode'); + Log.info( + 'Skipping local KBT LSP auto-detection in Docker tool execution mode. External KBT LSP is not launched via Docker because host file URIs are not mapped into the LSP protocol yet.' + ); return; } @@ -1205,7 +1244,7 @@ export class Configuration { if (!lspServerExecutablePath) { try { // Try to find the LSP server executable in the KBT directory - const fullPath = this.getKBTLSPFullPath(); + const fullPath = this.getResolvedLSPServerExecutablePath(); if (fullPath) { configuration.update('lspServerExecutablePath', fullPath, true, false); @@ -1406,6 +1445,32 @@ export class Configuration { } } + private detectSIEMJVersionSync(): string { + if (this.shouldUseDockerToolRunner()) { + return '2'; + } + + try { + const evtTestsPath = this.getEvtTestsFullPath(); + if (evtTestsPath && fs.existsSync(evtTestsPath)) { + return '2'; + } + } catch (error) { + Log.warn(`Failed to infer SIEMJ version from evt-tests: ${error.message}`); + } + + try { + const lspPath = this.getKBTLSPFullPath(); + if (lspPath && fs.existsSync(lspPath)) { + return '2'; + } + } catch (error) { + Log.warn(`Failed to infer SIEMJ version from KBT LSP path: ${error.message}`); + } + + return '1'; + } + public async checkUserSetting(): Promise { const extensionConfig = this.getWorkspaceConfiguration(); diff --git a/client/src/models/content/ruleBaseItem.ts b/client/src/models/content/ruleBaseItem.ts index 4b42c638..b5cdfd03 100644 --- a/client/src/models/content/ruleBaseItem.ts +++ b/client/src/models/content/ruleBaseItem.ts @@ -223,7 +223,7 @@ export abstract class RuleBaseItem extends ContentTreeBaseItem { testDirectoryPath = path.join(ruleDirPath, RuleBaseItem.TESTS_DIRNAME); } - // Очищаем старые тесты, так как если их стало меньше, то будут оставаться удалённые. + // Удаляем только те файлы тестов, для которых больше нет соответствующего теста. if (fs.existsSync(testDirectoryPath)) { let oldTestFilePaths = FileSystemHelper.readFilesNameFilter( testDirectoryPath, @@ -237,8 +237,20 @@ export abstract class RuleBaseItem extends ContentTreeBaseItem { oldTestFilePaths = oldTestFilePaths.concat(rawEventFilePaths); + const actualFilePaths = new Set(); + for (const it of this.integrationTests) { + if (ruleDirPath) { + it.setRuleDirectoryPath(ruleDirPath); + } + + actualFilePaths.add(it.getTestCodeFilePath()); + actualFilePaths.add(it.getRawEventsFilePath()); + } + for (const it of oldTestFilePaths) { - await fs.promises.unlink(it); + if (!actualFilePaths.has(it)) { + await fs.promises.unlink(it); + } } } diff --git a/client/src/models/siemj/setKBTVersionCommand.ts b/client/src/models/siemj/setKBTVersionCommand.ts index 36010db8..1e2ac8e0 100644 --- a/client/src/models/siemj/setKBTVersionCommand.ts +++ b/client/src/models/siemj/setKBTVersionCommand.ts @@ -13,6 +13,10 @@ export class SetKBTVersionCommand { static Name = 'xpContentEditor.setKBTVersion'; static async init(config: Configuration): Promise { + if (config.shouldUseDockerToolRunner()) { + return; + } + const context = config.getContext(); const setKBTVersionCommand = new SetKBTVersionCommand(); @@ -91,9 +95,9 @@ export class SetKBTVersionCommand { // Update lspServerExecutablePath to point to the new version try { - // Use the existing getKBTLSPFullPath method to determine the LSP server path + // Use the resolved LSP path helper so an explicit setting or a discovered KBT path stay aligned. // This ensures consistency with the rest of the codebase - const lspServerPath = config.getKBTLSPFullPath(); + const lspServerPath = config.getResolvedLSPServerExecutablePath(); // Check if the path is valid and update the configuration const configuration = config.getWorkspaceConfiguration(); diff --git a/client/src/models/siemj/siemjManager.ts b/client/src/models/siemj/siemjManager.ts index 4af3d897..455c831a 100644 --- a/client/src/models/siemj/siemjManager.ts +++ b/client/src/models/siemj/siemjManager.ts @@ -321,7 +321,7 @@ export class SiemjManager { case SIEMJVersion.Second: for (const [, resultFiles] of testResultFiles) { - let correlateEventsFileContent = await FileSystemHelper.readContentFile( + let correlateEventsFileContent = await this.config.readTextFile( resultFiles.actualEventsFilePath ); correlateEventsFileContent = correlateEventsFileContent.trimEnd(); diff --git a/client/src/providers/xpDocumentFormattingProvider.ts b/client/src/providers/xpDocumentFormattingProvider.ts new file mode 100644 index 00000000..dcbb110a --- /dev/null +++ b/client/src/providers/xpDocumentFormattingProvider.ts @@ -0,0 +1,79 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +import { Log } from '../extension'; +import { Configuration } from '../models/configuration'; +import { XpException } from '../models/xpException'; + +export class XpDocumentFormattingProvider implements vscode.DocumentFormattingEditProvider { + private static readonly SUPPORTED_LANGUAGES = ['xp', 'en', 'co', 'agr', 'flt']; + + public constructor(private readonly config: Configuration) {} + + public static init(config: Configuration): void { + const provider = new XpDocumentFormattingProvider(config); + + for (const language of this.SUPPORTED_LANGUAGES) { + config.getContext().subscriptions.push( + vscode.languages.registerDocumentFormattingEditProvider( + { scheme: 'file', language }, + provider + ) + ); + } + } + + public async provideDocumentFormattingEdits( + document: vscode.TextDocument + ): Promise { + const formatterPath = this.getFormatterPath(); + const tempDirectory = this.config.getRandTmpSubDirectoryPath(); + await fs.promises.mkdir(tempDirectory, { recursive: true }); + + const tempFilePath = path.join( + tempDirectory, + `${path.basename(document.fileName, path.extname(document.fileName))}.formatted${path.extname(document.fileName)}` + ); + + try { + await fs.promises.writeFile(tempFilePath, document.getText(), 'utf-8'); + await this.config.getToolRunner().runTool(formatterPath, [tempFilePath], { + encoding: 'utf-8', + cwd: path.dirname(tempFilePath) + }); + + const formattedText = await fs.promises.readFile(tempFilePath, 'utf-8'); + if (formattedText === document.getText()) { + return []; + } + + const fullRange = new vscode.Range( + document.positionAt(0), + document.positionAt(document.getText().length) + ); + + Log.info(`Formatted XP document '${document.fileName}' using '${formatterPath}'.`); + return [vscode.TextEdit.replace(fullRange, formattedText)]; + } catch (error) { + Log.warn(`Failed to format XP document '${document.fileName}': ${error.message}`); + throw error; + } finally { + await fs.promises.rm(tempDirectory, { recursive: true, force: true }); + } + } + + private getFormatterPath(): string { + const kbtBaseDirectory = this.config.getKbtBaseDirectory(); + const formatterName = process.platform === 'win32' ? 'evt-xp-formatter.exe' : 'evt-xp-formatter'; + const formatterPath = path.join(kbtBaseDirectory, 'xp-sdk', 'cli', formatterName); + + if (!this.config.shouldUseDockerToolRunner() && !fs.existsSync(formatterPath)) { + throw new XpException( + `XP formatter was not found at '${formatterPath}'. Install a KBT version that includes evt-xp-formatter or disable document formatting for XP files.` + ); + } + + return formatterPath; + } +} diff --git a/client/src/tools/lspDockerProxy.ts b/client/src/tools/lspDockerProxy.ts new file mode 100644 index 00000000..5619cc73 --- /dev/null +++ b/client/src/tools/lspDockerProxy.ts @@ -0,0 +1,195 @@ +import * as childProcess from 'child_process'; +import * as path from 'path'; +import { fileURLToPath, pathToFileURL } from 'url'; + +import { PathMapper, PathMapping } from './pathMapper'; + +interface ProxyConfig { + containerName: string; + workspaceHostPath: string; + workspaceContainerPath: string; + kbtBaseDirectory: string; + outputHostPath?: string; + outputContainerPath?: string; +} + +class LspMessageStream { + private buffer = Buffer.alloc(0); + + public constructor( + private readonly onMessage: (message: unknown) => void, + private readonly onError: (error: Error) => void + ) {} + + public push(chunk: Buffer): void { + this.buffer = Buffer.concat([this.buffer, chunk]); + + while (true) { + const separatorIndex = this.buffer.indexOf('\r\n\r\n'); + if (separatorIndex === -1) { + return; + } + + const header = this.buffer.slice(0, separatorIndex).toString('utf-8'); + const contentLength = this.parseContentLength(header); + if (contentLength === undefined) { + this.onError(new Error(`Invalid LSP header: ${header}`)); + return; + } + + const bodyStart = separatorIndex + 4; + const bodyEnd = bodyStart + contentLength; + if (this.buffer.length < bodyEnd) { + return; + } + + const body = this.buffer.slice(bodyStart, bodyEnd).toString('utf-8'); + this.buffer = this.buffer.slice(bodyEnd); + + try { + this.onMessage(JSON.parse(body)); + } catch (error) { + this.onError(error as Error); + } + } + } + + private parseContentLength(header: string): number | undefined { + const match = /Content-Length:\s*(\d+)/i.exec(header); + if (!match) { + return undefined; + } + + return Number(match[1]); + } +} + +class LspDockerProxy { + private readonly pathMapper: PathMapper; + private readonly languageServerPath: string; + + public constructor(private readonly config: ProxyConfig) { + const extraMappings: PathMapping[] = []; + if (config.outputHostPath && config.outputContainerPath) { + extraMappings.push({ + hostPath: config.outputHostPath, + containerPath: config.outputContainerPath + }); + } + + this.pathMapper = new PathMapper({ + workspaceHostPath: config.workspaceHostPath, + workspaceContainerPath: config.workspaceContainerPath, + extraMappings + }); + + this.languageServerPath = path.posix.join( + config.kbtBaseDirectory, + 'xp-sdk', + 'cli', + 'evt-xp-language-server' + ); + } + + public run(): void { + const dockerArgs = ['exec', '-i', this.config.containerName, this.languageServerPath]; + const child = childProcess.spawn('docker', dockerArgs, { + stdio: ['pipe', 'pipe', 'pipe'] + }); + + const clientInput = new LspMessageStream( + (message) => { + this.writeFramedMessage(child.stdin, this.mapMessage(message, 'hostToContainer')); + }, + (error) => this.logError(`Failed to decode client LSP message: ${error.message}`) + ); + + const serverInput = new LspMessageStream( + (message) => { + this.writeFramedMessage(process.stdout, this.mapMessage(message, 'containerToHost')); + }, + (error) => this.logError(`Failed to decode container LSP message: ${error.message}`) + ); + + process.stdin.on('data', (chunk: Buffer) => clientInput.push(chunk)); + child.stdout.on('data', (chunk: Buffer) => serverInput.push(chunk)); + child.stderr.on('data', (chunk: Buffer) => process.stderr.write(chunk)); + + process.stdin.on('end', () => child.stdin.end()); + process.stdin.on('error', (error) => this.logError(`Proxy stdin error: ${error.message}`)); + child.on('error', (error) => { + this.logError(`Failed to start Docker LSP proxy: ${error.message}`); + process.exit(1); + }); + child.on('close', (code) => process.exit(code ?? 0)); + + process.on('SIGINT', () => child.kill('SIGINT')); + process.on('SIGTERM', () => child.kill('SIGTERM')); + } + + private writeFramedMessage(stream: NodeJS.WritableStream, message: unknown): void { + const payload = Buffer.from(JSON.stringify(message), 'utf-8'); + stream.write(`Content-Length: ${payload.length}\r\n\r\n`); + stream.write(payload); + } + + private mapMessage( + value: unknown, + direction: 'hostToContainer' | 'containerToHost' + ): unknown { + if (typeof value === 'string') { + return this.mapString(value, direction); + } + + if (Array.isArray(value)) { + return value.map((item) => this.mapMessage(item, direction)); + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, nestedValue]) => [key, this.mapMessage(nestedValue, direction)]) + ); + } + + return value; + } + + private mapString(value: string, direction: 'hostToContainer' | 'containerToHost'): string { + if (value.startsWith('file://')) { + try { + const filePath = fileURLToPath(value); + const mappedPath = + direction === 'hostToContainer' + ? this.pathMapper.hostToContainer(filePath) + : this.pathMapper.containerToHost(filePath); + return pathToFileURL(mappedPath).toString(); + } catch { + return value; + } + } + + if (path.isAbsolute(value) || value.startsWith('/')) { + return direction === 'hostToContainer' + ? this.pathMapper.hostToContainer(value) + : this.pathMapper.containerToHost(value); + } + + return value; + } + + private logError(message: string): void { + process.stderr.write(`[xp-lsp-proxy] ${message}\n`); + } +} + +function readConfigFromArgs(): ProxyConfig { + const encodedConfig = process.argv[2]; + if (!encodedConfig) { + throw new Error('Missing Docker LSP proxy configuration.'); + } + + return JSON.parse(Buffer.from(encodedConfig, 'base64').toString('utf-8')) as ProxyConfig; +} + +const config = readConfigFromArgs(); +new LspDockerProxy(config).run(); diff --git a/client/src/tools/macosContainerSetup.ts b/client/src/tools/macosContainerSetup.ts index 8e45e90e..c4512904 100644 --- a/client/src/tools/macosContainerSetup.ts +++ b/client/src/tools/macosContainerSetup.ts @@ -28,7 +28,7 @@ export class MacOSContainerSetup { return; } - const setupIssue = this.getMacOSDockerSetupIssue(config); + const setupIssue = await this.getMacOSDockerSetupIssue(config); if (!config.getMacOSShowContainerSetupPrompt() && !setupIssue) { return; } @@ -69,17 +69,21 @@ export class MacOSContainerSetup { } } - private static getMacOSDockerSetupIssue(config: Configuration): string | undefined { - if (!config.shouldUseDockerToolRunner()) { - return undefined; - } - + private static async getMacOSDockerSetupIssue( + config: Configuration + ): Promise { const xpConfig = config.getWorkspaceConfiguration(); const containerName = xpConfig.get('docker.containerName'); const workspaceHostPath = xpConfig.get('docker.workspaceHostPath'); const workspaceContainerPath = xpConfig.get('docker.workspaceContainerPath'); const kbtBaseDirectory = xpConfig.get('docker.kbtBaseDirectory'); + if (!config.shouldUseDockerToolRunner()) { + return containerName || workspaceHostPath || workspaceContainerPath || kbtBaseDirectory + ? 'container backend is configured but not enabled' + : undefined; + } + if (!containerName) { return 'container is not selected'; } @@ -104,6 +108,21 @@ export class MacOSContainerSetup { return 'selected host path does not look like a knowledgebase root'; } + const dockerAvailable = await this.isDockerAvailable(); + if (!dockerAvailable) { + return 'Docker Desktop is not available'; + } + + const containerRunning = await this.isContainerRunning(containerName); + if (!containerRunning) { + return `container '${containerName}' is not running`; + } + + const kbtAvailable = await this.isKbtAvailable(containerName, kbtBaseDirectory); + if (!kbtAvailable) { + return `KBT was not found in container at '${kbtBaseDirectory}'`; + } + return undefined; } @@ -467,6 +486,48 @@ export class MacOSContainerSetup { return undefined; } + private static async isDockerAvailable(): Promise { + try { + const result = await ProcessHelper.execute('docker', ['version'], { + encoding: 'utf-8', + checkCommandBeforeExecution: true + }); + + return result.exitCode === 0; + } catch { + return false; + } + } + + private static async isContainerRunning(containerName: string): Promise { + const result = await ProcessHelper.execute( + 'docker', + ['inspect', '-f', '{{.State.Running}}', containerName], + { encoding: 'utf-8' } + ); + + return result.exitCode === 0 && result.output.trim() === 'true'; + } + + private static async isKbtAvailable( + containerName: string, + kbtBaseDirectory: string + ): Promise { + const result = await ProcessHelper.execute( + 'docker', + [ + 'exec', + containerName, + 'sh', + '-lc', + `test -x '${kbtBaseDirectory}/extra-tools/siemj/siemj' -o -x '${kbtBaseDirectory}/build-tools/normalize'` + ], + { encoding: 'utf-8' } + ); + + return result.exitCode === 0; + } + private static async resolveMissingKbt( containerName: string, outputChannel: vscode.OutputChannel diff --git a/client/src/views/integrationTests/command/saveAllCommand.ts b/client/src/views/integrationTests/command/saveAllCommand.ts index e6513185..b6408f77 100644 --- a/client/src/views/integrationTests/command/saveAllCommand.ts +++ b/client/src/views/integrationTests/command/saveAllCommand.ts @@ -76,12 +76,16 @@ export class SaveAllCommand extends Command { const newTests = plainTests.map((plainTest, index) => { const number = index + 1; const newTest = IntegrationTest.create(number); + const oldTest = oldTests?.[index]; // Сырые события. let rawEvents = plainTest?.rawEvents; // Из textarea новые строки только \n, поэтому надо их поправить под систему. rawEvents = rawEvents.replace(/(? ".*?"\s|\s+[\w.]+: \d+ => \d+\s)+)/s; - const diffMatch = correlateEventsFileContent.match(diffRegex); - if (diffMatch && diffMatch.length === 3) { - diff = diffMatch[1]; - } - } + const missingSection = this.extractDetailedReportSection( + correlateEventsFileContent, + 'Missing:' + ); + const differentSection = this.extractDetailedReportSection( + correlateEventsFileContent, + 'Different:' + ); + diff = differentSection || missingSection; } } catch (e) {} // TODO: extend logic for Enrichment rules tests const actualEventFound = await this.searchForActualEvent(it); + const hasFailureDetails = Boolean(diff || events || tlState); plain['IntegrationTests'].push({ TestNumber: it.getNumber(), RawEvents: rawEvents, @@ -328,7 +323,9 @@ export class IntegrationTestEditorViewProvider { NormState: events, TLState: tlState, IsFailed: it.getStatus() === TestStatus.Failed, - CanGetExpectedEvent: this.canGetExpectedEvent(it, actualEventFound) + CanGetExpectedEvent: this.canGetExpectedEvent(it, actualEventFound || hasFailureDetails), + CanShowActualEvent: actualEventFound, + CanCompareResults: it.getStatus() === TestStatus.Failed && (actualEventFound || hasFailureDetails) }); } } @@ -347,38 +344,51 @@ export class IntegrationTestEditorViewProvider { private async searchForActualEvent(it: IntegrationTest): Promise { const ruleName = this.rule.getName(); - if (!fs.existsSync(this.testsTmpFilesPath)) { - return false; + + const resultFiles = it.getResultFiles(); + let actualEventsFilePath = resultFiles?.actualEventsFilePath; + if (!actualEventsFilePath) { + actualEventsFilePath = resultFiles?.detailedReportFilePath; } + try { - // Получаем фактическое событие. - var actualEventsFilePath = TestHelper.getEnrichedCorrEventFilePath( - this.config, - this.testsTmpFilesPath, - ruleName, - it.getNumber() - ); - } catch (e) { - return false; - } + if (!actualEventsFilePath) { + if (!fs.existsSync(this.testsTmpFilesPath)) { + return false; + } - if (!actualEventsFilePath || !fs.existsSync(actualEventsFilePath)) { + actualEventsFilePath = TestHelper.getEnrichedCorrEventFilePath( + this.config, + this.testsTmpFilesPath, + ruleName, + it.getNumber() + ); + } + } catch (e) { return false; } - const actualEventsString = await FileSystemHelper.readContentFile(actualEventsFilePath); - if (!actualEventsString) { + if (!actualEventsFilePath) { return false; } - const siemjVersion = GetSIEMJVersion(this.config); - if (siemjVersion == SIEMJVersion.Second) { - if (!actualEventsString.match(/\[FromCorrelator\]/)) { + try { + const actualEventsString = await this.config.readTextFile(actualEventsFilePath); + if (!actualEventsString) { return false; } - return true; + + const actualEvents = TestHelper.extractEventsFromResultString( + this.config, + actualEventsString, + ruleName, + it.getNumber() + ); + + return actualEvents.length > 0; + } catch (e) { + return false; } - return true; } /** @@ -403,6 +413,20 @@ export class IntegrationTestEditorViewProvider { } } + private extractDetailedReportSection(reportContent: string, sectionHeader: string): string { + const escapedHeader = sectionHeader.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const sectionRegex = new RegExp( + `${escapedHeader}\\s*([\\s\\S]*?)(?:Event conditions:|Contents of the table lists \\(|\\[FromCorrelator\\]|$)`, + 's' + ); + const match = reportContent.match(sectionRegex); + if (!match || match.length < 2) { + return ''; + } + + return match[1].trim(); + } + private testStatusToUiStyle(it: IntegrationTest): string { const testStatus = it.getStatus(); switch (testStatus) { diff --git a/client/src/views/localization/checkLocalizationsCommand.ts b/client/src/views/localization/checkLocalizationsCommand.ts index 13b8b824..c47bf085 100644 --- a/client/src/views/localization/checkLocalizationsCommand.ts +++ b/client/src/views/localization/checkLocalizationsCommand.ts @@ -144,6 +144,7 @@ export class CheckLocalizationCommand extends ViewCommand { }, async (progress, token) => { let userResponse: string; + let testRuleFiles: Map = new Map(); if (fs.existsSync(this.params.tmpDirPath)) { const subDirItems = await fs.promises.readdir(this.params.tmpDirPath, { @@ -193,7 +194,7 @@ export class CheckLocalizationCommand extends ViewCommand { const testRunner = new IntegrationTestRunner(this.params.config, configBuilder); const siemjResult = await testRunner.runOnce(this.params.rule, options); - var testRuleFiles = RegExpHelper.getEnrichedCorrTestEventsFileNameNew( + testRuleFiles = RegExpHelper.getEnrichedCorrTestEventsFileNameNew( siemjResult.rawOutput ); @@ -202,6 +203,16 @@ export class CheckLocalizationCommand extends ViewCommand { 'Не все интеграционные тесты прошли. Для получения тестовых локализации необходимо чтобы успешно проходили все интеграционные тесты' ); } + } else { + testRuleFiles = RegExpHelper.getEnrichedCorrTestEventsFileNameFromDirectory( + this.params.tmpDirPath + ); + + if (testRuleFiles.size === 0) { + throw new XpException( + 'Не удалось найти результаты предыдущего запуска интеграционных тестов. Повторите запуск интеграционных тестов и после этого снова проверьте локализации.' + ); + } } const locExamples = await this.getLocalization(progress, testRuleFiles); diff --git a/client/templates/IntegrationTestEditor.html b/client/templates/IntegrationTestEditor.html index 16dfb8c6..24b2ca7d 100644 --- a/client/templates/IntegrationTestEditor.html +++ b/client/templates/IntegrationTestEditor.html @@ -179,15 +179,15 @@
- {{#CanGetExpectedEvent}} + {{/CanGetExpectedEvent}} + {{#CanShowActualEvent}} - - {{#IsFailed}} + {{/CanShowActualEvent}} + {{#CanCompareResults}} - {{/IsFailed}} - {{/CanGetExpectedEvent}} + {{/CanCompareResults}}
@@ -221,6 +221,27 @@ // Глобальное определение переменных. const redos = []; const undos = []; + const isMacOS = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + + function hasPrimaryModifier(event) { + return isMacOS ? event.metaKey : event.ctrlKey; + } + + function isUndoShortcut(event) { + return hasPrimaryModifier(event) && !event.shiftKey && event.code == 'KeyZ'; + } + + function isRedoShortcut(event) { + if (isMacOS) { + return hasPrimaryModifier(event) && event.shiftKey && event.code == 'KeyZ'; + } + + return hasPrimaryModifier(event) && event.code == 'KeyY'; + } + + function isSelectAllShortcut(event) { + return hasPrimaryModifier(event) && event.code == 'KeyA'; + } function normalizeTestCode (testCode) { if (!testCode || typeof testCode !== 'string') return testCode; @@ -342,7 +363,7 @@ setCaret(pos); e.preventDefault(); return; - } else if (e.ctrlKey && e.code == 'KeyZ') { + } else if (isUndoShortcut(e)) { const prevHTML = undo(redos, undos, el.innerHTML) if (prevHTML) { const difference = (el.innerHTML.length - prevHTML.length) || 0; @@ -350,7 +371,7 @@ el.innerHTML = prevHTML; setCaret(pos - difference < 0 ? pos : pos - difference); } - } else if (e.ctrlKey && e.code == 'KeyY') { + } else if (isRedoShortcut(e)) { const prevHTML = redo(redos, undos, el.innerHTML) if (prevHTML) { const difference = (prevHTML.length - el.innerHTML.length) || 0; @@ -373,7 +394,7 @@ // Обработка вставки символа с поддержкой выделения на Ctrl+A. el.addEventListener('keyup', e => { - if ((e.keyCode >= 0x30 || e.keyCode == 0x20 || e.code === 'Delete' || e.code === 'Backspace') && !(e.ctrlKey && e.code == 'KeyA') && !(e.ctrlKey && e.code == 'KeyZ') && !(e.ctrlKey && e.code == 'KeyY')) { + if ((e.keyCode >= 0x30 || e.keyCode == 0x20 || e.code === 'Delete' || e.code === 'Backspace') && !isSelectAllShortcut(e) && !isUndoShortcut(e) && !isRedoShortcut(e)) { const pos = caret(); el.innerHTML = hljs.highlight(el.innerText ? el.innerText : '', {language: language}).value setCaret(pos); @@ -753,9 +774,9 @@ saveAllTests(); }); - // Сохраняем все тесты по хот кею Ctrl+S + // Сохраняем все тесты по хот кею Cmd+S на macOS и Ctrl+S на остальных платформах $(document).on("keydown", e => { - if (e.ctrlKey && e.code == 'KeyS') { + if (hasPrimaryModifier(e) && e.code == 'KeyS') { e.preventDefault(); saveAllTests(); } @@ -842,4 +863,4 @@ } - \ No newline at end of file + diff --git a/client/templates/LocalizationEditor.html b/client/templates/LocalizationEditor.html index 08bd7452..82e520aa 100644 --- a/client/templates/LocalizationEditor.html +++ b/client/templates/LocalizationEditor.html @@ -179,10 +179,16 @@

{{Locale.LocalizationCriteria}}:

}); } + const isMacOS = navigator.platform.toUpperCase().indexOf('MAC') >= 0; - // Сохраняем все тесты по хот кею Ctrl+S + function hasPrimaryModifier(event) { + return isMacOS ? event.metaKey : event.ctrlKey; + } + + + // Сохраняем данные по хот кею Cmd+S на macOS и Ctrl+S на остальных платформах $(document).on("keydown", e => { - if (e.ctrlKey && e.code == 'KeyS') { + if (hasPrimaryModifier(e) && e.code == 'KeyS') { console.log(e.keyCode); e.preventDefault(); saveLocalizations(); @@ -221,4 +227,4 @@

{{Locale.LocalizationCriteria}}:

} - \ No newline at end of file + diff --git a/client/templates/js/meta.js b/client/templates/js/meta.js index 6e4f47e7..3c7c6330 100644 --- a/client/templates/js/meta.js +++ b/client/templates/js/meta.js @@ -129,9 +129,15 @@ function saveMetaInfo() { }); } +const isMacOS = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + +function hasPrimaryModifier(event) { + return isMacOS ? event.metaKey : event.ctrlKey; +} + // Сохраняем все тесты по хот кею Ctrl+S $(document).on("keydown", e => { - if (e.ctrlKey && e.code == 'KeyS') { + if (hasPrimaryModifier(e) && e.code == 'KeyS') { e.preventDefault(); saveMetaInfo(); } diff --git a/client/templates/js/unittest/code.js b/client/templates/js/unittest/code.js index fc8f0803..f97243c3 100644 --- a/client/templates/js/unittest/code.js +++ b/client/templates/js/unittest/code.js @@ -161,9 +161,15 @@ $(document).ready(function() { }); }); +const isMacOS = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + +function hasPrimaryModifier(event) { + return isMacOS ? event.metaKey : event.ctrlKey; +} + // Сохраняем все тесты по хот кею Ctrl+S $(document).on("keydown", e => { - if (e.ctrlKey && e.code == 'KeyS') { + if (hasPrimaryModifier(e) && e.code == 'KeyS') { e.preventDefault(); saveTest(); } diff --git a/esbuild.js b/esbuild.js index 6ffb7fec..25a28035 100644 --- a/esbuild.js +++ b/esbuild.js @@ -15,7 +15,7 @@ const clientConfig = { format: 'cjs', entryPoints: ['./client/src/extension.ts'], outfile: `${clientOutDirectoryPath}/extension.js`, - external: ['vscode', 'prettier'], + external: ['vscode'], plugins: [ clean({ patterns: [clientOutDirectoryPath] @@ -23,6 +23,15 @@ const clientConfig = { ] }; +const clientLspProxyConfig = { + ...baseConfig, + platform: 'node', + mainFields: ['module', 'main'], + format: 'cjs', + entryPoints: ['./client/src/tools/lspDockerProxy.ts'], + outfile: `${clientOutDirectoryPath}/lspDockerProxy.js` +}; + const uiConfig = { ...baseConfig, target: 'es2020', @@ -72,6 +81,10 @@ const watchConfig = { ...uiConfig, ...watchConfig }); + await build({ + ...clientLspProxyConfig, + ...watchConfig + }); await build({ ...serverConfig, ...watchConfig @@ -84,6 +97,8 @@ const watchConfig = { console.log('\x1b[32m✓ \x1b[34mclient \x1b[37mbuild \x1b[32mcomplete\x1b[0m'); await build(uiConfig); console.log('\x1b[32m✓ \x1b[35mui-toolkit \x1b[37mbuild \x1b[32mcomplete\x1b[0m'); + await build(clientLspProxyConfig); + console.log('\x1b[32m✓ \x1b[33mlsp-docker-proxy \x1b[37mbuild \x1b[32mcomplete\x1b[0m'); await build(serverConfig); console.log('\x1b[32m✓ \x1b[36mserver \x1b[37mbuild \x1b[32mcomplete\x1b[0m'); } diff --git a/package.json b/package.json index de718d86..79b29aa5 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "engines": { "vscode": "^1.75.0" }, - "main": "./client/out/extension", + "main": "./client/out/extension.js", "capabilities": { "untrustedWorkspaces": { "supported": true @@ -672,6 +672,17 @@ "default": "auto", "markdownDescription": "%xp.xpConfig.toolExecutionModeDescription%" }, + "xpConfig.lspMode": { + "scope": "window", + "type": "string", + "enum": [ + "auto", + "legacy", + "kbt" + ], + "default": "auto", + "markdownDescription": "%xp.xpConfig.lspModeDescription%" + }, "xpConfig.docker.containerName": { "scope": "window", "type": "string", diff --git a/package.nls.json b/package.nls.json index 03f7a051..f7ad4f96 100644 --- a/package.nls.json +++ b/package.nls.json @@ -47,6 +47,7 @@ "xp.xpConfig.kbtBaseDirectoryDescription": "Path of the folder with the [Knowledge Base Toolkit](https://github.com/vxcontrol/xp-kbt/releases)", "xp.xpConfig.outputDirectoryPathDescription": "Path of the folder where temporary objects are saved. The folder is emptied automatically every time you start the extension.", "xp.xpConfig.toolExecutionModeDescription": "How XP external tools are executed. Auto keeps local execution on Linux/Windows and uses Docker for local macOS VS Code.", + "xp.xpConfig.lspModeDescription": "Which XP language server to use. Auto prefers the KBT-provided LSP when available and falls back to the legacy in-extension LSP.", "xp.xpConfig.docker.containerNameDescription": "Name of the running Docker container with xp-kbt and related XP tools.", "xp.xpConfig.docker.composeFileDescription": "Optional docker-compose.yml path for a container backend preset.", "xp.xpConfig.docker.serviceNameDescription": "Optional Docker Compose service name for the XP backend.", diff --git a/package.nls.ru.json b/package.nls.ru.json index 1239f1b3..5f7d8811 100644 --- a/package.nls.ru.json +++ b/package.nls.ru.json @@ -46,6 +46,7 @@ "xp.xpConfig.kbtBaseDirectoryDescription": "Путь к папке c [Knowledge Base Toolkit](https://github.com/vxcontrol/xp-kbt/releases)", "xp.xpConfig.outputDirectoryPathDescription": "Путь к папке, в которой сохраняются временные объекты. Эта папка очищается автоматически при каждом запуске расширения", + "xp.xpConfig.lspModeDescription": "Какой XP language server использовать. Auto предпочитает LSP из KBT, когда он доступен, и откатывается на legacy LSP, встроенный в расширение.", "xp.xpConfig.correlatorTimeoutDescription": "Макс. время корреляции в секундах. Если оно будет превышено, корреляция будет прервана", "xp.xpConfig.lspServerExecutablePathDescription": "Путь до исполняемого файла LSP сервера", From dd71bcf39ee7ba63aefdaabcb192a8fa7791c245 Mon Sep 17 00:00:00 2001 From: Danil Lobachev Date: Tue, 7 Jul 2026 19:49:31 +0600 Subject: [PATCH 04/12] Add macOS hybrid backend support --- client/src/ext/contextMenuExtension.ts | 91 ------- client/src/extension.ts | 236 +++++++++++------- client/src/helpers/yamlHelper.ts | 87 ++++++- client/src/l10n/messages.ts | 4 - client/src/models/configuration.ts | 149 ++++++++++- client/src/models/siemj/siemjManager.ts | 2 + .../correlationUnitTestsRunnerViaEvtTests.ts | 3 +- ...normalizationUnitTestsRunnerViaEvtTests.ts | 6 +- .../providers/xpDocumentFormattingProvider.ts | 36 ++- client/src/providers/xpReferenceProvider.ts | 21 -- client/src/tools/lspDockerProxy.ts | 195 --------------- .../command/getExpectEventCommand.ts | 15 +- .../unitTestEditorViewProvider.ts | 54 +++- client/templates/IntegrationTestEditor.html | 2 +- client/templates/styles/main.css | 14 +- esbuild.js | 15 -- package.json | 27 +- package.nls.json | 5 +- package.nls.ru.json | 5 +- 19 files changed, 503 insertions(+), 464 deletions(-) delete mode 100644 client/src/ext/contextMenuExtension.ts delete mode 100644 client/src/l10n/messages.ts delete mode 100644 client/src/providers/xpReferenceProvider.ts delete mode 100644 client/src/tools/lspDockerProxy.ts diff --git a/client/src/ext/contextMenuExtension.ts b/client/src/ext/contextMenuExtension.ts deleted file mode 100644 index a0457bb8..00000000 --- a/client/src/ext/contextMenuExtension.ts +++ /dev/null @@ -1,91 +0,0 @@ -import * as vscode from 'vscode'; -import { DialogHelper } from '../helpers/dialogHelper'; -import { TestHelper } from '../helpers/testHelper'; -import { VsCodeApiHelper } from '../helpers/vsCodeApiHelper'; -import { ExceptionHelper } from '../helpers/exceptionHelper'; - -export class TestsFormatContentMenuExtension { - public static init(context: vscode.ExtensionContext) { - // Упаковка тестов перед сохранением. - vscode.workspace.onWillSaveTextDocument(async (e: vscode.TextDocumentWillSaveEvent) => { - const document = e.document; - if (document.languageId != 'test') return; - - const activeEditor = vscode.window.activeTextEditor; - const currDocument = activeEditor?.document; - if (!currDocument) { - await DialogHelper.showInfo('Документ для форматирования не открыт'); - return; - } - - try { - const testCode = currDocument.getText(); - const compressedTestCode = TestHelper.compressTestCode(testCode); - - // Заменяем текущий код на отформатированный. - const documentRange = VsCodeApiHelper.getDocumentRange(activeEditor); - activeEditor.edit((editBuilder) => { - editBuilder.replace(documentRange, compressedTestCode); - }); - } catch (error) { - ExceptionHelper.show(error, 'Не удалось упаковать кода теста'); - } - }); - - // Форматирование кода тестов при запросе форматирования. - const formatter = vscode.languages.registerDocumentFormattingEditProvider('test', { - provideDocumentFormattingEdits(document: vscode.TextDocument): vscode.TextEdit[] { - const originalTestCode = document.getText(); - const formattedTestCode = TestHelper.formatTestCodeAndEvents(originalTestCode); - const activeEditor = vscode.window.activeTextEditor; - if (!activeEditor) { - return []; - } - - const documentRage = VsCodeApiHelper.getDocumentRange(activeEditor); - return [vscode.TextEdit.replace(documentRage, formattedTestCode)]; - } - }); - context.subscriptions.push(formatter); - - // Форматирование кода теста для выделенного участка. - const rangeFormatter = vscode.languages.registerDocumentRangeFormattingEditProvider('test', { - provideDocumentRangeFormattingEdits: (document, range, options) => { - const originalTestCode = document.getText(range); - const formattedTestCode = TestHelper.formatTestCodeAndEvents(originalTestCode); - - return [vscode.TextEdit.replace(range, formattedTestCode)]; - } - }); - context.subscriptions.push(rangeFormatter); - - // Ручное или автоматическое обновление дерева контента - const compressTest = vscode.commands.registerCommand( - 'NativeEditorContextMenu.compressTest', - async (document: vscode.TextDocument) => { - const activeEditor = vscode.window.activeTextEditor; - const currDocument = activeEditor?.document; - if (!currDocument) { - await DialogHelper.showInfo('Документ для форматирования не открыт'); - return; - } - - try { - const testCode = currDocument.getText(); - const compressedTestCode = TestHelper.compressTestCode(testCode); - - // Заменяем текущий код на отформатированный. - const documentRange = VsCodeApiHelper.getDocumentRange(activeEditor); - activeEditor.edit((editBuilder) => { - editBuilder.replace(documentRange, compressedTestCode); - }); - } catch (error) { - ExceptionHelper.show(error, 'Не удалось упаковать код теста'); - } - }, - this - ); - - context.subscriptions.push(compressTest); - } -} diff --git a/client/src/extension.ts b/client/src/extension.ts index adffe28c..633d4c2f 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -2,6 +2,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import * as fs from 'fs'; import * as os from 'os'; +import { spawn } from 'child_process'; import { workspace, ExtensionContext } from 'vscode'; @@ -10,6 +11,7 @@ import { LanguageClient, LanguageClientOptions, ServerOptions, + StreamInfo, TransportKind } from 'vscode-languageclient/node'; @@ -43,15 +45,25 @@ import { MacOSContainerSetup } from './tools/macosContainerSetup'; import { XpDocumentFormattingProvider } from './providers/xpDocumentFormattingProvider'; export let Log: Logger; -let client: LanguageClient; +let client: LanguageClient | undefined; let siemCustomPackingTaskProvider: vscode.Disposable | undefined; interface LspStartupResult { - client: LanguageClient; + client?: LanguageClient; usesKbtFormatter: boolean; } class XpLanguageClient extends LanguageClient { + public constructor( + id: string, + name: string, + serverOptions: ServerOptions, + clientOptions: LanguageClientOptions, + private readonly disableWorkspaceConfigurationCapability = false + ) { + super(id, name, serverOptions, clientOptions); + } + protected fillInitializeParams(params: InitializeParams): void { super.fillInitializeParams(params); @@ -61,6 +73,10 @@ class XpLanguageClient extends LanguageClient { workspaceCapabilities.workspaceFolders = true; } + if (this.disableWorkspaceConfigurationCapability) { + workspaceCapabilities.configuration = false; + } + const textDocumentCapabilities = ((capabilities as any).textDocument ??= {}); const semanticTokens = (textDocumentCapabilities.semanticTokens ??= {}); semanticTokens.dynamicRegistration ??= false; @@ -127,6 +143,8 @@ export async function activate(context: ExtensionContext): Promise { await MacOSContainerSetup.maybePrompt(config); + await clearRuntimeDirectories(config); + // Конфигурирование LSP. const lspStartupResult = await configureLSPClient(context, config); client = lspStartupResult.client; @@ -178,9 +196,6 @@ export async function activate(context: ExtensionContext): Promise { new XPPackingTaskProvider(config) ); - // Расширение нативного контекстного меню. - // TestsFormatContentMenuExtension.init(context); - // Подпись функций. await XpSignatureHelpProvider.init(context); @@ -210,29 +225,6 @@ export async function activate(context: ExtensionContext): Promise { const legend = new vscode.SemanticTokensLegend(tokenTypes, tokenModifiers); await XpDocumentHighlightProvider.init(config, legend); - // Очистка директории временных файлов. - const tmpDirectory = config.getTmpDirectoryPath(); - if (fs.existsSync(tmpDirectory)) { - try { - await FileSystemHelper.deleteAllSubDirectoriesAndFiles(tmpDirectory); - Log.info(`The temporary files directory '${tmpDirectory}' was successfully cleared`); - } catch (error) { - Log.warn(`Error clearing files from temporary directory '${tmpDirectory}'`, error); - } - } - - // Очистка директории выходных файлов. Нужна для сохранения консистентности нормализаций. - const extensionSettings = config.getWorkspaceConfiguration(); - const outputDirectoryPath = extensionSettings.get('outputDirectoryPath'); - if (fs.existsSync(outputDirectoryPath)) { - try { - await FileSystemHelper.deleteAllSubDirectoriesAndFiles(outputDirectoryPath); - Log.info(`The output directory '${outputDirectoryPath}' was successfully cleared`); - } catch (error) { - Log.warn(`Error clearing files from output directory '${outputDirectoryPath}'`, error); - } - } - Log.info(`Extension '${Configuration.getExtensionDisplayName()}' is activated`); } catch (error) { ExceptionHelper.show( @@ -254,6 +246,29 @@ export async function deactivate(): Promise | undefined { return client.stop(); } +async function clearRuntimeDirectories(config: Configuration): Promise { + const tmpDirectory = config.getTmpDirectoryPath(); + if (fs.existsSync(tmpDirectory)) { + try { + await FileSystemHelper.deleteAllSubDirectoriesAndFiles(tmpDirectory); + Log.info(`The temporary files directory '${tmpDirectory}' was successfully cleared`); + } catch (error) { + Log.warn(`Error clearing files from temporary directory '${tmpDirectory}'`, error); + } + } + + const extensionSettings = config.getWorkspaceConfiguration(); + const outputDirectoryPath = extensionSettings.get('outputDirectoryPath'); + if (fs.existsSync(outputDirectoryPath)) { + try { + await FileSystemHelper.deleteAllSubDirectoriesAndFiles(outputDirectoryPath); + Log.info(`The output directory '${outputDirectoryPath}' was successfully cleared`); + } catch (error) { + Log.warn(`Error clearing files from output directory '${outputDirectoryPath}'`, error); + } + } +} + async function configureLSPClient( context: vscode.ExtensionContext, config: Configuration @@ -269,78 +284,84 @@ async function configureLSPClient( const initializationOptions = await buildKbtLspInitializationOptions(config); try { - if (config.getLSPMode() === 'legacy') { - Log.info('Skipping external KBT LSP startup because xpConfig.lspMode=legacy.'); - } else if (config.shouldUseDockerToolRunner()) { - const proxyModule = context.asAbsolutePath(path.join('client', 'out', 'lspDockerProxy.js')); - const extensionConfig = config.getWorkspaceConfiguration(); - const proxyConfig = { - containerName: extensionConfig.get('docker.containerName'), - workspaceHostPath: extensionConfig.get('docker.workspaceHostPath'), - workspaceContainerPath: extensionConfig.get('docker.workspaceContainerPath'), - kbtBaseDirectory: - extensionConfig.get('docker.kbtBaseDirectory') || '/home/coder/xp-kbt', - outputHostPath: extensionConfig.get('outputDirectoryPath'), - outputContainerPath: config.getDockerOutputDirectoryPath() - }; + if (config.isLocalMacOS()) { + const lspServerExecutablePath = config.getResolvedLSPServerExecutablePath(); + if (lspServerExecutablePath) { + Log.info(config.getMessage('LSPServer.ServerExecutableFoundAt', lspServerExecutablePath)); + const serverOptions = createNativeMacLspServerOptions(config, lspServerExecutablePath); - const encodedConfig = Buffer.from(JSON.stringify(proxyConfig), 'utf-8').toString('base64'); - const serverOptions: ServerOptions = { - command: process.execPath, - args: [proxyModule, encodedConfig], - transport: TransportKind.stdio, - options: { - cwd: __dirname - } - }; + const clientOptions: LanguageClientOptions = { + documentSelector, + synchronize: { + configurationSection: [config.getExtensionSettingsPrefix(), 'xplang_ls'] + }, + initializationOptions, + outputChannel: config.getOutputChannel() + }; - const clientOptions: LanguageClientOptions = { - documentSelector, - synchronize: { - configurationSection: [config.getExtensionSettingsPrefix(), 'xplang_ls'] - }, - initializationOptions - }; + const externalClient = new XpLanguageClient( + lspServerExecutablePath, + 'XP Language Server', + serverOptions, + clientOptions + ); - const dockerClient = new XpLanguageClient( - 'xpDockerLanguageServer', - 'XP Docker Language Server', - serverOptions, - clientOptions - ); + return externalClient + .start() + .then(() => { + notifyAboutStartedLspServer(config, externalClient, 'KBT XPLang LSP server'); + + return { + client: externalClient, + usesKbtFormatter: true + }; + }) + .catch((error) => { + vscode.window.showErrorMessage( + 'Failed to start XPLang Language Server: ' + error.message + ); + throw error; + }); + } - await dockerClient.start(); - notifyAboutStartedLspServer(config, dockerClient, 'Docker-backed XPLang LSP proxy'); Log.info( - `Docker-backed XPLang LSP proxy has started using '${proxyModule}' for container '${proxyConfig.containerName}'.` + 'Skipping XPLang LSP startup on macOS because xpConfig.lspServerExecutablePath is not configured. Legacy LSP is disabled on this platform.' ); return { - client: dockerClient, - usesKbtFormatter: true + client: undefined, + usesKbtFormatter: false }; + } + + if (config.getLSPMode() === 'legacy') { + Log.info('Skipping external KBT LSP startup because xpConfig.lspMode=legacy.'); + } else if (config.shouldUseDockerToolRunner()) { + Log.info( + 'Skipping external KBT LSP startup in Docker tool execution mode. Falling back to the legacy in-extension LSP because the containerized KBT LSP still requires additional configuration/taxonomy plumbing.' + ); } else { const lspServerExecutablePath = config.getResolvedLSPServerExecutablePath(); if (lspServerExecutablePath) { Log.info(config.getMessage('LSPServer.ServerExecutableFoundAt', lspServerExecutablePath)); - const command = lspServerExecutablePath; - const args: string[] = []; - - const serverOptions: ServerOptions = { - command, - args, - transport: TransportKind.stdio, - options: { - cwd: __dirname - } - }; + const serverOptions = config.isLocalMacOS() + ? createNativeMacLspServerOptions(config, lspServerExecutablePath) + : { + command: lspServerExecutablePath, + args: [], + transport: TransportKind.stdio, + options: { + cwd: __dirname + } + }; const clientOptions: LanguageClientOptions = { documentSelector, synchronize: { configurationSection: [config.getExtensionSettingsPrefix(), 'xplang_ls'] }, - initializationOptions + initializationOptions, + outputChannel: config.getOutputChannel() }; const externalClient = new XpLanguageClient( @@ -422,7 +443,8 @@ async function configureLSPClient( synchronize: { // Notify the server about file changes to '.clientrc files contained in the workspace fileEvents: workspace.createFileSystemWatcher('**/.clientrc') - } + }, + outputChannel: config.getOutputChannel() }; // Создаем клиент, запускаем его и сервер. @@ -448,18 +470,25 @@ async function buildKbtLspInitializationOptions(config: Configuration): Promise< }; try { + const shouldUseHostPathsForLsp = config.isLocalMacOS() && !!config.getResolvedLSPServerExecutablePath(); + const mapLspPath = (value: string) => + shouldUseHostPathsForLsp ? value : config.mapPathForExecution(value); + const taxonomyPath = config.craftLSPTaxonomyPath(); const taxonomyI18nPath = config.craftLSPi18nTaxonomyPath(); - initializationOptions.taxonomy_path = taxonomyPath; - initializationOptions.taxonomy_i18n_path = taxonomyI18nPath; await config.updateLSPTaxonomyPath(); await config.updateLSPi18nTaxonomyPath(); - const schemaPath = config.getKBTLSPConfiguration().get('schema_path'); - if (schemaPath) { - initializationOptions.schema_path = config.mapPathForExecution(schemaPath); - } + initializationOptions.taxonomy_path = mapLspPath(taxonomyPath); + initializationOptions.taxonomy_i18n_path = mapLspPath(taxonomyI18nPath); + + const schemaPath = await config.ensureLspSchemaPath(); + initializationOptions.schema_path = mapLspPath(schemaPath); + + Log.info( + `KBT LSP initialization options: taxonomy_path='${initializationOptions.taxonomy_path}', taxonomy_i18n_path='${initializationOptions.taxonomy_i18n_path}', schema_path='${initializationOptions.schema_path ?? ''}'` + ); } catch (error) { Log.warn(`Failed to prepare KBT LSP initialization options: ${error.message}`); } @@ -467,6 +496,37 @@ async function buildKbtLspInitializationOptions(config: Configuration): Promise< return initializationOptions; } +function createNativeMacLspServerOptions( + config: Configuration, + lspServerExecutablePath: string +): ServerOptions { + return async (): Promise => { + const child = spawn(lspServerExecutablePath, [], { + cwd: path.dirname(lspServerExecutablePath), + stdio: ['pipe', 'pipe', 'pipe'] + }); + + child.stderr.on('data', (chunk: Buffer | string) => { + const text = chunk.toString().trim(); + if (text) { + config.getOutputChannel().appendLine(text); + } + }); + + const reader = child.stdout; + const writer = child.stdin; + + if (!reader || !writer) { + throw new Error('Failed to initialize stdio pipes for native XPLang language server.'); + } + + return { + reader, + writer + }; + }; +} + function notifyAboutStartedLspServer( config: Configuration, startedClient: LanguageClient, diff --git a/client/src/helpers/yamlHelper.ts b/client/src/helpers/yamlHelper.ts index bd241b1c..fd8138c6 100644 --- a/client/src/helpers/yamlHelper.ts +++ b/client/src/helpers/yamlHelper.ts @@ -23,19 +23,20 @@ export class YamlHelper { const localizationDumpOptions = { ...this.dumpOptions }; localizationDumpOptions.forceQuotes = true; - return this.formatter(jsYaml.dump(object, localizationDumpOptions)); + return this.formatter(this.dumpToText(object, localizationDumpOptions)); } public static tableStringify(object: any): Promise { - return this.formatter(jsYaml.dump(object, this.dumpOptions).replace(/\n/g, os.EOL)); + return this.formatter(this.dumpToText(object, this.dumpOptions)); } public static stringify(object: any, styles?: any): Promise { + const dumpOptions = { ...this.dumpOptions }; if (styles !== undefined) { - this.dumpOptions.styles = styles; + dumpOptions.styles = styles; } - return this.formatter(jsYaml.dump(object, this.dumpOptions).replace(/\n/g, os.EOL)); + return this.formatter(this.dumpToText(object, dumpOptions)); } public static stringifyTable(object: any): string { @@ -58,6 +59,84 @@ export class YamlHelper { return jsYaml.load(str, this.loadOptions); } + private static dumpToText(object: any, dumpOptions: jsYaml.DumpOptions): string { + const text = jsYaml + .dump(object, dumpOptions) + .replace(/[ \t]+$/gm, '') + .replace(/\n/g, os.EOL); + + return this.normalizeSequenceMappings(text, dumpOptions.indent ?? 2); + } + + private static normalizeSequenceMappings(text: string, indentSize: number): string { + const normalizedEol = text.replace(/\r\n/g, '\n'); + const lines = normalizedEol.split('\n'); + const result: string[] = []; + let activeSequence: + | { + sourceChildIndent: string; + desiredDashIndent: string; + desiredChildIndent: string; + } + | undefined; + + for (let index = 0; index < lines.length; index++) { + const currentLine = lines[index]; + const previousLine = result[result.length - 1]; + const dashOnlyMatch = /^(\s*)-\s*$/.exec(currentLine); + + if (!dashOnlyMatch) { + if (currentLine.trim() !== '') { + activeSequence = undefined; + } + result.push(currentLine); + continue; + } + + const childIndex = index + 1; + const childLine = lines[childIndex]; + const childIndentMatch = childLine?.match(/^(\s+)\S/); + if (!childIndentMatch) { + result.push(currentLine); + continue; + } + + const childIndent = childIndentMatch[1]; + + if (!activeSequence) { + if (!previousLine?.trimEnd().endsWith(':')) { + result.push(currentLine); + continue; + } + + const previousIndent = previousLine.match(/^(\s*)/)?.[1].length ?? 0; + const desiredDashIndent = ' '.repeat(previousIndent + indentSize); + activeSequence = { + sourceChildIndent: childIndent, + desiredDashIndent, + desiredChildIndent: `${desiredDashIndent} ` + }; + } + + result.push(`${activeSequence.desiredDashIndent}- ${childLine.slice(childIndent.length)}`); + index = childIndex; + + while (index + 1 < lines.length) { + const nextLine = lines[index + 1]; + if (!nextLine.startsWith(activeSequence.sourceChildIndent) || nextLine.trim() === '') { + break; + } + + result.push( + `${activeSequence.desiredChildIndent}${nextLine.slice(activeSequence.sourceChildIndent.length)}` + ); + index++; + } + } + + return result.join(os.EOL); + } + private static dumpOptions: jsYaml.DumpOptions; private static loadOptions: jsYaml.LoadOptions; private static formatter: Formatter; diff --git a/client/src/l10n/messages.ts b/client/src/l10n/messages.ts deleted file mode 100644 index d2aef5ce..00000000 --- a/client/src/l10n/messages.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum DialogMessage { - Yes = 'Yes', - No = 'No' -} diff --git a/client/src/models/configuration.ts b/client/src/models/configuration.ts index ec2e5004..e5a441b7 100644 --- a/client/src/models/configuration.ts +++ b/client/src/models/configuration.ts @@ -98,6 +98,10 @@ export class Configuration { } public getLSPMode(): 'auto' | 'legacy' | 'kbt' { + if (this.isLocalMacOS()) { + return 'auto'; + } + const mode = this.getWorkspaceConfiguration().get<'auto' | 'legacy' | 'kbt'>('lspMode'); return mode ?? 'auto'; } @@ -108,6 +112,10 @@ export class Configuration { } public craftLSPTaxonomyPath(): string { + if (this.shouldUseNativeMacLspPaths()) { + return this.getNativeMacTaxonomyFullPath(); + } + return this.getTaxonomyFullPath(); } @@ -117,6 +125,10 @@ export class Configuration { } public craftLSPi18nTaxonomyPath(): string { + if (this.shouldUseNativeMacLspPaths()) { + return this.getNativeMacTaxonomyI18nDirPath(); + } + return path.join(this.getTaxonomyDirPath(), 'i18n'); } @@ -137,6 +149,95 @@ export class Configuration { configuration.update('schema_path', schemaPath); } + public async clearLSPSchemaPath(): Promise { + const configuration = this.getKBTLSPConfiguration(); + await configuration.update('schema_path', undefined, true, false); + } + + public async ensureLspSchemaPath(): Promise { + const configuredSchemaPath = this.getKBTLSPConfiguration().get('schema_path')?.trim(); + if (configuredSchemaPath && fs.existsSync(configuredSchemaPath)) { + return configuredSchemaPath; + } + + const contentRoots = this.getContentRoots(); + for (const contentRoot of this.getContentRoots()) { + const contentRootFolder = path.basename(contentRoot); + const schemaPath = this.getSchemaFullPath(contentRootFolder); + if (fs.existsSync(schemaPath)) { + await this.getKBTLSPConfiguration().update('schema_path', schemaPath, true, false); + return schemaPath; + } + } + + const placeholderRootFolder = + contentRoots.length > 0 ? path.basename(contentRoots[0]) : 'packages'; + const placeholderSchemaPath = this.getSchemaFullPath(placeholderRootFolder); + await fs.promises.mkdir(path.dirname(placeholderSchemaPath), { recursive: true }); + if (!fs.existsSync(placeholderSchemaPath)) { + await fs.promises.writeFile(placeholderSchemaPath, '{}', 'utf-8'); + } + + await this.getKBTLSPConfiguration().update('schema_path', placeholderSchemaPath, true, false); + return placeholderSchemaPath; + } + + private shouldUseNativeMacLspPaths(): boolean { + return this.isLocalMacOS() && !!this.getResolvedLSPServerExecutablePath(); + } + + private getNativeMacKbtBaseDirectory(): string { + const configuration = this.getWorkspaceConfiguration(); + const basePath = configuration.get('kbtBaseDirectory'); + if (basePath) { + if (!fs.existsSync(basePath)) { + throw new XpException(this.getMessage('Error.KbtDirectoryPathIsNoExist', basePath)); + } + + return basePath; + } + + const lspServerExecutablePath = this.getResolvedLSPServerExecutablePath(); + if (lspServerExecutablePath) { + const inferredBasePath = path.resolve(lspServerExecutablePath, '..', '..', '..'); + if (fs.existsSync(inferredBasePath)) { + return inferredBasePath; + } + } + + throw new XpException( + 'Local KBT base directory is not configured. Set xpConfig.kbtBaseDirectory or point xpConfig.lspServerExecutablePath to a binary inside /xp-sdk/cli/.' + ); + } + + private getNativeMacContractsDirectory(): string { + return path.join( + this.getNativeMacKbtBaseDirectory(), + 'knowledgebase', + Configuration.CONTRACTS_DIR_NAME + ); + } + + private getNativeMacTaxonomyFullPath(): string { + const fullPath = path.join( + this.getNativeMacContractsDirectory(), + Configuration.TAXONOMY_DIR_NAME, + 'taxonomy.json' + ); + this.checkReadablePath(fullPath); + return fullPath; + } + + private getNativeMacTaxonomyI18nDirPath(): string { + const fullPath = path.join( + this.getNativeMacContractsDirectory(), + Configuration.TAXONOMY_DIR_NAME, + 'i18n' + ); + this.checkReadablePath(fullPath); + return fullPath; + } + public getFirstWorkspaceFolder(): string { if (vscode.workspace.workspaceFolders.length === 0) { throw new XpException('Рабочая директория не найдена'); @@ -1154,26 +1255,59 @@ export class Configuration { return lspServerExecutablePath; } + public getDirectFormatterExecutablePath(): string { + const configuration = this.getWorkspaceConfiguration(); + const formatterExecutablePath = configuration.get('formatterExecutablePath'); + return formatterExecutablePath; + } + public getResolvedLSPServerExecutablePath(): string | undefined { if (this.getLSPMode() === 'legacy') { return undefined; } + const configuredPath = this.getDirectLSPServerExecutablePath(); + if (configuredPath) { + if (!fs.existsSync(configuredPath)) { + Log.warn(`Configured LSP server executable was not found: '${configuredPath}'.`); + return undefined; + } + + return configuredPath; + } + + if (this.isLocalMacOS()) { + return undefined; + } + if (this.shouldUseDockerToolRunner()) { return undefined; } - const configuredPath = this.getDirectLSPServerExecutablePath(); + return this.getKBTLSPFullPath() ?? undefined; + } + + public getResolvedFormatterExecutablePath(): string | undefined { + const configuredPath = this.getDirectFormatterExecutablePath(); if (configuredPath) { if (!fs.existsSync(configuredPath)) { - Log.warn(`Configured LSP server executable was not found: '${configuredPath}'.`); + Log.warn(`Configured formatter executable was not found: '${configuredPath}'.`); return undefined; } return configuredPath; } - return this.getKBTLSPFullPath() ?? undefined; + const kbtBaseDirectory = this.getKbtBaseDirectory(); + const formatterName = process.platform === 'win32' ? 'evt-xp-formatter.exe' : 'evt-xp-formatter'; + const formatterPath = path.join(kbtBaseDirectory, 'xp-sdk', 'cli', formatterName); + + if (!this.shouldUseDockerToolRunner() && !fs.existsSync(formatterPath)) { + Log.warn(`Can't find formatter executable in KBT directory: '${formatterPath}'.`); + return undefined; + } + + return formatterPath; } /** @@ -1226,11 +1360,18 @@ export class Configuration { * Automatically sets lspServerExecutablePath if not already set */ public autoSetLspServerExecutablePath(): void { - if (this.getLSPMode() === 'legacy') { + if (!this.isLocalMacOS() && this.getLSPMode() === 'legacy') { Log.info('Skipping KBT LSP auto-detection because xpConfig.lspMode=legacy.'); return; } + if (this.isLocalMacOS()) { + Log.info( + 'Skipping KBT LSP auto-detection on macOS. Set xpConfig.lspServerExecutablePath to use a native XPLang language server.' + ); + return; + } + if (this.shouldUseDockerToolRunner()) { Log.info( 'Skipping local KBT LSP auto-detection in Docker tool execution mode. External KBT LSP is not launched via Docker because host file URIs are not mapped into the LSP protocol yet.' diff --git a/client/src/models/siemj/siemjManager.ts b/client/src/models/siemj/siemjManager.ts index 455c831a..75080e7d 100644 --- a/client/src/models/siemj/siemjManager.ts +++ b/client/src/models/siemj/siemjManager.ts @@ -82,6 +82,8 @@ export class SiemjManager { throw new XpException('Ошибка компиляции схемы БД. Результирующий файл не создан'); } + this.config.updateLSPSchemaTaxonomyPath(schemaFilePath); + return schemaFilePath; } diff --git a/client/src/models/tests/correlationUnitTestsRunnerViaEvtTests.ts b/client/src/models/tests/correlationUnitTestsRunnerViaEvtTests.ts index 1638c18b..d9da32cc 100644 --- a/client/src/models/tests/correlationUnitTestsRunnerViaEvtTests.ts +++ b/client/src/models/tests/correlationUnitTestsRunnerViaEvtTests.ts @@ -84,7 +84,8 @@ export class CorrelationUnitTestsRunnerViaEvtTests implements UnitTestRunner { ], { encoding: 'utf-8', - outputChannel: this.config.getOutputChannel() + outputChannel: this.config.getOutputChannel(), + allowNonZeroExitCode: true } ); diff --git a/client/src/models/tests/normalizationUnitTestsRunnerViaEvtTests.ts b/client/src/models/tests/normalizationUnitTestsRunnerViaEvtTests.ts index af05dc17..192acff1 100644 --- a/client/src/models/tests/normalizationUnitTestsRunnerViaEvtTests.ts +++ b/client/src/models/tests/normalizationUnitTestsRunnerViaEvtTests.ts @@ -1,4 +1,3 @@ -import { ProcessHelper } from '../../helpers/processHelper'; import { Log } from '../../extension'; import { XpException } from '../xpException'; import { BaseUnitTest } from './baseUnitTest'; @@ -43,9 +42,10 @@ export class NormalizationUnitTestsRunnerViaEvtTests extends NormalizationUnitTe params = params.concat(['-x', this.config.getAppendixFullPath()]); } - const executeResult = await ProcessHelper.execute(evtTestsPath, params, { + const executeResult = await this.config.getToolRunner().runTool(evtTestsPath, params, { encoding: 'utf-8', - outputChannel: this.config.getOutputChannel() + outputChannel: this.config.getOutputChannel(), + allowNonZeroExitCode: true }); if (!executeResult.output) { diff --git a/client/src/providers/xpDocumentFormattingProvider.ts b/client/src/providers/xpDocumentFormattingProvider.ts index dcbb110a..a3c950f4 100644 --- a/client/src/providers/xpDocumentFormattingProvider.ts +++ b/client/src/providers/xpDocumentFormattingProvider.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode'; import { Log } from '../extension'; import { Configuration } from '../models/configuration'; import { XpException } from '../models/xpException'; +import { LocalToolRunner, ToolRunner } from '../tools/toolRunner'; export class XpDocumentFormattingProvider implements vscode.DocumentFormattingEditProvider { private static readonly SUPPORTED_LANGUAGES = ['xp', 'en', 'co', 'agr', 'flt']; @@ -27,7 +28,7 @@ export class XpDocumentFormattingProvider implements vscode.DocumentFormattingEd public async provideDocumentFormattingEdits( document: vscode.TextDocument ): Promise { - const formatterPath = this.getFormatterPath(); + const { formatterPath, runner } = this.getFormatterExecution(); const tempDirectory = this.config.getRandTmpSubDirectoryPath(); await fs.promises.mkdir(tempDirectory, { recursive: true }); @@ -38,7 +39,7 @@ export class XpDocumentFormattingProvider implements vscode.DocumentFormattingEd try { await fs.promises.writeFile(tempFilePath, document.getText(), 'utf-8'); - await this.config.getToolRunner().runTool(formatterPath, [tempFilePath], { + await runner.runTool(formatterPath, [tempFilePath], { encoding: 'utf-8', cwd: path.dirname(tempFilePath) }); @@ -63,17 +64,34 @@ export class XpDocumentFormattingProvider implements vscode.DocumentFormattingEd } } - private getFormatterPath(): string { - const kbtBaseDirectory = this.config.getKbtBaseDirectory(); - const formatterName = process.platform === 'win32' ? 'evt-xp-formatter.exe' : 'evt-xp-formatter'; - const formatterPath = path.join(kbtBaseDirectory, 'xp-sdk', 'cli', formatterName); + private getFormatterExecution(): { formatterPath: string; runner: ToolRunner } { + const configuredFormatterPath = this.config.getDirectFormatterExecutablePath(); + if (this.config.isLocalMacOS() && configuredFormatterPath) { + const resolvedFormatterPath = this.config.getResolvedFormatterExecutablePath(); + if (!resolvedFormatterPath) { + throw new XpException( + `XP formatter was not found at '${configuredFormatterPath}'. Update xpConfig.formatterExecutablePath or clear it to fall back to Docker formatting.` + ); + } + + return { + formatterPath: resolvedFormatterPath, + runner: new LocalToolRunner() + }; + } - if (!this.config.shouldUseDockerToolRunner() && !fs.existsSync(formatterPath)) { + const resolvedFormatterPath = this.config.getResolvedFormatterExecutablePath(); + if (!resolvedFormatterPath) { throw new XpException( - `XP formatter was not found at '${formatterPath}'. Install a KBT version that includes evt-xp-formatter or disable document formatting for XP files.` + this.config.shouldUseDockerToolRunner() + ? 'XP formatter path is not configured for macOS and Docker formatter could not be resolved.' + : 'XP formatter was not found. Install a KBT version that includes evt-xp-formatter or configure xpConfig.formatterExecutablePath.' ); } - return formatterPath; + return { + formatterPath: resolvedFormatterPath, + runner: this.config.getToolRunner() + }; } } diff --git a/client/src/providers/xpReferenceProvider.ts b/client/src/providers/xpReferenceProvider.ts deleted file mode 100644 index 1cd873c1..00000000 --- a/client/src/providers/xpReferenceProvider.ts +++ /dev/null @@ -1,21 +0,0 @@ -import * as vscode from 'vscode'; - -export class XpReferenceProvider implements vscode.ReferenceProvider { - public provideReferences( - document: vscode.TextDocument, - position: vscode.Position, - options: { includeDeclaration: boolean }, - token: vscode.CancellationToken - ): Thenable { - return Promise.resolve([ - new vscode.Location( - document.uri, - new vscode.Range(new vscode.Position(3, 1), new vscode.Position(3, 4)) - ), - new vscode.Location( - document.uri, - new vscode.Range(new vscode.Position(6, 1), new vscode.Position(6, 4)) - ) - ]); - } -} diff --git a/client/src/tools/lspDockerProxy.ts b/client/src/tools/lspDockerProxy.ts deleted file mode 100644 index 5619cc73..00000000 --- a/client/src/tools/lspDockerProxy.ts +++ /dev/null @@ -1,195 +0,0 @@ -import * as childProcess from 'child_process'; -import * as path from 'path'; -import { fileURLToPath, pathToFileURL } from 'url'; - -import { PathMapper, PathMapping } from './pathMapper'; - -interface ProxyConfig { - containerName: string; - workspaceHostPath: string; - workspaceContainerPath: string; - kbtBaseDirectory: string; - outputHostPath?: string; - outputContainerPath?: string; -} - -class LspMessageStream { - private buffer = Buffer.alloc(0); - - public constructor( - private readonly onMessage: (message: unknown) => void, - private readonly onError: (error: Error) => void - ) {} - - public push(chunk: Buffer): void { - this.buffer = Buffer.concat([this.buffer, chunk]); - - while (true) { - const separatorIndex = this.buffer.indexOf('\r\n\r\n'); - if (separatorIndex === -1) { - return; - } - - const header = this.buffer.slice(0, separatorIndex).toString('utf-8'); - const contentLength = this.parseContentLength(header); - if (contentLength === undefined) { - this.onError(new Error(`Invalid LSP header: ${header}`)); - return; - } - - const bodyStart = separatorIndex + 4; - const bodyEnd = bodyStart + contentLength; - if (this.buffer.length < bodyEnd) { - return; - } - - const body = this.buffer.slice(bodyStart, bodyEnd).toString('utf-8'); - this.buffer = this.buffer.slice(bodyEnd); - - try { - this.onMessage(JSON.parse(body)); - } catch (error) { - this.onError(error as Error); - } - } - } - - private parseContentLength(header: string): number | undefined { - const match = /Content-Length:\s*(\d+)/i.exec(header); - if (!match) { - return undefined; - } - - return Number(match[1]); - } -} - -class LspDockerProxy { - private readonly pathMapper: PathMapper; - private readonly languageServerPath: string; - - public constructor(private readonly config: ProxyConfig) { - const extraMappings: PathMapping[] = []; - if (config.outputHostPath && config.outputContainerPath) { - extraMappings.push({ - hostPath: config.outputHostPath, - containerPath: config.outputContainerPath - }); - } - - this.pathMapper = new PathMapper({ - workspaceHostPath: config.workspaceHostPath, - workspaceContainerPath: config.workspaceContainerPath, - extraMappings - }); - - this.languageServerPath = path.posix.join( - config.kbtBaseDirectory, - 'xp-sdk', - 'cli', - 'evt-xp-language-server' - ); - } - - public run(): void { - const dockerArgs = ['exec', '-i', this.config.containerName, this.languageServerPath]; - const child = childProcess.spawn('docker', dockerArgs, { - stdio: ['pipe', 'pipe', 'pipe'] - }); - - const clientInput = new LspMessageStream( - (message) => { - this.writeFramedMessage(child.stdin, this.mapMessage(message, 'hostToContainer')); - }, - (error) => this.logError(`Failed to decode client LSP message: ${error.message}`) - ); - - const serverInput = new LspMessageStream( - (message) => { - this.writeFramedMessage(process.stdout, this.mapMessage(message, 'containerToHost')); - }, - (error) => this.logError(`Failed to decode container LSP message: ${error.message}`) - ); - - process.stdin.on('data', (chunk: Buffer) => clientInput.push(chunk)); - child.stdout.on('data', (chunk: Buffer) => serverInput.push(chunk)); - child.stderr.on('data', (chunk: Buffer) => process.stderr.write(chunk)); - - process.stdin.on('end', () => child.stdin.end()); - process.stdin.on('error', (error) => this.logError(`Proxy stdin error: ${error.message}`)); - child.on('error', (error) => { - this.logError(`Failed to start Docker LSP proxy: ${error.message}`); - process.exit(1); - }); - child.on('close', (code) => process.exit(code ?? 0)); - - process.on('SIGINT', () => child.kill('SIGINT')); - process.on('SIGTERM', () => child.kill('SIGTERM')); - } - - private writeFramedMessage(stream: NodeJS.WritableStream, message: unknown): void { - const payload = Buffer.from(JSON.stringify(message), 'utf-8'); - stream.write(`Content-Length: ${payload.length}\r\n\r\n`); - stream.write(payload); - } - - private mapMessage( - value: unknown, - direction: 'hostToContainer' | 'containerToHost' - ): unknown { - if (typeof value === 'string') { - return this.mapString(value, direction); - } - - if (Array.isArray(value)) { - return value.map((item) => this.mapMessage(item, direction)); - } - - if (value && typeof value === 'object') { - return Object.fromEntries( - Object.entries(value).map(([key, nestedValue]) => [key, this.mapMessage(nestedValue, direction)]) - ); - } - - return value; - } - - private mapString(value: string, direction: 'hostToContainer' | 'containerToHost'): string { - if (value.startsWith('file://')) { - try { - const filePath = fileURLToPath(value); - const mappedPath = - direction === 'hostToContainer' - ? this.pathMapper.hostToContainer(filePath) - : this.pathMapper.containerToHost(filePath); - return pathToFileURL(mappedPath).toString(); - } catch { - return value; - } - } - - if (path.isAbsolute(value) || value.startsWith('/')) { - return direction === 'hostToContainer' - ? this.pathMapper.hostToContainer(value) - : this.pathMapper.containerToHost(value); - } - - return value; - } - - private logError(message: string): void { - process.stderr.write(`[xp-lsp-proxy] ${message}\n`); - } -} - -function readConfigFromArgs(): ProxyConfig { - const encodedConfig = process.argv[2]; - if (!encodedConfig) { - throw new Error('Missing Docker LSP proxy configuration.'); - } - - return JSON.parse(Buffer.from(encodedConfig, 'base64').toString('utf-8')) as ProxyConfig; -} - -const config = readConfigFromArgs(); -new LspDockerProxy(config).run(); diff --git a/client/src/views/integrationTests/command/getExpectEventCommand.ts b/client/src/views/integrationTests/command/getExpectEventCommand.ts index cff31168..467755f6 100644 --- a/client/src/views/integrationTests/command/getExpectEventCommand.ts +++ b/client/src/views/integrationTests/command/getExpectEventCommand.ts @@ -112,7 +112,18 @@ export class GetExpectedEventCommand { title: `Получение ожидаемого события для теста №${this.params.test.getNumber()}` }, async (progress) => { - const modularTestContent = `${integrationTestSimplifiedContent}\n\n${normalizedEvents}`; + const expectSectionMatch = + RegExpHelper.getExpectSectionRegExp().exec(integrationTestSimplifiedContent); + if (!expectSectionMatch) { + throw new XpException('Не удалось выделить секцию expect из интеграционного теста'); + } + + const expectSection = expectSectionMatch[0]; + const inputSection = integrationTestSimplifiedContent + .slice(0, expectSectionMatch.index) + .trimEnd(); + const modularTestContent = `${inputSection}\n${normalizedEvents}\n\n${expectSection}`; + const modularTestContentForRunner = TestHelper.compressTestCode(modularTestContent); // Сохраняем модульный тест во временный файл. const rootPath = this.params.config.getRootByPath(this.params.test.getRuleDirectoryPath()); @@ -124,7 +135,7 @@ export class GetExpectedEventCommand { randTmpPath, GetExpectedEventCommand.EXPECT_EVENT_FILENAME ); - await FileSystemHelper.writeContentFile(fastTestFilePath, modularTestContent); + await FileSystemHelper.writeContentFile(fastTestFilePath, modularTestContentForRunner); // Создаем временный модульный тест для быстрого тестирования. const fastTest = new FastTest(this.params.test.getNumber()); diff --git a/client/src/views/unitTestEditor/unitTestEditorViewProvider.ts b/client/src/views/unitTestEditor/unitTestEditorViewProvider.ts index 2f1ed76b..5f43623a 100644 --- a/client/src/views/unitTestEditor/unitTestEditorViewProvider.ts +++ b/client/src/views/unitTestEditor/unitTestEditorViewProvider.ts @@ -14,6 +14,8 @@ import { RuleBaseItem } from '../../models/content/ruleBaseItem'; import { Enrichment } from '../../models/content/enrichment'; import { Aggregation } from '../../models/content/aggregation'; import { TestStatus } from '../../models/tests/testStatus'; +import { CommonCommands } from '../../models/command/commonCommands'; +import { XpException } from '../../models/xpException'; enum CloseUnitTestsAnswer { Yes = 1, @@ -27,6 +29,7 @@ export class UnitTestContentEditorViewProvider extends WebViewProviderBase { 'ModularTestEditorView.onTestSelectionChange'; private rule: RuleBaseItem; + private toolBackendErrorShown = false; public constructor(private readonly config: Configuration) { super(); @@ -317,13 +320,15 @@ export class UnitTestContentEditorViewProvider extends WebViewProviderBase { private async runTest(testOrTestNumber: number | BaseUnitTest) { let test; let testNumber; + const isDirectInvocation = !(testOrTestNumber instanceof BaseUnitTest); - if (testOrTestNumber instanceof BaseUnitTest) { + if (!isDirectInvocation) { test = testOrTestNumber; testNumber = test.getNumber(); } else { test = this.rule.getUnitTestByNumber(testOrTestNumber); testNumber = testOrTestNumber; + this.toolBackendErrorShown = false; } if (!test) { @@ -368,7 +373,10 @@ export class UnitTestContentEditorViewProvider extends WebViewProviderBase { actualData: outputData }); - ExceptionHelper.show(error, 'Unexpected error while executing the modular test'); + const handled = await this.handleToolBackendError(error); + if (!handled) { + ExceptionHelper.show(error, 'Unexpected error while executing the modular test'); + } } finally { this._updateTestInWebview({ testNumber, @@ -381,6 +389,7 @@ export class UnitTestContentEditorViewProvider extends WebViewProviderBase { private async runAllTests() { const tests = this.rule.getUnitTests(); + this.toolBackendErrorShown = false; return vscode.window.withProgress( { @@ -411,6 +420,47 @@ export class UnitTestContentEditorViewProvider extends WebViewProviderBase { ); } + private async handleToolBackendError(error: unknown): Promise { + if (this.toolBackendErrorShown || !(error instanceof XpException)) { + return false; + } + + const message = error.message ?? ''; + if (!this.isToolBackendUnavailableMessage(message)) { + return false; + } + + this.toolBackendErrorShown = true; + + const outputAction = 'Show Output'; + const configureAction = 'Configure'; + const actions = this.config.isLocalMacOS() + ? [outputAction, configureAction] + : [outputAction]; + + const selection = await vscode.window.showErrorMessage(message, ...actions); + + if (selection === outputAction) { + await vscode.commands.executeCommand(CommonCommands.SHOW_OUTPUT_CHANNEL_COMMAND); + } else if (selection === configureAction) { + await vscode.commands.executeCommand( + CommonCommands.CONFIGURE_MACOS_CONTAINER_BACKEND_COMMAND + ); + } + + return true; + } + + private isToolBackendUnavailableMessage(message: string): boolean { + return [ + 'Docker is not installed or is not available in PATH', + 'Container not running.', + 'is not running. Start a container with XP tools, then retry.', + 'Path mapping failed.', + 'Tool not found in container' + ].some((part) => message.includes(part)); + } + private async _updateTestInWebview(payload: { testNumber: number; status?: 'Unknown' | 'Success' | 'Failed'; diff --git a/client/templates/IntegrationTestEditor.html b/client/templates/IntegrationTestEditor.html index 24b2ca7d..b1bd3ff6 100644 --- a/client/templates/IntegrationTestEditor.html +++ b/client/templates/IntegrationTestEditor.html @@ -16,7 +16,7 @@