-
Notifications
You must be signed in to change notification settings - Fork 13
Add MicrobotsLogAnalyzer custom ADO task support #141
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2e6f146
dc7143a
7fc7e6c
27f27da
c44d0b1
9049dd8
4aede03
6e0d4d0
63eaafa
8f3babb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,8 @@ | |
| # Log files | ||
| *.log | ||
|
|
||
| node_modules/ | ||
|
|
||
| # Byte-compiled / optimized / DLL files | ||
| __pycache__/ | ||
| *.py[codz] | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,194 @@ | ||
| "use strict"; | ||
|
|
||
| const fs = require("fs"); | ||
| const path = require("path"); | ||
| const { spawnSync } = require("child_process"); | ||
| const tl = require("azure-pipelines-task-lib/task"); | ||
| const { loginAzureRM } = require("azure-pipelines-tasks-azure-arm-rest/azCliUtility"); | ||
|
|
||
| const DEFAULT_TIMEOUT_SECONDS = "600"; | ||
| const VENV_NAME = "microbots-log-analyzer-venv"; | ||
| const VENV_READY_MARKER = ".microbots-venv-ready-v1"; | ||
|
|
||
| function runCommand(command, args, env) { | ||
| const result = spawnSync(command, args, { | ||
| stdio: ["ignore", "pipe", "pipe"], | ||
| env: env || process.env, | ||
| encoding: "utf8", | ||
| }); | ||
|
|
||
| if (result.stdout) { | ||
| if (process.stdout && typeof process.stdout.write === "function") process.stdout.write(result.stdout); | ||
| else console.log(result.stdout.trimEnd()); | ||
| } | ||
| if (result.stderr) { | ||
| if (process.stderr && typeof process.stderr.write === "function") process.stderr.write(result.stderr); | ||
| else console.error(result.stderr.trimEnd()); | ||
| } | ||
|
|
||
| if (result.error) throw new Error(`Failed to run ${command}: ${result.error.message}`); | ||
| if (result.status !== 0) { | ||
| const output = [result.stderr, result.stdout].filter(Boolean).join("\n").trim(); | ||
| const details = output ? `: ${output.split(/\r?\n/).slice(-10).join("\n")}` : ""; | ||
| throw new Error(`${command} ${args.join(" ")} -> exit ${result.status}${details}`); | ||
| } | ||
| } | ||
|
|
||
| function input(name, required) { | ||
| const value = tl.getInput(name, required); | ||
| return value ? value.trim() : value; | ||
| } | ||
|
|
||
| function azureSubscriptionInput() { | ||
| const value = input("azureSubscription", false) || input("serviceConnection", false); | ||
| if (!value) throw new Error("azureSubscription is required"); | ||
| return value; | ||
| } | ||
|
|
||
| function resolveLogPath(codebasePath, logFilePath) { | ||
| return path.isAbsolute(logFilePath) | ||
| ? path.resolve(logFilePath) | ||
| : path.resolve(codebasePath, logFilePath); | ||
| } | ||
|
|
||
| function getInputs() { | ||
| const inputs = { | ||
| serviceConnection: azureSubscriptionInput(), | ||
| deploymentName: input("deploymentName", true), | ||
| endpoint: input("endpoint", true), | ||
| apiVersion: input("apiVersion", true), | ||
| codebasePath: tl.getPathInput("codebasePath", true, true), | ||
| logFilePath: input("logFilePath", true), | ||
| timeoutSeconds: input("timeoutSeconds", false) || DEFAULT_TIMEOUT_SECONDS, | ||
|
MadhurAggarwal marked this conversation as resolved.
|
||
| maxIterations: input("maxIterations", false), | ||
| }; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As we publish to open-source, we should support key-based authentication too. Update the PR if it is trivial otherwise, we can take it in the next iteration.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sure, this would be a useful addition. |
||
|
|
||
| validateInputs(inputs); | ||
| return inputs; | ||
| } | ||
|
|
||
| function validateInputs(inputs) { | ||
| if (!fs.existsSync(inputs.codebasePath)) { | ||
| throw new Error(`codebasePath does not exist: ${inputs.codebasePath}`); | ||
| } | ||
|
|
||
| if (!fs.statSync(inputs.codebasePath).isDirectory()) { | ||
| throw new Error(`codebasePath must be a directory: ${inputs.codebasePath}`); | ||
| } | ||
|
|
||
| const logPath = resolveLogPath(inputs.codebasePath, inputs.logFilePath); | ||
| if (!fs.existsSync(logPath) || !fs.statSync(logPath).isFile()) { | ||
| throw new Error(`logFilePath does not exist: ${logPath}`); | ||
| } | ||
| inputs.logFilePath = logPath; | ||
|
|
||
| try { | ||
| const endpoint = new URL(inputs.endpoint); | ||
| if (endpoint.protocol !== "https:") throw new Error(); | ||
| } catch (_) { | ||
| throw new Error(`endpoint must be a valid HTTPS URL: ${inputs.endpoint}`); | ||
| } | ||
|
|
||
| const timeoutSeconds = Number(inputs.timeoutSeconds); | ||
| if (!Number.isSafeInteger(timeoutSeconds) || timeoutSeconds <= 0) { | ||
| throw new Error(`timeoutSeconds must be a positive integer: ${inputs.timeoutSeconds}`); | ||
| } | ||
| inputs.timeoutSeconds = String(timeoutSeconds); | ||
|
|
||
| if (inputs.maxIterations) { | ||
| const maxIterations = Number(inputs.maxIterations); | ||
| if (!Number.isSafeInteger(maxIterations) || maxIterations <= 0) { | ||
| throw new Error(`maxIterations must be a positive integer: ${inputs.maxIterations}`); | ||
| } | ||
| inputs.maxIterations = String(maxIterations); | ||
| } | ||
| } | ||
|
|
||
| async function loginWithServiceConnection(serviceConnection) { | ||
| console.log("##[section]MicrobotsLogAnalyzer: authenticating with Azure service connection"); | ||
| const previousAzureOutput = process.env.AZURE_CORE_OUTPUT; | ||
| const originalLoc = tl.loc; | ||
|
|
||
| process.env.AZURE_CORE_OUTPUT = "none"; | ||
| tl.loc = function loc(key, ...args) { | ||
| if (key === "LoginFailed" || key === "ErrorInSettingUpSubscription") return key; | ||
| return originalLoc(key, ...args); | ||
| }; | ||
|
|
||
| try { | ||
| await loginAzureRM(serviceConnection); | ||
| } catch (error) { | ||
| throw new Error(`Azure service connection login failed for '${serviceConnection}': ${error.message || String(error)}`); | ||
| } finally { | ||
| tl.loc = originalLoc; | ||
| if (previousAzureOutput === undefined) delete process.env.AZURE_CORE_OUTPUT; | ||
| else process.env.AZURE_CORE_OUTPUT = previousAzureOutput; | ||
| } | ||
|
|
||
| console.log("##[section]MicrobotsLogAnalyzer: Azure authentication complete"); | ||
| } | ||
|
|
||
| function venvPythonPath(venvDir) { | ||
| return process.platform === "win32" | ||
| ? path.join(venvDir, "Scripts", "python.exe") | ||
| : path.join(venvDir, "bin", "python"); | ||
| } | ||
|
|
||
| function setupVenv() { | ||
| const venvRoot = process.env.AGENT_TEMPDIRECTORY | ||
| || process.env.PIPELINE_WORKSPACE | ||
| || process.env.RUNNER_TEMP | ||
| || "/tmp"; | ||
| const venvDir = path.join(venvRoot, VENV_NAME); | ||
| const python = venvPythonPath(venvDir); | ||
| const venvReadyFile = path.join(venvDir, VENV_READY_MARKER); | ||
|
|
||
| if (fs.existsSync(python) && fs.existsSync(venvReadyFile)) { | ||
| console.log(`##[section]MicrobotsLogAnalyzer: reusing Python environment at ${venvDir}`); | ||
| return python; | ||
| } | ||
|
|
||
| if (fs.existsSync(venvDir)) fs.rmSync(venvDir, { recursive: true, force: true }); | ||
|
|
||
| console.log(`##[section]MicrobotsLogAnalyzer: creating Python environment at ${venvDir}`); | ||
| runCommand("python3", ["-m", "venv", venvDir]); | ||
| console.log("Installing Python dependencies (microbots, Azure identity)..."); | ||
| runCommand(python, ["-m", "pip", "install", "--quiet", "--upgrade", "pip"]); | ||
| runCommand(python, ["-m", "pip", "install", "--quiet", "microbots[azure_ad]"]); | ||
| fs.writeFileSync(venvReadyFile, new Date().toISOString()); | ||
|
|
||
| return python; | ||
| } | ||
|
|
||
| function microbotsEnvironment(inputs) { | ||
| return Object.assign({}, process.env, { | ||
| AZURE_OPENAI_DEPLOYMENT_NAME: inputs.deploymentName, | ||
| AZURE_OPENAI_ENDPOINT: inputs.endpoint, | ||
| AZURE_OPENAI_API_VERSION: inputs.apiVersion, | ||
| }); | ||
| } | ||
|
|
||
| function runLogAnalyzer(python, inputs) { | ||
| const scriptPath = path.join(__dirname, "log_analyzer_runner.py"); | ||
| const args = [scriptPath, inputs.codebasePath, inputs.logFilePath, inputs.timeoutSeconds]; | ||
|
|
||
| if (inputs.maxIterations) args.push(inputs.maxIterations); | ||
|
|
||
| runCommand(python, args, microbotsEnvironment(inputs)); | ||
| } | ||
|
|
||
| async function run() { | ||
| try { | ||
| const inputs = getInputs(); | ||
| await loginWithServiceConnection(inputs.serviceConnection); | ||
| const python = setupVenv(); | ||
| runLogAnalyzer(python, inputs); | ||
| tl.setResult(tl.TaskResult.Succeeded, "LogAnalysisBot completed"); | ||
| } catch (error) { | ||
| tl.setResult(tl.TaskResult.Failed, error.message || String(error)); | ||
| } finally { | ||
| try { spawnSync("az", ["account", "clear"], { stdio: "ignore" }); } catch (_) {} | ||
| } | ||
| } | ||
|
|
||
| run(); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| import os | ||
| import sys | ||
| import textwrap | ||
|
|
||
| from azure.identity import AzureCliCredential, get_bearer_token_provider | ||
| from microbots import LogAnalysisBot | ||
|
|
||
|
|
||
| def is_docker_access_error(error): | ||
| return "docker" in type(error).__module__.lower() or "docker" in str(error).lower() | ||
|
|
||
|
|
||
| def main(): | ||
| codebase_path = os.path.abspath(sys.argv[1]) | ||
| log_file_path = sys.argv[2] | ||
| timeout_seconds = int(sys.argv[3]) | ||
| max_iterations = int(sys.argv[4]) if len(sys.argv) > 4 else None | ||
|
|
||
| os.chdir(codebase_path) | ||
| print( | ||
| f"MicrobotsLogAnalyzer: analyzing {log_file_path} with deployment " | ||
| f"{os.environ['AZURE_OPENAI_DEPLOYMENT_NAME']}", | ||
| flush=True, | ||
| ) | ||
| print(f"MicrobotsLogAnalyzer: timeout is {timeout_seconds} seconds", flush=True) | ||
| if max_iterations is not None: | ||
| print(f"MicrobotsLogAnalyzer: max iterations is {max_iterations}", flush=True) | ||
|
|
||
| token_provider = get_bearer_token_provider( | ||
| AzureCliCredential(), | ||
| "https://cognitiveservices.azure.com/.default", | ||
| ) | ||
| run_kwargs = { | ||
| "file_name": log_file_path, | ||
| "timeout_in_seconds": timeout_seconds, | ||
| } | ||
| if max_iterations is not None: | ||
| run_kwargs["max_iterations"] = max_iterations | ||
|
|
||
| try: | ||
| bot = LogAnalysisBot( | ||
| model=f"azure-openai/{os.environ['AZURE_OPENAI_DEPLOYMENT_NAME']}", | ||
| folder_to_mount=codebase_path, | ||
| token_provider=token_provider, | ||
| ) | ||
| result = bot.run(**run_kwargs) | ||
| except Exception as error: | ||
| if not is_docker_access_error(error): | ||
| raise | ||
| print( | ||
| "MicrobotsLogAnalyzer: Docker-compatible daemon was not accessible " | ||
| "while starting the Microbots sandbox.", | ||
| file=sys.stderr, | ||
| ) | ||
| print(f"Details: {error}", file=sys.stderr) | ||
| return 1 | ||
|
|
||
| message = result.result or result.error or "" | ||
|
|
||
| print("##[section]MicrobotsLogAnalyzer: LLM analysis") | ||
| print("============================================================") | ||
| print("MICROBOTS LOG ANALYSIS") | ||
| print("============================================================") | ||
| for paragraph in str(message).splitlines() or [""]: | ||
| print(textwrap.fill(paragraph, width=125) if paragraph.strip() else "") | ||
| print("============================================================") | ||
|
|
||
| return 0 if result.status else 1 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main()) |
Uh oh!
There was an error while loading. Please reload this page.