From de3a111089d3838ee65e021f3b25ceadf15ff9fb Mon Sep 17 00:00:00 2001 From: Alberto Contreras Date: Wed, 22 May 2024 13:34:06 +0200 Subject: [PATCH] feat(auto-attach): skip if no codes on aws On aws, ua-auto-attach.service will only try to auto-attach if the instance identity document[0] contains any billing product or marketplace product code. Refs: - [0] https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-identity-documents.html#verify-iid - [1] CPC-4926 - [2] CPC0069 Signed-off-by: Alberto Contreras --- features/ubuntu_pro.feature | 36 +++++- lib/auto_attach.py | 19 +++- uaclient/clouds/aws.py | 34 +++++- uaclient/clouds/tests/test_aws.py | 152 +++++++++++++++++++++++++ uaclient/tests/test_lib_auto_attach.py | 45 ++++++-- 5 files changed, 266 insertions(+), 20 deletions(-) diff --git a/features/ubuntu_pro.feature b/features/ubuntu_pro.feature index b72e8c75f9..53d5043b77 100644 --- a/features/ubuntu_pro.feature +++ b/features/ubuntu_pro.feature @@ -174,22 +174,48 @@ Feature: Command behaviour when auto-attached in an ubuntu PRO image Examples: ubuntu release | release | machine_type | - | xenial | aws.pro | | xenial | azure.pro | | xenial | gcp.pro | - | bionic | aws.pro | | bionic | azure.pro | | bionic | gcp.pro | - | focal | aws.pro | | focal | azure.pro | | focal | gcp.pro | - | jammy | aws.pro | | jammy | azure.pro | | jammy | gcp.pro | - | noble | aws.pro | | noble | azure.pro | | noble | gcp.pro | + Scenario Outline: Auto-attach service works on Pro Machine on AWS + Given a `` aws.pro machine with ubuntu-advantage-tools installed + When I run `systemctl start ua-auto-attach.service` with sudo + And I create the file `/etc/ubuntu-advantage/uaclient.conf` with the following: + """ + contract_url: 'https://contracts.canonical.com' + data_dir: /var/lib/ubuntu-advantage + log_level: debug + log_file: /var/log/ubuntu-advantage.log + """ + And I reboot the machine + And I run `pro status --wait` with sudo + And I run `pro security-status --format json` with sudo + Then stdout matches regexp: + """ + "attached": true + """ + When I run `journalctl --no-pager -b -u ua-auto-attach.service` with sudo + Then stdout matches regexp: + """ + Auto-attaching: product code found on AWS + """ + + Examples: ubuntu release + | release | + | xenial | + | bionic | + | focal | + | jammy | + | noble | + Scenario Outline: Auto-attach no-op when cloud-init has ubuntu_advantage on userdata Given a `` `` machine with ubuntu-advantage-tools installed adding this cloud-init user_data: # This user_data should not do anything, just guarantee that the ua-auto-attach service diff --git a/lib/auto_attach.py b/lib/auto_attach.py index 6957145479..d4c23bd82b 100644 --- a/lib/auto_attach.py +++ b/lib/auto_attach.py @@ -13,7 +13,7 @@ import logging import sys -from uaclient import http, log, messages, system +from uaclient import exceptions, http, log, messages, system from uaclient.api.exceptions import ( AlreadyAttachedError, AutoAttachDisabledError, @@ -23,6 +23,8 @@ FullAutoAttachOptions, full_auto_attach, ) +from uaclient.clouds.aws import UAAutoAttachAWSInstance +from uaclient.clouds.identity import cloud_instance_factory from uaclient.config import UAConfig from uaclient.daemon import AUTO_ATTACH_STATUS_MOTD_FILE, retry_auto_attach from uaclient.files import state_files @@ -70,6 +72,21 @@ def main(cfg: UAConfig): ) return + try: + cloud = cloud_instance_factory() + except exceptions.CloudFactoryError as e: + LOG.debug("Error loading the cloud: %s", e) + else: + if isinstance(cloud, UAAutoAttachAWSInstance): + if not cloud.is_likely_pro: + LOG.info( + "Skipping auto-attach. Reason: No billingProduct nor" + " marketplaceProductCode on AWS." + ) + return + else: + LOG.info("Auto-attaching: product code found on AWS.") + system.write_file( AUTO_ATTACH_STATUS_MOTD_FILE, messages.AUTO_ATTACH_RUNNING ) diff --git a/uaclient/clouds/aws.py b/uaclient/clouds/aws.py index abc7ff8fc8..fea2e12c1a 100644 --- a/uaclient/clouds/aws.py +++ b/uaclient/clouds/aws.py @@ -1,3 +1,4 @@ +import json import logging from typing import Any, Dict @@ -10,6 +11,7 @@ IMDS_IP_ADDRESS = (IMDS_IPV4_ADDRESS, IMDS_IPV6_ADDRESS) IMDS_V2_TOKEN_URL = "http://{}/latest/api/token" IMDS_URL = "http://{}/latest/dynamic/instance-identity/pkcs7" +_IMDS_IID_URL = "http://{}/latest/dynamic/instance-identity/document" SYS_HYPERVISOR_PRODUCT_UUID = "/sys/hypervisor/uuid" DMI_PRODUCT_SERIAL = "/sys/class/dmi/id/product_serial" @@ -26,11 +28,8 @@ class UAAutoAttachAWSInstance(AutoAttachCloudInstance): _api_token = None _ip_address = None - def _get_imds_url_response(self): - headers = self._request_imds_v2_token_headers() - response = http.readurl( - IMDS_URL.format(self._ip_address), headers=headers, timeout=1 - ) + def _get_imds_url_response(self, url: str, headers): + response = http.readurl(url, headers=headers, timeout=1) if response.code == 200: return response.body else: @@ -43,10 +42,33 @@ def _get_imds_url_response(self): @property # type: ignore @util.retry(exceptions.CloudMetadataError, retry_sleeps=[0.5, 1, 1]) def identity_doc(self) -> Dict[str, Any]: - imds_url_response = self._get_imds_url_response() + headers = self._request_imds_v2_token_headers() + url = IMDS_URL.format(self._ip_address) + imds_url_response = self._get_imds_url_response(url, headers=headers) secret_manager.secrets.add_secret(imds_url_response) return {"pkcs7": imds_url_response} + @util.retry(exceptions.CloudMetadataError, retry_sleeps=[0.5, 1, 1]) + def _get_ii_doc(self) -> Dict: + headers = self._request_imds_v2_token_headers() + url = _IMDS_IID_URL.format(self._ip_address) + try: + ii_doc = json.loads( + self._get_imds_url_response(url, headers=headers) + ) + except json.JSONDecodeError as e: + LOG.debug("Error decoding instance identity document: %s", e) + return {} + return ii_doc + + @property # type: ignore + def is_likely_pro(self) -> bool: + ii_doc = self._get_ii_doc() + LOG.debug(ii_doc) + billing_products = ii_doc.get("billingProducts", None) + marketplace_product_codes = ii_doc.get("marketplaceProductCodes", None) + return bool(billing_products) or bool(marketplace_product_codes) + def _request_imds_v2_token_headers(self): for address in IMDS_IP_ADDRESS: try: diff --git a/uaclient/clouds/tests/test_aws.py b/uaclient/clouds/tests/test_aws.py index 96ca65aa99..46a80f8cb4 100644 --- a/uaclient/clouds/tests/test_aws.py +++ b/uaclient/clouds/tests/test_aws.py @@ -1,3 +1,4 @@ +import json import logging import re @@ -356,3 +357,154 @@ def test_unsupported_is_pro_license_present(self): instance = UAAutoAttachAWSInstance() with pytest.raises(exceptions.InPlaceUpgradeNotSupportedError): instance.is_pro_license_present(wait_for_change=False) + + def test__get_ii_doc_ok(self): + instance = UAAutoAttachAWSInstance() + instance._ip_address = "169.254.169.254" + headers = {"X-aws-ec2-metadata-token": "token"} + instance._request_imds_v2_token_headers = mock.MagicMock( + return_value=headers + ) + + ii_doc = { + "accountId": "937157663530", + "architecture": "x86_64", + "availabilityZone": "eu-south-2b", + "billingProducts": ["bp-66a5400f"], + "devpayProductCodes": None, + "marketplaceProductCodes": None, + "imageId": "ami-0691695a958575df9", + "instanceId": "i-09d1747b6cf64cdb9", + "instanceType": "t3.micro", + "kernelId": None, + "pendingTime": "2024-08-26T09:27:51Z", + "privateIp": "192.168.2.157", + "ramdiskId": None, + "region": "eu-south-2", + "version": "2017-09-30", + } + instance._get_imds_url_response = mock.MagicMock( + return_value=json.dumps(ii_doc) + ) + + assert ii_doc == instance._get_ii_doc() + + assert [ + mock.call( + "http://169.254.169.254/latest/dynamic/instance-identity" + "/document", + headers=headers, + ), + ] == instance._get_imds_url_response.call_args_list + + def test__get_ii_doc_json_error(self): + """test behavior when json.load fails""" + instance = UAAutoAttachAWSInstance() + instance._ip_address = "169.254.169.254" + headers = {"X-aws-ec2-metadata-token": "token"} + instance._request_imds_v2_token_headers = mock.MagicMock( + return_value=headers + ) + instance._get_imds_url_response = mock.MagicMock( + return_value="{invalidjson" + ) + assert {} == instance._get_ii_doc() + assert [ + mock.call( + "http://169.254.169.254/latest/dynamic/instance-identity" + "/document", + headers=headers, + ), + ] == instance._get_imds_url_response.call_args_list + + @pytest.mark.parametrize( + "ii_doc, is_likely_pro", + [ + ( + { + "accountId": "937157663530", + "architecture": "x86_64", + "availabilityZone": "eu-south-2b", + "billingProducts": ["bp-66a5400f"], + "devpayProductCodes": None, + "marketplaceProductCodes": None, + "imageId": "ami-0691695a958575df9", + "instanceId": "i-09d1747b6cf64cdb9", + "instanceType": "t3.micro", + "kernelId": None, + "pendingTime": "2024-08-26T09:27:51Z", + "privateIp": "192.168.2.157", + "ramdiskId": None, + "region": "eu-south-2", + "version": "2017-09-30", + }, + True, + ), + ( + { + "accountId": "937157663530", + "architecture": "x86_64", + "availabilityZone": "eu-south-2b", + "billingProducts": ["bp-66a5400f"], + "devpayProductCodes": None, + "marketplaceProductCodes": ["mp-66a5400f"], + "imageId": "ami-0691695a958575df9", + "instanceId": "i-09d1747b6cf64cdb9", + "instanceType": "t3.micro", + "kernelId": None, + "pendingTime": "2024-08-26T09:27:51Z", + "privateIp": "192.168.2.157", + "ramdiskId": None, + "region": "eu-south-2", + "version": "2017-09-30", + }, + True, + ), + ( + { + "accountId": "937157663530", + "architecture": "x86_64", + "availabilityZone": "eu-south-2b", + "billingProducts": None, + "devpayProductCodes": None, + "marketplaceProductCodes": ["mp-66a5400f"], + "imageId": "ami-0691695a958575df9", + "instanceId": "i-09d1747b6cf64cdb9", + "instanceType": "t3.micro", + "kernelId": None, + "pendingTime": "2024-08-26T09:27:51Z", + "privateIp": "192.168.2.157", + "ramdiskId": None, + "region": "eu-south-2", + "version": "2017-09-30", + }, + True, + ), + ( + { + "billingProducts": None, + "marketplaceProductCodes": ["mp-66a5400f"], + }, + True, + ), + ( + { + "billingProducts": None, + "marketplaceProductCodes": None, + }, + False, + ), + ( + { + "billingProducts": [], + "marketplaceProductCodes": [], + }, + False, + ), + ({}, False), + ], + ) + def test_is_likely_pro(self, ii_doc, is_likely_pro): + instance = UAAutoAttachAWSInstance() + instance._get_ii_doc = mock.MagicMock(return_value=ii_doc) + assert is_likely_pro == instance.is_likely_pro diff --git a/uaclient/tests/test_lib_auto_attach.py b/uaclient/tests/test_lib_auto_attach.py index c18cee1986..37ad314324 100644 --- a/uaclient/tests/test_lib_auto_attach.py +++ b/uaclient/tests/test_lib_auto_attach.py @@ -4,7 +4,7 @@ import pytest from lib.auto_attach import check_cloudinit_userdata_for_ua_info, main -from uaclient import messages +from uaclient import exceptions, messages from uaclient.api.exceptions import ( AlreadyAttachedError, AutoAttachDisabledError, @@ -107,43 +107,72 @@ def test_main( @pytest.mark.parametrize("caplog_text", [logging.DEBUG], indirect=True) @pytest.mark.parametrize( - "api_side_effect, log_msg", + "api_side_effect, log_msg, cloud_instance_factory_side_effect", [ ( AlreadyAttachedError(account_name="test_account"), "This machine is already attached to 'test_account'", + None, ), ( AutoAttachDisabledError, "Skipping auto-attach. Config disable_auto_attach is set.\n", + None, + ), + ( + None, + "Error loading the cloud: Unable to determine cloud" + " platform.\n", + exceptions.CloudFactoryNoCloudError, + ), + ( + None, + "Error loading the cloud: Auto-attach image support is not" + " available on this image\n", + exceptions.CloudFactoryNonViableCloudError, + ), + ( + None, + "Error loading the cloud: Auto-attach image support is not" + " available on cloud-test\n", + exceptions.NonAutoAttachImageError(cloud_type="cloud-test"), ), ], ) + @mock.patch("lib.auto_attach.cloud_instance_factory") @mock.patch("lib.auto_attach.check_cloudinit_userdata_for_ua_info") @mock.patch("lib.auto_attach.full_auto_attach") def test_main_handles_errors( self, m_api_full_auto_attach, m_check_cloudinit, + m_cloud_instance_factory, m_write_file, m_ensure_file_absent, api_side_effect, log_msg, + cloud_instance_factory_side_effect, caplog_text, fake_machine_token_file, ): fake_machine_token_file.attached = True m_check_cloudinit.return_value = False - m_api_full_auto_attach.side_effect = api_side_effect + if api_side_effect is not None: + m_api_full_auto_attach.side_effect = api_side_effect + if cloud_instance_factory_side_effect is not None: + m_cloud_instance_factory.side_effect = ( + cloud_instance_factory_side_effect + ) main(cfg=None) - assert ( - mock.call( - AUTO_ATTACH_STATUS_MOTD_FILE, messages.AUTO_ATTACH_RUNNING + if cloud_instance_factory_side_effect is None: + assert ( + mock.call( + AUTO_ATTACH_STATUS_MOTD_FILE, messages.AUTO_ATTACH_RUNNING + ) + in m_write_file.call_args_list ) - in m_write_file.call_args_list - ) assert m_api_full_auto_attach.call_count == 1 assert log_msg in caplog_text() assert (