diff --git a/.github/AL-Go-Settings.json b/.github/AL-Go-Settings.json index 9e192b255ea..60bb867acb2 100644 --- a/.github/AL-Go-Settings.json +++ b/.github/AL-Go-Settings.json @@ -2,7 +2,7 @@ "$schema": "https://raw.githubusercontent.com/microsoft/AL-Go/91b96c2b294be6f823277dafe6f03350abfb9d23/Actions/.Modules/settings.schema.json", "type": "PTE", "templateUrl": "https://github.com/microsoft/AL-Go-PTE@preview", - "bcContainerHelperVersion": "preview", + "bcContainerHelperVersion": "6.1.19-preview2811389", "runs-on": "windows-latest", "cacheImageName": "", "UsePsSession": true, diff --git a/.github/workflows/CICD.yaml b/.github/workflows/CICD.yaml index 4c650a3d64b..1dd4b8052ee 100644 --- a/.github/workflows/CICD.yaml +++ b/.github/workflows/CICD.yaml @@ -1,453 +1,109 @@ -name: ' CI/CD' +name: 'SQL reset pilot - DO NOT MERGE' on: workflow_dispatch: - push: - paths-ignore: - - '**.md' - - '.github/workflows/*.yaml' - - '!.github/workflows/CICD.yaml' - branches: [ 'main', 'releases/*' ] - -defaults: - run: - shell: powershell + inputs: + arm: + description: 'Control only by default; paired requires separate authorization' + required: true + type: choice + default: control + options: + - control + - paired permissions: actions: read contents: read id-token: write - pages: read - security-events: write packages: read -env: - workflowDepth: 2 - ALGoOrgSettings: ${{ vars.ALGoOrgSettings }} - ALGoRepoSettings: ${{ vars.ALGoRepoSettings }} - jobs: - Initialization: - needs: [ ] - runs-on: [ windows-latest ] - outputs: - telemetryScopeJson: ${{ steps.init.outputs.telemetryScopeJson }} - environmentsMatrixJson: ${{ steps.DetermineDeploymentEnvironments.outputs.EnvironmentsMatrixJson }} - environmentCount: ${{ steps.DetermineDeploymentEnvironments.outputs.EnvironmentCount }} - deploymentEnvironmentsJson: ${{ steps.DetermineDeploymentEnvironments.outputs.DeploymentEnvironmentsJson }} - generateALDocArtifact: ${{ steps.DetermineDeploymentEnvironments.outputs.GenerateALDocArtifact }} - deployALDocArtifact: ${{ steps.DetermineDeploymentEnvironments.outputs.DeployALDocArtifact }} - deliveryTargetsJson: ${{ steps.DetermineDeliveryTargets.outputs.DeliveryTargetsJson }} - githubRunner: ${{ steps.ReadSettings.outputs.GitHubRunnerJson }} - githubRunnerShell: ${{ steps.ReadSettings.outputs.GitHubRunnerShell }} - projects: ${{ steps.determineProjectsToBuild.outputs.ProjectsJson }} - skippedProjects: ${{ steps.determineProjectsToBuild.outputs.SkippedProjectsJson }} - projectDependenciesJson: ${{ steps.determineProjectsToBuild.outputs.ProjectDependenciesJson }} - buildOrderJson: ${{ steps.determineProjectsToBuild.outputs.BuildOrderJson }} - baselineWorkflowRunId: ${{ steps.determineProjectsToBuild.outputs.BaselineWorkflowRunId }} - baselineWorkflowSHA: ${{ steps.determineProjectsToBuild.outputs.BaselineWorkflowSHA }} - workflowDepth: ${{ steps.DetermineWorkflowDepth.outputs.WorkflowDepth }} - powerPlatformSolutionFolder: ${{ steps.DeterminePowerPlatformSolutionFolder.outputs.powerPlatformSolutionFolder }} - trackALAlertsInGitHub: ${{ steps.SetALCodeAnalysisVar.outputs.trackALAlertsInGitHub }} - steps: - - name: Dump Workflow Information - uses: microsoft/AL-Go/Actions/DumpWorkflowInfo@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - lfs: true - - - name: Initialize the workflow - id: init - uses: microsoft/AL-Go/Actions/WorkflowInitialize@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - - - name: Read settings - id: ReadSettings - uses: microsoft/AL-Go/Actions/ReadSettings@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - get: type,powerPlatformSolutionFolder,useGitSubmodules,trackALAlertsInGitHub - - - name: Set AL Code Analysis Var output - id: SetALCodeAnalysisVar - run: | - Write-Host "trackALAlertsInGitHub environment variable: '$($env:trackALAlertsInGitHub)'" - Add-Content -Encoding UTF8 -Path $env:GITHUB_OUTPUT -Value "trackALAlertsInGitHub=$($env:trackALAlertsInGitHub)" - - - name: Read submodules token - id: ReadSubmodulesToken - if: env.useGitSubmodules != 'false' && env.useGitSubmodules != '' - uses: microsoft/AL-Go/Actions/ReadSecrets@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - gitHubSecrets: ${{ toJson(secrets) }} - getSecrets: '-gitSubmodulesToken' - - - name: Checkout Submodules - if: env.useGitSubmodules != 'false' && env.useGitSubmodules != '' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - lfs: true - submodules: ${{ env.useGitSubmodules }} - token: '${{ fromJson(steps.ReadSubmodulesToken.outputs.Secrets).gitSubmodulesToken }}' - - - name: Determine Workflow Depth - id: DetermineWorkflowDepth - run: | - Add-Content -Encoding UTF8 -Path $env:GITHUB_OUTPUT -Value "WorkflowDepth=$($env:workflowDepth)" - - - name: Determine Projects To Build - id: determineProjectsToBuild - uses: microsoft/AL-Go/Actions/DetermineProjectsToBuild@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - maxBuildDepth: ${{ env.workflowDepth }} - - - name: Determine PowerPlatform Solution Folder - id: DeterminePowerPlatformSolutionFolder - if: env.type == 'PTE' - run: | - Add-Content -Encoding UTF8 -Path $env:GITHUB_OUTPUT -Value "powerPlatformSolutionFolder=$($env:powerPlatformSolutionFolder)" - - - name: Determine Delivery Target Secrets - id: DetermineDeliveryTargetSecrets - uses: microsoft/AL-Go/Actions/DetermineDeliveryTargets@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - projectsJson: '${{ steps.determineProjectsToBuild.outputs.ProjectsJson }}' - checkContextSecrets: 'false' - - - name: Read secrets - id: ReadSecrets - uses: microsoft/AL-Go/Actions/ReadSecrets@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - gitHubSecrets: ${{ toJson(secrets) }} - getSecrets: ${{ steps.DetermineDeliveryTargetSecrets.outputs.ContextSecrets }} - - - name: Determine Delivery Targets - id: DetermineDeliveryTargets - uses: microsoft/AL-Go/Actions/DetermineDeliveryTargets@91b96c2b294be6f823277dafe6f03350abfb9d23 - env: - Secrets: '${{ steps.ReadSecrets.outputs.Secrets }}' - with: - shell: powershell - projectsJson: '${{ steps.determineProjectsToBuild.outputs.ProjectsJson }}' - checkContextSecrets: 'true' - - - name: Determine Deployment Environments - id: DetermineDeploymentEnvironments - uses: microsoft/AL-Go/Actions/DetermineDeploymentEnvironments@91b96c2b294be6f823277dafe6f03350abfb9d23 - env: - GITHUB_TOKEN: ${{ github.token }} - with: - shell: powershell - getEnvironments: '*' - type: 'CD' - - CheckForUpdates: - needs: [ Initialization ] - runs-on: [ windows-latest ] - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Read settings - uses: microsoft/AL-Go/Actions/ReadSettings@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - get: templateUrl - - - name: Read secrets - id: ReadSecrets - uses: microsoft/AL-Go/Actions/ReadSecrets@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - gitHubSecrets: ${{ toJson(secrets) }} - getSecrets: 'ghTokenWorkflow' - - - name: Check for updates to AL-Go system files - uses: microsoft/AL-Go/Actions/CheckForUpdates@91b96c2b294be6f823277dafe6f03350abfb9d23 - env: - GITHUB_TOKEN: ${{ github.token }} - with: - shell: powershell - templateUrl: ${{ env.templateUrl }} - token: ${{ fromJson(steps.ReadSecrets.outputs.Secrets).ghTokenWorkflow }} - downloadLatest: true - - Build1: - needs: [ Initialization ] - if: (!failure()) && (!cancelled()) && fromJson(needs.Initialization.outputs.buildOrderJson)[0].projectsCount > 0 + Pilot: + if: github.ref == 'refs/heads/features/646383-sql-reset-pilot' && github.run_attempt == 1 strategy: - matrix: - include: ${{ fromJson(needs.Initialization.outputs.buildOrderJson)[0].buildDimensions }} fail-fast: false - name: Build ${{ matrix.projectName }} (${{ matrix.buildMode }}) - uses: ./.github/workflows/_BuildALGoProject.yaml - secrets: inherit - with: - shell: ${{ matrix.githubRunnerShell }} - runsOn: ${{ matrix.githubRunner }} - project: ${{ matrix.project }} - projectName: ${{ matrix.projectName }} - buildMode: ${{ matrix.buildMode }} - skippedProjectsJson: ${{ needs.Initialization.outputs.skippedProjects }} - projectDependenciesJson: ${{ needs.Initialization.outputs.projectDependenciesJson }} - baselineWorkflowRunId: ${{ needs.Initialization.outputs.baselineWorkflowRunId }} - baselineWorkflowSHA: ${{ needs.Initialization.outputs.baselineWorkflowSHA }} - secrets: 'licenseFileUrl,codeSignCertificateUrl,*codeSignCertificatePassword,keyVaultCertificateUrl,*keyVaultCertificatePassword,keyVaultClientId,gitHubPackagesContext,applicationInsightsConnectionString' - signArtifacts: true - useArtifactCache: true - needsContext: ${{ toJson(needs) }} - - Build: - needs: [ Initialization, Build1 ] - if: (!failure()) && (!cancelled()) && (needs.Build1.result == 'success' || needs.Build1.result == 'skipped') && fromJson(needs.Initialization.outputs.buildOrderJson)[1].projectsCount > 0 - strategy: matrix: - include: ${{ fromJson(needs.Initialization.outputs.buildOrderJson)[1].buildDimensions }} - fail-fast: false - name: Build ${{ matrix.projectName }} (${{ matrix.buildMode }}) - uses: ./.github/workflows/_BuildALGoProject.yaml - secrets: inherit - with: - shell: ${{ matrix.githubRunnerShell }} - runsOn: ${{ matrix.githubRunner }} - project: ${{ matrix.project }} - projectName: ${{ matrix.projectName }} - buildMode: ${{ matrix.buildMode }} - skippedProjectsJson: ${{ needs.Initialization.outputs.skippedProjects }} - projectDependenciesJson: ${{ needs.Initialization.outputs.projectDependenciesJson }} - baselineWorkflowRunId: ${{ needs.Initialization.outputs.baselineWorkflowRunId }} - baselineWorkflowSHA: ${{ needs.Initialization.outputs.baselineWorkflowSHA }} - secrets: 'licenseFileUrl,codeSignCertificateUrl,*codeSignCertificatePassword,keyVaultCertificateUrl,*keyVaultCertificatePassword,keyVaultClientId,gitHubPackagesContext,applicationInsightsConnectionString' - signArtifacts: true - useArtifactCache: true - needsContext: ${{ toJson(needs) }} - - CodeAnalysisUpload: - needs: [ Initialization, Build ] - if: (!cancelled()) && (needs.Initialization.outputs.trackALAlertsInGitHub == 'True') - runs-on: [ windows-latest ] - name: Code Analysis Processing - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Download artifacts - ErrorLogs - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - env: - ACTIONS_RUNNER_DISABLE_NODE_MAGLEV: 1 - if: (success() || failure()) - with: - pattern: '*-*ErrorLogs-*' - path: '${{ github.workspace }}/ErrorLogs/' - merge-multiple: true - - - name: Process AL Code Analysis Logs - id: ProcessALCodeAnalysisLogs - if: (success() || failure()) - uses: microsoft/AL-Go/Actions/ProcessALCodeAnalysisLogs@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - - - name: Upload SARIF file to GitHub - uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 - if: (success() || failure()) && (hashFiles(format('{0}/ErrorLogs/output.sarif.json',github.workspace)) != '') - with: - sarif_file: '${{ github.workspace }}/ErrorLogs/output.sarif.json' - category: "ALCodeAnalysis" - - DeployALDoc: - needs: [ Initialization, Build ] - if: (!cancelled()) && (needs.Build.result == 'success' || needs.Build.result == 'skipped') && needs.Initialization.outputs.generateALDocArtifact == 1 && github.ref_name == 'main' - runs-on: [ windows-latest ] - name: Deploy Reference Documentation - permissions: - contents: read - actions: read - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Download artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - env: - ACTIONS_RUNNER_DISABLE_NODE_MAGLEV: 1 - with: - path: '.artifacts' - - - name: Read settings - uses: microsoft/AL-Go/Actions/ReadSettings@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - - - name: Determine ArtifactUrl - id: determineArtifactUrl - uses: microsoft/AL-Go/Actions/DetermineArtifactUrl@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - - - name: Setup Pages - if: needs.Initialization.outputs.deployALDocArtifact == 1 - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - - - name: Build Reference Documentation - uses: microsoft/AL-Go/Actions/BuildReferenceDocumentation@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - artifacts: '.artifacts' - artifactUrl: ${{ env.artifact }} - - - name: Upload pages artifact - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 - with: - path: ".aldoc/_site/" - - - name: Deploy to GitHub Pages - if: needs.Initialization.outputs.deployALDocArtifact == 1 - id: deployment - uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 - - Deploy: - needs: [ Initialization, Build1, Build ] - if: (!cancelled()) && (needs.Build1.result == 'success' || needs.Build1.result == 'skipped') && (needs.Build.result == 'success' || needs.Build.result == 'skipped') && needs.Initialization.outputs.environmentCount > 0 - strategy: ${{ fromJson(needs.Initialization.outputs.environmentsMatrixJson) }} - runs-on: ${{ fromJson(matrix.os) }} - name: Deploy to ${{ matrix.environment }} + arm: ${{ fromJSON(inputs.arm == 'paired' && '["control","fresh"]' || '["control"]') }} + runs-on: GitHub-BCAppsPL + timeout-minutes: 120 defaults: run: - shell: ${{ matrix.shell }} - environment: - name: ${{ matrix.environment }} - url: ${{ steps.Deploy.outputs.environmentUrl }} + shell: pwsh env: - ALGoEnvSettings: ${{ vars.ALGoEnvironmentSettings }} - ALGoEnvName: ${{ matrix.environment }} + ALGoOrgSettings: ${{ vars.ALGoOrgSettings }} + ALGoRepoSettings: ${{ vars.ALGoRepoSettings }} + BC_SQL_PILOT_ARM: ${{ matrix.arm }} + BuildMode: IntegrationTests steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Download artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - env: - ACTIONS_RUNNER_DISABLE_NODE_MAGLEV: 1 + - name: Checkout experiment + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: - path: '.artifacts' - - - name: Read settings - uses: microsoft/AL-Go/Actions/ReadSettings@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: ${{ matrix.shell }} - get: type,powerPlatformSolutionFolder + lfs: true + fetch-depth: 0 - - name: EnvName - id: envName + - name: Verify baseline PowerShell 7 host run: | - $errorActionPreference = "Stop"; $ProgressPreference = "SilentlyContinue"; Set-StrictMode -Version 2.0 - $envName = '${{ matrix.environment }}'.split(' ')[0] - Add-Content -Encoding UTF8 -Path $env:GITHUB_OUTPUT -Value "envName=$envName" - - - name: Read secrets - id: ReadSecrets - uses: microsoft/AL-Go/Actions/ReadSecrets@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: ${{ matrix.shell }} - gitHubSecrets: ${{ toJson(secrets) }} - getSecrets: '${{ steps.envName.outputs.envName }}-AuthContext,${{ steps.envName.outputs.envName }}_AuthContext,AuthContext' - - - name: Deploy to Business Central - id: Deploy - uses: microsoft/AL-Go/Actions/Deploy@91b96c2b294be6f823277dafe6f03350abfb9d23 + if ($PSVersionTable.PSEdition -ne 'Core' -or $PSVersionTable.PSVersion.Major -ne 7) { + throw 'The pinned baseline executes on PowerShell 7; this pilot must use the same shell.' + } + $output = Join-Path $env:GITHUB_WORKSPACE 'sql-reset-pilot-output' + New-Item -ItemType Directory $output -Force | Out-Null + @{ + edition = $PSVersionTable.PSEdition + version = $PSVersionTable.PSVersion.ToString() + executable = (Get-Process -Id $PID).Path + utc = [DateTime]::UtcNow.ToString('o') + } | ConvertTo-Json | Tee-Object -FilePath (Join-Path $output 'shell-preflight.json') + + - name: Validate provenance and download exact compiled PR2 packages + id: packages env: - Secrets: '${{ steps.ReadSecrets.outputs.Secrets }}' - with: - shell: ${{ matrix.shell }} - environmentName: ${{ matrix.environment }} - artifactsFolder: '.artifacts' - type: 'CD' - deploymentEnvironmentsJson: ${{ needs.Initialization.outputs.deploymentEnvironmentsJson }} - - - name: Deploy to Power Platform - if: env.type == 'PTE' && env.powerPlatformSolutionFolder != '' - uses: microsoft/AL-Go/Actions/DeployPowerPlatform@91b96c2b294be6f823277dafe6f03350abfb9d23 - env: - Secrets: '${{ steps.ReadSecrets.outputs.Secrets }}' - with: - shell: powershell - environmentName: ${{ matrix.environment }} - artifactsFolder: '.artifacts' - deploymentEnvironmentsJson: ${{ needs.Initialization.outputs.deploymentEnvironmentsJson }} - - Deliver: - needs: [ Initialization, Build1, Build ] - if: (!cancelled()) && (needs.Build1.result == 'success' || needs.Build1.result == 'skipped') && (needs.Build.result == 'success' || needs.Build.result == 'skipped') && needs.Initialization.outputs.deliveryTargetsJson != '[]' - strategy: - matrix: - deliveryTarget: ${{ fromJson(needs.Initialization.outputs.deliveryTargetsJson) }} - fail-fast: false - runs-on: [ windows-latest ] - name: Deliver to ${{ matrix.deliveryTarget }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Download artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - env: - ACTIONS_RUNNER_DISABLE_NODE_MAGLEV: 1 - with: - path: '.artifacts' + GH_TOKEN: ${{ github.token }} + run: | + & .\build\scripts\SqlResetPilot\Prepare.ps1 - - name: Read settings + - name: Read pinned W1 Integration settings uses: microsoft/AL-Go/Actions/ReadSettings@91b96c2b294be6f823277dafe6f03350abfb9d23 with: - shell: powershell + shell: pwsh + project: build/projects/Test Apps W1 + buildMode: IntegrationTests - - name: Read secrets - id: ReadSecrets + - name: Read normal disposable CI license and package secrets + id: secrets uses: microsoft/AL-Go/Actions/ReadSecrets@91b96c2b294be6f823277dafe6f03350abfb9d23 with: - shell: powershell + shell: pwsh gitHubSecrets: ${{ toJson(secrets) }} - getSecrets: '${{ matrix.deliveryTarget }}Context' + getSecrets: 'licenseFileUrl,gitHubPackagesContext,applicationInsightsConnectionString,appDependencySecrets' - - name: Deliver - uses: microsoft/AL-Go/Actions/Deliver@91b96c2b294be6f823277dafe6f03350abfb9d23 + - name: Execute original pipeline with bounded clean-test prefix + uses: microsoft/AL-Go/Actions/RunPipeline@91b96c2b294be6f823277dafe6f03350abfb9d23 env: - Secrets: '${{ steps.ReadSecrets.outputs.Secrets }}' + Secrets: '${{ steps.secrets.outputs.Secrets }}' with: - shell: powershell - type: 'CD' - projects: ${{ needs.Initialization.outputs.projects }} - deliveryTarget: ${{ matrix.deliveryTarget }} - artifacts: '.artifacts' - - PostProcess: - needs: [ Initialization, Build1, Build, Deploy, Deliver, DeployALDoc, CodeAnalysisUpload ] - if: (!cancelled()) - runs-on: [ windows-latest ] - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + shell: pwsh + project: build/projects/Test Apps W1 + buildMode: IntegrationTests + artifact: https://bcinsider-fvh2ekdjecfjd6gk.b02.azurefd.net/sandbox/30.0.55683.0/w1 + installAppsJson: ${{ steps.packages.outputs.apps }} + installTestAppsJson: ${{ steps.packages.outputs.tests }} - - name: Finalize the workflow - id: PostProcess - uses: microsoft/AL-Go/Actions/WorkflowPostProcess@91b96c2b294be6f823277dafe6f03350abfb9d23 - env: - GITHUB_TOKEN: ${{ github.token }} - with: - shell: powershell - telemetryScopeJson: ${{ needs.Initialization.outputs.telemetryScopeJson }} - currentJobContext: ${{ toJson(job) }} + - name: Export diagnostics and tear down only owned disposable container + if: always() + run: | + & .\build\scripts\SqlResetPilot\Finalize.ps1 + + - name: Preserve raw outcomes (including failures) + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: sql-reset-pilot-${{ matrix.arm }}-${{ github.run_id }} + retention-days: 7 + if-no-files-found: error + path: | + sql-reset-pilot-output/** + build/projects/Test Apps W1/TestResults*.xml + build/projects/Test Apps W1/ContainerEventLog.evtx + build/projects/Test Apps W1/BuildOutput.txt diff --git a/.github/workflows/PullRequestHandler.yaml b/.github/workflows/PullRequestHandler.yaml index 5bf5c05d665..bfafea2b708 100644 --- a/.github/workflows/PullRequestHandler.yaml +++ b/.github/workflows/PullRequestHandler.yaml @@ -35,7 +35,7 @@ jobs: Initialization: needs: [ PregateCheck ] - if: (!failure() && !cancelled()) + if: (!failure() && !cancelled()) && github.head_ref != 'features/646383-sql-reset-pilot' runs-on: [ windows-latest ] outputs: projects: ${{ steps.determineProjectsToBuild.outputs.ProjectsJson }} @@ -93,7 +93,7 @@ jobs: Build1: needs: [ Initialization, CustomJob-VerifyAppChanges ] - if: (!failure()) && (!cancelled()) && fromJson(needs.Initialization.outputs.buildOrderJson)[0].projectsCount > 0 + if: (!failure()) && (!cancelled()) && needs.Initialization.result == 'success' && fromJson(needs.Initialization.outputs.buildOrderJson)[0].projectsCount > 0 strategy: matrix: include: ${{ fromJson(needs.Initialization.outputs.buildOrderJson)[0].buildDimensions }} @@ -119,7 +119,7 @@ jobs: Build: needs: [ Initialization, Build1, CustomJob-VerifyAppChanges ] - if: (!failure()) && (!cancelled()) && (needs.Build1.result == 'success' || needs.Build1.result == 'skipped') && fromJson(needs.Initialization.outputs.buildOrderJson)[1].projectsCount > 0 + if: (!failure()) && (!cancelled()) && needs.Initialization.result == 'success' && (needs.Build1.result == 'success' || needs.Build1.result == 'skipped') && fromJson(needs.Initialization.outputs.buildOrderJson)[1].projectsCount > 0 strategy: matrix: include: ${{ fromJson(needs.Initialization.outputs.buildOrderJson)[1].buildDimensions }} diff --git a/build/scripts/NewBcContainer.ps1 b/build/scripts/NewBcContainer.ps1 index 78871ab947b..974459b5f5d 100644 --- a/build/scripts/NewBcContainer.ps1 +++ b/build/scripts/NewBcContainer.ps1 @@ -22,7 +22,28 @@ if ($platformVersion) { $parameters.platformArtifactUrl = "$platformUrl/platform" } +if ($env:BC_SQL_PILOT_ARM) { + & (Join-Path $PSScriptRoot 'SqlResetPilot\ContainerPreflight.ps1') -Parameters $parameters +} New-BcContainer @parameters +if ($env:BC_SQL_PILOT_ARM) { + $runtime = Invoke-ScriptInBcContainer -containerName $parameters.ContainerName -scriptblock { + $commands = @('Get-NAVTenant', 'Dismount-NAVTenant', 'Mount-NAVTenant', 'Test-NAVDatabase', 'Copy-NAVDatabase', 'Remove-NAVDatabase') + @{ + commands = @($commands | ForEach-Object { + $command = Get-Command $_ -ErrorAction Stop + @{ name = $command.Name; version = [string]$command.Version; parameters = @($command.Parameters.Keys) } + }) + nst = @(Get-Process 'Microsoft.Dynamics.Nav.Server' | ForEach-Object { + @{ pid = $_.Id; version = $_.FileVersion } + }) + } + } + $runtime | ConvertTo-Json -Depth 6 | Set-Content (Join-Path $env:BC_SQL_PILOT_OUTPUT 'runtime-preflight.json') + if (@($runtime.nst).Count -ne 1 -or $runtime.nst[0].version -notmatch '^30\.0\.55665\.0(?:\s|$)') { + throw 'Installed NST is not the predeclared 30.0.55665.0 runtime.' + } +} if ($parameters.auth -in @('UserPassword', 'NavUserPassword')) { if (-not $parameters.credential) { diff --git a/build/scripts/ParallelTestExecution.psm1 b/build/scripts/ParallelTestExecution.psm1 index f8ed562ccee..4adfc6a0b21 100644 --- a/build/scripts/ParallelTestExecution.psm1 +++ b/build/scripts/ParallelTestExecution.psm1 @@ -13,6 +13,9 @@ if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { } } Import-Module (Join-Path $PSScriptRoot "ALAppBuild.psm1" -Resolve) +if ($env:BC_SQL_PILOT_ARM) { + Import-Module (Join-Path $PSScriptRoot 'SqlResetPilot\Lifecycle.psm1') +} <# .SYNOPSIS @@ -376,9 +379,12 @@ function New-BcTestTenantTemplate { [string]$SourceDatabaseName ) - $result = @(Invoke-ScriptInBcContainer -containerName $ContainerName -useSession $false -scriptblock { Param($sourceDatabaseName) + $result = @(Invoke-ScriptInBcContainer -containerName $ContainerName -useSession $false -scriptblock { Param($sourceDatabaseName, $sqlPilot) $templateDatabaseName = "$sourceDatabaseName-test-template" - $maxAttempts = 3 + if ($sqlPilot -and (Test-NAVDatabase -DatabaseName $templateDatabaseName)) { + throw 'Pilot template already exists; it must never be overwritten.' + } + $maxAttempts = if ($sqlPilot) { 1 } else { 3 } $retryDelaySeconds = 5 for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { try { @@ -398,7 +404,7 @@ function New-BcTestTenantTemplate { } } $templateDatabaseName - } -argumentList $SourceDatabaseName) + } -argumentList $SourceDatabaseName, ([bool]$env:BC_SQL_PILOT_ARM)) if ($result.Count -eq 0) { throw "Creating the clean test tenant template returned no database name." @@ -430,6 +436,12 @@ function Reset-BcTestTenant { [string]$TemplateDatabaseName ) + if ($env:BC_SQL_PILOT_ARM) { + Invoke-SqlPilotReset -ContainerName $ContainerName -Tenant $Tenant -DatabaseName $TenantDatabaseName ` + -TemplateDatabaseName $TemplateDatabaseName + return + } + Invoke-ScriptInBcContainer -containerName $ContainerName -useSession $false -scriptblock { Param($tenant, $tenantDatabaseName, $templateDatabaseName) @@ -635,13 +647,16 @@ function Receive-TestJobResult { $outcome = 'Passed' if ($Job.State -eq 'Failed' -or $Job.State -eq 'Stopped') { - if ((Test-TransientTestFailure $output) -and -not $Retried.ContainsKey($Entry.appName)) { + if (-not $env:BC_SQL_PILOT_ARM -and (Test-TransientTestFailure $output) -and -not $Retried.ContainsKey($Entry.appName)) { $outcome = 'Transient' } else { $outcome = 'Failed' } } + if ($env:BC_SQL_PILOT_ARM) { + $output | Set-Content (Join-Path $env:BC_SQL_PILOT_OUTPUT "worker-$($Job.Id).log") -Encoding UTF8 + } Remove-Job -Job $Job -Force return [PSCustomObject]@{ @@ -836,6 +851,9 @@ function Invoke-RequiredDisabledTestExecution { -ScriptPath $ScriptPath -TestType $TestType -State $state -Verb $dispatch.Verb } $null = Wait-ForAllTestJobs -state $state + if ($env:BC_SQL_PILOT_ARM -and $state.hasFailures) { + break + } } return (-not $state.hasFailures) @@ -1327,6 +1345,10 @@ function Invoke-ParallelTestExecution { } $tenantInfo = @(Get-AvailableBcTenantInfo -containerName $parameters.containerName) + if ($env:BC_SQL_PILOT_ARM) { + Initialize-SqlResetPilot -ContainerName $parameters.containerName -TenantInfo $tenantInfo ` + -Arm $env:BC_SQL_PILOT_ARM -RunId $env:GITHUB_RUN_ID -OutputDirectory $env:BC_SQL_PILOT_OUTPUT + } $tenants = @($tenantInfo | ForEach-Object { $_.Id }) Write-Host "Available tenants: $($tenants -join ', ')" @@ -1384,6 +1406,11 @@ function Invoke-ParallelTestExecution { -AppNamesToTest $appNamesToTest -AppIdByName $appIdByName ) } + if ($env:BC_SQL_PILOT_ARM) { + $requiredDisabledWorkItems = @(Select-SqlPilotPrefix -WorkItems $requiredDisabledWorkItems) + $requiredDisabledWorkItems | ConvertTo-Json -Depth 5 | + Set-Content (Join-Path $env:BC_SQL_PILOT_OUTPUT 'ordered-prefix.json') -Encoding UTF8 + } $templateDatabaseName = "" try { if ($requiredDisabledWorkItems.Count -gt 0) { @@ -1417,13 +1444,23 @@ function Invoke-ParallelTestExecution { } } finally { - foreach ($cleanTenant in $cleanTenantInfo) { + foreach ($cleanTenant in @($cleanTenantInfo | Where-Object { -not $env:BC_SQL_PILOT_ARM })) { Reset-BcTestTenant -ContainerName $parameters.containerName -Tenant $cleanTenant.Id ` -TenantDatabaseName $cleanTenant.DatabaseName -TemplateDatabaseName $templateDatabaseName } } } + if ($env:BC_SQL_PILOT_ARM) { + Merge-TenantTestResults -parameters $parameters -tenants $tenants -workItems $requiredDisabledWorkItems -rerunSuffixes @() + $state.finalResult = -not $state.hasFailures + $state.completed = $true + $state | ConvertTo-Json -Depth 5 | Set-Content $stateFile -Force + @{ completedUtc = [DateTime]::UtcNow.ToString('o'); passed = $state.finalResult; selectedCodeunits = $requiredDisabledWorkItems.Count } | + ConvertTo-Json | Set-Content (Join-Path $env:BC_SQL_PILOT_OUTPUT 'outcome.json') -Encoding UTF8 + return $state.finalResult + } + # Single dispatch loop, FIFO. TestConfiguration.json lists the smallest app first (a cheap # serial warmup) and the rest longest-first (LPT, keeps the tail short). The retry cap lives in # Receive-TestJobResult: an app already in $state.retried is classified as Failed on a re-fail. @@ -1521,7 +1558,7 @@ function Invoke-ParallelTestExecution { return $allPassed } finally { - if ($templateDatabaseName) { + if ($templateDatabaseName -and -not $env:BC_SQL_PILOT_ARM) { Remove-BcTestTenantTemplate -ContainerName $parameters.containerName ` -TemplateDatabaseName $templateDatabaseName } diff --git a/build/scripts/SqlResetPilot/ContainerPreflight.ps1 b/build/scripts/SqlResetPilot/ContainerPreflight.ps1 new file mode 100644 index 00000000000..d15e6d33df4 --- /dev/null +++ b/build/scripts/SqlResetPilot/ContainerPreflight.ps1 @@ -0,0 +1,34 @@ +param([hashtable]$Parameters) +$ErrorActionPreference = 'Stop' +if ($env:GITHUB_REF -ne 'refs/heads/features/646383-sql-reset-pilot' -or + $env:GITHUB_EVENT_NAME -ne 'workflow_dispatch' -or $env:GITHUB_RUN_ATTEMPT -ne '1' -or + $env:BC_SQL_PILOT_ARM -notin @('control', 'fresh') -or -not $env:BC_SQL_PILOT_OUTPUT) { + throw 'Refusing SQL pilot outside its disposable manually dispatched CI job.' +} +$expectedName = "bcbuildprojectsTestAppsW1$($env:GITHUB_RUN_ID)" +if ($Parameters.ContainerName -ne $expectedName -or (Test-BcContainer -containerName $expectedName)) { + throw 'Pilot container name is unexpected or already exists. No replacement is authorized.' +} +$module = Get-Module BcContainerHelper -ErrorAction Stop +$helperVersion = & $module { $bcContainerHelperVersion } +if ($helperVersion -ne '6.1.19-preview2811389') { throw "Wrong BCH version: $helperVersion" } +$settings = $env:Settings | ConvertFrom-Json +if ($settings.country -ne 'w1' -or $settings.testType -ne 'IntegrationTest' -or + $settings.numberOfTenantsForTesting -ne 4 -or $settings.companyName -ne 'My Company' -or + -not $settings.enableCleanTestCodeunitExecution -or $settings.enableTaskScheduler) { + throw 'Effective W1 Integration settings differ from the predeclared experiment.' +} +if (-not (Get-Command New-BcContainer).Parameters.ContainsKey('useGenericImage')) { + throw 'Exact BCH does not expose the supported generic-image parameter.' +} +$Parameters.useGenericImage = 'mcr.microsoft.com/businesscentral@sha256:c899d12093ad7bbdbfd08ccc0e6294e0f98c682c7e35db4ecfbca345fb068492' +if ($Parameters.platformArtifactUrl -ne 'https://bcinsider-fvh2ekdjecfjd6gk.b02.azurefd.net/platform/30.0.55665.0/platform') { + throw 'Platform artifact is not the pinned source platform.' +} +@{ + container = $expectedName; runId = $env:GITHUB_RUN_ID; arm = $env:BC_SQL_PILOT_ARM + helperVersion = $helperVersion; image = $Parameters.useGenericImage + imageGenericTag = '1.0.2.128'; platform = '30.0.55665.0' + hostOS = [Environment]::OSVersion.Version.ToString() + registeredUtc = [DateTime]::UtcNow.ToString('o') +} | ConvertTo-Json | Set-Content (Join-Path $env:BC_SQL_PILOT_OUTPUT 'container-ownership.json') -Encoding UTF8 diff --git a/build/scripts/SqlResetPilot/Finalize.Tests.ps1 b/build/scripts/SqlResetPilot/Finalize.Tests.ps1 new file mode 100644 index 00000000000..deca7127d20 --- /dev/null +++ b/build/scripts/SqlResetPilot/Finalize.Tests.ps1 @@ -0,0 +1,53 @@ +function global:Test-BcContainer { param($containerName) throw 'Live container call forbidden.' } +function global:Get-BcContainerEventLog { param($containerName, [switch]$doNotOpen) throw 'Live container call forbidden.' } +function global:Remove-BcContainer { param($containerName) throw 'Live container call forbidden.' } + +Describe 'SQL pilot final cleanup ownership' { + BeforeEach { + $script:oldWorkspace = $env:GITHUB_WORKSPACE + $script:oldRun = $env:GITHUB_RUN_ID + $script:oldArm = $env:BC_SQL_PILOT_ARM + $script:oldHelper = $env:BcContainerHelperPath + $env:GITHUB_WORKSPACE = $PSScriptRoot + $env:GITHUB_RUN_ID = '123' + $env:BC_SQL_PILOT_ARM = 'fresh' + $env:BcContainerHelperPath = Join-Path $PSScriptRoot 'BcContainerHelper.ps1' + Mock Import-Module {} + Mock Test-Path { $true } + Mock Get-Content { '{"runId":"123","arm":"fresh","container":"bcbuildprojectsTestAppsW1123"}' } + Mock Get-ChildItem {} + Mock Copy-Item {} + Mock Set-Content {} + $global:sqlPilotTestContainerExists = $true + Mock Test-BcContainer { $global:sqlPilotTestContainerExists } + Mock Get-BcContainerEventLog { 'events.evtx' } + Mock Remove-BcContainer { $global:sqlPilotTestContainerExists = $false } + } + AfterEach { + $env:GITHUB_WORKSPACE = $script:oldWorkspace + $env:GITHUB_RUN_ID = $script:oldRun + $env:BC_SQL_PILOT_ARM = $script:oldArm + $env:BcContainerHelperPath = $script:oldHelper + Remove-Variable sqlPilotTestContainerExists -Scope Global + } + It 'exports before disposing only the registered container' { + & (Join-Path $PSScriptRoot 'Finalize.ps1') + Assert-MockCalled Get-BcContainerEventLog -Times 1 -Exactly -Scope It + Assert-MockCalled Remove-BcContainer -Times 1 -Exactly -Scope It -ParameterFilter { $containerName -eq 'bcbuildprojectsTestAppsW1123' } + } + It 'still tears down if exporting fails and keeps that failure visible' { + Mock Get-BcContainerEventLog { throw 'event export failed' } + { & (Join-Path $PSScriptRoot 'Finalize.ps1') } | Should -Throw + Assert-MockCalled Remove-BcContainer -Times 1 -Exactly -Scope It + } + It 'refuses deletion if ownership was not recorded' { + Mock Test-Path { $false } + & (Join-Path $PSScriptRoot 'Finalize.ps1') + Assert-MockCalled Remove-BcContainer -Times 0 -Exactly -Scope It + } + It 'refuses deletion when run, arm or exact name differs' { + Mock Get-Content { '{"runId":"999","arm":"fresh","container":"shared"}' } + { & (Join-Path $PSScriptRoot 'Finalize.ps1') } | Should -Throw + Assert-MockCalled Remove-BcContainer -Times 0 -Exactly -Scope It + } +} diff --git a/build/scripts/SqlResetPilot/Finalize.ps1 b/build/scripts/SqlResetPilot/Finalize.ps1 new file mode 100644 index 00000000000..41a667bd01d --- /dev/null +++ b/build/scripts/SqlResetPilot/Finalize.ps1 @@ -0,0 +1,28 @@ +$ErrorActionPreference = 'Stop' +$output = Join-Path $env:GITHUB_WORKSPACE 'sql-reset-pilot-output' +$ownershipPath = Join-Path $output 'container-ownership.json' +if (-not (Test-Path $ownershipPath)) { + Write-Host 'No owned container was registered; no container deletion is authorized.' + return +} +$ownership = Get-Content $ownershipPath -Raw | ConvertFrom-Json +if ($ownership.runId -ne $env:GITHUB_RUN_ID -or $ownership.arm -ne $env:BC_SQL_PILOT_ARM -or + $ownership.container -ne "bcbuildprojectsTestAppsW1$($env:GITHUB_RUN_ID)") { + throw 'Cleanup ownership mismatch.' +} +Import-Module (Join-Path (Split-Path $env:BcContainerHelperPath -Parent) 'BcContainerHelper.psm1') -ErrorAction Stop +try { + if (Test-BcContainer -containerName $ownership.container) { + $events = Get-BcContainerEventLog -containerName $ownership.container -doNotOpen + Copy-Item $events (Join-Path $output 'final-container.evtx') + } + $project = Join-Path $env:GITHUB_WORKSPACE 'build\projects\Test Apps W1' + Get-ChildItem $project -Filter 'TestResults*.xml' -File | Copy-Item -Destination $output +} finally { + if (Test-BcContainer -containerName $ownership.container) { + Remove-BcContainer -containerName $ownership.container + } + @{ completedUtc = [DateTime]::UtcNow.ToString('o'); containerRemaining = (Test-BcContainer -containerName $ownership.container) } | + ConvertTo-Json | Set-Content (Join-Path $output 'cleanup.json') -Encoding UTF8 +} +if (Test-BcContainer -containerName $ownership.container) { throw 'Owned container teardown did not complete.' } diff --git a/build/scripts/SqlResetPilot/Lifecycle.Tests.ps1 b/build/scripts/SqlResetPilot/Lifecycle.Tests.ps1 new file mode 100644 index 00000000000..da023382fb4 --- /dev/null +++ b/build/scripts/SqlResetPilot/Lifecycle.Tests.ps1 @@ -0,0 +1,77 @@ +Import-Module (Join-Path $PSScriptRoot 'Lifecycle.psm1') -Force +function global:Invoke-ScriptInBcContainer { + param($containerName, $useSession, $scriptblock, $argumentList) + throw 'No live container calls are permitted in unit tests.' +} + +Describe 'SQL reset pilot ownership and mapping' { + BeforeEach { + $script:tenants = @('default','tenant2','tenant3','tenant4') | ForEach-Object { + [PSCustomObject]@{ Id = $_; DatabaseName = $_ } + } + Initialize-SqlResetPilot 'owned' $script:tenants 'fresh' '123456789' $PSScriptRoot + } + It 'uses a fresh database target and updates the original caller mapping for the next cycle' { + $first = Get-SqlResetPlan 'owned' 'tenant2' 'tenant2' 'default' + $first.Destination | Should -Be 'tenant2_r123456789_g1' + Complete-SqlResetPlan $first + $script:tenants[1].DatabaseName | Should -Be $first.Destination + $second = Get-SqlResetPlan 'owned' 'tenant2' $script:tenants[1].DatabaseName 'default-test-template' + $second.Previous | Should -Be $first.Destination + $second.Destination | Should -Be 'tenant2_r123456789_g2' + } + It 'retains the original SQL target in control while increasing its observation label' { + Initialize-SqlResetPilot 'owned' $script:tenants 'control' '123456789' $PSScriptRoot + $first = Get-SqlResetPlan 'owned' 'tenant3' 'tenant3' 'default-test-template' + Complete-SqlResetPlan $first + $second = Get-SqlResetPlan 'owned' 'tenant3' 'tenant3' 'default-test-template' + $second.Destination | Should -Be 'tenant3' + $second.Generation | Should -Be 2 + } + It 'rejects stale mappings after a successful rename' { + Complete-SqlResetPlan (Get-SqlResetPlan 'owned' 'tenant2' 'tenant2' 'default') + { Get-SqlResetPlan 'owned' 'tenant2' 'tenant2' 'default' } | Should -Throw + } + It 'never accepts the primary or template as a deletion target' { + { Get-SqlResetPlan 'owned' 'default' 'default' 'default-test-template' } | Should -Throw + { Get-SqlResetPlan 'owned' 'tenant2' 'default-test-template' 'default' } | Should -Throw + { Get-SqlResetPlan 'owned' 'tenant2' 'app' 'default' } | Should -Throw + } + It 'rejects unowned containers, workers and source databases' { + { Get-SqlResetPlan 'shared' 'tenant2' 'tenant2' 'default' } | Should -Throw + { Get-SqlResetPlan 'owned' 'tenant5' 'tenant5' 'default' } | Should -Throw + { Get-SqlResetPlan 'owned' 'tenant2' 'tenant2' 'shared' } | Should -Throw + } + It 'rejects unsafe run identities and unexpected original mappings' { + { Initialize-SqlResetPilot 'owned' $script:tenants 'fresh' '1;drop' $PSScriptRoot } | Should -Throw + $script:tenants[1].DatabaseName = 'default' + { Initialize-SqlResetPilot 'owned' $script:tenants 'fresh' '123' $PSScriptRoot } | Should -Throw + } + It 'preserves the seven original three-worker batches and stops after the target batch' { + $ids = @(139700,139702,139703,139706,139725,139726,139732,139739,139742,139745,139802,139803,139806,139826,139832,139854,139972,139780,148315,148318,148343,148344) + $items = @($ids | ForEach-Object { [PSCustomObject]@{ CodeunitId = $_ } }) + $prefix = @(Select-SqlPilotPrefix $items) + $prefix.Count | Should -Be 21 + $prefix[18].CodeunitId | Should -Be 148315 + $prefix[20].CodeunitId | Should -Be 148343 + $items[0].CodeunitId = 1 + { Select-SqlPilotPrefix $items } | Should -Throw + } + It 'advances mapping only after the container operation succeeds' { + Mock Invoke-ScriptInBcContainer -ModuleName Lifecycle { @{ state = 'Operational' } } + Mock Add-Content -ModuleName Lifecycle {} + Invoke-SqlPilotReset 'owned' 'tenant2' 'tenant2' 'default' + $script:tenants[1].DatabaseName | Should -Be 'tenant2_r123456789_g1' + Assert-MockCalled Invoke-ScriptInBcContainer -ModuleName Lifecycle -Times 1 -Exactly -Scope It -ParameterFilter { + $containerName -eq 'owned' -and $argumentList.Previous -eq 'tenant2' -and + $argumentList.Destination -eq 'tenant2_r123456789_g1' + } + } + It 'leaves mapping unchanged and propagates a failed restore without retrying' { + Mock Invoke-ScriptInBcContainer -ModuleName Lifecycle { throw 'copy failed' } + Mock Add-Content -ModuleName Lifecycle {} + { Invoke-SqlPilotReset 'owned' 'tenant2' 'tenant2' 'default' } | Should -Throw + $script:tenants[1].DatabaseName | Should -Be 'tenant2' + Assert-MockCalled Invoke-ScriptInBcContainer -ModuleName Lifecycle -Times 1 -Exactly -Scope It + } +} diff --git a/build/scripts/SqlResetPilot/Lifecycle.psm1 b/build/scripts/SqlResetPilot/Lifecycle.psm1 new file mode 100644 index 00000000000..180d63232b6 --- /dev/null +++ b/build/scripts/SqlResetPilot/Lifecycle.psm1 @@ -0,0 +1,131 @@ +Set-StrictMode -Version 2 +$script:pilot = $null + +function Initialize-SqlResetPilot { + param([string]$ContainerName, [array]$TenantInfo, [string]$Arm, [string]$RunId, [string]$OutputDirectory) + if ($Arm -notin @('control', 'fresh') -or $RunId -notmatch '^\d{1,20}$') { throw 'Invalid pilot identity.' } + if ($TenantInfo.Count -ne 4 -or @($TenantInfo.Id | Sort-Object -Unique).Count -ne 4) { + throw 'Pilot requires exactly default and three distinct workers.' + } + $workers = @{} + foreach ($id in @('tenant2', 'tenant3', 'tenant4')) { + $info = @($TenantInfo | Where-Object Id -eq $id) + if ($info.Count -ne 1 -or $info[0].DatabaseName -ne $id) { throw 'Unexpected initial owned worker mapping.' } + $workers[$id] = @{ Info = $info[0]; Generation = 0; Owned = @($id); Used = @($id) } + } + $source = @($TenantInfo | Where-Object Id -eq 'default') + if ($source.Count -ne 1 -or $source[0].DatabaseName -ne 'default') { throw 'Unexpected protected source mapping.' } + $script:pilot = @{ + Container = $ContainerName; Arm = $Arm; RunId = $RunId; Workers = $workers + OutputDirectory = $OutputDirectory + } +} + +function Get-SqlResetPlan { + param([string]$ContainerName, [string]$Tenant, [string]$DatabaseName, [string]$TemplateDatabaseName) + if (-not $script:pilot -or $ContainerName -ne $script:pilot.Container -or + -not $script:pilot.Workers.ContainsKey($Tenant)) { throw 'Unowned container or tenant.' } + $worker = $script:pilot.Workers[$Tenant] + if ($DatabaseName -ne $worker.Info.DatabaseName -or $DatabaseName -notin $worker.Owned -or + $TemplateDatabaseName -notin @('default', 'default-test-template') -or + $DatabaseName -in @('default', 'default-test-template', 'app') -or + $DatabaseName -eq $TemplateDatabaseName) { throw 'Unsafe or stale database mapping.' } + $generation = $worker.Generation + 1 + $destination = if ($script:pilot.Arm -eq 'fresh') { "${Tenant}_r$($script:pilot.RunId)_g$generation" } else { $DatabaseName } + if ($destination -notmatch '^tenant[234](?:_r\d{1,20}_g\d+)?$' -or $destination.Length -gt 64 -or + ($script:pilot.Arm -eq 'fresh' -and $destination -in $worker.Used)) { throw 'Unsafe or reused destination.' } + [PSCustomObject]@{ + Tenant = $Tenant; Previous = $DatabaseName; Destination = $destination + Template = $TemplateDatabaseName; Generation = $generation + } +} + +function Complete-SqlResetPlan { + param($Plan) + $worker = $script:pilot.Workers[$Plan.Tenant] + $worker.Info.DatabaseName = $Plan.Destination + $worker.Generation = $Plan.Generation + $worker.Owned = @($worker.Owned | Where-Object { $_ -ne $Plan.Previous }) + @($Plan.Destination) + $worker.Used += $Plan.Destination +} + +function Invoke-SqlPilotReset { + param([string]$ContainerName, [string]$Tenant, [string]$DatabaseName, [string]$TemplateDatabaseName) + $plan = Get-SqlResetPlan $ContainerName $Tenant $DatabaseName $TemplateDatabaseName + $trace = Join-Path $script:pilot.OutputDirectory 'reset-timeline.jsonl' + @{ phase = 'reset-start'; utc = [DateTime]::UtcNow.ToString('o'); arm = $script:pilot.Arm; plan = $plan } | + ConvertTo-Json -Depth 5 -Compress | Add-Content $trace + try { + $result = Invoke-ScriptInBcContainer -containerName $ContainerName -useSession $false -scriptblock { + param($plan) + $ErrorActionPreference = 'Stop' + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + $mounted = Get-NAVTenant -ServerInstance $ServerInstance -Tenant $plan.Tenant + $localServers = @('.', 'localhost', $env:COMPUTERNAME, '.\SQLEXPRESS', 'localhost\SQLEXPRESS', "$env:COMPUTERNAME\SQLEXPRESS") + if ($mounted.DatabaseName -ne $plan.Previous -or $mounted.DatabaseServer -notin $localServers) { + throw 'Actual mounted mapping differs from the owned local SQL mapping.' + } + if ($plan.Destination -ne $plan.Previous -and (Test-NAVDatabase -DatabaseName $plan.Destination)) { + throw 'Fresh database destination already exists; refusing to overwrite it.' + } + function Read-PilotIdentity($name) { + $connection = New-Object System.Data.SqlClient.SqlConnection 'Server=.\SQLEXPRESS;Database=master;Integrated Security=True;TrustServerCertificate=True' + try { + $connection.Open() + $command = $connection.CreateCommand() + $command.CommandText = 'SELECT name, database_id, create_date, service_broker_guid FROM sys.databases WHERE name = @name' + $null = $command.Parameters.AddWithValue('@name', $name) + $reader = $command.ExecuteReader() + if (-not $reader.Read()) { throw 'Owned SQL database identity is missing.' } + @{ name = [string]$reader[0]; databaseId = [int]$reader[1]; created = [string]$reader[2]; serviceBrokerGuid = [string]$reader[3] } + } finally { $connection.Dispose() } + } + $before = Read-PilotIdentity $plan.Previous + $started = [DateTime]::UtcNow.ToString('o') + Dismount-NAVTenant -ServerInstance $ServerInstance -Tenant $plan.Tenant -Force | Out-Null + if (Test-NAVDatabase -DatabaseName $plan.Previous) { + Remove-NAVDatabase -DatabaseName $plan.Previous | Out-Null + } + Copy-NAVDatabase -SourceDatabaseName $plan.Template -DestinationDatabaseName $plan.Destination -DatabaseServer '.' | Out-Null + Mount-NAVTenant -ServerInstance $ServerInstance -Id $plan.Tenant -DatabaseServer '.' ` + -DatabaseName $plan.Destination -OverwriteTenantIdInDatabase -Force | Out-Null + while ((Get-NAVTenant -ServerInstance $ServerInstance -Tenant $plan.Tenant).State -eq 'Mounting') { + if ($stopwatch.Elapsed.TotalSeconds -ge 300) { throw 'Tenant mount exceeded baseline 300-second bound.' } + Start-Sleep -Milliseconds 250 + } + $mounted = Get-NAVTenant -ServerInstance $ServerInstance -Tenant $plan.Tenant + if ($mounted.State -notin @('Operational', 'OperationalWithWarnings') -or $mounted.DatabaseName -ne $plan.Destination) { + throw 'Mounted state or mapping differs from the expected new generation.' + } + @{ + startedUtc = $started; completedUtc = [DateTime]::UtcNow.ToString('o') + before = $before; after = (Read-PilotIdentity $plan.Destination) + databaseServer = [string]$mounted.DatabaseServer; state = [string]$mounted.State + detailedState = [string]$mounted.DetailedState; forceRefresh = $false + nst = @(Get-Process 'Microsoft.Dynamics.Nav.Server' | ForEach-Object { + @{ pid = $_.Id; version = $_.FileVersion } + }) + } + } -argumentList $plan + Complete-SqlResetPlan $plan + @{ phase = 'reset-complete'; utc = [DateTime]::UtcNow.ToString('o'); plan = $plan; observation = $result } | + ConvertTo-Json -Depth 8 -Compress | Add-Content $trace + } catch { + # Do not log exception text: SQL/provider diagnostics can contain connection information. + @{ phase = 'reset-failed'; utc = [DateTime]::UtcNow.ToString('o'); plan = $plan; errorType = $_.Exception.GetType().FullName } | + ConvertTo-Json -Depth 5 -Compress | Add-Content $trace + throw + } +} + +function Select-SqlPilotPrefix { + param([array]$WorkItems) + $ids = @(139700,139702,139703,139706,139725,139726,139732,139739,139742,139745,139802,139803,139806,139826,139832,139854,139972,139780,148315,148318,148343) + if ($WorkItems.Count -lt $ids.Count) { throw 'Discovery returned an incomplete pilot prefix.' } + for ($i = 0; $i -lt $ids.Count; $i++) { + if ([int]$WorkItems[$i].CodeunitId -ne $ids[$i]) { throw "Original W1 discovery order differs at position $i." } + } + @($WorkItems | Select-Object -First $ids.Count) +} + +Export-ModuleMember -Function Initialize-SqlResetPilot, Get-SqlResetPlan, Complete-SqlResetPlan, Invoke-SqlPilotReset, Select-SqlPilotPrefix diff --git a/build/scripts/SqlResetPilot/Prepare.ps1 b/build/scripts/SqlResetPilot/Prepare.ps1 new file mode 100644 index 00000000000..50556207828 --- /dev/null +++ b/build/scripts/SqlResetPilot/Prepare.ps1 @@ -0,0 +1,53 @@ +$ErrorActionPreference = 'Stop' +$base = 'c4953dceffe02a017adad34973e1955017bf5d20' +if ($env:GITHUB_REPOSITORY -ne 'microsoft/BCApps' -or + $env:GITHUB_REF -ne 'refs/heads/features/646383-sql-reset-pilot' -or + $env:GITHUB_EVENT_NAME -ne 'workflow_dispatch' -or $env:GITHUB_RUN_ATTEMPT -ne '1' -or + $env:BC_SQL_PILOT_ARM -notin @('control', 'fresh')) { + throw 'This diagnostic is restricted to its explicitly dispatched disposable CI branch, attempt one.' +} +git merge-base --is-ancestor $base HEAD +if ($LASTEXITCODE -ne 0) { throw 'PR2 base is not an ancestor.' } +git diff --exit-code $base HEAD -- src +if ($LASTEXITCODE -ne 0) { throw 'AL/package source differs from the pinned compiled PR2 source.' } + +$output = Join-Path $env:GITHUB_WORKSPACE 'sql-reset-pilot-output' +New-Item -ItemType Directory $output -Force | Out-Null +"BC_SQL_PILOT_OUTPUT=$output" | Add-Content $env:GITHUB_ENV +@{ phase = 'package-preflight'; arm = $env:BC_SQL_PILOT_ARM; run = $env:GITHUB_RUN_ID; utc = [DateTime]::UtcNow.ToString('o') } | + ConvertTo-Json | Set-Content (Join-Path $output 'start.json') -Encoding UTF8 +$headers = @{ Authorization = "Bearer $env:GH_TOKEN"; Accept = 'application/vnd.github+json' } +$api = 'https://api.github.com/repos/microsoft/BCApps' +$artifacts = @( + @{ Kind = 'apps'; Id = 11374170356; Digest = '683343673e6efeb7699734b250d2ee5004d2d83fe08e57f324c4c645c09612db' }, + @{ Kind = 'tests'; Id = 11374145359; Digest = 'f75ce06920437328e5d79a5512eddcc2425624bb696c339c108478c83554171d' } +) +$manifest = @() +foreach ($artifact in $artifacts) { + $metadata = Invoke-RestMethod "$api/actions/artifacts/$($artifact.Id)" -Headers $headers + if ($metadata.expired -or $metadata.workflow_run.id -ne 37372848860 -or + $metadata.workflow_run.head_sha -ne $base -or $metadata.digest -ne "sha256:$($artifact.Digest)") { + throw 'Exact artifact provenance/digest check failed; no moving-build fallback is permitted.' + } + $destination = Join-Path $env:GITHUB_WORKSPACE "sql-reset-pilot-packages\$($artifact.Kind)" + New-Item -ItemType Directory $destination -Force | Out-Null + $zip = "$destination.zip" + Invoke-WebRequest "$api/actions/artifacts/$($artifact.Id)/zip" -Headers $headers -OutFile $zip -UseBasicParsing + if ((Get-FileHash $zip -Algorithm SHA256).Hash -ne $artifact.Digest) { throw 'Artifact archive hash mismatch.' } + Expand-Archive $zip $destination + $files = @(Get-ChildItem $destination -Filter '*.app' -Recurse -File | Sort-Object FullName) + if ($files.Count -eq 0) { throw 'Pinned artifact contains no app packages.' } + $list = Join-Path $destination 'packages.json' + ConvertTo-Json -InputObject @($files.FullName) | Set-Content $list -Encoding UTF8 + "$($artifact.Kind)=$list" | Add-Content $env:GITHUB_OUTPUT + $manifest += @{ + artifactId = $artifact.Id; sourceRun = 37372848860; sourceHead = $base + kind = $artifact.Kind; archiveSha256 = $artifact.Digest + files = @($files | ForEach-Object { @{ name = $_.Name; sha256 = (Get-FileHash $_.FullName).Hash } }) + } +} +@{ + experimentHead = $env:GITHUB_SHA; run = $env:GITHUB_RUN_ID; arm = $env:BC_SQL_PILOT_ARM + budgetMinutes = 120; startedUtc = [DateTime]::UtcNow.ToString('o') + artifacts = $manifest +} | ConvertTo-Json -Depth 8 | Set-Content (Join-Path $output 'provenance.json') -Encoding UTF8 diff --git a/build/scripts/SqlResetPilot/README.md b/build/scripts/SqlResetPilot/README.md new file mode 100644 index 00000000000..b127afef724 --- /dev/null +++ b/build/scripts/SqlResetPilot/README.md @@ -0,0 +1,102 @@ +# Diagnostic only — do not merge + +A manually dispatched W1 Integration diagnostic on top of PR #11860, +`c4953dceffe02a017adad34973e1955017bf5d20`. Related context: AB#646383; +this experiment does **not** resolve that work item and is outside the native +merge group. No auto-merge, queue entry, automatic rerun or main-branch change. + +The branch-only `CICD.yaml` replaces the normal CI matrix with disposable +`GitHub-BCAppsPL` containers, capped at **120 minutes per arm**. +Its `arm` choice defaults to `control` (one job); `paired` explicitly selects +control and fresh (two jobs, 240 runner-minutes) and requires separate authorization. +PR project discovery is disabled for this experiment branch to avoid a full-country +build. Dispatch the existing registered `CICD.yaml` with this branch as its ref. +Do not dispatch a second pair without new authorization. + +## Authorized preparation correction + +The initial pair, run `37493608127`, never created a container or ran AL tests. +It incorrectly selected Windows PowerShell 5.1 while original W1 job +`112207627851` used `pwsh` 7. `Get-PlatformVersions` therefore failed parsing an +HTTP response because the Internet Explorer engine was unavailable. This is an +experiment preparation/configuration bug, **not a network failure or SQL result**. +The failed pair remains inconclusive and its logs/artifacts are retained. + +One replacement pair is explicitly authorized after correcting the job shell +**and all AL-Go composite action shell inputs** to `pwsh`. `Invoke-AlGoAction` +invokes its supplied scriptblock in that shell; it does not select the shell. +A preflight records and requires PowerShell 7 before package preparation. +Checkout now fetches full history so further diagnostic commits cannot hide the +unchanged PR2 ancestor from the provenance guard. +No PlatformHelper, vendor action, parsing workaround or shell policy is changed. + +The failed preparation consumed 13m16s (control) plus 14m23s (fresh), totaling +27m39s of runner time. The replacement remains capped at 120 minutes per arm +(240 additional runner-minutes; 267m39s cumulative worst-case including the failed +preparation). Record actual old/new preparation time separately. That authorization +allowed no further dispatch or failed-job rerun after the replacement. + +## October 7 control-only repetitions + +Replacement paired run `37496613531` completed successfully for both arms. +Its control baseline recorded 234 passed cases and 19 skipped cases across +20 codeunits with executed cases in the selected 21-codeunit, seven-batch prefix. +This did not reproduce the error and does not establish that name reuse is safe. + +New authorization permits **exactly three additional independent control-only +dispatches**, each with `arm=control`, attempt one, capped at 120 minutes: +**360 additional runner-minutes maximum**. No treatment, automatic retry, +replacement dispatch, or failed-job rerun is authorized. Each run owns a distinct +container named `bcbuildprojectsTestAppsW1`; no workflow concurrency group +cancels or serializes these independent trials. + +These controls use the same original PR2 compiled app/test artifacts from run +`37372848860`, head `c4953dceffe02a017adad34973e1955017bf5d20`, not rebuilt tests. +They repeat only the existing first seven clean batches (21 selected codeunits), +not the ordinary lane or the full 192-codeunit cohort. Compare against 234 passes +and 19 skips if a trial reaches completion; failures can stop the prefix early. +Password-file authentication, reused `tenant2/3/4` database names, three-worker +scheduling, runtime/package pins, reset logic and diagnostic reads are unchanged. +Check artifact availability before dispatch; expired artifacts have no fallback. +Launch verification is not a test result: inspect final logs, JUnit, provenance +and cleanup artifacts separately before drawing conclusions. + +Both arms use the exact same PR2 app/test archive IDs and SHA256 digests, +BCH `6.1.19-preview2811389`, NST `30.0.55665.0`, W1 application artifact +`30.0.55683.0`, image digest, original license provisioning, auth files, +test assertions and three-worker scheduling. No AL source is changed or compiled +against a moving head. The image digest resolves the original generic +`1.0.2.128` LTSC2025-dev image; the historic log did not record its digest. +The pilot records the runner OS; compare both manifests before interpretation. + +The original discovery runs unchanged. Its ordered clean-test prefix must match +the 21 codeunits captured in W1 job 112207627851, run 37372848860. Seven batches +end with Expense Users, Capabilities and Activity Log. No ordinary-test phase, +HTTP warmup, auth mutation, extra sleep, force-refresh or pool clearing is added. +The existing mount-state polling remains. Copy failures and test failures are +terminal; baseline copy/transient retries are deliberately disabled in both arms. +A failed batch drains its three workers and prevents later batches. + +Control restores worker databases under `tenant2/3/4`. Treatment preserves those +logical IDs/URLs, but uses `tenantN_rRUN_gGENERATION` database names. Both arms +read the same SQL identity fields before and after reset. This administrative +probe has a possible timing effect and does not reveal the NST internal pool +generation. A fresh SQL name does not guarantee a fresh database ID or GUID. + +Deletion is allowed only for a tracked worker's previously mounted local database, +after dismount. A stale/foreign mapping or pre-existing fresh target fails closed. +The caller's tenant objects are updated after each successful mount, including +the discovery restore. Default/application/template databases are not deleted by +the pilot. The immutable template is retained until whole-container teardown. +The ordinary disposable pipeline teardown plus the final ownership-checked +fallback clean up the complete container, including partial failed copies. +Runner loss/hard cancellation may prevent exports/finally; that is inconclusive. + +Artifacts contain package hashes, effective identities, ordered worklist, +reset UTC/SQL identity timeline, worker output, JUnit and raw EVTX. +No credentials, access keys or authorization headers are added to diagnostics. +Missing artifacts, setup drift, timeout or no reproduction mean **inconclusive**. +Control-only reproduction is a name-reuse signal, not proof of a production fix. + +Local validation (Pester 5, no NST required): +`Invoke-Pester .\build\scripts\SqlResetPilot`