diff --git a/_tasks/_build.ps1 b/_tasks/_build.ps1 index 84ca8be..5059f41 100644 --- a/_tasks/_build.ps1 +++ b/_tasks/_build.ps1 @@ -16,7 +16,8 @@ $paths = @( "./Publish-BCModuleToTenant", "./Build-ALPackage", "./Get-BCDependencies", - "./Get-VSIXCompiler" + "./Get-VSIXCompiler", + "./Enumerate-Environment" ) foreach($path in $paths) { diff --git a/_tasks/environments.json b/_tasks/environments.json index 84b8b2a..0dfb84c 100644 --- a/_tasks/environments.json +++ b/_tasks/environments.json @@ -2,7 +2,7 @@ "version": { "major": 0, "minor": 1, - "patch": 9, + "patch": 10, "build": 0 }, "dev": { @@ -38,6 +38,10 @@ "EGDeployBCModule": { "location": "bc-tools-extension/Publish-BCModuleToTenant/task.json", "taskGuid": "def7c0a0-0d00-4f62-ae3f-7f084561e721" + }, + "EGEnumerateEnvironment": { + "location": "bc-tools-extension/Enumerate-Environment/task.json", + "taskGuid": "16821c4a-f2e7-4148-a686-2ec76cab618c" } } }, @@ -74,6 +78,10 @@ "EGDeployBCModule": { "location": "bc-tools-extension/Publish-BCModuleToTenant/task.json", "taskGuid": "7315a985-6da9-4b4a-bae9-56b04fc492fd" + }, + "EGEnumerateEnvironment": { + "location": "bc-tools-extension/Enumerate-Environment/task.json", + "taskGuid": "206d9815-c3dd-459f-b3c1-41e8b18dddab" } } } diff --git a/bc-tools-extension/Enumerate-Environment/function_Enumerate-Environment.js b/bc-tools-extension/Enumerate-Environment/function_Enumerate-Environment.js new file mode 100644 index 0000000..ecbe475 --- /dev/null +++ b/bc-tools-extension/Enumerate-Environment/function_Enumerate-Environment.js @@ -0,0 +1,207 @@ +const { execSync } = require('child_process'); +const path = require('path'); +const os = require('os'); +const fs = require('fs'); +const { PassThrough } = require('stream'); +const { logger, parseBool, getToken, normalizePath } = require(path.join(__dirname, '_common', 'CommonTools.js')); + +const inputFilenameAndPath = process.env.INPUT_FILEPATHANDNAME; + +// this routine is intended to provide information about the agent on which it is running +// +// 1. platform +// 2. whoami +// 3. current working directory +// 4. Powershell version(s) +// 5. BCContainerHelper existence / version +// 6. Docker existence / version +// 7. Docker image list + +(async () => { + let outputFilenameAndPath; + + if (inputFilenameAndPath && inputFilenameAndPath.trim() !== '') { + outputFilenameAndPath = normalizePath(inputFilenameAndPath); + const pathInfo = path.parse(outputFilenameAndPath); + + if (!pathInfo.base || !pathInfo.dir) { + logger.warn(`Invalid file path supplied: '${outputFilenameAndPath}'. Skipping file production.`); + outputFilenameAndPath = undefined; + } + } + + logger.info('Invoking EGEnumerateEnvironment with the following parameters:'); + logger.info('FilePathAndName:'.padStart(2).padEnd(30) + `${outputFilenameAndPath}`); + logger.info(''); + + // 0. setup + const logColWidth = 30; + + // 1. platform + logger.info('[platform]:'.padEnd(logColWidth) + `${os.platform()}`); + + // 2. whoami + let textOut; + try { + let whoami = execSync('whoami', { encoding: 'utf8' }); + textOut = whoami.toString().trim(); + if (textOut.length > 0) { + logger.info('[whoami]: '.padEnd(logColWidth) + `${textOut}`); + } else { + logger.info('[whoami]:'.padEnd(logColWidth) + 'Apparently a ghost; nothing returned'); + } + } catch (err) { + logger.error(`[whoami]: Encountered an error while executing a 'whoami'`); + logger.error(`[whoami]: Error: ${err}`); + } + + // 3. current working directory + logger.info('[current working directory]:'.padEnd(logColWidth) + `${process.cwd()}`); + + // 4. Powershell version(s) + let psVersion; + let pwshVersion; + if (os.platform() === "win32") { + try { + psVersion = execSync( + `powershell -NoProfile -Command "$v = $PSVersionTable.PSVersion; Write-Output ('' + $v.Major + '.' + $v.Minor + '.' + $v.Build + '.' + $v.Revision)"`, + { encoding: 'utf8' } + ).trim(); + logger.info('[powershell version]:'.padEnd(logColWidth) + `${psVersion}`); + } catch (err) { + logger.error(`[powershell version]: Encountered an error while executing a 'powerhsell version'`); + logger.error(`[powershell version]: Error: ${err}`); + } + } else { + psVersion = "[not installed; Linux environment]"; + logger.info('[powershell version]:'.padEnd(logColWidth) + `${psVersion}`); + } + + try { + const isLinux = process.platform === 'linux'; + + const psCommandRaw = '$PSVersionTable.PSVersion.ToString()'; + const psCommand = isLinux + ? psCommandRaw.replace(/(["\\$`])/g, '\\$1') // escape for bash + : psCommandRaw; // don't escape on Windows + const fullCommand = `pwsh -NoProfile -Command "${psCommand}"`; + //const quotedCommand = `"${psCommand.replace(/"/g, '\\"')}"`; + pwshVersion = execSync(fullCommand, { encoding: 'utf8' }).trim(); + logger.info('[pwsh version]:'.padEnd(logColWidth) + `${pwshVersion}`); + } catch (err) { + logger.error(`[pwsh version]: Encountered an error while executing a 'pwsh version'`); + logger.error(`[pwsh version]: Error: ${err}`); + } + + // 5. BCContainerHelper existence / version + let result; + let BCContainerHelperPresent = false; + + if (os.platform() === "win32") { + try { + const psCommand = `$modulePath = Get-Module -ListAvailable BCContainerHelper | Select-Object -First 1 -ExpandProperty Path; if ($modulePath) { $psd1 = $modulePath -replace '\\[^\\\\]+$', '.psd1'; if (Test-Path $psd1) { $lines = Get-Content $psd1 -Raw; if ($lines -match 'ModuleVersion\\s*=\\s*[\\"\\'']?([0-9\\.]+)[\\"\\'']?') { Write-Output $matches[1]; } else { Write-Output '[version not found]'; } } else { Write-Output '[not installed]'; } } else { Write-Output '[not installed]'; }`; + + result = execSync(`powershell.exe -NoProfile -Command "${psCommand.replace(/\n/g, ' ').replace(/"/g, '\\"')}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + if (result === "") { result = '[not installed]' } + if (result && result != "") { + logger.info('[BCContainerHelper version]:'.padEnd(logColWidth) + `${result}`); + BCContainerHelperPresent = true; + } + BCContainerHelperPresent = true; + } catch (err) { + logger.error(`[BCContainerHelper]: Failed to query module: ${err.message}`); + logger.info(err); + } + } else { + result = "[not installed; Linux environment]"; + logger.info('[BCContainerHelper version]:'.padEnd(logColWidth) + `${result}`); + } + + // 6. Docker existence / version + let DockerPresent = false; + let DockerVersionResult; + try { + DockerResult = execSync('docker version --format "{{.Client.Version}}"', { stdio: ['pipe', 'pipe', 'pipe'] }); + if (DockerResult === "") { DockerVersionResult = '[not installed]' } + else { DockerVersionResult = DockerResult.toString().trim(); } + if (DockerVersionResult && DockerVersionResult != "") { + logger.info('[dockerversion]:'.padEnd(logColWidth) + `${DockerVersionResult}`); + DockerPresent = true; + } + } catch (err) { + const msg = err.message || ''; + const stderr = err.stderr?.toString() || ''; + + const combined = `${msg}\n${stderr}`; + const normalized = combined.toLowerCase(); + if ( + normalized.includes("'docker' is not recognized") || // Windows case + normalized.includes("command not found") || // Linux case + normalized.includes("no such file or directory") // fallback + ) { + DockerVersionResult = '[not installed]'; + if (DockerVersionResult && DockerVersionResult != "") { + logger.info('[dockerversion]:'.padEnd(logColWidth) + `${DockerVersionResult}`); + } + } else { + logger.error(`[dockerversion]: Unexpected error: ${combined}`); + } + } + + // 7. Docker image list + let DockerPsObject; + if (DockerPresent) { + try { + const psResult = execSync('docker ps -a --no-trunc --format "{{json .}}"', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); + const lines = psResult.trim().split('\n'); + DockerPsObject = lines.filter(line => line && line.trim().startsWith('{') && line.trim().endsWith('}')).map(line => JSON.parse(line)); + + if (DockerPsObject.length > 0) { + logger.info('[dockerimage]:'.padEnd(logColWidth) + '**Name**'.padEnd(logColWidth) + '**Status**'); + DockerPsObject.forEach((image, idx) => { + if (image && image.name != "") { + logger.info('[dockerimage]:'.padEnd(logColWidth) + `${image.Names}`.padEnd(logColWidth) + `${image.Status}`); + } + }); + } else { + logger.info('[dockerimage]:'.padEnd(logColWidth) + '[no images]'); + } + } catch (err) { + const msg = err.message || ''; + const stderr = err.stderr?.toString() || ''; + + const combined = `${msg}\n${stderr}`; + logger.error(`[dockerimage]: Unexpected error: ${combined}`); + } + } else { + logger.info('[dockerimage]:'.padEnd(logColWidth) + '[not installed]'); + } + + // Deal with the file if requested (note it has already been parsed at the top of this routine) + if (outputFilenameAndPath) { + + let dockerList = []; + try { + dockerList = DockerPsObject.filter(img => img && img.Names).map(img => ({ name: img.Names, status: img.Status })); + } catch { + dockerList = []; + } + + let candidateFile = { + platform: os.platform(), + whoami: textOut, + workingDirectory: process.cwd(), + powershellVersion: psVersion, + pscoreVersion: pwshVersion, + bcContainerVersion: result, + dockerVersion: DockerVersionResult, + dockerImages: dockerList + } + + let candidateFileString = JSON.stringify(candidateFile); + fs.writeFileSync(outputFilenameAndPath, candidateFileString); + + logger.info(''); + logger.info(`Produced file at: ${outputFilenameAndPath}`); + } +})(); \ No newline at end of file diff --git a/bc-tools-extension/Enumerate-Environment/task.json b/bc-tools-extension/Enumerate-Environment/task.json new file mode 100644 index 0000000..ca8b4ef --- /dev/null +++ b/bc-tools-extension/Enumerate-Environment/task.json @@ -0,0 +1,41 @@ +{ + "id": "0d4e6693-bdcb-47c0-a373-67a34549da07", + "name": "EGEnumerateEnvironment", + "friendlyName": "Enumerate compiling environment", + "description": "Provides information about the pipeline environment in the context of the agent.", + "helpMarkDown": "Please open a GitHub issue at https://github.com/crazycga/bcdevopsextension/issues for queries or support.", + "category": "Build", + "author": "Evergrowth Consulting", + "version": { + "Major": 0, + "Minor": 1, + "Patch": 5 + }, + "instanceNameFormat": "Enumerate compiling environment", + "inputs": [ + { + "name": "ProduceFile", + "type": "boolean", + "label": "Produce file", + "defaultValue": false, + "required": false, + "helpMarkDown": "Specifies whether or not to produce an output file as one of the artifacts." + }, + { + "name": "FilePathAndName", + "type": "string", + "label": "Output file path and name", + "defaultValue": "$(Build.ArtifactStagingDirectory)/environment.$(System.StageName).$(Agent.JobName).$(Build.BuildId).json", + "required": false, + "helpMarkDown": "The output path and name of the output file if specified; default '$(Build.ArtifactStagingDirectory)/environment.$(System.StageName).$(Agent.JobName).$(Build.BuildId).json'" + } + ], + "execution": { + "Node16": { + "target": "function_Enumerate-Environment.js" + }, + "Node20_1": { + "target": "function_Enumerate-Environment.js" + } + } +} diff --git a/bc-tools-extension/README.md b/bc-tools-extension/README.md index 3e44b1b..aea7bbb 100644 --- a/bc-tools-extension/README.md +++ b/bc-tools-extension/README.md @@ -2,23 +2,27 @@ # Business Central Build Tasks for Azure DevOps - * [Overview](#overview) - * [Features](#features) - * [Installation](#installation) - * [Other Requirements](#other-requirements) - + [Azure AD App Registration](#azure-ad-app-registration) - + [Business Central Configuration](#business-central-configuration) - + [Setup Complete](#setup-complete) - * [Tasks Included](#tasks-included) - + [1. Get AL Compiler (`EGGetALCompiler`)](#1-get-al-compiler-eggetalcompiler) - + [2. Get AL Dependencies (`EGGetALDependencies`)](#2-get-al-dependencies-eggetaldependencies) - + [3. Build AL Package (`EGALBuildPackage`)](#3-build-al-package-egalbuildpackage) - + [4. Get List of Companies (`EGGetBCCompanies`)](#4-get-list-of-companies-eggetbccompanies) - + [5. Get List of Extensions (`EGGetBCModules`)](#5-get-list-of-extensions-eggetbcmodules) - + [6. Publish Extension to Business Central (`EGDeployBCModule`)](#6-publish-extension-to-business-central-egdeploybcmodule) - * [Example Pipeline](#example-pipeline) - * [Security & Trust](#security--trust) - * [Support](#support) +- [Business Central Build Tasks for Azure DevOps](#business-central-build-tasks-for-azure-devops) + - [Overview](#overview) + - [Features](#features) + - [Installation](#installation) + - [Other Requirements](#other-requirements) + - [Azure AD App Registration](#azure-ad-app-registration) + - [Business Central Configuration](#business-central-configuration) + - [Setup Complete](#setup-complete) + - [Tasks Included](#tasks-included) + - [1. Get AL Compiler (`EGGetALCompiler`)](#1-get-al-compiler-eggetalcompiler) + - [2. Get AL Dependencies (`EGGetALDependencies`)](#2-get-al-dependencies-eggetaldependencies) + - [3. Build AL Package (`EGALBuildPackage`)](#3-build-al-package-egalbuildpackage) + - [4. Get List of Companies (`EGGetBCCompanies`)](#4-get-list-of-companies-eggetbccompanies) + - [5. Get List of Extensions (`EGGetBCModules`)](#5-get-list-of-extensions-eggetbcmodules) + - [6. Publish Extension to Business Central (`EGDeployBCModule`)](#6-publish-extension-to-business-central-egdeploybcmodule) + - [7. Enumerate Environment (`EnumerateEnvironment`)](#7-enumerate-environment-enumerateenvironment) + - [Example Pipeline](#example-pipeline) + - [Common Failures](#common-failures) + - [Security \& Trust](#security--trust) + - [Support](#support) + - [License](#license) ## Overview @@ -32,7 +36,11 @@ This Azure DevOps extension provides build pipeline tasks for Microsoft Dynamics [![main-build](https://github.com/crazycga/bcdevopsextension/actions/workflows/mainbuild.yml/badge.svg?branch=main)](https://github.com/crazycga/bcdevopsextension/actions/workflows/mainbuild.yml) -[![dev-trunk](https://github.com/crazycga/bcdevopsextension/actions/workflows/mainbuild.yml/badge.svg?branch=dev_trunk)](https://github.com/crazycga/bcdevopsextension/actions/workflows/mainbuild.yml) +[![dev-trunk](https://img.shields.io/github/actions/workflow/status/crazycga/bcdevopsextension/mainbuild.yml?branch=dev_trunk&label=development)](https://github.com/crazycga/bcdevopsextension/actions/workflows/mainbuild.yml) + +![GitHub Release](https://img.shields.io/github/v/release/crazycga/bcdevopsextension) + +![Visual Studio Marketplace Installs - Azure DevOps Extension](https://img.shields.io/visual-studio-marketplace/azure-devops/installs/total/Evergrowth.eg-bc-build-tasks) ## Features @@ -239,6 +247,42 @@ There is not much more control that is provided and even the response codes from |Input|`PollingFrequency`||`10`|The number of **seconds** to wait between attempts to poll the extension deployment status for information after the upload| |Input|`MaxPollingTimeout`||`600`|The maximum number of **seconds** to stop the pipeline to wait for the result of the deployment status; **note: use this value to prevent the pipeline from consuming too much time waiting for a response**| +### 7. Enumerate Environment (`EnumerateEnvironment`) + +This function returns information about the agent on which the pipeline is running for diagnostic and troubleshooting purposes. Included is: +* platform (windows or linux) +* whoami (user security context) +* current working directory +* Powershell version (if installed) +* pwsh version (if installed) +* BCContainerHelper version (if installed) +* docker version (if installed) +* list of docker images (if installed, and if any exist) + +The output can be put to a file for consumption by later steps in the pipeline. The output file is a JSON file, with the following format: + +```json +{ + "platform": "string", + "whoami": "string", + "workingDirectory": "string", + "powershellVersion": "string", + "pscoreVersion": "string", + "bcContainerVersion": "string", + "dockerVersion": "string", + "dockerImages": [ + { + "name": "string", + "status": "string" + } + ] +} +``` + +|Type|Name|Required|Default|Use| +|---|---|---|---|---| +|Input|FileNameAndPath||``|Directs where to save the file if `GenerateFile` is `true` | + ## Example Pipeline ```yaml @@ -301,15 +345,20 @@ There is not much more control that is provided and even the response codes from ClientSecret: "" CompanyId: "" +- task: EGEnumerateEnvironment@0 + displayName: "Enumerate environment" + inputs: + FileNameAndPath: ./environment.json + ``` ## Common Failures Here are some common failures and their likely causes: -|Failure|Cause| -|---|---| -|**Immediate fail on deploy** | An immediate failure often means the extension’s **version number hasn’t changed**. Business Central may retain internal metadata **even if** the extension was unpublished and removed. This can cause silent rejections during re-deploy. To confirm, try a manual upload — the web interface will usually surface an error message that the API silently swallows. | +|Failure|Cause|Corrective Action| +|---|---|---| +|**Immediate fail on deploy** | An immediate failure often means the extension’s **version number hasn’t changed**. Business Central may retain internal metadata **even if** the extension was unpublished and removed. This can cause silent rejections during re-deploy. To confirm, try a manual upload — the web interface will usually surface an error message that the API silently swallows. | Increment build number in ```app.json```. | | **Extension never installs / stuck in “InProgress”** | Business Central backend is overloaded or stalled (e.g., schema sync issues, queued deployments). | Increase `PollingTimeout` and check the BC admin center for other queued extensions or backend delays. | | **Extension fails with no visible error message** | The BC API may suppress detailed errors. Often due to invalid dependencies, permission errors, or duplicate version conflicts. | Try uploading the extension manually through the BC UI to surface hidden error messages. | | **Authentication fails** | Incorrect or expired `ClientSecret`; or app registration missing permissions. | Ensure app has Application permissions, `API.ReadWrite.All`, and admin consent granted. Rotate secret if expired. | @@ -318,6 +367,7 @@ Here are some common failures and their likely causes: | **Pipeline fails to find `app.json`** | Folder structure doesn't match expected default; `PathToAppJson` may be incorrect. | Confirm folder layout. Use an inline `ls` or echo step to validate paths before running. | | **Polling hangs until timeout** | Deployment didn’t register; call to `Microsoft.NAV.upload` may have silently failed. | Recheck that upload succeeded and bookmark is valid. Consider increasing logging verbosity. | | **ENOENT / ETAG errors during upload** | Missing `.app` file or stale `@odata.etag` from a previous upload attempt. | Confirm `Build Package` step ran and `.app` file exists. If needed, reacquire a fresh bookmark with valid ETAG. | +| **500 Internal Server Error** or **```"An error occurred while trying to download ''"```** during package download | The pipeline user may not have an entry in the Entra users in Business Central. | Add the pipeline user to the Entra users. | ## Security & Trust diff --git a/bc-tools-extension/RELEASE.md b/bc-tools-extension/RELEASE.md index 9939ba9..8221c15 100644 --- a/bc-tools-extension/RELEASE.md +++ b/bc-tools-extension/RELEASE.md @@ -1,32 +1,44 @@ # Release Notes - BCBuildTasks Extension +- [Release Notes - BCBuildTasks Extension](#release-notes---bcbuildtasks-extension) +- [Version: 0.1.10](#version-0110) - [Version: 0.1.9](#version-019) - [Version: 0.1.8](#version-018) - [Version: 0.1.7](#version-017) + - [Fixes](#fixes) + - [Improvements](#improvements) - [Version: 0.1.6](#version-016) - [Version: 0.1.5](#version-015) - + [Feature Release](#feature-release) - * [New Features](#new-features) - + [1. **EGGetBCCompanies**](#1-eggetbccompanies) - + [2. **EGGetBCModules**](#2-eggetbcmodules) - + [3. **EGDeployBCModule**](#3-egdeploybcmodule) + - [Feature Release](#feature-release) + - [New Features](#new-features) + - [1. **EGGetBCCompanies**](#1-eggetbccompanies) + - [2. EGGetBCModules](#2-eggetbcmodules) + - [3. EGDeployBCModule](#3-egdeploybcmodule) - [Version: 0.1.4](#version-014) - + [Improvement Release](#improvement-release) - * [New Features](#new-features-1) - + [1. **EGGetALCompiler**](#1-eggetalcompiler) - + [2. **EGGetALDependencies**](#2-eggetaldependencies) - + [3. **EGBuildALPackage**](#3-egbuildalpackage) - * [Notes & Requirements](#notes--requirements) + - [Improvement Release](#improvement-release) + - [New Features](#new-features-1) + - [1. **EGGetALCompiler**](#1-eggetalcompiler) + - [2. **EGGetALDependencies**](#2-eggetaldependencies) + - [3. **EGBuildALPackage**](#3-egbuildalpackage) + - [Notes \& Requirements](#notes--requirements) - [Version: 0.1.0](#version-010) - + [Initial Release](#initial-release) - * [New Features](#new-features-2) - + [1. **EGGetALCompiler**](#1-eggetalcompiler-1) - + [2. **EGGetALDependencies**](#2-eggetaldependencies-1) - + [3. **EGBuildALPackage**](#3-egbuildalpackage-1) - * [Notes & Requirements](#notes--requirements-1) - * [Example Pipeline Usage](#example-pipeline-usage) - * [Known Limitations](#known-limitations) - * [Support](#support) + - [Initial Release](#initial-release) + - [New Features](#new-features-2) + - [1. **EGGetALCompiler**](#1-eggetalcompiler-1) + - [2. **EGGetALDependencies**](#2-eggetaldependencies-1) + - [3. **EGBuildALPackage**](#3-egbuildalpackage-1) + - [Notes \& Requirements](#notes--requirements-1) + - [Example Pipeline Usage](#example-pipeline-usage) + - [Known Limitations](#known-limitations) + - [Support](#support) + - [License](#license) + +# Version: 0.1.10 +- Adds [#36](https://github.com/crazycga/bcdevopsextension/issues/36): incorporate failure condition regarding missing pipeline user entry onto Microsoft Entra Application screen in Business Central. +- Adds new function ```EGEnumerateEnvironment``` for fast troubleshooting of pipeline environments. + +# Version: 0.1.9 +- Addresses [#31](https://github.com/crazycga/bcdevopsextension/issues/31): ```EGGetALCompiler``` returning incorrect value. This update addresses this particular bug, having refactored the search / walk algorithm to find the compiler. # Version: 0.1.9 - Addresses [#31](https://github.com/crazycga/bcdevopsextension/issues/31): ```EGGetALCompiler``` returning incorrect value. This update addresses this particular bug, having refactored the search / walk algorithm to find the compiler. diff --git a/bc-tools-extension/vss-extension.json b/bc-tools-extension/vss-extension.json index a9d7afb..0fa2e8c 100644 --- a/bc-tools-extension/vss-extension.json +++ b/bc-tools-extension/vss-extension.json @@ -2,7 +2,7 @@ "manifestVersion": 1, "id": "eg-bc-build-tasks", "name": "Business Central Build Tasks", - "version": "0.1.5", + "version": "0.1.10", "publisher": "Evergrowth", "targets": [ { @@ -85,6 +85,9 @@ }, { "path": "_common" + }, + { + "path": "Enumerate-Environment" } ], "contributions": [ @@ -147,6 +150,16 @@ "properties": { "name": "Publish-BCModuleToTenant" } - } + }, + { + "id": "Enumerate-Environment", + "type": "ms.vss-distributed-task.task", + "targets": [ + "ms.vss-distributed-task.tasks" + ], + "properties": { + "name": "Enumerate-Environment" + } + } ] } \ No newline at end of file