diff --git a/.github/workflows/powershell.yaml b/.github/workflows/powershell.yaml index 95300ae3c0..1cd720358f 100644 --- a/.github/workflows/powershell.yaml +++ b/.github/workflows/powershell.yaml @@ -26,7 +26,7 @@ jobs: with: path: .\ recurse: true - excludeRule: '"PSAvoidUsingInvokeExpression", "PSUseShouldProcessForStateChangingFunctions", "PSAvoidUsingWriteHost", "PSAvoidUsingCmdletAliases", "PSUseSingularNouns"' + excludeRule: '"PSAvoidUsingInvokeExpression", "PSUseShouldProcessForStateChangingFunctions", "PSAvoidUsingWriteHost", "PSAvoidUsingCmdletAliases", "PSUseSingularNouns", "PSUseApprovedVerbs"' output: results.sarif # Upload the SARIF file generated in the previous step diff --git a/.gitignore b/.gitignore index c9d4a3a063..13ccc52e83 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ bld/ [Oo]bj/ [Ll]og/ [Oo]ut/ +.artifactsCache # Visual Studio 2015 cache/options directory .vs/ diff --git a/build/scripts/DevEnv/ALGoProjectInfo.class.psm1 b/build/scripts/DevEnv/ALGoProjectInfo.class.psm1 new file mode 100644 index 0000000000..bb531024c4 --- /dev/null +++ b/build/scripts/DevEnv/ALGoProjectInfo.class.psm1 @@ -0,0 +1,78 @@ +using module .\AppProjectInfo.class.psm1 + +<# +.SYNOPSIS + This class is used to store information about an AL-Go project. +#> +class ALGoProjectInfo { + [string] $ProjectFolder + [PSCustomObject] $Settings + + + hidden ALGoProjectInfo([string] $projectFolder) { + $alGoFolder = Join-Path $projectFolder '.AL-Go' + + if (-not (Test-Path -Path $alGoFolder -PathType Container)) { + throw "Could not find .AL-Go folder in $projectFolder" + } + + $settingsJsonFile = Join-Path $alGoFolder 'settings.json' + + if (-not (Test-Path -Path $settingsJsonFile -PathType Leaf)) { + throw "Could not find settings.json in $alGoFolder" + } + + $this.ProjectFolder = $projectFolder + $this.Settings = Get-Content -Path $settingsJsonFile -Raw | ConvertFrom-Json + } + + <# + Gets the AL-Go project info from the specified folder. + #> + static [ALGoProjectInfo] Get([string] $projectFolder) { + $alGoProjectInfo = [ALGoProjectInfo]::new($projectFolder) + + return $alGoProjectInfo + } + + <# + Finds all AL-Go projects in the specified folder. + #> + static [ALGoProjectInfo[]] FindAll([string] $folder) { + $alGoProjects = @() + + $alGoProjectFolders = Get-ChildItem -Path $folder -Filter '.AL-Go' -Recurse -Directory | Select-Object -ExpandProperty Parent | Select-Object -ExpandProperty FullName + + foreach($alGoProjectFolder in $alGoProjectFolders) { + $alGoProjects += [ALGoProjectInfo]::Get($alGoProjectFolder) + } + + return $alGoProjects + } + + <# + Gets the app folders. + #> + [string[]] GetAppFolders([switch] $Resolve) { + $appFolders = $this.Settings.appFolders + + if ($Resolve) { + $appFolders = $appFolders | ForEach-Object { Join-Path $this.ProjectFolder $_ -Resolve -ErrorAction SilentlyContinue } | Where-Object { [AppProjectInfo]::IsAppProjectFolder($_) }| Select-Object -Unique + } + + return $appFolders + } + + <# + Gets the test folders. + #> + [string[]] GetTestFolders([switch] $Resolve) { + $testFolders = $this.Settings.testFolders + + if ($Resolve) { + $testFolders = $testFolders | ForEach-Object { Join-Path $this.ProjectFolder $_ -Resolve -ErrorAction SilentlyContinue } | Where-Object { [AppProjectInfo]::IsAppProjectFolder($_) }| Select-Object -Unique + } + + return $testFolders + } +} \ No newline at end of file diff --git a/build/scripts/DevEnv/AppProjectInfo.class.psm1 b/build/scripts/DevEnv/AppProjectInfo.class.psm1 new file mode 100644 index 0000000000..c7622d74cd --- /dev/null +++ b/build/scripts/DevEnv/AppProjectInfo.class.psm1 @@ -0,0 +1,75 @@ +<# +.SYNOPSIS + This class is used to store information about an AL project. +#> +class AppProjectInfo { + [string] $AppProjectFolder + [string] $Id + [ValidateSet('app', 'test')] + [string] $Type + [PSCustomObject] $AppJson + + + hidden AppProjectInfo([string] $appProjectFolder, [string] $type = 'app') { + + if(-not [AppProjectInfo]::IsAppProjectFolder($appProjectFolder)) { + throw "$appProjectFolder is not an app project folder" + } + + $appJsonFile = Join-Path $appProjectFolder 'app.json' -Resolve + $_appJson = Get-Content -Path $appJsonFile -Raw | ConvertFrom-Json + + $this.AppProjectFolder = $appProjectFolder + $this.Type = $type + $this.Id = $_appJson.id + $this.AppJson = $_appJson + } + + static [AppProjectInfo] Get([string] $appProjectFolder) { + $appInfo = [AppProjectInfo]::new($appProjectFolder, 'app') + + return $appInfo + } + + static [AppProjectInfo] Get([string] $appProjectFolder, [string] $type) { + $appInfo = [AppProjectInfo]::new($appProjectFolder, $type) + + return $appInfo + } + + static [boolean] IsAppProjectFolder([string] $folder) { + return (Test-Path -Path (Join-Path $folder 'app.json') -PathType Leaf) + } + + <# + Gets the app publisher. + #> + [string] GetAppPublisher() { + return $this.AppJson.publisher + } + + <# + Gets the app name. + #> + [string] GetAppName() { + return $this.AppJson.name + } + + <# + Gets the app version. + #> + [string] GetAppVersion() { + return $this.AppJson.version + } + + <# + Gets the app file name. + #> + [string] GetAppFileName() { + $appPublisher = $this.GetAppPublisher() + $appName = $this.GetAppName() + $appVersion = $this.GetAppVersion() + + return "$($appPublisher)_$($appName)_$($appVersion).app" + } +} \ No newline at end of file diff --git a/build/scripts/DevEnv/NewDevEnv.ps1 b/build/scripts/DevEnv/NewDevEnv.ps1 new file mode 100644 index 0000000000..00413fe4c9 --- /dev/null +++ b/build/scripts/DevEnv/NewDevEnv.ps1 @@ -0,0 +1,110 @@ +using module .\AppProjectInfo.class.psm1 +using module .\ALGoProjectInfo.class.psm1 + +<# + .Synopsis + Creates a docker-based development environment for AL apps. + .Parameter containerName + The name of the container to use. The container will be created if it does not exist. + .Parameter userName + The user name to use for the container. + .Parameter password + The password to use for the container. + .Parameter projectPaths + The paths of the AL projects to build. May contain wildcards. + .Parameter workspacePath + The path of the workspace to build. The workspace file must be in JSON format. + .Parameter alGoProject + The path of the AL-Go project to build. + .Parameter packageCacheFolder + The folder to store the built artifacts. +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', '', Justification = 'local build')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'local build')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'packageCacheFolder', Justification = 'false-postiive, used in Measure-Command')] +[CmdletBinding(DefaultParameterSetName = 'ProjectPaths')] +param( + [Parameter(Mandatory = $false)] + [string] $containerName = "BC-$(Get-Date -Format 'yyyyMMdd')", + + [Parameter(Mandatory = $false)] + [string] $userName = 'admin', + + [Parameter(Mandatory = $true)] + [string] $password, + + [Parameter(Mandatory = $true, ParameterSetName = 'ProjectPaths')] + [string[]] $projectPaths, + + [Parameter(Mandatory = $true, ParameterSetName = 'WorkspacePath')] + [string] $workspacePath, + + [Parameter(Mandatory = $true, ParameterSetName = 'ALGoProject')] + [string] $alGoProject, + + [Parameter(Mandatory = $false)] + [string] $packageCacheFolder = ".artifactsCache" +) + +$errorActionPreference = "Stop"; $ProgressPreference = "SilentlyContinue"; Set-StrictMode -Version 2.0 + +# Install BCContainerHelper module if not already installed +if (-not (Get-Module -ListAvailable -Name "BCContainerHelper")) { + Write-Host "BCContainerHelper module not found. Installing..." + Install-Module -Name "BCContainerHelper" -Scope CurrentUser -AllowPrerelease -Force +} + +Import-Module "BCContainerHelper" -DisableNameChecking +Import-Module "$PSScriptRoot\..\EnlistmentHelperFunctions.psm1" -DisableNameChecking +Import-Module "$PSScriptRoot\NewDevEnv.psm1" -DisableNameChecking + +$baseFolder = Get-BaseFolder + +# Create BC container +$credential = New-Object System.Management.Automation.PSCredential ($userName, $(ConvertTo-SecureString $password -AsPlainText -Force)) +$createContainerJob = Create-BCContainer -containerName $containerName -credential $credential -backgroundJob + +# Resolve AL project paths +$projectPaths = Resolve-ProjectPaths -projectPaths $projectPaths -workspacePath $workspacePath -alGoProject $alGoProject -baseFolder $baseFolder +Write-Host "Resolved project paths: $($projectPaths -join [Environment]::NewLine)" + +# Build apps +$appFiles = @() +$buildingAppsStats = Measure-Command { + Write-Host "Building apps..." -ForegroundColor Yellow + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'false-postiive')] + $appFiles = Build-Apps -projectPaths $projectPaths -packageCacheFolder $packageCacheFolder +} + +Write-Host "Building apps took $($buildingAppsStats.TotalSeconds) seconds" +Write-Host "App files: $($appFiles -join [Environment]::NewLine)" -ForegroundColor Green + +# Wait for container creation to finish +if($createContainerJob) { + Write-Host 'Waiting for container creation to finish...' -ForegroundColor Yellow + Wait-Job -Job $createContainerJob -Timeout 1 + Receive-Job -Job $createContainerJob -Wait -AutoRemoveJob + + if($createContainerJob.State -eq 'Failed'){ + Write-Output "Creating container failed:" + throw $($createContainerJob.ChildJobs | ForEach-Object { $_.JobStateInfo.Reason.Message } | Out-String) + } +} + +if(Test-ContainerExists -containerName $containerName) { + Write-Host "Container $containerName is available" -ForegroundColor Green +} else { + throw "Container $containerName not available. Check if the container was created successfully and is running." +} + + +# Publish apps +Write-Host "Publishing apps..." -ForegroundColor Yellow +$publishingAppsStats = Measure-Command { + foreach($currentAppFile in $appFiles) { + Publish-BcContainerApp -containerName $containerName -appFile $currentAppFile -syncMode ForceSync -sync -credential $credential -skipVerification -install -useDevEndpoint -replacePackageId + } +} + +Write-Host "Publishing apps took $($publishingAppsStats.TotalSeconds) seconds" + diff --git a/build/scripts/DevEnv/NewDevEnv.psm1 b/build/scripts/DevEnv/NewDevEnv.psm1 new file mode 100644 index 0000000000..c00a0eb27c --- /dev/null +++ b/build/scripts/DevEnv/NewDevEnv.psm1 @@ -0,0 +1,357 @@ +using module .\AppProjectInfo.class.psm1 +using module .\ALGoProjectInfo.class.psm1 + +Import-Module "BCContainerHelper" -DisableNameChecking +Import-Module "$PSScriptRoot\..\EnlistmentHelperFunctions.psm1" -DisableNameChecking + +$script:allApps = @() +function GetRootedFolder { + param( + [Parameter(Mandatory = $true)] + [string] $folder, + [Parameter(Mandatory = $true)] + [string] $baseFolder + ) + + if(-not [System.IO.Path]::IsPathRooted($folder)) { + $folder = Join-Path $baseFolder $folder + } + + return $folder +} + +<# + .Synopsis + Creates a BC container from based on the specified artifact URL in the AL-Go settings. + If the container already exists, it will be reused. + .Parameter containerName + The name of the container. + .Parameter credential + The credential to use for the container. + .Parameter backgroundJob + If specified, the container will be created in the background. + .Outputs + The job that creates the container if the backgroundJob switch is specified. +#> +function Create-BCContainer { + param ( + [Parameter(Mandatory = $true)] + [string] $containerName, + [Parameter(Mandatory = $true)] + [pscredential] $credential, + [switch] $backgroundJob + ) + + if(Test-ContainerExists -containerName $containerName) { + Write-Host "Container $containerName already exists" -ForegroundColor Yellow + return + } + + $baseFolder = Get-BaseFolder + + $bcArtifactUrl = Get-ConfigValue -Key "artifact" -ConfigType AL-Go + if($backgroundJob) { + [Scriptblock] $createContainerScriptblock = { + param( + [Parameter(Mandatory = $true)] + [string] $containerName, + [Parameter(Mandatory = $true)] + [pscredential] $credential, + [Parameter(Mandatory = $true)] + [string] $bcArtifactUrl, + [Parameter(Mandatory = $true)] + [string] $baseFolder + ) + Import-Module "BCContainerHelper" -DisableNameChecking + + $newContainerParams = @{ + "accept_eula" = $true + "accept_insiderEula" = $true + "containerName" = $containerName + "artifactUrl" = $bcArtifactUrl + "Credential" = $credential + "auth" = "UserPassword" + "additionalParameters" = @("--volume ""$($baseFolder):c:\sources""") + } + + $creatingContainerStats = Measure-Command { + $newBCContainerScript = Join-Path $baseFolder "build\scripts\NewBcContainer.ps1" -Resolve + . $newBCContainerScript -parameters $newContainerParams + } + + Write-Host "Creating container $containerName took $($creatingContainerStats.TotalSeconds) seconds" + } + + # Set the current location to the base folder + function jobInit { + param( + $baseFolder + ) + + return [ScriptBlock]::Create("Set-Location $baseFolder") + } + + $createContainerJob = $null + $createContainerJob = Start-Job -InitializationScript $(jobInit -baseFolder $baseFolder) -ScriptBlock $createContainerScriptblock -ArgumentList $containerName, $credential, $bcArtifactUrl, $baseFolder | Get-Job + Write-Host "Creating container $containerName from artifact URL $bcArtifactUrl in the background. Job ID: $($createContainerJob.Id)" -ForegroundColor Yellow + + return $createContainerJob + } else { + $createContainerScriptblock.Invoke($containerName, $credential, $bcArtifactUrl, $baseFolder) + } +} + +<# + .Synopsis + Resolves the project paths to AL app project folders. + .Parameter projectPaths + The project paths to resolve. May contain wildcards. + .Parameter workspacePath + The path to the workspace file. The workspace file contains a list of folders that contain AL projects. + .Parameter alGoProject + The path to the AL-Go project. + .Parameter baseFolder + The base folder where the AL projects are located. + .Outputs + The resolved project paths as an array of strings. The paths are absolute and unique. +#> +function Resolve-ProjectPaths { + param( + [Parameter(Mandatory = $false)] + [string[]] $projectPaths, + + [Parameter(Mandatory = $false)] + [string] $workspacePath, + + [Parameter(Mandatory = $false)] + [string] $alGoProject, + + [Parameter(Mandatory = $true)] + [string] $baseFolder + ) + + $result = @() + + if($projectPaths) { + foreach($projectPath in $projectPaths) { + $projectPath = GetRootedFolder -folder $projectPath -baseFolder $baseFolder + + # Each project path can contain a wildcard + $projectPath = Resolve-Path -Path $projectPath + $result += @($projectPath | Where-Object { [AppProjectInfo]::IsAppProjectFolder($_.Path) } | Select-Object -ExpandProperty Path ) + } + } + + if($workspacePath) { + $workspacePath = GetRootedFolder -folder $workspacePath -baseFolder $baseFolder + + $workspace = Get-Content -Path $workspacePath -Raw | ConvertFrom-Json + $workspaceParentPath = Split-Path -Path $workspacePath -Parent + + # Folders in the workspace are relative to the folder where the workspace file is located + $result += @($workspace.folders | ForEach-Object { GetRootedFolder -folder $($_.path) -baseFolder $workspaceParentPath } | Where-Object { [AppProjectInfo]::IsAppProjectFolder($_) }) | ForEach-Object { Resolve-Path -Path $_ } | Select-Object -ExpandProperty Path + } + + if($alGoProject) { + $alGoProject = GetRootedFolder -folder $alGoProject -baseFolder $baseFolder + $alGoProjectInfo = [ALGoProjectInfo]::Get($alGoProject) + + $result += @($alGoProjectInfo.GetAppFolders($true)) + $result += @($alGoProjectInfo.GetTestFolders($true)) + } + + return $result | Select-Object -Unique +} + +<# + .Synopsis + Checks if a container with the specified name exists. +#> +function Test-ContainerExists { + param ( + $containerName + ) + return ($null -ne $(docker ps -q -f name="$containerName")) +} + +<# + .Synopsis + Builds an app. + + .Parameter appProjectFolder + The folder of the app project. + + .Parameter compilerFolder + The folder where the compiler is located. If not specified, the compiler folder will be created on demand. + + .Parameter packageCacheFolder + The folder for the packagecache. + + .Parameter baseFolder + The base folder where the AL-Go projects are located. +#> +function BuildApp { + param( + [Parameter(Mandatory = $true)] + [string] $appProjectFolder, + [Parameter(Mandatory = $true)] + [ref] $compilerFolder, + [Parameter(Mandatory = $true)] + [string] $packageCacheFolder, + [Parameter(Mandatory = $true)] + [string] $baseFolder + ) + + $appFiles = @() + $allAppInfos = GetAllApps -baseFolder $baseFolder + $appOutputFolder = $packageCacheFolder + $appInfo = [AppProjectInfo]::Get($appProjectFolder) + + # Build dependencies + foreach($dependency in $appInfo.AppJson.dependencies) { + Write-Host "Building dependency: $($dependency.id)" -ForegroundColor Yellow + $dependencyAppInfo = $allAppInfos | Where-Object { $_.Id -eq $dependency.id } + $dependencyAppFile = BuildApp -appProjectFolder $($dependencyAppInfo.AppProjectFolder) -compilerFolder $compilerFolder -packageCacheFolder $packageCacheFolder -baseFolder $baseFolder + + $appFiles += @($dependencyAppFile) + } + + $appProjectFolder = GetRootedFolder -folder $appProjectFolder -baseFolder $baseFolder + + $appFile = $appInfo.GetAppFileName() + + if((Test-Path (Join-Path $appOutputFolder $appFile)) -and (-not $rebuild)) { + Write-Host "App $appFile already exists in $appOutputFolder. Skipping..." + $appFile = (Join-Path $appOutputFolder $appFile -Resolve) + } else { + # Create compiler folder on demand + if(-not $compilerFolder.Value) { + Write-Host "Creating compiler folder..." -ForegroundColor Yellow + $compilerFolder.Value = CreateCompilerFolder -packageCacheFolder $packageCacheFolder + Write-Host "Compiler folder: $($compilerFolder.Value)" -ForegroundColor Yellow + } + + $appFile = Compile-AppWithBcCompilerFolder -compilerFolder $($compilerFolder.Value) -appProjectFolder "$($appInfo.AppProjectFolder)" -appOutputFolder $appOutputFolder -appSymbolsFolder $packageCacheFolder + } + + $appFiles += $appFile + + return $appFiles +} + +<# + .Synopsis + Builds all apps in the specified project paths. + .Parameter projectPaths + The project paths to use for the build. + .Parameter packageCacheFolder + The folder for the packagecache. + .Outputs + The app files that were built. The paths are absolute and unique. +#> +function Build-Apps { + param ( + $projectPaths, + $packageCacheFolder + ) + $appFiles = @() + $baseFolder = Get-BaseFolder + $packageCacheFolder = GetRootedFolder -folder $packageCacheFolder -baseFolder $baseFolder + + # Compiler folder will be created on demand + $compilerFolder = '' + + try { + foreach($currentProjectPath in $projectPaths) { + Write-Host "Building app in $currentProjectPath" -ForegroundColor Yellow + $currentAppFiles = BuildApp -appProjectFolder $currentProjectPath -compilerFolder ([ref]$compilerFolder) -packageCacheFolder $packageCacheFolder -baseFolder $baseFolder + $appFiles += @($currentAppFiles) + } + } + catch { + Write-Host "Error building apps: $_" -ForegroundColor Red + throw $_ + } + finally { + if ($compilerFolder) { + Write-Host "Removing compiler folder $compilerFolder" -ForegroundColor Yellow + Remove-Item -Path $compilerFolder -Recurse -Force | Out-Null + } + } + + $appFiles = $appFiles | Select-Object -Unique + + return $appFiles +} + +<# + .Synopsis + Creates a compiler folder. + + .Parameter packageCacheFolder + The folder for the packagecache. +#> +function CreateCompilerFolder { + param( + [Parameter(Mandatory = $true)] + [string] $packageCacheFolder + ) + $bcArtifactUrl = Get-ConfigValue -Key "artifact" -ConfigType AL-Go + + if(-not (Test-Path -Path $packageCacheFolder)) { + Write-Host "Creating package cache folder $packageCacheFolder" + New-Item -Path $packageCacheFolder -ItemType Directory | Out-Null + } + + return New-BcCompilerFolder -artifactUrl $bcArtifactUrl -cacheFolder $packageCacheFolder +} + +<# + .Synopsis + Gets all apps from AL-Go projects in the base folder. + + .Parameter baseFolder + The base folder where the AL-Go projects are located. +#> +function GetAllApps { + param( + [Parameter(Mandatory = $true)] + [string] $baseFolder + ) + if(-not $($script:allApps)) { + # Get all AL-Go projects + $alGoProjects = [ALGoProjectInfo]::FindAll($baseFolder) + + $appInfos = @() + + # Collect all apps from AL-Go projects + foreach($alGoProject in $alGoProjects) { + $appFolders = $alGoProject.GetAppFolders($true) + foreach($appFolder in $appFolders) { + $appInfo = [AppProjectInfo]::Get($appFolder, 'app') + + if($appInfos.Id -notcontains $appInfo.Id) { + $appInfos += $appInfo + } + } + + $testAppFolders = $alGoProject.GetTestFolders($true) + foreach($testAppFolder in $testAppFolders) { + $testAppInfo = [AppProjectInfo]::Get($testAppFolder, 'test') + + if($appInfos.Id -notcontains $testAppInfo.Id) { + $appInfos += $testAppInfo + } + } + } + + $script:allApps = $appInfos + } + + return $script:allApps +} + +Export-ModuleMember -Function Create-BCContainer +Export-ModuleMember -Function Resolve-ProjectPaths +Export-ModuleMember -Function Test-ContainerExists +Export-ModuleMember -Function Build-Apps \ No newline at end of file