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.
+
+
+
+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()