diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d49b050 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Needed for publishing of examples, build worker defaults to core.autocrlf=input. +* text eol=crlf + +# Ensure any exe files are treated as binary +*.exe binary +*.jpg binary +*.xl* binary +*.pfx binary diff --git a/.github/ISSUE_TEMPLATE/General.md b/.github/ISSUE_TEMPLATE/General.md new file mode 100644 index 0000000..44e0792 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/General.md @@ -0,0 +1,7 @@ +--- +name: General question or documentation update +about: If you have a general question or documentation update suggestion around the module. +--- + diff --git a/.github/ISSUE_TEMPLATE/Problem_with_module.yml b/.github/ISSUE_TEMPLATE/Problem_with_module.yml new file mode 100644 index 0000000..76f94fc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Problem_with_module.yml @@ -0,0 +1,101 @@ +name: Problem with the module +description: If you have a problem using this module, want to report a bug, or suggest an enhancement to this module. +labels: [] +assignees: [] +body: + - type: markdown + attributes: + value: | + TITLE: Please be descriptive not sensationalist. + + Your feedback and support is greatly appreciated, thanks for contributing! + + Please provide information regarding your issue under each section below. + **Write N/A in sections that do not apply, or if the information is not available.** + - type: textarea + id: description + attributes: + label: Problem description + description: Details of the scenario you tried and the problem that is occurring, or the enhancement you are suggesting. + validations: + required: true + - type: textarea + id: logs + attributes: + label: Verbose logs + description: | + Verbose logs showing the problem. **NOTE! Sensitive information should be obfuscated.** _Will be automatically formatted as plain text._ + placeholder: | + Paste verbose logs here + render: text + validations: + required: true + - type: textarea + id: reproducible + attributes: + label: How to reproduce + description: Provide the steps to reproduce the problem. + validations: + required: true + - type: textarea + id: expectedBehavior + attributes: + label: Expected behavior + description: Describe what you expected to happen. + validations: + required: true + - type: textarea + id: currentBehavior + attributes: + label: Current behavior + description: Describe what actually happens. + validations: + required: true + - type: textarea + id: suggestedSolution + attributes: + label: Suggested solution + description: Do you have any suggestions how to solve the issue? + validations: + required: true + - type: textarea + id: targetNodeOS + attributes: + label: Operating system the target node is running + description: | + Please provide as much as possible about the node running DscResource.Common. _Will be automatically formatted as plain text._ + + To help with this information: + - On a Linux distribution, please provide the distribution name, version, and release. The following command can help get this information: `cat /etc/*-release && cat /proc/version` + - On a Windows OS please provide edition, version, build, and language. The following command can help get this information: `Get-ComputerInfo -Property @('OsName','OsOperatingSystemSKU','OSArchitecture','WindowsVersion','WindowsBuildLabEx','OsLanguage','OsMuiLanguages')` + placeholder: | + Add operating system information here + render: text + validations: + required: true + - type: textarea + id: targetNodePS + attributes: + label: PowerShell version and build the target node is running + description: | + Please provide the version and build of PowerShell the target node is running. _Will be automatically formatted as plain text._ + + To help with this information, please run this command: `$PSVersionTable` + placeholder: | + Add PowerShell information here + render: text + validations: + required: true + - type: textarea + id: moduleVersion + attributes: + label: Module version used + description: | + Please provide the version of the DscResource.Common module that was used. _Will be automatically formatted as plain text._ + + To help with this information, please run this command: `Get-Module -Name 'DscResource.Common' -ListAvailable | ft Name,Version,Path` + placeholder: | + Add module information here + render: text + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..4cd6921 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: "Virtual PowerShell User Group #DSC channel" + url: https://dsccommunity.org/community/contact/ + about: "To talk to the community and maintainers of DSC Community, please visit the #DSC channel." diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1900521 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,46 @@ + +#### Pull Request (PR) description + + +#### This Pull Request (PR) fixes the following issues + + +#### Task list + +- [ ] Added an entry to the change log under the Unreleased section of the + file CHANGELOG.md. Entry should say what was changed and how that + affects users (if applicable), and reference the issue being resolved + (if applicable). +- [ ] Documentation added/updated in README.md. +- [ ] Comment-based help added/updated for all new/changed functions. +- [ ] Localization strings added/updated in all localization files as appropriate. +- [ ] Examples appropriately added/updated. +- [ ] Unit tests added/updated. See [DSC Community Testing Guidelines](https://dsccommunity.org/guidelines/testing-guidelines). +- [ ] Integration tests added/updated (where possible). See + [DSC Community Testing Guidelines](https://dsccommunity.org/guidelines/testing-guidelines). +- [ ] New/changed code adheres to [DSC Community Style Guidelines](https://dsccommunity.org/styleguidelines). diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..bad4783 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,25 @@ +name: Mark stale issues and pull requests + +on: + schedule: + - cron: "30 1 * * *" + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v6 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + stale-issue-message: 'This issue has been automatically marked as stale because it has not had activity from the community in the last 30 days. It will be closed if no further activity occurs within 40 days. If the issue is labelled with any of the work labels (e.g bug, enhancement, documentation, or tests) then the issue will not auto-close.' + close-issue-message: 'This issue has been automatically closed because it is has not had activity from the community in the last 40 days. If this issue was wrongly closed, for a issue author please comment and re-open it, if you are not the issue author comment with a reason for it to be reopened and tag a maintainer in the comment.' + days-before-issue-stale: 30 + days-before-issue-close: 40 + exempt-issue-labels: 'bug,enhancement,tests,documentation,resource proposal,on hold,resolved' + stale-issue-label: 'stale' + stale-pr-message: 'Labeling this pull request (PR) as abandoned since it has gone 14 days or more since the last update. An abandoned PR can be continued by another contributor. The abandoned label will be removed if work on this PR is taken up again.' + days-before-pr-stale: 14 + days-before-pr-close: -1 + exempt-pr-labels: 'needs review,on hold,ready for merge' + stale-pr-label: 'abandoned' + remove-stale-when-updated: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1733276 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.vscode +.vs +output/ diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..87b7da5 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,10 @@ +{ + "default": true, + "MD029": { + "style": "one" + }, + "MD013": true, + "MD024": false, + "MD034": false, + "no-hard-tabs": true +} diff --git a/.vscode/analyzersettings.psd1 b/.vscode/analyzersettings.psd1 new file mode 100644 index 0000000..b0938cd --- /dev/null +++ b/.vscode/analyzersettings.psd1 @@ -0,0 +1,110 @@ +@{ + CustomRulePath = @( + './output/RequiredModules/DscResource.AnalyzerRules' + './output/RequiredModules/Indented.ScriptAnalyzerRules' + ) + IncludeDefaultRules = $true + IncludeRules = @( + # DSC Community style guideline rules from the module ScriptAnalyzer. + 'PSAvoidDefaultValueForMandatoryParameter' + 'PSAvoidDefaultValueSwitchParameter' + 'PSAvoidInvokingEmptyMembers' + 'PSAvoidNullOrEmptyHelpMessageAttribute' + 'PSAvoidUsingCmdletAliases' + 'PSAvoidUsingComputerNameHardcoded' + 'PSAvoidUsingDeprecatedManifestFields' + 'PSAvoidUsingEmptyCatchBlock' + 'PSAvoidUsingInvokeExpression' + 'PSAvoidUsingPositionalParameters' + 'PSAvoidShouldContinueWithoutForce' + 'PSAvoidUsingWMICmdlet' + 'PSAvoidUsingWriteHost' + 'PSDSCReturnCorrectTypesForDSCFunctions' + 'PSDSCStandardDSCFunctionsInResource' + 'PSDSCUseIdenticalMandatoryParametersForDSC' + 'PSDSCUseIdenticalParametersForDSC' + 'PSMisleadingBacktick' + 'PSMissingModuleManifestField' + 'PSPossibleIncorrectComparisonWithNull' + 'PSProvideCommentHelp' + 'PSReservedCmdletChar' + 'PSReservedParams' + 'PSUseApprovedVerbs' + 'PSUseCmdletCorrectly' + 'PSUseOutputTypeCorrectly' + 'PSAvoidGlobalVars' + 'PSAvoidUsingConvertToSecureStringWithPlainText' + 'PSAvoidUsingPlainTextForPassword' + 'PSAvoidUsingUsernameAndPasswordParams' + 'PSDSCUseVerboseMessageInDSCResource' + 'PSShouldProcess' + 'PSUseDeclaredVarsMoreThanAssignments' + 'PSUsePSCredentialType' + + # Additional rules from the module ScriptAnalyzer + 'PSUseConsistentWhitespace' + 'UseCorrectCasing' + 'PSPlaceOpenBrace' + 'PSPlaceCloseBrace' + 'AlignAssignmentStatement' + 'AvoidUsingDoubleQuotesForConstantString' + 'UseShouldProcessForStateChangingFunctions' + + # Rules from the modules DscResource.AnalyzerRules and SqlServerDsc.AnalyzerRules + 'Measure-*' + + # Rules from the module Indented.ScriptAnalyzerRules + 'AvoidCreatingObjectsFromAnEmptyString' + 'AvoidDashCharacters' + 'AvoidEmptyNamedBlocks' + 'AvoidFilter' + 'AvoidHelpMessage' + 'AvoidNestedFunctions' + 'AvoidNewObjectToCreatePSObject' + 'AvoidParameterAttributeDefaultValues' + 'AvoidProcessWithoutPipeline' + 'AvoidSmartQuotes' + 'AvoidThrowOutsideOfTry' + 'AvoidWriteErrorStop' + 'AvoidWriteOutput' + 'UseSyntacticallyCorrectExamples' + ) + + # TODO: This is not excluded correctly, see test QA/ScriptAnalyzer.Tests.ps1 for more information. + ExcludeRules = @( + 'TypeNotFound' + ) + + Rules = @{ + PSUseConsistentWhitespace = @{ + Enable = $true + CheckOpenBrace = $true + CheckInnerBrace = $true + CheckOpenParen = $true + CheckOperator = $false + CheckSeparator = $true + CheckPipe = $true + CheckPipeForRedundantWhitespace = $true + CheckParameter = $false + } + + PSPlaceOpenBrace = @{ + Enable = $true + OnSameLine = $false + NewLineAfter = $true + IgnoreOneLineBlock = $false + } + + PSPlaceCloseBrace = @{ + Enable = $true + NoEmptyLineBefore = $true + IgnoreOneLineBlock = $false + NewLineAfter = $true + } + + PSAlignAssignmentStatement = @{ + Enable = $true + CheckHashtable = $true + } + } +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..1792540 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "recommendations": [ + "davidanson.vscode-markdownlint", + "pspester.pester-test", + "ms-vscode.powershell", + "streetsidesoftware.code-spell-checker" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..f831d3a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,48 @@ + +{ + "powershell.codeFormatting.openBraceOnSameLine": false, + "powershell.codeFormatting.newLineAfterOpenBrace": true, + "powershell.codeFormatting.newLineAfterCloseBrace": true, + "powershell.codeFormatting.whitespaceBeforeOpenBrace": true, + "powershell.codeFormatting.whitespaceBeforeOpenParen": true, + "powershell.codeFormatting.whitespaceAroundOperator": true, + "powershell.codeFormatting.whitespaceAfterSeparator": true, + "powershell.codeFormatting.ignoreOneLineBlock": false, + "powershell.codeFormatting.pipelineIndentationStyle": "IncreaseIndentationForFirstPipeline", + "powershell.codeFormatting.preset": "Custom", + "powershell.codeFormatting.alignPropertyValuePairs": true, + "powershell.codeFormatting.useConstantStrings": true, + "powershell.developer.bundledModulesPath": "${cwd}/output/RequiredModules", + "powershell.scriptAnalysis.settingsPath": ".vscode/analyzersettings.psd1", + "powershell.scriptAnalysis.enable": true, + "files.trimTrailingWhitespace": true, + "files.trimFinalNewlines": true, + "files.insertFinalNewline": true, + "files.associations": { + "*.ps1xml": "xml" + }, + "cSpell.dictionaries": [ + "powershell" + ], + "cSpell.words": [ + ], + "cSpell.ignorePaths": [ + ".git" + ], + "cSpell.ignoreRegExpList": [ + ], + "[markdown]": { + "files.trimTrailingWhitespace": true, + "files.encoding": "utf8" + }, + "powershell.pester.useLegacyCodeLens": false, + "pester.testFilePath": [ + "[tT]ests/[qQ][aA]/*.[tT]ests.[pP][sS]1", + "[tT]ests/[uU]nit/**/*.[tT]ests.[pP][sS]1", + "[tT]ests/[uU]nit/*.[tT]ests.[pP][sS]1" + ], + "pester.runTestsInNewProcess": false, + "pester.pesterModulePath": "./output/RequiredModules/Pester", + "powershell.pester.codeLens": true, + "pester.suppressCodeLensNotice": true +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..2991140 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,125 @@ +{ + "version": "2.0.0", + "_runner": "terminal", + "windows": { + "options": { + "shell": { + "executable": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command" + ] + } + } + }, + "linux": { + "options": { + "shell": { + "executable": "/usr/bin/pwsh", + "args": [ + "-NoProfile", + "-Command" + ] + } + } + }, + "osx": { + "options": { + "shell": { + "executable": "/usr/local/bin/pwsh", + "args": [ + "-NoProfile", + "-Command" + ] + } + } + }, + "tasks": [ + { + "label": "build", + "type": "shell", + "command": "&${cwd}/build.ps1", + "args": [], + "presentation": { + "echo": true, + "reveal": "always", + "focus": true, + "panel": "new", + "clear": false + }, + "runOptions": { + "runOn": "default" + }, + "problemMatcher": [ + { + "owner": "powershell", + "fileLocation": [ + "absolute" + ], + "severity": "error", + "pattern": [ + { + "regexp": "^\\s*(\\[-\\]\\s*.*?)(\\d+)ms\\s*$", + "message": 1 + }, + { + "regexp": "(.*)", + "code": 1 + }, + { + "regexp": "" + }, + { + "regexp": "^.*,\\s*(.*):\\s*line\\s*(\\d+).*", + "file": 1, + "line": 2 + } + ] + } + ] + }, + { + "label": "test", + "type": "shell", + "command": "&${cwd}/build.ps1", + "args": ["-AutoRestore","-Tasks","test"], + "presentation": { + "echo": true, + "reveal": "always", + "focus": true, + "panel": "dedicated", + "showReuseMessage": true, + "clear": false + }, + "problemMatcher": [ + { + "owner": "powershell", + "fileLocation": [ + "absolute" + ], + "severity": "error", + "pattern": [ + { + "regexp": "^\\s*(\\[-\\]\\s*.*?)(\\d+)ms\\s*$", + "message": 1 + }, + { + "regexp": "(.*)", + "code": 1 + }, + { + "regexp": "" + }, + { + "regexp": "^.*,\\s*(.*):\\s*line\\s*(\\d+).*", + "file": 1, + "line": 2 + } + ] + } + ] + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d93db02 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Change log for DscResource.Base + +The format is based on and uses the types of changes according to [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Added the first version of `ResourceBase`. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..d7589dd --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,3 @@ +# Code of Conduct + +This project has adopted the [DSC Community Code of Conduct](https://dsccommunity.org/code_of_conduct). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..25aafd4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,200 @@ +# Contributing to DscResource.Base + +If you are keen to make DscResource.Base better, why not consider contributing your work +to the project? Every little change helps us make a better resource for everyone +to use, and we would love to have contributions from the community. + +## Core contribution guidelines + +Please check out common DSC Community [contributing guidelines](https://dsccommunity.org/guidelines/contributing). + +## Documentation with Markdown + +The following table is a guideline on when to use markdown code in parameter +description. There can be other usages that are not described here. Backslash +must always be escaped (using `\`, e.g `\\`). + + +Type | Markdown syntax | Example +-- | -- | -- +**Parameter reference** | `**ParameterName**` (bold) | **ParameterName** +**Parameter value reference** | `` `'String1'` ``, `` `$true` ``, `` `50` `` (inline code-block) | `'String1'`, `$true`, `50` +**Name reference** (resource, modules, products, or features, etc.) | `_Microsoft SQL Server Database Engine_` (Italic) | _Microsoft SQL Server Database Engine_ +**Path reference** | `` `C:\\Program Files\\SSRS` `` | `C:\\Program Files\\SSRS` +**Filename reference** | `` `log.txt` `` | `log.txt` + + + +If using Visual Studio Code to edit Markdown files it can be a good idea +to install the markdownlint extension. It will help to do style checking. +The file [.markdownlint.json](/.markdownlint.json) is prepared with a default +set of rules which will automatically be used by the extension. + +## Automatic formatting with VS Code + +There is a VS Code workspace settings file within this project with formatting +settings matching the style guideline. That will make it possible inside VS Code +to press SHIFT+ALT+F, or press F1 and choose 'Format document' in the list. The +PowerShell code will then be formatted according to the Style Guideline +(although maybe not complete, but would help a long way). + +## Script Analyzer rules + +There are several Script Analyzer rules to help with the development and review +process. Rules come from the modules **ScriptAnalyzer**, **DscResource.AnalyzerRules**, +**Indented.ScriptAnalyzerRules**, and **DscResource.Base.AnalyzerRules**. + +Some rules (but not all) are allowed to be overridden with a justification. + +This is an example how to override a rule from the module **DscResource.Base.AnalyzerRules**. + +```powershell +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('DscResource.Base.AnalyzerRules\Measure-CommandsNeededToLoadSMO', '', Justification='The command Connect-Sql is called when Get-TargetResource is called')] +param () +``` + +This is an example how to override a rule from the module **ScriptAnalyzer**. + +```powershell +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '', Justification='Because $global:DSCMachineStatus is used to trigger a Restart, either by force or when there are pending changes')] +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification='Because $global:DSCMachineStatus is only set, never used (by design of Desired State Configuration)')] +param () +``` + +This is an example how to override a rule from the module **Indented.ScriptAnalyzerRules**. + +```powershell +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('AvoidThrowOutsideOfTry', '', Justification='Because the code throws based on an prior expression')] +param () +``` + +## Design patterns + +### Localization + +In each resource folder there should be, at least, a localization folder for +english language 'en-US'. + +Read more about this in the [localization style guideline](https://dsccommunity.org/styleguidelines/localization/). + +### Private functions + +Private functions that is only used by classes or public commands shall be added +to a script file in the `source/Private` folder. Each file in the Private folder +should contain one function and the file name should be named the same as the +function name (Verb-Noun). + +### Public commands + +Public commands shall be added to a script file in the `source/Private` folder. +Each file in the Public folder should contain one command and the +file name should be named the same as the command name (Verb-Noun). +Each command in the folder Public will be publicly exported. + +### Unit tests + +For a review of a Pull Request (PR) to start, all tests must pass without error. +If you need help to figure why some test don't pass, just write a comment in the +Pull Request (PR), or submit an issue, and somebody will come along and assist. + +If want to know how to run this module's tests you can look at the [Testing Guidelines](https://dsccommunity.org/guidelines/testing-guidelines/#running-tests) + +### Class-based DSC resource + +#### Terminating Error + +A terminating error is an error that prevents the resource to continue further. +If a DSC resource shall throw an terminating error the commands of the module +**DscResource.Common** shall be used primarily; [`New-InvalidArgumentException`](https://github.com/dsccommunity/DscResource.Common#new-invalidargumentexception), +[`New-InvalidDataExcpetion`](https://github.com/dsccommunity/DscResource.Common#new-invaliddataexception), +[`New-InvalidOperationException`](https://github.com/dsccommunity/DscResource.Common#new-invalidoperationexception), +[`New-InvalidResultException`](https://github.com/dsccommunity/DscResource.Common#new-invalidresultexception), +or [`New-NotImplementedException`](https://github.com/dsccommunity/DscResource.Common#new-notimplementedexception). +If neither of those commands works in the scenarion then `throw` shall be used. + +### Commands + +Commands are publicly exported commands from the module, and the source for +commands are located in the folder `./source/Public`. + +#### Non-Terminating Error + +A non-terminating error should only be used when a command shall be able to +handle (ignoring) an error and continue processing and still give the user +an expected outcome. + +With a non-terminating error the user is able to decide whether the command +should throw or continue processing on error. The user can pass the +parameter and value `-ErrorAction 'SilentlyContinue'` to the command to +ignore the error and allowing the command to continue, for example the +command could then return `$null`. But if the user passes the parameter +and value `-ErrorAction 'Stop'` the same error will throw a terminating +error telling the user the expected outcome could not be achieved. + +The below example checks to see if a database exist, if it doesn't a +non-terminating error are called. The user is able to either ignore the +error or have it throw depending on what value the user specifies +in parameter `ErrorAction` (or `$ErrorActionPreference`). + +```powershell +if (-not $databaseExist) +{ + $errorMessage = $script:localizedData.MissingDatabase -f $DatabaseName + + Write-Error -Message $errorMessage -Category 'InvalidOperation' -ErrorId 'GS0001' -TargetObject $DatabaseName +} +``` + +#### Terminating Error + +A terminating error is an error that the user are not able to ignore by +passing a parameter to the command (like for non-terminating errors). + +If a command shall throw an terminating error then the statement `throw` shall +not be used, neither shall the command `Write-Error` with the parameter +`-ErrorAction Stop`. Always use the method `$PSCmdlet.ThrowTerminatingError()` +to throw a terminating error. The exception is when a `[ValidateScript()]` +has to throw an error, then `throw` must be used. + +>**NOTE:** Below output assumes `$ErrorView` is set to `'NormalView'` in the +>PowerShell session. + +When using `throw` it will fail on the line with the throw statement +making it look like it is that statement inside the function that failed, +which is not correct since it is either a previous command or evaluation +that failed resulting in the line with the `throw` being called. This is +an example when using `throw`: + +```plaintext +Exception: +Line | + 2 | throw 'My error' + | ~~~~~~~~~~~~~~~~ + | My error +``` + +When instead using `$PSCmdlet.ThrowTerminatingError()`: + +```powershell +$PSCmdlet.ThrowTerminatingError( + [System.Management.Automation.ErrorRecord]::new( + 'MyError', + 'GS0001', + [System.Management.Automation.ErrorCategory]::InvalidOperation, + 'MyObjectOrValue' + ) +) +``` + +The result from `$PSCmdlet.ThrowTerminatingError()` shows that the command +failed (in this example `Get-Something`) and returns a clear category and +error code. + +```plaintext +Get-Something : My Error +At line:1 char:1 ++ Get-Something ++ ~~~~~~~~~~~~~ ++ CategoryInfo : InvalidOperation: (MyObjectOrValue:String) [Get-Something], Exception ++ FullyQualifiedErrorId : GS0001,Get-Something +``` diff --git a/GitVersion.yml b/GitVersion.yml new file mode 100644 index 0000000..a3d15b9 --- /dev/null +++ b/GitVersion.yml @@ -0,0 +1,27 @@ +mode: ContinuousDelivery +next-version: 1.0.0 +major-version-bump-message: '(breaking\schange|breaking|major)\b' +minor-version-bump-message: '(adds?|features?|minor)\b' +patch-version-bump-message: '\s?(fix|patch)' +no-bump-message: '\+semver:\s?(none|skip)' +assembly-informational-format: '{NuGetVersionV2}+Sha.{Sha}.Date.{CommitDate}' +branches: + master: + tag: preview + regex: ^main$ + pull-request: + tag: PR + feature: + tag: useBranchName + increment: Minor + regex: f(eature(s)?)?[\/-] + source-branches: ['master'] + hotfix: + tag: fix + increment: Patch + regex: (hot)?fix(es)?[\/-] + source-branches: ['master'] + +ignore: + sha: [] +merge-message-formats: {} diff --git a/LICENSE b/LICENSE index 64e25b1..0683f75 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 DSC Community +Copyright (c) DSC Community 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 diff --git a/README.md b/README.md index 854e337..ae84b40 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,38 @@ # DscResource.Base -This module contains common classes that can be used to develop class-based DSC resources. + +The **DscResource.Base** module contains common classes that can be used +for class-based DSC resources development. + +[![Build Status](https://dev.azure.com/dsccommunity/DscResource.Base/_apis/build/status/dsccommunity.DscResource.Base?branchName=main)](https://dev.azure.com/dsccommunity/DscResource.Base/_build/latest?definitionId=11&branchName=main) +![Azure DevOps coverage (branch)](https://img.shields.io/azure-devops/coverage/dsccommunity/DscResource.Base/11/main) +[![codecov](https://codecov.io/gh/dsccommunity/DscResource.Base/branch/main/graph/badge.svg)](https://codecov.io/gh/dsccommunity/DscResource.Base) +[![Azure DevOps tests](https://img.shields.io/azure-devops/tests/dsccommunity/DscResource.Base/11/main)](https://dsccommunity.visualstudio.com/DscResource.Base/_test/analytics?definitionId=11&contextType=build) +[![PowerShell Gallery (with prereleases)](https://img.shields.io/powershellgallery/vpre/DscResource.Base?label=DscResource.Base%20Preview)](https://www.powershellgallery.com/packages/DscResource.Base/) +[![PowerShell Gallery](https://img.shields.io/powershellgallery/v/DscResource.Base?label=DscResource.Base)](https://www.powershellgallery.com/packages/DscResource.Base/) + +## Code of Conduct + +This project has adopted this [Code of Conduct](CODE_OF_CONDUCT.md). + +## Releases + +For each merge to the branch `main` a preview release will be +deployed to [PowerShell Gallery](https://www.powershellgallery.com/). +Periodically a release version tag will be pushed which will deploy a +full release to [PowerShell Gallery](https://www.powershellgallery.com/). + +## Contributing + +Please check out common DSC Community [contributing guidelines](https://dsccommunity.org/guidelines/contributing) +and the specific [Contributing to DscResource.Base](https://github.com/dsccommunity/DscResource.Base/blob/main/CONTRIBUTING.md) +guidelines. + +## Change log + +A full list of changes in each version can be found in the [change log](CHANGELOG.md). + +## Documentation + +The documentation can be found in the [DscResource.Base Wiki](https://github.com/dsccommunity/DscResource.Base/wiki). +The DSC resources schema files is used to automatically update the +documentation on each PR merge. diff --git a/RequiredModules.psd1 b/RequiredModules.psd1 new file mode 100644 index 0000000..e2527b9 --- /dev/null +++ b/RequiredModules.psd1 @@ -0,0 +1,29 @@ +@{ + PSDependOptions = @{ + AddToPath = $true + Target = 'output\RequiredModules' + Parameters = @{ + Repository = 'PSGallery' + } + } + + # Build dependencies needed for using the module + 'DscResource.Common' = 'latest' + + # Build dependencies for the pipeline + InvokeBuild = 'latest' + PSScriptAnalyzer = 'latest' + Pester = 'latest' + Plaster = 'latest' + ModuleBuilder = 'latest' + ChangelogManagement = 'latest' + Sampler = 'latest' + 'Sampler.GitHubTasks' = 'latest' + MarkdownLinkCheck = 'latest' + 'DscResource.Test' = 'latest' + 'DscResource.DocGenerator' = 'latest' + + # Analyzer rules + 'DscResource.AnalyzerRules' = 'latest' + 'Indented.ScriptAnalyzerRules' = 'latest' +} diff --git a/Resolve-Dependency.ps1 b/Resolve-Dependency.ps1 new file mode 100644 index 0000000..a8c6b4d --- /dev/null +++ b/Resolve-Dependency.ps1 @@ -0,0 +1,421 @@ +<# + .DESCRIPTION + Bootstrap script for PSDepend. + + .PARAMETER DependencyFile + Specifies the configuration file for the this script. The default value is + 'RequiredModules.psd1' relative to this script's path. + + .PARAMETER PSDependTarget + Path for PSDepend to be bootstrapped and save other dependencies. + Can also be CurrentUser or AllUsers if you wish to install the modules in + such scope. The default value is './output/RequiredModules' relative to + this script's path. + + .PARAMETER Proxy + Specifies the URI to use for Proxy when attempting to bootstrap + PackageProvider and PowerShellGet. + + .PARAMETER ProxyCredential + Specifies the credential to contact the Proxy when provided. + + .PARAMETER Scope + Specifies the scope to bootstrap the PackageProvider and PSGet if not available. + THe default value is 'CurrentUser'. + + .PARAMETER Gallery + Specifies the gallery to use when bootstrapping PackageProvider, PSGet and + when calling PSDepend (can be overridden in Dependency files). The default + value is 'PSGallery'. + + .PARAMETER GalleryCredential + Specifies the credentials to use with the Gallery specified above. + + .PARAMETER AllowOldPowerShellGetModule + Allow you to use a locally installed version of PowerShellGet older than + 1.6.0 (not recommended). Default it will install the latest PowerShellGet + if an older version than 2.0 is detected. + + .PARAMETER MinimumPSDependVersion + Allow you to specify a minimum version fo PSDepend, if you're after specific + features. + + .PARAMETER AllowPrerelease + Not yet written. + + .PARAMETER WithYAML + Not yet written. + + .NOTES + Load defaults for parameters values from Resolve-Dependency.psd1 if not + provided as parameter. +#> +[CmdletBinding()] +param +( + [Parameter()] + [System.String] + $DependencyFile = 'RequiredModules.psd1', + + [Parameter()] + [System.String] + $PSDependTarget = (Join-Path -Path $PSScriptRoot -ChildPath './output/RequiredModules'), + + [Parameter()] + [System.Uri] + $Proxy, + + [Parameter()] + [System.Management.Automation.PSCredential] + $ProxyCredential, + + [Parameter()] + [ValidateSet('CurrentUser', 'AllUsers')] + [System.String] + $Scope = 'CurrentUser', + + [Parameter()] + [System.String] + $Gallery = 'PSGallery', + + [Parameter()] + [System.Management.Automation.PSCredential] + $GalleryCredential, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $AllowOldPowerShellGetModule, + + [Parameter()] + [System.String] + $MinimumPSDependVersion, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $AllowPrerelease, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $WithYAML +) + +try +{ + if ($PSVersionTable.PSVersion.Major -le 5) + { + if (-not (Get-Command -Name 'Import-PowerShellDataFile' -ErrorAction 'SilentlyContinue')) + { + Import-Module -Name Microsoft.PowerShell.Utility -RequiredVersion '3.1.0.0' + } + + <# + Making sure the imported PackageManagement module is not from PS7 module + path. The VSCode PS extension is changing the $env:PSModulePath and + prioritize the PS7 path. This is an issue with PowerShellGet because + it loads an old version if available (or fail to load latest). + #> + Get-Module -ListAvailable PackageManagement | + Where-Object -Property 'ModuleBase' -NotMatch 'powershell.7' | + Select-Object -First 1 | + Import-Module -Force + } + + Write-Verbose -Message 'Importing Bootstrap default parameters from ''$PSScriptRoot/Resolve-Dependency.psd1''.' + + $resolveDependencyConfigPath = Join-Path -Path $PSScriptRoot -ChildPath '.\Resolve-Dependency.psd1' -Resolve -ErrorAction 'Stop' + + $resolveDependencyDefaults = Import-PowerShellDataFile -Path $resolveDependencyConfigPath + + $parameterToDefault = $MyInvocation.MyCommand.ParameterSets.Where{ $_.Name -eq $PSCmdlet.ParameterSetName }.Parameters.Keys + + if ($parameterToDefault.Count -eq 0) + { + $parameterToDefault = $MyInvocation.MyCommand.Parameters.Keys + } + + # Set the parameters available in the Parameter Set, or it's not possible to choose yet, so all parameters are an option. + foreach ($parameterName in $parameterToDefault) + { + if (-not $PSBoundParameters.Keys.Contains($parameterName) -and $resolveDependencyDefaults.ContainsKey($parameterName)) + { + Write-Verbose -Message "Setting $parameterName with $($resolveDependencyDefaults[$parameterName])." + + try + { + $variableValue = $resolveDependencyDefaults[$parameterName] + + if ($variableValue -is [System.String]) + { + $variableValue = $ExecutionContext.InvokeCommand.ExpandString($variableValue) + } + + $PSBoundParameters.Add($parameterName, $variableValue) + + Set-Variable -Name $parameterName -value $variableValue -Force -ErrorAction 'SilentlyContinue' + } + catch + { + Write-Verbose -Message "Error adding default for $parameterName : $($_.Exception.Message)." + } + } + } +} +catch +{ + Write-Warning -Message "Error attempting to import Bootstrap's default parameters from '$resolveDependencyConfigPath': $($_.Exception.Message)." +} + +Write-Progress -Activity 'Bootstrap:' -PercentComplete 0 -CurrentOperation 'NuGet Bootstrap' + +# TODO: This should handle the parameter $AllowOldPowerShellGetModule. +$powerShellGetModule = Import-Module -Name 'PowerShellGet' -MinimumVersion '2.0' -ErrorAction 'SilentlyContinue' -PassThru + +# Install the package provider if it is not available. +$nuGetProvider = Get-PackageProvider -Name 'NuGet' -ListAvailable | Select-Object -First 1 + +if (-not $powerShellGetModule -and -not $nuGetProvider) +{ + $providerBootstrapParameters = @{ + Name = 'nuget' + Force = $true + ForceBootstrap = $true + ErrorAction = 'Stop' + } + + switch ($PSBoundParameters.Keys) + { + 'Proxy' + { + $providerBootstrapParameters.Add('Proxy', $Proxy) + } + + 'ProxyCredential' + { + $providerBootstrapParameters.Add('ProxyCredential', $ProxyCredential) + } + + 'Scope' + { + $providerBootstrapParameters.Add('Scope', $Scope) + } + } + + if ($AllowPrerelease) + { + $providerBootstrapParameters.Add('AllowPrerelease', $true) + } + + Write-Information -MessageData 'Bootstrap: Installing NuGet Package Provider from the web (Make sure Microsoft addresses/ranges are allowed).' + + $null = Install-PackageProvider @providerBootstrapParams + + $nuGetProvider = Get-PackageProvider -Name 'NuGet' -ListAvailable | Select-Object -First 1 + + $nuGetProviderVersion = $nuGetProvider.Version.ToString() + + Write-Information -MessageData "Bootstrap: Importing NuGet Package Provider version $nuGetProviderVersion to current session." + + $Null = Import-PackageProvider -Name 'NuGet' -RequiredVersion $nuGetProviderVersion -Force +} + +Write-Progress -Activity 'Bootstrap:' -PercentComplete 10 -CurrentOperation "Ensuring Gallery $Gallery is trusted" + +# Fail if the given PSGallery is not registered. +$previousGalleryInstallationPolicy = (Get-PSRepository -Name $Gallery -ErrorAction 'Stop').InstallationPolicy + +Set-PSRepository -Name $Gallery -InstallationPolicy 'Trusted' -ErrorAction 'Ignore' + +try +{ + Write-Progress -Activity 'Bootstrap:' -PercentComplete 25 -CurrentOperation 'Checking PowerShellGet' + + # Ensure the module is loaded and retrieve the version you have. + $powerShellGetVersion = (Import-Module -Name 'PowerShellGet' -PassThru -ErrorAction 'SilentlyContinue').Version + + Write-Verbose -Message "Bootstrap: The PowerShellGet version is $powerShellGetVersion" + + # Versions below 2.0 are considered old, unreliable & not recommended + if (-not $powerShellGetVersion -or ($powerShellGetVersion -lt [System.Version] '2.0' -and -not $AllowOldPowerShellGetModule)) + { + Write-Progress -Activity 'Bootstrap:' -PercentComplete 40 -CurrentOperation 'Installing newer version of PowerShellGet' + + $installPowerShellGetParameters = @{ + Name = 'PowerShellGet' + Force = $True + SkipPublisherCheck = $true + AllowClobber = $true + Scope = $Scope + Repository = $Gallery + } + + switch ($PSBoundParameters.Keys) + { + 'Proxy' + { + $installPowerShellGetParameters.Add('Proxy', $Proxy) + } + + 'ProxyCredential' + { + $installPowerShellGetParameters.Add('ProxyCredential', $ProxyCredential) + } + + 'GalleryCredential' + { + $installPowerShellGetParameters.Add('Credential', $GalleryCredential) + } + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 60 -CurrentOperation 'Installing newer version of PowerShellGet' + + Install-Module @installPowerShellGetParameters + + Remove-Module -Name 'PowerShellGet' -Force -ErrorAction 'SilentlyContinue' + Remove-Module -Name 'PackageManagement' -Force + + $powerShellGetModule = Import-Module PowerShellGet -Force -PassThru + + $powerShellGetVersion = $powerShellGetModule.Version.ToString() + + Write-Information -MessageData "Bootstrap: PowerShellGet version loaded is $powerShellGetVersion" + } + + # Try to import the PSDepend module from the available modules. + $getModuleParameters = @{ + Name = 'PSDepend' + ListAvailable = $true + } + + $psDependModule = Get-Module @getModuleParameters + + if ($PSBoundParameters.ContainsKey('MinimumPSDependVersion')) + { + try + { + $psDependModule = $psDependModule | Where-Object -FilterScript { $_.Version -ge $MinimumPSDependVersion } + } + catch + { + throw ('There was a problem finding the minimum version of PSDepend. Error: {0}' -f $_) + } + } + + if (-not $psDependModule) + { + # PSDepend module not found, installing or saving it. + if ($PSDependTarget -in 'CurrentUser', 'AllUsers') + { + Write-Debug -Message "PSDepend module not found. Attempting to install from Gallery $Gallery." + + Write-Warning -Message "Installing PSDepend in $PSDependTarget Scope." + + $installPSDependParameters = @{ + Name = 'PSDepend' + Repository = $Gallery + Force = $true + Scope = $PSDependTarget + SkipPublisherCheck = $true + AllowClobber = $true + } + + if ($MinimumPSDependVersion) + { + $installPSDependParameters.Add('MinimumVersion', $MinimumPSDependVersion) + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 75 -CurrentOperation "Installing PSDepend from $Gallery" + + Install-Module @installPSDependParameters + } + else + { + Write-Debug -Message "PSDepend module not found. Attempting to Save from Gallery $Gallery to $PSDependTarget" + + $saveModuleParameters = @{ + Name = 'PSDepend' + Repository = $Gallery + Path = $PSDependTarget + Force = $true + } + + if ($MinimumPSDependVersion) + { + $saveModuleParameters.add('MinimumVersion', $MinimumPSDependVersion) + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 75 -CurrentOperation "Saving & Importing PSDepend from $Gallery to $Scope" + + Save-Module @saveModuleParameters + } + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 80 -CurrentOperation 'Loading PSDepend' + + $importModulePSDependParameters = @{ + Name = 'PSDepend' + ErrorAction = 'Stop' + Force = $true + } + + if ($PSBoundParameters.ContainsKey('MinimumPSDependVersion')) + { + $importModulePSDependParameters.Add('MinimumVersion', $MinimumPSDependVersion) + } + + # We should have successfully bootstrapped PSDepend. Fail if not available. + $null = Import-Module @importModulePSDependParameters + + if ($WithYAML) + { + Write-Progress -Activity 'Bootstrap:' -PercentComplete 82 -CurrentOperation 'Verifying PowerShell module PowerShell-Yaml' + + if (-not (Get-Module -ListAvailable -Name 'PowerShell-Yaml')) + { + Write-Progress -Activity 'Bootstrap:' -PercentComplete 85 -CurrentOperation 'Installing PowerShell module PowerShell-Yaml' + + Write-Verbose -Message "PowerShell-Yaml module not found. Attempting to Save from Gallery $Gallery to $PSDependTarget" + + $SaveModuleParam = @{ + Name = 'PowerShell-Yaml' + Repository = $Gallery + Path = $PSDependTarget + Force = $true + } + + Save-Module @SaveModuleParam + } + else + { + Write-Verbose "PowerShell-Yaml is already available" + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 88 -CurrentOperation 'Importing PowerShell module PowerShell-Yaml' + + Import-Module -Name 'PowerShell-Yaml' -ErrorAction 'Stop' + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 90 -CurrentOperation 'Invoke PSDepend' + + Write-Progress -Activity "PSDepend:" -PercentComplete 0 -CurrentOperation "Restoring Build Dependencies" + + if (Test-Path -Path $DependencyFile) + { + $psDependParameters = @{ + Force = $true + Path = $DependencyFile + } + + # TODO: Handle when the Dependency file is in YAML, and -WithYAML is specified. + Invoke-PSDepend @psDependParameters + } + + Write-Progress -Activity "PSDepend:" -PercentComplete 100 -CurrentOperation "Dependencies restored" -Completed + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 100 -CurrentOperation "Bootstrap complete" -Completed +} +finally +{ + # Reverting the Installation Policy for the given gallery + Set-PSRepository -Name $Gallery -InstallationPolicy $previousGalleryInstallationPolicy + Write-Verbose -Message "Project Bootstrapped, returning to Invoke-Build" +} diff --git a/Resolve-Dependency.psd1 b/Resolve-Dependency.psd1 new file mode 100644 index 0000000..2ae8c0d --- /dev/null +++ b/Resolve-Dependency.psd1 @@ -0,0 +1,5 @@ +@{ + Gallery = 'PSGallery' + AllowPrerelease = $false + WithYAML = $true +} diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f3c8ba8 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,36 @@ +## Security + +The DSC Community takes the security of our modules seriously, which includes all +source code repositories managed through our GitHub organization. + +If you believe you have found a security vulnerability in DscResource.Base, please +report it to us as described below. + +## Reporting Security Issues + +**Please do not report security vulnerabilities through public GitHub issues.** + +Instead, please create a [security advisory](https://github.com/dsccommunity/DscResource.Base/security/advisories). +Then please make one or several members of the DSC Community organization aware +that you created a security advisory. The easiest way to do so is to send us a +direct message via twitter or slack. + +You should receive a response within 48 hours. If for some reason you do not, +please follow up to other member of the community. + +Please include the requested information listed below (as much as you can provide) +to help us better understand the nature and scope of the possible issue: + +- Type of issue +- Full paths of source file(s) related to the manifestation of the issue +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- Step-by-step instructions to reproduce the issue +- Proof-of-concept or exploit code (if possible) +- Impact of the issue, including how an attacker might exploit the issue + +This information will help us triage your report more quickly. + +## Preferred Languages + +We prefer all communications to be in English. diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 0000000..03c9672 --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,256 @@ +trigger: + branches: + include: + - main + paths: + include: + - source/* + tags: + include: + - "v*" + exclude: + - "*-*" + +variables: + buildFolderName: output + buildArtifactName: output + testResultFolderName: testResults + testArtifactName: 'testResults_$(System.JobAttempt)' + sourceFolderName: source + defaultBranch: main + +stages: + - stage: Build + jobs: + - job: Package_Module + displayName: 'Package Module' + pool: + vmImage: 'windows-latest' + steps: + - pwsh: | + dotnet tool install --global GitVersion.Tool + $gitVersionObject = dotnet-gitversion | ConvertFrom-Json + $gitVersionObject.PSObject.Properties.ForEach{ + Write-Host -Object "Setting Task Variable '$($_.Name)' with value '$($_.Value)'." + Write-Host -Object "##vso[task.setvariable variable=$($_.Name);]$($_.Value)" + } + Write-Host -Object "##vso[build.updatebuildnumber]$($gitVersionObject.FullSemVer)" + displayName: Calculate ModuleVersion (GitVersion) + - task: PowerShell@2 + name: package + displayName: 'Build & Package Module' + inputs: + filePath: './build.ps1' + arguments: '-ResolveDependency -tasks pack' + pwsh: true + env: + ModuleVersion: $(NuGetVersionV2) + - task: PublishPipelineArtifact@1 + displayName: 'Publish Build Artifact' + inputs: + targetPath: '$(buildFolderName)/' + artifact: $(buildArtifactName) + publishLocation: 'pipeline' + parallel: true + + - stage: Test + dependsOn: Build + jobs: + - job: Test_HQRM + displayName: 'HQRM' + pool: + vmImage: 'windows-2022' + timeoutInMinutes: 0 + steps: + - task: DownloadPipelineArtifact@2 + displayName: 'Download Build Artifact' + inputs: + buildType: 'current' + artifactName: $(buildArtifactName) + targetPath: '$(Build.SourcesDirectory)/$(buildFolderName)' + # This task need to use Windows PowerShell due to a bug in PS7 that cannot + # find/use class-based DSC resources that uses inheritance, which result in + # the examples cannot compile. See the following issue for more information: + # https://github.com/dsccommunity/DnsServerDsc/issues/268#issuecomment-918505230 + - powershell: | + # Workaround for issue https://github.com/dsccommunity/DscResource.Test/issues/100 + ./build.ps1 -Task noop + + $pesterConfig = New-PesterConfiguration + $pesterConfig.Run.Path = '.\tests\QA' + $pesterConfig.Run.Throw = $true + $pesterConfig.Output.Verbosity = 'Detailed' + + Invoke-Pester -Configuration $pesterConfig + name: qualityTest + displayName: 'Run DscResource.Base QA Test' + - task: PowerShell@2 + name: test + displayName: 'Run HQRM Test' + condition: succeededOrFailed() + inputs: + filePath: './build.ps1' + arguments: '-Tasks hqrmtest' + pwsh: false + - task: PublishTestResults@2 + displayName: 'Publish Test Results' + condition: succeededOrFailed() + inputs: + testResultsFormat: 'NUnit' + testResultsFiles: '$(buildFolderName)/$(testResultFolderName)/NUnit*.xml' + testRunTitle: 'HQRM' + + - job: Test_Unit + displayName: 'Unit' + pool: + vmImage: 'windows-2019' + timeoutInMinutes: 0 + steps: + - task: DownloadPipelineArtifact@2 + displayName: 'Download Build Artifact' + inputs: + buildType: 'current' + artifactName: $(buildArtifactName) + targetPath: '$(Build.SourcesDirectory)/$(buildFolderName)' + - task: PowerShell@2 + name: test + displayName: 'Run Unit Test' + inputs: + filePath: './build.ps1' + arguments: "-Tasks test -PesterPath 'tests/Unit'" + pwsh: true + - task: PublishTestResults@2 + displayName: 'Publish Test Results' + condition: succeededOrFailed() + inputs: + testResultsFormat: 'NUnit' + testResultsFiles: '$(buildFolderName)/$(testResultFolderName)/NUnit*.xml' + testRunTitle: 'Unit (Windows Server 2019)' + - task: PublishPipelineArtifact@1 + displayName: 'Publish Test Artifact' + condition: succeededOrFailed() + inputs: + targetPath: '$(buildFolderName)/$(testResultFolderName)/' + artifactName: $(testArtifactName) + parallel: true + + - job: Test_Integration + displayName: 'Integration' + strategy: + matrix: + POWERSHELL_WIN2019: + JOB_VMIMAGE: 'windows-2019' + JOB_PWSH: true + POWERSHELL_WIN2022: + JOB_VMIMAGE: 'windows-2022' + JOB_PWSH: true + WINDOWSPOWERSHELL_WIN2019: # cSpell: disable-line + JOB_VMIMAGE: 'windows-2019' + JOB_PWSH: false + WINDOWSPOWERSHELL_WIN2022: # cSpell: disable-line + JOB_VMIMAGE: 'windows-2022' + JOB_PWSH: false + pool: + vmImage: $(JOB_VMIMAGE) + timeoutInMinutes: 0 + variables: + # This sets environment variable $env:CI. + CI: true + steps: + - task: DownloadPipelineArtifact@2 + displayName: 'Download Build Artifact' + inputs: + buildType: 'current' + artifactName: $(buildArtifactName) + targetPath: '$(Build.SourcesDirectory)/$(buildFolderName)' + - task: PowerShell@2 + name: test + displayName: 'Run Integration Test' + inputs: + filePath: './build.ps1' + arguments: "-Tasks test -CodeCoverageThreshold 0 -PesterPath 'tests/Integration'" + pwsh: $(JOB_PWSH) + - task: PublishTestResults@2 + displayName: 'Publish Test Results' + condition: succeededOrFailed() + inputs: + testResultsFormat: 'NUnit' + testResultsFiles: '$(buildFolderName)/$(testResultFolderName)/NUnit*.xml' + testRunTitle: 'Integration ($(TEST_CONFIGURATION) / $(JOB_VMIMAGE))' + + - job: Code_Coverage + displayName: 'Publish Code Coverage' + dependsOn: Test_Unit + condition: succeededOrFailed() + pool: + vmImage: 'ubuntu-latest' + timeoutInMinutes: 0 + steps: + - task: DownloadPipelineArtifact@2 + displayName: 'Download Build Artifact' + inputs: + buildType: 'current' + artifactName: $(buildArtifactName) + targetPath: '$(Build.SourcesDirectory)/$(buildFolderName)' + - task: DownloadPipelineArtifact@2 + displayName: 'Download Test Artifact' + inputs: + buildType: 'current' + artifactName: $(testArtifactName) + targetPath: '$(Build.SourcesDirectory)/$(buildFolderName)/$(testResultFolderName)' + - task: PublishCodeCoverageResults@1 + displayName: 'Publish Code Coverage to Azure DevOps' + inputs: + codeCoverageTool: 'JaCoCo' + summaryFileLocation: '$(Build.SourcesDirectory)/$(buildFolderName)/$(testResultFolderName)/JaCoCo_coverage.xml' + pathToSources: '$(Build.SourcesDirectory)/$(sourceFolderName)/' + - script: | + bash <(curl -s https://codecov.io/bash) -f "./$(buildFolderName)/$(testResultFolderName)/JaCoCo_coverage.xml" -F unit + displayName: 'Publish Code Coverage to Codecov.io' + + - stage: Deploy + dependsOn: Test + condition: | + and( + succeeded(), + or( + eq(variables['Build.SourceBranch'], 'refs/heads/main'), + startsWith(variables['Build.SourceBranch'], 'refs/tags/') + ), + contains(variables['System.TeamFoundationCollectionUri'], 'dsccommunity') + ) + jobs: + - job: Deploy_Module + displayName: 'Deploy Module' + pool: + vmImage: 'ubuntu-latest' + steps: + - task: DownloadPipelineArtifact@2 + displayName: 'Download Build Artifact' + inputs: + buildType: 'current' + artifactName: $(buildArtifactName) + targetPath: '$(Build.SourcesDirectory)/$(buildFolderName)' + - task: PowerShell@2 + name: publishRelease + displayName: 'Publish Release' + inputs: + filePath: './build.ps1' + arguments: '-tasks publish' + pwsh: true + env: + GitHubToken: $(GitHubToken) + GalleryApiToken: $(GalleryApiToken) + ReleaseBranch: $(defaultBranch) + MainGitBranch: $(defaultBranch) + - task: PowerShell@2 + name: sendChangelogPR + displayName: 'Send Changelog PR' + inputs: + filePath: './build.ps1' + arguments: '-tasks Create_ChangeLog_GitHub_PR' + pwsh: true + env: + GitHubToken: $(GitHubToken) + ReleaseBranch: $(defaultBranch) + MainGitBranch: $(defaultBranch) diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..19da5e4 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,513 @@ +<# + .DESCRIPTION + Bootstrap and build script for PowerShell module CI/CD pipeline + + .PARAMETER Tasks + The task or tasks to run. The default value is '.' (runs the default task). + + .PARAMETER CodeCoverageThreshold + The code coverage target threshold to uphold. Set to 0 to disable. + The default value is '' (empty string). + + .PARAMETER BuildConfig + Not yet written. + + .PARAMETER OutputDirectory + Specifies the folder to build the artefact into. The default value is 'output'. + + .PARAMETER BuiltModuleSubdirectory + Subdirectory name to build the module (under $OutputDirectory). The default + value is '' (empty string). + + .PARAMETER RequiredModulesDirectory + Can be a path (relative to $PSScriptRoot or absolute) to tell Resolve-Dependency + and PSDepend where to save the required modules. It is also possible to use + 'CurrentUser' och 'AllUsers' to install missing dependencies. You can override + the value for PSDepend in the Build.psd1 build manifest. The default value is + 'output/RequiredModules'. + + .PARAMETER PesterScript + One or more paths that will override the Pester configuration in build + configuration file when running the build task Invoke_Pester_Tests. + + If running Pester 5 test, use the alias PesterPath to be future-proof. + + .PARAMETER PesterTag + Filter which tags to run when invoking Pester tests. This is used in the + Invoke-Pester.pester.build.ps1 tasks. + + .PARAMETER PesterExcludeTag + Filter which tags to exclude when invoking Pester tests. This is used in + the Invoke-Pester.pester.build.ps1 tasks. + + .PARAMETER DscTestTag + Filter which tags to run when invoking DSC Resource tests. This is used + in the DscResource.Test.build.ps1 tasks. + + .PARAMETER DscTestExcludeTag + Filter which tags to exclude when invoking DSC Resource tests. This is + used in the DscResource.Test.build.ps1 tasks. + + .PARAMETER ResolveDependency + Not yet written. + + .PARAMETER BuildInfo + The build info object from ModuleBuilder. Defaults to an empty hashtable. + + .PARAMETER AutoRestore + Not yet written. +#> +[CmdletBinding()] +param +( + [Parameter(Position = 0)] + [System.String[]] + $Tasks = '.', + + [Parameter()] + [System.String] + $CodeCoverageThreshold = '', + + [Parameter()] + [System.String] + [ValidateScript( + { Test-Path -Path $_ } + )] + $BuildConfig, + + [Parameter()] + [System.String] + $OutputDirectory = 'output', + + [Parameter()] + [System.String] + $BuiltModuleSubdirectory = '', + + [Parameter()] + [System.String] + $RequiredModulesDirectory = $(Join-Path 'output' 'RequiredModules'), + + [Parameter()] + # This alias is to prepare for the rename of this parameter to PesterPath when Pester 4 support is removed + [Alias('PesterPath')] + [System.Object[]] + $PesterScript, + + [Parameter()] + [System.String[]] + $PesterTag, + + [Parameter()] + [System.String[]] + $PesterExcludeTag, + + [Parameter()] + [System.String[]] + $DscTestTag, + + [Parameter()] + [System.String[]] + $DscTestExcludeTag, + + [Parameter()] + [Alias('bootstrap')] + [System.Management.Automation.SwitchParameter] + $ResolveDependency, + + [Parameter(DontShow)] + [AllowNull()] + [System.Collections.Hashtable] + $BuildInfo, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $AutoRestore +) + +<# + The BEGIN block (at the end of this file) handles the Bootstrap of the Environment + before Invoke-Build can run the tasks if the parameter ResolveDependency (or + parameter alias Bootstrap) is specified. +#> + +process +{ + + if ($MyInvocation.ScriptName -notLike '*Invoke-Build.ps1') + { + # Only run the process block through InvokeBuild (look at the Begin block at the bottom of this script). + return + } + + # Execute the Build process from the .build.ps1 path. + Push-Location -Path $PSScriptRoot -StackName 'BeforeBuild' + + try + { + Write-Host -Object "[build] Parsing defined tasks" -ForeGroundColor Magenta + + # Load the default BuildInfo if the parameter BuildInfo is not set. + if (-not $PSBoundParameters.ContainsKey('BuildInfo')) + { + try + { + if (Test-Path -Path $BuildConfig) + { + $configFile = Get-Item -Path $BuildConfig + + Write-Host -Object "[build] Loading Configuration from $configFile" + + $BuildInfo = switch -Regex ($configFile.Extension) + { + # Native Support for PSD1 + '\.psd1' + { + if (-not (Get-Command -Name Import-PowerShellDataFile -ErrorAction SilentlyContinue)) + { + Import-Module -Name Microsoft.PowerShell.Utility -RequiredVersion 3.1.0.0 + } + + Import-PowerShellDataFile -Path $BuildConfig + } + + # Support for yaml when module PowerShell-Yaml is available + '\.[yaml|yml]' + { + Import-Module -Name 'powershell-yaml' -ErrorAction Stop + + ConvertFrom-Yaml -Yaml (Get-Content -Raw $configFile) + } + + # Native Support for JSON and JSONC (by Removing comments) + '\.[json|jsonc]' + { + $jsonFile = Get-Content -Raw -Path $configFile + + $jsonContent = $jsonFile -replace '(?m)\s*//.*?$' -replace '(?ms)/\*.*?\*/' + + # Yaml is superset of JSON. + ConvertFrom-Yaml -Yaml $jsonContent + } + + # Unknown extension, return empty hashtable. + default + { + Write-Error -Message "Extension '$_' not supported. using @{}" + + @{ } + } + } + } + else + { + Write-Host -Object "Configuration file '$($BuildConfig.FullName)' not found" -ForegroundColor Red + + # No config file was found, return empty hashtable. + $BuildInfo = @{ } + } + } + catch + { + $logMessage = "Error loading Config '$($BuildConfig.FullName)'.`r`nAre you missing dependencies?`r`nMake sure you run './build.ps1 -ResolveDependency -tasks noop' before running build to restore the required modules." + + Write-Host -Object $logMessage -ForegroundColor Yellow + + $BuildInfo = @{ } + + Write-Error -Message $_.Exception.Message + } + } + + # If the Invoke-Build Task Header is specified in the Build Info, set it. + if ($BuildInfo.TaskHeader) + { + Set-BuildHeader -Script ([scriptblock]::Create($BuildInfo.TaskHeader)) + } + + <# + Add BuildModuleOutput to PSModule Path environment variable. + Moved here (not in begin block) because build file can contains BuiltSubModuleDirectory value. + #> + if ($BuiltModuleSubdirectory) + { + if (-not (Split-Path -IsAbsolute -Path $BuiltModuleSubdirectory)) + { + $BuildModuleOutput = Join-Path -Path $OutputDirectory -ChildPath $BuiltModuleSubdirectory + } + else + { + $BuildModuleOutput = $BuiltModuleSubdirectory + } + } # test if BuiltModuleSubDirectory set in build config file + elseif ($BuildInfo.ContainsKey('BuiltModuleSubDirectory')) + { + $BuildModuleOutput = Join-Path -Path $OutputDirectory -ChildPath $BuildInfo['BuiltModuleSubdirectory'] + } + else + { + $BuildModuleOutput = $OutputDirectory + } + + # Pre-pending $BuildModuleOutput folder to PSModulePath to resolve built module from this folder. + if ($powerShellModulePaths -notcontains $BuildModuleOutput) + { + Write-Host -Object "[build] Pre-pending '$BuildModuleOutput' folder to PSModulePath" -ForegroundColor Green + + $env:PSModulePath = $BuildModuleOutput + [System.IO.Path]::PathSeparator + $env:PSModulePath + } + + <# + Import Tasks from modules via their exported aliases when defined in Build Manifest. + https://github.com/nightroman/Invoke-Build/tree/master/Tasks/Import#example-2-import-from-a-module-with-tasks + #> + if ($BuildInfo.ContainsKey('ModuleBuildTasks')) + { + foreach ($module in $BuildInfo['ModuleBuildTasks'].Keys) + { + try + { + Write-Host -Object "Importing tasks from module $module" -ForegroundColor DarkGray + + $loadedModule = Import-Module -Name $module -PassThru -ErrorAction Stop + + foreach ($TaskToExport in $BuildInfo['ModuleBuildTasks'].($module)) + { + $loadedModule.ExportedAliases.GetEnumerator().Where{ + Write-Host -Object "`t Loading $($_.Key)..." -ForegroundColor DarkGray + + # Using -like to support wildcard. + $_.Key -like $TaskToExport + }.ForEach{ + # Dot-sourcing the Tasks via their exported aliases. + . (Get-Alias $_.Key) + } + } + } + catch + { + Write-Host -Object "Could not load tasks for module $module." -ForegroundColor Red + + Write-Error -Message $_ + } + } + } + + # Loading Build Tasks defined in the .build/ folder (will override the ones imported above if same task name). + Get-ChildItem -Path '.build/' -Recurse -Include '*.ps1' -ErrorAction Ignore | + ForEach-Object { + "Importing file $($_.BaseName)" | Write-Verbose + + . $_.FullName + } + + # Synopsis: Empty task, useful to test the bootstrap process. + task noop { } + + # Define default task sequence ("."), can be overridden in the $BuildInfo. + task . { + Write-Build -Object 'No sequence currently defined for the default task' -ForegroundColor Yellow + } + + Write-Host -Object 'Adding Workflow from configuration:' -ForegroundColor DarkGray + + # Load Invoke-Build task sequences/workflows from $BuildInfo. + foreach ($workflow in $BuildInfo.BuildWorkflow.keys) + { + Write-Verbose -Message "Creating Build Workflow '$Workflow' with tasks $($BuildInfo.BuildWorkflow.($Workflow) -join ', ')." + + $workflowItem = $BuildInfo.BuildWorkflow.($workflow) + + if ($workflowItem.Trim() -match '^\{(?[\w\W]*)\}$') + { + $workflowItem = [ScriptBlock]::Create($Matches['sb']) + } + + Write-Host -Object " +-> $workflow" -ForegroundColor DarkGray + + task $workflow $workflowItem + } + + Write-Host -Object "[build] Executing requested workflow: $($Tasks -join ', ')" -ForeGroundColor Magenta + + } + finally + { + Pop-Location -StackName 'BeforeBuild' + } +} + +Begin +{ + # Find build config if not specified. + if (-not $BuildConfig) + { + $config = Get-ChildItem -Path "$PSScriptRoot\*" -Include 'build.y*ml', 'build.psd1', 'build.json*' -ErrorAction Ignore + + if (-not $config -or ($config -is [System.Array] -and $config.Length -le 0)) + { + throw 'No build configuration found. Specify path via parameter BuildConfig.' + } + elseif ($config -is [System.Array]) + { + if ($config.Length -gt 1) + { + throw 'More than one build configuration found. Specify which path to use via parameter BuildConfig.' + } + + $BuildConfig = $config[0] + } + else + { + $BuildConfig = $config + } + } + + # Bootstrapping the environment before using Invoke-Build as task runner + + if ($MyInvocation.ScriptName -notlike '*Invoke-Build.ps1') + { + Write-Host -Object "[pre-build] Starting Build Init" -ForegroundColor Green + + Push-Location $PSScriptRoot -StackName 'BuildModule' + } + + if ($RequiredModulesDirectory -in @('CurrentUser', 'AllUsers')) + { + # Installing modules instead of saving them. + Write-Host -Object "[pre-build] Required Modules will be installed to the PowerShell module path that is used for $RequiredModulesDirectory." -ForegroundColor Green + + <# + The variable $PSDependTarget will be used below when building the splatting + variable before calling Resolve-Dependency.ps1, unless overridden in the + file Resolve-Dependency.psd1. + #> + $PSDependTarget = $RequiredModulesDirectory + } + else + { + if (-not (Split-Path -IsAbsolute -Path $OutputDirectory)) + { + $OutputDirectory = Join-Path -Path $PSScriptRoot -ChildPath $OutputDirectory + } + + # Resolving the absolute path to save the required modules to. + if (-not (Split-Path -IsAbsolute -Path $RequiredModulesDirectory)) + { + $RequiredModulesDirectory = Join-Path -Path $PSScriptRoot -ChildPath $RequiredModulesDirectory + } + + # Create the output/modules folder if not exists, or resolve the Absolute path otherwise. + if (Resolve-Path -Path $RequiredModulesDirectory -ErrorAction SilentlyContinue) + { + Write-Debug -Message "[pre-build] Required Modules path already exist at $RequiredModulesDirectory" + + $requiredModulesPath = Convert-Path -Path $RequiredModulesDirectory + } + else + { + Write-Host -Object "[pre-build] Creating required modules directory $RequiredModulesDirectory." -ForegroundColor Green + + $requiredModulesPath = (New-Item -ItemType Directory -Force -Path $RequiredModulesDirectory).FullName + } + + $powerShellModulePaths = $env:PSModulePath -split [System.IO.Path]::PathSeparator + + # Pre-pending $requiredModulesPath folder to PSModulePath to resolve from this folder FIRST. + if ($RequiredModulesDirectory -notin @('CurrentUser', 'AllUsers') -and + ($powerShellModulePaths -notcontains $RequiredModulesDirectory)) + { + Write-Host -Object "[pre-build] Pre-pending '$RequiredModulesDirectory' folder to PSModulePath" -ForegroundColor Green + + $env:PSModulePath = $RequiredModulesDirectory + [System.IO.Path]::PathSeparator + $env:PSModulePath + } + + $powerShellYamlModule = Get-Module -Name 'powershell-yaml' -ListAvailable + $invokeBuildModule = Get-Module -Name 'InvokeBuild' -ListAvailable + $psDependModule = Get-Module -Name 'PSDepend' -ListAvailable + + # Checking if the user should -ResolveDependency. + if (-not ($powerShellYamlModule -and $invokeBuildModule -and $psDependModule) -and -not $ResolveDependency) + { + if ($AutoRestore -or -not $PSBoundParameters.ContainsKey('Tasks') -or $Tasks -contains 'build') + { + Write-Host -Object "[pre-build] Dependency missing, running './build.ps1 -ResolveDependency -Tasks noop' for you `r`n" -ForegroundColor Yellow + + $ResolveDependency = $true + } + else + { + Write-Warning -Message "Some required Modules are missing, make sure you first run with the '-ResolveDependency' parameter. Running 'build.ps1 -ResolveDependency -Tasks noop' will pull required modules without running the build task." + } + } + + <# + The variable $PSDependTarget will be used below when building the splatting + variable before calling Resolve-Dependency.ps1, unless overridden in the + file Resolve-Dependency.psd1. + #> + $PSDependTarget = $requiredModulesPath + } + + if ($ResolveDependency) + { + Write-Host -Object "[pre-build] Resolving dependencies." -ForegroundColor Green + $resolveDependencyParams = @{ } + + # If BuildConfig is a Yaml file, bootstrap powershell-yaml via ResolveDependency. + if ($BuildConfig -match '\.[yaml|yml]$') + { + $resolveDependencyParams.Add('WithYaml', $true) + } + + $resolveDependencyAvailableParams = (Get-Command -Name '.\Resolve-Dependency.ps1').Parameters.Keys + + foreach ($cmdParameter in $resolveDependencyAvailableParams) + { + # The parameter has been explicitly used for calling the .build.ps1 + if ($MyInvocation.BoundParameters.ContainsKey($cmdParameter)) + { + $paramValue = $MyInvocation.BoundParameters.ContainsKey($cmdParameter) + + Write-Debug " adding $cmdParameter :: $paramValue [from user-provided parameters to Build.ps1]" + + $resolveDependencyParams.Add($cmdParameter, $paramValue) + } + # Use defaults parameter value from Build.ps1, if any + else + { + $paramValue = Get-Variable -Name $cmdParameter -ValueOnly -ErrorAction Ignore + + if ($paramValue) + { + Write-Debug " adding $cmdParameter :: $paramValue [from default Build.ps1 variable]" + + $resolveDependencyParams.Add($cmdParameter, $paramValue) + } + } + } + + Write-Host -Object "[pre-build] Starting bootstrap process." -ForegroundColor Green + + .\Resolve-Dependency.ps1 @resolveDependencyParams + } + + if ($MyInvocation.ScriptName -notlike '*Invoke-Build.ps1') + { + Write-Verbose -Message "Bootstrap completed. Handing back to InvokeBuild." + + if ($PSBoundParameters.ContainsKey('ResolveDependency')) + { + Write-Verbose -Message "Dependency already resolved. Removing task." + + $null = $PSBoundParameters.Remove('ResolveDependency') + } + + Write-Host -Object "[build] Starting build with InvokeBuild." -ForegroundColor Green + + Invoke-Build @PSBoundParameters -Task $Tasks -File $MyInvocation.MyCommand.Path + + Pop-Location -StackName 'BuildModule' + + return + } +} diff --git a/build.yaml b/build.yaml new file mode 100644 index 0000000..34db77a --- /dev/null +++ b/build.yaml @@ -0,0 +1,155 @@ +--- +#################################################### +# Pipeline Build Task Configuration (Invoke-Build) # +#################################################### +BuildWorkflow: + '.': + - build + - test + + build: + - Clean + - Build_Module_ModuleBuilder + - Build_NestedModules_ModuleBuilder + - Create_Changelog_Release_Output + - Generate_Conceptual_Help + - Generate_Wiki_Content + + pack: + - build + - package_module_nupkg + + hqrmtest: + - Invoke_HQRM_Tests_Stop_On_Fail + + test: + - Pester_Tests_Stop_On_Fail + - Convert_Pester_Coverage + - Pester_If_Code_Coverage_Under_Threshold + + publish: + - Publish_Release_To_GitHub + - Publish_Module_To_gallery + - Publish_GitHub_Wiki_Content + +#################################################### +# ModuleBuilder Configuration # +#################################################### +CopyPaths: + - en-US +Prefix: prefix.ps1 +Encoding: UTF8 +VersionedOutputDirectory: true +BuiltModuleSubdirectory: builtModule + +ModuleBuildTasks: + Sampler: + - '*.build.Sampler.ib.tasks' + Sampler.GitHubTasks: + - '*.ib.tasks' + DscResource.DocGenerator: + - 'Task.*' + DscResource.Test: + - 'Task.*' + +TaskHeader: | + param($Path) + "" + "=" * 79 + Write-Build Cyan "`t`t`t$($Task.Name.replace("_"," ").ToUpper())" + Write-Build DarkGray "$(Get-BuildSynopsis $Task)" + "-" * 79 + Write-Build DarkGray " $Path" + Write-Build DarkGray " $($Task.InvocationInfo.ScriptName):$($Task.InvocationInfo.ScriptLineNumber)" + "" + +#################################################### +# Dependent Modules Configuration (Sampler) # +#################################################### +NestedModule: + DscResource.Common: + CopyOnly: true + Path: ./output/RequiredModules/DscResource.Common + AddToManifest: false + Exclude: PSGetModuleInfo.xml + +#################################################### +# Pester Configuration (Sampler) # +#################################################### +Pester: + Configuration: + Run: + Path: + - tests/Unit + Output: + Verbosity: Detailed + StackTraceVerbosity: Full + CIFormat: Auto + CodeCoverage: + CoveragePercentTarget: 85 + OutputPath: JaCoCo_coverage.xml + OutputEncoding: ascii + # There is a bug in Pester when running unit tests for classes when 'UseBreakpoints' is turned off. + # See error in gist: https://gist.github.com/johlju/c16dfd9587c7e066e8825fc54b33a703 + UseBreakpoints: true + TestResult: + OutputFormat: NUnitXML + OutputEncoding: ascii + ExcludeFromCodeCoverage: + - Modules/DscResource.Common + +#################################################### +# Pester Configuration (DscResource.Test) # +#################################################### +DscTest: + Pester: + Configuration: + Filter: + ExcludeTag: + - "Common Tests - New Error-Level Script Analyzer Rules" + Output: + Verbosity: Detailed + CIFormat: Auto + TestResult: + OutputFormat: NUnitXML + OutputEncoding: ascii + OutputPath: ./output/testResults/NUnitXml_HQRM_Tests.xml + Script: + ExcludeSourceFile: + - output + ExcludeModuleFile: + - Modules/DscResource.Common + # Must exclude built module file because it should not be tested like MOF-based resources + - DscResource.Base.psm1 + MainGitBranch: main + +#################################################### +# PSDepend Configuration # +#################################################### +Resolve-Dependency: + Gallery: 'PSGallery' + AllowPrerelease: false + Verbose: false + +#################################################### +# GitHub Configuration # +#################################################### +GitHubConfig: + GitHubFilesToAdd: + - 'CHANGELOG.md' + GitHubConfigUserName: dscbot + GitHubConfigUserEmail: dsccommunity@outlook.com + UpdateChangelogOnPrerelease: false + +#################################################### +# DscResource.DocGenerator Configuration # +#################################################### +DscResource.DocGenerator: + Generate_Conceptual_Help: + MarkdownCodeRegularExpression: + - '\`(.+?)\`' # Match inline code-block + - '\\(\\)' # Match escaped backslash + - '\[[^\[]+\]\((.+?)\)' # Match markdown URL + - '_(.+?)_' # Match Italic (underscore) + - '\*\*(.+?)\*\*' # Match bold + - '\*(.+?)\*' # Match Italic (asterisk) diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..450bb88 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,38 @@ +codecov: + require_ci_to_pass: no + # main should be the baseline for reporting + branch: main + +comment: + layout: "reach, diff, flags, files" + behavior: default + show_carryforward_flags: true + +coverage: + range: 50..80 + round: down + precision: 0 + + status: + project: + default: + # Set the overall project code coverage requirement to 70% + target: 70 + flags: + - unit + patch: + default: + # Set the pull request requirement to not regress overall coverage by more than 5% + # and let codecov.io set the goal for the code changed in the patch. + target: auto + threshold: 5 + flags: + - unit +flags: + unit: + paths: + - source/ + carryforward: true + +fixes: + - '^\d+\.\d+\.\d+::source' # move path "X.Y.Z" => "source" diff --git a/source/Classes/002.Reason.ps1 b/source/Classes/002.Reason.ps1 new file mode 100644 index 0000000..086cee3 --- /dev/null +++ b/source/Classes/002.Reason.ps1 @@ -0,0 +1,10 @@ +class Reason +{ + [DscProperty()] + [System.String] + $Code + + [DscProperty()] + [System.String] + $Phrase +} diff --git a/source/Classes/010.ResourceBase.ps1 b/source/Classes/010.ResourceBase.ps1 new file mode 100644 index 0000000..54231f4 --- /dev/null +++ b/source/Classes/010.ResourceBase.ps1 @@ -0,0 +1,283 @@ +<# + .SYNOPSIS + A class with methods that are equal for all class-based resources. + + .DESCRIPTION + A class with methods that are equal for all class-based resources. + + .NOTES + This class should be able to be inherited by all DSC resources. This class + shall not contain any DSC properties, neither shall it contain anything + specific to only a single resource. +#> + +class ResourceBase +{ + # Property for holding localization strings + hidden [System.Collections.Hashtable] $localizedData = @{} + + # Property for derived class to set properties that should not be enforced. + hidden [System.String[]] $ExcludeDscProperties = @() + + # Default constructor + ResourceBase() + { + $this.ImportLocalization($null) + } + + ResourceBase([System.String] $BasePath) + { + $this.ImportLocalization($BasePath) + } + + hidden [void] ImportLocalization([System.String] $BasePath) + { + $getLocalizedDataRecursiveParameters = @{ + ClassName = ($this | Get-ClassName -Recurse) + } + + if (-not [System.String]::IsNullOrEmpty($BasePath)) + { + <# + Passing the base directory of the module that contain the + derived class. + #> + $getLocalizedDataRecursiveParameters.BaseDirectory = $BasePath + } + + <# + TODO: When this fails, for example when the localized string file is missing + the LCM returns the error 'Failed to create an object of PowerShell + class SqlDatabasePermission' instead of the actual error that occurred. + #> + $this.localizedData = Get-LocalizedDataRecursive @getLocalizedDataRecursiveParameters + } + + [ResourceBase] Get() + { + $this.Assert() + + # Get all key properties. + $keyProperty = $this | Get-DscProperty -Attribute 'Key' + + Write-Verbose -Message ($this.localizedData.GetCurrentState -f $this.GetType().Name, ($keyProperty | ConvertTo-Json -Compress)) + + $getCurrentStateResult = $this.GetCurrentState($keyProperty) + + $dscResourceObject = [System.Activator]::CreateInstance($this.GetType()) + + # Set values returned from the derived class' GetCurrentState(). + foreach ($propertyName in $this.PSObject.Properties.Name) + { + if ($propertyName -in @($getCurrentStateResult.Keys)) + { + $dscResourceObject.$propertyName = $getCurrentStateResult.$propertyName + } + } + + $keyPropertyAddedToCurrentState = $false + + # Set key property values unless it was returned from the derived class' GetCurrentState(). + foreach ($propertyName in $keyProperty.Keys) + { + if ($propertyName -notin @($getCurrentStateResult.Keys)) + { + # Add the key value to the instance to be returned. + $dscResourceObject.$propertyName = $this.$propertyName + + $keyPropertyAddedToCurrentState = $true + } + } + + if (($this | Test-DscProperty -Name 'Ensure') -and -not $getCurrentStateResult.ContainsKey('Ensure')) + { + # Evaluate if we should set Ensure property. + if ($keyPropertyAddedToCurrentState) + { + <# + A key property was added to the current state, assume its because + the object did not exist in the current state. Set Ensure to Absent. + #> + $dscResourceObject.Ensure = [Ensure]::Absent + $getCurrentStateResult.Ensure = [Ensure]::Absent + } + else + { + $dscResourceObject.Ensure = [Ensure]::Present + $getCurrentStateResult.Ensure = [Ensure]::Present + } + } + + <# + Returns all enforced properties not in desires state, or $null if + all enforced properties are in desired state. + #> + $propertiesNotInDesiredState = $this.Compare($getCurrentStateResult, @()) + + <# + Return the correct values for Reasons property if the derived DSC resource + has such property and it hasn't been already set by GetCurrentState(). + #> + if (($this | Test-DscProperty -Name 'Reasons') -and -not $getCurrentStateResult.ContainsKey('Reasons')) + { + # Always return an empty array if all properties are in desired state. + $dscResourceObject.Reasons = $propertiesNotInDesiredState | + ConvertTo-Reason -ResourceName $this.GetType().Name + } + + # Return properties. + return $dscResourceObject + } + + [void] Set() + { + # Get all key properties. + $keyProperty = $this | Get-DscProperty -Attribute 'Key' + + Write-Verbose -Message ($this.localizedData.SetDesiredState -f $this.GetType().Name, ($keyProperty | ConvertTo-Json -Compress)) + + $this.Assert() + + <# + Returns all enforced properties not in desires state, or $null if + all enforced properties are in desired state. + #> + $propertiesNotInDesiredState = $this.Compare() + + if ($propertiesNotInDesiredState) + { + $propertiesToModify = $propertiesNotInDesiredState | ConvertFrom-CompareResult + + $propertiesToModify.Keys | + ForEach-Object -Process { + Write-Verbose -Message ($this.localizedData.SetProperty -f $_, $propertiesToModify.$_) + } + + <# + Call the Modify() method with the properties that should be enforced + and was not in desired state. + #> + $this.Modify($propertiesToModify) + } + else + { + Write-Verbose -Message $this.localizedData.NoPropertiesToSet + } + } + + [System.Boolean] Test() + { + # Get all key properties. + $keyProperty = $this | Get-DscProperty -Attribute 'Key' + + Write-Verbose -Message ($this.localizedData.TestDesiredState -f $this.GetType().Name, ($keyProperty | ConvertTo-Json -Compress)) + + $this.Assert() + + $isInDesiredState = $true + + <# + Returns all enforced properties not in desires state, or $null if + all enforced properties are in desired state. + #> + $propertiesNotInDesiredState = $this.Compare() + + if ($propertiesNotInDesiredState) + { + $isInDesiredState = $false + } + + if ($isInDesiredState) + { + Write-Verbose $this.localizedData.InDesiredState + } + else + { + Write-Verbose $this.localizedData.NotInDesiredState + } + + return $isInDesiredState + } + + <# + Returns a hashtable containing all properties that should be enforced and + are not in desired state, or $null if all enforced properties are in + desired state. + + This method should normally not be overridden. + #> + hidden [System.Collections.Hashtable[]] Compare() + { + # Get the current state, all properties except Read properties . + $currentState = $this.Get() | Get-DscProperty -Attribute @('Key', 'Mandatory', 'Optional') + + return $this.Compare($currentState, @()) + } + + <# + Returns a hashtable containing all properties that should be enforced and + are not in desired state, or $null if all enforced properties are in + desired state. + + This method should normally not be overridden. + #> + hidden [System.Collections.Hashtable[]] Compare([System.Collections.Hashtable] $currentState, [System.String[]] $excludeProperties) + { + # Get the desired state, all assigned properties that has an non-null value. + $desiredState = $this | Get-DscProperty -Attribute @('Key', 'Mandatory', 'Optional') -HasValue + + $CompareDscParameterState = @{ + CurrentValues = $currentState + DesiredValues = $desiredState + Properties = $desiredState.Keys + ExcludeProperties = ($excludeProperties + $this.ExcludeDscProperties) | Select-Object -Unique + IncludeValue = $true + # This is needed to sort complex types. + SortArrayValues = $true + } + + <# + Returns all enforced properties not in desires state, or $null if + all enforced properties are in desired state. + #> + return (Compare-DscParameterState @CompareDscParameterState) + } + + # This method should normally not be overridden. + hidden [void] Assert() + { + # Get the properties that has a non-null value and is not of type Read. + $desiredState = $this | Get-DscProperty -Attribute @('Key', 'Mandatory', 'Optional') -HasValue + + $this.AssertProperties($desiredState) + } + + <# + This method can be overridden if resource specific property asserts are + needed. The parameter properties will contain the properties that was + assigned a value. + #> + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('AvoidEmptyNamedBlocks', '')] + hidden [void] AssertProperties([System.Collections.Hashtable] $properties) + { + } + + <# + This method must be overridden by a resource. The parameter properties will + contain the properties that should be enforced and that are not in desired + state. + #> + hidden [void] Modify([System.Collections.Hashtable] $properties) + { + throw $this.localizedData.ModifyMethodNotImplemented + } + + <# + This method must be overridden by a resource. The parameter properties will + contain the key properties. + #> + hidden [System.Collections.Hashtable] GetCurrentState([System.Collections.Hashtable] $properties) + { + throw $this.localizedData.GetCurrentStateMethodNotImplemented + } +} diff --git a/source/DscResource.Base.psd1 b/source/DscResource.Base.psd1 new file mode 100644 index 0000000..e0a0bec --- /dev/null +++ b/source/DscResource.Base.psd1 @@ -0,0 +1,70 @@ +@{ + # Script module or binary module file associated with this manifest. + RootModule = 'DscResource.Base.psm1' + + # Version number of this module. + ModuleVersion = '0.0.1' + + # ID used to uniquely identify this module + GUID = '693ee082-ed36-45a7-b490-88b07c86b42f' + + # Author of this module + Author = 'DSC Community' + + # Company or vendor of this module + CompanyName = 'DSC Community' + + # Copyright statement for this module + Copyright = 'Copyright the DSC Community contributors. All rights reserved.' + + # Description of the functionality provided by this module + Description = 'Module with DSC resources for deployment and configuration of Microsoft SQL Server.' + + # Minimum version of the Windows PowerShell engine required by this module + PowerShellVersion = '5.0' + + # Minimum version of the common language runtime (CLR) required by this module + CLRVersion = '4.0' + + # Functions to export from this module + FunctionsToExport = @() + + # Cmdlets to export from this module + CmdletsToExport = @() + + # Variables to export from this module + VariablesToExport = @() + + # Aliases to export from this module + AliasesToExport = @() + + DscResourcesToExport = @() + + RequiredAssemblies = @() + + # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. + PrivateData = @{ + + PSData = @{ + # Set to a prerelease string value if the release should be a prerelease. + Prerelease = '' + + # Tags applied to this module. These help with module discovery in online galleries. + Tags = @('DesiredStateConfiguration', 'DSC', 'DSCResourceKit', 'DSCResource') + + # A URL to the license for this module. + LicenseUri = 'https://github.com/dsccommunity/DscResource.Base/blob/main/LICENSE' + + # A URL to the main website for this project. + ProjectUri = 'https://github.com/dsccommunity/DscResource.Base' + + # A URL to an icon representing this module. + IconUri = 'https://dsccommunity.org/images/DSC_Logo_300p.png' + + # ReleaseNotes of this module + ReleaseNotes = '' + + } # End of PSData hashtable + + } # End of PrivateData hashtable +} diff --git a/source/Enum/1.Ensure.ps1 b/source/Enum/1.Ensure.ps1 new file mode 100644 index 0000000..8f0c9e0 --- /dev/null +++ b/source/Enum/1.Ensure.ps1 @@ -0,0 +1,10 @@ +<# + .SYNOPSIS + The possible states for the DSC resource parameter Ensure. +#> + +enum Ensure +{ + Present + Absent +} diff --git a/source/Private/ConvertFrom-CompareResult.ps1 b/source/Private/ConvertFrom-CompareResult.ps1 new file mode 100644 index 0000000..8481481 --- /dev/null +++ b/source/Private/ConvertFrom-CompareResult.ps1 @@ -0,0 +1,47 @@ +<# + .SYNOPSIS + Returns a hashtable with property name and their expected value. + + .DESCRIPTION + Returns a hashtable with property name and their expected value. + + .PARAMETER CompareResult + The result from Compare-DscParameterState. + + .EXAMPLE + ConvertFrom-CompareResult -CompareResult (Compare-DscParameterState) + + Returns a hashtable that contain all the properties not in desired state + and their expected value. + + .OUTPUTS + [System.Collections.Hashtable] +#> +function ConvertFrom-CompareResult +{ + [CmdletBinding()] + [OutputType([System.Collections.Hashtable])] + param + ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [System.Collections.Hashtable[]] + $CompareResult + ) + + begin + { + $returnHashtable = @{} + } + + process + { + $CompareResult | ForEach-Object -Process { + $returnHashtable[$_.Property] = $_.ExpectedValue + } + } + + end + { + return $returnHashtable + } +} diff --git a/source/Private/ConvertTo-Reason.ps1 b/source/Private/ConvertTo-Reason.ps1 new file mode 100644 index 0000000..6586273 --- /dev/null +++ b/source/Private/ConvertTo-Reason.ps1 @@ -0,0 +1,119 @@ +<# + .SYNOPSIS + Returns a array of the type `[Reason]`. + + .DESCRIPTION + This command converts the array of properties that is returned by the command + `Compare-DscParameterState`. The result is an array of the type `[Reason]` that + can be returned in a DSC resource's property **Reasons**. + + .PARAMETER Property + The result from the command Compare-DscParameterState. + + .PARAMETER ResourceName + The name of the resource. Will be used to populate the property Code with + the correct value. + + .EXAMPLE + ConvertTo-Reason -Property (Compare-DscParameterState) -ResourceName 'MyResource' + + Returns an array of `[Reason]` that contain all the properties not in desired + state and why a specific property is not in desired state. + + .OUTPUTS + [Reason[]] +#> +function ConvertTo-Reason +{ + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('UseSyntacticallyCorrectExamples', '', Justification = 'Because the rule does not yet support parsing the code when the output type is not available. The ScriptAnalyzer rule UseSyntacticallyCorrectExamples will always error in the editor due to https://github.com/indented-automation/Indented.ScriptAnalyzerRules/issues/8.')] + [CmdletBinding()] + [OutputType([Reason[]])] + param + ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [AllowEmptyCollection()] + [AllowNull()] + [System.Collections.Hashtable[]] + $Property, + + [Parameter(Mandatory = $true)] + [System.String] + $ResourceName + ) + + begin + { + # Always return an empty array if there are no properties to add. + $reasons = [Reason[]] @() + } + + process + { + foreach ($currentProperty in $Property) + { + if ($currentProperty.ExpectedValue -is [System.Enum]) + { + # Return the string representation of the value (instead of the numeric value). + $propertyExpectedValue = $currentProperty.ExpectedValue.ToString() + } + else + { + $propertyExpectedValue = $currentProperty.ExpectedValue + } + + if ($property.ActualValue -is [System.Enum]) + { + # Return the string representation of the value so that conversion to json is correct. + $propertyActualValue = $currentProperty.ActualValue.ToString() + } + else + { + $propertyActualValue = $currentProperty.ActualValue + } + + <# + In PowerShell 7 the command ConvertTo-Json returns 'null' on null + value, but not in Windows PowerShell. Switch to output empty string + if value is null. + #> + if ($PSVersionTable.PSEdition -eq 'Desktop') + { + if ($null -eq $propertyExpectedValue) + { + $propertyExpectedValue = '' + } + + if ($null -eq $propertyActualValue) + { + $propertyActualValue = '' + } + } + + # Convert the value to Json to be able to easily visualize complex types + $propertyActualValueJson = $propertyActualValue | ConvertTo-Json -Compress + $propertyExpectedValueJson = $propertyExpectedValue | ConvertTo-Json -Compress + + # If the property name contain the word Path, remove '\\' from path. + if ($currentProperty.Property -match 'Path') + { + $propertyActualValueJson = $propertyActualValueJson -replace '\\\\', '\' + $propertyExpectedValueJson = $propertyExpectedValueJson -replace '\\\\', '\' + } + + $reasons += [Reason] @{ + Code = '{0}:{0}:{1}' -f $ResourceName, $currentProperty.Property + # Convert the object to JSON to handle complex types. + Phrase = 'The property {0} should be {1}, but was {2}' -f @( + $currentProperty.Property, + $propertyExpectedValueJson, + $propertyActualValueJson + ) + } + } + } + + end + { + return $reasons + } +} diff --git a/source/Private/Get-ClassName.ps1 b/source/Private/Get-ClassName.ps1 new file mode 100644 index 0000000..f60ace7 --- /dev/null +++ b/source/Private/Get-ClassName.ps1 @@ -0,0 +1,70 @@ +<# + .SYNOPSIS + Get the class name of the passed object, and optional an array with + all inherited classes. + + .DESCRIPTION + Get the class name of the passed object, and optional an array with + all inherited classes + + .PARAMETER InputObject + The object to be evaluated. + + .PARAMETER Recurse + Specifies if the class name of inherited classes shall be returned. The + recursive stops when the first object of the type `[System.Object]` is + found. + + .EXAMPLE + Get-ClassName -InputObject $this -Recurse + + Get the class name of the current instance and all the inherited (parent) + classes. + + .OUTPUTS + [System.String[]] +#> +function Get-ClassName +{ + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Because the rule does not understands that the command returns [System.String[]] when using , (comma) in the return statement')] + [CmdletBinding()] + [OutputType([System.String[]])] + param + ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [PSObject] + $InputObject, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $Recurse + ) + + begin + { + # Create a list of the inherited class names + $class = @() + } + + process + { + $class += $InputObject.GetType().FullName + + if ($Recurse.IsPresent) + { + $parentClass = $InputObject.GetType().BaseType + + while ($parentClass -ne [System.Object]) + { + $class += $parentClass.FullName + + $parentClass = $parentClass.BaseType + } + } + } + + end + { + return , [System.String[]] $class + } +} diff --git a/source/Private/Get-LocalizedDataRecursive.ps1 b/source/Private/Get-LocalizedDataRecursive.ps1 new file mode 100644 index 0000000..1a4dd31 --- /dev/null +++ b/source/Private/Get-LocalizedDataRecursive.ps1 @@ -0,0 +1,114 @@ +<# + .SYNOPSIS + Get the localization strings data from one or more localization string files. + + .DESCRIPTION + Get the localization strings data from one or more localization string files. + This can be used in classes to be able to inherit localization strings + from one or more parent (base) classes. + + The order of class names passed to parameter `ClassName` determines the order + of importing localization string files. First entry's localization string file + will be imported first, then next entry's localization string file, and so on. + If the second (or any consecutive) entry's localization string file contain a + localization string key that existed in a previous imported localization string + file that localization string key will be ignored. Making it possible for a + child class to override localization strings from one or more parent (base) + classes. + + .PARAMETER ClassName + An array of class names, normally provided by `Get-ClassName -Recurse`. + + .PARAMETER BaseDirectory + Specifies a base module path where it also searches for localization string + files. + + .EXAMPLE + Get-LocalizedDataRecursive -ClassName $InputObject.GetType().FullName + + Returns a hashtable containing all the localized strings for the current + instance. + + .EXAMPLE + Get-LocalizedDataRecursive -ClassName (Get-ClassNamn -InputObject $this -Recurse) + + Returns a hashtable containing all the localized strings for the current + instance and any inherited (parent) classes. + + .OUTPUTS + [System.Collections.Hashtable] +#> +function Get-LocalizedDataRecursive +{ + [CmdletBinding()] + [OutputType([System.Collections.Hashtable])] + param + ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [System.String[]] + $ClassName, + + [Parameter()] + [System.String] + $BaseDirectory + ) + + begin + { + $localizedData = @{} + } + + process + { + foreach ($name in $ClassName) + { + if ($name -match '\.psd1$') + { + # Assume we got full file name. + $localizationFileName = $name -replace '\.psd1$' + } + else + { + # Assume we only got class name. + $localizationFileName = '{0}.strings' -f $name + } + + Write-Debug -Message ('Importing localization data from {0}' -f $localizationFileName) + + if ($name -eq 'ResourceBase') + { + # The class ResourceBase will always be in the same module as this command. + $path = $PSScriptRoot + } + elseif ($null -ne $BaseDirectory) + { + # Assuming derived class that is not part of this module. + $path = $BaseDirectory + } + else + { + # Assuming derived class that is not part of this module. + throw 'The class ''{0}'' is not part of module DscResource.Base and no BaseDirectory was passed. Please provide BaseDirectory.' + } + + # Get localized data for the class + $classLocalizationStrings = Get-LocalizedData -DefaultUICulture 'en-US' -BaseDirectory $path -FileName $localizationFileName -ErrorAction 'Stop' + + # Append only previously unspecified keys in the localization data + foreach ($key in $classLocalizationStrings.Keys) + { + if (-not $localizedData.ContainsKey($key)) + { + $localizedData[$key] = $classLocalizationStrings[$key] + } + } + } + } + + end + { + Write-Debug -Message ('Localization data: {0}' -f ($localizedData | ConvertTo-JSON)) + + return $localizedData + } +} diff --git a/source/WikiSource/Home.md b/source/WikiSource/Home.md new file mode 100644 index 0000000..a1d30b6 --- /dev/null +++ b/source/WikiSource/Home.md @@ -0,0 +1,32 @@ +# Welcome to the DscResource.Base wiki + +*DscResource.Base v#.#.#* + +Here you will find all the information you need to make use of the class +`ResourceBase`. + +Please leave comments, feature requests, and bug reports for this module in +the [issues section](https://github.com/dsccommunity/DscResource.Base/issues) +for this repository. + +## Getting started + +## Prerequisites + +- Powershell 5.0 or higher + +### Powershell + +It is recommended to use Windows Management Framework (PowerShell) version 5.1. + +The minimum Windows Management Framework (PowerShell) version required is 5.0, +which ships with Windows 10 or Windows Server 2016, but can also be installed +on Windows 7 SP1, Windows 8.1, Windows Server 2012, and Windows Server 2012 R2. + +To use in PowerShell (v7.x) it must be configured to run class-based resources. +See other documentation resources on how to make PowerShell work with class-based +resources + +## Change log + +A full list of changes in each version can be found in the [change log](https://github.com/dsccommunity/DscResource.Base/blob/main/CHANGELOG.md). diff --git a/source/en-US/DscResource.Base.strings.psd1 b/source/en-US/DscResource.Base.strings.psd1 new file mode 100644 index 0000000..0e9964d --- /dev/null +++ b/source/en-US/DscResource.Base.strings.psd1 @@ -0,0 +1,9 @@ +<# + .SYNOPSIS + The localized resource strings in English (en-US) for the + DscResource.Base module. This file should only contain + localized strings for private and public functions. +#> + +ConvertFrom-StringData @' +'@ diff --git a/source/en-US/ResourceBase.strings.psd1 b/source/en-US/ResourceBase.strings.psd1 new file mode 100644 index 0000000..05f4ff4 --- /dev/null +++ b/source/en-US/ResourceBase.strings.psd1 @@ -0,0 +1,17 @@ +<# + .SYNOPSIS + The localized resource strings in English (en-US) for the + class ResourceBase. +#> + +ConvertFrom-StringData @' + GetCurrentState = Getting the current state for resource '{0}' using the key property '{1}'. (RB0001) + TestDesiredState = Determining the current state for resource '{0}' using the key property '{1}'. (RB0002) + SetDesiredState = Setting the desired state for resource '{0}' using the key property '{1}'. (RB0003) + NotInDesiredState = The current state is not the desired state. (RB0004) + InDesiredState = The current state is the desired state. (RB0005) + SetProperty = The property '{0}' will be set to '{1}'. (RB0006) + NoPropertiesToSet = All properties are in desired state. (RB0007) + ModifyMethodNotImplemented = An override for the method Modify() is not implemented in the resource. (RB0008) + GetCurrentStateMethodNotImplemented = An override for the method GetCurrentState() is not implemented in the resource. (RB0009) +'@ diff --git a/source/en-US/about_DscResource.Base.help.txt b/source/en-US/about_DscResource.Base.help.txt new file mode 100644 index 0000000..94b4323 --- /dev/null +++ b/source/en-US/about_DscResource.Base.help.txt @@ -0,0 +1,25 @@ +TOPIC + about_DscResource.Base + +SHORT DESCRIPTION + Contains standard classes for class-based resource development. + +LONG DESCRIPTION + This module contains standard classes for class-based resource development. + +EXAMPLES + PS C:\> using module 'DscResource.Base' + +NOTE: + Thank you to the DSC Community contributors who contributed to this module by + writing code, sharing opinions, and provided feedback. + +TROUBLESHOOTING NOTE: + Go to the Github repository for read about issues, submit a new issue, and read + about new releases. https://github.com/dsccommunity/DscResource.Base + +SEE ALSO + - https://github.com/dsccommunity/DscResource.Base + +KEYWORDS + DSC, DscResource, SqlServer diff --git a/source/prefix.ps1 b/source/prefix.ps1 new file mode 100644 index 0000000..d643005 --- /dev/null +++ b/source/prefix.ps1 @@ -0,0 +1,4 @@ +$script:dscResourceCommonModulePath = Join-Path -Path $PSScriptRoot -ChildPath 'Modules/DscResource.Common' +Import-Module -Name $script:dscResourceCommonModulePath + +$script:localizedData = Get-LocalizedData -DefaultUICulture 'en-US' diff --git a/tests/Integration/ResourceBase.Integration.Tests.ps1 b/tests/Integration/ResourceBase.Integration.Tests.ps1 new file mode 100644 index 0000000..6ad6f0e --- /dev/null +++ b/tests/Integration/ResourceBase.Integration.Tests.ps1 @@ -0,0 +1,28 @@ +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies has been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../build.ps1" -Tasks 'noop' 2>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies has not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks build" first.' + } +} + +Describe 'ResourceBase' { + # TODO: We must add integration tests here. +} diff --git a/tests/QA/ScriptAnalyzer.Tests.ps1 b/tests/QA/ScriptAnalyzer.Tests.ps1 new file mode 100644 index 0000000..ff901d4 --- /dev/null +++ b/tests/QA/ScriptAnalyzer.Tests.ps1 @@ -0,0 +1,82 @@ +<# + .SYNOPSIS + Quality test that runs the Script Analyzer with the Script Analyzer settings + file in the .vscode folder. + + .NOTES + In addition to the custom rules that are part of this repository's Script + Analyzer settings file, it will also run the HQRM test that has been run by + the build task 'DscResource_Tests_Stop_On_Fail'. When the issue in the + repository DscResource.Test is resolved this should not be needed. See issue + https://github.com/dsccommunity/DscResource.Test/issues/100. +#> +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies has been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../build.ps1" -Tasks 'noop' 2>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies has not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks build" first.' + } + + $repositoryPath = Resolve-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '../..') + $sourcePath = Join-Path -Path $repositoryPath -ChildPath 'source' + + $moduleFiles = Get-ChildItem -Path $sourcePath -Recurse -Include @('*.psm1', '*.ps1') + + $testCases = @() + + foreach ($moduleFile in $moduleFiles) + { + $moduleFilePathNormalized = $moduleFile.FullName -replace '\\', '/' + $repositoryPathNormalized = $repositoryPath -replace '\\', '/' + $escapedRepositoryPath = [System.Text.RegularExpressions.RegEx]::Escape($repositoryPathNormalized) + $relativePath = $moduleFilePathNormalized -replace ($escapedRepositoryPath + '/') + + $testCases += @{ + ScriptPath = $moduleFile.FullName + RelativePath = $relativePath + } + } +} + +Describe 'Script Analyzer Rules' { + Context 'When there are source files' { + BeforeAll { + $repositoryPath = Resolve-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '../..') + $scriptAnalyzerSettingsPath = Join-Path -Path $repositoryPath -ChildPath '.vscode\analyzersettings.psd1' + } + + It 'Should pass all PS Script Analyzer rules for file ''''' -ForEach $testCases { + $pssaError = Invoke-ScriptAnalyzer -Path $ScriptPath -Settings $scriptAnalyzerSettingsPath + + <# + Filter out rule TypeNotFound. + + TODO: The rule "TypeNotFound" is not excluded correctly even if it is + excluded in the file 'analyzersettings.psd1'. This is a workaround + until it is properly excluded for source files, and instead only + ran for the built module script module file (DscResource.Base.psm1). + #> + $pssaError = $pssaError | Where-Object -FilterScript { $_.RuleName -ne 'TypeNotFound' } + + $report = $pssaError | Format-Table -AutoSize | Out-String -Width 200 + $pssaError | Should -HaveCount 0 -Because "all script analyzer rules should pass.`r`n`r`n $report`r`n" + } + } +} diff --git a/tests/QA/module.tests.ps1 b/tests/QA/module.tests.ps1 new file mode 100644 index 0000000..21c019c --- /dev/null +++ b/tests/QA/module.tests.ps1 @@ -0,0 +1,256 @@ +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies has been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../build.ps1" -Tasks 'noop' 2>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies has not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks build" first.' + } + + $projectPath = "$($PSScriptRoot)\..\.." | Convert-Path + + <# + If the QA tests are run outside of the build script (e.g with Invoke-Pester) + the parent scope has not set the variable $ProjectName. + #> + if (-not $ProjectName) + { + # Assuming project folder name is project name. + $ProjectName = Get-SamplerProjectName -BuildRoot $projectPath + } + + $script:moduleName = $ProjectName + + Remove-Module -Name $script:moduleName -Force -ErrorAction SilentlyContinue + + $mut = Get-Module -Name $script:moduleName -ListAvailable | + Select-Object -First 1 | + Import-Module -Force -ErrorAction Stop -PassThru +} + +BeforeAll { + # Convert-Path required for PS7 or Join-Path fails + $projectPath = "$($PSScriptRoot)\..\.." | Convert-Path + + <# + If the QA tests are run outside of the build script (e.g with Invoke-Pester) + the parent scope has not set the variable $ProjectName. + #> + if (-not $ProjectName) + { + # Assuming project folder name is project name. + $ProjectName = Get-SamplerProjectName -BuildRoot $projectPath + } + + $script:moduleName = $ProjectName + + $sourcePath = ( + Get-ChildItem -Path $projectPath\*\*.psd1 | + Where-Object -FilterScript { + ($_.Directory.Name -match 'source|src' -or $_.Directory.Name -eq $_.BaseName) ` + -and $( + try + { + Test-ModuleManifest -Path $_.FullName -ErrorAction Stop + } + catch + { + $false + } + ) + } + ).Directory.FullName +} + +Describe 'Changelog Management' -Tag 'Changelog' { + It 'Changelog has been updated' -Skip:( + -not ([bool](Get-Command git -ErrorAction SilentlyContinue) -and + [bool](&(Get-Process -Id $PID).Path -NoProfile -Command 'git rev-parse --is-inside-work-tree 2>$null')) + ) { + # Get the list of changed files compared with branch main + $headCommit = &git rev-parse HEAD + $defaultBranchCommit = &git rev-parse origin/main + $filesChanged = &git @('diff', "$defaultBranchCommit...$headCommit", '--name-only') + $filesStagedAndUnstaged = &git @('diff', 'HEAD', '--name-only') + + $filesChanged += $filesStagedAndUnstaged + + # Only check if there are any changed files. + if ($filesChanged) + { + $filesChanged | Should -Contain 'CHANGELOG.md' -Because 'the CHANGELOG.md must be updated with at least one entry in the Unreleased section for each PR' + } + } + + It 'Changelog format compliant with keepachangelog format' -Skip:(![bool](Get-Command git -EA SilentlyContinue)) { + { Get-ChangelogData -Path (Join-Path $ProjectPath 'CHANGELOG.md') -ErrorAction Stop } | Should -Not -Throw + } + + It 'Changelog should have an Unreleased header' -Skip:$skipTest { + (Get-ChangelogData -Path (Join-Path -Path $ProjectPath -ChildPath 'CHANGELOG.md') -ErrorAction Stop).Unreleased | Should -Not -BeNullOrEmpty + } +} + +Describe 'General module control' -Tags 'FunctionalQuality' { + It 'Should import without errors' { + { Import-Module -Name $script:moduleName -Force -ErrorAction Stop } | Should -Not -Throw + + Get-Module -Name $script:moduleName | Should -Not -BeNullOrEmpty + } + + It 'Should remove without error' { + { Remove-Module -Name $script:moduleName -ErrorAction Stop } | Should -Not -Throw + + Get-Module $script:moduleName | Should -BeNullOrEmpty + } +} + +BeforeDiscovery { + # Must use the imported module to build test cases. + $allModuleFunctions = & $mut { Get-Command -Module $args[0] -CommandType Function } $script:moduleName + + # Build test cases. + $testCases = @() + + foreach ($function in $allModuleFunctions) + { + $testCases += @{ + Name = $function.Name + } + } +} + +Describe 'Quality for module' -Tags 'TestQuality' { + BeforeDiscovery { + if (Get-Command -Name Invoke-ScriptAnalyzer -ErrorAction SilentlyContinue) + { + $scriptAnalyzerRules = Get-ScriptAnalyzerRule + } + else + { + if ($ErrorActionPreference -ne 'Stop') + { + Write-Warning -Message 'ScriptAnalyzer not found!' + } + else + { + throw 'ScriptAnalyzer not found!' + } + } + } + + It 'Should have a unit test for ' -ForEach $testCases { + Get-ChildItem -Path 'tests\' -Recurse -Include "$Name.Tests.ps1" | Should -Not -BeNullOrEmpty + } + + It 'Should pass Script Analyzer for ' -ForEach $testCases -Skip:(-not $scriptAnalyzerRules) { + $functionFile = Get-ChildItem -Path $sourcePath -Recurse -Include "$Name.ps1" + + $pssaResult = (Invoke-ScriptAnalyzer -Path $functionFile.FullName) + $report = $pssaResult | Format-Table -AutoSize | Out-String -Width 110 + $pssaResult | Should -BeNullOrEmpty -Because ` + "some rule triggered.`r`n`r`n $report" + } +} + +Describe 'Help for module' -Tags 'helpQuality' { + It 'Should have .SYNOPSIS for ' -ForEach $testCases { + $functionFile = Get-ChildItem -Path $sourcePath -Recurse -Include "$Name.ps1" + + $scriptFileRawContent = Get-Content -Raw -Path $functionFile.FullName + + $abstractSyntaxTree = [System.Management.Automation.Language.Parser]::ParseInput($scriptFileRawContent, [ref] $null, [ref] $null) + + $astSearchDelegate = { $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] } + + $parsedFunction = $abstractSyntaxTree.FindAll( $astSearchDelegate, $true ) | + Where-Object -FilterScript { + $_.Name -eq $Name + } + + $functionHelp = $parsedFunction.GetHelpContent() + + $functionHelp.Synopsis | Should -Not -BeNullOrEmpty + } + + It 'Should have a .DESCRIPTION with length greater than 40 characters for ' -ForEach $testCases { + $functionFile = Get-ChildItem -Path $sourcePath -Recurse -Include "$Name.ps1" + + $scriptFileRawContent = Get-Content -Raw -Path $functionFile.FullName + + $abstractSyntaxTree = [System.Management.Automation.Language.Parser]::ParseInput($scriptFileRawContent, [ref] $null, [ref] $null) + + $astSearchDelegate = { $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] } + + $parsedFunction = $abstractSyntaxTree.FindAll($astSearchDelegate, $true) | + Where-Object -FilterScript { + $_.Name -eq $Name + } + + $functionHelp = $parsedFunction.GetHelpContent() + + $functionHelp.Description.Length | Should -BeGreaterThan 40 + } + + It 'Should have at least one (1) example for ' -ForEach $testCases { + $functionFile = Get-ChildItem -Path $sourcePath -Recurse -Include "$Name.ps1" + + $scriptFileRawContent = Get-Content -Raw -Path $functionFile.FullName + + $abstractSyntaxTree = [System.Management.Automation.Language.Parser]::ParseInput($scriptFileRawContent, [ref] $null, [ref] $null) + + $astSearchDelegate = { $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] } + + $parsedFunction = $abstractSyntaxTree.FindAll( $astSearchDelegate, $true ) | + Where-Object -FilterScript { + $_.Name -eq $Name + } + + $functionHelp = $parsedFunction.GetHelpContent() + + $functionHelp.Examples.Count | Should -BeGreaterThan 0 + $functionHelp.Examples[0] | Should -Match ([regex]::Escape($function.Name)) + $functionHelp.Examples[0].Length | Should -BeGreaterThan ($function.Name.Length + 10) + + } + + It 'Should have described all parameters for ' -ForEach $testCases { + $functionFile = Get-ChildItem -Path $sourcePath -Recurse -Include "$Name.ps1" + + $scriptFileRawContent = Get-Content -Raw -Path $functionFile.FullName + + $abstractSyntaxTree = [System.Management.Automation.Language.Parser]::ParseInput($scriptFileRawContent, [ref] $null, [ref] $null) + + $astSearchDelegate = { $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] } + + $parsedFunction = $abstractSyntaxTree.FindAll( $astSearchDelegate, $true ) | + Where-Object -FilterScript { + $_.Name -eq $Name + } + + $functionHelp = $parsedFunction.GetHelpContent() + + $parameters = $parsedFunction.Body.ParamBlock.Parameters.Name.VariablePath.ForEach({ $_.ToString() }) + + foreach ($parameter in $parameters) + { + $functionHelp.Parameters.($parameter.ToUpper()) | Should -Not -BeNullOrEmpty -Because ('the parameter {0} must have a description' -f $parameter) + $functionHelp.Parameters.($parameter.ToUpper()).Length | Should -BeGreaterThan 25 -Because ('the parameter {0} must have descriptive description' -f $parameter) + } + } +} diff --git a/tests/Unit/Classes/Reason.Tests.ps1 b/tests/Unit/Classes/Reason.Tests.ps1 new file mode 100644 index 0000000..eb79e6a --- /dev/null +++ b/tests/Unit/Classes/Reason.Tests.ps1 @@ -0,0 +1,70 @@ +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies has been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../build.ps1" -Tasks 'noop' 2>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies has not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks build" first.' + } +} + +BeforeAll { + $script:dscModuleName = 'DscResource.Base' + + Import-Module -Name $script:dscModuleName + + $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Mock:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Should:ModuleName'] = $script:dscModuleName +} + +AfterAll { + $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') + $PSDefaultParameterValues.Remove('Mock:ModuleName') + $PSDefaultParameterValues.Remove('Should:ModuleName') + + # Unload the module being tested so that it doesn't impact any other tests. + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force +} + +Describe 'Reason' -Tag 'Reason' { + Context 'When instantiating the class' { + It 'Should not throw an error' { + $script:mockReasonInstance = InModuleScope -ScriptBlock { + [Reason]::new() + } + } + + It 'Should be of the correct type' { + $mockReasonInstance | Should -Not -BeNullOrEmpty + $mockReasonInstance.GetType().Name | Should -Be 'Reason' + } + } + + Context 'When setting an reading values' { + It 'Should be able to set value in instance' { + $mockReasonInstance.Code = '{0}:{0}:Ensure' -f $mockReasonInstance.GetType() + $mockReasonInstance.Phrase = 'It should be absent, but it was present.' + } + + It 'Should be able read the values from instance' { + $mockReasonInstance.Code | Should -Be ('{0}:{0}:Ensure' -f 'Reason') + $mockReasonInstance.Phrase = 'It should be absent, but it was present.' + } + } +} diff --git a/tests/Unit/Classes/ResourceBase.Tests.ps1 b/tests/Unit/Classes/ResourceBase.Tests.ps1 new file mode 100644 index 0000000..24c78eb --- /dev/null +++ b/tests/Unit/Classes/ResourceBase.Tests.ps1 @@ -0,0 +1,1315 @@ +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies has been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../build.ps1" -Tasks 'noop' 2>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies has not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks build" first.' + } +} + +BeforeAll { + $script:dscModuleName = 'DscResource.Base' + + Import-Module -Name $script:dscModuleName + + $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Mock:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Should:ModuleName'] = $script:dscModuleName +} + +AfterAll { + $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') + $PSDefaultParameterValues.Remove('Mock:ModuleName') + $PSDefaultParameterValues.Remove('Should:ModuleName') + + # Unload the module being tested so that it doesn't impact any other tests. + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force +} + +Describe 'ResourceBase' { + Context 'When class is instantiated' { + It 'Should not throw an exception' { + InModuleScope -ScriptBlock { + { [ResourceBase]::new() } | Should -Not -Throw + } + } + + It 'Should have a default or empty constructor' { + InModuleScope -ScriptBlock { + $instance = [ResourceBase]::new() + $instance | Should -Not -BeNullOrEmpty + } + } + + # TODO: This should be uncommented when the PR in Sampler is merged: https://github.com/gaelcolas/Sampler/pull/408 + # It 'Should have a constructor that takes one string argument' { + # InModuleScope -ScriptBlock { + # $instance = [ResourceBase]::new($TestDrive) + # $instance | Should -Not -BeNullOrEmpty + # } + # } + + It 'Should be the correct type' { + InModuleScope -ScriptBlock { + $instance = [ResourceBase]::new() + $instance.GetType().Name | Should -Be 'ResourceBase' + } + } + } +} + +Describe 'ResourceBase\GetCurrentState()' -Tag 'GetCurrentState' { + Context 'When the required methods are not overridden' { + BeforeAll { + $mockResourceBaseInstance = InModuleScope -ScriptBlock { + [ResourceBase]::new() + } + } + + Context 'When there is no override for the method GetCurrentState' { + It 'Should throw the correct error' { + { $mockResourceBaseInstance.GetCurrentState(@{}) } | Should -Throw $mockResourceBaseInstance.GetCurrentStateMethodNotImplemented + } + } + } +} + +Describe 'ResourceBase\Modify()' -Tag 'Modify' { + Context 'When the required methods are not overridden' { + BeforeAll { + $mockResourceBaseInstance = InModuleScope -ScriptBlock { + [ResourceBase]::new() + } + } + + + Context 'When there is no override for the method Modify' { + It 'Should throw the correct error' { + { $mockResourceBaseInstance.Modify(@{}) } | Should -Throw $mockResourceBaseInstance.ModifyMethodNotImplemented + } + } + } +} + +Describe 'ResourceBase\AssertProperties()' -Tag 'AssertProperties' { + BeforeAll { + $mockResourceBaseInstance = InModuleScope -ScriptBlock { + [ResourceBase]::new() + } + } + + + It 'Should not throw' { + $mockDesiredState = @{ + MyProperty1 = 'MyValue1' + } + + { $mockResourceBaseInstance.AssertProperties($mockDesiredState) } | Should -Not -Throw + } +} + +Describe 'ResourceBase\Get()' -Tag 'Get' { + Context 'When the system is in the desired state' { + BeforeAll { + Mock -CommandName Get-ClassName -MockWith { + # Only return localized strings for this class name. + @('ResourceBase') + } + } + + Context 'When the object should be Present' { + BeforeAll { + Mock -CommandName Get-ClassName -MockWith { + # Only return localized strings for this class name. + @('ResourceBase') + } + + <# + Must use a here-string because we need to pass 'using' which must be + first in a scriptblock, but if it is outside the here-string then + PowerShell will fail to parse the test script. + #> + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty()] + [Ensure] + $Ensure = [Ensure]::Present + + [DscProperty()] + [System.String] + $MyResourceProperty2 + + [DscProperty(NotConfigurable)] + [Reason[]] + $Reasons + + <# + This will test so that a key value do not need to be enforced, and still + be returned by Get(). + #> + MyMockResource() : base () + { + # These properties will not be enforced. + $this.ExcludeDscProperties = @( + 'MyResourceKeyProperty1' + ) + } + + [System.Collections.Hashtable] GetCurrentState([System.Collections.Hashtable] $properties) + { + <# + This does not return the key property that is not being enforce, to let + the base class' method Get() return that value. + #> + return @{ + MyResourceKeyProperty1 = 'MyValue1' + MyResourceProperty2 = 'MyValue2' + } + } +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + } + + It 'Should have correctly instantiated the resource class' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance | Should -Not -BeNullOrEmpty + $mockResourceBaseInstance.GetType().BaseType.Name | Should -Be 'ResourceBase' + } + } + + It 'Should return the correct values for the properties' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance.MyResourceKeyProperty1 = 'MyValue1' + $mockResourceBaseInstance.MyResourceProperty2 = 'MyValue2' + + $getResult = $mockResourceBaseInstance.Get() + + $getResult.MyResourceKeyProperty1 | Should -Be 'MyValue1' + $getResult.MyResourceProperty2 | Should -Be 'MyValue2' + $getResult.Ensure | Should -Be ([Ensure]::Present) + $getResult.Reasons | Should -BeNullOrEmpty + } + } + } + + Context 'When the object should be Absent' { + BeforeAll { + Mock -CommandName Get-ClassName -MockWith { + # Only return localized strings for this class name. + @('ResourceBase') + } + + <# + Must use a here-string because we need to pass 'using' which must be + first in a scriptblock, but if it is outside the here-string then + PowerShell will fail to parse the test script. + #> + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty()] + [Ensure] + $Ensure = [Ensure]::Present + + [DscProperty()] + [System.String] + $MyResourceProperty2 + + [DscProperty(NotConfigurable)] + [Reason[]] + $Reasons + + <# + Tests to enforce a key property even if we do not return the key property value + from the method GetCurrentState. + #> + MyMockResource() : base () + { + # Test not to add the key property to the list of properties that are not enforced. + $this.ExcludeDscProperties = @('MyResourceKeyProperty1') + } + + [System.Collections.Hashtable] GetCurrentState([System.Collections.Hashtable] $properties) + { + return @{ + MyResourceProperty2 = $null + } + } +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + } + + It 'Should have correctly instantiated the resource class' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance | Should -Not -BeNullOrEmpty + $mockResourceBaseInstance.GetType().BaseType.Name | Should -Be 'ResourceBase' + } + } + + It 'Should return the correct values for the properties' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance.Ensure = [Ensure]::Absent + $mockResourceBaseInstance.MyResourceKeyProperty1 = 'MyValue1' + + $getResult = $mockResourceBaseInstance.Get() + + $getResult.MyResourceKeyProperty1 | Should -Be 'MyValue1' + $getResult.MyResourceProperty2 | Should -BeNullOrEmpty + $getResult.Ensure | Should -Be ([Ensure]::Absent) + $getResult.Reasons | Should -BeNullOrEmpty + } + } + } + + Context 'When returning Ensure property from method GetCurrentState()' { + Context 'When the configuration should be present' { + BeforeAll { + Mock -CommandName Get-ClassName -MockWith { + # Only return localized strings for this class name. + @('ResourceBase') + } + + <# + Must use a here-string because we need to pass 'using' which must be + first in a scriptblock, but if it is outside the here-string then + PowerShell will fail to parse the test script. + #> + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty()] + [Ensure] + $Ensure = [Ensure]::Present + + [DscProperty()] + [System.String] + $MyResourceProperty2 + + [DscProperty(NotConfigurable)] + [Reason[]] + $Reasons + + [System.Collections.Hashtable] GetCurrentState([System.Collections.Hashtable] $properties) + { + return @{ + Ensure = [Ensure]::Present + MyResourceKeyProperty1 = 'MyValue1' + MyResourceProperty2 = 'MyValue2' + } + } +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + } + + It 'Should have correctly instantiated the resource class' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance | Should -Not -BeNullOrEmpty + $mockResourceBaseInstance.GetType().BaseType.Name | Should -Be 'ResourceBase' + } + } + + It 'Should return the correct values for the properties' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance.MyResourceKeyProperty1 = 'MyValue1' + $mockResourceBaseInstance.MyResourceProperty2 = 'MyValue2' + + $getResult = $mockResourceBaseInstance.Get() + + $getResult.MyResourceKeyProperty1 | Should -Be 'MyValue1' + $getResult.MyResourceProperty2 | Should -Be 'MyValue2' + $getResult.Ensure | Should -Be ([Ensure]::Present) + $getResult.Reasons | Should -BeNullOrEmpty + } + } + } + + Context 'When the configuration should be absent' { + BeforeAll { + Mock -CommandName Get-ClassName -MockWith { + # Only return localized strings for this class name. + @('ResourceBase') + } + + <# + Must use a here-string because we need to pass 'using' which must be + first in a scriptblock, but if it is outside the here-string then + PowerShell will fail to parse the test script. + #> + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty()] + [Ensure] + $Ensure = [Ensure]::Present + + [DscProperty()] + [System.String] + $MyResourceProperty2 + + [DscProperty(NotConfigurable)] + [Reason[]] + $Reasons + + [System.Collections.Hashtable] GetCurrentState([System.Collections.Hashtable] $properties) + { + return @{ + Ensure = [Ensure]::Absent + MyResourceKeyProperty1 = 'MyValue1' + MyResourceProperty2 = $null + } + } +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + } + + It 'Should have correctly instantiated the resource class' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance | Should -Not -BeNullOrEmpty + $mockResourceBaseInstance.GetType().BaseType.Name | Should -Be 'ResourceBase' + } + } + + It 'Should return the correct values for the properties' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance.Ensure = [Ensure]::Absent + $mockResourceBaseInstance.MyResourceKeyProperty1 = 'MyValue1' + + $getResult = $mockResourceBaseInstance.Get() + + $getResult.MyResourceKeyProperty1 | Should -Be 'MyValue1' + $getResult.MyResourceProperty2 | Should -BeNullOrEmpty + $getResult.Ensure | Should -Be ([Ensure]::Absent) + $getResult.Reasons | Should -BeNullOrEmpty + } + } + } + } + } + + Context 'When the system is not in the desired state' { + BeforeAll { + Mock -CommandName Get-ClassName -MockWith { + # Only return localized strings for this class name. + @('ResourceBase') + } + } + + Context 'When the configuration should be present' { + Context 'When a non-mandatory parameter is not in desired state' { + BeforeAll { + <# + Must use a here-string because we need to pass 'using' which must be + first in a scriptblock, but if it is outside the here-string then + PowerShell will fail to parse the test script. + #> + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty()] + [Ensure] + $Ensure = [Ensure]::Present + + [DscProperty()] + [System.String] + $MyResourceProperty2 + + [DscProperty(NotConfigurable)] + [Reason[]] + $Reasons + + MyMockResource() : base () + { + # Test not to add the key property to the list of properties that are not enforced. + $this.ExcludeDscProperties = @('MyResourceKeyProperty1') + } + + [System.Collections.Hashtable] GetCurrentState([System.Collections.Hashtable] $properties) + { + return @{ + MyResourceKeyProperty1 = 'MyValue1' + MyResourceProperty2 = 'MyValue2' + } + } +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + } + + It 'Should have correctly instantiated the resource class' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance | Should -Not -BeNullOrEmpty + $mockResourceBaseInstance.GetType().BaseType.Name | Should -Be 'ResourceBase' + } + } + + It 'Should return the correct values for the properties' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance.MyResourceKeyProperty1 = 'MyValue1' + $mockResourceBaseInstance.MyResourceProperty2 = 'NewValue2' + + $getResult = $mockResourceBaseInstance.Get() + + $getResult.MyResourceKeyProperty1 | Should -Be 'MyValue1' + $getResult.MyResourceProperty2 | Should -Be 'MyValue2' + $getResult.Ensure | Should -Be ([Ensure]::Present) + + $getResult.Reasons | Should -HaveCount 1 + $getResult.Reasons[0].Code | Should -Be 'MyMockResource:MyMockResource:MyResourceProperty2' + $getResult.Reasons[0].Phrase | Should -Be 'The property MyResourceProperty2 should be "NewValue2", but was "MyValue2"' + } + } + } + + Context 'When the object should be Present' { + BeforeAll { + <# + Must use a here-string because we need to pass 'using' which must be + first in a scriptblock, but if it is outside the here-string then + PowerShell will fail to parse the test script. + #> + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty()] + [Ensure] + $Ensure = [Ensure]::Present + + [DscProperty()] + [System.String] + $MyResourceProperty2 + + [DscProperty(NotConfigurable)] + [Reason[]] + $Reasons + + MyMockResource() : base () + { + # Test not to add the key property to the list of properties that are not enforced. + $this.ExcludeDscProperties = @('MyResourceKeyProperty1') + } + + [System.Collections.Hashtable] GetCurrentState([System.Collections.Hashtable] $properties) + { + return @{ + } + } +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + } + + It 'Should have correctly instantiated the resource class' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance | Should -Not -BeNullOrEmpty + $mockResourceBaseInstance.GetType().BaseType.Name | Should -Be 'ResourceBase' + } + } + + It 'Should return the correct values for the properties' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance.MyResourceKeyProperty1 = 'MyValue1' + + $getResult = $mockResourceBaseInstance.Get() + + $getResult.MyResourceKeyProperty1 | Should -Be 'MyValue1' + $getResult.Ensure | Should -Be ([Ensure]::Absent) + + $getResult.Reasons | Should -HaveCount 1 + $getResult.Reasons[0].Code | Should -Be 'MyMockResource:MyMockResource:Ensure' + $getResult.Reasons[0].Phrase | Should -Be 'The property Ensure should be "Present", but was "Absent"' + } + } + } + } + + Context 'When the object should be Absent' { + BeforeAll { + Mock -CommandName Get-ClassName -MockWith { + # Only return localized strings for this class name. + @('ResourceBase') + } + + <# + Must use a here-string because we need to pass 'using' which must be + first in a scriptblock, but if it is outside the here-string then + PowerShell will fail to parse the test script. + #> + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty()] + [Ensure] + $Ensure = [Ensure]::Present + + [DscProperty()] + [System.String] + $MyResourceProperty2 + + [DscProperty(NotConfigurable)] + [Reason[]] + $Reasons + + MyMockResource() : base () + { + # Test not to add the key property to the list of properties that are not enforced. + $this.ExcludeDscProperties = @('MyResourceKeyProperty1') + } + + [System.Collections.Hashtable] GetCurrentState([System.Collections.Hashtable] $properties) + { + return @{ + MyResourceKeyProperty1 = 'MyValue1' + MyResourceProperty2 = 'MyValue2' + } + } +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + } + + It 'Should have correctly instantiated the resource class' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance | Should -Not -BeNullOrEmpty + $mockResourceBaseInstance.GetType().BaseType.Name | Should -Be 'ResourceBase' + } + } + + It 'Should return the correct values for the properties' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance.Ensure = [Ensure]::Absent + $mockResourceBaseInstance.MyResourceKeyProperty1 = 'MyValue1' + + $getResult = $mockResourceBaseInstance.Get() + + $getResult.MyResourceKeyProperty1 | Should -Be 'MyValue1' + $getResult.MyResourceProperty2 | Should -Be 'MyValue2' + $getResult.Ensure | Should -Be ([Ensure]::Present) + + $getResult.Reasons | Should -HaveCount 1 + $getResult.Reasons[0].Code | Should -Be 'MyMockResource:MyMockResource:Ensure' + $getResult.Reasons[0].Phrase | Should -Be 'The property Ensure should be "Absent", but was "Present"' + } + } + } + + Context 'When returning Ensure property from method GetCurrentState()' { + Context 'When the configuration should be present' { + BeforeAll { + <# + Must use a here-string because we need to pass 'using' which must be + first in a scriptblock, but if it is outside the here-string then + PowerShell will fail to parse the test script. + #> + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty()] + [Ensure] + $Ensure = ([Ensure]::Present) + + [DscProperty()] + [System.String] + $MyResourceProperty2 + + [DscProperty(NotConfigurable)] + [Reason[]] + $Reasons + + [System.Collections.Hashtable] GetCurrentState([System.Collections.Hashtable] $properties) + { + return @{ + Ensure = ([Ensure]::Absent) + MyResourceKeyProperty1 = 'MyValue1' + MyResourceProperty2 = 'MyValue2' + } + } +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + } + + It 'Should have correctly instantiated the resource class' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance | Should -Not -BeNullOrEmpty + $mockResourceBaseInstance.GetType().BaseType.Name | Should -Be 'ResourceBase' + } + } + + It 'Should return the correct values for the properties' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance.MyResourceKeyProperty1 = 'MyValue1' + $mockResourceBaseInstance.MyResourceProperty2 = 'NewValue2' + + $getResult = $mockResourceBaseInstance.Get() + + $getResult.MyResourceKeyProperty1 | Should -Be 'MyValue1' + $getResult.MyResourceProperty2 | Should -Be 'MyValue2' + $getResult.Ensure | Should -Be ([Ensure]::Absent) + + $getResult.Reasons | Should -HaveCount 2 + + # The order in the array was sometimes different so could not use array index ($getResult.Reasons[0]). + $getResult.Reasons.Code | Should -Contain 'MyMockResource:MyMockResource:MyResourceProperty2' + $getResult.Reasons.Code | Should -Contain 'MyMockResource:MyMockResource:Ensure' + $getResult.Reasons.Phrase | Should -Contain 'The property MyResourceProperty2 should be "NewValue2", but was "MyValue2"' + $getResult.Reasons.Phrase | Should -Contain 'The property Ensure should be "Present", but was "Absent"' + } + } + } + + Context 'When the configuration should be absent' { + BeforeAll { + Mock -CommandName Get-ClassName -MockWith { + # Only return localized strings for this class name. + @('ResourceBase') + } + + <# + Must use a here-string because we need to pass 'using' which must be + first in a scriptblock, but if it is outside the here-string then + PowerShell will fail to parse the test script. + #> + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty()] + [Ensure] + $Ensure = [Ensure]::Present + + [DscProperty()] + [System.String] + $MyResourceProperty2 + + [DscProperty(NotConfigurable)] + [Reason[]] + $Reasons + + [System.Collections.Hashtable] GetCurrentState([System.Collections.Hashtable] $properties) + { + return @{ + Ensure = [Ensure]::Present + MyResourceKeyProperty1 = 'MyValue1' + MyResourceProperty2 = 'MyValue2' + } + } +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + } + + It 'Should have correctly instantiated the resource class' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance | Should -Not -BeNullOrEmpty + $mockResourceBaseInstance.GetType().BaseType.Name | Should -Be 'ResourceBase' + } + } + + It 'Should return the correct values for the properties' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance.Ensure = [Ensure]::Absent + $mockResourceBaseInstance.MyResourceKeyProperty1 = 'MyValue1' + + $getResult = $mockResourceBaseInstance.Get() + + $getResult.MyResourceKeyProperty1 | Should -Be 'MyValue1' + $getResult.MyResourceProperty2 | Should -Be 'MyValue2' + $getResult.Ensure | Should -Be ([Ensure]::Present) + + $getResult.Reasons | Should -HaveCount 1 + + $getResult.Reasons[0].Code | Should -Be 'MyMockResource:MyMockResource:Ensure' + $getResult.Reasons[0].Phrase | Should -Be 'The property Ensure should be "Absent", but was "Present"' + } + } + } + } + } +} + +Describe 'ResourceBase\Test()' -Tag 'Test' { + BeforeAll { + Mock -CommandName Get-ClassName -MockWith { + # Only return localized strings for this class name. + @('ResourceBase') + } + } + + Context 'When the system is in the desired state' { + BeforeAll { + <# + This will override (mock) the method Compare() that is called by Test(). + Overriding this method is something a derived class normally should not + do, but done here to simplify the tests. + #> + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty()] + [System.String] + $MyResourceProperty2 + + [System.Collections.Hashtable[]] Compare() + { + return $null + } +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + } + + It 'Should have correctly instantiated the resource class' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance | Should -Not -BeNullOrEmpty + $mockResourceBaseInstance.GetType().BaseType.Name | Should -Be 'ResourceBase' + } + } + + It 'Should return $true' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance.Test() | Should -BeTrue + } + } + } + + Context 'When the system is not in the desired state' { + BeforeAll { + <# + This will override (mock) the method Compare() that is called by Test(). + Overriding this method is something a derived class normally should not + do, but done here to simplify the tests. + #> + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty()] + [System.String] + $MyResourceProperty2 + + [System.Collections.Hashtable[]] Compare() + { + # Could just return any non-null object, but mocking a real result. + return @{ + Property = 'MyResourceProperty2' + ExpectedValue = '1' + ActualValue = '2' + } + } +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + } + + It 'Should have correctly instantiated the resource class' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance | Should -Not -BeNullOrEmpty + $mockResourceBaseInstance.GetType().BaseType.Name | Should -Be 'ResourceBase' + } + } + + It 'Should return $true' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance.Test() | Should -BeFalse + } + } + } +} + +Describe 'ResourceBase\Compare()' -Tag 'Compare' { + BeforeAll { + Mock -CommandName Get-ClassName -MockWith { + # Only return localized strings for this class name. + @('ResourceBase') + } + } + + Context 'When the system is in the desired state' { + BeforeAll { + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty()] + [System.String] + $MyResourceProperty2 + + [DscProperty(NotConfigurable)] + [System.String] + $MyResourceReadProperty + + [ResourceBase] Get() + { + # Creates a new instance of the mock instance MyMockResource. + $currentStateInstance = [System.Activator]::CreateInstance($this.GetType()) + + $currentStateInstance.MyResourceProperty2 = 'MyValue1' + $currentStateInstance.MyResourceReadProperty = 'MyReadValue1' + + return $currentStateInstance + } +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + } + + It 'Should have correctly instantiated the resource class' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance | Should -Not -BeNullOrEmpty + $mockResourceBaseInstance.GetType().BaseType.Name | Should -Be 'ResourceBase' + } + } + + Context 'When no properties are enforced' { + It 'Should not return any property to enforce' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance.Compare() | Should -BeNullOrEmpty + } + } + } + + Context 'When one property are enforced but in desired state' { + BeforeAll { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance.MyResourceProperty2 = 'MyValue1' + } + } + + It 'Should not return any property to enforce' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance.Compare() | Should -BeNullOrEmpty -Because 'no result ($null) means all properties are in desired state' + } + } + } + } + + Context 'When the system is not in the desired state' { + BeforeAll { + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty()] + [System.String] + $MyResourceProperty2 + + [DscProperty()] + [System.String] + $MyResourceProperty3 + + [DscProperty(NotConfigurable)] + [System.String] + $MyResourceReadProperty + + [ResourceBase] Get() + { + # Creates a new instance of the mock instance MyMockResource. + $currentStateInstance = [System.Activator]::CreateInstance($this.GetType()) + + $currentStateInstance.MyResourceProperty2 = 'MyValue1' + $currentStateInstance.MyResourceProperty3 = 'MyValue2' + $currentStateInstance.MyResourceReadProperty = 'MyReadValue1' + + return $currentStateInstance + } +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + } + + It 'Should have correctly instantiated the resource class' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance | Should -Not -BeNullOrEmpty + $mockResourceBaseInstance.GetType().BaseType.Name | Should -Be 'ResourceBase' + } + } + + Context 'When only enforcing one property' { + BeforeAll { + InModuleScope -ScriptBlock { + # Set desired value for the property that should be enforced. + $mockResourceBaseInstance.MyResourceProperty2 = 'MyNewValue' + } + } + + It 'Should return the correct property that is not in desired state' { + InModuleScope -ScriptBlock { + $compareResult = $mockResourceBaseInstance.Compare() + $compareResult | Should -HaveCount 1 + + $compareResult[0].Property | Should -Be 'MyResourceProperty2' + $compareResult[0].ExpectedValue | Should -Be 'MyNewValue' + $compareResult[0].ActualValue | Should -Be 'MyValue1' + } + } + } + + Context 'When only enforcing two properties' { + BeforeAll { + InModuleScope -ScriptBlock { + # Set desired value for the properties that should be enforced. + $mockResourceBaseInstance.MyResourceProperty2 = 'MyNewValue1' + $mockResourceBaseInstance.MyResourceProperty3 = 'MyNewValue2' + } + } + + It 'Should return the correct property that is not in desired state' { + InModuleScope -ScriptBlock { + <# + The properties that are returned are not [ordered] so they can + come in any order from run to run. The test handle that. + #> + $compareResult = $mockResourceBaseInstance.Compare() + $compareResult | Should -HaveCount 2 + + $compareResult.Property | Should -Contain 'MyResourceProperty2' + $compareResult.Property | Should -Contain 'MyResourceProperty3' + + $compareProperty = $compareResult.Where( { $_.Property -eq 'MyResourceProperty2' }) + $compareProperty.ExpectedValue | Should -Be 'MyNewValue1' + $compareProperty.ActualValue | Should -Be 'MyValue1' + + $compareProperty = $compareResult.Where( { $_.Property -eq 'MyResourceProperty3' }) + $compareProperty.ExpectedValue | Should -Be 'MyNewValue2' + $compareProperty.ActualValue | Should -Be 'MyValue2' + } + } + } + } +} + +Describe 'ResourceBase\Set()' -Tag 'Set' { + BeforeAll { + Mock -CommandName Assert-Module + Mock -CommandName Get-ClassName -MockWith { + # Only return localized strings for this class name. + @('ResourceBase') + } + } + + Context 'When the system is in the desired state' { + BeforeAll { + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty()] + [System.String] + $MyResourceProperty2 + + [DscProperty()] + [System.String] + $MyResourceProperty3 + + # Hidden property to determine whether the method Modify() was called. + hidden [System.Collections.Hashtable] $mockModifyProperties = @{} + + [System.Collections.Hashtable[]] Compare() + { + return $null + } + + [void] Modify([System.Collections.Hashtable] $properties) + { + $this.mockModifyProperties = $properties + } +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + } + + It 'Should have correctly instantiated the resource class' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance | Should -Not -BeNullOrEmpty + $mockResourceBaseInstance.GetType().BaseType.Name | Should -Be 'ResourceBase' + } + } + + It 'Should not set any property' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance.Set() + + $mockResourceBaseInstance.mockModifyProperties | Should -BeNullOrEmpty + } + } + } + + Context 'When the system is not in the desired state' { + Context 'When setting one property' { + BeforeAll { + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty()] + [System.String] + $MyResourceProperty2 + + [DscProperty()] + [System.String] + $MyResourceProperty3 + + # Hidden property to determine whether the method Modify() was called. + hidden [System.Collections.Hashtable] $mockModifyProperties = @{} + + [System.Collections.Hashtable[]] Compare() + { + return @( + @{ + Property = 'MyResourceProperty2' + ExpectedValue = 'MyNewValue1' + ActualValue = 'MyValue1' + } + ) + } + + [void] Modify([System.Collections.Hashtable] $properties) + { + $this.mockModifyProperties = $properties + } +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + } + + It 'Should have correctly instantiated the resource class' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance | Should -Not -BeNullOrEmpty + $mockResourceBaseInstance.GetType().BaseType.Name | Should -Be 'ResourceBase' + } + } + + It 'Should set the correct property' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance.Set() + + $mockResourceBaseInstance.mockModifyProperties.Keys | Should -HaveCount 1 + $mockResourceBaseInstance.mockModifyProperties.Keys | Should -Contain 'MyResourceProperty2' + + $mockResourceBaseInstance.mockModifyProperties.MyResourceProperty2 | Should -Contain 'MyNewValue1' + } + } + } + + Context 'When setting two properties' { + BeforeAll { + $inModuleScopeScriptBlock = @' +using module DscResource.Base + +class MyMockResource : ResourceBase +{ + [DscProperty(Key)] + [System.String] + $MyResourceKeyProperty1 + + [DscProperty()] + [System.String] + $MyResourceProperty2 + + [DscProperty()] + [System.String] + $MyResourceProperty3 + + # Hidden property to determine whether the method Modify() was called. + hidden [System.Collections.Hashtable] $mockModifyProperties = @{} + + [System.Collections.Hashtable[]] Compare() + { + return @( + @{ + Property = 'MyResourceProperty2' + ExpectedValue = 'MyNewValue1' + ActualValue = 'MyValue1' + }, + @{ + Property = 'MyResourceProperty3' + ExpectedValue = 'MyNewValue2' + ActualValue = 'MyValue2' + } + ) + } + + [void] Modify([System.Collections.Hashtable] $properties) + { + $this.mockModifyProperties = $properties + } +} + +$script:mockResourceBaseInstance = [MyMockResource]::new() +'@ + + InModuleScope -ScriptBlock ([Scriptblock]::Create($inModuleScopeScriptBlock)) + } + + It 'Should have correctly instantiated the resource class' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance | Should -Not -BeNullOrEmpty + $mockResourceBaseInstance.GetType().BaseType.Name | Should -Be 'ResourceBase' + } + } + + It 'Should set the correct properties' { + InModuleScope -ScriptBlock { + $mockResourceBaseInstance.Set() + + $mockResourceBaseInstance.mockModifyProperties.Keys | Should -HaveCount 2 + $mockResourceBaseInstance.mockModifyProperties.Keys | Should -Contain 'MyResourceProperty2' + $mockResourceBaseInstance.mockModifyProperties.Keys | Should -Contain 'MyResourceProperty3' + + $mockResourceBaseInstance.mockModifyProperties.MyResourceProperty2 | Should -Contain 'MyNewValue1' + $mockResourceBaseInstance.mockModifyProperties.MyResourceProperty3 | Should -Contain 'MyNewValue2' + } + } + } + } +} diff --git a/tests/Unit/Private/ConvertFrom-CompareResult.Tests.ps1 b/tests/Unit/Private/ConvertFrom-CompareResult.Tests.ps1 new file mode 100644 index 0000000..79d28b1 --- /dev/null +++ b/tests/Unit/Private/ConvertFrom-CompareResult.Tests.ps1 @@ -0,0 +1,105 @@ +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies has been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../build.ps1" -Tasks 'noop' 2>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies has not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks build" first.' + } +} + +BeforeAll { + $script:dscModuleName = 'DscResource.Base' + + Import-Module -Name $script:dscModuleName + + $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Mock:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Should:ModuleName'] = $script:dscModuleName +} + +AfterAll { + $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') + $PSDefaultParameterValues.Remove('Mock:ModuleName') + $PSDefaultParameterValues.Remove('Should:ModuleName') + + # Unload the module being tested so that it doesn't impact any other tests. + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force +} + +Describe 'ConvertFrom-CompareResult' -Tag 'Private' { + Context 'When passing as named parameter' { + It 'Should return the correct values in a hashtable' { + InModuleScope -ScriptBlock { + $mockProperties = @( + @{ + Property = 'MyResourceProperty1' + ExpectedValue = 'MyNewValue1' + ActualValue = 'MyValue1' + }, + @{ + Property = 'MyResourceProperty2' + ExpectedValue = 'MyNewValue2' + ActualValue = 'MyValue2' + } + ) + + $result = ConvertFrom-CompareResult -CompareResult $mockProperties + + $result | Should -BeOfType [System.Collections.Hashtable] + + $result.Keys | Should -HaveCount 2 + $result.Keys | Should -Contain 'MyResourceProperty1' + $result.Keys | Should -Contain 'MyResourceProperty2' + + $result.MyResourceProperty1 | Should -Be 'MyNewValue1' + $result.MyResourceProperty2 | Should -Be 'MyNewValue2' + } + } + } + + Context 'When passing in the pipeline' { + It 'Should return the correct values in a hashtable' { + InModuleScope -ScriptBlock { + $mockProperties = @( + @{ + Property = 'MyResourceProperty1' + ExpectedValue = 'MyNewValue1' + ActualValue = 'MyValue1' + }, + @{ + Property = 'MyResourceProperty2' + ExpectedValue = 'MyNewValue2' + ActualValue = 'MyValue2' + } + ) + + $result = $mockProperties | ConvertFrom-CompareResult + + $result | Should -BeOfType [System.Collections.Hashtable] + + $result.Keys | Should -HaveCount 2 + $result.Keys | Should -Contain 'MyResourceProperty1' + $result.Keys | Should -Contain 'MyResourceProperty2' + + $result.MyResourceProperty1 | Should -Be 'MyNewValue1' + $result.MyResourceProperty2 | Should -Be 'MyNewValue2' + } + } + } +} diff --git a/tests/Unit/Private/ConvertTo-Reason.Tests.ps1 b/tests/Unit/Private/ConvertTo-Reason.Tests.ps1 new file mode 100644 index 0000000..289e289 --- /dev/null +++ b/tests/Unit/Private/ConvertTo-Reason.Tests.ps1 @@ -0,0 +1,213 @@ +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies has been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../build.ps1" -Tasks 'noop' 2>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies has not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks build" first.' + } +} + +BeforeAll { + $script:dscModuleName = 'DscResource.Base' + + Import-Module -Name $script:dscModuleName + + $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Mock:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Should:ModuleName'] = $script:dscModuleName +} + +AfterAll { + $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') + $PSDefaultParameterValues.Remove('Mock:ModuleName') + $PSDefaultParameterValues.Remove('Should:ModuleName') + + # Unload the module being tested so that it doesn't impact any other tests. + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force +} + +Describe 'ConvertTo-Reason' -Tag 'Private' { + Context 'When passing an empty collection' { + It 'Should return an empty collection' { + InModuleScope -ScriptBlock { + $mockProperties = @() + + $result = ConvertTo-Reason -Property $mockProperties -ResourceName 'MyResource' + + $result | Should -HaveCount 0 + } + } + } + + Context 'When passing a null value' { + It 'Should return an empty collection' { + InModuleScope -ScriptBlock { + $mockProperties = @() + + $result = ConvertTo-Reason -Property $null -ResourceName 'MyResource' + + $result | Should -HaveCount 0 + } + } + } + + Context 'When passing as named parameter' { + It 'Should return the correct values in a hashtable' { + InModuleScope -ScriptBlock { + $mockProperties = @( + @{ + Property = 'MyResourceProperty1' + ExpectedValue = 'MyNewValue1' + ActualValue = 'MyValue1' + }, + @{ + Property = 'MyResourceProperty2' + ExpectedValue = @('MyNewValue2', 'MyNewValue3') + ActualValue = @('MyValue2', 'MyValue3') + } + ) + + $result = ConvertTo-Reason -Property $mockProperties -ResourceName 'MyResource' + + $result | Should -HaveCount 2 + + $result.Code | Should -Contain 'MyResource:MyResource:MyResourceProperty1' + $result.Phrase | Should -Contain 'The property MyResourceProperty1 should be "MyNewValue1", but was "MyValue1"' + + $result.Code | Should -Contain 'MyResource:MyResource:MyResourceProperty2' + $result.Phrase | Should -Contain 'The property MyResourceProperty2 should be ["MyNewValue2","MyNewValue3"], but was ["MyValue2","MyValue3"]' + } + } + } + + Context 'When passing in the pipeline' { + It 'Should return the correct values in a hashtable' { + InModuleScope -ScriptBlock { + $mockProperties = @( + @{ + Property = 'MyResourceProperty1' + ExpectedValue = 'MyNewValue1' + ActualValue = 'MyValue1' + }, + @{ + Property = 'MyResourceProperty2' + ExpectedValue = @('MyNewValue2', 'MyNewValue3') + ActualValue = @('MyValue2', 'MyValue3') + } + ) + + $result = $mockProperties | ConvertTo-Reason -ResourceName 'MyResource' + + $result | Should -HaveCount 2 + + $result.Code | Should -Contain 'MyResource:MyResource:MyResourceProperty1' + $result.Phrase | Should -Contain 'The property MyResourceProperty1 should be "MyNewValue1", but was "MyValue1"' + + $result.Code | Should -Contain 'MyResource:MyResource:MyResourceProperty2' + $result.Phrase | Should -Contain 'The property MyResourceProperty2 should be ["MyNewValue2","MyNewValue3"], but was ["MyValue2","MyValue3"]' + } + } + } + + Context 'When ExpectedValue has $null for a property' { + Context 'When on Windows PowerShell' { + BeforeAll { + $script:originalPSEdition = $PSVersionTable.PSEdition + + $PSVersionTable.PSEdition = 'Desktop' + } + + AfterAll { + $PSVersionTable.PSEdition = $script:originalPSEdition + } + It 'Should return the correct values in a hashtable' { + InModuleScope -ScriptBlock { + $mockProperties = @( + @{ + Property = 'MyResourceProperty1' + ExpectedValue = $null + ActualValue = 'MyValue1' + } + ) + + $result = ConvertTo-Reason -Property $mockProperties -ResourceName 'MyResource' + + $result | Should -HaveCount 1 + + $result.Code | Should -Contain 'MyResource:MyResource:MyResourceProperty1' + $result.Phrase | Should -Contain 'The property MyResourceProperty1 should be "", but was "MyValue1"' + } + } + } + } + + Context 'When ActualValue has $null for a property' { + Context 'When on Windows PowerShell' { + BeforeAll { + $script:originalPSEdition = $PSVersionTable.PSEdition + + $PSVersionTable.PSEdition = 'Desktop' + } + + AfterAll { + $PSVersionTable.PSEdition = $script:originalPSEdition + } + + It 'Should return the correct values in a hashtable' { + InModuleScope -ScriptBlock { + $mockProperties = @( + @{ + Property = 'MyResourceProperty1' + ExpectedValue = 'MyValue1' + ActualValue = $null + } + ) + + $result = ConvertTo-Reason -Property $mockProperties -ResourceName 'MyResource' + + $result | Should -HaveCount 1 + + $result.Code | Should -Contain 'MyResource:MyResource:MyResourceProperty1' + $result.Phrase | Should -Contain 'The property MyResourceProperty1 should be "MyValue1", but was ""' + } + } + } + } + + Context 'When a path property contain double backslash' { + It 'Should return the correct values in a hashtable' { + InModuleScope -ScriptBlock { + $mockProperties = @( + @{ + Property = 'MyResourcePathProperty' + ExpectedValue = 'C:\Temp\MyFolder' + ActualValue = 'C:\Temp\MyNewFolder' + } + ) + + $result = ConvertTo-Reason -Property $mockProperties -ResourceName 'MyResource' + + $result | Should -HaveCount 1 + + $result.Code | Should -Contain 'MyResource:MyResource:MyResourcePathProperty' + $result.Phrase | Should -Contain 'The property MyResourcePathProperty should be "C:\Temp\MyFolder", but was "C:\Temp\MyNewFolder"' + } + } + } +} diff --git a/tests/Unit/Private/Get-ClassName.Tests.ps1 b/tests/Unit/Private/Get-ClassName.Tests.ps1 new file mode 100644 index 0000000..a498aa2 --- /dev/null +++ b/tests/Unit/Private/Get-ClassName.Tests.ps1 @@ -0,0 +1,109 @@ +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies has been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../build.ps1" -Tasks 'noop' 2>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies has not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks build" first.' + } +} + +BeforeAll { + $script:dscModuleName = 'DscResource.Base' + + Import-Module -Name $script:dscModuleName + + $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Mock:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Should:ModuleName'] = $script:dscModuleName +} + +AfterAll { + $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') + $PSDefaultParameterValues.Remove('Mock:ModuleName') + $PSDefaultParameterValues.Remove('Should:ModuleName') + + # Unload the module being tested so that it doesn't impact any other tests. + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force +} + +Describe 'Get-ClassName' -Tag 'Private' { + Context 'When getting the class name' { + Context 'When passing value with named parameter' { + It 'Should return the correct value' { + InModuleScope -ScriptBlock { + $result = Get-ClassName -InputObject ([System.UInt32] 3) + + $result.GetType().FullName | Should -Be 'System.String[]' + + $result | Should -HaveCount 1 + $result | Should -Contain 'System.UInt32' + } + } + } + + Context 'When passing value in pipeline' { + It 'Should return the correct value' { + InModuleScope -ScriptBlock { + $result = ([System.UInt32] 3) | Get-ClassName + + $result.GetType().FullName | Should -Be 'System.String[]' + + $result | Should -HaveCount 1 + $result | Should -Contain 'System.UInt32' + } + } + } + } + + Context 'When getting the class name and all inherited class names (base classes)' { + Context 'When passing value with named parameter' { + It 'Should return the correct value' { + InModuleScope -ScriptBlock { + $result = Get-ClassName -InputObject ([System.UInt32] 3) -Recurse + + $result.GetType().FullName | Should -Be 'System.String[]' + + $result | Should -HaveCount 2 + $result | Should -Contain 'System.UInt32' + $result | Should -Contain 'System.ValueType' + + $result[0] | Should -Be 'System.UInt32' + $result[1] | Should -Be 'System.ValueType' + } + } + } + + Context 'When passing value in pipeline' { + It 'Should return the correct value' { + InModuleScope -ScriptBlock { + $result = ([System.UInt32] 3) | Get-ClassName -Recurse + + $result.GetType().FullName | Should -Be 'System.String[]' + + $result | Should -HaveCount 2 + $result | Should -Contain 'System.UInt32' + $result | Should -Contain 'System.ValueType' + + $result[0] | Should -Be 'System.UInt32' + $result[1] | Should -Be 'System.ValueType' + } + } + } + } +} diff --git a/tests/Unit/Private/Get-LocalizedDataRecursive.Tests.ps1 b/tests/Unit/Private/Get-LocalizedDataRecursive.Tests.ps1 new file mode 100644 index 0000000..5e482b3 --- /dev/null +++ b/tests/Unit/Private/Get-LocalizedDataRecursive.Tests.ps1 @@ -0,0 +1,161 @@ +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies has been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../build.ps1" -Tasks 'noop' 2>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies has not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks build" first.' + } +} + +BeforeAll { + $script:dscModuleName = 'DscResource.Base' + + Import-Module -Name $script:dscModuleName + + $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Mock:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Should:ModuleName'] = $script:dscModuleName +} + +AfterAll { + $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') + $PSDefaultParameterValues.Remove('Mock:ModuleName') + $PSDefaultParameterValues.Remove('Should:ModuleName') + + # Unload the module being tested so that it doesn't impact any other tests. + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force +} + +Describe 'Get-LocalizedDataRecursive' -Tag 'Private' { + BeforeAll { + $getLocalizedData_ParameterFilter_Class = { + $FileName -eq 'MyClassResource.strings' + } + + $getLocalizedData_ParameterFilter_Base = { + $FileName -eq 'MyBaseClass.strings' + } + + Mock -CommandName Get-LocalizedData -MockWith { + return @{ + ClassStringKey = 'My class string' + } + } -ParameterFilter $getLocalizedData_ParameterFilter_Class + + Mock -CommandName Get-LocalizedData -MockWith { + return @{ + BaseStringKey = 'My base string' + } + } -ParameterFilter $getLocalizedData_ParameterFilter_Base + } + + Context 'When getting localization string for class name' { + Context 'When passing value with named parameter' { + It 'Should return the correct localization strings' { + InModuleScope -ScriptBlock { + $result = Get-LocalizedDataRecursive -ClassName 'MyClassResource' + + $result.Keys | Should -HaveCount 1 + $result.Keys | Should -Contain 'ClassStringKey' + } + + Should -Invoke -CommandName Get-LocalizedData -ParameterFilter $getLocalizedData_ParameterFilter_Class -Exactly -Times 1 -Scope It + } + } + + Context 'When passing value in pipeline' { + It 'Should return the correct localization strings' { + InModuleScope -ScriptBlock { + $result = 'MyClassResource' | Get-LocalizedDataRecursive + + $result.Keys | Should -HaveCount 1 + $result.Keys | Should -Contain 'ClassStringKey' + } + + Should -Invoke -CommandName Get-LocalizedData -ParameterFilter $getLocalizedData_ParameterFilter_Class -Exactly -Times 1 -Scope It + } + } + } + + Context 'When getting localization string for class and base name' { + Context 'When passing value with named parameter' { + It 'Should return the correct localization strings' { + InModuleScope -ScriptBlock { + $result = Get-LocalizedDataRecursive -ClassName @('MyClassResource', 'MyBaseClass') + + $result.Keys | Should -HaveCount 2 + $result.Keys | Should -Contain 'ClassStringKey' + $result.Keys | Should -Contain 'BaseStringKey' + } + + Should -Invoke -CommandName Get-LocalizedData -ParameterFilter $getLocalizedData_ParameterFilter_Class -Exactly -Times 1 -Scope It + } + } + + Context 'When passing value in pipeline' { + It 'Should return the correct localization strings' { + InModuleScope -ScriptBlock { + $result = @('MyClassResource', 'MyBaseClass') | Get-LocalizedDataRecursive + + $result.Keys | Should -HaveCount 2 + $result.Keys | Should -Contain 'ClassStringKey' + $result.Keys | Should -Contain 'BaseStringKey' + } + + Should -Invoke -CommandName Get-LocalizedData -ParameterFilter $getLocalizedData_ParameterFilter_Class -Exactly -Times 1 -Scope It + } + } + } + + Context 'When getting localization string for class and base file name' { + Context 'When passing value with named parameter' { + It 'Should return the correct localization strings' { + InModuleScope -ScriptBlock { + $result = Get-LocalizedDataRecursive -ClassName @( + 'MyClassResource.strings.psd1' + 'MyBaseClass.strings.psd1' + ) + + $result.Keys | Should -HaveCount 2 + $result.Keys | Should -Contain 'ClassStringKey' + $result.Keys | Should -Contain 'BaseStringKey' + } + + Should -Invoke -CommandName Get-LocalizedData -ParameterFilter $getLocalizedData_ParameterFilter_Class -Exactly -Times 1 -Scope It + } + } + + Context 'When passing value in pipeline' { + It 'Should return the correct localization strings' { + InModuleScope -ScriptBlock { + $result = @( + 'MyClassResource.strings.psd1' + 'MyBaseClass.strings.psd1' + ) | Get-LocalizedDataRecursive + + $result.Keys | Should -HaveCount 2 + $result.Keys | Should -Contain 'ClassStringKey' + $result.Keys | Should -Contain 'BaseStringKey' + } + + Should -Invoke -CommandName Get-LocalizedData -ParameterFilter $getLocalizedData_ParameterFilter_Class -Exactly -Times 1 -Scope It + } + } + } +}