foreman_wds 0.0.1

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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/.eslintrc.json +20 -0
  3. data/.gitignore +17 -0
  4. data/.rubocop.yml +66 -0
  5. data/.travis.yml +5 -0
  6. data/Gemfile +6 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +33 -0
  9. data/Rakefile +10 -0
  10. data/app/assets/javascripts/foreman_wds/wds_servers.js +19 -0
  11. data/app/assets/javascripts/host_edit_extensions.js +50 -0
  12. data/app/controllers/concerns/foreman/controller/parameters/wds_server.rb +19 -0
  13. data/app/controllers/concerns/foreman_wds/hosts_controller_extensions.rb +18 -0
  14. data/app/controllers/concerns/foreman_wds/unattended_controller_extensions.rb +21 -0
  15. data/app/controllers/wds_servers_controller.rb +85 -0
  16. data/app/lib/foreman_wds/wds_boot_image.rb +11 -0
  17. data/app/lib/foreman_wds/wds_image.rb +89 -0
  18. data/app/lib/foreman_wds/wds_install_image.rb +14 -0
  19. data/app/models/concerns/foreman_wds/compute_resource_extensions.rb +7 -0
  20. data/app/models/concerns/foreman_wds/host_extensions.rb +109 -0
  21. data/app/models/concerns/foreman_wds/nic_extensions.rb +40 -0
  22. data/app/models/foreman_wds/wds_facet.rb +48 -0
  23. data/app/models/wds_server.rb +239 -0
  24. data/app/services/wds_image_cache.rb +66 -0
  25. data/app/views/foreman_wds/unattend_2016.xml.erb +220 -0
  26. data/app/views/foreman_wds/windows_ptable.xml.erb +43 -0
  27. data/app/views/hosts/provision_method/wds/_form.html.erb +12 -0
  28. data/app/views/wds_servers/_form.html.erb +28 -0
  29. data/app/views/wds_servers/_image_select.html.erb +27 -0
  30. data/app/views/wds_servers/_server_select.html.erb +12 -0
  31. data/app/views/wds_servers/clients/_list.html.erb +43 -0
  32. data/app/views/wds_servers/edit.html.erb +3 -0
  33. data/app/views/wds_servers/images/_list.html.erb +36 -0
  34. data/app/views/wds_servers/index.html.erb +25 -0
  35. data/app/views/wds_servers/new.html.erb +3 -0
  36. data/app/views/wds_servers/show.html.erb +46 -0
  37. data/config/routes.rb +26 -0
  38. data/db/migrate/20180426133700_add_wds_servers.rb +23 -0
  39. data/db/seeds.d/50_ptable_templates.rb +16 -0
  40. data/db/seeds.d/50_unattend_templates.rb +19 -0
  41. data/foreman_wds.gemspec +23 -0
  42. data/lib/foreman_wds.rb +4 -0
  43. data/lib/foreman_wds/engine.rb +70 -0
  44. data/lib/foreman_wds/version.rb +3 -0
  45. data/test/foreman_wds_test.rb +11 -0
  46. data/test/test_helper.rb +4 -0
  47. metadata +147 -0
@@ -0,0 +1,14 @@
1
+ class ForemanWds::WdsInstallImage < ForemanWds::WdsImage
2
+ attr_accessor :compression, :dependent_files, :format, :image_group,
3
+ :partition_style, :security, :staged, :unattend_file_present
4
+
5
+ def initialize(json = {})
6
+ super json
7
+ end
8
+
9
+ def reload
10
+ return false if wds_server.nil?
11
+ @json = wds_server.install_image(name)
12
+ load!
13
+ end
14
+ end
@@ -0,0 +1,7 @@
1
+ module ForemanWds
2
+ module ComputeResourceExtensions
3
+ def capabilities
4
+ super + [:wds]
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,109 @@
1
+ module ForemanWds
2
+ module HostExtensions
3
+ def self.prepended(base)
4
+ base.class_eval do
5
+ after_build :ensure_wds_client
6
+ before_provision :remove_wds_client
7
+
8
+ has_one :wds_facet,
9
+ class_name: '::ForemanWds::WdsFacet',
10
+ foreign_key: :host_id,
11
+ inverse_of: :host,
12
+ dependent: :destroy
13
+ end
14
+ end
15
+
16
+ delegate :wds_server, to: :wds_facet
17
+
18
+ def wds_boot_image
19
+ ensure_wds_facet.boot_image
20
+ end
21
+
22
+ def wds_boot_image_name
23
+ ensure_wds_facet.boot_image_name
24
+ end
25
+
26
+ def wds_install_image
27
+ ensure_wds_facet.install_image
28
+ end
29
+
30
+ def wds_install_image_file
31
+ ensure_wds_facet.install_image_file
32
+ end
33
+
34
+ def wds_install_image_group
35
+ ensure_wds_facet.install_image_group
36
+ end
37
+
38
+ def wds_install_image_name
39
+ ensure_wds_facet.install_image_name
40
+ end
41
+
42
+ def capabilities
43
+ return super + [:wds] if compute_resource && (os.nil? || os.family == 'Windows')
44
+ super
45
+ end
46
+
47
+ def bare_metal_capabilities
48
+ return super + [:wds] if os.nil? || os.family == 'Windows'
49
+ super
50
+ end
51
+
52
+ def can_be_built?
53
+ super || (wds? && !build?)
54
+ end
55
+
56
+ def wds_build?
57
+ provision_method == 'wds'
58
+ end
59
+
60
+ def wds?
61
+ managed? && wds_build? && SETTINGS[:unattended]
62
+ end
63
+
64
+ def ensure_wds_facet
65
+ wds_facet || build_wds_facet
66
+ end
67
+
68
+ def unattend_pass(password, suffix = nil)
69
+ if suffix.nil?
70
+ suffix = password
71
+ password = Base64.decode64(root_pass)
72
+ end
73
+ Base64.encode64(Encoding::Converter.new('UTF-8', 'UTF-16LE', undef: nil).convert(password + suffix)).delete!("\n")
74
+ end
75
+
76
+ private
77
+
78
+ def ensure_wds_client
79
+ raise NotImplementedError, 'Not implemented yet'
80
+ return unless wds?
81
+
82
+ client = wds_server.client(self) || wds_server.create_client(self)
83
+
84
+ Rails.logger.info client
85
+ true
86
+ rescue ScriptError, StandardError => ex
87
+ Rails.logger.error "Failed to ensure WDS client, #{ex}"
88
+ # false
89
+ end
90
+
91
+ def remove_wds_client
92
+ raise NotImplementedError, 'Not implemented yet'
93
+ return unless wds?
94
+
95
+ client = wds_server.client(self)
96
+ return unless client
97
+
98
+ wds_server.delete_client(self)
99
+ true
100
+ rescue ScriptError, StandardError => ex
101
+ Rails.logger.error "Failed to remove WDS client, #{ex}"
102
+ # false
103
+ end
104
+ end
105
+ end
106
+
107
+ class ::Host::Managed::Jail < Safemode::Jail
108
+ allow :unattend_pass, :wds_facet, :wds_server, :wds_install_image_file, :wds_install_image_group, :wds_install_image_name
109
+ end
@@ -0,0 +1,40 @@
1
+ module ForemanWds
2
+ module NicExtensions
3
+ def dhcp_update_required?
4
+ return super if host.nil? || !host.wds? || host.wds_facet.nil?
5
+
6
+ # DHCP entry for WDS depends on build mode
7
+ return true if host.build_changed?
8
+ end
9
+
10
+ def boot_server
11
+ return super if host.nil? || !host.wds? || host.wds_facet.nil?
12
+
13
+ host.wds_server.next_server_ip if host.build? # TODO: Support choosing local boot method
14
+ end
15
+
16
+ def dhcp_records
17
+ # Always recalculate dhcp records for WDS hosts, to allow different filename for the deleting and setting of a DHCP rebuild
18
+ @dhcp_records = nil if !host.nil? && host.wds?
19
+ super
20
+ end
21
+
22
+ def dhcp_attrs(record_mac)
23
+ data = super(record_mac)
24
+ return data if host.nil? || !host.wds?
25
+
26
+ arch = WdsServer.wdsify_architecture(host.architecture)
27
+ build_stage = host.build? ? :pxe : :local
28
+ build_type = host.pxe_loader =~ /UEFI/i ? :uefi : :bios
29
+
30
+ if build_stage == :pxe # TODO: Support choosing local boot method
31
+ data[:filename] = WdsServer.bootfile_path(arch, build_type, build_stage)
32
+ end
33
+
34
+ # Don't compare filenames if trying to check for collisions, WDS entries differ on file depending on build mode
35
+ data.delete :filename if caller_locations.map(&:label).include?('dhcp_conflict_detected?')
36
+
37
+ data
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,48 @@
1
+ module ForemanWds
2
+ class WdsFacet < ApplicationRecord
3
+ class Jail < Safemode::Jail
4
+ allow :boot_image_file, :boot_image_name, :install_image_file, :install_image_group, :install_image_name
5
+ end
6
+
7
+ include Facets::Base
8
+
9
+ belongs_to :wds_server,
10
+ class_name: '::WdsServer',
11
+ inverse_of: :wds_facets
12
+
13
+ validates_lengths_from_database
14
+
15
+ validates :host, presence: true, allow_blank: false
16
+
17
+ validates :install_image_name, presence: true, allow_blank: false
18
+
19
+ def boot_image
20
+ return nil unless wds_server
21
+ @boot_image ||= if boot_image_name
22
+ wds_server.boot_image(boot_image_name)
23
+ else
24
+ wds_server.boot_images.first
25
+ end
26
+ end
27
+
28
+ def boot_image_file
29
+ return nil unless boot_image
30
+ @boot_image_file ||= boot_image.file_name
31
+ end
32
+
33
+ def install_image
34
+ return nil unless wds_server
35
+ @install_image ||= wds_server.install_image(install_image_name)
36
+ end
37
+
38
+ def install_image_file
39
+ return nil unless install_image
40
+ @install_image_file ||= install_image.file_name
41
+ end
42
+
43
+ def install_image_group
44
+ return nil unless install_image
45
+ @install_image_group ||= install_image.image_group
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,239 @@
1
+ class WdsServer < ApplicationRecord
2
+ class Jail < Safemode::Jail
3
+ allow :name, :shortname
4
+ end
5
+
6
+ # include Taxonomix
7
+ include Encryptable
8
+
9
+ extend FriendlyId
10
+ friendly_id :name
11
+ include Parameterizable::ByIdName
12
+ encrypts :password
13
+
14
+ validates_lengths_from_database
15
+
16
+ audited except: [:password]
17
+ has_associated_audits
18
+
19
+ before_destroy EnsureNotUsedBy.new(:hosts)
20
+
21
+ has_many :wds_facets,
22
+ class_name: '::ForemanWds::WdsFacet',
23
+ dependent: :nullify,
24
+ inverse_of: :wds_server
25
+
26
+ has_many :hosts,
27
+ class_name: '::Host::Managed',
28
+ dependent: :nullify,
29
+ inverse_of: :wds_server,
30
+ through: :wds_facets
31
+
32
+ validates :name, presence: true, uniqueness: true
33
+ validates :url, presence: true
34
+ validates :user, presence: true
35
+ validates :password, presence: true
36
+
37
+ scoped_search on: :name, complete_value: true
38
+ default_scope -> { order('wds_servers.name') }
39
+
40
+ def boot_image(name)
41
+ images(:boot, name).first
42
+ end
43
+
44
+ def install_image(name)
45
+ images(:install, name).first
46
+ end
47
+
48
+ def clients
49
+ objects = connection.run_wql('SELECT * FROM MSFT_WdsClient')[:msft_wdsclient] rescue nil
50
+ objects = nil if objects.empty?
51
+ objects ||= begin
52
+ data = connection.shell(:powershell) do |s|
53
+ s.run('Get-WdsClient | ConvertTo-Json -Compress')
54
+ end.stdout
55
+ data = '[]' if data.empty?
56
+
57
+ underscore_result([JSON.parse(data)].flatten)
58
+ end
59
+
60
+ objects
61
+ end
62
+
63
+ def client(host)
64
+ clients.find do |c|
65
+ [host.mac.upcase.tr(':', '-'), host.name].include?(c[:device_id]) || [host.name, host.shortname].include?(c[:device_name])
66
+ end
67
+ end
68
+
69
+ def boot_images
70
+ cache.cache(:boot_images) do
71
+ images(:boot)
72
+ end.each { |i| i.wds_server = self }
73
+ end
74
+
75
+ def install_images
76
+ cache.cache(:install_images) do
77
+ images(:install)
78
+ end.each { |i| i.wds_server = self }
79
+ end
80
+
81
+ def create_client(host)
82
+ raise NotImplementedError, 'Not finished yet'
83
+ ensure_unattend(host)
84
+
85
+ connection.shell(:powershell) do |sh|
86
+ # New-WdsClient -DeviceID '#{host.mac.upcase.delete ':'}' -DeviceName '#{host.name}' -WdsClientUnattend '#{unattend_file(host)}' -BootImagePath 'boot\\#{wdsify_architecture(host.architecture)}\\images\\#{(host.wds_boot_image || boot_images.first).file_name}' -PxePromptPolicy 'NoPrompt'
87
+ end
88
+ end
89
+
90
+ def delete_client(host)
91
+ raise NotImplementedError, 'Not finished yet'
92
+ delete_unattend(host)
93
+
94
+ connection.shell(:powershell) do |sh|
95
+ sh.run("Remove-WdsClient -DeviceID '#{host.mac.upcase.delete ':'}'")
96
+ end
97
+ end
98
+
99
+ def all_images
100
+ boot_images + install_images
101
+ end
102
+
103
+ def timezone
104
+ cache.cache(:timezone) do
105
+ connection.run_wql('SELECT Bias FROM Win32_TimeZone')[:xml_fragment].first[:bias].to_i * 60
106
+ end
107
+ end
108
+
109
+ def shortname
110
+ cache.cache(:shortname) do
111
+ connection.run_wql('SELECT Name FROM Win32_ComputerSystem')[:xml_fragment].first[:name]
112
+ end
113
+ end
114
+
115
+ def next_server_ip
116
+ IPSocket.getaddress URI(url).host
117
+ rescue SocketError
118
+ ::Rails.logger.info "Failed to look up IP of WDS server #{name}"
119
+ nil
120
+ end
121
+
122
+ def self.bootfile_path(architecture_name, loader = :bios, boot_type = :pxe)
123
+ file_name = nil
124
+ if boot_type == :local
125
+ file_name = 'bootmgfw.efi' if loader == :uefi
126
+ file_name = 'abortpxe.com' if loader == :bios
127
+ elsif boot_type == :pxe
128
+ file_name = 'wdsmgfw.efi' if loader == :uefi
129
+ file_name = 'wdsnbp.com' if loader == :bios
130
+ end
131
+ raise ArgumentError, 'Invalid loader or boot type provided' if file_name.nil?
132
+
133
+ "boot\\\\#{architecture_name}\\\\#{file_name}"
134
+ end
135
+
136
+ def self.wdsify_architecture(architecture)
137
+ wds_arch = ForemanWds::WdsImage::WDS_IMAGE_ARCHES.find_index { |arch| arch =~ architecture.name }
138
+ ForemanWds::WdsImage::WDS_ARCH_NAMES[wds_arch]
139
+ end
140
+
141
+ def test_connection
142
+ connection.run_wql('SELECT * FROM Win32_UTCTime').key? :win32_utc_time
143
+ rescue StandardError
144
+ false
145
+ end
146
+
147
+ def refresh_cache
148
+ cache.refresh
149
+ end
150
+
151
+ private
152
+
153
+ def unattend_path
154
+ cache.cache(:unattend_path) do
155
+ JSON.parse(connection.shell(:powershell) do |sh|
156
+ sh.run('Get-ItemProperty -Path HKLM:\SYSTEM\CurrentControlSet\Services\WDSServer\Providers\WDSTFTP -Name RootFolder | select RootFolder | ConvertTo-Json -Compress')
157
+ end, symbolize_names: true)[:RootFolder]
158
+ end
159
+ end
160
+
161
+ def unattend_file(host)
162
+ "#{unattend_path}\\#{host.mac.tr ':', '_'}.xml"
163
+ end
164
+
165
+ def target_image_for(host)
166
+ source_image = host.wds_install_image
167
+ ForemanWds::WdsInstallImage.new(
168
+ wds_server: self,
169
+ file_name: "install-#{host.mac.tr ':', '_'}.wim",
170
+ image_group: SETTINGS[:wds_unattend_group] || source_image.image_group,
171
+ image_name: "#{source_image.image_name} (specialized for #{host.name}/#{host.mac})"
172
+ )
173
+ end
174
+
175
+ def ensure_unattend(host)
176
+ raise NotImplementedException, 'TODO: Not implemented yet'
177
+ connection.shell(:powershell) do |sh|
178
+ target_image = target_image_for(host)
179
+ # TODO: render template, send as heredoc
180
+ # sh.run("$unattend_render = @'\n#{unattend_template}\n'@")
181
+ # sh.run("New-Item -Path '#{unattend_file(host)}' -ItemType 'file' -Value $unattend_render")
182
+ if SETTINGS[:wds_unattend_group]
183
+ # New-WdsInstallImageGroup -Name #{SETTINGS[:wds_unattend_group]}
184
+ # Export-WdsInstallImage -ImageGroup <Group> ...
185
+ # Import-WdsInstallImage -ImageGroup #{SETTINGS[:wds_unattend_group]} -UnattendFile '#{unattend_file(host)}' -OverwriteUnattend ...
186
+ else
187
+ source_image = host.wds_facet.install_image
188
+
189
+ sh.run("Copy-WdsInstallImage -ImageGroup '#{source_image.image_group}' -FileName '#{source_image.file_name}' -ImageName '#{source_image.image_name}' -NewFileName '#{target_image.file_name}' -NewImageName '#{target_image.image_name}'")
190
+ sh.run("Set-WdsInstallImage -ImageGroup '#{target_image.image_group}' -FileName '#{target_image.file_name}' -ImageName '#{target_image.image_name}' -DisplayOrder 99999 -UnattendFile '#{unattend_file(host)}' -OverwriteUnattend")
191
+ end
192
+ end
193
+ end
194
+
195
+ def delete_unattend(host)
196
+ image = target_image_for(host)
197
+
198
+ connection.shell(:powershell) do |sh|
199
+ sh.run("Remove-WdsInstallImage -ImageGroup '#{image.image_group}' -ImageName '#{image.image_name}' -FileName '#{image.file_name}'")
200
+ sh.run("Remove-Item -Path '#{unattend_file(host)}'")
201
+ end.errcode.zero?
202
+ end
203
+
204
+
205
+ def images(type, name = nil)
206
+ raise ArgumentError, 'Type must be :boot or :install' unless %i[boot install].include? type
207
+
208
+ objects = connection.run_wql("SELECT * FROM MSFT_Wds#{type.to_s.capitalize}Image#{" WHERE Name=\"#{name}\"" if name}")["msft_wds#{type}image".to_sym] rescue nil
209
+ objects = nil if objects.empty?
210
+ objects ||= underscore_result([JSON.parse(connection.shell(:powershell) do |s|
211
+ s.run("Get-WDS#{type.to_s.capitalize}Image #{"-ImageName '#{name.sub("'", "`'")}'" if name} | ConvertTo-Json -Compress")
212
+ end.stdout)].flatten)
213
+
214
+ objects.map do |obj|
215
+ ForemanWds.const_get("Wds#{type.to_s.capitalize}Image").new obj.merge(wds_server: self)
216
+ end
217
+ end
218
+
219
+ def underscore_result(result)
220
+ case result
221
+ when Array
222
+ result.map { |v| underscore_result(v) }
223
+ when Hash
224
+ Hash[result.map { |k, v| [k.to_s.underscore.to_sym, underscore_result(v)] }]
225
+ else
226
+ result
227
+ end
228
+ end
229
+
230
+ def cache
231
+ @cache ||= WdsImageCache.new(self)
232
+ end
233
+
234
+ def connection
235
+ require 'winrm'
236
+
237
+ @connection ||= WinRM::Connection.new endpoint: url, transport: :negotiate, user: user, password: password
238
+ end
239
+ end