Skip to content

Commit

Permalink
feat(auto-attach): skip if no codes on aws
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
aciba90 authored and renanrodrigo committed Sep 10, 2024
1 parent 1accb8c commit 02d0ccf
Show file tree
Hide file tree
Showing 5 changed files with 318 additions and 20 deletions.
72 changes: 67 additions & 5 deletions features/ubuntu_pro.feature
Original file line number Diff line number Diff line change
Expand Up @@ -174,22 +174,84 @@ 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.pro
Given a `<release>` 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
"""
Then stdout does not match regexp:
"""
Skipping auto-attach. Reason: No billingProduct nor marketplaceProductCode on AWS."
"""

Examples: ubuntu release
| release |
| xenial |
| bionic |
| focal |
| jammy |
| noble |

Scenario Outline: Auto-attach service works on Pro Machine on aws.generic
Given a `<release>` `<machine_type>` machine with ubuntu-advantage-tools installed
When I install ubuntu-pro-auto-attach
When I reboot the machine
And I run `pro status --wait` with sudo
Then stdout matches regexp:
"""
This machine is not attached to an Ubuntu Pro subscription.
"""
When I verify that running `systemctl status ua-auto-attach.service` `as non-root` exits `3`
Then stdout matches regexp:
"""
\s*Main PID: \d+ \(code=exited, status=0/SUCCESS\)
"""
When I run `journalctl --no-pager -b -u ua-auto-attach.service` with sudo
Then stdout does not match regexp:
"""
Auto-attaching: product code found on AWS
"""
Then stdout matches regexp:
"""
Skipping auto-attach. Reason: No billingProduct nor marketplaceProductCode on AWS."
"""

Examples: ubuntu release
| release | machine_type |
| xenial | aws.generic |
| bionic | aws.generic |
| focal | aws.generic |
| jammy | aws.generic |
| noble | aws.generic |

Scenario Outline: Auto-attach no-op when cloud-init has ubuntu_advantage on userdata
Given a `<release>` `<machine_type>` 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
Expand Down
19 changes: 18 additions & 1 deletion lib/auto_attach.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
)
Expand Down
50 changes: 44 additions & 6 deletions uaclient/clouds/aws.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import logging
from typing import Any, Dict

Expand All @@ -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"
Expand All @@ -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:
Expand All @@ -43,10 +42,49 @@ 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:
"""
Get the instance identity doc associated with the current instance.
See
https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/retrieve-iid.html
for more context.
@return: Dict containing the instance identity document.
"""
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:
"""
Determines if the instance is likely Ubuntu Pro.
Criteria: if any billing-product or marketplace-product-code is
present, then is likely a Pro instance.
@return: Boolean indicating if the instance is likely pro or not.
"""
ii_doc = self._get_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:
Expand Down
152 changes: 152 additions & 0 deletions uaclient/clouds/tests/test_aws.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import logging
import re

Expand Down Expand Up @@ -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
Loading

0 comments on commit 02d0ccf

Please sign in to comment.