diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b337aca --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,25 @@ +# How to Contribute + +We'd love to accept your patches and contributions to this project. There are +just a few small guidelines you need to follow. + +## Contributor License Agreement + +Contributions to any Google project must be accompanied by a Contributor License +Agreement. This is necessary because you own the copyright to your changes, even +after your contribution becomes part of this project. So this agreement simply +gives us permission to use and redistribute your contributions as part of the +project. Head over to to see your current +agreements on file or to sign a new one. + +You generally only need to submit a CLA once, so if you've already submitted one +(even if it was for a different project), you probably don't need to do it +again. + +## Code reviews + +All submissions, including submissions by project members, require review. We +use GitHub pull requests for this purpose. Consult [GitHub Help] for more +information on using pull requests. + +[GitHub Help]: https://help.github.com/articles/about-pull-requests/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index ed1a976..b90dce1 100644 --- a/README.md +++ b/README.md @@ -1 +1,40 @@ -# glazier +# Glazier + +Glazier is a tool for automating the installation of the Microsoft Windows +operating system on various device platforms. + +[TOC] + +## Why Glazier? + +Glazier was created with certain principles in mind. + +__Text-based & Code-driven__ + +With Glazier, imaging is configured entirely via text files. This allows +technicians to leverage source control systems to maintain and develop their +imaging platform. By keeping imaging configs in source control, we gain peer +review, change history, rollback/forward, and all the other benefits normally +reserved for writing code. + +Reuse and templating allows for config sharing across multiple image types. + +Configs can be consumed by unit tests, build simulators, and other helper +infrastructure to build a robust, automated imaging pipeline. + +Source controlled text makes it easy to integrate configs across multiple +branches, making it easy to QA new changes before releasing them to the general +population. + +__Scalability__ + +Glazier distributes all data over HTTPS, which means you can use as simple or as +advanced of a distribution platform as you need. Run it from a simple free web +server or a large cloud-based CDN. + +Proxies make it easy to accelerate image deployment to remote sites. + +__Extensible__ + +Glazier makes it simple to extend the installer by writing a bit of Python or +Powershell code. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..b85e343 --- /dev/null +++ b/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/autobuild.py b/autobuild.py new file mode 100644 index 0000000..155e593 --- /dev/null +++ b/autobuild.py @@ -0,0 +1,90 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Windows installation and configuration tool.""" + +import os +import sys +from glazier.lib import buildinfo +from glazier.lib import constants +from glazier.lib import logs +from glazier.lib.config import builder +from glazier.lib.config import runner +import gflags as flags + +FLAGS = flags.FLAGS +flags.DEFINE_bool('preserve_tasks', False, + 'Preserve the existing task list, if any.') + +logging = logs.logging + +_FAILURE_MSG = ('%s\n\nInstaller cannot continue.') + + +class AutoBuild(object): + """The AutoBuild class manages the imaging process.""" + + def __init__(self): + logs.Setup() + self._build_info = buildinfo.BuildInfo() + + def _LogFatal(self, msg): + """Log a fatal error and exit. + + Args: + msg: The error message to accompany the failure. + """ + logging.fatal(_FAILURE_MSG, msg) + sys.exit(1) + + def _SetupTaskList(self): + """Determines the location of the task list and erases if necessary.""" + location = constants.WINPE_TASK_LIST + if FLAGS.environment == 'Host': + location = constants.SYS_TASK_LIST + logging.debug('Using task list at %s', location) + if not FLAGS.preserve_tasks and os.path.exists(location): + logging.debug('Purging old task list.') + try: + os.remove(location) + except OSError as e: + self._LogFatal('Unable to remove task list. %s' % e) + return location + + def RunBuild(self): + """Perform the build.""" + task_list = self._SetupTaskList() + + if not os.path.exists(task_list): + root_path = FLAGS.config_root_path or '/' + try: + b = builder.ConfigBuilder(self._build_info) + b.Start(out_file=task_list, in_path=root_path) + except builder.ConfigBuilderError as e: + self._LogFatal(str(e)) + + try: + r = runner.ConfigRunner(self._build_info) + r.Start(task_list=task_list) + except runner.ConfigRunnerError as e: + self._LogFatal(str(e)) + + +def main(unused_argv): + ab = AutoBuild() + ab.RunBuild() + + +if __name__ == '__main__': + main(sys.argv) diff --git a/autobuild_test.py b/autobuild_test.py new file mode 100644 index 0000000..a471925 --- /dev/null +++ b/autobuild_test.py @@ -0,0 +1,71 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for autobuild.""" + +from fakefs import fake_filesystem +from glazier import autobuild +import mock +import unittest + + +class LogFatal(Exception): + pass + + +class BuildInfoTest(unittest.TestCase): + + @mock.patch.object(autobuild, 'logs', autospec=True) + def setUp(self, logs): + self.autobuild = autobuild.AutoBuild() + autobuild.logging = logs.logging + autobuild.logging.fatal.side_effect = LogFatal() + + def testLogFatal(self): + self.assertRaises(LogFatal, self.autobuild._LogFatal, + 'failure is always an option') + self.assertTrue(autobuild.logging.fatal.called) + + def testSetupTaskList(self): + cache = autobuild.constants.SYS_CACHE + filesystem = fake_filesystem.FakeFilesystem() + filesystem.CreateFile(r'X:\task_list.yaml') + autobuild.os = fake_filesystem.FakeOsModule(filesystem) + self.assertEqual(self.autobuild._SetupTaskList(), + '%s\\task_list.yaml' % cache) + autobuild.FLAGS.preserve_tasks = True + self.assertEqual(self.autobuild._SetupTaskList(), + '%s\\task_list.yaml' % cache) + autobuild.FLAGS.environment = 'WinPE' + self.assertEqual(self.autobuild._SetupTaskList(), r'X:\task_list.yaml') + self.assertTrue(autobuild.os.path.exists(r'X:\task_list.yaml')) + autobuild.FLAGS.preserve_tasks = False + self.assertEqual(self.autobuild._SetupTaskList(), r'X:\task_list.yaml') + self.assertFalse(autobuild.os.path.exists(r'X:\task_list.yaml')) + + @mock.patch.object(autobuild.runner, 'ConfigRunner', autospec=True) + @mock.patch.object(autobuild.builder, 'ConfigBuilder', autospec=True) + def testRunBuild(self, builder, runner): + self.autobuild.RunBuild() + # ConfigBuilderError + builder.side_effect = autobuild.builder.ConfigBuilderError + self.assertRaises(LogFatal, self.autobuild.RunBuild) + # ConfigRunnerError + builder.side_effect = None + runner.side_effect = autobuild.runner.ConfigRunnerError + self.assertRaises(LogFatal, self.autobuild.RunBuild) + + +if __name__ == '__main__': + unittest.main() diff --git a/chooser/__init__.py b/chooser/__init__.py new file mode 100644 index 0000000..5def370 --- /dev/null +++ b/chooser/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/chooser/chooser.py b/chooser/chooser.py new file mode 100644 index 0000000..b40f6e1 --- /dev/null +++ b/chooser/chooser.py @@ -0,0 +1,136 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""UI for obtaining dynamic configuration settings from the user. + +The chooser takes an option file in yaml format which specifies options to +be offered to the user. The UI is populated dynamically with the options and +various types of form inputs. + +The UI automatically times out to prevent it from blocking the build. If the +user interacts with the UI before the timer expires, the countdown stops and +the user must click to resume the build. Fields are assigned default values +at startup, and these defaults are also the final values if the UI times out. + +When the UI exits, the final state of all the forms is written to the answer +file, to be consumed by the caller. +""" + +import logging +from glazier.chooser import fields +from glazier.lib import resources +import Tkinter as tk + + +class Chooser(object): + """Dynamic UI for user configuration.""" + + def __init__(self, options, preload=True): + self.fields = {} + self.responses = {} + self.root = tk.Tk() + self.row = 0 + if preload: + self._GuiHeader() + self._LoadOptions(options) + self._GuiFooter() + + def _AddExpander(self): + """Adds an empty Frame which expands vertically in the UI.""" + expander = tk.Frame(self.root) + expander.grid(column=0, row=self.row) + self.root.rowconfigure(self.row, weight=1) + self.row += 1 + + def _AddSeparator(self): + """Adds a Separator visual element (UI decoration).""" + sep = fields.Separator(self.root) + sep.grid(column=0, row=self.row, sticky='EW') + self.root.rowconfigure(self.row, weight=0) + self.row += 1 + + def Display(self): + """Displays the UI on screen.""" + if self.fields: + w, h = self.root.winfo_screenwidth(), self.root.winfo_screenheight() + self.root.geometry('%dx%d+0+0' % (w, h)) + self.root.focus_set() + self.timer.Countdown() + self.root.mainloop() + self._Quit() + + def _GuiFooter(self): + """Creates all UI elements below the input fields.""" + self._AddExpander() + self.timer = fields.Timer(self.root) + self.timer.grid(column=0, row=self.row) + self.root.bind('', self.timer.Pause) + self.root.bind('', self.timer.Pause) + self.row += 1 + self._AddExpander() + self._GuiLogo() + + def _GuiHeader(self): + """Creates all UI elements above the input fields.""" + self.root.columnconfigure(0, weight=1) + self.root.overrideredirect(1) + top = self.root.winfo_toplevel() + top.rowconfigure(0, weight=1) + top.columnconfigure(0, weight=1) + + def _GuiLogo(self): + """Creates the UI graphical logo.""" + self.logo_frame = tk.Frame(self.root) + self.logo_frame.columnconfigure(0, weight=1) + r = resources.Resources() + path = r.GetResourceFileName('logo.gif') + self.logo_img = tk.PhotoImage(file=path) + self.logo = tk.Label(self.logo_frame, image=self.logo_img, text='logo here') + self.logo.grid(column=0, row=0, sticky='SE') + self.logo_frame.grid(column=0, row=self.row, sticky='EW') + self.row += 1 + + def _LoadOptions(self, options): + """Load all options from the options file input. + + UI elements are created for each option + + Args: + options: a list of all options pending for the user + """ + for option in options: + if 'type' not in option: + logging.error('Untyped option: %s.', option) + continue + if option['type'] == 'radio_menu': + self.fields[option['name']] = fields.RadioMenu(self.root, option) + self.fields[option['name']].grid(column=0, row=self.row, pady=5) + elif option['type'] == 'toggle': + self.fields[option['name']] = fields.Toggle(self.root, option) + self.fields[option['name']].grid(column=0, row=self.row, pady=5) + else: + logging.error('Unknown option type: %s.', option['type']) + continue + self.root.rowconfigure(self.row, weight=0) + self.row += 1 + self._AddSeparator() + + def _Quit(self): + """Save all responses and exit the UI.""" + for field in self.fields: + self.responses[field] = self.fields[field].Value() + self.root.destroy() + + def Responses(self): + return self.responses diff --git a/chooser/chooser_test.py b/chooser/chooser_test.py new file mode 100644 index 0000000..5b66e91 --- /dev/null +++ b/chooser/chooser_test.py @@ -0,0 +1,161 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.chooser.chooser.""" + +from fakefs import fake_filesystem +from glazier.chooser import chooser +import mock +import unittest + +_TEST_CONF = [{ + 'name': + 'system_locale', + 'type': + 'radio_menu', + 'prompt': + 'System Locale', + 'options': [ + { + 'label': 'de-de', + 'value': 'de-de', + 'tip': '' + }, + { + 'label': 'en-gb', + 'value': 'en-gb', + 'tip': '' + }, + { + 'label': 'en-us', + 'value': 'en-us', + 'tip': '', + 'default': True + }, + { + 'label': 'es-es', + 'value': 'es-es', + 'tip': '' + }, + { + 'label': 'fr-fr', + 'value': 'fr-fr', + 'tip': '' + }, + { + 'label': 'ja-jp', + 'value': 'ja-jp', + 'tip': '' + }, + { + 'label': 'ko-kr', + 'value': 'ko-kr', + 'tip': '' + }, + { + 'label': 'zh-cn', + 'value': 'zh-cn', + 'tip': '' + }, + { + 'label': 'zh-hk', + 'value': 'zh-hk', + 'tip': '' + }, + { + 'label': 'zh-tw', + 'value': 'zh-tw', + 'tip': '' + }, + ] +}, { + 'name': + 'puppet_enable', + 'type': + 'toggle', + 'prompt': + 'Enable Puppet', + 'options': [ + { + 'label': 'False', + 'value': False, + 'tip': '', + 'default': True + }, + { + 'label': 'True', + 'value': True, + 'tip': '' + }, + ] +}] + + +class ChooserTest(unittest.TestCase): + + @mock.patch.object(chooser, 'tk', autospec=True) + def setUp(self, unused_tk): + self.ui = chooser.Chooser(_TEST_CONF, preload=False) + v1 = mock.Mock() + v1.Value.return_value = 'value1' + v2 = mock.Mock() + v2.Value.return_value = 'value2' + v3 = mock.Mock() + v3.Value.return_value = 'value3' + self.ui.fields = {'field1': v1, 'field2': v2, 'field3': v3} + + self.fs = fake_filesystem.FakeFilesystem() + chooser.resources.os = fake_filesystem.FakeOsModule(self.fs) + self.fs.CreateFile('/resources/logo.gif') + + @mock.patch.object(chooser.fields, 'Timer', autospec=True) + def testDislpay(self, timer): + self.ui.timer = timer.return_value + self.ui.Display() + + @mock.patch.object(chooser, 'tk', autospec=True) + @mock.patch.object(chooser.fields, 'Timer', autospec=True) + def testGuiFooter(self, unused_timer, unused_tk): + self.ui._GuiFooter() + + def testGuiHeader(self): + self.ui._GuiHeader() + + @mock.patch.object(chooser.fields, 'RadioMenu', autospec=True) + @mock.patch.object(chooser.fields, 'Separator', autospec=True) + @mock.patch.object(chooser.fields, 'Toggle', autospec=True) + def testLoadOptions(self, toggle, unused_sep, radio): + self.ui._LoadOptions(_TEST_CONF) + self.assertEqual(radio.call_args[0][1]['name'], 'system_locale') + self.assertEqual(toggle.call_args[0][1]['name'], 'puppet_enable') + # bad options + self.ui._LoadOptions([{ + 'name': 'notype' + }, { + 'name': 'system_locale', + 'type': 'radio_menu' + }, { + 'name': 'unknown', + 'type': 'unknown' + }]) + + def testQuit(self): + self.ui._Quit() + responses = self.ui.Responses() + self.assertEqual(responses['field2'], 'value2') + self.assertEqual(responses['field3'], 'value3') + + +if __name__ == '__main__': + unittest.main() diff --git a/chooser/fields.py b/chooser/fields.py new file mode 100644 index 0000000..29d7c4a --- /dev/null +++ b/chooser/fields.py @@ -0,0 +1,135 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Form fields available for display in the chooser UI. + +The chooser UI displays a dynamic series of fields to the user. In order to +cope with a variable quantity of fields, each set of options is enclosed +in a Frame, with Frames stacked in a single column in the top level. + +Each Frame type that accepts user input is expected to offer a public Value +function which will return the state of the field in a yaml-compatible format. +This is called at the UI exit for final response storage. +""" +import Tkinter as tk + + +class RadioMenu(tk.Frame): + """Radio menu provides a dropdown menu containing radio buttons. + + One and only one menu element can be selected from the list. + """ + + def __init__(self, root, option): + tk.Frame.__init__(self, root) + self.label = tk.Label(self, text=option['prompt']) + self.label.grid(row=0, column=0, padx=20) + self.button = tk.Menubutton(self, text='Choose One', relief=tk.GROOVE) + self.menu = tk.Menu(self.button) + self.button['menu'] = self.menu + self.select = tk.StringVar() + for opt in option['options']: + self.menu.add_radiobutton(label=opt['label'], variable=self.select, + value=opt['value'], command=self._Update) + if 'default' in opt: + self.select.set(opt['value']) + self._Update() + + self.button.grid(row=0, column=1) + + def _Update(self): + current = self.Value() + self.button.configure(text=current) + + def Value(self): + return self.select.get() + + +class Separator(tk.Frame): + """A decorative separator.""" + + def __init__(self, root): + tk.Frame.__init__(self, root, height=2, bd=1, relief=tk.SUNKEN) + + +class Label(tk.Frame): + """A text label.""" + + def __init__(self, root, text, font_name='Helvetica', font_size=16): + tk.Frame.__init__(self, root) + self.label = tk.Label(self, text=text, font=font_name, font_size=font_size) + self.label.grid(row=0, column=0, padx=20) + + +class Timer(tk.Frame): + """Countdown timer with Image Now button for UI footer.""" + + def __init__(self, root, timeout=60): + tk.Frame.__init__(self, root) + self.root = root + self._counter = timeout + self.countdown_1 = tk.Label(self, text='Build will start in...', + font=('Helvetica', 16)) + self.countdown_2 = tk.Label(self, text=self._counter, + font=('Helvetica', 16)) + self.countdown_3 = tk.Label(self, text='... or ...') + self.image_now = tk.Button(self, text='Image Now', command=self._Quit, + font=('Helvetica', 18)) + self.countdown_1.grid(row=0, column=0) + self.countdown_2.grid(row=0, column=1) + self.countdown_3.grid(row=0, column=2) + self.image_now.grid(row=0, column=3) + + def Pause(self, event): + self.countdown_1.configure(text='Automatic build paused...') + self.countdown_2.configure(text='') + self.countdown_3.configure(text='') + self._counter = -1 + + def Countdown(self): + if self._counter < 0: # user interrupt + return + if self._counter == 0: # timeout + self._Quit() + self._counter -= 1 + self.countdown_2.configure(text=self._counter) + self.callback_id = self.root.after(1000, self.Countdown) + + def _Quit(self): + self.root.after_cancel(self.callback_id) + self.root.quit() + + +class Toggle(tk.Frame): + """An set of radio buttons with On (True)/Off (False) values.""" + + def __init__(self, root, option): + tk.Frame.__init__(self, root) + self.label = tk.Label(self, text=option['prompt']) + self.label.grid(row=0, column=0, padx=20) + + self.state = tk.BooleanVar() + self.on_button = tk.Radiobutton(self, text='On', variable=self.state, + value=True) + self.off_button = tk.Radiobutton(self, text='Off', variable=self.state, + value=False) + for opt in option['options']: + if 'default' in opt: + self.state.set(opt['value']) + + self.on_button.grid(row=0, column=1) + self.off_button.grid(row=0, column=2) + + def Value(self): + return self.state.get() diff --git a/chooser/fields_test.py b/chooser/fields_test.py new file mode 100644 index 0000000..4a8fb21 --- /dev/null +++ b/chooser/fields_test.py @@ -0,0 +1,102 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.chooser.fields.""" + +from glazier.chooser import fields +import mock +import unittest + + +@mock.patch.object(fields, 'tk', autospec=True) +class FieldsTest(unittest.TestCase): + + @mock.patch.object(fields, 'tk', autospec=True) + def setUp(self, tk): + self.root = tk.Tk() + + def testLabel(self, unused_tk): + fields.Label(self.root, 'some label') + + def testSeparator(self, unused_tk): + fields.Separator(self.root) + + def testToggle(self, unused_tk): + opts = {'prompt': 'enable puppet', + 'options': [{'label': 'true', 'value': True, 'default': True}, + {'label': 'false', 'value': False}]} + toggle = fields.Toggle(self.root, opts) + toggle.state.set.assert_called_with(True) + toggle.Value() + + +class RadioMenuTest(unittest.TestCase): + + @mock.patch.object(fields, 'tk', autospec=True) + def setUp(self, tk): + self.tk = tk + self.root = tk.Tk() + opts = { + 'prompt': 'choose locale', + 'options': [ + {'label': 'en-gb', 'value': 'en-gb', 'tip': ''}, + {'label': 'en-us', 'value': 'en-us', 'tip': '', 'default': True}, + {'label': 'es-es', 'value': 'es-es', 'tip': ''} + ]} + tk.StringVar.return_value.get.return_value = 'en-us' + self.rm = fields.RadioMenu(self.root, opts) + + def testRadioMenu(self): + self.rm.select.set.assert_called_with('en-us') + self.rm.button.configure.assert_called_with(text='en-us') + + +class TimerTest(unittest.TestCase): + + class Quit(Exception): + pass + + @mock.patch.object(fields, 'tk', autospec=True) + def setUp(self, tk): + self.root = tk.Tk() + self.root.quit.side_effect = TimerTest.Quit + self.timer = fields.Timer(self.root, timeout=10) + + def testPause(self): + self.timer.Pause(None) + self.assertEqual(self.timer._counter, -1) + + def testCountdown(self): + # countdown + self.assertEqual(self.timer._counter, 10) + self.timer.Countdown() + self.assertEqual(self.timer._counter, 9) + self.assertTrue(self.root.after.called) + self.assertFalse(self.root.quit.called) + self.root.reset_mock() + # timeout + self.timer._counter = 0 + self.assertRaises(TimerTest.Quit, self.timer.Countdown) + self.assertFalse(self.root.after.called) + self.assertTrue(self.root.quit.called) + self.root.reset_mock() + # interrupt + self.timer._counter = -1 + self.timer.Countdown() + self.assertFalse(self.root.after.called) + self.assertFalse(self.root.quit.called) + + +if __name__ == '__main__': + unittest.main() diff --git a/doc/index.md b/doc/index.md new file mode 100644 index 0000000..f3dad99 --- /dev/null +++ b/doc/index.md @@ -0,0 +1,12 @@ +# Glazier Documentation + +## Configuration + +* [Glazier Build YAML Specification](yaml/index.md) + * [Tips for Writing Effective Glazier Configs](yaml/tips.md) +* [Actions README](../lib/actions/README.md) +* [Policies README](../lib/policies/README.md) + +## Setup + +* [Setup Guide](setup/index.md) diff --git a/doc/setup/index.md b/doc/setup/index.md new file mode 100644 index 0000000..28224a4 --- /dev/null +++ b/doc/setup/index.md @@ -0,0 +1,41 @@ +# Setup Guide + +[TOC] + +## Distribution + +Glazier requires a web based repository of binary and image files to be +available over HTTP(S). You can use any web server or platform that suits your +needs. + +Inside the root of your web host, create two directories: the config root and +the binary root. + +### Config Root + +The configuration root must contain at minimum one `build.yaml` file. In a +mature system, this directory will likely contain a variety of branching config +files and scripts. + +We recommend keeping the entire contents of the config root in source control, +and exporting it out to the web service whenever changes are made. + +### Binary Root + +The binary root is a separate directory structure used to hold non-text data. +This split serves to draw a clean boundary between files which may be sourced +from version control, and those which may instead live in mass storage +elsewhere. + +We recommend using an organized tree structure to make binaries easy to locate. + +* Root/ + * Company1/ + * Product1/ + * v1/ + * v2/ + * ... + * Product2/ + * v1/ + * ... + * ... diff --git a/doc/yaml/chooser_frames.png b/doc/yaml/chooser_frames.png new file mode 100644 index 0000000..d682e42 Binary files /dev/null and b/doc/yaml/chooser_frames.png differ diff --git a/doc/yaml/chooser_ui.md b/doc/yaml/chooser_ui.md new file mode 100644 index 0000000..e2f9f24 --- /dev/null +++ b/doc/yaml/chooser_ui.md @@ -0,0 +1,98 @@ +# Chooser UI + +[TOC] + +The Chooser setup UI is an enhancement to autobuild which allows Glazier to +present the user with a dynamic list of options as part of the installation +process. + +## Architecture + +When the configuration handler code reaches a 'choice' option, that element is +stored in buildinfo as pending. The system will continue to accumulate pending +options until the config calls an action to display the UI. + +The UI display action retrieves all collected options and passes them to a fresh +UI instance. The Chooser UI is responsible for presenting the options and +collecting any responses from the user. + +The Chooser dynamically populates the visible UI from top to bottom. Each field +is contained in a frame, allowing the overall structure to flow downwards, even +though different fields may contain more or fewer individual elements. If the +user does not engage the UI, the responses are still populated using the default +selections. + +![Chooser Frames](chooser_frames.png) + +Once the UI exits, the Chooser will make the responses available via a +dictionary to the caller. The resulting "response" values are returned to +buildinfo where they will be saved in state. These same values (dynamically +named as USER\_\*) can be referenced via pinning at any point later on in the +build. + +## Syntax + +The Glazer YAML specification allows Chooser options to be encoded as part of +the build config files. Autobuild compiles and translates these options into an +option file for the chooser in stage15. Leveraging the build YAMLs allows for +all the same pinning and templating capabilities as the other commands, meaning +Chooser options can be targeted at images on the fly based on any available +buildinfo data. + +The top level YAML command *choice* indicates a chooser option. Each choice +consists of several required sub-fields: + +### name + +Name designates the option's internal name, and should be unique. Buildinfo will +aggregate all options as USER_\[name\] where name is determined by this field. + +### type + +Type indicates the UI field type to be shown (see below). + +### prompt + +Prompt is the text label shown in the UI next to the interactive fields. + +### options + +An ordered list of dictionaries containing all options to be presented. Each +dictionary in the list should have the following sub-fields. + +* label: The label shown in the UI next to the selector. +* value: The value to be stored in the backend if this option is chosen. +* tip: Tooltip (currently not implemented) +* default: Set to boolean True to indicate the default selection. The field + can be skipped for all non-defaults. + +## Field Types + +### radio_menu + +The radio_menu field provides a multiple choice drop-down menu. The menu allows +one and only one selection at a time from the available options. + + choice: +     name: system_locale +     type: radio_menu +     prompt: 'System Locale' +     options: [ +         {label: 'de-de', value: 'de-de', tip: ''}, +         {label: 'en-gb', value: 'en-gb', tip: ''}, +         {label: 'en-us', value: 'en-us', tip: '', default: True}, +         ... +     ] + +### toggle + +A simple pair of on/off (or true/false) radio buttons. + + choice: +     name: puppet_enable +     type: toggle +     prompt: 'Enable Puppet' +     options: [ +         {label: 'False', value: False, tip: '', default: True}, +         {label: 'True', value: True, tip: ''}, +     ] diff --git a/doc/yaml/index.md b/doc/yaml/index.md new file mode 100644 index 0000000..b31868c --- /dev/null +++ b/doc/yaml/index.md @@ -0,0 +1,186 @@ +# Glazier Build YAML Specification + +[TOC] + +Glazier uses YAML-based configuration files. These documents outline the +supported syntax. + + templates: +   software: +     include: +       - ['software/', 'build.yaml'] +   some_template: +     manifest: +         - '#some_executable.exe' +     pull: +         ['somefile.txt', 'C:\somefile.txt'] + controls: +   - pin: +       'os_code': ['win2008-x64-se', 'win2008-x64-ee'] +     template: +       - software +   - pin: +       'os_code': ['win7'] +     template: +       - some_template + +## Top Level + +The top level is a dictionary of two elements, *templates* and *controls*. + +### templates + +Templates is a dictionary of named elements. The name is used to reference each +template from one or more control elements. Templates are not executed unless +referenced by a control element. Their primary purpose is to allow a logical +grouping of commands which may be recycled more than once to simplify the +overall configuration. + +The second template level is the template name. Names can be arbitrary, but +ideally should have some relevance to what the template is doing. + +Beneath the template name is the common command element structure described +below. Pins are not used in templates, as it is assumed they will be called from +a pinned control instead. + +### controls + +Controls is an ordered list of unnamed elements. The list structure is used to +provide a consistent ordering of elements from top to bottom, so commands can be +executed in a predictable order. All build yamls execute commands from top to +bottom. + +The second control level is the common command structure detailed below. A +control commonly starts with a pin item, unlike templates, but a pin is not +required. Unpinned controls will match all. + +## Command Elements + +Each individual block of the controls list or the templates dictionary can +contain any combination of the following, except for pins, which are exclusive +to control elements. + +The order in which individual elements within a single command group are +processed is determined by the build code and may be subject to change. If you +need to control the order of operations, split the commands between multiple +command groups, as the groups are always process sequentially. + +### Actions + +Actions are dynamic command elements. Unlike the static commands listed on this +page, actions are not hardcoded into the config handler. When a configuration +file references a command that is not one of the known static commands, the +config handler will attempt to look up the class name in the actions module. If +it finds it, the class is loaded and run with the arguments from the +configuration file entry. + +Actions are the preferred method for adding new functionality to the autobuild +tool. Unlike hardcoded commands, actions are almost fully self contained and +capable of self-validating. + +See [the Actions README](../../lib/actions/README.md) for a list of available +actions. + +### Pin + +Exclusive to control elements, the pin attaches the current block to a specific +set of build info tags. The tags are inclusive, and must *all *match in order +for the command block to be executed by the build. The format is a dictionary, +where the key is the variable name from buildinfo and the value is a list of +acceptable values. If the key value in buildinfo matches any of the strings in +the list, the pin passes for that key. + +Some pins support "loose" matching. In the case of loose matches, the entire pin +string is checked against the start of every corresponding buildinfo value. For +example: 'A-B' matches 'A-B' as well as 'A-B-C-D', but not 'A-C'. + + - pin: + 'os_code': ['win7'] + 'department': ['demo'] + +Inverse pinning is also supported. Inverse pins are like regular pins, with the +match string beginning with an exclamation point (!). An inverse pin returns +False if any one buildinfo value matches the inverse string (minus the !). For +example: `'os_code': ['!win7']` excludes the pin from os_code=win7 hosts. + +While direct match pins are exclusive, skipping any values not named in the set, +inverse match pins are inclusive, accepting any values not named directly. If +the pin is not negated by a matching inverse pin, the outcome is a successful +match. For example: `'os_code': ['!win7', '!win8']` is False for os_code=win7 +and False for os_code=win8, but True for os_code=win2012. + +*Direct pins are only considered if no inverse pins are present.* This is to +compensate for direct matches being exclusive in nature. It would not make sense +to supply \[!A, !B, C\], because \[C\] would have the same result. + +Pins are generally treated as case insensitive. + +### Policy + +The policy tag specifies an imaging policy. Imaging policies are used to verify +that the state of the host being installed meets a given set of expectations. + +Each policy tag element is a single string consisting of the name of the imaging +policy class to be enforced. The class name must match exactly, as classes are +dynamically referenced. + + - policy: + - 'DeviceModel' + +See also [the Policies README](../../lib/policies/README.md) + +### Template + +The template tag tells build to process a list of one or more named templates. +Templates are processed recursively, so templates can call other templates as +well. + + template: +       - workstation + +### Include + +The include tag tells build to process an additional yaml file. The structure is +a list of two part entries, a directory name relative to the current build +directory, and a build file name. Includes are useful for breaking up large +build files into smaller logical groups. + + include: +       - ['demo/', 'build.yaml'] + +## Supported Pins + +The pins are essentially exported build info variables that help identify the +installing host. Not all of build info is exported for the purposes of pinning, +although it's always possible to extend the code to support different pins in +the future. + +* computer_model + * The computer model hardware string (eg HP Z620 Workstation) + * Supports partial model matching from the left. +* device_id + * A hardware device id string in the format of + \[vendor-device-subsystem-revision\]. Will be matched against every + device id detected in hardware. + * Supports partial device matching from the left (eg AA-BB in the config + will match AA-BB-CC-DD in hardware). +* encryption_type + * TPM, Startup Key, etc +* graphics + * Detected graphics cards (by name). +* os_code + * Corresponds to the generic operating system code as defined in + release-info.yaml. Used for generalized identification of the target + platform. + +## Misc + +### Comments + +The yaml specification allows comments by prefacing lines with a hash (#). Feel +free to comment the configs to improve readability. + +## Configuration Handlers + +See the [Glazier Configuration Handlers](config_handlers.md) page for more +information about how the configuration files are processed. diff --git a/doc/yaml/tips.md b/doc/yaml/tips.md new file mode 100644 index 0000000..bfca77f --- /dev/null +++ b/doc/yaml/tips.md @@ -0,0 +1,26 @@ +# Tips for Writing Effective Glazier Configs + +* YAML supports comments. Use them to delinate/decorate config blocks as well + as communicating intent or documenting bugs/TODOs. + +* Some parts of configs are strictly ordered and others are not. The top + level, implemented as an ordered list, will always happen in sequence. Be + careful not to assume strict ordering in other parts of the config, + particularly where the YAML is dictionary typed. When in doubt, use two top + level config elements to assert order of operations. + + * You can also achieve ordering with list-based types, such as templates + and includes. + +* Use includes to batch together a series of commands all affected by the same + Pins. Rather than applying the same Pins to each of a series of configs, put + the entire series in a separate file. Then apply the shared Pin(s) to the + include statement that references the new config file. + +* Use includes and directory structure to break up the configuration flow in a + logical way. Everything could live in one file and directory if you wanted + it to, but it would be ugly and hard to read. + +* It's easier to cherrypick changes from separate files. Consider separating + out elements that are frequently changed for easier management across + branches. diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..5def370 --- /dev/null +++ b/lib/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/lib/actions/README.md b/lib/actions/README.md new file mode 100644 index 0000000..a4b71dc --- /dev/null +++ b/lib/actions/README.md @@ -0,0 +1,409 @@ +# Glazier Installer Actions + +[TOC] + +Actions are classes which the configuration handler may call to perform a +variety of tasks during imaging. + +## Usage + +Each module should inherit from BaseAction and will receive a BuildInfo instance +(self.\_build_info). + +Arguments are stored as a data structure in the self.\_args variable, commonly +as an ordered list or dictionary. + +Each action class should override the Run function to execute its main behavior. + +If an action fails, the module should raise ActionError with a message +explaining the cause of failure. This will abort the build. + +## Validation + +Config validation can be accomplished by overriding the Validate() function. +Validate should test the inputs (\_args) for correctness. If \_args contains any +unexpected or inappropriate data, ValidationError should be raised. + +Validate() will pass for any actions which have not overridden it with custom +rules. + +## Actions + +### Abort + +Aborts the build with a custom error message. + +#### Arguments + +* Format: List + * Arg1[str]: An error message to display; the reason for aborting. + +### AddChoice + +Aliases: choice + +### BitlockerEnable + +Enable Bitlocker on the host system. + +Available modes: + +* ps_tpm: TPM via Powershell +* bde_tpm: TPM via manage-bde.exe + +#### Arguments + +* Format: List + * Arg1[str]: The mode to use for enabling Bitlocker. + +### BuildInfoDump + +Write state from the BuildInfo class to disk for later processing by +BuildInfoSave. + +### BuildInfoSave + +Load BuildInfo data from disk and store permanently to the registry. + +### CopyFile/MultiCopyFile + +Copy files from source to destination. + +Also available as MultiCopyFile for copying larger sets of files. + +#### CopyFile Arguments + +* Format: List + * Arg1[str]: Source file path + * Arg2[str]: Destination file path. + +#### MultiCopyFile Arguments + +* Format: List + * Arg1[list]: First set of files to copy + * Arg1[str]: Source file path + * Arg2[str]: Destination file path. + * Arg2[list]: Second set of files to copy + * Arg1[str]: Source file path + * Arg2[str]: Destination file path. + * ... + +#### Examples + + CopyFile: ['X:\glazier.log', 'C:\Windows\Logs\glazier.log'] + + MultiCopyFile: + - ['X:\glazier-applyimg.log', 'C:\Windows\Logs\glazier-applyimg.log'] + - ['X:\glazier.log', 'C:\Windows\Logs\glazier.log'] + +### DomainJoin + +Joins the host to the domain. (Requires installer to be running within the host +OS.) + +#### Arguments + +* Format: List + * Arg1[str]: The desired method to use for the join, as defined by the + domain join library. + * Arg2[str]: The name of the domain to join. + * Arg3[str]: The OU to join the machine to. (optional) + +#### Example + + DomainJoin: ['interactive', 'domain.example.com'] + DomainJoin: ['auto', 'domain.example.com', 'OU=Servers,DC=DOMAIN,DC=EXAMPLE,DC=COM'] + +### Driver + +Process drivers in WIM format. Downloads file, verifies hash, creates an empty +directory, mounts wim file, applies drivers to the base image, and finally +unmounts wim. + +#### Example + + Driver: ['@/Driver/HP/z840/win10/20160909/z840.wim', + 'C:\Glazier_Cache\z840.wim', + 'cd8f4222a9ba4c4493d8df208fe38cdad969514512d6f5dfd0f7cc7e1ea2c782'] + +### Execute + +Run one or more commands on the system. + +Supports multiple commands via nested list structure due to the frequency of +program executions occurring as part of a typical imaging process. + +#### Arguments + +* Format: List + * Arg1[list]: The first command to execute + * ArgA[str]: The entire command line to execute including flags. + * ArgB[list]: One or more integers indicating successful exit codes. + * Default: [0] + * ArgC[list]: One or more integers indicating that a reboot is + required. + * Default: [] + * ArgD[bool]: Rerun after a reboot occurs. A reboot code must be + provided and returned by the execution.) + * Default: False + * Arg2[list]: The second command to execute. (optional) + * ... + +#### Examples + + Execute: [ + # Using defaults. + ['C:\Windows\System32\netsh.exe interface teredo set state disabled'], + # 0 or 1 are successful exit codes, 3010 will trigger a restart. + ['C:\Windows\System32\msiexec.exe /i @Drivers/HP/zbook/HP_Hotkey_Support_6_2_20_8.msi /qn /norestart', [0,1], [3010]], + # 0 is a successful exit code, 2 will trigger a restart, and 'True' will rerun the command after the restart. + ['C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -NoLogo -NoProfile -File #secureboot.ps1', [0], [2], True] + ] + +### ExitWinPE + +Leave the WinPE environment en route to the local host configuration. Is +normally followed by sysprep, then the relaunch of the autobuild tool running +inside the new host image. + +Performs multiple steps in one: + +* Copies the autobuild executable to C: +* Copies the acting task list to C: +* Reboots the host + +Without a separate command, some of these actions would remain in the task list +after being carried over, and would be re-executed. + +#### Arguments + +* None + +### Get + +Aliases: pull + +Downloads remote files to local disk. Get is an ordered, two dimensional list of +source file names and destination file names. Source filenames are assumed to be +relative to the location of the current yaml file. + +#### Verification + +To use checksum verification, add the computed SHA256 hash as a third argument +to the list. This argument is optional, and being absent or null bypasses +verification. + + Get: +     - ['windows10.wim', 'c:\base.wim', '4b5b6bf0e59dadb4663ad9b4110bf0794ba24c344291f30d47467d177feb4776'] + +#### Arguments + +* Format: List + * Arg1[list]: The first file to retrieve. + * ArgA[str]: The remote path to the source file. + * ArgB[str]: The local destination path for the file. + * ArgC[str]: The sha256 sum of the flie for verification. (optional) + * Arg2[list]: The second file to retrieve. (optional) + * ... + +#### Examples + + Get: + - ['win2008-x64-se.wim', 'c:\base.wim'] + - ['win2008-x64-se.wim.sha256', 'c:\base.wim.sha256'] + +### LogCopy + +Attempts to copy a log file to a new destination for collection. + +Destinations include Event Log and CIFS. Copy failures only produce warnings +rather than hard failures. + +Logs will always be copied to the local Application log. Specifying the second +logs share parameter will also attempt to copy the log to the specified file +share. + +#### Arguments + +* Format: List + * Arg1[str]: Full path name of the source log file. + * Arg2[str]: The path to the destination file share. (optional) + +#### Examples + + LogCopy: ['C:\Windows\Logs\glazier.log', '\\shares.example.com\logs-share'] + +### MkDir + +Make a directory. + +#### Arguments + +* Format: List + * Arg1[str]: Full path name of directory + +#### Examples + + MkDir: ['C:\Glazier_Cache'] + +### PSScript + +Run a Powershell script file using the local Powershell interpreter. + +#### Arguments + +* Format: List + * Arg1[str]: The script file name or path to be run. + * Arg2[list]: A list of flags to be supplied to the script. (Optional) + +#### Examples + + PSScript: ['#Sample-Script.ps1'] + + PSScript: ['C:\Sample-Script2.ps1', ['-Flag1', 123, '-Flag2']] + +### RegAdd + +Create a registry key. + +#### Arguments + +* Format: List + * Arg1[str]: Root key + * Arg2[str]: Key path + * Arg3[str]: Key name + * Arg4[str]: Key value + * Arg5[str]: Key type + * Arg6[bool]: Use 64bit Registry (Optional) + +#### Examples + + RegAdd: ['HKLM', 'SOFTWARE\Microsoft\Windows NT\CurrentVersion\SoftwareProtectionPlatform', 'KeyManagementServiceName', 'kms.example.com', 'REG_SZ'] + +### Reboot + +Restart the host machine. + +#### Arguments + +* Format: List + * Arg1[int]: The timeout delay until restart occurs. + * Arg2[str]: The reason/message for the restart to be displayed. + (Optional) + +#### Examples + + Reboot: [30] + Reboot: [10, "Restarting to finish installing drivers."] + +### SetUnattendTimeZone + +Attempts to detect the timezone via DHCP and configures any \ fields +in unattend.xml with the resulting values. + +#### Arguments + +* None + +### SetupCache + +Creates the imaging cache directory with the path stored in BuildInfo. + +#### Arguments + +* None + +#### Examples + + SetupCache: [] + +### SetTimer + +Add an imaging timer. + +#### Arguments + +* Format: List + * Arg1[str]: Timer name + +#### Examples + + SetTimer: ['TimerName'] + +### ShowChooser + +Show the Chooser UI to display all accumulated options to the user. All results +are returned to BuildInfo and the pending options list is cleared. + +#### Arguments + +* None + +### Shutdown + +Shutdown the host machine. + +#### Arguments + +* Format: List + * Arg1[int]: The timeout delay until shutdown occurs. + * Arg2[str]: The reason/message for the shutdown to be displayed. + (Optional) + +#### Examples + + Shutdown: [30] + Shutdown: [10, "Shutting down to save power."] + +### Sleep + +Pause the installer. + +#### Arguments + +* Format: List + * Arg1[int]: Duration to sleep. + +#### Examples + + Sleep: [30] + +### Unzip + +Unzip a zip file to the local filesystem. + +#### Arguments + +* Format: List + + * Arg1[str]: Path to the zip file. + * Arg2[str]: Path to extract the zip file to. + +#### Examples + + Unzip: ['C:\some_archive.zip', 'C:\Some\Destination\Path'] + +### UpdateMSU + +Process updates in MSU format. Downloads file, verifies hash, creates a +SYS_CACHE\Updates folder that is used as a temp location to extract the msu +file, and applies the update to the base image. + +#### Example + + Update: ['@/Driver/HP/z840/win7/20160909/kb290292.msu', + 'C:\Glazier_Cache\kb290292.msu', + 'cd8f4222a9ba4c4493d8df208fe38cdad969514512d6f5dfd0f7cc7e1ea2c782'] + +### Warn + +Issue a warning that can be bypassed by the user. + +#### Arguments + +* Format: List + * Arg1[string]: Message to the user. + +#### Examples + + Warn: ["You probably don't want to do this, or bad things will happen."] diff --git a/lib/actions/__init__.py b/lib/actions/__init__.py new file mode 100644 index 0000000..c068ea6 --- /dev/null +++ b/lib/actions/__init__.py @@ -0,0 +1,72 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Simplify access to Glazier action modules.""" + +from glazier.lib.actions import abort +from glazier.lib.actions import base +from glazier.lib.actions import domain +from glazier.lib.actions import drivers +from glazier.lib.actions import file_system +from glazier.lib.actions import files +from glazier.lib.actions import installer +from glazier.lib.actions import powershell +from glazier.lib.actions import registry +from glazier.lib.actions import sysprep +from glazier.lib.actions import system +from glazier.lib.actions import timers +from glazier.lib.actions import tpm +from glazier.lib.actions import updates + +# pylint: disable=invalid-name +Abort = abort.Abort +AddChoice = installer.AddChoice +BitlockerEnable = tpm.BitlockerEnable +BuildInfoDump = installer.BuildInfoDump +BuildInfoSave = installer.BuildInfoSave +CopyFile = file_system.CopyFile +DomainJoin = domain.DomainJoin +DriverWIM = drivers.DriverWIM +Execute = files.Execute +ExitWinPE = installer.ExitWinPE +Get = files.Get +LogCopy = installer.LogCopy +MkDir = file_system.MkDir +MultiCopyFile = file_system.MultiCopyFile +PSScript = powershell.PSScript +Reboot = system.Reboot +RegAdd = registry.RegAdd +SetTimer = timers.SetTimer +SetUnattendTimeZone = sysprep.SetUnattendTimeZone +SetupCache = file_system.SetupCache +ShowChooser = installer.ShowChooser +Shutdown = system.Shutdown +Sleep = installer.Sleep +Unzip = files.Unzip +UpdateMSU = updates.UpdateMSU +Warn = abort.Warn + +ActionError = base.ActionError +ValidationError = base.ValidationError + +RestartEvent = base.RestartEvent +ShutdownEvent = base.ShutdownEvent + +# Legacy naming +choice = installer.AddChoice +copy = file_system.MultiCopyFile +driver = drivers.DriverWIM +pull = files.Get +run = files.Execute + diff --git a/lib/actions/abort.py b/lib/actions/abort.py new file mode 100644 index 0000000..a9af070 --- /dev/null +++ b/lib/actions/abort.py @@ -0,0 +1,60 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Actions for stopping the image.""" + +import logging +import re +from glazier.lib import interact +from glazier.lib.actions.base import ActionError +from glazier.lib.actions.base import BaseAction +from glazier.lib.actions.base import ValidationError + + +class Abort(BaseAction): + """Abort imaging with a custom message.""" + + def Run(self): + message = self._args[0] + raise ActionError(str(message)) + + def Validate(self): + if not isinstance(self._args, list): + raise ValidationError('Invalid args type (%s): %s' % + (type(self._args), self._args)) + if len(self._args) is not 1: + raise ValidationError('Invalid args length: %s' % self._args) + if not isinstance(self._args[0], str): + raise ValidationError('Invalid argument type: %s' % self._args[0]) + + +class Warn(BaseAction): + """Warn the user about a problem condition, and ask whether to continue.""" + + def Run(self): + print '\n\n%s\n\n' % str(self._args[0]) + response = interact.Prompt('Do you still want to proceed (y/n)? ') + if not response or not re.match(r'^[Yy](es)?$', response): + raise ActionError('User chose not to continue installation.') + logging.info('User chose to continue installation despite warning.') + + def Validate(self): + if not isinstance(self._args, list): + raise ValidationError('Invalid args type (%s): %s' % + (type(self._args), self._args)) + if len(self._args) is not 1: + raise ValidationError('Invalid args length: %s' % self._args) + if not isinstance(self._args[0], str): + raise ValidationError('Invalid argument type: %s' % self._args[0]) + diff --git a/lib/actions/abort_test.py b/lib/actions/abort_test.py new file mode 100644 index 0000000..713e539 --- /dev/null +++ b/lib/actions/abort_test.py @@ -0,0 +1,62 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.actions.abort.""" + +from glazier.lib.actions import abort +import mock +import unittest + + +class AbortTest(unittest.TestCase): + + @mock.patch('glazier.lib.buildinfo.BuildInfo', autospec=True) + def testAbort(self, build_info): + ab = abort.Abort(['abort message'], build_info) + self.assertRaises(abort.ActionError, ab.Run) + + def testAbortValidate(self): + ab = abort.Abort('abort message', None) + self.assertRaises(abort.ValidationError, ab.Validate) + ab = abort.Abort([1, 2, 3], None) + self.assertRaises(abort.ValidationError, ab.Validate) + ab = abort.Abort([1], None) + self.assertRaises(abort.ValidationError, ab.Validate) + ab = abort.Abort(['Error Message'], None) + ab.Validate() + + @mock.patch('glazier.lib.buildinfo.BuildInfo', autospec=True) + @mock.patch('glazier.lib.interact.Prompt', autospec=True) + def testWarn(self, prompt, build_info): + warn = abort.Warn(['warning message'], build_info) + prompt.return_value = None + self.assertRaises(abort.ActionError, warn.Run) + prompt.return_value = 'no thanks' + self.assertRaises(abort.ActionError, warn.Run) + prompt.return_value = 'Y' + warn.Run() + + def testWarnValidate(self): + warn = abort.Warn('abort message', None) + self.assertRaises(abort.ValidationError, warn.Validate) + warn = abort.Warn([1, 2, 3], None) + self.assertRaises(abort.ValidationError, warn.Validate) + warn = abort.Warn([1], None) + self.assertRaises(abort.ValidationError, warn.Validate) + warn = abort.Warn(['Error Message'], None) + warn.Validate() + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/actions/base.py b/lib/actions/base.py new file mode 100644 index 0000000..ce6a0e6 --- /dev/null +++ b/lib/actions/base.py @@ -0,0 +1,95 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Generic imaging action class.""" + +import logging + +# +# Error Types +# + + +class ActionError(Exception): + """Failure completing requested action.""" + + +class ValidationError(Exception): + """Failure validating a command type.""" + +# +# Event Types +# + + +class PowerEvent(Exception): + + def __init__(self, + message, + timeout, + retry_on_restart=False, + task_list_path=None): + super(PowerEvent, self).__init__(message) + self.retry_on_restart = retry_on_restart + self.task_list_path = task_list_path + self.timeout = timeout + + +class RestartEvent(PowerEvent): + """Action requesting a host restart.""" + + +class ShutdownEvent(PowerEvent): + """Action reuqesting a host shutdown.""" + + +class BaseAction(object): + """Generic action type.""" + + def __init__(self, args, build_info): + self._args = args + self._build_info = build_info + self._realtime = False + self._Setup() + + def IsRealtime(self): + """Run the action on discovery rather than queueing in the task list.""" + return self._realtime + + def Run(self): + """Override this function to implement a new action.""" + pass + + def _Setup(self): + """Override to customize action on initialization.""" + pass + + def Validate(self): + """Override this function to implement validation of actions.""" + logging.warn('Validation not implemented for action %s.', + self.__class__.__name__) + + def _ListOfStringsValidator(self, args, length=1, max_length=None): + if not max_length: + max_length = length + self._TypeValidator(args, list) + if not length <= len(args) <= max_length: + raise ValidationError('Invalid args length: %s' % args) + for arg in args: + self._TypeValidator(arg, str) + + def _TypeValidator(self, args, expect_types): + if not isinstance(args, expect_types): + raise ValidationError('Invalid type for arg %s. Found: %s, Expected: %s' % + (args, type(args), str(expect_types))) diff --git a/lib/actions/base_test.py b/lib/actions/base_test.py new file mode 100644 index 0000000..2ab44c5 --- /dev/null +++ b/lib/actions/base_test.py @@ -0,0 +1,30 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.actions.base.""" + +from glazier.lib.actions import base +import unittest + + +class BaseTest(unittest.TestCase): + + def testRun(self): + b = base.BaseAction(None, None) + b.Run() + b.Validate() + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/actions/domain.py b/lib/actions/domain.py new file mode 100644 index 0000000..dc3e27f --- /dev/null +++ b/lib/actions/domain.py @@ -0,0 +1,41 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Actions for interacting with the company domain.""" + +from glazier.lib import domain_join +from glazier.lib.actions.base import ActionError +from glazier.lib.actions.base import BaseAction +from glazier.lib.actions.base import ValidationError + + +class DomainJoin(BaseAction): + """Create an imaging timer.""" + + def Run(self): + method = str(self._args[0]) + domain = str(self._args[1]) + ou = None + if len(self._args) > 2: + ou = str(self._args[2]) + joiner = domain_join.DomainJoin(method, domain, ou) + try: + joiner.JoinDomain() + except domain_join.DomainJoinError as e: + raise ActionError('Unable to complete domain join. %s' % str(e)) + + def Validate(self): + self._ListOfStringsValidator(self._args, length=2, max_length=3) + if self._args[0] not in domain_join.AUTH_OPTS: + raise ValidationError('Invalid join method: %s' % self._args[0]) diff --git a/lib/actions/domain_test.py b/lib/actions/domain_test.py new file mode 100644 index 0000000..2724c8b --- /dev/null +++ b/lib/actions/domain_test.py @@ -0,0 +1,57 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.actions.domain.""" + +from glazier.lib.actions import domain +import mock +import unittest + + +class DomainTest(unittest.TestCase): + + @mock.patch.object(domain.domain_join, 'DomainJoin', autospec=True) + @mock.patch( + 'glazier.lib.buildinfo.BuildInfo', autospec=True) + def testDomainJoin(self, build_info, join): + args = ['interactive', 'domain.test.com'] + dj = domain.DomainJoin(args, build_info) + dj.Run() + join.assert_called_with('interactive', 'domain.test.com', None) + # default ou + args += ['OU=Test,DC=DOMAIN,DC=TEST,DC=COM'] + dj = domain.DomainJoin(args, build_info) + dj.Run() + join.assert_called_with('interactive', 'domain.test.com', + 'OU=Test,DC=DOMAIN,DC=TEST,DC=COM') + # error + join.return_value.JoinDomain.side_effect = ( + domain.domain_join.DomainJoinError) + self.assertRaises(domain.ActionError, dj.Run) + + def testDomainJoinValidate(self): + dj = domain.DomainJoin('interactive', None) + self.assertRaises(domain.ValidationError, dj.Validate) + dj = domain.DomainJoin([1, 2, 3], None) + self.assertRaises(domain.ValidationError, dj.Validate) + dj = domain.DomainJoin([1], None) + self.assertRaises(domain.ValidationError, dj.Validate) + dj = domain.DomainJoin(['unknown'], None) + self.assertRaises(domain.ValidationError, dj.Validate) + dj = domain.DomainJoin(['interactive', 'domain.test.com'], None) + dj.Validate() + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/actions/drivers.py b/lib/actions/drivers.py new file mode 100644 index 0000000..13afdad --- /dev/null +++ b/lib/actions/drivers.py @@ -0,0 +1,134 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Actions for managing device drivers.""" + +import logging +import os +from glazier.lib import constants +from glazier.lib import file_util +from glazier.lib.actions.base import ActionError +from glazier.lib.actions.base import BaseAction +from glazier.lib.actions.base import ValidationError +from glazier.lib.actions.files import Execute +from glazier.lib.actions.files import Get + + +class DriverWIM(BaseAction): + """Downloads file and verifies extension. + + File is downloaded and processed based on supported file extension. + file can then be processed to be used by dism commands. + + Method can be expanded to access and process other formats. + Also can be used to process multiple files. + + Raises: + ActionError: Call with unsupported file type. + """ + FILE_EXT_SUPPORTED = ['.wim'] + + def Run(self): + for wim in self._args: + dst = str(wim[1]) + file_ext = os.path.splitext(dst)[1] + + if file_ext not in self.FILE_EXT_SUPPORTED: + raise ActionError('Unsupported driver file format %s.' % dst) + + g = Get([wim], self._build_info) + g.Run() + + logging.info('Found WIM file, processing drivers using DISM.') + self._ProcessWim(dst) + + def Validate(self): + self._TypeValidator(self._args, list) + for cmd_arg in self._args: + self._TypeValidator(cmd_arg, list) + if not 2 <= len(cmd_arg) <= 3: + raise ValidationError('Invalid args length: %s' % cmd_arg) + self._TypeValidator(cmd_arg[0], str) # remote + self._TypeValidator(cmd_arg[1], str) # local + file_ext = os.path.splitext(cmd_arg[1])[1] + if file_ext not in self.FILE_EXT_SUPPORTED: + raise ValidationError('Invalid file type: %s' % cmd_arg[1]) + if len(cmd_arg) > 2: # hash + for arg in cmd_arg[2]: + self._TypeValidator(arg, str) + + def _AddDriver(self, mount_dir): + """Command used to process drivers in a given directory. + + This command will process all fo the .inf file in a folder recursively. It + can be used regardless of how the drivers are added to the local machine. + + If the exit code for the parsed command is anything other than zero, report + fatal error. + + Args: + mount_dir: local directory where the driver .inf files can be found. + + Raises: + ConfigRunnerError: Error during driver application. + """ + dism = ['{} /Image:c: /Add-Driver /Driver:{} /Recurse'.format( + constants.WINPE_DISM, mount_dir)] + ex = Execute([dism], self._build_info) + try: + ex.Run() + except ActionError as e: + raise ActionError('Error applying drivers to image from %s. (%s)' % + (mount_dir, e)) + + def _ProcessWim(self, wim_file): + """Processes WIM driver files using DISM commands. + + Runs necessary commands to process a driver file in WIM format + + Args: + wim_file: current file location. + + Raises: + ConfigRunnerError: Failure mounting or unmounting WIM. + """ + mount_dir = '%s\\Drivers\\' % constants.SYS_CACHE + + # dism commands + mount = [ + '{} /Mount-Image /ImageFile:{} /MountDir:{} /ReadOnly /Index:1'.format( + constants.WINPE_DISM, wim_file, mount_dir) + ] + unmount = ['{} /Unmount-Image /MountDir:{} /Discard'.format( + constants.WINPE_DISM, mount_dir)] + + # create mount directory + file_util.CreateDirectories(mount_dir) + + # mount image + ex = Execute([mount], self._build_info) + try: + ex.Run() + except ActionError as e: + raise ActionError('Unable to mount image %s. (%s)' % (wim_file, e)) + + logging.info('Applying %s image to main disk.', wim_file) + self._AddDriver(mount_dir) + + # Unmount after running + ex = Execute([unmount], self._build_info) + try: + ex.Run() + except ActionError as e: + raise ActionError('Error unmounting image. Unable to continue. (%s)' % e) diff --git a/lib/actions/drivers_test.py b/lib/actions/drivers_test.py new file mode 100644 index 0000000..d24bcb9 --- /dev/null +++ b/lib/actions/drivers_test.py @@ -0,0 +1,101 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.actions.drivers.""" + +from glazier.lib.actions import drivers +from glazier.lib.buildinfo import BuildInfo +import mock +import unittest + + +class DriversTest(unittest.TestCase): + + @mock.patch.object(BuildInfo, 'ReleasePath') + @mock.patch('glazier.lib.download.Download.VerifyShaHash', autospec=True) + @mock.patch('glazier.lib.download.Download.DownloadFile', autospec=True) + @mock.patch.object(drivers, 'Execute', autospec=True) + @mock.patch.object(drivers.file_util, 'CreateDirectories', autospec=True) + def testDriverWIM(self, mkdir, exe, dl, sha, rpath): + bi = BuildInfo() + # Setup + remote = '@Drivers/Lenovo/W54x-Win10-Storage.wim' + local = r'c:\W54x-Win10-Storage.wim' + sha_256 = ( + 'D30F9DB0698C87901DF6824D11203BDC2D6DAAF0CE14ABD7C0A7B75974936748') + conf = { + 'data': { + 'driver': [[remote, local, sha_256]] + }, + 'path': ['/autobuild'] + } + rpath.return_value = '/' + + # Success + dw = drivers.DriverWIM(conf['data']['driver'], bi) + dw.Run() + dl.assert_called_with( + mock.ANY, ('https://glazier-server.example.com/' + 'bin/Drivers/Lenovo/W54x-Win10-Storage.wim'), + local, + show_progress=True) + sha.assert_called_with(mock.ANY, local, sha_256) + cache = drivers.constants.SYS_CACHE + exe.assert_called_with([[('X:\\Windows\\System32\\dism.exe /Unmount-Image ' + '/MountDir:%s\\Drivers\\ /Discard' % cache)]], + mock.ANY) + mkdir.assert_called_with('%s\\Drivers\\' % cache) + + # Invalid format + conf['data']['driver'][0][1] = 'C:\\W54x-Win10-Storage.zip' + dw = drivers.DriverWIM(conf['data']['driver'], bi) + self.assertRaises(drivers.ActionError, dw.Run) + conf['data']['driver'][0][1] = 'C:\\W54x-Win10-Storage.wim' + + # Mount Fail + exe.return_value.Run.side_effect = drivers.ActionError() + self.assertRaises(drivers.ActionError, dw.Run) + # Dism Fail + exe.return_value.Run.side_effect = iter([0, drivers.ActionError()]) + self.assertRaises(drivers.ActionError, dw.Run) + # Unmount Fail + exe.return_value.Run.side_effect = iter([0, 0, drivers.ActionError()]) + self.assertRaises(drivers.ActionError, dw.Run) + + def testDriverWIMValidate(self): + g = drivers.DriverWIM('String', None) + self.assertRaises(drivers.ValidationError, g.Validate) + g = drivers.DriverWIM([[1, 2, 3]], None) + self.assertRaises(drivers.ValidationError, g.Validate) + g = drivers.DriverWIM([[1, '/tmp/out/path']], None) + self.assertRaises(drivers.ValidationError, g.Validate) + g = drivers.DriverWIM([['/tmp/src.zip', 2]], None) + self.assertRaises(drivers.ValidationError, g.Validate) + g = drivers.DriverWIM([['https://glazier/bin/src.wim', '/tmp/out/src.zip']], + None) + self.assertRaises(drivers.ValidationError, g.Validate) + g = drivers.DriverWIM([['https://glazier/bin/src.wim', '/tmp/out/src.wim']], + None) + g.Validate() + g = drivers.DriverWIM( + [['https://glazier/bin/src.wim', '/tmp/out/src.wim', '12345']], None) + g.Validate() + g = drivers.DriverWIM( + [['https://glazier/bin/src.zip', '/tmp/out/src.zip', '12345', '67890']], + None) + self.assertRaises(drivers.ValidationError, g.Validate) + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/actions/file_system.py b/lib/actions/file_system.py new file mode 100644 index 0000000..534539d --- /dev/null +++ b/lib/actions/file_system.py @@ -0,0 +1,109 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Actions for managing the local file systems.""" + +import logging +import os +import shutil +from glazier.lib.actions.base import ActionError +from glazier.lib.actions.base import BaseAction +from glazier.lib.actions.base import ValidationError # pylint:disable=unused-import + + +class FileSystem(BaseAction): + """Parent filesystem class with utility functions.""" + + def _CreateDirectories(self, path): + """Create a directory. + + Args: + path: The full path to the directory to be created. + + Raises: + ActionError: Failure creating the requested directory. + """ + if not os.path.isdir(path): + try: + logging.debug('Creating directory: %s', path) + os.makedirs(path) + except (shutil.Error, OSError) as e: + raise ActionError('Unable to create directory %s: %s' % (path, str(e))) + + +class CopyFile(FileSystem): + """Copies files on disk.""" + + def Run(self): + try: + src = self._args[0] + dst = self._args[1] + except IndexError: + raise ActionError('Unable to determine source and destination from %s.' % + str(self._args)) + try: + path = os.path.dirname(dst) + self._CreateDirectories(path) + shutil.copy2(src, dst) + logging.info('Copying: %s to %s', src, dst) + except (shutil.Error, IOError) as e: + raise ActionError('Unable to copy %s to %s: %s' % (src, dst, str(e))) + + def Validate(self): + self._ListOfStringsValidator(self._args, length=2) + + +class MultiCopyFile(BaseAction): + """Perform CopyFile on multiple sets of files.""" + + def Run(self): + try: + for arg in self._args: + cf = CopyFile([arg[0], arg[1]], self._build_info) + cf.Run() + except IndexError: + raise ActionError('Unable to determine copy sets from %s.' % + str(self._args)) + + def Validate(self): + self._TypeValidator(self._args, list) + for arg in self._args: + cf = CopyFile(arg, self._build_info) + cf.Validate() + + +class MkDir(FileSystem): + """Create a directory.""" + + def Run(self): + try: + path = self._args[0] + except IndexError: + raise ActionError('Unable to determine desired path from %s.' % + str(self._args)) + self._CreateDirectories(path) + + def Validate(self): + self._ListOfStringsValidator(self._args) + + +class SetupCache(FileSystem): + """Create the imaging cache directory.""" + + def Run(self): + path = self._build_info.Cache().Path() + self._CreateDirectories(path) + + def Validate(self): + self._TypeValidator(self._args, list) diff --git a/lib/actions/file_system_test.py b/lib/actions/file_system_test.py new file mode 100644 index 0000000..4d20a2c --- /dev/null +++ b/lib/actions/file_system_test.py @@ -0,0 +1,114 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.actions.file_system.""" + +from fakefs import fake_filesystem +from fakefs import fake_filesystem_shutil +from glazier.lib.actions import file_system +import mock +import unittest + + +class FileSystemTest(unittest.TestCase): + + def setUp(self): + # fake filesystem + fakefs = fake_filesystem.FakeFilesystem() + fakefs.CreateDirectory(r'/stage') + fakefs.CreateFile(r'/file1.txt', contents='file1') + fakefs.CreateFile(r'/file2.txt', contents='file2') + self.fake_open = fake_filesystem.FakeFileOpen(fakefs) + file_system.os = fake_filesystem.FakeOsModule(fakefs) + file_system.shutil = fake_filesystem_shutil.FakeShutilModule(fakefs) + file_system.open = self.fake_open + self.fakefs = fakefs + + @mock.patch( + 'glazier.lib.buildinfo.BuildInfo', autospec=True) + def testCopyFile(self, build_info): + src1 = r'/file1.txt' + dst1 = r'/windows/glazier/glazier.log' + src2 = r'/file2.txt' + dst2 = r'/windows/glazier/other.log' + cf = file_system.MultiCopyFile([[src1, dst1], [src2, dst2]], build_info) + cf.Run() + self.assertTrue(self.fakefs.Exists(r'/windows/glazier/glazier.log')) + self.assertTrue(self.fakefs.Exists(r'/windows/glazier/other.log')) + # bad path + src1 = r'/missing.txt' + cf = file_system.CopyFile([src1, dst1], build_info) + self.assertRaises(file_system.ActionError, cf.Run) + # bad args + cf = file_system.CopyFile([src1], build_info) + self.assertRaises(file_system.ActionError, cf.Run) + # bad multi args + cf = file_system.MultiCopyFile(src1, build_info) + self.assertRaises(file_system.ActionError, cf.Run) + + def testCopyFileValidate(self): + cf = file_system.MultiCopyFile('String', None) + self.assertRaises(file_system.ValidationError, cf.Validate) + cf = file_system.MultiCopyFile(['String'], None) + self.assertRaises(file_system.ValidationError, cf.Validate) + cf = file_system.MultiCopyFile([[1, 2, 3]], None) + self.assertRaises(file_system.ValidationError, cf.Validate) + cf = file_system.MultiCopyFile([[1, '/tmp/dest.txt']], None) + self.assertRaises(file_system.ValidationError, cf.Validate) + cf = file_system.MultiCopyFile([['/tmp/src.txt', 2]], None) + self.assertRaises(file_system.ValidationError, cf.Validate) + cf = file_system.MultiCopyFile([['/tmp/src1.txt', '/tmp/dest1.txt'], + ['/tmp/src2.txt', '/tmp/dest2.txt']], None) + cf.Validate() + + @mock.patch( + 'glazier.lib.buildinfo.BuildInfo', autospec=True) + def testMkdir(self, build_info): + md = file_system.MkDir(['/stage/subdir1/subdir2'], build_info) + md.Run() + self.assertTrue(file_system.os.path.exists('/stage/subdir1/subdir2')) + # bad path + md = file_system.MkDir([r'/file1.txt'], build_info) + self.assertRaises(file_system.ActionError, md.Run) + # bad args + md = file_system.MkDir([], build_info) + self.assertRaises(file_system.ActionError, md.Run) + + def testMkdirValidate(self): + md = file_system.MkDir('String', None) + self.assertRaises(file_system.ValidationError, md.Validate) + md = file_system.MkDir(['/tmp/some/dir', '/tmp/some/other/dir'], None) + self.assertRaises(file_system.ValidationError, md.Validate) + md = file_system.MkDir([1], None) + self.assertRaises(file_system.ValidationError, md.Validate) + md = file_system.MkDir(['/tmp/some/dir'], None) + md.Validate() + + @mock.patch( + 'glazier.lib.buildinfo.BuildInfo', autospec=True) + def testSetupCache(self, build_info): + build_info.Cache.return_value.Path.return_value = '/test/cache/path' + sc = file_system.SetupCache([], build_info) + sc.Run() + self.assertTrue(file_system.os.path.exists('/test/cache/path')) + + def testSetupCacheValidate(self): + sc = file_system.SetupCache('String', None) + self.assertRaises(file_system.ValidationError, sc.Validate) + sc = file_system.SetupCache([], None) + sc.Validate() + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/actions/files.py b/lib/actions/files.py new file mode 100644 index 0000000..a0b2644 --- /dev/null +++ b/lib/actions/files.py @@ -0,0 +1,147 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Actions for interacting with files (text, zip, exe, etc).""" + +import logging +import subprocess +import time +import zipfile +from glazier.lib import cache +from glazier.lib import download +from glazier.lib import file_util +from glazier.lib.actions.base import ActionError +from glazier.lib.actions.base import BaseAction +from glazier.lib.actions.base import RestartEvent +from glazier.lib.actions.base import ValidationError + + +class Execute(BaseAction): + """Run an executable.""" + + def _Run(self, command, success_codes, reboot_codes, restart_retry): + result = 0 + c = cache.Cache() + logging.debug('Interpreting command %s', command) + try: + command = c.CacheFromLine(command, self._build_info) + except cache.CacheError as e: + raise ActionError(e) + logging.info('Executing command %s', command) + try: + result = subprocess.call(command, shell=True) + except WindowsError as e: # pylint: disable=undefined-variable + raise ActionError('Failed to execute command %s (%s)' % (command, str(e))) + except KeyboardInterrupt: + logging.debug('Child received KeyboardInterrupt. Ignoring.') + if result in reboot_codes: + raise RestartEvent( + 'Restart triggered by exit code %d' % result, + 5, + retry_on_restart=restart_retry) + elif result not in success_codes: + raise ActionError('Command returned invalid exit code %d' % result) + time.sleep(5) + + def Run(self): + for cmd in self._args: + command = cmd[0] + success_codes = [0] + reboot_codes = [] + restart_retry = False + if len(cmd) > 1 and cmd[1]: + success_codes = cmd[1] + if len(cmd) > 2 and cmd[2]: + reboot_codes = cmd[2] + if len(cmd) > 3: + restart_retry = cmd[3] + self._Run(command, success_codes, reboot_codes, restart_retry) + + def Validate(self): + self._TypeValidator(self._args, list) + for cmd_arg in self._args: + self._TypeValidator(cmd_arg, list) + if not 1 <= len(cmd_arg) <= 4: + raise ValidationError('Invalid args length: %s' % cmd_arg) + self._TypeValidator(cmd_arg[0], str) # cmd + if len(cmd_arg) > 1: # success codes + self._TypeValidator(cmd_arg[1], list) + for arg in cmd_arg[1]: + self._TypeValidator(arg, int) + if len(cmd_arg) > 2: # reboot codes + self._TypeValidator(cmd_arg[2], list) + for arg in cmd_arg[2]: + self._TypeValidator(arg, int) + if len(cmd_arg) > 3: # retry on restart + self._TypeValidator(cmd_arg[3], bool) + + +class Get(BaseAction): + """Download a file from a remote source.""" + + def Run(self): + downloader = download.Download() + for arg in self._args: + src = arg[0] + dst = arg[1] + full_url = download.Transform(src, self._build_info) + if 'https' not in full_url: # support untagged short filenames + full_url = download.PathCompile(self._build_info, file_name=full_url) + try: + file_util.CreateDirectories(dst) + except file_util.Error as e: + raise ActionError('Could not create destination directory %s. %s' % + (dst, e)) + try: + downloader.DownloadFile(full_url, dst, show_progress=True) + except download.DownloadError as e: + downloader.PrintDebugInfo() + raise ActionError('Transfer error while downloading %s: %s' % + (full_url, str(e))) + if len(arg) > 2 and arg[2]: + logging.info('Verifying SHA256 hash for %s.', dst) + hash_ok = downloader.VerifyShaHash(dst, arg[2]) + if not hash_ok: + raise ActionError('SHA256 hash for %s was incorrect.' % dst) + + def Validate(self): + self._TypeValidator(self._args, list) + for arg in self._args: + self._ListOfStringsValidator(arg, 2, 3) + + +class Unzip(BaseAction): + """Unzip a zip archive to the local filesystem.""" + + def Run(self): + try: + zip_file = self._args[0] + out_path = self._args[1] + except IndexError: + raise ActionError('Unable to determine desired paths from %s.' % + str(self._args)) + + try: + file_util.CreateDirectories(out_path) + except file_util.Error: + raise ActionError('Unable to create output path %s.' % out_path) + + try: + zf = zipfile.ZipFile(zip_file) + zf.extractall(out_path) + except (IOError, zipfile.BadZipfile) as e: + raise ActionError('Bad zip file given as input. %s' % e) + + def Validate(self): + self._ListOfStringsValidator(self._args, 2) diff --git a/lib/actions/files_test.py b/lib/actions/files_test.py new file mode 100644 index 0000000..3507ee7 --- /dev/null +++ b/lib/actions/files_test.py @@ -0,0 +1,216 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.actions.files.""" + +from fakefs import fake_filesystem +from fakefs import fake_filesystem_shutil +from glazier.lib import buildinfo +from glazier.lib.actions import files +import mock +import unittest + + +class FilesTest(unittest.TestCase): + + def setUp(self): + self.filesystem = fake_filesystem.FakeFilesystem() + files.open = fake_filesystem.FakeFileOpen(self.filesystem) + files.file_util.shutil = fake_filesystem_shutil.FakeShutilModule( + self.filesystem) + + @mock.patch.object(files.time, 'sleep', autospec=True) + @mock.patch.object(files.cache.Cache, 'CacheFromLine', autospec=True) + @mock.patch.object(files.subprocess, 'call', autospec=True) + def testExecute(self, call, cache, sleep): + bi = buildinfo.BuildInfo() + cache.side_effect = iter(['cmd.exe /c', 'explorer.exe']) + e = files.Execute([['cmd.exe /c', [0]], ['explorer.exe']], bi) + call.return_value = 0 + e.Run() + call.assert_has_calls([mock.call( + 'cmd.exe /c', shell=True), mock.call( + 'explorer.exe', shell=True)]) + self.assertTrue(sleep.called) + + # success codes + cache.side_effect = None + cache.return_value = 'cmd.exe /c script.bat' + e = files.Execute([['cmd.exe /c script.bat', [2, 4]]], bi) + self.assertRaises(files.ActionError, e.Run) + call.return_value = 4 + e.Run() + + # reboot codes - no retry + e = files.Execute([['cmd.exe /c script.bat', [0], [2, 4]]], bi) + with self.assertRaises(files.RestartEvent) as r_evt: + e.Run() + self.assertEqual(r_evt.retry_on_restart, False) + + # reboot codes - retry + e = files.Execute([['cmd.exe /c #script.bat', [0], [2, 4], True]], bi) + with self.assertRaises(files.RestartEvent) as r_evt: + e.Run() + self.assertEqual(r_evt.retry_on_restart, True) + cache.assert_called_with(mock.ANY, 'cmd.exe /c #script.bat', bi) + call.assert_called_with('cmd.exe /c script.bat', shell=True) + + # WindowsError + files.WindowsError = Exception + call.side_effect = files.WindowsError + self.assertRaises(files.ActionError, e.Run) + # KeyboardInterrupt + call.side_effect = KeyboardInterrupt + e = files.Execute([['cmd.exe /c', [0]], ['explorer.exe']], bi) + e.Run() + # Cache error + call.side_effect = None + call.return_value = 0 + cache.side_effect = files.cache.CacheError + self.assertRaises(files.ActionError, e.Run) + + def testExecuteValidation(self): + e = files.Execute([['cmd.exe', [0], [2], False], ['explorer.exe']], None) + e.Validate() + e = files.Execute([[]], None) + self.assertRaises(files.ValidationError, e.Validate) + e = files.Execute(['explorer.exe'], None) + self.assertRaises(files.ValidationError, e.Validate) + e = files.Execute('explorer.exe', None) + self.assertRaises(files.ValidationError, e.Validate) + e = files.Execute([['cmd.exe', [0]], ['explorer.exe', '0']], None) + self.assertRaises(files.ValidationError, e.Validate) + e = files.Execute([['cmd.exe', [0]], ['explorer.exe', ['0']]], None) + self.assertRaises(files.ValidationError, e.Validate) + e = files.Execute([['cmd.exe', [0], ['2']], ['explorer.exe']], None) + self.assertRaises(files.ValidationError, e.Validate) + e = files.Execute([['cmd.exe', [0], [2], 'True'], ['explorer.exe']], None) + self.assertRaises(files.ValidationError, e.Validate) + + @mock.patch.object(buildinfo.BuildInfo, 'ReleasePath', autospec=True) + @mock.patch.object(files.download.Download, 'DownloadFile', autospec=True) + @mock.patch.object(files.download.Download, 'VerifyShaHash', autospec=True) + def testGet(self, verify, down_file, r_path): + bi = buildinfo.BuildInfo() + r_path.return_value = 'https://glazier-server.example.com/' + remote = '@glazier/1.0/autobuild.par' + local = r'/tmp/autobuild.par' + test_sha256 = ( + '58157bf41ce54731c0577f801035d47ec20ed16a954f10c29359b8adedcae800') + self.filesystem.CreateFile( + r'/tmp/autobuild.par.sha256', contents=test_sha256) + down_file.return_value = True + conf = [[remote, local]] + g = files.Get(conf, bi) + g.Run() + down_file.assert_called_with( + mock.ANY, + 'https://glazier-server.example.com/bin/glazier/1.0/autobuild.par', + local, + show_progress=True) + # Relative Paths + conf = [['autobuild.bat', '/tmp/autobuild.bat']] + g = files.Get(conf, bi) + g.Run() + down_file.assert_called_with( + mock.ANY, + 'https://glazier-server.example.com/autobuild.bat', + '/tmp/autobuild.bat', + show_progress=True) + down_file.return_value = None + # DownloadError + err = files.download.DownloadError('Error') + down_file.side_effect = err + g = files.Get([[remote, local]], bi) + self.assertRaises(files.ActionError, g.Run) + down_file.side_effect = None + # file_util.Error + self.filesystem.CreateFile('/directory') + g = files.Get([[remote, '/directory/file.txt']], bi) + self.assertRaises(files.ActionError, g.Run) + # good hash + verify.return_value = True + g = files.Get([[remote, local, test_sha256]], bi) + g.Run() + verify.assert_called_with(mock.ANY, local, test_sha256) + # bad hash + verify.return_value = False + g = files.Get([[remote, local, test_sha256]], bi) + self.assertRaises(files.ActionError, g.Run) + # none hash + verify.reset_mock() + conf = [[remote, local, '']] + g = files.Get(conf, bi) + g.Run() + self.assertFalse(verify.called) + + def testGetValidate(self): + g = files.Get('String', None) + self.assertRaises(files.ValidationError, g.Validate) + g = files.Get([[1, 2, 3]], None) + self.assertRaises(files.ValidationError, g.Validate) + g = files.Get([[1, '/tmp/out/path']], None) + self.assertRaises(files.ValidationError, g.Validate) + g = files.Get([['/tmp/src.zip', 2]], None) + self.assertRaises(files.ValidationError, g.Validate) + g = files.Get([['https://glazier/bin/src.zip', '/tmp/out/src.zip']], None) + g.Validate() + g = files.Get( + [['https://glazier/bin/src.zip', '/tmp/out/src.zip', '12345']], None) + g.Validate() + g = files.Get([['https://glazier/bin/src.zip', '/tmp/out/src.zip', '12345', + '67890']], None) + self.assertRaises(files.ValidationError, g.Validate) + + @mock.patch.object(files.file_util, 'CreateDirectories', autospec=True) + @mock.patch( + 'glazier.lib.buildinfo.BuildInfo', autospec=True) + def testUnzip(self, build_info, create_dir): + src = '/tmp/input.zip' + dst = '/out/dir/path' + # bad args + un = files.Unzip([], build_info) + self.assertRaises(files.ActionError, un.Run) + un = files.Unzip([src], build_info) + self.assertRaises(files.ActionError, un.Run) + # bad path + un = files.Unzip([src, dst], build_info) + self.assertRaises(files.ActionError, un.Run) + # create error + create_dir.side_effect = files.file_util.Error + self.assertRaises(files.ActionError, un.Run) + # good + create_dir.side_effect = None + with mock.patch.object(files.zipfile, 'ZipFile', autospec=True) as z: + un = files.Unzip([src, dst], build_info) + un.Run() + z.assert_called_with(src) + z.return_value.extractall.assert_called_with(dst) + create_dir.assert_called_with(dst) + + def testUnzipValidate(self): + un = files.Unzip('String', None) + self.assertRaises(files.ValidationError, un.Validate) + un = files.Unzip([1, 2, 3], None) + self.assertRaises(files.ValidationError, un.Validate) + un = files.Unzip([1, '/tmp/out/path'], None) + self.assertRaises(files.ValidationError, un.Validate) + un = files.Unzip(['/tmp/src.zip', 2], None) + self.assertRaises(files.ValidationError, un.Validate) + un = files.Unzip(['/tmp/src.zip', '/tmp/out/path'], None) + un.Validate() + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/actions/installer.py b/lib/actions/installer.py new file mode 100644 index 0000000..1ea34a3 --- /dev/null +++ b/lib/actions/installer.py @@ -0,0 +1,172 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Actions for managing the installer.""" + +import logging +import os +import time +from glazier.chooser import chooser +from glazier.lib import constants +from glazier.lib import log_copy +from glazier.lib.actions import file_system +from glazier.lib.actions.base import BaseAction +from glazier.lib.actions.base import RestartEvent +from glazier.lib.actions.base import ValidationError +from gwinpy.registry import registry +import yaml + + +class AddChoice(BaseAction): + """Add a pending question for display in the UI.""" + + def _Setup(self): + self._realtime = True + + def Run(self): + self._build_info.AddChooserOption(self._args) + + def Validate(self): + choice = self._args + self._TypeValidator(choice, dict) + + for f in ['name', 'type', 'prompt', 'options']: + if f not in choice: + raise ValidationError('Missing required field %s: %s' % (f, choice)) + + for f in ['name', 'type', 'prompt']: + self._TypeValidator(choice[f], str) + + self._TypeValidator(choice['options'], list) + for opt in choice['options']: + self._TypeValidator(opt, dict) + + if 'label' not in opt: + raise ValidationError('Missing required field %s: %s' % ('label', opt)) + self._TypeValidator(opt['label'], str) + + if 'value' not in opt: + raise ValidationError('Missing required field %s: %s' % ('value', opt)) + self._TypeValidator(opt['value'], (bool, str)) + + if 'tip' in opt: + self._TypeValidator(opt['tip'], str) + if 'default' in opt: + self._TypeValidator(opt['default'], bool) + + +class BuildInfoDump(BaseAction): + """Dump build information to disk.""" + + def Run(self): + path = os.path.join(self._build_info.Cache().Path(), 'build_info.yaml') + self._build_info.Serialize(path) + + +class BuildInfoSave(BaseAction): + """Save build information to the registry.""" + + def _WriteRegistry(self, input_keys): + """Populates the registry with build_info settings for future reference. + + Args: + input_keys: A dictionary of key/value pairs to be added to the registry. + """ + reg = registry.Registry(root_key='HKLM') + reg_root = constants.REG_ROOT + for registry_key in input_keys: + registry_value = input_keys[registry_key] + reg.SetKeyValue(reg_root, registry_key, registry_value) + logging.debug('Created registry value named %s with value %s.', + registry_key, registry_value) + + def Run(self): + path = os.path.join(self._build_info.Cache().Path(), 'build_info.yaml') + if os.path.exists(path): + with open(path) as handle: + input_config = yaml.safe_load(handle) + self._WriteRegistry(input_config['BUILD']) + os.remove(path) + else: + logging.debug('%s does not exist - skipping processing.', path) + + +class ExitWinPE(BaseAction): + """Exit the WinPE environment to start host configuration.""" + + def Run(self): + cp = file_system.CopyFile([constants.WINPE_TASK_LIST, + constants.SYS_TASK_LIST], self._build_info) + cp.Run() + cp = file_system.CopyFile([constants.WINPE_BUILD_LOG, + constants.SYS_BUILD_LOG], self._build_info) + cp.Run() + raise RestartEvent( + 'Leaving WinPE', timeout=10, task_list_path=constants.SYS_TASK_LIST) + + +class LogCopy(BaseAction): + """Upload build logs for collection.""" + + def Run(self): + file_name = str(self._args[0]) + share = None + if len(self._args) > 1: + share = str(self._args[1]) + logging.debug('Found log copy event for file %s to %s.', file_name, share) + copier = log_copy.LogCopy() + + # EventLog + try: + copier.EventLogCopy(file_name) + except log_copy.LogCopyError as e: + logging.warning('Unable to complete log copy to EventLog. %s', e) + # CIFS + if share: + try: + copier.ShareCopy(file_name, share) + except log_copy.LogCopyError as e: + logging.warning('Unable to complete log copy via CIFS. %s', e) + + def Validate(self): + self._ListOfStringsValidator(self._args, 1, 2) + + +class ShowChooser(BaseAction): + """Show the Chooser UI.""" + + def Run(self): + ui = chooser.Chooser(options=self._build_info.GetChooserOptions()) + ui.Display() + responses = ui.Responses() + self._build_info.StoreChooserResponses(responses) + self._build_info.FlushChooserOptions() + + def _Setup(self): + self._realtime = True + + +class Sleep(BaseAction): + """Pause the installer.""" + + def Run(self): + duration = int(self._args[0]) + logging.debug('Sleeping for %d seconds.', duration) + time.sleep(duration) + + def Validate(self): + self._TypeValidator(self._args, list) + if len(self._args) is not 1: + raise ValidationError('Invalid args length: %s' % self._args) + self._TypeValidator(self._args[0], int) diff --git a/lib/actions/installer_test.py b/lib/actions/installer_test.py new file mode 100644 index 0000000..e281c6b --- /dev/null +++ b/lib/actions/installer_test.py @@ -0,0 +1,209 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.actions.installer.""" + +from fakefs import fake_filesystem +from glazier.lib.actions import installer +import mock +import unittest + + +class InstallerTest(unittest.TestCase): + + @mock.patch( + 'glazier.lib.buildinfo.BuildInfo', autospec=True) + def testAddChoice(self, build_info): + choice = { + 'type': + 'toggle', + 'prompt': + 'Set system shell to PowerShell', + 'name': + 'core_ps_shell', + 'options': [{ + 'tip': '', + 'value': False, + 'label': 'False' + }, { + 'default': True, + 'tip': '', + 'value': True, + 'label': 'True' + }] + } + a = installer.AddChoice(choice, build_info) + a.Run() + build_info.AddChooserOption.assert_called_with(choice) + + def testAddChoiceValidate(self): + choice = { + 'type': + 'toggle', + 'prompt': + 'Set system shell to PowerShell', + 'name': + 'core_ps_shell', + 'options': [{ + 'tip': '', + 'value': False, + 'label': 'False' + }, { + 'default': True, + 'tip': '', + 'value': True, + 'label': 'True' + }] + } + a = installer.AddChoice(choice, None) + a.Validate() + # prompt (name, type) + choice['name'] = True + self.assertRaises(installer.ValidationError, a.Validate) + # tip + choice['name'] = 'core_ps_shell' + choice['options'][0]['tip'] = True + self.assertRaises(installer.ValidationError, a.Validate) + # default + choice['options'][0]['tip'] = '' + choice['options'][0]['default'] = 3 + self.assertRaises(installer.ValidationError, a.Validate) + # label + choice['options'][0]['default'] = True + choice['options'][0]['label'] = False + self.assertRaises(installer.ValidationError, a.Validate) + # value + choice['options'][0]['label'] = 'False' + choice['options'][0]['value'] = [] + self.assertRaises(installer.ValidationError, a.Validate) + # options dict + choice['options'][0] = False + self.assertRaises(installer.ValidationError, a.Validate) + # options list + choice['options'] = False + self.assertRaises(installer.ValidationError, a.Validate) + del choice['name'] + self.assertRaises(installer.ValidationError, a.Validate) + a = installer.AddChoice(False, None) + self.assertRaises(installer.ValidationError, a.Validate) + + @mock.patch( + 'glazier.lib.buildinfo.BuildInfo', autospec=True) + def testBuildInfoDump(self, build_info): + build_info.Cache.return_value.Path.return_value = r'C:\Cache\Dir' + d = installer.BuildInfoDump(None, build_info) + d.Run() + build_info.Serialize.assert_called_with(r'C:\Cache\Dir/build_info.yaml') + + @mock.patch.object(installer.registry, 'Registry', autospec=True) + @mock.patch( + 'glazier.lib.buildinfo.BuildInfo', autospec=True) + def testBuildInfoSave(self, build_info, reg): + fs = fake_filesystem.FakeFilesystem() + installer.open = fake_filesystem.FakeFileOpen(fs) + installer.os = fake_filesystem.FakeOsModule(fs) + fs.CreateFile( + '/tmp/build_info.yaml', + contents='{BUILD: {opt 1: true, opt 2: some value, opt 3: 12345}}\n') + build_info.Cache.return_value.Path.return_value = '/tmp' + s = installer.BuildInfoSave(None, build_info) + s.Run() + reg.return_value.SetKeyValue.assert_has_calls( + [ + mock.call(installer.constants.REG_ROOT, 'opt 1', True), + mock.call(installer.constants.REG_ROOT, 'opt 2', 'some value'), + mock.call(installer.constants.REG_ROOT, 'opt 3', 12345), + ], + any_order=True) + s.Run() + + @mock.patch.object(installer.file_system, 'CopyFile', autospec=True) + def testExitWinPE(self, copy): + cache = installer.constants.SYS_CACHE + ex = installer.ExitWinPE(None, None) + with self.assertRaises(installer.RestartEvent): + ex.Run() + copy.assert_has_calls([ + mock.call([r'X:\task_list.yaml', '%s\\task_list.yaml' % cache], + mock.ANY), + mock.call().Run(), + ]) + + @mock.patch.object(installer.log_copy, 'LogCopy', autospec=True) + def testLogCopy(self, copy): + log_file = r'X:\glazier.log' + log_host = 'log-server.example.com' + # copy eventlog + lc = installer.LogCopy([log_file], None) + lc.Run() + copy.return_value.EventLogCopy.assert_called_with(log_file) + self.assertFalse(copy.return_value.ShareCopy.called) + copy.reset_mock() + # copy both + lc = installer.LogCopy([log_file, log_host], None) + lc.Run() + copy.return_value.EventLogCopy.assert_called_with(log_file) + copy.return_value.ShareCopy.assert_called_with(log_file, log_host) + copy.reset_mock() + # copy errors + copy.return_value.EventLogCopy.side_effect = installer.log_copy.LogCopyError + copy.return_value.ShareCopy.side_effect = installer.log_copy.LogCopyError + lc.Run() + copy.return_value.EventLogCopy.assert_called_with(log_file) + copy.return_value.ShareCopy.assert_called_with(log_file, log_host) + + def testLogCopyValidate(self): + log_host = 'log-server.example.com' + lc = installer.LogCopy(r'X:\glazier.log', None) + self.assertRaises(installer.ValidationError, lc.Validate) + lc = installer.LogCopy([1, 2, 3], None) + self.assertRaises(installer.ValidationError, lc.Validate) + lc = installer.LogCopy([1], None) + self.assertRaises(installer.ValidationError, lc.Validate) + lc = installer.LogCopy([r'X:\glazier.log'], None) + lc.Validate() + lc = installer.LogCopy([r'X:\glazier.log', log_host], None) + lc.Validate() + + @mock.patch.object(installer.time, 'sleep', autospec=True) + def testSleep(self, sleep): + s = installer.Sleep([30], None) + s.Run() + sleep.assert_called_with(30) + + def testSleepValidate(self): + s = installer.Sleep('30', None) + self.assertRaises(installer.ValidationError, s.Validate) + s = installer.Sleep([1, 2, 3], None) + self.assertRaises(installer.ValidationError, s.Validate) + s = installer.Sleep(['30'], None) + self.assertRaises(installer.ValidationError, s.Validate) + s = installer.Sleep([30], None) + s.Validate() + + @mock.patch.object(installer.chooser, 'Chooser', autospec=True) + @mock.patch( + 'glazier.lib.buildinfo.BuildInfo', autospec=True) + def testShowChooser(self, build_info, chooser): + c = installer.ShowChooser(None, build_info) + c.Run() + self.assertTrue(chooser.return_value.Display.called) + self.assertTrue(chooser.return_value.Display.called) + build_info.StoreChooserResponses.assert_called_with( + chooser.return_value.Responses.return_value) + self.assertTrue(build_info.FlushChooserOptions.called) + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/actions/powershell.py b/lib/actions/powershell.py new file mode 100644 index 0000000..87c7897 --- /dev/null +++ b/lib/actions/powershell.py @@ -0,0 +1,53 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Actions for running Powershell scripts and commands.""" + +import logging +from glazier.lib import cache +from glazier.lib import powershell +from glazier.lib.actions.base import ActionError +from glazier.lib.actions.base import BaseAction +from glazier.lib.actions.base import ValidationError + + +class PSScript(BaseAction): + """Execute a Powershell script file.""" + + def Run(self): + script = self._args[0] + ps_args = None + if len(self._args) > 1: + ps_args = self._args[1] + ps = powershell.PowerShell(echo_off=True) + c = cache.Cache() + + logging.debug('Interpreting Powershell script %s', script) + try: + script = c.CacheFromLine(script, self._build_info) + except cache.CacheError as e: + raise ActionError(e) + + try: + ps.RunLocal(script, args=ps_args) + except powershell.PowerShellError as e: + raise ActionError('Failure executing Powershell script. [%s]' % e) + + def Validate(self): + self._TypeValidator(self._args, list) + if not 1 <= len(self._args) <= 2: + raise ValidationError('Invalid args length: %s' % self._args) + self._TypeValidator(self._args[0], str) + if len(self._args) > 1: + self._TypeValidator(self._args[1], list) diff --git a/lib/actions/powershell_test.py b/lib/actions/powershell_test.py new file mode 100644 index 0000000..01ccbb5 --- /dev/null +++ b/lib/actions/powershell_test.py @@ -0,0 +1,62 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.actions.powershell.""" + +from glazier.lib import buildinfo +from glazier.lib.actions import powershell +import mock +import unittest + + +class PowershellTest(unittest.TestCase): + + def setUp(self): + buildinfo.constants.FLAGS.config_server = 'https://glazier/' + + @mock.patch.object( + powershell.powershell.PowerShell, 'RunLocal', autospec=True) + @mock.patch.object(powershell.cache.Cache, 'CacheFromLine', autospec=True) + def testPSScript(self, cache, run): + bi = buildinfo.BuildInfo() + cache.return_value = r'C:\Cache\Some-Script.ps1' + ps = powershell.PSScript(['#Some-Script.ps1', ['-Flag1']], bi) + ps.Run() + cache.assert_called_with(mock.ANY, '#Some-Script.ps1', bi) + run.assert_called_with( + mock.ANY, r'C:\Cache\Some-Script.ps1', args=['-Flag1']) + run.side_effect = powershell.powershell.PowerShellError + self.assertRaises(powershell.ActionError, ps.Run) + # Cache error + run.side_effect = None + cache.side_effect = powershell.cache.CacheError + self.assertRaises(powershell.ActionError, ps.Run) + + def testPSScriptValidate(self): + ps = powershell.PSScript(30, None) + self.assertRaises(powershell.ValidationError, ps.Validate) + ps = powershell.PSScript([], None) + self.assertRaises(powershell.ValidationError, ps.Validate) + ps = powershell.PSScript([30, 40], None) + self.assertRaises(powershell.ValidationError, ps.Validate) + ps = powershell.PSScript(['#Some-Script.ps1'], None) + ps.Validate() + ps = powershell.PSScript(['#Some-Script.ps1', '-Flags'], None) + self.assertRaises(powershell.ValidationError, ps.Validate) + ps = powershell.PSScript(['#Some-Script.ps1', ['-Flags']], None) + ps.Validate() + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/actions/registry.py b/lib/actions/registry.py new file mode 100644 index 0000000..f79e199 --- /dev/null +++ b/lib/actions/registry.py @@ -0,0 +1,41 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Actions for managing the host registry.""" + +from glazier.lib.actions.base import ActionError +from glazier.lib.actions.base import BaseAction +from gwinpy.registry import registry + + +class RegAdd(BaseAction): + """Add a new registry key.""" + + def Run(self): + use_64bit = True + if len(self._args) > 5: + use_64bit = self._args[5] + + try: + reg = registry.Registry(root_key=self._args[0]) + reg.SetKeyValue(key_path=self._args[1], + key_name=self._args[2], + key_value=self._args[3], + key_type=self._args[4], + use_64bit=use_64bit) + except registry.RegistryError as e: + raise ActionError(str(e)) + except IndexError: + raise ActionError('Unable to access all required arguments. [%s]' % + str(self._args)) diff --git a/lib/actions/registry_test.py b/lib/actions/registry_test.py new file mode 100644 index 0000000..146a015 --- /dev/null +++ b/lib/actions/registry_test.py @@ -0,0 +1,53 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.actions.registry.""" + +from glazier.lib.actions import registry +import mock +import unittest + + +class RegistryTest(unittest.TestCase): + + @mock.patch( + 'glazier.lib.buildinfo.BuildInfo', autospec=True) + @mock.patch.object(registry.registry, 'Registry', autospec=True) + def testRun(self, winreg, build_info): + kpath = (r'SOFTWARE\Microsoft\Windows NT\CurrentVersion' + r'\SoftwareProtectionPlatform') + args = [ + 'HKLM', kpath, 'KeyManagementServiceName', 'kms-server.example.com', + 'REG_SZ', False + ] + skv = winreg.return_value.SetKeyValue + ra = registry.RegAdd(args, build_info) + ra.Run() + skv.assert_called_with( + key_path=kpath, + key_name='KeyManagementServiceName', + key_value='kms-server.example.com', + key_type='REG_SZ', + use_64bit=False) + # registry error + skv.side_effect = registry.registry.RegistryError + self.assertRaises(registry.ActionError, ra.Run) + skv.side_effect = None + # missing arguments + ra = registry.RegAdd(args[2:], build_info) + self.assertRaises(registry.ActionError, ra.Run) + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/actions/sysprep.py b/lib/actions/sysprep.py new file mode 100644 index 0000000..f7545e8 --- /dev/null +++ b/lib/actions/sysprep.py @@ -0,0 +1,89 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Actions for sysprep-related activites.""" + +import logging +import re +from glazier.lib import timezone +from glazier.lib.actions.base import ActionError +from glazier.lib.actions.base import BaseAction +from gwinpy.net import dhcp + + +class SetUnattendTimeZone(BaseAction): + """Configure the TimeZone entries in unattend.xml.""" + + def _EditUnattend(self, + zone, + unattend_path=r'C:\Windows\Panther\unattend.xml'): + """Edit the unattend.xml to replace the timezone entry. + + Args: + zone: The timezone string to insert into + unattend_path: Path to the unattend.xml file. + """ + lines = [] + try: + with open(unattend_path) as unattend: + lines = unattend.readlines() + lines = [ + re.sub('.*?', '%s' % zone, + l) for l in lines + ] + with open(unattend_path, 'w') as unattend: + unattend.write(''.join(lines)) + except IOError as e: + raise ActionError('Unable to set time zone in unattend.xml (%s)' % str(e)) + + def Run(self): + """Sets the timezone inside unattend.xml.""" + local_tz = 'Pacific Standard Time' + from_dhcp = False + retries = 0 + while not from_dhcp and retries < 5: + for intf in self._build_info.NetInterfaces(): + if intf.ip_address and intf.mac_address: + servers = ['255.255.255.255'] + if intf.dhcp_server: + servers.insert(0, intf.dhcp_server) + logging.debug( + 'Attempting to get timezone from interface with IP %s and MAC %s', + intf.ip_address, intf.mac_address) + for dhcp_server in servers: + from_dhcp = dhcp.GetDhcpOption( + client_addr=intf.ip_address, + client_mac=intf.mac_address, + option=101, server_addr=dhcp_server) + logging.debug('DHCP server %s returned: %s', dhcp_server, from_dhcp) + if from_dhcp: + break + if from_dhcp: + break + logging.debug('No result from DHCP. Retrying...') + retries += 1 + + if from_dhcp: + logging.debug('Got timezone %s from DHCP.', from_dhcp) + tz = timezone.Timezone(load_map=True) + translated = tz.TranslateZone(from_dhcp) + if translated: + local_tz = translated + logging.debug('Successfully translated timezone to %s.', local_tz) + else: + logging.error('Could not translate DHCP timezone.') + else: + logging.error('Could not find timezone from DHCP.') + logging.debug('Finalized timezone is %s.', local_tz) + self._EditUnattend(local_tz) diff --git a/lib/actions/sysprep_test.py b/lib/actions/sysprep_test.py new file mode 100644 index 0000000..b259b51 --- /dev/null +++ b/lib/actions/sysprep_test.py @@ -0,0 +1,134 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.actions.sysprep.""" + +from fakefs import fake_filesystem +from glazier.lib.actions import sysprep +import mock +import unittest + +UNATTEND_XML = r""" + + + + Company + Employee + * + false + Central Standard Time + true + + + + + en-us + en-us + en-us + en-us + + + Central Standard Time + + + cmd /c C:\prepare_build.bat + Prepare build + 1 + true + + + + +""" + + +class SysprepTest(unittest.TestCase): + + def setUp(self): + fakefs = fake_filesystem.FakeFilesystem() + fakefs.CreateDirectory('/windows/panther') + fakefs.CreateFile('/windows/panther/unattend.xml', contents=UNATTEND_XML) + self.fake_open = fake_filesystem.FakeFileOpen(fakefs) + sysprep.os = fake_filesystem.FakeOsModule(fakefs) + sysprep.open = self.fake_open + self.fakefs = fakefs + + @mock.patch( + 'glazier.lib.buildinfo.BuildInfo', autospec=True) + def testSetUnattendTimeZoneEditUnattend(self, build_info): + st = sysprep.SetUnattendTimeZone([], build_info) + st._EditUnattend( + 'Yakutsk Standard Time', unattend_path='/windows/panther/unattend.xml') + with self.fake_open('/windows/panther/unattend.xml') as handle: + result = [line.strip() for line in handle.readlines()] + self.assertIn('Yakutsk Standard Time', result) + # IOError + self.assertRaises(sysprep.ActionError, st._EditUnattend, + 'Yakutsk Standard Time', + '/windows/panther/noneattend.xml') + + @mock.patch( + 'glazier.lib.buildinfo.BuildInfo', autospec=True) + @mock.patch.object( + sysprep.SetUnattendTimeZone, '_EditUnattend', autospec=True) + @mock.patch.object(sysprep.dhcp, 'GetDhcpOption', autospec=True) + def testSetUnattendTimeZoneRun(self, dhcp, edit, build_info): + build_info.NetInterfaces.return_value = [ + mock.Mock( + ip_address='127.0.0.1', + mac_address='11:22:33:44:55:66', + dhcp_server=None), + mock.Mock(ip_address='127.0.0.2', mac_address=None, dhcp_server=None), + mock.Mock( + ip_address=None, mac_address='22:11:33:44:55:66', dhcp_server=None), + mock.Mock( + ip_address='10.1.10.1', + mac_address='AA:BB:CC:DD:EE:FF', + dhcp_server='192.168.1.1') + ] + st = sysprep.SetUnattendTimeZone([], build_info) + # Normal Run + dhcp.side_effect = iter([None, None, 'Antarctica/McMurdo']) + st.Run() + dhcp.assert_has_calls([ + mock.call( + client_addr='127.0.0.1', + client_mac='11:22:33:44:55:66', + option=101, + server_addr='255.255.255.255'), + mock.call( + client_addr='10.1.10.1', + client_mac='AA:BB:CC:DD:EE:FF', + option=101, + server_addr='192.168.1.1'), + mock.call( + client_addr='10.1.10.1', + client_mac='AA:BB:CC:DD:EE:FF', + option=101, + server_addr='255.255.255.255') + ]) + edit.assert_called_with(st, u'New Zealand Standard Time') + # Failed Mapping + dhcp.side_effect = None + dhcp.return_value = 'Antarctica/NorthPole' + st.Run() + edit.assert_called_with(st, u'Pacific Standard Time') + # No Result + dhcp.return_value = None + st.Run() + edit.assert_called_with(st, u'Pacific Standard Time') + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/actions/system.py b/lib/actions/system.py new file mode 100644 index 0000000..e6b63fd --- /dev/null +++ b/lib/actions/system.py @@ -0,0 +1,60 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Actions for interacting with the host system.""" + +import logging +from glazier.lib.actions.base import BaseAction +from glazier.lib.actions.base import RestartEvent +from glazier.lib.actions.base import ShutdownEvent +from glazier.lib.actions.base import ValidationError + + +class _PowerAction(BaseAction): + + def Validate(self): + self._TypeValidator(self._args, list) + if len(self._args) not in [1, 2]: + raise ValidationError('Invalid args length: %s' % self._args) + if not isinstance(self._args[0], str) and not isinstance(self._args[0], + int): + raise ValidationError('Invalid argument type: %s' % self._args[0]) + if len(self._args) > 1 and not isinstance(self._args[1], str): + raise ValidationError('Invalid argument type: %s' % self._args[1]) + + +class Reboot(_PowerAction): + """Perform a host reboot.""" + + def Run(self): + timeout = str(self._args[0]) + reason = 'unspecified' + if len(self._args) > 1: + reason = str(self._args[1]) + logging.info('Rebooting with a timeout of %s and a reason of %s', timeout, + reason) + raise RestartEvent(reason, timeout=timeout) + + +class Shutdown(_PowerAction): + """Perform a host shutdown.""" + + def Run(self): + timeout = str(self._args[0]) + reason = 'unspecified' + if len(self._args) > 1: + reason = str(self._args[1]) + logging.info('Shutting down with a timeout of %s and a reason of %s', + timeout, reason) + raise ShutdownEvent(reason, timeout=timeout) diff --git a/lib/actions/system_test.py b/lib/actions/system_test.py new file mode 100644 index 0000000..ba71bff --- /dev/null +++ b/lib/actions/system_test.py @@ -0,0 +1,77 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.actions.system.""" + +from glazier.lib.actions import system +import mock +import unittest + + +class SystemTest(unittest.TestCase): + + @mock.patch( + 'glazier.lib.buildinfo.BuildInfo', autospec=True) + def testReboot(self, build_info): + r = system.Reboot([30, 'reboot for reasons'], build_info) + with self.assertRaises(system.RestartEvent) as evt: + r.Run() + self.assertEqual(evt.timeout, '30') + self.assertEqual(evt.message, 'reboot for reasons') + + r = system.Reboot([10], build_info) + with self.assertRaises(system.RestartEvent) as evt: + r.Run() + self.assertEqual(evt.timeout, '10') + self.assertEqual(evt.message, 'undefined') + + def testRebootValidate(self): + r = system.Reboot(30, None) + self.assertRaises(system.ValidationError, r.Validate) + r = system.Reboot([], None) + self.assertRaises(system.ValidationError, r.Validate) + r = system.Reboot([30, 40], None) + self.assertRaises(system.ValidationError, r.Validate) + r = system.Reboot([30, 'reasons'], None) + r.Validate() + + @mock.patch( + 'glazier.lib.buildinfo.BuildInfo', autospec=True) + def testShutdown(self, build_info): + r = system.Shutdown([15, 'reboot for reasons'], build_info) + with self.assertRaises(system.ShutdownEvent) as evt: + r.Run() + self.assertEqual(evt.timeout, '15') + self.assertEqual(evt.message, 'reboot for reasons') + + r = system.Shutdown([1], build_info) + with self.assertRaises(system.ShutdownEvent) as evt: + r.Run() + self.assertEqual(evt.timeout, '1') + self.assertEqual(evt.message, 'undefined') + + def testShutdownValidate(self): + s = system.Shutdown(30, None) + self.assertRaises(system.ValidationError, s.Validate) + s = system.Shutdown([], None) + self.assertRaises(system.ValidationError, s.Validate) + s = system.Shutdown([30, 40], None) + self.assertRaises(system.ValidationError, s.Validate) + s = system.Shutdown([30, 'reasons'], None) + s.Validate() + s = system.Shutdown([10], None) + s.Validate() + +if __name__ == '__main__': + unittest.main() diff --git a/lib/actions/timers.py b/lib/actions/timers.py new file mode 100644 index 0000000..560a97e --- /dev/null +++ b/lib/actions/timers.py @@ -0,0 +1,27 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Actions to set imaging timers.""" + +from glazier.lib.actions.base import BaseAction + + +class SetTimer(BaseAction): + """Create an imaging timer.""" + + def Run(self): + self._build_info.TimerSet(str(self._args[0])) + + def Validate(self): + self._ListOfStringsValidator(self._args) diff --git a/lib/actions/timers_test.py b/lib/actions/timers_test.py new file mode 100644 index 0000000..a3aca22 --- /dev/null +++ b/lib/actions/timers_test.py @@ -0,0 +1,45 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.actions.timers.""" + +from glazier.lib.actions import timers +from glazier.lib.actions.base import ValidationError +import mock +import unittest + + +class TimersTest(unittest.TestCase): + + @mock.patch( + 'glazier.lib.buildinfo.BuildInfo', autospec=True) + def testSetTimer(self, build_info): + args = ['Timer1'] + st = timers.SetTimer(args, build_info) + st.Run() + build_info.TimerSet.assert_called_with('Timer1') + + def testSetTimerValidate(self): + st = timers.SetTimer('Timer1', None) + self.assertRaises(ValidationError, st.Validate) + st = timers.SetTimer([1, 2, 3], None) + self.assertRaises(ValidationError, st.Validate) + st = timers.SetTimer([1], None) + self.assertRaises(ValidationError, st.Validate) + st = timers.SetTimer(['Timer1'], None) + st.Validate() + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/actions/tpm.py b/lib/actions/tpm.py new file mode 100644 index 0000000..e233fd6 --- /dev/null +++ b/lib/actions/tpm.py @@ -0,0 +1,38 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Actions to manage the system TPM.""" + +from glazier.lib import bitlocker +from glazier.lib.actions.base import ActionError +from glazier.lib.actions.base import BaseAction +from glazier.lib.actions.base import ValidationError + + +class BitlockerEnable(BaseAction): + + def Run(self): + mode = str(self._args[0]) + try: + bl = bitlocker.Bitlocker(mode) + bl.Enable() + except bitlocker.BitlockerError as e: + raise ActionError('Failure enabling Bitlocker. (%s)' % str(e)) + + def Validate(self): + self._ListOfStringsValidator(self._args, 1) + if self._args[0] not in bitlocker.SUPPORTED_MODES: + raise ValidationError('Unknown mode for BitlockerEnable: %s' % + self._args[0]) + diff --git a/lib/actions/tpm_test.py b/lib/actions/tpm_test.py new file mode 100644 index 0000000..a6e9503 --- /dev/null +++ b/lib/actions/tpm_test.py @@ -0,0 +1,48 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.actions.tpm.""" + +from glazier.lib.actions import tpm +import mock +import unittest + + +class TpmTest(unittest.TestCase): + + @mock.patch.object(tpm.bitlocker, 'Bitlocker', autospec=True) + def testBitlockerEnable(self, bitlocker): + b = tpm.BitlockerEnable(['ps_tpm'], None) + b.Run() + bitlocker.assert_called_with('ps_tpm') + self.assertTrue(bitlocker.return_value.Enable.called) + bitlocker.return_value.Enable.side_effect = tpm.bitlocker.BitlockerError + self.assertRaises(tpm.ActionError, b.Run) + + def testBitlockerEnableValidate(self): + b = tpm.BitlockerEnable(30, None) + self.assertRaises(tpm.ValidationError, b.Validate) + b = tpm.BitlockerEnable([], None) + self.assertRaises(tpm.ValidationError, b.Validate) + b = tpm.BitlockerEnable(['invalid'], None) + self.assertRaises(tpm.ValidationError, b.Validate) + b = tpm.BitlockerEnable(['ps_tpm', 'ps_tpm'], None) + self.assertRaises(tpm.ValidationError, b.Validate) + b = tpm.BitlockerEnable(['ps_tpm'], None) + b.Validate() + + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/actions/updates.py b/lib/actions/updates.py new file mode 100644 index 0000000..e639c58 --- /dev/null +++ b/lib/actions/updates.py @@ -0,0 +1,104 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Actions for managing updates for specific machines.""" + +import logging +import os +from glazier.lib import constants +from glazier.lib import file_util +from glazier.lib.actions.base import ActionError +from glazier.lib.actions.base import BaseAction +from glazier.lib.actions.base import ValidationError +from glazier.lib.actions.files import Execute +from glazier.lib.actions.files import Get + + +class UpdateMSU(BaseAction): + """Downloads file and verifies extension. + + File is downloaded and processed based on supported file extension. + file can then be processed to be used by dism commands. + + Method can be expanded to access and process other formats allowed by DISM. + Also can be used to process multiple files. + + Raises: + ActionError: Call with unsupported file type. + """ + FILE_EXT_SUPPORTED = ['.msu'] + + def Run(self): + for msu in self._args: + dst = str(msu[1]) + file_ext = os.path.splitext(dst)[1] + + if file_ext not in self.FILE_EXT_SUPPORTED: + raise ActionError('Unsupported update file format %s.' % dst) + + g = Get([msu], self._build_info) + g.Run() + + logging.info('Found MSU file, processing update using DISM.') + self._ProcessMsu(dst) + + def Validate(self): + self._TypeValidator(self._args, list) + for cmd_arg in self._args: + self._TypeValidator(cmd_arg, list) + if not 2 <= len(cmd_arg) <= 3: + raise ValidationError('Invalid args length: %s' % cmd_arg) + self._TypeValidator(cmd_arg[0], str) # remote + self._TypeValidator(cmd_arg[1], str) # local + file_ext = os.path.splitext(cmd_arg[1])[1] + if file_ext not in self.FILE_EXT_SUPPORTED: + raise ValidationError('Invalid file type: %s' % cmd_arg[1]) + if len(cmd_arg) > 2: # hash + for arg in cmd_arg[2]: + self._TypeValidator(arg, str) + + def _ProcessMsu(self, msu_file): + """Command used to process updates downloaded. + + This command will apply updates to an image. + + If the exit code for the parsed command is anything other than zero, report + fatal error. + + Args: + msu_file: current file location. + + Raises: + ActionError: Error during update application. + """ + + scratch_dir = '%s\\Updates\\' % constants.SYS_CACHE + + # create scratch directory + file_util.CreateDirectories(scratch_dir) + + # dism commands + update = [ + '{} /image:c:\\ /Add-Package /PackagePath:{} /ScratchDir:{}'.format( + constants.WINPE_DISM, msu_file, scratch_dir) + ] + + logging.info('Applying %s image to main disk.', msu_file) + + # Apply updates to image + ex = Execute([update], self._build_info) + try: + ex.Run() + except ActionError as e: + raise ActionError('Unable to process update %s. (%s)' % (msu_file, e)) diff --git a/lib/actions/updates_test.py b/lib/actions/updates_test.py new file mode 100644 index 0000000..4889dfc --- /dev/null +++ b/lib/actions/updates_test.py @@ -0,0 +1,92 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.actions.updates.""" + +from glazier.lib.actions import updates +from glazier.lib.buildinfo import BuildInfo +import mock +import unittest + + +class UpdatesTest(unittest.TestCase): + + @mock.patch.object(BuildInfo, 'ReleasePath') + @mock.patch('glazier.lib.download.Download.VerifyShaHash', autospec=True) + @mock.patch('glazier.lib.download.Download.DownloadFile', autospec=True) + @mock.patch.object(updates, 'Execute', autospec=True) + @mock.patch.object(updates.file_util, 'CreateDirectories', autospec=True) + def testUpdateMSU(self, mkdir, exe, dl, sha, rpath): + bi = BuildInfo() + + # Setup + remote = '@Drivers/HP/KB2990941-v3-x64.msu' + local = r'c:\KB2990941-v3-x64.msu' + sha_256 = ( + 'd1acbdd8652d6c78ce284bf511f3a7f5f776a0a91357aca060039a99c6a93a16') + conf = {'data': {'update': [[remote, local, sha_256]]}, + 'path': ['/autobuild']} + rpath.return_value = '/' + + # Success + um = updates.UpdateMSU(conf['data']['update'], bi) + um.Run() + dl.assert_called_with( + mock.ANY, ('https://glazier-server.example.com/' + 'bin/Drivers/HP/KB2990941-v3-x64.msu'), + local, + show_progress=True) + sha.assert_called_with(mock.ANY, local, sha_256) + cache = updates.constants.SYS_CACHE + exe.assert_called_with([[( + 'X:\\Windows\\System32\\dism.exe /image:c:\\ ' + '/Add-Package /PackagePath:c:\\KB2990941-v3-x64.msu ' + '/ScratchDir:%s\\Updates\\' % cache)]], mock.ANY) + mkdir.assert_called_with('%s\\Updates\\' % cache) + + # Invalid format + conf['data']['update'][0][1] = 'C:\\Windows6.1-KB2990941-v3-x64.cab' + um = updates.UpdateMSU(conf['data']['update'], bi) + self.assertRaises(updates.ActionError, um.Run) + conf['data']['update'][0][1] = 'C:\\Windows6.1-KB2990941-v3-x64.msu' + + # Dism Fail + exe.return_value.Run.side_effect = updates.ActionError() + self.assertRaises(updates.ActionError, um.Run) + + def testUpdateMSUValidate(self): + g = updates.UpdateMSU('String', None) + self.assertRaises(updates.ValidationError, g.Validate) + g = updates.UpdateMSU([[1, 2, 3]], None) + self.assertRaises(updates.ValidationError, g.Validate) + g = updates.UpdateMSU([[1, '/tmp/out/path']], None) + self.assertRaises(updates.ValidationError, g.Validate) + g = updates.UpdateMSU([['/tmp/src.zip', 2]], None) + self.assertRaises(updates.ValidationError, g.Validate) + g = updates.UpdateMSU( + [['https://glazier/bin/src.msu', '/tmp/out/src.zip']], None) + self.assertRaises(updates.ValidationError, g.Validate) + g = updates.UpdateMSU( + [['https://glazier/bin/src.msu', '/tmp/out/src.msu']], None) + g.Validate() + g = updates.UpdateMSU( + [['https://glazier/bin/src.msu', '/tmp/out/src.msu', '12345']], None) + g.Validate() + g = updates.UpdateMSU([['https://glazier/bin/src.zip', '/tmp/out/src.zip', + '12345', '67890']], None) + self.assertRaises(updates.ValidationError, g.Validate) + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/bitlocker.py b/lib/bitlocker.py new file mode 100644 index 0000000..ee2e7e1 --- /dev/null +++ b/lib/bitlocker.py @@ -0,0 +1,74 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Bitlocker management functionality.""" + +import logging +import subprocess +from glazier.lib import constants +from glazier.lib import powershell + +SUPPORTED_MODES = ['ps_tpm', 'bde_tpm'] + + +class BitlockerError(Exception): + pass + + +class Bitlocker(object): + """Manage Bitlocker related operations on the local host.""" + + def __init__(self, mode): + self._mode = mode + + def _LaunchSubproc(self, command): + """Launch a subprocess. + + Args: + command: A command string to pass to subprocess.call() + + Raises: + BitlockerError: An unexpected exit code from manage-bde. + """ + logging.info('Running BitLocker command: %s', command) + exit_code = subprocess.call(command, shell=True) + if exit_code != 0: + raise BitlockerError('Unexpected exit code from Bitlocker: %s.' % + str(exit_code)) + + def _PsTpm(self): + """Enable TPM mode using Powershell (Win8 +).""" + ps = powershell.PowerShell() + try: + ps.RunCommand(['$ErrorActionPreference=\'Stop\'', ';', 'Enable-BitLocker', + 'C:', '-TpmProtector', '-UsedSpaceOnly', + '-SkipHardwareTest ', '>>', + r'%s\enable-bitlocker.txt' % constants.SYS_LOGS_PATH]) + ps.RunCommand(['$ErrorActionPreference=\'Stop\'', ';', + 'Add-BitLockerKeyProtector', 'C:', + '-RecoveryPasswordProtector', '>NUL']) + except powershell.PowerShellError as e: + raise BitlockerError('Error enabling Bitlocker via Powershell: %s.' % + str(e)) + + def Enable(self): + """Enable bitlocker.""" + if self._mode == 'ps_tpm': + self._PsTpm() + elif self._mode == 'bde_tpm': + self._LaunchSubproc(r'C:\Windows\System32\cmd.exe /c ' + r'C:\Windows\System32\manage-bde.exe -on c: -rp ' + '>NUL') + else: + raise BitlockerError('Unknown mode: %s.' % self._mode) diff --git a/lib/bitlocker_test.py b/lib/bitlocker_test.py new file mode 100644 index 0000000..bd6101b --- /dev/null +++ b/lib/bitlocker_test.py @@ -0,0 +1,59 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.bitlocker.""" + +from glazier.lib import bitlocker +import mock +import unittest + + +class BitlockerTest(unittest.TestCase): + + @mock.patch.object( + bitlocker.powershell.PowerShell, 'RunCommand', autospec=True) + def testPowershell(self, ps): + bit = bitlocker.Bitlocker(mode='ps_tpm') + bit.Enable() + ps.assert_has_calls([ + mock.call(mock.ANY, [ + "$ErrorActionPreference='Stop'", ';', 'Enable-BitLocker', 'C:', + '-TpmProtector', '-UsedSpaceOnly', '-SkipHardwareTest ', '>>', + '%s\\enable-bitlocker.txt' % bitlocker.constants.SYS_LOGS_PATH + ]), mock.call(mock.ANY, [ + "$ErrorActionPreference='Stop'", ';', 'Add-BitLockerKeyProtector', + 'C:', '-RecoveryPasswordProtector', '>NUL' + ]) + ]) + ps.side_effect = bitlocker.powershell.PowerShellError + self.assertRaises(bitlocker.BitlockerError, bit.Enable) + + @mock.patch.object(bitlocker.subprocess, 'call', autospec=True) + def testManageBde(self, call): + bit = bitlocker.Bitlocker(mode='bde_tpm') + call.return_value = 0 + cmdline = ('C:\\Windows\\System32\\cmd.exe /c ' + 'C:\\Windows\\System32\\manage-bde.exe -on c: -rp >NUL') + bit.Enable() + call.assert_called_with(cmdline, shell=True) + call.return_value = 1 + self.assertRaises(bitlocker.BitlockerError, bit.Enable) + + def testFailure(self): + bit = bitlocker.Bitlocker(mode='unsupported') + self.assertRaises(bitlocker.BitlockerError, bit.Enable) + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/buildinfo.py b/lib/buildinfo.py new file mode 100644 index 0000000..ffabad3 --- /dev/null +++ b/lib/buildinfo.py @@ -0,0 +1,582 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Glazier host information discovery subsystem.""" + +import logging +import time +from glazier.lib import cache +from glazier.lib import constants +from glazier.lib import timers +from glazier.lib.config import files +from glazier.lib.spec import spec +from gwinpy.wmi import hw_info +from gwinpy.wmi import net_info +from gwinpy.wmi import tpm_info +import yaml + + +class BuildInfoError(Exception): + pass + + +class BuildInfo(object): + """Encapsulates information pertaining to the build.""" + + def __init__(self): + self._active_conf_path = [] + self._cache = None + self._chooser_pending = [] + self._chooser_responses = {} + self._hw_info = None + self._net_info = None + self._release_info = None + self._timers = timers.Timers() + self._tpm_info = None + self._version_info = None + + # + # Chooser Control Functions + # + + def AddChooserOption(self, option): + """Add an option to the chooser pending questions list.""" + self._chooser_pending.append(option) + + def GetChooserOptions(self): + """Retrieve all pending chooser options.""" + return self._chooser_pending + + def FlushChooserOptions(self): + """Clear all pending chooser options.""" + self._chooser_pending = [] + + def StoreChooserResponses(self, responses): + """Store responses from the Chooser UI.""" + for key in responses: + renamed = 'USER_%s' % key + logging.debug('Importing key %s from chooser.', renamed) + self._chooser_responses[renamed] = responses[key] + + # + # Image Configuration Functions + # + + def BinaryPath(self): + """Determines the path to the folder containing all binaries for build. + + Returns: + The versioned base path to the current build as a string. + """ + server = self.ConfigServer() or '' + path = constants.FLAGS.binary_root_path.strip('/') + path = '%s/%s/' % (server, path) + return path + + def ConfigServer(self): + server = constants.FLAGS.config_server + if server: + server = server.rstrip('/') + return server + + def Release(self): + """Determine the current build release. + + Returns: + The build release as a string. + """ + rel_id_file = '%s/%s' % (self.ReleasePath().rstrip('/'), 'release-id.yaml') + try: + data = files.Read(rel_id_file) + except files.Error as e: + raise BuildInfoError(e) + if data and 'release_id' in data: + return data['release_id'] + return None + + def _ReleaseInfo(self): + if not self._release_info: + rel_info_file = '%s/%s' % (self.ReleasePath().rstrip('/'), + 'release-info.yaml') + try: + self._release_info = files.Read(rel_info_file) + except files.Error as e: + raise BuildInfoError(e) + return self._release_info + + def ReleasePath(self): + """Determines the path to the folder containing all files for build. + + Returns: + The versioned base path to the current build as a string. + """ + path = self.ConfigServer() or '' + if self.Branch(): + path += '/%s' % str(self.Branch()) + path += '/' + return path + + def ActiveConfigPath(self, append=None, pop=False, set_to=None): + """Tracks the active configuration path beneath the config root. + + Use append/pop for directory traversal. + + Args: + append: Append a string to the active config path. + pop: Pop the rightmost string from the active config path. + set_to: Set the config path to an entirely new path. + + Returns: + The active config path after any modifications. + """ + if append: + self._active_conf_path.append(append) + elif set_to: + self._active_conf_path = set_to + elif pop and self._active_conf_path: + self._active_conf_path.pop() + return self._active_conf_path + + def _VersionInfo(self): + if not self._version_info: + info_file = '%s/%s' % (self.ConfigServer().rstrip('/'), + 'version-info.yaml') + try: + self._version_info = files.Read(info_file) + except files.Error as e: + raise BuildInfoError(e) + return self._version_info + + # + # Host Discovery Functions + # + + def BuildPinMatch(self, pin_name, pin_values): + """Compare build pins to local build info data. + + Most pins operate on a simple 1:1 string comparison (eg os_code == + os_code). Pins also support negation match by beginning the pin value + with ! (!win7 matches anything except win7). See _StringPinner for details. + + Special cases: + computer_model: Permits partial string matching. + device_id: Performs a many:many matching, as it's comparing against a + list of all known internal hardware ids instead of just one string. + USER_*: USER_ pins are dynamic, based on options offered by the chooser. + There is no validation on the names of these inputs, as they may + vary between uses. No negation. + + Args: + pin_name: The name of the pin (determines function for comparison). + pin_values: A list of all pin values configured for this pin. + + Returns: + True for a pin match, else False. + + Raises: + BuildInfoError: Reference made to an unsupported pin. + """ + known_pins = self.GetExportedPins() + if pin_name.startswith('USER_'): + if pin_name in self._chooser_responses: + return self._StringPinner([self._chooser_responses[pin_name]], + pin_values) + else: + return False + elif pin_name not in known_pins: + raise BuildInfoError('Referencing illegal pin name: %s' % pin_name) + + loose = False + if pin_name in ['computer_model', 'device_id']: + loose = True + values = known_pins[pin_name]() + values = values if isinstance(values, list) else [values] + return self._StringPinner(values, pin_values, loose=loose) + + def GetExportedPins(self): + return { + 'computer_model': self.ComputerModel, + 'device_id': self.DeviceIds, + 'encryption_type': self.EncryptionLevel, + 'graphics': self.VideoControllersByName, + 'os_code': self.OsCode, + } + + def Cache(self): + """The local build cache. + + Returns: + An instance of the Cache class. + """ + if not self._cache: + self._cache = cache.Cache() + return self._cache + + def ComputerManufacturer(self): + """Get the computer manufacturer from WMI. + + Returns: + A string containing the device manufacturer. + + Raises: + BuildInfoError: Failure determining the system manufacturer. + """ + result = self._HWInfo().ComputerSystemManufacturer() + if not result: + raise BuildInfoError('System manufacturer could not be determined.') + return result + + def ComputerModel(self): + """Get the computer model from WMI. + + Lenovo models are trimmed to three characters to mitigate submodel drift. + + Returns: + the hardware model as a string + + Raises: + BuildInfoError: Failure determining the system model. + """ + result = self._HWInfo().ComputerSystemModel() + if not result: + raise BuildInfoError('System model could not be determined.') + return result + + def ComputerName(self): + """Get the assigned computer name string. + + Returns: + The name string assigned to this machine. + """ + return spec.GetModule().GetHostname() + + def ComputerOs(self): + """Get the assigned computer OS string. + + Returns: + The OS string assigned to this machine. + """ + return spec.GetModule().GetOs() + + def ComputerSerial(self): + """Get the computer serial from WMI. + + Returns: + A string containing the computer serial. + """ + return self._HWInfo().BiosSerial() + + def DeviceIds(self): + """Get local hardware device Ids. + + Returns: + A list containing all detected hardware device IDs in the format + [vendor]-[device]-[subsys]-[revision] + """ + dev_ids = [] + for device in self._HWInfo().PciDevices(): + dev_str = '%s-%s-%s-%s' % (device.ven, device.dev, device.subsys, + device.rev) + logging.debug('Found local device: %s', dev_str) + dev_ids.append(dev_str) + return dev_ids + + def EncryptionLevel(self): + """Determines what encryption level is required for this machine. + + Returns: + The required encryption type as a string (none, tpm) + """ + if self.IsVirtual(): + logging.info('Virtual machine type %s does not require full disk ' + 'encryption.', self.ComputerModel()) + return 'none' + + logging.info('Machine %s requires full disk encryption.', + self.ComputerModel()) + + if self.TpmPresent(): + logging.info('TPM detected - using TPM encryption.') + return 'tpm' + + logging.info('No TPM was detected in this machine.') + return 'tpm' + + def Fqdn(self): + """Get the assigned FQDN string. + + Returns: + The FQDN string assigned to this machine. + """ + return spec.GetModule().GetFqdn() + + def _HWInfo(self): + if not self._hw_info: + self._hw_info = hw_info.HWInfo() + return self._hw_info + + def IsLaptop(self): + """Whether or not this machine is a laptop. + + Returns: + true for laptop, else false + """ + return self._HWInfo().IsLaptop() + + def IsVirtual(self): + """Whether or not this build is in a virtual environment. + + Returns: + true for a virtual build, else false + """ + return self._HWInfo().IsVirtualMachine() + + def KnownBranches(self): + return self._VersionInfo()['versions'] + + def _NetInfo(self): + if not self._net_info: + self._net_info = net_info.NetInfo(active_only=False, poll=True) + return self._net_info + + def NetInterfaces(self, active_only=True): + """Access the local network interfaces. + + Args: + active_only: Only consider active interfaces. + + Returns: + A list of NetInterface objects corresponding to each detected interface. + """ + ni = net_info.NetInfo(active_only=active_only, poll=True) + return ni.Interfaces() + + def OsCode(self): + """Return the OS code associated with this build. + + Returns: + the os code as a string + + Raises: + BuildInfoError: Reference to an unknown operating system. + """ + os = self.ComputerOs() + release_info = self._ReleaseInfo() + if 'os_codes' in release_info: + os_codes = release_info['os_codes'] + if os in os_codes: + return os_codes[os]['code'] + raise BuildInfoError('Unknown OS [%s]', os) + + def Serialize(self, to_file): + """Dumps internal data to a file for later reference.""" + + build_data = { + 'BUILD': { + 'Binary Path': str(self.BinaryPath()), + 'branch': str(self.Branch()), + 'build_timestamp': str(time.strftime('%m/%d/%Y %H:%M:%S')), + 'Chassis': str(self._HWInfo().ChassisType()), + 'Name': str(self.ComputerName()), + 'encryption_type': str(self.EncryptionLevel()), + 'FQDN': str(self.Fqdn()), + 'isLaptop': str(self.IsLaptop()), + 'Manufacturer': str(self.ComputerManufacturer()), + 'Model': str(self.ComputerModel()), + 'OS': str(self.ComputerOs()), + 'release': str(self.Release()), + 'Release Path': str(self.ReleasePath()), + 'SerialNumber': str(self.ComputerSerial()), + 'Support Tier': str(self.SupportTier()), + 'tpm_present': str(self.TpmPresent()), + 'is_virtual': str(self.IsVirtual()), + } + } + # chooser data + user_data = self._chooser_responses + if user_data: + for key in user_data: + build_data['BUILD'][key] = str(user_data[key]) + # timers + t = self._timers.GetAll() + for key in t: + build_data['BUILD']['TIMER_%s' % key] = str(t[key]) + with open(to_file, 'w') as handle: + yaml.dump(build_data, handle) + + def _StringPinner(self, check_list, match_list, loose=False): + """Checks a list of strings for acceptable matches. + + The check_list of strings should be one or more strings we want to verify, + such as the computer model. + + The match_list is a list of strings which we will verify against, such as + a list of pinned computer models. + + A direct match occurs when any one entry in check_list matches any one + entry in match_list. If loose is True, the direct match will happen if + any one full string in check_list matches the beginning of any string in + match_list. + + Also supports inverse pinning. Inverse pins are strings starting with an + exclamation point (!). An inverse pin returns False if any one match + string matches the inverse string (minus the !). + + Inverse pinning results in all non-list elements being treated as matches. + If the set is not directly negated by a matching inverse pin, the outcome + is a successful match. For example: + + [!A, !B] returns False for A and False for B, but True for C. + + Any check_list with at least one inverse pin is treated strictly as an + inverse set. Direct pins are only considered if no inverse pins are + present. This is to compensate for direct matches being exclusive in + nature. It would not make sense to supply [!A, !B, C], because [C] would + have the same result. + + All strings are compared in lowered case. + + Args: + check_list: List of known strings. + match_list: List of acceptable strings. + loose: Accept partial matches (start of string only). + + Returns: + True for a match between check_list and match_list, else False. + """ + if not check_list or not match_list: + logging.debug('Invalid string comparison sets. [%s, %s]', check_list, + match_list) + return False + inverse_in_set = False + for pin in match_list: + if not pin: + continue + pin = str(pin).lower() + if pin[0] == '!': + for item in check_list: + real_pin = pin[1:] + if ((loose and str(item).lower().startswith(real_pin)) or + (not loose and real_pin == str(item).lower())): + logging.debug('Excluded by inverse pin. [%s]', item) + return False + inverse_in_set = True + + if inverse_in_set: + logging.debug('Included by inverse pinning.') + return True + + for pin in match_list: + pin = str(pin).lower() + for item in check_list: + if ((loose and str(item).lower().startswith(pin)) or + (not loose and pin == str(item).lower())): + logging.debug('Included by direct pin. [%s]', item) + return True + return False + + def SupportedModels(self): + """Returns the list of known supported models (tier1 and tier2). + + Returns: + A dict of two elements, tier1 and tier2, each with a list of models. + """ + supported_models = {} + models = self._ReleaseInfo()['supported_models'] + supported_models['tier1'] = [ + str(model).lower() for model in models['tier1'] + ] + supported_models['tier2'] = [ + str(model).lower() for model in models['tier2'] + ] + return supported_models + + def SupportTier(self): + """Determines the support tier for the current device. + + Returns: + 0 = unknown or totally unsupported platform + 1 = tier1, fully supported platform + 2 = tier2, best effort/partial support + """ + model = self.ComputerModel() + supported = self.SupportedModels() + if self._StringPinner([model], supported['tier1'], loose=True): + logging.debug('Model %s is fully supported: tier1.', model) + return 1 + if self._StringPinner([model], supported['tier2'], loose=True): + logging.debug('Model %s is partially supported: tier2.', model) + return 2 + logging.debug('Model %s is not recognized as supported.', model) + return 0 + + def TimerGet(self, name): + return self._timers.Get(name) + + def TimerSet(self, name): + self._timers.Set(name) + + def _TpmInfo(self): + if not self._tpm_info: + self._tpm_info = tpm_info.TpmInfo() + return self._tpm_info + + def TpmPresent(self): + """Get the TPM presence from WMI. + + Returns: + True if a TPM is present, else False. + """ + return self._TpmInfo().TpmPresent() + + + def VideoControllers(self): + """Get any local video (graphics) controllers. + + Returns: + A list containing the detected devices. + """ + return self._HWInfo().VideoControllers() + + def VideoControllersByName(self): + """Get all names of detected video controllers. + + Returns: + A list containing the names of the detected devices. + """ + names = [] + for v in self.VideoControllers(): + names.append(v['name']) + return names + + def WinpeVersion(self): + """The production WinPE version according to the distribution source.""" + return self._VersionInfo()['winpe-version'] + + def Branch(self): + """Determine the current build branch. + + Returns: + The build branch as a string. + + Raises: + BuildInfoError: Reference to an unknown operating system. + """ + versions = self.KnownBranches() + comp_os = self.ComputerOs() + if not comp_os: + raise BuildInfoError('Unable to determine host OS.') + if comp_os in versions: + return versions[comp_os] + raise BuildInfoError('Unable to find a release that supports %s.', comp_os) diff --git a/lib/buildinfo_test.py b/lib/buildinfo_test.py new file mode 100644 index 0000000..2c5db20 --- /dev/null +++ b/lib/buildinfo_test.py @@ -0,0 +1,497 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.buildinfo.""" + +import datetime +from fakefs import fake_filesystem +from glazier.lib import buildinfo +from gwinpy.wmi.hw_info import DeviceId +import mock +import yaml +import unittest + +_RELEASE_INFO = """ +supported_models: + tier1: + [ + Windows Tier 1 Device, # Testing + ] + tier2: + [ + Windows Tier 2 Device, # Testing + ] +""" + +_VERSION_INFO = """ +winpe-version: 12345 +versions: + windows-7-stable: 'stable' + windows-10-stable: 'stable' + windows-10-unstable: 'unstable' +""" + + +class BuildInfoTest(unittest.TestCase): + + def setUp(self): + # fake filesystem + self.filesystem = fake_filesystem.FakeFilesystem() + self.filesystem.CreateDirectory('/dev') + buildinfo.os = fake_filesystem.FakeOsModule(self.filesystem) + buildinfo.open = fake_filesystem.FakeFileOpen(self.filesystem) + # setup + mock_wmi = mock.patch.object( + buildinfo.hw_info.wmi_query, 'WMIQuery', autospec=True) + self.addCleanup(mock_wmi.stop) + mock_wmi.start() + self.buildinfo = buildinfo.BuildInfo() + + def testChooserOptions(self): + opt1 = { + 'name': 'system_locale', + 'type': 'radio_menu', + 'prompt': 'System Locale', + 'options': [] + } + opt2 = { + 'name': 'core_ps_shell', + 'type': 'toggle', + 'prompt': 'Set system shell to PowerShell', + 'options': [] + } + self.buildinfo.AddChooserOption(opt1) + self.buildinfo.AddChooserOption(opt2) + back = self.buildinfo.GetChooserOptions() + self.assertEqual(back[0]['name'], 'system_locale') + self.assertEqual(back[1]['name'], 'core_ps_shell') + self.assertEqual(len(back), 2) + self.buildinfo.FlushChooserOptions() + back = self.buildinfo.GetChooserOptions() + self.assertEqual(len(back), 0) + + def testStoreChooserResponses(self): + """Store responses from the Chooser UI.""" + resp = {'system_locale': 'en-us', 'core_ps_shell': True} + self.buildinfo.StoreChooserResponses(resp) + self.assertEqual(self.buildinfo._chooser_responses['USER_system_locale'], + 'en-us') + self.assertEqual(self.buildinfo._chooser_responses['USER_core_ps_shell'], + True) + + def testBinaryPath(self): + result = self.buildinfo.BinaryPath() + expected = 'https://glazier-server.example.com/bin/' + self.assertEqual(result, expected) + + def testBuildPinMatch(self): + with mock.patch.object( + buildinfo.BuildInfo, 'ComputerModel', autospec=True) as mock_mod: + mock_mod.return_value = 'HP Z620 Workstation' + # Direct include + self.assertTrue( + self.buildinfo.BuildPinMatch( + 'computer_model', ['HP Z640 Workstation', 'HP Z620 Workstation'])) + # Direct exclude + self.assertFalse( + self.buildinfo.BuildPinMatch( + 'computer_model', ['HP Z640 Workstation', 'HP Z840 Workstation'])) + # Inverse exclude + self.assertFalse( + self.buildinfo.BuildPinMatch('computer_model', + ['!HP Z620 Workstation'])) + # Inverse exclude (second) + self.assertFalse( + self.buildinfo.BuildPinMatch('computer_model', [ + '!HP Z840 Workstation', '!HP Z620 Workstation' + ])) + # Inverse include + self.assertTrue( + self.buildinfo.BuildPinMatch('computer_model', + ['!VMWare Virtual Platform'])) + # Inverse include (second) + self.assertTrue( + self.buildinfo.BuildPinMatch('computer_model', [ + '!HP Z640 Workstation', '!HP Z840 Workstation' + ])) + # Substrings + self.assertTrue( + self.buildinfo.BuildPinMatch('computer_model', + ['hp Z840', 'hp Z620'])) + + # Device Ids + with mock.patch.object( + buildinfo.BuildInfo, 'DeviceIds', autospec=True) as did: + did.return_value = ['WW-XX-YY-ZZ', '11-22-33-44'] + # Mismatch + self.assertFalse( + self.buildinfo.BuildPinMatch( + 'device_id', ['AA-BB-CC-DD', 'EE-FF-GG-HH', '11-22-33-55'])) + # Match + self.assertTrue( + self.buildinfo.BuildPinMatch( + 'device_id', ['AA-BB-CC-DD', 'WW-XX-YY-ZZ', 'EE-FF-GG-HH'])) + # Match + self.assertTrue( + self.buildinfo.BuildPinMatch('device_id', + ['AA-BB-CC-DD', 'WW-XX-YY-VV', '11-22'])) + + # Strict matches + with mock.patch.object( + buildinfo.BuildInfo, 'OsCode', autospec=True) as os: + os.return_value = 'win10' + self.assertTrue(self.buildinfo.BuildPinMatch('os_code', ['win10'])) + self.assertTrue(self.buildinfo.BuildPinMatch('os_code', ['WIN10'])) + self.assertFalse(self.buildinfo.BuildPinMatch('os_code', ['win7'])) + self.assertFalse(self.buildinfo.BuildPinMatch('os_code', ['wi'])) + self.assertFalse(self.buildinfo.BuildPinMatch('os_code', [''])) + + # Invalid pin + self.assertRaises(buildinfo.BuildInfoError, self.buildinfo.BuildPinMatch, + 'no_existo', ['invalid pin value']) + + def testBuildUserPinMatch(self): + self.buildinfo.StoreChooserResponses({'puppet': True, 'locale': 'de-de'}) + self.assertFalse(self.buildinfo.BuildPinMatch('USER_puppet', [False])) + self.assertTrue(self.buildinfo.BuildPinMatch('USER_puppet', [True])) + self.assertTrue( + self.buildinfo.BuildPinMatch('USER_locale', ['en-us', 'de-de'])) + self.assertFalse( + self.buildinfo.BuildPinMatch('USER_locale', ['en-us', 'fr-fr'])) + self.assertFalse(self.buildinfo.BuildPinMatch('USER_locale', [])) + self.assertFalse(self.buildinfo.BuildPinMatch('USER_missing', ['na'])) + + def testCache(self): + self.assertEqual(self.buildinfo.Cache().Path(), + buildinfo.constants.SYS_CACHE) + + @mock.patch.object( + buildinfo.hw_info.HWInfo, 'ComputerSystemManufacturer', autospec=True) + def testComputerManufacturer(self, mock_man): + mock_man.return_value = 'Google Inc.' + result = self.buildinfo.ComputerManufacturer() + self.assertEqual(result, 'Google Inc.') + mock_man.return_value = None + self.assertRaises(buildinfo.BuildInfoError, + self.buildinfo.ComputerManufacturer) + + @mock.patch.object( + buildinfo.hw_info.HWInfo, 'ComputerSystemModel', autospec=True) + def testComputerModel(self, sys_model): + sys_model.return_value = 'HP Z620 Workstation' + result = self.buildinfo.ComputerModel() + self.assertEqual(result, 'HP Z620 Workstation') + sys_model.return_value = '2537CE2' + self.assertEqual(result, 'HP Z620 Workstation') # caching + result = self.buildinfo.ComputerModel() + self.assertEqual(result, '2537CE2') + sys_model.return_value = None + self.assertRaises(buildinfo.BuildInfoError, self.buildinfo.ComputerModel) + + + def testHostSpecFlags(self): + old_spec = buildinfo.spec.FLAGS.glazier_spec + buildinfo.spec.FLAGS.glazier_spec = 'flag' + buildinfo.spec.FLAGS.glazier_spec_hostname = 'TEST-HOST' + buildinfo.spec.FLAGS.glazier_spec_fqdn = 'TEST-HOST.example.com' + self.assertEqual(self.buildinfo.ComputerName(), 'TEST-HOST') + self.assertEqual(self.buildinfo.Fqdn(), 'TEST-HOST.example.com') + # clean up + buildinfo.spec.FLAGS.glazier_spec = old_spec + + def testOsSpecFlags(self): + old_spec = buildinfo.spec.FLAGS.glazier_spec + buildinfo.spec.FLAGS.glazier_spec = 'flag' + buildinfo.spec.FLAGS.glazier_spec_os = 'windows-10-test' + self.assertEqual(self.buildinfo.ComputerOs(), 'windows-10-test') + # clean up + buildinfo.spec.FLAGS.glazier_spec = old_spec + + @mock.patch.object(buildinfo.hw_info.HWInfo, 'BiosSerial', autospec=True) + def testComputerSerial(self, bios_serial): + bios_serial.return_value = '5KD1BP1' + result = self.buildinfo.ComputerSerial() + self.assertEqual(result, '5KD1BP1') + + @mock.patch.object(buildinfo.hw_info.HWInfo, 'PciDevices', autospec=True) + def testDeviceIds(self, mock_pci): + test_dev = DeviceId(ven='8086', dev='1E10', subsys='21FB17AA', rev='C4') + mock_pci.return_value = [test_dev] + self.assertEqual(['8086-1E10-21FB17AA-C4'], self.buildinfo.DeviceIds()) + + def testDeviceIdPinning(self): + local_ids = ['11-22-33-44', 'AA-BB-CC-DD', 'AA-BB-CC-DD'] + self.assertTrue( + self.buildinfo._StringPinner( + local_ids, ['AA-BB-CC-DD'], loose=True)) + self.assertTrue( + self.buildinfo._StringPinner( + local_ids, ['AA-BB-CC'], loose=True)) + self.assertTrue( + self.buildinfo._StringPinner( + local_ids, ['AA-BB'], loose=True)) + self.assertTrue(self.buildinfo._StringPinner(local_ids, ['AA'], loose=True)) + self.assertFalse( + self.buildinfo._StringPinner( + local_ids, ['DD-CC-BB-AA'], loose=True)) + self.assertFalse( + self.buildinfo._StringPinner( + local_ids, ['BB-CC'], loose=True)) + + @mock.patch.object(buildinfo.hw_info, 'HWInfo', autospec=True) + def testHWInfo(self, hw_info): + result = self.buildinfo._HWInfo() + self.assertEqual(result, hw_info.return_value) + self.assertEqual(self.buildinfo._hw_info, hw_info.return_value) + + @mock.patch.object(buildinfo.hw_info.HWInfo, 'IsLaptop', autospec=True) + def testIsLaptop(self, mock_lap): + mock_lap.return_value = True + self.assertTrue(self.buildinfo.IsLaptop()) + mock_lap.return_value = False + self.assertFalse(self.buildinfo.IsLaptop()) + + @mock.patch.object( + buildinfo.hw_info.HWInfo, 'IsVirtualMachine', autospec=True) + def testIsVirtual(self, virt): + virt.return_value = False + self.assertFalse(self.buildinfo.IsVirtual()) + virt.return_value = True + self.assertTrue(self.buildinfo.IsVirtual()) + + @mock.patch.object(buildinfo.BuildInfo, '_ReleaseInfo', autospec=True) + @mock.patch.object(buildinfo.BuildInfo, 'ComputerOs', autospec=True) + def testOsCode(self, comp_os, rel_info): + codes = { + 'os_codes': { + 'windows-7-stable-x64': { + 'code': 'win7' + }, + 'windows-10-stable': { + 'code': 'win10' + }, + 'win2012r2-x64-se': { + 'code': 'win2012r2-x64-se' + } + } + } + rel_info.return_value = codes + comp_os.return_value = 'windows-10-stable' + self.assertEqual(self.buildinfo.OsCode(), 'win10') + comp_os.return_value = 'win2012r2-x64-se' + self.assertEqual(self.buildinfo.OsCode(), 'win2012r2-x64-se') + comp_os.return_value = 'win2000-x64-se' + self.assertRaises(buildinfo.BuildInfoError, self.buildinfo.OsCode) + + @mock.patch.object(buildinfo.net_info, 'NetInfo', autospec=True) + def testNetInterfaces(self, netinfo): + netinfo.return_value.Interfaces.return_value = [ + mock.Mock( + description='d1', mac_address='11:22:33:44:55'), + mock.Mock( + description='d2', mac_address='AA:BB:CC:DD:EE'), + mock.Mock( + description='d3', mac_address='AA:22:CC:44:EE'), + ] + ints = self.buildinfo.NetInterfaces() + self.assertEqual(ints[1].description, 'd2') + netinfo.assert_called_with(poll=True, active_only=True) + ints = self.buildinfo.NetInterfaces(False) + netinfo.assert_called_with(poll=True, active_only=False) + + @mock.patch.object(buildinfo.files, 'Read', autospec=True) + @mock.patch.object(buildinfo.BuildInfo, 'Branch', autospec=True) + def testRelease(self, branch, fread): + branch.return_value = 'unstable' + fread.return_value = {'release_id': '1234'} + self.assertEqual(self.buildinfo.Release(), '1234') + fread.assert_called_with( + 'https://glazier-server.example.com/unstable/release-id.yaml') + fread.return_value = {'no_release_id': '1234'} + self.assertEqual(self.buildinfo.Release(), None) + # read error + fread.side_effect = buildinfo.files.Error + self.assertRaises(buildinfo.BuildInfoError, self.buildinfo.Release) + + @mock.patch.object(buildinfo.files, 'Read', autospec=True) + @mock.patch.object(buildinfo.BuildInfo, 'Branch', autospec=True) + def testReleaseInfo(self, branch, fread): + branch.return_value = 'testing' + fread.return_value = {} + self.buildinfo._ReleaseInfo() + fread.assert_called_with( + 'https://glazier-server.example.com/testing/release-info.yaml') + # read error + fread.side_effect = buildinfo.files.Error + self.assertRaises(buildinfo.BuildInfoError, self.buildinfo._ReleaseInfo) + + @mock.patch.object(buildinfo.files, 'Read', autospec=True) + @mock.patch.object(buildinfo.BuildInfo, 'ComputerOs', autospec=True) + def testReleasePath(self, comp_os, read): + read.return_value = yaml.safe_load(_VERSION_INFO) + comp_os.return_value = 'windows-7-stable' + expected = 'https://glazier-server.example.com/stable/' + self.assertEqual(self.buildinfo.ReleasePath(), expected) + comp_os.return_value = 'windows-10-unstable' + expected = 'https://glazier-server.example.com/unstable/' + self.assertEqual(self.buildinfo.ReleasePath(), expected) + # no os + comp_os.return_value = None + self.assertRaises(buildinfo.BuildInfoError, self.buildinfo.ReleasePath) + # invalid os + comp_os.return_value = 'invalid-os-string' + self.assertRaises(buildinfo.BuildInfoError, self.buildinfo.ReleasePath) + + def testActiveConfigPath(self): + self.buildinfo.ActiveConfigPath(append='/foo') + self.buildinfo.ActiveConfigPath(append='/bar') + self.assertEqual(self.buildinfo.ActiveConfigPath(), ['/foo', '/bar']) + self.assertEqual(self.buildinfo.ActiveConfigPath(pop=True), ['/foo']) + self.assertEqual(self.buildinfo.ActiveConfigPath(pop=True), []) + self.assertEqual(self.buildinfo.ActiveConfigPath(pop=True), []) + self.buildinfo.ActiveConfigPath(set_to=['/foo', 'bar', 'baz']) + self.assertEqual(self.buildinfo.ActiveConfigPath(), ['/foo', 'bar', 'baz']) + + def testStringPinner(self): + self.assertFalse(self.buildinfo._StringPinner(['A', 'B'], [])) + self.assertFalse(self.buildinfo._StringPinner(['A', 'B'], None)) + self.assertFalse(self.buildinfo._StringPinner([], ['A', 'B'])) + self.assertFalse(self.buildinfo._StringPinner(None, ['A', 'B'])) + self.assertTrue(self.buildinfo._StringPinner(['A'], ['A', 'B'])) + self.assertTrue(self.buildinfo._StringPinner(['B'], ['A', 'B'])) + self.assertTrue(self.buildinfo._StringPinner(['A'], ['!C', '!D'])) + self.assertFalse(self.buildinfo._StringPinner(['D'], ['!C', '!D'])) + self.assertFalse(self.buildinfo._StringPinner([True], [False])) + self.assertFalse(self.buildinfo._StringPinner([False], [True])) + self.assertTrue(self.buildinfo._StringPinner([True], [True])) + self.assertTrue(self.buildinfo._StringPinner([False], [False])) + + @mock.patch.object(buildinfo.files, 'Read', autospec=True) + @mock.patch.object(buildinfo.BuildInfo, 'ReleasePath', autospec=True) + def testSupportedModels(self, unused_rel_path, fread): + fread.return_value = yaml.safe_load(_RELEASE_INFO) + results = self.buildinfo.SupportedModels() + self.assertIn('tier1', results) + self.assertIn('tier2', results) + for model in results['tier1'] + results['tier2']: + self.assertEqual(type(model), str) + self.assertIn('windows tier 1 device', results['tier1']) + self.assertIn('windows tier 2 device', results['tier2']) + + @mock.patch.object(buildinfo.BuildInfo, 'ComputerModel', autospec=True) + @mock.patch.object(buildinfo.BuildInfo, 'SupportedModels', autospec=True) + def testSupportTier(self, mock_supp, mock_comp): + # Tier1 + mock_comp.return_value = 'VMWare Virtual Platform' + mock_supp.return_value = { + 'tier1': ['vmware virtual platform', 'hp z620 workstation'], + 'tier2': ['precision workstation t3400', '20BT'], + } + self.assertEqual(self.buildinfo.SupportTier(), 1) + # Tier 2 + mock_comp.return_value = 'Precision WorkStation T3400' + self.assertEqual(self.buildinfo.SupportTier(), 2) + # Partial Match + mock_comp.return_value = '20BTS0A400' + self.assertEqual(self.buildinfo.SupportTier(), 2) + # Unsupported + mock_comp.return_value = 'Best Buy Special of the Day' + self.assertEqual(self.buildinfo.SupportTier(), 0) + + @mock.patch.object(buildinfo.tpm_info, 'TpmInfo', autospec=True) + def testTpmInfo(self, tpm_info): + result = self.buildinfo._TpmInfo() + self.assertEqual(result, tpm_info.return_value) + self.assertEqual(self.buildinfo._tpm_info, tpm_info.return_value) + + @mock.patch.object(buildinfo.tpm_info.TpmInfo, 'TpmPresent', autospec=True) + def testTpmPresent(self, tpm_present): + tpm_present.return_value = True + self.assertTrue(self.buildinfo.TpmPresent()) + tpm_present.return_value = False + self.assertTrue(self.buildinfo.TpmPresent()) # caching + self.assertFalse(self.buildinfo.TpmPresent()) + + @mock.patch.object(buildinfo.files, 'Read', autospec=True) + def testWinpeVersion(self, fread): + fread.return_value = yaml.safe_load(_VERSION_INFO) + self.assertEqual(type(self.buildinfo.WinpeVersion()), int) + + @mock.patch.object(buildinfo.BuildInfo, 'ComputerModel', autospec=True) + @mock.patch.object(buildinfo.BuildInfo, 'IsVirtual', autospec=True) + @mock.patch.object(buildinfo.BuildInfo, 'TpmPresent', autospec=True) + @mock.patch.object(buildinfo.logging, 'info', autospec=True) + def testEncryptionLevel(self, info, tpm, virtual, model): + model.return_value = 'HP Z440 Workstation' + tpm.return_value = False + virtual.return_value = True + # virtual machine + self.assertEqual(self.buildinfo.EncryptionLevel(), 'none') + info.assert_called_with(mock.REGEXP('^Virtual machine type .*'), mock.ANY) + virtual.return_value = False + # tpm + tpm.return_value = True + self.assertEqual(self.buildinfo.EncryptionLevel(), 'tpm') + info.assert_called_with(mock.REGEXP('^TPM detected .*')) + # default + self.assertEqual(self.buildinfo.EncryptionLevel(), 'tpm') + + def testSerialize(self): + mock_b = mock.Mock(spec_set=self.buildinfo) + mock_b._chooser_responses = { + 'USER_choice_one': 'value1', + 'USER_choice_two': 'value2' + } + mock_b._timers.GetAll.return_value = { + 'timer_1': datetime.datetime.utcnow() + } + mock_b.Serialize = buildinfo.BuildInfo.Serialize.__get__(mock_b) + mock_b.Serialize('/build_info.yaml') + parsed = yaml.safe_load(buildinfo.open('/build_info.yaml')) + self.assertIn('branch', parsed['BUILD']) + self.assertIn('Model', parsed['BUILD']) + self.assertIn('SerialNumber', parsed['BUILD']) + self.assertIn('USER_choice_two', parsed['BUILD']) + self.assertIn('TIMER_timer_1', parsed['BUILD']) + self.assertEqual(parsed['BUILD']['USER_choice_two'], 'value2') + + @mock.patch.object(buildinfo.timers.datetime, 'datetime', autospec=True) + def testTimers(self, dt): + now = datetime.datetime.utcnow() + dt.utcnow.return_value = now + self.buildinfo.TimerSet('test_timer_1') + self.assertEqual(self.buildinfo.TimerGet('test_timer_2'), None) + self.assertEqual(self.buildinfo.TimerGet('test_timer_1'), now) + + + @mock.patch.object( + buildinfo.hw_info.HWInfo, 'VideoControllers', autospec=True) + def testVideoControllers(self, controllers): + controllers.return_value = [{ + 'name': 'NVIDIA Quadro 600' + }, { + 'name': 'Intel(R) HD Graphics 4000' + }] + result = self.buildinfo.VideoControllers() + self.assertEqual(result[0]['name'], 'NVIDIA Quadro 600') + self.assertTrue( + self.buildinfo.BuildPinMatch( + 'graphics', ['Intel(R) HD Graphics 3000', 'NVIDIA Quadro 600'])) + self.assertFalse( + self.buildinfo.BuildPinMatch( + 'graphics', ['Intel(R) HD Graphics 3000', 'NVIDIA Quadro 500'])) + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/cache.py b/lib/cache.py new file mode 100644 index 0000000..89b7812 --- /dev/null +++ b/lib/cache.py @@ -0,0 +1,94 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Manages the on-disk build cache.""" + +import os +import re +from glazier.lib import constants +from glazier.lib import download + +DNLD_RE = re.compile(r'([@|#][\S]+)') + + +class CacheError(Exception): + pass + + +class Cache(object): + """Handles interation with the on-disk build cache.""" + + def __init__(self): + self._downloader = download.Download(show_progress=False) + + def _DestinationPath(self, url): + """Determines the local path for a file being downloaded. + + Args: + url: A web address to a file as a string + + Returns: + The local disk path as a string. + """ + file_name = url.split('/').pop() + destination = os.path.join(self.Path(), file_name) + return destination + + def _FindDownload(self, line): + """Searches a command line for any download strings. + + Args: + line: the command line to search + + Returns: + the url which requires downloading or none + """ + result = DNLD_RE.search(line) + if result: + return result.group(1).rstrip('"\'') + return None + + def CacheFromLine(self, line, build_info): + """Downloads any files in the command line and replaces with the local path. + + Args: + line: the command line to process as a string + build_info: the current build information + + Returns: + the final command line as a string; None on error + + Raises: + CacheError: unable to download a file to the local cache + """ + match = self._FindDownload(line) + while match: + dl = download.Transform(match, build_info) + destination = self._DestinationPath(dl) + try: + self._downloader.DownloadFile(dl, destination) + except download.DownloadError as e: + self._downloader.PrintDebugInfo() + raise CacheError('Unable to download required file %s: %s' % (dl, e)) + line = line.replace(match, destination) + match = self._FindDownload(line) + return line + + def Path(self): + """Returns the path to the local build cache. + + Returns: + the path to the local build cache + """ + return constants.SYS_CACHE diff --git a/lib/cache_test.py b/lib/cache_test.py new file mode 100644 index 0000000..3af88b7 --- /dev/null +++ b/lib/cache_test.py @@ -0,0 +1,86 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.cache.""" + +import os +from fakefs import fake_filesystem +from glazier.lib import cache +import mock +import unittest + + +class CacheTest(unittest.TestCase): + + def setUp(self): + self.cache = cache.Cache() + fakefs = fake_filesystem.FakeFilesystem() + fakefs.CreateDirectory(r'C:\Directory') + os_module = fake_filesystem.FakeOsModule(fakefs) + self.mock_open = fake_filesystem.FakeFileOpen(fakefs) + cache.os = os_module + cache.open = self.mock_open + + def MockTransform(self, string, unused_info): + if '#' in string: + string = string.replace('#', 'https://test.example.com/release/') + if '@' in string: + string = string.replace('@', 'https://test.example.com/bin/') + return string + + @mock.patch.object(cache.download, 'Transform', autospec=True) + @mock.patch.object(cache.download.Download, 'DownloadFile', autospec=True) + def testCacheFromLine(self, download, transform): + remote1 = r'folder/other/installer.msi' + remote2 = r'config_file.conf' + local1 = os.path.join(self.cache.Path(), 'installer.msi') + local2 = os.path.join(self.cache.Path(), 'config_file.conf') + line_in = 'msiexec /i @%s /qa /l*v CONF=#%s' % (remote1, remote2) + line_out = 'msiexec /i %s /qa /l*v CONF=%s' % (local1, local2) + download.return_value = True + transform.side_effect = self.MockTransform + result = self.cache.CacheFromLine(line_in, None) + self.assertEqual(result, line_out) + call1 = mock.call(self.cache._downloader, + 'https://test.example.com/bin/%s' % remote1, local1) + call2 = mock.call(self.cache._downloader, + 'https://test.example.com/release/%s' % remote2, local2) + download.assert_has_calls([call1, call2]) + # download exception + transfer_err = cache.download.DownloadError('Error message.') + download.side_effect = transfer_err + self.assertRaises(cache.CacheError, self.cache.CacheFromLine, + '@%s' % remote2, None) + + def testDestinationPath(self): + path = self.cache._DestinationPath('http://some.web.address/folder/other/' + 'an_installer.msi') + self.assertEqual(path, os.path.join(self.cache.Path(), 'an_installer.msi')) + + def testFindDownload(self): + line_test = self.cache._FindDownload('powershell -file ' + r'C:\run_some_file.ps1') + self.assertEqual(line_test, None) + line_test = self.cache._FindDownload('msiexec /i @installer.msi /qa') + self.assertEqual(line_test, '@installer.msi') + line_test = self.cache._FindDownload(r'C:\install_some_program.exe ' + '/i ARGS=FOO') + self.assertEqual(line_test, None) + line_test = self.cache._FindDownload( + 'some_executable.exe /conf=#remote.conf /flag1 /flag1') + self.assertEqual(line_test, '#remote.conf') + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/config/__init__.py b/lib/config/__init__.py new file mode 100644 index 0000000..5def370 --- /dev/null +++ b/lib/config/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/lib/config/base.py b/lib/config/base.py new file mode 100644 index 0000000..8b0d0c1 --- /dev/null +++ b/lib/config/base.py @@ -0,0 +1,62 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Configuration handling core functionality. + +This class contains features common to both the Config Builder and Config Runner +classes. It is meant to be inherited rather than run directly. +""" + +from glazier.lib import actions + + +class ConfigError(Exception): + pass + + +class ConfigBase(object): + """Core functionality for the configuration handling module.""" + + def __init__(self, build_info): + self._build_info = build_info + + def _GetAction(self, action, params): + try: + act_obj = getattr(actions, str(action)) + return act_obj(args=params, build_info=self._build_info) + except AttributeError: + raise ConfigError('Unknown imaging action: %s' % str(action)) + + def _IsRealtimeAction(self, action, params): + """Determine whether $action should happen in realtime.""" + if action not in dir(actions): + return False + a = self._GetAction(action, params) + return a.IsRealtime() + + def _ProcessAction(self, action, params): + """Attempt to process a dynamic action element. + + Args: + action: The name of the action. + params: The params being passed in with the action. + + Raises: + ConfigError: The action is either undefined, or failed to execute. + """ + try: + a = self._GetAction(action, params) + a.Run() + except actions.ActionError as e: + raise ConfigError(str(e)) diff --git a/lib/config/base_test.py b/lib/config/base_test.py new file mode 100644 index 0000000..9abb120 --- /dev/null +++ b/lib/config/base_test.py @@ -0,0 +1,45 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.config.base.""" + +from glazier.lib import buildinfo +from glazier.lib.config import base +import mock +import unittest + + +class BaseTest(unittest.TestCase): + + def setUp(self): + self.buildinfo = buildinfo.BuildInfo() + self.cb = base.ConfigBase(self.buildinfo) + + @mock.patch.object(base.actions, 'SetTimer', autospec=True) + def testProcessActions(self, set_timer): + # valid command + self.cb._ProcessAction('SetTimer', ['TestTimer']) + set_timer.assert_called_with(build_info=self.buildinfo, args=['TestTimer']) + self.assertTrue(set_timer.return_value.Run.called) + # invalid command + self.assertRaises(base.ConfigError, self.cb._ProcessAction, 'BadSetTimer', + ['Timer1']) + # action error + set_timer.side_effect = base.actions.ActionError + self.assertRaises(base.ConfigError, self.cb._ProcessAction, 'SetTimer', + ['Timer1']) + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/config/builder.py b/lib/config/builder.py new file mode 100644 index 0000000..9e8a595 --- /dev/null +++ b/lib/config/builder.py @@ -0,0 +1,165 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Manages the initial processing of build.yaml files. + +## Overview + +The Config Builder is responsible for compiling a custom installation task list +for the local host. It traces through all yaml configuration files linked to +the root config, and stores any commands that are determined applicable by +pinning (or lack thereof). + +The end result of running ConfigBuilder is a single ordered list of tasks to +be performed when installing the host. + +## Use + +Start is the main entry point for the class. It expects a path to a +configuration directory and a filename (the base build.yaml). + +## Include Logic + +Yaml files can refer to one another via the include directive. Each sub-yaml +is treated identically to the base file, following the exact same logic. The +include directive calls Start on each included file, completing that file +top-to-bottom before returning to the caller. + +BuildInfo tracks our position in the directory tree. Additional levels +(includes in sub-directories) are pushed onto the ActiveConfigPath while we +process the include. At the end of processing the stack is popped, returning us +to our previous level. This allows us to reference other files relative to the +location of the active config. +""" + +import copy +from glazier.lib import actions +from glazier.lib import buildinfo +from glazier.lib import download +from glazier.lib.config import base +from glazier.lib.config import files + +_ALLOW_IN_TEMPLATE = [ + 'include', + 'template', + 'execute', + 'policy', +] + dir(actions) +_ALLOW_IN_CONTROL = _ALLOW_IN_TEMPLATE + ['pin'] + + +class ConfigBuilderError(base.ConfigError): + pass + + +class ConfigBuilder(base.ConfigBase): + """Builds the complete task list for the installation.""" + + def Start(self, out_file, in_path, in_file='build.yaml'): + """Start parsing configuration files. + + Args: + out_file: The location to store the compiled config data. + in_path: The path to the root configuration file. + in_file: The root configuration file name. + """ + self._task_list = [] + self._Start(in_path, in_file) + try: + files.Dump(out_file, self._task_list, mode='a') + except files.Error as e: + raise ConfigBuilderError(e) + + def _Start(self, conf_path, conf_file): + """Pull and process a config file. + + Args: + conf_path: The path to the config below root. + conf_file: A named config file, normally build.yaml. + """ + self._build_info.ActiveConfigPath(append=conf_path.rstrip('/')) + try: + path = download.PathCompile(self._build_info, file_name=conf_file) + yaml_config = files.Read(path) + except (files.Error, buildinfo.BuildInfoError) as e: + raise ConfigBuilderError(e) + controls = yaml_config['controls'] + for control in controls: + if 'pin' not in control or self._MatchPin(control['pin']): + self._StoreControls(control, yaml_config.get('templates')) + self._build_info.ActiveConfigPath(pop=True) + + def _MatchPin(self, pins): + """Check all pin entries for a mismatch. + + Pins can mismatch either by the matching setting being omitted or by + matching an exclusion (!). + + Example: + pins: ['os', ['win7', 'win8']] + + * Will match os = win7 or os = win8. + * Will fail to match os = '2012r2'. + * Will match model = 'vmware' (because model is not pinned). + + Example 2: + pins: ['os', ['!win7']] + + * Will match os = win8 or os = 2012r2. + * Will fail to match os = 'win7'. + * Will match model = 'vmware' (because model is not pinned). + + Args: + pins: a list of all applicable pin names and acceptable values + + Returns: + True if this host passes all pin checks. False if the host fails a match. + """ + for pin in pins: + try: + if not self._build_info.BuildPinMatch(pin, pins[pin]): + return False + except buildinfo.BuildInfoError as e: + raise ConfigBuilderError('Error gathering system information. %s' % e) + return True + + def _StoreControls(self, control, templates): + """Process all of the possible sub-sections of a main control section. + + Args: + control: The data from this control subsection. + templates: Any templates declared in the current config. + + Raises: + ConfigBuilderError: Attempt to process an unknown command element. + """ + for element in control: + if element == 'pin': + continue + elif element == 'template': + for template in control['template']: + self._StoreControls(templates[template], templates) + elif element == 'include': + for sub_inc in control['include']: + self._Start(conf_path=sub_inc[0], conf_file=sub_inc[1]) + elif element in _ALLOW_IN_CONTROL: + if self._IsRealtimeAction(element, control[element]): + self._ProcessAction(element, control[element]) + else: + self._task_list.append({ + 'path': copy.deepcopy(self._build_info.ActiveConfigPath()), + 'data': {element: control[element]} + }) + else: + raise ConfigBuilderError('Unknown imaging action: %s' % str(element)) diff --git a/lib/config/builder_test.py b/lib/config/builder_test.py new file mode 100644 index 0000000..51e1619 --- /dev/null +++ b/lib/config/builder_test.py @@ -0,0 +1,90 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.config.builder.""" + +from fakefs import fake_filesystem +from glazier.lib import buildinfo +from glazier.lib.config import builder +import mock +import unittest + + +class ConfigBuilderTest(unittest.TestCase): + + def setUp(self): + + self.buildinfo = buildinfo.BuildInfo() + # filesystem + self.filesystem = fake_filesystem.FakeFilesystem() + self.cb = builder.ConfigBuilder(self.buildinfo) + self.cb._task_list = [] + + @mock.patch.object(buildinfo.BuildInfo, 'BuildPinMatch', autospec=True) + def testMatchPin(self, bpm): + # All direct matching. + bpm.side_effect = iter([True, True]) + pins = { + 'computer_model': [ + 'HP Z640 Workstation', + 'HP Z620 Workstation', + ], + 'os_code': ['win7'] + } + self.assertTrue(self.cb._MatchPin(pins)) + # Inverse match + direct match. + bpm.side_effect = iter([False, True]) + pins = { + 'computer_model': [ + 'HP Z640 Workstation', + '!HP Z620 Workstation', + ], + 'os_code': ['win7'] + } + self.assertFalse(self.cb._MatchPin(pins)) + # Inverse miss. + bpm.side_effect = iter([True, False]) + pins = { + 'computer_model': ['!VMWare Virtual Platform'], + 'os_code': ['win8'] + } + self.assertFalse(self.cb._MatchPin(pins)) + # Empty set. + pins = {} + self.assertTrue(self.cb._MatchPin(pins)) + # Inverse miss + direct mismatch. + bpm.side_effect = iter([False, False]) + pins = { + 'computer_model': ['VMWare Virtual Platform'], + 'os_code': ['win8'] + } + self.assertFalse(self.cb._MatchPin(pins)) + # Error + bpm.side_effect = buildinfo.BuildInfoError + self.assertRaises(builder.ConfigBuilderError, self.cb._MatchPin, pins) + + @mock.patch.object(builder.ConfigBuilder, '_ProcessAction', autospec=True) + def testRealtime(self, process): + config = {'ShowChooser': ['Chooser Stuff']} + self.cb._StoreControls(config, {}) + process.assert_called_with(mock.ANY, 'ShowChooser', ['Chooser Stuff']) + process.reset_mock() + config = {'CopyFile': [r'C:\input.txt', r'C:\output.txt']} + self.cb._StoreControls(config, {}) + self.assertFalse(process.called) + self.assertEqual(self.cb._task_list[0]['data'], config) + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/config/files.py b/lib/config/files.py new file mode 100644 index 0000000..9ca5165 --- /dev/null +++ b/lib/config/files.py @@ -0,0 +1,82 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Functions for interacting with yaml configuration files.""" + +import re +from glazier.lib import download +from glazier.lib import file_util +import yaml + + +class Error(Exception): + pass + + +def Dump(path, data, mode='w'): + """Write a config file containing some data. + + Args: + path: The filesystem path to the destination file. + data: Data to be written to the file as yaml. + mode: Mode to use for writing the file (default: w) + """ + file_util.CreateDirectories(path) + try: + with open(path, mode) as handle: + handle.write(yaml.dump(data)) + except IOError as e: + raise Error('Could not save data to yaml file %s: %s' % (path, str(e))) + + +def Read(path): + """Read a config file at path and return any data it contains. + + Will attempt to download files from remote repositories prior to reading. + + Args: + path: The path (either local or remote) to read from. + + Returns: + The parsed YAML content from the file. + + Raises: + Error: Failure retrieving a remote file or parsing file content. + """ + if re.match('^http(s)?://', path): + downloader = download.Download() + try: + path = downloader.DownloadFileTemp(path) + except download.DownloadError as e: + raise Error('Could not download yaml file %s: %s' % (path, str(e))) + return _YamlReader(path) + + +def _YamlReader(path): + """Read a configuration file and return the contents. + + Can be overloaded to read configs from different sources. + + Args: + path: The config file name (eg build.yaml). + + Returns: + The parsed content of the yaml file. + """ + try: + with open(path, 'r') as yaml_file: + yaml_config = yaml.safe_load(yaml_file) + except IOError as e: + raise Error('Could not read yaml file %s: %s' % (path, str(e))) + return yaml_config diff --git a/lib/config/files_test.py b/lib/config/files_test.py new file mode 100644 index 0000000..7e0ee59 --- /dev/null +++ b/lib/config/files_test.py @@ -0,0 +1,66 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.config.files.""" + +from fakefs import fake_filesystem +from glazier.lib.config import files +import mock +import unittest + + +class FilesTest(unittest.TestCase): + + def setUp(self): + self.filesystem = fake_filesystem.FakeFilesystem() + files.open = fake_filesystem.FakeFileOpen(self.filesystem) + files.file_util.os = fake_filesystem.FakeOsModule(self.filesystem) + + def testDump(self): + op_list = ['op1', ['op2a', 'op2b'], 'op3', {'op4a': 'op4b'}] + files.Dump('/tmp/foo/dump.txt', op_list) + result = files._YamlReader('/tmp/foo/dump.txt') + self.assertEqual(result[1], ['op2a', 'op2b']) + self.assertEqual(result[3], {'op4a': 'op4b'}) + self.assertRaises(files.Error, files.Dump, '/tmp', []) + + @mock.patch.object(files.download.Download, 'DownloadFileTemp', autospec=True) + def testRead(self, download): + self.filesystem.CreateFile('/tmp/downloaded1.yaml', contents='data: set1') + self.filesystem.CreateFile('/tmp/downloaded2.yaml', contents='data: set2') + download.return_value = '/tmp/downloaded1.yaml' + result = files.Read( + 'https://glazier-server.example.com/unstable/dir/test-build.yaml') + download.assert_called_with( + mock.ANY, + 'https://glazier-server.example.com/unstable/dir/test-build.yaml') + self.assertEqual(result['data'], 'set1') + # download error + download.side_effect = files.download.DownloadError + self.assertRaises( + files.Error, files.Read, + 'https://glazier-server.example.com/unstable/dir/test-build.yaml') + # local + result = files.Read('/tmp/downloaded2.yaml') + self.assertEqual(result['data'], 'set2') + + def testYamlReader(self): + self.filesystem.CreateFile( + '/foo/bar/baz.txt', contents='- item4\n- item5\n- item6') + result = files._YamlReader('/foo/bar/baz.txt') + self.assertEqual(result[1], 'item5') + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/config/runner.py b/lib/config/runner.py new file mode 100644 index 0000000..effe0ae --- /dev/null +++ b/lib/config/runner.py @@ -0,0 +1,97 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Manages the execution of the local host task list.""" + +import sys +from glazier.lib import policies +from glazier.lib import power +from glazier.lib.config import base +from glazier.lib.config import files + + +class ConfigRunnerError(base.ConfigError): + pass + + +class ConfigRunner(base.ConfigBase): + """Executes all steps from the installation task list.""" + + def Start(self, task_list): + self._task_list_path = task_list + try: + data = files.Read(self._task_list_path) + except files.Error as e: + raise ConfigRunnerError(e) + self._ProcessTasks(data) + + def _PopTask(self, tasks): + """Remove the first event from the task list and save new list to disk.""" + tasks.pop(0) + try: + files.Dump(self._task_list_path, tasks, mode='w') + except files.Error as e: + raise ConfigRunnerError(e) + + def _ProcessTasks(self, tasks): + """Process the pending tasks list. + + Args: + tasks: The list of pending tasks. + """ + while tasks: + entry = tasks[0]['data'] + self._build_info.ActiveConfigPath(set_to=tasks[0]['path']) + for element in entry: + if element == 'policy': + for line in entry['policy']: + self._Policy(line) + else: + try: + self._ProcessAction(element, entry[element]) + except base.ConfigError as e: + raise ConfigRunnerError(e) + except base.actions.RestartEvent as e: + if e.task_list_path: + self._task_list_path = e.task_list_path + if not e.retry_on_restart: + self._PopTask(tasks) + power.Restart(e.timeout, e.message) + sys.exit(0) + except base.actions.ShutdownEvent as e: + if e.task_list_path: + self._task_list_path = e.task_list_path + if not e.retry_on_restart: + self._PopTask(tasks) + power.Shutdown(e.timeout, e.message) + sys.exit(0) + self._PopTask(tasks) + + def _Policy(self, line): + """Execute an imaging policy check. + + Args: + line: The name of a supported imaging policy. + + Raises: + ConfigRunnerError: An imaging policy has raised an exception. + """ + try: + check = getattr(policies, str(line)) + policy = check(build_info=self._build_info) + policy.Verify() + except AttributeError: + raise ConfigRunnerError('Unknown imaging policy: %s' % str(line)) + except policies.ImagingPolicyException as e: + raise ConfigRunnerError(str(e)) diff --git a/lib/config/runner_test.py b/lib/config/runner_test.py new file mode 100644 index 0000000..57d7aaf --- /dev/null +++ b/lib/config/runner_test.py @@ -0,0 +1,128 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.config.runner.""" + +from fakefs import fake_filesystem +from fakefs import fake_filesystem_shutil +from glazier.lib import buildinfo +from glazier.lib.config import runner +import mock +import unittest + + +class ConfigRunnerTest(unittest.TestCase): + + def setUp(self): + self.buildinfo = buildinfo.BuildInfo() + # filesystem + self.filesystem = fake_filesystem.FakeFilesystem() + runner.os = fake_filesystem.FakeOsModule(self.filesystem) + runner.open = fake_filesystem.FakeFileOpen(self.filesystem) + runner.shutil = fake_filesystem_shutil.FakeShutilModule(self.filesystem) + self.cr = runner.ConfigRunner(self.buildinfo) + self.cr._task_list_path = '/tmp/task_list.yaml' + + @mock.patch.object(runner.base.actions, 'pull', autospec=True) + @mock.patch.object(runner.files, 'Dump', autospec=True) + def testIteration(self, dump, unused_get): + conf = [{'data': {'pull': 'val1'}, + 'path': ['path1']}, {'data': {'pull': 'val2'}, + 'path': ['path2']}, {'data': {'pull': 'val3'}, + 'path': ['path3']}] + self.cr._ProcessTasks(conf) + dump.assert_has_calls([ + mock.call( + self.cr._task_list_path, conf[1:], mode='w'), mock.call( + self.cr._task_list_path, conf[2:], mode='w'), mock.call( + self.cr._task_list_path, [], mode='w') + ]) + + @mock.patch.object(runner.files, 'Dump', autospec=True) + def testPopTask(self, dump): + self.cr._PopTask([1, 2, 3]) + dump.assert_called_with('/tmp/task_list.yaml', [2, 3], mode='w') + dump.side_effect = runner.files.Error + with self.assertRaises(runner.ConfigRunnerError): + self.cr._PopTask([1, 2]) + + @mock.patch.object(runner.power, 'Restart', autospec=True) + @mock.patch.object(runner.ConfigRunner, '_ProcessAction', autospec=True) + @mock.patch.object(runner.ConfigRunner, '_PopTask', autospec=True) + def testRestartEvents(self, pop, action, restart): + conf = [{'data': {'Shutdown': ['25', 'Reason']}, 'path': ['path1']}] + event = runner.base.actions.RestartEvent('Some reason', timeout=25) + action.side_effect = event + self.assertRaises(SystemExit, self.cr._ProcessTasks, conf) + restart.assert_called_with(25, 'Some reason') + self.assertTrue(pop.called) + pop.reset_mock() + # with retry + event = runner.base.actions.RestartEvent( + 'Some other reason', timeout=10, retry_on_restart=True) + action.side_effect = event + self.assertRaises(SystemExit, self.cr._ProcessTasks, conf) + restart.assert_called_with(10, 'Some other reason') + self.assertFalse(pop.called) + + @mock.patch.object(runner.power, 'Shutdown', autospec=True) + @mock.patch.object(runner.ConfigRunner, '_ProcessAction', autospec=True) + @mock.patch.object(runner.ConfigRunner, '_PopTask', autospec=True) + def testShutdownEvents(self, pop, action, shutdown): + conf = [{'data': {'Restart': ['25', 'Reason']}, 'path': ['path1']}] + event = runner.base.actions.ShutdownEvent('Some reason', timeout=25) + action.side_effect = event + self.assertRaises(SystemExit, self.cr._ProcessTasks, conf) + shutdown.assert_called_with(25, 'Some reason') + self.assertTrue(pop.called) + pop.reset_mock() + # with retry + event = runner.base.actions.ShutdownEvent( + 'Some other reason', timeout=10, retry_on_restart=True) + action.side_effect = event + self.assertRaises(SystemExit, self.cr._ProcessTasks, conf) + shutdown.assert_called_with(10, 'Some other reason') + self.assertFalse(pop.called) + + + @mock.patch.object(runner.base.actions, 'SetTimer', autospec=True) + @mock.patch.object(runner.files, 'Read', autospec=True) + @mock.patch.object(runner.files, 'Dump', autospec=True) + def testProcessActions(self, dump, reader, set_timer): + reader.return_value = [{'data': {'SetTimer': ['TestTimer']}, + 'path': ['/autobuild']}] + # missing file + reader.side_effect = runner.files.Error + self.assertRaises(runner.ConfigRunnerError, self.cr.Start, + '/tmp/path/missing.yaml') + reader.side_effect = None + # valid command + self.cr.Start('/tmp/path/tasks.yaml') + reader.assert_called_with('/tmp/path/tasks.yaml') + set_timer.assert_called_with(build_info=self.buildinfo, args=['TestTimer']) + self.assertTrue(set_timer.return_value.Run.called) + self.assertTrue(dump.called) + # invalid command + self.assertRaises(runner.ConfigRunnerError, self.cr._ProcessTasks, + [{'data': {'BadSetTimer': ['Timer1']}, + 'path': ['/autobuild']}]) + # action error + set_timer.side_effect = runner.base.actions.ActionError + self.assertRaises(runner.ConfigRunnerError, self.cr._ProcessTasks, + [{'data': {'SetTimer': ['Timer1']}, + 'path': ['/autobuild']}]) + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/constants.py b/lib/constants.py new file mode 100644 index 0000000..bcfe47a --- /dev/null +++ b/lib/constants.py @@ -0,0 +1,61 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Constants and Flags used by the Glazier imaging code.""" + +import gflags as flags + +BUILD_LOG_FILE = 'glazier.log' +REG_ROOT = r'SOFTWARE\Glazier' + +# Network + +DOMAIN = 'domain.example.com' +DOMAIN_DN = 'DC=domain,DC=example,DC=com' +USER_AGENT = 'Glazier Installer 1.0' + +# System +SYS_ROOT = 'C:' +SYS_CACHE = '%s\\glazier_cache' % SYS_ROOT +SYS_LOGS_PATH = '%s\\Windows\\Logs\\Glazier' % SYS_ROOT +SYS_BUILD_LOG = '%s\\%s' % (SYS_LOGS_PATH, BUILD_LOG_FILE) +SYS_SYSTEM32 = '%s\\Windows\\System32' % SYS_ROOT +SYS_TASK_LIST = '%s\\task_list.yaml' % SYS_CACHE +SYS_POWERSHELL = '%s\\WindowsPowerShell\\v1.0\\powershell.exe' % SYS_SYSTEM32 + +# WinPE +WINPE_ROOT = 'X:' +WINPE_CACHE = WINPE_ROOT +WINPE_LOGS_PATH = WINPE_ROOT +WINPE_BUILD_LOG = '%s\\%s' % (WINPE_LOGS_PATH, BUILD_LOG_FILE) +WINPE_SYSTEM32 = '%s\\Windows\\System32' % WINPE_ROOT +WINPE_TASK_LIST = '%s\\task_list.yaml' % WINPE_ROOT +WINPE_DISM = '%s\\dism.exe' % WINPE_SYSTEM32 +WINPE_POWERSHELL = ('%s\\WindowsPowerShell\\v1.0\\powershell.exe' % + WINPE_SYSTEM32) + + +## Flags + +FLAGS = flags.FLAGS +flags.DEFINE_string('binary_root_path', '/bin', 'Path to the binary storage.') +flags.DEFINE_string('config_root_path', '/autobuild', + 'Path to the root of the configuration directory.') +flags.DEFINE_string('config_server', 'https://glazier-server.example.com', + 'Root URL for all build data.') + +flags.DEFINE_enum('environment', 'Host', ['Host', 'WinPE'], + 'The running host environment.') +flags.DEFINE_string('ntp_server', 'time.google.com', + 'Server to use for synchronizing the local system time.') diff --git a/lib/domain_join.py b/lib/domain_join.py new file mode 100644 index 0000000..11b87f9 --- /dev/null +++ b/lib/domain_join.py @@ -0,0 +1,129 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""This joins a Windows machine to an Active Directory domain. + +Methods: + * auto: Auto join the domain with no user interaction. + * interactive: Prompt the user for domain join credentials. + +""" + +import logging +import socket +import time +from glazier.lib import buildinfo +from glazier.lib import constants +from glazier.lib import interact +from glazier.lib import powershell + +AUTH_OPTS = [ + 'auto', + 'interactive', +] + + + +class DomainJoinError(Exception): + pass + + +class DomainJoinCredentials(object): + + def __init__(self): + self._username = None + self._password = None + + def GetUsername(self): + """Override to provide automatic join credentials.""" + return self._username + + def GetPassword(self): + """Override to provide automatic join credentials.""" + return self._password + + + + +class DomainJoin(object): + """Defines several functions used to join a machine to the domain.""" + + def __init__(self, method, domain_name, ou=None): + self._build_info = buildinfo.BuildInfo() + self._domain_name = domain_name + self._domain_ou = ou + self._method = method + self._password = None + self._username = None + + def _AutomaticJoin(self): + """Join the domain with automated credentials.""" + creds = DomainJoinCredentials() + self._username = creds.GetUsername() + self._password = creds.GetPassword() + + logging.info('Starting automated domain join. Hostname: %s', + socket.gethostname()) + + while True: + ps = powershell.PowerShell() + try: + logging.debug('Attempting to join the domain %s.', self._domain_name) + ps.RunLocal( + r'%s\join-domain.ps1' % constants.SYS_CACHE, + args=[self._username, self._password, self._domain_name]) + except powershell.PowerShellError as e: + logging.error( + 'Domain join failed. Sleeping 5 minutes then trying again. (%s)', e) + time.sleep(300) + continue + logging.info('Joined the machine to the domain.') + break + + def _SetUsername(self): + self._username = interact.GetUsername() + + def _InteractiveJoin(self): + """Join the domain with user-interactive dialog.""" + while True: + self._SetUsername() + + ps = powershell.PowerShell() + cmd = [ + 'Add-Computer', '-DomainName', self._domain_name, '-Credential', + self._username, '-PassThru' + ] + if self._domain_ou: + cmd += ['-OUPath', self._domain_ou] + try: + logging.debug('Attempting to join the domain %s.', self._domain_name) + ps.RunCommand(cmd) + except powershell.PowerShellError as e: + logging.error( + 'Domain join failed. Sleeping 5 minutes then trying again. (%s)', e) + continue + + logging.info('Joined the machine to the domain.') + break + + def JoinDomain(self): + """Perform the domain join operation.""" + logging.debug('Beginning domain join process.') + + if self._method.startswith('auto'): + self._AutomaticJoin() + else: + self._InteractiveJoin() + logging.info('Domain join completed.') + diff --git a/lib/download.py b/lib/download.py new file mode 100644 index 0000000..9f0bdec --- /dev/null +++ b/lib/download.py @@ -0,0 +1,367 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Download files over HTTPS. + +> Resource Requirements + + * resources/ca_certs.crt + A certificate file containing permitted root certs for SSL validation. + +""" + +import hashlib +import logging +import os +import re +import socket +import subprocess +import sys +import tempfile +import time +import urllib2 + +CHUNK_BYTE_SIZE = 65536 + + +def Transform(string, build_info): + """Transforms abbreviated file names to absolute file paths. + + Short name support: + #: A reference to the active release branch location. + @: A reference to the binary storage root. + + Args: + string: The configuration string to be transformed. + build_info: the current build information + + Returns: + The adjusted file name string to be used in the manifest. + """ + if '#' in string: + string = string.replace('#', '%s/' % PathCompile(build_info)) + if '@' in string: + string = string.replace('@', str(build_info.BinaryPath())) + return string + + +def PathCompile(build_info, file_name=None, base=None): + """Compile the active path from the base path and the active conf path. + + Attempt to do a reasonable job of joining path components with single + slashes. + + The three main parts considered are the _base_url (or base arg), any + subdirectories from _conf_path, and the optional file name arg. These are + combined into [https://base.url][/conf/path/parts][/filename.ext] + + We attempt to strip trailing slashes, so paths without a filename return + with no trailing /. + + Args: + build_info: the current build information + file_name: append a filename to the path + base: use a non-default base path + + Returns: + The compiled URL as a string. + """ + path = base + if not path: + path = build_info.ReleasePath() + + path = path.rstrip('/') + + sub_path = build_info.ActiveConfigPath() + if sub_path: + path += '/' + sub_path = '/'.join(sub_path).strip('/') + path += sub_path + + if file_name: + path += '/' + file_name = file_name.lstrip('/') + path += file_name + + return path + + +class DownloadError(Exception): + """The transfer of the file failed.""" + pass + + +class BaseDownloader(object): + """Downloads files over HTTPS.""" + + def __init__(self, show_progress=False): + self._debug_info = {} + self._save_location = None + self._default_show_progress = show_progress + + def _ConvertBytes(self, num_bytes): + """Converts number of bytes to a human readable format. + + Args: + num_bytes: The number to convert to a more human readable format (int). + + Returns: + size: The number of bytes in human readable format (string). + """ + num_bytes = float(num_bytes) + if num_bytes >= 1099511627776: + terabytes = num_bytes / 1099511627776 + size = '%.2fTB' % terabytes + elif num_bytes >= 1073741824: + gigabytes = num_bytes / 1073741824 + size = '%.2fGB' % gigabytes + elif num_bytes >= 1048576: + megabytes = num_bytes / 1048576 + size = '%.2fMB' % megabytes + elif num_bytes >= 1024: + kilobytes = num_bytes / 1024 + size = '%.2fKB' % kilobytes + else: + size = '%.2fB' % num_bytes + return size + + def _GetHandlers(self): + return [urllib2.HTTPSHandler()] + + def _DownloadFile(self, url, max_retries=5, show_progress=None): + """Downloads a file from and saves it to the specified location. + + Args: + url: The address of the file to be downloaded. + max_retries: The number of times to attempt to download + a file if the first attempt fails. + show_progress: Print download progress to stdout (overrides default). + + Raises: + DownloadError: The downloaded file did not match the expected file + size. + """ + attempt = 0 + file_stream = None + + opener = urllib2.OpenerDirector() + for handler in self._GetHandlers(): + opener.add_handler(handler) + urllib2.install_opener(opener) + + while True: + try: + attempt += 1 + file_stream = urllib2.urlopen(url) + except urllib2.HTTPError: + logging.error('File not found on remote server: %s.', url) + except urllib2.URLError as e: + logging.error('Error connecting to remote server to download file ' + '"%s". The error was: %s', url, e) + if file_stream: + if file_stream.getcode() in [200]: + break + else: + raise DownloadError('Invalid return code for file %s. [%d]' % + (url, file_stream.getcode())) + + if attempt < max_retries: + logging.info('Sleeping for 20 seconds and then retrying the download.') + time.sleep(20) + else: + raise DownloadError('Permanent download failure for file %s.' % url) + + self._StreamToDisk(file_stream, show_progress) + + def DownloadFile(self, url, save_location, max_retries=5, show_progress=None): + """Downloads a file to temporary storage. + + Args: + url: The address of the file to be downloaded. + save_location: The full path of where the file should be saved. + max_retries: The number of times to attempt to download + a file if the first attempt fails. + show_progress: Print download progress to stdout (overrides default). + """ + self._save_location = save_location + self._DownloadFile(url, max_retries, show_progress) + + def DownloadFileTemp(self, url, max_retries=5, show_progress=None): + """Downloads a file to temporary storage. + + Args: + url: The address of the file to be downloaded. + max_retries: The number of times to attempt to download + a file if the first attempt fails. + show_progress: Print download progress to stdout (overrides default). + + Returns: + A string containing a path to the temporary file. + """ + destination = tempfile.NamedTemporaryFile() + self._save_location = destination.name + destination.close() + self._DownloadFile(url, max_retries, show_progress) + return self._save_location + + def _DownloadChunkReport(self, bytes_so_far, total_size): + """Prints download progress information. + + Args: + bytes_so_far: The number of bytes downloaded so far. + total_size: The total size of the file being downloaded. + """ + percent = float(bytes_so_far) / total_size + percent = round(percent * 100, 2) + message = (('\rDownloaded %s of %s (%0.2f%%)' + (' ' * 10)) % + (self._ConvertBytes(bytes_so_far), + self._ConvertBytes(total_size), percent)) + sys.stdout.write(message) + sys.stdout.flush() + + if bytes_so_far >= total_size: + sys.stdout.write('\n') + + def _StoreDebugInfo(self, file_stream, socket_error=None): + """Gathers debug information for use when file downloads fail. + + Args: + file_stream: The file stream object of the file being downloaded. + socket_error: Store the error raised from the socket class with + other debug info. + + Returns: + debug_info: A dictionary containing various pieces of debugging + information. + """ + if socket_error: + self._debug_info['socket_error'] = socket_error + if file_stream: + for header in file_stream.info().header_items(): + self._debug_info[header[0]] = header[1] + self._debug_info['current_time'] = time.strftime( + '%A, %d %B %Y %H:%M:%S UTC') + + def PrintDebugInfo(self): + """Print the debugging information to the screen.""" + if self._debug_info: + print '\n\n\n\n' + print '---------------' + print 'Debugging info: ' + print '---------------' + for key, value in self._debug_info.items(): + print '%s: %s' % (key, value) + print '\n\n\n' + + def _StreamToDisk(self, file_stream, show_progress=None): + """Save a file stream to disk. + + Args: + file_stream: The file stream returned by a successful urlopen() + show_progress: Print download progress to stdout (overrides default). + + Raises: + DownloadError: Error retrieving file or saving to disk. + """ + progress = self._default_show_progress + if show_progress is not None: + progress = show_progress + bytes_so_far = 0 + url = file_stream.geturl() + total_size = int(file_stream.info().getheader('Content-Length').strip()) + try: + with open(self._save_location, 'wb') as output_file: + logging.info('Downloading file "%s" to "%s".', url, self._save_location) + while 1: + chunk = file_stream.read(CHUNK_BYTE_SIZE) + bytes_so_far += len(chunk) + if not chunk: + break + output_file.write(chunk) + if progress: + self._DownloadChunkReport(bytes_so_far, total_size) + except socket.error as e: + self._StoreDebugInfo(file_stream, str(e)) + raise DownloadError('Socket error during download.') + except IOError: + raise DownloadError('File location could not be opened for writing: %s' % + self._save_location) + self._Validate(file_stream, total_size) + file_stream.close() + + def _Validate(self, file_stream, expected_size): + """Validate the downloaded file. + + Args: + file_stream: The file stream returned by a successful urlopen() + expected_size: The total size of the file being downloaded. + + Raises: + DownloadError: File failed validation. + """ + if not os.path.exists(self._save_location): + self._StoreDebugInfo(file_stream) + raise DownloadError('Could not locate file at %s' % self._save_location) + + actual_file_size = os.path.getsize(self._save_location) + if actual_file_size != expected_size: + self._StoreDebugInfo(file_stream) + message = ('File size of %s bytes did not match expected size of %s!', + actual_file_size, expected_size) + raise DownloadError(message) + + def VerifyShaHash(self, file_path, expected): + """Verifies the SHA256 hash of a file. + + Arguments: + file_path: The path to the file that will be checked. + expected: The expected SHA hash as a string. + + Returns: + True if the calculated hash matches the expected hash. + False if the calculated hash does not match the expected hash or if there + was an error reading the file or the SHA file. + """ + sha_object = hashlib.new('sha256') + + # Read the file in 4MB chunks to avoid running out of memory + # while processing very large files. + try: + with open(file_path, 'rb') as f: + while True: + current_chunk = f.read(4194304) + if not current_chunk: + break + sha_object.update(current_chunk) + except IOError: + logging.error('Unable to read file %s for SHA verification.', file_path) + return False + + file_hash = sha_object.hexdigest() + expected = expected.lower() + + if file_hash == expected: + logging.info('SHA256 hash for %s matched expected hash of %s.', file_path, + expected) + return True + else: + logging.error( + 'SHA256 hash for %s was %s, which did not match expected hash of %s.', + file_path, file_hash, expected) + return False + + +# Set our downloader of choice +Download = BaseDownloader + diff --git a/lib/download_test.py b/lib/download_test.py new file mode 100644 index 0000000..df451f5 --- /dev/null +++ b/lib/download_test.py @@ -0,0 +1,226 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.download.""" + +import StringIO +from fakefs import fake_filesystem +from glazier.lib import buildinfo +from glazier.lib import download +import mock +import unittest + +_TEST_INI = """ +[BUILD] +release=1.0 +branch=stable +""" + + +class PathsTest(unittest.TestCase): + + def setUp(self): + self.buildinfo = buildinfo.BuildInfo() + + @mock.patch.object(buildinfo.BuildInfo, 'ReleasePath', autospec=True) + @mock.patch.object(buildinfo.BuildInfo, 'BinaryPath', autospec=True) + def testTransform(self, binpath, relpath): + relpath.return_value = 'https://glazier' + binpath.return_value = 'https://glazier/bin/' + result = download.Transform('stuff#blah', self.buildinfo) + self.assertEqual(result, 'stuffhttps://glazier/blah') + result = download.Transform('stuff@blah', self.buildinfo) + self.assertEqual(result, 'stuffhttps://glazier/bin/blah') + result = download.Transform('nothing _ here', self.buildinfo) + self.assertEqual(result, 'nothing _ here') + + def testPathCompile(self): + result = download.PathCompile( + self.buildinfo, file_name='file.txt', base='/tmp/base') + self.assertEqual(result, '/tmp/base/file.txt') + self.buildinfo._active_conf_path = ['sub', 'dir'] + result = download.PathCompile( + self.buildinfo, file_name='/file.txt', base='/tmp/base') + self.assertEqual(result, '/tmp/base/sub/dir/file.txt') + result = download.PathCompile( + self.buildinfo, file_name='file.txt', base='/tmp/base') + self.assertEqual(result, '/tmp/base/sub/dir/file.txt') + self.buildinfo._active_conf_path = ['sub', 'dir/other', 'another/'] + result = download.PathCompile( + self.buildinfo, file_name='/file.txt', base='/tmp/') + self.assertEqual(result, '/tmp/sub/dir/other/another/file.txt') + + +class DownloadTest(unittest.TestCase): + + def setUp(self): + self._dl = download.BaseDownloader() + # filesystem + self.filesystem = fake_filesystem.FakeFilesystem() + self.filesystem.CreateFile(r'C:\input.ini', contents=_TEST_INI) + download.os = fake_filesystem.FakeOsModule(self.filesystem) + download.open = fake_filesystem.FakeFileOpen(self.filesystem) + + def testConvertBytes(self): + self.assertEqual(self._dl._ConvertBytes(123), '123.00B') + self.assertEqual(self._dl._ConvertBytes(23455), '22.91KB') + self.assertEqual(self._dl._ConvertBytes(3455555), '3.30MB') + self.assertEqual(self._dl._ConvertBytes(456555555), '435.41MB') + self.assertEqual(self._dl._ConvertBytes(56755555555), '52.86GB') + self.assertEqual(self._dl._ConvertBytes(6785555555555), '6.17TB') + + @mock.patch.object(download.urllib2, 'urlopen', autospec=True) + @mock.patch.object(download.BaseDownloader, '_StreamToDisk', autospec=True) + @mock.patch.object(download.time, 'sleep', autospec=True) + @mock.patch.object(download.urllib2, 'HTTPSHandler', autospec=True) + def testDownloadFileInternal(self, cert_handler, sleep, stream, urlopen): + file_stream = mock.Mock() + file_stream.getcode.return_value = 200 + httperr = download.urllib2.HTTPError('Error', None, None, None, None) + urlerr = download.urllib2.URLError('Error') + # 200 + urlopen.side_effect = iter([httperr, urlerr, file_stream]) + self._dl._DownloadFile('https://www.example.com/build.yaml', max_retries=4) + stream.assert_called_with(self._dl, file_stream, None) + self.assertTrue(cert_handler.called) + # 404 + file_stream.getcode.return_value = 404 + urlopen.side_effect = iter([httperr, file_stream]) + self.assertRaises(download.DownloadError, self._dl._DownloadFile, + 'https://www.example.com/build.yaml') + # retries + file_stream.getcode.return_value = 200 + urlopen.side_effect = iter([httperr, httperr, file_stream]) + self.assertRaises( + download.DownloadError, + self._dl._DownloadFile, + 'https://www.example.com/build.yaml', + max_retries=2) + sleep.assert_has_calls([mock.call(20), mock.call(20)]) + + @mock.patch.object(download.BaseDownloader, '_DownloadFile', autospec=True) + def testDownloadFile(self, downf): + url = 'https://www.example.com/build.yaml' + path = r'C:\Cache\build.yaml' + self._dl.DownloadFile(url, path, max_retries=5) + downf.assert_called_with(self._dl, url, 5, None) + self.assertEqual(self._dl._save_location, path) + self._dl.DownloadFile(url, path, max_retries=5, show_progress=True) + downf.assert_called_with(self._dl, url, 5, True) + self._dl.DownloadFile(url, path, max_retries=5, show_progress=False) + downf.assert_called_with(self._dl, url, 5, False) + + @mock.patch.object(download.BaseDownloader, '_DownloadFile', autospec=True) + @mock.patch.object(download.tempfile, 'NamedTemporaryFile', autospec=True) + def testDownloadFileTemp(self, tempf, downf): + url = 'https://www.example.com/build.yaml' + path = r'C:\Windows\Temp\tmpblahblah' + tempf.return_value.name = path + self._dl.DownloadFileTemp(url, max_retries=5) + downf.assert_called_with(self._dl, url, 5, None) + self.assertEqual(self._dl._save_location, path) + self._dl.DownloadFileTemp(url, max_retries=5, show_progress=True) + downf.assert_called_with(self._dl, url, 5, True) + self._dl.DownloadFileTemp(url, max_retries=5, show_progress=False) + downf.assert_called_with(self._dl, url, 5, False) + + @mock.patch.object(download.BaseDownloader, '_StoreDebugInfo', autospec=True) + def testStreamToDisk(self, store_info): + # setup + http_stream = StringIO.StringIO() + http_stream.write('First line.\nSecond line.\n') + http_stream.seek(0) + download.CHUNK_BYTE_SIZE = 5 + file_stream = mock.Mock() + file_stream.getcode.return_value = 200 + file_stream.geturl.return_value = 'https://www.example.com/build.yaml' + file_stream.info.return_value.getheader.return_value = '25' + file_stream.read = http_stream.read + # success + self._dl._save_location = r'C:\download.txt' + self._dl._StreamToDisk(file_stream) + # Progress + with mock.patch.object( + self._dl, '_DownloadChunkReport', autospec=True) as report: + # default false + self._dl._default_show_progress = False + http_stream.seek(0) + self._dl._StreamToDisk(file_stream) + self.assertFalse(report.called) + # override true + http_stream.seek(0) + report.reset_mock() + self._dl._StreamToDisk(file_stream, show_progress=True) + self.assertTrue(report.called) + # default true + self._dl._default_show_progress = True + http_stream.seek(0) + report.reset_mock() + self._dl._StreamToDisk(file_stream) + self.assertTrue(report.called) + # override false + http_stream.seek(0) + report.reset_mock() + self._dl._StreamToDisk(file_stream, show_progress=False) + self.assertFalse(report.called) + # IOError + http_stream.seek(0) + self.filesystem.CreateDirectory(r'C:\Windows') + self._dl._save_location = r'C:\Windows' + self.assertRaises(download.DownloadError, self._dl._StreamToDisk, + file_stream) + # File Size + http_stream.seek(0) + file_stream.info.return_value.getheader.return_value = '100000' + self._dl._save_location = r'C:\download.txt' + self.assertRaises(download.DownloadError, self._dl._StreamToDisk, + file_stream) + # Socket Error + http_stream.seek(0) + file_stream.info.return_value.getheader.return_value = '25' + file_stream.read = mock.Mock(side_effect=download.socket.error('SocketErr')) + self.assertRaises(download.DownloadError, self._dl._StreamToDisk, + file_stream) + store_info.assert_called_with(self._dl, file_stream, 'SocketErr') + + @mock.patch.object(download.BaseDownloader, '_StoreDebugInfo', autospec=True) + def testValidate(self, store_info): + file_stream = mock.Mock() + self._dl._save_location = r'C:\missing.txt' + self.assertRaises(download.DownloadError, self._dl._Validate, file_stream, + 200) + store_info.assert_called_with(self._dl, file_stream) + + def testVerifyShaHash(self): + test_sha256 = ( + '58157BF41CE54731C0577F801035D47EC20ED16A954F10C29359B8ADEDCAE800') + # sha256 + result = self._dl.VerifyShaHash(r'C:\input.ini', test_sha256) + self.assertTrue(result) + # missing source + result = self._dl.VerifyShaHash(r'C:\missing.ini', test_sha256) + self.assertFalse(result) + # missing hash + result = self._dl.VerifyShaHash(r'C:\input.ini', '') + self.assertFalse(result) + # mismatch hash + test_sha256 = ( + '58157bf41ce54731c0577f801035d47ec20ed16a954f10c29359b8adedcae801') + result = self._dl.VerifyShaHash(r'C:\input.ini', test_sha256) + self.assertFalse(result) + + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/drive_map.py b/lib/drive_map.py new file mode 100644 index 0000000..4af2db8 --- /dev/null +++ b/lib/drive_map.py @@ -0,0 +1,98 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Map network shares to drives on the local machine.""" + +import logging +import time + + +class ModuleImportError(Exception): + """Error loading required python modules.""" + + +class DriveMap(object): + """Map and Unmap network shares.""" + + def __init__(self): + self._ModuleInit() + + def MapDrive(self, drive_letter, server_path, username=None, password=None): + """Maps a Samba or WebDAV path to a drive letter in Windows. + + Args: + drive_letter: The drive letter to map the Samba path to. + server_path: The path to map to. + username: The username to use in mapping the drive. + password: The password to use in mapping the drive. + + Returns: + False if drive map fails, True if drive map succeeds. + """ + wait = 1 + limit = 65 + while wait < limit: + try: + self._win32wnet.WNetAddConnection2(self._win32netcon.RESOURCETYPE_DISK, + drive_letter, server_path, None, + username, password, 0) + break + except self._win32wnet.error: + logging.error('Failed to map path %s to network drive %s.', server_path, + drive_letter) + logging.error('Waiting for %s seconds.', str(wait)) + time.sleep(wait) + wait *= 2 + + if wait > limit: + logging.error('Unable to map path, aborting.') + return False + return True + + def UnmapDrive(self, drive): + """function to verify network drive connection. + + Checks if drive is connected. Writes to temporary log if not connected. + + Args: + drive: mapped network drive. + + Returns: + False if no network drive connected. Returns True if drive unmaps. + """ + try: + self._win32wnet.WNetCancelConnection2(drive, 1, True) + except self._win32wnet.error: + logging.error('The network drive does not exist.') + return False + return True + + def _ModuleInit(self): + """Initialize win32 platform modules. + + Raises: + ModuleImportError: failure to import a required module + """ + try: + import win32wnet # pylint: disable=g-import-not-at-top + self._win32wnet = win32wnet + except ImportError: + raise ModuleImportError('No win32wnet module available on this platform.') + + try: + import win32netcon # pylint: disable=g-import-not-at-top + self._win32netcon = win32netcon + except ImportError: + raise ModuleImportError( + 'No win32netcon module available on this platform.') diff --git a/lib/file_util.py b/lib/file_util.py new file mode 100644 index 0000000..1433ec6 --- /dev/null +++ b/lib/file_util.py @@ -0,0 +1,41 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Utility functions for working with files and directories.""" + +import logging +import os +import shutil + + +class Error(Exception): + pass + + +def CreateDirectories(path): + """Create directory if the path to a file doesn't exist. + + Args: + path: The full file path to where a file will be placed. + + Raises: + Error: Failure creating the requested directory. + """ + dirname = os.path.dirname(path) + if not os.path.isdir(dirname): + logging.debug('Creating directory %s ', dirname) + try: + os.makedirs(dirname) + except (shutil.Error, OSError): + raise Error('Unable to make directory: %s' % dirname) diff --git a/lib/file_util_test.py b/lib/file_util_test.py new file mode 100644 index 0000000..8d8ae69 --- /dev/null +++ b/lib/file_util_test.py @@ -0,0 +1,34 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.file_util.""" + +from fakefs import fake_filesystem +from glazier.lib import file_util +import unittest + + +class FileUtilTest(unittest.TestCase): + + def setUp(self): + self.filesystem = fake_filesystem.FakeFilesystem() + file_util.os = fake_filesystem.FakeOsModule(self.filesystem) + file_util.open = fake_filesystem.FakeFileOpen(self.filesystem) + + def testCreateDirectories(self): + self.filesystem.CreateFile('/test') + self.assertRaises(file_util.Error, file_util.CreateDirectories, + '/test/file.txt') + file_util.CreateDirectories('/tmp/test/path/file.log') + self.assertTrue(self.filesystem.Exists('/tmp/test/path')) diff --git a/lib/interact.py b/lib/interact.py new file mode 100644 index 0000000..8c21517 --- /dev/null +++ b/lib/interact.py @@ -0,0 +1,78 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Glazier user interaction.""" + +import logging +import re +import time + + +def GetUsername(): + """Prompt the user for their username. + + Returns: + The username string entered by the user. + """ + username = False + while not username: + username = Prompt('Please enter your username: ', + validator='^[a-zA-Z0-9]+$') + return username + + +def Keystroke(message, validator='.*', timeout=30): + """Prompts the user for a keystroke and waits the specified amount of time. + + Args: + message: the prompt message displayed to the user + validator: a regular expression to validate any responses + timeout: the length of time in seconds to wait for a response + + Returns: + String of the character input from the user that matched input_regex. + """ + import msvcrt # pylint: disable=g-import-not-at-top + print message + i = 0 + kbhit = False + while i < timeout and not kbhit: + kbhit = msvcrt.kbhit() + i += 1 + time.sleep(1) + if kbhit: + response = msvcrt.getch() + result = re.match(validator, response) + if result: + logging.debug('Matched user input, %s, as a valid input.', response) + return response + logging.debug('No input from user prior to timeout.') + return None + + +def Prompt(message, validator='.*'): + """Prompt the user for input. + + Args: + message: the prompt message displayed to the user + validator: a regular expression to validate any responses + + Returns: + a response string if successful, else None + """ + response = raw_input(message) + if not re.match(validator, response): + logging.error('Invalid response entered.') + return None + return response diff --git a/lib/interact_test.py b/lib/interact_test.py new file mode 100644 index 0000000..cb4672a --- /dev/null +++ b/lib/interact_test.py @@ -0,0 +1,59 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.interact.""" + +import sys +from glazier.lib import interact +import mock +import unittest + + +class InteractTest(unittest.TestCase): + + @mock.patch('__builtin__.raw_input', autospec=True) + def testGetUsername(self, raw): + raw.side_effect = iter(['invalid-name', '', ' ', 'username1']) + self.assertEqual(interact.GetUsername(), 'username1') + + @mock.patch.object(interact.time, 'sleep', autospec=True) + def testKeystroke(self, sleep): + msvcrt = mock.Mock() + msvcrt.kbhit.return_value = False + sys.modules['msvcrt'] = msvcrt + # no reply + result = interact.Keystroke('mesg', timeout=1) + self.assertEqual(result, None) + self.assertEqual(sleep.call_count, 1) + # reply + msvcrt.kbhit.side_effect = iter([False, False, False, False, True]) + msvcrt.getch.return_value = 'v' + result = interact.Keystroke('mesg', timeout=100) + self.assertEqual(result, 'v') + self.assertEqual(sleep.call_count, 6) + # validation miss + msvcrt.kbhit.side_effect = iter([True]) + result = interact.Keystroke('mesg', validator='[0-9]') + self.assertEqual(result, None) + + @mock.patch('__builtin__.raw_input', autospec=True) + def testPrompt(self, raw): + raw.return_value = 'user*name' + result = interact.Prompt('mesg', '^\\w+$') + self.assertEqual(None, result) + result = interact.Prompt('mesg') + self.assertEqual('user*name', result) + +if __name__ == '__main__': + unittest.main() diff --git a/lib/log_copy.py b/lib/log_copy.py new file mode 100644 index 0000000..41ea0c2 --- /dev/null +++ b/lib/log_copy.py @@ -0,0 +1,118 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""This copies the build log around places.""" + +import datetime +import logging +import logging.handlers +import shutil +from glazier.lib import constants +from glazier.lib import drive_map +from glazier.lib import logs +from gwinpy.registry import registry + + +class LogCopyError(Exception): + pass + + +class LogCopyCredentials(object): + + def __init__(self): + self._username = None + self._password = None + + def GetUsername(self): + """Override to provide share credentials.""" + return self._username + + def GetPassword(self): + """Override to provide share credentials.""" + return self._password + + + +class LogCopy(object): + """Copies text log files around.""" + + def __init__(self): + self._logging = logging.Logger('log_copy') + path = '%s\\log_copy.log' % logs.GetLogsPath() + self._logging.addHandler(logging.FileHandler(path)) + + def _EventLogUpload(self, source_log): + """Upload the log file contents to the local EventLog.""" + event_handler = logging.handlers.NTEventLogHandler('GlazierBuildLog') + logger = logging.Logger('eventlogger') + logger.addHandler(event_handler) + logger.setLevel(logging.INFO) + + try: + with open(source_log, 'r') as f: + content = f.readlines() + for line in content: + logger.info(line) + except IOError: + raise LogCopyError( + 'Unable to open log file. It will not be imported into ' + 'the Windows Event Log.') + + def _GetLogFileName(self): + """Creates the destination file name for a text log file. + + Returns: + The full text file log name (string). + """ + reg = registry.Registry(root_key='HKLM') + hostname = reg.GetKeyValue(constants.REG_ROOT, 'name') + destination_file_date = datetime.datetime.utcnow().replace(microsecond=0) + destination_file_date = destination_file_date.isoformat() + destination_file_date = destination_file_date.replace(':', '') + return 'l:\\' + hostname + '-' + destination_file_date + '.log' + + def _ShareUpload(self, source_log, share): + """Copy the log file to a network file share. + + Args: + source_log: Path to the source log file to be copied. + share: The destination share to copy the file to. + + Raises: + LogCopyError: Failure to mount share and copy log. + """ + + creds = LogCopyCredentials() + username = creds.GetUsername() + password = creds.GetPassword() + + mapper = drive_map.DriveMap() + result = mapper.MapDrive('l:', share, username, password) + if result: + destination = self._GetLogFileName() + try: + shutil.copy(source_log, destination) + except shutil.Error: + raise LogCopyError('Log copy failed.') + mapper.UnmapDrive('l:') + else: + raise LogCopyError('Drive mapping failed.') + + def EventLogCopy(self, source_log): + """Copy a log flie to EventLog.""" + self._EventLogUpload(source_log) + + def ShareCopy(self, source_log, share): + """Copy a log file via CIFS.""" + self._ShareUpload(source_log, share) diff --git a/lib/log_copy_test.py b/lib/log_copy_test.py new file mode 100644 index 0000000..c5de8e9 --- /dev/null +++ b/lib/log_copy_test.py @@ -0,0 +1,95 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.log_copy.""" + +import datetime +import shutil +import sys +from glazier.lib import log_copy +import mock +import unittest + + +class LogCopyTest(unittest.TestCase): + + @mock.patch.object(log_copy.logging, 'FileHandler', autospec=True) + def setUp(self, unused_handler): + self.log_file = r'C:\Windows\Logs\Glazier\glazier.log' + self.lc = log_copy.LogCopy() + # win32 modules + self.win32netcon = mock.Mock() + sys.modules['win32netcon'] = self.win32netcon + self.win32wnet = mock.Mock() + sys.modules['win32wnet'] = self.win32wnet + self._MockWinreg() + + def _MockWinreg(self): + winreg = mock.Mock() + winreg.KEY_READ = 1 + winreg.KEY_WRITE = 2 + self.winreg = winreg + sys.modules['_winreg'] = self.winreg + + def testGetLogFileName(self): + now = datetime.datetime.utcnow() + out_date = now.replace(microsecond=0).isoformat().replace(':', '') + self.winreg.QueryValueEx.return_value = ['WORKSTATION1-W'] + with mock.patch.object( + log_copy.datetime, 'datetime', autospec=True) as mock_dt: + mock_dt.utcnow.return_value = now + result = self.lc._GetLogFileName() + self.assertEqual(result, r'l:\WORKSTATION1-W-' + out_date + '.log') + + @mock.patch.object(log_copy.LogCopy, '_EventLogUpload', autospec=True) + def testEventLogCopy(self, event_up): + self.lc.EventLogCopy(self.log_file) + event_up.assert_called_with(self.lc, self.log_file) + + @mock.patch.object(log_copy.LogCopy, '_GetLogFileName', autospec=True) + @mock.patch.object(log_copy.shutil, 'copy', autospec=True) + @mock.patch.object(log_copy.drive_map.DriveMap, 'UnmapDrive', autospec=True) + @mock.patch.object(log_copy.drive_map.DriveMap, 'MapDrive', autospec=True) + def testShareUpload(self, map_drive, unmap_drive, copy, get_file_name): + + class TestCredProvider(log_copy.LogCopyCredentials): + + def GetUsername(self): + return 'test_user' + + def GetPassword(self): + return 'test_pass' + + log_copy.LogCopyCredentials = TestCredProvider + + log_host = 'log-host.example.com' + get_file_name.return_value = 'log.txt' + self.lc.ShareCopy(self.log_file, log_host) + map_drive.assert_called_with(mock.ANY, 'l:', 'log-host.example.com', + 'test_user', 'test_pass') + copy.assert_called_with(self.log_file, 'log.txt') + unmap_drive.assert_called_with(mock.ANY, 'l:') + # map error + map_drive.return_value = None + self.assertRaises(log_copy.LogCopyError, self.lc.ShareCopy, self.log_file, + log_host) + # copy error + map_drive.return_value = True + copy.side_effect = shutil.Error() + self.assertRaises(log_copy.LogCopyError, self.lc.ShareCopy, self.log_file, + log_host) + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/logs.py b/lib/logs.py new file mode 100644 index 0000000..f76527f --- /dev/null +++ b/lib/logs.py @@ -0,0 +1,58 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Set up logging for all imaging tools.""" + +import logging +import logging.handlers +from glazier.lib import constants + + +class LogError(Exception): + pass + + +def GetLogsPath(): + path = constants.SYS_LOGS_PATH + if constants.FLAGS.environment == 'WinPE': + path = constants.WINPE_LOGS_PATH + return path + + +def Setup(): + """Sets up the logging environment.""" + log_file = '%s\\%s' % (GetLogsPath(), constants.BUILD_LOG_FILE) + + logger = logging.getLogger() + logger.setLevel(logging.DEBUG) + + # file + try: + fh = logging.FileHandler(log_file) + except IOError: + raise LogError('Failed to open log file %s.', log_file) + + formatter = logging.Formatter( + '%(asctime)s.%(msecs)03d\t%(filename)s:%(lineno)d] %(message)s', + datefmt='%Y-%m-%d %H:%M:%S') + fh.setFormatter(formatter) + fh.setLevel(logging.DEBUG) + + # console + ch = logging.StreamHandler() + ch.setLevel(logging.INFO) + + # add the handlers to the logger + logger.addHandler(fh) + logger.addHandler(ch) diff --git a/lib/logs_test.py b/lib/logs_test.py new file mode 100644 index 0000000..e6551cc --- /dev/null +++ b/lib/logs_test.py @@ -0,0 +1,37 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.logs.""" + +from glazier.lib import logs +import mock +import unittest + + +class LoggingTest(unittest.TestCase): + + @mock.patch.object(logs.logging, 'FileHandler') + def testSetup(self, fh): + logs.constants.FLAGS.environment = 'Host' + logs.Setup() + fh.assert_called_with('%s\\glazier.log' % logs.constants.SYS_LOGS_PATH) + logs.constants.FLAGS.environment = 'WinPE' + logs.Setup() + fh.assert_called_with('X:\\glazier.log') + fh.side_effect = IOError + self.assertRaises(logs.LogError, logs.Setup) + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/ntp.py b/lib/ntp.py new file mode 100644 index 0000000..6cbc988 --- /dev/null +++ b/lib/ntp.py @@ -0,0 +1,67 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Glazier interface to NTP time service.""" + +import logging +import socket +import subprocess +import time +from glazier.lib.constants import WINPE_SYSTEM32 +import ntplib + +RETRY_DELAY = 30 + + +class NtpException(Exception): + pass + + +def SyncClockToNtp(retries=2, server='time.google.com'): + """Syncs the hardware clock to an NTP server.""" + logging.info('Reading time from NTP server %s.', server) + + attempts = 0 + client = ntplib.NTPClient() + response = None + + while True: + try: + response = client.request(server, version=3) + except (ntplib.NTPException, socket.gaierror) as e: + logging.error('NTP client request error: %s', str(e)) + if response or attempts >= retries: + break + logging.info( + 'Unable to contact NTP server %s to sync machine clock. This ' + 'machine may not have an IP address yet; waiting %d seconds and ' + 'trying again. Repeated failure may indicate network or driver ' + 'problems.', server, RETRY_DELAY) + time.sleep(RETRY_DELAY) + attempts += 1 + + if not response: + raise NtpException('No response from NTP server.') + + local_time = time.localtime(response.ref_time) + current_date = time.strftime('%m-%d-%Y', local_time) + current_time = time.strftime('%H:%M:%S', local_time) + logging.info('Current date/time is %s %s', current_date, current_time) + + date_set = r'%s\cmd.exe /c date %s' % (WINPE_SYSTEM32, current_date) + result = subprocess.call(date_set, shell=True) + logging.info('Setting date returned result %s', result) + time_set = r'%s\cmd.exe /c time %s' % (WINPE_SYSTEM32, current_time) + result = subprocess.call(time_set, shell=True) + logging.info('Setting time returned result %s', result) diff --git a/lib/ntp_test.py b/lib/ntp_test.py new file mode 100644 index 0000000..66dbddc --- /dev/null +++ b/lib/ntp_test.py @@ -0,0 +1,53 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.ntp.""" + +from glazier.lib import ntp +import mock +import unittest + + +class NtpTest(unittest.TestCase): + + @mock.patch.object(ntp.time, 'sleep', autospec=True) + @mock.patch.object(ntp.subprocess, 'call', autospec=True) + @mock.patch.object(ntp.ntplib.NTPClient, 'request', autospec=True) + def testSyncClockToNtp(self, request, subproc, sleep): + return_time = mock.Mock() + return_time.ref_time = 1453220630.64458 + request.side_effect = iter([None, None, None, return_time]) + subproc.return_value = True + # Too Few Retries + self.assertRaises(ntp.NtpException, ntp.SyncClockToNtp) + sleep.assert_has_calls([mock.call(30), mock.call(30)]) + # Sufficient Retries + ntp.SyncClockToNtp(retries=3, server='time.google.com') + request.assert_called_with(mock.ANY, 'time.google.com', version=3) + subproc.assert_has_calls([ + mock.call( + r'X:\Windows\System32\cmd.exe /c date 01-19-2016', shell=True), + mock.call( + r'X:\Windows\System32\cmd.exe /c time 08:23:50', shell=True) + ]) + # Socket Error + request.side_effect = ntp.socket.gaierror + self.assertRaises(ntp.NtpException, ntp.SyncClockToNtp) + # NTP lib error + request.side_effect = ntp.ntplib.NTPException + self.assertRaises(ntp.NtpException, ntp.SyncClockToNtp) + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/policies/README.md b/lib/policies/README.md new file mode 100644 index 0000000..e3cfa39 --- /dev/null +++ b/lib/policies/README.md @@ -0,0 +1,31 @@ +# Glazier Installer Policies + +[TOC] + +Policy modules determine whether or not Autobuild should be allowed to proceed +with an installation. + +## Usage + +Each module should inherit from BasePolicy, and will receive a BuildInfo +instance (self.\_build_info). + +If a policy fails, the module should raise ImagingPolicyException with a message +explaining the cause of failure. This will abort the build. + +If a policy causes a warning, the module should raise ImagingPolicyWarning. This +will not abort the build, but may present the warning to the user. + +## Modules + + +### DeviceModel + +DeviceModel checks whether the local device is a supported hardware model. If +the device is not fully supported (outside tier1), the user is prompted whether +or not to abort the build. + +### DiskEncryption + +DiskEncryption checks whether encryption is required of the host, and if so, +whether the host is capable of encryption (TPM is present). diff --git a/lib/policies/__init__.py b/lib/policies/__init__.py new file mode 100644 index 0000000..e9c0381 --- /dev/null +++ b/lib/policies/__init__.py @@ -0,0 +1,28 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Simplify access to Glazier policy modules.""" + +from glazier.lib.policies import base +from glazier.lib.policies import device_model +from glazier.lib.policies import disk_encryption + +# pylint: disable=invalid-name +BannedPlatform = device_model.BannedPlatform +DeviceModel = device_model.DeviceModel +DiskEncryption = disk_encryption.DiskEncryption + +ImagingPolicyException = base.ImagingPolicyException +ImagingPolicyWarning = base.ImagingPolicyWarning + diff --git a/lib/policies/base.py b/lib/policies/base.py new file mode 100644 index 0000000..dcbe703 --- /dev/null +++ b/lib/policies/base.py @@ -0,0 +1,35 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Generic imaging policy class.""" + + +class ImagingPolicyException(Exception): + """Policy verification failed with a fatal condition.""" + pass + + +class ImagingPolicyWarning(Exception): + """Policy verification failed with a non-fatal condition.""" + pass + + +class BasePolicy(object): + + def __init__(self, build_info): + self._build_info = build_info + + def Verify(self): + """Override this function to implement a new policy.""" + pass diff --git a/lib/policies/base_test.py b/lib/policies/base_test.py new file mode 100644 index 0000000..052422b --- /dev/null +++ b/lib/policies/base_test.py @@ -0,0 +1,29 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.policies.base.""" + +from glazier.lib.policies import base +import unittest + + +class BaseTest(unittest.TestCase): + + def testVerify(self): + b = base.BasePolicy(None) + b.Verify() + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/policies/device_model.py b/lib/policies/device_model.py new file mode 100644 index 0000000..7f1c5ab --- /dev/null +++ b/lib/policies/device_model.py @@ -0,0 +1,92 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Ensure the device hardware is supported.""" + +from __future__ import print_function +import logging +import re +from glazier.lib.policies.base import BasePolicy +from glazier.lib.policies.base import ImagingPolicyException + +_PARTIAL_NOTICE = (""" + !!!!! Notice !!!!! + + The installer considers this hardware model obsolete or experimental (%s). + + The hardware you are using is not part of active inventory. + While the installer may support this device, it is not being tested for + compatibility. There is a chance you may experience problems imaging. + We recommend considering a hardware refresh before continuing. + """) + +_UNSUPPORTED_NOTICE = (""" + !!!!! Warning !!!!! + + The installer does not recognize this hardware model (%s). + + If you chose to continue, this will be an unsupported build. The + final install MAY BE BROKEN. You should only continue if you are + sure you know what you're doing. When in doubt, contact support + for assistance. + """) + + + +class DeviceModel(BasePolicy): + """Verify that the device hardware is supported.""" + + def _ModelSupportPrompt(self, message, this_model): + """Prompts the user whether to halt an unsupported build. + + Args: + message: A message to be displayed to the user. + this_model: The hardware model that failed validation. + + Returns: + true if the user wishes to proceed anyway, else false. + """ + warning = message % this_model + print(warning) + answer = raw_input('Do you still want to proceed (y/n)? ') + answer_re = r'^[Yy](es)?$' + if re.match(answer_re, answer): + return True + return False + + def Verify(self): + model = self._build_info.ComputerModel() + logging.debug('Verifying hardware support tier for %s.', model) + tier = self._build_info.SupportTier() + if tier == 1: + return True + + build_anyway = False + if tier == 2: + build_anyway = self._ModelSupportPrompt(_PARTIAL_NOTICE, model) + else: + build_anyway = self._ModelSupportPrompt(_UNSUPPORTED_NOTICE, model) + + if not build_anyway: + raise ImagingPolicyException( + 'User chose not to continue with current model.') + logging.info('User chose to continue with partial or unsupported build.') + return build_anyway + + +class BannedPlatform(BasePolicy): + + def Verify(self): + raise ImagingPolicyException( + 'Windows cannot be installed on this platform.') diff --git a/lib/policies/device_model_test.py b/lib/policies/device_model_test.py new file mode 100644 index 0000000..44a100c --- /dev/null +++ b/lib/policies/device_model_test.py @@ -0,0 +1,61 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.policies.device_model.""" + +from glazier.lib.policies import device_model +import mock +import unittest + + +class DeviceModelTest(unittest.TestCase): + + @mock.patch('__builtin__.raw_input', autospec=True) + @mock.patch('__builtin__.print', autospec=True) + @mock.patch('glazier.lib.buildinfo.BuildInfo', + autospec=True) + def testVerify(self, build_info, user_out, user_in): + dm = device_model.DeviceModel(build_info) + + # Tier1 + dm._build_info.SupportTier.return_value = 1 + self.assertTrue(dm.Verify()) + + # Tier 2 + user_in.return_value = 'yes' + dm._build_info.ComputerModel.return_value = 'Test Workstation' + dm._build_info.SupportTier.return_value = 2 + self.assertTrue(dm.Verify()) + user_out.assert_called_with(device_model._PARTIAL_NOTICE % + 'Test Workstation') + # Unsupported: Continue + user_in.return_value = 'Y' + dm._build_info.SupportTier.return_value = 0 + self.assertTrue(dm.Verify()) + user_out.assert_called_with(device_model._UNSUPPORTED_NOTICE % + 'Test Workstation') + # Unsupported: Abort + user_in.return_value = 'n' + self.assertRaises(device_model.ImagingPolicyException, dm.Verify) + + @mock.patch('glazier.lib.buildinfo.BuildInfo', + autospec=True) + def testBannedPlatform(self, build_info): + bp = device_model.BannedPlatform(build_info) + self.assertRaises(device_model.ImagingPolicyException, + bp.Verify) + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/policies/disk_encryption.py b/lib/policies/disk_encryption.py new file mode 100644 index 0000000..05ee40c --- /dev/null +++ b/lib/policies/disk_encryption.py @@ -0,0 +1,27 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Ensure the machine supports disk encryption if required.""" + +from glazier.lib.policies.base import BasePolicy +from glazier.lib.policies.base import ImagingPolicyException + + +class DiskEncryption(BasePolicy): + + def Verify(self): + level = self._build_info.EncryptionLevel() + if level == 'tpm' and not self._build_info.TpmPresent(): + raise ImagingPolicyException( + 'This machine requires a TPM for encryption but no TPM was found.') diff --git a/lib/policies/disk_encryption_test.py b/lib/policies/disk_encryption_test.py new file mode 100644 index 0000000..3404e25 --- /dev/null +++ b/lib/policies/disk_encryption_test.py @@ -0,0 +1,40 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.policies.disk_encryption.""" + +from glazier.lib.policies import disk_encryption +import mock +import unittest + + +class DiskEncryptionTest(unittest.TestCase): + + @mock.patch( + 'glazier.lib.buildinfo.BuildInfo', autospec=True) + def testVerify(self, build_info): + de = disk_encryption.DiskEncryption(build_info) + de._build_info.EncryptionLevel.return_value = 'none' + de._build_info.TpmPresent.return_value = True + de.Verify() + de._build_info.EncryptionLevel.return_value = 'startupkey' + de.Verify() + de._build_info.EncryptionLevel.return_value = 'tpm' + de.Verify() + de._build_info.TpmPresent.return_value = False + self.assertRaises(disk_encryption.ImagingPolicyException, de.Verify) + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/power.py b/lib/power.py new file mode 100644 index 0000000..0d219c4 --- /dev/null +++ b/lib/power.py @@ -0,0 +1,49 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Turn things on and off.""" + +import subprocess +from glazier.lib import constants + + +def _System32(): + if constants.FLAGS.environment == 'WinPE': + return constants.WINPE_SYSTEM32 + else: + return constants.SYS_SYSTEM32 + + +def Shutdown(timeout, reason): + """Shuts down a Windows machine, given a timeout period and a reason. + + Args: + timeout: How long to wait before shutting down the machine. + reason: Reason why the machine is being shut down. This will be displayed + to the user and written to the Windows event log. + """ + subprocess.call(r'%s\shutdown.exe -s -t %s -c "%s" -f' + % (_System32(), timeout, reason)) + + +def Restart(timeout, reason): + """Restarts a Windows machine, given a timeout period and a reason. + + Args: + timeout: How long to wait before restarting the machine. + reason: Reason why the machine is being restarted. This will be displayed + to the user and written to the Windows event log. + """ + subprocess.call(r'%s\shutdown.exe -r -t %s -c "%s" -f' + % (_System32(), timeout, reason)) diff --git a/lib/power_test.py b/lib/power_test.py new file mode 100644 index 0000000..fec8163 --- /dev/null +++ b/lib/power_test.py @@ -0,0 +1,38 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.power.""" + +from glazier.lib import power +import mock +import unittest + + +class PowerTest(unittest.TestCase): + + @mock.patch.object(power.subprocess, 'call', autospec=True) + def testRestart(self, call): + power.Restart(60, 'Reboot fixes everything.') + call.assert_called_with('C:\\Windows\\System32\\shutdown.exe -r -t 60 ' + '-c "Reboot fixes everything." -f') + + @mock.patch.object(power.subprocess, 'call', autospec=True) + def testShutdown(self, call): + power.Shutdown(30, 'Because I said so.') + call.assert_called_with('C:\\Windows\\System32\\shutdown.exe -s -t 30 ' + '-c "Because I said so." -f') + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/powershell.py b/lib/powershell.py new file mode 100644 index 0000000..803cf55 --- /dev/null +++ b/lib/powershell.py @@ -0,0 +1,151 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Run scripts with Windows Powershell.""" + +import logging +import os +import subprocess +from glazier.lib import constants +from glazier.lib import resources + + +class PowerShellError(Exception): + pass + + +def _Powershell(): + if constants.FLAGS.environment == 'WinPE': + return constants.WINPE_POWERSHELL + else: + return constants.SYS_POWERSHELL + + +class PowerShell(object): + """Interact with the powershell interpreter to run scripts.""" + + def __init__(self, echo_off=False): + self.echo_off = echo_off + + def _LaunchPs(self, op, args, ok_result): + """Launch the powershell executable to run a script. + + Args: + op: -Command or -File + args: any additional commandline args as a list + ok_result: a list of acceptable exit codes; default is 0 + + Raises: + PowerShellError: failure to execute powershell command cleanly + """ + if op not in ['-Command', '-File']: + raise PowerShellError('Unsupported operation type. [%s]' % op) + if not ok_result: + ok_result = [0] + cmd = [_Powershell(), '-NoProfile', '-NoLogo', op] + args + if not self.echo_off: + logging.debug('Running Powershell:%s', cmd) + result = subprocess.call(cmd, shell=True) + if result not in ok_result: + raise PowerShellError('Powershell command returned non-zero.\n%s' % cmd) + + def RunCommand(self, command, ok_result=None): + """Run a powershell script on the local filesystem. + + Args: + command: a list containing the command and all accompanying arguments + ok_result: a list of acceptable exit codes; default is 0 + """ + assert isinstance(command, list), 'command must be passed as a list' + if ok_result: + assert isinstance(ok_result, + list), 'result codes must be passed as a list' + self._LaunchPs('-Command', command, ok_result) + + def _GetResPath(self, path): + """Translate an installer resource path into a local path. + + Args: + path: the resource path string + + Raises: + PowerShellError: unable to locate the requested resource + + Returns: + The local filesystem path as a string. + """ + r = resources.Resources() + try: + path = r.GetResourceFileName(path) + except resources.FileNotFound as e: + raise PowerShellError(e) + return os.path.normpath(path) + + def RunResource(self, path, args=None, ok_result=None): + """Run a Powershell script supplied as an installer resource file. + + Args: + path: relative path to a script under the installer resources directory + args: a list of any optional powershell arguments + ok_result: a list of acceptable exit codes; default is 0 + """ + path = self._GetResPath(path) + if not args: + args = [] + else: + assert isinstance(args, list), 'args must be passed as a list' + if ok_result: + assert isinstance(ok_result, + list), 'result codes must be passed as a list' + self.RunLocal(path, args, ok_result) + + def RunLocal(self, path, args=None, ok_result=None): + """Run a powershell script on the local filesystem. + + Args: + path: a local filesystem path string + args: a list of any optional powershell arguments + ok_result: a list of acceptable exit codes; default is 0 + + Raises: + PowerShellError: Invalid path supplied for execution. + """ + if not os.path.exists(path): + raise PowerShellError('Cannot find path to script. [%s]' % path) + if not args: + args = [] + else: + assert isinstance(args, list), 'args must be passed as a list' + if ok_result: + assert isinstance(ok_result, + list), 'result codes must be passed as a list' + self._LaunchPs('-File', [path] + args, ok_result) + + def SetExecutionPolicy(self, policy): + """Set the shell execution policy. + + Args: + policy: One of Restricted, RemoteSigned, AllSigned, Unrestricted + + Raises: + PowerShellError: Attempting to set an unsupported policy. + """ + if policy not in ['Restricted', 'RemoteSigned', 'AllSigned', 'Unrestricted' + ]: + raise PowerShellError('Unknown execution policy: %s' % policy) + self.RunCommand(['Set-ExecutionPolicy', '-ExecutionPolicy', policy]) + + def StartShell(self): + """Start the PowerShell interpreter.""" + subprocess.call([_Powershell(), '-NoProfile', '-NoLogo'], shell=True) diff --git a/lib/powershell_test.py b/lib/powershell_test.py new file mode 100644 index 0000000..101e384 --- /dev/null +++ b/lib/powershell_test.py @@ -0,0 +1,101 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.powershell.""" + +from fakefs import fake_filesystem +from glazier.lib import powershell +import mock +import unittest + + +class PowershellTest(unittest.TestCase): + + def setUp(self): + self.fs = fake_filesystem.FakeFilesystem() + powershell.os = fake_filesystem.FakeOsModule(self.fs) + powershell.resources.os = fake_filesystem.FakeOsModule(self.fs) + self.fs.CreateFile('/resources/bin/script.ps1') + self.ps = powershell.PowerShell() + + @mock.patch.object(powershell.subprocess, 'call', autospec=True) + def testRunLocal(self, call): + args = ['-Arg1', '-Arg2'] + call.return_value = 0 + with self.assertRaises(powershell.PowerShellError): + self.ps.RunLocal('/resources/missing.ps1', args=args) + + self.ps.RunLocal('/resources/bin/script.ps1', args=args) + cmd = [ + powershell._Powershell(), '-NoProfile', '-NoLogo', '-File', + '/resources/bin/script.ps1', '-Arg1', '-Arg2' + ] + call.assert_called_with(cmd, shell=True) + with self.assertRaises(powershell.PowerShellError): + self.ps.RunLocal('/resources/bin/script.ps1', args=args, ok_result=[100]) + + @mock.patch.object(powershell.subprocess, 'call', autospec=True) + def testRunCommand(self, call): + call.return_value = 0 + self.ps.RunCommand(['Get-ChildItem', '-Recurse']) + cmd = [ + powershell._Powershell(), '-NoProfile', '-NoLogo', '-Command', + 'Get-ChildItem', '-Recurse' + ] + call.assert_called_with(cmd, shell=True) + with self.assertRaises(powershell.PowerShellError): + self.ps.RunCommand(['Get-ChildItem', '-Recurse'], ok_result=[100]) + + @mock.patch.object(powershell.PowerShell, '_LaunchPs', autospec=True) + def testRunResource(self, launch): + self.ps.RunResource('bin/script.ps1', args=['>>', 'out.txt'], ok_result=[0]) + launch.assert_called_with = '/resources/bin/script.ps1' + # Not Found + self.assertRaises(powershell.PowerShellError, self.ps.RunResource, + 'missing.ps1') + # Validation + self.assertRaises( + AssertionError, + self.ps.RunResource, + 'bin/script.ps1', + args='not a list') + self.assertRaises( + AssertionError, + self.ps.RunResource, + 'bin/script.ps1', + args=[], + ok_result='0') + + @mock.patch.object(powershell.subprocess, 'call', autospec=True) + def testSetExecutionPolicy(self, call): + call.return_value = 0 + self.ps.SetExecutionPolicy(policy='RemoteSigned') + call.assert_called_with( + [ + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + '-NoProfile', '-NoLogo', '-Command', 'Set-ExecutionPolicy', + '-ExecutionPolicy', 'RemoteSigned' + ], + shell=True) + with self.assertRaisesRegexp(powershell.PowerShellError, + 'Unknown execution policy.*'): + self.ps.SetExecutionPolicy(policy='RandomPolicy') + + @mock.patch.object(powershell.subprocess, 'call', autospec=True) + def testStartShell(self, unused_call): + self.ps.StartShell() + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/resources.py b/lib/resources.py new file mode 100644 index 0000000..f30ec5f --- /dev/null +++ b/lib/resources.py @@ -0,0 +1,58 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Provide access to non-Python installer resource files.""" + +import os +from glazier.lib import constants +import gflags as flags + +FLAGS = flags.FLAGS + +flags.DEFINE_string('resource_path', '', + 'Path to top level installer resource file storage.') + + +class FileNotFound(Exception): + pass + + +class Resources(object): + + def __init__(self, resource_dir=None): + self._path = resource_dir + if not self._path: + self._path = constants.FLAGS.resource_path + if not self._path: + path = os.path.dirname(os.path.realpath(__file__)) + self._path = os.path.join(path, 'resources') + + def GetResourceFileName(self, file_name): + """Returns the full path to a resource file. + + Args: + file_name: A file to search for under the installer resource directory. + + Returns: + The full path to the resource on disk. + + Raises: + FileNotFound: No file exists at the determined path. + """ + file_name = file_name.strip('/') + path = os.path.join(self._path, file_name) + + if os.path.exists(path): + return path + raise FileNotFound('Could not locate a resource with path %s.' % path) diff --git a/lib/resources_test.py b/lib/resources_test.py new file mode 100644 index 0000000..6e748c9 --- /dev/null +++ b/lib/resources_test.py @@ -0,0 +1,40 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.resources.""" + +from fakefs import fake_filesystem +from glazier.lib import resources +import mock +import unittest + + +class ResourcesTest(unittest.TestCase): + + def setUp(self): + self.fs = fake_filesystem.FakeFilesystem() + resources.os = fake_filesystem.FakeOsModule(self.fs) + self.fs.CreateFile('/test/file.txt') + + def testGetResourceFileName(self): + r = resources.Resources('/test') + self.assertRaises(resources.FileNotFound, r.GetResourceFileName, + 'missing.txt') + self.assertEqual(r.GetResourceFileName('file.txt'), '/test/file.txt') + + with mock.patch.object(r.os, 'cwd') as cwd: + cwd.return_value = '/test2' + r = resources.Resources() + self.assertEqual( + r.GetResourceFileName('file.txt'), '/test2/resources/file.txt') diff --git a/lib/spec/README.md b/lib/spec/README.md new file mode 100644 index 0000000..9de7989 --- /dev/null +++ b/lib/spec/README.md @@ -0,0 +1,7 @@ +# Glazier Installer Host Specification + +The spec module contains libraries for determining the desired host +specification: + +* Desired operating system +* Desired host name diff --git a/lib/spec/__init__.py b/lib/spec/__init__.py new file mode 100644 index 0000000..5def370 --- /dev/null +++ b/lib/spec/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/lib/spec/flags.py b/lib/spec/flags.py new file mode 100644 index 0000000..472557d --- /dev/null +++ b/lib/spec/flags.py @@ -0,0 +1,41 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Class for determining host spec via flags.""" + +import gflags as flags + +FLAGS = flags.FLAGS + +flags.DEFINE_string('glazier_spec_hostname', '', + 'Host name for this installation.') +flags.DEFINE_string('glazier_spec_fqdn', '', + 'Host FQDN for this installation.') +flags.DEFINE_string('glazier_spec_os', '', + 'Operating system code for this image.') + + +def GetOs(): + """Get the desired OS via flags.""" + return FLAGS.glazier_spec_os + + +def GetFqdn(): + """Get the desired FQDN via flags.""" + return FLAGS.glazier_spec_fqdn + + +def GetHostname(): + """Get the desired hostname via flags.""" + return FLAGS.glazier_spec_hostname diff --git a/lib/spec/spec.py b/lib/spec/spec.py new file mode 100644 index 0000000..61249e5 --- /dev/null +++ b/lib/spec/spec.py @@ -0,0 +1,40 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Generic class for determining the desired host operating system.""" + +from glazier.lib.spec import flags as flag_spec +import gflags as flags + +SPEC_OPTS = { + 'flag': flag_spec, +} + +FLAGS = flags.FLAGS +flags.DEFINE_enum( + 'glazier_spec', None, + SPEC_OPTS.keys(), + ('Which host specification module to use for determining host features ' + 'like Hostname and OS.')) + + +class UnknownSpec(Exception): + pass + + +def GetModule(): + try: + return SPEC_OPTS[FLAGS.glazier_spec] + except KeyError: + raise UnknownSpec(FLAGS.glazier_spec) diff --git a/lib/timers.py b/lib/timers.py new file mode 100644 index 0000000..f011efc --- /dev/null +++ b/lib/timers.py @@ -0,0 +1,67 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Store points in time to be used for metrics.""" + +import datetime + + +class Timers(object): + """Store named time elements.""" + + def __init__(self): + self._time_store = {} + + def Get(self, name): + """Get the stored value of a single timer. + + Args: + name: The name of the timer being requested. + + Returns: + A specific named datetime value if stored, or None + """ + if name in self._time_store: + return self._time_store[name] + return None + + def GetAll(self): + """Get the dictionary of all stored timers. + + Returns: + A dictionary of all stored timer names and values. + """ + return self._time_store + + def Now(self): + """Get the current time using the default timer method. + + Returns: + A datetime object. + """ + return datetime.datetime.utcnow() + + def Set(self, name, at_time=None): + """Set a timer at a specific time. + + Defaults to the current time in UTC. + + Args: + name: Name of the timer being set. + at_time: A predetermined time value to store. + """ + if at_time: + self._time_store[name] = at_time + else: + self._time_store[name] = self.Now() diff --git a/lib/timers_test.py b/lib/timers_test.py new file mode 100644 index 0000000..3d8dbc6 --- /dev/null +++ b/lib/timers_test.py @@ -0,0 +1,44 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for glazier.lib.timers.""" + +import datetime +from glazier.lib import timers +import mock +import unittest + + +class TimersTest(unittest.TestCase): + + def setUp(self): + self.t = timers.Timers() + + @mock.patch.object(timers.datetime, 'datetime', autospec=True) + def testNow(self, dt): + now = datetime.datetime.utcnow() + dt.utcnow.return_value = now + self.assertEqual(self.t.Now(), now) + + def testGetAll(self): + time_2 = datetime.datetime.now() + self.t.Set('timer_1') + self.t.Set('timer_2', at_time=time_2) + self.assertEqual(self.t.Get('timer_2'), time_2) + all_t = self.t.GetAll() + self.assertIn('timer_1', all_t) + self.assertIn('timer_2', all_t) + +if __name__ == '__main__': + unittest.main() diff --git a/lib/timezone.py b/lib/timezone.py new file mode 100644 index 0000000..bd13372 --- /dev/null +++ b/lib/timezone.py @@ -0,0 +1,64 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Timezone processing for Windows. + +> Resource Requirements + + * resources/cldr/common/supplemental/windowsZones.xml + The Windows timezone map from http://cldr.unicode.org. + +""" +import logging +from xml.dom.minidom import parse +from glazier.lib import resources +import gflags as flags + +FLAGS = flags.FLAGS + +RESOURCE_PATH = 'cldr/common/supplemental/windowsZones.xml' + +flags.DEFINE_string('windows_zones_resource', RESOURCE_PATH, + 'Timezone map file location.') + + +class TimezoneError(Exception): + pass + + +class Timezone(object): + """Timezone processing for Windows.""" + + def __init__(self, load_map=False): + self.zones = {} + if load_map: + self.LoadMap() + + def LoadMap(self): + res = resources.Resources() + try: + win_zones = parse(res.GetResourceFileName(FLAGS.windows_zones_resource)) + except resources.FileNotFound: + raise TimezoneError('Cannot load zone map from %s.' % + FLAGS.windows_zones_resource) + for zone in win_zones.getElementsByTagName('mapZone'): + self.zones[zone.getAttribute('type')] = zone.getAttribute('other') + + def TranslateZone(self, name): + found = None + try: + found = self.zones[name] + except KeyError: + logging.error('Unable to translate zone %s.', name) + return found diff --git a/lib/timezone_test.py b/lib/timezone_test.py new file mode 100644 index 0000000..158844c --- /dev/null +++ b/lib/timezone_test.py @@ -0,0 +1,42 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unittests for the timezone library.""" + +from glazier.lib import timezone +import unittest + + +class TimezoneTest(unittest.TestCase): + + def setUp(self): + self.tz = timezone.Timezone() + timezone.FLAGS.windows_zones_resource = timezone.RESOURCE_PATH + self.tz.LoadMap() + + def testLoadMap(self): + timezone.FLAGS.windows_zones_resource = '/no/such/file.xml' + self.assertRaises(timezone.TimezoneError, self.tz.LoadMap) + + def testTranslateZone(self): + zone = self.tz.TranslateZone('Pacific/Tahiti') + self.assertEqual(zone, 'Hawaiian Standard Time') + zone = self.tz.TranslateZone('Nonsense/Atlantis') + self.assertEqual(zone, None) + zone = self.tz.TranslateZone('Europe/Dublin') + self.assertEqual(zone, 'GMT Standard Time') + + +if __name__ == '__main__': + unittest.main()