Skip to content

Commit

Permalink
lxd: attempt to attach on launch
Browse files Browse the repository at this point in the history
When a lxd container is launched, pro-client will attempt to auto-attach
via the lxd apis that talk to the host's pro-client. It will only try
once and will not continue to poll.
  • Loading branch information
orndorffgrant committed Sep 6, 2024
1 parent df250f6 commit 85ce40f
Show file tree
Hide file tree
Showing 13 changed files with 442 additions and 53 deletions.
66 changes: 66 additions & 0 deletions features/lxd.feature
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,69 @@ Feature: LXD Pro features
Examples:
| release | machine_type |
| jammy | lxd-container |

Scenario Outline: LXD guest auto-attach
Given a `<release>` `<machine_type>` machine with ubuntu-advantage-tools installed
# When I run `snap install lxd --channel latest/edge` with sudo
When I run `lxd init --minimal` with sudo
When I start `lxd-download` command `lxc image copy ubuntu-daily:noble local:` in the background
When I run `pro config show` with sudo
Then stdout matches regexp:
"""
lxd_guest_attach +off
"""
Then I verify that running `pro config set lxd_guest_attach=available` `with sudo` exits `1`
Then I will see the following on stderr:
"""
This machine is not attached to an Ubuntu Pro subscription.
See https://ubuntu.com/pro
"""
When I attach `contract_token` with sudo and options `--no-auto-enable`
When I run `pro config show` with sudo
Then stdout matches regexp:
"""
lxd_guest_attach +off
"""
# latest/edge needed for early testing for now
When I wait for the `lxd-download` command to complete
When I run `lxc launch ubuntu-daily:<guest_release> guest` with sudo
When I install ubuntu-advantage-tools on the `guest` lxd guest
Then I verify that running `lxc exec guest -- pro auto-attach` `with sudo` exits `1`
Then I will see the following on stderr:
"""
The LXD host does not allow guest auto attach
"""
When I run `pro config set lxd_guest_attach=available` with sudo
When I run `lxc exec guest -- pro auto-attach` with sudo
When I run `lxc exec guest -- pro api u.pro.status.is_attached.v1` with sudo
Then I will see the following on stdout:
"""
jq it to see its attached
"""
When I run `lxc exec guest -- pro detach --assume-yes` with sudo
When I run `pro config set lxd_guest_attach=off` with sudo
Then I verify that running `lxc exec guest -- pro auto-attach` `with sudo` exits `1`
Then I will see the following on stdout:
"""
The LXD host does not allow guest auto attach
"""
When I run `lxc delete --force guest` with sudo
When I run `pro config set lxd_guest_attach=on` with sudo
When I run `lxc restart guest` with sudo
When I run `lxc exec guest -- pro status --wait` with sudo
When I run `lxc exec guest -- pro api u.pro.status.is_attached.v1` with sudo
Then I will see the following on stdout:
"""
jq it to see its attached
"""
When I run `lxc exec guest -- pro detach --assume-yes` with sudo
When I run `pro config unset lxd_guest_attach` with sudo
Then I verify that running `lxc exec guest -- pro auto-attach` `with sudo` exits `1`
Then I will see the following on stdout:
"""
The LXD host does not allow guest auto attach
"""

Examples:
| release | machine_type | guest_release |
| jammy | lxd-vm | noble |
26 changes: 26 additions & 0 deletions features/steps/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import time

from behave import step

from features.steps.shell import when_i_run_command


@step("I start `{task}` command `{command}` in the background")
def start_task(context, task, command):
when_i_run_command(
context,
f"systemd-run --no-block --unit={task} --property=Type=oneshot --property=RemainAfterExit=yes {command}", # noqa: E501
"with sudo",
)


@step("I wait for the `{task}` command to complete")
def wait_for_task(context, task):
while True:
try:
when_i_run_command(
context, f"systemctl is-active {task}", "with sudo"
)
break
except AssertionError:
time.sleep(2)
156 changes: 107 additions & 49 deletions features/steps/ubuntu_advantage_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,64 +8,68 @@
from features.steps.shell import when_i_run_command, when_i_run_shell_command
from features.util import (
ALL_BINARY_PACKAGE_NAMES,
NORMAL_BINARY_PACKAGE_NAMES,
SUT,
InstallationSource,
build_debs,
get_debs_for_series,
)

SETUP_PRO_PACKAGE_SOURCES_SCRIPTS = {
InstallationSource.ARCHIVE: "sudo apt update",
InstallationSource.DAILY: """\
sudo add-apt-repository ppa:ua-client/daily
sudo apt update""",
InstallationSource.STAGING: """\
sudo add-apt-repository ppa:ua-client/staging
sudo apt update""",
InstallationSource.STABLE: """\
sudo add-apt-repository ppa:ua-client/stable
sudo apt update""",
InstallationSource.PROPOSED: """\
cat > /etc/apt/sources.list.d/proposed.list << EOF
deb http://archive.ubuntu.com/ubuntu/ {series}-proposed main
EOF
cat > /etc/apt/preferences.d/lower-proposed << EOF
Package: *
Pin: release a={series}-proposed
Pin-Priority: 400
EOF
cat > /etc/apt/preferences.d/upper-pro-posed << EOF
Package: {packages}
Pin: release a={series}-proposed
Pin-Priority: 1001
EOF
sudo apt update""",
InstallationSource.CUSTOM: """\
sudo add-apt-repository {ppa}
sudo apt update""",
}


def get_setup_pro_package_sources_script(context, series):
script = SETUP_PRO_PACKAGE_SOURCES_SCRIPTS.get(
context.pro_config.install_from, ""
)
script = script.format(
ppa=context.pro_config.custom_ppa,
series=series,
packages=" ".join(ALL_BINARY_PACKAGE_NAMES),
)
return script


def setup_pro_package_sources(context, machine_name=SUT):
instance = context.machines[machine_name].instance

if context.pro_config.install_from is InstallationSource.ARCHIVE:
instance.execute("sudo apt update")
elif context.pro_config.install_from is InstallationSource.DAILY:
instance.execute("sudo add-apt-repository ppa:ua-client/daily")
instance.execute("sudo apt update")
elif context.pro_config.install_from is InstallationSource.STAGING:
instance.execute("sudo add-apt-repository ppa:ua-client/staging")
instance.execute("sudo apt update")
elif context.pro_config.install_from is InstallationSource.STABLE:
instance.execute("sudo add-apt-repository ppa:ua-client/stable")
instance.execute("sudo apt update")
elif context.pro_config.install_from is InstallationSource.PROPOSED:
series = context.machines[machine_name].series
context.text = "deb http://archive.ubuntu.com/ubuntu/ {series}-proposed main\n".format( # noqa: E501
series=series
)
when_i_create_file_with_content(
context,
"/etc/apt/sources.list.d/uaclient-proposed.list",
machine_name=machine_name,
)

context.text = "Package: *\nPin: release a={series}-proposed\nPin-Priority: 400\n".format( # noqa: E501
series=series
)
when_i_create_file_with_content(
context,
"/etc/apt/preferences.d/lower-proposed",
machine_name=machine_name,
)

for package in ALL_BINARY_PACKAGE_NAMES:
context.text = "Package: {package}\nPin: release a={series}-proposed\nPin-Priority: 1001\n".format( # noqa: E501
package=package,
series=series,
)
when_i_create_file_with_content(
context,
"/etc/apt/preferences.d/{}-proposed".format(package),
machine_name=machine_name,
)

instance.execute("sudo apt update")
elif context.pro_config.install_from is InstallationSource.CUSTOM:
instance.execute(
"sudo add-apt-repository {}".format(context.pro_config.custom_ppa)
)
instance.execute("sudo apt update")
series = context.machines[machine_name].series
script = get_setup_pro_package_sources_script(context, series)
context.text = script
when_i_create_file_with_content(
context,
"/tmp/setup_pro.sh",
machine_name=machine_name,
)
instance.execute("sudo bash /tmp/setup_pro.sh")


@when("I install ubuntu-advantage-tools")
Expand Down Expand Up @@ -123,6 +127,60 @@ def when_i_install_uat(context, machine_name=SUT):
)


@when("I install ubuntu-advantage-tools on the `{guest_name}` lxd guest")
def when_i_install_uat_on_lxd_guest(context, guest_name):
# This function assumes "when_i_install_uat" was run on the SUT
if context.pro_config.install_from in {
InstallationSource.PREBUILT,
InstallationSource.LOCAL,
}:
to_install = []
for deb_name in NORMAL_BINARY_PACKAGE_NAMES:
deb_file_name = "behave_{}.deb".format(deb_name)
instance_tmp_path = "/tmp/{}".format(deb_file_name)
guest_path = "/root/{}".format(deb_file_name)
when_i_run_command(
context,
"lxc file push {tmp_path} {guest_name}{guest_path}".format(
tmp_path=instance_tmp_path,
guest_name=guest_name,
guest_path=guest_path,
),
"with sudo",
)
to_install.append(guest_path)
when_i_run_command(
context,
"lxc exec {guest_name} -- apt install -y {packages}".format(
guest_name=guest_name, packages=" ".join(to_install)
),
"with sudo",
)
else:
when_i_run_command(
context,
"lxc file push /tmp/setup_pro.sh {guest_name}/root/setup_pro.sh".format( # noqa: E501
guest_name=guest_name
),
"with sudo",
)
when_i_run_command(
context,
"lxc exec {guest_name} -- bash /root/setup_pro.sh".format(
guest_name=guest_name
),
"with sudo",
)
when_i_run_command(
context,
"lxc exec {guest_name} -- apt install -y {packages}".format(
guest_name=guest_name,
packages=" ".join(NORMAL_BINARY_PACKAGE_NAMES),
),
"with sudo",
)


@when("I ensure -proposed is not enabled anymore")
def when_i_ensure_proposed_not_enabled(context, machine_name=SUT):
if context.pro_config.install_from is InstallationSource.PROPOSED:
Expand Down
5 changes: 5 additions & 0 deletions features/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@
"ubuntu-advantage-tools",
"ubuntu-advantage-pro",
]
NORMAL_BINARY_PACKAGE_NAMES = [
"ubuntu-pro-client",
"ubuntu-pro-client-l10n",
"ubuntu-advantage-tools",
]


@dataclass
Expand Down
2 changes: 1 addition & 1 deletion lib/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def main() -> int:
LOG.debug("checking for condition files")
is_correct_cloud = any(
os.path.exists("/run/cloud-init/cloud-id-{}".format(cloud))
for cloud in ("gce", "azure")
for cloud in ("gce", "azure", "lxd")
)
if is_correct_cloud and not os.path.exists(
retry_auto_attach.FLAG_FILE_PATH
Expand Down
1 change: 1 addition & 0 deletions systemd/ubuntu-advantage.service
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ ConditionPathExists=!/var/lib/ubuntu-advantage/private/machine-token.json
# The following conditions correspond to those two modes.
ConditionPathExists=|/run/cloud-init/cloud-id-gce
ConditionPathExists=|/run/cloud-init/cloud-id-azure
ConditionPathExists=|/run/cloud-init/cloud-id-lxd
ConditionPathExists=|/run/ubuntu-advantage/flags/auto-attach-failed

[Service]
Expand Down
3 changes: 2 additions & 1 deletion uaclient/clouds/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,15 @@ def cloud_instance_factory(
:raises CloudFactoryNonViableCloudError: if no cloud instance object can be
constructed because we explicitly do not support the cloud we're on
"""
from uaclient.clouds import aws, azure, gcp
from uaclient.clouds import aws, azure, gcp, lxd

cloud_instance_map = {
"aws": aws.AWSAutoAttachInstance,
"aws-china": aws.AWSAutoAttachInstance,
"aws-gov": aws.AWSAutoAttachInstance,
"azure": azure.AzureAutoAttachInstance,
"gce": gcp.GCPAutoAttachInstance,
"lxd": lxd.LXDAutoAttachInstance,
} # type: Dict[str, Type[clouds.AutoAttachInstance]]

if cloud_override is not None:
Expand Down
58 changes: 58 additions & 0 deletions uaclient/clouds/lxd.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import logging

from uaclient import config, exceptions, http, log, secret_manager, util
from uaclient.clouds import AutoAttachInstance

LOG = logging.getLogger(util.replace_top_level_logger_name(__name__))

LXD_INSTANCE_API_SOCKET_PATH = "/dev/lxd/sock"
LXD_INSTANCE_API_ENDPOINT_UBUNTU_PRO = "/1.0/ubuntu-pro"
LXD_INSTANCE_API_ENDPOINT_UBUNTU_PRO_GUEST_TOKEN = "/1.0/ubuntu-pro/token"


class LXDAutoAttachInstance(AutoAttachInstance):
@property
def is_viable(self) -> bool:
return True

def should_poll_for_pro_license(self) -> bool:
"""Yes, but only once - is_pro_license_present doesn't
support wait_for_change"""
return True

def is_pro_license_present(self, *, wait_for_change: bool) -> bool:
if wait_for_change:
# Unsupported
raise exceptions.CancelProLicensePolling()

resp = http.unix_socket_request(
LXD_INSTANCE_API_SOCKET_PATH,
"GET",
LXD_INSTANCE_API_ENDPOINT_UBUNTU_PRO,
)
if resp.code != 200:
LOG.warning(
"LXD instance API returned error for ubuntu-pro query",
extra=log.extra(code=resp.code, body=resp.body),
)
return False
# returning True will cause auto-attach on launch, so only "on" counts
return resp.json_dict.get("guest_attach", "off") == "on"

def acquire_pro_token(self, cfg: config.UAConfig) -> str:
"""
Cloud-specific implementation of acquiring the pro token using whatever
method suits the platform
"""
resp = http.unix_socket_request(
LXD_INSTANCE_API_SOCKET_PATH,
"POST",
LXD_INSTANCE_API_ENDPOINT_UBUNTU_PRO_GUEST_TOKEN,
)
if resp.code == 404:
raise exceptions.LXDAutoAttachNotAvailable()
elif resp.code == 403:
raise exceptions.LXDAutoAttachNotAllowed()
guest_token = resp.json_dict.get("guest_token", "")
secret_manager.secrets.add_secret(guest_token)
return guest_token
4 changes: 3 additions & 1 deletion uaclient/clouds/tests/test_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ def fake_invalid_instance():
cloud_instance_factory()

@pytest.mark.parametrize(
"cloud_type", ("aws", "aws-gov", "aws-china", "azure")
"cloud_type", ("aws", "aws-gov", "aws-china", "azure", "lxd")
)
def test_return_cloud_instance_on_viable_clouds(
self, m_get_cloud_type, cloud_type
Expand All @@ -144,6 +144,8 @@ def fake_viable_instance():

if cloud_type == "azure":
M_INSTANCE_PATH = "uaclient.clouds.azure.AzureAutoAttachInstance"
elif cloud_type == "lxd":
M_INSTANCE_PATH = "uaclient.clouds.lxd.LXDAutoAttachInstance"
else:
M_INSTANCE_PATH = "uaclient.clouds.aws.AWSAutoAttachInstance"

Expand Down
Loading

0 comments on commit 85ce40f

Please sign in to comment.