├── .gitattributes ├── .github ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── ci.yml │ ├── custom_acceptance.yml │ ├── mend.yml │ ├── nightly.yml │ ├── release.yml │ └── release_prep.yml ├── .gitignore ├── .pdkignore ├── .puppet-lint.rc ├── .rspec ├── .rubocop.yml ├── .rubocop_todo.yml ├── .sync.yml ├── .vscode └── extensions.json ├── .yardopts ├── CHANGELOG.md ├── CODEOWNERS ├── CONTRIBUTING.md ├── DESIGN.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── design-comms.png ├── lib ├── puppet │ ├── feature │ │ └── pwshlib.rb │ └── provider │ │ └── dsc_base_provider │ │ ├── dsc_base_provider.rb │ │ ├── invoke_dsc_resource_functions.ps1 │ │ ├── invoke_dsc_resource_postscript.ps1 │ │ └── invoke_dsc_resource_preamble.ps1 ├── pwsh.rb ├── pwsh │ ├── util.rb │ ├── version.rb │ └── windows_powershell.rb ├── ruby-pwsh.rb └── templates │ ├── RubyPwsh.cs │ └── init.ps1 ├── metadata.json ├── pwshlib.md ├── ruby-pwsh.gemspec └── spec ├── acceptance ├── dsc │ ├── basic.rb │ ├── cim_instances.rb │ ├── class.rb │ └── complex.rb └── support │ └── setup_winrm.ps1 ├── default_facts.yml ├── exit-27.ps1 ├── spec_helper.rb └── unit ├── puppet └── provider │ └── dsc_base_provider │ └── dsc_base_provider_spec.rb ├── pwsh ├── util_spec.rb ├── version_spec.rb └── windows_powershell_spec.rb └── pwsh_spec.rb /.gitattributes: -------------------------------------------------------------------------------- 1 | *.rb eol=lf 2 | *.erb eol=lf 3 | *.pp eol=lf 4 | *.sh eol=lf 5 | *.epp eol=lf 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "bundler" 4 | directory: "/" # 5 | schedule: 6 | interval: "weekly" 7 | 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Summary 2 | Provide a detailed description of all the changes present in this pull request. 3 | 4 | ## Additional Context 5 | Add any additional context about the problem here. 6 | - [ ] Root cause and the steps to reproduce. (If applicable) 7 | - [ ] Thought process behind the implementation. 8 | 9 | ## Related Issues (if any) 10 | Mention any related issues or pull requests. 11 | 12 | ## Checklist 13 | - [ ] 🟢 Spec tests. 14 | - [ ] 🟢 Acceptance tests. 15 | - [ ] Manually verified. 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "ci" 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | pull_request: 8 | branches: 9 | - "main" 10 | workflow_dispatch: 11 | 12 | env: 13 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 14 | 15 | jobs: 16 | spec: 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | ruby_version: 21 | - '3.2' 22 | include: 23 | - ruby_version: '3.2' 24 | puppet_version: '~> 8.0' 25 | name: "spec (ruby ${{ matrix.ruby_version }} | puppet ${{ matrix.puppet_version }})" 26 | uses: "puppetlabs/cat-github-actions/.github/workflows/gem_ci.yml@main" 27 | secrets: "inherit" 28 | with: 29 | rake_task: "spec:coverage" 30 | ruby_version: ${{ matrix.ruby_version }} 31 | puppet_gem_version: ${{ matrix.puppet_version }} 32 | 33 | acceptance: 34 | needs: "spec" 35 | strategy: 36 | matrix: 37 | ruby_version: 38 | - "3.2" 39 | include: 40 | - ruby_version: '3.2' 41 | puppet_version: '~> 8.0' 42 | runs_on: 43 | - "windows-latest" 44 | name: "acceptance (${{ matrix.runs_on}} ruby ${{ matrix.ruby_version }} | puppet ${{ matrix.puppet_version }})" 45 | uses: "./.github/workflows/custom_acceptance.yml" 46 | secrets: "inherit" 47 | with: 48 | ruby_version: ${{ matrix.ruby_version }} 49 | puppet_version: ${{ matrix.puppet_version }} 50 | runs_on: ${{ matrix.runs_on }} 51 | -------------------------------------------------------------------------------- /.github/workflows/custom_acceptance.yml: -------------------------------------------------------------------------------- 1 | # This exists so we can force the acceptance tests to run under 2 | # powershell.exe. 3 | # 4 | # For such a small edge case it does not seem sensible adding extra 5 | # complication in to the shared actions at this point. 6 | # 7 | # Hopefully this is only a temporary work-around. 8 | name: "Gem Acceptance" 9 | 10 | on: 11 | workflow_call: 12 | inputs: 13 | ruby_version: 14 | description: "The target Ruby version." 15 | required: false 16 | default: "3.1" 17 | type: "string" 18 | puppet_version: 19 | description: "The target Puppet version." 20 | required: false 21 | default: "puppet8" 22 | type: "string" 23 | rake_task: 24 | description: "The name of the rake task that executes acceptance tests" 25 | required: false 26 | default: "acceptance" 27 | type: "string" 28 | runs_on: 29 | description: "The operating system used for the runner." 30 | required: false 31 | default: "ubuntu-latest" 32 | type: "string" 33 | 34 | jobs: 35 | acceptance: 36 | name: "acceptance" 37 | runs-on: ${{ inputs.runs_on }} 38 | 39 | steps: 40 | 41 | - name: "checkout" 42 | uses: "actions/checkout@v4" 43 | 44 | - name: "export environment" 45 | run: | 46 | echo "PUPPET_GEM_VERSION=${{ inputs.puppet_version }} >> $GITHUB_ENV" 47 | 48 | - name: "setup ruby" 49 | uses: "ruby/setup-ruby@v1" 50 | with: 51 | ruby-version: ${{ inputs.ruby_version }} 52 | bundler-cache: true 53 | 54 | - name: "bundle environment" 55 | run: | 56 | echo ::group::bundler environment 57 | bundle env 58 | echo ::endgroup:: 59 | 60 | - name: "execute acceptance tests" 61 | shell: powershell 62 | run: | 63 | # This generic task to run acceptance tests. 64 | # It should be overridden in the Rakefile. 65 | bundle exec rake ${{ inputs.rake_task }} 66 | -------------------------------------------------------------------------------- /.github/workflows/mend.yml: -------------------------------------------------------------------------------- 1 | name: "mend" 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "main" 7 | schedule: 8 | - cron: "0 0 * * *" 9 | workflow_dispatch: 10 | 11 | jobs: 12 | 13 | mend: 14 | uses: "puppetlabs/cat-github-actions/.github/workflows/tooling_mend_ruby.yml@main" 15 | secrets: "inherit" 16 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: "nightly" 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | spec: 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | ruby_version: 14 | - '3.2' 15 | include: 16 | - ruby_version: '3.2' 17 | puppet_version: '~> 8.0' 18 | name: "spec (ruby ${{ matrix.ruby_version }} | puppet ${{ matrix.puppet_version }})" 19 | uses: "puppetlabs/cat-github-actions/.github/workflows/gem_ci.yml@main" 20 | secrets: "inherit" 21 | with: 22 | ruby_version: ${{ matrix.ruby_version }} 23 | puppet_gem_version: ${{ matrix.puppet_version }} 24 | 25 | acceptance: 26 | needs: "spec" 27 | strategy: 28 | matrix: 29 | ruby_version: 30 | - "3.2" 31 | include: 32 | - ruby_version: '3.2' 33 | puppet_version: '~> 8.0' 34 | runs_on: 35 | - "windows-latest" 36 | name: "acceptance (${{ matrix.runs_on}} ruby ${{ matrix.ruby_version }} | puppet ${{ matrix.puppet_version }})" 37 | uses: "./.github/workflows/custom_acceptance.yml" 38 | secrets: "inherit" 39 | with: 40 | ruby_version: ${{ matrix.ruby_version }} 41 | puppet_version: ${{ matrix.puppet_version }} 42 | runs_on: ${{ matrix.runs_on }} 43 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "Release" 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | target: 7 | description: "The target for the release. This can be a commit sha or a branch." 8 | required: false 9 | default: "main" 10 | 11 | jobs: 12 | gem_release: 13 | uses: "puppetlabs/cat-github-actions/.github/workflows/gem_release.yml@main" 14 | with: 15 | target: "${{ github.event.inputs.target }}" 16 | secrets: "inherit" 17 | 18 | module_release: 19 | needs: gem_release 20 | runs-on: "ubuntu-latest" 21 | 22 | steps: 23 | 24 | - name: "Checkout" 25 | uses: "actions/checkout@v4" 26 | with: 27 | ref: "${{ github.ref }}" 28 | clean: true 29 | fetch-depth: 0 30 | 31 | - name: "PDK build" 32 | uses: "docker://puppet/pdk:3.0.0.0" 33 | with: 34 | args: "build" 35 | 36 | - name: "Publish module" 37 | uses: "docker://puppet/pdk:3.0.0.0" 38 | with: 39 | args: 'release publish --forge-token ${{ secrets.FORGE_API_KEY }} --force' 40 | -------------------------------------------------------------------------------- /.github/workflows/release_prep.yml: -------------------------------------------------------------------------------- 1 | name: "Release Prep" 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | target: 7 | description: "The target for the release. This can be a commit sha or a branch." 8 | required: false 9 | default: "main" 10 | version: 11 | description: "Version of gem to be released." 12 | required: true 13 | 14 | jobs: 15 | gem_release_prep: 16 | uses: "puppetlabs/cat-github-actions/.github/workflows/gem_release_prep.yml@main" 17 | with: 18 | target: "${{ github.event.inputs.target }}" 19 | version: "${{ github.event.inputs.version }}" 20 | secrets: "inherit" 21 | 22 | module_release_prep: 23 | needs: gem_release_prep 24 | runs-on: "ubuntu-latest" 25 | 26 | steps: 27 | 28 | - name: "Checkout" 29 | uses: "actions/checkout@v4" 30 | with: 31 | ref: 'release-prep' 32 | fetch-depth: 0 33 | 34 | - name: "Update metadata.json" 35 | run: | 36 | current_version=$(jq --raw-output .version metadata.json) 37 | # Update version in metadata.json, only matching first occurrence 38 | sed -i "0,/$current_version/s//${{ github.event.inputs.version }}/" $(find . -name 'metadata.json') 39 | 40 | - name: "Get version" 41 | id: "get_version" 42 | run: | 43 | echo "version=$(jq --raw-output .version metadata.json)" >> $GITHUB_OUTPUT 44 | 45 | - name: "Commit changes" 46 | run: | 47 | git config --local user.email "${{ github.repository_owner }}@users.noreply.github.com" 48 | git config --local user.name "GitHub Actions" 49 | git add . 50 | git commit -m "Module Release prep v${{ steps.get_version.outputs.version }}" 51 | 52 | - name: "Push changes" 53 | uses: ad-m/github-push-action@master 54 | with: 55 | github_token: ${{ secrets.GITHUB_TOKEN }} 56 | branch: release-prep 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | .*.sw[op] 3 | .metadata 4 | .yardoc 5 | .yardwarns 6 | *.iml 7 | /.bundle/ 8 | /.idea/ 9 | /.vagrant/ 10 | /coverage/ 11 | /bin/ 12 | /doc/ 13 | /Gemfile.local 14 | /Gemfile.lock 15 | /junit/ 16 | /log/ 17 | /pkg/ 18 | /spec/fixtures/manifests/ 19 | /spec/fixtures/modules/* 20 | /tmp/ 21 | /vendor/ 22 | /convert_report.txt 23 | /update_report.txt 24 | .DS_Store 25 | .project 26 | .envrc 27 | /inventory.yaml 28 | /spec/fixtures/litmus_inventory.yaml 29 | -------------------------------------------------------------------------------- /.pdkignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | .*.sw[op] 3 | .metadata 4 | .yardoc 5 | .yardwarns 6 | *.iml 7 | /.bundle/ 8 | /.idea/ 9 | /.vagrant/ 10 | /coverage/ 11 | /bin/ 12 | /doc/ 13 | /Gemfile.local 14 | /Gemfile.lock 15 | /junit/ 16 | /log/ 17 | /pkg/ 18 | /spec/fixtures/manifests/ 19 | /spec/fixtures/modules/* 20 | /tmp/ 21 | /vendor/ 22 | /convert_report.txt 23 | /update_report.txt 24 | .DS_Store 25 | .project 26 | .envrc 27 | /inventory.yaml 28 | /spec/fixtures/litmus_inventory.yaml 29 | /.fixtures.yml 30 | /Gemfile 31 | /.gitattributes 32 | /.github/ 33 | /.gitignore 34 | /.pdkignore 35 | /.puppet-lint.rc 36 | /Rakefile 37 | /rakelib/ 38 | /.rspec 39 | /..yml 40 | /.yardopts 41 | /spec/ 42 | /.vscode/ 43 | /.sync.yml 44 | /.devcontainer/ 45 | -------------------------------------------------------------------------------- /.puppet-lint.rc: -------------------------------------------------------------------------------- 1 | --relative 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format documentation 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | require: 4 | - rubocop-performance 5 | - rubocop-rspec 6 | 7 | AllCops: 8 | Exclude: 9 | - Gemfile 10 | - Rakefile 11 | - spec/fixtures/**/* 12 | - vendor/bundle/**/* 13 | NewCops: enable 14 | SuggestExtensions: false 15 | TargetRubyVersion: '3.1' 16 | 17 | # Disabled 18 | Style/ClassAndModuleChildren: 19 | Enabled: false 20 | 21 | Layout/LineLength: 22 | Max: 196 23 | 24 | #################################################### 25 | # Cops below here due for deprecation 26 | #################################################### 27 | # ``Rspec/FilePath`` is going to be deprecated in the next major release of rubocop >=3.0.0: see 28 | # As the new cops are already present, e.g., Rspec/SpecFilePathPathFormat, then disabling this in preparation 29 | RSpec/FilePath: 30 | Enabled: false 31 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2025-04-09 15:12:12 UTC using RuboCop version 1.70.0. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | 10 | # Offense count: 1 11 | # Configuration parameters: IgnoreLiteralBranches, IgnoreConstantBranches, IgnoreDuplicateElseBranch. 12 | Lint/DuplicateBranch: 13 | Exclude: 14 | - 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb' 15 | 16 | # Offense count: 1 17 | # Configuration parameters: AllowComments. 18 | Lint/EmptyClass: 19 | Exclude: 20 | - 'spec/unit/pwsh_spec.rb' 21 | 22 | # Offense count: 1 23 | # This cop supports unsafe autocorrection (--autocorrect-all). 24 | Lint/IncompatibleIoSelectWithFiberScheduler: 25 | Exclude: 26 | - 'lib/pwsh.rb' 27 | 28 | 29 | # Offense count: 16 30 | # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. 31 | Metrics/AbcSize: 32 | Max: 63 33 | 34 | # Offense count: 1 35 | # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns, inherit_mode. 36 | # AllowedMethods: refine 37 | Metrics/BlockLength: 38 | Max: 28 39 | 40 | # Offense count: 1 41 | # Configuration parameters: CountComments, CountAsOne. 42 | Metrics/ClassLength: 43 | Max: 296 44 | 45 | # Offense count: 12 46 | # Configuration parameters: AllowedMethods, AllowedPatterns. 47 | Metrics/CyclomaticComplexity: 48 | Max: 24 49 | 50 | # Offense count: 22 51 | # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. 52 | Metrics/MethodLength: 53 | Max: 42 54 | 55 | # Offense count: 10 56 | # Configuration parameters: AllowedMethods, AllowedPatterns. 57 | Metrics/PerceivedComplexity: 58 | Max: 24 59 | 60 | # Offense count: 1 61 | # This cop supports safe autocorrection (--autocorrect). 62 | # Configuration parameters: EnforcedStyle, BlockForwardingName. 63 | # SupportedStyles: anonymous, explicit 64 | Naming/BlockForwarding: 65 | Exclude: 66 | - 'lib/pwsh.rb' 67 | 68 | # Offense count: 1 69 | # Configuration parameters: ExpectMatchingDefinition, CheckDefinitionPathHierarchy, CheckDefinitionPathHierarchyRoots, Regex, IgnoreExecutableScripts, AllowedAcronyms. 70 | # CheckDefinitionPathHierarchyRoots: lib, spec, test, src 71 | # AllowedAcronyms: CLI, DSL, ACL, API, ASCII, CPU, CSS, DNS, EOF, GUID, HTML, HTTP, HTTPS, ID, IP, JSON, LHS, QPS, RAM, RHS, RPC, SLA, SMTP, SQL, SSH, TCP, TLS, TTL, UDP, UI, UID, UUID, URI, URL, UTF8, VM, XML, XMPP, XSRF, XSS 72 | Naming/FileName: 73 | Exclude: 74 | - 'Rakefile.rb' 75 | - 'lib/ruby-pwsh.rb' 76 | 77 | # Offense count: 1 78 | # Configuration parameters: MinSize. 79 | Performance/CollectionLiteralInLoop: 80 | Exclude: 81 | - 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb' 82 | 83 | # Offense count: 11 84 | RSpec/AnyInstance: 85 | Exclude: 86 | - 'spec/unit/pwsh/windows_powershell_spec.rb' 87 | 88 | # Offense count: 5 89 | RSpec/BeforeAfterAll: 90 | Exclude: 91 | - 'spec/spec_helper.rb' 92 | - 'spec/rails_helper.rb' 93 | - 'spec/support/**/*.rb' 94 | - 'spec/acceptance/dsc/basic.rb' 95 | 96 | # Offense count: 26 97 | # Configuration parameters: Prefixes, AllowedPatterns. 98 | # Prefixes: when, with, without 99 | RSpec/ContextWording: 100 | Exclude: 101 | - 'spec/acceptance/dsc/basic.rb' 102 | - 'spec/acceptance/dsc/cim_instances.rb' 103 | - 'spec/acceptance/dsc/class.rb' 104 | - 'spec/acceptance/dsc/complex.rb' 105 | - 'spec/unit/puppet/provider/dsc_base_provider/dsc_base_provider_spec.rb' 106 | - 'spec/unit/pwsh/windows_powershell_spec.rb' 107 | - 'spec/unit/pwsh_spec.rb' 108 | 109 | # Offense count: 6 110 | # Configuration parameters: IgnoredMetadata. 111 | RSpec/DescribeClass: 112 | Exclude: 113 | - '**/spec/features/**/*' 114 | - '**/spec/requests/**/*' 115 | - '**/spec/routing/**/*' 116 | - '**/spec/system/**/*' 117 | - '**/spec/views/**/*' 118 | - 'spec/acceptance/dsc/basic.rb' 119 | - 'spec/acceptance/dsc/cim_instances.rb' 120 | - 'spec/acceptance/dsc/class.rb' 121 | - 'spec/acceptance/dsc/complex.rb' 122 | - 'spec/unit/pwsh_spec.rb' 123 | 124 | # Offense count: 38 125 | # Configuration parameters: CountAsOne. 126 | RSpec/ExampleLength: 127 | Max: 70 128 | 129 | # Offense count: 151 130 | # Configuration parameters: . 131 | # SupportedStyles: have_received, receive 132 | RSpec/MessageSpies: 133 | EnforcedStyle: receive 134 | 135 | # Offense count: 1 136 | RSpec/MultipleDescribes: 137 | Exclude: 138 | - 'spec/unit/pwsh_spec.rb' 139 | 140 | # Offense count: 157 141 | RSpec/MultipleExpectations: 142 | Max: 15 143 | 144 | # Offense count: 109 145 | # Configuration parameters: AllowSubject. 146 | RSpec/MultipleMemoizedHelpers: 147 | Max: 19 148 | 149 | # Offense count: 55 150 | # Configuration parameters: AllowedGroups. 151 | RSpec/NestedGroups: 152 | Max: 7 153 | 154 | # Offense count: 2 155 | # Configuration parameters: AllowedPatterns. 156 | # AllowedPatterns: ^expect_, ^assert_ 157 | RSpec/NoExpectationExample: 158 | Exclude: 159 | - 'spec/unit/pwsh/windows_powershell_spec.rb' 160 | - 'spec/unit/pwsh_spec.rb' 161 | 162 | # Offense count: 86 163 | RSpec/StubbedMock: 164 | Exclude: 165 | - 'spec/unit/puppet/provider/dsc_base_provider/dsc_base_provider_spec.rb' 166 | - 'spec/unit/pwsh/util_spec.rb' 167 | - 'spec/unit/pwsh/windows_powershell_spec.rb' 168 | 169 | # Offense count: 71 170 | RSpec/SubjectStub: 171 | Exclude: 172 | - 'spec/unit/puppet/provider/dsc_base_provider/dsc_base_provider_spec.rb' 173 | 174 | 175 | # Offense count: 1 176 | # Configuration parameters: AllowedConstants. 177 | Style/Documentation: 178 | Exclude: 179 | - 'spec/**/*' 180 | - 'test/**/*' 181 | - 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb' 182 | 183 | # Offense count: 3 184 | # This cop supports unsafe autocorrection (--autocorrect-all). 185 | # Configuration parameters: AllowedReceivers. 186 | # AllowedReceivers: Thread.current 187 | Style/HashEachMethods: 188 | Exclude: 189 | - 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb' 190 | 191 | # Offense count: 2 192 | # This cop supports unsafe autocorrection (--autocorrect-all). 193 | Style/HashExcept: 194 | Exclude: 195 | - 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb' 196 | - 'spec/unit/puppet/provider/dsc_base_provider/dsc_base_provider_spec.rb' 197 | 198 | # Offense count: 2 199 | # This cop supports unsafe autocorrection (--autocorrect-all). 200 | Style/MapIntoArray: 201 | Exclude: 202 | - 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb' 203 | 204 | 205 | # Offense count: 1 206 | # This cop supports safe autocorrection (--autocorrect). 207 | Style/RedundantRegexpArgument: 208 | Exclude: 209 | - 'spec/unit/pwsh_spec.rb' 210 | 211 | # Offense count: 1 212 | # This cop supports safe autocorrection (--autocorrect). 213 | Style/RedundantSelfAssignmentBranch: 214 | Exclude: 215 | - 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb' 216 | 217 | # Offense count: 1 218 | # This cop supports unsafe autocorrection (--autocorrect-all). 219 | # Configuration parameters: AllowedMethods, AllowedPatterns. 220 | Style/ReturnNilInPredicateMethodDefinition: 221 | Exclude: 222 | - 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb' 223 | -------------------------------------------------------------------------------- /.sync.yml: -------------------------------------------------------------------------------- 1 | --- 2 | ".gitlab-ci.yml": 3 | delete: true 4 | appveyor.yml: 5 | delete: true 6 | spec/spec_helper.rb: 7 | unmanaged: true 8 | .github/workflows/auto_release.yml: 9 | unmanaged: true 10 | .github/workflows/ci.yml: 11 | unmanaged: true 12 | .github/workflows/nightly.yml: 13 | unmanaged: true 14 | .github/workflows/release.yml: 15 | unmanaged: true 16 | .travis.yml: 17 | delete: true 18 | Rakefile: 19 | unmanaged: true 20 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "puppet.puppet-vscode", 4 | "Shopify.ruby-lsp" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --markup markdown 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # Changelog 3 | 4 | All notable changes to this project will be documented in this file. 5 | 6 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org). 7 | 8 | ## [v2.0.0](https://github.com/puppetlabs/ruby-pwsh/tree/v2.0.0) - 2025-05-06 9 | 10 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/v1.2.3...v2.0.0) 11 | 12 | ### Changed 13 | 14 | - (CAT-2281) Remove puppet 7 infrastructure [#375](https://github.com/puppetlabs/ruby-pwsh/pull/375) ([LukasAud](https://github.com/LukasAud)) 15 | 16 | ### Fixed 17 | 18 | - Ensure metaparams casing is preserved [#374](https://github.com/puppetlabs/ruby-pwsh/pull/374) ([Clebam](https://github.com/Clebam)) 19 | 20 | ## [v1.2.3](https://github.com/puppetlabs/ruby-pwsh/tree/v1.2.3) - 2025-03-18 21 | 22 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/v1.2.2...v1.2.3) 23 | 24 | ### Fixed 25 | 26 | - Handle string credentials [#369](https://github.com/puppetlabs/ruby-pwsh/pull/369) ([Clebam](https://github.com/Clebam)) 27 | - (Bug): do not pass dsc_timeout as timeout parameter to DSC resource params [#366](https://github.com/puppetlabs/ruby-pwsh/pull/366) ([jordanbreen28](https://github.com/jordanbreen28)) 28 | - Change [System.Environment]::SetEnvironmentVariable() to Set-ItemProperty [#365](https://github.com/puppetlabs/ruby-pwsh/pull/365) ([pkotov87](https://github.com/pkotov87)) 29 | - double quote when passing env var values [#351](https://github.com/puppetlabs/ruby-pwsh/pull/351) ([garrettrowell](https://github.com/garrettrowell)) 30 | 31 | ## [v1.2.2](https://github.com/puppetlabs/ruby-pwsh/tree/v1.2.2) - 2024-09-25 32 | 33 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/v1.2.1...v1.2.2) 34 | 35 | ### Fixed 36 | 37 | - (CAT-2061) Fix empty string nullification [#346](https://github.com/puppetlabs/ruby-pwsh/pull/346) ([david22swan](https://github.com/david22swan)) 38 | 39 | ## [v1.2.1](https://github.com/puppetlabs/ruby-pwsh/tree/v1.2.1) - 2024-09-20 40 | 41 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/v1.2.0...v1.2.1) 42 | 43 | ### Added 44 | 45 | - AlmaLinux 8/9 added to metadata.json [#338](https://github.com/puppetlabs/ruby-pwsh/pull/338) ([alex501212](https://github.com/alex501212)) 46 | 47 | ### Fixed 48 | 49 | - Revert "Fix empty string nullification" [#342](https://github.com/puppetlabs/ruby-pwsh/pull/342) ([jordanbreen28](https://github.com/jordanbreen28)) 50 | - (maint) - Fix incorrect test for file_path [#335](https://github.com/puppetlabs/ruby-pwsh/pull/335) ([jordanbreen28](https://github.com/jordanbreen28)) 51 | - (CAT-1991) - Skip missing dirs invalid_dir method [#334](https://github.com/puppetlabs/ruby-pwsh/pull/334) ([jordanbreen28](https://github.com/jordanbreen28)) 52 | 53 | ## [v1.2.0](https://github.com/puppetlabs/ruby-pwsh/tree/v1.2.0) - 2024-08-15 54 | 55 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/v1.1.1...v1.2.0) 56 | 57 | ### Added 58 | 59 | - (CAT-1869) - Add configurable dsc_timeout [#319](https://github.com/puppetlabs/ruby-pwsh/pull/319) ([jordanbreen28](https://github.com/jordanbreen28)) 60 | - Add support for unit testing via Unix OS [#309](https://github.com/puppetlabs/ruby-pwsh/pull/309) ([chambersmp](https://github.com/chambersmp)) 61 | 62 | ### Fixed 63 | 64 | - (bug) - Fix dsc timeout matcher [#331](https://github.com/puppetlabs/ruby-pwsh/pull/331) ([jordanbreen28](https://github.com/jordanbreen28)) 65 | - Fix empty string nullification [#292](https://github.com/puppetlabs/ruby-pwsh/pull/292) ([Clebam](https://github.com/Clebam)) 66 | 67 | ## [v1.1.1](https://github.com/puppetlabs/ruby-pwsh/tree/v1.1.1) - 2024-02-21 68 | 69 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/v1.1.0...v1.1.1) 70 | 71 | ### Fixed 72 | 73 | - (CAT-1724) - Fix Provider returned data not matching Type Schema [#295](https://github.com/puppetlabs/ruby-pwsh/pull/295) ([jordanbreen28](https://github.com/jordanbreen28)) 74 | - Fix enum idempotency [#291](https://github.com/puppetlabs/ruby-pwsh/pull/291) ([Clebam](https://github.com/Clebam)) 75 | 76 | ## [v1.1.0](https://github.com/puppetlabs/ruby-pwsh/tree/v1.1.0) - 2024-01-31 77 | 78 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/v1.0.1...v1.1.0) 79 | 80 | ### Added 81 | 82 | - (feat) - add retries on failed dsc invocation [#282](https://github.com/puppetlabs/ruby-pwsh/pull/282) ([jordanbreen28](https://github.com/jordanbreen28)) 83 | 84 | ### Fixed 85 | 86 | - (CAT-1688) Upgrade rubocop to `~> 1.50.0` [#279](https://github.com/puppetlabs/ruby-pwsh/pull/279) ([LukasAud](https://github.com/LukasAud)) 87 | 88 | ## [v1.0.1](https://github.com/puppetlabs/ruby-pwsh/tree/v1.0.1) - 2023-12-13 89 | 90 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/v1.0.0...v1.0.1) 91 | 92 | ### Fixed 93 | 94 | - (CAT-1617) - Always load vendored module in PSModulePath [#261](https://github.com/puppetlabs/ruby-pwsh/pull/261) ([jordanbreen28](https://github.com/jordanbreen28)) 95 | 96 | ## [v1.0.0](https://github.com/puppetlabs/ruby-pwsh/tree/v1.0.0) - 2023-08-17 97 | 98 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/v0.11.0...v1.0.0) 99 | 100 | ### Changed 101 | 102 | - (maint) - Drop Support for Debian 8/9 [#227](https://github.com/puppetlabs/ruby-pwsh/pull/227) ([jordanbreen28](https://github.com/jordanbreen28)) 103 | - (maint) - Drop Support for Ubuntu 16.04 [#226](https://github.com/puppetlabs/ruby-pwsh/pull/226) ([jordanbreen28](https://github.com/jordanbreen28)) 104 | - (maint) - Drop Support for Fedora 30&31 [#225](https://github.com/puppetlabs/ruby-pwsh/pull/225) ([jordanbreen28](https://github.com/jordanbreen28)) 105 | - (maint) - Drop Support for OSX 10.14 [#224](https://github.com/puppetlabs/ruby-pwsh/pull/224) ([jordanbreen28](https://github.com/jordanbreen28)) 106 | - (maint) - Drop Support for Windows 2008(R2)/7/8 [#223](https://github.com/puppetlabs/ruby-pwsh/pull/223) ([jordanbreen28](https://github.com/jordanbreen28)) 107 | - (CAT-1172) - Add Puppet 8 Support/Drop Puppet 6 Support [#221](https://github.com/puppetlabs/ruby-pwsh/pull/221) ([jordanbreen28](https://github.com/jordanbreen28)) 108 | 109 | ### Added 110 | 111 | - (feat) - Add support for Ubuntu 22.04 [#232](https://github.com/puppetlabs/ruby-pwsh/pull/232) ([jordanbreen28](https://github.com/jordanbreen28)) 112 | - (feat) - add Windows 11 & Server 2022 support [#231](https://github.com/puppetlabs/ruby-pwsh/pull/231) ([jordanbreen28](https://github.com/jordanbreen28)) 113 | - (feat) - Add support for Fedora 36 [#230](https://github.com/puppetlabs/ruby-pwsh/pull/230) ([jordanbreen28](https://github.com/jordanbreen28)) 114 | - (feat) - Add support for OSX 11&12 [#229](https://github.com/puppetlabs/ruby-pwsh/pull/229) ([jordanbreen28](https://github.com/jordanbreen28)) 115 | - (feat) - Add support for Debian 11 [#228](https://github.com/puppetlabs/ruby-pwsh/pull/228) ([jordanbreen28](https://github.com/jordanbreen28)) 116 | 117 | ### Fixed 118 | 119 | - (bug) - Fixes missing mandatory ID [#234](https://github.com/puppetlabs/ruby-pwsh/pull/234) ([jordanbreen28](https://github.com/jordanbreen28)) 120 | 121 | ## [v0.11.0](https://github.com/puppetlabs/ruby-pwsh/tree/v0.11.0) - 2023-08-16 122 | 123 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/v0.11.0.rc.1...v0.11.0) 124 | 125 | ## [v0.11.0.rc.1](https://github.com/puppetlabs/ruby-pwsh/tree/v0.11.0.rc.1) - 2023-04-17 126 | 127 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/v0.10.3...v0.11.0.rc.1) 128 | 129 | ### Changed 130 | 131 | - (CONT-867) Ruby 3 / Puppet 8 Support [#208](https://github.com/puppetlabs/ruby-pwsh/pull/208) ([chelnak](https://github.com/chelnak)) 132 | 133 | ## [v0.10.3](https://github.com/puppetlabs/ruby-pwsh/tree/v0.10.3) - 2022-12-19 134 | 135 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.10.2...v0.10.3) 136 | 137 | ### Fixed 138 | 139 | - (MODULES-11343) Preserve metaparameters [#192](https://github.com/puppetlabs/ruby-pwsh/pull/192) ([chelnak](https://github.com/chelnak)) 140 | 141 | ## [0.10.2](https://github.com/puppetlabs/ruby-pwsh/tree/0.10.2) - 2022-06-24 142 | 143 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.10.1...0.10.2) 144 | 145 | ### Fixed 146 | 147 | - (GH-188) Filter current environment variables [#189](https://github.com/puppetlabs/ruby-pwsh/pull/189) ([chelnak](https://github.com/chelnak)) 148 | 149 | ## [0.10.1](https://github.com/puppetlabs/ruby-pwsh/tree/0.10.1) - 2021-08-23 150 | 151 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.10.0...0.10.1) 152 | 153 | ### Fixed 154 | 155 | - (GH-180) Ensure instance_key respects full uniqueness of options [#181](https://github.com/puppetlabs/ruby-pwsh/pull/181) ([michaeltlombardi](https://github.com/michaeltlombardi)) 156 | - (GH-165) Ensure null-value nested cim instance arrays are appropriately munged [#177](https://github.com/puppetlabs/ruby-pwsh/pull/177) ([michaeltlombardi](https://github.com/michaeltlombardi)) 157 | 158 | ## [0.10.0](https://github.com/puppetlabs/ruby-pwsh/tree/0.10.0) - 2021-07-02 159 | 160 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.9.0...0.10.0) 161 | 162 | ### Added 163 | 164 | - (GH-172) Enable use of class-based DSC Resources by munging PSModulePath [#173](https://github.com/puppetlabs/ruby-pwsh/pull/173) ([michaeltlombardi](https://github.com/michaeltlombardi)) 165 | 166 | ## [0.9.0](https://github.com/puppetlabs/ruby-pwsh/tree/0.9.0) - 2021-06-28 167 | 168 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.8.0...0.9.0) 169 | 170 | ### Added 171 | 172 | - (GH-147) Refactor Invocation methods to use shared helper and write error logs when appropriate [#152](https://github.com/puppetlabs/ruby-pwsh/pull/152) ([david22swan](https://github.com/david22swan)) 173 | - (GH-145) Improve DSC secrets redaction [#150](https://github.com/puppetlabs/ruby-pwsh/pull/150) ([michaeltlombardi](https://github.com/michaeltlombardi)) 174 | - (GH-145) Add insync? and invoke_test_method to dsc provider [#124](https://github.com/puppetlabs/ruby-pwsh/pull/124) ([michaeltlombardi](https://github.com/michaeltlombardi)) 175 | 176 | ### Fixed 177 | 178 | - (IAC-1657) Fix for invalid DateTime value error in `invoke_get_method` [#169](https://github.com/puppetlabs/ruby-pwsh/pull/169) ([david22swan](https://github.com/david22swan)) 179 | - (GH-154) Ensure values returned from `invoke_get_method` are recursively sorted in the DSC Base Provider to reduce canonicalization warnings. [#160](https://github.com/puppetlabs/ruby-pwsh/pull/160) ([michaeltlombardi](https://github.com/michaeltlombardi)) 180 | - (GH-154) Fix return data from `Invoke-DscResource` for empty strings and single item arrays in DSC Base Provider [#159](https://github.com/puppetlabs/ruby-pwsh/pull/159) ([michaeltlombardi](https://github.com/michaeltlombardi)) 181 | - (GH-155) Fix CIM Instance munging in `invoke_get_method` for DSC Base Provider [#158](https://github.com/puppetlabs/ruby-pwsh/pull/158) ([michaeltlombardi](https://github.com/michaeltlombardi)) 182 | - (GH-154) Fix canonicalization in `get` method for DSC Base Provider [#157](https://github.com/puppetlabs/ruby-pwsh/pull/157) ([michaeltlombardi](https://github.com/michaeltlombardi)) 183 | - (GH-144) Enable order-insensitive comparisons for DSC [#151](https://github.com/puppetlabs/ruby-pwsh/pull/151) ([michaeltlombardi](https://github.com/michaeltlombardi)) 184 | - (GH-143) Handle order insensitive arrays in the `same?` method of the DSC Base Provider [#148](https://github.com/puppetlabs/ruby-pwsh/pull/148) ([michaeltlombardi](https://github.com/michaeltlombardi)) 185 | - (GH-127) Canonicalize enums correctly [#131](https://github.com/puppetlabs/ruby-pwsh/pull/131) ([michaeltlombardi](https://github.com/michaeltlombardi)) 186 | - (GH-125) Fix dsc provider canonicalization for absent resources [#129](https://github.com/puppetlabs/ruby-pwsh/pull/129) ([michaeltlombardi](https://github.com/michaeltlombardi)) 187 | - (MODULES-11051) Ensure environment variables are not incorrectly munged in the PowerShell Host [#128](https://github.com/puppetlabs/ruby-pwsh/pull/128) ([michaeltlombardi](https://github.com/michaeltlombardi)) 188 | - (MODULES-11026) Ensure the PowerShell manager works with v7 [#122](https://github.com/puppetlabs/ruby-pwsh/pull/122) ([n3snah](https://github.com/n3snah)) 189 | - (Maint) Ensure canonicalize correctly compares sorted hashes [#118](https://github.com/puppetlabs/ruby-pwsh/pull/118) ([Hvid](https://github.com/Hvid)) 190 | 191 | ## [0.8.0](https://github.com/puppetlabs/ruby-pwsh/tree/0.8.0) - 2021-03-01 192 | 193 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.7.4...0.8.0) 194 | 195 | ### Added 196 | 197 | - (MAINT) Clarify supported platforms [#113](https://github.com/puppetlabs/ruby-pwsh/pull/113) ([michaeltlombardi](https://github.com/michaeltlombardi)) 198 | 199 | ## [0.7.4](https://github.com/puppetlabs/ruby-pwsh/tree/0.7.4) - 2021-02-11 200 | 201 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.7.3...0.7.4) 202 | 203 | ### Fixed 204 | 205 | - (GH-105) Ensure set runs on ambiguous ensure states [#108](https://github.com/puppetlabs/ruby-pwsh/pull/108) ([michaeltlombardi](https://github.com/michaeltlombardi)) 206 | - (GH-105) Ensure canonicalized_cache check validates against namevar [#107](https://github.com/puppetlabs/ruby-pwsh/pull/107) ([michaeltlombardi](https://github.com/michaeltlombardi)) 207 | 208 | ## [0.7.3](https://github.com/puppetlabs/ruby-pwsh/tree/0.7.3) - 2021-02-03 209 | 210 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.7.2...0.7.3) 211 | 212 | ### Fixed 213 | 214 | - (MAINT) Place nil check when assigning is_same [#101](https://github.com/puppetlabs/ruby-pwsh/pull/101) ([bwilcox](https://github.com/bwilcox)) 215 | 216 | ## [0.7.2](https://github.com/puppetlabs/ruby-pwsh/tree/0.7.2) - 2021-02-03 217 | 218 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.7.1...0.7.2) 219 | 220 | ### Fixed 221 | 222 | - (GH-97) Memoize class variables in initialize [#98](https://github.com/puppetlabs/ruby-pwsh/pull/98) ([michaeltlombardi](https://github.com/michaeltlombardi)) 223 | - (MAINT) Ensure is_same check works for nil manifest values [#96](https://github.com/puppetlabs/ruby-pwsh/pull/96) ([bwilcox](https://github.com/bwilcox)) 224 | 225 | ## [0.7.1](https://github.com/puppetlabs/ruby-pwsh/tree/0.7.1) - 2021-02-02 226 | 227 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.7.0...0.7.1) 228 | 229 | ### Fixed 230 | 231 | - (MAINT) Correctly canonicalize enumerable values in dsc [#92](https://github.com/puppetlabs/ruby-pwsh/pull/92) ([michaeltlombardi](https://github.com/michaeltlombardi)) 232 | - (MAINT) Ensure vendored path check works with mix of module builds [#91](https://github.com/puppetlabs/ruby-pwsh/pull/91) ([michaeltlombardi](https://github.com/michaeltlombardi)) 233 | - (GH-84) Fix empty array parameter check [#90](https://github.com/puppetlabs/ruby-pwsh/pull/90) ([michaeltlombardi](https://github.com/michaeltlombardi)) 234 | - (MAINT) Minor fixes to CIM instance handling [#89](https://github.com/puppetlabs/ruby-pwsh/pull/89) ([michaeltlombardi](https://github.com/michaeltlombardi)) 235 | 236 | ## [0.7.0](https://github.com/puppetlabs/ruby-pwsh/tree/0.7.0) - 2021-01-20 237 | 238 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.6.3...0.7.0) 239 | 240 | ### Added 241 | 242 | - (GH-75) Including module name in vendored module path [#85](https://github.com/puppetlabs/ruby-pwsh/pull/85) ([pmcmaw](https://github.com/pmcmaw)) 243 | 244 | ### Fixed 245 | 246 | - Make root module path use puppetized module name [#86](https://github.com/puppetlabs/ruby-pwsh/pull/86) ([michaeltlombardi](https://github.com/michaeltlombardi)) 247 | 248 | ## [0.6.3](https://github.com/puppetlabs/ruby-pwsh/tree/0.6.3) - 2021-01-12 249 | 250 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.6.2...0.6.3) 251 | 252 | ### Fixed 253 | 254 | - (MAINT) Add handling for when dsc_ensure is stripped [#78](https://github.com/puppetlabs/ruby-pwsh/pull/78) ([michaeltlombardi](https://github.com/michaeltlombardi)) 255 | 256 | ## [0.6.2](https://github.com/puppetlabs/ruby-pwsh/tree/0.6.2) - 2020-12-09 257 | 258 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.6.1...0.6.2) 259 | 260 | ### Fixed 261 | 262 | - (MAINT) Ensure parameters are canonicalized [#75](https://github.com/puppetlabs/ruby-pwsh/pull/75) ([michaeltlombardi](https://github.com/michaeltlombardi)) 263 | 264 | ## [0.6.1](https://github.com/puppetlabs/ruby-pwsh/tree/0.6.1) - 2020-11-25 265 | 266 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.6.0...0.6.1) 267 | 268 | ### Fixed 269 | 270 | - (maint) - Removal of inappropriate terminology [#70](https://github.com/puppetlabs/ruby-pwsh/pull/70) ([pmcmaw](https://github.com/pmcmaw)) 271 | - (Maint) Fix ensurability in the dsc base provider [#69](https://github.com/puppetlabs/ruby-pwsh/pull/69) ([michaeltlombardi](https://github.com/michaeltlombardi)) 272 | 273 | ## [0.6.0](https://github.com/puppetlabs/ruby-pwsh/tree/0.6.0) - 2020-11-24 274 | 275 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.5.1...0.6.0) 276 | 277 | ### Added 278 | 279 | - (GH-81) Handle parameters in the dsc base provider [#62](https://github.com/puppetlabs/ruby-pwsh/pull/62) ([michaeltlombardi](https://github.com/michaeltlombardi)) 280 | - (GH-74) Remove special handling for ensure in the dsc base provider [#61](https://github.com/puppetlabs/ruby-pwsh/pull/61) ([michaeltlombardi](https://github.com/michaeltlombardi)) 281 | - (GH-59) Refactor away from Simple Provider [#60](https://github.com/puppetlabs/ruby-pwsh/pull/60) ([michaeltlombardi](https://github.com/michaeltlombardi)) 282 | 283 | ### Fixed 284 | 285 | - (GH-57) Handle datetimes in dsc [#58](https://github.com/puppetlabs/ruby-pwsh/pull/58) ([michaeltlombardi](https://github.com/michaeltlombardi)) 286 | - (GH-55) Handle intentionally empty arrays [#56](https://github.com/puppetlabs/ruby-pwsh/pull/56) ([michaeltlombardi](https://github.com/michaeltlombardi)) 287 | 288 | ## [0.5.1](https://github.com/puppetlabs/ruby-pwsh/tree/0.5.1) - 2020-09-25 289 | 290 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.5.0...0.5.1) 291 | 292 | ### Fixed 293 | 294 | - (MAINT) Ensure dsc provider finds dsc resources during agent run [#45](https://github.com/puppetlabs/ruby-pwsh/pull/45) ([michaeltlombardi](https://github.com/michaeltlombardi)) 295 | 296 | ## [0.5.0](https://github.com/puppetlabs/ruby-pwsh/tree/0.5.0) - 2020-08-20 297 | 298 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.4.1...0.5.0) 299 | 300 | ### Added 301 | 302 | - (IAC-1045) Add the DSC base Puppet provider to pwshlib [#39](https://github.com/puppetlabs/ruby-pwsh/pull/39) ([michaeltlombardi](https://github.com/michaeltlombardi)) 303 | 304 | ## [0.4.1](https://github.com/puppetlabs/ruby-pwsh/tree/0.4.1) - 2020-02-12 305 | 306 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.4.0...0.4.1) 307 | 308 | ### Fixed 309 | 310 | - Ensure ruby versions older than 2.3 function correctly [#30](https://github.com/puppetlabs/ruby-pwsh/pull/30) ([binford2k](https://github.com/binford2k)) 311 | 312 | ## [0.4.0](https://github.com/puppetlabs/ruby-pwsh/tree/0.4.0) - 2020-01-13 313 | 314 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.3.0...0.4.0) 315 | 316 | ### Added 317 | 318 | - (MODULES-10389) Add puppet feature for dependent modules to leverage [#20](https://github.com/puppetlabs/ruby-pwsh/pull/20) ([sanfrancrisko](https://github.com/sanfrancrisko)) 319 | 320 | ## [0.3.0](https://github.com/puppetlabs/ruby-pwsh/tree/0.3.0) - 2019-12-04 321 | 322 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.2.0...0.3.0) 323 | 324 | ### Added 325 | 326 | - (FEAT) Add method for symbolizing hash keys [#16](https://github.com/puppetlabs/ruby-pwsh/pull/16) ([michaeltlombardi](https://github.com/michaeltlombardi)) 327 | 328 | ### Fixed 329 | 330 | - (FEAT) Ensure hash key casing methods work on arrays [#15](https://github.com/puppetlabs/ruby-pwsh/pull/15) ([michaeltlombardi](https://github.com/michaeltlombardi)) 331 | 332 | ## [0.2.0](https://github.com/puppetlabs/ruby-pwsh/tree/0.2.0) - 2019-11-25 333 | 334 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.1.0...0.2.0) 335 | 336 | ### Added 337 | 338 | - (FEAT) Add quality of life utilities [#11](https://github.com/puppetlabs/ruby-pwsh/pull/11) ([michaeltlombardi](https://github.com/michaeltlombardi)) 339 | - (FM-8422) Make library releasable as a Puppet module [#8](https://github.com/puppetlabs/ruby-pwsh/pull/8) ([michaeltlombardi](https://github.com/michaeltlombardi)) 340 | 341 | ## [0.1.0](https://github.com/puppetlabs/ruby-pwsh/tree/0.1.0) - 2019-09-25 342 | 343 | [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0eb77a723430cfbd77d4859c43e15b3f1276d164...0.1.0) 344 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Setting ownership to the tooling team 2 | * @puppetlabs/devx 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to ruby-pwsh 2 | 3 | So you want to contribute to the ruby-pwsh gem: Great! Below are some instructions to get you started doing 4 | that very thing while setting expectations around code quality as well as a few tips for making the 5 | process as easy as possible. 6 | 7 | ## Table of Contents 8 | 9 | 1. [Getting Started](#getting-started) 10 | 1. [Commit Checklist](#commit-checklist) 11 | 1. [Submission](#submission) 12 | 1. [More about commits](#more-about-commits) 13 | 1. [Testing](#testing) 14 | - [Running Tests](#running-tests) 15 | - [Writing Tests](#writing-tests) 16 | 1. [Get Help](#get-help) 17 | 18 | ## Getting Started 19 | 20 | - Fork the module repository on GitHub and clone to your workspace 21 | - Make your changes! 22 | 23 | ## Commit Checklist 24 | 25 | ### The Basics 26 | 27 | - [x] my commit is a single logical unit of work 28 | - [x] I have checked for unnecessary whitespace with "git diff --check" 29 | - [x] my commit does not include commented out code or unneeded files 30 | 31 | ### The Content 32 | 33 | - [x] my commit includes tests for the bug I fixed or feature I added 34 | - [x] my commit includes appropriate documentation changes if it is introducing a new feature or changing existing functionality 35 | - [x] my code passes existing test suites 36 | 37 | ### The Commit Message 38 | 39 | - [x] the first line of my commit message includes: 40 | - [x] an issue number (if applicable), e.g. "(GH-xxxx) This is the first line" 41 | - [x] a short description (50 characters is the soft limit, excluding issue number(s)) 42 | - [x] the body of my commit message: 43 | - [x] is meaningful 44 | - [x] uses the imperative, present tense: "change", not "changed" or "changes" 45 | - [x] includes motivation for the change, and contrasts its implementation with the previous behavior 46 | 47 | ## Submission 48 | 49 | ### Pre-requisites 50 | 51 | - Make sure you have a [GitHub account](https://github.com/join) 52 | - [Create an issue](https://github.com/puppetlabs/ruby-pwsh/issues/new/choose), or [watch the issue](https://github.com/puppetlabs/ruby-pwsh/issues) you are patching for. 53 | 54 | ### Push and PR 55 | 56 | - Push your changes to your fork 57 | - [Open a Pull Request](https://help.github.com/articles/creating-a-pull-request-from-a-fork/) against the repository in the puppetlabs organization 58 | 59 | ## More about commits 60 | 61 | 1. Make separate commits for logically separate changes. 62 | 63 | Please break your commits down into logically consistent units which include new or changed tests relevant to the rest of the change. 64 | The goal of doing this is to make the diff easier to read for whoever is reviewing your code. 65 | In general, the easier your diff is to read, the more likely someone will be happy to review it and get it into the code base. 66 | 67 | If you are going to refactor a piece of code, please do so as a separate commit from your feature or bug fix changes. 68 | 69 | We also really appreciate changes that include tests to make sure the bug is not re-introduced, and that the feature is not accidentally broken. 70 | 71 | Describe the technical detail of the change(s). 72 | If your description starts to get too long, that is a good sign that you probably need to split up your commit into more finely grained pieces. 73 | 74 | Commits which plainly describe the things which help reviewers check the patch and future developers understand the code are much more likely to be merged in with a minimum of bike-shedding or requested changes. 75 | Ideally, the commit message would include information, and be in a form suitable for inclusion in the release notes for the version of Puppet that includes them. 76 | 77 | Please also check that you are not introducing any trailing whitespace or other "whitespace errors". 78 | You can do this by running `git diff --check` on your changes before you commit. 79 | 80 | 2. Sending your patches 81 | 82 | To submit your changes via a GitHub pull request, we _highly_ recommend that you have them on a topic branch, instead of directly on "main". 83 | It makes things much easier to keep track of, especially if you decide to work on another thing before your first change is merged in. 84 | 85 | GitHub has some pretty good [general documentation](http://help.github.com/) on using their site. 86 | They also have documentation on [creating pull requests](https://help.github.com/articles/creating-a-pull-request-from-a-fork/). 87 | 88 | In general, after pushing your topic branch up to your repository on GitHub, you can switch to the branch in the GitHub UI and click "Pull Request" towards the top of the page in order to open a pull request. 89 | 90 | 3. Update the related GitHub issue. 91 | 92 | If there is a GitHub issue associated with the change you submitted, then you should update the issue to include the location of your branch, along with any other commentary you may wish to make. 93 | 94 | # Testing 95 | 96 | ## Getting Started 97 | 98 | Our Puppet modules provide [`Gemfile`](./Gemfile)s, which can tell a Ruby package manager such as [bundler](http://bundler.io/) what Ruby packages, 99 | or Gems, are required to build, develop, and test this software. 100 | 101 | Please make sure you have [bundler installed](http://bundler.io/#getting-started) on your system, and then use it to 102 | install all dependencies needed for this project in the project root by running 103 | 104 | ```shell 105 | # Unless you're doing a release, you don't need the puppet gem group 106 | % bundle install --path .bundle/gems --without puppet 107 | Fetching gem metadata from https://rubygems.org/........ 108 | Fetching gem metadata from https://rubygems.org/.. 109 | Using rake (10.1.0) 110 | Using builder (3.2.2) 111 | -- 8><-- many more --><8 -- 112 | Your bundle is complete! 113 | Use `bundle show [gemname]` to see where a bundled gem is installed. 114 | ``` 115 | 116 | NOTE: some systems may require you to run this command with sudo. 117 | 118 | If you already have those gems installed, make sure they are up-to-date: 119 | 120 | ```shell 121 | % bundle update 122 | ``` 123 | 124 | ## Running Tests 125 | 126 | With all dependencies in place and up-to-date, run the tests: 127 | 128 | ### Unit Tests 129 | 130 | ```shell 131 | % bundle exec rake spec 132 | ``` 133 | 134 | This executes all the [rspec tests](https://rspec.info/) in the `spec` directory. 135 | 136 | ## If you have commit access to the repository 137 | 138 | Even if you have commit access to the repository, you still need to go through the process above, and have someone else review and merge 139 | in your changes. 140 | The rule is that **all changes must be reviewed by a project developer that did not write the code to ensure that all changes go through a code review process.** 141 | 142 | The record of someone performing the merge is the record that they performed the code review. 143 | Again, this should be someone other than the author of the topic branch. 144 | 145 | ## Get Help 146 | 147 | ### On the web 148 | 149 | - [General GitHub documentation](http://help.github.com/) 150 | - [GitHub pull request documentation](http://help.github.com/send-pull-requests/) 151 | 152 | ### On chat 153 | 154 | - Slack (slack.puppet.com) #forge-modules, #puppet-dev, #windows, #voxpupuli 155 | - IRC (freenode) #puppet-dev, #voxpupuli 156 | -------------------------------------------------------------------------------- /DESIGN.md: -------------------------------------------------------------------------------- 1 | # PowerShell Manager: Design and Architecture 2 | 3 | This gem allows the use of a long-lived manager to which Ruby can send PowerShell invocations and receive the exection output. 4 | This reduces the overhead time to execute PowerShell commands from seconds to milliseconds because each execution does not need to spin up a PowerShell process, execute a single pipeline, and tear the process down. 5 | 6 | The manager operates by instantiating a custom PowerShell host process to which Ruby can then send commands over an IO pipe— 7 | on Windows machines, named pipes, on Unix/Linux, Unix Domain Sockets. 8 | 9 | ## Communication Between Ruby and PowerShell Host Process 10 | 11 | Communication between Ruby and the PowerShell host process uses binary encoded strings with a [4-byte prefix indicating how long the message is](https://en.wikipedia.org/wiki/Type-length-value). 12 | The length prefix is a Little Endian encoded 32-bit integer. 13 | The string being passed is always UTF-8. 14 | 15 | Before a command string is sent to the PowerShell host process, a single 1-byte command identifier is sent— 16 | `0` to tell the process to exit, `1` to tell the process to execute the next incoming string. 17 | 18 | The PowerShell code to be executed is always wrapped in the following for execution to standardize behavior inside the PowerShell host process: 19 | 20 | ```powershell 21 | $params = @{ 22 | Code = @' 23 | #{powershell_code} 24 | '@ 25 | TimeoutMilliseconds = #{timeout_ms} 26 | WorkingDirectory = "#{working_dir}" 27 | ExecEnvironmentVariables = #{exec_environment_variables} 28 | } 29 | 30 | Invoke-PowerShellUserCode @params 31 | ``` 32 | 33 | The code itself is placed inside a herestring and the timeout (integer), working directory (string), and environment variables (hash), if any, are passed as well. 34 | 35 | ![Diagram of communication flow for execution between Ruby and PowerShell manager](./design-comms.png) 36 | 37 | ### Output 38 | 39 | The return from a Ruby command will always include: 40 | 41 | + `stdout` from the output streams, as if using `*>&1` to redirect 42 | + `exitcode` for the exit code of the execution; this will always be `0`, unless an exit code is specified or an exception is _thrown_. 43 | + `stderr` will always be an empty array. 44 | + `errormessage` will be the exception message of any thrown exception during execution. 45 | + `native_stdout` will always be nil. 46 | 47 | #### Error Handling 48 | 49 | Because PowerShell does not halt execution when an error is encountered, only when an terminating exception is thrown, the manager _also_ continues to execute until it encounters a terminating exception when executing commands. 50 | This means that `Write-Error` messages will go to the stdout log but will not cause a change in the `exitcode` or populate the `errormessage` field. 51 | Using `Throw` or any other method of generating a terminating exception _will_ set the `exitcode` to `1` and populate the `errormessage` field. 52 | 53 | ## Multiple PowerShell Host Processes 54 | 55 | Until told otherwise, or they break, or their parent process closes, the instantiated PowerShell host processes will remain alive and waiting for further commands. 56 | The significantly speeds up the execution of serialized commands, making continual interoperation between Ruby and PowerShell less complex for the developer leveraging this library. 57 | 58 | In order to do this, the manager class has a class variable, `@@instances`, which contains a hash of the PowerShell hosts: 59 | 60 | + The key is the unique combination of options - path to the executable, flags, and additional options - passed to create the instance. 61 | + The value is the current state of that instance of the PowerShell host process. 62 | 63 | If you attempt to instantiate an instance of the manager using the `instance` method, it will _first_ look to see if the specified manager and host process are already built and alive - if the manager instance does not exist or the host process is dead, _then_ it will spin up a new host process. 64 | 65 | In test executions, standup of an instance takes around 1.5 seconds - accessing a pre-existing instance takes thousandths of a second. 66 | 67 | ## Multithreading 68 | 69 | The manager and PowerShell host process are designed to be used single-threadedly with the PowerShell host expecting a single command and returning a single output at a time. 70 | It does not at this time have additional guarding against being sent commands by multiple processes, but since the handles are unique IDs, this should not happen in practice. 71 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source ENV['GEM_SOURCE'] || 'https://rubygems.org' 2 | 3 | def location_for(place_or_version, fake_version = nil) 4 | git_url_regex = %r{\A(?(https?|git)[:@][^#]*)(#(?.*))?} 5 | file_url_regex = %r{\Afile:\/\/(?.*)} 6 | 7 | if place_or_version && (git_url = place_or_version.match(git_url_regex)) 8 | [fake_version, { git: git_url[:url], branch: git_url[:branch], require: false }].compact 9 | elsif place_or_version && (file_url = place_or_version.match(file_url_regex)) 10 | ['>= 0', { path: File.expand_path(file_url[:path]), require: false }] 11 | else 12 | [place_or_version, { require: false }] 13 | end 14 | end 15 | 16 | group :development do 17 | gem "json", '= 2.6.1', require: false if Gem::Requirement.create(['>= 3.1.0', '< 3.1.3']).satisfied_by?(Gem::Version.new(RUBY_VERSION.dup)) 18 | gem "json", '= 2.6.3', require: false if Gem::Requirement.create(['>= 3.2.0', '< 4.0.0']).satisfied_by?(Gem::Version.new(RUBY_VERSION.dup)) 19 | gem "deep_merge", '~> 1.0', require: false 20 | gem "voxpupuli-puppet-lint-plugins", '~> 5.0', require: false 21 | gem "facterdb", '~> 1.18', require: false 22 | gem "metadata-json-lint", '~> 4.0', require: false 23 | gem "rspec-puppet-facts", '~> 3.0', require: false 24 | gem "dependency_checker", '~> 1.0.0', require: false 25 | gem "parallel_tests", '3.13.0', require: false 26 | gem "pry", '~> 0.10', require: false 27 | gem "simplecov-console", '~> 0.9', require: false 28 | gem "puppet-debugger", '~> 1.0', require: false 29 | gem "rubocop", '~> 1.70.0', require: false 30 | gem "rubocop-performance", '1.22.1', require: false 31 | gem "rubocop-rspec", '= 2.19.0', require: false 32 | gem "rb-readline", '= 0.5.5', require: false, platforms: [:mswin, :mingw, :x64_mingw] 33 | end 34 | group :development, :release_prep do 35 | gem "puppet-strings", '~> 4.0', require: false 36 | gem "puppetlabs_spec_helper", '~> 8.0', require: false 37 | end 38 | group :system_tests do 39 | gem "puppet_litmus", '~> 1.0', require: false, platforms: [:ruby, :x64_mingw] 40 | gem "CFPropertyList", '< 3.0.7', require: false, platforms: [:mswin, :mingw, :x64_mingw] 41 | gem "serverspec", '~> 2.41', require: false 42 | end 43 | 44 | puppet_version = ENV['PUPPET_GEM_VERSION'] 45 | facter_version = ENV['FACTER_GEM_VERSION'] 46 | hiera_version = ENV['HIERA_GEM_VERSION'] 47 | 48 | gems = {} 49 | 50 | gems['puppet'] = location_for(puppet_version) 51 | 52 | # If facter or hiera versions have been specified via the environment 53 | # variables 54 | 55 | gems['facter'] = location_for(facter_version) if facter_version 56 | gems['hiera'] = location_for(hiera_version) if hiera_version 57 | 58 | gems.each do |gem_name, gem_params| 59 | gem gem_name, *gem_params 60 | end 61 | 62 | # Evaluate Gemfile.local and ~/.gemfile if they exist 63 | extra_gemfiles = [ 64 | "#{__FILE__}.local", 65 | File.join(Dir.home, '.gemfile'), 66 | ] 67 | 68 | extra_gemfiles.each do |gemfile| 69 | if File.file?(gemfile) && File.readable?(gemfile) 70 | eval(File.read(gemfile), binding) 71 | end 72 | end 73 | # vim: syntax=ruby 74 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 PuppetLabs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ruby-pwsh 2 | 3 | [![Code Owners](https://img.shields.io/badge/owners-DevX--team-blue)](https://github.com/puppetlabs/ruby-pwsh/blob/main/CODEOWNERS) 4 | [![ci](https://github.com/puppetlabs/ruby-pwsh/actions/workflows/ci.yml/badge.svg)](https://github.com/puppetlabs/ruby-pwsh/actions/workflows/ci.yml) 5 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/puppetlabs/ruby-pwsh) 6 | 7 | > _The PowerShell gem._ 8 | 9 | This gem enables you to execute PowerShell from within ruby without having to instantiate and tear down a PowerShell process for each command called. 10 | It supports Windows PowerShell as well as PowerShell Core (and, soon, _just_ PowerShell) - if you're running *PowerShell v3+, this gem supports you. 11 | 12 | The `Manager` class enables you to execute and interoperate with PowerShell from within ruby, leveraging the strengths of both languages as needed. 13 | 14 | ## Installation 15 | 16 | Add this line to your application's Gemfile: 17 | 18 | ```ruby 19 | gem 'ruby-pwsh' 20 | ``` 21 | 22 | And then execute: 23 | 24 | ```shell 25 | bundle install 26 | ``` 27 | 28 | Or install it yourself as: 29 | 30 | ```shell 31 | gem install ruby-pwsh 32 | ``` 33 | 34 | ## Usage 35 | 36 | Instantiating the manager can be done using some defaults: 37 | 38 | ```ruby 39 | # Instantiate the manager for Windows PowerShell, using the default path and arguments 40 | # Note that this takes a few seconds to instantiate. 41 | posh = Pwsh::Manager.instance(Pwsh::Manager.powershell_path, Pwsh::Manager.powershell_args) 42 | # If you try to create another manager with the same arguments it will reuse the existing one. 43 | ps = Pwsh::Manager.instance(Pwsh::Manager.powershell_path, Pwsh::Manager.powershell_args) 44 | # Note that this time the return is very fast. 45 | # We can also use the defaults for PowerShell Core, though these only work if PowerShell is 46 | # installed to the default paths - if it is installed anywhere else, you'll need to specify 47 | # the full path to the pwsh executable. 48 | pwsh = Pwsh::Manager.instance(Pwsh::Manager.pwsh_path, Pwsh::Manager.pwsh_args) 49 | ``` 50 | 51 | Execution can be done with relatively little additional work - pass the command string you want executed: 52 | 53 | ```ruby 54 | # Instantiate the Manager: 55 | posh = Pwsh::Manager.instance(Pwsh::Manager.powershell_path, Pwsh::Manager.powershell_args) 56 | # Pretty print the output of `$PSVersionTable` to validate the version of PowerShell running 57 | # Note that the output is a hash with a few different keys, including stdout. 58 | pp(posh.execute('$PSVersionTable')) 59 | # Lets reduce the noise a little and retrieve just the version number: 60 | # Note: We cast to a string because PSVersion is actually a Version object. 61 | pp(posh.execute('[String]$PSVersionTable.PSVersion')) 62 | # We could store this output to a ruby variable if we wanted, for further use: 63 | ps_version = posh.execute('[String]$PSVersionTable.PSVersion')[:stdout].strip 64 | pp("The PowerShell version of the currently running Manager is #{ps_version}") 65 | ``` 66 | 67 | ## Reference 68 | 69 | You can find the full reference documentation online, [here](https://rubydoc.info/gems/ruby-pwsh). 70 | 71 | 76 | 77 | ## Supported Operating Systems 78 | 79 | The following platforms are supported: 80 | 81 | - Windows 82 | - CentOS 83 | - Debian 84 | - Fedora 85 | - OSX 86 | - RedHat 87 | - Ubuntu 88 | - AlmaLinux 89 | 90 | ## Limitations 91 | 92 | - When PowerShell [Script Block Logging](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_logging_windows?view=powershell-7.4#enabling-script-block-logging) is enabled, data marked as sensitive in your manifest may appear in these logs as plain text. It is **highly recommended**, by both Puppet and Microsoft, that you also enable [Protected Event Logging](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_logging_windows?view=powershell-7.4#protected-event-logging) alongside this to encrypt the logs to protect this information. 93 | 94 | ## License 95 | 96 | This codebase is licensed under Apache 2.0. However, the open source dependencies included in this codebase might be subject to other software licenses such as AGPL, GPL2.0, and MIT. 97 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rubocop/rake_task' 5 | require 'fileutils' 6 | require 'open3' 7 | require 'pwsh/version' 8 | require 'rspec/core/rake_task' 9 | require 'yard' 10 | 11 | RSpec::Core::RakeTask.new(:spec) do |t| 12 | t.pattern = 'spec/unit/**/*_spec.rb' 13 | end 14 | task default: :spec 15 | 16 | namespace :spec do 17 | desc 'Run RSpec code examples with coverage collection' 18 | task :coverage do 19 | ENV['COVERAGE'] = 'yes' 20 | Rake::Task['spec'].execute 21 | end 22 | end 23 | 24 | YARD::Rake::YardocTask.new do |t| 25 | end 26 | # Used in vendor_dsc_module 27 | TAR_LONGLINK = '././@LongLink' 28 | 29 | # Vendor a Puppetized DSC Module to spec/fixtures/modules. 30 | # 31 | # This is necessary because `puppet module install` fails on modules with 32 | # long file paths, like xpsdesiredstateconfiguration 33 | # 34 | # @param command [String] command to execute. 35 | # @return [Object] the standard out stream. 36 | def vendor_dsc_module(name, version, destination) 37 | require 'open-uri' 38 | require 'rubygems/package' 39 | require 'zlib' 40 | 41 | module_uri = "https://forge.puppet.com/v3/files/dsc-#{name}-#{version}.tar.gz" 42 | tar_gz_archive = File.expand_path("#{name}.tar.gz", ENV['TEMP']) 43 | 44 | # Download the archive from the forge 45 | File.open(tar_gz_archive, 'wb') do |file| 46 | file.write(URI.open(module_uri).read) # rubocop:disable Security/Open 47 | end 48 | 49 | # Unzip to destination 50 | # Taken directly from StackOverflow: 51 | # - https://stackoverflow.com/a/19139114 52 | Gem::Package::TarReader.new(Zlib::GzipReader.open(tar_gz_archive)) do |tar| 53 | dest = nil 54 | tar.each do |entry| 55 | if entry.full_name == TAR_LONGLINK 56 | dest = File.join(destination, entry.read.strip) 57 | next 58 | end 59 | dest ||= File.join(destination, entry.full_name) 60 | if entry.directory? 61 | File.delete(dest) if File.file?(dest) 62 | FileUtils.mkdir_p(dest, mode: entry.header.mode, verbose: false) 63 | elsif entry.file? 64 | FileUtils.rm_rf(dest) if File.directory?(dest) 65 | File.open(dest, 'wb') do |f| 66 | f.print(entry.read) 67 | end 68 | FileUtils.chmod(entry.header.mode, dest, verbose: false) 69 | elsif entry.header.typeflag == '2' # Symlink! 70 | File.symlink(entry.header.linkname, dest) 71 | end 72 | dest = nil 73 | end 74 | end 75 | 76 | # Rename folder to just the module name, as needed by Puppet 77 | Dir.glob("#{destination}/*#{name}*").each do |existing_folder| 78 | new_folder = File.expand_path(name, destination) 79 | FileUtils.mv(existing_folder, new_folder) 80 | end 81 | end 82 | 83 | # Ensure that winrm is configured on the target system. 84 | # 85 | # @return [Object] The result of the command execution. 86 | def configure_winrm 87 | return unless Gem.win_platform? 88 | 89 | command = 'pwsh.exe -NoProfile -NonInteractive -NoLogo -ExecutionPolicy Bypass -File "spec/acceptance/support/setup_winrm.ps1"' 90 | system(command) 91 | rescue StandardError => e 92 | puts "Failed to configure WinRM: #{e}" 93 | exit 1 94 | end 95 | 96 | RSpec::Core::RakeTask.new(:acceptance) do |t| 97 | t.pattern = 'spec/acceptance/dsc/*.rb' 98 | end 99 | 100 | namespace :acceptance do 101 | desc 'Prep for running DSC acceptance tests' 102 | task :spec_prep do 103 | # Create the modules fixture folder, if needed 104 | modules_folder = File.expand_path('spec/fixtures/modules', File.dirname(__FILE__)) 105 | FileUtils.mkdir_p(modules_folder) unless Dir.exist?(modules_folder) 106 | # symlink the parent folder to the modules folder for puppet 107 | symlink_path = File.expand_path('pwshlib', modules_folder) 108 | File.symlink(File.dirname(__FILE__), symlink_path) unless Dir.exist?(symlink_path) 109 | # Install each of the required modules for acceptance testing 110 | # Note: This only works for modules in the dsc namespace on the forge. 111 | puppetized_dsc_modules = [ 112 | { name: 'powershellget', version: '2.2.5-0-1' }, 113 | { name: 'jeadsc', version: '0.7.2-0-3' }, 114 | { name: 'xpsdesiredstateconfiguration', version: '9.1.0-0-1' }, 115 | { name: 'xwebadministration', version: '3.2.0-0-2' }, 116 | { name: 'accesscontroldsc', version: '1.4.1-0-3' } 117 | ] 118 | puppetized_dsc_modules.each do |puppet_module| 119 | next if Dir.exist?(File.expand_path(puppet_module[:name], modules_folder)) 120 | 121 | vendor_dsc_module(puppet_module[:name], puppet_module[:version], modules_folder) 122 | end 123 | 124 | # Configure WinRM for acceptance tests 125 | configure_winrm 126 | end 127 | end 128 | 129 | task :acceptance => 'acceptance:spec_prep' 130 | -------------------------------------------------------------------------------- /design-comms.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/puppetlabs/ruby-pwsh/47fc6513876645e6785d346001fd9f96c520a8c9/design-comms.png -------------------------------------------------------------------------------- /lib/puppet/feature/pwshlib.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'puppet/util/feature' 4 | 5 | Puppet.features.add(:pwshlib, libs: ['ruby-pwsh']) 6 | -------------------------------------------------------------------------------- /lib/puppet/provider/dsc_base_provider/invoke_dsc_resource_functions.ps1: -------------------------------------------------------------------------------- 1 | function new-pscredential { 2 | [CmdletBinding()] 3 | param ( 4 | [parameter(Mandatory = $true, 5 | ValueFromPipelineByPropertyName = $true)] 6 | [string] 7 | $user, 8 | 9 | [parameter(Mandatory = $true, 10 | ValueFromPipelineByPropertyName = $true)] 11 | [string] 12 | $password 13 | ) 14 | 15 | $secpasswd = ConvertTo-SecureString $password -AsPlainText -Force 16 | $credentials = New-Object System.Management.Automation.PSCredential ($user, $secpasswd) 17 | return $credentials 18 | } 19 | 20 | Function ConvertTo-CanonicalResult { 21 | [CmdletBinding()] 22 | param( 23 | [Parameter(Mandatory, Position = 1)] 24 | [psobject] 25 | $Result, 26 | 27 | [Parameter(DontShow)] 28 | [string] 29 | $PropertyPath, 30 | 31 | [Parameter(DontShow)] 32 | [int] 33 | $RecursionLevel = 0 34 | ) 35 | 36 | $MaxDepth = 5 37 | $CimInstancePropertyFilter = { $_.Definition -match 'CimInstance' -and $_.Name -ne 'PSDscRunAsCredential' } 38 | 39 | # Get the properties which are/aren't Cim instances 40 | $ResultObject = @{ } 41 | $ResultPropertyList = $Result | Get-Member -MemberType Property | Where-Object { $_.Name -ne 'PSComputerName' } 42 | $CimInstanceProperties = $ResultPropertyList | Where-Object -FilterScript $CimInstancePropertyFilter 43 | 44 | foreach ($Property in $ResultPropertyList) { 45 | $PropertyName = $Property.Name 46 | if ($Property -notin $CimInstanceProperties) { 47 | $Value = $Result.$PropertyName 48 | if ($PropertyName -eq 'Ensure' -and [string]::IsNullOrEmpty($Result.$PropertyName)) { 49 | # Just set 'Present' since it was found /shrug 50 | # If the value IS listed as absent, don't update it unless you want flapping 51 | $Value = 'Present' 52 | } 53 | else { 54 | if ([string]::IsNullOrEmpty($value)) { 55 | # While PowerShell can happily treat empty strings as valid for returning 56 | # an undefined enum, Puppet expects undefined values to be nil. 57 | $Value = $null 58 | } 59 | 60 | if ($Value.Count -eq 1 -and $Property.Definition -match '\\[\\]') { 61 | $Value = @($Value) 62 | } 63 | } 64 | } 65 | elseif ($null -eq $Result.$PropertyName) { 66 | if ($Property -match 'InstanceArray') { 67 | $Value = @() 68 | } 69 | else { 70 | $Value = $null 71 | } 72 | } 73 | elseif ($Result.$PropertyName.GetType().Name -match 'DateTime') { 74 | # Handle DateTimes especially since they're an edge case 75 | $Value = Get-Date $Result.$PropertyName -UFormat "%Y-%m-%dT%H:%M:%S%Z" 76 | } 77 | else { 78 | # Looks like a nested CIM instance, recurse if we're not too deep in already. 79 | $RecursionLevel++ 80 | 81 | if ($PropertyPath -eq [string]::Empty) { 82 | $PropertyPath = $PropertyName 83 | } 84 | else { 85 | $PropertyPath = "$PropertyPath.$PropertyName" 86 | } 87 | 88 | if ($RecursionLevel -gt $MaxDepth) { 89 | # Give up recursing more than this 90 | return $Result.ToString() 91 | } 92 | 93 | $Value = foreach ($item in $Result.$PropertyName) { 94 | ConvertTo-CanonicalResult -Result $item -PropertyPath $PropertyPath -RecursionLevel ($RecursionLevel + 1) -WarningAction Continue 95 | } 96 | 97 | # The cim instance type is the last component of the type Name 98 | # We need to return this for ruby to compare the result hashes 99 | # We do NOT need it for the top-level properties as those are defined in the type 100 | If ($RecursionLevel -gt 1 -and ![string]::IsNullOrEmpty($Value) ) { 101 | # If there's multiple instances, you need to add the type to each one, but you 102 | # need to specify only *one* name, otherwise things end up *very* broken. 103 | if ($Value.GetType().Name -match '\[\]') { 104 | $Value | ForEach-Object -Process { 105 | $_.cim_instance_type = $Result.$PropertyName.CimClass.CimClassName[0] 106 | } 107 | } else { 108 | $Value.cim_instance_type = $Result.$PropertyName.CimClass.CimClassName 109 | # Ensure that, if it should be an array, it is 110 | if ($Result.$PropertyName.GetType().Name -match '\[\]') { 111 | $Value = @($Value) 112 | } 113 | } 114 | } 115 | } 116 | 117 | if ($Property.Definition -match 'InstanceArray') { 118 | If ($null -eq $Value -or $Value.GetType().Name -notmatch '\[\]') { $Value = @($Value) } 119 | } 120 | 121 | $ResultObject.$PropertyName = $Value 122 | } 123 | 124 | # Output the final result 125 | $ResultObject 126 | } -------------------------------------------------------------------------------- /lib/puppet/provider/dsc_base_provider/invoke_dsc_resource_postscript.ps1: -------------------------------------------------------------------------------- 1 | Try { 2 | $Result = Invoke-DscResource @InvokeParams 3 | } catch { 4 | $Response.errormessage = $_.Exception.Message 5 | return ($Response | ConvertTo-Json -Compress) 6 | } Finally { 7 | If (![string]::IsNullOrEmpty($UnmungedPSModulePath)) { 8 | # Reset the PSModulePath 9 | Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' -Name 'PSModulePath' -Value $UnmungedPSModulePath 10 | $env:PSModulePath = [System.Environment]::GetEnvironmentVariable('PSModulePath', 'machine') 11 | } 12 | } 13 | 14 | # keep the switch for when Test passes back changed properties 15 | Switch ($invokeParams.Method) { 16 | 'Test' { 17 | $Response.indesiredstate = $Result.InDesiredState 18 | return ($Response | ConvertTo-Json -Compress) 19 | } 20 | 'Set' { 21 | $Response.indesiredstate = $true 22 | $Response.rebootrequired = $Result.RebootRequired 23 | return ($Response | ConvertTo-Json -Compress) 24 | } 25 | 'Get' { 26 | $CanonicalizedResult = ConvertTo-CanonicalResult -Result $Result 27 | return ($CanonicalizedResult | ConvertTo-Json -Compress -Depth 10) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/puppet/provider/dsc_base_provider/invoke_dsc_resource_preamble.ps1: -------------------------------------------------------------------------------- 1 | $script:ErrorActionPreference = 'Stop' 2 | $script:WarningPreference = 'SilentlyContinue' 3 | 4 | $response = @{ 5 | indesiredstate = $false 6 | rebootrequired = $false 7 | errormessage = '' 8 | } 9 | -------------------------------------------------------------------------------- /lib/pwsh.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: false 2 | 3 | require 'pwsh/util' 4 | require 'pwsh/version' 5 | require 'pwsh/windows_powershell' 6 | require 'rexml/document' 7 | require 'securerandom' 8 | require 'socket' 9 | require 'open3' 10 | require 'base64' 11 | require 'logger' 12 | 13 | # Manage PowerShell and Windows PowerShell via ruby 14 | module Pwsh 15 | # Standard errors 16 | class Error < StandardError; end 17 | 18 | # Create an instance of a PowerShell host and manage execution of PowerShell code inside that host. 19 | class Manager 20 | attr_reader :powershell_command, :powershell_arguments 21 | 22 | # We actually want this to be a class variable. 23 | @@instances = {} # rubocop:disable Style/ClassVars 24 | 25 | # Return the list of currently instantiated instances of the PowerShell Manager 26 | # @return [Hash] the list of instantiated instances of the PowerShell Manager, including their params and status. 27 | def self.instances 28 | @@instances 29 | end 30 | 31 | # Returns a set of default options for instantiating a manager 32 | # 33 | # @return [Hash] the default options for a new manager 34 | def self.default_options 35 | { 36 | debug: false, 37 | pipe_timeout: 30 38 | } 39 | end 40 | 41 | # Return an instance of the manager if one already exists for the specified 42 | # options or instantiate a new one if needed 43 | # 44 | # @param cmd [String] the full path to the PowerShell executable to manage 45 | # @param args [Array] the list of additional arguments to pass PowerShell 46 | # @param options [Hash] the set of options to set the behavior of the manager, including debug/timeout 47 | # @return [] specific instance matching the specified parameters either newly created or previously instantiated 48 | def self.instance(cmd, args, options = {}) 49 | options = default_options.merge!(options) 50 | 51 | key = instance_key(cmd, args, options) 52 | manager = @@instances[key] 53 | 54 | if manager.nil? || !manager.alive? 55 | # ignore any errors trying to tear down this unusable instance 56 | begin 57 | manager.exit unless manager.nil? # rubocop:disable Style/SafeNavigation 58 | rescue StandardError 59 | nil 60 | end 61 | @@instances[key] = Manager.new(cmd, args, options) 62 | end 63 | 64 | @@instances[key] 65 | end 66 | 67 | # Determine whether or not the Win32 Console is enabled 68 | # 69 | # @return [Bool] true if enabled 70 | def self.win32console_enabled? 71 | @win32console_enabled ||= 72 | defined?(Win32::Console) && 73 | Win32::Console.instance_of?(Class) 74 | end 75 | 76 | # TODO: This thing isn't called anywhere and the variable it sets is never referenced... 77 | # Determine whether or not the machine has a compatible version of Windows PowerShell 78 | # 79 | # @return [Bool] true if Windows PowerShell 3+ is available or 2+ with .NET 3.5SP1 80 | # def self.compatible_version_of_windows_powershell? 81 | # @compatible_version_of_powershell ||= Pwsh::WindowsPowerShell.compatible_version? 82 | # end 83 | 84 | # Determine whether or not the manager is supported on the machine for Windows PowerShell 85 | # 86 | # @return [Bool] true if Windows PowerShell is manageable 87 | def self.windows_powershell_supported? 88 | Pwsh::Util.on_windows? && 89 | Pwsh::WindowsPowerShell.compatible_version? && 90 | !win32console_enabled? 91 | end 92 | 93 | # Determine whether or not the manager is supported on the machine for PowerShell 6+ 94 | # 95 | # @return [Bool] true if pwsh is manageable 96 | def self.pwsh_supported? 97 | !win32console_enabled? 98 | end 99 | 100 | # Instantiate a new instance of the PowerShell Manager 101 | # 102 | # @param cmd [String] 103 | # @param args [Array] 104 | # @param options [Hash] 105 | # @return nil 106 | def initialize(cmd, args = [], options = {}) 107 | @usable = true 108 | @powershell_command = cmd 109 | @powershell_arguments = args 110 | 111 | warn "Bad configuration for ENV['lib']=#{ENV['lib']} - invalid path" if Pwsh::Util.invalid_directories?(ENV['lib']) 112 | 113 | if Pwsh::Util.on_windows? 114 | # Named pipes under Windows will automatically be mounted in \\.\pipe\... 115 | # https://github.com/dotnet/corefx/blob/a10890f4ffe0fadf090c922578ba0e606ebdd16c/src/System.IO.Pipes/src/System/IO/Pipes/NamedPipeServerStream.Windows.cs#L34 116 | named_pipe_name = "#{SecureRandom.uuid}PsHost" 117 | # This named pipe path is Windows specific. 118 | pipe_path = "\\\\.\\pipe\\#{named_pipe_name}" 119 | else 120 | require 'tmpdir' 121 | # .Net implements named pipes under Linux etc. as Unix Sockets in the filesystem 122 | # Paths that are rooted are not munged within C# Core. 123 | # https://github.com/dotnet/corefx/blob/94e9d02ad70b2224d012ac4a66eaa1f913ae4f29/src/System.IO.Pipes/src/System/IO/Pipes/PipeStream.Unix.cs#L49-L60 124 | # https://github.com/dotnet/corefx/blob/a10890f4ffe0fadf090c922578ba0e606ebdd16c/src/System.IO.Pipes/src/System/IO/Pipes/NamedPipeServerStream.Unix.cs#L44 125 | # https://github.com/dotnet/corefx/blob/a10890f4ffe0fadf090c922578ba0e606ebdd16c/src/System.IO.Pipes/src/System/IO/Pipes/NamedPipeServerStream.Unix.cs#L298-L299 126 | named_pipe_name = File.join(Dir.tmpdir, "#{SecureRandom.uuid}PsHost") 127 | pipe_path = named_pipe_name 128 | end 129 | pipe_timeout = options[:pipe_timeout] || self.class.default_options[:pipe_timeout] 130 | debug = options[:debug] || self.class.default_options[:debug] 131 | native_cmd = Pwsh::Util.on_windows? ? "\"#{cmd}\"" : cmd 132 | 133 | ps_args = args + ['-File', self.class.template_path, "\"#{named_pipe_name}\""] 134 | ps_args << '"-EmitDebugOutput"' if debug 135 | # @stderr should never be written to as PowerShell host redirects output 136 | stdin, @stdout, @stderr, @ps_process = Open3.popen3("#{native_cmd} #{ps_args.join(' ')}") 137 | stdin.close 138 | 139 | # TODO: Log a debug for "#{Time.now} #{cmd} is running as pid: #{@ps_process[:pid]}" 140 | 141 | # Wait up to 180 seconds in 0.2 second intervals to be able to open the pipe. 142 | # If the pipe_timeout is ever specified as less than the sleep interval it will 143 | # never try to connect to a pipe and error out as if a timeout occurred. 144 | sleep_interval = 0.2 145 | (pipe_timeout / sleep_interval).to_int.times do 146 | begin # rubocop:disable Style/RedundantBegin 147 | @pipe = if Pwsh::Util.on_windows? 148 | # Pipe is opened in binary mode and must always <- always what?? 149 | File.open(pipe_path, 'r+b') 150 | else 151 | UNIXSocket.new(pipe_path) 152 | end 153 | break 154 | rescue StandardError 155 | sleep sleep_interval 156 | end 157 | end 158 | if @pipe.nil? 159 | # Tear down and kill the process if unable to connect to the pipe; failure to do so 160 | # results in zombie processes being left after a caller run. We discovered that 161 | # closing @ps_process via .kill instead of using this method actually kills the 162 | # watcher and leaves an orphaned process behind. Failing to close stdout and stderr 163 | # also leaves clutter behind, so explicitly close those too. 164 | @stdout.close unless @stdout.closed? 165 | @stderr.close unless @stderr.closed? 166 | Process.kill('KILL', @ps_process[:pid]) if @ps_process.alive? 167 | raise "Failure waiting for PowerShell process #{@ps_process[:pid]} to start pipe server" 168 | end 169 | 170 | # TODO: Log a debug for "#{Time.now} PowerShell initialization complete for pid: #{@ps_process[:pid]}" 171 | 172 | at_exit { exit } 173 | end 174 | 175 | # Return whether or not the manager is running, usable, and the I/O streams remain open. 176 | # 177 | # @return [Bool] true if manager is in working state 178 | def alive? 179 | # powershell process running 180 | @ps_process.alive? && 181 | # explicitly set during a read / write failure, like broken pipe EPIPE 182 | @usable && 183 | # an explicit failure state might not have been hit, but IO may be closed 184 | self.class.stream_valid?(@pipe) && 185 | self.class.stream_valid?(@stdout) && 186 | self.class.stream_valid?(@stderr) 187 | end 188 | 189 | # Run specified powershell code via the manager 190 | # 191 | # @param powershell_code [String] 192 | # @param timeout_ms [Int] 193 | # @param working_dir [String] 194 | # @param environment_variables [Hash] 195 | # @return [Hash] Hash containing exitcode, stderr, native_stdout and stdout 196 | def execute(powershell_code, timeout_ms = nil, working_dir = nil, environment_variables = []) 197 | code = make_ps_code(powershell_code, timeout_ms, working_dir, environment_variables) 198 | # err is drained stderr pipe (not captured by redirection inside PS) 199 | # or during a failure, a Ruby callstack array 200 | out, native_stdout, err = exec_read_result(code) 201 | 202 | # an error was caught during execution that has invalidated any results 203 | return { exitcode: -1, stderr: err } if out.nil? && !@usable 204 | 205 | out[:exitcode] = out[:exitcode].to_i unless out[:exitcode].nil? 206 | # If err contains data it must be "real" stderr output 207 | # which should be appended to what PS has already captured 208 | out[:stderr] = out[:stderr].nil? ? [] : [out[:stderr]] 209 | out[:stderr] += err unless err.nil? 210 | out[:native_stdout] = native_stdout 211 | out 212 | end 213 | 214 | # Tear down the instance of the manager, shutting down the pipe and process. 215 | # 216 | # @return nil 217 | def exit 218 | @usable = false 219 | 220 | # TODO: Log a debug for "Pwsh exiting..." 221 | 222 | # Ask PowerShell pipe server to shutdown if its still running 223 | # rather than expecting the pipe.close to terminate it 224 | begin 225 | write_pipe(pipe_command(:exit)) unless @pipe.closed? 226 | rescue StandardError 227 | nil 228 | end 229 | 230 | # Pipe may still be open, but if stdout / stderr are deat the PS 231 | # process is in trouble and will block forever on a write to the 232 | # pipe. It's safer to close pipe on the Ruby side, which gracefully 233 | # shuts down the PS side. 234 | @pipe.close unless @pipe.closed? 235 | @stdout.close unless @stdout.closed? 236 | @stderr.close unless @stderr.closed? 237 | 238 | # Wait up to 2 seconds for the watcher thread to full exit 239 | @ps_process.join(2) 240 | end 241 | 242 | # Return the path to the bootstrap template 243 | # 244 | # @return [String] full path to the bootstrap template 245 | def self.template_path 246 | # A PowerShell -File compatible path to bootstrap the instance 247 | path = File.expand_path('templates', __dir__) 248 | path = File.join(path, 'init.ps1').tr('/', '\\') 249 | "\"#{path}\"" 250 | end 251 | 252 | # Return the block of code to be run by the manager with appropriate settings 253 | # 254 | # @param powershell_code [String] the actual PowerShell code you want to run 255 | # @param timeout_ms [Int] the number of milliseconds to wait for the command to run 256 | # @param working_dir [String] the working directory for PowerShell to execute from within 257 | # @param environment_variables [Array] Any overrides for environment variables you want to specify 258 | # @return [String] PowerShell code to be executed via the manager with appropriate params per config. 259 | def make_ps_code(powershell_code, timeout_ms = nil, working_dir = nil, environment_variables = []) 260 | begin 261 | # Zero timeout is a special case. Other modules sometimes treat this 262 | # as an infinite timeout. We don't support infinite, so for the case 263 | # of a user specifying zero, we sub in the default value of 300s. 264 | timeout_ms = 300 * 1000 if timeout_ms.zero? 265 | 266 | timeout_ms = Integer(timeout_ms) 267 | 268 | # Lower bound protection. The polling resolution is only 50ms. 269 | timeout_ms = 50 if timeout_ms < 50 270 | rescue StandardError 271 | timeout_ms = 300 * 1000 272 | end 273 | 274 | # Environment array firstly needs to be parsed and converted into a hashtable. 275 | # And then the values passed in need to be converted to a PowerShell Hashtable. 276 | # 277 | # Environment parsing is based on the puppet exec equivalent code 278 | # https://github.com/puppetlabs/puppet/blob/a9f77d71e992fc2580de7705847e31264e0fbebe/lib/puppet/provider/exec.rb#L35-L49 279 | environment = {} 280 | if (envlist = environment_variables) 281 | envlist = [envlist] unless envlist.is_a? Array 282 | envlist.each do |setting| 283 | if setting =~ /^(\w+)=((.|\n)+)$/ 284 | env_name = Regexp.last_match(1) 285 | value = Regexp.last_match(2) 286 | if environment.include?(env_name) || environment.include?(env_name.to_sym) 287 | # TODO: log a warning for "Overriding environment setting '#{env_name}' with '#{value}'" 288 | end 289 | environment[env_name] = value 290 | else # rubocop:disable Style/EmptyElse 291 | # TODO: log a warning for "Cannot understand environment setting #{setting.inspect}" 292 | end 293 | end 294 | end 295 | # Convert the Ruby Hashtable into PowerShell syntax 296 | additional_environment_variables = '@{' 297 | unless environment.empty? 298 | environment.each do |name, value| 299 | # PowerShell escapes single quotes inside a single quoted string by just adding 300 | # another single quote i.e. a value of foo'bar turns into 'foo''bar' when single quoted. 301 | ps_name = name.gsub('\'', '\'\'') 302 | ps_value = value.gsub('\'', '\'\'') 303 | additional_environment_variables += " '#{ps_name}' = '#{ps_value}';" 304 | end 305 | end 306 | additional_environment_variables += '}' 307 | 308 | # PS Side expects Invoke-PowerShellUserCode is always the return value here 309 | # TODO: Refactor to use <<~ as soon as we can :sob: 310 | <<~CODE 311 | $params = @{ 312 | Code = @' 313 | #{powershell_code} 314 | '@ 315 | TimeoutMilliseconds = #{timeout_ms} 316 | WorkingDirectory = "#{working_dir}" 317 | AdditionalEnvironmentVariables = #{additional_environment_variables} 318 | } 319 | 320 | Invoke-PowerShellUserCode @params 321 | CODE 322 | end 323 | 324 | # Default arguments for running Windows PowerShell via the manager 325 | # 326 | # @return [Array[String]] array of command flags to pass Windows PowerShell 327 | def self.powershell_args 328 | ps_args = ['-NoProfile', '-NonInteractive', '-NoLogo', '-ExecutionPolicy', 'Bypass'] 329 | ps_args << '-Command' unless windows_powershell_supported? 330 | 331 | ps_args 332 | end 333 | 334 | # The path to Windows PowerShell on the system 335 | # 336 | # @return [String] the absolute path to the PowerShell executable. Returns 'powershell.exe' if no more specific path found. 337 | def self.powershell_path 338 | if File.exist?("#{ENV.fetch('SYSTEMROOT', nil)}\\sysnative\\WindowsPowershell\\v1.0\\powershell.exe") 339 | "#{ENV.fetch('SYSTEMROOT', nil)}\\sysnative\\WindowsPowershell\\v1.0\\powershell.exe" 340 | elsif File.exist?("#{ENV.fetch('SYSTEMROOT', nil)}\\system32\\WindowsPowershell\\v1.0\\powershell.exe") 341 | "#{ENV.fetch('SYSTEMROOT', nil)}\\system32\\WindowsPowershell\\v1.0\\powershell.exe" 342 | else 343 | 'powershell.exe' 344 | end 345 | end 346 | 347 | # Retrieves the absolute path to pwsh 348 | # 349 | # @return [String] the absolute path to the found pwsh executable. Returns nil when it does not exist 350 | def self.pwsh_path(additional_paths = []) 351 | # Environment variables on Windows are not case sensitive however ruby hash keys are. 352 | # Convert all the key names to upcase so we can be sure to find PATH etc. 353 | # Also while ruby can have difficulty changing the case of some UTF8 characters, we're 354 | # only going to use plain ASCII names so this is safe. 355 | current_path = Pwsh::Util.on_windows? ? ENV.select { |k, _| k.casecmp('PATH').zero? }.values[0] : ENV.fetch('PATH', nil) 356 | current_path = '' if current_path.nil? 357 | 358 | # Prefer any additional paths 359 | # TODO: Should we just use arrays by now instead of appending strings? 360 | search_paths = additional_paths.empty? ? current_path : additional_paths.join(File::PATH_SEPARATOR) + File::PATH_SEPARATOR + current_path 361 | 362 | # If we're on Windows, try the default installation locations as a last resort. 363 | # https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell-core-on-windows?view=powershell-6#msi 364 | # https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell-core-on-windows?view=powershell-7.1 365 | if Pwsh::Util.on_windows? 366 | # TODO: What about PS 8? 367 | # TODO: Need to check on French/Turkish windows if ENV['PROGRAMFILES'] parses UTF8 names correctly 368 | # TODO: Need to ensure ENV['PROGRAMFILES'] is case insensitive, i.e. ENV['PROGRAMFiles'] should also resolve on Windows 369 | search_paths += ";#{ENV.fetch('PROGRAMFILES', nil)}\\PowerShell\\6" \ 370 | ";#{ENV.fetch('PROGRAMFILES(X86)', nil)}\\PowerShell\\6" \ 371 | ";#{ENV.fetch('PROGRAMFILES', nil)}\\PowerShell\\7" \ 372 | ";#{ENV.fetch('PROGRAMFILES(X86)', nil)}\\PowerShell\\7" 373 | end 374 | raise 'No paths discovered to search for Powershell!' if search_paths.split(File::PATH_SEPARATOR).empty? 375 | 376 | pwsh_paths = [] 377 | # TODO: THis could probably be done better, but it works! 378 | if Pwsh::Util.on_windows? 379 | search_paths.split(File::PATH_SEPARATOR).each do |path| 380 | pwsh_paths << File.join(path, 'pwsh.exe') if File.exist?(File.join(path, 'pwsh.exe')) 381 | end 382 | else 383 | search_paths.split(':').each do |path| 384 | pwsh_paths << File.join(path, 'pwsh') if File.exist?(File.join(path, 'pwsh')) 385 | end 386 | end 387 | 388 | # TODO: not sure about nil? but .empty? is MethodNotFound on nil 389 | raise 'No pwsh discovered!' if pwsh_paths.nil? || pwsh_paths.empty? 390 | 391 | pwsh_paths[0] 392 | end 393 | 394 | # Default arguments for running PowerShell 6+ via the manager 395 | # 396 | # @return [Array[String]] array of command flags to pass PowerShell 6+ 397 | def self.pwsh_args 398 | ['-NoProfile', '-NonInteractive', '-NoLogo', '-ExecutionPolicy', 'Bypass'] 399 | end 400 | 401 | # The unique key for a given manager as determined by the full path to 402 | # the executable, the arguments to pass to the executable, and the options 403 | # specified for the manager; this enables the code to reuse an existing 404 | # manager if the same path, arguments, and options are specified. 405 | # 406 | # @return[String] Unique string representing the manager instance. 407 | def self.instance_key(cmd, args, options) 408 | cmd + args.join(' ') + options.to_s 409 | end 410 | 411 | # Return whether or not a particular stream is valid and readable 412 | # 413 | # @return [Bool] true if stream is readable and open 414 | def self.readable?(stream, timeout = 0.5) 415 | raise Errno::EPIPE unless stream_valid?(stream) 416 | 417 | read_ready = IO.select([stream], [], [], timeout) 418 | read_ready && stream == read_ready[0][0] && !stream.eof? 419 | end 420 | 421 | # When a stream has been closed by handle, but Ruby still has a file 422 | # descriptor for it, it can be tricky to detemine that it's actually 423 | # dead. The .fileno will still return an int, and calling get_osfhandle 424 | # against it returns what the CRT thinks is a valid Windows HANDLE value, 425 | # but that may no longer exist. 426 | # 427 | # @return [Bool] true if stream is open and operational 428 | def self.stream_valid?(stream) 429 | # When a stream is closed, it's obviously invalid, but Ruby doesn't always know 430 | !stream.closed? && 431 | # So calling stat will yield and EBADF when underlying OS handle is bad 432 | # as this resolves to a HANDLE and then calls the Windows API 433 | !stream.stat.nil? 434 | # Any exceptions mean the stream is dead 435 | rescue StandardError 436 | false 437 | end 438 | 439 | # The manager sends a 4-byte integer representing the number 440 | # of bytes to read for the incoming string. This method reads 441 | # that prefix and then reads the specified number of bytes. 442 | # Mutates the given bytes, removing the length prefixed value. 443 | # 444 | # @return [String] The UTF-8 encoded string containing the payload 445 | def self.read_length_prefixed_string!(bytes) 446 | # 32 bit integer in Little Endian format 447 | length = bytes.slice!(0, 4).unpack1('V') 448 | return nil if length.zero? 449 | 450 | bytes.slice!(0, length).force_encoding(Encoding::UTF_8) 451 | end 452 | 453 | # Takes a given input byte-stream from PowerShell, length-prefixed, 454 | # and reads the key-value pairs from that output until all the 455 | # information is retrieved. Mutates the given bytes. 456 | # 457 | # @return [Hash] String pairs representing the information passed 458 | def self.ps_output_to_hash!(bytes) 459 | hash = {} 460 | 461 | hash[read_length_prefixed_string!(bytes).to_sym] = read_length_prefixed_string!(bytes) until bytes.empty? 462 | 463 | hash 464 | end 465 | 466 | # This is the command that the ruby process will send to the PowerShell 467 | # process and utilizes a 1 byte command identifier 468 | # 0 - Exit 469 | # 1 - Execute 470 | # 471 | # @return[String] Single byte representing the specified command 472 | def pipe_command(command) 473 | case command 474 | when :exit 475 | "\x00" 476 | when :execute 477 | "\x01" 478 | end 479 | end 480 | 481 | # Take a given string and prefix it with a 4-byte length and encode for sending 482 | # to the PowerShell manager. 483 | # Data format is: 484 | # 4 bytes - Little Endian encoded 32-bit integer length of string 485 | # Intel CPUs are little endian, hence the .NET Framework typically is 486 | # variable length - UTF8 encoded string bytes 487 | # 488 | # @return[String] A binary encoded string prefixed with a 4-byte length identifier 489 | def length_prefixed_string(data) 490 | msg = data.encode(Encoding::UTF_8) 491 | # https://ruby-doc.org/core-1.9.3/Array.html#method-i-pack 492 | [msg.bytes.length].pack('V') + msg.force_encoding(Encoding::BINARY) 493 | end 494 | 495 | # Writes binary-encoded data to the PowerShell manager process via the pipe. 496 | # 497 | # @return nil 498 | def write_pipe(input) 499 | written = @pipe.write(input) 500 | @pipe.flush 501 | 502 | if written != input.length # rubocop:disable Style/GuardClause 503 | msg = "Only wrote #{written} out of #{input.length} expected bytes to PowerShell pipe" 504 | raise Errno::EPIPE.new, msg 505 | end 506 | end 507 | 508 | # Read output from the PowerShell manager process via the pipe. 509 | # 510 | # @param pipe [IO] I/O Pipe to read from 511 | # @param timeout [Float] The number of seconds to wait for the pipe to be readable 512 | # @yield [String] a binary encoded string chunk 513 | # @return nil 514 | def read_from_pipe(pipe, timeout = 0.1, &_block) 515 | if self.class.readable?(pipe, timeout) 516 | l = pipe.readpartial(4096) 517 | # TODO: Log a debug for "#{Time.now} PIPE> #{l}" 518 | # Since readpartial may return a nil at EOF, skip returning that value 519 | yield l unless l.nil? 520 | end 521 | 522 | nil 523 | end 524 | 525 | # Read from a specified pipe for as long as the signal is locked and 526 | # the pipe is readable. Then return the data as an array of UTF-8 strings. 527 | # 528 | # @param pipe [IO] the I/O pipe to read 529 | # @param signal [Mutex] the signal to wait for whilst reading data 530 | # @return [Array] An empty array if no data read or an array wrapping a single UTF-8 string if output received. 531 | def drain_pipe_until_signaled(pipe, signal) 532 | output = [] 533 | 534 | read_from_pipe(pipe) { |s| output << s } while signal.locked? 535 | 536 | # There's ultimately a bit of a race here 537 | # Read one more time after signal is received 538 | read_from_pipe(pipe, 0) { |s| output << s } while self.class.readable?(pipe) 539 | 540 | # String has been binary up to this point, so force UTF-8 now 541 | output == [] ? [] : [output.join.force_encoding(Encoding::UTF_8)] 542 | end 543 | 544 | # Open threads and pipes to read stdout and stderr from the PowerShell manager, 545 | # then continue to read data from the manager until either all data is returned 546 | # or an error interrupts the normal flow, then return that data. 547 | # 548 | # @return [Array] Array of three strings representing the output, native stdout, and stderr 549 | def read_streams 550 | pipe_done_reading = Mutex.new 551 | pipe_done_reading.lock 552 | # TODO: Uncomment again when implementing logging 553 | # start_time = Time.now 554 | 555 | stdout_reader = Thread.new { drain_pipe_until_signaled(@stdout, pipe_done_reading) } 556 | stderr_reader = Thread.new { drain_pipe_until_signaled(@stderr, pipe_done_reading) } 557 | 558 | pipe_reader = Thread.new(@pipe) do |pipe| 559 | # Read a Little Endian 32-bit integer for length of response 560 | expected_response_length = pipe.sysread(4).unpack1('V') 561 | 562 | next nil if expected_response_length.zero? 563 | 564 | # Reads the expected bytes as a binary string or fails 565 | buffer = '' 566 | # sysread may not return all of the requested bytes due to buffering or the 567 | # underlying IO system. Keep reading from the pipe until all the bytes are read. 568 | loop do 569 | buffer.concat(pipe.sysread(expected_response_length - buffer.length)) 570 | break if buffer.length >= expected_response_length 571 | end 572 | buffer 573 | end 574 | 575 | # TODO: Log a debug for "Waited #{Time.now - start_time} total seconds." 576 | 577 | # Block until sysread has completed or errors 578 | begin 579 | output = pipe_reader.value 580 | output = self.class.ps_output_to_hash!(output) unless output.nil? 581 | ensure 582 | # Signal stdout / stderr readers via Mutex so that 583 | # Ruby doesn't crash waiting on an invalid event. 584 | pipe_done_reading.unlock 585 | end 586 | 587 | # Given redirection on PowerShell side, this should always be empty 588 | stdout = stdout_reader.value 589 | 590 | [ 591 | output, 592 | stdout == [] ? nil : stdout.join, # native stdout 593 | stderr_reader.value # native stderr 594 | ] 595 | ensure 596 | # Failsafe if the prior unlock was never reached / Mutex wasn't unlocked 597 | pipe_done_reading.unlock if pipe_done_reading.locked? 598 | # Wait for all non-nil threads to see mutex unlocked and finish 599 | [pipe_reader, stdout_reader, stderr_reader].compact.each(&:join) 600 | end 601 | 602 | # Executes PowerShell code over the PowerShell manager and returns the results. 603 | # 604 | # @param powershell_code [String] The PowerShell code to execute via the manager 605 | # @return [Array] Array of three strings representing the output, native stdout, and stderr 606 | def exec_read_result(powershell_code) 607 | write_pipe(pipe_command(:execute)) 608 | write_pipe(length_prefixed_string(powershell_code)) 609 | read_streams 610 | # If any pipes are broken, the manager is totally hosed 611 | # Bad file descriptors mean closed stream handles 612 | # EOFError is a closed pipe (could be as a result of tearing down process) 613 | # Errno::ECONNRESET is a closed unix domain socket (could be as a result of tearing down process) 614 | rescue Errno::EPIPE, Errno::EBADF, EOFError, Errno::ECONNRESET => e 615 | @usable = false 616 | [nil, nil, [e.inspect, e.backtrace].flatten] 617 | # Catch closed stream errors specifically 618 | rescue IOError => e 619 | raise unless e.message.start_with?('closed stream') 620 | 621 | @usable = false 622 | [nil, nil, [e.inspect, e.backtrace].flatten] 623 | end 624 | end 625 | end 626 | -------------------------------------------------------------------------------- /lib/pwsh/util.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Manage PowerShell and Windows PowerShell via ruby 4 | module Pwsh 5 | # Various helper methods 6 | module Util 7 | module_function 8 | 9 | # Verifies whether or not the current context is running on a Windows node. 10 | # Implementation copied from `facets`: https://github.com/rubyworks/facets/blob/main/lib/standard/facets/rbconfig.rb 11 | # 12 | # @return [Bool] true if on windows 13 | def on_windows? 14 | host_os = RbConfig::CONFIG['host_os'] 15 | !!(host_os =~ /mswin|mingw/) 16 | end 17 | 18 | # Verify paths specified are valid directories. 19 | # Skips paths which do not exist. 20 | # @return [Bool] true if any paths specified are not valid directories 21 | def invalid_directories?(path_collection) 22 | return false if path_collection.nil? || path_collection.empty? 23 | 24 | delimiter = on_windows? ? ';' : ':' 25 | paths = path_collection.split(delimiter) 26 | 27 | paths.any? { |path| !path.empty? && File.exist?(path) && !File.directory?(path) } 28 | end 29 | 30 | # Return a string or symbol converted to snake_case 31 | # 32 | # @return [String] snake_cased string 33 | def snake_case(object) 34 | # Implementation copied from: https://github.com/rubyworks/facets/blob/master/lib/core/facets/string/snakecase.rb 35 | # gsub(/::/, '/'). 36 | should_symbolize = object.is_a?(Symbol) 37 | raise "snake_case method only handles strings and symbols, passed a #{object.class}: #{object}" unless should_symbolize || object.is_a?(String) 38 | 39 | text = object.to_s 40 | .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') 41 | .gsub(/([a-z\d])([A-Z])/, '\1_\2') 42 | .tr('-', '_') 43 | .gsub(/\s/, '_') 44 | .gsub(/__+/, '_') 45 | .downcase 46 | should_symbolize ? text.to_sym : text 47 | end 48 | 49 | # Iterate through a hashes keys, snake_casing them 50 | # 51 | # @return [Hash] Hash with all keys snake_cased 52 | def snake_case_hash_keys(object) 53 | snake_case_proc = proc { |key| snake_case(key) } 54 | apply_key_mutator(object, snake_case_proc) 55 | end 56 | 57 | # Return a string or symbol converted to PascalCase 58 | # 59 | # @return [String] PascalCased string 60 | def pascal_case(object) 61 | should_symbolize = object.is_a?(Symbol) 62 | raise "snake_case method only handles strings and symbols, passed a #{object.class}: #{object}" unless should_symbolize || object.is_a?(String) 63 | 64 | # Break word boundaries to snake case first 65 | text = snake_case(object.to_s).split('_').collect(&:capitalize).join 66 | should_symbolize ? text.to_sym : text 67 | end 68 | 69 | # Iterate through a hashes keys, PascalCasing them 70 | # 71 | # @return [Hash] Hash with all keys PascalCased 72 | def pascal_case_hash_keys(object) 73 | pascal_case_proc = proc { |key| pascal_case(key) } 74 | apply_key_mutator(object, pascal_case_proc) 75 | end 76 | 77 | # Ensure that quotes inside a passed string will continue to be passed 78 | # 79 | # @return [String] the string with quotes escaped 80 | def escape_quotes(text) 81 | text.gsub("'", "''") 82 | end 83 | 84 | # Ensure that all keys in a hash are symbols, not strings. 85 | # 86 | # @return [Hash] a hash whose keys have been converted to symbols. 87 | def symbolize_hash_keys(object) 88 | symbolize_proc = proc(&:to_sym) 89 | apply_key_mutator(object, symbolize_proc) 90 | end 91 | 92 | def apply_key_mutator(object, proc) 93 | return object.map { |item| apply_key_mutator(item, proc) } if object.is_a?(Array) 94 | return object unless object.is_a?(Hash) 95 | 96 | modified_hash = {} 97 | object.each do |key, value| 98 | modified_hash[proc.call(key)] = apply_key_mutator(value, proc) 99 | end 100 | modified_hash 101 | end 102 | 103 | private_class_method :apply_key_mutator 104 | 105 | # Convert a ruby value into a string to be passed along to PowerShell for interpolation in a command 106 | # Handles: 107 | # - Strings 108 | # - Numbers 109 | # - Booleans 110 | # - Symbols 111 | # - Arrays 112 | # - Hashes 113 | # 114 | # @return [String] representation of the value for interpolation 115 | def format_powershell_value(object) 116 | if %i[true false].include?(object) || %w[trueclass falseclass].include?(object.class.name.downcase) 117 | "$#{object}" 118 | elsif object.instance_of?(Symbol) || object.class.ancestors.include?(Numeric) 119 | object.to_s 120 | elsif object.instance_of?(String) 121 | "'#{escape_quotes(object)}'" 122 | elsif object.instance_of?(Array) 123 | "@(#{object.collect { |item| format_powershell_value(item) }.join(', ')})" 124 | elsif object.instance_of?(Hash) 125 | "@{#{object.collect { |k, v| "#{format_powershell_value(k)} = #{format_powershell_value(v)}" }.join('; ')}}" 126 | else 127 | raise "unsupported type #{object.class} of value '#{object}'" 128 | end 129 | end 130 | 131 | # Return the representative string of a PowerShell hash for a custom object property to be used in selecting or filtering. 132 | # The script block for the expression must be passed as the string you want interpolated into the hash; this method does 133 | # not do any of the additional work of interpolation for you as the type sits inside a code block inside a hash. 134 | # 135 | # @return [String] representation of a PowerShell hash with the keys 'Name' and 'Expression' 136 | def custom_powershell_property(name, expression) 137 | "@{Name = '#{name}'; Expression = {#{expression}}}" 138 | end 139 | end 140 | end 141 | 142 | # POWERSHELL_MODULE_UPGRADE_MSG ||= <<-UPGRADE 143 | # Currently, the PowerShell module has reduced v1 functionality on this machine 144 | # due to the following condition: 145 | 146 | # - PowerShell v2 with .NET Framework 2.0 147 | 148 | # PowerShell v2 works with both .NET Framework 2.0 and .NET Framework 3.5. 149 | # To be able to use the enhancements, we require .NET Framework 3.5. 150 | # Typically you will only see this on a base Windows Server 2008 (and R2) 151 | # install. 152 | 153 | # To enable these improvements, it is suggested to ensure you have .NET Framework 154 | # 3.5 installed. 155 | # UPGRADE 156 | 157 | # TODO: Generalize this upgrade message to be independent of Puppet 158 | # def upgrade_message 159 | # # Puppet.warning POWERSHELL_MODULE_UPGRADE_MSG if !@upgrade_warning_issued 160 | # @upgrade_warning_issued = true 161 | # end 162 | -------------------------------------------------------------------------------- /lib/pwsh/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pwsh 4 | # The version of the ruby-pwsh gem 5 | VERSION = '2.0.0' 6 | end 7 | -------------------------------------------------------------------------------- /lib/pwsh/windows_powershell.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.join(File.dirname(__FILE__), 'util') 4 | 5 | module Pwsh 6 | # Returns information about the available versions of Windows PowerShell on the node, if any. 7 | class WindowsPowerShell 8 | # Return whether or not the latest version of PowerShell available on the machine 9 | # is compatible with the implementation of the Manager. 10 | def self.compatible_version? 11 | # If this method isn't defined, we're not on Windows! 12 | return false if defined?(Pwsh::WindowsPowerShell.version).nil? 13 | 14 | powershell_version = defined?(Pwsh::WindowsPowerShell.version) ? Pwsh::WindowsPowerShell.version : nil 15 | 16 | # If we get nil, something's gone wrong and we're not compatible. 17 | return false if powershell_version.nil? 18 | 19 | # PowerShell v1 - definitely not good to go. Really the whole library 20 | # may not even work but I digress 21 | return false if Gem::Version.new(powershell_version) < Gem::Version.new(2) 22 | 23 | # PowerShell v3+, we are good to go b/c .NET 4+ 24 | # https://msdn.microsoft.com/en-us/powershell/scripting/setup/windows-powershell-system-requirements 25 | # Look at Microsoft .NET Framwork Requirements section. 26 | return true if Gem::Version.new(powershell_version) >= Gem::Version.new(3) 27 | 28 | # If we are using PowerShell v2, we need to see what the latest 29 | # version of .NET is that we have 30 | # https://msdn.microsoft.com/en-us/library/hh925568.aspx 31 | value = false 32 | if Pwsh::Util.on_windows? 33 | require 'win32/registry' 34 | begin 35 | # At this point in the check, PowerShell is using .NET Framework 36 | # 2.x family, so we only need to verify v3.5 key exists. 37 | # If we were verifying all compatible types we would look for 38 | # any of these keys: v3.5, v4.0, v4 39 | Win32::Registry::HKEY_LOCAL_MACHINE.open('SOFTWARE\Microsoft\NET Framework Setup\NDP\v3.5', Win32::Registry::KEY_READ | 0x100) do 40 | value = true 41 | end 42 | rescue Win32::Registry::Error 43 | value = false 44 | end 45 | end 46 | 47 | value 48 | end 49 | end 50 | end 51 | 52 | if Pwsh::Util.on_windows? 53 | require 'win32/registry' 54 | module Pwsh 55 | # Returns information about the available versions of Windows PowerShell on the node, if any. 56 | class WindowsPowerShell 57 | # Shorthand constant to reference the registry key access type 58 | ACCESS_TYPE = Win32::Registry::KEY_READ | 0x100 59 | # Shorthand constant to reference the local machine hive 60 | HKLM = Win32::Registry::HKEY_LOCAL_MACHINE 61 | # The path to the original version of the Windows PowerShell Engine's data in registry 62 | PS_ONE_REG_PATH = 'SOFTWARE\Microsoft\PowerShell\1\PowerShellEngine' 63 | # The path to the newer version of the Windows PowerShell Engine's data in registry 64 | PS_THREE_REG_PATH = 'SOFTWARE\Microsoft\PowerShell\3\PowerShellEngine' 65 | # The name of the registry key for looking up the latest version of Windows PowerShell for a given engine. 66 | REG_KEY = 'PowerShellVersion' 67 | 68 | # Returns the latest available version of Windows PowerShell on the machine 69 | # 70 | # @return [String] a version string representing the latest version of Windows PowerShell available 71 | def self.version 72 | powershell_three_version || powershell_one_version 73 | end 74 | 75 | # Returns the latest available version of Windows PowerShell using the older 76 | # engine as determined by checking the registry. 77 | # 78 | # @return [String] a version string representing the latest version of Windows PowerShell using the original engine 79 | def self.powershell_one_version 80 | version = nil 81 | begin 82 | HKLM.open(PS_ONE_REG_PATH, ACCESS_TYPE) do |reg| 83 | version = reg[REG_KEY] 84 | end 85 | rescue StandardError 86 | version = nil 87 | end 88 | version 89 | end 90 | 91 | # Returns the latest available version of Windows PowerShell as determined by 92 | # checking the registry. 93 | # 94 | # @return [String] a version string representing the latest version of Windows PowerShell using the newer engine 95 | def self.powershell_three_version 96 | version = nil 97 | begin 98 | HKLM.open(PS_THREE_REG_PATH, ACCESS_TYPE) do |reg| 99 | version = reg[REG_KEY] 100 | end 101 | rescue StandardError 102 | version = nil 103 | end 104 | version 105 | end 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/ruby-pwsh.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'pwsh' 4 | -------------------------------------------------------------------------------- /lib/templates/RubyPwsh.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.ObjectModel; 4 | using System.Globalization; 5 | using System.IO; 6 | using System.Management.Automation; 7 | using System.Management.Automation.Host; 8 | using System.Security; 9 | using System.Text; 10 | using System.Threading; 11 | 12 | namespace RubyPwsh 13 | { 14 | public class RubyPwshPSHostRawUserInterface : PSHostRawUserInterface 15 | { 16 | public RubyPwshPSHostRawUserInterface() 17 | { 18 | buffersize = new Size(120, 120); 19 | backgroundcolor = ConsoleColor.Black; 20 | foregroundcolor = ConsoleColor.White; 21 | cursorposition = new Coordinates(0, 0); 22 | cursorsize = 1; 23 | } 24 | 25 | private ConsoleColor backgroundcolor; 26 | public override ConsoleColor BackgroundColor 27 | { 28 | get { return backgroundcolor; } 29 | set { backgroundcolor = value; } 30 | } 31 | 32 | private Size buffersize; 33 | public override Size BufferSize 34 | { 35 | get { return buffersize; } 36 | set { buffersize = value; } 37 | } 38 | 39 | private Coordinates cursorposition; 40 | public override Coordinates CursorPosition 41 | { 42 | get { return cursorposition; } 43 | set { cursorposition = value; } 44 | } 45 | 46 | private int cursorsize; 47 | public override int CursorSize 48 | { 49 | get { return cursorsize; } 50 | set { cursorsize = value; } 51 | } 52 | 53 | private ConsoleColor foregroundcolor; 54 | public override ConsoleColor ForegroundColor 55 | { 56 | get { return foregroundcolor; } 57 | set { foregroundcolor = value; } 58 | } 59 | 60 | private Coordinates windowposition; 61 | public override Coordinates WindowPosition 62 | { 63 | get { return windowposition; } 64 | set { windowposition = value; } 65 | } 66 | 67 | private Size windowsize; 68 | public override Size WindowSize 69 | { 70 | get { return windowsize; } 71 | set { windowsize = value; } 72 | } 73 | 74 | private string windowtitle; 75 | public override string WindowTitle 76 | { 77 | get { return windowtitle; } 78 | set { windowtitle = value; } 79 | } 80 | 81 | public override bool KeyAvailable 82 | { 83 | get { return false; } 84 | } 85 | 86 | public override Size MaxPhysicalWindowSize 87 | { 88 | get { return new Size(165, 66); } 89 | } 90 | 91 | public override Size MaxWindowSize 92 | { 93 | get { return new Size(165, 66); } 94 | } 95 | 96 | public override void FlushInputBuffer() 97 | { 98 | throw new NotImplementedException(); 99 | } 100 | 101 | public override BufferCell[,] GetBufferContents(Rectangle rectangle) 102 | { 103 | throw new NotImplementedException(); 104 | } 105 | 106 | public override KeyInfo ReadKey(ReadKeyOptions options) 107 | { 108 | throw new NotImplementedException(); 109 | } 110 | 111 | public override void ScrollBufferContents(Rectangle source, Coordinates destination, Rectangle clip, BufferCell fill) 112 | { 113 | throw new NotImplementedException(); 114 | } 115 | 116 | public override void SetBufferContents(Rectangle rectangle, BufferCell fill) 117 | { 118 | throw new NotImplementedException(); 119 | } 120 | 121 | public override void SetBufferContents(Coordinates origin, BufferCell[,] contents) 122 | { 123 | throw new NotImplementedException(); 124 | } 125 | } 126 | 127 | public class RubyPwshPSHostUserInterface : PSHostUserInterface 128 | { 129 | private RubyPwshPSHostRawUserInterface _rawui; 130 | private StringBuilder _sb; 131 | private StringWriter _errWriter; 132 | private StringWriter _outWriter; 133 | 134 | public RubyPwshPSHostUserInterface() 135 | { 136 | _sb = new StringBuilder(); 137 | _errWriter = new StringWriter(new StringBuilder()); 138 | // NOTE: StringWriter / StringBuilder are not technically thread-safe 139 | // but PowerShell Write-XXX cmdlets and System.Console.Out.WriteXXX 140 | // should not be executed concurrently within PowerShell, so should be safe 141 | _outWriter = new StringWriter(_sb); 142 | } 143 | 144 | public override PSHostRawUserInterface RawUI 145 | { 146 | get 147 | { 148 | if ( _rawui == null){ 149 | _rawui = new RubyPwshPSHostRawUserInterface(); 150 | } 151 | return _rawui; 152 | } 153 | } 154 | 155 | public void ResetConsoleStreams() 156 | { 157 | System.Console.SetError(_errWriter); 158 | System.Console.SetOut(_outWriter); 159 | } 160 | 161 | public override void Write(ConsoleColor foregroundColor, ConsoleColor backgroundColor, string value) 162 | { 163 | _sb.Append(value); 164 | } 165 | 166 | public override void Write(string value) 167 | { 168 | _sb.Append(value); 169 | } 170 | 171 | public override void WriteDebugLine(string message) 172 | { 173 | _sb.AppendLine("DEBUG: " + message); 174 | } 175 | 176 | public override void WriteErrorLine(string value) 177 | { 178 | _sb.AppendLine(value); 179 | } 180 | 181 | public override void WriteLine(string value) 182 | { 183 | _sb.AppendLine(value); 184 | } 185 | 186 | public override void WriteVerboseLine(string message) 187 | { 188 | _sb.AppendLine("VERBOSE: " + message); 189 | } 190 | 191 | public override void WriteWarningLine(string message) 192 | { 193 | _sb.AppendLine("WARNING: " + message); 194 | } 195 | 196 | public override void WriteProgress(long sourceId, ProgressRecord record) 197 | { 198 | } 199 | 200 | public string Output 201 | { 202 | get 203 | { 204 | _outWriter.Flush(); 205 | string text = _outWriter.GetStringBuilder().ToString(); 206 | _outWriter.GetStringBuilder().Length = 0; // Only .NET 4+ has .Clear() 207 | return text; 208 | } 209 | } 210 | 211 | public string StdErr 212 | { 213 | get 214 | { 215 | _errWriter.Flush(); 216 | string text = _errWriter.GetStringBuilder().ToString(); 217 | _errWriter.GetStringBuilder().Length = 0; // Only .NET 4+ has .Clear() 218 | return text; 219 | } 220 | } 221 | 222 | public override Dictionary Prompt(string caption, string message, Collection descriptions) 223 | { 224 | throw new NotImplementedException(); 225 | } 226 | 227 | public override int PromptForChoice(string caption, string message, Collection choices, int defaultChoice) 228 | { 229 | throw new NotImplementedException(); 230 | } 231 | 232 | public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName) 233 | { 234 | throw new NotImplementedException(); 235 | } 236 | 237 | public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName, PSCredentialTypes allowedCredentialTypes, PSCredentialUIOptions options) 238 | { 239 | throw new NotImplementedException(); 240 | } 241 | 242 | public override string ReadLine() 243 | { 244 | throw new NotImplementedException(); 245 | } 246 | 247 | public override SecureString ReadLineAsSecureString() 248 | { 249 | throw new NotImplementedException(); 250 | } 251 | } 252 | 253 | public class RubyPwshPSHost : PSHost 254 | { 255 | private Guid _hostId = Guid.NewGuid(); 256 | private bool shouldExit; 257 | private int exitCode; 258 | 259 | private readonly RubyPwshPSHostUserInterface _ui = new RubyPwshPSHostUserInterface(); 260 | 261 | public RubyPwshPSHost () {} 262 | 263 | public bool ShouldExit { get { return this.shouldExit; } } 264 | public int ExitCode { get { return this.exitCode; } } 265 | public void ResetExitStatus() 266 | { 267 | this.exitCode = 0; 268 | this.shouldExit = false; 269 | } 270 | public void ResetConsoleStreams() 271 | { 272 | _ui.ResetConsoleStreams(); 273 | } 274 | 275 | public override Guid InstanceId { get { return _hostId; } } 276 | public override string Name { get { return "RubyPwshPSHost"; } } 277 | public override Version Version { get { return new Version(1, 1); } } 278 | public override PSHostUserInterface UI 279 | { 280 | get { return _ui; } 281 | } 282 | public override CultureInfo CurrentCulture 283 | { 284 | get { return Thread.CurrentThread.CurrentCulture; } 285 | } 286 | public override CultureInfo CurrentUICulture 287 | { 288 | get { return Thread.CurrentThread.CurrentUICulture; } 289 | } 290 | 291 | public override void EnterNestedPrompt() { throw new NotImplementedException(); } 292 | public override void ExitNestedPrompt() { throw new NotImplementedException(); } 293 | public override void NotifyBeginApplication() { return; } 294 | public override void NotifyEndApplication() { return; } 295 | 296 | public override void SetShouldExit(int exitCode) 297 | { 298 | this.shouldExit = true; 299 | this.exitCode = exitCode; 300 | } 301 | } 302 | } -------------------------------------------------------------------------------- /lib/templates/init.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param ( 3 | [Parameter(Mandatory = $true)] 4 | [String] 5 | $NamedPipeName, 6 | 7 | [Parameter(Mandatory = $false)] 8 | [Switch] 9 | $EmitDebugOutput = $False, 10 | 11 | [Parameter(Mandatory = $false)] 12 | [System.Text.Encoding] 13 | $Encoding = [System.Text.Encoding]::UTF8 14 | ) 15 | 16 | $script:EmitDebugOutput = $EmitDebugOutput 17 | # Necessary for [System.Console]::Error.WriteLine to roundtrip with UTF-8 18 | # Need to ensure we ignore encoding from other places and are consistent internally 19 | [System.Console]::OutputEncoding = $Encoding 20 | 21 | $TemplateFolderPath = Split-Path -Parent -Path $MyInvocation.MyCommand.Path 22 | $hostSource = Get-Content -Path "$TemplateFolderPath/RubyPwsh.cs" -Raw 23 | 24 | # Load the Custom PowerShell Host CSharp code 25 | Add-Type -TypeDefinition $hostSource -Language CSharp 26 | 27 | # Cache the current directory as the working directory for the Dynamic PowerShell session 28 | $global:DefaultWorkingDirectory = (Get-Location -PSProvider FileSystem).Path 29 | 30 | # Cache initial Environment Variables and values prior to any munging: 31 | $global:CachedEnvironmentVariables = Get-ChildItem -Path Env:\ 32 | 33 | #this is a string so we can import into our dynamic PS instance 34 | $global:ourFunctions = @' 35 | function Reset-ProcessEnvironmentVariables { 36 | param($CachedEnvironmentVariables) 37 | 38 | # When Protected Event Logging and PowerShell Script Block logging are enabled together 39 | # the SystemRoot environment variable is a requirement. If it is removed as part of this purge 40 | # it causes the PowerShell process to crash, therefore breaking the pipe between Ruby and the 41 | # remote PowerShell session. 42 | # The least descructive way to avoid this is to filter out SystemRoot when pulling our current list 43 | # of environment variables. Then we can continue safely with the removal. 44 | $CurrentEnvironmentVariables = Get-ChildItem -Path Env:\* | 45 | Where-Object {$_.Name -ne "SystemRoot"} 46 | 47 | # Delete existing environment variables 48 | $CurrentEnvironmentVariables | 49 | ForEach-Object -Process { Remove-Item -Path "ENV:\$($_.Name)" -ErrorAction SilentlyContinue -WarningAction SilentlyContinue -Recurse } 50 | 51 | # Re-add the cached environment variables 52 | $CachedEnvironmentVariables | 53 | ForEach-Object -Process { Set-Item -Path "Env:\$($_.Name)" -Value $_.Value } 54 | } 55 | 56 | function Reset-ProcessPowerShellVariables { 57 | param($psVariables) 58 | 59 | $psVariables | 60 | ForEach-Object -Process { 61 | $tempVar = $_ 62 | if (-not(Get-Variable -Name $_.Name -ErrorAction SilentlyContinue)) { 63 | New-Variable -Name $_.Name -Value $_.Value -Description $_.Description -Option $_.Options -Visibility $_.Visibility 64 | } 65 | } 66 | } 67 | '@ 68 | 69 | function Invoke-PowerShellUserCode { 70 | [CmdletBinding()] 71 | param( 72 | [String] 73 | $Code, 74 | 75 | [Int] 76 | $TimeoutMilliseconds, 77 | 78 | [String] 79 | $WorkingDirectory, 80 | 81 | [Hashtable] 82 | $AdditionalEnvironmentVariables 83 | ) 84 | 85 | # Instantiate the PowerShell Host and a new runspace to use if one is not already defined. 86 | if ($global:runspace -eq $null){ 87 | # CreateDefault2 requires PS3 88 | # Setup Initial Session State - can be modified later, defaults to only core PowerShell 89 | # commands loaded/available. Everything else will dynamically load when needed. 90 | if ([System.Management.Automation.Runspaces.InitialSessionState].GetMethod('CreateDefault2')){ 91 | $sessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault2() 92 | } else { 93 | $sessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() 94 | } 95 | 96 | $global:RubyPwshPSHost = New-Object RubyPwsh.RubyPwshPSHost 97 | $global:runspace = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace($global:RubyPwshPSHost, $sessionState) 98 | $global:runspace.Open() 99 | } 100 | 101 | try { 102 | # Reset the PowerShell handle, exit status, and streams. 103 | $ps = $null 104 | $global:RubyPwshPSHost.ResetExitStatus() 105 | $global:RubyPwshPSHost.ResetConsoleStreams() 106 | 107 | # This resets the variables from prior runs, clearing them from memory. 108 | if ($PSVersionTable.PSVersion -ge [Version]'3.0') { 109 | $global:runspace.ResetRunspaceState() 110 | } 111 | 112 | # Create a new instance of the PowerShell handle and drop into our reused runspace. 113 | $ps = [System.Management.Automation.PowerShell]::Create() 114 | $ps.Runspace = $global:runspace 115 | 116 | # Preload our own functions; this could be moved into the array of startup scripts in the 117 | # InitialSessionState, once implemented. 118 | [Void]$ps.AddScript($global:ourFunctions) 119 | $ps.Invoke() 120 | 121 | # Set the working directory for the runspace; if not specified, use default; If it doesn't 122 | # exist, terminate the execution and report the error back. 123 | if ([string]::IsNullOrEmpty($WorkingDirectory)) { 124 | [Void]$ps.Runspace.SessionStateProxy.Path.SetLocation($global:DefaultWorkingDirectory) 125 | } else { 126 | if (-not (Test-Path -Path $WorkingDirectory)) { Throw "Working directory `"$WorkingDirectory`" does not exist" } 127 | [Void]$ps.Runspace.SessionStateProxy.Path.SetLocation($WorkingDirectory) 128 | } 129 | 130 | # Reset the environment variables to those cached at the instantiation of the PowerShell Host. 131 | $ps.Commands.Clear() 132 | [Void]$ps.AddCommand('Reset-ProcessEnvironmentVariables').AddParameter('CachedEnvironmentVariables', $global:CachedEnvironmentVariables) 133 | $ps.Invoke() 134 | 135 | # This code is the companion to the code at L403-405 and clears variables from prior runs. 136 | # Because ResetRunspaceState does not work on v2 and earlier, it must be called here, after 137 | # a new handle to PowerShell is created in prior steps. 138 | if ($PSVersionTable.PSVersion -le [Version]'2.0'){ 139 | if (-not $global:psVariables){ 140 | $global:psVariables = $ps.AddScript('Get-Variable').Invoke() 141 | } 142 | 143 | $ps.Commands.Clear() 144 | [void]$ps.AddScript('Get-Variable -Scope Global | Remove-Variable -Force -ErrorAction SilentlyContinue -WarningAction SilentlyContinue') 145 | $ps.Invoke() 146 | 147 | $ps.Commands.Clear() 148 | [void]$ps.AddCommand('Reset-ProcessPowerShellVariables').AddParameter('psVariables', $global:psVariables) 149 | $ps.Invoke() 150 | } 151 | 152 | # Set any provided environment variables 153 | if ($AdditionalEnvironmentVariables -ne $null) { 154 | $AdditionalEnvironmentVariables.GetEnumerator() | 155 | ForEach-Object -Process { Set-Item -Path "Env:\$($_.Name)" -Value "$($_.Value)" } 156 | } 157 | 158 | # We clear the commands before each new command to avoid command pollution This does not need 159 | # to be a single command, it works the same if you pass a string with multiple lines of 160 | # PowerShell code. The user supplies a string and this gives it to the Host to execute. 161 | $ps.Commands.Clear() 162 | [Void]$ps.AddScript($Code) 163 | 164 | # Out-Default and MergeMyResults takes all output streams and writes it to the PowerShell Host 165 | # we create this needs to be the last thing executed. 166 | [void]$ps.AddCommand("out-default") 167 | 168 | # if the call operator & established an exit code, exit with it; if this is NOT included, exit 169 | # codes for scripts will not work; anything that does not throw will exit 0. 170 | [Void]$ps.AddScript('if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }') 171 | 172 | # This is the code that ensures the output from the Host is interleaved; this ensures 173 | # everything written to the streams in the Host is returned. 174 | if ($PSVersionTable.PSVersion -le [Version]'2.0') { 175 | $ps.Commands.Commands[0].MergeMyResults([System.Management.Automation.Runspaces.PipelineResultTypes]::Error, 176 | [System.Management.Automation.Runspaces.PipelineResultTypes]::Output) 177 | } else { 178 | $ps.Commands.Commands[0].MergeMyResults([System.Management.Automation.Runspaces.PipelineResultTypes]::All, 179 | [System.Management.Automation.Runspaces.PipelineResultTypes]::Output) 180 | } 181 | 182 | # The asynchronous execution enables a user to set a timeout for the execution of their 183 | # provided code; this keeps the process from hanging eternally until an external caller 184 | # times out or the user kills the process. 185 | $asyncResult = $ps.BeginInvoke() 186 | 187 | if (-not $asyncResult.AsyncWaitHandle.WaitOne($TimeoutMilliseconds)) { 188 | # forcibly terminate execution of pipeline 189 | $ps.Stop() 190 | throw "Catastrophic failure: PowerShell module timeout ($TimeoutMilliseconds ms) exceeded while executing" 191 | } 192 | 193 | try { 194 | $ps.EndInvoke($asyncResult) 195 | } catch [System.Management.Automation.IncompleteParseException] { 196 | # This surfaces an error for when syntactically incorrect code is passed 197 | # https://msdn.microsoft.com/en-us/library/system.management.automation.incompleteparseexception%28v=vs.85%29.aspx?f=255&MSPPError=-2147217396 198 | throw $_.Exception.Message 199 | } catch { 200 | # This catches any execution errors from the passed code, drops out of execution here and 201 | # throws the most specific exception available. 202 | if ($null -ne $_.Exception.InnerException) { 203 | throw $_.Exception.InnerException 204 | } else { 205 | throw $_.Exception 206 | } 207 | } 208 | 209 | [RubyPwsh.RubyPwshPSHostUserInterface]$ui = $global:RubyPwshPSHost.UI 210 | return @{ 211 | exitcode = $global:RubyPwshPSHost.Exitcode; 212 | stdout = $ui.Output; 213 | stderr = $ui.StdErr; 214 | errormessage = $null; 215 | } 216 | } 217 | catch { 218 | # if an execution or parse error is surfaced, dispose of the runspace and clear the global 219 | # runspace; it will be rebuilt on the next execution. 220 | try { 221 | if ($global:runspace) { $global:runspace.Dispose() } 222 | } finally { 223 | $global:runspace = $null 224 | } 225 | if (($global:RubyPwshPSHost -ne $null) -and $global:RubyPwshPSHost.ExitCode) { 226 | $ec = $global:RubyPwshPSHost.ExitCode 227 | } else { 228 | # This is technically not true at this point as we do not 229 | # know what exitcode we should return as an unexpected exception 230 | # happened and the user did not set an exitcode. Our best guess 231 | # is to return 1 so that we ensure ruby treats this run as an error. 232 | $ec = 1 233 | } 234 | 235 | # Format the exception message; this could be improved to surface more functional messaging 236 | # to the user; right now it dumps the exception message as a string. 237 | if ($_.Exception.ErrorRecord.InvocationInfo -ne $null) { 238 | $output = $_.Exception.Message + "`n`r" + $_.Exception.ErrorRecord.InvocationInfo.PositionMessage 239 | } else { 240 | $output = $_.Exception.Message | Out-String 241 | } 242 | 243 | # make an attempt to read Output / StdErr as it may contain partial output / info about failures 244 | # The PowerShell Host could be entirely dead and broken at this stage. 245 | try { 246 | $out = $global:RubyPwshPSHost.UI.Output 247 | } catch { 248 | $out = $null 249 | } 250 | try { 251 | $err = $global:RubyPwshPSHost.UI.StdErr 252 | } catch { 253 | $err = $null 254 | } 255 | 256 | # Make sure we return the expected data structure for what happened. 257 | return @{ 258 | exitcode = $ec; 259 | stdout = $out; 260 | stderr = $err; 261 | errormessage = $output; 262 | } 263 | } finally { 264 | # Dispose of the shell regardless of success/failure. This clears state and memory both. 265 | # To enable conditional keeping of state, this would need an additional condition. 266 | if ($ps -ne $null) { [Void]$ps.Dispose() } 267 | } 268 | } 269 | 270 | function Write-SystemDebugMessage { 271 | [CmdletBinding()] 272 | param( 273 | [Parameter(Mandatory = $true)] 274 | [String] 275 | $Message 276 | ) 277 | 278 | if ($script:EmitDebugOutput -or ($DebugPreference -ne 'SilentlyContinue')) { 279 | # This writes to the console, not to the PowerShell streams. 280 | # This is captured for communications with the pipe server. 281 | [System.Diagnostics.Debug]::WriteLine($Message) 282 | } 283 | } 284 | 285 | # This is not called anywhere else in the project. It may be dead code for 286 | # event handling used in an earlier implementation. Or magic? 287 | function Signal-Event { 288 | [CmdletBinding()] 289 | param( 290 | [String] 291 | $EventName 292 | ) 293 | 294 | $event = [System.Threading.EventWaitHandle]::OpenExisting($EventName) 295 | 296 | [Void]$event.Set() 297 | [Void]$event.Close() 298 | if ($PSVersionTable.CLRVersion.Major -ge 3) { 299 | [Void]$event.Dispose() 300 | } 301 | 302 | Write-SystemDebugMessage -Message "Signaled event $EventName" 303 | } 304 | 305 | function ConvertTo-LittleEndianBytes { 306 | [CmdletBinding()] 307 | param ( 308 | [Parameter(Mandatory = $true)] 309 | [Int32] 310 | $Value 311 | ) 312 | 313 | $bytes = [BitConverter]::GetBytes($Value) 314 | if (-not [BitConverter]::IsLittleEndian) { [Array]::Reverse($bytes) } 315 | 316 | return $bytes 317 | } 318 | 319 | function ConvertTo-ByteArray { 320 | [CmdletBinding()] 321 | param ( 322 | [Parameter(Mandatory = $true)] 323 | [Hashtable] 324 | $Hash, 325 | 326 | [Parameter(Mandatory = $true)] 327 | [System.Text.Encoding] 328 | $Encoding 329 | ) 330 | 331 | # Initialize empty byte array that can be appended to 332 | $result = [Byte[]]@() 333 | # and add length / name / length / value from Hashtable 334 | $Hash.GetEnumerator() | 335 | ForEach-Object -Process { 336 | $name = $Encoding.GetBytes($_.Name) 337 | $result += (ConvertTo-LittleEndianBytes $name.Length) + $name 338 | 339 | $value = @() 340 | if ($_.Value -ne $null) { $value = $Encoding.GetBytes($_.Value.ToString()) } 341 | $result += (ConvertTo-LittleEndianBytes $value.Length) + $value 342 | } 343 | 344 | return $result 345 | } 346 | 347 | function Write-StreamResponse { 348 | [CmdletBinding()] 349 | param ( 350 | [Parameter(Mandatory = $true)] 351 | [System.IO.Pipes.PipeStream] 352 | $Stream, 353 | 354 | [Parameter(Mandatory = $true)] 355 | [Byte[]] 356 | $Bytes 357 | ) 358 | 359 | $length = ConvertTo-LittleEndianBytes -Value $Bytes.Length 360 | $Stream.Write($length, 0, 4) 361 | $Stream.Flush() 362 | 363 | Write-SystemDebugMessage -Message "Wrote Int32 $($bytes.Length) as Byte[] $length to Stream" 364 | 365 | $Stream.Write($bytes, 0, $bytes.Length) 366 | $Stream.Flush() 367 | 368 | Write-SystemDebugMessage -Message "Wrote $($bytes.Length) bytes of data to Stream" 369 | } 370 | 371 | function Read-Int32FromStream { 372 | [CmdletBinding()] 373 | param ( 374 | [Parameter(Mandatory = $true)] 375 | [System.IO.Pipes.PipeStream] 376 | $Stream 377 | ) 378 | 379 | $length = New-Object Byte[] 4 380 | # Read blocks until all 4 bytes available 381 | $Stream.Read($length, 0, 4) | Out-Null 382 | # value is sent in Little Endian, but if the CPU is not, in-place reverse the array 383 | if (-not [BitConverter]::IsLittleEndian) { [Array]::Reverse($length) } 384 | $value = [BitConverter]::ToInt32($length, 0) 385 | 386 | Write-SystemDebugMessage -Message "Read Byte[] $length from stream as Int32 $value" 387 | 388 | return $value 389 | } 390 | 391 | # Message format is: 392 | # 1 byte - command identifier 393 | # 0 - Exit 394 | # 1 - Execute 395 | # -1 - Exit - automatically returned when ReadByte encounters a closed pipe 396 | # [optional] 4 bytes - Little Endian encoded 32-bit code block length for execute 397 | # Intel CPUs are little endian, hence the .NET Framework typically is 398 | # [optional] variable length - code block 399 | function ConvertTo-PipeCommand { 400 | [CmdletBinding()] 401 | param ( 402 | [Parameter(Mandatory = $true)] 403 | [System.IO.Pipes.PipeStream] 404 | $Stream, 405 | 406 | [Parameter(Mandatory = $true)] 407 | [System.Text.Encoding] 408 | $Encoding, 409 | 410 | [Parameter(Mandatory = $false)] 411 | [Int32] 412 | $BufferChunkSize = 4096 413 | ) 414 | 415 | # command identifier is a single value - ReadByte blocks until byte is ready / pipe closes 416 | $command = $Stream.ReadByte() 417 | 418 | Write-SystemDebugMessage -Message "Command id $command read from pipe" 419 | 420 | switch ($command) { 421 | # Exit 422 | # ReadByte returns a -1 when the pipe is closed on the other end 423 | { @(0, -1) -contains $_ } { return @{ Command = 'Exit' }} 424 | 425 | # Execute 426 | 1 { $parsed = @{ Command = 'Execute' } } 427 | 428 | default { throw "Catastrophic failure: Unexpected Command $command received" } 429 | } 430 | 431 | # read size of incoming byte buffer 432 | $parsed.Length = Read-Int32FromStream -Stream $Stream 433 | Write-SystemDebugMessage -Message "Expecting $($parsed.Length) raw bytes of $($Encoding.EncodingName) characters" 434 | 435 | # Read blocks until all bytes are read or EOF / broken pipe hit - tested with 5MB and worked fine 436 | $parsed.RawData = New-Object Byte[] $parsed.Length 437 | $readBytes = 0 438 | do { 439 | $attempt = $attempt + 1 440 | # This will block if there's not enough data in the pipe 441 | $read = $Stream.Read($parsed.RawData, $readBytes, $parsed.Length - $readBytes) 442 | if ($read -eq 0) { 443 | throw "Catastrophic failure: Expected $($parsed.Length - $readBytesh) raw bytes, but the pipe reached an end of stream" 444 | } 445 | 446 | $readBytes = $readBytes + $read 447 | Write-SystemDebugMessage -Message "Read $($read) bytes from the pipe" 448 | } while ($readBytes -lt $parsed.Length) 449 | 450 | if ($readBytes -lt $parsed.Length) { 451 | throw "Catastrophic failure: Expected $($parsed.Length) raw bytes, only received $readBytes" 452 | } 453 | 454 | # turn the raw bytes into the expected encoded string! 455 | $parsed.Code = $Encoding.GetString($parsed.RawData) 456 | 457 | return $parsed 458 | } 459 | 460 | function Start-PipeServer { 461 | [CmdletBinding()] 462 | param ( 463 | [Parameter(Mandatory = $true)] 464 | [String] 465 | $CommandChannelPipeName, 466 | 467 | [Parameter(Mandatory = $true)] 468 | [System.Text.Encoding] 469 | $Encoding 470 | ) 471 | 472 | Add-Type -AssemblyName System.Core 473 | 474 | # this does not require versioning in the payload as client / server are tightly coupled 475 | $server = New-Object System.IO.Pipes.NamedPipeServerStream($CommandChannelPipeName, 476 | [System.IO.Pipes.PipeDirection]::InOut) 477 | 478 | try { 479 | # block until Ruby process connects 480 | $server.WaitForConnection() 481 | 482 | Write-SystemDebugMessage -Message "Incoming Connection to $CommandChannelPipeName Received - Expecting Strings as $($Encoding.EncodingName)" 483 | 484 | # Infinite Loop to process commands until EXIT received 485 | $running = $true 486 | while ($running) { 487 | # throws if an unxpected command id is read from pipe 488 | $response = ConvertTo-PipeCommand -Stream $server -Encoding $Encoding 489 | 490 | Write-SystemDebugMessage -Message "Received $($response.Command) command from client" 491 | 492 | switch ($response.Command) { 493 | 'Execute' { 494 | Write-SystemDebugMessage -Message "[Execute] Invoking user code:`n`n $($response.Code)" 495 | 496 | # assuming that the Ruby code always calls Invoked-PowerShellUserCode, 497 | # result should already be returned as a hash 498 | $result = Invoke-Expression $response.Code 499 | 500 | $bytes = ConvertTo-ByteArray -Hash $result -Encoding $Encoding 501 | 502 | Write-StreamResponse -Stream $server -Bytes $bytes 503 | } 504 | 'Exit' { $running = $false } 505 | } 506 | } 507 | } catch [Exception] { 508 | Write-SystemDebugMessage -Message "PowerShell Pipe Server Failed!`n`n$_" 509 | throw 510 | } finally { 511 | if ($global:runspace -ne $null) { 512 | $global:runspace.Dispose() 513 | Write-SystemDebugMessage -Message "PowerShell Runspace Disposed`n`n$_" 514 | } 515 | if ($server -ne $null) { 516 | $server.Dispose() 517 | Write-SystemDebugMessage -Message "NamedPipeServerStream Disposed`n`n$_" 518 | } 519 | } 520 | } 521 | 522 | # Start the pipe server and wait for it to close. 523 | Start-PipeServer -CommandChannelPipeName $NamedPipeName -Encoding $Encoding 524 | Write-SystemDebugMessage -Message "Start-PipeServer Finished`n`n$_" 525 | -------------------------------------------------------------------------------- /metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "puppetlabs-pwshlib", 3 | "version": "2.0.0", 4 | "author": "puppetlabs", 5 | "summary": "Provide library code for interoperating with PowerShell.", 6 | "license": "MIT", 7 | "source": "https://github.com/puppetlabs/ruby-pwsh", 8 | "project_page": "https://github.com/puppetlabs/ruby-pwsh/blob/main/pwshlib.md", 9 | "issues_url": "https://github.com/puppetlabs/ruby-pwsh/issues", 10 | "dependencies": [ 11 | 12 | ], 13 | "operatingsystem_support": [ 14 | { 15 | "operatingsystem": "Windows", 16 | "operatingsystemrelease": [ 17 | "2012", 18 | "2012 R2", 19 | "2016", 20 | "2019", 21 | "2022", 22 | "10", 23 | "11" 24 | ] 25 | }, 26 | { 27 | "operatingsystem": "CentOS", 28 | "operatingsystemrelease": [ 29 | "7", 30 | "8" 31 | ] 32 | }, 33 | { 34 | "operatingsystem": "Debian", 35 | "operatingsystemrelease": [ 36 | "10", 37 | "11" 38 | ] 39 | }, 40 | { 41 | "operatingsystem": "Fedora", 42 | "operatingsystemrelease": [ 43 | "36" 44 | ] 45 | }, 46 | { 47 | "operatingsystem": "OSX ", 48 | "operatingsystemrelease": [ 49 | "10.15", 50 | "11", 51 | "12" 52 | ] 53 | }, 54 | { 55 | "operatingsystem": "RedHat", 56 | "operatingsystemrelease": [ 57 | "7", 58 | "8", 59 | "9" 60 | ] 61 | }, 62 | { 63 | "operatingsystem": "Ubuntu", 64 | "operatingsystemrelease": [ 65 | "18.04", 66 | "20.04", 67 | "22.04" 68 | ] 69 | }, 70 | { 71 | "operatingsystem": "AlmaLinux", 72 | "operatingsystemrelease": [ 73 | "8", 74 | "9" 75 | ] 76 | } 77 | ], 78 | "requirements": [ 79 | { 80 | "name": "puppet", 81 | "version_requirement": ">= 8.0.0 < 9.0.0" 82 | } 83 | ], 84 | "pdk-version": "3.0.1", 85 | "template-url": "https://github.com/puppetlabs/pdk-templates.git#main", 86 | "template-ref": "tags/3.2.0-0-gb257ef1" 87 | } 88 | -------------------------------------------------------------------------------- /pwshlib.md: -------------------------------------------------------------------------------- 1 | # pwshlib 2 | 3 | This module enables you to leverage the `ruby-pwsh` gem to execute PowerShell from within your Puppet providers without having to instantiate and tear down a PowerShell process for each command called. 4 | It supports Windows PowerShell as well as PowerShell Core - if you're running **PowerShell v3+**, this gem supports you. 5 | 6 | The `Manager` class enables you to execute and interoperate with PowerShell from within ruby, leveraging the strengths of both languages as needed. 7 | 8 | ## Prerequisites 9 | 10 | Include `puppetlabs-pwshlib` as a dependency in your module and you can leverage it in your providers by using a requires statement, such as in this example: 11 | 12 | ```ruby 13 | require 'puppet/resource_api/simple_provider' 14 | begin 15 | require 'ruby-pwsh' 16 | rescue LoadError 17 | raise 'Could not load the "ruby-pwsh" library; is the dependency module puppetlabs-pwshlib installed in this environment?' 18 | end 19 | 20 | # Implementation for the foo type using the Resource API. 21 | class Puppet::Provider::Foo::Foo < Puppet::ResourceApi::SimpleProvider 22 | def get(context) 23 | context.debug("PowerShell Path: #{Pwsh::Manager.powershell_path}") 24 | context.debug('Returning pre-canned example data') 25 | [ 26 | { 27 | name: 'foo', 28 | ensure: 'present', 29 | }, 30 | { 31 | name: 'bar', 32 | ensure: 'present', 33 | }, 34 | ] 35 | end 36 | 37 | def create(context, name, should) 38 | context.notice("Creating '#{name}' with #{should.inspect}") 39 | end 40 | 41 | def update(context, name, should) 42 | context.notice("Updating '#{name}' with #{should.inspect}") 43 | end 44 | 45 | def delete(context, name) 46 | context.notice("Deleting '#{name}'") 47 | end 48 | end 49 | ``` 50 | 51 | Aside from adding it as a dependency to your module metadata, you will probably also want to include it in your `.fixtures.yml` file: 52 | 53 | ```yaml 54 | fixtures: 55 | forge_modules: 56 | pwshlib: "puppetlabs/pwshlib" 57 | ``` 58 | 59 | ## Using the Library 60 | 61 | Instantiating the manager can be done using some defaults: 62 | 63 | ```ruby 64 | # Instantiate the manager for Windows PowerShell, using the default path and arguments 65 | # Note that this takes a few seconds to instantiate. 66 | posh = Pwsh::Manager.instance(Pwsh::Manager.powershell_path, Pwsh::Manager.powershell_args) 67 | # If you try to create another manager with the same arguments it will reuse the existing one. 68 | ps = Pwsh::Manager.instance(Pwsh::Manager.powershell_path, Pwsh::Manager.powershell_args) 69 | # Note that this time the return is very fast. 70 | # We can also use the defaults for PowerShell Core, though these only work if PowerShell is 71 | # installed to the default paths - if it is installed anywhere else, you'll need to specify 72 | # the full path to the pwsh executable. 73 | pwsh = Pwsh::Manager.instance(Pwsh::Manager.pwsh_path, Pwsh::Manager.pwsh_args) 74 | ``` 75 | 76 | Execution can be done with relatively little additional work - pass the command string you want executed: 77 | 78 | ```ruby 79 | # Instantiate the Manager: 80 | posh = Pwsh::Manager.instance(Pwsh::Manager.powershell_path, Pwsh::Manager.powershell_args) 81 | # Pretty print the output of `$PSVersionTable` to validate the version of PowerShell running 82 | # Note that the output is a hash with a few different keys, including stdout. 83 | Puppet.debug(posh.execute('$PSVersionTable')) 84 | # Lets reduce the noise a little and retrieve just the version number: 85 | # Note: We cast to a string because PSVersion is actually a Version object. 86 | Puppet.debug(posh.execute('[String]$PSVersionTable.PSVersion')) 87 | # We could store this output to a ruby variable if we wanted, for further use: 88 | ps_version = posh.execute('[String]$PSVersionTable.PSVersion')[:stdout].strip 89 | Puppet.debug("The PowerShell version of the currently running Manager is #{ps_version}") 90 | ``` 91 | 92 | For more information, please review the [online reference documentation for the gem](https://rubydoc.info/gems/ruby-pwsh). 93 | -------------------------------------------------------------------------------- /ruby-pwsh.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'pwsh/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'ruby-pwsh' 9 | spec.version = Pwsh::VERSION 10 | spec.authors = ['Puppet, Inc.'] 11 | spec.email = ['info@puppet.com'] 12 | 13 | spec.summary = 'PowerShell code manager for ruby.' 14 | spec.description = 'PowerShell code manager for ruby.' 15 | spec.homepage = 'https://github.com/puppetlabs/ruby-pwsh' 16 | spec.license = 'MIT' 17 | 18 | spec.metadata['homepage_uri'] = spec.homepage 19 | spec.metadata['source_code_uri'] = 'https://github.com/puppetlabs/ruby-pwsh' 20 | spec.metadata['changelog_uri'] = 'https://github.com/puppetlabs/ruby-pwsh' 21 | 22 | spec.files = Dir[ 23 | 'README.md', 24 | 'LICENSE', 25 | '.rubocop.yml', 26 | 'lib/**/*', 27 | 'bin/**/*', 28 | 'spec/**/*', 29 | ] 30 | 31 | spec.bindir = 'exe' 32 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 33 | spec.require_paths = ['lib'] 34 | 35 | spec.required_ruby_version = Gem::Requirement.new('>= 3.1.0') 36 | spec.metadata['rubygems_mfa_required'] = 'true' 37 | end 38 | -------------------------------------------------------------------------------- /spec/acceptance/dsc/basic.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'ruby-pwsh' 5 | require 'securerandom' 6 | 7 | powershell = Pwsh::Manager.instance(Pwsh::Manager.powershell_path, Pwsh::Manager.powershell_args) 8 | module_path = File.expand_path('../../fixtures/modules', File.dirname(__FILE__)) 9 | powershellget_path = File.expand_path('powershellget/lib/puppet_x/powershellget/dsc_resources/PowerShellGet', module_path) 10 | local_user = ['dsc', SecureRandom.uuid.slice(0, 7)].join('_') 11 | local_pw = SecureRandom.uuid 12 | 13 | def execute_reset_command(reset_command) 14 | manager = Pwsh::Manager.instance(Pwsh::Manager.powershell_path, Pwsh::Manager.powershell_args) 15 | result = manager.execute(reset_command) 16 | raise result[:errormessage] unless result[:errormessage].nil? 17 | end 18 | 19 | RSpec.describe 'DSC Acceptance: Basic' do 20 | let(:puppet_apply) do 21 | "bundle exec puppet apply --modulepath #{module_path} --detailed-exitcodes --debug --trace" 22 | end 23 | let(:command) { "#{puppet_apply} -e \"#{manifest}\"" } 24 | 25 | context 'Updating' do 26 | let(:manifest) do 27 | # This very awkward pattern is because we're not writing 28 | # manifest files and need to pass them directly to puppet apply. 29 | [ 30 | "dsc_psrepository { 'Trust PSGallery':", 31 | "dsc_name => 'PSGallery',", 32 | "dsc_ensure => 'Present',", 33 | "dsc_installationpolicy => 'Trusted'", 34 | '}' 35 | ].join(' ') 36 | end 37 | 38 | before(:all) do 39 | reset_command = <<~RESET_COMMAND 40 | $ErrorActionPreference = 'Stop' 41 | Import-Module PowerShellGet 42 | $ResetParameters = @{ 43 | Name = 'PSRepository' 44 | ModuleName = '#{powershellget_path}' 45 | Method = 'Set' 46 | Property = @{ 47 | Name = 'PSGallery' 48 | Ensure = 'Present' 49 | InstallationPolicy = 'Untrusted' 50 | } 51 | } 52 | Invoke-DscResource @ResetParameters | ConvertTo-Json -Compress 53 | RESET_COMMAND 54 | execute_reset_command(reset_command) 55 | end 56 | 57 | it 'applies idempotently' do 58 | first_run_result = powershell.execute(command) 59 | expect(first_run_result[:exitcode]).to be(2) 60 | expect(first_run_result[:native_stdout]).to match(/dsc_installationpolicy changed 'Untrusted' to 'Trusted'/) 61 | expect(first_run_result[:native_stdout]).to match(/Updating: Finished/) 62 | expect(first_run_result[:native_stdout]).to match(/Applied catalog/) 63 | second_run_result = powershell.execute(command) 64 | expect(second_run_result[:exitcode]).to be(0) 65 | end 66 | end 67 | 68 | context 'Creating' do 69 | let(:manifest) do 70 | [ 71 | "dsc_psmodule { 'Install BurntToast':", 72 | "dsc_name => 'BurntToast',", 73 | "dsc_ensure => 'Present',", 74 | '}' 75 | ].join(' ') 76 | end 77 | 78 | before(:all) do 79 | reset_command = <<~RESET_COMMAND 80 | $ErrorActionPreference = 'Stop' 81 | Import-Module PowerShellGet 82 | Get-InstalledModule -Name BurntToast -ErrorAction SilentlyContinue | 83 | Uninstall-Module -Force 84 | RESET_COMMAND 85 | execute_reset_command(reset_command) 86 | end 87 | 88 | it 'applies idempotently' do 89 | first_run_result = powershell.execute(command) 90 | expect(first_run_result[:exitcode]).to be(2) 91 | expect(first_run_result[:native_stdout]).to match(/dsc_ensure changed 'Absent' to 'Present'/) 92 | expect(first_run_result[:native_stdout]).to match(/Creating: Finished/) 93 | expect(first_run_result[:native_stdout]).to match(/Applied catalog/) 94 | second_run_result = powershell.execute(command) 95 | expect(second_run_result[:exitcode]).to be(0) 96 | end 97 | end 98 | 99 | context 'Deleting' do 100 | let(:manifest) do 101 | [ 102 | "dsc_psmodule { 'Install BurntToast':", 103 | "dsc_name => 'BurntToast',", 104 | "dsc_ensure => 'Absent',", 105 | '}' 106 | ].join(' ') 107 | end 108 | 109 | before(:all) do 110 | reset_command = <<~RESET_COMMAND 111 | $ErrorActionPreference = 'Stop' 112 | Import-Module PowerShellGet 113 | $Installed = Get-InstalledModule -Name BurntToast -ErrorAction SilentlyContinue 114 | If($null -eq $Installed) { 115 | Install-Module -Name BurntToast -Scope AllUsers -Force 116 | } 117 | RESET_COMMAND 118 | execute_reset_command(reset_command) 119 | end 120 | 121 | it 'applies idempotently' do 122 | first_run_result = powershell.execute(command) 123 | expect(first_run_result[:exitcode]).to be(2) 124 | expect(first_run_result[:native_stdout]).to match(/dsc_ensure changed 'Present' to 'Absent'/) 125 | expect(first_run_result[:native_stdout]).to match(/Deleting: Finished/) 126 | expect(first_run_result[:native_stdout]).to match(/Applied catalog/) 127 | second_run_result = powershell.execute(command) 128 | expect(second_run_result[:exitcode]).to be(0) 129 | end 130 | end 131 | 132 | context 'PSDscRunAsCredential' do 133 | before(:all) do 134 | prep_command = <<~PREP_USER.strip 135 | $ErrorActionPreference = 'Stop' 136 | $User = Get-LocalUser -Name #{local_user} -ErrorAction SilentlyContinue 137 | If ($null -eq $User) { 138 | $Secure = ConvertTo-SecureString -String '#{local_pw}' -AsPlainText -Force 139 | $User = New-LocalUser -Name #{local_user} -Password $Secure -Verbose 140 | } 141 | If ($User.Name -notin (Get-LocalGroupMember -Group Administrators).Name) { 142 | Add-LocalGroupMember -Group Administrators -Member $User -Verbose 143 | } 144 | Get-LocalGroupMember -Group Administrators | 145 | Where-Object Name -match '#{local_user}' 146 | PREP_USER 147 | execute_reset_command(prep_command) 148 | end 149 | 150 | after(:all) do 151 | cleanup_command = <<~CLEANUP_USER.strip 152 | Remove-LocalUser -Name #{local_user} -ErrorAction Stop 153 | CLEANUP_USER 154 | execute_reset_command(cleanup_command) 155 | end 156 | 157 | context 'with a valid credential' do 158 | let(:manifest) do 159 | [ 160 | "dsc_psrepository { 'Trust PSGallery':", 161 | "dsc_name => 'PSGallery',", 162 | "dsc_ensure => 'Present',", 163 | "dsc_installationpolicy => 'Trusted',", 164 | 'dsc_psdscrunascredential => {', 165 | "'user' => '#{local_user}',", 166 | "'password' => Sensitive('#{local_pw}')", 167 | '}', 168 | '}' 169 | ].join(' ') 170 | end 171 | 172 | it 'applies idempotently without leaking secrets' do 173 | first_run_result = powershell.execute(command) 174 | expect(first_run_result[:exitcode]).to be(2) 175 | expect(first_run_result[:native_stdout]).to match(/dsc_installationpolicy changed 'Untrusted' to 'Trusted'/) 176 | expect(first_run_result[:native_stdout]).to match(/Updating: Finished/) 177 | expect(first_run_result[:native_stdout]).to match(/Applied catalog/) 178 | expect(first_run_result[:native_stdout]).to match(/'#'/) 179 | expect(first_run_result[:native_stdout]).not_to match(local_pw) 180 | second_run_result = powershell.execute(command) 181 | expect(second_run_result[:exitcode]).to be(0) 182 | end 183 | end 184 | 185 | context 'with an invalid credential' do 186 | let(:manifest) do 187 | [ 188 | "dsc_psrepository { 'Trust PSGallery':", 189 | "dsc_name => 'PSGallery',", 190 | "dsc_ensure => 'Present',", 191 | "dsc_installationpolicy => 'Trusted',", 192 | 'dsc_psdscrunascredential => {', 193 | "'user' => 'definitely_do_not_exist_here',", 194 | "'password' => Sensitive('#{local_pw}')", 195 | '}', 196 | '}' 197 | ].join(' ') 198 | end 199 | 200 | it 'errors loudly without leaking secrets' do 201 | first_run_result = powershell.execute(command) 202 | expect(first_run_result[:exitcode]).to be(4) 203 | expect(first_run_result[:stderr].first).to match(/dsc_psrepository: The user name or password is incorrect/) 204 | expect(first_run_result[:native_stdout]).to match(/'#'/) 205 | expect(first_run_result[:native_stdout]).not_to match(local_pw) 206 | end 207 | end 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /spec/acceptance/dsc/cim_instances.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # TODO: Test against mcollera/AccessControlDsc for CIM instance behavior 4 | # 1. Make sure valid nested CIM instances can be passed to Invoke-DscResource 5 | # 2. Make sure nested CIM instances can be read back from Invoke-DscResource 6 | 7 | require 'spec_helper' 8 | require 'ruby-pwsh' 9 | 10 | # Needs to be declared here so it is usable in before and it blocks alike 11 | test_manifest = File.expand_path('../../fixtures/test.pp', File.dirname(__FILE__)) 12 | fixtures_path = File.expand_path('../../fixtures', File.dirname(__FILE__)) 13 | 14 | def execute_reset_command(reset_command) 15 | manager = Pwsh::Manager.instance(Pwsh::Manager.powershell_path, Pwsh::Manager.powershell_args) 16 | result = manager.execute(reset_command) 17 | raise result[:errormessage] unless result[:errormessage].nil? 18 | end 19 | 20 | RSpec.describe 'DSC Acceptance: Complex' do 21 | let(:powershell) { Pwsh::Manager.instance(Pwsh::Manager.powershell_path, Pwsh::Manager.powershell_args) } 22 | let(:module_path) { File.expand_path('../../fixtures/modules', File.dirname(__FILE__)) } 23 | let(:puppet_apply) do 24 | "bundle exec puppet apply #{test_manifest} --modulepath #{module_path} --detailed-exitcodes --trace" 25 | end 26 | 27 | context 'Managing the access control list of a folder' do 28 | before do 29 | reset_command = <<~RESET_COMMAND 30 | $TestFolderPath = Join-Path -Path "#{fixtures_path}" -Childpath access_control 31 | # Delete the test folder if it exists (to clear access control modifications) 32 | If (Test-Path -Path $TestFolderPath -PathType Container) { 33 | Remove-Item $TestFolderPath -Recurse -Force 34 | } 35 | # Create the test folder 36 | New-Item $TestFolderPath -ItemType Directory 37 | RESET_COMMAND 38 | execute_reset_command(reset_command) 39 | end 40 | 41 | it 'applies idempotently' do 42 | content = <<~MANIFEST.strip 43 | $test_folder_path = "#{fixtures_path}/access_control" 44 | # Configure access to the test folder 45 | dsc_ntfsaccessentry {'Test': 46 | dsc_path => $test_folder_path, 47 | dsc_accesscontrollist => [ 48 | { 49 | principal => 'Everyone', 50 | forceprincipal => true, 51 | accesscontrolentry => [ 52 | { 53 | accesscontroltype => 'Allow', 54 | filesystemrights => ['FullControl'], 55 | inheritance => 'This folder and files', 56 | ensure => 'Present', 57 | cim_instance_type => 'NTFSAccessControlEntry', 58 | } 59 | ] 60 | } 61 | ] 62 | } 63 | MANIFEST 64 | File.write(test_manifest, content) 65 | # Apply the test manifest 66 | first_run_result = powershell.execute(puppet_apply) 67 | expect(first_run_result[:exitcode]).to be(2) 68 | # Access Control Set 69 | expect(first_run_result[:native_stdout]).to match(/dsc_accesscontrollist: dsc_accesscontrollist changed/) 70 | expect(first_run_result[:native_stdout]).to match(%r{dsc_ntfsaccessentry\[{:name=>"Test", :dsc_path=>".+/spec/fixtures/access_control"}\]: Updating: Finished}) 71 | expect(first_run_result[:stderr]).not_to match(/Error/) 72 | expect(first_run_result[:stderr]).not_to match(/Warning: Provider returned data that does not match the Type Schema/) 73 | expect(first_run_result[:stderr]).not_to match(/Value type mismatch/) 74 | # Run finished 75 | expect(first_run_result[:native_stdout]).to match(/Applied catalog/) 76 | # Second run is idempotent 77 | second_run_result = powershell.execute(puppet_apply) 78 | expect(second_run_result[:exitcode]).to be(0) 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /spec/acceptance/dsc/class.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'ruby-pwsh' 5 | 6 | powershell = Pwsh::Manager.instance(Pwsh::Manager.powershell_path, Pwsh::Manager.powershell_args) 7 | module_path = File.expand_path('../../fixtures/modules', File.dirname(__FILE__)) 8 | psrc_path = File.expand_path('../../fixtures/example.psrc', File.dirname(__FILE__)) 9 | 10 | def execute_reset_command(reset_command) 11 | manager = Pwsh::Manager.instance(Pwsh::Manager.powershell_path, Pwsh::Manager.powershell_args) 12 | result = manager.execute(reset_command) 13 | raise result[:errormessage] unless result[:errormessage].nil? 14 | end 15 | 16 | RSpec.describe 'DSC Acceptance: Class-Based Resource' do 17 | let(:puppet_apply) do 18 | "bundle exec puppet apply --modulepath #{module_path} --detailed-exitcodes --debug --trace" 19 | end 20 | let(:command) { "#{puppet_apply} -e \"#{manifest}\"" } 21 | 22 | context 'Creating' do 23 | let(:manifest) do 24 | # This very awkward pattern is because we're not writing 25 | # manifest files and need to pass them directly to puppet apply. 26 | [ 27 | "dsc_jearolecapabilities { 'ExampleRoleCapability':", 28 | "dsc_ensure => 'Present',", 29 | "dsc_path => '#{psrc_path}',", 30 | "dsc_description => 'Example role capability file'", 31 | '}' 32 | ].join(' ') 33 | end 34 | 35 | before do 36 | reset_command = <<~RESET_COMMAND 37 | $PsrcPath = '#{psrc_path}' 38 | # Delete the test PSRC fixture if it exists 39 | If (Test-Path -Path $PsrcPath -PathType Leaf) { 40 | Remove-Item $PsrcPath -Force 41 | } 42 | RESET_COMMAND 43 | execute_reset_command(reset_command) 44 | end 45 | 46 | it 'applies idempotently' do 47 | first_run_result = powershell.execute(command) 48 | expect(first_run_result[:exitcode]).to be(2) 49 | expect(first_run_result[:native_stdout]).to match(//) 50 | expect(first_run_result[:native_stdout]).to match(/dsc_description changed to 'Example role capability file'/) 51 | expect(first_run_result[:native_stdout]).to match(/Creating: Finished/) 52 | expect(first_run_result[:native_stdout]).to match(/Applied catalog/) 53 | second_run_result = powershell.execute(command) 54 | expect(second_run_result[:exitcode]).to be(0) 55 | end 56 | end 57 | 58 | context 'Updating' do 59 | let(:manifest) do 60 | # This very awkward pattern is because we're not writing 61 | # manifest files and need to pass them directly to puppet apply. 62 | [ 63 | "dsc_jearolecapabilities { 'ExampleRoleCapability':", 64 | "dsc_ensure => 'Present',", 65 | "dsc_path => '#{psrc_path}',", 66 | "dsc_description => 'Updated role capability file'", 67 | '}' 68 | ].join(' ') 69 | end 70 | 71 | before do 72 | reset_command = <<~RESET_COMMAND 73 | $PsrcPath = '#{psrc_path}' 74 | # Delete the test PSRC fixture if it exists 75 | If (Test-Path -Path $PsrcPath -PathType Leaf) { 76 | Remove-Item $PsrcPath -Force 77 | } 78 | # Create the test PSRC fixture 79 | New-Item $PsrcPath -ItemType File -Value "@{'Description' = 'Example role capability file'}" 80 | RESET_COMMAND 81 | execute_reset_command(reset_command) 82 | end 83 | 84 | it 'applies idempotently' do 85 | first_run_result = powershell.execute(command) 86 | expect(first_run_result[:exitcode]).to be(2) 87 | expect(first_run_result[:native_stdout]).to match(/dsc_description changed 'Example role capability file' to 'Updated role capability file'/) 88 | expect(first_run_result[:native_stdout]).to match(/Updating: Finished/) 89 | expect(first_run_result[:native_stdout]).to match(/Applied catalog/) 90 | second_run_result = powershell.execute(command) 91 | expect(second_run_result[:exitcode]).to be(0) 92 | end 93 | end 94 | 95 | context 'Deleting' do 96 | let(:manifest) do 97 | # This very awkward pattern is because we're not writing 98 | # manifest files and need to pass them directly to puppet apply. 99 | [ 100 | "dsc_jearolecapabilities { 'ExampleRoleCapability':", 101 | "dsc_ensure => 'Absent',", 102 | "dsc_path => '#{psrc_path}'", 103 | '}' 104 | ].join(' ') 105 | end 106 | 107 | before do 108 | reset_command = <<~RESET_COMMAND 109 | $PsrcPath = '#{psrc_path}' 110 | # Delete the test PSRC fixture if it exists 111 | If (!(Test-Path -Path $PsrcPath -PathType Leaf)) { 112 | # Create the test PSRC fixture 113 | New-Item $PsrcPath -ItemType File -Value "@{'Description' = 'Updated'}" 114 | } 115 | RESET_COMMAND 116 | execute_reset_command(reset_command) 117 | end 118 | 119 | it 'applies idempotently' do 120 | first_run_result = powershell.execute(command) 121 | expect(first_run_result[:exitcode]).to be(2) 122 | expect(first_run_result[:native_stdout]).to match(/dsc_ensure changed 'Present' to 'Absent'/) 123 | expect(first_run_result[:native_stdout]).to match(/Deleting: Finished/) 124 | expect(first_run_result[:native_stdout]).to match(/Applied catalog/) 125 | second_run_result = powershell.execute(command) 126 | expect(second_run_result[:exitcode]).to be(0) 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /spec/acceptance/dsc/complex.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'ruby-pwsh' 5 | 6 | # Needs to be declared here so it is usable in before and it blocks alike 7 | test_manifest = File.expand_path('../../fixtures/test.pp', File.dirname(__FILE__)) 8 | fixtures_path = File.expand_path('../../fixtures', File.dirname(__FILE__)) 9 | 10 | def execute_reset_command(reset_command) 11 | manager = Pwsh::Manager.instance(Pwsh::Manager.powershell_path, Pwsh::Manager.powershell_args) 12 | result = manager.execute(reset_command) 13 | raise result[:errormessage] unless result[:errormessage].nil? 14 | end 15 | 16 | RSpec.describe 'DSC Acceptance: Complex' do 17 | let(:powershell) { Pwsh::Manager.instance(Pwsh::Manager.powershell_path, Pwsh::Manager.powershell_args) } 18 | let(:module_path) { File.expand_path('../../fixtures/modules', File.dirname(__FILE__)) } 19 | let(:puppet_apply) do 20 | "bundle exec puppet apply #{test_manifest} --modulepath #{module_path} --detailed-exitcodes --trace" 21 | end 22 | 23 | context 'Adding a new website' do 24 | before do 25 | reset_command = <<~RESET_COMMAND 26 | # Ensure IIS is not installed 27 | $Feature = Get-WindowsFeature -Name 'Web-Asp-Net45' 28 | If ($Feature.Installed) { 29 | Remove-WindowsFeature -Name $Feature.Name -ErrorAction Stop 30 | } 31 | $DefaultSite = Get-Website 'Default Web Site' -ErrorAction Continue 32 | $ExampleSite = Get-Website 'Puppet DSC Site' -ErrorAction Continue 33 | If ($DefaultSite.State -eq 'Stopped') { 34 | Start-Website -Name $DefaultSite.Name 35 | } 36 | If ($ExampleSite) { 37 | Stop-Website -Name $ExampleSite.Name 38 | Remove-Website -Name $ExampleSite.Name 39 | Remove-Item -Path '#{fixtures_path}/website' -Recurse -Force -ErrorAction SilentlyContinue 40 | } 41 | RESET_COMMAND 42 | execute_reset_command(reset_command) 43 | end 44 | 45 | it 'applies idempotently' do 46 | content = <<~MANIFEST.strip 47 | $destination_path = '#{fixtures_path}/website' 48 | $website_name = 'Puppet DSC Site' 49 | $site_id = 7 50 | $index_html = @(INDEXHTML) 51 | 52 | 53 | 54 | 55 | 56 | blah 57 | 58 | 59 | 60 |

I'm the content

61 | 62 | 63 | 64 | | INDEXHTML 65 | # Install the IIS role 66 | dsc_xwindowsfeature { 'IIS': 67 | dsc_ensure => 'Present', 68 | dsc_name => 'Web-Server', 69 | } 70 | 71 | # Stop the default website 72 | dsc_xwebsite { 'DefaultSite': 73 | dsc_ensure => 'Present', 74 | dsc_name => 'Default Web Site', 75 | dsc_state => 'Stopped', 76 | dsc_serverautostart => false, 77 | dsc_physicalpath => 'C:\inetpub\wwwroot', 78 | require => Dsc_xwindowsfeature['IIS'], 79 | } 80 | 81 | # Install the ASP .NET 4.5 role 82 | dsc_xwindowsfeature { 'AspNet45': 83 | dsc_ensure => 'Present', 84 | dsc_name => 'Web-Asp-Net45', 85 | } 86 | 87 | file { 'WebContentFolder': 88 | ensure => directory, 89 | path => $destination_path, 90 | require => Dsc_xwindowsfeature['AspNet45'], 91 | } 92 | 93 | # Copy the website content 94 | file { 'WebContentIndex': 95 | path => "${destination_path}/index.html", 96 | content => $index_html, 97 | require => File['WebContentFolder'], 98 | } 99 | 100 | # Create the new Website 101 | dsc_xwebsite { 'NewWebsite': 102 | dsc_ensure => 'Present', 103 | dsc_name => $website_name, 104 | dsc_siteid => $site_id, 105 | dsc_state => 'Started', 106 | dsc_serverautostart => true, 107 | dsc_physicalpath => $destination_path, 108 | require => File['WebContentIndex'], 109 | } 110 | MANIFEST 111 | File.write(test_manifest, content) 112 | # Puppet apply the test manifest 113 | first_run_result = powershell.execute(puppet_apply) 114 | expect(first_run_result[:exitcode]).to be(2) 115 | # The Default Site is stopped 116 | expect(first_run_result[:native_stdout]).to match(%r{Dsc_xwebsite\[DefaultSite\]/dsc_state: dsc_state changed 'Started' to 'Stopped'}) 117 | expect(first_run_result[:native_stdout]).to match(/dsc_xwebsite\[{:name=>"DefaultSite", :dsc_name=>"Default Web Site"}\]: Updating: Finished/) 118 | # AspNet45 is installed 119 | expect(first_run_result[:native_stdout]).to match(%r{Dsc_xwindowsfeature\[AspNet45\]/dsc_ensure: dsc_ensure changed 'Absent' to 'Present'}) 120 | expect(first_run_result[:native_stdout]).to match(/dsc_xwindowsfeature\[{:name=>"AspNet45", :dsc_name=>"Web-Asp-Net45"}\]: Creating: Finished/) 121 | # Web content folder created 122 | expect(first_run_result[:native_stdout]).to match(%r{File\[WebContentFolder\]/ensure: created}) 123 | # Web content index created 124 | expect(first_run_result[:native_stdout]).to match(%r{File\[WebContentIndex\]/ensure: defined content as '.+'}) 125 | # Web site created 126 | expect(first_run_result[:native_stdout]).to match(%r{Dsc_xwebsite\[NewWebsite\]/dsc_siteid: dsc_siteid changed to 7}) 127 | expect(first_run_result[:native_stdout]).to match(%r{Dsc_xwebsite\[NewWebsite\]/dsc_ensure: dsc_ensure changed 'Absent' to 'Present'}) 128 | expect(first_run_result[:native_stdout]).to match(%r{Dsc_xwebsite\[NewWebsite\]/dsc_physicalpath: dsc_physicalpath changed to '.+fixtures/website'}) 129 | expect(first_run_result[:native_stdout]).to match(%r{Dsc_xwebsite\[NewWebsite\]/dsc_state: dsc_state changed to 'Started'}) 130 | expect(first_run_result[:native_stdout]).to match(%r{Dsc_xwebsite\[NewWebsite\]/dsc_serverautostart: dsc_serverautostart changed to 'true'}) 131 | expect(first_run_result[:native_stdout]).to match(/dsc_xwebsite\[{:name=>"NewWebsite", :dsc_name=>"Puppet DSC Site"}\]: Creating: Finished/) 132 | # Run finished 133 | expect(first_run_result[:native_stdout]).to match(/Applied catalog/) 134 | # Second run is idempotent 135 | second_run_result = powershell.execute(puppet_apply) 136 | expect(second_run_result[:exitcode]).to be(0) 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /spec/acceptance/support/setup_winrm.ps1: -------------------------------------------------------------------------------- 1 | Get-ChildItem WSMan:\localhost\Listener\ -OutVariable Listeners | Format-List * -Force 2 | $HTTPListener = $Listeners | Where-Object -FilterScript { $_.Keys.Contains('Transport=HTTP') } 3 | If ($HTTPListener.Count -eq 0) { 4 | winrm create winrm/config/Listener?Address=*+Transport=HTTP 5 | winrm e winrm/config/listener 6 | } 7 | -------------------------------------------------------------------------------- /spec/default_facts.yml: -------------------------------------------------------------------------------- 1 | # Use default_module_facts.yml for module specific facts. 2 | # 3 | # Facts specified here will override the values provided by rspec-puppet-facts. 4 | --- 5 | networking: 6 | ip: "172.16.254.254" 7 | ip6: "FE80:0000:0000:0000:AAAA:AAAA:AAAA" 8 | mac: "AA:AA:AA:AA:AA:AA" 9 | is_pe: false 10 | -------------------------------------------------------------------------------- /spec/exit-27.ps1: -------------------------------------------------------------------------------- 1 | exit 27 2 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | if ENV['COVERAGE'] == 'yes' 4 | begin 5 | require 'simplecov' 6 | require 'simplecov-console' 7 | 8 | SimpleCov.formatters = [ 9 | SimpleCov::Formatter::HTMLFormatter, 10 | SimpleCov::Formatter::Console 11 | ] 12 | 13 | SimpleCov.start do 14 | track_files 'lib/**/*.rb' 15 | 16 | add_filter '/spec' 17 | add_filter 'lib/pwsh/version.rb' 18 | 19 | # do not track vendored files 20 | add_filter '/vendor' 21 | add_filter '/.vendor' 22 | end 23 | rescue LoadError 24 | raise 'Add the simplecov & simplecov-console gems to Gemfile to enable this task' 25 | end 26 | end 27 | 28 | require 'bundler/setup' 29 | require 'ruby-pwsh' 30 | 31 | RSpec.configure do |config| 32 | # Enable flags like --only-failures and --next-failure 33 | config.example_status_persistence_file_path = '.rspec_status' 34 | 35 | # Disable RSpec exposing methods globally on `Module` and `main` 36 | config.disable_monkey_patching! 37 | 38 | config.expect_with :rspec do |c| 39 | c.syntax = :expect 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/unit/pwsh/util_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Pwsh::Util do 6 | let(:snake_case_string) { 'this_is_a_string' } 7 | let(:pascal_case_string) { 'ThisIsAString' } 8 | let(:kebab_case_string) { 'this-is-a-string' } 9 | let(:camel_case_string) { 'thisIsAString' } 10 | let(:snake_case_hash_in_an_array) do 11 | [ 12 | 'just a string', 13 | { 14 | some_key: 'a value' 15 | }, 16 | 1, 17 | { 18 | another_key: { 19 | nested_key: 1, 20 | nested_array: [ 21 | 1, 22 | 'another string', 23 | { super_nested_key: 'value' } 24 | ] 25 | } 26 | } 27 | ] 28 | end 29 | let(:snake_case_hash) do 30 | { 31 | a: 1, 32 | apple_butter: %w[a b c], 33 | some_key_value: { 34 | nested_key: 1, 35 | another_nested_key: 2 36 | } 37 | } 38 | end 39 | let(:pascal_case_hash_in_an_array) do 40 | [ 41 | 'just a string', 42 | { 43 | SomeKey: 'a value' 44 | }, 45 | 1, 46 | { 47 | AnotherKey: { 48 | NestedKey: 1, 49 | NestedArray: [ 50 | 1, 51 | 'another string', 52 | { SuperNestedKey: 'value' } 53 | ] 54 | } 55 | } 56 | ] 57 | end 58 | let(:pascal_case_hash) do 59 | { 60 | A: 1, 61 | AppleButter: %w[a b c], 62 | SomeKeyValue: { 63 | NestedKey: 1, 64 | AnotherNestedKey: 2 65 | } 66 | } 67 | end 68 | let(:kebab_case_hash) do 69 | { 70 | a: 1, 71 | 'apple-butter': %w[a b c], 72 | 'some-key-value': { 73 | 'nested-key': 1, 74 | 'another-nested-key': 2 75 | } 76 | } 77 | end 78 | let(:camel_case_hash) do 79 | { 80 | a: 1, 81 | appleButter: %w[a b c], 82 | someKeyValue: { 83 | nestedKey: 1, 84 | anotherNestedKey: 2 85 | } 86 | } 87 | end 88 | 89 | describe '.invalid_directories?' do 90 | let(:valid_path_a) { 'C:/some/folder' } 91 | let(:valid_path_b) { 'C:/another/folder' } 92 | let(:valid_paths) { 'C:/some/folder;C:/another/folder' } 93 | let(:invalid_path) { 'C:/invalid/path' } 94 | let(:mixed_paths) { 'C:/some/folder;C:/another/folder;C:/invalid/path' } 95 | let(:empty_string) { '' } 96 | let(:file_path) { 'C:/some/folder/file.txt' } 97 | let(:non_existent_dir) { 'C:/some/dir/that/doesnt/exist' } 98 | let(:empty_members) { 'C:/some/folder;;C:/another/folder' } 99 | 100 | it 'returns false if passed nil' do 101 | expect(described_class.invalid_directories?(nil)).to be false 102 | end 103 | 104 | it 'returns false if passed an empty string' do 105 | expect(described_class.invalid_directories?('')).to be false 106 | end 107 | 108 | it 'returns true if a file path is provided' do 109 | expect(described_class).to receive(:on_windows?).and_return(true) 110 | expect(File).to receive(:exist?).with(file_path).and_return(true) 111 | expect(File).to receive(:directory?).with(file_path).and_return(false) 112 | expect(described_class.invalid_directories?(file_path)).to be true 113 | end 114 | 115 | it 'returns false if one valid path is provided' do 116 | expect(described_class).to receive(:on_windows?).and_return(true) 117 | expect(File).to receive(:exist?).with(valid_path_a).and_return(true) 118 | expect(File).to receive(:directory?).with(valid_path_a).and_return(true) 119 | expect(described_class.invalid_directories?(valid_path_a)).to be false 120 | end 121 | 122 | it 'returns false if a collection of valid paths is provided' do 123 | expect(described_class).to receive(:on_windows?).and_return(true) 124 | expect(File).to receive(:directory?).with(valid_path_a).and_return(true) 125 | expect(File).to receive(:exist?).with(valid_path_a).and_return(true) 126 | expect(File).to receive(:directory?).with(valid_path_b).and_return(true) 127 | expect(File).to receive(:exist?).with(valid_path_b).and_return(true) 128 | expect(described_class.invalid_directories?(valid_paths)).to be false 129 | end 130 | 131 | it 'returns true if there is only one path and it is invalid' do 132 | expect(described_class).to receive(:on_windows?).and_return(true) 133 | expect(File).to receive(:exist?).with(invalid_path).and_return(true) 134 | expect(File).to receive(:directory?).with(invalid_path).and_return(false) 135 | expect(described_class.invalid_directories?(invalid_path)).to be true 136 | end 137 | 138 | it 'returns true if the collection has on valid and one invalid member' do 139 | expect(described_class).to receive(:on_windows?).and_return(true) 140 | expect(File).to receive(:exist?).with(valid_path_a).and_return(true) 141 | expect(File).to receive(:directory?).with(valid_path_a).and_return(true) 142 | expect(File).to receive(:exist?).with(valid_path_b).and_return(true) 143 | expect(File).to receive(:directory?).with(valid_path_b).and_return(true) 144 | expect(File).to receive(:exist?).with(invalid_path).and_return(true) 145 | expect(File).to receive(:directory?).with(invalid_path).and_return(false) 146 | expect(described_class.invalid_directories?(mixed_paths)).to be true 147 | end 148 | 149 | it 'returns false if collection has empty members but other entries are valid' do 150 | expect(described_class).to receive(:on_windows?).and_return(true) 151 | expect(File).to receive(:exist?).with(valid_path_a).and_return(true) 152 | expect(File).to receive(:directory?).with(valid_path_a).and_return(true) 153 | expect(File).to receive(:exist?).with(valid_path_b).and_return(true) 154 | expect(File).to receive(:directory?).with(valid_path_b).and_return(true) 155 | allow(File).to receive(:directory?).with('') 156 | expect(described_class.invalid_directories?(empty_members)).to be false 157 | end 158 | 159 | it 'returns true if a collection has valid members but also contains a file path' do 160 | expect(described_class).to receive(:on_windows?).and_return(true) 161 | expect(File).to receive(:exist?).with(valid_path_a).and_return(true) 162 | expect(File).to receive(:directory?).with(valid_path_a).and_return(true) 163 | expect(File).to receive(:exist?).with(file_path).and_return(true) 164 | expect(File).to receive(:directory?).with(file_path).and_return(false) 165 | expect(described_class.invalid_directories?("#{valid_path_a};#{file_path}")).to be true 166 | end 167 | 168 | it 'returns false if a collection has valid members but contains a non-existent dir path' do 169 | expect(described_class).to receive(:on_windows?).and_return(true) 170 | expect(File).to receive(:exist?).with(valid_path_a).and_return(true) 171 | expect(File).to receive(:directory?).with(valid_path_a).and_return(true) 172 | expect(File).to receive(:exist?).with(non_existent_dir).and_return(false) 173 | expect(described_class.invalid_directories?("#{valid_path_a};#{non_existent_dir}")).to be false 174 | end 175 | end 176 | 177 | describe '.snake_case' do 178 | it 'converts a string to snake_case' do 179 | expect(described_class.snake_case(camel_case_string)).to eq snake_case_string 180 | expect(described_class.snake_case(kebab_case_string)).to eq snake_case_string 181 | expect(described_class.snake_case(pascal_case_string)).to eq snake_case_string 182 | end 183 | end 184 | 185 | describe '.snake_case_hash_keys' do 186 | it 'snake_cases the keys in a passed hash' do 187 | expect(described_class.snake_case_hash_keys(camel_case_hash)).to eq snake_case_hash 188 | expect(described_class.snake_case_hash_keys(kebab_case_hash)).to eq snake_case_hash 189 | expect(described_class.snake_case_hash_keys(pascal_case_hash)).to eq snake_case_hash 190 | expect(described_class.snake_case_hash_keys(pascal_case_hash_in_an_array)).to eq snake_case_hash_in_an_array 191 | end 192 | end 193 | 194 | describe '.pascal_case' do 195 | it 'converts a string to PascalCase' do 196 | expect(described_class.pascal_case(camel_case_string)).to eq pascal_case_string 197 | expect(described_class.pascal_case(kebab_case_string)).to eq pascal_case_string 198 | expect(described_class.pascal_case(snake_case_string)).to eq pascal_case_string 199 | end 200 | end 201 | 202 | describe '.pascal_case_hash_keys' do 203 | it 'PascalCases the keys in a passed hash' do 204 | expect(described_class.pascal_case_hash_keys(camel_case_hash)).to eq pascal_case_hash 205 | expect(described_class.pascal_case_hash_keys(kebab_case_hash)).to eq pascal_case_hash 206 | expect(described_class.pascal_case_hash_keys(snake_case_hash)).to eq pascal_case_hash 207 | expect(described_class.pascal_case_hash_keys(snake_case_hash_in_an_array)).to eq pascal_case_hash_in_an_array 208 | end 209 | end 210 | 211 | describe '.symbolize_hash_keys' do 212 | let(:array_with_string_keys_in_hashes) do 213 | [ 214 | 'just a string', 215 | { 216 | 'some_key' => 'a value' 217 | }, 218 | 1, 219 | { 220 | 'another_key' => { 221 | 'nested_key' => 1, 222 | 'nested_array' => [ 223 | 1, 224 | 'another string', 225 | { 'super_nested_key' => 'value' } 226 | ] 227 | } 228 | } 229 | ] 230 | end 231 | let(:array_with_symbol_keys_in_hashes) do 232 | [ 233 | 'just a string', 234 | { 235 | some_key: 'a value' 236 | }, 237 | 1, 238 | { 239 | another_key: { 240 | nested_key: 1, 241 | nested_array: [ 242 | 1, 243 | 'another string', 244 | { super_nested_key: 'value' } 245 | ] 246 | } 247 | } 248 | ] 249 | end 250 | 251 | it 'converts all string hash keys into symbols' do 252 | expect(described_class.symbolize_hash_keys(array_with_string_keys_in_hashes)).to eq array_with_symbol_keys_in_hashes 253 | end 254 | end 255 | 256 | describe '.escape_quotes' do 257 | it 'handles single quotes' do 258 | expect(described_class.escape_quotes("The 'Cats' go 'meow'!")).to match(/The ''Cats'' go ''meow''!/) 259 | end 260 | 261 | it 'handles double single quotes' do 262 | expect(described_class.escape_quotes("The ''Cats'' go 'meow'!")).to match(/The ''''Cats'''' go ''meow''!/) 263 | end 264 | 265 | it 'handles double quotes' do 266 | expect(described_class.escape_quotes("The 'Cats' go \"meow\"!")).to match(/The ''Cats'' go "meow"!/) 267 | end 268 | 269 | it 'handles dollar signs' do 270 | expect(described_class.escape_quotes('This should show $foo variable')).to match(/This should show \$foo variable/) 271 | end 272 | end 273 | 274 | describe '.format_powershell_value' do 275 | let(:ruby_array) { ['string', 1, :symbol, true] } 276 | let(:powershell_array) { "@('string', 1, symbol, $true)" } 277 | let(:ruby_hash) do 278 | { 279 | string: 'string', 280 | number: 1, 281 | symbol: :some_symbol, 282 | boolean: true, 283 | nested_hash: { 284 | another_string: 'foo', 285 | another_number: 2, 286 | array: [1, 2, 3] 287 | } 288 | } 289 | end 290 | let(:powershell_hash) { "@{string = 'string'; number = 1; symbol = some_symbol; boolean = $true; nested_hash = @{another_string = 'foo'; another_number = 2; array = @(1, 2, 3)}}" } 291 | 292 | it 'returns a symbol as a non-interpolated string' do 293 | expect(described_class.format_powershell_value(:apple)).to eq('apple') 294 | end 295 | 296 | it 'returns a number as a non interpolated string' do 297 | expect(described_class.format_powershell_value(101)).to eq('101') 298 | expect(described_class.format_powershell_value(1.1)).to eq('1.1') 299 | end 300 | 301 | it 'returns boolean values as the appropriate PowerShell automatic variable' do 302 | expect(described_class.format_powershell_value(true)).to eq('$true') 303 | expect(described_class.format_powershell_value(:false)).to eq('$false') # rubocop:disable Lint/BooleanSymbol 304 | end 305 | 306 | it 'returns a string as an escaped string' do 307 | expect(described_class.format_powershell_value('some string')).to eq("'some string'") 308 | end 309 | 310 | it 'returns an array as a string representing a PowerShell array' do 311 | expect(described_class.format_powershell_value(ruby_array)).to eq(powershell_array) 312 | end 313 | 314 | it 'returns a hash as a string representing a PowerShell hash' do 315 | expect(described_class.format_powershell_value(ruby_hash)).to eq(powershell_hash) 316 | end 317 | 318 | it 'raises an error if an unknown type is passed' do 319 | expect { described_class.format_powershell_value(described_class) }.to raise_error(/unsupported type Module/) 320 | end 321 | end 322 | 323 | describe '.custom_powershell_property' do 324 | it 'returns a powershell hash with the name and expression keys' do 325 | expect(described_class.custom_powershell_property('apple', '$_.SomeValue / 5')).to eq("@{Name = 'apple'; Expression = {$_.SomeValue / 5}}") 326 | end 327 | end 328 | end 329 | -------------------------------------------------------------------------------- /spec/unit/pwsh/version_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Pwsh do 6 | it 'has a version number' do 7 | expect(described_class::VERSION).not_to be_nil 8 | expect(described_class::VERSION).to be_a(String) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/unit/pwsh/windows_powershell_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: false 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Pwsh::WindowsPowerShell do 6 | describe '.version' do 7 | context 'on non-Windows platforms', unless: Pwsh::Util.on_windows? do 8 | it 'is not defined' do 9 | expect(defined?(described_class.version)).to be_nil 10 | end 11 | end 12 | 13 | context 'On Windows', if: Pwsh::Util.on_windows? do 14 | context 'when Windows PowerShell version is greater than three' do 15 | it 'detects a Windows PowerShell version' do 16 | allow_any_instance_of(Win32::Registry).to receive(:[]).with('PowerShellVersion').and_return('5.0.10514.6') 17 | expect(described_class.version).to eq('5.0.10514.6') 18 | end 19 | 20 | it 'calls the Windows PowerShell three registry path' do 21 | reg_key = instance_double(bob) 22 | allow(reg_key).to receive(:[]).with('PowerShellVersion').and_return('5.0.10514.6') 23 | allow_any_instance_of(Win32::Registry).to receive(:open).with('SOFTWARE\Microsoft\PowerShell\3\PowerShellEngine', Win32::Registry::KEY_READ | 0x100).and_yield(reg_key) 24 | 25 | described_class.version 26 | end 27 | 28 | it 'does not call Windows PowerShell one registry path' do 29 | reg_key = instance_double(bob) 30 | allow(reg_key).to receive(:[]).with('PowerShellVersion').and_return('5.0.10514.6') 31 | allow_any_instance_of(Win32::Registry).to receive(:open).with('SOFTWARE\Microsoft\PowerShell\3\PowerShellEngine', Win32::Registry::KEY_READ | 0x100).and_yield(reg_key) 32 | expect_any_instance_of(Win32::Registry).not_to receive(:open).with('SOFTWARE\Microsoft\PowerShell\1\PowerShellEngine', Win32::Registry::KEY_READ | 0x100) 33 | 34 | described_class.version 35 | end 36 | end 37 | 38 | context 'when Windows PowerShell version is less than three' do 39 | it 'detects a Windows PowerShell version' do 40 | allow_any_instance_of(Win32::Registry).to receive(:[]).with('PowerShellVersion').and_return('2.0') 41 | 42 | expect(described_class.version).to eq('2.0') 43 | end 44 | 45 | it 'calls the Windows PowerShell one registry path' do 46 | reg_key = instance_double(bob) 47 | allow(reg_key).to receive(:[]).with('PowerShellVersion').and_return('2.0') 48 | allow_any_instance_of(Win32::Registry).to receive(:open).with('SOFTWARE\Microsoft\PowerShell\1\PowerShellEngine', 49 | Win32::Registry::KEY_READ | 0x100).and_yield(reg_key) 50 | allow_any_instance_of(Win32::Registry).to receive(:open).with('SOFTWARE\Microsoft\PowerShell\3\PowerShellEngine', 51 | Win32::Registry::KEY_READ | 0x100).and_raise(Win32::Registry::Error.new(2), 'nope') 52 | 53 | expect(described_class.version).to eq('2.0') 54 | end 55 | end 56 | 57 | context 'when Windows PowerShell is not installed' do 58 | it 'returns nil and not throw' do 59 | allow_any_instance_of(Win32::Registry).to receive(:open).with('SOFTWARE\Microsoft\PowerShell\1\PowerShellEngine', 60 | Win32::Registry::KEY_READ | 0x100).and_raise(Win32::Registry::Error.new(2), 'nope') 61 | allow_any_instance_of(Win32::Registry).to receive(:open).with('SOFTWARE\Microsoft\PowerShell\3\PowerShellEngine', 62 | Win32::Registry::KEY_READ | 0x100).and_raise(Win32::Registry::Error.new(2), 'nope') 63 | 64 | expect(described_class.version).to be_nil 65 | end 66 | end 67 | end 68 | end 69 | 70 | describe '.compatible_version?' do 71 | context 'on non-Windows platforms', unless: Pwsh::Util.on_windows? do 72 | it 'returns false' do 73 | expect(described_class.compatible_version?).to be(false) 74 | end 75 | end 76 | 77 | context 'On Windows', if: Pwsh::Util.on_windows? do 78 | context 'when the Windows PowerShell major version is nil' do 79 | it 'returns false' do 80 | expect(described_class).to receive(:version).and_return(nil) 81 | expect(described_class.compatible_version?).to be(false) 82 | end 83 | end 84 | 85 | context 'when the Windows PowerShell major version is less than two' do 86 | it 'returns false' do 87 | expect(described_class).to receive(:version).and_return('1.0') 88 | expect(described_class.compatible_version?).to be(false) 89 | end 90 | end 91 | 92 | context 'when the Windows PowerShell major version is two' do 93 | it 'returns true if .NET 3.5 is installed' do 94 | expect(described_class).to receive(:version).and_return('2.0') 95 | allow_any_instance_of(Win32::Registry).to receive(:open).with('SOFTWARE\Microsoft\NET Framework Setup\NDP\v3.5', Win32::Registry::KEY_READ | 0x100).and_yield 96 | expect(described_class.compatible_version?).to be(true) 97 | end 98 | 99 | it 'returns false if .NET 3.5 is not installed' do 100 | expect(described_class).to receive(:version).and_return('2.0') 101 | allow_any_instance_of(Win32::Registry).to receive(:open).with('SOFTWARE\Microsoft\NET Framework Setup\NDP\v3.5', Win32::Registry::KEY_READ | 0x100).and_raise(Win32::Registry::Error, 1) 102 | expect(described_class.compatible_version?).to be(false) 103 | end 104 | end 105 | 106 | context 'when the Windows PowerShell major version is three' do 107 | it 'returns true' do 108 | expect(described_class).to receive(:version).and_return('3.0') 109 | expect(described_class.compatible_version?).to be(true) 110 | end 111 | end 112 | 113 | context 'when the Windows PowerShell major version is greater than three' do 114 | it 'returns true' do 115 | expect(described_class).to receive(:version).and_return('4.0') 116 | expect(described_class.compatible_version?).to be(true) 117 | end 118 | end 119 | end 120 | end 121 | end 122 | --------------------------------------------------------------------------------