diff --git a/shippable.yml b/shippable.yml index 1ed7999ed98..d24b2bd9a35 100644 --- a/shippable.yml +++ b/shippable.yml @@ -63,6 +63,11 @@ matrix: - env: T=windows/2016/7 - env: T=windows/2019/7 + - env: T=i/windows/2012 + - env: T=i/windows/2012-R2 + - env: T=i/windows/2016 + - env: T=i/windows/2019 + - env: T=ios/csr1000v//1 - env: T=vyos/1.1.8/2.7/1 diff --git a/test/integration/targets/incidental_win_copy/aliases b/test/integration/targets/incidental_win_copy/aliases new file mode 100644 index 00000000000..a5fc90dcf48 --- /dev/null +++ b/test/integration/targets/incidental_win_copy/aliases @@ -0,0 +1,2 @@ +shippable/windows/incidental +windows diff --git a/test/integration/targets/incidental_win_copy/defaults/main.yml b/test/integration/targets/incidental_win_copy/defaults/main.yml new file mode 100644 index 00000000000..5d8a1d23513 --- /dev/null +++ b/test/integration/targets/incidental_win_copy/defaults/main.yml @@ -0,0 +1 @@ +test_win_copy_path: C:\ansible\win_copy .ÅÑŚÌβŁÈ [$!@^&test(;)] diff --git a/test/integration/targets/incidental_win_copy/files-different/vault/folder/nested-vault-file b/test/integration/targets/incidental_win_copy/files-different/vault/folder/nested-vault-file new file mode 100644 index 00000000000..d8d1549874c --- /dev/null +++ b/test/integration/targets/incidental_win_copy/files-different/vault/folder/nested-vault-file @@ -0,0 +1,6 @@ +$ANSIBLE_VAULT;1.1;AES256 +65653164323866373138353632323531393664393563633665373635623763353561386431373366 +3232353263363034313136663062623336663463373966320a333763323032646463386432626161 +36386330356637666362396661653935653064623038333031653335626164376465353235303636 +3335616231663838620a303632343938326538656233393562303162343261383465623261646664 +33613932343461626339333832363930303962633364303736376634396364643861 diff --git a/test/integration/targets/incidental_win_copy/files-different/vault/readme.txt b/test/integration/targets/incidental_win_copy/files-different/vault/readme.txt new file mode 100644 index 00000000000..dae883b5eed --- /dev/null +++ b/test/integration/targets/incidental_win_copy/files-different/vault/readme.txt @@ -0,0 +1,5 @@ +This directory contains some files that have been encrypted with ansible-vault. + +This is to test out the decrypt parameter in win_copy. + +The password is: password diff --git a/test/integration/targets/incidental_win_copy/files-different/vault/vault-file b/test/integration/targets/incidental_win_copy/files-different/vault/vault-file new file mode 100644 index 00000000000..2fff7619a75 --- /dev/null +++ b/test/integration/targets/incidental_win_copy/files-different/vault/vault-file @@ -0,0 +1,6 @@ +$ANSIBLE_VAULT;1.1;AES256 +30353665333635633433356261616636356130386330363962386533303566313463383734373532 +3933643234323638623939613462346361313431363939370a303532656338353035346661353965 +34656231633238396361393131623834316262306533663838336362366137306562646561383766 +6363373965633337640a373666336461613337346131353564383134326139616561393664663563 +3431 diff --git a/test/integration/targets/incidental_win_copy/files/empty.txt b/test/integration/targets/incidental_win_copy/files/empty.txt new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/integration/targets/incidental_win_copy/files/foo.txt b/test/integration/targets/incidental_win_copy/files/foo.txt new file mode 100644 index 00000000000..7c6ded14ecf --- /dev/null +++ b/test/integration/targets/incidental_win_copy/files/foo.txt @@ -0,0 +1 @@ +foo.txt diff --git a/test/integration/targets/incidental_win_copy/files/subdir/bar.txt b/test/integration/targets/incidental_win_copy/files/subdir/bar.txt new file mode 100644 index 00000000000..76018072e09 --- /dev/null +++ b/test/integration/targets/incidental_win_copy/files/subdir/bar.txt @@ -0,0 +1 @@ +baz diff --git a/test/integration/targets/incidental_win_copy/files/subdir/subdir2/baz.txt b/test/integration/targets/incidental_win_copy/files/subdir/subdir2/baz.txt new file mode 100644 index 00000000000..76018072e09 --- /dev/null +++ b/test/integration/targets/incidental_win_copy/files/subdir/subdir2/baz.txt @@ -0,0 +1 @@ +baz diff --git a/test/integration/targets/incidental_win_copy/files/subdir/subdir2/subdir3/subdir4/qux.txt b/test/integration/targets/incidental_win_copy/files/subdir/subdir2/subdir3/subdir4/qux.txt new file mode 100644 index 00000000000..78df5b06bd3 --- /dev/null +++ b/test/integration/targets/incidental_win_copy/files/subdir/subdir2/subdir3/subdir4/qux.txt @@ -0,0 +1 @@ +qux \ No newline at end of file diff --git a/test/integration/targets/incidental_win_copy/tasks/main.yml b/test/integration/targets/incidental_win_copy/tasks/main.yml new file mode 100644 index 00000000000..b2ee103fd06 --- /dev/null +++ b/test/integration/targets/incidental_win_copy/tasks/main.yml @@ -0,0 +1,34 @@ +--- +- name: create empty folder + file: + path: '{{role_path}}/files/subdir/empty' + state: directory + delegate_to: localhost + +# removes the cached zip module from the previous task so we can replicate +# the below issue where win_copy would delete DEFAULT_LOCAL_TMP if it +# had permission to +# https://github.com/ansible/ansible/issues/35613 +- name: clear the local ansiballz cache + file: + path: "{{lookup('config', 'DEFAULT_LOCAL_TMP')}}/ansiballz_cache" + state: absent + delegate_to: localhost + +- name: create test folder + win_file: + path: '{{test_win_copy_path}}' + state: directory + +- block: + - name: run tests for local to remote + include_tasks: tests.yml + + - name: run tests for remote to remote + include_tasks: remote_tests.yml + + always: + - name: remove test folder + win_file: + path: '{{test_win_copy_path}}' + state: absent diff --git a/test/integration/targets/incidental_win_copy/tasks/remote_tests.yml b/test/integration/targets/incidental_win_copy/tasks/remote_tests.yml new file mode 100644 index 00000000000..5abb50200ba --- /dev/null +++ b/test/integration/targets/incidental_win_copy/tasks/remote_tests.yml @@ -0,0 +1,471 @@ +--- +- name: fail when source does not exist remote + win_copy: + src: fakesource + dest: fakedest + remote_src: yes + register: fail_remote_invalid_source + failed_when: "'it does not exist' not in fail_remote_invalid_source.msg" + +- name: setup source folder for remote tests + win_copy: + src: files/ + dest: '{{test_win_copy_path}}\source\' + +- name: setup remote failure tests + win_file: + path: '{{item.path}}' + state: '{{item.state}}' + with_items: + - { 'path': '{{test_win_copy_path}}\target\folder', 'state': 'directory' } + - { 'path': '{{test_win_copy_path}}\target\file', 'state': 'touch' } + - { 'path': '{{test_win_copy_path}}\target\subdir', 'state': 'touch' } + +- name: fail source is a file but dest is a folder + win_copy: + src: '{{test_win_copy_path}}\source\foo.txt' + dest: '{{test_win_copy_path}}\target\folder' + remote_src: yes + register: fail_remote_file_to_folder + failed_when: "'dest is already a folder' not in fail_remote_file_to_folder.msg" + +- name: fail source is a file but dest is a folder + win_copy: + src: '{{test_win_copy_path}}\source\' + dest: '{{test_win_copy_path}}\target\' + remote_src: yes + register: fail_remote_folder_to_file + failed_when: "'dest is already a file' not in fail_remote_folder_to_file.msg" + +- name: fail source is a file dest parent dir is also a file + win_copy: + src: '{{test_win_copy_path}}\source\foo.txt' + dest: '{{test_win_copy_path}}\target\file\foo.txt' + remote_src: yes + register: fail_remote_file_parent_dir_file + failed_when: "'is currently a file' not in fail_remote_file_parent_dir_file.msg" + +- name: fail source is a folder dest parent dir is also a file + win_copy: + src: '{{test_win_copy_path}}\source\subdir' + dest: '{{test_win_copy_path}}\target\file' + remote_src: yes + register: fail_remote_folder_parent_dir_file + failed_when: "'object at dest parent dir is not a folder' not in fail_remote_folder_parent_dir_file.msg" + +- name: fail to copy a remote file with parent dir that doesn't exist and filename is set + win_copy: + src: '{{test_win_copy_path}}\source\foo.txt' + dest: '{{test_win_copy_path}}\missing-dir\foo.txt' + remote_src: yes + register: fail_remote_missing_parent_dir + failed_when: "'does not exist' not in fail_remote_missing_parent_dir.msg" + +- name: remove target after remote failure tests + win_file: + path: '{{test_win_copy_path}}\target' + state: absent + +- name: create remote target after cleaning + win_file: + path: '{{test_win_copy_path}}\target' + state: directory + +- name: copy single file remote (check mode) + win_copy: + src: '{{test_win_copy_path}}\source\foo.txt' + dest: '{{test_win_copy_path}}\target\foo-target.txt' + remote_src: yes + register: remote_copy_file_check + check_mode: yes + +- name: get result of copy single file remote (check mode) + win_stat: + path: '{{test_win_copy_path}}\target\foo-target.txt' + register: remote_copy_file_actual_check + +- name: assert copy single file remote (check mode) + assert: + that: + - remote_copy_file_check is changed + - remote_copy_file_actual_check.stat.exists == False + +- name: copy single file remote + win_copy: + src: '{{test_win_copy_path}}\source\foo.txt' + dest: '{{test_win_copy_path}}\target\foo-target.txt' + remote_src: yes + register: remote_copy_file + +- name: get result of copy single file remote + win_stat: + path: '{{test_win_copy_path}}\target\foo-target.txt' + register: remote_copy_file_actual + +- name: assert copy single file remote + assert: + that: + - remote_copy_file is changed + - remote_copy_file.operation == 'file_copy' + - remote_copy_file.checksum == 'c79a6506c1c948be0d456ab5104d5e753ab2f3e6' + - remote_copy_file.size == 8 + - remote_copy_file.original_basename == 'foo.txt' + - remote_copy_file_actual.stat.exists == True + - remote_copy_file_actual.stat.checksum == 'c79a6506c1c948be0d456ab5104d5e753ab2f3e6' + +- name: copy single file remote (idempotent) + win_copy: + src: '{{test_win_copy_path}}\source\foo.txt' + dest: '{{test_win_copy_path}}\target\foo-target.txt' + remote_src: yes + register: remote_copy_file_again + +- name: assert copy single file remote (idempotent) + assert: + that: + - remote_copy_file_again is not changed + +- name: copy single file into folder remote (check mode) + win_copy: + src: '{{test_win_copy_path}}\source\foo.txt' + dest: '{{test_win_copy_path}}\target\' + remote_src: yes + register: remote_copy_file_to_folder_check + check_mode: yes + +- name: get result of copy single file into folder remote (check mode) + win_stat: + path: '{{test_win_copy_path}}\target\foo.txt' + register: remote_copy_file_to_folder_actual_check + +- name: assert copy single file into folder remote (check mode) + assert: + that: + - remote_copy_file_to_folder_check is changed + - remote_copy_file_to_folder_actual_check.stat.exists == False + +- name: copy single file into folder remote + win_copy: + src: '{{test_win_copy_path}}\source\foo.txt' + dest: '{{test_win_copy_path}}\target\' + remote_src: yes + register: remote_copy_file_to_folder + +- name: get result of copy single file into folder remote + win_stat: + path: '{{test_win_copy_path}}\target\foo.txt' + register: remote_copy_file_to_folder_actual + +- name: assert copy single file into folder remote + assert: + that: + - remote_copy_file_to_folder is changed + - remote_copy_file_to_folder.operation == 'file_copy' + - remote_copy_file_to_folder.checksum == 'c79a6506c1c948be0d456ab5104d5e753ab2f3e6' + - remote_copy_file_to_folder.size == 8 + - remote_copy_file_to_folder.original_basename == 'foo.txt' + - remote_copy_file_to_folder_actual.stat.exists == True + - remote_copy_file_to_folder_actual.stat.checksum == 'c79a6506c1c948be0d456ab5104d5e753ab2f3e6' + +- name: copy single file into folder remote (idempotent) + win_copy: + src: '{{test_win_copy_path}}\source\foo.txt' + dest: '{{test_win_copy_path}}\target\' + remote_src: yes + register: remote_copy_file_to_folder_again + +- name: assert copy single file into folder remote + assert: + that: + - remote_copy_file_to_folder_again is not changed + +- name: copy single file to missing folder (check mode) + win_copy: + src: '{{test_win_copy_path}}\source\foo.txt' + dest: '{{test_win_copy_path}}\target\missing\' + remote_src: yes + register: remote_copy_file_to_missing_folder_check + check_mode: yes + +- name: get result of copy single file to missing folder remote (check mode) + win_stat: + path: '{{test_win_copy_path}}\target\missing\foo.txt' + register: remote_copy_file_to_missing_folder_actual_check + +- name: assert copy single file to missing folder remote (check mode) + assert: + that: + - remote_copy_file_to_missing_folder_check is changed + - remote_copy_file_to_missing_folder_check.operation == 'file_copy' + - remote_copy_file_to_missing_folder_actual_check.stat.exists == False + +- name: copy single file to missing folder remote + win_copy: + src: '{{test_win_copy_path}}\source\foo.txt' + dest: '{{test_win_copy_path}}\target\missing\' + remote_src: yes + register: remote_copy_file_to_missing_folder + +- name: get result of copy single file to missing folder remote + win_stat: + path: '{{test_win_copy_path}}\target\missing\foo.txt' + register: remote_copy_file_to_missing_folder_actual + +- name: assert copy single file to missing folder remote + assert: + that: + - remote_copy_file_to_missing_folder is changed + - remote_copy_file_to_missing_folder.checksum == 'c79a6506c1c948be0d456ab5104d5e753ab2f3e6' + - remote_copy_file_to_missing_folder.operation == 'file_copy' + - remote_copy_file_to_missing_folder.size == 8 + - remote_copy_file_to_missing_folder_actual.stat.exists == True + - remote_copy_file_to_missing_folder_actual.stat.checksum == 'c79a6506c1c948be0d456ab5104d5e753ab2f3e6' + +- name: clear target for folder to folder test + win_file: + path: '{{test_win_copy_path}}\target' + state: absent + +- name: copy folder to folder remote (check mode) + win_copy: + src: '{{test_win_copy_path}}\source' + dest: '{{test_win_copy_path}}\target' + remote_src: yes + register: remote_copy_folder_to_folder_check + check_mode: yes + +- name: get result of copy folder to folder remote (check mode) + win_stat: + path: '{{test_win_copy_path}}\target' + register: remote_copy_folder_to_folder_actual_check + +- name: assert copy folder to folder remote (check mode) + assert: + that: + - remote_copy_folder_to_folder_check is changed + - remote_copy_folder_to_folder_check.operation == 'folder_copy' + - remote_copy_folder_to_folder_actual_check.stat.exists == False + +- name: copy folder to folder remote + win_copy: + src: '{{test_win_copy_path}}\source' + dest: '{{test_win_copy_path}}\target' + remote_src: yes + register: remote_copy_folder_to_folder + +- name: get result of copy folder to folder remote + win_find: + paths: '{{test_win_copy_path}}\target' + recurse: yes + file_type: directory + register: remote_copy_folder_to_folder_actual + +- name: assert copy folder to folder remote + assert: + that: + - remote_copy_folder_to_folder is changed + - remote_copy_folder_to_folder.operation == 'folder_copy' + - remote_copy_folder_to_folder_actual.examined == 11 + - remote_copy_folder_to_folder_actual.matched == 6 + - remote_copy_folder_to_folder_actual.files[0].filename == 'source' + - remote_copy_folder_to_folder_actual.files[1].filename == 'subdir' + - remote_copy_folder_to_folder_actual.files[2].filename == 'empty' + - remote_copy_folder_to_folder_actual.files[3].filename == 'subdir2' + - remote_copy_folder_to_folder_actual.files[4].filename == 'subdir3' + - remote_copy_folder_to_folder_actual.files[5].filename == 'subdir4' + +- name: copy folder to folder remote (idempotent) + win_copy: + src: '{{test_win_copy_path}}\source' + dest: '{{test_win_copy_path}}\target' + remote_src: yes + register: remote_copy_folder_to_folder_again + +- name: assert copy folder to folder remote (idempotent) + assert: + that: + - remote_copy_folder_to_folder_again is not changed + +- name: change remote file after folder to folder test + win_copy: + content: bar.txt + dest: '{{test_win_copy_path}}\target\source\foo.txt' + +- name: remote remote folder after folder to folder test + win_file: + path: '{{test_win_copy_path}}\target\source\subdir\subdir2\subdir3\subdir4' + state: absent + +- name: copy folder to folder remote after change + win_copy: + src: '{{test_win_copy_path}}\source' + dest: '{{test_win_copy_path}}\target' + remote_src: yes + register: remote_copy_folder_to_folder_after_change + +- name: get result of copy folder to folder remote after change + win_find: + paths: '{{test_win_copy_path}}\target\source' + recurse: yes + patterns: ['foo.txt', 'qux.txt'] + register: remote_copy_folder_to_folder_after_change_actual + +- name: assert copy folder after changes + assert: + that: + - remote_copy_folder_to_folder_after_change is changed + - remote_copy_folder_to_folder_after_change_actual.matched == 2 + - remote_copy_folder_to_folder_after_change_actual.files[0].checksum == 'c79a6506c1c948be0d456ab5104d5e753ab2f3e6' + - remote_copy_folder_to_folder_after_change_actual.files[1].checksum == 'b54ba7f5621240d403f06815f7246006ef8c7d43' + +- name: clear target folder before folder contents to remote test + win_file: + path: '{{test_win_copy_path}}\target' + state: absent + +- name: copy folder contents to folder remote with backslash (check mode) + win_copy: + src: '{{test_win_copy_path}}\source\' + dest: '{{test_win_copy_path}}\target' + remote_src: yes + register: remote_copy_folder_content_backslash_check + check_mode: yes + +- name: get result of copy folder contents to folder remote with backslash (check mode) + win_stat: + path: '{{test_win_copy_path}}\target' + register: remote_copy_folder_content_backslash_actual_check + +- name: assert copy folder content to folder remote with backslash (check mode) + assert: + that: + - remote_copy_folder_content_backslash_check is changed + - remote_copy_folder_content_backslash_actual_check.stat.exists == False + +- name: copy folder contents to folder remote with backslash + win_copy: + src: '{{test_win_copy_path}}\source\' + dest: '{{test_win_copy_path}}\target' + remote_src: yes + register: remote_copy_folder_content_backslash + +- name: get result of copy folder contents to folder remote with backslash + win_find: + paths: '{{test_win_copy_path}}\target' + recurse: yes + file_type: directory + register: remote_copy_folder_content_backslash_actual + +- name: assert copy folder content to folder remote with backslash + assert: + that: + - remote_copy_folder_content_backslash is changed + - remote_copy_folder_content_backslash.operation == 'folder_copy' + - remote_copy_folder_content_backslash_actual.examined == 10 + - remote_copy_folder_content_backslash_actual.matched == 5 + - remote_copy_folder_content_backslash_actual.files[0].filename == 'subdir' + - remote_copy_folder_content_backslash_actual.files[1].filename == 'empty' + - remote_copy_folder_content_backslash_actual.files[2].filename == 'subdir2' + - remote_copy_folder_content_backslash_actual.files[3].filename == 'subdir3' + - remote_copy_folder_content_backslash_actual.files[4].filename == 'subdir4' + +- name: copy folder contents to folder remote with backslash (idempotent) + win_copy: + src: '{{test_win_copy_path}}\source\' + dest: '{{test_win_copy_path}}\target' + remote_src: yes + register: remote_copy_folder_content_backslash_again + +- name: assert copy folder content to folder remote with backslash (idempotent) + assert: + that: + - remote_copy_folder_content_backslash_again is not changed + +- name: change remote file after folder content to folder test + win_copy: + content: bar.txt + dest: '{{test_win_copy_path}}\target\foo.txt' + +- name: remote remote folder after folder content to folder test + win_file: + path: '{{test_win_copy_path}}\target\subdir\subdir2\subdir3\subdir4' + state: absent + +- name: copy folder content to folder remote after change + win_copy: + src: '{{test_win_copy_path}}/source/' + dest: '{{test_win_copy_path}}/target/' + remote_src: yes + register: remote_copy_folder_content_to_folder_after_change + +- name: get result of copy folder content to folder remote after change + win_find: + paths: '{{test_win_copy_path}}\target' + recurse: yes + patterns: ['foo.txt', 'qux.txt'] + register: remote_copy_folder_content_to_folder_after_change_actual + +- name: assert copy folder content to folder after changes + assert: + that: + - remote_copy_folder_content_to_folder_after_change is changed + - remote_copy_folder_content_to_folder_after_change_actual.matched == 2 + - remote_copy_folder_content_to_folder_after_change_actual.files[0].checksum == 'c79a6506c1c948be0d456ab5104d5e753ab2f3e6' + - remote_copy_folder_content_to_folder_after_change_actual.files[1].checksum == 'b54ba7f5621240d403f06815f7246006ef8c7d43' + +# https://github.com/ansible/ansible/issues/50077 +- name: create empty nested directory + win_file: + path: '{{ test_win_copy_path }}\source\empty-nested\nested-dir' + state: directory + +- name: copy empty nested directory (check mode) + win_copy: + src: '{{ test_win_copy_path }}\source\empty-nested' + dest: '{{ test_win_copy_path }}\target' + remote_src: True + check_mode: True + register: copy_empty_dir_check + +- name: get result of copy empty nested directory (check mode) + win_stat: + path: '{{ test_win_copy_path }}\target\empty-nested' + register: copy_empty_dir_actual_check + +- name: assert copy empty nested directory (check mode) + assert: + that: + - copy_empty_dir_check is changed + - copy_empty_dir_check.operation == "folder_copy" + - not copy_empty_dir_actual_check.stat.exists + +- name: copy empty nested directory + win_copy: + src: '{{ test_win_copy_path }}\source\empty-nested' + dest: '{{ test_win_copy_path }}\target' + remote_src: True + register: copy_empty_dir + +- name: get result of copy empty nested directory + win_stat: + path: '{{ test_win_copy_path }}\target\empty-nested\nested-dir' + register: copy_empty_dir_actual + +- name: assert copy empty nested directory + assert: + that: + - copy_empty_dir is changed + - copy_empty_dir.operation == "folder_copy" + - copy_empty_dir_actual.stat.exists + +- name: copy empty nested directory (idempotent) + win_copy: + src: '{{ test_win_copy_path }}\source\empty-nested' + dest: '{{ test_win_copy_path }}\target' + remote_src: True + register: copy_empty_dir_again + +- name: assert copy empty nested directory (idempotent) + assert: + that: + - not copy_empty_dir_again is changed diff --git a/test/integration/targets/incidental_win_copy/tasks/tests.yml b/test/integration/targets/incidental_win_copy/tasks/tests.yml new file mode 100644 index 00000000000..d15e71f65c0 --- /dev/null +++ b/test/integration/targets/incidental_win_copy/tasks/tests.yml @@ -0,0 +1,535 @@ +--- +- name: fail no source or content + win_copy: + dest: dest + register: fail_no_source_content + failed_when: fail_no_source_content.msg != 'src (or content) and dest are required' + +- name: fail content but dest isn't a file, unix ending + win_copy: + content: a + dest: a/ + register: fail_dest_not_file_unix + failed_when: fail_dest_not_file_unix.msg != 'dest must be a file if content is defined' + +- name: fail content but dest isn't a file, windows ending + win_copy: + content: a + dest: a\ + register: fail_dest_not_file_windows + failed_when: fail_dest_not_file_windows.msg != 'dest must be a file if content is defined' + +- name: fail to copy a file with parent dir that doesn't exist and filename is set + win_copy: + src: foo.txt + dest: '{{test_win_copy_path}}\missing-dir\foo.txt' + register: fail_missing_parent_dir + failed_when: "'does not exist' not in fail_missing_parent_dir.msg" + +- name: fail to copy an encrypted file without the password set + win_copy: + src: '{{role_path}}/files-different/vault/vault-file' + dest: '{{test_win_copy_path}}\file' + register: fail_copy_encrypted_file + ignore_errors: yes # weird failed_when doesn't work in this case + +- name: assert failure message when copying an encrypted file without the password set + assert: + that: + - fail_copy_encrypted_file is failed + - fail_copy_encrypted_file.msg == 'A vault password or secret must be specified to decrypt {{role_path}}/files-different/vault/vault-file' + +- name: fail to copy a directory with an encrypted file without the password + win_copy: + src: '{{role_path}}/files-different/vault' + dest: '{{test_win_copy_path}}' + register: fail_copy_directory_with_enc_file + ignore_errors: yes + +- name: assert failure message when copying a directory that contains an encrypted file without the password set + assert: + that: + - fail_copy_directory_with_enc_file is failed + - fail_copy_directory_with_enc_file.msg == 'A vault password or secret must be specified to decrypt {{role_path}}/files-different/vault/vault-file' + +- name: copy with content (check mode) + win_copy: + content: a + dest: '{{test_win_copy_path}}\file' + register: copy_content_check + check_mode: yes + +- name: get result of copy with content (check mode) + win_stat: + path: '{{test_win_copy_path}}\file' + register: copy_content_actual_check + +- name: assert copy with content (check mode) + assert: + that: + - copy_content_check is changed + - copy_content_check.checksum == '86f7e437faa5a7fce15d1ddcb9eaeaea377667b8' + - copy_content_check.operation == 'file_copy' + - copy_content_check.size == 1 + - copy_content_actual_check.stat.exists == False + +- name: copy with content + win_copy: + content: a + dest: '{{test_win_copy_path}}\file' + register: copy_content + +- name: get result of copy with content + win_stat: + path: '{{test_win_copy_path}}\file' + register: copy_content_actual + +- name: assert copy with content + assert: + that: + - copy_content is changed + - copy_content.checksum == '86f7e437faa5a7fce15d1ddcb9eaeaea377667b8' + - copy_content.operation == 'file_copy' + - copy_content.size == 1 + - copy_content_actual.stat.exists == True + - copy_content_actual.stat.checksum == '86f7e437faa5a7fce15d1ddcb9eaeaea377667b8' + +- name: copy with content (idempotent) + win_copy: + content: a + dest: '{{test_win_copy_path}}\file' + register: copy_content_again + +- name: assert copy with content (idempotent) + assert: + that: + - copy_content_again is not changed + +- name: copy with content change when missing + win_copy: + content: b + dest: '{{test_win_copy_path}}\file' + force: no + register: copy_content_when_missing + +- name: assert copy with content change when missing + assert: + that: + - copy_content_when_missing is not changed + +- name: copy single file (check mode) + win_copy: + src: foo.txt + dest: '{{test_win_copy_path}}\foo-target.txt' + register: copy_file_check + check_mode: yes + +- name: get result of copy single file (check mode) + win_stat: + path: '{{test_win_copy_path}}\foo-target.txt' + register: copy_file_actual_check + +- name: assert copy single file (check mode) + assert: + that: + - copy_file_check is changed + - copy_file_check.checksum == 'c79a6506c1c948be0d456ab5104d5e753ab2f3e6' + - copy_file_check.dest == test_win_copy_path + '\\foo-target.txt' + - copy_file_check.operation == 'file_copy' + - copy_file_check.size == 8 + - copy_file_actual_check.stat.exists == False + +- name: copy single file + win_copy: + src: foo.txt + dest: '{{test_win_copy_path}}\foo-target.txt' + register: copy_file + +- name: get result of copy single file + win_stat: + path: '{{test_win_copy_path}}\foo-target.txt' + register: copy_file_actual + +- name: assert copy single file + assert: + that: + - copy_file is changed + - copy_file.checksum == 'c79a6506c1c948be0d456ab5104d5e753ab2f3e6' + - copy_file.dest == test_win_copy_path + '\\foo-target.txt' + - copy_file.operation == 'file_copy' + - copy_file.size == 8 + - copy_file_actual.stat.exists == True + - copy_file_actual.stat.checksum == 'c79a6506c1c948be0d456ab5104d5e753ab2f3e6' + +- name: copy single file (idempotent) + win_copy: + src: foo.txt + dest: '{{test_win_copy_path}}\foo-target.txt' + register: copy_file_again + +- name: assert copy single file (idempotent) + assert: + that: + - copy_file_again is not changed + +- name: copy single file (backup) + win_copy: + content: "{{ lookup('file', 'foo.txt') }}\nfoo bar" + dest: '{{test_win_copy_path}}\foo-target.txt' + backup: yes + register: copy_file_backup + +- name: check backup_file + win_stat: + path: '{{ copy_file_backup.backup_file }}' + register: backup_file + +- name: assert copy single file (backup) + assert: + that: + - copy_file_backup is changed + - backup_file.stat.exists == true + +- name: copy single file to folder (check mode) + win_copy: + src: foo.txt + dest: '{{test_win_copy_path}}\' + register: copy_file_to_folder_check + check_mode: yes + +- name: get result of copy single file to folder (check mode) + win_stat: + path: '{{test_win_copy_path}}\foo.txt' + register: copy_file_to_folder_actual_check + +- name: assert copy single file to folder (check mode) + assert: + that: + - copy_file_to_folder_check is changed + - copy_file_to_folder_check.checksum == 'c79a6506c1c948be0d456ab5104d5e753ab2f3e6' + - copy_file_to_folder_check.dest == test_win_copy_path + '\\foo.txt' + - copy_file_to_folder_check.operation == 'file_copy' + - copy_file_to_folder_check.size == 8 + - copy_file_to_folder_actual_check.stat.exists == False + +- name: copy single file to folder + win_copy: + src: foo.txt + dest: '{{test_win_copy_path}}\' + register: copy_file_to_folder + +- name: get result of copy single file to folder + win_stat: + path: '{{test_win_copy_path}}\foo.txt' + register: copy_file_to_folder_actual + +- name: assert copy single file to folder + assert: + that: + - copy_file_to_folder is changed + - copy_file_to_folder.checksum == 'c79a6506c1c948be0d456ab5104d5e753ab2f3e6' + - copy_file_to_folder.dest == test_win_copy_path + '\\foo.txt' + - copy_file_to_folder.operation == 'file_copy' + - copy_file_to_folder.size == 8 + - copy_file_to_folder_actual.stat.exists == True + - copy_file_to_folder_actual.stat.checksum == 'c79a6506c1c948be0d456ab5104d5e753ab2f3e6' + +- name: copy single file to folder (idempotent) + win_copy: + src: foo.txt + dest: '{{test_win_copy_path}}\' + register: copy_file_to_folder_again + +- name: assert copy single file to folder (idempotent) + assert: + that: + - copy_file_to_folder_again is not changed + +- name: copy single file to missing folder (check mode) + win_copy: + src: foo.txt + dest: '{{test_win_copy_path}}\missing\' + register: copy_file_to_missing_folder_check + check_mode: yes + +- name: get result of copy single file to missing folder (check mode) + win_stat: + path: '{{test_win_copy_path}}\missing\foo.txt' + register: copy_file_to_missing_folder_actual_check + +- name: assert copy single file to missing folder (check mode) + assert: + that: + - copy_file_to_missing_folder_check is changed + - copy_file_to_missing_folder_check.checksum == 'c79a6506c1c948be0d456ab5104d5e753ab2f3e6' + - copy_file_to_missing_folder_check.operation == 'file_copy' + - copy_file_to_missing_folder_check.size == 8 + - copy_file_to_missing_folder_actual_check.stat.exists == False + +- name: copy single file to missing folder + win_copy: + src: foo.txt + dest: '{{test_win_copy_path}}\missing\' + register: copy_file_to_missing_folder + +- name: get result of copy single file to missing folder + win_stat: + path: '{{test_win_copy_path}}\missing\foo.txt' + register: copy_file_to_missing_folder_actual + +- name: assert copy single file to missing folder + assert: + that: + - copy_file_to_missing_folder is changed + - copy_file_to_missing_folder.checksum == 'c79a6506c1c948be0d456ab5104d5e753ab2f3e6' + - copy_file_to_missing_folder.operation == 'file_copy' + - copy_file_to_missing_folder.size == 8 + - copy_file_to_missing_folder_actual.stat.exists == True + - copy_file_to_missing_folder_actual.stat.checksum == 'c79a6506c1c948be0d456ab5104d5e753ab2f3e6' + +- name: copy folder (check mode) + win_copy: + src: files + dest: '{{test_win_copy_path}}\recursive\folder' + register: copy_folder_check + check_mode: yes + +- name: get result of copy folder (check mode) + win_stat: + path: '{{test_win_copy_path}}\recursive\folder' + register: copy_folder_actual_check + +- name: assert copy folder (check mode) + assert: + that: + - copy_folder_check is changed + - copy_folder_check.operation == 'folder_copy' + - copy_folder_actual_check.stat.exists == False + +- name: copy folder + win_copy: + src: files + dest: '{{test_win_copy_path}}\recursive\folder' + register: copy_folder + +- name: get result of copy folder + win_find: + paths: '{{test_win_copy_path}}\recursive\folder' + recurse: yes + file_type: directory + register: copy_folder_actual + +- name: assert copy folder + assert: + that: + - copy_folder is changed + - copy_folder.operation == 'folder_copy' + - copy_folder_actual.examined == 11 # includes files and folders, the below is the nested order + - copy_folder_actual.matched == 6 + - copy_folder_actual.files[0].filename == 'files' + - copy_folder_actual.files[1].filename == 'subdir' + - copy_folder_actual.files[2].filename == 'empty' + - copy_folder_actual.files[3].filename == 'subdir2' + - copy_folder_actual.files[4].filename == 'subdir3' + - copy_folder_actual.files[5].filename == 'subdir4' + +- name: copy folder (idempotent) + win_copy: + src: files + dest: '{{test_win_copy_path}}\recursive\folder' + register: copy_folder_again + +- name: assert copy folder (idempotent) + assert: + that: + - copy_folder_again is not changed + +- name: change the text of a file in the remote source + win_copy: + content: bar.txt + dest: '{{test_win_copy_path}}\recursive\folder\files\foo.txt' + +- name: remove folder for test of recursive copy + win_file: + path: '{{test_win_copy_path}}\recursive\folder\files\subdir\subdir2\subdir3\subdir4' + state: absent + +- name: copy folder after changes + win_copy: + src: files + dest: '{{test_win_copy_path}}\recursive\folder' + register: copy_folder_after_change + +- name: get result of copy folder after changes + win_find: + paths: '{{test_win_copy_path}}\recursive\folder\files' + recurse: yes + patterns: ['foo.txt', 'qux.txt'] + register: copy_folder_after_changes_actual + +- name: assert copy folder after changes + assert: + that: + - copy_folder_after_change is changed + - copy_folder_after_changes_actual.matched == 2 + - copy_folder_after_changes_actual.files[0].checksum == 'c79a6506c1c948be0d456ab5104d5e753ab2f3e6' + - copy_folder_after_changes_actual.files[1].checksum == 'b54ba7f5621240d403f06815f7246006ef8c7d43' + +- name: copy folder's contents (check mode) + win_copy: + src: files/ + dest: '{{test_win_copy_path}}\recursive-contents\' + register: copy_folder_contents_check + check_mode: yes + +- name: get result of copy folder'scontents (check mode) + win_stat: + path: '{{test_win_copy_path}}\recursive-contents' + register: copy_folder_contents_actual_check + +- name: assert copy folder's contents (check mode) + assert: + that: + - copy_folder_contents_check is changed + - copy_folder_contents_check.operation == 'folder_copy' + - copy_folder_contents_actual_check.stat.exists == False + +- name: copy folder's contents + win_copy: + src: files/ + dest: '{{test_win_copy_path}}\recursive-contents\' + register: copy_folder_contents + +- name: get result of copy folder + win_find: + paths: '{{test_win_copy_path}}\recursive-contents' + recurse: yes + file_type: directory + register: copy_folder_contents_actual + +- name: assert copy folder + assert: + that: + - copy_folder_contents is changed + - copy_folder_contents.operation == 'folder_copy' + - copy_folder_contents_actual.examined == 10 # includes files and folders, the below is the nested order + - copy_folder_contents_actual.matched == 5 + - copy_folder_contents_actual.files[0].filename == 'subdir' + - copy_folder_contents_actual.files[1].filename == 'empty' + - copy_folder_contents_actual.files[2].filename == 'subdir2' + - copy_folder_contents_actual.files[3].filename == 'subdir3' + - copy_folder_contents_actual.files[4].filename == 'subdir4' + +- name: fail to copy file to a folder + win_copy: + src: foo.txt + dest: '{{test_win_copy_path}}\recursive-contents' + register: fail_file_to_folder + failed_when: "'object at path is already a directory' not in fail_file_to_folder.msg" + +- name: fail to copy folder to a file + win_copy: + src: subdir/ + dest: '{{test_win_copy_path}}\recursive-contents\foo.txt' + register: fail_folder_to_file + failed_when: "'object at parent directory path is already a file' not in fail_folder_to_file.msg" + +# https://github.com/ansible/ansible/issues/31336 +- name: create file with colon in the name + copy: + dest: '{{role_path}}/files-different/colon:file' + content: test + delegate_to: localhost + +- name: copy a file with colon as a source + win_copy: + src: '{{role_path}}/files-different/colon:file' + dest: '{{test_win_copy_path}}\colon.file' + register: copy_file_with_colon + +- name: get result of file with colon as a source + win_stat: + path: '{{test_win_copy_path}}\colon.file' + register: copy_file_with_colon_result + +- name: assert results of copy a file with colon as a source + assert: + that: + - copy_file_with_colon is changed + - copy_file_with_colon_result.stat.exists == True + - copy_file_with_colon_result.stat.checksum == "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3" + +- name: remove file with colon in the name + file: + path: '{{role_path}}/files-different/colon:file' + state: absent + delegate_to: localhost + +- name: copy an encrypted file without decrypting + win_copy: + src: '{{role_path}}/files-different/vault/vault-file' + dest: '{{test_win_copy_path}}\vault-file' + decrypt: no + register: copy_encrypted_file + +- name: get stat of copied encrypted file without decrypting + win_stat: + path: '{{test_win_copy_path}}\vault-file' + register: copy_encrypted_file_result + +- name: assert result of copy an encrypted file without decrypting + assert: + that: + - copy_encrypted_file is changed + - copy_encrypted_file_result.stat.checksum == "74a89620002d253f38834ee5b06cddd28956a43d" + +- name: copy an encrypted file without decrypting (idempotent) + win_copy: + src: '{{role_path}}/files-different/vault/vault-file' + dest: '{{test_win_copy_path}}\vault-file' + decrypt: no + register: copy_encrypted_file_again + +- name: assert result of copy an encrypted file without decrypting (idempotent) + assert: + that: + - copy_encrypted_file_again is not changed + +- name: copy folder with encrypted files without decrypting + win_copy: + src: '{{role_path}}/files-different/vault/' + dest: '{{test_win_copy_path}}\encrypted-test' + decrypt: no + register: copy_encrypted_file + +- name: get result of copy folder with encrypted files without decrypting + win_find: + paths: '{{test_win_copy_path}}\encrypted-test' + recurse: yes + patterns: '*vault*' + register: copy_encrypted_file_result + +- name: assert result of copy folder with encrypted files without decrypting + assert: + that: + - copy_encrypted_file is changed + - copy_encrypted_file_result.files|count == 2 + - copy_encrypted_file_result.files[0].checksum == "834563c94127730ecfa42dfc1e1821bbda2e51da" + - copy_encrypted_file_result.files[1].checksum == "74a89620002d253f38834ee5b06cddd28956a43d" + +- name: copy folder with encrypted files without decrypting (idempotent) + win_copy: + src: '{{role_path}}/files-different/vault/' + dest: '{{test_win_copy_path}}\encrypted-test' + decrypt: no + register: copy_encrypted_file_again + +- name: assert result of copy folder with encrypted files without decrypting (idempotent) + assert: + that: + - copy_encrypted_file_again is not changed + +- name: remove test folder after local to remote tests + win_file: + path: '{{test_win_copy_path}}' + state: absent diff --git a/test/integration/targets/incidental_win_data_deduplication/aliases b/test/integration/targets/incidental_win_data_deduplication/aliases new file mode 100644 index 00000000000..c7657537a7e --- /dev/null +++ b/test/integration/targets/incidental_win_data_deduplication/aliases @@ -0,0 +1,5 @@ +shippable/windows/incidental +windows +skip/windows/2008 +skip/windows/2008-R2 +skip/windows/2012 diff --git a/test/integration/targets/incidental_win_data_deduplication/meta/main.yml b/test/integration/targets/incidental_win_data_deduplication/meta/main.yml new file mode 100644 index 00000000000..9f37e96cd90 --- /dev/null +++ b/test/integration/targets/incidental_win_data_deduplication/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: +- setup_remote_tmp_dir diff --git a/test/integration/targets/incidental_win_data_deduplication/tasks/main.yml b/test/integration/targets/incidental_win_data_deduplication/tasks/main.yml new file mode 100644 index 00000000000..ae6be90ecb9 --- /dev/null +++ b/test/integration/targets/incidental_win_data_deduplication/tasks/main.yml @@ -0,0 +1,2 @@ +--- +- include: pre_test.yml diff --git a/test/integration/targets/incidental_win_data_deduplication/tasks/pre_test.yml b/test/integration/targets/incidental_win_data_deduplication/tasks/pre_test.yml new file mode 100644 index 00000000000..f72955e46bf --- /dev/null +++ b/test/integration/targets/incidental_win_data_deduplication/tasks/pre_test.yml @@ -0,0 +1,40 @@ +--- +- set_fact: + AnsibleVhdx: '{{ remote_tmp_dir }}\AnsiblePart.vhdx' + +- name: Install FS-Data-Deduplication + win_feature: + name: FS-Data-Deduplication + include_sub_features: true + state: present + register: data_dedup_feat_reg + +- name: Reboot windows after the feature has been installed + win_reboot: + reboot_timeout: 3600 + when: + - data_dedup_feat_reg.success + - data_dedup_feat_reg.reboot_required + +- name: Copy VHDX scripts + win_template: + src: "{{ item.src }}" + dest: '{{ remote_tmp_dir }}\{{ item.dest }}' + loop: + - { src: partition_creation_script.j2, dest: partition_creation_script.txt } + - { src: partition_deletion_script.j2, dest: partition_deletion_script.txt } + +- name: Create partition + win_command: diskpart.exe /s {{ remote_tmp_dir }}\partition_creation_script.txt + +- name: Format T with NTFS + win_format: + drive_letter: T + file_system: ntfs + +- name: Run tests + block: + - include: tests.yml + always: + - name: Detach disk + win_command: diskpart.exe /s {{ remote_tmp_dir }}\partition_deletion_script.txt diff --git a/test/integration/targets/incidental_win_data_deduplication/tasks/tests.yml b/test/integration/targets/incidental_win_data_deduplication/tasks/tests.yml new file mode 100644 index 00000000000..64a42927130 --- /dev/null +++ b/test/integration/targets/incidental_win_data_deduplication/tasks/tests.yml @@ -0,0 +1,47 @@ +--- + +- name: Enable Data Deduplication on the T drive - check mode + win_data_deduplication: + drive_letter: "T" + state: present + settings: + no_compress: true + minimum_file_age_days: 2 + minimum_file_size: 0 + check_mode: yes + register: win_data_deduplication_enable_check_mode + +- name: Check that it was successful with a change - check mode + assert: + that: + - win_data_deduplication_enable_check_mode is changed + +- name: Enable Data Deduplication on the T drive + win_data_deduplication: + drive_letter: "T" + state: present + settings: + no_compress: true + minimum_file_age_days: 2 + minimum_file_size: 0 + register: win_data_deduplication_enable + +- name: Check that it was successful with a change + assert: + that: + - win_data_deduplication_enable is changed + +- name: Enable Data Deduplication on the T drive + win_data_deduplication: + drive_letter: "T" + state: present + settings: + no_compress: true + minimum_file_age_days: 2 + minimum_file_size: 0 + register: win_data_deduplication_enable_again + +- name: Check that it was successful without a change + assert: + that: + - win_data_deduplication_enable_again is not changed diff --git a/test/integration/targets/incidental_win_data_deduplication/templates/partition_creation_script.j2 b/test/integration/targets/incidental_win_data_deduplication/templates/partition_creation_script.j2 new file mode 100644 index 00000000000..8e47fda95ba --- /dev/null +++ b/test/integration/targets/incidental_win_data_deduplication/templates/partition_creation_script.j2 @@ -0,0 +1,11 @@ +create vdisk file="{{ AnsibleVhdx }}" maximum=2000 type=fixed + +select vdisk file="{{ AnsibleVhdx }}" + +attach vdisk + +convert mbr + +create partition primary + +assign letter="T" diff --git a/test/integration/targets/incidental_win_data_deduplication/templates/partition_deletion_script.j2 b/test/integration/targets/incidental_win_data_deduplication/templates/partition_deletion_script.j2 new file mode 100644 index 00000000000..c2be9cd1446 --- /dev/null +++ b/test/integration/targets/incidental_win_data_deduplication/templates/partition_deletion_script.j2 @@ -0,0 +1,3 @@ +select vdisk file="{{ AnsibleVhdx }}" + +detach vdisk diff --git a/test/integration/targets/incidental_win_dsc/aliases b/test/integration/targets/incidental_win_dsc/aliases new file mode 100644 index 00000000000..9114c742276 --- /dev/null +++ b/test/integration/targets/incidental_win_dsc/aliases @@ -0,0 +1,6 @@ +shippable/windows/incidental +windows +skip/windows/2008 +skip/windows/2008-R2 +skip/windows/2012 +skip/windows/2012-R2 diff --git a/test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.0/DSCResources/ANSIBLE_xSetReboot/ANSIBLE_xSetReboot.psm1 b/test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.0/DSCResources/ANSIBLE_xSetReboot/ANSIBLE_xSetReboot.psm1 new file mode 100644 index 00000000000..dbf1ecf3ee7 --- /dev/null +++ b/test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.0/DSCResources/ANSIBLE_xSetReboot/ANSIBLE_xSetReboot.psm1 @@ -0,0 +1,41 @@ +#Requires -Version 5.0 -Modules CimCmdlets + +Function Get-TargetResource +{ + [CmdletBinding()] + [OutputType([Hashtable])] + param( + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [String]$KeyParam + ) + return @{Value = [bool]$global:DSCMachineStatus} +} + +Function Set-TargetResource +{ + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [String]$KeyParam, + [Bool]$Value = $true + ) + $global:DSCMachineStatus = [int]$Value +} + +Function Test-TargetResource +{ + [CmdletBinding()] + [OutputType([Boolean])] + param ( + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [String]$KeyParam, + [Bool]$Value = $true + ) + $false +} + +Export-ModuleMember -Function *-TargetResource + diff --git a/test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.0/DSCResources/ANSIBLE_xSetReboot/ANSIBLE_xSetReboot.schema.mof b/test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.0/DSCResources/ANSIBLE_xSetReboot/ANSIBLE_xSetReboot.schema.mof new file mode 100644 index 00000000000..288b8877224 --- /dev/null +++ b/test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.0/DSCResources/ANSIBLE_xSetReboot/ANSIBLE_xSetReboot.schema.mof @@ -0,0 +1,7 @@ +[ClassVersion("1.0.0"), FriendlyName("xSetReboot")] +class ANSIBLE_xSetReboot : OMI_BaseResource +{ + [Key] String KeyParam; + [Write] Boolean Value; +}; + diff --git a/test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.0/DSCResources/ANSIBLE_xTestResource/ANSIBLE_xTestResource.psm1 b/test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.0/DSCResources/ANSIBLE_xTestResource/ANSIBLE_xTestResource.psm1 new file mode 100644 index 00000000000..79f64969623 --- /dev/null +++ b/test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.0/DSCResources/ANSIBLE_xTestResource/ANSIBLE_xTestResource.psm1 @@ -0,0 +1,214 @@ +#Requires -Version 5.0 -Modules CimCmdlets + +Function ConvertFrom-CimInstance { + param( + [Parameter(Mandatory=$true)][CimInstance]$Instance + ) + $hashtable = @{ + _cim_instance = $Instance.CimSystemProperties.ClassName + } + foreach ($prop in $Instance.CimInstanceProperties) { + $hashtable."$($prop.Name)" = ConvertTo-OutputValue -Value $prop.Value + } + return $hashtable +} + +Function ConvertTo-OutputValue { + param($Value) + + if ($Value -is [DateTime[]]) { + $Value = $Value | ForEach-Object { $_.ToString("o") } + } elseif ($Value -is [DateTime]) { + $Value = $Value.ToString("o") + } elseif ($Value -is [Double]) { + $Value = $Value.ToString() # To avoid Python 2 double parsing issues on test validation + } elseif ($Value -is [Double[]]) { + $Value = $Value | ForEach-Object { $_.ToString() } + } elseif ($Value -is [PSCredential]) { + $password = $null + $password_ptr = [System.Runtime.InteropServices.Marshal]::SecureStringToGlobalAllocUnicode($Value.Password) + try { + $password = [System.Runtime.InteropServices.Marshal]::PtrToStringUni($password_ptr) + } finally { + [System.Runtime.InteropServices.Marshal]::ZeroFreeGlobalAllocUnicode($password_ptr) + } + $Value = @{ + username = $Value.Username + password = $password + } + } elseif ($Value -is [CimInstance[]]) { + $value_list = [System.Collections.Generic.List`1[Hashtable]]@() + foreach ($cim_instance in $Value) { + $value_list.Add((ConvertFrom-CimInstance -Instance $cim_instance)) + } + $Value = $value_list.ToArray() + } elseif ($Value -is [CimInstance]) { + $Value = ConvertFrom-CimInstance -Instance $Value + } + + return ,$Value +} + +Function Get-TargetResource +{ + [CmdletBinding()] + [OutputType([Hashtable])] + param( + [Parameter(Mandatory = $true)] + [ValidateSet("Present", "Absent")] + [String] $Ensure = "Present", + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] $Path + ) + return @{ + Ensure = $Ensure + Path = $Path + } +} + +Function Set-TargetResource +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateSet("Present", "Absent")] + [String] $Ensure = "Present", + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] $Path, + + [String] $DefaultParam = "Default", + [String] $StringParam, + [String[]] $StringArrayParam, + [SByte] $Int8Param, + [SByte[]] $Int8ArrayParam, + [Byte] $UInt8Param, + [Byte[]] $UInt8ArrayParam, + [Int16] $Int16Param, + [Int16[]] $Int16ArrayParam, + [UInt16] $UInt16Param, + [UInt16[]] $UInt16ArrayParam, + [Int32] $Int32Param, + [Int32[]] $Int32ArrayParam, + [UInt32] $UInt32Param, + [UInt32[]] $UInt32ArrayParam, + [Int64] $Int64Param, + [Int64[]] $Int64ArrayParam, + [UInt64] $UInt64Param, + [UInt64[]] $UInt64ArrayParam, + [Bool] $BooleanParam, + [Bool[]] $BooleanArrayParam, + [Char] $CharParam, + [Char[]] $CharArrayParam, + [Single] $SingleParam, + [Single[]] $SingleArrayParam, + [Double] $DoubleParam, + [Double[]] $DoubleArrayParam, + [DateTime] $DateTimeParam, + [DateTime[]] $DateTimeArrayParam, + [PSCredential] $PSCredentialParam, + [CimInstance[]] $HashtableParam, + [CimInstance] $CimInstanceParam, + [CimInstance[]] $CimInstanceArrayParam, + [CimInstance] $NestedCimInstanceParam, + [CimInstance[]] $NestedCimInstanceArrayParam + ) + + $info = @{ + Version = "1.0.0" + Ensure = @{ + Type = $Ensure.GetType().FullName + Value = $Ensure + } + Path = @{ + Type = $Path.GetType().FullName + Value = $Path + } + DefaultParam = @{ + Type = $DefaultParam.GetType().FullName + Value = $DefaultParam + } + } + + foreach ($kvp in $PSCmdlet.MyInvocation.BoundParameters.GetEnumerator()) { + $info."$($kvp.Key)" = @{ + Type = $kvp.Value.GetType().FullName + Value = (ConvertTo-OutputValue -Value $kvp.Value) + } + } + + if (Test-Path -Path $Path) { + Remove-Item -Path $Path -Force > $null + } + New-Item -Path $Path -ItemType File > $null + Set-Content -Path $Path -Value (ConvertTo-Json -InputObject $info -Depth 10) > $null + Write-Verbose -Message "set verbose" + Write-Warning -Message "set warning" +} + +Function Test-TargetResource +{ + [CmdletBinding()] + [OutputType([Boolean])] + param + ( + [Parameter(Mandatory = $true)] + [ValidateSet("Present", "Absent")] + [String] $Ensure = "Present", + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] $Path, + + [String] $DefaultParam = "Default", + [String] $StringParam, + [String[]] $StringArrayParam, + [SByte] $Int8Param, + [SByte[]] $Int8ArrayParam, + [Byte] $UInt8Param, + [Byte[]] $UInt8ArrayParam, + [Int16] $Int16Param, + [Int16[]] $Int16ArrayParam, + [UInt16] $UInt16Param, + [UInt16[]] $UInt16ArrayParam, + [Int32] $Int32Param, + [Int32[]] $Int32ArrayParam, + [UInt32] $UInt32Param, + [UInt32[]] $UInt32ArrayParam, + [Int64] $Int64Param, + [Int64[]] $Int64ArrayParam, + [UInt64] $UInt64Param, + [UInt64[]] $UInt64ArrayParam, + [Bool] $BooleanParam, + [Bool[]] $BooleanArrayParam, + [Char] $CharParam, + [Char[]] $CharArrayParam, + [Single] $SingleParam, + [Single[]] $SingleArrayParam, + [Double] $DoubleParam, + [Double[]] $DoubleArrayParam, + [DateTime] $DateTimeParam, + [DateTime[]] $DateTimeArrayParam, + [PSCredential] $PSCredentialParam, + [CimInstance[]] $HashtableParam, + [CimInstance] $CimInstanceParam, + [CimInstance[]] $CimInstanceArrayParam, + [CimInstance] $NestedCimInstanceParam, + [CimInstance[]] $NestedCimInstanceArrayParam + ) + Write-Verbose -Message "test verbose" + Write-Warning -Message "test warning" + $exists = Test-Path -LiteralPath $Path -PathType Leaf + if ($Ensure -eq "Present") { + $exists + } else { + -not $exists + } +} + +Export-ModuleMember -Function *-TargetResource + diff --git a/test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.0/DSCResources/ANSIBLE_xTestResource/ANSIBLE_xTestResource.schema.mof b/test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.0/DSCResources/ANSIBLE_xTestResource/ANSIBLE_xTestResource.schema.mof new file mode 100644 index 00000000000..c61b2b1e6a9 --- /dev/null +++ b/test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.0/DSCResources/ANSIBLE_xTestResource/ANSIBLE_xTestResource.schema.mof @@ -0,0 +1,60 @@ +[ClassVersion("1.0.0")] +class ANSIBLE_xTestClass +{ + [Key] String Key; + [Write] String StringValue; + [Write] SInt32 IntValue; + [Write] String StringArrayValue[]; +}; + +[ClassVersion("1.0.0")] +class ANSIBLE_xNestedClass +{ + [Key] String KeyValue; + [Write, EmbeddedInstance("ANSIBLE_xTestClass")] String CimValue; + [Write, EmbeddedInstance("MSFT_KeyValuePair")] String HashValue[]; + [Write] SInt16 IntValue; +}; + +[ClassVersion("1.0.0"), FriendlyName("xTestResource")] +class ANSIBLE_xTestResource : OMI_BaseResource +{ + [Key] String Path; + [Required, ValueMap{"Present", "Absent"}, Values{"Present", "Absent"}] String Ensure; + [Read] String ReadParam; + [Write] String DefaultParam; + [Write] String StringParam; + [Write] String StringArrayParam[]; + [Write] SInt8 Int8Param; + [Write] SInt8 Int8ArrayParam[]; + [Write] UInt8 UInt8Param; + [Write] UInt8 UInt8ArrayParam[]; + [Write] SInt16 Int16Param; + [Write] SInt16 Int16ArrayParam[]; + [Write] UInt16 UInt16Param; + [Write] UInt16 UInt16ArrayParam[]; + [Write] SInt32 Int32Param; + [Write] SInt32 Int32ArrayParam[]; + [Write] UInt32 UInt32Param; + [Write] UInt32 UInt32ArrayParam[]; + [Write] SInt64 Int64Param; + [Write] SInt64 Int64ArrayParam[]; + [Write] UInt64 UInt64Param; + [Write] UInt64 UInt64ArrayParam[]; + [Write] Boolean BooleanParam; + [Write] Boolean BooleanArrayParam[]; + [Write] Char16 CharParam; + [Write] Char16 CharArrayParam[]; + [Write] Real32 SingleParam; + [Write] Real32 SingleArrayParam[]; + [Write] Real64 DoubleParam; + [Write] Real64 DoubleArrayParam[]; + [Write] DateTime DateTimeParam; + [Write] DateTime DateTimeArrayParam[]; + [Write, EmbeddedInstance("MSFT_Credential")] String PSCredentialParam; + [Write, EmbeddedInstance("MSFT_KeyValuePair")] String HashtableParam[]; + [Write, EmbeddedInstance("ANSIBLE_xTestClass")] String CimInstanceArrayParam[]; + [Write, EmbeddedInstance("ANSIBLE_xNestedClass")] String NestedCimInstanceParam; + [Write, EmbeddedInstance("ANSIBLE_xNestedClass")] String NestedCimInstanceArrayParam[]; +}; + diff --git a/test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.0/xTestDsc.psd1 b/test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.0/xTestDsc.psd1 new file mode 100644 index 00000000000..3d61611d70a --- /dev/null +++ b/test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.0/xTestDsc.psd1 @@ -0,0 +1,13 @@ +@{ + ModuleVersion = '1.0.0' + GUID = '80c895c4-de3f-4d6d-8fa4-c504c96b6f22' + Author = 'Ansible' + CompanyName = 'Ansible' + Copyright = '(c) 2019' + Description = 'Test DSC Resource for Ansible integration tests' + PowerShellVersion = '5.0' + CLRVersion = '4.0' + FunctionsToExport = '*' + CmdletsToExport = '*' +} + diff --git a/test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.1/DSCResources/ANSIBLE_xTestResource/ANSIBLE_xTestResource.psm1 b/test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.1/DSCResources/ANSIBLE_xTestResource/ANSIBLE_xTestResource.psm1 new file mode 100644 index 00000000000..d75256e1d90 --- /dev/null +++ b/test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.1/DSCResources/ANSIBLE_xTestResource/ANSIBLE_xTestResource.psm1 @@ -0,0 +1,214 @@ +#Requires -Version 5.0 -Modules CimCmdlets + +Function ConvertFrom-CimInstance { + param( + [Parameter(Mandatory=$true)][CimInstance]$Instance + ) + $hashtable = @{ + _cim_instance = $Instance.CimSystemProperties.ClassName + } + foreach ($prop in $Instance.CimInstanceProperties) { + $hashtable."$($prop.Name)" = ConvertTo-OutputValue -Value $prop.Value + } + return $hashtable +} + +Function ConvertTo-OutputValue { + param($Value) + + if ($Value -is [DateTime[]]) { + $Value = $Value | ForEach-Object { $_.ToString("o") } + } elseif ($Value -is [DateTime]) { + $Value = $Value.ToString("o") + } elseif ($Value -is [Double]) { + $Value = $Value.ToString() # To avoid Python 2 double parsing issues on test validation + } elseif ($Value -is [Double[]]) { + $Value = $Value | ForEach-Object { $_.ToString() } + } elseif ($Value -is [PSCredential]) { + $password = $null + $password_ptr = [System.Runtime.InteropServices.Marshal]::SecureStringToGlobalAllocUnicode($Value.Password) + try { + $password = [System.Runtime.InteropServices.Marshal]::PtrToStringUni($password_ptr) + } finally { + [System.Runtime.InteropServices.Marshal]::ZeroFreeGlobalAllocUnicode($password_ptr) + } + $Value = @{ + username = $Value.Username + password = $password + } + } elseif ($Value -is [CimInstance[]]) { + $value_list = [System.Collections.Generic.List`1[Hashtable]]@() + foreach ($cim_instance in $Value) { + $value_list.Add((ConvertFrom-CimInstance -Instance $cim_instance)) + } + $Value = $value_list.ToArray() + } elseif ($Value -is [CimInstance]) { + $Value = ConvertFrom-CimInstance -Instance $Value + } + + return ,$Value +} + +Function Get-TargetResource +{ + [CmdletBinding()] + [OutputType([Hashtable])] + param( + [Parameter(Mandatory = $true)] + [ValidateSet("Present", "Absent")] + [String] $Ensure = "Present", + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] $Path + ) + return @{ + Ensure = $Ensure + Path = $Path + } +} + +Function Set-TargetResource +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [ValidateSet("Present", "Absent")] + [String] $Ensure = "Present", + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] $Path, + + [String] $DefaultParam = "Default", + [String] $StringParam, + [String[]] $StringArrayParam, + [SByte] $Int8Param, + [SByte[]] $Int8ArrayParam, + [Byte] $UInt8Param, + [Byte[]] $UInt8ArrayParam, + [Int16] $Int16Param, + [Int16[]] $Int16ArrayParam, + [UInt16] $UInt16Param, + [UInt16[]] $UInt16ArrayParam, + [Int32] $Int32Param, + [Int32[]] $Int32ArrayParam, + [UInt32] $UInt32Param, + [UInt32[]] $UInt32ArrayParam, + [Int64] $Int64Param, + [Int64[]] $Int64ArrayParam, + [UInt64] $UInt64Param, + [UInt64[]] $UInt64ArrayParam, + [Bool] $BooleanParam, + [Bool[]] $BooleanArrayParam, + [Char] $CharParam, + [Char[]] $CharArrayParam, + [Single] $SingleParam, + [Single[]] $SingleArrayParam, + [Double] $DoubleParam, + [Double[]] $DoubleArrayParam, + [DateTime] $DateTimeParam, + [DateTime[]] $DateTimeArrayParam, + [PSCredential] $PSCredentialParam, + [CimInstance[]] $HashtableParam, + [CimInstance] $CimInstanceParam, + [CimInstance[]] $CimInstanceArrayParam, + [CimInstance] $NestedCimInstanceParam, + [CimInstance[]] $NestedCimInstanceArrayParam + ) + + $info = @{ + Version = "1.0.1" + Ensure = @{ + Type = $Ensure.GetType().FullName + Value = $Ensure + } + Path = @{ + Type = $Path.GetType().FullName + Value = $Path + } + DefaultParam = @{ + Type = $DefaultParam.GetType().FullName + Value = $DefaultParam + } + } + + foreach ($kvp in $PSCmdlet.MyInvocation.BoundParameters.GetEnumerator()) { + $info."$($kvp.Key)" = @{ + Type = $kvp.Value.GetType().FullName + Value = (ConvertTo-OutputValue -Value $kvp.Value) + } + } + + if (Test-Path -Path $Path) { + Remove-Item -Path $Path -Force > $null + } + New-Item -Path $Path -ItemType File > $null + Set-Content -Path $Path -Value (ConvertTo-Json -InputObject $info -Depth 10) > $null + Write-Verbose -Message "set verbose" + Write-Warning -Message "set warning" +} + +Function Test-TargetResource +{ + [CmdletBinding()] + [OutputType([Boolean])] + param + ( + [Parameter(Mandatory = $true)] + [ValidateSet("Present", "Absent")] + [String] $Ensure = "Present", + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [String] $Path, + + [String] $DefaultParam = "Default", + [String] $StringParam, + [String[]] $StringArrayParam, + [SByte] $Int8Param, + [SByte[]] $Int8ArrayParam, + [Byte] $UInt8Param, + [Byte[]] $UInt8ArrayParam, + [Int16] $Int16Param, + [Int16[]] $Int16ArrayParam, + [UInt16] $UInt16Param, + [UInt16[]] $UInt16ArrayParam, + [Int32] $Int32Param, + [Int32[]] $Int32ArrayParam, + [UInt32] $UInt32Param, + [UInt32[]] $UInt32ArrayParam, + [Int64] $Int64Param, + [Int64[]] $Int64ArrayParam, + [UInt64] $UInt64Param, + [UInt64[]] $UInt64ArrayParam, + [Bool] $BooleanParam, + [Bool[]] $BooleanArrayParam, + [Char] $CharParam, + [Char[]] $CharArrayParam, + [Single] $SingleParam, + [Single[]] $SingleArrayParam, + [Double] $DoubleParam, + [Double[]] $DoubleArrayParam, + [DateTime] $DateTimeParam, + [DateTime[]] $DateTimeArrayParam, + [PSCredential] $PSCredentialParam, + [CimInstance[]] $HashtableParam, + [CimInstance] $CimInstanceParam, + [CimInstance[]] $CimInstanceArrayParam, + [CimInstance] $NestedCimInstanceParam, + [CimInstance[]] $NestedCimInstanceArrayParam + ) + Write-Verbose -Message "test verbose" + Write-Warning -Message "test warning" + $exists = Test-Path -LiteralPath $Path -PathType Leaf + if ($Ensure -eq "Present") { + $exists + } else { + -not $exists + } +} + +Export-ModuleMember -Function *-TargetResource + diff --git a/test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.1/DSCResources/ANSIBLE_xTestResource/ANSIBLE_xTestResource.schema.mof b/test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.1/DSCResources/ANSIBLE_xTestResource/ANSIBLE_xTestResource.schema.mof new file mode 100644 index 00000000000..9301664b3cd --- /dev/null +++ b/test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.1/DSCResources/ANSIBLE_xTestResource/ANSIBLE_xTestResource.schema.mof @@ -0,0 +1,63 @@ +[ClassVersion("1.0.1")] +class ANSIBLE_xTestClass +{ + [Key] String KeyValue; + [Write, ValueMap{"Choice1", "Choice2"}, Values{"Choice1", "Choice2"}] String Choice; + [Write] String StringValue; + [Write] SInt32 IntValue; + [Write] String StringArrayValue[]; +}; + +[ClassVersion("1.0.1")] +class ANSIBLE_xNestedClass +{ + [Key] String KeyValue; + [Write, EmbeddedInstance("ANSIBLE_xTestClass")] String CimValue; + [Write, EmbeddedInstance("ANSIBLE_xTestClass")] String CimArrayValue[]; + [Write, EmbeddedInstance("MSFT_KeyValuePair")] String HashValue[]; + [Write] SInt16 IntValue; +}; + +[ClassVersion("1.0.1"), FriendlyName("xTestResource")] +class ANSIBLE_xTestResource : OMI_BaseResource +{ + [Key] String Path; + [Required, ValueMap{"Present", "Absent"}, Values{"Present", "Absent"}] String Ensure; + [Read] String ReadParam; + [Write] String DefaultParam; + [Write] String StringParam; + [Write] String StringArrayParam[]; + [Write] SInt8 Int8Param; + [Write] SInt8 Int8ArrayParam[]; + [Write] UInt8 UInt8Param; + [Write] UInt8 UInt8ArrayParam[]; + [Write] SInt16 Int16Param; + [Write] SInt16 Int16ArrayParam[]; + [Write] UInt16 UInt16Param; + [Write] UInt16 UInt16ArrayParam[]; + [Write] SInt32 Int32Param; + [Write] SInt32 Int32ArrayParam[]; + [Write] UInt32 UInt32Param; + [Write] UInt32 UInt32ArrayParam[]; + [Write] SInt64 Int64Param; + [Write] SInt64 Int64ArrayParam[]; + [Write] UInt64 UInt64Param; + [Write] UInt64 UInt64ArrayParam[]; + [Write] Boolean BooleanParam; + [Write] Boolean BooleanArrayParam[]; + [Write] Char16 CharParam; + [Write] Char16 CharArrayParam[]; + [Write] Real32 SingleParam; + [Write] Real32 SingleArrayParam[]; + [Write] Real64 DoubleParam; + [Write] Real64 DoubleArrayParam[]; + [Write] DateTime DateTimeParam; + [Write] DateTime DateTimeArrayParam[]; + [Write, EmbeddedInstance("MSFT_Credential")] String PSCredentialParam; + [Write, EmbeddedInstance("MSFT_KeyValuePair")] String HashtableParam[]; + [Write, EmbeddedInstance("ANSIBLE_xTestClass")] String CimInstanceParam; + [Write, EmbeddedInstance("ANSIBLE_xTestClass")] String CimInstanceArrayParam[]; + [Write, EmbeddedInstance("ANSIBLE_xNestedClass")] String NestedCimInstanceParam; + [Write, EmbeddedInstance("ANSIBLE_xNestedClass")] String NestedCimInstanceArrayParam[]; +}; + diff --git a/test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.1/xTestDsc.psd1 b/test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.1/xTestDsc.psd1 new file mode 100644 index 00000000000..0c43b85238d --- /dev/null +++ b/test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.1/xTestDsc.psd1 @@ -0,0 +1,13 @@ +@{ + ModuleVersion = '1.0.1' + GUID = '80c895c4-de3f-4d6d-8fa4-c504c96b6f22' + Author = 'Ansible' + CompanyName = 'Ansible' + Copyright = '(c) 2019' + Description = 'Test DSC Resource for Ansible integration tests' + PowerShellVersion = '5.0' + CLRVersion = '4.0' + FunctionsToExport = '*' + CmdletsToExport = '*' +} + diff --git a/test/integration/targets/incidental_win_dsc/meta/main.yml b/test/integration/targets/incidental_win_dsc/meta/main.yml new file mode 100644 index 00000000000..9f37e96cd90 --- /dev/null +++ b/test/integration/targets/incidental_win_dsc/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: +- setup_remote_tmp_dir diff --git a/test/integration/targets/incidental_win_dsc/tasks/main.yml b/test/integration/targets/incidental_win_dsc/tasks/main.yml new file mode 100644 index 00000000000..f37295ab711 --- /dev/null +++ b/test/integration/targets/incidental_win_dsc/tasks/main.yml @@ -0,0 +1,39 @@ +--- +- name: get powershell version + win_shell: $PSVersionTable.PSVersion.Major + register: powershell_version + +- name: expect failure when running on old PS hosts + win_dsc: + resource_name: File + register: fail_dsc_old + failed_when: '"This module cannot run as it requires a minimum PowerShell version of 5.0" not in fail_dsc_old.msg' + when: powershell_version.stdout_lines[0]|int < 5 + +- name: run tests when PSv5+ + when: powershell_version.stdout_lines[0]|int >= 5 + block: + - name: add remote temp dir to PSModulePath + win_path: + name: PSModulePath + state: present + scope: machine + elements: + - '{{ remote_tmp_dir }}' + + - name: copy custom DSC resources to remote temp dir + win_copy: + src: xTestDsc + dest: '{{ remote_tmp_dir }}' + + - name: run tests + include_tasks: tests.yml + + always: + - name: remove remote tmp dir from PSModulePath + win_path: + name: PSModulePath + state: absent + scope: machine + elements: + - '{{ remote_tmp_dir }}' diff --git a/test/integration/targets/incidental_win_dsc/tasks/tests.yml b/test/integration/targets/incidental_win_dsc/tasks/tests.yml new file mode 100644 index 00000000000..d2a6802fdfa --- /dev/null +++ b/test/integration/targets/incidental_win_dsc/tasks/tests.yml @@ -0,0 +1,544 @@ +--- +- name: fail with incorrect DSC resource name + win_dsc: + resource_name: FakeResource + register: fail_invalid_resource + failed_when: fail_invalid_resource.msg != "Resource 'FakeResource' not found." + +- name: fail with invalid DSC version + win_dsc: + resource_name: xTestResource + module_version: 0.0.1 + register: fail_invalid_version + failed_when: 'fail_invalid_version.msg != "Resource ''xTestResource'' with version ''0.0.1'' not found. Versions installed: ''1.0.0'', ''1.0.1''."' + +- name: fail with mandatory option not set + win_dsc: + resource_name: xSetReboot + Value: yes + register: fail_man_key + failed_when: 'fail_man_key.msg != "missing required arguments: KeyParam"' + +- name: fail with mandatory option not set in sub dict + win_dsc: + resource_name: xTestResource + Path: C:\path + Ensure: Present + CimInstanceParam: # Missing KeyValue in dict + Choice: Choice1 + register: fail_man_key_sub_dict + failed_when: 'fail_man_key_sub_dict.msg != "missing required arguments: KeyValue found in CimInstanceParam"' + +- name: fail invalid option + win_dsc: + resource_name: xSetReboot + KeyParam: key + OtherParam: invalid + register: fail_invalid_option + failed_when: 'fail_invalid_option.msg != "Unsupported parameters for (win_dsc) module: OtherParam. Supported parameters include: KeyParam, PsDscRunAsCredential_username, module_version, Value, PsDscRunAsCredential_password, resource_name, DependsOn"' + +- name: fail invalid option in sub dict + win_dsc: + resource_name: xTestResource + Path: C:\path + Ensure: Present + NestedCimInstanceParam: + KeyValue: key + CimValue: + KeyValue: other key + InvalidKey: invalid + register: fail_invalid_option_sub_dict + failed_when: 'fail_invalid_option_sub_dict.msg != "Unsupported parameters for (win_dsc) module: InvalidKey found in NestedCimInstanceParam -> CimValue. Supported parameters include: IntValue, KeyValue, StringArrayValue, Choice, StringValue"' + +- name: fail invalid read only option + win_dsc: + resource_name: xTestResource + Path: C:\path + Ensure: Present + ReadParam: abc + register: fail_invalid_option_read_only + failed_when: '"Unsupported parameters for (win_dsc) module: ReadParam" not in fail_invalid_option_read_only.msg' + +- name: fail invalid choice + win_dsc: + resource_name: xTestResource + Path: C:\path + Ensure: invalid + register: fail_invalid_choice + failed_when: 'fail_invalid_choice.msg != "value of Ensure must be one of: Present, Absent. Got no match for: invalid"' + +- name: fail invalid choice in sub dict + win_dsc: + resource_name: xTestResource + Path: C:\path + Ensure: Present + CimInstanceArrayParam: + - KeyValue: key + - KeyValue: key2 + Choice: Choice3 + register: fail_invalid_choice_sub_dict + failed_when: 'fail_invalid_choice_sub_dict.msg != "value of Choice must be one of: Choice1, Choice2. Got no match for: Choice3 found in CimInstanceArrayParam"' + +- name: fail old version missing new option + win_dsc: + resource_name: xTestResource + module_version: 1.0.0 + Path: C:\path + Ensure: Present + CimInstanceParam: # CimInstanceParam does not exist in the 1.0.0 version + Key: key + register: fail_invalid_option_old + failed_when: '"Unsupported parameters for (win_dsc) module: CimInstanceParam" not in fail_invalid_option_old.msg' + +- name: fail old version missing new option sub dict + win_dsc: + resource_name: xTestResource + module_version: 1.0.0 + Path: C:\path + Ensure: Present + CimInstanceArrayParam: + - Key: key + Choice: Choice1 + register: fail_invalid_option_old_sub_dict + failed_when: 'fail_invalid_option_old_sub_dict.msg != "Unsupported parameters for (win_dsc) module: Choice found in CimInstanceArrayParam. Supported parameters include: Key, IntValue, StringArrayValue, StringValue"' + +- name: create test file (check mode) + win_dsc: + resource_name: File + DestinationPath: '{{ remote_tmp_dir }}\dsc-file' + Contents: file contents + Attributes: + - Hidden + - ReadOnly + Ensure: Present + Type: File + register: create_file_check + check_mode: yes + +- name: get result of create test file (check mode) + win_stat: + path: '{{ remote_tmp_dir }}\dsc-file' + register: create_file_actual_check + +- name: assert create test file (check mode) + assert: + that: + - create_file_check is changed + - create_file_check.module_version == None # Some built in modules don't have a version set + - not create_file_check.reboot_required + - not create_file_actual_check.stat.exists + +- name: assert create test file verbosity (check mode) + assert: + that: + - create_file_check.verbose_test is defined + - not create_file_check.verbose_set is defined + when: ansible_verbosity >= 3 + +- name: create test file + win_dsc: + resource_name: File + DestinationPath: '{{ remote_tmp_dir }}\dsc-file' + Contents: file contents + Attributes: + - Hidden + - ReadOnly + Ensure: Present + Type: File + register: create_file + +- name: get result of create test file + win_stat: + path: '{{ remote_tmp_dir }}\dsc-file' + register: create_file_actual + +- name: assert create test file verbosity + assert: + that: + - create_file.verbose_test is defined + - create_file.verbose_set is defined + when: ansible_verbosity >= 3 + +- name: assert create test file + assert: + that: + - create_file is changed + - create_file.module_version == None + - not create_file.reboot_required + - create_file_actual.stat.exists + - create_file_actual.stat.attributes == "ReadOnly, Hidden, Archive" + - create_file_actual.stat.checksum == 'd48daab51112b49ecabd917adc345b8ba257055e' + +- name: create test file (idempotent) + win_dsc: + resource_name: File + DestinationPath: '{{ remote_tmp_dir }}\dsc-file' + Contents: file contents + Attributes: + - Hidden + - ReadOnly + Ensure: Present + Type: File + register: create_file_again + +- name: assert create test file (idempotent) + assert: + that: + - not create_file_again is changed + - create_file.module_version == None + - not create_file.reboot_required + +- name: get SID of the current Ansible user + win_shell: | + Add-Type -AssemblyName System.DirectoryServices.AccountManagement + [System.DirectoryServices.AccountManagement.UserPrincipal]::Current.Sid.Value + register: actual_sid + +- name: run DSC process as another user + win_dsc: + resource_name: Script + GetScript: '@{ Result= "" }' + SetScript: | + Add-Type -AssemblyName System.DirectoryServices.AccountManagement + $sid = [System.DirectoryServices.AccountManagement.UserPrincipal]::Current.Sid.Value + Set-Content -Path "{{ remote_tmp_dir }}\runas.txt" -Value $sid + TestScript: $false + PsDscRunAsCredential_username: '{{ ansible_user }}' + PsDscRunAsCredential_password: '{{ ansible_password }}' + register: runas_user + +- name: get result of run DSC process as another user + slurp: + path: '{{ remote_tmp_dir }}\runas.txt' + register: runas_user_result + +- name: assert run DSC process as another user + assert: + that: + - runas_user is changed + - runas_user.module_version != None # Can't reliably set the version but we can test it is set + - not runas_user.reboot_required + - runas_user_result.content|b64decode == actual_sid.stdout + +- name: run DSC that sets reboot_required with defaults + win_dsc: + resource_name: xSetReboot + KeyParam: value # Just to satisfy the Resource with key validation + register: set_reboot_defaults + +- name: assert run DSC that sets reboot_required with defaults + assert: + that: + - set_reboot_defaults.reboot_required + +- name: run DSC that sets reboot_required with False + win_dsc: + resource_name: xSetReboot + KeyParam: value + Value: no + register: set_reboot_false + +- name: assert run DSC that sets reboot_required with False + assert: + that: + - not set_reboot_false.reboot_required + +- name: run DSC that sets reboot_required with True + win_dsc: + resource_name: xSetReboot + KeyParam: value + Value: yes + register: set_reboot_true + +- name: assert run DSC that sets reboot_required with True + assert: + that: + - set_reboot_true.reboot_required + +- name: test DSC with all types + win_dsc: + resource_name: xTestResource + Path: '{{ remote_tmp_dir }}\test-types.json' + Ensure: Present + StringParam: string param + StringArrayParam: + - string 1 + - string 2 + Int8Param: 127 # [SByte]::MaxValue + Int8ArrayParam: + - 127 + - '127' + UInt8Param: 255 # [Byte]::MaxValue + UInt8ArrayParam: + - 255 + - '255' + Int16Param: 32767 # [Int16]::MaxValue + Int16ArrayParam: 32767, 32767 + UInt16Param: '65535' # [UInt16]::MaxValue + UInt16ArrayParam: 65535 + Int32Param: 2147483647 # [Int32]::MaxValue + Int32ArrayParam: '2147483647' + UInt32Param: '4294967295' # [UInt32]::MaxValue + UInt32ArrayParam: + - '4294967295' + - 4294967295 + Int64Param: 9223372036854775807 # [Int64]::MaxValue + Int64ArrayParam: + - -9223372036854775808 # [Int64]::MinValue + - 9223372036854775807 + UInt64Param: 18446744073709551615 # [UInt64]::MaxValue + UInt64ArrayParam: + - 0 # [UInt64]::MinValue + - 18446744073709551615 + BooleanParam: True + BooleanArrayParam: + - True + - 'True' + - 'true' + - 'y' + - 'yes' + - 1 + - False + - 'False' + - 'false' + - 'n' + - 'no' + - 0 + CharParam: c + CharArrayParam: + - c + - h + - a + - r + SingleParam: 3.402823E+38 + SingleArrayParam: + - '3.402823E+38' + - 1.2393494 + DoubleParam: 1.79769313486232E+300 + DoubleArrayParam: + - '1.79769313486232E+300' + - 3.56821831681516 + DateTimeParam: '2019-02-22T13:57:31.2311892-04:00' + DateTimeArrayParam: + - '2019-02-22T13:57:31.2311892+00:00' + - '2019-02-22T13:57:31.2311892+04:00' + PSCredentialParam_username: username1 + PSCredentialParam_password: password1 + HashtableParam: + key1: string 1 + key2: '' + key3: 1 + CimInstanceParam: + KeyValue: a + CimInstanceArrayParam: + - KeyValue: b + Choice: Choice1 + StringValue: string 1 + IntValue: 1 + StringArrayValue: + - abc + - def + - KeyValue: c + Choice: Choice2 + StringValue: string 2 + IntValue: '2' + StringArrayValue: + - ghi + - jkl + NestedCimInstanceParam: + KeyValue: key value + CimValue: + KeyValue: d + CimArrayValue: + - KeyValue: e + Choice: Choice2 + HashValue: + a: a + IntValue: '300' + register: dsc_types + +- name: get result of test DSC with all types + slurp: + path: '{{ remote_tmp_dir }}\test-types.json' + register: dsc_types_raw + +- name: convert result of test DSC with all types to dict + set_fact: + dsc_types_actual: '{{ dsc_types_raw.content | b64decode | from_json }}' + +- name: assert test DSC with all types + assert: + that: + - dsc_types is changed + - dsc_types.module_version == '1.0.1' + - not dsc_types.reboot_required + - dsc_types_actual.Version == '1.0.1' + - dsc_types_actual.Verbose.Value.IsPresent + - dsc_types_actual.DefaultParam.Value == 'Default' # ensures that the default is set in the engine if we don't set it outselves + - dsc_types_actual.Ensure.Value == 'Present' + - dsc_types_actual.Path.Value == remote_tmp_dir + "\\test-types.json" + - dsc_types_actual.StringParam.Type == 'System.String' + - dsc_types_actual.StringParam.Value == 'string param' + - dsc_types_actual.StringArrayParam.Type == 'System.String[]' + - dsc_types_actual.StringArrayParam.Value == ['string 1', 'string 2'] + - dsc_types_actual.Int8Param.Type == 'System.SByte' + - dsc_types_actual.Int8Param.Value == 127 + - dsc_types_actual.Int8ArrayParam.Type == 'System.SByte[]' + - dsc_types_actual.Int8ArrayParam.Value == [127, 127] + - dsc_types_actual.UInt8Param.Type == 'System.Byte' + - dsc_types_actual.UInt8Param.Value == 255 + - dsc_types_actual.UInt8ArrayParam.Type == 'System.Byte[]' + - dsc_types_actual.UInt8ArrayParam.Value == [255, 255] + - dsc_types_actual.Int16Param.Type == 'System.Int16' + - dsc_types_actual.Int16Param.Value == 32767 + - dsc_types_actual.Int16ArrayParam.Type == 'System.Int16[]' + - dsc_types_actual.Int16ArrayParam.Value == [32767, 32767] + - dsc_types_actual.UInt16Param.Type == 'System.UInt16' + - dsc_types_actual.UInt16Param.Value == 65535 + - dsc_types_actual.UInt16ArrayParam.Type == 'System.UInt16[]' + - dsc_types_actual.UInt16ArrayParam.Value == [65535] + - dsc_types_actual.Int32Param.Type == 'System.Int32' + - dsc_types_actual.Int32Param.Value == 2147483647 + - dsc_types_actual.Int32ArrayParam.Type == 'System.Int32[]' + - dsc_types_actual.Int32ArrayParam.Value == [2147483647] + - dsc_types_actual.UInt32Param.Type == 'System.UInt32' + - dsc_types_actual.UInt32Param.Value == 4294967295 + - dsc_types_actual.UInt32ArrayParam.Type == 'System.UInt32[]' + - dsc_types_actual.UInt32ArrayParam.Value == [4294967295, 4294967295] + - dsc_types_actual.Int64Param.Type == 'System.Int64' + - dsc_types_actual.Int64Param.Value == 9223372036854775807 + - dsc_types_actual.Int64ArrayParam.Type == 'System.Int64[]' + - dsc_types_actual.Int64ArrayParam.Value == [-9223372036854775808, 9223372036854775807] + - dsc_types_actual.UInt64Param.Type == 'System.UInt64' + - dsc_types_actual.UInt64Param.Value == 18446744073709551615 + - dsc_types_actual.UInt64ArrayParam.Type == 'System.UInt64[]' + - dsc_types_actual.UInt64ArrayParam.Value == [0, 18446744073709551615] + - dsc_types_actual.BooleanParam.Type == 'System.Boolean' + - dsc_types_actual.BooleanParam.Value == True + - dsc_types_actual.BooleanArrayParam.Type == 'System.Boolean[]' + - dsc_types_actual.BooleanArrayParam.Value == [True, True, True, True, True, True, False, False, False, False, False, False] + - dsc_types_actual.CharParam.Type == 'System.Char' + - dsc_types_actual.CharParam.Value == 'c' + - dsc_types_actual.CharArrayParam.Type == 'System.Char[]' + - dsc_types_actual.CharArrayParam.Value == ['c', 'h', 'a', 'r'] + - dsc_types_actual.SingleParam.Type == 'System.Single' + - dsc_types_actual.SingleParam.Value|string == '3.402823e+38' + - dsc_types_actual.SingleArrayParam.Type == 'System.Single[]' + - dsc_types_actual.SingleArrayParam.Value|length == 2 + - dsc_types_actual.SingleArrayParam.Value[0]|string == '3.402823e+38' + - dsc_types_actual.SingleArrayParam.Value[1]|string == '1.23934937' + - dsc_types_actual.DoubleParam.Type == 'System.Double' + - dsc_types_actual.DoubleParam.Value == '1.79769313486232E+300' + - dsc_types_actual.DoubleArrayParam.Type == 'System.Double[]' + - dsc_types_actual.DoubleArrayParam.Value|length == 2 + - dsc_types_actual.DoubleArrayParam.Value[0] == '1.79769313486232E+300' + - dsc_types_actual.DoubleArrayParam.Value[1] == '3.56821831681516' + - dsc_types_actual.DateTimeParam.Type == 'System.DateTime' + - dsc_types_actual.DateTimeParam.Value == '2019-02-22T17:57:31.2311890+00:00' + - dsc_types_actual.DateTimeArrayParam.Type == 'System.DateTime[]' + - dsc_types_actual.DateTimeArrayParam.Value == ['2019-02-22T13:57:31.2311890+00:00', '2019-02-22T09:57:31.2311890+00:00'] + - dsc_types_actual.PSCredentialParam.Type == 'System.Management.Automation.PSCredential' + - dsc_types_actual.PSCredentialParam.Value.username == 'username1' + - dsc_types_actual.PSCredentialParam.Value.password == 'password1' + # Hashtable is actually a CimInstance[] of MSFT_KeyValuePairs + - dsc_types_actual.HashtableParam.Type == 'Microsoft.Management.Infrastructure.CimInstance[]' + - dsc_types_actual.HashtableParam.Value|length == 3 + # Can't guarantee the order of the keys so just check they are the values they could be + - dsc_types_actual.HashtableParam.Value[0].Key in ["key1", "key2", "key3"] + - dsc_types_actual.HashtableParam.Value[0].Value in ["string 1", "1", ""] + - dsc_types_actual.HashtableParam.Value[0]._cim_instance == 'MSFT_KeyValuePair' + - dsc_types_actual.HashtableParam.Value[1].Key in ["key1", "key2", "key3"] + - dsc_types_actual.HashtableParam.Value[1].Value in ["string 1", "1", ""] + - dsc_types_actual.HashtableParam.Value[1]._cim_instance == 'MSFT_KeyValuePair' + - dsc_types_actual.HashtableParam.Value[2].Key in ["key1", "key2", "key3"] + - dsc_types_actual.HashtableParam.Value[2].Value in ["string 1", "1", ""] + - dsc_types_actual.HashtableParam.Value[2]._cim_instance == 'MSFT_KeyValuePair' + - dsc_types_actual.CimInstanceParam.Type == 'Microsoft.Management.Infrastructure.CimInstance' + - dsc_types_actual.CimInstanceParam.Value.Choice == None + - dsc_types_actual.CimInstanceParam.Value.IntValue == None + - dsc_types_actual.CimInstanceParam.Value.KeyValue == 'a' + - dsc_types_actual.CimInstanceParam.Value.StringArrayValue == None + - dsc_types_actual.CimInstanceParam.Value.StringValue == None + - dsc_types_actual.CimInstanceParam.Value._cim_instance == "ANSIBLE_xTestClass" + - dsc_types_actual.CimInstanceArrayParam.Type == 'Microsoft.Management.Infrastructure.CimInstance[]' + - dsc_types_actual.CimInstanceArrayParam.Value|length == 2 + - dsc_types_actual.CimInstanceArrayParam.Value[0].Choice == 'Choice1' + - dsc_types_actual.CimInstanceArrayParam.Value[0].IntValue == 1 + - dsc_types_actual.CimInstanceArrayParam.Value[0].KeyValue == 'b' + - dsc_types_actual.CimInstanceArrayParam.Value[0].StringArrayValue == ['abc', 'def'] + - dsc_types_actual.CimInstanceArrayParam.Value[0].StringValue == 'string 1' + - dsc_types_actual.CimInstanceArrayParam.Value[0]._cim_instance == 'ANSIBLE_xTestClass' + - dsc_types_actual.CimInstanceArrayParam.Value[1].Choice == 'Choice2' + - dsc_types_actual.CimInstanceArrayParam.Value[1].IntValue == 2 + - dsc_types_actual.CimInstanceArrayParam.Value[1].KeyValue == 'c' + - dsc_types_actual.CimInstanceArrayParam.Value[1].StringArrayValue == ['ghi', 'jkl'] + - dsc_types_actual.CimInstanceArrayParam.Value[1].StringValue == 'string 2' + - dsc_types_actual.CimInstanceArrayParam.Value[1]._cim_instance == 'ANSIBLE_xTestClass' + - dsc_types_actual.NestedCimInstanceParam.Type == 'Microsoft.Management.Infrastructure.CimInstance' + - dsc_types_actual.NestedCimInstanceParam.Value.CimArrayValue|length == 1 + - dsc_types_actual.NestedCimInstanceParam.Value.CimArrayValue[0].Choice == 'Choice2' + - dsc_types_actual.NestedCimInstanceParam.Value.CimArrayValue[0].IntValue == None + - dsc_types_actual.NestedCimInstanceParam.Value.CimArrayValue[0].KeyValue == 'e' + - dsc_types_actual.NestedCimInstanceParam.Value.CimArrayValue[0].StringArrayValue == None + - dsc_types_actual.NestedCimInstanceParam.Value.CimArrayValue[0].StringValue == None + - dsc_types_actual.NestedCimInstanceParam.Value.CimArrayValue[0]._cim_instance == 'ANSIBLE_xTestClass' + - dsc_types_actual.NestedCimInstanceParam.Value.CimValue.Choice == None + - dsc_types_actual.NestedCimInstanceParam.Value.CimValue.IntValue == None + - dsc_types_actual.NestedCimInstanceParam.Value.CimValue.KeyValue == 'd' + - dsc_types_actual.NestedCimInstanceParam.Value.CimValue.StringArrayValue == None + - dsc_types_actual.NestedCimInstanceParam.Value.CimValue.StringValue == None + - dsc_types_actual.NestedCimInstanceParam.Value.CimValue._cim_instance == 'ANSIBLE_xTestClass' + - dsc_types_actual.NestedCimInstanceParam.Value.HashValue|length == 1 + - dsc_types_actual.NestedCimInstanceParam.Value.HashValue[0].Key == 'a' + - dsc_types_actual.NestedCimInstanceParam.Value.HashValue[0].Value == 'a' + - dsc_types_actual.NestedCimInstanceParam.Value.HashValue[0]._cim_instance == 'MSFT_KeyValuePair' + - dsc_types_actual.NestedCimInstanceParam.Value.IntValue == 300 + - dsc_types_actual.NestedCimInstanceParam.Value.KeyValue == 'key value' + - dsc_types_actual.NestedCimInstanceParam.Value._cim_instance == 'ANSIBLE_xNestedClass' + +- name: test DSC with all types older version + win_dsc: + resource_name: xTestResource + module_version: 1.0.0 + Path: '{{ remote_tmp_dir }}\test-types.json' + Ensure: Absent + StringParam: string param old + CimInstanceArrayParam: + - Key: old key + StringValue: string old 1 + IntValue: 0 + StringArrayValue: + - zyx + - wvu + register: dsc_types_old + +- name: get result of test DSC with all types older version + slurp: + path: '{{ remote_tmp_dir }}\test-types.json' + register: dsc_types_old_raw + +- name: convert result of test DSC with all types to dict + set_fact: + dsc_types_old_actual: '{{ dsc_types_old_raw.content | b64decode | from_json }}' + +- name: assert test DSC with all types older version + assert: + that: + - dsc_types_old is changed + - dsc_types_old.module_version == '1.0.0' + - not dsc_types_old.reboot_required + - dsc_types_old_actual.Version == '1.0.0' + - dsc_types_old_actual.Verbose.Value.IsPresent + - dsc_types_old_actual.DefaultParam.Value == 'Default' + - dsc_types_old_actual.Ensure.Value == 'Absent' + - dsc_types_old_actual.Path.Value == remote_tmp_dir + "\\test-types.json" + - dsc_types_old_actual.StringParam.Type == 'System.String' + - dsc_types_old_actual.StringParam.Value == 'string param old' + - dsc_types_old_actual.CimInstanceArrayParam.Type == 'Microsoft.Management.Infrastructure.CimInstance[]' + - dsc_types_old_actual.CimInstanceArrayParam.Value|length == 1 + - not dsc_types_old_actual.CimInstanceArrayParam.Value[0].Choice is defined # 1.0.0 does not have a Choice option + - dsc_types_old_actual.CimInstanceArrayParam.Value[0].IntValue == 0 + - dsc_types_old_actual.CimInstanceArrayParam.Value[0].Key == 'old key' + - dsc_types_old_actual.CimInstanceArrayParam.Value[0].StringArrayValue == ['zyx', 'wvu'] + - dsc_types_old_actual.CimInstanceArrayParam.Value[0].StringValue == 'string old 1' + - dsc_types_old_actual.CimInstanceArrayParam.Value[0]._cim_instance == 'ANSIBLE_xTestClass' diff --git a/test/integration/targets/incidental_win_hosts/aliases b/test/integration/targets/incidental_win_hosts/aliases new file mode 100644 index 00000000000..a5fc90dcf48 --- /dev/null +++ b/test/integration/targets/incidental_win_hosts/aliases @@ -0,0 +1,2 @@ +shippable/windows/incidental +windows diff --git a/test/integration/targets/incidental_win_hosts/defaults/main.yml b/test/integration/targets/incidental_win_hosts/defaults/main.yml new file mode 100644 index 00000000000..c6270216d68 --- /dev/null +++ b/test/integration/targets/incidental_win_hosts/defaults/main.yml @@ -0,0 +1,13 @@ +--- +test_win_hosts_cname: testhost +test_win_hosts_ip: 192.168.168.1 + +test_win_hosts_aliases_set: + - alias1 + - alias2 + - alias3 + - alias4 + +test_win_hosts_aliases_remove: + - alias3 + - alias4 diff --git a/test/integration/targets/incidental_win_hosts/meta/main.yml b/test/integration/targets/incidental_win_hosts/meta/main.yml new file mode 100644 index 00000000000..9f37e96cd90 --- /dev/null +++ b/test/integration/targets/incidental_win_hosts/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: +- setup_remote_tmp_dir diff --git a/test/integration/targets/incidental_win_hosts/tasks/main.yml b/test/integration/targets/incidental_win_hosts/tasks/main.yml new file mode 100644 index 00000000000..0997375f9fd --- /dev/null +++ b/test/integration/targets/incidental_win_hosts/tasks/main.yml @@ -0,0 +1,17 @@ +--- +- name: take a copy of the original hosts file + win_copy: + src: C:\Windows\System32\drivers\etc\hosts + dest: '{{ remote_tmp_dir }}\hosts' + remote_src: yes + +- block: + - name: run tests + include_tasks: tests.yml + + always: + - name: restore hosts file + win_copy: + src: '{{ remote_tmp_dir }}\hosts' + dest: C:\Windows\System32\drivers\etc\hosts + remote_src: yes diff --git a/test/integration/targets/incidental_win_hosts/tasks/tests.yml b/test/integration/targets/incidental_win_hosts/tasks/tests.yml new file mode 100644 index 00000000000..a29e01a708b --- /dev/null +++ b/test/integration/targets/incidental_win_hosts/tasks/tests.yml @@ -0,0 +1,189 @@ +--- + +- name: add a simple host with address + win_hosts: + state: present + ip_address: "{{ test_win_hosts_ip }}" + canonical_name: "{{ test_win_hosts_cname }}" + register: add_ip + +- assert: + that: + - "add_ip.changed == true" + +- name: get actual dns result + win_shell: "try{ [array]$t = [Net.DNS]::GetHostEntry('{{ test_win_hosts_cname }}') } catch { return 'false' } if ($t[0].HostName -eq '{{ test_win_hosts_cname }}' -and $t[0].AddressList[0].toString() -eq '{{ test_win_hosts_ip }}'){ return 'true' } else { return 'false' }" + register: add_ip_actual + +- assert: + that: + - "add_ip_actual.stdout_lines[0]|lower == 'true'" + +- name: add a simple host with ipv4 address (idempotent) + win_hosts: + state: present + ip_address: "{{ test_win_hosts_ip }}" + canonical_name: "{{ test_win_hosts_cname }}" + register: add_ip + +- assert: + that: + - "add_ip.changed == false" + +- name: remove simple host + win_hosts: + state: absent + ip_address: "{{ test_win_hosts_ip }}" + canonical_name: "{{ test_win_hosts_cname }}" + register: remove_ip + +- assert: + that: + - "remove_ip.changed == true" + +- name: get actual dns result + win_shell: "try{ [array]$t = [Net.DNS]::GetHostEntry('{{ test_win_hosts_cname}}') } catch { return 'false' } if ($t[0].HostName -eq '{{ test_win_hosts_cname }}' -and $t[0].AddressList[0].toString() -eq '{{ test_win_hosts_ip }}'){ return 'true' } else { return 'false' }" + register: remove_ip_actual + failed_when: "remove_ip_actual.rc == 0" + +- assert: + that: + - "remove_ip_actual.stdout_lines[0]|lower == 'false'" + +- name: remove simple host (idempotent) + win_hosts: + state: absent + ip_address: "{{ test_win_hosts_ip }}" + canonical_name: "{{ test_win_hosts_cname }}" + register: remove_ip + +- assert: + that: + - "remove_ip.changed == false" + +- name: add host and set aliases + win_hosts: + state: present + ip_address: "{{ test_win_hosts_ip }}" + canonical_name: "{{ test_win_hosts_cname }}" + aliases: "{{ test_win_hosts_aliases_set | union(test_win_hosts_aliases_remove) }}" + action: set + register: set_aliases + +- assert: + that: + - "set_aliases.changed == true" + +- name: get actual dns result for host + win_shell: "try{ [array]$t = [Net.DNS]::GetHostEntry('{{ test_win_hosts_cname }}') } catch { return 'false' } if ($t[0].HostName -eq '{{ test_win_hosts_cname }}' -and $t[0].AddressList[0].toString() -eq '{{ test_win_hosts_ip }}'){ return 'true' } else { return 'false' }" + register: set_aliases_actual_host + +- assert: + that: + - "set_aliases_actual_host.stdout_lines[0]|lower == 'true'" + +- name: get actual dns results for aliases + win_shell: "try{ [array]$t = [Net.DNS]::GetHostEntry('{{ item }}') } catch { return 'false' } if ($t[0].HostName -eq '{{ test_win_hosts_cname }}' -and $t[0].AddressList[0].toString() -eq '{{ test_win_hosts_ip }}'){ return 'true' } else { return 'false' }" + register: set_aliases_actual + with_items: "{{ test_win_hosts_aliases_set | union(test_win_hosts_aliases_remove) }}" + +- assert: + that: + - "item.stdout_lines[0]|lower == 'true'" + with_items: "{{ set_aliases_actual.results }}" + +- name: add host and set aliases (idempotent) + win_hosts: + state: present + ip_address: "{{ test_win_hosts_ip }}" + canonical_name: "{{ test_win_hosts_cname }}" + aliases: "{{ test_win_hosts_aliases_set | union(test_win_hosts_aliases_remove) }}" + action: set + register: set_aliases + +- assert: + that: + - "set_aliases.changed == false" + +- name: remove aliases from the list + win_hosts: + state: present + ip_address: "{{ test_win_hosts_ip }}" + canonical_name: "{{ test_win_hosts_cname }}" + aliases: "{{ test_win_hosts_aliases_remove }}" + action: remove + register: remove_aliases + +- assert: + that: + - "remove_aliases.changed == true" + +- name: get actual dns result for removed aliases + win_shell: "try{ [array]$t = [Net.DNS]::GetHostEntry('{{ item }}') } catch { return 'false' } if ($t[0].HostName -eq '{{ test_win_hosts_cname }}' -and $t[0].AddressList[0].toString() -eq '{{ test_win_hosts_ip }}'){ return 'true' } else { return 'false' }" + register: remove_aliases_removed_actual + failed_when: "remove_aliases_removed_actual.rc == 0" + with_items: "{{ test_win_hosts_aliases_remove }}" + +- assert: + that: + - "item.stdout_lines[0]|lower == 'false'" + with_items: "{{ remove_aliases_removed_actual.results }}" + +- name: get actual dns result for remaining aliases + win_shell: "try{ [array]$t = [Net.DNS]::GetHostEntry('{{ item }}') } catch { return 'false' } if ($t[0].HostName -eq '{{ test_win_hosts_cname }}' -and $t[0].AddressList[0].toString() -eq '{{ test_win_hosts_ip }}'){ return 'true' } else { return 'false' }" + register: remove_aliases_remain_actual + with_items: "{{ test_win_hosts_aliases_set | difference(test_win_hosts_aliases_remove) }}" + +- assert: + that: + - "item.stdout_lines[0]|lower == 'true'" + with_items: "{{ remove_aliases_remain_actual.results }}" + +- name: remove aliases from the list (idempotent) + win_hosts: + state: present + ip_address: "{{ test_win_hosts_ip }}" + canonical_name: "{{ test_win_hosts_cname }}" + aliases: "{{ test_win_hosts_aliases_remove }}" + action: remove + register: remove_aliases + +- assert: + that: + - "remove_aliases.changed == false" + +- name: add aliases back + win_hosts: + state: present + ip_address: "{{ test_win_hosts_ip }}" + canonical_name: "{{ test_win_hosts_cname }}" + aliases: "{{ test_win_hosts_aliases_remove }}" + action: add + register: add_aliases + +- assert: + that: + - "add_aliases.changed == true" + +- name: get actual dns results for aliases + win_shell: "try{ [array]$t = [Net.DNS]::GetHostEntry('{{ item }}') } catch { return 'false' } if ($t[0].HostName -eq '{{ test_win_hosts_cname }}' -and $t[0].AddressList[0].toString() -eq '{{ test_win_hosts_ip }}'){ return 'true' } else { return 'false' }" + register: add_aliases_actual + with_items: "{{ test_win_hosts_aliases_set | union(test_win_hosts_aliases_remove) }}" + +- assert: + that: + - "item.stdout_lines[0]|lower == 'true'" + with_items: "{{ add_aliases_actual.results }}" + +- name: add aliases back (idempotent) + win_hosts: + state: present + ip_address: "{{ test_win_hosts_ip }}" + canonical_name: "{{ test_win_hosts_cname }}" + aliases: "{{ test_win_hosts_aliases_remove }}" + action: add + register: add_aliases + +- assert: + that: + - "add_aliases.changed == false" diff --git a/test/integration/targets/incidental_win_lineinfile/aliases b/test/integration/targets/incidental_win_lineinfile/aliases new file mode 100644 index 00000000000..194cbc3f2e0 --- /dev/null +++ b/test/integration/targets/incidental_win_lineinfile/aliases @@ -0,0 +1,3 @@ +shippable/windows/incidental +windows +skip/windows/2016 # Host takes a while to run and module isn't OS dependent diff --git a/test/integration/targets/incidental_win_lineinfile/files/test.txt b/test/integration/targets/incidental_win_lineinfile/files/test.txt new file mode 100644 index 00000000000..8187db9f029 --- /dev/null +++ b/test/integration/targets/incidental_win_lineinfile/files/test.txt @@ -0,0 +1,5 @@ +This is line 1 +This is line 2 +REF this is a line for backrefs REF +This is line 4 +This is line 5 diff --git a/test/integration/targets/incidental_win_lineinfile/files/test_linebreak.txt b/test/integration/targets/incidental_win_lineinfile/files/test_linebreak.txt new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/integration/targets/incidental_win_lineinfile/files/test_quoting.txt b/test/integration/targets/incidental_win_lineinfile/files/test_quoting.txt new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/integration/targets/incidental_win_lineinfile/files/testempty.txt b/test/integration/targets/incidental_win_lineinfile/files/testempty.txt new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/integration/targets/incidental_win_lineinfile/files/testnoeof.txt b/test/integration/targets/incidental_win_lineinfile/files/testnoeof.txt new file mode 100644 index 00000000000..152780b9ff0 --- /dev/null +++ b/test/integration/targets/incidental_win_lineinfile/files/testnoeof.txt @@ -0,0 +1,2 @@ +This is line 1 +This is line 2 \ No newline at end of file diff --git a/test/integration/targets/incidental_win_lineinfile/meta/main.yml b/test/integration/targets/incidental_win_lineinfile/meta/main.yml new file mode 100644 index 00000000000..e0ff46db127 --- /dev/null +++ b/test/integration/targets/incidental_win_lineinfile/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - incidental_win_prepare_tests diff --git a/test/integration/targets/incidental_win_lineinfile/tasks/main.yml b/test/integration/targets/incidental_win_lineinfile/tasks/main.yml new file mode 100644 index 00000000000..e5f047bec30 --- /dev/null +++ b/test/integration/targets/incidental_win_lineinfile/tasks/main.yml @@ -0,0 +1,708 @@ +# Test code for the win_lineinfile module, adapted from the standard lineinfile module tests +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + + +- name: deploy the test file for lineinfile + win_copy: src=test.txt dest={{win_output_dir}}/test.txt + register: result + +- name: assert that the test file was deployed + assert: + that: + - "result.changed == true" + +- name: stat the test file + win_stat: path={{win_output_dir}}/test.txt + register: result + +- name: check win_stat file result + assert: + that: + - "result.stat.exists" + - "not result.stat.isdir" + - "result.stat.checksum == '5feac65e442c91f557fc90069ce6efc4d346ab51'" + - "result is not failed" + - "result is not changed" + + +- name: insert a line at the beginning of the file, and back it up + win_lineinfile: dest={{win_output_dir}}/test.txt state=present line="New line at the beginning" insertbefore="BOF" backup=yes + register: result + +- name: check backup_file + win_stat: + path: '{{ result.backup_file }}' + register: backup_file + +- name: assert that the line was inserted at the head of the file + assert: + that: + - result.changed == true + - result.msg == 'line added' + - backup_file.stat.exists == true + +- name: stat the backup file + win_stat: path={{result.backup}} + register: result + +- name: assert the backup file matches the previous hash + assert: + that: + - "result.stat.checksum == '5feac65e442c91f557fc90069ce6efc4d346ab51'" + +- name: stat the test after the insert at the head + win_stat: path={{win_output_dir}}/test.txt + register: result + +- name: assert test hash is what we expect for the file with the insert at the head + assert: + that: + - "result.stat.checksum == 'b526e2e044defc64dfb0fad2f56e105178f317d8'" + +- name: insert a line at the end of the file + win_lineinfile: dest={{win_output_dir}}/test.txt state=present line="New line at the end" insertafter="EOF" + register: result + +- name: assert that the line was inserted at the end of the file + assert: + that: + - "result.changed == true" + - "result.msg == 'line added'" + +- name: stat the test after the insert at the end + win_stat: path={{win_output_dir}}/test.txt + register: result + +- name: assert test checksum matches after the insert at the end + assert: + that: + - "result.stat.checksum == 'dd5e207e28ce694ab18e41c2b16deb74fde93b14'" + +- name: insert a line after the first line + win_lineinfile: dest={{win_output_dir}}/test.txt state=present line="New line after line 1" insertafter="^This is line 1$" + register: result + +- name: assert that the line was inserted after the first line + assert: + that: + - "result.changed == true" + - "result.msg == 'line added'" + +- name: stat the test after insert after the first line + win_stat: path={{win_output_dir}}/test.txt + register: result + +- name: assert test checksum matches after the insert after the first line + assert: + that: + - "result.stat.checksum == '604b17405f2088e6868af9680b7834087acdc8f4'" + +- name: insert a line before the last line + win_lineinfile: dest={{win_output_dir}}/test.txt state=present line="New line before line 5" insertbefore="^This is line 5$" + register: result + +- name: assert that the line was inserted before the last line + assert: + that: + - "result.changed == true" + - "result.msg == 'line added'" + +- name: stat the test after the insert before the last line + win_stat: path={{win_output_dir}}/test.txt + register: result + +- name: assert test checksum matches after the insert before the last line + assert: + that: + - "result.stat.checksum == '8f5b30e8f01578043d782e5a68d4c327e75a6e34'" + +- name: replace a line with backrefs + win_lineinfile: dest={{win_output_dir}}/test.txt state=present line="This is line 3" backrefs=yes regexp="^(REF).*$" + register: result + +- name: assert that the line with backrefs was changed + assert: + that: + - "result.changed == true" + - "result.msg == 'line replaced'" + +- name: stat the test after the backref line was replaced + win_stat: path={{win_output_dir}}/test.txt + register: result + +- name: assert test checksum matches after backref line was replaced + assert: + that: + - "result.stat.checksum == 'ef6b02645908511a2cfd2df29d50dd008897c580'" + +- name: remove the middle line + win_lineinfile: dest={{win_output_dir}}/test.txt state=absent regexp="^This is line 3$" + register: result + +- name: assert that the line was removed + assert: + that: + - "result.changed == true" + - "result.msg == '1 line(s) removed'" + +- name: stat the test after the middle line was removed + win_stat: path={{win_output_dir}}/test.txt + register: result + +- name: assert test checksum matches after the middle line was removed + assert: + that: + - "result.stat.checksum == '11695efa472be5c31c736bc43e055f8ac90eabdf'" + +- name: run a validation script that succeeds + win_lineinfile: dest={{win_output_dir}}/test.txt state=absent regexp="^This is line 5$" validate="sort.exe %s" + register: result + +- name: assert that the file validated after removing a line + assert: + that: + - "result.changed == true" + - "result.msg == '1 line(s) removed'" + +- name: stat the test after the validation succeeded + win_stat: path={{win_output_dir}}/test.txt + register: result + +- name: assert test checksum matches after the validation succeeded + assert: + that: + - "result.stat.checksum == '39c38a30aa6ac6af9ec41f54c7ed7683f1249347'" + +- name: run a validation script that fails + win_lineinfile: dest={{win_output_dir}}/test.txt state=absent regexp="^This is line 1$" validate="sort.exe %s.foo" + register: result + ignore_errors: yes + +- name: assert that the validate failed + assert: + that: + - "result.failed == true" + +- name: stat the test after the validation failed + win_stat: path={{win_output_dir}}/test.txt + register: result + +- name: assert test checksum matches the previous after the validation failed + assert: + that: + - "result.stat.checksum == '39c38a30aa6ac6af9ec41f54c7ed7683f1249347'" + +- name: use create=yes + win_lineinfile: dest={{win_output_dir}}/new_test.txt create=yes insertbefore=BOF state=present line="This is a new file" + register: result + +- name: assert that the new file was created + assert: + that: + - "result.changed == true" + - "result.msg == 'line added'" + +- name: validate that the newly created file exists + win_stat: path={{win_output_dir}}/new_test.txt + register: result + ignore_errors: yes + +- name: assert the newly created test checksum matches + assert: + that: + - "result.stat.checksum == '84faac1183841c57434693752fc3debc91b9195d'" + +# Test EOF in cases where file has no newline at EOF +- name: testnoeof deploy the file for lineinfile + win_copy: src=testnoeof.txt dest={{win_output_dir}}/testnoeof.txt + register: result + +- name: testnoeof insert a line at the end of the file + win_lineinfile: dest={{win_output_dir}}/testnoeof.txt state=present line="New line at the end" insertafter="EOF" + register: result + +- name: testempty assert that the line was inserted at the end of the file + assert: + that: + - "result.changed == true" + - "result.msg == 'line added'" + +- name: testnoeof stat the no newline EOF test after the insert at the end + win_stat: path={{win_output_dir}}/testnoeof.txt + register: result + +- name: testnoeof assert test checksum matches after the insert at the end + assert: + that: + - "result.stat.checksum == '229852b09f7e9921fbcbb0ee0166ba78f7f7f261'" + +- name: add multiple lines at the end of the file + win_lineinfile: dest={{win_output_dir}}/test.txt state=present line="This is a line\r\nwith newline character" insertafter="EOF" + register: result + +- name: assert that the multiple lines was inserted + assert: + that: + - "result.changed == true" + - "result.msg == 'line added'" + +- name: stat file after adding multiple lines + win_stat: path={{win_output_dir}}/test.txt + register: result + +- name: assert test checksum matches after inserting multiple lines + assert: + that: + - "result.stat.checksum == '1401413cd4eac732be66cd6aceddd334c4240f86'" + + + +# Test EOF with empty file to make sure no unnecessary newline is added +- name: testempty deploy the testempty file for lineinfile + win_copy: src=testempty.txt dest={{win_output_dir}}/testempty.txt + register: result + +- name: testempty insert a line at the end of the file + win_lineinfile: dest={{win_output_dir}}/testempty.txt state=present line="New line at the end" insertafter="EOF" + register: result + +- name: testempty assert that the line was inserted at the end of the file + assert: + that: + - "result.changed == true" + - "result.msg == 'line added'" + +- name: testempty stat the test after the insert at the end + win_stat: path={{win_output_dir}}/testempty.txt + register: result + +- name: testempty assert test checksum matches after the insert at the end + assert: + that: + - "result.stat.checksum == 'd3d34f11edda51be7ca5dcb0757cf3e1257c0bfe'" + + + +- name: replace a line with backrefs included in the line + win_lineinfile: dest={{win_output_dir}}/test.txt state=present line="New $1 created with the backref" backrefs=yes regexp="^This is (line 4)$" + register: result + +- name: assert that the line with backrefs was changed + assert: + that: + - "result.changed == true" + - "result.msg == 'line replaced'" + +- name: stat the test after the backref line was replaced + win_stat: path={{win_output_dir}}/test.txt + register: result + +- name: assert test checksum matches after backref line was replaced + assert: + that: + - "result.stat.checksum == 'e6ff42e926dac2274c93dff0b8a323e07ae09149'" + +################################################################### +# issue 8535 + +- name: create a new file for testing quoting issues + win_copy: src=test_quoting.txt dest={{win_output_dir}}/test_quoting.txt + register: result + +- name: assert the new file was created + assert: + that: + - result.changed + +- name: use with_items to add code-like strings to the quoting txt file + win_lineinfile: > + dest={{win_output_dir}}/test_quoting.txt + line="{{ item }}" + insertbefore="BOF" + with_items: + - "'foo'" + - "dotenv.load();" + - "var dotenv = require('dotenv');" + register: result + +- name: assert the quote test file was modified correctly + assert: + that: + - result.results|length == 3 + - result.results[0].changed + - result.results[0].item == "'foo'" + - result.results[1].changed + - result.results[1].item == "dotenv.load();" + - result.results[2].changed + - result.results[2].item == "var dotenv = require('dotenv');" + +- name: stat the quote test file + win_stat: path={{win_output_dir}}/test_quoting.txt + register: result + +- name: assert test checksum matches for quote test file + assert: + that: + - "result.stat.checksum == 'f3bccdbdfa1d7176c497ef87d04957af40ab48d2'" + +- name: append a line into the quoted file with a single quote + win_lineinfile: dest={{win_output_dir}}/test_quoting.txt line="import g'" + register: result + +- name: assert that the quoted file was changed + assert: + that: + - result.changed + +- name: stat the quote test file + win_stat: path={{win_output_dir}}/test_quoting.txt + register: result + +- name: assert test checksum matches adding line with single quote + assert: + that: + - "result.stat.checksum == 'dabf4cbe471e1797d8dcfc773b6b638c524d5237'" + +- name: insert a line into the quoted file with many double quotation strings + win_lineinfile: dest={{win_output_dir}}/test_quoting.txt line='"quote" and "unquote"' + register: result + +- name: assert that the quoted file was changed + assert: + that: + - result.changed + +- name: stat the quote test file + win_stat: path={{win_output_dir}}/test_quoting.txt + register: result + +- name: assert test checksum matches quoted line added + assert: + that: + - "result.stat.checksum == '9dc1fc1ff19942e2936564102ad37134fa83b91d'" + + +# Windows vs. Unix line separator test cases + +- name: Create windows test file with initial line + win_lineinfile: dest={{win_output_dir}}/test_windows_sep.txt create=yes insertbefore=BOF state=present line="This is a new file" + register: result + +- name: assert that the new file was created + assert: + that: + - "result.changed == true" + - "result.msg == 'line added'" + +- name: validate that the newly created file exists + win_stat: path={{win_output_dir}}/test_windows_sep.txt + register: result + +- name: assert the newly created file checksum matches + assert: + that: + - "result.stat.checksum == '84faac1183841c57434693752fc3debc91b9195d'" + +- name: Test appending to the file using the default (windows) line separator + win_lineinfile: dest={{win_output_dir}}/test_windows_sep.txt insertbefore=EOF state=present line="This is the last line" + register: result + +- name: assert that the new line was added + assert: + that: + - "result.changed == true" + - "result.msg == 'line added'" + +- name: stat the file + win_stat: path={{win_output_dir}}/test_windows_sep.txt + register: result + +- name: assert the file checksum matches expected checksum + assert: + that: + - "result.stat.checksum == '71a17ddd1d57ed7c7912e4fd11ecb2ead0b27033'" + + +- name: Create unix test file with initial line + win_lineinfile: dest={{win_output_dir}}/test_unix_sep.txt create=yes insertbefore=BOF state=present line="This is a new file" + register: result + +- name: assert that the new file was created + assert: + that: + - "result.changed == true" + - "result.msg == 'line added'" + +- name: validate that the newly created file exists + win_stat: path={{win_output_dir}}/test_unix_sep.txt + register: result + +- name: assert the newly created file checksum matches + assert: + that: + - "result.stat.checksum == '84faac1183841c57434693752fc3debc91b9195d'" + +- name: Test appending to the file using unix line separator + win_lineinfile: dest={{win_output_dir}}/test_unix_sep.txt insertbefore=EOF state=present line="This is the last line" newline="unix" + register: result + +- name: assert that the new line was added + assert: + that: + - "result.changed == true" + - "result.msg == 'line added'" + +- name: stat the file + win_stat: path={{win_output_dir}}/test_unix_sep.txt + register: result + +- name: assert the file checksum matches expected checksum + assert: + that: + - "result.stat.checksum == 'f1f634a37ab1c73efb77a71a5ad2cc87b61b17ae'" + + +# Encoding management test cases + +# Default (auto) encoding should use utf-8 with no BOM +- name: Test create file without explicit encoding results in utf-8 without BOM + win_lineinfile: dest={{win_output_dir}}/test_auto_utf8.txt create=yes insertbefore=BOF state=present line="This is a new utf-8 file" + register: result + +- name: assert that the new file was created + assert: + that: + - "result.changed == true" + - "result.msg == 'line added'" + - "result.encoding == 'utf-8'" + +- name: validate that the newly created file exists + win_stat: path={{win_output_dir}}/test_auto_utf8.txt + register: result + +- name: assert the newly created file checksum matches + assert: + that: + - "result.stat.checksum == 'b69fcbacca8291a4668f57fba91d7c022f1c3dc7'" + +- name: Test appending to the utf-8 without BOM file - should autodetect UTF-8 no BOM + win_lineinfile: dest={{win_output_dir}}/test_auto_utf8.txt insertbefore=EOF state=present line="This is the last line" + register: result + +- name: assert that the new line was added and encoding did not change + assert: + that: + - "result.changed == true" + - "result.msg == 'line added'" + - "result.encoding == 'utf-8'" + +- name: stat the file + win_stat: path={{win_output_dir}}/test_auto_utf8.txt + register: result + +- name: assert the file checksum matches + assert: + that: + - "result.stat.checksum == '64d747f1ebf8c9d793dbfd27126e4152d39a3848'" + + +# UTF-8 explicit (with BOM) +- name: Test create file with explicit utf-8 encoding results in utf-8 with a BOM + win_lineinfile: dest={{win_output_dir}}/test_utf8.txt create=yes encoding="utf-8" insertbefore=BOF state=present line="This is a new utf-8 file" + register: result + +- name: assert that the new file was created + assert: + that: + - "result.changed == true" + - "result.msg == 'line added'" + - "result.encoding == 'utf-8'" + +- name: validate that the newly created file exists + win_stat: path={{win_output_dir}}/test_utf8.txt + register: result + +- name: assert the newly created file checksum matches + assert: + that: + - "result.stat.checksum == 'd45344b2b3bf1cf90eae851b40612f5f37a88bbb'" + +- name: Test appending to the utf-8 with BOM file - should autodetect utf-8 with BOM encoding + win_lineinfile: dest={{win_output_dir}}/test_utf8.txt insertbefore=EOF state=present line="This is the last line" + register: result + +- name: assert that the new line was added and encoding did not change + assert: + that: + - "result.changed == true" + - "result.msg == 'line added'" + - "result.encoding == 'utf-8'" + +- name: stat the file + win_stat: path={{win_output_dir}}/test_utf8.txt + register: result + +- name: assert the file checksum matches + assert: + that: + - "result.stat.checksum == '9b84254489f40f258871a4c6573cacc65895ee1a'" + + +# UTF-16 explicit +- name: Test create file with explicit utf-16 encoding + win_lineinfile: dest={{win_output_dir}}/test_utf16.txt create=yes encoding="utf-16" insertbefore=BOF state=present line="This is a new utf-16 file" + register: result + +- name: assert that the new file was created + assert: + that: + - "result.changed == true" + - "result.msg == 'line added'" + - "result.encoding == 'utf-16'" + +- name: validate that the newly created file exists + win_stat: path={{win_output_dir}}/test_utf16.txt + register: result + +- name: assert the newly created file checksum matches + assert: + that: + - "result.stat.checksum == '785b0693cec13b60e2c232782adeda2f8a967434'" + +- name: Test appending to the utf-16 file - should autodetect utf-16 encoding + win_lineinfile: dest={{win_output_dir}}/test_utf16.txt insertbefore=EOF state=present line="This is the last line" + register: result + +- name: assert that the new line was added and encoding did not change + assert: + that: + - "result.changed == true" + - "result.msg == 'line added'" + - "result.encoding == 'utf-16'" + +- name: stat the file + win_stat: path={{win_output_dir}}/test_utf16.txt + register: result + +- name: assert the file checksum matches + assert: + that: + - "result.stat.checksum == '70e4eb3ba795e1ba94d262db47e4fd17c64b2e73'" + +# UTF-32 explicit +- name: Test create file with explicit utf-32 encoding + win_lineinfile: dest={{win_output_dir}}/test_utf32.txt create=yes encoding="utf-32" insertbefore=BOF state=present line="This is a new utf-32 file" + register: result + +- name: assert that the new file was created + assert: + that: + - "result.changed == true" + - "result.msg == 'line added'" + - "result.encoding == 'utf-32'" + +- name: validate that the newly created file exists + win_stat: path={{win_output_dir}}/test_utf32.txt + register: result + +- name: assert the newly created file checksum matches + assert: + that: + - "result.stat.checksum == '7a6e3f3604c0def431aaa813173a4ddaa10fd1fb'" + +- name: Test appending to the utf-32 file - should autodetect utf-32 encoding + win_lineinfile: dest={{win_output_dir}}/test_utf32.txt insertbefore=EOF state=present line="This is the last line" + register: result + +- name: assert that the new line was added and encoding did not change + assert: + that: + - "result.changed == true" + - "result.msg == 'line added'" + - "result.encoding == 'utf-32'" + +- name: stat the file + win_stat: path={{win_output_dir}}/test_utf32.txt + register: result + +- name: assert the file checksum matches + assert: + that: + - "result.stat.checksum == '66a72e71f42c4775f4326da95cfe82c8830e5022'" + +######################################################################### +# issue #33858 +# \r\n causes line break instead of printing literally which breaks paths. + +- name: create testing file + win_copy: + src: test_linebreak.txt + dest: "{{win_output_dir}}/test_linebreak.txt" + +- name: stat the test file + win_stat: + path: "{{win_output_dir}}/test_linebreak.txt" + register: result + +# (Get-FileHash -path C:\ansible\test\integration\targets\win_lineinfile\files\test_linebreak.txt -Algorithm sha1).hash.tolower() +- name: check win_stat file result + assert: + that: + - result.stat.exists + - not result.stat.isdir + - result.stat.checksum == 'da39a3ee5e6b4b0d3255bfef95601890afd80709' + - result is not failed + - result is not changed + +- name: insert path c:\return\new to test file + win_lineinfile: + dest: "{{win_output_dir}}/test_linebreak.txt" + line: c:\return\new + register: result_literal + +- name: insert path "c:\return\new" to test file, will cause line breaks + win_lineinfile: + dest: "{{win_output_dir}}/test_linebreak.txt" + line: "c:\return\new" + register: result_expand + +- name: assert that the lines were inserted + assert: + that: + - result_literal.changed == true + - result_literal.msg == 'line added' + - result_expand.changed == true + - result_expand.msg == 'line added' + +- name: stat the test file + win_stat: + path: "{{win_output_dir}}/test_linebreak.txt" + register: result + +- debug: + var: result + verbosity: 1 + +# expect that the file looks like this: +# c:\return\new +# c: +# eturn +# ew #or c:eturnew on windows +- name: assert that one line is literal and the other has breaks + assert: + that: + - result.stat.checksum == 'd2dfd11bc70526ff13a91153c76a7ae5595a845b' diff --git a/test/integration/targets/incidental_win_ping/aliases b/test/integration/targets/incidental_win_ping/aliases new file mode 100644 index 00000000000..a5fc90dcf48 --- /dev/null +++ b/test/integration/targets/incidental_win_ping/aliases @@ -0,0 +1,2 @@ +shippable/windows/incidental +windows diff --git a/test/integration/targets/incidental_win_ping/library/win_ping_set_attr.ps1 b/test/integration/targets/incidental_win_ping/library/win_ping_set_attr.ps1 new file mode 100644 index 00000000000..f17049643b4 --- /dev/null +++ b/test/integration/targets/incidental_win_ping/library/win_ping_set_attr.ps1 @@ -0,0 +1,31 @@ +#!powershell +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +# POWERSHELL_COMMON + +$params = Parse-Args $args $true; + +$data = Get-Attr $params "data" "pong"; + +$result = @{ + changed = $false + ping = "pong" +}; + +# Test that Set-Attr will replace an existing attribute. +Set-Attr $result "ping" $data + +Exit-Json $result; diff --git a/test/integration/targets/incidental_win_ping/library/win_ping_strict_mode_error.ps1 b/test/integration/targets/incidental_win_ping/library/win_ping_strict_mode_error.ps1 new file mode 100644 index 00000000000..508174afcc6 --- /dev/null +++ b/test/integration/targets/incidental_win_ping/library/win_ping_strict_mode_error.ps1 @@ -0,0 +1,30 @@ +#!powershell +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +# POWERSHELL_COMMON + +$params = Parse-Args $args $true; + +$params.thisPropertyDoesNotExist + +$data = Get-Attr $params "data" "pong"; + +$result = @{ + changed = $false + ping = $data +}; + +Exit-Json $result; diff --git a/test/integration/targets/incidental_win_ping/library/win_ping_syntax_error.ps1 b/test/integration/targets/incidental_win_ping/library/win_ping_syntax_error.ps1 new file mode 100644 index 00000000000..d4c9f07ad55 --- /dev/null +++ b/test/integration/targets/incidental_win_ping/library/win_ping_syntax_error.ps1 @@ -0,0 +1,30 @@ +#!powershell +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +# POWERSHELL_COMMON + +$blah = 'I can't quote my strings correctly.' + +$params = Parse-Args $args $true; + +$data = Get-Attr $params "data" "pong"; + +$result = @{ + changed = $false + ping = $data +}; + +Exit-Json $result; diff --git a/test/integration/targets/incidental_win_ping/library/win_ping_throw.ps1 b/test/integration/targets/incidental_win_ping/library/win_ping_throw.ps1 new file mode 100644 index 00000000000..7306f4d2808 --- /dev/null +++ b/test/integration/targets/incidental_win_ping/library/win_ping_throw.ps1 @@ -0,0 +1,30 @@ +#!powershell +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +# POWERSHELL_COMMON + +throw + +$params = Parse-Args $args $true; + +$data = Get-Attr $params "data" "pong"; + +$result = @{ + changed = $false + ping = $data +}; + +Exit-Json $result; diff --git a/test/integration/targets/incidental_win_ping/library/win_ping_throw_string.ps1 b/test/integration/targets/incidental_win_ping/library/win_ping_throw_string.ps1 new file mode 100644 index 00000000000..09e3b7cb458 --- /dev/null +++ b/test/integration/targets/incidental_win_ping/library/win_ping_throw_string.ps1 @@ -0,0 +1,30 @@ +#!powershell +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +# POWERSHELL_COMMON + +throw "no ping for you" + +$params = Parse-Args $args $true; + +$data = Get-Attr $params "data" "pong"; + +$result = @{ + changed = $false + ping = $data +}; + +Exit-Json $result; diff --git a/test/integration/targets/incidental_win_ping/tasks/main.yml b/test/integration/targets/incidental_win_ping/tasks/main.yml new file mode 100644 index 00000000000..a7e6ba7fc4e --- /dev/null +++ b/test/integration/targets/incidental_win_ping/tasks/main.yml @@ -0,0 +1,67 @@ +# test code for the win_ping module +# (c) 2014, Chris Church + +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +- name: test win_ping + action: win_ping + register: win_ping_result + +- name: check win_ping result + assert: + that: + - win_ping_result is not failed + - win_ping_result is not changed + - win_ping_result.ping == 'pong' + +- name: test win_ping with data + win_ping: + data: ☠ + register: win_ping_with_data_result + +- name: check win_ping result with data + assert: + that: + - win_ping_with_data_result is not failed + - win_ping_with_data_result is not changed + - win_ping_with_data_result.ping == '☠' + +- name: test win_ping.ps1 with data as complex args + # win_ping.ps1: # TODO: do we want to actually support this? no other tests that I can see... + win_ping: + data: bleep + register: win_ping_ps1_result + +- name: check win_ping.ps1 result with data + assert: + that: + - win_ping_ps1_result is not failed + - win_ping_ps1_result is not changed + - win_ping_ps1_result.ping == 'bleep' + +- name: test win_ping using data=crash so that it throws an exception + win_ping: + data: crash + register: win_ping_crash_result + ignore_errors: yes + +- name: check win_ping_crash result + assert: + that: + - win_ping_crash_result is failed + - win_ping_crash_result is not changed + - 'win_ping_crash_result.msg == "Unhandled exception while executing module: boom"' + - '"throw \"boom\"" in win_ping_crash_result.exception' diff --git a/test/integration/targets/incidental_win_prepare_tests/aliases b/test/integration/targets/incidental_win_prepare_tests/aliases new file mode 100644 index 00000000000..136c05e0d02 --- /dev/null +++ b/test/integration/targets/incidental_win_prepare_tests/aliases @@ -0,0 +1 @@ +hidden diff --git a/test/integration/targets/incidental_win_prepare_tests/meta/main.yml b/test/integration/targets/incidental_win_prepare_tests/meta/main.yml new file mode 100644 index 00000000000..cf5427b6084 --- /dev/null +++ b/test/integration/targets/incidental_win_prepare_tests/meta/main.yml @@ -0,0 +1,3 @@ +--- + +allow_duplicates: yes diff --git a/test/integration/targets/incidental_win_prepare_tests/tasks/main.yml b/test/integration/targets/incidental_win_prepare_tests/tasks/main.yml new file mode 100644 index 00000000000..e87b614b22c --- /dev/null +++ b/test/integration/targets/incidental_win_prepare_tests/tasks/main.yml @@ -0,0 +1,29 @@ +# test code for the windows versions of copy, file and template module +# originally +# (c) 2014, Michael DeHaan + +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + + +- name: clean out the test directory + win_file: name={{win_output_dir|mandatory}} state=absent + tags: + - prepare + +- name: create the test directory + win_file: name={{win_output_dir}} state=directory + tags: + - prepare diff --git a/test/integration/targets/incidental_win_psexec/aliases b/test/integration/targets/incidental_win_psexec/aliases new file mode 100644 index 00000000000..a5fc90dcf48 --- /dev/null +++ b/test/integration/targets/incidental_win_psexec/aliases @@ -0,0 +1,2 @@ +shippable/windows/incidental +windows diff --git a/test/integration/targets/incidental_win_psexec/meta/main.yml b/test/integration/targets/incidental_win_psexec/meta/main.yml new file mode 100644 index 00000000000..9f37e96cd90 --- /dev/null +++ b/test/integration/targets/incidental_win_psexec/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: +- setup_remote_tmp_dir diff --git a/test/integration/targets/incidental_win_psexec/tasks/main.yml b/test/integration/targets/incidental_win_psexec/tasks/main.yml new file mode 100644 index 00000000000..27783f9e62a --- /dev/null +++ b/test/integration/targets/incidental_win_psexec/tasks/main.yml @@ -0,0 +1,80 @@ +# Would use [] but this has troubles with PATH and trying to find the executable so just resort to keeping a space +- name: record special path for tests + set_fact: + testing_dir: '{{ remote_tmp_dir }}\ansible win_psexec' + +- name: create special path testing dir + win_file: + path: '{{ testing_dir }}' + state: directory + +- name: Download PsExec + win_get_url: + url: https://ansible-ci-files.s3.amazonaws.com/test/integration/targets/win_psexec/PsExec.exe + dest: '{{ testing_dir }}\PsExec.exe' + +- name: Get the existing PATH env var + win_shell: '$env:PATH' + register: system_path + changed_when: False + +- name: Run whoami + win_psexec: + command: whoami.exe + nobanner: true + register: whoami + environment: + PATH: '{{ testing_dir }};{{ system_path.stdout | trim }}' + +- name: Test whoami + assert: + that: + - whoami.rc == 0 + - whoami.stdout == '' + # FIXME: Standard output does not work or is truncated + #- whoami.stdout == '{{ ansible_hostname|lower }}' + +- name: Run whoami as SYSTEM + win_psexec: + command: whoami.exe + system: yes + nobanner: true + executable: '{{ testing_dir }}\PsExec.exe' + register: whoami_as_system + # Seems to be a bug with PsExec where the stdout can be empty, just retry the task to make this test a bit more stable + until: whoami_as_system.rc == 0 and whoami_as_system.stdout == 'nt authority\system' + retries: 3 + delay: 2 + +# FIXME: Behaviour is not consistent on all Windows systems +#- name: Run whoami as ELEVATED +# win_psexec: +# command: whoami.exe +# elevated: yes +# register: whoami_as_elevated +# +## Ensure we have basic facts +#- setup: +# +#- debug: +# msg: '{{ whoami_as_elevated.stdout|lower }} == {{ ansible_hostname|lower }}\{{ ansible_user_id|lower }}' +# +#- name: Test whoami +# assert: +# that: +# - whoami_as_elevated.rc == 0 +# - whoami_as_elevated.stdout|lower == '{{ ansible_hostname|lower }}\{{ ansible_user_id|lower }}' + +- name: Run command with multiple arguments + win_psexec: + command: powershell.exe -NonInteractive "exit 1" + ignore_errors: yes + register: whoami_multiple_args + environment: + PATH: '{{ testing_dir }};{{ system_path.stdout | trim }}' + +- name: Test command with multiple argumetns + assert: + that: + - whoami_multiple_args.rc == 1 + - whoami_multiple_args.psexec_command == "psexec.exe -accepteula powershell.exe -NonInteractive \"exit 1\"" diff --git a/test/integration/targets/incidental_win_reboot/aliases b/test/integration/targets/incidental_win_reboot/aliases new file mode 100644 index 00000000000..a5fc90dcf48 --- /dev/null +++ b/test/integration/targets/incidental_win_reboot/aliases @@ -0,0 +1,2 @@ +shippable/windows/incidental +windows diff --git a/test/integration/targets/incidental_win_reboot/tasks/main.yml b/test/integration/targets/incidental_win_reboot/tasks/main.yml new file mode 100644 index 00000000000..7757e08fcdd --- /dev/null +++ b/test/integration/targets/incidental_win_reboot/tasks/main.yml @@ -0,0 +1,70 @@ +--- +- name: make sure win output dir exists + win_file: + path: "{{win_output_dir}}" + state: directory + +- name: reboot with defaults + win_reboot: + +- name: test with negative values for delays + win_reboot: + post_reboot_delay: -0.5 + pre_reboot_delay: -61 + +- name: schedule a reboot for sometime in the future + win_command: shutdown.exe /r /t 599 + +- name: reboot with a shutdown already scheduled + win_reboot: + +# test a reboot that reboots again during the test_command phase +- name: create test file + win_file: + path: '{{win_output_dir}}\win_reboot_test' + state: touch + +- name: reboot with secondary reboot stage + win_reboot: + test_command: '{{ lookup("template", "post_reboot.ps1") }}' + +- name: reboot with test command that fails + win_reboot: + test_command: 'FAIL' + reboot_timeout: 120 + register: reboot_fail_test + failed_when: "reboot_fail_test.msg != 'Timed out waiting for post-reboot test command (timeout=120)'" + +- name: remove SeRemoteShutdownPrivilege + win_user_right: + name: SeRemoteShutdownPrivilege + users: [] + action: set + register: removed_shutdown_privilege + +- block: + - name: try and reboot without required privilege + win_reboot: + register: fail_privilege + failed_when: + - "'Reboot command failed, error was:' not in fail_privilege.msg" + - "'Access is denied.(5)' not in fail_privilege.msg" + + always: + - name: reset the SeRemoteShutdownPrivilege + win_user_right: + name: SeRemoteShutdownPrivilege + users: '{{ removed_shutdown_privilege.removed }}' + action: add + +- name: Use invalid parameter + reboot: + foo: bar + ignore_errors: true + register: invalid_parameter + +- name: Ensure task fails with error + assert: + that: + - invalid_parameter is failed + - "invalid_parameter.msg == 'Invalid options for reboot: foo'" diff --git a/test/integration/targets/incidental_win_reboot/templates/post_reboot.ps1 b/test/integration/targets/incidental_win_reboot/templates/post_reboot.ps1 new file mode 100644 index 00000000000..e4a99a721de --- /dev/null +++ b/test/integration/targets/incidental_win_reboot/templates/post_reboot.ps1 @@ -0,0 +1,8 @@ +if (Test-Path -Path '{{win_output_dir}}\win_reboot_test') { + New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' ` + -Name PendingFileRenameOperations ` + -Value @("\??\{{win_output_dir}}\win_reboot_test`0") ` + -PropertyType MultiString + Restart-Computer -Force + exit 1 +} diff --git a/test/integration/targets/incidental_win_security_policy/aliases b/test/integration/targets/incidental_win_security_policy/aliases new file mode 100644 index 00000000000..a5fc90dcf48 --- /dev/null +++ b/test/integration/targets/incidental_win_security_policy/aliases @@ -0,0 +1,2 @@ +shippable/windows/incidental +windows diff --git a/test/integration/targets/incidental_win_security_policy/library/test_win_security_policy.ps1 b/test/integration/targets/incidental_win_security_policy/library/test_win_security_policy.ps1 new file mode 100644 index 00000000000..5c83c1b5d0d --- /dev/null +++ b/test/integration/targets/incidental_win_security_policy/library/test_win_security_policy.ps1 @@ -0,0 +1,53 @@ +#!powershell + +# WANT_JSON +# POWERSHELL_COMMON + +# basic script to get the lsit of users in a particular right +# this is quite complex to put as a simple script so this is +# just a simple module + +$ErrorActionPreference = 'Stop' + +$params = Parse-Args $args -supports_check_mode $false +$section = Get-AnsibleParam -obj $params -name "section" -type "str" -failifempty $true +$key = Get-AnsibleParam -obj $params -name "key" -type "str" -failifempty $true + +$result = @{ + changed = $false +} + +Function ConvertFrom-Ini($file_path) { + $ini = @{} + switch -Regex -File $file_path { + "^\[(.+)\]" { + $section = $matches[1] + $ini.$section = @{} + } + "(.+?)\s*=(.*)" { + $name = $matches[1].Trim() + $value = $matches[2].Trim() + if ($value -match "^\d+$") { + $value = [int]$value + } elseif ($value.StartsWith('"') -and $value.EndsWith('"')) { + $value = $value.Substring(1, $value.Length - 2) + } + + $ini.$section.$name = $value + } + } + + $ini +} + +$secedit_ini_path = [IO.Path]::GetTempFileName() +&SecEdit.exe /export /cfg $secedit_ini_path /quiet +$secedit_ini = ConvertFrom-Ini -file_path $secedit_ini_path + +if ($secedit_ini.ContainsKey($section)) { + $result.value = $secedit_ini.$section.$key +} else { + $result.value = $null +} + +Exit-Json $result diff --git a/test/integration/targets/incidental_win_security_policy/tasks/main.yml b/test/integration/targets/incidental_win_security_policy/tasks/main.yml new file mode 100644 index 00000000000..28fdb5ea094 --- /dev/null +++ b/test/integration/targets/incidental_win_security_policy/tasks/main.yml @@ -0,0 +1,41 @@ +--- +- name: get current entry for audit + test_win_security_policy: + section: Event Audit + key: AuditSystemEvents + register: before_value_audit + +- name: get current entry for guest + test_win_security_policy: + section: System Access + key: NewGuestName + register: before_value_guest + +- block: + - name: set AuditSystemEvents entry before tests + win_security_policy: + section: Event Audit + key: AuditSystemEvents + value: 0 + + - name: set NewGuestName entry before tests + win_security_policy: + section: System Access + key: NewGuestName + value: Guest + + - name: run tests + include_tasks: tests.yml + + always: + - name: reset entries for AuditSystemEvents + win_security_policy: + section: Event Audit + key: AuditSystemEvents + value: "{{before_value_audit.value}}" + + - name: reset entries for NewGuestName + win_security_policy: + section: System Access + key: NewGuestName + value: "{{before_value_guest.value}}" diff --git a/test/integration/targets/incidental_win_security_policy/tasks/tests.yml b/test/integration/targets/incidental_win_security_policy/tasks/tests.yml new file mode 100644 index 00000000000..724b6010a34 --- /dev/null +++ b/test/integration/targets/incidental_win_security_policy/tasks/tests.yml @@ -0,0 +1,186 @@ +--- +- name: fail with invalid section name + win_security_policy: + section: This is not a valid section + key: KeyName + value: 0 + register: fail_invalid_section + failed_when: fail_invalid_section.msg != "The section 'This is not a valid section' does not exist in SecEdit.exe output ini" + +- name: fail with invalid key name + win_security_policy: + section: System Access + key: InvalidKey + value: 0 + register: fail_invalid_key + failed_when: fail_invalid_key.msg != "The key 'InvalidKey' in section 'System Access' is not a valid key, cannot set this value" + +- name: change existing key check + win_security_policy: + section: Event Audit + key: AuditSystemEvents + value: 1 + register: change_existing_check + check_mode: yes + +- name: get actual change existing key check + test_win_security_policy: + section: Event Audit + key: AuditSystemEvents + register: change_existing_actual_check + +- name: assert change existing key check + assert: + that: + - change_existing_check is changed + - change_existing_actual_check.value == 0 + +- name: change existing key + win_security_policy: + section: Event Audit + key: AuditSystemEvents + value: 1 + register: change_existing + +- name: get actual change existing key + test_win_security_policy: + section: Event Audit + key: AuditSystemEvents + register: change_existing_actual + +- name: assert change existing key + assert: + that: + - change_existing is changed + - change_existing_actual.value == 1 + +- name: change existing key again + win_security_policy: + section: Event Audit + key: AuditSystemEvents + value: 1 + register: change_existing_again + +- name: assert change existing key again + assert: + that: + - change_existing_again is not changed + - change_existing_again.value == 1 + +- name: change existing key with string type + win_security_policy: + section: Event Audit + key: AuditSystemEvents + value: "1" + register: change_existing_key_with_type + +- name: assert change existing key with string type + assert: + that: + - change_existing_key_with_type is not changed + - change_existing_key_with_type.value == "1" + +- name: change existing string key check + win_security_policy: + section: System Access + key: NewGuestName + value: New Guest + register: change_existing_string_check + check_mode: yes + +- name: get actual change existing string key check + test_win_security_policy: + section: System Access + key: NewGuestName + register: change_existing_string_actual_check + +- name: assert change existing string key check + assert: + that: + - change_existing_string_check is changed + - change_existing_string_actual_check.value == "Guest" + +- name: change existing string key + win_security_policy: + section: System Access + key: NewGuestName + value: New Guest + register: change_existing_string + +- name: get actual change existing string key + test_win_security_policy: + section: System Access + key: NewGuestName + register: change_existing_string_actual + +- name: assert change existing string key + assert: + that: + - change_existing_string is changed + - change_existing_string_actual.value == "New Guest" + +- name: change existing string key again + win_security_policy: + section: System Access + key: NewGuestName + value: New Guest + register: change_existing_string_again + +- name: assert change existing string key again + assert: + that: + - change_existing_string_again is not changed + - change_existing_string_again.value == "New Guest" + +- name: add policy setting + win_security_policy: + section: Privilege Rights + # following key is empty by default + key: SeCreateTokenPrivilege + # add Guests + value: '*S-1-5-32-546' + +- name: get actual policy setting + test_win_security_policy: + section: Privilege Rights + key: SeCreateTokenPrivilege + register: add_policy_setting_actual + +- name: assert add policy setting + assert: + that: + - add_policy_setting_actual.value == '*S-1-5-32-546' + +- name: remove policy setting + win_security_policy: + section: Privilege Rights + key: SeCreateTokenPrivilege + value: '' + diff: yes + register: remove_policy_setting + +- name: get actual policy setting + test_win_security_policy: + section: Privilege Rights + key: SeCreateTokenPrivilege + register: remove_policy_setting_actual + +- name: assert remove policy setting + assert: + that: + - remove_policy_setting is changed + - remove_policy_setting.diff.prepared == "[Privilege Rights]\n-SeCreateTokenPrivilege = *S-1-5-32-546\n+SeCreateTokenPrivilege = " + - remove_policy_setting_actual.value is none + +- name: remove policy setting again + win_security_policy: + section: Privilege Rights + key: SeCreateTokenPrivilege + value: '' + register: remove_policy_setting_again + +- name: assert remove policy setting again + assert: + that: + - remove_policy_setting_again is not changed + - remove_policy_setting_again.value == '' diff --git a/test/lib/ansible_test/_internal/sanity/integration_aliases.py b/test/lib/ansible_test/_internal/sanity/integration_aliases.py index ee9adb11f86..1238d120041 100644 --- a/test/lib/ansible_test/_internal/sanity/integration_aliases.py +++ b/test/lib/ansible_test/_internal/sanity/integration_aliases.py @@ -243,6 +243,7 @@ class IntegrationAliasesTest(SanityVersionNeutral): messages += self.check_ci_group( targets=windows_targets, find=self.format_shippable_group_alias('windows'), + find_incidental=['shippable/windows/incidental/'], ) return messages diff --git a/test/sanity/ignore.txt b/test/sanity/ignore.txt index ebef06c3afc..3b78d23f86e 100644 --- a/test/sanity/ignore.txt +++ b/test/sanity/ignore.txt @@ -8106,6 +8106,13 @@ test/integration/targets/ignore_unreachable/fake_connectors/bad_put_file.py futu test/integration/targets/ignore_unreachable/fake_connectors/bad_put_file.py metaclass-boilerplate test/integration/targets/incidental_script_inventory_vmware_inventory/vmware_inventory.py future-import-boilerplate test/integration/targets/incidental_script_inventory_vmware_inventory/vmware_inventory.py metaclass-boilerplate +test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.0/DSCResources/ANSIBLE_xSetReboot/ANSIBLE_xSetReboot.psm1 pslint!skip +test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.0/DSCResources/ANSIBLE_xTestResource/ANSIBLE_xTestResource.psm1 pslint!skip +test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.0/xTestDsc.psd1 pslint!skip +test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.1/DSCResources/ANSIBLE_xTestResource/ANSIBLE_xTestResource.psm1 pslint!skip +test/integration/targets/incidental_win_dsc/files/xTestDsc/1.0.1/xTestDsc.psd1 pslint!skip +test/integration/targets/incidental_win_ping/library/win_ping_syntax_error.ps1 pslint!skip +test/integration/targets/incidental_win_reboot/templates/post_reboot.ps1 pslint!skip test/integration/targets/inventory_kubevirt/inventory_diff.py future-import-boilerplate test/integration/targets/inventory_kubevirt/inventory_diff.py metaclass-boilerplate test/integration/targets/inventory_kubevirt/server.py future-import-boilerplate @@ -8285,6 +8292,16 @@ test/support/network-integration/collections/ansible_collections/vyos/vyos/plugi test/support/network-integration/collections/ansible_collections/vyos/vyos/plugins/modules/vyos_logging.py metaclass-boilerplate test/support/network-integration/collections/ansible_collections/vyos/vyos/plugins/modules/vyos_static_route.py future-import-boilerplate test/support/network-integration/collections/ansible_collections/vyos/vyos/plugins/modules/vyos_static_route.py metaclass-boilerplate +test/support/windows-integration/plugins/modules/async_status.ps1 pslint!skip +test/support/windows-integration/plugins/modules/setup.ps1 pslint!skip +test/support/windows-integration/plugins/modules/win_copy.ps1 pslint!skip +test/support/windows-integration/plugins/modules/win_dsc.ps1 pslint!skip +test/support/windows-integration/plugins/modules/win_feature.ps1 pslint!skip +test/support/windows-integration/plugins/modules/win_find.ps1 pslint!skip +test/support/windows-integration/plugins/modules/win_lineinfile.ps1 pslint!skip +test/support/windows-integration/plugins/modules/win_regedit.ps1 pslint!skip +test/support/windows-integration/plugins/modules/win_security_policy.ps1 pslint!skip +test/support/windows-integration/plugins/modules/win_shell.ps1 pslint!skip test/units/config/manager/test_find_ini_config_file.py future-import-boilerplate test/units/contrib/inventory/test_vmware_inventory.py future-import-boilerplate test/units/contrib/inventory/test_vmware_inventory.py metaclass-boilerplate diff --git a/test/support/windows-integration/plugins/action/win_copy.py b/test/support/windows-integration/plugins/action/win_copy.py new file mode 100644 index 00000000000..adb918be290 --- /dev/null +++ b/test/support/windows-integration/plugins/action/win_copy.py @@ -0,0 +1,522 @@ +# This file is part of Ansible + +# Copyright (c) 2017 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# Make coding more python3-ish +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import base64 +import json +import os +import os.path +import shutil +import tempfile +import traceback +import zipfile + +from ansible import constants as C +from ansible.errors import AnsibleError, AnsibleFileNotFound +from ansible.module_utils._text import to_bytes, to_native, to_text +from ansible.module_utils.parsing.convert_bool import boolean +from ansible.plugins.action import ActionBase +from ansible.utils.hashing import checksum + + +def _walk_dirs(topdir, loader, decrypt=True, base_path=None, local_follow=False, trailing_slash_detector=None, checksum_check=False): + """ + Walk a filesystem tree returning enough information to copy the files. + This is similar to the _walk_dirs function in ``copy.py`` but returns + a dict instead of a tuple for each entry and includes the checksum of + a local file if wanted. + + :arg topdir: The directory that the filesystem tree is rooted at + :arg loader: The self._loader object from ActionBase + :kwarg decrypt: Whether to decrypt a file encrypted with ansible-vault + :kwarg base_path: The initial directory structure to strip off of the + files for the destination directory. If this is None (the default), + the base_path is set to ``top_dir``. + :kwarg local_follow: Whether to follow symlinks on the source. When set + to False, no symlinks are dereferenced. When set to True (the + default), the code will dereference most symlinks. However, symlinks + can still be present if needed to break a circular link. + :kwarg trailing_slash_detector: Function to determine if a path has + a trailing directory separator. Only needed when dealing with paths on + a remote machine (in which case, pass in a function that is aware of the + directory separator conventions on the remote machine). + :kawrg whether to get the checksum of the local file and add to the dict + :returns: dictionary of dictionaries. All of the path elements in the structure are text string. + This separates all the files, directories, and symlinks along with + import information about each:: + + { + 'files'; [{ + src: '/absolute/path/to/copy/from', + dest: 'relative/path/to/copy/to', + checksum: 'b54ba7f5621240d403f06815f7246006ef8c7d43' + }, ...], + 'directories'; [{ + src: '/absolute/path/to/copy/from', + dest: 'relative/path/to/copy/to' + }, ...], + 'symlinks'; [{ + src: '/symlink/target/path', + dest: 'relative/path/to/copy/to' + }, ...], + + } + + The ``symlinks`` field is only populated if ``local_follow`` is set to False + *or* a circular symlink cannot be dereferenced. The ``checksum`` entry is set + to None if checksum_check=False. + + """ + # Convert the path segments into byte strings + + r_files = {'files': [], 'directories': [], 'symlinks': []} + + def _recurse(topdir, rel_offset, parent_dirs, rel_base=u'', checksum_check=False): + """ + This is a closure (function utilizing variables from it's parent + function's scope) so that we only need one copy of all the containers. + Note that this function uses side effects (See the Variables used from + outer scope). + + :arg topdir: The directory we are walking for files + :arg rel_offset: Integer defining how many characters to strip off of + the beginning of a path + :arg parent_dirs: Directories that we're copying that this directory is in. + :kwarg rel_base: String to prepend to the path after ``rel_offset`` is + applied to form the relative path. + + Variables used from the outer scope + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + :r_files: Dictionary of files in the hierarchy. See the return value + for :func:`walk` for the structure of this dictionary. + :local_follow: Read-only inside of :func:`_recurse`. Whether to follow symlinks + """ + for base_path, sub_folders, files in os.walk(topdir): + for filename in files: + filepath = os.path.join(base_path, filename) + dest_filepath = os.path.join(rel_base, filepath[rel_offset:]) + + if os.path.islink(filepath): + # Dereference the symlnk + real_file = loader.get_real_file(os.path.realpath(filepath), decrypt=decrypt) + if local_follow and os.path.isfile(real_file): + # Add the file pointed to by the symlink + r_files['files'].append( + { + "src": real_file, + "dest": dest_filepath, + "checksum": _get_local_checksum(checksum_check, real_file) + } + ) + else: + # Mark this file as a symlink to copy + r_files['symlinks'].append({"src": os.readlink(filepath), "dest": dest_filepath}) + else: + # Just a normal file + real_file = loader.get_real_file(filepath, decrypt=decrypt) + r_files['files'].append( + { + "src": real_file, + "dest": dest_filepath, + "checksum": _get_local_checksum(checksum_check, real_file) + } + ) + + for dirname in sub_folders: + dirpath = os.path.join(base_path, dirname) + dest_dirpath = os.path.join(rel_base, dirpath[rel_offset:]) + real_dir = os.path.realpath(dirpath) + dir_stats = os.stat(real_dir) + + if os.path.islink(dirpath): + if local_follow: + if (dir_stats.st_dev, dir_stats.st_ino) in parent_dirs: + # Just insert the symlink if the target directory + # exists inside of the copy already + r_files['symlinks'].append({"src": os.readlink(dirpath), "dest": dest_dirpath}) + else: + # Walk the dirpath to find all parent directories. + new_parents = set() + parent_dir_list = os.path.dirname(dirpath).split(os.path.sep) + for parent in range(len(parent_dir_list), 0, -1): + parent_stat = os.stat(u'/'.join(parent_dir_list[:parent])) + if (parent_stat.st_dev, parent_stat.st_ino) in parent_dirs: + # Reached the point at which the directory + # tree is already known. Don't add any + # more or we might go to an ancestor that + # isn't being copied. + break + new_parents.add((parent_stat.st_dev, parent_stat.st_ino)) + + if (dir_stats.st_dev, dir_stats.st_ino) in new_parents: + # This was a a circular symlink. So add it as + # a symlink + r_files['symlinks'].append({"src": os.readlink(dirpath), "dest": dest_dirpath}) + else: + # Walk the directory pointed to by the symlink + r_files['directories'].append({"src": real_dir, "dest": dest_dirpath}) + offset = len(real_dir) + 1 + _recurse(real_dir, offset, parent_dirs.union(new_parents), + rel_base=dest_dirpath, + checksum_check=checksum_check) + else: + # Add the symlink to the destination + r_files['symlinks'].append({"src": os.readlink(dirpath), "dest": dest_dirpath}) + else: + # Just a normal directory + r_files['directories'].append({"src": dirpath, "dest": dest_dirpath}) + + # Check if the source ends with a "/" so that we know which directory + # level to work at (similar to rsync) + source_trailing_slash = False + if trailing_slash_detector: + source_trailing_slash = trailing_slash_detector(topdir) + else: + source_trailing_slash = topdir.endswith(os.path.sep) + + # Calculate the offset needed to strip the base_path to make relative + # paths + if base_path is None: + base_path = topdir + if not source_trailing_slash: + base_path = os.path.dirname(base_path) + if topdir.startswith(base_path): + offset = len(base_path) + + # Make sure we're making the new paths relative + if trailing_slash_detector and not trailing_slash_detector(base_path): + offset += 1 + elif not base_path.endswith(os.path.sep): + offset += 1 + + if os.path.islink(topdir) and not local_follow: + r_files['symlinks'] = {"src": os.readlink(topdir), "dest": os.path.basename(topdir)} + return r_files + + dir_stats = os.stat(topdir) + parents = frozenset(((dir_stats.st_dev, dir_stats.st_ino),)) + # Actually walk the directory hierarchy + _recurse(topdir, offset, parents, checksum_check=checksum_check) + + return r_files + + +def _get_local_checksum(get_checksum, local_path): + if get_checksum: + return checksum(local_path) + else: + return None + + +class ActionModule(ActionBase): + + WIN_PATH_SEPARATOR = "\\" + + def _create_content_tempfile(self, content): + ''' Create a tempfile containing defined content ''' + fd, content_tempfile = tempfile.mkstemp(dir=C.DEFAULT_LOCAL_TMP) + f = os.fdopen(fd, 'wb') + content = to_bytes(content) + try: + f.write(content) + except Exception as err: + os.remove(content_tempfile) + raise Exception(err) + finally: + f.close() + return content_tempfile + + def _create_zip_tempfile(self, files, directories): + tmpdir = tempfile.mkdtemp(dir=C.DEFAULT_LOCAL_TMP) + zip_file_path = os.path.join(tmpdir, "win_copy.zip") + zip_file = zipfile.ZipFile(zip_file_path, "w", zipfile.ZIP_STORED, True) + + # encoding the file/dir name with base64 so Windows can unzip a unicode + # filename and get the right name, Windows doesn't handle unicode names + # very well + for directory in directories: + directory_path = to_bytes(directory['src'], errors='surrogate_or_strict') + archive_path = to_bytes(directory['dest'], errors='surrogate_or_strict') + + encoded_path = to_text(base64.b64encode(archive_path), errors='surrogate_or_strict') + zip_file.write(directory_path, encoded_path, zipfile.ZIP_DEFLATED) + + for file in files: + file_path = to_bytes(file['src'], errors='surrogate_or_strict') + archive_path = to_bytes(file['dest'], errors='surrogate_or_strict') + + encoded_path = to_text(base64.b64encode(archive_path), errors='surrogate_or_strict') + zip_file.write(file_path, encoded_path, zipfile.ZIP_DEFLATED) + + return zip_file_path + + def _remove_tempfile_if_content_defined(self, content, content_tempfile): + if content is not None: + os.remove(content_tempfile) + + def _copy_single_file(self, local_file, dest, source_rel, task_vars, tmp, backup): + if self._play_context.check_mode: + module_return = dict(changed=True) + return module_return + + # copy the file across to the server + tmp_src = self._connection._shell.join_path(tmp, 'source') + self._transfer_file(local_file, tmp_src) + + copy_args = self._task.args.copy() + copy_args.update( + dict( + dest=dest, + src=tmp_src, + _original_basename=source_rel, + _copy_mode="single", + backup=backup, + ) + ) + copy_args.pop('content', None) + + copy_result = self._execute_module(module_name="copy", + module_args=copy_args, + task_vars=task_vars) + + return copy_result + + def _copy_zip_file(self, dest, files, directories, task_vars, tmp, backup): + # create local zip file containing all the files and directories that + # need to be copied to the server + if self._play_context.check_mode: + module_return = dict(changed=True) + return module_return + + try: + zip_file = self._create_zip_tempfile(files, directories) + except Exception as e: + module_return = dict( + changed=False, + failed=True, + msg="failed to create tmp zip file: %s" % to_text(e), + exception=traceback.format_exc() + ) + return module_return + + zip_path = self._loader.get_real_file(zip_file) + + # send zip file to remote, file must end in .zip so + # Com Shell.Application works + tmp_src = self._connection._shell.join_path(tmp, 'source.zip') + self._transfer_file(zip_path, tmp_src) + + # run the explode operation of win_copy on remote + copy_args = self._task.args.copy() + copy_args.update( + dict( + src=tmp_src, + dest=dest, + _copy_mode="explode", + backup=backup, + ) + ) + copy_args.pop('content', None) + module_return = self._execute_module(module_name='copy', + module_args=copy_args, + task_vars=task_vars) + shutil.rmtree(os.path.dirname(zip_path)) + return module_return + + def run(self, tmp=None, task_vars=None): + ''' handler for file transfer operations ''' + if task_vars is None: + task_vars = dict() + + result = super(ActionModule, self).run(tmp, task_vars) + del tmp # tmp no longer has any effect + + source = self._task.args.get('src', None) + content = self._task.args.get('content', None) + dest = self._task.args.get('dest', None) + remote_src = boolean(self._task.args.get('remote_src', False), strict=False) + local_follow = boolean(self._task.args.get('local_follow', False), strict=False) + force = boolean(self._task.args.get('force', True), strict=False) + decrypt = boolean(self._task.args.get('decrypt', True), strict=False) + backup = boolean(self._task.args.get('backup', False), strict=False) + + result['src'] = source + result['dest'] = dest + + result['failed'] = True + if (source is None and content is None) or dest is None: + result['msg'] = "src (or content) and dest are required" + elif source is not None and content is not None: + result['msg'] = "src and content are mutually exclusive" + elif content is not None and dest is not None and ( + dest.endswith(os.path.sep) or dest.endswith(self.WIN_PATH_SEPARATOR)): + result['msg'] = "dest must be a file if content is defined" + else: + del result['failed'] + + if result.get('failed'): + return result + + # If content is defined make a temp file and write the content into it + content_tempfile = None + if content is not None: + try: + # if content comes to us as a dict it should be decoded json. + # We need to encode it back into a string and write it out + if isinstance(content, dict) or isinstance(content, list): + content_tempfile = self._create_content_tempfile(json.dumps(content)) + else: + content_tempfile = self._create_content_tempfile(content) + source = content_tempfile + except Exception as err: + result['failed'] = True + result['msg'] = "could not write content tmp file: %s" % to_native(err) + return result + # all actions should occur on the remote server, run win_copy module + elif remote_src: + new_module_args = self._task.args.copy() + new_module_args.update( + dict( + _copy_mode="remote", + dest=dest, + src=source, + force=force, + backup=backup, + ) + ) + new_module_args.pop('content', None) + result.update(self._execute_module(module_args=new_module_args, task_vars=task_vars)) + return result + # find_needle returns a path that may not have a trailing slash on a + # directory so we need to find that out first and append at the end + else: + trailing_slash = source.endswith(os.path.sep) + try: + # find in expected paths + source = self._find_needle('files', source) + except AnsibleError as e: + result['failed'] = True + result['msg'] = to_text(e) + result['exception'] = traceback.format_exc() + return result + + if trailing_slash != source.endswith(os.path.sep): + if source[-1] == os.path.sep: + source = source[:-1] + else: + source = source + os.path.sep + + # A list of source file tuples (full_path, relative_path) which will try to copy to the destination + source_files = {'files': [], 'directories': [], 'symlinks': []} + + # If source is a directory populate our list else source is a file and translate it to a tuple. + if os.path.isdir(to_bytes(source, errors='surrogate_or_strict')): + result['operation'] = 'folder_copy' + + # Get a list of the files we want to replicate on the remote side + source_files = _walk_dirs(source, self._loader, decrypt=decrypt, local_follow=local_follow, + trailing_slash_detector=self._connection._shell.path_has_trailing_slash, + checksum_check=force) + + # If it's recursive copy, destination is always a dir, + # explicitly mark it so (note - win_copy module relies on this). + if not self._connection._shell.path_has_trailing_slash(dest): + dest = "%s%s" % (dest, self.WIN_PATH_SEPARATOR) + + check_dest = dest + # Source is a file, add details to source_files dict + else: + result['operation'] = 'file_copy' + + # If the local file does not exist, get_real_file() raises AnsibleFileNotFound + try: + source_full = self._loader.get_real_file(source, decrypt=decrypt) + except AnsibleFileNotFound as e: + result['failed'] = True + result['msg'] = "could not find src=%s, %s" % (source_full, to_text(e)) + return result + + original_basename = os.path.basename(source) + result['original_basename'] = original_basename + + # check if dest ends with / or \ and append source filename to dest + if self._connection._shell.path_has_trailing_slash(dest): + check_dest = dest + filename = original_basename + result['dest'] = self._connection._shell.join_path(dest, filename) + else: + # replace \\ with / so we can use os.path to get the filename or dirname + unix_path = dest.replace(self.WIN_PATH_SEPARATOR, os.path.sep) + filename = os.path.basename(unix_path) + check_dest = os.path.dirname(unix_path) + + file_checksum = _get_local_checksum(force, source_full) + source_files['files'].append( + dict( + src=source_full, + dest=filename, + checksum=file_checksum + ) + ) + result['checksum'] = file_checksum + result['size'] = os.path.getsize(to_bytes(source_full, errors='surrogate_or_strict')) + + # find out the files/directories/symlinks that we need to copy to the server + query_args = self._task.args.copy() + query_args.update( + dict( + _copy_mode="query", + dest=check_dest, + force=force, + files=source_files['files'], + directories=source_files['directories'], + symlinks=source_files['symlinks'], + ) + ) + # src is not required for query, will fail path validation is src has unix allowed chars + query_args.pop('src', None) + + query_args.pop('content', None) + query_return = self._execute_module(module_args=query_args, + task_vars=task_vars) + + if query_return.get('failed') is True: + result.update(query_return) + return result + + if len(query_return['files']) > 0 or len(query_return['directories']) > 0 and self._connection._shell.tmpdir is None: + self._connection._shell.tmpdir = self._make_tmp_path() + + if len(query_return['files']) == 1 and len(query_return['directories']) == 0: + # we only need to copy 1 file, don't mess around with zips + file_src = query_return['files'][0]['src'] + file_dest = query_return['files'][0]['dest'] + result.update(self._copy_single_file(file_src, dest, file_dest, + task_vars, self._connection._shell.tmpdir, backup)) + if result.get('failed') is True: + result['msg'] = "failed to copy file %s: %s" % (file_src, result['msg']) + result['changed'] = True + + elif len(query_return['files']) > 0 or len(query_return['directories']) > 0: + # either multiple files or directories need to be copied, compress + # to a zip and 'explode' the zip on the server + # TODO: handle symlinks + result.update(self._copy_zip_file(dest, source_files['files'], + source_files['directories'], + task_vars, self._connection._shell.tmpdir, backup)) + result['changed'] = True + else: + # no operations need to occur + result['failed'] = False + result['changed'] = False + + # remove the content tmp file and remote tmp file if it was created + self._remove_tempfile_if_content_defined(content, content_tempfile) + self._remove_tmp_path(self._connection._shell.tmpdir) + return result diff --git a/test/support/windows-integration/plugins/action/win_reboot.py b/test/support/windows-integration/plugins/action/win_reboot.py new file mode 100644 index 00000000000..c408f4f30ec --- /dev/null +++ b/test/support/windows-integration/plugins/action/win_reboot.py @@ -0,0 +1,96 @@ +# Copyright: (c) 2018, Matt Davis +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from datetime import datetime + +from ansible.errors import AnsibleError +from ansible.module_utils._text import to_native +from ansible.plugins.action import ActionBase +from ansible.plugins.action.reboot import ActionModule as RebootActionModule +from ansible.utils.display import Display + +display = Display() + + +class TimedOutException(Exception): + pass + + +class ActionModule(RebootActionModule, ActionBase): + TRANSFERS_FILES = False + _VALID_ARGS = frozenset(( + 'connect_timeout', 'connect_timeout_sec', 'msg', 'post_reboot_delay', 'post_reboot_delay_sec', 'pre_reboot_delay', 'pre_reboot_delay_sec', + 'reboot_timeout', 'reboot_timeout_sec', 'shutdown_timeout', 'shutdown_timeout_sec', 'test_command', + )) + + DEFAULT_BOOT_TIME_COMMAND = "(Get-WmiObject -ClassName Win32_OperatingSystem).LastBootUpTime" + DEFAULT_CONNECT_TIMEOUT = 5 + DEFAULT_PRE_REBOOT_DELAY = 2 + DEFAULT_SUDOABLE = False + DEFAULT_SHUTDOWN_COMMAND_ARGS = '/r /t {delay_sec} /c "{message}"' + + DEPRECATED_ARGS = { + 'shutdown_timeout': '2.5', + 'shutdown_timeout_sec': '2.5', + } + + def __init__(self, *args, **kwargs): + super(ActionModule, self).__init__(*args, **kwargs) + + def get_distribution(self, task_vars): + return {'name': 'windows', 'version': '', 'family': ''} + + def get_shutdown_command(self, task_vars, distribution): + return self.DEFAULT_SHUTDOWN_COMMAND + + def run_test_command(self, distribution, **kwargs): + # Need to wrap the test_command in our PowerShell encoded wrapper. This is done to align the command input to a + # common shell and to allow the psrp connection plugin to report the correct exit code without manually setting + # $LASTEXITCODE for just that plugin. + test_command = self._task.args.get('test_command', self.DEFAULT_TEST_COMMAND) + kwargs['test_command'] = self._connection._shell._encode_script(test_command) + super(ActionModule, self).run_test_command(distribution, **kwargs) + + def perform_reboot(self, task_vars, distribution): + shutdown_command = self.get_shutdown_command(task_vars, distribution) + shutdown_command_args = self.get_shutdown_command_args(distribution) + reboot_command = self._connection._shell._encode_script('{0} {1}'.format(shutdown_command, shutdown_command_args)) + + display.vvv("{action}: rebooting server...".format(action=self._task.action)) + display.debug("{action}: distribution: {dist}".format(action=self._task.action, dist=distribution)) + display.debug("{action}: rebooting server with command '{command}'".format(action=self._task.action, command=reboot_command)) + + result = {} + reboot_result = self._low_level_execute_command(reboot_command, sudoable=self.DEFAULT_SUDOABLE) + result['start'] = datetime.utcnow() + + # Test for "A system shutdown has already been scheduled. (1190)" and handle it gracefully + stdout = reboot_result['stdout'] + stderr = reboot_result['stderr'] + if reboot_result['rc'] == 1190 or (reboot_result['rc'] != 0 and "(1190)" in reboot_result['stderr']): + display.warning('A scheduled reboot was pre-empted by Ansible.') + + # Try to abort (this may fail if it was already aborted) + result1 = self._low_level_execute_command(self._connection._shell._encode_script('shutdown /a'), + sudoable=self.DEFAULT_SUDOABLE) + + # Initiate reboot again + result2 = self._low_level_execute_command(reboot_command, sudoable=self.DEFAULT_SUDOABLE) + + reboot_result['rc'] = result2['rc'] + stdout += result1['stdout'] + result2['stdout'] + stderr += result1['stderr'] + result2['stderr'] + + if reboot_result['rc'] != 0: + result['failed'] = True + result['rebooted'] = False + result['msg'] = "Reboot command failed, error was: {stdout} {stderr}".format( + stdout=to_native(stdout.strip()), + stderr=to_native(stderr.strip())) + return result + + result['failed'] = False + return result diff --git a/test/support/windows-integration/plugins/action/win_template.py b/test/support/windows-integration/plugins/action/win_template.py new file mode 100644 index 00000000000..20494b93e77 --- /dev/null +++ b/test/support/windows-integration/plugins/action/win_template.py @@ -0,0 +1,29 @@ +# (c) 2012-2014, Michael DeHaan +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +# Make coding more python3-ish +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from ansible.plugins.action import ActionBase +from ansible.plugins.action.template import ActionModule as TemplateActionModule + + +# Even though TemplateActionModule inherits from ActionBase, we still need to +# directly inherit from ActionBase to appease the plugin loader. +class ActionModule(TemplateActionModule, ActionBase): + DEFAULT_NEWLINE_SEQUENCE = '\r\n' diff --git a/test/support/windows-integration/plugins/become/runas.py b/test/support/windows-integration/plugins/become/runas.py new file mode 100644 index 00000000000..c8ae881c3c2 --- /dev/null +++ b/test/support/windows-integration/plugins/become/runas.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = """ + become: runas + short_description: Run As user + description: + - This become plugins allows your remote/login user to execute commands as another user via the windows runas facility. + author: ansible (@core) + version_added: "2.8" + options: + become_user: + description: User you 'become' to execute the task + ini: + - section: privilege_escalation + key: become_user + - section: runas_become_plugin + key: user + vars: + - name: ansible_become_user + - name: ansible_runas_user + env: + - name: ANSIBLE_BECOME_USER + - name: ANSIBLE_RUNAS_USER + required: True + become_flags: + description: Options to pass to runas, a space delimited list of k=v pairs + default: '' + ini: + - section: privilege_escalation + key: become_flags + - section: runas_become_plugin + key: flags + vars: + - name: ansible_become_flags + - name: ansible_runas_flags + env: + - name: ANSIBLE_BECOME_FLAGS + - name: ANSIBLE_RUNAS_FLAGS + become_pass: + description: password + ini: + - section: runas_become_plugin + key: password + vars: + - name: ansible_become_password + - name: ansible_become_pass + - name: ansible_runas_pass + env: + - name: ANSIBLE_BECOME_PASS + - name: ANSIBLE_RUNAS_PASS + notes: + - runas is really implemented in the powershell module handler and as such can only be used with winrm connections. + - This plugin ignores the 'become_exe' setting as it uses an API and not an executable. + - The Secondary Logon service (seclogon) must be running to use runas +""" + +from ansible.plugins.become import BecomeBase + + +class BecomeModule(BecomeBase): + + name = 'runas' + + def build_become_command(self, cmd, shell): + # runas is implemented inside the winrm connection plugin + return cmd diff --git a/test/support/windows-integration/plugins/module_utils/Ansible.Service.cs b/test/support/windows-integration/plugins/module_utils/Ansible.Service.cs new file mode 100644 index 00000000000..be0f3db3f3b --- /dev/null +++ b/test/support/windows-integration/plugins/module_utils/Ansible.Service.cs @@ -0,0 +1,1341 @@ +using Microsoft.Win32.SafeHandles; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.ConstrainedExecution; +using System.Runtime.InteropServices; +using System.Security.Principal; +using System.Text; +using Ansible.Privilege; + +namespace Ansible.Service +{ + internal class NativeHelpers + { + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct ENUM_SERVICE_STATUSW + { + public string lpServiceName; + public string lpDisplayName; + public SERVICE_STATUS ServiceStatus; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct QUERY_SERVICE_CONFIGW + { + public ServiceType dwServiceType; + public ServiceStartType dwStartType; + public ErrorControl dwErrorControl; + [MarshalAs(UnmanagedType.LPWStr)] public string lpBinaryPathName; + [MarshalAs(UnmanagedType.LPWStr)] public string lpLoadOrderGroup; + public Int32 dwTagId; + public IntPtr lpDependencies; // Can't rely on marshaling as dependencies are delimited by \0. + [MarshalAs(UnmanagedType.LPWStr)] public string lpServiceStartName; + [MarshalAs(UnmanagedType.LPWStr)] public string lpDisplayName; + } + + [StructLayout(LayoutKind.Sequential)] + public struct SC_ACTION + { + public FailureAction Type; + public UInt32 Delay; + } + + [StructLayout(LayoutKind.Sequential)] + public struct SERVICE_DELAYED_AUTO_START_INFO + { + public bool fDelayedAutostart; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct SERVICE_DESCRIPTIONW + { + [MarshalAs(UnmanagedType.LPWStr)] public string lpDescription; + } + + [StructLayout(LayoutKind.Sequential)] + public struct SERVICE_FAILURE_ACTIONS_FLAG + { + public bool fFailureActionsOnNonCrashFailures; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct SERVICE_FAILURE_ACTIONSW + { + public UInt32 dwResetPeriod; + [MarshalAs(UnmanagedType.LPWStr)] public string lpRebootMsg; + [MarshalAs(UnmanagedType.LPWStr)] public string lpCommand; + public UInt32 cActions; + public IntPtr lpsaActions; + } + + [StructLayout(LayoutKind.Sequential)] + public struct SERVICE_LAUNCH_PROTECTED_INFO + { + public LaunchProtection dwLaunchProtected; + } + + [StructLayout(LayoutKind.Sequential)] + public struct SERVICE_PREFERRED_NODE_INFO + { + public UInt16 usPreferredNode; + public bool fDelete; + } + + [StructLayout(LayoutKind.Sequential)] + public struct SERVICE_PRESHUTDOWN_INFO + { + public UInt32 dwPreshutdownTimeout; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct SERVICE_REQUIRED_PRIVILEGES_INFOW + { + // Can't rely on marshaling as privileges are delimited by \0. + public IntPtr pmszRequiredPrivileges; + } + + [StructLayout(LayoutKind.Sequential)] + public struct SERVICE_SID_INFO + { + public ServiceSidInfo dwServiceSidType; + } + + [StructLayout(LayoutKind.Sequential)] + public struct SERVICE_STATUS + { + public ServiceType dwServiceType; + public ServiceStatus dwCurrentState; + public ControlsAccepted dwControlsAccepted; + public UInt32 dwWin32ExitCode; + public UInt32 dwServiceSpecificExitCode; + public UInt32 dwCheckPoint; + public UInt32 dwWaitHint; + } + + [StructLayout(LayoutKind.Sequential)] + public struct SERVICE_STATUS_PROCESS + { + public ServiceType dwServiceType; + public ServiceStatus dwCurrentState; + public ControlsAccepted dwControlsAccepted; + public UInt32 dwWin32ExitCode; + public UInt32 dwServiceSpecificExitCode; + public UInt32 dwCheckPoint; + public UInt32 dwWaitHint; + public UInt32 dwProcessId; + public ServiceFlags dwServiceFlags; + } + + [StructLayout(LayoutKind.Sequential)] + public struct SERVICE_TRIGGER + { + public TriggerType dwTriggerType; + public TriggerAction dwAction; + public IntPtr pTriggerSubtype; + public UInt32 cDataItems; + public IntPtr pDataItems; + } + + [StructLayout(LayoutKind.Sequential)] + public struct SERVICE_TRIGGER_SPECIFIC_DATA_ITEM + { + public TriggerDataType dwDataType; + public UInt32 cbData; + public IntPtr pData; + } + + [StructLayout(LayoutKind.Sequential)] + public struct SERVICE_TRIGGER_INFO + { + public UInt32 cTriggers; + public IntPtr pTriggers; + public IntPtr pReserved; + } + + public enum ConfigInfoLevel : uint + { + SERVICE_CONFIG_DESCRIPTION = 0x00000001, + SERVICE_CONFIG_FAILURE_ACTIONS = 0x00000002, + SERVICE_CONFIG_DELAYED_AUTO_START_INFO = 0x00000003, + SERVICE_CONFIG_FAILURE_ACTIONS_FLAG = 0x00000004, + SERVICE_CONFIG_SERVICE_SID_INFO = 0x00000005, + SERVICE_CONFIG_REQUIRED_PRIVILEGES_INFO = 0x00000006, + SERVICE_CONFIG_PRESHUTDOWN_INFO = 0x00000007, + SERVICE_CONFIG_TRIGGER_INFO = 0x00000008, + SERVICE_CONFIG_PREFERRED_NODE = 0x00000009, + SERVICE_CONFIG_LAUNCH_PROTECTED = 0x0000000c, + } + } + + internal class NativeMethods + { + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool ChangeServiceConfigW( + SafeHandle hService, + ServiceType dwServiceType, + ServiceStartType dwStartType, + ErrorControl dwErrorControl, + string lpBinaryPathName, + string lpLoadOrderGroup, + IntPtr lpdwTagId, + string lpDependencies, + string lpServiceStartName, + string lpPassword, + string lpDisplayName); + + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool ChangeServiceConfig2W( + SafeHandle hService, + NativeHelpers.ConfigInfoLevel dwInfoLevel, + IntPtr lpInfo); + + [DllImport("Advapi32.dll", SetLastError = true)] + public static extern bool CloseServiceHandle( + IntPtr hSCObject); + + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern SafeServiceHandle CreateServiceW( + SafeHandle hSCManager, + string lpServiceName, + string lpDisplayName, + ServiceRights dwDesiredAccess, + ServiceType dwServiceType, + ServiceStartType dwStartType, + ErrorControl dwErrorControl, + string lpBinaryPathName, + string lpLoadOrderGroup, + IntPtr lpdwTagId, + string lpDependencies, + string lpServiceStartName, + string lpPassword); + + [DllImport("Advapi32.dll", SetLastError = true)] + public static extern bool DeleteService( + SafeHandle hService); + + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool EnumDependentServicesW( + SafeHandle hService, + UInt32 dwServiceState, + SafeMemoryBuffer lpServices, + UInt32 cbBufSize, + out UInt32 pcbBytesNeeded, + out UInt32 lpServicesReturned); + + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern SafeServiceHandle OpenSCManagerW( + string lpMachineName, + string lpDatabaseNmae, + SCMRights dwDesiredAccess); + + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern SafeServiceHandle OpenServiceW( + SafeHandle hSCManager, + string lpServiceName, + ServiceRights dwDesiredAccess); + + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool QueryServiceConfigW( + SafeHandle hService, + IntPtr lpServiceConfig, + UInt32 cbBufSize, + out UInt32 pcbBytesNeeded); + + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool QueryServiceConfig2W( + SafeHandle hservice, + NativeHelpers.ConfigInfoLevel dwInfoLevel, + IntPtr lpBuffer, + UInt32 cbBufSize, + out UInt32 pcbBytesNeeded); + + [DllImport("Advapi32.dll", SetLastError = true)] + public static extern bool QueryServiceStatusEx( + SafeHandle hService, + UInt32 InfoLevel, + IntPtr lpBuffer, + UInt32 cbBufSize, + out UInt32 pcbBytesNeeded); + } + + internal class SafeMemoryBuffer : SafeHandleZeroOrMinusOneIsInvalid + { + public UInt32 BufferLength { get; internal set; } + + public SafeMemoryBuffer() : base(true) { } + public SafeMemoryBuffer(int cb) : base(true) + { + BufferLength = (UInt32)cb; + base.SetHandle(Marshal.AllocHGlobal(cb)); + } + public SafeMemoryBuffer(IntPtr handle) : base(true) + { + base.SetHandle(handle); + } + + [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)] + protected override bool ReleaseHandle() + { + Marshal.FreeHGlobal(handle); + return true; + } + } + + internal class SafeServiceHandle : SafeHandleZeroOrMinusOneIsInvalid + { + public SafeServiceHandle() : base(true) { } + public SafeServiceHandle(IntPtr handle) : base(true) { this.handle = handle; } + + [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)] + protected override bool ReleaseHandle() + { + return NativeMethods.CloseServiceHandle(handle); + } + } + + [Flags] + public enum ControlsAccepted : uint + { + None = 0x00000000, + Stop = 0x00000001, + PauseContinue = 0x00000002, + Shutdown = 0x00000004, + ParamChange = 0x00000008, + NetbindChange = 0x00000010, + HardwareProfileChange = 0x00000020, + PowerEvent = 0x00000040, + SessionChange = 0x00000080, + PreShutdown = 0x00000100, + } + + public enum ErrorControl : uint + { + Ignore = 0x00000000, + Normal = 0x00000001, + Severe = 0x00000002, + Critical = 0x00000003, + } + + public enum FailureAction : uint + { + None = 0x00000000, + Restart = 0x00000001, + Reboot = 0x00000002, + RunCommand = 0x00000003, + } + + public enum LaunchProtection : uint + { + None = 0, + Windows = 1, + WindowsLight = 2, + AntimalwareLight = 3, + } + + [Flags] + public enum SCMRights : uint + { + Connect = 0x00000001, + CreateService = 0x00000002, + EnumerateService = 0x00000004, + Lock = 0x00000008, + QueryLockStatus = 0x00000010, + ModifyBootConfig = 0x00000020, + AllAccess = 0x000F003F, + } + + [Flags] + public enum ServiceFlags : uint + { + None = 0x0000000, + RunsInSystemProcess = 0x00000001, + } + + [Flags] + public enum ServiceRights : uint + { + QueryConfig = 0x00000001, + ChangeConfig = 0x00000002, + QueryStatus = 0x00000004, + EnumerateDependents = 0x00000008, + Start = 0x00000010, + Stop = 0x00000020, + PauseContinue = 0x00000040, + Interrogate = 0x00000080, + UserDefinedControl = 0x00000100, + Delete = 0x00010000, + ReadControl = 0x00020000, + WriteDac = 0x00040000, + WriteOwner = 0x00080000, + AllAccess = 0x000F01FF, + AccessSystemSecurity = 0x01000000, + } + + public enum ServiceStartType : uint + { + BootStart = 0x00000000, + SystemStart = 0x00000001, + AutoStart = 0x00000002, + DemandStart = 0x00000003, + Disabled = 0x00000004, + + // Not part of ChangeServiceConfig enumeration but built by the Srvice class for the StartType property. + AutoStartDelayed = 0x1000000 + } + + [Flags] + public enum ServiceType : uint + { + KernelDriver = 0x00000001, + FileSystemDriver = 0x00000002, + Adapter = 0x00000004, + RecognizerDriver = 0x00000008, + Driver = KernelDriver | FileSystemDriver | RecognizerDriver, + Win32OwnProcess = 0x00000010, + Win32ShareProcess = 0x00000020, + Win32 = Win32OwnProcess | Win32ShareProcess, + UserProcess = 0x00000040, + UserOwnprocess = Win32OwnProcess | UserProcess, + UserShareProcess = Win32ShareProcess | UserProcess, + UserServiceInstance = 0x00000080, + InteractiveProcess = 0x00000100, + PkgService = 0x00000200, + } + + public enum ServiceSidInfo : uint + { + None, + Unrestricted, + Restricted = 3, + } + + public enum ServiceStatus : uint + { + Stopped = 0x00000001, + StartPending = 0x00000002, + StopPending = 0x00000003, + Running = 0x00000004, + ContinuePending = 0x00000005, + PausePending = 0x00000006, + Paused = 0x00000007, + } + + public enum TriggerAction : uint + { + ServiceStart = 0x00000001, + ServiceStop = 0x000000002, + } + + public enum TriggerDataType : uint + { + Binary = 00000001, + String = 0x00000002, + Level = 0x00000003, + KeywordAny = 0x00000004, + KeywordAll = 0x00000005, + } + + public enum TriggerType : uint + { + DeviceInterfaceArrival = 0x00000001, + IpAddressAvailability = 0x00000002, + DomainJoin = 0x00000003, + FirewallPortEvent = 0x00000004, + GroupPolicy = 0x00000005, + NetworkEndpoint = 0x00000006, + Custom = 0x00000014, + } + + public class ServiceManagerException : System.ComponentModel.Win32Exception + { + private string _msg; + + public ServiceManagerException(string message) : this(Marshal.GetLastWin32Error(), message) { } + public ServiceManagerException(int errorCode, string message) : base(errorCode) + { + _msg = String.Format("{0} ({1}, Win32ErrorCode {2} - 0x{2:X8})", message, base.Message, errorCode); + } + + public override string Message { get { return _msg; } } + public static explicit operator ServiceManagerException(string message) + { + return new ServiceManagerException(message); + } + } + + public class Action + { + public FailureAction Type; + public UInt32 Delay; + } + + public class FailureActions + { + public UInt32? ResetPeriod = null; // Get is always populated, can be null on set to preserve existing. + public string RebootMsg = null; + public string Command = null; + public List Actions = null; + + public FailureActions() { } + + internal FailureActions(NativeHelpers.SERVICE_FAILURE_ACTIONSW actions) + { + ResetPeriod = actions.dwResetPeriod; + RebootMsg = actions.lpRebootMsg; + Command = actions.lpCommand; + Actions = new List(); + + int actionLength = Marshal.SizeOf(typeof(NativeHelpers.SC_ACTION)); + for (int i = 0; i < actions.cActions; i++) + { + IntPtr actionPtr = IntPtr.Add(actions.lpsaActions, i * actionLength); + + NativeHelpers.SC_ACTION rawAction = (NativeHelpers.SC_ACTION)Marshal.PtrToStructure( + actionPtr, typeof(NativeHelpers.SC_ACTION)); + + Actions.Add(new Action() + { + Type = rawAction.Type, + Delay = rawAction.Delay, + }); + } + } + } + + public class TriggerItem + { + public TriggerDataType Type; + public object Data; // Can be string, List, byte, byte[], or Int64 depending on Type. + + public TriggerItem() { } + + internal TriggerItem(NativeHelpers.SERVICE_TRIGGER_SPECIFIC_DATA_ITEM dataItem) + { + Type = dataItem.dwDataType; + + byte[] itemBytes = new byte[dataItem.cbData]; + Marshal.Copy(dataItem.pData, itemBytes, 0, itemBytes.Length); + + switch (dataItem.dwDataType) + { + case TriggerDataType.String: + string value = Encoding.Unicode.GetString(itemBytes, 0, itemBytes.Length); + + if (value.EndsWith("\0\0")) + { + // Multistring with a delimiter of \0 and terminated with \0\0. + Data = new List(value.Split(new char[1] { '\0' }, StringSplitOptions.RemoveEmptyEntries)); + } + else + // Just a single string with null character at the end, strip it off. + Data = value.Substring(0, value.Length - 1); + break; + case TriggerDataType.Level: + Data = itemBytes[0]; + break; + case TriggerDataType.KeywordAll: + case TriggerDataType.KeywordAny: + Data = BitConverter.ToUInt64(itemBytes, 0); + break; + default: + Data = itemBytes; + break; + } + } + } + + public class Trigger + { + // https://docs.microsoft.com/en-us/windows/win32/api/winsvc/ns-winsvc-service_trigger + public const string NAMED_PIPE_EVENT_GUID = "1f81d131-3fac-4537-9e0c-7e7b0c2f4b55"; + public const string RPC_INTERFACE_EVENT_GUID = "bc90d167-9470-4139-a9ba-be0bbbf5b74d"; + public const string DOMAIN_JOIN_GUID = "1ce20aba-9851-4421-9430-1ddeb766e809"; + public const string DOMAIN_LEAVE_GUID = "ddaf516e-58c2-4866-9574-c3b615d42ea1"; + public const string FIREWALL_PORT_OPEN_GUID = "b7569e07-8421-4ee0-ad10-86915afdad09"; + public const string FIREWALL_PORT_CLOSE_GUID = "a144ed38-8e12-4de4-9d96-e64740b1a524"; + public const string MACHINE_POLICY_PRESENT_GUID = "659fcae6-5bdb-4da9-b1ff-ca2a178d46e0"; + public const string NETWORK_MANAGER_FIRST_IP_ADDRESS_ARRIVAL_GUID = "4f27f2de-14e2-430b-a549-7cd48cbc8245"; + public const string NETWORK_MANAGER_LAST_IP_ADDRESS_REMOVAL_GUID = "cc4ba62a-162e-4648-847a-b6bdf993e335"; + public const string USER_POLICY_PRESENT_GUID = "54fb46c8-f089-464c-b1fd-59d1b62c3b50"; + + public TriggerType Type; + public TriggerAction Action; + public Guid SubType; + public List DataItems = new List(); + + public Trigger() { } + + internal Trigger(NativeHelpers.SERVICE_TRIGGER trigger) + { + Type = trigger.dwTriggerType; + Action = trigger.dwAction; + SubType = (Guid)Marshal.PtrToStructure(trigger.pTriggerSubtype, typeof(Guid)); + + int dataItemLength = Marshal.SizeOf(typeof(NativeHelpers.SERVICE_TRIGGER_SPECIFIC_DATA_ITEM)); + for (int i = 0; i < trigger.cDataItems; i++) + { + IntPtr dataPtr = IntPtr.Add(trigger.pDataItems, i * dataItemLength); + + var dataItem = (NativeHelpers.SERVICE_TRIGGER_SPECIFIC_DATA_ITEM)Marshal.PtrToStructure( + dataPtr, typeof(NativeHelpers.SERVICE_TRIGGER_SPECIFIC_DATA_ITEM)); + + DataItems.Add(new TriggerItem(dataItem)); + } + } + } + + public class Service : IDisposable + { + private const UInt32 SERVICE_NO_CHANGE = 0xFFFFFFFF; + + private SafeServiceHandle _scmHandle; + private SafeServiceHandle _serviceHandle; + private SafeMemoryBuffer _rawServiceConfig; + private NativeHelpers.SERVICE_STATUS_PROCESS _statusProcess; + + private NativeHelpers.QUERY_SERVICE_CONFIGW _ServiceConfig + { + get + { + return (NativeHelpers.QUERY_SERVICE_CONFIGW)Marshal.PtrToStructure( + _rawServiceConfig.DangerousGetHandle(), typeof(NativeHelpers.QUERY_SERVICE_CONFIGW)); + } + } + + // ServiceConfig + public string ServiceName { get; private set; } + + public ServiceType ServiceType + { + get { return _ServiceConfig.dwServiceType; } + set { ChangeServiceConfig(serviceType: value); } + } + + public ServiceStartType StartType + { + get + { + ServiceStartType startType = _ServiceConfig.dwStartType; + if (startType == ServiceStartType.AutoStart) + { + var value = QueryServiceConfig2( + NativeHelpers.ConfigInfoLevel.SERVICE_CONFIG_DELAYED_AUTO_START_INFO); + + if (value.fDelayedAutostart) + startType = ServiceStartType.AutoStartDelayed; + } + + return startType; + } + set + { + ServiceStartType newStartType = value; + bool delayedStart = false; + if (value == ServiceStartType.AutoStartDelayed) + { + newStartType = ServiceStartType.AutoStart; + delayedStart = true; + } + + ChangeServiceConfig(startType: newStartType); + + var info = new NativeHelpers.SERVICE_DELAYED_AUTO_START_INFO() + { + fDelayedAutostart = delayedStart, + }; + ChangeServiceConfig2(NativeHelpers.ConfigInfoLevel.SERVICE_CONFIG_DELAYED_AUTO_START_INFO, info); + } + } + + public ErrorControl ErrorControl + { + get { return _ServiceConfig.dwErrorControl; } + set { ChangeServiceConfig(errorControl: value); } + } + + public string Path + { + get { return _ServiceConfig.lpBinaryPathName; } + set { ChangeServiceConfig(binaryPath: value); } + } + + public string LoadOrderGroup + { + get { return _ServiceConfig.lpLoadOrderGroup; } + set { ChangeServiceConfig(loadOrderGroup: value); } + } + + public List DependentOn + { + get + { + StringBuilder deps = new StringBuilder(); + IntPtr depPtr = _ServiceConfig.lpDependencies; + + bool wasNull = false; + while (true) + { + // Get the current char at the pointer and add it to the StringBuilder. + byte[] charBytes = new byte[sizeof(char)]; + Marshal.Copy(depPtr, charBytes, 0, charBytes.Length); + depPtr = IntPtr.Add(depPtr, charBytes.Length); + char currentChar = BitConverter.ToChar(charBytes, 0); + deps.Append(currentChar); + + // If the previous and current char is \0 exit the loop. + if (currentChar == '\0' && wasNull) + break; + wasNull = currentChar == '\0'; + } + + return new List(deps.ToString().Split(new char[1] { '\0' }, + StringSplitOptions.RemoveEmptyEntries)); + } + set { ChangeServiceConfig(dependencies: value); } + } + + public IdentityReference Account + { + get + { + if (_ServiceConfig.lpServiceStartName == null) + // User services don't have the start name specified and will be null. + return null; + else if (_ServiceConfig.lpServiceStartName == "LocalSystem") + // Special string used for the SYSTEM account, this is the same even for different localisations. + return (NTAccount)new SecurityIdentifier("S-1-5-18").Translate(typeof(NTAccount)); + else + return new NTAccount(_ServiceConfig.lpServiceStartName); + } + set + { + string startName = null; + string pass = null; + + if (value != null) + { + // Create a SID and convert back from a SID to get the Netlogon form regardless of the input + // specified. + SecurityIdentifier accountSid = (SecurityIdentifier)value.Translate(typeof(SecurityIdentifier)); + NTAccount accountName = (NTAccount)accountSid.Translate(typeof(NTAccount)); + string[] accountSplit = accountName.Value.Split(new char[1] { '\\' }, 2); + + // SYSTEM, Local Service, Network Service + List serviceAccounts = new List { "S-1-5-18", "S-1-5-19", "S-1-5-20" }; + + // Well known service accounts and MSAs should have no password set. Explicitly blank out the + // existing password to ensure older passwords are no longer stored by Windows. + if (serviceAccounts.Contains(accountSid.Value) || accountSplit[1].EndsWith("$")) + pass = ""; + + // The SYSTEM account uses this special string to specify that account otherwise use the original + // NTAccount value in case it is in a custom format (not Netlogon) for a reason. + if (accountSid.Value == serviceAccounts[0]) + startName = "LocalSystem"; + else + startName = value.Translate(typeof(NTAccount)).Value; + } + + ChangeServiceConfig(startName: startName, password: pass); + } + } + + public string Password { set { ChangeServiceConfig(password: value); } } + + public string DisplayName + { + get { return _ServiceConfig.lpDisplayName; } + set { ChangeServiceConfig(displayName: value); } + } + + // ServiceConfig2 + + public string Description + { + get + { + var value = QueryServiceConfig2( + NativeHelpers.ConfigInfoLevel.SERVICE_CONFIG_DESCRIPTION); + + return value.lpDescription; + } + set + { + var info = new NativeHelpers.SERVICE_DESCRIPTIONW() + { + lpDescription = value, + }; + ChangeServiceConfig2(NativeHelpers.ConfigInfoLevel.SERVICE_CONFIG_DESCRIPTION, info); + } + } + + public FailureActions FailureActions + { + get + { + using (SafeMemoryBuffer b = QueryServiceConfig2( + NativeHelpers.ConfigInfoLevel.SERVICE_CONFIG_FAILURE_ACTIONS)) + { + NativeHelpers.SERVICE_FAILURE_ACTIONSW value = (NativeHelpers.SERVICE_FAILURE_ACTIONSW) + Marshal.PtrToStructure(b.DangerousGetHandle(), typeof(NativeHelpers.SERVICE_FAILURE_ACTIONSW)); + + return new FailureActions(value); + } + } + set + { + // dwResetPeriod and lpsaActions must be set together, we need to read the existing config if someone + // wants to update 1 or the other but both aren't explicitly defined. + UInt32? resetPeriod = value.ResetPeriod; + List actions = value.Actions; + if ((resetPeriod != null && actions == null) || (resetPeriod == null && actions != null)) + { + FailureActions existingValue = this.FailureActions; + + if (resetPeriod != null && existingValue.Actions.Count == 0) + throw new ArgumentException( + "Cannot set FailureAction ResetPeriod without explicit Actions and no existing Actions"); + else if (resetPeriod == null) + resetPeriod = (UInt32)existingValue.ResetPeriod; + + if (actions == null) + actions = existingValue.Actions; + } + + var info = new NativeHelpers.SERVICE_FAILURE_ACTIONSW() + { + dwResetPeriod = resetPeriod == null ? 0 : (UInt32)resetPeriod, + lpRebootMsg = value.RebootMsg, + lpCommand = value.Command, + cActions = actions == null ? 0 : (UInt32)actions.Count, + lpsaActions = IntPtr.Zero, + }; + + // null means to keep the existing actions whereas an empty list deletes the actions. + if (actions == null) + { + ChangeServiceConfig2(NativeHelpers.ConfigInfoLevel.SERVICE_CONFIG_FAILURE_ACTIONS, info); + return; + } + + int actionLength = Marshal.SizeOf(typeof(NativeHelpers.SC_ACTION)); + using (SafeMemoryBuffer buffer = new SafeMemoryBuffer(actionLength * actions.Count)) + { + info.lpsaActions = buffer.DangerousGetHandle(); + HashSet privileges = new HashSet(); + + for (int i = 0; i < actions.Count; i++) + { + IntPtr actionPtr = IntPtr.Add(info.lpsaActions, i * actionLength); + NativeHelpers.SC_ACTION action = new NativeHelpers.SC_ACTION() + { + Delay = actions[i].Delay, + Type = actions[i].Type, + }; + Marshal.StructureToPtr(action, actionPtr, false); + + // Need to make sure the SeShutdownPrivilege is enabled when adding a reboot failure action. + if (action.Type == FailureAction.Reboot) + privileges.Add("SeShutdownPrivilege"); + } + + using (new PrivilegeEnabler(true, privileges.ToList().ToArray())) + ChangeServiceConfig2(NativeHelpers.ConfigInfoLevel.SERVICE_CONFIG_FAILURE_ACTIONS, info); + } + } + } + + public bool FailureActionsOnNonCrashFailures + { + get + { + var value = QueryServiceConfig2( + NativeHelpers.ConfigInfoLevel.SERVICE_CONFIG_FAILURE_ACTIONS_FLAG); + + return value.fFailureActionsOnNonCrashFailures; + } + set + { + var info = new NativeHelpers.SERVICE_FAILURE_ACTIONS_FLAG() + { + fFailureActionsOnNonCrashFailures = value, + }; + ChangeServiceConfig2(NativeHelpers.ConfigInfoLevel.SERVICE_CONFIG_FAILURE_ACTIONS_FLAG, info); + } + } + + public ServiceSidInfo ServiceSidInfo + { + get + { + var value = QueryServiceConfig2( + NativeHelpers.ConfigInfoLevel.SERVICE_CONFIG_SERVICE_SID_INFO); + + return value.dwServiceSidType; + } + set + { + var info = new NativeHelpers.SERVICE_SID_INFO() + { + dwServiceSidType = value, + }; + ChangeServiceConfig2(NativeHelpers.ConfigInfoLevel.SERVICE_CONFIG_SERVICE_SID_INFO, info); + } + } + + public List RequiredPrivileges + { + get + { + using (SafeMemoryBuffer buffer = QueryServiceConfig2( + NativeHelpers.ConfigInfoLevel.SERVICE_CONFIG_REQUIRED_PRIVILEGES_INFO)) + { + var value = (NativeHelpers.SERVICE_REQUIRED_PRIVILEGES_INFOW)Marshal.PtrToStructure( + buffer.DangerousGetHandle(), typeof(NativeHelpers.SERVICE_REQUIRED_PRIVILEGES_INFOW)); + + int structLength = Marshal.SizeOf(value); + int stringLength = ((int)buffer.BufferLength - structLength) / sizeof(char); + + if (stringLength > 0) + { + string privilegesString = Marshal.PtrToStringUni(value.pmszRequiredPrivileges, stringLength); + return new List(privilegesString.Split(new char[1] { '\0' }, + StringSplitOptions.RemoveEmptyEntries)); + } + else + return new List(); + } + } + set + { + string privilegeString = String.Join("\0", value ?? new List()) + "\0\0"; + + using (SafeMemoryBuffer buffer = new SafeMemoryBuffer(Marshal.StringToHGlobalUni(privilegeString))) + { + var info = new NativeHelpers.SERVICE_REQUIRED_PRIVILEGES_INFOW() + { + pmszRequiredPrivileges = buffer.DangerousGetHandle(), + }; + ChangeServiceConfig2(NativeHelpers.ConfigInfoLevel.SERVICE_CONFIG_REQUIRED_PRIVILEGES_INFO, info); + } + } + } + + public UInt32 PreShutdownTimeout + { + get + { + var value = QueryServiceConfig2( + NativeHelpers.ConfigInfoLevel.SERVICE_CONFIG_PRESHUTDOWN_INFO); + + return value.dwPreshutdownTimeout; + } + set + { + var info = new NativeHelpers.SERVICE_PRESHUTDOWN_INFO() + { + dwPreshutdownTimeout = value, + }; + ChangeServiceConfig2(NativeHelpers.ConfigInfoLevel.SERVICE_CONFIG_PRESHUTDOWN_INFO, info); + } + } + + public List Triggers + { + get + { + List triggers = new List(); + + using (SafeMemoryBuffer b = QueryServiceConfig2( + NativeHelpers.ConfigInfoLevel.SERVICE_CONFIG_TRIGGER_INFO)) + { + var value = (NativeHelpers.SERVICE_TRIGGER_INFO)Marshal.PtrToStructure( + b.DangerousGetHandle(), typeof(NativeHelpers.SERVICE_TRIGGER_INFO)); + + int triggerLength = Marshal.SizeOf(typeof(NativeHelpers.SERVICE_TRIGGER)); + for (int i = 0; i < value.cTriggers; i++) + { + IntPtr triggerPtr = IntPtr.Add(value.pTriggers, i * triggerLength); + var trigger = (NativeHelpers.SERVICE_TRIGGER)Marshal.PtrToStructure(triggerPtr, + typeof(NativeHelpers.SERVICE_TRIGGER)); + + triggers.Add(new Trigger(trigger)); + } + } + + return triggers; + } + set + { + var info = new NativeHelpers.SERVICE_TRIGGER_INFO() + { + cTriggers = value == null ? 0 : (UInt32)value.Count, + pTriggers = IntPtr.Zero, + pReserved = IntPtr.Zero, + }; + + if (info.cTriggers == 0) + { + try + { + ChangeServiceConfig2(NativeHelpers.ConfigInfoLevel.SERVICE_CONFIG_TRIGGER_INFO, info); + } + catch (ServiceManagerException e) + { + // Can fail with ERROR_INVALID_PARAMETER if no triggers were already set on the service, just + // continue as the service is what we want it to be. + if (e.NativeErrorCode != 87) + throw; + } + return; + } + + // Due to the dynamic nature of the trigger structure(s) we need to manually calculate the size of the + // data items on each trigger if present. This also serializes the raw data items to bytes here. + int structDataLength = 0; + int dataLength = 0; + Queue dataItems = new Queue(); + foreach (Trigger trigger in value) + { + if (trigger.DataItems == null || trigger.DataItems.Count == 0) + continue; + + foreach (TriggerItem dataItem in trigger.DataItems) + { + structDataLength += Marshal.SizeOf(typeof(NativeHelpers.SERVICE_TRIGGER_SPECIFIC_DATA_ITEM)); + + byte[] dataItemBytes; + Type dataItemType = dataItem.Data.GetType(); + if (dataItemType == typeof(byte)) + dataItemBytes = new byte[1] { (byte)dataItem.Data }; + else if (dataItemType == typeof(byte[])) + dataItemBytes = (byte[])dataItem.Data; + else if (dataItemType == typeof(UInt64)) + dataItemBytes = BitConverter.GetBytes((UInt64)dataItem.Data); + else if (dataItemType == typeof(string)) + dataItemBytes = Encoding.Unicode.GetBytes((string)dataItem.Data + "\0"); + else if (dataItemType == typeof(List)) + dataItemBytes = Encoding.Unicode.GetBytes( + String.Join("\0", (List)dataItem.Data) + "\0"); + else + throw new ArgumentException(String.Format("Trigger data type '{0}' not a value type", + dataItemType.Name)); + + dataLength += dataItemBytes.Length; + dataItems.Enqueue(dataItemBytes); + } + } + + using (SafeMemoryBuffer triggerBuffer = new SafeMemoryBuffer( + value.Count * Marshal.SizeOf(typeof(NativeHelpers.SERVICE_TRIGGER)))) + using (SafeMemoryBuffer triggerGuidBuffer = new SafeMemoryBuffer( + value.Count * Marshal.SizeOf(typeof(Guid)))) + using (SafeMemoryBuffer dataItemBuffer = new SafeMemoryBuffer(structDataLength)) + using (SafeMemoryBuffer dataBuffer = new SafeMemoryBuffer(dataLength)) + { + info.pTriggers = triggerBuffer.DangerousGetHandle(); + + IntPtr triggerPtr = triggerBuffer.DangerousGetHandle(); + IntPtr guidPtr = triggerGuidBuffer.DangerousGetHandle(); + IntPtr dataItemPtr = dataItemBuffer.DangerousGetHandle(); + IntPtr dataPtr = dataBuffer.DangerousGetHandle(); + + foreach (Trigger trigger in value) + { + int dataCount = trigger.DataItems == null ? 0 : trigger.DataItems.Count; + var rawTrigger = new NativeHelpers.SERVICE_TRIGGER() + { + dwTriggerType = trigger.Type, + dwAction = trigger.Action, + pTriggerSubtype = guidPtr, + cDataItems = (UInt32)dataCount, + pDataItems = dataCount == 0 ? IntPtr.Zero : dataItemPtr, + }; + guidPtr = StructureToPtr(trigger.SubType, guidPtr); + + for (int i = 0; i < rawTrigger.cDataItems; i++) + { + byte[] dataItemBytes = dataItems.Dequeue(); + var rawTriggerData = new NativeHelpers.SERVICE_TRIGGER_SPECIFIC_DATA_ITEM() + { + dwDataType = trigger.DataItems[i].Type, + cbData = (UInt32)dataItemBytes.Length, + pData = dataPtr, + }; + Marshal.Copy(dataItemBytes, 0, dataPtr, dataItemBytes.Length); + dataPtr = IntPtr.Add(dataPtr, dataItemBytes.Length); + + dataItemPtr = StructureToPtr(rawTriggerData, dataItemPtr); + } + + triggerPtr = StructureToPtr(rawTrigger, triggerPtr); + } + + ChangeServiceConfig2(NativeHelpers.ConfigInfoLevel.SERVICE_CONFIG_TRIGGER_INFO, info); + } + } + } + + public UInt16? PreferredNode + { + get + { + try + { + var value = QueryServiceConfig2( + NativeHelpers.ConfigInfoLevel.SERVICE_CONFIG_PREFERRED_NODE); + + return value.usPreferredNode; + } + catch (ServiceManagerException e) + { + // If host has no NUMA support this will fail with ERROR_INVALID_PARAMETER + if (e.NativeErrorCode == 0x00000057) // ERROR_INVALID_PARAMETER + return null; + + throw; + } + } + set + { + var info = new NativeHelpers.SERVICE_PREFERRED_NODE_INFO(); + if (value == null) + info.fDelete = true; + else + info.usPreferredNode = (UInt16)value; + ChangeServiceConfig2(NativeHelpers.ConfigInfoLevel.SERVICE_CONFIG_PREFERRED_NODE, info); + } + } + + public LaunchProtection LaunchProtection + { + get + { + var value = QueryServiceConfig2( + NativeHelpers.ConfigInfoLevel.SERVICE_CONFIG_LAUNCH_PROTECTED); + + return value.dwLaunchProtected; + } + set + { + var info = new NativeHelpers.SERVICE_LAUNCH_PROTECTED_INFO() + { + dwLaunchProtected = value, + }; + ChangeServiceConfig2(NativeHelpers.ConfigInfoLevel.SERVICE_CONFIG_LAUNCH_PROTECTED, info); + } + } + + // ServiceStatus + public ServiceStatus State { get { return _statusProcess.dwCurrentState; } } + + public ControlsAccepted ControlsAccepted { get { return _statusProcess.dwControlsAccepted; } } + + public UInt32 Win32ExitCode { get { return _statusProcess.dwWin32ExitCode; } } + + public UInt32 ServiceExitCode { get { return _statusProcess.dwServiceSpecificExitCode; } } + + public UInt32 Checkpoint { get { return _statusProcess.dwCheckPoint; } } + + public UInt32 WaitHint { get { return _statusProcess.dwWaitHint; } } + + public UInt32 ProcessId { get { return _statusProcess.dwProcessId; } } + + public ServiceFlags ServiceFlags { get { return _statusProcess.dwServiceFlags; } } + + public Service(string name) : this(name, ServiceRights.AllAccess) { } + + public Service(string name, ServiceRights access) : this(name, access, SCMRights.Connect) { } + + public Service(string name, ServiceRights access, SCMRights scmAccess) + { + ServiceName = name; + _scmHandle = OpenSCManager(scmAccess); + _serviceHandle = NativeMethods.OpenServiceW(_scmHandle, name, access); + if (_serviceHandle.IsInvalid) + throw new ServiceManagerException(String.Format("Failed to open service '{0}'", name)); + + Refresh(); + } + + private Service(SafeServiceHandle scmHandle, SafeServiceHandle serviceHandle, string name) + { + ServiceName = name; + _scmHandle = scmHandle; + _serviceHandle = serviceHandle; + + Refresh(); + } + + // EnumDependentServices + public List DependedBy + { + get + { + UInt32 bytesNeeded = 0; + UInt32 numServices = 0; + NativeMethods.EnumDependentServicesW(_serviceHandle, 3, new SafeMemoryBuffer(IntPtr.Zero), 0, + out bytesNeeded, out numServices); + + using (SafeMemoryBuffer buffer = new SafeMemoryBuffer((int)bytesNeeded)) + { + if (!NativeMethods.EnumDependentServicesW(_serviceHandle, 3, buffer, bytesNeeded, out bytesNeeded, + out numServices)) + { + throw new ServiceManagerException("Failed to enumerated dependent services"); + } + + List dependents = new List(); + Type enumType = typeof(NativeHelpers.ENUM_SERVICE_STATUSW); + for (int i = 0; i < numServices; i++) + { + var service = (NativeHelpers.ENUM_SERVICE_STATUSW)Marshal.PtrToStructure( + IntPtr.Add(buffer.DangerousGetHandle(), i * Marshal.SizeOf(enumType)), enumType); + + dependents.Add(service.lpServiceName); + } + + return dependents; + } + } + } + + public static Service Create(string name, string binaryPath, string displayName = null, + ServiceType serviceType = ServiceType.Win32OwnProcess, + ServiceStartType startType = ServiceStartType.DemandStart, ErrorControl errorControl = ErrorControl.Normal, + string loadOrderGroup = null, List dependencies = null, string startName = null, + string password = null) + { + SafeServiceHandle scmHandle = OpenSCManager(SCMRights.CreateService | SCMRights.Connect); + + if (displayName == null) + displayName = name; + + string depString = null; + if (dependencies != null && dependencies.Count > 0) + depString = String.Join("\0", dependencies) + "\0\0"; + + SafeServiceHandle serviceHandle = NativeMethods.CreateServiceW(scmHandle, name, displayName, + ServiceRights.AllAccess, serviceType, startType, errorControl, binaryPath, + loadOrderGroup, IntPtr.Zero, depString, startName, password); + + if (serviceHandle.IsInvalid) + throw new ServiceManagerException(String.Format("Failed to create new service '{0}'", name)); + + return new Service(scmHandle, serviceHandle, name); + } + + public void Delete() + { + if (!NativeMethods.DeleteService(_serviceHandle)) + throw new ServiceManagerException("Failed to delete service"); + Dispose(); + } + + public void Dispose() + { + if (_serviceHandle != null) + _serviceHandle.Dispose(); + + if (_scmHandle != null) + _scmHandle.Dispose(); + GC.SuppressFinalize(this); + } + + public void Refresh() + { + UInt32 bytesNeeded; + NativeMethods.QueryServiceConfigW(_serviceHandle, IntPtr.Zero, 0, out bytesNeeded); + + _rawServiceConfig = new SafeMemoryBuffer((int)bytesNeeded); + if (!NativeMethods.QueryServiceConfigW(_serviceHandle, _rawServiceConfig.DangerousGetHandle(), bytesNeeded, + out bytesNeeded)) + { + throw new ServiceManagerException("Failed to query service config"); + } + + NativeMethods.QueryServiceStatusEx(_serviceHandle, 0, IntPtr.Zero, 0, out bytesNeeded); + using (SafeMemoryBuffer buffer = new SafeMemoryBuffer((int)bytesNeeded)) + { + if (!NativeMethods.QueryServiceStatusEx(_serviceHandle, 0, buffer.DangerousGetHandle(), bytesNeeded, + out bytesNeeded)) + { + throw new ServiceManagerException("Failed to query service status"); + } + + _statusProcess = (NativeHelpers.SERVICE_STATUS_PROCESS)Marshal.PtrToStructure( + buffer.DangerousGetHandle(), typeof(NativeHelpers.SERVICE_STATUS_PROCESS)); + } + } + + private void ChangeServiceConfig(ServiceType serviceType = (ServiceType)SERVICE_NO_CHANGE, + ServiceStartType startType = (ServiceStartType)SERVICE_NO_CHANGE, + ErrorControl errorControl = (ErrorControl)SERVICE_NO_CHANGE, string binaryPath = null, + string loadOrderGroup = null, List dependencies = null, string startName = null, + string password = null, string displayName = null) + { + string depString = null; + if (dependencies != null && dependencies.Count > 0) + depString = String.Join("\0", dependencies) + "\0\0"; + + if (!NativeMethods.ChangeServiceConfigW(_serviceHandle, serviceType, startType, errorControl, binaryPath, + loadOrderGroup, IntPtr.Zero, depString, startName, password, displayName)) + { + throw new ServiceManagerException("Failed to change service config"); + } + + Refresh(); + } + + private void ChangeServiceConfig2(NativeHelpers.ConfigInfoLevel infoLevel, object info) + { + using (SafeMemoryBuffer buffer = new SafeMemoryBuffer(Marshal.SizeOf(info))) + { + Marshal.StructureToPtr(info, buffer.DangerousGetHandle(), false); + + if (!NativeMethods.ChangeServiceConfig2W(_serviceHandle, infoLevel, buffer.DangerousGetHandle())) + throw new ServiceManagerException("Failed to change service config"); + } + } + + private static SafeServiceHandle OpenSCManager(SCMRights desiredAccess) + { + SafeServiceHandle handle = NativeMethods.OpenSCManagerW(null, null, desiredAccess); + if (handle.IsInvalid) + throw new ServiceManagerException("Failed to open SCManager"); + + return handle; + } + + private T QueryServiceConfig2(NativeHelpers.ConfigInfoLevel infoLevel) + { + using (SafeMemoryBuffer buffer = QueryServiceConfig2(infoLevel)) + return (T)Marshal.PtrToStructure(buffer.DangerousGetHandle(), typeof(T)); + } + + private SafeMemoryBuffer QueryServiceConfig2(NativeHelpers.ConfigInfoLevel infoLevel) + { + UInt32 bytesNeeded = 0; + NativeMethods.QueryServiceConfig2W(_serviceHandle, infoLevel, IntPtr.Zero, 0, out bytesNeeded); + + SafeMemoryBuffer buffer = new SafeMemoryBuffer((int)bytesNeeded); + if (!NativeMethods.QueryServiceConfig2W(_serviceHandle, infoLevel, buffer.DangerousGetHandle(), bytesNeeded, + out bytesNeeded)) + { + throw new ServiceManagerException(String.Format("QueryServiceConfig2W({0}) failed", + infoLevel.ToString())); + } + + return buffer; + } + + private static IntPtr StructureToPtr(object structure, IntPtr ptr) + { + Marshal.StructureToPtr(structure, ptr, false); + return IntPtr.Add(ptr, Marshal.SizeOf(structure)); + } + + ~Service() { Dispose(); } + } +} diff --git a/test/support/windows-integration/plugins/modules/async_status.ps1 b/test/support/windows-integration/plugins/modules/async_status.ps1 new file mode 100644 index 00000000000..1ce3ff40f3a --- /dev/null +++ b/test/support/windows-integration/plugins/modules/async_status.ps1 @@ -0,0 +1,58 @@ +#!powershell + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +$results = @{changed=$false} + +$parsed_args = Parse-Args $args +$jid = Get-AnsibleParam $parsed_args "jid" -failifempty $true -resultobj $results +$mode = Get-AnsibleParam $parsed_args "mode" -Default "status" -ValidateSet "status","cleanup" + +# parsed in from the async_status action plugin +$async_dir = Get-AnsibleParam $parsed_args "_async_dir" -type "path" -failifempty $true + +$log_path = [System.IO.Path]::Combine($async_dir, $jid) + +If(-not $(Test-Path $log_path)) +{ + Fail-Json @{ansible_job_id=$jid; started=1; finished=1} "could not find job at '$async_dir'" +} + +If($mode -eq "cleanup") { + Remove-Item $log_path -Recurse + Exit-Json @{ansible_job_id=$jid; erased=$log_path} +} + +# NOT in cleanup mode, assume regular status mode +# no remote kill mode currently exists, but probably should +# consider log_path + ".pid" file and also unlink that above + +$data = $null +Try { + $data_raw = Get-Content $log_path + + # TODO: move this into module_utils/powershell.ps1? + $jss = New-Object System.Web.Script.Serialization.JavaScriptSerializer + $data = $jss.DeserializeObject($data_raw) +} +Catch { + If(-not $data_raw) { + # file not written yet? That means it is running + Exit-Json @{results_file=$log_path; ansible_job_id=$jid; started=1; finished=0} + } + Else { + Fail-Json @{ansible_job_id=$jid; results_file=$log_path; started=1; finished=1} "Could not parse job output: $data" + } +} + +If (-not $data.ContainsKey("started")) { + $data['finished'] = 1 + $data['ansible_job_id'] = $jid +} +ElseIf (-not $data.ContainsKey("finished")) { + $data['finished'] = 0 +} + +Exit-Json $data diff --git a/test/support/windows-integration/plugins/modules/setup.ps1 b/test/support/windows-integration/plugins/modules/setup.ps1 new file mode 100644 index 00000000000..50647239860 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/setup.ps1 @@ -0,0 +1,516 @@ +#!powershell + +# Copyright: (c) 2018, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +Function Get-CustomFacts { + [cmdletBinding()] + param ( + [Parameter(mandatory=$false)] + $factpath = $null + ) + + if (Test-Path -Path $factpath) { + $FactsFiles = Get-ChildItem -Path $factpath | Where-Object -FilterScript {($PSItem.PSIsContainer -eq $false) -and ($PSItem.Extension -eq '.ps1')} + + foreach ($FactsFile in $FactsFiles) { + $out = & $($FactsFile.FullName) + $result.ansible_facts.Add("ansible_$(($FactsFile.Name).Split('.')[0])", $out) + } + } + else + { + Add-Warning $result "Non existing path was set for local facts - $factpath" + } +} + +Function Get-MachineSid { + # The Machine SID is stored in HKLM:\SECURITY\SAM\Domains\Account and is + # only accessible by the Local System account. This method get's the local + # admin account (ends with -500) and lops it off to get the machine sid. + + $machine_sid = $null + + try { + $admins_sid = "S-1-5-32-544" + $admin_group = ([Security.Principal.SecurityIdentifier]$admins_sid).Translate([Security.Principal.NTAccount]).Value + + Add-Type -AssemblyName System.DirectoryServices.AccountManagement + $principal_context = New-Object -TypeName System.DirectoryServices.AccountManagement.PrincipalContext([System.DirectoryServices.AccountManagement.ContextType]::Machine) + $group_principal = New-Object -TypeName System.DirectoryServices.AccountManagement.GroupPrincipal($principal_context, $admin_group) + $searcher = New-Object -TypeName System.DirectoryServices.AccountManagement.PrincipalSearcher($group_principal) + $groups = $searcher.FindOne() + + foreach ($user in $groups.Members) { + $user_sid = $user.Sid + if ($user_sid.Value.EndsWith("-500")) { + $machine_sid = $user_sid.AccountDomainSid.Value + break + } + } + } catch { + #can fail for any number of reasons, if it does just return the original null + Add-Warning -obj $result -message "Error during machine sid retrieval: $($_.Exception.Message)" + } + + return $machine_sid +} + +$cim_instances = @{} + +Function Get-LazyCimInstance([string]$instance_name, [string]$namespace="Root\CIMV2") { + if(-not $cim_instances.ContainsKey($instance_name)) { + $cim_instances[$instance_name] = $(Get-CimInstance -Namespace $namespace -ClassName $instance_name) + } + + return $cim_instances[$instance_name] +} + +$result = @{ + ansible_facts = @{ } + changed = $false +} + +$grouped_subsets = @{ + min=[System.Collections.Generic.List[string]]@('date_time','distribution','dns','env','local','platform','powershell_version','user') + network=[System.Collections.Generic.List[string]]@('all_ipv4_addresses','all_ipv6_addresses','interfaces','windows_domain', 'winrm') + hardware=[System.Collections.Generic.List[string]]@('bios','memory','processor','uptime','virtual') + external=[System.Collections.Generic.List[string]]@('facter') +} + +# build "all" set from everything mentioned in the group- this means every value must be in at least one subset to be considered legal +$all_set = [System.Collections.Generic.HashSet[string]]@() + +foreach($kv in $grouped_subsets.GetEnumerator()) { + [void] $all_set.UnionWith($kv.Value) +} + +# dynamically create an "all" subset now that we know what should be in it +$grouped_subsets['all'] = [System.Collections.Generic.List[string]]$all_set + +# start with all, build up gather and exclude subsets +$gather_subset = [System.Collections.Generic.HashSet[string]]$grouped_subsets.all +$explicit_subset = [System.Collections.Generic.HashSet[string]]@() +$exclude_subset = [System.Collections.Generic.HashSet[string]]@() + +$params = Parse-Args $args -supports_check_mode $true +$factpath = Get-AnsibleParam -obj $params -name "fact_path" -type "path" +$gather_subset_source = Get-AnsibleParam -obj $params -name "gather_subset" -type "list" -default "all" + +foreach($item in $gather_subset_source) { + if(([string]$item).StartsWith("!")) { + $item = ([string]$item).Substring(1) + if($item -eq "all") { + $all_minus_min = [System.Collections.Generic.HashSet[string]]@($all_set) + [void] $all_minus_min.ExceptWith($grouped_subsets.min) + [void] $exclude_subset.UnionWith($all_minus_min) + } + elseif($grouped_subsets.ContainsKey($item)) { + [void] $exclude_subset.UnionWith($grouped_subsets[$item]) + } + elseif($all_set.Contains($item)) { + [void] $exclude_subset.Add($item) + } + # NB: invalid exclude values are ignored, since that's what posix setup does + } + else { + if($grouped_subsets.ContainsKey($item)) { + [void] $explicit_subset.UnionWith($grouped_subsets[$item]) + } + elseif($all_set.Contains($item)) { + [void] $explicit_subset.Add($item) + } + else { + # NB: POSIX setup fails on invalid value; we warn, because we don't implement the same set as POSIX + # and we don't have platform-specific config for this... + Add-Warning $result "invalid value $item specified in gather_subset" + } + } +} + +[void] $gather_subset.ExceptWith($exclude_subset) +[void] $gather_subset.UnionWith($explicit_subset) + +$ansible_facts = @{ + gather_subset=@($gather_subset_source) + module_setup=$true +} + +$osversion = [Environment]::OSVersion + +if ($osversion.Version -lt [version]"6.2") { + # Server 2008, 2008 R2, and Windows 7 are not tested in CI and we want to let customers know about it before + # removing support altogether. + $version_string = "{0}.{1}" -f ($osversion.Version.Major, $osversion.Version.Minor) + $msg = "Windows version '$version_string' will no longer be supported or tested in the next Ansible release" + Add-DeprecationWarning -obj $result -message $msg -version "2.11" +} + +if($gather_subset.Contains('all_ipv4_addresses') -or $gather_subset.Contains('all_ipv6_addresses')) { + $netcfg = Get-LazyCimInstance Win32_NetworkAdapterConfiguration + + # TODO: split v4/v6 properly, return in separate keys + $ips = @() + Foreach ($ip in $netcfg.IPAddress) { + If ($ip) { + $ips += $ip + } + } + + $ansible_facts += @{ + ansible_ip_addresses = $ips + } +} + +if($gather_subset.Contains('bios')) { + $win32_bios = Get-LazyCimInstance Win32_Bios + $win32_cs = Get-LazyCimInstance Win32_ComputerSystem + $ansible_facts += @{ + ansible_bios_date = $win32_bios.ReleaseDate.ToString("MM/dd/yyyy") + ansible_bios_version = $win32_bios.SMBIOSBIOSVersion + ansible_product_name = $win32_cs.Model.Trim() + ansible_product_serial = $win32_bios.SerialNumber + # ansible_product_version = ([string] $win32_cs.SystemFamily) + } +} + +if($gather_subset.Contains('date_time')) { + $datetime = (Get-Date) + $datetime_utc = $datetime.ToUniversalTime() + $date = @{ + date = $datetime.ToString("yyyy-MM-dd") + day = $datetime.ToString("dd") + epoch = (Get-Date -UFormat "%s") + hour = $datetime.ToString("HH") + iso8601 = $datetime_utc.ToString("yyyy-MM-ddTHH:mm:ssZ") + iso8601_basic = $datetime.ToString("yyyyMMddTHHmmssffffff") + iso8601_basic_short = $datetime.ToString("yyyyMMddTHHmmss") + iso8601_micro = $datetime_utc.ToString("yyyy-MM-ddTHH:mm:ss.ffffffZ") + minute = $datetime.ToString("mm") + month = $datetime.ToString("MM") + second = $datetime.ToString("ss") + time = $datetime.ToString("HH:mm:ss") + tz = ([System.TimeZoneInfo]::Local.Id) + tz_offset = $datetime.ToString("zzzz") + # Ensure that the weekday is in English + weekday = $datetime.ToString("dddd", [System.Globalization.CultureInfo]::InvariantCulture) + weekday_number = (Get-Date -UFormat "%w") + weeknumber = (Get-Date -UFormat "%W") + year = $datetime.ToString("yyyy") + } + + $ansible_facts += @{ + ansible_date_time = $date + } +} + +if($gather_subset.Contains('distribution')) { + $win32_os = Get-LazyCimInstance Win32_OperatingSystem + $product_type = switch($win32_os.ProductType) { + 1 { "workstation" } + 2 { "domain_controller" } + 3 { "server" } + default { "unknown" } + } + + $installation_type = $null + $current_version_path = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion" + if (Test-Path -LiteralPath $current_version_path) { + $install_type_prop = Get-ItemProperty -LiteralPath $current_version_path -ErrorAction SilentlyContinue + $installation_type = [String]$install_type_prop.InstallationType + } + + $ansible_facts += @{ + ansible_distribution = $win32_os.Caption + ansible_distribution_version = $osversion.Version.ToString() + ansible_distribution_major_version = $osversion.Version.Major.ToString() + ansible_os_family = "Windows" + ansible_os_name = ($win32_os.Name.Split('|')[0]).Trim() + ansible_os_product_type = $product_type + ansible_os_installation_type = $installation_type + } +} + +if($gather_subset.Contains('env')) { + $env_vars = @{ } + foreach ($item in Get-ChildItem Env:) { + $name = $item | Select-Object -ExpandProperty Name + # Powershell ConvertTo-Json fails if string ends with \ + $value = ($item | Select-Object -ExpandProperty Value).TrimEnd("\") + $env_vars.Add($name, $value) + } + + $ansible_facts += @{ + ansible_env = $env_vars + } +} + +if($gather_subset.Contains('facter')) { + # See if Facter is on the System Path + Try { + Get-Command facter -ErrorAction Stop > $null + $facter_installed = $true + } Catch { + $facter_installed = $false + } + + # Get JSON from Facter, and parse it out. + if ($facter_installed) { + &facter -j | Tee-Object -Variable facter_output > $null + $facts = "$facter_output" | ConvertFrom-Json + ForEach($fact in $facts.PSObject.Properties) { + $fact_name = $fact.Name + $ansible_facts.Add("facter_$fact_name", $fact.Value) + } + } +} + +if($gather_subset.Contains('interfaces')) { + $netcfg = Get-LazyCimInstance Win32_NetworkAdapterConfiguration + $ActiveNetcfg = @() + $ActiveNetcfg += $netcfg | Where-Object {$_.ipaddress -ne $null} + + $namespaces = Get-LazyCimInstance __Namespace -namespace root + if ($namespaces | Where-Object { $_.Name -eq "StandardCimv" }) { + $net_adapters = Get-LazyCimInstance MSFT_NetAdapter -namespace Root\StandardCimv2 + $guid_key = "InterfaceGUID" + $name_key = "Name" + } else { + $net_adapters = Get-LazyCimInstance Win32_NetworkAdapter + $guid_key = "GUID" + $name_key = "NetConnectionID" + } + + $formattednetcfg = @() + foreach ($adapter in $ActiveNetcfg) + { + $thisadapter = @{ + default_gateway = $null + connection_name = $null + dns_domain = $adapter.dnsdomain + interface_index = $adapter.InterfaceIndex + interface_name = $adapter.description + macaddress = $adapter.macaddress + } + + if ($adapter.defaultIPGateway) + { + $thisadapter.default_gateway = $adapter.DefaultIPGateway[0].ToString() + } + $net_adapter = $net_adapters | Where-Object { $_.$guid_key -eq $adapter.SettingID } + if ($net_adapter) { + $thisadapter.connection_name = $net_adapter.$name_key + } + + $formattednetcfg += $thisadapter + } + + $ansible_facts += @{ + ansible_interfaces = $formattednetcfg + } +} + +if ($gather_subset.Contains("local") -and $null -ne $factpath) { + # Get any custom facts; results are updated in the + Get-CustomFacts -factpath $factpath +} + +if($gather_subset.Contains('memory')) { + $win32_cs = Get-LazyCimInstance Win32_ComputerSystem + $win32_os = Get-LazyCimInstance Win32_OperatingSystem + $ansible_facts += @{ + # Win32_PhysicalMemory is empty on some virtual platforms + ansible_memtotal_mb = ([math]::ceiling($win32_cs.TotalPhysicalMemory / 1024 / 1024)) + ansible_memfree_mb = ([math]::ceiling($win32_os.FreePhysicalMemory / 1024)) + ansible_swaptotal_mb = ([math]::round($win32_os.TotalSwapSpaceSize / 1024)) + ansible_pagefiletotal_mb = ([math]::round($win32_os.SizeStoredInPagingFiles / 1024)) + ansible_pagefilefree_mb = ([math]::round($win32_os.FreeSpaceInPagingFiles / 1024)) + } +} + + +if($gather_subset.Contains('platform')) { + $win32_cs = Get-LazyCimInstance Win32_ComputerSystem + $win32_os = Get-LazyCimInstance Win32_OperatingSystem + $domain_suffix = $win32_cs.Domain.Substring($win32_cs.Workgroup.length) + $fqdn = $win32_cs.DNSHostname + + if( $domain_suffix -ne "") + { + $fqdn = $win32_cs.DNSHostname + "." + $domain_suffix + } + + try { + $ansible_reboot_pending = Get-PendingRebootStatus + } catch { + # fails for non-admin users, set to null in this case + $ansible_reboot_pending = $null + } + + $ansible_facts += @{ + ansible_architecture = $win32_os.OSArchitecture + ansible_domain = $domain_suffix + ansible_fqdn = $fqdn + ansible_hostname = $win32_cs.DNSHostname + ansible_netbios_name = $win32_cs.Name + ansible_kernel = $osversion.Version.ToString() + ansible_nodename = $fqdn + ansible_machine_id = Get-MachineSid + ansible_owner_contact = ([string] $win32_cs.PrimaryOwnerContact) + ansible_owner_name = ([string] $win32_cs.PrimaryOwnerName) + # FUTURE: should this live in its own subset? + ansible_reboot_pending = $ansible_reboot_pending + ansible_system = $osversion.Platform.ToString() + ansible_system_description = ([string] $win32_os.Description) + ansible_system_vendor = $win32_cs.Manufacturer + } +} + +if($gather_subset.Contains('powershell_version')) { + $ansible_facts += @{ + ansible_powershell_version = ($PSVersionTable.PSVersion.Major) + } +} + +if($gather_subset.Contains('processor')) { + $win32_cs = Get-LazyCimInstance Win32_ComputerSystem + $win32_cpu = Get-LazyCimInstance Win32_Processor + if ($win32_cpu -is [array]) { + # multi-socket, pick first + $win32_cpu = $win32_cpu[0] + } + + $cpu_list = @( ) + for ($i=1; $i -le $win32_cs.NumberOfLogicalProcessors; $i++) { + $cpu_list += $win32_cpu.Manufacturer + $cpu_list += $win32_cpu.Name + } + + $ansible_facts += @{ + ansible_processor = $cpu_list + ansible_processor_cores = $win32_cpu.NumberOfCores + ansible_processor_count = $win32_cs.NumberOfProcessors + ansible_processor_threads_per_core = ($win32_cpu.NumberOfLogicalProcessors / $win32_cpu.NumberofCores) + ansible_processor_vcpus = $win32_cs.NumberOfLogicalProcessors + } +} + +if($gather_subset.Contains('uptime')) { + $win32_os = Get-LazyCimInstance Win32_OperatingSystem + $ansible_facts += @{ + ansible_lastboot = $win32_os.lastbootuptime.ToString("u") + ansible_uptime_seconds = $([System.Convert]::ToInt64($(Get-Date).Subtract($win32_os.lastbootuptime).TotalSeconds)) + } +} + +if($gather_subset.Contains('user')) { + $user = [Security.Principal.WindowsIdentity]::GetCurrent() + $ansible_facts += @{ + ansible_user_dir = $env:userprofile + # Win32_UserAccount.FullName is probably the right thing here, but it can be expensive to get on large domains + ansible_user_gecos = "" + ansible_user_id = $env:username + ansible_user_sid = $user.User.Value + } +} + +if($gather_subset.Contains('windows_domain')) { + $win32_cs = Get-LazyCimInstance Win32_ComputerSystem + $domain_roles = @{ + 0 = "Stand-alone workstation" + 1 = "Member workstation" + 2 = "Stand-alone server" + 3 = "Member server" + 4 = "Backup domain controller" + 5 = "Primary domain controller" + } + + $domain_role = $domain_roles.Get_Item([Int32]$win32_cs.DomainRole) + + $ansible_facts += @{ + ansible_windows_domain = $win32_cs.Domain + ansible_windows_domain_member = $win32_cs.PartOfDomain + ansible_windows_domain_role = $domain_role + } +} + +if($gather_subset.Contains('winrm')) { + + $winrm_https_listener_parent_paths = Get-ChildItem -Path WSMan:\localhost\Listener -Recurse -ErrorAction SilentlyContinue | ` + Where-Object {$_.PSChildName -eq "Transport" -and $_.Value -eq "HTTPS"} | Select-Object PSParentPath + if ($winrm_https_listener_parent_paths -isnot [array]) { + $winrm_https_listener_parent_paths = @($winrm_https_listener_parent_paths) + } + + $winrm_https_listener_paths = @() + foreach ($winrm_https_listener_parent_path in $winrm_https_listener_parent_paths) { + $winrm_https_listener_paths += $winrm_https_listener_parent_path.PSParentPath.Substring($winrm_https_listener_parent_path.PSParentPath.LastIndexOf("\")) + } + + $https_listeners = @() + foreach ($winrm_https_listener_path in $winrm_https_listener_paths) { + $https_listeners += Get-ChildItem -Path "WSMan:\localhost\Listener$winrm_https_listener_path" + } + + $winrm_cert_thumbprints = @() + foreach ($https_listener in $https_listeners) { + $winrm_cert_thumbprints += $https_listener | Where-Object {$_.Name -EQ "CertificateThumbprint" } | Select-Object Value + } + + $winrm_cert_expiry = @() + foreach ($winrm_cert_thumbprint in $winrm_cert_thumbprints) { + Try { + $winrm_cert_expiry += Get-ChildItem -Path Cert:\LocalMachine\My | Where-Object Thumbprint -EQ $winrm_cert_thumbprint.Value.ToString().ToUpper() | Select-Object NotAfter + } Catch { + Add-Warning -obj $result -message "Error during certificate expiration retrieval: $($_.Exception.Message)" + } + } + + $winrm_cert_expirations = $winrm_cert_expiry | Sort-Object NotAfter + if ($winrm_cert_expirations) { + # this fact was renamed from ansible_winrm_certificate_expires due to collision with ansible_winrm_X connection var pattern + $ansible_facts.Add("ansible_win_rm_certificate_expires", $winrm_cert_expirations[0].NotAfter.ToString("yyyy-MM-dd HH:mm:ss")) + } +} + +if($gather_subset.Contains('virtual')) { + $machine_info = Get-LazyCimInstance Win32_ComputerSystem + + switch ($machine_info.model) { + "Virtual Machine" { + $machine_type="Hyper-V" + $machine_role="guest" + } + + "VMware Virtual Platform" { + $machine_type="VMware" + $machine_role="guest" + } + + "VirtualBox" { + $machine_type="VirtualBox" + $machine_role="guest" + } + + "HVM domU" { + $machine_type="Xen" + $machine_role="guest" + } + + default { + $machine_type="NA" + $machine_role="NA" + } + } + + $ansible_facts += @{ + ansible_virtualization_role = $machine_role + ansible_virtualization_type = $machine_type + } +} + +$result.ansible_facts += $ansible_facts + +Exit-Json $result diff --git a/test/support/windows-integration/plugins/modules/slurp.ps1 b/test/support/windows-integration/plugins/modules/slurp.ps1 new file mode 100644 index 00000000000..eb506c7c7f8 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/slurp.ps1 @@ -0,0 +1,28 @@ +#!powershell + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +$params = Parse-Args $args -supports_check_mode $true; +$src = Get-AnsibleParam -obj $params -name "src" -type "path" -aliases "path" -failifempty $true; + +$result = @{ + changed = $false; +} + +If (Test-Path -LiteralPath $src -PathType Leaf) +{ + $bytes = [System.IO.File]::ReadAllBytes($src); + $result.content = [System.Convert]::ToBase64String($bytes); + $result.encoding = "base64"; + Exit-Json $result; +} +ElseIf (Test-Path -LiteralPath $src -PathType Container) +{ + Fail-Json $result "Path $src is a directory"; +} +Else +{ + Fail-Json $result "Path $src is not found"; +} diff --git a/test/support/windows-integration/plugins/modules/win_acl.ps1 b/test/support/windows-integration/plugins/modules/win_acl.ps1 new file mode 100644 index 00000000000..e3c3813038d --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_acl.ps1 @@ -0,0 +1,225 @@ +#!powershell + +# Copyright: (c) 2015, Phil Schwartz +# Copyright: (c) 2015, Trond Hindenes +# Copyright: (c) 2015, Hans-Joachim Kliemeck +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy +#Requires -Module Ansible.ModuleUtils.PrivilegeUtil +#Requires -Module Ansible.ModuleUtils.SID + +$ErrorActionPreference = "Stop" + +# win_acl module (File/Resources Permission Additions/Removal) + +#Functions +function Get-UserSID { + param( + [String]$AccountName + ) + + $userSID = $null + $searchAppPools = $false + + if ($AccountName.Split("\").Count -gt 1) { + if ($AccountName.Split("\")[0] -eq "IIS APPPOOL") { + $searchAppPools = $true + $AccountName = $AccountName.Split("\")[1] + } + } + + if ($searchAppPools) { + Import-Module -Name WebAdministration + $testIISPath = Test-Path -LiteralPath "IIS:" + if ($testIISPath) { + $appPoolObj = Get-ItemProperty -LiteralPath "IIS:\AppPools\$AccountName" + $userSID = $appPoolObj.applicationPoolSid + } + } + else { + $userSID = Convert-ToSID -account_name $AccountName + } + + return $userSID +} + +$params = Parse-Args $args + +Function SetPrivilegeTokens() { + # Set privilege tokens only if admin. + # Admins would have these privs or be able to set these privs in the UI Anyway + + $adminRole=[System.Security.Principal.WindowsBuiltInRole]::Administrator + $myWindowsID=[System.Security.Principal.WindowsIdentity]::GetCurrent() + $myWindowsPrincipal=new-object System.Security.Principal.WindowsPrincipal($myWindowsID) + + + if ($myWindowsPrincipal.IsInRole($adminRole)) { + # Need to adjust token privs when executing Set-ACL in certain cases. + # e.g. d:\testdir is owned by group in which current user is not a member and no perms are inherited from d:\ + # This also sets us up for setting the owner as a feature. + # See the following for details of each privilege + # https://msdn.microsoft.com/en-us/library/windows/desktop/bb530716(v=vs.85).aspx + $privileges = @( + "SeRestorePrivilege", # Grants all write access control to any file, regardless of ACL. + "SeBackupPrivilege", # Grants all read access control to any file, regardless of ACL. + "SeTakeOwnershipPrivilege" # Grants ability to take owernship of an object w/out being granted discretionary access + ) + foreach ($privilege in $privileges) { + $state = Get-AnsiblePrivilege -Name $privilege + if ($state -eq $false) { + Set-AnsiblePrivilege -Name $privilege -Value $true + } + } + } +} + + +$result = @{ + changed = $false +} + +$path = Get-AnsibleParam -obj $params -name "path" -type "str" -failifempty $true +$user = Get-AnsibleParam -obj $params -name "user" -type "str" -failifempty $true +$rights = Get-AnsibleParam -obj $params -name "rights" -type "str" -failifempty $true + +$type = Get-AnsibleParam -obj $params -name "type" -type "str" -failifempty $true -validateset "allow","deny" +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "present" -validateset "absent","present" + +$inherit = Get-AnsibleParam -obj $params -name "inherit" -type "str" +$propagation = Get-AnsibleParam -obj $params -name "propagation" -type "str" -default "None" -validateset "InheritOnly","None","NoPropagateInherit" + +# We mount the HKCR, HKU, and HKCC registry hives so PS can access them. +# Network paths have no qualifiers so we use -EA SilentlyContinue to ignore that +$path_qualifier = Split-Path -Path $path -Qualifier -ErrorAction SilentlyContinue +if ($path_qualifier -eq "HKCR:" -and (-not (Test-Path -LiteralPath HKCR:\))) { + New-PSDrive -Name HKCR -PSProvider Registry -Root HKEY_CLASSES_ROOT > $null +} +if ($path_qualifier -eq "HKU:" -and (-not (Test-Path -LiteralPath HKU:\))) { + New-PSDrive -Name HKU -PSProvider Registry -Root HKEY_USERS > $null +} +if ($path_qualifier -eq "HKCC:" -and (-not (Test-Path -LiteralPath HKCC:\))) { + New-PSDrive -Name HKCC -PSProvider Registry -Root HKEY_CURRENT_CONFIG > $null +} + +If (-Not (Test-Path -LiteralPath $path)) { + Fail-Json -obj $result -message "$path file or directory does not exist on the host" +} + +# Test that the user/group is resolvable on the local machine +$sid = Get-UserSID -AccountName $user +if (!$sid) { + Fail-Json -obj $result -message "$user is not a valid user or group on the host machine or domain" +} + +If (Test-Path -LiteralPath $path -PathType Leaf) { + $inherit = "None" +} +ElseIf ($null -eq $inherit) { + $inherit = "ContainerInherit, ObjectInherit" +} + +# Bug in Set-Acl, Get-Acl where -LiteralPath only works for the Registry provider if the location is in that root +# qualifier. We also don't have a qualifier for a network path so only change if not null +if ($null -ne $path_qualifier) { + Push-Location -LiteralPath $path_qualifier +} + +Try { + SetPrivilegeTokens + $path_item = Get-Item -LiteralPath $path -Force + If ($path_item.PSProvider.Name -eq "Registry") { + $colRights = [System.Security.AccessControl.RegistryRights]$rights + } + Else { + $colRights = [System.Security.AccessControl.FileSystemRights]$rights + } + + $InheritanceFlag = [System.Security.AccessControl.InheritanceFlags]$inherit + $PropagationFlag = [System.Security.AccessControl.PropagationFlags]$propagation + + If ($type -eq "allow") { + $objType =[System.Security.AccessControl.AccessControlType]::Allow + } + Else { + $objType =[System.Security.AccessControl.AccessControlType]::Deny + } + + $objUser = New-Object System.Security.Principal.SecurityIdentifier($sid) + If ($path_item.PSProvider.Name -eq "Registry") { + $objACE = New-Object System.Security.AccessControl.RegistryAccessRule ($objUser, $colRights, $InheritanceFlag, $PropagationFlag, $objType) + } + Else { + $objACE = New-Object System.Security.AccessControl.FileSystemAccessRule ($objUser, $colRights, $InheritanceFlag, $PropagationFlag, $objType) + } + $objACL = Get-ACL -LiteralPath $path + + # Check if the ACE exists already in the objects ACL list + $match = $false + + ForEach($rule in $objACL.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])){ + + If ($path_item.PSProvider.Name -eq "Registry") { + If (($rule.RegistryRights -eq $objACE.RegistryRights) -And ($rule.AccessControlType -eq $objACE.AccessControlType) -And ($rule.IdentityReference -eq $objACE.IdentityReference) -And ($rule.IsInherited -eq $objACE.IsInherited) -And ($rule.InheritanceFlags -eq $objACE.InheritanceFlags) -And ($rule.PropagationFlags -eq $objACE.PropagationFlags)) { + $match = $true + Break + } + } else { + If (($rule.FileSystemRights -eq $objACE.FileSystemRights) -And ($rule.AccessControlType -eq $objACE.AccessControlType) -And ($rule.IdentityReference -eq $objACE.IdentityReference) -And ($rule.IsInherited -eq $objACE.IsInherited) -And ($rule.InheritanceFlags -eq $objACE.InheritanceFlags) -And ($rule.PropagationFlags -eq $objACE.PropagationFlags)) { + $match = $true + Break + } + } + } + + If ($state -eq "present" -And $match -eq $false) { + Try { + $objACL.AddAccessRule($objACE) + If ($path_item.PSProvider.Name -eq "Registry") { + Set-ACL -LiteralPath $path -AclObject $objACL + } else { + (Get-Item -LiteralPath $path).SetAccessControl($objACL) + } + $result.changed = $true + } + Catch { + Fail-Json -obj $result -message "an exception occurred when adding the specified rule - $($_.Exception.Message)" + } + } + ElseIf ($state -eq "absent" -And $match -eq $true) { + Try { + $objACL.RemoveAccessRule($objACE) + If ($path_item.PSProvider.Name -eq "Registry") { + Set-ACL -LiteralPath $path -AclObject $objACL + } else { + (Get-Item -LiteralPath $path).SetAccessControl($objACL) + } + $result.changed = $true + } + Catch { + Fail-Json -obj $result -message "an exception occurred when removing the specified rule - $($_.Exception.Message)" + } + } + Else { + # A rule was attempting to be added but already exists + If ($match -eq $true) { + Exit-Json -obj $result -message "the specified rule already exists" + } + # A rule didn't exist that was trying to be removed + Else { + Exit-Json -obj $result -message "the specified rule does not exist" + } + } +} +Catch { + Fail-Json -obj $result -message "an error occurred when attempting to $state $rights permission(s) on $path for $user - $($_.Exception.Message)" +} +Finally { + # Make sure we revert the location stack to the original path just for cleanups sake + if ($null -ne $path_qualifier) { + Pop-Location + } +} + +Exit-Json -obj $result diff --git a/test/support/windows-integration/plugins/modules/win_acl.py b/test/support/windows-integration/plugins/modules/win_acl.py new file mode 100644 index 00000000000..14fbd82f3ac --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_acl.py @@ -0,0 +1,132 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2015, Phil Schwartz +# Copyright: (c) 2015, Trond Hindenes +# Copyright: (c) 2015, Hans-Joachim Kliemeck +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'core'} + +DOCUMENTATION = r''' +--- +module: win_acl +version_added: "2.0" +short_description: Set file/directory/registry permissions for a system user or group +description: +- Add or remove rights/permissions for a given user or group for the specified + file, folder, registry key or AppPool identifies. +options: + path: + description: + - The path to the file or directory. + type: str + required: yes + user: + description: + - User or Group to add specified rights to act on src file/folder or + registry key. + type: str + required: yes + state: + description: + - Specify whether to add C(present) or remove C(absent) the specified access rule. + type: str + choices: [ absent, present ] + default: present + type: + description: + - Specify whether to allow or deny the rights specified. + type: str + required: yes + choices: [ allow, deny ] + rights: + description: + - The rights/permissions that are to be allowed/denied for the specified + user or group for the item at C(path). + - If C(path) is a file or directory, rights can be any right under MSDN + FileSystemRights U(https://msdn.microsoft.com/en-us/library/system.security.accesscontrol.filesystemrights.aspx). + - If C(path) is a registry key, rights can be any right under MSDN + RegistryRights U(https://msdn.microsoft.com/en-us/library/system.security.accesscontrol.registryrights.aspx). + type: str + required: yes + inherit: + description: + - Inherit flags on the ACL rules. + - Can be specified as a comma separated list, e.g. C(ContainerInherit), + C(ObjectInherit). + - For more information on the choices see MSDN InheritanceFlags enumeration + at U(https://msdn.microsoft.com/en-us/library/system.security.accesscontrol.inheritanceflags.aspx). + - Defaults to C(ContainerInherit, ObjectInherit) for Directories. + type: str + choices: [ ContainerInherit, ObjectInherit ] + propagation: + description: + - Propagation flag on the ACL rules. + - For more information on the choices see MSDN PropagationFlags enumeration + at U(https://msdn.microsoft.com/en-us/library/system.security.accesscontrol.propagationflags.aspx). + type: str + choices: [ InheritOnly, None, NoPropagateInherit ] + default: "None" +notes: +- If adding ACL's for AppPool identities (available since 2.3), the Windows + Feature "Web-Scripting-Tools" must be enabled. +seealso: +- module: win_acl_inheritance +- module: win_file +- module: win_owner +- module: win_stat +author: +- Phil Schwartz (@schwartzmx) +- Trond Hindenes (@trondhindenes) +- Hans-Joachim Kliemeck (@h0nIg) +''' + +EXAMPLES = r''' +- name: Restrict write and execute access to User Fed-Phil + win_acl: + user: Fed-Phil + path: C:\Important\Executable.exe + type: deny + rights: ExecuteFile,Write + +- name: Add IIS_IUSRS allow rights + win_acl: + path: C:\inetpub\wwwroot\MySite + user: IIS_IUSRS + rights: FullControl + type: allow + state: present + inherit: ContainerInherit, ObjectInherit + propagation: 'None' + +- name: Set registry key right + win_acl: + path: HKCU:\Bovine\Key + user: BUILTIN\Users + rights: EnumerateSubKeys + type: allow + state: present + inherit: ContainerInherit, ObjectInherit + propagation: 'None' + +- name: Remove FullControl AccessRule for IIS_IUSRS + win_acl: + path: C:\inetpub\wwwroot\MySite + user: IIS_IUSRS + rights: FullControl + type: allow + state: absent + inherit: ContainerInherit, ObjectInherit + propagation: 'None' + +- name: Deny Intern + win_acl: + path: C:\Administrator\Documents + user: Intern + rights: Read,Write,Modify,FullControl,Delete + type: deny + state: present +''' diff --git a/test/support/windows-integration/plugins/modules/win_command.ps1 b/test/support/windows-integration/plugins/modules/win_command.ps1 new file mode 100644 index 00000000000..e2a30650d29 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_command.ps1 @@ -0,0 +1,78 @@ +#!powershell + +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy +#Requires -Module Ansible.ModuleUtils.CommandUtil +#Requires -Module Ansible.ModuleUtils.FileUtil + +# TODO: add check mode support + +Set-StrictMode -Version 2 +$ErrorActionPreference = 'Stop' + +$params = Parse-Args $args -supports_check_mode $false + +$raw_command_line = Get-AnsibleParam -obj $params -name "_raw_params" -type "str" -failifempty $true +$chdir = Get-AnsibleParam -obj $params -name "chdir" -type "path" +$creates = Get-AnsibleParam -obj $params -name "creates" -type "path" +$removes = Get-AnsibleParam -obj $params -name "removes" -type "path" +$stdin = Get-AnsibleParam -obj $params -name "stdin" -type "str" +$output_encoding_override = Get-AnsibleParam -obj $params -name "output_encoding_override" -type "str" + +$raw_command_line = $raw_command_line.Trim() + +$result = @{ + changed = $true + cmd = $raw_command_line +} + +if ($creates -and $(Test-AnsiblePath -Path $creates)) { + Exit-Json @{msg="skipped, since $creates exists";cmd=$raw_command_line;changed=$false;skipped=$true;rc=0} +} + +if ($removes -and -not $(Test-AnsiblePath -Path $removes)) { + Exit-Json @{msg="skipped, since $removes does not exist";cmd=$raw_command_line;changed=$false;skipped=$true;rc=0} +} + +$command_args = @{ + command = $raw_command_line +} +if ($chdir) { + $command_args['working_directory'] = $chdir +} +if ($stdin) { + $command_args['stdin'] = $stdin +} +if ($output_encoding_override) { + $command_args['output_encoding_override'] = $output_encoding_override +} + +$start_datetime = [DateTime]::UtcNow +try { + $command_result = Run-Command @command_args +} catch { + $result.changed = $false + try { + $result.rc = $_.Exception.NativeErrorCode + } catch { + $result.rc = 2 + } + Fail-Json -obj $result -message $_.Exception.Message +} + +$result.stdout = $command_result.stdout +$result.stderr = $command_result.stderr +$result.rc = $command_result.rc + +$end_datetime = [DateTime]::UtcNow +$result.start = $start_datetime.ToString("yyyy-MM-dd hh:mm:ss.ffffff") +$result.end = $end_datetime.ToString("yyyy-MM-dd hh:mm:ss.ffffff") +$result.delta = $($end_datetime - $start_datetime).ToString("h\:mm\:ss\.ffffff") + +If ($result.rc -ne 0) { + Fail-Json -obj $result -message "non-zero return code" +} + +Exit-Json $result diff --git a/test/support/windows-integration/plugins/modules/win_command.py b/test/support/windows-integration/plugins/modules/win_command.py new file mode 100644 index 00000000000..508419b28b1 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_command.py @@ -0,0 +1,136 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2016, Ansible, inc +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'core'} + +DOCUMENTATION = r''' +--- +module: win_command +short_description: Executes a command on a remote Windows node +version_added: 2.2 +description: + - The C(win_command) module takes the command name followed by a list of space-delimited arguments. + - The given command will be executed on all selected nodes. It will not be + processed through the shell, so variables like C($env:HOME) and operations + like C("<"), C(">"), C("|"), and C(";") will not work (use the M(win_shell) + module if you need these features). + - For non-Windows targets, use the M(command) module instead. +options: + free_form: + description: + - The C(win_command) module takes a free form command to run. + - There is no parameter actually named 'free form'. See the examples! + type: str + required: yes + creates: + description: + - A path or path filter pattern; when the referenced path exists on the target host, the task will be skipped. + type: path + removes: + description: + - A path or path filter pattern; when the referenced path B(does not) exist on the target host, the task will be skipped. + type: path + chdir: + description: + - Set the specified path as the current working directory before executing a command. + type: path + stdin: + description: + - Set the stdin of the command directly to the specified value. + type: str + version_added: '2.5' + output_encoding_override: + description: + - This option overrides the encoding of stdout/stderr output. + - You can use this option when you need to run a command which ignore the console's codepage. + - You should only need to use this option in very rare circumstances. + - This value can be any valid encoding C(Name) based on the output of C([System.Text.Encoding]::GetEncodings()). + See U(https://docs.microsoft.com/dotnet/api/system.text.encoding.getencodings). + type: str + version_added: '2.10' +notes: + - If you want to run a command through a shell (say you are using C(<), + C(>), C(|), etc), you actually want the M(win_shell) module instead. The + C(win_command) module is much more secure as it's not affected by the user's + environment. + - C(creates), C(removes), and C(chdir) can be specified after the command. For instance, if you only want to run a command if a certain file does not + exist, use this. +seealso: +- module: command +- module: psexec +- module: raw +- module: win_psexec +- module: win_shell +author: + - Matt Davis (@nitzmahone) +''' + +EXAMPLES = r''' +- name: Save the result of 'whoami' in 'whoami_out' + win_command: whoami + register: whoami_out + +- name: Run command that only runs if folder exists and runs from a specific folder + win_command: wbadmin -backupTarget:C:\backup\ + args: + chdir: C:\somedir\ + creates: C:\backup\ + +- name: Run an executable and send data to the stdin for the executable + win_command: powershell.exe - + args: + stdin: Write-Host test +''' + +RETURN = r''' +msg: + description: changed + returned: always + type: bool + sample: true +start: + description: The command execution start time + returned: always + type: str + sample: '2016-02-25 09:18:26.429568' +end: + description: The command execution end time + returned: always + type: str + sample: '2016-02-25 09:18:26.755339' +delta: + description: The command execution delta time + returned: always + type: str + sample: '0:00:00.325771' +stdout: + description: The command standard output + returned: always + type: str + sample: 'Clustering node rabbit@slave1 with rabbit@master ...' +stderr: + description: The command standard error + returned: always + type: str + sample: 'ls: cannot access foo: No such file or directory' +cmd: + description: The command executed by the task + returned: always + type: str + sample: 'rabbitmqctl join_cluster rabbit@master' +rc: + description: The command return code (0 means success) + returned: always + type: int + sample: 0 +stdout_lines: + description: The command standard output split in lines + returned: always + type: list + sample: [u'Clustering node rabbit@slave1 with rabbit@master ...'] +''' diff --git a/test/support/windows-integration/plugins/modules/win_copy.ps1 b/test/support/windows-integration/plugins/modules/win_copy.ps1 new file mode 100644 index 00000000000..6a26ee722d0 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_copy.ps1 @@ -0,0 +1,403 @@ +#!powershell + +# Copyright: (c) 2015, Jon Hawkesworth (@jhawkesworth) +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy +#Requires -Module Ansible.ModuleUtils.Backup + +$ErrorActionPreference = 'Stop' + +$params = Parse-Args -arguments $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false +$diff_mode = Get-AnsibleParam -obj $params -name "_ansible_diff" -type "bool" -default $false + +# there are 4 modes to win_copy which are driven by the action plugins: +# explode: src is a zip file which needs to be extracted to dest, for use with multiple files +# query: win_copy action plugin wants to get the state of remote files to check whether it needs to send them +# remote: all copy action is happening remotely (remote_src=True) +# single: a single file has been copied, also used with template +$copy_mode = Get-AnsibleParam -obj $params -name "_copy_mode" -type "str" -default "single" -validateset "explode","query","remote","single" + +# used in explode, remote and single mode +$src = Get-AnsibleParam -obj $params -name "src" -type "path" -failifempty ($copy_mode -in @("explode","process","single")) +$dest = Get-AnsibleParam -obj $params -name "dest" -type "path" -failifempty $true +$backup = Get-AnsibleParam -obj $params -name "backup" -type "bool" -default $false + +# used in single mode +$original_basename = Get-AnsibleParam -obj $params -name "_original_basename" -type "str" + +# used in query and remote mode +$force = Get-AnsibleParam -obj $params -name "force" -type "bool" -default $true + +# used in query mode, contains the local files/directories/symlinks that are to be copied +$files = Get-AnsibleParam -obj $params -name "files" -type "list" +$directories = Get-AnsibleParam -obj $params -name "directories" -type "list" + +$result = @{ + changed = $false +} + +if ($diff_mode) { + $result.diff = @{} +} + +Function Copy-File($source, $dest) { + $diff = "" + $copy_file = $false + $source_checksum = $null + if ($force) { + $source_checksum = Get-FileChecksum -path $source + } + + if (Test-Path -LiteralPath $dest -PathType Container) { + Fail-Json -obj $result -message "cannot copy file from '$source' to '$dest': dest is already a folder" + } elseif (Test-Path -LiteralPath $dest -PathType Leaf) { + if ($force) { + $target_checksum = Get-FileChecksum -path $dest + if ($source_checksum -ne $target_checksum) { + $copy_file = $true + } + } + } else { + $copy_file = $true + } + + if ($copy_file) { + $file_dir = [System.IO.Path]::GetDirectoryName($dest) + # validate the parent dir is not a file and that it exists + if (Test-Path -LiteralPath $file_dir -PathType Leaf) { + Fail-Json -obj $result -message "cannot copy file from '$source' to '$dest': object at dest parent dir is not a folder" + } elseif (-not (Test-Path -LiteralPath $file_dir)) { + # directory doesn't exist, need to create + New-Item -Path $file_dir -ItemType Directory -WhatIf:$check_mode | Out-Null + $diff += "+$file_dir\`n" + } + + if ($backup) { + $result.backup_file = Backup-File -path $dest -WhatIf:$check_mode + } + + if (Test-Path -LiteralPath $dest -PathType Leaf) { + Remove-Item -LiteralPath $dest -Force -Recurse -WhatIf:$check_mode | Out-Null + $diff += "-$dest`n" + } + + if (-not $check_mode) { + # cannot run with -WhatIf:$check_mode as if the parent dir didn't + # exist and was created above would still not exist in check mode + Copy-Item -LiteralPath $source -Destination $dest -Force | Out-Null + } + $diff += "+$dest`n" + + $result.changed = $true + } + + # ugly but to save us from running the checksum twice, let's return it for + # the main code to add it to $result + return ,@{ diff = $diff; checksum = $source_checksum } +} + +Function Copy-Folder($source, $dest) { + $diff = "" + + if (-not (Test-Path -LiteralPath $dest -PathType Container)) { + $parent_dir = [System.IO.Path]::GetDirectoryName($dest) + if (Test-Path -LiteralPath $parent_dir -PathType Leaf) { + Fail-Json -obj $result -message "cannot copy file from '$source' to '$dest': object at dest parent dir is not a folder" + } + if (Test-Path -LiteralPath $dest -PathType Leaf) { + Fail-Json -obj $result -message "cannot copy folder from '$source' to '$dest': dest is already a file" + } + + New-Item -Path $dest -ItemType Container -WhatIf:$check_mode | Out-Null + $diff += "+$dest\`n" + $result.changed = $true + } + + $child_items = Get-ChildItem -LiteralPath $source -Force + foreach ($child_item in $child_items) { + $dest_child_path = Join-Path -Path $dest -ChildPath $child_item.Name + if ($child_item.PSIsContainer) { + $diff += (Copy-Folder -source $child_item.Fullname -dest $dest_child_path) + } else { + $diff += (Copy-File -source $child_item.Fullname -dest $dest_child_path).diff + } + } + + return $diff +} + +Function Get-FileSize($path) { + $file = Get-Item -LiteralPath $path -Force + if ($file.PSIsContainer) { + $size = (Get-ChildItem -Literalpath $file.FullName -Recurse -Force | ` + Where-Object { $_.PSObject.Properties.Name -contains 'Length' } | ` + Measure-Object -Property Length -Sum).Sum + if ($null -eq $size) { + $size = 0 + } + } else { + $size = $file.Length + } + + $size +} + +Function Extract-Zip($src, $dest) { + $archive = [System.IO.Compression.ZipFile]::Open($src, [System.IO.Compression.ZipArchiveMode]::Read, [System.Text.Encoding]::UTF8) + foreach ($entry in $archive.Entries) { + $archive_name = $entry.FullName + + # FullName may be appended with / or \, determine if it is padded and remove it + $padding_length = $archive_name.Length % 4 + if ($padding_length -eq 0) { + $is_dir = $false + $base64_name = $archive_name + } elseif ($padding_length -eq 1) { + $is_dir = $true + if ($archive_name.EndsWith("/") -or $archive_name.EndsWith("`\")) { + $base64_name = $archive_name.Substring(0, $archive_name.Length - 1) + } else { + throw "invalid base64 archive name '$archive_name'" + } + } else { + throw "invalid base64 length '$archive_name'" + } + + # to handle unicode character, win_copy action plugin has encoded the filename + $decoded_archive_name = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($base64_name)) + # re-add the / to the entry full name if it was a directory + if ($is_dir) { + $decoded_archive_name = "$decoded_archive_name/" + } + $entry_target_path = [System.IO.Path]::Combine($dest, $decoded_archive_name) + $entry_dir = [System.IO.Path]::GetDirectoryName($entry_target_path) + + if (-not (Test-Path -LiteralPath $entry_dir)) { + New-Item -Path $entry_dir -ItemType Directory -WhatIf:$check_mode | Out-Null + } + + if ($is_dir -eq $false) { + if (-not $check_mode) { + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $entry_target_path, $true) + } + } + } + $archive.Dispose() # release the handle of the zip file +} + +Function Extract-ZipLegacy($src, $dest) { + if (-not (Test-Path -LiteralPath $dest)) { + New-Item -Path $dest -ItemType Directory -WhatIf:$check_mode | Out-Null + } + $shell = New-Object -ComObject Shell.Application + $zip = $shell.NameSpace($src) + $dest_path = $shell.NameSpace($dest) + + foreach ($entry in $zip.Items()) { + $is_dir = $entry.IsFolder + $encoded_archive_entry = $entry.Name + # to handle unicode character, win_copy action plugin has encoded the filename + $decoded_archive_entry = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($encoded_archive_entry)) + if ($is_dir) { + $decoded_archive_entry = "$decoded_archive_entry/" + } + + $entry_target_path = [System.IO.Path]::Combine($dest, $decoded_archive_entry) + $entry_dir = [System.IO.Path]::GetDirectoryName($entry_target_path) + + if (-not (Test-Path -LiteralPath $entry_dir)) { + New-Item -Path $entry_dir -ItemType Directory -WhatIf:$check_mode | Out-Null + } + + if ($is_dir -eq $false -and (-not $check_mode)) { + # https://msdn.microsoft.com/en-us/library/windows/desktop/bb787866.aspx + # From Folder.CopyHere documentation, 1044 means: + # - 1024: do not display a user interface if an error occurs + # - 16: respond with "yes to all" for any dialog box that is displayed + # - 4: do not display a progress dialog box + $dest_path.CopyHere($entry, 1044) + + # once file is extraced, we need to rename it with non base64 name + $combined_encoded_path = [System.IO.Path]::Combine($dest, $encoded_archive_entry) + Move-Item -LiteralPath $combined_encoded_path -Destination $entry_target_path -Force | Out-Null + } + } +} + +if ($copy_mode -eq "query") { + # we only return a list of files/directories that need to be copied over + # the source of the local file will be the key used + $changed_files = @() + $changed_directories = @() + $changed_symlinks = @() + + foreach ($file in $files) { + $filename = $file.dest + $local_checksum = $file.checksum + + $filepath = Join-Path -Path $dest -ChildPath $filename + if (Test-Path -LiteralPath $filepath -PathType Leaf) { + if ($force) { + $checksum = Get-FileChecksum -path $filepath + if ($checksum -ne $local_checksum) { + $changed_files += $file + } + } + } elseif (Test-Path -LiteralPath $filepath -PathType Container) { + Fail-Json -obj $result -message "cannot copy file to dest '$filepath': object at path is already a directory" + } else { + $changed_files += $file + } + } + + foreach ($directory in $directories) { + $dirname = $directory.dest + + $dirpath = Join-Path -Path $dest -ChildPath $dirname + $parent_dir = [System.IO.Path]::GetDirectoryName($dirpath) + if (Test-Path -LiteralPath $parent_dir -PathType Leaf) { + Fail-Json -obj $result -message "cannot copy folder to dest '$dirpath': object at parent directory path is already a file" + } + if (Test-Path -LiteralPath $dirpath -PathType Leaf) { + Fail-Json -obj $result -message "cannot copy folder to dest '$dirpath': object at path is already a file" + } elseif (-not (Test-Path -LiteralPath $dirpath -PathType Container)) { + $changed_directories += $directory + } + } + + # TODO: Handle symlinks + + $result.files = $changed_files + $result.directories = $changed_directories + $result.symlinks = $changed_symlinks +} elseif ($copy_mode -eq "explode") { + # a single zip file containing the files and directories needs to be + # expanded this will always result in a change as the calculation is done + # on the win_copy action plugin and is only run if a change needs to occur + if (-not (Test-Path -LiteralPath $src -PathType Leaf)) { + Fail-Json -obj $result -message "Cannot expand src zip file: '$src' as it does not exist" + } + + # Detect if the PS zip assemblies are available or whether to use Shell + $use_legacy = $false + try { + Add-Type -AssemblyName System.IO.Compression.FileSystem | Out-Null + Add-Type -AssemblyName System.IO.Compression | Out-Null + } catch { + $use_legacy = $true + } + if ($use_legacy) { + Extract-ZipLegacy -src $src -dest $dest + } else { + Extract-Zip -src $src -dest $dest + } + + $result.changed = $true +} elseif ($copy_mode -eq "remote") { + # all copy actions are happening on the remote side (windows host), need + # too copy source and dest using PS code + $result.src = $src + $result.dest = $dest + + if (-not (Test-Path -LiteralPath $src)) { + Fail-Json -obj $result -message "Cannot copy src file: '$src' as it does not exist" + } + + if (Test-Path -LiteralPath $src -PathType Container) { + # we are copying a directory or the contents of a directory + $result.operation = 'folder_copy' + if ($src.EndsWith("/") -or $src.EndsWith("`\")) { + # copying the folder's contents to dest + $diff = "" + $child_files = Get-ChildItem -LiteralPath $src -Force + foreach ($child_file in $child_files) { + $dest_child_path = Join-Path -Path $dest -ChildPath $child_file.Name + if ($child_file.PSIsContainer) { + $diff += Copy-Folder -source $child_file.FullName -dest $dest_child_path + } else { + $diff += (Copy-File -source $child_file.FullName -dest $dest_child_path).diff + } + } + } else { + # copying the folder and it's contents to dest + $dest = Join-Path -Path $dest -ChildPath (Get-Item -LiteralPath $src -Force).Name + $result.dest = $dest + $diff = Copy-Folder -source $src -dest $dest + } + } else { + # we are just copying a single file to dest + $result.operation = 'file_copy' + + $source_basename = (Get-Item -LiteralPath $src -Force).Name + $result.original_basename = $source_basename + + if ($dest.EndsWith("/") -or $dest.EndsWith("`\")) { + $dest = Join-Path -Path $dest -ChildPath (Get-Item -LiteralPath $src -Force).Name + $result.dest = $dest + } else { + # check if the parent dir exists, this is only done if src is a + # file and dest if the path to a file (doesn't end with \ or /) + $parent_dir = Split-Path -LiteralPath $dest + if (Test-Path -LiteralPath $parent_dir -PathType Leaf) { + Fail-Json -obj $result -message "object at destination parent dir '$parent_dir' is currently a file" + } elseif (-not (Test-Path -LiteralPath $parent_dir -PathType Container)) { + Fail-Json -obj $result -message "Destination directory '$parent_dir' does not exist" + } + } + $copy_result = Copy-File -source $src -dest $dest + $diff = $copy_result.diff + $result.checksum = $copy_result.checksum + } + + # the file might not exist if running in check mode + if (-not $check_mode -or (Test-Path -LiteralPath $dest -PathType Leaf)) { + $result.size = Get-FileSize -path $dest + } else { + $result.size = $null + } + if ($diff_mode) { + $result.diff.prepared = $diff + } +} elseif ($copy_mode -eq "single") { + # a single file is located in src and we need to copy to dest, this will + # always result in a change as the calculation is done on the Ansible side + # before this is run. This should also never run in check mode + if (-not (Test-Path -LiteralPath $src -PathType Leaf)) { + Fail-Json -obj $result -message "Cannot copy src file: '$src' as it does not exist" + } + + # the dest parameter is a directory, we need to append original_basename + if ($dest.EndsWith("/") -or $dest.EndsWith("`\") -or (Test-Path -LiteralPath $dest -PathType Container)) { + $remote_dest = Join-Path -Path $dest -ChildPath $original_basename + $parent_dir = Split-Path -LiteralPath $remote_dest + + # when dest ends with /, we need to create the destination directories + if (Test-Path -LiteralPath $parent_dir -PathType Leaf) { + Fail-Json -obj $result -message "object at destination parent dir '$parent_dir' is currently a file" + } elseif (-not (Test-Path -LiteralPath $parent_dir -PathType Container)) { + New-Item -Path $parent_dir -ItemType Directory | Out-Null + } + } else { + $remote_dest = $dest + $parent_dir = Split-Path -LiteralPath $remote_dest + + # check if the dest parent dirs exist, need to fail if they don't + if (Test-Path -LiteralPath $parent_dir -PathType Leaf) { + Fail-Json -obj $result -message "object at destination parent dir '$parent_dir' is currently a file" + } elseif (-not (Test-Path -LiteralPath $parent_dir -PathType Container)) { + Fail-Json -obj $result -message "Destination directory '$parent_dir' does not exist" + } + } + + if ($backup) { + $result.backup_file = Backup-File -path $remote_dest -WhatIf:$check_mode + } + + Copy-Item -LiteralPath $src -Destination $remote_dest -Force | Out-Null + $result.changed = $true +} + +Exit-Json -obj $result diff --git a/test/support/windows-integration/plugins/modules/win_copy.py b/test/support/windows-integration/plugins/modules/win_copy.py new file mode 100644 index 00000000000..a55f4c65b7e --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_copy.py @@ -0,0 +1,207 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2015, Jon Hawkesworth (@jhawkesworth) +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['stableinterface'], + 'supported_by': 'core'} + +DOCUMENTATION = r''' +--- +module: win_copy +version_added: '1.9.2' +short_description: Copies files to remote locations on windows hosts +description: +- The C(win_copy) module copies a file on the local box to remote windows locations. +- For non-Windows targets, use the M(copy) module instead. +options: + content: + description: + - When used instead of C(src), sets the contents of a file directly to the + specified value. + - This is for simple values, for anything complex or with formatting please + switch to the M(template) module. + type: str + version_added: '2.3' + decrypt: + description: + - This option controls the autodecryption of source files using vault. + type: bool + default: yes + version_added: '2.5' + dest: + description: + - Remote absolute path where the file should be copied to. + - If C(src) is a directory, this must be a directory too. + - Use \ for path separators or \\ when in "double quotes". + - If C(dest) ends with \ then source or the contents of source will be + copied to the directory without renaming. + - If C(dest) is a nonexistent path, it will only be created if C(dest) ends + with "/" or "\", or C(src) is a directory. + - If C(src) and C(dest) are files and if the parent directory of C(dest) + doesn't exist, then the task will fail. + type: path + required: yes + backup: + description: + - Determine whether a backup should be created. + - When set to C(yes), create a backup file including the timestamp information + so you can get the original file back if you somehow clobbered it incorrectly. + - No backup is taken when C(remote_src=False) and multiple files are being + copied. + type: bool + default: no + version_added: '2.8' + force: + description: + - If set to C(yes), the file will only be transferred if the content + is different than destination. + - If set to C(no), the file will only be transferred if the + destination does not exist. + - If set to C(no), no checksuming of the content is performed which can + help improve performance on larger files. + type: bool + default: yes + version_added: '2.3' + local_follow: + description: + - This flag indicates that filesystem links in the source tree, if they + exist, should be followed. + type: bool + default: yes + version_added: '2.4' + remote_src: + description: + - If C(no), it will search for src at originating/master machine. + - If C(yes), it will go to the remote/target machine for the src. + type: bool + default: no + version_added: '2.3' + src: + description: + - Local path to a file to copy to the remote server; can be absolute or + relative. + - If path is a directory, it is copied (including the source folder name) + recursively to C(dest). + - If path is a directory and ends with "/", only the inside contents of + that directory are copied to the destination. Otherwise, if it does not + end with "/", the directory itself with all contents is copied. + - If path is a file and dest ends with "\", the file is copied to the + folder with the same filename. + - Required unless using C(content). + type: path +notes: +- Currently win_copy does not support copying symbolic links from both local to + remote and remote to remote. +- It is recommended that backslashes C(\) are used instead of C(/) when dealing + with remote paths. +- Because win_copy runs over WinRM, it is not a very efficient transfer + mechanism. If sending large files consider hosting them on a web service and + using M(win_get_url) instead. +seealso: +- module: assemble +- module: copy +- module: win_get_url +- module: win_robocopy +author: +- Jon Hawkesworth (@jhawkesworth) +- Jordan Borean (@jborean93) +''' + +EXAMPLES = r''' +- name: Copy a single file + win_copy: + src: /srv/myfiles/foo.conf + dest: C:\Temp\renamed-foo.conf + +- name: Copy a single file, but keep a backup + win_copy: + src: /srv/myfiles/foo.conf + dest: C:\Temp\renamed-foo.conf + backup: yes + +- name: Copy a single file keeping the filename + win_copy: + src: /src/myfiles/foo.conf + dest: C:\Temp\ + +- name: Copy folder to C:\Temp (results in C:\Temp\temp_files) + win_copy: + src: files/temp_files + dest: C:\Temp + +- name: Copy folder contents recursively + win_copy: + src: files/temp_files/ + dest: C:\Temp + +- name: Copy a single file where the source is on the remote host + win_copy: + src: C:\Temp\foo.txt + dest: C:\ansible\foo.txt + remote_src: yes + +- name: Copy a folder recursively where the source is on the remote host + win_copy: + src: C:\Temp + dest: C:\ansible + remote_src: yes + +- name: Set the contents of a file + win_copy: + content: abc123 + dest: C:\Temp\foo.txt + +- name: Copy a single file as another user + win_copy: + src: NuGet.config + dest: '%AppData%\NuGet\NuGet.config' + vars: + ansible_become_user: user + ansible_become_password: pass + # The tmp dir must be set when using win_copy as another user + # This ensures the become user will have permissions for the operation + # Make sure to specify a folder both the ansible_user and the become_user have access to (i.e not %TEMP% which is user specific and requires Admin) + ansible_remote_tmp: 'c:\tmp' +''' + +RETURN = r''' +backup_file: + description: Name of the backup file that was created. + returned: if backup=yes + type: str + sample: C:\Path\To\File.txt.11540.20150212-220915.bak +dest: + description: Destination file/path. + returned: changed + type: str + sample: C:\Temp\ +src: + description: Source file used for the copy on the target machine. + returned: changed + type: str + sample: /home/httpd/.ansible/tmp/ansible-tmp-1423796390.97-147729857856000/source +checksum: + description: SHA1 checksum of the file after running copy. + returned: success, src is a file + type: str + sample: 6e642bb8dd5c2e027bf21dd923337cbb4214f827 +size: + description: Size of the target, after execution. + returned: changed, src is a file + type: int + sample: 1220 +operation: + description: Whether a single file copy took place or a folder copy. + returned: success + type: str + sample: file_copy +original_basename: + description: Basename of the copied file. + returned: changed, src is a file + type: str + sample: foo.txt +''' diff --git a/test/support/windows-integration/plugins/modules/win_data_deduplication.ps1 b/test/support/windows-integration/plugins/modules/win_data_deduplication.ps1 new file mode 100644 index 00000000000..593ee763815 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_data_deduplication.ps1 @@ -0,0 +1,129 @@ +#!powershell + +# Copyright: 2019, rnsc(@rnsc) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt + +#AnsibleRequires -CSharpUtil Ansible.Basic +#AnsibleRequires -OSVersion 6.3 + +$spec = @{ + options = @{ + drive_letter = @{ type = "str"; required = $true } + state = @{ type = "str"; choices = "absent", "present"; default = "present"; } + settings = @{ + type = "dict" + required = $false + options = @{ + minimum_file_size = @{ type = "int"; default = 32768 } + minimum_file_age_days = @{ type = "int"; default = 2 } + no_compress = @{ type = "bool"; required = $false; default = $false } + optimize_in_use_files = @{ type = "bool"; required = $false; default = $false } + verify = @{ type = "bool"; required = $false; default = $false } + } + } + } + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$drive_letter = $module.Params.drive_letter +$state = $module.Params.state +$settings = $module.Params.settings + +$module.Result.changed = $false +$module.Result.reboot_required = $false +$module.Result.msg = "" + +function Set-DataDeduplication($volume, $state, $settings, $dedup_job) { + + $current_state = 'absent' + + try { + $dedup_info = Get-DedupVolume -Volume "$($volume.DriveLetter):" + } catch { + $dedup_info = $null + } + + if ($dedup_info.Enabled) { + $current_state = 'present' + } + + if ( $state -ne $current_state ) { + if( -not $module.CheckMode) { + if($state -eq 'present') { + # Enable-DedupVolume -Volume + Enable-DedupVolume -Volume "$($volume.DriveLetter):" + } elseif ($state -eq 'absent') { + Disable-DedupVolume -Volume "$($volume.DriveLetter):" + } + } + $module.Result.changed = $true + } + + if ($state -eq 'present') { + if ($null -ne $settings) { + Set-DataDedupJobSettings -volume $volume -settings $settings + } + } +} + +function Set-DataDedupJobSettings ($volume, $settings) { + + try { + $dedup_info = Get-DedupVolume -Volume "$($volume.DriveLetter):" + } catch { + $dedup_info = $null + } + + ForEach ($key in $settings.keys) { + + # See Microsoft documentation: + # https://docs.microsoft.com/en-us/powershell/module/deduplication/set-dedupvolume?view=win10-ps + + $update_key = $key + $update_value = $settings.$($key) + # Transform Ansible style options to Powershell params + $update_key = $update_key -replace('_', '') + + if ($update_key -eq "MinimumFileSize" -and $update_value -lt 32768) { + $update_value = 32768 + } + + $current_value = ($dedup_info | Select-Object -ExpandProperty $update_key) + + if ($update_value -ne $current_value) { + $command_param = @{ + $($update_key) = $update_value + } + + # Set-DedupVolume -Volume ` + # -NoCompress ` + # -MinimumFileAgeDays ` + # -MinimumFileSize (minimum 32768) + if( -not $module.CheckMode ) { + Set-DedupVolume -Volume "$($volume.DriveLetter):" @command_param + } + + $module.Result.changed = $true + } + } + +} + +# Install required feature +$feature_name = "FS-Data-Deduplication" +if( -not $module.CheckMode) { + $feature = Install-WindowsFeature -Name $feature_name + + if ($feature.RestartNeeded -eq 'Yes') { + $module.Result.reboot_required = $true + $module.FailJson("$feature_name was installed but requires Windows to be rebooted to work.") + } +} + +$volume = Get-Volume -DriveLetter $drive_letter + +Set-DataDeduplication -volume $volume -state $state -settings $settings -dedup_job $dedup_job + +$module.ExitJson() diff --git a/test/support/windows-integration/plugins/modules/win_data_deduplication.py b/test/support/windows-integration/plugins/modules/win_data_deduplication.py new file mode 100644 index 00000000000..d320b9f7c2b --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_data_deduplication.py @@ -0,0 +1,87 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: 2019, rnsc(@rnsc) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: win_data_deduplication +version_added: "2.10" +short_description: Module to enable Data Deduplication on a volume. +description: +- This module can be used to enable Data Deduplication on a Windows volume. +- The module will install the FS-Data-Deduplication feature (a reboot will be necessary). +options: + drive_letter: + description: + - Windows drive letter on which to enable data deduplication. + required: yes + type: str + state: + description: + - Wether to enable or disable data deduplication on the selected volume. + default: present + type: str + choices: [ present, absent ] + settings: + description: + - Dictionary of settings to pass to the Set-DedupVolume powershell command. + type: dict + suboptions: + minimum_file_size: + description: + - Minimum file size you want to target for deduplication. + - It will default to 32768 if not defined or if the value is less than 32768. + type: int + default: 32768 + minimum_file_age_days: + description: + - Minimum file age you want to target for deduplication. + type: int + default: 2 + no_compress: + description: + - Wether you want to enabled filesystem compression or not. + type: bool + default: no + optimize_in_use_files: + description: + - Indicates that the server attempts to optimize currently open files. + type: bool + default: no + verify: + description: + - Indicates whether the deduplication engine performs a byte-for-byte verification for each duplicate chunk + that optimization creates, rather than relying on a cryptographically strong hash. + - This option is not recommend. + - Setting this parameter to True can degrade optimization performance. + type: bool + default: no +author: +- rnsc (@rnsc) +''' + +EXAMPLES = r''' +- name: Enable Data Deduplication on D + win_data_deduplication: + drive_letter: 'D' + state: present + +- name: Enable Data Deduplication on D + win_data_deduplication: + drive_letter: 'D' + state: present + settings: + no_compress: true + minimum_file_age_days: 1 + minimum_file_size: 0 +''' + +RETURN = r''' +# +''' diff --git a/test/support/windows-integration/plugins/modules/win_dsc.ps1 b/test/support/windows-integration/plugins/modules/win_dsc.ps1 new file mode 100644 index 00000000000..690f391a7b4 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_dsc.ps1 @@ -0,0 +1,398 @@ +#!powershell + +# Copyright: (c) 2015, Trond Hindenes , and others +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Version 5 + +Function ConvertTo-ArgSpecType { + <# + .SYNOPSIS + Converts the DSC parameter type to the arg spec type required for Ansible. + #> + param( + [Parameter(Mandatory=$true)][String]$CimType + ) + + $arg_type = switch($CimType) { + Boolean { "bool" } + Char16 { [Func[[Object], [Char]]]{ [System.Char]::Parse($args[0].ToString()) } } + DateTime { [Func[[Object], [DateTime]]]{ [System.DateTime]($args[0].ToString()) } } + Instance { "dict" } + Real32 { "float" } + Real64 { [Func[[Object], [Double]]]{ [System.Double]::Parse($args[0].ToString()) } } + Reference { "dict" } + SInt16 { [Func[[Object], [Int16]]]{ [System.Int16]::Parse($args[0].ToString()) } } + SInt32 { "int" } + SInt64 { [Func[[Object], [Int64]]]{ [System.Int64]::Parse($args[0].ToString()) } } + SInt8 { [Func[[Object], [SByte]]]{ [System.SByte]::Parse($args[0].ToString()) } } + String { "str" } + UInt16 { [Func[[Object], [UInt16]]]{ [System.UInt16]::Parse($args[0].ToString()) } } + UInt32 { [Func[[Object], [UInt32]]]{ [System.UInt32]::Parse($args[0].ToString()) } } + UInt64 { [Func[[Object], [UInt64]]]{ [System.UInt64]::Parse($args[0].ToString()) } } + UInt8 { [Func[[Object], [Byte]]]{ [System.Byte]::Parse($args[0].ToString()) } } + Unknown { "raw" } + default { "raw" } + } + return $arg_type +} + +Function Get-DscCimClassProperties { + <# + .SYNOPSIS + Get's a list of CimProperties of a CIM Class. It filters out any magic or + read only properties that we don't need to know about. + #> + param([Parameter(Mandatory=$true)][String]$ClassName) + + $resource = Get-CimClass -ClassName $ClassName -Namespace root\Microsoft\Windows\DesiredStateConfiguration + + # Filter out any magic properties that are used internally on an OMI_BaseResource + # https://github.com/PowerShell/PowerShell/blob/master/src/System.Management.Automation/DscSupport/CimDSCParser.cs#L1203 + $magic_properties = @("ResourceId", "SourceInfo", "ModuleName", "ModuleVersion", "ConfigurationName") + $properties = $resource.CimClassProperties | Where-Object { + + ($resource.CimSuperClassName -ne "OMI_BaseResource" -or $_.Name -notin $magic_properties) -and + -not $_.Flags.HasFlag([Microsoft.Management.Infrastructure.CimFlags]::ReadOnly) + } + + return ,$properties +} + +Function Add-PropertyOption { + <# + .SYNOPSIS + Adds the spec for the property type to the existing module specification. + #> + param( + [Parameter(Mandatory=$true)][Hashtable]$Spec, + [Parameter(Mandatory=$true)] + [Microsoft.Management.Infrastructure.CimPropertyDeclaration]$Property + ) + + $option = @{ + required = $false + } + $property_name = $Property.Name + $property_type = $Property.CimType.ToString() + + if ($Property.Flags.HasFlag([Microsoft.Management.Infrastructure.CimFlags]::Key) -or + $Property.Flags.HasFlag([Microsoft.Management.Infrastructure.CimFlags]::Required)) { + $option.required = $true + } + + if ($null -ne $Property.Qualifiers['Values']) { + $option.choices = [System.Collections.Generic.List`1[Object]]$Property.Qualifiers['Values'].Value + } + + if ($property_name -eq "Name") { + # For backwards compatibility we support specifying the Name DSC property as item_name + $option.aliases = @("item_name") + } elseif ($property_name -ceq "key") { + # There seems to be a bug in the CIM property parsing when the property name is 'Key'. The CIM instance will + # think the name is 'key' when the MOF actually defines it as 'Key'. We set the proper casing so the module arg + # validator won't fire a case sensitive warning + $property_name = "Key" + } + + if ($Property.ReferenceClassName -eq "MSFT_Credential") { + # Special handling for the MSFT_Credential type (PSCredential), we handle this with having 2 options that + # have the suffix _username and _password. + $option_spec_pass = @{ + type = "str" + required = $option.required + no_log = $true + } + $Spec.options."$($property_name)_password" = $option_spec_pass + $Spec.required_together.Add(@("$($property_name)_username", "$($property_name)_password")) > $null + + $property_name = "$($property_name)_username" + $option.type = "str" + } elseif ($Property.ReferenceClassName -eq "MSFT_KeyValuePair") { + $option.type = "dict" + } elseif ($property_type.EndsWith("Array")) { + $option.type = "list" + $option.elements = ConvertTo-ArgSpecType -CimType $property_type.Substring(0, $property_type.Length - 5) + } else { + $option.type = ConvertTo-ArgSpecType -CimType $property_type + } + + if (($option.type -eq "dict" -or ($option.type -eq "list" -and $option.elements -eq "dict")) -and + $Property.ReferenceClassName -ne "MSFT_KeyValuePair") { + # Get the sub spec if the type is a Instance (CimInstance/dict) + $sub_option_spec = Get-OptionSpec -ClassName $Property.ReferenceClassName + $option += $sub_option_spec + } + + $Spec.options.$property_name = $option +} + +Function Get-OptionSpec { + <# + .SYNOPSIS + Generates the specifiec used in AnsibleModule for a CIM MOF resource name. + + .NOTES + This won't be able to retrieve the default values for an option as that is not defined in the MOF for a resource. + Default values are still preserved in the DSC engine if we don't pass in the property at all, we just can't report + on what they are automatically. + #> + param( + [Parameter(Mandatory=$true)][String]$ClassName + ) + + $spec = @{ + options = @{} + required_together = [System.Collections.ArrayList]@() + } + $properties = Get-DscCimClassProperties -ClassName $ClassName + foreach ($property in $properties) { + Add-PropertyOption -Spec $spec -Property $property + } + + return $spec +} + +Function ConvertTo-CimInstance { + <# + .SYNOPSIS + Converts a dict to a CimInstance of the specified Class. Also provides a + better error message if this fails that contains the option name that failed. + #> + param( + [Parameter(Mandatory=$true)][String]$Name, + [Parameter(Mandatory=$true)][String]$ClassName, + [Parameter(Mandatory=$true)][System.Collections.IDictionary]$Value, + [Parameter(Mandatory=$true)][Ansible.Basic.AnsibleModule]$Module, + [Switch]$Recurse + ) + + $properties = @{} + foreach ($value_info in $Value.GetEnumerator()) { + # Need to remove all null values from existing dict so the conversion works + if ($null -eq $value_info.Value) { + continue + } + $properties.($value_info.Key) = $value_info.Value + } + + if ($Recurse) { + # We want to validate and convert and values to what's required by DSC + $properties = ConvertTo-DscProperty -ClassName $ClassName -Params $properties -Module $Module + } + + try { + return (New-CimInstance -ClassName $ClassName -Property $properties -ClientOnly) + } catch { + # New-CimInstance raises a poor error message, make sure we mention what option it is for + $Module.FailJson("Failed to cast dict value for option '$Name' to a CimInstance: $($_.Exception.Message)", $_) + } +} + +Function ConvertTo-DscProperty { + <# + .SYNOPSIS + Converts the input module parameters that have been validated and casted + into the types expected by the DSC engine. This is mostly done to deal with + types like PSCredential and Dictionaries. + #> + param( + [Parameter(Mandatory=$true)][String]$ClassName, + [Parameter(Mandatory=$true)][System.Collections.IDictionary]$Params, + [Parameter(Mandatory=$true)][Ansible.Basic.AnsibleModule]$Module + ) + $properties = Get-DscCimClassProperties -ClassName $ClassName + + $dsc_properties = @{} + foreach ($property in $properties) { + $property_name = $property.Name + $property_type = $property.CimType.ToString() + + if ($property.ReferenceClassName -eq "MSFT_Credential") { + $username = $Params."$($property_name)_username" + $password = $Params."$($property_name)_password" + + # No user set == No option set in playbook, skip this property + if ($null -eq $username) { + continue + } + $sec_password = ConvertTo-SecureString -String $password -AsPlainText -Force + $value = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $username, $sec_password + } else { + $value = $Params.$property_name + + # The actual value wasn't set, skip adding this property + if ($null -eq $value) { + continue + } + + if ($property.ReferenceClassName -eq "MSFT_KeyValuePair") { + $key_value_pairs = [System.Collections.Generic.List`1[CimInstance]]@() + foreach ($value_info in $value.GetEnumerator()) { + $kvp = @{Key = $value_info.Key; Value = $value_info.Value.ToString()} + $cim_instance = ConvertTo-CimInstance -Name $property_name -ClassName MSFT_KeyValuePair ` + -Value $kvp -Module $Module + $key_value_pairs.Add($cim_instance) > $null + } + $value = $key_value_pairs.ToArray() + } elseif ($null -ne $property.ReferenceClassName) { + # Convert the dict to a CimInstance (or list of CimInstances) + $convert_args = @{ + ClassName = $property.ReferenceClassName + Module = $Module + Name = $property_name + Recurse = $true + } + if ($property_type.EndsWith("Array")) { + $value = [System.Collections.Generic.List`1[CimInstance]]@() + foreach ($raw in $Params.$property_name.GetEnumerator()) { + $cim_instance = ConvertTo-CimInstance -Value $raw @convert_args + $value.Add($cim_instance) > $null + } + $value = $value.ToArray() # Need to make sure we are dealing with an Array not a List + } else { + $value = ConvertTo-CimInstance -Value $value @convert_args + } + } + } + $dsc_properties.$property_name = $value + } + + return $dsc_properties +} + +Function Invoke-DscMethod { + <# + .SYNOPSIS + Invokes the DSC Resource Method specified in another PS pipeline. This is + done so we can retrieve the Verbose stream and return it back to the user + for futher debugging. + #> + param( + [Parameter(Mandatory=$true)][Ansible.Basic.AnsibleModule]$Module, + [Parameter(Mandatory=$true)][String]$Method, + [Parameter(Mandatory=$true)][Hashtable]$Arguments + ) + + # Invoke the DSC resource in a separate runspace so we can capture the Verbose output + $ps = [PowerShell]::Create() + $ps.AddCommand("Invoke-DscResource").AddParameter("Method", $Method) > $null + $ps.AddParameters($Arguments) > $null + + $result = $ps.Invoke() + + # Pass the warnings through to the AnsibleModule return result + foreach ($warning in $ps.Streams.Warning) { + $Module.Warn($warning.Message) + } + + # If running at a high enough verbosity, add the verbose output to the AnsibleModule return result + if ($Module.Verbosity -ge 3) { + $verbose_logs = [System.Collections.Generic.List`1[String]]@() + foreach ($verbosity in $ps.Streams.Verbose) { + $verbose_logs.Add($verbosity.Message) > $null + } + $Module.Result."verbose_$($Method.ToLower())" = $verbose_logs + } + + if ($ps.HadErrors) { + # Cannot pass in the ErrorRecord as it's a RemotingErrorRecord and doesn't contain the ScriptStackTrace + # or other info that would be useful + $Module.FailJson("Failed to invoke DSC $Method method: $($ps.Streams.Error[0].Exception.Message)") + } + + return $result +} + +# win_dsc is unique in that is builds the arg spec based on DSC Resource input. To get this info +# we need to read the resource_name and module_version value which is done outside of Ansible.Basic +if ($args.Length -gt 0) { + $params = Get-Content -Path $args[0] | ConvertFrom-Json +} else { + $params = $complex_args +} +if (-not $params.ContainsKey("resource_name")) { + $res = @{ + msg = "missing required argument: resource_name" + failed = $true + } + Write-Output -InputObject (ConvertTo-Json -Compress -InputObject $res) + exit 1 +} +$resource_name = $params.resource_name + +if ($params.ContainsKey("module_version")) { + $module_version = $params.module_version +} else { + $module_version = "latest" +} + +$module_versions = (Get-DscResource -Name $resource_name -ErrorAction SilentlyContinue | Sort-Object -Property Version) +$resource = $null +if ($module_version -eq "latest" -and $null -ne $module_versions) { + $resource = $module_versions[-1] +} elseif ($module_version -ne "latest") { + $resource = $module_versions | Where-Object { $_.Version -eq $module_version } +} + +if (-not $resource) { + if ($module_version -eq "latest") { + $msg = "Resource '$resource_name' not found." + } else { + $msg = "Resource '$resource_name' with version '$module_version' not found." + $msg += " Versions installed: '$($module_versions.Version -join "', '")'." + } + + Write-Output -InputObject (ConvertTo-Json -Compress -InputObject @{ failed = $true; msg = $msg }) + exit 1 +} + +# Build the base args for the DSC Invocation based on the resource selected +$dsc_args = @{ + Name = $resource.Name +} + +# Binary resources are not working very well with that approach - need to guesstimate module name/version +$module_version = $null +if ($resource.Module) { + $dsc_args.ModuleName = @{ + ModuleName = $resource.Module.Name + ModuleVersion = $resource.Module.Version + } + $module_version = $resource.Module.Version.ToString() +} else { + $dsc_args.ModuleName = "PSDesiredStateConfiguration" +} + +# To ensure the class registered with CIM is the one based on our version, we want to run the Get method so the DSC +# engine updates the metadata propery. We don't care about any errors here +try { + Invoke-DscResource -Method Get -Property @{Fake="Fake"} @dsc_args > $null +} catch {} + +# Dynamically build the option spec based on the resource_name specified and create the module object +$spec = Get-OptionSpec -ClassName $resource.ResourceType +$spec.supports_check_mode = $true +$spec.options.module_version = @{ type = "str"; default = "latest" } +$spec.options.resource_name = @{ type = "str"; required = $true } + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) +$module.Result.reboot_required = $false +$module.Result.module_version = $module_version + +# Build the DSC invocation arguments and invoke the resource +$dsc_args.Property = ConvertTo-DscProperty -ClassName $resource.ResourceType -Module $module -Params $Module.Params +$dsc_args.Verbose = $true + +$test_result = Invoke-DscMethod -Module $module -Method Test -Arguments $dsc_args +if ($test_result.InDesiredState -ne $true) { + if (-not $module.CheckMode) { + $result = Invoke-DscMethod -Module $module -Method Set -Arguments $dsc_args + $module.Result.reboot_required = $result.RebootRequired + } + $module.Result.changed = $true +} + +$module.ExitJson() diff --git a/test/support/windows-integration/plugins/modules/win_dsc.py b/test/support/windows-integration/plugins/modules/win_dsc.py new file mode 100644 index 00000000000..200d025eb85 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_dsc.py @@ -0,0 +1,183 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2015, Trond Hindenes , and others +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: win_dsc +version_added: "2.4" +short_description: Invokes a PowerShell DSC configuration +description: +- Configures a resource using PowerShell DSC. +- Requires PowerShell version 5.0 or newer. +- Most of the options for this module are dynamic and will vary depending on + the DSC Resource specified in I(resource_name). +- See :doc:`/user_guide/windows_dsc` for more information on how to use this module. +options: + resource_name: + description: + - The name of the DSC Resource to use. + - Must be accessible to PowerShell using any of the default paths. + type: str + required: yes + module_version: + description: + - Can be used to configure the exact version of the DSC resource to be + invoked. + - Useful if the target node has multiple versions installed of the module + containing the DSC resource. + - If not specified, the module will follow standard PowerShell convention + and use the highest version available. + type: str + default: latest + free_form: + description: + - The M(win_dsc) module takes in multiple free form options based on the + DSC resource being invoked by I(resource_name). + - There is no option actually named C(free_form) so see the examples. + - This module will try and convert the option to the correct type required + by the DSC resource and throw a warning if it fails. + - If the type of the DSC resource option is a C(CimInstance) or + C(CimInstance[]), this means the value should be a dictionary or list + of dictionaries based on the values required by that option. + - If the type of the DSC resource option is a C(PSCredential) then there + needs to be 2 options set in the Ansible task definition suffixed with + C(_username) and C(_password). + - If the type of the DSC resource option is an array, then a list should be + provided but a comma separated string also work. Use a list where + possible as no escaping is required and it works with more complex types + list C(CimInstance[]). + - If the type of the DSC resource option is a C(DateTime), you should use + a string in the form of an ISO 8901 string to ensure the exact date is + used. + - Since Ansible 2.8, Ansible will now validate the input fields against the + DSC resource definition automatically. Older versions will silently + ignore invalid fields. + type: str + required: true +notes: +- By default there are a few builtin resources that come with PowerShell 5.0, + see U(https://docs.microsoft.com/en-us/powershell/scripting/dsc/resources/resources) for + more information on these resources. +- Custom DSC resources can be installed with M(win_psmodule) using the I(name) + option. +- The DSC engine run's each task as the SYSTEM account, any resources that need + to be accessed with a different account need to have C(PsDscRunAsCredential) + set. +- To see the valid options for a DSC resource, run the module with C(-vvv) to + show the possible module invocation. Default values are not shown in this + output but are applied within the DSC engine. +author: +- Trond Hindenes (@trondhindenes) +''' + +EXAMPLES = r''' +- name: Extract zip file + win_dsc: + resource_name: Archive + Ensure: Present + Path: C:\Temp\zipfile.zip + Destination: C:\Temp\Temp2 + +- name: Install a Windows feature with the WindowsFeature resource + win_dsc: + resource_name: WindowsFeature + Name: telnet-client + +- name: Edit HKCU reg key under specific user + win_dsc: + resource_name: Registry + Ensure: Present + Key: HKEY_CURRENT_USER\ExampleKey + ValueName: TestValue + ValueData: TestData + PsDscRunAsCredential_username: '{{ansible_user}}' + PsDscRunAsCredential_password: '{{ansible_password}}' + no_log: true + +- name: Create file with multiple attributes + win_dsc: + resource_name: File + DestinationPath: C:\ansible\dsc + Attributes: # can also be a comma separated string, e.g. 'Hidden, System' + - Hidden + - System + Ensure: Present + Type: Directory + +- name: Call DSC resource with DateTime option + win_dsc: + resource_name: DateTimeResource + DateTimeOption: '2019-02-22T13:57:31.2311892+00:00' + +# more complex example using custom DSC resource and dict values +- name: Setup the xWebAdministration module + win_psmodule: + name: xWebAdministration + state: present + +- name: Create IIS Website with Binding and Authentication options + win_dsc: + resource_name: xWebsite + Ensure: Present + Name: DSC Website + State: Started + PhysicalPath: C:\inetpub\wwwroot + BindingInfo: # Example of a CimInstance[] DSC parameter (list of dicts) + - Protocol: https + Port: 1234 + CertificateStoreName: MY + CertificateThumbprint: C676A89018C4D5902353545343634F35E6B3A659 + HostName: DSCTest + IPAddress: '*' + SSLFlags: '1' + - Protocol: http + Port: 4321 + IPAddress: '*' + AuthenticationInfo: # Example of a CimInstance DSC parameter (dict) + Anonymous: no + Basic: true + Digest: false + Windows: yes +''' + +RETURN = r''' +module_version: + description: The version of the dsc resource/module used. + returned: always + type: str + sample: "1.0.1" +reboot_required: + description: Flag returned from the DSC engine indicating whether or not + the machine requires a reboot for the invoked changes to take effect. + returned: always + type: bool + sample: true +verbose_test: + description: The verbose output as a list from executing the DSC test + method. + returned: Ansible verbosity is -vvv or greater + type: list + sample: [ + "Perform operation 'Invoke CimMethod' with the following parameters, ", + "[SERVER]: LCM: [Start Test ] [[File]DirectResourceAccess]", + "Operation 'Invoke CimMethod' complete." + ] +verbose_set: + description: The verbose output as a list from executing the DSC Set + method. + returned: Ansible verbosity is -vvv or greater and a change occurred + type: list + sample: [ + "Perform operation 'Invoke CimMethod' with the following parameters, ", + "[SERVER]: LCM: [Start Set ] [[File]DirectResourceAccess]", + "Operation 'Invoke CimMethod' complete." + ] +''' diff --git a/test/support/windows-integration/plugins/modules/win_feature.ps1 b/test/support/windows-integration/plugins/modules/win_feature.ps1 new file mode 100644 index 00000000000..9a7e1c30814 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_feature.ps1 @@ -0,0 +1,111 @@ +#!powershell + +# Copyright: (c) 2014, Paul Durivage +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +Import-Module -Name ServerManager + +$result = @{ + changed = $false +} + +$params = Parse-Args $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false + +$name = Get-AnsibleParam -obj $params -name "name" -type "list" -failifempty $true +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "present" -validateset "present","absent" + +$include_sub_features = Get-AnsibleParam -obj $params -name "include_sub_features" -type "bool" -default $false +$include_management_tools = Get-AnsibleParam -obj $params -name "include_management_tools" -type "bool" -default $false +$source = Get-AnsibleParam -obj $params -name "source" -type "str" + +$install_cmdlet = $false +if (Get-Command -Name Install-WindowsFeature -ErrorAction SilentlyContinue) { + Set-Alias -Name Install-AnsibleWindowsFeature -Value Install-WindowsFeature + Set-Alias -Name Uninstall-AnsibleWindowsFeature -Value Uninstall-WindowsFeature + $install_cmdlet = $true +} elseif (Get-Command -Name Add-WindowsFeature -ErrorAction SilentlyContinue) { + Set-Alias -Name Install-AnsibleWindowsFeature -Value Add-WindowsFeature + Set-Alias -Name Uninstall-AnsibleWindowsFeature -Value Remove-WindowsFeature +} else { + Fail-Json -obj $result -message "This version of Windows does not support the cmdlets Install-WindowsFeature or Add-WindowsFeature" +} + +if ($state -eq "present") { + $install_args = @{ + Name = $name + IncludeAllSubFeature = $include_sub_features + Restart = $false + WhatIf = $check_mode + ErrorAction = "Stop" + } + + if ($install_cmdlet) { + $install_args.IncludeManagementTools = $include_management_tools + $install_args.Confirm = $false + if ($source) { + if (-not (Test-Path -Path $source)) { + Fail-Json -obj $result -message "Failed to find source path $source for feature install" + } + $install_args.Source = $source + } + } + + try { + $action_results = Install-AnsibleWindowsFeature @install_args + } catch { + Fail-Json -obj $result -message "Failed to install Windows Feature: $($_.Exception.Message)" + } +} else { + $uninstall_args = @{ + Name = $name + Restart = $false + WhatIf = $check_mode + ErrorAction = "Stop" + } + if ($install_cmdlet) { + $uninstall_args.IncludeManagementTools = $include_management_tools + } + + try { + $action_results = Uninstall-AnsibleWindowsFeature @uninstall_args + } catch { + Fail-Json -obj $result -message "Failed to uninstall Windows Feature: $($_.Exception.Message)" + } +} + +# Loop through results and create a hash containing details about +# each role/feature that is installed/removed +# $action_results.FeatureResult is not empty if anything was changed +$feature_results = @() +foreach ($action_result in $action_results.FeatureResult) { + $message = @() + foreach ($msg in $action_result.Message) { + $message += @{ + message_type = $msg.MessageType.ToString() + error_code = $msg.ErrorCode + text = $msg.Text + } + } + + $feature_results += @{ + id = $action_result.Id + display_name = $action_result.DisplayName + message = $message + reboot_required = ConvertTo-Bool -obj $action_result.RestartNeeded + skip_reason = $action_result.SkipReason.ToString() + success = ConvertTo-Bool -obj $action_result.Success + restart_needed = ConvertTo-Bool -obj $action_result.RestartNeeded + } + $result.changed = $true +} +$result.feature_result = $feature_results +$result.success = ConvertTo-Bool -obj $action_results.Success +$result.exitcode = $action_results.ExitCode.ToString() +$result.reboot_required = ConvertTo-Bool -obj $action_results.RestartNeeded +# controls whether Ansible will fail or not +$result.failed = (-not $action_results.Success) + +Exit-Json -obj $result diff --git a/test/support/windows-integration/plugins/modules/win_feature.py b/test/support/windows-integration/plugins/modules/win_feature.py new file mode 100644 index 00000000000..62e310b282b --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_feature.py @@ -0,0 +1,149 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2014, Paul Durivage +# Copyright: (c) 2014, Trond Hindenes +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# this is a windows documentation stub. actual code lives in the .ps1 +# file of the same name + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: win_feature +version_added: "1.7" +short_description: Installs and uninstalls Windows Features on Windows Server +description: + - Installs or uninstalls Windows Roles or Features on Windows Server. + - This module uses the Add/Remove-WindowsFeature Cmdlets on Windows 2008 R2 + and Install/Uninstall-WindowsFeature Cmdlets on Windows 2012, which are not available on client os machines. +options: + name: + description: + - Names of roles or features to install as a single feature or a comma-separated list of features. + - To list all available features use the PowerShell command C(Get-WindowsFeature). + type: list + required: yes + state: + description: + - State of the features or roles on the system. + type: str + choices: [ absent, present ] + default: present + include_sub_features: + description: + - Adds all subfeatures of the specified feature. + type: bool + default: no + include_management_tools: + description: + - Adds the corresponding management tools to the specified feature. + - Not supported in Windows 2008 R2 and will be ignored. + type: bool + default: no + source: + description: + - Specify a source to install the feature from. + - Not supported in Windows 2008 R2 and will be ignored. + - Can either be C({driveletter}:\sources\sxs) or C(\\{IP}\share\sources\sxs). + type: str + version_added: "2.1" +seealso: +- module: win_chocolatey +- module: win_package +author: + - Paul Durivage (@angstwad) + - Trond Hindenes (@trondhindenes) +''' + +EXAMPLES = r''' +- name: Install IIS (Web-Server only) + win_feature: + name: Web-Server + state: present + +- name: Install IIS (Web-Server and Web-Common-Http) + win_feature: + name: + - Web-Server + - Web-Common-Http + state: present + +- name: Install NET-Framework-Core from file + win_feature: + name: NET-Framework-Core + source: C:\Temp\iso\sources\sxs + state: present + +- name: Install IIS Web-Server with sub features and management tools + win_feature: + name: Web-Server + state: present + include_sub_features: yes + include_management_tools: yes + register: win_feature + +- name: Reboot if installing Web-Server feature requires it + win_reboot: + when: win_feature.reboot_required +''' + +RETURN = r''' +exitcode: + description: The stringified exit code from the feature installation/removal command. + returned: always + type: str + sample: Success +feature_result: + description: List of features that were installed or removed. + returned: success + type: complex + sample: + contains: + display_name: + description: Feature display name. + returned: always + type: str + sample: "Telnet Client" + id: + description: A list of KB article IDs that apply to the update. + returned: always + type: int + sample: 44 + message: + description: Any messages returned from the feature subsystem that occurred during installation or removal of this feature. + returned: always + type: list + elements: str + sample: [] + reboot_required: + description: True when the target server requires a reboot as a result of installing or removing this feature. + returned: always + type: bool + sample: true + restart_needed: + description: DEPRECATED in Ansible 2.4 (refer to C(reboot_required) instead). True when the target server requires a reboot as a + result of installing or removing this feature. + returned: always + type: bool + sample: true + skip_reason: + description: The reason a feature installation or removal was skipped. + returned: always + type: str + sample: NotSkipped + success: + description: If the feature installation or removal was successful. + returned: always + type: bool + sample: true +reboot_required: + description: True when the target server requires a reboot to complete updates (no further updates can be installed until after a reboot). + returned: success + type: bool + sample: true +''' diff --git a/test/support/windows-integration/plugins/modules/win_file.ps1 b/test/support/windows-integration/plugins/modules/win_file.ps1 new file mode 100644 index 00000000000..54427549254 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_file.ps1 @@ -0,0 +1,152 @@ +#!powershell + +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +$ErrorActionPreference = "Stop" + +$params = Parse-Args $args -supports_check_mode $true + +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -default $false +$_remote_tmp = Get-AnsibleParam $params "_ansible_remote_tmp" -type "path" -default $env:TMP + +$path = Get-AnsibleParam -obj $params -name "path" -type "path" -failifempty $true -aliases "dest","name" +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -validateset "absent","directory","file","touch" + +# used in template/copy when dest is the path to a dir and source is a file +$original_basename = Get-AnsibleParam -obj $params -name "_original_basename" -type "str" +if ((Test-Path -LiteralPath $path -PathType Container) -and ($null -ne $original_basename)) { + $path = Join-Path -Path $path -ChildPath $original_basename +} + +$result = @{ + changed = $false +} + +# Used to delete symlinks as powershell cannot delete broken symlinks +$symlink_util = @" +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace Ansible.Command { + public class SymLinkHelper { + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + public static extern bool DeleteFileW(string lpFileName); + + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + public static extern bool RemoveDirectoryW(string lpPathName); + + public static void DeleteDirectory(string path) { + if (!RemoveDirectoryW(path)) + throw new Exception(String.Format("RemoveDirectoryW({0}) failed: {1}", path, new Win32Exception(Marshal.GetLastWin32Error()).Message)); + } + + public static void DeleteFile(string path) { + if (!DeleteFileW(path)) + throw new Exception(String.Format("DeleteFileW({0}) failed: {1}", path, new Win32Exception(Marshal.GetLastWin32Error()).Message)); + } + } +} +"@ +$original_tmp = $env:TMP +$env:TMP = $_remote_tmp +Add-Type -TypeDefinition $symlink_util +$env:TMP = $original_tmp + +# Used to delete directories and files with logic on handling symbolic links +function Remove-File($file, $checkmode) { + try { + if ($file.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { + # Bug with powershell, if you try and delete a symbolic link that is pointing + # to an invalid path it will fail, using Win32 API to do this instead + if ($file.PSIsContainer) { + if (-not $checkmode) { + [Ansible.Command.SymLinkHelper]::DeleteDirectory($file.FullName) + } + } else { + if (-not $checkmode) { + [Ansible.Command.SymlinkHelper]::DeleteFile($file.FullName) + } + } + } elseif ($file.PSIsContainer) { + Remove-Directory -directory $file -checkmode $checkmode + } else { + Remove-Item -LiteralPath $file.FullName -Force -WhatIf:$checkmode + } + } catch [Exception] { + Fail-Json $result "Failed to delete $($file.FullName): $($_.Exception.Message)" + } +} + +function Remove-Directory($directory, $checkmode) { + foreach ($file in Get-ChildItem -LiteralPath $directory.FullName) { + Remove-File -file $file -checkmode $checkmode + } + Remove-Item -LiteralPath $directory.FullName -Force -Recurse -WhatIf:$checkmode +} + + +if ($state -eq "touch") { + if (Test-Path -LiteralPath $path) { + if (-not $check_mode) { + (Get-ChildItem -LiteralPath $path).LastWriteTime = Get-Date + } + $result.changed = $true + } else { + Write-Output $null | Out-File -LiteralPath $path -Encoding ASCII -WhatIf:$check_mode + $result.changed = $true + } +} + +if (Test-Path -LiteralPath $path) { + $fileinfo = Get-Item -LiteralPath $path -Force + if ($state -eq "absent") { + Remove-File -file $fileinfo -checkmode $check_mode + $result.changed = $true + } else { + if ($state -eq "directory" -and -not $fileinfo.PsIsContainer) { + Fail-Json $result "path $path is not a directory" + } + + if ($state -eq "file" -and $fileinfo.PsIsContainer) { + Fail-Json $result "path $path is not a file" + } + } + +} else { + + # If state is not supplied, test the $path to see if it looks like + # a file or a folder and set state to file or folder + if ($null -eq $state) { + $basename = Split-Path -Path $path -Leaf + if ($basename.length -gt 0) { + $state = "file" + } else { + $state = "directory" + } + } + + if ($state -eq "directory") { + try { + New-Item -Path $path -ItemType Directory -WhatIf:$check_mode | Out-Null + } catch { + if ($_.CategoryInfo.Category -eq "ResourceExists") { + $fileinfo = Get-Item -LiteralPath $_.CategoryInfo.TargetName + if ($state -eq "directory" -and -not $fileinfo.PsIsContainer) { + Fail-Json $result "path $path is not a directory" + } + } else { + Fail-Json $result $_.Exception.Message + } + } + $result.changed = $true + } elseif ($state -eq "file") { + Fail-Json $result "path $path will not be created" + } + +} + +Exit-Json $result diff --git a/test/support/windows-integration/plugins/modules/win_file.py b/test/support/windows-integration/plugins/modules/win_file.py new file mode 100644 index 00000000000..28149579cda --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_file.py @@ -0,0 +1,70 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2015, Jon Hawkesworth (@jhawkesworth) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['stableinterface'], + 'supported_by': 'core'} + +DOCUMENTATION = r''' +--- +module: win_file +version_added: "1.9.2" +short_description: Creates, touches or removes files or directories +description: + - Creates (empty) files, updates file modification stamps of existing files, + and can create or remove directories. + - Unlike M(file), does not modify ownership, permissions or manipulate links. + - For non-Windows targets, use the M(file) module instead. +options: + path: + description: + - Path to the file being managed. + required: yes + type: path + aliases: [ dest, name ] + state: + description: + - If C(directory), all immediate subdirectories will be created if they + do not exist. + - If C(file), the file will NOT be created if it does not exist, see the M(copy) + or M(template) module if you want that behavior. + - If C(absent), directories will be recursively deleted, and files will be removed. + - If C(touch), an empty file will be created if the C(path) does not + exist, while an existing file or directory will receive updated file access and + modification times (similar to the way C(touch) works from the command line). + type: str + choices: [ absent, directory, file, touch ] +seealso: +- module: file +- module: win_acl +- module: win_acl_inheritance +- module: win_owner +- module: win_stat +author: +- Jon Hawkesworth (@jhawkesworth) +''' + +EXAMPLES = r''' +- name: Touch a file (creates if not present, updates modification time if present) + win_file: + path: C:\Temp\foo.conf + state: touch + +- name: Remove a file, if present + win_file: + path: C:\Temp\foo.conf + state: absent + +- name: Create directory structure + win_file: + path: C:\Temp\folder\subfolder + state: directory + +- name: Remove directory structure + win_file: + path: C:\Temp + state: absent +''' diff --git a/test/support/windows-integration/plugins/modules/win_find.ps1 b/test/support/windows-integration/plugins/modules/win_find.ps1 new file mode 100644 index 00000000000..bc57c5ff5d2 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_find.ps1 @@ -0,0 +1,416 @@ +#!powershell + +# Copyright: (c) 2016, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module Ansible.ModuleUtils.LinkUtil + +$spec = @{ + options = @{ + paths = @{ type = "list"; elements = "str"; required = $true } + age = @{ type = "str" } + age_stamp = @{ type = "str"; default = "mtime"; choices = "mtime", "ctime", "atime" } + file_type = @{ type = "str"; default = "file"; choices = "file", "directory" } + follow = @{ type = "bool"; default = $false } + hidden = @{ type = "bool"; default = $false } + patterns = @{ type = "list"; elements = "str"; aliases = "regex", "regexp" } + recurse = @{ type = "bool"; default = $false } + size = @{ type = "str" } + use_regex = @{ type = "bool"; default = $false } + get_checksum = @{ type = "bool"; default = $true } + checksum_algorithm = @{ type = "str"; default = "sha1"; choices = "md5", "sha1", "sha256", "sha384", "sha512" } + } + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$paths = $module.Params.paths +$age = $module.Params.age +$age_stamp = $module.Params.age_stamp +$file_type = $module.Params.file_type +$follow = $module.Params.follow +$hidden = $module.Params.hidden +$patterns = $module.Params.patterns +$recurse = $module.Params.recurse +$size = $module.Params.size +$use_regex = $module.Params.use_regex +$get_checksum = $module.Params.get_checksum +$checksum_algorithm = $module.Params.checksum_algorithm + +$module.Result.examined = 0 +$module.Result.files = @() +$module.Result.matched = 0 + +Load-LinkUtils + +Function Assert-Age { + Param ( + [System.IO.FileSystemInfo]$File, + [System.Int64]$Age, + [System.String]$AgeStamp + ) + + $actual_age = switch ($AgeStamp) { + mtime { $File.LastWriteTime.Ticks } + ctime { $File.CreationTime.Ticks } + atime { $File.LastAccessTime.Ticks } + } + + if ($Age -ge 0) { + return $Age -ge $actual_age + } else { + return ($Age * -1) -le $actual_age + } +} + +Function Assert-FileType { + Param ( + [System.IO.FileSystemInfo]$File, + [System.String]$FileType + ) + + $is_dir = $File.Attributes.HasFlag([System.IO.FileAttributes]::Directory) + return ($FileType -eq 'directory' -and $is_dir) -or ($FileType -eq 'file' -and -not $is_dir) +} + +Function Assert-FileHidden { + Param ( + [System.IO.FileSystemInfo]$File, + [Switch]$IsHidden + ) + + $file_is_hidden = $File.Attributes.HasFlag([System.IO.FileAttributes]::Hidden) + return $IsHidden.IsPresent -eq $file_is_hidden +} + + +Function Assert-FileNamePattern { + Param ( + [System.IO.FileSystemInfo]$File, + [System.String[]]$Patterns, + [Switch]$UseRegex + ) + + $valid_match = $false + foreach ($pattern in $Patterns) { + if ($UseRegex) { + if ($File.Name -match $pattern) { + $valid_match = $true + break + } + } else { + if ($File.Name -like $pattern) { + $valid_match = $true + break + } + } + } + return $valid_match +} + +Function Assert-FileSize { + Param ( + [System.IO.FileSystemInfo]$File, + [System.Int64]$Size + ) + + if ($Size -ge 0) { + return $File.Length -ge $Size + } else { + return $File.Length -le ($Size * -1) + } +} + +Function Get-FileChecksum { + Param ( + [System.String]$Path, + [System.String]$Algorithm + ) + + $sp = switch ($algorithm) { + 'md5' { New-Object -TypeName System.Security.Cryptography.MD5CryptoServiceProvider } + 'sha1' { New-Object -TypeName System.Security.Cryptography.SHA1CryptoServiceProvider } + 'sha256' { New-Object -TypeName System.Security.Cryptography.SHA256CryptoServiceProvider } + 'sha384' { New-Object -TypeName System.Security.Cryptography.SHA384CryptoServiceProvider } + 'sha512' { New-Object -TypeName System.Security.Cryptography.SHA512CryptoServiceProvider } + } + + $fp = [System.IO.File]::Open($Path, [System.IO.Filemode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) + try { + $hash = [System.BitConverter]::ToString($sp.ComputeHash($fp)).Replace("-", "").ToLower() + } finally { + $fp.Dispose() + } + + return $hash +} + +Function Search-Path { + [CmdletBinding()] + Param ( + [Parameter(Mandatory=$true)] + [System.String] + $Path, + + [Parameter(Mandatory=$true)] + [AllowEmptyCollection()] + [System.Collections.Generic.HashSet`1[System.String]] + $CheckedPaths, + + [Parameter(Mandatory=$true)] + [Object] + $Module, + + [System.Int64] + $Age, + + [System.String] + $AgeStamp, + + [System.String] + $FileType, + + [Switch] + $Follow, + + [Switch] + $GetChecksum, + + [Switch] + $IsHidden, + + [System.String[]] + $Patterns, + + [Switch] + $Recurse, + + [System.Int64] + $Size, + + [Switch] + $UseRegex + ) + + $dir_obj = New-Object -TypeName System.IO.DirectoryInfo -ArgumentList $Path + if ([Int32]$dir_obj.Attributes -eq -1) { + $Module.Warn("Argument path '$Path' does not exist, skipping") + return + } elseif (-not $dir_obj.Attributes.HasFlag([System.IO.FileAttributes]::Directory)) { + $Module.Warn("Argument path '$Path' is a file not a directory, skipping") + return + } + + $dir_files = @() + try { + $dir_files = $dir_obj.EnumerateFileSystemInfos("*", [System.IO.SearchOption]::TopDirectoryOnly) + } catch [System.IO.DirectoryNotFoundException] { # Broken ReparsePoint/Symlink, cannot enumerate + } catch [System.UnauthorizedAccessException] {} # No ListDirectory permissions, Get-ChildItem ignored this + + foreach ($dir_child in $dir_files) { + if ($dir_child.Attributes.HasFlag([System.IO.FileAttributes]::Directory) -and $Recurse) { + if ($Follow -or -not $dir_child.Attributes.HasFlag([System.IO.FileAttributes]::ReparsePoint)) { + $PSBoundParameters.Remove('Path') > $null + Search-Path -Path $dir_child.FullName @PSBoundParameters + } + } + + # Check to see if we've already encountered this path and skip if we have. + if (-not $CheckedPaths.Add($dir_child.FullName.ToLowerInvariant())) { + continue + } + + $Module.Result.examined++ + + if ($PSBoundParameters.ContainsKey('Age')) { + $age_match = Assert-Age -File $dir_child -Age $Age -AgeStamp $AgeStamp + } else { + $age_match = $true + } + + $file_type_match = Assert-FileType -File $dir_child -FileType $FileType + $hidden_match = Assert-FileHidden -File $dir_child -IsHidden:$IsHidden + + if ($PSBoundParameters.ContainsKey('Patterns')) { + $pattern_match = Assert-FileNamePattern -File $dir_child -Patterns $Patterns -UseRegex:$UseRegex.IsPresent + } else { + $pattern_match = $true + } + + if ($PSBoundParameters.ContainsKey('Size')) { + $size_match = Assert-FileSize -File $dir_child -Size $Size + } else { + $size_match = $true + } + + if (-not ($age_match -and $file_type_match -and $hidden_match -and $pattern_match -and $size_match)) { + continue + } + + # It passed all our filters so add it + $module.Result.matched++ + + # TODO: Make this generic so it can be shared with win_find and win_stat. + $epoch = New-Object -Type System.DateTime -ArgumentList 1970, 1, 1, 0, 0, 0, 0 + $file_info = @{ + attributes = $dir_child.Attributes.ToString() + checksum = $null + creationtime = (New-TimeSpan -Start $epoch -End $dir_child.CreationTime).TotalSeconds + exists = $true + extension = $null + filename = $dir_child.Name + isarchive = $dir_child.Attributes.HasFlag([System.IO.FileAttributes]::Archive) + isdir = $dir_child.Attributes.HasFlag([System.IO.FileAttributes]::Directory) + ishidden = $dir_child.Attributes.HasFlag([System.IO.FileAttributes]::Hidden) + isreadonly = $dir_child.Attributes.HasFlag([System.IO.FileAttributes]::ReadOnly) + isreg = $false + isshared = $false + lastaccesstime = (New-TimeSpan -Start $epoch -End $dir_child.LastAccessTime).TotalSeconds + lastwritetime = (New-TimeSpan -Start $epoch -End $dir_child.LastWriteTime).TotalSeconds + owner = $null + path = $dir_child.FullName + sharename = $null + size = $null + } + + try { + $file_info.owner = $dir_child.GetAccessControl().Owner + } catch {} # May not have rights to get the Owner, historical behaviour is to ignore. + + if ($dir_child.Attributes.HasFlag([System.IO.FileAttributes]::Directory)) { + $share_info = Get-CimInstance -ClassName Win32_Share -Filter "Path='$($dir_child.FullName -replace '\\', '\\')'" + if ($null -ne $share_info) { + $file_info.isshared = $true + $file_info.sharename = $share_info.Name + } + } else { + $file_info.extension = $dir_child.Extension + $file_info.isreg = $true + $file_info.size = $dir_child.Length + + if ($GetChecksum) { + try { + $file_info.checksum = Get-FileChecksum -Path $dir_child.FullName -Algorithm $checksum_algorithm + } catch {} # Just keep the checksum as $null in the case of a failure. + } + } + + # Append the link information if the path is a link + $link_info = @{ + isjunction = $false + islnk = $false + nlink = 1 + lnk_source = $null + lnk_target = $null + hlnk_targets = @() + } + $link_stat = Get-Link -link_path $dir_child.FullName + if ($null -ne $link_stat) { + switch ($link_stat.Type) { + "SymbolicLink" { + $link_info.islnk = $true + $link_info.isreg = $false + $link_info.lnk_source = $link_stat.AbsolutePath + $link_info.lnk_target = $link_stat.TargetPath + break + } + "JunctionPoint" { + $link_info.isjunction = $true + $link_info.isreg = $false + $link_info.lnk_source = $link_stat.AbsolutePath + $link_info.lnk_target = $link_stat.TargetPath + break + } + "HardLink" { + $link_info.nlink = $link_stat.HardTargets.Count + + # remove current path from the targets + $hlnk_targets = $link_info.HardTargets | Where-Object { $_ -ne $dir_child.FullName } + $link_info.hlnk_targets = @($hlnk_targets) + break + } + } + } + foreach ($kv in $link_info.GetEnumerator()) { + $file_info.$($kv.Key) = $kv.Value + } + + # Output the file_info object + $file_info + } +} + +$search_params = @{ + CheckedPaths = [System.Collections.Generic.HashSet`1[System.String]]@() + GetChecksum = $get_checksum + Module = $module + FileType = $file_type + Follow = $follow + IsHidden = $hidden + Recurse = $recurse +} + +if ($null -ne $age) { + $seconds_per_unit = @{'s'=1; 'm'=60; 'h'=3600; 'd'=86400; 'w'=604800} + $seconds_pattern = '^(-?\d+)(s|m|h|d|w)?$' + $match = $age -match $seconds_pattern + if ($Match) { + $specified_seconds = [Int64]$Matches[1] + if ($null -eq $Matches[2]) { + $chosen_unit = 's' + } else { + $chosen_unit = $Matches[2] + } + + $total_seconds = $specified_seconds * ($seconds_per_unit.$chosen_unit) + + if ($total_seconds -ge 0) { + $search_params.Age = (Get-Date).AddSeconds($total_seconds * -1).Ticks + } else { + # Make sure we add the positive value of seconds to current time then make it negative for later comparisons. + $age = (Get-Date).AddSeconds($total_seconds).Ticks + $search_params.Age = $age * -1 + } + $search_params.AgeStamp = $age_stamp + } else { + $module.FailJson("Invalid age pattern specified") + } +} + +if ($null -ne $patterns) { + $search_params.Patterns = $patterns + $search_params.UseRegex = $use_regex +} + +if ($null -ne $size) { + $bytes_per_unit = @{'b'=1; 'k'=1KB; 'm'=1MB; 'g'=1GB;'t'=1TB} + $size_pattern = '^(-?\d+)(b|k|m|g|t)?$' + $match = $size -match $size_pattern + if ($Match) { + $specified_size = [Int64]$Matches[1] + if ($null -eq $Matches[2]) { + $chosen_byte = 'b' + } else { + $chosen_byte = $Matches[2] + } + + $search_params.Size = $specified_size * ($bytes_per_unit.$chosen_byte) + } else { + $module.FailJson("Invalid size pattern specified") + } +} + +$matched_files = foreach ($path in $paths) { + # Ensure we pass in an absolute path. We use the ExecutionContext as this is based on the PSProvider path not the + # process location which can be different. + $abs_path = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($path) + Search-Path -Path $abs_path @search_params +} + +# Make sure we sort the files in alphabetical order. +$module.Result.files = @() + ($matched_files | Sort-Object -Property {$_.path}) + +$module.ExitJson() + diff --git a/test/support/windows-integration/plugins/modules/win_find.py b/test/support/windows-integration/plugins/modules/win_find.py new file mode 100644 index 00000000000..f506f956f29 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_find.py @@ -0,0 +1,345 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2016, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# this is a windows documentation stub. actual code lives in the .ps1 +# file of the same name + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: win_find +version_added: "2.3" +short_description: Return a list of files based on specific criteria +description: + - Return a list of files based on specified criteria. + - Multiple criteria are AND'd together. + - For non-Windows targets, use the M(find) module instead. +options: + age: + description: + - Select files or folders whose age is equal to or greater than + the specified time. + - Use a negative age to find files equal to or less than + the specified time. + - You can choose seconds, minutes, hours, days or weeks + by specifying the first letter of an of + those words (e.g., "2s", "10d", 1w"). + type: str + age_stamp: + description: + - Choose the file property against which we compare C(age). + - The default attribute we compare with is the last modification time. + type: str + choices: [ atime, ctime, mtime ] + default: mtime + checksum_algorithm: + description: + - Algorithm to determine the checksum of a file. + - Will throw an error if the host is unable to use specified algorithm. + type: str + choices: [ md5, sha1, sha256, sha384, sha512 ] + default: sha1 + file_type: + description: Type of file to search for. + type: str + choices: [ directory, file ] + default: file + follow: + description: + - Set this to C(yes) to follow symlinks in the path. + - This needs to be used in conjunction with C(recurse). + type: bool + default: no + get_checksum: + description: + - Whether to return a checksum of the file in the return info (default sha1), + use C(checksum_algorithm) to change from the default. + type: bool + default: yes + hidden: + description: Set this to include hidden files or folders. + type: bool + default: no + paths: + description: + - List of paths of directories to search for files or folders in. + - This can be supplied as a single path or a list of paths. + type: list + required: yes + patterns: + description: + - One or more (powershell or regex) patterns to compare filenames with. + - The type of pattern matching is controlled by C(use_regex) option. + - The patterns restrict the list of files or folders to be returned based on the filenames. + - For a file to be matched it only has to match with one pattern in a list provided. + type: list + aliases: [ "regex", "regexp" ] + recurse: + description: + - Will recursively descend into the directory looking for files or folders. + type: bool + default: no + size: + description: + - Select files or folders whose size is equal to or greater than the specified size. + - Use a negative value to find files equal to or less than the specified size. + - You can specify the size with a suffix of the byte type i.e. kilo = k, mega = m... + - Size is not evaluated for symbolic links. + type: str + use_regex: + description: + - Will set patterns to run as a regex check if set to C(yes). + type: bool + default: no +author: +- Jordan Borean (@jborean93) +''' + +EXAMPLES = r''' +- name: Find files in path + win_find: + paths: D:\Temp + +- name: Find hidden files in path + win_find: + paths: D:\Temp + hidden: yes + +- name: Find files in multiple paths + win_find: + paths: + - C:\Temp + - D:\Temp + +- name: Find files in directory while searching recursively + win_find: + paths: D:\Temp + recurse: yes + +- name: Find files in directory while following symlinks + win_find: + paths: D:\Temp + recurse: yes + follow: yes + +- name: Find files with .log and .out extension using powershell wildcards + win_find: + paths: D:\Temp + patterns: [ '*.log', '*.out' ] + +- name: Find files in path based on regex pattern + win_find: + paths: D:\Temp + patterns: out_\d{8}-\d{6}.log + +- name: Find files older than 1 day + win_find: + paths: D:\Temp + age: 86400 + +- name: Find files older than 1 day based on create time + win_find: + paths: D:\Temp + age: 86400 + age_stamp: ctime + +- name: Find files older than 1 day with unit syntax + win_find: + paths: D:\Temp + age: 1d + +- name: Find files newer than 1 hour + win_find: + paths: D:\Temp + age: -3600 + +- name: Find files newer than 1 hour with unit syntax + win_find: + paths: D:\Temp + age: -1h + +- name: Find files larger than 1MB + win_find: + paths: D:\Temp + size: 1048576 + +- name: Find files larger than 1GB with unit syntax + win_find: + paths: D:\Temp + size: 1g + +- name: Find files smaller than 1MB + win_find: + paths: D:\Temp + size: -1048576 + +- name: Find files smaller than 1GB with unit syntax + win_find: + paths: D:\Temp + size: -1g + +- name: Find folders/symlinks in multiple paths + win_find: + paths: + - C:\Temp + - D:\Temp + file_type: directory + +- name: Find files and return SHA256 checksum of files found + win_find: + paths: C:\Temp + get_checksum: yes + checksum_algorithm: sha256 + +- name: Find files and do not return the checksum + win_find: + paths: C:\Temp + get_checksum: no +''' + +RETURN = r''' +examined: + description: The number of files/folders that was checked. + returned: always + type: int + sample: 10 +matched: + description: The number of files/folders that match the criteria. + returned: always + type: int + sample: 2 +files: + description: Information on the files/folders that match the criteria returned as a list of dictionary elements + for each file matched. The entries are sorted by the path value alphabetically. + returned: success + type: complex + contains: + attributes: + description: attributes of the file at path in raw form. + returned: success, path exists + type: str + sample: "Archive, Hidden" + checksum: + description: The checksum of a file based on checksum_algorithm specified. + returned: success, path exists, path is a file, get_checksum == True + type: str + sample: 09cb79e8fc7453c84a07f644e441fd81623b7f98 + creationtime: + description: The create time of the file represented in seconds since epoch. + returned: success, path exists + type: float + sample: 1477984205.15 + exists: + description: Whether the file exists, will always be true for M(win_find). + returned: success, path exists + type: bool + sample: true + extension: + description: The extension of the file at path. + returned: success, path exists, path is a file + type: str + sample: ".ps1" + filename: + description: The name of the file. + returned: success, path exists + type: str + sample: temp + hlnk_targets: + description: List of other files pointing to the same file (hard links), excludes the current file. + returned: success, path exists + type: list + sample: + - C:\temp\file.txt + - C:\Windows\update.log + isarchive: + description: If the path is ready for archiving or not. + returned: success, path exists + type: bool + sample: true + isdir: + description: If the path is a directory or not. + returned: success, path exists + type: bool + sample: true + ishidden: + description: If the path is hidden or not. + returned: success, path exists + type: bool + sample: true + isjunction: + description: If the path is a junction point. + returned: success, path exists + type: bool + sample: true + islnk: + description: If the path is a symbolic link. + returned: success, path exists + type: bool + sample: true + isreadonly: + description: If the path is read only or not. + returned: success, path exists + type: bool + sample: true + isreg: + description: If the path is a regular file or not. + returned: success, path exists + type: bool + sample: true + isshared: + description: If the path is shared or not. + returned: success, path exists + type: bool + sample: true + lastaccesstime: + description: The last access time of the file represented in seconds since epoch. + returned: success, path exists + type: float + sample: 1477984205.15 + lastwritetime: + description: The last modification time of the file represented in seconds since epoch. + returned: success, path exists + type: float + sample: 1477984205.15 + lnk_source: + description: The target of the symlink normalized for the remote filesystem. + returned: success, path exists, path is a symbolic link or junction point + type: str + sample: C:\temp + lnk_target: + description: The target of the symlink. Note that relative paths remain relative, will return null if not a link. + returned: success, path exists, path is a symbolic link or junction point + type: str + sample: temp + nlink: + description: Number of links to the file (hard links) + returned: success, path exists + type: int + sample: 1 + owner: + description: The owner of the file. + returned: success, path exists + type: str + sample: BUILTIN\Administrators + path: + description: The full absolute path to the file. + returned: success, path exists + type: str + sample: BUILTIN\Administrators + sharename: + description: The name of share if folder is shared. + returned: success, path exists, path is a directory and isshared == True + type: str + sample: file-share + size: + description: The size in bytes of the file. + returned: success, path exists, path is a file + type: int + sample: 1024 +''' diff --git a/test/support/windows-integration/plugins/modules/win_format.ps1 b/test/support/windows-integration/plugins/modules/win_format.ps1 new file mode 100644 index 00000000000..b5fd3ae0380 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_format.ps1 @@ -0,0 +1,200 @@ +#!powershell + +# Copyright: (c) 2019, Varun Chopra (@chopraaa) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#AnsibleRequires -OSVersion 6.2 + +Set-StrictMode -Version 2 + +$ErrorActionPreference = "Stop" + +$spec = @{ + options = @{ + drive_letter = @{ type = "str" } + path = @{ type = "str" } + label = @{ type = "str" } + new_label = @{ type = "str" } + file_system = @{ type = "str"; choices = "ntfs", "refs", "exfat", "fat32", "fat" } + allocation_unit_size = @{ type = "int" } + large_frs = @{ type = "bool" } + full = @{ type = "bool"; default = $false } + compress = @{ type = "bool" } + integrity_streams = @{ type = "bool" } + force = @{ type = "bool"; default = $false } + } + mutually_exclusive = @( + ,@('drive_letter', 'path', 'label') + ) + required_one_of = @( + ,@('drive_letter', 'path', 'label') + ) + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$drive_letter = $module.Params.drive_letter +$path = $module.Params.path +$label = $module.Params.label +$new_label = $module.Params.new_label +$file_system = $module.Params.file_system +$allocation_unit_size = $module.Params.allocation_unit_size +$large_frs = $module.Params.large_frs +$full_format = $module.Params.full +$compress_volume = $module.Params.compress +$integrity_streams = $module.Params.integrity_streams +$force_format = $module.Params.force + +# Some pre-checks +if ($null -ne $drive_letter -and $drive_letter -notmatch "^[a-zA-Z]$") { + $module.FailJson("The parameter drive_letter should be a single character A-Z") +} +if ($integrity_streams -eq $true -and $file_system -ne "refs") { + $module.FailJson("Integrity streams can be enabled only on ReFS volumes. You specified: $($file_system)") +} +if ($compress_volume -eq $true) { + if ($file_system -eq "ntfs") { + if ($null -ne $allocation_unit_size -and $allocation_unit_size -gt 4096) { + $module.FailJson("NTFS compression is not supported for allocation unit sizes above 4096") + } + } + else { + $module.FailJson("Compression can be enabled only on NTFS volumes. You specified: $($file_system)") + } +} + +function Get-AnsibleVolume { + param( + $DriveLetter, + $Path, + $Label + ) + + if ($null -ne $DriveLetter) { + try { + $volume = Get-Volume -DriveLetter $DriveLetter + } catch { + $module.FailJson("There was an error retrieving the volume using drive_letter $($DriveLetter): $($_.Exception.Message)", $_) + } + } + elseif ($null -ne $Path) { + try { + $volume = Get-Volume -Path $Path + } catch { + $module.FailJson("There was an error retrieving the volume using path $($Path): $($_.Exception.Message)", $_) + } + } + elseif ($null -ne $Label) { + try { + $volume = Get-Volume -FileSystemLabel $Label + } catch { + $module.FailJson("There was an error retrieving the volume using label $($Label): $($_.Exception.Message)", $_) + } + } + else { + $module.FailJson("Unable to locate volume: drive_letter, path and label were not specified") + } + + return $volume +} + +function Format-AnsibleVolume { + param( + $Path, + $Label, + $FileSystem, + $Full, + $UseLargeFRS, + $Compress, + $SetIntegrityStreams, + $AllocationUnitSize + ) + $parameters = @{ + Path = $Path + Full = $Full + } + if ($null -ne $UseLargeFRS) { + $parameters.Add("UseLargeFRS", $UseLargeFRS) + } + if ($null -ne $SetIntegrityStreams) { + $parameters.Add("SetIntegrityStreams", $SetIntegrityStreams) + } + if ($null -ne $Compress){ + $parameters.Add("Compress", $Compress) + } + if ($null -ne $Label) { + $parameters.Add("NewFileSystemLabel", $Label) + } + if ($null -ne $FileSystem) { + $parameters.Add("FileSystem", $FileSystem) + } + if ($null -ne $AllocationUnitSize) { + $parameters.Add("AllocationUnitSize", $AllocationUnitSize) + } + + Format-Volume @parameters -Confirm:$false | Out-Null + +} + +$ansible_volume = Get-AnsibleVolume -DriveLetter $drive_letter -Path $path -Label $label +$ansible_file_system = $ansible_volume.FileSystem +$ansible_volume_size = $ansible_volume.Size +$ansible_volume_alu = (Get-CimInstance -ClassName Win32_Volume -Filter "DeviceId = '$($ansible_volume.path.replace('\','\\'))'" -Property BlockSize).BlockSize + +$ansible_partition = Get-Partition -Volume $ansible_volume + +if (-not $force_format -and $null -ne $allocation_unit_size -and $ansible_volume_alu -ne 0 -and $null -ne $ansible_volume_alu -and $allocation_unit_size -ne $ansible_volume_alu) { + $module.FailJson("Force format must be specified since target allocation unit size: $($allocation_unit_size) is different from the current allocation unit size of the volume: $($ansible_volume_alu)") +} + +foreach ($access_path in $ansible_partition.AccessPaths) { + if ($access_path -ne $Path) { + if ($null -ne $file_system -and + -not [string]::IsNullOrEmpty($ansible_file_system) -and + $file_system -ne $ansible_file_system) + { + if (-not $force_format) + { + $no_files_in_volume = (Get-ChildItem -LiteralPath $access_path -ErrorAction SilentlyContinue | Measure-Object).Count -eq 0 + if($no_files_in_volume) + { + $module.FailJson("Force format must be specified since target file system: $($file_system) is different from the current file system of the volume: $($ansible_file_system.ToLower())") + } + else + { + $module.FailJson("Force format must be specified to format non-pristine volumes") + } + } + } + else + { + $pristine = -not $force_format + } + } +} + +if ($force_format) { + if (-not $module.CheckMode) { + Format-AnsibleVolume -Path $ansible_volume.Path -Full $full_format -Label $new_label -FileSystem $file_system -SetIntegrityStreams $integrity_streams -UseLargeFRS $large_frs -Compress $compress_volume -AllocationUnitSize $allocation_unit_size + } + $module.Result.changed = $true +} +else { + if ($pristine) { + if ($null -eq $new_label) { + $new_label = $ansible_volume.FileSystemLabel + } + # Conditions for formatting + if ($ansible_volume_size -eq 0 -or + $ansible_volume.FileSystemLabel -ne $new_label) { + if (-not $module.CheckMode) { + Format-AnsibleVolume -Path $ansible_volume.Path -Full $full_format -Label $new_label -FileSystem $file_system -SetIntegrityStreams $integrity_streams -UseLargeFRS $large_frs -Compress $compress_volume -AllocationUnitSize $allocation_unit_size + } + $module.Result.changed = $true + } + } +} + +$module.ExitJson() diff --git a/test/support/windows-integration/plugins/modules/win_format.py b/test/support/windows-integration/plugins/modules/win_format.py new file mode 100644 index 00000000000..f8f18ed7b77 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_format.py @@ -0,0 +1,103 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2019, Varun Chopra (@chopraaa) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community' +} + +DOCUMENTATION = r''' +module: win_format +version_added: '2.8' +short_description: Formats an existing volume or a new volume on an existing partition on Windows +description: + - The M(win_format) module formats an existing volume or a new volume on an existing partition on Windows +options: + drive_letter: + description: + - Used to specify the drive letter of the volume to be formatted. + type: str + path: + description: + - Used to specify the path to the volume to be formatted. + type: str + label: + description: + - Used to specify the label of the volume to be formatted. + type: str + new_label: + description: + - Used to specify the new file system label of the formatted volume. + type: str + file_system: + description: + - Used to specify the file system to be used when formatting the target volume. + type: str + choices: [ ntfs, refs, exfat, fat32, fat ] + allocation_unit_size: + description: + - Specifies the cluster size to use when formatting the volume. + - If no cluster size is specified when you format a partition, defaults are selected based on + the size of the partition. + - This value must be a multiple of the physical sector size of the disk. + type: int + large_frs: + description: + - Specifies that large File Record System (FRS) should be used. + type: bool + compress: + description: + - Enable compression on the resulting NTFS volume. + - NTFS compression is not supported where I(allocation_unit_size) is more than 4096. + type: bool + integrity_streams: + description: + - Enable integrity streams on the resulting ReFS volume. + type: bool + full: + description: + - A full format writes to every sector of the disk, takes much longer to perform than the + default (quick) format, and is not recommended on storage that is thinly provisioned. + - Specify C(true) for full format. + type: bool + force: + description: + - Specify if formatting should be forced for volumes that are not created from new partitions + or if the source and target file system are different. + type: bool +notes: + - Microsoft Windows Server 2012 or Microsoft Windows 8 or newer is required to use this module. To check if your system is compatible, see + U(https://docs.microsoft.com/en-us/windows/desktop/sysinfo/operating-system-version). + - One of three parameters (I(drive_letter), I(path) and I(label)) are mandatory to identify the target + volume but more than one cannot be specified at the same time. + - This module is idempotent if I(force) is not specified and file system labels remain preserved. + - For more information, see U(https://docs.microsoft.com/en-us/previous-versions/windows/desktop/stormgmt/format-msft-volume) +seealso: + - module: win_disk_facts + - module: win_partition +author: + - Varun Chopra (@chopraaa) +''' + +EXAMPLES = r''' +- name: Create a partition with drive letter D and size 5 GiB + win_partition: + drive_letter: D + partition_size: 5 GiB + disk_number: 1 + +- name: Full format the newly created partition as NTFS and label it + win_format: + drive_letter: D + file_system: NTFS + new_label: Formatted + full: True +''' + +RETURN = r''' +# +''' diff --git a/test/support/windows-integration/plugins/modules/win_get_url.ps1 b/test/support/windows-integration/plugins/modules/win_get_url.ps1 new file mode 100644 index 00000000000..a4c6e13d326 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_get_url.ps1 @@ -0,0 +1,277 @@ +#!powershell + +# Copyright: (c) 2015, Paul Durivage +# Copyright: (c) 2015, Tal Auslander +# Copyright: (c) 2017, Dag Wieers +# Copyright: (c) 2019, Viktor Utkin +# Copyright: (c) 2019, Uladzimir Klybik +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module Ansible.ModuleUtils.FileUtil +#Requires -Module Ansible.ModuleUtils.WebRequest + +$spec = @{ + options = @{ + url = @{ type="str"; required=$true } + dest = @{ type='path'; required=$true } + force = @{ type='bool'; default=$true } + checksum = @{ type='str' } + checksum_algorithm = @{ type='str'; default='sha1'; choices = @("md5", "sha1", "sha256", "sha384", "sha512") } + checksum_url = @{ type='str' } + + # Defined for the alias backwards compatibility, remove once aliases are removed + url_username = @{ + aliases = @("user", "username") + deprecated_aliases = @( + @{ name = "user"; version = "2.14" }, + @{ name = "username"; version = "2.14" } + ) + } + url_password = @{ + aliases = @("password") + deprecated_aliases = @( + @{ name = "password"; version = "2.14" } + ) + } + } + mutually_exclusive = @( + ,@('checksum', 'checksum_url') + ) + supports_check_mode = $true +} +$spec = Merge-WebRequestSpec -ModuleSpec $spec + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$url = $module.Params.url +$dest = $module.Params.dest +$force = $module.Params.force +$checksum = $module.Params.checksum +$checksum_algorithm = $module.Params.checksum_algorithm +$checksum_url = $module.Params.checksum_url + +$module.Result.elapsed = 0 +$module.Result.url = $url + +Function Get-ChecksumFromUri { + param( + [Parameter(Mandatory=$true)][Ansible.Basic.AnsibleModule]$Module, + [Parameter(Mandatory=$true)][Uri]$Uri, + [Uri]$SourceUri + ) + + $script = { + param($Response, $Stream) + + $read_stream = New-Object -TypeName System.IO.StreamReader -ArgumentList $Stream + $web_checksum = $read_stream.ReadToEnd() + $basename = (Split-Path -Path $SourceUri.LocalPath -Leaf) + $basename = [regex]::Escape($basename) + $web_checksum_str = $web_checksum -split '\r?\n' | Select-String -Pattern $("\s+\.?\/?\\?" + $basename + "\s*$") + if (-not $web_checksum_str) { + $Module.FailJson("Checksum record not found for file name '$basename' in file from url: '$Uri'") + } + + $web_checksum_str_splitted = $web_checksum_str[0].ToString().split(" ", 2) + $hash_from_file = $web_checksum_str_splitted[0].Trim() + # Remove any non-alphanumeric characters + $hash_from_file = $hash_from_file -replace '\W+', '' + + Write-Output -InputObject $hash_from_file + } + $web_request = Get-AnsibleWebRequest -Uri $Uri -Module $Module + + try { + Invoke-WithWebRequest -Module $Module -Request $web_request -Script $script + } catch { + $Module.FailJson("Error when getting the remote checksum from '$Uri'. $($_.Exception.Message)", $_) + } +} + +Function Compare-ModifiedFile { + <# + .SYNOPSIS + Compares the remote URI resource against the local Dest resource. Will + return true if the LastWriteTime/LastModificationDate of the remote is + newer than the local resource date. + #> + param( + [Parameter(Mandatory=$true)][Ansible.Basic.AnsibleModule]$Module, + [Parameter(Mandatory=$true)][Uri]$Uri, + [Parameter(Mandatory=$true)][String]$Dest + ) + + $dest_last_mod = (Get-AnsibleItem -Path $Dest).LastWriteTimeUtc + + # If the URI is a file we don't need to go through the whole WebRequest + if ($Uri.IsFile) { + $src_last_mod = (Get-AnsibleItem -Path $Uri.AbsolutePath).LastWriteTimeUtc + } else { + $web_request = Get-AnsibleWebRequest -Uri $Uri -Module $Module + $web_request.Method = switch ($web_request.GetType().Name) { + FtpWebRequest { [System.Net.WebRequestMethods+Ftp]::GetDateTimestamp } + HttpWebRequest { [System.Net.WebRequestMethods+Http]::Head } + } + $script = { param($Response, $Stream); $Response.LastModified } + + try { + $src_last_mod = Invoke-WithWebRequest -Module $Module -Request $web_request -Script $script + } catch { + $Module.FailJson("Error when requesting 'Last-Modified' date from '$Uri'. $($_.Exception.Message)", $_) + } + } + + # Return $true if the Uri LastModification date is newer than the Dest LastModification date + ((Get-Date -Date $src_last_mod).ToUniversalTime() -gt $dest_last_mod) +} + +Function Get-Checksum { + param( + [Parameter(Mandatory=$true)][String]$Path, + [String]$Algorithm = "sha1" + ) + + switch ($Algorithm) { + 'md5' { $sp = New-Object -TypeName System.Security.Cryptography.MD5CryptoServiceProvider } + 'sha1' { $sp = New-Object -TypeName System.Security.Cryptography.SHA1CryptoServiceProvider } + 'sha256' { $sp = New-Object -TypeName System.Security.Cryptography.SHA256CryptoServiceProvider } + 'sha384' { $sp = New-Object -TypeName System.Security.Cryptography.SHA384CryptoServiceProvider } + 'sha512' { $sp = New-Object -TypeName System.Security.Cryptography.SHA512CryptoServiceProvider } + } + + $fs = [System.IO.File]::Open($Path, [System.IO.Filemode]::Open, [System.IO.FileAccess]::Read, + [System.IO.FileShare]::ReadWrite) + try { + $hash = [System.BitConverter]::ToString($sp.ComputeHash($fs)).Replace("-", "").ToLower() + } finally { + $fs.Dispose() + } + return $hash +} + +Function Invoke-DownloadFile { + param( + [Parameter(Mandatory=$true)][Ansible.Basic.AnsibleModule]$Module, + [Parameter(Mandatory=$true)][Uri]$Uri, + [Parameter(Mandatory=$true)][String]$Dest, + [String]$Checksum, + [String]$ChecksumAlgorithm + ) + + # Check $dest parent folder exists before attempting download, which avoids unhelpful generic error message. + $dest_parent = Split-Path -LiteralPath $Dest + if (-not (Test-Path -LiteralPath $dest_parent -PathType Container)) { + $module.FailJson("The path '$dest_parent' does not exist for destination '$Dest', or is not visible to the current user. Ensure download destination folder exists (perhaps using win_file state=directory) before win_get_url runs.") + } + + $download_script = { + param($Response, $Stream) + + # Download the file to a temporary directory so we can compare it + $tmp_dest = Join-Path -Path $Module.Tmpdir -ChildPath ([System.IO.Path]::GetRandomFileName()) + $fs = [System.IO.File]::Create($tmp_dest) + try { + $Stream.CopyTo($fs) + $fs.Flush() + } finally { + $fs.Dispose() + } + $tmp_checksum = Get-Checksum -Path $tmp_dest -Algorithm $ChecksumAlgorithm + $Module.Result.checksum_src = $tmp_checksum + + # If the checksum has been set, verify the checksum of the remote against the input checksum. + if ($Checksum -and $Checksum -ne $tmp_checksum) { + $Module.FailJson(("The checksum for {0} did not match '{1}', it was '{2}'" -f $Uri, $Checksum, $tmp_checksum)) + } + + $download = $true + if (Test-Path -LiteralPath $Dest) { + # Validate the remote checksum against the existing downloaded file + $dest_checksum = Get-Checksum -Path $Dest -Algorithm $ChecksumAlgorithm + + # If we don't need to download anything, save the dest checksum so we don't waste time calculating it + # again at the end of the script + if ($dest_checksum -eq $tmp_checksum) { + $download = $false + $Module.Result.checksum_dest = $dest_checksum + $Module.Result.size = (Get-AnsibleItem -Path $Dest).Length + } + } + + if ($download) { + Copy-Item -LiteralPath $tmp_dest -Destination $Dest -Force -WhatIf:$Module.CheckMode > $null + $Module.Result.changed = $true + } + } + $web_request = Get-AnsibleWebRequest -Uri $Uri -Module $Module + + try { + Invoke-WithWebRequest -Module $Module -Request $web_request -Script $download_script + } catch { + $Module.FailJson("Error downloading '$Uri' to '$Dest': $($_.Exception.Message)", $_) + } +} + +# Use last part of url for dest file name if a directory is supplied for $dest +if (Test-Path -LiteralPath $dest -PathType Container) { + $uri = [System.Uri]$url + $basename = Split-Path -Path $uri.LocalPath -Leaf + if ($uri.LocalPath -and $uri.LocalPath -ne '/' -and $basename) { + $url_basename = Split-Path -Path $uri.LocalPath -Leaf + $dest = Join-Path -Path $dest -ChildPath $url_basename + } else { + $dest = Join-Path -Path $dest -ChildPath $uri.Host + } + + # Ensure we have a string instead of a PS object to avoid serialization issues + $dest = $dest.ToString() +} elseif (([System.IO.Path]::GetFileName($dest)) -eq '') { + # We have a trailing path separator + $module.FailJson("The destination path '$dest' does not exist, or is not visible to the current user. Ensure download destination folder exists (perhaps using win_file state=directory) before win_get_url runs.") +} + +$module.Result.dest = $dest + +if ($checksum) { + $checksum = $checksum.Trim().ToLower() +} +if ($checksum_algorithm) { + $checksum_algorithm = $checksum_algorithm.Trim().ToLower() +} +if ($checksum_url) { + $checksum_url = $checksum_url.Trim() +} + +# Check for case $checksum variable contain url. If yes, get file data from url and replace original value in $checksum +if ($checksum_url) { + $checksum_uri = [System.Uri]$checksum_url + if ($checksum_uri.Scheme -notin @("file", "ftp", "http", "https")) { + $module.FailJson("Unsupported 'checksum_url' value for '$dest': '$checksum_url'") + } + + $checksum = Get-ChecksumFromUri -Module $Module -Uri $checksum_uri -SourceUri $url +} + +if ($force -or -not (Test-Path -LiteralPath $dest)) { + # force=yes or dest does not exist, download the file + # Note: Invoke-DownloadFile will compare the checksums internally if dest exists + Invoke-DownloadFile -Module $module -Uri $url -Dest $dest -Checksum $checksum ` + -ChecksumAlgorithm $checksum_algorithm +} else { + # force=no, we want to check the last modified dates and only download if they don't match + $is_modified = Compare-ModifiedFile -Module $module -Uri $url -Dest $dest + if ($is_modified) { + Invoke-DownloadFile -Module $module -Uri $url -Dest $dest -Checksum $checksum ` + -ChecksumAlgorithm $checksum_algorithm + } +} + +if ((-not $module.Result.ContainsKey("checksum_dest")) -and (Test-Path -LiteralPath $dest)) { + # Calculate the dest file checksum if it hasn't already been done + $module.Result.checksum_dest = Get-Checksum -Path $dest -Algorithm $checksum_algorithm + $module.Result.size = (Get-AnsibleItem -Path $dest).Length +} + +$module.ExitJson() + diff --git a/test/support/windows-integration/plugins/modules/win_get_url.py b/test/support/windows-integration/plugins/modules/win_get_url.py new file mode 100644 index 00000000000..ef5b5f970b4 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_get_url.py @@ -0,0 +1,215 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2014, Paul Durivage , and others +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# This is a windows documentation stub. actual code lives in the .ps1 +# file of the same name + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['stableinterface'], + 'supported_by': 'core'} + +DOCUMENTATION = r''' +--- +module: win_get_url +version_added: "1.7" +short_description: Downloads file from HTTP, HTTPS, or FTP to node +description: +- Downloads files from HTTP, HTTPS, or FTP to the remote server. +- The remote server I(must) have direct access to the remote resource. +- For non-Windows targets, use the M(get_url) module instead. +options: + url: + description: + - The full URL of a file to download. + type: str + required: yes + dest: + description: + - The location to save the file at the URL. + - Be sure to include a filename and extension as appropriate. + type: path + required: yes + force: + description: + - If C(yes), will download the file every time and replace the file if the contents change. If C(no), will only + download the file if it does not exist or the remote file has been + modified more recently than the local file. + - This works by sending an http HEAD request to retrieve last modified + time of the requested resource, so for this to work, the remote web + server must support HEAD requests. + type: bool + default: yes + version_added: "2.0" + checksum: + description: + - If a I(checksum) is passed to this parameter, the digest of the + destination file will be calculated after it is downloaded to ensure + its integrity and verify that the transfer completed successfully. + - This option cannot be set with I(checksum_url). + type: str + version_added: "2.8" + checksum_algorithm: + description: + - Specifies the hashing algorithm used when calculating the checksum of + the remote and destination file. + type: str + choices: + - md5 + - sha1 + - sha256 + - sha384 + - sha512 + default: sha1 + version_added: "2.8" + checksum_url: + description: + - Specifies a URL that contains the checksum values for the resource at + I(url). + - Like C(checksum), this is used to verify the integrity of the remote + transfer. + - This option cannot be set with I(checksum). + type: str + version_added: "2.8" + url_username: + description: + - The username to use for authentication. + - The aliases I(user) and I(username) are deprecated and will be removed in + Ansible 2.14. + aliases: + - user + - username + url_password: + description: + - The password for I(url_username). + - The alias I(password) is deprecated and will be removed in Ansible 2.14. + aliases: + - password + proxy_url: + version_added: "2.0" + proxy_username: + version_added: "2.0" + proxy_password: + version_added: "2.0" + headers: + version_added: "2.4" + use_proxy: + version_added: "2.4" + follow_redirects: + version_added: "2.9" + maximum_redirection: + version_added: "2.9" + client_cert: + version_added: "2.9" + client_cert_password: + version_added: "2.9" + method: + description: + - This option is not for use with C(win_get_url) and should be ignored. + version_added: "2.9" +notes: +- If your URL includes an escaped slash character (%2F) this module will convert it to a real slash. + This is a result of the behaviour of the System.Uri class as described in + L(the documentation,https://docs.microsoft.com/en-us/dotnet/framework/configure-apps/file-schema/network/schemesettings-element-uri-settings#remarks). +- Since Ansible 2.8, the module will skip reporting a change if the remote + checksum is the same as the local local even when C(force=yes). This is to + better align with M(get_url). +extends_documentation_fragment: +- url_windows +seealso: +- module: get_url +- module: uri +- module: win_uri +author: +- Paul Durivage (@angstwad) +- Takeshi Kuramochi (@tksarah) +''' + +EXAMPLES = r''' +- name: Download earthrise.jpg to specified path + win_get_url: + url: http://www.example.com/earthrise.jpg + dest: C:\Users\RandomUser\earthrise.jpg + +- name: Download earthrise.jpg to specified path only if modified + win_get_url: + url: http://www.example.com/earthrise.jpg + dest: C:\Users\RandomUser\earthrise.jpg + force: no + +- name: Download earthrise.jpg to specified path through a proxy server. + win_get_url: + url: http://www.example.com/earthrise.jpg + dest: C:\Users\RandomUser\earthrise.jpg + proxy_url: http://10.0.0.1:8080 + proxy_username: username + proxy_password: password + +- name: Download file from FTP with authentication + win_get_url: + url: ftp://server/file.txt + dest: '%TEMP%\ftp-file.txt' + url_username: ftp-user + url_password: ftp-password + +- name: Download src with sha256 checksum url + win_get_url: + url: http://www.example.com/earthrise.jpg + dest: C:\temp\earthrise.jpg + checksum_url: http://www.example.com/sha256sum.txt + checksum_algorithm: sha256 + force: True + +- name: Download src with sha256 checksum url + win_get_url: + url: http://www.example.com/earthrise.jpg + dest: C:\temp\earthrise.jpg + checksum: a97e6837f60cec6da4491bab387296bbcd72bdba + checksum_algorithm: sha1 + force: True +''' + +RETURN = r''' +dest: + description: destination file/path + returned: always + type: str + sample: C:\Users\RandomUser\earthrise.jpg +checksum_dest: + description: checksum of the file after the download + returned: success and dest has been downloaded + type: str + sample: 6e642bb8dd5c2e027bf21dd923337cbb4214f827 +checksum_src: + description: checksum of the remote resource + returned: force=yes or dest did not exist + type: str + sample: 6e642bb8dd5c2e027bf21dd923337cbb4214f827 +elapsed: + description: The elapsed seconds between the start of poll and the end of the module. + returned: always + type: float + sample: 2.1406487 +size: + description: size of the dest file + returned: success + type: int + sample: 1220 +url: + description: requested url + returned: always + type: str + sample: http://www.example.com/earthrise.jpg +msg: + description: Error message, or HTTP status message from web-server + returned: always + type: str + sample: OK +status_code: + description: HTTP status code + returned: always + type: int + sample: 200 +''' diff --git a/test/support/windows-integration/plugins/modules/win_hosts.ps1 b/test/support/windows-integration/plugins/modules/win_hosts.ps1 new file mode 100644 index 00000000000..9e617c66643 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_hosts.ps1 @@ -0,0 +1,257 @@ +#!powershell + +# Copyright: (c) 2018, Micah Hunsberger (@mhunsber) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic + +Set-StrictMode -Version 2 +$ErrorActionPreference = "Stop" + +$spec = @{ + options = @{ + state = @{ type = "str"; choices = "absent", "present"; default = "present" } + aliases = @{ type = "list"; elements = "str" } + canonical_name = @{ type = "str" } + ip_address = @{ type = "str" } + action = @{ type = "str"; choices = "add", "remove", "set"; default = "set" } + } + required_if = @(,@( "state", "present", @("canonical_name", "ip_address"))) + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$state = $module.Params.state +$aliases = $module.Params.aliases +$canonical_name = $module.Params.canonical_name +$ip_address = $module.Params.ip_address +$action = $module.Params.action + +$tmp = [ipaddress]::None +if($ip_address -and -not [ipaddress]::TryParse($ip_address, [ref]$tmp)){ + $module.FailJson("win_hosts: Argument ip_address needs to be a valid ip address, but was $ip_address") +} +$ip_address_type = $tmp.AddressFamily + +$hosts_file = Get-Item -LiteralPath "$env:SystemRoot\System32\drivers\etc\hosts" + +Function Get-CommentIndex($line) { + $c_index = $line.IndexOf('#') + if($c_index -lt 0) { + $c_index = $line.Length + } + return $c_index +} + +Function Get-HostEntryParts($line) { + $success = $true + $c_index = Get-CommentIndex -line $line + $pure_line = $line.Substring(0,$c_index).Trim() + $bits = $pure_line -split "\s+" + if($bits.Length -lt 2){ + return @{ + success = $false + ip_address = "" + ip_type = "" + canonical_name = "" + aliases = @() + } + } + $ip_obj = [ipaddress]::None + if(-not [ipaddress]::TryParse($bits[0], [ref]$ip_obj) ){ + $success = $false + } + $cname = $bits[1] + $als = New-Object string[] ($bits.Length - 2) + [array]::Copy($bits, 2, $als, 0, $als.Length) + return @{ + success = $success + ip_address = $ip_obj.IPAddressToString + ip_type = $ip_obj.AddressFamily + canonical_name = $cname + aliases = $als + } +} + +Function Find-HostName($line, $name) { + $c_idx = Get-CommentIndex -line $line + $re = New-Object regex ("\s+$($name.Replace('.',"\."))(\s|$)", [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + $match = $re.Match($line, 0, $c_idx) + return $match +} + +Function Remove-HostEntry($list, $idx) { + $module.Result.changed = $true + $list.RemoveAt($idx) +} + +Function Add-HostEntry($list, $cname, $aliases, $ip) { + $module.Result.changed = $true + $line = "$ip $cname $($aliases -join ' ')" + $list.Add($line) | Out-Null +} + +Function Remove-HostnamesFromEntry($list, $idx, $aliases) { + $line = $list[$idx] + $line_removed = $false + + foreach($name in $aliases){ + $match = Find-HostName -line $line -name $name + if($match.Success){ + $line = $line.Remove($match.Index + 1, $match.Length -1) + # was this the last alias? (check for space characters after trimming) + if($line.Substring(0,(Get-CommentIndex -line $line)).Trim() -inotmatch "\s") { + $list.RemoveAt($idx) + $line_removed = $true + # we're done + return @{ + line_removed = $line_removed + } + } + } + } + if($line -ne $list[$idx]){ + $module.Result.changed = $true + $list[$idx] = $line + } + return @{ + line_removed = $line_removed + } +} + +Function Add-AliasesToEntry($list, $idx, $aliases) { + $line = $list[$idx] + foreach($name in $aliases){ + $match = Find-HostName -line $line -name $name + if(-not $match.Success) { + # just add the alias before the comment + $line = $line.Insert((Get-CommentIndex -line $line), " $name ") + } + } + if($line -ne $list[$idx]){ + $module.Result.changed = $true + $list[$idx] = $line + } +} + +$hosts_lines = New-Object System.Collections.ArrayList + +Get-Content -LiteralPath $hosts_file.FullName | ForEach-Object { $hosts_lines.Add($_) } | Out-Null +$module.Diff.before = ($hosts_lines -join "`n") + "`n" + +if ($state -eq 'absent') { + # go through and remove canonical_name and ip + for($idx = 0; $idx -lt $hosts_lines.Count; $idx++) { + $entry = $hosts_lines[$idx] + # skip comment lines + if(-not $entry.Trim().StartsWith('#')) { + $entry_parts = Get-HostEntryParts -line $entry + if($entry_parts.success) { + if(-not $ip_address -or $entry_parts.ip_address -eq $ip_address) { + if(-not $canonical_name -or $entry_parts.canonical_name -eq $canonical_name) { + if(Remove-HostEntry -list $hosts_lines -idx $idx){ + # keep index correct if we removed the line + $idx = $idx - 1 + } + } + } + } + } + } +} +if($state -eq 'present') { + $entry_idx = -1 + $aliases_to_keep = @() + # go through lines, find the entry and determine what to remove based on action + for($idx = 0; $idx -lt $hosts_lines.Count; $idx++) { + $entry = $hosts_lines[$idx] + # skip comment lines + if(-not $entry.Trim().StartsWith('#')) { + $entry_parts = Get-HostEntryParts -line $entry + if($entry_parts.success) { + $aliases_to_remove = @() + if($entry_parts.ip_address -eq $ip_address) { + if($entry_parts.canonical_name -eq $canonical_name) { + $entry_idx = $idx + + if($action -eq 'set') { + $aliases_to_remove = $entry_parts.aliases | Where-Object { $aliases -notcontains $_ } + } elseif($action -eq 'remove') { + $aliases_to_remove = $aliases + } + } else { + # this is the right ip_address, but not the cname we were looking for. + # we need to make sure none of aliases or canonical_name exist for this entry + # since the given canonical_name should be an A/AAAA record, + # and aliases should be cname records for the canonical_name. + $aliases_to_remove = $aliases + $canonical_name + } + } else { + # this is not the ip_address we are looking for + if ($ip_address_type -eq $entry_parts.ip_type) { + if ($entry_parts.canonical_name -eq $canonical_name) { + Remove-HostEntry -list $hosts_lines -idx $idx + $idx = $idx - 1 + if ($action -ne "set") { + # keep old aliases intact + $aliases_to_keep += $entry_parts.aliases | Where-Object { ($aliases + $aliases_to_keep + $canonical_name) -notcontains $_ } + } + } elseif ($action -eq "remove") { + $aliases_to_remove = $canonical_name + } elseif ($aliases -contains $entry_parts.canonical_name) { + Remove-HostEntry -list $hosts_lines -idx $idx + $idx = $idx - 1 + if ($action -eq "add") { + # keep old aliases intact + $aliases_to_keep += $entry_parts.aliases | Where-Object { ($aliases + $aliases_to_keep + $canonical_name) -notcontains $_ } + } + } else { + $aliases_to_remove = $aliases + $canonical_name + } + } else { + # TODO: Better ipv6 support. There is odd behavior for when an alias can be used for both ipv6 and ipv4 + } + } + + if($aliases_to_remove) { + if((Remove-HostnamesFromEntry -list $hosts_lines -idx $idx -aliases $aliases_to_remove).line_removed) { + $idx = $idx - 1 + } + } + } + } + } + + if($entry_idx -ge 0) { + $aliases_to_add = @() + $entry_parts = Get-HostEntryParts -line $hosts_lines[$entry_idx] + if($action -eq 'remove') { + $aliases_to_add = $aliases_to_keep | Where-Object { $entry_parts.aliases -notcontains $_ } + } else { + $aliases_to_add = ($aliases + $aliases_to_keep) | Where-Object { $entry_parts.aliases -notcontains $_ } + } + + if($aliases_to_add) { + Add-AliasesToEntry -list $hosts_lines -idx $entry_idx -aliases $aliases_to_add + } + } else { + # add the entry at the end + if($action -eq 'remove') { + if($aliases_to_keep) { + Add-HostEntry -list $hosts_lines -ip $ip_address -cname $canonical_name -aliases $aliases_to_keep + } else { + Add-HostEntry -list $hosts_lines -ip $ip_address -cname $canonical_name + } + } else { + Add-HostEntry -list $hosts_lines -ip $ip_address -cname $canonical_name -aliases ($aliases + $aliases_to_keep) + } + } +} + +$module.Diff.after = ($hosts_lines -join "`n") + "`n" +if( $module.Result.changed -and -not $module.CheckMode ) { + Set-Content -LiteralPath $hosts_file.FullName -Value $hosts_lines +} + +$module.ExitJson() diff --git a/test/support/windows-integration/plugins/modules/win_hosts.py b/test/support/windows-integration/plugins/modules/win_hosts.py new file mode 100644 index 00000000000..9fd2d1d10d2 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_hosts.py @@ -0,0 +1,126 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Micah Hunsberger (@mhunsber) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# this is a windows documentation stub. actual code lives in the .ps1 +# file of the same name + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: win_hosts +version_added: '2.8' +short_description: Manages hosts file entries on Windows. +description: + - Manages hosts file entries on Windows. + - Maps IPv4 or IPv6 addresses to canonical names. + - Adds, removes, or sets cname records for ip and hostname pairs. + - Modifies %windir%\\system32\\drivers\\etc\\hosts. +options: + state: + description: + - Whether the entry should be present or absent. + - If only I(canonical_name) is provided when C(state=absent), then + all hosts entries with the canonical name of I(canonical_name) + will be removed. + - If only I(ip_address) is provided when C(state=absent), then all + hosts entries with the ip address of I(ip_address) will be removed. + - If I(ip_address) and I(canonical_name) are both omitted when + C(state=absent), then all hosts entries will be removed. + choices: + - absent + - present + default: present + type: str + canonical_name: + description: + - A canonical name for the host entry. + - required for C(state=present). + type: str + ip_address: + description: + - The ip address for the host entry. + - Can be either IPv4 (A record) or IPv6 (AAAA record). + - Required for C(state=present). + type: str + aliases: + description: + - A list of additional names (cname records) for the host entry. + - Only applicable when C(state=present). + type: list + action: + choices: + - add + - remove + - set + description: + - Controls the behavior of I(aliases). + - Only applicable when C(state=present). + - If C(add), each alias in I(aliases) will be added to the host entry. + - If C(set), each alias in I(aliases) will be added to the host entry, + and other aliases will be removed from the entry. + default: set + type: str +author: + - Micah Hunsberger (@mhunsber) +notes: + - Each canonical name can only be mapped to one IPv4 and one IPv6 address. + If I(canonical_name) is provided with C(state=present) and is found + to be mapped to another IP address that is the same type as, but unique + from I(ip_address), then I(canonical_name) and all I(aliases) will + be removed from the entry and added to an entry with the provided IP address. + - Each alias can only be mapped to one canonical name. If I(aliases) is provided + with C(state=present) and an alias is found to be mapped to another canonical + name, then the alias will be removed from the entry and either added to or removed + from (depending on I(action)) an entry with the provided canonical name. +seealso: + - module: win_template + - module: win_file + - module: win_copy +''' + +EXAMPLES = r''' +- name: Add 127.0.0.1 as an A record for localhost + win_hosts: + state: present + canonical_name: localhost + ip_address: 127.0.0.1 + +- name: Add ::1 as an AAAA record for localhost + win_hosts: + state: present + canonical_name: localhost + ip_address: '::1' + +- name: Remove 'bar' and 'zed' from the list of aliases for foo (192.168.1.100) + win_hosts: + state: present + canoncial_name: foo + ip_address: 192.168.1.100 + action: remove + aliases: + - bar + - zed + +- name: Remove hosts entries with canonical name 'bar' + win_hosts: + state: absent + canonical_name: bar + +- name: Remove 10.2.0.1 from the list of hosts + win_hosts: + state: absent + ip_address: 10.2.0.1 + +- name: Ensure all name resolution is handled by DNS + win_hosts: + state: absent +''' + +RETURN = r''' +''' diff --git a/test/support/windows-integration/plugins/modules/win_lineinfile.ps1 b/test/support/windows-integration/plugins/modules/win_lineinfile.ps1 new file mode 100644 index 00000000000..38dd8b8bc09 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_lineinfile.ps1 @@ -0,0 +1,450 @@ +#!powershell + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy +#Requires -Module Ansible.ModuleUtils.Backup + +function WriteLines($outlines, $path, $linesep, $encodingobj, $validate, $check_mode) { + Try { + $temppath = [System.IO.Path]::GetTempFileName(); + } + Catch { + Fail-Json @{} "Cannot create temporary file! ($($_.Exception.Message))"; + } + $joined = $outlines -join $linesep; + [System.IO.File]::WriteAllText($temppath, $joined, $encodingobj); + + If ($validate) { + + If (-not ($validate -like "*%s*")) { + Fail-Json @{} "validate must contain %s: $validate"; + } + + $validate = $validate.Replace("%s", $temppath); + + $parts = [System.Collections.ArrayList] $validate.Split(" "); + $cmdname = $parts[0]; + + $cmdargs = $validate.Substring($cmdname.Length + 1); + + $process = [Diagnostics.Process]::Start($cmdname, $cmdargs); + $process.WaitForExit(); + + If ($process.ExitCode -ne 0) { + [string] $output = $process.StandardOutput.ReadToEnd(); + [string] $error = $process.StandardError.ReadToEnd(); + Remove-Item $temppath -force; + Fail-Json @{} "failed to validate $cmdname $cmdargs with error: $output $error"; + } + + } + + # Commit changes to the path + $cleanpath = $path.Replace("/", "\"); + Try { + Copy-Item -Path $temppath -Destination $cleanpath -Force -WhatIf:$check_mode; + } + Catch { + Fail-Json @{} "Cannot write to: $cleanpath ($($_.Exception.Message))"; + } + + Try { + Remove-Item -Path $temppath -Force -WhatIf:$check_mode; + } + Catch { + Fail-Json @{} "Cannot remove temporary file: $temppath ($($_.Exception.Message))"; + } + + return $joined; + +} + + +# Implement the functionality for state == 'present' +function Present($path, $regex, $line, $insertafter, $insertbefore, $create, $backup, $backrefs, $validate, $encodingobj, $linesep, $check_mode, $diff_support) { + + # Note that we have to clean up the path because ansible wants to treat / and \ as + # interchangeable in windows pathnames, but .NET framework internals do not support that. + $cleanpath = $path.Replace("/", "\"); + + # Check if path exists. If it does not exist, either create it if create == "yes" + # was specified or fail with a reasonable error message. + If (-not (Test-Path -LiteralPath $path)) { + If (-not $create) { + Fail-Json @{} "Path $path does not exist !"; + } + # Create new empty file, using the specified encoding to write correct BOM + [System.IO.File]::WriteAllLines($cleanpath, "", $encodingobj); + } + + # Initialize result information + $result = @{ + backup = ""; + changed = $false; + msg = ""; + } + + # Read the dest file lines using the indicated encoding into a mutable ArrayList. + $before = [System.IO.File]::ReadAllLines($cleanpath, $encodingobj) + If ($null -eq $before) { + $lines = New-Object System.Collections.ArrayList; + } + Else { + $lines = [System.Collections.ArrayList] $before; + } + + if ($diff_support) { + $result.diff = @{ + before = $before -join $linesep; + } + } + + # Compile the regex specified, if provided + $mre = $null; + If ($regex) { + $mre = New-Object Regex $regex, 'Compiled'; + } + + # Compile the regex for insertafter or insertbefore, if provided + $insre = $null; + If ($insertafter -and $insertafter -ne "BOF" -and $insertafter -ne "EOF") { + $insre = New-Object Regex $insertafter, 'Compiled'; + } + ElseIf ($insertbefore -and $insertbefore -ne "BOF") { + $insre = New-Object Regex $insertbefore, 'Compiled'; + } + + # index[0] is the line num where regex has been found + # index[1] is the line num where insertafter/insertbefore has been found + $index = -1, -1; + $lineno = 0; + + # The latest match object and matched line + $matched_line = ""; + + # Iterate through the lines in the file looking for matches + Foreach ($cur_line in $lines) { + If ($regex) { + $m = $mre.Match($cur_line); + $match_found = $m.Success; + If ($match_found) { + $matched_line = $cur_line; + } + } + Else { + $match_found = $line -ceq $cur_line; + } + If ($match_found) { + $index[0] = $lineno; + } + ElseIf ($insre -and $insre.Match($cur_line).Success) { + If ($insertafter) { + $index[1] = $lineno + 1; + } + If ($insertbefore) { + $index[1] = $lineno; + } + } + $lineno = $lineno + 1; + } + + If ($index[0] -ne -1) { + If ($backrefs) { + $new_line = [regex]::Replace($matched_line, $regex, $line); + } + Else { + $new_line = $line; + } + If ($lines[$index[0]] -cne $new_line) { + $lines[$index[0]] = $new_line; + $result.changed = $true; + $result.msg = "line replaced"; + } + } + ElseIf ($backrefs) { + # No matches - no-op + } + ElseIf ($insertbefore -eq "BOF" -or $insertafter -eq "BOF") { + $lines.Insert(0, $line); + $result.changed = $true; + $result.msg = "line added"; + } + ElseIf ($insertafter -eq "EOF" -or $index[1] -eq -1) { + $lines.Add($line) > $null; + $result.changed = $true; + $result.msg = "line added"; + } + Else { + $lines.Insert($index[1], $line); + $result.changed = $true; + $result.msg = "line added"; + } + + # Write changes to the path if changes were made + If ($result.changed) { + + # Write backup file if backup == "yes" + If ($backup) { + $result.backup_file = Backup-File -path $path -WhatIf:$check_mode + # Ensure backward compatibility (deprecate in future) + $result.backup = $result.backup_file + } + + $writelines_params = @{ + outlines = $lines + path = $path + linesep = $linesep + encodingobj = $encodingobj + validate = $validate + check_mode = $check_mode + } + $after = WriteLines @writelines_params; + + if ($diff_support) { + $result.diff.after = $after; + } + } + + $result.encoding = $encodingobj.WebName; + + Exit-Json $result; +} + + +# Implement the functionality for state == 'absent' +function Absent($path, $regex, $line, $backup, $validate, $encodingobj, $linesep, $check_mode, $diff_support) { + + # Check if path exists. If it does not exist, fail with a reasonable error message. + If (-not (Test-Path -LiteralPath $path)) { + Fail-Json @{} "Path $path does not exist !"; + } + + # Initialize result information + $result = @{ + backup = ""; + changed = $false; + msg = ""; + } + + # Read the dest file lines using the indicated encoding into a mutable ArrayList. Note + # that we have to clean up the path because ansible wants to treat / and \ as + # interchangeable in windows pathnames, but .NET framework internals do not support that. + $cleanpath = $path.Replace("/", "\"); + $before = [System.IO.File]::ReadAllLines($cleanpath, $encodingobj); + If ($null -eq $before) { + $lines = New-Object System.Collections.ArrayList; + } + Else { + $lines = [System.Collections.ArrayList] $before; + } + + if ($diff_support) { + $result.diff = @{ + before = $before -join $linesep; + } + } + + # Compile the regex specified, if provided + $cre = $null; + If ($regex) { + $cre = New-Object Regex $regex, 'Compiled'; + } + + $found = New-Object System.Collections.ArrayList; + $left = New-Object System.Collections.ArrayList; + + Foreach ($cur_line in $lines) { + If ($regex) { + $m = $cre.Match($cur_line); + $match_found = $m.Success; + } + Else { + $match_found = $line -ceq $cur_line; + } + If ($match_found) { + $found.Add($cur_line) > $null; + $result.changed = $true; + } + Else { + $left.Add($cur_line) > $null; + } + } + + # Write changes to the path if changes were made + If ($result.changed) { + + # Write backup file if backup == "yes" + If ($backup) { + $result.backup_file = Backup-File -path $path -WhatIf:$check_mode + # Ensure backward compatibility (deprecate in future) + $result.backup = $result.backup_file + } + + $writelines_params = @{ + outlines = $left + path = $path + linesep = $linesep + encodingobj = $encodingobj + validate = $validate + check_mode = $check_mode + } + $after = WriteLines @writelines_params; + + if ($diff_support) { + $result.diff.after = $after; + } + } + + $result.encoding = $encodingobj.WebName; + $result.found = $found.Count; + $result.msg = "$($found.Count) line(s) removed"; + + Exit-Json $result; +} + + +# Parse the parameters file dropped by the Ansible machinery +$params = Parse-Args $args -supports_check_mode $true; +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false; +$diff_support = Get-AnsibleParam -obj $params -name "_ansible_diff" -type "bool" -default $false; + +# Initialize defaults for input parameters. +$path = Get-AnsibleParam -obj $params -name "path" -type "path" -failifempty $true -aliases "dest","destfile","name"; +$regex = Get-AnsibleParam -obj $params -name "regex" -type "str" -aliases "regexp"; +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "present" -validateset "present","absent"; +$line = Get-AnsibleParam -obj $params -name "line" -type "str"; +$backrefs = Get-AnsibleParam -obj $params -name "backrefs" -type "bool" -default $false; +$insertafter = Get-AnsibleParam -obj $params -name "insertafter" -type "str"; +$insertbefore = Get-AnsibleParam -obj $params -name "insertbefore" -type "str"; +$create = Get-AnsibleParam -obj $params -name "create" -type "bool" -default $false; +$backup = Get-AnsibleParam -obj $params -name "backup" -type "bool" -default $false; +$validate = Get-AnsibleParam -obj $params -name "validate" -type "str"; +$encoding = Get-AnsibleParam -obj $params -name "encoding" -type "str" -default "auto"; +$newline = Get-AnsibleParam -obj $params -name "newline" -type "str" -default "windows" -validateset "unix","windows"; + +# Fail if the path is not a file +If (Test-Path -LiteralPath $path -PathType "container") { + Fail-Json @{} "Path $path is a directory"; +} + +# Default to windows line separator - probably most common +$linesep = "`r`n" +If ($newline -eq "unix") { + $linesep = "`n"; +} + +# Figure out the proper encoding to use for reading / writing the target file. + +# The default encoding is UTF-8 without BOM +$encodingobj = [System.Text.UTF8Encoding] $false; + +# If an explicit encoding is specified, use that instead +If ($encoding -ne "auto") { + $encodingobj = [System.Text.Encoding]::GetEncoding($encoding); +} + +# Otherwise see if we can determine the current encoding of the target file. +# If the file doesn't exist yet (create == 'yes') we use the default or +# explicitly specified encoding set above. +ElseIf (Test-Path -LiteralPath $path) { + + # Get a sorted list of encodings with preambles, longest first + $max_preamble_len = 0; + $sortedlist = New-Object System.Collections.SortedList; + Foreach ($encodinginfo in [System.Text.Encoding]::GetEncodings()) { + $encoding = $encodinginfo.GetEncoding(); + $plen = $encoding.GetPreamble().Length; + If ($plen -gt $max_preamble_len) { + $max_preamble_len = $plen; + } + If ($plen -gt 0) { + $sortedlist.Add(-($plen * 1000000 + $encoding.CodePage), $encoding) > $null; + } + } + + # Get the first N bytes from the file, where N is the max preamble length we saw + [Byte[]]$bom = Get-Content -Encoding Byte -ReadCount $max_preamble_len -TotalCount $max_preamble_len -LiteralPath $path; + + # Iterate through the sorted encodings, looking for a full match. + $found = $false; + Foreach ($encoding in $sortedlist.GetValueList()) { + $preamble = $encoding.GetPreamble(); + If ($preamble -and $bom) { + Foreach ($i in 0..($preamble.Length - 1)) { + If ($i -ge $bom.Length) { + break; + } + If ($preamble[$i] -ne $bom[$i]) { + break; + } + ElseIf ($i + 1 -eq $preamble.Length) { + $encodingobj = $encoding; + $found = $true; + } + } + If ($found) { + break; + } + } + } +} + + +# Main dispatch - based on the value of 'state', perform argument validation and +# call the appropriate handler function. +If ($state -eq "present") { + + If ($backrefs -and -not $regex) { + Fail-Json @{} "regexp= is required with backrefs=true"; + } + + If (-not $line) { + Fail-Json @{} "line= is required with state=present"; + } + + If ($insertbefore -and $insertafter) { + Add-Warning $result "Both insertbefore and insertafter parameters found, ignoring `"insertafter=$insertafter`"" + } + + If (-not $insertbefore -and -not $insertafter) { + $insertafter = "EOF"; + } + + $present_params = @{ + path = $path + regex = $regex + line = $line + insertafter = $insertafter + insertbefore = $insertbefore + create = $create + backup = $backup + backrefs = $backrefs + validate = $validate + encodingobj = $encodingobj + linesep = $linesep + check_mode = $check_mode + diff_support = $diff_support + } + Present @present_params; + +} +ElseIf ($state -eq "absent") { + + If (-not $regex -and -not $line) { + Fail-Json @{} "one of line= or regexp= is required with state=absent"; + } + + $absent_params = @{ + path = $path + regex = $regex + line = $line + backup = $backup + validate = $validate + encodingobj = $encodingobj + linesep = $linesep + check_mode = $check_mode + diff_support = $diff_support + } + Absent @absent_params; +} diff --git a/test/support/windows-integration/plugins/modules/win_lineinfile.py b/test/support/windows-integration/plugins/modules/win_lineinfile.py new file mode 100644 index 00000000000..f4fb7f5afa7 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_lineinfile.py @@ -0,0 +1,180 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: win_lineinfile +short_description: Ensure a particular line is in a file, or replace an existing line using a back-referenced regular expression +description: + - This module will search a file for a line, and ensure that it is present or absent. + - This is primarily useful when you want to change a single line in a file only. +version_added: "2.0" +options: + path: + description: + - The path of the file to modify. + - Note that the Windows path delimiter C(\) must be escaped as C(\\) when the line is double quoted. + - Before Ansible 2.3 this option was only usable as I(dest), I(destfile) and I(name). + type: path + required: yes + aliases: [ dest, destfile, name ] + backup: + description: + - Determine whether a backup should be created. + - When set to C(yes), create a backup file including the timestamp information + so you can get the original file back if you somehow clobbered it incorrectly. + type: bool + default: no + regex: + description: + - The regular expression to look for in every line of the file. For C(state=present), the pattern to replace if found; only the last line found + will be replaced. For C(state=absent), the pattern of the line to remove. Uses .NET compatible regular expressions; + see U(https://msdn.microsoft.com/en-us/library/hs600312%28v=vs.110%29.aspx). + aliases: [ "regexp" ] + state: + description: + - Whether the line should be there or not. + type: str + choices: [ absent, present ] + default: present + line: + description: + - Required for C(state=present). The line to insert/replace into the file. If C(backrefs) is set, may contain backreferences that will get + expanded with the C(regexp) capture groups if the regexp matches. + - Be aware that the line is processed first on the controller and thus is dependent on yaml quoting rules. Any double quoted line + will have control characters, such as '\r\n', expanded. To print such characters literally, use single or no quotes. + type: str + backrefs: + description: + - Used with C(state=present). If set, line can contain backreferences (both positional and named) that will get populated if the C(regexp) + matches. This flag changes the operation of the module slightly; C(insertbefore) and C(insertafter) will be ignored, and if the C(regexp) + doesn't match anywhere in the file, the file will be left unchanged. + - If the C(regexp) does match, the last matching line will be replaced by the expanded line parameter. + type: bool + default: no + insertafter: + description: + - Used with C(state=present). If specified, the line will be inserted after the last match of specified regular expression. A special value is + available; C(EOF) for inserting the line at the end of the file. + - If specified regular expression has no matches, EOF will be used instead. May not be used with C(backrefs). + type: str + choices: [ EOF, '*regex*' ] + default: EOF + insertbefore: + description: + - Used with C(state=present). If specified, the line will be inserted before the last match of specified regular expression. A value is available; + C(BOF) for inserting the line at the beginning of the file. + - If specified regular expression has no matches, the line will be inserted at the end of the file. May not be used with C(backrefs). + type: str + choices: [ BOF, '*regex*' ] + create: + description: + - Used with C(state=present). If specified, the file will be created if it does not already exist. By default it will fail if the file is missing. + type: bool + default: no + validate: + description: + - Validation to run before copying into place. Use %s in the command to indicate the current file to validate. + - The command is passed securely so shell features like expansion and pipes won't work. + type: str + encoding: + description: + - Specifies the encoding of the source text file to operate on (and thus what the output encoding will be). The default of C(auto) will cause + the module to auto-detect the encoding of the source file and ensure that the modified file is written with the same encoding. + - An explicit encoding can be passed as a string that is a valid value to pass to the .NET framework System.Text.Encoding.GetEncoding() method - + see U(https://msdn.microsoft.com/en-us/library/system.text.encoding%28v=vs.110%29.aspx). + - This is mostly useful with C(create=yes) if you want to create a new file with a specific encoding. If C(create=yes) is specified without a + specific encoding, the default encoding (UTF-8, no BOM) will be used. + type: str + default: auto + newline: + description: + - Specifies the line separator style to use for the modified file. This defaults to the windows line separator (C(\r\n)). Note that the indicated + line separator will be used for file output regardless of the original line separator that appears in the input file. + type: str + choices: [ unix, windows ] + default: windows +notes: + - As of Ansible 2.3, the I(dest) option has been changed to I(path) as default, but I(dest) still works as well. +seealso: +- module: assemble +- module: lineinfile +author: +- Brian Lloyd (@brianlloyd) +''' + +EXAMPLES = r''' +# Before Ansible 2.3, option 'dest', 'destfile' or 'name' was used instead of 'path' +- name: Insert path without converting \r\n + win_lineinfile: + path: c:\file.txt + line: c:\return\new + +- win_lineinfile: + path: C:\Temp\example.conf + regex: '^name=' + line: 'name=JohnDoe' + +- win_lineinfile: + path: C:\Temp\example.conf + regex: '^name=' + state: absent + +- win_lineinfile: + path: C:\Temp\example.conf + regex: '^127\.0\.0\.1' + line: '127.0.0.1 localhost' + +- win_lineinfile: + path: C:\Temp\httpd.conf + regex: '^Listen ' + insertafter: '^#Listen ' + line: Listen 8080 + +- win_lineinfile: + path: C:\Temp\services + regex: '^# port for http' + insertbefore: '^www.*80/tcp' + line: '# port for http by default' + +- name: Create file if it doesn't exist with a specific encoding + win_lineinfile: + path: C:\Temp\utf16.txt + create: yes + encoding: utf-16 + line: This is a utf-16 encoded file + +- name: Add a line to a file and ensure the resulting file uses unix line separators + win_lineinfile: + path: C:\Temp\testfile.txt + line: Line added to file + newline: unix + +- name: Update a line using backrefs + win_lineinfile: + path: C:\Temp\example.conf + backrefs: yes + regex: '(^name=)' + line: '$1JohnDoe' +''' + +RETURN = r''' +backup: + description: + - Name of the backup file that was created. + - This is now deprecated, use C(backup_file) instead. + returned: if backup=yes + type: str + sample: C:\Path\To\File.txt.11540.20150212-220915.bak +backup_file: + description: Name of the backup file that was created. + returned: if backup=yes + type: str + sample: C:\Path\To\File.txt.11540.20150212-220915.bak +''' diff --git a/test/support/windows-integration/plugins/modules/win_path.ps1 b/test/support/windows-integration/plugins/modules/win_path.ps1 new file mode 100644 index 00000000000..04eb41a3748 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_path.ps1 @@ -0,0 +1,145 @@ +#!powershell + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +Set-StrictMode -Version 2 +$ErrorActionPreference = "Stop" + +$system_path = "System\CurrentControlSet\Control\Session Manager\Environment" +$user_path = "Environment" + +# list/arraylist methods don't allow IEqualityComparer override for case/backslash/quote-insensitivity, roll our own search +Function Get-IndexOfPathElement ($list, [string]$value) { + $idx = 0 + $value = $value.Trim('"').Trim('\') + ForEach($el in $list) { + If ([string]$el.Trim('"').Trim('\') -ieq $value) { + return $idx + } + + $idx++ + } + + return -1 +} + +# alters list in place, returns true if at least one element was added +Function Add-Elements ($existing_elements, $elements_to_add) { + $last_idx = -1 + $changed = $false + + ForEach($el in $elements_to_add) { + $idx = Get-IndexOfPathElement $existing_elements $el + + # add missing elements at the end + If ($idx -eq -1) { + $last_idx = $existing_elements.Add($el) + $changed = $true + } + ElseIf ($idx -lt $last_idx) { + $existing_elements.RemoveAt($idx) | Out-Null + $existing_elements.Add($el) | Out-Null + $last_idx = $existing_elements.Count - 1 + $changed = $true + } + Else { + $last_idx = $idx + } + } + + return $changed +} + +# alters list in place, returns true if at least one element was removed +Function Remove-Elements ($existing_elements, $elements_to_remove) { + $count = $existing_elements.Count + + ForEach($el in $elements_to_remove) { + $idx = Get-IndexOfPathElement $existing_elements $el + $result.removed_idx = $idx + If ($idx -gt -1) { + $existing_elements.RemoveAt($idx) + } + } + + return $count -ne $existing_elements.Count +} + +# PS registry provider doesn't allow access to unexpanded REG_EXPAND_SZ; fall back to .NET +Function Get-RawPathVar ($scope) { + If ($scope -eq "user") { + $env_key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($user_path) + } + ElseIf ($scope -eq "machine") { + $env_key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey($system_path) + } + + return $env_key.GetValue($var_name, "", [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) +} + +Function Set-RawPathVar($path_value, $scope) { + If ($scope -eq "user") { + $var_path = "HKCU:\" + $user_path + } + ElseIf ($scope -eq "machine") { + $var_path = "HKLM:\" + $system_path + } + + Set-ItemProperty $var_path -Name $var_name -Value $path_value -Type ExpandString | Out-Null + + return $path_value +} + +$parsed_args = Parse-Args $args -supports_check_mode $true + +$result = @{changed=$false} + +$var_name = Get-AnsibleParam $parsed_args "name" -Default "PATH" +$elements = Get-AnsibleParam $parsed_args "elements" -FailIfEmpty $result +$state = Get-AnsibleParam $parsed_args "state" -Default "present" -ValidateSet "present","absent" +$scope = Get-AnsibleParam $parsed_args "scope" -Default "machine" -ValidateSet "machine","user" + +$check_mode = Get-AnsibleParam $parsed_args "_ansible_check_mode" -Default $false + +If ($elements -is [string]) { + $elements = @($elements) +} + +If ($elements -isnot [Array]) { + Fail-Json $result "elements must be a string or list of path strings" +} + +$current_value = Get-RawPathVar $scope +$result.path_value = $current_value + +# TODO: test case-canonicalization on wacky unicode values (eg turkish i) +# TODO: detect and warn/fail on unparseable path? (eg, unbalanced quotes, invalid path chars) +# TODO: detect and warn/fail if system path and Powershell isn't on it? + +$existing_elements = New-Object System.Collections.ArrayList + +# split on semicolons, accounting for quoted values with embedded semicolons (which may or may not be wrapped in whitespace) +$pathsplit_re = [regex] '((?\s*"[^"]+"\s*)|(?[^;]+))(;$|$|;)' + +ForEach ($m in $pathsplit_re.Matches($current_value)) { + $existing_elements.Add($m.Groups['q'].Value) | Out-Null +} + +If ($state -eq "absent") { + $result.changed = Remove-Elements $existing_elements $elements +} +ElseIf ($state -eq "present") { + $result.changed = Add-Elements $existing_elements $elements +} + +# calculate the new path value from the existing elements +$path_value = [String]::Join(";", $existing_elements.ToArray()) +$result.path_value = $path_value + +If ($result.changed -and -not $check_mode) { + Set-RawPathVar $path_value $scope | Out-Null +} + +Exit-Json $result diff --git a/test/support/windows-integration/plugins/modules/win_path.py b/test/support/windows-integration/plugins/modules/win_path.py new file mode 100644 index 00000000000..6404504fa2d --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_path.py @@ -0,0 +1,79 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2016, Red Hat | Ansible +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# This is a windows documentation stub. Actual code lives in the .ps1 +# file of the same name + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'core'} + +DOCUMENTATION = r''' +--- +module: win_path +version_added: "2.3" +short_description: Manage Windows path environment variables +description: + - Allows element-based ordering, addition, and removal of Windows path environment variables. +options: + name: + description: + - Target path environment variable name. + type: str + default: PATH + elements: + description: + - A single path element, or a list of path elements (ie, directories) to add or remove. + - When multiple elements are included in the list (and C(state) is C(present)), the elements are guaranteed to appear in the same relative order + in the resultant path value. + - Variable expansions (eg, C(%VARNAME%)) are allowed, and are stored unexpanded in the target path element. + - Any existing path elements not mentioned in C(elements) are always preserved in their current order. + - New path elements are appended to the path, and existing path elements may be moved closer to the end to satisfy the requested ordering. + - Paths are compared in a case-insensitive fashion, and trailing backslashes are ignored for comparison purposes. However, note that trailing + backslashes in YAML require quotes. + type: list + required: yes + state: + description: + - Whether the path elements specified in C(elements) should be present or absent. + type: str + choices: [ absent, present ] + scope: + description: + - The level at which the environment variable specified by C(name) should be managed (either for the current user or global machine scope). + type: str + choices: [ machine, user ] + default: machine +notes: + - This module is for modifying individual elements of path-like + environment variables. For general-purpose management of other + environment vars, use the M(win_environment) module. + - This module does not broadcast change events. + This means that the minority of windows applications which can have + their environment changed without restarting will not be notified and + therefore will need restarting to pick up new environment settings. + - User level environment variables will require an interactive user to + log out and in again before they become available. +seealso: +- module: win_environment +author: +- Matt Davis (@nitzmahone) +''' + +EXAMPLES = r''' +- name: Ensure that system32 and Powershell are present on the global system path, and in the specified order + win_path: + elements: + - '%SystemRoot%\system32' + - '%SystemRoot%\system32\WindowsPowerShell\v1.0' + +- name: Ensure that C:\Program Files\MyJavaThing is not on the current user's CLASSPATH + win_path: + name: CLASSPATH + elements: C:\Program Files\MyJavaThing + scope: user + state: absent +''' diff --git a/test/support/windows-integration/plugins/modules/win_ping.ps1 b/test/support/windows-integration/plugins/modules/win_ping.ps1 new file mode 100644 index 00000000000..c848b9121ea --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_ping.ps1 @@ -0,0 +1,21 @@ +#!powershell + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic + +$spec = @{ + options = @{ + data = @{ type = "str"; default = "pong" } + } + supports_check_mode = $true +} +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) +$data = $module.Params.data + +if ($data -eq "crash") { + throw "boom" +} + +$module.Result.ping = $data +$module.ExitJson() diff --git a/test/support/windows-integration/plugins/modules/win_ping.py b/test/support/windows-integration/plugins/modules/win_ping.py new file mode 100644 index 00000000000..6d35f379940 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_ping.py @@ -0,0 +1,55 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2012, Michael DeHaan , and others +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# this is a windows documentation stub. actual code lives in the .ps1 +# file of the same name + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['stableinterface'], + 'supported_by': 'core'} + +DOCUMENTATION = r''' +--- +module: win_ping +version_added: "1.7" +short_description: A windows version of the classic ping module +description: + - Checks management connectivity of a windows host. + - This is NOT ICMP ping, this is just a trivial test module. + - For non-Windows targets, use the M(ping) module instead. + - For Network targets, use the M(net_ping) module instead. +options: + data: + description: + - Alternate data to return instead of 'pong'. + - If this parameter is set to C(crash), the module will cause an exception. + type: str + default: pong +seealso: +- module: ping +author: +- Chris Church (@cchurch) +''' + +EXAMPLES = r''' +# Test connectivity to a windows host +# ansible winserver -m win_ping + +- name: Example from an Ansible Playbook + win_ping: + +- name: Induce an exception to see what happens + win_ping: + data: crash +''' + +RETURN = r''' +ping: + description: Value provided with the data parameter. + returned: success + type: str + sample: pong +''' diff --git a/test/support/windows-integration/plugins/modules/win_psexec.ps1 b/test/support/windows-integration/plugins/modules/win_psexec.ps1 new file mode 100644 index 00000000000..04a512706e1 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_psexec.ps1 @@ -0,0 +1,152 @@ +#!powershell + +# Copyright: (c) 2017, Dag Wieers (@dagwieers) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module Ansible.ModuleUtils.ArgvParser +#Requires -Module Ansible.ModuleUtils.CommandUtil + +# See also: https://technet.microsoft.com/en-us/sysinternals/pxexec.aspx + +$spec = @{ + options = @{ + command = @{ type='str'; required=$true } + executable = @{ type='path'; default='psexec.exe' } + hostnames = @{ type='list' } + username = @{ type='str' } + password = @{ type='str'; no_log=$true } + chdir = @{ type='path' } + wait = @{ type='bool'; default=$true } + nobanner = @{ type='bool'; default=$false } + noprofile = @{ type='bool'; default=$false } + elevated = @{ type='bool'; default=$false } + limited = @{ type='bool'; default=$false } + system = @{ type='bool'; default=$false } + interactive = @{ type='bool'; default=$false } + session = @{ type='int' } + priority = @{ type='str'; choices=@( 'background', 'low', 'belownormal', 'abovenormal', 'high', 'realtime' ) } + timeout = @{ type='int' } + } +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$command = $module.Params.command +$executable = $module.Params.executable +$hostnames = $module.Params.hostnames +$username = $module.Params.username +$password = $module.Params.password +$chdir = $module.Params.chdir +$wait = $module.Params.wait +$nobanner = $module.Params.nobanner +$noprofile = $module.Params.noprofile +$elevated = $module.Params.elevated +$limited = $module.Params.limited +$system = $module.Params.system +$interactive = $module.Params.interactive +$session = $module.Params.session +$priority = $module.Params.Priority +$timeout = $module.Params.timeout + +$module.Result.changed = $true + +If (-Not (Get-Command $executable -ErrorAction SilentlyContinue)) { + $module.FailJson("Executable '$executable' was not found.") +} + +$arguments = [System.Collections.Generic.List`1[String]]@($executable) + +If ($nobanner -eq $true) { + $arguments.Add("-nobanner") +} + +# Support running on local system if no hostname is specified +If ($hostnames) { + $hostname_argument = ($hostnames | sort -Unique) -join ',' + $arguments.Add("\\$hostname_argument") +} + +# Username is optional +If ($null -ne $username) { + $arguments.Add("-u") + $arguments.Add($username) +} + +# Password is optional +If ($null -ne $password) { + $arguments.Add("-p") + $arguments.Add($password) +} + +If ($null -ne $chdir) { + $arguments.Add("-w") + $arguments.Add($chdir) +} + +If ($wait -eq $false) { + $arguments.Add("-d") +} + +If ($noprofile -eq $true) { + $arguments.Add("-e") +} + +If ($elevated -eq $true) { + $arguments.Add("-h") +} + +If ($system -eq $true) { + $arguments.Add("-s") +} + +If ($interactive -eq $true) { + $arguments.Add("-i") + If ($null -ne $session) { + $arguments.Add($session) + } +} + +If ($limited -eq $true) { + $arguments.Add("-l") +} + +If ($null -ne $priority) { + $arguments.Add("-$priority") +} + +If ($null -ne $timeout) { + $arguments.Add("-n") + $arguments.Add($timeout) +} + +$arguments.Add("-accepteula") + +$argument_string = Argv-ToString -arguments $arguments + +# Add the command at the end of the argument string, we don't want to escape +# that as psexec doesn't expect it to be one arg +$argument_string += " $command" + +$start_datetime = [DateTime]::UtcNow +$module.Result.psexec_command = $argument_string + +$command_result = Run-Command -command $argument_string + +$end_datetime = [DateTime]::UtcNow + +$module.Result.stdout = $command_result.stdout +$module.Result.stderr = $command_result.stderr + +If ($wait -eq $true) { + $module.Result.rc = $command_result.rc +} else { + $module.Result.rc = 0 + $module.Result.pid = $command_result.rc +} + +$module.Result.start = $start_datetime.ToString("yyyy-MM-dd hh:mm:ss.ffffff") +$module.Result.end = $end_datetime.ToString("yyyy-MM-dd hh:mm:ss.ffffff") +$module.Result.delta = $($end_datetime - $start_datetime).ToString("h\:mm\:ss\.ffffff") + +$module.ExitJson() diff --git a/test/support/windows-integration/plugins/modules/win_psexec.py b/test/support/windows-integration/plugins/modules/win_psexec.py new file mode 100644 index 00000000000..c3fc37e4a60 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_psexec.py @@ -0,0 +1,172 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: 2017, Dag Wieers (@dagwieers) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: win_psexec +version_added: '2.3' +short_description: Runs commands (remotely) as another (privileged) user +description: +- Run commands (remotely) through the PsExec service. +- Run commands as another (domain) user (with elevated privileges). +requirements: +- Microsoft PsExec +options: + command: + description: + - The command line to run through PsExec (limited to 260 characters). + type: str + required: yes + executable: + description: + - The location of the PsExec utility (in case it is not located in your PATH). + type: path + default: psexec.exe + hostnames: + description: + - The hostnames to run the command. + - If not provided, the command is run locally. + type: list + username: + description: + - The (remote) user to run the command as. + - If not provided, the current user is used. + type: str + password: + description: + - The password for the (remote) user to run the command as. + - This is mandatory in order authenticate yourself. + type: str + chdir: + description: + - Run the command from this (remote) directory. + type: path + nobanner: + description: + - Do not display the startup banner and copyright message. + - This only works for specific versions of the PsExec binary. + type: bool + default: no + version_added: '2.4' + noprofile: + description: + - Run the command without loading the account's profile. + type: bool + default: no + elevated: + description: + - Run the command with elevated privileges. + type: bool + default: no + interactive: + description: + - Run the program so that it interacts with the desktop on the remote system. + type: bool + default: no + session: + description: + - Specifies the session ID to use. + - This parameter works in conjunction with I(interactive). + - It has no effect when I(interactive) is set to C(no). + type: int + version_added: '2.7' + limited: + description: + - Run the command as limited user (strips the Administrators group and allows only privileges assigned to the Users group). + type: bool + default: no + system: + description: + - Run the remote command in the System account. + type: bool + default: no + priority: + description: + - Used to run the command at a different priority. + choices: [ abovenormal, background, belownormal, high, low, realtime ] + timeout: + description: + - The connection timeout in seconds + type: int + wait: + description: + - Wait for the application to terminate. + - Only use for non-interactive applications. + type: bool + default: yes +notes: +- More information related to Microsoft PsExec is available from + U(https://technet.microsoft.com/en-us/sysinternals/bb897553.aspx) +seealso: +- module: psexec +- module: raw +- module: win_command +- module: win_shell +author: +- Dag Wieers (@dagwieers) +''' + +EXAMPLES = r''' +- name: Test the PsExec connection to the local system (target node) with your user + win_psexec: + command: whoami.exe + +- name: Run regedit.exe locally (on target node) as SYSTEM and interactively + win_psexec: + command: regedit.exe + interactive: yes + system: yes + +- name: Run the setup.exe installer on multiple servers using the Domain Administrator + win_psexec: + command: E:\setup.exe /i /IACCEPTEULA + hostnames: + - remote_server1 + - remote_server2 + username: DOMAIN\Administrator + password: some_password + priority: high + +- name: Run PsExec from custom location C:\Program Files\sysinternals\ + win_psexec: + command: netsh advfirewall set allprofiles state off + executable: C:\Program Files\sysinternals\psexec.exe + hostnames: [ remote_server ] + password: some_password + priority: low +''' + +RETURN = r''' +cmd: + description: The complete command line used by the module, including PsExec call and additional options. + returned: always + type: str + sample: psexec.exe -nobanner \\remote_server -u "DOMAIN\Administrator" -p "some_password" -accepteula E:\setup.exe +pid: + description: The PID of the async process created by PsExec. + returned: when C(wait=False) + type: int + sample: 1532 +rc: + description: The return code for the command. + returned: always + type: int + sample: 0 +stdout: + description: The standard output from the command. + returned: always + type: str + sample: Success. +stderr: + description: The error output from the command. + returned: always + type: str + sample: Error 15 running E:\setup.exe +''' diff --git a/test/support/windows-integration/plugins/modules/win_reboot.py b/test/support/windows-integration/plugins/modules/win_reboot.py new file mode 100644 index 00000000000..1431804143d --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_reboot.py @@ -0,0 +1,131 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['stableinterface'], + 'supported_by': 'core'} + +DOCUMENTATION = r''' +--- +module: win_reboot +short_description: Reboot a windows machine +description: +- Reboot a Windows machine, wait for it to go down, come back up, and respond to commands. +- For non-Windows targets, use the M(reboot) module instead. +version_added: '2.1' +options: + pre_reboot_delay: + description: + - Seconds to wait before reboot. Passed as a parameter to the reboot command. + type: int + default: 2 + aliases: [ pre_reboot_delay_sec ] + post_reboot_delay: + description: + - Seconds to wait after the reboot command was successful before attempting to validate the system rebooted successfully. + - This is useful if you want wait for something to settle despite your connection already working. + type: int + default: 0 + version_added: '2.4' + aliases: [ post_reboot_delay_sec ] + shutdown_timeout: + description: + - Maximum seconds to wait for shutdown to occur. + - Increase this timeout for very slow hardware, large update applications, etc. + - This option has been removed since Ansible 2.5 as the win_reboot behavior has changed. + type: int + default: 600 + aliases: [ shutdown_timeout_sec ] + reboot_timeout: + description: + - Maximum seconds to wait for machine to re-appear on the network and respond to a test command. + - This timeout is evaluated separately for both reboot verification and test command success so maximum clock time is actually twice this value. + type: int + default: 600 + aliases: [ reboot_timeout_sec ] + connect_timeout: + description: + - Maximum seconds to wait for a single successful TCP connection to the WinRM endpoint before trying again. + type: int + default: 5 + aliases: [ connect_timeout_sec ] + test_command: + description: + - Command to expect success for to determine the machine is ready for management. + type: str + default: whoami + msg: + description: + - Message to display to users. + type: str + default: Reboot initiated by Ansible + boot_time_command: + description: + - Command to run that returns a unique string indicating the last time the system was booted. + - Setting this to a command that has different output each time it is run will cause the task to fail. + type: str + default: '(Get-WmiObject -ClassName Win32_OperatingSystem).LastBootUpTime' + version_added: '2.10' +notes: +- If a shutdown was already scheduled on the system, C(win_reboot) will abort the scheduled shutdown and enforce its own shutdown. +- Beware that when C(win_reboot) returns, the Windows system may not have settled yet and some base services could be in limbo. + This can result in unexpected behavior. Check the examples for ways to mitigate this. +- The connection user must have the C(SeRemoteShutdownPrivilege) privilege enabled, see + U(https://docs.microsoft.com/en-us/windows/security/threat-protection/security-policy-settings/force-shutdown-from-a-remote-system) + for more information. +seealso: +- module: reboot +author: +- Matt Davis (@nitzmahone) +''' + +EXAMPLES = r''' +- name: Reboot the machine with all defaults + win_reboot: + +- name: Reboot a slow machine that might have lots of updates to apply + win_reboot: + reboot_timeout: 3600 + +# Install a Windows feature and reboot if necessary +- name: Install IIS Web-Server + win_feature: + name: Web-Server + register: iis_install + +- name: Reboot when Web-Server feature requires it + win_reboot: + when: iis_install.reboot_required + +# One way to ensure the system is reliable, is to set WinRM to a delayed startup +- name: Ensure WinRM starts when the system has settled and is ready to work reliably + win_service: + name: WinRM + start_mode: delayed + + +# Additionally, you can add a delay before running the next task +- name: Reboot a machine that takes time to settle after being booted + win_reboot: + post_reboot_delay: 120 + +# Or you can make win_reboot validate exactly what you need to work before running the next task +- name: Validate that the netlogon service has started, before running the next task + win_reboot: + test_command: 'exit (Get-Service -Name Netlogon).Status -ne "Running"' +''' + +RETURN = r''' +rebooted: + description: True if the machine was rebooted. + returned: always + type: bool + sample: true +elapsed: + description: The number of seconds that elapsed waiting for the system to be rebooted. + returned: always + type: float + sample: 23.2 +''' diff --git a/test/support/windows-integration/plugins/modules/win_regedit.ps1 b/test/support/windows-integration/plugins/modules/win_regedit.ps1 new file mode 100644 index 00000000000..c56b48335dd --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_regedit.ps1 @@ -0,0 +1,495 @@ +#!powershell + +# Copyright: (c) 2015, Adam Keech +# Copyright: (c) 2015, Josh Ludwig +# Copyright: (c) 2017, Jordan Borean +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy +#Requires -Module Ansible.ModuleUtils.PrivilegeUtil + +$params = Parse-Args -arguments $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false +$diff_mode = Get-AnsibleParam -obj $params -name "_ansible_diff" -type "bool" -default $false +$_remote_tmp = Get-AnsibleParam $params "_ansible_remote_tmp" -type "path" -default $env:TMP + +$path = Get-AnsibleParam -obj $params -name "path" -type "str" -failifempty $true -aliases "key" +$name = Get-AnsibleParam -obj $params -name "name" -type "str" -aliases "entry","value" +$data = Get-AnsibleParam -obj $params -name "data" +$type = Get-AnsibleParam -obj $params -name "type" -type "str" -default "string" -validateset "none","binary","dword","expandstring","multistring","string","qword" -aliases "datatype" +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "present" -validateset "present","absent" +$delete_key = Get-AnsibleParam -obj $params -name "delete_key" -type "bool" -default $true +$hive = Get-AnsibleParam -obj $params -name "hive" -type "path" + +$result = @{ + changed = $false + data_changed = $false + data_type_changed = $false +} + +if ($diff_mode) { + $result.diff = @{ + before = "" + after = "" + } +} + +$registry_util = @' +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +namespace Ansible.WinRegedit +{ + internal class NativeMethods + { + [DllImport("advapi32.dll", CharSet = CharSet.Unicode)] + public static extern int RegLoadKeyW( + UInt32 hKey, + string lpSubKey, + string lpFile); + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode)] + public static extern int RegUnLoadKeyW( + UInt32 hKey, + string lpSubKey); + } + + public class Win32Exception : System.ComponentModel.Win32Exception + { + private string _msg; + public Win32Exception(string message) : this(Marshal.GetLastWin32Error(), message) { } + public Win32Exception(int errorCode, string message) : base(errorCode) + { + _msg = String.Format("{0} ({1}, Win32ErrorCode {2})", message, base.Message, errorCode); + } + public override string Message { get { return _msg; } } + public static explicit operator Win32Exception(string message) { return new Win32Exception(message); } + } + + public class Hive : IDisposable + { + private const UInt32 SCOPE = 0x80000002; // HKLM + private string hiveKey; + private bool loaded = false; + + public Hive(string hiveKey, string hivePath) + { + this.hiveKey = hiveKey; + int ret = NativeMethods.RegLoadKeyW(SCOPE, hiveKey, hivePath); + if (ret != 0) + throw new Win32Exception(ret, String.Format("Failed to load registry hive at {0}", hivePath)); + loaded = true; + } + + public static void UnloadHive(string hiveKey) + { + int ret = NativeMethods.RegUnLoadKeyW(SCOPE, hiveKey); + if (ret != 0) + throw new Win32Exception(ret, String.Format("Failed to unload registry hive at {0}", hiveKey)); + } + + public void Dispose() + { + if (loaded) + { + // Make sure the garbage collector disposes all unused handles and waits until it is complete + GC.Collect(); + GC.WaitForPendingFinalizers(); + + UnloadHive(hiveKey); + loaded = false; + } + GC.SuppressFinalize(this); + } + ~Hive() { this.Dispose(); } + } +} +'@ + +# fire a warning if the property name isn't specified, the (Default) key ($null) can only be a string +if ($null -eq $name -and $type -ne "string") { + Add-Warning -obj $result -message "the data type when name is not specified can only be 'string', the type has automatically been converted" + $type = "string" +} + +# Check that the registry path is in PSDrive format: HKCC, HKCR, HKCU, HKLM, HKU +if ($path -notmatch "^HK(CC|CR|CU|LM|U):\\") { + Fail-Json $result "path: $path is not a valid powershell path, see module documentation for examples." +} + +# Add a warning if the path does not contains a \ and is not the leaf path +$registry_path = (Split-Path -Path $path -NoQualifier).Substring(1) # removes the hive: and leading \ +$registry_leaf = Split-Path -Path $path -Leaf +if ($registry_path -ne $registry_leaf -and -not $registry_path.Contains('\')) { + $msg = "path is not using '\' as a separator, support for '/' as a separator will be removed in a future Ansible version" + Add-DeprecationWarning -obj $result -message $msg -version 2.12 + $registry_path = $registry_path.Replace('/', '\') +} + +# Simplified version of Convert-HexStringToByteArray from +# https://cyber-defense.sans.org/blog/2010/02/11/powershell-byte-array-hex-convert +# Expects a hex in the format you get when you run reg.exe export, +# and converts to a byte array so powershell can modify binary registry entries +# import format is like 'hex:be,ef,be,ef,be,ef,be,ef,be,ef' +Function Convert-RegExportHexStringToByteArray($string) { + # Remove 'hex:' from the front of the string if present + $string = $string.ToLower() -replace '^hex\:','' + + # Remove whitespace and any other non-hex crud. + $string = $string -replace '[^a-f0-9\\,x\-\:]','' + + # Turn commas into colons + $string = $string -replace ',',':' + + # Maybe there's nothing left over to convert... + if ($string.Length -eq 0) { + return ,@() + } + + # Split string with or without colon delimiters. + if ($string.Length -eq 1) { + return ,@([System.Convert]::ToByte($string,16)) + } elseif (($string.Length % 2 -eq 0) -and ($string.IndexOf(":") -eq -1)) { + return ,@($string -split '([a-f0-9]{2})' | foreach-object { if ($_) {[System.Convert]::ToByte($_,16)}}) + } elseif ($string.IndexOf(":") -ne -1) { + return ,@($string -split ':+' | foreach-object {[System.Convert]::ToByte($_,16)}) + } else { + return ,@() + } +} + +Function Compare-RegistryProperties($existing, $new) { + # Outputs $true if the property values don't match + if ($existing -is [Array]) { + (Compare-Object -ReferenceObject $existing -DifferenceObject $new -SyncWindow 0).Length -ne 0 + } else { + $existing -cne $new + } +} + +Function Get-DiffValue { + param( + [Parameter(Mandatory=$true)][Microsoft.Win32.RegistryValueKind]$Type, + [Parameter(Mandatory=$true)][Object]$Value + ) + + $diff = @{ type = $Type.ToString(); value = $Value } + + $enum = [Microsoft.Win32.RegistryValueKind] + if ($Type -in @($enum::Binary, $enum::None)) { + $diff.value = [System.Collections.Generic.List`1[String]]@() + foreach ($dec_value in $Value) { + $diff.value.Add("0x{0:x2}" -f $dec_value) + } + } elseif ($Type -eq $enum::DWord) { + $diff.value = "0x{0:x8}" -f $Value + } elseif ($Type -eq $enum::QWord) { + $diff.value = "0x{0:x16}" -f $Value + } + + return $diff +} + +Function Set-StateAbsent { + param( + # Used for diffs and exception messages to match up against Ansible input + [Parameter(Mandatory=$true)][String]$PrintPath, + [Parameter(Mandatory=$true)][Microsoft.Win32.RegistryKey]$Hive, + [Parameter(Mandatory=$true)][String]$Path, + [String]$Name, + [Switch]$DeleteKey + ) + + $key = $Hive.OpenSubKey($Path, $true) + if ($null -eq $key) { + # Key does not exist, no need to delete anything + return + } + + try { + if ($DeleteKey -and -not $Name) { + # delete_key=yes is set and name is null/empty, so delete the entire key + $key.Dispose() + $key = $null + if (-not $check_mode) { + try { + $Hive.DeleteSubKeyTree($Path, $false) + } catch { + Fail-Json -obj $result -message "failed to delete registry key at $($PrintPath): $($_.Exception.Message)" + } + } + $result.changed = $true + + if ($diff_mode) { + $result.diff.before = @{$PrintPath = @{}} + $result.diff.after = @{} + } + } else { + # delete_key=no or name is not null/empty, delete the property not the full key + $property = $key.GetValue($Name) + if ($null -eq $property) { + # property does not exist + return + } + $property_type = $key.GetValueKind($Name) # used for the diff + + if (-not $check_mode) { + try { + $key.DeleteValue($Name) + } catch { + Fail-Json -obj $result -message "failed to delete registry property '$Name' at $($PrintPath): $($_.Exception.Message)" + } + } + + $result.changed = $true + if ($diff_mode) { + $diff_value = Get-DiffValue -Type $property_type -Value $property + $result.diff.before = @{ $PrintPath = @{ $Name = $diff_value } } + $result.diff.after = @{ $PrintPath = @{} } + } + } + } finally { + if ($key) { + $key.Dispose() + } + } +} + +Function Set-StatePresent { + param( + [Parameter(Mandatory=$true)][String]$PrintPath, + [Parameter(Mandatory=$true)][Microsoft.Win32.RegistryKey]$Hive, + [Parameter(Mandatory=$true)][String]$Path, + [String]$Name, + [Object]$Data, + [Microsoft.Win32.RegistryValueKind]$Type + ) + + $key = $Hive.OpenSubKey($Path, $true) + try { + if ($null -eq $key) { + # the key does not exist, create it so the next steps work + if (-not $check_mode) { + try { + $key = $Hive.CreateSubKey($Path) + } catch { + Fail-Json -obj $result -message "failed to create registry key at $($PrintPath): $($_.Exception.Message)" + } + } + $result.changed = $true + + if ($diff_mode) { + $result.diff.before = @{} + $result.diff.after = @{$PrintPath = @{}} + } + } elseif ($diff_mode) { + # Make sure the diff is in an expected state for the key + $result.diff.before = @{$PrintPath = @{}} + $result.diff.after = @{$PrintPath = @{}} + } + + if ($null -eq $key -or $null -eq $Data) { + # Check mode and key was created above, we cannot do any more work, or $Data is $null which happens when + # we create a new key but haven't explicitly set the data + return + } + + $property = $key.GetValue($Name, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($null -ne $property) { + # property exists, need to compare the values and type + $existing_type = $key.GetValueKind($name) + $change_value = $false + + if ($Type -ne $existing_type) { + $change_value = $true + $result.data_type_changed = $true + $data_mismatch = Compare-RegistryProperties -existing $property -new $Data + if ($data_mismatch) { + $result.data_changed = $true + } + } else { + $data_mismatch = Compare-RegistryProperties -existing $property -new $Data + if ($data_mismatch) { + $change_value = $true + $result.data_changed = $true + } + } + + if ($change_value) { + if (-not $check_mode) { + try { + $key.SetValue($Name, $Data, $Type) + } catch { + Fail-Json -obj $result -message "failed to change registry property '$Name' at $($PrintPath): $($_.Exception.Message)" + } + } + $result.changed = $true + + if ($diff_mode) { + $result.diff.before.$PrintPath.$Name = Get-DiffValue -Type $existing_type -Value $property + $result.diff.after.$PrintPath.$Name = Get-DiffValue -Type $Type -Value $Data + } + } elseif ($diff_mode) { + $diff_value = Get-DiffValue -Type $existing_type -Value $property + $result.diff.before.$PrintPath.$Name = $diff_value + $result.diff.after.$PrintPath.$Name = $diff_value + } + } else { + # property doesn't exist just create a new one + if (-not $check_mode) { + try { + $key.SetValue($Name, $Data, $Type) + } catch { + Fail-Json -obj $result -message "failed to create registry property '$Name' at $($PrintPath): $($_.Exception.Message)" + } + } + $result.changed = $true + + if ($diff_mode) { + $result.diff.after.$PrintPath.$Name = Get-DiffValue -Type $Type -Value $Data + } + } + } finally { + if ($key) { + $key.Dispose() + } + } +} + +# convert property names "" to $null as "" refers to (Default) +if ($name -eq "") { + $name = $null +} + +# convert the data to the required format +if ($type -in @("binary", "none")) { + if ($null -eq $data) { + $data = "" + } + + # convert the data from string to byte array if in hex: format + if ($data -is [String]) { + $data = [byte[]](Convert-RegExportHexStringToByteArray -string $data) + } elseif ($data -is [Int]) { + if ($data -gt 255) { + Fail-Json $result "cannot convert binary data '$data' to byte array, please specify this value as a yaml byte array or a comma separated hex value string" + } + $data = [byte[]]@([byte]$data) + } elseif ($data -is [Array]) { + $data = [byte[]]$data + } +} elseif ($type -in @("dword", "qword")) { + # dword's and dword's don't allow null values, set to 0 + if ($null -eq $data) { + $data = 0 + } + + if ($data -is [String]) { + # if the data is a string we need to convert it to an unsigned int64 + # it needs to be unsigned as Ansible passes in an unsigned value while + # powershell uses a signed data type. The value will then be converted + # below + $data = [UInt64]$data + } + + if ($type -eq "dword") { + if ($data -gt [UInt32]::MaxValue) { + Fail-Json $result "data cannot be larger than 0xffffffff when type is dword" + } elseif ($data -gt [Int32]::MaxValue) { + # when dealing with larger int32 (> 2147483647 or 0x7FFFFFFF) powershell + # automatically converts it to a signed int64. We need to convert this to + # signed int32 by parsing the hex string value. + $data = "0x$("{0:x}" -f $data)" + } + $data = [Int32]$data + } else { + if ($data -gt [UInt64]::MaxValue) { + Fail-Json $result "data cannot be larger than 0xffffffffffffffff when type is qword" + } elseif ($data -gt [Int64]::MaxValue) { + $data = "0x$("{0:x}" -f $data)" + } + $data = [Int64]$data + } +} elseif ($type -in @("string", "expandstring") -and $name) { + # a null string or expandstring must be empty quotes + # Only do this if $name has been defined (not the default key) + if ($null -eq $data) { + $data = "" + } +} elseif ($type -eq "multistring") { + # convert the data for a multistring to a String[] array + if ($null -eq $data) { + $data = [String[]]@() + } elseif ($data -isnot [Array]) { + $new_data = New-Object -TypeName String[] -ArgumentList 1 + $new_data[0] = $data.ToString([CultureInfo]::InvariantCulture) + $data = $new_data + } else { + $new_data = New-Object -TypeName String[] -ArgumentList $data.Count + foreach ($entry in $data) { + $new_data[$data.IndexOf($entry)] = $entry.ToString([CultureInfo]::InvariantCulture) + } + $data = $new_data + } +} + +# convert the type string to the .NET class +$type = [System.Enum]::Parse([Microsoft.Win32.RegistryValueKind], $type, $true) + +$registry_hive = switch(Split-Path -Path $path -Qualifier) { + "HKCR:" { [Microsoft.Win32.Registry]::ClassesRoot } + "HKCC:" { [Microsoft.Win32.Registry]::CurrentConfig } + "HKCU:" { [Microsoft.Win32.Registry]::CurrentUser } + "HKLM:" { [Microsoft.Win32.Registry]::LocalMachine } + "HKU:" { [Microsoft.Win32.Registry]::Users } +} +$loaded_hive = $null +try { + if ($hive) { + if (-not (Test-Path -LiteralPath $hive)) { + Fail-Json -obj $result -message "hive at path '$hive' is not valid or accessible, cannot load hive" + } + + $original_tmp = $env:TMP + $env:TMP = $_remote_tmp + Add-Type -TypeDefinition $registry_util + $env:TMP = $original_tmp + + try { + Set-AnsiblePrivilege -Name SeBackupPrivilege -Value $true + Set-AnsiblePrivilege -Name SeRestorePrivilege -Value $true + } catch [System.ComponentModel.Win32Exception] { + Fail-Json -obj $result -message "failed to enable SeBackupPrivilege and SeRestorePrivilege for the current process: $($_.Exception.Message)" + } + + if (Test-Path -Path HKLM:\ANSIBLE) { + Add-Warning -obj $result -message "hive already loaded at HKLM:\ANSIBLE, had to unload hive for win_regedit to continue" + try { + [Ansible.WinRegedit.Hive]::UnloadHive("ANSIBLE") + } catch [System.ComponentModel.Win32Exception] { + Fail-Json -obj $result -message "failed to unload registry hive HKLM:\ANSIBLE from $($hive): $($_.Exception.Message)" + } + } + + try { + $loaded_hive = New-Object -TypeName Ansible.WinRegedit.Hive -ArgumentList "ANSIBLE", $hive + } catch [System.ComponentModel.Win32Exception] { + Fail-Json -obj $result -message "failed to load registry hive from '$hive' to HKLM:\ANSIBLE: $($_.Exception.Message)" + } + } + + if ($state -eq "present") { + Set-StatePresent -PrintPath $path -Hive $registry_hive -Path $registry_path -Name $name -Data $data -Type $type + } else { + Set-StateAbsent -PrintPath $path -Hive $registry_hive -Path $registry_path -Name $name -DeleteKey:$delete_key + } +} finally { + $registry_hive.Dispose() + if ($loaded_hive) { + $loaded_hive.Dispose() + } +} + +Exit-Json $result + diff --git a/test/support/windows-integration/plugins/modules/win_regedit.py b/test/support/windows-integration/plugins/modules/win_regedit.py new file mode 100644 index 00000000000..2c0fff71245 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_regedit.py @@ -0,0 +1,210 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2015, Adam Keech +# Copyright: (c) 2015, Josh Ludwig +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# this is a windows documentation stub. actual code lives in the .ps1 +# file of the same name + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'core'} + + +DOCUMENTATION = r''' +--- +module: win_regedit +version_added: '2.0' +short_description: Add, change, or remove registry keys and values +description: +- Add, modify or remove registry keys and values. +- More information about the windows registry from Wikipedia + U(https://en.wikipedia.org/wiki/Windows_Registry). +options: + path: + description: + - Name of the registry path. + - 'Should be in one of the following registry hives: HKCC, HKCR, HKCU, + HKLM, HKU.' + type: str + required: yes + aliases: [ key ] + name: + description: + - Name of the registry entry in the above C(path) parameters. + - If not provided, or empty then the '(Default)' property for the key will + be used. + type: str + aliases: [ entry, value ] + data: + description: + - Value of the registry entry C(name) in C(path). + - If not specified then the value for the property will be null for the + corresponding C(type). + - Binary and None data should be expressed in a yaml byte array or as comma + separated hex values. + - An easy way to generate this is to run C(regedit.exe) and use the + I(export) option to save the registry values to a file. + - In the exported file, binary value will look like C(hex:be,ef,be,ef), the + C(hex:) prefix is optional. + - DWORD and QWORD values should either be represented as a decimal number + or a hex value. + - Multistring values should be passed in as a list. + - See the examples for more details on how to format this data. + type: str + type: + description: + - The registry value data type. + type: str + choices: [ binary, dword, expandstring, multistring, string, qword ] + default: string + aliases: [ datatype ] + state: + description: + - The state of the registry entry. + type: str + choices: [ absent, present ] + default: present + delete_key: + description: + - When C(state) is 'absent' then this will delete the entire key. + - If C(no) then it will only clear out the '(Default)' property for + that key. + type: bool + default: yes + version_added: '2.4' + hive: + description: + - A path to a hive key like C:\Users\Default\NTUSER.DAT to load in the + registry. + - This hive is loaded under the HKLM:\ANSIBLE key which can then be used + in I(name) like any other path. + - This can be used to load the default user profile registry hive or any + other hive saved as a file. + - Using this function requires the user to have the C(SeRestorePrivilege) + and C(SeBackupPrivilege) privileges enabled. + type: path + version_added: '2.5' +notes: +- Check-mode C(-C/--check) and diff output C(-D/--diff) are supported, so that you can test every change against the active configuration before + applying changes. +- Beware that some registry hives (C(HKEY_USERS) in particular) do not allow to create new registry paths in the root folder. +- Since ansible 2.4, when checking if a string registry value has changed, a case-sensitive test is used. Previously the test was case-insensitive. +seealso: +- module: win_reg_stat +- module: win_regmerge +author: +- Adam Keech (@smadam813) +- Josh Ludwig (@joshludwig) +- Jordan Borean (@jborean93) +''' + +EXAMPLES = r''' +- name: Create registry path MyCompany + win_regedit: + path: HKCU:\Software\MyCompany + +- name: Add or update registry path MyCompany, with entry 'hello', and containing 'world' + win_regedit: + path: HKCU:\Software\MyCompany + name: hello + data: world + +- name: Add or update registry path MyCompany, with dword entry 'hello', and containing 1337 as the decimal value + win_regedit: + path: HKCU:\Software\MyCompany + name: hello + data: 1337 + type: dword + +- name: Add or update registry path MyCompany, with dword entry 'hello', and containing 0xff2500ae as the hex value + win_regedit: + path: HKCU:\Software\MyCompany + name: hello + data: 0xff2500ae + type: dword + +- name: Add or update registry path MyCompany, with binary entry 'hello', and containing binary data in hex-string format + win_regedit: + path: HKCU:\Software\MyCompany + name: hello + data: hex:be,ef,be,ef,be,ef,be,ef,be,ef + type: binary + +- name: Add or update registry path MyCompany, with binary entry 'hello', and containing binary data in yaml format + win_regedit: + path: HKCU:\Software\MyCompany + name: hello + data: [0xbe,0xef,0xbe,0xef,0xbe,0xef,0xbe,0xef,0xbe,0xef] + type: binary + +- name: Add or update registry path MyCompany, with expand string entry 'hello' + win_regedit: + path: HKCU:\Software\MyCompany + name: hello + data: '%appdata%\local' + type: expandstring + +- name: Add or update registry path MyCompany, with multi string entry 'hello' + win_regedit: + path: HKCU:\Software\MyCompany + name: hello + data: ['hello', 'world'] + type: multistring + +- name: Disable keyboard layout hotkey for all users (changes existing) + win_regedit: + path: HKU:\.DEFAULT\Keyboard Layout\Toggle + name: Layout Hotkey + data: 3 + type: dword + +- name: Disable language hotkey for current users (adds new) + win_regedit: + path: HKCU:\Keyboard Layout\Toggle + name: Language Hotkey + data: 3 + type: dword + +- name: Remove registry path MyCompany (including all entries it contains) + win_regedit: + path: HKCU:\Software\MyCompany + state: absent + delete_key: yes + +- name: Clear the existing (Default) entry at path MyCompany + win_regedit: + path: HKCU:\Software\MyCompany + state: absent + delete_key: no + +- name: Remove entry 'hello' from registry path MyCompany + win_regedit: + path: HKCU:\Software\MyCompany + name: hello + state: absent + +- name: Change default mouse trailing settings for new users + win_regedit: + path: HKLM:\ANSIBLE\Control Panel\Mouse + name: MouseTrails + data: 10 + type: str + state: present + hive: C:\Users\Default\NTUSER.dat +''' + +RETURN = r''' +data_changed: + description: Whether this invocation changed the data in the registry value. + returned: success + type: bool + sample: false +data_type_changed: + description: Whether this invocation changed the datatype of the registry value. + returned: success + type: bool + sample: true +''' diff --git a/test/support/windows-integration/plugins/modules/win_security_policy.ps1 b/test/support/windows-integration/plugins/modules/win_security_policy.ps1 new file mode 100644 index 00000000000..274204b6aaf --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_security_policy.ps1 @@ -0,0 +1,196 @@ +#!powershell + +# Copyright: (c) 2017, Jordan Borean +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +$ErrorActionPreference = 'Stop' + +$params = Parse-Args $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false +$diff_mode = Get-AnsibleParam -obj $Params -name "_ansible_diff" -type "bool" -default $false + +$section = Get-AnsibleParam -obj $params -name "section" -type "str" -failifempty $true +$key = Get-AnsibleParam -obj $params -name "key" -type "str" -failifempty $true +$value = Get-AnsibleParam -obj $params -name "value" -failifempty $true + +$result = @{ + changed = $false + section = $section + key = $key + value = $value +} + +if ($diff_mode) { + $result.diff = @{} +} + +Function Run-SecEdit($arguments) { + $stdout = $null + $stderr = $null + $log_path = [IO.Path]::GetTempFileName() + $arguments = $arguments + @("/log", $log_path) + + try { + $stdout = &SecEdit.exe $arguments | Out-String + } catch { + $stderr = $_.Exception.Message + } + $log = Get-Content -Path $log_path + Remove-Item -Path $log_path -Force + + $return = @{ + log = ($log -join "`n").Trim() + stdout = $stdout + stderr = $stderr + rc = $LASTEXITCODE + } + + return $return +} + +Function Export-SecEdit() { + $secedit_ini_path = [IO.Path]::GetTempFileName() + # while this will technically make a change to the system in check mode by + # creating a new file, we need these values to be able to do anything + # substantial in check mode + $export_result = Run-SecEdit -arguments @("/export", "/cfg", $secedit_ini_path, "/quiet") + + # check the return code and if the file has been populated, otherwise error out + if (($export_result.rc -ne 0) -or ((Get-Item -Path $secedit_ini_path).Length -eq 0)) { + Remove-Item -Path $secedit_ini_path -Force + $result.rc = $export_result.rc + $result.stdout = $export_result.stdout + $result.stderr = $export_result.stderr + Fail-Json $result "Failed to export secedit.ini file to $($secedit_ini_path)" + } + $secedit_ini = ConvertFrom-Ini -file_path $secedit_ini_path + + return $secedit_ini +} + +Function Import-SecEdit($ini) { + $secedit_ini_path = [IO.Path]::GetTempFileName() + $secedit_db_path = [IO.Path]::GetTempFileName() + Remove-Item -Path $secedit_db_path -Force # needs to be deleted for SecEdit.exe /import to work + + $ini_contents = ConvertTo-Ini -ini $ini + Set-Content -Path $secedit_ini_path -Value $ini_contents + $result.changed = $true + + $import_result = Run-SecEdit -arguments @("/configure", "/db", $secedit_db_path, "/cfg", $secedit_ini_path, "/quiet") + $result.import_log = $import_result.log + Remove-Item -Path $secedit_ini_path -Force + if ($import_result.rc -ne 0) { + $result.rc = $import_result.rc + $result.stdout = $import_result.stdout + $result.stderr = $import_result.stderr + Fail-Json $result "Failed to import secedit.ini file from $($secedit_ini_path)" + } +} + +Function ConvertTo-Ini($ini) { + $content = @() + foreach ($key in $ini.GetEnumerator()) { + $section = $key.Name + $values = $key.Value + + $content += "[$section]" + foreach ($value in $values.GetEnumerator()) { + $value_key = $value.Name + $value_value = $value.Value + + if ($null -ne $value_value) { + $content += "$value_key = $value_value" + } + } + } + + return $content -join "`r`n" +} + +Function ConvertFrom-Ini($file_path) { + $ini = @{} + switch -Regex -File $file_path { + "^\[(.+)\]" { + $section = $matches[1] + $ini.$section = @{} + } + "(.+?)\s*=(.*)" { + $name = $matches[1].Trim() + $value = $matches[2].Trim() + if ($value -match "^\d+$") { + $value = [int]$value + } elseif ($value.StartsWith('"') -and $value.EndsWith('"')) { + $value = $value.Substring(1, $value.Length - 2) + } + + $ini.$section.$name = $value + } + } + + return $ini +} + +if ($section -eq "Privilege Rights") { + Add-Warning -obj $result -message "Using this module to edit rights and privileges is error-prone, use the win_user_right module instead" +} + +$will_change = $false +$secedit_ini = Export-SecEdit +if (-not ($secedit_ini.ContainsKey($section))) { + Fail-Json $result "The section '$section' does not exist in SecEdit.exe output ini" +} + +if ($secedit_ini.$section.ContainsKey($key)) { + $current_value = $secedit_ini.$section.$key + + if ($current_value -cne $value) { + if ($diff_mode) { + $result.diff.prepared = @" +[$section] +-$key = $current_value ++$key = $value +"@ + } + + $secedit_ini.$section.$key = $value + $will_change = $true + } +} elseif ([string]$value -eq "") { + # Value is requested to be removed, and has already been removed, do nothing +} else { + if ($diff_mode) { + $result.diff.prepared = @" +[$section] ++$key = $value +"@ + } + $secedit_ini.$section.$key = $value + $will_change = $true +} + +if ($will_change -eq $true) { + $result.changed = $true + if (-not $check_mode) { + Import-SecEdit -ini $secedit_ini + + # secedit doesn't error out on improper entries, re-export and verify + # the changes occurred + $verification_ini = Export-SecEdit + $new_section_values = $verification_ini.$section + if ($new_section_values.ContainsKey($key)) { + $new_value = $new_section_values.$key + if ($new_value -cne $value) { + Fail-Json $result "Failed to change the value for key '$key' in section '$section', the value is still $new_value" + } + } elseif ([string]$value -eq "") { + # Value was empty, so OK if no longer in the result + } else { + Fail-Json $result "The key '$key' in section '$section' is not a valid key, cannot set this value" + } + } +} + +Exit-Json $result diff --git a/test/support/windows-integration/plugins/modules/win_security_policy.py b/test/support/windows-integration/plugins/modules/win_security_policy.py new file mode 100644 index 00000000000..d582a532317 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_security_policy.py @@ -0,0 +1,126 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# this is a windows documentation stub, actual code lives in the .ps1 +# file of the same name + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: win_security_policy +version_added: '2.4' +short_description: Change local security policy settings +description: +- Allows you to set the local security policies that are configured by + SecEdit.exe. +options: + section: + description: + - The ini section the key exists in. + - If the section does not exist then the module will return an error. + - Example sections to use are 'Account Policies', 'Local Policies', + 'Event Log', 'Restricted Groups', 'System Services', 'Registry' and + 'File System' + - If wanting to edit the C(Privilege Rights) section, use the + M(win_user_right) module instead. + type: str + required: yes + key: + description: + - The ini key of the section or policy name to modify. + - The module will return an error if this key is invalid. + type: str + required: yes + value: + description: + - The value for the ini key or policy name. + - If the key takes in a boolean value then 0 = False and 1 = True. + type: str + required: yes +notes: +- This module uses the SecEdit.exe tool to configure the values, more details + of the areas and keys that can be configured can be found here + U(https://msdn.microsoft.com/en-us/library/bb742512.aspx). +- If you are in a domain environment these policies may be set by a GPO policy, + this module can temporarily change these values but the GPO will override + it if the value differs. +- You can also run C(SecEdit.exe /export /cfg C:\temp\output.ini) to view the + current policies set on your system. +- When assigning user rights, use the M(win_user_right) module instead. +seealso: +- module: win_user_right +author: +- Jordan Borean (@jborean93) +''' + +EXAMPLES = r''' +- name: Change the guest account name + win_security_policy: + section: System Access + key: NewGuestName + value: Guest Account + +- name: Set the maximum password age + win_security_policy: + section: System Access + key: MaximumPasswordAge + value: 15 + +- name: Do not store passwords using reversible encryption + win_security_policy: + section: System Access + key: ClearTextPassword + value: 0 + +- name: Enable system events + win_security_policy: + section: Event Audit + key: AuditSystemEvents + value: 1 +''' + +RETURN = r''' +rc: + description: The return code after a failure when running SecEdit.exe. + returned: failure with secedit calls + type: int + sample: -1 +stdout: + description: The output of the STDOUT buffer after a failure when running + SecEdit.exe. + returned: failure with secedit calls + type: str + sample: check log for error details +stderr: + description: The output of the STDERR buffer after a failure when running + SecEdit.exe. + returned: failure with secedit calls + type: str + sample: failed to import security policy +import_log: + description: The log of the SecEdit.exe /configure job that configured the + local policies. This is used for debugging purposes on failures. + returned: secedit.exe /import run and change occurred + type: str + sample: Completed 6 percent (0/15) \tProcess Privilege Rights area. +key: + description: The key in the section passed to the module to modify. + returned: success + type: str + sample: NewGuestName +section: + description: The section passed to the module to modify. + returned: success + type: str + sample: System Access +value: + description: The value passed to the module to modify to. + returned: success + type: str + sample: Guest Account +''' diff --git a/test/support/windows-integration/plugins/modules/win_shell.ps1 b/test/support/windows-integration/plugins/modules/win_shell.ps1 new file mode 100644 index 00000000000..54aef8de120 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_shell.ps1 @@ -0,0 +1,138 @@ +#!powershell + +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy +#Requires -Module Ansible.ModuleUtils.CommandUtil +#Requires -Module Ansible.ModuleUtils.FileUtil + +# TODO: add check mode support + +Set-StrictMode -Version 2 +$ErrorActionPreference = "Stop" + +# Cleanse CLIXML from stderr (sift out error stream data, discard others for now) +Function Cleanse-Stderr($raw_stderr) { + Try { + # NB: this regex isn't perfect, but is decent at finding CLIXML amongst other stderr noise + If($raw_stderr -match "(?s)(?.*)#< CLIXML(?.*)(?)(?.*)") { + $clixml = [xml]$matches["clixml"] + + $merged_stderr = "{0}{1}{2}{3}" -f @( + $matches["prenoise1"], + $matches["prenoise2"], + # filter out just the Error-tagged strings for now, and zap embedded CRLF chars + ($clixml.Objs.ChildNodes | Where-Object { $_.Name -eq 'S' } | Where-Object { $_.S -eq 'Error' } | ForEach-Object { $_.'#text'.Replace('_x000D__x000A_','') } | Out-String), + $matches["postnoise"]) | Out-String + + return $merged_stderr.Trim() + + # FUTURE: parse/return other streams + } + Else { + $raw_stderr + } + } + Catch { + "***EXCEPTION PARSING CLIXML: $_***" + $raw_stderr + } +} + +$params = Parse-Args $args -supports_check_mode $false + +$raw_command_line = Get-AnsibleParam -obj $params -name "_raw_params" -type "str" -failifempty $true +$chdir = Get-AnsibleParam -obj $params -name "chdir" -type "path" +$executable = Get-AnsibleParam -obj $params -name "executable" -type "path" +$creates = Get-AnsibleParam -obj $params -name "creates" -type "path" +$removes = Get-AnsibleParam -obj $params -name "removes" -type "path" +$stdin = Get-AnsibleParam -obj $params -name "stdin" -type "str" +$no_profile = Get-AnsibleParam -obj $params -name "no_profile" -type "bool" -default $false +$output_encoding_override = Get-AnsibleParam -obj $params -name "output_encoding_override" -type "str" + +$raw_command_line = $raw_command_line.Trim() + +$result = @{ + changed = $true + cmd = $raw_command_line +} + +if ($creates -and $(Test-AnsiblePath -Path $creates)) { + Exit-Json @{msg="skipped, since $creates exists";cmd=$raw_command_line;changed=$false;skipped=$true;rc=0} +} + +if ($removes -and -not $(Test-AnsiblePath -Path $removes)) { + Exit-Json @{msg="skipped, since $removes does not exist";cmd=$raw_command_line;changed=$false;skipped=$true;rc=0} +} + +$exec_args = $null +If(-not $executable -or $executable -eq "powershell") { + $exec_application = "powershell.exe" + + # force input encoding to preamble-free UTF8 so PS sub-processes (eg, Start-Job) don't blow up + $raw_command_line = "[Console]::InputEncoding = New-Object Text.UTF8Encoding `$false; " + $raw_command_line + + # Base64 encode the command so we don't have to worry about the various levels of escaping + $encoded_command = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($raw_command_line)) + + if ($stdin) { + $exec_args = "-encodedcommand $encoded_command" + } else { + $exec_args = "-noninteractive -encodedcommand $encoded_command" + } + + if ($no_profile) { + $exec_args = "-noprofile $exec_args" + } +} +Else { + # FUTURE: support arg translation from executable (or executable_args?) to process arguments for arbitrary interpreter? + $exec_application = $executable + if (-not ($exec_application.EndsWith(".exe"))) { + $exec_application = "$($exec_application).exe" + } + $exec_args = "/c $raw_command_line" +} + +$command = "`"$exec_application`" $exec_args" +$run_command_arg = @{ + command = $command +} +if ($chdir) { + $run_command_arg['working_directory'] = $chdir +} +if ($stdin) { + $run_command_arg['stdin'] = $stdin +} +if ($output_encoding_override) { + $run_command_arg['output_encoding_override'] = $output_encoding_override +} + +$start_datetime = [DateTime]::UtcNow +try { + $command_result = Run-Command @run_command_arg +} catch { + $result.changed = $false + try { + $result.rc = $_.Exception.NativeErrorCode + } catch { + $result.rc = 2 + } + Fail-Json -obj $result -message $_.Exception.Message +} + +# TODO: decode CLIXML stderr output (and other streams?) +$result.stdout = $command_result.stdout +$result.stderr = Cleanse-Stderr $command_result.stderr +$result.rc = $command_result.rc + +$end_datetime = [DateTime]::UtcNow +$result.start = $start_datetime.ToString("yyyy-MM-dd hh:mm:ss.ffffff") +$result.end = $end_datetime.ToString("yyyy-MM-dd hh:mm:ss.ffffff") +$result.delta = $($end_datetime - $start_datetime).ToString("h\:mm\:ss\.ffffff") + +If ($result.rc -ne 0) { + Fail-Json -obj $result -message "non-zero return code" +} + +Exit-Json $result diff --git a/test/support/windows-integration/plugins/modules/win_shell.py b/test/support/windows-integration/plugins/modules/win_shell.py new file mode 100644 index 00000000000..ee2cd76240d --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_shell.py @@ -0,0 +1,167 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2016, Ansible, inc +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'core'} + +DOCUMENTATION = r''' +--- +module: win_shell +short_description: Execute shell commands on target hosts +version_added: 2.2 +description: + - The C(win_shell) module takes the command name followed by a list of space-delimited arguments. + It is similar to the M(win_command) module, but runs + the command via a shell (defaults to PowerShell) on the target host. + - For non-Windows targets, use the M(shell) module instead. +options: + free_form: + description: + - The C(win_shell) module takes a free form command to run. + - There is no parameter actually named 'free form'. See the examples! + type: str + required: yes + creates: + description: + - A path or path filter pattern; when the referenced path exists on the target host, the task will be skipped. + type: path + removes: + description: + - A path or path filter pattern; when the referenced path B(does not) exist on the target host, the task will be skipped. + type: path + chdir: + description: + - Set the specified path as the current working directory before executing a command + type: path + executable: + description: + - Change the shell used to execute the command (eg, C(cmd)). + - The target shell must accept a C(/c) parameter followed by the raw command line to be executed. + type: path + stdin: + description: + - Set the stdin of the command directly to the specified value. + type: str + version_added: '2.5' + no_profile: + description: + - Do not load the user profile before running a command. This is only valid + when using PowerShell as the executable. + type: bool + default: no + version_added: '2.8' + output_encoding_override: + description: + - This option overrides the encoding of stdout/stderr output. + - You can use this option when you need to run a command which ignore the console's codepage. + - You should only need to use this option in very rare circumstances. + - This value can be any valid encoding C(Name) based on the output of C([System.Text.Encoding]::GetEncodings()). + See U(https://docs.microsoft.com/dotnet/api/system.text.encoding.getencodings). + type: str + version_added: '2.10' +notes: + - If you want to run an executable securely and predictably, it may be + better to use the M(win_command) module instead. Best practices when writing + playbooks will follow the trend of using M(win_command) unless C(win_shell) is + explicitly required. When running ad-hoc commands, use your best judgement. + - WinRM will not return from a command execution until all child processes created have exited. + Thus, it is not possible to use C(win_shell) to spawn long-running child or background processes. + Consider creating a Windows service for managing background processes. +seealso: +- module: psexec +- module: raw +- module: script +- module: shell +- module: win_command +- module: win_psexec +author: + - Matt Davis (@nitzmahone) +''' + +EXAMPLES = r''' +# Execute a command in the remote shell; stdout goes to the specified +# file on the remote. +- win_shell: C:\somescript.ps1 >> C:\somelog.txt + +# Change the working directory to somedir/ before executing the command. +- win_shell: C:\somescript.ps1 >> C:\somelog.txt chdir=C:\somedir + +# You can also use the 'args' form to provide the options. This command +# will change the working directory to somedir/ and will only run when +# somedir/somelog.txt doesn't exist. +- win_shell: C:\somescript.ps1 >> C:\somelog.txt + args: + chdir: C:\somedir + creates: C:\somelog.txt + +# Run a command under a non-Powershell interpreter (cmd in this case) +- win_shell: echo %HOMEDIR% + args: + executable: cmd + register: homedir_out + +- name: Run multi-lined shell commands + win_shell: | + $value = Test-Path -Path C:\temp + if ($value) { + Remove-Item -Path C:\temp -Force + } + New-Item -Path C:\temp -ItemType Directory + +- name: Retrieve the input based on stdin + win_shell: '$string = [Console]::In.ReadToEnd(); Write-Output $string.Trim()' + args: + stdin: Input message +''' + +RETURN = r''' +msg: + description: Changed. + returned: always + type: bool + sample: true +start: + description: The command execution start time. + returned: always + type: str + sample: '2016-02-25 09:18:26.429568' +end: + description: The command execution end time. + returned: always + type: str + sample: '2016-02-25 09:18:26.755339' +delta: + description: The command execution delta time. + returned: always + type: str + sample: '0:00:00.325771' +stdout: + description: The command standard output. + returned: always + type: str + sample: 'Clustering node rabbit@slave1 with rabbit@master ...' +stderr: + description: The command standard error. + returned: always + type: str + sample: 'ls: cannot access foo: No such file or directory' +cmd: + description: The command executed by the task. + returned: always + type: str + sample: 'rabbitmqctl join_cluster rabbit@master' +rc: + description: The command return code (0 means success). + returned: always + type: int + sample: 0 +stdout_lines: + description: The command standard output split in lines. + returned: always + type: list + sample: [u'Clustering node rabbit@slave1 with rabbit@master ...'] +''' diff --git a/test/support/windows-integration/plugins/modules/win_stat.ps1 b/test/support/windows-integration/plugins/modules/win_stat.ps1 new file mode 100644 index 00000000000..071eb11ceb1 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_stat.ps1 @@ -0,0 +1,186 @@ +#!powershell + +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module Ansible.ModuleUtils.FileUtil +#Requires -Module Ansible.ModuleUtils.LinkUtil + +function ConvertTo-Timestamp($start_date, $end_date) { + if ($start_date -and $end_date) { + return (New-TimeSpan -Start $start_date -End $end_date).TotalSeconds + } +} + +function Get-FileChecksum($path, $algorithm) { + switch ($algorithm) { + 'md5' { $sp = New-Object -TypeName System.Security.Cryptography.MD5CryptoServiceProvider } + 'sha1' { $sp = New-Object -TypeName System.Security.Cryptography.SHA1CryptoServiceProvider } + 'sha256' { $sp = New-Object -TypeName System.Security.Cryptography.SHA256CryptoServiceProvider } + 'sha384' { $sp = New-Object -TypeName System.Security.Cryptography.SHA384CryptoServiceProvider } + 'sha512' { $sp = New-Object -TypeName System.Security.Cryptography.SHA512CryptoServiceProvider } + default { Fail-Json -obj $result -message "Unsupported hash algorithm supplied '$algorithm'" } + } + + $fp = [System.IO.File]::Open($path, [System.IO.Filemode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) + try { + $hash = [System.BitConverter]::ToString($sp.ComputeHash($fp)).Replace("-", "").ToLower() + } finally { + $fp.Dispose() + } + + return $hash +} + +function Get-FileInfo { + param([String]$Path, [Switch]$Follow) + + $info = Get-AnsibleItem -Path $Path -ErrorAction SilentlyContinue + $link_info = $null + if ($null -ne $info) { + try { + $link_info = Get-Link -link_path $info.FullName + } catch { + $module.Warn("Failed to check/get link info for file: $($_.Exception.Message)") + } + + # If follow=true we want to follow the link all the way back to root object + if ($Follow -and $null -ne $link_info -and $link_info.Type -in @("SymbolicLink", "JunctionPoint")) { + $info, $link_info = Get-FileInfo -Path $link_info.AbsolutePath -Follow + } + } + + return $info, $link_info +} + +$spec = @{ + options = @{ + path = @{ type='path'; required=$true; aliases=@( 'dest', 'name' ) } + get_checksum = @{ type='bool'; default=$true } + checksum_algorithm = @{ type='str'; default='sha1'; choices=@( 'md5', 'sha1', 'sha256', 'sha384', 'sha512' ) } + follow = @{ type='bool'; default=$false } + } + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$path = $module.Params.path +$get_checksum = $module.Params.get_checksum +$checksum_algorithm = $module.Params.checksum_algorithm +$follow = $module.Params.follow + +$module.Result.stat = @{ exists=$false } + +Load-LinkUtils +$info, $link_info = Get-FileInfo -Path $path -Follow:$follow +If ($null -ne $info) { + $epoch_date = Get-Date -Date "01/01/1970" + $attributes = @() + foreach ($attribute in ($info.Attributes -split ',')) { + $attributes += $attribute.Trim() + } + + # default values that are always set, specific values are set below this + # but are kept commented for easier readability + $stat = @{ + exists = $true + attributes = $info.Attributes.ToString() + isarchive = ($attributes -contains "Archive") + isdir = $false + ishidden = ($attributes -contains "Hidden") + isjunction = $false + islnk = $false + isreadonly = ($attributes -contains "ReadOnly") + isreg = $false + isshared = $false + nlink = 1 # Number of links to the file (hard links), overriden below if islnk + # lnk_target = islnk or isjunction Target of the symlink. Note that relative paths remain relative + # lnk_source = islnk os isjunction Target of the symlink normalized for the remote filesystem + hlnk_targets = @() + creationtime = (ConvertTo-Timestamp -start_date $epoch_date -end_date $info.CreationTime) + lastaccesstime = (ConvertTo-Timestamp -start_date $epoch_date -end_date $info.LastAccessTime) + lastwritetime = (ConvertTo-Timestamp -start_date $epoch_date -end_date $info.LastWriteTime) + # size = a file and directory - calculated below + path = $info.FullName + filename = $info.Name + # extension = a file + # owner = set outsite this dict in case it fails + # sharename = a directory and isshared is True + # checksum = a file and get_checksum: True + } + try { + $stat.owner = $info.GetAccessControl().Owner + } catch { + # may not have rights, historical behaviour was to just set to $null + # due to ErrorActionPreference being set to "Continue" + $stat.owner = $null + } + + # values that are set according to the type of file + if ($info.Attributes.HasFlag([System.IO.FileAttributes]::Directory)) { + $stat.isdir = $true + $share_info = Get-CimInstance -ClassName Win32_Share -Filter "Path='$($stat.path -replace '\\', '\\')'" + if ($null -ne $share_info) { + $stat.isshared = $true + $stat.sharename = $share_info.Name + } + + try { + $size = 0 + foreach ($file in $info.EnumerateFiles("*", [System.IO.SearchOption]::AllDirectories)) { + $size += $file.Length + } + $stat.size = $size + } catch { + $stat.size = 0 + } + } else { + $stat.extension = $info.Extension + $stat.isreg = $true + $stat.size = $info.Length + + if ($get_checksum) { + try { + $stat.checksum = Get-FileChecksum -path $path -algorithm $checksum_algorithm + } catch { + $module.FailJson("Failed to get hash of file, set get_checksum to False to ignore this error: $($_.Exception.Message)", $_) + } + } + } + + # Get symbolic link, junction point, hard link info + if ($null -ne $link_info) { + switch ($link_info.Type) { + "SymbolicLink" { + $stat.islnk = $true + $stat.isreg = $false + $stat.lnk_target = $link_info.TargetPath + $stat.lnk_source = $link_info.AbsolutePath + break + } + "JunctionPoint" { + $stat.isjunction = $true + $stat.isreg = $false + $stat.lnk_target = $link_info.TargetPath + $stat.lnk_source = $link_info.AbsolutePath + break + } + "HardLink" { + $stat.lnk_type = "hard" + $stat.nlink = $link_info.HardTargets.Count + + # remove current path from the targets + $hlnk_targets = $link_info.HardTargets | Where-Object { $_ -ne $stat.path } + $stat.hlnk_targets = @($hlnk_targets) + break + } + } + } + + $module.Result.stat = $stat +} + +$module.ExitJson() + diff --git a/test/support/windows-integration/plugins/modules/win_stat.py b/test/support/windows-integration/plugins/modules/win_stat.py new file mode 100644 index 00000000000..0676b5b2350 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_stat.py @@ -0,0 +1,236 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# this is a windows documentation stub. actual code lives in the .ps1 +# file of the same name + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['stableinterface'], + 'supported_by': 'core'} + +DOCUMENTATION = r''' +--- +module: win_stat +version_added: "1.7" +short_description: Get information about Windows files +description: + - Returns information about a Windows file. + - For non-Windows targets, use the M(stat) module instead. +options: + path: + description: + - The full path of the file/object to get the facts of; both forward and + back slashes are accepted. + type: path + required: yes + aliases: [ dest, name ] + get_checksum: + description: + - Whether to return a checksum of the file (default sha1) + type: bool + default: yes + version_added: "2.1" + checksum_algorithm: + description: + - Algorithm to determine checksum of file. + - Will throw an error if the host is unable to use specified algorithm. + type: str + default: sha1 + choices: [ md5, sha1, sha256, sha384, sha512 ] + version_added: "2.3" + follow: + description: + - Whether to follow symlinks or junction points. + - In the case of C(path) pointing to another link, then that will + be followed until no more links are found. + type: bool + default: no + version_added: "2.8" +seealso: +- module: stat +- module: win_acl +- module: win_file +- module: win_owner +author: +- Chris Church (@cchurch) +''' + +EXAMPLES = r''' +- name: Obtain information about a file + win_stat: + path: C:\foo.ini + register: file_info + +- name: Obtain information about a folder + win_stat: + path: C:\bar + register: folder_info + +- name: Get MD5 checksum of a file + win_stat: + path: C:\foo.ini + get_checksum: yes + checksum_algorithm: md5 + register: md5_checksum + +- debug: + var: md5_checksum.stat.checksum + +- name: Get SHA1 checksum of file + win_stat: + path: C:\foo.ini + get_checksum: yes + register: sha1_checksum + +- debug: + var: sha1_checksum.stat.checksum + +- name: Get SHA256 checksum of file + win_stat: + path: C:\foo.ini + get_checksum: yes + checksum_algorithm: sha256 + register: sha256_checksum + +- debug: + var: sha256_checksum.stat.checksum +''' + +RETURN = r''' +changed: + description: Whether anything was changed + returned: always + type: bool + sample: true +stat: + description: dictionary containing all the stat data + returned: success + type: complex + contains: + attributes: + description: Attributes of the file at path in raw form. + returned: success, path exists + type: str + sample: "Archive, Hidden" + checksum: + description: The checksum of a file based on checksum_algorithm specified. + returned: success, path exist, path is a file, get_checksum == True + checksum_algorithm specified is supported + type: str + sample: 09cb79e8fc7453c84a07f644e441fd81623b7f98 + creationtime: + description: The create time of the file represented in seconds since epoch. + returned: success, path exists + type: float + sample: 1477984205.15 + exists: + description: If the path exists or not. + returned: success + type: bool + sample: true + extension: + description: The extension of the file at path. + returned: success, path exists, path is a file + type: str + sample: ".ps1" + filename: + description: The name of the file (without path). + returned: success, path exists, path is a file + type: str + sample: foo.ini + hlnk_targets: + description: List of other files pointing to the same file (hard links), excludes the current file. + returned: success, path exists + type: list + sample: + - C:\temp\file.txt + - C:\Windows\update.log + isarchive: + description: If the path is ready for archiving or not. + returned: success, path exists + type: bool + sample: true + isdir: + description: If the path is a directory or not. + returned: success, path exists + type: bool + sample: true + ishidden: + description: If the path is hidden or not. + returned: success, path exists + type: bool + sample: true + isjunction: + description: If the path is a junction point or not. + returned: success, path exists + type: bool + sample: true + islnk: + description: If the path is a symbolic link or not. + returned: success, path exists + type: bool + sample: true + isreadonly: + description: If the path is read only or not. + returned: success, path exists + type: bool + sample: true + isreg: + description: If the path is a regular file. + returned: success, path exists + type: bool + sample: true + isshared: + description: If the path is shared or not. + returned: success, path exists + type: bool + sample: true + lastaccesstime: + description: The last access time of the file represented in seconds since epoch. + returned: success, path exists + type: float + sample: 1477984205.15 + lastwritetime: + description: The last modification time of the file represented in seconds since epoch. + returned: success, path exists + type: float + sample: 1477984205.15 + lnk_source: + description: Target of the symlink normalized for the remote filesystem. + returned: success, path exists and the path is a symbolic link or junction point + type: str + sample: C:\temp\link + lnk_target: + description: Target of the symlink. Note that relative paths remain relative. + returned: success, path exists and the path is a symbolic link or junction point + type: str + sample: ..\link + nlink: + description: Number of links to the file (hard links). + returned: success, path exists + type: int + sample: 1 + owner: + description: The owner of the file. + returned: success, path exists + type: str + sample: BUILTIN\Administrators + path: + description: The full absolute path to the file. + returned: success, path exists, file exists + type: str + sample: C:\foo.ini + sharename: + description: The name of share if folder is shared. + returned: success, path exists, file is a directory and isshared == True + type: str + sample: file-share + size: + description: The size in bytes of a file or folder. + returned: success, path exists, file is not a link + type: int + sample: 1024 +''' diff --git a/test/support/windows-integration/plugins/modules/win_tempfile.ps1 b/test/support/windows-integration/plugins/modules/win_tempfile.ps1 new file mode 100644 index 00000000000..9a1a7174397 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_tempfile.ps1 @@ -0,0 +1,72 @@ +#!powershell + +# Copyright: (c) 2017, Dag Wieers (@dagwieers) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic + +Function New-TempFile { + Param ([string]$path, [string]$prefix, [string]$suffix, [string]$type, [bool]$checkmode) + $temppath = $null + $curerror = $null + $attempt = 0 + + # Since we don't know if the file already exists, we try 5 times with a random name + do { + $attempt += 1 + $randomname = [System.IO.Path]::GetRandomFileName() + $temppath = (Join-Path -Path $path -ChildPath "$prefix$randomname$suffix") + Try { + $file = New-Item -Path $temppath -ItemType $type -WhatIf:$checkmode + # Makes sure we get the full absolute path of the created temp file and not a relative or DOS 8.3 dir + if (-not $checkmode) { + $temppath = $file.FullName + } else { + # Just rely on GetFulLpath for check mode + $temppath = [System.IO.Path]::GetFullPath($temppath) + } + } Catch { + $temppath = $null + $curerror = $_ + } + } until (($null -ne $temppath) -or ($attempt -ge 5)) + + # If it fails 5 times, something is wrong and we have to report the details + if ($null -eq $temppath) { + $module.FailJson("No random temporary file worked in $attempt attempts. Error: $($curerror.Exception.Message)", $curerror) + } + + return $temppath.ToString() +} + +$spec = @{ + options = @{ + path = @{ type='path'; default='%TEMP%'; aliases=@( 'dest' ) } + state = @{ type='str'; default='file'; choices=@( 'directory', 'file') } + prefix = @{ type='str'; default='ansible.' } + suffix = @{ type='str' } + } + supports_check_mode = $true +} + +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$path = $module.Params.path +$state = $module.Params.state +$prefix = $module.Params.prefix +$suffix = $module.Params.suffix + +# Expand environment variables on non-path types +if ($null -ne $prefix) { + $prefix = [System.Environment]::ExpandEnvironmentVariables($prefix) +} +if ($null -ne $suffix) { + $suffix = [System.Environment]::ExpandEnvironmentVariables($suffix) +} + +$module.Result.changed = $true +$module.Result.state = $state + +$module.Result.path = New-TempFile -Path $path -Prefix $prefix -Suffix $suffix -Type $state -CheckMode $module.CheckMode + +$module.ExitJson() diff --git a/test/support/windows-integration/plugins/modules/win_tempfile.py b/test/support/windows-integration/plugins/modules/win_tempfile.py new file mode 100644 index 00000000000..58dd6501373 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_tempfile.py @@ -0,0 +1,67 @@ +#!/usr/bin/python +# coding: utf-8 -*- + +# Copyright: (c) 2017, Dag Wieers +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: win_tempfile +version_added: "2.3" +short_description: Creates temporary files and directories +description: + - Creates temporary files and directories. + - For non-Windows targets, please use the M(tempfile) module instead. +options: + state: + description: + - Whether to create file or directory. + type: str + choices: [ directory, file ] + default: file + path: + description: + - Location where temporary file or directory should be created. + - If path is not specified default system temporary directory (%TEMP%) will be used. + type: path + default: '%TEMP%' + aliases: [ dest ] + prefix: + description: + - Prefix of file/directory name created by module. + type: str + default: ansible. + suffix: + description: + - Suffix of file/directory name created by module. + type: str + default: '' +seealso: +- module: tempfile +author: +- Dag Wieers (@dagwieers) +''' + +EXAMPLES = r""" +- name: Create temporary build directory + win_tempfile: + state: directory + suffix: build + +- name: Create temporary file + win_tempfile: + state: file + suffix: temp +""" + +RETURN = r''' +path: + description: The absolute path to the created file or directory. + returned: success + type: str + sample: C:\Users\Administrator\AppData\Local\Temp\ansible.bMlvdk +''' diff --git a/test/support/windows-integration/plugins/modules/win_template.py b/test/support/windows-integration/plugins/modules/win_template.py new file mode 100644 index 00000000000..bd8b2492fa6 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_template.py @@ -0,0 +1,66 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# this is a virtual module that is entirely implemented server side + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['stableinterface'], + 'supported_by': 'core'} + +DOCUMENTATION = r''' +--- +module: win_template +version_added: "1.9.2" +short_description: Template a file out to a remote server +options: + backup: + description: + - Determine whether a backup should be created. + - When set to C(yes), create a backup file including the timestamp information + so you can get the original file back if you somehow clobbered it incorrectly. + type: bool + default: no + version_added: '2.8' + newline_sequence: + default: '\r\n' + force: + version_added: '2.4' +notes: +- Beware fetching files from windows machines when creating templates because certain tools, such as Powershell ISE, + and regedit's export facility add a Byte Order Mark as the first character of the file, which can cause tracebacks. +- You can use the M(win_copy) module with the C(content:) option if you prefer the template inline, as part of the + playbook. +- For Linux you can use M(template) which uses '\\n' as C(newline_sequence) by default. +seealso: +- module: win_copy +- module: copy +- module: template +author: +- Jon Hawkesworth (@jhawkesworth) +extends_documentation_fragment: +- template_common +''' + +EXAMPLES = r''' +- name: Create a file from a Jinja2 template + win_template: + src: /mytemplates/file.conf.j2 + dest: C:\Temp\file.conf + +- name: Create a Unix-style file from a Jinja2 template + win_template: + src: unix/config.conf.j2 + dest: C:\share\unix\config.conf + newline_sequence: '\n' + backup: yes +''' + +RETURN = r''' +backup_file: + description: Name of the backup file that was created. + returned: if backup=yes + type: str + sample: C:\Path\To\File.txt.11540.20150212-220915.bak +''' diff --git a/test/support/windows-integration/plugins/modules/win_user.ps1 b/test/support/windows-integration/plugins/modules/win_user.ps1 new file mode 100644 index 00000000000..54905cb2eb6 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_user.ps1 @@ -0,0 +1,273 @@ +#!powershell + +# Copyright: (c) 2014, Paul Durivage +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.AccessToken +#Requires -Module Ansible.ModuleUtils.Legacy + +######## +$ADS_UF_PASSWD_CANT_CHANGE = 64 +$ADS_UF_DONT_EXPIRE_PASSWD = 65536 + +$adsi = [ADSI]"WinNT://$env:COMPUTERNAME" + +function Get-User($user) { + $adsi.Children | Where-Object {$_.SchemaClassName -eq 'user' -and $_.Name -eq $user } + return +} + +function Get-UserFlag($user, $flag) { + If ($user.UserFlags[0] -band $flag) { + $true + } + Else { + $false + } +} + +function Set-UserFlag($user, $flag) { + $user.UserFlags = ($user.UserFlags[0] -BOR $flag) +} + +function Clear-UserFlag($user, $flag) { + $user.UserFlags = ($user.UserFlags[0] -BXOR $flag) +} + +function Get-Group($grp) { + $adsi.Children | Where-Object { $_.SchemaClassName -eq 'Group' -and $_.Name -eq $grp } + return +} + +Function Test-LocalCredential { + param([String]$Username, [String]$Password) + + try { + $handle = [Ansible.AccessToken.TokenUtil]::LogonUser($Username, $null, $Password, "Network", "Default") + $handle.Dispose() + $valid_credentials = $true + } catch [Ansible.AccessToken.Win32Exception] { + # following errors indicate the creds are correct but the user was + # unable to log on for other reasons, which we don't care about + $success_codes = @( + 0x0000052F, # ERROR_ACCOUNT_RESTRICTION + 0x00000530, # ERROR_INVALID_LOGON_HOURS + 0x00000531, # ERROR_INVALID_WORKSTATION + 0x00000569 # ERROR_LOGON_TYPE_GRANTED + ) + + if ($_.Exception.NativeErrorCode -eq 0x0000052E) { + # ERROR_LOGON_FAILURE - the user or pass was incorrect + $valid_credentials = $false + } elseif ($_.Exception.NativeErrorCode -in $success_codes) { + $valid_credentials = $true + } else { + # an unknown failure, reraise exception + throw $_ + } + } + return $valid_credentials +} + +######## + +$params = Parse-Args $args; + +$result = @{ + changed = $false +}; + +$username = Get-AnsibleParam -obj $params -name "name" -type "str" -failifempty $true +$fullname = Get-AnsibleParam -obj $params -name "fullname" -type "str" +$description = Get-AnsibleParam -obj $params -name "description" -type "str" +$password = Get-AnsibleParam -obj $params -name "password" -type "str" +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "present" -validateset "present","absent","query" +$update_password = Get-AnsibleParam -obj $params -name "update_password" -type "str" -default "always" -validateset "always","on_create" +$password_expired = Get-AnsibleParam -obj $params -name "password_expired" -type "bool" +$password_never_expires = Get-AnsibleParam -obj $params -name "password_never_expires" -type "bool" +$user_cannot_change_password = Get-AnsibleParam -obj $params -name "user_cannot_change_password" -type "bool" +$account_disabled = Get-AnsibleParam -obj $params -name "account_disabled" -type "bool" +$account_locked = Get-AnsibleParam -obj $params -name "account_locked" -type "bool" +$groups = Get-AnsibleParam -obj $params -name "groups" +$groups_action = Get-AnsibleParam -obj $params -name "groups_action" -type "str" -default "replace" -validateset "add","remove","replace" + +If ($null -ne $account_locked -and $account_locked) { + Fail-Json $result "account_locked must be set to 'no' if provided" +} + +If ($null -ne $groups) { + If ($groups -is [System.String]) { + [string[]]$groups = $groups.Split(",") + } + ElseIf ($groups -isnot [System.Collections.IList]) { + Fail-Json $result "groups must be a string or array" + } + $groups = $groups | ForEach-Object { ([string]$_).Trim() } | Where-Object { $_ } + If ($null -eq $groups) { + $groups = @() + } +} + +$user_obj = Get-User $username + +If ($state -eq 'present') { + # Add or update user + try { + If (-not $user_obj) { + $user_obj = $adsi.Create("User", $username) + If ($null -ne $password) { + $user_obj.SetPassword($password) + } + $user_obj.SetInfo() + $result.changed = $true + } + ElseIf (($null -ne $password) -and ($update_password -eq 'always')) { + # ValidateCredentials will fail if either of these are true- just force update... + If($user_obj.AccountDisabled -or $user_obj.PasswordExpired) { + $password_match = $false + } + Else { + try { + $password_match = Test-LocalCredential -Username $username -Password $password + } catch [System.ComponentModel.Win32Exception] { + Fail-Json -obj $result -message "Failed to validate the user's credentials: $($_.Exception.Message)" + } + } + + If (-not $password_match) { + $user_obj.SetPassword($password) + $result.changed = $true + } + } + If (($null -ne $fullname) -and ($fullname -ne $user_obj.FullName[0])) { + $user_obj.FullName = $fullname + $result.changed = $true + } + If (($null -ne $description) -and ($description -ne $user_obj.Description[0])) { + $user_obj.Description = $description + $result.changed = $true + } + If (($null -ne $password_expired) -and ($password_expired -ne ($user_obj.PasswordExpired | ConvertTo-Bool))) { + $user_obj.PasswordExpired = If ($password_expired) { 1 } Else { 0 } + $result.changed = $true + } + If (($null -ne $password_never_expires) -and ($password_never_expires -ne (Get-UserFlag $user_obj $ADS_UF_DONT_EXPIRE_PASSWD))) { + If ($password_never_expires) { + Set-UserFlag $user_obj $ADS_UF_DONT_EXPIRE_PASSWD + } + Else { + Clear-UserFlag $user_obj $ADS_UF_DONT_EXPIRE_PASSWD + } + $result.changed = $true + } + If (($null -ne $user_cannot_change_password) -and ($user_cannot_change_password -ne (Get-UserFlag $user_obj $ADS_UF_PASSWD_CANT_CHANGE))) { + If ($user_cannot_change_password) { + Set-UserFlag $user_obj $ADS_UF_PASSWD_CANT_CHANGE + } + Else { + Clear-UserFlag $user_obj $ADS_UF_PASSWD_CANT_CHANGE + } + $result.changed = $true + } + If (($null -ne $account_disabled) -and ($account_disabled -ne $user_obj.AccountDisabled)) { + $user_obj.AccountDisabled = $account_disabled + $result.changed = $true + } + If (($null -ne $account_locked) -and ($account_locked -ne $user_obj.IsAccountLocked)) { + $user_obj.IsAccountLocked = $account_locked + $result.changed = $true + } + If ($result.changed) { + $user_obj.SetInfo() + } + If ($null -ne $groups) { + [string[]]$current_groups = $user_obj.Groups() | ForEach-Object { $_.GetType().InvokeMember("Name", "GetProperty", $null, $_, $null) } + If (($groups_action -eq "remove") -or ($groups_action -eq "replace")) { + ForEach ($grp in $current_groups) { + If ((($groups_action -eq "remove") -and ($groups -contains $grp)) -or (($groups_action -eq "replace") -and ($groups -notcontains $grp))) { + $group_obj = Get-Group $grp + If ($group_obj) { + $group_obj.Remove($user_obj.Path) + $result.changed = $true + } + Else { + Fail-Json $result "group '$grp' not found" + } + } + } + } + If (($groups_action -eq "add") -or ($groups_action -eq "replace")) { + ForEach ($grp in $groups) { + If ($current_groups -notcontains $grp) { + $group_obj = Get-Group $grp + If ($group_obj) { + $group_obj.Add($user_obj.Path) + $result.changed = $true + } + Else { + Fail-Json $result "group '$grp' not found" + } + } + } + } + } + } + catch { + Fail-Json $result $_.Exception.Message + } +} +ElseIf ($state -eq 'absent') { + # Remove user + try { + If ($user_obj) { + $username = $user_obj.Name.Value + $adsi.delete("User", $user_obj.Name.Value) + $result.changed = $true + $result.msg = "User '$username' deleted successfully" + $user_obj = $null + } else { + $result.msg = "User '$username' was not found" + } + } + catch { + Fail-Json $result $_.Exception.Message + } +} + +try { + If ($user_obj -and $user_obj -is [System.DirectoryServices.DirectoryEntry]) { + $user_obj.RefreshCache() + $result.name = $user_obj.Name[0] + $result.fullname = $user_obj.FullName[0] + $result.path = $user_obj.Path + $result.description = $user_obj.Description[0] + $result.password_expired = ($user_obj.PasswordExpired | ConvertTo-Bool) + $result.password_never_expires = (Get-UserFlag $user_obj $ADS_UF_DONT_EXPIRE_PASSWD) + $result.user_cannot_change_password = (Get-UserFlag $user_obj $ADS_UF_PASSWD_CANT_CHANGE) + $result.account_disabled = $user_obj.AccountDisabled + $result.account_locked = $user_obj.IsAccountLocked + $result.sid = (New-Object System.Security.Principal.SecurityIdentifier($user_obj.ObjectSid.Value, 0)).Value + $user_groups = @() + ForEach ($grp in $user_obj.Groups()) { + $group_result = @{ + name = $grp.GetType().InvokeMember("Name", "GetProperty", $null, $grp, $null) + path = $grp.GetType().InvokeMember("ADsPath", "GetProperty", $null, $grp, $null) + } + $user_groups += $group_result; + } + $result.groups = $user_groups + $result.state = "present" + } + Else { + $result.name = $username + if ($state -eq 'query') { + $result.msg = "User '$username' was not found" + } + $result.state = "absent" + } +} +catch { + Fail-Json $result $_.Exception.Message +} + +Exit-Json $result diff --git a/test/support/windows-integration/plugins/modules/win_user.py b/test/support/windows-integration/plugins/modules/win_user.py new file mode 100644 index 00000000000..5fc0633d06b --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_user.py @@ -0,0 +1,194 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2014, Matt Martz , and others +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# this is a windows documentation stub. actual code lives in the .ps1 +# file of the same name + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['stableinterface'], + 'supported_by': 'core'} + +DOCUMENTATION = r''' +--- +module: win_user +version_added: "1.7" +short_description: Manages local Windows user accounts +description: + - Manages local Windows user accounts. + - For non-Windows targets, use the M(user) module instead. +options: + name: + description: + - Name of the user to create, remove or modify. + type: str + required: yes + fullname: + description: + - Full name of the user. + type: str + version_added: "1.9" + description: + description: + - Description of the user. + type: str + version_added: "1.9" + password: + description: + - Optionally set the user's password to this (plain text) value. + type: str + update_password: + description: + - C(always) will update passwords if they differ. C(on_create) will + only set the password for newly created users. + type: str + choices: [ always, on_create ] + default: always + version_added: "1.9" + password_expired: + description: + - C(yes) will require the user to change their password at next login. + - C(no) will clear the expired password flag. + type: bool + version_added: "1.9" + password_never_expires: + description: + - C(yes) will set the password to never expire. + - C(no) will allow the password to expire. + type: bool + version_added: "1.9" + user_cannot_change_password: + description: + - C(yes) will prevent the user from changing their password. + - C(no) will allow the user to change their password. + type: bool + version_added: "1.9" + account_disabled: + description: + - C(yes) will disable the user account. + - C(no) will clear the disabled flag. + type: bool + version_added: "1.9" + account_locked: + description: + - C(no) will unlock the user account if locked. + choices: [ 'no' ] + version_added: "1.9" + groups: + description: + - Adds or removes the user from this comma-separated list of groups, + depending on the value of I(groups_action). + - When I(groups_action) is C(replace) and I(groups) is set to the empty + string ('groups='), the user is removed from all groups. + version_added: "1.9" + groups_action: + description: + - If C(add), the user is added to each group in I(groups) where not + already a member. + - If C(replace), the user is added as a member of each group in + I(groups) and removed from any other groups. + - If C(remove), the user is removed from each group in I(groups). + type: str + choices: [ add, replace, remove ] + default: replace + version_added: "1.9" + state: + description: + - When C(absent), removes the user account if it exists. + - When C(present), creates or updates the user account. + - When C(query) (new in 1.9), retrieves the user account details + without making any changes. + type: str + choices: [ absent, present, query ] + default: present +seealso: +- module: user +- module: win_domain_membership +- module: win_domain_user +- module: win_group +- module: win_group_membership +- module: win_user_profile +author: + - Paul Durivage (@angstwad) + - Chris Church (@cchurch) +''' + +EXAMPLES = r''' +- name: Ensure user bob is present + win_user: + name: bob + password: B0bP4ssw0rd + state: present + groups: + - Users + +- name: Ensure user bob is absent + win_user: + name: bob + state: absent +''' + +RETURN = r''' +account_disabled: + description: Whether the user is disabled. + returned: user exists + type: bool + sample: false +account_locked: + description: Whether the user is locked. + returned: user exists + type: bool + sample: false +description: + description: The description set for the user. + returned: user exists + type: str + sample: Username for test +fullname: + description: The full name set for the user. + returned: user exists + type: str + sample: Test Username +groups: + description: A list of groups and their ADSI path the user is a member of. + returned: user exists + type: list + sample: [ + { + "name": "Administrators", + "path": "WinNT://WORKGROUP/USER-PC/Administrators" + } + ] +name: + description: The name of the user + returned: always + type: str + sample: username +password_expired: + description: Whether the password is expired. + returned: user exists + type: bool + sample: false +password_never_expires: + description: Whether the password is set to never expire. + returned: user exists + type: bool + sample: true +path: + description: The ADSI path for the user. + returned: user exists + type: str + sample: "WinNT://WORKGROUP/USER-PC/username" +sid: + description: The SID for the user. + returned: user exists + type: str + sample: S-1-5-21-3322259488-2828151810-3939402796-1001 +user_cannot_change_password: + description: Whether the user can change their own password. + returned: user exists + type: bool + sample: false +''' diff --git a/test/support/windows-integration/plugins/modules/win_user_right.ps1 b/test/support/windows-integration/plugins/modules/win_user_right.ps1 new file mode 100644 index 00000000000..3fac52a8a82 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_user_right.ps1 @@ -0,0 +1,349 @@ +#!powershell + +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy +#Requires -Module Ansible.ModuleUtils.SID + +$ErrorActionPreference = 'Stop' + +$params = Parse-Args $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false +$diff_mode = Get-AnsibleParam -obj $params -name "_ansible_diff" -type "bool" -default $false +$_remote_tmp = Get-AnsibleParam $params "_ansible_remote_tmp" -type "path" -default $env:TMP + +$name = Get-AnsibleParam -obj $params -name "name" -type "str" -failifempty $true +$users = Get-AnsibleParam -obj $params -name "users" -type "list" -failifempty $true +$action = Get-AnsibleParam -obj $params -name "action" -type "str" -default "set" -validateset "add","remove","set" + +$result = @{ + changed = $false + added = @() + removed = @() +} + +if ($diff_mode) { + $result.diff = @{} +} + +$sec_helper_util = @" +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.Principal; + +namespace Ansible +{ + public class LsaRightHelper : IDisposable + { + // Code modified from https://gallery.technet.microsoft.com/scriptcenter/Grant-Revoke-Query-user-26e259b0 + + enum Access : int + { + POLICY_READ = 0x20006, + POLICY_ALL_ACCESS = 0x00F0FFF, + POLICY_EXECUTE = 0X20801, + POLICY_WRITE = 0X207F8 + } + + IntPtr lsaHandle; + + const string LSA_DLL = "advapi32.dll"; + const CharSet DEFAULT_CHAR_SET = CharSet.Unicode; + + const uint STATUS_NO_MORE_ENTRIES = 0x8000001a; + const uint STATUS_NO_SUCH_PRIVILEGE = 0xc0000060; + + internal sealed class Sid : IDisposable + { + public IntPtr pSid = IntPtr.Zero; + public SecurityIdentifier sid = null; + + public Sid(string sidString) + { + try + { + sid = new SecurityIdentifier(sidString); + } catch + { + throw new ArgumentException(String.Format("SID string {0} could not be converted to SecurityIdentifier", sidString)); + } + + Byte[] buffer = new Byte[sid.BinaryLength]; + sid.GetBinaryForm(buffer, 0); + + pSid = Marshal.AllocHGlobal(sid.BinaryLength); + Marshal.Copy(buffer, 0, pSid, sid.BinaryLength); + } + + public void Dispose() + { + if (pSid != IntPtr.Zero) + { + Marshal.FreeHGlobal(pSid); + pSid = IntPtr.Zero; + } + GC.SuppressFinalize(this); + } + ~Sid() { Dispose(); } + } + + [StructLayout(LayoutKind.Sequential)] + private struct LSA_OBJECT_ATTRIBUTES + { + public int Length; + public IntPtr RootDirectory; + public IntPtr ObjectName; + public int Attributes; + public IntPtr SecurityDescriptor; + public IntPtr SecurityQualityOfService; + } + + [StructLayout(LayoutKind.Sequential, CharSet = DEFAULT_CHAR_SET)] + private struct LSA_UNICODE_STRING + { + public ushort Length; + public ushort MaximumLength; + [MarshalAs(UnmanagedType.LPWStr)] + public string Buffer; + } + + [StructLayout(LayoutKind.Sequential)] + private struct LSA_ENUMERATION_INFORMATION + { + public IntPtr Sid; + } + + [DllImport(LSA_DLL, CharSet = DEFAULT_CHAR_SET, SetLastError = true)] + private static extern uint LsaOpenPolicy( + LSA_UNICODE_STRING[] SystemName, + ref LSA_OBJECT_ATTRIBUTES ObjectAttributes, + int AccessMask, + out IntPtr PolicyHandle + ); + + [DllImport(LSA_DLL, CharSet = DEFAULT_CHAR_SET, SetLastError = true)] + private static extern uint LsaAddAccountRights( + IntPtr PolicyHandle, + IntPtr pSID, + LSA_UNICODE_STRING[] UserRights, + int CountOfRights + ); + + [DllImport(LSA_DLL, CharSet = DEFAULT_CHAR_SET, SetLastError = true)] + private static extern uint LsaRemoveAccountRights( + IntPtr PolicyHandle, + IntPtr pSID, + bool AllRights, + LSA_UNICODE_STRING[] UserRights, + int CountOfRights + ); + + [DllImport(LSA_DLL, CharSet = DEFAULT_CHAR_SET, SetLastError = true)] + private static extern uint LsaEnumerateAccountsWithUserRight( + IntPtr PolicyHandle, + LSA_UNICODE_STRING[] UserRights, + out IntPtr EnumerationBuffer, + out ulong CountReturned + ); + + [DllImport(LSA_DLL)] + private static extern int LsaNtStatusToWinError(int NTSTATUS); + + [DllImport(LSA_DLL)] + private static extern int LsaClose(IntPtr PolicyHandle); + + [DllImport(LSA_DLL)] + private static extern int LsaFreeMemory(IntPtr Buffer); + + public LsaRightHelper() + { + LSA_OBJECT_ATTRIBUTES lsaAttr; + lsaAttr.RootDirectory = IntPtr.Zero; + lsaAttr.ObjectName = IntPtr.Zero; + lsaAttr.Attributes = 0; + lsaAttr.SecurityDescriptor = IntPtr.Zero; + lsaAttr.SecurityQualityOfService = IntPtr.Zero; + lsaAttr.Length = Marshal.SizeOf(typeof(LSA_OBJECT_ATTRIBUTES)); + + lsaHandle = IntPtr.Zero; + + LSA_UNICODE_STRING[] system = new LSA_UNICODE_STRING[1]; + system[0] = InitLsaString(""); + + uint ret = LsaOpenPolicy(system, ref lsaAttr, (int)Access.POLICY_ALL_ACCESS, out lsaHandle); + if (ret != 0) + throw new Win32Exception(LsaNtStatusToWinError((int)ret)); + } + + public void AddPrivilege(string sidString, string privilege) + { + uint ret = 0; + using (Sid sid = new Sid(sidString)) + { + LSA_UNICODE_STRING[] privileges = new LSA_UNICODE_STRING[1]; + privileges[0] = InitLsaString(privilege); + ret = LsaAddAccountRights(lsaHandle, sid.pSid, privileges, 1); + } + if (ret != 0) + throw new Win32Exception(LsaNtStatusToWinError((int)ret)); + } + + public void RemovePrivilege(string sidString, string privilege) + { + uint ret = 0; + using (Sid sid = new Sid(sidString)) + { + LSA_UNICODE_STRING[] privileges = new LSA_UNICODE_STRING[1]; + privileges[0] = InitLsaString(privilege); + ret = LsaRemoveAccountRights(lsaHandle, sid.pSid, false, privileges, 1); + } + if (ret != 0) + throw new Win32Exception(LsaNtStatusToWinError((int)ret)); + } + + public string[] EnumerateAccountsWithUserRight(string privilege) + { + uint ret = 0; + ulong count = 0; + LSA_UNICODE_STRING[] rights = new LSA_UNICODE_STRING[1]; + rights[0] = InitLsaString(privilege); + IntPtr buffer = IntPtr.Zero; + + ret = LsaEnumerateAccountsWithUserRight(lsaHandle, rights, out buffer, out count); + switch (ret) + { + case 0: + string[] accounts = new string[count]; + for (int i = 0; i < (int)count; i++) + { + LSA_ENUMERATION_INFORMATION LsaInfo = (LSA_ENUMERATION_INFORMATION)Marshal.PtrToStructure( + IntPtr.Add(buffer, i * Marshal.SizeOf(typeof(LSA_ENUMERATION_INFORMATION))), + typeof(LSA_ENUMERATION_INFORMATION)); + + accounts[i] = new SecurityIdentifier(LsaInfo.Sid).ToString(); + } + LsaFreeMemory(buffer); + return accounts; + + case STATUS_NO_MORE_ENTRIES: + return new string[0]; + + case STATUS_NO_SUCH_PRIVILEGE: + throw new ArgumentException(String.Format("Invalid privilege {0} not found in LSA database", privilege)); + + default: + throw new Win32Exception(LsaNtStatusToWinError((int)ret)); + } + } + + static LSA_UNICODE_STRING InitLsaString(string s) + { + // Unicode strings max. 32KB + if (s.Length > 0x7ffe) + throw new ArgumentException("String too long"); + + LSA_UNICODE_STRING lus = new LSA_UNICODE_STRING(); + lus.Buffer = s; + lus.Length = (ushort)(s.Length * sizeof(char)); + lus.MaximumLength = (ushort)(lus.Length + sizeof(char)); + + return lus; + } + + public void Dispose() + { + if (lsaHandle != IntPtr.Zero) + { + LsaClose(lsaHandle); + lsaHandle = IntPtr.Zero; + } + GC.SuppressFinalize(this); + } + ~LsaRightHelper() { Dispose(); } + } +} +"@ + +$original_tmp = $env:TMP +$env:TMP = $_remote_tmp +Add-Type -TypeDefinition $sec_helper_util +$env:TMP = $original_tmp + +Function Compare-UserList($existing_users, $new_users) { + $added_users = [String[]]@() + $removed_users = [String[]]@() + if ($action -eq "add") { + $added_users = [Linq.Enumerable]::Except($new_users, $existing_users) + } elseif ($action -eq "remove") { + $removed_users = [Linq.Enumerable]::Intersect($new_users, $existing_users) + } else { + $added_users = [Linq.Enumerable]::Except($new_users, $existing_users) + $removed_users = [Linq.Enumerable]::Except($existing_users, $new_users) + } + + $change_result = @{ + added = $added_users + removed = $removed_users + } + + return $change_result +} + +# C# class we can use to enumerate/add/remove rights +$lsa_helper = New-Object -TypeName Ansible.LsaRightHelper + +$new_users = [System.Collections.ArrayList]@() +foreach ($user in $users) { + $user_sid = Convert-ToSID -account_name $user + $new_users.Add($user_sid) > $null +} +$new_users = [String[]]$new_users.ToArray() +try { + $existing_users = $lsa_helper.EnumerateAccountsWithUserRight($name) +} catch [ArgumentException] { + Fail-Json -obj $result -message "the specified right $name is not a valid right" +} catch { + Fail-Json -obj $result -message "failed to enumerate existing accounts with right: $($_.Exception.Message)" +} + +$change_result = Compare-UserList -existing_users $existing_users -new_user $new_users +if (($change_result.added.Length -gt 0) -or ($change_result.removed.Length -gt 0)) { + $result.changed = $true + $diff_text = "[$name]`n" + + # used in diff mode calculation + $new_user_list = [System.Collections.ArrayList]$existing_users + foreach ($user in $change_result.removed) { + if (-not $check_mode) { + $lsa_helper.RemovePrivilege($user, $name) + } + $user_name = Convert-FromSID -sid $user + $result.removed += $user_name + $diff_text += "-$user_name`n" + $new_user_list.Remove($user) > $null + } + foreach ($user in $change_result.added) { + if (-not $check_mode) { + $lsa_helper.AddPrivilege($user, $name) + } + $user_name = Convert-FromSID -sid $user + $result.added += $user_name + $diff_text += "+$user_name`n" + $new_user_list.Add($user) > $null + } + + if ($diff_mode) { + if ($new_user_list.Count -eq 0) { + $diff_text = "-$diff_text" + } else { + if ($existing_users.Count -eq 0) { + $diff_text = "+$diff_text" + } + } + $result.diff.prepared = $diff_text + } +} + +Exit-Json $result diff --git a/test/support/windows-integration/plugins/modules/win_user_right.py b/test/support/windows-integration/plugins/modules/win_user_right.py new file mode 100644 index 00000000000..5588208333c --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_user_right.py @@ -0,0 +1,108 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# this is a windows documentation stub. actual code lives in the .ps1 +# file of the same name + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: win_user_right +version_added: '2.4' +short_description: Manage Windows User Rights +description: +- Add, remove or set User Rights for a group or users or groups. +- You can set user rights for both local and domain accounts. +options: + name: + description: + - The name of the User Right as shown by the C(Constant Name) value from + U(https://technet.microsoft.com/en-us/library/dd349804.aspx). + - The module will return an error if the right is invalid. + type: str + required: yes + users: + description: + - A list of users or groups to add/remove on the User Right. + - These can be in the form DOMAIN\user-group, user-group@DOMAIN.COM for + domain users/groups. + - For local users/groups it can be in the form user-group, .\user-group, + SERVERNAME\user-group where SERVERNAME is the name of the remote server. + - You can also add special local accounts like SYSTEM and others. + - Can be set to an empty list with I(action=set) to remove all accounts + from the right. + type: list + required: yes + action: + description: + - C(add) will add the users/groups to the existing right. + - C(remove) will remove the users/groups from the existing right. + - C(set) will replace the users/groups of the existing right. + type: str + default: set + choices: [ add, remove, set ] +notes: +- If the server is domain joined this module can change a right but if a GPO + governs this right then the changes won't last. +seealso: +- module: win_group +- module: win_group_membership +- module: win_user +author: +- Jordan Borean (@jborean93) +''' + +EXAMPLES = r''' +--- +- name: Replace the entries of Deny log on locally + win_user_right: + name: SeDenyInteractiveLogonRight + users: + - Guest + - Users + action: set + +- name: Add account to Log on as a service + win_user_right: + name: SeServiceLogonRight + users: + - .\Administrator + - '{{ansible_hostname}}\local-user' + action: add + +- name: Remove accounts who can create Symbolic links + win_user_right: + name: SeCreateSymbolicLinkPrivilege + users: + - SYSTEM + - Administrators + - DOMAIN\User + - group@DOMAIN.COM + action: remove + +- name: Remove all accounts who cannot log on remote interactively + win_user_right: + name: SeDenyRemoteInteractiveLogonRight + users: [] +''' + +RETURN = r''' +added: + description: A list of accounts that were added to the right, this is empty + if no accounts were added. + returned: success + type: list + sample: ["NT AUTHORITY\\SYSTEM", "DOMAIN\\User"] +removed: + description: A list of accounts that were removed from the right, this is + empty if no accounts were removed. + returned: success + type: list + sample: ["SERVERNAME\\Administrator", "BUILTIN\\Administrators"] +''' diff --git a/test/support/windows-integration/plugins/modules/win_whoami.ps1 b/test/support/windows-integration/plugins/modules/win_whoami.ps1 new file mode 100644 index 00000000000..6c9965af7e7 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_whoami.ps1 @@ -0,0 +1,837 @@ +#!powershell + +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy +#Requires -Module Ansible.ModuleUtils.CamelConversion + +$ErrorActionPreference = "Stop" + +$params = Parse-Args $args -supports_check_mode $true +$_remote_tmp = Get-AnsibleParam $params "_ansible_remote_tmp" -type "path" -default $env:TMP + +$session_util = @' +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Security.Principal; +using System.Text; + +namespace Ansible +{ + public class SessionInfo + { + // SECURITY_LOGON_SESSION_DATA + public UInt64 LogonId { get; internal set; } + public Sid Account { get; internal set; } + public string LoginDomain { get; internal set; } + public string AuthenticationPackage { get; internal set; } + public SECURITY_LOGON_TYPE LogonType { get; internal set; } + public string LoginTime { get; internal set; } + public string LogonServer { get; internal set; } + public string DnsDomainName { get; internal set; } + public string Upn { get; internal set; } + public ArrayList UserFlags { get; internal set; } + + // TOKEN_STATISTICS + public SECURITY_IMPERSONATION_LEVEL ImpersonationLevel { get; internal set; } + public TOKEN_TYPE TokenType { get; internal set; } + + // TOKEN_GROUPS + public ArrayList Groups { get; internal set; } + public ArrayList Rights { get; internal set; } + + // TOKEN_MANDATORY_LABEL + public Sid Label { get; internal set; } + + // TOKEN_PRIVILEGES + public Hashtable Privileges { get; internal set; } + } + + public class Win32Exception : System.ComponentModel.Win32Exception + { + private string _msg; + public Win32Exception(string message) : this(Marshal.GetLastWin32Error(), message) { } + public Win32Exception(int errorCode, string message) : base(errorCode) + { + _msg = String.Format("{0} ({1}, Win32ErrorCode {2})", message, base.Message, errorCode); + } + public override string Message { get { return _msg; } } + public static explicit operator Win32Exception(string message) { return new Win32Exception(message); } + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct LSA_UNICODE_STRING + { + public UInt16 Length; + public UInt16 MaximumLength; + public IntPtr buffer; + } + + [StructLayout(LayoutKind.Sequential)] + public struct LUID + { + public UInt32 LowPart; + public Int32 HighPart; + } + + [StructLayout(LayoutKind.Sequential)] + public struct SECURITY_LOGON_SESSION_DATA + { + public UInt32 Size; + public LUID LogonId; + public LSA_UNICODE_STRING Username; + public LSA_UNICODE_STRING LoginDomain; + public LSA_UNICODE_STRING AuthenticationPackage; + public SECURITY_LOGON_TYPE LogonType; + public UInt32 Session; + public IntPtr Sid; + public UInt64 LoginTime; + public LSA_UNICODE_STRING LogonServer; + public LSA_UNICODE_STRING DnsDomainName; + public LSA_UNICODE_STRING Upn; + public UInt32 UserFlags; + public LSA_LAST_INTER_LOGON_INFO LastLogonInfo; + public LSA_UNICODE_STRING LogonScript; + public LSA_UNICODE_STRING ProfilePath; + public LSA_UNICODE_STRING HomeDirectory; + public LSA_UNICODE_STRING HomeDirectoryDrive; + public UInt64 LogoffTime; + public UInt64 KickOffTime; + public UInt64 PasswordLastSet; + public UInt64 PasswordCanChange; + public UInt64 PasswordMustChange; + } + + [StructLayout(LayoutKind.Sequential)] + public struct LSA_LAST_INTER_LOGON_INFO + { + public UInt64 LastSuccessfulLogon; + public UInt64 LastFailedLogon; + public UInt32 FailedAttemptCountSinceLastSuccessfulLogon; + } + + public enum TOKEN_TYPE + { + TokenPrimary = 1, + TokenImpersonation + } + + public enum SECURITY_IMPERSONATION_LEVEL + { + SecurityAnonymous, + SecurityIdentification, + SecurityImpersonation, + SecurityDelegation + } + + public enum SECURITY_LOGON_TYPE + { + System = 0, // Used only by the Sytem account + Interactive = 2, + Network, + Batch, + Service, + Proxy, + Unlock, + NetworkCleartext, + NewCredentials, + RemoteInteractive, + CachedInteractive, + CachedRemoteInteractive, + CachedUnlock + } + + [Flags] + public enum TokenGroupAttributes : uint + { + SE_GROUP_ENABLED = 0x00000004, + SE_GROUP_ENABLED_BY_DEFAULT = 0x00000002, + SE_GROUP_INTEGRITY = 0x00000020, + SE_GROUP_INTEGRITY_ENABLED = 0x00000040, + SE_GROUP_LOGON_ID = 0xC0000000, + SE_GROUP_MANDATORY = 0x00000001, + SE_GROUP_OWNER = 0x00000008, + SE_GROUP_RESOURCE = 0x20000000, + SE_GROUP_USE_FOR_DENY_ONLY = 0x00000010, + } + + [Flags] + public enum UserFlags : uint + { + LOGON_OPTIMIZED = 0x4000, + LOGON_WINLOGON = 0x8000, + LOGON_PKINIT = 0x10000, + LOGON_NOT_OPTMIZED = 0x20000, + } + + [StructLayout(LayoutKind.Sequential)] + public struct SID_AND_ATTRIBUTES + { + public IntPtr Sid; + public UInt32 Attributes; + } + + [StructLayout(LayoutKind.Sequential)] + public struct LUID_AND_ATTRIBUTES + { + public LUID Luid; + public UInt32 Attributes; + } + + [StructLayout(LayoutKind.Sequential)] + public struct TOKEN_GROUPS + { + public UInt32 GroupCount; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 1)] + public SID_AND_ATTRIBUTES[] Groups; + } + + [StructLayout(LayoutKind.Sequential)] + public struct TOKEN_MANDATORY_LABEL + { + public SID_AND_ATTRIBUTES Label; + } + + [StructLayout(LayoutKind.Sequential)] + public struct TOKEN_STATISTICS + { + public LUID TokenId; + public LUID AuthenticationId; + public UInt64 ExpirationTime; + public TOKEN_TYPE TokenType; + public SECURITY_IMPERSONATION_LEVEL ImpersonationLevel; + public UInt32 DynamicCharged; + public UInt32 DynamicAvailable; + public UInt32 GroupCount; + public UInt32 PrivilegeCount; + public LUID ModifiedId; + } + + [StructLayout(LayoutKind.Sequential)] + public struct TOKEN_PRIVILEGES + { + public UInt32 PrivilegeCount; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 1)] + public LUID_AND_ATTRIBUTES[] Privileges; + } + + public class AccessToken : IDisposable + { + public enum TOKEN_INFORMATION_CLASS + { + TokenUser = 1, + TokenGroups, + TokenPrivileges, + TokenOwner, + TokenPrimaryGroup, + TokenDefaultDacl, + TokenSource, + TokenType, + TokenImpersonationLevel, + TokenStatistics, + TokenRestrictedSids, + TokenSessionId, + TokenGroupsAndPrivileges, + TokenSessionReference, + TokenSandBoxInert, + TokenAuditPolicy, + TokenOrigin, + TokenElevationType, + TokenLinkedToken, + TokenElevation, + TokenHasRestrictions, + TokenAccessInformation, + TokenVirtualizationAllowed, + TokenVirtualizationEnabled, + TokenIntegrityLevel, + TokenUIAccess, + TokenMandatoryPolicy, + TokenLogonSid, + TokenIsAppContainer, + TokenCapabilities, + TokenAppContainerSid, + TokenAppContainerNumber, + TokenUserClaimAttributes, + TokenDeviceClaimAttributes, + TokenRestrictedUserClaimAttributes, + TokenRestrictedDeviceClaimAttributes, + TokenDeviceGroups, + TokenRestrictedDeviceGroups, + TokenSecurityAttributes, + TokenIsRestricted, + MaxTokenInfoClass + } + + public IntPtr hToken = IntPtr.Zero; + + [DllImport("kernel32.dll")] + private static extern IntPtr GetCurrentProcess(); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern bool OpenProcessToken( + IntPtr ProcessHandle, + TokenAccessLevels DesiredAccess, + out IntPtr TokenHandle); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern bool GetTokenInformation( + IntPtr TokenHandle, + TOKEN_INFORMATION_CLASS TokenInformationClass, + IntPtr TokenInformation, + UInt32 TokenInformationLength, + out UInt32 ReturnLength); + + public AccessToken(TokenAccessLevels tokenAccessLevels) + { + IntPtr currentProcess = GetCurrentProcess(); + if (!OpenProcessToken(currentProcess, tokenAccessLevels, out hToken)) + throw new Win32Exception("OpenProcessToken() for current process failed"); + } + + public IntPtr GetTokenInformation(out T tokenInformation, TOKEN_INFORMATION_CLASS tokenClass) + { + UInt32 tokenLength = 0; + GetTokenInformation(hToken, tokenClass, IntPtr.Zero, 0, out tokenLength); + + IntPtr infoPtr = Marshal.AllocHGlobal((int)tokenLength); + + if (!GetTokenInformation(hToken, tokenClass, infoPtr, tokenLength, out tokenLength)) + throw new Win32Exception(String.Format("GetTokenInformation() data for {0} failed", tokenClass.ToString())); + + tokenInformation = (T)Marshal.PtrToStructure(infoPtr, typeof(T)); + return infoPtr; + } + + public void Dispose() + { + GC.SuppressFinalize(this); + } + + ~AccessToken() { Dispose(); } + } + + public class LsaHandle : IDisposable + { + [Flags] + public enum DesiredAccess : uint + { + POLICY_VIEW_LOCAL_INFORMATION = 0x00000001, + POLICY_VIEW_AUDIT_INFORMATION = 0x00000002, + POLICY_GET_PRIVATE_INFORMATION = 0x00000004, + POLICY_TRUST_ADMIN = 0x00000008, + POLICY_CREATE_ACCOUNT = 0x00000010, + POLICY_CREATE_SECRET = 0x00000020, + POLICY_CREATE_PRIVILEGE = 0x00000040, + POLICY_SET_DEFAULT_QUOTA_LIMITS = 0x00000080, + POLICY_SET_AUDIT_REQUIREMENTS = 0x00000100, + POLICY_AUDIT_LOG_ADMIN = 0x00000200, + POLICY_SERVER_ADMIN = 0x00000400, + POLICY_LOOKUP_NAMES = 0x00000800, + POLICY_NOTIFICATION = 0x00001000 + } + + public IntPtr handle = IntPtr.Zero; + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern uint LsaOpenPolicy( + LSA_UNICODE_STRING[] SystemName, + ref LSA_OBJECT_ATTRIBUTES ObjectAttributes, + DesiredAccess AccessMask, + out IntPtr PolicyHandle); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern uint LsaClose( + IntPtr ObjectHandle); + + [DllImport("advapi32.dll", SetLastError = false)] + private static extern int LsaNtStatusToWinError( + uint Status); + + [StructLayout(LayoutKind.Sequential)] + public struct LSA_OBJECT_ATTRIBUTES + { + public int Length; + public IntPtr RootDirectory; + public IntPtr ObjectName; + public int Attributes; + public IntPtr SecurityDescriptor; + public IntPtr SecurityQualityOfService; + } + + public LsaHandle(DesiredAccess desiredAccess) + { + LSA_OBJECT_ATTRIBUTES lsaAttr; + lsaAttr.RootDirectory = IntPtr.Zero; + lsaAttr.ObjectName = IntPtr.Zero; + lsaAttr.Attributes = 0; + lsaAttr.SecurityDescriptor = IntPtr.Zero; + lsaAttr.SecurityQualityOfService = IntPtr.Zero; + lsaAttr.Length = Marshal.SizeOf(typeof(LSA_OBJECT_ATTRIBUTES)); + LSA_UNICODE_STRING[] system = new LSA_UNICODE_STRING[1]; + system[0].buffer = IntPtr.Zero; + + uint res = LsaOpenPolicy(system, ref lsaAttr, desiredAccess, out handle); + if (res != 0) + throw new Win32Exception(LsaNtStatusToWinError(res), "LsaOpenPolicy() failed"); + } + + public void Dispose() + { + if (handle != IntPtr.Zero) + { + LsaClose(handle); + handle = IntPtr.Zero; + } + GC.SuppressFinalize(this); + } + + ~LsaHandle() { Dispose(); } + } + + public class Sid + { + public string SidString { get; internal set; } + public string DomainName { get; internal set; } + public string AccountName { get; internal set; } + public SID_NAME_USE SidType { get; internal set; } + + public enum SID_NAME_USE + { + SidTypeUser = 1, + SidTypeGroup, + SidTypeDomain, + SidTypeAlias, + SidTypeWellKnownGroup, + SidTypeDeletedAccount, + SidTypeInvalid, + SidTypeUnknown, + SidTypeComputer, + SidTypeLabel, + SidTypeLogon, + } + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool LookupAccountSid( + string lpSystemName, + [MarshalAs(UnmanagedType.LPArray)] + byte[] Sid, + StringBuilder lpName, + ref UInt32 cchName, + StringBuilder ReferencedDomainName, + ref UInt32 cchReferencedDomainName, + out SID_NAME_USE peUse); + + public Sid(IntPtr sidPtr) + { + SecurityIdentifier sid; + try + { + sid = new SecurityIdentifier(sidPtr); + } + catch (Exception e) + { + throw new ArgumentException(String.Format("Failed to cast IntPtr to SecurityIdentifier: {0}", e)); + } + + SetSidInfo(sid); + } + + public Sid(SecurityIdentifier sid) + { + SetSidInfo(sid); + } + + public override string ToString() + { + return SidString; + } + + private void SetSidInfo(SecurityIdentifier sid) + { + byte[] sidBytes = new byte[sid.BinaryLength]; + sid.GetBinaryForm(sidBytes, 0); + + StringBuilder lpName = new StringBuilder(); + UInt32 cchName = 0; + StringBuilder referencedDomainName = new StringBuilder(); + UInt32 cchReferencedDomainName = 0; + SID_NAME_USE peUse; + LookupAccountSid(null, sidBytes, lpName, ref cchName, referencedDomainName, ref cchReferencedDomainName, out peUse); + + lpName.EnsureCapacity((int)cchName); + referencedDomainName.EnsureCapacity((int)cchReferencedDomainName); + + SidString = sid.ToString(); + if (!LookupAccountSid(null, sidBytes, lpName, ref cchName, referencedDomainName, ref cchReferencedDomainName, out peUse)) + { + int lastError = Marshal.GetLastWin32Error(); + + if (lastError != 1332 && lastError != 1789) // Fails to lookup Logon Sid + { + throw new Win32Exception(lastError, String.Format("LookupAccountSid() failed for SID: {0} {1}", sid.ToString(), lastError)); + } + else if (SidString.StartsWith("S-1-5-5-")) + { + AccountName = String.Format("LogonSessionId_{0}", SidString.Substring(8)); + DomainName = "NT AUTHORITY"; + SidType = SID_NAME_USE.SidTypeLogon; + } + else + { + AccountName = null; + DomainName = null; + SidType = SID_NAME_USE.SidTypeUnknown; + } + } + else + { + AccountName = lpName.ToString(); + DomainName = referencedDomainName.ToString(); + SidType = peUse; + } + } + } + + public class SessionUtil + { + [DllImport("secur32.dll", SetLastError = false)] + private static extern uint LsaFreeReturnBuffer( + IntPtr Buffer); + + [DllImport("secur32.dll", SetLastError = false)] + private static extern uint LsaEnumerateLogonSessions( + out UInt64 LogonSessionCount, + out IntPtr LogonSessionList); + + [DllImport("secur32.dll", SetLastError = false)] + private static extern uint LsaGetLogonSessionData( + IntPtr LogonId, + out IntPtr ppLogonSessionData); + + [DllImport("advapi32.dll", SetLastError = false)] + private static extern int LsaNtStatusToWinError( + uint Status); + + [DllImport("advapi32", SetLastError = true)] + private static extern uint LsaEnumerateAccountRights( + IntPtr PolicyHandle, + IntPtr AccountSid, + out IntPtr UserRights, + out UInt64 CountOfRights); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool LookupPrivilegeName( + string lpSystemName, + ref LUID lpLuid, + StringBuilder lpName, + ref UInt32 cchName); + + private const UInt32 SE_PRIVILEGE_ENABLED_BY_DEFAULT = 0x00000001; + private const UInt32 SE_PRIVILEGE_ENABLED = 0x00000002; + private const UInt32 STATUS_OBJECT_NAME_NOT_FOUND = 0xC0000034; + private const UInt32 STATUS_ACCESS_DENIED = 0xC0000022; + + public static SessionInfo GetSessionInfo() + { + AccessToken accessToken = new AccessToken(TokenAccessLevels.Query); + + // Get Privileges + Hashtable privilegeInfo = new Hashtable(); + TOKEN_PRIVILEGES privileges; + IntPtr privilegesPtr = accessToken.GetTokenInformation(out privileges, AccessToken.TOKEN_INFORMATION_CLASS.TokenPrivileges); + LUID_AND_ATTRIBUTES[] luidAndAttributes = new LUID_AND_ATTRIBUTES[privileges.PrivilegeCount]; + try + { + PtrToStructureArray(luidAndAttributes, privilegesPtr.ToInt64() + Marshal.SizeOf(privileges.PrivilegeCount)); + } + finally + { + Marshal.FreeHGlobal(privilegesPtr); + } + foreach (LUID_AND_ATTRIBUTES luidAndAttribute in luidAndAttributes) + { + LUID privLuid = luidAndAttribute.Luid; + UInt32 privNameLen = 0; + StringBuilder privName = new StringBuilder(); + LookupPrivilegeName(null, ref privLuid, null, ref privNameLen); + privName.EnsureCapacity((int)(privNameLen + 1)); + if (!LookupPrivilegeName(null, ref privLuid, privName, ref privNameLen)) + throw new Win32Exception("LookupPrivilegeName() failed"); + + string state = "disabled"; + if ((luidAndAttribute.Attributes & SE_PRIVILEGE_ENABLED) == SE_PRIVILEGE_ENABLED) + state = "enabled"; + if ((luidAndAttribute.Attributes & SE_PRIVILEGE_ENABLED_BY_DEFAULT) == SE_PRIVILEGE_ENABLED_BY_DEFAULT) + state = "enabled-by-default"; + privilegeInfo.Add(privName.ToString(), state); + } + + // Get Current Process LogonSID, User Rights and Groups + ArrayList userRights = new ArrayList(); + ArrayList userGroups = new ArrayList(); + TOKEN_GROUPS groups; + IntPtr groupsPtr = accessToken.GetTokenInformation(out groups, AccessToken.TOKEN_INFORMATION_CLASS.TokenGroups); + SID_AND_ATTRIBUTES[] sidAndAttributes = new SID_AND_ATTRIBUTES[groups.GroupCount]; + LsaHandle lsaHandle = null; + // We can only get rights if we are an admin + if (new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator)) + lsaHandle = new LsaHandle(LsaHandle.DesiredAccess.POLICY_LOOKUP_NAMES); + try + { + PtrToStructureArray(sidAndAttributes, groupsPtr.ToInt64() + IntPtr.Size); + foreach (SID_AND_ATTRIBUTES sidAndAttribute in sidAndAttributes) + { + TokenGroupAttributes attributes = (TokenGroupAttributes)sidAndAttribute.Attributes; + if (attributes.HasFlag(TokenGroupAttributes.SE_GROUP_ENABLED) && lsaHandle != null) + { + ArrayList rights = GetAccountRights(lsaHandle.handle, sidAndAttribute.Sid); + foreach (string right in rights) + { + // Includes both Privileges and Account Rights, only add the ones with Logon in the name + // https://msdn.microsoft.com/en-us/library/windows/desktop/bb545671(v=vs.85).aspx + if (!userRights.Contains(right) && right.Contains("Logon")) + userRights.Add(right); + } + } + // Do not include the Logon SID in the groups category + if (!attributes.HasFlag(TokenGroupAttributes.SE_GROUP_LOGON_ID)) + { + Hashtable groupInfo = new Hashtable(); + Sid group = new Sid(sidAndAttribute.Sid); + ArrayList groupAttributes = new ArrayList(); + foreach (TokenGroupAttributes attribute in Enum.GetValues(typeof(TokenGroupAttributes))) + { + if (attributes.HasFlag(attribute)) + { + string attributeName = attribute.ToString().Substring(9); + attributeName = attributeName.Replace('_', ' '); + attributeName = attributeName.First().ToString().ToUpper() + attributeName.Substring(1).ToLower(); + groupAttributes.Add(attributeName); + } + } + // Using snake_case here as I can't generically convert all dict keys in PS (see Privileges) + groupInfo.Add("sid", group.SidString); + groupInfo.Add("domain_name", group.DomainName); + groupInfo.Add("account_name", group.AccountName); + groupInfo.Add("type", group.SidType); + groupInfo.Add("attributes", groupAttributes); + userGroups.Add(groupInfo); + } + } + } + finally + { + Marshal.FreeHGlobal(groupsPtr); + if (lsaHandle != null) + lsaHandle.Dispose(); + } + + // Get Integrity Level + Sid integritySid = null; + TOKEN_MANDATORY_LABEL mandatoryLabel; + IntPtr mandatoryLabelPtr = accessToken.GetTokenInformation(out mandatoryLabel, AccessToken.TOKEN_INFORMATION_CLASS.TokenIntegrityLevel); + Marshal.FreeHGlobal(mandatoryLabelPtr); + integritySid = new Sid(mandatoryLabel.Label.Sid); + + // Get Token Statistics + TOKEN_STATISTICS tokenStats; + IntPtr tokenStatsPtr = accessToken.GetTokenInformation(out tokenStats, AccessToken.TOKEN_INFORMATION_CLASS.TokenStatistics); + Marshal.FreeHGlobal(tokenStatsPtr); + + SessionInfo sessionInfo = GetSessionDataForLogonSession(tokenStats.AuthenticationId); + sessionInfo.Groups = userGroups; + sessionInfo.Label = integritySid; + sessionInfo.ImpersonationLevel = tokenStats.ImpersonationLevel; + sessionInfo.TokenType = tokenStats.TokenType; + sessionInfo.Privileges = privilegeInfo; + sessionInfo.Rights = userRights; + return sessionInfo; + } + + private static ArrayList GetAccountRights(IntPtr lsaHandle, IntPtr sid) + { + UInt32 res; + ArrayList rights = new ArrayList(); + IntPtr userRightsPointer = IntPtr.Zero; + UInt64 countOfRights = 0; + + res = LsaEnumerateAccountRights(lsaHandle, sid, out userRightsPointer, out countOfRights); + if (res != 0 && res != STATUS_OBJECT_NAME_NOT_FOUND) + throw new Win32Exception(LsaNtStatusToWinError(res), "LsaEnumerateAccountRights() failed"); + else if (res != STATUS_OBJECT_NAME_NOT_FOUND) + { + LSA_UNICODE_STRING[] userRights = new LSA_UNICODE_STRING[countOfRights]; + PtrToStructureArray(userRights, userRightsPointer.ToInt64()); + rights = new ArrayList(); + foreach (LSA_UNICODE_STRING right in userRights) + rights.Add(Marshal.PtrToStringUni(right.buffer)); + } + + return rights; + } + + private static SessionInfo GetSessionDataForLogonSession(LUID logonSession) + { + uint res; + UInt64 count = 0; + IntPtr luidPtr = IntPtr.Zero; + SessionInfo sessionInfo = null; + UInt64 processDataId = ConvertLuidToUint(logonSession); + + res = LsaEnumerateLogonSessions(out count, out luidPtr); + if (res != 0) + throw new Win32Exception(LsaNtStatusToWinError(res), "LsaEnumerateLogonSessions() failed"); + Int64 luidAddr = luidPtr.ToInt64(); + + try + { + for (UInt64 i = 0; i < count; i++) + { + IntPtr dataPointer = IntPtr.Zero; + res = LsaGetLogonSessionData(luidPtr, out dataPointer); + if (res == STATUS_ACCESS_DENIED) // Non admins won't be able to get info for session's that are not their own + { + luidPtr = new IntPtr(luidPtr.ToInt64() + Marshal.SizeOf(typeof(LUID))); + continue; + } + else if (res != 0) + throw new Win32Exception(LsaNtStatusToWinError(res), String.Format("LsaGetLogonSessionData() failed {0}", res)); + + SECURITY_LOGON_SESSION_DATA sessionData = (SECURITY_LOGON_SESSION_DATA)Marshal.PtrToStructure(dataPointer, typeof(SECURITY_LOGON_SESSION_DATA)); + UInt64 sessionDataid = ConvertLuidToUint(sessionData.LogonId); + + if (sessionDataid == processDataId) + { + ArrayList userFlags = new ArrayList(); + UserFlags flags = (UserFlags)sessionData.UserFlags; + foreach (UserFlags flag in Enum.GetValues(typeof(UserFlags))) + { + if (flags.HasFlag(flag)) + { + string flagName = flag.ToString().Substring(6); + flagName = flagName.Replace('_', ' '); + flagName = flagName.First().ToString().ToUpper() + flagName.Substring(1).ToLower(); + userFlags.Add(flagName); + } + } + + sessionInfo = new SessionInfo() + { + AuthenticationPackage = Marshal.PtrToStringUni(sessionData.AuthenticationPackage.buffer), + DnsDomainName = Marshal.PtrToStringUni(sessionData.DnsDomainName.buffer), + LoginDomain = Marshal.PtrToStringUni(sessionData.LoginDomain.buffer), + LoginTime = ConvertIntegerToDateString(sessionData.LoginTime), + LogonId = ConvertLuidToUint(sessionData.LogonId), + LogonServer = Marshal.PtrToStringUni(sessionData.LogonServer.buffer), + LogonType = sessionData.LogonType, + Upn = Marshal.PtrToStringUni(sessionData.Upn.buffer), + UserFlags = userFlags, + Account = new Sid(sessionData.Sid) + }; + break; + } + luidPtr = new IntPtr(luidPtr.ToInt64() + Marshal.SizeOf(typeof(LUID))); + } + } + finally + { + LsaFreeReturnBuffer(new IntPtr(luidAddr)); + } + + if (sessionInfo == null) + throw new Exception(String.Format("Could not find the data for logon session {0}", processDataId)); + return sessionInfo; + } + + private static string ConvertIntegerToDateString(UInt64 time) + { + if (time == 0) + return null; + if (time > (UInt64)DateTime.MaxValue.ToFileTime()) + return null; + + DateTime dateTime = DateTime.FromFileTime((long)time); + return dateTime.ToString("o"); + } + + private static UInt64 ConvertLuidToUint(LUID luid) + { + UInt32 low = luid.LowPart; + UInt64 high = (UInt64)luid.HighPart; + high = high << 32; + UInt64 uintValue = (high | (UInt64)low); + return uintValue; + } + + private static void PtrToStructureArray(T[] array, Int64 pointerAddress) + { + Int64 pointerOffset = pointerAddress; + for (int i = 0; i < array.Length; i++, pointerOffset += Marshal.SizeOf(typeof(T))) + array[i] = (T)Marshal.PtrToStructure(new IntPtr(pointerOffset), typeof(T)); + } + + public static IEnumerable GetValues() + { + return Enum.GetValues(typeof(T)).Cast(); + } + } +} +'@ + +$original_tmp = $env:TMP +$env:TMP = $_remote_tmp +Add-Type -TypeDefinition $session_util +$env:TMP = $original_tmp + +$session_info = [Ansible.SessionUtil]::GetSessionInfo() + +Function Convert-Value($value) { + $new_value = $value + if ($value -is [System.Collections.ArrayList]) { + $new_value = [System.Collections.ArrayList]@() + foreach ($list_value in $value) { + $new_list_value = Convert-Value -value $list_value + [void]$new_value.Add($new_list_value) + } + } elseif ($value -is [Hashtable]) { + $new_value = @{} + foreach ($entry in $value.GetEnumerator()) { + $entry_value = Convert-Value -value $entry.Value + # manually convert Sid type entry to remove the SidType prefix + if ($entry.Name -eq "type") { + $entry_value = $entry_value.Replace("SidType", "") + } + $new_value[$entry.Name] = $entry_value + } + } elseif ($value -is [Ansible.Sid]) { + $new_value = @{ + sid = $value.SidString + account_name = $value.AccountName + domain_name = $value.DomainName + type = $value.SidType.ToString().Replace("SidType", "") + } + } elseif ($value -is [Enum]) { + $new_value = $value.ToString() + } + + return ,$new_value +} + +$result = @{ + changed = $false +} + +$properties = [type][Ansible.SessionInfo] +foreach ($property in $properties.DeclaredProperties) { + $property_name = $property.Name + $property_value = $session_info.$property_name + $snake_name = Convert-StringToSnakeCase -string $property_name + + $result.$snake_name = Convert-Value -value $property_value +} + +Exit-Json -obj $result diff --git a/test/support/windows-integration/plugins/modules/win_whoami.py b/test/support/windows-integration/plugins/modules/win_whoami.py new file mode 100644 index 00000000000..d647374b6c1 --- /dev/null +++ b/test/support/windows-integration/plugins/modules/win_whoami.py @@ -0,0 +1,203 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# this is a windows documentation stub. actual code lives in the .ps1 +# file of the same name + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: win_whoami +version_added: "2.5" +short_description: Get information about the current user and process +description: +- Designed to return the same information as the C(whoami /all) command. +- Also includes information missing from C(whoami) such as logon metadata like + logon rights, id, type. +notes: +- If running this module with a non admin user, the logon rights will be an + empty list as Administrator rights are required to query LSA for the + information. +seealso: +- module: win_credential +- module: win_group_membership +- module: win_user_right +author: +- Jordan Borean (@jborean93) +''' + +EXAMPLES = r''' +- name: Get whoami information + win_whoami: +''' + +RETURN = r''' +authentication_package: + description: The name of the authentication package used to authenticate the + user in the session. + returned: success + type: str + sample: Negotiate +user_flags: + description: The user flags for the logon session, see UserFlags in + U(https://msdn.microsoft.com/en-us/library/windows/desktop/aa380128). + returned: success + type: str + sample: Winlogon +upn: + description: The user principal name of the current user. + returned: success + type: str + sample: Administrator@DOMAIN.COM +logon_type: + description: The logon type that identifies the logon method, see + U(https://msdn.microsoft.com/en-us/library/windows/desktop/aa380129.aspx). + returned: success + type: str + sample: Network +privileges: + description: A dictionary of privileges and their state on the logon token. + returned: success + type: dict + sample: { + "SeChangeNotifyPrivileges": "enabled-by-default", + "SeRemoteShutdownPrivilege": "disabled", + "SeDebugPrivilege": "enabled" + } +label: + description: The mandatory label set to the logon session. + returned: success + type: complex + contains: + domain_name: + description: The domain name of the label SID. + returned: success + type: str + sample: Mandatory Label + sid: + description: The SID in string form. + returned: success + type: str + sample: S-1-16-12288 + account_name: + description: The account name of the label SID. + returned: success + type: str + sample: High Mandatory Level + type: + description: The type of SID. + returned: success + type: str + sample: Label +impersonation_level: + description: The impersonation level of the token, only valid if + C(token_type) is C(TokenImpersonation), see + U(https://msdn.microsoft.com/en-us/library/windows/desktop/aa379572.aspx). + returned: success + type: str + sample: SecurityAnonymous +login_time: + description: The logon time in ISO 8601 format + returned: success + type: str + sample: '2017-11-27T06:24:14.3321665+10:00' +groups: + description: A list of groups and attributes that the user is a member of. + returned: success + type: list + sample: [ + { + "account_name": "Domain Users", + "domain_name": "DOMAIN", + "attributes": [ + "Mandatory", + "Enabled by default", + "Enabled" + ], + "sid": "S-1-5-21-1654078763-769949647-2968445802-513", + "type": "Group" + }, + { + "account_name": "Administrators", + "domain_name": "BUILTIN", + "attributes": [ + "Mandatory", + "Enabled by default", + "Enabled", + "Owner" + ], + "sid": "S-1-5-32-544", + "type": "Alias" + } + ] +account: + description: The running account SID details. + returned: success + type: complex + contains: + domain_name: + description: The domain name of the account SID. + returned: success + type: str + sample: DOMAIN + sid: + description: The SID in string form. + returned: success + type: str + sample: S-1-5-21-1654078763-769949647-2968445802-500 + account_name: + description: The account name of the account SID. + returned: success + type: str + sample: Administrator + type: + description: The type of SID. + returned: success + type: str + sample: User +login_domain: + description: The name of the domain used to authenticate the owner of the + session. + returned: success + type: str + sample: DOMAIN +rights: + description: A list of logon rights assigned to the logon. + returned: success and running user is a member of the local Administrators group + type: list + sample: [ + "SeNetworkLogonRight", + "SeInteractiveLogonRight", + "SeBatchLogonRight", + "SeRemoteInteractiveLogonRight" + ] +logon_server: + description: The name of the server used to authenticate the owner of the + logon session. + returned: success + type: str + sample: DC01 +logon_id: + description: The unique identifier of the logon session. + returned: success + type: int + sample: 20470143 +dns_domain_name: + description: The DNS name of the logon session, this is an empty string if + this is not set. + returned: success + type: str + sample: DOMAIN.COM +token_type: + description: The token type to indicate whether it is a primary or + impersonation token. + returned: success + type: str + sample: TokenPrimary +''' diff --git a/test/utils/shippable/incidental/windows.sh b/test/utils/shippable/incidental/windows.sh new file mode 100755 index 00000000000..f84d7c3ba31 --- /dev/null +++ b/test/utils/shippable/incidental/windows.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +set -o pipefail -eux + +declare -a args +IFS='/:' read -ra args <<< "$1" + +version="${args[1]}" + +target="shippable/windows/incidental/" + +stage="${S:-prod}" +provider="${P:-default}" + +# python versions to test in order +# python 2.7 runs full tests while other versions run minimal tests +python_versions=( + 3.5 + 3.6 + 3.7 + 3.8 + 2.7 +) + +# version to test when only testing a single version +single_version=2012-R2 + +# shellcheck disable=SC2086 +ansible-test windows-integration --explain ${CHANGED:+"$CHANGED"} ${UNSTABLE:+"$UNSTABLE"} > /tmp/explain.txt 2>&1 || { cat /tmp/explain.txt && false; } +{ grep ' windows-integration: .* (targeted)$' /tmp/explain.txt || true; } > /tmp/windows.txt + +if [ -s /tmp/windows.txt ] || [ "${CHANGED:+$CHANGED}" == "" ]; then + echo "Detected changes requiring integration tests specific to Windows:" + cat /tmp/windows.txt + + echo "Running Windows integration tests for multiple versions concurrently." + + platforms=( + --windows "${version}" + ) +else + echo "No changes requiring integration tests specific to Windows were detected." + echo "Running Windows integration tests for a single version only: ${single_version}" + + if [ "${version}" != "${single_version}" ]; then + echo "Skipping this job since it is for: ${version}" + exit 0 + fi + + platforms=( + --windows "${version}" + ) +fi + +for version in "${python_versions[@]}"; do + if [ "${version}" == "2.7" ]; then + # full tests for python 2.7 + ci="${target}" + else + # minimal tests for other python versions + ci="incidental_win_ping" + fi + + # terminate remote instances on the final python version tested + if [ "${version}" = "${python_versions[-1]}" ]; then + terminate="always" + else + terminate="never" + fi + + # shellcheck disable=SC2086 + ansible-test windows-integration --color -v --retry-on-error "${ci}" ${COVERAGE:+"$COVERAGE"} ${CHANGED:+"$CHANGED"} ${UNSTABLE:+"$UNSTABLE"} \ + "${platforms[@]}" \ + --docker default --python "${version}" \ + --enable-test-support \ + --remote-terminate "${terminate}" --remote-stage "${stage}" --remote-provider "${provider}" +done