diff --git a/build.ps1 b/build.ps1 index b19abbbf9..04c55ba1b 100755 --- a/build.ps1 +++ b/build.ps1 @@ -63,6 +63,8 @@ $filesForWindowsPackage = @( 'NOTICE.txt', 'osinfo.exe', 'osinfo.dsc.resource.json', + 'powershell.discover.ps1', + 'powershell.dsc.extension.json', 'powershell.dsc.resource.json', 'psDscAdapter/', 'psscript.ps1', @@ -101,6 +103,8 @@ $filesForLinuxPackage = @( 'NOTICE.txt', 'osinfo', 'osinfo.dsc.resource.json', + 'powershell.discover.ps1', + 'powershell.dsc.extension.json', 'powershell.dsc.resource.json', 'psDscAdapter/', 'psscript.ps1', @@ -126,6 +130,8 @@ $filesForMacPackage = @( 'NOTICE.txt', 'osinfo', 'osinfo.dsc.resource.json', + 'powershell.discover.ps1', + 'powershell.dsc.extension.json', 'powershell.dsc.resource.json', 'psDscAdapter/', 'psscript.ps1', @@ -347,9 +353,9 @@ if (!$SkipBuild) { New-Item -ItemType Directory $target -ErrorAction Ignore > $null # make sure dependencies are built first so clippy runs correctly - $windows_projects = @("pal", "registry_lib", "registry", "reboot_pending", "wmi-adapter", "configurations/windows", 'extensions/appx') - $macOS_projects = @("resources/brew") - $linux_projects = @("resources/apt") + $windows_projects = @("pal", "registry_lib", "registry", "reboot_pending", "wmi-adapter", "configurations/windows", "extensions/appx", "extensions/powershell") + $macOS_projects = @("resources/brew", "extensions/powershell") + $linux_projects = @("resources/apt", "extensions/powershell") # projects are in dependency order $projects = @( diff --git a/dsc/tests/dsc_extension_discover.tests.ps1 b/dsc/tests/dsc_extension_discover.tests.ps1 index 043713536..51fde8e13 100644 --- a/dsc/tests/dsc_extension_discover.tests.ps1 +++ b/dsc/tests/dsc_extension_discover.tests.ps1 @@ -23,30 +23,29 @@ Describe 'Discover extension tests' { It 'Discover extensions' { $out = dsc extension list | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 - if ($IsWindows) { - $out.Count | Should -Be 3 -Because ($out | Out-String) - $out[0].type | Should -Be 'Microsoft.DSC.Extension/Bicep' - $out[0].version | Should -Be '0.1.0' - $out[0].capabilities | Should -BeExactly @('import') - $out[0].manifest | Should -Not -BeNullOrEmpty - $out[1].type | Should -Be 'Microsoft.Windows.Appx/Discover' - $out[1].version | Should -Be '0.1.0' - $out[1].capabilities | Should -BeExactly @('discover') - $out[1].manifest | Should -Not -BeNullOrEmpty - $out[2].type | Should -BeExactly 'Test/Discover' - $out[2].version | Should -BeExactly '0.1.0' - $out[2].capabilities | Should -BeExactly @('discover') - $out[2].manifest | Should -Not -BeNullOrEmpty + $expectedExtensions = if ($IsWindows) { + @( + @{ type = 'Microsoft.DSC.Extension/Bicep'; version = '0.1.0'; capabilities = @('import') } + @{ type = 'Microsoft.Windows.Appx/Discover'; version = '0.1.0'; capabilities = @('discover') } + @{ type = 'Microsoft.PowerShell/Discover'; version = '0.1.0'; capabilities = @('discover') } + @{ type = 'Test/Discover'; version = '0.1.0'; capabilities = @('discover') } + ) } else { - $out.Count | Should -Be 2 -Because ($out | Out-String) - $out[0].type | Should -Be 'Microsoft.DSC.Extension/Bicep' - $out[0].version | Should -Be '0.1.0' - $out[0].capabilities | Should -BeExactly @('import') - $out[0].manifest | Should -Not -BeNullOrEmpty - $out[1].type | Should -BeExactly 'Test/Discover' - $out[1].version | Should -BeExactly '0.1.0' - $out[1].capabilities | Should -BeExactly @('discover') - $out[1].manifest | Should -Not -BeNullOrEmpty + @( + @{ type = 'Microsoft.DSC.Extension/Bicep'; version = '0.1.0'; capabilities = @('import') } + @{ type = 'Microsoft.PowerShell/Discover'; version = '0.1.0'; capabilities = @('discover') } + @{ type = 'Test/Discover'; version = '0.1.0'; capabilities = @('discover') } + ) + } + + $out.Count | Should -Be $expectedExtensions.Count -Because ($out | Out-String) + + foreach ($expected in $expectedExtensions) { + $extension = $out | Where-Object { $_.type -eq $expected.type } + $extension | Should -Not -BeNullOrEmpty -Because "Extension $($expected.type) should exist" + $extension.version | Should -BeExactly $expected.version + $extension.capabilities | Should -BeExactly $expected.capabilities + $extension.manifest | Should -Not -BeNullOrEmpty } } diff --git a/extensions/powershell/copy_files.txt b/extensions/powershell/copy_files.txt new file mode 100644 index 000000000..e3b12dc13 --- /dev/null +++ b/extensions/powershell/copy_files.txt @@ -0,0 +1,2 @@ +powershell.discover.ps1 +powershell.dsc.extension.json diff --git a/extensions/powershell/powershell.discover.ps1 b/extensions/powershell/powershell.discover.ps1 new file mode 100644 index 000000000..9b614bdda --- /dev/null +++ b/extensions/powershell/powershell.discover.ps1 @@ -0,0 +1,129 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[CmdletBinding()] +param () + +# begin { +# $psPaths = $env:PSModulePath -split [System.IO.Path]::PathSeparator | Where-Object { $_ -notmatch 'WindowsPowerShell' } +# } +# process { +# $manifests = $psPaths | ForEach-Object -Parallel { +# $searchPatterns = @('*.dsc.resource.json', '*.dsc.resource.yaml', '*.dsc.resource.yml') +# $enumOptions = [System.IO.EnumerationOptions]@{ IgnoreInaccessible = $false; RecurseSubdirectories = $true } +# foreach ($pattern in $searchPatterns) { +# try { +# [System.IO.Directory]::EnumerateFiles($_, $pattern, $enumOptions) | ForEach-Object { +# @{ manifestPath = $_ } +# } +# } catch { } +# } +# } -ThrottleLimit 10 +# } +# end { +# $manifests | ForEach-Object { $_ | ConvertTo-Json -Compress } +# } + + +# [CmdletBinding()] +# param () + +# function Write-DscTrace { +# param( +# [Parameter(Mandatory = $false)] +# [ValidateSet('Error', 'Warn', 'Info', 'Debug', 'Trace')] +# [string]$Operation = 'Debug', + +# [Parameter(Mandatory = $true, ValueFromPipeline = $true)] +# [string]$Message +# ) + +# $trace = @{$Operation.ToLower() = $Message } | ConvertTo-Json -Compress +# $host.ui.WriteErrorLine($trace) +# } + +# function Get-CacheFilePath { +# if ($IsWindows) { +# Join-Path $env:LocalAppData "dsc\PowerShellDiscoverCache.json" +# } else { +# Join-Path $env:HOME ".dsc" "PowerShellDiscoverCache.json" +# } +# } + +# function Test-CacheValid { +# param([string]$CacheFilePath, [string[]]$PSPaths) + +# if (-not (Test-Path $CacheFilePath)) { +# "Cache file not found '$CacheFilePath'" | Write-DscTrace +# return $false +# } + +# try { +# "Reading cache file '$CacheFilePath'" | Write-DscTrace +# $cache = Get-Content -Raw $CacheFilePath | ConvertFrom-Json + +# "Checking cache for stale entries" | Write-DscTrace +# foreach ($entry in $cache.PathInfo.PSObject.Properties) { +# $path = $entry.Name +# if (-not (Test-Path $path)) { +# "Detected non-existent cache entry '$path'" | Write-DscTrace +# return $false +# } + +# $currentLastWrite = (Get-Item $path).LastWriteTimeUtc +# $cachedLastWrite = [DateTime]$entry.Value + +# if ($currentLastWrite -ne $cachedLastWrite) { +# "Detected stale cache entry '$path' (cached: $cachedLastWrite, current: $currentLastWrite)" | Write-DscTrace +# return $false +# } +# } + +# "Checking cache for stale PSModulePath" | Write-DscTrace +# $cachedPaths = [string[]]$cache.PSModulePaths +# if ($cachedPaths.Count -ne $PSPaths.Count) { +# "PSModulePath count changed (cached: $($cachedPaths.Count), current: $($PSPaths.Count))" | Write-DscTrace +# return $false +# } + +# $diff = Compare-Object $cachedPaths $PSPaths +# if ($null -ne $diff) { +# "PSModulePath contents changed" | Write-DscTrace +# return $false +# } + +# "Cache is valid" | Write-DscTrace +# return $true +# } catch { +# "Stale cached entries detected: $_" | Write-DscTrace +# return $false +# } +# } + +function Invoke-DscResourceDiscovery { + [CmdletBinding()] + param() + + begin { + $psPaths = $env:PSModulePath -split [System.IO.Path]::PathSeparator | Where-Object { $_ -notmatch 'WindowsPowerShell' } + } + process { + $manifests = $psPaths | ForEach-Object -Parallel { + $searchPatterns = @('*.dsc.resource.json', '*.dsc.resource.yaml', '*.dsc.resource.yml') + $enumOptions = [System.IO.EnumerationOptions]@{ IgnoreInaccessible = $false; RecurseSubdirectories = $true } + foreach ($pattern in $searchPatterns) { + try { + [System.IO.Directory]::EnumerateFiles($_, $pattern, $enumOptions) | ForEach-Object { + @{ manifestPath = $_ } + } + } catch { } + } + } -ThrottleLimit 10 + } + end { + $manifests | ForEach-Object { $_ | ConvertTo-Json -Compress } + } +} + +Invoke-DscResourceDiscovery + diff --git a/extensions/powershell/powershell.discover.tests.ps1 b/extensions/powershell/powershell.discover.tests.ps1 new file mode 100644 index 000000000..4fc720a57 --- /dev/null +++ b/extensions/powershell/powershell.discover.tests.ps1 @@ -0,0 +1,162 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +BeforeAll { + $fakeManifest = @{ + '$schema' = "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json" + type = "Test/FakeResource" + version = "0.1.0" + get = @{ + executable = "fakeResource" + args = @( + "get", + @{ + jsonInputArg = "--input" + mandatory = $true + } + ) + } + } + + $manifestPath = Join-Path $TestDrive "fake.dsc.resource.json" + $fakeManifest | ConvertTo-Json -Depth 10 | Set-Content -Path $manifestPath + $env:PSModulePath += [System.IO.Path]::PathSeparator + $TestDrive +} + +Describe 'Tests for PowerShell resource discovery' { + It 'Should find DSC PowerShell resources' { + $out = dsc resource list | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.directory | Should -Contain $TestDrive + } +} + +# # Copyright (c) Microsoft Corporation. +# # Licensed under the MIT License. + +# BeforeAll { +# $fakeManifest = @{ +# '$schema' = "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json" +# type = "Test/FakeResource" +# version = "0.1.0" +# get = @{ +# executable = "fakeResource" +# args = @( +# "get", +# @{ +# jsonInputArg = "--input" +# mandatory = $true +# } +# ) +# } +# } + +# $manifestPath = Join-Path $TestDrive "fake.dsc.resource.json" +# $fakeManifest | ConvertTo-Json -Depth 10 | Set-Content -Path $manifestPath +# $env:PSModulePath += [System.IO.Path]::PathSeparator + $TestDrive + +# $script:discoverScript = Join-Path $PSScriptRoot "powershell.discover.ps1" + +# # $cacheFilePath = if ($IsWindows) { +# # Join-Path $env:LocalAppData "dsc\PowerShellDiscoverCache.json" +# # } else { +# # Join-Path $env:HOME ".dsc" "PowerShellDiscoverCache.json" +# # } +# # $script:cacheFilePath = $cacheFilePath +# } + +# Describe 'Tests for PowerShell resource discovery' { +# # BeforeAll { +# # Remove-Item -Force -ErrorAction SilentlyContinue -Path $script:cacheFilePath +# # } + +# It 'Should find DSC PowerShell resources' { +# $out = dsc resource list | ConvertFrom-Json +# $LASTEXITCODE | Should -Be 0 +# $out.directory | Should -Contain $TestDrive +# } + +# # It 'Should create cache file on first run' { +# # $script:cacheFilePath | Should -Not -Exist + +# # $out = & $script:discoverScript 2>&1 + +# # $script:cacheFilePath | Should -Exist + +# # $cache = Get-Content -Raw $script:cacheFilePath | ConvertFrom-Json +# # $cache.PSModulePaths | Should -Not -BeNullOrEmpty +# # $cache.PathInfo | Should -Not -BeNullOrEmpty +# # $cache.Manifests | Should -Not -BeNullOrEmpty +# # } + +# # It 'Should use cache on subsequent runs' { +# # $null = & $script:discoverScript 2>&1 +# # $script:cacheFilePath | Should -Exist + +# # $cacheLastWriteTime = (Get-Item $script:cacheFilePath).LastWriteTimeUtc + +# # Start-Sleep -Milliseconds 100 + +# # $null = & $script:discoverScript 2>&1 + +# # $newLastWriteTime = (Get-Item $script:cacheFilePath).LastWriteTimeUtc +# # $newLastWriteTime | Should -Be $cacheLastWriteTime +# # } + +# # It 'Should invalidate cache when PSModulePath changes' { +# # $null = & $script:discoverScript 2>&1 +# # $script:cacheFilePath | Should -Exist + +# # $cache = Get-Content -Raw $script:cacheFilePath | ConvertFrom-Json +# # $originalPaths = $cache.PSModulePaths +# # $cache.PSModulePaths = @($originalPaths[0]) # Remove some paths +# # $cache | ConvertTo-Json -Depth 10 | Set-Content -Path $script:cacheFilePath -Force + +# # $cacheLastWriteTime = (Get-Item $script:cacheFilePath).LastWriteTimeUtc +# # Start-Sleep -Milliseconds 100 + +# # $null = & $script:discoverScript 2>&1 + +# # $newLastWriteTime = (Get-Item $script:cacheFilePath).LastWriteTimeUtc +# # $newLastWriteTime | Should -Not -Be $cacheLastWriteTime +# # } + +# # It 'Should invalidate cache when module directory is modified' { +# # $null = & $script:discoverScript 2>&1 +# # $script:cacheFilePath | Should -Exist + +# # $cache = Get-Content -Raw $script:cacheFilePath | ConvertFrom-Json + +# # $firstPath = $cache.PathInfo.PSObject.Properties | Select-Object -First 1 +# # if ($firstPath) { +# # $oldTimestamp = [DateTime]$firstPath.Value +# # $newTimestamp = $oldTimestamp.AddDays(-1) +# # $cache.PathInfo.($firstPath.Name) = $newTimestamp +# # $cache | ConvertTo-Json -Depth 10 | Set-Content -Path $script:cacheFilePath -Force + +# # $cacheLastWriteTime = (Get-Item $script:cacheFilePath).LastWriteTimeUtc +# # Start-Sleep -Milliseconds 100 + +# # $null = & $script:discoverScript 2>&1 + +# # $newLastWriteTime = (Get-Item $script:cacheFilePath).LastWriteTimeUtc +# # $newLastWriteTime | Should -Not -Be $cacheLastWriteTime +# # } +# # } + +# # It 'Should rebuild cache if cache file is corrupted' { +# # "{ invalid json }" | Set-Content -Path $script:cacheFilePath -Force +# # $script:cacheFilePath | Should -Exist + +# # $null = & $script:discoverScript 2>&1 + +# # $cache = Get-Content -Raw $script:cacheFilePath | ConvertFrom-Json +# # $cache.PSModulePaths | Should -Not -BeNullOrEmpty +# # $cache.PathInfo | Should -Not -BeNullOrEmpty +# # } + +# # It 'Should include test manifest in discovery results' { +# # $out = & $script:discoverScript | ConvertFrom-Json +# # $out.manifestPath | Should -Contain $manifestPath +# # } +# } diff --git a/extensions/powershell/powershell.dsc.extension.json b/extensions/powershell/powershell.dsc.extension.json new file mode 100644 index 000000000..9096e4fa1 --- /dev/null +++ b/extensions/powershell/powershell.dsc.extension.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Microsoft.PowerShell/Discover", + "version": "0.1.0", + "description": "Discovers DSC resources packaged in PowerShell 7 modules.", + "discover": { + "executable": "pwsh", + "args": [ + "-NoLogo", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-NoProfile", + "-Command", + "./powershell.discover.ps1" + ] + } +}