Modules/businessdev.ALbuild.Core/Public/Test-ALbuildLicense.ps1
|
function Test-ALbuildLicense { <# .SYNOPSIS Verifies an ALbuild commercial license against the licensing service. .DESCRIPTION Free-tier functionality performs no license check. Licensed features call this to verify a tenant's license via the 365 business development licensing service. The result is cached per tenant for the session. Network/service failures do NOT throw - they return an object with IsValid = $false and a Reason - so that license enforcement is an explicit decision of the caller (see Assert-ALbuildLicensed), never an accidental side effect. A verification is a WRITE: the service answers by moving the license's validity forward, so two builds verifying the same tenant in the same instant collide and one is rejected with HTTP 400. Requests therefore retry with jitter (Invoke-ALbuildLicenseRequest) - four parallel build jobs on one organization is an ordinary day, not an edge case. Two offline accommodations exist for trusted agents that cannot always reach the service: * ALBUILD_LICENSE_KEY - when this environment variable is set, the tenant is authorized without contacting the service (set it as a secret on agents in locked-down networks). * Grace window - a successful verification is cached to disk; if the service is later unreachable, that cached result keeps the feature working for LicenseGraceDays days (config, default 14). An explicit invalid/expired response is never graced (the cache is cleared), so a revoked license cannot keep working offline. .PARAMETER TenantId The Azure DevOps organization / collection id. Defaults to $env:System_CollectionId. .PARAMETER TenantName The Azure DevOps organization / collection URI. Defaults to $env:System_CollectionUri. .PARAMETER Refresh Bypass the per-tenant session cache and re-query the service. .EXAMPLE Test-ALbuildLicense -TenantId $env:System_CollectionId .OUTPUTS PSCustomObject with IsValid, Status, IsTrial, ExpiresOn, TenantId, TenantName, Reason. #> [CmdletBinding()] param( [string] $TenantId = $env:System_CollectionId, [string] $TenantName = $env:System_CollectionUri, [switch] $Refresh ) $normalizedName = if ($TenantName) { $TenantName -replace '^https?://', '' -replace '/+$', '' } else { '' } # Offline override for trusted agents that cannot reach the licensing service (e.g. a self-hosted # agent in a locked-down customer network). The organization sets ALBUILD_LICENSE_KEY as a secret # on those agents; its presence authorizes the licensed features without contacting the service. if (-not [string]::IsNullOrWhiteSpace($env:ALBUILD_LICENSE_KEY)) { return [PSCustomObject]@{ IsValid = $true; Status = 'OfflineKey'; IsTrial = $false; ExpiresOn = $null TenantId = $TenantId; TenantName = $normalizedName Reason = 'Authorized by the offline license key (ALBUILD_LICENSE_KEY).' } } if ([string]::IsNullOrWhiteSpace($TenantId)) { return [PSCustomObject]@{ IsValid = $false; Status = 'NoTenant'; IsTrial = $false; ExpiresOn = $null TenantId = $TenantId; TenantName = $TenantName Reason = 'No tenant id available (set -TenantId, ALBUILD_LICENSE_KEY, or run inside Azure DevOps).' } } if (-not $script:ALbuildLicenseCache) { $script:ALbuildLicenseCache = @{} } if (-not $Refresh -and $script:ALbuildLicenseCache.ContainsKey($TenantId)) { return $script:ALbuildLicenseCache[$TenantId] } $baseUrl = (Get-ALbuildConfig -Name 'LicensingBaseUrl').TrimEnd('/') $appId = Get-ALbuildConfig -Name 'LicenseAppId' $url = "$baseUrl/v1/apps/$appId/features/$appId/tenant/$TenantId/verifyLicense" $retry = @{ RetryCount = [int](Get-ALbuildConfig -Name 'LicenseRetryCount') RetryDelayMilliseconds = [int](Get-ALbuildConfig -Name 'LicenseRetryDelayMilliseconds') } # Read a property defensively so a partial response (e.g. an explicit denial with no 'license' # node) yields a clean result under Set-StrictMode instead of throwing into the unreachable path. function Get-Field([object] $Object, [string] $Name) { if ($null -eq $Object) { return $null } $prop = $Object.PSObject.Properties[$Name] if ($prop) { $prop.Value } else { $null } } try { $response = Invoke-ALbuildLicenseRequest -Uri $url -Method Get @retry $license = Get-Field $response 'license' $status = [string](Get-Field $response 'status') $isOk = $status -and ($status.ToLowerInvariant() -eq 'ok') # Auto-register a trial when the tenant has no registration yet (mirrors AL.build V1's # Check-License -> Register-Feature flow). Without this only manually-provisioned licenses # work: a brand-new tenant returns not-ok forever. Register a 30-day trial, then re-verify so # the result reflects the freshly-provisioned trial. Only on an explicit not-ok response - a # network failure lands in the catch below and uses the grace cache instead. An expired trial # comes back status 'ok' with a past trialPeriodEndingDate (Assert-ALbuildLicensed enforces # it), so it is never silently re-registered here. if (-not $isOk) { $companyName = if ($normalizedName -like 'dev.azure.com/*') { $normalizedName -replace '^dev\.azure\.com/', '' } else { $normalizedName } $trialEnd = (Get-Date).ToUniversalTime().AddDays(30).ToString('yyyy-MM-ddTHH:mm:ss.fffffffZ') $registerUrl = "$baseUrl/v1/apps/$appId/features/$appId/tenant/$TenantId/registerTrial" $registerBody = @{ tenantName = $normalizedName; companyName = $companyName; trialPeriodEndingDate = $trialEnd } | ConvertTo-Json try { Write-ALbuildLog -Level Information "No license registration for tenant '$TenantId'; registering a 30-day trial." $regResp = Invoke-ALbuildLicenseRequest -Uri $registerUrl -Method Post -Body $registerBody @retry $regStatus = [string](Get-Field $regResp 'status') if ($regStatus -and ($regStatus.ToLowerInvariant() -eq 'ok')) { Write-ALbuildLog -Level Success "Registered a trial for tenant '$TenantId'; re-verifying." $response = Invoke-ALbuildLicenseRequest -Uri $url -Method Get @retry $license = Get-Field $response 'license' $status = [string](Get-Field $response 'status') $isOk = $status -and ($status.ToLowerInvariant() -eq 'ok') } else { Write-ALbuildLog -Level Warning "Trial registration for tenant '$TenantId' returned status '$regStatus'." } } catch { Write-ALbuildLog -Level Warning "Could not register a trial for tenant '$TenantId': $($_.Exception.Message)." } } $licenseKey = [string](Get-Field $license 'licenseKey') $isTrial = $isOk -and ($null -ne $license) -and [string]::IsNullOrEmpty($licenseKey) $expires = $null $trialEnd = Get-Field $license 'trialPeriodEndingDate' if ($isOk -and $trialEnd) { [datetime] $parsed = [datetime]::MinValue if ([datetime]::TryParse([string]$trialEnd, [ref] $parsed)) { $expires = $parsed } } $result = [PSCustomObject]@{ IsValid = [bool]$isOk Status = if ($status) { $status } else { 'Unknown' } IsTrial = [bool]$isTrial ExpiresOn = $expires TenantId = $TenantId TenantName = $normalizedName Reason = if ($isOk) { '' } else { "Licensing service returned status '$status'." } } # Persist a valid verification for the offline grace window; drop the cache on an explicit # denial so a revoked license cannot keep working offline. if ($isOk) { Save-ALbuildLicenseCacheEntry -TenantId $TenantId -Status $status -IsTrial ([bool]$isTrial) -ExpiresOn $expires } else { Save-ALbuildLicenseCacheEntry -TenantId $TenantId -Clear } } catch { # Every attempt failed. Fall back to a previously-verified license within the grace window so a # trusted agent that has verified before keeps working through a transient outage. (An agent # that can never reach the service should use ALBUILD_LICENSE_KEY.) # # Name the failure precisely. A status code means the service answered and refused, and # reporting that as 'unreachable' sends whoever reads the log hunting a network problem that # does not exist. $failureStatus = Get-ALbuildHttpStatusCode -ErrorRecord $_ $failureText = if ($failureStatus -gt 0) { "The licensing service rejected the request with HTTP $failureStatus (url '$url')." } else { "Could not reach the licensing service: $($_.Exception.Message) (url '$url')." } $graceDays = [int](Get-ALbuildConfig -Name 'LicenseGraceDays') $cached = if ($graceDays -gt 0) { Get-ALbuildLicenseCacheEntry -TenantId $TenantId } else { $null } if ($cached -and ((((Get-Date) - $cached.VerifiedAt).TotalDays) -le $graceDays)) { $ageDays = [int]((Get-Date) - $cached.VerifiedAt).TotalDays Write-ALbuildLog -Level Warning "$failureText Using the cached license verified $ageDays day(s) ago (grace window $graceDays days)." $result = [PSCustomObject]@{ IsValid = $true; Status = 'Cached'; IsTrial = $cached.IsTrial; ExpiresOn = $cached.ExpiresOn TenantId = $TenantId; TenantName = $normalizedName Reason = 'Using the cached license (licensing service unreachable).' } } else { $result = [PSCustomObject]@{ IsValid = $false; Status = 'Error'; IsTrial = $false; ExpiresOn = $null TenantId = $TenantId; TenantName = $normalizedName Reason = "$failureText Set ALBUILD_LICENSE_KEY on a trusted agent that cannot reach the service." } } } $script:ALbuildLicenseCache[$TenantId] = $result return $result } |