diff --git a/.codacy.yml b/.codacy.yml index 0701063..ecc3b91 100644 --- a/.codacy.yml +++ b/.codacy.yml @@ -1,6 +1,5 @@ --- exclude_paths: - 'aiojss/**' - - 'tools/**' - 'scripts/**' - 'extension_attributes/**' diff --git a/.gitignore b/.gitignore index ed50ea2..0666066 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ extension_attributes/* !extension_attributes/Last User/ scripts/* !scripts/Install Software Updates/ +!scripts/templates +!extension_attributes/templates +tools/ci_tests/computers.json diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index e45ba81..56e4c75 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -8,19 +8,19 @@ In the interest of fostering an open and welcoming environment, we as contributo Examples of behavior that contributes to creating a positive environment include: -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members Examples of unacceptable behavior by participants include: -* The use of sexualized language or imagery and unwelcome sexual attention or advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a professional setting +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities diff --git a/README.md b/README.md index cd9a455..f479f50 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,31 @@ # git2jss +[![Codacy Badge](https://app.codacy.com/project/badge/Grade/c49c0bd6a88d4f1e8c6808455171178e)](https://app.codacy.com/gh/rustymyers/git2jss/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) A fast asynchronous python library for syncing your scripts in git with your JSS easily. This allows admins to keep their script in a version control system for easy updating rather than googling and copy-pasting from resources that they find online. ## Getting Started -1. Fork the Project -2. Install [Python version 3.6](https://www.python.org/downloads/) or higher. (this is because of the async requirements) -3. Run `python3.6 -m pip install -r requirements.txt` to install required modules -4. Run `./tools/download.py --url https://your.jss.url:8443 --username api_user` to download all scripts and extension attributes to the repository -5. Run `./sync.py --url https://your.jss.url:8443 --username api_user` to sync all scripts back to your JSS +1. Fork the Project +2. Install [Python version 3.6](https://www.python.org/downloads/) or higher. (this is because of the async requirements) +3. Run `python3.6 -m pip install -r requirements.txt` to install required modules +4. Run `./tools/download.py --url https://your.jss.url:8443 --username api_user` to download all scripts and extension attributes to the repository +5. Run `./sync.py --url https://your.jss.url:8443 --username api_user` to sync all scripts back to your JSS Optional flags for `download.py`: -- `--password` for CI/CD (Will prompt for password if not set) -- `--do_not_verify_ssl` to skip ssl verification -- `--overwrite` to overwrite all scripts and extension attributes +- `--password` for CI/CD (Will prompt for password if not set) +- `--do_not_verify_ssl` to skip ssl verification +- `--overwrite` to overwrite all scripts and extension attributes Optional flags for `sync.py`: -- `--password` for CI/CD (Will prompt for password if not set) -- `--do_not_verify_ssl` to skip ssl verification -- `--overwrite` to overwrite all scripts and extension attributes -- `--limit` to limit max connections (default=25) -- `--timeout` to limit max connections (default=60) -- `--verbose` to add additional logging -- `--update_all` to upload all resources in `./extension_attributes` and `./scripts` -- `--jenkins` to write a Jenkins file:`jenkins.properties` with `$scripts` and `$eas` and compare `$GIT_PREVIOUS_COMMIT` with `$GIT_COMMIT` +- `--password` for CI/CD (Will prompt for password if not set) +- `--do_not_verify_ssl` to skip ssl verification +- `--overwrite` to overwrite all scripts and extension attributes +- `--limit` to limit max connections (default=25) +- `--timeout` to limit max connections (default=60) +- `--verbose` to add additional logging +- `--update_all` to upload all resources in `./extension_attributes` and `./scripts` +- `--jenkins` to write a Jenkins file:`jenkins.properties` with `$scripts` and `$eas` and compare `$GIT_PREVIOUS_COMMIT` with `$GIT_COMMIT` ### [ConfigParser](https://docs.python.org/3/library/configparser.html) (Optional): @@ -32,9 +33,9 @@ A config file can be created in the project root or the users home folder. When A jamfapi.cfg file can provide the following variables: - - username - - password - - url +- username +- password +- url ### Prerequisites git2jss requires [Python 3.6](https://www.python.org/downloads/) and the python modules listed in `requirements.txt` diff --git a/extension_attributes/templates/Last User/ea.sh b/extension_attributes/templates/Last User/ea.sh new file mode 100644 index 0000000..059b4d3 --- /dev/null +++ b/extension_attributes/templates/Last User/ea.sh @@ -0,0 +1,7 @@ +#!/bin/sh +lastUser=`defaults read /Library/Preferences/com.apple.loginwindow lastUserName` + +if [ $lastUser == "" ]; then + echo "No logins" +else + echo "$lastUser" \ No newline at end of file diff --git a/extension_attributes/templates/Last User/ea.xml b/extension_attributes/templates/Last User/ea.xml new file mode 100644 index 0000000..8195681 --- /dev/null +++ b/extension_attributes/templates/Last User/ea.xml @@ -0,0 +1,13 @@ + + + Last User + true + This attribute displays the last user to log in. This attribute applies to both Mac and Windows. + String + + script + Mac + diff --git a/sync.py b/sync.py index f39f433..297e3d7 100755 --- a/sync.py +++ b/sync.py @@ -1,464 +1,413 @@ #!/usr/bin/env python3 # pylint: disable=missing-docstring,invalid-name -import warnings -import os -from os.path import dirname, join, realpath -import sys -import xml.etree.ElementTree as ET -import getpass + +"""Synchronize Jamf Pro scripts and computer extension attributes from a repository.""" + +from __future__ import annotations + import argparse -import logging import asyncio -import async_timeout -import aiohttp -import uvloop -import configparser -import requests import configparser +import getpass +import logging +import os +import subprocess +import sys +import warnings +from dataclasses import dataclass, field +from pathlib import Path +from typing import Sequence +from urllib.parse import quote + +import aiohttp import requests +from defusedxml import ElementTree as eTree + +try: + import uvloop +except ImportError: + uvloop = None + logging.basicConfig( level=logging.DEBUG, format="%(levelname)7s: %(message)s", stream=sys.stderr, ) -LOG = logging.getLogger("") +LOG = logging.getLogger(__name__) -# The Jenkins file will contain a list of changes scripts and eas -# in $scripts and $eas. -# Use this variable to add a Slack emoji in front of each item if -# you use a post-build action for a Slack custom message SLACK_EMOJI = ":white_check_mark: " -SUPPORTED_SCRIPT_EXTENSIONS = ("sh", "py", "pl", "swift", "rb") -SUPPORTED_EA_EXTENSIONS = ("sh", "py", "pl", "swift", "rb") -CATEGORIES = [] - - -# https://github.com/lazymutt/Jamf-Pro-API-Sampler/blob/5f8efa92911271248f527e70bd682db79bc600f2/jamf_duplicate_detection.py#L99 -def get_uapi_token(): - """ - fetches api token - """ - jamf_test_url = url + "/api/v1/auth/token" - response = requests.post(url=jamf_test_url, auth=(username, password)) - response_json = response.json() - return response_json["token"] +SUPPORTED_SCRIPT_EXTENSIONS = {"sh", "py", "pl", "swift", "rb"} +SUPPORTED_EA_EXTENSIONS = {"sh", "py", "pl", "swift", "rb"} +SUCCESS_STATUSES = {200, 201} -def invalidate_uapi_token(uapi_token): - """ - invalidates api token - """ - jamf_test_url = url + "/api/v1/auth/invalidate-token" - headers = {"Accept": "*/*", "Authorization": "Bearer " + uapi_token} - _ = requests.post(url=jamf_test_url, headers=headers) +@dataclass(frozen=True) +class AppSettings: + """Resolved application settings.""" + url: str + username: str + password: str + sync_path: Path -# https://github.com/lazymutt/Jamf-Pro-API-Sampler/blob/5f8efa92911271248f527e70bd682db79bc600f2/jamf_duplicate_detection.py#L99 -def get_uapi_token(): - """ - fetches api token - """ - jamf_test_url = url + "/api/v1/auth/token" - response = requests.post(url=jamf_test_url, auth=(username, password)) - response_json = response.json() - return response_json["token"] - - -def invalidate_uapi_token(uapi_token): - """ - invalidates api token - """ - jamf_test_url = url + "/api/v1/auth/invalidate-token" - headers = {"Accept": "*/*", "Authorization": "Bearer " + uapi_token} - _ = requests.post(url=jamf_test_url, headers=headers) - - -def check_for_changes(): - """Looks for files that were changed between the current commit and - the last commit so we don't upload everything on every run - --jenkins will utilize $GIT_PREVIOUS_COMMIT and $GIT_COMMIT - environmental variables - --update_all can be invoked to upload all scripts and - extension attributes - """ - # This line will work with the environmental variables in Jenkins - if args.jenkins: - git_changes = ( - os.popen("git diff --name-only $GIT_PREVIOUS_COMMIT $GIT_COMMIT") - .read() - .split("\n") + +@dataclass +class RuntimeContext: + """Mutable state shared by one synchronization run.""" + + args: argparse.Namespace + settings: AppSettings + token: str = "" + changed_scripts: list[str] = field(default_factory=list) + changed_ext_attrs: list[str] = field(default_factory=list) + categories: set[str] = field(default_factory=set) + + @property + def url(self) -> str: + return self.settings.url + + @property + def username(self) -> str: + return self.settings.username + + @property + def password(self) -> str: + return self.settings.password + + @property + def sync_path(self) -> Path: + return self.settings.sync_path + + +class JamfSync: + """Manage asynchronous Jamf Classic API synchronization operations.""" + + def __init__(self, context: RuntimeContext): + self.ctx = context + self.session: aiohttp.ClientSession | None = None + self.semaphore = asyncio.BoundedSemaphore(context.args.limit) + + @property + def headers(self) -> dict[str, str]: + return { + "Accept": "application/xml", + "Content-Type": "application/xml", + "Authorization": f"Bearer {self.ctx.token}", + } + + async def run(self) -> None: + """Create the HTTP session and run all synchronization operations.""" + connector = aiohttp.TCPConnector( + ssl=False if self.ctx.args.do_not_verify_ssl else None ) + timeout = aiohttp.ClientTimeout(total=self.ctx.args.timeout) - # Compare the last two commits to determine the list of files that - # were changed - else: - git_commits = ( - os.popen('git log -2 --pretty=oneline --pretty=format:"%h"') - .read() - .split("\n") + async with aiohttp.ClientSession( + connector=connector, + timeout=timeout, + headers=self.headers, + ) as session: + self.session = session + self.ctx.categories = await self.get_existing_categories() + LOG.debug("Found %d Jamf categories", len(self.ctx.categories)) + + script_results, ea_results = await asyncio.gather( + self.upload_scripts(), + self.upload_extension_attributes(), + ) + + failures = [ + *[name for name, status in script_results if status not in SUCCESS_STATUSES], + *[name for name, status in ea_results if status not in SUCCESS_STATUSES], + ] + if failures: + raise RuntimeError( + "One or more Jamf objects failed to upload: " + ", ".join(failures) + ) + + def require_session(self) -> aiohttp.ClientSession: + if self.session is None: + raise RuntimeError("JamfSync session has not been initialized") + return self.session + + async def request(self, method: str, endpoint: str, **kwargs) -> tuple[int, str]: + """Perform one concurrency-limited Jamf request and return status/body.""" + session = self.require_session() + url = f"{self.ctx.url}{endpoint}" + + async with self.semaphore: + async with session.request(method, url, **kwargs) as response: + body = await response.text() + if self.ctx.args.verbose: + LOG.debug("%s %s returned HTTP %s", method, endpoint, response.status) + return response.status, body + + async def get_existing_categories(self) -> set[str]: + status, body = await self.request("GET", "/JSSResource/categories") + if status not in SUCCESS_STATUSES: + LOG.warning("Unable to retrieve Jamf categories: HTTP %s", status) + return set() + + root = eTree.fromstring(body) + return { + category.text + for category in root.findall("category/name") + if category.text + } + + async def upload_scripts(self) -> list[tuple[str, int]]: + scripts = self._selected_directories( + root=self.ctx.sync_path / "scripts", + changed_names=self.ctx.changed_scripts, + object_label="scripts", ) - command = "git diff --name-only" + " " + git_commits[1] + " " + git_commits[0] - git_changes = os.popen(command).read().split("\n") - for i in git_changes: - if "extension_attributes/" in i and i.split("/")[1] not in changed_ext_attrs: - changed_ext_attrs.append(i.split("/")[1]) + if not scripts: + LOG.info("No scripts selected for upload") + return [] - for i in git_changes: - if "scripts/" in i and i.split("/")[1] not in changed_scripts: - changed_scripts.append(i.split("/")[1]) + results = await asyncio.gather( + *(self.upload_script(script_name) for script_name in scripts) + ) + return list(zip(scripts, results)) + async def upload_script(self, script_name: str) -> int: + script_dir = self.ctx.sync_path / "scripts" / script_name + script_file = self._first_file_with_extensions( + script_dir, + SUPPORTED_SCRIPT_EXTENSIONS, + ) -def write_jenkins_file(): - """Write changed_ext_attrs and changed_scripts to jenkins file. - $eas will contains the changed extension attributes, - $scripts will contains the changed scripts - If there are no changes, the variable will be set to 'None' - """ + if script_file is None: + LOG.warning("No script file found in scripts/%s", script_name) + return 0 - if not changed_ext_attrs: - contents = "eas=" + "None" - else: - contents = "eas=" + SLACK_EMOJI + changed_ext_attrs[0] + "\\n" + "\\" - for changed_ext_attr in changed_ext_attrs[1:]: - contents = contents + "\n" + SLACK_EMOJI + changed_ext_attr + "\\n" + "\\" + script_contents = script_file.read_text(encoding="utf-8") + template = await self.get_script_template(script_name) + name = self._ensure_name(template, script_name) - if not changed_scripts: - contents = contents.rstrip("\\") + "\n" + "scripts=" + "None" + contents_element = template.find("script_contents") + if contents_element is None: + contents_element = eTree.SubElement(template, "script_contents") + contents_element.text = script_contents - else: - contents = ( - contents.rstrip("\\") - + "\n" - + "scripts=" - + SLACK_EMOJI - + changed_scripts[0] - + "\\n" - + "\\" + status = await self._create_or_update( + resource="scripts", + name=name, + template=template, ) - for changed_script in changed_scripts[1:]: - contents = contents + "\n" + SLACK_EMOJI + changed_script + "\\n" + "\\" - - with open("jenkins.properties", "w") as f: - f.write(contents) - - -async def upload_extension_attributes(session, url, user, passwd, semaphore): - # sync_path = dirname(realpath(__file__)) - if not changed_ext_attrs and not args.update_all: - print("No Changes in Extension Attributes") - return - ext_attrs = [ - f.name - for f in os.scandir(join(sync_path, "extension_attributes")) - if f.is_dir() and f.name in changed_ext_attrs - ] - if args.update_all: - print("Copying all extension attributes...") - ext_attrs = [ - f.name - for f in os.scandir(join(sync_path, "extension_attributes")) - if f.is_dir() - ] - tasks = [] - for ea in ext_attrs: - task = asyncio.ensure_future( - upload_extension_attribute(session, url, user, passwd, ea, semaphore) + self._log_upload_result("script", name, status) + return status + + async def get_script_template(self, script_name: str): + object_dir = self.ctx.sync_path / "scripts" / script_name + template = await self._load_local_or_remote_template( + object_dir=object_dir, + fallback_path=self.ctx.sync_path / "templates" / "script.xml", + resource="scripts", + lookup_name=script_name, ) - tasks.append(task) - await asyncio.gather(*tasks) + self._normalize_category(template, add_none=False) + self._ensure_name(template, script_name) + self._log_xml(template) + return template -async def upload_extension_attribute(session, url, user, passwd, ext_attr, semaphore): - has_script = True + async def upload_extension_attributes(self) -> list[tuple[str, int]]: + extension_attributes = self._selected_directories( + root=self.ctx.sync_path / "extension_attributes", + changed_names=self.ctx.changed_ext_attrs, + object_label="extension attributes", + ) - # sync_path = dirname(realpath(__file__)) - # auth = aiohttp.BasicAuth(user, passwd) - headers = { - "Accept": "application/xml", - "Content-Type": "application/xml", - "Authorization": "Bearer " + token, - } - # Get the script files within the folder, we'll only use - # script_file[0] in case there are multiple files - script_file = [ - f.name - for f in os.scandir(join(sync_path, "extension_attributes", ext_attr)) - if f.is_file() and f.name.split(".")[-1] in SUPPORTED_EA_EXTENSIONS - ] - if script_file == []: - print("Warning: No script file found in extension_attributes/%s" % ext_attr) - has_script = False - # return # Need to skip if no script. - if has_script: - with open( - join(sync_path, "extension_attributes", ext_attr, script_file[0]), "r" - ) as f: - data = f.read() - async with semaphore: - with async_timeout.timeout(args.timeout): - template = await get_ea_template(session, url, user, passwd, ext_attr) - async with session.get( - url - + "/JSSResource/computerextensionattributes/name/" - + template.find("name").text, - headers=headers, - ) as resp: - if has_script and data: - template.find("input_type/script").text = data - if args.verbose: - print(ET.tostring(template)) - print("response status initial get: ", resp.status) - if resp.status == 200: - put_url = ( - url - + "/JSSResource/computerextensionattributes/name/" - + template.find("name").text - ) - resp = await session.put( - put_url, data=ET.tostring(template), headers=headers - ) - else: - post_url = url + "/JSSResource/computerextensionattributes/id/0" - resp = await session.post( - post_url, data=ET.tostring(template), headers=headers - ) - if args.verbose: - print("response status: ", resp.status) - print("EA: ", ext_attr) - print("EA Name: ", template.find("name").text) - if resp.status in (201, 200): - print("Uploaded Extension Attribute: %s" % template.find("name").text) - else: - print("Error uploading script: %s" % template.find("name").text) - print("Error: %s" % resp.status) - return resp.status - - -async def get_ea_template(session, url, user, passwd, ext_attr): - # auth = aiohttp.BasicAuth(user, passwd) - # sync_path = dirname(realpath(__file__)) - xml_file = [ - f.name - for f in os.scandir(join(sync_path, "extension_attributes", ext_attr)) - if f.is_file() and f.name.split(".")[-1] in "xml" - ] - try: - with open( - join(sync_path, "extension_attributes", ext_attr, xml_file[0]), "r" - ) as file: - template = ET.fromstring(file.read()) - except IndexError: - with async_timeout.timeout(args.timeout): - headers = { - "Accept": "application/xml", - "Content-Type": "application/xml", - "Authorization": "Bearer " + token, - } - - async with session.get( - url + "/JSSResource/computerextensionattributes/name/" + ext_attr, - headers=headers, - ) as resp: - if resp.status == 200: - async with session.get( - url - + "/JSSResource/computerextensionattributes/name/" - + ext_attr, - headers=headers, - ) as response: - template = ET.fromstring(await response.text()) - else: - template = ET.parse(join(sync_path, "templates/ea.xml")).getroot() - # name is mandatory, so we use the foldername if nothing is set in - # a template - if args.verbose: - print(ET.tostring(template)) - if template.find("category") and template.find("category").text not in CATEGORIES: - ET.SubElement(template, "category").text = "None" - if args.verbose: - c = template.find("category").text - print( - f"""WARNING: Unable to find category {c} in the JSS, - setting to None""" + if not extension_attributes: + LOG.info("No extension attributes selected for upload") + return [] + + results = await asyncio.gather( + *( + self.upload_extension_attribute(ext_attr) + for ext_attr in extension_attributes ) - if template.find("name") is None: - ET.SubElement(template, "name").text = ext_attr - elif not template.find("name").text or template.find("name").text is None: - template.find("name").text = ext_attr - return template - - -async def upload_scripts(session, url, user, passwd, semaphore): - # sync_path = dirname(realpath(__file__)) - - if not changed_scripts and not args.update_all: - print("No Changes in Scripts") - scripts = [ - f.name - for f in os.scandir(join(sync_path, "scripts")) - if f.is_dir() and f.name in changed_scripts - ] - if args.update_all: - print("Copying all scripts...") - scripts = [f.name for f in os.scandir(join(sync_path, "scripts")) if f.is_dir()] - - tasks = [] - for script in scripts: - task = asyncio.ensure_future( - upload_script(session, url, user, passwd, script, semaphore) ) - tasks.append(task) - await asyncio.gather(*tasks) + return list(zip(extension_attributes, results)) + async def upload_extension_attribute(self, ext_attr: str) -> int: + extension_attribute_dir = ( + self.ctx.sync_path / "extension_attributes" / ext_attr + ) + script_file = self._first_file_with_extensions( + extension_attribute_dir, + SUPPORTED_EA_EXTENSIONS, + ) -async def upload_script(session, url, user, passwd, script, semaphore): - # sync_path = dirname(realpath(__file__)) - # auth = aiohttp.BasicAuth(user, passwd) - headers = { - "Accept": "application/xml", - "Content-Type": "application/xml", - "Authorization": "Bearer " + token, - } - script_file = [ - f.name - for f in os.scandir(join(sync_path, "scripts", script)) - if f.is_file() and f.name.split(".")[-1] in SUPPORTED_SCRIPT_EXTENSIONS - ] - if script_file == []: - print("Warning: No script file found in scripts/%s" % script) - return # Need to skip if no script. - with open(join(sync_path, "scripts", script, script_file[0]), "r") as f: - data = f.read() - async with semaphore: - with async_timeout.timeout(args.timeout): - template = await get_script_template(session, url, user, passwd, script) - async with session.get( - url + "/JSSResource/scripts/name/" + template.find("name").text, - headers=headers, - ) as resp: - template.find("script_contents").text = data - if resp.status == 200: - put_url = ( - url + "/JSSResource/scripts/name/" + template.find("name").text - ) - resp = await session.put( - put_url, data=ET.tostring(template), headers=headers - ) - else: - post_url = url + "/JSSResource/scripts/id/0" - resp = await session.post( - post_url, data=ET.tostring(template), headers=headers - ) - if resp.status in (201, 200): - print("Uploaded script: %s" % template.find("name").text) - else: - print("Error uploading script: %s" % template.find("name").text) - print("Error: %s" % resp.status) - return resp.status - - -async def get_script_template(session, url, user, passwd, script): - # auth = aiohttp.BasicAuth(user, passwd) - # sync_path = dirname(realpath(__file__)) - xml_file = [ - f.name - for f in os.scandir(join(sync_path, "scripts", script)) - if f.is_file() and f.name.split(".")[-1] in "xml" - ] - try: - with open(join(sync_path, "scripts", script, xml_file[0]), "r") as file: - template = ET.fromstring(file.read()) - except IndexError: - with async_timeout.timeout(args.timeout): - headers = { - "Accept": "application/xml", - "Content-Type": "application/xml", - "Authorization": "Bearer " + token, - } - async with session.get( - url + "/JSSResource/scripts/name/" + script, headers=headers - ) as resp: - if resp.status == 200: - async with session.get( - url + "/JSSResource/scripts/name/" + script, headers=headers - ) as response: - template = ET.fromstring(await response.text()) - else: - template = ET.parse( - join(sync_path, "templates/script.xml") - ).getroot() - # name is mandatory, so we use the filename if nothing is set in a template - if args.verbose: - print(ET.tostring(template)) - if ( - template.find("category") is not None - and template.find("category").text not in CATEGORIES - ): - c = template.find("category").text - template.remove(template.find("category")) - if args.verbose: - print( - f"""WARNING: Unable to find category "{c}" in the JSS, - setting to None""" + if script_file is None: + LOG.warning( + "No script file found in extension_attributes/%s; " + "uploading the XML template without a script", + ext_attr, ) - if template.find("name") is None: - ET.SubElement(template, "name").text = script - elif not template.find("name").text or template.find("name").text is None: - template.find("name").text = script - return template - - -async def get_existing_categories(session, url, user, passwd, semaphore): - # auth = aiohttp.BasicAuth(user, passwd) - headers = { - "Accept": "application/xml", - "Content-Type": "application/xml", - "Authorization": "Bearer " + token, - } - async with semaphore: - with async_timeout.timeout(args.timeout): - async with session.get( - url + "/JSSResource/categories", headers=headers - ) as resp: - if resp.status in (201, 200): - return [ - c.find("name").text - for c in [ - e - for e in ET.fromstring(await resp.text()).findall( - "category" - ) - ] - ] - return [] - - -async def main(): - # pylint: disable=global-statement - global CATEGORIES - semaphore = asyncio.BoundedSemaphore(args.limit) - async with aiohttp.ClientSession() as session: - async with aiohttp.ClientSession( - connector=aiohttp.TCPConnector(ssl=args.do_not_verify_ssl) - ) as session: - CATEGORIES = await get_existing_categories( - session, url, username, password, semaphore - ) - await upload_scripts(session, url, username, password, semaphore) - await upload_extension_attributes( - session, url, username, password, semaphore + script_contents = None + else: + script_contents = script_file.read_text(encoding="utf-8") + + template = await self.get_ea_template(ext_attr) + name = self._ensure_name(template, ext_attr) + + if script_contents is not None: + script_element = template.find("input_type/script") + if script_element is None: + input_type = template.find("input_type") + if input_type is None: + input_type = eTree.SubElement(template, "input_type") + script_element = eTree.SubElement(input_type, "script") + script_element.text = script_contents + + status = await self._create_or_update( + resource="computerextensionattributes", + name=name, + template=template, + ) + self._log_upload_result("extension attribute", name, status) + return status + + async def get_ea_template(self, ext_attr: str): + object_dir = self.ctx.sync_path / "extension_attributes" / ext_attr + template = await self._load_local_or_remote_template( + object_dir=object_dir, + fallback_path=self.ctx.sync_path / "templates" / "ea.xml", + resource="computerextensionattributes", + lookup_name=ext_attr, + ) + + self._normalize_category(template, add_none=True) + self._ensure_name(template, ext_attr) + self._log_xml(template) + return template + + async def _load_local_or_remote_template( + self, + object_dir: Path, + fallback_path: Path, + resource: str, + lookup_name: str, + ): + xml_files = sorted( + path for path in object_dir.iterdir() if path.is_file() and path.suffix.lower() == ".xml" + ) + + if xml_files: + return eTree.parse(str(xml_files[0])).getroot() + + endpoint = f"/JSSResource/{resource}/name/{quote(lookup_name, safe='')}" + status, body = await self.request("GET", endpoint) + + if status == 200: + return eTree.fromstring(body) + + if not fallback_path.is_file(): + raise FileNotFoundError(f"Missing fallback template: {fallback_path}") + + return eTree.parse(str(fallback_path)).getroot() + + async def _create_or_update(self, resource: str, name: str, template) -> int: + encoded_name = quote(name, safe="") + lookup_endpoint = f"/JSSResource/{resource}/name/{encoded_name}" + lookup_status, _ = await self.request("GET", lookup_endpoint) + payload = eTree.tostring(template, encoding="utf-8") + + if lookup_status == 200: + status, body = await self.request("PUT", lookup_endpoint, data=payload) + elif lookup_status == 404: + create_endpoint = f"/JSSResource/{resource}/id/0" + status, body = await self.request("POST", create_endpoint, data=payload) + else: + LOG.error( + "Unable to determine whether %s '%s' exists: HTTP %s", + resource, + name, + lookup_status, ) + return lookup_status + + if status not in SUCCESS_STATUSES and body: + LOG.error("Jamf response for %s '%s': %s", resource, name, body) + return status + + def _selected_directories( + self, + root: Path, + changed_names: Sequence[str], + object_label: str, + ) -> list[str]: + if self.ctx.args.update_all: + LOG.info("Copying all %s", object_label) + return sorted(path.name for path in root.iterdir() if path.is_dir()) + + if not changed_names: + return [] + + changed_set = set(changed_names) + return sorted( + path.name + for path in root.iterdir() + if path.is_dir() and path.name in changed_set + ) + @staticmethod + def _first_file_with_extensions( + directory: Path, + extensions: set[str], + ) -> Path | None: + matches = sorted( + path + for path in directory.iterdir() + if path.is_file() and path.suffix.lower().lstrip(".") in extensions + ) + return matches[0] if matches else None + + @staticmethod + def _ensure_name(template, fallback_name: str) -> str: + name_element = template.find("name") + if name_element is None: + name_element = eTree.SubElement(template, "name") + if not name_element.text: + name_element.text = fallback_name + return name_element.text + + def _normalize_category(self, template, add_none: bool) -> None: + category = template.find("category") + if category is None or not category.text: + return + if category.text in self.ctx.categories: + return + + invalid_category = category.text + template.remove(category) + if add_none: + eTree.SubElement(template, "category").text = "None" + + LOG.warning( + 'Category "%s" does not exist in Jamf; using %s', + invalid_category, + '"None"' if add_none else "no category", + ) -if __name__ == "__main__": - asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) + def _log_xml(self, template) -> None: + if self.ctx.args.verbose: + LOG.debug("Template XML: %s", eTree.tostring(template, encoding="unicode")) + + @staticmethod + def _log_upload_result(object_type: str, name: str, status: int) -> None: + if status in SUCCESS_STATUSES: + LOG.info("Uploaded %s: %s", object_type, name) + else: + LOG.error("Error uploading %s '%s': HTTP %s", object_type, name, status) - # Export to current directory by default - sync_path = dirname(realpath(__file__)) - parser = argparse.ArgumentParser(description="Sync repo with JamfPro") +def parse_arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Sync repository with Jamf Pro") parser.add_argument("--url") parser.add_argument("--username") parser.add_argument("--password") @@ -466,74 +415,229 @@ async def main(): parser.add_argument("--limit", type=int, default=25) parser.add_argument("--timeout", type=int, default=60) parser.add_argument("--verbose", action="store_true") - parser.add_argument("--do_not_verify_ssl", action="store_false") + parser.add_argument( + "--do_not_verify_ssl", + action="store_true", + help="Disable SSL certificate verification", + ) parser.add_argument("--update_all", action="store_true") parser.add_argument("--jenkins", action="store_true") - args = parser.parse_args() + return parser.parse_args() - changed_ext_attrs = [] - changed_scripts = [] - check_for_changes() - print("Changed Extension Attributes: ", changed_ext_attrs) - print("Changed Scripts: ", changed_scripts) - if args.jenkins: - write_jenkins_file() - # Set configs file locations - CONFIG_FILE_LOCATIONS = ["jamfapi.cfg", os.path.expanduser("~/jamfapi.cfg")] - CONFIG_FILE = "" - # Parse Config File - CONFPARSER = configparser.ConfigParser() - for config_path in CONFIG_FILE_LOCATIONS: - if os.path.exists(config_path): - print("Found Config: {0}".format(config_path)) - CONFIG_FILE = config_path - - if CONFIG_FILE != "": - try: - # Get config - CONFPARSER.read(CONFIG_FILE) - except: - print("Can't read config file") - try: - username = CONFPARSER.get("jss", "username") - except: - print("Can't find username in configfile") - try: - password = CONFPARSER.get("jss", "password") - except: - print("Can't find password in configfile") - try: - url = CONFPARSER.get("jss", "server") - except: - print("Can't find url in configfile") - try: - sync_path = CONFPARSER.get("jss", "sync_path") - except: - print("Can't find sync_path in config") - - # Ask for password if not supplied via command line args - if args.password: - password = args.password - elif password is None: - password = getpass.getpass() - - if args.sync_path: - sync_path = args.sync_path - - if args.url: - url = args.url - - if args.username: - username = args.username - - token = get_uapi_token() - - loop = asyncio.get_event_loop() +def find_config_file() -> Path | None: + config_locations = ( + Path("jamfapi.cfg"), + Path.home() / "jamfapi.cfg", + ) + + for config_path in config_locations: + if config_path.is_file(): + LOG.info("Found configuration file: %s", config_path) + return config_path + return None + +def read_config_file(config_path: Path | None) -> dict[str, str | None]: + settings: dict[str, str | None] = { + "username": None, + "password": None, + "url": None, + "sync_path": None, + } + if config_path is None: + return settings + + config = configparser.ConfigParser() + config.read(config_path) + if not config.has_section("jss"): + LOG.warning("Configuration file %s has no [jss] section", config_path) + return settings + + settings["username"] = config.get("jss", "username", fallback=None) + settings["password"] = config.get("jss", "password", fallback=None) + settings["url"] = config.get("jss", "server", fallback=None) + settings["sync_path"] = config.get("jss", "sync_path", fallback=None) + return settings + + +def first_value(*values): + return next((value for value in values if value not in (None, "")), None) + + +def resolve_settings(args: argparse.Namespace) -> AppSettings: + config = read_config_file(find_config_file()) + + username = first_value(args.username, os.getenv("JAMF_API_USER"), config["username"]) + password = first_value(args.password, os.getenv("JAMF_API_PASS"), config["password"]) + url = first_value(args.url, os.getenv("MDM_URL"), config["url"]) + sync_path_value = first_value( + args.sync_path, + config["sync_path"], + str(Path(__file__).resolve().parent), + ) + + if not username: + raise ValueError("Missing required Jamf setting: username") + if not url: + raise ValueError("Missing required Jamf setting: url") + if not password: + password = getpass.getpass(f"Password for {username}: ") + + settings = AppSettings( + url=str(url).rstrip("/"), + username=str(username), + password=str(password), + sync_path=Path(str(sync_path_value)).expanduser().resolve(), + ) + validate_settings(settings) + return settings + + +def validate_settings(settings: AppSettings) -> None: + if not settings.sync_path.is_dir(): + raise ValueError(f"Sync path is not a directory: {settings.sync_path}") + + required_directories = ("scripts", "extension_attributes", "templates") + missing = [ + name + for name in required_directories + if not (settings.sync_path / name).is_dir() + ] + if missing: + raise ValueError( + "Sync path is missing required directories: " + ", ".join(missing) + ) + + +def get_uapi_token(settings: AppSettings) -> str: + response = requests.post( + f"{settings.url}/api/v1/auth/token", + auth=(settings.username, settings.password), + timeout=10, + ) + response.raise_for_status() + response_json = response.json() + token = response_json.get("token") + if not token: + raise ValueError("Jamf token response did not contain a token") + return token + + +def invalidate_uapi_token(settings: AppSettings, token: str) -> None: + response = requests.post( + f"{settings.url}/api/v1/auth/invalidate-token", + headers={"Accept": "*/*", "Authorization": f"Bearer {token}"}, + timeout=10, + ) + if response.status_code not in (200, 204): + LOG.warning("Unable to invalidate Jamf token: HTTP %s", response.status_code) + + +def git_changed_files(jenkins: bool) -> list[str]: + if jenkins: + previous_commit = os.getenv("GIT_PREVIOUS_COMMIT") + current_commit = os.getenv("GIT_COMMIT") + if not previous_commit or not current_commit: + raise ValueError( + "Jenkins mode requires GIT_PREVIOUS_COMMIT and GIT_COMMIT" + ) + command = ["git", "diff", "--name-only", previous_commit, current_commit] + else: + result = subprocess.run( + ["git", "log", "-2", "--pretty=format:%H"], + check=True, + capture_output=True, + text=True, + ) + commits = [line for line in result.stdout.splitlines() if line] + if len(commits) < 2: + LOG.warning("Fewer than two Git commits found; no changed files selected") + return [] + command = ["git", "diff", "--name-only", commits[1], commits[0]] + + result = subprocess.run( + command, + check=True, + capture_output=True, + text=True, + ) + return [line.strip() for line in result.stdout.splitlines() if line.strip()] + + +def check_for_changes(ctx: RuntimeContext) -> None: + for changed_path in git_changed_files(ctx.args.jenkins): + parts = Path(changed_path).parts + if len(parts) < 2: + continue + + if parts[0] == "extension_attributes": + if parts[1] not in ctx.changed_ext_attrs: + ctx.changed_ext_attrs.append(parts[1]) + elif parts[0] == "scripts": + if parts[1] not in ctx.changed_scripts: + ctx.changed_scripts.append(parts[1]) + + +def format_jenkins_value(items: Sequence[str]) -> str: + if not items: + return "None" + return "\\n\\\n".join(f"{SLACK_EMOJI}{item}" for item in items) + "\\n" + + +def write_jenkins_file(ctx: RuntimeContext) -> None: + contents = ( + f"eas={format_jenkins_value(ctx.changed_ext_attrs)}\n" + f"scripts={format_jenkins_value(ctx.changed_scripts)}" + ) + Path("jenkins.properties").write_text(contents, encoding="utf-8") + + +def configure_runtime(args: argparse.Namespace) -> None: if args.verbose: - loop.set_debug(True) - loop.slow_callback_duration = 0.001 warnings.simplefilter("always", ResourceWarning) - loop.run_until_complete(main()) + +def run() -> None: + args = parse_arguments() + configure_runtime(args) + settings = resolve_settings(args) + ctx = RuntimeContext(args=args, settings=settings) + + check_for_changes(ctx) + LOG.info("Changed Extension Attributes: %s", ctx.changed_ext_attrs or "None") + LOG.info("Changed Scripts: %s", ctx.changed_scripts or "None") + + if args.jenkins: + write_jenkins_file(ctx) + + try: + ctx.token = get_uapi_token(settings) + asyncio.run(JamfSync(ctx).run(), debug=args.verbose) + finally: + if ctx.token: + invalidate_uapi_token(settings, ctx.token) + + +if __name__ == "__main__": + if uvloop is not None: + uvloop.install() + + try: + run() + except KeyboardInterrupt: + LOG.warning("Synchronization interrupted by user") + sys.exit(130) + except ( + ValueError, + FileNotFoundError, + requests.RequestException, + aiohttp.ClientError, + subprocess.CalledProcessError, + RuntimeError, + ) as error: + LOG.error("%s", error) + sys.exit(1) + except Exception: + LOG.exception("Unexpected synchronization failure") + sys.exit(1) diff --git a/tools/ci_tests/validate_files_and_folders.sh b/tools/ci_tests/validate_files_and_folders.sh old mode 100644 new mode 100755 index 7433be8..81d113c --- a/tools/ci_tests/validate_files_and_folders.sh +++ b/tools/ci_tests/validate_files_and_folders.sh @@ -5,20 +5,20 @@ #Load up some variables #Define scripts and templates folders -scripts=$(ls -p scripts | grep -v '/$' | sed -e 's/\..*$//') -scripts_templates=$(ls -p scripts/templates/| sed -e 's/\..*$//') +scripts=$(ls -1 scripts | grep -v "templates") +scripts_templates=$(ls -1 scripts/templates/) #Define EA and templates -extensionattributes=$(ls -p extension_attributes | grep -v '/$' | sed -e 's/\..*$//') -extensionattributes_templates=$(ls -p extension_attributes/templates/| sed -e 's/\..*$//') +extensionattributes=$(ls -1 extension_attributes | grep -v "templates") +extensionattributes_templates=$(ls -1 extension_attributes/templates/) #Validate both the Script and the Template for the Script exist. -echo "Making sure files exist in both places in scripts and scripts/Templates" +echo "Making sure files with same names exist in both places in scripts and scripts/Templates" scriptcompare=$(sdiff -bBWsw 75 <(echo "$scripts") <(echo "$scripts_templates" )) if [ "$scriptcompare" == "" ]; then - echo "Script and Script Template Exist All good in the hood!" + echo "Script and Script Template Exist in both folders!" else echo "Errors! occurred please correct the below" echo " Scripts | Templates" @@ -31,10 +31,10 @@ fi #Valate both the EA and the Template for the EA exist. -echo "Making sure files exist in both places extension_attributes and extension_attributes/Templates" +echo "Making sure files with same names exist in both places extension_attributes and extension_attributes/Templates" eacompare=$(sdiff -bBWsw 75 <(echo "$extensionattributes") <(echo "$extensionattributes_templates")) if [ "$eacompare" == "" ]; then - echo "EA and EA Template Exist All good in the hood!" + echo "EA and EA Template Exist in both folders!" else echo "Errors! occurred please correct the below" echo " Extension Attributes | Templates" diff --git a/tools/ci_tests/validatexml.sh b/tools/ci_tests/validatexml.sh old mode 100644 new mode 100755 index b20eaeb..78f203b --- a/tools/ci_tests/validatexml.sh +++ b/tools/ci_tests/validatexml.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/bin/zsh ################################################################################### ## Validates XML for proper formatting ################################################################################### @@ -9,10 +9,10 @@ function scripts() { printf "\033[31m Working on Scripts\n" printf "\033[31m---------------------------------------------------------------------------------\n" printf "\033[0m" -scriptfolders=$(ls -ltr ./scripts | cut -c52- | awk 'NR>1') +scriptfolders=$(ls -1 ./scripts | grep -v templates) while read folder ; do echo "$folder" - xmllint --noout ./scripts/"$folder"/*.xml + xmllint --noout ./scripts/"$folder"/*.xml done <<< "$scriptfolders" } @@ -20,7 +20,7 @@ scriptfolders=$(ls -ltr ./scripts | cut -c52- | awk 'NR>1') function ea(){ - eafolders=$(ls -ltr ./extension_attributes | cut -c52- | awk 'NR>1') + eafolders=$(ls -1 ./extension_attributes | grep -v templates) printf "\033[31m---------------------------------------------------------------------------------\n" printf "\033[31m Working on Extension Attributes\n" @@ -30,7 +30,7 @@ function ea(){ echo "$folder" - xmllint --noout ./extension_attributes/"$folder"/*.xml + xmllint --noout ./extension_attributes/"$folder"/*.xml done <<< "$eafolders" } diff --git a/tools/ci_tests/verifyEA.py b/tools/ci_tests/verifyEA.py old mode 100644 new mode 100755 index 8e40a16..473f5a5 --- a/tools/ci_tests/verifyEA.py +++ b/tools/ci_tests/verifyEA.py @@ -1,91 +1,162 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import requests -from xml.etree import ElementTree as ET +from defusedxml import ElementTree as eTree import os import getpass import json +import configparser # Use this script to validate that EA values aren't changing as a result of syncing # Overwrite computers.json overwrite = False +smart_group = "1018" # Constants -url = 'https://your.jss.com' -username = getpass.getuser() -password = getpass.getpass() +# Get configs from files +CONFIG_FILE_LOCATIONS = ["jamfapi.cfg", os.path.expanduser("~/jamfapi.cfg")] +CONFIG_FILE = "" +# Parse Config File +CONFPARSER = configparser.ConfigParser() +for config_path in CONFIG_FILE_LOCATIONS: + if os.path.exists(config_path): + print("Found Config: {0}".format(config_path)) + CONFIG_FILE = config_path + +if CONFIG_FILE != "": + # Get config + CONFPARSER.read(CONFIG_FILE) + try: + username = CONFPARSER.get("jss", "username") + password = CONFPARSER.get("jss", "password") + url = CONFPARSER.get("jss", "server") + smart_group = CONFPARSER.get("verifyEA", "smart_group") + except configparser.NoOptionError: + print("Can't find configs in configfile") + except configparser.NoSectionError: + print("Can't find sections in configfile") +else: + url = "https://your.jss.com" + username = getpass.getuser() + password = getpass.getpass() + smart_group = input("Enter smart_group") + + +def get_uapi_token(): + """ + Fetches api token. + """ + jamf_test_url = url + "/api/v1/auth/token" + response = requests.post(url=jamf_test_url, auth=(username, password), timeout=5) + response_json = response.json() + return response_json["token"] -def overwrite_file(): - print('Overwriting File: computers.json...') - with open('computers.json', 'w') as f: +def invalidate_uapi_token(uapi_token): + """ + Invalidates api token. + """ + jamf_test_url = url + "/api/v1/auth/invalidate-token" + headers = {"Accept": "*/*", "Authorization": "Bearer " + uapi_token} + _ = requests.post(url=jamf_test_url, headers=headers, timeout=5) + + +def overwrite_file(file_path): + print("Overwriting File: computers.json...") + with open(file_path, "w") as f: f.write(json.dumps(computers)) -def read_file(): - print('Reading cached data from disk...') - with open('computers.json', 'r') as f: + +def read_file(file_path): + print("Reading cached data from disk...") + with open(file_path, "r") as f: computers_from_disk = json.load(f) return computers_from_disk -def build_computers_data_object(): - # Get IDs for computers - print('Communicating with the Jamf Pro Server...') + +def build_computers_data_object(token, group_id): + """ + Builds computer data into local file. + params: token, group_id + returns: computers objects json + """ + print("Communicating with the Jamf Pro Server...") computers = {} - r = requests.get(url + '/JSSResource/computergroups/id/810', - auth = (username, password), - headers= {'Content-Type': 'application/xml'}) + r = requests.get( + url + "/JSSResource/computergroups/id/{0}".format(group_id), + headers={"Content-Type": "application/xml", "Authorization": "Bearer " + token}, + timeout=5 + ) - tree = ET.fromstring(r.content) - resource_ids = [ e.text for e in tree.findall('computers/computer/id') ] + tree = eTree.fromstring(r.content) + resource_ids = [e.text for e in tree.findall("computers/computer/id")] # Download each resource and save to disk for resource_id in resource_ids: - # Get detailed information about the record - r = requests.get(url + '/JSSResource/computers/id/%s' % (resource_id), - auth = (username, password), - headers={'Content-Type': 'application/json'}) - - # Parse xml - tree = ET.fromstring(r.content) - ea_values = [ e.text for e in tree.findall('extension_attributes/extension_attribute/value') ] - ea_names = [ e.text for e in tree.findall('extension_attributes/extension_attribute/name') ] - - # Build the json for the comparison + r = requests.get( + url + "/JSSResource/computers/id/{0}".format(resource_id), + headers={"Content-Type": "application/json", "Authorization": "Bearer " + token}, + timeout=5 + ) + + # Parse xml + tree = eTree.fromstring(r.content) + ea_values = [ + e.text + for e in tree.findall("extension_attributes/extension_attribute/value") + ] + ea_names = [ + e.text + for e in tree.findall("extension_attributes/extension_attribute/name") + ] + + # Build the json for the comparison computers[resource_id] = {} - for k,v in zip(ea_names,ea_values): + for k, v in zip(ea_names, ea_values): computers[resource_id][k] = v return computers def compare_computer(computer_id): - """Compares a computer id record from live to cached copy on disk + """ + Compares a computer id record from live to cached copy on disk. params: computer_id returns: None """ print("Processing Computer ID: %s" % computer_id) for key in computers[computer_id].keys(): if computers[computer_id][key] != computers_from_disk[computer_id][key]: - print("Value Change Found\n\tEA Name:\t{}\n\tOriginal Value:\t{}\n\tNew Value:\t{}".format(key,computers[computer_id][key],computers_from_disk[computer_id][key])) + print( + "Value Change Found\n\tEA Name:\t{}\n\tOriginal Value:\t{}\n\tNew Value:\t{}".format( + key, + computers[computer_id][key], + computers_from_disk[computer_id][key], + ) + ) + # Is this the first time it was run? mypath = os.path.dirname(os.path.realpath(__file__)) -if os.path.exists(os.path.join(mypath,'computers.json')): - computers_from_disk = read_file() +myfile = os.path.join(mypath, "computers.json") +if os.path.exists(myfile): + computers_from_disk = read_file(myfile) else: - print('No cached data found, writing new data to computers.json') + print("No cached data found, writing new data to computers.json") overwrite = True -# Get computers information from JSS -computers = build_computers_data_object() +token = get_uapi_token() -# Overwrite local file? -if overwrite == True: - overwrite_file() +# Get computers information from JSS smart group +computers = build_computers_data_object(token, smart_group) +# Overwrite local file? +if overwrite: + overwrite_file(myfile) + print("Computer data staged for comparison with future runs.") else: # Compare each computer - print('Analyzing the results...') + print("Analyzing the results...") for computer_id in list(computers.keys()): compare_computer(computer_id) diff --git a/tools/download.py b/tools/download.py index 6c7545b..02ef580 100755 --- a/tools/download.py +++ b/tools/download.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 import getpass import requests -from xml.etree import ElementTree as ET +from defusedxml import ElementTree as eTree from xml.dom import minidom import os import argparse @@ -18,7 +18,7 @@ def get_uapi_token(): fetches api token """ jamf_test_url = url + "/api/v1/auth/token" - response = requests.post(url=jamf_test_url, auth=(username, password)) + response = requests.post(url=jamf_test_url, auth=(username, password), timeout=5) response_json = response.json() return response_json["token"] @@ -29,7 +29,7 @@ def invalidate_uapi_token(uapi_token): """ jamf_test_url = url + "/api/v1/auth/invalidate-token" headers = {"Accept": "*/*", "Authorization": "Bearer " + uapi_token} - _ = requests.post(url=jamf_test_url, headers=headers) + _ = requests.post(url=jamf_test_url, headers=headers, timeout=5) def download_scripts( @@ -79,6 +79,7 @@ def download_scripts( "Authorization": "Bearer " + token, }, verify=args.do_not_verify_ssl, + timeout=5, ) # Basic error handling @@ -90,7 +91,7 @@ def download_scripts( % r.status_code ) exit(1) - tree = ET.fromstring(r.content) + tree = eTree.fromstring(r.content) resource_ids = [e.text for e in tree.findall(".//id")] # Download each resource and save to disk @@ -105,8 +106,9 @@ def download_scripts( "Authorization": "Bearer " + token, }, verify=args.do_not_verify_ssl, + timeout=5, ) - tree = ET.fromstring(r.content) + tree = eTree.fromstring(r.content) if mode == "ea": if tree.find("input_type/type").text != "script": @@ -132,7 +134,7 @@ def download_scripts( # Create script string, and determine the file extension if get_script: - xmlstr = ET.tostring( + xmlstr = eTree.tostring( tree.find(script_xml), encoding="unicode", method="text" ).replace("\r", "") if xmlstr.startswith("#!/bin/sh"): @@ -166,11 +168,11 @@ def download_scripts( tree.remove(tree.find("id")) tree.remove(tree.find("script_contents_encoded")) tree.remove(tree.find("filename")) - except: + except TypeError: pass xmlstr = minidom.parseString( - ET.tostring(tree, encoding="unicode", method="xml") + eTree.tostring(tree, encoding="unicode", method="xml") ).toprettyxml(indent=" ") with open(os.path.join(resource_path, "%s.xml" % mode), "w") as f: f.write(xmlstr) @@ -202,26 +204,23 @@ def download_scripts( CONFIG_FILE = config_path if CONFIG_FILE != "": - try: - # Get config - CONFPARSER.read(CONFIG_FILE) - except: - print("Can't read config file") + # Get config + CONFPARSER.read(CONFIG_FILE) try: username = CONFPARSER.get("jss", "username") - except: + except configparser.NoOptionError: print("Can't find username in configfile") try: password = CONFPARSER.get("jss", "password") - except: + except configparser.NoOptionError: print("Can't find password in configfile") try: url = CONFPARSER.get("jss", "server") - except: + except configparser.NoOptionError: print("Can't find url in configfile") try: export_path = CONFPARSER.get("jss", "export_path") - except: + except configparser.NoOptionError: print("Can't find export_path in config") # Ask for password if not supplied via command line args