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'
+ }
+ }
+ ]