From 6d4608020533971fab6571cec54f4a7642947d85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcus=20Sch=C3=A4fer?= Date: Wed, 15 Jan 2025 13:07:10 +0100 Subject: [PATCH 1/2] Added resolve_customer lambda handler The resolve_customer handler expects an event providing the x-amzn-marketplace-token. It will then resolve customer information from the metering marketplace as well as the entitlements for this customer's ID and product code. The input and return API is documented in the header of the lambda_handler entrypoint and follows the AWS API Gateway specifications. --- .github/workflows/ci-code-style.yml | 29 +++++ .github/workflows/ci-units-types.yml | 30 +++++ Makefile | 15 +++ aws/resolve_customer/pyproject.toml | 7 +- aws/resolve_customer/resolve_customer.py | 6 + aws/resolve_customer/resolve_customer/app.py | 107 ++++++++++++++++++ .../resolve_customer/customer.py | 54 +++++++++ .../resolve_customer/entitlements.py | 49 ++++++++ aws/resolve_customer/test/unit/.coveragerc | 7 ++ aws/resolve_customer/test/unit/app_test.py | 96 ++++++++++++++++ .../test/unit/customer_test.py | 48 ++++++++ .../test/unit/entitlements_test.py | 67 +++++++++++ 12 files changed, 512 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ci-code-style.yml create mode 100644 .github/workflows/ci-units-types.yml create mode 100755 aws/resolve_customer/resolve_customer.py create mode 100644 aws/resolve_customer/resolve_customer/app.py create mode 100644 aws/resolve_customer/resolve_customer/customer.py create mode 100644 aws/resolve_customer/resolve_customer/entitlements.py create mode 100644 aws/resolve_customer/test/unit/.coveragerc create mode 100644 aws/resolve_customer/test/unit/app_test.py create mode 100644 aws/resolve_customer/test/unit/customer_test.py create mode 100644 aws/resolve_customer/test/unit/entitlements_test.py 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/pyproject.toml b/aws/resolve_customer/pyproject.toml index 9a6a071..ad97e8f 100644 --- a/aws/resolve_customer/pyproject.toml +++ b/aws/resolve_customer/pyproject.toml @@ -37,13 +37,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.py b/aws/resolve_customer/resolve_customer.py new file mode 100755 index 0000000..b42473a --- /dev/null +++ b/aws/resolve_customer/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/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' + } + } + ] From c72f04af6312750b3c40e062eb4f82a0f644d3ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcus=20Sch=C3=A4fer?= Date: Thu, 16 Jan 2025 11:25:17 +0100 Subject: [PATCH 2/2] Update spec file Add resolve_customer.py as callable entry point for the lambda Also fix spec file errors for requirements and a mistake on the %description field --- .../{resolve_customer.py => app_resolve_customer.py} | 0 .../package/python-resolve_customer-spec-template | 8 ++++++-- aws/resolve_customer/pyproject.toml | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) rename aws/resolve_customer/{resolve_customer.py => app_resolve_customer.py} (100%) diff --git a/aws/resolve_customer/resolve_customer.py b/aws/resolve_customer/app_resolve_customer.py similarity index 100% rename from aws/resolve_customer/resolve_customer.py rename to aws/resolve_customer/app_resolve_customer.py 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 ad97e8f..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" },