diff --git a/.github/workflows/ci-code-style.yml b/.github/workflows/ci-code-style.yml new file mode 100644 index 0000000..5562eaa --- /dev/null +++ b/.github/workflows/ci-code-style.yml @@ -0,0 +1,29 @@ +name: CI-Code-Style + +on: + push: + branches: + - "main" + pull_request: + +jobs: + unit_tests: + name: Linter checks for SUSE SaaS tools + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11"] + + steps: + - uses: actions/checkout@v3 + - name: Python${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install Poetry + run: | + python -m pip install --upgrade pip + python -m pip install poetry + - name: Run code checks + run: | + make -C aws/resolve_customer check diff --git a/.github/workflows/ci-units-types.yml b/.github/workflows/ci-units-types.yml new file mode 100644 index 0000000..95ab05f --- /dev/null +++ b/.github/workflows/ci-units-types.yml @@ -0,0 +1,30 @@ +name: CI-Unit-And-Types + +on: + push: + branches: + - "main" + pull_request: + +jobs: + unit_tests: + name: Unit and Static Type tests for SUSE SaaS tools + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11"] + + steps: + - uses: actions/checkout@v3 + - name: Python${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install Poetry + run: | + python -m pip install --upgrade pip + python -m pip install poetry + - name: Run unit and type tests + run: make -C aws/resolve_customer test + env: + PY_VER: ${{ matrix.python-version }} diff --git a/Makefile b/Makefile index ae6124b..500817f 100644 --- a/Makefile +++ b/Makefile @@ -20,3 +20,18 @@ package: cat package/python-${namespace}-spec-template | sed -e s'@%%VERSION@${version}@' \ > dist/python-${namespace}.spec cp package/python-${namespace}.changes dist/ + + +setup: + poetry install --all-extras + +check: setup + poetry run flake8 --statistics -j auto --count ${namespace} + poetry run flake8 --statistics -j auto --count test/unit + +test: setup + poetry run mypy ${namespace} + poetry run bash -c 'pushd test/unit && pytest -n 5 \ + --doctest-modules --no-cov-on-fail --cov=${namespace} \ + --cov-report=term-missing --cov-fail-under=100 \ + --cov-config .coveragerc' diff --git a/aws/resolve_customer/app_resolve_customer.py b/aws/resolve_customer/app_resolve_customer.py new file mode 100755 index 0000000..b42473a --- /dev/null +++ b/aws/resolve_customer/app_resolve_customer.py @@ -0,0 +1,6 @@ +#!/usr/bin/python3 +from resolve_customer.app import lambda_handler as resolve_customer_lambda + + +def lambda_handler(event, context): + return resolve_customer_lambda(event, context) diff --git a/aws/resolve_customer/package/python-resolve_customer-spec-template b/aws/resolve_customer/package/python-resolve_customer-spec-template index c6eab18..6ec1795 100644 --- a/aws/resolve_customer/package/python-resolve_customer-spec-template +++ b/aws/resolve_customer/package/python-resolve_customer-spec-template @@ -40,11 +40,13 @@ Source: %{name}.tar.gz BuildRoot: %{_tmppath}/%{name}-%{version}-build BuildRequires: %{pythons}-%{develsuffix} >= 3.9 BuildRequires: %{pythons}-build +BuildRequires: %{pythons}-pip BuildRequires: %{pythons}-installer BuildRequires: %{pythons}-poetry-core >= 1.2.0 BuildRequires: %{pythons}-wheel BuildRequires: %{pythons}-requests BuildRequires: %{pythons}-setuptools +BuildRequires: python-rpm-macros %description SaaS tooling for the pubcloud team. @@ -53,12 +55,12 @@ SaaS tooling for the pubcloud team. %package -n %{pythons}-resolve_customer Summary: resolve_customer - AWS ResolveCustomer Group: %{pygroup} -Requires: python >= 3.11 +Requires: python3 >= 3.11 Requires: %{pythons}-boto3 Requires: %{pythons}-requests Requires: %{pythons}-setuptools -%description -n python%{python3_pkgversion}-resolve_customer +%description -n %{pythons}-resolve_customer SaaS tooling for the pubcloud team. %prep @@ -69,8 +71,10 @@ SaaS tooling for the pubcloud team. %install %pyproject_install +install -m 755 app_resolve_customer.py %{buildroot}/app_resolve_customer.py %files -n %{pythons}-resolve_customer %{_sitelibdir}/resolve_customer* +/app_resolve_customer.py %changelog diff --git a/aws/resolve_customer/pyproject.toml b/aws/resolve_customer/pyproject.toml index 9a6a071..43e732a 100644 --- a/aws/resolve_customer/pyproject.toml +++ b/aws/resolve_customer/pyproject.toml @@ -20,6 +20,7 @@ packages = [ ] include = [ + { path = "app_resolve_customer.py", format = "sdist" }, { path = ".bumpversion.cfg", format = "sdist" }, { path = ".coverage*", format = "sdist" }, { path = "package", format = "sdist" }, @@ -37,13 +38,14 @@ classifiers = [ [tool.poetry.urls] # "Bug Tracker" = "" +[tool.poetry.scripts] +# resolve_customer = "resolve_customer.app:lambda_handler" + [tool.poetry.dependencies] python = "^3.11" requests = ">=2.25.0" setuptools = ">=50" - -[tool.poetry.scripts] -# resolve_customer = "resolve_customer.resolve_customer:handle_event" +boto3 = ">=1.12" [tool.poetry.group.test] [tool.poetry.group.test.dependencies] diff --git a/aws/resolve_customer/resolve_customer/app.py b/aws/resolve_customer/resolve_customer/app.py new file mode 100644 index 0000000..9be01f8 --- /dev/null +++ b/aws/resolve_customer/resolve_customer/app.py @@ -0,0 +1,107 @@ +# Copyright (c) 2025 SUSE LLC. All rights reserved. +# +# This file is part of suse-saas-tools +# +# suse-saas-tools is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# mash is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with mash. If not, see +# +""" +Lambda to retrieve customer information from +AWS Metering/Entitlement Marketplace API's +""" +import json +import base64 +from typing import ( + Dict, Union, List +) + +from resolve_customer.customer import AWSCustomer +from resolve_customer.entitlements import AWSCustomerEntitlement + + +def lambda_handler(event, context): + """ + Expects event type matching the AWS API Gateway. + + Example event body: + { + "x-amzn-marketplace-token": "some" + } + + Example return: + { + "statusCode": 200, + "isBase64Encoded": False, + "body": { + "CustomerIdentifier": "id" + "CustomerAWSAccountId": "account_id" + "ProductCode": "product_code" + "Entitlements": [ + { + "CustomerIdentifier": "id", + "Dimension": "some", + "ExpirationDate": date, + "ProductCode": "product_code", + "Value": { + "BooleanValue": true|false, + "DoubleValue": number, + "IntegerValue": number, + "StringValue": "str" + } + } + ] + } + } + """ + try: + event_body = event['body'] + if event.get('isBase64Encoded'): + event_body = json.loads(base64.b64decode(event_body)) + return json.dumps( + process_event(event_body.get('x-amzn-marketplace-token')) + ) + except Exception as error: + return json.dumps( + error_response(500, f'{type(error).__name__}: {error}') + ) + + +def process_event( + token: str +) -> Dict[str, Union[str, int, Dict[str, Union[str, List]]]]: + customer = AWSCustomer(token) + if customer.error: + return error_response(400, customer.error) + entitlements = AWSCustomerEntitlement( + customer.get_id(), customer.get_product_code() + ) + if entitlements.error: + return error_response(400, entitlements.error) + return { + 'isBase64Encoded': False, + 'statusCode': 200, + 'body': { + 'CustomerIdentifier': customer.get_id(), + 'CustomerAWSAccountId': customer.get_account_id(), + 'ProductCode': customer.get_product_code(), + 'Entitlements': entitlements.get_entitlements() + } + } + + +def error_response(status_code: int, message: str): + return { + 'isBase64Encoded': False, + 'statusCode': status_code, + 'body': message + } diff --git a/aws/resolve_customer/resolve_customer/customer.py b/aws/resolve_customer/resolve_customer/customer.py new file mode 100644 index 0000000..a0c4dca --- /dev/null +++ b/aws/resolve_customer/resolve_customer/customer.py @@ -0,0 +1,54 @@ +# Copyright (c) 2025 SUSE LLC. All rights reserved. +# +# This file is part of suse-saas-tools +# +# suse-saas-tools is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# mash is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with mash. If not, see +# +import logging +import boto3 + +logger = logging.getLogger() + + +class AWSCustomer: + """ + Get AWS customer ID information from a marketplace token + """ + def __init__(self, token: str): + self.customer = {} + self.error = '' + if token: + try: + marketplace = boto3.client('meteringmarketplace') + self.customer = marketplace.resolve_customer( + RegistrationToken=token + ) + except Exception as error: + self.error = f'meteringmarketplace client failed with: {error}' + logger.error(self.error) + else: + self.error = 'no marketplace token provided' + logger.error(self.error) + + def get_id(self) -> str: + return self.__get('CustomerIdentifier') + + def get_account_id(self) -> str: + return self.__get('CustomerAWSAccountId') + + def get_product_code(self) -> str: + return self.__get('ProductCode') + + def __get(self, key) -> str: + return self.customer[key] if self.customer else '' diff --git a/aws/resolve_customer/resolve_customer/entitlements.py b/aws/resolve_customer/resolve_customer/entitlements.py new file mode 100644 index 0000000..6903329 --- /dev/null +++ b/aws/resolve_customer/resolve_customer/entitlements.py @@ -0,0 +1,49 @@ +# Copyright (c) 2025 SUSE LLC. All rights reserved. +# +# This file is part of suse-saas-tools +# +# suse-saas-tools is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# mash is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with mash. If not, see +# +import logging +import boto3 +from typing import List + +logger = logging.getLogger() + + +class AWSCustomerEntitlement: + """ + Get AWS customer entitlements for given customer ID and product code + """ + def __init__(self, customer_id: str, product_code: str): + self.entitlements = {} + self.error = '' + if customer_id and product_code: + try: + marketplace = boto3.client('marketplace-entitlement') + self.entitlements = marketplace.get_entitlements( + { + 'ProductCode': product_code, + 'Filter': {'CUSTOMER_IDENTIFIER': [customer_id]} + } + ) + except Exception as error: + self.error = f'marketplace-entitlement client failed with: {error}' + logger.error(self.error) + else: + self.error = 'no customer_id and/or product_code provided' + logger.error(self.error) + + def get_entitlements(self) -> List[dict]: + return self.entitlements.get('Entitlements') or [] diff --git a/aws/resolve_customer/test/unit/.coveragerc b/aws/resolve_customer/test/unit/.coveragerc new file mode 100644 index 0000000..27bb669 --- /dev/null +++ b/aws/resolve_customer/test/unit/.coveragerc @@ -0,0 +1,7 @@ +[run] +omit = + */version.py + +[report] +omit = + */version.py diff --git a/aws/resolve_customer/test/unit/app_test.py b/aws/resolve_customer/test/unit/app_test.py new file mode 100644 index 0000000..5c1b52f --- /dev/null +++ b/aws/resolve_customer/test/unit/app_test.py @@ -0,0 +1,96 @@ +from unittest.mock import ( + Mock, patch +) + +from resolve_customer.app import ( + lambda_handler, process_event +) + + +class TestApp: + def test_lambda_handler_invalid_event(self): + assert lambda_handler(event={'some': 'some'}, context=Mock()) == \ + '{\"isBase64Encoded\": false, \"statusCode\": 500, ' \ + '\"body\": \"KeyError: \'body\'\"}' + + @patch('resolve_customer.app.process_event') + def test_lambda_handler(self, mock_process_event): + mock_process_event.return_value = {} + lambda_handler( + event={ + 'version': '2.0', + 'routeKey': 'ANY /ms-lambda-from-container', + 'rawPath': '/default/ms-lambda-from-container', + 'rawQueryString': '', + 'headers': { + 'accept': '*/*', + 'content-length': '31', + 'content-type': 'application/x-www-form-urlencoded', + 'host': 'some', + 'user-agent': 'curl/8.0.1', + 'x-amzn-trace-id': 'Root=xxx', + 'x-forwarded-for': '79.201.150.192', + 'x-forwarded-port': '443', + 'x-forwarded-proto': 'https' + }, + 'requestContext': { + 'accountId': '810320120389', + 'apiId': 'jfh2r389u9', + 'domainName': 'some', + 'domainPrefix': 'jfh2r389u9', + 'http': { + 'method': 'POST', + 'path': '/default/ms-lambda-from-container', + 'protocol': 'HTTP/1.1', + 'sourceIp': '79.201.150.192', + 'userAgent': 'curl/8.0.1' + }, + 'requestId': 'EcDY1h1zFiAEPLQ=', + 'routeKey': 'ANY /ms-lambda-from-container', + 'stage': 'default', + 'time': '15/Jan/2025:16:44:01 +0000', + 'timeEpoch': 1736959441730 + }, + 'body': 'eyJ4LWFtem4tbWFya2V0cGxhY2UtdG9rZW4iOiAidG9rZW4ifQo=', + 'isBase64Encoded': True + }, + context=Mock() + ) + mock_process_event.assert_called_once_with( + 'token' + ) + + @patch('resolve_customer.app.AWSCustomer') + @patch('resolve_customer.app.AWSCustomerEntitlement') + def test_process_event( + self, mock_AWSCustomerEntitlement, mock_AWSCustomer + ): + customer = Mock() + customer.error = '' + entitlements = Mock() + entitlements.error = '' + mock_AWSCustomer.return_value = customer + mock_AWSCustomerEntitlement.return_value = entitlements + assert process_event('token') == { + 'isBase64Encoded': False, + 'statusCode': 200, + 'body': { + 'CustomerIdentifier': customer.get_id.return_value, + 'CustomerAWSAccountId': customer.get_account_id.return_value, + 'ProductCode': customer.get_product_code.return_value, + 'Entitlements': entitlements.get_entitlements.return_value + } + } + customer.error = 'some-customer-error' + assert process_event('token') == { + 'isBase64Encoded': False, + 'statusCode': 400, + 'body': 'some-customer-error' + } + customer.error = '' + entitlements.error = 'some-entitlement-error' + assert process_event('token') == { + 'isBase64Encoded': False, + 'statusCode': 400, + 'body': 'some-entitlement-error' + } diff --git a/aws/resolve_customer/test/unit/customer_test.py b/aws/resolve_customer/test/unit/customer_test.py new file mode 100644 index 0000000..4dac126 --- /dev/null +++ b/aws/resolve_customer/test/unit/customer_test.py @@ -0,0 +1,48 @@ +import logging +from unittest.mock import ( + patch, MagicMock +) +from pytest import fixture + +from botocore.exceptions import ClientError +from resolve_customer.customer import AWSCustomer + + +class TestAWSCustomer: + @fixture(autouse=True) + def inject_fixtures(self, caplog): + self._caplog = caplog + + @patch('boto3.client') + def setup_method(self, cls, mock_boto_client): + self.marketplace = mock_boto_client.return_value + self.marketplace.resolve_customer.return_value = { + 'CustomerIdentifier': 'id', + 'CustomerAWSAccountId': 'account_id', + 'ProductCode': 'some' + } + self.customer = AWSCustomer('token') + + @patch('boto3.client') + def test_setup_incomplete(self, mock_boto_client): + with self._caplog.at_level(logging.INFO): + AWSCustomer('') + assert 'no marketplace token provided' in self._caplog.text + + @patch('boto3.client') + def test_setup_boto_client_raises(self, mock_boto_client): + mock_boto_client.side_effect = ClientError( + operation_name=MagicMock(), error_response=MagicMock() + ) + with self._caplog.at_level(logging.INFO): + AWSCustomer('token') + assert 'meteringmarketplace client failed' in self._caplog.text + + def test_get_id(self): + assert self.customer.get_id() == 'id' + + def test_get_account_id(self): + assert self.customer.get_account_id() == 'account_id' + + def test_get_product_code(self): + assert self.customer.get_product_code() == 'some' diff --git a/aws/resolve_customer/test/unit/entitlements_test.py b/aws/resolve_customer/test/unit/entitlements_test.py new file mode 100644 index 0000000..b08a5fc --- /dev/null +++ b/aws/resolve_customer/test/unit/entitlements_test.py @@ -0,0 +1,67 @@ +import logging +from unittest.mock import ( + patch, MagicMock +) +from pytest import fixture + +from botocore.exceptions import ClientError +from resolve_customer.entitlements import AWSCustomerEntitlement + + +class TestAWSCustomerEntitlement: + @fixture(autouse=True) + def inject_fixtures(self, caplog): + self._caplog = caplog + + @patch('boto3.client') + def setup_method(self, cls, mock_boto_client): + self.marketplace = mock_boto_client.return_value + self.marketplace.get_entitlements.return_value = { + 'Entitlements': [ + { + 'CustomerIdentifier': 'id', + 'Dimension': 'some', + 'ExpirationDate': 'some', + 'ProductCode': 'some', + 'Value': { + 'BooleanValue': True, + 'DoubleValue': 42, + 'IntegerValue': 42, + 'StringValue': 'some' + } + } + ], + 'NextToken': 'some' + } + self.entitlements = AWSCustomerEntitlement('id', 'product') + + @patch('boto3.client') + def test_setup_incomplete(self, mock_boto_client): + with self._caplog.at_level(logging.INFO): + AWSCustomerEntitlement('', '') + assert 'no customer_id and/or product_code' in self._caplog.text + + @patch('boto3.client') + def test_setup_boto_client_raises(self, mock_boto_client): + mock_boto_client.side_effect = ClientError( + operation_name=MagicMock(), error_response=MagicMock() + ) + with self._caplog.at_level(logging.INFO): + AWSCustomerEntitlement('id', 'product') + assert 'marketplace-entitlement client failed' in self._caplog.text + + def test_get_entitlements(self): + assert self.entitlements.get_entitlements() == [ + { + 'CustomerIdentifier': 'id', + 'Dimension': 'some', + 'ExpirationDate': 'some', + 'ProductCode': 'some', + 'Value': { + 'BooleanValue': True, + 'DoubleValue': 42, + 'IntegerValue': 42, + 'StringValue': 'some' + } + } + ]