diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..7106e2a --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,27 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "SharpCrafters.Backstage.LicenseServer", + "runtimeExecutable": "dotnet", + "runtimeArgs": ["run", "--project", "src/SharpCrafters.Backstage.LicenseServer.Web"], + "port": 44670, + "url": "http://localhost:44670" + }, + { + "name": "SharpCrafters.Backstage.LicenseServer (accelerated)", + "runtimeExecutable": "dotnet", + "runtimeArgs": [ + "run", + "--project", + "src/SharpCrafters.Backstage.LicenseServer.Web", + "--", + "--LicenseServer:TimeAcceleration=1440", + "--LicenseServer:BuildServers=server", + "--Authentication:Scheme=None" + ], + "port": 44670, + "url": "http://localhost:44670" + } + ] +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..955d35b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +**/bin +**/obj +**/.vs +.git +.idea +.teamcity +eng +src +tests +docs +packages +*.db +*.db-shm +*.db-wal +App_Data + +# Everything the build produces, except the unpacked release archive, which is what the image runs. +artifacts/* +!artifacts/app diff --git a/.editorconfig b/.editorconfig new file mode 120000 index 0000000..11790e1 --- /dev/null +++ b/.editorconfig @@ -0,0 +1 @@ +eng/style/.editorconfig \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1903bd6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,26 @@ +# Normalize line endings, so that the repository is the same on every platform. +* text=auto + +*.cs text diff=csharp +*.cshtml text +*.csproj text +*.props text +*.slnx text +*.json text +*.md text +*.css text +*.js text +*.sql text +*.ps1 text eol=crlf + +# The launcher of the Docker tests is generated with Unix line endings by `Build.ps1 generate-scripts`. Without +# this line the rule above rewrites the whole file at every regeneration. +eng/RunDockerTests.ps1 text eol=lf +*.sh text eol=lf +Dockerfile text eol=lf +*.yml text eol=lf + +*.png binary +*.ico binary +*.woff binary +*.woff2 binary diff --git a/.gitignore b/.gitignore index 465c9f1..c366586 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,34 @@ /packages +/artifacts /*.suo bin obj /.build **/.vs +.idea/ *.slnLaunch.user *.csproj.user +*.DotSettings.user +*.db +*.db-shm +*.db-wal +# The directory the server writes its own files to. It holds the key pair of the test licensing +# authority, which a development server signs its own license keys with. The pattern is not anchored, +# because the directory is created next to whichever project is being run. +App_Data/ + +# Generated by PostSharp.Engineering. `./Build.ps1 prepare` writes them from the product definition +# and from the resolved dependencies, so they carry machine-local paths and must not be committed. +global.json +nuget.config +*.g.props +*.g.ps1 +*.g.json +*.Import.props +/source-dependencies/ +/eng/tools/ +/.config/dotnet-tools.json + +# The local tool manifest that `Build.ps1 codestyle format` writes when it installs the JetBrains +# command line tools. It pins a version per machine and is not part of the product. +/dotnet-tools.json diff --git a/.idea/.idea.PostSharp.LicenseServer/.idea/.gitignore b/.idea/.idea.PostSharp.LicenseServer/.idea/.gitignore deleted file mode 100644 index b1620ce..0000000 --- a/.idea/.idea.PostSharp.LicenseServer/.idea/.gitignore +++ /dev/null @@ -1,13 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Rider ignored files -/contentModel.xml -/projectSettingsUpdater.xml -/.idea.PostSharp.LicenseServer.iml -/modules.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/.idea.PostSharp.LicenseServer/.idea/encodings.xml b/.idea/.idea.PostSharp.LicenseServer/.idea/encodings.xml deleted file mode 100644 index df87cf9..0000000 --- a/.idea/.idea.PostSharp.LicenseServer/.idea/encodings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/.idea.PostSharp.LicenseServer/.idea/indexLayout.xml b/.idea/.idea.PostSharp.LicenseServer/.idea/indexLayout.xml deleted file mode 100644 index 7b08163..0000000 --- a/.idea/.idea.PostSharp.LicenseServer/.idea/indexLayout.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/.idea.PostSharp.LicenseServer/.idea/vcs.xml b/.idea/.idea.PostSharp.LicenseServer/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/.idea.PostSharp.LicenseServer/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/config/applicationhost.config b/.idea/config/applicationhost.config deleted file mode 100644 index 1a9997f..0000000 --- a/.idea/config/applicationhost.config +++ /dev/null @@ -1,972 +0,0 @@ - - - - - - -
-
-
-
-
-
-
-
- - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
- -
-
-
-
-
-
- -
-
-
-
-
- -
-
-
- -
-
- -
-
- -
-
-
- - -
-
-
-
-
-
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.nuget/nuget.exe b/.nuget/nuget.exe deleted file mode 100644 index 8f393dd..0000000 Binary files a/.nuget/nuget.exe and /dev/null differ diff --git a/.teamcity/pom.xml b/.teamcity/pom.xml new file mode 100644 index 0000000..ecdeb4e --- /dev/null +++ b/.teamcity/pom.xml @@ -0,0 +1,104 @@ + + + 4.0.0 + Backstage_BackstageLicenseServer20270 Config DSL Script + Backstage_BackstageLicenseServer20270 + Backstage_BackstageLicenseServer20270_dsl + 1.0-SNAPSHOT + + + org.jetbrains.teamcity + configs-dsl-kotlin-parent + 1.0-SNAPSHOT + + + + + jetbrains-all + https://download.jetbrains.com/teamcity-repository + + true + + + + teamcity-server + https://postsharp.teamcity.com/app/dsl-plugins-repository + + true + + + + + + + JetBrains + https://download.jetbrains.com/teamcity-repository + + + + + ${basedir} + + + kotlin-maven-plugin + org.jetbrains.kotlin + ${kotlin.version} + + + + + compile + process-sources + + compile + + + + test-compile + process-test-sources + + test-compile + + + + + + org.jetbrains.teamcity + teamcity-configs-maven-plugin + ${teamcity.dsl.version} + + kotlin + target/generated-configs + + + + + + + + org.jetbrains.teamcity + configs-dsl-kotlin-latest + ${teamcity.dsl.version} + compile + + + org.jetbrains.teamcity + configs-dsl-kotlin-plugins-latest + 1.0-SNAPSHOT + pom + compile + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + ${kotlin.version} + compile + + + org.jetbrains.kotlin + kotlin-script-runtime + ${kotlin.version} + compile + + + \ No newline at end of file diff --git a/.teamcity/settings.kts b/.teamcity/settings.kts new file mode 100644 index 0000000..7491382 --- /dev/null +++ b/.teamcity/settings.kts @@ -0,0 +1,675 @@ +// This file is automatically generated by `Build.ps1 generate-scripts`. + +import jetbrains.buildServer.configs.kotlin.* +import jetbrains.buildServer.configs.kotlin.buildFeatures.* +import jetbrains.buildServer.configs.kotlin.buildSteps.* +import jetbrains.buildServer.configs.kotlin.failureConditions.* +import jetbrains.buildServer.configs.kotlin.triggers.* +import jetbrains.buildServer.configs.kotlin.projectFeatures.* + +version = "2025.11" + +project { + + buildType(DebugBuild) + buildType(ReleaseBuild) + buildType(PublicBuild) + buildType(PublicDeployment) + buildType(VersionBump) + + buildTypesOrder = arrayListOf(DebugBuild,ReleaseBuild,PublicBuild,PublicDeployment,VersionBump) + + subProject(DockerTests) + + subProjectsOrder = arrayListOf(DockerTests) + +} + +object DebugBuild : BuildType({ + + name = "Build [Debug]" + + artifactRules = """+:artifacts/publish/public/**/*=>artifacts/publish/public ++:artifacts/publish/private/**/*=>artifacts/publish/private ++:artifacts/testResults/**/*=>artifacts/testResults ++:artifacts/logs/**/*=>logs ++:artifacts/dumps/**/*=>dumps +""" + + params { + text( + "Build.Arguments", + "", + label ="DockerBuild.ps1 Arguments", + description = "Arguments to append to the 'Build' build step.", allowEmpty = true) + param("Build.Timeout", "30") + } + + vcs { + root(AbsoluteId("Backstage_BackstageLicenseServer20270")) + checkoutMode = CheckoutMode.ON_AGENT + } + + steps { + powerShell { + name = "Clean NuGet cache of produced and dependency packages" + id = "CleanNuGetCache" + edition = PowerShellStep.Edition.Core + scriptMode = script { + content = "${'$'}nugetPackages = if ( ${'$'}env:NUGET_PACKAGES ) { ${'$'}env:NUGET_PACKAGES } else { Join-Path ${'$'}HOME '.nuget' 'packages' }; ${'$'}removedDirs = 0; ${'$'}removedFiles = 0; if ( Test-Path -LiteralPath ${'$'}nugetPackages ) { foreach ( ${'$'}pattern in @('metalama.backstage*', 'postsharp.engineering', 'postsharp.engineering.*', 'sharpcrafters.backstage*', 'sharpcrafters.backstage.licenseserver*', 'sharpcrafters.common*') ) { Get-ChildItem -LiteralPath ${'$'}nugetPackages -Directory -Filter ${'$'}pattern -ErrorAction SilentlyContinue | ForEach-Object { ${'$'}files = @( Get-ChildItem -LiteralPath ${'$'}_.FullName -Recurse -File -ErrorAction SilentlyContinue ).Count; Write-Host \"Removing NuGet cache directory: ${'$'}(${'$'}_.FullName) (${'$'}files file(s))\"; Remove-Item -LiteralPath ${'$'}_.FullName -Recurse -Force -ErrorAction SilentlyContinue; if ( -not ( Test-Path -LiteralPath ${'$'}_.FullName ) ) { ${'$'}removedDirs++; ${'$'}removedFiles += ${'$'}files } } } Write-Host \"Removed ${'$'}removedDirs package directory(ies) and ${'$'}removedFiles file(s) from the NuGet cache.\"; } else { Write-Host \"NuGet packages folder not found: ${'$'}nugetPackages\" }" + } + noProfile = false + } + powerShell { + name = "Prepare Docker image backstagelicenseserver-2027.0" + id = "PrepareImage" + edition = PowerShellStep.Edition.Core + scriptMode = file { + path = "DockerBuild.ps1" + } + noProfile = false + scriptArgs = "-BuildImage -ImageName backstagelicenseserver-2027.0 " + } + powerShell { + name = "Build" + id = "Build" + edition = PowerShellStep.Edition.Core + scriptMode = file { + path = "DockerBuild.ps1" + } + noProfile = false + scriptArgs = "-Script Build.ps1 -ImageName backstagelicenseserver-2027.0 -NoBuildImage -Label %system.teamcity.buildType.id%_%build.number% test --configuration Debug --buildNumber %build.number% --buildType %system.teamcity.buildType.id% --timeout %Build.Timeout% %Build.Arguments%" + } + powerShell { + name = "Cleanup Docker containers" + id = "DockerCleanup" + executionMode = BuildStep.ExecutionMode.ALWAYS + edition = PowerShellStep.Edition.Core + scriptMode = script { + content = "${'$'}label = \"%system.teamcity.buildType.id%_%build.number%\"; ${'$'}ids = docker ps -a -q --filter \"label=postsharp.build=${'$'}label\"; if (${'$'}ids) { docker rm -f ${'$'}ids 2>&1 | Out-Null }" + } + noProfile = false + } + } + + requirements { + matches("teamcity.agent.jvm.os.family", "Windows") + matches("teamcity.agent.jvm.os.arch", "amd64") + equals("env.BuildAgentType", "docker-win-x64-md") + } + + features { + swabra { + filesCleanup = Swabra.FilesCleanup.BEFORE_BUILD + lockingProcesses = Swabra.LockingProcessPolicy.KILL + verbose = true + } + gitHubAppBuildScopedToken { + parameterName = "env.GITHUB_TOKEN" + connectionId = "%GITHUB_CONNECTION_POSTSHARP_OPS%" + targetRepositories = "SharpCrafters.Backstage.LicenseServer" + } + commitStatusPublisher { + vcsRootExtId = "Backstage_BackstageLicenseServer20270" + publisher = github { + githubUrl = "https://api.github.com" + authType = vcsRoot() + } + } + pullRequests { + vcsRootExtId = "Backstage_BackstageLicenseServer20270" + provider = github { + authType = vcsRoot() + filterTargetBranch = "+:refs/heads/develop/2027.0" + filterAuthorRole = PullRequests.GitHubRoleFilter.EVERYBODY + } + } + } + + triggers { + vcs { + watchChangesInDependencies = true + branchFilter = "+:develop/2027.0" + quietPeriodMode = VcsTrigger.QuietPeriodMode.USE_CUSTOM + quietPeriod = 7200 + // Build will not trigger automatically if the commit message contains comment value. + triggerRules = "-:comment=<>|<>:**" + } + } + + dependencies { + snapshot(AbsoluteId("Backstage_Backstage20270_DebugBuild")) { + onDependencyFailure = FailureAction.FAIL_TO_START + } + + artifacts(AbsoluteId("Backstage_Backstage20270_DebugBuild")) { + cleanDestination = true + artifactRules = "+:artifacts/publish/private/**/*=>dependencies/Backstage" + } + } + +}) + +object ReleaseBuild : BuildType({ + + name = "Build [Release]" + + artifactRules = """+:artifacts/publish/public/**/*=>artifacts/publish/public ++:artifacts/publish/private/**/*=>artifacts/publish/private ++:artifacts/testResults/**/*=>artifacts/testResults ++:artifacts/logs/**/*=>logs ++:artifacts/dumps/**/*=>dumps +""" + + params { + text( + "Build.Arguments", + "", + label ="DockerBuild.ps1 Arguments", + description = "Arguments to append to the 'Build' build step.", allowEmpty = true) + param("Build.Timeout", "30") + } + + vcs { + root(AbsoluteId("Backstage_BackstageLicenseServer20270")) + checkoutMode = CheckoutMode.ON_AGENT + } + + steps { + powerShell { + name = "Clean NuGet cache of produced and dependency packages" + id = "CleanNuGetCache" + edition = PowerShellStep.Edition.Core + scriptMode = script { + content = "${'$'}nugetPackages = if ( ${'$'}env:NUGET_PACKAGES ) { ${'$'}env:NUGET_PACKAGES } else { Join-Path ${'$'}HOME '.nuget' 'packages' }; ${'$'}removedDirs = 0; ${'$'}removedFiles = 0; if ( Test-Path -LiteralPath ${'$'}nugetPackages ) { foreach ( ${'$'}pattern in @('metalama.backstage*', 'postsharp.engineering', 'postsharp.engineering.*', 'sharpcrafters.backstage*', 'sharpcrafters.backstage.licenseserver*', 'sharpcrafters.common*') ) { Get-ChildItem -LiteralPath ${'$'}nugetPackages -Directory -Filter ${'$'}pattern -ErrorAction SilentlyContinue | ForEach-Object { ${'$'}files = @( Get-ChildItem -LiteralPath ${'$'}_.FullName -Recurse -File -ErrorAction SilentlyContinue ).Count; Write-Host \"Removing NuGet cache directory: ${'$'}(${'$'}_.FullName) (${'$'}files file(s))\"; Remove-Item -LiteralPath ${'$'}_.FullName -Recurse -Force -ErrorAction SilentlyContinue; if ( -not ( Test-Path -LiteralPath ${'$'}_.FullName ) ) { ${'$'}removedDirs++; ${'$'}removedFiles += ${'$'}files } } } Write-Host \"Removed ${'$'}removedDirs package directory(ies) and ${'$'}removedFiles file(s) from the NuGet cache.\"; } else { Write-Host \"NuGet packages folder not found: ${'$'}nugetPackages\" }" + } + noProfile = false + } + powerShell { + name = "Prepare Docker image backstagelicenseserver-2027.0" + id = "PrepareImage" + edition = PowerShellStep.Edition.Core + scriptMode = file { + path = "DockerBuild.ps1" + } + noProfile = false + scriptArgs = "-BuildImage -ImageName backstagelicenseserver-2027.0 " + } + powerShell { + name = "Build" + id = "Build" + edition = PowerShellStep.Edition.Core + scriptMode = file { + path = "DockerBuild.ps1" + } + noProfile = false + scriptArgs = "-Script Build.ps1 -ImageName backstagelicenseserver-2027.0 -NoBuildImage -Label %system.teamcity.buildType.id%_%build.number% test --configuration Release --buildNumber %build.number% --buildType %system.teamcity.buildType.id% --timeout %Build.Timeout% %Build.Arguments%" + } + powerShell { + name = "Cleanup Docker containers" + id = "DockerCleanup" + executionMode = BuildStep.ExecutionMode.ALWAYS + edition = PowerShellStep.Edition.Core + scriptMode = script { + content = "${'$'}label = \"%system.teamcity.buildType.id%_%build.number%\"; ${'$'}ids = docker ps -a -q --filter \"label=postsharp.build=${'$'}label\"; if (${'$'}ids) { docker rm -f ${'$'}ids 2>&1 | Out-Null }" + } + noProfile = false + } + } + + requirements { + matches("teamcity.agent.jvm.os.family", "Windows") + matches("teamcity.agent.jvm.os.arch", "amd64") + equals("env.BuildAgentType", "docker-win-x64-md") + } + + features { + swabra { + filesCleanup = Swabra.FilesCleanup.BEFORE_BUILD + lockingProcesses = Swabra.LockingProcessPolicy.KILL + verbose = true + } + gitHubAppBuildScopedToken { + parameterName = "env.GITHUB_TOKEN" + connectionId = "%GITHUB_CONNECTION_POSTSHARP_OPS%" + targetRepositories = "SharpCrafters.Backstage.LicenseServer" + } + commitStatusPublisher { + vcsRootExtId = "Backstage_BackstageLicenseServer20270" + publisher = github { + githubUrl = "https://api.github.com" + authType = vcsRoot() + } + } + pullRequests { + vcsRootExtId = "Backstage_BackstageLicenseServer20270" + provider = github { + authType = vcsRoot() + filterTargetBranch = "+:refs/heads/develop/2027.0" + filterAuthorRole = PullRequests.GitHubRoleFilter.EVERYBODY + } + } + } + + dependencies { + snapshot(AbsoluteId("Backstage_Backstage20270_ReleaseBuild")) { + onDependencyFailure = FailureAction.FAIL_TO_START + } + + artifacts(AbsoluteId("Backstage_Backstage20270_ReleaseBuild")) { + cleanDestination = true + artifactRules = "+:artifacts/publish/private/**/*=>dependencies/Backstage" + } + } + +}) + +object PublicBuild : BuildType({ + + name = "Build [Public]" + + artifactRules = """+:artifacts/publish/public/**/*=>artifacts/publish/public ++:artifacts/publish/private/**/*=>artifacts/publish/private ++:artifacts/testResults/**/*=>artifacts/testResults ++:artifacts/logs/**/*=>logs ++:artifacts/dumps/**/*=>dumps +""" + + params { + text( + "Build.Arguments", + "", + label ="DockerBuild.ps1 Arguments", + description = "Arguments to append to the 'Build' build step.", allowEmpty = true) + param("Build.Timeout", "30") + } + + vcs { + root(AbsoluteId("Backstage_BackstageLicenseServer20270")) + checkoutMode = CheckoutMode.ON_AGENT + } + + steps { + powerShell { + name = "Clean NuGet cache of produced and dependency packages" + id = "CleanNuGetCache" + edition = PowerShellStep.Edition.Core + scriptMode = script { + content = "${'$'}nugetPackages = if ( ${'$'}env:NUGET_PACKAGES ) { ${'$'}env:NUGET_PACKAGES } else { Join-Path ${'$'}HOME '.nuget' 'packages' }; ${'$'}removedDirs = 0; ${'$'}removedFiles = 0; if ( Test-Path -LiteralPath ${'$'}nugetPackages ) { foreach ( ${'$'}pattern in @('metalama.backstage*', 'postsharp.engineering', 'postsharp.engineering.*', 'sharpcrafters.backstage*', 'sharpcrafters.backstage.licenseserver*', 'sharpcrafters.common*') ) { Get-ChildItem -LiteralPath ${'$'}nugetPackages -Directory -Filter ${'$'}pattern -ErrorAction SilentlyContinue | ForEach-Object { ${'$'}files = @( Get-ChildItem -LiteralPath ${'$'}_.FullName -Recurse -File -ErrorAction SilentlyContinue ).Count; Write-Host \"Removing NuGet cache directory: ${'$'}(${'$'}_.FullName) (${'$'}files file(s))\"; Remove-Item -LiteralPath ${'$'}_.FullName -Recurse -Force -ErrorAction SilentlyContinue; if ( -not ( Test-Path -LiteralPath ${'$'}_.FullName ) ) { ${'$'}removedDirs++; ${'$'}removedFiles += ${'$'}files } } } Write-Host \"Removed ${'$'}removedDirs package directory(ies) and ${'$'}removedFiles file(s) from the NuGet cache.\"; } else { Write-Host \"NuGet packages folder not found: ${'$'}nugetPackages\" }" + } + noProfile = false + } + powerShell { + name = "Prepare Docker image backstagelicenseserver-2027.0" + id = "PrepareImage" + edition = PowerShellStep.Edition.Core + scriptMode = file { + path = "DockerBuild.ps1" + } + noProfile = false + scriptArgs = "-BuildImage -ImageName backstagelicenseserver-2027.0 " + } + powerShell { + name = "Build" + id = "Build" + edition = PowerShellStep.Edition.Core + scriptMode = file { + path = "DockerBuild.ps1" + } + noProfile = false + scriptArgs = "-Script Build.ps1 -ImageName backstagelicenseserver-2027.0 -NoBuildImage -Label %system.teamcity.buildType.id%_%build.number% test --configuration Public --buildNumber %build.number% --buildType %system.teamcity.buildType.id% --timeout %Build.Timeout% %Build.Arguments%" + } + powerShell { + name = "Cleanup Docker containers" + id = "DockerCleanup" + executionMode = BuildStep.ExecutionMode.ALWAYS + edition = PowerShellStep.Edition.Core + scriptMode = script { + content = "${'$'}label = \"%system.teamcity.buildType.id%_%build.number%\"; ${'$'}ids = docker ps -a -q --filter \"label=postsharp.build=${'$'}label\"; if (${'$'}ids) { docker rm -f ${'$'}ids 2>&1 | Out-Null }" + } + noProfile = false + } + } + + requirements { + matches("teamcity.agent.jvm.os.family", "Windows") + matches("teamcity.agent.jvm.os.arch", "amd64") + equals("env.BuildAgentType", "docker-win-x64-md") + } + + features { + swabra { + filesCleanup = Swabra.FilesCleanup.BEFORE_BUILD + lockingProcesses = Swabra.LockingProcessPolicy.KILL + verbose = true + } + gitHubAppBuildScopedToken { + parameterName = "env.GITHUB_TOKEN" + connectionId = "%GITHUB_CONNECTION_POSTSHARP_OPS%" + targetRepositories = "SharpCrafters.Backstage.LicenseServer" + } + commitStatusPublisher { + vcsRootExtId = "Backstage_BackstageLicenseServer20270" + publisher = github { + githubUrl = "https://api.github.com" + authType = vcsRoot() + } + } + pullRequests { + vcsRootExtId = "Backstage_BackstageLicenseServer20270" + provider = github { + authType = vcsRoot() + filterTargetBranch = "+:refs/heads/develop/2027.0" + filterAuthorRole = PullRequests.GitHubRoleFilter.EVERYBODY + } + } + } + + dependencies { + snapshot(AbsoluteId("Backstage_Backstage20270_PublicBuild")) { + onDependencyFailure = FailureAction.FAIL_TO_START + } + + artifacts(AbsoluteId("Backstage_Backstage20270_PublicBuild")) { + cleanDestination = true + artifactRules = "+:artifacts/publish/private/**/*=>dependencies/Backstage" + } + } + +}) + +object PublicDeployment : BuildType({ + + name = "Deploy [Public]" + + type = Type.DEPLOYMENT + + params { + text( + "Publish.Arguments", + "", + label ="DockerBuild.ps1 Arguments", + description = "Arguments to append to the 'Publish' build step.", allowEmpty = true) + param("Publish.Timeout", "30") + } + + vcs { + root(AbsoluteId("Backstage_BackstageLicenseServer20270")) + checkoutMode = CheckoutMode.ON_AGENT + } + + steps { + powerShell { + name = "Clean NuGet cache of produced and dependency packages" + id = "CleanNuGetCache" + edition = PowerShellStep.Edition.Core + scriptMode = script { + content = "${'$'}nugetPackages = if ( ${'$'}env:NUGET_PACKAGES ) { ${'$'}env:NUGET_PACKAGES } else { Join-Path ${'$'}HOME '.nuget' 'packages' }; ${'$'}removedDirs = 0; ${'$'}removedFiles = 0; if ( Test-Path -LiteralPath ${'$'}nugetPackages ) { foreach ( ${'$'}pattern in @('metalama.backstage*', 'postsharp.engineering', 'postsharp.engineering.*', 'sharpcrafters.backstage*', 'sharpcrafters.backstage.licenseserver*', 'sharpcrafters.common*') ) { Get-ChildItem -LiteralPath ${'$'}nugetPackages -Directory -Filter ${'$'}pattern -ErrorAction SilentlyContinue | ForEach-Object { ${'$'}files = @( Get-ChildItem -LiteralPath ${'$'}_.FullName -Recurse -File -ErrorAction SilentlyContinue ).Count; Write-Host \"Removing NuGet cache directory: ${'$'}(${'$'}_.FullName) (${'$'}files file(s))\"; Remove-Item -LiteralPath ${'$'}_.FullName -Recurse -Force -ErrorAction SilentlyContinue; if ( -not ( Test-Path -LiteralPath ${'$'}_.FullName ) ) { ${'$'}removedDirs++; ${'$'}removedFiles += ${'$'}files } } } Write-Host \"Removed ${'$'}removedDirs package directory(ies) and ${'$'}removedFiles file(s) from the NuGet cache.\"; } else { Write-Host \"NuGet packages folder not found: ${'$'}nugetPackages\" }" + } + noProfile = false + } + powerShell { + name = "Prepare Docker image backstagelicenseserver-2027.0" + id = "PrepareImage" + edition = PowerShellStep.Edition.Core + scriptMode = file { + path = "DockerBuild.ps1" + } + noProfile = false + scriptArgs = "-BuildImage -ImageName backstagelicenseserver-2027.0 " + } + powerShell { + name = "Publish" + id = "Publish" + edition = PowerShellStep.Edition.Core + scriptMode = file { + path = "DockerBuild.ps1" + } + noProfile = false + scriptArgs = "-Script Build.ps1 -ImageName backstagelicenseserver-2027.0 -NoBuildImage -Label %system.teamcity.buildType.id%_%build.number% publish --configuration Public --deployment default --timeout %Publish.Timeout% %Publish.Arguments%" + } + powerShell { + name = "Cleanup Docker containers" + id = "DockerCleanup" + executionMode = BuildStep.ExecutionMode.ALWAYS + edition = PowerShellStep.Edition.Core + scriptMode = script { + content = "${'$'}label = \"%system.teamcity.buildType.id%_%build.number%\"; ${'$'}ids = docker ps -a -q --filter \"label=postsharp.build=${'$'}label\"; if (${'$'}ids) { docker rm -f ${'$'}ids 2>&1 | Out-Null }" + } + noProfile = false + } + } + + requirements { + matches("teamcity.agent.jvm.os.family", "Windows") + matches("teamcity.agent.jvm.os.arch", "amd64") + equals("env.BuildAgentType", "docker-win-x64-md") + } + + features { + swabra { + filesCleanup = Swabra.FilesCleanup.BEFORE_BUILD + lockingProcesses = Swabra.LockingProcessPolicy.KILL + verbose = true + } + gitHubAppBuildScopedToken { + parameterName = "env.GITHUB_TOKEN" + connectionId = "%GITHUB_CONNECTION_POSTSHARP_OPS%" + targetRepositories = "SharpCrafters.Backstage.LicenseServer" + } + } + + dependencies { + snapshot(AbsoluteId("Backstage_Backstage20270_PublicBuild")) { + onDependencyFailure = FailureAction.FAIL_TO_START + } + + artifacts(AbsoluteId("Backstage_Backstage20270_PublicBuild")) { + cleanDestination = true + artifactRules = "+:artifacts/publish/private/**/*=>dependencies/Backstage" + } + snapshot(AbsoluteId("Backstage_Backstage20270_PublicDeployment")) { + onDependencyFailure = FailureAction.FAIL_TO_START + } + snapshot(PublicBuild) { + onDependencyFailure = FailureAction.FAIL_TO_START + } + + artifacts(PublicBuild) { + cleanDestination = true + artifactRules = "+:artifacts/publish/public/**/*=>artifacts/publish/public\n+:artifacts/publish/private/**/*=>artifacts/publish/private" + } + } + +}) + +object VersionBump : BuildType({ + + name = "Version Bump" + + params { + text( + "Bump.Arguments", + "", + label ="DockerBuild.ps1 Arguments", + description = "Arguments to append to the 'Bump' build step.", allowEmpty = true) + param("Bump.Timeout", "15") + } + + vcs { + root(AbsoluteId("Backstage_BackstageLicenseServer20270")) + checkoutMode = CheckoutMode.ON_AGENT + } + + steps { + powerShell { + name = "Clean NuGet cache of produced and dependency packages" + id = "CleanNuGetCache" + edition = PowerShellStep.Edition.Core + scriptMode = script { + content = "${'$'}nugetPackages = if ( ${'$'}env:NUGET_PACKAGES ) { ${'$'}env:NUGET_PACKAGES } else { Join-Path ${'$'}HOME '.nuget' 'packages' }; ${'$'}removedDirs = 0; ${'$'}removedFiles = 0; if ( Test-Path -LiteralPath ${'$'}nugetPackages ) { foreach ( ${'$'}pattern in @('metalama.backstage*', 'postsharp.engineering', 'postsharp.engineering.*', 'sharpcrafters.backstage*', 'sharpcrafters.backstage.licenseserver*', 'sharpcrafters.common*') ) { Get-ChildItem -LiteralPath ${'$'}nugetPackages -Directory -Filter ${'$'}pattern -ErrorAction SilentlyContinue | ForEach-Object { ${'$'}files = @( Get-ChildItem -LiteralPath ${'$'}_.FullName -Recurse -File -ErrorAction SilentlyContinue ).Count; Write-Host \"Removing NuGet cache directory: ${'$'}(${'$'}_.FullName) (${'$'}files file(s))\"; Remove-Item -LiteralPath ${'$'}_.FullName -Recurse -Force -ErrorAction SilentlyContinue; if ( -not ( Test-Path -LiteralPath ${'$'}_.FullName ) ) { ${'$'}removedDirs++; ${'$'}removedFiles += ${'$'}files } } } Write-Host \"Removed ${'$'}removedDirs package directory(ies) and ${'$'}removedFiles file(s) from the NuGet cache.\"; } else { Write-Host \"NuGet packages folder not found: ${'$'}nugetPackages\" }" + } + noProfile = false + } + powerShell { + name = "Prepare Docker image backstagelicenseserver-2027.0" + id = "PrepareImage" + edition = PowerShellStep.Edition.Core + scriptMode = file { + path = "DockerBuild.ps1" + } + noProfile = false + scriptArgs = "-BuildImage -ImageName backstagelicenseserver-2027.0 " + } + powerShell { + name = "Bump" + id = "Bump" + edition = PowerShellStep.Edition.Core + scriptMode = file { + path = "DockerBuild.ps1" + } + noProfile = false + scriptArgs = "-Script Build.ps1 -ImageName backstagelicenseserver-2027.0 -NoBuildImage -Label %system.teamcity.buildType.id%_%build.number% bump --timeout %Bump.Timeout% %Bump.Arguments%" + } + powerShell { + name = "Cleanup Docker containers" + id = "DockerCleanup" + executionMode = BuildStep.ExecutionMode.ALWAYS + edition = PowerShellStep.Edition.Core + scriptMode = script { + content = "${'$'}label = \"%system.teamcity.buildType.id%_%build.number%\"; ${'$'}ids = docker ps -a -q --filter \"label=postsharp.build=${'$'}label\"; if (${'$'}ids) { docker rm -f ${'$'}ids 2>&1 | Out-Null }" + } + noProfile = false + } + } + + requirements { + matches("teamcity.agent.jvm.os.family", "Windows") + matches("teamcity.agent.jvm.os.arch", "amd64") + equals("env.BuildAgentType", "docker-win-x64-md") + } + + features { + swabra { + filesCleanup = Swabra.FilesCleanup.BEFORE_BUILD + lockingProcesses = Swabra.LockingProcessPolicy.KILL + verbose = true + } + gitHubAppBuildScopedToken { + parameterName = "env.GITHUB_TOKEN" + connectionId = "%GITHUB_CONNECTION_POSTSHARP_OPS%" + targetRepositories = "SharpCrafters.Backstage.LicenseServer" + } + } + +}) + +object DockerTestsLinuxX64 : BuildType({ + + name = "Docker Tests (Linux x64)" + + params { + text( + "Exec.Arguments", + "", + label ="eng/RunDockerTests.ps1 Arguments", + description = "Arguments to append to the 'Execute eng/RunDockerTests.ps1' build step.", allowEmpty = true) + } + + vcs { + root(AbsoluteId("Backstage_BackstageLicenseServer20270")) + checkoutMode = CheckoutMode.ON_AGENT + } + + steps { + powerShell { + name = "Clean NuGet cache of produced and dependency packages" + id = "CleanNuGetCache" + edition = PowerShellStep.Edition.Core + scriptMode = script { + content = "${'$'}nugetPackages = if ( ${'$'}env:NUGET_PACKAGES ) { ${'$'}env:NUGET_PACKAGES } else { Join-Path ${'$'}HOME '.nuget' 'packages' }; ${'$'}removedDirs = 0; ${'$'}removedFiles = 0; if ( Test-Path -LiteralPath ${'$'}nugetPackages ) { foreach ( ${'$'}pattern in @('metalama.backstage*', 'postsharp.engineering', 'postsharp.engineering.*', 'sharpcrafters.backstage*', 'sharpcrafters.backstage.licenseserver*', 'sharpcrafters.common*') ) { Get-ChildItem -LiteralPath ${'$'}nugetPackages -Directory -Filter ${'$'}pattern -ErrorAction SilentlyContinue | ForEach-Object { ${'$'}files = @( Get-ChildItem -LiteralPath ${'$'}_.FullName -Recurse -File -ErrorAction SilentlyContinue ).Count; Write-Host \"Removing NuGet cache directory: ${'$'}(${'$'}_.FullName) (${'$'}files file(s))\"; Remove-Item -LiteralPath ${'$'}_.FullName -Recurse -Force -ErrorAction SilentlyContinue; if ( -not ( Test-Path -LiteralPath ${'$'}_.FullName ) ) { ${'$'}removedDirs++; ${'$'}removedFiles += ${'$'}files } } } Write-Host \"Removed ${'$'}removedDirs package directory(ies) and ${'$'}removedFiles file(s) from the NuGet cache.\"; } else { Write-Host \"NuGet packages folder not found: ${'$'}nugetPackages\" }" + } + noProfile = false + } + powerShell { + name = "Copy nuget.restored.config to nuget.config" + id = "CopyNuGetConfig" + edition = PowerShellStep.Edition.Core + scriptMode = script { + content = "Copy-Item -Path \"artifacts/publish/private/nuget.restored.config\" -Destination \"nuget.config\" -Force;" + } + noProfile = false + } + powerShell { + name = "Create eng/Versions.g.props" + id = "CreateVersionsFile" + edition = PowerShellStep.Edition.Core + scriptMode = script { + content = "New-Item -Path \"eng/Versions.g.props\" -ItemType File -Force -Value \"\" | Out-Null;" + } + noProfile = false + } + powerShell { + name = "Execute eng/RunDockerTests.ps1" + id = "Exec" + edition = PowerShellStep.Edition.Core + scriptMode = file { + path = "eng/RunDockerTests.ps1" + } + noProfile = false + scriptArgs = "-Platform linux-x64 %Exec.Arguments%" + } + } + + failureConditions { + executionTimeoutMin = 60 + } + + requirements { + equals("teamcity.agent.jvm.os.name", "Linux") + equals("teamcity.agent.jvm.os.arch", "amd64") + } + + features { + swabra { + filesCleanup = Swabra.FilesCleanup.BEFORE_BUILD + lockingProcesses = Swabra.LockingProcessPolicy.KILL + verbose = true + } + gitHubAppBuildScopedToken { + parameterName = "env.GITHUB_TOKEN" + connectionId = "%GITHUB_CONNECTION_POSTSHARP_OPS%" + targetRepositories = "SharpCrafters.Backstage.LicenseServer" + } + } + + dependencies { + snapshot(DebugBuild) { + onDependencyFailure = FailureAction.FAIL_TO_START + } + + artifacts(DebugBuild) { + cleanDestination = true + artifactRules = "+:artifacts/publish/private/**/*=>artifacts/publish/private" + } + snapshot(AbsoluteId("Backstage_Backstage20270_DebugBuild")) { + onDependencyFailure = FailureAction.FAIL_TO_START + } + + artifacts(AbsoluteId("Backstage_Backstage20270_DebugBuild")) { + cleanDestination = true + artifactRules = "+:artifacts/publish/private/**/*=>dependencies/Backstage" + } + } + +}) + +object DockerTests : Project({ + + name = "Docker Tests" + + buildType(DockerTestsLinuxX64) + + buildTypesOrder = arrayListOf(DockerTestsLinuxX64) + +}) diff --git a/Build.ps1 b/Build.ps1 new file mode 100644 index 0000000..acc1b6b --- /dev/null +++ b/Build.ps1 @@ -0,0 +1,249 @@ +# *** DO NOT EDIT THIS FILE DIRECTLY *** +# This file is auto-generated from src/PostSharp.Engineering.BuildTools/Resources/Build.ps1 +# Edit the source version, then run `./Build.ps1 generate-scripts` to regenerate this file. + +[CmdletBinding(PositionalBinding = $false)] +param( + [switch]$Interactive, # Opens an interactive PowerShell session + [switch]$StartVsmon, # Enable the remote debugger. + [switch]$NoCache, # Bypass the build cache for `eng`, and force a rebuild. + [switch]$Snapshot, # Copy built output to temp directory to avoid file locking during execution. + [switch]$SnapshotRepo, # Copy the repo to a temp directory and execute the build from there. + [Parameter(ValueFromRemainingArguments)] + [string[]]$BuildArgs # Arguments passed to `Build.ps1` within the container. +) + +# Require PowerShell 7.4 or higher (the version installed on GitHub build agents) +if ($PSVersionTable.PSVersion -lt [Version]'7.4') +{ + Write-Error "This script requires PowerShell 7.4 or higher (run with 'pwsh', not 'powershell'). Current version: $($PSVersionTable.PSVersion)" + exit 1 +} + +#### +# These settings are replaced by the generate-scripts command. +$EngPath = 'eng' +$ProductName = 'BackstageLicenseServer' +#### + +if ($StartVsmon) +{ + $vsmonport = 4024 + Write-Host "Starting Visual Studio Remote Debugger, listening at port $vsmonport." -ForegroundColor Cyan + $vsmonProcess = Start-Process -FilePath "C:\msvsmon\msvsmon.exe" ` + -ArgumentList "/noauth","/anyuser","/silent","/port:$vsmonport","/timeout:2147483647" ` + -NoNewWindow -PassThru +} + +# Change the prompt and window title in Docker. +if ($env:RUNNING_IN_DOCKER) +{ + function global:prompt + { + $host.UI.RawUI.WindowTitle = "[docker] " + (Get-Location).Path + "[docker] $( Get-Location )> " + } +} + + +# The exit code of the engineering tool, propagated as the script's own exit code at the very end. Initialized here, +# at script scope, so a purely interactive run that never invokes the tool still exits 0. +$engExitCode = 0 + +if (-not $Interactive -or $BuildArgs) +{ + # The generate-scripts command implies -NoCache + if ($BuildArgs -contains 'generate-scripts') + { + $NoCache = $true + } + + # Change the working directory so we can use a global.json that is specific to eng. + $previousLocation = Get-Location + $engSrcPath = Join-Path $PSScriptRoot $EngPath "src" + + Set-Location $engSrcPath + + $snapshotDir = $null + $snapshotRepoDir = $null + + try + { + # SnapshotRepo mode: copy the repo to a temp directory before building + if ($SnapshotRepo) + { + $snapshotRepoDir = Join-Path $env:TEMP "eng-snapshot-repo-$([Guid]::NewGuid().ToString('N').Substring(0,8))" + Write-Host "Creating snapshot of repository at $snapshotRepoDir..." -ForegroundColor Cyan + $snapshotStopwatch = [Diagnostics.Stopwatch]::StartNew() + & robocopy $PSScriptRoot $snapshotRepoDir /E /XD bin obj artifacts /NJH /NJS /NP | Out-Null + if ($LASTEXITCODE -ge 8) + { + throw "robocopy failed with exit code $LASTEXITCODE" + } + $snapshotStopwatch.Stop() + Write-Host "Snapshot created in $($snapshotStopwatch.Elapsed.TotalSeconds.ToString('F1'))s." -ForegroundColor Cyan + # Redirect paths to the snapshot + $engSrcPath = Join-Path $snapshotRepoDir $EngPath "src" + Set-Location $engSrcPath + } + + # Build caching: check if we need to rebuild + $projectPath = Join-Path $engSrcPath "Build$ProductName.csproj" + + # Find the output DLL by looking for the first DLL in bin/Debug/*/ + $binDebugPath = Join-Path $engSrcPath "bin" "Debug" + $outputDll = $null + $tfmDir = $null + if (Test-Path $binDebugPath) + { + $outputDll = Get-ChildItem -Path $binDebugPath -Filter "Build$ProductName.dll" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 | ForEach-Object { $_.FullName } + if ($outputDll) + { + $tfmDir = Split-Path $outputDll -Parent + } + } + + $needsBuild = $false + + if ($NoCache) + { + # Cache bypassed by -NoCache switch + $needsBuild = $true + } + elseif (-not $outputDll -or -not (Test-Path $outputDll)) + { + # DLL doesn't exist, need to build + $needsBuild = $true + } + else + { + # Get the DLL's last write time + $dllTime = (Get-Item $outputDll).LastWriteTime + + # Check files in $EngPath/src (non-recursive) + $engSrcFiles = Get-ChildItem -Path (Join-Path $PSScriptRoot $EngPath "src") -File -ErrorAction SilentlyContinue + + # Check files in $EngPath (non-recursive) + $engFiles = Get-ChildItem -Path (Join-Path $PSScriptRoot $EngPath) -File -ErrorAction SilentlyContinue + + # Check files in $ScriptRoot (non-recursive) + $rootFiles = Get-ChildItem -Path $PSScriptRoot -File -ErrorAction SilentlyContinue + + # Combine all files and check if any are newer than the DLL + $allFiles = @($engSrcFiles) + @($engFiles) + @($rootFiles) + foreach ($file in $allFiles) + { + if ($file.LastWriteTime -gt $dllTime) + { + $needsBuild = $true + break + } + } + } + + if ($needsBuild) + { + # Build is needed + Write-Host "Building Build$ProductName..." -ForegroundColor Cyan + & dotnet build $projectPath + if ($LASTEXITCODE -ne 0) + { + throw "Build failed with exit code $LASTEXITCODE" + } + + # Re-find the output DLL after build + if (Test-Path $binDebugPath) + { + $outputDll = Get-ChildItem -Path $binDebugPath -Filter "Build$ProductName.dll" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 | ForEach-Object { $_.FullName } + if ($outputDll) + { + $tfmDir = Split-Path $outputDll -Parent + } + } + + # Update the DLL timestamp to mark the cache as valid + if ($outputDll -and (Test-Path $outputDll)) + { + (Get-Item $outputDll).LastWriteTime = Get-Date + } + else + { + throw "Build succeeded but output DLL '$outputDll' not found." + } + } + + # Run the project using dotnet exec (faster than dotnet run) + if (-not $outputDll -or -not (Test-Path $outputDll)) + { + throw "Output DLL not found. Expected path: '$outputDll'." + } + + # Snapshot mode: copy the TFM output directory to temp to avoid file locking during execution + $execDll = $outputDll + if ($Snapshot -and $tfmDir) + { + $snapshotDir = Join-Path $env:TEMP "eng-snapshot-$([Guid]::NewGuid().ToString('N').Substring(0,8))" + Write-Host "Creating snapshot of build output at $snapshotDir..." -ForegroundColor Cyan + Copy-Item -Path $tfmDir -Destination $snapshotDir -Recurse -Force + $execDll = Join-Path $snapshotDir "Build$ProductName.dll" + } + + # Set the repository directory via environment variable (needed when running from snapshot) + $env:ENG_REPO_DIRECTORY = if ($snapshotRepoDir) { $snapshotRepoDir } else { $PSScriptRoot } + & dotnet exec $execDll $BuildArgs + + # Captured on the very next line: $LASTEXITCODE is clobbered by any later command, including the vsmon kill + # just below and everything in the finally block. Without this, the wrapper always exited 0 and every failure + # of the tool was reported to CI as success. The tool returns 1 for a reported failure and 100 for an + # unhandled exception; both are preserved rather than flattened into a `throw`, so the two stay distinguishable. + $engExitCode = $LASTEXITCODE + + if ($StartVsmon) + { + Write-Host "" + Write-Host "Killing vsmon.exe." + $vsmonProcess.Kill() + } + } + finally + { + Set-Location $previousLocation + + # Cleanup snapshot directory if it was created + if ($snapshotDir -and (Test-Path $snapshotDir)) + { + Write-Host "Cleaning up snapshot directory..." -ForegroundColor Cyan + Remove-Item -Path $snapshotDir -Recurse -Force -ErrorAction SilentlyContinue + } + + # Copy artifacts back from repo snapshot + if ($snapshotRepoDir -and (Test-Path $snapshotRepoDir)) + { + $snapshotArtifacts = Join-Path $snapshotRepoDir "artifacts" + $repoArtifacts = Join-Path $PSScriptRoot "artifacts" + + if (Test-Path $snapshotArtifacts) + { + Write-Host "Copying artifacts from snapshot back to repository..." -ForegroundColor Cyan + if (Test-Path $repoArtifacts) + { + Remove-Item -Path $repoArtifacts -Recurse -Force + } + Copy-Item -Path $snapshotArtifacts -Destination $repoArtifacts -Recurse -Force + } + } + + # Reset environment variable + $env:ENG_REPO_DIRECTORY = "" + } +} + +if ($Interactive) +{ + Write-Host "Entering interactive PowerShell." -ForegroundColor Green +} + +# The last statement, so it is the script's exit code. The finally block above has already run, so the working +# directory is restored and snapshots are cleaned up before this. A `throw` earlier in the script (a build failure, +# a missing DLL) exits non-zero on its own and never reaches here. +exit $engExitCode diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..11f2417 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,20 @@ + + + + $(MSBuildThisFileDirectory) + GitHub + + + + + + + + enable + + SharpCrafters Backstage License Server + PostSharp Technologies + Copyright (c) SharpCrafters s.r.o. + + + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..a8a269e --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,6 @@ + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..bbc7fc6 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,54 @@ + + + + true + + + + + 2023.2.459 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DockerBuild.ps1 b/DockerBuild.ps1 new file mode 100644 index 0000000..ffa7606 --- /dev/null +++ b/DockerBuild.ps1 @@ -0,0 +1,3507 @@ +# The original of this file is in /src/PostSharp.Engineering.BuildTools/Resources/DockerBuild.ps1. +# You can generate this file using `./Build.ps1 generate-scripts`. +# Documentation: https://raw.githubusercontent.com/postsharp/PostSharp.Engineering/HEAD/doc/dockerbuild.md + +<# +.SYNOPSIS + Builds and runs a Docker container for building the product or running Claude CLI. + +.DESCRIPTION + Builds a Docker image from the repository's Dockerfile, then runs the build script + (or Claude CLI) inside a container with the source tree and dependencies mounted. + + The script automatically: + - Collects environment variables and generates Init.g.ps1 for container startup + - Mounts the source directory, NuGet cache, source-dependencies, and sibling repos + - Handles non-C: drive letters on Windows via subst + - Supports registry image caching for faster CI builds + +.PARAMETER Interactive + Opens an interactive PowerShell session inside the container. + +.PARAMETER BuildImage + Only builds the Docker image without running the build. + +.PARAMETER NoBuildImage + Skips building the Docker image (assumes it already exists). + +.PARAMETER Clean + Performs cleanup of bin and obj directories before building. + +.PARAMETER NoNuGetCache + Does not mount the host NuGet cache in the container. + +.PARAMETER KeepInit + Does not regenerate Init.g.ps1 (keeps the existing one as-is). + The existing Init.g.ps1 is still executed. Cannot be combined with -PostInit. + +.PARAMETER PostInit + Path to a script to execute at the end of Init.g.ps1. + The build fails if the PostInit script returns a non-zero exit code. + Cannot be combined with -KeepInit or -NoInit. + +.PARAMETER Claude + Runs Claude CLI instead of Build.ps1. Use -Claude for interactive mode, + or pass a prompt as a trailing argument for non-interactive mode. + +.PARAMETER NoMcp + Do not connect to the MCP approval server (for -Claude mode). + +.PARAMETER Update + Force full timestamp update to invalidate Docker cache and force Claude/plugin updates. + +.PARAMETER ImageName + Docker image name. Defaults to a content-hash-based name. + +.PARAMETER BuildAgentPath + Path to build agent directory. Defaults based on platform. + +.PARAMETER LoadEnvFromKeyVault + Forces loading environment variables from the PostSharpBuildEnv key vault. + +.PARAMETER StartVsmon + Mounts and enables the Visual Studio remote debugger in the container. + +.PARAMETER Script + The build script to execute inside Docker. Defaults to 'Build.ps1'. + +.PARAMETER Dockerfile + Path to a custom Dockerfile. Defaults to Dockerfile or Dockerfile.claude based on -Claude. + +.PARAMETER OS + The operating system of the containers this run builds and starts: windows or linux. Defaults to the + operating system of the host. + + A build agent runs one engine, so there the default is the only possibility and anything else fails with + that reason. A development machine is the case that differs: Windows containers run on Docker Desktop + while Linux containers run on the Docker engine inside WSL. Asking for linux on a Windows development + machine therefore re-executes this script inside WSL, after checking that WSL, PowerShell 7 and a Linux + Docker engine of the host's own architecture are all there. + +.PARAMETER Test + Runs a Docker test container instead of the product build. Requires -Dockerfile and -Command. + The image is built and cached exactly as a product image is, with the same content-hash tag and the same + registry push and pull. What differs is that no product image chain is resolved and no product environment + variable is passed: the container gets what -Env asks for and nothing else, so the product credentials stay + out of it. The mounts are those of an ordinary build -- the repository, the caches and the dependency + repositories -- and the command runs with the repository as its working directory, so a test addresses what + it needs by a path relative to the repository root. Use it for a test that needs a container of its own. + +.PARAMETER Context + (-Test) The Docker build context directory. Defaults to the directory containing the Dockerfile. + +.PARAMETER Command + (-Test) The command line executed in the test container, instead of the build script. Mutually exclusive + with -Script: -Script names a PowerShell file that the container runs through pwsh, which the image must + therefore carry, while -Command is a command line run through the container's own shell, which any image + has. -Test requires -Command; -Script belongs to an ordinary build. The container's exit code + becomes the exit code of this script. + +.PARAMETER RegistryImage + Use a pre-built image from a registry, skipping Dockerfile build entirely. + +.PARAMETER NoRegistry + Ignore DOCKER_REGISTRY, DOCKER_USERNAME and DOCKER_PASSWORD, and build every image locally under a local + tag: no authentication, no pull of an ancestor image, no push of what is built. Use it on a host that + cannot reach the registry, or cannot verify its certificate, so that the build proceeds without the layer + reuse the registry would otherwise give it. + +.PARAMETER NoInit + Do not generate or call Init.g.ps1 (skips environment variables, git config, safe.directory, etc). + +.PARAMETER Isolation + Docker isolation mode: 'process' or 'hyperv'. Windows only; ignored on Linux and macOS. + When not specified, defaults to 'hyperv' on Windows Desktop and 'process' on Windows Server. + On Windows, -Memory and a static -Cpus only apply under hyperv isolation. + +.PARAMETER Memory + Docker memory limit (e.g., "8g"). Applied on Linux and macOS, and on Windows under + hyperv isolation; Windows process isolation ignores it. + Clamped to the memory that the Docker engine reports, so a default larger than the + machine does not produce a limit the engine cannot honour. The MSBuild node count + passed to the container as MAX_BUILD_PARALLELISM is derived from the result, at one + node per 4 GB. + Defaults to $env:BuildAgentMemory (an integer in GB) if set, otherwise 24g. + +.PARAMETER Cpus + Docker CPU limit. Use a positive integer for a static limit, or "dynamic" for + automatic allocation that rebalances CPUs across all managed containers. + A static limit is applied wherever -Memory is; "dynamic" applies under any isolation. + Defaults to $env:BuildAgentCpus if set, otherwise the host processor count. + +.PARAMETER Mount + Additional directories to mount from the host (readonly by default, append :w for writable). + Supports * and ** glob patterns. + +.PARAMETER Env + Additional environment variables to pass from host to container. + Supports "NAME" (read from host) and "NAME=VALUE" (literal) forms. + +.PARAMETER Ports + Port mappings from host to container (e.g., "8080:80", "3000"). + +.PARAMETER Label + Label to apply to the container for identification (e.g., for cleanup of orphaned build containers). + The label is set as "postsharp.build=" on the container. + +.PARAMETER MaxImageSpace + Budget for the Docker image store, in gigabytes. Before the image chain is built, if the image + store exceeds this budget, unused images are removed oldest first until the store is back within + the budget. + The budget is compared to the size that `docker system df` reports for images, which counts a + layer shared by several images only once. + The removal covers every image on the Docker engine, not only the images of this repository. + It never removes an image that this run needs, an image that any container references, or an + image created in the last two hours. + Gigabytes are decimal (1 GB = 1e9 bytes), which is the unit `docker system df` prints. + Set it to 0 to disable the cleanup. + Defaults to $env:DOCKER_MAX_IMAGE_SPACE if set, otherwise 100. + +.PARAMETER BuildArgs + Arguments passed to Build.ps1 within the container (or Claude prompt if -Claude is specified). + +.EXAMPLE + .\DockerBuild.ps1 build + Builds the image and runs Build.ps1 inside the container. + +.EXAMPLE + .\DockerBuild.ps1 -Claude + Builds the image and starts an interactive Claude CLI session. + +.EXAMPLE + .\DockerBuild.ps1 -Claude "Fix the failing tests" + Runs Claude CLI with the given prompt in non-interactive mode. + +.EXAMPLE + .\DockerBuild.ps1 -Interactive + Opens an interactive PowerShell session inside the container. + +.EXAMPLE + .\DockerBuild.ps1 build -PostInit eng/SetupLocalDb.ps1 + Runs the build with a PostInit script that executes after Init.g.ps1. +#> + +[CmdletBinding(PositionalBinding = $false)] +param( + [switch]$Interactive, # Opens an interactive PowerShell session + [switch]$BuildImage, # Only builds the image, but does not build the product. + [switch]$NoBuildImage, # Does not build the image. + [switch]$Clean, # Performs cleanup of bin and obj directories. + [switch]$NoNuGetCache, # Does not mount the host nuget cache in the container. + [switch]$KeepInit, # Does not regenerate Init.g.ps1 (keeps the existing one as-is). + [string]$PostInit, # Script to execute at the end of Init.g.ps1 (fails the build if it fails). + [switch]$Claude, # Run Claude CLI instead of Build.ps1. Use -Claude for interactive, -Claude "prompt" for non-interactive. + [switch]$NoMcp, # Do not start the MCP approval server (for -Claude mode). + [switch]$Update, # Force full timestamp update to invalidate Docker cache and force Claude/plugin updates. + [string]$ImageName, # Image name (defaults to a name based on the directory). + [string]$BuildAgentPath, # Path to build agent directory (defaults based on platform). + [switch]$LoadEnvFromKeyVault, # Forces loading environment variables form the key vault. + [switch]$StartVsmon, # Enable the remote debugger. + [string]$Script = 'Build.ps1', # The build script to be executed inside Docker. + [string]$Dockerfile, # Path to custom Dockerfile (defaults to Dockerfile or Dockerfile.claude based on -Claude). + [ValidateSet('windows', 'linux')] + [string]$OS, # The operating system of the containers. Defaults to the host's. On a Windows development machine, 'linux' re-executes this script inside WSL. + [switch]$Test, # Run an isolated Docker test container. Requires -Dockerfile and -Command. Builds no product image chain, mounts no repository directory, and passes no product environment variable. + [string]$Context, # (-Test) The Docker build context directory. Defaults to the directory containing the Dockerfile. + [string]$Command, # (-Test) The command line executed in the test container, instead of the build script. + [string]$RegistryImage, # Use a pre-built image from a registry, skipping Dockerfile build entirely. + [switch]$NoRegistry, # Ignore DOCKER_REGISTRY and its credentials; build locally without pulling or pushing. + [switch]$NoInit, # Do not generate or call Init.g.ps1 (skips git config, safe.directory, etc). + [string]$Isolation = 'process', # Docker isolation mode (process or hyperv). Windows only. When not specified, defaults to hyperv on Windows Desktop and process on Windows Server. Memory/CPU limits only apply to hyperv. + [string]$Memory = $(if ($env:BuildAgentMemory) { "${env:BuildAgentMemory}g" } else { '24g' }), # Docker memory limit (e.g., "8g"). Applied except under Windows process isolation, and clamped to the memory reported by the Docker engine. Defaults to $env:BuildAgentMemory (in GB) or 24g. + [string]$Cpus = $(if ($env:BuildAgentCpus) { $env:BuildAgentCpus } else { [Environment]::ProcessorCount }), # Docker CPU limit. Use a positive integer or "dynamic". Defaults to $env:BuildAgentCpus or host processor count. + [string[]]$Mount, # Additional directories to mount from host (readonly by default, append :w for writable). Supports * and ** glob patterns. + [string[]]$Env, # Additional environment variables to pass from host to container. + [string[]]$Ports, # Port mappings from host to container (e.g., "8080:80", "3000"). + [string]$Label, # Label to apply to the container (e.g., for identifying build containers for cleanup). + [string]$MaxImageSpace = $(if ($env:DOCKER_MAX_IMAGE_SPACE) { $env:DOCKER_MAX_IMAGE_SPACE } else { '100' }), # Budget for the Docker image store, in decimal GB. Unused images are removed oldest first before the build when the store exceeds it. 0 disables the cleanup. Defaults to $env:DOCKER_MAX_IMAGE_SPACE or 100. + [Parameter(ValueFromRemainingArguments)] + [string[]]$BuildArgs # Arguments passed to `Build.ps1` within the container (or Claude prompt if -Claude is specified). +) + +# Require PowerShell 7.5 or higher (run with pwsh, not powershell) +if ($PSVersionTable.PSVersion -lt [Version]'7.5') +{ + Write-Error "This script requires PowerShell 7.5 or higher (run with 'pwsh', not 'powershell'). Current version: $( $PSVersionTable.PSVersion )" + exit 1 +} + + +# A NuGet packages directory under C:\Windows\System32 is unsafe. 32-bit build tools restored there hit WOW64 +# file-system redirection, which rewrites C:\Windows\System32 -> C:\Windows\SysWOW64 for a 32-bit process - so +# the tool's own image resolves to a non-existent SysWOW64 path and the CLR shim aborts with exit -2146232576 +# (0x80131700, CLR_E_SHIM_RUNTIMELOAD). This happens silently when the agent service runs as SYSTEM (whose profile +# is under System32) and NUGET_PACKAGES is unset, so fail fast with the fix instead of a cryptic build failure. +function Assert-NuGetPackagesPathSafe([string]$path) +{ + if ($IsUnix -or [string]::IsNullOrEmpty($path)) { return } + if (($path -replace '/', '\') -match '(?i)^[a-z]:\\windows\\system32(\\|$)') + { + Write-Host "NUGET_PACKAGES resolves to '$path', under C:\Windows\System32 - 32-bit build tools fail" -ForegroundColor Red + Write-Host "there via WOW64 System32->SysWOW64 redirection (exit 0x80131700, CLR_E_SHIM_RUNTIMELOAD)." -ForegroundColor Red + Write-Host "Set a machine-level NUGET_PACKAGES off System32, e.g.:" -ForegroundColor Red + Write-Host " md C:\packages; [Environment]::SetEnvironmentVariable('NUGET_PACKAGES','C:\packages','Machine')" -ForegroundColor Red + exit 1 + } +} + +#### +# These settings are replaced by the generate-scripts command. +$EngPath = 'eng' +$EnvironmentVariables = 'AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AZ_IDENTITY_USERNAME,AZURE_CLIENT_ID,AZURE_CLIENT_SECRET,AZURE_DEVOPS_TOKEN,AZURE_DEVOPS_USER,AZURE_TENANT_ID,CLAUDE_CODE_OAUTH_TOKEN,DOC_API_KEY,DOWNLOADS_API_KEY,ENG_USERNAME,GIT_USER_EMAIL,GIT_USER_NAME,GITHUB_APP_ID,GITHUB_APP_PRIVATE_KEY,GITHUB_AUTHOR_EMAIL,GITHUB_REVIEWER_TOKEN,GITHUB_TOKEN,IS_POSTSHARP_OWNED,IS_TEAMCITY_AGENT,MetalamaLicense,NUGET_ORG_API_KEY,PostSharpLicense,SIGNSERVER_SECRET,TEAMCITY_CLOUD_TOKEN,TYPESENSE_API_KEY,VS_MARKETPLACE_ACCESS_TOKEN,VSS_NUGET_EXTERNAL_FEED_ENDPOINTS' +$DockerImagePrefix = 'backstagelicenseserver-2027.0' +$OvercommitRatio = 1.0 +#### + +$ErrorActionPreference = "Stop" +$dockerContextDirectory = "$EngPath/docker-context" + +# Detect platform (use built-in variables if available, fallback for older PowerShell) +if ($null -eq $IsWindows) +{ + $IsWindows = [System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT +} +$IsUnix = -not $IsWindows # Covers both Linux and macOS + +# Converts a host path to the form WSL sees it under: C:\src\x becomes /mnt/c/src/x. Used only when this +# script hands its own arguments to a copy of itself running inside WSL, where every path it was given names +# a Windows location that the Linux side reaches through /mnt. +# Quotes a value as a PowerShell single-quoted literal, doubling any apostrophe it contains. The WSL hop builds +# a command line rather than passing arguments, so every value it forwards goes through here: a path holding an +# apostrophe would otherwise end the literal early and have its remainder parsed as PowerShell. +function ConvertTo-PowerShellLiteral([string]$value) +{ + return "'" + ( $value -replace "'", "''" ) + "'" +} + +function ConvertTo-WslHostPath([string]$path) +{ + if ($path -match '^([A-Za-z]):[\\/](.*)$') + { + return "/mnt/$( $Matches[1].ToLowerInvariant() )/$( $Matches[2] -replace '\\', '/' )" + } + + return $path -replace '\\', '/' +} + +# A caller that splats an ARRAY -- `& ./DockerBuild.ps1 @arguments` where $arguments is @('-Test', ...) -- does not +# bind these parameters by name. -BuildArgs takes the remaining arguments, so every value lands there and this +# script goes on to run an ordinary product build, mounting the source tree and forwarding the product secrets, +# instead of whatever mode was asked for. That failure is silent and its consequences are not, so it is refused +# here. Callers splat a hashtable: @{ Test = $true; OS = 'linux' }. +# The parameter names are read from the command rather than from $MyInvocation, whose MyCommand.Parameters is +# empty at script scope and silently matched nothing. +$declaredParameters = ( Get-Command -Name $PSCommandPath ).Parameters.Keys + +$misboundArguments = @( $BuildArgs | Where-Object { + $_ -and $_.StartsWith('-') -and $declaredParameters -contains $_.Substring(1) +} ) + +if ($misboundArguments.Count -gt 0) +{ + Write-Host "These arguments name parameters of this script but were not bound to them: $( $misboundArguments -join ', ' )" -ForegroundColor Red + Write-Host "That happens when a caller splats an array. Splat a hashtable instead, for example:" -ForegroundColor Red + Write-Host " `$arguments = @{ Test = `$true; OS = 'linux' }; ./DockerBuild.ps1 @arguments" -ForegroundColor Red + exit 1 +} + +# The operating system of the containers. The host's own is the default and, on a build agent, the only +# possibility: an agent runs one engine, and a build that needs the other one is routed to another agent. +# +# A development machine is where the two can differ, because Windows containers run on Docker Desktop while +# Linux containers run on the Docker engine inside WSL. Rather than special-casing every place that branches +# on the host -- the isolation flag, the escape character, the base image tag, the path conversion -- this +# script re-executes itself inside WSL, where it is running on a genuine Linux host and none of those places +# needs to know that a Windows machine started it. +$hostOs = if ($IsWindows) { 'windows' } else { 'linux' } + +if (-not $OS) +{ + $OS = $hostOs +} + +if ($OS -ne $hostOs) +{ + if ($IsUnix) + { + Write-Host "This host runs $hostOs containers, and -OS $OS was requested." -ForegroundColor Red + Write-Host "Windows containers need a Windows host. There is no counterpart of WSL in that direction." -ForegroundColor Red + exit 1 + } + + # Switching on an agent would run the build on an engine other than the one it was routed to, and would + # hide a wrong agent requirement behind a silent fallback. It is refused with the reason instead. + if ($env:IS_TEAMCITY_AGENT) + { + Write-Host "This agent runs $hostOs containers, and -OS $OS was requested." -ForegroundColor Red + Write-Host "A build agent does not switch. Route this build to an agent whose engine is $OS." -ForegroundColor Red + exit 1 + } + + if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) + { + Write-Host "Linux containers on a Windows development machine run on the Docker engine inside WSL, and wsl.exe was not found." -ForegroundColor Red + Write-Host "Install it with 'wsl --install', then install PowerShell 7 and a Docker engine inside the distribution." -ForegroundColor Red + exit 1 + } + + $wslPwsh = (& wsl.exe -- sh -c 'command -v pwsh' 2>&1 | Out-String).Trim() + + if ($LASTEXITCODE -ne 0 -or -not $wslPwsh) + { + Write-Host "PowerShell 7 is not installed inside the WSL distribution, so this script cannot run there." -ForegroundColor Red + Write-Host "Install pwsh in the distribution and try again." -ForegroundColor Red + exit 1 + } + + $wslEngineOs = (& wsl.exe -- docker version --format '{{.Server.Os}}' 2>&1 | Out-String).Trim() + + if ($LASTEXITCODE -ne 0 -or $wslEngineOs -ne 'linux') + { + Write-Host "The Docker engine inside WSL did not answer, or is not a Linux engine: $wslEngineOs" -ForegroundColor Red + Write-Host "Start it inside the distribution, for example with 'sudo service docker start'." -ForegroundColor Red + exit 1 + } + + # An engine of another architecture would build images this machine cannot run, and the failure would come + # much later and name something else, so it is checked here. + $wslEngineArch = (& wsl.exe -- docker version --format '{{.Server.Arch}}' 2>&1 | Out-String).Trim() + + $hostArch = switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture) + { + 'X64' { 'amd64' } + 'Arm64' { 'arm64' } + default { $null } + } + + if ($hostArch -and $wslEngineArch -and $wslEngineArch -ne $hostArch) + { + Write-Host "The Docker engine inside WSL reports $wslEngineArch while this host is $hostArch." -ForegroundColor Red + Write-Host "An emulated engine produces images of the wrong architecture, so the run is refused." -ForegroundColor Red + exit 1 + } + + # Rebuild the invocation for the Linux side. Everything is forwarded as it was given except -OS, which has + # been answered by the hop itself, and the parameters holding a path, which name Windows locations. + $pathParameters = @('Dockerfile', 'Context', 'PostInit', 'BuildAgentPath') + $wslArguments = @() + + foreach ($parameter in $PSBoundParameters.GetEnumerator()) + { + if ($parameter.Key -eq 'OS' -or $parameter.Key -eq 'BuildArgs') + { + continue + } + + $value = $parameter.Value + + if ($value -is [System.Management.Automation.SwitchParameter]) + { + if ($value.IsPresent) + { + $wslArguments += "-$( $parameter.Key )" + } + + continue + } + + $items = @() + + foreach ($item in @($value)) + { + $text = [string]$item + + if ($pathParameters -contains $parameter.Key) + { + $text = ConvertTo-WslHostPath $text + } + elseif ($parameter.Key -eq 'Mount') + { + # A mount is a host directory, optionally followed by ':w'. Only the directory is a path. + if ($text -match '^(.*):w$') + { + $text = ( ConvertTo-WslHostPath $Matches[1] ) + ':w' + } + else + { + $text = ConvertTo-WslHostPath $text + } + } + + $items += $text + } + + # A collection is written as an array literal, and a single value as a scalar. Joining with commas will + # not do: `pwsh -File` passes arguments as literal strings, so '-Mount a,b' arrives as the one element + # 'a,b' rather than as two mounts, and the second would silently become part of a path that does not + # exist. Repeating the parameter is not an option either -- `pwsh -File` rejects that outright -- so the + # hop goes through -Command, where an array literal means what it says. + if ($items.Count -gt 1) + { + $wslArguments += "-$( $parameter.Key ) @(" + ( ( $items | ForEach-Object { ConvertTo-PowerShellLiteral $_ } ) -join ',' ) + ")" + } + else + { + $wslArguments += "-$( $parameter.Key ) " + ( ConvertTo-PowerShellLiteral $items[0] ) + } + } + + foreach ($buildArg in $BuildArgs) + { + $wslArguments += ConvertTo-PowerShellLiteral ([string]$buildArg) + } + + Write-Host "Linux containers run on the Docker engine inside WSL; re-executing this script there." -ForegroundColor Cyan + + $wslCommand = "& " + ( ConvertTo-PowerShellLiteral ( ConvertTo-WslHostPath $PSCommandPath ) ) + " " + ( $wslArguments -join ' ' ) + + & wsl.exe -- $wslPwsh -NoProfile -Command $wslCommand + + exit $LASTEXITCODE +} + +# Docker isolation is Windows-only. Windows Server supports process isolation (faster, +# no per-container VM); Windows Desktop (client) only reliably runs hyperv isolation. +# Auto-detect by Windows edition unless -Isolation was passed explicitly. +if ($IsWindows -and -not $PSBoundParameters.ContainsKey('Isolation')) +{ + # Win32_OperatingSystem.ProductType: 1 = Workstation (Desktop), 2/3 = Server. + $productType = (Get-CimInstance -ClassName Win32_OperatingSystem).ProductType + $Isolation = if ($productType -eq 1) { 'hyperv' } else { 'process' } + Write-Host "Detected Windows ProductType=$productType; using --isolation=$Isolation" -ForegroundColor Cyan +} +$isolationArg = if ($IsWindows) +{ + "--isolation=$Isolation" +} +else +{ + "" +} + +# --memory and --cpus are honoured by the Linux and macOS engines whatever $Isolation says: isolation modes +# are a Windows concept and nothing outside $isolationArg acts on the value there. On Windows the limits only +# take effect under hyperv isolation - a process-isolated container shares the host kernel and the daemon +# silently drops both flags. Guarding on $Isolation alone would therefore leave every Linux container +# unlimited, which also loses MAX_BUILD_PARALLELISM (msbuild.ps1 derives the node count from the memory +# budget, and falls back to one node per CPU when there is none). +$supportsResourceLimits = $IsUnix -or $Isolation -ne 'process' + +# Set BuildAgentPath default based on platform +if ( [string]::IsNullOrEmpty($BuildAgentPath)) +{ + if ($env:TEAMCITY_JRE) + { + $BuildAgentPath = Split-Path $env:TEAMCITY_JRE -Parent + } + elseif ($IsUnix) + { + $BuildAgentPath = '/build-agent' + } + else + { + $BuildAgentPath = 'C:\BuildAgent' + } +} + +# Capture the calling directory (where the user invoked the script from) +# This will be used as the working directory in the container +$CallingDirectory = (Get-Location).Path + +# Resolve Dockerfile path relative to original current directory (before changing location) +# This must be done before Set-Location to preserve the user's intended relative path +if ($Dockerfile -and -not [System.IO.Path]::IsPathRooted($Dockerfile)) +{ + $Dockerfile = Join-Path $CallingDirectory $Dockerfile +} + +# Resolve PostInit path relative to original current directory (before changing location) +if ($PostInit -and -not [System.IO.Path]::IsPathRooted($PostInit)) +{ + $PostInit = Join-Path $CallingDirectory $PostInit +} + +# Save current location and restore on exit +Push-Location +try +{ + Set-Location $PSScriptRoot + + # Validate parameter combinations + if ($PostInit -and $NoInit) + { + Write-Error "-PostInit cannot be used with -NoInit." + exit 1 + } + if ($PostInit -and $KeepInit) + { + Write-Error "-PostInit cannot be used with -KeepInit." + exit 1 + } + + # -Test shares this script's image caching and registry handling, and nothing else. What it excludes is + # implied rather than requested, so that a test cannot acquire the build container's credentials by + # forgetting a flag. + if ($Test) + { + if (-not $Dockerfile) + { + Write-Error "-Test requires -Dockerfile." + exit 1 + } + + if (-not $Command) + { + Write-Error "-Test requires -Command." + exit 1 + } + + foreach ($excluded in @('Claude', 'Interactive', 'BuildImage', 'StartVsmon', 'PostInit', 'KeepInit', 'Script')) + { + if ( $PSBoundParameters.ContainsKey($excluded)) + { + Write-Error "-Test cannot be combined with -$excluded." + exit 1 + } + } + + $testContext = if ($Context) { $Context } else { Split-Path -Parent $Dockerfile } + + if (-not (Test-Path -LiteralPath $testContext -PathType Container)) + { + Write-Error "The build context directory '$testContext' does not exist." + exit 1 + } + + $script:TestContextDirectory = (Resolve-Path -LiteralPath $testContext).Path + + # Init.g.ps1 is not invoked in a test container, because a test image is chosen for the tool chain under + # test and is not required to carry PowerShell 7. That is the only reason. The environment it would have + # inlined reaches the container as -e arguments instead, so a test container is configured like a build + # container: this repository builds it from a Dockerfile it owns and runs it, and it is trusted the same + # way. Withholding the environment bought no isolation worth the cost, because NUGET_PACKAGES is in that + # set and without it NuGet falls back to $HOME/.nuget/packages and the host cache mapped below is never + # read -- every test restored over the network, the opposite of what the mount is for. + $NoInit = $true + } + + # Validate and parse -Cpus parameter + $isDynamicCpus = $false + if ($Cpus -eq 'dynamic') + { + $isDynamicCpus = $true + $TotalCpus = if ($env:BuildAgentCpus) { [int]$env:BuildAgentCpus } else { [Environment]::ProcessorCount } + Write-Host "Dynamic CPU allocation enabled. Total CPUs: $TotalCpus, Overcommit ratio: $OvercommitRatio" -ForegroundColor Cyan + } + else + { + $cpuInt = 0 + if (-not [int]::TryParse($Cpus, [ref]$cpuInt) -or $cpuInt -le 0) + { + Write-Error "-Cpus must be a positive integer or 'dynamic'. Got: '$Cpus'" + exit 1 + } + $Cpus = $cpuInt + } + + # Validate and parse -MaxImageSpace. An empty value or 0 disables the image-space cleanup; anything else + # must be a whole number of gigabytes. An unparseable value is a hard error, as for -Cpus: this variable is + # normally set once for a whole build agent, so a typo that silently disabled the cleanup would only be + # discovered as a full disk, weeks later. + $maxImageSpaceBytes = [long]0 + if (-not [string]::IsNullOrWhiteSpace($MaxImageSpace)) + { + $maxImageSpaceGb = 0 + if (-not [int]::TryParse($MaxImageSpace.Trim(), [ref]$maxImageSpaceGb) -or $maxImageSpaceGb -lt 0) + { + Write-Error "-MaxImageSpace must be a whole number of gigabytes, or 0 to disable the image-space cleanup. Got: '$MaxImageSpace'" + exit 1 + } + + # Docker prints sizes in DECIMAL gigabytes, so the budget uses the same unit as the number it is + # compared to. PowerShell's 1GB is binary, and would silently turn a budget of 100 into 107.4 of the + # gigabytes that `docker system df` reports. + $maxImageSpaceBytes = [long]$maxImageSpaceGb * 1000000000 + } + + # Images created within this window are never removed, and `docker image prune` is given the same window. + # This is what makes the cleanup safe against the concurrent DockerBuild runs that share a build agent. A + # sibling run's freshly built or pulled chain image, and its boot image between `docker build` and + # `docker run`, are referenced by no container, are absent from this run's keep set, and are invisible to + # everything else here. Two hours is much longer than that window, and costs nothing on an agent whose + # image store has grown over weeks. + $ImageCleanupGraceHours = 2 + + # How many measure-and-remove passes the cleanup makes. Each pass costs one `docker system df`, which is + # the expensive part, and each pass necessarily removes too little, because the size reported for an image + # includes the layers it shares with images that survive. A chain therefore loses one level per pass. The + # chains here are three deep, so four passes leave one to spare, and whatever is not freed by then is freed + # by the next build. + $ImageCleanupMaxPasses = 4 + + # Wall-clock budget for the whole cleanup. `docker system df` walks the layer store and can take minutes on + # an agent that holds hundreds of images. Freeing disk must never become the slowest part of the build. + $ImageCleanupTimeoutMinutes = 10 + + # msbuild.ps1 budgets one MSBuild node per 4 GB of the container's memory. + $MinMemoryPerCpuGb = 4 + + # -Memory defaults to 24g, which is more than several agents have. A limit above what the engine can honour is + # worse than no limit at all on Linux: the cgroup ceiling is then unreachable, so nothing constrains the build, + # and the node count below would be derived from memory the container can never use. Ask the engine what it + # actually has and clamp to it. A failure to reach the engine leaves the requested value untouched. + $memoryGb = 0 + if ($supportsResourceLimits -and $Memory -match '^\s*(\d+(?:\.\d+)?)\s*([gm])b?\s*$') + { + $memoryGb = [double]$Matches[1] + if ($Matches[2] -eq 'm') { $memoryGb = $memoryGb / 1024 } + + $engineMemoryBytes = [long]0 + $engineMemoryRaw = "$( docker info --format '{{.MemTotal}}' 2>$null )".Trim() + if ([long]::TryParse($engineMemoryRaw, [ref]$engineMemoryBytes) -and $engineMemoryBytes -gt 0) + { + $engineMemoryGb = $engineMemoryBytes / 1GB + if ($memoryGb -gt $engineMemoryGb) + { + $clampedGb = [int][Math]::Max(1, [Math]::Floor($engineMemoryGb)) + Write-Host "Requested --memory=$Memory exceeds the $( [Math]::Round($engineMemoryGb, 1) )g reported by the Docker engine; clamping to ${clampedGb}g" -ForegroundColor Yellow + $Memory = "${clampedGb}g" + $memoryGb = $clampedGb + } + } + } + + # Derive the node count here, where the container's memory budget is known. Inside the container msbuild.ps1 + # cannot read the cgroup limit, so without this it falls back to the processor count and over-subscribes a + # small agent - 16 nodes against 7 GB on the cell that reported this. + $maxBuildParallelism = 0 + if ($memoryGb -gt 0) + { + $maxBuildParallelism = [int][Math]::Max(1, [Math]::Floor($memoryGb / $MinMemoryPerCpuGb)) + } + + if ($env:IS_TEAMCITY_AGENT) + { + Write-Host "Running on TeamCity agent at '$BuildAgentPath'" -ForegroundColor Cyan + } + + # Dynamic CPU allocation helpers + $DynamicCpuLabel = 'managed-by=DockerBuild' + + function Get-DynamicCpuAllocation + { + param( + [int]$AdditionalContainers = 0 + ) + + $budget = $TotalCpus * (1.0 + $OvercommitRatio) + + # Count running containers with the dynamic CPU label + $containerIds = @(docker ps -q --filter "label=$DynamicCpuLabel" 2>$null) + # Filter out empty strings from docker output + $containerIds = @($containerIds | Where-Object { $_ -and $_.Trim() -ne '' }) + $runningCount = $containerIds.Count + + $totalContainers = $runningCount + $AdditionalContainers + if ($totalContainers -le 0) { $totalContainers = 1 } + + $allocation = [Math]::Min($TotalCpus, [Math]::Floor($budget / $totalContainers)) + if ($allocation -lt 1) { $allocation = 1 } + + return @{ + Allocation = [int]$allocation + ContainerIds = $containerIds + } + } + + function Invoke-DynamicCpuRebalance + { + param( + [int]$AdditionalContainers = 0 + ) + + $result = Get-DynamicCpuAllocation -AdditionalContainers $AdditionalContainers + $allocation = $result.Allocation + $containerIds = $result.ContainerIds + + if ($containerIds.Count -gt 0) + { + Write-Host "Rebalancing $( $containerIds.Count ) managed container(s) to $allocation CPUs each" -ForegroundColor Cyan + foreach ($cid in $containerIds) + { + try + { + docker update --cpus=$allocation $cid 2>$null | Out-Null + } + catch + { + Write-Warning "Failed to rebalance container $cid`: $_" + } + } + } + else + { + Write-Host "Dynamic CPU allocation: $allocation CPUs (no other managed containers)" -ForegroundColor Cyan + } + + return $allocation + } + + # Function to collect environment variables for container + function New-EnvHashtable + { + param( + [string]$EnvironmentVariableList + ) + + # Parse comma-separated environment variable names + $envVarNames = $EnvironmentVariableList -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' } + + # Build hashtable with environment variable values + $envVariables = @{ } + foreach ($envVarName in $envVarNames) + { + $value = [Environment]::GetEnvironmentVariable($envVarName) + if (-not [string]::IsNullOrEmpty($value)) + { + $envVariables[$envVarName] = $value + } + } + + # Process additional environment variables from -Env parameter + # Supports both "NAME" (read from host) and "NAME=VALUE" (literal value) forms + if ($Env -and $Env.Count -gt 0) + { + foreach ($envSpec in $Env) + { + if ($envSpec -match '^([^=]+)=(.*)$') + { + # NAME=VALUE form: use literal value + $envVarName = $Matches[1] + $value = $Matches[2] + $envVariables[$envVarName] = $value + } + else + { + # NAME form: read from host environment + $envVarName = $envSpec + $value = [Environment]::GetEnvironmentVariable($envVarName) + if (-not [string]::IsNullOrEmpty($value)) + { + $envVariables[$envVarName] = $value + } + } + } + } + + # Add NUGET_PACKAGES with default if not set + if (-not $envVariables.ContainsKey("NUGET_PACKAGES")) + { + $nugetPackages = $env:NUGET_PACKAGES + if ( [string]::IsNullOrEmpty($nugetPackages)) + { + if ($IsUnix) + { + $nugetPackages = Join-Path $env:HOME ".nuget/packages" + } + else + { + $nugetPackages = Join-Path $env:USERPROFILE ".nuget\packages" + } + } + $envVariables["NUGET_PACKAGES"] = $nugetPackages + Assert-NuGetPackagesPathSafe ($envVariables["NUGET_PACKAGES"]) + } + + # Add secrets from the PostSharpBuildEnv key vault, on our development machines. + # On CI agents, these environment variables are supposed to be set by the host. + # -BuildImage only builds the image; the secrets below are for the container run, so do not + # require an Azure login in that case. + if ($LoadEnvFromKeyVault -or ($env:IS_POSTSHARP_OWNED -and -not $env:IS_TEAMCITY_AGENT -and -not $BuildImage)) + { + $moduleName = "Az.KeyVault" + + if (-not (Get-Module -ListAvailable -Name $moduleName)) + { + Write-Error "The required module '$moduleName' is not installed. Please install it with: Install-Module -Name $moduleName" + exit 1 + } + + Import-Module $moduleName + foreach ($secret in Get-AzKeyVaultSecret -VaultName "PostSharpBuildEnv") + { + $secretWithValue = Get-AzKeyVaultSecret -VaultName "PostSharpBuildEnv" -Name $secret.Name + $envName = $secretWithValue.Name -Replace "-", "_" + $envValue = (ConvertFrom-SecureString $secretWithValue.SecretValue -AsPlainText) + $envVariables[$envName] = $envValue + } + } + + # Print sorted list of environment variables being passed + $sortedKeys = $envVariables.Keys | Sort-Object + Write-Host "Environment variables: $( $sortedKeys -join ', ' )" -ForegroundColor Gray + + # Store in script-level variable for Init.g.ps1 generation + $script:ContainerEnvironmentVariables = $envVariables + } + + # Function to collect Claude-specific environment variables for container + function New-ClaudeEnvHashtable + { + $claudeEnv = @{ } + + # Process $EnvironmentVariables list - only transfer variables that have CLAUDE_ prefix defined + # e.g., if CLAUDE_GITHUB_TOKEN is set, transfer it as GITHUB_TOKEN + $envVarNames = $EnvironmentVariables -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' } + foreach ($envVarName in $envVarNames) + { + $claudeVarName = "CLAUDE_$envVarName" + $value = [Environment]::GetEnvironmentVariable($claudeVarName) + if (-not [string]::IsNullOrEmpty($value)) + { + $claudeEnv[$envVarName] = $value + } + } + + # Preserved variables (transferred as-is, without requiring CLAUDE_ prefix) + if ($env:ANTHROPIC_API_KEY) + { + $claudeEnv["ANTHROPIC_API_KEY"] = $env:ANTHROPIC_API_KEY + } + if ($env:CLAUDE_CODE_OAUTH_TOKEN) + { + $claudeEnv["CLAUDE_CODE_OAUTH_TOKEN"] = $env:CLAUDE_CODE_OAUTH_TOKEN + } + if ($env:IS_POSTSHARP_OWNED) + { + $claudeEnv["IS_POSTSHARP_OWNED"] = $env:IS_POSTSHARP_OWNED + } + if ($env:IS_TEAMCITY_AGENT) + { + $claudeEnv["IS_TEAMCITY_AGENT"] = $env:IS_TEAMCITY_AGENT + } + + # Git identity - CLAUDE_ prefixed vars take precedence, then GIT_USER_*, then git config + $gitUserName = $env:CLAUDE_GIT_USER_NAME + if (-not $gitUserName) + { + $gitUserName = $env:GIT_USER_NAME + } + if (-not $gitUserName) + { + $gitUserName = git config --global user.name + } + $gitUserEmail = $env:CLAUDE_GIT_USER_EMAIL + if (-not $gitUserEmail) + { + $gitUserEmail = $env:GIT_USER_EMAIL + } + if (-not $gitUserEmail) + { + $gitUserEmail = git config --global user.email + } + if ($gitUserName) + { + $claudeEnv["GIT_USER_NAME"] = $gitUserName + } + if ($gitUserEmail) + { + $claudeEnv["GIT_USER_EMAIL"] = $gitUserEmail + } + + # Add NUGET_PACKAGES with default if not set + $nugetPackages = $env:NUGET_PACKAGES + if ( [string]::IsNullOrEmpty($nugetPackages)) + { + if ($IsUnix) + { + $nugetPackages = Join-Path $env:HOME ".nuget/packages" + } + else + { + $nugetPackages = Join-Path $env:USERPROFILE ".nuget\packages" + } + } + $claudeEnv["NUGET_PACKAGES"] = $nugetPackages + Assert-NuGetPackagesPathSafe ($claudeEnv["NUGET_PACKAGES"]) + + # Process additional environment variables from -Env parameter + # Supports both "NAME" (read from host) and "NAME=VALUE" (literal value) forms + # In Claude mode, CLAUDE_FOO takes precedence over FOO + if ($Env -and $Env.Count -gt 0) + { + foreach ($envSpec in $Env) + { + if ($envSpec -match '^([^=]+)=(.*)$') + { + # NAME=VALUE form: use literal value + $envVarName = $Matches[1] + $value = $Matches[2] + $claudeEnv[$envVarName] = $value + } + else + { + # NAME form: read from host environment (with CLAUDE_ prefix support) + $envVarName = $envSpec + $claudeVarName = "CLAUDE_$envVarName" + $value = [Environment]::GetEnvironmentVariable($claudeVarName) + if ( [string]::IsNullOrEmpty($value)) + { + $value = [Environment]::GetEnvironmentVariable($envVarName) + } + if (-not [string]::IsNullOrEmpty($value)) + { + $claudeEnv[$envVarName] = $value + } + } + } + } + + # Print sorted list of environment variables being passed + $sortedKeys = $claudeEnv.Keys | Sort-Object + Write-Host "Environment variables: $( $sortedKeys -join ', ' )" -ForegroundColor Gray + + # Store in script-level variable for Init.g.ps1 generation + $script:ContainerEnvironmentVariables = $claudeEnv + } + + # Fixed port for MCP approval server (must match McpHttpServer.FixedPort) + $mcpFixedPort = 9847 + + # Function to check if the MCP approval server is running + function Test-McpServerRunning + { + param( + [int]$Port = $mcpFixedPort + ) + + try + { + $response = Invoke-WebRequest -Uri "http://localhost:$Port/health" -TimeoutSec 10 -ErrorAction Stop + return $response.StatusCode -eq 200 + } + catch + { + return $false + } + } + + function Get-TimestampFile + { + # Persists $script:DayStamp (the single source of truth, also mixed + # into the image tag by Get-ContentHash in Claude mode) to disk so + # Dockerfile.claude can COPY it in and invalidate inner layers on + # the same week boundary as the outer image tag. + + $timestampDir = if ($IsUnix) + { + Join-Path $env:HOME ".local/share/PostSharp.Engineering" + } + else + { + Join-Path $env:LOCALAPPDATA "PostSharp.Engineering" + } + $timestampFile = Join-Path $timestampDir "update.timestamp" + + # Ensure directory exists + if (-not (Test-Path $timestampDir)) + { + New-Item -ItemType Directory -Path $timestampDir -Force | Out-Null + } + + # Only rewrite the file if the content would actually change — avoids + # bumping mtime on every run, which would pointlessly invalidate the + # Docker COPY layer for the timestamp file. + $needsUpdate = $true + if (Test-Path $timestampFile) + { + $currentTimestamp = Get-Content $timestampFile -Raw -ErrorAction SilentlyContinue + if ($currentTimestamp -eq $script:DayStamp) + { + $needsUpdate = $false + } + } + + if ($needsUpdate) + { + Set-Content -Path $timestampFile -Value $script:DayStamp -NoNewline -Force + $label = if ($Update) { "forced" } else { "weekly" } + Write-Host "Timestamp file updated ($label): $script:DayStamp" -ForegroundColor Cyan + } + + return $timestampFile + } + + function Get-ContentHash + { + param( + [string]$DockerfilePath, + [string]$ContextDirectory, + [string]$DayStamp, # non-empty => mix into hash (used in -Claude mode) + [string]$ExtraInput # folded in so a base-image (or OS) change invalidates this image's hash + ) + + $hashInput = Get-Content $DockerfilePath -Raw -ErrorAction SilentlyContinue + if (-not $hashInput) + { + $hashInput = "" + } + + # Add context files (excluding generated .g/ directory, which holds + # per-invocation files like env.g.json and Init.g.ps1). + # Sort with the invariant culture so the file order (and therefore the hash) is identical regardless of the + # host's locale. The default Sort-Object uses the current culture, which can order non-ASCII names differently + # on different machines and yield a different tag for identical content. + $contextFiles = Get-ChildItem $ContextDirectory -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -notmatch '[/\\]\.g[/\\]' } | + Sort-Object -Property FullName -Culture ([System.Globalization.CultureInfo]::InvariantCulture) + + foreach ($file in $contextFiles) + { + $content = Get-Content $file.FullName -Raw -ErrorAction SilentlyContinue + if ($content) + { + $hashInput += "`n--- $( $file.Name ) ---`n" + $hashInput += $content + } + } + + # When a week stamp is supplied (Claude mode), rotate the image tag once + # per UTC week so @latest npm installs of the Claude CLI and marketplace + # plug-ins actually get refreshed. Same string as update.timestamp. + if ($DayStamp) + { + $hashInput += "`n--- day-stamp ---`n$DayStamp" + } + + # Fold the base/OS discriminator so a parent-image change (or a different WINDOWS_VERSION) yields a + # different tag for this image and all its descendants. + if ($ExtraInput) + { + $hashInput += "`n--- base ---`n$ExtraInput" + } + + # Normalize line endings so the hash is identical whether files were checked out with LF (typical on a + # dev machine) or CRLF (git autocrlf on CI). Otherwise the same Dockerfile yields a different tag on CI + # than on dev, the registry cache never hits, and CI rebuilds the chain from scratch every time. + $hashInput = $hashInput -replace "`r", "" + + $hashBytes = [System.Security.Cryptography.SHA256]::Create().ComputeHash( + [System.Text.Encoding]::UTF8.GetBytes($hashInput) + ) + # Use 8 bytes (16 hex chars) for uniqueness + return [System.BitConverter]::ToString($hashBytes, 0, 8).Replace("-", "").ToLower() + } + + # --- Image chain resolution --------------------------------------------------------------------- + # A Dockerfile may declare its parent with `ARG BASE_IMAGE=.Dockerfile`. We resolve that to the + # parent's content-hash tag (building or pulling it first), fold the parent tag into this image's hash, and + # inject --build-arg BASE_IMAGE=. The image NAME is the Dockerfile stem (e.g. + # -build.Dockerfile -> image -build), so the product/version prefix lives in the file name. + $script:resolvedTags = @{ } + + function Get-DockerfileStem([string]$dfPath) + { + return [System.IO.Path]::GetFileNameWithoutExtension($dfPath) + } + + # Per-image build context: docker-context/. There is deliberately NO fallback to the shared docker-context + # root: stray machine-specific files living there (e.g. .credentials.json, claude.json) would otherwise be folded + # into the image content hash and produce different tags on different machines for identical content. A missing + # directory is treated as an empty context by Get-ContentHash, and Build-OneImage creates it before building. + function Get-ContextDirFor([string]$dfPath) + { + # A test image takes its context from the test's own directory. The test owns the files its Dockerfile + # copies, and it is not part of the repository's image chain. + if ($Test) + { + return $script:TestContextDirectory + } + + return Join-Path $dockerContextDirectory (Get-DockerfileStem $dfPath) + } + + # True when an image bakes the weekly cache-buster (`COPY .g/update.timestamp`). This is the single + # discriminator for "this is a Claude leaf": it decides that the day stamp folds into the tag, that the + # image is local-only, and that the timestamp file is staged into its context. It is derived from the + # Dockerfile body rather than from its name, because the stem carries a product-defined prefix + # (AdditionalDockerfile "agent" -> agent-claude.Dockerfile), so comparing the stem to "claude" silently + # misses every prefixed leaf. + function Test-BakesCacheBuster([string]$dfPath) + { + $body = Get-Content $dfPath -Raw -ErrorAction SilentlyContinue + return [bool]($body -and $body -match 'update\.timestamp') + } + + # Stage the cache-buster into the context of the image that actually declares the COPY, i.e. + # docker-context//.g/ - for whatever stem, prefixed or not. The .g/ directory is gitignored and is + # excluded from Get-ContentHash, so writing here neither becomes tracked nor perturbs any image tag. + function Copy-TimestampToContext([string]$dfPath) + { + if (-not (Test-BakesCacheBuster $dfPath)) + { + return + } + + # The run step (-NoBuildImage) skips the up-front timestamp creation, yet it still (re)builds the + # local-only Claude leaf when the daemon does not already carry it, so materialize the file on demand. + # Get-TimestampFile is idempotent and reads the same $script:DayStamp the tag was computed from. + if (-not $script:TimestampFile) + { + $script:TimestampFile = Get-TimestampFile + } + + $gDir = Join-Path (Get-ContextDirFor $dfPath) ".g" + if (-not (Test-Path $gDir)) + { + New-Item -ItemType Directory -Path $gDir -Force | Out-Null + } + + Copy-Item -Path $script:TimestampFile -Destination (Join-Path $gDir "update.timestamp") -Force + Write-Host "Staged cache-buster timestamp into the context of '$( Get-DockerfileStem $dfPath )'" -ForegroundColor Cyan + } + + # Parse the parent Dockerfile from `ARG BASE_IMAGE=.Dockerfile`; $null if this is a chain root. + function Get-BaseDockerfile([string]$dfPath) + { + foreach ($line in (Get-Content $dfPath -ErrorAction SilentlyContinue)) + { + if ($line -match '^\s*ARG\s+BASE_IMAGE\s*=\s*(\S+\.Dockerfile)\s*$') + { + return (Join-Path (Split-Path $dfPath -Parent) $Matches[1]) + } + } + return $null + } + + # Pure: compute the content-hash tag for a Dockerfile and (recursively) its ancestors. No docker calls. + function Resolve-ImageTag([string]$dfPath) + { + $key = $dfPath.ToLower() + if ($script:resolvedTags.ContainsKey($key)) { return $script:resolvedTags[$key] } + + $baseFold = $null + $baseDf = Get-BaseDockerfile $dfPath + if ($baseDf) + { + if (-not (Test-Path $baseDf)) { Write-Error "Base Dockerfile '$baseDf' referenced by '$dfPath' was not found."; exit 1 } + # Fold only the base's CONTENT HASH (the part after the last ':'), never the full tag - so the child + # hash is independent of the registry prefix and is identical in local and registry modes. + $baseFold = ((Resolve-ImageTag $baseDf) -split ':')[-1] + } + + # OS discriminator so ltsc2025 / ltsc2022 produce distinct tags of the same image name. Propagates to + # descendants through $baseFold. + # + # The architecture discriminates too, always, including amd64. The content hash is otherwise identical + # for the same Dockerfile built on x64 and on ARM64: both agents compute one tag, the first pushes an + # image of its own architecture, and the second pulls it and fails -- with "no matching manifest" if the + # registry rejects it, or, worse, with "exec /bin/sh: exec format error" once it has been pulled, which + # names neither the image nor the architecture. + # + # Folding it in only for non-amd64 was tried and is not enough: it gives a future ARM64 build its own + # tag, but leaves amd64 on the name an ARM64 build may already have pushed to, so the poisoning survives + # the fix. Including it everywhere changes every tag once, which is a rebuild, and is what actually + # abandons the bad ones. + $extra = "os=$windowsVersion|arch=$( Get-DockerArchitecture )|base=$baseFold" + + # Fold the weekly stamp only for images that bake the update.timestamp cache-buster (the Claude leaf), so + # @latest npm installs of the Claude CLI and plug-ins refresh once per UTC week. + $hashDayStamp = if (Test-BakesCacheBuster $dfPath) { $script:DayStamp } else { $null } + + $hash = Get-ContentHash -DockerfilePath $dfPath -ContextDirectory (Get-ContextDirFor $dfPath) -DayStamp $hashDayStamp -ExtraInput $extra + # The image NAME carries the product/version prefix ($DockerImagePrefix); the Dockerfile file stem does + # not. e.g. stem 'build' -> image '-build'. ARG BASE_IMAGE references stems (prefix-free). + # Lower-cased because a Docker repository name must be lower case, while the stem is the file name as + # written: a custom -Dockerfile named 'Dockerfile', which is the conventional name and the one the + # container tests use, would otherwise produce an invalid tag and fail the build. + $imageName = "$DockerImagePrefix-$( Get-DockerfileStem $dfPath )".ToLowerInvariant() + $tag = if ($dockerRegistry) { "${dockerRegistry}/${imageName}:${hash}" } else { "${imageName}:${hash}" } + $script:resolvedTags[$key] = $tag + return $tag + } + + # The platform-specific mountpoints-creation step. This is NEVER baked into a chain Dockerfile - it goes + # only into the dynamically generated boot image (see New-BootImage), so the chain images stay clean and + # free of the machine-specific mount set. + function Get-MountpointsBlock + { + if ($IsWindows) + { + return @" +ARG MOUNTPOINTS +# The RUN below is PowerShell, so the shell has to be declared. A Windows image inherits whatever SHELL its base +# declares, and that differs between the images this script is pointed at: the .NET Framework SDK images declare +# PowerShell, while the .NET SDK images leave the Docker default of cmd, which fails on the first brace. The +# product build image happens to declare PowerShell, which is why this only surfaced once a test container -- on +# an arbitrary base image -- was given mounts and therefore a boot image. +SHELL ["powershell", "-Command", "`$ErrorActionPreference = 'Stop';"] +RUN if (`$env:MOUNTPOINTS) { `` + `$mounts = `$env:MOUNTPOINTS -split ';'; `` + foreach (`$dir in `$mounts) { `` + if (`$dir) { `` + Write-Host "Creating directory `$dir``."; `` + New-Item -ItemType Directory -Path `$dir -Force | Out-Null; `` + } `` + } `` + } +"@ + } + else + { + return @" +ARG MOUNTPOINTS +RUN if [ -n "`$MOUNTPOINTS" ]; then \ + OLD_IFS="`$IFS"; \ + IFS=':'; \ + set -- `$MOUNTPOINTS; \ + IFS="`$OLD_IFS"; \ + for dir in "`$@"; do \ + if [ -n "`$dir" ]; then \ + echo "Creating directory `$dir."; \ + mkdir -p "`$dir"; \ + fi; \ + done; \ + fi +"@ + } + } + + # Parse the OS image a chain ROOT is built FROM, as declared by `ARG OS_IMAGE_REPOSITORY=` (the Windows + # default root, which takes its tag from WINDOWS_VERSION) or `ARG OS_IMAGE=` (every other root, which + # declares a complete reference). Returns $null for a Dockerfile that declares neither, which is every + # image that is not a chain root. + # + # Pure: it only reads the file. That is what lets the image-space cleanup protect the OS image before any + # image is built, without asking the daemon anything. ArgName tells the caller which build-arg the value + # belongs to, because the two spellings need different values (a repository, or a full reference). + function Get-OsImageSpec([string]$dfPath) + { + $content = Get-Content -Raw $dfPath -ErrorAction SilentlyContinue + if (-not $content) { return $null } + + if ($windowsVersion -and ($content -match 'ARG\s+OS_IMAGE_REPOSITORY=(\S+)')) + { + return [pscustomobject]@{ Repository = $Matches[1]; Tag = $windowsVersion; ArgName = 'OS_IMAGE_REPOSITORY' } + } + + if ($content -match 'ARG\s+OS_IMAGE=(\S+)') + { + # Split the trailing tag off the reference; a ':' before the last '/' belongs to a registry port. + $ref = $Matches[1] + $slash = $ref.LastIndexOf('/') + $colon = $ref.LastIndexOf(':') + if ($colon -gt $slash) { return [pscustomobject]@{ Repository = $ref.Substring(0, $colon); Tag = $ref.Substring($colon + 1); ArgName = 'OS_IMAGE' } } + + return [pscustomobject]@{ Repository = $ref; Tag = 'latest'; ArgName = 'OS_IMAGE' } + } + + return $null + } + + # Build one chain image from its STATIC Dockerfile, unmodified (per-image context, base build-arg). + function Build-OneImage([string]$dfPath, [string]$tag, [string[]]$baseBuildArg) + { + $content = Get-Content -Raw $dfPath # piped to docker build verbatim - the file on disk is never changed + $ctxDir = Get-ContextDirFor $dfPath + # The per-image context dir is normally created by generate-scripts, but it is not tracked by git (it is often + # empty), so ensure it exists here before handing it to docker build. + if (-not (Test-Path $ctxDir)) { New-Item -ItemType Directory -Path $ctxDir -Force | Out-Null } + # Staged here, against the Dockerfile actually being built, so every Claude leaf gets the cache-buster in + # its own context regardless of prefix, and images that do not bake it are left untouched. + Copy-TimestampToContext $dfPath + $cmd = @('build', '-t', $tag) + if ($isolationArg) { $cmd += $isolationArg } + if ($Memory -and $supportsResourceLimits) { $cmd += "--memory=$Memory" } + # Pass WINDOWS_VERSION only to the root image that declares it (avoids 'unconsumed build-arg' warnings). + if ($IsWindows -and $windowsVersion -and ($content -match 'ARG\s+WINDOWS_VERSION')) + { + $cmd += @('--build-arg', "WINDOWS_VERSION=$windowsVersion") + } + + # Same for the OS image: only a root image declares it, and resolving it here (rather than up front) + # means the mirror is only consulted when a root image is genuinely being built. The Windows default + # root takes its tag from WINDOWS_VERSION and so declares a repository; every other root (Linux, and + # any product that pins its own base image) declares a complete reference. + $osImageSpec = Get-OsImageSpec $dfPath + if ($osImageSpec) + { + $osImage = Get-OsImage $osImageSpec.Repository $osImageSpec.Tag + $osImageValue = if ($osImageSpec.ArgName -eq 'OS_IMAGE_REPOSITORY') { $osImage.Repository } else { $osImage.Reference } + $cmd += @('--build-arg', "$( $osImageSpec.ArgName )=$osImageValue") + } + $cmd += $baseBuildArg + $cmd += @('-f', '-', $ctxDir) + Write-Host "Building $tag" -ForegroundColor Green + Write-Host "Docker command: docker $( $cmd -join ' ' )" -ForegroundColor Cyan + # Pipe docker output to the host so it does NOT become this function's return value (which would + # otherwise pollute the tag string the caller folds into the next --build-arg BASE_IMAGE). + # + # The config dir must be passed here too: `docker build` resolves the FROM itself, and when that is the + # OS mirror (or any other image in the private registry) it needs the credentials that the login wrote + # into the temporary config. Without it the build fails with "no basic auth credentials" on any agent + # whose default config is not already logged in. + $content | & docker @dockerConfigArg @cmd 2>&1 | Out-Host + if ($LASTEXITCODE -ne 0) { Write-Host "Docker build failed for $tag (exit $LASTEXITCODE)" -ForegroundColor Red; exit $LASTEXITCODE } + $script:builtNewImage = $true + } + + # Build the local "boot" image: a thin layer over the resolved chain image that creates the bind-mount + # directories. The mount set is machine-specific, so this is kept out of the shared chain images and is + # never pushed. Returns the boot image tag, which is what `docker run` uses. + # + # The boot image is the leaf that `docker run` actually executes, so its tag must be GLOBALLY UNIQUE: + # concurrent invocations on the same host resolve to the same chain hash and would otherwise collide on a + # single boot tag, with one run rebuilding (or removing) the image out from under the other. A + # YYYYMMDDTHHmmss timestamp suffix keeps each run's leaf image distinct. The image is removed after the run + # (see the boot-image cleanup near the end), so unique tags do not accumulate. + function New-BootImage([string]$baseTag) + { + $ref = ($baseTag -split '/')[-1] # strip any registry prefix - the boot image is local only + $stamp = (Get-Date).ToString("yyyyMMdd'T'HHmmss") # local time; only needs to be unique per host run + if ($ref -match '^(.*):([^:]+)$') { $bootTag = "$( $Matches[1] )-boot:$( $Matches[2] )-$stamp" } else { $bootTag = "$ref-boot:$stamp" } + $script:BootImageTag = $bootTag # tracked so the run can remove this leaf image afterwards + + # On Windows the mountpoints RUN uses backtick line-continuations, so set `# escape=` + backtick. On + # Unix the block uses backslash continuations, so keep Docker's default escape char (emit no directive). + $escapeLine = if ($IsWindows) { "# escape=$([char]96)`n" } else { "" } + $content = $escapeLine + "FROM $baseTag`n" + (Get-MountpointsBlock) + + # The boot layer has no COPY, so build it against an empty context. + $bootCtx = Join-Path ([System.IO.Path]::GetTempPath()) "docker-boot-$( New-Guid )" + New-Item -ItemType Directory -Path $bootCtx -Force | Out-Null + try + { + $cmd = @('build', '-t', $bootTag) + if ($isolationArg) { $cmd += $isolationArg } + if ($Memory -and $supportsResourceLimits) { $cmd += "--memory=$Memory" } + $cmd += @('--build-arg', "MOUNTPOINTS=$mountPointsAsString", '-f', '-', $bootCtx) + Write-Host "Building boot image $bootTag (bind-mount dirs) over $baseTag" -ForegroundColor Green + # Its FROM is the local chain leaf, so no credentials are needed - but the config dir is passed for + # consistency with Build-OneImage, and costs nothing when it is empty. + $content | & docker @dockerConfigArg @cmd 2>&1 | Out-Host + if ($LASTEXITCODE -ne 0) { Write-Host "Boot image build failed for $bootTag (exit $LASTEXITCODE)" -ForegroundColor Red; exit $LASTEXITCODE } + } + finally { Remove-Item $bootCtx -Recurse -Force -ErrorAction SilentlyContinue } + return $bootTag + } + + # Push one image to the registry in a background job, without waiting for it. The jobs are collected in + # $script:RegistryPushJobs and waited for at the end of the script, where a failed push fails the build. + function Start-AsyncPush([string]$tag) + { + if (-not $script:PushedTags.Add($tag)) + { + return + } + + Write-Host " starting async push to registry" -ForegroundColor Cyan + + # Copied to a local so that $using: captures it: $dockerConfigArg belongs to the enclosing scope. + $configArg = $dockerConfigArg + + $pushJob = Start-Job -ScriptBlock { + docker @using:configArg push $using:tag 2>&1 + $LASTEXITCODE + } + + $script:RegistryPushJobs += [pscustomobject]@{ Tag = $tag; Job = $pushJob } + } + + # Wait for ALL async registry push jobs to complete (each image pushed in its own job). A push that failed + # or timed out fails the script: an image missing from the registry is silently rebuilt from scratch by + # every later build, which costs far more than a red build here. + # + # Called on the normal path and again from the `finally` block, so that the failure of a later step never + # abandons a push in flight: the jobs die with the process, and a half-pushed image never becomes a tag in + # the registry. The job list is emptied here, so the second call is a no-op after a normal completion. + function Wait-ForRegistryPushes + { + if ($script:RegistryPushJobs.Count -eq 0) + { + return + } + + Write-Host "" + Write-Host "Waiting for $( $script:RegistryPushJobs.Count ) registry push job(s) to complete..." -ForegroundColor Cyan + + foreach ($entry in $script:RegistryPushJobs) + { + $completed = Wait-Job -Job $entry.Job -Timeout 1800 # 30 minute timeout per job + if ($completed) + { + $jobOutput = Receive-Job -Job $entry.Job + $exitCode = $jobOutput[-1] # last item is the exit code + $output = if ($jobOutput.Count -gt 1) { $jobOutput[0..($jobOutput.Count - 2)] -join "`n" } else { "" } + + if ($exitCode -eq 0) + { + Write-Host "Registry push completed: $( $entry.Tag )" -ForegroundColor Green + } + else + { + Write-Host "Registry push FAILED (exit $exitCode): $( $entry.Tag )" -ForegroundColor Red + if ($output) { Write-Host "Push output: $output" -ForegroundColor Gray } + $script:PushFailed = $true + } + } + else + { + Write-Host "Registry push TIMED OUT after 30 minutes: $( $entry.Tag )" -ForegroundColor Red + Stop-Job -Job $entry.Job + $script:PushFailed = $true + } + Remove-Job -Job $entry.Job -Force + } + + $script:RegistryPushJobs = @() + } + + # The processor architecture of the Docker engine, in the naming Docker itself uses ('amd64', 'arm64'). + # It is the architecture of the OS image a mirror ends up holding, because `docker pull` of a + # multi-architecture manifest selects the engine's own platform. + # + # The engine is asked rather than the host, because the two differ on macOS: the engine runs in a Linux + # virtual machine (Docker Desktop, Colima, Rancher Desktop), and the images are of the virtual machine's + # architecture, not of the architecture the PowerShell process happens to run under. Queried once per run. + function Get-DockerArchitecture + { + if ($script:DockerArchitecture) + { + return $script:DockerArchitecture + } + + # '{{.Server.Arch}}' is the daemon's own Go architecture name, the same vocabulary an image manifest + # uses. `docker info --format '{{.Architecture}}'` is not interchangeable with it: that one reports the + # uname form, 'x86_64' rather than 'amd64', which is why the result is normalized below. + $architecture = (docker version --format '{{.Server.Arch}}' 2>$null | Select-Object -Last 1) + + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($architecture)) + { + # The daemon could not be asked: it is not running, or on Linux the user is not in the docker + # group. The host architecture is the next best answer, and is the right one wherever the daemon + # runs on the host itself, which covers Linux and Windows. + $architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + Write-Host "Could not read the Docker engine architecture; assuming the host's, $architecture." -ForegroundColor Yellow + } + + $architecture = $architecture.Trim() + + # Accept every spelling the two sources above can produce, including the .NET enum names ('X64', + # 'Arm64') that the fallback returns. + $script:DockerArchitecture = switch -Regex ($architecture) + { + '^(amd64|x64|x86_64)$' { 'amd64' } + '^(arm64|aarch64)$' { 'arm64' } + default { $architecture.ToLowerInvariant() } + } + + return $script:DockerArchitecture + } + + # Resolve the OS image a root image is built FROM, mirroring it into the configured registry the first time + # it is needed. On a fresh agent the OS image is the most expensive download of the whole build, and the + # registry is on the LAN, so building from the mirror replaces an internet transfer with a local one. + # Agents that configure no registry (the cloud ones) go straight to the upstream registry, as before. + # + # The repository the given upstream OS repository is mirrored under in $dockerRegistry. Split out of + # Get-OsImage so that the image-space cleanup can name the mirror without pulling or creating it. + function Get-OsMirrorRepository([string]$repository) + { + # Flatten the upstream repository into a single product-neutral name, dropping the registry host: + # 'mcr.microsoft.com/windows/servercore' -> 'windows-servercore', 'ubuntu' -> 'ubuntu'. Every product + # using this registry then shares the one mirror. + $segments = $repository -split '/' + if ($segments.Length -gt 1 -and ($segments[0] -match '[.:]')) + { + $segments = $segments[1..($segments.Length - 1)] + } + + # A repository holds one manifest per tag, and the mirror is single-platform: whichever agent creates + # it decides the architecture every later agent gets, with no error until a command runs in a + # container built from it. So the architecture belongs in the mirror name. amd64 keeps the unsuffixed + # name, which every mirror already in the registry was pushed under: + # amd64 -> /ubuntu, arm64 -> /ubuntu-arm64. + $architecture = Get-DockerArchitecture + $architectureSuffix = if ($architecture -eq 'amd64') + { + '' + } + else + { + "-$architecture" + } + + return "$dockerRegistry/$( $segments -join '-' )$architectureSuffix" + } + + # Takes the upstream repository and tag, and returns an object with the Repository and the full Reference to + # build from - either the mirror or, when there is no registry (or the mirror cannot be created), upstream. + # Called only when a ROOT image is actually being built, so a run that pulls its whole chain never touches + # the mirror. Each distinct image is resolved once per run. + function Get-OsImage([string]$repository, [string]$tag) + { + $upstreamRef = "${repository}:${tag}" + + if ($script:OsImages.ContainsKey($upstreamRef)) + { + return $script:OsImages[$upstreamRef] + } + + $upstream = [pscustomobject]@{ Repository = $repository; Reference = $upstreamRef } + + if (-not $dockerRegistry) + { + $script:OsImages[$upstreamRef] = $upstream + return $upstream + } + + $mirrorRepository = Get-OsMirrorRepository $repository + $mirror = [pscustomobject]@{ Repository = $mirrorRepository; Reference = "${mirrorRepository}:${tag}" } + + docker @dockerConfigArg manifest inspect $mirror.Reference *> $null + if ($LASTEXITCODE -eq 0) + { + # Pull it here, with the credentials from the temporary config, rather than leaving it to the FROM + # resolution inside `docker build`: the build only sees the credentials this script passes it, and + # an agent whose default config is not logged in would otherwise fail with "no basic auth + # credentials". Pulling it first also means the build never touches the registry at all. + Write-Host "Building from the OS image mirrored at $( $mirror.Reference )" -ForegroundColor Green + docker @dockerConfigArg pull $mirror.Reference 2>&1 | Out-Host + + if ($LASTEXITCODE -ne 0) + { + # The mirror is unusable on this agent; upstream still is. Not fatal. + Write-Host "Could not pull the mirrored OS image; building from $upstreamRef." -ForegroundColor Yellow + $script:OsImages[$upstreamRef] = $upstream + return $upstream + } + + $script:OsImages[$upstreamRef] = $mirror + return $mirror + } + + # Not mirrored yet: take it from upstream this once and mirror it, so that every later build on every + # agent gets it from the registry. The push is normally near-instant even though the image is gigabytes: + # those exact blobs already underlie every chain image pushed so far, so the registry mounts them across + # repositories instead of receiving them again. + Write-Host "The OS image is not mirrored yet; pulling $upstreamRef to mirror it" -ForegroundColor Cyan + docker pull $upstreamRef 2>&1 | Out-Host + + if ($LASTEXITCODE -ne 0) + { + # Not fatal: the build below pulls the same image from upstream anyway, and reports its own error. + Write-Host "Could not pull the OS image; building from $upstreamRef." -ForegroundColor Yellow + $script:OsImages[$upstreamRef] = $upstream + return $upstream + } + + docker tag $upstreamRef $mirror.Reference | Out-Null + Start-AsyncPush $mirror.Reference + $script:OsImages[$upstreamRef] = $mirror + + return $mirror + } + + # Ensure the image and its ancestors exist (parent first): use local, else pull, else build; start a push + # when building in registry mode. Returns the image tag. + # True when $tag exists in the registry. A miss is the ordinary case (the image has not been pushed yet) + # and stays quiet, but any OTHER failure - experimental CLI gating, an untrusted certificate, a lost + # session, an unreachable host - is reported once. Silencing those made a broken registry look exactly like + # a cache miss, so every agent rebuilt the whole ancestor chain on every run and nobody could see why. + function Test-ImageInRegistry([string]$tag) + { + $output = (docker @dockerConfigArg manifest inspect $tag 2>&1 | Out-String).Trim() + + if ($LASTEXITCODE -eq 0) + { + return $true + } + + # A genuine "not in the registry" answer. Anything else is a configuration or connectivity fault. + if ($output -notmatch 'manifest unknown|no such manifest|not found|manifest for .* not found') + { + if (-not $script:RegistryProbeWarned) + { + $script:RegistryProbeWarned = $true + Write-Host "Warning: cannot query the registry for '$tag', so cached images cannot be reused and every layer will be rebuilt locally. $output" -ForegroundColor Yellow + } + } + + return $false + } + + function Ensure-Image([string]$dfPath) + { + $baseBuildArg = @() + $baseDf = Get-BaseDockerfile $dfPath + if ($baseDf) + { + $baseTag = Ensure-Image $baseDf + $baseBuildArg = @('--build-arg', "BASE_IMAGE=$baseTag") + } + + $tag = Resolve-ImageTag $dfPath + + # The Claude leaf is ALWAYS built locally and is NEVER pulled from or pushed to the registry. It bakes a + # weekly cache-buster (update.timestamp) and `@latest` npm/plugin installs, so a registry copy is stale by + # design and sharing it saves nothing. Keeping it local-only also means a missing/unauthenticated registry + # (which only ever served the stable ancestor chain) can never fail a Claude run on pull/push. + $isClaudeLeaf = Test-BakesCacheBuster $dfPath + + Write-Host "Ensuring image: $tag" -ForegroundColor Cyan + + docker image inspect $tag *> $null + if ($LASTEXITCODE -eq 0) + { + Write-Host " found locally" -ForegroundColor Green + } + elseif (-not $isClaudeLeaf -and $dockerRegistry -and (Test-ImageInRegistry $tag)) + { + Write-Host " pulling from registry" -ForegroundColor Green + docker @dockerConfigArg pull $tag 2>&1 | Out-Host + if ($LASTEXITCODE -ne 0) { Write-Host "Docker pull failed for $tag" -ForegroundColor Red; exit 1 } + return $tag + } + else + { + Build-OneImage $dfPath $tag $baseBuildArg + } + + # Push the image as soon as it exists, if it isn't already in the registry: the push then overlaps the + # builds of the images above it in the chain instead of waiting for all of them. The job is waited for + # at the end of the script. The Claude leaf is excluded (see $isClaudeLeaf above): it is local-only and + # never enters the registry. + if ($dockerRegistry -and -not $isClaudeLeaf) + { + if (-not (Test-ImageInRegistry $tag)) + { + Start-AsyncPush $tag + } + } + return $tag + } + + # Docker prints sizes with go-units: "0B", "45.5kB", "12.34GB", "1.1TB". `system df`, `image ls` and + # `image prune` use the DECIMAL scale (kB = 1000); other Docker surfaces use the binary spellings ("1.1GiB"). + # Both are accepted, rather than guessing which one produced a given string. + # + # Returns -1, not 0, for anything that is not a size. Every caller has to tell "Docker reported zero" from + # "Docker reported something this script does not understand", because the second one must disable the + # cleanup instead of making it believe the image store is empty. + function ConvertFrom-DockerSize([string]$text) + { + if ("$text" -notmatch '^\s*(\d+(?:\.\d+)?)\s*([kKmMgGtTpP]?)(i?)B\s*$') + { + return [long]-1 + } + + $exponent = switch ($Matches[2].ToUpperInvariant()) + { + 'K' { 1 } + 'M' { 2 } + 'G' { 3 } + 'T' { 4 } + 'P' { 5 } + default { 0 } + } + + return [long]([double]$Matches[1] * [Math]::Pow($( if ($Matches[3]) { 1024 } else { 1000 } ), $exponent)) + } + + # Formats a byte count in the DECIMAL gigabytes Docker prints (1 GB = 1e9 bytes), not in PowerShell's binary + # 1GB, so that every line logged here agrees with the tool it quotes. + function Format-Gigabytes([long]$bytes) + { + return "$( [Math]::Round($bytes / 1e9, 1) ) GB" + } + + # The size of the whole image store in bytes, or -1 when it cannot be determined. + # + # The Images row of `docker system df` reports what the image store occupies on disk, counting a layer + # shared by several images only once. Adding up the sizes from `docker image ls` instead would count each + # shared layer once per image, and would report roughly three times the truth for a three-deep chain. + # + # Deliberately not `docker system df -v`, which recomputes the shared and unique size of every image and + # takes minutes on an agent that holds hundreds of images. Deliberately not `--format json`, which only + # recent versions of the Docker command line accept; the per-row template below is what the default table + # is built from and has worked ever since `system df` was introduced. + function Get-ImageStoreSize + { + $rows = @(docker system df --format '{{.Type}}|{{.Size}}' 2>&1) + + if ($LASTEXITCODE -ne 0) + { + Write-Host "Could not measure the Docker image store: $( ($rows -join ' ').Trim() )" -ForegroundColor Yellow + return [long]-1 + } + + foreach ($row in $rows) + { + $fields = "$row" -split '\|', 2 + if ($fields.Count -ne 2 -or $fields[0].Trim() -ne 'Images') + { + continue + } + + $size = ConvertFrom-DockerSize $fields[1] + if ($size -lt 0) + { + Write-Host "Could not parse the image store size '$( $fields[1].Trim() )' reported by 'docker system df'." -ForegroundColor Yellow + } + + return $size + } + + Write-Host "'docker system df' reported no Images row, so the image store cannot be measured." -ForegroundColor Yellow + return [long]-1 + } + + # Serializes the cleanup across the concurrent DockerBuild runs of one agent. Without it, two runs that both + # measure the store 50 GB over budget each remove 50 GB, and the agent loses twice what it had to. + # + # This is an optimization, not the guarantee of correctness: runs under different accounts resolve different + # temporary directories and never see each other's lock file. What actually keeps a sibling run's images + # alive is the grace window. The operating system releases the handle when the process ends, so the lock + # cannot go stale. Returns $null when another run holds it; the caller then skips the cleanup rather than + # waiting, because whatever the other run frees, it frees for both. + function Enter-ImageCleanupLock + { + try + { + # Resolved inside the try as well: a temporary directory that the platform rejects makes this throw + # rather than the Open below. + $lockPath = Join-Path ([System.IO.Path]::GetTempPath()) 'PostSharp.DockerBuild.ImageCleanup.lock' + + return [System.IO.File]::Open($lockPath, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None) + } + catch [System.IO.IOException] + { + # The expected case: another run holds the lock. Sharing violations, and every other input/output + # error, arrive here. + return $null + } + catch + { + # Anything else, such as a temporary directory that this account cannot write to, or a path the + # platform rejects. UnauthorizedAccessException does not derive from IOException, so it would + # otherwise escape, and $ErrorActionPreference is 'Stop' - which would fail the build over a + # cleanup that is only ever best effort. + Write-Host "Skipping the image-space cleanup: the lock file could not be opened. $( $_.Exception.Message )" -ForegroundColor Yellow + return $null + } + } + + # Every image reference this run is about to need, so that the cleanup does not remove what the build + # immediately rebuilds or pulls again. + # + # Docker already refuses to remove the ancestor of an image it keeps, so listing the whole chain only makes + # the log readable and saves failed removal attempts. The OS image is the entry that genuinely matters: on a + # run whose chain root is absent, no local image depends on the OS image yet, and on a Windows agent that + # image is both the largest and the oldest one on the machine. Without this it would be the first candidate + # removed, seconds before the root build asks for it. + function Get-ImageChainKeepSet + { + $keep = [System.Collections.Generic.List[string]]::new() + + if ($RegistryImage) + { + # -RegistryImage skips all Dockerfile logic, so there is no chain to resolve and the single image + # this run uses is the whole keep set. + $keep.Add($RegistryImage) + return $keep + } + + # Resolve-ImageTag is pure and memoized, and Get-BaseDockerfile only reads the file, so the complete set + # of tags this run needs is computed without a single call to the Docker engine. + $current = $dockerfileFullPath + $root = $current + while ($current) + { + $keep.Add((Resolve-ImageTag $current)) + $root = $current + $current = Get-BaseDockerfile $current + } + + # $root is now the chain root, the only Dockerfile that declares an OS image. + $osImageSpec = Get-OsImageSpec $root + if ($osImageSpec) + { + $keep.Add("$( $osImageSpec.Repository ):$( $osImageSpec.Tag )") + if ($dockerRegistry) + { + $keep.Add("$( Get-OsMirrorRepository $osImageSpec.Repository ):$( $osImageSpec.Tag )") + } + } + + return $keep + } + + # The images that may be removed, oldest first. + # + # Whatever Docker itself refuses to delete is left to Docker: `docker image rm` refuses to remove an image + # that a container references, or that a descendant is built on, and this function does not try to reproduce + # that reasoning. It only produces a sensible order and drops what is pointless to attempt. + # + # `docker image ls` without -a is deliberate: -a also lists the intermediate images of the classic builder, + # which are the whole build cache of the Windows engine. + function Get-EvictionCandidates([System.Collections.Generic.HashSet[string]]$keepReferences, [datetime]$graceCutoff) + { + $rows = @(docker image ls --no-trunc --format '{{.ID}}|{{.Repository}}|{{.Tag}}|{{.CreatedAt}}|{{.Size}}' 2>&1) + if ($LASTEXITCODE -ne 0) + { + Write-Host "Could not list the Docker images: $( ($rows -join ' ').Trim() )" -ForegroundColor Yellow + return @() + } + + # The image identifier of every container on the engine, running or stopped, which includes the boot + # images of the other DockerBuild runs that share this agent. `docker ps -a --format '{{.Image}}'` would + # give the reference as it was typed when the container was created, which is often a tag that has since + # moved; inspecting the containers gives the identifier the container is really pinned to, which is also + # what `docker image ls` reports. + $inUse = [System.Collections.Generic.HashSet[string]]::new( [StringComparer]::OrdinalIgnoreCase ) + $containerIds = @(docker ps -a -q 2>$null | Where-Object { $_ -and $_.Trim() -ne '' }) + if ($containerIds.Count -gt 0) + { + foreach ($imageId in @(docker inspect --format '{{.Image}}' @containerIds 2>$null)) + { + [void]$inUse.Add(("$imageId" -replace '^sha256:', '')) + } + } + + # One row per TAG, so two rows can carry the same image identifier (the same image tagged both locally + # and with the registry prefix). They are grouped by identifier because the size must be counted once, + # and because removing only some of an image's tags frees nothing at all: only the last tag deletes it. + $byId = [ordered]@{ } + $dateWarningIssued = $false + + foreach ($row in $rows) + { + $fields = "$row" -split '\|' + if ($fields.Count -lt 5) { continue } + + $id = $fields[0] -replace '^sha256:', '' + $repository = $fields[1] + $tag = $fields[2] + + # Dangling images are handled up front by `docker image prune`, which applies Docker's own + # definition of dangling. A '' repository here is therefore already gone, or is the parent of + # something, and is not a candidate either way. + if ($repository -eq '') { continue } + + if ($inUse.Contains($id)) { continue } + + $reference = "${repository}:${tag}" + + if (-not $byId.Contains($id)) + { + # Docker prints CreatedAt as "2026-05-13 09:21:33 +0200 CEST", a Go layout that .NET cannot + # parse as a whole. Every row is formatted in the same time zone, so the leading + # "yyyy-MM-dd HH:mm:ss" alone orders them correctly and is all that is read. A date that cannot + # be read is treated as brand new, and therefore never removed, rather than as ancient: if the + # format ever changes, the result must be "frees nothing", never "deletes the oldest image on + # the agent". + $created = [datetime]::MaxValue + if ($fields[3] -match '^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})') + { + [void][datetime]::TryParseExact($Matches[1], 'yyyy-MM-dd HH:mm:ss', [cultureinfo]::InvariantCulture, [Globalization.DateTimeStyles]::None, [ref]$created) + } + elseif (-not $dateWarningIssued) + { + $dateWarningIssued = $true + Write-Host "Cannot read the creation date '$( $fields[3] )' that 'docker image ls' reports; an image whose date cannot be read is never removed." -ForegroundColor Yellow + } + + $size = ConvertFrom-DockerSize $fields[4] + + $byId[$id] = [pscustomobject]@{ + Id = $id + References = [System.Collections.Generic.List[string]]::new() + Created = $created + Size = $( if ($size -lt 0) { [long]0 } else { $size } ) # an unreadable size contributes nothing to the target + Keep = $false + Removed = $false + } + } + + # A tag in the keep set protects the whole image, not only that one tag: removing its other tags + # would free nothing and would leave a half-untagged image behind. + if ($keepReferences.Contains($reference)) { $byId[$id].Keep = $true } + + if ($tag -ne '') { $byId[$id].References.Add($reference) } + } + + # Oldest first. The Docker API exposes no last-used time for an image, so age is the only signal + # available; the images this run is about to need are protected by the keep set instead. + return @($byId.Values | + Where-Object { -not $_.Keep -and $_.Created -lt $graceCutoff } | + Sort-Object Created) + } + + # Frees image disk space when the image store is over budget. Best effort throughout: this frees disk, it + # does not gate the build, so nothing here may change the exit code of the script. + # + # Runs BEFORE the image chain is built, so that the space it frees is space this build can use, and before + # the registry login, because everything it does is local to the Docker engine and needs no credentials. + function Invoke-ImageSpaceCleanup([long]$budgetBytes, [string[]]$keepReferences) + { + $total = Get-ImageStoreSize + if ($total -lt 0) + { + Write-Host "Skipping the image-space cleanup: the image store could not be measured." -ForegroundColor Yellow + return + } + + if ($total -le $budgetBytes) + { + Write-Host "Docker image store: $( Format-Gigabytes $total ) of the $( Format-Gigabytes $budgetBytes ) budget." -ForegroundColor Cyan + return + } + + Write-Host "Docker image store is $( Format-Gigabytes $total ), over the $( Format-Gigabytes $budgetBytes ) budget; freeing space." -ForegroundColor Yellow + + $lock = Enter-ImageCleanupLock + if (-not $lock) + { + Write-Host "Another DockerBuild run is already freeing image space; skipping the cleanup." -ForegroundColor Yellow + return + } + + try + { + $started = [System.Diagnostics.Stopwatch]::StartNew() + $graceCutoff = (Get-Date).AddHours(-$ImageCleanupGraceHours) + $initial = $total + + # Dangling images first, through Docker's own prune, which applies Docker's definition of dangling + # (untagged AND not the parent of anything) and so leaves the intermediate images of the Windows + # classic builder alone. The `until` filter applies the same grace window as the removal below, and + # closes the moment between a sibling run committing its last layer and tagging it, during which its + # image is briefly indistinguishable from an orphan. + # + # Done separately, and first, because a dangling image shares almost every layer with the image that + # replaced it. Inside the loop below its reported size would satisfy the whole overage on paper and + # free nothing at all, wasting a pass. + $pruneOutput = (docker image prune --force --filter "until=$( $ImageCleanupGraceHours )h" 2>&1 | Out-String) + if ($pruneOutput -match 'Total reclaimed space:\s*(\S+)') + { + $reclaimed = ConvertFrom-DockerSize $Matches[1] + if ($reclaimed -gt 0) + { + Write-Host " reclaimed $( Format-Gigabytes $reclaimed ) from dangling images" -ForegroundColor Gray + + # Measure again rather than subtract, so that the loop below never removes space that has + # already been freed. + $total = Get-ImageStoreSize + if ($total -lt 0) { return } + } + } + + # The ?? guards the HashSet constructor, which rejects a null collection: a caller that resolved no + # keep set at all must lose the cleanup, not the build. + $keep = [System.Collections.Generic.HashSet[string]]::new( [string[]]($keepReferences ?? @()), [StringComparer]::OrdinalIgnoreCase ) + $candidates = Get-EvictionCandidates $keep $graceCutoff + + for ($pass = 1; $pass -le $ImageCleanupMaxPasses -and $total -gt $budgetBytes; $pass++) + { + if ($started.Elapsed.TotalMinutes -ge $ImageCleanupTimeoutMinutes) + { + Write-Host "Giving up on the image-space cleanup after $ImageCleanupTimeoutMinutes minutes." -ForegroundColor Yellow + break + } + + # Accumulate against the overage that was just measured. The size reported for an image includes + # the layers it shares with images that survive, so this sum overstates what removing the batch + # frees: the batch is guaranteed to free at most the overage, never more. That is the right + # direction to be wrong in, because it costs passes and not images, and it is why the true total + # is measured again after every pass instead of being tracked by subtraction. + $overage = $total - $budgetBytes + $batch = [System.Collections.Generic.List[object]]::new() + $accumulated = [long]0 + + foreach ($candidate in $candidates) + { + if ($candidate.Removed) { continue } + $batch.Add($candidate) + $accumulated += $candidate.Size + if ($accumulated -ge $overage) { break } + } + + if ($batch.Count -eq 0) + { + Write-Host "No image is left to remove; the image store stays at $( Format-Gigabytes $total )." -ForegroundColor Yellow + break + } + + Write-Host "Pass $pass`: removing up to $( $batch.Count ) unused image(s) to reclaim $( Format-Gigabytes $overage )." -ForegroundColor Cyan + + # Issue the removals NEWEST first, although the order of selection is oldest first: an image + # cannot be removed while a descendant is built on it, and within a chain the descendant is the + # younger image. Issuing them oldest first would fail on every parent. Repeat while anything is + # still coming off, so that a parent freed by the removal of its child also goes in this pass. + $removedInPass = 0 + for ($attempt = 0; $attempt -lt 4; $attempt++) + { + $removedInAttempt = 0 + + foreach ($candidate in ($batch | Sort-Object Created -Descending)) + { + if ($candidate.Removed) { continue } + + # An image with no usable name and tag (a pull pinned to a digest) can only be addressed + # by its identifier. Docker refuses that when several repositories reference the image, + # which is handled below like any other refusal. + $references = if ($candidate.References.Count -gt 0) { $candidate.References } else { @($candidate.Id) } + + $failure = $null + foreach ($reference in $references) + { + $output = (docker image rm $reference 2>&1 | Out-String).Trim() + + # Deliberately not forced. `docker image rm -f` untags an image that a stopped + # container still holds, which is exactly how a concurrent run gets broken: the + # refusal below is the safety net that this whole function relies on. + # + # "No such image" means that another pass, or another run, got there first, which + # counts as success. + if ($LASTEXITCODE -ne 0 -and $output -notmatch 'No such image') + { + $failure = $output + } + } + + if ($failure) + { + # The two expected refusals are that a container holds the image, possibly a sibling + # run's, and that a descendant protected by the keep set or the grace window is + # built on it. Both mean the image was correctly skipped, and are reported as detail + # rather than as a problem. An image that carries several tags loses the tags that + # were removed before the refusal; that frees nothing, but it also breaks nothing, + # because a container and a child image are both pinned to the identifier. + if ($failure -notmatch 'being used by|dependent child images|No such image') + { + Write-Host " could not remove $( $references[0] ): $failure" -ForegroundColor Yellow + } + } + else + { + $candidate.Removed = $true + $removedInAttempt++ + Write-Host " removed $( $references -join ', ' )" -ForegroundColor Gray + } + } + + $removedInPass += $removedInAttempt + if ($removedInAttempt -eq 0) { break } + } + + if ($removedInPass -eq 0) + { + # Every image in the batch was refused, and the list of candidates only ever shrinks, so + # another pass would select the same images and be refused again. + Write-Host "Nothing could be removed; the image store stays at $( Format-Gigabytes $total )." -ForegroundColor Yellow + break + } + + $total = Get-ImageStoreSize + if ($total -lt 0) + { + # The store can no longer be measured, so there is no way to tell when to stop. Stopping is + # the only safe answer. + break + } + } + + $freed = $initial - $total + if ($total -le $budgetBytes) + { + Write-Host "Freed $( Format-Gigabytes $freed ); the image store is now $( Format-Gigabytes $total ) of the $( Format-Gigabytes $budgetBytes ) budget." -ForegroundColor Green + } + else + { + Write-Host "Freed $( Format-Gigabytes $freed ), but the image store is still $( Format-Gigabytes $total ), over the $( Format-Gigabytes $budgetBytes ) budget." -ForegroundColor Yellow + } + } + finally + { + $lock.Dispose() + } + } + + # Dictionary to track volume mounts with "writable wins" logic + $script:VolumeMountDict = @{ } + + # Async registry push: each image is pushed in its own background job, started by Ensure-Image as soon as + # the image exists, and waited for at the very end of the script. A push therefore overlaps the builds that + # follow it and the container run. + $script:RegistryPushJobs = @() + + # Set by Wait-ForRegistryPushes when any push failed, and read on both exit paths (normal completion and + # the `finally` block). + $script:PushFailed = $false + + # The exit code the script has decided on, so that the `finally` block can tell a build that is failing for + # another reason from one that would otherwise have succeeded. + $script:ExitCode = 0 + + # OS images the root images are built FROM, keyed by upstream reference and resolved on first use by + # Get-OsImage. + $script:OsImages = @{ } + + # The Docker engine architecture, resolved on first use by Get-DockerArchitecture. + $script:DockerArchitecture = $null + + # Tags already handed to Start-AsyncPush, so that resolving the same image twice (the run step resolves the + # chain again) does not push it twice. + $script:PushedTags = [System.Collections.Generic.HashSet[string]]::new( [StringComparer]::Ordinal ) + + # Tag of the local, run-specific boot image (set by New-BootImage); removed after the container exits. + $script:BootImageTag = $null + + function Add-VolumeMount + { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [switch]$Writable, + [switch]$Requested # Asked for by -Mount, rather than part of the default build-container set. + ) + + $normalizedPath = $Path.TrimEnd('\', '/') + $normalizedKey = $normalizedPath.ToLower() + $isGitDirectory = Test-Path (Join-Path $normalizedPath ".git") + + if ( $script:VolumeMountDict.ContainsKey($normalizedKey)) + { + if ($Writable) + { + $script:VolumeMountDict[$normalizedKey].Writable = $true + } + } + else + { + $script:VolumeMountDict[$normalizedKey] = @{ + HostPath = $normalizedPath + Writable = [bool]$Writable + IsGitDirectory = $isGitDirectory + } + } + } + + if ($env:RUNNING_IN_DOCKER) + { + Write-Error "Already running in Docker." + exit 1 + } + + if ($RegistryImage) + { + # Use the pre-built registry image directly, skip all Dockerfile logic + $ImageTag = $RegistryImage + $NoBuildImage = $true + Write-Host "Using registry image: $ImageTag" -ForegroundColor Cyan + } + else + { + # Single source of truth for the cache-busting stamp, shared by + # Get-ContentHash (image tag, Claude mode only) and Get-TimestampFile + # (update.timestamp file baked into the image). Computing it once here + # guarantees both consumers see the same value even if the wall clock + # crosses a week boundary mid-run. + # + # The stamp rotates once per UTC week (anchored to the Monday of the + # current week) so the @latest npm installs of the Claude CLI and + # marketplace plug-ins refresh weekly rather than daily. Use -Update to + # force an immediate refresh regardless of the week boundary. + $script:DayStamp = if ($Update) + { + [DateTime]::UtcNow.ToString("o") # full ISO 8601, seconds precision + } + else + { + # Monday of the current UTC week (DayOfWeek: Sunday=0 .. Saturday=6). + $utcToday = [DateTime]::UtcNow.Date + $daysSinceMonday = ([int]$utcToday.DayOfWeek + 6) % 7 + $utcToday.AddDays(-$daysSinceMonday).ToString("yyyy-MM-dd") + } + + # Determine which Dockerfile will be used. + $DockerfilesDir = "$EngPath/docker" + + # Detect the Windows base-image tag. The OS variant is delivered as the WINDOWS_VERSION build-arg (and + # folded into the content hash) rather than as a separate Dockerfile, so one chain serves both editions. + # Windows build < 26100 is Windows Server 2022; otherwise Windows Server 2025. + $windowsVersion = $null + if ($IsWindows) + { + $osBuild = [System.Environment]::OSVersion.Version.Build + $windowsVersion = if ($osBuild -lt 26100) { 'ltsc2022' } else { 'ltsc2025' } + Write-Host "Detected Windows build $osBuild; using base image tag '$windowsVersion'" -ForegroundColor Cyan + } + + if (-not $Dockerfile) + { + # Dockerfile names are prefix-free (".Dockerfile"). -Claude targets the claude leaf; otherwise + # the build leaf. The chain resolver walks ARG BASE_IMAGE to build/pull the ancestors first. + $layer = if ($Claude) { 'claude' } else { 'build' } + $Dockerfile = "$DockerfilesDir/$layer.Dockerfile" + } + + # Get the full path of the Dockerfile + if ( [System.IO.Path]::IsPathRooted($Dockerfile)) + { + $dockerfileFullPath = $Dockerfile + } + else + { + $dockerfileFullPath = Join-Path $PSScriptRoot $Dockerfile + } + + # Resolve the Docker registry for build images (env-based). Registry mode is off (local image tags) if + # not set. Set before Resolve-ImageTag, which uses it to form the tag. + # -NoRegistry suppresses the environment, which is how a host that cannot reach or verify the registry + # still builds: the cost is that it builds every layer itself instead of pulling the ones already made. + if ($NoRegistry) + { + Write-Host "Registry disabled by -NoRegistry; building images locally." -ForegroundColor Yellow + $dockerRegistry = $null + } + else + { + $dockerRegistry = $env:DOCKER_REGISTRY + } + + # Compute the target image tag (and, transitively, its ancestors' tags via ARG BASE_IMAGE). The image + # name is the Dockerfile stem; the tag is its content hash (folding the parent tag, OS and day-stamp). + $ImageTag = Resolve-ImageTag $dockerfileFullPath + Write-Host "Target image tag: $ImageTag" -ForegroundColor Cyan + } + + # Check MCP server availability for -Claude mode + # The MCP approval server is now a standalone GUI app that must be started separately + $mcpServerAvailable = $false + if ($Claude -and -not $NoMcp) + { + if (Test-McpServerRunning) + { + Write-Host "MCP approval server detected on port $mcpFixedPort" -ForegroundColor Cyan + $mcpServerAvailable = $true + } + else + { + Write-Warning "MCP approval server not running on port $mcpFixedPort." + Write-Warning "Start PostSharp.Engineering.McpApprovalServer.exe before using -Claude mode for host operations." + Write-Warning "Continuing without MCP server support." + } + } + + # When building locally (as opposed as on the build agent), we can optionally do a complete cleanup because + # obj files may point to the host filesystem. + if ($Clean) + { + Write-Host "Cleaning up." -ForegroundColor Green + Get-ChildItem "bin" -Recurse | Remove-Item -Force -Recurse -ErrorAction SilentlyContinue + Get-ChildItem "obj" -Recurse | Remove-Item -Force -Recurse -ErrorAction SilentlyContinue + } + + Write-Host "Preparing context and mounts." -ForegroundColor Green + # Collect environment variables for container (will be inlined in Init.g.ps1) + if (-not $KeepInit) + { + # Create timestamp file for cache invalidation (only if building image). Build-OneImage stages it into + # the context of each image that bakes it; when the run step rebuilds a Claude leaf without having come + # through here, Copy-TimestampToContext creates it on demand. + if (-not $NoBuildImage) + { + $script:TimestampFile = Get-TimestampFile + } + + if ($Claude) + + { + # Use Claude-specific environment variables (filtered and renamed) + New-ClaudeEnvHashtable + } + else + { + # Use standard build environment variables + if (-not $env:ENG_USERNAME) + { + $env:ENG_USERNAME = $env:USERNAME + } + + # Add git identity to environment + if ($env:IS_TEAMCITY_AGENT) + { + # On TeamCity agents, check if the environment variables are set. + if (-not $env:GIT_USER_EMAIL -or -not $env:GIT_USER_NAME) + { + Write-Error "On TeamCity agents, the GIT_USER_EMAIL and GIT_USER_NAME environment variables must be set." + exit 1 + } + } + else + { + # On developer machines, use the current git user. + $env:GIT_USER_EMAIL = git config --global user.email + $env:GIT_USER_NAME = git config --global user.name + } + + New-EnvHashtable -EnvironmentVariableList $EnvironmentVariables + } + + # Allow the product repo to customize the container environment variables. + # The optional script mutates the hashtable in place (add / change / remove keys) + # and receives the leaf Dockerfile name and the mode as context. + $customizeEnvScript = Join-Path $EngPath 'CustomizeDockerEnvironment.ps1' + if (Test-Path $customizeEnvScript) + { + $dockerfileName = if ($Dockerfile) { Split-Path -Leaf $Dockerfile } else { '' } + Write-Host "Customizing environment variables from $customizeEnvScript" -ForegroundColor Cyan + . $customizeEnvScript ` + -ContainerEnvironmentVariables $script:ContainerEnvironmentVariables ` + -DockerfileName $dockerfileName ` + -Claude:([bool]$Claude) + + $sortedKeys = $script:ContainerEnvironmentVariables.Keys | Sort-Object + Write-Host "Environment variables after customization: $( $sortedKeys -join ', ' )" -ForegroundColor Gray + } + } + + # Get the source directory name from $PSScriptRoot (script location) + $SourceDirName = $PSScriptRoot + + # Start timing the entire process except cleaning + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + + # Ensure docker context directory exists (not needed for registry images) + if (-not $RegistryImage -and -not (Test-Path $dockerContextDirectory)) + { + New-Item -ItemType Directory -Path $dockerContextDirectory -Force | Out-Null + } + + + # Container user profile (matches actual user in container) + $containerUserProfile = if ($IsUnix) + { + "/root" + } + else + { + "C:\Users\ContainerAdministrator" + } + + # Initialize arrays for special mounts (those with different host/container paths) + $VolumeMappings = @() + $MountPoints = @() + $GitDirectories = @() + + # Prepare volume mappings using the dictionary + Add-VolumeMount -Path $SourceDirName -Writable + + # Define static Git system directory for mapping. This used by Teamcity as an LFS parent repo. + $gitSystemDir = "$BuildAgentPath\system\git" + + if (Test-Path $gitSystemDir) + { + Add-VolumeMount -Path $gitSystemDir + } + + # Mount the host NuGet cache in the container. + if (-not $NoNuGetCache) + { + # Use NUGET_PACKAGES from environment or default to user profile + $nugetCacheDir = $env:NUGET_PACKAGES + if ( [string]::IsNullOrEmpty($nugetCacheDir)) + { + if ($IsUnix) + { + $nugetCacheDir = Join-Path $env:HOME ".nuget/packages" + } + else + { + $nugetCacheDir = Join-Path $env:USERPROFILE ".nuget\packages" + } + } + + Write-Host "NuGet cache directory: $nugetCacheDir" -ForegroundColor Cyan + if (-not (Test-Path $nugetCacheDir)) + { + Write-Host "Creating NuGet cache directory on host: $nugetCacheDir" + New-Item -ItemType Directory -Force -Path $nugetCacheDir | Out-Null + } + + # Mount to the same path in the container (will be transformed by Get-ContainerPath later) + Add-VolumeMount -Path $nugetCacheDir -Writable + } + + # Mount PostSharp.Engineering data directory (for version counters) + $hostEngineeringDataDir = if ($IsUnix) + { + Join-Path $env:HOME ".local/share/PostSharp.Engineering" + } + else + { + Join-Path $env:LOCALAPPDATA "PostSharp.Engineering" + } + + if (-not (Test-Path $hostEngineeringDataDir)) + { + New-Item -ItemType Directory -Force -Path $hostEngineeringDataDir | Out-Null + } + + $containerEngineeringDataDir = if ($IsUnix) + { + Join-Path $containerUserProfile ".local/share/PostSharp.Engineering" + } + else + { + Join-Path $containerUserProfile "AppData\Local\PostSharp.Engineering" + } + $VolumeMappings += "${hostEngineeringDataDir}:${containerEngineeringDataDir}" + $MountPoints += $containerEngineeringDataDir + + # Mount VS Remote Debugger + if ($StartVsmon) + { + if (-not $env:DevEnvDir) + { + Write-Host "Environment variable 'DevEnvDir' is not defined." -ForegroundColor Red + exit 1 + } + + $remoteDebuggerHostDir = "$( $env:DevEnvDir )Remote Debugger\x64" + if (-not (Test-Path $remoteDebuggerHostDir)) + { + Write-Host "Directory '$remoteDebuggerHostDir' does not exist." -ForegroundColor Red + exit 1 + } + + $remoteDebuggerContainerDir = "C:\msvsmon" + $VolumeMappings += "${remoteDebuggerHostDir}:${remoteDebuggerContainerDir}:ro" + $MountPoints += $remoteDebuggerContainerDir + + } + + # Discover symbolic links in source-dependencies and add their targets to mount points + $sourceDependenciesDir = Join-Path $SourceDirName "source-dependencies" + if (Test-Path $sourceDependenciesDir) + { + $symbolicLinks = Get-ChildItem -Path $sourceDependenciesDir -Force | Where-Object { $_.LinkType -eq 'SymbolicLink' } + + foreach ($link in $symbolicLinks) + { + $targetPath = $link.Target + if (-not [string]::IsNullOrEmpty($targetPath) -and (Test-Path $targetPath)) + { + Write-Host "Found symbolic link '$( $link.Name )' -> '$targetPath'" -ForegroundColor Cyan + Add-VolumeMount -Path $targetPath + } + else + { + Write-Host "Warning: Symbolic link '$( $link.Name )' target '$targetPath' does not exist or is invalid" -ForegroundColor Yellow + } + } + + $sourceDirectories = Get-ChildItem -Path $sourceDependenciesDir -Force | Where-Object { $_.LinkType -eq $null } + foreach ($sourceDirectory in $sourceDirectories) + { + Write-Host "Mounting source-dependencies directory: $( $sourceDirectory.FullName )" -ForegroundColor Cyan + $GitDirectories += $sourceDirectory.FullName + } + } + + # Mount sibling directories from the product family (parent directory) + # Only if parent is a recognized product family (PostSharp* or Metalama*) + $parentDir = Split-Path $SourceDirName -Parent + $parentDirName = Split-Path $parentDir -Leaf + if ($parentDir -and (Test-Path $parentDir) -and ($parentDirName -like "PostSharp*" -or $parentDirName -like "Metalama*")) + { + Write-Host "Detected product family directory: $parentDirName" -ForegroundColor Cyan + $siblingDirs = Get-ChildItem -Path $parentDir -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -ne $SourceDirName } + + foreach ($sibling in $siblingDirs) + { + $siblingPath = $sibling.FullName + Write-Host "Mounting product family sibling: $siblingPath" -ForegroundColor Cyan + Add-VolumeMount -Path $siblingPath + } + } + + # Mount PostSharp.Engineering.* directories from grandparent + # This provides access to engineering tools and related repos + $grandparentDir = Split-Path $parentDir -Parent + if ($grandparentDir -and (Test-Path $grandparentDir)) + { + $engineeringDirs = Get-ChildItem -Path $grandparentDir -Directory -Filter "PostSharp.Engineering*" -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -ne $SourceDirName } + + foreach ($engDir in $engineeringDirs) + { + $engDirPath = $engDir.FullName + Write-Host "Mounting engineering repo: $engDirPath" -ForegroundColor Cyan + Add-VolumeMount -Path $engDirPath + } + } + + # Process -Mount parameter for additional directory mounts + if ($Mount -and $Mount.Count -gt 0) + { + foreach ($mountSpec in $Mount) + { + # Check if writable (ends with :w) + $isWritable = $false + $pattern = $mountSpec + if ($mountSpec -match ':w$') + { + $isWritable = $true + $pattern = $mountSpec -replace ':w$', '' + } + + # Trim trailing slashes + $pattern = $pattern.TrimEnd('\', '/') + + # Check if pattern contains glob characters + if ($pattern -match '\*') + { + # Expand glob pattern to match directories only + # Get the base directory (everything before the first glob) + $patternParts = $pattern -split '[\\/]' + $basePathParts = @() + $globStartIndex = -1 + + for ($i = 0; $i -lt $patternParts.Count; $i++) + { + if ($patternParts[$i] -match '\*') + { + $globStartIndex = $i + break + } + $basePathParts += $patternParts[$i] + } + + if ($basePathParts.Count -gt 0) + { + $basePath = $basePathParts -join [System.IO.Path]::DirectorySeparatorChar + } + else + { + $basePath = "." + } + + if (Test-Path $basePath) + { + # Determine if recursive search is needed (pattern contains **) + $isRecursive = $pattern -match '\*\*' + + # Build the glob pattern for the part after the base path + $globPart = ($patternParts[$globStartIndex..($patternParts.Count - 1)]) -join [System.IO.Path]::DirectorySeparatorChar + + # Get matching directories + $matchingDirs = @() + if ($isRecursive) + { + # For ** patterns, recurse and convert ** to * for -like matching + # Replace ** with a regex-friendly pattern for matching + $likePattern = $pattern -replace '\*\*', '*' + $matchingDirs = Get-ChildItem -Path $basePath -Directory -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -like $likePattern } + } + else + { + # For single * patterns, use direct matching without recursion + $matchingDirs = Get-ChildItem -Path $basePath -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -like $pattern } + } + + if ($matchingDirs.Count -eq 0) + { + Write-Host "Warning: No directories matched pattern '$pattern'" -ForegroundColor Yellow + } + else + { + foreach ($dir in $matchingDirs) + { + $dirPath = $dir.FullName + $rwStatus = if ($isWritable) + { + "writable" + } + else + { + "readonly" + } + Write-Host "Mounting from -Mount pattern '$pattern': $dirPath ($rwStatus)" -ForegroundColor Cyan + Add-VolumeMount -Path $dirPath -Writable:$isWritable -Requested + } + } + } + else + { + Write-Host "Warning: Base path '$basePath' for pattern '$pattern' does not exist" -ForegroundColor Yellow + } + } + else + { + # No glob - mount directly if it's a directory + if (Test-Path $pattern -PathType Container) + { + $rwStatus = if ($isWritable) + { + "writable" + } + else + { + "readonly" + } + Write-Host "Mounting from -Mount: $pattern ($rwStatus)" -ForegroundColor Cyan + Add-VolumeMount -Path $pattern -Writable:$isWritable -Requested + } + else + { + Write-Host "Warning: Mount path '$pattern' does not exist or is not a directory" -ForegroundColor Yellow + } + } + } + } + + # Convert dictionary entries to arrays (with "writable wins" deduplication already applied) + # Sort by key for deterministic ordering to optimize Docker image layer reuse + foreach ($key in $script:VolumeMountDict.Keys | Sort-Object) + { + $entry = $script:VolumeMountDict[$key] + $mountOption = if ($entry.Writable) + { + "" + } + else + { + ":ro" + } + $VolumeMappings += "$( $entry.HostPath ):$( $entry.HostPath )$mountOption" + $MountPoints += $entry.HostPath + if ($entry.IsGitDirectory) + { + $GitDirectories += $entry.HostPath + } + } + + # Execute auto-generated DockerMounts.g.ps1 script to add more directory mounts. A test container gets these + # like any other build: a test that consumes a source dependency needs the same repositories the build needs. + $dockerMountsScript = Join-Path $EngPath 'DockerMounts.g.ps1' + if (Test-Path $dockerMountsScript) + { + Write-Host "Importing Docker mount points from $dockerMountsScript" -ForegroundColor Cyan + . $dockerMountsScript + + # Check if we need to convert Windows paths to WSL paths + # This happens when DockerMounts.g.ps1 was generated on Windows but we're running on WSL + if ($IsUnix) + { + # Check if any volume mapping contains Windows-style paths (e.g., C:\) + $hasWindowsPaths = $VolumeMappings | Where-Object { $_ -match '^[A-Za-z]:\\' } + + if ($hasWindowsPaths) + { + Write-Host "Detected Windows paths in DockerMounts.g.ps1 while running on Unix. Converting paths to WSL format." -ForegroundColor Yellow + + # Function to convert Windows path to WSL path + function ConvertTo-WslPath + { + param([string]$WindowsPath) + + if ($WindowsPath -match '^([A-Za-z]):\\(.*)$') + { + $drive = $Matches[1].ToLower() + $path = $Matches[2] -replace '\\', '/' + return "/mnt/$drive/$path" + } + return $WindowsPath + } + + # Convert VolumeMappings + # Note: When running Docker Desktop for Windows from WSL, BOTH host and container paths + # need to be in WSL format (/mnt/c/...) because Docker is invoked from WSL context. + $convertedVolumeMappings = @() + foreach ($mapping in $VolumeMappings) + { + # Parse mapping: hostPath:containerPath[:options] + # Challenge: colons appear in Windows paths (C:\) and as delimiters + # Strategy: Split on : and reconstruct Windows paths (single letter followed by \ path) + $parts = $mapping -split ':' + + $i = 0 + + # Extract host path + if ($parts[$i].Length -eq 1 -and $i + 1 -lt $parts.Length -and $parts[$i + 1] -match '^[\\/]') + { + # Windows path: C:\path - convert to WSL format + $hostPath = "$( $parts[$i] ):$( $parts[$i + 1] )" + $hostPath = ConvertTo-WslPath $hostPath + $i += 2 + } + else + { + # Unix path: /path - keep as-is + $hostPath = $parts[$i] + $i += 1 + } + + # Extract container path + if ($i -lt $parts.Length) + { + if ($parts[$i].Length -eq 1 -and $i + 1 -lt $parts.Length -and $parts[$i + 1] -match '^[\\/]') + { + # Windows path - convert to WSL format + $containerPath = "$( $parts[$i] ):$( $parts[$i + 1] )" + $containerPath = ConvertTo-WslPath $containerPath + $i += 2 + } + else + { + # Unix path - keep as-is + $containerPath = $parts[$i] + $i += 1 + } + } + else + { + $containerPath = $hostPath # Fallback + } + + # Rest is options (:ro or :rw) + if ($i -lt $parts.Length) + { + $options = ':' + ($parts[$i..($parts.Length - 1)] -join ':') + } + else + { + $options = '' + } + + $convertedVolumeMappings += "${hostPath}:${containerPath}${options}" + } + $VolumeMappings = $convertedVolumeMappings + + # Convert MountPoints + $MountPoints = $MountPoints | ForEach-Object { ConvertTo-WslPath $_ } + + # Convert GitDirectories + $GitDirectories = $GitDirectories | ForEach-Object { ConvertTo-WslPath $_ } + } + } + } + elseif (-not $env:IS_TEAMCITY_AGENT) + { + Write-Error "DockerMounts.g.ps1 not found at '$dockerMountsScript'. Run './Build.ps1 prepare' or './Build.ps1 dependencies update' to generate it." + exit 1 + } + + # Handle path transformations (platform-specific) + $substCommandsInline = "" + + if ($IsWindows) + { + # Handle non-C: drive letters for Docker (Windows containers only have C: by default) + # We mount X:\foo to C:\X\foo in the container, then use subst to create the X: drive + $driveLetters = @{ } + + function Get-ContainerPath($hostPath) + { + if ($hostPath -match '^([A-Za-z]):(.*)$') + { + $driveLetter = $Matches[1].ToUpper() + $pathWithoutDrive = $Matches[2] + if ($driveLetter -ne 'C') + { + $driveLetters[$driveLetter] = $true + return "C:\$driveLetter$pathWithoutDrive" + } + } + return $hostPath + } + + # Transform all volume mappings to use container paths + $transformedVolumeMappings = @() + foreach ($mapping in $VolumeMappings) + { + # Parse volume mapping: hostPath:containerPath[:options] + if ($mapping -match '^([A-Za-z]:\\[^:]*):([A-Za-z]:\\[^:]*)(:.+)?$') + { + $hostPath = $Matches[1] + $containerPath = $Matches[2] + $options = $Matches[3] + $newContainerPath = Get-ContainerPath $containerPath + $transformedVolumeMappings += "${hostPath}:${newContainerPath}${options}" + } + else + { + $transformedVolumeMappings += $mapping + } + } + $VolumeMappings = $transformedVolumeMappings + + # Transform MountPoints, GitDirectories, SourceDirName, and CallingDirectory for the container + $MountPoints = $MountPoints | ForEach-Object { Get-ContainerPath $_ } + $GitDirectories = $GitDirectories | ForEach-Object { Get-ContainerPath $_ } + $ContainerSourceDir = Get-ContainerPath $SourceDirName + $ContainerCallingDir = Get-ContainerPath $CallingDirectory + if ($PostInit) + { + $ContainerPostInit = Get-ContainerPath $PostInit + } + + # Add both the unmapped (C:\X\...) and mapped (X:\...) paths to GitDirectories for safe.directory + # Git may resolve paths differently depending on how it's invoked + $expandedGitDirectories = @() + foreach ($dir in $GitDirectories) + { + $expandedGitDirectories += $dir + # If path is C:\\... (unmapped subst path), also add :\... (mapped path) + if ($dir -match '^C:\\([A-Za-z])\\(.*)$') + { + $letter = $Matches[1].ToUpper() + $rest = $Matches[2] + $expandedGitDirectories += "${letter}:\$rest" + } + } + $GitDirectories = $expandedGitDirectories + + # Deduplicate again after transformations and expansions (case-insensitive for Windows paths) + $VolumeMappings = $VolumeMappings | Group-Object { $_.ToLower() } | ForEach-Object { $_.Group[0] } + $MountPoints = $MountPoints | Group-Object { $_.ToLower() } | ForEach-Object { $_.Group[0] } + $GitDirectories = $GitDirectories | Group-Object { "$_".ToLower() } | ForEach-Object { $_.Group[0] } + + # Build subst commands string for inline execution in docker run + foreach ($letter in $driveLetters.Keys | Sort-Object) + { + $substCommandsInline += "C:\Windows\System32\subst.exe ${letter}: C:\$letter; " + } + if ($driveLetters.Keys.Count -gt 0) + { + Write-Host "Drive letter mappings for container: $( $driveLetters.Keys -join ', ' )" -ForegroundColor Cyan + } + } + else + { + # Unix (Linux/macOS): No drive letter mapping needed, paths remain as-is + $ContainerSourceDir = $SourceDirName + $ContainerCallingDir = $CallingDirectory + if ($PostInit) + { + $ContainerPostInit = $PostInit + } + + # Deduplicate (case-sensitive for Unix paths) + $VolumeMappings = $VolumeMappings | Sort-Object -Unique + $MountPoints = $MountPoints | Sort-Object -Unique + $GitDirectories = $GitDirectories | Sort-Object -Unique + } + + # Create Init.g.ps1 with environment variables, git configuration (safe.directory and user identity) + # This file is generated in $EngPath/.g/ (outside docker-context) and accessed via mounted directory + if (-not $NoInit -and -not $KeepInit) + { + $gDirectory = Join-Path $EngPath ".g" + if (-not (Test-Path $gDirectory)) + { + New-Item -ItemType Directory -Path $gDirectory -Force | Out-Null + } + $initScript = Join-Path $gDirectory "Init.g.ps1" + + # Generate inline environment variable assignments + $envVarAssignments = "" + if ($script:ContainerEnvironmentVariables -and $script:ContainerEnvironmentVariables.Count -gt 0) + { + $envVarAssignments = "# Set environment variables`n" + foreach ($key in $script:ContainerEnvironmentVariables.Keys | Sort-Object) + { + $value = $script:ContainerEnvironmentVariables[$key] + # Escape single quotes in the value + $escapedValue = $value -replace "'", "''" + $envVarAssignments += "Write-Host `"Setting environment variable: $key`" -ForegroundColor Green`n" + $envVarAssignments += "[Environment]::SetEnvironmentVariable('$key', '$escapedValue', [EnvironmentVariableTarget]::Machine)`n" + $envVarAssignments += "`$env:$key='$escapedValue'`n" + } + $envVarAssignments += "`n" + } + + # Generate git config commands directly from known values + $gitConfigCommands = "# Configure git identity and safe.directory`n" + + if ($script:ContainerEnvironmentVariables -and $script:ContainerEnvironmentVariables.ContainsKey('GIT_USER_NAME')) + { + $escapedName = $script:ContainerEnvironmentVariables['GIT_USER_NAME'] -replace "'", "''" + $gitConfigCommands += "git config --global user.name '$escapedName'`n" + } + if ($script:ContainerEnvironmentVariables -and $script:ContainerEnvironmentVariables.ContainsKey('GIT_USER_EMAIL')) + { + $escapedEmail = $script:ContainerEnvironmentVariables['GIT_USER_EMAIL'] -replace "'", "''" + $gitConfigCommands += "git config --global user.email '$escapedEmail'`n" + } + + # Generate git safe.directory commands directly + foreach ($dir in $GitDirectories) + { + if ($dir) + { + # Git compares safe.directory against the repository path exactly, and the path it reports has + # no trailing separator: registering only the trailing-slash form leaves the exception unmatched + # and the repository still refused as dubiously owned. Register both forms. + $normalizedDir = ($dir -replace '\\', '/').TrimEnd('/') + $gitConfigCommands += "git config --global --add safe.directory '$normalizedDir'`n" + $gitConfigCommands += "git config --global --add safe.directory '$normalizedDir/'`n" + } + } + + # Generate PostInit script call if specified + $postInitCommands = "" + if ($PostInit -and $ContainerPostInit) + { + $escapedPostInit = $ContainerPostInit -replace "'", "''" + $postInitCommands = "`n# Execute PostInit script`n" + $postInitCommands += "Write-Host `"Executing PostInit script: $ContainerPostInit`" -ForegroundColor Cyan`n" + $postInitCommands += "& '$escapedPostInit'`n" + $postInitCommands += "`$postInitExitCode = `$LASTEXITCODE`n" + $postInitCommands += "if (`$postInitExitCode -and `$postInitExitCode -ne 0) { Write-Host `"PostInit script failed with exit code `$postInitExitCode.`" -ForegroundColor Red; exit `$postInitExitCode }`n" + } + + $initScriptContent = @" +# Auto-generated initialization script for container startup + +$envVarAssignments$gitConfigCommands$postInitCommands +"@ + + # Write a test file with GUID first to check git tracking + @" +# Test file - checking git tracking +# GUID: $([System.Guid]::NewGuid().ToString() ) +"@ | Set-Content -Path $initScript -Encoding UTF8 + + # Check if Init.g.ps1 is tracked by git + $gitStatus = git status --porcelain $initScript 2> $null + if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($gitStatus)) + { + Write-Error "Init script '$initScript' is tracked by git. Please add '$gDirectory' to .gitignore first." + exit 1 + } + + $initScriptContent | Set-Content -Path $initScript -Encoding UTF8 + } + + # The cache-buster is staged by Build-OneImage, into the context of each image that actually declares the + # `COPY .g/update.timestamp`. Doing it there rather than here is what makes it prefix-agnostic: the + # destination is derived from the Dockerfile being built instead of from a hardcoded "claude" directory. + + # Path separator depends on platform (and container OS) + $pathSeparator = if ($IsUnix) + { + ":" + } + else + { + ";" + } + $mountPointsAsString = $MountPoints -Join $pathSeparator + $gitDirectoriesAsString = $GitDirectories -Join $pathSeparator + + Write-Host "Volume mappings: " @VolumeMappings -ForegroundColor Gray + Write-Host "Mount points: " $mountPointsAsString -ForegroundColor Gray + Write-Host "Git directories: " $gitDirectoriesAsString -ForegroundColor Gray + + # Check if a container is already running with this image (only for interactive scenarios) + $existingContainerId = $null + + if ($Interactive) + { + # Check for existing container + $existingContainerId = docker ps -q --filter "ancestor=$ImageTag" | Select-Object -First 1 + if ($existingContainerId) + { + Write-Host "Found existing container $existingContainerId running with image $ImageTag" -ForegroundColor Cyan + Write-Host "Will reuse existing container instead of starting a new one." -ForegroundColor Cyan + $ImageTag = $searchImageTag + } + else + { + Write-Host "No existing container for $ImageTag." + } + } + + # Free image disk space before the chain is resolved, so that what is freed is available to the builds and + # pulls below rather than only to the next run. Everything the cleanup does is local to the Docker engine, + # so it runs before the registry login and needs no credentials. + # + # Skipped when an existing container is reused: nothing is built or pulled then, so there is no space to + # make room for, and the image of that container must stay untouched. + if ($maxImageSpaceBytes -gt 0 -and -not $existingContainerId) + { + Invoke-ImageSpaceCleanup $maxImageSpaceBytes (Get-ImageChainKeepSet) + } + + # Registry authentication + image-chain resolution (build the ancestor chain; pull-or-build+push each level). + $builtNewImage = $false + $dockerConfigArg = @() + + # $RegistryImage is an explicit user override ("use this pre-built image, skip all Dockerfile logic"), so the + # chain is neither authenticated nor resolved for it. Every other path (build step, default run, and the CI run + # step with -NoBuildImage) resolves the chain so the local-only Claude leaf is guaranteed present below. + if (-not $existingContainerId -and -not $RegistryImage) + { + if ($dockerRegistry) + { + # Temporary Docker config dir to avoid credential-helper issues (e.g. docker-credential-desktop not + # found when using Docker Engine without Desktop), then authenticate. + # + # Authentication runs for BOTH the build step (-BuildImage) and the run step (-NoBuildImage). The run + # step still resolves the chain below, and when it executes on a different docker daemon than the build + # step it must PULL the stable ancestors (vs/build) from the registry - so credentials must be present + # here too. (The Claude leaf is never pulled; it is always built locally - see Ensure-Image.) + $tempDockerConfig = Join-Path ([System.IO.Path]::GetTempPath()) "docker-config-$( New-Guid )" + New-Item -ItemType Directory -Path $tempDockerConfig -Force | Out-Null + @{ auths = @{ } } | ConvertTo-Json | Set-Content (Join-Path $tempDockerConfig "config.json") + $dockerConfigArg = @("--config", $tempDockerConfig) + + $dockerPassword = $env:DOCKER_PASSWORD + $dockerUsername = $env:DOCKER_USERNAME + + # Setting DOCKER_REGISTRY without credentials is a configuration error, not a request for anonymous + # access: continuing would silently rebuild every ancestor locally (no pull) and then fail the pushes + # at the end of the build anyway, after a long and misleading build. + $missingCredentials = @() + if (-not $dockerUsername) { $missingCredentials += "DOCKER_USERNAME" } + if (-not $dockerPassword) { $missingCredentials += "DOCKER_PASSWORD" } + if ($missingCredentials) + { + Write-Error "DOCKER_REGISTRY is set to '$dockerRegistry' but $( $missingCredentials -join " and " ) $( if ($missingCredentials.Count -eq 1) { "is" } else { "are" } ) not set. Set the missing variable(s), or unset DOCKER_REGISTRY to build without a registry." + exit 1 + } + + Write-Host "Authenticating to registry..." -ForegroundColor Gray + $loginOutput = $dockerPassword | docker @dockerConfigArg login $dockerRegistry --username $dockerUsername --password-stdin 2>&1 + if ($LASTEXITCODE -ne 0) + { + Write-Error "Registry authentication to '$dockerRegistry' failed as user '$dockerUsername': $( $loginOutput -join [System.Environment]::NewLine )" + exit 1 + } + } + + # Resolve the whole chain (parent first): use local, else pull ancestors, else build; freshly built layers + # are queued for push. This runs in the run step (-NoBuildImage) too: Ensure-Image is idempotent (it is a + # no-op for images already present locally), so when the build step shared this daemon nothing is rebuilt. + # Its purpose here is to guarantee the local-only Claude leaf exists before the boot image's FROM resolves + # it - the leaf is never pushed, so it cannot be pulled and MUST be (re)built locally in the run step. + Ensure-Image $dockerfileFullPath | Out-Null + } + elseif ($existingContainerId) + { + Write-Host "Skipping image build (reusing existing container $existingContainerId)." -ForegroundColor Yellow + } + else + { + Write-Host "Skipping image build (using pre-built registry image $ImageTag)." -ForegroundColor Yellow + } + + # Build the local boot image over the resolved chain image (creates the bind-mount directories). The static + # chain images stay pure and shareable; this thin layer carries the machine-specific mount set and is never + # pushed. `docker run` below uses the boot image. Its `FROM` resolves the chain leaf locally: Ensure-Image + # above guarantees the leaf is present (the Claude leaf is built locally, ancestors are local-or-pulled), so + # the boot build never pulls and needs no registry credentials (same as the chain builds in Build-OneImage). + if (-not $BuildImage -and -not $existingContainerId -and $mountPointsAsString) + { + $ImageTag = New-BootImage $ImageTag + } + + # Run the build within the container + if (-not $BuildImage) + { + # Common setup for both Claude and normal build modes + $pwshPath = if ($IsUnix) + { + '/usr/bin/pwsh' + } + else + { + 'C:\Program Files\PowerShell\7\pwsh.exe' + } + # Init.g.ps1 is in the mounted source directory, not baked into the image + # Init.g.ps1 is in $EngPath/.g/ (outside docker-context), accessed via mounted source directory + $containerInitScript = "$ContainerSourceDir/$EngPath/.g/Init.g.ps1" + $initCall = if (-not $NoInit) + { + "& '$containerInitScript'; " + } + else + { + "" + } + + # Convert volume mappings to docker args format (interleave "-v" flags) + $volumeArgs = @() + foreach ($mapping in $VolumeMappings) + { + $volumeArgs += @("-v", $mapping) + } + + if ($Test) + { + Write-Host "Running the test command in the container." -ForegroundColor Green + + # The command runs through the container's own shell rather than through pwsh, because a test image + # is chosen for the tool chain under test and is not required to carry PowerShell 7. + $testCommandArgs = if ($IsUnix) + { + @('sh', '-c', $Command) + } + else + { + @('cmd', '/S', '/C', $Command) + } + + $pwshArgs = $null + $dockerArgs = @() + $inlineScript = $null + $needsMcpCleanup = $false + + # The same environment a build container gets, as -e arguments rather than through Init.g.ps1: see + # where $NoInit is set for why that script cannot be invoked in a test image. What -Env named is + # already folded into this set by New-EnvHashtable, so an explicitly named variable is here too. + $envArgs = @() + + if ($script:ContainerEnvironmentVariables) + { + foreach ($key in $script:ContainerEnvironmentVariables.Keys | Sort-Object) + { + $envArgs += @('-e', "$key=$( $script:ContainerEnvironmentVariables[$key] )") + } + } + } + elseif ($Claude) + { + # MCP server configuration + $mcpPort = $null + if (-not $NoMcp -and $mcpServerAvailable) + { + $mcpPort = $mcpFixedPort + } + elseif (-not $NoMcp) + { + Write-Host "Skipping MCP (server not running)." -ForegroundColor Yellow + } + else + { + Write-Host "Skipping MCP approval server (-NoMcp specified)." -ForegroundColor Yellow + } + + # Run Claude mode + Write-Host "Running Claude in the container." -ForegroundColor Green + + # Container will have its own Claude profile (no mount, no copy from host) + $hostUserProfile = if ($IsUnix) + { + $env:HOME + } + else + { + $env:USERPROFILE + } + + # Mount Claude sessions directory to preserve history (but not plugins) + $hostClaudeSessions = Join-Path $hostUserProfile ".claude\.sessions" + $containerClaudeSessions = Join-Path $containerUserProfile ".claude\.sessions" + if (-not (Test-Path $hostClaudeSessions)) + { + New-Item -ItemType Directory -Path $hostClaudeSessions -Force | Out-Null + } + $volumeArgs += @("-v", "${hostClaudeSessions}:${containerClaudeSessions}") + Write-Host "Mounting Claude sessions directory: $hostClaudeSessions" -ForegroundColor Cyan + + # Mount Claude projects directory to share session history between container instances + $hostClaudeProjects = Join-Path $hostUserProfile ".claude\projects" + $containerClaudeProjects = Join-Path $containerUserProfile ".claude\projects" + if (-not (Test-Path $hostClaudeProjects)) + { + New-Item -ItemType Directory -Path $hostClaudeProjects -Force | Out-Null + } + $volumeArgs += @("-v", "${hostClaudeProjects}:${containerClaudeProjects}") + Write-Host "Mounting Claude projects directory: $hostClaudeProjects" -ForegroundColor Cyan + + # Extract Claude prompt from remaining arguments if present + # Usage: -Claude for interactive, -Claude "prompt" for non-interactive + $ClaudePrompt = $null + if ($BuildArgs -and $BuildArgs.Count -gt 0 -and $BuildArgs[0] -and -not $BuildArgs[0].StartsWith('-')) + { + $ClaudePrompt = $BuildArgs[0] + } + + # Build inline script: subst drives, copy claude.json, cd to source, run Claude + if ($ClaudePrompt) + { + # Non-interactive mode with prompt - no -it flags + $dockerArgs = @() + $mcpArg = if ($mcpPort) + { + " -McpPort $mcpPort" + } + else + { + "" + } + $inlineScript = "${substCommandsInline}${initCall}cd '$SourceDirName'; & .\eng\RunClaude.ps1 -Prompt `"$ClaudePrompt`"$mcpArg" + } + else + { + # Interactive mode - requires TTY + $dockerArgs = @("-it") + $mcpArg = if ($mcpPort) + { + " -McpPort $mcpPort" + } + else + { + "" + } + $inlineScript = "${substCommandsInline}${initCall}cd '$SourceDirName'; & .\eng\RunClaude.ps1$mcpArg" + } + + # Environment variables to pass to container + # No MCP secret needed - server binds to localhost only + $envArgs = @() + + # No pwshArgs for Claude mode + $pwshArgs = $null + # No MCP cleanup needed - server runs independently + $needsMcpCleanup = $false + } + else + { + # Run standard build mode + # Delete now and not in the container because it's much faster and lock error messages are more relevant. + Write-Host "Running the script in the container." -ForegroundColor Green + + # Prepare Build.ps1 arguments + if ($StartVsmon) + { + $BuildArgs = @("-StartVsmon") + $BuildArgs + } + + if ($Interactive) + { + $pwshArgs = "-NoExit" + $BuildArgs = @("-Interactive") + $BuildArgs + $dockerArgs = @("-it") + $pwshExitCommand = "" + } + else + { + $pwshArgs = "-NonInteractive" + $dockerArgs = @() + $pwshExitCommand = "exit `$LASTEXITCODE`;" + } + + $buildArgsString = $BuildArgs -join " " + + # Build inline script: subst drives, run init, cd to source, run build + # Get full script path (combine with container source dir if relative) + if ( [System.IO.Path]::IsPathRooted($Script)) + { + $scriptFullPath = $Script + } + else + { + $scriptFullPath = Join-Path $ContainerSourceDir $Script + } + # Fail if the script is not there. In -Command mode `& ` is a non-terminating error and + # leaves $LASTEXITCODE at 0, so without this guard the container exits 0 and the build is reported + # successful having run nothing at all. That is what an unmounted workspace looks like: the bind + # mount silently yields an empty directory when the engine is not allowed to share the host path. + $missingScriptMessage = "The script $scriptFullPath is not present in the container. The workspace " + + "is most likely not mounted -- check that the agent work directory is a path the container " + + "engine is allowed to share." + + $scriptInvocation = "if ( -not ( Test-Path -LiteralPath '$scriptFullPath' ) ) " + + "{ Write-Host '$missingScriptMessage' -ForegroundColor Red; exit 127 }; & '$scriptFullPath'" + $inlineScript = "${substCommandsInline}${initCall}cd '$SourceDirName'; $scriptInvocation $buildArgsString; $pwshExitCommand" + + # No environment args for normal build + $envArgs = @() + $needsMcpCleanup = $false + } + + # Common docker execution for both modes + $dockerArgsAsString = $dockerArgs -join " " + + # Execute docker command + if ($existingContainerId) + { + # Reuse existing container with docker exec + Write-Host "Executing: ``docker exec $existingContainerId $dockerArgsAsString -w $ContainerCallingDir $ImageTag `"$pwshPath`" $pwshArgs -Command `"$inlineScript`"" -ForegroundColor Cyan + docker exec $dockerArgs -w $ContainerCallingDir $existingContainerId $pwshPath $pwshArgs -Command $inlineScript + } + else + { + # Start new container with docker run + # Build docker command with proper argument handling (avoid empty strings) + $dockerCmd = @('run', '--rm') + + # Memory limit: everywhere except Windows process isolation, which ignores it. + if ($supportsResourceLimits -and $Memory) + { + $dockerCmd += "--memory=$Memory" + } + + # The MSBuild node count that matches that budget. msbuild.ps1 reads it inside the container, where the + # limit itself is not visible. + if ($maxBuildParallelism -gt 0) + { + $dockerCmd += @('-e', "MAX_BUILD_PARALLELISM=$maxBuildParallelism") + } + + # CPU limit: dynamic or static + if ($isDynamicCpus) + { + $dynamicAllocation = Invoke-DynamicCpuRebalance -AdditionalContainers 1 + $dockerCmd += "--cpus=$dynamicAllocation" + $dockerCmd += @('-e', "DOTNET_PROCESSOR_COUNT=$dynamicAllocation") + $dockerCmd += @('--label', "$DynamicCpuLabel") + } + elseif ($supportsResourceLimits) + { + $dockerCmd += "--cpus=$Cpus" + } + + if ($isolationArg) + { + $dockerCmd += $isolationArg + } + $dockerCmd += $dockerArgs + $dockerCmd += $volumeArgs + $dockerCmd += $envArgs + + # A test command runs with the mounted repository as its working directory, so that a test addresses + # what it needs by a path relative to the repository root rather than by one it has to compute. + if ($Test) + { + $dockerCmd += @('-w', $ContainerSourceDir) + } + + # Add port mappings from -Ports parameter + if ($Ports -and $Ports.Count -gt 0) + { + foreach ($portMapping in $Ports) + { + $dockerCmd += @('-p', $portMapping) + } + } + + # Add label for container identification (used for cleanup of orphaned containers) + if ($Label) + { + $dockerCmd += @('--label', "postsharp.build=$Label") + } + + if ($Test) + { + # The label the launcher removes by, when a test overruns its timeout. The container outlives the + # process that started it, so without this a timed-out test leaves it running on the agent. + if ($env:POSTSHARP_DOCKER_TEST_RUN_ID) + { + $dockerCmd += @('--label', "postsharp.test-run=$( $env:POSTSHARP_DOCKER_TEST_RUN_ID )") + } + + # The working directory was set above, to the mounted repository. + $dockerCmd += @($ImageTag) + $testCommandArgs + } + elseif ($pwshArgs) + { + $dockerCmd += @('-w', $ContainerCallingDir, $ImageTag, $pwshPath, $pwshArgs, '-Command', $inlineScript) + } + else + { + $dockerCmd += @('-w', $ContainerCallingDir, $ImageTag, $pwshPath, '-Command', $inlineScript) + } + + Write-Host "Executing: ``docker $( $dockerCmd -join ' ' )" -ForegroundColor Cyan + & docker @dockerCmd + } + $dockerExitCode = $LASTEXITCODE + + # Post-exit rebalance: when our container exits (--rm removes it), + # redistribute CPUs to remaining managed containers + if ($isDynamicCpus -and -not $existingContainerId) + { + Invoke-DynamicCpuRebalance -AdditionalContainers 0 | Out-Null + } + + # Check exit code + if ($dockerExitCode -ne 0) + { + Write-Host "Container failed with exit code $dockerExitCode" -ForegroundColor Red + } + } + else + { + Write-Host "Skipping container run (BuildImage specified)." -ForegroundColor Yellow + } + + Wait-ForRegistryPushes + + # Stop timing and display results + $elapsed = $stopwatch.Elapsed + Write-Host "" + Write-Host "Total build time: $($elapsed.ToString('hh\:mm\:ss\.fff') )" -ForegroundColor Cyan + Write-Host "Build completed at: $( Get-Date -Format 'yyyy-MM-dd HH:mm:ss' )" -ForegroundColor Cyan + + # The container's own exit code wins when both failed: it says more about what went wrong than the push does. + # $dockerExitCode is $null when the container was not run at all (-BuildImage). + if ($dockerExitCode) + { + $script:ExitCode = $dockerExitCode + } + elseif ($script:PushFailed) + { + Write-Host "The build failed because at least one registry push failed." -ForegroundColor Red + $script:ExitCode = 1 + } + + exit $script:ExitCode +} +finally +{ + # Never abandon a push that is still in flight. On the normal path the jobs have already been waited for + # (and the list emptied), so this only does something when a later step failed or was interrupted - exactly + # when the push would otherwise die with the process and leave the image out of the registry. + if ($script:RegistryPushJobs.Count -gt 0) + { + Wait-ForRegistryPushes + } + + # Safety-net rebalance on Ctrl+C or unexpected exit + if ($isDynamicCpus) + { + try { Invoke-DynamicCpuRebalance -AdditionalContainers 0 | Out-Null } catch { } + } + + # Remove the run-specific boot image. Its tag is unique per run (timestamp suffix), so leaving it behind + # would accumulate dangling leaf images. Running here (not after the run) guarantees removal even when the + # build throws or the user presses Ctrl+C - PowerShell still executes finally on a pipeline interrupt. The + # `docker run --rm` removes the container, so the image is normally unreferenced; `-f` untags it even if a + # stopped container still references it. Best-effort: a removal failure must not mask the build's exit code. + if ($script:BootImageTag) + { + Write-Host "Removing boot image $($script:BootImageTag)" -ForegroundColor Gray + docker image rm -f $script:BootImageTag *> $null + } + + # Restore original location + Pop-Location + + # A failed push must fail the build even when the script is unwinding from another failure. A non-zero + # $script:ExitCode means the script already decided to fail, and that reason is the more informative one. + if ($script:PushFailed -and $script:ExitCode -eq 0) + { + Write-Host "The build failed because at least one registry push failed." -ForegroundColor Red + exit 1 + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..587f8b9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# Runs the SharpCrafters Backstage License Server as a Linux container. +# +# The image carries the contents of the release archive, so it runs exactly what is released rather +# than a second build of the same sources. Build the product first: +# +# ./Build.ps1 build +# docker compose up +# +# See docker-compose.yml for a deployment with a SQL Server database, and docs/docker.md for what to +# change before running it for real. +# +# The build is not done inside the image on purpose. The licensing component comes from a private +# feed and the build needs the generated global.json and nuget.config that `./Build.ps1 prepare` +# writes, none of which belongs in a container that a customer may build. + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app + +RUN mkdir -p /app/App_Data && useradd --uid 64198 --create-home licenseserver \ + && chown -R licenseserver /app +USER licenseserver + +# Written by the PackAndZip target of the web project; it is what the release archive contains. +COPY --chown=licenseserver artifacts/app/ ./ + +# The server generates the audit signing key here on first start, and the audit log signature chain +# restarts if it is lost. Declaring the volume means a container started without an explicit mount +# still keeps the key somewhere outside its own writable layer, rather than losing it silently when +# the container is replaced. Name the volume in production: an anonymous one is easy to prune by +# accident. LicenseServer:DataDirectory moves the directory elsewhere. +VOLUME ["/app/App_Data"] + +ENV ASPNETCORE_HTTP_PORTS=8080 +EXPOSE 8080 + +ENTRYPOINT ["dotnet", "SharpCrafters.Backstage.LicenseServer.dll"] diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..6886398 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,18 @@ + + +# MIT License + +Copyright (c) 2026 SharpCrafters s.r.o. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/PostSharp.LicenseServer.sln b/PostSharp.LicenseServer.sln deleted file mode 100644 index d928456..0000000 --- a/PostSharp.LicenseServer.sln +++ /dev/null @@ -1,63 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.30523.141 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostSharp.LicenseServer.Test", "test\PostSharp.LicenseServer.Test\PostSharp.LicenseServer.Test.csproj", "{6A706191-B0A4-4B9A-A597-7A1DAF32EA50}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostSharp.LicenseServer", "src\PostSharp.LicenseServer\PostSharp.LicenseServer.csproj", "{3B511E09-1CFD-43EB-978F-70FA3DFEC83B}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|Mixed Platforms = Debug|Mixed Platforms - Debug|x86 = Debug|x86 - Documentation|Any CPU = Documentation|Any CPU - Documentation|Mixed Platforms = Documentation|Mixed Platforms - Documentation|x86 = Documentation|x86 - Release|Any CPU = Release|Any CPU - Release|Mixed Platforms = Release|Mixed Platforms - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {6A706191-B0A4-4B9A-A597-7A1DAF32EA50}.Debug|Any CPU.ActiveCfg = Debug|x86 - {6A706191-B0A4-4B9A-A597-7A1DAF32EA50}.Debug|Mixed Platforms.ActiveCfg = Debug|x86 - {6A706191-B0A4-4B9A-A597-7A1DAF32EA50}.Debug|Mixed Platforms.Build.0 = Debug|x86 - {6A706191-B0A4-4B9A-A597-7A1DAF32EA50}.Debug|x86.ActiveCfg = Debug|x86 - {6A706191-B0A4-4B9A-A597-7A1DAF32EA50}.Debug|x86.Build.0 = Debug|x86 - {6A706191-B0A4-4B9A-A597-7A1DAF32EA50}.Documentation|Any CPU.ActiveCfg = Documentation|x86 - {6A706191-B0A4-4B9A-A597-7A1DAF32EA50}.Documentation|Mixed Platforms.ActiveCfg = Documentation|x86 - {6A706191-B0A4-4B9A-A597-7A1DAF32EA50}.Documentation|Mixed Platforms.Build.0 = Documentation|x86 - {6A706191-B0A4-4B9A-A597-7A1DAF32EA50}.Documentation|x86.ActiveCfg = Documentation|x86 - {6A706191-B0A4-4B9A-A597-7A1DAF32EA50}.Documentation|x86.Build.0 = Documentation|x86 - {6A706191-B0A4-4B9A-A597-7A1DAF32EA50}.Release|Any CPU.ActiveCfg = Release|x86 - {6A706191-B0A4-4B9A-A597-7A1DAF32EA50}.Release|Mixed Platforms.ActiveCfg = Release|x86 - {6A706191-B0A4-4B9A-A597-7A1DAF32EA50}.Release|Mixed Platforms.Build.0 = Release|x86 - {6A706191-B0A4-4B9A-A597-7A1DAF32EA50}.Release|x86.ActiveCfg = Release|x86 - {6A706191-B0A4-4B9A-A597-7A1DAF32EA50}.Release|x86.Build.0 = Release|x86 - {3B511E09-1CFD-43EB-978F-70FA3DFEC83B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3B511E09-1CFD-43EB-978F-70FA3DFEC83B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3B511E09-1CFD-43EB-978F-70FA3DFEC83B}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU - {3B511E09-1CFD-43EB-978F-70FA3DFEC83B}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU - {3B511E09-1CFD-43EB-978F-70FA3DFEC83B}.Debug|x86.ActiveCfg = Debug|Any CPU - {3B511E09-1CFD-43EB-978F-70FA3DFEC83B}.Debug|x86.Build.0 = Debug|Any CPU - {3B511E09-1CFD-43EB-978F-70FA3DFEC83B}.Documentation|Any CPU.ActiveCfg = Documentation|Any CPU - {3B511E09-1CFD-43EB-978F-70FA3DFEC83B}.Documentation|Any CPU.Build.0 = Documentation|Any CPU - {3B511E09-1CFD-43EB-978F-70FA3DFEC83B}.Documentation|Mixed Platforms.ActiveCfg = Documentation|Any CPU - {3B511E09-1CFD-43EB-978F-70FA3DFEC83B}.Documentation|Mixed Platforms.Build.0 = Documentation|Any CPU - {3B511E09-1CFD-43EB-978F-70FA3DFEC83B}.Documentation|x86.ActiveCfg = Documentation|Any CPU - {3B511E09-1CFD-43EB-978F-70FA3DFEC83B}.Documentation|x86.Build.0 = Documentation|Any CPU - {3B511E09-1CFD-43EB-978F-70FA3DFEC83B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3B511E09-1CFD-43EB-978F-70FA3DFEC83B}.Release|Any CPU.Build.0 = Release|Any CPU - {3B511E09-1CFD-43EB-978F-70FA3DFEC83B}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU - {3B511E09-1CFD-43EB-978F-70FA3DFEC83B}.Release|Mixed Platforms.Build.0 = Release|Any CPU - {3B511E09-1CFD-43EB-978F-70FA3DFEC83B}.Release|x86.ActiveCfg = Release|Any CPU - {3B511E09-1CFD-43EB-978F-70FA3DFEC83B}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {A33D862C-49E9-4001-BD4B-268BD053EE77} - EndGlobalSection -EndGlobal diff --git a/PostSharp.LicenseServer.sln.DotSettings.user b/PostSharp.LicenseServer.sln.DotSettings.user deleted file mode 100644 index 1f8a406..0000000 --- a/PostSharp.LicenseServer.sln.DotSettings.user +++ /dev/null @@ -1,3 +0,0 @@ - - ForceIncluded - ForceIncluded \ No newline at end of file diff --git a/README.md b/README.md index 26f3c1a..d4bdbf4 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,272 @@ -# PostSharp.LicenseServer +# SharpCrafters.Backstage.LicenseServer -This repository contains the source code and releases of PostSharp License Server. +This repository contains the source code and the releases of the license server of PostSharp and +Metalama. The server serves the license keys of both product families. The license keys that its +administrator adds to it decide which products it serves. -The use of the license server is optional. Since all commercial licenses are floating ones, -the license server can help teams knowing how many licenses they actually use. +The license server is optional. All commercial licenses are floating licenses, and the license server +reports how many of them a team uses. -The license server is a classic ASP.NET application with an MS SQL back-end. +The license server is an ASP.NET Core application with a SQL Server or a PostgreSQL database. It runs +on Windows behind IIS, and on Linux and macOS in its own process or in a container. -We at PostSharp consider that it is the customer's sole responsibility to respect the license agreement, and this is why we are providing the source code of the license server. Note that the use of licenses keys [is audited anyway](http://doc.postsharp.net/license-audit); if this is not an option for your organization, you can ask the PostSharp sales team for a license key with audit waiver. +Respecting the license agreement is the responsibility of the customer. This is the reason why we +publish the source code of the license server. The use of license keys +[is audited in any case](http://doc.postsharp.net/license-audit). If the audit is not acceptable for +your organization, ask the PostSharp sales team for a license key that waives it. ## License -The license server itself is licensed under the *MIT License*. Note that PostSharp is a commercial product with a proprietary license. +The license server is published under the MIT License, which [LICENSE.md](LICENSE.md) states in full. +PostSharp itself is a commercial product with a proprietary license. ## Download -You can download the latest release from https://github.com/postsharp/PostSharp.LicenseServer/releases/latest. +Download the latest release from +https://github.com/postsharp-ops/SharpCrafters.Backstage.LicenseServer/releases/latest. ## Documentation * [Installing the license server](http://doc.postsharp.net/license-server-admin). * [Using the license server](http://doc.postsharp.net/license-server). +* [Configuring the license server](docs/configuration.md). +* [Running the license server in a container](docs/docker.md). +* [The license server protocol](docs/protocol.md), for the developer who maintains a client or + diagnoses a deployment. + +## Trying it out + +On a machine that runs Docker, the container deployment is the shortest way to see the license server +work. It starts the server, a SQL Server database, and a job that creates the schema: + +``` +export MSSQL_SA_PASSWORD='...' +./Build.ps1 build +docker compose up +``` + +The build runs first because the image contains the files of the release archive, so the container +runs what is released. + +Open http://localhost:8080 and add your license key. If you have no license key, the test override +makes the server issue to itself the license keys it serves: + +``` +docker compose -f docker-compose.yml -f docker-compose.test.yml up +``` + +[docs/docker.md](docs/docker.md) describes what this override changes, and what to configure before +you use the container for anything else than an evaluation. + +## Installing on IIS + +### Requirements + +* Windows Server with IIS. +* The [ASP.NET Core Hosting Bundle](https://dotnet.microsoft.com/download/dotnet/10.0) for .NET 10. +* SQL Server 2016 or later, or PostgreSQL 14 or later. + +### Instructions + +1. Install the ASP.NET Core Hosting Bundle on the web server, then restart IIS with `iisreset`. +2. Create the database, then run `Database\CreateTables.sql` against it. On PostgreSQL, run + `Database\CreateTables.PostgreSql.sql` instead and set `LicenseServer:DatabaseProvider` to + `PostgreSql`. +3. Unpack `SharpCrafters.Backstage.LicenseServer..zip` into the directory of an IIS + application. +4. Edit `appsettings.json`: set the connection string, the addresses for notifications and the SMTP + server. [docs/configuration.md](docs/configuration.md) describes every setting. +5. In IIS Manager, enable Windows authentication on the application to record which account sends + each lease request. Lease requests are served anonymously in both cases. +6. Restrict the administrative pages. They are open in the released configuration, and restricting + them is your responsibility. See + [Securing the administrative pages](docs/configuration.md#securing-the-administrative-pages). +7. Open the application in a browser and add your license key. + +## Installing elsewhere + +The release package is portable. The same archive runs on every system that runs the .NET 10 runtime. + +1. Install the [.NET 10 runtime](https://dotnet.microsoft.com/download/dotnet/10.0) + (`aspnetcore-runtime-10.0`). +2. Create the database, then run `Database/CreateTables.sql` against it. +3. Unpack the archive, edit `appsettings.json`, and run the application: + + ``` + dotnet SharpCrafters.Backstage.LicenseServer.dll + ``` + +Two settings usually need another value outside Windows. The connection string cannot use +`Integrated Security=True` unless the host is joined to the domain, so use a SQL Server login or a +managed identity. Windows authentication requires Kerberos, so either join the host to the domain and +set `Authentication:Scheme` to `Negotiate`, or set it to `None` and accept that the leases record no +authenticated caller. [docs/configuration.md](docs/configuration.md) describes both settings. + +## Upgrading from version 2025.1 or earlier + +Earlier versions ran on .NET Framework and ASP.NET WebForms. The database schema has not changed, so +an existing database is used as it is, and there is no migration step. Four points require attention. + +* The ASP.NET Core Hosting Bundle is now required. This is the only new prerequisite. +* The settings have moved from `Web.config` to `appsettings.json`. Their names have not changed, so + you can copy the values one by one. See [docs/configuration.md](docs/configuration.md). +* Connection strings now need `Encrypt=False`, unless your SQL Server presents a certificate that the + web server trusts, because the current SQL client encrypts the connection by default. A connection + string that worked before the upgrade can otherwise fail with an error about the certificate. +* The timestamps of the exported audit log are now correct on a server that does not run in UTC. + Earlier versions wrote the local time as if it were UTC, which shifted the values by the offset of + the server. The exports produced after the upgrade therefore differ from the earlier ones by that + offset. + +License keys are now parsed by SharpCrafters.Backstage instead of the PostSharp SDK. This changes two +points in the interpretation of a license key. + +* A license key that carries no grace period now receives thirty days instead of none. Such a license + used to deny a request as soon as the capacity was exceeded. It now serves requests during thirty + days beyond the capacity, and the server sends the warning e-mail as usual. A license key issued + with an explicit grace period is not affected, and the capacity during the grace period does not + change. +* Products are recorded under a new name. A license added after the upgrade is stored as + `PostSharpUltimate` where it used to be stored as `Ultimate`. The existing rows are not modified, + and a request that names either spelling finds both, so there is nothing to migrate. + +Two changes concern the exported audit log. + +* A line now has seven fields instead of eight. The eighth field contained a signature of the line, + and that signature could not be verified: the server generated a random key at every call, so no + two lines were signed with the same key. The server no longer writes that field, and it no longer + writes the `HMAC` column of the `Leases` table. The column is left in place, and the values already + written are left as they are. +* The sixth field and the seventh field contain the machine name and the user name, where they used + to contain a hash of each. The log is read by the administrator of the server, who needs to know + which user and which machine hold a seat. The exported file therefore contains personal data. + Handle it as you handle the database, which has always contained the same names. ## Building from source ### Requirements -* Visual Studio 2015 with Web Tools. +* The [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0). +* PowerShell 7.4 or later. +* Access to the package feed that contains `SharpCrafters.Backstage`, the licensing component. ### Instructions -1. Open a Developer Command Prompt and go to repository directory. -2. Restore NuGet packages with the command: `.nuget\nuget.exe restore`. -3. Go to directory `src\PostSharp.LicenseServer`. -4. Execute `msbuild PostSharp.LicenseServer.csproj /t:Zip`. +The repository is built with +[PostSharp.Engineering](https://github.com/postsharp/PostSharp.Engineering), as the +`Backstage.LicenseServer` product of the Backstage 2027.0 family. + +``` +./Build.ps1 prepare +./Build.ps1 test +``` + +`prepare` resolves the dependencies and writes `global.json`, `nuget.config` and the version files. +None of these files is in source control. `build` and `test` run `prepare` themselves, so you need it +only before you open the solution in an IDE, or before you run `dotnet` directly. + +`./Build.ps1 build` writes the release archive to `artifacts\publish\private`, and unpacks its +contents into `artifacts\app`, which is the directory the container image is built from. A public +build also copies the archive to `artifacts\publish\public`, the directory the deployment uploads. + +To build against a local clone of the licensing component instead of the published packages, point +the dependency at it, and build that repository first: + +``` +./Build.ps1 dependencies set local Backstage --path +``` + +### The code style + +`eng/style` holds the shared code style of PostSharp.Engineering: `.editorconfig`, +`CommonStyle.DotSettings` for the JetBrains tools, and `stylecop.json`. The `.editorconfig` at the +root of the repository is a symbolic link to `eng/style/.editorconfig`, so a clone needs +`core.symlinks = true`. Refresh the style with `./Build.ps1 codestyle pull`. + +`SharpCrafters.Backstage.LicenseServer.slnx.DotSettings` carries the team-shared layer of the +solution, which names `eng/style/CommonStyle.DotSettings`. In Rider that layer is what +"Manage Layers" edits, so no manual step is left there. + +Reformat the code with the command below. `./Build.ps1 codestyle format` does the same thing, but it +fails against this repository: it relies on the settings layer of the solution to find the cleanup +profile, and the JetBrains command line tools do not read that layer for a solution in the `.slnx` +format. They report "Unable to find the code cleanup profile with 'Custom' name". Naming the settings +file works: + +``` +dotnet jb cleanupcode --profile:Custom --settings=eng/style/CommonStyle.DotSettings ` + --disable-settings-layers:"GlobalAll;GlobalPerProduct;SolutionPersonal;ProjectPersonal" ` + SharpCrafters.Backstage.LicenseServer.slnx +``` + +Commit your work before you reformat, so that the reformatting is a commit of its own. + +### Running the tests + +`./Build.ps1 test` runs the suite on SQLite, which needs no server and keeps the loop short. The same +tests run again against each engine that customers deploy, each in a container of its own. The +engines differ in the collation, in the column types and in the lock that serializes the lease +requests, so a defect can appear on one of them alone. Run them before you open a pull request: + +``` +pwsh ./eng/RunDockerTests.ps1 +pwsh ./eng/RunDockerTests.ps1 -Test PostgreSql +pwsh ./tests/docker/PostgreSql/RunTest.ps1 -Platform linux-x64 +``` + +The first command runs every test that the container engine of this host can run. The second runs one +of them, reported the way the continuous integration build reports it. The third runs the same test +without the launcher, which is the shortest path while a test is being written. + +The host needs PowerShell 7.5 and a container engine, and nothing else: the .NET SDK and the database +server live in the image of the test. On Windows, Linux containers run on the engine inside the +Windows Subsystem for Linux, and the scripts reach it themselves. + +Each test is a directory under `tests/docker`: `test.psd1` names the platforms the test applies to, +`Dockerfile` installs the server and the SDK, `RunTest.ps1` starts the container through +`DockerBuild.ps1 -Test`, and `run.sh` starts the server inside the container and runs `dotnet test`. +The continuous integration build runs them in the configuration `Docker Tests (Linux x64)`. See +[the harness](https://github.com/postsharp-ops/PostSharp.Engineering/blob/HEAD/doc/docker-tests.md). + +To run the suite against a server of your own instead, name it and run the tests directly: + +``` +$env:LICENSESERVER_TEST_SQLSERVER = "Server=127.0.0.1,1433;User Id=sa;Password=;TrustServerCertificate=True;Encrypt=False" +$env:LICENSESERVER_TEST_POSTGRESQL = "Host=127.0.0.1;Port=5432;Username=postgres;Password=" +dotnet test tests\SharpCrafters.Backstage.LicenseServer.Tests +``` + +The tests read those two variables, and they run on SQLite when neither is set. A connection string +names no database: each test receives a database of its own, created from `Database\CreateTables.sql` +or from `Database\CreateTables.PostgreSql.sql`, so each run also proves that the script and the +Entity Framework model agree. The databases are named `licenseserver_test_` followed by a hexadecimal +number. They are reused during the run, and the next run drops the ones an interrupted run left +behind, so the login needs the permission to create a database. + +### Running locally + +``` +./Build.ps1 prepare +dotnet run --project src\SharpCrafters.Backstage.LicenseServer.Web +``` + +The development configuration uses a SQLite database, created at the first start, so no database +server is required. Once the server runs, `/Admin/GenerateDemoData` fills the database with simulated activity. +That page exists only in the Development environment. + +### Repository layout + +| Project | Contents | +|---|---| +| `src\SharpCrafters.Backstage.LicenseServer.Core` | The licensing rules, the database model, and the services they depend on. | +| `src\SharpCrafters.Backstage.LicenseServer.Web` | The web application: the pages, the endpoints and the composition root. | +| `tests\SharpCrafters.Backstage.LicenseServer.Tests` | The test suite. It runs on SQLite by default, and on SQL Server or PostgreSQL when the run is given one. | +| `eng` | The product definition and the version files that PostSharp.Engineering builds from. | + +To put a server under load, use `LicenseServerLoadSimulator`, in the SharpCrafters.Backstage +repository. It simulates an organization of many users on many machines, and it sends the requests +that the product sends, so it measures the load that real clients produce. ## Support -Please use PostSharp support facility at https://www.postsharp.net/support. +Please use the PostSharp support facility at https://www.postsharp.net/support. diff --git a/SharpCrafters.Backstage.LicenseServer.slnx b/SharpCrafters.Backstage.LicenseServer.slnx new file mode 100644 index 0000000..c75cfec --- /dev/null +++ b/SharpCrafters.Backstage.LicenseServer.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/SharpCrafters.Backstage.LicenseServer.slnx.DotSettings b/SharpCrafters.Backstage.LicenseServer.slnx.DotSettings new file mode 100644 index 0000000..5dc3523 --- /dev/null +++ b/SharpCrafters.Backstage.LicenseServer.slnx.DotSettings @@ -0,0 +1,5 @@ + + eng\style\CommonStyle.DotSettings + True + True + 1 \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..c878010 --- /dev/null +++ b/build.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# *** DO NOT EDIT THIS FILE DIRECTLY *** +# This file is auto-generated from src/PostSharp.Engineering.BuildTools/Resources/build.sh +# Edit the source version, then run `./Build.ps1 generate-scripts` to regenerate this file. + +# Wrapper script to call Build.ps1 with PowerShell +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +pwsh -File "$SCRIPT_DIR/Build.ps1" "$@" +exit $? diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..d22089a --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,28 @@ +# Turns the deployment in docker-compose.yml into one that can be exercised without buying a +# license. Apply it beside the main file, never on its own: +# +# docker compose -f docker-compose.yml -f docker-compose.test.yml up +# +# What it changes: the server runs in the Development environment, issues itself the license keys it +# serves, and runs its clock 1440 times faster than real time so that a fortnight of leases fits into +# a coffee break. The license keys are signed by a licensing authority the server generates into its +# own data volume, and no other server accepts them. +# +# The server refuses to start with these settings outside the Development environment, so this file +# cannot quietly turn a real deployment into a test one. + +services: + + licenseserver: + environment: + ASPNETCORE_ENVIRONMENT: Development + + # Generates a licensing authority in the data volume and adds a license key per product family. + LicenseServer__SeedTestLicenses: "true" + + # One real minute is one virtual day. See the license server protocol for how a client follows + # the accelerated clock through GetTime.ashx. + LicenseServer__TimeAcceleration: "1440" + + # The machine names that LicenseServerLoadSimulator gives its build agents. + LicenseServer__BuildServers: server diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e6ce4d5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,93 @@ +# The license server and a SQL Server database, with the schema created from CreateTables.sql. +# +# export MSSQL_SA_PASSWORD='...' +# ./Build.ps1 build +# docker compose up +# +# This deployment serves the license keys you add to it, and holds nothing that is not yours. To +# bring it up with license keys it issues to itself instead, which is what a trial or a load +# simulation wants, add the test override: +# +# docker compose -f docker-compose.yml -f docker-compose.test.yml up +# +# Read docs/docker.md before running either for anything that matters. The database password is +# taken from the environment and the server has no administrator credentials of its own, so securing +# the pages under /Admin is still yours to do; see docs/configuration.md. + +name: backstage-license-server + +services: + + database: + image: mcr.microsoft.com/mssql/server:2022-latest + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: ${MSSQL_SA_PASSWORD:?set MSSQL_SA_PASSWORD in the environment} + MSSQL_PID: Developer + ports: + - "1433:1433" + volumes: + - database-data:/var/opt/mssql + healthcheck: + # The server answers long before it is ready to take a query, so the check is a real query. + test: + - CMD-SHELL + - /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$$MSSQL_SA_PASSWORD" -C -Q "SELECT 1" -b + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + + database-schema: + # The server image already carries sqlcmd, so the schema job needs no second image to pull. + image: mcr.microsoft.com/mssql/server:2022-latest + depends_on: + database: + condition: service_healthy + environment: + MSSQL_SA_PASSWORD: ${MSSQL_SA_PASSWORD:?set MSSQL_SA_PASSWORD in the environment} + volumes: + - ./src/SharpCrafters.Backstage.LicenseServer.Web/Database:/schema:ro + entrypoint: + - /bin/bash + - -c + - | + set -e + SQLCMD=/opt/mssql-tools18/bin/sqlcmd + $$SQLCMD -S database -U sa -P "$$MSSQL_SA_PASSWORD" -C -b \ + -Q "IF DB_ID('PostSharpLicenseServer') IS NULL CREATE DATABASE PostSharpLicenseServer" + # Re-running compose must not fail on an existing schema. + TABLES=$$($$SQLCMD -S database -U sa -P "$$MSSQL_SA_PASSWORD" -C -h -1 -W -d PostSharpLicenseServer \ + -Q "SET NOCOUNT ON; SELECT COUNT(*) FROM sys.tables WHERE name = 'Licenses'") + if [ "$$TABLES" = "0" ]; then + $$SQLCMD -S database -U sa -P "$$MSSQL_SA_PASSWORD" -C -b -d PostSharpLicenseServer -i /schema/CreateTables.sql + echo "Schema created." + else + echo "Schema already present." + fi + + licenseserver: + build: + context: . + depends_on: + database-schema: + condition: service_completed_successfully + ports: + - "8080:8080" + environment: + LicenseServer__DatabaseProvider: SqlServer + ConnectionStrings__SharpCrafters_LicenseServerConnectionString: >- + Server=database,1433;Database=PostSharpLicenseServer;User Id=sa;Password=${MSSQL_SA_PASSWORD:?set MSSQL_SA_PASSWORD in the environment};TrustServerCertificate=True + # A container has no domain controller to authenticate against, so leases record no user name. + # Whatever fronts the container has to restrict the pages under /Admin. + Authentication__Scheme: None + Smtp__Enabled: "false" + volumes: + # The files the server generates live here. With the test override, that is the key pair of + # the test licensing authority, which has to outlive the container: the license keys it signed + # are in the database and stop verifying when the pair changes. + - licenseserver-data:/app/App_Data + +volumes: + database-data: + licenseserver-data: diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..dc81791 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,423 @@ +# Configuring the license server + +## Why this server exists + +Respecting the license agreement is the responsibility of the customer. The license server is a tool +that helps a customer to measure how many licenses are used, inside the network of the customer. It +sends nothing to PostSharp Technologies. + +The source code is published, so the server can be read, built and modified. A modified server, and +an unmodified one, can both be operated in a way that does not comply with the license agreement. +What the server reports is not, by itself, a proof of compliance. + +The license server is independent of the license audit that the license agreement and the privacy +policy describe. The audit reports the use of the product to PostSharp Technologies. The license +server reports it to the administrator of the customer. + +A customer who uses the license server is eligible for a waiver of the license audit. The waiver is +what guarantees that no data about the use of the product reaches PostSharp Technologies. Ask the +[sales team](https://www.postsharp.net/support) for it. + +## Where the settings are read from + +The server reads its settings from `appsettings.json`, in the application directory. Every setting +can also be given as an environment variable. In the name of the variable, a colon becomes a double +underscore: the connection string is `ConnectionStrings__SharpCrafters_LicenseServerConnectionString`. +Use an environment variable for a password, so that the password is not written in a file. + +Every setting has a default value. A setting that is absent from the file keeps its default, so the +file needs to contain only the settings you change. + +The server validates the settings when it starts. It refuses to start when a setting is invalid or +when two settings contradict each other, instead of failing later on a lease request. + +## An example + +The following `appsettings.json` configures a server that stores its data in SQL Server, restricts +the administrative pages to one Windows group, and sends notifications. + +```json +{ + "ConnectionStrings": { + "SharpCrafters_LicenseServerConnectionString": "Server=db.example.com,1433;Database=PostSharpLicenseServer;Integrated Security=True;Encrypt=False" + }, + "Authentication": { + "Scheme": "IISIntegrated" + }, + "LicenseServer": { + "DatabaseProvider": "SqlServer", + "MachinesPerUser": 2, + "NewLeaseDays": 3, + "MinLeaseDays": 1, + "BuildServers": "buildagent-1;buildagent-2", + "AdminRoles": [ "DOMAIN\\PostSharp Administrators" ], + "GracePeriodWarningEmailTo": "licenses@example.com", + "DeniedRequestEmailTo": "licenses@example.com" + }, + "Smtp": { + "Enabled": true, + "Host": "smtp.example.com", + "Port": 587, + "EnableSsl": true, + "FromAddress": "licenses@example.com" + } +} +``` + +The `appsettings.json` of the release package lists most of these settings with their default values. + +## Database + +| Setting | Default | Meaning | +|---|---|---| +| `LicenseServer:DatabaseProvider` | `SqlServer` | The database engine: `SqlServer`, `PostgreSql` or `Sqlite`. | +| `ConnectionStrings:SharpCrafters_LicenseServerConnectionString` | a local SQL Server | The database to connect to. | + +The two settings are set together. The connection string is interpreted by the engine named in +`DatabaseProvider`, so the two must agree. For SQL Server: + +```json +{ + "LicenseServer": { "DatabaseProvider": "SqlServer" }, + "ConnectionStrings": { + "SharpCrafters_LicenseServerConnectionString": "Server=db.example.com,1433;Database=PostSharpLicenseServer;Integrated Security=True;Encrypt=False" + } +} +``` + +For PostgreSQL: + +```json +{ + "LicenseServer": { "DatabaseProvider": "PostgreSql" }, + "ConnectionStrings": { + "SharpCrafters_LicenseServerConnectionString": "Host=db.example.com;Port=5432;Database=postsharplicenseserver;Username=licenseserver;Password=..." + } +} +``` + +For SQLite: + +```json +{ + "LicenseServer": { "DatabaseProvider": "Sqlite" }, + "ConnectionStrings": { + "SharpCrafters_LicenseServerConnectionString": "DataSource=licenseserver.db" + } +} +``` + +SQL Server and PostgreSQL are the engines supported in production. Create the schema by running +`Database\CreateTables.sql` on SQL Server, or `Database\CreateTables.PostgreSql.sql` on PostgreSQL. +The server never creates the schema and never modifies it, so an upgrade of the server makes no change +to the database. + +The PostgreSQL schema needs PostgreSQL 14 or later, and it creates a collation of its own. The +comparison of a user name and of a machine name ignores the case on SQL Server, whose default +collation ignores it, and PostgreSQL offers no such collation, so the script creates one from the +International Components for Unicode. A server built without them refuses the script. + +SQLite is supported for tests and for evaluation. The test suite of this repository runs on SQLite by +default, and it runs again against SQL Server and against PostgreSQL. The development configuration of +the web project uses SQLite. The database file is created at the +first start. A relative path is resolved against the application directory, not against the working +directory of the process. Do not use SQLite for a server that serves a team: SQLite accepts one +writer at a time, and every lease request writes. + +`Microsoft.Data.SqlClient`, the SQL Server client, encrypts the connection by default. The connection +fails when the web server does not trust the certificate of the SQL Server. Add `Encrypt=False` to +the connection string to connect without encryption, or `TrustServerCertificate=True` to keep the +encryption and skip the verification of the certificate. + +The default connection string contains `Integrated Security=True`. The application then authenticates +to SQL Server as the Windows account it runs under. This requires a host that is joined to the +domain. On another host, and on Linux and macOS, use a SQL Server login: + +``` +Server=db.example.com,1433;Database=PostSharpLicenseServer;User Id=licenseserver;Password=...;TrustServerCertificate=True +``` + +Give this connection string in the environment variable +`ConnectionStrings__SharpCrafters_LicenseServerConnectionString`, so that the password is not written +in `appsettings.json`. + +## Licensing rules + +A seat is one user working on up to `MachinesPerUser` machines. A user working on more machines takes +more than one seat. The number of seats is the number of machines divided by `MachinesPerUser`, +rounded up. With the default value of two, one or two machines are one seat, and three or four +machines are two seats. + +The capacity of a license key is a number of seats. The seat is the only unit the server counts. The +license agreement expresses the same rule in the opposite direction: it grants a number of authorized +users, and each user may work on a number of devices. + +| Setting | Default | Meaning | +|---|---|---| +| `LicenseServer:MachinesPerUser` | 2 | How many machines one seat covers. Read your license agreement before you change it. | +| `LicenseServer:NewLeaseDays` | 3 | How long a new lease lasts. | +| `LicenseServer:MinLeaseDays` | 1 | How long before the end of a lease the client renews it. Raise this value above the number of days your developers work offline. Must be smaller than `NewLeaseDays`. | +| `LicenseServer:BuildServers` | empty | The machine names of the build agents, separated by semicolons, commas or spaces. A build agent receives a license but no lease, so that it does not consume the seat of a developer. A trailing hexadecimal identifier is ignored, so `buildagent-1f2e` matches `buildagent`. | + +## Notifications + +| Setting | Default | Meaning | +|---|---|---| +| `LicenseServer:GracePeriodWarningEmailTo` | empty | Who is informed that the license is over capacity. | +| `LicenseServer:GracePeriodWarningEmailCC` | empty | Who else is informed. | +| `LicenseServer:DeniedRequestEmailTo` | empty | Who is informed that a request was denied. | +| `LicenseServer:GracePeriodWarningDays` | 1 | How many days the server waits before it repeats a warning. | +| `Smtp:Enabled` | `false` | Whether notifications are sent. | +| `Smtp:Host`, `Smtp:Port`, `Smtp:EnableSsl` | `localhost`, 25, `false` | The SMTP server. | +| `Smtp:FromAddress` | `sales@postsharp.net` | The sender. | +| `Smtp:UserName`, `Smtp:Password` | empty | The credentials of the SMTP server, when it requires them. Give the password in an environment variable. | + +An address left empty disables that notification. A notification that cannot be sent is written to +the log. A failure to send a notification never denies a license. + +## Access + +| Setting | Default | Meaning | +|---|---|---| +| `Authentication:Scheme` | detected | `IISIntegrated`, `Negotiate` or `None`. See below. | +| `LicenseServer:AdminRoles` | empty | The Windows groups allowed to open the administrative pages, for example `["DOMAIN\\PostSharp Administrators"]`. | +| `LicenseServer:RequireAuthenticatedLeaseRequests` | `false` | Whether a lease request must be authenticated. | + +A lease request carries the user name and the machine name in its query string. The server trusts +these two values and records them in the `UserName` and `Machine` columns, and it counts seats from +them. Authentication does not provide them. + +Authentication has two other purposes. It records the caller in the `AuthenticatedUser` column of the +audit log, next to the user name that the request declared. It also restricts access: +`RequireAuthenticatedLeaseRequests` requires the caller of `Lease.ashx` to be authenticated, and +`AdminRoles` restricts the administrative pages to the members of the groups it names. + +| Scheme | Use it when | +|---|---| +| `IISIntegrated` | The application is hosted by IIS. Windows authentication must also be enabled on the site in IIS Manager. | +| `Negotiate` | The application runs in its own process, on a host joined to your domain. On a system other than Windows, this requires Kerberos and a keytab. | +| `None` | There is no domain to authenticate against, for example in a container. Requests are served anonymously and the `AuthenticatedUser` column stays empty. The server writes a warning to the log at every start. | + +When `Authentication:Scheme` is absent, the server selects `IISIntegrated` when IIS hosts it, +`Negotiate` on Windows, and `None` on another system, and writes the selected scheme to the log. Set +the scheme explicitly. A wrong selection is not reported as an error, because a server that +authenticates no caller still serves leases. The consequence is that the `AuthenticatedUser` column +stays empty. + +### Securing the administrative pages + +Restricting the administrative pages is the responsibility of the administrator. The server does not +restrict them on its own. `AdminRoles` and `RequireAuthenticatedLeaseRequests` are both empty in the +released configuration, as the corresponding sections were commented out in the `Web.config` of +earlier versions. A restrictive default would lock an administrator out of their own server during an +upgrade. The server writes a warning to the log at every start while `AdminRoles` is empty. + +Restrict these pages. They are the only way to add and to revoke a license, and the export endpoint +at `/Admin/Export.ashx` returns the whole audit log. There are two mechanisms, and you can combine +them. + +#### The AdminRoles setting + +`LicenseServer:AdminRoles` covers every page under `/Admin` and the export endpoint. It requires an +authentication scheme that reports the Windows groups of the caller, so it works with +`IISIntegrated` and with `Negotiate`, and not with `None`. + +#### A restriction on the path, in the web server + +A restriction configured in the web server works with any authentication scheme. Under IIS, enable +Windows authentication on the site, then add a URL authorization rule for the `Admin` path to the +`web.config` of the application, which is in the published output: + +```xml + + + + + + + + + + +``` + +This rule requires the URL Authorization role service of IIS, which is not installed by default. +Behind another web server, and in a container, restrict the path in the component that receives the +requests. + +## Concurrency + +| Setting | Default | Meaning | +|---|---|---| +| `LicenseServer:MutexTimeout` | 30 | How many seconds a request waits for its turn before the server answers 503. | + +The server serializes the lease requests, so that two concurrent requests cannot both take the last +free seat. A request that waits longer than `MutexTimeout` receives the status 503 with the body +`Service overloaded.`. + +The lock is held by the database and not by the process. Several worker processes on one database are +therefore serialized against each other, which covers a web garden of Internet Information Services, +several containers, and two servers that share one database. + +The deployments described here are the deployments that are supported today. If you need another one, +for instance one database per site, ask the [support team](https://www.postsharp.net/support). + +The mechanism depends on the database engine, and there is no setting to choose it. + +On SQL Server, the server calls `sp_getapplock` at the beginning of the request and +`sp_releaseapplock` at the end of it. The lock belongs to the session, which is the connection of the +request. It requires no permission beyond the ones the server already needs on its database. + +On PostgreSQL, the server takes an advisory lock with `pg_advisory_lock` and releases it with +`pg_advisory_unlock`. That lock also belongs to the session. The wait is bounded by `lock_timeout`, +which the server sets on the connection from `MutexTimeout`. + +On SQLite, the server opens the transaction of the request with `BEGIN IMMEDIATE`, which takes the +write lock of the database file at once. SQLite accepts one writer at a time, so the next request +waits for the transaction to be committed. + +## Auditing + +The `Leases` table is the audit log. The server never updates a lease and never deletes one: +prolonging a lease and cancelling a lease both insert a new row. Export the log from the page +[Audit log](protocol.md#exporting-the-audit-log-get-adminexportashx), which writes one line per lease. + +The audit log has no setting. It names the user and the machine of every lease, so it contains +personal data. Protect it as you protect the database, which contains the same names: with the +permissions of the database and with your backups. The log is for the organization that runs the +server, and it is not sent to PostSharp Technologies. + +## Storage + +| Setting | Default | Meaning | +|---|---|---| +| `LicenseServer:DataDirectory` | `App_Data` | Where the server stores the files it generates. | + +The directory contains the test licensing authority, when the server has one. A relative path is +resolved against the application directory, so the default value works wherever you unpack the +release package. Set an absolute path to store these files on another volume. + +A server that serves the license keys of a production authority writes nothing to this directory. + +In a container, this directory must be a volume when the server uses a test licensing authority. The +image declares a volume at `/app/App_Data`, so the files survive the replacement of the container +even when no volume is named. See [docker.md](docker.md). + +## Monitoring + +| Path | Answers | +|---|---| +| `/health/live` | Whether the process answers. It runs no check. | +| `/health` | Whether the process answers, the database can be queried, and a license can serve a lease. | +| `/version` | Which build is deployed, and which version of the licensing library it uses to parse license keys. | + +The server serves these three endpoints without authentication, as it serves `Lease.ashx`. A +monitoring agent has no Windows credentials, and a probe that receives 401 reports the server as +unavailable. The responses contain no license key, no user name and no connection string. The +version number is readable by anyone who can reach the server. + +The operating requirements of this server are low. One developer sends about one request per day, +because the client stores its lease and renews it after `NewLeaseDays` minus `MinLeaseDays` days. A +client that cannot reach the server keeps the lease it holds. An interruption of a few hours +therefore affects only a user or a machine that holds no lease yet, which means a new user, a new +machine, or one whose lease has expired. Monitor the server to learn that it needs attention, and +not to fail over. + +`/health` reports the result of each check in its body. It has three states: + +| Status | Code | Means | +|---|---|---| +| `Healthy` | 200 | The database answers and a license can serve a lease. | +| `Degraded` | 200 | The server works, but no license can serve a lease. | +| `Unhealthy` | 503 | The database cannot be queried. | + +```json +{ + "status": "Degraded", + "checks": [ + { "name": "database", "status": "Healthy", "description": "The database answers." }, + { "name": "licenses", "status": "Degraded", "description": "No license can serve a lease: 1 expired." } + ] +} +``` + +The database check runs a query on the license table instead of opening a connection. The server +never creates its schema on SQL Server, so a database that accepts connections but has no schema is a +deployment error. The query detects it; opening a connection does not. + +The license check reports whether a license can serve a lease at this moment. It warns when the +server has no license, and when every license is expired, disabled, unparsable, or full with its +grace period over. A server in that state answers every lease request with 403 while its process and +its database are healthy. + +The license check warns and never fails. An expired license needs an administrator, and restarting +the server does not add a license, so the state is reported and the probe still succeeds. Only the +process and the database can fail the probe. + +Configure your monitoring system to report the status in the body, and not only the status code. The +server also writes a warning to its log at each degraded check: + +``` +warn: Microsoft.Extensions.Diagnostics.HealthChecks.DefaultHealthCheckService[103] + Health check licenses with status Degraded completed after 33.8303ms with message 'No license is registered.' +``` + +The license check answers a weaker question than a lease request. A lease request names a product, a +version and a build date, and a license can be refused because of any of the three. A server that +reports `Healthy` can therefore still deny an individual request. See +[the protocol](protocol.md#what-decides-a-grant). + +## Testing + +The settings of this section exist for the developers of the license server itself, and for an +evaluation of it. A customer who serves license keys needs none of them. + +| Setting | Default | Meaning | +|---|---|---| +| `LicenseServer:TimeAcceleration` | 1 | How much faster than real time the clock of the server runs. | +| `LicenseServer:SeedTestLicenses` | `false` | Whether the server issues to itself the license keys it serves. | +| `LicenseServer:TestLicensingAuthorities` | empty | The licensing authorities whose license keys a development server accepts, in addition to the production authority. | + +The server refuses to start when one of the last two settings is set outside the Development +environment. + +The environment is the one of ASP.NET Core. It is read from the variable `ASPNETCORE_ENVIRONMENT`, +and the server runs in the environment `Production` when that variable is not set. The value +`Development` is set by `Properties/launchSettings.json` when the project is started from an editor +or with `dotnet run`, and by nothing else. A published server therefore runs as `Production`, unless +an administrator sets the variable. + +Keep `TimeAcceleration` at 1. Another value exists so that a licensing scenario that lasts several +days can be replayed against a test server in a few minutes. The server writes a warning to the log +when the value is not 1. + +`SeedTestLicenses` exists so that an evaluation or a load simulation has a license to lease before +anyone buys one. The server generates a licensing authority of its own in +[`DataDirectory`](#storage), accepts the license keys of that authority, and adds one license key per +product family when the database contains none. No other server accepts these license keys. The +authority must survive a restart, because the license keys it signed are stored in the database and +stop being valid when the key pair changes. In a container, this means that the data directory must +be a volume. + +`TestLicensingAuthorities` exists so that a load simulation can run against license keys that are not +sold. Each entry contains the identifier of the key that signs a license key, and the public half of +that key, in the XML representation that SharpCrafters.Backstage reads: + +```json +{ + "LicenseServer": { + "TestLicensingAuthorities": [ + { + "KeyId": 200, + "PublicKey": "nistP256" + } + ] + } +} +``` + +The identifier must differ from the identifiers of the production keys, which are 0, 1 and 2. The +server refuses to start on a duplicate identifier. It also refuses to start when this setting is used +outside the Development environment, because whoever holds the private half of the key pair can +create license keys that such a server accepts. Keep the private half out of source control, and +configure the public half in user secrets instead of `appsettings.json`. diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 0000000..52c5561 --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,160 @@ +# Running the license server in a container + +This deployment is provided for development and for testing, and it is not recommended for +production. The database it starts is SQL Server 2022 Developer Edition, whose license does not cover +a production installation. For a production installation, run the server against a SQL Server or a +PostgreSQL that your organization licenses and operates, as +[configuration.md](configuration.md) describes. + +`docker-compose.yml` starts a license server: the application, a SQL Server database, and a job that +creates the schema from `Database/CreateTables.sql` and then stops. + +``` +export MSSQL_SA_PASSWORD='...' +./Build.ps1 build +docker compose up +``` + +The build runs first because the image contains the files of the release archive. The image does not +build the sources again, so the container runs what is released. + +This deployment serves the license keys that you add to it. The database password comes from the +environment and not from a file of the repository, and the server generates its own audit signing key +in a volume. To start a server that issues license keys to itself, see +[Trying it out without a license key](#trying-it-out-without-a-license-key). + +The server then listens on http://localhost:8080, and the database on `localhost:1433`. Add a license +key on the *Add a license* page, then configure a PostSharp client with +`http://localhost:8080/Lease.ashx`. + +To stop the server and keep the data: + +``` +docker compose down +``` + +To stop the server and delete the database: + +``` +docker compose down --volumes +``` + +## What it starts + +| Service | What it is | +|---|---| +| `database` | SQL Server 2022 Developer Edition. Its health check runs a query, because the server accepts connections before it can answer one. | +| `database-schema` | Runs once: it creates the database when the database does not exist, then runs `CreateTables.sql` when the tables do not exist. A second `docker compose up` does not fail on an existing schema. The service reuses the SQL Server image, which contains `sqlcmd`, instead of pulling a second image. | +| `licenseserver` | The application, built from `Dockerfile`. It waits for the schema job to finish. | + +The application stores the files it generates in the `licenseserver-data` volume, mounted at +`/app/App_Data`. The only such file is the test licensing authority, which a server that serves +license keys of the production authority does not have. That file must survive the container, +because its loss invalidates the license keys that the authority signed. The database stores its own +files in `database-data`. + +The image declares `/app/App_Data` as a volume, so a container started with `docker run` and no +explicit mount still stores these files outside its writable layer. +`LicenseServer__DataDirectory` moves the directory to another location. + +## Probing it + +The server answers `/health/live` with the state of the process, `/health` with the state of the +process, of the database and of the licenses, and `/version` with the build it runs. The three +endpoints require no authentication. In Kubernetes, use `/health/live` as the liveness probe and +`/health` as the readiness probe. A missing or expired license fails neither of them, so it never +blocks a deployment; the server reports it in the body of `/health` and in its log. See +[Monitoring](configuration.md#monitoring). + +The image declares no `HEALTHCHECK`, and the compose file declares none either. A health check of +Docker runs inside the container, and the ASP.NET runtime image contains no HTTP client to run it +with. Adding `curl` to the image would increase the attack surface, and every system that runs +containers can send an HTTP request of its own. + +## Trying it out without a license key + +A server with no license key cannot serve a lease, and the production licensing authority signs only +license keys that were sold. The test override makes the server issue to itself the license keys it +serves: + +``` +docker compose -f docker-compose.yml -f docker-compose.test.yml up +``` + +The override puts the server in the Development environment, sets +`LicenseServer__SeedTestLicenses`, and runs the clock 1440 times faster than real time, so that two +weeks of leases take fifteen minutes. The server generates a licensing authority of its own in the +data volume, accepts the license keys of that authority, and adds one license key per product family. +No other server accepts these license keys. The server also refuses to start with either setting +outside the Development environment, so this file cannot turn a real deployment into a test +deployment. + +[LicenseServerLoadSimulator](protocol.md#reading-the-server-clock-gettimeashx), in the +SharpCrafters.Backstage repository, expects a server configured in this way. Delete the data volume +between two simulations: the virtual clock is anchored when the process starts, so a restart moves it +backwards and leaves the existing leases dated in the future. + +## Before you run it for real + +- The application connects as `sa`. A real deployment uses a login that has rights on one database. + The password comes from `MSSQL_SA_PASSWORD` in the environment, and compose refuses to start + without it. +- No caller is authenticated. A container has no domain controller, so `Authentication__Scheme` is + `None` and the server records every lease without an authenticated user. To record the caller, run + the container on a host joined to your domain with a Kerberos keytab, and set the scheme to + `Negotiate`. +- The administrative pages are open, as they are in the default configuration of every deployment. + The container does not restrict them. See + [Securing the administrative pages](configuration.md#securing-the-administrative-pages). +- Back up the database. A deployment that serves license keys of the production authority keeps + everything it must not lose in the database, and the `licenseserver-data` volume stays empty. + +## The image on its own + +The image does not need the compose file. Against an existing SQL Server: + +``` +./Build.ps1 build +docker build -t backstage-licenseserver . +docker run --rm -p 8080:8080 \ + -e ConnectionStrings__SharpCrafters_LicenseServerConnectionString="Server=db;Database=PostSharpLicenseServer;User Id=licenseserver;Password=...;TrustServerCertificate=True" \ + -e Authentication__Scheme=None \ + -v licenseserver-data:/app/App_Data \ + backstage-licenseserver +``` + +Every setting of [configuration.md](configuration.md) can be given as an environment variable, with a +double underscore where the name of the setting contains a colon. + +Against an existing PostgreSQL, name the engine as well, because the connection string is interpreted +by the engine that `DatabaseProvider` names: + +``` +docker run --rm -p 8080:8080 -e LicenseServer__DatabaseProvider=PostgreSql -e ConnectionStrings__SharpCrafters_LicenseServerConnectionString="Host=db;Port=5432;Database=postsharplicenseserver;Username=licenseserver;Password=..." -e Authentication__Scheme=None -v licenseserver-data:/app/App_Data backstage-licenseserver +``` + +For an evaluation without a database server, the server can store its data in a SQLite file, which it +creates itself: + +``` +docker run --rm -p 8080:8080 \ + -e LicenseServer__DatabaseProvider=Sqlite \ + -e ConnectionStrings__SharpCrafters_LicenseServerConnectionString="DataSource=/app/App_Data/licenseserver.db" \ + -v licenseserver-data:/app/App_Data \ + backstage-licenseserver +``` + +SQL Server and PostgreSQL are the engines supported for a real installation. Create the schema before +the first start, as [configuration.md](configuration.md) describes: the server never creates it. + +## The image + +`Dockerfile` has a single stage, based on the ASP.NET runtime image. It copies `artifacts/app`, the +directory into which `./Build.ps1 build` unpacks the release archive, so the image and the archive +contain the same files. The application runs as a user that is not root, and listens on port 8080. + +The image does not build the product. The licensing component comes from a private feed, and the +build requires the `global.json` and the `nuget.config` that `./Build.ps1 prepare` generates. Neither +file belongs in a container that a customer can build. + +`./Build.ps1 build` does not run the tests. Run `./Build.ps1 test` before you publish the image. diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..ab569f4 --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,337 @@ +# The license server protocol + +A client asks this server for a lease: the permission to use a license key during a limited period. +Every client of PostSharp and of Metalama uses this protocol, and clients of several versions use one +server at the same time. The protocol is compatible with all of them, including the versions released +before PostSharp 5. + +The shape of each request and of each response is therefore a contract. A later version of the +protocol may add an argument or a part, and a client that sends neither is served as it was before. +This server cannot change what is already there. + +This document describes what the server accepts and what it answers. It is written for the developer +who maintains a client, diagnoses a deployment, or modifies this server. + +The client is in `SharpCrafters.Backstage.Licensing.LicenseServer`, in the SharpCrafters.Backstage +repository. `LicenseServerClient` builds the requests, `LicenseLease` parses the responses, and +`LicenseServerLoadSimulator` exercises the whole protocol against a running server. + +## Conventions + +| Property | Value | +|---|---| +| Transport | HTTP or HTTPS. The server does not require HTTPS; a client warns the user when a license server URL is plain HTTP. | +| Method | `GET` for every endpoint. | +| Content type | `text/plain` for every response body, without a charset parameter. Bodies are UTF-8. | +| Paths | `Lease.ashx`, `GetTime.ashx` and `Admin/Export.ashx`, relative to the base URL of the server. | +| Query arguments | Percent-encoded. Argument names are case-sensitive. | +| Instants | UTC, in the XML round-trip representation, for instance `2026-09-22T07:07:08.3615328Z`. | + +The `.ashx` extension is historical. The server is no longer an ASP.NET application with handlers, +but the paths are kept because deployed clients contain them. + +A client that receives a URL that already contains a query string sends that URL without appending +`Lease.ashx`. This case exists for a URL passed directly to a build. Registration refuses such a URL. + +### Authentication + +A lease request is anonymous by design. The server answers a request that carries no credentials. + +The request declares the user and the machine in its query string, and the server trusts both values. +It stores them and counts seats from them. Authentication does not provide them. + +A client sends the credentials of the current user nevertheless, unless it is configured otherwise, +because a server published by IIS with Windows authentication answers 401 to an anonymous request. +When credentials arrive, the server records the authenticated caller in the `AuthenticatedUser` +column, in addition to the user that the request declares. + +Authentication protects the administrative interface. The pages under `/Admin` and the export of the +audit log are the resources that need protection, and protecting them is the responsibility of the +administrator. See [configuration.md](configuration.md). + +`LicenseServer:RequireAuthenticatedLeaseRequests` makes the server refuse an anonymous lease request +as well. It is disabled by default. + +The query string contains the name of the user and the name of the machine, so a server reached over +plain HTTP transmits both without encryption. A client warns about this and serves the build. + +## Leasing a license: `GET /Lease.ashx` + +### Request + +| Argument | Required | Meaning | +|---|---|---| +| `user` | yes | The account that borrows the license, as the operating system names it, for instance `CONTOSO\alice`. | +| `machine` | yes | The machine name, a hyphen, and a hexadecimal machine identifier. See below. | +| `version` | no | The version of the client, for instance `2027.0.0`. | +| `buildDate` | no | The date the client was built, in the round-trip format. | +| `product` | no | The product whose pool of licenses the server allocates from, for instance `MetalamaProfessional`. | + +An example, before percent-encoding: + +``` +GET /Lease.ashx?user=CONTOSO\alice&machine=DESKTOP-ABC-1f2e3d4c&version=2027.0.0&buildDate=2026-01-15T00:00:00.0000000Z&product=MetalamaProfessional +``` + +The server converts `user` and `machine` to lower case before it stores or compares them, so two +clients that differ only in case are one user on one machine. + +#### The machine argument + +The value is the machine name, a hyphen, and the hexadecimal hash of the machine identifier, in lower +case. The hash distinguishes two machines that have the same name. This happens with cloned virtual +machines and with build agents created from an image. + +The server uses the suffix as well. Before it compares a machine name to the list of build servers, +it removes one trailing `-` followed by hexadecimal digits, so `buildagent-1f2e` matches the +configured name `buildagent`. It removes exactly one group, and every client appends the hash, so a +machine whose own name ends with hexadecimal digits keeps them: `build-01` arrives as `build-01-1f2e` +and is compared as `build-01`. + +The name and the hash are in a single argument, so the server has to recognize the hash by its form. +Two separate arguments would remove this heuristic. They could only be added next to this argument +and not replace it, because deployed clients cannot be changed. + +#### The version argument + +A client older than PostSharp 5 does not send `version`. The server reads an absent value as `4.9.9`, +so that the client is treated as older than the version that introduced the argument. A client that +has no version of its own sends `0.0` instead of omitting the argument. + +The version has two effects. It decides whether a license that requires a newer client may be served, +and it selects the wording of a refusal. See [Denials](#denials). + +#### The buildDate argument + +The server compares the build date to the end of the maintenance subscription of a license. A build +produced after the end of the subscription is not covered by it, whatever the date on which it runs. + +A client that has no build date of its own sends the earliest representable instant, +`0001-01-01T00:00:00.0000000`, instead of an empty value, which the server cannot parse. When the +server receives no `buildDate`, it skips the subscription check. + +#### The product argument + +PostSharp never sent this argument. A server that receives no product allocates from any pool it +holds, and PostSharp clients depend on this behaviour. + +A client of the Backstage generation names the product of its family, so that one server can hold the +licenses of several products at the same time. + +The value is the name of a member of the `LicenseProduct` enumeration of SharpCrafters.Backstage. The +server matches it against the `ProductCode` column. It accepts both spellings of a product whose name +changed between the two licensing libraries: a request for `PostSharpUltimate` also finds the +licenses that an earlier version of this server stored as `Ultimate`. The pairs are listed in +`ProductCodes`. + +### Response + +The server answers a granted lease with `200 OK` and a body of four named parts: + +``` +License: 900001-ZEE78XQQ…ZAUPA; StartTime: 2026-09-22T07:07:08.3615328Z; EndTime: 2026-09-25T07:07:08.3615328Z; RenewTime: 2026-09-24T07:07:08.3615328Z +``` + +| Part | Meaning | +|---|---| +| `License` | The license key the client uses until the lease ends. | +| `StartTime` | The instant the lease began. | +| `EndTime` | The instant after which the license key may no longer be used. | +| `RenewTime` | The instant from which the client asks for a new lease. It precedes `EndTime`. | + +The client parses the body leniently, and the server relies on this: + +- The parts are separated by `;`. A license key is an identifier, a hyphen and Base32 characters, so + it never contains a semicolon. +- Each part is split at its first `:`, so the colons inside an instant are preserved. +- The name of a part is matched without regard to case. +- A part that the client does not know is ignored. A later version of the server can therefore add a + part, but it cannot rename one. +- Only `License` is mandatory. A client that receives no `StartTime` uses the current instant. An + absent `EndTime` gives a lease of one day, and an absent `RenewTime` gives `EndTime`. +- A client reads the instants as UTC and keeps them as UTC. + +A client reads at most 64 KiB of the body. A lease is a few hundred bytes. The limit prevents a broken +or hostile server from sending an unbounded response. + +### Status codes + +| Code | Body | Meaning | +|---|---|---| +| 200 | The lease, as above. | A license was allocated. | +| 400 | `Missing query string argument: machine.` | `machine` was absent or empty. | +| 400 | `Missing query string argument: user.` | `user` was absent or empty. | +| 400 | `Cannot parse the argument: version.` | `version` is not a version number. | +| 400 | `Cannot parse the argument: buildDate.` | `buildDate` is not a round-trip date. | +| 403 | `No license with free capacity. ` followed by one explanation per license. | No license could serve the request. | +| 503 | `Service overloaded.` | The server did not obtain its lease lock within `LicenseServer:MutexTimeout` seconds. | + +A client displays the body of a 403 to the user, because that text is the explanation the +administrator of the server needs. It reports any other status without its body. + +### Denials + +The body of a 403 begins with `No license with free capacity. `, followed by the reason each license +was not used, separated by spaces. A server that holds no license answers with the prefix alone. + +The server does not use a license for the following reasons: + +| Reason | Message | +|---|---| +| The key does not parse or its signature does not verify. | `The license key #N is invalid.` | +| The key requires a newer licensing library than the server carries. | `The license #N requires a higher version of the licensing library on the License Server. Please upgrade the License Server to >= X.Y.Z` | +| The key requires a newer client than the one that asks. | `The license #N of type T requires PostSharp version >= X.Y.Z but the requested version is A.B.C.` | +| The key may not be served by a license server. | `The license #N, of type T, cannot be used in the license server.` | +| The client was built after the end of the maintenance subscription. | `The maintenance subscription of license #N ends on D but the requested version X.Y.Z has been built on E.` | + +The last message omits the version when the client did not send one, because such a client is older +than the argument. + +A request that passes every check can still be denied for capacity. The server then reports no reason +for any license, so the body is the prefix alone. + +### What decides a grant + +The server tries the licenses it holds in the order of their `Priority` column, lowest first, and +skips a license whose priority is negative. For each license, in order: + +1. A lease that this user already holds on this machine is returned unchanged, when it ends more than + `MinLeaseDays` from now. +2. Such a lease is prolonged when it ends sooner. Prolonging replaces the lease with a new lease that + overwrites it, so the audit log keeps both. +3. A new lease is granted when the license has a free seat. +4. A new lease is granted beyond the capacity of the license when the license is within its grace + period. + +A seat is one user working on up to `LicenseServer:MachinesPerUser` machines. A user working on more +machines takes more than one seat: the number of machines divided by that setting, rounded up. With +the default value of two, one or two machines are one seat, and three or four machines are two seats. +The capacity of a license key is a number of seats, and the seat is the only unit the server counts. + +`EndTime` is `NewLeaseDays` from now, limited to the expiry of the license key. `RenewTime` is +`MinLeaseDays` before `EndTime`. The server refuses to start unless `MinLeaseDays` is smaller than +`NewLeaseDays`, because a renew time in the past makes every client renew at every build. + +A machine named in `LicenseServer:BuildServers` receives a license key, and the server stores no lease +for it, so that build agents do not consume the seats of the developers they build for. A build agent +is exempt from consuming a seat and from nothing else. The same validation runs, and the server +refuses an expired or ineligible license as it does for any other request. + +### Renewal + +A client stores the lease it received and contacts the server again only when it needs to. This is +the behaviour of the client, and the server does not enforce it. It keeps the load of the server +proportional to the number of developers instead of the number of builds. + +- The client uses the stored lease while the current instant precedes `RenewTime`. +- The client downloads a new lease after `RenewTime`. A renewal on a machine that the user already + holds prolongs a seat instead of allocating one. +- A renewal that fails while the stored lease is still valid keeps the stored lease and reports + nothing. The build has a license, and a short interruption of the server must not fail it. +- The client discards a lease whose `EndTime` has passed and downloads a new one. +- The client also discards a lease whose `EndTime` is already in the past when the lease arrives. + Without this rule, a server whose clock is behind the clock of the client would make every process + download a lease, find it expired, and download another one, without end. + +With the default values, a lease of three days renewed after two, one machine contacts the server +about once every two days, whatever the number of builds in between. + +## Reading the server clock: `GET /GetTime.ashx` + +The server answers with its own current instant and with the factor by which its clock runs faster +than real time, separated by `;`: + +``` +2026-09-17T11:00:58.5236731Z;1440 +``` + +The factor is `1` on a production server, where the clock is the real one. Another value comes from +`LicenseServer:TimeAcceleration`, and the server writes a warning to the log at startup when the +setting is used. + +A test harness anchors its own virtual clock on this reading and advances it at the same rate: + +``` +virtualNow = serverTimeAtSync + (realNow - realTimeAtSync) * acceleration +``` + +The reading does not compensate the round trip. At a factor of 1440, a round trip of 50 ms is +72 seconds of virtual time. A harness therefore reads the clock again when it observes drift. For a +licensing harness, drift becomes visible when a lease arrives whose renewal instant has already +passed. + +The virtual clock of the server is anchored when the process starts, and it never resets. Restarting +a server therefore moves its clock backwards, and the leases granted before the restart are then +dated in the future. Delete the database as well as restarting the process between two simulations. + +## Exporting the audit log: `GET /Admin/Export.ashx` + +The export is not part of the client protocol. The administrator of the server exports the log to +understand how the licenses are used: which user held which seat, on which machine, and when. Its +format is a contract of its own, because customers archive these files and compare them across years. + +The exported file contains personal data. It is meant for the organization that runs the server, and +it is not meant to be sent to PostSharp Technologies. + +### Request + +| Argument | Meaning | +|---|---| +| `fy`, `fm` | The year and the month the range starts at. | +| `ty`, `tm` | The year and the month the range ends at, inclusive. | + +Years are between 2010 and 2100, and months between 1 and 12. The server answers any other value with +`400` and the body `The range of months is missing or invalid. Years must be between 2010 and 2100.` + +The response carries `Content-Disposition: attachment` with a file name derived from the range, for +instance `PostSharp_LicenseLog_2026-1_2026-12.txt`. The server writes the response while it reads the +rows. + +### Format + +One line per lease, with seven fields separated by `;`: + +``` +40;;900001;2026-09-17T11:02:14.1234567Z;2026-09-20T11:02:14.1234567Z;desktop-1;alice +``` + +| Field | Meaning | +|---|---| +| 1 | The identifier of the lease. | +| 2 | The identifier of the lease that this one overwrote, empty when it overwrote none. | +| 3 | The identifier of the license. | +| 4 | The instant the lease began, in UTC. | +| 5 | The instant the lease ended, in UTC. | +| 6 | The machine name. | +| 7 | The user name. | + +The two names are written as the server recorded them, which is in lower case, because the endpoint +converts them before it stores them. + +A version earlier than 2027.0 wrote the two names as hashes. The log is read by the administrator of +the server, who needs to know which user and which machine hold a seat, and a hash answers neither +question. + +A version earlier than 2027.0 wrote an eighth field, which contained a signature of the line. That +signature could not be verified: the server generated a random key at every call, so no two lines +were signed with the same key. The server no longer writes the field, and it no longer writes the +`HMAC` column of the `Leases` table. + +The server resolves a range of months to a range of lease identifiers, and exports every lease in +that range. The result is a contiguous section of the log and not a filtered selection. This is the +reason why a few leases outside the requested months appear in the file. + +## Compatibility + +The server keeps the following behaviours because clients depend on them. A change to any of them +breaks a client that is already deployed. + +| Behaviour | Why | +|---|---| +| The `.ashx` paths. | Deployed clients contain them. | +| An absent `version` means 4.9.9. | This is how a client older than PostSharp 5 is recognized. | +| An absent `product` means any product. | No PostSharp client ever sent the argument. | +| The four part names of a lease. | A client matches the parts by name and ignores a part it does not know. | +| The status codes, and the body of a 403. | A client displays the body of a 403 to the user and treats every other status than 200 as a failure. | +| The trailing hexadecimal suffix of a machine name is removed before a build server is matched. | The list of build servers in an existing configuration names the machines without it. | +| The order of the seven fields of an audit line. | Customers archive exported files and compare them across years. | diff --git a/eng/AutoUpdatedVersions.props b/eng/AutoUpdatedVersions.props new file mode 100644 index 0000000..46ed20b --- /dev/null +++ b/eng/AutoUpdatedVersions.props @@ -0,0 +1,9 @@ + + + + + 2027.0.1-preview + + + diff --git a/eng/DeterministicZip.tasks b/eng/DeterministicZip.tasks new file mode 100644 index 0000000..ed32f82 --- /dev/null +++ b/eng/DeterministicZip.tasks @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/eng/MainVersion.props b/eng/MainVersion.props new file mode 100644 index 0000000..999fcc3 --- /dev/null +++ b/eng/MainVersion.props @@ -0,0 +1,6 @@ + + + 2027.0.0 + -preview + + diff --git a/eng/RunClaude.ps1 b/eng/RunClaude.ps1 new file mode 100644 index 0000000..2e72675 --- /dev/null +++ b/eng/RunClaude.ps1 @@ -0,0 +1,608 @@ +# The original of this file is in the PostSharp.Engineering repo. +# You can generate this file using `./Build.ps1 generate-scripts`. + +param( + [string]$Prompt, + [int]$McpPort, + + # Where this build's work lands, when it does not land in git. Repeatable, and also settable as + # CLAUDE_PROGRESS_PATHS (comma or semicolon separated) so DockerBuild can forward it. Relative paths are + # resolved against the repository root. + [string[]]$ProgressPath +) + +$ErrorActionPreference = "Stop" + +$Model = "opus" + +if ($env:RUNNING_IN_DOCKER -ne "true") +{ + Write-Error "This script must be run inside a Docker container. Set RUNNING_IN_DOCKER=true to override." + exit 1 +} + +# --- Output sanitization (matches ClaudeCodeHelper.SanitizeOutput) --- +function Sanitize-ClaudeOutput { + param([string]$Text) + + if ([string]::IsNullOrEmpty($Text)) { return "" } + + # Strip ANSI escape sequences + $stripped = $Text -replace '\x1b\[[0-9;]*m','' -replace '\[\d+(?:;\d+)*m','' + + $sb = [System.Text.StringBuilder]::new($stripped.Length) + foreach ($c in $stripped.ToCharArray()) { + $code = [int]$c + if (($code -ge 32 -and $code -le 126) -or $c -eq "`n" -or $c -eq "`r" -or $c -eq "`t") { + [void]$sb.Append($c) + } + elseif ([char]::IsWhiteSpace($c)) { + [void]$sb.Append(' ') + } + # Skip all other characters (including extended Unicode) + } + return $sb.ToString() +} + +# Tools whose output is silenced in the monitoring display +$script:SilentTools = @('Read', 'Glob', 'Grep', 'Edit') +$script:SilentToolIds = @{} + +# --- JSON stream line parser (matches ClaudeCodeHelper.TranslateJsonToHumanReadable) --- +function ConvertFrom-ClaudeJsonLine { + param([string]$Line) + + if ([string]::IsNullOrWhiteSpace($Line)) { return } + + try { + $json = $Line | ConvertFrom-Json + } catch { + Write-Host (Sanitize-ClaudeOutput $Line) + return + } + + switch ($json.type) { + 'system' { + if ($json.subtype -eq 'init') { + $model = if ($json.model) { $json.model } else { "unknown" } + Write-Host (Sanitize-ClaudeOutput "[Claude Code initialized - model: $model]") -ForegroundColor Green + } + } + 'assistant' { + if ($json.message -and $json.message.content) { + foreach ($block in $json.message.content) { + if ($block.type -eq 'text') { + Write-Host "" + Write-Host (Sanitize-ClaudeOutput $block.text) -ForegroundColor Cyan + } + elseif ($block.type -eq 'tool_use') { + $toolName = if ($block.name) { $block.name } else { "unknown" } + if ($script:SilentTools -contains $toolName) { + if ($block.id) { $script:SilentToolIds[$block.id] = $true } + continue + } + # Silence Bash calls for read-only commands (ls, grep) + if ($toolName -eq 'Bash' -and $block.input.command -match '^\s*(ls|grep|find)\b') { + if ($block.id) { $script:SilentToolIds[$block.id] = $true } + continue + } + Write-Host "" + Write-Host (Sanitize-ClaudeOutput "[Tool: $toolName]") -ForegroundColor Yellow + if ($block.input) { + # Display the most identifying property from the input + $displayProps = @( + @{ Key = 'file_path'; Label = 'File' } + @{ Key = 'command'; Label = '$' } + @{ Key = 'pattern'; Label = 'Pattern' } + @{ Key = 'query'; Label = 'Query' } + @{ Key = 'url'; Label = 'URL' } + @{ Key = 'skill'; Label = 'Skill' } + @{ Key = 'prompt'; Label = 'Prompt' } + @{ Key = 'description'; Label = 'Task' } + ) + $shown = $false + foreach ($dp in $displayProps) { + $val = $block.input.($dp.Key) + if ($val) { + $truncated = if ($val.Length -gt 1024) { $val.Substring(0, 1024) + "..." } else { $val } + Write-Host (Sanitize-ClaudeOutput " $($dp.Label): $truncated") -ForegroundColor Gray + $shown = $true + break + } + } + if (-not $shown) { + # Fallback: show the property names so the user at least sees what was passed + $keys = ($block.input.PSObject.Properties | Select-Object -ExpandProperty Name) -join ', ' + if ($keys) { + Write-Host (Sanitize-ClaudeOutput " [$keys]") -ForegroundColor Gray + } + } + } + } + } + } + } + 'user' { + if ($json.message -and $json.message.content) { + foreach ($block in $json.message.content) { + if ($block.type -eq 'tool_result') { + if ($block.tool_use_id -and $script:SilentToolIds.ContainsKey($block.tool_use_id)) { + $script:SilentToolIds.Remove($block.tool_use_id) + continue + } + $content = if ($block.content) { $block.content } else { "" } + # Strip system reminders and tool use markup + $content = $content -replace '(?s).*?', '' + $closingTag = '' + $content = $content -replace "(?s).*?$closingTag", '' + $sanitized = Sanitize-ClaudeOutput $content + $lines = $sanitized -split "`n" + $maxLines = 5 + $color = if ($block.is_error) { "Red" } else { "DarkGray" } + $prefix = if ($block.is_error) { " [ERROR] " } else { " ->" } + for ($i = 0; $i -lt [Math]::Min($lines.Count, $maxLines); $i++) { + Write-Host "$prefix$($lines[$i])" -ForegroundColor $color + } + if ($lines.Count -gt $maxLines) { + Write-Host " ... ($($lines.Count - $maxLines) more lines)" -ForegroundColor $color + } + } + } + } + } + 'result' { + Write-Host (Sanitize-ClaudeOutput "[Session completed]") -ForegroundColor Green + } + 'error' { + $msg = if ($json.error.message) { $json.error.message } elseif ($json.error) { $json.error } else { "Unknown error" } + Write-Host (Sanitize-ClaudeOutput "[ERROR] $msg") -ForegroundColor Red + } + } +} + +# --- Resume-loop helpers ------------------------------------------------------------------ +# In headless `-p` mode the process exits the instant the turn ends, with no way to wake it. +# The model occasionally ends its turn mid-build (e.g. "I'll wait for the background +# notification rather than poll"), which abandons all work. We cannot distinguish that from a +# genuine finish via the exit code (both are exit 0 / result.subtype == "success"), so we invert +# the logic: the run is considered DONE only when the model emits an explicit sentinel; any other +# ending is auto-resumed via `claude --resume `, bounded by the guards below. Each +# resume waits a short delay first (the exit may signal a transient condition), and the "give up" +# guards (no-progress / max-iterations) are held off until a minimum runtime floor so a burst of +# fast exits cannot abandon the run within the first few minutes. + +# Spawn one `claude` process, stream/log its stdout, and return what we need to decide whether +# to resume: the exit code, the (stable) session id, and any completion sentinel in the final text. +function Invoke-ClaudeOnce { + param( + [string]$Arguments, + [string]$StdinContent, + [string]$LogFile + ) + + $psi = [System.Diagnostics.ProcessStartInfo]::new() + $psi.FileName = $script:ClaudeExe + $psi.Arguments = $Arguments + $psi.RedirectStandardInput = $true + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + + $process = [System.Diagnostics.Process]::Start($psi) + + # Send the prompt (initial issue prompt, or the resume nudge) via stdin. + if ($null -ne $StdinContent) { $process.StandardInput.Write($StdinContent) } + $process.StandardInput.Close() + + $logWriter = [System.IO.StreamWriter]::new($LogFile, $false, [System.Text.Encoding]::UTF8) + $logWriter.WriteLine("[") + $isFirstJsonLine = $true + + $sessionId = $null + $resultText = $null + $resultSubtype = $null + $resultIsError = $false + $lastAssistantText = $null + + # Read and parse stdout line by line (real-time streaming). + while ($null -ne ($line = $process.StandardOutput.ReadLine())) { + if (-not [string]::IsNullOrWhiteSpace($line)) { + try { + $obj = $line | ConvertFrom-Json + $indented = $obj | ConvertTo-Json -Depth 100 + if (-not $isFirstJsonLine) { $logWriter.WriteLine(",") } + $logWriter.Write($indented) + $isFirstJsonLine = $false + + # session_id rides on most events and is stable across resumes; capture the latest. + if ($obj.session_id) { $sessionId = $obj.session_id } + + # Final result event carries the model's final text + status. + if ($obj.type -eq 'result') { + if ($null -ne $obj.result) { $resultText = [string]$obj.result } + if ($obj.subtype) { $resultSubtype = [string]$obj.subtype } + if ($null -ne $obj.is_error) { $resultIsError = [bool]$obj.is_error } + } + + # Fallback for stream-json schema uncertainty: remember the last assistant text block. + if ($obj.type -eq 'assistant' -and $obj.message -and $obj.message.content) { + foreach ($block in $obj.message.content) { + if ($block.type -eq 'text' -and $block.text) { $lastAssistantText = [string]$block.text } + } + } + } catch { + # Non-JSON line - write as raw string + if (-not $isFirstJsonLine) { $logWriter.WriteLine(",") } + $logWriter.Write("`"$($line -replace '\\','\\\\' -replace '"','\"')`"") + $isFirstJsonLine = $false + } + $logWriter.Flush() + } + ConvertFrom-ClaudeJsonLine -Line $line + } + + $stderr = $process.StandardError.ReadToEnd() + if ($stderr) { Write-Host (Sanitize-ClaudeOutput $stderr) -ForegroundColor Red } + + $process.WaitForExit() + $exitCode = $process.ExitCode + + $logWriter.WriteLine() + $logWriter.WriteLine("]") + $logWriter.Close() + + # Detect the completion sentinel in the model's final message (result text, else last assistant text). + $scanText = if ($resultText) { $resultText } else { $lastAssistantText } + $sentinel = $null + if ($scanText) { + if ($scanText -match '') { $sentinel = 'done' } + elseif ($scanText -match '') { $sentinel = 'blocked' } + } + + return @{ + ExitCode = $exitCode + SessionId = $sessionId + Sentinel = $sentinel + ResultIsError = $resultIsError + ResultSubtype = $resultSubtype + + # Returned so the resume loop can tell "the model ran and did nothing" from "the model could not run + # at all". Those look identical from outside the process and must not be treated the same way. + ResultText = $scanText + } +} + +# Whether an iteration ended because the API could not serve it, rather than because the model did nothing +# useful. A 429, a 5xx or an explicit overload is a condition to wait out, not evidence of a stuck loop: the +# turn produced nothing because it never got to run. +function Test-TransientApiFailure { + param([string]$Text) + + if ([string]::IsNullOrEmpty($Text)) { return $false } + + return ($Text -match 'API Error:\s*(429|5\d\d)') -or + ($Text -match 'overloaded_error') -or + ($Text -match 'rate_limit_error') -or + ($Text -match 'Service Unavailable') +} + +# What counts as progress, as one comparable string. +# +# GIT ALONE IS NOT ENOUGH, and assuming it was cost a scheduled run two nights. A commit is the right signal +# for an agent whose job is to change code and the wrong one for an agent whose job is anything else: the CEIP +# triage build writes rows to a database and files under `artifacts`, never a commit, so every iteration of a +# healthy run reported "no progress" and the run was stopped after two of them. A build whose work lands +# somewhere else says where, with -ProgressPath. +function Get-ProgressFingerprint { + param([string[]]$Repos, [string[]]$Paths, [string]$ExcludePath) + + $parts = @() + + $heads = Get-RepoHeads -Repos $Repos + + foreach ($r in ($heads.Keys | Sort-Object)) { + $parts += "$r=$($heads[$r])" + } + + foreach ($p in $Paths) { + try { + if (-not (Test-Path $p)) { $parts += "$p=absent"; continue } + + # Count, newest write and total size. Between them they catch a file added, a file rewritten in + # place and a file truncated, which is every way a turn leaves a mark on a directory. + $files = @(Get-ChildItem -Path $p -Recurse -File -Force -ErrorAction SilentlyContinue) + + # Never count our own output. This loop writes a fresh transcript under artifacts\logs on every + # iteration, so a build watching `artifacts` would see a change every time and the guard would + # silently never fire again, which is worse than the false negative it is here to fix. + if ($ExcludePath) { + $files = @($files | Where-Object { -not $_.FullName.StartsWith($ExcludePath, [StringComparison]::OrdinalIgnoreCase) }) + } + $newest = ($files | Measure-Object -Property LastWriteTimeUtc -Maximum).Maximum + $bytes = ($files | Measure-Object -Property Length -Sum).Sum + $parts += "$p=$($files.Count):$($newest.Ticks):$bytes" + } + catch { + $parts += "$p=error" + } + } + + return ($parts -join '|') +} + +# Discover git repos to watch for progress (handles both the source-dependencies and sibling layouts). +function Get-GitRepos { + param([string]$RepoRoot) + + $candidates = New-Object System.Collections.Generic.List[string] + $candidates.Add($RepoRoot) + + $srcDeps = Join-Path $RepoRoot "source-dependencies" + if (Test-Path $srcDeps) { + Get-ChildItem -Path $srcDeps -Directory -ErrorAction SilentlyContinue | ForEach-Object { $candidates.Add($_.FullName) } + } + + $parent = Split-Path $RepoRoot -Parent + if ($parent -and (Test-Path $parent)) { + Get-ChildItem -Path $parent -Directory -ErrorAction SilentlyContinue | ForEach-Object { $candidates.Add($_.FullName) } + } + + $repos = @{} + foreach ($c in $candidates) { + if (Test-Path (Join-Path $c ".git")) { $repos[$c] = $true } + } + return $repos.Keys +} + +# Snapshot HEAD of each watched repo. A HEAD that advances == the model committed == progress. +function Get-RepoHeads { + param([string[]]$Repos) + + $heads = @{} + foreach ($r in $Repos) { + try { + $sha = (& git -C $r rev-parse HEAD 2>$null) + if ($LASTEXITCODE -eq 0 -and $sha) { $heads[$r] = $sha.Trim() } + } catch { } + } + return $heads +} + +# Configure MCP approval server if port is specified +$mcpConfigArg = "" +if ($McpPort -gt 0) +{ + # On Windows containers, host.docker.internal doesn't resolve. + # Use the default gateway IP which points to the host. + $hostIp = (Get-NetRoute -DestinationPrefix '0.0.0.0/0' | Select-Object -First 1).NextHop + if ([string]::IsNullOrEmpty($hostIp)) + { + Write-Error "Could not determine host IP from default gateway." + exit 1 + } + Write-Host "Host IP (gateway): $hostIp" -ForegroundColor Cyan + + # Use HTTP Streamable transport - no authentication needed (server binds to localhost) + $mcpUrl = "http://${hostIp}:$McpPort" + Write-Host "Configuring MCP approval server at $mcpUrl" -ForegroundColor Cyan + + # Create temporary MCP config file (no authentication header - server binds to localhost only) + $mcpConfigPath = "$env:TEMP\mcp-config.json" + $mcpConfig = @{ + 'mcpServers' = @{ + 'host-approval' = @{ + 'type' = 'http' + 'url' = $mcpUrl + } + } + } + $mcpConfig | ConvertTo-Json -Depth 10 | Set-Content $mcpConfigPath -Encoding UTF8 + $mcpConfigArg = "--mcp-config `"$mcpConfigPath`"" + Write-Host "MCP config file created: $mcpConfigPath" -ForegroundColor Green +} + +Write-Host "Starting Claude CLI..." -ForegroundColor Green + +# Run Claude +if ($Prompt) +{ + # Write prompt to a temporary file to avoid command line length limits + $promptFile = "$env:TEMP\claude-prompt-$([System.Guid]::NewGuid().ToString('N').Substring(0, 8)).txt" + $Prompt | Set-Content -Path $promptFile -Encoding UTF8 -NoNewline + Write-Host "Running Claude with prompt from file: $promptFile" -ForegroundColor Cyan + + # Tag TeamCity build with the prompt + if ($env:IS_TEAMCITY_AGENT -eq "true" -or $env:IS_TEAMCITY_AGENT -eq "1") { + # Escape special characters for TeamCity service message format + $tagValue = $Prompt -replace '\|','||' -replace "'","|'" -replace '\[','|[' -replace '\]','|]' -replace "`n",'|n' -replace "`r",'|r' + # Truncate to avoid excessively long tags + if ($tagValue.Length -gt 200) { $tagValue = $tagValue.Substring(0, 200) + "..." } + Write-Host "##teamcity[addBuildTag '$tagValue']" + } + + # Stream JSON output for human-readable real-time monitoring. + # In headless `-p` mode the process exits at the `result` event, so a scheduled wakeup / + # cron / remote trigger can never fire and any work deferred to it is abandoned. We disallow + # those, AND -- because the model can still simply END its turn mid-build -- the resume loop + # below re-invokes `claude --resume` whenever a turn ends without a completion sentinel. + # Do NOT disallow Monitor or run_in_background -- those are the in-turn wait mechanisms long + # builds depend on. + $disallowedTools = "ScheduleWakeup CronCreate CronDelete CronList RemoteTrigger" + $commonArgs = "--output-format stream-json --verbose --model $Model --dangerously-skip-permissions --disallowedTools `"$disallowedTools`" $mcpConfigArg" + + # Resolve the Claude CLI launcher: npm ships claude.cmd on Windows, the native installer ships + # claude.exe, and on Linux/macOS the binary is plain "claude". Get-Command honors PATHEXT so + # asking for "claude" without an extension finds whichever variant is on PATH. + $claudeCommand = Get-Command claude.cmd -ErrorAction SilentlyContinue + if (-not $claudeCommand) { $claudeCommand = Get-Command claude.exe -ErrorAction SilentlyContinue } + if (-not $claudeCommand) { $claudeCommand = Get-Command claude -ErrorAction SilentlyContinue } + if (-not $claudeCommand) { + Write-Error "Claude CLI not found on PATH. Install it via 'npm i -g @anthropic-ai/claude-code' or the native installer." + exit 1 + } + $script:ClaudeExe = $claudeCommand.Source + Write-Host "Using Claude executable: $script:ClaudeExe" -ForegroundColor Cyan + + $promptContent = Get-Content -Path $promptFile -Raw + + $repoRoot = (Resolve-Path "$PSScriptRoot\..").Path + $logDir = Join-Path $repoRoot "artifacts\logs" + New-Item -ItemType Directory -Path $logDir -Force | Out-Null + + # Resume-loop guards (env-overridable) so a stuck model cannot loop forever. + $maxIterations = if ($env:CLAUDE_MAX_ITERATIONS) { [int]$env:CLAUDE_MAX_ITERATIONS } else { 8 } + $maxMinutes = if ($env:CLAUDE_MAX_MINUTES) { [int]$env:CLAUDE_MAX_MINUTES } else { 120 } + $maxNoProgress = 2 + # Floor before any "give up" guard (no-progress / max-iterations) may fire: a fast-exiting or + # crashing claude must be retried for at least this long before we conclude it is truly stuck. + $minMinutes = if ($env:CLAUDE_MIN_MINUTES) { [int]$env:CLAUDE_MIN_MINUTES } else { 10 } + # Pause before each resume -- claude exited for a reason (rate limit, transient error, etc.), + # so give that condition a moment to clear instead of hammering an immediate retry. + $retryDelaySeconds = if ($env:CLAUDE_RETRY_DELAY_SECONDS) { [int]$env:CLAUDE_RETRY_DELAY_SECONDS } else { 30 } + + # How many consecutive iterations may die on a transient API failure before the run gives up. Higher than + # the no-progress cap on purpose: an outage is not the model's fault and is usually over in minutes, and + # the wall-clock budget bounds the whole thing anyway. Two 529s in a row ended two real runs. + $maxTransient = if ($env:CLAUDE_MAX_TRANSIENT_FAILURES) { [int]$env:CLAUDE_MAX_TRANSIENT_FAILURES } else { 5 } + + $gitRepos = @(Get-GitRepos -RepoRoot $repoRoot) + + $watchedPaths = @() + + if ($ProgressPath) { $watchedPaths += $ProgressPath } + + if ($env:CLAUDE_PROGRESS_PATHS) { $watchedPaths += ($env:CLAUDE_PROGRESS_PATHS -split '[;,]') } + + $watchedPaths = @( + $watchedPaths | + ForEach-Object { $_.Trim() } | + Where-Object { $_ } | + ForEach-Object { if ([System.IO.Path]::IsPathRooted($_)) { $_ } else { Join-Path $repoRoot $_ } } | + Select-Object -Unique ) + + if ($watchedPaths.Count -gt 0) { + Write-Host "Monitoring $($gitRepos.Count) git repo(s) and $($watchedPaths.Count) path(s) for progress between iterations." -ForegroundColor Cyan + $watchedPaths | ForEach-Object { Write-Host " $_" -ForegroundColor Gray } + } + else { + Write-Host "Monitoring $($gitRepos.Count) git repo(s) for progress between iterations." -ForegroundColor Cyan + } + + $startTime = Get-Date + $sessionId = $null + $iteration = 0 + $noProgressStreak = 0 + $transientStreak = 0 + $finalExitCode = 1 + $stopReason = "unknown" + + while ($true) { + $iteration++ + $elapsedMin = [int]((Get-Date) - $startTime).TotalMinutes + $remainingMin = [Math]::Max(0, $maxMinutes - $elapsedMin) + + Write-Host "" + Write-Host "=== Claude iteration $iteration (elapsed ${elapsedMin}m, ~${remainingMin}m budget left) ===" -ForegroundColor Magenta + + # Snapshot before the turn so we can detect whether it made any progress. + $progressBefore = Get-ProgressFingerprint -Repos $gitRepos -Paths $watchedPaths -ExcludePath $logDir + + $timestamp = (Get-Date).ToString("yyyy-MM-dd-HHmmss") + $logFile = Join-Path $logDir "claude-$timestamp.log.json" + + if ($iteration -eq 1) { + # First turn: the original issue prompt, fresh session. + $claudeArgs = "-p $commonArgs" + $stdinContent = $promptContent + } + else { + # Resume turn: re-enter the SAME session and nudge the model to continue to completion. + $stdinContent = @" +Your previous turn ended without a completion sentinel, so your work is presumed incomplete. Resume exactly where you left off, following CLAUDE.md instructions and phases strictly. You are running headless: never wait for a notification, schedule a wakeup, or defer work across turns -- keep working WITHIN this turn and poll background builds until they finish. About $remainingMin minutes of budget remain. End your run ONLY by emitting the literal token (all CLAUDE.md phases complete and PRs ready) or (genuinely blocked after at least 5 distinct attempts and a blocker comment posted to the issue). +"@ + $claudeArgs = "--resume $sessionId -p $commonArgs" + } + + $result = Invoke-ClaudeOnce -Arguments $claudeArgs -StdinContent $stdinContent -LogFile $logFile + Write-Host "Claude output log: $logFile" -ForegroundColor Green + Write-Host "Iteration $iteration exited with code $($result.ExitCode); sentinel='$($result.Sentinel)'; session=$($result.SessionId)" -ForegroundColor Cyan + + if ($result.SessionId) { $sessionId = $result.SessionId } + + # Terminal: model emitted an explicit completion sentinel. + if ($result.Sentinel -eq 'done') { $finalExitCode = 0; $stopReason = "done"; break } + if ($result.Sentinel -eq 'blocked') { $finalExitCode = 0; $stopReason = "blocked"; break } + + # Cannot resume without a session id (e.g. claude crashed before emitting one). + if (-not $sessionId) { + Write-Host "No session id captured; cannot resume." -ForegroundColor Red + $finalExitCode = $result.ExitCode; $stopReason = "no-session-id"; break + } + + # Guard: wall-clock budget. + $elapsedMin = [int]((Get-Date) - $startTime).TotalMinutes + if ($elapsedMin -ge $maxMinutes) { + Write-Host "Wall-clock budget of ${maxMinutes}m exhausted." -ForegroundColor Red + $finalExitCode = 1; $stopReason = "budget-exhausted"; break + } + + # Guard: max iterations. Held off until the minimum runtime floor so a burst of fast + # exits cannot exhaust the iteration budget within the first few minutes. + if ($iteration -ge $maxIterations -and $elapsedMin -ge $minMinutes) { + Write-Host "Reached max iterations ($maxIterations) after ${elapsedMin}m." -ForegroundColor Red + $finalExitCode = 1; $stopReason = "max-iterations"; break + } + + # Guard: transient API failure. An iteration the API refused to serve says nothing about whether the + # model is stuck, so it is a retry rather than a strike, and it has its own budget. Checked BEFORE the + # no-progress guard, since such a turn is guaranteed to have produced no work. + if (Test-TransientApiFailure -Text $result.ResultText) { + $transientStreak++ + Write-Host "Iteration $iteration was refused by the API (transient failure $transientStreak/$maxTransient); it does not count as a lack of progress." -ForegroundColor Yellow + + if ($transientStreak -ge $maxTransient) { + Write-Host "The API refused $maxTransient consecutive iterations after ${elapsedMin}m; giving up on this run." -ForegroundColor Red + $finalExitCode = 1; $stopReason = "api-unavailable"; break + } + } + else { + $transientStreak = 0 + + # Guard: no-progress. Nothing the build declared as a place where work lands has changed. + $progressAfter = Get-ProgressFingerprint -Repos $gitRepos -Paths $watchedPaths -ExcludePath $logDir + $madeProgress = $progressBefore -ne $progressAfter + + if ($madeProgress) { $noProgressStreak = 0 } else { $noProgressStreak++ } + Write-Host "Progress this iteration: $madeProgress (no-progress streak: $noProgressStreak/$maxNoProgress)" -ForegroundColor Cyan + + # Held off until the minimum runtime floor: a model that exits fast without committing + # (e.g. repeated crashes) still gets at least $minMinutes of retries before we give up. + if ($noProgressStreak -ge $maxNoProgress -and $elapsedMin -ge $minMinutes) { + Write-Host "No progress for $maxNoProgress consecutive iterations after ${elapsedMin}m; stopping to avoid a stuck loop." -ForegroundColor Red + $finalExitCode = 1; $stopReason = "stuck-no-progress"; break + } + } + + # Pause before resuming -- claude exited for a reason, so let any transient condition clear. + if ($retryDelaySeconds -gt 0) { + Write-Host "Waiting ${retryDelaySeconds}s before resuming..." -ForegroundColor DarkGray + Start-Sleep -Seconds $retryDelaySeconds + } + Write-Host "No completion sentinel -- resuming Claude session $sessionId." -ForegroundColor Yellow + } + + # Clean up prompt file + Remove-Item $promptFile -ErrorAction SilentlyContinue + + $color = if ($finalExitCode -eq 0) { "Green" } else { "Red" } + Write-Host "Claude run finished: reason=$stopReason, iterations=$iteration, exitCode=$finalExitCode" -ForegroundColor $color + exit $finalExitCode +} +else +{ + Write-Host "Running Claude in interactive mode" -ForegroundColor Cyan + $cmd = "claude --model $Model --dangerously-skip-permissions $mcpConfigArg" + Invoke-Expression $cmd + exit $LASTEXITCODE +} diff --git a/eng/RunDockerTests.ps1 b/eng/RunDockerTests.ps1 new file mode 100644 index 0000000..7393bfd --- /dev/null +++ b/eng/RunDockerTests.ps1 @@ -0,0 +1,688 @@ +# The original of this file is in /src/PostSharp.Engineering.BuildTools/Resources/RunDockerTests.ps1. +# You can generate this file using `./Build.ps1 generate-scripts`. +# Documentation: https://raw.githubusercontent.com/postsharp/PostSharp.Engineering/HEAD/doc/docker-tests.md + +<# +.SYNOPSIS + Runs the Docker-based tests that apply to one platform, and reports each of them to TeamCity. + +.DESCRIPTION + A Docker-based test is a test that needs a container of its own. Each test is a directory containing a + manifest (test.psd1) and an entry point (RunTest.ps1). This script discovers those directories, selects + the tests whose manifest lists the current platform, runs them one at a time, and reports the result of + each one. + + The only requirements on the host are PowerShell 7.5 and a container engine whose operating system and + processor architecture match the platform. The tool chain under test lives in the test's own image, so + this script never needs the .NET SDK, MSBuild or Visual Studio. + + A test reports its own result through its exit code alone. This script owns the TeamCity protocol, so a + test stays runnable by hand. + +.PARAMETER Path + The directory containing the test directories, relative to this script. Defaults to the value the product + declares, which is what generate-scripts wrote into this file. + +.PARAMETER Platform + The platform to run: win-x64, win-arm64, linux-x64 or linux-arm64. Defaults to the platform of the + container engine this host is running. + +.PARAMETER Test + Runs only the test of this name. Without it, every test that applies to the platform is run. + +.NOTES + A test reports its outcome by its exit code: 0 passed, 4 skipped, anything else failed. The skip code is + for a scenario the test finds it cannot reproduce on this host, which is different from the manifest's Skip: + that one is known in advance, this one only once the test has looked. A skipping test should write a line + beginning with 'SKIPPED:', which becomes the reason reported. + +.PARAMETER NoTeamCity + Writes plain text instead of TeamCity service messages. Implied when TEAMCITY_VERSION is not set. + +.EXAMPLE + ./RunDockerTests.ps1 -Platform linux-x64 + +.EXAMPLE + ./RunDockerTests.ps1 -Platform win-x64 -Test Issue15-AssetsFileV4 +#> + +[CmdletBinding(PositionalBinding = $false)] +param( + [ValidateSet('win-x64', 'win-arm64', 'linux-x64', 'linux-arm64')] + [string]$Platform, + [string]$Path, # Defaults to $DockerTestsPath below. + [string]$Test, + [switch]$NoTeamCity +) + +# Require PowerShell 7.5 or higher (run with pwsh, not powershell) +if ($PSVersionTable.PSVersion -lt [Version]'7.5') +{ + Write-Error "This script requires PowerShell 7.5 or higher (run with 'pwsh', not 'powershell'). Current version: $( $PSVersionTable.PSVersion )" + exit 1 +} + +#### +# These settings are replaced by the generate-scripts command. +# +# Where the tests are is a fact about this repository, not a choice a caller makes. This script is generated into +# the repository root, so it is fixed relative to it, and a build configuration that had to pass it would be one +# more place for it to drift. +# +# Where what the tests consume is, is not here at all: a test resolves that for itself, from where it lives. It is +# a fact about the product, and this script has no opinion about it. +$DockerTestsPath = 'tests/docker' +$EngPath = 'eng' +#### + +if (-not $Path) +{ + $Path = $DockerTestsPath +} + +$DefaultTimeoutSeconds = 900 + +# The exit code by which a test reports that it decided, at run time, that its scenario cannot occur on this +# host, and that it therefore tested nothing. The manifest's Skip covers what is known before the test runs; +# this covers what is only discoverable once it has looked -- an SDK that ships a pack the scenario needs +# absent, a case-insensitive file system, a kernel without the facility under test. Reporting those as failures +# would train people to ignore red, and reporting them as passes would claim coverage that does not exist. +$SkipExitCode = 4 + +# TeamCity reads a service message up to the first unescaped delimiter, and Docker output is full of brackets, +# so an unescaped value silently truncates or corrupts the report. The vertical bar is replaced first: doing it +# later would double the bars introduced by the other replacements. +function ConvertTo-TeamCityValue([string]$value) +{ + if ($null -eq $value) + { + return '' + } + + $escaped = $value.Replace('|', '||') + $escaped = $escaped.Replace("'", "|'") + $escaped = $escaped.Replace('[', '|[') + $escaped = $escaped.Replace(']', '|]') + $escaped = $escaped.Replace("`r", '|r') + $escaped = $escaped.Replace("`n", '|n') + + return $escaped +} + +function Write-ServiceMessage([string]$name, [hashtable]$attributes) +{ + if ($NoTeamCity) + { + return + } + + $pairs = $attributes.Keys | Sort-Object | ForEach-Object { + "$_='$( ConvertTo-TeamCityValue $attributes[$_] )'" + } + + Write-Host "##teamcity[$name $( $pairs -join ' ' )]" +} + +# The operating system a platform identifier asks for, in the spelling DockerBuild.ps1 -OS uses. +function Get-RequestedOs([string]$platform) +{ + return $(if ($platform -like 'linux-*') { 'linux' } else { 'windows' }) +} + +# Asks the engine that would run the given operating system for its own platform. Which engine that is depends +# on the host: a build agent has one, while a Windows development machine runs Windows containers on Docker +# Desktop and Linux containers on the Docker engine inside WSL. The engine is asked rather than the host +# because only the engine knows what it can actually run. +function Get-EnginePlatform([string]$requestedOs) +{ + $useWsl = $IsWindows -and $requestedOs -eq 'linux' -and -not $env:IS_TEAMCITY_AGENT + + if ($useWsl) + { + if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) + { + Write-Host "Linux containers on a Windows development machine run on the Docker engine inside WSL, and wsl.exe was not found." -ForegroundColor Red + Write-Host "Install it with 'wsl --install', then install PowerShell 7 and a Docker engine inside the distribution." -ForegroundColor Red + exit 1 + } + + $engineOs = (& wsl.exe -- docker version --format '{{.Server.Os}}' 2>&1 | Out-String).Trim() + $engineArchRaw = (& wsl.exe -- docker version --format '{{.Server.Arch}}' 2>&1 | Out-String).Trim() + } + else + { + $engineOs = (docker version --format '{{.Server.Os}}' 2>&1 | Out-String).Trim() + + if ($LASTEXITCODE -ne 0) + { + Write-Host "No container engine is available on this host: $engineOs" -ForegroundColor Red + Write-Host "A Docker test host needs PowerShell 7.5 and a container engine, and nothing else." -ForegroundColor Red + exit 1 + } + + $engineArchRaw = (docker version --format '{{.Server.Arch}}' 2>&1 | Out-String).Trim() + } + + $os = switch ($engineOs) + { + 'windows' { 'win' } + 'linux' { 'linux' } + default { $null } + } + + $arch = switch ($engineArchRaw) + { + 'amd64' { 'x64' } + 'x86_64' { 'x64' } + 'arm64' { 'arm64' } + 'aarch64' { 'arm64' } + default { $null } + } + + if (-not $os -or -not $arch) + { + $where = if ($useWsl) { 'inside WSL' } else { 'on this host' } + Write-Host "The container engine $where reports an unsupported platform: os='$engineOs', arch='$engineArchRaw'." -ForegroundColor Red + exit 1 + } + + return "$os-$arch" +} + +function Read-TestManifest([string]$manifestPath) +{ + try + { + $manifest = Import-PowerShellDataFile -LiteralPath $manifestPath -ErrorAction Stop + } + catch + { + return @{ Error = "The manifest cannot be read: $( $_.Exception.Message )" } + } + + if (-not $manifest.Platforms) + { + return @{ Error = 'The manifest does not declare Platforms.' } + } + + $timeout = if ($manifest.TimeoutSeconds) { [int]$manifest.TimeoutSeconds } else { $DefaultTimeoutSeconds } + + return @{ + Platforms = @($manifest.Platforms) + TimeoutSeconds = $timeout + Skip = $manifest.Skip + } +} + +# Runs the product's own preparation, once, before any test. +# +# What a suite needs before it can run is not the same in every repository: packages may arrive as an archive +# that has to be expanded, a fixture may have to be materialised, a tool may have to be fetched. That belongs to +# the product, not here, so the launcher looks for one script at a known place and runs it if it is there. +# +# Once, not per test. A test's entry point runs for each test, so anything expensive done there is done again +# for every one of them. +function Invoke-ProductPreparation([string]$repositoryRoot) +{ + $script = Join-Path $repositoryRoot ( Join-Path $EngPath 'PrepareDockerTests.ps1' ) + + if (-not (Test-Path -LiteralPath $script)) + { + return + } + + Write-Host "Preparing the suite with '$script'." -ForegroundColor Green + + $global:LASTEXITCODE = 0 + + try + { + & $script + } + catch + { + # Without this the run continued: an exception from the script left $LASTEXITCODE at whatever the last + # native command had set, which is 0 when there was none, so the check below passed and the suite went on + # to find no tests and report success. + throw "'$script' failed: $( $_.Exception.Message )" + } + + if ($LASTEXITCODE -ne 0) + { + throw "'$script' failed with exit code $LASTEXITCODE." + } +} + +# Fails unless the repository root has a NuGet configuration, which is what lets a test resolve the product from +# the packages the build produced rather than from nuget.org. +# +# That distinction is the whole point. A Docker test consumes the product through a PackageReference, and the +# version it asks for is often one that has been released, so nuget.org can satisfy it. Without a source for the +# local packages and a packageSourceMapping sending the product's packages there, the restore would quietly +# succeed against the public package and the test would report a pass having verified binaries that nobody just +# built. A test that silently checks the wrong thing is worse than one that fails, so a missing configuration is +# fatal rather than tolerated. +# +# This only checks, it does not write. Putting the file there is the harness's job and it already does it: a +# configuration that has a build snapshot dependency is generated with a CopyNuGetConfig step that copies +# nuget.restored.config -- published beside the packages -- to the root before this script runs. On a prepared +# developer machine the root nuget.config that Build.ps1 writes is already there. Copying it again here would +# only repeat what one of those two has done. +function Assert-NuGetConfiguration([string]$repositoryRoot) +{ + $configuration = Join-Path $repositoryRoot 'nuget.config' + + if (-not (Test-Path -LiteralPath $configuration)) + { + throw "'$configuration' does not exist, so the packages these tests consume cannot be located and the restore would fall back to nuget.org. Prepare the repository, or copy artifacts/publish/private/nuget.restored.config to the root, before running the Docker tests." + } +} + +# Kills a process and everything it started. Stop-Process alone does not: it terminates the one process, and the +# children it spawned are reparented rather than killed. +function Stop-ProcessTree([int]$processId) +{ + # Win32_Process is the only way to walk the tree on Windows. On Linux and macOS pgrep does the same job, and + # the container cleanup below is what actually matters there in any case. + $children = if ($IsWindows) + { + @( Get-CimInstance Win32_Process -Filter "ParentProcessId = $processId" -ErrorAction SilentlyContinue | + ForEach-Object { [int]$_.ProcessId } ) + } + else + { + @( & pgrep -P $processId 2>$null | ForEach-Object { [int]$_ } ) + } + + foreach ($child in $children) + { + Stop-ProcessTree $child + } + + Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue +} + +# Force-removes every container labelled with this test run. A container outlives the process that started it, +# so a timeout that killed only the process would leave it running. +function Remove-TestContainers([string]$runId) +{ + $containers = @(& docker ps --all --quiet --filter "label=postsharp.test-run=$runId" 2>$null) + + if ($containers.Count -eq 0) + { + return + } + + Write-Host "Removing $( $containers.Count ) container(s) left by the timed-out test." -ForegroundColor Yellow + & docker rm --force @containers 2>&1 | Out-Null +} + +# Copies whatever has been appended to the redirected output files since the last call, so that a running test +# is visible while it runs. FileShare.ReadWrite is required: the child process still holds these files open, +# and opening them any other way would fail. +function Copy-NewOutput([hashtable]$positions) +{ + foreach ($file in @($positions.Keys)) + { + if (-not (Test-Path -LiteralPath $file)) + { + continue + } + + $stream = $null + + try + { + $stream = [System.IO.File]::Open($file, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) + + if ($stream.Length -gt $positions[$file]) + { + $stream.Position = $positions[$file] + $reader = New-Object System.IO.StreamReader($stream) + $text = $reader.ReadToEnd() + $positions[$file] = $stream.Position + + if ($text) + { + Write-Host $text -NoNewline + } + } + } + catch + { + # A transient sharing failure only delays the output to the next pass, and must never fail the test. + } + finally + { + if ($stream) + { + $stream.Dispose() + } + } + } +} + +# Runs one test to completion and returns its outcome. The output is redirected to files so that the whole of +# it can be attached to the test even when the test is killed on its timeout, and is echoed as it arrives so +# that a long test is not indistinguishable from a hung one. Pulling a Windows base image takes tens of +# minutes, and a silent log for that long is a log nobody can act on. +function Invoke-OneTest([string]$testDirectory, [int]$timeoutSeconds, [string]$platform) +{ + $rootDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "dockertest-$( [System.Guid]::NewGuid().ToString('n') )" + + # The captured output is kept outside the directory handed to the test, so that a test writing into its own + # scratch directory cannot collide with it. + # Only the captured output. A test is given no scratch directory: it runs against the repository, which + # DockerBuild.ps1 mounts into the container, rather than against anything staged for it here. + $logDirectory = Join-Path $rootDirectory 'log' + New-Item -ItemType Directory -Path $logDirectory -Force | Out-Null + + $stdOutFile = Join-Path $logDirectory 'stdout.log' + $stdErrFile = Join-Path $logDirectory 'stderr.log' + + # Every container this test starts is labelled with this, so that a timeout can remove them. Killing the + # test process is not enough: it is waiting on `docker run`, and the container is a child of the engine, not + # of the process tree. Left behind, it goes on holding CPU, memory and the image, and a later test that + # expects an idle machine -- or the same port, or the same mount -- fails for a reason that has nothing to + # do with it. + $runId = [System.Guid]::NewGuid().ToString('n') + $env:POSTSHARP_DOCKER_TEST_RUN_ID = $runId + + try + { + $arguments = @( + '-NonInteractive' + '-NoProfile' + '-File' + (Join-Path $testDirectory 'RunTest.ps1') + '-Platform' + $platform + ) + + # [Environment]::ProcessPath is this pwsh, so the test runs under the same PowerShell that discovered it. + $process = Start-Process -FilePath ([Environment]::ProcessPath) ` + -ArgumentList $arguments ` + -PassThru ` + -NoNewWindow ` + -RedirectStandardOutput $stdOutFile ` + -RedirectStandardError $stdErrFile + + $timedOut = $false + $positions = @{ $stdOutFile = [long]0; $stdErrFile = [long]0 } + $deadline = [DateTime]::UtcNow.AddSeconds($timeoutSeconds) + + while (-not $process.HasExited) + { + if ([DateTime]::UtcNow -gt $deadline) + { + $timedOut = $true + break + } + + Copy-NewOutput $positions + Start-Sleep -Milliseconds 500 + } + + Copy-NewOutput $positions + + if ($timedOut) + { + Write-Host "The test exceeded its timeout of $timeoutSeconds seconds and is being killed." -ForegroundColor Red + + # The whole tree: the test process is a pwsh that started another to run DockerBuild.ps1, and killing + # only the one that was waited on leaves the rest running. + Stop-ProcessTree $process.Id + + $process.WaitForExit(30 * 1000) | Out-Null + + Remove-TestContainers $runId + } + else + { + # The parameterless overload also waits for the redirected streams to be flushed. Without it the + # captured output can be read back short of its last lines. + $process.WaitForExit() + } + + $output = @() + + foreach ($file in @($stdOutFile, $stdErrFile)) + { + if (Test-Path -LiteralPath $file) + { + $content = Get-Content -LiteralPath $file -Raw -ErrorAction SilentlyContinue + + if ($content) + { + $output += $content + } + } + } + + return @{ + TimedOut = $timedOut + ExitCode = if ($timedOut) { -1 } else { $process.ExitCode } + Output = ($output -join "`n") + } + } + finally + { + Remove-Item -LiteralPath $rootDirectory -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item Env:\POSTSHARP_DOCKER_TEST_RUN_ID -ErrorAction SilentlyContinue + } +} + +Push-Location +try +{ + # This script lives in the engineering directory, not at the root, so everything it addresses relative to + # the repository is resolved from the parent. + $repositoryRoot = ( Resolve-Path ( Join-Path $PSScriptRoot '..' ) ).Path + + Set-Location $repositoryRoot + + if (-not $env:TEAMCITY_VERSION) + { + $NoTeamCity = $true + } + + if (-not $Platform) + { + # Without a platform, the host's own engine is the one that answers, which is what a developer running + # the suite with no argument means. No requested operating system means no hop into WSL. + $Platform = Get-EnginePlatform $null + Write-Host "Platform not specified; using the container engine's own platform: $Platform" -ForegroundColor Cyan + } + else + { + $enginePlatform = Get-EnginePlatform ( Get-RequestedOs $Platform ) + + if ($Platform -ne $enginePlatform) + { + # This is the whole point of one configuration per platform. A mismatch on an agent means the build + # was routed to the wrong one, and every test would otherwise fail for the same uninformative + # reason. + Write-Host "The engine that would run the '$Platform' tests reports '$enginePlatform' instead." -ForegroundColor Red + + if ($env:IS_TEAMCITY_AGENT) + { + Write-Host "Check the agent requirements of this build configuration." -ForegroundColor Red + } + + exit 1 + } + } + + if (-not (Test-Path -LiteralPath $Path -PathType Container)) + { + Write-Host "The test directory '$Path' does not exist." -ForegroundColor Red + exit 1 + } + + Invoke-ProductPreparation $repositoryRoot + Assert-NuGetConfiguration $repositoryRoot + +$testDirectories = Get-ChildItem -LiteralPath $Path -Directory | + Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'test.psd1') } | + Sort-Object Name + + if ($Test) + { + $testDirectories = $testDirectories | Where-Object { $_.Name -eq $Test } + + if (-not $testDirectories) + { + Write-Host "There is no test named '$Test' in '$Path'." -ForegroundColor Red + exit 1 + } + } + + if (-not $testDirectories) + { + Write-Host "No test was found in '$Path'. A test is a directory containing test.psd1." -ForegroundColor Yellow + exit 0 + } + + $suiteName = "DockerTests.$Platform" + Write-ServiceMessage 'testSuiteStarted' @{ name = $suiteName } + + $failed = @() + $passed = 0 + $ignored = 0 + + foreach ($testDirectory in $testDirectories) + { + $testName = $testDirectory.Name + $manifest = Read-TestManifest (Join-Path $testDirectory.FullName 'test.psd1') + + Write-ServiceMessage 'testStarted' @{ name = $testName; captureStandardOutput = 'false' } + + $durationMilliseconds = 0 + + # Every test's outcome is decided inside this try, so that one test's failure never ends the run. The + # launcher's own exit code is the aggregate, reported at the end. testFinished is written once, in the + # finally, and therefore always after the testFailed or testIgnored that explains the outcome. + try + { + if ($manifest.Error) + { + Write-Host "$testName : $( $manifest.Error )" -ForegroundColor Red + Write-ServiceMessage 'testFailed' @{ name = $testName; message = $manifest.Error } + $failed += $testName + } + elseif ($manifest.Skip) + { + Write-Host "$testName : skipped -- $( $manifest.Skip )" -ForegroundColor Yellow + Write-ServiceMessage 'testIgnored' @{ name = $testName; message = $manifest.Skip } + $ignored++ + } + elseif ($manifest.Platforms -notcontains $Platform) + { + $reason = "This test declares $( $manifest.Platforms -join ', ' ) and not $Platform." + Write-Host "$testName : not applicable -- $reason" -ForegroundColor DarkGray + Write-ServiceMessage 'testIgnored' @{ name = $testName; message = $reason } + $ignored++ + } + elseif (-not (Test-Path -LiteralPath (Join-Path $testDirectory.FullName 'RunTest.ps1'))) + { + $reason = 'The test directory has no RunTest.ps1.' + Write-Host "$testName : $reason" -ForegroundColor Red + Write-ServiceMessage 'testFailed' @{ name = $testName; message = $reason } + $failed += $testName + } + else + { + Write-Host "Running $testName on $Platform." -ForegroundColor Green + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + $result = Invoke-OneTest $testDirectory.FullName $manifest.TimeoutSeconds $Platform + $stopwatch.Stop() + $durationMilliseconds = [int]$stopwatch.Elapsed.TotalMilliseconds + + if ($result.Output) + { + # Not written to the console again: it was echoed as the test produced it. Only the service + # message is sent, so that the whole of the output is attached to the test in the report. + Write-ServiceMessage 'testStdOut' @{ name = $testName; out = $result.Output } + } + + if ($result.TimedOut) + { + $message = "The test exceeded its timeout of $( $manifest.TimeoutSeconds ) seconds." + Write-ServiceMessage 'testFailed' @{ name = $testName; message = $message } + $failed += $testName + } + elseif ($result.ExitCode -eq $SkipExitCode) + { + # The reason is taken from the last line the test wrote beginning with SKIPPED:, which is how + # these tests already explain themselves. Without one the exit code still counts, because the + # decision belongs to the test; only the explanation is missing. + $skipLine = @( $result.Output -split "`n" | Where-Object { $_ -match '^\s*SKIPPED:' } ) | + Select-Object -Last 1 + + $message = if ($skipLine -match '^\s*SKIPPED:\s*(.+?)\s*$') + { + $Matches[1] + } + else + { + "The test reported exit code $SkipExitCode, meaning its scenario cannot occur on this host." + } + + Write-Host "$testName : skipped -- $message" -ForegroundColor Yellow + Write-ServiceMessage 'testIgnored' @{ name = $testName; message = $message } + $ignored++ + } + elseif ($result.ExitCode -ne 0) + { + $message = "The test failed with exit code $( $result.ExitCode )." + Write-ServiceMessage 'testFailed' @{ name = $testName; message = $message } + $failed += $testName + } + else + { + $passed++ + } + } + } + catch + { + # The harness itself failed, which is a failure of this test and not of the run. + $message = "The test harness failed: $( $_.Exception.Message )" + Write-Host $message -ForegroundColor Red + Write-ServiceMessage 'testFailed' @{ name = $testName; message = $message } + $failed += $testName + } + finally + { + Write-ServiceMessage 'testFinished' @{ name = $testName; duration = [string]$durationMilliseconds } + } + } + + Write-ServiceMessage 'testSuiteFinished' @{ name = $suiteName } + + Write-Host "" + Write-Host "$Platform : $passed passed, $( $failed.Count ) failed, $ignored ignored." -ForegroundColor Cyan + + # Nothing ran at all. That is not a pass: a suite reporting success without executing a test is worse than one + # that fails, because nobody looks at it again. It happens when the discovery found tests and every one of them + # was skipped by a fault rather than by a manifest, or when preparation left the suite unable to start. + if ($passed -eq 0 -and $failed.Count -eq 0 -and $ignored -eq 0 -and $testDirectories.Count -gt 0) + { + Write-Host "$( $testDirectories.Count ) test(s) were found and none of them ran." -ForegroundColor Red + Write-ServiceMessage 'buildProblem' @{ description = "$( $testDirectories.Count ) Docker test(s) were found and none of them ran." } + + exit 1 + } + + if ($failed.Count -gt 0) + { + Write-Host "Failed: $( $failed -join ', ' )" -ForegroundColor Red + exit 1 + } + + exit 0 +} +finally +{ + Pop-Location +} diff --git a/eng/Versions.props b/eng/Versions.props new file mode 100644 index 0000000..6d1ed1a --- /dev/null +++ b/eng/Versions.props @@ -0,0 +1,28 @@ + + + + + + + + + $(MainVersion)$(PackageVersionSuffix) + $(MainVersion) + + + + + + + + + + $(BackstageLicenseServerAssemblyVersion) + $(BackstageLicenseServerVersion) + + + + + + diff --git a/eng/docker/build.Dockerfile b/eng/docker/build.Dockerfile new file mode 100644 index 0000000..9cdaff2 --- /dev/null +++ b/eng/docker/build.Dockerfile @@ -0,0 +1,75 @@ +# escape=` + +# This file is auto-generated by PostSharp.Engineering. + +ARG WINDOWS_VERSION=ltsc2025 +ARG OS_IMAGE_REPOSITORY=mcr.microsoft.com/windows/servercore +FROM ${OS_IMAGE_REPOSITORY}:${WINDOWS_VERSION} + +# The initial shell is Windows PowerShell (use full path to avoid HCS issues) +SHELL ["C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", "-Command"] + +# Prepare environment +ENV PSExecutionPolicyPreference=Bypass +ENV POWERSHELL_UPDATECHECK=Off +ENV TEMP=C:\Temp +ENV TMP=C:\Temp +ENV RUNNING_IN_DOCKER=TRUE + +# Set locale for consistent behavior regardless of host locale +ENV LANG=C.UTF-8 +ENV LC_ALL=C.UTF-8 +ENV DOTNET_CLI_UI_LANGUAGE=en +ENV VSLANG=1033 + +# Set base PATH explicitly to avoid issues with expansion +ENV PATH="C:\Windows\System32;C:\Windows;C:\Windows\System32\Wbem;C:\Windows\System32\WindowsPowerShell\v1.0" + +# Enable long path support +RUN Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -Value 1 + + + +# Install Git +RUN Invoke-WebRequest -Uri https://github.com/git-for-windows/git/releases/download/v2.50.0.windows.1/PortableGit-2.50.0-64-bit.7z.exe -OutFile PortableGit.exe; ` + Start-Process -FilePath .\PortableGit.exe -ArgumentList '-o"C:\git"', '-y' -Wait; ` + Remove-Item PortableGit.exe + +# Add git to PATH using ENV directive (persists across shell switches) +ENV PATH="C:\git\cmd;C:\git\bin;C:\git\usr\bin;${PATH}" + +RUN git config --system core.longpaths true; git config --system core.autocrlf false + +# Set CLAUDE_CODE_GIT_BASH_PATH for Claude Code +ENV CLAUDE_CODE_GIT_BASH_PATH=C:\git\bin\bash.exe + + +# Install PowerShell 7 +RUN Invoke-WebRequest -Uri https://github.com/PowerShell/PowerShell/releases/download/v7.5.2/PowerShell-7.5.2-win-x64.msi -OutFile PowerShell.msi; ` + $process = Start-Process msiexec.exe -Wait -PassThru -ArgumentList '/I PowerShell.msi /quiet'; ` + if ($process.ExitCode -ne 0) { exit $process.ExitCode }; ` + Remove-Item PowerShell.msi + +ENV PATH="C:\Program Files\PowerShell\7;${PATH}" + + +# Download .NET Installer +RUN Invoke-WebRequest -Uri https://dot.net/v1/dotnet-install.ps1 -OutFile dotnet-install.ps1 + +# Add .NET to PATH using ENV directive (persists across shell switches) +ENV PATH="C:\Program Files\dotnet;${PATH}" + + +# Install .NET Sdk 10.0.102 +RUN & .\dotnet-install.ps1 -Version 10.0.102 -InstallDir 'C:\Program Files\dotnet' + + +# .NET Dump Tool +RUN dotnet tool install --global dotnet-dump; + +ENV PATH="C:\Users\ContainerAdministrator\.dotnet\tools;${PATH}" + + +# Epilogue +# Configure .NET SDK +ENV DOTNET_NOLOGO=1 diff --git a/eng/docker/claude-pre.Dockerfile b/eng/docker/claude-pre.Dockerfile new file mode 100644 index 0000000..7e5235f --- /dev/null +++ b/eng/docker/claude-pre.Dockerfile @@ -0,0 +1,20 @@ +# escape=` + +# This file is auto-generated by PostSharp.Engineering. + +ARG BASE_IMAGE=build.Dockerfile +FROM ${BASE_IMAGE} + +# The base image's shell is Windows PowerShell (use full path to avoid HCS issues) +SHELL ["C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", "-Command"] + + + +# Install Node.js +RUN Invoke-WebRequest -Uri "https://nodejs.org/dist/v22.0.0/node-v22.0.0-win-x64.zip" -OutFile node.zip; ` + Expand-Archive node.zip -DestinationPath C:\; ` + Rename-Item "C:\node-v22.0.0-win-x64" "C:\nodejs"; ` + Remove-Item node.zip + +ENV NPM_CONFIG_PREFIX=C:\npm +ENV PATH="C:\nodejs;C:\npm;${PATH}" diff --git a/eng/docker/claude.Dockerfile b/eng/docker/claude.Dockerfile new file mode 100644 index 0000000..bccc57b --- /dev/null +++ b/eng/docker/claude.Dockerfile @@ -0,0 +1,39 @@ +# escape=` + +# This file is auto-generated by PostSharp.Engineering. + +ARG BASE_IMAGE=claude-pre.Dockerfile +FROM ${BASE_IMAGE} + +# The base image's shell is Windows PowerShell (use full path to avoid HCS issues) +SHELL ["C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", "-Command"] + + + +# Timestamp +# Cache invalidation layer - changes when -Update is used +COPY .g/update.timestamp C:\docker-context\update.timestamp +RUN Write-Host "PostSharp.Engineering build timestamp: $(Get-Content C:\docker-context\update.timestamp)" + + +# Install Claude CLI +# Set HOME/USERPROFILE so Claude CLI finds credentials during build +ENV HOME=C:\\Users\\ContainerAdministrator +ENV USERPROFILE=C:\\Users\\ContainerAdministrator + +# Install Claude CLI and configure using cmd shell to avoid HCS issues with PowerShell +SHELL ["cmd", "/S", "/C"] +RUN C:\nodejs\npm.cmd install --global @anthropic-ai/claude-code@latest +RUN mkdir C:\Users\ContainerAdministrator\.claude && echo {"hasCompletedOnboarding": true} > C:\Users\ContainerAdministrator\.claude.json && echo {"alwaysThinkingEnabled": true, "spinnerTipsEnabled": false} > C:\Users\ContainerAdministrator\.claude\settings.json + +# Restore PowerShell shell using full path +SHELL ["C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", "-Command"] + + +# Install Claude CLI Add-ins +# Install Claude plugins using cmd shell to avoid HCS issues with PowerShell +SHELL ["cmd", "/S", "/C"] +RUN echo Installing Claude plugins && C:\npm\claude plugin marketplace add https://github.com/metalama/Metalama.AI.Skills && C:\npm\claude plugin marketplace add https://github.com/postsharp-ops/PostSharp.Engineering.AISkills && C:\npm\claude plugin install metalama && C:\npm\claude plugin install metalama-dev && C:\npm\claude plugin install eng + +# Restore PowerShell shell using full path +SHELL ["C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", "-Command"] diff --git a/eng/src/BuildBackstageLicenseServer.csproj b/eng/src/BuildBackstageLicenseServer.csproj new file mode 100644 index 0000000..c4adcaf --- /dev/null +++ b/eng/src/BuildBackstageLicenseServer.csproj @@ -0,0 +1,17 @@ + + + + Exe + net10.0 + latest + enable + + + $(NoWarn);NU1903;NU1904 + + + + + + + diff --git a/eng/src/Directory.Build.props b/eng/src/Directory.Build.props new file mode 100644 index 0000000..5fd85ab --- /dev/null +++ b/eng/src/Directory.Build.props @@ -0,0 +1,3 @@ + + + diff --git a/eng/src/Directory.Build.targets b/eng/src/Directory.Build.targets new file mode 100644 index 0000000..8c119d5 --- /dev/null +++ b/eng/src/Directory.Build.targets @@ -0,0 +1,2 @@ + + diff --git a/eng/src/Program.cs b/eng/src/Program.cs new file mode 100644 index 0000000..0d44dbf --- /dev/null +++ b/eng/src/Program.cs @@ -0,0 +1,82 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using PostSharp.Engineering.BuildTools; +using PostSharp.Engineering.BuildTools.Build; +using PostSharp.Engineering.BuildTools.Build.Model; +using PostSharp.Engineering.BuildTools.Build.Solutions; +using PostSharp.Engineering.BuildTools.ContinuousIntegration; +using PostSharp.Engineering.BuildTools.ContinuousIntegration.Model; +using PostSharp.Engineering.BuildTools.ContinuousIntegration.TeamCity; +using PostSharp.Engineering.BuildTools.Docker; +using BackstageDependencies = PostSharp.Engineering.BuildTools.Dependencies.Definitions.BackstageDependencies.V2027_0; + +var dotNetSdkVersion = BackstageDependencies.Family.PreferredVersions.DotNetSdk.V_10_0; + +var product = new Product( BackstageDependencies.BackstageLicenseServer ) +{ + // The product consumes SharpCrafters.Backstage, whose packages carry a prefix that does not match the + // default source, so the generated nuget.config has to carry the source mapping of the dependency. + GenerateNuGetConfig = true, + + DotNetSdkVersion = new DotNetSdkVersion( dotNetSdkVersion ), + + // The build runs in a container, as the builds of the other products of the family do. The image carries the + // .NET SDK and nothing else: the product is a set of SDK-style projects, and its dependencies come from + // nuget.org and from the artifacts of the build it depends on. + OverriddenBuildAgentRequirements = new ContainerRequirements( ContainerHostKind.Windows ) + { + Components = [new DotNetComponent( dotNetSdkVersion, DotNetComponentKind.Sdk )] + }, + + // The test runs that need a database server. The build itself runs the suite on SQLite, which needs no + // server; these runs exercise the same tests against the two engines that customers deploy, each in a + // container of its own. See Tests/Docker and the section "Running the tests" of README.md. + // + // One configuration runs every test of a platform, so there is one per platform and not one per engine. + // SQL Server runs on amd64 alone, which is why linux-x64 is the only platform declared here. + AdditionalCiBuildConfigurations = DockerTestsAdditionalCiBuildConfiguration.WithCompositeConfiguration( + new DockerTestsAdditionalCiBuildConfiguration( + "DockerTestsLinuxX64", + "Docker Tests (Linux x64)", + DockerTestPlatform.LinuxX64, + + // The directories of this repository are named in lower case, so the tests are under tests/docker + // and not under the default Tests/Docker. The name is compared as it is written on a Linux agent. + "tests/docker" ) + { + // The tests build the product from the sources of the repository, and they need the packages of the + // licensing component, which the artifacts of the debug build carry. + BuildSnapshotDependency = BuildConfiguration.Debug, + + // Each test acquires an image of several hundred megabytes on an agent that meets it for the first + // time, installs a database server, and then builds and runs the suite. + TimeoutInMinutes = 60 + } ), + + // Built rather than packed: the product ships a deployable archive and no NuGet package, so the Pack + // target of every project would be a no-op. + Solutions = + [ + new DotNetSolution( "SharpCrafters.Backstage.LicenseServer.slnx" ) + { + BuildMethod = BuildMethod.Build, CanFormatCode = true + } + ], + + // The deliverable is the archive that an administrator unpacks into an IIS application or runs with + // `dotnet SharpCrafters.Backstage.LicenseServer.dll`. The web project builds it; see the PackAndZip + // target of SharpCrafters.Backstage.LicenseServer.Web.csproj. + // + // Note that none of the default publishers matches it: they publish NuGet packages and Visual Studio + // extensions. A public build therefore produces the archive and uploads nothing, until the upload to + // S3 is added here. + PublicArtifacts = Pattern.Create( "SharpCrafters.Backstage.LicenseServer.$(PackageVersion).zip" ), + + // The archive carries the whole dependency tree of an ASP.NET Core application, and the sign service + // descends into a container and signs every executable it finds. Without this filter it would sign + // MailKit, the Azure libraries and the rest with our certificate, asserting authorship of code that + // is not ours. + SigningFilter = ["**/SharpCrafters.Backstage.LicenseServer*.dll"] +}; + +return new EngineeringApp( product ).Run( args ); diff --git a/eng/style/.editorconfig b/eng/style/.editorconfig new file mode 100644 index 0000000..0fe1fa3 --- /dev/null +++ b/eng/style/.editorconfig @@ -0,0 +1,586 @@ +root = true + + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# File header +file_header_template = Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +# Indentation and spacing +indent_size = 4 +indent_style = space +tab_width = 4 + +# New line preferences +end_of_line = crlf +insert_final_newline = false + +#### .NET Coding Conventions #### + +# Organize usings +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = false + +# this. and Me. preferences +dotnet_style_qualification_for_event = true:warning +dotnet_style_qualification_for_field = true:warning +dotnet_style_qualification_for_method = true:warning +dotnet_style_qualification_for_property = true:warning + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:warning +dotnet_style_predefined_type_for_member_access = true:warning + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:warning +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:warning +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:warning +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:warning + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning + +# Expression-level preferences +csharp_style_deconstructed_variable_declaration = true:none +dotnet_style_coalesce_expression = true:warning +dotnet_style_collection_initializer = true:warning +dotnet_style_explicit_tuple_names = true:warning +dotnet_style_null_propagation = true:warning +dotnet_style_object_initializer = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:warning +dotnet_style_prefer_compound_assignment = true:warning +dotnet_style_prefer_conditional_expression_over_assignment = true:none +dotnet_style_prefer_conditional_expression_over_return = true:none +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = false:warning +dotnet_style_prefer_simplified_boolean_expressions = true:warning +dotnet_style_prefer_simplified_interpolation = true:warning + +# Field preferences +dotnet_style_readonly_field = true:warning + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:suggestion + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = warning + +#### C# Coding Conventions #### + +# var preferences +csharp_style_var_elsewhere = true:warning +csharp_style_var_for_built_in_types = true:warning +csharp_style_var_when_type_is_apparent = true:warning + +# Expression-bodied members +csharp_style_expression_bodied_accessors = when_on_single_line:warning +csharp_style_expression_bodied_constructors = false:warning +csharp_style_expression_bodied_indexers = when_on_single_line:warning +csharp_style_expression_bodied_lambdas = true:warning +csharp_style_expression_bodied_local_functions = false:none +csharp_style_expression_bodied_methods = when_on_single_line:hint +csharp_style_expression_bodied_operators = when_on_single_line:warning +csharp_style_expression_bodied_properties = when_on_single_line:warning + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_pattern_matching = false:none +csharp_style_prefer_switch_expression = true:suggestion + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:warning + +# Modifier preferences +csharp_prefer_static_local_function = true:warning +csharp_preferred_modifier_order = public, private, protected, internal, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, volatile, async:warning + +# Code-block preferences +csharp_prefer_braces = true:warning +csharp_prefer_simple_using_statement = true:suggestion + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:warning +csharp_style_deconstructed_variable_declaration = true:warning +csharp_style_inlined_variable_declaration = true:warning +csharp_style_pattern_local_over_anonymous_function = true:warning +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_throw_expression = true:warning +csharp_style_unused_value_assignment_preference = discard_variable:warning +csharp_style_unused_value_expression_statement_preference = discard_variable:suggestion + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:warning + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# ElasticSpace preferences +csharp_space_after_cast = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = true +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = true +csharp_space_between_parentheses = control_flow_statements +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = false + +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = error +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = error +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = warning +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.public_or_protected_field_should_be_pascal_case.severity = warning +dotnet_naming_rule.public_or_protected_field_should_be_pascal_case.symbols = public_or_protected_field +dotnet_naming_rule.public_or_protected_field_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.private_field_should_be_begins_with_underscore.severity = warning +dotnet_naming_rule.private_field_should_be_begins_with_underscore.symbols = private_field +dotnet_naming_rule.private_field_should_be_begins_with_underscore.style = begins_with_underscore + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.public_or_protected_field.applicable_kinds = field +dotnet_naming_symbols.public_or_protected_field.applicable_accessibilities = public, protected +dotnet_naming_symbols.public_or_protected_field.required_modifiers = + +dotnet_naming_symbols.private_field.applicable_kinds = field +dotnet_naming_symbols.private_field.applicable_accessibilities = private, private_protected +dotnet_naming_symbols.private_field.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +dotnet_naming_style.begins_with_underscore.required_prefix = _ +dotnet_naming_style.begins_with_underscore.required_suffix = +dotnet_naming_style.begins_with_underscore.word_separator = +dotnet_naming_style.begins_with_underscore.capitalization = camel_case + +# IDE0021: Use expression body for constructors +dotnet_diagnostic.IDE0021.severity = suggestion + +# IDE0019: Use pattern matching +dotnet_diagnostic.IDE0019.severity = suggestion + +# IDE0021: Use expression body for constructors +dotnet_diagnostic.IDE0021.severity = warning + +# Default severity for analyzer diagnostics with category 'MicrosoftCodeAnalysisReleaseTracking' +# https://github.com/dotnet/roslyn-analyzers/blob/master/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md +dotnet_analyzer_diagnostic.category-MicrosoftCodeAnalysisReleaseTracking.severity = none + + +# IDE0044: Add readonly modifier +dotnet_diagnostic.IDE0044.severity = warning + +# IDE0079: Remove unnecessary suppression +dotnet_diagnostic.IDE0079.severity = warning + +# SA1009: Closing parenthesis should be spaced correctly +dotnet_diagnostic.SA1009.severity = none + +# SA1309: Field names should not begin with underscore +dotnet_diagnostic.SA1309.severity = none + +# SA1633: File should have header +dotnet_diagnostic.SA1633.severity = none + +# SA1636: File header should be correct +dotnet_diagnostic.SA1636.severity = none + + +# SA1200: Using directives should be placed within namespace +dotnet_diagnostic.SA1200.severity = none + +# SA1204: Static members should appear before non-static members +dotnet_diagnostic.SA1204.severity = none + +# SA1208: Using directive should appear before... +dotnet_diagnostic.SA1208.severity = none + +# SA1501: Statement should not be on a single line +dotnet_diagnostic.SA1501.severity = none + + +# SA1502: Element should not be on a single line +dotnet_diagnostic.SA1502.severity = none + + +# SA1505: Opening braces should not be followed by blank line +dotnet_diagnostic.SA1505.severity = none + +# SA1600: Elements should be documented +dotnet_diagnostic.SA1600.severity = none + +# SA1008: Opening parenthesis should be spaced correctly +dotnet_diagnostic.SA1008.severity = none + +# SA1128: Put constructor initializers on their own line +dotnet_diagnostic.SA1128.severity = none + +# SA1202: Elements should be ordered by access +dotnet_diagnostic.SA1202.severity = none + +# SA1614: Element parameter documentation should have text +dotnet_diagnostic.SA1614.severity = none + +# SA1615: Element return value should be documented +dotnet_diagnostic.SA1615.severity = none + +# SA1616: Element return value documentation should have text +dotnet_diagnostic.SA1616.severity = none + +# SA1201: Elements should appear in the correct order +dotnet_diagnostic.SA1201.severity = none + +# SA1601: Partial elements should be documented +dotnet_diagnostic.SA1601.severity = none + +# SA1003: Symbols should be spaced correctly +dotnet_diagnostic.SA1003.severity = none + +# SA1622: Generic type parameter documentation should have text +dotnet_diagnostic.SA1622.severity = none + +# SA1512: Single-line comments should not be followed by blank line +dotnet_diagnostic.SA1512.severity = none + +# SA1611: Element parameters should be documented +dotnet_diagnostic.SA1611.severity = none + +# SA1010: Opening square brackets should be spaced correctly +dotnet_diagnostic.SA1010.severity = none + +# SA1011: Closing square brackets should be spaced correctly +dotnet_diagnostic.SA1011.severity = none + +# SA1118: Parameter should not span multiple lines +dotnet_diagnostic.SA1118.severity = suggestion + +# SA1413: Use trailing comma in multi-line initializers +dotnet_diagnostic.SA1413.severity = none + +# SA1122: Use string.Empty for empty strings +dotnet_diagnostic.SA1122.severity = none + +# CA1310: Specify StringComparison for correctness +dotnet_diagnostic.CA1310.severity = warning + +# CA1725: Parameter names should match base declaration +dotnet_diagnostic.CA1725.severity = warning + +# CA1805: Do not initialize unnecessarily +dotnet_diagnostic.CA1805.severity = warning + +# CA1822: Member can be marked as static +dotnet_diagnostic.CA1822.severity = warning + +# CA1829: Use the Count property instead of Enumerable.Count() +dotnet_diagnostic.CA1829.severity = warning + +# CA2201: Do not raise reserved exception types +dotnet_diagnostic.CA2201.severity = warning + +# CA2215: Dispose methods should call base class dispose +dotnet_diagnostic.CA2215.severity = warning + +# CA5350: Do Not Use Weak Cryptographic Algorithms +dotnet_diagnostic.CA5350.severity = warning + +# CA5351: Do Not Use Broken Cryptographic Algorithms +dotnet_diagnostic.CA5351.severity = warning + +# CA5369: Use XmlReader For Deserialize +dotnet_diagnostic.CA5369.severity = warning + +# CA5370: Use XmlReader For Validating Reader +dotnet_diagnostic.CA5370.severity = warning + +# CA5371: Use XmlReader For Schema Read +dotnet_diagnostic.CA5371.severity = warning + +# CA5372: Use XmlReader For XPathDocument +dotnet_diagnostic.CA5372.severity = warning + +# CA5373: Do not use obsolete key derivation function +dotnet_diagnostic.CA5373.severity = warning + +# CA5374: Do Not Use XslTransform +dotnet_diagnostic.CA5374.severity = warning + +# CA5379: Do Not Use Weak Key Derivation Function Algorithm +dotnet_diagnostic.CA5379.severity = warning + +# CA5384: Do Not Use Digital Signature Algorithm (DSA) +dotnet_diagnostic.CA5384.severity = warning + +# CA5385: Use Rivest�Shamir�Adleman (RSA) Algorithm With Sufficient Key Size +dotnet_diagnostic.CA5385.severity = warning + +# CA5397: Do not use deprecated SslProtocols values +dotnet_diagnostic.CA5397.severity = warning + +# CA1001: Types that own disposable fields should be disposable +dotnet_diagnostic.CA1001.severity = warning + +# CA1309: Use ordinal string comparison +dotnet_diagnostic.CA1309.severity = warning +dotnet_diagnostic.CA1307.severity = warning +dotnet_diagnostic.CA1305.severity = warning +dotnet_diagnostic.CA1304.severity = warning + +# IDE0004: Remove Unnecessary Cast +dotnet_diagnostic.IDE0004.severity = warning + +# IDE0005: Using directive is unnecessary. +dotnet_diagnostic.IDE0005.severity = warning + +# IDE0007: Use implicit type +dotnet_diagnostic.IDE0007.severity = warning + +# IDE0007: Use explicit type +dotnet_diagnostic.IDE0008.severity = none + +# IDE0009: Member access should be qualified. +dotnet_diagnostic.IDE0009.severity = warning + +# IDE0011: Add braces +dotnet_diagnostic.IDE0011.severity = warning + +# IDE0016: Use 'throw' expression +dotnet_diagnostic.IDE0016.severity = warning + +# IDE0017: Simplify object initialization +dotnet_diagnostic.IDE0017.severity = suggestion + +# IDE0018: Inline variable declaration +dotnet_diagnostic.IDE0018.severity = warning + +# IDE0020: Use pattern matching +dotnet_diagnostic.IDE0020.severity = suggestion + +# IDE0023: Use expression body for operators +dotnet_diagnostic.IDE0023.severity = none + +# IDE0024: Use expression body for operators +dotnet_diagnostic.IDE0024.severity = none + +# IDE0025: Use expression body for properties +dotnet_diagnostic.IDE0025.severity = warning + +# IDE0026: Use expression body for indexers +dotnet_diagnostic.IDE0026.severity = warning + +# IDE0027: Use expression body for accessors +dotnet_diagnostic.IDE0027.severity = warning + +# IDE0028: Simplify collection initialization +dotnet_diagnostic.IDE0028.severity = warning + +# IDE0031: Use null propagation +dotnet_diagnostic.IDE0031.severity = warning + +# IDE0032: Use auto property +dotnet_diagnostic.IDE0032.severity = warning + +# IDE0030: Use coalesce expression +dotnet_diagnostic.IDE0030.severity = warning + +# IDE0029: Use coalesce expression +dotnet_diagnostic.IDE0029.severity = warning + +# IDE0034: Simplify 'default' expression +dotnet_diagnostic.IDE0034.severity = warning + +# IDE0036: Order modifiers +dotnet_diagnostic.IDE0036.severity = warning + +# IDE0039: Use local function +dotnet_diagnostic.IDE0039.severity = warning + +# IDE0040: Add accessibility modifiers +dotnet_diagnostic.IDE0040.severity = warning + +# IDE0041: Use 'is null' check +dotnet_diagnostic.IDE0041.severity = warning + +# IDE0042: Deconstruct variable declaration +dotnet_diagnostic.IDE0042.severity = none + +# IDE0047: Remove unnecessary parentheses +dotnet_diagnostic.IDE0047.severity = warning + +# IDE0048: Add parentheses for clarity +dotnet_diagnostic.IDE0048.severity = warning + +# IDE0050: Convert to tuple +dotnet_diagnostic.IDE0050.severity = warning + +# IDE0051: Private member is unused +dotnet_diagnostic.IDE0051.severity = warning + +# IDE0054: Use compound assignment +dotnet_diagnostic.IDE0054.severity = warning + +# IDE0057: Use range operator +dotnet_diagnostic.IDE0057.severity = suggestion + +# IDE0058: Expression value is never used +dotnet_diagnostic.IDE0058.severity = none + +# IDE0059: Unnecessary assignment of a value +dotnet_diagnostic.IDE0059.severity = warning + +# IDE0060: Remove unused parameter +dotnet_diagnostic.IDE0060.severity = suggestion + +# IDE0061: Use expression body for local functions +dotnet_diagnostic.IDE0061.severity = none + +# IDE0062: Make local function 'static' +dotnet_diagnostic.IDE0062.severity = warning + +# IDE0065: Misplaced using directive +dotnet_diagnostic.IDE0065.severity = warning + +# IDE0071: Simplify interpolation +dotnet_diagnostic.IDE0071.severity = warning + +# IDE0072: Add missing cases +dotnet_diagnostic.IDE0072.severity = suggestion + +# IDE0073: File header +dotnet_diagnostic.IDE0073.severity = warning + +# IDE0075: Simplify conditional expression +dotnet_diagnostic.IDE0075.severity = warning + +# IDE0080: Remove unnecessary suppression operator +dotnet_diagnostic.IDE0080.severity = warning + +# IDE0082: 'typeof' can be converted to 'nameof' +dotnet_diagnostic.IDE0082.severity = warning + +# IDE0083: Use pattern matching +dotnet_diagnostic.IDE0083.severity = suggestion + +# IDE0090: 'new' expression can be simplified +dotnet_diagnostic.IDE0083.severity = warning + + +# IDE1005: Delegate invocation can be simplified. +dotnet_diagnostic.IDE1005.severity = warning + +# IDE1006: Naming Styles +dotnet_diagnostic.IDE1006.severity = warning + +# SA1602: Enumeration items should be documented +dotnet_diagnostic.SA1602.severity = suggestion + +# SA1618: Generic type parameters should be documented +dotnet_diagnostic.SA1618.severity = suggestion + +# SA1021: Negative signs should be spaced correctly +dotnet_diagnostic.SA1021.severity = none + +# SA1402: File may only contain a single type +dotnet_diagnostic.SA1402.severity = warning + +# SA1604: Element documentation should have summary +dotnet_diagnostic.SA1604.severity = none + +# SA1620: Generic type parameter documentation should match type parameters +dotnet_diagnostic.SA1620.severity = none + + +# SA1028: Code should not contain trailing whitespace +dotnet_diagnostic.SA1028.severity = none + +# SA1024: Deference symbol '*' should not be followed by a space. +dotnet_diagnostic.SA1023.severity = none +dotnet_diagnostic.IDE0052.severity = warning +dotnet_diagnostic.IDE0043.severity = warning +dotnet_diagnostic.CA1507.severity = warning +dotnet_diagnostic.CA1825.severity = warning + +dotnet_diagnostic.CA1018.severity = warning +dotnet_diagnostic.CA1821.severity = warning +dotnet_diagnostic.CA1827.severity = warning +dotnet_diagnostic.CA1836.severity = warning +dotnet_diagnostic.CA2011.severity = warning + +# RS1024 Compare (Roslyn) symbols correctly. There is a bug in the analyzer and it has dozens of false positive. +dotnet_diagnostic.RS1024.severity = none \ No newline at end of file diff --git a/eng/style/CommonStyle.DotSettings b/eng/style/CommonStyle.DotSettings new file mode 100644 index 0000000..dddc862 --- /dev/null +++ b/eng/style/CommonStyle.DotSettings @@ -0,0 +1,346 @@ + + AutomaticProperty + True + ExplicitlyExcluded + ExplicitlyExcluded + *.g.cs + WARNING + HINT + WARNING + WARNING + DO_NOT_SHOW + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + SUGGESTION + SUGGESTION + SUGGESTION + SUGGESTION + WARNING + DO_NOT_SHOW + WARNING + HINT + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + SUGGESTION + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + SUGGESTION + WARNING + WARNING + HINT + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + DO_NOT_SHOW + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + DO_NOT_SHOW + DO_NOT_SHOW + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + True + False + ShowAndRun + SUGGESTION + <?xml version="1.0" encoding="utf-16"?><Profile name="Add file header"><XMLReformatCode>True</XMLReformatCode><CSCodeStyleAttributes ArrangeTypeAccessModifier="False" ArrangeTypeMemberAccessModifier="False" SortModifiers="False" RemoveRedundantParentheses="False" AddMissingParentheses="False" ArrangeBraces="False" ArrangeAttributes="False" ArrangeArgumentsStyle="False" ArrangeCodeBodyStyle="False" ArrangeVarStyle="False" ArrangeTrailingCommas="False" ArrangeObjectCreation="False" ArrangeDefaultValue="False" /><HtmlReformatCode>True</HtmlReformatCode><CppReformatCode>True</CppReformatCode><ShaderLabReformatCode>True</ShaderLabReformatCode><VBReformatCode>True</VBReformatCode><CSOptimizeUsings><OptimizeUsings>False</OptimizeUsings><EmbraceInRegion>False</EmbraceInRegion><RegionName></RegionName></CSOptimizeUsings><CSReformatCode>True</CSReformatCode><CSUpdateFileHeader>True</CSUpdateFileHeader><IDEA_SETTINGS>&lt;profile version="1.0"&gt; + &lt;option name="myName" value="Add file header" /&gt; +&lt;/profile&gt;</IDEA_SETTINGS></Profile> + <?xml version="1.0" encoding="utf-16"?><Profile name="Custom"><XMLReformatCode>True</XMLReformatCode><CSCodeStyleAttributes ArrangeTypeAccessModifier="True" ArrangeTypeMemberAccessModifier="True" SortModifiers="True" ArrangeBraces="True" ArrangeAttributes="True" ArrangeVarStyle="True" ArrangeTrailingCommas="True" ArrangeObjectCreation="True" ArrangeDefaultValue="True" /><CSOptimizeUsings><OptimizeUsings>False</OptimizeUsings></CSOptimizeUsings><CSReformatCode>True</CSReformatCode><CSArrangeQualifiers>True</CSArrangeQualifiers><CSFixBuiltinTypeReferences>True</CSFixBuiltinTypeReferences><CSShortenReferences>True</CSShortenReferences><IDEA_SETTINGS>&lt;profile version="1.0"&gt; + &lt;option name="myName" value="Custom" /&gt; + &lt;inspection_tool class="ES6ShorthandObjectProperty" enabled="false" level="INFORMATION" enabled_by_default="false" /&gt; + &lt;inspection_tool class="JSArrowFunctionBracesCanBeRemoved" enabled="false" level="INFORMATION" enabled_by_default="false" /&gt; + &lt;inspection_tool class="JSPrimitiveTypeWrapperUsage" enabled="false" level="WARNING" enabled_by_default="false" /&gt; + &lt;inspection_tool class="JSRemoveUnnecessaryParentheses" enabled="false" level="INFORMATION" enabled_by_default="false" /&gt; + &lt;inspection_tool class="JSUnnecessarySemicolon" enabled="false" level="WARNING" enabled_by_default="false" /&gt; + &lt;inspection_tool class="TypeScriptExplicitMemberType" enabled="false" level="INFORMATION" enabled_by_default="false" /&gt; + &lt;inspection_tool class="UnnecessaryContinueJS" enabled="false" level="WARNING" enabled_by_default="false" /&gt; + &lt;inspection_tool class="UnnecessaryLabelJS" enabled="false" level="WARNING" enabled_by_default="false" /&gt; + &lt;inspection_tool class="UnnecessaryLabelOnBreakStatementJS" enabled="false" level="WARNING" enabled_by_default="false" /&gt; + &lt;inspection_tool class="UnnecessaryLabelOnContinueStatementJS" enabled="false" level="WARNING" enabled_by_default="false" /&gt; + &lt;inspection_tool class="UnnecessaryReturnJS" enabled="false" level="WARNING" enabled_by_default="false" /&gt; +&lt;/profile&gt;</IDEA_SETTINGS><CppCodeStyleCleanupDescriptor /><CSUpdateFileHeader>True</CSUpdateFileHeader><FormatAttributeQuoteDescriptor>True</FormatAttributeQuoteDescriptor><RIDER_SETTINGS>&lt;profile&gt; + &lt;Language id="CSS"&gt; + &lt;Rearrange&gt;false&lt;/Rearrange&gt; + &lt;Reformat&gt;false&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="EditorConfig"&gt; + &lt;Reformat&gt;false&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="HTML"&gt; + &lt;OptimizeImports&gt;false&lt;/OptimizeImports&gt; + &lt;Rearrange&gt;false&lt;/Rearrange&gt; + &lt;Reformat&gt;false&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="HTTP Request"&gt; + &lt;Reformat&gt;false&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="Handlebars"&gt; + &lt;Reformat&gt;false&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="Ini"&gt; + &lt;Reformat&gt;false&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="JSON"&gt; + &lt;Reformat&gt;false&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="Jade"&gt; + &lt;Reformat&gt;false&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="JavaScript"&gt; + &lt;OptimizeImports&gt;false&lt;/OptimizeImports&gt; + &lt;Rearrange&gt;false&lt;/Rearrange&gt; + &lt;Reformat&gt;false&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="Markdown"&gt; + &lt;Reformat&gt;false&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="Properties"&gt; + &lt;Reformat&gt;false&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="RELAX-NG"&gt; + &lt;Reformat&gt;false&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="SQL"&gt; + &lt;Reformat&gt;false&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="XML"&gt; + &lt;OptimizeImports&gt;false&lt;/OptimizeImports&gt; + &lt;Rearrange&gt;false&lt;/Rearrange&gt; + &lt;Reformat&gt;false&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="yaml"&gt; + &lt;Reformat&gt;false&lt;/Reformat&gt; + &lt;/Language&gt; +&lt;/profile&gt;</RIDER_SETTINGS></Profile> + Built-in: Reformat & Apply Syntax Style + False + Required + Required + Required + Required + True + True + False + True + True + True + True + True + True + True + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + TOGETHER_SAME_LINE + True + 1 + 1 + False + False + False + False + False + True + True + NEVER + NEVER + NEVER + False + NEVER + True + True + True + True + True + True + True + True + CHOP_IF_LONG + CHOP_IF_LONG + True + True + True + CHOP_IF_LONG + 160 + CHOP_ALWAYS + CHOP_IF_LONG + False + False + True + Skip + Skip + True + True + True + False + True + True + False + False + True + False + True + True + MS + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="_" Suffix="" Style="aaBb" /> + <Policy><Descriptor Staticness="Any" AccessRightKinds="Private" Description="Constant fields (private)"><ElementKinds><Kind Name="CONSTANT_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="_" Suffix="" Style="aaBb" /></Policy> + <Policy><Descriptor Staticness="Any" AccessRightKinds="Protected, ProtectedInternal, Internal, Public, PrivateProtected" Description="Constant fields (not private)"><ElementKinds><Kind Name="CONSTANT_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /></Policy> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True \ No newline at end of file diff --git a/eng/style/LICENSE b/eng/style/LICENSE new file mode 100644 index 0000000..fe7a0a4 --- /dev/null +++ b/eng/style/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 SharpCrafters s.r.o. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/eng/style/README.md b/eng/style/README.md new file mode 100644 index 0000000..c4b66c3 --- /dev/null +++ b/eng/style/README.md @@ -0,0 +1,44 @@ +# PostSharp Engineering: Code Style Features + +Make sure you have read and understood [PostSharp Engineering](../README.md) before reading this doc. + +## Table of contents + +- [PostSharp Engineering: Code Style Features](#postsharp-engineering-code-style-features) + - [Table of contents](#table-of-contents) + - [Introduction](#introduction) + - [Installation](#installation) + - [Configuration](#configuration) + - [Code style cleanup](#code-style-cleanup) + +## Introduction + +This directory contains centralized code-style configuration and scripts. + +## Installation + +1. Copy the `PostSharp.Engineering.CodeStyle` repo to your own repo into `eng/style` using `PostSharp.Engineering.BuildTools`, with the command: + + ``` + .\Build.ps1 codestyle pull + ``` + + This tool will create a symlink for `.editorconfig`. + +2. Enable symlinks for your repo (edit `.git/config`). + +3. For each solution, in Rider, open Settings, choose "Manage Layers", select the team-shared layer, click on the `+` icon and then on "Open Settings File", then choose `eng/style/CommonStyle.DotSettings`. + This step is required for code formatting using `Build.ps1 reformat`, even if you are otherwise not using Rider. + +## Configuration + +The code quality configuration is configured in the following files: + +- `.editorconfig` +- `CommonStyle.DotSettings` (used by JetBrains tools) +- `stylecop.json` + +## Code style cleanup + +1. Commit all your changes. You cannot reformat a repo with uncommitted changes. +2. Do `.\Build.ps1 reformat` from the repo root (see `PostSharp.Engineering`). \ No newline at end of file diff --git a/eng/style/stylecop.json b/eng/style/stylecop.json new file mode 100644 index 0000000..2b28d72 --- /dev/null +++ b/eng/style/stylecop.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", + "settings": { + "documentationRules": { + "companyName": "SharpCrafters s.r.o." + } + } +} \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/.vs/config/applicationhost.config b/src/PostSharp.LicenseServer/.vs/config/applicationhost.config deleted file mode 100644 index 2ad21f2..0000000 --- a/src/PostSharp.LicenseServer/.vs/config/applicationhost.config +++ /dev/null @@ -1,1038 +0,0 @@ - - - - - - - - -
-
-
-
-
-
-
-
- - - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
- -
-
-
-
-
-
- -
-
-
-
-
- -
-
-
- -
-
- -
-
- -
-
-
- - -
-
-
-
-
-
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/PostSharp.LicenseServer/Admin/AddLicense.aspx b/src/PostSharp.LicenseServer/Admin/AddLicense.aspx deleted file mode 100644 index c2c245c..0000000 --- a/src/PostSharp.LicenseServer/Admin/AddLicense.aspx +++ /dev/null @@ -1,14 +0,0 @@ -<%@ Page Title="Add License — PostSharp License Server" Language="C#" MasterPageFile="~/Site.Master" AutoEventWireup="true" - CodeBehind="AddLicense.aspx.cs" Inherits="PostSharp.LicenseServer.Admin.AddLicensePage" %> - - - - -

- License key:

- -
- -
- -
diff --git a/src/PostSharp.LicenseServer/Admin/AddLicense.aspx.cs b/src/PostSharp.LicenseServer/Admin/AddLicense.aspx.cs deleted file mode 100644 index ff051e0..0000000 --- a/src/PostSharp.LicenseServer/Admin/AddLicense.aspx.cs +++ /dev/null @@ -1,64 +0,0 @@ -#region Copyright (c) 2004-2010 by SharpCrafters s.r.o. - -// This file is part of PostSharp source code and is the property of SharpCrafters s.r.o. -// -// Source code is provided to customers under strict non-disclosure agreement (NDA). -// YOU MUST HAVE READ THE NDA BEFORE HAVING RECEIVED ACCESS TO THIS SOURCE CODE. -// Severe financial penalties apply in case of non respect of the NDA. - -#endregion - -using System; -using System.Linq; -using System.Web.UI; -using PostSharp.Sdk; -using PostSharp.Sdk.Extensibility.Licensing; -using ParsedLicense = PostSharp.Sdk.Extensibility.Licensing.License; - -namespace PostSharp.LicenseServer.Admin -{ - public partial class AddLicensePage : Page - { - protected void AddButton_OnClick( object sender, EventArgs e ) - { - ParsedLicense parsedLicense = ParsedLicense.Deserialize( this.licenseKeyTextBox.Text ); - - if ( parsedLicense == null ) - { - this.errorLabel.Text = "Invalid license key."; - this.errorLabel.Visible = true; - return; - } - - if ( !parsedLicense.IsLicenseServerEligible() ) - { - this.errorLabel.Text = string.Format( "Cannot add a {0} of {1} to the server.", - parsedLicense.GetLicenseTypeName() ?? "(unknown license type)", - parsedLicense.GetProductName( ) ); - this.errorLabel.Visible = true; - return; - } - - License license = new License - { - LicenseId = parsedLicense.LicenseId, - LicenseKey = ParsedLicense.CleanLicenseString( this.licenseKeyTextBox.Text ), - CreatedOn = VirtualDateTime.UtcNow, - ProductCode = parsedLicense.Product.ToString(), - }; - - Database db = new Database(); - if (db.Licenses.Any(l => l.LicenseId == license.LicenseId)) - { - this.errorLabel.Text = "The given license has been added already."; - this.errorLabel.Visible = true; - return; - } - - db.Licenses.InsertOnSubmit(license); - db.SubmitChanges(); - - this.Response.Redirect( ".." ); - } - } -} \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/Admin/AddLicense.aspx.designer.cs b/src/PostSharp.LicenseServer/Admin/AddLicense.aspx.designer.cs deleted file mode 100644 index f57d1be..0000000 --- a/src/PostSharp.LicenseServer/Admin/AddLicense.aspx.designer.cs +++ /dev/null @@ -1,42 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace PostSharp.LicenseServer.Admin { - - - public partial class AddLicensePage { - - /// - /// licenseKeyTextBox control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.TextBox licenseKeyTextBox; - - /// - /// errorLabel control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.Label errorLabel; - - /// - /// AddButton control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.Button AddButton; - } -} diff --git a/src/PostSharp.LicenseServer/Admin/Cancel.aspx b/src/PostSharp.LicenseServer/Admin/Cancel.aspx deleted file mode 100644 index 9795531..0000000 --- a/src/PostSharp.LicenseServer/Admin/Cancel.aspx +++ /dev/null @@ -1,22 +0,0 @@ -<%@ Page Title="Cancel Lease — SharpCrafters License Server" Language="C#" - MasterPageFile="~/Site.Master" AutoEventWireup="true" CodeBehind="Cancel.aspx.cs" - Inherits="PostSharp.LicenseServer.Admin.CancelPage" %> - - - - -

- Cancel Lease

-

- Cancelling a lease on server side does not prevent the client from using the licensed - software until the lease has expired. You, and not the licensing server, are - ultimately responsible to ensure that the license agreement is being respected. -

-

- Are you sure you want to cancel the lease? -

-

- - -

-
diff --git a/src/PostSharp.LicenseServer/Admin/Cancel.aspx.cs b/src/PostSharp.LicenseServer/Admin/Cancel.aspx.cs deleted file mode 100644 index 4a412ac..0000000 --- a/src/PostSharp.LicenseServer/Admin/Cancel.aspx.cs +++ /dev/null @@ -1,41 +0,0 @@ -#region Copyright (c) 2004-2010 by SharpCrafters s.r.o. - -// This file is part of PostSharp source code and is the property of SharpCrafters s.r.o. -// -// Source code is provided to customers under strict non-disclosure agreement (NDA). -// YOU MUST HAVE READ THE NDA BEFORE HAVING RECEIVED ACCESS TO THIS SOURCE CODE. -// Severe financial penalties apply in case of non respect of the NDA. - -#endregion - -using System; -using System.Linq; -using System.Web.UI; - -namespace PostSharp.LicenseServer.Admin -{ - public partial class CancelPage : Page - { - private Database db; - private Lease lease; - - protected override void OnInit( EventArgs e ) - { - int leaseId = int.Parse( this.Request.QueryString["id"] ); - this.db = new Database(); - this.lease = (from l in db.Leases where l.LeaseId == leaseId select l).Single(); - } - - protected void yesButton_OnClick( object sender, EventArgs e ) - { - db.CancelLease( lease, this.User.Identity.Name, VirtualDateTime.UtcNow ); - db.SubmitChanges(); - this.Response.Redirect( "Details.aspx?id=" + lease.LicenseId ); - } - - protected void noButton_OnClick( object sender, EventArgs e ) - { - this.Response.Redirect( "Details.aspx?id=" + lease.LicenseId ); - } - } -} \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/Admin/Cancel.aspx.designer.cs b/src/PostSharp.LicenseServer/Admin/Cancel.aspx.designer.cs deleted file mode 100644 index 0bbdba4..0000000 --- a/src/PostSharp.LicenseServer/Admin/Cancel.aspx.designer.cs +++ /dev/null @@ -1,33 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace PostSharp.LicenseServer.Admin { - - - public partial class CancelPage { - - /// - /// yesButton control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.Button yesButton; - - /// - /// noButton control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.Button noButton; - } -} diff --git a/src/PostSharp.LicenseServer/Admin/Details.aspx b/src/PostSharp.LicenseServer/Admin/Details.aspx deleted file mode 100644 index db9ddc5..0000000 --- a/src/PostSharp.LicenseServer/Admin/Details.aspx +++ /dev/null @@ -1,46 +0,0 @@ -<%@ Page Title="Lease Details — SharpCrafters License Server" Language="C#" MasterPageFile="~/Site.Master" AutoEventWireup="true" CodeBehind="Details.aspx.cs" Inherits="PostSharp.LicenseServer.Admin.DetailsPage" %> -<%@ Import Namespace="PostSharp.LicenseServer" %> - - - - -

- Back -

- -

- Current leases on license -

- - - - - - - - - - - ">Cancel - - - - -
-

- lease(s), - concurrent user(s). - -

- -

-
-
- -

- -

- Page generated on <%=VirtualDateTime.UtcNow.ToLocalTime()%>. -

-
diff --git a/src/PostSharp.LicenseServer/Admin/Details.aspx.cs b/src/PostSharp.LicenseServer/Admin/Details.aspx.cs deleted file mode 100644 index ec3c7bf..0000000 --- a/src/PostSharp.LicenseServer/Admin/Details.aspx.cs +++ /dev/null @@ -1,90 +0,0 @@ -#region Copyright (c) 2004-2010 by SharpCrafters s.r.o. - -// This file is part of PostSharp source code and is the property of SharpCrafters s.r.o. -// -// Source code is provided to customers under strict non-disclosure agreement (NDA). -// YOU MUST HAVE READ THE NDA BEFORE HAVING RECEIVED ACCESS TO THIS SOURCE CODE. -// Severe financial penalties apply in case of non respect of the NDA. - -#endregion - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Web; -using System.Web.UI; - -namespace PostSharp.LicenseServer.Admin -{ - public partial class DetailsPage : Page - { - protected override void OnLoad( EventArgs e ) - { - int licenseId = int.Parse( this.Request.QueryString["id"] ); - this.licenseIdLabel.Text = licenseId.ToString(); - - Database db = new Database(); - License license = db.Licenses.Single(l => l.LicenseId == licenseId); - - DateTime now = VirtualDateTime.UtcNow; - List leases = (from l in db.Leases - join Lease o in db.Leases on l.LeaseId equals o.OverwrittenLeaseId into o - from oo in o.DefaultIfEmpty() - where - oo == null && - l.LicenseId == licenseId && - l.StartTime <= now && - l.EndTime >= now - orderby l.StartTime - select l).ToList(); - - - this.GridView1.DataSource = leases; - this.GridView1.DataBind(); - - this.leaseCountLiteral.Text = leases.Count.ToString(); - this.concurrentUserCountLiteral.Text = db.GetActiveLeads( licenseId, now ).ToString(); - - bool disabled = license.Priority < 0; - this.enableHyperlink.Visible = disabled; - this.deleteHyperlink.Visible = disabled; - this.disableHyperlink.Visible = !disabled; - - } - - private void ChangePriority(int priority) - { - int licenseId = int.Parse(this.Request.QueryString["id"]); - - Database db = new Database(); - License license = db.Licenses.Single(l => l.LicenseId == licenseId); - license.Priority = priority; - db.SubmitChanges(); - - this.Response.Redirect(".."); - } - - protected void disableHyperlink_OnClick(object sender, EventArgs e) - { - this.ChangePriority(-1); - } - - protected void enableHyperlink_OnClick(object sender, EventArgs e) - { - this.ChangePriority(0); - } - - protected void deleteHyperlink_OnClick(object sender, EventArgs e) - { - int licenseId = int.Parse(this.Request.QueryString["id"]); - - Database db = new Database(); - License license = db.Licenses.Single(l => l.LicenseId == licenseId); - db.Leases.DeleteAllOnSubmit(license.Leases); - db.Licenses.DeleteOnSubmit(license); - db.SubmitChanges(); - - this.Response.Redirect(".."); - } - } -} \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/Admin/Details.aspx.designer.cs b/src/PostSharp.LicenseServer/Admin/Details.aspx.designer.cs deleted file mode 100644 index 45f62d8..0000000 --- a/src/PostSharp.LicenseServer/Admin/Details.aspx.designer.cs +++ /dev/null @@ -1,78 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace PostSharp.LicenseServer.Admin { - - - public partial class DetailsPage { - - /// - /// licenseIdLabel control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.Label licenseIdLabel; - - /// - /// GridView1 control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.GridView GridView1; - - /// - /// leaseCountLiteral control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.Literal leaseCountLiteral; - - /// - /// concurrentUserCountLiteral control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.Literal concurrentUserCountLiteral; - - /// - /// disableHyperlink control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.LinkButton disableHyperlink; - - /// - /// enableHyperlink control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.LinkButton enableHyperlink; - - /// - /// deleteHyperlink control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.LinkButton deleteHyperlink; - } -} diff --git a/src/PostSharp.LicenseServer/Admin/Export.ashx b/src/PostSharp.LicenseServer/Admin/Export.ashx deleted file mode 100644 index 2e17582..0000000 --- a/src/PostSharp.LicenseServer/Admin/Export.ashx +++ /dev/null @@ -1 +0,0 @@ -<%@ WebHandler Language="C#" CodeBehind="Export.ashx.cs" Class="PostSharp.LicenseServer.Admin.ExportHandler" %> diff --git a/src/PostSharp.LicenseServer/Admin/Export.ashx.cs b/src/PostSharp.LicenseServer/Admin/Export.ashx.cs deleted file mode 100644 index 655bab6..0000000 --- a/src/PostSharp.LicenseServer/Admin/Export.ashx.cs +++ /dev/null @@ -1,69 +0,0 @@ -#region Copyright (c) 2004-2010 by SharpCrafters s.r.o. - -// This file is part of PostSharp source code and is the property of SharpCrafters s.r.o. -// -// Source code is provided to customers under strict non-disclosure agreement (NDA). -// YOU MUST HAVE READ THE NDA BEFORE HAVING RECEIVED ACCESS TO THIS SOURCE CODE. -// Severe financial penalties apply in case of non respect of the NDA. - -#endregion - -using System; -using System.IO; -using System.Linq; -using System.Web; - -namespace PostSharp.LicenseServer.Admin -{ - /// - /// Summary description for Export - /// - public class ExportHandler : IHttpHandler - { - public void ProcessRequest( HttpContext context ) - { - int fromYear = int.Parse( context.Request.QueryString["fy"] ); - int toYear = int.Parse( context.Request.QueryString["ty"] ); - int fromMonth = int.Parse( context.Request.QueryString["fm"] ); - int toMonth = int.Parse( context.Request.QueryString["tm"] ); - - context.Response.ContentType = "text/plain"; - context.Response.AddHeader( "Content-Disposition", string.Format( "attachment; filename=PostSharp_LicenseLog_{0}-{1}_{2}-{3}.txt", - fromYear, fromMonth, toYear, toMonth ) ); - - - DateTime fromTime = new DateTime( fromYear, fromMonth, 1 ); - DateTime toTime = new DateTime( toYear, toMonth, 1 ).AddMonths( 1 ); - - Database db = new Database(); - var bounds = (from l in db.Leases - where l.EndTime >= fromTime && l.StartTime <= toTime - group l by 0 - into g - select new {MinLeaseId = g.Min( ll => ll.LeaseId ), MaxLeaseId = g.Max( ll => ll.LeaseId )}).SingleOrDefault(); - - if ( bounds != null ) - { - StreamWriter writer = new StreamWriter( context.Response.OutputStream ); - - IOrderedQueryable query = from l in db.Leases - where l.LeaseId >= bounds.MinLeaseId && l.LeaseId <= bounds.MaxLeaseId - orderby l.LeaseId - select l; - - foreach ( Lease lease in query ) - { - lease.Write( writer, true ); - writer.WriteLine(); - } - - writer.Flush(); - } - } - - public bool IsReusable - { - get { return true; } - } - } -} \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/Admin/Export.aspx b/src/PostSharp.LicenseServer/Admin/Export.aspx deleted file mode 100644 index b1841f8..0000000 --- a/src/PostSharp.LicenseServer/Admin/Export.aspx +++ /dev/null @@ -1,59 +0,0 @@ -<%@ Page Title="Export — SharpCrafters License Server" Language="C#" MasterPageFile="~/Site.Master" - AutoEventWireup="true" CodeBehind="Export.aspx.cs" Inherits="PostSharp.LicenseServer.Admin.ExportPage" %> - - - - -

- Log Export

-

- From - - - - - - - - - - - - - - -   - - to - - - - - - - - - - - - - - -   - -   - -

-

- - - -

-

- Back -

-
diff --git a/src/PostSharp.LicenseServer/Admin/Export.aspx.cs b/src/PostSharp.LicenseServer/Admin/Export.aspx.cs deleted file mode 100644 index 4b68e89..0000000 --- a/src/PostSharp.LicenseServer/Admin/Export.aspx.cs +++ /dev/null @@ -1,39 +0,0 @@ -#region Copyright (c) 2004-2010 by SharpCrafters s.r.o. - -// This file is part of PostSharp source code and is the property of SharpCrafters s.r.o. -// -// Source code is provided to customers under strict non-disclosure agreement (NDA). -// YOU MUST HAVE READ THE NDA BEFORE HAVING RECEIVED ACCESS TO THIS SOURCE CODE. -// Severe financial penalties apply in case of non respect of the NDA. - -#endregion - -using System; -using System.Web.UI; - -namespace PostSharp.LicenseServer.Admin -{ - public partial class ExportPage : Page - { - protected override void OnLoad( EventArgs e ) - { - if ( !this.IsPostBack ) - { - DateTime lastMonth = VirtualDateTime.UtcNow.AddMonths( -1 ); - this.fromYearTextBox.Text = this.toYearTextBox.Text = lastMonth.Year.ToString(); - this.fromMonthDropDownList.SelectedIndex = this.toMonthDropDownList.SelectedIndex = (lastMonth.Month - 1); - } - } - - protected void downloadButton_OnClick( object sender, EventArgs e ) - { - if ( this.IsValid ) - { - this.Response.Redirect( string.Format( "Export.ashx?fy={0}&fm={1}&ty={2}&tm={3}", - this.fromYearTextBox.Text, this.fromMonthDropDownList.SelectedValue, - this.toYearTextBox.Text, this.toMonthDropDownList.SelectedValue ) - ); - } - } - } -} \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/Admin/Export.aspx.designer.cs b/src/PostSharp.LicenseServer/Admin/Export.aspx.designer.cs deleted file mode 100644 index 0290775..0000000 --- a/src/PostSharp.LicenseServer/Admin/Export.aspx.designer.cs +++ /dev/null @@ -1,96 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace PostSharp.LicenseServer.Admin { - - - public partial class ExportPage { - - /// - /// fromMonthDropDownList control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.DropDownList fromMonthDropDownList; - - /// - /// fromYearTextBox control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.TextBox fromYearTextBox; - - /// - /// toMonthDropDownList control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.DropDownList toMonthDropDownList; - - /// - /// toYearTextBox control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.TextBox toYearTextBox; - - /// - /// downloadButton control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.Button downloadButton; - - /// - /// fromYearTextBoxRequiredFieldValidator control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.RequiredFieldValidator fromYearTextBoxRequiredFieldValidator; - - /// - /// fromYearRangeValidator control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.RangeValidator fromYearRangeValidator; - - /// - /// toYearTextBoxRequiredFieldValidator control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.RequiredFieldValidator toYearTextBoxRequiredFieldValidator; - - /// - /// toYearRangeValidator control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.RangeValidator toYearRangeValidator; - } -} diff --git a/src/PostSharp.LicenseServer/Admin/GenerateDemoData.aspx b/src/PostSharp.LicenseServer/Admin/GenerateDemoData.aspx deleted file mode 100644 index faa9367..0000000 --- a/src/PostSharp.LicenseServer/Admin/GenerateDemoData.aspx +++ /dev/null @@ -1,14 +0,0 @@ -<%@ Page Title="" Language="C#" MasterPageFile="~/Site.Master" AutoEventWireup="true" CodeBehind="GenerateDemoData.aspx.cs" Inherits="PostSharp.LicenseServer.Admin.GenerateDemoData" %> - - - - Product: - -
- -
-Count: - -
- -
diff --git a/src/PostSharp.LicenseServer/Admin/GenerateDemoData.aspx.cs b/src/PostSharp.LicenseServer/Admin/GenerateDemoData.aspx.cs deleted file mode 100644 index 352788f..0000000 --- a/src/PostSharp.LicenseServer/Admin/GenerateDemoData.aspx.cs +++ /dev/null @@ -1,242 +0,0 @@ -#region Copyright (c) 2004-2010 by SharpCrafters s.r.o. - -// This file is part of PostSharp source code and is the property of SharpCrafters s.r.o. -// -// Source code is provided to customers under strict non-disclosure agreement (NDA). -// YOU MUST HAVE READ THE NDA BEFORE HAVING RECEIVED ACCESS TO THIS SOURCE CODE. -// Severe financial penalties apply in case of non respect of the NDA. - -#endregion - -using System; -using System.Collections.Generic; -using System.IO; -using System.Web.UI; -using ParsedLicense = PostSharp.Sdk.Extensibility.Licensing.License; - -namespace PostSharp.LicenseServer.Admin -{ - public partial class GenerateDemoData : Page - { - private const string unparsedNames = - @"David,Rahm -Jimmy,Smith -Carroll,Lash -Keith,Smith -Wm,Martinez -Marsha,Bassham -Mike,Bergeron -Julio,Bayliss -Salvatore,Nail -Herbert,Thompson -Keith,Gentile -Gary,Turner -Jesse,Stinson -Louis,Callender -Duane,Rudder -Joan,Lowrey -Thomas,Bourdeau -Richard,Vega -Charles,Numbers -Paul,Cooper -Albert,Speier -Jerry,Johnson -Sidney,Painter -John,Denton -Monica,Grise -William,Davis -Miguel,Collins -Melanie,Johnson -Nida,Perez -George,Young -Gary,Wilkes -Thomas,Stoneburner -Richard,Bushong -Edmund,Davis -Michael,Smart -Dena,Anderson -Bobbie,Sherman -Ray,Roberts -Francisco,Linck -Jeremy,Thompson -Hubert,Nunnally -Marshall,Hoffman -Stephen,Montes -Wilfred,Cassel -Matthew,Sease -Edward,Johnson -Timothy,Hassell -Brian,Jones -Israel,West -Jesse,Bombard -Don,Tann -Brian,Stringer -Marilyn,Belanger -Donald,Miller -Patrick,Clark -Milton,Cranston -Russell,Christensen -Bill,Piper -Chester,Bennett -Dean,Mcgrath -Dennis,Ortiz -Paul,Arno -Manuel,Cole -Felix,Johnson -Nancy,Mccormick -Robert,Callan -Bernard,Vanwyk -Kelly,Gary -Robert,Murphy -Quincy,Grossman -Richard,Brown -Theodore,Flemming -Christopher,Young -David,Riddle -Donnie,Comstock -Bryan,Hartsock -Timothy,Villarreal -Troy,Thompson -Frederick,Johnson -Joseph,Davis -Christopher,Ayala -Cyrus,Smith -Thomas,Johnson -Monica,Tobin -Kyle,Pierce -Ginger,Miller -John,Smith -Michael,Hines -William,Hansen -Joseph,Hurlburt -Stephen,Green -Noe,Houle -Troy,Lofton -Kristofer,Booth -Joseph,Hoffman -Larry,Mcknight -Brian,Watson -Tom,Greenwood -Cory,Ryals -David,Bradway"; - - - private readonly Random random = new Random(); - - protected void Button1_Click( object sender, EventArgs e ) - { - Database db = new Database(); - - LeaseService leaseService = new LeaseService( false ); - string product = this.ProductTextBox.Text; - Version version = Version.Parse( this.VersionTextBox.Text ); - int maxUsers = int.Parse( this.CountTextBox.Text ); - - StringReader reader = new StringReader( unparsedNames ); - List users = new List(); - - - string line; - while ( (line = reader.ReadLine()) != null ) - { - string[] parts = line.Split( ',' ); - - User user = new User - { - UserName = string.Format( "{0}.{1}", parts[0], parts[1] ).ToUpper(), - AuthenticatedName = string.Format( "CONTOSO\\{0}.{1}", parts[0], parts[1] ).ToUpper(), - WorksOnWeekend = random.NextDouble() - }; - double machineProb = random.NextDouble(); - - if ( machineProb < 0.7 ) - { - user.Machines.Add( string.Format( "DESKTOP-{0:x}", random.Next( 0, ushort.MaxValue ) ) ); - } - else - { - user.Machines.Add( string.Format( "DESKTOP-{0:x}", random.Next( 0, ushort.MaxValue ) ) ); - user.Machines.Add( string.Format( "NOTEBOOK-{0:x}", random.Next( 0, ushort.MaxValue ) ) ); - } - - users.Add( user ); - } - - const int days = 90; - DateTime startDate = DateTime.Today.AddDays( -days ); - int numberNewUsersPerDay = (int) Math.Ceiling( 2*random.NextDouble()*maxUsers/days ); - - List activeUsers = new List(); - for ( int i = 0; i < days; i++ ) - { - DateTime day = startDate.AddDays( i ); - DateTime time = day.AddHours( 7 ); - - List removeList = new List(); - foreach ( User user in activeUsers ) - { - double probWorksToday; - switch ( day.DayOfWeek ) - { - case DayOfWeek.Sunday: - probWorksToday = user.WorksOnWeekend*0.3; - break; - case DayOfWeek.Saturday: - probWorksToday = user.WorksOnWeekend*0.7; - break; - default: - probWorksToday = 0.9; - break; - } - - if ( random.NextDouble() < probWorksToday ) - { - // Which machine will he use this day? - int machineIndex = (int) Math.Floor( random.NextDouble()*user.Machines.Count ); - string machine = user.Machines[machineIndex]; - Dictionary errors = new Dictionary(); - time = time.AddHours( random.NextDouble()*3.0/activeUsers.Count ); - DateTime buildDate = time; - Lease lease = leaseService.GetLease( db, product, version, buildDate, machine, user.UserName, user.AuthenticatedName, time, errors ); - if ( lease != null ) - { - db.SubmitChanges(); - LeaseService.CheckAssertions( db, lease.License, time ); - } - } - - // Will this user stop using the product? - if ( random.NextDouble()*activeUsers.Count < 0.02 ) - removeList.Add( user ); - } - - // Enlist new users. - - - for ( int j = 0; j < numberNewUsersPerDay; j++ ) - { - if ( users.Count > 0 ) - { - activeUsers.Add( users[0] ); - users.RemoveAt( 0 ); - } - } - - // Remove users. - foreach ( User user in removeList ) - { - activeUsers.Remove( user ); - users.Add( user ); - } - } - } - - private new class User - { - public string UserName; - public string AuthenticatedName; - public double WorksOnWeekend; - public readonly List Machines = new List(); - } - } -} \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/Admin/GenerateDemoData.aspx.designer.cs b/src/PostSharp.LicenseServer/Admin/GenerateDemoData.aspx.designer.cs deleted file mode 100644 index e14f62e..0000000 --- a/src/PostSharp.LicenseServer/Admin/GenerateDemoData.aspx.designer.cs +++ /dev/null @@ -1,51 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace PostSharp.LicenseServer.Admin { - - - public partial class GenerateDemoData { - - /// - /// ProductTextBox control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.TextBox ProductTextBox; - - /// - /// VersionTextBox control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.TextBox VersionTextBox; - - /// - /// CountTextBox control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.TextBox CountTextBox; - - /// - /// Button1 control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.Button Button1; - } -} diff --git a/src/PostSharp.LicenseServer/DataClasses.dbml b/src/PostSharp.LicenseServer/DataClasses.dbml deleted file mode 100644 index 61ce7cd..0000000 --- a/src/PostSharp.LicenseServer/DataClasses.dbml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - -
-
\ No newline at end of file diff --git a/src/PostSharp.LicenseServer/DataClasses.dbml.layout b/src/PostSharp.LicenseServer/DataClasses.dbml.layout deleted file mode 100644 index c54fec4..0000000 --- a/src/PostSharp.LicenseServer/DataClasses.dbml.layout +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/DataClasses.designer.cs b/src/PostSharp.LicenseServer/DataClasses.designer.cs deleted file mode 100644 index e88577e..0000000 --- a/src/PostSharp.LicenseServer/DataClasses.designer.cs +++ /dev/null @@ -1,710 +0,0 @@ -#pragma warning disable 1591 -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace PostSharp.LicenseServer -{ - using System.Data.Linq; - using System.Data.Linq.Mapping; - using System.Data; - using System.Collections.Generic; - using System.Reflection; - using System.Linq; - using System.Linq.Expressions; - using System.ComponentModel; - using System; - - - [global::System.Data.Linq.Mapping.DatabaseAttribute(Name="PostSharp.LicenseServer")] - public partial class Database : System.Data.Linq.DataContext - { - - private static System.Data.Linq.Mapping.MappingSource mappingSource = new AttributeMappingSource(); - - #region Extensibility Method Definitions - partial void OnCreated(); - partial void InsertLicense(License instance); - partial void UpdateLicense(License instance); - partial void DeleteLicense(License instance); - partial void InsertLease(Lease instance); - partial void UpdateLease(Lease instance); - partial void DeleteLease(Lease instance); - #endregion - - public Database() : - base(global::System.Configuration.ConfigurationManager.ConnectionStrings["SharpCrafters_LicenseServerConnectionString"].ConnectionString, mappingSource) - { - OnCreated(); - } - - public Database(string connection) : - base(connection, mappingSource) - { - OnCreated(); - } - - public Database(System.Data.IDbConnection connection) : - base(connection, mappingSource) - { - OnCreated(); - } - - public Database(string connection, System.Data.Linq.Mapping.MappingSource mappingSource) : - base(connection, mappingSource) - { - OnCreated(); - } - - public Database(System.Data.IDbConnection connection, System.Data.Linq.Mapping.MappingSource mappingSource) : - base(connection, mappingSource) - { - OnCreated(); - } - - public System.Data.Linq.Table Licenses - { - get - { - return this.GetTable(); - } - } - - public System.Data.Linq.Table Leases - { - get - { - return this.GetTable(); - } - } - } - - [global::System.Data.Linq.Mapping.TableAttribute(Name="dbo.Licenses")] - public partial class License : INotifyPropertyChanging, INotifyPropertyChanged - { - - private static PropertyChangingEventArgs emptyChangingEventArgs = new PropertyChangingEventArgs(String.Empty); - - private int _LicenseId; - - private string _LicenseKey; - - private string _ProductCode; - - private int _Priority; - - private System.DateTime _CreatedOn; - - private System.Nullable _GraceStartTime; - - private System.Nullable _GraceLastWarningTime; - - private EntitySet _Leases; - - #region Extensibility Method Definitions - partial void OnLoaded(); - partial void OnValidate(System.Data.Linq.ChangeAction action); - partial void OnCreated(); - partial void OnLicenseIdChanging(int value); - partial void OnLicenseIdChanged(); - partial void OnLicenseKeyChanging(string value); - partial void OnLicenseKeyChanged(); - partial void OnProductCodeChanging(string value); - partial void OnProductCodeChanged(); - partial void OnPriorityChanging(int value); - partial void OnPriorityChanged(); - partial void OnCreatedOnChanging(System.DateTime value); - partial void OnCreatedOnChanged(); - partial void OnGraceStartTimeChanging(System.Nullable value); - partial void OnGraceStartTimeChanged(); - partial void OnGraceLastWarningTimeChanging(System.Nullable value); - partial void OnGraceLastWarningTimeChanged(); - #endregion - - public License() - { - this._Leases = new EntitySet(new Action(this.attach_Leases), new Action(this.detach_Leases)); - OnCreated(); - } - - [global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_LicenseId", DbType="Int NOT NULL", IsPrimaryKey=true)] - public int LicenseId - { - get - { - return this._LicenseId; - } - set - { - if ((this._LicenseId != value)) - { - this.OnLicenseIdChanging(value); - this.SendPropertyChanging(); - this._LicenseId = value; - this.SendPropertyChanged("LicenseId"); - this.OnLicenseIdChanged(); - } - } - } - - [global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_LicenseKey", DbType="Text NOT NULL", CanBeNull=false, UpdateCheck=UpdateCheck.Never)] - public string LicenseKey - { - get - { - return this._LicenseKey; - } - set - { - if ((this._LicenseKey != value)) - { - this.OnLicenseKeyChanging(value); - this.SendPropertyChanging(); - this._LicenseKey = value; - this.SendPropertyChanged("LicenseKey"); - this.OnLicenseKeyChanged(); - } - } - } - - [global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_ProductCode", DbType="VarChar(50) NOT NULL", CanBeNull=false)] - public string ProductCode - { - get - { - return this._ProductCode; - } - set - { - if ((this._ProductCode != value)) - { - this.OnProductCodeChanging(value); - this.SendPropertyChanging(); - this._ProductCode = value; - this.SendPropertyChanged("ProductCode"); - this.OnProductCodeChanged(); - } - } - } - - [global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_Priority", DbType="Int NOT NULL")] - public int Priority - { - get - { - return this._Priority; - } - set - { - if ((this._Priority != value)) - { - this.OnPriorityChanging(value); - this.SendPropertyChanging(); - this._Priority = value; - this.SendPropertyChanged("Priority"); - this.OnPriorityChanged(); - } - } - } - - [global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_CreatedOn", DbType="DateTime NOT NULL")] - public System.DateTime CreatedOn - { - get - { - return this._CreatedOn; - } - set - { - if ((this._CreatedOn != value)) - { - this.OnCreatedOnChanging(value); - this.SendPropertyChanging(); - this._CreatedOn = value; - this.SendPropertyChanged("CreatedOn"); - this.OnCreatedOnChanged(); - } - } - } - - [global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_GraceStartTime", DbType="DateTime")] - public System.Nullable GraceStartTime - { - get - { - return this._GraceStartTime; - } - set - { - if ((this._GraceStartTime != value)) - { - this.OnGraceStartTimeChanging(value); - this.SendPropertyChanging(); - this._GraceStartTime = value; - this.SendPropertyChanged("GraceStartTime"); - this.OnGraceStartTimeChanged(); - } - } - } - - [global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_GraceLastWarningTime", DbType="DateTime")] - public System.Nullable GraceLastWarningTime - { - get - { - return this._GraceLastWarningTime; - } - set - { - if ((this._GraceLastWarningTime != value)) - { - this.OnGraceLastWarningTimeChanging(value); - this.SendPropertyChanging(); - this._GraceLastWarningTime = value; - this.SendPropertyChanged("GraceLastWarningTime"); - this.OnGraceLastWarningTimeChanged(); - } - } - } - - [global::System.Data.Linq.Mapping.AssociationAttribute(Name="License_Lease", Storage="_Leases", ThisKey="LicenseId", OtherKey="LicenseId")] - public EntitySet Leases - { - get - { - return this._Leases; - } - set - { - this._Leases.Assign(value); - } - } - - public event PropertyChangingEventHandler PropertyChanging; - - public event PropertyChangedEventHandler PropertyChanged; - - protected virtual void SendPropertyChanging() - { - if ((this.PropertyChanging != null)) - { - this.PropertyChanging(this, emptyChangingEventArgs); - } - } - - protected virtual void SendPropertyChanged(String propertyName) - { - if ((this.PropertyChanged != null)) - { - this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName)); - } - } - - private void attach_Leases(Lease entity) - { - this.SendPropertyChanging(); - entity.License = this; - } - - private void detach_Leases(Lease entity) - { - this.SendPropertyChanging(); - entity.License = null; - } - } - - [global::System.Data.Linq.Mapping.TableAttribute(Name="dbo.Leases")] - public partial class Lease : INotifyPropertyChanging, INotifyPropertyChanged - { - - private static PropertyChangingEventArgs emptyChangingEventArgs = new PropertyChangingEventArgs(String.Empty); - - private int _LeaseId; - - private System.Nullable _OverwrittenLeaseId; - - private int _LicenseId; - - private System.DateTime _StartTime; - - private System.DateTime _EndTime; - - private string _UserName; - - private string _Machine; - - private string _AuthenticatedUser; - - private string _HMAC; - - private bool _Grace; - - private EntitySet _OverwrittenByLease; - - private EntityRef _OverwritesLease; - - private EntityRef _License; - - #region Extensibility Method Definitions - partial void OnLoaded(); - partial void OnValidate(System.Data.Linq.ChangeAction action); - partial void OnCreated(); - partial void OnLeaseIdChanging(int value); - partial void OnLeaseIdChanged(); - partial void OnOverwrittenLeaseIdChanging(System.Nullable value); - partial void OnOverwrittenLeaseIdChanged(); - partial void OnLicenseIdChanging(int value); - partial void OnLicenseIdChanged(); - partial void OnStartTimeChanging(System.DateTime value); - partial void OnStartTimeChanged(); - partial void OnEndTimeChanging(System.DateTime value); - partial void OnEndTimeChanged(); - partial void OnUserNameChanging(string value); - partial void OnUserNameChanged(); - partial void OnMachineChanging(string value); - partial void OnMachineChanged(); - partial void OnAuthenticatedUserChanging(string value); - partial void OnAuthenticatedUserChanged(); - partial void OnHMACChanging(string value); - partial void OnHMACChanged(); - partial void OnGraceChanging(bool value); - partial void OnGraceChanged(); - #endregion - - public Lease() - { - this._OverwrittenByLease = new EntitySet(new Action(this.attach_OverwrittenByLease), new Action(this.detach_OverwrittenByLease)); - this._OverwritesLease = default(EntityRef); - this._License = default(EntityRef); - OnCreated(); - } - - [global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_LeaseId", AutoSync=AutoSync.OnInsert, DbType="Int NOT NULL IDENTITY", IsPrimaryKey=true, IsDbGenerated=true)] - public int LeaseId - { - get - { - return this._LeaseId; - } - set - { - if ((this._LeaseId != value)) - { - this.OnLeaseIdChanging(value); - this.SendPropertyChanging(); - this._LeaseId = value; - this.SendPropertyChanged("LeaseId"); - this.OnLeaseIdChanged(); - } - } - } - - [global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_OverwrittenLeaseId", DbType="Int")] - public System.Nullable OverwrittenLeaseId - { - get - { - return this._OverwrittenLeaseId; - } - set - { - if ((this._OverwrittenLeaseId != value)) - { - if (this._OverwritesLease.HasLoadedOrAssignedValue) - { - throw new System.Data.Linq.ForeignKeyReferenceAlreadyHasValueException(); - } - this.OnOverwrittenLeaseIdChanging(value); - this.SendPropertyChanging(); - this._OverwrittenLeaseId = value; - this.SendPropertyChanged("OverwrittenLeaseId"); - this.OnOverwrittenLeaseIdChanged(); - } - } - } - - [global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_LicenseId", DbType="Int NOT NULL")] - public int LicenseId - { - get - { - return this._LicenseId; - } - set - { - if ((this._LicenseId != value)) - { - if (this._License.HasLoadedOrAssignedValue) - { - throw new System.Data.Linq.ForeignKeyReferenceAlreadyHasValueException(); - } - this.OnLicenseIdChanging(value); - this.SendPropertyChanging(); - this._LicenseId = value; - this.SendPropertyChanged("LicenseId"); - this.OnLicenseIdChanged(); - } - } - } - - [global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_StartTime", DbType="DateTime NOT NULL")] - public System.DateTime StartTime - { - get - { - return this._StartTime; - } - set - { - if ((this._StartTime != value)) - { - this.OnStartTimeChanging(value); - this.SendPropertyChanging(); - this._StartTime = value; - this.SendPropertyChanged("StartTime"); - this.OnStartTimeChanged(); - } - } - } - - [global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_EndTime", DbType="DateTime NOT NULL")] - public System.DateTime EndTime - { - get - { - return this._EndTime; - } - set - { - if ((this._EndTime != value)) - { - this.OnEndTimeChanging(value); - this.SendPropertyChanging(); - this._EndTime = value; - this.SendPropertyChanged("EndTime"); - this.OnEndTimeChanged(); - } - } - } - - [global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_UserName", DbType="NVarChar(200) NOT NULL", CanBeNull=false)] - public string UserName - { - get - { - return this._UserName; - } - set - { - if ((this._UserName != value)) - { - this.OnUserNameChanging(value); - this.SendPropertyChanging(); - this._UserName = value; - this.SendPropertyChanged("UserName"); - this.OnUserNameChanged(); - } - } - } - - [global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_Machine", DbType="NVarChar(200) NOT NULL", CanBeNull=false)] - public string Machine - { - get - { - return this._Machine; - } - set - { - if ((this._Machine != value)) - { - this.OnMachineChanging(value); - this.SendPropertyChanging(); - this._Machine = value; - this.SendPropertyChanged("Machine"); - this.OnMachineChanged(); - } - } - } - - [global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_AuthenticatedUser", DbType="NVarChar(200) NOT NULL", CanBeNull=false)] - public string AuthenticatedUser - { - get - { - return this._AuthenticatedUser; - } - set - { - if ((this._AuthenticatedUser != value)) - { - this.OnAuthenticatedUserChanging(value); - this.SendPropertyChanging(); - this._AuthenticatedUser = value; - this.SendPropertyChanged("AuthenticatedUser"); - this.OnAuthenticatedUserChanged(); - } - } - } - - [global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_HMAC", DbType="VarChar(100)")] - public string HMAC - { - get - { - return this._HMAC; - } - set - { - if ((this._HMAC != value)) - { - this.OnHMACChanging(value); - this.SendPropertyChanging(); - this._HMAC = value; - this.SendPropertyChanged("HMAC"); - this.OnHMACChanged(); - } - } - } - - [global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_Grace", DbType="Bit NOT NULL")] - public bool Grace - { - get - { - return this._Grace; - } - set - { - if ((this._Grace != value)) - { - this.OnGraceChanging(value); - this.SendPropertyChanging(); - this._Grace = value; - this.SendPropertyChanged("Grace"); - this.OnGraceChanged(); - } - } - } - - [global::System.Data.Linq.Mapping.AssociationAttribute(Name="Lease_Lease", Storage="_OverwrittenByLease", ThisKey="LeaseId", OtherKey="OverwrittenLeaseId")] - public EntitySet OverwrittenByLease - { - get - { - return this._OverwrittenByLease; - } - set - { - this._OverwrittenByLease.Assign(value); - } - } - - [global::System.Data.Linq.Mapping.AssociationAttribute(Name="Lease_Lease", Storage="_OverwritesLease", ThisKey="OverwrittenLeaseId", OtherKey="LeaseId", IsForeignKey=true)] - public Lease OverwritesLease - { - get - { - return this._OverwritesLease.Entity; - } - set - { - Lease previousValue = this._OverwritesLease.Entity; - if (((previousValue != value) - || (this._OverwritesLease.HasLoadedOrAssignedValue == false))) - { - this.SendPropertyChanging(); - if ((previousValue != null)) - { - this._OverwritesLease.Entity = null; - previousValue.OverwrittenByLease.Remove(this); - } - this._OverwritesLease.Entity = value; - if ((value != null)) - { - value.OverwrittenByLease.Add(this); - this._OverwrittenLeaseId = value.LeaseId; - } - else - { - this._OverwrittenLeaseId = default(Nullable); - } - this.SendPropertyChanged("OverwritesLease"); - } - } - } - - [global::System.Data.Linq.Mapping.AssociationAttribute(Name="License_Lease", Storage="_License", ThisKey="LicenseId", OtherKey="LicenseId", IsForeignKey=true)] - public License License - { - get - { - return this._License.Entity; - } - set - { - License previousValue = this._License.Entity; - if (((previousValue != value) - || (this._License.HasLoadedOrAssignedValue == false))) - { - this.SendPropertyChanging(); - if ((previousValue != null)) - { - this._License.Entity = null; - previousValue.Leases.Remove(this); - } - this._License.Entity = value; - if ((value != null)) - { - value.Leases.Add(this); - this._LicenseId = value.LicenseId; - } - else - { - this._LicenseId = default(int); - } - this.SendPropertyChanged("License"); - } - } - } - - public event PropertyChangingEventHandler PropertyChanging; - - public event PropertyChangedEventHandler PropertyChanged; - - protected virtual void SendPropertyChanging() - { - if ((this.PropertyChanging != null)) - { - this.PropertyChanging(this, emptyChangingEventArgs); - } - } - - protected virtual void SendPropertyChanged(String propertyName) - { - if ((this.PropertyChanged != null)) - { - this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName)); - } - } - - private void attach_OverwrittenByLease(Lease entity) - { - this.SendPropertyChanging(); - entity.OverwritesLease = this; - } - - private void detach_OverwrittenByLease(Lease entity) - { - this.SendPropertyChanging(); - entity.OverwritesLease = null; - } - } -} -#pragma warning restore 1591 diff --git a/src/PostSharp.LicenseServer/Database.cs b/src/PostSharp.LicenseServer/Database.cs deleted file mode 100644 index 4fea3a3..0000000 --- a/src/PostSharp.LicenseServer/Database.cs +++ /dev/null @@ -1,232 +0,0 @@ -#region Copyright (c) 2004-2010 by SharpCrafters s.r.o. - -// This file is part of PostSharp source code and is the property of SharpCrafters s.r.o. -// -// Source code is provided to customers under strict non-disclosure agreement (NDA). -// YOU MUST HAVE READ THE NDA BEFORE HAVING RECEIVED ACCESS TO THIS SOURCE CODE. -// Severe financial penalties apply in case of non respect of the NDA. - -#endregion - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using PostSharp.LicenseServer.Properties; - -namespace PostSharp.LicenseServer -{ - public partial class Database - { - public string GetSignature(Lease lease) - { - Lease lastLease = (from l in this.Leases orderby l.LeaseId descending select l).FirstOrDefault(); - - StringWriter stringWriter = new StringWriter(); - stringWriter.Write(lastLease != null ? lastLease.HMAC : ""); - stringWriter.Write(';'); - lease.Write(stringWriter, false); - - - using (HMAC hmac = HMAC.Create()) - { - return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(stringWriter.ToString()))); - } - } - - public Lease CreateLease(License license, string user, string machine, string authenticatedUserName, DateTime time, bool grace) - { - Lease lease = new Lease - { - License = license, - AuthenticatedUser = authenticatedUserName, - EndTime = time.AddDays(Settings.Default.NewLeaseDays), - Machine = machine, - StartTime = time, - UserName = user, - Grace = grace - }; - - if (!this.FixLease(lease, time)) - return null; - - - this.Leases.InsertOnSubmit((lease)); - - LeaseService.CheckAssertions(this, license, time); - - - return lease; - } - - public Lease ProlongLease(Lease oldLease, string authenticatedUserName, DateTime time) - { - Lease newLease = new Lease - { - License = oldLease.License, - AuthenticatedUser = authenticatedUserName, - OverwritesLease = oldLease, - StartTime = oldLease.StartTime, - EndTime = time.AddDays(Settings.Default.NewLeaseDays), - Machine = oldLease.Machine, - UserName = oldLease.UserName, - Grace = oldLease.Grace - }; - - if (!this.FixLease(newLease, time)) - return null; - - this.Leases.InsertOnSubmit(newLease); - - LeaseService.CheckAssertions( this, newLease.License, time ); - - return newLease; - } - - - public void CancelLease(Lease lease, string authenticatedUserName, DateTime time) - { - Lease overwrite = new Lease - { - AuthenticatedUser = authenticatedUserName, - License = lease.License, - UserName = lease.UserName, - Machine = lease.Machine, - StartTime = lease.StartTime, - OverwritesLease = lease, - Grace = lease.Grace, - EndTime = time, - }; - this.FixLease( overwrite, time, false ); - this.Leases.InsertOnSubmit( overwrite ); - } - - private bool FixLease(Lease lease, DateTime time, bool fixEndTime = true) - { - PostSharp.Sdk.Extensibility.Licensing.License parsedLicense = ParsedLicenseManager.GetParsedLicense(lease.License.LicenseKey); - - if (lease.EndTime <= lease.StartTime) - throw new Exception( "Assertion failed." ); - - if (fixEndTime) - { - if ( parsedLicense.ValidTo.HasValue && parsedLicense.ValidTo < lease.EndTime ) - { - lease.EndTime = parsedLicense.ValidTo.Value; - } - - if ( lease.Grace ) - { - DateTime graceEnd = lease.License.GraceStartTime.Value.AddDays( parsedLicense.GetGraceDaysOrDefault() ); - if ( lease.EndTime > graceEnd ) - { - lease.EndTime = graceEnd; - } - } - - - if ( lease.EndTime <= time ) - return false; - - if (lease.EndTime <= lease.StartTime) - throw new Exception("Assertion failed."); - } - - lease.HMAC = this.GetSignature(lease); - - - return true; - } - - public IQueryable OpenLeases - { - get - { - return from l in this.Leases - join Lease o in this.Leases on l.LeaseId equals o.OverwrittenLeaseId into o - from oo in o.DefaultIfEmpty() - where oo == null - select l; - } - } - - public IEnumerable GetLeaseCountingPoints(int licenseId, DateTime startTime, DateTime endTime) - { - IQueryable openingLeases = from l in this.OpenLeases - where l.LicenseId == licenseId && - l.StartTime <= endTime && - l.EndTime > startTime - select new LeaseCountingPoint {Time = l.StartTime, Kind = LeaseCountingPointKind.Open, Lease = l}; - - IQueryable closingLeases = from l in this.OpenLeases - where l.LicenseId == licenseId && - l.StartTime <= endTime && - l.EndTime > startTime - select new LeaseCountingPoint { Time = l.EndTime, Kind = LeaseCountingPointKind.Close, Lease = l }; - - - IQueryable allRecords = from l in openingLeases.Concat(closingLeases) orderby l.Time, l.Kind select l; - - Dictionary> currentUsers = new Dictionary>(); - - int leaseCount = 0; - foreach (LeaseCountingPoint record in allRecords) - { - List machines; - if (!currentUsers.TryGetValue(record.Lease.UserName, out machines)) - { - machines = new List(); - currentUsers.Add(record.Lease.UserName, machines); - } - - int licensesBefore = (int)Math.Ceiling(machines.Count / (float)Settings.Default.MachinesPerUser); - - if (record.Kind == LeaseCountingPointKind.Open) - { - if ( !machines.Contains( record.Lease.Machine, StringComparer.OrdinalIgnoreCase ) ) - { - machines.Add(record.Lease.Machine); - } - } - else if ( record.Kind == LeaseCountingPointKind.Close ) - { - string machine = record.Lease.Machine; - int index = machines.FindIndex( s => string.Equals( s, machine, StringComparison.OrdinalIgnoreCase ) ); - - if ( index >= 0 ) - { - machines.RemoveAt( index ); - } - else - { - throw new Exception("Assertion failed."); - } - } - - int licensesAfter = (int)Math.Ceiling(machines.Count / (float)Settings.Default.MachinesPerUser); - - leaseCount += licensesAfter - licensesBefore; - record.LeaseCount = leaseCount; - - yield return record; - } - } - - public int GetActiveLeads(int licenseId, DateTime dateTime) - { - IQueryable machinesPerUser = from l in this.OpenLeases - where l.StartTime <= dateTime && l.EndTime > dateTime && l.LicenseId == licenseId - group l by l.UserName - into u - select u.Count(); - - IQueryable query = from u in machinesPerUser - group u by 0 - into g select g.Sum( i => Math.Ceiling( i/(double) Settings.Default.MachinesPerUser ) ); - - return (int) query.SingleOrDefault(); - } - } -} \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/Default.aspx b/src/PostSharp.LicenseServer/Default.aspx deleted file mode 100644 index 87cc808..0000000 --- a/src/PostSharp.LicenseServer/Default.aspx +++ /dev/null @@ -1,42 +0,0 @@ -<%@ Page Title="PostSharp License Server" Language="C#" MasterPageFile="~/Site.Master" AutoEventWireup="true" - CodeBehind="Default.aspx.cs" Inherits="PostSharp.LicenseServer.DefaultPage" %> -<%@ Import Namespace="PostSharp.LicenseServer" %> - - - - -

- Licenses Installed

- - - - - - - - - - - - - - - - - -

No license has been installed in this license server.

-
-

- Install a new license -
- Export logs -

- -

- Page generated on <%=VirtualDateTime.UtcNow.ToLocalTime()%>. -

- -
diff --git a/src/PostSharp.LicenseServer/Default.aspx.cs b/src/PostSharp.LicenseServer/Default.aspx.cs deleted file mode 100644 index da6dc46..0000000 --- a/src/PostSharp.LicenseServer/Default.aspx.cs +++ /dev/null @@ -1,67 +0,0 @@ -#region Copyright (c) 2004-2010 by SharpCrafters s.r.o. - -// This file is part of PostSharp source code and is the property of SharpCrafters s.r.o. -// -// Source code is provided to customers under strict non-disclosure agreement (NDA). -// YOU MUST HAVE READ THE NDA BEFORE HAVING RECEIVED ACCESS TO THIS SOURCE CODE. -// Severe financial penalties apply in case of non respect of the NDA. - -#endregion - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Web.UI; -using ParsedLicense = PostSharp.Sdk.Extensibility.Licensing.License; - -namespace PostSharp.LicenseServer -{ - public partial class DefaultPage : Page - { - protected override void OnLoad( EventArgs e ) - { - Database db = new Database(); - License[] licenses = (from l in db.Licenses orderby l.Priority, l.LicenseId descending select l).ToArray(); - List licenseInfos = new List(); - - for ( int i = 0; i < licenses.Length; i++ ) - { - License license = licenses[i]; - LicenseInfo licenseInfo = new LicenseInfo {LicenseId = license.LicenseId}; - ParsedLicense parsedLicense = ParsedLicenseManager.GetParsedLicense( license.LicenseKey ); - if ( parsedLicense == null ) - { - licenseInfo.LicenseType = "INVALID"; - } - else - { - licenseInfo.LicenseType = parsedLicense.LicenseType.ToString(); - licenseInfo.MaxUsers = parsedLicense.UserNumber; - licenseInfo.CurrentUsers = db.GetActiveLeads( license.LicenseId, VirtualDateTime.UtcNow ); - licenseInfo.ProductCode = parsedLicense.Product.ToString(); - licenseInfo.GraceStartTime = license.GraceStartTime; - licenseInfo.Status = license.Priority >= 0 ? "Active" : "Disabled"; - licenseInfo.MaintenanceEndDate = parsedLicense.SubscriptionEndDate; - } - - licenseInfos.Add( licenseInfo ); - } - - this.noLicensePanel.Visible = licenseInfos.Count == 0; - this.GridView1.DataSource = licenseInfos; - this.GridView1.DataBind(); - } - - private class LicenseInfo - { - public int LicenseId { get; set; } - public string LicenseType { get; set; } - public string ProductCode { get; set; } - public int? MaxUsers { get; set; } - public int CurrentUsers { get; set; } - public DateTime? GraceStartTime { get; set; } - public string Status { get; set; } - public DateTime? MaintenanceEndDate { get; set; } - } - } -} \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/Default.aspx.designer.cs b/src/PostSharp.LicenseServer/Default.aspx.designer.cs deleted file mode 100644 index f9ca91a..0000000 --- a/src/PostSharp.LicenseServer/Default.aspx.designer.cs +++ /dev/null @@ -1,33 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace PostSharp.LicenseServer { - - - public partial class DefaultPage { - - /// - /// GridView1 control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.GridView GridView1; - - /// - /// noLicensePanel control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.Panel noLicensePanel; - } -} diff --git a/src/PostSharp.LicenseServer/ExtensionMethods.cs b/src/PostSharp.LicenseServer/ExtensionMethods.cs deleted file mode 100644 index a6ca78a..0000000 --- a/src/PostSharp.LicenseServer/ExtensionMethods.cs +++ /dev/null @@ -1,26 +0,0 @@ -#region Copyright (c) 2004-2010 by SharpCrafters s.r.o. - -// This file is part of PostSharp source code and is the property of SharpCrafters s.r.o. -// -// Source code is provided to customers under strict non-disclosure agreement (NDA). -// YOU MUST HAVE READ THE NDA BEFORE HAVING RECEIVED ACCESS TO THIS SOURCE CODE. -// Severe financial penalties apply in case of non respect of the NDA. - -#endregion - -using System.Collections.Specialized; - -namespace PostSharp.LicenseServer -{ - internal static class ExtensionMethods - { - public static int GetInt32( this NameValueCollection collection, string name, int defaultValue ) - { - string value = collection[name]; - int intValue; - if ( string.IsNullOrEmpty( value ) || !int.TryParse( value, out intValue ) ) return defaultValue; - - return intValue; - } - } -} \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/GetTime.ashx b/src/PostSharp.LicenseServer/GetTime.ashx deleted file mode 100644 index ae6273d..0000000 --- a/src/PostSharp.LicenseServer/GetTime.ashx +++ /dev/null @@ -1 +0,0 @@ -<%@ WebHandler Language="C#" CodeBehind="GetTime.ashx.cs" Class="PostSharp.LicenseServer.GetTime" %> diff --git a/src/PostSharp.LicenseServer/GetTime.ashx.cs b/src/PostSharp.LicenseServer/GetTime.ashx.cs deleted file mode 100644 index 3710142..0000000 --- a/src/PostSharp.LicenseServer/GetTime.ashx.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Web; -using System.Xml; - -namespace PostSharp.LicenseServer -{ - /// - /// Summary description for GetTime - /// - public class GetTime : IHttpHandler - { - - public void ProcessRequest(HttpContext context) - { - context.Response.ContentType = "text/plain"; - context.Response.Write(XmlConvert.ToString(VirtualDateTime.UtcNow, XmlDateTimeSerializationMode.Utc)); - context.Response.Write(";"); - context.Response.Write(XmlConvert.ToString(VirtualDateTime.Acceleration)); - } - - public bool IsReusable - { - get - { - return true; - } - } - } -} \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/Graph.aspx b/src/PostSharp.LicenseServer/Graph.aspx deleted file mode 100644 index be0b06b..0000000 --- a/src/PostSharp.LicenseServer/Graph.aspx +++ /dev/null @@ -1,162 +0,0 @@ -<%@ Page Title="" Language="C#" MasterPageFile="~/Site.Master" AutoEventWireup="true" - CodeBehind="Graph.aspx.cs" Inherits="PostSharp.LicenseServer.Graph" %> - - - - - - - - - -

- Back -

- -

License <%=this.LicenseId%>: <%=this.Days%>-Days Usage History

- -

- 30 days | - 90 days | - 180 days | - 365 days -

- -
-
- - - - diff --git a/src/PostSharp.LicenseServer/Graph.aspx.cs b/src/PostSharp.LicenseServer/Graph.aspx.cs deleted file mode 100644 index d6d32c9..0000000 --- a/src/PostSharp.LicenseServer/Graph.aspx.cs +++ /dev/null @@ -1,122 +0,0 @@ -#region Copyright (c) 2004-2010 by SharpCrafters s.r.o. - -// This file is part of PostSharp source code and is the property of SharpCrafters s.r.o. -// -// Source code is provided to customers under strict non-disclosure agreement (NDA). -// YOU MUST HAVE READ THE NDA BEFORE HAVING RECEIVED ACCESS TO THIS SOURCE CODE. -// Severe financial penalties apply in case of non respect of the NDA. - -#endregion - -using System; -using System.Globalization; -using System.Linq; -using System.Web.UI; -using ParsedLicense = PostSharp.Sdk.Extensibility.Licensing.License; - -namespace PostSharp.LicenseServer -{ - public partial class Graph : Page - { - protected string[] Keys; - protected string[] Values; - protected int? Maximum; - protected int? GraceMaximum; - protected int LicenseId; - protected int Days; - protected int AxisMaximum; - - protected override void OnLoad(EventArgs e) - { - Database db = new Database(); - - - LicenseId = int.Parse(this.Request.QueryString["id"]); - - Days = int.Parse( this.Request.QueryString["days"] ?? "30" ); - - DateTime endDate = VirtualDateTime.UtcNow.Date.AddDays(1); - DateTime startDate = endDate.AddDays( -Days ); - - // Retrieve license information. - License license = (from l in db.Licenses where l.LicenseId == LicenseId select l).Single(); - ParsedLicense parsedLicense = ParsedLicense.Deserialize(license.LicenseKey); - if (parsedLicense != null) - { - if (parsedLicense.UserNumber.HasValue) - { - this.Maximum = parsedLicense.UserNumber; - this.GraceMaximum = this.Maximum.GetValueOrDefault()*(100 + parsedLicense.GetGracePercentOrDefault())/100; - this.AxisMaximum = GraceMaximum.GetValueOrDefault(); - } - } - - - var q = from l in db.GetLeaseCountingPoints(LicenseId, startDate, endDate) - group l by l.Time.Date - into d - select - new - { - Date = d.Key, - Count = d.Max(point => point.LeaseCount), - LastCount = d.OrderByDescending(point => point.Time).First().LeaseCount - }; - - // Fill the array with data. - this.Keys = new string[Days]; - this.Values = new string[Days]; - var lastValues = new int?[Days]; - foreach ( var point in q ) - { - int day = (int) Math.Floor( point.Date.Subtract( startDate ).TotalDays ); - - if ( point.Count > this.AxisMaximum ) - { - this.AxisMaximum = point.Count; - } - - if ( day < 0 ) - { - this.Values[0] = point.Count.ToString(); - lastValues[0] = point.LastCount; - } - else if ( day < Days ) - { - this.Values[day] = point.Count.ToString(); - lastValues[day] = point.LastCount; - } - } - - // Do extrapolation of missing values. - string lastValue = "0"; - - for ( int i = 0; i < Days; i++ ) - { - DateTime date = startDate.AddDays( i ); - if (date.DayOfWeek == DayOfWeek.Monday) - { - this.Keys[i] = date.ToString( "MM/dd/yyyy", CultureInfo.InvariantCulture ); - } - if ( this.Values[i] == null ) - { - if (i > 0) - { - this.Values[i] = lastValue; - } - else - { - this.Values[0] = lastValue; - } - } - if ( lastValues[i] != null ) - { - lastValue = lastValues[i].ToString(); - } - } - - - this.AxisMaximum = (int) Math.Ceiling( Math.Ceiling( this.AxisMaximum*1.2 )/10)*10; - } - } -} \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/Graph.aspx.designer.cs b/src/PostSharp.LicenseServer/Graph.aspx.designer.cs deleted file mode 100644 index 689777f..0000000 --- a/src/PostSharp.LicenseServer/Graph.aspx.designer.cs +++ /dev/null @@ -1,17 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace PostSharp.LicenseServer -{ - - - public partial class Graph - { - } -} diff --git a/src/PostSharp.LicenseServer/Img/PostSharpText_Light_240x33.png b/src/PostSharp.LicenseServer/Img/PostSharpText_Light_240x33.png deleted file mode 100644 index 3437bc6..0000000 Binary files a/src/PostSharp.LicenseServer/Img/PostSharpText_Light_240x33.png and /dev/null differ diff --git a/src/PostSharp.LicenseServer/Lease.ashx b/src/PostSharp.LicenseServer/Lease.ashx deleted file mode 100644 index 4f5fc18..0000000 --- a/src/PostSharp.LicenseServer/Lease.ashx +++ /dev/null @@ -1 +0,0 @@ -<%@ WebHandler Language="C#" CodeBehind="Lease.ashx.cs" Class="PostSharp.LicenseServer.LeaseHandler" %> diff --git a/src/PostSharp.LicenseServer/Lease.ashx.cs b/src/PostSharp.LicenseServer/Lease.ashx.cs deleted file mode 100644 index da03e46..0000000 --- a/src/PostSharp.LicenseServer/Lease.ashx.cs +++ /dev/null @@ -1,206 +0,0 @@ -#region Copyright (c) 2004-2010 by SharpCrafters s.r.o. - -// This file is part of PostSharp source code and is the property of SharpCrafters s.r.o. -// -// Source code is provided to customers under strict non-disclosure agreement (NDA). -// YOU MUST HAVE READ THE NDA BEFORE HAVING RECEIVED ACCESS TO THIS SOURCE CODE. -// Severe financial penalties apply in case of non respect of the NDA. - -#endregion - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.Linq; -using System.Threading; -using System.Web; -using PostSharp.Sdk.Extensibility.Licensing; -using PostSharp.LicenseServer.Properties; -using ParsedLicense = PostSharp.Sdk.Extensibility.Licensing.License; - -namespace PostSharp.LicenseServer -{ - /// - /// Summary description for Lease1 - /// - public sealed class LeaseHandler : IHttpHandler - { - private readonly Dictionary errors = new Dictionary(); - private HttpContext context; - static Mutex mutex = CreateMutex(); - - static LeaseHandler() - { - AppDomain.CurrentDomain.DomainUnload += (sender, args) => mutex.Close(); - } - - private static Mutex CreateMutex() - { - return new Mutex(false, "PostSharp.LicenseServer.Lease"); - } - - public void ProcessRequest(HttpContext context) - { - this.context = context; - using (Database db = new Database()) - { - - // Parse requested product code. - string productCode = context.Request.QueryString["product"]; - - // Parse version. - string versionString = context.Request.QueryString["version"]; - Version version; - if ( string.IsNullOrEmpty( versionString ) ) - { - // Versions < 5.0 do not include version in the request. - version = new Version( 4, 9, 9 ); - } - else - { - if ( !Version.TryParse( versionString, out version ) ) - { - this.SetError( 400, "Cannot parse the argument: version." ); - return; - } - } - - // Parse build date. - string buildDateString = context.Request.QueryString["buildDate"]; - DateTime? buildDate = null; - if (!string.IsNullOrEmpty(buildDateString)) - { - DateTime buildDateNotNull; - if ( - !DateTime.TryParse(buildDateString, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, - out buildDateNotNull)) - { - this.SetError(400, "Cannot parse the argument: buildDate."); - return; - } - - buildDate = buildDateNotNull; - } - - - - // Parse machine name. - string machine = context.Request.QueryString["machine"]; - if (string.IsNullOrEmpty(machine)) - { - this.SetError(400, "Missing query string argument: machine."); - return; - } - - machine = machine.ToLowerInvariant(); - - - // Parse user name. - string userName = context.Request.QueryString["user"]; - - if (string.IsNullOrEmpty(userName)) - { - this.SetError(400, "Missing query string argument: user."); - return; - } - - userName = userName.ToLowerInvariant(); - - - - bool releaseMutex = false; - try - { - - while (true) - { - try - { - - if (!mutex.WaitOne(TimeSpan.FromSeconds(Settings.Default.MutexTimeout))) - { - this.SetError(503, "Service overloaded."); - return; - } - else - { - releaseMutex = true; - break; - } - - } - catch (AbandonedMutexException) - { - try - { - mutex.ReleaseMutex(); - } - catch - { - } - - try - { - mutex.Close(); - } - catch - { - } - - mutex = CreateMutex(); - } - } - - Stopwatch stopwatch = Stopwatch.StartNew(); - - LicenseLease licenseLease = new LeaseService(true).GetLicenseLease(db, productCode, version, buildDate, machine, userName, context.User.Identity.Name, VirtualDateTime.UtcNow, errors); - - if (licenseLease != null) - { - db.SubmitChanges(); - - string serializedLease = licenseLease.Serialize(); - context.Response.Write(serializedLease); - } - else - { - this.SetError(403, "No license with free capacity. " + string.Join(" ", this.errors.Values)); - } - - if (stopwatch.ElapsedMilliseconds > 1000) - { - context.Trace.Warn(string.Format("Request served in {0}", stopwatch.Elapsed)); - } - } - finally - { - if (releaseMutex) - { - try - { - mutex.ReleaseMutex(); - } - catch - { - - } - } - } - } - } - - private void SetError(int statusCode, string description) - { - this.context.Response.StatusCode = statusCode; - this.context.Response.Write(description); - this.context.Response.End(); - } - - - public bool IsReusable - { - get { return false; } - } - } -} \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/Lease.cs b/src/PostSharp.LicenseServer/Lease.cs deleted file mode 100644 index b38c646..0000000 --- a/src/PostSharp.LicenseServer/Lease.cs +++ /dev/null @@ -1,41 +0,0 @@ -#region Copyright (c) 2004-2010 by SharpCrafters s.r.o. - -// This file is part of PostSharp source code and is the property of SharpCrafters s.r.o. -// -// Source code is provided to customers under strict non-disclosure agreement (NDA). -// YOU MUST HAVE READ THE NDA BEFORE HAVING RECEIVED ACCESS TO THIS SOURCE CODE. -// Severe financial penalties apply in case of non respect of the NDA. - -#endregion - -using System.IO; -using System.Xml; -using PostSharp.Sdk.Extensibility.Licensing; - -namespace PostSharp.LicenseServer -{ - partial class Lease - { - public void Write( TextWriter textWriter, bool includeHmac ) - { - textWriter.Write( this.LeaseId ); - textWriter.Write( ';' ); - textWriter.Write( this.OverwrittenLeaseId ); - textWriter.Write( ';' ); - textWriter.Write( this.LicenseId ); - textWriter.Write( ';' ); - textWriter.Write( XmlConvert.ToString( this.StartTime, XmlDateTimeSerializationMode.Utc ) ); - textWriter.Write( ';' ); - textWriter.Write( XmlConvert.ToString( this.EndTime, XmlDateTimeSerializationMode.Utc ) ); - textWriter.Write( ';' ); - textWriter.Write( CryptoUtilities.ComputeStringHash64( this.Machine ).ToString( "x" ) ); - textWriter.Write( ';' ); - textWriter.Write( CryptoUtilities.ComputeStringHash64( this.UserName ).ToString( "x" ) ); - if ( includeHmac ) - { - textWriter.Write( ';' ); - textWriter.Write( this.HMAC ); - } - } - } -} \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/LeaseCountingPoint.cs b/src/PostSharp.LicenseServer/LeaseCountingPoint.cs deleted file mode 100644 index 59cc9ea..0000000 --- a/src/PostSharp.LicenseServer/LeaseCountingPoint.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; - -namespace PostSharp.LicenseServer -{ - public class LeaseCountingPoint - { - public DateTime Time; - public LeaseCountingPointKind Kind; - public Lease Lease; - public int LeaseCount; - } -} \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/LeaseCountingPointKind.cs b/src/PostSharp.LicenseServer/LeaseCountingPointKind.cs deleted file mode 100644 index f679648..0000000 --- a/src/PostSharp.LicenseServer/LeaseCountingPointKind.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace PostSharp.LicenseServer -{ - public enum LeaseCountingPointKind - { - // Process close before open! - Close = 1, - Open = 2, - } -} \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/LeaseService.cs b/src/PostSharp.LicenseServer/LeaseService.cs deleted file mode 100644 index 94782fc..0000000 --- a/src/PostSharp.LicenseServer/LeaseService.cs +++ /dev/null @@ -1,417 +0,0 @@ -#region Copyright (c) 2004-2010 by SharpCrafters s.r.o. - -// This file is part of PostSharp source code and is the property of SharpCrafters s.r.o. -// -// Source code is provided to customers under strict non-disclosure agreement (NDA). -// YOU MUST HAVE READ THE NDA BEFORE HAVING RECEIVED ACCESS TO THIS SOURCE CODE. -// Severe financial penalties apply in case of non respect of the NDA. - -#endregion - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Net.Mail; -using System.Text.RegularExpressions; -using PostSharp.Sdk.Extensibility.Licensing; -using PostSharp.LicenseServer.Properties; -using PostSharp.Sdk; -using ParsedLicense = PostSharp.Sdk.Extensibility.Licensing.License; - -namespace PostSharp.LicenseServer -{ - public class LeaseService - { - private readonly HashSet buildServers = new HashSet( StringComparer.OrdinalIgnoreCase ); - private readonly Regex computerUniqueIdRegex = new Regex( "-[0-9a-fA-F]+$", RegexOptions.Compiled ); - - public LeaseService( bool sendEmails ) - { - this.sendEmails = sendEmails; - if ( !string.IsNullOrWhiteSpace( Settings.Default.BuildServers ) ) - { - foreach (string buildServer in Settings.Default.BuildServers.Split( ';', ',', ' ' )) - { - if ( !string.IsNullOrWhiteSpace( buildServer ) ) - { - buildServers.Add( buildServer.Trim() ); - } - } - } - } - - private readonly bool sendEmails; - - internal static void CheckAssertions(Database db, License license, DateTime now) - { -#if FALSE - db.SubmitChanges(); - Dictionary cache = new Dictionary(); - List errors = new List(); - LicenseState licenseState = GetLicenseState( db, license, now, cache, errors ); - if ( licenseState == null ) - throw new Exception( string.Join( ". ", errors.ToArray() ) ); - - - if ( licenseState.Maximum.HasValue ) - { - double max = Math.Ceiling(licenseState.Maximum.Value * (100.0 + Settings.Default.GracePeriodPercent) / 100.0); - if (licenseState.Usage > max) - throw new Exception( "Maximum exceeded." ); - } -#endif - } - - private static LicenseState GetLicenseState(Database db, License license, Version version, DateTime? buildDate, DateTime now, Dictionary cache, Dictionary errors) - { - LicenseState licenseState; - - if (cache.TryGetValue(license.LicenseId, out licenseState)) - return licenseState; - - ParsedLicense parsedLicense = ParsedLicenseManager.GetParsedLicense(license.LicenseKey); - if (parsedLicense == null) - { - errors[license.LicenseId] = string.Format("The license key #{0} is invalid.", license.LicenseId); - return null; - } - - if (parsedLicense.MinPostSharpVersion > ApplicationInfo.Version) - { - errors[license.LicenseId] = string.Format( - "The license #{0} requires higher version of PostSharp on the License Server. Please upgrade PostSharp NuGet package of the License Server to >= {1}.{2}.{3}", - license.LicenseId, - parsedLicense.MinPostSharpVersion.Major, - parsedLicense.MinPostSharpVersion.Minor, - parsedLicense.MinPostSharpVersion.Build); - return null; - } - - if (parsedLicense.MinPostSharpVersion > version) - { - errors[license.LicenseId] = string.Format( - "The license #{0} of type {1} requires PostSharp version >= {2}.{3}.{4} but the requested version is {5}.{6}.{7}.", - license.LicenseId, - parsedLicense.LicenseType, - parsedLicense.MinPostSharpVersion.Major, - parsedLicense.MinPostSharpVersion.Minor, - parsedLicense.MinPostSharpVersion.Build, - version.Major, - version.Minor, - version.Build); - return null; - } - - if (!parsedLicense.IsLicenseServerEligible()) - { - errors[license.LicenseId] = string.Format("The license #{0}, of type {1}, cannot be used in the license server.", - license.LicenseId, parsedLicense.LicenseType); - return null; - } - - - if ( !(buildDate == null || parsedLicense.SubscriptionEndDate == null || buildDate <= parsedLicense.SubscriptionEndDate ) ) - { - // PostSharp version number has been introduced in the license server protocol in PostSharp v5. - if (version.Major >= 5) - { - errors[license.LicenseId] = string.Format( - "The maintenance subscription of license #{0} ends on {1:d} but the requested version {2}.{3}.{4} has been built on {5:d}.", - license.LicenseId, - parsedLicense.SubscriptionEndDate, - version.Major, - version.Minor, - version.Build, - buildDate ); - } - else - { - errors[license.LicenseId] = string.Format( - "The maintenance subscription of license #{0} ends on {1:d} but the requested version has been built on {2:d}.", - license.LicenseId, - parsedLicense.SubscriptionEndDate, - buildDate ); - } - - return null; - } - - - licenseState = new LicenseState(now, db, license, parsedLicense); - cache.Add(license.LicenseId, licenseState); - return licenseState; - } - - public LicenseLease GetLicenseLease(Database db, string productCode, Version version, DateTime? buildDate, string machine, string userName, string authenticatedUserName, DateTime now, Dictionary errors) - { - Settings settings = Settings.Default; - - // Get suitable active licenses. - License[] licenses = (from license in db.Licenses - where (string.IsNullOrEmpty(productCode) || license.ProductCode == productCode) && license.Priority >= 0 - orderby license.Priority - select license).ToArray(); - - // Check if the machine belongs to build server user. - if ( IsBuildServer( machine ) ) - { - License buildServerLicense = GetBuildServerLicense( licenses ); - if ( buildServerLicense != null ) - { - DateTime endTime = now.AddDays( Settings.Default.NewLeaseDays ); - LicenseLease buildServerLicenseLease = new LicenseLease( buildServerLicense.LicenseKey, now, endTime, endTime.AddDays( -settings.MinLeaseDays ) ); - - // Lease for build server should not be stored - build server users shouldn't steal developer licenses. - return buildServerLicenseLease; - } - - // try to acquire lease in normal way - } - - Lease lease = GetLease(db, productCode, version, buildDate, machine, userName, authenticatedUserName, now, errors, licenses); - if ( lease == null ) - return null; - - LicenseLease licenseLease = new LicenseLease(lease.License.LicenseKey, - lease.StartTime, lease.EndTime, lease.EndTime.AddDays(-settings.MinLeaseDays)); - - return licenseLease; - } - - public Lease GetLease(Database db, string productCode, Version version, DateTime? buildDate, string machine, string userName, string authenticatedUserName, DateTime now, Dictionary errors) - { - License[] licenses = (from license in db.Licenses - where license.ProductCode == productCode - orderby license.Priority, license.LicenseId descending - select license).ToArray(); - - return this.GetLease( db, productCode, version, buildDate, machine, userName, authenticatedUserName, now, errors, licenses ); - } - - public Lease GetLease( Database db, string productCode, Version version, DateTime? buildDate, string machine, string userName, string authenticatedUserName, DateTime now, Dictionary errors, License[] licenses ) - { - Dictionary licenseStates = new Dictionary(); - - Settings settings = Settings.Default; - - // Check if we have a lease that we can use. - foreach (License license in licenses) - { - LicenseState licenseState = GetLicenseState(db, license, version, buildDate, now, licenseStates, errors); - if ( licenseState == null ) continue; - - - - int licenseId = license.LicenseId; - Lease[] currentLeases = (from l in db.OpenLeases - where - l.LicenseId == licenseId && - l.StartTime <= now && - l.EndTime > now && - l.UserName == userName - orderby l.StartTime - select l).ToArray(); - - Dictionary machines = new Dictionary( StringComparer.OrdinalIgnoreCase ); - foreach (Lease candidateLease in currentLeases) - { - machines[candidateLease.Machine] = candidateLease.Machine; - - if (candidateLease.Machine != machine) - continue; - - if (candidateLease.EndTime > now.AddDays(settings.MinLeaseDays)) - { - // We have already a good lease. - return candidateLease; - } - else - { - // Invariant: we can always prolong a lease because licenses are always acquired from - // the present moment, therefore taking the current lease into account. - // UNLESS it's the end of the license period or the grace period. - Lease lease = db.ProlongLease( candidateLease, authenticatedUserName, now ); - if ( lease == null ) - { - continue; - } - return lease; - } - } - - // We did not find a lease for the requested machines. - // See if we can do a new lease that would not increment the number of concurrent users. - if (machines.Count % Settings.Default.MachinesPerUser != 0) - { - CheckAssertions( db, license, now ); - Lease lease = db.CreateLease(license, userName, machine, authenticatedUserName, now, licenseState.InExcess); - if (lease != null) return lease; - } - } - - // We don't have a current lease. Create a new one. - foreach (License license in licenses) - { - LicenseState licenseState = GetLicenseState(db, license, version, buildDate, now, licenseStates, errors); - if (licenseState == null) continue; - - - if (licenseState.Maximum.HasValue && licenseState.Maximum.Value <= licenseState.Usage - && ( !licenseState.ParsedLicense.ValidTo.HasValue || now < licenseState.ParsedLicense.ValidTo )) - { - // Not enough users for this license. - continue; - } - - - Lease lease = db.CreateLease( license, userName, machine, authenticatedUserName, now, false ); - if (lease != null) return lease; - } - - // We did not find a suitable license. See if we can use the grace period. - foreach (License license in licenses) - { - LicenseState licenseState = GetLicenseState(db, license, version, buildDate, now, licenseStates, errors); - - if ( licenseState == null ) continue; - - - if (license.GraceStartTime == null) - { - license.GraceStartTime = now; - } - - int graceLimit = (int)Math.Ceiling(licenseState.Maximum.Value * (100.0 + licenseState.ParsedLicense.GetGracePercentOrDefault()) / 100.0); - DateTime graceEnd = license.GraceStartTime.Value.AddDays(licenseState.ParsedLicense.GetGraceDaysOrDefault()); - - if (license.GraceStartTime <= now && graceEnd > now && licenseState.Usage < graceLimit) - { - // We will use the grace period. - - // See if we should send a warning message. - if (license.GraceLastWarningTime.GetValueOrDefault(DateTime.MinValue).AddDays(settings.GracePeriodWarningDays) < now) - { - try - { - string body = string.Format( - "The license #{0} has a capacity of {1} concurrent user(s), but {2} users are currently using the product {3}. " + - "The grace period has started on {4} and will end on {5}. After this date, additional leases will be denied." + - "Please contact PostSharp Technologies to acquire additional licenses.", - license.LicenseId, licenseState.Maximum, licenseState.Usage + 1, licenseState.ParsedLicense.Product, - license.GraceStartTime, graceEnd); - const string subject = "WARNING: licensing capacity exceeded"; - - SendEmail(settings.GracePeriodWarningEmailTo, settings.GracePeriodWarningEmailCC, subject, body); - license.GraceLastWarningTime = now; - } - catch (Exception e) - { - Trace.TraceError(e.ToString()); - } - } - - Lease lease = db.CreateLease(license, userName, machine, authenticatedUserName, now, true); - if (lease != null) return lease; - } - } - - - SendEmail(settings.DeniedRequestEmailTo, null, "ERROR: license request denied", - string.Format("No license with free capacity was found to satisfy the lease request for the product {0} from " + - "the user '{1}' (authentication: '{2}'), machine '{3}'. " + string.Join(". ", errors.ToArray()), - productCode, userName, authenticatedUserName, machine)); - - return null; - } - - private License GetBuildServerLicense(License[] licenses) - { - return licenses.FirstOrDefault( IsLicenseValid ); - } - - private static bool IsLicenseValid( License license ) - { - return ParsedLicenseManager.GetParsedLicense( license.LicenseKey ) != null; - } - - private bool IsBuildServer( string machineName ) - { - machineName = computerUniqueIdRegex.Replace( machineName, string.Empty ); - - return buildServers.Contains( machineName ); - } - - private void SendEmail(string to, string cc, string subject, string body) - { - if (!this.sendEmails) return; - if (string.IsNullOrEmpty(to) || to.Trim().Length == 0) return; - - to = to.Trim( ' ', '\n', '\r', '\t' ); - - - MailMessage message = new MailMessage("sales@postsharp.net", to, - "[SharpCrafters License Server] " + subject, body) - { - Priority = MailPriority.High - }; - - if (!string.IsNullOrEmpty(cc)) - { - foreach (string address in cc.Split(',', ';', ' ')) - { - message.CC.Add(address.Trim( ' ', '\n', '\r', '\t' )); - } - } - - SmtpClient smtp = new SmtpClient(); - smtp.SendAsync( message, null ); - } - - private class LicenseState - { - private readonly DateTime time; - private readonly Database db; - private readonly License license; - private int usage = -1; - - public LicenseState( DateTime time, Database db, License license, ParsedLicense parsedLicense ) - { - this.time = time; - this.db = db; - this.license = license; - this.ParsedLicense = parsedLicense; - } - - private void ComputeUsage() - { - if ( this.usage == -1 ) - { - this.usage = db.GetActiveLeads(license.LicenseId, time); - } - } - - public int Usage - { - get - { - this.ComputeUsage(); - return usage; - } - } - - public int? Maximum - { - get { return this.ParsedLicense.UserNumber; } - } - - public bool InExcess - { - get { return this.Maximum.HasValue && this.Maximum.Value < this.Usage; } - } - - public ParsedLicense ParsedLicense { get; private set; } - - } - } -} \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/ParsedLicenseManager.cs b/src/PostSharp.LicenseServer/ParsedLicenseManager.cs deleted file mode 100644 index 209839b..0000000 --- a/src/PostSharp.LicenseServer/ParsedLicenseManager.cs +++ /dev/null @@ -1,55 +0,0 @@ -#region Copyright (c) 2004-2010 by SharpCrafters s.r.o. - -// This file is part of PostSharp source code and is the property of SharpCrafters s.r.o. -// -// Source code is provided to customers under strict non-disclosure agreement (NDA). -// YOU MUST HAVE READ THE NDA BEFORE HAVING RECEIVED ACCESS TO THIS SOURCE CODE. -// Severe financial penalties apply in case of non respect of the NDA. - -#endregion - -using System.Collections.Generic; -using PostSharp.Platform.NetFramework; -using PostSharp.Platform.Neutral; -using PostSharp.Sdk.Extensibility.Licensing; -using ParsedLicense = PostSharp.Sdk.Extensibility.Licensing.License; - -namespace PostSharp.LicenseServer -{ - public static class ParsedLicenseManager - { - private static readonly Dictionary parsedLicenses = new Dictionary(); - - static ParsedLicenseManager() - { - CommonDefaultSystemServices.Initialize(); - NetFrameworkDefaultSystemServices.Initialize(); - } - - - - public static ParsedLicense GetParsedLicense( string licenseKey ) - { - ParsedLicense parsedLicense; - - lock (parsedLicenses) - { - if (!parsedLicenses.TryGetValue(licenseKey, out parsedLicense)) - { - parsedLicense = ParsedLicense.Deserialize(licenseKey); - - string errorDescription; - if (parsedLicense == null || !parsedLicense.Validate(null, out errorDescription)) - { - return null; - } - - parsedLicenses.Add(licenseKey, parsedLicense); - } - - return parsedLicense; - } - } - } - -} \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/PostSharp.LicenseServer.csproj b/src/PostSharp.LicenseServer/PostSharp.LicenseServer.csproj deleted file mode 100644 index 8850dad..0000000 --- a/src/PostSharp.LicenseServer/PostSharp.LicenseServer.csproj +++ /dev/null @@ -1,293 +0,0 @@ - - - - - - Debug - AnyCPU - - - 2.0 - {3B511E09-1CFD-43EB-978F-70FA3DFEC83B} - {349c5851-65df-11da-9384-00065b846f21};{fae04ec0-301f-11d3-bf4b-00c04f79efbc} - Library - Properties - PostSharp.LicenseServer - PostSharp.LicenseServer - v4.7.2 - true - - - - - 4.0 - - - - - - $(MSBuildThisFileDirectory)..\..\packages\MSBuildTasks.1.5.0.235\tools - - - - - true - full - false - bin\ - DEBUG;TRACE - prompt - 4 - true - - - - - - - - - - - - false - true - false - false - True - - - pdbonly - true - bin\ - TRACE - prompt - 4 - false - - - - - true - false - True - - - - ..\..\packages\PostSharp.Redist.2025.1.5\lib\net45\PostSharp.dll - - - ..\..\packages\PostSharp.Compiler.Common.2025.1.5\lib\netstandard2.0\PostSharp.Compiler.Common.dll - - - ..\..\packages\PostSharp.Compiler.Platforms.2025.1.5\lib\net472\PostSharp.Compiler.Platform.NetFramework.dll - - - ..\..\packages\PostSharp.Compiler.Settings.2025.1.5\lib\netstandard2.0\PostSharp.Compiler.Settings.dll - - - - - - - - - - - - - - - - - - - - - - - Designer - - - Web.config - - - Web.config - - - - - AddLicense.aspx - ASPXCodeBehind - - - AddLicense.aspx - - - Cancel.aspx - ASPXCodeBehind - - - Cancel.aspx - - - Details.aspx - ASPXCodeBehind - - - Details.aspx - - - Export.ashx - - - Export.aspx - ASPXCodeBehind - - - Export.aspx - - - GenerateDemoData.aspx - ASPXCodeBehind - - - GenerateDemoData.aspx - - - - True - True - DataClasses.dbml - - - Default.aspx - ASPXCodeBehind - - - Default.aspx - - - - GetTime.ashx - - - Graph.aspx - ASPXCodeBehind - - - Graph.aspx - - - Lease.ashx - - - - - - - - - True - True - Settings.settings - - - Site.Master - ASPXCodeBehind - - - Site.Master - - - - - - - - - - MSLinqToSQLGenerator - DataClasses.designer.cs - Designer - - - - - SettingsSingleFileGenerator - Settings.Designer.cs - - - - - - - - - DataClasses.dbml - - - - 10.0 - $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - - - bin\ - TRACE - true - pdbonly - AnyCPU - prompt - MinimumRecommendedRules.ruleset - false - True - - - - - - - - - True - - - - - - - - - - - - - - - - - - - <_ZipFiles Include="obj\$(Configuration)\Package\PackageTmp\**\*" /> - - - - - - - This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - - - - - - - \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/PostSharp.LicenseServer.csproj.user b/src/PostSharp.LicenseServer/PostSharp.LicenseServer.csproj.user deleted file mode 100644 index 97f75a5..0000000 --- a/src/PostSharp.LicenseServer/PostSharp.LicenseServer.csproj.user +++ /dev/null @@ -1,47 +0,0 @@ - - - - true - - - - - - Debug|Any CPU - - - - - - - - - CurrentPage - True - False - False - False - - - - - - - - - True - True - True - True - 0 - / - http://localhost:44670/ - False - False - - - - - - - \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/Properties/AssemblyInfo.cs b/src/PostSharp.LicenseServer/Properties/AssemblyInfo.cs deleted file mode 100644 index 93c53c4..0000000 --- a/src/PostSharp.LicenseServer/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,48 +0,0 @@ -#region Copyright (c) 2004-2010 by SharpCrafters s.r.o. - -// This file is part of PostSharp source code and is the property of SharpCrafters s.r.o. -// -// Source code is provided to customers under strict non-disclosure agreement (NDA). -// YOU MUST HAVE READ THE NDA BEFORE HAVING RECEIVED ACCESS TO THIS SOURCE CODE. -// Severe financial penalties apply in case of non respect of the NDA. - -#endregion - -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. - -[assembly: AssemblyTitle( "PostSharp.LicenseServer" )] -[assembly: AssemblyDescription( "" )] -[assembly: AssemblyConfiguration( "" )] -[assembly: AssemblyCompany( "Microsoft" )] -[assembly: AssemblyProduct( "PostSharp.LicenseServer" )] -[assembly: AssemblyCopyright( "Copyright © Microsoft 2011" )] -[assembly: AssemblyTrademark( "" )] -[assembly: AssemblyCulture( "" )] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. - -[assembly: ComVisible( false )] - -// The following GUID is for the ID of the typelib if this project is exposed to COM - -[assembly: Guid( "a5a5279c-8b93-41e9-a262-cb4a852b288a" )] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Revision and Build Numbers -// by using the '*' as shown below: - -[assembly: AssemblyVersion( "1.0.0.0" )] -[assembly: AssemblyFileVersion( "1.0.0.0" )] \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/Properties/Settings.Designer.cs b/src/PostSharp.LicenseServer/Properties/Settings.Designer.cs deleted file mode 100644 index 27836f5..0000000 --- a/src/PostSharp.LicenseServer/Properties/Settings.Designer.cs +++ /dev/null @@ -1,116 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace PostSharp.LicenseServer.Properties { - - - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "16.7.0.0")] - internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { - - private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); - - public static Settings Default { - get { - return defaultInstance; - } - } - - [global::System.Configuration.ApplicationScopedSettingAttribute()] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Configuration.DefaultSettingValueAttribute("1")] - public int GracePeriodWarningDays { - get { - return ((int)(this["GracePeriodWarningDays"])); - } - } - - [global::System.Configuration.ApplicationScopedSettingAttribute()] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Configuration.DefaultSettingValueAttribute("2")] - public int MachinesPerUser { - get { - return ((int)(this["MachinesPerUser"])); - } - } - - [global::System.Configuration.ApplicationScopedSettingAttribute()] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Configuration.DefaultSettingValueAttribute("1")] - public int MinLeaseDays { - get { - return ((int)(this["MinLeaseDays"])); - } - } - - [global::System.Configuration.ApplicationScopedSettingAttribute()] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Configuration.DefaultSettingValueAttribute("3")] - public int NewLeaseDays { - get { - return ((int)(this["NewLeaseDays"])); - } - } - - [global::System.Configuration.ApplicationScopedSettingAttribute()] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Configuration.DefaultSettingValueAttribute("gael@sharpcrafters.com")] - public string GracePeriodWarningEmailTo { - get { - return ((string)(this["GracePeriodWarningEmailTo"])); - } - } - - [global::System.Configuration.ApplicationScopedSettingAttribute()] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Configuration.DefaultSettingValueAttribute("gael@sharpcrafters.com")] - public string DeniedRequestEmailTo { - get { - return ((string)(this["DeniedRequestEmailTo"])); - } - } - - [global::System.Configuration.ApplicationScopedSettingAttribute()] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Configuration.DefaultSettingValueAttribute("30")] - public float MutexTimeout { - get { - return ((float)(this["MutexTimeout"])); - } - } - - [global::System.Configuration.ApplicationScopedSettingAttribute()] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Configuration.DefaultSettingValueAttribute("1440")] - public decimal TimeAcceleration { - get { - return ((decimal)(this["TimeAcceleration"])); - } - } - - [global::System.Configuration.ApplicationScopedSettingAttribute()] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Configuration.DefaultSettingValueAttribute("")] - public string BuildServers { - get { - return ((string)(this["BuildServers"])); - } - } - - [global::System.Configuration.ApplicationScopedSettingAttribute()] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Configuration.DefaultSettingValueAttribute("sales@postsharp.net")] - public string GracePeriodWarningEmailCC { - get { - return ((string)(this["GracePeriodWarningEmailCC"])); - } - } - } -} diff --git a/src/PostSharp.LicenseServer/Properties/Settings.settings b/src/PostSharp.LicenseServer/Properties/Settings.settings deleted file mode 100644 index 39a4b3d..0000000 --- a/src/PostSharp.LicenseServer/Properties/Settings.settings +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - 1 - - - 2 - - - 1 - - - 3 - - - gael@sharpcrafters.com - - - gael@sharpcrafters.com - - - 30 - - - 1440 - - - - - - sales@postsharp.net - - - \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/Site.Master b/src/PostSharp.LicenseServer/Site.Master deleted file mode 100644 index 390e25f..0000000 --- a/src/PostSharp.LicenseServer/Site.Master +++ /dev/null @@ -1,47 +0,0 @@ -<%@ Master Language="C#" AutoEventWireup="true" CodeBehind="Site.master.cs" Inherits="PostSharp.LicenseServer.Site" %> - - - - - - - - - - - -
'" style="cursor:pointer" > - -

PostSharp License Server

-
- -
-
-
- - - -
-
- - - diff --git a/src/PostSharp.LicenseServer/Site.Master.cs b/src/PostSharp.LicenseServer/Site.Master.cs deleted file mode 100644 index c5edb2b..0000000 --- a/src/PostSharp.LicenseServer/Site.Master.cs +++ /dev/null @@ -1,22 +0,0 @@ -#region Copyright (c) 2004-2010 by SharpCrafters s.r.o. - -// This file is part of PostSharp source code and is the property of SharpCrafters s.r.o. -// -// Source code is provided to customers under strict non-disclosure agreement (NDA). -// YOU MUST HAVE READ THE NDA BEFORE HAVING RECEIVED ACCESS TO THIS SOURCE CODE. -// Severe financial penalties apply in case of non respect of the NDA. - -#endregion - -using System; -using System.Web.UI; - -namespace PostSharp.LicenseServer -{ - public partial class Site : MasterPage - { - protected void Page_Load( object sender, EventArgs e ) - { - } - } -} \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/Site.Master.designer.cs b/src/PostSharp.LicenseServer/Site.Master.designer.cs deleted file mode 100644 index 4cedeac..0000000 --- a/src/PostSharp.LicenseServer/Site.Master.designer.cs +++ /dev/null @@ -1,42 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace PostSharp.LicenseServer { - - - public partial class Site { - - /// - /// Head control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.ContentPlaceHolder Head; - - /// - /// form1 control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.HtmlControls.HtmlForm form1; - - /// - /// Body control. - /// - /// - /// Auto-generated field. - /// To modify move field declaration from designer file to code-behind file. - /// - protected global::System.Web.UI.WebControls.ContentPlaceHolder Body; - } -} diff --git a/src/PostSharp.LicenseServer/VirtualDateTime.cs b/src/PostSharp.LicenseServer/VirtualDateTime.cs deleted file mode 100644 index 76c3365..0000000 --- a/src/PostSharp.LicenseServer/VirtualDateTime.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Web; -using PostSharp.LicenseServer.Properties; - -namespace PostSharp.LicenseServer -{ - public static class VirtualDateTime - { - private static DateTime startTime = DateTime.UtcNow; - - public static readonly decimal Acceleration = Settings.Default.TimeAcceleration; - - public static DateTime UtcNow - { - get - { -#if DEBUG - if ( Acceleration != 0 && Acceleration != 1 ) - { - return startTime.AddMilliseconds( (DateTime.UtcNow - startTime).TotalMilliseconds* (double) Acceleration ); - } - else - { - return DateTime.UtcNow; - } -#else - return DateTime.UtcNow; -#endif - } - } - } -} \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/Web.Debug.config b/src/PostSharp.LicenseServer/Web.Debug.config deleted file mode 100644 index 2c6dd51..0000000 --- a/src/PostSharp.LicenseServer/Web.Debug.config +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/Web.Release.config b/src/PostSharp.LicenseServer/Web.Release.config deleted file mode 100644 index 4122d79..0000000 --- a/src/PostSharp.LicenseServer/Web.Release.config +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/Web.config b/src/PostSharp.LicenseServer/Web.config deleted file mode 100644 index 358ff72..0000000 --- a/src/PostSharp.LicenseServer/Web.config +++ /dev/null @@ -1,148 +0,0 @@ - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - - - 2 - - - 1 - - - 3 - - - hello@postsharp.net - - - hello@postsharp.net - - - 30 - - - 1 - - - server - - - hello@postsharp.net - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/PostSharp.LicenseServer/packages.config b/src/PostSharp.LicenseServer/packages.config deleted file mode 100644 index 86fb0e7..0000000 --- a/src/PostSharp.LicenseServer/packages.config +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Data/ILeaseRepository.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Data/ILeaseRepository.cs new file mode 100644 index 0000000..d4a02ae --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Data/ILeaseRepository.cs @@ -0,0 +1,63 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +namespace SharpCrafters.Backstage.LicenseServer.Data; + +/// +/// Reads and writes leases. Replaces the methods that the LINQ to SQL Database class carried. +/// +public interface ILeaseRepository +{ + /// + /// Gets the leases that have not been replaced by a later lease. + /// + IQueryable OpenLeases { get; } + + /// + /// Gets every lease that was recorded, including the leases that were replaced later. These rows + /// are the audit log. + /// + IQueryable Leases { get; } + + IQueryable Licenses { get; } + + /// + /// Creates a lease for a user on a machine. + /// + /// + /// The new lease, or null when no lease can be granted beyond , + /// because the license or the grace period ends first. + /// + Lease? CreateLease( + License license, + string user, + string machine, + string authenticatedUserName, + DateTime time, + bool grace ); + + /// + /// Replaces a lease with a new one ending later. + /// + /// The new lease, or null when it cannot be extended past . + Lease? ProlongLease( Lease oldLease, string authenticatedUserName, DateTime time ); + + /// + /// Ends a lease immediately, by inserting a lease that overwrites it. + /// + void CancelLease( Lease lease, string authenticatedUserName, DateTime time ); + + /// + /// Counts the seats of a license that are in use at a given instant. + /// + int GetActiveSeats( int licenseId, DateTime dateTime ); + + /// + /// Returns the usage timeline of a license during a period. The timeline is a sequence of events + /// that open and close a lease, and each event carries the number of seats in use after it. + /// + IEnumerable GetLeaseCountingPoints( int licenseId, DateTime startTime, DateTime endTime ); + + Task SaveChangesAsync( CancellationToken cancellationToken = default ); + + int SaveChanges(); +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Data/Lease.Audit.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Data/Lease.Audit.cs new file mode 100644 index 0000000..275e3c8 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Data/Lease.Audit.cs @@ -0,0 +1,59 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Xml; + +namespace SharpCrafters.Backstage.LicenseServer; + +public partial class Lease +{ + /// + /// Writes this lease as a line of the audit log. The fields are separated by semicolons, and the + /// machine name and the user name are written as they are stored. + /// + /// + /// + /// The log is read by the administrator of the server, who needs to know which user and which + /// machine hold a seat. A version earlier than 2027.0 wrote the two names as hashes. Nothing + /// could read such a log, and the file was exported to be read. + /// + /// + /// The file therefore contains personal data. It is meant for the organization that runs the + /// server, and not for PostSharp Technologies. + /// + /// + /// This format is a serialization contract. The timestamps are written as UTC. The + /// values of the entity receive the UTC kind when they are read from the + /// database. Without that kind, would treat them as local times and + /// shift them. + /// + /// + public void Write( TextWriter textWriter ) + { + ArgumentNullException.ThrowIfNull( textWriter ); + + textWriter.Write( this.LeaseId ); + textWriter.Write( ';' ); + textWriter.Write( this.OverwrittenLeaseId ); + textWriter.Write( ';' ); + textWriter.Write( this.LicenseId ); + textWriter.Write( ';' ); + textWriter.Write( XmlConvert.ToString( this.StartTime, XmlDateTimeSerializationMode.RoundtripKind ) ); + textWriter.Write( ';' ); + textWriter.Write( XmlConvert.ToString( this.EndTime, XmlDateTimeSerializationMode.RoundtripKind ) ); + textWriter.Write( ';' ); + textWriter.Write( this.Machine ); + textWriter.Write( ';' ); + textWriter.Write( this.UserName ); + } + + /// + /// Returns this lease as a line of the audit log. + /// + public string ToAuditLine() + { + StringWriter writer = new(); + this.Write( writer ); + + return writer.ToString(); + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Data/Lease.Entity.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Data/Lease.Entity.cs new file mode 100644 index 0000000..26d3f1c --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Data/Lease.Entity.cs @@ -0,0 +1,50 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +namespace SharpCrafters.Backstage.LicenseServer; + +/// +/// A seat of a license, held by one user on one machine for a period of time. Maps to the +/// dbo.Leases table. +/// +/// +/// The server never updates a lease. Prolonging a lease and cancelling a lease both insert a new row +/// that overwrites the previous row through . The table is therefore +/// an audit log to which the server only appends. +/// +public partial class Lease +{ + public int LeaseId { get; set; } + + /// + /// Gets or sets the lease that this lease replaces, if any. + /// + public int? OverwrittenLeaseId { get; set; } + + public int LicenseId { get; set; } + + public DateTime StartTime { get; set; } + + public DateTime EndTime { get; set; } + + public string UserName { get; set; } = null!; + + public string Machine { get; set; } = null!; + + /// + /// Gets or sets the authenticated caller that requested the lease. It can differ from + /// , which the request declares. + /// + public string AuthenticatedUser { get; set; } = null!; + + /// + /// Gets or sets a value indicating whether the server granted this lease during the grace + /// period, that is, above the capacity of the license. + /// + public bool Grace { get; set; } + + public License License { get; set; } = null!; + + public Lease? OverwritesLease { get; set; } + + public ICollection OverwrittenByLease { get; set; } = new List(); +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Data/LeaseConfiguration.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Data/LeaseConfiguration.cs new file mode 100644 index 0000000..5608eb3 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Data/LeaseConfiguration.cs @@ -0,0 +1,39 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace SharpCrafters.Backstage.LicenseServer.Data; + +/// +/// Maps onto the dbo.Leases table created by CreateTables.sql. +/// +public sealed class LeaseConfiguration : IEntityTypeConfiguration +{ + public void Configure( EntityTypeBuilder builder ) + { + builder.ToTable( "Leases" ); + builder.HasKey( x => x.LeaseId ).HasName( "PK_Leases" ); + builder.Property( x => x.LeaseId ).ValueGeneratedOnAdd(); + + builder.Property( x => x.UserName ).IsRequired().HasMaxLength( 200 ); + builder.Property( x => x.Machine ).IsRequired().HasMaxLength( 200 ); + builder.Property( x => x.AuthenticatedUser ).IsRequired().HasMaxLength( 200 ); + builder.Property( x => x.Grace ).IsRequired(); + + builder.HasOne( x => x.License ) + .WithMany( x => x.Leases ) + .HasForeignKey( x => x.LicenseId ) + .HasConstraintName( "FK_Leases_Licenses" ) + .OnDelete( DeleteBehavior.Restrict ); + + builder.HasOne( x => x.OverwritesLease ) + .WithMany( x => x.OverwrittenByLease ) + .HasForeignKey( x => x.OverwrittenLeaseId ) + .HasConstraintName( "FK_Leases_Leases" ) + .OnDelete( DeleteBehavior.Restrict ); + + builder.HasIndex( x => x.EndTime ).HasDatabaseName( "IX_Leases_EndTime" ); + builder.HasIndex( x => x.OverwrittenLeaseId ).HasDatabaseName( "IX_Leases_OverwrittenLeaseId" ); + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Data/LeaseCountingPoint.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Data/LeaseCountingPoint.cs new file mode 100644 index 0000000..3af9042 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Data/LeaseCountingPoint.cs @@ -0,0 +1,27 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +namespace SharpCrafters.Backstage.LicenseServer; + +/// +/// A point of the usage timeline of a license. It is the instant at which a lease starts or ends, +/// with the number of seats in use immediately after that instant. +/// +public sealed class LeaseCountingPoint +{ + public required DateTime Time { get; init; } + + public required LeaseCountingPointKind Kind { get; init; } + + public required Lease Lease { get; init; } + + /// + /// Gets the number of seats in use immediately after this point. + /// + /// + /// A seat is one user and the machines that user works on, up to MachinesPerUser + /// machines. See . The allocator compares this quantity to the + /// capacity of the license when it decides whether to grant a lease, and the usage chart draws + /// the same quantity against the capacity. + /// + public int SeatCount { get; set; } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Data/LeaseCountingPointKind.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Data/LeaseCountingPointKind.cs new file mode 100644 index 0000000..338416f --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Data/LeaseCountingPointKind.cs @@ -0,0 +1,20 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +namespace SharpCrafters.Backstage.LicenseServer; + +/// +/// The kind of event a represents. +/// +/// +/// The numeric values are part of the algorithm. The counting points are ordered by time and then by +/// kind. is lower than , so a lease that ends at the instant at +/// which another lease begins releases its machine before the next lease takes it. Other values +/// would raise the seat count for that instant, and the code that closes a point would no longer +/// find the machine it closes. +/// +public enum LeaseCountingPointKind +{ + // A close is processed before an open. + Close = 1, + Open = 2 +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Data/LeaseRepository.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Data/LeaseRepository.cs new file mode 100644 index 0000000..3526939 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Data/LeaseRepository.cs @@ -0,0 +1,267 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SharpCrafters.Backstage.LicenseServer.Licensing; +using SharpCrafters.Backstage.LicenseServer.Options; + +namespace SharpCrafters.Backstage.LicenseServer.Data; + +/// +public sealed class LeaseRepository : ILeaseRepository +{ + private readonly LicenseServerDbContext db; + private readonly ILicenseParser licenseParser; + private readonly LicenseServerOptions settings; + + public LeaseRepository( + LicenseServerDbContext db, + IOptions options, + ILicenseParser licenseParser ) + { + this.db = db; + this.licenseParser = licenseParser; + this.settings = options.Value; + } + + public IQueryable OpenLeases => this.db.OpenLeases; + + public IQueryable Leases => this.db.Leases; + + public IQueryable Licenses => this.db.Licenses; + + public Lease? CreateLease( + License license, + string user, + string machine, + string authenticatedUserName, + DateTime time, + bool grace ) + { + Lease lease = new() + { + License = license, + + // Assigned next to the navigation property, and not left to the fixup of EF Core, so that + // the lease is complete before it is tracked and signed. + LicenseId = license.LicenseId, + AuthenticatedUser = authenticatedUserName, + EndTime = time.AddDays( this.settings.NewLeaseDays ), + Machine = machine, + StartTime = time, + UserName = user, + Grace = grace + }; + + if ( !this.FixLease( lease, time ) ) + { + return null; + } + + this.db.Leases.Add( lease ); + + return lease; + } + + public Lease? ProlongLease( Lease oldLease, string authenticatedUserName, DateTime time ) + { + Lease newLease = new() + { + License = oldLease.License, + LicenseId = oldLease.LicenseId, + AuthenticatedUser = authenticatedUserName, + OverwritesLease = oldLease, + OverwrittenLeaseId = oldLease.LeaseId, + StartTime = oldLease.StartTime, + EndTime = time.AddDays( this.settings.NewLeaseDays ), + Machine = oldLease.Machine, + UserName = oldLease.UserName, + Grace = oldLease.Grace + }; + + if ( !this.FixLease( newLease, time ) ) + { + return null; + } + + this.db.Leases.Add( newLease ); + + return newLease; + } + + public void CancelLease( Lease lease, string authenticatedUserName, DateTime time ) + { + Lease overwrite = new() + { + AuthenticatedUser = authenticatedUserName, + License = lease.License, + LicenseId = lease.LicenseId, + UserName = lease.UserName, + Machine = lease.Machine, + StartTime = lease.StartTime, + OverwritesLease = lease, + OverwrittenLeaseId = lease.LeaseId, + Grace = lease.Grace, + EndTime = time + }; + + // A cancellation skips the adjustment of the end time. It ends the lease at the current + // instant, and the rule that a lease must end after the current instant would reject that. + this.FixLease( overwrite, time, false ); + + this.db.Leases.Add( overwrite ); + } + + /// + /// Limits the end of a lease to the end of the license and to the end of the grace period. + /// + /// false when no time is left to grant. + private bool FixLease( Lease lease, DateTime time, bool fixEndTime = true ) + { + var parsedLicense = this.licenseParser.TryParse( lease.License.LicenseKey ); + + if ( parsedLicense == null ) + { + throw new InvalidOperationException( $"The license key #{lease.License.LicenseId} cannot be parsed." ); + } + + if ( lease.EndTime <= lease.StartTime ) + { + throw new InvalidOperationException( "A lease cannot end before it starts." ); + } + + if ( fixEndTime ) + { + if ( parsedLicense.ValidTo.HasValue && parsedLicense.ValidTo < lease.EndTime ) + { + lease.EndTime = parsedLicense.ValidTo.Value; + } + + if ( lease.Grace ) + { + var graceEnd = lease.License.GraceStartTime!.Value.AddDays( parsedLicense.GraceDays ); + + if ( lease.EndTime > graceEnd ) + { + lease.EndTime = graceEnd; + } + } + + if ( lease.EndTime <= time ) + { + return false; + } + + if ( lease.EndTime <= lease.StartTime ) + { + throw new InvalidOperationException( "A lease cannot end before it starts." ); + } + } + + // The signature is applied by SaveChanges, once the database has assigned an identifier. + return true; + } + + public int GetActiveSeats( int licenseId, DateTime dateTime ) + { + // The database counts the machines and the seat arithmetic runs here, so that the query + // translates on every provider. The query returns one row per user that holds a lease on this + // license. + // + // It counts distinct machines and not leases. A user can hold two leases on one machine, + // which happens when the clock of the server moves backwards. Counting the leases would + // charge that user for a machine they do not work on. + var machinesPerUser = this.db.OpenLeases + .Where( l => l.LicenseId == licenseId && l.StartTime <= dateTime && l.EndTime > dateTime ) + .GroupBy( l => l.UserName ) + .Select( g => g.Select( l => l.Machine ).Distinct().Count() ) + .ToList(); + + return SeatCounter.CountSeats( machinesPerUser, this.settings.MachinesPerUser ); + } + + public IEnumerable GetLeaseCountingPoints( + int licenseId, + DateTime startTime, + DateTime endTime ) + { + var leases = this.db.OpenLeases + .Where( l => l.LicenseId == licenseId && l.StartTime <= endTime && l.EndTime > startTime ) + .AsNoTracking() + .ToList(); + + // A lease cancelled at the instant it was granted covers no time and belongs on no timeline. + // It is removed before the points are built. Close sorts before Open at the same instant, so + // its closing point would be processed first, and its opening point would then raise the + // count for the rest of the window. + leases.RemoveAll( l => l.EndTime <= l.StartTime ); + + // Ordering in memory gives a stable sort, so the timeline is reproducible. Close sorts + // before Open at the same instant; see LeaseCountingPointKind. + var allRecords = leases + .Select( l => new LeaseCountingPoint { Time = l.StartTime, Kind = LeaseCountingPointKind.Open, Lease = l } ) + .Concat( leases.Select( l => new LeaseCountingPoint { Time = l.EndTime, Kind = LeaseCountingPointKind.Close, Lease = l } ) ) + .OrderBy( p => p.Time ) + .ThenBy( p => p.Kind ) + .ThenBy( p => p.Lease.LeaseId ) + .ToList(); + + // The number of open leases that each user holds on each machine. A seat is counted from the + // number of distinct machines, so a user who holds two leases on one machine occupies the + // same seat as a user who holds one lease. A count per machine, instead of a list of + // machines, makes the closing points balance the opening points for any data. A list removed + // the machine at the first close and found nothing to remove at the second. + Dictionary> currentUsers = new( StringComparer.OrdinalIgnoreCase ); + + var seatCount = 0; + + foreach ( var record in allRecords ) + { + if ( !currentUsers.TryGetValue( record.Lease.UserName, out var machines ) ) + { + machines = new Dictionary( StringComparer.OrdinalIgnoreCase ); + currentUsers.Add( record.Lease.UserName, machines ); + } + + var seatsBefore = SeatCounter.CountSeats( [machines.Count], this.settings.MachinesPerUser ); + var machine = record.Lease.Machine; + + if ( record.Kind == LeaseCountingPointKind.Open ) + { + machines[machine] = machines.GetValueOrDefault( machine ) + 1; + } + else if ( machines.TryGetValue( machine, out var openLeases ) ) + { + // The machine leaves the list when its last lease closes. + if ( openLeases > 1 ) + { + machines[machine] = openLeases - 1; + } + else + { + machines.Remove( machine ); + } + } + + // A closing point that finds no machine to close is ignored. Both points are produced for + // every lease that remains, and an opening point always sorts before its own closing + // point, so this case does not occur. It is ignored and not reported as an exception, + // because the page that draws the timeline is a report: an administrator who looks at + // usage must not receive an error. + + var seatsAfter = SeatCounter.CountSeats( [machines.Count], this.settings.MachinesPerUser ); + + seatCount += seatsAfter - seatsBefore; + record.SeatCount = seatCount; + + yield return record; + } + } + + /// + /// Saves the unit of work. + /// + public Task SaveChangesAsync( CancellationToken cancellationToken = default ) => this.db.SaveChangesAsync( cancellationToken ); + + public int SaveChanges() => this.db.SaveChanges(); +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Data/License.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Data/License.cs new file mode 100644 index 0000000..e0acd37 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Data/License.cs @@ -0,0 +1,41 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +namespace SharpCrafters.Backstage.LicenseServer; + +/// +/// A license key registered on the license server. Maps to the dbo.Licenses table. +/// +public class License +{ + /// + /// Gets or sets the identifier of the license. The value comes from the license key, so the + /// application assigns it and the database does not generate it. + /// + public int LicenseId { get; set; } + + public string LicenseKey { get; set; } = null!; + + public string ProductCode { get; set; } = null!; + + /// + /// Gets or sets the order in which the server uses the licenses. A negative value disables the + /// license. + /// + public int Priority { get; set; } + + public DateTime CreatedOn { get; set; } + + /// + /// Gets or sets the instant at which the license first exceeded its capacity. That instant + /// starts the grace period. + /// + public DateTime? GraceStartTime { get; set; } + + /// + /// Gets or sets the instant at which the server sent the last warning e-mail about the grace + /// period. + /// + public DateTime? GraceLastWarningTime { get; set; } + + public ICollection Leases { get; set; } = new List(); +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Data/LicenseConfiguration.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Data/LicenseConfiguration.cs new file mode 100644 index 0000000..e0f8b80 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Data/LicenseConfiguration.cs @@ -0,0 +1,30 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace SharpCrafters.Backstage.LicenseServer.Data; + +/// +/// Maps onto the dbo.Licenses table created by CreateTables.sql. +/// +public sealed class LicenseConfiguration : IEntityTypeConfiguration +{ + public void Configure( EntityTypeBuilder builder ) + { + builder.ToTable( "Licenses" ); + builder.HasKey( x => x.LicenseId ).HasName( "PK_Licenses" ); + + // The identifier comes from the license key and not from the database. + builder.Property( x => x.LicenseId ).ValueGeneratedNever(); + + // LicenseServerDbContext maps this property to the SQL type 'text'. Never filter, sort, + // group, or apply DISTINCT on this column. Transact-SQL forbids the type 'text' in these + // positions, and the query fails at run time. + builder.Property( x => x.LicenseKey ).IsRequired().IsUnicode( false ); + + builder.Property( x => x.ProductCode ).IsRequired().IsUnicode( false ).HasMaxLength( 50 ); + builder.Property( x => x.Priority ).IsRequired(); + builder.Property( x => x.CreatedOn ).IsRequired(); + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Data/LicenseServerDbContext.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Data/LicenseServerDbContext.cs new file mode 100644 index 0000000..fed5893 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Data/LicenseServerDbContext.cs @@ -0,0 +1,116 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace SharpCrafters.Backstage.LicenseServer.Data; + +/// +/// The license server database. The hosting application chooses the provider: SQL Server or +/// PostgreSQL in production, SQLite for an evaluation and for the tests. +/// +/// +/// The model maps exactly onto the schema that CreateTables.sql creates, so that an existing +/// deployment is upgraded without any work on the database. The project contains no EF migration. +/// SchemaCompatibilityTests verifies the compatibility of the model with the schema, and a run +/// against SQL Server or PostgreSQL creates every test database from the script of that engine, which +/// verifies the same thing against a live server. +/// +public class LicenseServerDbContext : DbContext +{ + public LicenseServerDbContext( DbContextOptions options ) : base( options ) { } + + public DbSet Licenses => this.Set(); + + public DbSet Leases => this.Set(); + + /// + /// Gets the leases that no later lease has replaced, that is, the leases that are in effect. + /// This query is the only place that interprets . + /// + /// + /// The query is an anti-join. The legacy query was a left join with a test for null, and it + /// returned a lease twice when two leases had overwritten it. No unique constraint prevents + /// that. + /// + public IQueryable OpenLeases => this.Leases.Where( l => !this.Leases.Any( o => o.OverwrittenLeaseId == l.LeaseId ) ); + + protected override void OnModelCreating( ModelBuilder modelBuilder ) + { + modelBuilder.ApplyConfigurationsFromAssembly( typeof(LicenseServerDbContext).Assembly ); + + var isSqlServer = this.Database.ProviderName == "Microsoft.EntityFrameworkCore.SqlServer"; + + // Every timestamp of this database is UTC, and the converter states it in both directions. + // + // On the way out: SQL Server returns DateTimeKind.Unspecified. Lease.Write serializes the + // timestamps as UTC, and XmlConvert treats an Unspecified value as a local time, which shifted + // every exported timestamp by the offset of the server. + // + // On the way in: PostgreSQL stores these columns as 'timestamp with time zone', and Npgsql + // refuses a value whose kind is Unspecified. A date built from a year and a month, which is + // what the export takes from its query string, is such a value. The kind is set and not + // converted, so the value written is the value the caller gave, which is what SQL Server and + // SQLite have always stored. + ValueConverter toUtc = + new( + v => DateTime.SpecifyKind( v, DateTimeKind.Utc ), + v => DateTime.SpecifyKind( v, DateTimeKind.Utc ) ); + + ValueConverter toUtcNullable = + new( + v => v.HasValue ? DateTime.SpecifyKind( v.Value, DateTimeKind.Utc ) : null, + v => v.HasValue ? DateTime.SpecifyKind( v.Value, DateTimeKind.Utc ) : null ); + + foreach ( var property in modelBuilder.Model.GetEntityTypes().SelectMany( e => e.GetProperties() ) ) + { + if ( property.ClrType == typeof(DateTime) ) + { + property.SetValueConverter( toUtc ); + } + else if ( property.ClrType == typeof(DateTime?) ) + { + property.SetValueConverter( toUtcNullable ); + } + else + { + continue; + } + + if ( isSqlServer ) + { + // The existing columns have the type 'datetime'. With the default type of EF, + // 'datetime2', SQL Server would convert the column at every comparison of StartTime + // and EndTime. + property.SetColumnType( "datetime" ); + } + } + + if ( isSqlServer ) + { + modelBuilder.Entity().Property( x => x.LicenseKey ).HasColumnType( "text" ); + } + else + { + // The default collation of SQL Server ignores the case. The default collation of SQLite + // and of PostgreSQL does not. Without a collation that ignores the case, grouping the + // leases by user name would behave differently from one engine to the next, and a user + // who signed in under two spellings would hold two sets of machines. + // + // SQLite carries NOCASE. PostgreSQL carries no collation that ignores the case, so the + // schema creates one. + var collation = this.Database.ProviderName == "Npgsql.EntityFrameworkCore.PostgreSQL" + ? CaseInsensitiveCollation + : "NOCASE"; + + modelBuilder.Entity().Property( x => x.UserName ).UseCollation( collation ); + modelBuilder.Entity().Property( x => x.Machine ).UseCollation( collation ); + } + } + + /// + /// The name of the PostgreSQL collation that ignores the case. CreateTables.PostgreSql.sql + /// creates it, and so does a test database, because PostgreSQL defines no such collation itself. + /// + public const string CaseInsensitiveCollation = "license_server_ci"; +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Data/SeatCounter.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Data/SeatCounter.cs new file mode 100644 index 0000000..d448918 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Data/SeatCounter.cs @@ -0,0 +1,42 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +namespace SharpCrafters.Backstage.LicenseServer.Data; + +/// +/// Counts the seats that the holders of a set of leases consume. +/// +/// +/// +/// A seat is one user working on up to MachinesPerUser machines. A user working on more +/// machines takes more than one seat: the number of machines divided by MachinesPerUser, +/// rounded up. With the default value of two, one or two machines are one seat, and three or four +/// machines are two seats. +/// +/// +/// The capacity of a license key is expressed in seats, and this is the only rule in which the +/// number of machines appears. The license agreement expresses the same rule in the opposite +/// direction, as a number of authorized users that each may work on a number of devices. The server +/// counts in seats. +/// +/// +public static class SeatCounter +{ + /// + /// Counts the seats consumed by users working on the given numbers of machines. + /// + /// The number of distinct machines each user is working on. + /// The number of machines one seat covers. + /// + /// This arithmetic used to run inside the SQL GROUP BY clause, which only the SQL Server + /// provider translates. Running it here keeps the query portable, and it makes the rounding + /// boundaries testable without a database. The number of rows is limited by the number of + /// distinct users of one license. + /// + public static int CountSeats( IEnumerable machinesPerUser, int machinesPerUserLimit ) + { + ArgumentNullException.ThrowIfNull( machinesPerUser ); + ArgumentOutOfRangeException.ThrowIfLessThan( machinesPerUserLimit, 1 ); + + return (int) machinesPerUser.Sum( count => Math.Ceiling( count / (double) machinesPerUserLimit ) ); + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Email/EmailMessage.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Email/EmailMessage.cs new file mode 100644 index 0000000..e22db95 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Email/EmailMessage.cs @@ -0,0 +1,12 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +namespace SharpCrafters.Backstage.LicenseServer.Email; + +/// +/// A notification e-mail sent to the administrator of the licenses. +/// +public sealed record EmailMessage( + string To, + string? Cc, + string Subject, + string Body ); \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Email/IEmailSender.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Email/IEmailSender.cs new file mode 100644 index 0000000..1e14356 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Email/IEmailSender.cs @@ -0,0 +1,12 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +namespace SharpCrafters.Backstage.LicenseServer.Email; + +/// +/// Sends notification e-mails. A failure to send must never fail a lease request, so an +/// implementation writes the failure to the log and raises no exception. +/// +public interface IEmailSender +{ + Task SendAsync( EmailMessage message, CancellationToken cancellationToken = default ); +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Email/NullEmailSender.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Email/NullEmailSender.cs new file mode 100644 index 0000000..42bf57c --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Email/NullEmailSender.cs @@ -0,0 +1,12 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +namespace SharpCrafters.Backstage.LicenseServer.Email; + +/// +/// Discards the notification e-mails. The server uses it when SMTP is disabled and when it generates +/// demonstration data. +/// +public sealed class NullEmailSender : IEmailSender +{ + public Task SendAsync( EmailMessage message, CancellationToken cancellationToken = default ) => Task.CompletedTask; +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Email/SmtpEmailSender.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Email/SmtpEmailSender.cs new file mode 100644 index 0000000..873f604 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Email/SmtpEmailSender.cs @@ -0,0 +1,87 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MimeKit; +using SharpCrafters.Backstage.LicenseServer.Options; + +namespace SharpCrafters.Backstage.LicenseServer.Email; + +/// +/// Sends notification e-mails over SMTP. +/// +/// +/// The legacy implementation called SmtpClient.SendAsync(message, null) and never read the +/// result, so every failure was silent. This implementation writes a failure to the log, and it +/// still raises no exception, because an SMTP server that fails must not deny a license. +/// +public sealed class SmtpEmailSender : IEmailSender +{ + private readonly SmtpOptions options; + private readonly ILogger logger; + + public SmtpEmailSender( IOptions options, ILogger logger ) + { + this.options = options.Value; + this.logger = logger; + } + + public async Task SendAsync( EmailMessage message, CancellationToken cancellationToken = default ) + { + if ( !this.options.Enabled ) + { + return; + } + + if ( string.IsNullOrWhiteSpace( message.To ) ) + { + return; + } + + try + { + MimeMessage mimeMessage = new(); + mimeMessage.From.Add( MailboxAddress.Parse( this.options.FromAddress ) ); + mimeMessage.To.Add( MailboxAddress.Parse( message.To.Trim( ' ', '\n', '\r', '\t' ) ) ); + + if ( !string.IsNullOrEmpty( message.Cc ) ) + { + foreach ( var address in message.Cc.Split( [',', ';', ' '], StringSplitOptions.RemoveEmptyEntries ) ) + { + var trimmed = address.Trim( ' ', '\n', '\r', '\t' ); + + if ( trimmed.Length > 0 ) + { + mimeMessage.Cc.Add( MailboxAddress.Parse( trimmed ) ); + } + } + } + + mimeMessage.Subject = "[SharpCrafters License Server] " + message.Subject; + mimeMessage.Priority = MessagePriority.Urgent; + mimeMessage.Body = new TextPart( "plain" ) { Text = message.Body }; + + using SmtpClient client = new(); + + await client.ConnectAsync( + this.options.Host, + this.options.Port, + this.options.EnableSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto, + cancellationToken ); + + if ( !string.IsNullOrEmpty( this.options.UserName ) ) + { + await client.AuthenticateAsync( this.options.UserName, this.options.Password ?? string.Empty, cancellationToken ); + } + + await client.SendAsync( mimeMessage, cancellationToken ); + await client.DisconnectAsync( true, cancellationToken ); + } + catch ( Exception e ) + { + this.logger.LogError( e, "Cannot send the notification email '{Subject}' to {To}.", message.Subject, message.To ); + } + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/BackstageLicenseParser.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/BackstageLicenseParser.cs new file mode 100644 index 0000000..88ddde5 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/BackstageLicenseParser.cs @@ -0,0 +1,114 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using SharpCrafters.Backstage.Licensing; +using SharpCrafters.Backstage.Licensing.Licenses; +using SharpCrafters.Backstage.Licensing.Registration; + +namespace SharpCrafters.Backstage.LicenseServer.Licensing; + +/// +/// Parses and validates license keys with SharpCrafters.Backstage. This class is the only one that +/// uses the licensing types of that package. The rest of the application uses +/// . +/// +/// +/// The authorities whose signature the parser accepts. The default authorities are the production +/// ones. A test signs with an authority of its own. +/// +public sealed class BackstageLicenseParser : ILicenseParser +{ + /// + /// The percentage of additional seats allowed during the grace period, when the license key + /// contains no percentage. Backstage leaves the field null, so this class applies a default + /// value. The value is the one PostSharp applied, so a license key served by an earlier version + /// of this server is served in the same way. + /// + private const int defaultGracePercent = 30; + + private readonly ILicensingAuthorityProvider authorities; + + public BackstageLicenseParser( ILicensingAuthorityProvider? authorities = null ) + { + this.authorities = authorities ?? new ProductionLicensingAuthorityProvider(); + } + + public LicenseInfo? TryParse( string licenseKey ) + { + if ( string.IsNullOrWhiteSpace( licenseKey ) ) + { + return null; + } + + if ( !LicenseKeyData.TryDeserialize( licenseKey, out var data, out _ ) ) + { + return null; + } + + // The fields are validated before the signature, as a client validates them. A key that + // contains a required field that this version does not know cannot be served, whether or not + // its signature is valid. + if ( !data.ValidateFields( out _ ) ) + { + return null; + } + + // The verification asks the authority provider for the key that created the signature, and + // the provider raises an exception when it holds no key of that identifier. An administrator + // pastes a license key into a web form, so a key that names an identifier that was never + // issued must be reported as an invalid key and not as an unhandled exception. This code + // works around postsharp-ops/SharpCrafters.Backstage#2. It can be removed when + // TryVerifySignature returns false for an unknown identifier. + if ( data.RequiresSignature() + && ( data.SignatureKeyId == null || !this.authorities.KeyIds.Contains( data.SignatureKeyId.Value ) ) ) + { + return null; + } + + if ( !data.TryVerifySignature( this.authorities, out _ ) ) + { + return null; + } + + // The registration properties contain the rules that derive a value from several fields: the + // eligibility of a key that is older than the LicenseServerEligible field, the minimal + // PostSharp version of a key that is older than MinPostSharpVersion, and the normalization + // of the products that were renamed. Reading the fields directly would duplicate these + // rules. + var properties = data.ToLicenseRegistrationProperties( + LicenseServerProductCatalog.Instance, + licenseKey ); + + return new LicenseInfo + { + LicenseId = data.LicenseId, + Product = ProductCodes.ForStorage( properties.Product ), + LicenseType = properties.LicenseType.ToString(), + UserNumber = data.UserNumber, + ValidTo = properties.ValidTo, + SubscriptionEndDate = properties.SubscriptionEndDate, + MinPostSharpVersion = properties.MinPostSharpVersion, + MinMetalamaVersion = properties.MinMetalamaVersion, + + // The enumeration of the products names the family first, and Backstage offers no other + // way to read it. + IsMetalamaProduct = properties.Product.ToString().StartsWith( "Metalama", StringComparison.Ordinal ), + GraceDays = data.GraceDays, + GracePercent = data.GracePercent ?? defaultGracePercent, + IsLicenseServerEligible = properties.LicenseServerEligible, + LicenseTypeName = properties.LicenseType.GetLicenseTypeName(), + ProductName = LicenseServerProductCatalog.Instance.GetDisplayName( properties.Product ) + }; + } + + /// + /// Removes the whitespace that a license key receives when it is copied from an e-mail. + /// + /// + /// After its identifier and a hyphen, a license key contains only Base32 characters, so it + /// contains no whitespace. Backstage cleans a key in License.CleanLicenseKey, which is + /// private, so this server cannot call it. That method also keeps letters, digits and hyphens + /// alone and converts the result to upper case, which changes a key rather than only trimming + /// what a web form added. + /// + public string CleanLicenseString( string licenseKey ) => new( licenseKey.Where( c => !char.IsWhiteSpace( c ) ).ToArray() ); +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/BackstageServerVersion.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/BackstageServerVersion.cs new file mode 100644 index 0000000..5c29150 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/BackstageServerVersion.cs @@ -0,0 +1,19 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using SharpCrafters.Backstage.Licensing.Licenses; + +namespace SharpCrafters.Backstage.LicenseServer.Licensing; + +/// +/// Reports the version of SharpCrafters.Backstage, which is the library that parses license keys in +/// this server. +/// +/// +/// The version is read from the assembly and not written in the code, so that an upgrade of the +/// package is sufficient to serve a license key that requires a newer version. +/// +public sealed class BackstageServerVersion : ILicenseServerVersion +{ + public Version LicensingLibraryVersion { get; } = + typeof(LicenseKeyData).Assembly.GetName().Version ?? new Version( 0, 0 ); +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/CachingLicenseParser.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/CachingLicenseParser.cs new file mode 100644 index 0000000..67eb472 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/CachingLicenseParser.cs @@ -0,0 +1,32 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Collections.Concurrent; + +namespace SharpCrafters.Backstage.LicenseServer.Licensing; + +/// +/// Caches the result of a parse, because the server parses a license key at every lease request and +/// at every display of the home page. +/// +/// +/// This cache also stores the result for a key that is invalid. The legacy +/// ParsedLicenseManager returned before it added the entry to the dictionary, so it parsed an +/// invalid key at every call. +/// +public sealed class CachingLicenseParser : ILicenseParser +{ + private readonly ConcurrentDictionary cache = new( StringComparer.Ordinal ); + private readonly ILicenseParser inner; + + public CachingLicenseParser( ILicenseParser inner ) + { + this.inner = inner; + } + + public LicenseInfo? TryParse( string licenseKey ) + => string.IsNullOrWhiteSpace( licenseKey ) + ? null + : this.cache.GetOrAdd( licenseKey, this.inner.TryParse ); + + public string CleanLicenseString( string licenseKey ) => this.inner.CleanLicenseString( licenseKey ); +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/CompositeLicensingAuthorityProvider.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/CompositeLicensingAuthorityProvider.cs new file mode 100644 index 0000000..a36b43f --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/CompositeLicensingAuthorityProvider.cs @@ -0,0 +1,44 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using SharpCrafters.Backstage.Licensing.Licenses; + +namespace SharpCrafters.Backstage.LicenseServer.Licensing; + +/// +/// Accepts the signature of a license key issued by any one of several licensing authorities. +/// +/// +/// The identifiers of the keys must be distinct across the providers, because the identifier that a +/// signature carries selects the key that verifies it. The constructor rejects a duplicate +/// identifier instead of selecting the first provider, because the first provider depends on the +/// order in which the providers were passed. +/// +public sealed class CompositeLicensingAuthorityProvider : ILicensingAuthorityProvider +{ + private readonly Dictionary providers = []; + + public CompositeLicensingAuthorityProvider( params ILicensingAuthorityProvider[] providers ) + { + ArgumentNullException.ThrowIfNull( providers ); + + foreach ( var provider in providers ) + { + foreach ( var keyId in provider.KeyIds ) + { + if ( !this.providers.TryAdd( keyId, provider ) ) + { + throw new ArgumentException( + $"Two licensing authorities declare the key of identifier {keyId}.", + nameof(providers) ); + } + } + } + } + + public IEnumerable KeyIds => this.providers.Keys; + + public LicensingAuthority GetAuthority( byte keyId ) + => this.providers.TryGetValue( keyId, out var provider ) + ? provider.GetAuthority( keyId ) + : throw new KeyNotFoundException( $"There is no licensing authority key of identifier {keyId}." ); +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/ILicenseParser.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/ILicenseParser.cs new file mode 100644 index 0000000..b0d2b41 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/ILicenseParser.cs @@ -0,0 +1,21 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +namespace SharpCrafters.Backstage.LicenseServer.Licensing; + +/// +/// Parses and validates PostSharp license keys. Replaces the static ParsedLicenseManager. +/// +public interface ILicenseParser +{ + /// + /// Parses and validates a license key. + /// + /// The parsed license, or null when the key is malformed or invalid. + LicenseInfo? TryParse( string licenseKey ); + + /// + /// Removes the whitespace and the formatting from a license key that a person pasted into a + /// form. + /// + string CleanLicenseString( string licenseKey ); +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/ILicenseServerVersion.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/ILicenseServerVersion.cs new file mode 100644 index 0000000..5e24bbd --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/ILicenseServerVersion.cs @@ -0,0 +1,12 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +namespace SharpCrafters.Backstage.LicenseServer.Licensing; + +/// +/// The version of the licensing library contained in this license server. The server cannot serve a +/// license key that requires a higher version until the server is upgraded. +/// +public interface ILicenseServerVersion +{ + Version LicensingLibraryVersion { get; } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/LeaseSerializer.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/LeaseSerializer.cs new file mode 100644 index 0000000..f5c3315 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/LeaseSerializer.cs @@ -0,0 +1,43 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Xml; + +namespace SharpCrafters.Backstage.LicenseServer.Licensing; + +/// +/// Serializes a lease in the format the client parses. +/// +/// +/// +/// The format is the format that LicenseLease.Serialize of PostSharp produced, and that +/// SharpCrafters.Backstage.Licensing.LicenseServer.LicenseLease.TryDeserialize reads. It has +/// four parts separated by "; ". Each part carries a name followed by a colon, and each +/// instant is written in the XML round-trip representation of UTC. The serializer is written here +/// and not called, because the type of Backstage is internal to that package and has no serializer. +/// A client only reads a lease. +/// +/// +/// Every deployed client parses this format, so the format is a contract, and a test verifies it. +/// The client parses it leniently: it matches the parts by name without regard to case, and it +/// ignores a part it does not know. A later version of the server can therefore add a part, but it +/// cannot rename one. +/// +/// +public static class LeaseSerializer +{ + public static string Serialize( string licenseKey, DateTime startTime, DateTime endTime, DateTime renewTime ) + => $"License: {licenseKey}" + + $"; StartTime: {ToUtcString( startTime )}" + + $"; EndTime: {ToUtcString( endTime )}" + + $"; RenewTime: {ToUtcString( renewTime )}"; + + /// + /// The mode is and not + /// . The instants come from the database, + /// where a datetime has no time zone, and this mode reads an unspecified instant as UTC + /// and not as a local time. The instants that this server produces already carry the UTC kind, + /// so both modes give the same result for them. This mode also gives the correct result for a + /// caller that passes an instant without a kind. + /// + private static string ToUtcString( DateTime value ) => XmlConvert.ToString( value, XmlDateTimeSerializationMode.Utc ); +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/LicenseInfo.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/LicenseInfo.cs new file mode 100644 index 0000000..f349584 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/LicenseInfo.cs @@ -0,0 +1,90 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +namespace SharpCrafters.Backstage.LicenseServer.Licensing; + +/// +/// The properties of a license key that the license server needs. The licensing library provides +/// them, and the rest of the application uses this type, so that it does not depend on that library +/// and can be tested without a real license key. +/// +public sealed record LicenseInfo +{ + public required int LicenseId { get; init; } + + /// + /// Gets the licensed product, as stored in the ProductCode column. + /// + public required string Product { get; init; } + + public required string LicenseType { get; init; } + + /// + /// Gets the number of concurrent users that the license allows, or null when the license sets no + /// limit. + /// + public int? UserNumber { get; init; } + + /// + /// Gets the date after which the license itself stops working. + /// + public DateTime? ValidTo { get; init; } + + /// + /// Gets the date after which builds of PostSharp are no longer covered by the maintenance + /// subscription. + /// + public DateTime? SubscriptionEndDate { get; init; } + + /// + /// Gets the lowest version of PostSharp that can read this license. + /// + public required Version MinPostSharpVersion { get; init; } + + /// + /// Gets the lowest version of Metalama that can read this license, or null when every version of + /// Metalama can read it. The signature algorithm of the license key decides the value: a key + /// signed with an elliptic curve is read by no version released before that algorithm. + /// + public Version? MinMetalamaVersion { get; init; } + + /// + /// Gets a value indicating whether is a Metalama product. A Metalama client + /// reads and a PostSharp client reads + /// , and the two are independent of each other. + /// + public bool IsMetalamaProduct { get; init; } + + /// + /// Gets the lowest version of the client that can read this license, which is the minimum of the + /// family of . + /// + public Version MinClientVersion + => this.IsMetalamaProduct + ? this.MinMetalamaVersion ?? new Version( 0, 0, 0 ) + : this.MinPostSharpVersion; + + /// + /// Gets the name of the product family, which the messages that name a version use. + /// + public string ClientName => this.IsMetalamaProduct ? "Metalama" : "PostSharp"; + + /// + /// Gets the number of days during which the license may be used above its capacity before the + /// server denies a request. + /// + public required int GraceDays { get; init; } + + /// + /// Gets the percentage by which may be exceeded during the grace period. + /// + public required int GracePercent { get; init; } + + /// + /// Gets a value indicating whether a license server may serve this kind of license. + /// + public required bool IsLicenseServerEligible { get; init; } + + public string? LicenseTypeName { get; init; } + + public string? ProductName { get; init; } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/LicenseServerProductCatalog.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/LicenseServerProductCatalog.cs new file mode 100644 index 0000000..a7caa0a --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/LicenseServerProductCatalog.cs @@ -0,0 +1,68 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Collections.Immutable; +using SharpCrafters.Backstage.Licensing; + +namespace SharpCrafters.Backstage.LicenseServer.Licensing; + +/// +/// Names the products whose license keys the server holds. +/// +/// +/// +/// A answers two kinds of question. The first kind is the name +/// of a product. The second kind is how a license key of that product is registered on the machine +/// of a developer. Only the first kind applies here, because a license server serves the license +/// keys that its administrator added and registers none. The base class answers the first kind for +/// every product of both families, so this class only declares that the server is not a product +/// family of its own. +/// +/// +/// The members of the second kind let a client decide which edition to offer, which license key to +/// keep when another one is registered, and where to store it. A server makes none of these +/// decisions, so these members raise an exception instead of returning a value that would be wrong. +/// +/// +public sealed class LicenseServerProductCatalog : LicenseProductCatalog +{ + private const string notAClient = + "The license server names products but does not register license keys, so it has no " + + "editions of its own."; + + /// + /// Gets the single instance, which holds no state. + /// + public static LicenseServerProductCatalog Instance { get; } = new(); + + private LicenseServerProductCatalog() { } + + /// + /// A server holds the license keys of the products that its administrator added, and it can hold + /// the products of both families at the same time. + /// + public override bool IsProductOfFamily( LicenseProduct product ) => true; + + /// + public override bool IsFreeProduct( LicenseProduct product ) => product is LicenseProduct.MetalamaCommunity or LicenseProduct.PostSharpEssentials; + + /// + /// The server stores every license key in the same table. The registration of a client, which + /// depends on the version, has no equivalent here. + /// + public override bool RequiresVersionSpecificRegistration( LicenseProduct product ) => false; + + /// + public override ImmutableArray GetProductsCoexistingWith( LicenseProduct product ) => throw new NotSupportedException( notAClient ); + + /// + public override string PremiumEditionDisplayName => throw new NotSupportedException( notAClient ); + + /// + public override LicenseProduct EvaluationProduct => throw new NotSupportedException( notAClient ); + + /// + public override LicenseProduct? CommunityProduct => throw new NotSupportedException( notAClient ); + + /// + public override LicenseProduct? LegacyFreeProduct => throw new NotSupportedException( notAClient ); +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/ProductCodes.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/ProductCodes.cs new file mode 100644 index 0000000..2f3351e --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/ProductCodes.cs @@ -0,0 +1,76 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Collections.Immutable; +using SharpCrafters.Backstage.Licensing; + +namespace SharpCrafters.Backstage.LicenseServer.Licensing; + +/// +/// The value of the ProductCode column, and the names a client may ask for it by. +/// +/// +/// +/// The column contains the name of the licensed product. A client that names a product in its +/// request is served only from the licenses that carry that name. The two spellings of a name must +/// be treated as one name, because PostSharp and SharpCrafters.Backstage use different ones. The +/// enumeration of PostSharp calls the products Ultimate and Framework. The enumeration +/// of Backstage calls the same values PostSharpUltimate and PostSharpFramework. +/// +/// +/// A database created by an earlier version of this server therefore contains the PostSharp +/// spelling, and a client of the Backstage generation asks for the Backstage spelling. Without this +/// mapping, a request that names a product would find none of the licenses of an existing +/// installation. The server writes new rows with the Backstage spelling, which is the spelling the +/// clients send, so a database that went through the upgrade contains both. The two spellings are +/// therefore reconciled when a request is matched, and not when a row is written. +/// +/// +public static class ProductCodes +{ + /// + /// The name that each product had in the LicensedProduct enumeration of the PostSharp + /// SDK, for the products whose name changed. The table contains neither the values that a + /// license server never wrote, nor the Metalama products, which are more recent than the change. + /// + private static readonly ImmutableDictionary legacyNames = + new Dictionary( StringComparer.OrdinalIgnoreCase ) + { + [nameof(LicenseProduct.PostSharpUltimate)] = "Ultimate", + [nameof(LicenseProduct.PostSharpFramework)] = "Framework", + [nameof(LicenseProduct.PostSharpDiagnosticsLibrary)] = "DiagnosticsLibrary", + [nameof(LicenseProduct.PostSharpModelLibrary)] = "ModelLibrary", + [nameof(LicenseProduct.PostSharpThreadingLibrary)] = "ThreadingLibrary", + [nameof(LicenseProduct.PostSharpCachingLibrary)] = "CachingLibrary", +#pragma warning disable CS0618 // The product is obsolete, but a row naming it may still exist. + [nameof(LicenseProduct.PostSharpUltimate1)] = "PostSharp30" +#pragma warning restore CS0618 + }.ToImmutableDictionary( StringComparer.OrdinalIgnoreCase ); + + private static readonly ImmutableDictionary currentNames = + legacyNames.ToImmutableDictionary( x => x.Value, x => x.Key, StringComparer.OrdinalIgnoreCase ); + + /// + /// Gets the value to store in the ProductCode column for a product. + /// + public static string ForStorage( LicenseProduct product ) => product.ToString(); + + /// + /// Gets every value of the ProductCode column that satisfies a request for a product. + /// These values are the name that the client asked for and, when the name changed, its other + /// spelling. + /// + public static IReadOnlyList Matching( string productCode ) + { + if ( legacyNames.TryGetValue( productCode, out var legacy ) ) + { + return [productCode, legacy]; + } + + if ( currentNames.TryGetValue( productCode, out var current ) ) + { + return [productCode, current]; + } + + return [productCode]; + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/TestLicenseAuthority.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/TestLicenseAuthority.cs new file mode 100644 index 0000000..f1c16a6 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/TestLicenseAuthority.cs @@ -0,0 +1,114 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Security.Cryptography; +using Microsoft.Extensions.Logging; +using SharpCrafters.Backstage.Licensing; +using SharpCrafters.Backstage.Licensing.Licenses; + +namespace SharpCrafters.Backstage.LicenseServer.Licensing; + +/// +/// A licensing authority that a development server uses to issue to itself the license keys it +/// serves. +/// +/// +/// +/// A server that has no license key cannot serve a lease, and the production authority signs only +/// license keys that were sold. This authority exists for an evaluation and for a load simulation. +/// It signs license keys that only a server holding the same key pair accepts. +/// +/// +/// The key pair is generated at the first use and stored in the data directory, next to the audit +/// signing key. It must survive the process, because the license keys it signed are stored in the +/// database and stop being valid when the pair changes. In a container, the data directory must +/// therefore be a volume. +/// +/// +public sealed class TestLicenseAuthority +{ + /// + /// The identifier that the signature of these license keys contains. It differs from the + /// identifiers of the production keys, which are 0, 1 and 2. + /// + public const byte KeyId = 200; + + private readonly LicensingAuthority signingAuthority; + + /// + /// Gets the provider that verifies the license keys this authority signs. It holds the public + /// half of the key pair, and the server passes it to the license parser. + /// + public ILicensingAuthorityProvider Authority { get; } + + private TestLicenseAuthority( ECParameters parameters ) + { + this.signingAuthority = CreateAuthority( ToXml( parameters, true ) ); + this.Authority = new ExplicitLicensingAuthorityProvider( ( KeyId, ToXml( parameters, false ) ) ); + } + + /// + /// Reads the key pair from . When the file does not exist, it + /// generates a key pair and stores it in that file. + /// + public static TestLicenseAuthority LoadOrCreate( string keyFilePath, ILogger logger ) + { + ArgumentException.ThrowIfNullOrWhiteSpace( keyFilePath ); + ArgumentNullException.ThrowIfNull( logger ); + + using var key = ECDsa.Create( ECCurve.NamedCurves.nistP256 ); + + if ( File.Exists( keyFilePath ) ) + { + key.ImportECPrivateKey( Convert.FromBase64String( File.ReadAllText( keyFilePath ).Trim() ), out _ ); + + return new TestLicenseAuthority( key.ExportParameters( true ) ); + } + + Directory.CreateDirectory( Path.GetDirectoryName( keyFilePath )! ); + File.WriteAllText( keyFilePath, Convert.ToBase64String( key.ExportECPrivateKey() ) ); + + logger.LogInformation( + "Generated a test licensing authority in {Path}. The license keys it signs are accepted by " + + "this server alone, and stop being accepted if the file is lost.", + keyFilePath ); + + return new TestLicenseAuthority( key.ExportParameters( true ) ); + } + + /// + /// Signs a license key that this server accepts. + /// + public string CreateLicenseKey( + int licenseId, + LicenseProduct product, + LicenseType licenseType, + short userNumber, + byte graceDays, + byte gracePercent, + DateTime validTo ) + { + LicenseKeyDataBuilder builder = new() + { + LicenseId = licenseId, + Product = product, + LicenseType = licenseType, + UserNumber = userNumber, + GraceDays = graceDays, + GracePercent = gracePercent, + LicenseServerEligible = true, + ValidTo = validTo, + SubscriptionEndDate = validTo + }; + + return builder.SignAndSerialize( this.signingAuthority ); + } + + private static LicensingAuthority CreateAuthority( string keyXml ) => new ExplicitLicensingAuthorityProvider( ( KeyId, keyXml ) ).GetAuthority( KeyId ); + + private static string ToXml( ECParameters parameters, bool includePrivateValue ) + => "nistP256" + + $"{Convert.ToBase64String( parameters.Q.X! )}" + + $"{Convert.ToBase64String( parameters.Q.Y! )}" + + ( includePrivateValue ? $"{Convert.ToBase64String( parameters.D! )}" : string.Empty ) + + ""; +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/TestLicenseSeeder.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/TestLicenseSeeder.cs new file mode 100644 index 0000000..5be4c33 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Licensing/TestLicenseSeeder.cs @@ -0,0 +1,91 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.Licensing; + +namespace SharpCrafters.Backstage.LicenseServer.Licensing; + +/// +/// Adds license keys to the database of a development server, so that the server can serve a lease +/// before a license is bought. +/// +public static class TestLicenseSeeder +{ + /// + /// The licenses that this class adds. There is one license per product family, so that a client + /// that names a product exercises the matching of the product instead of the rule that serves + /// any product. + /// + private static readonly (int LicenseId, LicenseProduct Product, short Users)[] licenses = + [ + ( 900001, LicenseProduct.MetalamaProfessional, 25 ), + ( 900002, LicenseProduct.PostSharpUltimate, 25 ) + ]; + + /// + /// Adds the license keys that the database does not contain, and keeps the license keys it + /// contains. + /// + /// The identifiers of the licenses that were added. + /// + /// A license that is already present is kept and not replaced, so that a server restarted against + /// the same database keeps the leases it has granted. signs + /// the keys, and its key pair is stored in the same data directory, so the keys and the authority + /// survive a restart together, or are lost together. + /// + public static IReadOnlyList Seed( + LicenseServerDbContext db, + TestLicenseAuthority authority, + TimeProvider timeProvider, + ILogger logger ) + { + ArgumentNullException.ThrowIfNull( db ); + ArgumentNullException.ThrowIfNull( authority ); + ArgumentNullException.ThrowIfNull( timeProvider ); + ArgumentNullException.ThrowIfNull( logger ); + + var now = timeProvider.GetUtcNow().UtcDateTime; + HashSet existing = [.. db.Licenses.Select( l => l.LicenseId )]; + List added = []; + + foreach ( var (licenseId, product, users) in licenses ) + { + if ( existing.Contains( licenseId ) ) + { + continue; + } + + db.Licenses.Add( + new License + { + LicenseId = licenseId, + ProductCode = ProductCodes.ForStorage( product ), + CreatedOn = now, + LicenseKey = authority.CreateLicenseKey( + licenseId, + product, + LicenseType.Business, + users, + graceDays: 5, + gracePercent: 20, + validTo: now.AddYears( 5 ) ) + } ); + + added.Add( licenseId ); + } + + if ( added.Count > 0 ) + { + db.SaveChanges(); + + logger.LogWarning( + "Added the test licenses {LicenseIds}, signed by this server's own test licensing " + + "authority. They are not licenses anybody sold, and no other server accepts them.", + string.Join( ", ", added ) ); + } + + return added; + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Locking/ILeaseLock.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Locking/ILeaseLock.cs new file mode 100644 index 0000000..cce6a8d --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Locking/ILeaseLock.cs @@ -0,0 +1,20 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +namespace SharpCrafters.Backstage.LicenseServer.Locking; + +/// +/// Serializes the lease requests, so that two concurrent requests cannot both take the last free +/// seat. Replaces the named Mutex of the legacy Lease.ashx handler, which covered the +/// whole machine, ran only on Windows, and blocked a thread of the thread pool. +/// +public interface ILeaseLock +{ + /// + /// Acquires the lock, waiting at most . + /// + /// + /// A handle that releases the lock when it is disposed, or null when the timeout elapsed. + /// The caller then answers with the status 503. + /// + ValueTask TryAcquireAsync( TimeSpan timeout, CancellationToken cancellationToken = default ); +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Options/LicenseServerOptions.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Options/LicenseServerOptions.cs new file mode 100644 index 0000000..0042db5 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Options/LicenseServerOptions.cs @@ -0,0 +1,126 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.ComponentModel.DataAnnotations; + +namespace SharpCrafters.Backstage.LicenseServer.Options; + +/// +/// Settings of the license server. Replaces the applicationSettings section of the legacy +/// Web.config. The names of the settings are unchanged, so the existing administration +/// documentation remains valid. +/// +public sealed class LicenseServerOptions +{ + public const string SectionName = "LicenseServer"; + + /// + /// Gets or sets the number of days between two notification e-mails about the grace period of a + /// license, that is, the period during which a license has more leases than its capacity allows. + /// + [Range( 0, 365 )] + public int GracePeriodWarningDays { get; set; } = 1; + + /// + /// Gets or sets the number of machines that one seat covers. A user working on more machines + /// takes more than one seat: the number of machines divided by this value, rounded up. See + /// . The default value is 2. Read your license agreement before you + /// set another value. + /// + [Range( 1, 100 )] + public int MachinesPerUser { get; set; } = 2; + + /// + /// Gets or sets the number of days before the end of a lease at which a client renews that lease. + /// When developers work five weeks without a network connection to the license server, set this + /// value above 35. Must be smaller than . + /// + [Range( 0, 3650 )] + public int MinLeaseDays { get; set; } = 1; + + /// + /// Gets or sets the duration of a new lease, in days. + /// + [Range( 1, 3650 )] + public int NewLeaseDays { get; set; } = 3; + + /// + /// Gets or sets the address that receives a notification e-mail when the grace period starts. + /// + public string? GracePeriodWarningEmailTo { get; set; } + + /// + /// Gets or sets the addresses copied on the notification e-mails about the grace period. + /// + public string? GracePeriodWarningEmailCC { get; set; } + + /// + /// Gets or sets the address that receives a notification e-mail when a lease request is denied. + /// + public string? DeniedRequestEmailTo { get; set; } + + /// + /// Gets or sets the timeout of the lock that serializes concurrent lease requests, in seconds. + /// The server answers a lease request with the status 503 when it cannot obtain the lock within + /// this period. + /// + [Range( 1, 600 )] + public int MutexTimeout { get; set; } = 30; + + /// + /// Gets or sets the factor by which the clock of the server runs faster than real time. Set it to + /// 1 in production. It exists for tests. + /// + public decimal TimeAcceleration { get; set; } = 1; + + /// + /// Gets or sets the names of the machines of the build servers, separated by semicolons. A build + /// server receives a lease that is not stored, so that it does not consume the seat of a + /// developer. + /// + public string? BuildServers { get; set; } + + /// + /// Gets or sets the Windows groups allowed to open the administrative pages, for example + /// DOMAIN\PostSharp Administrators. When the value is empty, the administrative pages are + /// not restricted, which is the behaviour of the legacy Web.config. + /// + public string[] AdminRoles { get; set; } = []; + + /// + /// Gets or sets a value indicating whether a lease request must be authenticated. When the value + /// is false, the server serves anonymous requests, which is the behaviour of the legacy + /// Web.config. + /// + public bool RequireAuthenticatedLeaseRequests { get; set; } + + /// + /// Gets or sets the licensing authorities whose license keys this server accepts in addition to + /// the production authority. This setting exists so that a load simulation can run against + /// license keys that are not sold. The server refuses to start when this setting is used outside + /// the Development environment. + /// + public TestLicensingAuthority[] TestLicensingAuthorities { get; set; } = []; + + /// + /// Gets or sets a value indicating whether the server issues to itself the license keys it + /// serves. This setting exists so that an evaluation or a load simulation has a license to lease + /// before a license is bought. The server refuses to start when this setting is used outside the + /// Development environment. + /// + /// + /// The server generates a licensing authority of its own, stores it in + /// , and accepts its license keys. No other server accepts them. + /// + public bool SeedTestLicenses { get; set; } + + /// + /// Gets or sets the directory that contains the files the server generates, which is the test + /// licensing authority when the server has one. A relative path is resolved against the + /// application directory. In a container, this directory must be a volume. Otherwise the server + /// generates a new test licensing authority every time the container is replaced, and the + /// license keys of the previous authority stop being valid. + /// + public string DataDirectory { get; set; } = "App_Data"; + + public TimeSpan MutexTimeoutSpan => TimeSpan.FromSeconds( this.MutexTimeout ); +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Options/LicenseServerOptionsValidator.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Options/LicenseServerOptionsValidator.cs new file mode 100644 index 0000000..945bf16 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Options/LicenseServerOptionsValidator.cs @@ -0,0 +1,33 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.Extensions.Options; + +namespace SharpCrafters.Backstage.LicenseServer.Options; + +/// +/// Validates the relations between settings that a data annotation cannot express. The legacy +/// Web.config documented these constraints, and no code enforced them. +/// +public sealed class LicenseServerOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate( string? name, LicenseServerOptions options ) + { + List failures = []; + + // When the renewal time of a lease is not before its end time, the client renews the lease at + // every request. + if ( options.MinLeaseDays >= options.NewLeaseDays ) + { + failures.Add( + $"MinLeaseDays ({options.MinLeaseDays}) must be smaller than NewLeaseDays ({options.NewLeaseDays}), " + + "otherwise a new lease is already due for renewal when it is granted." ); + } + + if ( options.TimeAcceleration < 0 ) + { + failures.Add( $"TimeAcceleration ({options.TimeAcceleration}) cannot be negative." ); + } + + return failures.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail( failures ); + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Options/SmtpOptions.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Options/SmtpOptions.cs new file mode 100644 index 0000000..d500f51 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Options/SmtpOptions.cs @@ -0,0 +1,33 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +namespace SharpCrafters.Backstage.LicenseServer.Options; + +/// +/// Settings of the SMTP server that sends the notification e-mails. Replaces the +/// system.net/mailSettings section of the legacy Web.config. +/// +public sealed class SmtpOptions +{ + public const string SectionName = "Smtp"; + + /// + /// Gets or sets a value indicating whether the server sends notification e-mails. + /// + public bool Enabled { get; set; } = true; + + public string Host { get; set; } = "localhost"; + + public int Port { get; set; } = 25; + + public bool EnableSsl { get; set; } + + /// + /// Gets or sets the address of the sender. The legacy implementation used the fixed address + /// sales@postsharp.net. + /// + public string FromAddress { get; set; } = "sales@postsharp.net"; + + public string? UserName { get; set; } + + public string? Password { get; set; } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Options/TestLicensingAuthority.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Options/TestLicensingAuthority.cs new file mode 100644 index 0000000..4744a68 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Options/TestLicensingAuthority.cs @@ -0,0 +1,29 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +namespace SharpCrafters.Backstage.LicenseServer.Options; + +/// +/// One licensing authority whose license keys a development server accepts, in addition to the +/// production authority. +/// +/// +/// This setting contains only the public half of the key pair. The public half verifies a signature +/// and cannot create one. Whoever holds the private half can create license keys that a server +/// configured in this way accepts, so the server refuses this setting outside the Development +/// environment. +/// +public sealed class TestLicensingAuthority +{ + /// + /// Gets or sets the identifier that the signature of a license key contains. It must differ from + /// the identifiers of the production keys, which are 0, 1 and 2. + /// + public byte KeyId { get; set; } + + /// + /// Gets or sets the public key, in the XML representation that SharpCrafters.Backstage reads: + /// an ECDSAKeyValue element for an Elliptic Curve DSA key, or a DSAKeyValue element + /// for a finite field DSA one. + /// + public string PublicKey { get; set; } = ""; +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Services/GrantedLease.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Services/GrantedLease.cs new file mode 100644 index 0000000..bfdc282 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Services/GrantedLease.cs @@ -0,0 +1,18 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Email; +using SharpCrafters.Backstage.LicenseServer.Licensing; +using SharpCrafters.Backstage.LicenseServer.Options; + +namespace SharpCrafters.Backstage.LicenseServer.Services; + +/// +/// The lease granted to a client: which license key to use, and for how long. +/// +public sealed record GrantedLease( string LicenseKey, DateTime StartTime, DateTime EndTime, DateTime RenewTime ); \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Services/LeaseService.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Services/LeaseService.cs new file mode 100644 index 0000000..59941f0 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Services/LeaseService.cs @@ -0,0 +1,510 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Email; +using SharpCrafters.Backstage.LicenseServer.Licensing; +using SharpCrafters.Backstage.LicenseServer.Options; + +namespace SharpCrafters.Backstage.LicenseServer.Services; + +/// +/// Decides which license, if any, satisfies a lease request. +/// +/// +/// The allocation runs three passes over the candidate licenses. The first pass reuses or prolongs a +/// lease that the user already holds. The second pass grants a new lease against free capacity. The +/// third pass grants a lease within the grace period. +/// +public sealed partial class LeaseService +{ + private readonly ILeaseRepository repository; + private readonly LicenseServerOptions settings; + private readonly ILicenseParser licenseParser; + private readonly ILicenseServerVersion serverVersion; + private readonly IEmailSender emailSender; + private readonly ILogger logger; + private readonly HashSet buildServers = new( StringComparer.OrdinalIgnoreCase ); + + public LeaseService( + ILeaseRepository repository, + IOptions options, + ILicenseParser licenseParser, + ILicenseServerVersion serverVersion, + IEmailSender emailSender, + ILogger logger ) + { + this.repository = repository; + this.settings = options.Value; + this.licenseParser = licenseParser; + this.serverVersion = serverVersion; + this.emailSender = emailSender; + this.logger = logger; + + if ( !string.IsNullOrWhiteSpace( this.settings.BuildServers ) ) + { + foreach ( var buildServer in this.settings.BuildServers.Split( [';', ',', ' '] ) ) + { + if ( !string.IsNullOrWhiteSpace( buildServer ) ) + { + this.buildServers.Add( buildServer.Trim() ); + } + } + } + } + + /// + /// Matches the unique-identifier suffix that build agents append to their machine name. + /// + [GeneratedRegex( "-[0-9a-fA-F]+$" )] + private static partial Regex ComputerUniqueIdRegex { get; } + + /// + /// Validates a license against the request and, if it is usable, returns its current state. + /// + /// null when the license cannot serve this request, in which case + /// explains why. + private LicenseState? GetLicenseState( + License license, + Version version, + DateTime? buildDate, + DateTime now, + Dictionary cache, + Dictionary errors ) + { + if ( cache.TryGetValue( license.LicenseId, out var licenseState ) ) + { + return licenseState; + } + + var parsedLicense = this.licenseParser.TryParse( license.LicenseKey ); + + if ( parsedLicense == null ) + { + errors[license.LicenseId] = $"The license key #{license.LicenseId} is invalid."; + + return null; + } + + if ( parsedLicense.MinPostSharpVersion > this.serverVersion.LicensingLibraryVersion ) + { + errors[license.LicenseId] = + $"The license #{license.LicenseId} requires a higher version of the licensing library on the License Server. " + + $"Please upgrade the License Server to >= {parsedLicense.MinPostSharpVersion.Major}." + + $"{parsedLicense.MinPostSharpVersion.Minor}.{parsedLicense.MinPostSharpVersion.Build}"; + + return null; + } + + // A PostSharp license names the lowest version of PostSharp that can read it, and a Metalama + // license names the lowest version of Metalama. The two are independent, so the minimum that + // applies is the one of the family of the licensed product. + var minClientVersion = parsedLicense.MinClientVersion; + + if ( minClientVersion > version ) + { + errors[license.LicenseId] = + $"The license #{license.LicenseId} of type {parsedLicense.LicenseType} requires " + + $"{parsedLicense.ClientName} version >= {minClientVersion.Major}.{minClientVersion.Minor}." + + $"{minClientVersion.Build} but the requested version is " + + $"{version.Major}.{version.Minor}.{version.Build}."; + + return null; + } + + if ( !parsedLicense.IsLicenseServerEligible ) + { + errors[license.LicenseId] = + $"The license #{license.LicenseId}, of type {parsedLicense.LicenseType}, cannot be used in the license server."; + + return null; + } + + if ( !( buildDate == null || parsedLicense.SubscriptionEndDate == null + || buildDate <= parsedLicense.SubscriptionEndDate ) ) + { + // The version number was introduced in the license server protocol in PostSharp 5. + errors[license.LicenseId] = version.Major >= 5 + ? $"The maintenance subscription of license #{license.LicenseId} ends on " + + $"{parsedLicense.SubscriptionEndDate:d} but the requested version " + + $"{version.Major}.{version.Minor}.{version.Build} has been built on {buildDate:d}." + : $"The maintenance subscription of license #{license.LicenseId} ends on " + + $"{parsedLicense.SubscriptionEndDate:d} but the requested version has been built on " + + $"{buildDate:d}."; + + return null; + } + + licenseState = new LicenseState( now, this.repository, license, parsedLicense ); + cache.Add( license.LicenseId, licenseState ); + + return licenseState; + } + + /// + /// Serves a lease request, returning the license key and the validity of the lease. + /// + public async Task GetLicenseLeaseAsync( + string? productCode, + Version version, + DateTime? buildDate, + string machine, + string userName, + string authenticatedUserName, + DateTime now, + Dictionary errors, + CancellationToken cancellationToken = default ) + { + // A client that names no product is served from any pool. Every PostSharp client relies on + // this behaviour, because none of them sends the argument. + var productCodes = string.IsNullOrEmpty( productCode ) + ? [] + : ProductCodes.Matching( productCode ); + + var licenses = await this.repository.Licenses + .Where( license => ( productCodes.Count == 0 || productCodes.Contains( license.ProductCode ) ) + && license.Priority >= 0 ) + .OrderBy( license => license.Priority ) + .ToArrayAsync( cancellationToken ); + + if ( this.IsBuildServer( machine ) ) + { + Dictionary buildServerStates = []; + + // A build agent is exempt from consuming a seat. It is not exempt from the rules that + // decide which licenses may be served, so the same validation runs. + foreach ( var candidate in licenses ) + { + var state = + this.GetLicenseState( candidate, version, buildDate, now, buildServerStates, errors ); + + if ( state == null ) + { + continue; + } + + var endTime = now.AddDays( this.settings.NewLeaseDays ); + + if ( state.ParsedLicense.ValidTo.HasValue && state.ParsedLicense.ValidTo < endTime ) + { + endTime = state.ParsedLicense.ValidTo.Value; + } + + if ( endTime <= now ) + { + // The license expires before the lease would begin. + continue; + } + + // The server does not store the lease of a build server, so that build agents do not + // consume the seats of the developers they build for. + return new GrantedLease( + candidate.LicenseKey, + now, + endTime, + endTime.AddDays( -this.settings.MinLeaseDays ) ); + } + + // No license could be served without a lease. Continue with the normal allocation. + } + + var lease = await this.GetLeaseAsync( + version, + buildDate, + machine, + userName, + authenticatedUserName, + now, + errors, + licenses, + productCode, + cancellationToken ); + + if ( lease == null ) + { + return null; + } + + return new GrantedLease( + lease.License.LicenseKey, + lease.StartTime, + lease.EndTime, + lease.EndTime.AddDays( -this.settings.MinLeaseDays ) ); + } + + /// + /// Finds or creates the lease that satisfies a request. + /// + public async Task GetLeaseAsync( + Version version, + DateTime? buildDate, + string machine, + string userName, + string authenticatedUserName, + DateTime now, + Dictionary errors, + License[] licenses, + string? productCode = null, + CancellationToken cancellationToken = default ) + { + Dictionary licenseStates = []; + + // First pass: a lease this user already holds on this machine, reused or prolonged. + foreach ( var license in licenses ) + { + var licenseState = + this.GetLicenseState( license, version, buildDate, now, licenseStates, errors ); + + if ( licenseState == null ) + { + continue; + } + + var licenseId = license.LicenseId; + + var currentLeases = await this.repository.OpenLeases + .Where( l => l.LicenseId == licenseId && l.StartTime <= now && l.EndTime > now && l.UserName == userName ) + .Include( l => l.License ) + .OrderBy( l => l.StartTime ) + .ToArrayAsync( cancellationToken ); + + Dictionary machines = new( StringComparer.OrdinalIgnoreCase ); + + foreach ( var candidateLease in currentLeases ) + { + machines[candidateLease.Machine] = candidateLease.Machine; + + if ( candidateLease.Machine != machine ) + { + continue; + } + + if ( candidateLease.EndTime > now.AddDays( this.settings.MinLeaseDays ) ) + { + // The lease that the user already holds ends late enough. + return candidateLease; + } + + // A lease can always be prolonged, because a lease starts at the current instant and + // therefore already covers it. The license period or the grace period can still end + // first. + var prolonged = this.repository.ProlongLease( candidateLease, authenticatedUserName, now ); + + if ( prolonged == null ) + { + continue; + } + + return prolonged; + } + + // This user holds no lease on this machine. An additional machine consumes no seat while + // the user works on fewer machines than MachinesPerUser. + if ( machines.Count % this.settings.MachinesPerUser != 0 ) + { + var lease = this.repository.CreateLease( + license, + userName, + machine, + authenticatedUserName, + now, + licenseState.InExcess ); + + if ( lease != null ) + { + return lease; + } + } + } + + // Second pass: a new lease against spare capacity. + foreach ( var license in licenses ) + { + var licenseState = + this.GetLicenseState( license, version, buildDate, now, licenseStates, errors ); + + if ( licenseState == null ) + { + continue; + } + + if ( licenseState.Maximum.HasValue && licenseState.Maximum.Value <= licenseState.Usage + && ( !licenseState.ParsedLicense.ValidTo.HasValue + || now < licenseState.ParsedLicense.ValidTo ) ) + { + // This license is full. + continue; + } + + var lease = this.repository.CreateLease( license, userName, machine, authenticatedUserName, now, false ); + + if ( lease != null ) + { + return lease; + } + } + + // Third pass: the grace period. + foreach ( var license in licenses ) + { + var licenseState = + this.GetLicenseState( license, version, buildDate, now, licenseStates, errors ); + + if ( licenseState == null ) + { + continue; + } + + if ( !licenseState.Maximum.HasValue ) + { + // A license with no seat limit has no capacity to exceed, so it has no grace period. + // It reaches this pass only when the second pass refused it for another reason, for + // example an expired license. + continue; + } + + license.GraceStartTime ??= now; + + var graceLimit = LicenseCapacity.GetGraceLimit( + licenseState.Maximum.Value, + licenseState.ParsedLicense.GracePercent ); + + var graceEnd = license.GraceStartTime.Value.AddDays( licenseState.ParsedLicense.GraceDays ); + + if ( license.GraceStartTime <= now && graceEnd > now && licenseState.Usage < graceLimit ) + { + if ( license.GraceLastWarningTime.GetValueOrDefault( DateTime.MinValue ) + .AddDays( this.settings.GracePeriodWarningDays ) < now ) + { + var body = + $"The license #{license.LicenseId} has a capacity of {licenseState.Maximum} concurrent user(s), " + + $"but {licenseState.Usage + 1} users are currently using the product " + + $"{licenseState.ParsedLicense.Product}. " + + $"The grace period has started on {license.GraceStartTime} and will end on {graceEnd}. " + + "After this date, additional leases will be denied." + + "Please contact PostSharp Technologies to acquire additional licenses."; + + await this.SendEmailAsync( + this.settings.GracePeriodWarningEmailTo, + this.settings.GracePeriodWarningEmailCC, + "WARNING: licensing capacity exceeded", + body, + cancellationToken ); + + // The time is recorded whether or not the message was delivered, so that an SMTP + // server that fails does not turn every request into a new warning e-mail. + license.GraceLastWarningTime = now; + } + + var lease = this.repository.CreateLease( + license, + userName, + machine, + authenticatedUserName, + now, + true ); + + if ( lease != null ) + { + return lease; + } + } + } + + await this.SendEmailAsync( + this.settings.DeniedRequestEmailTo, + null, + "ERROR: license request denied", + $"No license with free capacity was found to satisfy the lease request for the product " + + $"{productCode} from the user '{userName}' (authentication: '{authenticatedUserName}'), " + + $"machine '{machine}'. " + string.Join( ". ", errors.Values ), + cancellationToken ); + + return null; + } + + /// + /// Determines whether a machine is a build agent, ignoring the unique-identifier suffix that + /// build agents append to their name. + /// + public bool IsBuildServer( string machineName ) + { + machineName = ComputerUniqueIdRegex.Replace( machineName, string.Empty ); + + return this.buildServers.Contains( machineName ); + } + + private async Task SendEmailAsync( + string? to, + string? cc, + string subject, + string body, + CancellationToken cancellationToken ) + { + if ( string.IsNullOrWhiteSpace( to ) ) + { + return; + } + + try + { + await this.emailSender.SendAsync( + new EmailMessage( to.Trim( ' ', '\n', '\r', '\t' ), cc, subject, body ), + cancellationToken ); + } + catch ( Exception e ) + { + // A notification that cannot be sent must never deny a license. + this.logger.LogError( e, "Cannot send the notification email '{Subject}'.", subject ); + } + } + + /// + /// The capacity and the current usage of a license. The usage is computed on demand, because the + /// first pass serves most requests and does not need it. + /// + private sealed class LicenseState + { + private readonly DateTime time; + private readonly ILeaseRepository repository; + private readonly License license; + private readonly LicenseInfo parsedLicense; + private int usage = -1; + + public LicenseState( + DateTime time, + ILeaseRepository repository, + License license, + LicenseInfo parsedLicense ) + { + this.time = time; + this.repository = repository; + this.license = license; + this.parsedLicense = parsedLicense; + } + + public int Usage + { + get + { + if ( this.usage == -1 ) + { + this.usage = this.repository.GetActiveSeats( this.license.LicenseId, this.time ); + } + + return this.usage; + } + } + + public int? Maximum => this.parsedLicense.UserNumber; + + [SuppressMessage( "ReSharper", "UnusedMember.Local", Justification = "Part of the state's contract." )] + public bool InExcess => this.Maximum.HasValue && this.Maximum.Value < this.Usage; + + public LicenseInfo ParsedLicense => this.parsedLicense; + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Services/LicenseAvailability.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Services/LicenseAvailability.cs new file mode 100644 index 0000000..b05bfbc --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Services/LicenseAvailability.cs @@ -0,0 +1,77 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.EntityFrameworkCore; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Licensing; + +namespace SharpCrafters.Backstage.LicenseServer.Services; + +/// +/// How many licenses the server holds, and why the ones it cannot serve are unusable. +/// +public sealed record LicenseAvailability +{ + /// + /// Gets the number of licenses that can serve a lease now. + /// + public required int Available { get; init; } + + /// + /// Gets the number of licenses an administrator has disabled. + /// + public required int Disabled { get; init; } + + /// + /// Gets the number of licenses whose key the server cannot parse, whose type a license server + /// may not serve, or that require a newer licensing library than this server contains. + /// + public required int Invalid { get; init; } + + /// + /// Gets the number of licenses whose validity has ended. + /// + public required int Expired { get; init; } + + /// + /// Gets the number of licenses that are full and whose grace period has ended or is full as + /// well. + /// + public required int Exhausted { get; init; } + + public int Total => this.Available + this.Disabled + this.Invalid + this.Expired + this.Exhausted; + + public bool CanServeLease => this.Available > 0; + + /// + /// Returns one sentence that says whether a lease can be served, and why not when it cannot. + /// + public string Describe() + { + if ( this.CanServeLease ) + { + return $"{this.Available} of {this.Total} license(s) can serve a lease."; + } + + if ( this.Total == 0 ) + { + return "No license is registered."; + } + + List reasons = []; + + Add( this.Exhausted, "at capacity" ); + Add( this.Expired, "expired" ); + Add( this.Invalid, "invalid" ); + Add( this.Disabled, "disabled" ); + + return $"No license can serve a lease: {string.Join( ", ", reasons )}."; + + void Add( int count, string reason ) + { + if ( count > 0 ) + { + reasons.Add( $"{count} {reason}" ); + } + } + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Services/LicenseAvailabilityService.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Services/LicenseAvailabilityService.cs new file mode 100644 index 0000000..3042e4f --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Services/LicenseAvailabilityService.cs @@ -0,0 +1,126 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.EntityFrameworkCore; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Licensing; + +namespace SharpCrafters.Backstage.LicenseServer.Services; + +/// +/// Reports whether the server can serve a lease. This is the question a monitoring system asks. The +/// list of licenses on the home page does not answer it. +/// +/// +/// +/// This question is weaker than the question answers. A lease request +/// names a product, a version and a build date, and a license can be refused because of any of the +/// three. This service ignores the three and reports whether a license could serve a client at this +/// moment. A server that this service reports as available can therefore still deny an individual +/// request. +/// +/// +/// This service reads and never writes. The allocator starts the grace period of a license when it +/// grants a lease within that period. A health check that did the same would start the grace period +/// of a license that no client uses, and the period would elapse while the server is idle. +/// +/// +public sealed class LicenseAvailabilityService +{ + private readonly ILeaseRepository repository; + private readonly ILicenseParser licenseParser; + private readonly ILicenseServerVersion serverVersion; + + public LicenseAvailabilityService( + ILeaseRepository repository, + ILicenseParser licenseParser, + ILicenseServerVersion serverVersion ) + { + this.repository = repository; + this.licenseParser = licenseParser; + this.serverVersion = serverVersion; + } + + public async Task GetAvailabilityAsync( + DateTime now, + CancellationToken cancellationToken = default ) + { + var licenses = await this.repository.Licenses + .AsNoTracking() + .ToArrayAsync( cancellationToken ); + + var available = 0; + var disabled = 0; + var invalid = 0; + var expired = 0; + var exhausted = 0; + + foreach ( var license in licenses ) + { + if ( license.Priority < 0 ) + { + disabled++; + + continue; + } + + var parsedLicense = this.licenseParser.TryParse( license.LicenseKey ); + + if ( parsedLicense == null + || !parsedLicense.IsLicenseServerEligible + || parsedLicense.MinPostSharpVersion > this.serverVersion.LicensingLibraryVersion ) + { + invalid++; + + continue; + } + + if ( parsedLicense.ValidTo.HasValue && parsedLicense.ValidTo.Value <= now ) + { + expired++; + + continue; + } + + if ( !parsedLicense.UserNumber.HasValue ) + { + // A license with no seat limit always has a free seat. + available++; + + continue; + } + + var usage = this.repository.GetActiveSeats( license.LicenseId, now ); + + if ( usage < parsedLicense.UserNumber.Value ) + { + available++; + + continue; + } + + // Above the capacity, only the grace period remains. It is limited by a number of seats + // and by a number of days. A license whose grace period has not started yet starts it at + // the next request, so it counts as available. + var graceLimit = LicenseCapacity.GetGraceLimit( parsedLicense.UserNumber.Value, parsedLicense.GracePercent ); + var graceEnd = ( license.GraceStartTime ?? now ).AddDays( parsedLicense.GraceDays ); + + if ( usage < graceLimit && graceEnd > now ) + { + available++; + } + else + { + exhausted++; + } + } + + return new LicenseAvailability + { + Available = available, + Disabled = disabled, + Invalid = invalid, + Expired = expired, + Exhausted = exhausted + }; + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Services/LicenseCapacity.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Services/LicenseCapacity.cs new file mode 100644 index 0000000..97eb3d9 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Services/LicenseCapacity.cs @@ -0,0 +1,16 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +namespace SharpCrafters.Backstage.LicenseServer.Services; + +/// +/// The capacity arithmetic of a license, in one place so that the allocator and the health check +/// cannot disagree about how many seats a license covers. +/// +public static class LicenseCapacity +{ + /// + /// Returns the number of seats a license covers while its grace period runs, which is its + /// capacity raised by the percentage the license key carries and rounded up. + /// + public static int GetGraceLimit( int maximum, int gracePercent ) => (int) Math.Ceiling( maximum * ( 100.0 + gracePercent ) / 100.0 ); +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/SharpCrafters.Backstage.LicenseServer.Core.csproj b/src/SharpCrafters.Backstage.LicenseServer.Core/SharpCrafters.Backstage.LicenseServer.Core.csproj new file mode 100644 index 0000000..f74065f --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/SharpCrafters.Backstage.LicenseServer.Core.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + SharpCrafters.Backstage.LicenseServer + SharpCrafters.Backstage.LicenseServer.Core + + + + + + + + + + + + + diff --git a/src/SharpCrafters.Backstage.LicenseServer.Core/Time/AcceleratedTimeProvider.cs b/src/SharpCrafters.Backstage.LicenseServer.Core/Time/AcceleratedTimeProvider.cs new file mode 100644 index 0000000..14dc350 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Core/Time/AcceleratedTimeProvider.cs @@ -0,0 +1,40 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +namespace SharpCrafters.Backstage.LicenseServer.Time; + +/// +/// A whose clock runs faster than real time, so that a licensing scenario +/// that lasts several days can be simulated in minutes. Replaces the legacy VirtualDateTime +/// class, whose acceleration was removed from a release build by the compiler, and which no test +/// harness could therefore use. +/// +public sealed class AcceleratedTimeProvider : TimeProvider +{ + private readonly TimeProvider inner; + private readonly DateTimeOffset origin; + private readonly double acceleration; + + public AcceleratedTimeProvider( TimeProvider inner, double acceleration ) + { + ArgumentNullException.ThrowIfNull( inner ); + + if ( acceleration <= 0 ) + { + throw new ArgumentOutOfRangeException( nameof(acceleration), acceleration, "Acceleration must be positive." ); + } + + this.inner = inner; + this.acceleration = acceleration; + this.origin = inner.GetUtcNow(); + } + + public double Acceleration => this.acceleration; + + public override DateTimeOffset GetUtcNow() => this.origin + ( ( this.inner.GetUtcNow() - this.origin ) * this.acceleration ); + + public override long GetTimestamp() => this.inner.GetTimestamp(); + + public override long TimestampFrequency => this.inner.TimestampFrequency; + + public override TimeZoneInfo LocalTimeZone => this.inner.LocalTimeZone; +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/AnonymousAuthenticationHandler.cs b/src/SharpCrafters.Backstage.LicenseServer.Web/AnonymousAuthenticationHandler.cs new file mode 100644 index 0000000..20101cb --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/AnonymousAuthenticationHandler.cs @@ -0,0 +1,25 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Runtime.InteropServices; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Negotiate; +using Microsoft.AspNetCore.Server.IISIntegration; +using Microsoft.Extensions.Options; + +namespace SharpCrafters.Backstage.LicenseServer; + +/// +/// Authenticates no caller, so that the server serves every request anonymously. +/// +public sealed class AnonymousAuthenticationHandler : AuthenticationHandler +{ + public AnonymousAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder ) : base( options, logger, encoder ) { } + + public const string SchemeName = "None"; + + protected override Task HandleAuthenticateAsync() => Task.FromResult( AuthenticateResult.NoResult() ); +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/AuthenticationRegistration.cs b/src/SharpCrafters.Backstage.LicenseServer.Web/AuthenticationRegistration.cs new file mode 100644 index 0000000..9660804 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/AuthenticationRegistration.cs @@ -0,0 +1,108 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Runtime.InteropServices; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Negotiate; +using Microsoft.AspNetCore.Server.IISIntegration; +using Microsoft.Extensions.Options; + +namespace SharpCrafters.Backstage.LicenseServer; + +/// +/// Selects how the license server authenticates the caller of a request. +/// +public static class AuthenticationRegistration +{ + /// + /// Windows authentication handled by IIS. Required for in-process hosting behind IIS. + /// + public const string IisIntegrated = "IISIntegrated"; + + /// + /// Windows authentication performed by the application. It works on Windows without further + /// configuration. On another system, it requires a host joined to the domain and a Kerberos + /// keytab. + /// + public const string Negotiate = "Negotiate"; + + /// + /// No authentication. The server records the leases with an empty authenticated user, as it + /// records an anonymous request. + /// + public const string None = "None"; + + /// + /// Registers the scheme named by Authentication:Scheme. + /// + /// The scheme that was registered, for logging. + public static string AddLicenseServerAuthentication( + this IServiceCollection services, + IConfiguration configuration ) + { + var scheme = ResolveScheme( configuration ); + + switch ( scheme ) + { + case IisIntegrated: + services.AddAuthentication( IISDefaults.AuthenticationScheme ); + + break; + + case Negotiate: + services.AddAuthentication( NegotiateDefaults.AuthenticationScheme ).AddNegotiate(); + + break; + + case None: + services.AddAuthentication( AnonymousAuthenticationHandler.SchemeName ) + .AddScheme( + AnonymousAuthenticationHandler.SchemeName, + _ => { } ); + + break; + + default: + throw new InvalidOperationException( $"Unknown authentication scheme '{scheme}'. Use '{IisIntegrated}', '{Negotiate}' or '{None}'." ); + } + + return scheme; + } + + /// + /// Selects the scheme when the configuration does not name one. + /// + /// + /// The same package runs behind IIS, in its own process on a Windows machine, and on a Linux host + /// that can have no domain. A wrong selection is not reported as an error: the server records + /// every lease with an empty authenticated user. The selection is therefore made from the way the + /// application is hosted, and it is written to the log at startup. + /// + private static string ResolveScheme( IConfiguration configuration ) + { + var configured = configuration["Authentication:Scheme"]; + + if ( !string.IsNullOrWhiteSpace( configured ) ) + { + // The comparison ignores the case, so that "negotiate" in the environment of a container + // is accepted. + foreach ( var known in new[] { IisIntegrated, Negotiate, None } ) + { + if ( string.Equals( configured, known, StringComparison.OrdinalIgnoreCase ) ) + { + return known; + } + } + + return configured; + } + + // The ASP.NET Core Module sets this when it hosts the application. + if ( Environment.GetEnvironmentVariable( "ASPNETCORE_IIS_PHYSICAL_PATH" ) != null ) + { + return IisIntegrated; + } + + return RuntimeInformation.IsOSPlatform( OSPlatform.Windows ) ? Negotiate : None; + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Database/CreateTables.PostgreSql.sql b/src/SharpCrafters.Backstage.LicenseServer.Web/Database/CreateTables.PostgreSql.sql new file mode 100644 index 0000000..f9e28e3 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Database/CreateTables.PostgreSql.sql @@ -0,0 +1,56 @@ +-- The schema of the license server on PostgreSQL. Run it once, against an empty database, before you +-- start the server for the first time. The server never creates the schema and never modifies it, so +-- an upgrade of the server makes no change to the database. +-- +-- The names of the tables and of the columns are quoted, so they keep their capitals. The queries of +-- the server quote them the same way. +-- +-- This schema has no HMAC column. The SQL Server schema has one, because installations created before +-- 2026 have it and the column is left in place there. Nothing writes it. + +-- The comparison of a user name and of a machine name ignores the case, as it does on SQL Server, +-- whose default collation ignores it. A collation that is not deterministic is what makes a +-- comparison and a grouping ignore the case. It refuses the pattern operators, and no query of this +-- server applies one to these two columns. +CREATE COLLATION IF NOT EXISTS "license_server_ci" ( + provider = icu, locale = 'und-u-ks-level2', deterministic = false ); + +CREATE TABLE "Licenses" ( + -- The identifier comes from the license key and is not generated by the database. + "LicenseId" integer NOT NULL, + "LicenseKey" text NOT NULL, + "ProductCode" character varying(50) NOT NULL, + "Priority" integer NOT NULL, + "CreatedOn" timestamp with time zone NOT NULL, + "GraceStartTime" timestamp with time zone, + "GraceLastWarningTime" timestamp with time zone, + CONSTRAINT "PK_Licenses" PRIMARY KEY ("LicenseId") +); + +CREATE TABLE "Leases" ( + "LeaseId" integer GENERATED BY DEFAULT AS IDENTITY, + -- The lease this one replaces. Prolonging a lease and cancelling one both insert a row, so the + -- table is the audit log and a row is never updated and never deleted. + "OverwrittenLeaseId" integer, + "LicenseId" integer NOT NULL, + "StartTime" timestamp with time zone NOT NULL, + "EndTime" timestamp with time zone NOT NULL, + "UserName" character varying(200) COLLATE "license_server_ci" NOT NULL, + "Machine" character varying(200) COLLATE "license_server_ci" NOT NULL, + "AuthenticatedUser" character varying(200) NOT NULL, + "Grace" boolean NOT NULL, + CONSTRAINT "PK_Leases" PRIMARY KEY ("LeaseId"), + -- The deletion of a license must not cascade through the chain of replaced leases, so both keys + -- restrict instead of cascading. The page that deletes a license deletes the leases itself, the + -- most recent one first. + CONSTRAINT "FK_Leases_Leases" FOREIGN KEY ("OverwrittenLeaseId") REFERENCES "Leases" ("LeaseId") ON DELETE RESTRICT, + CONSTRAINT "FK_Leases_Licenses" FOREIGN KEY ("LicenseId") REFERENCES "Licenses" ("LicenseId") ON DELETE RESTRICT +); + +-- The index a lease request uses: it reads the leases that are open at the current time. +CREATE INDEX "IX_Leases_EndTime" ON "Leases" ("EndTime"); + +CREATE INDEX "IX_Leases_LicenseId" ON "Leases" ("LicenseId"); + +-- The index of the anti-join that finds the leases nothing has replaced. +CREATE INDEX "IX_Leases_OverwrittenLeaseId" ON "Leases" ("OverwrittenLeaseId"); diff --git a/src/PostSharp.LicenseServer/CreateTables.sql b/src/SharpCrafters.Backstage.LicenseServer.Web/Database/CreateTables.sql similarity index 95% rename from src/PostSharp.LicenseServer/CreateTables.sql rename to src/SharpCrafters.Backstage.LicenseServer.Web/Database/CreateTables.sql index 07b6325..462e2b5 100644 --- a/src/PostSharp.LicenseServer/CreateTables.sql +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Database/CreateTables.sql @@ -38,6 +38,8 @@ CREATE TABLE [dbo].[Leases]( [UserName] [nvarchar](200) NOT NULL, [Machine] [nvarchar](200) NOT NULL, [AuthenticatedUser] [nvarchar](200) NOT NULL, + -- The server no longer writes this column. It held a signature that nothing verified. The column + -- is kept so that this script creates the same table as the databases of existing installations. [HMAC] [varchar](100) NULL, [Grace] [bit] NOT NULL, CONSTRAINT [PK_Leases] PRIMARY KEY CLUSTERED diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/DatabaseRegistration.cs b/src/SharpCrafters.Backstage.LicenseServer.Web/DatabaseRegistration.cs new file mode 100644 index 0000000..f141d1b --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/DatabaseRegistration.cs @@ -0,0 +1,92 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Locking; +using SharpCrafters.Backstage.LicenseServer.Web.Locking; + +namespace SharpCrafters.Backstage.LicenseServer; + +/// +/// Chooses the database engine the license server runs against. +/// +public static class DatabaseRegistration +{ + public const string ConnectionStringName = "SharpCrafters_LicenseServerConnectionString"; + + /// + /// Registers the database context against the engine named by + /// LicenseServer:DatabaseProvider, together with the lock that serializes the lease + /// requests of that engine. + /// + /// + /// SQL Server and PostgreSQL are the engines supported in production. SQLite is supported so that + /// the server can be evaluated and developed against without a database server, and so that the + /// test suite can run against a database held in memory. + /// + public static IServiceCollection AddLicenseServerDatabase( + this IServiceCollection services, + IConfiguration configuration, + IHostEnvironment environment ) + { + var provider = configuration["LicenseServer:DatabaseProvider"] ?? "SqlServer"; + var connectionString = configuration.GetConnectionString( ConnectionStringName ); + + if ( string.IsNullOrWhiteSpace( connectionString ) ) + { + throw new InvalidOperationException( $"The connection string '{ConnectionStringName}' is not configured." ); + } + + // The lock belongs to the engine and not to the configuration: an administrator cannot select + // it, and there is no implementation that serializes one process only. + switch ( provider.ToLowerInvariant() ) + { + case "sqlserver": + services.AddDbContext( options => options.UseSqlServer( connectionString ) ); + services.AddScoped(); + + break; + + case "postgresql": + services.AddDbContext( options => options.UseNpgsql( connectionString ) ); + services.AddScoped(); + + break; + + case "sqlite": + services.AddDbContext( options => options.UseSqlite( ResolveSqliteFile( connectionString, environment ) ) ); + + services.AddScoped(); + + break; + + default: + throw new InvalidOperationException( $"Unknown database provider '{provider}'. Use 'SqlServer', 'PostgreSql' or 'Sqlite'." ); + } + + return services; + } + + /// + /// Resolves a relative path of a SQLite file against the application directory, and not against + /// the working directory of the process. + /// + private static string ResolveSqliteFile( string connectionString, IHostEnvironment environment ) + { + SqliteConnectionStringBuilder builder = new( connectionString ); + + // A database held in memory names a shared cache and not a file. + if ( builder.Mode == SqliteOpenMode.Memory + || string.IsNullOrEmpty( builder.DataSource ) + || builder.DataSource == ":memory:" + || Path.IsPathRooted( builder.DataSource ) ) + { + return connectionString; + } + + builder.DataSource = Path.Combine( environment.ContentRootPath, builder.DataSource ); + + return builder.ToString(); + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Endpoints/LegacyUrlRedirects.cs b/src/SharpCrafters.Backstage.LicenseServer.Web/Endpoints/LegacyUrlRedirects.cs new file mode 100644 index 0000000..7dce8bc --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Endpoints/LegacyUrlRedirects.cs @@ -0,0 +1,57 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +namespace SharpCrafters.Backstage.LicenseServer.Endpoints; + +/// +/// Redirects the WebForms URLs of the previous versions to the pages that replaced them, so that +/// bookmarks and the links of old notification e-mails continue to work. +/// +public static class LegacyUrlRedirects +{ + private static readonly (string Legacy, string Current)[] redirects = + [ + ( "/Default.aspx", "/" ), + ( "/Graph.aspx", "/Graph" ), + ( "/Admin/AddLicense.aspx", "/Admin/AddLicense" ), + ( "/Admin/Cancel.aspx", "/Admin/Cancel" ), + ( "/Admin/Details.aspx", "/Admin/Details" ), + ( "/Admin/Export.aspx", "/Admin/Export" ), + ( "/Admin/GenerateDemoData.aspx", "/Admin/GenerateDemoData" ) + ]; + + /// + /// Returns the target of the redirection of a legacy URL. + /// + /// + /// The target contains the path base. When the server is installed as an application below the + /// root of an IIS site, a redirection to /Graph would reach the parent site and not this + /// application. The target also contains the query string, which carries the identifier of the + /// license and the window of the graph. + /// + public static string BuildRedirectLocation( PathString pathBase, string target, QueryString queryString ) + { + if ( !pathBase.HasValue ) + { + return target + queryString; + } + + // The home page is the path base itself, and not the path base followed by a slash. + var path = target == "/" ? pathBase.Value! : pathBase.Value + target; + + return path + queryString; + } + + public static void MapLegacyUrlRedirects( this WebApplication app ) + { + foreach ( var (legacy, current) in redirects ) + { + var target = current; + + app.MapGet( + legacy, + ( HttpContext context ) => Results.Redirect( + BuildRedirectLocation( context.Request.PathBase, target, context.Request.QueryString ), + true ) ); + } + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Endpoints/LicenseServerEndpoints.cs b/src/SharpCrafters.Backstage.LicenseServer.Web/Endpoints/LicenseServerEndpoints.cs new file mode 100644 index 0000000..dbd6535 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Endpoints/LicenseServerEndpoints.cs @@ -0,0 +1,275 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Diagnostics; +using System.Globalization; +using System.Xml; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Licensing; +using SharpCrafters.Backstage.LicenseServer.Locking; +using SharpCrafters.Backstage.LicenseServer.Options; +using SharpCrafters.Backstage.LicenseServer.Services; +using SharpCrafters.Common; + +namespace SharpCrafters.Backstage.LicenseServer.Endpoints; + +/// +/// The endpoints the PostSharp client talks to. +/// +/// +/// The .ashx paths are part of the contract with the clients that are already deployed, so +/// the paths are kept although no ASP.NET handler serves them any more. The status codes and the +/// bodies of the responses are part of the same contract. +/// +public static class LicenseServerEndpoints +{ + /// + /// The synchronization point that a lease request reaches while it holds the lease lock. + /// + public const string HoldingLeaseLockSyncPoint = "LicenseServerEndpoints.GetLeaseAsync:HoldingLeaseLock"; + + public static void MapLicenseServerEndpoints( this WebApplication app ) + { + app.MapGet( "/Lease.ashx", GetLeaseAsync ).RequireAuthorization( AuthorizationPolicies.LeaseRequest ); + app.MapGet( "/GetTime.ashx", GetTime ); + app.MapGet( "/Admin/Export.ashx", ExportAsync ).RequireAuthorization( AuthorizationPolicies.Admin ); + } + + private static async Task GetLeaseAsync( + HttpContext context, + LeaseService leaseService, + ILeaseRepository repository, + ILeaseLock leaseLock, + IOptions options, + TimeProvider timeProvider, + ILogger logger, + CancellationToken cancellationToken ) + { + string? productCode = context.Request.Query["product"]; + + // Clients older than PostSharp 5 do not send their version. + string? versionString = context.Request.Query["version"]; + Version version; + + if ( string.IsNullOrEmpty( versionString ) ) + { + version = new Version( 4, 9, 9 ); + } + else if ( !Version.TryParse( versionString, out var parsedVersion ) ) + { + return Error( 400, "Cannot parse the argument: version." ); + } + else + { + version = parsedVersion; + } + + string? buildDateString = context.Request.Query["buildDate"]; + DateTime? buildDate = null; + + if ( !string.IsNullOrEmpty( buildDateString ) ) + { + if ( !DateTime.TryParse( + buildDateString, + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out var parsedBuildDate ) ) + { + return Error( 400, "Cannot parse the argument: buildDate." ); + } + + buildDate = parsedBuildDate; + } + + string? machine = context.Request.Query["machine"]; + + if ( string.IsNullOrEmpty( machine ) ) + { + return Error( 400, "Missing query string argument: machine." ); + } + + machine = machine.ToLowerInvariant(); + + string? userName = context.Request.Query["user"]; + + if ( string.IsNullOrEmpty( userName ) ) + { + return Error( 400, "Missing query string argument: user." ); + } + + userName = userName.ToLowerInvariant(); + + // An anonymous request has no name in ASP.NET Core, where WebForms returned an empty string. + // The column does not accept null. + var authenticatedUserName = context.User.Identity?.Name ?? string.Empty; + + await using var handle = await leaseLock.TryAcquireAsync( + options.Value.MutexTimeoutSpan, + cancellationToken ); + + if ( handle == null ) + { + return Error( 503, "Service overloaded." ); + } + + // A test holds a request here, while it starts a second one, to prove that the second one + // waits for the lock. Two requests that merely run at the same time do not prove it: they + // pass whether the lock works or not. The service is absent in production, and the call then + // costs a null check. See ITestSynchronizationProvider. + var synchronization = + context.RequestServices.GetService(); + + if ( synchronization != null ) + { + await synchronization.SyncPointAsync( HoldingLeaseLockSyncPoint, cancellationToken ); + } + + var startTimestamp = timeProvider.GetTimestamp(); + + Dictionary errors = []; + + var grantedLease = await leaseService.GetLicenseLeaseAsync( + productCode, + version, + buildDate, + machine, + userName, + authenticatedUserName, + timeProvider.GetUtcNow().UtcDateTime, + errors, + cancellationToken ); + + if ( grantedLease == null ) + { + return Error( 403, "No license with free capacity. " + string.Join( " ", errors.Values ) ); + } + + await repository.SaveChangesAsync( cancellationToken ); + + var elapsed = timeProvider.GetElapsedTime( startTimestamp ); + + if ( elapsed > TimeSpan.FromSeconds( 1 ) ) + { + // The user name and the machine name come from the query string. The server removes the + // characters with which they could forge a line in a plain-text log. + logger.LogWarning( + "The lease request for {User} on {Machine} took {Elapsed}.", + Sanitize( userName ), + Sanitize( machine ), + elapsed ); + } + + return Results.Text( + LeaseSerializer.Serialize( + grantedLease.LicenseKey, + grantedLease.StartTime, + grantedLease.EndTime, + grantedLease.RenewTime ), + "text/plain" ); + } + + /// + /// Reports the current instant of the clock of the server, so that a simulator can synchronize + /// its own accelerated clock. + /// + private static IResult GetTime( TimeProvider timeProvider, IOptions options ) + => Results.Text( + XmlConvert.ToString( timeProvider.GetUtcNow().UtcDateTime, XmlDateTimeSerializationMode.RoundtripKind ) + + ";" + + XmlConvert.ToString( options.Value.TimeAcceleration ), + "text/plain" ); + + /// + /// Streams the lease audit log for a range of months. + /// + private static async Task ExportAsync( + HttpContext context, + ILeaseRepository repository, + int? fy, + int? fm, + int? ty, + int? tm, + CancellationToken cancellationToken ) + { + // The same range as the form offers. Without an upper bound, a year such as 10000 passes the + // check and then raises an exception when the date is constructed. + const int firstYear = 2010; + const int lastYear = 2100; + + if ( fy is null or < firstYear or > lastYear + || ty is null or < firstYear or > lastYear + || fm is null or < 1 or > 12 + || tm is null or < 1 or > 12 ) + { + return Results.BadRequest( $"The range of months is missing or invalid. Years must be between {firstYear} and {lastYear}." ); + } + + // The months are read as UTC, which is the time zone of every timestamp of the database. + DateTime fromTime = new( fy.Value, fm.Value, 1, 0, 0, 0, DateTimeKind.Utc ); + var toTime = new DateTime( ty.Value, tm.Value, 1, 0, 0, 0, DateTimeKind.Utc ).AddMonths( 1 ); + + // The range of months is resolved to a range of lease identifiers, and every lease in that + // range is exported. The legacy server selected the rows in the same way, and customers + // compare the files they archived, so the selection is kept. It is the reason why a few + // leases outside the requested months appear in the file. + var bounds = await repository.Leases + .Where( l => l.EndTime >= fromTime && l.StartTime <= toTime ) + .GroupBy( _ => 1 ) + .Select( g => new { MinLeaseId = g.Min( l => l.LeaseId ), MaxLeaseId = g.Max( l => l.LeaseId ) } ) + .SingleOrDefaultAsync( cancellationToken ); + + var fileName = + $"PostSharp_LicenseLog_{fy.Value}-{fm.Value}_{ty.Value}-{tm.Value}.txt"; + + context.Response.Headers.ContentDisposition = $"attachment; filename={fileName}"; + + if ( bounds == null ) + { + return Results.Text( string.Empty, "text/plain" ); + } + + var minLeaseId = bounds.MinLeaseId; + var maxLeaseId = bounds.MaxLeaseId; + + // The rows are written to the response as they arrive. An audit log that covers years of + // activity is too large to assemble in memory, and the download starts before the server has + // read the last row. + return Results.Stream( + async stream => + { + await using StreamWriter writer = new( stream ); + + var leases = repository.Leases + .Where( l => l.LeaseId >= minLeaseId && l.LeaseId <= maxLeaseId ) + .OrderBy( l => l.LeaseId ) + .AsNoTracking() + .AsAsyncEnumerable(); + + await foreach ( var lease in leases.WithCancellation( cancellationToken ) ) + { + // The line is built in memory and written asynchronously. Writing the fields + // directly to the writer makes the writer flush synchronously when its buffer is + // full, and Kestrel refuses a synchronous write to a response body. One line is + // a hundred bytes. The constraint applies to the whole log, not to one line. + await writer.WriteLineAsync( lease.ToAuditLine() ); + } + }, + "text/plain" ); + } + + private static IResult Error( int statusCode, string description ) => Results.Text( description, "text/plain", statusCode: statusCode ); + + /// + /// Removes the control characters from a value of the request, so that the value cannot forge a + /// line break in a log, and limits its length. + /// + private static string Sanitize( string value ) + { + const int maximumLength = 200; + + string cleaned = new( value.Where( c => !char.IsControl( c ) ).ToArray() ); + + return cleaned.Length <= maximumLength ? cleaned : cleaned[..maximumLength]; + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Endpoints/OperationsEndpoints.cs b/src/SharpCrafters.Backstage.LicenseServer.Web/Endpoints/OperationsEndpoints.cs new file mode 100644 index 0000000..a5cd205 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Endpoints/OperationsEndpoints.cs @@ -0,0 +1,99 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Reflection; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using SharpCrafters.Backstage.LicenseServer.Licensing; + +namespace SharpCrafters.Backstage.LicenseServer.Endpoints; + +/// +/// The endpoints of a monitoring system. They report the state of the server and the version that is +/// deployed. +/// +/// +/// These endpoints require no authentication, as Lease.ashx does not, and unlike the +/// administrative pages. A monitoring agent has no Windows credentials, so an endpoint behind +/// authentication would answer 401, and the probe would report the server as unavailable. The +/// responses contain no license key, no user name and no connection string. The version number is +/// readable by anyone who can reach the server. +/// +public static class OperationsEndpoints +{ + /// + /// The path of the monitoring probe. It reports the state of the server and of the resources the + /// server needs. + /// + public const string HealthPath = "/health"; + + /// + /// The path of the liveness probe. It reports whether the process answers. + /// + public const string LivenessPath = "/health/live"; + + public const string VersionPath = "/version"; + + public static void MapOperationsEndpoints( this WebApplication app ) + { + app.MapHealthChecks( HealthPath, new HealthCheckOptions { ResponseWriter = WriteHealthAsync } ); + + // No check runs here. The response reports the state of the process, which is the state a + // container orchestrator restarts on. Restarting the server repairs neither a database that + // is down nor a license that has expired. + app.MapHealthChecks( + LivenessPath, + new HealthCheckOptions { Predicate = _ => false, ResponseWriter = WriteHealthAsync } ); + + app.MapGet( VersionPath, GetVersion ); + } + + /// + /// Writes the report as JSON. The default writer answers with the single word Healthy, + /// which does not say which check failed. + /// + private static Task WriteHealthAsync( HttpContext context, HealthReport report ) + => context.Response.WriteAsJsonAsync( + new + { + status = report.Status.ToString(), + checks = report.Entries + .Select( entry => new { name = entry.Key, status = entry.Value.Status.ToString(), description = entry.Value.Description } ) + .ToArray() + } ); + + /// + /// Reports the build that is deployed, and the version of the licensing library that parses the + /// license keys. The version of the library says whether the server accepts a license key that + /// requires a recent library. + /// + private static IResult GetVersion( ILicenseServerVersion version ) + => Results.Json( new { product = ProductName, version = ProductVersion, licensingLibrary = version.LicensingLibraryVersion.ToString() } ); + + private static string ProductName { get; } = + typeof(OperationsEndpoints).Assembly.GetName().Name ?? "SharpCrafters.Backstage.LicenseServer"; + + /// + /// The informational version of this assembly, without the commit hash that the build appends to + /// it after a +. + /// + private static string ProductVersion { get; } = ReadProductVersion(); + + private static string ReadProductVersion() + { + var assembly = typeof(OperationsEndpoints).Assembly; + + var informationalVersion = assembly + .GetCustomAttribute() + ? + .InformationalVersion; + + if ( string.IsNullOrEmpty( informationalVersion ) ) + { + return assembly.GetName().Version?.ToString() ?? "unknown"; + } + + var metadata = informationalVersion.IndexOf( '+', StringComparison.Ordinal ); + + return metadata < 0 ? informationalVersion : informationalVersion[..metadata]; + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Health/DatabaseHealthCheck.cs b/src/SharpCrafters.Backstage.LicenseServer.Web/Health/DatabaseHealthCheck.cs new file mode 100644 index 0000000..e6b60df --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Health/DatabaseHealthCheck.cs @@ -0,0 +1,45 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using SharpCrafters.Backstage.LicenseServer.Data; + +namespace SharpCrafters.Backstage.LicenseServer.Health; + +/// +/// Reports whether the database answers a query. +/// +/// +/// The check reads the license table instead of opening a connection. The server never creates its +/// own schema on SQL Server, so a database that accepts connections but has no schema is a +/// deployment error. A query detects it; opening a connection does not. +/// +public sealed class DatabaseHealthCheck : IHealthCheck +{ + private readonly LicenseServerDbContext db; + private readonly IHostEnvironment environment; + + public DatabaseHealthCheck( LicenseServerDbContext db, IHostEnvironment environment ) + { + this.db = db; + this.environment = environment; + } + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default ) + { + try + { + await this.db.Licenses.AsNoTracking().Select( l => l.LicenseId ).FirstOrDefaultAsync( cancellationToken ); + + return HealthCheckResult.Healthy( "The database answers." ); + } + catch ( Exception e ) when ( e is not OperationCanceledException ) + { + return HealthCheckResult.Unhealthy( + HealthDescriptions.ForException( "The database cannot be queried.", e, this.environment ), + e ); + } + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Health/HealthDescriptions.cs b/src/SharpCrafters.Backstage.LicenseServer.Web/Health/HealthDescriptions.cs new file mode 100644 index 0000000..8d6ba8c --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Health/HealthDescriptions.cs @@ -0,0 +1,19 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +namespace SharpCrafters.Backstage.LicenseServer.Health; + +/// +/// Describes the failure of a health check without disclosing why it failed. +/// +/// +/// The message of a database exception contains the name of the server, and sometimes the whole +/// connection string. The health endpoint requires no authentication, so the server writes the +/// reason to its log. Only a development server writes the reason in the response. +/// +internal static class HealthDescriptions +{ + public static string ForException( string summary, Exception exception, IHostEnvironment environment ) + => environment.IsDevelopment() + ? $"{summary} {exception.Message}" + : $"{summary} The reason is in the log of the server."; +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Health/LicenseHealthCheck.cs b/src/SharpCrafters.Backstage.LicenseServer.Web/Health/LicenseHealthCheck.cs new file mode 100644 index 0000000..c79f793 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Health/LicenseHealthCheck.cs @@ -0,0 +1,75 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.Extensions.Diagnostics.HealthChecks; +using SharpCrafters.Backstage.LicenseServer.Services; + +namespace SharpCrafters.Backstage.LicenseServer.Health; + +/// +/// Reports whether any license can serve a lease now. +/// +/// +/// +/// A server whose licenses are all expired, or all full with their grace period ended, answers every +/// lease request with 403 while its process and its database work. This check reports that state. +/// +/// +/// It warns and never fails. The result is , so the endpoint +/// answers 200, and the body of the response and the log of the server name the problem. An expired +/// license requires an action from an administrator, and restarting the server does not add a +/// license. Only the process and the database can make the probe fail. +/// +/// +public sealed class LicenseHealthCheck : IHealthCheck +{ + private readonly LicenseAvailabilityService availability; + private readonly TimeProvider timeProvider; + private readonly IHostEnvironment environment; + + public LicenseHealthCheck( + LicenseAvailabilityService availability, + TimeProvider timeProvider, + IHostEnvironment environment ) + { + this.availability = availability; + this.timeProvider = timeProvider; + this.environment = environment; + } + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default ) + { + LicenseAvailability result; + + try + { + result = await this.availability.GetAvailabilityAsync( this.timeProvider.GetUtcNow().UtcDateTime, cancellationToken ); + } + catch ( Exception e ) when ( e is not OperationCanceledException ) + { + // Reading the license state reads the database, so this check fails whenever the database + // check fails, and the database check is the one that fails the probe. The exception is + // caught here as well, because the health check middleware uses the message of an + // uncaught exception as the description of the failure, and an anonymous endpoint must + // not return the message of a database exception. + return HealthCheckResult.Degraded( + HealthDescriptions.ForException( "The license state cannot be read.", e, this.environment ), + e ); + } + + IReadOnlyDictionary data = new Dictionary + { + ["available"] = result.Available, + ["exhausted"] = result.Exhausted, + ["expired"] = result.Expired, + ["invalid"] = result.Invalid, + ["disabled"] = result.Disabled, + ["total"] = result.Total + }; + + return result.CanServeLease + ? HealthCheckResult.Healthy( result.Describe(), data ) + : HealthCheckResult.Degraded( result.Describe(), data: data ); + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/LicensingRegistration.cs b/src/SharpCrafters.Backstage.LicenseServer.Web/LicensingRegistration.cs new file mode 100644 index 0000000..656f12c --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/LicensingRegistration.cs @@ -0,0 +1,109 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.Extensions.Logging.Abstractions; +using SharpCrafters.Backstage.LicenseServer.Licensing; +using SharpCrafters.Backstage.LicenseServer.Options; +using SharpCrafters.Backstage.Licensing.Licenses; + +namespace SharpCrafters.Backstage.LicenseServer; + +/// +/// Registers the component that parses and validates license keys, and decides which licensing +/// authorities it accepts them from. +/// +public static class LicensingRegistration +{ + /// + /// Registers the license key parser, and the server's own licensing authority when it issues + /// itself test license keys. + /// + /// + /// The identifiers of the test licensing authorities that were added to the production + /// authority, so that the caller can write them to the log. The list is empty on a server + /// configured as a customer runs it. + /// + /// + /// A test licensing authority is configured, or test license keys are asked for, outside the + /// Development environment. + /// + public static IReadOnlyList AddLicenseServerLicensing( + this IServiceCollection services, + IConfiguration configuration, + IHostEnvironment environment ) + { + var section = configuration.GetSection( LicenseServerOptions.SectionName ); + + var testAuthorities = + section.GetSection( "TestLicensingAuthorities" ).Get() ?? []; + + var seedTestLicenses = section.GetValue( "SeedTestLicenses", false ); + + // The server refuses to start instead of ignoring the setting or applying it. Ignoring it + // would let an administrator believe that the server accepts those license keys. Applying it + // would let whoever holds the private key create licenses that this server serves. + if ( !environment.IsDevelopment() ) + { + Refuse( testAuthorities.Length > 0, "TestLicensingAuthorities is set" ); + Refuse( seedTestLicenses, "SeedTestLicenses is on" ); + } + + ILicensingAuthorityProvider authorities = new ProductionLicensingAuthorityProvider(); + List testKeyIds = []; + + if ( testAuthorities.Length > 0 ) + { + var explicitAuthorities = new ExplicitLicensingAuthorityProvider( testAuthorities.Select( a => ( (int) a.KeyId, a.PublicKey ) ).ToArray() ); + + // A key is parsed at its first use, which would be during a lease request. Requesting + // each authority here turns a malformed key into a failure at startup. + foreach ( var authority in testAuthorities ) + { + explicitAuthorities.GetAuthority( authority.KeyId ); + } + + authorities = new CompositeLicensingAuthorityProvider( authorities, explicitAuthorities ); + testKeyIds.AddRange( testAuthorities.Select( a => a.KeyId ) ); + } + + if ( seedTestLicenses ) + { + var ownAuthority = TestLicenseAuthority.LoadOrCreate( + Path.Combine( ResolveDataDirectory( section, environment ), "test-authority.key" ), + NullLogger.Instance ); + + services.AddSingleton( ownAuthority ); + + authorities = new CompositeLicensingAuthorityProvider( authorities, ownAuthority.Authority ); + testKeyIds.Add( TestLicenseAuthority.KeyId ); + } + + services.AddSingleton( _ => new CachingLicenseParser( new BackstageLicenseParser( authorities ) ) ); + + return testKeyIds; + + void Refuse( bool condition, string what ) + { + if ( condition ) + { + throw new InvalidOperationException( + $"{LicenseServerOptions.SectionName}:{what}, but the environment is " + + $"'{environment.EnvironmentName}' and not 'Development'. A server that is not a development " + + "server serves license keys of the production licensing authority only. Remove the setting." ); + } + } + } + + /// + /// Gets the directory that contains the files the server generates. A relative path is resolved + /// against the application directory, so that the default value works wherever the release + /// package is unpacked. + /// + public static string ResolveDataDirectory( IConfigurationSection section, IHostEnvironment environment ) + { + var configured = section.GetValue( "DataDirectory", "App_Data" ) ?? "App_Data"; + + return Path.IsPathRooted( configured ) + ? configured + : Path.Combine( environment.ContentRootPath, configured ); + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Locking/PostgreSqlLeaseLock.cs b/src/SharpCrafters.Backstage.LicenseServer.Web/Locking/PostgreSqlLeaseLock.cs new file mode 100644 index 0000000..695d390 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Locking/PostgreSqlLeaseLock.cs @@ -0,0 +1,152 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Data.Common; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Locking; + +namespace SharpCrafters.Backstage.LicenseServer.Web.Locking; + +/// +/// Serializes the lease requests with an advisory lock of PostgreSQL, so that the lock covers every +/// process connected to the database. +/// +/// +/// +/// The lock belongs to the session, which means to the connection. The connection is therefore opened +/// here and stays open until the handle is disposed. Without that, Entity Framework would close the +/// connection after the first query and the lock would be released in the middle of the request. The +/// server would keep answering, and it would grant more leases than the capacity of the license. +/// +/// +/// The wait is bounded by lock_timeout. When it elapses, PostgreSQL cancels the statement with +/// the code 55P03, and the caller answers with the status 503, as it does on SQL Server. +/// +/// +public sealed class PostgreSqlLeaseLock : ILeaseLock +{ + private readonly LicenseServerDbContext db; + + /// + /// The name of the locked resource, which is the name the SQL Server lock uses. The two engines + /// never share a database, so the two locks never meet. + /// + private const string resourceName = "SharpCrafters.Backstage.LicenseServer.Lease"; + + /// + /// The code PostgreSQL reports when lock_timeout elapses, which is lock_not_available. + /// + private const string lockNotAvailable = "55P03"; + + /// + /// The key of the advisory lock. PostgreSQL identifies an advisory lock by a number and not by a + /// name, so the number is derived from . An advisory lock is scoped to + /// the database, so every process that serves this database asks for this key, and nothing else + /// does. + /// + public static readonly long ResourceKey = + BitConverter.ToInt64( SHA256.HashData( Encoding.UTF8.GetBytes( resourceName ) ) ); + + public PostgreSqlLeaseLock( LicenseServerDbContext db ) + { + this.db = db; + } + + public async ValueTask TryAcquireAsync( + TimeSpan timeout, + CancellationToken cancellationToken = default ) + { + var database = this.db.Database; + + await database.OpenConnectionAsync( cancellationToken ); + + try + { + // SET takes no parameter, so the value is written into the statement. It is a number + // computed here and never a value that a request carries. + var milliseconds = Math.Max( 0, (int) timeout.TotalMilliseconds ); + + await ExecuteAsync( + database, + $"SET lock_timeout = {milliseconds.ToString( CultureInfo.InvariantCulture )}", + cancellationToken ); + + try + { + await ExecuteAsync( database, $"SELECT pg_advisory_lock({ResourceKey})", cancellationToken ); + } + catch ( DbException e ) when ( e.SqlState == lockNotAvailable ) + { + await ResetTimeoutAsync( database ); + await database.CloseConnectionAsync(); + + return null; + } + + return new Handle( database ); + } + catch + { + await database.CloseConnectionAsync(); + + throw; + } + } + + private static async Task ExecuteAsync( + DatabaseFacade database, + string sql, + CancellationToken cancellationToken ) + { + await using var command = database.GetDbConnection().CreateCommand(); + command.CommandText = sql; + + await command.ExecuteNonQueryAsync( cancellationToken ); + } + + /// + /// Restores the timeout of the session. The connection returns to the pool of the client, which + /// hands it to another request. + /// + private static async Task ResetTimeoutAsync( DatabaseFacade database ) + => await ExecuteAsync( database, "SET lock_timeout = DEFAULT", CancellationToken.None ); + + private sealed class Handle : IAsyncDisposable + { + private readonly DatabaseFacade database; + private int released; + + public Handle( DatabaseFacade database ) + { + this.database = database; + } + + public async ValueTask DisposeAsync() + { + if ( Interlocked.Exchange( ref this.released, 1 ) != 0 ) + { + return; + } + + try + { + await ExecuteAsync( + this.database, + $"SELECT pg_advisory_unlock({ResourceKey})", + CancellationToken.None ); + + await ResetTimeoutAsync( this.database ); + } + finally + { + // Closing the connection releases the lock as well, so the lock is never held by a + // connection that returns to the pool. + await this.database.CloseConnectionAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Locking/SqlServerLeaseLock.cs b/src/SharpCrafters.Backstage.LicenseServer.Web/Locking/SqlServerLeaseLock.cs new file mode 100644 index 0000000..757b982 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Locking/SqlServerLeaseLock.cs @@ -0,0 +1,164 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Data; +using System.Data.Common; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Locking; + +namespace SharpCrafters.Backstage.LicenseServer.Web.Locking; + +/// +/// Serializes the lease requests with an application lock of SQL Server, so that the lock covers +/// every process connected to the database. +/// +/// +/// +/// The lock belongs to the session, which means to the connection. The connection is therefore opened +/// here and stays open until the handle is disposed. Without that, Entity Framework would close the +/// connection after the first query and the lock would be released in the middle of the request. The +/// server would keep answering, and it would grant more leases than the capacity of the license. +/// +/// +/// Every process that serves the same database asks for the same resource name, so the lock +/// serializes the requests of a web garden and of several instances as well. +/// +/// +public sealed class SqlServerLeaseLock : ILeaseLock +{ + private readonly LicenseServerDbContext db; + + /// + /// The name of the locked resource. SQL Server scopes an application lock to the database, so + /// this name is shared by every process that serves this database, and by nothing else. + /// + public const string ResourceName = "SharpCrafters.Backstage.LicenseServer.Lease"; + + public SqlServerLeaseLock( LicenseServerDbContext db ) + { + this.db = db; + } + + public async ValueTask TryAcquireAsync( + TimeSpan timeout, + CancellationToken cancellationToken = default ) + { + var database = this.db.Database; + + await database.OpenConnectionAsync( cancellationToken ); + + try + { + var result = await ExecuteAsync( + database, + "sp_getapplock", + command => + { + AddParameter( command, "@Resource", ResourceName ); + AddParameter( command, "@LockMode", "Exclusive" ); + AddParameter( command, "@LockOwner", "Session" ); + AddParameter( command, "@LockTimeout", (int) timeout.TotalMilliseconds ); + }, + cancellationToken ); + + // 0 and 1 mean granted. -1 means that the timeout elapsed, which the caller answers with + // the status 503. Anything else is a fault of the server or of the arguments. + if ( result == -1 ) + { + await database.CloseConnectionAsync(); + + return null; + } + + if ( result < 0 ) + { + await database.CloseConnectionAsync(); + + throw new InvalidOperationException( $"sp_getapplock returned {result} for the resource '{ResourceName}'." ); + } + + return new Handle( database ); + } + catch + { + await database.CloseConnectionAsync(); + + throw; + } + } + + private static void AddParameter( DbCommand command, string name, object value ) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value; + command.Parameters.Add( parameter ); + } + + /// + /// Runs one of the two stored procedures and returns its return value, which is how both report + /// their result. + /// + private static async Task ExecuteAsync( + DatabaseFacade database, + string procedure, + Action addParameters, + CancellationToken cancellationToken ) + { + await using var command = database.GetDbConnection().CreateCommand(); + + command.CommandText = procedure; + command.CommandType = CommandType.StoredProcedure; + + addParameters( command ); + + var returnValue = command.CreateParameter(); + returnValue.ParameterName = "@Result"; + returnValue.DbType = DbType.Int32; + returnValue.Direction = ParameterDirection.ReturnValue; + command.Parameters.Add( returnValue ); + + await command.ExecuteNonQueryAsync( cancellationToken ); + + return (int) returnValue.Value!; + } + + private sealed class Handle : IAsyncDisposable + { + private readonly DatabaseFacade database; + private int released; + + public Handle( DatabaseFacade database ) + { + this.database = database; + } + + public async ValueTask DisposeAsync() + { + if ( Interlocked.Exchange( ref this.released, 1 ) != 0 ) + { + return; + } + + try + { + await ExecuteAsync( + this.database, + "sp_releaseapplock", + command => + { + AddParameter( command, "@Resource", ResourceName ); + AddParameter( command, "@LockOwner", "Session" ); + }, + CancellationToken.None ); + } + finally + { + // Closing the connection releases the lock as well, so the lock is never held by a + // connection that returns to the pool. + await this.database.CloseConnectionAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Locking/SqliteLeaseLock.cs b/src/SharpCrafters.Backstage.LicenseServer.Web/Locking/SqliteLeaseLock.cs new file mode 100644 index 0000000..9e509c3 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Locking/SqliteLeaseLock.cs @@ -0,0 +1,137 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Data; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Locking; + +namespace SharpCrafters.Backstage.LicenseServer.Web.Locking; + +/// +/// Serializes the lease requests with a write transaction of SQLite. +/// +/// +/// +/// SQLite has no application lock. A transaction opened as BEGIN IMMEDIATE takes the write +/// lock of the database at once, and SQLite allows one writer at a time, so a second request waits. +/// The transaction covers the whole request, and Entity Framework runs its own statements inside it, +/// so the reads that decide the allocation and the insert that records it are one unit. +/// +/// +/// The wait is bounded by the timeout of the caller. When it elapses, SQLite reports that the +/// database is locked, and the caller answers with the status 503, as it does on SQL Server. +/// +/// +public sealed class SqliteLeaseLock : ILeaseLock +{ + private readonly LicenseServerDbContext db; + + public SqliteLeaseLock( LicenseServerDbContext db ) + { + this.db = db; + } + + public async ValueTask TryAcquireAsync( + TimeSpan timeout, + CancellationToken cancellationToken = default ) + { + var database = this.db.Database; + + await database.OpenConnectionAsync( cancellationToken ); + + try + { + var connection = (SqliteConnection) database.GetDbConnection(); + + // Microsoft.Data.Sqlite retries a statement that finds the database locked, and it stops + // after DefaultTimeout, which is thirty seconds. The PRAGMA busy_timeout of SQLite does + // not change that, because the provider passes its own value at every command. The + // previous value is restored with the connection, which the pool hands to another + // request. + var previousTimeout = connection.DefaultTimeout; + connection.DefaultTimeout = Math.Max( 1, (int) timeout.TotalSeconds ); + + SqliteTransaction transaction; + + try + { + // deferred: false is BEGIN IMMEDIATE, which takes the write lock now instead of at the + // first write. Without it, two requests would both read, and one would fail at its + // insert instead of waiting for its turn. + // + // Microsoft.Data.Sqlite offers no asynchronous form of this call, and the wait for the + // write lock therefore blocks this thread. SQLite serves the development loop and an + // evaluation, where one user sends one request at a time. + transaction = connection.BeginTransaction( IsolationLevel.Serializable, deferred: false ); + } + catch ( SqliteException e ) when ( e.SqliteErrorCode is SqliteBusy or SqliteLocked ) + { + connection.DefaultTimeout = previousTimeout; + await database.CloseConnectionAsync(); + + return null; + } + + // The context runs its own statements inside this transaction, so the reads that decide + // the allocation and the insert that records it are one unit. + var contextTransaction = + await database.UseTransactionAsync( transaction, cancellationToken ); + + return new Handle( database, contextTransaction!, connection, previousTimeout ); + } + catch + { + await database.CloseConnectionAsync(); + + throw; + } + } + + private const int SqliteBusy = 5; + private const int SqliteLocked = 6; + + private sealed class Handle : IAsyncDisposable + { + private readonly DatabaseFacade database; + private readonly IDbContextTransaction transaction; + private readonly SqliteConnection connection; + private readonly int previousTimeout; + private int released; + + public Handle( + DatabaseFacade database, + IDbContextTransaction transaction, + SqliteConnection connection, + int previousTimeout ) + { + this.database = database; + this.transaction = transaction; + this.connection = connection; + this.previousTimeout = previousTimeout; + } + + public async ValueTask DisposeAsync() + { + if ( Interlocked.Exchange( ref this.released, 1 ) != 0 ) + { + return; + } + + try + { + // The lease was saved inside this this.transaction, so the commit is what makes it + // visible, and it releases the write lock. + await this.transaction.CommitAsync(); + } + finally + { + await this.transaction.DisposeAsync(); + this.connection.DefaultTimeout = this.previousTimeout; + await this.database.CloseConnectionAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/AddLicense.cshtml b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/AddLicense.cshtml new file mode 100644 index 0000000..644347f --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/AddLicense.cshtml @@ -0,0 +1,28 @@ +@page +@model AddLicenseModel +@{ + ViewData["Title"] = "Add a license"; +} + + + +

Paste the license key sent to you by PostSharp Technologies.

+ +
+
+ +

+
+ + +

+ +

+ +

+
diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/AddLicense.cshtml.cs b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/AddLicense.cshtml.cs new file mode 100644 index 0000000..4b2b6ed --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/AddLicense.cshtml.cs @@ -0,0 +1,87 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Licensing; + +namespace SharpCrafters.Backstage.LicenseServer.Pages.Admin; + +/// +/// Registers a license key so that the server can serve leases against it. +/// +public sealed class AddLicenseModel : PageModel +{ + private readonly ILeaseRepository repository; + private readonly LicenseServerDbContext db; + private readonly ILicenseParser licenseParser; + private readonly TimeProvider timeProvider; + + public AddLicenseModel( + ILeaseRepository repository, + LicenseServerDbContext db, + ILicenseParser licenseParser, + TimeProvider timeProvider ) + { + this.repository = repository; + this.db = db; + this.licenseParser = licenseParser; + this.timeProvider = timeProvider; + } + + [BindProperty] + [Required( ErrorMessage = "Paste the license key." )] + [Display( Name = "License key" )] + public string LicenseKey { get; set; } = ""; + + public void OnGet() { } + + public async Task OnPostAsync( CancellationToken cancellationToken ) + { + if ( !this.ModelState.IsValid ) + { + return this.Page(); + } + + var parsedLicense = this.licenseParser.TryParse( this.LicenseKey ); + + if ( parsedLicense == null ) + { + this.ModelState.AddModelError( nameof(this.LicenseKey), "Invalid license key." ); + + return this.Page(); + } + + if ( !parsedLicense.IsLicenseServerEligible ) + { + this.ModelState.AddModelError( + nameof(this.LicenseKey), + $"Cannot add a {parsedLicense.LicenseTypeName ?? "(unknown license type)"} of " + + $"{parsedLicense.ProductName} to the server." ); + + return this.Page(); + } + + if ( await this.repository.Licenses.AnyAsync( l => l.LicenseId == parsedLicense.LicenseId, cancellationToken ) ) + { + this.ModelState.AddModelError( nameof(this.LicenseKey), "The given license has been added already." ); + + return this.Page(); + } + + this.db.Licenses.Add( + new License + { + LicenseId = parsedLicense.LicenseId, + LicenseKey = this.licenseParser.CleanLicenseString( this.LicenseKey ), + CreatedOn = this.timeProvider.GetUtcNow().UtcDateTime, + ProductCode = parsedLicense.Product + } ); + + await this.db.SaveChangesAsync( cancellationToken ); + + return this.RedirectToPage( "/Index" ); + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/Cancel.cshtml b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/Cancel.cshtml new file mode 100644 index 0000000..db3dc25 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/Cancel.cshtml @@ -0,0 +1,33 @@ +@page +@model CancelModel +@{ + ViewData["Title"] = "Cancel a lease"; +} + + + +

+ Cancel the lease of @Model.Lease!.UserName on + @Model.Lease.Machine, which currently runs until + @Model.Lease.EndTime.ToString( "g" )? +

+ +

+ The seat is released on this server immediately, and the lease is kept in the audit log. +

+ +

+ The machine of the user keeps the lease it already holds until that lease expires, because a + client is never told that a lease was cancelled. Cancelling frees the seat for somebody else; it + does not stop the product on that machine. +

+ +
+ + Keep it +
diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/Cancel.cshtml.cs b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/Cancel.cshtml.cs new file mode 100644 index 0000000..a998c57 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/Cancel.cshtml.cs @@ -0,0 +1,59 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using SharpCrafters.Backstage.LicenseServer.Data; + +namespace SharpCrafters.Backstage.LicenseServer.Pages.Admin; + +/// +/// Ends a lease before its end time, so that another user can take the seat. +/// +public sealed class CancelModel : PageModel +{ + private readonly ILeaseRepository repository; + private readonly TimeProvider timeProvider; + + public CancelModel( ILeaseRepository repository, TimeProvider timeProvider ) + { + this.repository = repository; + this.timeProvider = timeProvider; + } + + [BindProperty( SupportsGet = true )] + public int Id { get; set; } + + public Lease? Lease { get; private set; } + + public async Task OnGetAsync( CancellationToken cancellationToken ) + { + this.Lease = await this.repository.OpenLeases + .Include( l => l.License ) + .AsNoTracking() + .SingleOrDefaultAsync( l => l.LeaseId == this.Id, cancellationToken ); + + return this.Lease == null ? this.NotFound() : this.Page(); + } + + public async Task OnPostAsync( CancellationToken cancellationToken ) + { + var lease = await this.repository.OpenLeases + .Include( l => l.License ) + .SingleOrDefaultAsync( l => l.LeaseId == this.Id, cancellationToken ); + + if ( lease == null ) + { + return this.NotFound(); + } + + this.repository.CancelLease( + lease, + this.User.Identity?.Name ?? string.Empty, + this.timeProvider.GetUtcNow().UtcDateTime ); + + await this.repository.SaveChangesAsync( cancellationToken ); + + return this.RedirectToPage( "/Admin/Details", new { id = lease.LicenseId } ); + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/Details.cshtml b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/Details.cshtml new file mode 100644 index 0000000..c5fb9da --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/Details.cshtml @@ -0,0 +1,129 @@ +@page +@model DetailsModel +@{ + ViewData["Title"] = $"License {Model.Id}"; +} + + + +@if (Model.IsDisabled) +{ +
+

+ This license is disabled: it serves no new lease. The leases it has already granted run + until they expire. +

+
+} + +

+ @Model.Leases.Count current lease(s), consuming + @Model.Seats seat(s). + See the usage history. +

+ +

+ A seat is one user working on up to @Model.MachinesPerSeatText. A user working on more machines + takes more than one seat: the number of machines divided by @Model.MachinesPerSeat, rounded up. + The capacity of a license is a number of seats. +

+ +@if (Model.Leases.Count == 0) +{ +

No lease is currently held against this license.

+} +else +{ +
+ + + + + + + + + + + + + + @foreach (var lease in Model.Leases) + { + + + + + + + + + + } + +
LeaseUserMachineStartEndGrace
@lease.LeaseId@lease.UserName@lease.Machine@lease.StartTime.ToString( "g" )@lease.EndTime.ToString( "g" )@( lease.Grace ? "Yes" : "" ) + Cancel +
+
+} + +@* The script fills this dialog from the action that the administrator selected. Without a script, + the dialog is never displayed and the action runs at the first click, which is how the page + behaved before this dialog existed. *@ + +
+

+

+ +
+ + +
+
+
+ +@section Scripts { + +} diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/Details.cshtml.cs b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/Details.cshtml.cs new file mode 100644 index 0000000..d5cbf5b --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/Details.cshtml.cs @@ -0,0 +1,126 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Options; + +namespace SharpCrafters.Backstage.LicenseServer.Pages.Admin; + +/// +/// The leases currently held against one license, and the actions an administrator can take on it. +/// +public sealed class DetailsModel : PageModel +{ + private readonly ILeaseRepository repository; + private readonly LicenseServerDbContext db; + private readonly IOptions options; + private readonly TimeProvider timeProvider; + + public DetailsModel( + ILeaseRepository repository, + LicenseServerDbContext db, + IOptions options, + TimeProvider timeProvider ) + { + this.repository = repository; + this.db = db; + this.options = options; + this.timeProvider = timeProvider; + } + + [BindProperty( SupportsGet = true )] + public int Id { get; set; } + + public IReadOnlyList Leases { get; private set; } = []; + + public int Seats { get; private set; } + + /// + /// Gets the number of machines that one seat covers, so that the page states the rule with the + /// value configured on this server and not with the default value. + /// + public int MachinesPerSeat => this.options.Value.MachinesPerUser; + + /// + /// Gets with its noun, so that a server configured with one + /// machine per seat displays "1 machine" and not "1 machines". + /// + public string MachinesPerSeatText => this.MachinesPerSeat == 1 ? "1 machine" : $"{this.MachinesPerSeat} machines"; + + public bool IsDisabled { get; private set; } + + public async Task OnGetAsync( CancellationToken cancellationToken ) + { + var license = await this.repository.Licenses + .AsNoTracking() + .SingleOrDefaultAsync( l => l.LicenseId == this.Id, cancellationToken ); + + if ( license == null ) + { + return this.NotFound(); + } + + var now = this.timeProvider.GetUtcNow().UtcDateTime; + + this.Leases = await this.repository.OpenLeases + .Where( l => l.LicenseId == this.Id && l.StartTime <= now && l.EndTime >= now ) + .OrderBy( l => l.StartTime ) + .AsNoTracking() + .ToListAsync( cancellationToken ); + + this.Seats = this.repository.GetActiveSeats( this.Id, now ); + this.IsDisabled = license.Priority < 0; + + return this.Page(); + } + + public Task OnPostEnableAsync( CancellationToken cancellationToken ) => this.SetPriorityAsync( 0, cancellationToken ); + + public Task OnPostDisableAsync( CancellationToken cancellationToken ) => this.SetPriorityAsync( -1, cancellationToken ); + + private async Task SetPriorityAsync( int priority, CancellationToken cancellationToken ) + { + var license = await this.db.Licenses.SingleOrDefaultAsync( l => l.LicenseId == this.Id, cancellationToken ); + + if ( license == null ) + { + return this.NotFound(); + } + + license.Priority = priority; + await this.db.SaveChangesAsync( cancellationToken ); + + return this.RedirectToPage( "/Index" ); + } + + public async Task OnPostDeleteAsync( CancellationToken cancellationToken ) + { + if ( !await this.db.Licenses.AnyAsync( l => l.LicenseId == this.Id, cancellationToken ) ) + { + return this.NotFound(); + } + + await using var transaction = await this.db.Database.BeginTransactionAsync( cancellationToken ); + + // The leases reference each other through the chain of replacements, so they are deleted + // before the license, and the most recent ones before the ones they replaced. + var leases = await this.db.Leases + .Where( l => l.LicenseId == this.Id ) + .OrderByDescending( l => l.LeaseId ) + .ToListAsync( cancellationToken ); + + foreach ( var lease in leases ) + { + this.db.Leases.Remove( lease ); + await this.db.SaveChangesAsync( cancellationToken ); + } + + await this.db.Licenses.Where( l => l.LicenseId == this.Id ).ExecuteDeleteAsync( cancellationToken ); + await transaction.CommitAsync( cancellationToken ); + + return this.RedirectToPage( "/Index" ); + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/Export.cshtml b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/Export.cshtml new file mode 100644 index 0000000..0c5eda6 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/Export.cshtml @@ -0,0 +1,40 @@ +@page +@model ExportModel +@{ + ViewData["Title"] = "Export the audit log"; +} + + + +

+ The export is a text file with one line per lease, with the user name and the machine name as + they were recorded. The file contains personal data. It is meant for the administrator of this + server, who uses it to understand how the licenses are used. +

+ +
+
+ +

+ + + + +

+ +

+ + + + +

+ +

+ +

+
diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/Export.cshtml.cs b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/Export.cshtml.cs new file mode 100644 index 0000000..fe2ef18 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/Export.cshtml.cs @@ -0,0 +1,77 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.RazorPages; +using System.Globalization; + +namespace SharpCrafters.Backstage.LicenseServer.Pages.Admin; + +/// +/// Chooses the range of months to export from the lease audit log. +/// +public sealed class ExportModel : PageModel +{ + private readonly TimeProvider timeProvider; + + public ExportModel( TimeProvider timeProvider ) + { + this.timeProvider = timeProvider; + } + + [BindProperty] + [Range( 2010, 2100, ErrorMessage = "The year must be between 2010 and 2100." )] + [Display( Name = "From year" )] + public int FromYear { get; set; } + + [BindProperty] + [Range( 1, 12 )] + [Display( Name = "From month" )] + public int FromMonth { get; set; } + + [BindProperty] + [Range( 2010, 2100, ErrorMessage = "The year must be between 2010 and 2100." )] + [Display( Name = "To year" )] + public int ToYear { get; set; } + + [BindProperty] + [Range( 1, 12 )] + [Display( Name = "To month" )] + public int ToMonth { get; set; } + + public static SelectList Months { get; } = new( + Enumerable.Range( 1, 12 ) + .Select( m => new { Value = m, Text = CultureInfo.CurrentCulture.DateTimeFormat.GetMonthName( m ) } ), + "Value", + "Text" ); + + public void OnGet() + { + var now = this.timeProvider.GetUtcNow().UtcDateTime; + + this.FromYear = now.Year; + this.ToYear = now.Year; + this.FromMonth = 1; + this.ToMonth = now.Month; + } + + public IActionResult OnPost() + { + if ( !this.ModelState.IsValid ) + { + return this.Page(); + } + + if ( new DateTime( this.ToYear, this.ToMonth, 1 ) < new DateTime( this.FromYear, this.FromMonth, 1 ) ) + { + this.ModelState.AddModelError( string.Empty, "The end of the range is before its start." ); + + return this.Page(); + } + + // Url.Content resolves the path, so that the link works when the server is installed as an + // application below the root of an IIS site. + return this.Redirect( this.Url.Content( $"~/Admin/Export.ashx?fy={this.FromYear}&fm={this.FromMonth}&ty={this.ToYear}&tm={this.ToMonth}" ) ); + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/GenerateDemoData.cshtml b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/GenerateDemoData.cshtml new file mode 100644 index 0000000..e0563ae --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/GenerateDemoData.cshtml @@ -0,0 +1,38 @@ +@page +@model GenerateDemoDataModel +@{ + ViewData["Title"] = "Generate demo data"; +} + + + +

+ Simulates a team building software over a period, so that the dashboard and the usage graph have + something to show. This page exists only in a development environment. +

+ +@if (Model.Message != null) +{ +

+ @Model.Message +

+} + +
+

+ + +

+

+ + +

+

+ +

+
diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/GenerateDemoData.cshtml.cs b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/GenerateDemoData.cshtml.cs new file mode 100644 index 0000000..37e443b --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Admin/GenerateDemoData.cshtml.cs @@ -0,0 +1,158 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Services; + +namespace SharpCrafters.Backstage.LicenseServer.Pages.Admin; + +/// +/// Fills the database with a realistic history of lease activity, so that the home page and the +/// usage graph can be examined without waiting for real requests. +/// +/// +/// This page exists only in the Development environment. The legacy version of this page was +/// reachable in production, on a deployment whose administrative pages were open by default. +/// +public sealed class GenerateDemoDataModel : PageModel +{ + private readonly ILeaseRepository repository; + private readonly LicenseServerDbContext db; + private readonly LeaseService leaseService; + private readonly IHostEnvironment environment; + private readonly TimeProvider timeProvider; + + public GenerateDemoDataModel( + ILeaseRepository repository, + LicenseServerDbContext db, + LeaseService leaseService, + IHostEnvironment environment, + TimeProvider timeProvider ) + { + this.repository = repository; + this.db = db; + this.leaseService = leaseService; + this.environment = environment; + this.timeProvider = timeProvider; + } + + private static readonly string[] firstNames = + [ + "David", "Jimmy", "Carroll", "Keith", "Marsha", "Mike", "Julio", "Salvatore", "Herbert", "Gary", + "Jesse", "Louis", "Duane", "Joan", "Thomas", "Richard", "Charles", "Paul", "Albert", "Jerry", + "Sidney", "John", "Monica", "William", "Miguel", "Melanie", "Nida", "George", "Edmund", "Michael" + ]; + + private static readonly string[] lastNames = + [ + "Rahm", "Smith", "Lash", "Martinez", "Bassham", "Bergeron", "Bayliss", "Nail", "Thompson", "Gentile", + "Turner", "Stinson", "Callender", "Rudder", "Lowrey", "Bourdeau", "Vega", "Numbers", "Cooper", + "Speier", "Johnson", "Painter", "Denton", "Grise", "Davis", "Collins", "Perez", "Young", "Wilkes" + ]; + + [BindProperty] + [BindNever] + public string? Message { get; private set; } + + public bool IsAvailable => this.environment.IsDevelopment(); + + public int UserCount { get; set; } = 20; + + public int Days { get; set; } = 90; + + public IActionResult OnGet() => this.IsAvailable ? this.Page() : this.NotFound(); + + public async Task OnPostAsync( int userCount, int days, CancellationToken cancellationToken ) + { + if ( !this.IsAvailable ) + { + return this.NotFound(); + } + + this.UserCount = Math.Clamp( userCount, 1, 200 ); + this.Days = Math.Clamp( days, 1, 365 ); + + var licenses = await this.repository.Licenses + .Where( l => l.Priority >= 0 ) + .OrderBy( l => l.Priority ) + .ToArrayAsync( cancellationToken ); + + if ( licenses.Length == 0 ) + { + this.Message = "Add a license first: there is nothing to lease against."; + + return this.Page(); + } + + // A fixed seed, so that a second run of the generator produces a comparable history. + Random random = new( 20260105 ); + + (string User, string[] Machines)[] users = Enumerable.Range( 0, this.UserCount ) + .Select( i => + { + var user = + $"{firstNames[i % firstNames.Length]}.{lastNames[( i * 7 ) % lastNames.Length]}".ToLowerInvariant(); + + string[] machines = random.Next( 3 ) == 0 + ? [$"{user}-desktop", $"{user}-laptop"] + : [$"{user}-desktop"]; + + return ( user, machines ); + } ) + .ToArray(); + + var now = this.timeProvider.GetUtcNow().UtcDateTime; + var start = now.Date.AddDays( -this.Days ); + var granted = 0; + + for ( var day = 0; day < this.Days; day++ ) + { + var date = start.AddDays( day ); + + var isWeekend = date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday; + + foreach ( var (user, machines) in users ) + { + // Most developers do not work at the weekend, and a developer does not build every + // day. + if ( random.NextDouble() > ( isWeekend ? 0.1 : 0.85 ) ) + { + continue; + } + + var machine = machines[random.Next( machines.Length )]; + var time = date.AddHours( 8 + ( random.NextDouble() * 9 ) ); + + var lease = await this.leaseService.GetLeaseAsync( + new Version( 2025, 1, 0 ), + null, + machine, + user, + user, + time, + [], + licenses, + cancellationToken: cancellationToken ); + + if ( lease != null ) + { + granted++; + } + } + + // The generator saves once per simulated day, and not once per lease. Otherwise the + // change tracker would grow during the whole run, and each save would be slower than the + // previous one. + await this.db.SaveChangesAsync( cancellationToken ); + } + + this.Message = + $"Simulated {this.Days} days of activity for {this.UserCount} users against " + + $"{licenses.Length} license(s); {granted} lease(s) granted."; + + return this.Page(); + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Error.cshtml b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Error.cshtml new file mode 100644 index 0000000..c6666a8 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Error.cshtml @@ -0,0 +1,20 @@ +@page +@model ErrorModel +@{ + ViewData["Title"] = "Error"; +} + +

Something went wrong

+ +

An unexpected error occurred while handling your request.

+ +@if (Model.RequestId != null) +{ +

Request identifier: @Model.RequestId

+} + +

The details are in the server log.

+ +

+ Back to the licenses +

diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Error.cshtml.cs b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Error.cshtml.cs new file mode 100644 index 0000000..fdca4f7 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Error.cshtml.cs @@ -0,0 +1,18 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Diagnostics; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace SharpCrafters.Backstage.LicenseServer.Pages; + +[ResponseCache( Duration = 0, Location = ResponseCacheLocation.None, NoStore = true )] +public sealed class ErrorModel : PageModel +{ + /// + /// Gets the identifier an administrator can use to find this request in the log. + /// + public string? RequestId { get; private set; } + + public void OnGet() => this.RequestId = Activity.Current?.Id ?? this.HttpContext.TraceIdentifier; +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Graph.cshtml b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Graph.cshtml new file mode 100644 index 0000000..7940346 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Graph.cshtml @@ -0,0 +1,40 @@ +@page +@model GraphModel +@{ + ViewData["Title"] = $"Usage of license {Model.Id}"; +} + +@section Head { + +} + + + +
+ +
+ +@* The data is written as JSON in an element of its own, and not inside the body of a script, where a + closing script tag inside a string would end the block. Razor encodes the content. *@ + + +@section Scripts { + +} diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Graph.cshtml.cs b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Graph.cshtml.cs new file mode 100644 index 0000000..e770d58 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Graph.cshtml.cs @@ -0,0 +1,182 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Globalization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Licensing; + +namespace SharpCrafters.Backstage.LicenseServer.Pages; + +/// +/// The usage history of one license. It reports the number of seats in use on each of the last days, +/// with the capacity of the license and the capacity of its grace period. +/// +/// +/// A seat is one user and the machines that user works on, up to MachinesPerUser machines. +/// See . The chart draws the quantity that the allocator compares to +/// the capacity, so the line and the two limits above it use the same unit, and the chart agrees +/// with the column "In use" of the license list. +/// +public sealed class GraphModel : PageModel +{ + private readonly ILeaseRepository repository; + private readonly ILicenseParser licenseParser; + private readonly TimeProvider timeProvider; + + public GraphModel( + ILeaseRepository repository, + ILicenseParser licenseParser, + TimeProvider timeProvider ) + { + this.repository = repository; + this.licenseParser = licenseParser; + this.timeProvider = timeProvider; + } + + /// + /// The windows that the page offers. The list is closed, so that an arbitrary value cannot + /// produce an unbounded query. + /// + public static readonly int[] AllowedWindows = [30, 90, 180, 365]; + + [BindProperty( SupportsGet = true )] + public int Id { get; set; } + + [BindProperty( SupportsGet = true, Name = "days" )] + public int Days { get; set; } = 30; + + public UsageChart Chart { get; private set; } = new(); + + public async Task OnGetAsync( CancellationToken cancellationToken ) + { + if ( !AllowedWindows.Contains( this.Days ) ) + { + return this.BadRequest( $"The window must be one of {string.Join( ", ", AllowedWindows )} days." ); + } + + var license = await this.repository.Licenses + .AsNoTracking() + .SingleOrDefaultAsync( l => l.LicenseId == this.Id, cancellationToken ); + + if ( license == null ) + { + return this.NotFound(); + } + + var endDate = this.timeProvider.GetUtcNow().UtcDateTime.Date.AddDays( 1 ); + var startDate = endDate.AddDays( -this.Days ); + + int? maximum = null; + int? graceMaximum = null; + var axisMaximum = 0; + + var parsedLicense = this.licenseParser.TryParse( license.LicenseKey ); + + if ( parsedLicense?.UserNumber != null ) + { + maximum = parsedLicense.UserNumber; + + // The arithmetic of the allocator, which rounds up. An integer division would round + // down, and a license of one seat with a grace period of 20 per cent would then be drawn + // with a limit of one seat, while the server grants two. + graceMaximum = (int) Math.Ceiling( maximum.Value * ( 100.0 + parsedLicense.GracePercent ) / 100.0 ); + axisMaximum = graceMaximum.Value; + } + + var dailyUsage = this.repository.GetLeaseCountingPoints( this.Id, startDate, endDate ) + .GroupBy( point => point.Time.Date ) + .Select( day => new + { + Date = day.Key, + Peak = day.Max( point => point.SeatCount ), + + // The timeline is ordered, and the grouping preserves that order inside a group, + // so the last point of a day carries the count that the next day starts from. + AtEndOfDay = day.Last().SeatCount + } ) + .ToList(); + + var labels = new string[this.Days]; + var values = new int[this.Days]; + var hasValue = new bool[this.Days]; + var endOfDayValues = new int?[this.Days]; + + foreach ( var point in dailyUsage ) + { + var day = (int) Math.Floor( point.Date.Subtract( startDate ).TotalDays ); + + if ( point.Peak > axisMaximum ) + { + axisMaximum = point.Peak; + } + + // A lease that started before the window contributes to its first day. + var index = day < 0 ? 0 : day; + + if ( index < this.Days ) + { + values[index] = point.Peak; + hasValue[index] = true; + endOfDayValues[index] = point.AtEndOfDay; + } + } + + // A day without lease activity keeps the count of the end of the previous day. + var lastValue = 0; + + for ( var i = 0; i < this.Days; i++ ) + { + var date = startDate.AddDays( i ); + + labels[i] = date.ToString( "yyyy-MM-dd", CultureInfo.InvariantCulture ); + + if ( !hasValue[i] ) + { + values[i] = lastValue; + } + + if ( endOfDayValues[i] != null ) + { + lastValue = endOfDayValues[i]!.Value; + } + } + + this.Chart = new UsageChart + { + Labels = labels, + Seats = values, + Maximum = maximum, + Grace = graceMaximum, + AxisMaximum = (int) Math.Ceiling( Math.Ceiling( axisMaximum * 1.2 ) / 10 ) * 10 + }; + + return this.Page(); + } + + /// + /// The data passed to the chart script, serialized as JSON. + /// + public sealed class UsageChart + { + public string[] Labels { get; init; } = []; + + /// + /// Gets the number of seats in use on each day of the window. + /// + public int[] Seats { get; init; } = []; + + /// + /// Gets the number of seats the license allows, or null when it is unlimited. + /// + public int? Maximum { get; init; } + + /// + /// Gets the number of seats allowed during the grace period. + /// + public int? Grace { get; init; } + + public int AxisMaximum { get; init; } + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Index.cshtml b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Index.cshtml new file mode 100644 index 0000000..c1abdca --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Index.cshtml @@ -0,0 +1,61 @@ +@page +@model IndexModel +@{ + ViewData["Title"] = "Licenses"; +} + + + +@if (Model.Licenses.Count == 0) +{ +
+

+ No license has been registered yet. Add a license key to + let this server serve leases. +

+
+} +else +{ +
+ + + + + + + + + + + + + + + + @foreach (var license in Model.Licenses) + { + + + + + + + + + + + + } + +
LicenseTypeProductSeatsIn useGrace startedMaintenance endsStatus
@license.LicenseId@license.LicenseType@license.ProductCode@( license.MaxUsers?.ToString() ?? "Unlimited" )@license.CurrentUsers@license.GraceStartTime?.ToString( "d" )@license.MaintenanceEndDate?.ToString( "d" ) + @license.Status + + Details + · + Usage +
+
+} diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Index.cshtml.cs b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Index.cshtml.cs new file mode 100644 index 0000000..4748724 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Index.cshtml.cs @@ -0,0 +1,93 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Licensing; + +namespace SharpCrafters.Backstage.LicenseServer.Pages; + +/// +/// The home page. It lists every registered license with the part of its capacity that is in use. +/// +public sealed class IndexModel : PageModel +{ + private readonly ILeaseRepository repository; + private readonly ILicenseParser licenseParser; + private readonly TimeProvider timeProvider; + + public IndexModel( + ILeaseRepository repository, + ILicenseParser licenseParser, + TimeProvider timeProvider ) + { + this.repository = repository; + this.licenseParser = licenseParser; + this.timeProvider = timeProvider; + } + + public IReadOnlyList Licenses { get; private set; } = []; + + public async Task OnGetAsync( CancellationToken cancellationToken ) + { + var licenses = await this.repository.Licenses + .OrderBy( l => l.Priority ) + .ThenByDescending( l => l.LicenseId ) + .AsNoTracking() + .ToArrayAsync( cancellationToken ); + + var now = this.timeProvider.GetUtcNow().UtcDateTime; + List summaries = []; + + foreach ( var license in licenses ) + { + var parsedLicense = this.licenseParser.TryParse( license.LicenseKey ); + + summaries.Add( + parsedLicense == null + ? new LicenseSummary { LicenseId = license.LicenseId, LicenseType = "INVALID", Status = "Invalid" } + : new LicenseSummary + { + LicenseId = license.LicenseId, + LicenseType = parsedLicense.LicenseType, + ProductCode = parsedLicense.Product, + MaxUsers = parsedLicense.UserNumber, + CurrentUsers = this.repository.GetActiveSeats( license.LicenseId, now ), + GraceStartTime = license.GraceStartTime, + Status = license.Priority >= 0 ? "Active" : "Disabled", + MaintenanceEndDate = parsedLicense.SubscriptionEndDate + } ); + } + + this.Licenses = summaries; + } + + public sealed class LicenseSummary + { + public int LicenseId { get; init; } + + public string LicenseType { get; init; } = ""; + + public string? ProductCode { get; init; } + + public int? MaxUsers { get; init; } + + public int CurrentUsers { get; init; } + + public DateTime? GraceStartTime { get; init; } + + public string? Status { get; init; } + + public DateTime? MaintenanceEndDate { get; init; } + + /// + /// Gets the modifier that gives the status its color. An active license is green, a key that + /// cannot be parsed is orange, and a license in its grace period is amber. + /// + public string StatusModifier + => this.LicenseType == "INVALID" ? "invalid" + : this.GraceStartTime != null ? "grace" + : this.Status == "Active" ? "active" + : "disabled"; + } +} \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Shared/_Layout.cshtml b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Shared/_Layout.cshtml new file mode 100644 index 0000000..b2732e2 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/Shared/_Layout.cshtml @@ -0,0 +1,41 @@ + + + + + + @ViewData["Title"] - PostSharp License Server + + + + + + + + @await RenderSectionAsync( "Head", required: false ) + + +
+
+ + License Server + +
+
+ +
+ @RenderBody() +
+ +
+ PostSharp and Metalama are trade names of SharpCrafters s.r.o. © 2004–@DateTime.UtcNow.Year. +
+ +@await RenderSectionAsync( "Scripts", required: false ) + + diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/_ViewImports.cshtml b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..fd907e3 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@using SharpCrafters.Backstage.LicenseServer +@using SharpCrafters.Backstage.LicenseServer.Pages +@namespace SharpCrafters.Backstage.LicenseServer.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/_ViewStart.cshtml b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/_ViewStart.cshtml new file mode 100644 index 0000000..820a2f6 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Pages/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Program.cs b/src/SharpCrafters.Backstage.LicenseServer.Web/Program.cs new file mode 100644 index 0000000..6fd9496 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Program.cs @@ -0,0 +1,214 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SharpCrafters.Backstage.LicenseServer; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Email; +using SharpCrafters.Backstage.LicenseServer.Endpoints; +using SharpCrafters.Backstage.LicenseServer.Health; +using SharpCrafters.Backstage.LicenseServer.Licensing; +using SharpCrafters.Backstage.LicenseServer.Options; +using SharpCrafters.Backstage.LicenseServer.Services; +using SharpCrafters.Backstage.LicenseServer.Time; + +var builder = WebApplication.CreateBuilder( args ); + +builder.Services + .AddOptions() + .Bind( builder.Configuration.GetSection( LicenseServerOptions.SectionName ) ) + .ValidateDataAnnotations() + .ValidateOnStart(); + +builder.Services.AddSingleton, LicenseServerOptionsValidator>(); + +builder.Services + .AddOptions() + .Bind( builder.Configuration.GetSection( SmtpOptions.SectionName ) ) + .ValidateDataAnnotations() + .ValidateOnStart(); + +builder.Services.AddLicenseServerDatabase( builder.Configuration, builder.Environment ); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +var testLicensingAuthorities = + builder.Services.AddLicenseServerLicensing( builder.Configuration, builder.Environment ); + +builder.Services.AddSingleton(); + +builder.Services.AddScoped(); + +// The health checks of the monitoring probe. The liveness probe at /health/live runs none of them. +// See OperationsEndpoints. +builder.Services.AddHealthChecks() + .AddCheck( "database" ) + .AddCheck( "licenses" ); + +builder.Services.AddSingleton( services => services.GetRequiredService>().Value.Enabled + ? ActivatorUtilities.CreateInstance( services ) + : new NullEmailSender() ); + +// The acceleration of the clock exists so that a licensing scenario that lasts several days can be +// replayed in minutes. It is disabled unless the configuration enables it. +builder.Services.AddSingleton( services => +{ + var options = services.GetRequiredService>().Value; + + if ( options.TimeAcceleration is 0 or 1 ) + { + return TimeProvider.System; + } + + services.GetRequiredService>() + .LogWarning( + "Time is accelerated by a factor of {Acceleration}. This is a test configuration and must not be " + + "used in production.", + options.TimeAcceleration ); + + return new AcceleratedTimeProvider( TimeProvider.System, (double) options.TimeAcceleration ); +} ); + +var authenticationScheme = builder.Services.AddLicenseServerAuthentication( builder.Configuration ); + +// A policy is built before the options are available from the container, so the section is bound +// here. It is bound as a whole, and not read key by key, so that the policy and the rest of the +// application read the same properties of the same type. +var startupOptions = + builder.Configuration.GetSection( LicenseServerOptions.SectionName ).Get() ?? new LicenseServerOptions(); + +builder.Services.AddAuthorizationBuilder() + .AddPolicy( + AuthorizationPolicies.Admin, + policy => + { + var roles = startupOptions.AdminRoles; + + // When no role is configured, the administrative pages are open, as they were in the + // legacy Web.config. A restrictive default would lock administrators out of their own + // server during an upgrade. The server writes a warning to the log at startup instead. + if ( roles.Length == 0 ) + { + policy.RequireAssertion( _ => true ); + } + else + { + policy.RequireRole( roles ); + } + } ) + .AddPolicy( + AuthorizationPolicies.LeaseRequest, + policy => + { + if ( startupOptions.RequireAuthenticatedLeaseRequests ) + { + policy.RequireAuthenticatedUser(); + } + else + { + policy.RequireAssertion( _ => true ); + } + } ); + +builder.Services.AddRazorPages( options => +{ + options.Conventions.AuthorizeFolder( "/Admin", AuthorizationPolicies.Admin ); +} ); + +var app = builder.Build(); + +if ( !app.Environment.IsDevelopment() ) +{ + app.UseExceptionHandler( "/Error" ); +} + +app.UseStaticFiles(); +app.UseRouting(); +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapRazorPages(); +app.MapLicenseServerEndpoints(); +app.MapOperationsEndpoints(); +app.MapLegacyUrlRedirects(); + +// On SQL Server and on PostgreSQL, the script of that engine under Database/ creates the schema. The +// script defines the schema, and an administrator runs it. A SQLite database is created here, because +// it is used for evaluation and for tests, where no administrator runs a script. +if ( string.Equals( + app.Configuration["LicenseServer:DatabaseProvider"], + "Sqlite", + StringComparison.OrdinalIgnoreCase ) ) +{ + using var scope = app.Services.CreateScope(); + scope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); +} + +// A development server can issue to itself the license keys it serves, so that an evaluation or a +// load simulation has a license to lease. The registration has already refused to start when this +// setting is used outside the Development environment. +{ + var testAuthority = app.Services.GetService(); + + if ( testAuthority != null ) + { + using var scope = app.Services.CreateScope(); + + TestLicenseSeeder.Seed( + scope.ServiceProvider.GetRequiredService(), + testAuthority, + app.Services.GetRequiredService(), + app.Logger ); + } +} + +// The administrative pages are the only way to add and to revoke a license. The server writes a +// warning, because a comment in a configuration file is not enough for an open default. +{ + var options = app.Services.GetRequiredService>().Value; + + if ( options.AdminRoles.Length == 0 ) + { + app.Logger.LogWarning( + "The administrative pages are not restricted. Set {Setting} to the Windows groups allowed to manage " + + "licenses, for example \"DOMAIN\\\\PostSharp Administrators\".", + $"{LicenseServerOptions.SectionName}:AdminRoles" ); + } +} + +// The server writes the selected scheme to the log, because the leases recorded under the scheme +// "None" carry no authenticated user. +app.Logger.LogInformation( "Authenticating with the {Scheme} scheme.", authenticationScheme ); + +if ( authenticationScheme == AuthenticationRegistration.None ) +{ + app.Logger.LogWarning( + "Authentication is disabled, so leases will not record who requested them. Set {Setting} to " + + "\"Negotiate\" on a host that is joined to your domain.", + "Authentication:Scheme" ); +} + +if ( testLicensingAuthorities.Count > 0 ) +{ + app.Logger.LogWarning( + "This server accepts license keys signed by the test licensing authorities {KeyIds} besides the " + + "production one. This is a test configuration and must not be used in production.", + string.Join( ", ", testLicensingAuthorities ) ); +} + +app.Run(); + +/// +/// Names of the authorization policies. +/// +public static class AuthorizationPolicies +{ + public const string Admin = "Admin"; + public const string LeaseRequest = "LeaseRequest"; +} + +/// +/// Declared as a public type so that the integration tests can host the application in memory. +/// +public partial class Program; \ No newline at end of file diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/Properties/launchSettings.json b/src/SharpCrafters.Backstage.LicenseServer.Web/Properties/launchSettings.json new file mode 100644 index 0000000..ade9693 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "SharpCrafters.Backstage.LicenseServer": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "http://localhost:44670", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/SharpCrafters.Backstage.LicenseServer.Web.csproj b/src/SharpCrafters.Backstage.LicenseServer.Web/SharpCrafters.Backstage.LicenseServer.Web.csproj new file mode 100644 index 0000000..42ebef5 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/SharpCrafters.Backstage.LicenseServer.Web.csproj @@ -0,0 +1,67 @@ + + + + net10.0 + + SharpCrafters.Backstage.LicenseServer + SharpCrafters.Backstage.LicenseServer + sharpcrafters-backstage-license-server + + + + + + + + + + + + + + + + + + + + + + + + + + $(PackageOutputPath)$(AssemblyName).$(Version).zip + + + $([MSBuild]::NormalizeDirectory('$(RepoDirectory)artifacts\app')) + + + $(BuildDate) + 2000-01-01 + + + + + + + + + + + + + + + + + diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/appsettings.Development.json b/src/SharpCrafters.Backstage.LicenseServer.Web/appsettings.Development.json new file mode 100644 index 0000000..30db7bb --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/appsettings.Development.json @@ -0,0 +1,17 @@ +{ + "Authentication": { + "Scheme": "Negotiate" + }, + "LicenseServer": { + "DatabaseProvider": "Sqlite" + }, + "ConnectionStrings": { + "SharpCrafters_LicenseServerConnectionString": "DataSource=licenseserver-dev.db" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/appsettings.json b/src/SharpCrafters.Backstage.LicenseServer.Web/appsettings.json new file mode 100644 index 0000000..139a27a --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/appsettings.json @@ -0,0 +1,38 @@ +{ + "ConnectionStrings": { + "SharpCrafters_LicenseServerConnectionString": "Data Source=localhost;Initial Catalog=PostSharpLicenseServer;Integrated Security=True;Encrypt=False" + }, + "Authentication": { + "Scheme": "IISIntegrated" + }, + "LicenseServer": { + "GracePeriodWarningDays": 1, + "MachinesPerUser": 2, + "MinLeaseDays": 1, + "NewLeaseDays": 3, + "GracePeriodWarningEmailTo": "", + "GracePeriodWarningEmailCC": "", + "DeniedRequestEmailTo": "", + "MutexTimeout": 30, + "TimeAcceleration": 1, + "BuildServers": "", + "AdminRoles": [], + "RequireAuthenticatedLeaseRequests": false + }, + "Smtp": { + "Enabled": false, + "Host": "localhost", + "Port": 25, + "EnableSsl": false, + "FromAddress": "sales@postsharp.net", + "UserName": null, + "Password": null + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/css/site.css b/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/css/site.css new file mode 100644 index 0000000..fac05c9 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/css/site.css @@ -0,0 +1,555 @@ +/* PostSharp License Server. + + The design tokens below are copied from the PostSharp website + (metalama-website/_sass/0-base/_tokens.scss) so that the license server looks like the rest of + the product. The brand is dark-first and has no light palette, so neither has this. + + The website's display face, Monosten Pro, is a commercially licensed webfont that may not be + redistributed. This application ships to customers and runs on their own servers, so the font is + named first in the stack and falls back to a monospace face, which keeps the character of the + headings without redistributing anything. */ + +:root { + color-scheme: dark; + + /* Brand purples. On dark surfaces the bright purple is used with a white wordmark; the deep + purple is the light-surface counterpart and is deliberately not interchangeable with it. */ + --purple: #973bfc; + --purple-deep: #6122b2; + --purple-medium-1: #6527a9; + --purple-dark: #190822; + --ink: #110b1d; + + /* Surfaces. */ + --bg-canvas: #191321; + --bg-footer: #120b1d; + --bg-panel: #1e0b38; + --bg-panel-2: #26183a; + --bg-code: #1e1e1e; + --doc-content-bg: #1a1229; + --doc-table-line: #2c1a4f; + + /* Accents. Cyan is the secondary accent and carries prose links. */ + --cyan: #38d5e4; + --cyan-light: #61d3e1; + --orange: #ff5e45; + --yellow: #ffc107; + --green: #28a745; + --pink-deep: #e83e8c; + + /* Text. */ + --text-primary: #ffffff; + --text-body: #bdbdbd; + --text-muted: #a0a0a0; + --text-soft: #d3d3d3; + + /* Borders. */ + --border-subtle: rgba(255, 255, 255, 0.15); + --border-card: 1px solid rgba(255, 255, 255, 0.12); + --border-hairline: rgba(255, 255, 255, 0.06); + + /* Type. Monosten is not redistributed with this application; see the note above. */ + --font-display: "Monosten", "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace; + --font-body: "LatoLatinWeb", "Lato", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + --font-code: "Consolas", "SFMono-Regular", ui-monospace, Menlo, monospace; + + --fs-base: 16px; + --fs-sm: 12.8px; + --fs-h3: 22px; + --fs-h2: 32px; + --fs-h1: 44px; + + --fw-normal: 400; + --fw-bold: 700; + + --lh-snug: 1.2; + --lh-normal: 1.4; + --lh-relaxed: 1.6; + --ls-wide: 0.04em; + + /* Buttons are sharp; panels round at 8px. */ + --radius-0: 0; + --radius-sm: 4px; + --radius-md: 8px; + + --transition-fast: 150ms; + --transition-slow: all 500ms ease; + + --container: 1400px; + --header-height: 64px; +} + +*, *::before, *::after { + box-sizing: border-box; +} + +body { + margin: 0; + background-color: var(--bg-canvas); + color: var(--text-body); + font-family: var(--font-body); + font-size: var(--fs-base); + line-height: var(--lh-relaxed); +} + +/* Headings are weight 400 throughout: size does the work, not weight. */ +h1, h2, h3 { + font-family: var(--font-display); + font-weight: var(--fw-normal); + color: var(--text-primary); +} + +h1 { font-size: var(--fs-h1); line-height: var(--lh-snug); } +h2 { font-size: var(--fs-h2); line-height: var(--lh-snug); margin-top: 0; } +h3 { font-size: var(--fs-h3); line-height: var(--lh-normal); } + +a { + color: var(--cyan); + text-decoration: none; + transition: color var(--transition-fast) ease; +} + +a:hover { + color: var(--cyan-light); + text-decoration: underline; +} + +code { + font-family: var(--font-code); + color: var(--cyan-light); +} + +/* The website's own focus ring. */ +:where(a, button, input, select, textarea, [tabindex]):focus-visible { + outline: 2px solid var(--cyan); + outline-offset: 2px; +} + +/* --- Page chrome -------------------------------------------------------- */ + +/* The company bar, reproduced from the website's header: the deepest surface, 64px tall, with the + logo on the left. */ +.company-bar { + background-color: var(--bg-footer); + border-bottom: 1px solid var(--border-hairline); +} + +.company-bar__inner { + display: flex; + align-items: center; + gap: 32px; + height: var(--header-height); + max-width: var(--container); + margin: 0 auto; + padding: 0 24px; +} + +.company-bar__logo img { + display: block; + width: 180px; + height: auto; +} + +.company-bar__title { + font-family: var(--font-display); + font-size: 15px; + color: var(--text-muted); + letter-spacing: var(--ls-wide); + text-transform: uppercase; +} + +.company-bar__nav { + margin-left: auto; + display: flex; + gap: 4px; +} + +.company-bar__nav a { + display: flex; + align-items: center; + height: var(--header-height); + padding: 0 18px; + font-family: var(--font-body); + font-size: 15px; + color: var(--text-muted); + text-decoration: none; +} + +.company-bar__nav a:hover { + color: var(--text-primary); + text-decoration: none; +} + +main { + max-width: var(--container); + margin: 0 auto; + padding: 40px 24px 80px; +} + +.page-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 24px; + flex-wrap: wrap; +} + +/* The link and the menu that act on the page, kept together at its top right. */ +.page-header__actions { + display: flex; + align-items: center; + gap: 20px; +} + +.site-footer { + max-width: var(--container); + margin: 0 auto; + padding: 32px 24px 40px; + border-top: 1px solid var(--border-hairline); + color: var(--text-muted); + font-size: var(--fs-sm); +} + +/* --- Panels and tables --------------------------------------------------- */ + +.panel { + padding: 24px; + background: var(--doc-content-bg); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: var(--radius-md); +} + +table { + width: 100%; + border-collapse: collapse; + border-radius: var(--radius-md); + overflow: hidden; +} + +td, th { + padding: 10px 14px; + text-align: left; + border: 1px solid var(--doc-table-line); + color: var(--text-soft); +} + +th { + background-color: var(--bg-panel); + color: var(--text-primary); + font-family: var(--font-display); + font-weight: var(--fw-normal); +} + +tbody tr:nth-child(even) td { + background-color: rgba(255, 255, 255, 0.02); +} + +/* --- Status ------------------------------------------------------------- */ + +.status { + font-family: var(--font-display); + font-size: var(--fs-sm); + letter-spacing: var(--ls-wide); + text-transform: uppercase; +} + +.status--active { color: var(--green); } +.status--disabled { color: var(--text-muted); } +.status--invalid { color: var(--orange); } +.status--grace { color: var(--yellow); } + +/* --- Buttons ------------------------------------------------------------ */ + +/* Sharp, uppercase, two-pixel border: the signature of the brand's buttons. */ +.btn, +button, +input[type="submit"] { + display: inline-block; + padding: 7px 40px; + line-height: 27px; + font-family: var(--font-body); + font-size: var(--fs-base); + font-weight: var(--fw-bold); + text-transform: uppercase; + letter-spacing: var(--ls-wide); + color: var(--text-primary); + background-color: var(--purple-medium-1); + border: 2px solid var(--purple-medium-1); + border-radius: var(--radius-0); + text-align: center; + text-decoration: none; + cursor: pointer; + transition: var(--transition-slow); +} + +.btn:hover, +button:hover, +input[type="submit"]:hover { + background-color: var(--purple-dark); + border-color: var(--purple-dark); + color: var(--text-primary); + text-decoration: none; +} + +.btn--transparent { + background-color: transparent; + border-color: var(--purple); +} + +.btn--small { + padding: 8px 20px; + font-size: 14px; + line-height: 1; +} + +/* --- Menus -------------------------------------------------------------- */ + +/* A button that opens a short list of actions. The button is the of a
element, + which opens and closes without a script. */ +.menu { + position: relative; +} + +.menu > summary { + list-style: none; + user-select: none; +} + +.menu > summary::-webkit-details-marker { + display: none; +} + +.menu > summary::after { + content: " \25BE"; +} + +/* The list floats over the page rather than moving what is under it. */ +.menu__items { + position: absolute; + right: 0; + z-index: 10; + display: flex; + flex-direction: column; + min-width: 220px; + margin-top: 4px; + padding: 6px; + background: var(--bg-panel-2); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); +} + +.menu__items form { + margin: 0; +} + +/* An item reads as a line of the list, not as a button of its own, until it is pointed at. */ +.menu__items button { + width: 100%; + padding: 8px 12px; + line-height: var(--lh-normal); + font-weight: var(--fw-normal); + text-align: left; + text-transform: none; + letter-spacing: normal; + background-color: transparent; + border-color: transparent; + transition: background-color var(--transition-fast) ease; +} + +.menu__items button:hover { + background-color: var(--purple-medium-1); + border-color: transparent; +} + +/* An action that destroys data, wherever it is offered. */ +.is-destructive { + color: var(--orange); +} + +/* --- Dialogs ------------------------------------------------------------- */ + +.dialog { + max-width: 480px; + padding: 24px; + background: var(--doc-content-bg); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + color: var(--text-body); +} + +.dialog::backdrop { + background: rgba(0, 0, 0, 0.6); +} + +.dialog h3 { + margin-top: 0; +} + +.dialog form { + margin: 0; +} + +/* The button that carries out a destructive action, where orange text on the purple of a button + would not be legible. */ +.dialog .is-destructive, +.dialog .is-destructive:hover { + color: var(--ink); + background-color: var(--orange); + border-color: var(--orange); +} + +/* --- Forms -------------------------------------------------------------- */ + +label { + display: inline-block; + font-family: var(--font-display); + font-size: 14px; + color: var(--text-muted); + letter-spacing: var(--ls-wide); + text-transform: uppercase; +} + +input[type="text"], +input[type="number"], +select, +textarea { + padding: 10px 14px; + background: rgba(30, 11, 56, 0.55); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: var(--radius-md); + color: var(--text-primary); + font-family: var(--font-body); + font-size: var(--fs-base); + line-height: var(--lh-snug); + transition: border-color 200ms ease, box-shadow 200ms ease; +} + +textarea { + width: 100%; + font-family: var(--font-code); +} + +input:focus, select:focus, textarea:focus { + border-color: var(--purple); +} + +.validation-summary-errors, +.field-validation-error { + color: var(--pink-deep); +} + +.validation-summary-errors ul { + margin: 0; + padding-left: 20px; +} + +/* --- Callouts ----------------------------------------------------------- */ + +/* A row of buttons that act on the thing the page is about, with the sentence that explains them. */ +.actions { + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: center; +} + +.actions .note { + margin: 0; +} + +/* The buttons of a dialog, which sit at its end rather than at its start. */ +.actions--end { + justify-content: flex-end; + margin-top: 16px; +} + +/* An explanation of a term used just above it: present, but quieter than what it explains. */ +.note { + max-width: 68ch; + font-size: 14px; + color: var(--text-muted); +} + +/* The website's note callout: a coloured left rule on a raised panel. */ +.callout { + padding: 20px 24px; + background: var(--bg-panel-2); + border-left: 4px solid var(--cyan); + border-radius: var(--radius-sm); +} + +.callout--warning { + border-left-color: var(--orange); +} + +/* --- Usage graph -------------------------------------------------------- */ + +#usage-chart-container { + position: relative; + width: 100%; + height: 420px; + padding: 24px; + background: var(--doc-content-bg); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: var(--radius-md); +} + +.chart-windows { + display: flex; + gap: 8px; + align-items: center; +} + +.chart-windows .current { + color: var(--text-primary); + font-family: var(--font-display); +} + +/* --- Narrow viewports ---------------------------------------------------- */ + +@media (max-width: 900px) { + .company-bar__title { display: none; } +} + +@media (max-width: 720px) { + /* The bar becomes two rows rather than overflowing: logo above, navigation below. */ + .company-bar__inner { + flex-wrap: wrap; + height: auto; + gap: 8px 16px; + padding: 12px 16px; + } + + .company-bar__logo img { width: 150px; } + + .company-bar__nav { + margin-left: 0; + width: 100%; + flex-wrap: wrap; + } + + .company-bar__nav a { height: 36px; padding: 0 12px 0 0; } + + main { padding: 24px 16px 48px; } + .site-footer { padding: 24px 16px 32px; } + + h1 { font-size: 30px; } + h2 { font-size: 24px; } + + .page-header { gap: 8px; } + + /* The header wraps here, which puts the menu button at the left of the page: the list opens + under its left edge rather than off the screen. */ + .menu__items { + right: auto; + left: 0; + } + + /* A wide table scrolls inside its own box instead of widening the page. */ + .table-scroll { overflow-x: auto; } + + .btn, button, input[type="submit"] { padding: 7px 24px; } + + #usage-chart-container { padding: 12px; height: 340px; } +} diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/favicon.ico b/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/favicon.ico new file mode 100644 index 0000000..59a2693 Binary files /dev/null and b/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/favicon.ico differ diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/img/icons/android-icon-192x192.png b/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/img/icons/android-icon-192x192.png new file mode 100644 index 0000000..be55ccb Binary files /dev/null and b/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/img/icons/android-icon-192x192.png differ diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/img/icons/android-icon-96x96.png b/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/img/icons/android-icon-96x96.png new file mode 100644 index 0000000..a0a3d4c Binary files /dev/null and b/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/img/icons/android-icon-96x96.png differ diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/img/icons/apple-icon-152x152.png b/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/img/icons/apple-icon-152x152.png new file mode 100644 index 0000000..9e22f74 Binary files /dev/null and b/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/img/icons/apple-icon-152x152.png differ diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/img/icons/favicon-16x16.png b/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/img/icons/favicon-16x16.png new file mode 100644 index 0000000..df3a9d7 Binary files /dev/null and b/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/img/icons/favicon-16x16.png differ diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/img/icons/favicon-32x32.png b/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/img/icons/favicon-32x32.png new file mode 100644 index 0000000..8177b68 Binary files /dev/null and b/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/img/icons/favicon-32x32.png differ diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/img/postsharp-logo.svg b/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/img/postsharp-logo.svg new file mode 100644 index 0000000..a0b9e64 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/img/postsharp-logo.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/js/admin-actions.js b/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/js/admin-actions.js new file mode 100644 index 0000000..8b8a2bf --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/js/admin-actions.js @@ -0,0 +1,77 @@ +// The management actions of a license. This file closes the menu when the administrator clicks +// outside it, and it asks for a confirmation before an action runs. +// +// Both behaviours are additions to markup that already works. The menu is a
element, which +// opens and closes without a script, and a form whose submission this file does not intercept is +// submitted by the browser. A browser that does not run this file therefore keeps every action, and +// loses only the confirmation. + +(function () { + "use strict"; + + var menus = Array.prototype.slice.call( document.querySelectorAll( "[data-menu]" ) ); + + function closeMenus( except ) { + menus.forEach( function ( menu ) { + if ( menu !== except ) { + menu.open = false; + } + } ); + } + + document.addEventListener( "click", function ( event ) { + var target = event.target; + + closeMenus( target && target.closest ? target.closest( "[data-menu]" ) : null ); + } ); + + document.addEventListener( "keydown", function ( event ) { + if ( event.key === "Escape" ) { + closeMenus( null ); + } + } ); + + var dialog = document.getElementById( "confirm-dialog" ); + + // In a browser that does not support , the form is submitted directly, so the action is + // not lost. + if ( !dialog || typeof dialog.showModal !== "function" ) { + return; + } + + var title = document.getElementById( "confirm-title" ); + var detail = document.getElementById( "confirm-detail" ); + var okButton = dialog.querySelector( "[data-confirm-ok]" ); + var pendingForm = null; + + document.querySelectorAll( "form[data-confirm]" ).forEach( function ( form ) { + form.addEventListener( "submit", function ( event ) { + event.preventDefault(); + + pendingForm = form; + title.textContent = form.dataset.confirm; + detail.textContent = form.dataset.confirmDetail || ""; + okButton.textContent = form.dataset.confirmAction || "Continue"; + okButton.classList.toggle( "is-destructive", form.dataset.confirmDestructive === "true" ); + + closeMenus( null ); + + // The value is cleared explicitly. In some browsers, a dialog closed with the Escape key + // keeps the value of the previous close, which would confirm an action that the + // administrator did not confirm. + dialog.returnValue = ""; + dialog.showModal(); + } ); + } ); + + dialog.addEventListener( "close", function () { + var form = pendingForm; + pendingForm = null; + + if ( form && dialog.returnValue === "confirm" ) { + // HTMLFormElement.submit does not raise the submit event, so the confirmation is not + // asked a second time. + form.submit(); + } + } ); +})(); diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/js/graph.js b/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/js/graph.js new file mode 100644 index 0000000..f4fcc7f --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/js/graph.js @@ -0,0 +1,117 @@ +// Draws the usage history of a license. GraphModel produces the data, and the page contains it as +// JSON. The page downloads nothing at run time, so it works on a network without access to the +// Internet. +(function () { + "use strict"; + + var dataElement = document.getElementById("usage-chart-data"); + var canvas = document.getElementById("usage-chart"); + + if (!dataElement || !canvas || typeof Chart === "undefined") { + return; + } + + var chart = JSON.parse(dataElement.textContent); + + // The colors of the axes and of the grid lines come from the design tokens, so that the chart + // belongs to the page instead of appearing as a white rectangle on it. + var styles = getComputedStyle(document.documentElement); + Chart.defaults.color = styles.getPropertyValue("--text-muted").trim() || "#a0a0a0"; + Chart.defaults.borderColor = styles.getPropertyValue("--doc-table-line").trim() || "#2c1a4f"; + Chart.defaults.font.family = styles.getPropertyValue("--font-body").trim() || "sans-serif"; + + var datasets = [{ + label: "Seats", + data: chart.seats, + borderColor: "#973bfc", + backgroundColor: "rgba(151, 59, 252, 0.16)", + fill: true, + tension: 0.1, + pointRadius: 0, + pointHitRadius: 8 + }]; + + // A license without a seat limit has no capacity line and no grace line. + if (chart.maximum !== null && chart.maximum !== undefined) { + datasets.push({ + label: "Authorized", + data: chart.labels.map(function () { return chart.maximum; }), + borderColor: "#38d5e4", + borderDash: [6, 4], + fill: false, + pointRadius: 0 + }); + + datasets.push({ + label: "Grace", + data: chart.labels.map(function () { return chart.grace; }), + borderColor: "#ff5e45", + borderDash: [2, 3], + fill: false, + pointRadius: 0 + }); + } + + new Chart(canvas, { + type: "line", + data: { labels: chart.labels, datasets: datasets }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: "index", intersect: false }, + scales: { + y: { + beginAtZero: true, + max: chart.axisMaximum, + title: { display: true, text: "Seats" }, + ticks: { precision: 0 } + }, + x: { + ticks: { + autoSkip: false, + maxRotation: 45, + minRotation: 45, + // The original chart labelled only the Mondays, which keeps a window of one + // year readable. + callback: function (value, index) { + var label = chart.labels[index]; + return new Date(label + "T00:00:00Z").getUTCDay() === 1 ? label : ""; + } + } + } + }, + plugins: { + legend: { + position: "bottom", + + // Each series is a line, so the legend draws a line and not the filled rectangle + // that Chart.js draws by default. The dash pattern and the width are copied from + // the dataset, because the generated legend item does not contain them. Without + // them, the three samples of the legend would differ only by their color, while + // the lines of the chart are solid, dashed and dotted. + labels: { + usePointStyle: true, + pointStyle: "line", + boxWidth: 32, + generateLabels: function (instance) { + var items = Chart.defaults.plugins.legend.labels.generateLabels(instance); + + items.forEach(function (item) { + var dataset = instance.data.datasets[item.datasetIndex]; + item.lineDash = dataset.borderDash || []; + item.lineWidth = dataset.borderWidth || 2; + }); + + return items; + } + } + }, + tooltip: { + callbacks: { + title: function (items) { return items[0].label; } + } + } + } + } + }); +})(); diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/lib/chartjs/LICENSE.md b/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/lib/chartjs/LICENSE.md new file mode 100644 index 0000000..f216610 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/lib/chartjs/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright (c) 2014-2024 Chart.js Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/lib/chartjs/chart.umd.min.js b/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/lib/chartjs/chart.umd.min.js new file mode 100644 index 0000000..7eec4e6 --- /dev/null +++ b/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/lib/chartjs/chart.umd.min.js @@ -0,0 +1,14 @@ +/*! + * Chart.js v4.5.0 + * https://www.chartjs.org + * (c) 2025 Chart.js Contributors + * Released under the MIT License + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Jo},get Decimation(){return ta},get Filler(){return ba},get Legend(){return Ma},get SubTitle(){return Pa},get Title(){return ka},get Tooltip(){return Na}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;at,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)t-e)).pop(),e}function N(t){return!function(t){return"symbol"==typeof t||"object"==typeof t&&null!==t&&!(Symbol.toPrimitive in t||"toString"in t||"valueOf"in t)}(t)&&!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;sl&&h=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return nt[s][e]et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;ss&&t[n-1]>i;)n--;return s>0||n{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...e){const n=s.apply(this,e);return t._chartjs.listeners.forEach((t=>{"function"==typeof t[i]&&t[i](...e)})),n}})})))}function rt(t,e){const i=t._chartjs;if(!i)return;const s=i.listeners,n=s.indexOf(e);-1!==n&&s.splice(n,1),s.length>0||(ot.forEach((e=>{delete t[e]})),delete t._chartjs)}function lt(t){const e=new Set(t);return e.size===t.length?t:Array.from(e)}const ht="undefined"==typeof window?function(t){return t()}:window.requestAnimationFrame;function ct(t,e){let i=[],s=!1;return function(...n){i=n,s||(s=!0,ht.call(window,(()=>{s=!1,t.apply(e,i)})))}}function dt(t,e){let i;return function(...s){return e?(clearTimeout(i),i=setTimeout(t,e,s)):t.apply(this,s),e}}const ut=t=>"start"===t?"left":"end"===t?"right":"center",ft=(t,e,i)=>"start"===t?e:"end"===t?i:(e+i)/2,gt=(t,e,i,s)=>t===(s?"left":"right")?i:"center"===t?(e+i)/2:e;function pt(t,e,i){const n=e.length;let o=0,a=n;if(t._sorted){const{iScale:r,vScale:l,_parsed:h}=t,c=t.dataset&&t.dataset.options?t.dataset.options.spanGaps:null,d=r.axis,{min:u,max:f,minDefined:g,maxDefined:p}=r.getUserBounds();if(g){if(o=Math.min(it(h,d,u).lo,i?n:it(e,d,r.getPixelForValue(u)).lo),c){const t=h.slice(0,o+1).reverse().findIndex((t=>!s(t[l.axis])));o-=Math.max(0,t)}o=Z(o,0,n-1)}if(p){let t=Math.max(it(h,r.axis,f,!0).hi+1,i?0:it(e,d,r.getPixelForValue(f),!0).hi+1);if(c){const e=h.slice(t-1).findIndex((t=>!s(t[l.axis])));t+=Math.max(0,e)}a=Z(t,o,n)-o}else a=n-o}return{start:o,count:a}}function mt(t){const{xScale:e,yScale:i,_scaleRanges:s}=t,n={xmin:e.min,xmax:e.max,ymin:i.min,ymax:i.max};if(!s)return t._scaleRanges=n,!0;const o=s.xmin!==e.min||s.xmax!==e.max||s.ymin!==i.min||s.ymax!==i.max;return Object.assign(s,n),o}class xt{constructor(){this._request=null,this._charts=new Map,this._running=!1,this._lastDate=void 0}_notify(t,e,i,s){const n=e.listeners[s],o=e.duration;n.forEach((s=>s({chart:t,initial:e.initial,numSteps:o,currentStep:Math.min(i-e.start,o)})))}_refresh(){this._request||(this._running=!0,this._request=ht.call(window,(()=>{this._update(),this._request=null,this._running&&this._refresh()})))}_update(t=Date.now()){let e=0;this._charts.forEach(((i,s)=>{if(!i.running||!i.items.length)return;const n=i.items;let o,a=n.length-1,r=!1;for(;a>=0;--a)o=n[a],o._active?(o._total>i.duration&&(i.duration=o._total),o.tick(t),r=!0):(n[a]=n[n.length-1],n.pop());r&&(s.draw(),this._notify(s,i,t,"progress")),n.length||(i.running=!1,this._notify(s,i,t,"complete"),i.initial=!1),e+=n.length})),this._lastDate=t,0===e&&(this._running=!1)}_getAnims(t){const e=this._charts;let i=e.get(t);return i||(i={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},e.set(t,i)),i}listen(t,e,i){this._getAnims(t).listeners[e].push(i)}add(t,e){e&&e.length&&this._getAnims(t).items.push(...e)}has(t){return this._getAnims(t).items.length>0}start(t){const e=this._charts.get(t);e&&(e.running=!0,e.start=Date.now(),e.duration=e.items.reduce(((t,e)=>Math.max(t,e._duration)),0),this._refresh())}running(t){if(!this._running)return!1;const e=this._charts.get(t);return!!(e&&e.running&&e.items.length)}stop(t){const e=this._charts.get(t);if(!e||!e.items.length)return;const i=e.items;let s=i.length-1;for(;s>=0;--s)i[s].cancel();e.items=[],this._notify(t,e,Date.now(),"complete")}remove(t){return this._charts.delete(t)}}var bt=new xt; +/*! + * @kurkle/color v0.3.2 + * https://github.com/kurkle/color#readme + * (c) 2023 Jukka Kurkela + * Released under the MIT License + */function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),s=Ft(s),t.r=s[0],t.g=s[1],t.b=s[2]}}function qt(t,e){return t?Object.assign(e||{},t):t}function Kt(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?t.length>=3&&(e={r:t[0],g:t[1],b:t[2],a:255},t.length>3&&(e.a=Mt(t[3]))):(e=qt(t,{r:0,g:0,b:0,a:1})).a=Mt(e.a),e}function Gt(t){return"r"===t.charAt(0)?function(t){const e=$t.exec(t);let i,s,n,o=255;if(e){if(e[7]!==i){const t=+e[7];o=e[8]?vt(t):yt(255*t,0,255)}return i=+e[1],s=+e[3],n=+e[5],i=255&(e[2]?vt(i):yt(i,0,255)),s=255&(e[4]?vt(s):yt(s,0,255)),n=255&(e[6]?vt(n):yt(n,0,255)),{r:i,g:s,b:n,a:o}}}(t):Bt(t)}class Jt{constructor(t){if(t instanceof Jt)return t;const e=typeof t;let i;var s,n,o;"object"===e?i=Kt(t):"string"===e&&(o=(s=t).length,"#"===s[0]&&(4===o||5===o?n={r:255&17*St[s[1]],g:255&17*St[s[2]],b:255&17*St[s[3]],a:5===o?17*St[s[4]]:255}:7!==o&&9!==o||(n={r:St[s[1]]<<4|St[s[2]],g:St[s[3]]<<4|St[s[4]],b:St[s[5]]<<4|St[s[6]],a:9===o?St[s[7]]<<4|St[s[8]]:255})),i=n||jt(t)||Gt(t)),this._rgb=i,this._valid=!!i}get valid(){return this._valid}get rgb(){var t=qt(this._rgb);return t&&(t.a=wt(t.a)),t}set rgb(t){this._rgb=Kt(t)}rgbString(){return this._valid?(t=this._rgb)&&(t.a<255?`rgba(${t.r}, ${t.g}, ${t.b}, ${wt(t.a)})`:`rgb(${t.r}, ${t.g}, ${t.b})`):void 0;var t}hexString(){return this._valid?At(this._rgb):void 0}hslString(){return this._valid?function(t){if(!t)return;const e=It(t),i=e[0],s=kt(e[1]),n=kt(e[2]);return t.a<255?`hsla(${i}, ${s}%, ${n}%, ${wt(t.a)})`:`hsl(${i}, ${s}%, ${n}%)`}(this._rgb):void 0}mix(t,e){if(t){const i=this.rgb,s=t.rgb;let n;const o=e===n?.5:e,a=2*o-1,r=i.a-s.a,l=((a*r==-1?a:(a+r)/(1+a*r))+1)/2;n=1-l,i.r=255&l*i.r+n*s.r+.5,i.g=255&l*i.g+n*s.g+.5,i.b=255&l*i.b+n*s.b+.5,i.a=o*i.a+(1-o)*s.a,this.rgb=i}return this}interpolate(t,e){return t&&(this._rgb=function(t,e,i){const s=Ut(wt(t.r)),n=Ut(wt(t.g)),o=Ut(wt(t.b));return{r:Mt(Yt(s+i*(Ut(wt(e.r))-s))),g:Mt(Yt(n+i*(Ut(wt(e.g))-n))),b:Mt(Yt(o+i*(Ut(wt(e.b))-o))),a:t.a+i*(e.a-t.a)}}(this._rgb,t._rgb,e)),this}clone(){return new Jt(this.rgb)}alpha(t){return this._rgb.a=Mt(t),this}clearer(t){return this._rgb.a*=1-t,this}greyscale(){const t=this._rgb,e=_t(.3*t.r+.59*t.g+.11*t.b);return t.r=t.g=t.b=e,this}opaquer(t){return this._rgb.a*=1+t,this}negate(){const t=this._rgb;return t.r=255-t.r,t.g=255-t.g,t.b=255-t.b,this}lighten(t){return Xt(this._rgb,2,t),this}darken(t){return Xt(this._rgb,2,-t),this}saturate(t){return Xt(this._rgb,1,t),this}desaturate(t){return Xt(this._rgb,1,-t),this}rotate(t){return function(t,e){var i=It(t);i[0]=Vt(i[0]+e),i=Ft(i),t.r=i[0],t.g=i[1],t.b=i[2]}(this._rgb,t),this}}function Zt(t){if(t&&"object"==typeof t){const e=t.toString();return"[object CanvasPattern]"===e||"[object CanvasGradient]"===e}return!1}function Qt(t){return Zt(t)?t:new Jt(t)}function te(t){return Zt(t)?t:new Jt(t).saturate(.5).darken(.1).hexString()}const ee=["x","y","borderWidth","radius","tension"],ie=["color","borderColor","backgroundColor"];const se=new Map;function ne(t,e,i){return function(t,e){e=e||{};const i=t+JSON.stringify(e);let s=se.get(i);return s||(s=new Intl.NumberFormat(t,e),se.set(i,s)),s}(e,i).format(t)}const oe={values:t=>n(t)?t:""+t,numeric(t,e,i){if(0===t)return"0";const s=this.chart.options.locale;let n,o=t;if(i.length>1){const e=Math.max(Math.abs(i[0].value),Math.abs(i[i.length-1].value));(e<1e-4||e>1e15)&&(n="scientific"),o=function(t,e){let i=e.length>3?e[2].value-e[1].value:e[1].value-e[0].value;Math.abs(i)>=1&&t!==Math.floor(t)&&(i=t-Math.floor(t));return i}(t,i)}const a=z(Math.abs(o)),r=isNaN(a)?1:Math.max(Math.min(-1*Math.floor(a),20),0),l={notation:n,minimumFractionDigits:r,maximumFractionDigits:r};return Object.assign(l,this.options.ticks.format),ne(t,s,l)},logarithmic(t,e,i){if(0===t)return"0";const s=i[e].significand||t/Math.pow(10,Math.floor(z(t)));return[1,2,3,5,10,15].includes(s)||e>.8*i.length?oe.numeric.call(this,t,e,i):""}};var ae={formatters:oe};const re=Object.create(null),le=Object.create(null);function he(t,e){if(!e)return t;const i=e.split(".");for(let e=0,s=i.length;et.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(t,e)=>te(e.backgroundColor),this.hoverBorderColor=(t,e)=>te(e.borderColor),this.hoverColor=(t,e)=>te(e.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(t),this.apply(e)}set(t,e){return ce(this,t,e)}get(t){return he(this,t)}describe(t,e){return ce(le,t,e)}override(t,e){return ce(re,t,e)}route(t,e,i,s){const n=he(this,t),a=he(this,i),r="_"+e;Object.defineProperties(n,{[r]:{value:n[e],writable:!0},[e]:{enumerable:!0,get(){const t=this[r],e=a[s];return o(t)?Object.assign({},e,t):l(t,e)},set(t){this[r]=t}}})}apply(t){t.forEach((t=>t(this)))}}var ue=new de({_scriptable:t=>!t.startsWith("on"),_indexable:t=>"events"!==t,hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}},[function(t){t.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),t.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:t=>"onProgress"!==t&&"onComplete"!==t&&"fn"!==t}),t.set("animations",{colors:{type:"color",properties:ie},numbers:{type:"number",properties:ee}}),t.describe("animations",{_fallback:"animation"}),t.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:t=>0|t}}}})},function(t){t.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})},function(t){t.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(t,e)=>e.lineWidth,tickColor:(t,e)=>e.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:ae.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),t.route("scale.ticks","color","","color"),t.route("scale.grid","color","","borderColor"),t.route("scale.border","color","","borderColor"),t.route("scale.title","color","","color"),t.describe("scale",{_fallback:!1,_scriptable:t=>!t.startsWith("before")&&!t.startsWith("after")&&"callback"!==t&&"parser"!==t,_indexable:t=>"borderDash"!==t&&"tickBorderDash"!==t&&"dash"!==t}),t.describe("scales",{_fallback:"scale"}),t.describe("scale.ticks",{_scriptable:t=>"backdropPadding"!==t&&"callback"!==t,_indexable:t=>"backdropPadding"!==t})}]);function fe(){return"undefined"!=typeof window&&"undefined"!=typeof document}function ge(t){let e=t.parentNode;return e&&"[object ShadowRoot]"===e.toString()&&(e=e.host),e}function pe(t,e,i){let s;return"string"==typeof t?(s=parseInt(t,10),-1!==t.indexOf("%")&&(s=s/100*e.parentNode[i])):s=t,s}const me=t=>t.ownerDocument.defaultView.getComputedStyle(t,null);function xe(t,e){return me(t).getPropertyValue(e)}const be=["top","right","bottom","left"];function _e(t,e,i){const s={};i=i?"-"+i:"";for(let n=0;n<4;n++){const o=be[n];s[o]=parseFloat(t[e+"-"+o+i])||0}return s.width=s.left+s.right,s.height=s.top+s.bottom,s}const ye=(t,e,i)=>(t>0||e>0)&&(!i||!i.shadowRoot);function ve(t,e){if("native"in t)return t;const{canvas:i,currentDevicePixelRatio:s}=e,n=me(i),o="border-box"===n.boxSizing,a=_e(n,"padding"),r=_e(n,"border","width"),{x:l,y:h,box:c}=function(t,e){const i=t.touches,s=i&&i.length?i[0]:t,{offsetX:n,offsetY:o}=s;let a,r,l=!1;if(ye(n,o,t.target))a=n,r=o;else{const t=e.getBoundingClientRect();a=s.clientX-t.left,r=s.clientY-t.top,l=!0}return{x:a,y:r,box:l}}(t,i),d=a.left+(c&&r.left),u=a.top+(c&&r.top);let{width:f,height:g}=e;return o&&(f-=a.width+r.width,g-=a.height+r.height),{x:Math.round((l-d)/f*i.width/s),y:Math.round((h-u)/g*i.height/s)}}const Me=t=>Math.round(10*t)/10;function we(t,e,i,s){const n=me(t),o=_e(n,"margin"),a=pe(n.maxWidth,t,"clientWidth")||T,r=pe(n.maxHeight,t,"clientHeight")||T,l=function(t,e,i){let s,n;if(void 0===e||void 0===i){const o=t&&ge(t);if(o){const t=o.getBoundingClientRect(),a=me(o),r=_e(a,"border","width"),l=_e(a,"padding");e=t.width-l.width-r.width,i=t.height-l.height-r.height,s=pe(a.maxWidth,o,"clientWidth"),n=pe(a.maxHeight,o,"clientHeight")}else e=t.clientWidth,i=t.clientHeight}return{width:e,height:i,maxWidth:s||T,maxHeight:n||T}}(t,e,i);let{width:h,height:c}=l;if("content-box"===n.boxSizing){const t=_e(n,"border","width"),e=_e(n,"padding");h-=e.width+t.width,c-=e.height+t.height}h=Math.max(0,h-o.width),c=Math.max(0,s?h/s:c-o.height),h=Me(Math.min(h,a,l.maxWidth)),c=Me(Math.min(c,r,l.maxHeight)),h&&!c&&(c=Me(h/2));return(void 0!==e||void 0!==i)&&s&&l.height&&c>l.height&&(c=l.height,h=Me(Math.floor(c*s))),{width:h,height:c}}function ke(t,e,i){const s=e||1,n=Math.floor(t.height*s),o=Math.floor(t.width*s);t.height=Math.floor(t.height),t.width=Math.floor(t.width);const a=t.canvas;return a.style&&(i||!a.style.height&&!a.style.width)&&(a.style.height=`${t.height}px`,a.style.width=`${t.width}px`),(t.currentDevicePixelRatio!==s||a.height!==n||a.width!==o)&&(t.currentDevicePixelRatio=s,a.height=n,a.width=o,t.ctx.setTransform(s,0,0,s,0,0),!0)}const Se=function(){let t=!1;try{const e={get passive(){return t=!0,!1}};fe()&&(window.addEventListener("test",null,e),window.removeEventListener("test",null,e))}catch(t){}return t}();function Pe(t,e){const i=xe(t,e),s=i&&i.match(/^(\d+)(\.\d+)?px$/);return s?+s[1]:void 0}function De(t){return!t||s(t.size)||s(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family}function Ce(t,e,i,s,n){let o=e[n];return o||(o=e[n]=t.measureText(n).width,i.push(n)),o>s&&(s=o),s}function Oe(t,e,i,s){let o=(s=s||{}).data=s.data||{},a=s.garbageCollect=s.garbageCollect||[];s.font!==e&&(o=s.data={},a=s.garbageCollect=[],s.font=e),t.save(),t.font=e;let r=0;const l=i.length;let h,c,d,u,f;for(h=0;hi.length){for(h=0;h0&&t.stroke()}}function Re(t,e,i){return i=i||.5,!e||t&&t.x>e.left-i&&t.xe.top-i&&t.y0&&""!==r.strokeColor;let c,d;for(t.save(),t.font=a.string,function(t,e){e.translation&&t.translate(e.translation[0],e.translation[1]),s(e.rotation)||t.rotate(e.rotation),e.color&&(t.fillStyle=e.color),e.textAlign&&(t.textAlign=e.textAlign),e.textBaseline&&(t.textBaseline=e.textBaseline)}(t,r),c=0;ct[0])){const o=i||t;void 0===s&&(s=ti("_fallback",t));const a={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:t,_rootScopes:o,_fallback:s,_getTarget:n,override:i=>je([i,...t],e,o,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete e._keys,delete t[0][i],!0),get:(i,s)=>qe(i,s,(()=>function(t,e,i,s){let n;for(const o of e)if(n=ti(Ue(o,t),i),void 0!==n)return Xe(t,n)?Ze(i,s,t,n):n}(s,e,t,i))),getOwnPropertyDescriptor:(t,e)=>Reflect.getOwnPropertyDescriptor(t._scopes[0],e),getPrototypeOf:()=>Reflect.getPrototypeOf(t[0]),has:(t,e)=>ei(t).includes(e),ownKeys:t=>ei(t),set(t,e,i){const s=t._storage||(t._storage=n());return t[e]=s[e]=i,delete t._keys,!0}})}function $e(t,e,i,s){const a={_cacheable:!1,_proxy:t,_context:e,_subProxy:i,_stack:new Set,_descriptors:Ye(t,s),setContext:e=>$e(t,e,i,s),override:n=>$e(t.override(n),e,i,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete t[i],!0),get:(t,e,i)=>qe(t,e,(()=>function(t,e,i){const{_proxy:s,_context:a,_subProxy:r,_descriptors:l}=t;let h=s[e];S(h)&&l.isScriptable(e)&&(h=function(t,e,i,s){const{_proxy:n,_context:o,_subProxy:a,_stack:r}=i;if(r.has(t))throw new Error("Recursion detected: "+Array.from(r).join("->")+"->"+t);r.add(t);let l=e(o,a||s);r.delete(t),Xe(t,l)&&(l=Ze(n._scopes,n,t,l));return l}(e,h,t,i));n(h)&&h.length&&(h=function(t,e,i,s){const{_proxy:n,_context:a,_subProxy:r,_descriptors:l}=i;if(void 0!==a.index&&s(t))return e[a.index%e.length];if(o(e[0])){const i=e,s=n._scopes.filter((t=>t!==i));e=[];for(const o of i){const i=Ze(s,n,t,o);e.push($e(i,a,r&&r[t],l))}}return e}(e,h,t,l.isIndexable));Xe(e,h)&&(h=$e(h,a,r&&r[e],l));return h}(t,e,i))),getOwnPropertyDescriptor:(e,i)=>e._descriptors.allKeys?Reflect.has(t,i)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(t,i),getPrototypeOf:()=>Reflect.getPrototypeOf(t),has:(e,i)=>Reflect.has(t,i),ownKeys:()=>Reflect.ownKeys(t),set:(e,i,s)=>(t[i]=s,delete e[i],!0)})}function Ye(t,e={scriptable:!0,indexable:!0}){const{_scriptable:i=e.scriptable,_indexable:s=e.indexable,_allKeys:n=e.allKeys}=t;return{allKeys:n,scriptable:i,indexable:s,isScriptable:S(i)?i:()=>i,isIndexable:S(s)?s:()=>s}}const Ue=(t,e)=>t?t+w(e):e,Xe=(t,e)=>o(e)&&"adapters"!==t&&(null===Object.getPrototypeOf(e)||e.constructor===Object);function qe(t,e,i){if(Object.prototype.hasOwnProperty.call(t,e)||"constructor"===e)return t[e];const s=i();return t[e]=s,s}function Ke(t,e,i){return S(t)?t(e,i):t}const Ge=(t,e)=>!0===t?e:"string"==typeof t?M(e,t):void 0;function Je(t,e,i,s,n){for(const o of e){const e=Ge(i,o);if(e){t.add(e);const o=Ke(e._fallback,i,n);if(void 0!==o&&o!==i&&o!==s)return o}else if(!1===e&&void 0!==s&&i!==s)return null}return!1}function Ze(t,e,i,s){const a=e._rootScopes,r=Ke(e._fallback,i,s),l=[...t,...a],h=new Set;h.add(s);let c=Qe(h,l,i,r||i,s);return null!==c&&((void 0===r||r===i||(c=Qe(h,l,r,c,s),null!==c))&&je(Array.from(h),[""],a,r,(()=>function(t,e,i){const s=t._getTarget();e in s||(s[e]={});const a=s[e];if(n(a)&&o(i))return i;return a||{}}(e,i,s))))}function Qe(t,e,i,s,n){for(;i;)i=Je(t,e,i,s,n);return i}function ti(t,e){for(const i of e){if(!i)continue;const e=i[t];if(void 0!==e)return e}}function ei(t){let e=t._keys;return e||(e=t._keys=function(t){const e=new Set;for(const i of t)for(const t of Object.keys(i).filter((t=>!t.startsWith("_"))))e.add(t);return Array.from(e)}(t._scopes)),e}function ii(t,e,i,s){const{iScale:n}=t,{key:o="r"}=this._parsing,a=new Array(s);let r,l,h,c;for(r=0,l=s;re"x"===t?"y":"x";function ai(t,e,i,s){const n=t.skip?e:t,o=e,a=i.skip?e:i,r=q(o,n),l=q(a,o);let h=r/(r+l),c=l/(r+l);h=isNaN(h)?0:h,c=isNaN(c)?0:c;const d=s*h,u=s*c;return{previous:{x:o.x-d*(a.x-n.x),y:o.y-d*(a.y-n.y)},next:{x:o.x+u*(a.x-n.x),y:o.y+u*(a.y-n.y)}}}function ri(t,e="x"){const i=oi(e),s=t.length,n=Array(s).fill(0),o=Array(s);let a,r,l,h=ni(t,0);for(a=0;a!t.skip))),"monotone"===e.cubicInterpolationMode)ri(t,n);else{let i=s?t[t.length-1]:t[0];for(o=0,a=t.length;o0===t||1===t,di=(t,e,i)=>-Math.pow(2,10*(t-=1))*Math.sin((t-e)*O/i),ui=(t,e,i)=>Math.pow(2,-10*t)*Math.sin((t-e)*O/i)+1,fi={linear:t=>t,easeInQuad:t=>t*t,easeOutQuad:t=>-t*(t-2),easeInOutQuad:t=>(t/=.5)<1?.5*t*t:-.5*(--t*(t-2)-1),easeInCubic:t=>t*t*t,easeOutCubic:t=>(t-=1)*t*t+1,easeInOutCubic:t=>(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2),easeInQuart:t=>t*t*t*t,easeOutQuart:t=>-((t-=1)*t*t*t-1),easeInOutQuart:t=>(t/=.5)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2),easeInQuint:t=>t*t*t*t*t,easeOutQuint:t=>(t-=1)*t*t*t*t+1,easeInOutQuint:t=>(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2),easeInSine:t=>1-Math.cos(t*E),easeOutSine:t=>Math.sin(t*E),easeInOutSine:t=>-.5*(Math.cos(C*t)-1),easeInExpo:t=>0===t?0:Math.pow(2,10*(t-1)),easeOutExpo:t=>1===t?1:1-Math.pow(2,-10*t),easeInOutExpo:t=>ci(t)?t:t<.5?.5*Math.pow(2,10*(2*t-1)):.5*(2-Math.pow(2,-10*(2*t-1))),easeInCirc:t=>t>=1?t:-(Math.sqrt(1-t*t)-1),easeOutCirc:t=>Math.sqrt(1-(t-=1)*t),easeInOutCirc:t=>(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1),easeInElastic:t=>ci(t)?t:di(t,.075,.3),easeOutElastic:t=>ci(t)?t:ui(t,.075,.3),easeInOutElastic(t){const e=.1125;return ci(t)?t:t<.5?.5*di(2*t,e,.45):.5+.5*ui(2*t-1,e,.45)},easeInBack(t){const e=1.70158;return t*t*((e+1)*t-e)},easeOutBack(t){const e=1.70158;return(t-=1)*t*((e+1)*t+e)+1},easeInOutBack(t){let e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:t=>1-fi.easeOutBounce(1-t),easeOutBounce(t){const e=7.5625,i=2.75;return t<1/i?e*t*t:t<2/i?e*(t-=1.5/i)*t+.75:t<2.5/i?e*(t-=2.25/i)*t+.9375:e*(t-=2.625/i)*t+.984375},easeInOutBounce:t=>t<.5?.5*fi.easeInBounce(2*t):.5*fi.easeOutBounce(2*t-1)+.5};function gi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:t.y+i*(e.y-t.y)}}function pi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:"middle"===s?i<.5?t.y:e.y:"after"===s?i<1?t.y:e.y:i>0?e.y:t.y}}function mi(t,e,i,s){const n={x:t.cp2x,y:t.cp2y},o={x:e.cp1x,y:e.cp1y},a=gi(t,n,i),r=gi(n,o,i),l=gi(o,e,i),h=gi(a,r,i),c=gi(r,l,i);return gi(h,c,i)}const xi=/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/,bi=/^(normal|italic|initial|inherit|unset|(oblique( -?[0-9]?[0-9]deg)?))$/;function _i(t,e){const i=(""+t).match(xi);if(!i||"normal"===i[1])return 1.2*e;switch(t=+i[2],i[3]){case"px":return t;case"%":t/=100}return e*t}const yi=t=>+t||0;function vi(t,e){const i={},s=o(e),n=s?Object.keys(e):e,a=o(t)?s?i=>l(t[i],t[e[i]]):e=>t[e]:()=>t;for(const t of n)i[t]=yi(a(t));return i}function Mi(t){return vi(t,{top:"y",right:"x",bottom:"y",left:"x"})}function wi(t){return vi(t,["topLeft","topRight","bottomLeft","bottomRight"])}function ki(t){const e=Mi(t);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function Si(t,e){t=t||{},e=e||ue.font;let i=l(t.size,e.size);"string"==typeof i&&(i=parseInt(i,10));let s=l(t.style,e.style);s&&!(""+s).match(bi)&&(console.warn('Invalid font style specified: "'+s+'"'),s=void 0);const n={family:l(t.family,e.family),lineHeight:_i(l(t.lineHeight,e.lineHeight),i),size:i,style:s,weight:l(t.weight,e.weight),string:""};return n.string=De(n),n}function Pi(t,e,i,s){let o,a,r,l=!0;for(o=0,a=t.length;oi&&0===t?0:t+e;return{min:a(s,-Math.abs(o)),max:a(n,o)}}function Ci(t,e){return Object.assign(Object.create(t),e)}function Oi(t,e,i){return t?function(t,e){return{x:i=>t+t+e-i,setWidth(t){e=t},textAlign:t=>"center"===t?t:"right"===t?"left":"right",xPlus:(t,e)=>t-e,leftForLtr:(t,e)=>t-e}}(e,i):{x:t=>t,setWidth(t){},textAlign:t=>t,xPlus:(t,e)=>t+e,leftForLtr:(t,e)=>t}}function Ai(t,e){let i,s;"ltr"!==e&&"rtl"!==e||(i=t.canvas.style,s=[i.getPropertyValue("direction"),i.getPropertyPriority("direction")],i.setProperty("direction",e,"important"),t.prevTextDirection=s)}function Ti(t,e){void 0!==e&&(delete t.prevTextDirection,t.canvas.style.setProperty("direction",e[0],e[1]))}function Li(t){return"angle"===t?{between:J,compare:K,normalize:G}:{between:tt,compare:(t,e)=>t-e,normalize:t=>t}}function Ei({start:t,end:e,count:i,loop:s,style:n}){return{start:t%i,end:e%i,loop:s&&(e-t+1)%i==0,style:n}}function Ri(t,e,i){if(!i)return[t];const{property:s,start:n,end:o}=i,a=e.length,{compare:r,between:l,normalize:h}=Li(s),{start:c,end:d,loop:u,style:f}=function(t,e,i){const{property:s,start:n,end:o}=i,{between:a,normalize:r}=Li(s),l=e.length;let h,c,{start:d,end:u,loop:f}=t;if(f){for(d+=l,u+=l,h=0,c=l;hb||l(n,x,p)&&0!==r(n,x),v=()=>!b||0===r(o,p)||l(o,x,p);for(let t=c,i=c;t<=d;++t)m=e[t%a],m.skip||(p=h(m[s]),p!==x&&(b=l(p,n,o),null===_&&y()&&(_=0===r(p,n)?t:i),null!==_&&v()&&(g.push(Ei({start:_,end:t,loop:u,count:a,style:f})),_=null),i=t,x=p));return null!==_&&g.push(Ei({start:_,end:d,loop:u,count:a,style:f})),g}function Ii(t,e){const i=[],s=t.segments;for(let n=0;nn&&t[o%e].skip;)o--;return o%=e,{start:n,end:o}}(i,n,o,s);if(!0===s)return Fi(t,[{start:a,end:r,loop:o}],i,e);return Fi(t,function(t,e,i,s){const n=t.length,o=[];let a,r=e,l=t[e];for(a=e+1;a<=i;++a){const i=t[a%n];i.skip||i.stop?l.skip||(s=!1,o.push({start:e%n,end:(a-1)%n,loop:s}),e=r=i.stop?a:null):(r=a,l.skip&&(e=a)),l=i}return null!==r&&o.push({start:e%n,end:r%n,loop:s}),o}(i,a,r!s(t[e.axis])));n.lo-=Math.max(0,a);const r=i.slice(n.hi).findIndex((t=>!s(t[e.axis])));n.hi+=Math.max(0,r)}return n}if(o._sharedOptions){const t=a[0],s="function"==typeof t.getRange&&t.getRange(e);if(s){const t=r(a,e,i-s),n=r(a,e,i+s);return{lo:t.lo,hi:n.hi}}}}return{lo:0,hi:a.length-1}}function $i(t,e,i,s,n){const o=t.getSortedVisibleDatasetMetas(),a=i[e];for(let t=0,i=o.length;t{t[a]&&t[a](e[i],n)&&(o.push({element:t,datasetIndex:s,index:l}),r=r||t.inRange(e.x,e.y,n))})),s&&!r?[]:o}var Ki={evaluateInteractionItems:$i,modes:{index(t,e,i,s){const n=ve(e,t),o=i.axis||"x",a=i.includeInvisible||!1,r=i.intersect?Yi(t,n,o,s,a):Xi(t,n,o,!1,s,a),l=[];return r.length?(t.getSortedVisibleDatasetMetas().forEach((t=>{const e=r[0].index,i=t.data[e];i&&!i.skip&&l.push({element:i,datasetIndex:t.index,index:e})})),l):[]},dataset(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;let r=i.intersect?Yi(t,n,o,s,a):Xi(t,n,o,!1,s,a);if(r.length>0){const e=r[0].datasetIndex,i=t.getDatasetMeta(e).data;r=[];for(let t=0;tYi(t,ve(e,t),i.axis||"xy",s,i.includeInvisible||!1),nearest(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;return Xi(t,n,o,i.intersect,s,a)},x:(t,e,i,s)=>qi(t,ve(e,t),"x",i.intersect,s),y:(t,e,i,s)=>qi(t,ve(e,t),"y",i.intersect,s)}};const Gi=["left","top","right","bottom"];function Ji(t,e){return t.filter((t=>t.pos===e))}function Zi(t,e){return t.filter((t=>-1===Gi.indexOf(t.pos)&&t.box.axis===e))}function Qi(t,e){return t.sort(((t,i)=>{const s=e?i:t,n=e?t:i;return s.weight===n.weight?s.index-n.index:s.weight-n.weight}))}function ts(t,e){const i=function(t){const e={};for(const i of t){const{stack:t,pos:s,stackWeight:n}=i;if(!t||!Gi.includes(s))continue;const o=e[t]||(e[t]={count:0,placed:0,weight:0,size:0});o.count++,o.weight+=n}return e}(t),{vBoxMaxWidth:s,hBoxMaxHeight:n}=e;let o,a,r;for(o=0,a=t.length;o{s[t]=Math.max(e[t],i[t])})),s}return s(t?["left","right"]:["top","bottom"])}function os(t,e,i,s){const n=[];let o,a,r,l,h,c;for(o=0,a=t.length,h=0;ot.box.fullSize)),!0),s=Qi(Ji(e,"left"),!0),n=Qi(Ji(e,"right")),o=Qi(Ji(e,"top"),!0),a=Qi(Ji(e,"bottom")),r=Zi(e,"x"),l=Zi(e,"y");return{fullSize:i,leftAndTop:s.concat(o),rightAndBottom:n.concat(l).concat(a).concat(r),chartArea:Ji(e,"chartArea"),vertical:s.concat(n).concat(l),horizontal:o.concat(a).concat(r)}}(t.boxes),l=r.vertical,h=r.horizontal;u(t.boxes,(t=>{"function"==typeof t.beforeLayout&&t.beforeLayout()}));const c=l.reduce(((t,e)=>e.box.options&&!1===e.box.options.display?t:t+1),0)||1,d=Object.freeze({outerWidth:e,outerHeight:i,padding:n,availableWidth:o,availableHeight:a,vBoxMaxWidth:o/2/c,hBoxMaxHeight:a/2}),f=Object.assign({},n);is(f,ki(s));const g=Object.assign({maxPadding:f,w:o,h:a,x:n.left,y:n.top},n),p=ts(l.concat(h),d);os(r.fullSize,g,d,p),os(l,g,d,p),os(h,g,d,p)&&os(l,g,d,p),function(t){const e=t.maxPadding;function i(i){const s=Math.max(e[i]-t[i],0);return t[i]+=s,s}t.y+=i("top"),t.x+=i("left"),i("right"),i("bottom")}(g),rs(r.leftAndTop,g,d,p),g.x+=g.w,g.y+=g.h,rs(r.rightAndBottom,g,d,p),t.chartArea={left:g.left,top:g.top,right:g.left+g.w,bottom:g.top+g.h,height:g.h,width:g.w},u(r.chartArea,(e=>{const i=e.box;Object.assign(i,t.chartArea),i.update(g.w,g.h,{left:0,top:0,right:0,bottom:0})}))}};class hs{acquireContext(t,e){}releaseContext(t){return!1}addEventListener(t,e,i){}removeEventListener(t,e,i){}getDevicePixelRatio(){return 1}getMaximumSize(t,e,i,s){return e=Math.max(0,e||t.width),i=i||t.height,{width:e,height:Math.max(0,s?Math.floor(e/s):i)}}isAttached(t){return!0}updateConfig(t){}}class cs extends hs{acquireContext(t){return t&&t.getContext&&t.getContext("2d")||null}updateConfig(t){t.options.animation=!1}}const ds="$chartjs",us={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},fs=t=>null===t||""===t;const gs=!!Se&&{passive:!0};function ps(t,e,i){t&&t.canvas&&t.canvas.removeEventListener(e,i,gs)}function ms(t,e){for(const i of t)if(i===e||i.contains(e))return!0}function xs(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||ms(i.addedNodes,s),e=e&&!ms(i.removedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}function bs(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||ms(i.removedNodes,s),e=e&&!ms(i.addedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}const _s=new Map;let ys=0;function vs(){const t=window.devicePixelRatio;t!==ys&&(ys=t,_s.forEach(((e,i)=>{i.currentDevicePixelRatio!==t&&e()})))}function Ms(t,e,i){const s=t.canvas,n=s&&ge(s);if(!n)return;const o=ct(((t,e)=>{const s=n.clientWidth;i(t,e),s{const e=t[0],i=e.contentRect.width,s=e.contentRect.height;0===i&&0===s||o(i,s)}));return a.observe(n),function(t,e){_s.size||window.addEventListener("resize",vs),_s.set(t,e)}(t,o),a}function ws(t,e,i){i&&i.disconnect(),"resize"===e&&function(t){_s.delete(t),_s.size||window.removeEventListener("resize",vs)}(t)}function ks(t,e,i){const s=t.canvas,n=ct((e=>{null!==t.ctx&&i(function(t,e){const i=us[t.type]||t.type,{x:s,y:n}=ve(t,e);return{type:i,chart:e,native:t,x:void 0!==s?s:null,y:void 0!==n?n:null}}(e,t))}),t);return function(t,e,i){t&&t.addEventListener(e,i,gs)}(s,e,n),n}class Ss extends hs{acquireContext(t,e){const i=t&&t.getContext&&t.getContext("2d");return i&&i.canvas===t?(function(t,e){const i=t.style,s=t.getAttribute("height"),n=t.getAttribute("width");if(t[ds]={initial:{height:s,width:n,style:{display:i.display,height:i.height,width:i.width}}},i.display=i.display||"block",i.boxSizing=i.boxSizing||"border-box",fs(n)){const e=Pe(t,"width");void 0!==e&&(t.width=e)}if(fs(s))if(""===t.style.height)t.height=t.width/(e||2);else{const e=Pe(t,"height");void 0!==e&&(t.height=e)}}(t,e),i):null}releaseContext(t){const e=t.canvas;if(!e[ds])return!1;const i=e[ds].initial;["height","width"].forEach((t=>{const n=i[t];s(n)?e.removeAttribute(t):e.setAttribute(t,n)}));const n=i.style||{};return Object.keys(n).forEach((t=>{e.style[t]=n[t]})),e.width=e.width,delete e[ds],!0}addEventListener(t,e,i){this.removeEventListener(t,e);const s=t.$proxies||(t.$proxies={}),n={attach:xs,detach:bs,resize:Ms}[e]||ks;s[e]=n(t,e,i)}removeEventListener(t,e){const i=t.$proxies||(t.$proxies={}),s=i[e];if(!s)return;({attach:ws,detach:ws,resize:ws}[e]||ps)(t,e,s),i[e]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(t,e,i,s){return we(t,e,i,s)}isAttached(t){const e=t&&ge(t);return!(!e||!e.isConnected)}}function Ps(t){return!fe()||"undefined"!=typeof OffscreenCanvas&&t instanceof OffscreenCanvas?cs:Ss}var Ds=Object.freeze({__proto__:null,BasePlatform:hs,BasicPlatform:cs,DomPlatform:Ss,_detectPlatform:Ps});const Cs="transparent",Os={boolean:(t,e,i)=>i>.5?e:t,color(t,e,i){const s=Qt(t||Cs),n=s.valid&&Qt(e||Cs);return n&&n.valid?n.mix(s,i).hexString():e},number:(t,e,i)=>t+(e-t)*i};class As{constructor(t,e,i,s){const n=e[i];s=Pi([t.to,s,n,t.from]);const o=Pi([t.from,n,s]);this._active=!0,this._fn=t.fn||Os[t.type||typeof o],this._easing=fi[t.easing]||fi.linear,this._start=Math.floor(Date.now()+(t.delay||0)),this._duration=this._total=Math.floor(t.duration),this._loop=!!t.loop,this._target=e,this._prop=i,this._from=o,this._to=s,this._promises=void 0}active(){return this._active}update(t,e,i){if(this._active){this._notify(!1);const s=this._target[this._prop],n=i-this._start,o=this._duration-n;this._start=i,this._duration=Math.floor(Math.max(o,t.duration)),this._total+=n,this._loop=!!t.loop,this._to=Pi([t.to,e,s,t.from]),this._from=Pi([t.from,s,e])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(t){const e=t-this._start,i=this._duration,s=this._prop,n=this._from,o=this._loop,a=this._to;let r;if(this._active=n!==a&&(o||e1?2-r:r,r=this._easing(Math.min(1,Math.max(0,r))),this._target[s]=this._fn(n,a,r))}wait(){const t=this._promises||(this._promises=[]);return new Promise(((e,i)=>{t.push({res:e,rej:i})}))}_notify(t){const e=t?"res":"rej",i=this._promises||[];for(let t=0;t{const a=t[s];if(!o(a))return;const r={};for(const t of e)r[t]=a[t];(n(a.properties)&&a.properties||[s]).forEach((t=>{t!==s&&i.has(t)||i.set(t,r)}))}))}_animateOptions(t,e){const i=e.options,s=function(t,e){if(!e)return;let i=t.options;if(!i)return void(t.options=e);i.$shared&&(t.options=i=Object.assign({},i,{$shared:!1,$animations:{}}));return i}(t,i);if(!s)return[];const n=this._createAnimations(s,i);return i.$shared&&function(t,e){const i=[],s=Object.keys(e);for(let e=0;e{t.options=i}),(()=>{})),n}_createAnimations(t,e){const i=this._properties,s=[],n=t.$animations||(t.$animations={}),o=Object.keys(e),a=Date.now();let r;for(r=o.length-1;r>=0;--r){const l=o[r];if("$"===l.charAt(0))continue;if("options"===l){s.push(...this._animateOptions(t,e));continue}const h=e[l];let c=n[l];const d=i.get(l);if(c){if(d&&c.active()){c.update(d,h,a);continue}c.cancel()}d&&d.duration?(n[l]=c=new As(d,t,l,h),s.push(c)):t[l]=h}return s}update(t,e){if(0===this._properties.size)return void Object.assign(t,e);const i=this._createAnimations(t,e);return i.length?(bt.add(this._chart,i),!0):void 0}}function Ls(t,e){const i=t&&t.options||{},s=i.reverse,n=void 0===i.min?e:0,o=void 0===i.max?e:0;return{start:s?o:n,end:s?n:o}}function Es(t,e){const i=[],s=t._getSortedDatasetMetas(e);let n,o;for(n=0,o=s.length;n0||!i&&e<0)return n.index}return null}function Vs(t,e){const{chart:i,_cachedMeta:s}=t,n=i._stacks||(i._stacks={}),{iScale:o,vScale:a,index:r}=s,l=o.axis,h=a.axis,c=function(t,e,i){return`${t.id}.${e.id}.${i.stack||i.type}`}(o,a,s),d=e.length;let u;for(let t=0;ti[t].axis===e)).shift()}function Ws(t,e){const i=t.controller.index,s=t.vScale&&t.vScale.axis;if(s){e=e||t._parsed;for(const t of e){const e=t._stacks;if(!e||void 0===e[s]||void 0===e[s][i])return;delete e[s][i],void 0!==e[s]._visualValues&&void 0!==e[s]._visualValues[i]&&delete e[s]._visualValues[i]}}}const Ns=t=>"reset"===t||"none"===t,Hs=(t,e)=>e?t:Object.assign({},t);class js{static defaults={};static datasetElementType=null;static dataElementType=null;constructor(t,e){this.chart=t,this._ctx=t.ctx,this.index=e,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){const t=this._cachedMeta;this.configure(),this.linkScales(),t._stacked=Is(t.vScale,t),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled("filler")&&console.warn("Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options")}updateIndex(t){this.index!==t&&Ws(this._cachedMeta),this.index=t}linkScales(){const t=this.chart,e=this._cachedMeta,i=this.getDataset(),s=(t,e,i,s)=>"x"===t?e:"r"===t?s:i,n=e.xAxisID=l(i.xAxisID,Bs(t,"x")),o=e.yAxisID=l(i.yAxisID,Bs(t,"y")),a=e.rAxisID=l(i.rAxisID,Bs(t,"r")),r=e.indexAxis,h=e.iAxisID=s(r,n,o,a),c=e.vAxisID=s(r,o,n,a);e.xScale=this.getScaleForId(n),e.yScale=this.getScaleForId(o),e.rScale=this.getScaleForId(a),e.iScale=this.getScaleForId(h),e.vScale=this.getScaleForId(c)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(t){return this.chart.scales[t]}_getOtherScale(t){const e=this._cachedMeta;return t===e.iScale?e.vScale:e.iScale}reset(){this._update("reset")}_destroy(){const t=this._cachedMeta;this._data&&rt(this._data,this),t._stacked&&Ws(t)}_dataCheck(){const t=this.getDataset(),e=t.data||(t.data=[]),i=this._data;if(o(e)){const t=this._cachedMeta;this._data=function(t,e){const{iScale:i,vScale:s}=e,n="x"===i.axis?"x":"y",o="x"===s.axis?"x":"y",a=Object.keys(t),r=new Array(a.length);let l,h,c;for(l=0,h=a.length;l0&&i._parsed[t-1];if(!1===this._parsing)i._parsed=s,i._sorted=!0,d=s;else{d=n(s[t])?this.parseArrayData(i,s,t,e):o(s[t])?this.parseObjectData(i,s,t,e):this.parsePrimitiveData(i,s,t,e);const a=()=>null===c[l]||f&&c[l]t&&!e.hidden&&e._stacked&&{keys:Es(i,!0),values:null})(e,i,this.chart),h={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY},{min:c,max:d}=function(t){const{min:e,max:i,minDefined:s,maxDefined:n}=t.getUserBounds();return{min:s?e:Number.NEGATIVE_INFINITY,max:n?i:Number.POSITIVE_INFINITY}}(r);let u,f;function g(){f=s[u];const e=f[r.axis];return!a(f[t.axis])||c>e||d=0;--u)if(!g()){this.updateRangeFromParsed(h,t,f,l);break}return h}getAllParsedValues(t){const e=this._cachedMeta._parsed,i=[];let s,n,o;for(s=0,n=e.length;s=0&&tthis.getContext(i,s,e)),c);return f.$shared&&(f.$shared=r,n[o]=Object.freeze(Hs(f,r))),f}_resolveAnimations(t,e,i){const s=this.chart,n=this._cachedDataOpts,o=`animation-${e}`,a=n[o];if(a)return a;let r;if(!1!==s.options.animation){const s=this.chart.config,n=s.datasetAnimationScopeKeys(this._type,e),o=s.getOptionScopes(this.getDataset(),n);r=s.createResolver(o,this.getContext(t,i,e))}const l=new Ts(s,r&&r.animations);return r&&r._cacheable&&(n[o]=Object.freeze(l)),l}getSharedOptions(t){if(t.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},t))}includeOptions(t,e){return!e||Ns(t)||this.chart._animationsDisabled}_getSharedOptions(t,e){const i=this.resolveDataElementOptions(t,e),s=this._sharedOptions,n=this.getSharedOptions(i),o=this.includeOptions(e,n)||n!==s;return this.updateSharedOptions(n,e,i),{sharedOptions:n,includeOptions:o}}updateElement(t,e,i,s){Ns(s)?Object.assign(t,i):this._resolveAnimations(e,s).update(t,i)}updateSharedOptions(t,e,i){t&&!Ns(e)&&this._resolveAnimations(void 0,e).update(t,i)}_setStyle(t,e,i,s){t.active=s;const n=this.getStyle(e,s);this._resolveAnimations(e,i,s).update(t,{options:!s&&this.getSharedOptions(n)||n})}removeHoverStyle(t,e,i){this._setStyle(t,i,"active",!1)}setHoverStyle(t,e,i){this._setStyle(t,i,"active",!0)}_removeDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!1)}_setDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!0)}_resyncElements(t){const e=this._data,i=this._cachedMeta.data;for(const[t,e,i]of this._syncList)this[t](e,i);this._syncList=[];const s=i.length,n=e.length,o=Math.min(n,s);o&&this.parse(0,o),n>s?this._insertElements(s,n-s,t):n{for(t.length+=e,a=t.length-1;a>=o;a--)t[a]=t[a-e]};for(r(n),a=t;a{s[t]=i[t]&&i[t].active()?i[t]._to:this[t]})),s}}function Ys(t,e){const i=t.options.ticks,n=function(t){const e=t.options.offset,i=t._tickSize(),s=t._length/i+(e?0:1),n=t._maxLength/i;return Math.floor(Math.min(s,n))}(t),o=Math.min(i.maxTicksLimit||n,n),a=i.major.enabled?function(t){const e=[];let i,s;for(i=0,s=t.length;io)return function(t,e,i,s){let n,o=0,a=i[0];for(s=Math.ceil(s),n=0;nn)return e}return Math.max(n,1)}(a,e,o);if(r>0){let t,i;const n=r>1?Math.round((h-l)/(r-1)):null;for(Us(e,c,d,s(n)?0:l-n,l),t=0,i=r-1;t"top"===e||"left"===e?t[e]+i:t[e]-i,qs=(t,e)=>Math.min(e||t,t);function Ks(t,e){const i=[],s=t.length/e,n=t.length;let o=0;for(;oa+r)))return h}function Js(t){return t.drawTicks?t.tickLength:0}function Zs(t,e){if(!t.display)return 0;const i=Si(t.font,e),s=ki(t.padding);return(n(t.text)?t.text.length:1)*i.lineHeight+s.height}function Qs(t,e,i){let s=ut(t);return(i&&"right"!==e||!i&&"right"===e)&&(s=(t=>"left"===t?"right":"right"===t?"left":t)(s)),s}class tn extends $s{constructor(t){super(),this.id=t.id,this.type=t.type,this.options=void 0,this.ctx=t.ctx,this.chart=t.chart,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this._margins={left:0,right:0,top:0,bottom:0},this.maxWidth=void 0,this.maxHeight=void 0,this.paddingTop=void 0,this.paddingBottom=void 0,this.paddingLeft=void 0,this.paddingRight=void 0,this.axis=void 0,this.labelRotation=void 0,this.min=void 0,this.max=void 0,this._range=void 0,this.ticks=[],this._gridLineItems=null,this._labelItems=null,this._labelSizes=null,this._length=0,this._maxLength=0,this._longestTextCache={},this._startPixel=void 0,this._endPixel=void 0,this._reversePixels=!1,this._userMax=void 0,this._userMin=void 0,this._suggestedMax=void 0,this._suggestedMin=void 0,this._ticksLength=0,this._borderValue=0,this._cache={},this._dataLimitsCached=!1,this.$context=void 0}init(t){this.options=t.setContext(this.getContext()),this.axis=t.axis,this._userMin=this.parse(t.min),this._userMax=this.parse(t.max),this._suggestedMin=this.parse(t.suggestedMin),this._suggestedMax=this.parse(t.suggestedMax)}parse(t,e){return t}getUserBounds(){let{_userMin:t,_userMax:e,_suggestedMin:i,_suggestedMax:s}=this;return t=r(t,Number.POSITIVE_INFINITY),e=r(e,Number.NEGATIVE_INFINITY),i=r(i,Number.POSITIVE_INFINITY),s=r(s,Number.NEGATIVE_INFINITY),{min:r(t,i),max:r(e,s),minDefined:a(t),maxDefined:a(e)}}getMinMax(t){let e,{min:i,max:s,minDefined:n,maxDefined:o}=this.getUserBounds();if(n&&o)return{min:i,max:s};const a=this.getMatchingVisibleMetas();for(let r=0,l=a.length;rs?s:i,s=n&&i>s?i:s,{min:r(i,r(s,i)),max:r(s,r(i,s))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){const t=this.chart.data;return this.options.labels||(this.isHorizontal()?t.xLabels:t.yLabels)||t.labels||[]}getLabelItems(t=this.chart.chartArea){return this._labelItems||(this._labelItems=this._computeLabelItems(t))}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){d(this.options.beforeUpdate,[this])}update(t,e,i){const{beginAtZero:s,grace:n,ticks:o}=this.options,a=o.sampleSize;this.beforeUpdate(),this.maxWidth=t,this.maxHeight=e,this._margins=i=Object.assign({left:0,right:0,top:0,bottom:0},i),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+i.left+i.right:this.height+i.top+i.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=Di(this,n,s),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();const r=a=n||i<=1||!this.isHorizontal())return void(this.labelRotation=s);const h=this._getLabelSizes(),c=h.widest.width,d=h.highest.height,u=Z(this.chart.width-c,0,this.maxWidth);o=t.offset?this.maxWidth/i:u/(i-1),c+6>o&&(o=u/(i-(t.offset?.5:1)),a=this.maxHeight-Js(t.grid)-e.padding-Zs(t.title,this.chart.options.font),r=Math.sqrt(c*c+d*d),l=Y(Math.min(Math.asin(Z((h.highest.height+6)/o,-1,1)),Math.asin(Z(a/r,-1,1))-Math.asin(Z(d/r,-1,1)))),l=Math.max(s,Math.min(n,l))),this.labelRotation=l}afterCalculateLabelRotation(){d(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){d(this.options.beforeFit,[this])}fit(){const t={width:0,height:0},{chart:e,options:{ticks:i,title:s,grid:n}}=this,o=this._isVisible(),a=this.isHorizontal();if(o){const o=Zs(s,e.options.font);if(a?(t.width=this.maxWidth,t.height=Js(n)+o):(t.height=this.maxHeight,t.width=Js(n)+o),i.display&&this.ticks.length){const{first:e,last:s,widest:n,highest:o}=this._getLabelSizes(),r=2*i.padding,l=$(this.labelRotation),h=Math.cos(l),c=Math.sin(l);if(a){const e=i.mirror?0:c*n.width+h*o.height;t.height=Math.min(this.maxHeight,t.height+e+r)}else{const e=i.mirror?0:h*n.width+c*o.height;t.width=Math.min(this.maxWidth,t.width+e+r)}this._calculatePadding(e,s,c,h)}}this._handleMargins(),a?(this.width=this._length=e.width-this._margins.left-this._margins.right,this.height=t.height):(this.width=t.width,this.height=this._length=e.height-this._margins.top-this._margins.bottom)}_calculatePadding(t,e,i,s){const{ticks:{align:n,padding:o},position:a}=this.options,r=0!==this.labelRotation,l="top"!==a&&"x"===this.axis;if(this.isHorizontal()){const a=this.getPixelForTick(0)-this.left,h=this.right-this.getPixelForTick(this.ticks.length-1);let c=0,d=0;r?l?(c=s*t.width,d=i*e.height):(c=i*t.height,d=s*e.width):"start"===n?d=e.width:"end"===n?c=t.width:"inner"!==n&&(c=t.width/2,d=e.width/2),this.paddingLeft=Math.max((c-a+o)*this.width/(this.width-a),0),this.paddingRight=Math.max((d-h+o)*this.width/(this.width-h),0)}else{let i=e.height/2,s=t.height/2;"start"===n?(i=0,s=t.height):"end"===n&&(i=e.height,s=0),this.paddingTop=i+o,this.paddingBottom=s+o}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){d(this.options.afterFit,[this])}isHorizontal(){const{axis:t,position:e}=this.options;return"top"===e||"bottom"===e||"x"===t}isFullSize(){return this.options.fullSize}_convertTicksToLabels(t){let e,i;for(this.beforeTickToLabelConversion(),this.generateTickLabels(t),e=0,i=t.length;e{const i=t.gc,s=i.length/2;let n;if(s>e){for(n=0;n({width:r[t]||0,height:l[t]||0});return{first:P(0),last:P(e-1),widest:P(k),highest:P(S),widths:r,heights:l}}getLabelForValue(t){return t}getPixelForValue(t,e){return NaN}getValueForPixel(t){}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getPixelForDecimal(t){this._reversePixels&&(t=1-t);const e=this._startPixel+t*this._length;return Q(this._alignToPixels?Ae(this.chart,e,0):e)}getDecimalForPixel(t){const e=(t-this._startPixel)/this._length;return this._reversePixels?1-e:e}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){const{min:t,max:e}=this;return t<0&&e<0?e:t>0&&e>0?t:0}getContext(t){const e=this.ticks||[];if(t>=0&&ta*s?a/i:r/s:r*s0}_computeGridLineItems(t){const e=this.axis,i=this.chart,s=this.options,{grid:n,position:a,border:r}=s,h=n.offset,c=this.isHorizontal(),d=this.ticks.length+(h?1:0),u=Js(n),f=[],g=r.setContext(this.getContext()),p=g.display?g.width:0,m=p/2,x=function(t){return Ae(i,t,p)};let b,_,y,v,M,w,k,S,P,D,C,O;if("top"===a)b=x(this.bottom),w=this.bottom-u,S=b-m,D=x(t.top)+m,O=t.bottom;else if("bottom"===a)b=x(this.top),D=t.top,O=x(t.bottom)-m,w=b+m,S=this.top+u;else if("left"===a)b=x(this.right),M=this.right-u,k=b-m,P=x(t.left)+m,C=t.right;else if("right"===a)b=x(this.left),P=t.left,C=x(t.right)-m,M=b+m,k=this.left+u;else if("x"===e){if("center"===a)b=x((t.top+t.bottom)/2+.5);else if(o(a)){const t=Object.keys(a)[0],e=a[t];b=x(this.chart.scales[t].getPixelForValue(e))}D=t.top,O=t.bottom,w=b+m,S=w+u}else if("y"===e){if("center"===a)b=x((t.left+t.right)/2);else if(o(a)){const t=Object.keys(a)[0],e=a[t];b=x(this.chart.scales[t].getPixelForValue(e))}M=b-m,k=M-u,P=t.left,C=t.right}const A=l(s.ticks.maxTicksLimit,d),T=Math.max(1,Math.ceil(d/A));for(_=0;_0&&(o-=s/2)}d={left:o,top:n,width:s+e.width,height:i+e.height,color:t.backdropColor}}x.push({label:v,font:P,textOffset:O,options:{rotation:m,color:i,strokeColor:o,strokeWidth:h,textAlign:f,textBaseline:A,translation:[M,w],backdrop:d}})}return x}_getXAxisLabelAlignment(){const{position:t,ticks:e}=this.options;if(-$(this.labelRotation))return"top"===t?"left":"right";let i="center";return"start"===e.align?i="left":"end"===e.align?i="right":"inner"===e.align&&(i="inner"),i}_getYAxisLabelAlignment(t){const{position:e,ticks:{crossAlign:i,mirror:s,padding:n}}=this.options,o=t+n,a=this._getLabelSizes().widest.width;let r,l;return"left"===e?s?(l=this.right+n,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l+=a)):(l=this.right-o,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l=this.left)):"right"===e?s?(l=this.left+n,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l-=a)):(l=this.left+o,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l=this.right)):r="right",{textAlign:r,x:l}}_computeLabelArea(){if(this.options.ticks.mirror)return;const t=this.chart,e=this.options.position;return"left"===e||"right"===e?{top:0,left:this.left,bottom:t.height,right:this.right}:"top"===e||"bottom"===e?{top:this.top,left:0,bottom:this.bottom,right:t.width}:void 0}drawBackground(){const{ctx:t,options:{backgroundColor:e},left:i,top:s,width:n,height:o}=this;e&&(t.save(),t.fillStyle=e,t.fillRect(i,s,n,o),t.restore())}getLineWidthForValue(t){const e=this.options.grid;if(!this._isVisible()||!e.display)return 0;const i=this.ticks.findIndex((e=>e.value===t));if(i>=0){return e.setContext(this.getContext(i)).lineWidth}return 0}drawGrid(t){const e=this.options.grid,i=this.ctx,s=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(t));let n,o;const a=(t,e,s)=>{s.width&&s.color&&(i.save(),i.lineWidth=s.width,i.strokeStyle=s.color,i.setLineDash(s.borderDash||[]),i.lineDashOffset=s.borderDashOffset,i.beginPath(),i.moveTo(t.x,t.y),i.lineTo(e.x,e.y),i.stroke(),i.restore())};if(e.display)for(n=0,o=s.length;n{this.drawBackground(),this.drawGrid(t),this.drawTitle()}},{z:s,draw:()=>{this.drawBorder()}},{z:e,draw:t=>{this.drawLabels(t)}}]:[{z:e,draw:t=>{this.draw(t)}}]}getMatchingVisibleMetas(t){const e=this.chart.getSortedVisibleDatasetMetas(),i=this.axis+"AxisID",s=[];let n,o;for(n=0,o=e.length;n{const s=i.split("."),n=s.pop(),o=[t].concat(s).join("."),a=e[i].split("."),r=a.pop(),l=a.join(".");ue.route(o,n,l,r)}))}(e,t.defaultRoutes);t.descriptors&&ue.describe(e,t.descriptors)}(t,o,i),this.override&&ue.override(t.id,t.overrides)),o}get(t){return this.items[t]}unregister(t){const e=this.items,i=t.id,s=this.scope;i in e&&delete e[i],s&&i in ue[s]&&(delete ue[s][i],this.override&&delete re[i])}}class sn{constructor(){this.controllers=new en(js,"datasets",!0),this.elements=new en($s,"elements"),this.plugins=new en(Object,"plugins"),this.scales=new en(tn,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...t){this._each("register",t)}remove(...t){this._each("unregister",t)}addControllers(...t){this._each("register",t,this.controllers)}addElements(...t){this._each("register",t,this.elements)}addPlugins(...t){this._each("register",t,this.plugins)}addScales(...t){this._each("register",t,this.scales)}getController(t){return this._get(t,this.controllers,"controller")}getElement(t){return this._get(t,this.elements,"element")}getPlugin(t){return this._get(t,this.plugins,"plugin")}getScale(t){return this._get(t,this.scales,"scale")}removeControllers(...t){this._each("unregister",t,this.controllers)}removeElements(...t){this._each("unregister",t,this.elements)}removePlugins(...t){this._each("unregister",t,this.plugins)}removeScales(...t){this._each("unregister",t,this.scales)}_each(t,e,i){[...e].forEach((e=>{const s=i||this._getRegistryForType(e);i||s.isForType(e)||s===this.plugins&&e.id?this._exec(t,s,e):u(e,(e=>{const s=i||this._getRegistryForType(e);this._exec(t,s,e)}))}))}_exec(t,e,i){const s=w(t);d(i["before"+s],[],i),e[t](i),d(i["after"+s],[],i)}_getRegistryForType(t){for(let e=0;et.filter((t=>!e.some((e=>t.plugin.id===e.plugin.id))));this._notify(s(e,i),t,"stop"),this._notify(s(i,e),t,"start")}}function an(t,e){return e||!1!==t?!0===t?{}:t:null}function rn(t,{plugin:e,local:i},s,n){const o=t.pluginScopeKeys(e),a=t.getOptionScopes(s,o);return i&&e.defaults&&a.push(e.defaults),t.createResolver(a,n,[""],{scriptable:!1,indexable:!1,allKeys:!0})}function ln(t,e){const i=ue.datasets[t]||{};return((e.datasets||{})[t]||{}).indexAxis||e.indexAxis||i.indexAxis||"x"}function hn(t){if("x"===t||"y"===t||"r"===t)return t}function cn(t,...e){if(hn(t))return t;for(const s of e){const e=s.axis||("top"===(i=s.position)||"bottom"===i?"x":"left"===i||"right"===i?"y":void 0)||t.length>1&&hn(t[0].toLowerCase());if(e)return e}var i;throw new Error(`Cannot determine type of '${t}' axis. Please provide 'axis' or 'position' option.`)}function dn(t,e,i){if(i[e+"AxisID"]===t)return{axis:e}}function un(t,e){const i=re[t.type]||{scales:{}},s=e.scales||{},n=ln(t.type,e),a=Object.create(null);return Object.keys(s).forEach((e=>{const r=s[e];if(!o(r))return console.error(`Invalid scale configuration for scale: ${e}`);if(r._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${e}`);const l=cn(e,r,function(t,e){if(e.data&&e.data.datasets){const i=e.data.datasets.filter((e=>e.xAxisID===t||e.yAxisID===t));if(i.length)return dn(t,"x",i[0])||dn(t,"y",i[0])}return{}}(e,t),ue.scales[r.type]),h=function(t,e){return t===e?"_index_":"_value_"}(l,n),c=i.scales||{};a[e]=b(Object.create(null),[{axis:l},r,c[l],c[h]])})),t.data.datasets.forEach((i=>{const n=i.type||t.type,o=i.indexAxis||ln(n,e),r=(re[n]||{}).scales||{};Object.keys(r).forEach((t=>{const e=function(t,e){let i=t;return"_index_"===t?i=e:"_value_"===t&&(i="x"===e?"y":"x"),i}(t,o),n=i[e+"AxisID"]||e;a[n]=a[n]||Object.create(null),b(a[n],[{axis:e},s[n],r[t]])}))})),Object.keys(a).forEach((t=>{const e=a[t];b(e,[ue.scales[e.type],ue.scale])})),a}function fn(t){const e=t.options||(t.options={});e.plugins=l(e.plugins,{}),e.scales=un(t,e)}function gn(t){return(t=t||{}).datasets=t.datasets||[],t.labels=t.labels||[],t}const pn=new Map,mn=new Set;function xn(t,e){let i=pn.get(t);return i||(i=e(),pn.set(t,i),mn.add(i)),i}const bn=(t,e,i)=>{const s=M(e,i);void 0!==s&&t.add(s)};class _n{constructor(t){this._config=function(t){return(t=t||{}).data=gn(t.data),fn(t),t}(t),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(t){this._config.type=t}get data(){return this._config.data}set data(t){this._config.data=gn(t)}get options(){return this._config.options}set options(t){this._config.options=t}get plugins(){return this._config.plugins}update(){const t=this._config;this.clearCache(),fn(t)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(t){return xn(t,(()=>[[`datasets.${t}`,""]]))}datasetAnimationScopeKeys(t,e){return xn(`${t}.transition.${e}`,(()=>[[`datasets.${t}.transitions.${e}`,`transitions.${e}`],[`datasets.${t}`,""]]))}datasetElementScopeKeys(t,e){return xn(`${t}-${e}`,(()=>[[`datasets.${t}.elements.${e}`,`datasets.${t}`,`elements.${e}`,""]]))}pluginScopeKeys(t){const e=t.id;return xn(`${this.type}-plugin-${e}`,(()=>[[`plugins.${e}`,...t.additionalOptionScopes||[]]]))}_cachedScopes(t,e){const i=this._scopeCache;let s=i.get(t);return s&&!e||(s=new Map,i.set(t,s)),s}getOptionScopes(t,e,i){const{options:s,type:n}=this,o=this._cachedScopes(t,i),a=o.get(e);if(a)return a;const r=new Set;e.forEach((e=>{t&&(r.add(t),e.forEach((e=>bn(r,t,e)))),e.forEach((t=>bn(r,s,t))),e.forEach((t=>bn(r,re[n]||{},t))),e.forEach((t=>bn(r,ue,t))),e.forEach((t=>bn(r,le,t)))}));const l=Array.from(r);return 0===l.length&&l.push(Object.create(null)),mn.has(e)&&o.set(e,l),l}chartOptionScopes(){const{options:t,type:e}=this;return[t,re[e]||{},ue.datasets[e]||{},{type:e},ue,le]}resolveNamedOptions(t,e,i,s=[""]){const o={$shared:!0},{resolver:a,subPrefixes:r}=yn(this._resolverCache,t,s);let l=a;if(function(t,e){const{isScriptable:i,isIndexable:s}=Ye(t);for(const o of e){const e=i(o),a=s(o),r=(a||e)&&t[o];if(e&&(S(r)||vn(r))||a&&n(r))return!0}return!1}(a,e)){o.$shared=!1;l=$e(a,i=S(i)?i():i,this.createResolver(t,i,r))}for(const t of e)o[t]=l[t];return o}createResolver(t,e,i=[""],s){const{resolver:n}=yn(this._resolverCache,t,i);return o(e)?$e(n,e,void 0,s):n}}function yn(t,e,i){let s=t.get(e);s||(s=new Map,t.set(e,s));const n=i.join();let o=s.get(n);if(!o){o={resolver:je(e,i),subPrefixes:i.filter((t=>!t.toLowerCase().includes("hover")))},s.set(n,o)}return o}const vn=t=>o(t)&&Object.getOwnPropertyNames(t).some((e=>S(t[e])));const Mn=["top","bottom","left","right","chartArea"];function wn(t,e){return"top"===t||"bottom"===t||-1===Mn.indexOf(t)&&"x"===e}function kn(t,e){return function(i,s){return i[t]===s[t]?i[e]-s[e]:i[t]-s[t]}}function Sn(t){const e=t.chart,i=e.options.animation;e.notifyPlugins("afterRender"),d(i&&i.onComplete,[t],e)}function Pn(t){const e=t.chart,i=e.options.animation;d(i&&i.onProgress,[t],e)}function Dn(t){return fe()&&"string"==typeof t?t=document.getElementById(t):t&&t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas),t}const Cn={},On=t=>{const e=Dn(t);return Object.values(Cn).filter((t=>t.canvas===e)).pop()};function An(t,e,i){const s=Object.keys(t);for(const n of s){const s=+n;if(s>=e){const o=t[n];delete t[n],(i>0||s>e)&&(t[s+i]=o)}}}class Tn{static defaults=ue;static instances=Cn;static overrides=re;static registry=nn;static version="4.5.0";static getChart=On;static register(...t){nn.add(...t),Ln()}static unregister(...t){nn.remove(...t),Ln()}constructor(t,e){const s=this.config=new _n(e),n=Dn(t),o=On(n);if(o)throw new Error("Canvas is already in use. Chart with ID '"+o.id+"' must be destroyed before the canvas with ID '"+o.canvas.id+"' can be reused.");const a=s.createResolver(s.chartOptionScopes(),this.getContext());this.platform=new(s.platform||Ps(n)),this.platform.updateConfig(s);const r=this.platform.acquireContext(n,a.aspectRatio),l=r&&r.canvas,h=l&&l.height,c=l&&l.width;this.id=i(),this.ctx=r,this.canvas=l,this.width=c,this.height=h,this._options=a,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new on,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=dt((t=>this.update(t)),a.resizeDelay||0),this._dataChanges=[],Cn[this.id]=this,r&&l?(bt.listen(this,"complete",Sn),bt.listen(this,"progress",Pn),this._initialize(),this.attached&&this.update()):console.error("Failed to create chart: can't acquire context from the given item")}get aspectRatio(){const{options:{aspectRatio:t,maintainAspectRatio:e},width:i,height:n,_aspectRatio:o}=this;return s(t)?e&&o?o:n?i/n:null:t}get data(){return this.config.data}set data(t){this.config.data=t}get options(){return this._options}set options(t){this.config.options=t}get registry(){return nn}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():ke(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return Te(this.canvas,this.ctx),this}stop(){return bt.stop(this),this}resize(t,e){bt.running(this)?this._resizeBeforeDraw={width:t,height:e}:this._resize(t,e)}_resize(t,e){const i=this.options,s=this.canvas,n=i.maintainAspectRatio&&this.aspectRatio,o=this.platform.getMaximumSize(s,t,e,n),a=i.devicePixelRatio||this.platform.getDevicePixelRatio(),r=this.width?"resize":"attach";this.width=o.width,this.height=o.height,this._aspectRatio=this.aspectRatio,ke(this,a,!0)&&(this.notifyPlugins("resize",{size:o}),d(i.onResize,[this,o],this),this.attached&&this._doResize(r)&&this.render())}ensureScalesHaveIDs(){u(this.options.scales||{},((t,e)=>{t.id=e}))}buildOrUpdateScales(){const t=this.options,e=t.scales,i=this.scales,s=Object.keys(i).reduce(((t,e)=>(t[e]=!1,t)),{});let n=[];e&&(n=n.concat(Object.keys(e).map((t=>{const i=e[t],s=cn(t,i),n="r"===s,o="x"===s;return{options:i,dposition:n?"chartArea":o?"bottom":"left",dtype:n?"radialLinear":o?"category":"linear"}})))),u(n,(e=>{const n=e.options,o=n.id,a=cn(o,n),r=l(n.type,e.dtype);void 0!==n.position&&wn(n.position,a)===wn(e.dposition)||(n.position=e.dposition),s[o]=!0;let h=null;if(o in i&&i[o].type===r)h=i[o];else{h=new(nn.getScale(r))({id:o,type:r,ctx:this.ctx,chart:this}),i[h.id]=h}h.init(n,t)})),u(s,((t,e)=>{t||delete i[e]})),u(i,(t=>{ls.configure(this,t,t.options),ls.addBox(this,t)}))}_updateMetasets(){const t=this._metasets,e=this.data.datasets.length,i=t.length;if(t.sort(((t,e)=>t.index-e.index)),i>e){for(let t=e;te.length&&delete this._stacks,t.forEach(((t,i)=>{0===e.filter((e=>e===t._dataset)).length&&this._destroyDatasetMeta(i)}))}buildOrUpdateControllers(){const t=[],e=this.data.datasets;let i,s;for(this._removeUnreferencedMetasets(),i=0,s=e.length;i{this.getDatasetMeta(e).controller.reset()}),this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(t){const e=this.config;e.update();const i=this._options=e.createResolver(e.chartOptionScopes(),this.getContext()),s=this._animationsDisabled=!i.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),!1===this.notifyPlugins("beforeUpdate",{mode:t,cancelable:!0}))return;const n=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let o=0;for(let t=0,e=this.data.datasets.length;t{t.reset()})),this._updateDatasets(t),this.notifyPlugins("afterUpdate",{mode:t}),this._layers.sort(kn("z","_idx"));const{_active:a,_lastEvent:r}=this;r?this._eventHandler(r,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){u(this.scales,(t=>{ls.removeBox(this,t)})),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){const t=this.options,e=new Set(Object.keys(this._listeners)),i=new Set(t.events);P(e,i)&&!!this._responsiveListeners===t.responsive||(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){const{_hiddenIndices:t}=this,e=this._getUniformDataChanges()||[];for(const{method:i,start:s,count:n}of e){An(t,s,"_removeElements"===i?-n:n)}}_getUniformDataChanges(){const t=this._dataChanges;if(!t||!t.length)return;this._dataChanges=[];const e=this.data.datasets.length,i=e=>new Set(t.filter((t=>t[0]===e)).map(((t,e)=>e+","+t.splice(1).join(",")))),s=i(0);for(let t=1;tt.split(","))).map((t=>({method:t[1],start:+t[2],count:+t[3]})))}_updateLayout(t){if(!1===this.notifyPlugins("beforeLayout",{cancelable:!0}))return;ls.update(this,this.width,this.height,t);const e=this.chartArea,i=e.width<=0||e.height<=0;this._layers=[],u(this.boxes,(t=>{i&&"chartArea"===t.position||(t.configure&&t.configure(),this._layers.push(...t._layers()))}),this),this._layers.forEach(((t,e)=>{t._idx=e})),this.notifyPlugins("afterLayout")}_updateDatasets(t){if(!1!==this.notifyPlugins("beforeDatasetsUpdate",{mode:t,cancelable:!0})){for(let t=0,e=this.data.datasets.length;t=0;--e)this._drawDataset(t[e]);this.notifyPlugins("afterDatasetsDraw")}_drawDataset(t){const e=this.ctx,i={meta:t,index:t.index,cancelable:!0},s=Ni(this,t);!1!==this.notifyPlugins("beforeDatasetDraw",i)&&(s&&Ie(e,s),t.controller.draw(),s&&ze(e),i.cancelable=!1,this.notifyPlugins("afterDatasetDraw",i))}isPointInArea(t){return Re(t,this.chartArea,this._minPadding)}getElementsAtEventForMode(t,e,i,s){const n=Ki.modes[e];return"function"==typeof n?n(this,t,i,s):[]}getDatasetMeta(t){const e=this.data.datasets[t],i=this._metasets;let s=i.filter((t=>t&&t._dataset===e)).pop();return s||(s={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e&&e.order||0,index:t,_dataset:e,_parsed:[],_sorted:!1},i.push(s)),s}getContext(){return this.$context||(this.$context=Ci(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(t){const e=this.data.datasets[t];if(!e)return!1;const i=this.getDatasetMeta(t);return"boolean"==typeof i.hidden?!i.hidden:!e.hidden}setDatasetVisibility(t,e){this.getDatasetMeta(t).hidden=!e}toggleDataVisibility(t){this._hiddenIndices[t]=!this._hiddenIndices[t]}getDataVisibility(t){return!this._hiddenIndices[t]}_updateVisibility(t,e,i){const s=i?"show":"hide",n=this.getDatasetMeta(t),o=n.controller._resolveAnimations(void 0,s);k(e)?(n.data[e].hidden=!i,this.update()):(this.setDatasetVisibility(t,i),o.update(n,{visible:i}),this.update((e=>e.datasetIndex===t?s:void 0)))}hide(t,e){this._updateVisibility(t,e,!1)}show(t,e){this._updateVisibility(t,e,!0)}_destroyDatasetMeta(t){const e=this._metasets[t];e&&e.controller&&e.controller._destroy(),delete this._metasets[t]}_stop(){let t,e;for(this.stop(),bt.remove(this),t=0,e=this.data.datasets.length;t{e.addEventListener(this,i,s),t[i]=s},s=(t,e,i)=>{t.offsetX=e,t.offsetY=i,this._eventHandler(t)};u(this.options.events,(t=>i(t,s)))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const t=this._responsiveListeners,e=this.platform,i=(i,s)=>{e.addEventListener(this,i,s),t[i]=s},s=(i,s)=>{t[i]&&(e.removeEventListener(this,i,s),delete t[i])},n=(t,e)=>{this.canvas&&this.resize(t,e)};let o;const a=()=>{s("attach",a),this.attached=!0,this.resize(),i("resize",n),i("detach",o)};o=()=>{this.attached=!1,s("resize",n),this._stop(),this._resize(0,0),i("attach",a)},e.isAttached(this.canvas)?a():o()}unbindEvents(){u(this._listeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._listeners={},u(this._responsiveListeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._responsiveListeners=void 0}updateHoverStyle(t,e,i){const s=i?"set":"remove";let n,o,a,r;for("dataset"===e&&(n=this.getDatasetMeta(t[0].datasetIndex),n.controller["_"+s+"DatasetHoverStyle"]()),a=0,r=t.length;a{const i=this.getDatasetMeta(t);if(!i)throw new Error("No dataset found at index "+t);return{datasetIndex:t,element:i.data[e],index:e}}));!f(i,e)&&(this._active=i,this._lastEvent=null,this._updateHoverStyles(i,e))}notifyPlugins(t,e,i){return this._plugins.notify(this,t,e,i)}isPluginEnabled(t){return 1===this._plugins._cache.filter((e=>e.plugin.id===t)).length}_updateHoverStyles(t,e,i){const s=this.options.hover,n=(t,e)=>t.filter((t=>!e.some((e=>t.datasetIndex===e.datasetIndex&&t.index===e.index)))),o=n(e,t),a=i?t:n(t,e);o.length&&this.updateHoverStyle(o,s.mode,!1),a.length&&s.mode&&this.updateHoverStyle(a,s.mode,!0)}_eventHandler(t,e){const i={event:t,replay:e,cancelable:!0,inChartArea:this.isPointInArea(t)},s=e=>(e.options.events||this.options.events).includes(t.native.type);if(!1===this.notifyPlugins("beforeEvent",i,s))return;const n=this._handleEvent(t,e,i.inChartArea);return i.cancelable=!1,this.notifyPlugins("afterEvent",i,s),(n||i.changed)&&this.render(),this}_handleEvent(t,e,i){const{_active:s=[],options:n}=this,o=e,a=this._getActiveElements(t,s,i,o),r=D(t),l=function(t,e,i,s){return i&&"mouseout"!==t.type?s?e:t:null}(t,this._lastEvent,i,r);i&&(this._lastEvent=null,d(n.onHover,[t,a,this],this),r&&d(n.onClick,[t,a,this],this));const h=!f(a,s);return(h||e)&&(this._active=a,this._updateHoverStyles(a,s,e)),this._lastEvent=l,h}_getActiveElements(t,e,i,s){if("mouseout"===t.type)return[];if(!i)return e;const n=this.options.hover;return this.getElementsAtEventForMode(t,n.mode,n,s)}}function Ln(){return u(Tn.instances,(t=>t._plugins.invalidate()))}function En(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}class Rn{static override(t){Object.assign(Rn.prototype,t)}options;constructor(t){this.options=t||{}}init(){}formats(){return En()}parse(){return En()}format(){return En()}add(){return En()}diff(){return En()}startOf(){return En()}endOf(){return En()}}var In={_date:Rn};function zn(t){const e=t.iScale,i=function(t,e){if(!t._cache.$bar){const i=t.getMatchingVisibleMetas(e);let s=[];for(let e=0,n=i.length;et-e)))}return t._cache.$bar}(e,t.type);let s,n,o,a,r=e._length;const l=()=>{32767!==o&&-32768!==o&&(k(a)&&(r=Math.min(r,Math.abs(o-a)||r)),a=o)};for(s=0,n=i.length;sMath.abs(r)&&(l=r,h=a),e[i.axis]=h,e._custom={barStart:l,barEnd:h,start:n,end:o,min:a,max:r}}(t,e,i,s):e[i.axis]=i.parse(t,s),e}function Vn(t,e,i,s){const n=t.iScale,o=t.vScale,a=n.getLabels(),r=n===o,l=[];let h,c,d,u;for(h=i,c=i+s;ht.x,i="left",s="right"):(e=t.base"spacing"!==t,_indexable:t=>"spacing"!==t&&!t.startsWith("borderDash")&&!t.startsWith("hoverBorderDash")};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(t){const e=t.data;if(e.labels.length&&e.datasets.length){const{labels:{pointStyle:i,color:s}}=t.legend.options;return e.labels.map(((e,n)=>{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}}};constructor(t,e){super(t,e),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(t,e){const i=this.getDataset().data,s=this._cachedMeta;if(!1===this._parsing)s._parsed=i;else{let n,a,r=t=>+i[t];if(o(i[t])){const{key:t="value"}=this._parsing;r=e=>+M(i[e],t)}for(n=t,a=t+e;nJ(t,r,l,!0)?1:Math.max(e,e*i,s,s*i),g=(t,e,s)=>J(t,r,l,!0)?-1:Math.min(e,e*i,s,s*i),p=f(0,h,d),m=f(E,c,u),x=g(C,h,d),b=g(C+E,c,u);s=(p-x)/2,n=(m-b)/2,o=-(p+x)/2,a=-(m+b)/2}return{ratioX:s,ratioY:n,offsetX:o,offsetY:a}}(u,d,r),x=(i.width-o)/f,b=(i.height-o)/g,_=Math.max(Math.min(x,b)/2,0),y=c(this.options.radius,_),v=(y-Math.max(y*r,0))/this._getVisibleDatasetWeightTotal();this.offsetX=p*y,this.offsetY=m*y,s.total=this.calculateTotal(),this.outerRadius=y-v*this._getRingWeightOffset(this.index),this.innerRadius=Math.max(this.outerRadius-v*l,0),this.updateElements(n,0,n.length,t)}_circumference(t,e){const i=this.options,s=this._cachedMeta,n=this._getCircumference();return e&&i.animation.animateRotate||!this.chart.getDataVisibility(t)||null===s._parsed[t]||s.data[t].hidden?0:this.calculateCircumference(s._parsed[t]*n/O)}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.chartArea,r=o.options.animation,l=(a.left+a.right)/2,h=(a.top+a.bottom)/2,c=n&&r.animateScale,d=c?0:this.innerRadius,u=c?0:this.outerRadius,{sharedOptions:f,includeOptions:g}=this._getSharedOptions(e,s);let p,m=this._getRotation();for(p=0;p0&&!isNaN(t)?O*(Math.abs(t)/e):0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t],i.options.locale);return{label:s[t]||"",value:n}}getMaxBorderWidth(t){let e=0;const i=this.chart;let s,n,o,a,r;if(!t)for(s=0,n=i.data.datasets.length;s{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}},scales:{r:{type:"radialLinear",angleLines:{display:!1},beginAtZero:!0,grid:{circular:!0},pointLabels:{display:!1},startAngle:0}}};constructor(t,e){super(t,e),this.innerRadius=void 0,this.outerRadius=void 0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t].r,i.options.locale);return{label:s[t]||"",value:n}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta.data;this._updateRadius(),this.updateElements(e,0,e.length,t)}getMinMax(){const t=this._cachedMeta,e={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY};return t.data.forEach(((t,i)=>{const s=this.getParsed(i).r;!isNaN(s)&&this.chart.getDataVisibility(i)&&(se.max&&(e.max=s))})),e}_updateRadius(){const t=this.chart,e=t.chartArea,i=t.options,s=Math.min(e.right-e.left,e.bottom-e.top),n=Math.max(s/2,0),o=(n-Math.max(i.cutoutPercentage?n/100*i.cutoutPercentage:1,0))/t.getVisibleDatasetCount();this.outerRadius=n-o*this.index,this.innerRadius=this.outerRadius-o}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.options.animation,r=this._cachedMeta.rScale,l=r.xCenter,h=r.yCenter,c=r.getIndexAngle(0)-.5*C;let d,u=c;const f=360/this.countVisibleElements();for(d=0;d{!isNaN(this.getParsed(i).r)&&this.chart.getDataVisibility(i)&&e++})),e}_computeAngle(t,e,i){return this.chart.getDataVisibility(t)?$(this.resolveDataElementOptions(t,e).angle||i):0}}var Un=Object.freeze({__proto__:null,BarController:class extends js{static id="bar";static defaults={datasetElementType:!1,dataElementType:"bar",categoryPercentage:.8,barPercentage:.9,grouped:!0,animations:{numbers:{type:"number",properties:["x","y","base","width","height"]}}};static overrides={scales:{_index_:{type:"category",offset:!0,grid:{offset:!0}},_value_:{type:"linear",beginAtZero:!0}}};parsePrimitiveData(t,e,i,s){return Vn(t,e,i,s)}parseArrayData(t,e,i,s){return Vn(t,e,i,s)}parseObjectData(t,e,i,s){const{iScale:n,vScale:o}=t,{xAxisKey:a="x",yAxisKey:r="y"}=this._parsing,l="x"===n.axis?a:r,h="x"===o.axis?a:r,c=[];let d,u,f,g;for(d=i,u=i+s;dt.controller.options.grouped)),o=i.options.stacked,a=[],r=this._cachedMeta.controller.getParsed(e),l=r&&r[i.axis],h=t=>{const e=t._parsed.find((t=>t[i.axis]===l)),n=e&&e[t.vScale.axis];if(s(n)||isNaN(n))return!0};for(const i of n)if((void 0===e||!h(i))&&((!1===o||-1===a.indexOf(i.stack)||void 0===o&&void 0===i.stack)&&a.push(i.stack),i.index===t))break;return a.length||a.push(void 0),a}_getStackCount(t){return this._getStacks(void 0,t).length}_getAxisCount(){return this._getAxis().length}getFirstScaleIdForIndexAxis(){const t=this.chart.scales,e=this.chart.options.indexAxis;return Object.keys(t).filter((i=>t[i].axis===e)).shift()}_getAxis(){const t={},e=this.getFirstScaleIdForIndexAxis();for(const i of this.chart.data.datasets)t[l("x"===this.chart.options.indexAxis?i.xAxisID:i.yAxisID,e)]=!0;return Object.keys(t)}_getStackIndex(t,e,i){const s=this._getStacks(t,i),n=void 0!==e?s.indexOf(e):-1;return-1===n?s.length-1:n}_getRuler(){const t=this.options,e=this._cachedMeta,i=e.iScale,s=[];let n,o;for(n=0,o=e.data.length;n=i?1:-1)}(u,e,r)*a,f===r&&(x-=u/2);const t=e.getPixelForDecimal(0),s=e.getPixelForDecimal(1),o=Math.min(t,s),h=Math.max(t,s);x=Math.max(Math.min(x,h),o),d=x+u,i&&!c&&(l._stacks[e.axis]._visualValues[n]=e.getValueForPixel(d)-e.getValueForPixel(x))}if(x===e.getPixelForValue(r)){const t=F(u)*e.getLineWidthForValue(r)/2;x+=t,u-=t}return{size:u,base:x,head:d,center:d+u/2}}_calculateBarIndexPixels(t,e){const i=e.scale,n=this.options,o=n.skipNull,a=l(n.maxBarThickness,1/0);let r,h;const c=this._getAxisCount();if(e.grouped){const i=o?this._getStackCount(t):e.stackCount,d="flex"===n.barThickness?function(t,e,i,s){const n=e.pixels,o=n[t];let a=t>0?n[t-1]:null,r=t=0;--i)e=Math.max(e,t[i].size(this.resolveDataElementOptions(i))/2);return e>0&&e}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart.data.labels||[],{xScale:s,yScale:n}=e,o=this.getParsed(t),a=s.getLabelForValue(o.x),r=n.getLabelForValue(o.y),l=o._custom;return{label:i[t]||"",value:"("+a+", "+r+(l?", "+l:"")+")"}}update(t){const e=this._cachedMeta.data;this.updateElements(e,0,e.length,t)}updateElements(t,e,i,s){const n="reset"===s,{iScale:o,vScale:a}=this._cachedMeta,{sharedOptions:r,includeOptions:l}=this._getSharedOptions(e,s),h=o.axis,c=a.axis;for(let d=e;d0&&this.getParsed(e-1);for(let i=0;i<_;++i){const g=t[i],_=x?g:{};if(i=b){_.skip=!0;continue}const v=this.getParsed(i),M=s(v[f]),w=_[u]=a.getPixelForValue(v[u],i),k=_[f]=o||M?r.getBasePixel():r.getPixelForValue(l?this.applyStack(r,v,l):v[f],i);_.skip=isNaN(w)||isNaN(k)||M,_.stop=i>0&&Math.abs(v[u]-y[u])>m,p&&(_.parsed=v,_.raw=h.data[i]),d&&(_.options=c||this.resolveDataElementOptions(i,g.active?"active":n)),x||this.updateElement(g,i,_,n),y=v}}getMaxOverflow(){const t=this._cachedMeta,e=t.dataset,i=e.options&&e.options.borderWidth||0,s=t.data||[];if(!s.length)return i;const n=s[0].size(this.resolveDataElementOptions(0)),o=s[s.length-1].size(this.resolveDataElementOptions(s.length-1));return Math.max(i,n,o)/2}draw(){const t=this._cachedMeta;t.dataset.updateControlPoints(this.chart.chartArea,t.iScale.axis),super.draw()}},PieController:class extends $n{static id="pie";static defaults={cutout:0,rotation:0,circumference:360,radius:"100%"}},PolarAreaController:Yn,RadarController:class extends js{static id="radar";static defaults={datasetElementType:"line",dataElementType:"point",indexAxis:"r",showLine:!0,elements:{line:{fill:"start"}}};static overrides={aspectRatio:1,scales:{r:{type:"radialLinear"}}};getLabelAndValue(t){const e=this._cachedMeta.vScale,i=this.getParsed(t);return{label:e.getLabels()[t],value:""+e.getLabelForValue(i[e.axis])}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta,i=e.dataset,s=e.data||[],n=e.iScale.getLabels();if(i.points=s,"resize"!==t){const e=this.resolveDatasetElementOptions(t);this.options.showLine||(e.borderWidth=0);const o={_loop:!0,_fullLoop:n.length===s.length,options:e};this.updateElement(i,void 0,o,t)}this.updateElements(s,0,s.length,t)}updateElements(t,e,i,s){const n=this._cachedMeta.rScale,o="reset"===s;for(let a=e;a0&&this.getParsed(e-1);for(let c=e;c0&&Math.abs(i[f]-_[f])>x,m&&(p.parsed=i,p.raw=h.data[c]),u&&(p.options=d||this.resolveDataElementOptions(c,e.active?"active":n)),b||this.updateElement(e,c,p,n),_=i}this.updateSharedOptions(d,n,c)}getMaxOverflow(){const t=this._cachedMeta,e=t.data||[];if(!this.options.showLine){let t=0;for(let i=e.length-1;i>=0;--i)t=Math.max(t,e[i].size(this.resolveDataElementOptions(i))/2);return t>0&&t}const i=t.dataset,s=i.options&&i.options.borderWidth||0;if(!e.length)return s;const n=e[0].size(this.resolveDataElementOptions(0)),o=e[e.length-1].size(this.resolveDataElementOptions(e.length-1));return Math.max(s,n,o)/2}}});function Xn(t,e,i,s){const n=vi(t.options.borderRadius,["outerStart","outerEnd","innerStart","innerEnd"]);const o=(i-e)/2,a=Math.min(o,s*e/2),r=t=>{const e=(i-Math.min(o,t))*s/2;return Z(t,0,Math.min(o,e))};return{outerStart:r(n.outerStart),outerEnd:r(n.outerEnd),innerStart:Z(n.innerStart,0,a),innerEnd:Z(n.innerEnd,0,a)}}function qn(t,e,i,s){return{x:i+t*Math.cos(e),y:s+t*Math.sin(e)}}function Kn(t,e,i,s,n,o){const{x:a,y:r,startAngle:l,pixelMargin:h,innerRadius:c}=e,d=Math.max(e.outerRadius+s+i-h,0),u=c>0?c+s+i+h:0;let f=0;const g=n-l;if(s){const t=((c>0?c-s:0)+(d>0?d-s:0))/2;f=(g-(0!==t?g*t/(t+s):g))/2}const p=(g-Math.max(.001,g*d-i/C)/d)/2,m=l+p+f,x=n-p-f,{outerStart:b,outerEnd:_,innerStart:y,innerEnd:v}=Xn(e,u,d,x-m),M=d-b,w=d-_,k=m+b/M,S=x-_/w,P=u+y,D=u+v,O=m+y/P,A=x-v/D;if(t.beginPath(),o){const e=(k+S)/2;if(t.arc(a,r,d,k,e),t.arc(a,r,d,e,S),_>0){const e=qn(w,S,a,r);t.arc(e.x,e.y,_,S,x+E)}const i=qn(D,x,a,r);if(t.lineTo(i.x,i.y),v>0){const e=qn(D,A,a,r);t.arc(e.x,e.y,v,x+E,A+Math.PI)}const s=(x-v/u+(m+y/u))/2;if(t.arc(a,r,u,x-v/u,s,!0),t.arc(a,r,u,s,m+y/u,!0),y>0){const e=qn(P,O,a,r);t.arc(e.x,e.y,y,O+Math.PI,m-E)}const n=qn(M,m,a,r);if(t.lineTo(n.x,n.y),b>0){const e=qn(M,k,a,r);t.arc(e.x,e.y,b,m-E,k)}}else{t.moveTo(a,r);const e=Math.cos(k)*d+a,i=Math.sin(k)*d+r;t.lineTo(e,i);const s=Math.cos(S)*d+a,n=Math.sin(S)*d+r;t.lineTo(s,n)}t.closePath()}function Gn(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r,options:l}=e,{borderWidth:h,borderJoinStyle:c,borderDash:d,borderDashOffset:u,borderRadius:f}=l,g="inner"===l.borderAlign;if(!h)return;t.setLineDash(d||[]),t.lineDashOffset=u,g?(t.lineWidth=2*h,t.lineJoin=c||"round"):(t.lineWidth=h,t.lineJoin=c||"bevel");let p=e.endAngle;if(o){Kn(t,e,i,s,p,n);for(let e=0;en?(h=n/l,t.arc(o,a,l,i+h,s-h,!0)):t.arc(o,a,n,i+E,s-E),t.closePath(),t.clip()}(t,e,p),l.selfJoin&&p-a>=C&&0===f&&"miter"!==c&&function(t,e,i){const{startAngle:s,x:n,y:o,outerRadius:a,innerRadius:r,options:l}=e,{borderWidth:h,borderJoinStyle:c}=l,d=Math.min(h/a,G(s-i));if(t.beginPath(),t.arc(n,o,a-h/2,s+d/2,i-d/2),r>0){const e=Math.min(h/r,G(s-i));t.arc(n,o,r+h/2,i-e/2,s+e/2,!0)}else{const e=Math.min(h/2,a*G(s-i));if("round"===c)t.arc(n,o,e,i-C/2,s+C/2,!0);else if("bevel"===c){const a=2*e*e,r=-a*Math.cos(i+C/2)+n,l=-a*Math.sin(i+C/2)+o,h=a*Math.cos(s+C/2)+n,c=a*Math.sin(s+C/2)+o;t.lineTo(r,l),t.lineTo(h,c)}}t.closePath(),t.moveTo(0,0),t.rect(0,0,t.canvas.width,t.canvas.height),t.clip("evenodd")}(t,e,p),o||(Kn(t,e,i,s,p,n),t.stroke())}function Jn(t,e,i=e){t.lineCap=l(i.borderCapStyle,e.borderCapStyle),t.setLineDash(l(i.borderDash,e.borderDash)),t.lineDashOffset=l(i.borderDashOffset,e.borderDashOffset),t.lineJoin=l(i.borderJoinStyle,e.borderJoinStyle),t.lineWidth=l(i.borderWidth,e.borderWidth),t.strokeStyle=l(i.borderColor,e.borderColor)}function Zn(t,e,i){t.lineTo(i.x,i.y)}function Qn(t,e,i={}){const s=t.length,{start:n=0,end:o=s-1}=i,{start:a,end:r}=e,l=Math.max(n,a),h=Math.min(o,r),c=nr&&o>r;return{count:s,start:l,loop:e.loop,ilen:h(a+(h?r-t:t))%o,_=()=>{f!==g&&(t.lineTo(m,g),t.lineTo(m,f),t.lineTo(m,p))};for(l&&(d=n[b(0)],t.moveTo(d.x,d.y)),c=0;c<=r;++c){if(d=n[b(c)],d.skip)continue;const e=d.x,i=d.y,s=0|e;s===u?(ig&&(g=i),m=(x*m+e)/++x):(_(),t.lineTo(e,i),u=s,x=0,f=g=i),p=i}_()}function io(t){const e=t.options,i=e.borderDash&&e.borderDash.length;return!(t._decimated||t._loop||e.tension||"monotone"===e.cubicInterpolationMode||e.stepped||i)?eo:to}const so="function"==typeof Path2D;function no(t,e,i,s){so&&!e.options.segment?function(t,e,i,s){let n=e._path;n||(n=e._path=new Path2D,e.path(n,i,s)&&n.closePath()),Jn(t,e.options),t.stroke(n)}(t,e,i,s):function(t,e,i,s){const{segments:n,options:o}=e,a=io(e);for(const r of n)Jn(t,o,r.style),t.beginPath(),a(t,e,r,{start:i,end:i+s-1})&&t.closePath(),t.stroke()}(t,e,i,s)}class oo extends $s{static id="line";static defaults={borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderWidth:3,capBezierPoints:!0,cubicInterpolationMode:"default",fill:!1,spanGaps:!1,stepped:!1,tension:0};static defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};static descriptors={_scriptable:!0,_indexable:t=>"borderDash"!==t&&"fill"!==t};constructor(t){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,t&&Object.assign(this,t)}updateControlPoints(t,e){const i=this.options;if((i.tension||"monotone"===i.cubicInterpolationMode)&&!i.stepped&&!this._pointsUpdated){const s=i.spanGaps?this._loop:this._fullLoop;hi(this._points,i,t,s,e),this._pointsUpdated=!0}}set points(t){this._points=t,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=zi(this,this.options.segment))}first(){const t=this.segments,e=this.points;return t.length&&e[t[0].start]}last(){const t=this.segments,e=this.points,i=t.length;return i&&e[t[i-1].end]}interpolate(t,e){const i=this.options,s=t[e],n=this.points,o=Ii(this,{property:e,start:s,end:s});if(!o.length)return;const a=[],r=function(t){return t.stepped?pi:t.tension||"monotone"===t.cubicInterpolationMode?mi:gi}(i);let l,h;for(l=0,h=o.length;l"borderDash"!==t};circumference;endAngle;fullCircles;innerRadius;outerRadius;pixelMargin;startAngle;constructor(t){super(),this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,t&&Object.assign(this,t)}inRange(t,e,i){const s=this.getProps(["x","y"],i),{angle:n,distance:o}=X(s,{x:t,y:e}),{startAngle:a,endAngle:r,innerRadius:h,outerRadius:c,circumference:d}=this.getProps(["startAngle","endAngle","innerRadius","outerRadius","circumference"],i),u=(this.options.spacing+this.options.borderWidth)/2,f=l(d,r-a),g=J(n,a,r)&&a!==r,p=f>=O||g,m=tt(o,h+u,c+u);return p&&m}getCenterPoint(t){const{x:e,y:i,startAngle:s,endAngle:n,innerRadius:o,outerRadius:a}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius"],t),{offset:r,spacing:l}=this.options,h=(s+n)/2,c=(o+a+l+r)/2;return{x:e+Math.cos(h)*c,y:i+Math.sin(h)*c}}tooltipPosition(t){return this.getCenterPoint(t)}draw(t){const{options:e,circumference:i}=this,s=(e.offset||0)/4,n=(e.spacing||0)/2,o=e.circular;if(this.pixelMargin="inner"===e.borderAlign?.33:0,this.fullCircles=i>O?Math.floor(i/O):0,0===i||this.innerRadius<0||this.outerRadius<0)return;t.save();const a=(this.startAngle+this.endAngle)/2;t.translate(Math.cos(a)*s,Math.sin(a)*s);const r=s*(1-Math.sin(Math.min(C,i||0)));t.fillStyle=e.backgroundColor,t.strokeStyle=e.borderColor,function(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r}=e;let l=e.endAngle;if(o){Kn(t,e,i,s,l,n);for(let e=0;e("string"==typeof e?(i=t.push(e)-1,s.unshift({index:i,label:e})):isNaN(e)&&(i=null),i))(t,e,i,s);return n!==t.lastIndexOf(e)?i:n}function mo(t){const e=this.getLabels();return t>=0&&ts=e?s:t,a=t=>n=i?n:t;if(t){const t=F(s),e=F(n);t<0&&e<0?a(0):t>0&&e>0&&o(0)}if(s===n){let e=0===n?1:Math.abs(.05*n);a(n+e),t||o(s-e)}this.min=s,this.max=n}getTickLimit(){const t=this.options.ticks;let e,{maxTicksLimit:i,stepSize:s}=t;return s?(e=Math.ceil(this.max/s)-Math.floor(this.min/s)+1,e>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${s} would result generating up to ${e} ticks. Limiting to 1000.`),e=1e3)):(e=this.computeTickLimit(),i=i||11),i&&(e=Math.min(i,e)),e}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){const t=this.options,e=t.ticks;let i=this.getTickLimit();i=Math.max(2,i);const n=function(t,e){const i=[],{bounds:n,step:o,min:a,max:r,precision:l,count:h,maxTicks:c,maxDigits:d,includeBounds:u}=t,f=o||1,g=c-1,{min:p,max:m}=e,x=!s(a),b=!s(r),_=!s(h),y=(m-p)/(d+1);let v,M,w,k,S=B((m-p)/g/f)*f;if(S<1e-14&&!x&&!b)return[{value:p},{value:m}];k=Math.ceil(m/S)-Math.floor(p/S),k>g&&(S=B(k*S/g/f)*f),s(l)||(v=Math.pow(10,l),S=Math.ceil(S*v)/v),"ticks"===n?(M=Math.floor(p/S)*S,w=Math.ceil(m/S)*S):(M=p,w=m),x&&b&&o&&H((r-a)/o,S/1e3)?(k=Math.round(Math.min((r-a)/S,c)),S=(r-a)/k,M=a,w=r):_?(M=x?a:M,w=b?r:w,k=h-1,S=(w-M)/k):(k=(w-M)/S,k=V(k,Math.round(k),S/1e3)?Math.round(k):Math.ceil(k));const P=Math.max(U(S),U(M));v=Math.pow(10,s(l)?P:l),M=Math.round(M*v)/v,w=Math.round(w*v)/v;let D=0;for(x&&(u&&M!==a?(i.push({value:a}),Mr)break;i.push({value:t})}return b&&u&&w!==r?i.length&&V(i[i.length-1].value,r,xo(r,y,t))?i[i.length-1].value=r:i.push({value:r}):b&&w!==r||i.push({value:w}),i}({maxTicks:i,bounds:t.bounds,min:t.min,max:t.max,precision:e.precision,step:e.stepSize,count:e.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:e.minRotation||0,includeBounds:!1!==e.includeBounds},this._range||this);return"ticks"===t.bounds&&j(n,this,"value"),t.reverse?(n.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),n}configure(){const t=this.ticks;let e=this.min,i=this.max;if(super.configure(),this.options.offset&&t.length){const s=(i-e)/Math.max(t.length-1,1)/2;e-=s,i+=s}this._startValue=e,this._endValue=i,this._valueRange=i-e}getLabelForValue(t){return ne(t,this.chart.options.locale,this.options.ticks.format)}}class _o extends bo{static id="linear";static defaults={ticks:{callback:ae.formatters.numeric}};determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?t:0,this.max=a(e)?e:1,this.handleTickRangeOptions()}computeTickLimit(){const t=this.isHorizontal(),e=t?this.width:this.height,i=$(this.options.ticks.minRotation),s=(t?Math.sin(i):Math.cos(i))||.001,n=this._resolveTickFontOptions(0);return Math.ceil(e/Math.min(40,n.lineHeight/s))}getPixelForValue(t){return null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getValueForPixel(t){return this._startValue+this.getDecimalForPixel(t)*this._valueRange}}const yo=t=>Math.floor(z(t)),vo=(t,e)=>Math.pow(10,yo(t)+e);function Mo(t){return 1===t/Math.pow(10,yo(t))}function wo(t,e,i){const s=Math.pow(10,i),n=Math.floor(t/s);return Math.ceil(e/s)-n}function ko(t,{min:e,max:i}){e=r(t.min,e);const s=[],n=yo(e);let o=function(t,e){let i=yo(e-t);for(;wo(t,e,i)>10;)i++;for(;wo(t,e,i)<10;)i--;return Math.min(i,yo(t))}(e,i),a=o<0?Math.pow(10,Math.abs(o)):1;const l=Math.pow(10,o),h=n>o?Math.pow(10,n):0,c=Math.round((e-h)*a)/a,d=Math.floor((e-h)/l/10)*l*10;let u=Math.floor((c-d)/Math.pow(10,o)),f=r(t.min,Math.round((h+d+u*Math.pow(10,o))*a)/a);for(;f=10?u=u<15?15:20:u++,u>=20&&(o++,u=2,a=o>=0?1:a),f=Math.round((h+d+u*Math.pow(10,o))*a)/a;const g=r(t.max,f);return s.push({value:g,major:Mo(g),significand:u}),s}class So extends tn{static id="logarithmic";static defaults={ticks:{callback:ae.formatters.logarithmic,major:{enabled:!0}}};constructor(t){super(t),this.start=void 0,this.end=void 0,this._startValue=void 0,this._valueRange=0}parse(t,e){const i=bo.prototype.parse.apply(this,[t,e]);if(0!==i)return a(i)&&i>0?i:null;this._zero=!0}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?Math.max(0,t):null,this.max=a(e)?Math.max(0,e):null,this.options.beginAtZero&&(this._zero=!0),this._zero&&this.min!==this._suggestedMin&&!a(this._userMin)&&(this.min=t===vo(this.min,0)?vo(this.min,-1):vo(this.min,0)),this.handleTickRangeOptions()}handleTickRangeOptions(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let i=this.min,s=this.max;const n=e=>i=t?i:e,o=t=>s=e?s:t;i===s&&(i<=0?(n(1),o(10)):(n(vo(i,-1)),o(vo(s,1)))),i<=0&&n(vo(s,-1)),s<=0&&o(vo(i,1)),this.min=i,this.max=s}buildTicks(){const t=this.options,e=ko({min:this._userMin,max:this._userMax},this);return"ticks"===t.bounds&&j(e,this,"value"),t.reverse?(e.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),e}getLabelForValue(t){return void 0===t?"0":ne(t,this.chart.options.locale,this.options.ticks.format)}configure(){const t=this.min;super.configure(),this._startValue=z(t),this._valueRange=z(this.max)-z(t)}getPixelForValue(t){return void 0!==t&&0!==t||(t=this.min),null===t||isNaN(t)?NaN:this.getPixelForDecimal(t===this.min?0:(z(t)-this._startValue)/this._valueRange)}getValueForPixel(t){const e=this.getDecimalForPixel(t);return Math.pow(10,this._startValue+e*this._valueRange)}}function Po(t){const e=t.ticks;if(e.display&&t.display){const t=ki(e.backdropPadding);return l(e.font&&e.font.size,ue.font.size)+t.height}return 0}function Do(t,e,i,s,n){return t===s||t===n?{start:e-i/2,end:e+i/2}:tn?{start:e-i,end:e}:{start:e,end:e+i}}function Co(t){const e={l:t.left+t._padding.left,r:t.right-t._padding.right,t:t.top+t._padding.top,b:t.bottom-t._padding.bottom},i=Object.assign({},e),s=[],o=[],a=t._pointLabels.length,r=t.options.pointLabels,l=r.centerPointLabels?C/a:0;for(let u=0;ue.r&&(r=(s.end-e.r)/o,t.r=Math.max(t.r,e.r+r)),n.starte.b&&(l=(n.end-e.b)/a,t.b=Math.max(t.b,e.b+l))}function Ao(t,e,i){const s=t.drawingArea,{extra:n,additionalAngle:o,padding:a,size:r}=i,l=t.getPointPosition(e,s+n+a,o),h=Math.round(Y(G(l.angle+E))),c=function(t,e,i){90===i||270===i?t-=e/2:(i>270||i<90)&&(t-=e);return t}(l.y,r.h,h),d=function(t){if(0===t||180===t)return"center";if(t<180)return"left";return"right"}(h),u=function(t,e,i){"right"===i?t-=e:"center"===i&&(t-=e/2);return t}(l.x,r.w,d);return{visible:!0,x:l.x,y:c,textAlign:d,left:u,top:c,right:u+r.w,bottom:c+r.h}}function To(t,e){if(!e)return!0;const{left:i,top:s,right:n,bottom:o}=t;return!(Re({x:i,y:s},e)||Re({x:i,y:o},e)||Re({x:n,y:s},e)||Re({x:n,y:o},e))}function Lo(t,e,i){const{left:n,top:o,right:a,bottom:r}=i,{backdropColor:l}=e;if(!s(l)){const i=wi(e.borderRadius),s=ki(e.backdropPadding);t.fillStyle=l;const h=n-s.left,c=o-s.top,d=a-n+s.width,u=r-o+s.height;Object.values(i).some((t=>0!==t))?(t.beginPath(),He(t,{x:h,y:c,w:d,h:u,radius:i}),t.fill()):t.fillRect(h,c,d,u)}}function Eo(t,e,i,s){const{ctx:n}=t;if(i)n.arc(t.xCenter,t.yCenter,e,0,O);else{let i=t.getPointPosition(0,e);n.moveTo(i.x,i.y);for(let o=1;ot,padding:5,centerPointLabels:!1}};static defaultRoutes={"angleLines.color":"borderColor","pointLabels.color":"color","ticks.color":"color"};static descriptors={angleLines:{_fallback:"grid"}};constructor(t){super(t),this.xCenter=void 0,this.yCenter=void 0,this.drawingArea=void 0,this._pointLabels=[],this._pointLabelItems=[]}setDimensions(){const t=this._padding=ki(Po(this.options)/2),e=this.width=this.maxWidth-t.width,i=this.height=this.maxHeight-t.height;this.xCenter=Math.floor(this.left+e/2+t.left),this.yCenter=Math.floor(this.top+i/2+t.top),this.drawingArea=Math.floor(Math.min(e,i)/2)}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!1);this.min=a(t)&&!isNaN(t)?t:0,this.max=a(e)&&!isNaN(e)?e:0,this.handleTickRangeOptions()}computeTickLimit(){return Math.ceil(this.drawingArea/Po(this.options))}generateTickLabels(t){bo.prototype.generateTickLabels.call(this,t),this._pointLabels=this.getLabels().map(((t,e)=>{const i=d(this.options.pointLabels.callback,[t,e],this);return i||0===i?i:""})).filter(((t,e)=>this.chart.getDataVisibility(e)))}fit(){const t=this.options;t.display&&t.pointLabels.display?Co(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(t,e,i,s){this.xCenter+=Math.floor((t-e)/2),this.yCenter+=Math.floor((i-s)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(t,e,i,s))}getIndexAngle(t){return G(t*(O/(this._pointLabels.length||1))+$(this.options.startAngle||0))}getDistanceFromCenterForValue(t){if(s(t))return NaN;const e=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-t)*e:(t-this.min)*e}getValueForDistanceFromCenter(t){if(s(t))return NaN;const e=t/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-e:this.min+e}getPointLabelContext(t){const e=this._pointLabels||[];if(t>=0&&t=0;n--){const e=t._pointLabelItems[n];if(!e.visible)continue;const o=s.setContext(t.getPointLabelContext(n));Lo(i,o,e);const a=Si(o.font),{x:r,y:l,textAlign:h}=e;Ne(i,t._pointLabels[n],r,l+a.lineHeight/2,a,{color:o.color,textAlign:h,textBaseline:"middle"})}}(this,o),s.display&&this.ticks.forEach(((t,e)=>{if(0!==e||0===e&&this.min<0){r=this.getDistanceFromCenterForValue(t.value);const i=this.getContext(e),a=s.setContext(i),l=n.setContext(i);!function(t,e,i,s,n){const o=t.ctx,a=e.circular,{color:r,lineWidth:l}=e;!a&&!s||!r||!l||i<0||(o.save(),o.strokeStyle=r,o.lineWidth=l,o.setLineDash(n.dash||[]),o.lineDashOffset=n.dashOffset,o.beginPath(),Eo(t,i,a,s),o.closePath(),o.stroke(),o.restore())}(this,a,r,o,l)}})),i.display){for(t.save(),a=o-1;a>=0;a--){const s=i.setContext(this.getPointLabelContext(a)),{color:n,lineWidth:o}=s;o&&n&&(t.lineWidth=o,t.strokeStyle=n,t.setLineDash(s.borderDash),t.lineDashOffset=s.borderDashOffset,r=this.getDistanceFromCenterForValue(e.reverse?this.min:this.max),l=this.getPointPosition(a,r),t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(l.x,l.y),t.stroke())}t.restore()}}drawBorder(){}drawLabels(){const t=this.ctx,e=this.options,i=e.ticks;if(!i.display)return;const s=this.getIndexAngle(0);let n,o;t.save(),t.translate(this.xCenter,this.yCenter),t.rotate(s),t.textAlign="center",t.textBaseline="middle",this.ticks.forEach(((s,a)=>{if(0===a&&this.min>=0&&!e.reverse)return;const r=i.setContext(this.getContext(a)),l=Si(r.font);if(n=this.getDistanceFromCenterForValue(this.ticks[a].value),r.showLabelBackdrop){t.font=l.string,o=t.measureText(s.label).width,t.fillStyle=r.backdropColor;const e=ki(r.backdropPadding);t.fillRect(-o/2-e.left,-n-l.size/2-e.top,o+e.width,l.size+e.height)}Ne(t,s.label,0,-n,l,{color:r.color,strokeColor:r.textStrokeColor,strokeWidth:r.textStrokeWidth})})),t.restore()}drawTitle(){}}const Io={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},zo=Object.keys(Io);function Fo(t,e){return t-e}function Vo(t,e){if(s(e))return null;const i=t._adapter,{parser:n,round:o,isoWeekday:r}=t._parseOpts;let l=e;return"function"==typeof n&&(l=n(l)),a(l)||(l="string"==typeof n?i.parse(l,n):i.parse(l)),null===l?null:(o&&(l="week"!==o||!N(r)&&!0!==r?i.startOf(l,o):i.startOf(l,"isoWeek",r)),+l)}function Bo(t,e,i,s){const n=zo.length;for(let o=zo.indexOf(t);o=e?i[s]:i[n]]=!0}}else t[e]=!0}function No(t,e,i){const s=[],n={},o=e.length;let a,r;for(a=0;a=0&&(e[l].major=!0);return e}(t,s,n,i):s}class Ho extends tn{static id="time";static defaults={bounds:"data",adapters:{},time:{parser:!1,unit:!1,round:!1,isoWeekday:!1,minUnit:"millisecond",displayFormats:{}},ticks:{source:"auto",callback:!1,major:{enabled:!1}}};constructor(t){super(t),this._cache={data:[],labels:[],all:[]},this._unit="day",this._majorUnit=void 0,this._offsets={},this._normalized=!1,this._parseOpts=void 0}init(t,e={}){const i=t.time||(t.time={}),s=this._adapter=new In._date(t.adapters.date);s.init(e),b(i.displayFormats,s.formats()),this._parseOpts={parser:i.parser,round:i.round,isoWeekday:i.isoWeekday},super.init(t),this._normalized=e.normalized}parse(t,e){return void 0===t?null:Vo(this,t)}beforeLayout(){super.beforeLayout(),this._cache={data:[],labels:[],all:[]}}determineDataLimits(){const t=this.options,e=this._adapter,i=t.time.unit||"day";let{min:s,max:n,minDefined:o,maxDefined:r}=this.getUserBounds();function l(t){o||isNaN(t.min)||(s=Math.min(s,t.min)),r||isNaN(t.max)||(n=Math.max(n,t.max))}o&&r||(l(this._getLabelBounds()),"ticks"===t.bounds&&"labels"===t.ticks.source||l(this.getMinMax(!1))),s=a(s)&&!isNaN(s)?s:+e.startOf(Date.now(),i),n=a(n)&&!isNaN(n)?n:+e.endOf(Date.now(),i)+1,this.min=Math.min(s,n-1),this.max=Math.max(s+1,n)}_getLabelBounds(){const t=this.getLabelTimestamps();let e=Number.POSITIVE_INFINITY,i=Number.NEGATIVE_INFINITY;return t.length&&(e=t[0],i=t[t.length-1]),{min:e,max:i}}buildTicks(){const t=this.options,e=t.time,i=t.ticks,s="labels"===i.source?this.getLabelTimestamps():this._generate();"ticks"===t.bounds&&s.length&&(this.min=this._userMin||s[0],this.max=this._userMax||s[s.length-1]);const n=this.min,o=nt(s,n,this.max);return this._unit=e.unit||(i.autoSkip?Bo(e.minUnit,this.min,this.max,this._getLabelCapacity(n)):function(t,e,i,s,n){for(let o=zo.length-1;o>=zo.indexOf(i);o--){const i=zo[o];if(Io[i].common&&t._adapter.diff(n,s,i)>=e-1)return i}return zo[i?zo.indexOf(i):0]}(this,o.length,e.minUnit,this.min,this.max)),this._majorUnit=i.major.enabled&&"year"!==this._unit?function(t){for(let e=zo.indexOf(t)+1,i=zo.length;e+t.value)))}initOffsets(t=[]){let e,i,s=0,n=0;this.options.offset&&t.length&&(e=this.getDecimalForValue(t[0]),s=1===t.length?1-e:(this.getDecimalForValue(t[1])-e)/2,i=this.getDecimalForValue(t[t.length-1]),n=1===t.length?i:(i-this.getDecimalForValue(t[t.length-2]))/2);const o=t.length<3?.5:.25;s=Z(s,0,o),n=Z(n,0,o),this._offsets={start:s,end:n,factor:1/(s+1+n)}}_generate(){const t=this._adapter,e=this.min,i=this.max,s=this.options,n=s.time,o=n.unit||Bo(n.minUnit,e,i,this._getLabelCapacity(e)),a=l(s.ticks.stepSize,1),r="week"===o&&n.isoWeekday,h=N(r)||!0===r,c={};let d,u,f=e;if(h&&(f=+t.startOf(f,"isoWeek",r)),f=+t.startOf(f,h?"day":o),t.diff(i,e,o)>1e5*a)throw new Error(e+" and "+i+" are too far apart with stepSize of "+a+" "+o);const g="data"===s.ticks.source&&this.getDataTimestamps();for(d=f,u=0;d+t))}getLabelForValue(t){const e=this._adapter,i=this.options.time;return i.tooltipFormat?e.format(t,i.tooltipFormat):e.format(t,i.displayFormats.datetime)}format(t,e){const i=this.options.time.displayFormats,s=this._unit,n=e||i[s];return this._adapter.format(t,n)}_tickFormatFunction(t,e,i,s){const n=this.options,o=n.ticks.callback;if(o)return d(o,[t,e,i],this);const a=n.time.displayFormats,r=this._unit,l=this._majorUnit,h=r&&a[r],c=l&&a[l],u=i[e],f=l&&c&&u&&u.major;return this._adapter.format(t,s||(f?c:h))}generateTickLabels(t){let e,i,s;for(e=0,i=t.length;e0?a:1}getDataTimestamps(){let t,e,i=this._cache.data||[];if(i.length)return i;const s=this.getMatchingVisibleMetas();if(this._normalized&&s.length)return this._cache.data=s[0].controller.getAllParsedValues(this);for(t=0,e=s.length;t=t[r].pos&&e<=t[l].pos&&({lo:r,hi:l}=it(t,"pos",e)),({pos:s,time:o}=t[r]),({pos:n,time:a}=t[l])):(e>=t[r].time&&e<=t[l].time&&({lo:r,hi:l}=it(t,"time",e)),({time:s,pos:o}=t[r]),({time:n,pos:a}=t[l]));const h=n-s;return h?o+(a-o)*(e-s)/h:o}var $o=Object.freeze({__proto__:null,CategoryScale:class extends tn{static id="category";static defaults={ticks:{callback:mo}};constructor(t){super(t),this._startValue=void 0,this._valueRange=0,this._addedLabels=[]}init(t){const e=this._addedLabels;if(e.length){const t=this.getLabels();for(const{index:i,label:s}of e)t[i]===s&&t.splice(i,1);this._addedLabels=[]}super.init(t)}parse(t,e){if(s(t))return null;const i=this.getLabels();return((t,e)=>null===t?null:Z(Math.round(t),0,e))(e=isFinite(e)&&i[e]===t?e:po(i,t,l(e,t),this._addedLabels),i.length-1)}determineDataLimits(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let{min:i,max:s}=this.getMinMax(!0);"ticks"===this.options.bounds&&(t||(i=0),e||(s=this.getLabels().length-1)),this.min=i,this.max=s}buildTicks(){const t=this.min,e=this.max,i=this.options.offset,s=[];let n=this.getLabels();n=0===t&&e===n.length-1?n:n.slice(t,e+1),this._valueRange=Math.max(n.length-(i?0:1),1),this._startValue=this.min-(i?.5:0);for(let i=t;i<=e;i++)s.push({value:i});return s}getLabelForValue(t){return mo.call(this,t)}configure(){super.configure(),this.isHorizontal()||(this._reversePixels=!this._reversePixels)}getPixelForValue(t){return"number"!=typeof t&&(t=this.parse(t)),null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getValueForPixel(t){return Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange)}getBasePixel(){return this.bottom}},LinearScale:_o,LogarithmicScale:So,RadialLinearScale:Ro,TimeScale:Ho,TimeSeriesScale:class extends Ho{static id="timeseries";static defaults=Ho.defaults;constructor(t){super(t),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){const t=this._getTimestampsForTable(),e=this._table=this.buildLookupTable(t);this._minPos=jo(e,this.min),this._tableRange=jo(e,this.max)-this._minPos,super.initOffsets(t)}buildLookupTable(t){const{min:e,max:i}=this,s=[],n=[];let o,a,r,l,h;for(o=0,a=t.length;o=e&&l<=i&&s.push(l);if(s.length<2)return[{time:e,pos:0},{time:i,pos:1}];for(o=0,a=s.length;ot-e))}_getTimestampsForTable(){let t=this._cache.all||[];if(t.length)return t;const e=this.getDataTimestamps(),i=this.getLabelTimestamps();return t=e.length&&i.length?this.normalize(e.concat(i)):e.length?e:i,t=this._cache.all=t,t}getDecimalForValue(t){return(jo(this._table,t)-this._minPos)/this._tableRange}getValueForPixel(t){const e=this._offsets,i=this.getDecimalForPixel(t)/e.factor-e.end;return jo(this._table,i*this._tableRange+this._minPos,!0)}}});const Yo=["rgb(54, 162, 235)","rgb(255, 99, 132)","rgb(255, 159, 64)","rgb(255, 205, 86)","rgb(75, 192, 192)","rgb(153, 102, 255)","rgb(201, 203, 207)"],Uo=Yo.map((t=>t.replace("rgb(","rgba(").replace(")",", 0.5)")));function Xo(t){return Yo[t%Yo.length]}function qo(t){return Uo[t%Uo.length]}function Ko(t){let e=0;return(i,s)=>{const n=t.getDatasetMeta(s).controller;n instanceof $n?e=function(t,e){return t.backgroundColor=t.data.map((()=>Xo(e++))),e}(i,e):n instanceof Yn?e=function(t,e){return t.backgroundColor=t.data.map((()=>qo(e++))),e}(i,e):n&&(e=function(t,e){return t.borderColor=Xo(e),t.backgroundColor=qo(e),++e}(i,e))}}function Go(t){let e;for(e in t)if(t[e].borderColor||t[e].backgroundColor)return!0;return!1}var Jo={id:"colors",defaults:{enabled:!0,forceOverride:!1},beforeLayout(t,e,i){if(!i.enabled)return;const{data:{datasets:s},options:n}=t.config,{elements:o}=n,a=Go(s)||(r=n)&&(r.borderColor||r.backgroundColor)||o&&Go(o)||"rgba(0,0,0,0.1)"!==ue.borderColor||"rgba(0,0,0,0.1)"!==ue.backgroundColor;var r;if(!i.forceOverride&&a)return;const l=Ko(t);s.forEach(l)}};function Zo(t){if(t._decimated){const e=t._data;delete t._decimated,delete t._data,Object.defineProperty(t,"data",{configurable:!0,enumerable:!0,writable:!0,value:e})}}function Qo(t){t.data.datasets.forEach((t=>{Zo(t)}))}var ta={id:"decimation",defaults:{algorithm:"min-max",enabled:!1},beforeElementsUpdate:(t,e,i)=>{if(!i.enabled)return void Qo(t);const n=t.width;t.data.datasets.forEach(((e,o)=>{const{_data:a,indexAxis:r}=e,l=t.getDatasetMeta(o),h=a||e.data;if("y"===Pi([r,t.options.indexAxis]))return;if(!l.controller.supportsDecimation)return;const c=t.scales[l.xAxisID];if("linear"!==c.type&&"time"!==c.type)return;if(t.options.parsing)return;let{start:d,count:u}=function(t,e){const i=e.length;let s,n=0;const{iScale:o}=t,{min:a,max:r,minDefined:l,maxDefined:h}=o.getUserBounds();return l&&(n=Z(it(e,o.axis,a).lo,0,i-1)),s=h?Z(it(e,o.axis,r).hi+1,n,i)-n:i-n,{start:n,count:s}}(l,h);if(u<=(i.threshold||4*n))return void Zo(e);let f;switch(s(a)&&(e._data=h,delete e.data,Object.defineProperty(e,"data",{configurable:!0,enumerable:!0,get:function(){return this._decimated},set:function(t){this._data=t}})),i.algorithm){case"lttb":f=function(t,e,i,s,n){const o=n.samples||s;if(o>=i)return t.slice(e,e+i);const a=[],r=(i-2)/(o-2);let l=0;const h=e+i-1;let c,d,u,f,g,p=e;for(a[l++]=t[p],c=0;cu&&(u=f,d=t[s],g=s);a[l++]=d,p=g}return a[l++]=t[h],a}(h,d,u,n,i);break;case"min-max":f=function(t,e,i,n){let o,a,r,l,h,c,d,u,f,g,p=0,m=0;const x=[],b=e+i-1,_=t[e].x,y=t[b].x-_;for(o=e;og&&(g=l,d=o),p=(m*p+a.x)/++m;else{const i=o-1;if(!s(c)&&!s(d)){const e=Math.min(c,d),s=Math.max(c,d);e!==u&&e!==i&&x.push({...t[e],x:p}),s!==u&&s!==i&&x.push({...t[s],x:p})}o>0&&i!==u&&x.push(t[i]),x.push(a),h=e,m=0,f=g=l,c=d=u=o}}return x}(h,d,u,n);break;default:throw new Error(`Unsupported decimation algorithm '${i.algorithm}'`)}e._decimated=f}))},destroy(t){Qo(t)}};function ea(t,e,i,s){if(s)return;let n=e[t],o=i[t];return"angle"===t&&(n=G(n),o=G(o)),{property:t,start:n,end:o}}function ia(t,e,i){for(;e>t;e--){const t=i[e];if(!isNaN(t.x)&&!isNaN(t.y))break}return e}function sa(t,e,i,s){return t&&e?s(t[i],e[i]):t?t[i]:e?e[i]:0}function na(t,e){let i=[],s=!1;return n(t)?(s=!0,i=t):i=function(t,e){const{x:i=null,y:s=null}=t||{},n=e.points,o=[];return e.segments.forEach((({start:t,end:e})=>{e=ia(t,e,n);const a=n[t],r=n[e];null!==s?(o.push({x:a.x,y:s}),o.push({x:r.x,y:s})):null!==i&&(o.push({x:i,y:a.y}),o.push({x:i,y:r.y}))})),o}(t,e),i.length?new oo({points:i,options:{tension:0},_loop:s,_fullLoop:s}):null}function oa(t){return t&&!1!==t.fill}function aa(t,e,i){let s=t[e].fill;const n=[e];let o;if(!i)return s;for(;!1!==s&&-1===n.indexOf(s);){if(!a(s))return s;if(o=t[s],!o)return!1;if(o.visible)return s;n.push(s),s=o.fill}return!1}function ra(t,e,i){const s=function(t){const e=t.options,i=e.fill;let s=l(i&&i.target,i);void 0===s&&(s=!!e.backgroundColor);if(!1===s||null===s)return!1;if(!0===s)return"origin";return s}(t);if(o(s))return!isNaN(s.value)&&s;let n=parseFloat(s);return a(n)&&Math.floor(n)===n?function(t,e,i,s){"-"!==t&&"+"!==t||(i=e+i);if(i===e||i<0||i>=s)return!1;return i}(s[0],e,n,i):["origin","start","end","stack","shape"].indexOf(s)>=0&&s}function la(t,e,i){const s=[];for(let n=0;n=0;--e){const i=n[e].$filler;i&&(i.line.updateControlPoints(o,i.axis),s&&i.fill&&ua(t.ctx,i,o))}},beforeDatasetsDraw(t,e,i){if("beforeDatasetsDraw"!==i.drawTime)return;const s=t.getSortedVisibleDatasetMetas();for(let e=s.length-1;e>=0;--e){const i=s[e].$filler;oa(i)&&ua(t.ctx,i,t.chartArea)}},beforeDatasetDraw(t,e,i){const s=e.meta.$filler;oa(s)&&"beforeDatasetDraw"===i.drawTime&&ua(t.ctx,s,t.chartArea)},defaults:{propagate:!0,drawTime:"beforeDatasetDraw"}};const _a=(t,e)=>{let{boxHeight:i=e,boxWidth:s=e}=t;return t.usePointStyle&&(i=Math.min(i,e),s=t.pointStyleWidth||Math.min(s,e)),{boxWidth:s,boxHeight:i,itemHeight:Math.max(e,i)}};class ya extends $s{constructor(t){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e,i){this.maxWidth=t,this.maxHeight=e,this._margins=i,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const t=this.options.labels||{};let e=d(t.generateLabels,[this.chart],this)||[];t.filter&&(e=e.filter((e=>t.filter(e,this.chart.data)))),t.sort&&(e=e.sort(((e,i)=>t.sort(e,i,this.chart.data)))),this.options.reverse&&e.reverse(),this.legendItems=e}fit(){const{options:t,ctx:e}=this;if(!t.display)return void(this.width=this.height=0);const i=t.labels,s=Si(i.font),n=s.size,o=this._computeTitleHeight(),{boxWidth:a,itemHeight:r}=_a(i,n);let l,h;e.font=s.string,this.isHorizontal()?(l=this.maxWidth,h=this._fitRows(o,n,a,r)+10):(h=this.maxHeight,l=this._fitCols(o,s,a,r)+10),this.width=Math.min(l,t.maxWidth||this.maxWidth),this.height=Math.min(h,t.maxHeight||this.maxHeight)}_fitRows(t,e,i,s){const{ctx:n,maxWidth:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.lineWidths=[0],h=s+a;let c=t;n.textAlign="left",n.textBaseline="middle";let d=-1,u=-h;return this.legendItems.forEach(((t,f)=>{const g=i+e/2+n.measureText(t.text).width;(0===f||l[l.length-1]+g+2*a>o)&&(c+=h,l[l.length-(f>0?0:1)]=0,u+=h,d++),r[f]={left:0,top:u,row:d,width:g,height:s},l[l.length-1]+=g+a})),c}_fitCols(t,e,i,s){const{ctx:n,maxHeight:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.columnSizes=[],h=o-t;let c=a,d=0,u=0,f=0,g=0;return this.legendItems.forEach(((t,o)=>{const{itemWidth:p,itemHeight:m}=function(t,e,i,s,n){const o=function(t,e,i,s){let n=t.text;n&&"string"!=typeof n&&(n=n.reduce(((t,e)=>t.length>e.length?t:e)));return e+i.size/2+s.measureText(n).width}(s,t,e,i),a=function(t,e,i){let s=t;"string"!=typeof e.text&&(s=va(e,i));return s}(n,s,e.lineHeight);return{itemWidth:o,itemHeight:a}}(i,e,n,t,s);o>0&&u+m+2*a>h&&(c+=d+a,l.push({width:d,height:u}),f+=d+a,g++,d=u=0),r[o]={left:f,top:u,col:g,width:p,height:m},d=Math.max(d,p),u+=m+a})),c+=d,l.push({width:d,height:u}),c}adjustHitBoxes(){if(!this.options.display)return;const t=this._computeTitleHeight(),{legendHitBoxes:e,options:{align:i,labels:{padding:s},rtl:n}}=this,o=Oi(n,this.left,this.width);if(this.isHorizontal()){let n=0,a=ft(i,this.left+s,this.right-this.lineWidths[n]);for(const r of e)n!==r.row&&(n=r.row,a=ft(i,this.left+s,this.right-this.lineWidths[n])),r.top+=this.top+t+s,r.left=o.leftForLtr(o.x(a),r.width),a+=r.width+s}else{let n=0,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height);for(const r of e)r.col!==n&&(n=r.col,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height)),r.top=a,r.left+=this.left+s,r.left=o.leftForLtr(o.x(r.left),r.width),a+=r.height+s}}isHorizontal(){return"top"===this.options.position||"bottom"===this.options.position}draw(){if(this.options.display){const t=this.ctx;Ie(t,this),this._draw(),ze(t)}}_draw(){const{options:t,columnSizes:e,lineWidths:i,ctx:s}=this,{align:n,labels:o}=t,a=ue.color,r=Oi(t.rtl,this.left,this.width),h=Si(o.font),{padding:c}=o,d=h.size,u=d/2;let f;this.drawTitle(),s.textAlign=r.textAlign("left"),s.textBaseline="middle",s.lineWidth=.5,s.font=h.string;const{boxWidth:g,boxHeight:p,itemHeight:m}=_a(o,d),x=this.isHorizontal(),b=this._computeTitleHeight();f=x?{x:ft(n,this.left+c,this.right-i[0]),y:this.top+c+b,line:0}:{x:this.left+c,y:ft(n,this.top+b+c,this.bottom-e[0].height),line:0},Ai(this.ctx,t.textDirection);const _=m+c;this.legendItems.forEach(((y,v)=>{s.strokeStyle=y.fontColor,s.fillStyle=y.fontColor;const M=s.measureText(y.text).width,w=r.textAlign(y.textAlign||(y.textAlign=o.textAlign)),k=g+u+M;let S=f.x,P=f.y;r.setWidth(this.width),x?v>0&&S+k+c>this.right&&(P=f.y+=_,f.line++,S=f.x=ft(n,this.left+c,this.right-i[f.line])):v>0&&P+_>this.bottom&&(S=f.x=S+e[f.line].width+c,f.line++,P=f.y=ft(n,this.top+b+c,this.bottom-e[f.line].height));if(function(t,e,i){if(isNaN(g)||g<=0||isNaN(p)||p<0)return;s.save();const n=l(i.lineWidth,1);if(s.fillStyle=l(i.fillStyle,a),s.lineCap=l(i.lineCap,"butt"),s.lineDashOffset=l(i.lineDashOffset,0),s.lineJoin=l(i.lineJoin,"miter"),s.lineWidth=n,s.strokeStyle=l(i.strokeStyle,a),s.setLineDash(l(i.lineDash,[])),o.usePointStyle){const a={radius:p*Math.SQRT2/2,pointStyle:i.pointStyle,rotation:i.rotation,borderWidth:n},l=r.xPlus(t,g/2);Ee(s,a,l,e+u,o.pointStyleWidth&&g)}else{const o=e+Math.max((d-p)/2,0),a=r.leftForLtr(t,g),l=wi(i.borderRadius);s.beginPath(),Object.values(l).some((t=>0!==t))?He(s,{x:a,y:o,w:g,h:p,radius:l}):s.rect(a,o,g,p),s.fill(),0!==n&&s.stroke()}s.restore()}(r.x(S),P,y),S=gt(w,S+g+u,x?S+k:this.right,t.rtl),function(t,e,i){Ne(s,i.text,t,e+m/2,h,{strikethrough:i.hidden,textAlign:r.textAlign(i.textAlign)})}(r.x(S),P,y),x)f.x+=k+c;else if("string"!=typeof y.text){const t=h.lineHeight;f.y+=va(y,t)+c}else f.y+=_})),Ti(this.ctx,t.textDirection)}drawTitle(){const t=this.options,e=t.title,i=Si(e.font),s=ki(e.padding);if(!e.display)return;const n=Oi(t.rtl,this.left,this.width),o=this.ctx,a=e.position,r=i.size/2,l=s.top+r;let h,c=this.left,d=this.width;if(this.isHorizontal())d=Math.max(...this.lineWidths),h=this.top+l,c=ft(t.align,c,this.right-d);else{const e=this.columnSizes.reduce(((t,e)=>Math.max(t,e.height)),0);h=l+ft(t.align,this.top,this.bottom-e-t.labels.padding-this._computeTitleHeight())}const u=ft(a,c,c+d);o.textAlign=n.textAlign(ut(a)),o.textBaseline="middle",o.strokeStyle=e.color,o.fillStyle=e.color,o.font=i.string,Ne(o,e.text,u,h,i)}_computeTitleHeight(){const t=this.options.title,e=Si(t.font),i=ki(t.padding);return t.display?e.lineHeight+i.height:0}_getLegendItemAt(t,e){let i,s,n;if(tt(t,this.left,this.right)&&tt(e,this.top,this.bottom))for(n=this.legendHitBoxes,i=0;it.chart.options.color,boxWidth:40,padding:10,generateLabels(t){const e=t.data.datasets,{labels:{usePointStyle:i,pointStyle:s,textAlign:n,color:o,useBorderRadius:a,borderRadius:r}}=t.legend.options;return t._getSortedDatasetMetas().map((t=>{const l=t.controller.getStyle(i?0:void 0),h=ki(l.borderWidth);return{text:e[t.index].label,fillStyle:l.backgroundColor,fontColor:o,hidden:!t.visible,lineCap:l.borderCapStyle,lineDash:l.borderDash,lineDashOffset:l.borderDashOffset,lineJoin:l.borderJoinStyle,lineWidth:(h.width+h.height)/4,strokeStyle:l.borderColor,pointStyle:s||l.pointStyle,rotation:l.rotation,textAlign:n||l.textAlign,borderRadius:a&&(r||l.borderRadius),datasetIndex:t.index}}),this)}},title:{color:t=>t.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:t=>!t.startsWith("on"),labels:{_scriptable:t=>!["generateLabels","filter","sort"].includes(t)}}};class wa extends $s{constructor(t){super(),this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e){const i=this.options;if(this.left=0,this.top=0,!i.display)return void(this.width=this.height=this.right=this.bottom=0);this.width=this.right=t,this.height=this.bottom=e;const s=n(i.text)?i.text.length:1;this._padding=ki(i.padding);const o=s*Si(i.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=o:this.width=o}isHorizontal(){const t=this.options.position;return"top"===t||"bottom"===t}_drawArgs(t){const{top:e,left:i,bottom:s,right:n,options:o}=this,a=o.align;let r,l,h,c=0;return this.isHorizontal()?(l=ft(a,i,n),h=e+t,r=n-i):("left"===o.position?(l=i+t,h=ft(a,s,e),c=-.5*C):(l=n-t,h=ft(a,e,s),c=.5*C),r=s-e),{titleX:l,titleY:h,maxWidth:r,rotation:c}}draw(){const t=this.ctx,e=this.options;if(!e.display)return;const i=Si(e.font),s=i.lineHeight/2+this._padding.top,{titleX:n,titleY:o,maxWidth:a,rotation:r}=this._drawArgs(s);Ne(t,e.text,0,0,i,{color:e.color,maxWidth:a,rotation:r,textAlign:ut(e.align),textBaseline:"middle",translation:[n,o]})}}var ka={id:"title",_element:wa,start(t,e,i){!function(t,e){const i=new wa({ctx:t.ctx,options:e,chart:t});ls.configure(t,i,e),ls.addBox(t,i),t.titleBlock=i}(t,i)},stop(t){const e=t.titleBlock;ls.removeBox(t,e),delete t.titleBlock},beforeUpdate(t,e,i){const s=t.titleBlock;ls.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Sa=new WeakMap;var Pa={id:"subtitle",start(t,e,i){const s=new wa({ctx:t.ctx,options:i,chart:t});ls.configure(t,s,i),ls.addBox(t,s),Sa.set(t,s)},stop(t){ls.removeBox(t,Sa.get(t)),Sa.delete(t)},beforeUpdate(t,e,i){const s=Sa.get(t);ls.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"normal"},fullSize:!0,padding:0,position:"top",text:"",weight:1500},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Da={average(t){if(!t.length)return!1;let e,i,s=new Set,n=0,o=0;for(e=0,i=t.length;et+e))/s.size,y:n/o}},nearest(t,e){if(!t.length)return!1;let i,s,n,o=e.x,a=e.y,r=Number.POSITIVE_INFINITY;for(i=0,s=t.length;i-1?t.split("\n"):t}function Aa(t,e){const{element:i,datasetIndex:s,index:n}=e,o=t.getDatasetMeta(s).controller,{label:a,value:r}=o.getLabelAndValue(n);return{chart:t,label:a,parsed:o.getParsed(n),raw:t.data.datasets[s].data[n],formattedValue:r,dataset:o.getDataset(),dataIndex:n,datasetIndex:s,element:i}}function Ta(t,e){const i=t.chart.ctx,{body:s,footer:n,title:o}=t,{boxWidth:a,boxHeight:r}=e,l=Si(e.bodyFont),h=Si(e.titleFont),c=Si(e.footerFont),d=o.length,f=n.length,g=s.length,p=ki(e.padding);let m=p.height,x=0,b=s.reduce(((t,e)=>t+e.before.length+e.lines.length+e.after.length),0);if(b+=t.beforeBody.length+t.afterBody.length,d&&(m+=d*h.lineHeight+(d-1)*e.titleSpacing+e.titleMarginBottom),b){m+=g*(e.displayColors?Math.max(r,l.lineHeight):l.lineHeight)+(b-g)*l.lineHeight+(b-1)*e.bodySpacing}f&&(m+=e.footerMarginTop+f*c.lineHeight+(f-1)*e.footerSpacing);let _=0;const y=function(t){x=Math.max(x,i.measureText(t).width+_)};return i.save(),i.font=h.string,u(t.title,y),i.font=l.string,u(t.beforeBody.concat(t.afterBody),y),_=e.displayColors?a+2+e.boxPadding:0,u(s,(t=>{u(t.before,y),u(t.lines,y),u(t.after,y)})),_=0,i.font=c.string,u(t.footer,y),i.restore(),x+=p.width,{width:x,height:m}}function La(t,e,i,s){const{x:n,width:o}=i,{width:a,chartArea:{left:r,right:l}}=t;let h="center";return"center"===s?h=n<=(r+l)/2?"left":"right":n<=o/2?h="left":n>=a-o/2&&(h="right"),function(t,e,i,s){const{x:n,width:o}=s,a=i.caretSize+i.caretPadding;return"left"===t&&n+o+a>e.width||"right"===t&&n-o-a<0||void 0}(h,t,e,i)&&(h="center"),h}function Ea(t,e,i){const s=i.yAlign||e.yAlign||function(t,e){const{y:i,height:s}=e;return it.height-s/2?"bottom":"center"}(t,i);return{xAlign:i.xAlign||e.xAlign||La(t,e,i,s),yAlign:s}}function Ra(t,e,i,s){const{caretSize:n,caretPadding:o,cornerRadius:a}=t,{xAlign:r,yAlign:l}=i,h=n+o,{topLeft:c,topRight:d,bottomLeft:u,bottomRight:f}=wi(a);let g=function(t,e){let{x:i,width:s}=t;return"right"===e?i-=s:"center"===e&&(i-=s/2),i}(e,r);const p=function(t,e,i){let{y:s,height:n}=t;return"top"===e?s+=i:s-="bottom"===e?n+i:n/2,s}(e,l,h);return"center"===l?"left"===r?g+=h:"right"===r&&(g-=h):"left"===r?g-=Math.max(c,u)+n:"right"===r&&(g+=Math.max(d,f)+n),{x:Z(g,0,s.width-e.width),y:Z(p,0,s.height-e.height)}}function Ia(t,e,i){const s=ki(i.padding);return"center"===e?t.x+t.width/2:"right"===e?t.x+t.width-s.right:t.x+s.left}function za(t){return Ca([],Oa(t))}function Fa(t,e){const i=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return i?t.override(i):t}const Va={beforeTitle:e,title(t){if(t.length>0){const e=t[0],i=e.chart.data.labels,s=i?i.length:0;if(this&&this.options&&"dataset"===this.options.mode)return e.dataset.label||"";if(e.label)return e.label;if(s>0&&e.dataIndex{const e={before:[],lines:[],after:[]},n=Fa(i,t);Ca(e.before,Oa(Ba(n,"beforeLabel",this,t))),Ca(e.lines,Ba(n,"label",this,t)),Ca(e.after,Oa(Ba(n,"afterLabel",this,t))),s.push(e)})),s}getAfterBody(t,e){return za(Ba(e.callbacks,"afterBody",this,t))}getFooter(t,e){const{callbacks:i}=e,s=Ba(i,"beforeFooter",this,t),n=Ba(i,"footer",this,t),o=Ba(i,"afterFooter",this,t);let a=[];return a=Ca(a,Oa(s)),a=Ca(a,Oa(n)),a=Ca(a,Oa(o)),a}_createItems(t){const e=this._active,i=this.chart.data,s=[],n=[],o=[];let a,r,l=[];for(a=0,r=e.length;at.filter(e,s,n,i)))),t.itemSort&&(l=l.sort(((e,s)=>t.itemSort(e,s,i)))),u(l,(e=>{const i=Fa(t.callbacks,e);s.push(Ba(i,"labelColor",this,e)),n.push(Ba(i,"labelPointStyle",this,e)),o.push(Ba(i,"labelTextColor",this,e))})),this.labelColors=s,this.labelPointStyles=n,this.labelTextColors=o,this.dataPoints=l,l}update(t,e){const i=this.options.setContext(this.getContext()),s=this._active;let n,o=[];if(s.length){const t=Da[i.position].call(this,s,this._eventPosition);o=this._createItems(i),this.title=this.getTitle(o,i),this.beforeBody=this.getBeforeBody(o,i),this.body=this.getBody(o,i),this.afterBody=this.getAfterBody(o,i),this.footer=this.getFooter(o,i);const e=this._size=Ta(this,i),a=Object.assign({},t,e),r=Ea(this.chart,i,a),l=Ra(i,a,r,this.chart);this.xAlign=r.xAlign,this.yAlign=r.yAlign,n={opacity:1,x:l.x,y:l.y,width:e.width,height:e.height,caretX:t.x,caretY:t.y}}else 0!==this.opacity&&(n={opacity:0});this._tooltipItems=o,this.$context=void 0,n&&this._resolveAnimations().update(this,n),t&&i.external&&i.external.call(this,{chart:this.chart,tooltip:this,replay:e})}drawCaret(t,e,i,s){const n=this.getCaretPosition(t,i,s);e.lineTo(n.x1,n.y1),e.lineTo(n.x2,n.y2),e.lineTo(n.x3,n.y3)}getCaretPosition(t,e,i){const{xAlign:s,yAlign:n}=this,{caretSize:o,cornerRadius:a}=i,{topLeft:r,topRight:l,bottomLeft:h,bottomRight:c}=wi(a),{x:d,y:u}=t,{width:f,height:g}=e;let p,m,x,b,_,y;return"center"===n?(_=u+g/2,"left"===s?(p=d,m=p-o,b=_+o,y=_-o):(p=d+f,m=p+o,b=_-o,y=_+o),x=p):(m="left"===s?d+Math.max(r,h)+o:"right"===s?d+f-Math.max(l,c)-o:this.caretX,"top"===n?(b=u,_=b-o,p=m-o,x=m+o):(b=u+g,_=b+o,p=m+o,x=m-o),y=b),{x1:p,x2:m,x3:x,y1:b,y2:_,y3:y}}drawTitle(t,e,i){const s=this.title,n=s.length;let o,a,r;if(n){const l=Oi(i.rtl,this.x,this.width);for(t.x=Ia(this,i.titleAlign,i),e.textAlign=l.textAlign(i.titleAlign),e.textBaseline="middle",o=Si(i.titleFont),a=i.titleSpacing,e.fillStyle=i.titleColor,e.font=o.string,r=0;r0!==t))?(t.beginPath(),t.fillStyle=n.multiKeyBackground,He(t,{x:e,y:g,w:h,h:l,radius:r}),t.fill(),t.stroke(),t.fillStyle=a.backgroundColor,t.beginPath(),He(t,{x:i,y:g+1,w:h-2,h:l-2,radius:r}),t.fill()):(t.fillStyle=n.multiKeyBackground,t.fillRect(e,g,h,l),t.strokeRect(e,g,h,l),t.fillStyle=a.backgroundColor,t.fillRect(i,g+1,h-2,l-2))}t.fillStyle=this.labelTextColors[i]}drawBody(t,e,i){const{body:s}=this,{bodySpacing:n,bodyAlign:o,displayColors:a,boxHeight:r,boxWidth:l,boxPadding:h}=i,c=Si(i.bodyFont);let d=c.lineHeight,f=0;const g=Oi(i.rtl,this.x,this.width),p=function(i){e.fillText(i,g.x(t.x+f),t.y+d/2),t.y+=d+n},m=g.textAlign(o);let x,b,_,y,v,M,w;for(e.textAlign=o,e.textBaseline="middle",e.font=c.string,t.x=Ia(this,m,i),e.fillStyle=i.bodyColor,u(this.beforeBody,p),f=a&&"right"!==m?"center"===o?l/2+h:l+2+h:0,y=0,M=s.length;y0&&e.stroke()}_updateAnimationTarget(t){const e=this.chart,i=this.$animations,s=i&&i.x,n=i&&i.y;if(s||n){const i=Da[t.position].call(this,this._active,this._eventPosition);if(!i)return;const o=this._size=Ta(this,t),a=Object.assign({},i,this._size),r=Ea(e,t,a),l=Ra(t,a,r,e);s._to===l.x&&n._to===l.y||(this.xAlign=r.xAlign,this.yAlign=r.yAlign,this.width=o.width,this.height=o.height,this.caretX=i.x,this.caretY=i.y,this._resolveAnimations().update(this,l))}}_willRender(){return!!this.opacity}draw(t){const e=this.options.setContext(this.getContext());let i=this.opacity;if(!i)return;this._updateAnimationTarget(e);const s={width:this.width,height:this.height},n={x:this.x,y:this.y};i=Math.abs(i)<.001?0:i;const o=ki(e.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;e.enabled&&a&&(t.save(),t.globalAlpha=i,this.drawBackground(n,t,s,e),Ai(t,e.textDirection),n.y+=o.top,this.drawTitle(n,t,e),this.drawBody(n,t,e),this.drawFooter(n,t,e),Ti(t,e.textDirection),t.restore())}getActiveElements(){return this._active||[]}setActiveElements(t,e){const i=this._active,s=t.map((({datasetIndex:t,index:e})=>{const i=this.chart.getDatasetMeta(t);if(!i)throw new Error("Cannot find a dataset at index "+t);return{datasetIndex:t,element:i.data[e],index:e}})),n=!f(i,s),o=this._positionChanged(s,e);(n||o)&&(this._active=s,this._eventPosition=e,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(t,e,i=!0){if(e&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;const s=this.options,n=this._active||[],o=this._getActiveElements(t,n,e,i),a=this._positionChanged(o,t),r=e||!f(o,n)||a;return r&&(this._active=o,(s.enabled||s.external)&&(this._eventPosition={x:t.x,y:t.y},this.update(!0,e))),r}_getActiveElements(t,e,i,s){const n=this.options;if("mouseout"===t.type)return[];if(!s)return e.filter((t=>this.chart.data.datasets[t.datasetIndex]&&void 0!==this.chart.getDatasetMeta(t.datasetIndex).controller.getParsed(t.index)));const o=this.chart.getElementsAtEventForMode(t,n.mode,n,i);return n.reverse&&o.reverse(),o}_positionChanged(t,e){const{caretX:i,caretY:s,options:n}=this,o=Da[n.position].call(this,t,e);return!1!==o&&(i!==o.x||s!==o.y)}}var Na={id:"tooltip",_element:Wa,positioners:Da,afterInit(t,e,i){i&&(t.tooltip=new Wa({chart:t,options:i}))},beforeUpdate(t,e,i){t.tooltip&&t.tooltip.initialize(i)},reset(t,e,i){t.tooltip&&t.tooltip.initialize(i)},afterDraw(t){const e=t.tooltip;if(e&&e._willRender()){const i={tooltip:e};if(!1===t.notifyPlugins("beforeTooltipDraw",{...i,cancelable:!0}))return;e.draw(t.ctx),t.notifyPlugins("afterTooltipDraw",i)}},afterEvent(t,e){if(t.tooltip){const i=e.replay;t.tooltip.handleEvent(e.event,i,e.inChartArea)&&(e.changed=!0)}},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(t,e)=>e.bodyFont.size,boxWidth:(t,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:Va},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:t=>"filter"!==t&&"itemSort"!==t&&"external"!==t,_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]};return Tn.register(Un,$o,go,t),Tn.helpers={...Hi},Tn._adapters=In,Tn.Animation=As,Tn.Animations=Ts,Tn.animator=bt,Tn.controllers=nn.controllers.items,Tn.DatasetController=js,Tn.Element=$s,Tn.elements=go,Tn.Interaction=Ki,Tn.layouts=ls,Tn.platforms=Ds,Tn.Scale=tn,Tn.Ticks=ae,Object.assign(Tn,Un,$o,go,t,Ds),Tn.Chart=Tn,"undefined"!=typeof window&&(window.Chart=Tn),Tn})); +//# sourceMappingURL=chart.umd.min.js.map diff --git a/src/PostSharp.LicenseServer/robots.txt b/src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/robots.txt similarity index 100% rename from src/PostSharp.LicenseServer/robots.txt rename to src/SharpCrafters.Backstage.LicenseServer.Web/wwwroot/robots.txt diff --git a/test/PostSharp.LicenseServer.Test/ClientSimulator.cs b/test/PostSharp.LicenseServer.Test/ClientSimulator.cs deleted file mode 100644 index c04e78b..0000000 --- a/test/PostSharp.LicenseServer.Test/ClientSimulator.cs +++ /dev/null @@ -1,133 +0,0 @@ -using Microsoft.Win32; -using PostSharp.Platform; -using PostSharp.Sdk; -using PostSharp.Sdk.Extensibility.Licensing; -using PostSharp.Sdk.Extensibility.Licensing.Helpers; -using System; -using System.Diagnostics; -using System.Threading; - -namespace SharpCrafters.LicenseServer.Test -{ - - class ClientSimulator - { - readonly Random random = new Random(); - private readonly User user; - private static int waitingUsers; - - public ClientSimulator( User user ) - { - this.user = user; - } - - public void Main() - { - - Guid guid = Guid.NewGuid(); - - MessageSink messageSink = new MessageSink(); - - IRegistryKey registryKey = new MemoryRegistryKey(); - - using ( registryKey ) - { - LicenseLease lease = null; - while ( true ) - { - DateTime day = VirtualDateTime.UtcNow.ToLocalTime(); - - // We don't start before 9. - DateTime startTime = day.Date.AddHours( 8 + (random.NextDouble()-0.5)*4 ); - VirtualDateTime.WakeOn( startTime ); - - double probWorksToday; - switch (day.DayOfWeek) - { - case DayOfWeek.Sunday: - probWorksToday = user.WorksOnWeekend; - break; - case DayOfWeek.Saturday: - probWorksToday = user.WorksOnWeekend; - break; - default: - probWorksToday =1; - break; - } - - if (random.NextDouble() < probWorksToday) - { - DateTime endTime = startTime.AddHours( 9 + random.NextDouble() ); - - // Which machine will he use this day? - int machineIndex = (int)Math.Floor(random.NextDouble() * user.Machines.Count); - string machine = user.Machines[machineIndex]; - - // We are running PostSharp every 15 minute. - for ( DateTime time = startTime; time < endTime; time = time.AddMinutes( 30*random.NextDouble() ) ) - { - - //Console.WriteLine("{2} {0} waiting until {1}", VirtualDateTime.UtcNow.ToLocalTime(), time, user); - VirtualDateTime.WakeOn( time ); - - if ( lease == null || lease.RenewTime < time ) - { - Interlocked.Increment( ref waitingUsers ); - Console.WriteLine("{2} {0} on {3}: Acquiring lease; waiting users = {1}", user.AuthenticatedName, waitingUsers, VirtualDateTime.UtcNow.ToLocalTime(), machine); - - string url = Program.Url.TrimEnd('/') + string.Format("/Lease.ashx?user={0}&machine={1}&product={2}&version=2025.1.0", - user.AuthenticatedName, machine, - LicensedProduct.Ultimate); - - Stopwatch stopwatch = Stopwatch.StartNew(); - lease = LicenseServerClient.TryGetLease( url, registryKey, VirtualDateTime.UtcNow.ToLocalTime(), messageSink ); - - if ( lease == null ) - { - Console.WriteLine("Could not get a valid lease: lease is null, downloading a new lease..."); - - // TODO: Uncomment after the following method is public in PostSharp (likely 2025.1.6 or later) - // Upgrade dependencies. - // Remove the throw. - - //lease = LicenseServerClient.TryDownloadLease(messageSink, url, registryKey); - - throw new Exception("Upgrade PostSharp to make these tests work (see above comment)."); - - if (lease == null) - { - Console.WriteLine("Could not download a new lease."); - } - else - { - Console.WriteLine($"Key {lease.LicenseString}:"); - Console.WriteLine($"Leased until: {lease.EndTime}:"); - } - } - else if ( lease.EndTime < time ) - { - Console.WriteLine("Could not get a valid lease: lease end time is in the past"); - } - else if ( lease.RenewTime < time ) - { - Console.WriteLine("Got a lease with past renewal time: {0}, it is now {1}", - lease.RenewTime, time); - Program.FixVirtualTime(); - } - - Interlocked.Decrement(ref waitingUsers); - Console.WriteLine("{3} {0}: response received in {1}, waiting users = '{2}'", user, stopwatch.Elapsed, waitingUsers, VirtualDateTime.UtcNow.ToLocalTime()); - - } - } - - } - - } - - } - - } - - } -} \ No newline at end of file diff --git a/test/PostSharp.LicenseServer.Test/MemoryRegistryKey.cs b/test/PostSharp.LicenseServer.Test/MemoryRegistryKey.cs deleted file mode 100644 index 051c792..0000000 --- a/test/PostSharp.LicenseServer.Test/MemoryRegistryKey.cs +++ /dev/null @@ -1,208 +0,0 @@ -using PostSharp.Platform; -using System; -using System.Collections.Generic; -using System.Runtime.InteropServices; - -namespace SharpCrafters.LicenseServer.Test -{ - class MemoryRegistryKey : IRegistryKey - { - private Dictionary values = new Dictionary(); - private Dictionary subKeys = new Dictionary(); - - public void Dispose() - { - } - - public string[] GetSubKeyNames() - { - List subKeyNames = new List(subKeys.Keys); - return subKeyNames.ToArray(); - } - - public IRegistryKey OpenSubKey(string subKey) - { - if (subKeys.TryGetValue(subKey, out IRegistryKey subKeyValue)) - { - return subKeyValue; - } - else - { - throw new ArgumentException($"Subkey '{subKey}' does not exist."); - } - } - - public IRegistryKey OpenSubKey(string name, bool writable) - { - if (subKeys.TryGetValue(name, out IRegistryKey subKeyValue)) - { - return subKeyValue; - } - else - { - throw new ArgumentException($"Subkey '{name}' does not exist."); - } - } - - public IRegistryKey CreateSubKey(string subKey) - { - if (!subKeys.ContainsKey(subKey)) - { - MemoryRegistryKey newSubKey = new MemoryRegistryKey(); - subKeys[subKey] = newSubKey; - return newSubKey; - } - else - { - throw new ArgumentException($"Subkey '{subKey}' already exists."); - } - } - - public void DeleteSubKey(string subKey) - { - if (subKeys.ContainsKey(subKey)) - { - subKeys.Remove(subKey); - } - else - { - throw new ArgumentException($"Subkey '{subKey}' does not exist."); - } - } - - public void DeleteSubKey(string subKey, bool throwOnMissingSubKey) - { - if (subKeys.ContainsKey(subKey)) - { - subKeys.Remove(subKey); - } - else if (throwOnMissingSubKey) - { - throw new ArgumentException($"Subkey '{subKey}' does not exist."); - } - } - - public void DeleteSubKeyTree(string subKey) - { - if (subKeys.ContainsKey(subKey)) - { - subKeys.Remove(subKey); - } - else - { - throw new ArgumentException($"Subkey '{subKey}' does not exist."); - } - } - - public void DeleteSubKeyTree(string subKey, bool throwOnMissingSubKey) - { - if (subKeys.ContainsKey(subKey)) - { - subKeys.Remove(subKey); - } - else if (throwOnMissingSubKey) - { - throw new ArgumentException($"Subkey '{subKey}' does not exist."); - } - } - - public string[] GetValueNames() - { - List valueNames = new List(values.Keys); - return valueNames.ToArray(); - } - - public object GetValue(string name) - { - if (values.TryGetValue(name, out object value)) - { - return value; - } - else - { - throw new ArgumentException($"Value '{name}' does not exist."); - } - } - - public object GetValue(string name, object defaultValue) - { - if (values.TryGetValue(name, out object value)) - { - return value; - } - else - { - return defaultValue; - } - } - - public void SetValue(string name, string value) - { - if (values.ContainsKey(name)) - { - values[name] = value; - } - else - { - values.Add(name, value); - } - } - - public void SetDWordValue(string name, int value) - { - if (values.ContainsKey(name)) - { - values[name] = value; - } - else - { - values.Add(name, value); - } - } - - public void SetQWordValue(string name, long value) - { - if (values.ContainsKey(name)) - { - values[name] = value; - } - else - { - values.Add(name, value); - } - } - - public void DeleteValue(string name) - { - if (values.ContainsKey(name)) - { - values.Remove(name); - } - else - { - throw new ArgumentException($"Value '{name}' does not exist."); - } - } - - public void DeleteValue(string name, bool throwOnMissingSubKey) - { - if (values.ContainsKey(name)) - { - values.Remove(name); - } - else if (throwOnMissingSubKey) - { - throw new ArgumentException($"Value '{name}' does not exist."); - } - } - - public void Close() - { - // No action needed for in-memory registry key. - } - - public bool CanMonitorChanges => false; - - public SafeHandle Handle => null; - } -} \ No newline at end of file diff --git a/test/PostSharp.LicenseServer.Test/MessageSink.cs b/test/PostSharp.LicenseServer.Test/MessageSink.cs deleted file mode 100644 index d510021..0000000 --- a/test/PostSharp.LicenseServer.Test/MessageSink.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; -using PostSharp.Extensibility; - -namespace SharpCrafters.LicenseServer.Test -{ - internal class MessageSink : IMessageSink - { - public void Write( Message message ) - { - Console.WriteLine(message.MessageText); - } - } -} \ No newline at end of file diff --git a/test/PostSharp.LicenseServer.Test/PostSharp.LicenseServer.Test.csproj b/test/PostSharp.LicenseServer.Test/PostSharp.LicenseServer.Test.csproj deleted file mode 100644 index bf2a325..0000000 --- a/test/PostSharp.LicenseServer.Test/PostSharp.LicenseServer.Test.csproj +++ /dev/null @@ -1,114 +0,0 @@ - - - - - Debug - x86 - 8.0.30703 - 2.0 - {6A706191-B0A4-4B9A-A597-7A1DAF32EA50} - Exe - Properties - SharpCrafters.LicenseServer.Test - SharpCrafters.LicenseServer.Test - v4.7.2 - - - 512 - - - - - x86 - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - false - True - - - x86 - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - false - True - - - bin\x86\Documentation\ - TRACE - true - pdbonly - x86 - prompt - MinimumRecommendedRules.ruleset - false - True - - - - ..\..\packages\PostSharp.Redist.2025.1.5\lib\net45\PostSharp.dll - - - ..\..\packages\PostSharp.Compiler.Common.2025.1.5\lib\netstandard2.0\PostSharp.Compiler.Common.dll - - - ..\..\packages\PostSharp.Compiler.Engine.2025.1.5\lib\netstandard2.0\PostSharp.Compiler.Engine.dll - - - ..\..\packages\PostSharp.Compiler.Platforms.2025.1.5\lib\net472\PostSharp.Compiler.Platform.NetFramework.dll - - - ..\..\packages\PostSharp.Compiler.Settings.2025.1.5\lib\netstandard2.0\PostSharp.Compiler.Settings.dll - - - - - - - - - - - - - - - - - - - - - - - - - {3b511e09-1cfd-43eb-978f-70fa3dfec83b} - PostSharp.LicenseServer - - - - - - This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - - - - - \ No newline at end of file diff --git a/test/PostSharp.LicenseServer.Test/Program.cs b/test/PostSharp.LicenseServer.Test/Program.cs deleted file mode 100644 index 2cdfbf9..0000000 --- a/test/PostSharp.LicenseServer.Test/Program.cs +++ /dev/null @@ -1,236 +0,0 @@ -using PostSharp.Platform.Neutral; -using PostSharp.Sdk.Extensibility; -using PostSharp.Sdk.Extensibility.Licensing; -using PostSharp.Sdk.User; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Text; -using System.Threading; -using System.Xml; - -namespace SharpCrafters.LicenseServer.Test -{ - class Program - { - static readonly Random random = new Random(); - private const string unparsedNames = - @"David,Rahm -Jimmy,Smith -Carroll,Lash -Keith,Smith -Wm,Martinez -Marsha,Bassham -Mike,Bergeron -Julio,Bayliss -Salvatore,Nail -Herbert,Thompson -Keith,Gentile -Gary,Turner -Jesse,Stinson -Louis,Callender -Duane,Rudder -Joan,Lowrey -Thomas,Bourdeau -Richard,Vega -Charles,Numbers -Paul,Cooper -Albert,Speier -Jerry,Johnson -Sidney,Painter -John,Denton -Monica,Grise -William,Davis -Miguel,Collins -Melanie,Johnson -Nida,Perez -George,Young -Gary,Wilkes -Thomas,Stoneburner -Richard,Bushong -Edmund,Davis -Michael,Smart -Dena,Anderson -Bobbie,Sherman -Ray,Roberts -Francisco,Linck -Jeremy,Thompson -Hubert,Nunnally -Marshall,Hoffman -Stephen,Montes -Wilfred,Cassel -Matthew,Sease -Edward,Johnson -Timothy,Hassell -Brian,Jones -Israel,West -Jesse,Bombard -Don,Tann -Brian,Stringer -Marilyn,Belanger -Donald,Miller -Patrick,Clark -Milton,Cranston -Russell,Christensen -Bill,Piper -Chester,Bennett -Dean,Mcgrath -Dennis,Ortiz -Paul,Arno -Manuel,Cole -Felix,Johnson -Nancy,Mccormick -Robert,Callan -Bernard,Vanwyk -Kelly,Gary -Robert,Murphy -Quincy,Grossman -Richard,Brown -Theodore,Flemming -Christopher,Young -David,Riddle -Donnie,Comstock -Bryan,Hartsock -Timothy,Villarreal -Troy,Thompson -Frederick,Johnson -Joseph,Davis -Christopher,Ayala -Cyrus,Smith -Thomas,Johnson -Monica,Tobin -Kyle,Pierce -Ginger,Miller -John,Smith -Michael,Hines -William,Hansen -Joseph,Hurlburt -Stephen,Green -Noe,Houle -Troy,Lofton -Kristofer,Booth -Joseph,Hoffman -Larry,Mcknight -Brian,Watson -Tom,Greenwood -Cory,Ryals -David,Bradway"; - - public static string Url = "http://localhost:44670/"; - - private static void OnTime( object state ) - { - Console.WriteLine(VirtualDateTime.UtcNow.ToLocalTime()); - } - - static void Main(string[] args) - { - if ( args.Length > 0 ) - Url = args[0]; - - CommonDefaultSystemServices.Initialize(); - Messenger.Initialize(); - Messenger.Current.Message += ( sender, eventArgs ) => Console.WriteLine( VirtualDateTime.UtcNow.ToLocalTime().ToString() + ": " + - eventArgs.Message.MessageText ); - - FixVirtualTime(); - - List users = GetUsers(75, 2, 2); - - foreach ( User user in users) - { - StartUserSimulation( user ); - } - - new Thread( PrintTime ).Start(); - - } - - private static void PrintTime() - { - while ( true ) - { - Thread.Sleep( TimeSpan.FromSeconds( 5 ) ); - Console.WriteLine("Current time: {0}", VirtualDateTime.UtcNow.ToLocalTime() ); - } - } - - public static void FixVirtualTime() - { - string getTimeUrl = Url + "GetTime.ashx"; - WebClient webClient = new WebClient(); - string[] remoteTime = webClient.DownloadString( getTimeUrl ).Split( ';' ); - VirtualDateTime.Initialize( XmlConvert.ToDateTime( remoteTime[0], XmlDateTimeSerializationMode.Utc ), XmlConvert.ToDecimal( remoteTime[1] ) ); - } - - private static void StartUserSimulation( User user ) - { - ClientSimulator clientSimulator = new ClientSimulator( user ); - Thread thread = new Thread( clientSimulator.Main ) - { - Name = string.Format( "Simulation for user {0} ", user ) - }; - - thread.Start(); - } - - private static List GetUsers(int userCount, int buildServerUserCount, int buildServerMachineCount) - { - StringReader reader = new StringReader( unparsedNames ); - List users = new List(); - - - string line; - while ( (line = reader.ReadLine()) != null ) - { - string[] parts = line.Split( ',' ); - - User user = new User - { - UserName = string.Format( "{0}.{1}", parts[0], parts[1] ).ToUpper(), - AuthenticatedName = string.Format( "CONTOSO\\{0}.{1}", parts[0], parts[1] ).ToUpper(), - WorksOnWeekend = random.NextDouble() - }; - double machineProb = random.NextDouble(); - - if ( machineProb < 0.7 ) - { - user.Machines.Add( string.Format( "DESKTOP-{0:x}", random.Next( 0, ushort.MaxValue ) ) ); - } - else - { - user.Machines.Add( string.Format( "DESKTOP-{0:x}", random.Next( 0, ushort.MaxValue ) ) ); - user.Machines.Add( string.Format( "NOTEBOOK-{0:x}", random.Next( 0, ushort.MaxValue ) ) ); - } - - users.Add( user ); - - if ( users.Count >= userCount ) - break; - } - - for ( int i = 0; i < buildServerUserCount; i++ ) - { - User user = new User - { - UserName = string.Format("BUILDSERVER.{0}", i), - AuthenticatedName = string.Format("CONTOSO\\BUILDSERVER.{0}", i), - WorksOnWeekend = 1 - }; - - for ( int j = 0; j < buildServerMachineCount; j++ ) - { - user.Machines.Add(string.Format("SERVER-{0:x}", random.Next( 0, ushort.MaxValue ))); - } - - users.Add(user); - } - - return users; - } - - - } -} diff --git a/test/PostSharp.LicenseServer.Test/Properties/AssemblyInfo.cs b/test/PostSharp.LicenseServer.Test/Properties/AssemblyInfo.cs deleted file mode 100644 index 3dab666..0000000 --- a/test/PostSharp.LicenseServer.Test/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("SharpCrafters.LicenseServer.Test")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("SharpCrafters.LicenseServer.Test")] -[assembly: AssemblyCopyright("Copyright © 2012")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("cc948cb7-f2f4-4f00-9d6a-3d5474f72268")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/test/PostSharp.LicenseServer.Test/User.cs b/test/PostSharp.LicenseServer.Test/User.cs deleted file mode 100644 index b4c356c..0000000 --- a/test/PostSharp.LicenseServer.Test/User.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; - -namespace SharpCrafters.LicenseServer.Test -{ - class User - { - public string UserName; - public string AuthenticatedName; - public double WorksOnWeekend; - public readonly List Machines = new List(); - - public override string ToString() - { - return this.UserName; - } - } -} \ No newline at end of file diff --git a/test/PostSharp.LicenseServer.Test/VirtualDateTime.cs b/test/PostSharp.LicenseServer.Test/VirtualDateTime.cs deleted file mode 100644 index e930c54..0000000 --- a/test/PostSharp.LicenseServer.Test/VirtualDateTime.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using System.Threading; - -namespace SharpCrafters.LicenseServer.Test -{ - public static class VirtualDateTime - { - private static DateTime initTimeUtc = DateTime.UtcNow; - private static DateTime startTimeUtc = DateTime.UtcNow; - private static decimal acceleration = 0; - - public static void Initialize( DateTime time, decimal acceleration ) - { - Console.WriteLine("Setting time from {0} to {1}, acceleration={2}", startTimeUtc, time, acceleration); - VirtualDateTime.initTimeUtc = DateTime.UtcNow; - VirtualDateTime.startTimeUtc = time; - VirtualDateTime.acceleration = acceleration; - } - - public static DateTime UtcNow - { - get - { -#if DEBUG - if (acceleration != 0 && acceleration != 1) - { - return startTimeUtc.AddMilliseconds((DateTime.UtcNow - initTimeUtc).TotalMilliseconds * (double)acceleration); - } - else - { - return DateTime.UtcNow; - } -#else - return DateTime.UtcNow; -#endif - } - } - - public static void WakeOn( DateTime time ) - { - DateTime realTime = initTimeUtc.AddMilliseconds( ((time.ToUniversalTime() - startTimeUtc).TotalMilliseconds/(double) acceleration) ); - TimeSpan timeSpan = realTime - DateTime.UtcNow; - if ( timeSpan.TotalMilliseconds > 0 ) - { - Thread.Sleep( (int) timeSpan.TotalMilliseconds ); - } - } - - } -} \ No newline at end of file diff --git a/test/PostSharp.LicenseServer.Test/app.config b/test/PostSharp.LicenseServer.Test/app.config deleted file mode 100644 index b691c5d..0000000 --- a/test/PostSharp.LicenseServer.Test/app.config +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/test/PostSharp.LicenseServer.Test/packages.config b/test/PostSharp.LicenseServer.Test/packages.config deleted file mode 100644 index 43f43b9..0000000 --- a/test/PostSharp.LicenseServer.Test/packages.config +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/AuditLogExportTests.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/AuditLogExportTests.cs new file mode 100644 index 0000000..f5abff6 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/AuditLogExportTests.cs @@ -0,0 +1,133 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Net; +using Microsoft.AspNetCore.Mvc.Testing; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +namespace SharpCrafters.Backstage.LicenseServer.Tests; + +/// +/// The export of the audit log. It writes the rows to the response as it reads them, and it does not +/// build the whole file in memory. +/// +public sealed class AuditLogExportTests : IDisposable +{ + /// + /// A number of leases large enough that the writer of the response flushes before it writes the + /// last one. A buffers about a thousand characters, and an audit line + /// has about ninety, so a few leases stay in the buffer and exercise nothing. The export returned + /// a truncated response in production while it passed a test that wrote three lines. + /// + private const int leaseCount = 60; + + private readonly LicenseServerApplication application = new(); + + public void Dispose() => this.application.Dispose(); + + [Fact] + public async Task Export_WithMoreLeasesThanTheWriterBuffers_StreamsThemAll() + { + var license = this.application.AddLicense( LicenseBuilder.Default().WithUsers( 5 ) ); + this.SeedLeases( license, leaseCount ); + + var client = this.application.CreateClient(); + + // The response body refuses a synchronous write, exactly as Kestrel does. TestServer accepts + // one whatever its AllowSynchronousIO property says, so without this guard an endpoint that + // writes synchronously passes here and fails against a real server. + this.application.ResponseBody.IsEnabled = true; + + var response = await client.GetAsync( ExportUrl( 1, 12 ) ); + + Assert.Equal( HttpStatusCode.OK, response.StatusCode ); + + var lines = ( await response.Content.ReadAsStringAsync() ) + .Split( '\n', StringSplitOptions.RemoveEmptyEntries ); + + Assert.Equal( leaseCount, lines.Length ); + + foreach ( var line in lines ) + { + // The identifier, the overwritten lease, the license, the two instants, the machine and + // the user. + var fields = line.TrimEnd( '\r' ).Split( ';' ); + + Assert.Equal( 7, fields.Length ); + } + + // The log is read by the administrator of the server, so it names the user. + Assert.Contains( "alice", string.Join( "", lines ), StringComparison.Ordinal ); + } + + /// + /// The download is offered as a file, named after the range that was asked for. + /// + [Fact] + public async Task Export_WithLeases_IsOfferedAsAFile() + { + var license = this.application.AddLicense( LicenseBuilder.Default().WithUsers( 5 ) ); + this.SeedLeases( license, 1 ); + + var client = this.application.CreateClient(); + this.application.ResponseBody.IsEnabled = true; + + var response = await client.GetAsync( ExportUrl( 3, 4 ) ); + + Assert.Equal( HttpStatusCode.OK, response.StatusCode ); + + Assert.Equal( + $"attachment; filename=PostSharp_LicenseLog_{DateTime.UtcNow.Year}-3_{DateTime.UtcNow.Year}-4.txt", + response.Content.Headers.ContentDisposition?.ToString() ); + } + + /// + /// A range that contains no lease is answered with an empty body. The earlier tests of the export + /// all followed this path, because the database they exported contained no lease. + /// + [Fact] + public async Task Export_WithNoLease_IsEmpty() + { + var client = this.application.CreateClient(); + this.application.ResponseBody.IsEnabled = true; + + var response = await client.GetAsync( ExportUrl( 1, 12 ) ); + + Assert.Equal( HttpStatusCode.OK, response.StatusCode ); + Assert.Equal( "", await response.Content.ReadAsStringAsync() ); + } + + /// + /// The leases are written directly to the database and not requested, so that their number does + /// not depend on the capacity of the license or on the rules of the allocator. + /// + private void SeedLeases( License license, int count ) + { + using var db = this.application.CreateDbContext(); + + var start = DateTime.UtcNow.Date.AddDays( -1 ); + + for ( var i = 0; i < count; i++ ) + { + db.Leases.Add( + new Lease + { + LicenseId = license.LicenseId, + StartTime = start, + EndTime = start.AddDays( 3 ), + UserName = $"alice.{i:D3}", + Machine = $"desktop-{i:D3}", + AuthenticatedUser = "DOMAIN\\tester" + } ); + } + + db.SaveChanges(); + } + + private static string ExportUrl( int fromMonth, int toMonth ) + { + var year = DateTime.UtcNow.Year; + + return $"/Admin/Export.ashx?fy={year}&fm={fromMonth}&ty={year}&tm={toMonth}"; + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/BackstageLicenseParserTests.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/BackstageLicenseParserTests.cs new file mode 100644 index 0000000..aa6e9ec --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/BackstageLicenseParserTests.cs @@ -0,0 +1,195 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using SharpCrafters.Backstage.LicenseServer.Licensing; +using SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; +using SharpCrafters.Backstage.Licensing; +using SharpCrafters.Backstage.Licensing.Licenses; + +namespace SharpCrafters.Backstage.LicenseServer.Tests; + +/// +/// The licensing component, which is SharpCrafters.Backstage. Every other test of this suite runs +/// against FakeLicenseParser. These tests are the only ones that parse a real license key, +/// and therefore the only ones that detect a change in the values the package reports. +/// +public sealed class BackstageLicenseParserTests +{ + private static readonly BackstageLicenseParser parser = new( TestLicenseKeys.Authority ); + + [Fact] + public void SignedKey_IsParsedIntoTheFactsTheServerNeeds() + { + var builder = TestLicenseKeys.Builder( licenseId: 4242 ); + builder.UserNumber = 25; + builder.ValidTo = new DateTime( 2030, 6, 30, 0, 0, 0, DateTimeKind.Utc ); + builder.SubscriptionEndDate = new DateTime( 2029, 6, 30, 0, 0, 0, DateTimeKind.Utc ); + builder.GraceDays = 7; + builder.GracePercent = 15; + + var license = parser.TryParse( builder.Sign() ); + + Assert.NotNull( license ); + Assert.Equal( 4242, license.LicenseId ); + Assert.Equal( "PostSharpUltimate", license.Product ); + + // LicenseType.Business and LicenseType.PerUser are two names of the same value, and PerUser is + // the one declared first, so it is the one the enumeration formats. + Assert.Equal( "PerUser", license.LicenseType ); + Assert.Equal( 25, license.UserNumber ); + Assert.Equal( new DateTime( 2030, 6, 30 ), license.ValidTo ); + Assert.Equal( new DateTime( 2029, 6, 30 ), license.SubscriptionEndDate ); + Assert.Equal( 7, license.GraceDays ); + Assert.Equal( 15, license.GracePercent ); + Assert.True( license.IsLicenseServerEligible ); + Assert.Equal( "Business License", license.LicenseTypeName ); + Assert.Equal( "PostSharp Ultimate", license.ProductName ); + } + + /// + /// The parser also reads a license key that SharpCrafters.Backstage creates for its own tests. + /// The server and the package therefore agree on the whole format, and not only on the fields + /// that a key built in this file uses. + /// + [Fact] + public void KeyIssuedByTheBackstageTestProvider_IsParsed() + { + var license = parser.TryParse( TestLicenseKeys.Keys.PostSharpUltimate ); + + Assert.NotNull( license ); + Assert.Equal( "PostSharpUltimate", license.Product ); + Assert.True( license.IsLicenseServerEligible ); + } + + /// + /// The signature prevents a customer from creating their own licenses, so the server must not + /// serve a key signed with a key that the authority does not hold. + /// + [Fact] + public void KeySignedWithAForgedKey_IsRejected() => Assert.Null( parser.TryParse( TestLicenseKeys.Builder().SignWithAForgedKey() ) ); + + /// + /// A key whose signature names an authority that was never issued is an invalid key, and not an + /// exception. The provider raises an exception when it is asked for a key it does not hold, and + /// an administrator pastes license keys into a web form. + /// + [Fact] + public void KeySignedByAnUnknownAuthority_IsRejectedWithoutThrowing() + => Assert.Null( parser.TryParse( TestLicenseKeys.Builder().SignWithAnUnknownAuthority() ) ); + + [Fact] + public void UnsignedKeyOfATypeThatRequiresASignature_IsRejected() => Assert.Null( parser.TryParse( TestLicenseKeys.Builder().Unsigned() ) ); + + /// + /// An evaluation key carries no signature by design, and the server may serve it. + /// + [Fact] + public void UnsignedKeyOfATypeThatRequiresNoSignature_IsParsed() + { + var key = TestLicenseKeys.Builder( licenseType: LicenseType.Evaluation ).Unsigned(); + + var license = parser.TryParse( key ); + + Assert.NotNull( license ); + Assert.Equal( nameof(LicenseType.Evaluation), license.LicenseType ); + } + + [Theory] + [InlineData( "" )] + [InlineData( " " )] + [InlineData( "NOT-A-KEY" )] + [InlineData( "1-AAAAAAAA" )] + [InlineData( "no-hyphen-prefix" )] + public void MalformedKey_IsRejected( string key ) => Assert.Null( parser.TryParse( key ) ); + + /// + /// A key that carries no grace percentage is served with the percentage PostSharp applied, so a + /// license that was being served before the migration is served the same way after it. + /// + [Fact] + public void KeyWithoutAGracePercentage_GetsThirtyPercent() + { + var builder = TestLicenseKeys.Builder(); + builder.GracePercent = null; + + Assert.Equal( 30, parser.TryParse( builder.Sign() )!.GracePercent ); + } + + /// + /// A key that carries no grace period gets the thirty days that SharpCrafters.Backstage applies. + /// + /// + /// This is a change of behaviour. The PostSharp SDK returned zero days here, so such a license + /// denied a request as soon as its capacity was exceeded, while it now keeps serving for thirty + /// days beyond capacity. The default is applied inside LicenseKeyData.GraceDays, whose type + /// is not nullable, so the parser cannot tell an absent field from a field set to thirty and + /// cannot restore the old value. Keys issued with an explicit grace period are unaffected. + /// + [Fact] + public void KeyWithoutAGracePeriod_GetsThirtyDays() + { + var builder = TestLicenseKeys.Builder(); + + Assert.Equal( 30, parser.TryParse( builder.Sign() )!.GraceDays ); + } + + /// + /// A key that carries no minimal version of the client is not served to every client. The + /// licensing library derives the version from the other fields. A key that a license server may + /// serve is readable by PostSharp 5.0.22 and later, which is the version that introduced the + /// license server. + /// + [Fact] + public void MinPostSharpVersion_IsDerivedWhenTheKeyDoesNotDeclareIt() + { + var license = parser.TryParse( TestLicenseKeys.Builder().Sign() ); + + Assert.NotNull( license ); + Assert.Equal( new Version( 5, 0, 22 ), license.MinPostSharpVersion ); + } + + /// + /// The product name stored in the database is the one of the Backstage enumeration, which is the + /// one a client of that generation asks for. covers the licenses + /// that a previous version of this server stored under the PostSharp spelling. + /// + [Fact] + public void Product_IsStoredUnderItsBackstageName() + { + var license = parser.TryParse( TestLicenseKeys.Builder( product: LicenseProduct.PostSharpFramework ).Sign() ); + + Assert.Equal( "PostSharpFramework", license!.Product ); + } + + /// + /// A key that says it may not be leased is not served, whatever else it carries. + /// + [Fact] + public void KeyThatIsNotEligibleForALicenseServer_SaysSo() + { + var builder = TestLicenseKeys.Builder(); + builder.LicenseServerEligible = false; + + Assert.False( parser.TryParse( builder.Sign() )!.IsLicenseServerEligible ); + } + + [Theory] + [InlineData( " 1-ABCDEF ", "1-ABCDEF" )] + [InlineData( "1-ABC DEF", "1-ABCDEF" )] + [InlineData( "1-ABC\r\n\tDEF", "1-ABCDEF" )] + public void CleanLicenseString_RemovesEveryKindOfWhitespace( string pasted, string expected ) + => Assert.Equal( expected, parser.CleanLicenseString( pasted ) ); + + /// + /// A key pasted with the whitespace that an e-mail adds is parsed after it is cleaned. The page + /// that adds a license performs these two steps in this order. + /// + [Fact] + public void PastedKey_ParsesAfterCleaning() + { + var key = TestLicenseKeys.Builder().Sign(); + var pasted = key[..10] + " \r\n " + key[10..]; + + Assert.Null( parser.TryParse( pasted ) ); + Assert.NotNull( parser.TryParse( parser.CleanLicenseString( pasted ) ) ); + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/BuildServerDetectionTests.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/BuildServerDetectionTests.cs new file mode 100644 index 0000000..bfa16e8 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/BuildServerDetectionTests.cs @@ -0,0 +1,110 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Options; +using SharpCrafters.Backstage.LicenseServer.Services; +using SharpCrafters.Backstage.LicenseServer.Tests.Fakes; + +namespace SharpCrafters.Backstage.LicenseServer.Tests; + +/// +/// Build agents are recognised by name so that their leases are not persisted and therefore do not +/// consume developer seats. Agents append a hexadecimal unique identifier to their machine name, +/// which has to be stripped before the name is matched. +/// +public sealed class BuildServerDetectionTests +{ + private static LeaseService CreateService( string? buildServers ) + { + LicenseServerOptions options = new() { BuildServers = buildServers }; + + return new LeaseService( + new StubRepository(), + Microsoft.Extensions.Options.Options.Create( options ), + new FakeLicenseParser(), + new FixedServerVersion(), + new InMemoryEmailSender(), + NullLogger.Instance ); + } + + [Theory] + [InlineData( "server", true )] + [InlineData( "server-1a2b", true )] + [InlineData( "server-ABCDEF", true )] + [InlineData( "server-0", true )] + [InlineData( "SERVER", true )] + [InlineData( "Server-1A2B", true )] + public void IsBuildServer_KnownAgent_ReturnsTrue( string machine, bool expected ) + => Assert.Equal( expected, CreateService( "server" ).IsBuildServer( machine ) ); + + [Theory] + + // Only a hexadecimal suffix is stripped, so "-xyz" stays part of the name. + [InlineData( "server-xyz" )] + [InlineData( "myserver" )] + [InlineData( "server2" )] + [InlineData( "desktop-1a2b" )] + [InlineData( "" )] + public void IsBuildServer_OtherMachine_ReturnsFalse( string machine ) => Assert.False( CreateService( "server" ).IsBuildServer( machine ) ); + + [Theory] + [InlineData( "build1;build2" )] + [InlineData( "build1,build2" )] + [InlineData( "build1 build2" )] + [InlineData( " build1 ; build2 " )] + public void IsBuildServer_AcceptsEverySeparatorAndTrimsWhitespace( string buildServers ) + { + var service = CreateService( buildServers ); + + Assert.True( service.IsBuildServer( "build1" ) ); + Assert.True( service.IsBuildServer( "build2-ff01" ) ); + Assert.False( service.IsBuildServer( "build3" ) ); + } + + [Theory] + [InlineData( null )] + [InlineData( "" )] + [InlineData( " " )] + public void IsBuildServer_NoBuildServersConfigured_ReturnsFalse( string? buildServers ) + => Assert.False( CreateService( buildServers ).IsBuildServer( "server" ) ); + + /// + /// A repository that is never reached: these tests only exercise name matching, which happens + /// before any database access. + /// + private sealed class StubRepository : ILeaseRepository + { + public IQueryable OpenLeases => Array.Empty().AsQueryable(); + + public IQueryable Leases => Array.Empty().AsQueryable(); + + public IQueryable Licenses => Array.Empty().AsQueryable(); + + public Lease? CreateLease( + License license, + string user, + string machine, + string authenticatedUserName, + DateTime time, + bool grace ) + => throw new NotSupportedException(); + + public Lease? ProlongLease( Lease oldLease, string authenticatedUserName, DateTime time ) => throw new NotSupportedException(); + + public void CancelLease( Lease lease, string authenticatedUserName, DateTime time ) => throw new NotSupportedException(); + + public int GetActiveSeats( int licenseId, DateTime dateTime ) => throw new NotSupportedException(); + + public IEnumerable GetLeaseCountingPoints( + int licenseId, + DateTime startTime, + DateTime endTime ) + => throw new NotSupportedException(); + + public Task SaveChangesAsync( CancellationToken cancellationToken = default ) => throw new NotSupportedException(); + + public int SaveChanges() => throw new NotSupportedException(); + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/CancelLeaseTests.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/CancelLeaseTests.cs new file mode 100644 index 0000000..797a8b8 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/CancelLeaseTests.cs @@ -0,0 +1,107 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.EntityFrameworkCore; +using SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +namespace SharpCrafters.Backstage.LicenseServer.Tests; + +/// +/// An administrator can end a lease before its end time. The server inserts a replacement that ends +/// at the current instant, and it deletes nothing. +/// +public sealed class CancelLeaseTests +{ + [Fact] + public async Task CancelLease_InsertsAReplacementEndingNow() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + var original = LeaseBuilder.For( license ).From( TestClock.Origin ).Lasting( 3 ).AddTo( context ); + + context.Repository.CancelLease( original, "DOMAIN\\admin", TestClock.Days( 1 ) ); + await context.Repository.SaveChangesAsync(); + + var replacement = await context.Db.Leases.SingleAsync( l => l.LeaseId != original.LeaseId ); + + Assert.Equal( original.LeaseId, replacement.OverwrittenLeaseId ); + Assert.Equal( TestClock.Days( 1 ), replacement.EndTime ); + Assert.Equal( original.StartTime, replacement.StartTime ); + Assert.Equal( original.UserName, replacement.UserName ); + Assert.Equal( original.Machine, replacement.Machine ); + Assert.Equal( "DOMAIN\\admin", replacement.AuthenticatedUser ); + } + + [Fact] + public async Task CancelLease_KeepsTheOriginalRow() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + var original = LeaseBuilder.For( license ).AddTo( context ); + + context.Repository.CancelLease( original, "admin", TestClock.Days( 1 ) ); + await context.Repository.SaveChangesAsync(); + + // The audit log is append-only. + Assert.Equal( 2, await context.Db.Leases.CountAsync() ); + Assert.NotNull( await context.Db.Leases.FindAsync( original.LeaseId ) ); + } + + [Fact] + public async Task CancelLease_ReleasesTheSeat() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + var original = LeaseBuilder.For( license ).From( TestClock.Origin ).Lasting( 3 ).AddTo( context ); + + Assert.Equal( 1, context.Repository.GetActiveSeats( license.LicenseId, TestClock.Days( 2 ) ) ); + + context.Repository.CancelLease( original, "admin", TestClock.Days( 1 ) ); + await context.Repository.SaveChangesAsync(); + + Assert.Equal( 0, context.Repository.GetActiveSeats( license.LicenseId, TestClock.Days( 2 ) ) ); + } + + /// + /// A cancellation skips the adjustment of the end time that a new lease receives. With that + /// adjustment, a lease that ends at the current instant would be rejected, because it does not + /// end after the current instant, and the cancellation would have no effect. + /// + [Fact] + public async Task CancelLease_IsNotRejectedForEndingImmediately() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().WithValidTo( TestClock.Days( 2 ) ).AddTo( context ); + var original = LeaseBuilder.For( license ).From( TestClock.Origin ).Lasting( 2 ).AddTo( context ); + + context.Repository.CancelLease( original, "admin", TestClock.Days( 1 ) ); + await context.Repository.SaveChangesAsync(); + + var replacement = await context.Db.Leases.SingleAsync( l => l.LeaseId != original.LeaseId ); + Assert.Equal( TestClock.Days( 1 ), replacement.EndTime ); + } + + [Fact] + public async Task CancelLease_ThenRequestAgain_GrantsAFreshLease() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + var original = LeaseBuilder.For( license ).User( "alice" ).Machine( "desktop-1" ).AddTo( context ); + + context.Repository.CancelLease( original, "admin", TestClock.Days( 1 ) ); + await context.Repository.SaveChangesAsync(); + + var lease = await context.LeaseService.GetLeaseAsync( + new Version( 2025, 1, 0 ), + null, + "desktop-1", + "alice", + "alice", + TestClock.Days( 2 ), + [], + [license] ); + + Assert.NotNull( lease ); + Assert.NotEqual( original.LeaseId, lease.LeaseId ); + Assert.Equal( TestClock.Days( 2 ), lease.StartTime ); + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/ConfigurationTests.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/ConfigurationTests.cs new file mode 100644 index 0000000..50ce624 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/ConfigurationTests.cs @@ -0,0 +1,131 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.ComponentModel.DataAnnotations; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; +using SharpCrafters.Backstage.LicenseServer.Licensing; +using SharpCrafters.Backstage.LicenseServer.Options; +using SharpCrafters.Backstage.LicenseServer.Time; +using SharpCrafters.Backstage.LicenseServer.Tests.Fakes; +using SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +namespace SharpCrafters.Backstage.LicenseServer.Tests; + +/// +/// Settings validation, the accelerated clock and the license parse cache. +/// +public sealed class ConfigurationTests +{ + private static ValidateOptionsResult Validate( Action configure ) + { + LicenseServerOptions options = new(); + configure( options ); + + return new LicenseServerOptionsValidator().Validate( null, options ); + } + + [Fact] + public void Defaults_AreValid() => Assert.True( Validate( _ => { } ).Succeeded ); + + /// + /// When the renewal time is not before the end of the lease, the client renews the lease at + /// every request. The legacy configuration documented this constraint and never enforced it. + /// + [Theory] + [InlineData( 3, 3 )] + [InlineData( 5, 3 )] + public void MinLeaseDaysNotBelowNewLeaseDays_IsRejected( int minLeaseDays, int newLeaseDays ) + { + var result = Validate( o => + { + o.MinLeaseDays = minLeaseDays; + o.NewLeaseDays = newLeaseDays; + } ); + + Assert.True( result.Failed ); + Assert.Contains( result.Failures!, f => f.Contains( "MinLeaseDays", StringComparison.Ordinal ) ); + } + + [Fact] + public void NegativeTimeAcceleration_IsRejected() => Assert.True( Validate( o => o.TimeAcceleration = -1 ).Failed ); + + [Theory] + [InlineData( 0 )] + [InlineData( -1 )] + public void MachinesPerUserBelowOne_FailsDataAnnotations( int value ) + { + LicenseServerOptions options = new() { MachinesPerUser = value }; + List results = []; + + Assert.False( Validator.TryValidateObject( options, new ValidationContext( options ), results, true ) ); + } + + [Fact] + public void MutexTimeout_IsExposedAsATimeSpan() + => Assert.Equal( TimeSpan.FromSeconds( 45 ), new LicenseServerOptions { MutexTimeout = 45 }.MutexTimeoutSpan ); + + [Fact] + public void AcceleratedTimeProvider_MultipliesElapsedTime() + { + FakeTimeProvider inner = new( TestClock.Origin ); + AcceleratedTimeProvider accelerated = new( inner, 1440 ); + + inner.Advance( TimeSpan.FromSeconds( 1 ) ); + + // A second of real time becomes a day of simulated time. + Assert.Equal( TestClock.Origin.AddMinutes( 24 ), accelerated.GetUtcNow().UtcDateTime ); + } + + [Fact] + public void AcceleratedTimeProvider_StartsAtTheCurrentTime() + { + FakeTimeProvider inner = new( TestClock.Origin ); + + Assert.Equal( TestClock.Origin, new AcceleratedTimeProvider( inner, 100 ).GetUtcNow().UtcDateTime ); + } + + [Theory] + [InlineData( 0 )] + [InlineData( -2 )] + public void AcceleratedTimeProvider_RejectsNonPositiveAcceleration( double acceleration ) + => Assert.Throws( () => new AcceleratedTimeProvider( TimeProvider.System, acceleration ) ); + + [Fact] + public void CachingLicenseParser_ParsesEachKeyOnlyOnce() + { + FakeLicenseParser inner = new(); + inner.Register( "KEY", LicenseBuilder.Default().BuildInfo() ); + + CachingLicenseParser cache = new( inner ); + + Assert.NotNull( cache.TryParse( "KEY" ) ); + Assert.NotNull( cache.TryParse( "KEY" ) ); + Assert.Equal( 1, inner.ParseCount ); + } + + /// + /// The legacy cache returned before it stored a failure, so it parsed an invalid key at every + /// request and at every display of the home page. + /// + [Fact] + public void CachingLicenseParser_RemembersThatAKeyIsInvalid() + { + FakeLicenseParser inner = new(); + CachingLicenseParser cache = new( inner ); + + Assert.Null( cache.TryParse( "BAD" ) ); + Assert.Null( cache.TryParse( "BAD" ) ); + Assert.Equal( 1, inner.ParseCount ); + } + + [Theory] + [InlineData( "" )] + [InlineData( " " )] + public void CachingLicenseParser_BlankKey_IsRejectedWithoutParsing( string key ) + { + FakeLicenseParser inner = new(); + + Assert.Null( new CachingLicenseParser( inner ).TryParse( key ) ); + Assert.Equal( 0, inner.ParseCount ); + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/EmailSenderTests.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/EmailSenderTests.cs new file mode 100644 index 0000000..2ba25bf --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/EmailSenderTests.cs @@ -0,0 +1,83 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using SharpCrafters.Backstage.LicenseServer.Email; +using SharpCrafters.Backstage.LicenseServer.Tests.Fakes; + +namespace SharpCrafters.Backstage.LicenseServer.Tests; + +/// +/// The in-memory used throughout the test suite, and the guarantee that +/// no test ever reaches a real SMTP server. +/// +public sealed class EmailSenderTests +{ + [Fact] + public async Task InMemorySender_RecordsWhatItWasAskedToSend() + { + InMemoryEmailSender sender = new(); + + await sender.SendAsync( new EmailMessage( "admin@example.com", null, "Subject", "Body" ) ); + + var message = Assert.Single( sender.Sent ); + Assert.Equal( "admin@example.com", message.To ); + Assert.Equal( "Subject", message.Subject ); + Assert.Equal( "Body", message.Body ); + Assert.Null( message.Cc ); + } + + [Fact] + public async Task InMemorySender_PreservesOrder() + { + InMemoryEmailSender sender = new(); + + await sender.SendAsync( new EmailMessage( "a@example.com", null, "first", "" ) ); + await sender.SendAsync( new EmailMessage( "b@example.com", null, "second", "" ) ); + + Assert.Equal( ["first", "second"], sender.Sent.Select( m => m.Subject ) ); + Assert.Equal( "second", sender.Last!.Subject ); + } + + [Fact] + public async Task InMemorySender_FiltersBySubject() + { + InMemoryEmailSender sender = new(); + + await sender.SendAsync( new EmailMessage( "a@example.com", null, "WARNING: capacity exceeded", "" ) ); + await sender.SendAsync( new EmailMessage( "a@example.com", null, "ERROR: request denied", "" ) ); + + Assert.Single( sender.WithSubject( "WARNING" ) ); + Assert.Single( sender.WithSubject( "denied" ) ); + Assert.Empty( sender.WithSubject( "nothing like this" ) ); + } + + [Fact] + public async Task InMemorySender_CanSimulateABrokenServer() + { + InMemoryEmailSender sender = new() { ThrowOnSend = new InvalidOperationException( "SMTP is down" ) }; + + await Assert.ThrowsAsync( () => sender.SendAsync( new EmailMessage( "a@example.com", null, "s", "b" ) ) ); + + Assert.Empty( sender.Sent ); + } + + [Fact] + public async Task InMemorySender_Clear_DiscardsRecordedMessages() + { + InMemoryEmailSender sender = new(); + await sender.SendAsync( new EmailMessage( "a@example.com", null, "s", "b" ) ); + + sender.Clear(); + + Assert.Empty( sender.Sent ); + Assert.Null( sender.Last ); + } + + [Fact] + public async Task NullSender_DiscardsEverything() + { + NullEmailSender sender = new(); + + // Must not throw: this is what the demo-data generator and disabled-SMTP deployments use. + await sender.SendAsync( new EmailMessage( "a@example.com", "b@example.com", "s", "b" ) ); + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/Fakes/FakeLicenseParser.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Fakes/FakeLicenseParser.cs new file mode 100644 index 0000000..6f60392 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Fakes/FakeLicenseParser.cs @@ -0,0 +1,38 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using SharpCrafters.Backstage.LicenseServer.Licensing; + +namespace SharpCrafters.Backstage.LicenseServer.Tests.Fakes; + +/// +/// Maps the synthetic license keys of a test to the properties that the test gives them. +/// +/// +/// A real PostSharp license key is signed, and this repository contains none, so almost every test +/// uses this parser. BackstageLicenseParserTests covers the real parser with the license keys +/// of the test authority. +/// +public sealed class FakeLicenseParser : ILicenseParser +{ + private readonly Dictionary licenses = new( StringComparer.Ordinal ); + + /// + /// Gets the number of calls to that did the work, so that a test can + /// verify the cache. + /// + public int ParseCount { get; private set; } + + public void Register( string licenseKey, LicenseInfo info ) => this.licenses[licenseKey] = info; + + public LicenseInfo? TryParse( string licenseKey ) + { + this.ParseCount++; + + return this.licenses.GetValueOrDefault( licenseKey ); + } + + /// + /// Reproduces the real implementation, which removes the whitespace of a pasted key. + /// + public string CleanLicenseString( string licenseKey ) => new( licenseKey.Where( c => !char.IsWhiteSpace( c ) ).ToArray() ); +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/Fakes/FixedServerVersion.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Fakes/FixedServerVersion.cs new file mode 100644 index 0000000..a585544 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Fakes/FixedServerVersion.cs @@ -0,0 +1,21 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using SharpCrafters.Backstage.LicenseServer.Licensing; + +namespace SharpCrafters.Backstage.LicenseServer.Tests.Fakes; + +/// +/// Reports a fixed version of the licensing library, so that a test can reach the branch in which +/// the license server is older than the license key requires. +/// +public sealed class FixedServerVersion : ILicenseServerVersion +{ + public FixedServerVersion() : this( new Version( 2025, 1, 5 ) ) { } + + public FixedServerVersion( Version version ) + { + this.LicensingLibraryVersion = version; + } + + public Version LicensingLibraryVersion { get; } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/Fakes/InMemoryEmailSender.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Fakes/InMemoryEmailSender.cs new file mode 100644 index 0000000..1e81217 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Fakes/InMemoryEmailSender.cs @@ -0,0 +1,48 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Collections.Concurrent; +using SharpCrafters.Backstage.LicenseServer.Email; + +namespace SharpCrafters.Backstage.LicenseServer.Tests.Fakes; + +/// +/// An that stores the messages in memory instead of sending them, so that +/// a test can assert on the notifications that the license server produces. +/// +public sealed class InMemoryEmailSender : IEmailSender +{ + private readonly ConcurrentQueue sent = new(); + + /// + /// Gets or sets an exception that this sender raises instead of recording the message, so that a + /// test can verify that an SMTP server that fails never denies a license. + /// + public Exception? ThrowOnSend { get; set; } + + /// + /// Gets the messages recorded so far, in the order they were sent. + /// + public IReadOnlyList Sent => this.sent.ToArray(); + + public EmailMessage? Last => this.Sent.LastOrDefault(); + + public Task SendAsync( EmailMessage message, CancellationToken cancellationToken = default ) + { + if ( this.ThrowOnSend != null ) + { + throw this.ThrowOnSend; + } + + this.sent.Enqueue( message ); + + return Task.CompletedTask; + } + + public void Clear() => this.sent.Clear(); + + /// + /// Returns the messages whose subject contains the given text. + /// + public IReadOnlyList WithSubject( string substring ) + => this.Sent.Where( m => m.Subject.Contains( substring, StringComparison.OrdinalIgnoreCase ) ).ToArray(); +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/Fakes/NeverAcquiringLeaseLock.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Fakes/NeverAcquiringLeaseLock.cs new file mode 100644 index 0000000..e0267f9 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Fakes/NeverAcquiringLeaseLock.cs @@ -0,0 +1,16 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using SharpCrafters.Backstage.LicenseServer.Locking; + +namespace SharpCrafters.Backstage.LicenseServer.Tests.Fakes; + +/// +/// Never grants the lock, so that a test can exercise the path that answers "Service overloaded." +/// +public sealed class NeverAcquiringLeaseLock : ILeaseLock +{ + public ValueTask TryAcquireAsync( + TimeSpan timeout, + CancellationToken cancellationToken = default ) + => ValueTask.FromResult( null ); +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/GetActiveSeatsTests.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/GetActiveSeatsTests.cs new file mode 100644 index 0000000..f1c0383 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/GetActiveSeatsTests.cs @@ -0,0 +1,166 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +namespace SharpCrafters.Backstage.LicenseServer.Tests; + +/// +/// How many seats of a license are in use at a given moment. +/// +public sealed class GetActiveSeatsTests +{ + [Fact] + public async Task GetActiveSeats_NoLeases_ReturnsZero() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + + Assert.Equal( 0, context.Repository.GetActiveSeats( license.LicenseId, TestClock.Days( 1 ) ) ); + } + + [Fact] + public async Task GetActiveSeats_OneUserOneMachine_ReturnsOne() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + LeaseBuilder.For( license ).User( "alice" ).Machine( "desktop-1" ).AddTo( context ); + + Assert.Equal( 1, context.Repository.GetActiveSeats( license.LicenseId, TestClock.Days( 1 ) ) ); + } + + [Fact] + public async Task GetActiveSeats_OneUserTwoMachines_StillReturnsOne() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + LeaseBuilder.For( license ).User( "alice" ).Machine( "desktop-1" ).AddTo( context ); + LeaseBuilder.For( license ).User( "alice" ).Machine( "laptop-1" ).AddTo( context ); + + Assert.Equal( 1, context.Repository.GetActiveSeats( license.LicenseId, TestClock.Days( 1 ) ) ); + } + + [Fact] + public async Task GetActiveSeats_OneUserThreeMachines_ReturnsTwo() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + + foreach ( var machine in new[] { "desktop-1", "laptop-1", "desktop-2" } ) + { + LeaseBuilder.For( license ).User( "alice" ).Machine( machine ).AddTo( context ); + } + + Assert.Equal( 2, context.Repository.GetActiveSeats( license.LicenseId, TestClock.Days( 1 ) ) ); + } + + [Fact] + public async Task GetActiveSeats_TwoUsers_ReturnsTwo() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + LeaseBuilder.For( license ).User( "alice" ).AddTo( context ); + LeaseBuilder.For( license ).User( "bob" ).AddTo( context ); + + Assert.Equal( 2, context.Repository.GetActiveSeats( license.LicenseId, TestClock.Days( 1 ) ) ); + } + + [Fact] + public async Task GetActiveSeats_LeaseStartingExactlyNow_IsCounted() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + LeaseBuilder.For( license ).From( TestClock.Origin ).Lasting( 3 ).AddTo( context ); + + Assert.Equal( 1, context.Repository.GetActiveSeats( license.LicenseId, TestClock.Origin ) ); + } + + [Fact] + public async Task GetActiveSeats_LeaseEndingExactlyNow_IsNotCounted() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + LeaseBuilder.For( license ).From( TestClock.Origin ).Lasting( 3 ).AddTo( context ); + + Assert.Equal( 0, context.Repository.GetActiveSeats( license.LicenseId, TestClock.Days( 3 ) ) ); + } + + [Fact] + public async Task GetActiveSeats_OtherLicense_IsNotCounted() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var first = LicenseBuilder.Default().WithLicenseId( 1 ).AddTo( context ); + var second = LicenseBuilder.Default().WithLicenseId( 2 ).AddTo( context ); + + LeaseBuilder.For( second ).AddTo( context ); + + Assert.Equal( 0, context.Repository.GetActiveSeats( first.LicenseId, TestClock.Days( 1 ) ) ); + Assert.Equal( 1, context.Repository.GetActiveSeats( second.LicenseId, TestClock.Days( 1 ) ) ); + } + + [Fact] + public async Task GetActiveSeats_ReplacedLease_IsNotCounted() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + var original = LeaseBuilder.For( license ).AddTo( context ); + + context.Repository.CancelLease( original, "admin", TestClock.Days( 1 ) ); + await context.Repository.SaveChangesAsync(); + + Assert.Equal( 0, context.Repository.GetActiveSeats( license.LicenseId, TestClock.Days( 2 ) ) ); + } + + /// + /// The default collation of SQL Server ignores the case. The database held in memory uses the + /// same collation, so that a test cannot pass here and fail in production. + /// + [Fact] + public async Task GetActiveSeats_UserNameCasingDiffers_CountsAsOneUser() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + LeaseBuilder.For( license ).User( "alice" ).Machine( "desktop-1" ).AddTo( context ); + LeaseBuilder.For( license ).User( "ALICE" ).Machine( "laptop-1" ).AddTo( context ); + + Assert.Equal( 1, context.Repository.GetActiveSeats( license.LicenseId, TestClock.Days( 1 ) ) ); + } + + [Fact] + public async Task GetActiveSeats_HonoursMachinesPerUser() + { + await using var context = + await LicenseServerTestContext.CreateAsync( o => o.MachinesPerUser = 1 ); + + var license = LicenseBuilder.Default().AddTo( context ); + LeaseBuilder.For( license ).User( "alice" ).Machine( "desktop-1" ).AddTo( context ); + LeaseBuilder.For( license ).User( "alice" ).Machine( "laptop-1" ).AddTo( context ); + + // With one machine per seat, the same user on two machines consumes two seats. + Assert.Equal( 2, context.Repository.GetActiveSeats( license.LicenseId, TestClock.Days( 1 ) ) ); + } + + /// + /// A seat is counted from the machines a user works on, and not from the leases that user holds. + /// A user can hold two leases on one machine. Counting a second machine for that user would deny + /// a lease to a colleague, while the license still has a free seat. + /// + /// + /// The lease service prevents a second lease on one machine: it prolongs the first lease. A + /// server whose clock moved backwards grants a second lease. A load simulation produced this + /// state a few minutes after a restart. + /// + [Fact] + public async Task GetActiveSeats_TwoLeasesOnOneMachine_CountAsOneMachine() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + + LeaseBuilder.For( license ).User( "alice" ).Machine( "desktop-1" ).AddTo( context ); + LeaseBuilder.For( license ).User( "alice" ).Machine( "desktop-1" ).AddTo( context ); + LeaseBuilder.For( license ).User( "alice" ).Machine( "laptop-1" ).AddTo( context ); + + // Two machines at two machines per seat is one seat. Counting the three leases would make it + // two. + Assert.Equal( 1, context.Repository.GetActiveSeats( license.LicenseId, TestClock.Days( 1 ) ) ); + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/AsyncOnlyResponseBody.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/AsyncOnlyResponseBody.cs new file mode 100644 index 0000000..7f5df6d --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/AsyncOnlyResponseBody.cs @@ -0,0 +1,105 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; + +namespace SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +/// +/// Makes the response body of the test host refuse a synchronous write, as Kestrel does. +/// +/// +/// accepts a synchronous write whatever the +/// value of its AllowSynchronousIO property. An endpoint that writes synchronously therefore +/// passes every test, and then fails against a real server with the message "Synchronous operations +/// are disallowed". This filter wraps the body in a stream that raises the same exception. +/// +public sealed class AsyncOnlyResponseBody : IStartupFilter +{ + /// + /// Gets or sets a value indicating whether the stream refuses a synchronous write. The value is + /// false by default, so that a test that does not read the response body is not affected. + /// + public bool IsEnabled { get; set; } + + public Action Configure( Action next ) + => builder => + { + builder.Use( async ( context, nextMiddleware ) => + { + if ( !this.IsEnabled ) + { + await nextMiddleware( context ); + + return; + } + + var original = context.Response.Body; + context.Response.Body = new AsyncOnlyStream( original ); + + try + { + await nextMiddleware( context ); + } + finally + { + context.Response.Body = original; + } + } ); + + next( builder ); + }; + + /// + /// Forwards every asynchronous write, and raises an exception at every synchronous write, with + /// the message that Kestrel uses. + /// + private sealed class AsyncOnlyStream : Stream + { + private readonly Stream inner; + + public AsyncOnlyStream( Stream inner ) + { + this.inner = inner; + } + + private const string message = + "Synchronous operations are disallowed. Call WriteAsync or set AllowSynchronousIO to true instead."; + + public override bool CanRead => false; + + public override bool CanSeek => false; + + public override bool CanWrite => this.inner.CanWrite; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Write( byte[] buffer, int offset, int count ) => throw new InvalidOperationException( message ); + + public override void Write( ReadOnlySpan buffer ) => throw new InvalidOperationException( message ); + + public override void WriteByte( byte value ) => throw new InvalidOperationException( message ); + + public override void Flush() => throw new InvalidOperationException( message ); + + public override Task WriteAsync( byte[] buffer, int offset, int count, CancellationToken cancellationToken ) + => this.inner.WriteAsync( buffer, offset, count, cancellationToken ); + + public override ValueTask WriteAsync( ReadOnlyMemory buffer, CancellationToken cancellationToken = default ) + => this.inner.WriteAsync( buffer, cancellationToken ); + + public override Task FlushAsync( CancellationToken cancellationToken ) => this.inner.FlushAsync( cancellationToken ); + + public override int Read( byte[] buffer, int offset, int count ) => throw new NotSupportedException(); + + public override long Seek( long offset, SeekOrigin origin ) => throw new NotSupportedException(); + + public override void SetLength( long value ) => throw new NotSupportedException(); + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/ITestDatabase.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/ITestDatabase.cs new file mode 100644 index 0000000..375ac21 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/ITestDatabase.cs @@ -0,0 +1,35 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using SharpCrafters.Backstage.LicenseServer.Data; + +namespace SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +/// +/// A database that belongs to one test, on the engine the test run was started with. +/// +public interface ITestDatabase : IAsyncDisposable +{ + /// + /// Gets the name of the provider, as LicenseServer:DatabaseProvider spells it. + /// + string ProviderName { get; } + + /// + /// Gets the connection string of this database, which the tests that host the whole application + /// pass to it as configuration. + /// + string ConnectionString { get; } + + /// + /// Creates a context over this database. Each context is a separate unit of work with its own + /// change tracker, so a test can distinguish a lease that is pending from a lease that is saved. + /// + LicenseServerDbContext CreateContext(); + + /// + /// States that this database must not serve another test, which a test that modifies the schema + /// calls. A SQL Server run lends its databases to one test after another, and a test that drops + /// a table would leave the next test without one. + /// + void DoNotReuse(); +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/LeaseBuilder.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/LeaseBuilder.cs new file mode 100644 index 0000000..fb3c1a3 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/LeaseBuilder.cs @@ -0,0 +1,88 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using SharpCrafters.Backstage.LicenseServer.Licensing; + +namespace SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +/// +/// Builds a lease directly, without the allocation rules, to create the initial state of a test. +/// +public sealed class LeaseBuilder +{ + private readonly License license; + private string userName = "alice"; + private string machine = "desktop-1"; + private DateTime startTime = TestClock.Origin; + private DateTime endTime = TestClock.Origin.AddDays( 3 ); + private bool grace; + + private LeaseBuilder( License license ) + { + this.license = license; + } + + public static LeaseBuilder For( License license ) => new( license ); + + public LeaseBuilder User( string value ) + { + this.userName = value; + + return this; + } + + public LeaseBuilder Machine( string value ) + { + this.machine = value; + + return this; + } + + public LeaseBuilder From( DateTime value ) + { + this.startTime = value; + + return this; + } + + public LeaseBuilder To( DateTime value ) + { + this.endTime = value; + + return this; + } + + public LeaseBuilder Lasting( double days ) + { + this.endTime = this.startTime.AddDays( days ); + + return this; + } + + public LeaseBuilder InGrace() + { + this.grace = true; + + return this; + } + + public Lease AddTo( LicenseServerTestContext context ) + { + Lease lease = new() + { + License = this.license, + LicenseId = this.license.LicenseId, + UserName = this.userName, + Machine = this.machine, + AuthenticatedUser = this.userName, + StartTime = this.startTime, + EndTime = this.endTime, + Grace = this.grace + }; + + // The lease is saved through the repository, so that it is signed as a real lease is. + context.Db.Leases.Add( lease ); + context.Repository.SaveChanges(); + + return lease; + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/LicenseBuilder.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/LicenseBuilder.cs new file mode 100644 index 0000000..83703ce --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/LicenseBuilder.cs @@ -0,0 +1,182 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using SharpCrafters.Backstage.LicenseServer.Licensing; + +namespace SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +/// +/// Builds a license, and the properties that the fake parser reports for its key. +/// +public sealed class LicenseBuilder +{ + private int licenseId = 1; + private int? userNumber = 5; + private int priority; + private string product = "Ultimate"; + private string licenseType = "PerUser"; + private DateTime? validTo; + private DateTime? subscriptionEndDate; + private Version minPostSharpVersion = new( 1, 0, 0 ); + private Version? minMetalamaVersion; + private bool isMetalamaProduct; + private int graceDays = 30; + private int gracePercent = 20; + private bool isLicenseServerEligible = true; + private DateTime? graceStartTime; + + public static LicenseBuilder Default() => new(); + + public LicenseBuilder WithLicenseId( int value ) + { + this.licenseId = value; + + return this; + } + + /// + /// Sets the number of concurrent users, or null for an unlimited license. + /// + public LicenseBuilder WithUsers( int? value ) + { + this.userNumber = value; + + return this; + } + + /// + /// Gets the priority of the license, which is negative when the license is disabled. + /// + public int Priority => this.priority; + + public LicenseBuilder WithPriority( int value ) + { + this.priority = value; + + return this; + } + + public LicenseBuilder WithProduct( string value ) + { + this.product = value; + + return this; + } + + public LicenseBuilder WithLicenseType( string value ) + { + this.licenseType = value; + + return this; + } + + public LicenseBuilder WithValidTo( DateTime? value ) + { + this.validTo = value; + + return this; + } + + public LicenseBuilder WithSubscriptionEndDate( DateTime? value ) + { + this.subscriptionEndDate = value; + + return this; + } + + public LicenseBuilder WithMinPostSharpVersion( Version value ) + { + this.minPostSharpVersion = value; + + return this; + } + + /// + /// Makes this a license of a Metalama product, whose client reads the minimum Metalama version + /// and not the minimum PostSharp version. + /// + public LicenseBuilder AsMetalamaProduct( string productCode = "MetalamaProfessional" ) + { + this.isMetalamaProduct = true; + this.product = productCode; + + return this; + } + + public LicenseBuilder WithMinMetalamaVersion( Version? value ) + { + this.minMetalamaVersion = value; + + return this; + } + + public LicenseBuilder WithGraceDays( int value ) + { + this.graceDays = value; + + return this; + } + + public LicenseBuilder WithGracePercent( int value ) + { + this.gracePercent = value; + + return this; + } + + public LicenseBuilder NotLicenseServerEligible() + { + this.isLicenseServerEligible = false; + + return this; + } + + public LicenseBuilder WithGraceStartTime( DateTime? value ) + { + this.graceStartTime = value; + + return this; + } + + public LicenseInfo BuildInfo() + => new() + { + LicenseId = this.licenseId, + Product = this.product, + LicenseType = this.licenseType, + UserNumber = this.userNumber, + ValidTo = this.validTo, + SubscriptionEndDate = this.subscriptionEndDate, + MinPostSharpVersion = this.minPostSharpVersion, + MinMetalamaVersion = this.minMetalamaVersion, + IsMetalamaProduct = this.isMetalamaProduct, + GraceDays = this.graceDays, + GracePercent = this.gracePercent, + IsLicenseServerEligible = this.isLicenseServerEligible, + LicenseTypeName = this.licenseType, + ProductName = this.product + }; + + /// + /// Adds the license to the database and registers its key with the fake parser. + /// + public License AddTo( LicenseServerTestContext context ) + { + var key = $"FAKE-KEY-{this.licenseId}"; + + License license = new() + { + LicenseId = this.licenseId, + LicenseKey = key, + ProductCode = this.product, + Priority = this.priority, + CreatedOn = TestClock.Origin, + GraceStartTime = this.graceStartTime + }; + + context.LicenseParser.Register( key, this.BuildInfo() ); + context.Db.Licenses.Add( license ); + context.Db.SaveChanges(); + + return license; + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/LicenseServerApplication.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/LicenseServerApplication.cs new file mode 100644 index 0000000..0857f89 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/LicenseServerApplication.cs @@ -0,0 +1,190 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Email; +using SharpCrafters.Backstage.LicenseServer.Licensing; +using SharpCrafters.Backstage.LicenseServer.Locking; +using SharpCrafters.Backstage.LicenseServer.Options; +using SharpCrafters.Backstage.LicenseServer.Tests.Fakes; +using SharpCrafters.Common; +using System.Globalization; + +namespace SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +/// +/// Hosts the real application in memory, over the database of the test, with test doubles for the +/// license parser, the clock and the e-mail sender. Everything else is the production +/// pipeline: the routing, the model binding, the authorization and the endpoints. +/// +public sealed class LicenseServerApplication : WebApplicationFactory +{ + private readonly ITestDatabase database; + + /// + /// Creates an application over a database of its own, on the engine the run uses. The database is + /// created here, and not at the first request, because a test adds licenses before it sends its + /// first request, and the first request is what starts the host. + /// + /// + /// Creating the database is asynchronous, and a constructor cannot await. The work runs on the + /// thread pool rather than on the synchronization context of the test, where waiting for it would + /// deadlock. The alternative is an asynchronous factory, which every test class would have to + /// call from InitializeAsync. + /// + public LicenseServerApplication() + { + this.database = Task.Run( TestDatabases.CreateAsync ).GetAwaiter().GetResult(); + } + + public FakeLicenseParser LicenseParser { get; } = new(); + + public InMemoryEmailSender EmailSender { get; } = new(); + + /// + /// Gets or sets a lock that replaces the one of the engine, so that a test can force the path + /// that answers "Service overloaded.". When it stays null, the application uses the lock of its + /// database engine, as it does in production. + /// + public ILeaseLock? LeaseLock { get; set; } + + /// + /// Gets the provider of the synchronization points, which lets a test hold a request at a named + /// point in the code under test. It is registered in every test and enabled by none. + /// + public TestSynchronizationProvider Synchronization { get; } = new(); + + /// + /// Gets the guard that makes the response body refuse a synchronous write, which a test writing + /// to the response body enables. + /// + public AsyncOnlyResponseBody ResponseBody { get; } = new(); + + /// + /// Gets or sets the number of seconds a request waits for the lease lock, which a test that + /// exercises the timeout shortens. + /// + public int MutexTimeoutSeconds { get; set; } = 5; + + protected override void ConfigureWebHost( IWebHostBuilder builder ) + { + builder.UseEnvironment( "Testing" ); + + // The settings are passed with UseSetting and not with ConfigureAppConfiguration. With the + // minimal hosting model, the application reads its configuration while Program.cs runs, + // which is before the callbacks of ConfigureAppConfiguration are applied. + Dictionary settings = new() + { + ["LicenseServer:MachinesPerUser"] = "2", + ["LicenseServer:NewLeaseDays"] = "3", + ["LicenseServer:MinLeaseDays"] = "1", + ["LicenseServer:BuildServers"] = "buildagent", + ["LicenseServer:MutexTimeout"] = this.MutexTimeoutSeconds.ToString( CultureInfo.InvariantCulture ), + ["LicenseServer:DatabaseProvider"] = this.database.ProviderName, + [$"ConnectionStrings:{DatabaseRegistration.ConnectionStringName}"] = this.database.ConnectionString, + ["Smtp:Enabled"] = "false" + }; + + foreach ( var (key, value) in settings ) + { + builder.UseSetting( key, value ); + } + + builder.ConfigureServices( services => + { + // Neither the database nor its lock is replaced here. The application selects both + // from the configuration above, through the code path that a customer uses. + services.RemoveAll(); + services.AddSingleton( this.LicenseParser ); + + services.RemoveAll(); + services.AddSingleton( this.EmailSender ); + + if ( this.LeaseLock != null ) + { + services.RemoveAll(); + services.AddSingleton( this.LeaseLock ); + } + + services.AddSingleton( this.Synchronization ); + + // Windows authentication cannot be negotiated against an in-memory host. + services.AddAuthentication( TestAuthenticationHandler.SchemeName ) + .AddScheme( + TestAuthenticationHandler.SchemeName, + _ => { } ); + + services.PostConfigure( options => + { + options.DefaultAuthenticateScheme = TestAuthenticationHandler.SchemeName; + options.DefaultChallengeScheme = TestAuthenticationHandler.SchemeName; + } ); + + services.AddSingleton( this.ResponseBody ); + + services.AddLogging( logging => logging.SetMinimumLevel( LogLevel.Warning ) ); + } ); + } + + public LicenseServerDbContext CreateDbContext() => this.database.CreateContext(); + + /// + /// States that the database of this application must not serve another test. A test that modifies + /// the schema calls it, because a SQL Server run lends the same databases to one test after + /// another. + /// + public void DoNotReuseDatabase() => this.database.DoNotReuse(); + + /// + /// Registers a license both in the database and with the fake parser. + /// + public License AddLicense( LicenseBuilder builder ) + { + var info = builder.BuildInfo(); + var key = $"FAKE-KEY-{info.LicenseId}"; + + this.LicenseParser.Register( key, info ); + + using var db = this.CreateDbContext(); + + License license = new() + { + LicenseId = info.LicenseId, + LicenseKey = key, + ProductCode = info.Product, + Priority = builder.Priority, + CreatedOn = TestClock.Origin + }; + + db.Licenses.Add( license ); + db.SaveChanges(); + + return license; + } + + protected override void Dispose( bool disposing ) + { + base.Dispose( disposing ); + + if ( disposing ) + { + this.Synchronization.Dispose(); + + // The database of a SQL Server run returns to its pool here, and a SQLite database in + // memory disappears with its connection. Disposal runs on the thread pool for the same + // reason as the creation. + Task.Run( async () => await this.database.DisposeAsync() ).GetAwaiter().GetResult(); + } + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/LicenseServerTestContext.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/LicenseServerTestContext.cs new file mode 100644 index 0000000..f4cb07f --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/LicenseServerTestContext.cs @@ -0,0 +1,109 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Licensing; +using SharpCrafters.Backstage.LicenseServer.Options; +using SharpCrafters.Backstage.LicenseServer.Services; +using SharpCrafters.Backstage.LicenseServer.Tests.Fakes; + +namespace SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +/// +/// A license server wired up for a test: a real and +/// over the database of the test, with the license parser, the +/// clock and the e-mail sender replaced by test doubles. +/// +public sealed class LicenseServerTestContext : IAsyncDisposable +{ + private readonly ITestDatabase database; + private readonly LicenseServerDbContext db; + + private LicenseServerTestContext( + ITestDatabase database, + LicenseServerDbContext db, + LicenseServerOptions options ) + { + this.database = database; + this.db = db; + this.Options = options; + this.LicenseParser = new FakeLicenseParser(); + this.EmailSender = new InMemoryEmailSender(); + this.ServerVersion = new FixedServerVersion(); + + this.Repository = new LeaseRepository( + db, + Microsoft.Extensions.Options.Options.Create( options ), + this.LicenseParser ); + + this.LeaseService = new LeaseService( + this.Repository, + Microsoft.Extensions.Options.Options.Create( options ), + this.LicenseParser, + this.ServerVersion, + this.EmailSender, + NullLogger.Instance ); + } + + public LicenseServerOptions Options { get; } + + public FakeLicenseParser LicenseParser { get; } + + public InMemoryEmailSender EmailSender { get; } + + public FixedServerVersion ServerVersion { get; } + + public LeaseRepository Repository { get; } + + public LeaseService LeaseService { get; } + + public LicenseServerDbContext Db => this.db; + + /// + /// Creates a context over a database that belongs to the calling test. See + /// for the engine the run uses. + /// + public static async Task CreateAsync( Action? configure = null ) + { + LicenseServerOptions options = new() + { + MachinesPerUser = 2, + NewLeaseDays = 3, + MinLeaseDays = 1, + GracePeriodWarningDays = 1, + GracePeriodWarningEmailTo = "admin@example.com", + DeniedRequestEmailTo = "admin@example.com" + }; + + configure?.Invoke( options ); + + var database = await TestDatabases.CreateAsync(); + + return new LicenseServerTestContext( database, database.CreateContext(), options ); + } + + /// + /// Returns a repository over a new unit of work on the same database. Use it to assert on the + /// rows that were saved, and not on the state of the change tracker. + /// + public LeaseRepository CreateFreshRepository() + => new( + this.database.CreateContext(), + Microsoft.Extensions.Options.Options.Create( this.Options ), + this.LicenseParser ); + + public LicenseServerDbContext CreateFreshContext() => this.database.CreateContext(); + + /// + /// States that the database of this context must not serve another test. A test that modifies the + /// schema calls it, because a SQL Server run lends the same databases to one test after another. + /// + public void DoNotReuseDatabase() => this.database.DoNotReuse(); + + public async ValueTask DisposeAsync() + { + await this.db.DisposeAsync(); + await this.database.DisposeAsync(); + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/PostgreSqlDatabasePool.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/PostgreSqlDatabasePool.cs new file mode 100644 index 0000000..250fbf9 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/PostgreSqlDatabasePool.cs @@ -0,0 +1,228 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using SharpCrafters.Backstage.LicenseServer.Data; + +namespace SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +/// +/// The databases of one PostgreSQL server, lent to the tests one at a time. +/// +[SuppressMessage( + "Microsoft.Design", + "CA1001", + Justification = "A pool lives as long as the run, in a static dictionary, and nothing disposes it. " + + "Its two semaphores are released with the process." )] +internal sealed class PostgreSqlDatabasePool +{ + /// + /// The prefix of every database this pool creates. A run drops the databases that carry it before + /// it creates its own, so a run that was interrupted leaves nothing behind for long. + /// + private const string prefix = "licenseserver_test_"; + + /// + /// The number of databases the pool lends at the same time, which is also the number of tests + /// that touch the server at the same time. A test that finds no free database waits for one. + /// + private const int capacity = 8; + + private static readonly ConcurrentDictionary pools = new( StringComparer.Ordinal ); + + private readonly string serverConnectionString; + private readonly ConcurrentBag available = []; + private readonly SemaphoreSlim lease = new( capacity, capacity ); + private readonly SemaphoreSlim initialization = new( 1, 1 ); + private bool initialized; + + private PostgreSqlDatabasePool( string serverConnectionString ) + { + NpgsqlConnectionStringBuilder builder = new( serverConnectionString ); + + // A connection string that names no database connects to the maintenance database, which is + // where CREATE DATABASE and DROP DATABASE run. + if ( string.IsNullOrEmpty( builder.Database ) ) + { + builder.Database = "postgres"; + } + + this.serverConnectionString = builder.ToString(); + } + + public static PostgreSqlDatabasePool Get( string serverConnectionString ) + => pools.GetOrAdd( serverConnectionString, connectionString => new PostgreSqlDatabasePool( connectionString ) ); + + public string GetConnectionString( string databaseName ) + => new NpgsqlConnectionStringBuilder( this.serverConnectionString ) { Database = databaseName }.ToString(); + + public async Task RentAsync() + { + await this.DropLeftoverDatabasesAsync(); + await this.lease.WaitAsync(); + + try + { + if ( this.available.TryTake( out var databaseName ) ) + { + await this.ClearAsync( databaseName ); + + return databaseName; + } + + databaseName = prefix + Guid.NewGuid().ToString( "N" ); + + await ExecuteAsync( this.serverConnectionString, $"CREATE DATABASE \"{databaseName}\"" ); + await this.CreateSchemaAsync( databaseName ); + + return databaseName; + } + catch + { + this.lease.Release(); + + throw; + } + } + + /// + /// Takes a database back. A database whose schema a test modified is dropped instead of kept, and + /// the next test that finds the pool empty creates one. + /// + public async ValueTask ReturnAsync( string databaseName, bool reusable ) + { + try + { + if ( reusable ) + { + this.available.Add( databaseName ); + } + else + { + await this.DropAsync( databaseName ); + } + } + finally + { + this.lease.Release(); + } + } + + /// + /// Empties the two tables and restarts the identity of the leases, so that a database that was + /// used by an earlier test looks like a database that was just created. TRUNCATE states both, and + /// it states them for a table a foreign key points to, which is why it names both tables. + /// + private async Task ClearAsync( string databaseName ) + => await ExecuteAsync( + this.GetConnectionString( databaseName ), + "TRUNCATE TABLE \"Leases\", \"Licenses\" RESTART IDENTITY" ); + + private async Task CreateSchemaAsync( string databaseName ) + { + var script = await File.ReadAllTextAsync( LocateCreateTablesScript() ); + + // PostgreSQL has no batch separator: the whole script is one command. + await ExecuteAsync( this.GetConnectionString( databaseName ), script ); + } + + /// + /// Drops the databases that an interrupted run left behind. It runs once per pool, before the + /// first database is created. + /// + private async Task DropLeftoverDatabasesAsync() + { + if ( this.initialized ) + { + return; + } + + await this.initialization.WaitAsync(); + + try + { + if ( this.initialized ) + { + return; + } + + List leftovers = []; + + await using ( NpgsqlConnection connection = new( this.serverConnectionString ) ) + { + await connection.OpenAsync(); + + await using var query = connection.CreateCommand(); + query.CommandText = $"SELECT datname FROM pg_database WHERE datname LIKE '{prefix}%'"; + + await using var reader = await query.ExecuteReaderAsync(); + + while ( await reader.ReadAsync() ) + { + leftovers.Add( reader.GetString( 0 ) ); + } + } + + foreach ( var leftover in leftovers ) + { + await this.DropAsync( leftover ); + } + + this.initialized = true; + } + finally + { + this.initialization.Release(); + } + } + + /// + /// Drops one database. A test leaves its connections open in the pool of the client, and + /// PostgreSQL refuses to drop a database that a session is connected to, so the connections of + /// this client are closed first and the remaining sessions are ended by the server. + /// + private async Task DropAsync( string databaseName ) + { + NpgsqlConnection.ClearPool( new NpgsqlConnection( this.GetConnectionString( databaseName ) ) ); + + await ExecuteAsync( + this.serverConnectionString, + $""" + SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = '{databaseName}' AND pid <> pg_backend_pid(); + """ ); + + await ExecuteAsync( this.serverConnectionString, $"DROP DATABASE IF EXISTS \"{databaseName}\"" ); + } + + private static async Task ExecuteAsync( string connectionString, string sql ) + { + await using NpgsqlConnection connection = new( connectionString ); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = sql; + + await command.ExecuteNonQueryAsync(); + } + + /// + /// Finds CreateTables.PostgreSql.sql next to the test assembly, where the web project + /// copies it. + /// + private static string LocateCreateTablesScript() + { + var path = Path.Combine( AppContext.BaseDirectory, "Database", "CreateTables.PostgreSql.sql" ); + + if ( !File.Exists( path ) ) + { + throw new FileNotFoundException( + $"The schema script was not found at '{path}'. The test project copies it from the web project.", + path ); + } + + return path; + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/PostgreSqlTestDatabase.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/PostgreSqlTestDatabase.cs new file mode 100644 index 0000000..f9f58d9 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/PostgreSqlTestDatabase.cs @@ -0,0 +1,75 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using SharpCrafters.Backstage.LicenseServer.Data; + +namespace SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +/// +/// A database of the PostgreSQL server that the run was given, created from +/// CreateTables.PostgreSql.sql. +/// +/// +/// +/// The schema comes from the script that an administrator runs, and not from the EF Core model, so +/// this run also proves that the model and the script agree. +/// +/// +/// The databases are pooled, for the reason the SQL Server databases are pooled: a test takes one, +/// the tables are emptied, and the database returns to the pool when the test ends. +/// +/// +public sealed class PostgreSqlTestDatabase : ITestDatabase +{ + private readonly PostgreSqlDatabasePool pool; + private readonly string databaseName; + private int returned; + private volatile bool reusable = true; + + private PostgreSqlTestDatabase( PostgreSqlDatabasePool pool, string databaseName, string connectionString ) + { + this.pool = pool; + this.databaseName = databaseName; + this.ConnectionString = connectionString; + } + + public string ProviderName => "PostgreSql"; + + public string ConnectionString { get; } + + public static async Task CreateAsync( string serverConnectionString ) + { + var pool = PostgreSqlDatabasePool.Get( serverConnectionString ); + var databaseName = await pool.RentAsync(); + + return new PostgreSqlTestDatabase( pool, databaseName, pool.GetConnectionString( databaseName ) ); + } + + public LicenseServerDbContext CreateContext() + => new( + new DbContextOptionsBuilder() + .UseNpgsql( this.ConnectionString ) + .EnableSensitiveDataLogging() + .Options ); + + /// + /// Marks this database for deletion instead of reuse, which a test that modifies the schema calls. + /// + public void DoNotReuse() => this.reusable = false; + + /// + /// Returns the database to the pool, once. A host that implements both + /// and can dispose its database twice, and the pool would then + /// lend the same database to two tests, which clear it under each other. + /// + public async ValueTask DisposeAsync() + { + if ( Interlocked.Exchange( ref this.returned, 1 ) == 0 ) + { + await this.pool.ReturnAsync( this.databaseName, this.reusable ); + } + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/SqlServerDatabasePool.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/SqlServerDatabasePool.cs new file mode 100644 index 0000000..182b704 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/SqlServerDatabasePool.cs @@ -0,0 +1,247 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using SharpCrafters.Backstage.LicenseServer.Data; + +namespace SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +/// +/// The databases of one SQL Server, lent to the tests one at a time. +/// +[SuppressMessage( + "Microsoft.Design", + "CA1001", + Justification = "A pool lives as long as the run, in a static dictionary, and nothing disposes it. " + + "Its two semaphores are released with the process." )] +internal sealed class SqlServerDatabasePool +{ + /// + /// The prefix of every database this pool creates. A run drops the databases that carry it before + /// it creates its own, so a run that was interrupted leaves nothing behind for long. + /// + private const string prefix = "licenseserver_test_"; + + /// + /// The number of databases the pool lends at the same time, which is also the number of tests + /// that touch the server at the same time. A test that finds no free database waits for one. + /// Without this bound, every test of the suite would create a database of its own at once, and + /// the server would refuse the connections. + /// + private const int capacity = 8; + + private static readonly ConcurrentDictionary pools = new( StringComparer.Ordinal ); + + private readonly string serverConnectionString; + private readonly ConcurrentBag available = []; + private readonly SemaphoreSlim lease = new( capacity, capacity ); + private readonly SemaphoreSlim initialization = new( 1, 1 ); + private bool initialized; + + private SqlServerDatabasePool( string serverConnectionString ) + { + SqlConnectionStringBuilder builder = new( serverConnectionString ); + + // Creating a database and connecting to it while the suite runs takes longer than the + // fifteen seconds of the default. + if ( builder.ConnectTimeout < 60 ) + { + builder.ConnectTimeout = 60; + } + + this.serverConnectionString = builder.ToString(); + } + + public static SqlServerDatabasePool Get( string serverConnectionString ) + => pools.GetOrAdd( serverConnectionString, connectionString => new SqlServerDatabasePool( connectionString ) ); + + public string GetConnectionString( string databaseName ) + => new SqlConnectionStringBuilder( this.serverConnectionString ) { InitialCatalog = databaseName }.ToString(); + + public async Task RentAsync() + { + await this.DropLeftoverDatabasesAsync(); + await this.lease.WaitAsync(); + + try + { + if ( this.available.TryTake( out var databaseName ) ) + { + await this.ClearAsync( databaseName ); + + return databaseName; + } + + databaseName = prefix + Guid.NewGuid().ToString( "N" ); + + await ExecuteAsync( this.serverConnectionString, $"CREATE DATABASE [{databaseName}]" ); + await this.CreateSchemaAsync( databaseName ); + + return databaseName; + } + catch + { + this.lease.Release(); + + throw; + } + } + + /// + /// Takes a database back. A database whose schema a test modified is dropped instead of kept, and + /// the next test that finds the pool empty creates one. + /// + public async ValueTask ReturnAsync( string databaseName, bool reusable ) + { + try + { + if ( reusable ) + { + this.available.Add( databaseName ); + } + else + { + await this.DropAsync( databaseName ); + } + } + finally + { + this.lease.Release(); + } + } + + /// + /// Empties the two tables and restarts the identity of the leases, so that a database that was + /// used by an earlier test looks like a database that was just created. The first lease of the + /// next test therefore always receives the identifier 1. + /// + /// + /// The reseed is conditional. On a table that has received a row since it was created, the next + /// row takes the new value plus the increment, which is 1. On a table that has received no row, + /// the next row takes the new value itself, which is 0, and a test that asserts on the identifier + /// of a lease then fails. The column last_value is null until the first row is inserted, + /// and that case needs no reseed, because the next row already takes the seed of the column. + /// + private async Task ClearAsync( string databaseName ) + => await ExecuteAsync( + this.GetConnectionString( databaseName ), + """ + DELETE FROM [dbo].[Leases]; + DELETE FROM [dbo].[Licenses]; + + IF EXISTS ( + SELECT 1 FROM sys.identity_columns + WHERE object_id = OBJECT_ID('[dbo].[Leases]') AND last_value IS NOT NULL ) + BEGIN + DBCC CHECKIDENT ('[dbo].[Leases]', RESEED, 0) WITH NO_INFOMSGS; + END + """ ); + + private async Task CreateSchemaAsync( string databaseName ) + { + var script = await File.ReadAllTextAsync( LocateCreateTablesScript() ); + + // sqlcmd separates the batches of a script with GO, which is not a statement of Transact-SQL. + foreach ( var batch in script.Split( + "\nGO", + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries ) ) + { + if ( batch.Length > 0 ) + { + await ExecuteAsync( this.GetConnectionString( databaseName ), batch ); + } + } + } + + /// + /// Drops the databases that an interrupted run left behind. It runs once per pool, before the + /// first database is created. + /// + private async Task DropLeftoverDatabasesAsync() + { + if ( this.initialized ) + { + return; + } + + await this.initialization.WaitAsync(); + + try + { + if ( this.initialized ) + { + return; + } + + await using SqlConnection connection = new( this.serverConnectionString ); + await connection.OpenAsync(); + + List leftovers = []; + + await using ( var query = connection.CreateCommand() ) + { + query.CommandText = $"SELECT name FROM sys.databases WHERE name LIKE '{prefix}%'"; + + await using var reader = await query.ExecuteReaderAsync(); + + while ( await reader.ReadAsync() ) + { + leftovers.Add( reader.GetString( 0 ) ); + } + } + + foreach ( var leftover in leftovers ) + { + await this.DropAsync( leftover ); + } + + this.initialized = true; + } + finally + { + this.initialization.Release(); + } + } + + /// + /// Drops one database. A test leaves its connections open in the pool of the client, so the + /// server closes them before it drops the database. + /// + private async Task DropAsync( string databaseName ) + => await ExecuteAsync( + this.serverConnectionString, + $""" + ALTER DATABASE [{databaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE [{databaseName}]; + """ ); + + private static async Task ExecuteAsync( string connectionString, string sql ) + { + await using SqlConnection connection = new( connectionString ); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = sql; + + await command.ExecuteNonQueryAsync(); + } + + /// + /// Finds CreateTables.sql next to the test assembly, where the web project copies it. + /// + private static string LocateCreateTablesScript() + { + var path = Path.Combine( AppContext.BaseDirectory, "Database", "CreateTables.sql" ); + + if ( !File.Exists( path ) ) + { + throw new FileNotFoundException( + $"The schema script was not found at '{path}'. The test project copies it from the web project.", + path ); + } + + return path; + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/SqlServerTestDatabase.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/SqlServerTestDatabase.cs new file mode 100644 index 0000000..6445a02 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/SqlServerTestDatabase.cs @@ -0,0 +1,78 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using SharpCrafters.Backstage.LicenseServer.Data; + +namespace SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +/// +/// A database of the SQL Server that the run was given, created from CreateTables.sql. +/// +/// +/// +/// The schema comes from the script that an administrator runs, and not from the EF Core model, so +/// this run also proves that the model and the script agree. +/// +/// +/// Creating a database costs about half a second, and the suite has several hundred tests, so the +/// databases are pooled. A test takes one, the tables are emptied, and the database returns to the +/// pool when the test ends. The pool therefore holds as many databases as the number of tests that +/// run at the same time. +/// +/// +public sealed class SqlServerTestDatabase : ITestDatabase +{ + private readonly SqlServerDatabasePool pool; + private readonly string databaseName; + private int returned; + private volatile bool reusable = true; + + private SqlServerTestDatabase( SqlServerDatabasePool pool, string databaseName, string connectionString ) + { + this.pool = pool; + this.databaseName = databaseName; + this.ConnectionString = connectionString; + } + + public string ProviderName => "SqlServer"; + + public string ConnectionString { get; } + + public static async Task CreateAsync( string serverConnectionString ) + { + var pool = SqlServerDatabasePool.Get( serverConnectionString ); + var databaseName = await pool.RentAsync(); + + return new SqlServerTestDatabase( pool, databaseName, pool.GetConnectionString( databaseName ) ); + } + + public LicenseServerDbContext CreateContext() + => new( + new DbContextOptionsBuilder() + .UseSqlServer( this.ConnectionString ) + .EnableSensitiveDataLogging() + .Options ); + + /// + /// Marks this database for deletion instead of reuse. The pool empties the tables of a database + /// before it lends it again, and a test that dropped a table would leave the next test without + /// one. + /// + public void DoNotReuse() => this.reusable = false; + + /// + /// Returns the database to the pool, once. A host that implements both + /// and can dispose its database twice, and the pool would then + /// lend the same database to two tests, which clear it under each other. + /// + public async ValueTask DisposeAsync() + { + if ( Interlocked.Exchange( ref this.returned, 1 ) == 0 ) + { + await this.pool.ReturnAsync( this.databaseName, this.reusable ); + } + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/SqliteTestDatabase.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/SqliteTestDatabase.cs new file mode 100644 index 0000000..f0e056a --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/SqliteTestDatabase.cs @@ -0,0 +1,61 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using SharpCrafters.Backstage.LicenseServer.Data; + +namespace SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +/// +/// A SQLite database held in memory and created from the EF Core model. It behaves like a relational +/// database: it enforces the foreign keys, it runs transactions, and it translates the queries. +/// +/// +/// A database held in memory exists as long as a connection to it is open, so this class holds one +/// connection during its whole lifetime. The cache is shared, so the code under test opens its own +/// connections to the same database, which is what a lock between two connections requires. +/// +public sealed class SqliteTestDatabase : ITestDatabase +{ + private readonly SqliteConnection connection; + + private SqliteTestDatabase( SqliteConnection connection, string connectionString ) + { + this.connection = connection; + this.ConnectionString = connectionString; + } + + public string ProviderName => "Sqlite"; + + public string ConnectionString { get; } + + public static async Task CreateAsync() + { + var connectionString = $"DataSource=licenseserver-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; + + SqliteConnection connection = new( connectionString ); + await connection.OpenAsync(); + + SqliteTestDatabase database = new( connection, connectionString ); + + await using var context = database.CreateContext(); + await context.Database.EnsureCreatedAsync(); + + return database; + } + + public LicenseServerDbContext CreateContext() + => new( + new DbContextOptionsBuilder() + .UseSqlite( this.ConnectionString ) + .EnableSensitiveDataLogging() + .Options ); + + /// + /// Does nothing. This database belongs to one test and disappears with its connection, so no + /// other test can see the schema that the test modified. + /// + public void DoNotReuse() { } + + public async ValueTask DisposeAsync() => await this.connection.DisposeAsync(); +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/TestAuthenticationHandler.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/TestAuthenticationHandler.cs new file mode 100644 index 0000000..345d59c --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/TestAuthenticationHandler.cs @@ -0,0 +1,57 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Email; +using SharpCrafters.Backstage.LicenseServer.Licensing; +using SharpCrafters.Backstage.LicenseServer.Locking; +using SharpCrafters.Backstage.LicenseServer.Options; +using SharpCrafters.Backstage.LicenseServer.Tests.Fakes; +using SharpCrafters.Common; +using System.Globalization; + +namespace SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +/// +/// Authenticates every request as the same Windows-style identity, so the tests exercise the +/// authenticated path without a domain controller. +/// +public sealed class TestAuthenticationHandler : AuthenticationHandler +{ + public TestAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder ) : base( options, logger, encoder ) { } + + public const string SchemeName = "Test"; + + /// + /// The header a test sets to be served anonymously instead. + /// + public const string AnonymousHeader = "X-Test-Anonymous"; + + protected override Task HandleAuthenticateAsync() + { + if ( this.Request.Headers.ContainsKey( AnonymousHeader ) ) + { + return Task.FromResult( AuthenticateResult.NoResult() ); + } + + ClaimsIdentity identity = new( + [new Claim( ClaimTypes.Name, "DOMAIN\\tester" )], + SchemeName ); + + return Task.FromResult( AuthenticateResult.Success( new AuthenticationTicket( new ClaimsPrincipal( identity ), SchemeName ) ) ); + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/TestClock.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/TestClock.cs new file mode 100644 index 0000000..bf5bc04 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/TestClock.cs @@ -0,0 +1,25 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using SharpCrafters.Backstage.LicenseServer.Licensing; + +namespace SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +/// +/// Fixed points in time used by the tests. +/// +public static class TestClock +{ + /// + /// A Monday, at a whole second. + /// + /// + /// Monday exercises the branch of the usage graph that labels the weekdays. Whole seconds keep + /// the tests independent of the rounding of the SQL type datetime, which is 1/300 of a + /// second. + /// + public static readonly DateTime Origin = new( 2026, 1, 5, 9, 0, 0, DateTimeKind.Utc ); + + public static DateTime Days( double days ) => Origin.AddDays( days ); + + public static DateTime Hours( double hours ) => Origin.AddHours( hours ); +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/TestDatabases.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/TestDatabases.cs new file mode 100644 index 0000000..d899c11 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/TestDatabases.cs @@ -0,0 +1,81 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using SharpCrafters.Backstage.LicenseServer.Data; + +namespace SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +/// +/// Creates the database of a test on the engine the run was started with. +/// +/// +/// +/// The engine is a property of the run and not of a test, so the same tests run against all three. +/// SQLite is the default, because it needs no server and keeps the development loop short. The run +/// uses SQL Server when LICENSESERVER_TEST_SQLSERVER holds a connection string, and PostgreSQL +/// when LICENSESERVER_TEST_POSTGRESQL holds one. That is how the continuous integration build +/// runs the suite again against each engine that customers use. +/// +/// +/// A Docker test starts either server in a container and sets the variable. See tests/docker and +/// the section "Running the tests" of README.md. +/// +/// +public static class TestDatabases +{ + /// + /// The name of the environment variable that holds the connection string of the SQL Server used + /// by the tests. The connection string names no database: each test receives one of its own. + /// + public const string SqlServerVariable = "LICENSESERVER_TEST_SQLSERVER"; + + /// + /// The name of the environment variable that holds the connection string of the PostgreSQL server + /// used by the tests. The connection string names no database: each test receives one of its own. + /// + public const string PostgreSqlVariable = "LICENSESERVER_TEST_POSTGRESQL"; + + /// + /// Gets the connection string of the SQL Server of the run, or null when the run uses + /// another engine. + /// + public static string? SqlServerConnectionString { get; } = Read( SqlServerVariable ); + + /// + /// Gets the connection string of the PostgreSQL server of the run, or null when the run + /// uses another engine. + /// + public static string? PostgreSqlConnectionString { get; } = Read( PostgreSqlVariable ); + + /// + /// Gets a value indicating whether this run uses SQL Server. + /// + public static bool UsesSqlServer => SqlServerConnectionString != null; + + /// + /// Gets a value indicating whether this run uses PostgreSQL. + /// + public static bool UsesPostgreSql => PostgreSqlConnectionString != null; + + /// + /// Gets a value indicating whether this run uses SQLite, which is the engine of a run that was + /// given no other one. + /// + public static bool UsesSqlite => !UsesSqlServer && !UsesPostgreSql; + + public static async Task CreateAsync() + { + if ( SqlServerConnectionString != null ) + { + return await SqlServerTestDatabase.CreateAsync( SqlServerConnectionString ); + } + + if ( PostgreSqlConnectionString != null ) + { + return await PostgreSqlTestDatabase.CreateAsync( PostgreSqlConnectionString ); + } + + return await SqliteTestDatabase.CreateAsync(); + } + + private static string? Read( string variable ) => Environment.GetEnvironmentVariable( variable ) is { Length: > 0 } value ? value : null; +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/TestLicenseKeys.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/TestLicenseKeys.cs new file mode 100644 index 0000000..3a583bd --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/Infrastructure/TestLicenseKeys.cs @@ -0,0 +1,148 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Security.Cryptography; +using SharpCrafters.Backstage.Licensing; +using SharpCrafters.Backstage.Licensing.Licenses; +using SharpCrafters.Backstage.Testing; + +namespace SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +/// +/// Builds real license keys, so that the parser can be tested against the format it meets in +/// production. +/// +/// +/// +/// The test licensing authority of SharpCrafters.Backstage signs the keys, and +/// gives access to it. That authority generates its key pair in +/// the current process, so a license key signed here is valid in this process and in no other one. +/// A public repository under the MIT license can therefore test the real code path of the signature. +/// These tests do not cover the production authority, whose public keys are constants of +/// SharpCrafters.Backstage, and which the tests of that package cover. +/// +/// +/// holds the authority object that signs the keys, so a test verifies a key +/// against the authority that signed it, and not against another instance of that authority. +/// +/// +public static class TestLicenseKeys +{ + private static readonly TestLicenseKeyProvider provider = new(); + + /// + /// The identifier of the key of the test authority. It is a constant of SharpCrafters.Backstage, + /// and that constant is internal, so this class reads the identifier from a license key that the + /// authority signed. + /// + private static readonly byte authorityKeyId; + + /// + /// Gets the authority that verifies the keys this class signs. A test passes it to the parser + /// that it exercises. + /// + public static ILicensingAuthorityProvider Authority { get; } + + /// + /// Gets the license keys that SharpCrafters.Backstage creates for its own tests. There is one + /// key per product and per type of license. + /// + public static TestLicenseKeyProvider Keys => provider; + + static TestLicenseKeys() + { + var probe = new LicenseKeyDataBuilder { LicenseId = 1, LicenseType = LicenseType.Business } + .SignAndSerialize( provider.Authority ); + + LicenseKeyData.TryDeserialize( probe, out var data, out _ ); + authorityKeyId = data!.SignatureKeyId!.Value; + + Authority = new TestAuthorityProvider( provider.Authority, authorityKeyId ); + } + + /// + /// Creates a builder of a license key that the license server accepts. The caller modifies the + /// builder before it serializes the key. + /// + public static LicenseKeyDataBuilder Builder( + int licenseId = 1, + LicenseType licenseType = LicenseType.Business, + LicenseProduct product = LicenseProduct.PostSharpUltimate ) + => new() + { + LicenseId = licenseId, + LicenseType = licenseType, + Product = product, + UserNumber = 5, + LicenseServerEligible = true + }; + + /// + /// Signs and serializes a license key with the test authority. + /// + public static string Sign( this LicenseKeyDataBuilder builder ) => builder.SignAndSerialize( provider.Authority ); + + /// + /// Serializes a license key without signing it. Only the types of license that require no + /// signature can be parsed in this form. + /// + public static string Unsigned( this LicenseKeyDataBuilder builder ) => builder.Serialize(); + + /// + /// Signs a license key with another key that carries the identifier of the test authority. The + /// result is a forgery: the parser reads the identifier, finds the real key, and the signature + /// does not verify against it. + /// + public static string SignWithAForgedKey( this LicenseKeyDataBuilder builder ) => builder.SignAndSerialize( CreateStandaloneAuthority( authorityKeyId ) ); + + /// + /// Signs a license key with an authority that the parser does not know, so that the identifier of + /// the signature matches no key the parser holds. + /// + /// + /// The identifier differs from the identifiers of the production keys and from the identifiers of + /// the test keys of Backstage. + /// + public static string SignWithAnUnknownAuthority( this LicenseKeyDataBuilder builder ) => builder.SignAndSerialize( CreateStandaloneAuthority( 200 ) ); + + private static LicensingAuthority CreateStandaloneAuthority( byte keyId ) + { + using var key = ECDsa.Create( ECCurve.NamedCurves.nistP256 ); + var parameters = key.ExportParameters( true ); + + var xml = "nistP256" + + $"{Convert.ToBase64String( parameters.Q.X! )}" + + $"{Convert.ToBase64String( parameters.Q.Y! )}" + + $"{Convert.ToBase64String( parameters.D! )}" + + ""; + + return new ExplicitLicensingAuthorityProvider( ( keyId, xml ) ).GetAuthority( keyId ); + } + + /// + /// Returns the authority that signed the keys, for the identifier that the key of that authority + /// carries. + /// + /// + /// cannot do this, because it builds an + /// authority from the XML representation of a key, and the key of the test authority is generated + /// in the process instead of being written in the code. + /// + private sealed class TestAuthorityProvider : ILicensingAuthorityProvider + { + private readonly LicensingAuthority authority; + private readonly byte keyId; + + public TestAuthorityProvider( LicensingAuthority authority, byte keyId ) + { + this.authority = authority; + this.keyId = keyId; + } + + public IEnumerable KeyIds => [this.keyId]; + + public LicensingAuthority GetAuthority( byte id ) + => id == this.keyId + ? this.authority + : throw new KeyNotFoundException( $"There is no test licensing this.authority key of identifier {id}." ); + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/LeaseAllocationTests.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/LeaseAllocationTests.cs new file mode 100644 index 0000000..b9351c6 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/LeaseAllocationTests.cs @@ -0,0 +1,311 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.EntityFrameworkCore; +using SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +namespace SharpCrafters.Backstage.LicenseServer.Tests; + +/// +/// The rules that decide whether a developer receives a license. The server reuses the lease the +/// developer holds, then grants a free seat, then grants a seat of the grace period, and denies the +/// request when none of the three applies. +/// +public sealed class LeaseAllocationTests +{ + private static readonly Version currentVersion = new( 2025, 1, 0 ); + + private static Task RequestAsync( + LicenseServerTestContext context, + License[] licenses, + string user = "alice", + string machine = "desktop-1", + DateTime? now = null, + Dictionary? errors = null ) + => context.LeaseService.GetLeaseAsync( + currentVersion, + null, + machine, + user, + user, + now ?? TestClock.Origin, + errors ?? [], + licenses ); + + [Fact] + public async Task GetLease_NoExistingLease_GrantsANewOne() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().WithUsers( 5 ).AddTo( context ); + + var lease = await RequestAsync( context, [license] ); + + Assert.NotNull( lease ); + Assert.Equal( "alice", lease.UserName ); + Assert.Equal( "desktop-1", lease.Machine ); + Assert.Equal( TestClock.Origin, lease.StartTime ); + Assert.Equal( TestClock.Days( 3 ), lease.EndTime ); + Assert.False( lease.Grace ); + } + + [Fact] + public async Task GetLease_GoodExistingLease_IsReusedWithoutInsertingARow() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + var existing = LeaseBuilder.For( license ).From( TestClock.Origin ).Lasting( 3 ).AddTo( context ); + + var lease = await RequestAsync( context, [license], now: TestClock.Days( 1 ) ); + + Assert.Equal( existing.LeaseId, lease!.LeaseId ); + Assert.DoesNotContain( context.Db.ChangeTracker.Entries(), e => e.State == EntityState.Added ); + } + + [Fact] + public async Task GetLease_LeaseNearingExpiry_IsProlonged() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + var existing = LeaseBuilder.For( license ).From( TestClock.Origin ).Lasting( 3 ).AddTo( context ); + + // Within MinLeaseDays of the end, so the client is told to renew. + var lease = await RequestAsync( context, [license], now: TestClock.Days( 2.5 ) ); + + Assert.NotNull( lease ); + Assert.NotEqual( existing.LeaseId, lease.LeaseId ); + Assert.Equal( existing.LeaseId, lease.OverwrittenLeaseId ); + Assert.Equal( existing.StartTime, lease.StartTime ); + Assert.Equal( TestClock.Days( 5.5 ), lease.EndTime ); + } + + [Fact] + public async Task GetLease_SecondMachineForTheSameUser_DoesNotConsumeASeat() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().WithUsers( 1 ).AddTo( context ); + LeaseBuilder.For( license ).User( "alice" ).Machine( "desktop-1" ).AddTo( context ); + + var lease = await RequestAsync( context, [license], machine: "laptop-1", now: TestClock.Days( 1 ) ); + await context.Repository.SaveChangesAsync(); + + Assert.NotNull( lease ); + Assert.Equal( 1, context.Repository.GetActiveSeats( license.LicenseId, TestClock.Days( 1 ) ) ); + } + + [Fact] + public async Task GetLease_ThirdMachineOnAFullLicense_FallsBackToGrace() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().WithUsers( 1 ).AddTo( context ); + LeaseBuilder.For( license ).User( "alice" ).Machine( "desktop-1" ).AddTo( context ); + LeaseBuilder.For( license ).User( "alice" ).Machine( "laptop-1" ).AddTo( context ); + + // A third machine rounds up to a second seat, which this one-seat license does not have. + var lease = await RequestAsync( context, [license], machine: "desktop-2", now: TestClock.Days( 1 ) ); + + Assert.NotNull( lease ); + Assert.True( lease.Grace ); + } + + [Fact] + public async Task GetLease_CapacityAvailable_DoesNotUseGrace() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().WithUsers( 5 ).AddTo( context ); + LeaseBuilder.For( license ).User( "bob" ).AddTo( context ); + + var lease = await RequestAsync( context, [license], now: TestClock.Days( 1 ) ); + + Assert.False( lease!.Grace ); + Assert.Null( license.GraceStartTime ); + } + + [Fact] + public async Task GetLease_CapacityExhausted_StartsTheGracePeriodAndWarns() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().WithUsers( 1 ).AddTo( context ); + LeaseBuilder.For( license ).User( "bob" ).AddTo( context ); + + var lease = await RequestAsync( context, [license], now: TestClock.Days( 1 ) ); + + Assert.NotNull( lease ); + Assert.True( lease.Grace ); + Assert.Equal( TestClock.Days( 1 ), license.GraceStartTime ); + + var warning = Assert.Single( context.EmailSender.WithSubject( "WARNING" ) ); + Assert.Equal( "admin@example.com", warning.To ); + Assert.Contains( "capacity of 1 concurrent user", warning.Body, StringComparison.Ordinal ); + } + + [Fact] + public async Task GetLease_WithinGraceLimit_IsGranted() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + + // 10 seats, 20 percent grace, so up to 12 are tolerated. + var license = LicenseBuilder.Default() + .WithUsers( 10 ) + .WithGracePercent( 20 ) + .WithGraceStartTime( TestClock.Origin ) + .AddTo( context ); + + for ( var i = 0; i < 11; i++ ) + { + LeaseBuilder.For( license ).User( $"user{i}" ).AddTo( context ); + } + + var lease = await RequestAsync( context, [license], user: "newcomer", now: TestClock.Days( 1 ) ); + + Assert.NotNull( lease ); + Assert.True( lease.Grace ); + } + + [Fact] + public async Task GetLease_AtTheGraceLimit_IsDenied() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + + var license = LicenseBuilder.Default() + .WithUsers( 10 ) + .WithGracePercent( 20 ) + .WithGraceStartTime( TestClock.Origin ) + .AddTo( context ); + + for ( var i = 0; i < 12; i++ ) + { + LeaseBuilder.For( license ).User( $"user{i}" ).AddTo( context ); + } + + var lease = await RequestAsync( context, [license], user: "newcomer", now: TestClock.Days( 1 ) ); + + Assert.Null( lease ); + } + + [Fact] + public async Task GetLease_GracePeriodOver_IsDenied() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + + var license = LicenseBuilder.Default() + .WithUsers( 1 ) + .WithGraceDays( 30 ) + .WithGraceStartTime( TestClock.Origin ) + .AddTo( context ); + + LeaseBuilder.For( license ).User( "bob" ).From( TestClock.Days( 40 ) ).Lasting( 3 ).AddTo( context ); + + var lease = await RequestAsync( context, [license], now: TestClock.Days( 41 ) ); + + Assert.Null( lease ); + } + + [Fact] + public async Task GetLease_GraceLease_EndsWhenTheGracePeriodEnds() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + + var license = LicenseBuilder.Default() + .WithUsers( 1 ) + .WithGraceDays( 30 ) + .WithGraceStartTime( TestClock.Origin ) + .AddTo( context ); + + LeaseBuilder.For( license ).User( "bob" ).From( TestClock.Days( 28 ) ).Lasting( 5 ).AddTo( context ); + + var lease = await RequestAsync( context, [license], now: TestClock.Days( 29 ) ); + + // A three-day lease would run past the end of the grace period, so it is cut short. + Assert.NotNull( lease ); + Assert.Equal( TestClock.Days( 30 ), lease.EndTime ); + } + + [Fact] + public async Task GetLease_DeniedRequest_NotifiesTheAdministrator() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + + var license = LicenseBuilder.Default() + .WithUsers( 1 ) + .WithGracePercent( 0 ) + .WithGraceStartTime( TestClock.Origin.AddDays( -100 ) ) + .WithGraceDays( 1 ) + .AddTo( context ); + + LeaseBuilder.For( license ).User( "bob" ).AddTo( context ); + + var lease = await RequestAsync( context, [license], now: TestClock.Days( 1 ) ); + + Assert.Null( lease ); + var denial = Assert.Single( context.EmailSender.WithSubject( "denied" ) ); + Assert.Contains( "alice", denial.Body, StringComparison.Ordinal ); + Assert.Contains( "desktop-1", denial.Body, StringComparison.Ordinal ); + } + + [Fact] + public async Task GetLease_LeaseEndClampedByLicenseExpiry() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().WithValidTo( TestClock.Days( 1 ) ).AddTo( context ); + + var lease = await RequestAsync( context, [license] ); + + Assert.NotNull( lease ); + Assert.Equal( TestClock.Days( 1 ), lease.EndTime ); + } + + [Fact] + public async Task GetLease_LicenseAlreadyExpired_IsDenied() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().WithValidTo( TestClock.Days( -1 ) ).AddTo( context ); + + Assert.Null( await RequestAsync( context, [license] ) ); + } + + [Fact] + public async Task GetLease_TriesLicensesInPriorityOrder() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var low = LicenseBuilder.Default().WithLicenseId( 1 ).WithPriority( 10 ).AddTo( context ); + var high = LicenseBuilder.Default().WithLicenseId( 2 ).WithPriority( 0 ).AddTo( context ); + + var lease = await RequestAsync( context, [high, low] ); + + Assert.Equal( high.LicenseId, lease!.LicenseId ); + } + + /// + /// The server records a warning even when it could not send it, so that an SMTP server that + /// fails does not turn every later request into another attempt. + /// + [Fact] + public async Task GetLease_WarningEmailFails_StillRecordsThatItWasAttempted() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + context.EmailSender.ThrowOnSend = new InvalidOperationException( "SMTP is down" ); + + var license = LicenseBuilder.Default().WithUsers( 1 ).AddTo( context ); + LeaseBuilder.For( license ).User( "bob" ).AddTo( context ); + + var lease = await RequestAsync( context, [license], now: TestClock.Days( 1 ) ); + + Assert.NotNull( lease ); + Assert.Equal( TestClock.Days( 1 ), license.GraceLastWarningTime ); + } + + [Fact] + public async Task GetLease_WarningIsNotRepeatedWithinTheConfiguredInterval() + { + await using var context = + await LicenseServerTestContext.CreateAsync( o => o.GracePeriodWarningDays = 7 ); + + var license = LicenseBuilder.Default().WithUsers( 1 ).AddTo( context ); + LeaseBuilder.For( license ).User( "bob" ).AddTo( context ); + + await RequestAsync( context, [license], user: "alice", now: TestClock.Days( 1 ) ); + await context.Repository.SaveChangesAsync(); + await RequestAsync( context, [license], user: "carol", now: TestClock.Days( 2 ) ); + + Assert.Single( context.EmailSender.WithSubject( "WARNING" ) ); + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/LeaseAuditLineTests.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/LeaseAuditLineTests.cs new file mode 100644 index 0000000..d3302ae --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/LeaseAuditLineTests.cs @@ -0,0 +1,118 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.EntityFrameworkCore; +using System.Globalization; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +namespace SharpCrafters.Backstage.LicenseServer.Tests; + +/// +/// The line of the audit log is a serialization contract. Customers archive the exported files and +/// compare them across years. These tests therefore compare the format to literal strings, and not +/// to a second implementation of the same logic. +/// +public sealed class LeaseAuditLineTests +{ + private static Lease CreateLease() + => new() + { + LeaseId = 42, + OverwrittenLeaseId = 41, + LicenseId = 7, + StartTime = new DateTime( 2026, 1, 5, 9, 0, 0, DateTimeKind.Utc ), + EndTime = new DateTime( 2026, 1, 8, 9, 0, 0, DateTimeKind.Utc ), + UserName = "alice", + Machine = "desktop-1", + AuthenticatedUser = "DOMAIN\\alice" + }; + + [Fact] + public void Write_ProducesTheExpectedLine() + { + Assert.Equal( + "42;41;7;2026-01-05T09:00:00Z;2026-01-08T09:00:00Z;desktop-1;alice", + CreateLease().ToAuditLine() ); + } + + [Fact] + public void Write_NoOverwrittenLease_LeavesTheFieldEmpty() + { + var lease = CreateLease(); + lease.OverwrittenLeaseId = null; + + Assert.Equal( + "42;;7;2026-01-05T09:00:00Z;2026-01-08T09:00:00Z;desktop-1;alice", + lease.ToAuditLine() ); + } + + /// + /// The administrator of the server reads the log to learn which user and which machine hold a + /// seat, so both names are written as they were recorded. A version earlier than 2027.0 wrote + /// them as hashes, which nothing could read. + /// + [Fact] + public void Write_NamesTheUserAndTheMachine() + { + var fields = CreateLease().ToAuditLine().Split( ';' ); + + Assert.Equal( "desktop-1", fields[5] ); + Assert.Equal( "alice", fields[6] ); + } + + /// + /// Timestamps must be absolute, whatever time zone the server keeps. + /// + /// + /// SQL Server returns values whose is + /// . The legacy implementation serialized them in a mode + /// that treated them as local times and shifted them, so the exported file depended on the + /// machine that produced it. + /// + [Fact] + public void Write_EmitsAbsoluteTimestamps() + { + var fields = CreateLease().ToAuditLine().Split( ';' ); + + Assert.EndsWith( "Z", fields[3], StringComparison.Ordinal ); + Assert.EndsWith( "Z", fields[4], StringComparison.Ordinal ); + } + + /// + /// A lease read from the database is also serialized as UTC. The value converter of the model + /// provides this part of the guarantee, and Lease.Write provides the other part. + /// + [Fact] + public async Task Write_AfterReload_StillEmitsUtc() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + var saved = LeaseBuilder.For( license ).From( TestClock.Origin ).Lasting( 3 ).AddTo( context ); + + await using var reader = context.CreateFreshContext(); + var reloaded = await reader.Leases.SingleAsync( l => l.LeaseId == saved.LeaseId ); + + Assert.Equal( DateTimeKind.Utc, reloaded.StartTime.Kind ); + Assert.Equal( DateTimeKind.Utc, reloaded.EndTime.Kind ); + Assert.Equal( saved.ToAuditLine(), reloaded.ToAuditLine() ); + } + + [Fact] + public void Write_UsesInvariantFormatting() + { + var original = CultureInfo.CurrentCulture; + + try + { + CultureInfo.CurrentCulture = new CultureInfo( "de-DE" ); + + Assert.Equal( + "42;41;7;2026-01-05T09:00:00Z;2026-01-08T09:00:00Z;desktop-1;alice", + CreateLease().ToAuditLine() ); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/LeaseCountingPointsTests.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/LeaseCountingPointsTests.cs new file mode 100644 index 0000000..033b371 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/LeaseCountingPointsTests.cs @@ -0,0 +1,309 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +namespace SharpCrafters.Backstage.LicenseServer.Tests; + +/// +/// The usage timeline that the graph draws. It is a sequence of events that open and close a lease, +/// and each event carries the number of seats in use after it. +/// +public sealed class LeaseCountingPointsTests +{ + private static List Timeline( LicenseServerTestContext context, License license ) + => context.Repository + .GetLeaseCountingPoints( license.LicenseId, TestClock.Days( -10 ), TestClock.Days( 100 ) ) + .ToList(); + + [Fact] + public async Task GetLeaseCountingPoints_OneLease_OpensThenCloses() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + LeaseBuilder.For( license ).From( TestClock.Origin ).Lasting( 3 ).AddTo( context ); + + var points = Timeline( context, license ); + + Assert.Equal( 2, points.Count ); + Assert.Equal( LeaseCountingPointKind.Open, points[0].Kind ); + Assert.Equal( 1, points[0].SeatCount ); + Assert.Equal( LeaseCountingPointKind.Close, points[1].Kind ); + Assert.Equal( 0, points[1].SeatCount ); + } + + [Fact] + public async Task GetLeaseCountingPoints_ThreeUsersOneMachineEach_AreThreeSeats() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + + foreach ( var user in new[] { "alice", "bob", "carol" } ) + { + LeaseBuilder.For( license ) + .User( user ) + .Machine( $"desktop-{user}" ) + .From( TestClock.Origin ) + .Lasting( 3 ) + .AddTo( context ); + } + + var points = Timeline( context, license ); + + Assert.Equal( 3, points.Max( p => p.SeatCount ) ); + Assert.Equal( 0, points[^1].SeatCount ); + } + + /// + /// A user who gives up one machine and keeps another still holds their seat, and releases it only + /// when the last of their leases ends. + /// + [Fact] + public async Task GetLeaseCountingPoints_UserKeepingOneMachine_StaysCounted() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + + LeaseBuilder.For( license ) + .User( "alice" ) + .Machine( "desktop-1" ) + .From( TestClock.Origin ) + .Lasting( 1 ) + .AddTo( context ); + + LeaseBuilder.For( license ) + .User( "alice" ) + .Machine( "laptop-1" ) + .From( TestClock.Origin ) + .Lasting( 5 ) + .AddTo( context ); + + var points = Timeline( context, license ); + + // The first lease closes on day one and the second on day five. The seat is held throughout + // and released only at the last point. + Assert.All( points[..^1], p => Assert.Equal( 1, p.SeatCount ) ); + Assert.Equal( 0, points[^1].SeatCount ); + } + + [Fact] + public async Task GetLeaseCountingPoints_OneUserTwoMachines_NeverExceedsOneSeat() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + + LeaseBuilder.For( license ) + .User( "alice" ) + .Machine( "desktop-1" ) + .From( TestClock.Origin ) + .Lasting( 3 ) + .AddTo( context ); + + LeaseBuilder.For( license ) + .User( "alice" ) + .Machine( "laptop-1" ) + .From( TestClock.Days( 1 ) ) + .Lasting( 3 ) + .AddTo( context ); + + var points = Timeline( context, license ); + + Assert.Equal( 1, points.Max( p => p.SeatCount ) ); + } + + [Fact] + public async Task GetLeaseCountingPoints_OneUserThreeMachines_ReachesTwoSeats() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + + foreach ( var machine in new[] { "desktop-1", "laptop-1", "desktop-2" } ) + { + LeaseBuilder.For( license ) + .User( "alice" ) + .Machine( machine ) + .From( TestClock.Origin ) + .Lasting( 3 ) + .AddTo( context ); + } + + var points = Timeline( context, license ); + + // One user on three machines is two seats: one seat covers two machines, and the third takes + // a second seat. + Assert.Equal( 2, points.Max( p => p.SeatCount ) ); + } + + /// + /// When one lease ends at the instant at which another lease begins, the machine is released + /// before it is taken again. The numeric values of produce + /// this order, and this is the reason why they must not change. + /// + [Fact] + public async Task GetLeaseCountingPoints_CloseIsProcessedBeforeOpenAtTheSameInstant() + { + await using var context = + await LicenseServerTestContext.CreateAsync( o => o.MachinesPerUser = 1 ); + + var license = LicenseBuilder.Default().AddTo( context ); + + LeaseBuilder.For( license ) + .User( "alice" ) + .Machine( "desktop-1" ) + .From( TestClock.Origin ) + .To( TestClock.Days( 1 ) ) + .AddTo( context ); + + LeaseBuilder.For( license ) + .User( "alice" ) + .Machine( "desktop-1" ) + .From( TestClock.Days( 1 ) ) + .To( TestClock.Days( 2 ) ) + .AddTo( context ); + + var points = Timeline( context, license ); + + // With one machine per seat, a transient double-count would show up as 2. + Assert.Equal( 1, points.Max( p => p.SeatCount ) ); + + var atHandover = points.Where( p => p.Time == TestClock.Days( 1 ) ).ToArray(); + Assert.Equal( 2, atHandover.Length ); + Assert.Equal( LeaseCountingPointKind.Close, atHandover[0].Kind ); + Assert.Equal( LeaseCountingPointKind.Open, atHandover[1].Kind ); + } + + [Fact] + public void LeaseCountingPointKind_OrdersCloseBeforeOpen() + { + // Pinned explicitly: the ordering of the timeline depends on these values. + Assert.True( LeaseCountingPointKind.Close < LeaseCountingPointKind.Open ); + } + + [Fact] + public async Task GetLeaseCountingPoints_IsOrderedByTime() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + + for ( var i = 3; i >= 0; i-- ) + { + LeaseBuilder.For( license ).User( $"user{i}" ).From( TestClock.Days( i ) ).Lasting( 1 ).AddTo( context ); + } + + var points = Timeline( context, license ); + + Assert.Equal( points.Select( p => p.Time ).Order(), points.Select( p => p.Time ) ); + } + + [Fact] + public async Task GetLeaseCountingPoints_IsDeterministic() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + + // Several leases starting and ending at the same instants, so ties are everywhere. + for ( var i = 0; i < 5; i++ ) + { + LeaseBuilder.For( license ).User( $"user{i}" ).From( TestClock.Origin ).Lasting( 1 ).AddTo( context ); + } + + string First() + => string.Join( + "|", + Timeline( context, license ).Select( p => $"{p.Time:O}/{p.Kind}/{p.Lease.LeaseId}/{p.SeatCount}" ) ); + + Assert.Equal( First(), First() ); + } + + [Fact] + public async Task GetLeaseCountingPoints_ExcludesReplacedLeases() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + var original = LeaseBuilder.For( license ).AddTo( context ); + + context.Repository.CancelLease( original, "admin", TestClock.Days( 1 ) ); + await context.Repository.SaveChangesAsync(); + + Assert.DoesNotContain( Timeline( context, license ), p => p.Lease.LeaseId == original.LeaseId ); + } + + [Fact] + public async Task GetLeaseCountingPoints_ExcludesLeasesOutsideTheWindow() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + + LeaseBuilder.For( license ).User( "past" ).From( TestClock.Days( -30 ) ).Lasting( 1 ).AddTo( context ); + LeaseBuilder.For( license ).User( "inside" ).From( TestClock.Origin ).Lasting( 1 ).AddTo( context ); + LeaseBuilder.For( license ).User( "future" ).From( TestClock.Days( 30 ) ).Lasting( 1 ).AddTo( context ); + + var points = context.Repository + .GetLeaseCountingPoints( license.LicenseId, TestClock.Days( -1 ), TestClock.Days( 1 ) ) + .ToList(); + + Assert.All( points, p => Assert.Equal( "inside", p.Lease.UserName ) ); + } + + /// + /// Two open leases held by one user on one machine occupy one seat, and the timeline still + /// returns to zero once both have ended. + /// + /// + /// The lease service prevents this state: it reuses or prolongs a lease instead of granting a + /// second one. An earlier version of this test treated the state as data that the timeline could + /// refuse. The timeline cannot refuse it. A server whose clock moves backwards grants a second + /// lease while the first one is open, and the clock moves backwards after a restart with + /// TimeAcceleration, after a correction from a time server, and after the restore of a + /// snapshot. A load simulation produced this state in a few minutes, and the usage page answered + /// with the status 500 until the older lease ended. + /// + [Fact] + public async Task GetLeaseCountingPoints_TwoOpenLeasesOnOneMachine_CountAsOneSeat() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + + LeaseBuilder.For( license ) + .User( "alice" ) + .Machine( "desktop-1" ) + .From( TestClock.Origin ) + .Lasting( 3 ) + .AddTo( context ); + + LeaseBuilder.For( license ) + .User( "alice" ) + .Machine( "desktop-1" ) + .From( TestClock.Days( 1 ) ) + .Lasting( 3 ) + .AddTo( context ); + + var points = Timeline( context, license ); + + Assert.Equal( 4, points.Count ); + Assert.Equal( 1, points.Max( p => p.SeatCount ) ); + Assert.Equal( 0, points[^1].SeatCount ); + } + + [Fact] + public async Task GetLeaseCountingPoints_ReturnsToZeroAfterEveryLeaseEnds() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + + for ( var i = 0; i < 6; i++ ) + { + LeaseBuilder.For( license ) + .User( $"user{i}" ) + .Machine( $"machine-{i}" ) + .From( TestClock.Days( i * 0.5 ) ) + .Lasting( 2 ) + .AddTo( context ); + } + + var points = Timeline( context, license ); + + Assert.NotEmpty( points ); + Assert.Equal( 0, points[^1].SeatCount ); + Assert.All( points, p => Assert.True( p.SeatCount >= 0 ) ); + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/LeaseEndpointTests.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/LeaseEndpointTests.cs new file mode 100644 index 0000000..bc2da0d --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/LeaseEndpointTests.cs @@ -0,0 +1,248 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Net; +using Microsoft.EntityFrameworkCore; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Tests.Fakes; +using SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +namespace SharpCrafters.Backstage.LicenseServer.Tests; + +/// +/// The contract with the PostSharp client: the URL, the query string, the status codes and the +/// body. Deployed clients depend on all four, so they are exercised through the real HTTP pipeline. +/// +public sealed class LeaseEndpointTests : IDisposable +{ + private readonly LicenseServerApplication application = new(); + + public void Dispose() => this.application.Dispose(); + + private static string Url( + string? user = "alice", + string? machine = "desktop-1", + string? product = "Ultimate", + string? version = "2025.1.0", + string? buildDate = null ) + { + List arguments = []; + + if ( user != null ) { arguments.Add( $"user={user}" ); } + + if ( machine != null ) { arguments.Add( $"machine={machine}" ); } + + if ( product != null ) { arguments.Add( $"product={product}" ); } + + if ( version != null ) { arguments.Add( $"version={version}" ); } + + if ( buildDate != null ) { arguments.Add( $"buildDate={buildDate}" ); } + + return "/Lease.ashx?" + string.Join( "&", arguments ); + } + + [Fact] + public async Task Lease_Succeeds_ReturnsTheSerializedLease() + { + this.application.AddLicense( LicenseBuilder.Default().WithUsers( 5 ) ); + var client = this.application.CreateClient(); + + var response = await client.GetAsync( Url() ); + var body = await response.Content.ReadAsStringAsync(); + + Assert.Equal( HttpStatusCode.OK, response.StatusCode ); + Assert.Equal( "text/plain", response.Content.Headers.ContentType?.MediaType ); + Assert.Contains( "License:", body, StringComparison.Ordinal ); + Assert.Contains( "StartTime:", body, StringComparison.Ordinal ); + Assert.Contains( "EndTime:", body, StringComparison.Ordinal ); + Assert.Contains( "RenewTime:", body, StringComparison.Ordinal ); + } + + [Fact] + public async Task Lease_Succeeds_PersistsExactlyOneLease() + { + this.application.AddLicense( LicenseBuilder.Default().WithUsers( 5 ) ); + var client = this.application.CreateClient(); + + await client.GetAsync( Url() ); + + await using var db = this.application.CreateDbContext(); + var lease = await db.Leases.SingleAsync(); + + Assert.Equal( "alice", lease.UserName ); + Assert.Equal( "desktop-1", lease.Machine ); + Assert.Equal( "DOMAIN\\tester", lease.AuthenticatedUser ); + } + + [Fact] + public async Task Lease_MixedCaseUserAndMachine_ArePersistedInLowerCase() + { + this.application.AddLicense( LicenseBuilder.Default().WithUsers( 5 ) ); + var client = this.application.CreateClient(); + + await client.GetAsync( Url( user: "Alice", machine: "DESKTOP-1" ) ); + + await using var db = this.application.CreateDbContext(); + var lease = await db.Leases.SingleAsync(); + + Assert.Equal( "alice", lease.UserName ); + Assert.Equal( "desktop-1", lease.Machine ); + } + + /// + /// An anonymous request must still be served, and must still be recorded, even though the + /// authenticated name is empty. The column does not accept null. + /// + [Fact] + public async Task Lease_AnonymousRequest_IsServedAndRecorded() + { + this.application.AddLicense( LicenseBuilder.Default().WithUsers( 5 ) ); + var client = this.application.CreateClient(); + client.DefaultRequestHeaders.Add( TestAuthenticationHandler.AnonymousHeader, "1" ); + + var response = await client.GetAsync( Url() ); + + Assert.Equal( HttpStatusCode.OK, response.StatusCode ); + + await using var db = this.application.CreateDbContext(); + var lease = await db.Leases.SingleAsync(); + Assert.Equal( string.Empty, lease.AuthenticatedUser ); + } + + [Theory] + [InlineData( null, "desktop-1", "Missing query string argument: user." )] + [InlineData( "alice", null, "Missing query string argument: machine." )] + public async Task Lease_MissingArgument_Returns400( string? user, string? machine, string expected ) + { + var client = this.application.CreateClient(); + + var response = await client.GetAsync( Url( user, machine ) ); + + Assert.Equal( HttpStatusCode.BadRequest, response.StatusCode ); + Assert.Equal( expected, await response.Content.ReadAsStringAsync() ); + } + + [Fact] + public async Task Lease_UnparseableVersion_Returns400() + { + var client = this.application.CreateClient(); + + var response = await client.GetAsync( Url( version: "not-a-version" ) ); + + Assert.Equal( HttpStatusCode.BadRequest, response.StatusCode ); + Assert.Equal( "Cannot parse the argument: version.", await response.Content.ReadAsStringAsync() ); + } + + [Fact] + public async Task Lease_UnparseableBuildDate_Returns400() + { + var client = this.application.CreateClient(); + + var response = await client.GetAsync( Url( buildDate: "yesterday" ) ); + + Assert.Equal( HttpStatusCode.BadRequest, response.StatusCode ); + Assert.Equal( "Cannot parse the argument: buildDate.", await response.Content.ReadAsStringAsync() ); + } + + /// + /// Clients older than PostSharp 5 send no version at all, and are treated as 4.9.9. + /// + [Fact] + public async Task Lease_NoVersion_IsTreatedAsPostSharp499() + { + this.application.AddLicense( LicenseBuilder.Default().WithUsers( 5 ).WithMinPostSharpVersion( new Version( 5, 0, 0 ) ) ); + + var client = this.application.CreateClient(); + + var response = await client.GetAsync( Url( version: null ) ); + var body = await response.Content.ReadAsStringAsync(); + + Assert.Equal( HttpStatusCode.Forbidden, response.StatusCode ); + Assert.Contains( "the requested version is 4.9.9", body, StringComparison.Ordinal ); + } + + [Fact] + public async Task Lease_NoCapacity_Returns403WithTheReason() + { + this.application.AddLicense( LicenseBuilder.Default().NotLicenseServerEligible() ); + var client = this.application.CreateClient(); + + var response = await client.GetAsync( Url() ); + var body = await response.Content.ReadAsStringAsync(); + + Assert.Equal( HttpStatusCode.Forbidden, response.StatusCode ); + Assert.StartsWith( "No license with free capacity. ", body, StringComparison.Ordinal ); + Assert.Contains( "cannot be used in the license server", body, StringComparison.Ordinal ); + } + + [Fact] + public async Task Lease_NoLicenseAtAll_Returns403() + { + var client = this.application.CreateClient(); + + var response = await client.GetAsync( Url() ); + + Assert.Equal( HttpStatusCode.Forbidden, response.StatusCode ); + } + + [Fact] + public async Task Lease_LockTimesOut_Returns503() + { + this.application.AddLicense( LicenseBuilder.Default().WithUsers( 5 ) ); + this.application.LeaseLock = new NeverAcquiringLeaseLock(); + + var client = this.application.CreateClient(); + + var response = await client.GetAsync( Url() ); + + Assert.Equal( HttpStatusCode.ServiceUnavailable, response.StatusCode ); + Assert.Equal( "Service overloaded.", await response.Content.ReadAsStringAsync() ); + } + + /// + /// A build agent receives a license key, and the server stores no lease for it, so that build + /// machines do not consume the seats of the developers they build for. + /// + [Fact] + public async Task Lease_BuildAgent_IsServedWithoutConsumingASeat() + { + this.application.AddLicense( LicenseBuilder.Default().WithUsers( 5 ) ); + var client = this.application.CreateClient(); + + var response = await client.GetAsync( Url( machine: "buildagent-1f2e" ) ); + + Assert.Equal( HttpStatusCode.OK, response.StatusCode ); + + await using var db = this.application.CreateDbContext(); + Assert.Equal( 0, await db.Leases.CountAsync() ); + } + + [Fact] + public async Task Lease_RequestedTwiceForTheSameMachine_ReusesTheLease() + { + this.application.AddLicense( LicenseBuilder.Default().WithUsers( 5 ) ); + var client = this.application.CreateClient(); + + await client.GetAsync( Url() ); + await client.GetAsync( Url() ); + + await using var db = this.application.CreateDbContext(); + Assert.Equal( 1, await db.Leases.CountAsync() ); + } + + /// + /// Concurrent requests must not each decide that the last free seat is theirs. + /// + [Fact] + public async Task Lease_ConcurrentRequestsForTheSameMachine_GrantOneLease() + { + this.application.AddLicense( LicenseBuilder.Default().WithUsers( 5 ) ); + var client = this.application.CreateClient(); + + var responses = await Task.WhenAll( Enumerable.Range( 0, 8 ).Select( _ => client.GetAsync( Url() ) ) ); + + Assert.All( responses, r => Assert.Equal( HttpStatusCode.OK, r.StatusCode ) ); + + await using var db = this.application.CreateDbContext(); + Assert.Equal( 1, await db.Leases.CountAsync() ); + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/LeaseLockTests.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/LeaseLockTests.cs new file mode 100644 index 0000000..4af5bc1 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/LeaseLockTests.cs @@ -0,0 +1,93 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Net; +using Microsoft.EntityFrameworkCore; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Endpoints; +using SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +namespace SharpCrafters.Backstage.LicenseServer.Tests; + +/// +/// The lock that serializes the lease requests, so that two requests cannot both take the last free +/// seat. +/// +/// +/// The lock belongs to the database, so these tests exercise the lock of the engine the run uses: an +/// application lock on SQL Server, and a write transaction on SQLite. They drive the interleaving +/// with a synchronization point instead of starting two requests and hoping that they overlap. Two +/// requests that merely run at the same time prove nothing: they pass whether the lock works or not. +/// +public sealed class LeaseLockTests : IDisposable +{ + private readonly LicenseServerApplication application = new(); + + public void Dispose() => this.application.Dispose(); + + private static string Url( string user, string machine ) => $"/Lease.ashx?user={user}&machine={machine}&product=Ultimate&version=2027.0.0"; + + /// + /// The second request waits for the first one, sees the seat it took, and is denied. Without the + /// lock it would read the seat count before the first request wrote its lease, and both would be + /// granted. + /// + [Fact] + public async Task SecondRequest_WaitsForTheFirst_AndSeesItsLease() + { + var license = this.application.AddLicense( LicenseBuilder.Default().WithUsers( 1 ).WithGracePercent( 0 ) ); + + this.application.Synchronization.EnableSyncPoint( LicenseServerEndpoints.HoldingLeaseLockSyncPoint ); + + var client = this.application.CreateClient(); + + var first = client.GetAsync( Url( "alice", "desktop-1" ) ); + + // The first request now holds the lock. + await this.application.Synchronization.WaitForSyncPointReachedAsync( LicenseServerEndpoints.HoldingLeaseLockSyncPoint ); + + var second = client.GetAsync( Url( "bob", "desktop-2" ) ); + + // Releases the first request, and lets the second one through the synchronization point when + // it finally acquires the lock. + this.application.Synchronization.DisableSyncPoint( LicenseServerEndpoints.HoldingLeaseLockSyncPoint ); + + var firstResponse = await first; + var secondResponse = await second; + + Assert.Equal( HttpStatusCode.OK, firstResponse.StatusCode ); + Assert.Equal( HttpStatusCode.Forbidden, secondResponse.StatusCode ); + + await using var db = this.application.CreateDbContext(); + + Assert.Equal( 1, await db.Leases.CountAsync( l => l.LicenseId == license.LicenseId ) ); + } + + /// + /// A request that waits longer than MutexTimeout for the lock is answered with the status + /// 503, which is what the legacy server answered when its mutex timed out. + /// + [Fact] + public async Task SecondRequest_WhenTheFirstHoldsTheLockTooLong_IsAnsweredWithServiceOverloaded() + { + this.application.MutexTimeoutSeconds = 1; + this.application.AddLicense( LicenseBuilder.Default().WithUsers( 5 ) ); + + this.application.Synchronization.EnableSyncPoint( LicenseServerEndpoints.HoldingLeaseLockSyncPoint ); + + var client = this.application.CreateClient(); + + var first = client.GetAsync( Url( "alice", "desktop-1" ) ); + + await this.application.Synchronization.WaitForSyncPointReachedAsync( LicenseServerEndpoints.HoldingLeaseLockSyncPoint ); + + // The first request holds the lock for longer than the second one waits. + var secondResponse = await client.GetAsync( Url( "bob", "desktop-2" ) ); + + Assert.Equal( HttpStatusCode.ServiceUnavailable, secondResponse.StatusCode ); + Assert.Equal( "Service overloaded.", await secondResponse.Content.ReadAsStringAsync() ); + + this.application.Synchronization.DisableSyncPoint( LicenseServerEndpoints.HoldingLeaseLockSyncPoint ); + + Assert.Equal( HttpStatusCode.OK, ( await first ).StatusCode ); + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/LeaseSerializerTests.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/LeaseSerializerTests.cs new file mode 100644 index 0000000..973a28e --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/LeaseSerializerTests.cs @@ -0,0 +1,55 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using SharpCrafters.Backstage.LicenseServer.Licensing; + +namespace SharpCrafters.Backstage.LicenseServer.Tests; + +/// +/// The body of the response of a lease request. Every deployed client parses it, so these tests +/// compare it to a literal string, and not to a second implementation of the same formatting. +/// +/// +/// The expected values are the ones the LicenseLease.Serialize of the PostSharp SDK produced, +/// so that a client that was talking to the previous version of this server sees no change. +/// +public sealed class LeaseSerializerTests +{ + private static readonly DateTime start = new( 2026, 1, 5, 9, 0, 0, DateTimeKind.Utc ); + + [Fact] + public void Serialize_ProducesTheExpectedBody() + => Assert.Equal( + "License: 1-ABCDEF" + + "; StartTime: 2026-01-05T09:00:00Z" + + "; EndTime: 2026-01-08T09:00:00Z" + + "; RenewTime: 2026-01-07T09:00:00Z", + LeaseSerializer.Serialize( "1-ABCDEF", start, start.AddDays( 3 ), start.AddDays( 2 ) ) ); + + /// + /// An instant read from a datetime column carries no kind. It is a UTC instant, and the + /// time zone of the server must not shift it. + /// + [Fact] + public void Serialize_TreatsAnUntaggedTimeAsUtc() + { + DateTime unspecified = new( 2026, 1, 5, 9, 0, 0, DateTimeKind.Unspecified ); + + Assert.Equal( + LeaseSerializer.Serialize( "1-ABCDEF", start, start, start ), + LeaseSerializer.Serialize( "1-ABCDEF", unspecified, unspecified, unspecified ) ); + } + + /// + /// The client splits the body at every ;, and each part at its first :, so a key + /// must contain neither character. A license key contains an identifier, a hyphen and Base32 + /// characters, so it contains neither. This test verifies that assumption. + /// + [Fact] + public void Serialize_ProducesFourPartsTheClientCanSplit() + { + var parts = LeaseSerializer.Serialize( "1-ABCDEF", start, start, start ).Split( ';' ); + + Assert.Equal( 4, parts.Length ); + Assert.Equal( ["License", "StartTime", "EndTime", "RenewTime"], parts.Select( p => p[..p.IndexOf( ':', StringComparison.Ordinal )].Trim() ) ); + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/LicenseAvailabilityTests.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/LicenseAvailabilityTests.cs new file mode 100644 index 0000000..cb4fe59 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/LicenseAvailabilityTests.cs @@ -0,0 +1,220 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Services; +using SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +namespace SharpCrafters.Backstage.LicenseServer.Tests; + +/// +/// Whether the server can serve a lease, which is what the health check reports. +/// +/// +/// The last tests of this class ask the allocator the same question, by allocating a lease. This +/// service applies the rules of without allocating, and the two +/// implementations must give the same answer. +/// +public sealed class LicenseAvailabilityTests +{ + private static readonly DateTime now = TestClock.Days( 1 ); + + private static LicenseAvailabilityService CreateService( LicenseServerTestContext context ) + => new( context.Repository, context.LicenseParser, context.ServerVersion ); + + private static async Task GetAvailabilityAsync( LicenseServerTestContext context ) + => await CreateService( context ).GetAvailabilityAsync( now ); + + /// + /// Fills a license to the number of seats given, one machine per user. + /// + private static void Occupy( LicenseServerTestContext context, License license, int seats ) + { + for ( var i = 0; i < seats; i++ ) + { + LeaseBuilder.For( license ).User( $"user{i}" ).Machine( $"machine{i}" ).AddTo( context ); + } + } + + [Fact] + public async Task Availability_NoLicense_CannotServe() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + + var availability = await GetAvailabilityAsync( context ); + + Assert.False( availability.CanServeLease ); + Assert.Equal( 0, availability.Total ); + Assert.Equal( "No license is registered.", availability.Describe() ); + } + + [Fact] + public async Task Availability_FreeCapacity_CanServe() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().WithUsers( 5 ).AddTo( context ); + Occupy( context, license, 3 ); + + var availability = await GetAvailabilityAsync( context ); + + Assert.True( availability.CanServeLease ); + Assert.Equal( 1, availability.Available ); + } + + [Fact] + public async Task Availability_NoSeatLimit_CanServe() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + LicenseBuilder.Default().WithUsers( null ).AddTo( context ); + + Assert.True( ( await GetAvailabilityAsync( context ) ).CanServeLease ); + } + + [Fact] + public async Task Availability_Disabled_CannotServe() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + LicenseBuilder.Default().WithUsers( 5 ).WithPriority( -1 ).AddTo( context ); + + var availability = await GetAvailabilityAsync( context ); + + Assert.False( availability.CanServeLease ); + Assert.Equal( 1, availability.Disabled ); + Assert.Contains( "1 disabled", availability.Describe(), StringComparison.Ordinal ); + } + + [Fact] + public async Task Availability_Expired_CannotServe() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + LicenseBuilder.Default().WithUsers( 5 ).WithValidTo( TestClock.Days( 0.5 ) ).AddTo( context ); + + var availability = await GetAvailabilityAsync( context ); + + Assert.False( availability.CanServeLease ); + Assert.Equal( 1, availability.Expired ); + } + + /// + /// A license key that the server cannot parse counts as invalid. The home page reports the same + /// state. + /// + [Fact] + public async Task Availability_UnparsableKey_CannotServe() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + + context.Db.Licenses.Add( new License { LicenseId = 1, LicenseKey = "NOT-A-KEY", ProductCode = "Ultimate", CreatedOn = TestClock.Origin } ); + + await context.Db.SaveChangesAsync(); + + var availability = await GetAvailabilityAsync( context ); + + Assert.False( availability.CanServeLease ); + Assert.Equal( 1, availability.Invalid ); + } + + /// + /// The license is full, and its grace period still has a free seat and remaining days, so the + /// server serves the next request. + /// + [Fact] + public async Task Availability_FullWithGraceLeft_CanServe() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + + // Five seats plus 20 per cent is a grace limit of six. + var license = LicenseBuilder.Default().WithUsers( 5 ).WithGracePercent( 20 ).AddTo( context ); + Occupy( context, license, 5 ); + + Assert.True( ( await GetAvailabilityAsync( context ) ).CanServeLease ); + } + + [Fact] + public async Task Availability_GraceSeatsUsedUp_CannotServe() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().WithUsers( 5 ).WithGracePercent( 20 ).AddTo( context ); + Occupy( context, license, 6 ); + + var availability = await GetAvailabilityAsync( context ); + + Assert.False( availability.CanServeLease ); + Assert.Equal( 1, availability.Exhausted ); + Assert.Contains( "1 at capacity", availability.Describe(), StringComparison.Ordinal ); + } + + [Fact] + public async Task Availability_GracePeriodOver_CannotServe() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + + var license = LicenseBuilder.Default() + .WithUsers( 5 ) + .WithGraceDays( 10 ) + .WithGraceStartTime( TestClock.Days( -20 ) ) + .AddTo( context ); + + Occupy( context, license, 5 ); + + var availability = await GetAvailabilityAsync( context ); + + Assert.False( availability.CanServeLease ); + Assert.Equal( 1, availability.Exhausted ); + } + + /// + /// Reading the availability does not start the grace period of a license. The allocator starts + /// that period when it grants a lease within it. A probe that did the same would let the period + /// elapse while the server is idle. + /// + [Fact] + public async Task Availability_FullLicense_DoesNotStartTheGracePeriod() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().WithUsers( 5 ).AddTo( context ); + Occupy( context, license, 5 ); + + await GetAvailabilityAsync( context ); + + Assert.Null( context.Db.Licenses.Single().GraceStartTime ); + } + + /// + /// The health check and the allocator answer the same question. A server that the check reports + /// as available grants a lease, and a server that it reports as unavailable denies the request. + /// + [Theory] + [InlineData( 3, false, true )] + [InlineData( 5, false, true )] + [InlineData( 6, false, false )] + [InlineData( 5, true, false )] + public async Task Availability_AgreesWithTheAllocator( int seatsInUse, bool graceOver, bool expected ) + { + await using var context = await LicenseServerTestContext.CreateAsync(); + + var builder = LicenseBuilder.Default().WithUsers( 5 ).WithGracePercent( 20 ).WithGraceDays( 10 ); + + if ( graceOver ) + { + builder = builder.WithGraceStartTime( TestClock.Days( -20 ) ); + } + + var license = builder.AddTo( context ); + Occupy( context, license, seatsInUse ); + + Assert.Equal( expected, ( await GetAvailabilityAsync( context ) ).CanServeLease ); + + // Asked afterwards, because allocating changes the state the check reads. + var granted = await context.LeaseService.GetLicenseLeaseAsync( + null, + new Version( 2027, 0 ), + null, + "new-machine", + "new-user", + "new-user", + now, + [] ); + + Assert.Equal( expected, granted != null ); + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/LicenseValidationTests.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/LicenseValidationTests.cs new file mode 100644 index 0000000..47612dd --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/LicenseValidationTests.cs @@ -0,0 +1,216 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +namespace SharpCrafters.Backstage.LicenseServer.Tests; + +/// +/// The reasons why a license does not serve a request. The server reports each reason to the +/// developer in the body of the response 403, so these tests verify the text. +/// +public sealed class LicenseValidationTests +{ + private static async Task<(Lease? Lease, Dictionary Errors)> RequestAsync( + LicenseServerTestContext context, + License license, + Version? version = null, + DateTime? buildDate = null ) + { + Dictionary errors = []; + + var lease = await context.LeaseService.GetLeaseAsync( + version ?? new Version( 2025, 1, 0 ), + buildDate, + "desktop-1", + "alice", + "alice", + TestClock.Origin, + errors, + [license] ); + + return ( lease, errors ); + } + + [Fact] + public async Task UnparseableKey_IsReportedAsInvalid() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + + // Added straight to the database, so the fake parser has no entry for its key. + License license = new() { LicenseId = 99, LicenseKey = "NOT-A-KEY", ProductCode = "Ultimate", CreatedOn = TestClock.Origin }; + + context.Db.Licenses.Add( license ); + await context.Db.SaveChangesAsync(); + + var (lease, errors) = await RequestAsync( context, license ); + + Assert.Null( lease ); + Assert.Equal( "The license key #99 is invalid.", errors[99] ); + } + + [Fact] + public async Task LicenseNeedsANewerLicenseServer_SaysSo() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + + var license = LicenseBuilder.Default() + .WithMinPostSharpVersion( new Version( 2099, 3, 7 ) ) + .AddTo( context ); + + var (lease, errors) = await RequestAsync( context, license ); + + Assert.Null( lease ); + + Assert.Contains( + "requires a higher version of the licensing library on the License Server", + errors[1], + StringComparison.Ordinal ); + + Assert.Contains( "2099.3.7", errors[1], StringComparison.Ordinal ); + } + + [Fact] + public async Task ClientIsOlderThanTheLicenseRequires_SaysSo() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + + var license = LicenseBuilder.Default() + .WithMinPostSharpVersion( new Version( 2024, 0, 0 ) ) + .AddTo( context ); + + var (lease, errors) = await RequestAsync( context, license, new Version( 6, 5, 4 ) ); + + Assert.Null( lease ); + Assert.Contains( "requires PostSharp version >= 2024.0.0", errors[1], StringComparison.Ordinal ); + Assert.Contains( "the requested version is 6.5.4", errors[1], StringComparison.Ordinal ); + } + + /// + /// A Metalama license names the lowest version of Metalama that can read it, which the signature + /// algorithm of the key decides. That minimum is independent of the minimum PostSharp version. + /// + [Fact] + public async Task MetalamaClientIsOlderThanTheLicenseRequires_SaysSo() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + + var license = LicenseBuilder.Default() + .AsMetalamaProduct() + .WithMinMetalamaVersion( new Version( 2026, 1, 0 ) ) + .AddTo( context ); + + var (lease, errors) = await RequestAsync( context, license, new Version( 2025, 2, 3 ) ); + + Assert.Null( lease ); + Assert.Contains( "requires Metalama version >= 2026.1.0", errors[1], StringComparison.Ordinal ); + Assert.Contains( "the requested version is 2025.2.3", errors[1], StringComparison.Ordinal ); + } + + /// + /// A Metalama client is not refused by the minimum PostSharp version of the same license. The two + /// minimums belong to two product families, and a Metalama version number is lower than the + /// PostSharp version numbers of the same years. + /// + [Fact] + public async Task MetalamaLicense_IgnoresTheMinimumPostSharpVersion() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + + var license = LicenseBuilder.Default() + .AsMetalamaProduct() + .WithMinPostSharpVersion( new Version( 2024, 0, 0 ) ) + .WithMinMetalamaVersion( null ) + .AddTo( context ); + + var (lease, errors) = await RequestAsync( context, license, new Version( 2023, 4, 0 ) ); + + Assert.NotNull( lease ); + Assert.Empty( errors ); + } + + [Fact] + public async Task LicenseNotEligibleForALicenseServer_SaysSo() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().NotLicenseServerEligible().AddTo( context ); + + var (lease, errors) = await RequestAsync( context, license ); + + Assert.Null( lease ); + Assert.Contains( "cannot be used in the license server", errors[1], StringComparison.Ordinal ); + } + + [Fact] + public async Task BuildIsNewerThanTheSubscription_SaysSoWithTheRequestedVersion() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + + var license = LicenseBuilder.Default() + .WithSubscriptionEndDate( TestClock.Days( -30 ) ) + .AddTo( context ); + + var (lease, errors) = await RequestAsync( context, license, new Version( 2025, 1, 0 ), TestClock.Origin ); + + Assert.Null( lease ); + Assert.Contains( "maintenance subscription of license #1 ends on", errors[1], StringComparison.Ordinal ); + Assert.Contains( "the requested version 2025.1.0", errors[1], StringComparison.Ordinal ); + } + + /// + /// Clients older than PostSharp 5 do not send their version, so the message cannot mention one. + /// + [Fact] + public async Task BuildIsNewerThanTheSubscriptionOnAnOldClient_OmitsTheVersion() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + + var license = LicenseBuilder.Default() + .WithSubscriptionEndDate( TestClock.Days( -30 ) ) + .AddTo( context ); + + var (lease, errors) = await RequestAsync( context, license, new Version( 4, 9, 9 ), TestClock.Origin ); + + Assert.Null( lease ); + Assert.Contains( "but the requested version has been built on", errors[1], StringComparison.Ordinal ); + Assert.DoesNotContain( "the requested version 4.9.9", errors[1], StringComparison.Ordinal ); + } + + [Fact] + public async Task BuildExactlyOnTheSubscriptionEndDate_IsAccepted() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + + var license = LicenseBuilder.Default() + .WithSubscriptionEndDate( TestClock.Origin ) + .AddTo( context ); + + var (lease, _) = await RequestAsync( context, license, buildDate: TestClock.Origin ); + + Assert.NotNull( lease ); + } + + [Fact] + public async Task NoBuildDate_SkipsTheSubscriptionCheck() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + + var license = LicenseBuilder.Default() + .WithSubscriptionEndDate( TestClock.Days( -30 ) ) + .AddTo( context ); + + var (lease, _) = await RequestAsync( context, license ); + + Assert.NotNull( lease ); + } + + [Fact] + public async Task NoSubscriptionEndDate_SkipsTheSubscriptionCheck() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().WithSubscriptionEndDate( null ).AddTo( context ); + + var (lease, _) = await RequestAsync( context, license, buildDate: TestClock.Days( 1000 ) ); + + Assert.NotNull( lease ); + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/OpenLeasesTests.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/OpenLeasesTests.cs new file mode 100644 index 0000000..4af5f18 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/OpenLeasesTests.cs @@ -0,0 +1,104 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.EntityFrameworkCore; +using SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +namespace SharpCrafters.Backstage.LicenseServer.Tests; + +/// +/// The server never updates a lease. Prolonging a lease and cancelling a lease both insert a +/// replacement that references the previous lease. A lease is open when no other lease references +/// it. +/// +public sealed class OpenLeasesTests +{ + [Fact] + public async Task OpenLeases_LeaseNeverReplaced_IsOpen() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + var lease = LeaseBuilder.For( license ).AddTo( context ); + + Assert.Equal( [lease.LeaseId], await context.Db.OpenLeases.Select( l => l.LeaseId ).ToListAsync() ); + } + + [Fact] + public async Task OpenLeases_ReplacedLease_IsNotOpen() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + var original = LeaseBuilder.For( license ).AddTo( context ); + + var replacement = context.Repository.ProlongLease( original, "alice", TestClock.Days( 2.5 ) ); + await context.Repository.SaveChangesAsync(); + + Assert.NotNull( replacement ); + Assert.Equal( [replacement.LeaseId], await context.Db.OpenLeases.Select( l => l.LeaseId ).ToListAsync() ); + } + + [Fact] + public async Task OpenLeases_ChainOfReplacements_LeavesOnlyTheLast() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + + var current = LeaseBuilder.For( license ).AddTo( context ); + + for ( var i = 1; i <= 3; i++ ) + { + current = context.Repository.ProlongLease( current, "alice", TestClock.Days( i * 2.5 ) )!; + await context.Repository.SaveChangesAsync(); + } + + Assert.Equal( 4, await context.Db.Leases.CountAsync() ); + Assert.Equal( [current.LeaseId], await context.Db.OpenLeases.Select( l => l.LeaseId ).ToListAsync() ); + } + + /// + /// No constraint of the schema prevents two leases from replacing the same lease. The legacy + /// query, a left join, returned such a lease once per replacement. An anti-join returns it once. + /// + [Fact] + public async Task OpenLeases_LeaseReplacedTwice_IsStillListedOnlyOnce() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var license = LicenseBuilder.Default().AddTo( context ); + var original = LeaseBuilder.For( license ).AddTo( context ); + + var survivor = LeaseBuilder.For( license ).User( "bob" ).AddTo( context ); + + foreach ( var _ in Enumerable.Range( 0, 2 ) ) + { + context.Db.Leases.Add( + new Lease + { + LicenseId = license.LicenseId, + OverwrittenLeaseId = original.LeaseId, + UserName = original.UserName, + Machine = original.Machine, + AuthenticatedUser = original.AuthenticatedUser, + StartTime = original.StartTime, + EndTime = TestClock.Days( 4 ) + } ); + } + + await context.Db.SaveChangesAsync(); + + var open = await context.Db.OpenLeases.Select( l => l.LeaseId ).ToListAsync(); + + Assert.DoesNotContain( original.LeaseId, open ); + Assert.Equal( open.Count, open.Distinct().Count() ); + Assert.Contains( survivor.LeaseId, open ); + } + + [Fact] + public async Task OpenLeases_TranslatesToSqlAsAnAntiJoin() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + + var sql = context.Db.OpenLeases.ToQueryString(); + + // Proves the filter runs in the database rather than after loading every lease. + Assert.Contains( "NOT EXISTS", sql, StringComparison.OrdinalIgnoreCase ); + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/OperationsEndpointTests.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/OperationsEndpointTests.cs new file mode 100644 index 0000000..8b3cfd3 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/OperationsEndpointTests.cs @@ -0,0 +1,170 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Net; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +namespace SharpCrafters.Backstage.LicenseServer.Tests; + +/// +/// The endpoints of a monitoring system. A probe reads the status code, so each test asserts the +/// status code together with the body. +/// +public sealed class OperationsEndpointTests : IDisposable +{ + private readonly LicenseServerApplication application = new(); + + public void Dispose() => this.application.Dispose(); + + private async Task<(HttpStatusCode Status, JsonElement Body)> GetAsync( string url ) + { + var response = await this.application.CreateClient().GetAsync( url ); + + return ( response.StatusCode, JsonDocument.Parse( await response.Content.ReadAsStringAsync() ).RootElement ); + } + + private static JsonElement Check( JsonElement body, string name ) + => body.GetProperty( "checks" ).EnumerateArray().Single( c => c.GetProperty( "name" ).GetString() == name ); + + private static JsonElement DatabaseCheck( JsonElement body ) => Check( body, "database" ); + + private static JsonElement LicenseCheck( JsonElement body ) => Check( body, "licenses" ); + + [Fact] + public async Task Health_LicenseWithFreeCapacity_IsHealthy() + { + this.application.AddLicense( LicenseBuilder.Default().WithUsers( 5 ) ); + + var (status, body) = await this.GetAsync( "/health" ); + + Assert.Equal( HttpStatusCode.OK, status ); + Assert.Equal( "Healthy", body.GetProperty( "status" ).GetString() ); + + var checks = body.GetProperty( "checks" ) + .EnumerateArray() + .Select( c => c.GetProperty( "name" ).GetString()! ) + .ToArray(); + + Assert.Equal( ["database", "licenses"], checks ); + } + + /// + /// A server without a license answers every lease request with 403 while its process and its + /// database work. The check reports that state. It warns and does not fail, so the probe still + /// succeeds, because no system that reads a probe can add a license. + /// + [Fact] + public async Task Health_NoLicense_WarnsAndStaysServed() + { + var (status, body) = await this.GetAsync( "/health" ); + + Assert.Equal( HttpStatusCode.OK, status ); + Assert.Equal( "Degraded", body.GetProperty( "status" ).GetString() ); + + var licenses = LicenseCheck( body ); + + Assert.Equal( "Degraded", licenses.GetProperty( "status" ).GetString() ); + Assert.Equal( "No license is registered.", licenses.GetProperty( "description" ).GetString() ); + } + + [Fact] + public async Task Health_ExpiredLicense_WarnsAndStaysServed() + { + this.application.AddLicense( LicenseBuilder.Default().WithUsers( 5 ).WithValidTo( TestClock.Days( -1 ) ) ); + + var (status, body) = await this.GetAsync( "/health" ); + + Assert.Equal( HttpStatusCode.OK, status ); + Assert.Equal( "Degraded", body.GetProperty( "status" ).GetString() ); + Assert.Contains( "1 expired", LicenseCheck( body ).GetProperty( "description" ).GetString()!, StringComparison.Ordinal ); + } + + /// + /// A database without the schema is the deployment error that this check detects, because the + /// server never runs CreateTables.sql itself. The database check fails the probe. The license + /// check only warns, and here it warns because it cannot read the state. + /// + /// + /// Neither check returns the message of the exception. A database exception contains the name of + /// the server, and sometimes the whole connection string, and this endpoint requires no + /// authentication. + /// + [Fact] + public async Task Health_DatabaseWithoutSchema_FailsAndSaysNothingMore() + { + // The database keeps no schema after this test, so it must not serve another one. + this.application.DoNotReuseDatabase(); + + using ( var db = this.application.CreateDbContext() ) + { + // PostgreSQL folds an identifier that is not quoted to lower case, and the tables of this + // schema keep their capitals. The quotation marks are accepted by all three engines. + await db.Database.ExecuteSqlRawAsync( "DROP TABLE \"Leases\"" ); + await db.Database.ExecuteSqlRawAsync( "DROP TABLE \"Licenses\"" ); + } + + var (status, body) = await this.GetAsync( "/health" ); + + Assert.Equal( HttpStatusCode.ServiceUnavailable, status ); + Assert.Equal( "Unhealthy", DatabaseCheck( body ).GetProperty( "status" ).GetString() ); + Assert.Equal( "Degraded", LicenseCheck( body ).GetProperty( "status" ).GetString() ); + + foreach ( var check in body.GetProperty( "checks" ).EnumerateArray() ) + { + var description = check.GetProperty( "description" ).GetString()!; + + Assert.EndsWith( "The reason is in the log of the server.", description, StringComparison.Ordinal ); + Assert.DoesNotContain( "no such table", description, StringComparison.OrdinalIgnoreCase ); + } + } + + /// + /// The liveness probe reports the state of the process only. An expired license is not a reason + /// to restart the server, and an orchestrator that restarted it would restart it indefinitely. + /// + [Fact] + public async Task Liveness_NoLicense_IsHealthy() + { + var (status, body) = await this.GetAsync( "/health/live" ); + + Assert.Equal( HttpStatusCode.OK, status ); + Assert.Equal( "Healthy", body.GetProperty( "status" ).GetString() ); + Assert.Empty( body.GetProperty( "checks" ).EnumerateArray() ); + } + + [Fact] + public async Task Version_ReportsTheProductAndTheLicensingLibrary() + { + var (status, body) = await this.GetAsync( "/version" ); + + Assert.Equal( HttpStatusCode.OK, status ); + Assert.Equal( "SharpCrafters.Backstage.LicenseServer", body.GetProperty( "product" ).GetString() ); + + // A version, not the empty string, and without the commit hash the build appends to it. + var version = body.GetProperty( "version" ).GetString()!; + + Assert.NotEmpty( version ); + Assert.DoesNotContain( "+", version, StringComparison.Ordinal ); + + Assert.True( Version.TryParse( body.GetProperty( "licensingLibrary" ).GetString(), out _ ) ); + } + + /// + /// Both endpoints require no authentication. A monitoring agent has no Windows credentials, and + /// a probe that receives 401 reports the server as unavailable. + /// + [Theory] + [InlineData( "/health/live" )] + [InlineData( "/version" )] + public async Task Endpoint_AnonymousRequest_IsServed( string url ) + { + var client = this.application.CreateClient(); + client.DefaultRequestHeaders.Add( TestAuthenticationHandler.AnonymousHeader, "true" ); + + var response = await client.GetAsync( url ); + + Assert.Equal( HttpStatusCode.OK, response.StatusCode ); + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/PageTests.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/PageTests.cs new file mode 100644 index 0000000..471566c --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/PageTests.cs @@ -0,0 +1,329 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.AspNetCore.Mvc.Testing; +using System.Net; +using System.Text.Json; +using System.Text.RegularExpressions; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; +using System.Globalization; + +namespace SharpCrafters.Backstage.LicenseServer.Tests; + +/// +/// The pages an administrator uses, exercised through the real pipeline. +/// +public sealed partial class PageTests : IDisposable +{ + private readonly LicenseServerApplication application = new(); + + /// + /// Collapses every run of whitespace, so that an assertion on a sentence does not depend on where + /// the markup wraps it. + /// + [GeneratedRegex( @"\s+" )] + private static partial Regex Whitespace(); + + /// + /// Captures the action of a form and its contents, so that a test can submit it the way a browser + /// would. + /// + [GeneratedRegex( "]*action=\"([^\"]+)\"[^>]*>(.*?)", RegexOptions.Singleline )] + private static partial Regex Form(); + + [GeneratedRegex( "name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"" )] + private static partial Regex AntiforgeryToken(); + + public void Dispose() => this.application.Dispose(); + + [Theory] + [InlineData( "/" )] + [InlineData( "/Admin/AddLicense" )] + [InlineData( "/Admin/Export" )] + public async Task Page_IsServed( string url ) + { + var client = this.application.CreateClient(); + + var response = await client.GetAsync( url ); + + Assert.Equal( HttpStatusCode.OK, response.StatusCode ); + } + + [Fact] + public async Task Index_NoLicenses_InvitesTheAdministratorToAddOne() + { + var client = this.application.CreateClient(); + + var body = await client.GetStringAsync( "/" ); + + Assert.Contains( "No license has been registered yet", body, StringComparison.Ordinal ); + } + + [Fact] + public async Task Index_ListsTheLicense() + { + this.application.AddLicense( LicenseBuilder.Default().WithLicenseId( 42 ).WithUsers( 7 ) ); + var client = this.application.CreateClient(); + + var body = await client.GetStringAsync( "/" ); + + Assert.Contains( "42", body, StringComparison.Ordinal ); + Assert.Contains( "Ultimate", body, StringComparison.Ordinal ); + } + + [Fact] + public async Task Details_IsServed() + { + this.application.AddLicense( LicenseBuilder.Default().WithLicenseId( 3 ) ); + var client = this.application.CreateClient(); + + var response = await client.GetAsync( "/Admin/Details?id=3" ); + + Assert.Equal( HttpStatusCode.OK, response.StatusCode ); + } + + /// + /// The page states what a seat is, because it reports a number of them. The rule is stated with + /// the value this server is configured with, so an installation that changed it is not told the + /// default. + /// + [Fact] + public async Task Details_ExplainsWhatASeatIs() + { + this.application.AddLicense( LicenseBuilder.Default().WithLicenseId( 3 ) ); + var client = this.application.CreateClient(); + + // The markup wraps the sentence over several lines, and where it wraps is not what this test + // is about. + var body = Whitespace().Replace( await client.GetStringAsync( "/Admin/Details?id=3" ), " " ); + + // LicenseServerApplication configures two machines per seat. + Assert.Contains( "A seat is one user working on up to 2 machines.", body, StringComparison.Ordinal ); + + Assert.Contains( + "the number of machines divided by 2, rounded up", + body, + StringComparison.Ordinal ); + } + + /// + /// The actions that change a license sit in one menu at the top of the page, and each of them + /// carries the text of the confirmation it asks for before it runs. + /// + [Fact] + public async Task Details_OffersItsActionsInAMenu() + { + this.application.AddLicense( LicenseBuilder.Default().WithLicenseId( 3 ) ); + var client = this.application.CreateClient(); + + var body = await client.GetStringAsync( "/Admin/Details?id=3" ); + + Assert.Contains( ">Manage
", body, StringComparison.Ordinal ); + Assert.Contains( "handler=Disable", body, StringComparison.Ordinal ); + Assert.Contains( "data-confirm=\"Disable license 3?\"", body, StringComparison.Ordinal ); + + // An enabled license is not offered for deletion: it is disabled first. + Assert.DoesNotContain( "handler=Delete", body, StringComparison.Ordinal ); + } + + /// + /// A disabled license says so on the page itself, because the menu that holds the state is closed + /// until the administrator opens it. + /// + [Fact] + public async Task Details_DisabledLicense_SaysSoAndOffersToEnableOrDeleteIt() + { + this.application.AddLicense( LicenseBuilder.Default().WithLicenseId( 3 ).WithPriority( -1 ) ); + var client = this.application.CreateClient(); + + var body = Whitespace().Replace( await client.GetStringAsync( "/Admin/Details?id=3" ), " " ); + + Assert.Contains( "This license is disabled: it serves no new lease.", body, StringComparison.Ordinal ); + Assert.Contains( "handler=Enable", body, StringComparison.Ordinal ); + Assert.Contains( "handler=Delete", body, StringComparison.Ordinal ); + } + + /// + /// The action posts against the license the page is about. The license is named in the query + /// string, which a form does not inherit from the page that contains it, so an action that does + /// not name it again reaches no license at all. + /// + [Fact] + public async Task Details_Disable_DisablesThatLicense() + { + this.application.AddLicense( LicenseBuilder.Default().WithLicenseId( 3 ) ); + + var response = await this.SubmitDetailsActionAsync( 3, "handler=Disable" ); + + Assert.Equal( HttpStatusCode.Found, response.StatusCode ); + + using var db = this.application.CreateDbContext(); + + Assert.True( db.Licenses.Single( l => l.LicenseId == 3 ).Priority < 0 ); + } + + [Fact] + public async Task Details_Delete_RemovesThatLicense() + { + this.application.AddLicense( LicenseBuilder.Default().WithLicenseId( 3 ).WithPriority( -1 ) ); + this.application.AddLicense( LicenseBuilder.Default().WithLicenseId( 4 ) ); + + var response = await this.SubmitDetailsActionAsync( 3, "handler=Delete" ); + + Assert.Equal( HttpStatusCode.Found, response.StatusCode ); + + using var db = this.application.CreateDbContext(); + + Assert.Equal( [4], db.Licenses.Select( l => l.LicenseId ).ToArray() ); + } + + [Fact] + public async Task Details_UnknownLicense_Returns404() + { + var client = this.application.CreateClient(); + + Assert.Equal( HttpStatusCode.NotFound, ( await client.GetAsync( "/Admin/Details?id=999" ) ).StatusCode ); + } + + [Fact] + public async Task Graph_IsServedWithItsDataEmbedded() + { + this.application.AddLicense( LicenseBuilder.Default().WithLicenseId( 5 ).WithUsers( 10 ).WithGracePercent( 20 ) ); + var client = this.application.CreateClient(); + + var body = await client.GetStringAsync( "/Graph?id=5&days=30" ); + + Assert.Contains( "usage-chart-data", body, StringComparison.Ordinal ); + + // Served from the application, not from a content delivery network. + Assert.Contains( "/lib/chartjs/chart.umd.min.js", body, StringComparison.Ordinal ); + Assert.DoesNotContain( "yui.yahooapis.com", body, StringComparison.Ordinal ); + Assert.DoesNotContain( "http://", body, StringComparison.Ordinal ); + } + + [Fact] + public async Task Graph_EmbeddedDataIsValidJsonCoveringTheWindow() + { + this.application.AddLicense( LicenseBuilder.Default().WithLicenseId( 5 ).WithUsers( 10 ).WithGracePercent( 20 ) ); + var client = this.application.CreateClient(); + + var body = await client.GetStringAsync( "/Graph?id=5&days=90" ); + var json = ExtractChartData( body ); + + using var document = JsonDocument.Parse( json ); + var root = document.RootElement; + + Assert.Equal( 90, root.GetProperty( "labels" ).GetArrayLength() ); + Assert.Equal( 90, root.GetProperty( "seats" ).GetArrayLength() ); + Assert.Equal( 10, root.GetProperty( "maximum" ).GetInt32() ); + Assert.Equal( 12, root.GetProperty( "grace" ).GetInt32() ); + Assert.True( root.GetProperty( "axisMaximum" ).GetInt32() >= 12 ); + } + + [Fact] + public async Task Graph_UnlimitedLicense_OmitsTheCapacityLines() + { + this.application.AddLicense( LicenseBuilder.Default().WithLicenseId( 6 ).WithUsers( null ) ); + var client = this.application.CreateClient(); + + var json = ExtractChartData( await client.GetStringAsync( "/Graph?id=6" ) ); + + using var document = JsonDocument.Parse( json ); + + Assert.Equal( JsonValueKind.Null, document.RootElement.GetProperty( "maximum" ).ValueKind ); + Assert.Equal( JsonValueKind.Null, document.RootElement.GetProperty( "grace" ).ValueKind ); + } + + [Fact] + public async Task Graph_UnknownLicense_Returns404() + { + var client = this.application.CreateClient(); + + Assert.Equal( HttpStatusCode.NotFound, ( await client.GetAsync( "/Graph?id=999" ) ).StatusCode ); + } + + /// + /// The legacy page answered 500 for anything it could not parse. + /// + [Theory] + [InlineData( "/Graph?id=5&days=7" )] + [InlineData( "/Graph?id=5&days=99999" )] + public async Task Graph_UnsupportedWindow_Returns400( string url ) + { + this.application.AddLicense( LicenseBuilder.Default().WithLicenseId( 5 ) ); + var client = this.application.CreateClient(); + + Assert.Equal( HttpStatusCode.BadRequest, ( await client.GetAsync( url ) ).StatusCode ); + } + + [Fact] + public async Task GetTime_ReportsTheTimeAndTheAcceleration() + { + var client = this.application.CreateClient(); + + var body = await client.GetStringAsync( "/GetTime.ashx" ); + var fields = body.Split( ';' ); + + // The simulator parses this positionally. + Assert.Equal( 2, fields.Length ); + Assert.EndsWith( "Z", fields[0], StringComparison.Ordinal ); + Assert.Equal( 1m, decimal.Parse( fields[1], CultureInfo.InvariantCulture ) ); + } + + [Theory] + [InlineData( "/Default.aspx", "/" )] + [InlineData( "/Graph.aspx?id=5", "/Graph?id=5" )] + [InlineData( "/Admin/Details.aspx?id=5", "/Admin/Details?id=5" )] + [InlineData( "/Admin/AddLicense.aspx", "/Admin/AddLicense" )] + public async Task LegacyUrl_RedirectsPermanently( string legacy, string expected ) + { + var client = this.application.CreateClient( new WebApplicationFactoryClientOptions { AllowAutoRedirect = false } ); + + var response = await client.GetAsync( legacy ); + + Assert.Equal( HttpStatusCode.MovedPermanently, response.StatusCode ); + Assert.Equal( expected, response.Headers.Location?.OriginalString ); + } + + [Fact] + public async Task DemoDataGenerator_IsNotAvailableOutsideDevelopment() + { + var client = this.application.CreateClient(); + + Assert.Equal( HttpStatusCode.NotFound, ( await client.GetAsync( "/Admin/GenerateDemoData" ) ).StatusCode ); + } + + /// + /// Submits one of the management forms of the details page as a browser would, with the action and + /// the antiforgery token the page itself supplies. + /// + private async Task SubmitDetailsActionAsync( int licenseId, string handler ) + { + var client = this.application.CreateClient( new WebApplicationFactoryClientOptions { AllowAutoRedirect = false } ); + + var page = await client.GetStringAsync( $"/Admin/Details?id={licenseId}" ); + + var form = Form().Matches( page ).SingleOrDefault( m => m.Groups[1].Value.Contains( handler, StringComparison.Ordinal ) ) + ?? throw new InvalidOperationException( $"The page has no form posting to {handler}." ); + + var token = AntiforgeryToken().Match( form.Groups[2].Value ); + Assert.True( token.Success, "The form carries no antiforgery token." ); + + return await client.PostAsync( + WebUtility.HtmlDecode( form.Groups[1].Value ), + new FormUrlEncodedContent( new Dictionary { ["__RequestVerificationToken"] = token.Groups[1].Value } ) ); + } + + private static string ExtractChartData( string html ) + { + const string opening = "", start, StringComparison.Ordinal ); + + return WebUtility.HtmlDecode( html[start..end] ); + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/ProductCodesTests.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/ProductCodesTests.cs new file mode 100644 index 0000000..e073de4 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/ProductCodesTests.cs @@ -0,0 +1,87 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using SharpCrafters.Backstage.LicenseServer.Licensing; +using SharpCrafters.Backstage.LicenseServer.Services; +using SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; +using SharpCrafters.Backstage.Licensing; + +namespace SharpCrafters.Backstage.LicenseServer.Tests; + +/// +/// The ProductCode column of an installation that has been upgraded holds the product names of +/// the PostSharp SDK, while the clients of the Backstage generation ask by the names of the Backstage +/// enumeration. A request that names a product has to find the licenses under either spelling. +/// +public sealed class ProductCodesTests +{ + [Theory] + [InlineData( LicenseProduct.PostSharpUltimate, "PostSharpUltimate", "Ultimate" )] + [InlineData( LicenseProduct.PostSharpFramework, "PostSharpFramework", "Framework" )] + [InlineData( LicenseProduct.PostSharpCachingLibrary, "PostSharpCachingLibrary", "CachingLibrary" )] + [InlineData( LicenseProduct.PostSharpDiagnosticsLibrary, "PostSharpDiagnosticsLibrary", "DiagnosticsLibrary" )] + [InlineData( LicenseProduct.PostSharpModelLibrary, "PostSharpModelLibrary", "ModelLibrary" )] + [InlineData( LicenseProduct.PostSharpThreadingLibrary, "PostSharpThreadingLibrary", "ThreadingLibrary" )] + public void RenamedProduct_IsMatchedUnderBothSpellings( LicenseProduct product, string current, string legacy ) + { + Assert.Equal( current, ProductCodes.ForStorage( product ) ); + Assert.Equal( [current, legacy], ProductCodes.Matching( current ) ); + Assert.Equal( [legacy, current], ProductCodes.Matching( legacy ) ); + } + + /// + /// The products created after the renaming, and every value that the server does not know, match + /// only themselves. + /// + [Theory] + [InlineData( "MetalamaProfessional" )] + [InlineData( "PostSharpEssentials" )] + [InlineData( "SomethingNobodyHasHeardOf" )] + public void ProductWithOneSpelling_IsMatchedByItself( string productCode ) => Assert.Equal( [productCode], ProductCodes.Matching( productCode ) ); + + /// + /// The server serves a license added by an earlier version to a client that asks for the same + /// product by its Backstage name. Without the mapping, the server would deny the request while + /// the license is present, which is what an upgraded installation would observe. + /// + [Fact] + public async Task LicenseStoredUnderTheLegacyName_IsServedToAClientAskingForTheBackstageName() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + LicenseBuilder.Default().WithProduct( "Ultimate" ).WithUsers( 5 ).AddTo( context ); + + var lease = await context.LeaseService.GetLicenseLeaseAsync( + "PostSharpUltimate", + new Version( 2025, 1, 0 ), + null, + "desktop-1", + "alice", + "alice", + TestClock.Origin, + [] ); + + Assert.NotNull( lease ); + } + + /// + /// The mapping adds the other spelling of a product to a request. It does not make one product + /// match another product. + /// + [Fact] + public async Task LicenseOfAnotherProduct_IsStillNotServed() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + LicenseBuilder.Default().WithProduct( "Ultimate" ).WithUsers( 5 ).AddTo( context ); + + var lease = await context.LeaseService.GetLicenseLeaseAsync( + "PostSharpFramework", + new Version( 2025, 1, 0 ), + null, + "desktop-1", + "alice", + "alice", + TestClock.Origin, + [] ); + + Assert.Null( lease ); + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/ReviewRegressionTests.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/ReviewRegressionTests.cs new file mode 100644 index 0000000..38d4135 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/ReviewRegressionTests.cs @@ -0,0 +1,154 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.AspNetCore.Http; +using System.Net; +using Microsoft.AspNetCore.Mvc.Testing; +using SharpCrafters.Backstage.LicenseServer.Endpoints; +using SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +namespace SharpCrafters.Backstage.LicenseServer.Tests; + +/// +/// The defects found during the review of the migration. Each of them passed unnoticed, because the +/// legacy implementation behaved in the same way. +/// +public sealed class ReviewRegressionTests : IDisposable +{ + private readonly LicenseServerApplication application = new(); + + public void Dispose() => this.application.Dispose(); + + /// + /// A build agent is exempt from consuming a seat. It is not exempt from the rules that decide + /// which licenses may be served. The legacy code verified only that the key parsed. + /// + [Fact] + public async Task BuildAgent_IneligibleLicense_IsNotServed() + { + this.application.AddLicense( LicenseBuilder.Default().NotLicenseServerEligible() ); + var client = this.application.CreateClient(); + + var response = await client.GetAsync( "/Lease.ashx?user=alice&machine=buildagent-1f2e&product=Ultimate&version=2025.1.0" ); + + Assert.Equal( HttpStatusCode.Forbidden, response.StatusCode ); + + Assert.Contains( + "cannot be used in the license server", + await response.Content.ReadAsStringAsync(), + StringComparison.Ordinal ); + } + + [Fact] + public async Task BuildAgent_LicenseRequiringANewerClient_IsNotServed() + { + this.application.AddLicense( LicenseBuilder.Default().WithMinPostSharpVersion( new Version( 2099, 1, 0 ) ) ); + + var client = this.application.CreateClient(); + + var response = await client.GetAsync( "/Lease.ashx?user=alice&machine=buildagent-1f2e&product=Ultimate&version=2025.1.0" ); + + Assert.Equal( HttpStatusCode.Forbidden, response.StatusCode ); + } + + /// + /// The server must not give an expired license to a build agent with three more days of + /// validity. + /// + [Fact] + public async Task BuildAgent_ExpiredLicense_IsNotServed() + { + this.application.AddLicense( LicenseBuilder.Default().WithValidTo( new DateTime( 2020, 1, 1, 0, 0, 0, DateTimeKind.Utc ) ) ); + + var client = this.application.CreateClient(); + + var response = await client.GetAsync( "/Lease.ashx?user=alice&machine=buildagent-1f2e&product=Ultimate&version=2025.1.0" ); + + Assert.Equal( HttpStatusCode.Forbidden, response.StatusCode ); + } + + [Fact] + public async Task BuildAgent_ValidLicense_IsStillServedWithoutALease() + { + this.application.AddLicense( LicenseBuilder.Default().WithUsers( 5 ) ); + var client = this.application.CreateClient(); + + var response = await client.GetAsync( "/Lease.ashx?user=alice&machine=buildagent-1f2e&product=Ultimate&version=2025.1.0" ); + + Assert.Equal( HttpStatusCode.OK, response.StatusCode ); + + await using var db = this.application.CreateDbContext(); + Assert.Empty( db.Leases ); + } + + /// + /// A license with no seat limit has no capacity to exceed, so it has no grace period. When such + /// a license reached the grace pass, the code read a maximum that was null and the server + /// answered with the status 500 instead of denying the request. + /// + [Fact] + public async Task UnlimitedButExpiredLicense_IsDeniedRatherThanFailing() + { + this.application.AddLicense( + LicenseBuilder.Default() + .WithUsers( null ) + .WithValidTo( new DateTime( 2020, 1, 1, 0, 0, 0, DateTimeKind.Utc ) ) ); + + var client = this.application.CreateClient(); + + var response = await client.GetAsync( "/Lease.ashx?user=alice&machine=desktop-1&product=Ultimate&version=2025.1.0" ); + + Assert.Equal( HttpStatusCode.Forbidden, response.StatusCode ); + } + + /// + /// A year outside the range of a date passed the validation, and the construction of the date + /// then raised an exception. + /// + [Theory] + [InlineData( "fy=10000&fm=1&ty=10000&tm=2" )] + [InlineData( "fy=0&fm=1&ty=2026&tm=2" )] + [InlineData( "fy=2026&fm=13&ty=2026&tm=1" )] + [InlineData( "fy=2026&fm=1&ty=2026&tm=0" )] + public async Task Export_OutOfRangeMonthOrYear_Returns400( string query ) + { + var client = this.application.CreateClient(); + + Assert.Equal( + HttpStatusCode.BadRequest, + ( await client.GetAsync( $"/Admin/Export.ashx?{query}" ) ).StatusCode ); + } + + [Fact] + public async Task Export_ValidRange_IsStillServed() + { + var client = this.application.CreateClient(); + + Assert.Equal( + HttpStatusCode.OK, + ( await client.GetAsync( "/Admin/Export.ashx?fy=2026&fm=1&ty=2026&tm=12" ) ).StatusCode ); + } + + /// + /// When the server is installed below the root of a site, a redirection must contain the path + /// base. Otherwise it reaches the parent site. + /// + [Theory] + [InlineData( "/LicenseServer", "/Graph", "?id=5", "/LicenseServer/Graph?id=5" )] + [InlineData( "/LicenseServer", "/", "", "/LicenseServer" )] + [InlineData( "/LicenseServer", "/Admin/Details", "?id=5", "/LicenseServer/Admin/Details?id=5" )] + [InlineData( "", "/Graph", "?id=5", "/Graph?id=5" )] + [InlineData( "", "/", "", "/" )] + public void LegacyUrl_KeepsThePathBaseAndTheQueryString( + string pathBase, + string target, + string queryString, + string expected ) + { + Assert.Equal( + expected, + LegacyUrlRedirects.BuildRedirectLocation( + new PathString( pathBase.Length == 0 ? null : pathBase ), + target, + new QueryString( queryString.Length == 0 ? null : queryString ) ) ); + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/SchemaCompatibilityTests.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/SchemaCompatibilityTests.cs new file mode 100644 index 0000000..42282ba --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/SchemaCompatibilityTests.cs @@ -0,0 +1,187 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.EntityFrameworkCore; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +namespace SharpCrafters.Backstage.LicenseServer.Tests; + +/// +/// The schema an existing installation already has, which this version must keep using unchanged. +/// +/// +/// The project contains no EF migration. CreateTables.sql defines the schema, and the server +/// never creates and never modifies a SQL Server schema. These tests replace a migration: they +/// verify that the model uses the column types, the generation of the keys and the names of the +/// constraints that CreateTables.sql produces. Without them, a customer would discover a +/// mapping that changed, on their own data. +/// +public sealed class SchemaCompatibilityTests +{ + /// + /// Generates the SQL Server DDL for the model. No server is contacted. + /// + private static string CreateScript() + { + var options = + new DbContextOptionsBuilder() + .UseSqlServer( "Server=none;Database=none;" ) + .Options; + + using LicenseServerDbContext db = new( options ); + + return db.Database.GenerateCreateScript(); + } + + [Fact] + public void Licenses_LicenseKeyIsStillText() + { + // CreateTables.sql declares [LicenseKey] [text]. Letting EF default to nvarchar(max) would + // generate a schema that does not match an existing database. + Assert.Contains( "[LicenseKey] text NOT NULL", CreateScript(), StringComparison.OrdinalIgnoreCase ); + } + + /// + /// In Transact-SQL, a text column cannot appear in a comparison, in an ORDER BY clause, in + /// a GROUP BY clause, or in a DISTINCT clause, and the query fails at run time. A query may + /// therefore only read the license key. + /// + [Fact] + public void Licenses_LicenseKeyIsNeverFilteredOrSorted() + { + var options = + new DbContextOptionsBuilder() + .UseSqlServer( "Server=none;Database=none;" ) + .Options; + + using LicenseServerDbContext db = new( options ); + + var sql = db.Licenses.OrderBy( l => l.Priority ).ThenByDescending( l => l.LicenseId ).ToQueryString(); + + Assert.Contains( "[LicenseKey]", sql, StringComparison.OrdinalIgnoreCase ); + Assert.DoesNotContain( "ORDER BY [l].[LicenseKey]", sql, StringComparison.OrdinalIgnoreCase ); + } + + [Fact] + public void Licenses_LicenseIdIsAssignedByTheApplication() + { + // The identifier comes from the license key. Making it an identity column would both break + // an existing database and lose the link between the row and the key. + var script = CreateScript(); + var licensesTable = script.IndexOf( "CREATE TABLE [Licenses]", StringComparison.OrdinalIgnoreCase ); + + Assert.True( licensesTable >= 0 ); + + var licenses = script[licensesTable..script.IndexOf( ");", licensesTable, StringComparison.Ordinal )]; + + Assert.Contains( "[LicenseId] int NOT NULL", licenses, StringComparison.OrdinalIgnoreCase ); + Assert.DoesNotContain( "IDENTITY", licenses, StringComparison.OrdinalIgnoreCase ); + } + + [Fact] + public void Leases_LeaseIdIsGeneratedByTheDatabase() + { + Assert.Contains( "[LeaseId] int NOT NULL IDENTITY", CreateScript(), StringComparison.OrdinalIgnoreCase ); + } + + [Theory] + + // Types as declared by CreateTables.sql. + [InlineData( "[ProductCode] varchar(50) NOT NULL" )] + [InlineData( "[Priority] int NOT NULL" )] + [InlineData( "[CreatedOn] datetime NOT NULL" )] + [InlineData( "[GraceStartTime] datetime NULL" )] + [InlineData( "[GraceLastWarningTime] datetime NULL" )] + [InlineData( "[StartTime] datetime NOT NULL" )] + [InlineData( "[EndTime] datetime NOT NULL" )] + [InlineData( "[UserName] nvarchar(200) NOT NULL" )] + [InlineData( "[Machine] nvarchar(200) NOT NULL" )] + [InlineData( "[AuthenticatedUser] nvarchar(200) NOT NULL" )] + [InlineData( "[Grace] bit NOT NULL" )] + [InlineData( "[OverwrittenLeaseId] int NULL" )] + [InlineData( "[LicenseId] int NOT NULL" )] + public void Column_KeepsItsType( string expected ) => Assert.Contains( expected, CreateScript(), StringComparison.OrdinalIgnoreCase ); + + /// + /// The timestamps keep the type datetime. The default type of EF is datetime2, and + /// SQL Server then converts the column at every comparison of the start time or of the end time + /// of a lease. + /// + [Fact] + public void Timestamps_AreNeverDateTime2() => Assert.DoesNotContain( "datetime2", CreateScript(), StringComparison.OrdinalIgnoreCase ); + + [Theory] + [InlineData( "PK_Licenses" )] + [InlineData( "PK_Leases" )] + [InlineData( "FK_Leases_Licenses" )] + [InlineData( "FK_Leases_Leases" )] + [InlineData( "IX_Leases_EndTime" )] + [InlineData( "IX_Leases_OverwrittenLeaseId" )] + public void Constraint_KeepsItsName( string expected ) => Assert.Contains( expected, CreateScript(), StringComparison.Ordinal ); + + [Fact] + public void Tables_KeepTheirNames() + { + var script = CreateScript(); + + Assert.Contains( "CREATE TABLE [Licenses]", script, StringComparison.OrdinalIgnoreCase ); + Assert.Contains( "CREATE TABLE [Leases]", script, StringComparison.OrdinalIgnoreCase ); + } + + /// + /// The deletion of a license must not cascade through the chain of replaced leases. + /// + [Fact] + public void ForeignKeys_DoNotCascade() => Assert.DoesNotContain( "ON DELETE CASCADE", CreateScript(), StringComparison.OrdinalIgnoreCase ); + + /// + /// The database of an existing installation contains the column HMAC, which held the + /// signature of the audit log. The model no longer maps that column. The column is nullable, so + /// the server keeps writing to such a database, and it leaves the column empty. + /// + [Fact] + public async Task LegacyHmacColumn_IsIgnored() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + + // On SQL Server the column is already there, because CreateTables.sql declares it. On the + // other two engines the schema does not declare it, so the test adds it. This test modifies + // the schema, so the database must not serve another test. + if ( !TestDatabases.UsesSqlServer ) + { + context.DoNotReuseDatabase(); + + // PostgreSQL folds an identifier that is not quoted to lower case, and the tables of this + // schema keep their capitals. + await context.Db.Database.ExecuteSqlRawAsync( + TestDatabases.UsesPostgreSql + ? "ALTER TABLE \"Leases\" ADD COLUMN \"HMAC\" varchar(100) NULL" + : "ALTER TABLE Leases ADD COLUMN HMAC varchar(100) NULL" ); + } + + var license = LicenseBuilder.Default().AddTo( context ); + var lease = LeaseBuilder.For( license ).AddTo( context ); + + Assert.Equal( 1, await context.Db.Leases.CountAsync( l => l.LeaseId == lease.LeaseId ) ); + } + + [Fact] + public void Model_HasExactlyTheTwoExpectedTables() + { + var options = + new DbContextOptionsBuilder() + .UseSqlServer( "Server=none;Database=none;" ) + .Options; + + using LicenseServerDbContext db = new( options ); + + string[] tables = db.Model.GetEntityTypes() + .Select( e => e.GetTableName() ) + .Where( name => name != null ) + .Distinct() + .Order() + .ToArray()!; + + Assert.Equal( ["Leases", "Licenses"], tables ); + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/SeatCountingTests.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/SeatCountingTests.cs new file mode 100644 index 0000000..30bbee8 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/SeatCountingTests.cs @@ -0,0 +1,50 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using SharpCrafters.Backstage.LicenseServer.Data; + +namespace SharpCrafters.Backstage.LicenseServer.Tests; + +/// +/// The rounding rule that decides how many seats a group of users consumes. This arithmetic used to +/// run inside a SQL GROUP BY clause, where no test could reach it. +/// +public sealed class SeatCountingTests +{ + [Fact] + public void CountSeats_NoUsers_ReturnsZero() => Assert.Equal( 0, SeatCounter.CountSeats( [], 2 ) ); + + [Theory] + + // One user, N machines, two machines per seat. + [InlineData( 1, 1 )] + [InlineData( 2, 1 )] + [InlineData( 3, 2 )] + [InlineData( 4, 2 )] + [InlineData( 5, 3 )] + public void CountSeats_SingleUser_RoundsMachinesUp( int machines, int expectedSeats ) + => Assert.Equal( expectedSeats, SeatCounter.CountSeats( [machines], 2 ) ); + + [Fact] + public void CountSeats_TwoUsersOneMachineEach_ConsumesTwoSeats() => Assert.Equal( 2, SeatCounter.CountSeats( [1, 1], 2 ) ); + + [Fact] + public void CountSeats_TwoUsersTwoMachinesEach_ConsumesTwoSeats() => Assert.Equal( 2, SeatCounter.CountSeats( [2, 2], 2 ) ); + + [Fact] + public void CountSeats_RoundsUpPerUserNotInTotal() + { + // Three machines for one user and one for another is 3 seats, not ceil(4/2) == 2. + Assert.Equal( 3, SeatCounter.CountSeats( [3, 1], 2 ) ); + } + + [Theory] + [InlineData( 1, 3, 3 )] + [InlineData( 3, 3, 1 )] + [InlineData( 3, 4, 2 )] + [InlineData( 3, 7, 3 )] + public void CountSeats_HonoursMachinesPerUser( int machinesPerUser, int machines, int expectedSeats ) + => Assert.Equal( expectedSeats, SeatCounter.CountSeats( [machines], machinesPerUser ) ); + + [Fact] + public void CountSeats_MachinesPerUserBelowOne_Throws() => Assert.Throws( () => SeatCounter.CountSeats( [1], 0 ) ); +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/SeedTestLicensesTests.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/SeedTestLicensesTests.cs new file mode 100644 index 0000000..ed4b0c3 --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/SeedTestLicensesTests.cs @@ -0,0 +1,178 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using SharpCrafters.Backstage.LicenseServer.Data; +using SharpCrafters.Backstage.LicenseServer.Licensing; +using SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; + +// Aliased because SharpCrafters.Backstage.LicenseServer.Licensing, the namespace of this product, +// shadows SharpCrafters.Backstage.Licensing, the namespace of the licensing component. +using LicenseProduct = SharpCrafters.Backstage.Licensing.LicenseProduct; +using LicenseType = SharpCrafters.Backstage.Licensing.LicenseType; + +namespace SharpCrafters.Backstage.LicenseServer.Tests; + +/// +/// The license keys a development server issues to itself, so that a trial or a load simulation has +/// something to lease without anybody buying a license first. +/// +public sealed class SeedTestLicensesTests : IDisposable +{ + private readonly string dataDirectory = + Path.Combine( Path.GetTempPath(), "licenseserver-tests", Guid.NewGuid().ToString( "N" ) ); + + public void Dispose() + { + if ( Directory.Exists( this.dataDirectory ) ) + { + Directory.Delete( this.dataDirectory, true ); + } + } + + private string KeyFile => Path.Combine( this.dataDirectory, "test-authority.key" ); + + private TestLicenseAuthority CreateAuthority() => TestLicenseAuthority.LoadOrCreate( this.KeyFile, NullLogger.Instance ); + + /// + /// The key pair has to outlive the process, because the license keys it signed are in the + /// database and stop verifying when the pair changes. In a container that is what makes the data + /// directory a volume rather than part of the container. + /// + [Fact] + public void Authority_IsReusedAcrossProcesses() + { + var licenseKey = this.CreateAuthority() + .CreateLicenseKey( 900001, LicenseProduct.MetalamaProfessional, LicenseType.Business, 25, 5, 20, DateTime.UtcNow.AddYears( 1 ) ); + + Assert.True( File.Exists( this.KeyFile ) ); + + // A second instance reads the file rather than generating a new pair, so a key signed before + // a restart is still accepted after it. + BackstageLicenseParser parser = new( this.CreateAuthority().Authority ); + + Assert.NotNull( parser.TryParse( licenseKey ) ); + } + + [Fact] + public void Authority_OfAnotherServer_IsNotAccepted() + { + var licenseKey = this.CreateAuthority() + .CreateLicenseKey( 900001, LicenseProduct.MetalamaProfessional, LicenseType.Business, 25, 5, 20, DateTime.UtcNow.AddYears( 1 ) ); + + var otherDirectory = Path.Combine( this.dataDirectory, "other" ); + + var other = + TestLicenseAuthority.LoadOrCreate( Path.Combine( otherDirectory, "test-authority.key" ), NullLogger.Instance ); + + Assert.Null( new BackstageLicenseParser( other.Authority ).TryParse( licenseKey ) ); + } + + [Fact] + public async Task Seed_EmptyDatabase_AddsLicensesThisServerAccepts() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var authority = this.CreateAuthority(); + + var added = TestLicenseSeeder.Seed( + context.Db, + authority, + TimeProvider.System, + NullLogger.Instance ); + + Assert.Equal( 2, added.Count ); + + BackstageLicenseParser parser = new( authority.Authority ); + + foreach ( var license in context.Db.Licenses ) + { + var parsed = parser.TryParse( license.LicenseKey ); + + Assert.NotNull( parsed ); + Assert.True( parsed.IsLicenseServerEligible ); + Assert.Equal( license.ProductCode, parsed.Product ); + } + } + + /// + /// Seeding twice leaves the first set alone, so a server restarted against the same database + /// keeps the leases it has granted. + /// + [Fact] + public async Task Seed_Twice_AddsNothingTheSecondTime() + { + await using var context = await LicenseServerTestContext.CreateAsync(); + var authority = this.CreateAuthority(); + + TestLicenseSeeder.Seed( context.Db, authority, TimeProvider.System, NullLogger.Instance ); + + // The keys are sorted after they are read. On SQL Server the column has the type text, which + // Transact-SQL refuses to sort, and the query would fail. This is the rule that + // SchemaCompatibilityTests states for the queries of the server. + var first = ReadKeys( context ); + + Assert.Empty( TestLicenseSeeder.Seed( context.Db, authority, TimeProvider.System, NullLogger.Instance ) ); + Assert.Equal( first, ReadKeys( context ) ); + + static string[] ReadKeys( LicenseServerTestContext context ) => context.Db.Licenses.Select( l => l.LicenseKey ).AsEnumerable().Order().ToArray(); + } + + /// + /// Outside the Development environment, the server refuses to start instead of serving license + /// keys that it issued to itself. + /// + [Theory] + [InlineData( "Production" )] + [InlineData( "Staging" )] + public void SeedTestLicenses_OutsideDevelopment_RefusesToStart( string environmentName ) + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection( new Dictionary { ["LicenseServer:SeedTestLicenses"] = "true" } ) + .Build(); + + ServiceCollection services = []; + + var exception = Assert.Throws( () => services.AddLicenseServerLicensing( + configuration, + new StubEnvironment( environmentName, this.dataDirectory ) ) ); + + Assert.Contains( "SeedTestLicenses", exception.Message, StringComparison.Ordinal ); + Assert.Contains( environmentName, exception.Message, StringComparison.Ordinal ); + } + + /// + /// A server that asks for none of this starts with the production authority alone, which is what + /// the container image does by default. + /// + [Fact] + public void NoTestSettings_OutsideDevelopment_Starts() + { + IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection( [] ).Build(); + + ServiceCollection services = []; + + Assert.Empty( services.AddLicenseServerLicensing( configuration, new StubEnvironment( "Production", this.dataDirectory ) ) ); + Assert.Null( services.BuildServiceProvider().GetService() ); + } + + private sealed class StubEnvironment : IHostEnvironment + { + public StubEnvironment( string environmentName, string contentRootPath = "." ) + { + this.EnvironmentName = environmentName; + this.ContentRootPath = contentRootPath; + } + + public string EnvironmentName { get; set; } + + public string ApplicationName { get; set; } = "Tests"; + + public string ContentRootPath { get; set; } + + public IFileProvider ContentRootFileProvider { get; set; } = + new NullFileProvider(); + } +} \ No newline at end of file diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/SharpCrafters.Backstage.LicenseServer.Tests.csproj b/tests/SharpCrafters.Backstage.LicenseServer.Tests/SharpCrafters.Backstage.LicenseServer.Tests.csproj new file mode 100644 index 0000000..45d9a4d --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/SharpCrafters.Backstage.LicenseServer.Tests.csproj @@ -0,0 +1,47 @@ + + + + net10.0 + SharpCrafters.Backstage.LicenseServer.Tests + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/SharpCrafters.Backstage.LicenseServer.Tests/TestLicensingAuthorityTests.cs b/tests/SharpCrafters.Backstage.LicenseServer.Tests/TestLicensingAuthorityTests.cs new file mode 100644 index 0000000..e8ec7eb --- /dev/null +++ b/tests/SharpCrafters.Backstage.LicenseServer.Tests/TestLicensingAuthorityTests.cs @@ -0,0 +1,162 @@ +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. + +using System.Globalization; +using System.Security.Cryptography; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using SharpCrafters.Backstage.LicenseServer.Licensing; +using SharpCrafters.Backstage.LicenseServer.Tests.Infrastructure; +using SharpCrafters.Backstage.Licensing.Licenses; + +namespace SharpCrafters.Backstage.LicenseServer.Tests; + +/// +/// The licensing authorities whose license keys a development server accepts, in addition to the +/// production authority. They let a load simulation run against license keys that are not sold. +/// +public sealed class TestLicensingAuthorityTests +{ + private const byte keyId = 200; + + /// + /// A license key signed by a configured authority is parsed, and the production authority still + /// works alongside it. + /// + [Fact] + public void ConfiguredAuthority_InDevelopment_IsAccepted() + { + var (publicKey, licenseKey) = CreateAuthorityAndKey(); + + var parser = BuildParser( "Development", ( keyId, publicKey ) ); + + Assert.NotNull( parser.TryParse( licenseKey ) ); + } + + /// + /// A license key signed by an authority that is not configured is rejected, so the setting widens + /// what the server accepts by exactly the keys it names. + /// + [Fact] + public void UnconfiguredAuthority_InDevelopment_IsRejected() + { + var (publicKey, _) = CreateAuthorityAndKey(); + var (_, otherLicenseKey) = CreateAuthorityAndKey(); + + var parser = BuildParser( "Development", ( keyId, publicKey ) ); + + Assert.Null( parser.TryParse( otherLicenseKey ) ); + } + + /// + /// Outside the Development environment, the server refuses to start instead of ignoring the + /// setting. The administrator who set it is informed, and no server accepts the license keys of + /// whoever holds the private key. + /// + [Theory] + [InlineData( "Production" )] + [InlineData( "Staging" )] + [InlineData( "Testing" )] + public void ConfiguredAuthority_OutsideDevelopment_RefusesToStart( string environmentName ) + { + var (publicKey, _) = CreateAuthorityAndKey(); + + var exception = Assert.Throws( () => BuildParser( environmentName, ( keyId, publicKey ) ) ); + + Assert.Contains( "TestLicensingAuthorities", exception.Message, StringComparison.Ordinal ); + Assert.Contains( environmentName, exception.Message, StringComparison.Ordinal ); + } + + /// + /// A server that configures none of these starts as it always did, and accepts the production + /// authority alone. + /// + [Fact] + public void NoConfiguredAuthority_OutsideDevelopment_Starts() + { + var (_, licenseKey) = CreateAuthorityAndKey(); + + var parser = BuildParser( "Production" ); + + Assert.Null( parser.TryParse( licenseKey ) ); + } + + /// + /// A key whose identifier is one of the production ones is refused, because the identifier is what + /// selects the key that verifies a signature and the production authority must keep its own. + /// + [Fact] + public void ConfiguredAuthority_TakingAProductionKeyIdentifier_IsRefused() + { + var (publicKey, _) = CreateAuthorityAndKey(); + + Assert.Throws( () => BuildParser( "Development", ( 2, publicKey ) ) ); + } + + /// + /// A malformed key fails at startup rather than in the middle of a lease request, which is when + /// the authority would otherwise first be built. + /// + [Fact] + public void ConfiguredAuthority_WithAMalformedKey_FailsAtStartup() + => Assert.ThrowsAny( () => BuildParser( "Development", ( keyId, "not-a-key" ) ) ); + + private static ILicenseParser BuildParser( string environmentName, params (int KeyId, string PublicKey)[] authorities ) + { + Dictionary settings = []; + + for ( var i = 0; i < authorities.Length; i++ ) + { + settings[$"LicenseServer:TestLicensingAuthorities:{i}:KeyId"] = authorities[i].KeyId.ToString( CultureInfo.InvariantCulture ); + settings[$"LicenseServer:TestLicensingAuthorities:{i}:PublicKey"] = authorities[i].PublicKey; + } + + IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection( settings ).Build(); + + ServiceCollection services = []; + services.AddLicenseServerLicensing( configuration, new StubEnvironment( environmentName ) ); + + return services.BuildServiceProvider().GetRequiredService(); + } + + /// + /// Creates a licensing authority and a license key that it signs. + /// + private static (string PublicKey, string LicenseKey) CreateAuthorityAndKey() + { + using var key = ECDsa.Create( ECCurve.NamedCurves.nistP256 ); + var parameters = key.ExportParameters( true ); + + string ToXml( bool includePrivateValue ) + => "nistP256" + + $"{Convert.ToBase64String( parameters.Q.X! )}" + + $"{Convert.ToBase64String( parameters.Q.Y! )}" + + ( includePrivateValue ? $"{Convert.ToBase64String( parameters.D! )}" : "" ) + + ""; + + var authority = + new ExplicitLicensingAuthorityProvider( ( keyId, ToXml( true ) ) ).GetAuthority( keyId ); + + var licenseKey = TestLicenseKeys.Builder().SignAndSerialize( authority ); + + return ( ToXml( false ), licenseKey ); + } + + private sealed class StubEnvironment : IHostEnvironment + { + public StubEnvironment( string environmentName ) + { + this.EnvironmentName = environmentName; + } + + public string EnvironmentName { get; set; } + + public string ApplicationName { get; set; } = "Tests"; + + public string ContentRootPath { get; set; } = AppContext.BaseDirectory; + + public IFileProvider ContentRootFileProvider { get; set; } = + new NullFileProvider(); + } +} \ No newline at end of file diff --git a/tests/docker/PostgreSql/Dockerfile b/tests/docker/PostgreSql/Dockerfile new file mode 100644 index 0000000..04d9677 --- /dev/null +++ b/tests/docker/PostgreSql/Dockerfile @@ -0,0 +1,34 @@ +# The image of the PostgreSQL test: the server the suite runs against, and the .NET SDK that runs the suite. +# +# The repository is mounted rather than copied, so this file installs software and nothing else. +# +# OS_IMAGE is read by DockerBuild.ps1, which replaces it with the same image in the registry named by +# DOCKER_REGISTRY when that registry is configured. An agent that cannot reach the mirror builds from the +# public image instead. +ARG OS_IMAGE=ubuntu:22.04 +FROM ${OS_IMAGE} + +ENV DEBIAN_FRONTEND=noninteractive +ENV LANG=C.UTF-8 +ENV LC_ALL=C.UTF-8 + +RUN apt-get update && apt-get install -y curl ca-certificates libicu70 libssl3 && rm -rf /var/lib/apt/lists/* + +# The packages come from the repository of the PostgreSQL project and not from Ubuntu, whose repository carries +# the version that shipped with the distribution. The tests therefore run against a version a customer can +# deploy today. jammy is Ubuntu 22.04. The server is installed but no cluster is created: a container image +# runs no service, and run.sh creates a cluster under /tmp and starts it. +RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc -o /etc/apt/trusted.gpg.d/postgresql.asc && echo "deb http://apt.postgresql.org/pub/repos/apt jammy-pgdg main" > /etc/apt/sources.list.d/pgdg.list && apt-get update && apt-get install -y postgresql-17 && rm -rf /var/lib/apt/lists/* + +ENV PGBINDIR=/usr/lib/postgresql/17/bin +ENV PATH="${PGBINDIR}:${PATH}" + +# The feature band and not a single version: global.json pins the SDK of the repository to 10.0.1xx and rolls +# forward over its patches alone, so an image carrying another band, such as 10.0.4xx, cannot build the suite. +# The band follows the DotNetSdkVersion that eng/src/Program.cs declares. +ENV DOTNET_ROOT=/usr/share/dotnet +ENV PATH="${DOTNET_ROOT}:${PATH}" +ENV DOTNET_NOLOGO=1 +ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 + +RUN curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh && chmod +x /tmp/dotnet-install.sh && /tmp/dotnet-install.sh --channel 10.0.1xx --install-dir $DOTNET_ROOT && rm /tmp/dotnet-install.sh diff --git a/tests/docker/PostgreSql/RunTest.ps1 b/tests/docker/PostgreSql/RunTest.ps1 new file mode 100644 index 0000000..a188e33 --- /dev/null +++ b/tests/docker/PostgreSql/RunTest.ps1 @@ -0,0 +1,28 @@ +# Runs the test suite against PostgreSQL, in a container that carries the server. +# +# The suite runs on SQLite in the ordinary build, which needs no server. PostgreSQL is one of the two engines +# that customers deploy, and it differs from SQLite in the collation, in the column types and in the lock that +# serializes the lease requests, so the same tests are run against it in full. + +param( + # The platform identifier the launcher selected, for example 'linux-x64'. + [Parameter( Mandatory = $true )] [string] $Platform +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# The operating system of the container engine to use. On a Windows development machine, Linux containers run +# on the engine inside the Windows Subsystem for Linux, and DockerBuild.ps1 re-executes itself there. +$os = if ( $Platform -like 'linux-*' ) { 'linux' } else { 'windows' } + +$arguments = @{ + Test = $true + OS = $os + Dockerfile = "$PSScriptRoot/Dockerfile" + Command = 'bash tests/docker/PostgreSql/run.sh' +} + +& "$PSScriptRoot/../../../DockerBuild.ps1" @arguments + +exit $LASTEXITCODE diff --git a/tests/docker/PostgreSql/run.sh b/tests/docker/PostgreSql/run.sh new file mode 100644 index 0000000..0f07530 --- /dev/null +++ b/tests/docker/PostgreSql/run.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Creates a PostgreSQL cluster in this container, starts it, and runs the test suite against it. +# +# The script runs inside the container, with the repository mounted and as the working directory. RunTest.ps1 +# is what starts the container; this file holds the part that needs no PowerShell, because a test image is +# chosen for the tool chain under test and carries no PowerShell. + +set -e + +data=/tmp/licenseserver-postgres + +mkdir -p "$data" +chown postgres "$data" + +# Both connection methods trust the client: the cluster lives in this container, it listens on the loopback +# address alone, and it is deleted with the container. +runuser -u postgres -- "$PGBINDIR/initdb" --pgdata="$data" --username=postgres --auth-local=trust --auth-host=trust > /tmp/initdb.log 2>&1 + +runuser -u postgres -- "$PGBINDIR/pg_ctl" --pgdata="$data" --log=/tmp/postgres.log --options="-c listen_addresses=127.0.0.1" --wait start + +if ! runuser -u postgres -- "$PGBINDIR/pg_isready" -h 127.0.0.1 > /dev/null 2>&1; then + echo "PostgreSQL did not accept a connection. The log of the server follows." + tail -50 /tmp/postgres.log + exit 1 +fi + +# The suite reads this variable and gives every test a database of its own, created from +# Database/CreateTables.PostgreSql.sql. The connection string names no database. +export LICENSESERVER_TEST_POSTGRESQL="Host=127.0.0.1;Port=5432;Username=postgres" + +# The restore and the build write outside the repository, and they read the packages of the licensing +# component from the source that nuget.config names. +# shellcheck source=../prepare-restore.sh +source tests/docker/prepare-restore.sh + +dotnet restore tests/SharpCrafters.Backstage.LicenseServer.Tests $RESTORE_ONLY_ARGUMENTS $COMMON_ARGUMENTS + +dotnet test tests/SharpCrafters.Backstage.LicenseServer.Tests --no-restore --nologo $COMMON_ARGUMENTS diff --git a/tests/docker/PostgreSql/test.psd1 b/tests/docker/PostgreSql/test.psd1 new file mode 100644 index 0000000..4c32b19 --- /dev/null +++ b/tests/docker/PostgreSql/test.psd1 @@ -0,0 +1,9 @@ +@{ + # PostgreSQL publishes packages for both architectures, so the test applies to both. The continuous + # integration build declares the amd64 platform alone, because the neighbouring SQL Server test needs it. + Platforms = @( 'linux-x64', 'linux-arm64' ) + + # The server is a fraction of the size of SQL Server, so the image costs less to acquire. The rest of the + # budget is the restore, the build and the suite. + TimeoutSeconds = 1800 +} diff --git a/tests/docker/SqlServer/Dockerfile b/tests/docker/SqlServer/Dockerfile new file mode 100644 index 0000000..defa23b --- /dev/null +++ b/tests/docker/SqlServer/Dockerfile @@ -0,0 +1,45 @@ +# The image of the SQL Server test: the server the suite runs against, and the .NET SDK that runs the suite. +# +# The repository is mounted rather than copied, so this file installs software and nothing else. +# +# OS_IMAGE is read by DockerBuild.ps1, which replaces it with the same image in the registry named by +# DOCKER_REGISTRY when that registry is configured. An agent that cannot reach the mirror builds from the +# public image instead. Ubuntu 22.04 is the distribution that Microsoft publishes the server packages for. +ARG OS_IMAGE=ubuntu:22.04 +FROM ${OS_IMAGE} + +ENV DEBIAN_FRONTEND=noninteractive +ENV LANG=C.UTF-8 +ENV LC_ALL=C.UTF-8 + +RUN apt-get update && apt-get install -y \ + curl \ + ca-certificates \ + libicu70 \ + libssl3 \ + && rm -rf /var/lib/apt/lists/* + +# SQL Server 2022 and the command-line tools, from the package repository of Microsoft. The server is +# installed but no instance is started: a container image runs no service, and RunTest.ps1 starts one. +RUN curl -fsSL https://packages.microsoft.com/keys/microsoft.asc -o /etc/apt/trusted.gpg.d/microsoft.asc \ + && curl -fsSL https://packages.microsoft.com/config/ubuntu/22.04/mssql-server-2022.list -o /etc/apt/sources.list.d/mssql-server-2022.list \ + && curl -fsSL https://packages.microsoft.com/config/ubuntu/22.04/prod.list -o /etc/apt/sources.list.d/microsoft-prod.list \ + && apt-get update \ + && apt-get install -y mssql-server \ + && ACCEPT_EULA=Y apt-get install -y mssql-tools18 \ + && rm -rf /var/lib/apt/lists/* + +ENV PATH="/opt/mssql-tools18/bin:${PATH}" + +# The feature band and not a single version: global.json pins the SDK of the repository to 10.0.1xx and rolls +# forward over its patches alone, so an image carrying another band, such as 10.0.4xx, cannot build the suite. +# The band follows the DotNetSdkVersion that eng/src/Program.cs declares. +ENV DOTNET_ROOT=/usr/share/dotnet +ENV PATH="${DOTNET_ROOT}:${PATH}" +ENV DOTNET_NOLOGO=1 +ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 + +RUN curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh \ + && chmod +x /tmp/dotnet-install.sh \ + && /tmp/dotnet-install.sh --channel 10.0.1xx --install-dir $DOTNET_ROOT \ + && rm /tmp/dotnet-install.sh diff --git a/tests/docker/SqlServer/RunTest.ps1 b/tests/docker/SqlServer/RunTest.ps1 new file mode 100644 index 0000000..2dd9c6e --- /dev/null +++ b/tests/docker/SqlServer/RunTest.ps1 @@ -0,0 +1,28 @@ +# Runs the test suite against SQL Server, in a container that carries the server. +# +# The suite runs on SQLite in the ordinary build, which needs no server. SQL Server is one of the two engines +# that customers deploy, and it differs from SQLite in the collation, in the column types and in the lock that +# serializes the lease requests, so the same tests are run against it in full. + +param( + # The platform identifier the launcher selected, for example 'linux-x64'. + [Parameter( Mandatory = $true )] [string] $Platform +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# The operating system of the container engine to use. On a Windows development machine, Linux containers run +# on the engine inside the Windows Subsystem for Linux, and DockerBuild.ps1 re-executes itself there. +$os = if ( $Platform -like 'linux-*' ) { 'linux' } else { 'windows' } + +$arguments = @{ + Test = $true + OS = $os + Dockerfile = "$PSScriptRoot/Dockerfile" + Command = 'bash tests/docker/SqlServer/run.sh' +} + +& "$PSScriptRoot/../../../DockerBuild.ps1" @arguments + +exit $LASTEXITCODE diff --git a/tests/docker/SqlServer/run.sh b/tests/docker/SqlServer/run.sh new file mode 100644 index 0000000..7607f39 --- /dev/null +++ b/tests/docker/SqlServer/run.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Starts the SQL Server of this container and runs the test suite against it. +# +# The script runs inside the container, with the repository mounted and as the working directory. RunTest.ps1 +# is what starts the container; this file holds the part that needs no PowerShell, because a test image is +# chosen for the tool chain under test and carries no PowerShell. + +set -e + +password="Lease-$(head -c 8 /dev/urandom | od -An -tx1 | tr -d ' \n')-1" + +export ACCEPT_EULA=Y +export MSSQL_SA_PASSWORD="$password" +export MSSQL_PID=Developer + +# The server refuses to run as root, and the container runs the command as root, so it runs under the account +# its own package creates. +runuser -u mssql -- /opt/mssql/bin/sqlservr > /tmp/sqlservr.log 2>&1 & + +for _ in $(seq 1 90); do + if /opt/mssql-tools18/bin/sqlcmd -S 127.0.0.1 -U sa -P "$password" -C -b -Q "SELECT 1" > /dev/null 2>&1; then + break + fi + + sleep 2 +done + +if ! /opt/mssql-tools18/bin/sqlcmd -S 127.0.0.1 -U sa -P "$password" -C -b -Q "SELECT 1" > /dev/null 2>&1; then + echo "SQL Server did not accept a query. The log of the server follows." + tail -50 /tmp/sqlservr.log + exit 1 +fi + +# The suite reads this variable and gives every test a database of its own, created from +# Database/CreateTables.sql. The connection string names no database. +export LICENSESERVER_TEST_SQLSERVER="Server=127.0.0.1,1433;User Id=sa;Password=$password;TrustServerCertificate=True;Encrypt=False" + +# The restore and the build write outside the repository, and they read the packages of the licensing +# component from the source that nuget.config names. +# shellcheck source=../prepare-restore.sh +source tests/docker/prepare-restore.sh + +dotnet restore tests/SharpCrafters.Backstage.LicenseServer.Tests $RESTORE_ONLY_ARGUMENTS $COMMON_ARGUMENTS + +dotnet test tests/SharpCrafters.Backstage.LicenseServer.Tests --no-restore --nologo $COMMON_ARGUMENTS diff --git a/tests/docker/SqlServer/test.psd1 b/tests/docker/SqlServer/test.psd1 new file mode 100644 index 0000000..7fe2bfa --- /dev/null +++ b/tests/docker/SqlServer/test.psd1 @@ -0,0 +1,9 @@ +@{ + # SQL Server runs on amd64 alone: Microsoft publishes no arm64 image of it. + Platforms = @( 'linux-x64' ) + + # The image is about one and a half gigabytes, and an agent that meets it for the first time pulls it + # before the test does anything. The test itself then restores, builds and runs the suite. The neighbouring + # PostgreSQL test needs less, because its server is a fraction of that size. + TimeoutSeconds = 2400 +} diff --git a/tests/docker/prepare-restore.sh b/tests/docker/prepare-restore.sh new file mode 100644 index 0000000..3616668 --- /dev/null +++ b/tests/docker/prepare-restore.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# Prepares the restore of a Docker test, and is sourced by the run.sh of each of them. +# +# It sets RESTORE_ONLY_ARGUMENTS to what only the restore takes, and COMMON_ARGUMENTS to what the restore and +# the test both take: the version of the licensing component, and the directory the build writes to. +# +# In the continuous integration build the packages and the version file of that component are directories of +# the repository, which this container reaches unchanged, and this script has nothing to do. On a development +# machine the dependency is resolved to a directory of the host, named by a Windows path. DockerBuild.ps1 mounts +# each of those directories under /mnt/, so the paths are translated here. Every file that is written is +# written to /tmp, because the files of the repository belong to the host and are what the host builds with. + +set -e + +# Translates a Windows path into the path this container mounts it at. +translate_path() { + echo "$1" | sed -E 's#^([A-Za-z]):\\#/mnt/\l\1/#' | sed -E 's#\\#/#g' +} + +RESTORE_ONLY_ARGUMENTS="" +COMMON_ARGUMENTS="" + +if grep -qE 'value="[A-Za-z]:\\' nuget.config; then + echo "The dependencies are resolved to directories of the host. Translating the paths that name them." + + sed -E 's#value="([A-Za-z]):\\#value="/mnt/\l\1/#g' nuget.config \ + | sed -E '/\/mnt\//s#\\#/#g' > /tmp/nuget.config + + RESTORE_ONLY_ARGUMENTS="--configfile /tmp/nuget.config" + + # eng/Versions.g.props imports eng/Versions.Debug.g.props by an absolute path of the host, so MSBuild skips + # it here and the version of the licensing component falls back to the placeholder of + # eng/AutoUpdatedVersions.props, which names no package that exists. The version is therefore read from the + # file that the dependency resolution wrote and passed on the command line, where it wins over that + # placeholder. + version_file=$( grep -oE '[^<]+' eng/Versions.Debug.g.props | head -1 | cut -d '>' -f 2 ) + + if [ -n "$version_file" ]; then + version_file=$( translate_path "$version_file" ) + + if [ ! -f "$version_file" ]; then + echo "The version file of the dependency is not mounted at '$version_file'." + exit 1 + fi + + backstage_version=$( grep -oE '[^<]+' "$version_file" | head -1 | cut -d '>' -f 2 ) + + echo "The licensing component is version $backstage_version." + COMMON_ARGUMENTS="-p:BackstageVersion=$backstage_version" + fi +fi + +# The release archive is not what these tests exercise. Building it starts a nested dotnet publish, which +# restores a second time and with the configuration of the repository rather than the one prepared here. +COMMON_ARGUMENTS="$COMMON_ARGUMENTS -p:ProduceReleaseArchive=false" + +# The build writes outside the repository. The repository is mounted, so its bin and obj directories belong to +# the host, and a build of this container would leave the host with an assets file naming paths that exist in +# the container alone. +COMMON_ARGUMENTS="$COMMON_ARGUMENTS -p:ArtifactsPath=/tmp/artifacts" + +export RESTORE_ONLY_ARGUMENTS +export COMMON_ARGUMENTS