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
+[](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
+
+
+ General
+
diff --git a/requirements.txt b/requirements.txt
index c201a9a..95ed3b5 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,4 +3,5 @@ cchardet
aiodns
uvloop
requests
-configparser
\ No newline at end of file
+configparser
+defusedxml
\ No newline at end of file
diff --git a/scripts/templates/Install Software Updates/script.sh b/scripts/templates/Install Software Updates/script.sh
new file mode 100644
index 0000000..827e16e
--- /dev/null
+++ b/scripts/templates/Install Software Updates/script.sh
@@ -0,0 +1,8 @@
+#!/bin/bash
+
+# Install all software updates
+# Example for git2jss
+
+softwareupdate -i -a
+
+exit 0
\ No newline at end of file
diff --git a/scripts/templates/Install Software Updates/script.xml b/scripts/templates/Install Software Updates/script.xml
new file mode 100644
index 0000000..ce83c38
--- /dev/null
+++ b/scripts/templates/Install Software Updates/script.xml
@@ -0,0 +1,11 @@
+
+
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