Skip to content

Commit bc9388c

Browse files
authored
Merge pull request #1071 from Gijsreyn/implement-powershell-discover
Experimental PowerShell discover extension
2 parents f80cd71 + 8331861 commit bc9388c

8 files changed

Lines changed: 329 additions & 7 deletions

File tree

data.build.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
"NOTICE.txt",
2020
"osinfo",
2121
"osinfo.dsc.resource.json",
22+
"powershell.dsc.extension.json",
23+
"powershell.discover.ps1",
2224
"powershell.dsc.resource.json",
2325
"psDscAdapter/",
2426
"psscript.ps1",
@@ -44,6 +46,8 @@
4446
"NOTICE.txt",
4547
"osinfo",
4648
"osinfo.dsc.resource.json",
49+
"powershell.dsc.extension.json",
50+
"powershell.discover.ps1",
4751
"powershell.dsc.resource.json",
4852
"psDscAdapter/",
4953
"psscript.ps1",
@@ -69,6 +73,8 @@
6973
"NOTICE.txt",
7074
"osinfo.exe",
7175
"osinfo.dsc.resource.json",
76+
"powershell.dsc.extension.json",
77+
"powershell.discover.ps1",
7278
"powershell.dsc.resource.json",
7379
"psDscAdapter/",
7480
"psscript.ps1",
@@ -191,6 +197,17 @@
191197
]
192198
}
193199
},
200+
{
201+
"Name": "extensions/powershell",
202+
"Kind": "Extension",
203+
"RelativePath": "extensions/powershell",
204+
"CopyFiles": {
205+
"All": [
206+
"powershell.discover.ps1",
207+
"powershell.dsc.extension.json"
208+
]
209+
}
210+
},
194211
{
195212
"Name": "tree-sitter-dscexpression",
196213
"Kind": "Grammar",

docs/reference/schemas/extension/stdout/discover.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,17 @@ Type: object
2222
2323
## Description
2424
25-
Represents the actual state of a resource instance in DSCpath to a discovered DSC resource or
25+
Represents the actual state of a resource instance in DSC path to a discovered DSC resource or
2626
extension manifest on the system. DSC expects every JSON Line emitted to stdout for the
2727
**Discover** operation to adhere to this schema.
2828
2929
The output must be a JSON object. The object must define the full path to the discovered manifest.
3030
If an extension returns JSON that is invalid against this schema, DSC raises an error.
3131
32+
If the extension doesn't discover any manifests, it must return nothing to stdout and exit with
33+
code `0`. An empty output with a zero exit code indicates no resources were found. A non-zero exit
34+
code indicates an error, even if stdout is empty.
35+
3236
## Required Properties
3337

3438
The output for the `discover` operation must include these properties:
@@ -43,7 +47,18 @@ The value for this property must be the absolute path to a manifest file on the
4347
manifest can be for a DSC resource or extension. If the returned path doesn't exist, DSC raises an
4448
error.
4549

50+
Each discovered manifest must be emitted as a separate JSON Line to stdout. If no manifests are
51+
discovered, the extension must not emit any output to stdout.
52+
4653
```yaml
4754
Type: string
4855
Required: true
4956
```
57+
58+
## Exit codes
59+
60+
The extension must return one of the following exit codes:
61+
62+
- `0` - Success. The extension completed discovery. If no manifests were found, stdout is empty.
63+
- Non-zero - Error. DSC treats any non-zero exit code as a failure and surfaces the extension's
64+
stderr output as an error message.

dsc/tests/dsc_extension_discover.tests.ps1

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,21 +24,29 @@ Describe 'Discover extension tests' {
2424
$out = dsc extension list | ConvertFrom-Json
2525
$LASTEXITCODE | Should -Be 0
2626
if ($IsWindows) {
27-
$out.Count | Should -Be 2 -Because ($out | Out-String)
28-
$out[0].type | Should -Be 'Microsoft.Windows.Appx/Discover'
29-
$out[0].version | Should -Be '0.1.0'
27+
$out.Count | Should -Be 3 -Because ($out | Out-String)
28+
$out[0].type | Should -BeExactly 'Microsoft.PowerShell/Discover'
29+
$out[0].version | Should -BeExactly '0.1.0'
3030
$out[0].capabilities | Should -BeExactly @('discover')
3131
$out[0].manifest | Should -Not -BeNullOrEmpty
32-
$out[1].type | Should -BeExactly 'Test/Discover'
32+
$out[1].type | Should -BeExactly 'Microsoft.Windows.Appx/Discover'
3333
$out[1].version | Should -BeExactly '0.1.0'
3434
$out[1].capabilities | Should -BeExactly @('discover')
3535
$out[1].manifest | Should -Not -BeNullOrEmpty
36+
$out[2].type | Should -BeExactly 'Test/Discover'
37+
$out[2].version | Should -BeExactly '0.1.0'
38+
$out[2].capabilities | Should -BeExactly @('discover')
39+
$out[2].manifest | Should -Not -BeNullOrEmpty
3640
} else {
37-
$out.Count | Should -Be 1 -Because ($out | Out-String)
38-
$out[0].type | Should -BeExactly 'Test/Discover'
41+
$out.Count | Should -Be 2 -Because ($out | Out-String)
42+
$out[0].type | Should -BeExactly 'Microsoft.PowerShell/Discover'
3943
$out[0].version | Should -BeExactly '0.1.0'
4044
$out[0].capabilities | Should -BeExactly @('discover')
4145
$out[0].manifest | Should -Not -BeNullOrEmpty
46+
$out[1].type | Should -BeExactly 'Test/Discover'
47+
$out[1].version | Should -BeExactly '0.1.0'
48+
$out[1].capabilities | Should -BeExactly @('discover')
49+
$out[1].manifest | Should -Not -BeNullOrEmpty
4250
}
4351
}
4452

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"Name": "extensions/powershell",
3+
"Kind": "Extension",
4+
"CopyFiles": {
5+
"All": [
6+
"powershell.discover.ps1",
7+
"powershell.dsc.extension.json"
8+
]
9+
}
10+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
powershell.discover.ps1
2+
powershell.dsc.extension.json
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
[CmdletBinding()]
5+
param ()
6+
7+
function Get-CacheFilePath {
8+
if ($IsWindows) {
9+
Join-Path $env:LocalAppData "dsc\PowerShellDiscoverCache.json"
10+
} else {
11+
Join-Path $env:HOME ".dsc" "PowerShellDiscoverCache.json"
12+
}
13+
}
14+
15+
function Test-CacheValid {
16+
param([string]$CacheFilePath, [string[]]$PSPaths)
17+
18+
if (-not (Test-Path $CacheFilePath)) {
19+
return $false
20+
}
21+
22+
try {
23+
$cache = Get-Content -Raw $CacheFilePath | ConvertFrom-Json
24+
25+
foreach ($entry in $cache.PathInfo.PSObject.Properties) {
26+
$path = $entry.Name
27+
if (-not (Test-Path $path)) {
28+
return $false
29+
}
30+
31+
$currentLastWrite = (Get-Item $path).LastWriteTimeUtc
32+
$cachedLastWrite = [DateTime]$entry.Value
33+
34+
if ($currentLastWrite -ne $cachedLastWrite) {
35+
return $false
36+
}
37+
}
38+
39+
$cachedPaths = [string[]]$cache.PSModulePaths
40+
if ($cachedPaths.Count -ne $PSPaths.Count) {
41+
return $false
42+
}
43+
44+
$diff = Compare-Object $cachedPaths $PSPaths
45+
if ($null -ne $diff) {
46+
return $false
47+
}
48+
49+
foreach ($manifest in $cache.Manifests) {
50+
if (-not (Test-Path -LiteralPath $manifest.manifestPath)) {
51+
return $false
52+
}
53+
}
54+
55+
return $true
56+
} catch {
57+
return $false
58+
}
59+
}
60+
61+
function Invoke-DscResourceDiscovery {
62+
[CmdletBinding()]
63+
param()
64+
65+
begin {
66+
$psPaths = $env:PSModulePath -split [System.IO.Path]::PathSeparator | Where-Object { $_ -notmatch 'WindowsPowerShell' }
67+
68+
$cacheFilePath = Get-CacheFilePath
69+
$useCache = Test-CacheValid -CacheFilePath $cacheFilePath -PSPaths $psPaths
70+
}
71+
process {
72+
if ($useCache) {
73+
$cache = Get-Content -Raw $cacheFilePath | ConvertFrom-Json
74+
$manifests = $cache.Manifests
75+
} else {
76+
$manifests = $psPaths | ForEach-Object -Parallel {
77+
$searchPatterns = @('*.dsc.resource.json', '*.dsc.resource.yaml', '*.dsc.resource.yml')
78+
$enumOptions = [System.IO.EnumerationOptions]@{ IgnoreInaccessible = $true; RecurseSubdirectories = $true }
79+
foreach ($pattern in $searchPatterns) {
80+
try {
81+
[System.IO.Directory]::EnumerateFiles($_, $pattern, $enumOptions) | ForEach-Object {
82+
@{ manifestPath = $_ }
83+
}
84+
} catch { }
85+
}
86+
} -ThrottleLimit 10
87+
88+
$pathInfo = @{}
89+
foreach ($path in $psPaths) {
90+
$item = Get-Item -LiteralPath $path -ErrorAction Ignore
91+
if ($item) {
92+
$pathInfo[$path] = $item.LastWriteTimeUtc
93+
# Track each module subdirectory so that manifest changes inside an
94+
# already-known module are detected, even if the parent directory timestamp isn't updated.
95+
Get-ChildItem -LiteralPath $path -Directory -ErrorAction Ignore | ForEach-Object {
96+
$pathInfo[$_.FullName] = $_.LastWriteTimeUtc
97+
}
98+
}
99+
}
100+
101+
$cacheObject = @{
102+
PSModulePaths = $psPaths
103+
PathInfo = $pathInfo
104+
Manifests = $manifests
105+
}
106+
107+
$cacheDir = Split-Path $cacheFilePath -Parent
108+
if (-not (Test-Path $cacheDir)) {
109+
New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
110+
}
111+
112+
$cacheObject | ConvertTo-Json -Depth 10 | Set-Content -Path $cacheFilePath -Force
113+
}
114+
}
115+
end {
116+
if ($manifests.Count -gt 0) {
117+
$manifests | ForEach-Object { $_ | ConvertTo-Json -Compress }
118+
}
119+
}
120+
}
121+
122+
Invoke-DscResourceDiscovery
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
BeforeAll {
5+
$fakeManifest = @{
6+
'$schema' = "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json"
7+
type = "Test/FakeResource"
8+
version = "0.1.0"
9+
get = @{
10+
executable = "fakeResource"
11+
args = @(
12+
"get",
13+
@{
14+
jsonInputArg = "--input"
15+
mandatory = $true
16+
}
17+
)
18+
}
19+
}
20+
21+
$manifestPath = Join-Path $TestDrive "fake.dsc.resource.json"
22+
$fakeManifest | ConvertTo-Json -Depth 10 | Set-Content -Path $manifestPath
23+
$script:OldPSModulePath = $env:PSModulePath
24+
$env:PSModulePath += [System.IO.Path]::PathSeparator + $TestDrive
25+
26+
$script:discoverScript = Join-Path $PSScriptRoot "powershell.discover.ps1"
27+
28+
$cacheFilePath = if ($IsWindows) {
29+
Join-Path $env:LocalAppData "dsc\PowerShellDiscoverCache.json"
30+
} else {
31+
Join-Path $env:HOME ".dsc" "PowerShellDiscoverCache.json"
32+
}
33+
$script:cacheFilePath = $cacheFilePath
34+
35+
Remove-Item -Force -ErrorAction SilentlyContinue -Path $script:cacheFilePath
36+
}
37+
38+
AfterAll {
39+
$env:PSModulePath = $script:OldPSModulePath
40+
}
41+
42+
Describe 'Tests for PowerShell resource discovery' {
43+
It 'Should create cache file on first run' {
44+
$script:cacheFilePath | Should -Not -Exist
45+
$null = & $script:discoverScript 2>&1
46+
$script:cacheFilePath | Should -Exist
47+
48+
$cache = Get-Content -Raw $script:cacheFilePath | ConvertFrom-Json
49+
$cache.PSModulePaths | Should -Not -BeNullOrEmpty
50+
$cache.PathInfo | Should -Not -BeNullOrEmpty
51+
$cache.Manifests | Should -Not -BeNullOrEmpty
52+
}
53+
54+
It 'Should find DSC PowerShell resources' {
55+
$out = dsc resource list | ConvertFrom-Json
56+
$LASTEXITCODE | Should -Be 0
57+
$out.manifest.type | Should -Contain 'Test/FakeResource'
58+
}
59+
60+
It 'Should use cache on subsequent runs' {
61+
$null = & $script:discoverScript 2>&1
62+
$script:cacheFilePath | Should -Exist
63+
64+
$cacheLastWriteTime = (Get-Item $script:cacheFilePath).LastWriteTimeUtc
65+
66+
Start-Sleep -Milliseconds 100
67+
68+
$null = & $script:discoverScript 2>&1
69+
70+
$newLastWriteTime = (Get-Item $script:cacheFilePath).LastWriteTimeUtc
71+
$newLastWriteTime | Should -Be $cacheLastWriteTime
72+
}
73+
74+
It 'Should invalidate cache when PSModulePath changes' {
75+
$null = & $script:discoverScript 2>&1
76+
$script:cacheFilePath | Should -Exist
77+
78+
$cache = Get-Content -Raw $script:cacheFilePath | ConvertFrom-Json
79+
$originalPaths = $cache.PSModulePaths
80+
$cache.PSModulePaths = @($originalPaths[0]) # Remove some paths
81+
$cache | ConvertTo-Json -Depth 10 | Set-Content -Path $script:cacheFilePath -Force
82+
83+
$cacheLastWriteTime = (Get-Item $script:cacheFilePath).LastWriteTimeUtc
84+
Start-Sleep -Milliseconds 100
85+
86+
$null = & $script:discoverScript 2>&1
87+
88+
$newLastWriteTime = (Get-Item $script:cacheFilePath).LastWriteTimeUtc
89+
$newLastWriteTime | Should -Not -Be $cacheLastWriteTime
90+
}
91+
92+
It 'Should invalidate cache when module directory is modified' {
93+
$null = & $script:discoverScript 2>&1
94+
$script:cacheFilePath | Should -Exist
95+
96+
$cache = Get-Content -Raw $script:cacheFilePath | ConvertFrom-Json
97+
98+
$firstPath = $cache.PathInfo.PSObject.Properties | Select-Object -First 1
99+
if ($firstPath) {
100+
$oldTimestamp = [DateTime]$firstPath.Value
101+
$newTimestamp = $oldTimestamp.AddDays(-1)
102+
$cache.PathInfo.($firstPath.Name) = $newTimestamp
103+
$cache | ConvertTo-Json -Depth 10 | Set-Content -Path $script:cacheFilePath -Force
104+
105+
$cacheLastWriteTime = (Get-Item $script:cacheFilePath).LastWriteTimeUtc
106+
Start-Sleep -Milliseconds 100
107+
108+
$null = & $script:discoverScript 2>&1
109+
110+
$newLastWriteTime = (Get-Item $script:cacheFilePath).LastWriteTimeUtc
111+
$newLastWriteTime | Should -Not -Be $cacheLastWriteTime
112+
}
113+
}
114+
115+
It 'Should rebuild cache if cache file is corrupted' {
116+
"{ invalid json }" | Set-Content -Path $script:cacheFilePath -Force
117+
$script:cacheFilePath | Should -Exist
118+
119+
$null = & $script:discoverScript 2>&1
120+
121+
$cache = Get-Content -Raw $script:cacheFilePath | ConvertFrom-Json
122+
$cache.PSModulePaths | Should -Not -BeNullOrEmpty
123+
$cache.PathInfo | Should -Not -BeNullOrEmpty
124+
}
125+
126+
It 'Should include test manifest in discovery results' {
127+
$out = & $script:discoverScript | ConvertFrom-Json
128+
$out.manifestPath | Should -Contain $manifestPath
129+
}
130+
}

0 commit comments

Comments
 (0)