kitchen-dsc 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,590 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Kitchen::Provisioner::Dsc do
4
+ subject(:provisioner) { build_provisioner }
5
+
6
+ # Every public command is wrapped by Kitchen::Configurable#wrap_shell_code,
7
+ # which injects `$env:TEST_KITCHEN` and — only when ENV["CI"] is set —
8
+ # `$env:CI`. Specs therefore assert on the PowerShell that this gem generates
9
+ # rather than on whole-string equality, so they behave the same on a laptop
10
+ # and on a runner.
11
+
12
+ describe "plugin registration" do
13
+ it "declares provisioner API version 2" do
14
+ expect(provisioner.diagnose_plugin[:api_version]).to eq(2)
15
+ end
16
+
17
+ it "exposes a writable tmp_dir" do
18
+ provisioner.tmp_dir = 'C:\\temp'
19
+
20
+ expect(provisioner.tmp_dir).to eq('C:\\temp')
21
+ end
22
+ end
23
+
24
+ describe "default configuration" do
25
+ it "stages configuration scripts out of examples/" do
26
+ expect(provisioner[:configuration_script_folder]).to eq("examples")
27
+ expect(provisioner[:configuration_script]).to eq("dsc_configuration.ps1")
28
+ end
29
+
30
+ it "looks for DSC resources under modules/" do
31
+ expect(provisioner[:modules_path]).to eq("modules")
32
+ end
33
+
34
+ it "targets WMF 4 and force-bootstraps NuGet" do
35
+ expect(provisioner[:dsc_local_configuration_manager_version]).to eq("wmf4")
36
+ expect(provisioner[:nuget_force_bootstrap]).to be(true)
37
+ end
38
+
39
+ context "when the suite is named" do
40
+ def suite_name
41
+ "webserver"
42
+ end
43
+
44
+ it "derives the configuration name from the suite" do
45
+ expect(provisioner[:configuration_name]).to eq(["webserver"])
46
+ end
47
+ end
48
+ end
49
+
50
+ describe "#finalize_config!" do
51
+ # finalize_config! is called for us when KitchenHelpers builds the
52
+ # instance, so the merged LCM settings are already on config by this point.
53
+
54
+ it "replaces the LCM config with the resolved settings" do
55
+ expect(provisioner[:dsc_local_configuration_manager]).to include(
56
+ allow_module_overwrite: false,
57
+ configuration_mode: "ApplyAndAutoCorrect",
58
+ configuration_mode_frequency_mins: 30,
59
+ reboot_if_needed: false,
60
+ refresh_mode: "PUSH",
61
+ refresh_frequency_mins: 15
62
+ )
63
+ end
64
+
65
+ it "renders an unset certificate_id as a PowerShell null" do
66
+ expect(provisioner[:dsc_local_configuration_manager][:certificate_id]).to eq("$null")
67
+ end
68
+
69
+ it "keeps caller-supplied LCM settings" do
70
+ provisioner = build_provisioner(
71
+ dsc_local_configuration_manager: {
72
+ configuration_mode: "ApplyOnly",
73
+ reboot_if_needed: true,
74
+ }
75
+ )
76
+
77
+ expect(provisioner[:dsc_local_configuration_manager]).to include(
78
+ configuration_mode: "ApplyOnly",
79
+ reboot_if_needed: true
80
+ )
81
+ end
82
+
83
+ context "when targeting WMF 5" do
84
+ subject(:provisioner) { build_provisioner(dsc_local_configuration_manager_version: "wmf5") }
85
+
86
+ it "uses the WMF 5 defaults and settings" do
87
+ expect(provisioner[:dsc_local_configuration_manager]).to include(
88
+ action_after_reboot: "StopConfiguration",
89
+ debug_mode: "All",
90
+ configuration_mode_frequency_mins: 15,
91
+ refresh_frequency_mins: 30
92
+ )
93
+ end
94
+ end
95
+ end
96
+
97
+ describe "#install_command" do
98
+ subject(:command) { provisioner.install_command }
99
+
100
+ it "silences the PowerShell progress stream" do
101
+ expect(command).to include("$ProgressPreference = 'SilentlyContinue';")
102
+ end
103
+
104
+ it "declares and applies the SetupLCM configuration" do
105
+ expect(command).to include("configuration SetupLCM")
106
+ expect(command).to include("$null = SetupLCM")
107
+ expect(command).to include("Set-DscLocalConfigurationManager -Path ./SetupLCM | out-null")
108
+ end
109
+
110
+ it "emits the resolved LCM settings" do
111
+ expect(command).to include("AllowModuleOverwrite = [bool]::Parse('false')")
112
+ expect(command).to include("ConfigurationMode = 'ApplyAndAutoCorrect'")
113
+ expect(command).to include("RefreshMode = 'PUSH'")
114
+ expect(command).to include("CertificateID = $null")
115
+ end
116
+
117
+ context "with a certificate thumbprint" do
118
+ subject(:provisioner) do
119
+ build_provisioner(dsc_local_configuration_manager: { certificate_id: "ABC123" })
120
+ end
121
+
122
+ it "single-quotes the thumbprint" do
123
+ expect(command).to include("CertificateID = 'ABC123'")
124
+ end
125
+ end
126
+
127
+ context "when targeting WMF 5" do
128
+ subject(:provisioner) { build_provisioner(dsc_local_configuration_manager_version: "wmf5") }
129
+
130
+ it "emits the WMF 5 meta-configuration" do
131
+ expect(command).to include("[DSCLocalConfigurationManager()]")
132
+ expect(command).to include("ActionAfterReboot = 'StopConfiguration'")
133
+ expect(command).to include("DebugMode = 'All'")
134
+ end
135
+ end
136
+
137
+ context "when the LCM version is unrecognized" do
138
+ subject(:provisioner) { build_provisioner(dsc_local_configuration_manager_version: "wmf9000") }
139
+
140
+ # DscLcmConfiguration::Factory falls through to LcmBase for anything it
141
+ # does not recognize — including the "wmf4" default this gem ships.
142
+ it "falls back to the base LCM configuration" do
143
+ expect(command).to include("LocalConfigurationManager")
144
+ expect(command).not_to include("ActionAfterReboot")
145
+ end
146
+ end
147
+ end
148
+
149
+ describe "#init_command" do
150
+ subject(:command) { provisioner.init_command }
151
+
152
+ it "creates the remote configuration directory" do
153
+ expect(command).to include(
154
+ "mkdir (split-path (join-path #{KitchenHelpers::DEFAULT_ROOT_PATH} " \
155
+ "configuration/dsc_configuration.ps1)) -force | out-null"
156
+ )
157
+ end
158
+
159
+ context "on WMF 4 with gallery modules requested" do
160
+ subject(:provisioner) { build_provisioner(modules_from_gallery: %w{xNetworking}) }
161
+
162
+ # Gallery installation needs PowerShellGet, which ships with WMF 5.
163
+ it "does not attempt to install them" do
164
+ expect(command).not_to include("install-module")
165
+ expect(command).not_to include("install-packageprovider")
166
+ end
167
+ end
168
+
169
+ context "on WMF 5 without gallery modules" do
170
+ subject(:provisioner) { build_provisioner(dsc_local_configuration_manager_version: "wmf5") }
171
+
172
+ it "does not attempt to install anything" do
173
+ expect(command).not_to include("install-module")
174
+ end
175
+ end
176
+
177
+ context "on WMF 5 with gallery modules" do
178
+ subject(:provisioner) do
179
+ build_provisioner(
180
+ dsc_local_configuration_manager_version: "wmf5",
181
+ modules_from_gallery: %w{xNetworking xWebAdministration}
182
+ )
183
+ end
184
+
185
+ it "bootstraps the NuGet package provider" do
186
+ expect(command).to include("install-packageprovider nuget -force -forcebootstrap | out-null")
187
+ end
188
+
189
+ it "installs each module from the default gallery" do
190
+ expect(command).to include("install-module -name 'xNetworking' -Repository PSGallery -force | out-null")
191
+ expect(command).to include("install-module -name 'xWebAdministration' -Repository PSGallery -force | out-null")
192
+ end
193
+
194
+ it "does not register a repository when no gallery_uri is given" do
195
+ expect(command).not_to include("register-packagesource")
196
+ end
197
+
198
+ context "with nuget_force_bootstrap disabled" do
199
+ subject(:provisioner) do
200
+ build_provisioner(
201
+ dsc_local_configuration_manager_version: "wmf5",
202
+ modules_from_gallery: %w{xNetworking},
203
+ nuget_force_bootstrap: false
204
+ )
205
+ end
206
+
207
+ it "skips the package provider bootstrap" do
208
+ expect(command).not_to include("install-packageprovider")
209
+ end
210
+ end
211
+
212
+ context "with a private gallery_uri and no gallery_name" do
213
+ subject(:provisioner) do
214
+ build_provisioner(
215
+ dsc_local_configuration_manager_version: "wmf5",
216
+ modules_from_gallery: %w{xNetworking},
217
+ gallery_uri: "https://gallery.example.invalid/api/v2"
218
+ )
219
+ end
220
+
221
+ it "registers the source under the fallback name 'testing'" do
222
+ expect(command).to include(
223
+ "register-packagesource -providername PowerShellGet -name 'testing' " \
224
+ "-location 'https://gallery.example.invalid/api/v2' -force -trusted"
225
+ )
226
+ end
227
+
228
+ it "installs modules from that source" do
229
+ expect(command).to include("install-module -name 'xNetworking' -Repository testing -force | out-null")
230
+ end
231
+ end
232
+
233
+ context "with a named private gallery" do
234
+ subject(:provisioner) do
235
+ build_provisioner(
236
+ dsc_local_configuration_manager_version: "wmf5",
237
+ modules_from_gallery: %w{xNetworking},
238
+ gallery_uri: "https://gallery.example.invalid/api/v2",
239
+ gallery_name: "Internal"
240
+ )
241
+ end
242
+
243
+ it "registers and installs under the given name" do
244
+ expect(command).to include("-name 'Internal'")
245
+ expect(command).to include("install-module -name 'xNetworking' -Repository Internal -force | out-null")
246
+ end
247
+ end
248
+
249
+ context "when a module is given as a specification hash" do
250
+ subject(:provisioner) do
251
+ build_provisioner(
252
+ dsc_local_configuration_manager_version: "wmf5",
253
+ modules_from_gallery: [{ "Name" => "xWebAdministration", "RequiredVersion" => "1.10.0.0" }]
254
+ )
255
+ end
256
+
257
+ it "passes each key through as a PowerShell parameter" do
258
+ expect(command).to include("install-module -Name xWebAdministration -RequiredVersion 1.10.0.0")
259
+ end
260
+
261
+ it "defaults the repository to the resolved gallery" do
262
+ expect(command).to include("-repository PSGallery")
263
+ end
264
+ end
265
+
266
+ context "when a module specification sets its own repository" do
267
+ subject(:provisioner) do
268
+ build_provisioner(
269
+ dsc_local_configuration_manager_version: "wmf5",
270
+ gallery_name: "Internal",
271
+ gallery_uri: "https://gallery.example.invalid/api/v2",
272
+ modules_from_gallery: [{ "Name" => "xWebAdministration", "Repository" => "Other" }]
273
+ )
274
+ end
275
+
276
+ it "does not override it with the configured gallery" do
277
+ expect(command).to include("-Repository Other")
278
+ expect(command).not_to include("-repository Internal")
279
+ end
280
+ end
281
+
282
+ context "when a module specification passes Force" do
283
+ subject(:provisioner) do
284
+ build_provisioner(
285
+ dsc_local_configuration_manager_version: "wmf5",
286
+ modules_from_gallery: [{ "Name" => "xWebAdministration", "Force" => true }]
287
+ )
288
+ end
289
+
290
+ # -force is already appended to every install-module call; passing it
291
+ # twice is a PowerShell parameter-binding error.
292
+ it "drops it rather than emitting -Force twice" do
293
+ expect(command).to include("install-module -Name xWebAdministration -repository PSGallery -force")
294
+ expect(command).not_to include("-Force true")
295
+ end
296
+ end
297
+ end
298
+ end
299
+
300
+ describe "#create_sandbox" do
301
+ let(:sandbox) { create_sandbox_for(provisioner) }
302
+
303
+ before { write_kitchen_file("examples/dsc_configuration.ps1", "configuration default {}\n") }
304
+
305
+ it "stages the configuration script under configuration/" do
306
+ expect(File.read(File.join(sandbox, "configuration/dsc_configuration.ps1")))
307
+ .to eq("configuration default {}\n")
308
+ end
309
+
310
+ it "logs what it is staging" do
311
+ sandbox
312
+
313
+ expect(kitchen_log).to include("Staging DSC Resource Modules for copy to the SUT")
314
+ expect(kitchen_log).to include("Staging DSC configuration script for copy to the SUT")
315
+ end
316
+
317
+ context "with a custom configuration script location" do
318
+ subject(:provisioner) do
319
+ build_provisioner(
320
+ configuration_script_folder: "dsc",
321
+ configuration_script: "web.ps1"
322
+ )
323
+ end
324
+
325
+ before { write_kitchen_file("dsc/web.ps1", "configuration web {}\n") }
326
+
327
+ it "stages it under its own name" do
328
+ expect(File.read(File.join(sandbox, "configuration/web.ps1"))).to eq("configuration web {}\n")
329
+ end
330
+ end
331
+
332
+ context "when the configuration script is missing" do
333
+ subject(:provisioner) { build_provisioner(configuration_script: "absent.ps1") }
334
+
335
+ it "fails loudly rather than staging an empty sandbox" do
336
+ expect { create_sandbox_for(provisioner) }.to raise_error(Errno::ENOENT, /absent\.ps1/)
337
+ end
338
+ end
339
+
340
+ context "with a repository-style layout" do
341
+ before do
342
+ write_kitchen_file("modules/xExample/xExample.psd1")
343
+ write_kitchen_file("modules/xExample/DSCResources/xThing/xThing.psm1")
344
+ end
345
+
346
+ it "copies the modules directory into the sandbox" do
347
+ expect(files_under(File.join(sandbox, "modules"))).to contain_exactly(
348
+ "xExample/xExample.psd1",
349
+ "xExample/DSCResources/xThing/xThing.psm1"
350
+ )
351
+ end
352
+
353
+ context "and a custom modules_path" do
354
+ subject(:provisioner) { build_provisioner(modules_path: "dsc_resources") }
355
+
356
+ before { write_kitchen_file("dsc_resources/xOther/xOther.psd1") }
357
+
358
+ it "copies from that path instead" do
359
+ expect(files_under(File.join(sandbox, "modules"))).to include("xOther/xOther.psd1")
360
+ end
361
+ end
362
+
363
+ context "and no modules directory at all" do
364
+ subject(:provisioner) { build_provisioner(modules_path: "does_not_exist") }
365
+
366
+ it "stages the configuration script anyway" do
367
+ expect(File).to exist(File.join(sandbox, "configuration/dsc_configuration.ps1"))
368
+ end
369
+
370
+ it "says why it skipped the copy" do
371
+ sandbox
372
+
373
+ expect(kitchen_log).to include("was not found. Not moving to")
374
+ end
375
+ end
376
+ end
377
+
378
+ context "with a module-style layout" do
379
+ # A <root>/<basename>.psd1 manifest is what flips the provisioner from
380
+ # repository style to module style.
381
+ let(:module_name) { File.basename(kitchen_root) }
382
+
383
+ before do
384
+ write_kitchen_file("#{module_name}.psd1", "@{ ModuleVersion = '1.0' }\n")
385
+ write_kitchen_file("#{module_name}.psm1", "function Get-Thing {}\n")
386
+ write_kitchen_file("DSCResources/xThing/xThing.psm1")
387
+ write_kitchen_file("Gemfile", "source 'https://rubygems.org'\n")
388
+ write_kitchen_file("Gemfile.lock", "DEPENDENCIES\n")
389
+ write_kitchen_file("README.md", "# docs\n")
390
+ write_kitchen_file("LICENSE.txt", "Apache-2.0\n")
391
+ end
392
+
393
+ it "stages the whole module under modules/<module name>" do
394
+ expect(files_under(File.join(sandbox, "modules", module_name))).to contain_exactly(
395
+ "#{module_name}.psd1",
396
+ "#{module_name}.psm1",
397
+ "DSCResources/xThing/xThing.psm1",
398
+ "examples/dsc_configuration.ps1"
399
+ )
400
+ end
401
+
402
+ it "leaves repository housekeeping files behind" do
403
+ staged = files_under(File.join(sandbox, "modules", module_name))
404
+
405
+ expect(staged).not_to include("Gemfile", "Gemfile.lock", "README.md", "LICENSE.txt")
406
+ end
407
+ end
408
+ end
409
+
410
+ describe "#prepare_command" do
411
+ subject(:command) { provisioner.prepare_command }
412
+
413
+ it "copies staged modules onto the PSModulePath" do
414
+ expect(command).to include("if (Test-Path (join-path #{KitchenHelpers::DEFAULT_ROOT_PATH} 'modules'))")
415
+ expect(command).to include("copy-item -destination $env:programfiles/windowspowershell/modules/ -recurse -force")
416
+ end
417
+
418
+ it "fails on the SUT when the configuration script did not arrive" do
419
+ expect(command).to include('throw "Failed to find $ConfigurationScriptPath"')
420
+ end
421
+
422
+ it "dot-sources the configuration script" do
423
+ expect(command).to include("invoke-expression (get-content $ConfigurationScriptPath -raw)")
424
+ end
425
+
426
+ it "clears $Error and compiles the MOF for the suite's configuration" do
427
+ expect(command).to include("$Error.clear()")
428
+ expect(command).to include("$null = default -outputpath c:/configurations/default")
429
+ end
430
+
431
+ it "removes any MOF left over from a previous converge" do
432
+ expect(command).to include("Remove-Item -Recurse -Force c:/configurations/default")
433
+ end
434
+
435
+ it "fails when the configuration command was never defined" do
436
+ expect(command).to include('throw "Failed to create a configuration command default"')
437
+ end
438
+
439
+ it "logs the configuration it is compiling" do
440
+ command
441
+
442
+ expect(kitchen_log).to include("Generating the MOF script for the configuration default")
443
+ end
444
+
445
+ context "with several configuration names" do
446
+ subject(:provisioner) { build_provisioner(configuration_name: %w{web database}) }
447
+
448
+ it "emits a compile block per configuration" do
449
+ expect(command).to include("$null = web -outputpath c:/configurations/web")
450
+ expect(command).to include("$null = database -outputpath c:/configurations/database")
451
+ end
452
+ end
453
+
454
+ context "with a single configuration name given as a string" do
455
+ subject(:provisioner) { build_provisioner(configuration_name: "web") }
456
+
457
+ it "treats it as a one-element list" do
458
+ expect(command).to include("$null = web -outputpath c:/configurations/web")
459
+ end
460
+ end
461
+
462
+ context "with configuration data" do
463
+ subject(:provisioner) do
464
+ build_provisioner(
465
+ configuration_data: {
466
+ "AllNodes" => [{ "NodeName" => "*", "PSDscAllowPlainTextPassword" => true }],
467
+ }
468
+ )
469
+ end
470
+
471
+ it "assigns it to a PowerShell hashtable before compiling" do
472
+ expect(command).to include("$ConfigurationData = @{")
473
+ expect(command).to include('"AllNodes" =')
474
+ expect(command).to include('"NodeName" = "*"')
475
+ end
476
+
477
+ it "renders nested arrays as PowerShell arrays" do
478
+ expect(command).to include("@(")
479
+ end
480
+
481
+ # ps_hash stringifies every scalar, so Ruby booleans reach DSC quoted.
482
+ it "renders booleans as quoted strings" do
483
+ expect(command).to include('"PSDscAllowPlainTextPassword" = "true"')
484
+ end
485
+
486
+ it "passes the hashtable to the configuration" do
487
+ expect(command).to include("-configurationdata $ConfigurationData")
488
+ end
489
+
490
+ # `configuration_data_variable:` with an empty value in kitchen.yml parses
491
+ # as nil, which must not produce a bare `$` in the generated script.
492
+ context "with the variable name explicitly blanked out" do
493
+ subject(:provisioner) do
494
+ build_provisioner(
495
+ configuration_data: { "AllNodes" => [] },
496
+ configuration_data_variable: nil
497
+ )
498
+ end
499
+
500
+ it "falls back to $ConfigurationData" do
501
+ expect(command).to include("$ConfigurationData = @{")
502
+ expect(command).to include("-configurationdata $ConfigurationData")
503
+ end
504
+ end
505
+
506
+ context "under a custom variable name" do
507
+ subject(:provisioner) do
508
+ build_provisioner(
509
+ configuration_data: { "AllNodes" => [] },
510
+ configuration_data_variable: "MyData"
511
+ )
512
+ end
513
+
514
+ it "assigns and passes that variable" do
515
+ expect(command).to include("$MyData = @{")
516
+ expect(command).to include("-configurationdata $MyData")
517
+ end
518
+ end
519
+ end
520
+
521
+ context "without configuration data" do
522
+ it "assigns nothing" do
523
+ expect(command).not_to include("$ConfigurationData = @{")
524
+ end
525
+
526
+ # Known wart: the -configurationdata argument is emitted unconditionally,
527
+ # so the compile references an undefined variable. DSC tolerates it
528
+ # because $null is a valid ConfigurationData value.
529
+ it "still passes the (undefined) configuration data variable" do
530
+ expect(command).to include("-configurationdata $ConfigurationData")
531
+ end
532
+ end
533
+ end
534
+
535
+ describe "#run_command" do
536
+ subject(:command) { provisioner.run_command }
537
+
538
+ it "starts and waits on the DSC configuration job" do
539
+ expect(command).to include("$job = start-dscconfiguration -Path c:/configurations/default -force")
540
+ expect(command).to include("$job | wait-job")
541
+ end
542
+
543
+ it "surfaces the job's verbose stream" do
544
+ expect(command).to include("$verbose_output = $job.childjobs[0].verbose")
545
+ end
546
+
547
+ it "reboots and exits 35 when DSC asks for a reboot" do
548
+ expect(command).to include("shutdown /r /t 15")
549
+ expect(command).to include("exit 35")
550
+ end
551
+
552
+ it "exits non-zero when the job reported errors" do
553
+ expect(command).to include("$dsc_errors = $job.childjobs[0].Error")
554
+ expect(command).to include("exit 1")
555
+ end
556
+
557
+ it "logs the configuration it is applying" do
558
+ command
559
+
560
+ expect(kitchen_log).to include("Running the configuration default")
561
+ end
562
+
563
+ it "retries on the reboot exit code" do
564
+ command
565
+
566
+ expect(provisioner[:retry_on_exit_code]).to eq([35])
567
+ expect(provisioner[:max_retries]).to eq(3)
568
+ end
569
+
570
+ context "when the user configured their own retry behaviour" do
571
+ subject(:provisioner) { build_provisioner(retry_on_exit_code: [1, 2], max_retries: 5) }
572
+
573
+ it "leaves it alone" do
574
+ command
575
+
576
+ expect(provisioner[:retry_on_exit_code]).to eq([1, 2])
577
+ expect(provisioner[:max_retries]).to eq(5)
578
+ end
579
+ end
580
+
581
+ context "with several configuration names" do
582
+ subject(:provisioner) { build_provisioner(configuration_name: %w{web database}) }
583
+
584
+ it "applies each configuration in turn" do
585
+ expect(command).to include("start-dscconfiguration -Path c:/configurations/web -force")
586
+ expect(command).to include("start-dscconfiguration -Path c:/configurations/database -force")
587
+ end
588
+ end
589
+ end
590
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Kitchen::Dsc do
4
+ describe "VERSION" do
5
+ subject(:version) { described_class::VERSION }
6
+
7
+ it "is a frozen string" do
8
+ expect(version).to be_a(String).and be_frozen
9
+ end
10
+
11
+ it "is a dotted release number" do
12
+ expect(version).to match(/\A\d+\.\d+\.\d+(?:\.[A-Za-z0-9]+)*\z/)
13
+ end
14
+
15
+ it "matches the version the gemspec builds against" do
16
+ gemspec = Gem::Specification.load(File.expand_path("../../kitchen-dsc.gemspec", __dir__))
17
+
18
+ expect(gemspec.version.to_s).to eq(version)
19
+ end
20
+ end
21
+ end