From a0b6004455dcd1041ce317b5bd54fe421785660d Mon Sep 17 00:00:00 2001 From: Grant Orndorff Date: Mon, 17 Jul 2023 17:07:03 -0400 Subject: [PATCH] wip --- .github/workflows/ci-integration.yaml | 3 + features/environment.py | 22 ++- features/landscape.feature | 247 ++++++++++++++++++++++++++ features/steps/landscape.py | 69 +++++++ features/steps/output.py | 61 +++++-- features/steps/shell.py | 6 + features/util.py | 19 +- help_data.yaml | 6 + tools/ua.bash | 4 +- uaclient/actions.py | 4 +- uaclient/cli.py | 41 +++-- uaclient/entitlements/__init__.py | 2 + uaclient/entitlements/base.py | 5 + uaclient/entitlements/landscape.py | 166 +++++++++++++++++ uaclient/messages.py | 36 ++++ uaclient/system.py | 8 + uaclient/tests/test_util.py | 36 ++++ uaclient/util.py | 9 + 18 files changed, 701 insertions(+), 43 deletions(-) create mode 100644 features/landscape.feature create mode 100644 features/steps/landscape.py create mode 100644 uaclient/entitlements/landscape.py diff --git a/.github/workflows/ci-integration.yaml b/.github/workflows/ci-integration.yaml index 2182561788..b219fd6943 100644 --- a/.github/workflows/ci-integration.yaml +++ b/.github/workflows/ci-integration.yaml @@ -123,6 +123,9 @@ jobs: UACLIENT_BEHAVE_CONTRACT_TOKEN: '${{ secrets.UACLIENT_BEHAVE_CONTRACT_TOKEN }}' UACLIENT_BEHAVE_CONTRACT_TOKEN_STAGING: '${{ secrets.UACLIENT_BEHAVE_CONTRACT_TOKEN_STAGING }}' UACLIENT_BEHAVE_CONTRACT_TOKEN_STAGING_EXPIRED: '${{ secrets.UACLIENT_BEHAVE_CONTRACT_TOKEN_STAGING_EXPIRED }}' + UACLIENT_BEHAVE_LANDSCAPE_REGISTRATION_KEY: '${{ secrets.UACLIENT_BEHAVE_LANDSCAPE_REGISTRATION_KEY }}' + UACLIENT_BEHAVE_LANDSCAPE_API_ACCESS_KEY: '${{ secrets.UACLIENT_BEHAVE_LANDSCAPE_API_ACCESS_KEY }}' + UACLIENT_BEHAVE_LANDSCAPE_API_SECRET_KEY: '${{ secrets.UACLIENT_BEHAVE_LANDSCAPE_API_SECRET_KEY }}' run: | PYCLOUDLIB_CONFIG="$(mktemp --tmpdir="${{ runner.temp }}" pycloudlib.toml.XXXXXXXXXX)" GCE_CREDENTIALS_PATH="$(mktemp --tmpdir="${{ runner.temp }}" gcloud.json.XXXXXXXXXX)" diff --git a/features/environment.py b/features/environment.py index 9f1dd02308..dda001e54b 100644 --- a/features/environment.py +++ b/features/environment.py @@ -86,6 +86,9 @@ class UAClientBehaveConfig: "contract_token", "contract_token_staging", "contract_token_staging_expired", + "landscape_registration_key", + "landscape_api_access_key", + "landscape_api_secret_key", "machine_type", "private_key_file", "private_key_name", @@ -102,6 +105,9 @@ class UAClientBehaveConfig: "contract_token", "contract_token_staging", "contract_token_staging_expired", + "landscape_registration_key", + "landscape_api_access_key", + "landscape_api_secret_key", ] # This variable is used in .from_environ() but also to emit the "Config @@ -124,6 +130,9 @@ def __init__( contract_token: Optional[str] = None, contract_token_staging: Optional[str] = None, contract_token_staging_expired: Optional[str] = None, + landscape_registration_key: Optional[str] = None, + landscape_api_access_key: Optional[str] = None, + landscape_api_secret_key: Optional[str] = None, artifact_dir: str = "artifacts", install_from: InstallationSource = InstallationSource.LOCAL, custom_ppa: Optional[str] = None, @@ -141,6 +150,9 @@ def __init__( self.contract_token = contract_token self.contract_token_staging = contract_token_staging self.contract_token_staging_expired = contract_token_staging_expired + self.landscape_registration_key = landscape_registration_key + self.landscape_api_access_key = landscape_api_access_key + self.landscape_api_secret_key = landscape_api_secret_key self.image_clean = image_clean self.destroy_instances = destroy_instances self.machine_type = machine_type @@ -285,17 +297,17 @@ def from_environ(cls, config) -> "UAClientBehaveConfig": continue kwargs[our_key] = value + # userdata should override environment variables + kwargs.update(config.userdata) + # Next, sanitise the non-string options to Python types for key in cls.boolean_options: bool_value = True # Default to True if key in kwargs: - if kwargs[key] == "0": + if kwargs[key] == "0" or str(kwargs[key]).lower() == "false": bool_value = False kwargs[key] = bool_value - # userdata should override environment variables - kwargs.update(config.userdata) - if "install_from" in kwargs: kwargs["install_from"] = InstallationSource(kwargs["install_from"]) @@ -455,7 +467,7 @@ def before_scenario(context: Context, scenario: Scenario): for step in scenario.steps: if step.text: step.text = process_template_vars( - context, step.text, logger_fn=logger.warn + context, step.text, logger_fn=logger.warn, shown=True ) diff --git a/features/landscape.feature b/features/landscape.feature new file mode 100644 index 0000000000..60258205dd --- /dev/null +++ b/features/landscape.feature @@ -0,0 +1,247 @@ +@uses.config.contract_token +@uses.config.landscape_registration_key +@uses.config.landscape_api_access_key +@uses.config.landscape_api_secret_key +Feature: Enable landscape on Ubuntu + + @series.lunar + @uses.config.machine_type.any + @uses.config.machine_type.lxd-container + Scenario Outline: Enable Landscape non-interactively + Given a `` `` machine with ubuntu-advantage-tools installed + When I set the machine token overlay to the following yaml + """ + availableResources: + - available: true + name: landscape + machineTokenInfo: + contractInfo: + resourceEntitlements: + - type: landscape + affordances: + series: + - focal + - jammy + - kinetic + - lunar + - mantic + """ + When I attach `contract_token` with sudo and options `--no-auto-enable` + + Then I verify that running `pro enable landscape` `as non-root` exits `1` + And I will see the following on stderr: + """ + This command must be run as root (try using sudo). + """ + + When I run `pro enable landscape -- --computer-title $behave_var{machine-name system-under-test} --account-name pro-client-qa --registration-key $behave_var{config landscape_registration_key} --silent` with sudo + Then stdout contains substring: + """ + One moment, checking your subscription first + Updating package lists + Installing landscape-client + Executing `landscape-config --computer-title $behave_var{machine-name system-under-test} --account-name pro-client-qa --registration-key --silent` + """ + Then stdout contains substring + """ + Landscape enabled + """ + When I run `pro status` as non-root + Then stdout matches regexp: + """ + landscape +yes +enabled + """ + When I run `pro status` with sudo + Then stdout matches regexp: + """ + landscape +yes +enabled + """ + + When I run `systemctl stop landscape-client` with sudo + When I run `pro status` with sudo + Then stdout matches regexp: + """ + landscape +yes +warning + """ + Then stdout contains substring: + """ + Landscape is installed and configured and registered but not running. + Run `sudo landscape-config` to start it, or run `sudo pro disable landscape` + """ + + When I run `rm /etc/landscape/client.conf` with sudo + When I run `pro status` with sudo + Then stdout matches regexp: + """ + landscape +yes +warning + """ + Then stdout contains substring: + """ + Landscape is installed but not configured. + Run `sudo landscape-config` to set it up, or run `sudo pro disable landscape` + """ + + When I run `sudo pro disable landscape` with sudo + Then I will see the following on stdout: + """ + Executing `landscape-config --disable` + Failed running command 'landscape-config --disable' [exit(1)]. Message: error: config file /etc/landscape/client.conf can't be read + Backing up /etc/landscape/client.conf as /etc/landscape/client.conf.pro-disable-backup + [Errno 2] No such file or directory: '/etc/landscape/client.conf' -> '/etc/landscape/client.conf.pro-disable-backup' + Uninstalling landscape-client + Landscape disabled + """ + When I run `pro status` with sudo + Then stdout matches regexp: + """ + landscape +yes +disabled + """ + + # Enable with assume-yes + When I run `pro enable landscape --assume-yes -- --computer-title $behave_var{machine-name system-under-test} --account-name pro-client-qa --registration-key $behave_var{config landscape_registration_key}` with sudo + Then I will see the following on stdout: + """ + One moment, checking your subscription first + Updating package lists + Installing landscape-client + Executing `landscape-config --computer-title $behave_var{machine-name system-under-test} --account-name pro-client-qa --registration-key --silent` + Landscape enabled + """ + When I run `pro status` with sudo + Then stdout matches regexp: + """ + landscape +yes +enabled + """ + When I run `sudo pro disable landscape` with sudo + + # Fail to enable with assume-yes + When I verify that running `pro enable landscape --assume-yes -- --computer-title $behave_var{machine-name system-under-test} --account-name pro-client-qa` `with sudo` exits `1` + Then I will see the following on stdout: + """ + One moment, checking your subscription first + Updating package lists + Installing landscape-client + Executing `landscape-config --computer-title $behave_var{machine-name system-under-test} --account-name pro-client-qa --silent` + Created symlink /etc/systemd/system/multi-user.target.wants/landscape-client.service → /lib/systemd/system/landscape-client.service. + Invalid account name or registration key. + Could not enable Landscape. + """ + When I run `pro status` with sudo + Then stdout matches regexp: + """ + landscape +yes +warning + """ + Then stdout contains substring: + """ + Landscape is installed and configured but not registered. + Run `sudo landscape-config` to register, or run `sudo pro disable landscape` + """ + When I run `sudo pro disable landscape` with sudo + + # Enable with assume-yes and format json + When I run `pro enable landscape --assume-yes --format=json -- --computer-title $behave_var{machine-name system-under-test} --account-name pro-client-qa --registration-key $behave_var{config landscape_registration_key}` with sudo + Then I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [], "failed_services": [], "needs_reboot": false, "processed_services": ["landscape"], "result": "success", "warnings": []} + """ + When I run `pro status` with sudo + Then stdout matches regexp: + """ + landscape +yes +enabled + """ + When I run `sudo pro disable landscape` with sudo + + # Fail to enable with assume-yes and format json + When I verify that running `pro enable landscape --assume-yes --format=json -- --computer-title $behave_var{machine-name system-under-test} --account-name pro-client-qa` `with sudo` exits `1` + Then I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [{"additional_info": {"stderr": "Created symlink /etc/systemd/system/multi-user.target.wants/landscape-client.service \u2192 /lib/systemd/system/landscape-client.service.\nInvalid account name or registration key.", "stdout": "Please wait..."}, "message": "landscape-config command failed", "message_code": "landscape-config-failed", "service": "landscape", "type": "service"}], "failed_services": ["landscape"], "needs_reboot": false, "processed_services": [], "result": "failure", "warnings": []} + """ + When I run `pro status` with sudo + Then stdout matches regexp: + """ + landscape +yes +warning + """ + Then stdout contains substring: + """ + Landscape is installed and configured but not registered. + Run `sudo landscape-config` to register, or run `sudo pro disable landscape` + """ + When I run `sudo pro disable landscape` with sudo + + # cleanup + Then I reject all pending computers on Landscape + Examples: ubuntu release + | release | machine_type | + | lunar | lxd-container | + + @series.lunar + @uses.config.machine_type.any + @uses.config.machine_type.lxd-container + Scenario Outline: Enable Landscape interactively + Given a `` `` machine with ubuntu-advantage-tools installed + When I set the machine token overlay to the following yaml + """ + availableResources: + - available: true + name: landscape + machineTokenInfo: + contractInfo: + resourceEntitlements: + - type: landscape + affordances: + series: + - focal + - jammy + - kinetic + - lunar + - mantic + """ + When I attach `contract_token` with sudo and options `--no-auto-enable` + + Then I verify that running `pro enable landscape` `as non-root` exits `1` + And I will see the following on stderr: + """ + This command must be run as root (try using sudo). + """ + + When I run `pro enable landscape` `with sudo` and the following stdin + """ + y + $behave_var{machine-name system-under-test} + pro-client-qa + $behave_var{config landscape_registration_key} + $behave_var{config landscape_registration_key} + + + n + + + y + """ + Then stdout contains substring: + """ + One moment, checking your subscription first + Updating package lists + Installing landscape-client + Executing `landscape-config` + """ + Then stdout contains substring: + """ + System successfully registered. + """ + Then stdout contains substring + """ + Landscape enabled + """ + When I run `pro status` with sudo + Then stdout matches regexp: + """ + landscape +yes +enabled + """ + + # cleanup + Then I reject all pending computers on Landscape + Examples: ubuntu release + | release | machine_type | + | lunar | lxd-container | diff --git a/features/steps/landscape.py b/features/steps/landscape.py new file mode 100644 index 0000000000..9151756f85 --- /dev/null +++ b/features/steps/landscape.py @@ -0,0 +1,69 @@ +import hmac +import json +import time +from base64 import b64encode +from hashlib import sha256 +from urllib.parse import quote +from urllib.request import Request, urlopen + +from behave import step + + +def _landscape_api_request(access_key, secret_key, action, action_params): + timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + params = { + "action": action, + "access_key_id": access_key, + "signature_method": "HmacSHA256", + "signature_version": "2", + "timestamp": timestamp, + "version": "2011-08-01", + **action_params, + } + method = "POST" + uri = "https://landscape.canonical.com/api/" + host = "landscape.canonical.com" + path = "/api/" + + formatted_params = "&".join( + quote(k, safe="~") + "=" + quote(v, safe="~") + for k, v in sorted(params.items()) + ) + + to_sign = "{method}\n{host}\n{path}\n{formatted_params}".format( + method=method, + host=host, + path=path, + formatted_params=formatted_params, + ) + digest = hmac.new(secret_key.encode(), to_sign.encode(), sha256).digest() + signature = b64encode(digest) + formatted_params += "&signature=" + quote(signature) + + request = Request( + uri, + headers={"Host": host}, + method=method, + data=formatted_params.encode(), + ) + response = urlopen(request) + + return response.code, json.load(response) + + +@step("I reject all pending computers on Landscape") +def reject_all_pending_computers(context): + access_key = context.pro_config.landscape_api_access_key + secret_key = context.pro_config.landscape_api_secret_key + code, pending_computers = _landscape_api_request( + access_key, secret_key, "GetPendingComputers", {} + ) + assert code == 200 + reject_params = { + "computer_ids.{}".format(i + 1): str(computer["id"]) + for i, computer in enumerate(pending_computers) + } + code, _resp = _landscape_api_request( + access_key, secret_key, "RejectPendingComputers", reject_params + ) + assert code == 200 diff --git a/features/steps/output.py b/features/steps/output.py index 788e9396f5..0eb91d36ea 100644 --- a/features/steps/output.py +++ b/features/steps/output.py @@ -1,24 +1,26 @@ import json +import re +import textwrap import jsonschema # type: ignore import yaml from behave import then, when -from hamcrest import ( - assert_that, - contains_string, - equal_to, - matches_regexp, - not_, -) +from hamcrest import assert_that, contains_string, equal_to, not_ from features.steps.shell import when_i_run_command from features.util import SafeLoaderWithoutDatetime, process_template_vars -@then("I will see the following on stdout") -def then_i_will_see_on_stdout(context): +@then("I will see the following on {stream}") +def then_i_will_see_on_stdout(context, stream): + content = getattr(context.process, stream).strip() text = process_template_vars(context, context.text) - assert_that(context.process.stdout.strip(), equal_to(text)) + if not text == content: + raise AssertionError( + "Expected to find exactly:\n{}\nBut got:\n{}".format( + textwrap.indent(text, " "), textwrap.indent(content, " ") + ) + ) @then("if `{value1}` in `{value2}` and stdout matches regexp") @@ -48,21 +50,39 @@ def then_not_in_conditional_stdout_does_not_match_regexp( def then_stream_does_not_match_regexp(context, stream): text = process_template_vars(context, context.text) content = getattr(context.process, stream).strip() - assert_that(content, not_(matches_regexp(text))) + if re.compile(text).search(content) is not None: + raise AssertionError( + "Expected to NOT match regexp:\n{}\nBut got:\n{}".format( + textwrap.indent(text, " "), textwrap.indent(content, " ") + ) + ) @then("{stream} matches regexp") def then_stream_matches_regexp(context, stream): content = getattr(context.process, stream).strip() text = process_template_vars(context, context.text) - assert_that(content, matches_regexp(text)) + if re.compile(text).search(content) is None: + raise AssertionError( + "Expected to match regexp:\n{}\nBut got:\n{}".format( + textwrap.indent(text, " "), textwrap.indent(content, " ") + ) + ) @then("{stream} contains substring") def then_stream_contains_substring(context, stream): content = getattr(context.process, stream).strip() text = process_template_vars(context, context.text) - assert_that(content, contains_string(text)) + if text not in content: + raise AssertionError( + ( + "Expected to find substring:\n{}\n" + + "But couldn't find it in:\n{}" + ).format( + textwrap.indent(text, " "), textwrap.indent(content, " ") + ) + ) @then("{stream} does not contain substring") @@ -70,12 +90,15 @@ def then_stream_not_contains_substring(context, stream): content = getattr(context.process, stream).strip() text = process_template_vars(context, context.text) assert_that(content, not_(contains_string(text))) - - -@then("I will see the following on stderr") -def then_i_will_see_on_stderr(context): - text = process_template_vars(context, context.text) - assert_that(context.process.stderr.strip(), equal_to(text)) + if text in content: + raise AssertionError( + ( + "Expected to NOT find substring:\n{}\n" + + "But did find it in:\n{}" + ).format( + textwrap.indent(text, " "), textwrap.indent(content, " ") + ) + ) @then("I will see the uaclient version on stdout") diff --git a/features/steps/shell.py b/features/steps/shell.py index 6e04988b5c..18da336da2 100644 --- a/features/steps/shell.py +++ b/features/steps/shell.py @@ -31,6 +31,12 @@ def when_i_retry_run_command(context, command, user_spec, exit_codes): assert_that(context.process.returncode, equal_to(0)) +@when("I run `{command}` `{user_spec}` and the following stdin") +def when_i_run_command_with_long_stdin(context, command, user_spec): + text = process_template_vars(context, context.text) + when_i_run_command(context, command, user_spec, stdin=text) + + @when("I run `{command}` {user_spec}") @when("I run `{command}` `{user_spec}` on the `{machine_name}` machine") @when("I run `{command}` `{user_spec}` and stdin `{stdin}`") diff --git a/features/util.py b/features/util.py index 5bdcef4cd0..0b609f6574 100644 --- a/features/util.py +++ b/features/util.py @@ -308,7 +308,7 @@ def _replace_and_log(s, old, new, logger_fn): def process_template_vars( - context, template: str, logger_fn: Optional[Callable] = None + context, template: str, logger_fn: Optional[Callable] = None, shown=False ) -> str: if logger_fn is None: logger_fn = logging.info @@ -334,6 +334,14 @@ def process_template_vars( context.machines[args[1]].instance.ip, logger_fn, ) + elif function_name == "machine-name": + if args[1] in context.machines: + processed_template = _replace_and_log( + processed_template, + match.group(0), + context.machines[args[1]].instance.name, + logger_fn, + ) elif function_name == "cloud": processed_template = _replace_and_log( processed_template, @@ -360,6 +368,15 @@ def process_template_vars( context.pro_config.contract_token_staging, logger_fn, ) + elif function_name == "config": + item = args[1] + if not shown or item not in context.pro_config.redact_options: + processed_template = _replace_and_log( + processed_template, + match.group(0), + getattr(context.pro_config, item), + logger_fn, + ) elif function_name == "stored_var": if context.stored_vars.get(args[1]): processed_template = _replace_and_log( diff --git a/help_data.yaml b/help_data.yaml index ae8559e5e2..9bae165ad2 100644 --- a/help_data.yaml +++ b/help_data.yaml @@ -62,6 +62,12 @@ fips-updates: for those modules that have been provided since their certification date. You can find out more at https://ubuntu.com/security/certifications#fips. +landscape: + help: | + landscape is a ... TODO + Find out more about Landscape at + https://ubuntu.com/landscape + livepatch: help: | Livepatch provides selected high and critical kernel CVE fixes and other diff --git a/tools/ua.bash b/tools/ua.bash index 14891ace89..3e45f18219 100644 --- a/tools/ua.bash +++ b/tools/ua.bash @@ -9,9 +9,9 @@ _ua_complete() prev_word="${COMP_WORDS[COMP_CWORD-1]}" if [ "$VERSION_ID" = "16.04" ] || [ "$VERSION_ID" == "18.04" ]; then - services="cc-eal cis esm-apps esm-infra fips fips-updates livepatch realtime-kernel ros ros-updates" + services="cc-eal cis esm-apps esm-infra fips fips-updates landscape livepatch realtime-kernel ros ros-updates" else - services="cc-eal esm-apps esm-infra fips fips-updates livepatch realtime-kernel ros ros-updates usg" + services="cc-eal esm-apps esm-infra fips fips-updates landscape livepatch realtime-kernel ros ros-updates usg" fi subcmds="--debug --help --version api attach auto-attach collect-logs config detach disable enable fix help refresh security-status status system version" diff --git a/uaclient/actions.py b/uaclient/actions.py index 47ff727540..174f51f42a 100644 --- a/uaclient/actions.py +++ b/uaclient/actions.py @@ -105,7 +105,8 @@ def enable_entitlement_by_name( assume_yes: bool = False, allow_beta: bool = False, access_only: bool = False, - variant: str = "" + variant: str = "", + extra_args: Optional[List[str]] = None, ): """ Constructs an entitlement based on the name provided. Passes kwargs onto @@ -122,6 +123,7 @@ def enable_entitlement_by_name( allow_beta=allow_beta, called_name=name, access_only=access_only, + extra_args=extra_args, ) return entitlement.enable() diff --git a/uaclient/cli.py b/uaclient/cli.py index 9d783c0b77..c55dfd3102 100644 --- a/uaclient/cli.py +++ b/uaclient/cli.py @@ -272,12 +272,12 @@ def assert_not_attached(f): """Decorator asserting unattached config.""" @wraps(f) - def new_f(args, cfg): + def new_f(args, cfg, **kwargs): if _is_attached(cfg).is_attached: raise exceptions.AlreadyAttachedError( cfg.machine_token_file.account.get("name", "") ) - return f(args, cfg=cfg) + return f(args, cfg=cfg, **kwargs) return new_f @@ -716,7 +716,7 @@ def enable_parser(parser, cfg: config.UAConfig): parser.add_argument( "service", action="store", - nargs="+", + nargs="*", help=( "the name(s) of the Ubuntu Pro services to enable." " One of: {}".format( @@ -1271,7 +1271,7 @@ def action_disable(args, *, cfg, **kwargs): @assert_root @assert_attached(_create_enable_disable_unattached_msg) @assert_lock_file("pro enable") -def action_enable(args, *, cfg, **kwargs): +def action_enable(args, *, cfg, extra_args=None, **kwargs): """Perform the enable action on a named entitlement. @return: 0 on success, 1 otherwise @@ -1306,6 +1306,7 @@ def action_enable(args, *, cfg, **kwargs): allow_beta=args.beta, access_only=access_only, variant=variant, + extra_args=extra_args, ) ua_status.status(cfg=cfg) # Update the status cache @@ -1353,7 +1354,7 @@ def action_enable(args, *, cfg, **kwargs): @assert_root @assert_attached() @assert_lock_file("pro detach") -def action_detach(args, *, cfg) -> int: +def action_detach(args, *, cfg, **kwargs) -> int: """Perform the detach action for this machine. @return: 0 on success, 1 otherwise @@ -1436,14 +1437,14 @@ def _post_cli_attach(cfg: config.UAConfig) -> None: event.process_events() -def action_api(args, *, cfg): +def action_api(args, *, cfg, **kwargs): result = call_api(args.endpoint_path, args.options, cfg) print(result.to_json()) return 0 if result.result == "success" else 1 @assert_root -def action_auto_attach(args, *, cfg: config.UAConfig) -> int: +def action_auto_attach(args, *, cfg: config.UAConfig, **kwargs) -> int: try: _full_auto_attach( FullAutoAttachOptions(), @@ -1494,7 +1495,7 @@ def _magic_attach(args, *, cfg, **kwargs): @assert_not_attached @assert_root @assert_lock_file("pro attach") -def action_attach(args, *, cfg): +def action_attach(args, *, cfg, **kwargs): if args.token and args.attach_config: raise exceptions.UserFacingError( msg=messages.ATTACH_TOKEN_ARG_XOR_CONFIG.msg, @@ -1562,7 +1563,7 @@ def action_attach(args, *, cfg): return ret -def action_collect_logs(args, *, cfg: config.UAConfig): +def action_collect_logs(args, *, cfg: config.UAConfig, **kwargs): output_file = args.output or UA_COLLECT_LOGS_FILE with tempfile.TemporaryDirectory() as output_dir: actions.collect_logs(cfg, output_dir) @@ -1700,7 +1701,7 @@ def get_parser(cfg: config.UAConfig): return parser -def action_status(args, *, cfg: config.UAConfig): +def action_status(args, *, cfg: config.UAConfig, **kwargs): if not cfg: cfg = config.UAConfig() show_all = args.all if args else False @@ -1740,7 +1741,7 @@ def action_system(args, *, cfg, **kwargs): return 0 -def action_system_reboot_required(args, *, cfg: config.UAConfig): +def action_system_reboot_required(args, *, cfg: config.UAConfig, **kwargs): result = _reboot_required(cfg) event.info(result.reboot_required) return 0 @@ -1790,7 +1791,7 @@ def _action_refresh_messages(_args, cfg: config.UAConfig): @assert_root @assert_lock_file("pro refresh") -def action_refresh(args, *, cfg: config.UAConfig): +def action_refresh(args, *, cfg: config.UAConfig, **kwargs): if args.target is None or args.target == "config": _action_refresh_config(args, cfg) @@ -1828,7 +1829,7 @@ def configure_apt_proxy( ) -def action_help(args, *, cfg): +def action_help(args, *, cfg, **kwargs): service = args.service show_all = args.all @@ -2029,7 +2030,17 @@ def main(sys_argv=None): parser.print_usage() print(TRY_HELP) sys.exit(1) - args = parser.parse_args(args=cli_arguments) + + # Grab everything after a "--" if present and handle separately + if "--" in cli_arguments: + double_dash_index = cli_arguments.index("--") + pro_cli_args = cli_arguments[:double_dash_index] + extra_args = cli_arguments[double_dash_index + 1 :] + else: + pro_cli_args = cli_arguments + extra_args = [] + + args = parser.parse_args(args=pro_cli_args) set_event_mode(args) http_proxy = cfg.http_proxy @@ -2056,7 +2067,7 @@ def main(sys_argv=None): _warn_about_output_redirection(args) - return_value = args.action(args, cfg=cfg) + return_value = args.action(args, cfg=cfg, extra_args=extra_args) _warn_about_new_version(args) diff --git a/uaclient/entitlements/__init__.py b/uaclient/entitlements/__init__.py index e38181c4c4..eb89df8b54 100644 --- a/uaclient/entitlements/__init__.py +++ b/uaclient/entitlements/__init__.py @@ -10,6 +10,7 @@ from uaclient.entitlements.cc import CommonCriteriaEntitlement from uaclient.entitlements.cis import CISEntitlement from uaclient.entitlements.esm import ESMAppsEntitlement, ESMInfraEntitlement +from uaclient.entitlements.landscape import LandscapeEntitlement from uaclient.entitlements.livepatch import LivepatchEntitlement from uaclient.entitlements.realtime import RealtimeKernelEntitlement from uaclient.entitlements.ros import ROSEntitlement, ROSUpdatesEntitlement @@ -24,6 +25,7 @@ ESMInfraEntitlement, fips.FIPSEntitlement, fips.FIPSUpdatesEntitlement, + LandscapeEntitlement, LivepatchEntitlement, RealtimeKernelEntitlement, ROSEntitlement, diff --git a/uaclient/entitlements/base.py b/uaclient/entitlements/base.py index 2d2f9a60f3..4f103bb7b5 100644 --- a/uaclient/entitlements/base.py +++ b/uaclient/entitlements/base.py @@ -287,6 +287,7 @@ def __init__( allow_beta: bool = False, called_name: str = "", access_only: bool = False, + extra_args: Optional[List[str]] = None, ) -> None: """Setup UAEntitlement instance @@ -298,6 +299,10 @@ def __init__( self.assume_yes = assume_yes self.allow_beta = allow_beta self.access_only = access_only + if extra_args is not None: + self.extra_args = extra_args + else: + self.extra_args = [] self._called_name = called_name self._valid_service = None # type: Optional[bool] diff --git a/uaclient/entitlements/landscape.py b/uaclient/entitlements/landscape.py new file mode 100644 index 0000000000..13ce9d144c --- /dev/null +++ b/uaclient/entitlements/landscape.py @@ -0,0 +1,166 @@ +import logging +import os +import subprocess +from typing import Any, Dict, Optional, Tuple + +from uaclient import apt, event_logger, exceptions, messages, system, util +from uaclient.entitlements.base import UAEntitlement +from uaclient.entitlements.entitlement_status import ApplicationStatus + +event = event_logger.get_event_logger() + +LANDSCAPE_CLIENT_PACKAGE_NAME = "landscape-client" +LANDSCAPE_CLIENT_CONFIG_PATH = "/etc/landscape/client.conf" +LANDSCAPE_CLIENT_CONFIG_PATH_DISABLE_BACKUP = ( + "/etc/landscape/client.conf.pro-disable-backup" +) + + +class LandscapeEntitlement(UAEntitlement): + name = "landscape" + title = "Landscape" + description = "Management and administration tool for Ubuntu" + help_doc_url = "https://ubuntu.com/landscape" + + def _perform_enable(self, silent: bool = False) -> bool: + event.info(messages.APT_UPDATING_LISTS) + apt.run_apt_update_command() + + event.info( + messages.INSTALLING_PACKAGE.format(LANDSCAPE_CLIENT_PACKAGE_NAME) + ) + apt.run_apt_install_command([LANDSCAPE_CLIENT_PACKAGE_NAME]) + + cmd = ["landscape-config"] + self.extra_args + stdout = None + stderr = None + if self.assume_yes: + if "--silent" not in cmd: + cmd += ["--silent"] + stdout = subprocess.PIPE + stderr = subprocess.PIPE + + logging.debug(messages.EXECUTING_COMMAND.format(" ".join(cmd))) + event.info( + util.redact_sensitive_logs( + messages.EXECUTING_COMMAND.format(" ".join(cmd)) + ) + ) + proc = subprocess.run(cmd, stdout=stdout, stderr=stderr) + if proc.returncode != 0: + if self.assume_yes: + stdout_out = proc.stdout.decode("utf-8").strip() + stderr_out = proc.stderr.decode("utf-8").strip() + err_msg = messages.LANDSCAPE_CONFIG_FAILED + event.error( + err_msg.msg, + err_msg.name, + service=self.name, + additional_info={ + "stdout": stdout_out, + "stderr": stderr_out, + }, + ) + event.info(stderr_out) + event.info( + messages.ENABLED_FAILED.format(title=self.title).msg + ) + return False + + event.info(messages.ENABLED_TMPL.format(title=self.title)) + return True + + def _perform_disable(self, silent: bool = False) -> bool: + cmd = ["landscape-config", "--disable"] + event.info(messages.EXECUTING_COMMAND.format(" ".join(cmd))) + try: + system.subp(cmd) + except exceptions.ProcessExecutionError as e: + with util.disable_log_to_console(): + logging.error(e) + event.info(str(e).strip()) + event.warning(str(e), self.name) + + msg = messages.BACKING_UP_FILE.format( + original=LANDSCAPE_CLIENT_CONFIG_PATH, + backup=LANDSCAPE_CLIENT_CONFIG_PATH_DISABLE_BACKUP, + ) + logging.info(msg) + event.info(msg) + try: + os.rename( + LANDSCAPE_CLIENT_CONFIG_PATH, + LANDSCAPE_CLIENT_CONFIG_PATH_DISABLE_BACKUP, + ) + except FileNotFoundError as e: + with util.disable_log_to_console(): + logging.error(e) + event.info(str(e)) + event.warning(str(e), self.name) + + msg = messages.UNINSTALLING_PACKAGE.format( + LANDSCAPE_CLIENT_PACKAGE_NAME + ) + logging.debug(msg) + event.info(msg) + apt.remove_packages( + [LANDSCAPE_CLIENT_PACKAGE_NAME], + messages.UNINSTALLING_PACKAGE_FAILED.format( + LANDSCAPE_CLIENT_PACKAGE_NAME + ), + ) + + event.info(messages.DISABLED_TMPL.format(title=self.title)) + return True + + def application_status( + self, + ) -> Tuple[ApplicationStatus, Optional[messages.NamedMessage]]: + if apt.is_installed(LANDSCAPE_CLIENT_PACKAGE_NAME): + return (ApplicationStatus.ENABLED, None) + else: + return ( + ApplicationStatus.DISABLED, + messages.LANDSCAPE_CLIENT_NOT_INSTALLED, + ) + + def enabled_warning_status( + self, + ) -> Tuple[bool, Optional[messages.NamedMessage]]: + if not os.path.exists(LANDSCAPE_CLIENT_CONFIG_PATH): + return ( + True, + messages.LANDSCAPE_NOT_CONFIGURED, + ) + + # This check wrongly gives warning when non-root + if util.we_are_currently_root(): + try: + system.subp( + ["landscape-config", "--is-registered", "--silent"] + ) + except exceptions.ProcessExecutionError: + return ( + True, + messages.LANDSCAPE_NOT_REGISTERED, + ) + + if not system.is_service_active("landscape-client"): + return ( + True, + messages.LANDSCAPE_SERVICE_NOT_ACTIVE, + ) + + return False, None + + def process_contract_deltas( + self, + orig_access: Dict[str, Any], + deltas: Dict[str, Any], + allow_enable: bool = False, + ) -> bool: + # overriding allow_enable to always be False for this entitlement + # effectively prevents enableByDefault from ever happening + return super().process_contract_deltas( + orig_access, deltas, allow_enable=False + ) diff --git a/uaclient/messages.py b/uaclient/messages.py index 09ad80eb4c..7556778ef6 100644 --- a/uaclient/messages.py +++ b/uaclient/messages.py @@ -184,6 +184,7 @@ class TxtColor: DISABLE_FAILED_TMPL = "Could not disable {title}." ACCESS_ENABLED_TMPL = "{title} access enabled" ENABLED_TMPL = "{title} enabled" +DISABLED_TMPL = "{title} disabled" UNABLE_TO_DETERMINE_CLOUD_TYPE = ( """\ Unable to determine auto-attach platform support @@ -1395,3 +1396,38 @@ class TxtColor: INSTALLING_REQUIRED_SNAP_PACKAGE = FormattedNamedMessage( "installing-required-snap-package", "Installing required snap: {snap}" ) + +EXECUTING_COMMAND = "Executing `{}`" +BACKING_UP_FILE = "Backing up {original} as {backup}" +INSTALLING_PACKAGE = "Installing {}" +UNINSTALLING_PACKAGE = "Uninstalling {}" +UNINSTALLING_PACKAGE_FAILED = "Failure when uninstalling {}" + +LANDSCAPE_CLIENT_NOT_INSTALLED = NamedMessage( + "landscape-client-not-installed", "lanscape-client is not installed" +) +LANDSCAPE_NOT_CONFIGURED = NamedMessage( + "landscape-not-configured", + """\ +Landscape is installed but not configured. +Run `sudo landscape-config` to set it up, or run `sudo pro disable landscape`\ +""", +) +LANDSCAPE_NOT_REGISTERED = NamedMessage( + "landscape-not-registered", + """\ +Landscape is installed and configured but not registered. +Run `sudo landscape-config` to register, or run `sudo pro disable landscape`\ +""", +) +LANDSCAPE_SERVICE_NOT_ACTIVE = NamedMessage( + "landscape-service-not-active", + """\ +Landscape is installed and configured and registered but not running. +Run `sudo landscape-config` to start it, or run `sudo pro disable landscape`\ +""", +) +LANDSCAPE_CONFIG_FAILED = NamedMessage( + "landscape-config-failed", + """landscape-config command failed""", +) diff --git a/uaclient/system.py b/uaclient/system.py index 6b9fe0bd71..b0dda654b3 100644 --- a/uaclient/system.py +++ b/uaclient/system.py @@ -713,3 +713,11 @@ def get_reboot_required_pkgs() -> Optional[RebootRequiredPkgs]: standard_packages=sorted(standard_packages), kernel_packages=sorted(kernel_packages), ) + + +def is_service_active(service_name: str) -> bool: + try: + subp(["systemctl", "is-active", "--quiet", service_name]) + except exceptions.ProcessExecutionError: + return False + return True diff --git a/uaclient/tests/test_util.py b/uaclient/tests/test_util.py index a032c51c48..cc3a8daa16 100644 --- a/uaclient/tests/test_util.py +++ b/uaclient/tests/test_util.py @@ -328,6 +328,42 @@ class TestRedactSensitiveLogs: "'magic_token=SEKRET'", "'magic_token='", ), + ( + "--account-name name --registration-key=reg-key --silent", + "--account-name name --registration-key= --silent", + ), + ( + '--account-name name --registration-key="reg key" --silent', + "--account-name name --registration-key= --silent", + ), + ( + "--account-name name --registration-key='reg key' --silent", + "--account-name name --registration-key= --silent", + ), + ( + "--account-name name --registration-key reg-key --silent", + "--account-name name --registration-key --silent", + ), + ( + '--account-name name --registration-key "reg key" --silent', + "--account-name name --registration-key --silent", + ), + ( + "--account-name name --registration-key 'reg key' --silent", + "--account-name name --registration-key --silent", + ), + ( + "--account-name name -P reg-key --silent", + "--account-name name -P --silent", + ), + ( + '--account-name name -P "reg key" --silent', + "--account-name name -P --silent", + ), + ( + "--account-name name -P 'reg key' --silent", + "--account-name name -P --silent", + ), ), ) def test_redact_all_matching_regexs(self, raw_log, expected): diff --git a/uaclient/util.py b/uaclient/util.py index 1fea10a25d..2b79b6c0d2 100644 --- a/uaclient/util.py +++ b/uaclient/util.py @@ -288,6 +288,15 @@ def is_config_value_true(config: Dict[str, Any], path_to_value: str) -> bool: r"(\'token\': \')[^\']+", r"(\'userCode\': \')[^\']+", r"(\'magic_token=)[^\']+", + r"(--registration-key=\")[^\"]+", + r"(--registration-key=\')[^\']+", + r"(--registration-key=)[^ ]+", + r"(--registration-key \")[^\"]+", + r"(--registration-key \')[^\']+", + r"(--registration-key )[^\s]+", + r"(-P \")[^\"]+", + r"(-P \')[^\']+", + r"(-P )[^\s]+", ]