From 8c9bb0590ffca58fe7f865bdaeb89008795f05a2 Mon Sep 17 00:00:00 2001 From: ulric denis Date: Wed, 10 Jul 2024 13:39:52 +0200 Subject: [PATCH 1/2] :sparkles: auto update apk on push to soti --- .gitignore | 135 ++++++++++++++++++++++++++++++++ README.md | 27 +++++-- action.yml | 33 +++++--- entrypoint.sh | 6 +- soti_api.py | 192 ++++++++++++++++++++++++++++++++++++++++++++++ test_soti_api.py | 165 +++++++++++++++++++++++++++++++++++++++ upload_package.py | 100 ++++++------------------ 7 files changed, 565 insertions(+), 93 deletions(-) create mode 100644 .gitignore create mode 100644 soti_api.py create mode 100644 test_soti_api.py 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..4f4dd92 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 Required if `auto-update` is true** 'soti profile name' + +### `package-name`: + +**Optional Required 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..01d9f4d 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,3 +1,7 @@ #!/bin/sh -l -python /upload_package.py $1 /github/workspace/$2 $3 $4 $5 $6 +if [ "$7" = "true" ]; then + python /upload_package.py $1 /github/workspace/$2 $3 $4 $5 $6 --auto-update --profile-name "$8" --package-name "$9" +else + python /upload_package.py $1 /github/workspace/$2 $3 $4 $5 $6 +fi diff --git a/soti_api.py b/soti_api.py new file mode 100644 index 0000000..3e9ba3f --- /dev/null +++ b/soti_api.py @@ -0,0 +1,192 @@ +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.metadata = {"DeviceFamily": "AndroidPlus"} + self._token = None + + @property + def token(self): + if not self._token: + self._token = self._get_token() + return self._token + + @token.setter + def token(self, value): + self._token = value + + + def upload_package(self, srcfile): + 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}", + }, + ) + + self._check_response(resp, "uploading APK") + + def auto_update_package(self, profile_name, package_name): + + 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 _get_token(self): + print(f"get token") + + resp = requests.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._check_response(resp, "getting token") + + 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") + + return resp.json()[0]["ReferenceId"], resp.json()[0]["LastVersion"] + + 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() + + def _update_profile_package(self, profile_id, package_id, last_version): + print(f"update profile package") + last_version.pop("$type") + resp = requests.put( + f"{self.soti_api_endpoint}/api/profiles/{profile_id}/packages", + json=[ + { + "ReferenceId": package_id, + "Version": last_version["Version"], + "ActivePackageVersions": [last_version], + } + ], + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.token}", + }, + verify=True, + ) + self._check_response(resp, "updating profile package") + + def _assign_devices_to_profile(self, profile_id, assigned_devices): + print(f"assign devices to profile") + assigned_devices = remove_dollar_keys(assigned_devices) + assigned_devices["AssignmentOptions"]["PackageAssignmentOptions"][ + "ForceReinstallation" + ] = True + resp = requests.put( + f"{self.soti_api_endpoint}/api/profiles/{profile_id}/assignment", + json=assigned_devices, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.token}", + }, + verify=True, + ) + self._check_response(resp, "assigning devices to profile") + + 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()} \n" + ) + print(f"Status code OK: {resp.status_code} \n") + + +def remove_dollar_keys(obj): + if isinstance(obj, dict): + return {k: remove_dollar_keys(v) for k, v in obj.items() if "$" not in k} + elif isinstance(obj, list): + return [remove_dollar_keys(item) for item in obj] + else: + return obj diff --git a/test_soti_api.py b/test_soti_api.py new file mode 100644 index 0000000..4338b60 --- /dev/null +++ b/test_soti_api.py @@ -0,0 +1,165 @@ +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") + 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") + @patch("soti_api.SotiApi._get_token", return_value="fake_token") + 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'} \n""" == 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 == {'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 == {'TargetDeviceGroups': [{'DeviceGroupPath': '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" + + self.soti_api._update_profile_package( + "fake_profile_id", "fake_package_id", {"Version": "1.0.0", "$type": "PackageVersion"} + ) + + mock_put.assert_called_once_with( + "http://fakeapi.com/api/profiles/fake_profile_id/packages", + json=[{"ReferenceId": "fake_package_id", "Version": "1.0.0", "ActivePackageVersions": [{"Version": "1.0.0"}]}], + headers={ + "Content-Type": "application/json", + "Authorization": "Bearer fake_token", + }, + verify=True, + ) + + @patch("soti_api.requests.put") + 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", {"message": "success", "AssignmentOptions": {"PackageAssignmentOptions": {"ForceReinstallation": False}}} + ) + + mock_post.assert_called_once_with( + "http://fakeapi.com/api/profiles/fake_profile_id/assignment", + json={"message": "success", "AssignmentOptions": {"PackageAssignmentOptions": {"ForceReinstallation": True}}}, + headers={ + "Content-Type": "application/json", + "Authorization": "Bearer fake_token", + }, + verify=True, + ) diff --git a/upload_package.py b/upload_package.py index 96a73d8..6ca14b8 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.", + action="store_true", + 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) From da29c7076a7a6f5164f65497d02e25e05bed062e Mon Sep 17 00:00:00 2001 From: ulric denis Date: Thu, 1 Aug 2024 16:47:16 +0200 Subject: [PATCH 2/2] add CI test --- .github/workflows/test.yml | 28 ++++++++++++++++++++++++++++ requirements.txt | 2 ++ 2 files changed, 30 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 requirements.txt diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a4f8cab --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,28 @@ +name: Run Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: "3.x" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest + pip install -r requirements.txt + + - name: Run tests + run: pytest diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f5583c0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests==2.32.3 +urllib3==2.2.2 \ No newline at end of file