diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3fc62ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,135 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyderworkspace + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ diff --git a/README.md b/README.md index ac0ca45..1a9c45a 100644 --- a/README.md +++ b/README.md @@ -26,16 +26,31 @@ **Required** 'Soti password' +### `auto-update`: + +**Optional** _boolean -> default false_ 'auto update apk version on profile and install it on device link' + +### `soti-profile-id`: + +**Optional Requiered if `auto-update` is true** 'soti profile name' + +### `package-name`: + +**Optional Requiered if `auto-update` is true** 'package name' ## Example usage ```yaml uses: ZeroGachis/github-action-soti-apk-uploader@main with: - soti-api-url: 'Soti api url' - apk-path: 'Apk path' - soti-api-key: 'Soti api key' - soti-api-secret: 'Soti api secret' - soti-username: 'Soti username' - soti-password: 'Soti password' + soti-api-url: "Soti api url" + apk-path: "Apk path" + soti-api-key: "Soti api key" + soti-api-secret: "Soti api secret" + soti-username: "Soti username" + soti-password: "Soti password" + //optional + auto-update: true + soti-profile-id: "Soti profile id" + package-name: "Package name" ``` diff --git a/action.yml b/action.yml index 23c5107..d536172 100644 --- a/action.yml +++ b/action.yml @@ -1,27 +1,37 @@ -name: 'Github Action Soti Apk Uploader' -description: 'a Github Action to push an Apk on Soti' +name: "Github Action Soti Apk Uploader" +description: "a Github Action to push an Apk on Soti" inputs: soti-api-url: - description: 'Soti api url' + description: "Soti api url" required: true apk-path: - description: 'apk path' + description: "apk path" required: true soti-api-key: - description: 'Soti api key' + description: "Soti api key" required: true soti-api-secret: - description: 'Soti api secret' + description: "Soti api secret" required: true soti-username: - description: 'Soti username' + description: "Soti username" required: true soti-password: - description: 'Soti password' + description: "Soti password" required: true + auto-update: + description: "auto update" + default: "false" + required: false + soti-profile-id: + description: "Soti profile id" + required: false + package-name: + description: "Package name" + required: false runs: - using: 'docker' - image: 'Dockerfile' + using: "docker" + image: "Dockerfile" args: - ${{ inputs.soti-api-url }} - ${{ inputs.apk-path }} @@ -29,3 +39,6 @@ runs: - ${{ inputs.soti-api-secret }} - ${{ inputs.soti-username }} - ${{ inputs.soti-password }} + - ${{ inputs.auto-update }} + - ${{ inputs.soti-profile-id }} + - ${{ inputs.package-name }} diff --git a/entrypoint.sh b/entrypoint.sh index 446f83f..48cd9f5 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,3 +1,3 @@ #!/bin/sh -l -python /upload_package.py $1 /github/workspace/$2 $3 $4 $5 $6 +python /upload_package.py $1 /github/workspace/$2 $3 $4 $5 $6 $7 $8 $9 diff --git a/soti_api.py b/soti_api.py new file mode 100644 index 0000000..b77664b --- /dev/null +++ b/soti_api.py @@ -0,0 +1,181 @@ +import json +import requests +from urllib3.fields import RequestField +from urllib3.filepost import encode_multipart_formdata, choose_boundary + + +class SotiApi: + APK_ALREADY_EXISTS = 1611 + + def __init__(self, sotiapi, client_id, client_secret, username, password): + self.soti_api_endpoint = sotiapi + self.client_id = client_id + self.client_secret = client_secret + self.username = username + self.password = password + self.token = "" + self.metadata = {"DeviceFamily": "AndroidPlus"} + + def upload_package(self, srcfile): + self._ensure_token() + body, content_type = self._encode_media_related( + self.metadata, + open(srcfile, "rb").read(), + "application/vnd.android.application", + ) + + print("Upload packages.\n") + resp = requests.post( + f"{self.soti_api_endpoint}/api/packages", + data=body, + headers={ + "Content-Type": content_type, + "Authorization": f"Bearer {self.token}", + }, + ) + print(resp.status_code, "\n") + + if resp.status_code == 422: + if resp.json()["ErrorCode"] == self.APK_ALREADY_EXISTS: + print("APK already exists in SOTI") + return + + print("Message: %s" % resp.json()) + + self._check_response(resp, "uploading APK") + + def auto_update_package(self, profile_name, package_name): + self._ensure_token() + + if not profile_name or not package_name: + raise Exception("Profile name or package name is empty.") + + package_id, version = self._get_package_id(package_name) + profile_id = self._get_profile_id(profile_name) + device_group_path = self._get_devices_profile_assignment(profile_id) + self._update_profile_package(profile_id, package_id, version) + self._assign_devices_to_profile(profile_id, device_group_path) + + def _ensure_token(self): + if not self.token: + self._get_token() + + def _get_token(self): + with requests.session() as c: + resp = c.post( + f"{self.soti_api_endpoint}/api/token", + auth=(self.client_id, self.client_secret), + data={ + "grant_type": "password", + "username": self.username, + "password": self.password, + }, + verify=True, + ) + self.token = resp.json()["access_token"] + + def _encode_multipart_related(self, fields, boundary=None): + if boundary is None: + boundary = choose_boundary() + + body, _ = encode_multipart_formdata(fields, boundary) + content_type = str("multipart/related; boundary=%s" % boundary) + + return body, content_type + + def _encode_media_related(self, metadata, media, media_content_type): + rf1 = RequestField( + name="metadata", + data=json.dumps(metadata), + headers={ + "Content-Type": "application/vnd.android.application.metadata+json" + }, + ) + rf2 = RequestField( + name="apk", + data=media, + headers={ + "Content-Type": media_content_type, + "Content-Transfer-Encoding": "binary", + }, + ) + return self._encode_multipart_related([rf1, rf2]) + + def _get_profile_id(self, profile_name): + print(f"get profile id") + resp = requests.get( + f"{self.soti_api_endpoint}/api/profiles", + params={"NameContains": profile_name}, + headers={"Authorization": f"Bearer {self.token}"}, + verify=True, + ) + + self._check_response(resp, "getting profile id") + + return resp.json()[0]["ReferenceId"] + + def _get_package_id(self, package_name): + print(f"get package id") + resp = requests.get( + f"{self.soti_api_endpoint}/api/packages", + params={"packageName": package_name}, + headers={"Authorization": f"Bearer {self.token}"}, + verify=True, + ) + + self._check_response(resp, "getting package id") + + print(f"Status code OK: {resp.status_code}") + return resp.json()[0]["ReferenceId"], resp.json()[0]["LastVersion"]["Version"] + + def _get_devices_profile_assignment(self, profile_id): + print(f"get devices profile assign") + resp = requests.get( + f"{self.soti_api_endpoint}/api/profiles/{profile_id}/assignment", + headers={"Authorization": f"Bearer {self.token}"}, + verify=True, + ) + + self._check_response(resp, "getting devices profile assignment") + + return resp.json()["TargetDeviceGroups"][0]["DeviceGroupPath"] + + def _update_profile_package(self, profile_id, package_id, version): + print(f"update profile package") + resp = requests.put( + f"{self.soti_api_endpoint}/api/profiles/{profile_id}/packages", + data=[{"ReferenceId": package_id, "Version": version}], + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.token}", + }, + verify=True, + ) + + self._check_response(resp, "updating profile package") + + print(f"Status code OK: {resp.status_code}") + + return resp.json()[0]["ReferenceId"] + + def _assign_devices_to_profile(self, profile_id, device_group_path): + print(f"assign devices to profile") + resp = requests.post( + f"{self.soti_api_endpoint}/api/profiles/{profile_id}/assignment/targetDeviceGroups", + data=[device_group_path], + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.token}", + }, + verify=True, + ) + + self._check_response(resp, "assigning devices to profile") + + print(f"Status code OK: {resp.status_code}") + + def _check_response(self, resp, action): + if not (200 <= resp.status_code < 300): + raise Exception( + f"Issue was encountered while {action} in SOTI !\n {resp.json()}" + ) diff --git a/test_soti_api.py b/test_soti_api.py new file mode 100644 index 0000000..aa2a9e8 --- /dev/null +++ b/test_soti_api.py @@ -0,0 +1,199 @@ +import unittest +from unittest.mock import patch, mock_open, MagicMock +from soti_api import SotiApi + + +class TestSotiApi(unittest.TestCase): + + def setUp(self): + self.soti_api = SotiApi( + "http://fakeapi.com", + "fake_client_id", + "fake_client_secret", + "fake_username", + "fake_password", + ) + + @patch("soti_api.requests.post") + @patch("builtins.open", new_callable=mock_open, read_data=b"fake_apk_data") + @patch("soti_api.SotiApi._get_token") + def test_upload_package(self, mock_get_token, mock_file, mock_post): + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = {"message": "success"} + mock_get_token.return_value = "fake_token" + + self.soti_api.upload_package("fake_apk_path") + + mock_file.assert_called_once_with("fake_apk_path", "rb") + mock_post.assert_called_once() + + assert ( + mock_post.call_args[1]["headers"]["Authorization"] + == f"Bearer {self.soti_api.token}" + ) + assert mock_post.return_value.status_code == 200 + assert mock_post.return_value.json() == {"message": "success"} + + @patch("soti_api.SotiApi._get_token") + def test_upload_package_failure_with_no_file(self, mock_get_token): + mock_get_token.return_value = "fake_token" + with self.assertRaises(Exception) as context: + self.soti_api.upload_package("fake_apk_path") + + assert "[Errno 2] No such file or directory: 'fake_apk_path'" == str( + context.exception + ) + + @patch("soti_api.SotiApi._get_token") + @patch( + "soti_api.SotiApi._get_package_id", + return_value=("fake_package_id", "fake_version"), + ) + @patch("soti_api.SotiApi._get_profile_id", return_value="fake_profile_id") + @patch( + "soti_api.SotiApi._get_devices_profile_assignment", + return_value="fake_device_group_path", + ) + @patch("soti_api.SotiApi._update_profile_package") + @patch("soti_api.SotiApi._assign_devices_to_profile") + def test_auto_update_package( + self, + mock_assign_devices_to_profile, + mock_update_profile_package, + mock_get_devices_profile_assignment, + mock_get_profile_id, + mock_get_package_id, + mock_get_token, + ): + self.soti_api.auto_update_package("fake_profile_name", "fake_package_name") + + mock_get_token.assert_called_once() + mock_get_package_id.assert_called_once_with("fake_package_name") + mock_get_profile_id.assert_called_once_with("fake_profile_name") + mock_get_devices_profile_assignment.assert_called_once_with("fake_profile_id") + mock_update_profile_package.assert_called_once_with( + "fake_profile_id", "fake_package_id", "fake_version" + ) + mock_assign_devices_to_profile.assert_called_once_with( + "fake_profile_id", "fake_device_group_path" + ) + + @patch("soti_api.SotiApi._get_token") + def test_auto_update_package_failure_with_empty_profile_name_or_package_name( + self, mock_get_token + ): + mock_get_token.return_value = "fake_token" + + with self.assertRaises( + Exception, + ) as context: + self.soti_api.auto_update_package("", "") + + assert "Profile name or package name is empty." == str(context.exception) + + @patch("soti_api.requests.get") + def test_get_profile_id_success(self, mock_get): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = [{"ReferenceId": "fake_profile_id"}] + self.soti_api.token = "fake_token" + profile_id = self.soti_api._get_profile_id("fake_profile_name") + + mock_get.assert_called_once_with( + "http://fakeapi.com/api/profiles", + params={"NameContains": "fake_profile_name"}, + headers={"Authorization": "Bearer fake_token"}, + verify=True, + ) + assert profile_id == "fake_profile_id" + + @patch("soti_api.requests.get") + def test_get_profile_id_failure(self, mock_get): + mock_get.return_value.status_code = 404 + mock_get.return_value.json.return_value = {"error": "Profile not found"} + + with self.assertRaises( + Exception, + ) as context: + self.soti_api._get_profile_id("fake_profile_id") + + assert """Issue was encountered while getting profile id in SOTI ! + {'error': 'Profile not found'}""" == str( + context.exception + ) + + @patch("soti_api.requests.get") + def test_get_package_id_success(self, mock_get): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = [ + {"ReferenceId": "fake_package_id", "LastVersion": {"Version": "1.0.0"}} + ] + self.soti_api.token = "fake_token" + package_id, version = self.soti_api._get_package_id("fake_package_name") + + mock_get.assert_called_once_with( + "http://fakeapi.com/api/packages", + params={"packageName": "fake_package_name"}, + headers={"Authorization": "Bearer fake_token"}, + verify=True, + ) + assert package_id == "fake_package_id" + assert version == "1.0.0" + + @patch("soti_api.requests.get") + def test_get_devices_profile_assignment_success(self, mock_get): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = { + "TargetDeviceGroups": [{"DeviceGroupPath": "list/of/device/groups"}] + } + self.soti_api.token = "fake_token" + devices_path = self.soti_api._get_devices_profile_assignment("fake_profile_id") + + mock_get.assert_called_once_with( + "http://fakeapi.com/api/profiles/fake_profile_id/assignment", + headers={"Authorization": "Bearer fake_token"}, + verify=True, + ) + assert devices_path == "list/of/device/groups" + + @patch("soti_api.requests.put") + def test_update_profile_package_success(self, mock_put): + mock_put.return_value.status_code = 200 + mock_put.return_value.json.return_value = [ + {"ReferenceId": "new_fake_profile_id"} + ] + self.soti_api.token = "fake_token" + + new_profile_id = self.soti_api._update_profile_package( + "fake_profile_id", "fake_package_id", "1.0.0" + ) + + mock_put.assert_called_once_with( + "http://fakeapi.com/api/profiles/fake_profile_id/packages", + data=[{"ReferenceId": "fake_package_id", "Version": "1.0.0"}], + headers={ + "Content-Type": "application/json", + "Authorization": "Bearer fake_token", + }, + verify=True, + ) + assert new_profile_id == "new_fake_profile_id" + + @patch("soti_api.requests.post") + def test_assign_devices_to_profile_success(self, mock_post): + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = {"message": "success"} + self.soti_api.token = "fake_token" + + self.soti_api._assign_devices_to_profile( + "fake_profile_id", "fake_device_group_path" + ) + + mock_post.assert_called_once_with( + "http://fakeapi.com/api/profiles/fake_profile_id/assignment/targetDeviceGroups", + data=["fake_device_group_path"], + headers={ + "Content-Type": "application/json", + "Authorization": "Bearer fake_token", + }, + verify=True, + ) diff --git a/upload_package.py b/upload_package.py index 96a73d8..da4302b 100644 --- a/upload_package.py +++ b/upload_package.py @@ -1,82 +1,30 @@ import argparse -import json -from urllib3.fields import RequestField -from urllib3.filepost import encode_multipart_formdata, choose_boundary -import requests - -parser = argparse.ArgumentParser("Upload apk on mobi control.") -parser.add_argument("sotiapi", help="Uri soti api endpoint.", type=str) -parser.add_argument("srcfile", help="Apk file path", type=str) -parser.add_argument("client_id", help="Soti api client id", type=str) -parser.add_argument("client_secret", help="Soti api client secret.", type=str) -parser.add_argument("username", help="Mobi control username.", type=str) -parser.add_argument("password", help="Mobi control password.", type=str) -args = parser.parse_args() - -sotiapi = args.sotiapi -srcfile = args.srcfile -client_id = args.client_id -client_secret = args.client_secret -username = args.username -password = args.password - - -def encode_multipart_related(fields, boundary=None): - if boundary is None: - boundary = choose_boundary() - - body, _ = encode_multipart_formdata(fields, boundary) - content_type = str("multipart/related; boundary=%s" % boundary) - - return body, content_type - - -def encode_media_related(metadata, media, media_content_type): - rf1 = RequestField( - name="metadata", - data=json.dumps(metadata), - headers={"Content-Type": "application/vnd.android.application.metadata+json"}, - ) - rf2 = RequestField( - name="apk", - data=media, - headers={ - "Content-Type": media_content_type, - "Content-Transfer-Encoding": "binary", - }, +from soti_api import SotiApi + +if __name__ == "__main__": + + parser = argparse.ArgumentParser("Upload apk on mobi control.") + parser.add_argument("sotiapi", help="Uri soti api endpoint.", type=str) + parser.add_argument("srcfile", help="Apk file path", type=str) + parser.add_argument("client_id", help="Soti api client id", type=str) + parser.add_argument("client_secret", help="Soti api client secret.", type=str) + parser.add_argument("username", help="Mobi control username.", type=str) + parser.add_argument("password", help="Mobi control password.", type=str) + parser.add_argument( + "auto_update", + help="Auto update device's profile package .", + type=bool, + default=False, ) - return encode_multipart_related([rf1, rf2]) - + parser.add_argument("profile_name", help="Profile name.", type=str, default="") + parser.add_argument("package_name", help="Package name.", type=str, default="") + args = parser.parse_args() -metadata = {"DeviceFamily": "AndroidPlus"} -token = "" - -print("Getting token.\n") -with requests.session() as c: - resp = c.post( - f"{sotiapi}/api/token", - auth=(client_id, client_secret), - data={"grant_type": "password", "username": username, "password": password}, - verify=True, + soti_api = SotiApi( + args.sotiapi, args.client_id, args.client_secret, args.username, args.password ) - token = resp.json()["access_token"] - -body, content_type = encode_media_related( - metadata, - open(srcfile, "rb").read(), - "application/vnd.android.application", -) - -print("Upload pakages.\n") -resp = requests.post( - f"{sotiapi}/api/packages", - data=body, - headers={"Content-Type": content_type, "Authorization": f"Bearer {token}"}, - verify=True, -) -print(resp.status_code, "\n") -print("Message: %s" % resp.json()) -if not (200 <= resp.status_code < 300): - raise Exception("Issue was encountered while uploading APK on SOTI !") + soti_api.upload_package(args.srcfile) + if args.auto_update: + soti_api.auto_update_package(args.profile_name, args.package_name)