diff --git a/.config/dictionary.txt b/.config/dictionary.txt index 64b7cf8e3..9e2708933 100644 --- a/.config/dictionary.txt +++ b/.config/dictionary.txt @@ -5,6 +5,7 @@ alefragnani alphanums ALS Anson +ansibug autofix autoupdate backticks @@ -22,6 +23,7 @@ contentmatches copyfiles cygwin dbaeumer +debuggee dedupe depcheck deps @@ -88,6 +90,7 @@ Pyenv pyparsing PYTHONBREAKPOINT PYTHONHOME +PYTHONPATH reindent reindented relogin diff --git a/package.json b/package.json index 76030ee94..9fc34dc4c 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,13 @@ "onCommand:extension.ansible-navigator.run", "onCommand:extension.ansible-playbook.run", "onCommand:extension.ansible.vault", + "onCommand:ansible.debugger.pickAnsiblePlaybook", + "onCommand:ansible.debugger.pickAnsibleProcess", "onLanguage:ansible", "onLanguage:yaml", "onCommand:extension.resync-ansible-inventory", - "workspaceContains:tox-ansible.ini" + "workspaceContains:tox-ansible.ini", + "onDebugResolve:ansible" ], "badges": [ { @@ -475,6 +478,29 @@ "order": 0 } } + }, + { + "title": "Debugger", + "properties": { + "ansible.debugger.logFile": { + "type": "string", + "default": "", + "markdownDescription": "Set to enable the debug server logging to the path set.", + "order": 0 + }, + "ansible.debugger.logLevel": { + "type": "string", + "default": "info", + "enum": [ + "info", + "debug", + "warning", + "error" + ], + "markdownDescription": "The logging level to configure for the debug server.", + "order": 1 + } + } } ], "configurationDefaults": { @@ -670,6 +696,203 @@ } } } + ], + "breakpoints": [ + { + "language": "ansible" + }, + { + "language": "yaml" + } + ], + "debuggers": [ + { + "type": "ansible", + "label": "Ansible Debug", + "languages": [ + "ansible" + ], + "variables": { + "PickAnsiblePlaybook": "ansible.debugger.pickAnsiblePlaybook", + "PickAnsibleProcess": "ansible.debugger.pickAnsibleProcess" + }, + "configurationAttributes": { + "attach": { + "properties": { + "processId": { + "type": [ + "string", + "number" + ], + "description": "The process id of the ansible-playbook process to attach to.", + "default": "${command:PickAnsibleProcess}" + }, + "address": { + "type": "string", + "description": "The host that is running the ansible-playbook process with the scheme tcp:// or uds://.", + "default": "tcp://remote-host:1234" + }, + "useTLS": { + "type": "boolean", + "description": "Wrap the communication socket with TLS to add server verification and encryption to the connection.", + "default": false + }, + "tlsVerification": { + "type": "string", + "description": "The TLS verification settings, defaults to verify but can be set to ignore to ignore the verification checks. Can also be set to the path of a file or directory to use as the CA trust store.", + "default": "verify" + }, + "tlsCertificate": { + "type": "string", + "description": "", + "default": "The path to a PEM encoded certificate, and optional key, to use for client certificate authentication with TLS. Use tlsKey if the path does not contain the key." + }, + "tlsKey": { + "type": "string", + "description": "", + "default": "The path to a PEM encoded key for the certificate used for client certificate authentication with TLS. If encrypted use tlsKeyPassword to supply the password." + }, + "tlsKeyPassword": { + "type": "string", + "description": "", + "default": "The password for the client certificate key if it is encrypted." + }, + "connectTimeout": { + "type": "float", + "description": "The timeout, in seconds, to wait when trying to attach to the ansible-playbook process.", + "default": 5 + }, + "pathMappings": { + "$id": "#pathMappings", + "type": "array", + "items": { + "type": "object", + "description": "The remote path prefix the Ansible playbook is running under and the local path prefix it maps to.", + "properties": { + "localRoot": { + "type": "string", + "description": "The local path root prefix this mapping applies to.", + "default": "${workspaceFolder}/" + }, + "remoteRoot": { + "type": "string", + "description": "The remote path root prefix this mapping applied to.", + "default": "" + } + }, + "required": [ + "localRoot", + "remoteRoot" + ] + }, + "description": "A list of case sensitive path mappings between a local and remote path. Multiple paths can be defined as needed.", + "default": [] + } + } + }, + "launch": { + "properties": { + "playbook": { + "type": "string", + "description": "The path to the Ansible playbook to launch.", + "default": "${command:PickAnsiblePlaybook}" + }, + "args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Command line arguments to pass to the ansible-playbook call, excluding the playbook itself.", + "default": [] + }, + "console": { + "type": "string", + "description": "Where to launch the debug target.", + "default": "integratedTerminal", + "enum": [ + "integratedTerminal", + "externalTerminal" + ] + }, + "cwd": { + "type": "string", + "description": "Absolute path to the working directory of the new ansible-playbook process that is spawned.", + "default": "${workspaceFolder}" + }, + "connectTimeout": { + "type": "float", + "description": "The timeout, in seconds, to wait for the new ansible-playbook process to connect back to the debug client before failing.", + "default": 5 + }, + "logFile": { + "type": "string", + "description": "The path to a file to log the ansibug debuggee logging entries to. Use logLevel to control the verbosity of these logs." + }, + "logLevel": { + "type": "string", + "description": "The level of logging to enable on the ansibug debuggee run. This is only enabled if logFile is also set.", + "default": "info", + "enum": [ + "info", + "debug", + "warning", + "error" + ] + }, + "pathMappings": { + "$ref": "#pathMappings" + } + }, + "required": [ + "playbook" + ] + } + }, + "configurationSnippets": [ + { + "label": "Ansible: Launch new ansible-playbook Process", + "description": "Launch a new ansible-playbook process", + "body": { + "name": "Ansible: Launch ansible-playbook Process", + "type": "ansible", + "request": "launch", + "playbook": "^\"\\${command:PickAnsiblePlaybook}\"" + } + }, + { + "label": "Ansible: Launch Current File", + "description": "Launch and debug the file in the currently active editor window", + "body": { + "name": "Ansible: Launch Current File", + "type": "ansible", + "request": "launch", + "playbook": "^\"\\${file}\"", + "cwd": "^\"\\${cwd}\"" + } + }, + { + "label": "Ansible: Attach to local ansible-playbook Process", + "description": "Attach the debugger to a locally running ansible-playbook process", + "body": { + "name": "Ansible: Attach to local ansible-playbook Process", + "type": "ansible", + "request": "attach", + "processId": "^\"\\${command:PickAnsibleProcess}\"" + } + }, + { + "label": "Ansible: Attach to remote ansible-playbook Process", + "description": "Attach the debugger to a remote ansible-playbook process", + "body": { + "name": "Ansible: Attach to remote ansible-playbook Process", + "type": "ansible", + "request": "attach", + "address": "tcp://target-host:1234" + } + } + ], + "initialConfigurations": [] + } ] }, "dependencies": { diff --git a/src/extension.ts b/src/extension.ts index 753fe5fe4..1fc901eb6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -29,6 +29,12 @@ import { /* local */ import { SettingsManager } from "./settings"; +import { + AnsibleDebugConfigurationProvider, + DebuggerManager, + DebuggerCommands, + createAnsibleDebugAdapter, +} from "./features/debugger"; import { AnsiblePlaybookRunProvider } from "./features/runner"; import { getConflictingExtensions, @@ -208,6 +214,41 @@ export async function activate(context: ExtensionContext): Promise { ) ); + // Debugging + const debuggerManager = new DebuggerManager(); + context.subscriptions.push( + vscode.debug.registerDebugAdapterDescriptorFactory("ansible", { + createDebugAdapterDescriptor: () => { + return createAnsibleDebugAdapter(extSettings.settings); + }, + }) + ); + + context.subscriptions.push( + vscode.debug.registerDebugConfigurationProvider( + "ansible", + new AnsibleDebugConfigurationProvider() + ) + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + DebuggerCommands.PICK_ANSIBLE_PLAYBOOK, + () => { + return debuggerManager.pickAnsiblePlaybook(); + } + ) + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + DebuggerCommands.PICK_ANSIBLE_PROCESS, + () => { + return debuggerManager.pickAnsibleProcess(); + } + ) + ); + // Listen for text selection changes context.subscriptions.push( vscode.window.onDidChangeTextEditorSelection(() => { diff --git a/src/features/debugger.ts b/src/features/debugger.ts new file mode 100644 index 000000000..59a248bba --- /dev/null +++ b/src/features/debugger.ts @@ -0,0 +1,148 @@ +import * as vscode from "vscode"; +import * as glob from "glob"; +import * as fs from "fs"; + +import { withPythonModule } from "./utils/commandRunner"; +import { ExtensionSettings } from "../interfaces/extensionSettings"; + +export class DebuggerCommands { + public static readonly PICK_ANSIBLE_PLAYBOOK = + "ansible.debugger.pickAnsiblePlaybook"; + public static readonly PICK_ANSIBLE_PROCESS = + "ansible.debugger.pickAnsibleProcess"; +} + +export class DebuggerManager { + constructor() {} + + /** + * Prompt the user for a playbook filename. + * @returns The entered playbook filename. + */ + public pickAnsiblePlaybook(): Thenable { + return vscode.window.showInputBox({ + title: "Enter Ansible Playbook File", + placeHolder: "Enter the name of the playbook file to debug.", + }); + } + + /** + * Prompts the user for a ansible-playbook process. + * @returns The process id selected. + */ + public pickAnsibleProcess(): Thenable { + // See get_pid_info_path() in ansibug + const tmpDir = process.env.TMPDIR || "/tmp"; + + const playbookProcesses: { label: string; description: string }[] = []; + for (const procFile of glob.globIterateSync(`${tmpDir}/ansibug-pid-*`)) { + const procInfoRaw = fs.readFileSync(procFile, "utf8"); + let procInfo; + try { + procInfo = JSON.parse(procInfoRaw); + } catch (SyntaxError) { + continue; + } + + if (procInfo.pid && this.isAlive(procInfo.pid)) { + playbookProcesses.push({ + label: procInfo.pid.toString(), + description: procInfo.playbook_file || "Unknown playbook", + }); + } + } + + if (playbookProcesses.length === 0) { + throw new Error( + "Cannot find an available ansible-playbook process to debug" + ); + } + + return vscode.window + .showQuickPick(playbookProcesses, { + canPickMany: false, + }) + .then((v) => v?.label); + } + + private isAlive(pid: number): boolean { + try { + // Signal of 0 checks if the process exists or not. + return process.kill(pid, 0); + } catch { + return false; + } + } +} + +export class AnsibleDebugConfigurationProvider + implements vscode.DebugConfigurationProvider +{ + /** + * Massage a debug configuration just before a debug session is being + * launched. This is used to provide the default debug launch configuration + * that launches the current file. + */ + resolveDebugConfiguration( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + folder: vscode.WorkspaceFolder | undefined, + config: vscode.DebugConfiguration, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + token?: vscode.CancellationToken + ): vscode.ProviderResult { + // if launch.json is missing or empty + if (!config.type && !config.request && !config.name) { + const editor = vscode.window.activeTextEditor; + + // Both ansible and yaml is used in case the file hasn't been explicitly + // marked as ansible and is just yaml. + if ( + (editor && editor.document.languageId === "ansible") || + editor?.document.languageId === "yaml" + ) { + config = { + request: "launch", + type: "ansible", + name: "Ansible: Run Current Playbook File", + playbook: "${file}", + }; + } + } + + // Ensures the Debug Console window isn't open by default, Ansible's + // debuggers works more with the terminal. + config.internalConsoleOptions = "neverOpen"; + + return config; + } +} + +/** + * Creates the debug adapter executable for debugging. + * @param settings - The extension settings. + * @returns The debug adapter executable. + */ +export function createAnsibleDebugAdapter( + settings: ExtensionSettings +): vscode.DebugAdapterExecutable { + const ansibugArgs = ["dap"]; + if (settings.debugger.logFile) { + ansibugArgs.push( + "--log-file", + settings.debugger.logFile, + "--log-level", + settings.debugger.logLevel + ); + } + + // FUTURE: inject PYTHONPATH with embedded ansibug module + const [command, commandArgs, newEnv] = withPythonModule( + settings, + "ansibug", + ansibugArgs + ); + const dapOptions: vscode.DebugAdapterExecutableOptions = { + env: newEnv, + }; + return new vscode.DebugAdapterExecutable(command, commandArgs, dapOptions); +} diff --git a/src/features/utils/commandRunner.ts b/src/features/utils/commandRunner.ts index 1b1d6ef9e..3213f21af 100644 --- a/src/features/utils/commandRunner.ts +++ b/src/features/utils/commandRunner.ts @@ -28,23 +28,107 @@ export function withInterpreter( const activationScript = settings.activationScript; if (activationScript) { - command = `bash -c 'source ${activationScript} && ${runExecutable} ${cmdArgs}'`; - return [command, undefined]; + const [command, shellArgs] = wrapWithActivationScript( + activationScript, + runExecutable, + cmdArgs + ); + return [`${command} ${shellArgs.join(" ")}`, undefined]; } const interpreterPath = settings.interpreterPath; if (interpreterPath && interpreterPath !== "") { - const virtualEnv = path.resolve(interpreterPath, "../.."); - - const pathEntry = path.join(virtualEnv, "bin"); if (path.isAbsolute(runExecutable)) { // if the user provided a path to the executable, we directly execute the app. command = `${runExecutable} ${cmdArgs}`; } + // emulating virtual environment activation script - newEnv["VIRTUAL_ENV"] = virtualEnv; - newEnv["PATH"] = `${pathEntry}:${process.env.PATH}`; + const venvVars = buildPythonVirtualEnvVars(interpreterPath); + for (const key in venvVars) { + newEnv[key] = venvVars[key]; + } delete newEnv.PYTHONHOME; } return [command, newEnv]; } + +/** + * Builds the command, args, and env vars needed to run a Python module. + * @param settings - The extension settings. + * @param module - The python module to invoke. + * @param moduleArgs - The arguments to invoke with the module. + * @returns The executable, arguments, and environment variables to run. + */ +export function withPythonModule( + settings: ExtensionSettings, + module: string, + moduleArgs: string[] +): [string, string[], { [key: string]: string }] { + let command: string = "python"; + + let commandArgs: string[] = ["-m", module]; + commandArgs.push(...moduleArgs); + + const newEnv: { [key: string]: string } = {}; + for (const e in process.env) { + newEnv[e] = process.env[e] ?? ""; + } + + const activationScript = settings.activationScript; + const interpreterPath = settings.interpreterPath; + if (activationScript) { + [command, commandArgs] = wrapWithActivationScript( + activationScript, + command, + commandArgs.join(" ") + ); + } else if (interpreterPath) { + const venvVars = buildPythonVirtualEnvVars(interpreterPath); + for (const key in venvVars) { + newEnv[key] = venvVars[key]; + } + delete newEnv.PYTHONHOME; + + command = interpreterPath; + } + + return [command, commandArgs, newEnv]; +} + +/** + * A helper method to wrap the executable in the bash compatible + * activation script. + * @param activationScript - The activation script to run before the command. + * @param executable - The executable to run with the activation script. + * @param cmdArgs - The command args to run with the activation script. + * @returns The wrapped command and args with the activation script. + */ +function wrapWithActivationScript( + activationScript: string, + executable: string, + cmdArgs: string +): [string, string[]] { + return [ + "bash", + ["-c", `source ${activationScript} && ${executable} ${cmdArgs}`], + ]; +} + +/** + * A helper method to build the env vars needed to run in a Python virtual + * environment. + * @param interpreterPath - The Python interpreter path. + * @returns The env vars that need to be set to run in the venv. + */ +function buildPythonVirtualEnvVars(interpreterPath: string): { + [key: string]: string; +} { + const virtualEnv = path.resolve(interpreterPath, "../.."); + const pathEntry = path.join(virtualEnv, "bin"); + + return { + VIRTUAL_ENV: virtualEnv, + PATH: `${pathEntry}${path.delimiter}${process.env.PATH}`, + }; +} diff --git a/src/interfaces/extensionSettings.ts b/src/interfaces/extensionSettings.ts index a706bba97..2c153e873 100644 --- a/src/interfaces/extensionSettings.ts +++ b/src/interfaces/extensionSettings.ts @@ -7,6 +7,7 @@ export interface ExtensionSettings { interpreterPath: string | undefined; executionEnvironment: ExecutionEnvironmentSettings; lightSpeedService: LightSpeedServiceSettings; + debugger: DebuggerSettings; } export interface IVolumeMounts { @@ -30,3 +31,8 @@ export interface LightSpeedServiceSettings { suggestions: { enabled: boolean }; model: string | undefined; } + +export interface DebuggerSettings { + logFile: string | null; + logLevel: string; +} diff --git a/src/settings.ts b/src/settings.ts index 1796cfbc9..a966f86ec 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -16,6 +16,8 @@ export class SettingsManager { ); const lightSpeedSettings = vscode.workspace.getConfiguration("ansible.lightspeed"); + const debuggerSettings = + vscode.workspace.getConfiguration("ansible.debugger"); this.settings = { activationScript: (await ansibleSettings.get( "python.activationScript" @@ -42,6 +44,10 @@ export class SettingsManager { }, model: lightSpeedSettings.get("modelIdOverride", undefined), }, + debugger: { + logFile: debuggerSettings.get("logFile", null), + logLevel: debuggerSettings.get("logLevel", "info"), + }, }; // Remove whitespace before and after the model ID and if it is empty, set it to undefined