1 #!powershell
2
3 # Copyright: (c) 2015, Jon Hawkesworth (@jhawkesworth) <figs@unity.demon.co.uk>
4 # Copyright: (c) 2017, Ansible Project
5 # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
6
7 #Requires -Module Ansible.ModuleUtils.Legacy
8 #Requires -Module Ansible.ModuleUtils.Backup
9
10 $ErrorActionPreference = 'Stop'
11
12 $params = Parse-Args -arguments $args -supports_check_mode $true
13 $check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false
14 $diff_mode = Get-AnsibleParam -obj $params -name "_ansible_diff" -type "bool" -default $false
15
16 # there are 4 modes to win_copy which are driven by the action plugins:
17 # explode: src is a zip file which needs to be extracted to dest, for use with multiple files
18 # query: win_copy action plugin wants to get the state of remote files to check whether it needs to send them
19 # remote: all copy action is happening remotely (remote_src=True)
20 # single: a single file has been copied, also used with template
21 $copy_mode = Get-AnsibleParam -obj $params -name "_copy_mode" -type "str" -default "single" -validateset "explode","query","remote","single"
22
23 # used in explode, remote and single mode
24 $src = Get-AnsibleParam -obj $params -name "src" -type "path" -failifempty ($copy_mode -in @("explode","process","single"))
25 $dest = Get-AnsibleParam -obj $params -name "dest" -type "path" -failifempty $true
26 $backup = Get-AnsibleParam -obj $params -name "backup" -type "bool" -default $false
27
28 # used in single mode
29 $original_basename = Get-AnsibleParam -obj $params -name "_original_basename" -type "str"
30
31 # used in query and remote mode
32 $force = Get-AnsibleParam -obj $params -name "force" -type "bool" -default $true
33
34 # used in query mode, contains the local files/directories/symlinks that are to be copied
35 $files = Get-AnsibleParam -obj $params -name "files" -type "list"
36 $directories = Get-AnsibleParam -obj $params -name "directories" -type "list"
37
38 $result = @{
39 changed = $false
40 }
41
42 if ($diff_mode) {
43 $result.diff = @{}
44 }
45
Copy-File($source, $dest)46 Function Copy-File($source, $dest) {
47 $diff = ""
48 $copy_file = $false
49 $source_checksum = $null
50 if ($force) {
51 $source_checksum = Get-FileChecksum -path $source
52 }
53
54 if (Test-Path -LiteralPath $dest -PathType Container) {
55 Fail-Json -obj $result -message "cannot copy file from '$source' to '$dest': dest is already a folder"
56 } elseif (Test-Path -LiteralPath $dest -PathType Leaf) {
57 if ($force) {
58 $target_checksum = Get-FileChecksum -path $dest
59 if ($source_checksum -ne $target_checksum) {
60 $copy_file = $true
61 }
62 }
63 } else {
64 $copy_file = $true
65 }
66
67 if ($copy_file) {
68 $file_dir = [System.IO.Path]::GetDirectoryName($dest)
69 # validate the parent dir is not a file and that it exists
70 if (Test-Path -LiteralPath $file_dir -PathType Leaf) {
71 Fail-Json -obj $result -message "cannot copy file from '$source' to '$dest': object at dest parent dir is not a folder"
72 } elseif (-not (Test-Path -LiteralPath $file_dir)) {
73 # directory doesn't exist, need to create
74 New-Item -Path $file_dir -ItemType Directory -WhatIf:$check_mode | Out-Null
75 $diff += "+$file_dir\`n"
76 }
77
78 if ($backup) {
79 $result.backup_file = Backup-File -path $dest -WhatIf:$check_mode
80 }
81
82 if (Test-Path -LiteralPath $dest -PathType Leaf) {
83 Remove-Item -LiteralPath $dest -Force -Recurse -WhatIf:$check_mode | Out-Null
84 $diff += "-$dest`n"
85 }
86
87 if (-not $check_mode) {
88 # cannot run with -WhatIf:$check_mode as if the parent dir didn't
89 # exist and was created above would still not exist in check mode
90 Copy-Item -LiteralPath $source -Destination $dest -Force | Out-Null
91 }
92 $diff += "+$dest`n"
93
94 $result.changed = $true
95 }
96
97 # ugly but to save us from running the checksum twice, let's return it for
98 # the main code to add it to $result
99 return ,@{ diff = $diff; checksum = $source_checksum }
100 }
101
Copy-Folder($source, $dest)102 Function Copy-Folder($source, $dest) {
103 $diff = ""
104
105 if (-not (Test-Path -LiteralPath $dest -PathType Container)) {
106 $parent_dir = [System.IO.Path]::GetDirectoryName($dest)
107 if (Test-Path -LiteralPath $parent_dir -PathType Leaf) {
108 Fail-Json -obj $result -message "cannot copy file from '$source' to '$dest': object at dest parent dir is not a folder"
109 }
110 if (Test-Path -LiteralPath $dest -PathType Leaf) {
111 Fail-Json -obj $result -message "cannot copy folder from '$source' to '$dest': dest is already a file"
112 }
113
114 New-Item -Path $dest -ItemType Container -WhatIf:$check_mode | Out-Null
115 $diff += "+$dest\`n"
116 $result.changed = $true
117 }
118
119 $child_items = Get-ChildItem -LiteralPath $source -Force
120 foreach ($child_item in $child_items) {
121 $dest_child_path = Join-Path -Path $dest -ChildPath $child_item.Name
122 if ($child_item.PSIsContainer) {
123 $diff += (Copy-Folder -source $child_item.Fullname -dest $dest_child_path)
124 } else {
125 $diff += (Copy-File -source $child_item.Fullname -dest $dest_child_path).diff
126 }
127 }
128
129 return $diff
130 }
131
Get-FileSize($path)132 Function Get-FileSize($path) {
133 $file = Get-Item -LiteralPath $path -Force
134 if ($file.PSIsContainer) {
135 $size = (Get-ChildItem -Literalpath $file.FullName -Recurse -Force | `
136 Where-Object { $_.PSObject.Properties.Name -contains 'Length' } | `
137 Measure-Object -Property Length -Sum).Sum
138 if ($null -eq $size) {
139 $size = 0
140 }
141 } else {
142 $size = $file.Length
143 }
144
145 $size
146 }
147
Extract-Zip($src, $dest)148 Function Extract-Zip($src, $dest) {
149 $archive = [System.IO.Compression.ZipFile]::Open($src, [System.IO.Compression.ZipArchiveMode]::Read, [System.Text.Encoding]::UTF8)
150 foreach ($entry in $archive.Entries) {
151 $archive_name = $entry.FullName
152
153 # FullName may be appended with / or \, determine if it is padded and remove it
154 $padding_length = $archive_name.Length % 4
155 if ($padding_length -eq 0) {
156 $is_dir = $false
157 $base64_name = $archive_name
158 } elseif ($padding_length -eq 1) {
159 $is_dir = $true
160 if ($archive_name.EndsWith("/") -or $archive_name.EndsWith("`\")) {
161 $base64_name = $archive_name.Substring(0, $archive_name.Length - 1)
162 } else {
163 throw "invalid base64 archive name '$archive_name'"
164 }
165 } else {
166 throw "invalid base64 length '$archive_name'"
167 }
168
169 # to handle unicode character, win_copy action plugin has encoded the filename
170 $decoded_archive_name = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($base64_name))
171 # re-add the / to the entry full name if it was a directory
172 if ($is_dir) {
173 $decoded_archive_name = "$decoded_archive_name/"
174 }
175 $entry_target_path = [System.IO.Path]::Combine($dest, $decoded_archive_name)
176 $entry_dir = [System.IO.Path]::GetDirectoryName($entry_target_path)
177
178 if (-not (Test-Path -LiteralPath $entry_dir)) {
179 New-Item -Path $entry_dir -ItemType Directory -WhatIf:$check_mode | Out-Null
180 }
181
182 if ($is_dir -eq $false) {
183 if (-not $check_mode) {
184 [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $entry_target_path, $true)
185 }
186 }
187 }
188 $archive.Dispose() # release the handle of the zip file
189 }
190
Extract-ZipLegacy($src, $dest)191 Function Extract-ZipLegacy($src, $dest) {
192 if (-not (Test-Path -LiteralPath $dest)) {
193 New-Item -Path $dest -ItemType Directory -WhatIf:$check_mode | Out-Null
194 }
195 $shell = New-Object -ComObject Shell.Application
196 $zip = $shell.NameSpace($src)
197 $dest_path = $shell.NameSpace($dest)
198
199 foreach ($entry in $zip.Items()) {
200 $is_dir = $entry.IsFolder
201 $encoded_archive_entry = $entry.Name
202 # to handle unicode character, win_copy action plugin has encoded the filename
203 $decoded_archive_entry = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($encoded_archive_entry))
204 if ($is_dir) {
205 $decoded_archive_entry = "$decoded_archive_entry/"
206 }
207
208 $entry_target_path = [System.IO.Path]::Combine($dest, $decoded_archive_entry)
209 $entry_dir = [System.IO.Path]::GetDirectoryName($entry_target_path)
210
211 if (-not (Test-Path -LiteralPath $entry_dir)) {
212 New-Item -Path $entry_dir -ItemType Directory -WhatIf:$check_mode | Out-Null
213 }
214
215 if ($is_dir -eq $false -and (-not $check_mode)) {
216 # https://msdn.microsoft.com/en-us/library/windows/desktop/bb787866.aspx
217 # From Folder.CopyHere documentation, 1044 means:
218 # - 1024: do not display a user interface if an error occurs
219 # - 16: respond with "yes to all" for any dialog box that is displayed
220 # - 4: do not display a progress dialog box
221 $dest_path.CopyHere($entry, 1044)
222
223 # once file is extraced, we need to rename it with non base64 name
224 $combined_encoded_path = [System.IO.Path]::Combine($dest, $encoded_archive_entry)
225 Move-Item -LiteralPath $combined_encoded_path -Destination $entry_target_path -Force | Out-Null
226 }
227 }
228 }
229
230 if ($copy_mode -eq "query") {
231 # we only return a list of files/directories that need to be copied over
232 # the source of the local file will be the key used
233 $changed_files = @()
234 $changed_directories = @()
235 $changed_symlinks = @()
236
237 foreach ($file in $files) {
238 $filename = $file.dest
239 $local_checksum = $file.checksum
240
241 $filepath = Join-Path -Path $dest -ChildPath $filename
242 if (Test-Path -LiteralPath $filepath -PathType Leaf) {
243 if ($force) {
244 $checksum = Get-FileChecksum -path $filepath
245 if ($checksum -ne $local_checksum) {
246 $changed_files += $file
247 }
248 }
249 } elseif (Test-Path -LiteralPath $filepath -PathType Container) {
250 Fail-Json -obj $result -message "cannot copy file to dest '$filepath': object at path is already a directory"
251 } else {
252 $changed_files += $file
253 }
254 }
255
256 foreach ($directory in $directories) {
257 $dirname = $directory.dest
258
259 $dirpath = Join-Path -Path $dest -ChildPath $dirname
260 $parent_dir = [System.IO.Path]::GetDirectoryName($dirpath)
261 if (Test-Path -LiteralPath $parent_dir -PathType Leaf) {
262 Fail-Json -obj $result -message "cannot copy folder to dest '$dirpath': object at parent directory path is already a file"
263 }
264 if (Test-Path -LiteralPath $dirpath -PathType Leaf) {
265 Fail-Json -obj $result -message "cannot copy folder to dest '$dirpath': object at path is already a file"
266 } elseif (-not (Test-Path -LiteralPath $dirpath -PathType Container)) {
267 $changed_directories += $directory
268 }
269 }
270
271 # TODO: Handle symlinks
272
273 $result.files = $changed_files
274 $result.directories = $changed_directories
275 $result.symlinks = $changed_symlinks
276 } elseif ($copy_mode -eq "explode") {
277 # a single zip file containing the files and directories needs to be
278 # expanded this will always result in a change as the calculation is done
279 # on the win_copy action plugin and is only run if a change needs to occur
280 if (-not (Test-Path -LiteralPath $src -PathType Leaf)) {
281 Fail-Json -obj $result -message "Cannot expand src zip file: '$src' as it does not exist"
282 }
283
284 # Detect if the PS zip assemblies are available or whether to use Shell
285 $use_legacy = $false
286 try {
287 Add-Type -AssemblyName System.IO.Compression.FileSystem | Out-Null
288 Add-Type -AssemblyName System.IO.Compression | Out-Null
289 } catch {
290 $use_legacy = $true
291 }
292 if ($use_legacy) {
293 Extract-ZipLegacy -src $src -dest $dest
294 } else {
295 Extract-Zip -src $src -dest $dest
296 }
297
298 $result.changed = $true
299 } elseif ($copy_mode -eq "remote") {
300 # all copy actions are happening on the remote side (windows host), need
301 # too copy source and dest using PS code
302 $result.src = $src
303 $result.dest = $dest
304
305 if (-not (Test-Path -LiteralPath $src)) {
306 Fail-Json -obj $result -message "Cannot copy src file: '$src' as it does not exist"
307 }
308
309 if (Test-Path -LiteralPath $src -PathType Container) {
310 # we are copying a directory or the contents of a directory
311 $result.operation = 'folder_copy'
312 if ($src.EndsWith("/") -or $src.EndsWith("`\")) {
313 # copying the folder's contents to dest
314 $diff = ""
315 $child_files = Get-ChildItem -LiteralPath $src -Force
316 foreach ($child_file in $child_files) {
317 $dest_child_path = Join-Path -Path $dest -ChildPath $child_file.Name
318 if ($child_file.PSIsContainer) {
319 $diff += Copy-Folder -source $child_file.FullName -dest $dest_child_path
320 } else {
321 $diff += (Copy-File -source $child_file.FullName -dest $dest_child_path).diff
322 }
323 }
324 } else {
325 # copying the folder and it's contents to dest
326 $dest = Join-Path -Path $dest -ChildPath (Get-Item -LiteralPath $src -Force).Name
327 $result.dest = $dest
328 $diff = Copy-Folder -source $src -dest $dest
329 }
330 } else {
331 # we are just copying a single file to dest
332 $result.operation = 'file_copy'
333
334 $source_basename = (Get-Item -LiteralPath $src -Force).Name
335 $result.original_basename = $source_basename
336
337 if ($dest.EndsWith("/") -or $dest.EndsWith("`\")) {
338 $dest = Join-Path -Path $dest -ChildPath (Get-Item -LiteralPath $src -Force).Name
339 $result.dest = $dest
340 } else {
341 # check if the parent dir exists, this is only done if src is a
342 # file and dest if the path to a file (doesn't end with \ or /)
343 $parent_dir = Split-Path -LiteralPath $dest
344 if (Test-Path -LiteralPath $parent_dir -PathType Leaf) {
345 Fail-Json -obj $result -message "object at destination parent dir '$parent_dir' is currently a file"
346 } elseif (-not (Test-Path -LiteralPath $parent_dir -PathType Container)) {
347 Fail-Json -obj $result -message "Destination directory '$parent_dir' does not exist"
348 }
349 }
350 $copy_result = Copy-File -source $src -dest $dest
351 $diff = $copy_result.diff
352 $result.checksum = $copy_result.checksum
353 }
354
355 # the file might not exist if running in check mode
356 if (-not $check_mode -or (Test-Path -LiteralPath $dest -PathType Leaf)) {
357 $result.size = Get-FileSize -path $dest
358 } else {
359 $result.size = $null
360 }
361 if ($diff_mode) {
362 $result.diff.prepared = $diff
363 }
364 } elseif ($copy_mode -eq "single") {
365 # a single file is located in src and we need to copy to dest, this will
366 # always result in a change as the calculation is done on the Ansible side
367 # before this is run. This should also never run in check mode
368 if (-not (Test-Path -LiteralPath $src -PathType Leaf)) {
369 Fail-Json -obj $result -message "Cannot copy src file: '$src' as it does not exist"
370 }
371
372 # the dest parameter is a directory, we need to append original_basename
373 if ($dest.EndsWith("/") -or $dest.EndsWith("`\") -or (Test-Path -LiteralPath $dest -PathType Container)) {
374 $remote_dest = Join-Path -Path $dest -ChildPath $original_basename
375 $parent_dir = Split-Path -LiteralPath $remote_dest
376
377 # when dest ends with /, we need to create the destination directories
378 if (Test-Path -LiteralPath $parent_dir -PathType Leaf) {
379 Fail-Json -obj $result -message "object at destination parent dir '$parent_dir' is currently a file"
380 } elseif (-not (Test-Path -LiteralPath $parent_dir -PathType Container)) {
381 New-Item -Path $parent_dir -ItemType Directory | Out-Null
382 }
383 } else {
384 $remote_dest = $dest
385 $parent_dir = Split-Path -LiteralPath $remote_dest
386
387 # check if the dest parent dirs exist, need to fail if they don't
388 if (Test-Path -LiteralPath $parent_dir -PathType Leaf) {
389 Fail-Json -obj $result -message "object at destination parent dir '$parent_dir' is currently a file"
390 } elseif (-not (Test-Path -LiteralPath $parent_dir -PathType Container)) {
391 Fail-Json -obj $result -message "Destination directory '$parent_dir' does not exist"
392 }
393 }
394
395 if ($backup) {
396 $result.backup_file = Backup-File -path $remote_dest -WhatIf:$check_mode
397 }
398
399 Copy-Item -LiteralPath $src -Destination $remote_dest -Force | Out-Null
400 $result.changed = $true
401 }
402
403 Exit-Json -obj $result
404