├── 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 |
--------------------------------------------------------------------------------