├── scripts ├── Uninstall AEiOS.app │ └── Contents │ │ ├── PkgInfo │ │ ├── MacOS │ │ └── applet │ │ ├── Resources │ │ ├── applet.icns │ │ ├── applet.rsrc │ │ ├── uninstall.icns │ │ ├── Scripts │ │ │ └── main.scpt │ │ └── description.rtfd │ │ │ └── TXT.rtf │ │ └── Info.plist ├── uninstall.sh ├── checkout_ipads.py └── aeiosutil ├── tests ├── __init__.py ├── data │ ├── tethering │ │ ├── empty.txt │ │ ├── disabled.txt │ │ ├── 10.12 │ │ │ ├── disabled.txt │ │ │ ├── empty.txt │ │ │ └── status.txt │ │ └── status.txt │ └── adapter │ │ └── mock │ │ └── standard.txt ├── test_reporting.py ├── test_ACAdapter.py ├── test_cfgutil.py ├── test_resources.py ├── test_devicemanager.py └── test_tethering.py ├── .gitignore ├── aeios ├── __init__.py ├── reporting.py ├── prompt.py ├── resources.py ├── tasks.py ├── device.py ├── config.py └── tethering.py ├── LICENSE ├── actools ├── __init__.py ├── cfgutil.py └── adapter.py └── README.md /scripts/Uninstall AEiOS.app/Contents/PkgInfo: -------------------------------------------------------------------------------- 1 | APPLaplt -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | #import test_device -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | 3 | *.py[cod] 4 | *~ 5 | 6 | *.egg-info/ 7 | *.egg 8 | 9 | *tmp/ 10 | *.log 11 | *log/ 12 | *private/ 13 | scripts/aeiosutilc 14 | -------------------------------------------------------------------------------- /scripts/Uninstall AEiOS.app/Contents/MacOS/applet: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/univ-of-utah-marriott-library-apple/aeios/HEAD/scripts/Uninstall AEiOS.app/Contents/MacOS/applet -------------------------------------------------------------------------------- /scripts/Uninstall AEiOS.app/Contents/Resources/applet.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/univ-of-utah-marriott-library-apple/aeios/HEAD/scripts/Uninstall AEiOS.app/Contents/Resources/applet.icns -------------------------------------------------------------------------------- /scripts/Uninstall AEiOS.app/Contents/Resources/applet.rsrc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/univ-of-utah-marriott-library-apple/aeios/HEAD/scripts/Uninstall AEiOS.app/Contents/Resources/applet.rsrc -------------------------------------------------------------------------------- /tests/data/tethering/empty.txt: -------------------------------------------------------------------------------- 1 | {"name":"status","result":{"Primary Interface":{"IP Address":"192.1.1.2","BSD Name":"en0","User Readable":"Ethernet","Wired":true,"Mbps":1000},"Device Roster":[]}} 2 | -------------------------------------------------------------------------------- /scripts/Uninstall AEiOS.app/Contents/Resources/uninstall.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/univ-of-utah-marriott-library-apple/aeios/HEAD/scripts/Uninstall AEiOS.app/Contents/Resources/uninstall.icns -------------------------------------------------------------------------------- /tests/data/tethering/disabled.txt: -------------------------------------------------------------------------------- 1 | {"name":"status","result":{"Primary Interface":{"IP Address":"Unknown","BSD Name":"Unknown","User Readable":"Unknown","Wired":false,"Mbps":0},"Device Roster":[]}} 2 | -------------------------------------------------------------------------------- /scripts/Uninstall AEiOS.app/Contents/Resources/Scripts/main.scpt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/univ-of-utah-marriott-library-apple/aeios/HEAD/scripts/Uninstall AEiOS.app/Contents/Resources/Scripts/main.scpt -------------------------------------------------------------------------------- /scripts/Uninstall AEiOS.app/Contents/Resources/description.rtfd/TXT.rtf: -------------------------------------------------------------------------------- 1 | {\rtf1\ansi\ansicpg1252\cocoartf1504\cocoasubrtf830 2 | {\fonttbl} 3 | {\colortbl;\red255\green255\blue255;} 4 | {\*\expandedcolortbl;;} 5 | } -------------------------------------------------------------------------------- /tests/data/tethering/10.12/disabled.txt: -------------------------------------------------------------------------------- 1 | 2018-12-10 09:44:23.140 AssetCacheTetheratorUtil[29661:7322191] Tetherator status: { 2 | "Paired Devices" = ( 3 | ); 4 | "Primary Interface" = { 5 | "BSD Name" = Unknown; 6 | "IP Address" = Unknown; 7 | Speed = 0; 8 | "User Readable" = Unknown; 9 | Wired = No; 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /tests/data/tethering/10.12/empty.txt: -------------------------------------------------------------------------------- 1 | 2018-12-05 08:48:09.120 AssetCacheTetheratorUtil[54828:6445525] Tetherator status: { 2 | "Paired Devices" = ( 3 | ); 4 | "Primary Interface" = { 5 | "BSD Name" = en0; 6 | "IP Address" = "192.1.1.2"; 7 | Speed = 1000; 8 | "User Readable" = Ethernet; 9 | Wired = Yes; 10 | }; 11 | } -------------------------------------------------------------------------------- /tests/data/tethering/status.txt: -------------------------------------------------------------------------------- 1 | {"name":"status","result":{"Primary Interface":{"IP Address":"192.1.1.2","BSD Name":"en0","User Readable":"Ethernet","Wired":true,"Mbps":1000},"Device Roster":[{"Paired":true,"Check In Pending":false,"Checked In":true,"Check In Attempts":4,"Location ID":337641472,"Name":"test-ipad-pro","Bridged":true,"Serial Number":"DMPVAA00J28K"},{"Paired":true,"Check In Pending":true,"Check In Attempts":3,"Checked In":false,"Location ID":336592896,"Name":"test-ipad-2","Bridged":true,"Serial Number":"DMPWAA01JF8J"},{"Paired":false,"Check In Pending":false,"Check In Attempts":0,"Checked In":false,"Location ID":341835776,"Name":"test-ipad-1","Bridged":false,"Serial Number":"DMQX7000JF8J"}]}} 2 | -------------------------------------------------------------------------------- /scripts/uninstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ $EUID -ne 0 ]; then 4 | echo "run me as root" >&2 5 | #exit 1 6 | fi 7 | 8 | SITE_PACKAGES=/Library/Python/2.7/site-packages 9 | 10 | DIRECTORIES=($SITE_PACKAGES/aeios 11 | $SITE_PACKAGES/actools 12 | "$HOME/Library/aeios") 13 | 14 | FILES=("/usr/local/bin/aeiosutil" 15 | "/usr/local/bin/checkout_ipads.py" 16 | "$HOME/Library/Preferences/edu.utah.mlib.aeios.plist" 17 | "$HOME/Library/LaunchAgents/edu.utah.mlib.aeios.plist") 18 | 19 | # recursively remove all directories 20 | for d in "${DIRECTORIES[@]}"; do 21 | echo "> rm -rf '$d'" 22 | rm -rf "$d" 23 | done 24 | 25 | # remove all files 26 | for f in "${FILES[@]}"; do 27 | echo "> rm -f '$f'" 28 | rm -rf "$f" 29 | done 30 | 31 | pkgutil --forget "edu.utah.mlib.aeios" -------------------------------------------------------------------------------- /aeios/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | 5 | from . import apps 6 | from . import config 7 | from . import reporting 8 | from . import resources 9 | from . import tethering 10 | from . import utility 11 | 12 | from .device import Device, DeviceList 13 | from .devicemanager import DeviceManager, Stopped 14 | from .tasks import TaskList 15 | 16 | """ 17 | Automated Enterprise iOS 18 | 19 | A collection of tools for managing and automating iOS devices 20 | """ 21 | 22 | __author__ = 'Sam Forester' 23 | __email__ = 'sam.forester@utah.edu' 24 | __copyright__ = 'Copyright (c) 2019 University of Utah, Marriott Library' 25 | __license__ = 'MIT' 26 | __version__ = "2.9.0" 27 | __all__ = [ 28 | 'apps', 29 | 'Device', 30 | 'DeviceList', 31 | 'DeviceManager', 32 | 'Stopped', 33 | 'TaskList', 34 | 'tethering'] 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 University of Utah, Marriott Library, Apple Support 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/data/tethering/10.12/status.txt: -------------------------------------------------------------------------------- 1 | 2018-12-05 08:48:09.120 AssetCacheTetheratorUtil[54828:6445525] Tetherator status: { 2 | "Paired Devices" = ( 3 | { 4 | "Check In Pending" = No; 5 | "Check In Retry Attempts" = 4; 6 | "Checked In" = Yes; 7 | "Device Location ID" = 337641472; 8 | "Device Name" = "test-ipad-pro"; 9 | "Serial Number" = DMPVAA00J28K; 10 | Tethered = Yes; 11 | Paired = Yes; 12 | }, 13 | { 14 | "Check In Pending" = No; 15 | "Check In Retry Attempts" = 0; 16 | "Checked In" = No; 17 | "Device Location ID" = 341835776; 18 | "Device Name" = "test-ipad-1"; 19 | "Serial Number" = DMQX7000JF8J; 20 | Tethered = No; 21 | Paired = No; 22 | }, 23 | { 24 | "Check In Pending" = Yes; 25 | "Check In Retry Attempts" = 3; 26 | "Checked In" = No; 27 | "Device Location ID" = 336592896; 28 | "Device Name" = "test-ipad-2"; 29 | "Serial Number" = DMPWAA01JF8J; 30 | Tethered = Yes; 31 | Paired = Yes; 32 | } 33 | ); 34 | "Primary Interface" = { 35 | "BSD Name" = en0; 36 | "IP Address" = "192.1.1.2"; 37 | Speed = 1000; 38 | "User Readable" = Ethernet; 39 | Wired = Yes; 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /tests/test_reporting.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | # import shutil 5 | import logging 6 | import unittest 7 | 8 | from aeios import reporting 9 | 10 | """ 11 | Tests for aeios.reporting 12 | """ 13 | 14 | __author__ = 'Sam Forester' 15 | __email__ = 'sam.forester@utah.edu' 16 | __copyright__ = 'Copyright (c) 2019 University of Utah, Marriott Library' 17 | __license__ = 'MIT' 18 | __version__ = "0.0.0" 19 | 20 | # suppress "No handlers could be found" message 21 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 22 | 23 | LOCATION = os.path.dirname(__file__) 24 | DATA = os.path.join(LOCATION, 'data', 'reporting') 25 | TMPDIR = os.path.join(LOCATION, 'tmp', 'reporting') 26 | 27 | 28 | def setUpModule(): 29 | pass 30 | 31 | 32 | def tearDownModule(): 33 | pass 34 | 35 | 36 | class BaseTestCase(unittest.TestCase): 37 | 38 | @classmethod 39 | def setUpClass(cls): 40 | pass 41 | 42 | @classmethod 43 | def tearDownClass(cls): 44 | pass 45 | 46 | def setUp(self): 47 | pass 48 | 49 | def tearDown(self): 50 | pass 51 | 52 | 53 | class ReportingTestCase(BaseTestCase): 54 | 55 | def setUp(self): 56 | BaseTestCase.setUp(self) 57 | 58 | 59 | if __name__ == '__main__': 60 | fmt = ('%(asctime)s %(process)d: %(levelname)6s: ' 61 | '%(name)s - %(funcName)s(): %(message)s') 62 | # logging.basicConfig(format=fmt, level=logging.DEBUG) 63 | unittest.main(verbosity=1) 64 | -------------------------------------------------------------------------------- /scripts/Uninstall AEiOS.app/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleAllowMixedLocalizations 6 | 7 | CFBundleDevelopmentRegion 8 | English 9 | CFBundleExecutable 10 | applet 11 | CFBundleIconFile 12 | uninstall 13 | CFBundleIdentifier 14 | com.apple.ScriptEditor.id.Uninstall-AEiOS 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleName 18 | Uninstall AEiOS 19 | CFBundlePackageType 20 | APPL 21 | CFBundleShortVersionString 22 | 1.0 23 | CFBundleSignature 24 | aplt 25 | LSMinimumSystemVersionByArchitecture 26 | 27 | x86_64 28 | 10.6 29 | 30 | LSRequiresCarbon 31 | 32 | WindowState 33 | 34 | bundleDividerCollapsed 35 | 36 | bundlePositionOfDivider 37 | 0.0 38 | dividerCollapsed 39 | 40 | eventLogLevel 41 | 2 42 | name 43 | ScriptWindowState 44 | positionOfDivider 45 | 388 46 | savedFrame 47 | 20 341 700 672 0 0 1680 1028 48 | selectedTab 49 | description 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /tests/test_ACAdapter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # import os 4 | # import shutil 5 | import unittest 6 | 7 | from actools import adapter 8 | 9 | """ 10 | Tests for ACAdapter.scpt 11 | """ 12 | 13 | __author__ = 'Sam Forester' 14 | __email__ = 'sam.forester@utah.edu' 15 | __copyright__ = 'Copyright (c) 2018 University of Utah, Marriott Library' 16 | __license__ = 'MIT' 17 | __version__ = "0.0.0" 18 | 19 | 20 | def setUpModule(): 21 | pass 22 | 23 | 24 | def tearDownModule(): 25 | pass 26 | 27 | 28 | class BaseTestCase(unittest.TestCase): 29 | 30 | @classmethod 31 | def setUpClass(cls): 32 | pass 33 | 34 | @classmethod 35 | def tearDownClass(cls): 36 | pass 37 | 38 | def setUp(self): 39 | pass 40 | 41 | def tearDown(self): 42 | pass 43 | 44 | 45 | class TestACAdapterLaunch(BaseTestCase): 46 | pass 47 | 48 | 49 | class TestConvertJSONToAS(BaseTestCase): 50 | pass 51 | 52 | 53 | class TestBuildRecord(BaseTestCase): 54 | pass 55 | 56 | 57 | class TestGetRecordValue(BaseTestCase): 58 | pass 59 | 60 | 61 | class TestMaximize(BaseTestCase): 62 | pass 63 | 64 | 65 | class TestPutWindowIntoListViewMode(BaseTestCase): 66 | pass 67 | 68 | 69 | class TestAllWindows(BaseTestCase): 70 | pass 71 | 72 | 73 | class TestDeviceWindow(BaseTestCase): 74 | pass 75 | 76 | 77 | class TestParseUI(BaseTestCase): 78 | pass 79 | 80 | 81 | class TestGetDeviceInfo(BaseTestCase): 82 | pass 83 | 84 | 85 | class TestGetTableInfo(BaseTestCase): 86 | pass 87 | 88 | 89 | class TestSelectDevices(BaseTestCase): 90 | pass 91 | 92 | 93 | class TestSelectApps(BaseTestCase): 94 | pass 95 | 96 | 97 | class TestSelectFromTable(BaseTestCase): 98 | pass 99 | 100 | 101 | class TestFindTargetPrompt(BaseTestCase): 102 | pass 103 | 104 | 105 | class TestStatus(BaseTestCase): 106 | pass 107 | 108 | 109 | class TestInstallVPPApps(BaseTestCase): 110 | pass 111 | 112 | 113 | class TestApplyBlueprint(BaseTestCase): 114 | pass 115 | 116 | 117 | class TestPerformAction(BaseTestCase): 118 | pass 119 | 120 | 121 | class TestListDevices(BaseTestCase): 122 | pass 123 | 124 | 125 | if __name__ == '__main__': 126 | unittest.main(verbosity=1) 127 | -------------------------------------------------------------------------------- /actools/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import print_function 4 | 5 | import logging 6 | import cfgutil 7 | import adapter 8 | 9 | __all__ = [] 10 | 11 | # meant as an abstraction between cfgutil and ACAdapter 12 | # some functionality may come from one or the other, but should stay 13 | # constant here 14 | 15 | def reset(ecids): 16 | '''Erase and re-enroll specified UDIDs 17 | ''' 18 | erase(ecids) 19 | DEPenroll(ecids) 20 | 21 | def erase(ecids): 22 | '''Erases all Content and Settings on specified UDIDs 23 | ''' 24 | cmd = ['cfgutil', '--ecid', ECID, 'erase'] 25 | pass 26 | 27 | def prepareDEP(ecids): 28 | '''Enroll specified UDIDs in using DEP 29 | ''' 30 | cfgutil.prepareDEP(ecids) 31 | cmd = ['cfgutil', '--ecid', ECID, 'prepare', '--dep', 32 | '--skip-languange', '--skip-region'] 33 | 34 | def install_apps(ecids, apps): 35 | '''Install local apps on specified UDIDs 36 | ''' 37 | pass 38 | 39 | def install_vpp_apps(udids, apps): 40 | '''Install VPP apps on specified UDIDs 41 | ''' 42 | adapter.install_vpp_apps(udids, apps) 43 | 44 | def install_profile(ecids, profile): 45 | '''Install profile on spececified UDIDs 46 | ''' 47 | pass 48 | 49 | def install_profiles(ecids, profiles): 50 | '''Install multiple profiles on specified UDIDs 51 | ''' 52 | pass 53 | 54 | def restart(ecids): 55 | '''Restart specified UDIDs 56 | ''' 57 | pass 58 | 59 | def shutdown(ecids): 60 | '''Shutdown specified UDIDs 61 | ''' 62 | pass 63 | 64 | def restore(ecids): 65 | pass 66 | 67 | def tag(ecids, tags): 68 | pass 69 | 70 | def wallpaper(ecids, image, screen='both'): 71 | if screen not in ['both', 'lock', 'home']: 72 | raise RuntimeError("invalid screen: {0}".format(screen)) 73 | pass 74 | 75 | def rename(ecid, name): 76 | pass 77 | 78 | def get(ecids, keys, **kwargs): 79 | return cfgutil.get(keys, ecids, **kwargs) 80 | 81 | def run_blueprint(udids, blueprint): 82 | # this will need the most work: 83 | # it would be cool if this could do all the file locking, recover 84 | # the alert, and process possible actions (cataloging if possible) 85 | # it should return a dictionary of udids and whether the blueprint 86 | # was successful or not 87 | try: 88 | return adapter.run_blueprint(udids, blueprint) 89 | except adapter.ACAdapterError as e: 90 | pass 91 | 92 | try: 93 | blueprint.recover(e) 94 | except BlueprintRecoveryError as e: 95 | pass 96 | 97 | def list(): 98 | return cfgutil.list() 99 | 100 | -------------------------------------------------------------------------------- /aeios/reporting.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import json 4 | import urllib2 5 | import logging 6 | 7 | __author__ = 'Sam Forester' 8 | __email__ = 'sam.forester@utah.edu' 9 | __copyright__ = 'Copyright(c) 2019 University of Utah, Marriott Library' 10 | __license__ = 'MIT' 11 | __version__ = "1.0.6" 12 | __all__ = [ 13 | 'SlackSender', 14 | 'Reporter', 15 | 'NullReporter', 16 | 'Slack', 17 | 'reporterFromSettings' 18 | ] 19 | 20 | # suppress "No handlers could be found" message 21 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 22 | 23 | 24 | class Error(Exception): 25 | pass 26 | 27 | 28 | class Reporter(object): 29 | """ 30 | Base Reporter class 31 | """ 32 | def send(self, msg): 33 | #TO-DO: raise NotImplementedError() 34 | pass 35 | 36 | 37 | class NullReporter(Reporter): 38 | """ 39 | Does nothing 40 | """ 41 | #TO-DO: implement send() 42 | # def send(self, msg): 43 | # pass 44 | pass 45 | 46 | 47 | #TO-DO: combine SlackBot and Slack 48 | class SlackBot(Reporter): 49 | """ 50 | Minimal functionality of management_tools.slack 51 | """ 52 | def __init__(self, url, channel, name): 53 | self.url = url 54 | self.name = name 55 | self.channel = channel 56 | 57 | def send(self, msg): 58 | json_str = json.dumps({'text': msg, 59 | 'username': self.name, 60 | 'channel': self.channel}) 61 | request = urllib2.Request(self.url, json_str) 62 | urllib2.urlopen(request) 63 | 64 | 65 | class Slack(Reporter): 66 | """ 67 | Class for sending messages via Slack 68 | """ 69 | def __init__(self, url, channel, name=__name__): 70 | self.log = logging.getLogger(__name__ + '.Slack') 71 | self.url = url 72 | self.channel = channel 73 | self.name = name 74 | self.bot = SlackBot(url, channel, name) 75 | 76 | def send(self, msg): 77 | try: 78 | self.bot.send(msg) 79 | except: 80 | self.log.error(u"failed to send message: %s", msg) 81 | 82 | 83 | def reporterFromSettings(info): 84 | """ 85 | Returns appropriate Reporter based upon settings 86 | """ 87 | logger = logging.getLogger(__name__) 88 | logger.info("building reporter") 89 | logger.debug("settings: %r", info) 90 | try: 91 | _slack = info['Slack'] 92 | name = _slack.get('name') 93 | return Slack(_slack['URL'], _slack['channel'], name) 94 | except KeyError as e: 95 | logger.error("missing key: %s", e) 96 | logger.debug("returning NullReporter()") 97 | return NullReporter() 98 | 99 | 100 | if __name__ == '__main__': 101 | pass 102 | -------------------------------------------------------------------------------- /tests/test_cfgutil.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import json 5 | import shutil 6 | import logging 7 | import unittest 8 | import datetime as dt 9 | 10 | from actools import cfgutil 11 | 12 | """ 13 | Tests for actools.cfgutil 14 | """ 15 | 16 | __author__ = 'Sam Forester' 17 | __email__ = 'sam.forester@utah.edu' 18 | __copyright__ = 'Copyright(c) 2019 University of Utah, Marriott Library' 19 | __license__ = 'MIT' 20 | __version__ = '1.0.0' 21 | 22 | # suppress "No handlers could be found" message 23 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 24 | 25 | LOCATION = os.path.dirname(__file__) 26 | DATA = os.path.join(LOCATION, 'data', 'cfgutil') 27 | TMPDIR = os.path.join(LOCATION, 'tmp', 'cfgutil') 28 | 29 | 30 | class BaseTestCase(unittest.TestCase): 31 | 32 | @classmethod 33 | def setUpClass(cls): 34 | pass 35 | 36 | @classmethod 37 | def tearDownClass(cls): 38 | pass 39 | 40 | def setUp(self): 41 | self.data = DATA 42 | self.tmp = TMPDIR 43 | 44 | def tearDown(self): 45 | pass 46 | 47 | 48 | class MockOutputTestCase(BaseTestCase): 49 | 50 | def setUp(self): 51 | BaseTestCase.setUp(self) 52 | self.mockfiles = os.path.join(self.data, 'mock') 53 | 54 | @staticmethod 55 | def lines(file): 56 | with open(file) as f: 57 | for line in f: 58 | yield line 59 | 60 | def mock(self, path, default=None): 61 | line = self.lines(path) 62 | 63 | def _mock(): 64 | try: 65 | _line = next(line) 66 | return json.loads(_line) 67 | except StopIteration: 68 | if default: 69 | return default 70 | 71 | return _mock 72 | 73 | 74 | class ResultErrorTests(BaseTestCase): 75 | 76 | def test_empty_result(self): 77 | """ 78 | test cfgutil.Error is raised without params 79 | """ 80 | with self.assertRaises(TypeError): 81 | cfgutil.Result() 82 | 83 | 84 | class ResultTestCase(BaseTestCase): 85 | """ 86 | Base TestCase for cfgutil.Result 87 | """ 88 | 89 | def setUp(self): 90 | BaseTestCase.setUp(self) 91 | self.result = None 92 | 93 | def test_output_defined(self): 94 | """ 95 | test result.output is defined 96 | """ 97 | if self.result: 98 | self.assertIsNotNone(self.result.output) 99 | 100 | def test_output_type(self): 101 | """ 102 | test result.output is dict 103 | """ 104 | if self.result: 105 | self.assertIsInstance(self.result.output, dict) 106 | 107 | # def test_errors_defined(self): 108 | # """ 109 | # test result.error defined 110 | # """ 111 | # if self.result: 112 | # self.assertIsNotNone(self.result.errors) 113 | # 114 | # def test_errors_type(self): 115 | # """ 116 | # test result.error is dict 117 | # """ 118 | # if self.result: 119 | # self.assertIsInstance(self.result.output, dict) 120 | 121 | def test_missing_defined(self): 122 | """ 123 | test result.missing defined 124 | """ 125 | if self.result: 126 | self.assertIsNotNone(self.result.missing) 127 | 128 | def test_missing_type(self): 129 | """ 130 | test result.missing is list 131 | """ 132 | if self.result: 133 | self.assertIsInstance(self.result.missing, list) 134 | 135 | def test_ecids_defined(self): 136 | """ 137 | test result.ecids defined 138 | """ 139 | if self.result: 140 | self.assertIsNotNone(self.result.ecids) 141 | 142 | def test_ecids_type(self): 143 | """ 144 | test result.ecids is list 145 | """ 146 | if self.result: 147 | self.assertIsInstance(self.result.ecids, list) 148 | 149 | 150 | class MinimalResultsTest(ResultTestCase): 151 | 152 | def setUp(self): 153 | ResultTestCase.setUp(self) 154 | self.cfgout = {'Output': {}, 'Devices': [], 'Command': 'test'} 155 | self.result = cfgutil.Result(self.cfgout) 156 | 157 | def test_nothing_missing(self): 158 | self.assertEquals(self.result.missing, []) 159 | 160 | def test_get_ecid(self): 161 | self.assertIsNone(self.result.get('0x000000001')) 162 | 163 | 164 | class EmptyResult(ResultTestCase): 165 | """ 166 | Tests for minimal Result 167 | """ 168 | def setUp(self): 169 | self.result = cfgutil.Result({}) 170 | 171 | def test_nothing_missing(self): 172 | self.assertEquals(self.result.missing, []) 173 | 174 | def test_get_ecid(self): 175 | self.assertIsNone(self.result.get('0x000000001')) 176 | 177 | 178 | if __name__ == '__main__': 179 | unittest.main(verbosity=1) 180 | -------------------------------------------------------------------------------- /scripts/checkout_ipads.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | import time 7 | import signal 8 | import subprocess 9 | import logging 10 | import logging.config 11 | 12 | import aeios 13 | 14 | # should be replaced with `aeiosutil start` 15 | """ 16 | Run aeios Automation 17 | """ 18 | 19 | __author__ = 'Sam Forester' 20 | __email__ = 'sam.forester@utah.edu' 21 | __copyright__ = 'Copyright (c) 2019 University of Utah, Marriott Library' 22 | __license__ = 'MIT' 23 | __version__ = '2.2.1' 24 | 25 | SCRIPT = os.path.basename(__file__) 26 | 27 | LOGGING = { 28 | 'version': 1, 29 | 'disable_existing_loggers': False, 30 | 'formatters': { 31 | 'simple': { 32 | 'format': '%(name)s: %(funcName)s: %(message)s' 33 | }, 34 | 'precise': { 35 | 'format': ('%(asctime)s %(process)d: %(levelname)8s: %(name)s ' 36 | '- %(funcName)s(): line:%(lineno)d: %(message)s') 37 | }, 38 | }, 39 | 'handlers': { 40 | 'console': { 41 | 'level': 'INFO', 42 | 'class': 'logging.StreamHandler', 43 | 'formatter': 'simple', 44 | 'stream': 'ext://sys.stderr' 45 | }, 46 | 'file': { 47 | 'level': 'DEBUG', 48 | 'class': 'logging.handlers.TimedRotatingFileHandler', 49 | 'formatter': 'precise', 50 | 'when': 'midnight', 51 | 'encoding': 'utf8', 52 | 'backupCount': 5, 53 | 'filename': None, 54 | }, 55 | }, 56 | 'root': { 57 | 'level': 'DEBUG', 58 | 'handlers': ["file", "console"] 59 | } 60 | } 61 | 62 | 63 | class SignalTrap(object): 64 | """ 65 | Class for trapping interruptions in an attempt to shutdown 66 | more gracefully 67 | """ 68 | def __init__(self, logger): 69 | self.stopped = False 70 | self.log = logger 71 | signal.signal(signal.SIGINT, self.trap) 72 | signal.signal(signal.SIGQUIT, self.trap) 73 | signal.signal(signal.SIGTERM, self.trap) 74 | signal.signal(signal.SIGTSTP, self.trap) 75 | 76 | def trap(self, signum, frame): 77 | self.log.debug("received signal: {0}".format(signum)) 78 | self.stopped = True 79 | 80 | 81 | # DEVICE ACTIONS 82 | 83 | def run(manager): 84 | """ 85 | Attempt at script level recursion and daemonization 86 | Benefits, would reduce the number of launchagents and the 87 | Accessiblity access 88 | """ 89 | logger = logging.getLogger(SCRIPT) 90 | logger.info("starting automation") 91 | # start up cfgutil exec with this script as the attach and detach 92 | # scriptpath 93 | # FUTURE: controller.monitor() 94 | cmd = ['/usr/local/bin/cfgutil', 'exec', 95 | '-a', "{0} attached".format(sys.argv[0]), 96 | '-d', "{0} detached".format(sys.argv[0])] 97 | try: 98 | logger.debug("> %s", " ".join(cmd)) 99 | p = subprocess.Popen(cmd, stderr=subprocess.PIPE) 100 | except OSError as e: 101 | if e.errno == 2: 102 | err = 'cfgutil missing... install automation tools' 103 | logger.error(err) 104 | raise SystemExit(err) 105 | logger.critical("unable to run command: %s", e, exc_info=True) 106 | raise 107 | 108 | if p.poll() is not None: 109 | err = "{0}".format(p.communicate()[1]).rstrip() 110 | logger.error(err) 111 | raise SystemExit(err) 112 | 113 | sig = SignalTrap(logger) 114 | while not sig.stopped: 115 | time.sleep(manager.idle) 116 | if sig.stopped: 117 | break 118 | ## restart the cfgtuil command if it isn't running 119 | if p.poll() is not None: 120 | p = subprocess.Popen[cmd] 121 | ## As long as the manager isn't stopped, run the verification 122 | if not manager.stopped: 123 | try: 124 | logger.debug("running idle verification") 125 | manager.verify(run=True) 126 | logger.debug("idle verification finished") 127 | except aeios.Stopped: 128 | logger.info("manager was stopped") 129 | except Exception as e: 130 | logger.exception("unexpected error occurred") 131 | # terminate the cfutil command 132 | p.kill() 133 | logger.info("finished") 134 | 135 | 136 | def main(): 137 | resources = aeios.resources.Resources() 138 | logfile = os.path.join(resources.logs, "checkout_ipads.log") 139 | LOGGING['handlers']['file']['filename'] = logfile 140 | logging.config.dictConfig(LOGGING) 141 | logger = logging.getLogger(SCRIPT) 142 | 143 | logger.debug("started") 144 | 145 | logger.debug("loading DeviceManager") 146 | manager = aeios.DeviceManager() 147 | 148 | try: 149 | action = sys.argv[1] 150 | except IndexError: 151 | run(manager) 152 | sys.exit(0) 153 | 154 | # get the iOS Device environment variables set by `cfgutil exec` 155 | env_keys = ['ECID', 'deviceName', 'bootedState', 'deviceType', 156 | 'UDID', 'buildVersion', 'firmwareVersion', 'locationID'] 157 | info = {k:os.environ.get(k) for k in env_keys} 158 | 159 | logger.debug("performing action: %s", action) 160 | 161 | if action == 'attached': 162 | manager.checkin(info) 163 | elif action == 'detached': 164 | manager.checkout(info) 165 | elif action == 'refresh': 166 | manager.verify() 167 | else: 168 | err = "invalid action: {0}".format(action) 169 | logger.error(err) 170 | raise SystemExit(err) 171 | 172 | 173 | if __name__ == '__main__': 174 | main() 175 | -------------------------------------------------------------------------------- /scripts/aeiosutil: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | import shutil 7 | import logging 8 | 9 | import aeios 10 | 11 | """ 12 | Tool to configure aeios 13 | """ 14 | 15 | __author__ = 'Sam Forester' 16 | __email__ = 'sam.forester@utah.edu' 17 | __copyright__ = 'Copyright (c) 2019 University of Utah, Marriott Library' 18 | __license__ = 'MIT' 19 | __version__ = "1.1.0" 20 | 21 | # name of this script 22 | SCRIPT = os.path.basename(sys.argv[0]) 23 | 24 | def add(resources, args): 25 | logger = logging.getLogger(__name__) 26 | 27 | #> aeiosutil add wifi PATH 28 | if args.item == 'wifi': 29 | aeios.utility.add_wifi_profile(args.path, resources.wifi) 30 | 31 | #> aeiosutil add image (--lock | --alert | --background) PATH 32 | elif args.item == 'image': 33 | aeios.utility.add_item(args.path, resources.images, name=args.image) 34 | logger.info("added %s image: %s", args.image, args.path) 35 | 36 | #> aeiosutil add identity (--p12 | --certs) PATH 37 | elif args.item == 'identity': 38 | try: 39 | if args.p12: 40 | #> aeiosutil add identity --p12 PATH 41 | aeios.utility.add_p12(args.path, resources.supervision) 42 | logger.info("converted p12: %r", args.path) 43 | else: 44 | #> aeiosutil add identity --certs PATH 45 | aeios.utility.copy_certs(args.path) 46 | logger.info("imported identity files: %r", args.path) 47 | except ValueError as e: 48 | # both copy_certs() and add_p12() raise ValueErrors 49 | raise SystemExit("{0!s}: {1!s}".format(SCRIPT, e)) 50 | 51 | #> aeiosutil add app "app name" 52 | elif args.item == 'app': 53 | if not args.name: 54 | raise SystemExit("must specify app name") 55 | logger.debug(u"adding app: '%s'", args.name) 56 | manager = aeios.apps.AppManager().add('all-iPads', args.name) 57 | logger.info(u"added app to automation: '%s'", args.name) 58 | else: 59 | err = "{0!s}: unable to add {1!r}".format(SCRIPT, args.item) 60 | raise SystemExit(err) 61 | 62 | 63 | def remove(resources, args): 64 | logger = logging.getLogger(__name__) 65 | 66 | #> aeiosutil remove wifi 67 | if args.item == 'wifi': 68 | logger.debug("> remove: %r", resources.wifi) 69 | try: 70 | os.remove(resources.wifi) 71 | except OSError as e: 72 | if e.errno == 2: 73 | # profile is already removed 74 | pass 75 | logger.info("successfully removed Wi-Fi profile") 76 | 77 | #> aeiosutil remove image (--background | --lock | --alert | --all) 78 | elif args.item == 'image': 79 | if args.image == 'all': 80 | logger.debug("> rmtree: %r", resources.images) 81 | shutil.rmtree(resources.images) 82 | logger.debug("> mkdir: %r", resources.images) 83 | os.mkdir(resources.images) 84 | logger.info("all images removed") 85 | else: 86 | images = os.listdir(resources.images) 87 | logger.debug("images: %r", images) 88 | try: 89 | name = [x for x in images if x.startswith(args.image)][0] 90 | path = os.path.join(resources.images, name) 91 | logger.debug("> remove: %r", path) 92 | os.remove(path) 93 | except IndexError: 94 | pass 95 | logger.info("%s image removed", args.image) 96 | 97 | #> aeiosutil remove identity 98 | elif args.item == 'identity': 99 | logger.debug("> remove: %r", resources.key) 100 | os.remove(resources.key) 101 | logger.debug("> remove: %r", resources.cert) 102 | os.remove(resources.cert) 103 | logger.info("removed supervision identity") 104 | 105 | #> aeiosutil remove app "app name" 106 | elif args.item == 'app': 107 | logger.debug(u"removing: '%s'", args.name) 108 | manager = aeios.apps.AppManager().remove(args.name) 109 | logger.info(u"removed app from automation: '%s'", args.name) 110 | 111 | #> aeiosutil remove reporting 112 | elif args.item == 'reporting': 113 | resources.reporting(aeios.resources.DEFAULT.reporting) 114 | logger.info("removed reporting configuration") 115 | else: 116 | err = "{0!s}: unable to remove {1!r}".format(SCRIPT, args.item) 117 | raise SystemExit(err) 118 | 119 | 120 | def configure(resources, args): 121 | logger = logging.getLogger(__name__) 122 | 123 | #> aeiosutil configure slack URL CHANNEL [--name NAME] 124 | if args.item == 'slack': 125 | data = {'URL': args.URL, 'channel': args.channel, 'name': args.name} 126 | resources.reporting({'Slack': data}) 127 | logger.info("successfully configured reporting") 128 | else: 129 | err = "{0!s}: unable to configure {1!r}".format(SCRIPT, args.item) 130 | raise SystemExit(err) 131 | 132 | 133 | def main(argv): 134 | logger = logging.getLogger(__name__) 135 | 136 | # NOTE: defining before logging.basicConfig suppresses logging 137 | resources = aeios.resources.Resources() 138 | 139 | parser = aeios.utility.Parser() 140 | args = parser.parse(argv) 141 | 142 | if args.version: 143 | raise SystemExit("{0}: v{1}".format(SCRIPT, __version__)) 144 | 145 | # there's probably a better way to dynamically format logging... 146 | format = '{0!s}: %(message)s'.format(SCRIPT) 147 | if args.debug: 148 | format = ('%(asctime)s %(levelname)6s: %(name)s - %(funcName)s(): ' 149 | '%(message)s') 150 | level = logging.DEBUG 151 | elif args.verbose: 152 | level = logging.INFO 153 | else: 154 | level = logging.CRITICAL 155 | 156 | logging.basicConfig(format=format, level=level) 157 | 158 | if args.cmd == 'add': 159 | # resources.add() 160 | add(resources, args) 161 | elif args.cmd == 'remove': 162 | remove(resources, args) 163 | elif args.cmd == 'configure': 164 | configure(resources, args) 165 | elif args.cmd == 'start': 166 | aeios.utility.start(args.login) 167 | elif args.cmd == 'stop': 168 | aeios.utility.stop(args.login) 169 | 170 | 171 | if __name__ == '__main__': 172 | main(sys.argv[1:]) 173 | -------------------------------------------------------------------------------- /aeios/prompt.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | import subprocess 5 | import re 6 | 7 | """ 8 | Prompts for aeios 9 | """ 10 | 11 | __author__ = 'Sam Forester' 12 | __email__ = 'sam.forester@utah.edu' 13 | __copyright__ = 'Copyright (c) 2019 University of Utah, Marriott Library' 14 | __license__ = 'MIT' 15 | __version__ = "1.0.2" 16 | __all__ = ['Button', 'Prompt', 'Cancelled', 'confirm', 'ignore', 'automation'] 17 | 18 | # suppress "No handlers could be found" message 19 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 20 | 21 | # NOTES: It might be advantageous to have adapter.Prompt here, but not now 22 | 23 | 24 | class Error(Exception): 25 | pass 26 | 27 | 28 | class Cancelled(Error): 29 | """ 30 | Raised when user cancels 31 | """ 32 | pass 33 | 34 | 35 | class Button(object): 36 | """ 37 | Class to attach functions to buttons 38 | """ 39 | @staticmethod 40 | def _callback(result): 41 | logger = logging.getLogger(__name__) 42 | logger.debug("creating default callback: returning %r", result) 43 | 44 | def _wrapped(): 45 | return result 46 | 47 | logger.debug("returning wrapper function") 48 | return _wrapped 49 | 50 | def __init__(self, text, callback=None, default=False): 51 | self.log = logging.getLogger(__name__ + '.Button') 52 | _msg = "initializing Button({0!r}, {1!r}, {2!r})" 53 | self.log.debug(_msg.format(text, callback, default)) 54 | self.text = text 55 | self.callback = callback if callback else self._callback(text) 56 | self.default = default 57 | 58 | def __str__(self): 59 | try: 60 | return self.text.encode('utf-8') 61 | except UnicodeDecodeError: 62 | return self.text 63 | 64 | def __unicode__(self): 65 | try: 66 | return self.text.decode('utf-8') 67 | except UnicodeEncodeError: 68 | return self.text 69 | 70 | def __repr__(self): 71 | return u"Button({0.text!r}, {1.callback!r})".format(self, self) 72 | 73 | def press(self): 74 | self.log.debug(u"pressing '%s'", self) 75 | return self.callback() 76 | 77 | 78 | class Prompt(object): 79 | 80 | def __init__(self, msg, details=None, buttons=()): 81 | self.log = logging.getLogger(__name__ + '.Prompt') 82 | self.msg = msg 83 | self.details = details 84 | if not buttons: 85 | # buttons = (Button("OK"), Cancel()) 86 | buttons = (Button("OK"), Button("Cancel")) 87 | self.buttons = buttons 88 | 89 | def display(self): 90 | """ 91 | Build AppleScript dialog and 92 | """ 93 | self.log.debug("displaying prompt") 94 | scpt = u'display alert "{0!s}"'.format(self.msg) 95 | if self.details: 96 | self.log.debug("adding details: %s", self.details) 97 | scpt += u' message "{0!s}"'.format(self.details) 98 | 99 | # Button("OK"), Button("Cancel") -> r'{"OK", "Cancel"}' 100 | b_str = '", "'.join([str(x) for x in self.buttons]) 101 | scpt += u' as critical buttons {{"{0!s}"}}'.format(b_str) 102 | 103 | # get all default buttons 104 | default = [b for b in self.buttons if b.default] 105 | if default: 106 | self.log.debug("adding default button: %s", default[0]) 107 | # use first default encountered 108 | scpt += u' default button "{0!s}"'.format(default[0]) 109 | 110 | # execute the AppleScript 111 | self.log.debug("> osascript -e %r", scpt) 112 | #out = subprocess.check_output(['osascript', '-e', scpt]).rstrip() 113 | out = subprocess.check_output(['osascript', '-e', scpt]) 114 | self.log.debug("output: %r", out) 115 | 116 | result = re.match(r'^button returned:(.+)$', out).group(1) 117 | button = [b for b in self.buttons if b.text == result][0] 118 | 119 | if button.text == 'Cancel': 120 | # cancel out of this prompt (stops recursion) 121 | raise Cancelled("prompt was cancelled") 122 | else: 123 | try: 124 | return button.press() 125 | except Cancelled: 126 | # re-display this prompt if another prompt was opened 127 | self.log.debug("re-displaying prompt") 128 | return self.display() 129 | 130 | 131 | def confirm(device): 132 | """ 133 | Displays Erase Confirmation message 134 | """ 135 | logger = logging.getLogger(__name__) 136 | logger.debug("started: %s", device) 137 | 138 | message = "Are you sure you want to erase this device?" 139 | details = (u'“{0!s}” will be automatically erased each time it' 140 | u' is connected to this computer.\n\n' 141 | u'This cannot be undone.'.format(device)) 142 | buttons = (Button("Cancel", default=True), Button("Erase")) 143 | 144 | prompt = Prompt(message, details, buttons) 145 | 146 | def _button_callback(): 147 | logger.debug("running callback") 148 | return prompt.display() 149 | 150 | return _button_callback 151 | 152 | 153 | def ignore(device): 154 | """ 155 | Displays Ignore Confirmation message 156 | """ 157 | logger = logging.getLogger(__name__) 158 | logger.debug("started: %s", device) 159 | 160 | message = u"Exclude “{0!s}” from automation?".format(device) 161 | details = "You will no longer be prompted when this device is connected." 162 | buttons = (Button("Ignore"), Button("Cancel")) 163 | 164 | prompt = Prompt(message, details, buttons) 165 | 166 | def _button_callback(): 167 | logger.debug("running callback") 168 | return prompt.display() 169 | 170 | return _button_callback 171 | 172 | 173 | def automation(device): 174 | """ 175 | Main Dialog Window that prompts for Automation: 176 | Ignore -> Confirm 177 | Automatically Erase -> 178 | """ 179 | message = (u"aeiOS wants to automatically Erase “{0!s}”.").format(device) 180 | details = ("This device will be automatically erased each time it" 181 | " is connected to this system.") 182 | buttons = (Button("Ignore Device", ignore(device)), 183 | Button("Automatically Erase", confirm(device)), 184 | Button("Cancel")) 185 | 186 | return Prompt(message, details, buttons).display() 187 | 188 | 189 | if __name__ == '__main__': 190 | pass 191 | -------------------------------------------------------------------------------- /aeios/resources.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import logging 5 | 6 | from . import config 7 | from . import reporting 8 | 9 | """ 10 | Shared resources and defaults for aeios 11 | """ 12 | 13 | __author__ = "Sam Forester" 14 | __email__ = "sam.forester@utah.edu" 15 | __copyright__ = 'Copyright (c) 2019 University of Utah, Marriott Library' 16 | __license__ = 'MIT' 17 | __version__ = "1.0.3" 18 | __all__ = [] 19 | 20 | # suppress "No handlers could be found" message 21 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 22 | 23 | DOMAIN = 'edu.utah.mlib' 24 | PATH = os.path.expanduser('~/Library/aeios') 25 | PREFERENCES = os.path.expanduser('~/Library/Preferences') 26 | DIRECTORIES = ['Apps', 'Devices', 'Images', 'Logs', 'Supervision', 'Profiles'] 27 | 28 | 29 | class Error(Exception): 30 | pass 31 | 32 | 33 | class MissingConfiguration(Error): 34 | """ 35 | Raised if resource is missing 36 | """ 37 | pass 38 | 39 | 40 | class MissingDefault(Error): 41 | """ 42 | Raised if no default value is provided 43 | """ 44 | pass 45 | 46 | 47 | class CacheError(Error): 48 | pass 49 | 50 | 51 | class Defaults(object): 52 | 53 | def __init__(self): 54 | self.log = logging.getLogger(__name__ + '.Defaults') 55 | 56 | def __getattr__(self, attr): 57 | raise MissingDefault(attr) 58 | 59 | def find(self, domain): 60 | self.log.debug("domain: %r", domain) 61 | name = domain.split('.')[-1] 62 | return getattr(self, name) 63 | 64 | @property 65 | def apps(self): 66 | return {'groups': {'model': {'iPad7,3': ['iPadPros'], 67 | 'iPad8,1': ['iPadPros'], 68 | 'iPad7,5': ['iPads']}}, 69 | 'all-iPads': [], 70 | 'iPadPros': [], 71 | 'iPads': []} 72 | 73 | @property 74 | def aeios(self): 75 | return {'Idle': 300, 76 | 'Reporting': self.reporting} 77 | 78 | @property 79 | def tasks(self): 80 | return {'erase': [], 'prepare': [], 'installapps': [], 'queries': []} 81 | 82 | @property 83 | def reporting(self): 84 | return {'Slack': {}} 85 | 86 | @property 87 | def devicemanager(self): 88 | return {'Devices': [], 'loadBalancing': 5} 89 | 90 | @property 91 | def devices(self): 92 | return {} 93 | 94 | @property 95 | def path(self): 96 | return PATH 97 | 98 | @property 99 | def preferences(self): 100 | return PREFERENCES 101 | 102 | # instantiate global object 103 | DEFAULT = Defaults() 104 | 105 | 106 | class Cache(object): 107 | 108 | def __init__(self): 109 | self.log = logging.getLogger(__name__ + '.Cache') 110 | # self.devices = DeviceList() 111 | # self.conf = conf 112 | 113 | @property 114 | def listed(self): 115 | return self.conf.get('Devices', []) 116 | 117 | @listed.setter 118 | def listed(self, value): 119 | self.conf.update({'Devices': value}) 120 | 121 | @property 122 | def available(self): 123 | pass 124 | 125 | def device(self, ecid): 126 | for d in self.devices: 127 | if ecid == d.ecid: 128 | return d 129 | raise CacheError("{0!s}: not in cache".format(ecid)) 130 | 131 | def add(self, device): 132 | if device not in self.devices: 133 | self.log.debug("cached device: %s", device) 134 | self.devices.append(device) 135 | 136 | 137 | class Resources(object): 138 | 139 | def __init__(self, name=None, path=None): 140 | """ 141 | """ 142 | self.log = logging.getLogger(__name__ + '.Resources') 143 | self.log.debug("getting resources: %r", name) 144 | self.path = path if path else PATH 145 | if name: 146 | self.domain = "{0}.{1}".format(DOMAIN, name) 147 | self.log.debug("building config: %r: %r", self.domain, self.path) 148 | self.config = _config(self.domain, path) 149 | else: 150 | self.domain = DOMAIN 151 | self.preferences = _config('edu.utah.mlib.aeios', PREFERENCES) 152 | self.auth = None 153 | 154 | # self._cache = None 155 | self._reporter = None 156 | 157 | for d in DIRECTORIES: 158 | path = os.path.join(self.path, d) 159 | setattr(self, d.lower(), path) 160 | 161 | self.directories = build_directories(self.path, DIRECTORIES) 162 | 163 | @property 164 | def wifi(self): 165 | """ 166 | :returns: path to wifi.mobileconfig 167 | """ 168 | return os.path.join(self.profiles, 'wifi.mobileconfig') 169 | 170 | @property 171 | def key(self): 172 | """ 173 | :returns: path to identity.der 174 | """ 175 | return os.path.join(self.supervision, 'identity.der') 176 | 177 | @property 178 | def cert(self): 179 | """ 180 | :returns: path to identity.crt 181 | """ 182 | return os.path.join(self.supervision, 'identity.crt') 183 | 184 | @property 185 | def reporter(self): 186 | """ 187 | :return: aeios.reporter 188 | """ 189 | if not self._reporter: 190 | data = self.reporting() 191 | self._reporter = reporting.reporterFromSettings(data) 192 | return self._reporter 193 | 194 | def idle(self, seconds=None): 195 | if seconds: 196 | self.preferences.update({'Idle': seconds}) 197 | return self.preferences.get('Idle') 198 | 199 | def authorization(self): 200 | """ 201 | :returns: cfgutil.Authentication 202 | """ 203 | if not self.auth: 204 | self.log.debug("getting cfgutil authentication") 205 | self.auth = cfgutil.Authentication(self.key, self.cert) 206 | return self.auth 207 | 208 | def reporting(self, data=None): 209 | """ 210 | retrieve and/or set reporting configuration 211 | 212 | :param data: updates reporting configuration (if provided) 213 | :returns: dict containing current reporting configuration 214 | raises Missing() if None 215 | """ 216 | if data: 217 | self.preferences.update({'Reporting': data}) 218 | 219 | info = self.preferences.get('Reporting') 220 | if not info: 221 | raise MissingConfiguration("No configuration for Reporting") 222 | return info 223 | 224 | def __str__(self): 225 | return self.path 226 | 227 | 228 | def build_directories(root, names, mode=0o755): 229 | logger = logging.getLogger(__name__) 230 | dirs = [os.path.join(root, x) for x in names] 231 | for d in dirs: 232 | if not os.path.isdir(d): 233 | logger.debug("> makedirs: %r (mode=%o)", d, mode) 234 | os.makedirs(d, mode) 235 | else: 236 | logger.debug("directory exists: %r", d) 237 | return dirs 238 | 239 | 240 | def _config(domain, path=None, defaults=None): 241 | """ 242 | 243 | """ 244 | logger = logging.getLogger(__name__) 245 | 246 | # global default variables 247 | if not path: 248 | path = PATH 249 | if not defaults: 250 | logger.debug("looking for defaults: %r", domain) 251 | defaults = DEFAULT.find(domain) 252 | logger.debug("found defaults: %r", defaults) 253 | 254 | # TO-DO: this should really support default variables 255 | # conf = config.Manager(domain, path, defaults) 256 | conf = config.Manager(domain, path) 257 | try: 258 | # if the config doesn't exist, this will raise an error 259 | conf.read() 260 | except config.Error as e: 261 | # logger.error("unable to read config: %s", e) 262 | logger.debug("creating config: %r", conf.file) 263 | logger.debug("default: %r", defaults) 264 | conf.write(defaults) 265 | return conf 266 | 267 | 268 | def configure(key, data): 269 | """ 270 | Modify general configuration 271 | """ 272 | # configure('Reporting', {'Slack': {'URL': url, 273 | # 'channel': channel, 274 | # 'name': name}}) 275 | pass 276 | 277 | 278 | if __name__ == '__main__': 279 | pass 280 | -------------------------------------------------------------------------------- /aeios/tasks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | 5 | # import config 6 | from . import resources 7 | 8 | """ 9 | Persistant Tasking Queue 10 | """ 11 | 12 | __author__ = 'Sam Forester' 13 | __email__ = 'sam.forester@utah.edu' 14 | __copyright__ = 'Copyright(c) 2019 University of Utah, Marriott Library' 15 | __license__ = 'MIT' 16 | __version__ = "2.1.4" 17 | __all__ = ['TaskList'] 18 | 19 | # suppress "No handlers could be found" message 20 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 21 | 22 | #TO-DO: move this elsewhere 23 | def debug(fn): 24 | def DEBUG(*args, **kwargs): 25 | logger = logging.getLogger(__name__) 26 | lvl = logger.level if logger.level != logging.DEBUG else None 27 | n = fn.func_name 28 | logger.debug(">> %s(%r, %r)", n, args, kwargs) 29 | ret = fn(*args,**kwargs) 30 | logger.debug(">> %s(%r, %r) -> returned: %r", n, args, kwargs, ret) 31 | if lvl is not None: 32 | logger.level = lvl 33 | return ret 34 | return DEBUG 35 | 36 | 37 | class TaskList(object): 38 | 39 | def __init__(self, *args, **kwargs): 40 | self.log = logging.getLogger(__name__) 41 | self.resources = resources.Resources(__name__) 42 | self._taskkeys = ['erase', 'prepare', 'installapps'] 43 | self.config = self.resources.config 44 | self.file = self.config.file 45 | if kwargs.has_key('timeout'): 46 | self.config.lock.timeout = kwargs['timeout'] 47 | 48 | @property 49 | def record(self): 50 | """ 51 | :returns: dict of contents as read from disk 52 | """ 53 | return self.config.read() 54 | 55 | def get(self, key, exclude=(), only=None): 56 | """ 57 | Retrieve tasked ECIDs (removed from) 58 | 59 | :param string key: name of task 60 | :param exclude: iterable of ECIDs to exclude 61 | excluded ECIDs remain tasked (if present) 62 | :param only: iterable of ECIDs to retrieve 63 | all other ECIDs remain tasked 64 | 65 | :returns: list of ECIDs 66 | """ 67 | with self.config.lock.acquire(): 68 | # get all items as set (or empty list) 69 | current = set(self.config.get(key, [])) 70 | try: 71 | o = set(only) 72 | except TypeError: 73 | o = set() 74 | # only exclude what was there to begin with 75 | excluded = current.intersection(exclude) 76 | # what's left after removing exclusions (if any) 77 | left = current - excluded 78 | # update o from what's left (if anything) 79 | o = o.intersection(left) 80 | # if only was specified, it's what we get (even if empty) 81 | if only is not None: 82 | # remove what was taken 83 | self.config.update({key: list(current - o)}) 84 | return list(o) 85 | if left: 86 | # leave behind any exclusions 87 | self.config.update({key: list(excluded)}) 88 | return list(left) 89 | return [] 90 | 91 | def list(self, key, exclude=(), only=None): 92 | """ 93 | List tasked ECIDs (all ECIDs remain tasked) 94 | 95 | :returns: list of ECIDs 96 | """ 97 | # similar logic to get() except no writing 98 | with self.config.lock.acquire(): 99 | current = set(self.config.get(key, [])) 100 | try: 101 | o = set(only) 102 | except TypeError: 103 | o = set() 104 | excluded = current.intersection(exclude) 105 | left = current - excluded 106 | o = o.intersection(left) 107 | if only is not None: 108 | return list(o) 109 | if left: 110 | return list(left) 111 | return [] 112 | 113 | @debug 114 | def add(self, key, items, exclude=()): 115 | """ 116 | Add items to specified task 117 | 118 | :param string key: name of task 119 | :param iterable items: items to add 120 | :param iterable exclude: ignore items (present or not) 121 | 122 | NOTE: 123 | exclude is useful when working with a generic list 124 | 125 | # only adds (1, 2) 126 | >>> task.add('example', [1, 2, 3], exclude=(3, 4)) 127 | 128 | :returns: None 129 | """ 130 | if not items: 131 | self.log.debug("%s: nothing to add", key) 132 | return 133 | with self.config.lock.acquire(): 134 | if not isinstance(items, (list, set)): 135 | self.log.error("%r: not list or set", items) 136 | raise TypeError("{0!r}: not list or set".format(items)) 137 | _items = set(items).difference(exclude) 138 | if _items: 139 | self.log.debug("adding: %r: %r", key, _items) 140 | try: 141 | self.config.add(key, _items) 142 | except KeyError: 143 | self.config.update({key: list(_items)}) 144 | 145 | def remove(self, ecids, tasks=None, queries=None): 146 | """ 147 | Remove specified items from multiple tasks 148 | 149 | :param iterable items: items to remove 150 | :param iterable tasks: only remove items from specified tasks 151 | :param iterable queries: keys for queries 152 | 153 | if tasks is None, then items are removed from all tasks 154 | 155 | :returns: None 156 | """ 157 | # TO-DO: 158 | # - more tests 159 | # - document 160 | if not ecids: 161 | self.log.debug("nothing specified") 162 | return 163 | with self.config.lock.acquire(): 164 | if not tasks: 165 | # remove all tasks associated with specified ECIDs 166 | for task in self._taskkeys: 167 | self.get(task, only=ecids) 168 | 169 | for q in self.queries(only=ecids): 170 | self.query(q, only=ecids) 171 | else: 172 | # remove ECID's from specified tasks 173 | if tasks: 174 | for task in tasks: 175 | self.get(task, only=ecids) 176 | # remove ECID's from specified queries 177 | if queries: 178 | for q in queries: 179 | self.query(q, only=ecids) 180 | 181 | #TO-DO: rename to 'empty' or all 182 | def alldone(self): 183 | """ 184 | :returns: False if any items are tasked, otherwise True 185 | """ 186 | with self.config.lock.acquire(): 187 | for v in self.record.values(): 188 | if v: 189 | return False 190 | return True 191 | 192 | def queries(self, exclude=(), only=None): 193 | """ 194 | :returns: list of query keys 195 | """ 196 | result = [] 197 | for k in self.list('queries'): 198 | if self.list(k, exclude, only): 199 | result.append(k) 200 | return result 201 | 202 | def query(self, key, ecids=(), exclude=(), only=None): 203 | with self.config.lock.acquire(): 204 | if ecids: 205 | e = set(ecids).difference(exclude) 206 | self.add(key, e) 207 | if self.list(key): 208 | self.add('queries', [key]) 209 | else: 210 | ecids = self.get(key, exclude, only) 211 | if not self.list(key): 212 | # if nothing's left, we can get rid of the query 213 | try: 214 | self.config.remove('queries', key) 215 | self.config.remove(key) 216 | except ValueError: 217 | pass 218 | return ecids 219 | 220 | def erase(self, ecids=(), exclude=(), only=None): 221 | """ 222 | Convenience function for add('erase') and get('erase') 223 | 224 | task.erase(ecids, exclude=[ecid,...]) 225 | == task.add('erase', ecids, exclude=[ecid, ...]) 226 | 227 | task.erase(exclude=[ecid,...]) 228 | == task.get('erase', exclude=[ecid, ...]) 229 | """ 230 | with self.config.lock.acquire(): 231 | if ecids: 232 | self.add('erase', ecids, exclude) 233 | else: 234 | return self.get('erase', exclude, only) 235 | 236 | def prepare(self, ecids=(), exclude=(), only=None): 237 | """ 238 | Convenience function for add('prepare') and get('prepare') 239 | 240 | task.prepare(ecids=[ecid, ...], exclude=[ecid,...]) 241 | == task.add('prepare', ecids, exclude=[ecid, ...]) 242 | 243 | task.prepare(exclude=[ecid,...]) 244 | == task.get('prepare', exclude=[ecid, ...]) 245 | """ 246 | with self.config.lock.acquire(): 247 | if ecids: 248 | self.add('prepare', ecids, exclude) 249 | else: 250 | return self.get('prepare', exclude, only) 251 | 252 | def installapps(self, ecids=(), exclude=(), only=None): 253 | """ 254 | Convenience function for add('installapps') and get('installapps') 255 | 256 | task.installapps(ecids=[ecid, ...], exclude=[ecid,...]) 257 | == task.add('installapps', ecids, exclude=[ecid, ...]) 258 | 259 | task.installapps(exclude=[ecid,...]) 260 | == task.get('installapps', exclude=[ecid, ...]) 261 | """ 262 | with self.config.lock.acquire(): 263 | if ecids: 264 | self.add('installapps', ecids, exclude) 265 | else: 266 | return self.get('installapps', exclude, only) 267 | 268 | 269 | if __name__ == '__main__': 270 | pass 271 | -------------------------------------------------------------------------------- /aeios/device.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | from datetime import datetime, timedelta 5 | 6 | import config 7 | from actools import cfgutil 8 | 9 | """ 10 | Persistant iOS Device Record 11 | """ 12 | 13 | __author__ = 'Sam Forester' 14 | __email__ = 'sam.forester@utah.edu' 15 | __copyright__ = 'Copyright (c) 2019 University of Utah, Marriott Library' 16 | __license__ = 'MIT' 17 | __version__ = "2.8.1" 18 | __all__ = ['Device', 'DeviceError', 'DeviceList'] 19 | 20 | # suppress "No handlers could be found" message 21 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 22 | 23 | 24 | class Error(Exception): 25 | pass 26 | 27 | 28 | class DeviceError(Error): 29 | pass 30 | 31 | 32 | class DeviceList(list): 33 | """ 34 | convenience class for getting singular property from multiple devices 35 | at once 36 | """ 37 | @property 38 | def ecids(self): 39 | """ 40 | :returns: list of device ECIDs 41 | """ 42 | return [x.ecid for x in self] 43 | 44 | @property 45 | def serialnumbers(self): 46 | """ 47 | :returns: list of device serial numbers 48 | """ 49 | return [x.serialnumber for x in self] 50 | 51 | @property 52 | def udids(self): 53 | """ 54 | :returns: list of device UDIDs 55 | """ 56 | return [x.udid for x in self] 57 | 58 | @property 59 | def names(self): 60 | """ 61 | :returns: list of device names 62 | """ 63 | return [x.name for x in self] 64 | 65 | @property 66 | def verified(self): 67 | """ 68 | :returns: DeviceList of verified devices 69 | """ 70 | return DeviceList([x for x in self if x.verified]) 71 | 72 | @property 73 | def unverified(self): 74 | """ 75 | :returns: DeviceList of un-verified devices 76 | """ 77 | return DeviceList([x for x in self if not x.verified]) 78 | 79 | @property 80 | def supervised(self): 81 | """ 82 | :returns: DeviceList of supervised devices 83 | """ 84 | return DeviceList([x for x in self if x.supervised]) 85 | 86 | @property 87 | def unsupervised(self): 88 | """ 89 | :returns: DeviceList of un-supervised devices 90 | """ 91 | return DeviceList([x for x in self if not x.supervised]) 92 | 93 | def __repr__(self): 94 | """ 95 | 'DeviceList(name, name2, ...)' 96 | """ 97 | return "DeviceList({0})".format(self.names) 98 | 99 | def __str__(self): 100 | """ 101 | comma separated names per device e.g. 'name, name2, ...' 102 | """ 103 | return ", ".join(self.names) 104 | 105 | def __eq__(self, x): 106 | if len(self) == len(x): 107 | return sorted(self.ecids) == sorted(x.ecids) 108 | else: 109 | return False 110 | 111 | def __ne__(self, x): 112 | return not self == x 113 | 114 | def __contains__(self, device): 115 | """ 116 | x.__contains__(y) <==> y.ecid in x.ecids 117 | """ 118 | return device.ecid in self.ecids 119 | 120 | 121 | class Device(object): 122 | 123 | def __init__(self, ecid, info=None, **kwargs): 124 | self.log = logging.getLogger(__name__) 125 | 126 | self.config = config.Manager(ecid, **kwargs) 127 | self.file = self.config.file 128 | 129 | try: 130 | self._record = self.config.read() 131 | except config.Error: 132 | if not info: 133 | raise DeviceError("missing device information") 134 | self.config.write(info) 135 | self._record = self.config.read() 136 | except Exception as e: 137 | self.log.exception("unknown error occurred") 138 | raise DeviceError(e) 139 | 140 | if info: 141 | # verify unchanging values 142 | self._verify(info) 143 | # if device info was provided, let's update the record 144 | updatekeys = ['deviceName', 'bootedState', 'buildVersion', 145 | 'firmwareVersion', 'locationID'] 146 | # get k,v from info that are in updatekeys and are not None 147 | updated = {k: info.get(k) for k in updatekeys} 148 | # only update non-null values 149 | self.config.update({k: v for k, v in updated.items() if v}) 150 | 151 | # indelible attributes (if one is missing we are in bad shape) 152 | try: 153 | self._record = self.config.read() 154 | self.ecid = self._record['ECID'] 155 | self.udid = self._record['UDID'] 156 | self.model = self._record['deviceType'] 157 | except KeyError as e: 158 | raise DeviceError("record missing key: {0}".format(e)) 159 | self.serialnumber = self._record.get('serialNumber') 160 | 161 | def __str__(self): 162 | return self.name 163 | 164 | def _verify(self, info): 165 | """ 166 | Verify device fidelity 167 | 168 | :raises: DeviceError if ECID, deviceType, or serialNumber change 169 | """ 170 | for k in ['ECID', 'deviceType', 'serialNumber']: 171 | try: 172 | p = info[k] 173 | r = self._record[k] 174 | if p != r: 175 | err = ("static device key mismatch: " 176 | "{0!r} != {1!r}".format(r, p)) 177 | raise DeviceError(err) 178 | except KeyError: 179 | pass 180 | 181 | def _timestamp(self, key, value): 182 | """ 183 | Generic timestamp setter for various attributes 184 | """ 185 | if value is not None: 186 | if not isinstance(value, datetime): 187 | raise TypeError("invalid datetime: {0}".format(value)) 188 | self.config.update({key: value}) 189 | else: 190 | return self.delete(key) 191 | 192 | def delete(self, key): 193 | try: 194 | return self.config.delete(key) 195 | except: 196 | return None 197 | 198 | def update(self, key, value): 199 | _attrmap = {'serialNumber': 'serialnumber'} 200 | attribute = _attrmap.get(key) 201 | if attribute: 202 | setattr(self, attribute, value) 203 | self.config.update({key: value}) 204 | 205 | def updateall(self, info): 206 | _attrmap = {'serialNumber': 'serialnumber'} 207 | for k, a in _attrmap.items(): 208 | if k in info.keys(): 209 | setattr(self, a, info[k]) 210 | self.config.update(info) 211 | 212 | @property 213 | def verified(self): 214 | return self.config.setdefault('verified', False) 215 | 216 | @verified.setter 217 | def verified(self, value): 218 | if not isinstance(value, bool): 219 | raise TypeError("{0}: not boolean".format(value)) 220 | self.config.update({'verified': value}) 221 | 222 | @property 223 | def record(self): 224 | return self.config.read() 225 | 226 | @property 227 | def name(self): 228 | """ 229 | reads name from configuration file, if it is missing 230 | then the deviceName is given (as long as the device name) 231 | doesn't start with 'i' ('iPad (1)', 'iPad', 'iPhone') 232 | it is written to the configuration file 233 | """ 234 | _name = self.config.get('name') 235 | if not _name: 236 | self.log.debug("no name was found...") 237 | _name = self.config.get('deviceName', _name) 238 | # default 'iPhone', 'iPad', etc 239 | if _name and not _name.startswith('i'): 240 | self.log.debug('saving name: {0}'.format(_name)) 241 | self.config.update({'name': _name}) 242 | else: 243 | _name += " ({0})".format(self.ecid) 244 | return _name 245 | 246 | @name.setter 247 | def name(self, new): 248 | """ 249 | Rename devices using actools.cfgutil 250 | """ 251 | if self.name != new: 252 | if not self._testing: 253 | cfgutil.rename(self.ecid, new) 254 | self.config.update({'name': new}) 255 | 256 | @property 257 | def restarting(self): 258 | _restarting = self.config.setdefault('restarting', False) 259 | if _restarting: 260 | restarted = self.config.get('restarted') 261 | try: 262 | if (datetime.now() - restarted) > timedelta(minutes=5): 263 | self.restarting = False 264 | _restarting = False 265 | except TypeError: 266 | self.restarting = False 267 | _restarting = False 268 | return _restarting 269 | 270 | @restarting.setter 271 | def restarting(self, value): 272 | if not isinstance(value, bool): 273 | raise TypeError("{0}: not boolean".format(value)) 274 | _value = {'restarting': value} 275 | if value: 276 | # mark the time the device was restarted 277 | _value['restarted'] = datetime.now() 278 | self.config.update(_value) 279 | 280 | @property 281 | def enrolled(self): 282 | return self.config.get('enrolled') 283 | 284 | @enrolled.setter 285 | def enrolled(self, timestamp): 286 | self._timestamp('enrolled', timestamp) 287 | 288 | @property 289 | def managed(self): 290 | return self.config.get('managed', False) 291 | 292 | @managed.setter 293 | def managed(self, value): 294 | if not isinstance(value, bool): 295 | raise TypeError('not boolean: {0}'.format(value)) 296 | self.config.update({'managed': value}) 297 | 298 | @property 299 | def checkin(self): 300 | return self.config.get('checkin') 301 | 302 | @checkin.setter 303 | def checkin(self, timestamp): 304 | self._timestamp('checkin', timestamp) 305 | 306 | @property 307 | def checkout(self): 308 | return self.config.get('checkout') 309 | 310 | @checkout.setter 311 | def checkout(self, timestamp): 312 | self._timestamp('checkout', timestamp) 313 | 314 | @property 315 | def supervised(self): 316 | return self.config.setdefault('isSupervised', False) 317 | 318 | @supervised.setter 319 | def supervised(self, value): 320 | if not isinstance(value, bool): 321 | raise TypeError('not boolean: {0}'.format(value)) 322 | self.config.update({'isSupervised': value}) 323 | 324 | @property 325 | def erased(self): 326 | return self.config.get('erased') 327 | 328 | @erased.setter 329 | def erased(self, timestamp): 330 | self._timestamp('erased', timestamp) 331 | _reset = ['background', 'apps', 'enrolled', 'isSupervised', 332 | 'installedApps', 'verified'] 333 | self.config.deletekeys(_reset) 334 | 335 | @property 336 | def locked(self): 337 | return self.config.get('locked') 338 | 339 | @locked.setter 340 | def locked(self, timestamp): 341 | self._timestamp('locked', timestamp) 342 | 343 | @property 344 | def apps(self): 345 | return self.config.setdefault('installedApps', []) 346 | 347 | @apps.setter 348 | def apps(self, applist): 349 | try: 350 | self.config.reset('installedApps', list(applist)) 351 | except: 352 | self.config.update({'installedApps': applist}) 353 | 354 | @property 355 | def background(self): 356 | return self.config.get('background') 357 | 358 | @background.setter 359 | def background(self, name): 360 | if name: 361 | if not isinstance(name, str): 362 | raise TypeError("invalid background: {0}".format(name)) 363 | self.config.update({'background': name}) 364 | else: 365 | try: 366 | self.config.delete('background') 367 | except: 368 | pass 369 | 370 | 371 | if __name__ == '__main__': 372 | pass 373 | -------------------------------------------------------------------------------- /tests/test_resources.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import shutil 5 | import logging 6 | import unittest 7 | 8 | from aeios import resources 9 | 10 | """ 11 | Tests for aeios.resources 12 | """ 13 | 14 | __author__ = 'Sam Forester' 15 | __email__ = 'sam.forester@utah.edu' 16 | __copyright__ = 'Copyright (c) 2019 University of Utah, Marriott Library' 17 | __license__ = 'MIT' 18 | __version__ = "1.0.0" 19 | 20 | LOCATION = os.path.dirname(__file__) 21 | DATA = os.path.join(LOCATION, 'data', 'resources') 22 | TMP = os.path.join(LOCATION, 'tmp') 23 | TMPDIR = os.path.join(TMP, 'resources') 24 | 25 | 26 | def setUpModule(): 27 | """ 28 | create tmp directory 29 | """ 30 | try: 31 | os.makedirs(TMPDIR) 32 | except OSError as e: 33 | if e.errno != 17: 34 | # raise Exception unless TMP already exists 35 | raise 36 | 37 | # modify module constants 38 | resources.PREFERENCES = TMPDIR 39 | resources.PATH = TMPDIR 40 | 41 | 42 | def tearDownModule(): 43 | """ 44 | remove tmp directory 45 | """ 46 | shutil.rmtree(TMPDIR) 47 | 48 | 49 | class BaseTestCase(unittest.TestCase): 50 | pass 51 | 52 | 53 | class DefaultsTestCase(BaseTestCase): 54 | 55 | @classmethod 56 | def setUpClass(cls): 57 | BaseTestCase.setUpClass() 58 | 59 | @classmethod 60 | def tearDownClass(cls): 61 | BaseTestCase.tearDownClass() 62 | 63 | def setUp(self): 64 | BaseTestCase.setUp(self) 65 | self.defaults = resources.Defaults() 66 | 67 | def tearDown(self): 68 | BaseTestCase.tearDown(self) 69 | 70 | def test_apps(self): 71 | self.assertTrue(hasattr(self.defaults, 'apps')) 72 | 73 | def test_aeios(self): 74 | self.assertTrue(hasattr(self.defaults, 'aeios')) 75 | 76 | def test_devices(self): 77 | self.assertTrue(hasattr(self.defaults, 'devices')) 78 | 79 | def test_tasks(self): 80 | self.assertTrue(hasattr(self.defaults, 'tasks')) 81 | 82 | def test_reporting(self): 83 | self.assertTrue(hasattr(self.defaults, 'reporting')) 84 | 85 | 86 | class TestFindDefault(DefaultsTestCase): 87 | 88 | def test_find_devices(self): 89 | n = self.defaults.find('devices') 90 | _id = self.defaults.find('aeios.devices') 91 | self.assertIsNotNone(n) 92 | self.assertItemsEqual(n, _id) 93 | 94 | def test_find_apps(self): 95 | n = self.defaults.find('apps') 96 | _id = self.defaults.find('aeios.apps') 97 | self.assertIsNotNone(n) 98 | self.assertItemsEqual(n, _id) 99 | 100 | def test_find_reporting(self): 101 | n = self.defaults.find('reporting') 102 | _id = self.defaults.find('aeios.reporting') 103 | self.assertIsNotNone(n) 104 | self.assertItemsEqual(n, _id) 105 | 106 | def test_find_tasks(self): 107 | n = self.defaults.find('tasks') 108 | _id = self.defaults.find('aeios.tasks') 109 | self.assertIsNotNone(n) 110 | self.assertItemsEqual(n, _id) 111 | 112 | def test_find_preferences(self): 113 | n = self.defaults.find('preferences') 114 | _id = self.defaults.find('aeios.preferences') 115 | self.assertIsNotNone(n) 116 | self.assertItemsEqual(n, _id) 117 | self.assertEquals(resources.PREFERENCES, n) 118 | 119 | def test_find_path(self): 120 | n = self.defaults.find('path') 121 | _id = self.defaults.find('aeios.path') 122 | self.assertIsNotNone(n) 123 | self.assertItemsEqual(n, _id) 124 | self.assertEquals(resources.PATH, n) 125 | 126 | def test_find_nothing(self): 127 | with self.assertRaises(TypeError): 128 | n = self.defaults.find() 129 | 130 | def test_find_empty_string(self): 131 | with self.assertRaises(resources.MissingDefault): 132 | n = self.defaults.find('') 133 | 134 | def test_find_None(self): 135 | with self.assertRaises(AttributeError): 136 | n = self.defaults.find(None) 137 | 138 | def test_find_no_default(self): 139 | with self.assertRaises(resources.MissingDefault): 140 | n = self.defaults.find('aeios.unknown') 141 | 142 | 143 | class ResourcesTestCase(BaseTestCase): 144 | """ 145 | Common tests for all resources.Resources() 146 | """ 147 | @classmethod 148 | def setUpClass(cls): 149 | BaseTestCase.setUpClass() 150 | cls.defaults = {} 151 | cls.directories = [] 152 | for d in resources.DIRECTORIES: 153 | cls.directories.append(os.path.join(TMPDIR, d)) 154 | 155 | @classmethod 156 | def tearDownClass(cls): 157 | BaseTestCase.tearDownClass() 158 | for d in cls.directories: 159 | os.rmdir(d) 160 | 161 | def setUp(self): 162 | BaseTestCase.setUp(self) 163 | resources.DEFAULT.test = {} 164 | self.resources = resources.Resources('test', path=TMPDIR) 165 | 166 | def tearDown(self): 167 | BaseTestCase.tearDown(self) 168 | try: 169 | del(resources.DEFAULT.test) 170 | except AttributeError: 171 | pass 172 | 173 | prefs = self.resources.preferences.file 174 | os.remove(prefs) 175 | self.assertFalse(os.path.exists(prefs)) 176 | 177 | conf = self.resources.config.file 178 | os.remove(conf) 179 | self.assertFalse(os.path.exists(conf)) 180 | 181 | def assertDomain(self, domain): 182 | d = "{0}.{1}".format(resources.DOMAIN, domain) 183 | self.assertEquals(self.resources.domain, d) 184 | # self.assertTrue(self.resources.domain.endswith(domain)) 185 | 186 | def test_config_attr(self): 187 | self.assertTrue(hasattr(self.resources, 'config')) 188 | 189 | def test_preferences_attr(self): 190 | self.assertTrue(hasattr(self.resources, 'preferences')) 191 | 192 | def test_domain_attr(self): 193 | self.assertTrue(hasattr(self.resources, 'domain')) 194 | 195 | def test_idle_attr(self): 196 | self.assertTrue(hasattr(self.resources, 'idle')) 197 | 198 | def test_path_attr(self): 199 | self.assertTrue(hasattr(self.resources, 'path')) 200 | 201 | def test_config_exists(self): 202 | self.assertTrue(os.path.exists(self.resources.config.file)) 203 | 204 | def test_preferences_exists(self): 205 | self.assertTrue(os.path.exists(self.resources.preferences.file)) 206 | 207 | def test_paths_created(self): 208 | for d in self.resources.directories: 209 | self.assertTrue(os.path.isdir(d)) 210 | 211 | 212 | class TestResourcesInitializaion(ResourcesTestCase): 213 | 214 | def test_no_default_conf(self): 215 | del(resources.DEFAULT.test) 216 | with self.assertRaises(resources.MissingDefault): 217 | r = resources.Resources('test', path=TMPDIR) 218 | 219 | def test_init_no_path(self): 220 | r = resources.Resources('test') 221 | self.assertEquals(r.domain, self.resources.domain) 222 | self.assertEquals(r.path, self.resources.path) 223 | self.assertEquals(r.config.file, self.resources.config.file) 224 | self.assertEquals(r.preferences.file, self.resources.preferences.file) 225 | 226 | def test_default_conf(self): 227 | self.assertTrue(os.path.exists(self.resources.config.file)) 228 | 229 | def test_default_prefs_exists(self): 230 | path = self.resources.preferences.file 231 | self.assertTrue(os.path.exists(path)) 232 | 233 | def test_default_prefs_value(self): 234 | expected = resources.DEFAULT.aeios 235 | result = self.resources.preferences.read() 236 | self.assertItemsEqual(result, expected) 237 | 238 | 239 | class TestResourceProperties(ResourcesTestCase): 240 | 241 | def test_wifi_attr(self): 242 | self.assertTrue(hasattr(self.resources, 'wifi')) 243 | 244 | def test_wifi_value(self): 245 | expected = os.path.join(TMPDIR, 'Profiles', 'wifi.mobileconfig') 246 | self.assertEquals(self.resources.wifi, expected) 247 | 248 | def test_key_attr(self): 249 | self.assertTrue(hasattr(self.resources, 'key')) 250 | 251 | def test_key_value(self): 252 | expected = os.path.join(TMPDIR, 'Supervision', 'identity.der') 253 | self.assertEquals(self.resources.key, expected) 254 | 255 | def test_key_attr(self): 256 | self.assertTrue(hasattr(self.resources, 'cert')) 257 | 258 | def test_key_value(self): 259 | expected = os.path.join(TMPDIR, 'Supervision', 'identity.crt') 260 | self.assertEquals(self.resources.cert, expected) 261 | 262 | 263 | class TestDevicesResource(ResourcesTestCase): 264 | 265 | def setUp(self): 266 | super(ResourcesTestCase, self).setUp() 267 | self.name = 'aeios.devices' 268 | self.resources = resources.Resources(self.name) 269 | 270 | def test_domain(self): 271 | self.assertDomain(self.name) 272 | 273 | def test_config(self): 274 | plist = "{0}.plist".format(self.resources.domain) 275 | path = os.path.join(TMPDIR, plist) 276 | self.assertEquals(path, self.resources.config.file) 277 | 278 | def test_config_values(self): 279 | data = self.resources.config.read() 280 | defaults = resources.DEFAULT.find(self.resources.domain) 281 | self.assertItemsEqual(data, defaults) 282 | 283 | 284 | class TestAppsResources(ResourcesTestCase): 285 | 286 | def setUp(self): 287 | super(ResourcesTestCase, self).setUp() 288 | self.name = 'aeios.apps' 289 | self.resources = resources.Resources(self.name) 290 | 291 | def test_domain(self): 292 | self.assertDomain(self.name) 293 | 294 | def test_config(self): 295 | plist = "{0}.plist".format(self.resources.domain) 296 | path = os.path.join(TMPDIR, plist) 297 | self.assertEquals(path, self.resources.config.file) 298 | 299 | def test_config_values(self): 300 | data = self.resources.config.read() 301 | defaults = resources.DEFAULT.find(self.resources.domain) 302 | self.assertItemsEqual(data, defaults) 303 | 304 | 305 | 306 | class TestIdle(ResourcesTestCase): 307 | 308 | def test_attr(self): 309 | self.assertTrue(hasattr(self.resources, 'idle')) 310 | 311 | def test_default_value(self): 312 | expected = resources.DEFAULT.aeios['Idle'] 313 | self.assertEquals(self.resources.idle(), expected) 314 | 315 | def test_modified_value(self): 316 | expected = 30 317 | modified = self.resources.idle(expected) 318 | self.assertEquals(modified, expected) 319 | prefs = self.resources.preferences 320 | self.assertEquals(prefs.get('Idle'), expected) 321 | 322 | 323 | 324 | class TestReporting(ResourcesTestCase): 325 | 326 | def setUp(self): 327 | ResourcesTestCase.setUp(self) 328 | self.data = {'Slack': {'URL': 'https://url.test', 329 | 'channel': '#test-channel', 330 | 'name': 'test-name'}} 331 | 332 | def test_attr(self): 333 | self.assertTrue(hasattr(self.resources, 'reporting')) 334 | 335 | def test_default_value(self): 336 | default = resources.DEFAULT.reporting 337 | self.assertItemsEqual(self.resources.reporting(), default) 338 | 339 | def test_modified_value(self): 340 | result = self.resources.reporting(self.data) 341 | self.assertEquals(self.resources.reporting(), result) 342 | 343 | def test_modified_values(self): 344 | """ 345 | test default data is returned 346 | """ 347 | result = self.resources.reporting(self.data) 348 | self.assertItemsEqual(result, self.data) 349 | self.assertItemsEqual(self.resources.reporting(), self.data) 350 | 351 | def test_update(self): 352 | new = {'Slack': {'URL': 'https://new.url', 353 | 'channel': '#new-channel', 354 | 'name': 'new-name'}} 355 | modified = self.resources.reporting(new) 356 | self.assertItemsEqual(modified, new) 357 | self.assertItemsEqual(self.resources.reporting(), new) 358 | 359 | def test_same_reporter(self): 360 | first = self.resources.reporter 361 | second = self.resources.reporter 362 | self.assertIs(first, second) 363 | 364 | 365 | if __name__ == '__main__': 366 | fmt = ('%(asctime)s %(process)d: %(levelname)6s: ' 367 | '%(name)s - %(funcName)s(): %(message)s') 368 | # logging.basicConfig(format=fmt, level=logging.DEBUG) 369 | # logging.getLogger('aeios.resources.Resources').setLevel(logging.DEBUG) 370 | 371 | unittest.main(verbosity=1) 372 | 373 | -------------------------------------------------------------------------------- /aeios/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import plistlib 3 | # import filelock 4 | import logging 5 | import fcntl 6 | import threading 7 | import time 8 | import xml.parsers.expat 9 | 10 | """ 11 | Persistant Configuration 12 | """ 13 | 14 | __author__ = 'Sam Forester' 15 | __email__ = 'sam.forester@utah.edu' 16 | __copyright__ = 'Copyright (c) 2019 University of Utah, Marriott Library' 17 | __license__ = 'MIT' 18 | __version__ = "1.3.1" 19 | 20 | # suppress "No handlers could be found" message 21 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 22 | 23 | __all__ = [ 24 | 'Manager', 25 | 'FileLock', 26 | 'TimeoutError', 27 | 'ConfigError' 28 | ] 29 | 30 | class Error(Exception): 31 | pass 32 | 33 | 34 | class ConfigError(Error): 35 | pass 36 | 37 | 38 | class Missing(Error): 39 | pass 40 | 41 | 42 | class TimeoutError(Error): 43 | """ 44 | Raised when lock could not be acquired before timeout 45 | """ 46 | def __init__(self, lockfile): 47 | self.file = lockfile 48 | 49 | def __str__(self): 50 | return "{0}: lock could not be acquired".format(self.file) 51 | 52 | 53 | class ReturnProxy(object): 54 | """ 55 | Wrap the lock to make sure __enter__ is not called twice 56 | when entering the with statement. 57 | 58 | If we would simply return *self*, the lock would be acquired 59 | again in the *__enter__* method of the BaseFileLock, 60 | but not released again automatically. 61 | (Not sure if this is pertinant, but it definitely breaks without it) 62 | """ 63 | def __init__(self, lock): 64 | self.lock = lock 65 | def __enter__(self): 66 | return self.lock 67 | def __exit__(self, exc_type, exc_value, traceback): 68 | self.lock.release() 69 | 70 | 71 | class FileLock(object): 72 | """ 73 | Unix filelocking 74 | Adapted from py-filelock, by Benedikt Schmitt 75 | https://github.com/benediktschmitt/py-filelock 76 | """ 77 | def __init__(self, file, timeout=-1): 78 | self._file = file 79 | self._fd = None 80 | self._timeout = timeout 81 | self._thread_lock = threading.Lock() 82 | self._counter = 0 83 | 84 | @property 85 | def file(self): 86 | """ 87 | :returns: lockfile path 88 | """ 89 | return self._file 90 | 91 | @property 92 | def timeout(self): 93 | """ 94 | :returns: value (in seconds) of the timeout 95 | """ 96 | return self._timeout 97 | 98 | @timeout.setter 99 | def timeout(self, value): 100 | """ 101 | Seconds to wait before raising TimeoutError() 102 | a negative timeout will disable the timeout 103 | a timeout of 0 will allow for one attempt acquire the lock 104 | """ 105 | self._timeout = float(value) 106 | 107 | @property 108 | def locked(self): 109 | """ 110 | :returns: True, if the object holds the file lock, else False 111 | """ 112 | return self._fd is not None 113 | 114 | def _acquire(self): 115 | """ 116 | Unix based locking using fcntl.flock(LOCK_EX | LOCK_NB) 117 | """ 118 | flags = os.O_RDWR | os.O_CREAT | os.O_TRUNC 119 | fd = os.open(self._file, flags, 0644) 120 | try: 121 | fcntl.flock(fd, fcntl.LOCK_EX|fcntl.LOCK_NB) 122 | self._fd = fd 123 | except (IOError, OSError): 124 | os.close(fd) 125 | 126 | def _release(self): 127 | """ 128 | Unix based unlocking using fcntl.flock(LOCK_UN) 129 | """ 130 | fcntl.flock(self._fd, fcntl.LOCK_UN) 131 | os.close(self._fd) 132 | self._fd = None 133 | 134 | def acquire(self, timeout=None, poll_intervall=0.05): 135 | if not timeout: 136 | timeout = self.timeout 137 | with self._thread_lock: 138 | self._counter += 1 139 | 140 | start = time.time() 141 | try: 142 | while True: 143 | with self._thread_lock: 144 | if not self.locked: 145 | self._acquire() 146 | if self.locked: 147 | break 148 | elif timeout >= 0 and (time.time() - start) > timeout: 149 | raise TimeoutError(self._file) 150 | else: 151 | time.sleep(poll_intervall) 152 | except: 153 | with self._thread_lock: 154 | self._counter = max(0, self._counter-1) 155 | raise 156 | 157 | return ReturnProxy(lock=self) 158 | 159 | def release(self, force=False): 160 | """ 161 | Release the lock. 162 | 163 | Note, that the lock is only completly released, if the 164 | lock counter is 0 165 | 166 | lockfile is not automatically deleted. 167 | 168 | :arg bool force: 169 | If true, the lock counter is ignored and the lock is 170 | released in every case. 171 | """ 172 | with self._thread_lock: 173 | if self.locked: 174 | self._counter -= 1 175 | if self._counter == 0 or force: 176 | self._release() 177 | self._counter = 0 178 | 179 | def __enter__(self): 180 | self.acquire() 181 | 182 | def __exit__(self, exc_type, exc_value, traceback): 183 | self.release() 184 | 185 | def __del__(self): 186 | self.release(force=True) 187 | 188 | 189 | class Manager(object): 190 | """ 191 | This class is meant to allow scripts to read and serialize 192 | configuration files. 193 | 194 | The configuration files themselves are modified via filelocking to 195 | prevent them from being mangled when being accessed by multiple 196 | scripts. 197 | 198 | :param id: the configuration identifier 199 | :type id: str 200 | 201 | EXAMPLE: 202 | conf = config.Manager("foo") # initializes the config manager 203 | try: 204 | settings = conf.read() # read the config file 205 | except config.Error: 206 | settings = {} 207 | 208 | settings['foo'] = 'bar' 209 | 210 | conf.write(settings) # serialize the modified settings 211 | 212 | All serialization files will be written to: 213 | /user/specified/directory (path specified at instantiation) 214 | /Library/Management/Configuration 215 | ~/Library/Management/Configuration 216 | """ 217 | TMP = '/tmp/config' 218 | 219 | def __init__(self, id, path=None, logger=None, **kwargs): 220 | """ 221 | Setup the configuration manager. Checks to make sure a 222 | configuration directory exists (creates directory if not) 223 | """ 224 | if not logger: 225 | logger = logging.getLogger(__name__) 226 | logger.addHandler(logging.NullHandler()) 227 | self.log = logger 228 | lockdir = self.__class__.TMP 229 | if not os.path.exists(lockdir): 230 | os.mkdir(lockdir) 231 | management = 'Library/Management/Configuration' 232 | homefolder = os.path.expanduser('~') 233 | directories = [os.path.join('/', management), 234 | os.path.join(homefolder, management)] 235 | if path: 236 | if os.path.isfile(path): 237 | raise TypeError("not a directory: {0}".format(path)) 238 | try: 239 | dir = check_and_create_directories([path]) 240 | except ConfigError as e: 241 | if os.path.isdir(path) and os.access(path, os.R_OK): 242 | dir = path 243 | else: 244 | raise e 245 | 246 | else: 247 | # create the config directory if it doesn't exist 248 | dir = check_and_create_directories(directories) 249 | self.file = os.path.join(dir, "{0}.plist".format(id)) 250 | ## create a lockfile to block race conditions 251 | self.lockfile = "{0}/{1}.lockfile".format(lockdir, id) 252 | # self.lock = filelock.FileLock(self.lockfile, **kwargs) 253 | self.lock = FileLock(self.lockfile, **kwargs) 254 | 255 | 256 | def write(self, data): 257 | """ 258 | Serializes specified settings to file 259 | """ 260 | with self.lock.acquire(): 261 | plistlib.writePlist(data, self.file) 262 | 263 | def read(self): 264 | """ 265 | :returns: data structure (list|dict) as read from disk 266 | :raises: ConfigError if unable to read 267 | """ 268 | if not os.path.exists(self.file): 269 | raise Missing("file missing: {0}".format(self.file)) 270 | 271 | try: 272 | with self.lock.acquire(): 273 | return plistlib.readPlist(self.file) 274 | except xml.parsers.expat.ExpatError: 275 | raise ConfigError("corrupted plist: {0}".format(self.file)) 276 | 277 | # TYPE SPECIFIC FUNCTIONS 278 | def get(self, key, default=None): 279 | with self.lock.acquire(): 280 | data = self.read() 281 | return data.get(key, default) 282 | 283 | def update(self, value): 284 | """ 285 | read data from file, update data, and write back to file 286 | """ 287 | with self.lock.acquire(): 288 | data = self.read() 289 | data.update(value) 290 | self.write(data) 291 | return data 292 | 293 | def delete(self, key): 294 | """ 295 | read data from file, update data, and write back to file 296 | """ 297 | with self.lock.acquire(): 298 | data = self.read() 299 | v = data.pop(key) 300 | self.write(data) 301 | return v 302 | 303 | def deletekeys(self, keys): 304 | """ 305 | remove specified keys from file (if they exist) 306 | returns old values as dictionary 307 | """ 308 | with self.lock.acquire(): 309 | data = self.read() 310 | _old = {} 311 | for key in keys: 312 | try: 313 | _old[key] = data.pop(key) 314 | except KeyError: 315 | pass 316 | self.write(data) 317 | return _old 318 | 319 | # EXPERIMENTAL 320 | def reset(self, key, value): 321 | """ 322 | this is poor design, but I'm going to leave it for now 323 | overwrites existing key with value 324 | returns previous value 325 | """ 326 | with self.lock.acquire(): 327 | data = self.read() 328 | previous = data[key] 329 | data[key] = value 330 | self.write(data) 331 | return previous 332 | 333 | def append(self, value): 334 | with self.lock.acquire(): 335 | data = self.read() 336 | data.append(value) 337 | self.write(data) 338 | return data 339 | 340 | def remove(self, key, value=None): 341 | with self.lock.acquire(): 342 | data = self.read() 343 | if value: 344 | if isinstance(data[key], list): 345 | data[key].remove(value) 346 | elif isinstance(data[key], dict): 347 | data[key].pop(value) 348 | elif value is None: 349 | del(data[key]) 350 | else: 351 | if isinstance(data, list): 352 | data.remove(value) 353 | elif isinstance(data, dict): 354 | data.pop(value) 355 | 356 | self.write(data) 357 | 358 | def add(self, key, value): 359 | with self.lock.acquire(): 360 | data = self.read() 361 | try: 362 | for i in value: 363 | if i not in data[key]: 364 | data[key].append(i) 365 | # TO-DO: Is there a reason I'm catching KeyError specifically? 366 | except: 367 | data[key].append(value) 368 | self.write(data) 369 | 370 | def setdefault(self, key, default=None): 371 | with self.lock.acquire(): 372 | data = self.read() 373 | try: 374 | return data[key] 375 | except KeyError: 376 | data[key] = default 377 | if default is not None: 378 | self.write(data) 379 | return default 380 | 381 | 382 | def check_and_create_directories(dirs, mode=0755): 383 | """ 384 | checks list of directories to see what would be a suitable place 385 | to write the configuration file 386 | """ 387 | for path in dirs: 388 | try: 389 | os.makedirs(path, mode) 390 | return path 391 | except OSError as e: 392 | if e.errno == 17 and os.access(path, os.W_OK): 393 | # directory already exists and is writable 394 | return path 395 | ## exhausted all options 396 | raise ConfigError("no suitable directory was found for config") 397 | 398 | 399 | if __name__ == '__main__': 400 | pass 401 | 402 | -------------------------------------------------------------------------------- /actools/cfgutil.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import json 5 | import stat 6 | import logging 7 | import subprocess 8 | 9 | """ 10 | Execute commands with `cfgutil` 11 | """ 12 | 13 | __author__ = 'Sam Forester' 14 | __email__ = 'sam.forester@utah.edu' 15 | __copyright__ = 'Copyright (c) 2019 University of Utah, Marriott Library' 16 | __license__ = 'MIT' 17 | __version__ = "2.5.1" 18 | 19 | # suppress "No handlers could be found" message 20 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 21 | 22 | CFGUTILBIN = '/usr/local/bin/cfgutil' 23 | 24 | # record raw execution info (cfgutil.log = '/path/to/execution.log') 25 | log = None 26 | 27 | 28 | class Error(Exception): 29 | pass 30 | 31 | 32 | class AuthenticationError(Error): 33 | """ 34 | Raised when incorrect Authentication was provided 35 | OR when Authentication required and None or incorrect 36 | """ 37 | pass 38 | 39 | 40 | class CfgutilError(Error): 41 | '''Raised when execution of cfgutil partially fails 42 | ''' 43 | def __init__(self, info, msg='', cmd=None): 44 | self.command = info.get('Command', cmd) 45 | self.message = info.get('Message', msg) 46 | self.code = info.get('Code', 61) # ENODATA: 'No data available' 47 | self.domain = info.get('Domain', '') 48 | self.reason = info.get('FailureReason', '') 49 | self.detail = info.get('Detail', '') 50 | self.affected = info.get('AffectedDevices', []) 51 | self.unaffected = info.get('UnaffectedDevices', []) 52 | self.ecids = self.affected + self.unaffected 53 | 54 | def __str__(self): 55 | # [": "] + " ()" + [": devices: "] 56 | _str = "{0} ({1})".format(self.message, self.code) 57 | if self.command: 58 | _str = "{0}: {1}".format(self.command, _str) 59 | if self.affected: 60 | _str += ": devices: {0}".format(self.affected) 61 | return _str 62 | 63 | def __repr__(self): 64 | # include attributes with values 65 | _repr = '<{0}.{1} object at 0x{2:x} {3}>' 66 | _dict = {k:v for k,v in self.__dict__.items() if v} 67 | return _repr.format(__name__, 'Error', id(self), _dict) 68 | 69 | 70 | class FatalError(CfgutilError): 71 | """ 72 | Raised when execution of cfgutil completely fails 73 | """ 74 | pass 75 | 76 | 77 | class Result(object): 78 | 79 | def __init__(self, cfgout, ecids=(), err=(), cmd=()): 80 | self._output = cfgout 81 | self.cmdargs = cmd 82 | self.command = cfgout.get('Command', '') 83 | self.ecids = cfgout.get('Devices', []) 84 | self.output = cfgout.get('Output', {}) 85 | self.missing = [x for x in ecids if x not in self.ecids] 86 | 87 | def get(self, ecid, default=None): 88 | return self.output.get(ecid, default) 89 | 90 | 91 | class Authentication(object): 92 | 93 | def __init__(self, key, cert): 94 | self.log = logging.getLogger(__name__ + '.Authentication') 95 | ## verify each file 96 | for file in (key, cert): 97 | self._verify(file) 98 | self.key = key 99 | self.cert = cert 100 | 101 | def _verify(self, file): 102 | """ 103 | verify file exists and has the correct permissions 104 | """ 105 | self.log.debug("verifying: %r", file) 106 | if not os.path.exists(file): 107 | self.log.error("no such file: %r", file) 108 | raise AuthenticationError(e) 109 | ## check file permissions are 0600 ~ '-rw-------' 110 | mode = stat.S_IMODE(os.stat(file).st_mode) 111 | if mode != (stat.S_IREAD|stat.S_IWRITE): 112 | e = "invalid permissions: {0:04do}: {1}".format(mode, file) 113 | self.log.error(e) 114 | raise AuthenticationError(e) 115 | self.log.debug("verified: %r", file) 116 | 117 | def args(self): 118 | """ 119 | :return: list of arguments for cfgutil() 120 | """ 121 | return ['-C', self.cert, '-K', self.key] 122 | 123 | 124 | def requires_authentication(subcmd): 125 | """ 126 | :return: True if specifed subcommand requires authentication 127 | """ 128 | cmds = ['add-tags', 'activate', 'get-unlock-token', 129 | 'install-app', 'install-profile', 130 | 'remove-profile', 'restart', 'restore', 131 | 'restore-backup', 'shut-down', 'wallpaper'] 132 | if subcmd in cmds: 133 | return True 134 | else: 135 | return False 136 | 137 | 138 | def _record(file, info): 139 | """ 140 | Record raw data to specified file 141 | 142 | :param path: path to file 143 | :param info: dict of info to record 144 | :return: None 145 | """ 146 | logger = logging.getLogger(__name__) 147 | logger.debug("recording execution to: %r", file) 148 | 149 | if not os.path.exists(file): 150 | try: 151 | dir = os.path.dirname(file) 152 | os.makedirs(os.path.dirname(file)) 153 | except OSError as e: 154 | if e.errno != 17 or not os.path.isdir(dir): 155 | logger.error(e) 156 | raise e 157 | with open(file, 'w+') as f: 158 | f.write("{0}\n".format(info)) 159 | else: 160 | with open(file, 'a+') as f: 161 | f.write("{0}\n".format(info)) 162 | 163 | 164 | def erase(ecids, auth=None): 165 | """ 166 | Erase devices 167 | 168 | :param ecids: iterable of ECIDs 169 | :param auth: Authentication object 170 | :returns: cfgutil.Result 171 | """ 172 | if not ecids: 173 | raise ValueError('no ECIDs specified') 174 | return cfgutil('erase', ecids, [], auth) 175 | 176 | 177 | def get(keys, ecids): 178 | """ 179 | Get information about from specified ECIDs 180 | 181 | :param keys: list of property keys supported by `cfgutil` 182 | :param ecids: list of ECIDs 183 | :returns: cfgutil.Result 184 | """ 185 | if not ecids: 186 | raise ValueError('no ECIDs specified') 187 | return cfgutil('get', ecids, keys) 188 | 189 | 190 | # TO-DO: this should be changed to list_devices 191 | def list(ecids=None): 192 | """ 193 | Get connected devices 194 | 195 | :returns: list of dicts for attached devices 196 | 197 | Each dict will have the following keys defined: 198 | UDID, ECID, name, deviceType, locationID 199 | 200 | e.g.: 201 | >>> cfgutil.list() 202 | [{'ECID': '0x123456789ABCD0', 203 | 'UDID': 'a0111222333444555666777888999abcdefabcde', 204 | 'deviceType': 'iPad7,5', 205 | 'locationID': 337920512, 206 | 'name': 'checkout-ipad-1'}, 207 | {'ECID': '0x123456789ABCD1', 208 | 'UDID': 'a1111222333444555666777888999abcdefabcde', 209 | 'deviceType': 'iPad8,1', 210 | 'locationID': 337907712, 211 | 'name': 'checkout-ipad-2'}, ...] 212 | """ 213 | _ecids = ecids if ecids else [] 214 | result = cfgutil('list', _ecids, []) 215 | return [info for info in result.output.values()] 216 | 217 | 218 | def wallpaper(ecids, image, auth, args=None): 219 | """ 220 | Set the wallpaper of specified ECIDs using image 221 | 222 | :param ecids: list of ECIDs 223 | :param image: path to image 224 | :param auth: cfgutil.Authentication 225 | :param args: list of additional arguments for `cfgutil` 226 | :returns: cfgutil.Result 227 | """ 228 | if not ecids: 229 | raise ValueError('no ECIDs specified') 230 | elif not image: 231 | raise ValueError('no image was specfied') 232 | 233 | if not args: 234 | args = ['--screen', 'both'] 235 | args.append(image) 236 | 237 | return cfgutil('wallpaper', ecids, args, auth) 238 | 239 | 240 | def install_wifi_profile(ecids, profile): 241 | """ 242 | Install wifi profile on unmanaged devices 243 | 244 | :param ecids: list of ecids 245 | :param profile: path to wifi profile 246 | :returns: None 247 | 248 | NOTE: 249 | install-profile reports failure, but allows the wifi profile to 250 | be installed regardless 251 | 252 | Currently there is no support for checking if the wifi profile was 253 | actually installed 254 | """ 255 | if not ecids: 256 | raise ValueError('no ECIDs specified') 257 | if not os.path.exists(profile): 258 | raise Error("profile missing: {0}".format(profile)) 259 | 260 | # dummy auth (not required for a unmanaged device wifi profile) 261 | class _faux(object): 262 | def args(self): 263 | return [] 264 | try: 265 | # incorrectly reports failure 266 | cfgutil('install-profile', ecids, [profile], _faux()) 267 | except: 268 | pass 269 | 270 | 271 | def restart(ecids, auth): 272 | if not ecids: 273 | raise ValueError('no ECIDs specified') 274 | return cfgutil('restart', ecids, [], auth) 275 | 276 | 277 | def shutdown(ecids, auth): 278 | if not ecids: 279 | raise ValueError('no ECIDs specified') 280 | return cfgutil('shut-down', ecids, [], auth) 281 | 282 | 283 | # TO-DO: combine prepareManually and prepareDEP to prepare() 284 | # def prepare(ecids, args=None): 285 | # if not ecids: 286 | # raise ValueError('no ECIDs specified') 287 | # if not args: 288 | # args = ['--dep', '--skip-language', '--skip-region'] 289 | # return cfgutil('prepare', ecids, args) 290 | 291 | # TO-DO: remove (see prepare above) 292 | def prepareDEP(ecids): 293 | """ 294 | Prepare devices using DEP 295 | """ 296 | if not ecids: 297 | raise ValueError('no ECIDs specified') 298 | args = ['--dep', '--skip-language', '--skip-region'] 299 | return cfgutil('prepare', ecids, args) 300 | 301 | 302 | # TO-DO: remove (see prepare above) 303 | def prepareManually(ecids): 304 | """ 305 | Prepare devices manually (Not Implemented) 306 | """ 307 | raise NotImplementedError('prepareManually') 308 | if not ecids: 309 | raise ValueError('no ECIDs specified') 310 | 311 | 312 | def cfgutil(command, ecids, args, auth=None): 313 | """ 314 | Executes `cfgutil` with specified arguments 315 | 316 | :param command: command to execute 317 | :param ecids: list of device ECIDs 318 | :param args: list of additional arguments for `cfgutil` 319 | :param auth: cfgutil.Authentication 320 | 321 | :return: cfgutil.Result 322 | 323 | :raises: cfgutil.AuthenticationError when command requires authorization 324 | but None, or invalid authentication provided 325 | :raises: cfgutil.FatalError on non-zero status (nothing was modified) 326 | :raises: cfgutil.CfgutilError some modification (but not all) 327 | """ 328 | logger = logging.getLogger(__name__) 329 | 330 | # build the command 331 | cmd = [CFGUTILBIN, '--format', 'JSON'] 332 | 333 | if not command: 334 | raise ValueError('no command was specified') 335 | 336 | # list of sub-commands that require authentication 337 | if requires_authentication(command) or auth: 338 | try: 339 | cmd += auth.args() 340 | except AttributeError: 341 | logger.error("invalid authentication: %r", auth) 342 | raise 343 | 344 | # pre-append '--ecid' per (sorted) ECID as flat list 345 | # i.e. [ecid1, ecid2] -> ['--ecid', ecid1, '--ecid', ecid2] 346 | cmd += [x for e in sorted(ecids) for x in ('--ecid', e)] 347 | 348 | # finally, add the command and args 349 | cmd += [command] + args 350 | 351 | logger.info("> {0}".format(" ".join(cmd))) 352 | p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 353 | out, err = p.communicate() 354 | 355 | logger.debug(" output: %r", out) 356 | logger.debug(" error: %r", err) 357 | logger.debug("returncode: %r", p.returncode) 358 | 359 | if log: 360 | # record everything to specified file (if cfgutil.log) 361 | try: 362 | _record(log, {'execution': cmd, 'output': out, 'error': err, 363 | 'ecids': ecids, 'args': args, 'command': command, 364 | 'returncode': p.returncode}) 365 | except: 366 | logger.debug("failed to record execution data") 367 | 368 | if out: 369 | try: 370 | cfgout = json.loads(out) 371 | except: 372 | logger.error("%s: returned invalid JSON: %r", command, out) 373 | raise 374 | else: 375 | logger.debug("no JSON output returned") 376 | cfgout = {'Command': command, 'Type': 'Error', 377 | 'Message': err, 'Details': u"ERR: {0!r}".format(err), 378 | 'FailureReason': 'missing output', 'Output': {}} 379 | 380 | # cfgutil command failed (action wasn't performed) 381 | if p.returncode != 0: 382 | cfgerr = err if err else "cfgutil: {0}: failed".format(command) 383 | raise FatalError(cfgout, cfgerr, command) 384 | 385 | _type = cfgout.get('Type') 386 | if _type == 'Error': 387 | raise CfgutilError(cfgout, 'Unknown error', command) 388 | elif _type is None: 389 | # TO-DO: remove (this shouldn't happen ever) 390 | raise CfgutilError(cfgout, 'unexpected output type', command) 391 | 392 | return Result(cfgout, ecids, cmd) 393 | 394 | 395 | if __name__ == '__main__': 396 | pass 397 | -------------------------------------------------------------------------------- /aeios/tethering.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Library for iOS Device tethering 5 | 6 | Mostly Deprecated in macOS 10.13+ 7 | """ 8 | 9 | import os 10 | import sys 11 | import re 12 | import subprocess 13 | import time 14 | import plistlib 15 | import json 16 | import logging 17 | 18 | __author__ = 'Sam Forester' 19 | __email__ = 'sam.forester@utah.edu' 20 | __copyright__ = 'Copyright(c) 2019 University of Utah, Marriott Library' 21 | __license__ = 'MIT' 22 | __version__ = "1.4.3" 23 | 24 | ENABLED = None 25 | 26 | # suppress "No handlers could be found" message 27 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 28 | 29 | 30 | class Error(Exception): 31 | pass 32 | 33 | 34 | class TetheringError(Error): 35 | pass 36 | 37 | 38 | def _old_tetherator(arg, output=True, **kwargs): 39 | """ 40 | old style output from `AssetCacheTetheratorUtil` 41 | 42 | :param string arg: argument for `AssetCacheTetheratorUtil` 43 | :param bool output: modify return 44 | 45 | :returns: (output=True) new-style `AssetCacheTetheratorUtil` output 46 | :returns: (output=False) returncode from `AssetCacheTetheratorUtil` 47 | """ 48 | p, out = assetcachetetheratorutil(arg, json=False, **kwargs) 49 | if output: 50 | # modify the output of old `AssetCacheTetheratorUtil` to be 51 | # what is returned in the newer version 52 | universal = ['Checked In', 'Serial Number', 'Check In Pending'] 53 | changed = {'Tethered': 'Bridged', 'Device Name': 'Name', 54 | 'Check In Retry Attempts': 'Check In Attempts', 55 | 'Device Location ID': 'Location ID'} 56 | modified = [] 57 | for device in _parse_tetherator_status(out.rstrip()): 58 | info = {v:device[k] for k,v in changed.items()} 59 | info.update({k:device[k] for k in universal}) 60 | # hack for paired (this might be a bad idea overall) 61 | info['Paired'] = device.get('Paired') 62 | modified.append(info) 63 | 64 | return {'Device Roster': modified} 65 | else: 66 | return p.returncode 67 | 68 | 69 | def _parse_tetherator_status(status): 70 | """ 71 | Parse output of `AssetCacheTetheratorUtil status` 72 | 73 | (Deprecated in 10.13+) 74 | """ 75 | logger = logging.getLogger(__name__) 76 | logger.debug("parsing: %r", status) 77 | # remove newlines and extra whitespace from status 78 | stripped = re.sub(r'\n|\s{4}', '', status) 79 | 80 | # get dictionary of all devices as a string: 81 | # e.g. '{"k" = v; "k2" = v2; "k3" = "v3";}, {...}, ...' 82 | devices_string = re.search(r'\((.*)\)', stripped).group(1) 83 | 84 | # split devices_string into list of individual dictionary strings: 85 | # e.g. ['{"k" = v; "k2" = v2; "k3" = "v3"}', '{...}', ...] 86 | dict_strings = re.findall(r'\{.+?\}', devices_string) 87 | logger.debug("found %d device(s)", len(dict_strings)) 88 | 89 | tethered_devices = [] 90 | for d_str in dict_strings: 91 | # split each dictionary string into key-value pairs: 92 | # e.g. ['{"k" = "v"', '"k2" = v2', '"k3" = "v3"', ..., '}'] 93 | # split on ';' and skip the last value (always '}') 94 | tethered_device = {} 95 | for kvp in d_str.split(';')[0:-1]: 96 | # split key-value pairs 97 | # e.g. ('{"k"','v'), ('"k2"','v2'), or ('"k3"','"v3"'), etc. 98 | try: 99 | raw_k,raw_v = kvp.split(" = ") 100 | except ValueError: 101 | logger.exception("unable to parse: %r", d_str) 102 | logger.debug("key-value pair: %r", kvp) 103 | raise 104 | try: 105 | # exclude quotations (if any) from each key 106 | # first key will still have '{' at the beginning 107 | k = re.match(r'^\{?"?(.+?)"?$', raw_k).group(1) 108 | except AttributeError: 109 | # all information 110 | logger.exception("unable to parse: %r", d_str) 111 | logger.debug("unexpected key: %r", raw_k) 112 | logger.debug("key-value pair: %r", kvp) 113 | raise 114 | try: 115 | # exclude quotations (if any), convert various types 116 | # of values (Yes|No -> bool, digits -> int) 117 | v = re.match(r'^"?(.+?)"?$', raw_v).group(1) 118 | if re.match(r'\d+', v): 119 | # convert "all integer" values to ints 120 | v = int(v) 121 | elif re.match(r'Yes|No', v): 122 | # convert 'Yes' or 'No' strings to True or False 123 | v = True if v == 'Yes' else False 124 | except AttributeError: 125 | # all information 126 | logger.exception("unable to parse: %r", d_str) 127 | logger.debug("key-value pair: %r", kvp) 128 | logger.debug("unexpected value: %r", raw_v) 129 | raise 130 | # add each parsed key and value to dict 131 | tethered_device[k] = v 132 | 133 | tethered_devices.append(tethered_device) 134 | 135 | # return list of all device dicts 136 | return tethered_devices 137 | 138 | 139 | def assetcachetetheratorutil(arg, json=True, log=None): 140 | if not log: 141 | log = logging.getLogger(__name__) 142 | cmd = ['/usr/bin/AssetCacheTetheratorUtil'] 143 | if json: 144 | cmd += ['--json'] 145 | cmd += [arg] 146 | log.debug("> {0}".format(" ".join(cmd))) 147 | p = subprocess.Popen(cmd, stdout=subprocess.PIPE, 148 | stderr=subprocess.PIPE) 149 | out, err = p.communicate() 150 | # older version of command prints output to stderr 151 | return (p, out) if json else (p, err) 152 | 153 | 154 | def _tetherator(arg, output=True, **kwargs): 155 | """ 156 | 10.13+ `AssetCacheTetheratorUtil` 157 | 158 | """ 159 | p, out = assetcachetetheratorutil(arg, json=True, **kwargs) 160 | if output: 161 | return json.loads(out.rstrip())['result'] 162 | else: 163 | return p.returncode 164 | 165 | 166 | # decorator 167 | def dynamic(func): 168 | """ 169 | decorator to dynamically pick an assign the appropriate 170 | function used for returning information about device tethering 171 | function is only calculated once 172 | """ 173 | # calculate which version function to use (only calculated once) 174 | try: 175 | # --json flag only available in 10.13+ 176 | cmd = ['/usr/bin/AssetCacheTetheratorUtil', '--json', 'status'] 177 | subprocess.check_call(cmd, stdout=subprocess.PIPE, 178 | stderr=subprocess.PIPE) 179 | # use the newer version 180 | _func = _tetherator 181 | except subprocess.CalledProcessError: 182 | # use the older version 183 | _func = _old_tetherator 184 | # I'll be honest, this is witchcraft... see tetherator() 185 | def wrapper(*args, **kwargs): 186 | return _func(*args, **kwargs) 187 | return wrapper 188 | 189 | 190 | @dynamic 191 | def tetherator(): 192 | """ 193 | function called is determined by the @dynamic decorator 194 | """ 195 | # NOTE: I find this strange, because I don't define _decorated() 196 | # but it doesn't matter what I call: f(), blah(), broken() 197 | # ... everything seems to point to the wrapped function 198 | # and I'm not quite sure how, or if this will bite me 199 | # all *args and **kwargs are passed to the decorated function 200 | 201 | #return _decorated() 202 | 203 | # NOTE: apparently nothing in this function is ever run (which 204 | # is why the call above didn't raise UnboundLocalError) 205 | pass 206 | 207 | 208 | # ADDITIONAL TOOLS 209 | 210 | def wait_for_devices(previous, timeout=10, poll=2, **kwargs): 211 | """ 212 | compare items in the previous device list with devices that appear 213 | """ 214 | logger = logging.getLogger(__name__) 215 | logger.info("waiting for devices to reappear") 216 | prev_sn = [d['Serial Number'] for d in previous] 217 | found = [] 218 | tethered = [] 219 | max = timeout / poll 220 | count = 0 221 | 222 | while count < max: 223 | current = devices(**kwargs) 224 | appeared = [] 225 | 226 | for device in current: 227 | sn = device['Serial Number'] 228 | name = device['Name'] 229 | 230 | # modify name for logging 231 | if name == 'iPad': 232 | name += ' ({0})'.format(sn) 233 | 234 | if sn in prev_sn: 235 | if sn not in found: 236 | appeared.append(name) 237 | found.append(sn) 238 | else: 239 | found.append(sn) 240 | 241 | if device['Checked In']: 242 | logger.debug("{0} tethered!".format(name)) 243 | tethered.append(sn) 244 | 245 | # superfluous logging 246 | if appeared: 247 | msg = "device(s) appeared: {0}".format(", ".join(appeared)) 248 | logger.debug(msg) 249 | 250 | sn_set = set(found + prev_sn) 251 | # list of items that 252 | waiting = [x for x in sn_set if x not in tethered] 253 | 254 | if not waiting: 255 | return 256 | 257 | waitmsg = "waiting on {0} device(s)".format(len(waiting)) 258 | waitmsg += ": ({0})".format(", ".join(waiting)) 259 | logger.info(waitmsg) 260 | count += 1 261 | time.sleep(poll) 262 | 263 | raise TetheringError("devices never came up: {0}".format(waiting)) 264 | 265 | 266 | def tethered_caching(args): 267 | """ 268 | Start or stop tethered-caching 269 | (Not Supported in macOS 10.13+) 270 | """ 271 | _bin = '/usr/bin/tethered-caching' 272 | logger = logging.getLogger(__name__) 273 | try: 274 | cmd = ['/usr/bin/sudo', '-n', _bin, args] 275 | logger.debug("> {0}".format(" ".join(cmd))) 276 | subprocess.check_call(cmd, stdout=subprocess.PIPE, 277 | stderr=subprocess.PIPE) 278 | except subprocess.CalledProcessError as e: 279 | logger.debug("`%s %s`: failed", _bin, " ".join(args)) 280 | logger.error(e) 281 | raise Error("tethered-caching failed") 282 | 283 | 284 | def restart(timeout=30, wait=True, log=None): 285 | """ 286 | Restart tethered-caching (requires root) 287 | (Not Supported in macOS 10.13+) 288 | """ 289 | if not log: 290 | logger = logging.getLogger(__name__) 291 | logger.info("restarting tethered caching") 292 | # get current devices before restarting 293 | previous = devices() 294 | 295 | start() 296 | logger.info("successfully restarted tethering!") 297 | 298 | if previous and wait: 299 | wait_for_devices(previous, timeout=timeout) 300 | 301 | 302 | def start(): 303 | """ 304 | Starts tethered-caching (requires root) 305 | (Not Supported in macOS 10.13+) 306 | """ 307 | logger = logging.getLogger(__name__) 308 | logger.info("starting tethering services") 309 | tethered_caching('-b') 310 | logger.debug("successfully started tethering!") 311 | 312 | 313 | def stop(): 314 | """ 315 | Stops tethered-caching (requires root) 316 | (Not Supported in macOS 10.13+) 317 | """ 318 | logger = logging.getLogger(__name__) 319 | if not enabled(refresh=False): 320 | logger.warn("tethering services not running") 321 | 322 | logger.info("stopping tethering services") 323 | tethered_caching('-k') 324 | logger.debug("successfully stopped tethering!") 325 | 326 | 327 | def enabled(refresh=True, log=None, **kwargs): 328 | """ 329 | :returns: True if device Tethering is enabled, else False 330 | """ 331 | global ENABLED 332 | if refresh or ENABLED is None: 333 | retcode = tetherator('isEnabled', output=False, **kwargs) 334 | ENABLED = True if retcode == 0 else False 335 | return ENABLED 336 | 337 | 338 | def devices(**kwargs): 339 | """ 340 | shortcut function returning list of all devices found by 341 | tetherator() 342 | """ 343 | return tetherator('status', **kwargs)['Device Roster'] 344 | 345 | 346 | def device_is_tethered(sn, **kwargs): 347 | """ 348 | :returns: True if device with specified serial number is tethered 349 | """ 350 | if not sn: 351 | raise Error("no device specified") 352 | return devices_are_tethered([sn], **kwargs) 353 | 354 | 355 | def devices_are_tethered(sns, strict=False, **kwargs): 356 | """ 357 | Use list of devices serial numbers to determine tethering status 358 | 359 | :returns: True if all specified devices are tethered 360 | """ 361 | logger = logging.getLogger(__name__) 362 | if not enabled(refresh=False, **kwargs): 363 | raise Error("tethering is not enabled") 364 | 365 | info = {d['Serial Number']:d for d in devices(**kwargs)} 366 | _tethered = True 367 | missing = [] 368 | for sn in sns: 369 | try: 370 | device = info[sn] 371 | if not device['Checked In']: 372 | _tethered = False 373 | except KeyError: 374 | _tethered = False 375 | missing.append(sn) 376 | 377 | if missing: 378 | err = "missing device(s): {0}".format(missing) 379 | logger.error(err) 380 | if strict: 381 | raise TetheringError(err) 382 | 383 | return _tethered 384 | 385 | all_tethered = True 386 | try: 387 | _queried = [info[sn] for sn in sns] 388 | _tethered = [v] 389 | except KeyError: 390 | err = "missing device: {0}".format(sn) 391 | logger.error(err) 392 | raise TetheringError(err) 393 | 394 | for sn in sns: 395 | try: 396 | device = info[sn] 397 | if not device['Checked In']: 398 | all_tethered = False 399 | except KeyError: 400 | err = "missing device: {0}".format(sn) 401 | logger.error(err) 402 | raise TetheringError(err) 403 | 404 | return all_tethered 405 | 406 | 407 | if __name__ == '__main__': 408 | pass 409 | -------------------------------------------------------------------------------- /tests/data/adapter/mock/standard.txt: -------------------------------------------------------------------------------- 1 | {"activity":null,"alerts":[],"busy":false} 2 | {"activity":{"options":[],"info":["Step 2 of 27: Assigning licenses for “Microsoft Excel”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 3 | {"activity":{"options":[],"info":["Step 3 of 27: Assigning licenses for “Microsoft PowerPoint”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 4 | {"activity":{"options":[],"info":["Step 3 of 27: Assigning licenses for “Microsoft PowerPoint”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 5 | {"activity":{"options":[],"info":["Step 5 of 27: Assigning licenses for “Microsoft Teams”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 6 | {"activity":{"options":[],"info":["Step 5 of 27: Assigning licenses for “Microsoft Teams”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 7 | {"activity":{"options":[],"info":["Step 6 of 27: Assigning licenses for “Microsoft Word”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 8 | {"activity":{"options":[],"info":["Step 7 of 27: Assigning licenses for “Parallels Client”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 9 | {"activity":{"options":[],"info":["Step 8 of 27: Assigning licenses for “Slack”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 10 | {"activity":{"options":[],"info":["Step 8 of 27: Assigning licenses for “Slack”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 11 | {"activity":{"options":[],"info":["Step 8 of 27: Assigning licenses for “Slack”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 12 | {"activity":{"options":[],"info":["Step 8 of 27: Assigning licenses for “Slack”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 13 | {"activity":{"options":[],"info":["Step 8 of 27: Assigning licenses for “Slack”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 14 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 15 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 16 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 17 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 18 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 19 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 20 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 21 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 22 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 23 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 24 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 25 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 26 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 27 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 28 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 29 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 30 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 31 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 32 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 33 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 34 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 35 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 36 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 37 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 38 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 39 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 40 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 41 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 42 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 43 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 44 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 45 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 46 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 47 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 48 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 49 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 50 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 51 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 52 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 53 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 54 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 55 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 56 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 57 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 58 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 59 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 60 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 61 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 62 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 63 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 64 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 65 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 66 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 67 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 68 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 69 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 70 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 71 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 72 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 73 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 74 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 75 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 76 | {"activity":{"options":[],"info":["Step 9 of 27: Downloading apps","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 77 | {"activity":{"options":[],"info":["Step 11 of 27: Transferring placeholder for “ReCap Pro”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 78 | {"activity":{"options":[],"info":["Step 18 of 27: Transferring placeholder for “Slack”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 79 | {"activity":{"options":[],"info":["Step 19 of 27: Transferring “ReCap Pro”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 80 | {"activity":{"options":[],"info":["Step 19 of 27: Transferring “ReCap Pro”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 81 | {"activity":{"options":[],"info":["Step 19 of 27: Transferring “ReCap Pro”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 82 | {"activity":{"options":[],"info":["Step 19 of 27: Transferring “ReCap Pro”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 83 | {"activity":{"options":[],"info":["Step 19 of 27: Transferring “ReCap Pro”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 84 | {"activity":{"options":[],"info":["Step 19 of 27: Transferring “ReCap Pro”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 85 | {"activity":{"options":[],"info":["Step 19 of 27: Transferring “ReCap Pro”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 86 | {"activity":{"options":[],"info":["Step 19 of 27: Transferring “ReCap Pro”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 87 | {"activity":{"options":[],"info":["Step 19 of 27: Transferring “ReCap Pro”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 88 | {"activity":{"options":[],"info":["Step 20 of 27: Transferring “Excel”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 89 | {"activity":{"options":[],"info":["Step 20 of 27: Transferring “Excel”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 90 | {"activity":{"options":[],"info":["Step 20 of 27: Transferring “Excel”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 91 | {"activity":{"options":[],"info":["Step 20 of 27: Transferring “Excel”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 92 | {"activity":{"options":[],"info":["Step 20 of 27: Transferring “Excel”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 93 | {"activity":{"options":[],"info":["Step 20 of 27: Transferring “Excel”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 94 | {"activity":{"options":[],"info":["Step 21 of 27: Transferring “PowerPoint”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 95 | {"activity":{"options":[],"info":["Step 21 of 27: Transferring “PowerPoint”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 96 | {"activity":{"options":[],"info":["Step 21 of 27: Transferring “PowerPoint”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 97 | {"activity":{"options":[],"info":["Step 21 of 27: Transferring “PowerPoint”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 98 | {"activity":{"options":[],"info":["Step 21 of 27: Transferring “PowerPoint”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 99 | {"activity":{"options":[],"info":["Step 21 of 27: Transferring “PowerPoint”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 100 | {"activity":{"options":[],"info":["Step 21 of 27: Transferring “PowerPoint”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 101 | {"activity":{"options":[],"info":["Step 23 of 27: Transferring “Teams”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 102 | {"activity":{"options":[],"info":["Step 23 of 27: Transferring “Teams”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 103 | {"activity":{"options":[],"info":["Step 24 of 27: Transferring “Word”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 104 | {"activity":{"options":[],"info":["Step 24 of 27: Transferring “Word”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 105 | {"activity":{"options":[],"info":["Step 24 of 27: Transferring “Word”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 106 | {"activity":{"options":[],"info":["Step 24 of 27: Transferring “Word”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 107 | {"activity":{"options":[],"info":["Step 24 of 27: Transferring “Word”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 108 | {"activity":{"options":[],"info":["Step 24 of 27: Transferring “Word”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 109 | {"activity":{"options":[],"info":["Step 25 of 27: Transferring “Client”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 110 | {"activity":{"options":[],"info":["Step 25 of 27: Transferring “Client”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 111 | {"activity":{"options":[],"info":["Step 26 of 27: Transferring “Slack”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 112 | {"activity":{"options":[],"info":["Step 26 of 27: Transferring “Slack”","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 113 | {"activity":{"options":[],"info":["Step 27 of 27: Waiting for device to complete transfer","Adding apps on 4 iPads"],"choices":["Cancel"]},"alerts":[],"busy":true} 114 | {"activity":null,"alerts":[],"busy":false} -------------------------------------------------------------------------------- /actools/adapter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import re 5 | import json 6 | import time 7 | import logging 8 | import subprocess 9 | import datetime as dt 10 | 11 | """ 12 | Apple Configurator 2 GUI Adapter 13 | """ 14 | 15 | __author__ = 'Sam Forester' 16 | __email__ = 'sam.forester@utah.edu' 17 | __copyright__ = 'Copyright (c) 2018 University of Utah, Marriott Library' 18 | __license__ = 'MIT' 19 | __version__ = "3.0.0" 20 | 21 | # suppress "No handlers could be found" message 22 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 23 | 24 | # dynamic find path to ACAdapter.scpt 25 | ACADAPTER = os.path.join(os.path.dirname(__file__), 'scripts/ACAdapter.scpt') 26 | 27 | # record raw execution info (adapter.log = '/path/to/execution.log') 28 | log = None 29 | 30 | 31 | class Error(Exception): 32 | pass 33 | 34 | 35 | class ACAdapterError(Error): 36 | pass 37 | 38 | 39 | class ACStalled(Error): 40 | pass 41 | 42 | 43 | class StatusError(Error): 44 | pass 45 | 46 | 47 | class HandlerError(Error): 48 | pass 49 | 50 | 51 | class Text(unicode): 52 | """ 53 | Unicode wrapper allowing string to be split into parts preserving spaces 54 | encapsulated by unicode quotation (“ ”) 55 | """ 56 | regex = re.compile('(“.+?”)| ') 57 | 58 | @property 59 | def parts(self): 60 | # split text by whitespace except when encapsulated by u'“ ”' 61 | return [x for x in re.split(Text.regex, self) if x] 62 | 63 | 64 | class Prompt(object): 65 | """ 66 | Base Class for Activity and Alert 67 | """ 68 | def __init__(self, message, details, choices=None, options=None): 69 | self.message = Text(message) if message else None 70 | self.details = Text(details) if details else None 71 | self.choices = choices if choices else [] 72 | self.options = options if options else [] 73 | 74 | def __str__(self): 75 | try: 76 | return self.message.encode('utf-8') 77 | except AttributeError: 78 | return '' 79 | 80 | def __unicode__(self): 81 | if self.message is not None: 82 | return self.message 83 | else: 84 | return u'' 85 | 86 | def __bool__(self): 87 | return self.message is not None 88 | 89 | __nonzero__ = __bool__ 90 | 91 | def __repr__(self): 92 | # 93 | _repr = '<{0}.{1} 0x{2:x} ({3})>' 94 | p = (self.message, self.details, self.choices, self.options) 95 | return _repr.format(__name__, self.__class__.__name__, 96 | id(self), ", ".join([repr(x) for x in p])) 97 | 98 | 99 | class Alert(Prompt, Error): 100 | 101 | @classmethod 102 | def compare(cls, x, y): 103 | if x == y: 104 | similarity = 1 105 | else: 106 | most = x.parts if len(x.parts) >= len(y.parts) else y.parts 107 | # compare each part the text and get count of identical parts 108 | matching = len([a for a, b in zip(x.parts, y.parts) if a == b]) 109 | similarity = float(matching) / len(most) 110 | return similarity * 100 111 | 112 | def __init__(self, info): 113 | message = info['info'][0] 114 | details = info['info'][1] 115 | Prompt.__init__(self, message, details, info['choices'], info['options']) 116 | 117 | def __eq__(self, other): 118 | if not isinstance(other, self.__class__): 119 | return False 120 | m = Alert.compare(self.message, other.message) 121 | d = Alert.compare(self.details, other.details) 122 | return ((m + d) / 2) == 100 123 | 124 | def __ne__(self, other): 125 | return not self.__eq__(other) 126 | 127 | def __repr__(self): 128 | return Prompt.__repr__(self) 129 | 130 | def similar(self, alert): 131 | m = Alert.compare(self.message, alert.message) 132 | d = Alert.compare(self.details, alert.details) 133 | return ((m + d) / 2) >= 75 134 | 135 | def dismiss(self, callback=None): 136 | if not callback: 137 | def callback(): 138 | for choice in ["Cancel", "OK", "Stop"]: 139 | if choice in self.choices: 140 | action(choice) 141 | break 142 | callback() 143 | 144 | 145 | class Activity(Prompt): 146 | 147 | def __init__(self, info, timeout=300): 148 | self.log = logging.getLogger(__name__ + '.Activity') 149 | self.timeout = dt.timedelta(seconds=timeout) 150 | self.expiration = dt.datetime.now() + self.timeout 151 | # TO-DO: fix open VPP app window 152 | # {'info':[], 'options':[],'choices':['Cancel','Add',u'Choose from my Mac\u2026']} 153 | try: 154 | d, m = info['info'][0:2] if info['info'] else (Text(''), Text('')) 155 | Prompt.__init__(self, m, d, info['choices'], info['options']) 156 | except (TypeError, IndexError): 157 | self.message = Text('') 158 | self.details = Text('') 159 | self.choices = [] 160 | self.options = [] 161 | 162 | def __bool__(self): 163 | return self.active 164 | 165 | __nonzero__ = __bool__ 166 | 167 | @property 168 | def active(self): 169 | if not self.choices: 170 | return False 171 | now = dt.datetime.now() 172 | _active = now <= self.expiration 173 | self.log.debug("%s <= %s == %s", now, self.expiration, _active) 174 | return _active 175 | 176 | def update(self, activity): 177 | self.message = activity.message 178 | self.choices = activity.choices 179 | self.options = activity.options 180 | if self.details != activity.details: 181 | self.details = activity.details 182 | self.expiration = dt.datetime.now() + self.timeout 183 | if self.details: 184 | self.log.debug("%s: expires: %s", self.details, self.expiration) 185 | 186 | 187 | class Handler(object): 188 | 189 | def __init__(self, actions=None): 190 | self.log = logging.getLogger(__name__ + '.Handler') 191 | if not actions: 192 | actions = [] 193 | self.actions = actions 194 | 195 | def add_action(self, x): 196 | self.actions.append(x) 197 | 198 | def process(self, activity=None, alerts=None, busy=True, info=None): 199 | self.log.debug(u"processing: %s", info) 200 | 201 | if not self.actions: 202 | raise HandlerError("no actions defined") 203 | 204 | try: 205 | self.log.info("performing action") 206 | for _action in self.actions: 207 | _action(activity=activity, alerts=alerts, busy=busy, info=info) 208 | except TypeError as e: 209 | raise HandlerError("unable to perform action: %s", e) 210 | 211 | 212 | class Status(object): 213 | 214 | def __init__(self, timeout=300, callback=None): 215 | """ 216 | :param timeout: seconds to wait until non activity considered stalled 217 | :type timeout: int default: 300 218 | :param callback: function to use for status updates 219 | :type callback: function default: lambda: return acadapter('--status') 220 | """ 221 | self.log = logging.getLogger(__name__ + '.Status') 222 | 223 | if not callback: 224 | def callback(): 225 | return acadapter('--status') 226 | self._update = callback 227 | 228 | # get the initial status 229 | _status = self._update() 230 | self.activity = Activity(_status['activity'], timeout) 231 | self.alerts = [Alert(x) for x in _status['alerts']] 232 | self.busy = _status['busy'] 233 | 234 | def __str__(self): 235 | return self.activity.message.decode('utf-8') 236 | 237 | def __unicode__(self): 238 | return self.activity.message 239 | 240 | @property 241 | def task(self): 242 | if self.activity.message: 243 | return self.activity.message 244 | 245 | @property 246 | def timeout(self): 247 | if self.activity.timeout: 248 | return self.activity.timeout 249 | 250 | @timeout.setter 251 | def timeout(self, t): 252 | self.activity.timeout = t 253 | 254 | @property 255 | def details(self): 256 | if self.activity.details: 257 | return self.activity.details 258 | 259 | @property 260 | def alert(self): 261 | try: 262 | return self.alerts[0] 263 | except IndexError: 264 | pass 265 | 266 | @property 267 | def stalled(self): 268 | # if self.activity == None this will trigger immediately 269 | return self.busy and not self.activity.active 270 | 271 | @property 272 | def progress(self): 273 | m = re.search(r'^Step (\d+) of (\d+)', self.activity.details) 274 | if m: 275 | v = float(m.group(1)) / int(m.group(2)) 276 | return "{0:.0f}%".format(v * 100) 277 | 278 | def update(self): 279 | """ 280 | Refresh the status, updating current activity and gather alerts 281 | if there are any alerts, 282 | they are passed to each handler to be processed otherwise, they are raised 283 | if no handlers are defined, 284 | the alert is raised 285 | 286 | if there are any handlers, 287 | pass the updated activity, alerts, and raw status to each handler 288 | alerts are re-raised if there is an error with any of the handlers 289 | 290 | :return: None 291 | """ 292 | _status = self._update() 293 | self.log.debug("status: %r", _status) 294 | self.busy = _status['busy'] 295 | self.alerts = [Alert(x) for x in _status['alerts']] 296 | 297 | activity = Activity(_status['activity']) 298 | if activity or not self.busy: 299 | self.activity.update(activity) 300 | 301 | self.log.debug(" activity: %s", self.activity) 302 | self.log.debug(" alerts: %r", self.alerts) 303 | self.log.debug(" busy: %s", self.busy) 304 | 305 | 306 | def _record(path, info): 307 | """ 308 | Record raw data to specified file 309 | :param path: path to file 310 | :param info: dict of info to record 311 | :return: None 312 | """ 313 | logger = logging.getLogger(__name__) 314 | logger.debug("recording execution: %r", path) 315 | if not path: 316 | return 317 | 318 | directory = os.path.dirname(path) 319 | if not os.path.exists(directory): 320 | os.makedirs(directory, 0o755) 321 | 322 | if os.path.exists(path): 323 | with open(path, 'a+') as f: 324 | f.write("{0}\n".format(info)) 325 | else: 326 | with open(path, 'w+') as f: 327 | f.write("{0}\n".format(info)) 328 | 329 | 330 | def acadapter(command, data=None): 331 | logger = logging.getLogger(__name__) 332 | 333 | # build the command 334 | cmd = ['/usr/bin/caffeinate', '-d', '-i', '-u'] 335 | cmd += [ACADAPTER, command] 336 | 337 | # convert python to JSON for ACAdapter.scpt 338 | if data: 339 | cmd += [json.dumps(data)] 340 | 341 | logger.debug("> {0}".format(" ".join(cmd))) 342 | p = subprocess.Popen(cmd, stdout=subprocess.PIPE, 343 | stderr=subprocess.PIPE) 344 | out, err = p.communicate() 345 | logger.debug(" OUT: %r", out) 346 | logger.debug("ERROR: %r", err) 347 | 348 | if log: 349 | # record everything to specified file (if cfgutil.log) 350 | try: 351 | _record(log, {'execution': cmd, 'output': out, 'error': err, 352 | 'data': data, 'command': command, 353 | 'returncode': p.returncode}) 354 | except (OSError, IOError): 355 | logger.warning("failed to record execution", exc_info=True) 356 | 357 | if p.returncode != 0: 358 | _script = os.path.basename(ACADAPTER) 359 | logger.error("%s: %s", _script, err.rstrip()) 360 | logger.debug("returncode: %d", p.returncode) 361 | raise ACAdapterError(err.rstrip()) 362 | 363 | result = {} 364 | if out: 365 | logger.debug("loading JSON: %r", out) 366 | result = json.loads(out) 367 | logger.debug("JSON successfully loaded") 368 | else: 369 | logger.debug("no output to load") 370 | 371 | return result 372 | 373 | 374 | def action(choice, options=None): 375 | options = options or [] 376 | args = {'choice': choice, 'options': options} 377 | return acadapter('--action', args) 378 | 379 | 380 | def relaunch(force=False): 381 | # formatted for better readability 382 | scpt = ('if application "Apple Configurator 2" is running then', 383 | ' tell application "Apple Configurator 2" to quit', 384 | ' delay 1', 385 | 'end if', 386 | 'tell application "Apple Configurator 2" to launch') 387 | # join all the strings 388 | applscpt = "\n".join(scpt) 389 | if force: 390 | try: 391 | action("Cancel") 392 | except ACAdapterError: 393 | pass 394 | try: 395 | subprocess.check_call(['osascript', '-e', applscpt], 396 | stdout=subprocess.PIPE, 397 | stderr=subprocess.PIPE) 398 | except subprocess.CalledProcessError: 399 | raise Error("unable to relaunch Apple Configurator 2") 400 | 401 | 402 | def install_vpp_apps(udids, apps, recovery=None, hook=None, **kwargs): 403 | if not udids: 404 | raise Error("no UDIDs were specified") 405 | elif not apps: 406 | raise Error("no apps were specified") 407 | 408 | logger = logging.getLogger(__name__) 409 | logger.info("installing VPP apps") 410 | logger.debug(" UDIDs: %r", udids) 411 | logger.debug(" APPS: %r", apps) 412 | 413 | try: 414 | status = Status(**kwargs) 415 | except ACAdapterError as e: 416 | # bug on first launch with error window 417 | # FIX: status should return empty dict if it wasn't running 418 | status = Status(**kwargs) 419 | # TO-DO: this is not looping correctly when: 420 | # - AC is not running & has network issue 421 | if not status.busy: 422 | try: 423 | acadapter('--vppapps', {'udids': udids, 'apps': apps}) 424 | logger.info("waiting for app installation to start") 425 | while not status.task or "apps on" not in status.task: 426 | status.update() 427 | time.sleep(1) 428 | logger.debug("VPP app installation started") 429 | except ACAdapterError: 430 | logger.error("failed to start VPP app installation") 431 | raise 432 | else: 433 | logger.info(u"AC was busy with: %s", status.task) 434 | 435 | try: 436 | step = status.details 437 | while status.busy: 438 | if step != status.details: 439 | step = status.details 440 | expiration = status.activity.expiration 441 | logger.info("%s: (expires: %s)", status.details, expiration) 442 | if hook: 443 | hook(status) 444 | if status.alert: 445 | if recovery: 446 | logger.debug(u"attempting to recover: %r", status.alert) 447 | recovery(status.alert) 448 | else: 449 | raise status.alert 450 | if status.stalled: 451 | raise ACStalled(u"stalled: %s", status.details) 452 | status.update() 453 | time.sleep(1) 454 | 455 | logger.info(u"finished %s", status) 456 | # clear_selection() 457 | logger.info("finished VPP app installation") 458 | 459 | except ACAdapterError as e: 460 | logger.error(u"VPP App installation failed: %s", e) 461 | raise 462 | 463 | except ACStalled as e: 464 | logger.error(u"%s stalled on %s", e, status.details) 465 | try: 466 | _status = Status(timeout=30) 467 | action("Cancel") 468 | while _status.busy: 469 | if _status.stalled: 470 | raise ACStalled("Cancellation stalled") 471 | _status.update() 472 | time.sleep(2) 473 | except ACStalled as e: 474 | logger.error(u"couldn't cancel: %s", e) 475 | raise 476 | 477 | except Alert as e: 478 | logger.error(u"alert: %s", e) 479 | raise 480 | 481 | 482 | if __name__ == '__main__': 483 | pass 484 | -------------------------------------------------------------------------------- /tests/test_devicemanager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import types 5 | import shutil 6 | import logging 7 | import unittest 8 | import datetime as dt 9 | 10 | from actools import cfgutil 11 | from aeios import devicemanager 12 | 13 | """ 14 | Tests for aeios.devicemanager 15 | """ 16 | 17 | __author__ = 'Sam Forester' 18 | __email__ = 'sam.forester@utah.edu' 19 | __copyright__ = 'Copyright(c) 2019 University of Utah, Marriott Library' 20 | __license__ = 'MIT' 21 | __version__ = "1.0.2" 22 | 23 | # suppress "No handlers could be found" message 24 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 25 | 26 | LOCATION = os.path.dirname(__file__) 27 | DATA = os.path.join(LOCATION, 'data', 'devicemanager') 28 | TMPDIR = os.path.join(LOCATION, 'tmp', 'devicemanager') 29 | 30 | 31 | def setUpModule(): 32 | """ 33 | create tmp directory 34 | """ 35 | try: 36 | os.makedirs(TMPDIR) 37 | except OSError as e: 38 | if e.errno != 17: 39 | raise # raise unless TMPDIR already exists 40 | # aeios.resources.PATH = TMPDIR 41 | # aeios.resources.PREFERENCES = TMPDIR 42 | 43 | 44 | def tearDownModule(): 45 | """ 46 | remove tmp directory 47 | """ 48 | shutil.rmtree(TMPDIR) 49 | 50 | 51 | class BaseTestCase(unittest.TestCase): 52 | 53 | file = None 54 | tmp = TMPDIR 55 | logger = None 56 | 57 | @classmethod 58 | def setUpClass(cls): 59 | cls.logger = logging.getLogger(__name__) 60 | cls.env = [{'ECID': '0x123456789ABCD0', 61 | 'UDID': 'a0111222333444555666777888999abcdefabcde', 62 | 'bootedState': 'Booted', 63 | 'buildVersion': '15G77', 64 | 'deviceName': 'test-ipad-1', 65 | 'deviceType': 'iPad7,5', 66 | 'firmwareVersion': '11.4.1', 67 | 'locationID': '0x00000001'}, 68 | {'ECID': '0x123456789ABCD1', 69 | 'UDID': 'a1111222333444555666777888999abcdefabcde', 70 | 'bootedState': 'Booted', 71 | 'buildVersion': '15G77', 72 | 'deviceName': 'test-ipad-2', 73 | 'deviceType': 'iPad7,5', 74 | 'firmwareVersion': '11.4.1', 75 | 'locationID': '0x00000002'}, 76 | {'ECID': '0x123456789ABCD2', 77 | 'UDID': 'a2111222333444555666777888999abcdefabcde', 78 | 'bootedState': 'Booted', 79 | 'buildVersion': '15G77', 80 | 'deviceName': 'test-ipad-3', 81 | 'deviceType': 'iPad7,5', 82 | 'firmwareVersion': '11.4.1', 83 | 'locationID': '0x00000003'}, 84 | {'ECID': '0x123456789ABCD3', 85 | 'UDID': 'a3111222333444555666777888999abcdefabcde', 86 | 'bootedState': 'Booted', 87 | 'buildVersion': '15G77', 88 | 'deviceName': 'test-ipad-4', 89 | 'deviceType': 'iPad7,5', 90 | 'firmwareVersion': '11.4.1', 91 | 'locationID': '0x00000004'}, 92 | {'ECID': '0x123456789ABCD4', 93 | 'UDID': 'a4111222333444555666777888999abcdefabcde', 94 | 'bootedState': 'Booted', 95 | 'buildVersion': '15G77', 96 | 'deviceName': 'test-ipad-5', 97 | 'deviceType': 'iPad7,5', 98 | 'firmwareVersion': '11.4.1', 99 | 'locationID': '0x00000005'}, 100 | {'ECID': '0x123456789ABCD5', 101 | 'UDID': 'a5111222333444555666777888999abcdefabcde', 102 | 'bootedState': 'Booted', 103 | 'buildVersion': '15G77', 104 | 'deviceName': 'test-ipad-6', 105 | 'deviceType': 'iPad7,5', 106 | 'firmwareVersion': '11.4.1', 107 | 'locationID': '0x00000006'}, 108 | {'ECID': '0x123456789ABCD6', 109 | 'UDID': 'a6111222333444555666777888999abcdefabcde', 110 | 'bootedState': 'Booted', 111 | 'buildVersion': '15G77', 112 | 'deviceName': 'test-ipad-7', 113 | 'deviceType': 'iPad7,5', 114 | 'firmwareVersion': '11.4.1', 115 | 'locationID': '0x00000007'}, 116 | {'ECID': '0x123456789ABCD7', 117 | 'UDID': 'a7111222333444555666777888999abcdefabcde', 118 | 'bootedState': 'Booted', 119 | 'buildVersion': '15G77', 120 | 'deviceName': 'test-ipad-8', 121 | 'deviceType': 'iPad7,5', 122 | 'firmwareVersion': '11.4.1', 123 | 'locationID': '0x00000008'}, 124 | {'ECID': '0x123456789ABCD8', 125 | 'UDID': 'a8111222333444555666777888999abcdefabcde', 126 | 'bootedState': 'Booted', 127 | 'buildVersion': '15G77', 128 | 'deviceName': 'test-ipad-9', 129 | 'deviceType': 'iPad7,5', 130 | 'firmwareVersion': '11.4.1', 131 | 'locationID': '0x00000009'}, 132 | {'ECID': '0x123456789ABCD9', 133 | 'UDID': 'a9111222333444555666777888999abcdefabcde', 134 | 'bootedState': 'Booted', 135 | 'buildVersion': '15G77', 136 | 'deviceName': 'test-ipad-10', 137 | 'deviceType': 'iPad7,5', 138 | 'firmwareVersion': '11.4.1', 139 | 'locationID': '0x00000010'}] 140 | for d in cls.env: 141 | d['managed'] = True 142 | 143 | @classmethod 144 | def tearDownClass(cls): 145 | # os.remove(cls.file) 146 | pass 147 | 148 | def setUp(self): 149 | self.path = self.__class__.tmp 150 | # logging.basicConfig(level=logging.CRITICAL) 151 | self.manager = devicemanager.DeviceManager(path=self.path) 152 | self.env = self.__class__.env 153 | self.info = self.env 154 | self.now = dt.datetime.now() 155 | self.devices = [] 156 | self.__class__.file = self.manager.file 157 | 158 | def tearDown(self): 159 | # self.logger.setLevel(logging.CRITICAL) 160 | pass 161 | 162 | 163 | class TestCheckin(BaseTestCase): 164 | 165 | def test_checkin(self): 166 | for info in self.info: 167 | self.manager.checkin(info, run=False) 168 | 169 | 170 | class TestCheckout(BaseTestCase): 171 | 172 | def test_never_checked_in(self): 173 | """ 174 | test that a device that has never been checked in 175 | """ 176 | for info in self.info: 177 | self.manager.checkout(info) 178 | 179 | 180 | class TestNeedsErase(BaseTestCase): 181 | """ 182 | Tests for detecting if a device needs to be erased 183 | """ 184 | 185 | def setUp(self): 186 | super(self.__class__, self).setUp() 187 | d = self.env[0] 188 | self.device = self.manager.device(d['ECID'], d) 189 | self.now = dt.datetime.now() 190 | 191 | def test_unmanaged_device(self): 192 | """ 193 | test unmanaged devices will not be erased 194 | """ 195 | # Object Method patching 196 | # replace our manager's managed function with one that simply 197 | # returns False. The function will be reset on next setUp 198 | def _dummy(self, x): 199 | return False 200 | self.manager.managed = types.MethodType(_dummy, self.manager) 201 | 202 | self.assertFalse(self.manager.need_to_erase(self.device)) 203 | 204 | def test_not_checkedin(self): 205 | """ 206 | test non-checkedin device will be erased 207 | """ 208 | self.device.checkin = None 209 | self.assertTrue(self.manager.need_to_erase(self.device)) 210 | self.device.checkin = dt.datetime.now() 211 | 212 | def test_restarting(self): 213 | """ 214 | test restarting device will be erased 215 | """ 216 | self.device.restarting = True 217 | self.assertFalse(self.manager.need_to_erase(self.device)) 218 | 219 | def test_quick_disconnect(self): 220 | """ 221 | test devices that quickly disconnect and reconnect 222 | """ 223 | self.device.erased = self.now - dt.timedelta(minutes=5) 224 | self.device.checking = self.now - dt.timedelta(seconds=10) 225 | self.device.checkout = self.now 226 | self.device.restarting = False 227 | self.assertFalse(self.manager.need_to_erase(self.device)) 228 | 229 | def test_not_erased(self): 230 | """ 231 | test devices that have not been erased will be 232 | """ 233 | self.device.erased = None 234 | self.assertTrue(self.manager.need_to_erase(self.device)) 235 | 236 | def test_erased_more_than_timeout_with_blink(self): 237 | """ 238 | test device was erased but exceeds the timeout 239 | """ 240 | self.device.erased = self.now - dt.timedelta(minutes=12) 241 | self.device.checkin = self.now - dt.timedelta(seconds=10) 242 | self.device.checkout = self.now 243 | self.restarting = False 244 | self.assertFalse(self.manager.need_to_erase(self.device)) 245 | 246 | def test_valid_checkout(self): 247 | """ 248 | test valid device checkout 249 | """ 250 | device = self.manager.device(self.device.ecid) 251 | self.device.erased = self.now - dt.timedelta(hours=3) 252 | self.device.checkin = self.now - dt.timedelta(hours=2) 253 | self.device.checkout = self.now - dt.timedelta(hours=1) 254 | self.device.restarting = False 255 | self.assertTrue(self.manager.need_to_erase(self.device)) 256 | 257 | 258 | class TestListRefresh(BaseTestCase): 259 | """ 260 | Complicated tests relying on the replacement of an underlying 261 | function used by devicemanager.DeviceManager.list() 262 | 263 | tests verify that the manager caches the results to a file and 264 | reads the cached results when appropriate as well as refreshes 265 | the cache when appropriate 266 | """ 267 | @classmethod 268 | def setUpClass(cls): 269 | super(cls, cls).setUpClass() 270 | # save original cfgutil.list function for restore after tests 271 | cls._cfglist = cfgutil.list 272 | 273 | @classmethod 274 | def tearDownClass(cls): 275 | # restore original cfgutil.list function 276 | cfgutil.list = cls._cfglist 277 | 278 | def setUp(self): 279 | """ 280 | """ 281 | BaseTestCase.setUp(self) 282 | # replacement function to return simple list 283 | self.listed = [] 284 | for d in self.env[0:2]: 285 | m = {k:d[k] for k in ['UDID', 'ECID', 'locationID', 286 | 'deviceName', 'deviceType']} 287 | self.listed.append(m) 288 | # manually modify the config file with the cached list 289 | self.manager.config.update({'Devices': self.listed, 290 | 'lastListed': self.now}) 291 | 292 | # replacement function to return empty list 293 | def _empty(*args, **kwargs): 294 | return [] 295 | self.empty = _empty 296 | 297 | # replacement function to return simple device list 298 | def _simple(*args, **kwargs): 299 | return self.listed 300 | self.simple = _simple 301 | 302 | # replace cfgutil.list with _simple() above 303 | cfgutil.list = _simple 304 | self.listed = self.manager.list() 305 | # verify function replacement worked 306 | self.assertEquals(self.listed, _simple()) 307 | 308 | def tearDown(self): 309 | BaseTestCase.tearDown(self) 310 | 311 | # remove any cached values 312 | try: 313 | self.manager.config.delete('lastListed') 314 | except: 315 | pass 316 | try: 317 | self.manager.config.delete('Devices') 318 | except: 319 | pass 320 | 321 | def test_default_list(self): 322 | """ 323 | test that given default empty values, cached values are populated 324 | """ 325 | # delete the values configured in setUp() 326 | self.manager.config.delete('lastListed') 327 | self.manager.config.delete('Devices') 328 | 329 | # re-replace cfgutil.list and re-run 330 | cfgutil.list = self.empty 331 | self.manager.list() 332 | 333 | # Actual tests 334 | timestamp = self.manager.config.get('lastListed') 335 | cached = self.manager.config.get('Devices') 336 | self.assertIsNotNone(timestamp) 337 | self.assertIsNotNone(cached) 338 | 339 | def test_cached_list_returned(self): 340 | """ 341 | test cached result is returned 342 | """ 343 | # re-replace cfgutil.list (should not be called) 344 | cfgutil.list = self.empty 345 | listed = self.manager.list() 346 | # re-run manager.list(), should return cached results 347 | # (not the replaced function) 348 | self.assertEquals(listed, self.listed) 349 | 350 | def test_results_cached_to_file(self): 351 | """ 352 | test cached result is written to file 353 | """ 354 | listed = self.manager.list() 355 | file_cache = self.manager.config.get('Devices') 356 | self.assertItemsEqual(listed, file_cache) 357 | 358 | def test_cached_results_expire(self): 359 | """ 360 | test cached result expires 361 | """ 362 | # manually modify the lastListed to 1 minute ago (force update) 363 | timestamp = self.now - dt.timedelta(minutes=1) 364 | self.manager.config.update({'lastListed':timestamp}) 365 | 366 | # re-replace cfgutil.list 367 | cfgutil.list = self.empty 368 | # verify manager.list() returns results from second replacement 369 | result = self.manager.list() 370 | self.assertEquals(result, self.empty()) 371 | 372 | def test_forced_refresh(self): 373 | """ 374 | test list can be forcibly refreshed 375 | """ 376 | # re-replace cfgutil.list 377 | cfgutil.list = self.empty 378 | # verify manager.list() returns results from second replacement 379 | result = self.manager.list(refresh=True) 380 | # verify manager.list() refreshes the list 381 | self.assertEquals(result, self.empty()) 382 | 383 | 384 | # @unittest.skip("Not implemented") 385 | class TestRecords(BaseTestCase): 386 | """ 387 | Tests for device.Manager.records() 388 | """ 389 | def test_all_records(self): 390 | """ 391 | test all device records are returned 392 | """ 393 | expected = os.listdir(self.manager.resources.devices) 394 | result = [x[1] for x in self.manager.records()] 395 | self.assertItemsEqual(expected, result) 396 | 397 | def test_single_ecid(self): 398 | """ 399 | test single device record is returned 400 | """ 401 | ecid1 = self.env[0]['ECID'] 402 | expected = [(ecid1, "{0}.plist".format(ecid1))] 403 | self.assertItemsEqual(expected, self.manager.records([ecid1])) 404 | 405 | def test_missing_record(self): 406 | """ 407 | test empty list is returned for missing record 408 | """ 409 | self.assertEquals([], self.manager.records('missing')) 410 | 411 | def test_specified_ecids(self): 412 | """ 413 | test specified device records are returned 414 | """ 415 | ecid1 = self.env[0]['ECID'] 416 | ecid2 = self.env[1]['ECID'] 417 | expected = [(ecid1, "{0}.plist".format(ecid1)), 418 | (ecid2, "{0}.plist".format(ecid2))] 419 | self.assertItemsEqual(expected, self.manager.records([ecid1, ecid2])) 420 | 421 | def test_non_iterable(self): 422 | """ 423 | test error is raised when given a non-iterable 424 | """ 425 | with self.assertRaises(TypeError): 426 | self.manager.records(1) 427 | 428 | def test_non_ecid(self): 429 | """ 430 | test empty list is returned for non-ECID 431 | """ 432 | self.assertEquals([], self.manager.records(['test'])) 433 | 434 | 435 | class TestCache(BaseTestCase): 436 | 437 | def setUp(self): 438 | BaseTestCase.setUp(self) 439 | self.cache = devicemanager.DeviceManager.Cache(self.manager.config) 440 | 441 | 442 | class TestVerify(BaseTestCase): 443 | pass 444 | 445 | 446 | class TestThreaded(BaseTestCase): 447 | pass 448 | 449 | 450 | 451 | if __name__ == '__main__': 452 | fmt = ('%(asctime)s %(process)d: %(levelname)6s: ' 453 | '%(name)s - %(funcName)s(): %(message)s') 454 | # logging.basicConfig(format=fmt, level=logging.DEBUG) 455 | # logging.getLogger('aeios.resources.Resources').setLevel(logging.DEBUG) 456 | unittest.main(verbosity=1) 457 | -------------------------------------------------------------------------------- /tests/test_tethering.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import sys 5 | import logging 6 | import unittest 7 | import plistlib 8 | import subprocess 9 | 10 | from aeios import tethering 11 | 12 | """ 13 | Tests for aeios.tethering 14 | """ 15 | 16 | __author__ = 'Sam Forester' 17 | __email__ = 'sam.forester@utah.edu' 18 | __copyright__ = 'Copyright (c) 2019 University of Utah, Marriott Library' 19 | __license__ = 'MIT' 20 | __version__ = "2.0.0" 21 | 22 | OS = None 23 | ACTIONABLE = None 24 | LOCATION = os.path.dirname(__file__) 25 | DATA = os.path.join(LOCATION, 'data', 'tethering') 26 | 27 | def setUpModule(): 28 | global OS, ACTIONABLE 29 | OS = os_version() 30 | try: 31 | tethering.stop() 32 | ACTIONABLE = True 33 | except tethering.Error: 34 | # if we can't stop tethering, we can't run ActionTests 35 | ACTIONABLE = False 36 | 37 | 38 | def tearDownModule(): 39 | pass 40 | 41 | 42 | # TestCases 43 | class BaseTestCase(unittest.TestCase): 44 | 45 | @classmethod 46 | def setUpClass(cls): 47 | cls.data = DATA 48 | cls.version = OS 49 | 50 | @classmethod 51 | def tearDownClass(cls): 52 | pass 53 | 54 | def setUp(self): 55 | self.data = self.__class__.data 56 | self.ver = self.__class__.version 57 | self.maxDiff = None 58 | 59 | def tearDown(self): 60 | pass 61 | 62 | 63 | class MockOutputTestCase(BaseTestCase): 64 | 65 | def setUp(self): 66 | BaseTestCase.setUp(self) 67 | self.assetutil = tethering.assetcachetetheratorutil 68 | if self.ver.startswith('10.12'): 69 | self.static_tetherator = tethering._old_tetherator 70 | else: 71 | self.static_tetherator = tethering._tetherator 72 | tethering.assetcachetetheratorutil = self.mockassetutil 73 | 74 | def tearDown(self): 75 | BaseTestCase.tearDown(self) 76 | tethering.assetcachetetheratorutil = self.assetutil 77 | 78 | def lines(self, file): 79 | with open(file) as f: 80 | for line in f: 81 | yield line 82 | 83 | def mock(self, file, default=None): 84 | line = self.lines(file) 85 | 86 | def _mock(): 87 | try: 88 | _line = next(line) 89 | except StopIteration: 90 | return {'activity': None, 'alerts': [], 'busy': False} 91 | try: 92 | return json.loads(_line) 93 | except ValueError: 94 | if default: 95 | return default 96 | 97 | return _mock 98 | 99 | def mockassetutil(self, arg, json=False, _mock=(1, None)): 100 | """ 101 | replaces tethering.assetcachetetheratorutil() to mock data that 102 | would be returned 103 | """ 104 | code, name = _mock 105 | # dummy object to have obj.returncode 106 | class _dummy(object): 107 | def __init__(self, c): 108 | self.returncode = c 109 | out = None 110 | if name: 111 | filename = '{0}.txt'.format(name) 112 | version, filename = os.path.split(filename) 113 | if not version and self.version.startswith('10.12'): 114 | version = '10.12' 115 | file = os.path.join(self.data, version, filename) 116 | # instead of running command, read output from file 117 | with open(file, 'r') as f: 118 | out = f.read() 119 | 120 | return (_dummy(code), out) 121 | 122 | 123 | class TestSierraParser(MockOutputTestCase): 124 | 125 | def test_empty(self): 126 | _, out = self.mockassetutil('status', _mock=(0, '10.12/empty')) 127 | result = tethering._parse_tetherator_status(out) 128 | expected = {} 129 | self.assertItemsEqual(expected, result) 130 | 131 | def test_disabled(self): 132 | _, out = self.mockassetutil('status', _mock=(0, '10.12/disabled')) 133 | result = tethering._parse_tetherator_status(out) 134 | expected = {} 135 | self.assertItemsEqual(expected, result) 136 | 137 | def test_standard(self): 138 | _, out = self.mockassetutil('status', _mock=(0, '10.12/status')) 139 | result = tethering._parse_tetherator_status(out) 140 | expected = [{'Checked In': True, 141 | 'Check In Pending': False, 142 | 'Device Name': 'test-ipad-pro', 143 | 'Tethered': True, 144 | 'Device Location ID': 337641472, 145 | 'Check In Retry Attempts': 4, 146 | 'Serial Number': 'DMPVAA00J28K', 147 | 'Paired': True}, 148 | {'Checked In': False, 149 | 'Check In Pending': False, 150 | 'Device Name': 'test-ipad-1', 151 | 'Tethered': False, 152 | 'Device Location ID': 341835776, 153 | 'Check In Retry Attempts': 0, 154 | 'Serial Number': 'DMQX7000JF8J', 155 | 'Paired': False}, 156 | {'Checked In': False, 157 | 'Check In Pending': True, 158 | 'Device Name': 'test-ipad-2', 159 | 'Tethered': True, 160 | 'Device Location ID': 336592896, 161 | 'Check In Retry Attempts': 3, 162 | 'Serial Number': 'DMPWAA01JF8J', 163 | 'Paired': True}] 164 | self.assertItemsEqual(expected, result) 165 | 166 | 167 | class TestTetherator(MockOutputTestCase): 168 | 169 | def test_dynamic_function_mapped(self): 170 | args = ['status'] 171 | dynamic = tethering.tetherator(*args, _mock=(0, 'empty')) 172 | static = self.static_tetherator(*args, _mock=(0, 'empty')) 173 | self.assertEqual(dynamic, static) 174 | 175 | def test_standard(self): 176 | args = ['status'] 177 | result = tethering.tetherator(*args, _mock=(0, 'status')) 178 | expected = {'Device Roster': [ 179 | {'Check In Pending': False, 180 | 'Check In Attempts': 4, 181 | 'Checked In': True, 182 | 'Location ID': 337641472, 183 | 'Name': 'test-ipad-pro', 184 | 'Serial Number': 'DMPVAA00J28K', 185 | 'Bridged': True, 186 | 'Paired': True}, 187 | {'Check In Pending': False, 188 | 'Check In Attempts': 0, 189 | 'Checked In': False, 190 | 'Location ID': 341835776, 191 | 'Name': 'test-ipad-1', 192 | 'Serial Number': 'DMQX7000JF8J', 193 | 'Bridged': False, 194 | 'Paired': False}, 195 | {'Check In Pending': True, 196 | 'Check In Attempts': 3, 197 | 'Checked In': False, 198 | 'Location ID': 336592896, 199 | 'Name': 'test-ipad-2', 200 | 'Serial Number': 'DMPWAA01JF8J', 201 | 'Bridged': True, 202 | 'Paired': True}]} 203 | self.assertItemsEqual(expected['Device Roster'], 204 | result['Device Roster']) 205 | 206 | def test_disabled(self): 207 | """test disabled returns empty device roster 208 | """ 209 | args = ['status'] 210 | result = tethering.tetherator(*args, _mock=(0, 'disabled')) 211 | expected = {'Device Roster':[]} 212 | self.assertItemsEqual(expected['Device Roster'], 213 | result['Device Roster']) 214 | 215 | def test_no_devices(self): 216 | """test empty devices returns empty roster 217 | """ 218 | args = ['status'] 219 | result = tethering.tetherator(*args, _mock=(0, 'empty')) 220 | expected = {'Device Roster': []} 221 | self.assertItemsEqual(expected['Device Roster'], 222 | result['Device Roster']) 223 | 224 | def test_not_enabled(self): 225 | status = tethering.enabled(_mock=(1, 'disabled')) 226 | self.assertFalse(status) 227 | 228 | def test_enabled(self): 229 | status = tethering.enabled(_mock=(0, 'empty')) 230 | self.assertTrue(status) 231 | 232 | 233 | class TestDevices(MockOutputTestCase): 234 | 235 | def setUp(self): 236 | MockOutputTestCase.setUp(self) 237 | 238 | def test_no_devices(self): 239 | tethering.ENABLED = True 240 | result = tethering.devices(_mock=(0, 'empty')) 241 | expected = [] 242 | self.assertEqual(expected, result) 243 | 244 | def test_disabled(self): 245 | tethering.ENABLED = True 246 | result = tethering.devices(_mock=(0, 'disabled')) 247 | expected = [] 248 | self.assertEqual(expected, result) 249 | 250 | def test_standard(self): 251 | tethering.ENABLED = True 252 | result = tethering.devices(_mock=(0, 'status')) 253 | expected = [{'Check In Pending': False, 254 | 'Check In Attempts': 4, 255 | 'Checked In': True, 256 | 'Location ID': 337641472, 257 | 'Name': 'test-ipad-pro', 258 | 'Serial Number': 'DMPVAA00J28K', 259 | 'Bridged': True, 260 | 'Paired': True}, 261 | {'Check In Pending': False, 262 | 'Check In Attempts': 0, 263 | 'Checked In': False, 264 | 'Location ID': 341835776, 265 | 'Name': 'test-ipad-1', 266 | 'Serial Number': 'DMQX7000JF8J', 267 | 'Bridged': False, 268 | 'Paired': False}, 269 | {'Check In Pending': True, 270 | 'Check In Attempts': 3, 271 | 'Checked In': False, 272 | 'Location ID': 336592896, 273 | 'Name': 'test-ipad-2', 274 | 'Serial Number': 'DMPWAA01JF8J', 275 | 'Bridged': True, 276 | 'Paired': True}] 277 | self.assertItemsEqual(expected, result) 278 | 279 | def test_device_is_tethered(self): 280 | tethering.ENABLED = True 281 | m = (0, 'status') 282 | tethered = tethering.device_is_tethered('DMPVAA00J28K', _mock=m) 283 | self.assertTrue(tethered) 284 | 285 | def test_device_is_tethered_disabled(self): 286 | tethering.ENABLED = False 287 | m = (0, 'status') 288 | with self.assertRaises(tethering.Error): 289 | tethering.device_is_tethered('DMPWAA01JF8J', _mock=m) 290 | 291 | def test_device_is_not_tethered(self): 292 | tethering.ENABLED = True 293 | m = (0, 'status') 294 | tethered = tethering.device_is_tethered('DMPWAA01JF8J', _mock=m) 295 | self.assertFalse(tethered) 296 | 297 | def test_sn_tethered_missing_empty_disabled(self): 298 | tethering.ENABLED = False 299 | m = (0, 'status') 300 | with self.assertRaises(tethering.Error): 301 | tethering.device_is_tethered('', _mock=m) 302 | 303 | def test_sn_tethered_missing_empty_enabled(self): 304 | tethering.ENABLED = True 305 | m = (0, 'status') 306 | with self.assertRaises(tethering.Error): 307 | tethering.device_is_tethered('', _mock=m) 308 | 309 | def test_devices_are_tethered_single(self): 310 | tethering.ENABLED = True 311 | m = (0, 'status') 312 | tethered = tethering.devices_are_tethered(['DMPVAA00J28K'], _mock=m) 313 | self.assertTrue(tethered) 314 | 315 | def test_devices_are_not_tethered_single(self): 316 | tethering.ENABLED = True 317 | m = (0, 'status') 318 | tethered = tethering.devices_are_tethered(['DMPWAA01JF8J'], _mock=m) 319 | self.assertFalse(tethered) 320 | 321 | def test_devices_are_not_tethered_multiple(self): 322 | tethering.ENABLED = True 323 | sns = ['DMPWAA01JF8J', 'DMPVAA00J28K'] 324 | m = (0, 'status') 325 | tethered = tethering.devices_are_tethered(sns, _mock=m) 326 | self.assertFalse(tethered) 327 | 328 | 329 | class TestEnabled(MockOutputTestCase): 330 | 331 | def test_ENABLED_not_none(self): 332 | self.assertFalse(tethering.ENABLED is None) 333 | 334 | def test_ENABLED_reflects_enabled(self): 335 | status = tethering.enabled(_mock=(0, None)) 336 | self.assertTrue(status == tethering.ENABLED) 337 | 338 | def test_updated_by_default(self): 339 | reverse_status = not tethering.enabled() 340 | tethering.ENABLED = reverse_status 341 | status = tethering.enabled() 342 | self.assertTrue(status == tethering.ENABLED) 343 | 344 | def test_updated_with_refresh(self): 345 | reverse_status = not tethering.enabled() 346 | tethering.ENABLED = reverse_status 347 | status = tethering.enabled(refresh=True) 348 | self.assertTrue(status == tethering.ENABLED) 349 | 350 | def test_not_updated_without_refresh(self): 351 | reverse_status = not tethering.enabled() 352 | tethering.ENABLED = reverse_status 353 | status = tethering.enabled(refresh=False) 354 | self.assertTrue(status == tethering.ENABLED) 355 | self.assertTrue(status == reverse_status) 356 | 357 | 358 | @unittest.skipUnless(ACTIONABLE, "unable to test tethering actions") 359 | class ActionTests(BaseTestCase): 360 | 361 | @classmethod 362 | def setUpClass(cls): 363 | BaseTestCase.setUpClass() 364 | tethering.stop() 365 | 366 | @classmethod 367 | def tearDownClass(cls): 368 | BaseTestCase.tearDownClass() 369 | tethering.stop() 370 | 371 | def test_start_not_running(self): 372 | if not tethering.enabled(): 373 | tethering.start() 374 | else: 375 | raise Exception("test wasn't run in proper order") 376 | self.assertTrue(tethering.enabled()) 377 | 378 | def test_start_running(self): 379 | if tethering.enabled(): 380 | tethering.start() 381 | else: 382 | raise Exception("test wasn't run in proper order") 383 | enabled = tethering.enabled() 384 | self.assertTrue(enabled) 385 | 386 | def test_stop_not_running(self): 387 | if not tethering.enabled(): 388 | tethering.stop() 389 | else: 390 | raise Exception("test wasn't run in proper order") 391 | enabled = tethering.enabled() 392 | self.assertFalse(enabled) 393 | 394 | def test_stop_running(self): 395 | if tethering.enabled(): 396 | tethering.stop() 397 | else: 398 | raise Exception("test wasn't run in proper order") 399 | enabled = tethering.enabled() 400 | self.assertFalse(enabled) 401 | 402 | def test_restart_not_running(self): 403 | if not tethering.enabled(): 404 | tethering.restart() 405 | else: 406 | raise Exception("test wasn't run in proper order") 407 | self.assertTrue(tethering.enabled()) 408 | 409 | def test_restart_running(self): 410 | if tethering.enabled(): 411 | tethering.restart() 412 | else: 413 | raise Exception("test wasn't run in proper order") 414 | enabled = tethering.enabled() 415 | self.assertTrue(enabled) 416 | 417 | 418 | class TestUnsupported(BaseTestCase): 419 | 420 | def test_restart(self): 421 | """ 422 | verify tethering.Error is raised on restart 423 | """ 424 | with self.assertRaises(tethering.Error): 425 | tethering.restart() 426 | 427 | def test_stop(self): 428 | """ 429 | verify tethering.Error is raised on stop 430 | """ 431 | with self.assertRaises(tethering.Error): 432 | tethering.stop() 433 | 434 | def test_start(self): 435 | """ 436 | verify tethering.Error is raised on start 437 | """ 438 | with self.assertRaises(tethering.Error): 439 | tethering.start() 440 | 441 | def test_tethered_caching(self): 442 | """ 443 | verify tethering.Error is raised on tethered_caching 444 | """ 445 | with self.assertRaises(tethering.Error): 446 | tethering.tethered_caching('-b') 447 | 448 | 449 | @unittest.skip("Unfinished") 450 | class TestTetheringStatus(BaseTestCase): 451 | """ 452 | test_enabled_refreshes_state() 453 | """ 454 | pass 455 | 456 | 457 | # Extra 458 | def os_version(): 459 | cmd = ['/usr/sbin/system_profiler', 'SPSoftwareDataType', '-xml'] 460 | out = subprocess.check_output(cmd) 461 | info = plistlib.readPlistFromString(out)[0] 462 | os_ver = info['_items'][0]['os_version'] # 'macOS 10.12.6 (16G1510)' 463 | return os_ver.split(" ")[1] # '10.12.6' 464 | 465 | 466 | # Test Loading 467 | def genericTests(loader): 468 | testcases = [TestTetherator, TestDevices, TestEnabled] 469 | suites = [] 470 | for cls in testcases: 471 | suites.append(loader.loadTestsFromTestCase(cls)) 472 | return unittest.TestSuite(suites) 473 | 474 | 475 | def sierraTests(loader): 476 | testcases = [TestSierraParser] 477 | suites = [] 478 | for cls in testcases: 479 | suites.append(loader.loadTestsFromTestCase(cls)) 480 | return unittest.TestSuite(suites) 481 | 482 | 483 | def nonSierraTests(loader): 484 | testcases = [TestUnsupported] 485 | suites = [] 486 | for cls in testcases: 487 | suites.append(loader.loadTestsFromTestCase(cls)) 488 | return unittest.TestSuite(suites) 489 | 490 | 491 | def extended_tests(loader): 492 | tests = [# stopped 493 | 'test_stop_not_running', 494 | 'test_start_not_running', 495 | #running 496 | 'test_restart_running', 497 | 'test_start_running', 498 | 'test_stop_running', 499 | #stopped 500 | 'test_restart_not_running'] 501 | return unittest.TestSuite(map(ActionTests, tests)) 502 | 503 | 504 | if __name__ == '__main__': 505 | loader = unittest.TestLoader() 506 | generic = genericTests(loader) 507 | if os_version().startswith('10.12'): 508 | dynamic = sierraTests(loader) 509 | else: 510 | dynamic = nonSierraTests(loader) 511 | 512 | suites = unittest.TestSuite([generic, dynamic]) 513 | if '--extended' in sys.argv: 514 | _extended = extended_tests(loader) 515 | suites = unittest.TestSuite([generic, dynamic, _extended]) 516 | 517 | unittest.TextTestRunner(verbosity=1).run(suites) 518 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AEiOS (Automated Enterprise iOS) 2 | 3 | *AEiOS* is a python library designed to aid the automation of Apple *iOS* device management, configuration, and imaging. Originally designed for our in-house Student Checkout iPads, we wanted to provide our students and patrons the ability to use our iPads without restrictions as if they were personal devices. Users can configure the devices however they like, install their own applications, and even use iCloud, while we (MacAdmins) maintain user data privacy between each checkout. 4 | 5 | By integrating the best features of Apple's **Apple Configurator**, **Device Enrollment Program** (DEP), **Mobile Device Management** (MDM) and **Volume Purchase Program** (VPP). We have created a completely automated, and truly zero-touch solution for *iOS* device checkout using free and native Apple *macOS* solutions that requires no interaction by our very busy support staff other than plugging in with checkin. 6 | 7 | # Contents 8 | 9 | * [Download](#download) 10 | * [Setup and Configuration](#setup-and-configuration) 11 | * [System Requirements](#system-requirements) 12 | * [General Configuration](#configuration) 13 | * [Usage](#configuration) 14 | * [Wi-Fi Profile](#wi-fi-profile) 15 | * [Supervision Identity](#supervision-identity) 16 | * [Custom Backgrounds](#custom-backgrounds) 17 | * [Reporting](#reporting) 18 | * [Application Installation](#automating-application-installation) 19 | * [Configuration](#automating-application-installation) 20 | * [Running Automation](#running-automation) 21 | * [How it Works](#under-the-hood) 22 | * [Reset](#erasing-devices) 23 | * [Supervision](#device-supervision) 24 | * [App Installation](#vpp-app-installation) 25 | * [Verification](#verification) 26 | * [Load Balancing](#load-balancing) 27 | * [Caveats](#caveats) 28 | * [GUI Device Identification](#erasing-devices) 29 | * [Unsupervised Devices](#erasing-devices) 30 | * [Troubleshooting](#troubleshooting) 31 | * [Uninstallation](#uninstallation) 32 | * [Supporting Files](#files) 33 | * [Contact](#contact) 34 | * [Update History](#update-history) 35 | 36 | 37 | # Download 38 | 39 | The latest release is available for download [here](../../releases). Uninstallation instructions are provided [below](#uninstallation). 40 | 41 | 42 | # Setup and Configuration 43 | 44 | 45 | ### System Requirements 46 | 47 | Make sure you have [Apple Configurator 2](https://itunes.apple.com/us/app/apple-configurator-2/id1037126344) installed as well as its [automation tools](https://support.apple.com/guide/apple-configurator-2/command-line-tool-installation-cad856a8ea58). *AEiOS* will not be able to perform any tasks without these tools installed. 48 | 49 | ### Configuration 50 | 51 | Because device automation is enterprise specific, *AEiOS* will need some site specific configuration before automation will work properly. It comes with a tool designed for just that purpose called `aeiosutil`. 52 | 53 | Here is an example of general configuration: 54 | ```bash 55 | $ aeiosutil add wifi 56 | $ aeiosutil add identity --p12 57 | $ aeiosutil add image --background 58 | $ aeiosutil configure slack "https://slack.webhook" "#aeios-channel" 59 | $ aeiosutil add app "Microsoft Word" 60 | $ aeiosutil add app "Google Docs: Sync, Share, Edit" 61 | $ aeiosutil start 62 | ``` 63 | 64 | Most configuration will be a one-time process, but as you need to update various parts (i.e. automate new apps/remove old apps, change backgrounds, different wifi, etc.) `aeiosutil` will be your go-to tool. 65 | 66 | 67 | ### Usage 68 | 69 | Each sub-command for `aeiosutil` has it's own help page, as do most of the arguments themselves. All of the following commands will provide different help pages: 70 | ```bash 71 | $ aeiosutil --help 72 | $ aeiosutil add --help 73 | $ aeiosutil add identity --help 74 | ``` 75 | 76 | ### Wi-Fi Profile 77 | 78 | A working Wi-Fi profile is necessary for [DEP Re-Enrollment](#device-supervision), and can be added via: 79 | ```bash 80 | $ aeiosutil add wifi /path/to/wifi.mobileconfig 81 | ``` 82 | 83 | Be sure to test your wifi profile before adding it to *AEiOS*. The wifi profile can be removed via: 84 | ```bash 85 | $ aeiosutil remove wifi 86 | ``` 87 | 88 | 89 | ### Supervision Identity 90 | 91 | Some automated actions can only be performed on supervised devices (specifically [Custom Backgrounds](#custom-backgrounds), and [Load Balancing](#load-balancing)). These actions will necessitate *AEiOS* to have access to the same supervision identity used to manage the device in your MDM. 92 | 93 | Your MDM should have a mechanism for exporting your supervision identity used for your DEP. If you've already added your supervision identity to **Apple Configurator**, it can be [exported](https://support.apple.com/en-us/HT207434) from there. Once exported it can be added one of two ways: 94 | 95 | Import password protected pkcs: 96 | ```bash 97 | $ aeiosutil add identity --p12 /path/to/supervision_identity.p12 98 | ``` 99 | 100 | Import unencrypted supervision identity certificates: 101 | ```bash 102 | $ aeiosutil add identity --certs /path/to/exported/certs/directory 103 | ``` 104 | 105 | Importing your supervision identity is not required for *AEiOS* to automate [Resetting Devices](#erasing-devices), [DEP Re-Enrollment](#device-supervision), or [VPP App Installation](#vpp-app-installation). 106 | 107 | 108 | ### Custom Backgrounds 109 | 110 | Once all automation and verification is completed, *AEiOS* will set the Lock and Home screen with an image of your choosing, if provided. 111 | 112 | Add custom background image: 113 | ```bash 114 | $ aeiosutil add image --background /path/to/image 115 | ``` 116 | 117 | Setting the background requires the device to be supervised and an imported [supervision identity](#supervision-identity). Unsupervised devices will skip this step. 118 | 119 | 120 | ### Reporting 121 | 122 | In order to keep your library of apps up-to-date and relevant, any apps installed on devices outside of *AEiOS* will be reported as they are encountered. Reporting is handled via [Slack Incoming Webhooks](https://api.slack.com/incoming-webhooks). 123 | 124 | It can be configured via: 125 | ```bash 126 | $ aeiosutil configure slack 'https://slack.webhook.url' '#channel-name' 127 | ``` 128 | 129 | Additionally, critical errors with automation that require attention will also be reported to Slack. 130 | 131 | 132 | ## Automating Application Installation 133 | 134 | All *iOS* app installation is done using **Apple Configurator 2** GUI. You'll need to have VPP apps purchased and available for *AEiOS* to be able to automatically install them. 135 | 136 | In **Apple Configurator 2**: 137 | 138 | 1. View > List > Add UDID column 139 | 2. Sign into your VPP account 140 | 3. Specify apps to automatically install 141 | 142 | 143 | ### Configuring App Installation 144 | 145 | `aeiosutil` can be used to specify apps to be installed during automation. Each app has to be added via its iTunes name (**Apple Configurator 2** > **Actions Menu** > **Add** > **Apps…** > "Name" column). You'll be prompted for Accessibility Access the first time apps are Installed (see [VPP App Installation](#vpp-app-installation)). 146 | 147 | The app name has to be added **exactly** as it appears in **Apple Configurator**. Be sure you have enough available licenses for all of your devices. 148 | 149 | 150 | Adding apps: 151 | ```bash 152 | $ aeiosutil add app "Microsoft Word" 153 | $ aeiosutil add app "Google Docs: Sync, Share, Edit" 154 | ``` 155 | 156 | Removing apps: 157 | ```bash 158 | $ aeiosutil remove app "Microsoft Word" 159 | ``` 160 | 161 | Additional help: 162 | ```bash 163 | $ aeiosutil add app --help 164 | $ aeiosutil remove app --help 165 | ``` 166 | 167 | 168 | ## Running Automation 169 | 170 | Starting automation: 171 | ```bash 172 | $ aeiosutil start 173 | ``` 174 | 175 | Stopping automation: 176 | ```bash 177 | $ aeiosutil stop 178 | ``` 179 | 180 | Start *AEiOS* automatically at login: 181 | ```bash 182 | $ aeiosutil start --login 183 | ``` 184 | 185 | Stop *AEiOS* from automatically running at login: 186 | ```bash 187 | $ aeiosutil stop --login 188 | ``` 189 | 190 | 191 | # Under The Hood 192 | 193 | *AEiOS* essentially performs 6 tasks: 194 | 195 | 1. Erase 196 | 2. Re-Enroll via DEP 197 | 3. Install VPP Apps (optional) 198 | 4. Customization (optional) 199 | 5. Verification 200 | 6. Load Balancing 201 | 202 | 203 | ## Erasing Devices 204 | 205 | When an *iOS* device is connected for the first time to a *macOS* system running *AEiOS*, you will be given following choices: 206 | 207 | A) Enable automation for the device which will cause it to be automatically 208 | erased each time it is connected to the system, 209 | B) Ignore the device and permanently exclude it from automation. 210 | C) Cancel 211 | 212 | Currently, the "Ignore" and "Erase" options are not configurable apart from this first prompt. This will probably change in the near future. 213 | 214 | If you select "Cancel", you'll be re-prompted each time this device connects until another choice is made. 215 | 216 | If you've accidentally ignored a device you want automated you can always reset *AEiOS* to a default state (see [Troubleshooting](#troubleshooting)) 217 | 218 | 219 | ### WARNING - THIS SOFTWARE IS DESIGNED TO AUTOMATICALLY ERASE iOS DEVICES!! 220 | 221 | *AEiOS* will erase any *iOS* devices (including iPhones) that been instructed to [Trust This Computer](https://support.apple.com/en-us/HT202778). 222 | 223 | While efforts have been made to limit automation to explicitly specified devices. You may find an edge-case and accidentally erase a device erroneously. **Be sure to test your setup thoroughly**... Please don't use a computer with *AEiOS* installed to charge your phone... 224 | 225 | Bugs submitted regarding an "Accidental Erase" will be redirected [here](https://i.imgur.com/HB9eUe4.png). 226 | 227 | *You've been warned...* 228 | 229 | 230 | ## Device Supervision 231 | 232 | Device supervision is handled via DEP, and while it's *technically* not required, I'm not sure how gracefully *AEiOS* handles non-DEP devices (see [Intentionally Unsupervised Devices](#Intentionally-unsupervised-devices)). If this is proves problematic in your environment, submit a bug, and I'll do my best to integrate non-DEP supervision and/or non-supervised device automation. 233 | 234 | Because DEP Enrollment requires *iOS* device network, DEP Re-enrollment and device supervision cannot be done without a [Wi-Fi profile](#wi-fi-profile). 235 | 236 | Though I put a lot of work to integrate Tethered-Caching as an alternative network mechanism, Apple has refused to support *iOS* device tethering since releasing *iOS 12*. I could rant and complain (in detail), but it's not going to change the fact that it's currently inoperable. I've included the tethering library in *AEiOS*, but it doesn't really do much. 237 | 238 | Enabling **Content Caching** in **System Preferences** > **Sharing** will lessen the load on your network for App installation, but a working Wi-Fi profile is still required. 239 | 240 | If the Wi-Fi Profile works, via MDM or **Apple Configurator 2**, it will work with *AEiOS* and because DEP only requires a device to have network connectivity for few seconds, I suggest setting the profile to automatically remove itself, but hey... do whatever. 241 | 242 | 243 | ## VPP App Installation 244 | 245 | Because of inconsistencies with "Best Effort" MDM app installation, and instability with *iOS* device tethering, *AEiOS* automates the installation of VPP apps via the **Apple Configurator 2** GUI. There is not currently a (viably configurable) way to manually install VPP apps other than with the **Apple Configurator** GUI. 246 | 247 | However, utilizing System Events comes with baggage... namely **Accessibility Access**. 248 | 249 | With known exploits, Apple is particularly sensitive about granting Accessibility Access to anything that asks, but it's also not very consistant with how Accessibility Access is handled. As far as I can tell, any script executed by `cfgutil` (Apple's own automation tool) executes scripts directly from `/bin/sh`, which means it *needs* Accessibility Access in order for the GUI automation to work. 250 | 251 | I have figured out a way to circumvent giving Accessibility Access to `/bin/sh`, but it is going to require some significant refactoring, and will not be included in the initial release. 252 | 253 | In this version of *AEiOS*, `checkout_ipads.py` and `/bin/sh` will *BOTH* need to be given Accessibility Access for VPP App installation to work. If that's considered too great of an insecurity, VPP App Installation does not need to be implemented via *AEiOS* and you can install apps via other, institutionally applicable mechanisms. 254 | 255 | Securing Accessibility Access requirements is my top-most priority, and will be addressed before additional features are released. 256 | 257 | While `cfgutil` *does* have an `install-apps` subcommand that would circumvent the need for Accessibility Access altogether, but the catch is that it only works with local .ipa files. As far as I can tell, Apple has removed the ability to easily save .ipa files locally on a system, however, even with local .ipa's, `cfgutil` lacks the ability to assign VPP app licenses, making `install-apps` almost entirely useless. 258 | 259 | We've submitted a feature request to update `install-apps` to work with VPP apps, but I'm not holding my breath... If the feature is added though, it will immediately be leveraged and integrated into *AEiOS*. 260 | 261 | Utilizing **Apple Configurator 2** to install apps, means that it comes with some overhead has taken a significant amount of effort to mitigate. That being said, sometimes **Apple Configurator 2** just freaks out for no reason... 262 | 263 | I've integrated some blanket fault tolerance for the most common issues. (Internal VPP errors, App not available, unable to assign license, etc.), but GUI's are difficult to test, especially when the GUI is someone else's. 264 | 265 | I'll be improving app installation as development continues. 266 | 267 | 268 | ## Verification 269 | 270 | Due to inherent uncertainty of a device's state (e.g. random disconnects, false checkouts, internal VPP errors, etc.) *AEiOS* has a lot of built-in fault tolerance. I'm constantly surprised how many errors are fixed simply by "trying again with fewer devices", so instead of failing on an error, it just moves along to the next step. 271 | 272 | After any given round of automation is completed, *AEiOS* verifies each device and re-tasks any steps that failed. All verified devices are load-balanced and a smaller subset of tasks are performed. 273 | 274 | Due to random intermittent issues with VPP App Installation. App verification is only performed 3 times and failure is reported ([if configured](#reporting)) after a 3rd unsuccessful attempt. 275 | 276 | 277 | ## Load Balancing 278 | 279 | A single system tends to get overloaded around 9-10 *iOS* devices, this causes the USB bus to start acting oddly and as a result, devices can randomly disconnect in the middle of automation, drop all connections when another device is reconnected, or keep devices from connecting to the system at all. 280 | 281 | Because VPP accounts can only be tied to one system at a time, you'll either have to have multiple VPP accounts, or limit the number of devices connected to a single system at a time. 282 | 283 | To mitigate this issue as much as possible, devices that have successfully completed all automation are shutdown to limit the number of active connections on a single USB bus. 284 | 285 | Because there is not (currently) a way to determine if a device was checked out other than it is no longer connected to the system, a device will be re-erased if it is reconnected after being shutdown (for more than 5 minutes). 286 | 287 | Load balancing cannot be performed without supervised devices and an [imported supervision identity](#supervision-identity). 288 | 289 | 290 | ## CAVEATS 291 | 292 | ### Apple Configurator UDID Column and Sorting 293 | 294 | Devices are identified in the **Apple Configurator** GUI via the device's UDID, so if that column is missing, app installation will fail. 295 | 296 | Before running *AEiOS*, make sure the **UDID** column is present, and used for device sorting. If **Apple Configurator** is set to sort by device name, and those names are modified during device selection, weird things can happen. Sorting via UDID will keep everything very consistent and keep things running smoothly. 297 | 298 | 299 | ### Intentionally Unsupervised Devices 300 | 301 | Device supervision *is* one of the hard-coded verification step in *AEiOS*, so if a device is left unsupervised, it will not be counted "verified" and it will continuously attempt to re-supervise the device. 302 | 303 | Although Erase and App installation will still take place if a device is left intentionally unsupervised, custom backgrounds are skipped, and load-balancing cannot take place. 304 | 305 | This may be addressed in a future release. 306 | 307 | 308 | ## Troubleshooting 309 | 310 | *AEiOS* is designed to work from scratch, so all `.plist` files located in `~/Library/aeios` can be safely deleted, but you might lose some configuration 311 | 312 | If you ever need to simply "reset" *AEiOS*, you can safely run the following command without deleting any existing configuration: 313 | 314 | ```bash 315 | $ aeiosutil stop 316 | $ find ~/Library/aeios -name "*.plist" -not -name "*apps.plist" -delete 317 | $ aeiosutil start 318 | ``` 319 | 320 | **NOTE**: Don't rely on this one-liner in future releases, but until I add the functionality to `aeiosutil`, the example above is the de-facto, non-destructive way clear everything and start fresh. 321 | 322 | **WARNING**: This will also delete all *iOS* device records, as well as ignored devices, so each device will re-prompt the next time it reconnects to the system and any device that is currently connected to the system will be re-erased. 323 | 324 | 325 | ## Uninstallation 326 | 327 | *Uninstall AEiOS.app* is included with the installer, as well as: `/Library/Python/2.7/site-packages/aeios/scripts`. 328 | 329 | Alternatively, you can manually run the uninstall script with: 330 | ```bash 331 | $ sudo /Library/Python/2.7/site-packages/aeios/scripts/uninstall.sh 332 | ``` 333 | 334 | The uninstaller will remove all trace of *AEiOS* from the system including itself. This includes all user files (e.g. logs, supervision identities, images, profiles, and preferences) so if you want to save them, copy them before-hand. 335 | 336 | ### Files 337 | 338 | I always find myself wanting to know where certain files are kept, so I've made sure to include that information here for those who are interested. 339 | 340 | Installed files can be listed via the command-line: 341 | ```bash 342 | $ pkgutil --files "edu.utah.mlib.aeios" 343 | ``` 344 | 345 | Most configuration and supporting files are located in: `~/Library/aeios` 346 | 347 | Logs: ~/Library/aeios/Logs 348 | LaunchAgent: ~/Library/LaunchAgents/edu.utah.mlib.aeios.plist 349 | Preferences: ~/Library/Preferences/edu.utah.mlib.aeios.plist 350 | 351 | 352 | All of these files listed above are removed by the uninstaller. 353 | 354 | 355 | # Contact 356 | 357 | Issues/bugs can be reported [here](../../issues). If you have any questions or comments, feel free to [email us](mailto:mlib-its-mac-github@lists.utah.edu). 358 | 359 | Thanks! 360 | 361 | 362 | # Update History 363 | 364 | | Date | Version | Description 365 | |------------|:-------:|------------------------------------------------------| 366 | | 2019-04-25 | 1.0.0 | Initial Release 367 | --------------------------------------------------------------------------------