diff -Nru ubuntu-advantage-tools-27.6~16.04.1/apt-hook/json-hook-src/json-hook_test.go ubuntu-advantage-tools-27.7~16.04.1/apt-hook/json-hook-src/json-hook_test.go --- ubuntu-advantage-tools-27.6~16.04.1/apt-hook/json-hook-src/json-hook_test.go 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/apt-hook/json-hook-src/json-hook_test.go 2022-03-10 17:17:29.000000000 +0000 @@ -114,9 +114,9 @@ "pin": 500, "origins": [ { - "archive": "hirsute-apps-security", - "codename": "hirsute", - "version": "21.04", + "archive": "focal-apps-security", + "codename": "focal", + "version": "20.04", "origin": "UbuntuESMApps", "label": "Ubuntu", "site": "" @@ -130,9 +130,9 @@ "pin": 500, "origins": [ { - "archive": "hirsute-apps-security", - "codename": "hirsute", - "version": "21.04", + "archive": "focal-apps-security", + "codename": "focal", + "version": "20.04", "origin": "UbuntuESMApps", "label": "Ubuntu", "site": "" @@ -162,9 +162,9 @@ "pin": 500, "origins": [ { - "archive": "hirsute-apps-security", - "codename": "hirsute", - "version": "21.04", + "archive": "focal-apps-security", + "codename": "focal", + "version": "20.04", "origin": "UbuntuESMApps", "label": "Ubuntu", "site": "" @@ -178,9 +178,9 @@ "pin": 500, "origins": [ { - "archive": "hirsute-apps-security", - "codename": "hirsute", - "version": "21.04", + "archive": "focal-apps-security", + "codename": "focal", + "version": "20.04", "origin": "UbuntuESMApps", "label": "Ubuntu", "site": "" @@ -210,17 +210,17 @@ "pin": 500, "origins": [ { - "archive": "hirsute-infra-security", - "codename": "hirsute", - "version": "21.04", + "archive": "focal-infra-security", + "codename": "focal", + "version": "20.04", "origin": "UbuntuESM", "label": "Ubuntu", "site": "" }, { - "archive": "hirsute", - "codename": "hirsute", - "version": "21.04", + "archive": "focal", + "codename": "focal", + "version": "20.04", "origin": "Ubuntu", "label": "Ubuntu", "site": "" @@ -234,17 +234,17 @@ "pin": 500, "origins": [ { - "archive": "hirsute-infra-security", - "codename": "hirsute", - "version": "21.04", + "archive": "focal-infra-security", + "codename": "focal", + "version": "20.04", "origin": "UbuntuESM", "label": "Ubuntu", "site": "" }, { - "archive": "hirsute", - "codename": "hirsute", - "version": "21.04", + "archive": "focal", + "codename": "focal", + "version": "20.04", "origin": "Ubuntu", "label": "Ubuntu", "site": "" @@ -274,9 +274,9 @@ "pin": 500, "origins": [ { - "archive": "hirsute-infra-security", - "codename": "hirsute", - "version": "21.04", + "archive": "focal-infra-security", + "codename": "focal", + "version": "20.04", "origin": "UbuntuESM", "label": "Ubuntu", "site": "" @@ -290,9 +290,9 @@ "pin": 500, "origins": [ { - "archive": "hirsute-infra-security", - "codename": "hirsute", - "version": "21.04", + "archive": "focal-infra-security", + "codename": "focal", + "version": "20.04", "origin": "UbuntuESM", "label": "Ubuntu", "site": "" @@ -322,8 +322,8 @@ "pin": 500, "origins": [ { - "archive": "hirsute-apps-security", - "codename": "hirsute", + "archive": "focal-apps-security", + "codename": "focal", "version": "1.0", "origin": "UbuntuESMApps", "label": "Google", @@ -338,8 +338,8 @@ "pin": 500, "origins": [ { - "archive": "hirsute-apps-security", - "codename": "hirsute", + "archive": "focal-apps-security", + "codename": "focal", "version": "1.0", "origin": "UbuntuESMApps", "label": "Google", @@ -370,9 +370,9 @@ "pin": 500, "origins": [ { - "archive": "hirsute-security", - "codename": "hirsute", - "version": "21.04", + "archive": "focal-security", + "codename": "focal", + "version": "20.04", "origin": "Ubuntu", "label": "Ubuntu", "site": "" @@ -386,9 +386,9 @@ "pin": 500, "origins": [ { - "archive": "hirsute-security", - "codename": "hirsute", - "version": "21.04", + "archive": "focal-security", + "codename": "focal", + "version": "20.04", "origin": "Ubuntu", "label": "Ubuntu", "site": "" diff -Nru ubuntu-advantage-tools-27.6~16.04.1/debian/changelog ubuntu-advantage-tools-27.7~16.04.1/debian/changelog --- ubuntu-advantage-tools-27.6~16.04.1/debian/changelog 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/debian/changelog 2022-03-10 17:17:29.000000000 +0000 @@ -1,8 +1,46 @@ -ubuntu-advantage-tools (27.6~16.04.1) xenial; urgency=medium +ubuntu-advantage-tools (27.7~16.04.1) xenial; urgency=medium - * Backport new upstream release: (LP: #1958556) to xenial + * Backport new upstream release: (LP: #1964028) to xenial - -- Lucas Moura Thu, 20 Jan 2022 18:02:17 -0300 + -- Grant Orndorff Thu, 10 Mar 2022 12:17:29 -0500 + +ubuntu-advantage-tools (27.7~22.04.1) jammy; urgency=medium + + * d/changelog: + - fix changelog trailer line for 27.4.1 + * d/logrotate: + - make new logs world readable + * d/tools.postinst: + - refactor to catch exception from entitlement_factory + - no longer always set log file to only root readable + - when creating log file for the first time, make world readable + - adapt postinst for new messages module + * New upstream release 27.7 (LP: #1964028) + - attach: --attach-config option for customizing auto-enabled services + and supplying token via a file + - auto-attach: fix bug where auto-attach caused a manually attached + machine to detach + - cli: + + support --format=json for attach + + support --format=json for detach + + support --format=json for enable + + support --format=json for disable + - contract: include activity info when updating contract + - detach: no longer contacts contract server on detach + - fips: allow fips on containers + - fix: support USNs that don't have related CVEs + - logs: make all newly created logs world-readable + - security-status: + + show already installed esm package counts + + include APT origin for each potential update + + bump schema version to "0.1" + + remove previously required --beta flag + - status: + + include blocked_by information in service status when format=json + + --simulate-with-token now reports expired tokens as errors + + --simulate-with-token now returns errors in the specified format + + -- Grant Orndorff Mon, 07 Mar 2022 13:14:57 -0500 ubuntu-advantage-tools (27.6~22.04.1) jammy; urgency=medium diff -Nru ubuntu-advantage-tools-27.6~16.04.1/debian/ubuntu-advantage-tools.logrotate ubuntu-advantage-tools-27.7~16.04.1/debian/ubuntu-advantage-tools.logrotate --- ubuntu-advantage-tools-27.6~16.04.1/debian/ubuntu-advantage-tools.logrotate 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/debian/ubuntu-advantage-tools.logrotate 2022-03-10 17:17:29.000000000 +0000 @@ -2,6 +2,7 @@ # of /var/log/ubuntu-advantage*.log files. /var/log/ubuntu-advantage*.log { su root root + create 0644 root root rotate 6 monthly compress diff -Nru ubuntu-advantage-tools-27.6~16.04.1/debian/ubuntu-advantage-tools.postinst ubuntu-advantage-tools-27.7~16.04.1/debian/ubuntu-advantage-tools.postinst --- ubuntu-advantage-tools-27.6~16.04.1/debian/ubuntu-advantage-tools.postinst 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/debian/ubuntu-advantage-tools.postinst 2022-03-10 17:17:29.000000000 +0000 @@ -119,12 +119,12 @@ _IS_BETA_SVC=$(/usr/bin/python3 -c " from uaclient.config import UAConfig from uaclient.entitlements import entitlement_factory -ent_cls = entitlement_factory('${service_name}') -if ent_cls: +try: + ent_cls = entitlement_factory('${service_name}') cfg = UAConfig() allow_beta = cfg.features.get('allow_beta', False) print(all([ent_cls.is_beta, not allow_beta])) -else: +except Exception: print(True) ") if [ "${_IS_BETA_SVC}" = "True" ]; then @@ -248,16 +248,17 @@ mark_reboot_for_fips_pro() { FIPS_HOLDS=$(apt-mark showholds | grep -E 'fips|libssl1|openssh-client|openssh-server|linux-fips|openssl|strongswan' || exit 0) if [ "$FIPS_HOLDS" ]; then - mark_reboot_cmds_as_needed MESSAGE_FIPS_REBOOT_REQUIRED + mark_reboot_cmds_as_needed FIPS_REBOOT_REQUIRED_MSG fi } add_notice() { - msg_name=$1 + module=$1 + msg_name=$2 /usr/bin/python3 -c " from uaclient.config import UAConfig -from uaclient.status import ${msg_name} +from uaclient.${module} import ${msg_name} cfg = UAConfig() cfg.add_notice(label='', description=${msg_name}) " @@ -268,7 +269,7 @@ if [ ! -f "$REBOOT_CMD_MARKER_FILE" ]; then touch $REBOOT_CMD_MARKER_FILE fi - add_notice "$msg_name" + add_notice messages "$msg_name" } patch_status_json_0_1_for_non_root() { @@ -303,7 +304,7 @@ if echo "$cloud_id" | grep -E -q "^(azure|aws)"; then if echo "$fips_installed" | grep -E -q "installed"; then - add_notice NOTICE_WRONG_FIPS_METAPACKAGE_ON_CLOUD + add_notice status NOTICE_WRONG_FIPS_METAPACKAGE_ON_CLOUD fi fi } @@ -399,7 +400,7 @@ # Repo for FIPS packages changed from old client if [ -f $FIPS_APT_SOURCE_FILE ]; then if grep -q $OLD_CLIENT_FIPS_PPA $FIPS_APT_SOURCE_FILE; then - add_notice MESSAGE_FIPS_INSTALL_OUT_OF_DATE + add_notice messages FIPS_INSTALL_OUT_OF_DATE fi fi @@ -419,15 +420,14 @@ fi fi - # We modify permissions for ubuntu-advantage.log because - # in a past version of UA, this log file was world readable. - # This isn't necessary for the timer/license-check log files, - # because they have always been only root-readable. + # log files need to be world-readable if [ ! -f /var/log/ubuntu-advantage.log ]; then touch /var/log/ubuntu-advantage.log + # We are only making new log files world readable + chmod 0644 /var/log/ubuntu-advantage.log fi - chmod 0600 /var/log/ubuntu-advantage.log chown root:root /var/log/ubuntu-advantage.log + private_dir="/var/lib/ubuntu-advantage/private" if [ -d "$private_dir" ]; then chmod 0700 "$private_dir" @@ -435,7 +435,7 @@ if [ "$VERSION_ID" = "16.04" ]; then if echo "$PREVIOUS_PKG_VER" | grep -q "14.04"; then - mark_reboot_cmds_as_needed MESSAGE_LIVEPATCH_LTS_REBOOT_REQUIRED + mark_reboot_cmds_as_needed LIVEPATCH_LTS_REBOOT_REQUIRED fi fi mark_reboot_for_fips_pro diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/attached_commands.feature ubuntu-advantage-tools-27.7~16.04.1/features/attached_commands.feature --- ubuntu-advantage-tools-27.6~16.04.1/features/attached_commands.feature 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/attached_commands.feature 2022-03-10 17:17:29.000000000 +0000 @@ -38,7 +38,9 @@ And I run `sh -c "ls /var/log/ubuntu-advantage* | sort -d"` as non-root Then stdout matches regexp: """ + /var/log/ubuntu-advantage.log /var/log/ubuntu-advantage.log.1 + /var/log/ubuntu-advantage-timer.log /var/log/ubuntu-advantage-timer.log.1 """ @@ -47,79 +49,89 @@ | bionic | | focal | | xenial | - | hirsute | | impish | | jammy | - @series.all - @uses.config.machine_type.lxd.container - Scenario Outline: Attached and detach correctly reach contract endpoint - Given a `` machine with ubuntu-advantage-tools installed - When I attach `contract_token` with sudo - And I run `ua detach --assume-yes` with sudo - Then I verify that running `grep "Found new machine-id. Do not call detach on contract backend" /var/log/ubuntu-advantage.log` `with sudo` exits `1` - - Examples: ubuntu release - | release | - | bionic | - | focal | - | xenial | - | hirsute | - | impish | - | jammy | @series.all @uses.config.machine_type.lxd.container - Scenario Outline: Attached and detach don't reach contract endpoint if machine-id changes + Scenario Outline: Attached disable of an already disabled service in a ubuntu machine Given a `` machine with ubuntu-advantage-tools installed When I attach `contract_token` with sudo - And I update contract to use `machineId` as `new-machine-id` - And I run `ua detach --assume-yes` with sudo - Then stdout matches regexp: + Then I verify that running `ua disable livepatch` `as non-root` exits `1` + And stderr matches regexp: """ - This machine is now detached. + This command must be run as root \(try using sudo\). + """ + And I verify that running `ua disable livepatch` `with sudo` exits `1` + And I will see the following on stdout: + """ + Livepatch is not currently enabled + See: sudo ua status """ - And I verify that running `grep "Found new machine-id. Do not call detach on contract backend" /var/log/ubuntu-advantage.log` `with sudo` exits `0` - When I run `ua status` with sudo - Then stdout matches regexp: - """ - This machine is not attached to a UA subscription. - """ Examples: ubuntu release | release | | bionic | | focal | | xenial | - | hirsute | | impish | | jammy | - @series.all + @series.lts @uses.config.machine_type.lxd.container - Scenario Outline: Attached disable of an already disabled service in a ubuntu machine + Scenario Outline: Attached disable with json format Given a `` machine with ubuntu-advantage-tools installed When I attach `contract_token` with sudo - Then I verify that running `ua disable livepatch` `as non-root` exits `1` - And stderr matches regexp: + Then I verify that running `ua disable foobar --format json` `as non-root` exits `1` + And stdout is a json matching the `ua_operation` schema + And I will see the following on stdout: """ - This command must be run as root \(try using sudo\). + {"_schema_version": "0.1", "errors": [{"message": "json formatted response requires --assume-yes flag.", "message_code": "json-format-require-assume-yes", "service": null, "type": "system"}], "failed_services": [], "needs_reboot": false, "processed_services": [], "result": "failure", "warnings": []} """ - And I verify that running `ua disable livepatch` `with sudo` exits `1` + Then I verify that running `ua disable foobar --format json` `with sudo` exits `1` + And stdout is a json matching the `ua_operation` schema And I will see the following on stdout: """ - Livepatch is not currently enabled - See: sudo ua status + {"_schema_version": "0.1", "errors": [{"message": "json formatted response requires --assume-yes flag.", "message_code": "json-format-require-assume-yes", "service": null, "type": "system"}], "failed_services": [], "needs_reboot": false, "processed_services": [], "result": "failure", "warnings": []} + """ + Then I verify that running `ua disable foobar --format json --assume-yes` `as non-root` exits `1` + And stdout is a json matching the `ua_operation` schema + And I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [{"message": "This command must be run as root (try using sudo).", "message_code": "nonroot-user", "service": null, "type": "system"}], "failed_services": [], "needs_reboot": false, "processed_services": [], "result": "failure", "warnings": []} + """ + And I verify that running `ua disable foobar --format json --assume-yes` `with sudo` exits `1` + And stdout is a json matching the `ua_operation` schema + And I will see the following on stdout: """ + {"_schema_version": "0.1", "errors": [{"message": "Cannot disable unknown service 'foobar'.\nTry ", "message_code": "invalid-service-or-failure", "service": null, "type": "system"}], "failed_services": [], "needs_reboot": false, "processed_services": [], "result": "failure", "warnings": []} + """ + And I verify that running `ua disable livepatch --format json --assume-yes` `with sudo` exits `1` + And stdout is a json matching the `ua_operation` schema + And I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [{"message": "Livepatch is not currently enabled\nSee: sudo ua status", "message_code": "service-already-disabled", "service": "livepatch", "type": "service"}], "failed_services": ["livepatch"], "needs_reboot": false, "processed_services": [], "result": "failure", "warnings": []} + """ + And I verify that running `ua disable esm-infra esm-apps --format json --assume-yes` `with sudo` exits `0` + And stdout is a json matching the `ua_operation` schema + And I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [], "failed_services": [], "needs_reboot": false, "processed_services": ["esm-apps", "esm-infra"], "result": "success", "warnings": []} + """ + When I run `ua enable esm-infra` with sudo + Then I verify that running `ua disable esm-infra foobar --format json --assume-yes` `with sudo` exits `1` + And stdout is a json matching the `ua_operation` schema + And I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [{"message": "Cannot disable unknown service 'foobar'.\nTry ", "message_code": "invalid-service-or-failure", "service": null, "type": "system"}], "failed_services": [], "needs_reboot": false, "processed_services": ["esm-infra"], "result": "failure", "warnings": []} + """ Examples: ubuntu release - | release | - | bionic | - | focal | - | xenial | - | hirsute | - | impish | - | jammy | + | release | valid_services | + | xenial | cc-eal, cis, esm-apps, esm-infra, fips, fips-updates, livepatch, ros,\nros-updates. | + | bionic | cc-eal, cis, esm-apps, esm-infra, fips, fips-updates, livepatch, ros,\nros-updates. | + | focal | cc-eal, esm-apps, esm-infra, fips, fips-updates, livepatch, ros,\nros-updates, usg. | @series.xenial @series.bionic @@ -241,6 +253,31 @@ This machine is not attached to a UA subscription. """ And I verify that running `apt update` `with sudo` exits `0` + When I attach `contract_token` with sudo + Then I verify that running `ua enable foobar --format json` `as non-root` exits `1` + And stdout is a json matching the `ua_operation` schema + And I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [{"message": "json formatted response requires --assume-yes flag.", "message_code": "json-format-require-assume-yes", "service": null, "type": "system"}], "failed_services": [], "needs_reboot": false, "processed_services": [], "result": "failure", "warnings": []} + """ + Then I verify that running `ua enable foobar --format json` `with sudo` exits `1` + And stdout is a json matching the `ua_operation` schema + And I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [{"message": "json formatted response requires --assume-yes flag.", "message_code": "json-format-require-assume-yes", "service": null, "type": "system"}], "failed_services": [], "needs_reboot": false, "processed_services": [], "result": "failure", "warnings": []} + """ + Then I verify that running `ua detach --format json --assume-yes` `as non-root` exits `1` + And stdout is a json matching the `ua_operation` schema + And I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [{"message": "This command must be run as root (try using sudo).", "message_code": "nonroot-user", "service": null, "type": "system"}], "failed_services": [], "needs_reboot": false, "processed_services": [], "result": "failure", "warnings": []} + """ + When I run `ua detach --format json --assume-yes` with sudo + Then stdout is a json matching the `ua_operation` schema + And I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [], "failed_services": [], "needs_reboot": false, "processed_services": ["esm-apps", "esm-infra"], "result": "success", "warnings": []} + """ Examples: ubuntu release | release | esm-apps | cc-eal | cis | fips | fips-update | ros | cis_or_usg | @@ -269,7 +306,6 @@ | bionic | | focal | | xenial | - | hirsute | | impish | | jammy | @@ -292,7 +328,6 @@ | bionic | | focal | | xenial | - | hirsute | | impish | | jammy | @@ -344,7 +379,6 @@ | bionic | | focal | | xenial | - | hirsute | | impish | | jammy | @@ -534,7 +568,6 @@ | release | infra-status | | bionic | enabled | | xenial | enabled | - | hirsute | n/a | | impish | n/a | | jammy | n/a | @@ -751,7 +784,6 @@ | xenial | | bionic | | focal | - | hirsute | | impish | | jammy | @@ -884,14 +916,11 @@ """ And I run `dpkg-reconfigure ubuntu-advantage-tools` with sudo And I run `apt-get update` with sudo - When I run `ua security-status --format json --beta` as non-root - Then stdout is formatted as `json` and has keys: - """ - _schema_version summary packages - """ + When I run `ua security-status --format json` as non-root + Then stdout is a json matching the `ua_security_status` schema And stdout matches regexp: """ - "_schema_version": "0" + "_schema_version": "0.1" """ And stdout matches regexp: """ @@ -915,13 +944,17 @@ """ And stdout matches regexp: """ + "origin": "esm.ubuntu.com" + """ + And stdout matches regexp: + """ "status": "pending_attach" """ When I attach `contract_token` with sudo - And I run `ua security-status --format json --beta` as non-root + And I run `ua security-status --format json` as non-root Then stdout matches regexp: """ - "_schema_version": "0" + "_schema_version": "0.1" """ And stdout matches regexp: """ @@ -939,32 +972,23 @@ """ "status": "upgrade_available" """ - When I run `ua security-status --format yaml --beta` as non-root - Then stdout is formatted as `yaml` and has keys: - """ - _schema_version summary packages - """ + When I run `ua security-status --format yaml` as non-root + Then stdout is a yaml matching the `ua_security_status` schema And stdout matches regexp: """ - _schema_version: '0' - """ - When I verify that running `ua security-status --format json` `as non-root` exits `2` - Then I will see the following on stderr: - """ - usage: security-status [-h] --format {json,yaml} --beta - the following arguments are required: --beta + _schema_version: '0.1' """ - When I verify that running `ua security-status --format unsupported --beta` `as non-root` exits `2` + When I verify that running `ua security-status --format unsupported` `as non-root` exits `2` Then I will see the following on stderr: """ - usage: security-status [-h] --format {json,yaml} --beta + usage: security-status [-h] --format {json,yaml} argument --format: invalid choice: 'unsupported' (choose from 'json', 'yaml') """ When I verify that running `ua security-status` `as non-root` exits `2` Then I will see the following on stderr: """ - usage: security-status [-h] --format {json,yaml} --beta - the following arguments are required: --format, --beta + usage: security-status [-h] --format {json,yaml} + the following arguments are required: --format """ Examples: ubuntu release | release | package | service | diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/attached_enable.feature ubuntu-advantage-tools-27.7~16.04.1/features/attached_enable.feature --- ubuntu-advantage-tools-27.6~16.04.1/features/attached_enable.feature 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/attached_enable.feature 2022-03-10 17:17:29.000000000 +0000 @@ -29,7 +29,6 @@ | bionic | @series.focal - @series.hirsute @series.impish @uses.config.machine_type.lxd.container Scenario Outline: Attached enable Common Criteria service in an ubuntu lxd container @@ -49,11 +48,80 @@ Examples: ubuntu release | release | version | full_name | | focal | 20.04 LTS | Focal Fossa | - | hirsute | 21.04 | Hirsute Hippo | | impish | 21.10 | Impish Indri | @series.xenial @series.bionic + @series.focal + @uses.config.machine_type.lxd.container + Scenario Outline: Attached enable of different services using json format + Given a `` machine with ubuntu-advantage-tools installed + When I attach `contract_token` with sudo + Then I verify that running `ua enable foobar --format json` `as non-root` exits `1` + And stdout is a json matching the `ua_operation` schema + And I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [{"message": "json formatted response requires --assume-yes flag.", "message_code": "json-format-require-assume-yes", "service": null, "type": "system"}], "failed_services": [], "needs_reboot": false, "processed_services": [], "result": "failure", "warnings": []} + """ + Then I verify that running `ua enable foobar --format json` `with sudo` exits `1` + And stdout is a json matching the `ua_operation` schema + And I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [{"message": "json formatted response requires --assume-yes flag.", "message_code": "json-format-require-assume-yes", "service": null, "type": "system"}], "failed_services": [], "needs_reboot": false, "processed_services": [], "result": "failure", "warnings": []} + """ + Then I verify that running `ua enable foobar --format json --assume-yes` `as non-root` exits `1` + And stdout is a json matching the `ua_operation` schema + And I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [{"message": "This command must be run as root (try using sudo).", "message_code": "nonroot-user", "service": null, "type": "system"}], "failed_services": [], "needs_reboot": false, "processed_services": [], "result": "failure", "warnings": []} + """ + And I verify that running `ua enable foobar --format json --assume-yes` `with sudo` exits `1` + And stdout is a json matching the `ua_operation` schema + And I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [{"message": "Cannot enable unknown service 'foobar'.\nTry ", "message_code": "invalid-service-or-failure", "service": null, "type": "system"}], "failed_services": ["foobar"], "needs_reboot": false, "processed_services": [], "result": "failure", "warnings": []} + """ + And I verify that running `ua enable ros foobar --format json --assume-yes` `with sudo` exits `1` + And stdout is a json matching the `ua_operation` schema + And I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [{"message": "Cannot enable unknown service 'foobar, ros'.\nTry ", "message_code": "invalid-service-or-failure", "service": null, "type": "system"}], "failed_services": ["foobar", "ros"], "needs_reboot": false, "processed_services": [], "result": "failure", "warnings": []} + """ + And I verify that running `ua enable esm-infra --format json --assume-yes` `with sudo` exits `1` + And stdout is a json matching the `ua_operation` schema + Then I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [{"message": "UA Infra: ESM is already enabled.\nSee: sudo ua status", "message_code": "service-already-enabled", "service": "esm-infra", "type": "service"}], "failed_services": ["esm-infra"], "needs_reboot": false, "processed_services": [], "result": "failure", "warnings": []} + """ + When I run `ua disable esm-infra` with sudo + And I run `ua enable esm-infra --format json --assume-yes` with sudo + Then stdout is a json matching the `ua_operation` schema + And I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [], "failed_services": [], "needs_reboot": false, "processed_services": ["esm-infra"], "result": "success", "warnings": []} + """ + When I run `ua disable esm-infra` with sudo + And I verify that running `ua enable esm-infra foobar --format json --assume-yes` `with sudo` exits `1` + Then stdout is a json matching the `ua_operation` schema + And I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [{"message": "Cannot enable unknown service 'foobar'.\nTry ", "message_code": "invalid-service-or-failure", "service": null, "type": "system"}], "failed_services": ["foobar"], "needs_reboot": false, "processed_services": ["esm-infra"], "result": "failure", "warnings": []} + """ + When I run `ua disable esm-infra esm-apps` with sudo + And I run `ua enable esm-infra esm-apps --beta --format json --assume-yes` with sudo + Then stdout is a json matching the `ua_operation` schema + And I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [], "failed_services": [], "needs_reboot": false, "processed_services": ["esm-apps", "esm-infra"], "result": "success", "warnings": []} + """ + + Examples: ubuntu release + | release | valid_services | + | xenial | cc-eal, cis, esm-infra, fips, fips-updates, livepatch. | + | bionic | cc-eal, cis, esm-infra, fips, fips-updates, livepatch. | + | focal | cc-eal, esm-infra, fips, fips-updates, livepatch, usg. | + + @series.lts @uses.config.machine_type.lxd.container Scenario Outline: Attached enable of a service in a ubuntu machine Given a `` machine with ubuntu-advantage-tools installed @@ -84,7 +152,7 @@ Try cc-eal, cis, esm-infra, fips, fips-updates, livepatch. """ And I verify that running `ua enable esm-infra` `with sudo` exits `1` - Then I will see the following on stdout: + And I will see the following on stdout: """ One moment, checking your subscription first UA Infra: ESM is already enabled. @@ -176,25 +244,12 @@ One moment, checking your subscription first Cannot install Livepatch on a container. """ - And I verify that running `ua enable fips --assume-yes` `with sudo` exits `1` - And I will see the following on stdout: - """ - One moment, checking your subscription first - Cannot install FIPS on a container. - """ - And I verify that running `ua enable fips-updates --assume-yes` `with sudo` exits `1` - And I will see the following on stdout: - """ - One moment, checking your subscription first - Cannot install FIPS Updates on a container. - """ Examples: Un-supported services in containers | release | | bionic | | focal | | xenial | - | hirsute | | impish | | jammy | @@ -495,7 +550,7 @@ """ When I run `ua disable livepatch` with sudo Then I verify that running `canonical-livepatch status` `with sudo` exits `1` - And stdout matches regexp: + And stderr matches regexp: """ Machine is not enabled. Please run 'sudo canonical-livepatch enable' with the token obtained from https://ubuntu.com/livepatch. @@ -581,6 +636,11 @@ One moment, checking your subscription first Cannot enable Livepatch when FIPS is enabled. """ + Then I verify that running `ua enable livepatch --format json --assume-yes` `with sudo` exits `1` + And I will see the following on stdout + """ + {"_schema_version": "0.1", "errors": [{"message": "Cannot enable Livepatch when FIPS is enabled.", "message_code": "livepatch-error-when-fips-enabled", "service": "livepatch", "type": "service"}], "failed_services": ["livepatch"], "needs_reboot": false, "processed_services": [], "result": "failure", "warnings": []} + """ @series.bionic @uses.config.machine_type.lxd.vm @@ -603,11 +663,14 @@ And I will see the following on stdout """ One moment, checking your subscription first - """ - And I will see the following on stderr - """ Cannot enable FIPS when Livepatch is enabled. """ + Then I verify that running `ua enable fips --assume-yes --format json` `with sudo` exits `1` + And stdout is a json matching the `ua_operation` schema + And I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [{"message": "Cannot enable FIPS when Livepatch is enabled.", "service": "fips", "type": "service"}], "failed_services": ["fips"], "needs_reboot": false, "processed_services": [], "result": "failure", "warnings": []} + """ @slow @series.xenial @@ -688,7 +751,8 @@ | bionic | | xenial | - @series.lts + @series.xenial + @series.bionic @uses.config.contract_token @uses.config.machine_type.lxd.container Scenario Outline: Attached enable ros on a machine @@ -741,7 +805,6 @@ ROS ESM Security Updates cannot be enabled with UA Apps: ESM disabled. Enable UA Apps: ESM and proceed to enable ROS ESM Security Updates\? \(y\/N\) Cannot enable ROS ESM Security Updates when UA Apps: ESM is disabled. """ - When I run `ua enable ros --beta` `with sudo` and stdin `y` Then stdout matches regexp """ diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/attached_status.feature ubuntu-advantage-tools-27.7~16.04.1/features/attached_status.feature --- ubuntu-advantage-tools-27.6~16.04.1/features/attached_status.feature 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/attached_status.feature 2022-03-10 17:17:29.000000000 +0000 @@ -7,25 +7,14 @@ Given a `` machine with ubuntu-advantage-tools installed When I attach `contract_token` with sudo And I run `ua status --format json` as non-root - Then stdout is formatted as `json` and has keys: - """ - _doc _schema_version account attached config config_path contract effective - environment_vars execution_details execution_status expires machine_id notices - services version simulated - """ + Then stdout is a json matching the `ua_status` schema When I run `ua status --format yaml` as non-root - Then stdout is formatted as `yaml` and has keys: - """ - _doc _schema_version account attached config config_path contract effective - environment_vars execution_details execution_status expires machine_id notices - services version simulated - """ + Then stdout is a yaml matching the `ua_status` schema Examples: ubuntu release | release | | bionic | | focal | | xenial | - | hirsute | | impish | | jammy | diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/attach_invalidtoken.feature ubuntu-advantage-tools-27.7~16.04.1/features/attach_invalidtoken.feature --- ubuntu-advantage-tools-27.6~16.04.1/features/attach_invalidtoken.feature 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/attach_invalidtoken.feature 2022-03-10 17:17:29.000000000 +0000 @@ -15,12 +15,17 @@ """ This command must be run as root (try using sudo). """ + When I verify that running `ua attach invalid-token --format json` `with sudo` exits `1` + Then I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [{"message": "Invalid token. See https://ubuntu.com/advantage", "message_code": "attach-invalid-token", "service": null, "type": "system"}], "failed_services": [], "needs_reboot": false, "processed_services": [], "result": "failure", "warnings": []} + """ + Examples: ubuntu release | release | | xenial | | bionic | | focal | - | hirsute | | impish | | jammy | @@ -41,6 +46,5 @@ | xenial | | bionic | | focal | - | hirsute | | impish | | jammy | diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/attach_validtoken.feature ubuntu-advantage-tools-27.7~16.04.1/features/attach_validtoken.feature --- ubuntu-advantage-tools-27.6~16.04.1/features/attach_validtoken.feature 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/attach_validtoken.feature 2022-03-10 17:17:29.000000000 +0000 @@ -3,7 +3,6 @@ subscription using a valid token @series.jammy - @series.hirsute @series.impish @uses.config.machine_type.lxd.container Scenario Outline: Attached command in a non-lts ubuntu machine @@ -24,7 +23,6 @@ Examples: ubuntu release | release | - | hirsute | | impish | | jammy | @@ -67,8 +65,8 @@ """ esm-apps +yes +enabled +UA Apps: Extended Security Maintenance \(ESM\) esm-infra +yes +enabled +UA Infra: Extended Security Maintenance \(ESM\) - fips +yes +n/a +NIST-certified core packages - fips-updates +yes +n/a +NIST-certified core packages with priority security updates + fips +yes +disabled +NIST-certified core packages + fips-updates +yes +disabled +NIST-certified core packages with priority security updates livepatch +yes +n/a +Canonical Livepatch service """ And stdout matches regexp: @@ -173,6 +171,115 @@ | bionic | libkrad0=1.16-2build1 | disabled | cis | | focal | hello=2.10-2ubuntu2 | n/a | usg | + @series.lts + @uses.config.machine_type.lxd.container + Scenario Outline: Attach command with attach config + Given a `` machine with ubuntu-advantage-tools installed + # simplest happy path + When I create the file `/tmp/attach.yaml` with the following + """ + token: + """ + When I replace `` in `/tmp/attach.yaml` with token `contract_token` + When I run `ua attach --attach-config /tmp/attach.yaml` with sudo + Then stdout matches regexp: + """ + esm-apps +yes +enabled + """ + And stdout matches regexp: + """ + esm-infra +yes +enabled + """ + And stdout matches regexp: + """ + +yes +disabled + """ + When I run `ua detach --assume-yes` with sudo + # don't allow both token on cli and config + Then I verify that running `ua attach TOKEN --attach-config /tmp/attach.yaml` `with sudo` exits `1` + Then stderr matches regexp: + """ + Do not pass the TOKEN arg if you are using --attach-config. + Include the token in the attach-config file instead. + """ + # happy path with service overrides + When I create the file `/tmp/attach.yaml` with the following + """ + token: + enable_services: + - esm-apps + - + """ + When I replace `` in `/tmp/attach.yaml` with token `contract_token` + When I run `ua attach --attach-config /tmp/attach.yaml` with sudo + Then stdout matches regexp: + """ + esm-apps +yes +enabled + """ + And stdout matches regexp: + """ + esm-infra +yes +disabled + """ + And stdout matches regexp: + """ + +yes +enabled + """ + When I run `ua detach --assume-yes` with sudo + # missing token + When I create the file `/tmp/attach.yaml` with the following + """ + enable_services: + - esm-apps + - + """ + Then I verify that running `ua attach --attach-config /tmp/attach.yaml` `with sudo` exits `1` + Then stderr matches regexp: + """ + Error while reading /tmp/attach.yaml: Got value with incorrect type for field + "token": Expected value with type StringDataValue but got value: None + """ + # other schema error + When I create the file `/tmp/attach.yaml` with the following + """ + token: + enable_services: {cis: true} + """ + When I replace `` in `/tmp/attach.yaml` with token `contract_token` + Then I verify that running `ua attach --attach-config /tmp/attach.yaml` `with sudo` exits `1` + Then stderr matches regexp: + """ + Error while reading /tmp/attach.yaml: Got value with incorrect type for field + "enable_services": Expected value with type list but got value: {\'cis\': True} + """ + # invalid service name + When I create the file `/tmp/attach.yaml` with the following + """ + token: + enable_services: + - esm-apps + - nonexistent + - nonexistent2 + """ + When I replace `` in `/tmp/attach.yaml` with token `contract_token` + Then I verify that running `ua attach --attach-config /tmp/attach.yaml` `with sudo` exits `1` + Then stdout matches regexp: + """ + esm-apps +yes +enabled + """ + And stdout matches regexp: + """ + esm-infra +yes +disabled + """ + Then stderr matches regexp: + """ + Cannot enable unknown service 'nonexistent, nonexistent2'. + """ + Examples: ubuntu + | release | cis_or_usg | + | xenial | cis | + | bionic | cis | + | focal | usg | + @series.all @uses.config.machine_type.aws.generic Scenario Outline: Attach command in an generic AWS Ubuntu VM @@ -231,7 +338,7 @@ | release | fips_status |lp_status | lp_desc | cc_status | cis_or_usg | | xenial | disabled |enabled | Canonical Livepatch service | disabled | cis | | bionic | disabled |enabled | Canonical Livepatch service | disabled | cis | - | focal | n/a |enabled | Canonical Livepatch service | n/a | usg | + | focal | disabled |enabled | Canonical Livepatch service | n/a | usg | @series.all @uses.config.machine_type.azure.generic @@ -290,8 +397,8 @@ Examples: ubuntu release livepatch status | release | lp_status | fips_status | cc_status | cis_or_usg | | xenial | enabled | n/a | disabled | cis | - | bionic | n/a | disabled | disabled | cis | - | focal | enabled | n/a | n/a | usg | + | bionic | enabled | disabled | disabled | cis | + | focal | enabled | disabled | n/a | usg | @series.all @uses.config.machine_type.gcp.generic @@ -351,4 +458,39 @@ | release | lp_status | fips_status | cc_status | cis_or_usg | | xenial | n/a | n/a | disabled | cis | | bionic | n/a | disabled | disabled | cis | - | focal | enabled | n/a | n/a | usg | + | focal | enabled | disabled | n/a | usg | + + @series.all + @uses.config.machine_type.lxd.container + Scenario Outline: Attach command with json output + Given a `` machine with ubuntu-advantage-tools installed + When I verify that running attach `as non-root` with json response exits `1` + Then I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [{"message": "This command must be run as root (try using sudo).", "message_code": "nonroot-user", "service": null, "type": "system"}], "failed_services": [], "needs_reboot": false, "processed_services": [], "result": "failure", "warnings": []} + """ + When I verify that running attach `with sudo` with json response exits `0` + Then I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [], "failed_services": [], "needs_reboot": false, "processed_services": ["esm-apps", "esm-infra"], "result": "success", "warnings": []} + """ + When I run `ua status` with sudo + Then stdout matches regexp: + """ + SERVICE ENTITLED STATUS DESCRIPTION + cc-eal +yes + +Common Criteria EAL2 Provisioning Packages + """ + And stdout matches regexp: + """ + esm-apps +yes +enabled +UA Apps: Extended Security Maintenance \(ESM\) + esm-infra +yes +enabled +UA Infra: Extended Security Maintenance \(ESM\) + fips +yes +disabled +NIST-certified core packages + fips-updates +yes +disabled +NIST-certified core packages with priority security updates + livepatch +yes +n/a +Canonical Livepatch service + """ + + Examples: ubuntu release + | release | cc-eal | + | xenial | disabled | + | bionic | disabled | + | focal | n/a | diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/aws-ids.yaml ubuntu-advantage-tools-27.7~16.04.1/features/aws-ids.yaml --- ubuntu-advantage-tools-27.6~16.04.1/features/aws-ids.yaml 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/aws-ids.yaml 2022-03-10 17:17:29.000000000 +0000 @@ -1,3 +1,5 @@ -bionic: ami-094e9c95623db463c -focal: ami-0193aa0a9df84a08b -xenial: ami-06e94647aeaed1e5c +bionic: ami-0419d66039473da9d +bionic-fips: ami-03b75f613f80bcff1 +focal: ami-0489b8bdbbf3a3b32 +xenial: ami-011bcfe2bea365b6a +xenial-fips: ami-077e4c339a098fc9f diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/azure-ids.yaml ubuntu-advantage-tools-27.7~16.04.1/features/azure-ids.yaml --- ubuntu-advantage-tools-27.6~16.04.1/features/azure-ids.yaml 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/azure-ids.yaml 2022-03-10 17:17:29.000000000 +0000 @@ -1,3 +1,5 @@ bionic: "Canonical:0001-com-ubuntu-pro-bionic:pro-18_04-lts" focal: "Canonical:0001-com-ubuntu-pro-focal:pro-20_04-lts" xenial: "Canonical:0001-com-ubuntu-pro-xenial:pro-16_04-lts" +bionic-fips: "Canonical:0001-com-ubuntu-pro-bionic-fips:pro-fips-18_04" +xenial-fips: "Canonical:0001-com-ubuntu-pro-xenial-fips:pro-fips-16_04-private" diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/cloud.py ubuntu-advantage-tools-27.7~16.04.1/features/cloud.py --- ubuntu-advantage-tools-27.6~16.04.1/features/cloud.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/cloud.py 2022-03-10 17:17:29.000000000 +0000 @@ -218,7 +218,10 @@ if "pro" in self.machine_type: with open(self.pro_ids_path, "r") as stream: pro_ids = yaml.safe_load(stream.read()) - image_name = pro_ids[series] + if "fips" in self.machine_type: + image_name = pro_ids[series + "-fips"] + else: + image_name = pro_ids[series] else: image_name = self.api.daily_image(release=series) @@ -539,7 +542,7 @@ List of ports to open for network ingress to the instance :returns: - An AWS cloud provider instance + An Azure cloud provider instance """ if not image_name: image_name = self.locate_image_name(series) @@ -654,7 +657,7 @@ List of ports to open for network ingress to the instance :returns: - An AWS cloud provider instance + An GCP cloud provider instance """ if not image_name: image_name = self.locate_image_name(series) diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/detached_auto_attach.feature ubuntu-advantage-tools-27.7~16.04.1/features/detached_auto_attach.feature --- ubuntu-advantage-tools-27.6~16.04.1/features/detached_auto_attach.feature 1970-01-01 00:00:00.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/detached_auto_attach.feature 2022-03-10 17:17:29.000000000 +0000 @@ -0,0 +1,32 @@ +@uses.config.contract_token +Feature: Attached cloud does not detach when auto-attaching after manually attaching + + @series.all + @uses.config.machine_type.aws.generic + @uses.config.machine_type.azure.generic + @uses.config.machine_type.gcp.generic + Scenario Outline: No detaching on manually attached machine on all clouds + Given a `` machine with ubuntu-advantage-tools installed + When I attach `contract_token` with sudo + And I run `ua refresh` with sudo + Then I will see the following on stdout: + """ + Successfully processed your ua configuration. + Successfully refreshed your subscription. + """ + When I run `ua auto-attach` with sudo + Then stderr matches regexp: + """ + Skipping attach: Instance '[0-9a-z\-]+' is already attached. + """ + When I run `ua status` with sudo + Then stdout matches regexp: + """ + esm-infra +yes + +UA Infra: Extended Security Maintenance \(ESM\) + """ + + Examples: ubuntu release + | release | esm-service | + | bionic | enabled | + | focal | enabled | + | xenial | enabled | diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/enable_fips_container.feature ubuntu-advantage-tools-27.7~16.04.1/features/enable_fips_container.feature --- ubuntu-advantage-tools-27.6~16.04.1/features/enable_fips_container.feature 1970-01-01 00:00:00.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/enable_fips_container.feature 2022-03-10 17:17:29.000000000 +0000 @@ -0,0 +1,144 @@ + +@uses.config.contract_token +Feature: FIPS enablement in lxd containers + + @series.xenial + @series.bionic + @series.focal + @uses.config.machine_type.lxd.container + Scenario Outline: Attached enable of FIPS in an ubuntu lxd container + Given a `` machine with ubuntu-advantage-tools installed + When I attach `contract_token` with sudo + And I run `DEBIAN_FRONTEND=noninteractive apt-get install -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" -y openssh-client openssh-server strongswan openssl libgcrypt20` with sudo, retrying exit [100] + And I run `ua enable fips` `with sudo` and stdin `y` + Then stdout matches regexp: + """ + Warning: Enabling in a container. + This will install the FIPS packages but not the kernel. + This container must run on a host with enabled to be + compliant. + Warning: This action can take some time and cannot be undone. + """ + And stdout matches regexp: + """ + Updating package lists + Installing packages + enabled + A reboot is required to complete install. + Please run `apt upgrade` to ensure all FIPS packages are updated to the correct + version. + """ + When I run `ua status --all` with sudo + Then stdout matches regexp: + """ + fips +yes enabled + """ + And stdout matches regexp: + """ + FIPS support requires system reboot to complete configuration + """ + And I verify that running `apt update` `with sudo` exits `0` + And I verify that `openssh-server` is installed from apt source `https://esm.ubuntu.com/fips/ubuntu /main` + And I verify that `openssh-client` is installed from apt source `https://esm.ubuntu.com/fips/ubuntu /main` + And I verify that `strongswan` is installed from apt source `https://esm.ubuntu.com/fips/ubuntu /main` + And I verify that `strongswan-hmac` is installed from apt source `https://esm.ubuntu.com/fips/ubuntu /main` + And I verify that `openssl` is installed from apt source `https://esm.ubuntu.com/fips/ubuntu /main` + And I verify that `` is installed from apt source `https://esm.ubuntu.com/fips/ubuntu /main` + And I verify that `-hmac` is installed from apt source `https://esm.ubuntu.com/fips/ubuntu /main` + And I verify that `` are installed from apt source `https://esm.ubuntu.com/fips/ubuntu /main` + When I reboot the `` machine + When I run `ua status --all` with sudo + Then stdout does not match regexp: + """ + FIPS support requires system reboot to complete configuration + """ + When I run `ua disable fips` `with sudo` and stdin `y` + Then stdout matches regexp: + """ + This will disable the FIPS entitlement but the FIPS packages will remain installed. + """ + And stdout matches regexp: + """ + Updating package lists + """ + And stdout does not match regexp: + """ + A reboot is required to complete disable operation + """ + When I run `ua status --all` with sudo + Then stdout matches regexp: + """ + fips +yes disabled + """ + Then stdout does not match regexp: + """ + Disabling FIPS requires system reboot to complete operation + """ + When I run `apt-cache policy ubuntu-fips` as non-root + Then stdout does not match regexp: + """ + .*Installed: \(none\) + """ + Then I verify that `openssh-server` installed version matches regexp `fips` + And I verify that `openssh-client` installed version matches regexp `fips` + And I verify that `strongswan` installed version matches regexp `fips` + And I verify that `strongswan-hmac` installed version matches regexp `fips` + And I verify that `openssl` installed version matches regexp `fips` + And I verify that `` installed version matches regexp `fips` + And I verify that `-hmac` installed version matches regexp `fips` + And I verify that packages `` installed versions match regexp `fips` + + Examples: ubuntu release + | release | fips-name | updates | libssl | additional-fips-packages | + | xenial | FIPS | | libssl1.0.0 | openssh-server-hmac openssh-client-hmac | + | xenial | FIPS Updates | -updates | libssl1.0.0 | openssh-server-hmac openssh-client-hmac | + | bionic | FIPS | | libssl1.1 | openssh-server-hmac openssh-client-hmac libgcrypt20 libgcrypt20-hmac | + | bionic | FIPS Updates | -updates | libssl1.1 | openssh-server-hmac openssh-client-hmac libgcrypt20 libgcrypt20-hmac | + | focal | FIPS | | libssl1.1 | libgcrypt20 libgcrypt20-hmac | + | focal | FIPS Updates | -updates | libssl1.1 | libgcrypt20 libgcrypt20-hmac | + + @series.xenial + @series.bionic + @series.focal + @uses.config.machine_type.lxd.container + Scenario Outline: Try to enable FIPS after FIPS Updates in a lxd container + Given a `` machine with ubuntu-advantage-tools installed + When I attach `contract_token` with sudo + When I run `ua status --all` with sudo + Then stdout matches regexp: + """ + fips-updates +yes +disabled + """ + And stdout matches regexp: + """ + fips +yes +disabled + """ + When I run `ua enable fips-updates --assume-yes` with sudo + When I run `ua status --all` with sudo + Then stdout matches regexp: + """ + fips-updates +yes +enabled + """ + And stdout matches regexp: + """ + fips +yes +n/a + """ + When I verify that running `ua enable fips --assume-yes` `with sudo` exits `1` + Then stdout matches regexp: + """ + Cannot enable FIPS when FIPS Updates is enabled. + """ + When I run `ua status --all` with sudo + Then stdout matches regexp: + """ + fips-updates +yes +enabled + """ + And stdout matches regexp: + """ + fips +yes +n/a + """ + Examples: ubuntu release + | release | + | xenial | + | bionic | + | focal | diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/enable_fips_vm.feature ubuntu-advantage-tools-27.7~16.04.1/features/enable_fips_vm.feature --- ubuntu-advantage-tools-27.6~16.04.1/features/enable_fips_vm.feature 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/enable_fips_vm.feature 2022-03-10 17:17:29.000000000 +0000 @@ -8,7 +8,12 @@ Scenario Outline: Attached enable of FIPS in an ubuntu lxd vm Given a `` machine with ubuntu-advantage-tools installed When I attach `contract_token` with sudo - And I run `ua disable livepatch` with sudo + When I run `ua status --format json` with sudo + Then stdout contains substring + """ + {"available": "yes", "blocked_by": [{"name": "livepatch", "reason": "Livepatch cannot be enabled while running the official FIPS certified kernel. If you would like a FIPS compliant kernel with additional bug fixes and security updates, you can use the FIPS Updates service with Livepatch.", "reason_code": "livepatch-invalidates-fips"}], "description": "NIST-certified core packages", "description_override": null, "entitled": "yes", "name": "fips", "status": "disabled", "status_details": "FIPS is not configured"} + """ + When I run `ua disable livepatch` with sudo And I run `DEBIAN_FRONTEND=noninteractive apt-get install -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" -y openssh-client openssh-server strongswan` with sudo, retrying exit [100] And I run `apt-mark hold openssh-client openssh-server strongswan` with sudo And I run `ua enable ` `with sudo` and stdin `y` @@ -40,6 +45,12 @@ And I verify that `openssh-server-hmac` is installed from apt source `` And I verify that `openssh-client-hmac` is installed from apt source `` And I verify that `strongswan-hmac` is installed from apt source `` + When I run `ua status --format json` with sudo + Then stdout contains substring: + """ + {"available": "yes", "blocked_by": [{"name": "fips", "reason": "Livepatch cannot be enabled while running the official FIPS certified kernel. If you would like a FIPS compliant kernel with additional bug fixes and security updates, you can use the FIPS Updates service with Livepatch.", "reason_code": "livepatch-invalidates-fips"}], "description": "Canonical Livepatch service", "description_override": null, "entitled": "yes", "name": "livepatch", "status": "n/a", "status_details": "Cannot enable Livepatch when FIPS is enabled."} + """ + When I reboot the `` machine And I run `uname -r` as non-root Then stdout matches regexp: @@ -99,6 +110,24 @@ """ Disabling FIPS requires system reboot to complete operation """ + When I run `ua enable --assume-yes --format json --assume-yes` with sudo + Then stdout is a json matching the `ua_operation` schema + And I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [], "failed_services": [], "needs_reboot": true, "processed_services": [""], "result": "success", "warnings": []} + """ + When I reboot the `` machine + And I run `ua disable --assume-yes --format json` with sudo + Then stdout is a json matching the `ua_operation` schema + And I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [], "failed_services": [], "needs_reboot": true, "processed_services": [""], "result": "success", "warnings": []} + """ + When I run `ua status --all` with sudo + Then stdout matches regexp: + """ + +yes disabled + """ Examples: ubuntu release | release | fips-name | fips-service |fips-apt-source | @@ -139,6 +168,12 @@ And I verify that `openssh-server-hmac` is installed from apt source `` And I verify that `openssh-client-hmac` is installed from apt source `` And I verify that `strongswan-hmac` is installed from apt source `` + When I run `ua status --format json` with sudo + Then stdout contains substring: + """ + {"available": "yes", "blocked_by": [{"name": "fips-updates", "reason": "FIPS cannot be enabled if FIPS Updates has ever been enabled because FIPS Updates installs security patches that aren't officially certified.", "reason_code": "fips-updates-invalidates-fips"}], "description": "NIST-certified core packages", "description_override": null, "entitled": "yes", "name": "fips", "status": "n/a", "status_details": "Cannot enable FIPS when FIPS Updates is enabled."} + """ + When I reboot the `` machine And I run `uname -r` as non-root Then stdout matches regexp: @@ -207,6 +242,30 @@ """ livepatch +yes +enabled """ + When I run `ua status --format json` with sudo + Then stdout contains substring: + """ + {"available": "yes", "blocked_by": [{"name": "livepatch", "reason": "Livepatch cannot be enabled while running the official FIPS certified kernel. If you would like a FIPS compliant kernel with additional bug fixes and security updates, you can use the FIPS Updates service with Livepatch.", "reason_code": "livepatch-invalidates-fips"}, {"name": "fips-updates", "reason": "FIPS cannot be enabled if FIPS Updates has ever been enabled because FIPS Updates installs security patches that aren't officially certified.", "reason_code": "fips-updates-invalidates-fips"}], "description": "NIST-certified core packages", "description_override": null, "entitled": "yes", "name": "fips", "status": "n/a", "status_details": "Cannot enable FIPS when FIPS Updates is enabled."} + """ + When I run `ua disable --assume-yes` with sudo + And I run `ua enable --assume-yes --format json --assume-yes` with sudo + Then stdout is a json matching the `ua_operation` schema + And I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [], "failed_services": [], "needs_reboot": true, "processed_services": [""], "result": "success", "warnings": []} + """ + When I reboot the `` machine + And I run `ua disable --assume-yes --format json` with sudo + Then stdout is a json matching the `ua_operation` schema + And I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [], "failed_services": [], "needs_reboot": true, "processed_services": [""], "result": "success", "warnings": []} + """ + When I run `ua status --all` with sudo + Then stdout matches regexp: + """ + +yes disabled + """ Examples: ubuntu release | release | fips-name | fips-service |fips-apt-source | diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/install_uninstall.feature ubuntu-advantage-tools-27.7~16.04.1/features/install_uninstall.feature --- ubuntu-advantage-tools-27.6~16.04.1/features/install_uninstall.feature 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/install_uninstall.feature 2022-03-10 17:17:29.000000000 +0000 @@ -12,7 +12,6 @@ | xenial | | bionic | | focal | - | hirsute | | impish | | jammy | diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/license_check.feature ubuntu-advantage-tools-27.7~16.04.1/features/license_check.feature --- ubuntu-advantage-tools-27.6~16.04.1/features/license_check.feature 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/license_check.feature 2022-03-10 17:17:29.000000000 +0000 @@ -38,8 +38,9 @@ | xenial | | bionic | | focal | + | jammy | - @series.hirsute + @series.impish @uses.config.contract_token @uses.config.machine_type.gcp.generic Scenario Outline: license_check is disabled gcp generic non lts @@ -59,7 +60,7 @@ Then I verify the `ua-license-check` systemd timer is disabled Examples: version | release | - | hirsute | + | impish | @series.all @uses.config.contract_token @@ -91,7 +92,6 @@ | xenial | | bionic | | focal | - | hirsute | | impish | | jammy | diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/proxy_config.feature ubuntu-advantage-tools-27.7~16.04.1/features/proxy_config.feature --- ubuntu-advantage-tools-27.6~16.04.1/features/proxy_config.feature 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/proxy_config.feature 2022-03-10 17:17:29.000000000 +0000 @@ -24,12 +24,23 @@ https_proxy: http://:3128 """ And I verify `/var/log/squid/access.log` is empty on `proxy` machine - And I attach `contract_token` with sudo + # We need this for the route command + And I run `apt-get install net-tools` with sudo + # We will guarantee that the machine will only use the proxy when + # running the ua commands + And I run `route del default` with sudo + And I attach `contract_token` with sudo and options `--no-auto-enable` And I run `cat /var/log/squid/access.log` `with sudo` on the `proxy` machine Then stdout matches regexp: """ .*CONNECT contracts.canonical.com.* """ + When I run `ua status` with sudo + # Just to verify that the machine is attached + Then stdout matches regexp: + """ + esm-infra +yes +disabled +UA Infra: Extended Security Maintenance \(ESM\) + """ When I run `truncate -s 0 /var/log/squid/access.log` `with sudo` on the `proxy` machine When I create the file `/etc/ubuntu-advantage/uaclient.conf` with the following: """ @@ -139,8 +150,12 @@ And I run `systemctl restart squid.service` `with sudo` on the `proxy` machine And I run `ua config set http_proxy=http://:3128` with sudo And I run `ua config set https_proxy=http://:3128` with sudo - And I verify `/var/log/squid/access.log` is empty on `proxy` machine - And I attach `contract_token` with sudo + And I run `cat /var/log/squid/access.log` `with sudo` on the `proxy` machine + Then stdout matches regexp: + """ + .*CONNECT api.snapcraft.io.* + """ + When I attach `contract_token` with sudo Then stdout matches regexp: """ Setting snap proxy @@ -296,8 +311,12 @@ And I run `systemctl restart squid.service` `with sudo` on the `proxy` machine And I run `ua config set http_proxy=http://someuser:somepassword@:3128` with sudo And I run `ua config set https_proxy=http://someuser:somepassword@:3128` with sudo - And I verify `/var/log/squid/access.log` is empty on `proxy` machine - And I attach `contract_token` with sudo + And I run `cat /var/log/squid/access.log` `with sudo` on the `proxy` machine + Then stdout matches regexp: + """ + .*CONNECT api.snapcraft.io:443.* + """ + When I attach `contract_token` with sudo Then stdout matches regexp: """ Setting snap proxy diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/schemas/ua_operation.json ubuntu-advantage-tools-27.7~16.04.1/features/schemas/ua_operation.json --- ubuntu-advantage-tools-27.6~16.04.1/features/schemas/ua_operation.json 1970-01-01 00:00:00.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/schemas/ua_operation.json 2022-03-10 17:17:29.000000000 +0000 @@ -0,0 +1,76 @@ +{ + "type": "object", + "properties": { + "_schema_version": { + "type": "string", + "const": "0.1" + }, + "result": { + "type": "string", + "enum": ["success", "failure"] + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "required": [ "message", "service", "type" ], + "properties": { + "message": { + "type": "string" + }, + "message_code": { + "type": ["null", "string"] + }, + "service": { + "type": ["null", "string"] + } + }, + "patternProperties": { + "^type$": { + "type": "string", + "enum": ["service", "system"] + } + } + } + }, + "warnings": { + "type": "array", + "items": { + "type": "object", + "required": [ "message", "service", "type" ], + "properties": { + "message": { + "type": "string" + }, + "message_code": { + "type": ["null", "string"] + }, + "service": { + "type": ["null", "string"] + } + }, + "patternProperties": { + "^type$": { + "type": "string", + "enum": ["service", "system"] + } + } + } + }, + "failed_services": { + "type": "array", + "items": { + "type": "string" + } + }, + "processed_services": { + "type": "array", + "items": { + "type": "string" + } + }, + "needs_reboot": { + "type": "boolean" + } + } +} diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/schemas/ua_security_status.json ubuntu-advantage-tools-27.7~16.04.1/features/schemas/ua_security_status.json --- ubuntu-advantage-tools-27.6~16.04.1/features/schemas/ua_security_status.json 1970-01-01 00:00:00.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/schemas/ua_security_status.json 2022-03-10 17:17:29.000000000 +0000 @@ -0,0 +1,81 @@ +{ + "type": "object", + "properties": { + "_schema_version": { + "type": "string", + "const": "0.1" + }, + "summary": { + "type": "object", + "properties": { + "ua": { + "type": "object", + "properties": { + "attached": { + "type": "boolean" + }, + "enabled_services": { + "type": "array", + "items": { + "type": "string" + } + }, + "entitled_services": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "num_installed_packages": { + "type": "integer" + }, + "num_esm_infra_packages": { + "type": "integer" + }, + "num_esm_apps_packages": { + "type": "integer" + }, + "num_esm_infra_updates": { + "type": "integer" + }, + "num_esm_apps_updates": { + "type": "integer" + }, + "num_standard_security_updates": { + "type": "integer" + } + } + }, + "packages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "version": { + "type": "string" + }, + "service_name": { + "type": "string" + }, + "origin": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "upgrade_available", + "pending_attach", + "pending_enable", + "upgrade_unavailable" + ] + } + } + } + } + } +} diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/schemas/ua_status.json ubuntu-advantage-tools-27.7~16.04.1/features/schemas/ua_status.json --- ubuntu-advantage-tools-27.6~16.04.1/features/schemas/ua_status.json 1970-01-01 00:00:00.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/schemas/ua_status.json 2022-03-10 17:17:29.000000000 +0000 @@ -0,0 +1,247 @@ +{ + "type": "object", + "properties": { + "_doc": { + "type": "string" + }, + "_schema_version": { + "type": "string", + "const": "0.1" + }, + "version": { + "type": "string" + }, + "result": { + "type": "string", + "enum": ["success", "failure"] + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "required": [ "message", "service", "type" ], + "properties": { + "message": { + "type": "string" + }, + "service": { + "type": ["null", "string"] + } + }, + "patternProperties": { + "^type$": { + "type": "string", + "enum": ["service", "system"] + } + } + } + }, + "warnings": { + "type": "array", + "items": { + "type": "object", + "required": [ "message", "service", "type" ], + "properties": { + "message": { + "type": "string" + }, + "service": { + "type": ["null", "string"] + } + }, + "patternProperties": { + "^type$": { + "type": "string", + "enum": ["service", "system"] + } + } + } + }, + "attached": { + "type": "boolean" + }, + "machine_id": { + "type": ["null", "string"] + }, + "effective": { + "type": ["null", "string"] + }, + "expires": { + "type": ["null", "string"] + }, + "execution_status": { + "type": "string", + "enum": ["active", "inactive", "reboot-required"] + }, + "execution_details": { + "type": "string" + }, + "simulated": { + "type": "boolean" + }, + "services": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "available": { + "type": "string", + "enum": ["yes", "no"] + }, + "entitled": { + "type": "string", + "enum": ["yes", "no"] + }, + "status": { + "type": "string", + "enum": ["enabled", "disabled", "n/a"] + }, + "status_details": { + "type": "string" + }, + "description_override": { + "type": ["null", "string"] + }, + "blocked_by": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "reason_code": { + "type": "string" + } + } + } + } + } + } + }, + "notices": { + "type": "array", + "items": { + "type": "string" + } + }, + "config_path": { + "type": "string" + }, + "environment_vars": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + }, + "contract": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "products": { + "type": "array", + "items": { + "type": "string" + } + }, + "tech_support_level": { + "type": "string" + } + } + }, + "account": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "external_account_ids": { + "type": "array" + } + } + }, + "config": { + "type": "object", + "properties": { + "contract_url": { + "type": "string" + }, + "security_url": { + "type": "string" + }, + "data_dir": { + "type": "string" + }, + "log_level": { + "type": "string" + }, + "log_file": { + "type": "string" + }, + "timer_log_file": { + "type": "string" + }, + "license_check_log_file": { + "type": "string" + }, + "ua_config": { + "type": "object", + "properties": { + "apt_http_proxy": { + "type": ["null", "string"] + }, + "apt_https_proxy": { + "type": ["null", "string"] + }, + "http_proxy": { + "type": ["null", "string"] + }, + "https_proxy": { + "type": ["null", "string"] + }, + "update_messaging_timer": { + "type": "integer" + }, + "update_status_timer": { + "type": "integer" + }, + "metering_timer": { + "type": "integer" + } + } + } + } + } + } +} diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/steps/steps.py ubuntu-advantage-tools-27.7~16.04.1/features/steps/steps.py --- ubuntu-advantage-tools-27.6~16.04.1/features/steps/steps.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/steps/steps.py 2022-03-10 17:17:29.000000000 +0000 @@ -6,6 +6,7 @@ import subprocess import time +import jsonschema # type: ignore import yaml from behave import given, then, when from hamcrest import ( @@ -20,7 +21,12 @@ capture_container_as_image, create_instance_with_uat_installed, ) -from features.util import SLOW_CMDS, emit_spinner_on_travis, nullcontext +from features.util import ( + SLOW_CMDS, + SafeLoaderWithoutDatetime, + emit_spinner_on_travis, + nullcontext, +) from uaclient.defaults import DEFAULT_CONFIG_FILE, DEFAULT_MACHINE_TOKEN_PATH from uaclient.util import DatetimeAwareJSONDecoder @@ -191,7 +197,7 @@ @when("I verify `{file_name}` is empty on `{instance_name}` machine") def when_i_verify_file_is_empty_on_machine(context, file_name, instance_name): - command = 'sh -c "cat {} | wc -l"' + command = 'sh -c "cat {} | wc -l"'.format(file_name) when_i_run_command( context, command, user_spec="with sudo", instance_name=instance_name ) @@ -230,8 +236,8 @@ @when("I do a preflight check for `{contract_token}` {user_spec}") -def when_i_preflight(context, contract_token, user_spec): - token = getattr(context.config, contract_token) +def when_i_preflight(context, contract_token, user_spec, verify_return=True): + token = getattr(context.config, contract_token, "invalid_token") command = "ua status --simulate-with-token {}".format(token) if user_spec == "with the all flag": command += " --all" @@ -239,10 +245,23 @@ output_format = user_spec.split()[2] command += " --format {}".format(output_format) when_i_run_command( - context=context, command=command, user_spec="as non-root" + context=context, + command=command, + user_spec="as non-root", + verify_return=verify_return, ) +@when( + "I verify that a preflight check for `{contract_token}` {user_spec} exits {exit_codes}" # noqa +) +def when_i_attempt_preflight(context, contract_token, user_spec, exit_codes): + when_i_preflight(context, contract_token, user_spec, verify_return=False) + + expected_codes = exit_codes.split(",") + assert str(context.process.returncode) in expected_codes + + @when("I run `{command}` {user_spec}") def when_i_run_command( context, @@ -280,6 +299,9 @@ logging.error("Error executing command: {}".format(command)) logging.error("stdout: {}".format(result.stdout)) logging.error("stderr: {}".format(result.stderr)) + else: + logging.debug("stdout: {}".format(result.stdout)) + logging.debug("stderr: {}".format(result.stderr)) if verify_return: assert_that(process.returncode, equal_to(0)) @@ -290,11 +312,21 @@ @when("I fix `{issue}` by attaching to a subscription with `{token_type}`") def when_i_fix_a_issue_by_attaching(context, issue, token_type): token = getattr(context.config, token_type) + + if ( + token_type == "contract_token_staging" + or token_type == "contract_token_staging_expired" + ): + change_contract_endpoint_to_staging(context, user_spec="with sudo") + else: + change_contract_endpoint_to_production(context, user_spec="with sudo") + when_i_run_command( context=context, command="ua fix {}".format(issue), user_spec="with sudo", stdin="a\n{}\n".format(token), + verify_return=False, ) @@ -340,23 +372,46 @@ ) +def change_contract_endpoint_to_staging(context, user_spec): + when_i_run_command( + context, + "sed -i 's/contracts.can/contracts.staging.can/' {}".format( + DEFAULT_CONFIG_FILE + ), + user_spec, + ) + + +def change_contract_endpoint_to_production(context, user_spec): + when_i_run_command( + context, + "sed -i 's/contracts.staging.can/contracts.can/' {}".format( + DEFAULT_CONFIG_FILE + ), + user_spec, + ) + + +@when("I attach `{token_type}` {user_spec} and options `{options}`") +def when_i_attach_staging_token_with_options( + context, token_type, user_spec, options +): + when_i_attach_staging_token( + context, token_type, user_spec, options=options + ) + + @when("I attach `{token_type}` {user_spec}") def when_i_attach_staging_token( - context, token_type, user_spec, verify_return=True + context, token_type, user_spec, verify_return=True, options="" ): token = getattr(context.config, token_type) if ( token_type == "contract_token_staging" or token_type == "contract_token_staging_expired" ): - when_i_run_command( - context, - "sed -i 's/contracts.can/contracts.staging.can/' {}".format( - DEFAULT_CONFIG_FILE - ), - user_spec, - ) - cmd = "ua attach {}".format(token) + change_contract_endpoint_to_staging(context, user_spec) + cmd = "ua attach {} {}".format(token, options).strip() when_i_run_command(context, cmd, user_spec, verify_return=False) if verify_return: @@ -420,6 +475,25 @@ time.sleep(int(seconds)) +@when("I replace `{original}` in `{filename}` with `{new}`") +def when_i_replace_string_in_file(context, original, filename, new): + when_i_run_command( + context, + "sed -i 's/{original}/{new}/' {filename}".format( + original=original, new=new, filename=filename + ), + "with sudo", + ) + + +@when("I replace `{original}` in `{filename}` with token `{token_name}`") +def when_i_replace_string_in_file_with_token( + context, original, filename, token_name +): + token = getattr(context.config, token_name) + when_i_replace_string_in_file(context, original, filename, token) + + @then("I will see the following on stdout") def then_i_will_see_on_stdout(context): assert_that(context.process.stdout.strip(), equal_to(context.text)) @@ -439,27 +513,6 @@ then_stream_does_not_match_regexp(context, "stdout") -@then("stdout is formatted as `{output_format}` and has keys") -def then_stdout_is_formatted_and_has_keys(context, output_format): - output = context.process.stdout.strip() - if output_format == "json": - data = json.loads(output) - elif output_format == "yaml": - data = yaml.safe_load(output) - - keys = set(context.text.split()) - output_keys = set(data.keys()) - - if keys != output_keys: - message = """ - Missing keys in output: {} - Extra keys in output: {} - """.format( - keys - output_keys or "", output_keys - keys or "" - ) - raise AssertionError(message) - - @then("{stream} does not match regexp") def then_stream_does_not_match_regexp(context, stream): content = getattr(context.process, stream).strip() @@ -472,6 +525,12 @@ assert_that(content, matches_regexp(context.text)) +@then("{stream} contains substring") +def then_stream_contains_substring(context, stream): + content = getattr(context.process, stream).strip() + assert_that(content, contains_string(context.text)) + + @then("I will see the following on stderr") def then_i_will_see_on_stderr(context): assert_that(context.process.stderr.strip(), equal_to(context.text)) @@ -521,6 +580,16 @@ @when( + "I verify that running attach `{spec}` with json response exits `{exit_codes}`" # noqa +) +def when_i_verify_attach_with_json_response(context, spec, exit_codes): + cmd = "ua attach {} --format json".format(context.config.contract_token) + then_i_verify_that_running_cmd_with_spec_exits_with_codes( + context=context, cmd_name=cmd, spec=spec, exit_codes=exit_codes + ) + + +@when( "I verify that running `{cmd_name}` `{spec}` and stdin `{stdin}` exits `{exit_codes}`" # noqa ) def then_i_verify_that_running_cmd_with_spec_and_stdin_exits_with_codes( @@ -581,6 +650,16 @@ assert_that(context.process.stdout.strip(), matches_regexp(regex)) +@then( + "I verify that packages `{packages}` installed versions match regexp `{regex}`" # noqa: E501 +) +def verify_installed_packages_match_version_regexp(context, packages, regex): + for package in packages.split(" "): + verify_installed_package_matches_version_regexp( + context, package, regex + ) + + @then("I verify that `{package}` is installed from apt source `{apt_source}`") def verify_package_is_installed_from_apt_source(context, package, apt_source): when_i_run_command( @@ -609,6 +688,18 @@ ) +@then( + "I verify that `{packages}` are installed from apt source `{apt_source}`" +) +def verify_packages_are_installed_from_apt_source( + context, packages, apt_source +): + for package in packages.split(" "): + verify_package_is_installed_from_apt_source( + context, package, apt_source + ) + + @then("I verify that the timer interval for `{job}` is `{interval}`") def verify_timer_interval_for_job(context, job, interval): when_i_run_command( @@ -779,6 +870,18 @@ ) +@then("stdout is a {output_format} matching the `{schema}` schema") +def stdout_matches_the_json_schema(context, output_format, schema): + if output_format == "json": + instance = json.loads(context.process.stdout.strip()) + elif output_format == "yaml": + instance = yaml.load( + context.process.stdout.strip(), SafeLoaderWithoutDatetime + ) + with open("features/schemas/{}.json".format(schema), "r") as schema_file: + jsonschema.validate(instance=instance, schema=json.load(schema_file)) + + def get_command_prefix_for_user_spec(user_spec): prefix = [] if user_spec == "with sudo": diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/ubuntu_pro.feature ubuntu-advantage-tools-27.7~16.04.1/features/ubuntu_pro.feature --- ubuntu-advantage-tools-27.6~16.04.1/features/ubuntu_pro.feature 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/ubuntu_pro.feature 2022-03-10 17:17:29.000000000 +0000 @@ -40,6 +40,22 @@ """ +yes + +Security compliance and audit tools """ + When I run `ua enable ` with sudo + And I run `ua status` with sudo + Then stdout matches regexp: + """ + +yes +enabled +Security compliance and audit tools + """ + When I run `ua disable ` with sudo + Then stdout matches regexp: + """ + Updating package lists + """ + When I run `ua status` with sudo + Then stdout matches regexp: + """ + +yes +disabled +Security compliance and audit tools + """ When I run `cat /var/log/squid/access.log` `with sudo` on the `proxy` machine Then stdout matches regexp: """ @@ -53,7 +69,7 @@ | release | fips-s | cc-eal-s | cis-s | cis_or_usg | | xenial | disabled | disabled | disabled | cis | | bionic | disabled | disabled | disabled | cis | - | focal | n/a | n/a | disabled | usg | + | focal | disabled | n/a | disabled | usg | @series.lts @uses.config.machine_type.azure.pro @@ -95,6 +111,22 @@ """ +yes + +Security compliance and audit tools """ + When I run `ua enable ` with sudo + And I run `ua status` with sudo + Then stdout matches regexp: + """ + +yes +enabled +Security compliance and audit tools + """ + When I run `ua disable ` with sudo + Then stdout matches regexp: + """ + Updating package lists + """ + When I run `ua status` with sudo + Then stdout matches regexp: + """ + +yes +disabled +Security compliance and audit tools + """ When I run `cat /var/log/squid/access.log` `with sudo` on the `proxy` machine Then stdout matches regexp: """ @@ -107,8 +139,8 @@ Examples: ubuntu release | release | fips-s | cc-eal-s | cis-s | livepatch-s | cis_or_usg | | xenial | n/a | disabled | disabled | enabled | cis | - | bionic | disabled | disabled | disabled | n/a | cis | - | focal | n/a | n/a | disabled | enabled | usg | + | bionic | disabled | disabled | disabled | enabled | cis | + | focal | disabled | n/a | disabled | enabled | usg | @series.lts @uses.config.machine_type.gcp.pro @@ -150,6 +182,22 @@ """ +yes + +Security compliance and audit tools """ + When I run `ua enable ` with sudo + And I run `ua status` with sudo + Then stdout matches regexp: + """ + +yes +enabled +Security compliance and audit tools + """ + When I run `ua disable ` with sudo + Then stdout matches regexp: + """ + Updating package lists + """ + When I run `ua status` with sudo + Then stdout matches regexp: + """ + +yes +disabled +Security compliance and audit tools + """ When I run `cat /var/log/squid/access.log` `with sudo` on the `proxy` machine Then stdout matches regexp: """ @@ -163,7 +211,7 @@ | release | fips-s | cc-eal-s | cis-s | livepatch-s | cis_or_usg | | xenial | n/a | disabled | disabled | n/a | cis | | bionic | disabled | disabled | disabled | n/a | cis | - | focal | n/a | n/a | disabled | enabled | usg | + | focal | disabled | n/a | disabled | enabled | usg | @series.lts @uses.config.machine_type.aws.pro @@ -178,7 +226,6 @@ """ And I run `ua auto-attach` with sudo And I run `ua status --wait` as non-root - And I run `ua status` as non-root Then stdout matches regexp: """ SERVICE ENTITLED STATUS DESCRIPTION @@ -214,6 +261,22 @@ """ +yes + +Security compliance and audit tools """ + When I run `ua enable ` with sudo + And I run `ua status` with sudo + Then stdout matches regexp: + """ + +yes +enabled +Security compliance and audit tools + """ + When I run `ua disable ` with sudo + Then stdout matches regexp: + """ + Updating package lists + """ + When I run `ua status` with sudo + Then stdout matches regexp: + """ + +yes +disabled +Security compliance and audit tools + """ When I run `systemctl start ua-auto-attach.service` with sudo And I verify that running `systemctl status ua-auto-attach.service` `as non-root` exits `0,3` Then stdout matches regexp: @@ -281,7 +344,7 @@ | release | fips-s | cc-eal-s | cis-s | infra-pkg | apps-pkg | cis_or_usg | | xenial | disabled | disabled | disabled | libkrad0 | jq | cis | | bionic | disabled | disabled | disabled | libkrad0 | bundler | cis | - | focal | n/a | n/a | disabled | hello | ant | usg | + | focal | disabled | n/a | disabled | hello | ant | usg | @series.lts @uses.config.machine_type.azure.pro @@ -332,6 +395,22 @@ """ +yes + +Security compliance and audit tools """ + When I run `ua enable ` with sudo + And I run `ua status` with sudo + Then stdout matches regexp: + """ + +yes +enabled +Security compliance and audit tools + """ + When I run `ua disable ` with sudo + Then stdout matches regexp: + """ + Updating package lists + """ + When I run `ua status` with sudo + Then stdout matches regexp: + """ + +yes +disabled +Security compliance and audit tools + """ When I run `systemctl start ua-auto-attach.service` with sudo And I verify that running `systemctl status ua-auto-attach.service` `as non-root` exits `0,3` Then stdout matches regexp: @@ -398,8 +477,8 @@ Examples: ubuntu release | release | fips-s | cc-eal-s | cis-s | infra-pkg | apps-pkg | livepatch | cis_or_usg | | xenial | n/a | disabled | disabled | libkrad0 | jq | enabled | cis | - | bionic | disabled | disabled | disabled | libkrad0 | bundler | n/a | cis | - | focal | n/a | n/a | disabled | hello | ant | enabled | usg | + | bionic | disabled | disabled | disabled | libkrad0 | bundler | enabled | cis | + | focal | disabled | n/a | disabled | hello | ant | enabled | usg | @series.lts @uses.config.machine_type.gcp.pro @@ -450,6 +529,22 @@ """ +yes + +Security compliance and audit tools """ + When I run `ua enable ` with sudo + And I run `ua status` with sudo + Then stdout matches regexp: + """ + +yes +enabled +Security compliance and audit tools + """ + When I run `ua disable ` with sudo + Then stdout matches regexp: + """ + Updating package lists + """ + When I run `ua status` with sudo + Then stdout matches regexp: + """ + +yes +disabled +Security compliance and audit tools + """ When I run `systemctl start ua-auto-attach.service` with sudo And I verify that running `systemctl status ua-auto-attach.service` `as non-root` exits `0,3` Then stdout matches regexp: @@ -517,4 +612,4 @@ | release | fips-s | cc-eal-s | cis-s | infra-pkg | apps-pkg | livepatch | cis_or_usg | | xenial | n/a | disabled | disabled | libkrad0 | jq | n/a | cis | | bionic | disabled | disabled | disabled | libkrad0 | bundler | n/a | cis | - | focal | n/a | n/a | disabled | hello | ant | enabled | usg | + | focal | disabled | n/a | disabled | hello | ant | enabled | usg | diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/ubuntu_pro_fips.feature ubuntu-advantage-tools-27.7~16.04.1/features/ubuntu_pro_fips.feature --- ubuntu-advantage-tools-27.6~16.04.1/features/ubuntu_pro_fips.feature 1970-01-01 00:00:00.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/ubuntu_pro_fips.feature 2022-03-10 17:17:29.000000000 +0000 @@ -0,0 +1,210 @@ +Feature: Command behaviour when auto-attached in an ubuntu PRO fips image + + @series.lts + @uses.config.machine_type.azure.pro.fips + Scenario Outline: Check fips is enabled correctly on Ubuntu pro fips Azure machine + Given a `` machine with ubuntu-advantage-tools installed + When 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 + features: + allow_xenial_fips_on_cloud: true + """ + And I run `ua auto-attach` with sudo + And I run `ua status --wait` as non-root + And I run `ua status` as non-root + Then stdout matches regexp: + """ + esm-apps +yes +enabled +UA Apps: Extended Security Maintenance \(ESM\) + esm-infra +yes +enabled +UA Infra: Extended Security Maintenance \(ESM\) + fips +yes +enabled +NIST-certified core packages + fips-updates +yes +disabled +NIST-certified core packages with priority security updates + livepatch +yes +n/a +Canonical Livepatch service + """ + And I verify that running `apt update` `with sudo` exits `0` + And I verify that running `grep Traceback /var/log/ubuntu-advantage.log` `with sudo` exits `1` + And I verify that `openssh-server` is installed from apt source `` + And I verify that `openssh-client` is installed from apt source `` + And I verify that `strongswan` is installed from apt source `` + And I verify that `openssh-server-hmac` is installed from apt source `` + And I verify that `openssh-client-hmac` is installed from apt source `` + And I verify that `strongswan-hmac` is installed from apt source `` + When I run `uname -r` as non-root + Then stdout matches regexp: + """ + + """ + When I run `apt-cache policy ubuntu-azure-fips` as non-root + Then stdout does not match regexp: + """ + .*Installed: \(none\) + """ + When I run `cat /proc/sys/crypto/fips_enabled` with sudo + Then I will see the following on stdout: + """ + 1 + """ + When I run `systemctl start ua-auto-attach.service` with sudo + And I verify that running `systemctl status ua-auto-attach.service` `as non-root` exits `0,3` + Then stdout matches regexp: + """ + .*status=0\/SUCCESS.* + """ + And stdout matches regexp: + """ + Skipping attach: Instance '[0-9a-z\-]+' is already attached. + """ + When I run `ua auto-attach` with sudo + Then stderr matches regexp: + """ + Skipping attach: Instance '[0-9a-z\-]+' is already attached. + """ + When I run `apt-cache policy` with sudo + Then apt-cache policy for the following url has permission `500` + """ + https://esm.ubuntu.com/infra/ubuntu -infra-updates/main amd64 Packages + """ + And apt-cache policy for the following url has permission `500` + """ + https://esm.ubuntu.com/infra/ubuntu -infra-security/main amd64 Packages + """ + And apt-cache policy for the following url has permission `500` + """ + https://esm.ubuntu.com/apps/ubuntu -apps-updates/main amd64 Packages + """ + And apt-cache policy for the following url has permission `500` + """ + https://esm.ubuntu.com/apps/ubuntu -apps-security/main amd64 Packages + """ + And I verify that running `apt update` `with sudo` exits `0` + When I run `apt install -y /-infra-security` with sudo, retrying exit [100] + And I run `apt-cache policy ` as non-root + Then stdout matches regexp: + """ + \s*500 https://esm.ubuntu.com/infra/ubuntu -infra-security/main amd64 Packages + \s*500 https://esm.ubuntu.com/infra/ubuntu -infra-updates/main amd64 Packages + """ + And stdout matches regexp: + """ + Installed: .*[~+]esm + """ + When I run `apt install -y /-apps-security` with sudo, retrying exit [100] + And I run `apt-cache policy ` as non-root + Then stdout matches regexp: + """ + Version table: + \s*\*\*\* .* 500 + \s*500 https://esm.ubuntu.com/apps/ubuntu -apps-security/main amd64 Packages + """ + + Examples: ubuntu release + | release | infra-pkg | apps-pkg | fips-apt-source | fips-kernel-version | + | xenial | libkrad0 | jq | https://esm.ubuntu.com/fips/ubuntu xenial/main | fips | + | bionic | libkrad0 | bundler | https://esm.ubuntu.com/fips/ubuntu bionic/main | azure-fips | + + @series.lts + @uses.config.machine_type.aws.pro.fips + Scenario Outline: Check fips is enabled correctly on Ubuntu pro fips AWS machine + Given a `` machine with ubuntu-advantage-tools installed + When 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 run `ua auto-attach` with sudo + And I run `ua status --wait` as non-root + And I run `ua status` as non-root + Then stdout matches regexp: + """ + esm-apps +yes +enabled +UA Apps: Extended Security Maintenance \(ESM\) + esm-infra +yes +enabled +UA Infra: Extended Security Maintenance \(ESM\) + fips +yes +enabled +NIST-certified core packages + fips-updates +yes +disabled +NIST-certified core packages with priority security updates + livepatch +yes +n/a +Canonical Livepatch service + """ + And I verify that running `apt update` `with sudo` exits `0` + And I verify that running `grep Traceback /var/log/ubuntu-advantage.log` `with sudo` exits `1` + And I verify that `openssh-server` is installed from apt source `` + And I verify that `openssh-client` is installed from apt source `` + And I verify that `strongswan` is installed from apt source `` + And I verify that `openssh-server-hmac` is installed from apt source `` + And I verify that `openssh-client-hmac` is installed from apt source `` + And I verify that `strongswan-hmac` is installed from apt source `` + When I run `uname -r` as non-root + Then stdout matches regexp: + """ + + """ + When I run `apt-cache policy ubuntu-aws-fips` as non-root + Then stdout does not match regexp: + """ + .*Installed: \(none\) + """ + When I run `cat /proc/sys/crypto/fips_enabled` with sudo + Then I will see the following on stdout: + """ + 1 + """ + When I run `systemctl start ua-auto-attach.service` with sudo + And I verify that running `systemctl status ua-auto-attach.service` `as non-root` exits `0,3` + Then stdout matches regexp: + """ + .*status=0\/SUCCESS.* + """ + And stdout matches regexp: + """ + Skipping attach: Instance '[0-9a-z\-]+' is already attached. + """ + When I run `ua auto-attach` with sudo + Then stderr matches regexp: + """ + Skipping attach: Instance '[0-9a-z\-]+' is already attached. + """ + When I run `apt-cache policy` with sudo + Then apt-cache policy for the following url has permission `500` + """ + https://esm.ubuntu.com/infra/ubuntu -infra-updates/main amd64 Packages + """ + And apt-cache policy for the following url has permission `500` + """ + https://esm.ubuntu.com/infra/ubuntu -infra-security/main amd64 Packages + """ + And apt-cache policy for the following url has permission `500` + """ + https://esm.ubuntu.com/apps/ubuntu -apps-updates/main amd64 Packages + """ + And apt-cache policy for the following url has permission `500` + """ + https://esm.ubuntu.com/apps/ubuntu -apps-security/main amd64 Packages + """ + And I verify that running `apt update` `with sudo` exits `0` + When I run `apt install -y /-infra-security` with sudo, retrying exit [100] + And I run `apt-cache policy ` as non-root + Then stdout matches regexp: + """ + \s*500 https://esm.ubuntu.com/infra/ubuntu -infra-security/main amd64 Packages + \s*500 https://esm.ubuntu.com/infra/ubuntu -infra-updates/main amd64 Packages + """ + And stdout matches regexp: + """ + Installed: .*[~+]esm + """ + When I run `apt install -y /-apps-security` with sudo, retrying exit [100] + And I run `apt-cache policy ` as non-root + Then stdout matches regexp: + """ + Version table: + \s*\*\*\* .* 500 + \s*500 https://esm.ubuntu.com/apps/ubuntu -apps-security/main amd64 Packages + """ + + Examples: ubuntu release + | release | infra-pkg | apps-pkg | fips-apt-source | fips-kernel-version | + | xenial | libkrad0 | jq | https://esm.ubuntu.com/fips/ubuntu xenial/main | fips | + | bionic | libkrad0 | bundler | https://esm.ubuntu.com/fips/ubuntu bionic/main | aws-fips | + diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/ubuntu_upgrade.feature ubuntu-advantage-tools-27.7~16.04.1/features/ubuntu_upgrade.feature --- ubuntu-advantage-tools-27.6~16.04.1/features/ubuntu_upgrade.feature 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/ubuntu_upgrade.feature 2022-03-10 17:17:29.000000000 +0000 @@ -3,7 +3,6 @@ @slow @series.focal - @series.hirsute @series.impish @uses.config.machine_type.lxd.container @upgrade @@ -11,6 +10,8 @@ Given a `` machine with ubuntu-advantage-tools installed When I attach `contract_token` with sudo And I run `apt-get dist-upgrade --assume-yes` with sudo + # Some packages upgrade may require a reboot + And I reboot the `` machine And I create the file `/etc/update-manager/release-upgrades.d/ua-test.cfg` with the following """ [Sources] @@ -41,8 +42,7 @@ Examples: ubuntu release | release | next_release | devel_release | - | focal | hirsute | | - | hirsute | impish | | + | focal | impish | | | impish | jammy | --devel-release | @slow diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/ubuntu_upgrade_unattached.feature ubuntu-advantage-tools-27.7~16.04.1/features/ubuntu_upgrade_unattached.feature --- ubuntu-advantage-tools-27.6~16.04.1/features/ubuntu_upgrade_unattached.feature 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/ubuntu_upgrade_unattached.feature 2022-03-10 17:17:29.000000000 +0000 @@ -3,13 +3,14 @@ @slow @series.focal - @series.hirsute @series.impish @uses.config.machine_type.lxd.container @upgrade Scenario Outline: Unattached upgrade across releases Given a `` machine with ubuntu-advantage-tools installed When I run `apt-get dist-upgrade --assume-yes` with sudo + # Some packages upgrade may require a reboot + And I reboot the `` machine And I create the file `/etc/update-manager/release-upgrades.d/ua-test.cfg` with the following """ [Sources] @@ -40,8 +41,7 @@ Examples: ubuntu release | release | next_release | devel_release | - | focal | hirsute | | - | hirsute | impish | | + | focal | impish | | | impish | jammy | --devel-release | @slow diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/unattached_commands.feature ubuntu-advantage-tools-27.7~16.04.1/features/unattached_commands.feature --- ubuntu-advantage-tools-27.6~16.04.1/features/unattached_commands.feature 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/unattached_commands.feature 2022-03-10 17:17:29.000000000 +0000 @@ -27,7 +27,6 @@ | bionic | | focal | | xenial | - | hirsute | | impish | | jammy | @@ -131,8 +130,6 @@ | focal | refresh | | xenial | detach | | xenial | refresh | - | hirsute | detach | - | hirsute | refresh | | impish | detach | | impish | refresh | | jammy | detach | @@ -169,10 +166,6 @@ | xenial | disable | livepatch | | xenial | enable | unknown | | xenial | disable | unknown | - | hirsute | enable | livepatch | - | hirsute | disable | livepatch | - | hirsute | enable | unknown | - | hirsute | disable | unknown | | impish | enable | livepatch | | impish | disable | livepatch | | impish | enable | unknown | @@ -220,7 +213,6 @@ | bionic | yes | | focal | yes | | xenial | yes | - | hirsute | no | | impish | no | | jammy | no | @@ -251,7 +243,6 @@ | xenial | | bionic | | focal | - | hirsute | | impish | | jammy | @@ -288,7 +279,7 @@ USN-4539-1: AWL vulnerability Found CVEs: https://ubuntu.com/security/CVE-2020-11728 - 1 affected package is installed: awl + 1 affected source package is installed: awl \(1/1\) awl: A fix is available in Ubuntu standard updates. .*\{ apt update && apt install --only-upgrade -y libawl-php \}.* @@ -299,7 +290,7 @@ """ CVE-2020-28196: Kerberos vulnerability https://ubuntu.com/security/CVE-2020-28196 - 1 affected package is installed: krb5 + 1 affected source package is installed: krb5 \(1/1\) krb5: A fix is available in Ubuntu standard updates. The update is already installed. @@ -323,7 +314,7 @@ USN-4539-1: AWL vulnerability Found CVEs: https://ubuntu.com/security/CVE-2020-11728 - 1 affected package is installed: awl + 1 affected source package is installed: awl \(1/1\) awl: Sorry, no fix is available. 1 package is still affected: awl @@ -334,7 +325,7 @@ """ CVE-2020-15180: MariaDB vulnerabilities https://ubuntu.com/security/CVE-2020-15180 - No affected packages are installed. + No affected source packages are installed. .*✔.* CVE-2020-15180 does not affect your system. """ When I run `ua fix CVE-2020-28196` as non-root @@ -342,7 +333,7 @@ """ CVE-2020-28196: Kerberos vulnerability https://ubuntu.com/security/CVE-2020-28196 - 1 affected package is installed: krb5 + 1 affected source package is installed: krb5 \(1/1\) krb5: A fix is available in Ubuntu standard updates. The update is already installed. @@ -354,7 +345,7 @@ """ CVE-2017-9233: Expat vulnerability https://ubuntu.com/security/CVE-2017-9233 - 3 affected packages are installed: expat, matanza, swish-e + 3 affected source packages are installed: expat, matanza, swish-e \(1/3, 2/3\) matanza, swish-e: Sorry, no fix is available. \(3/3\) expat: @@ -363,6 +354,28 @@ 2 packages are still affected: matanza, swish-e .*✘.* CVE-2017-9233 is not resolved. """ + When I fix `USN-5079-2` by attaching to a subscription with `contract_token_staging_expired` + Then stdout matches regexp + """ + USN-5079-2: curl vulnerabilities + Found CVEs: + https://ubuntu.com/security/CVE-2021-22946 + https://ubuntu.com/security/CVE-2021-22947 + 1 affected source package is installed: curl + \(1/1\) curl: + A fix is available in UA Infra. + The update is not installed because this system is not attached to a + subscription. + + Choose: \[S\]ubscribe at ubuntu.com \[A\]ttach existing token \[C\]ancel + > Enter your token \(from https://ubuntu.com/advantage\) to attach this system: + > .*\{ ua attach .*\}.* + Attach denied: + Contract ".*" expired on .* + Visit https://ubuntu.com/advantage to manage contract tokens. + 1 package is still affected: curl + .*✘.* USN-5079-2 is not resolved. + """ When I fix `USN-5079-2` by attaching to a subscription with `contract_token` Then stdout matches regexp: """ @@ -370,7 +383,7 @@ Found CVEs: https://ubuntu.com/security/CVE-2021-22946 https://ubuntu.com/security/CVE-2021-22947 - 1 affected package is installed: curl + 1 affected source package is installed: curl \(1/1\) curl: A fix is available in UA Infra. The update is not installed because this system is not attached to a @@ -395,7 +408,7 @@ USN-5051-2: OpenSSL vulnerability Found CVEs: https://ubuntu.com/security/CVE-2021-3712 - 1 affected package is installed: openssl + 1 affected source package is installed: openssl \(1/1\) openssl: A fix is available in UA Infra. .*\{ apt update && apt install --only-upgrade -y libssl1.0.0 openssl \}.* @@ -440,7 +453,7 @@ USN-4539-1: AWL vulnerability Found CVEs: https://ubuntu.com/security/CVE-2020-11728 - 1 affected package is installed: awl + 1 affected source package is installed: awl \(1/1\) awl: Ubuntu security engineers are investigating this issue. 1 package is still affected: awl @@ -451,7 +464,7 @@ """ CVE-2020-28196: Kerberos vulnerability https://ubuntu.com/security/CVE-2020-28196 - 1 affected package is installed: krb5 + 1 affected source package is installed: krb5 \(1/1\) krb5: A fix is available in Ubuntu standard updates. The update is already installed. @@ -463,7 +476,7 @@ """ CVE-2021-27135: xterm vulnerability https://ubuntu.com/security/CVE-2021-27135 - 1 affected package is installed: xterm + 1 affected source package is installed: xterm \(1/1\) xterm: A fix is available in Ubuntu standard updates. Package fixes cannot be installed. @@ -476,7 +489,7 @@ """ CVE-2021-27135: xterm vulnerability https://ubuntu.com/security/CVE-2021-27135 - 1 affected package is installed: xterm + 1 affected source package is installed: xterm \(1/1\) xterm: A fix is available in Ubuntu standard updates. .*\{ apt update && apt install --only-upgrade -y xterm \}.* @@ -487,12 +500,26 @@ """ CVE-2021-27135: xterm vulnerability https://ubuntu.com/security/CVE-2021-27135 - 1 affected package is installed: xterm + 1 affected source package is installed: xterm \(1/1\) xterm: A fix is available in Ubuntu standard updates. The update is already installed. .*✔.* CVE-2021-27135 is resolved. """ + When I run `apt-get install libbz2-1.0=1.0.6-8.1 -y --allow-downgrades` with sudo + And I run `apt-get install bzip2=1.0.6-8.1 -y` with sudo + And I run `ua fix USN-4038-3` with sudo + Then stdout matches regexp: + """ + USN-4038-3: bzip2 regression + Found Launchpad bugs: + https://launchpad.net/bugs/1834494 + 1 affected source package is installed: bzip2 + \(1/1\) bzip2: + A fix is available in Ubuntu standard updates. + .*\{ apt update && apt install --only-upgrade -y bzip2 libbz2-1.0 \}.* + .*✔.* USN-4038-3 is resolved. + """ @series.all @@ -535,6 +562,31 @@ | release | | bionic | | focal | - | hirsute | + | impish | + | jammy | + + @series.all + @uses.config.machine_type.lxd.container + Scenario Outline: Unattached enable fails in a ubuntu machine + Given a `` machine with ubuntu-advantage-tools installed + When I verify that running `ua enable esm-infra` `with sudo` exits `1` + Then I will see the following on stderr: + """ + To use 'esm-infra' you need an Ubuntu Advantage subscription + Personal and community subscriptions are available at no charge + See https://ubuntu.com/advantage + """ + When I verify that running `ua enable esm-infra --format json --assume-yes` `with sudo` exits `1` + Then stdout is a json matching the `ua_operation` schema + And I will see the following on stdout: + """ + {"_schema_version": "0.1", "errors": [{"message": "To use 'esm-infra' you need an Ubuntu Advantage subscription\nPersonal and community subscriptions are available at no charge\nSee https://ubuntu.com/advantage", "message_code": "enable-failure-unattached", "service": null, "type": "system"}], "failed_services": [], "needs_reboot": false, "processed_services": [], "result": "failure", "warnings": []} + """ + + Examples: ubuntu release + | release | + | xenial | + | bionic | + | focal | | impish | | jammy | diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/unattached_status.feature ubuntu-advantage-tools-27.7~16.04.1/features/unattached_status.feature --- ubuntu-advantage-tools-27.6~16.04.1/features/unattached_status.feature 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/unattached_status.feature 2022-03-10 17:17:29.000000000 +0000 @@ -5,18 +5,31 @@ Scenario Outline: Unattached status in a ubuntu machine - formatted Given a `` machine with ubuntu-advantage-tools installed When I run `ua status --format json` as non-root - Then stdout is formatted as `json` and has keys: - """ - _doc _schema_version account attached config config_path contract effective - environment_vars execution_details execution_status expires machine_id notices - services version simulated - """ + Then stdout is a json matching the `ua_status` schema When I run `ua status --format yaml` as non-root - Then stdout is formatted as `yaml` and has keys: - """ - _doc _schema_version account attached config config_path contract effective - environment_vars execution_details execution_status expires machine_id notices - services version simulated + Then stdout is a yaml matching the `ua_status` schema + When I run `sed -i 's/contracts.can/invalidurl.notcan/' /etc/ubuntu-advantage/uaclient.conf` with sudo + And I verify that running `ua status --format json` `as non-root` exits `1` + Then stdout is a json matching the `ua_status` schema + And I will see the following on stdout: + """ + {"environment_vars": [], "errors": [{"message": "Failed to connect to authentication server\nCheck your Internet connection and try again.", "message_code": "connectivity-error", "service": null, "type": "system"}], "result": "failure", "services": [], "warnings": []} + """ + And I verify that running `ua status --format yaml` `as non-root` exits `1` + Then stdout is a yaml matching the `ua_status` schema + And I will see the following on stdout: + """ + environment_vars: [] + errors: + - message: 'Failed to connect to authentication server + + Check your Internet connection and try again.' + message_code: connectivity-error + service: null + type: system + result: failure + services: [] + warnings: [] """ Examples: ubuntu release @@ -24,7 +37,6 @@ | bionic | | focal | | xenial | - | hirsute | | impish | | jammy | @@ -32,7 +44,6 @@ @uses.config.machine_type.lxd.container Scenario Outline: Unattached status in a ubuntu machine Given a `` machine with ubuntu-advantage-tools installed - When I run `sed -i 's/contracts.can/contracts.staging.can/' /etc/ubuntu-advantage/uaclient.conf` with sudo When I run `ua status` as non-root Then stdout matches regexp: """ @@ -128,63 +139,104 @@ | xenial | yes | yes | cis | yes | yes | yes | yes | yes | | | bionic | yes | yes | cis | yes | yes | yes | yes | yes | | | focal | yes | no | | yes | yes | yes | no | yes | usg | - | hirsute | no | no | cis | no | no | no | no | no | | | impish | no | no | cis | no | no | no | no | no | | | jammy | no | no | cis | no | no | no | no | no | | @series.all @uses.config.machine_type.lxd.container @uses.config.contract_token + @uses.config.contract_token_staging_expired Scenario Outline: Simulate status in a ubuntu machine Given a `` machine with ubuntu-advantage-tools installed When I do a preflight check for `contract_token` without the all flag Then stdout matches regexp: - """ - SERVICE AVAILABLE ENTITLED AUTO_ENABLED DESCRIPTION - cc-eal +yes +no +Common Criteria EAL2 Provisioning Packages - ?( + +yes +no +Security compliance and audit tools)? - ?esm-infra +yes +yes +UA Infra: Extended Security Maintenance \(ESM\) - fips +yes +no +NIST-certified core packages - fips-updates +yes +no +NIST-certified core packages with priority security updates - livepatch +yes +yes +Canonical Livepatch service - ?( + +yes +no +Security compliance and audit tools)? - """ + """ + SERVICE AVAILABLE ENTITLED AUTO_ENABLED DESCRIPTION + cc-eal +yes +no +Common Criteria EAL2 Provisioning Packages + ?( + +yes +no +Security compliance and audit tools)? + ?esm-infra +yes +yes +UA Infra: Extended Security Maintenance \(ESM\) + fips +yes +no +NIST-certified core packages + fips-updates +yes +no +NIST-certified core packages with priority security updates + livepatch +yes +yes +Canonical Livepatch service + ?( + +yes +no +Security compliance and audit tools)? + """ When I do a preflight check for `contract_token` with the all flag Then stdout matches regexp: - """ - SERVICE AVAILABLE ENTITLED AUTO_ENABLED DESCRIPTION - cc-eal +yes +no +Common Criteria EAL2 Provisioning Packages - ?( + +yes +no +Security compliance and audit tools)? - ?esm-apps +yes +yes +UA Apps: Extended Security Maintenance \(ESM\) - esm-infra +yes +yes +UA Infra: Extended Security Maintenance \(ESM\) - fips +yes +no +NIST-certified core packages - fips-updates +yes +no +NIST-certified core packages with priority security updates - livepatch +yes +yes +Canonical Livepatch service - ros +yes +no +Security Updates for the Robot Operating System - ros-updates +yes +no +All Updates for the Robot Operating System - ?( + +yes +no +Security compliance and audit tools)? - """ + """ + SERVICE AVAILABLE ENTITLED AUTO_ENABLED DESCRIPTION + cc-eal +yes +no +Common Criteria EAL2 Provisioning Packages + ?( + +yes +no +Security compliance and audit tools)? + ?esm-apps +yes +yes +UA Apps: Extended Security Maintenance \(ESM\) + esm-infra +yes +yes +UA Infra: Extended Security Maintenance \(ESM\) + fips +yes +no +NIST-certified core packages + fips-updates +yes +no +NIST-certified core packages with priority security updates + livepatch +yes +yes +Canonical Livepatch service + ros +yes +no +Security Updates for the Robot Operating System + ros-updates +yes +no +All Updates for the Robot Operating System + ?( + +yes +no +Security compliance and audit tools)? + """ When I do a preflight check for `contract_token` formatted as json - Then stdout is formatted as `json` and has keys: - """ - _doc _schema_version account attached config config_path contract effective - environment_vars execution_details execution_status expires machine_id notices - services version simulated - """ + Then stdout is a json matching the `ua_status` schema When I do a preflight check for `contract_token` formatted as yaml - Then stdout is formatted as `yaml` and has keys: - """ - _doc _schema_version account attached config config_path contract effective - environment_vars execution_details execution_status expires machine_id notices - services version simulated - """ + Then stdout is a yaml matching the `ua_status` schema + When I verify that a preflight check for `invalid_token` formatted as json exits 1 + Then stdout is a json matching the `ua_status` schema + And I will see the following on stdout: + """ + {"environment_vars": [], "errors": [{"message": "Invalid token. See https://ubuntu.com/advantage", "message_code": "attach-invalid-token", "service": null, "type": "system"}], "result": "failure", "services": [], "warnings": []} + """ + When I verify that a preflight check for `invalid_token` formatted as yaml exits 1 + Then stdout is a yaml matching the `ua_status` schema + And I will see the following on stdout: + """ + environment_vars: [] + errors: + - message: Invalid token. See https://ubuntu.com/advantage + message_code: attach-invalid-token + service: null + type: system + result: failure + services: [] + warnings: [] + """ + When I run `sed -i 's/contracts.can/contracts.staging.can/' /etc/ubuntu-advantage/uaclient.conf` with sudo + And I verify that a preflight check for `contract_token_staging_expired` formatted as json exits 1 + Then stdout is a json matching the `ua_status` schema + And stdout matches regexp: + """ + \"result\": \"failure\" + """ + And stdout matches regexp: + """ + \"message\": \"Contract .* expired on .*\" + """ + When I verify that a preflight check for `contract_token_staging_expired` formatted as yaml exits 1 + Then stdout is a yaml matching the `ua_status` schema + Then stdout matches regexp: + """ + errors: + - message: Contract .* expired on .* + """ + When I verify that a preflight check for `contract_token_staging_expired` without the all flag exits 1 + Then stdout matches regexp: + """ + This token is not valid. + Contract \".*\" expired on .* + SERVICE AVAILABLE ENTITLED AUTO_ENABLED DESCRIPTION + cc-eal +yes +no +Common Criteria EAL2 Provisioning Packages + ?( + +yes +no +Security compliance and audit tools)? + ?esm-infra +yes +yes +UA Infra: Extended Security Maintenance \(ESM\) + fips +yes +no +NIST-certified core packages + fips-updates +yes +no +NIST-certified core packages with priority security updates + livepatch +yes +yes +Canonical Livepatch service + ?( + +yes +no +Security compliance and audit tools)? + """ Examples: ubuntu release | release | esm-apps | cc-eal | cis | cis-available | fips | esm-infra | ros | livepatch | usg | | xenial | yes | yes | cis | yes | yes | yes | yes | yes | | | bionic | yes | yes | cis | yes | yes | yes | yes | yes | | | focal | yes | no | | yes | yes | yes | no | yes | usg | - | hirsute | no | no | cis | no | no | no | no | no | | | impish | no | no | cis | no | no | no | no | no | | | jammy | no | no | cis | no | no | no | no | no | | diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/util.py ubuntu-advantage-tools-27.7~16.04.1/features/util.py --- ubuntu-advantage-tools-27.6~16.04.1/features/util.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/util.py 2022-03-10 17:17:29.000000000 +0000 @@ -325,3 +325,10 @@ finally: print() dot_process.terminate() + + +class SafeLoaderWithoutDatetime(yaml.SafeLoader): + yaml_implicit_resolvers = { + k: [r for r in v if r[0] != "tag:yaml.org,2002:timestamp"] + for k, v in yaml.SafeLoader.yaml_implicit_resolvers.items() + } diff -Nru ubuntu-advantage-tools-27.6~16.04.1/features/_version.feature ubuntu-advantage-tools-27.7~16.04.1/features/_version.feature --- ubuntu-advantage-tools-27.6~16.04.1/features/_version.feature 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/features/_version.feature 2022-03-10 17:17:29.000000000 +0000 @@ -6,8 +6,10 @@ @uses.config.machine_type.lxd.vm @uses.config.machine_type.aws.generic @uses.config.machine_type.aws.pro + @uses.config.machine_type.aws.pro.fips @uses.config.machine_type.azure.generic @uses.config.machine_type.azure.pro + @uses.config.machine_type.azure.pro.fips @uses.config.machine_type.gcp.generic @uses.config.machine_type.gcp.pro Scenario Outline: Check ua version @@ -30,7 +32,6 @@ | xenial | | bionic | | focal | - | hirsute | | impish | | jammy | @@ -58,5 +59,4 @@ | xenial | | bionic | | focal | - | hirsute | | impish | diff -Nru ubuntu-advantage-tools-27.6~16.04.1/integration-requirements.txt ubuntu-advantage-tools-27.7~16.04.1/integration-requirements.txt --- ubuntu-advantage-tools-27.6~16.04.1/integration-requirements.txt 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/integration-requirements.txt 2022-03-10 17:17:29.000000000 +0000 @@ -1,5 +1,6 @@ # Integration testing behave +jsonschema PyHamcrest pycloudlib @ git+https://github.com/canonical/pycloudlib.git@756a2c2de044ca60eaa7cdc76653d23a1339dc0a diff -Nru ubuntu-advantage-tools-27.6~16.04.1/Jenkinsfile ubuntu-advantage-tools-27.7~16.04.1/Jenkinsfile --- ubuntu-advantage-tools-27.6~16.04.1/Jenkinsfile 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/Jenkinsfile 2022-03-10 17:17:29.000000000 +0000 @@ -141,6 +141,27 @@ ''' } } + stage ('Package build: 22.04') { + environment { + BUILD_SERIES = "jammy" + SERIES_VERSION = "22.04" + PKG_VERSION = sh(returnStdout: true, script: "dpkg-parsechangelog --show-field Version").trim() + NEW_PKG_VERSION = "${PKG_VERSION}~${SERIES_VERSION}~${JOB_SUFFIX}" + ARTIFACT_DIR = "${TMPDIR}${BUILD_SERIES}" + } + steps { + sh ''' + set -x + mkdir ${ARTIFACT_DIR} + cp debian/changelog ${WORKSPACE}/debian/changelog-${SERIES_VERSION} + sed -i "s/${PKG_VERSION}/${NEW_PKG_VERSION}/" ${WORKSPACE}/debian/changelog-${SERIES_VERSION} + dpkg-source -l${WORKSPACE}/debian/changelog-${SERIES_VERSION} -b . + sbuild --resolve-alternatives --nolog --verbose --dist=${BUILD_SERIES} --no-run-lintian --append-to-version=~${SERIES_VERSION} ../ubuntu-advantage-tools*${NEW_PKG_VERSION}*dsc + cp ./ubuntu-advantage-tools*${SERIES_VERSION}*.deb ${ARTIFACT_DIR}/ubuntu-advantage-tools-${BUILD_SERIES}.deb + cp ./ubuntu-advantage-pro*${SERIES_VERSION}*.deb ${ARTIFACT_DIR}/ubuntu-advantage-pro-${BUILD_SERIES}.deb + ''' + } + } } } stage ('Integration Tests') { @@ -190,6 +211,21 @@ ''' } } + stage("lxc 22.04") { + environment { + UACLIENT_BEHAVE_DEBS_PATH = "${TMPDIR}jammy/" + UACLIENT_BEHAVE_ARTIFACT_DIR = "artifacts/behave-lxd-22.04" + UACLIENT_BEHAVE_EPHEMERAL_INSTANCE = 1 + UACLIENT_BEHAVE_SNAPSHOT_STRATEGY = 1 + } + steps { + sh ''' + set +x + . $TMPDIR/bin/activate + tox --parallel--safe-build -e behave-lxd-22.04 -- --tags="~slow" + ''' + } + } stage("lxc vm 20.04") { environment { UACLIENT_BEHAVE_DEBS_PATH = "${TMPDIR}focal/" diff -Nru ubuntu-advantage-tools-27.6~16.04.1/lib/reboot_cmds.py ubuntu-advantage-tools-27.7~16.04.1/lib/reboot_cmds.py --- ubuntu-advantage-tools-27.6~16.04.1/lib/reboot_cmds.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/lib/reboot_cmds.py 2022-03-10 17:17:29.000000000 +0000 @@ -18,11 +18,10 @@ import os import sys -from uaclient import config, contract, lock, status +from uaclient import config, contract, exceptions, lock, messages from uaclient.cli import setup_logging from uaclient.entitlements.fips import FIPSEntitlement -from uaclient.exceptions import LockHeldError, UserFacingError -from uaclient.util import ProcessExecutionError, UrlError, subp +from uaclient.util import subp # Retry sleep backoff algorithm if lock is held. # Lock may be held by auto-attach on systems with ubuntu-advantage-pro. @@ -34,7 +33,7 @@ try: out, _ = subp(cmd.split(), capture=True) logging.debug("Successfully executed cmd: {}".format(cmd)) - except ProcessExecutionError as exec_error: + except exceptions.ProcessExecutionError as exec_error: msg = ( "Failed running cmd: {}\n" "Return code: {}\n" @@ -75,7 +74,7 @@ ) try: entitlement.install_packages(cleanup_on_failure=False) - except UserFacingError as e: + except exceptions.UserFacingError as e: logging.error(e.msg) logging.warning( "Failed to install packages at boot: {}".format( @@ -83,22 +82,22 @@ ) ) sys.exit(1) - cfg.remove_notice("", status.MESSAGE_FIPS_REBOOT_REQUIRED) + cfg.remove_notice("", messages.FIPS_SYSTEM_REBOOT_REQUIRED.msg) def refresh_contract(cfg): try: contract.request_updated_contract(cfg) - except UrlError as exc: + except exceptions.UrlError as exc: logging.exception(exc) - logging.warning(status.MESSAGE_REFRESH_CONTRACT_FAILURE) + logging.warning(messages.REFRESH_CONTRACT_FAILURE) sys.exit(1) def process_remaining_deltas(cfg): cmd = "/usr/bin/python3 /usr/lib/ubuntu-advantage/upgrade_lts_contract.py" run_command(cmd=cmd, cfg=cfg) - cfg.remove_notice("", status.MESSAGE_LIVEPATCH_LTS_REBOOT_REQUIRED) + cfg.remove_notice("", messages.LIVEPATCH_LTS_REBOOT_REQUIRED) def process_reboot_operations(cfg): @@ -122,13 +121,13 @@ process_remaining_deltas(cfg) cfg.delete_cache_key("marker-reboot-cmds") - cfg.remove_notice("", status.MESSAGE_REBOOT_SCRIPT_FAILED) + cfg.remove_notice("", messages.REBOOT_SCRIPT_FAILED) logging.debug("Successfully ran all commands on reboot.") except Exception as e: msg = "Failed running commands on reboot." msg += str(e) logging.error(msg) - cfg.add_notice("", status.MESSAGE_REBOOT_SCRIPT_FAILED) + cfg.add_notice("", messages.REBOOT_SCRIPT_FAILED) def main(cfg): @@ -145,7 +144,7 @@ max_retries=MAX_RETRIES_ON_LOCK_HELD, ): process_reboot_operations(cfg=cfg) - except LockHeldError as e: + except exceptions.LockHeldError as e: logging.warning("Lock not released. %s", str(e.msg)) sys.exit(1) diff -Nru ubuntu-advantage-tools-27.6~16.04.1/Makefile ubuntu-advantage-tools-27.7~16.04.1/Makefile --- ubuntu-advantage-tools-27.6~16.04.1/Makefile 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/Makefile 2022-03-10 17:17:29.000000000 +0000 @@ -33,6 +33,7 @@ endif pip install tox pip install tox-pip-version + pip install tox-setuptools-version travis-deb-install: git fetch --unshallow diff -Nru ubuntu-advantage-tools-27.6~16.04.1/README.md ubuntu-advantage-tools-27.7~16.04.1/README.md --- ubuntu-advantage-tools-27.6~16.04.1/README.md 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/README.md 2022-03-10 17:17:29.000000000 +0000 @@ -33,7 +33,7 @@ | Bionic | amd64, arm64, armhf, i386, ppc64el, s390x | Active SRU of all features | | Focal | amd64, arm64, armhf, ppc64el, riscv64, s390x | Active SRU of all features | | Groovy | amd64, arm64, armhf, ppc64el, riscv64, s390x | Last release 27.1 | -| Hirsute | amd64, arm64, armhf, ppc64el, riscv64, s390x | Active SRU of all features | +| Hirsute | amd64, arm64, armhf, ppc64el, riscv64, s390x | Last release 27.5 | | Impish | amd64, arm64, armhf, ppc64el, riscv64, s390x | Active SRU of all features | Note: ppc64el will not have support for APT JSON hook messaging due to insufficient golang packages @@ -107,6 +107,27 @@ * UA client auto-enables any services defined with `obligations:{enableByDefault: true}` +#### Attaching with --attach-config +Running `ua attach` with the `--attach-config` may be better suited to certain scenarios. + +When using `--attach-config` the token must be passed in the file rather than on the command line. This is useful in situations where it is preffered to keep the secret token in a file. + +Optionally, the attach config file can be used to override the services that are automatically enabled as a part of the attach process. + +An attach config file looks like this: +```yaml +token: YOUR_TOKEN_HERE # required +enable_services: # optional list of service names to auto-enable + - esm-infra + - esm-apps + - cis +``` + +And can be passed on the cli like this: +```shell +sudo ua attach --attach-config /path/to/file.yaml +``` + ### Enabling a service Each service controlled by UA client will have a python module in uaclient/entitlements/\*.py which handles setup and teardown of services when @@ -367,9 +388,9 @@ The following tox environments allow for testing focal on EC2: ``` - # To test ubuntu-pro-images on EC2 + # To test ubuntu-pro-images tox -e behave-awspro-20.04 - # To test Canonical cloud images (non-ubuntu-pro) on EC2 + # To test Canonical cloud images (non-ubuntu-pro) tox -e behave-awsgeneric-20.04 ``` @@ -417,9 +438,9 @@ The following tox environments allow for testing focal on Azure: ``` - # To test ubuntu-pro-images on EC2 + # To test ubuntu-pro-images tox -e behave-azurepro-20.04 - # To test Canonical cloud images (non-ubuntu-pro) on EC2 + # To test Canonical cloud images (non-ubuntu-pro) tox -e behave-azuregeneric-20.04 ``` @@ -563,7 +584,7 @@ ## Daily Builds On Launchpad, there is a [daily build recipe](https://code.launchpad.net/~canonical-server/+recipe/ua-client-daily), -which will build the client and place it in the [ua-client-daily PPA](https://code.launchpad.net/~canonical-server/+archive/ubuntu/ua-client-daily). +which will build the client and place it in the [ua-client-daily PPA](https://code.launchpad.net/~ua-client/+archive/ubuntu/daily). ## Remastering custom golden images based on Ubuntu PRO diff -Nru ubuntu-advantage-tools-27.6~16.04.1/RELEASES.md ubuntu-advantage-tools-27.7~16.04.1/RELEASES.md --- ubuntu-advantage-tools-27.6~16.04.1/RELEASES.md 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/RELEASES.md 2022-03-10 17:17:29.000000000 +0000 @@ -226,9 +226,11 @@ lxc exec dev-i -- bash /setup_proposed.sh ``` - e. Once [ubuntu-advantage-tools shows up in the pending_sru page](https://people.canonical.com/~ubuntu-archive/pending-sru.html), perform the [Ubuntu-advantage-client SRU verification steps](https://wiki.ubuntu.com/UbuntuAdvantageToolsUpdates). This typically involves running all behave targets with `UACLIENT_BEHAVE_ENABLE_PROPOSED=1 UACLIENT_BEHAVE_CHECK_VERSION=` and saving the output. + e. With the package in proposed, perform the steps from `I.3` above but use a `~stableppaX` suffix instead of `~rcX` in the version name, and upload to `ppa:ua-client/stable` instead of staging. - f. After all tests have passed, tarball all of the output files and upload them to the SRU bug with a message that looks like this: + f. Once [ubuntu-advantage-tools shows up in the pending_sru page](https://people.canonical.com/~ubuntu-archive/pending-sru.html), perform the [Ubuntu-advantage-client SRU verification steps](https://wiki.ubuntu.com/UbuntuAdvantageToolsUpdates). This typically involves running all behave targets with `UACLIENT_BEHAVE_ENABLE_PROPOSED=1 UACLIENT_BEHAVE_CHECK_VERSION=` and saving the output. + + g. After all tests have passed, tarball all of the output files and upload them to the SRU bug with a message that looks like this: ``` We have run the full ubuntu-advantage-tools integration test suite against the version in -proposed. The results are attached. All tests passed (or call out specific explained failures). @@ -238,33 +240,23 @@ ``` Change the tags on the bug from `verification-needed` to `verification-done` (including the verification tags for each release). - g. For any other related Launchpad bugs that are fixed in this release. Perform the verification steps necessary for those bugs and mark them `verification-done` as needed. This will likely involve following the test steps, but instead of adding the staging PPA, enabling -proposed. + h. For any other related Launchpad bugs that are fixed in this release. Perform the verification steps necessary for those bugs and mark them `verification-done` as needed. This will likely involve following the test steps, but instead of adding the staging PPA, enabling -proposed. - h. Once all SRU bugs are tagged as `verification*-done`, all SRU-bugs should be listed as green in [the pending_sru page](https://people.canonical.com/~ubuntu-archive/pending-sru.html). + i. Once all SRU bugs are tagged as `verification*-done`, all SRU-bugs should be listed as green in [the pending_sru page](https://people.canonical.com/~ubuntu-archive/pending-sru.html). - i. After the pending sru page says that ubuntu-advantage-tools has been in proposed for 7 days, it is now time to ping the [current SRU vanguard](https://wiki.ubuntu.com/StableReleaseUpdates#Publishing) for acceptance of ubuntu-advantage-tools into -updates. + j. After the pending sru page says that ubuntu-advantage-tools has been in proposed for 7 days, it is now time to ping the [current SRU vanguard](https://wiki.ubuntu.com/StableReleaseUpdates#Publishing) for acceptance of ubuntu-advantage-tools into -updates. - j. Ping the Ubuntu Server team member who approved the version in step `II.4` to now upload to the devel release. + k. Ping the Ubuntu Server team member who approved the version in step `II.4` to now upload to the devel release. - k. Check `rmadison ubuntu-advantage-tools` for updated version in devel release + l. Check `rmadison ubuntu-advantage-tools` for updated version in devel release - l. Confirm availability in -updates pocket via `lxc launch ubuntu-daily: dev-i; lxc exec dev-i -- apt update; lxc exec dev-i -- apt-cache policy ubuntu-advantage-tools` + m. Confirm availability in -updates pocket via `lxc launch ubuntu-daily: dev-i; lxc exec dev-i -- apt update; lxc exec dev-i -- apt-cache policy ubuntu-advantage-tools` -### III. Final release to team infrastructure +### III. Github Repository Post-release Update 1. Ensure the version tag is correct on github. The `version` git tag should point to the commit that was released as that version to ubuntu -updates. If changes were made in response to feedback during the release process, the tag may have to be moved. -2. Perform the steps from `I.3` above but use a `~stableppaX` suffix instead of `~rcX` in the version name, and upload to `ppa:ua-client/stable` instead of staging. -3. Bring in any changes that were made to the release branch into `main` via PR (e.g. Changelog edits). - -## Ubuntu PRO Release Process +2. Bring in any changes that were made to the release branch into `main` via PR (e.g. Changelog edits). -Below is the procedure used to release ubuntu-advantage-tools to Ubuntu PRO images: +## Cloud Images Update - 1. [Open Daily PPA copy-package operation](https://code.launchpad.net/~ua-client/+archive/ubuntu/daily/+copy-packages) - 2. Check Xenial, Bionic, Focal, Hirsute, Impish packages - 3. Select Destination PPA: UA Client Premium [~ua-client/ubuntu/staging] - 4. Select Destination series: The same series - 5. Copy options: "Copy existing binaries" - 6. Click Copy packages - 7. Notify Pro Image creators about expected Premium PPA version (patviafore/powersj) - 8. Once new PRO AMIs are publicly available run `./tools/refresh-aws-pro-ids` to update AMIs we test during CI runs +After the release process is finished, CPC must be informed. They will be responsible to update the cloud images using the package from the pockets it was released to (whether it is the `stable` PPA or the`-updates` pocket). diff -Nru ubuntu-advantage-tools-27.6~16.04.1/setup.py ubuntu-advantage-tools-27.7~16.04.1/setup.py --- ubuntu-advantage-tools-27.6~16.04.1/setup.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/setup.py 2022-03-10 17:17:29.000000000 +0000 @@ -5,7 +5,7 @@ import setuptools -from uaclient import defaults, util, version +from uaclient import defaults, version NAME = "ubuntu-advantage-tools" @@ -39,7 +39,7 @@ def _get_data_files(): - data_files = [ + return [ ("/etc/ubuntu-advantage", ["uaclient.conf", "help_data.yaml"]), ("/etc/update-motd.d", glob.glob("update-motd.d/*")), ("/usr/lib/ubuntu-advantage", glob.glob("lib/[!_]*")), @@ -49,16 +49,8 @@ ["release-upgrades.d/ubuntu-advantage-upgrades.cfg"], ), (defaults.CONFIG_DEFAULTS["data_dir"], []), + ("/lib/systemd/system", glob.glob("systemd/*")), ] - rel_major, _rel_minor = util.get_platform_info()["release"].split(".", 1) - if rel_major == "14": - data_files.append( - ("/etc/apt/apt.conf.d", ["apt.conf.d/51ubuntu-advantage-esm"]) - ) - data_files.append(("/etc/init", glob.glob("upstart/*"))) - else: - data_files.append(("/lib/systemd/system", glob.glob("systemd/*"))) - return data_files setuptools.setup( diff -Nru ubuntu-advantage-tools-27.6~16.04.1/sru/release-27.7/test_world_readable_logs.sh ubuntu-advantage-tools-27.7~16.04.1/sru/release-27.7/test_world_readable_logs.sh --- ubuntu-advantage-tools-27.6~16.04.1/sru/release-27.7/test_world_readable_logs.sh 1970-01-01 00:00:00.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/sru/release-27.7/test_world_readable_logs.sh 2022-03-10 17:17:29.000000000 +0000 @@ -0,0 +1,66 @@ +series=$1 +deb=$2 + +set -eE + +GREEN="\e[32m" +RED="\e[31m" +BLUE="\e[36m" +END_COLOR="\e[0m" + +function cleanup { + lxc delete test --force +} + +function on_err { + echo -e "${RED}Test Failed${END_COLOR}" + cleanup + exit 1 +} + +trap on_err ERR + +function print_and_run_cmd { + echo -e "${BLUE}Running:${END_COLOR}" "$@" + echo -e "${BLUE}Output:${END_COLOR}" + lxc exec test -- sh -c "$@" + echo +} + +function explanatory_message { + echo -e "${BLUE}$@${END_COLOR}" +} + +explanatory_message "Starting $series container and updating ubuntu-advantage-tools" +lxc launch ubuntu-daily:$series test >/dev/null 2>&1 +sleep 10 + +explanatory_message "Check that log is not world readable" +print_and_run_cmd "ua version" +print_and_run_cmd "head /var/log/ubuntu-advantage.log" +print_and_run_cmd "find /var/log/ -name ubuntu-advantage.log -perm 0600 | grep -qz ." + +lxc exec test -- apt-get update >/dev/null +explanatory_message "installing new version of ubuntu-advantage-tools from local copy" +lxc file push $deb test/tmp/ua.deb > /dev/null +print_and_run_cmd "dpkg -i /tmp/ua.deb" +print_and_run_cmd "ua version" + +explanatory_message "Check that log files permissions are still the same" +print_and_run_cmd "find /var/log/ -name ubuntu-advantage.log -perm 0600 | grep -qz ." + +explanatory_message "Check that logrotate command will create world readable files" +print_and_run_cmd "logrotate --force /etc/logrotate.d/ubuntu-advantage-tools" +print_and_run_cmd "find /var/log/ -name ubuntu-advantage.log -perm 0644 | grep -qz ." +print_and_run_cmd "find /var/log/ -name ubuntu-advantage.log.1 -perm 0600 | grep -qz ." + +explanatory_message "Check that running logrotate again will stil make world readable files" +# Just to add new entry to the log +print_and_run_cmd "ua version" +print_and_run_cmd "logrotate --force /etc/logrotate.d/ubuntu-advantage-tools" +print_and_run_cmd "find /var/log/ -name ubuntu-advantage.log -perm 0644 | grep -qz ." +print_and_run_cmd "find /var/log/ -name ubuntu-advantage.log.1 -perm 0644 | grep -qz ." +print_and_run_cmd "find /var/log/ -name ubuntu-advantage.log.2.gz -perm 0600 | grep -qz ." + +echo -e "${GREEN}Test Passed${END_COLOR}" +cleanup diff -Nru ubuntu-advantage-tools-27.6~16.04.1/tools/create-lp-release-branches.sh ubuntu-advantage-tools-27.7~16.04.1/tools/create-lp-release-branches.sh --- ubuntu-advantage-tools-27.6~16.04.1/tools/create-lp-release-branches.sh 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/tools/create-lp-release-branches.sh 2022-03-10 17:17:29.000000000 +0000 @@ -32,7 +32,7 @@ set -e fi -for release in xenial bionic focal hirsute impish +for release in xenial bionic focal impish do echo echo $release @@ -48,7 +48,6 @@ xenial) version=${UA_VERSION}~16.04.1;; bionic) version=${UA_VERSION}~18.04.1;; focal) version=${UA_VERSION}~20.04.1;; - hirsute) version=${UA_VERSION}~21.04.1;; impish) version=${UA_VERSION}~21.10.1;; esac dch_cmd=(dch -v ${version} -D ${release} -b "Backport new upstream release: (LP: #${SRU_BUG}) to $release") diff -Nru ubuntu-advantage-tools-27.6~16.04.1/tools/refresh-aws-pro-ids ubuntu-advantage-tools-27.7~16.04.1/tools/refresh-aws-pro-ids --- ubuntu-advantage-tools-27.6~16.04.1/tools/refresh-aws-pro-ids 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/tools/refresh-aws-pro-ids 2022-03-10 17:17:29.000000000 +0000 @@ -3,6 +3,7 @@ import glob import os import re + import yaml from uaclient import util @@ -19,7 +20,7 @@ Create a new pull request @ https://github.com/canonical/ubuntu-advantage-client/pulls """ -EOL_RELEASES = ("trusty", ) # Releases we no longer test +EOL_RELEASES = ("trusty",) # Releases we no longer test def main(): @@ -33,35 +34,45 @@ aws_ids = {} for aws_listing in glob.glob("listing-aws-pro-*"): m = re.match( - r"^listing-aws-pro-(?P\w+).yaml$", aws_listing + r"^listing-aws-pro-(fips-)?(?P\w+).yaml$", aws_listing ) if not m: print("Skipping unexpected listing file name: ", aws_listing) continue elif m.group("release") in EOL_RELEASES: print( - "Skipping release %s. No longer CI on EOL releases" % - m.group("release") + "Skipping release %s. No longer CI on EOL releases" + % m.group("release") ) continue listing = yaml.safe_load(open(aws_listing, "r")) - for md in listing['metadata']: + for md in listing["metadata"]: if md["key"] == "series": release = md["value"] + if "fips" in listing["productID"]: + release = release + "-fips" break - for externalID in listing['externalIDs']: - if externalID['origin'] == 'AWS': + for externalID in listing["externalIDs"]: + if externalID["origin"] == "AWS": # TODO(handle multiple IDs) - [marketplace_id] = externalID['IDs'] + [marketplace_id] = externalID["IDs"] break marketplace_id = marketplace_id.replace(MARKETPLACE_PREFIX, "") out, _err = util.subp( - ["aws", "ec2", "describe-images", "--owners", "aws-marketplace", - "--filters", "Name=product-code,Values={}".format(marketplace_id), - "--query", "sort_by(Images, &CreationDate)[-1].ImageId"] + [ + "aws", + "ec2", + "describe-images", + "--owners", + "aws-marketplace", + "--filters", + "Name=product-code,Values={}".format(marketplace_id), + "--query", + "sort_by(Images, &CreationDate)[-1].ImageId", + ] ) ami_id = out.strip() - aws_ids[release] = ami_id.replace("\"", "") + aws_ids[release] = ami_id.replace('"', "") os.chdir("../..") with open("features/aws-ids.yaml", "w") as stream: diff -Nru ubuntu-advantage-tools-27.6~16.04.1/tools/run-integration-tests.py ubuntu-advantage-tools-27.7~16.04.1/tools/run-integration-tests.py --- ubuntu-advantage-tools-27.6~16.04.1/tools/run-integration-tests.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/tools/run-integration-tests.py 2022-03-10 17:17:29.000000000 +0000 @@ -12,7 +12,6 @@ "xenial": "16.04", "bionic": "18.04", "focal": "20.04", - "hirsute": "21.04", "impish": "21.10", "jammy": "22.04", } @@ -28,11 +27,11 @@ "azurepro": ["xenial", "bionic", "focal"], "awsgeneric": ["xenial", "bionic", "focal"], "awspro": ["xenial", "bionic", "focal"], - "gcpgeneric": ["xenial", "bionic", "focal", "hirsute"], + "gcpgeneric": ["xenial", "bionic", "focal", "impish", "jammy"], "gcppro": ["xenial", "bionic", "focal"], "vm": ["xenial", "bionic", "focal"], - "lxd": ["xenial", "bionic", "focal", "hirsute", "impish", "jammy"], - "upgrade": ["xenial", "bionic", "focal", "hirsute", "impish"], + "lxd": ["xenial", "bionic", "focal", "impish", "jammy"], + "upgrade": ["xenial", "bionic", "focal", "impish"], } diff -Nru ubuntu-advantage-tools-27.6~16.04.1/tox.ini ubuntu-advantage-tools-27.7~16.04.1/tox.ini --- ubuntu-advantage-tools-27.6~16.04.1/tox.ini 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/tox.ini 2022-03-10 17:17:29.000000000 +0000 @@ -1,20 +1,21 @@ [tox] -envlist = py3, flake8, py3-{xenial,bionic}, flake8-{trusty,xenial,bionic}, mypy, black, isort - -[testenv:flake8-trusty] -pip_version = 9.0.2 +envlist = py3, flake8, py3-{xenial,bionic}, flake8-{xenial,bionic}, mypy, black, isort [testenv:flake8-bionic] pip_version = 9.0.2 +setuptools_version = 59.8.0 [testenv:flake8-xenial] pip_version = 9.0.2 +setuptools_version = 59.8.0 [testenv:py3-xenial] pip_version = 9.0.2 +setuptools_version = 59.8.0 [testenv:py3-bionic] pip_version = 9.0.2 +setuptools_version = 59.8.0 [testenv] deps = @@ -35,8 +36,10 @@ setenv = awsgeneric: UACLIENT_BEHAVE_MACHINE_TYPE = aws.generic awspro: UACLIENT_BEHAVE_MACHINE_TYPE = aws.pro + awspro-fips: UACLIENT_BEHAVE_MACHINE_TYPE = aws.pro.fips azuregeneric: UACLIENT_BEHAVE_MACHINE_TYPE = azure.generic azurepro: UACLIENT_BEHAVE_MACHINE_TYPE = azure.pro + azurepro-fips: UACLIENT_BEHAVE_MACHINE_TYPE = azure.pro.fips gcpgeneric: UACLIENT_BEHAVE_MACHINE_TYPE = gcp.generic gcppro: UACLIENT_BEHAVE_MACHINE_TYPE = gcp.pro vm: UACLIENT_BEHAVE_MACHINE_TYPE = lxd.vm @@ -52,16 +55,14 @@ behave-lxd-16.04: behave -v {posargs} --tags="uses.config.machine_type.lxd.container" --tags="series.xenial,series.lts,series.all" --tags="~upgrade" behave-lxd-18.04: behave -v {posargs} --tags="uses.config.machine_type.lxd.container" --tags="series.bionic,series.lts,series.all" --tags="~upgrade" behave-lxd-20.04: behave -v {posargs} --tags="uses.config.machine_type.lxd.container" --tags="series.focal,series.lts,series.all" --tags="~upgrade" - behave-lxd-21.04: behave -v {posargs} --tags="uses.config.machine_type.lxd.container" --tags="series.hirsute,series.all" --tags="~upgrade" behave-lxd-21.10: behave -v {posargs} --tags="uses.config.machine_type.lxd.container" --tags="series.impish,series.all" --tags="~upgrade" - behave-lxd-22.04: behave -v {posargs} --tags="uses.config.machine_type.lxd.container" --tags="series.jammy,series.all" --tags="~upgrade" + behave-lxd-22.04: behave -v {posargs} --tags="uses.config.machine_type.lxd.container" --tags="series.jammy,series.lts,series.all" --tags="~upgrade" behave-vm-16.04: behave -v {posargs} --tags="uses.config.machine_type.lxd.vm" --tags="series.xenial,series.all,series.lts" --tags="~upgrade" behave-vm-18.04: behave -v {posargs} --tags="uses.config.machine_type.lxd.vm" --tags="series.bionic,series.all,series.lts" --tags="~upgrade" behave-vm-20.04: behave -v {posargs} --tags="uses.config.machine_type.lxd.vm" --tags="series.focal,series.all,series.lts" --tags="~upgrade" behave-upgrade-16.04: behave -v {posargs} --tags="upgrade" --tags="series.xenial,series.all" behave-upgrade-18.04: behave -v {posargs} --tags="upgrade" --tags="series.bionic,series.all" behave-upgrade-20.04: behave -v {posargs} --tags="upgrade" --tags="series.focal,series.all" - behave-upgrade-21.04: behave -v {posargs} --tags="upgrade" --tags="series.hirsute,series.all" behave-upgrade-21.10: behave -v {posargs} --tags="upgrade" --tags="series.impish,series.all" behave-awsgeneric-16.04: behave -v {posargs} --tags="uses.config.machine_type.aws.generic" --tags="series.xenial,series.lts,series.all" --tags="~upgrade" behave-awsgeneric-18.04: behave -v {posargs} --tags="uses.config.machine_type.aws.generic" --tags="series.bionic,series.lts,series.all" --tags="~upgrade" @@ -69,16 +70,21 @@ behave-awspro-16.04: behave -v {posargs} --tags="uses.config.machine_type.aws.pro" --tags="series.xenial,series.lts,series.all" behave-awspro-18.04: behave -v {posargs} --tags="uses.config.machine_type.aws.pro" --tags="series.bionic,series.lts,series.all" behave-awspro-20.04: behave -v {posargs} --tags="uses.config.machine_type.aws.pro" --tags="series.focal,series.lts,series.all" + behave-awspro-fips-16.04: behave -v {posargs} --tags="uses.config.machine_type.aws.pro.fips" --tags="series.xenial,series.lts,series.all" + behave-awspro-fips-18.04: behave -v {posargs} --tags="uses.config.machine_type.aws.pro.fips" --tags="series.bionic,series.lts,series.all" behave-azuregeneric-16.04: behave -v {posargs} --tags="uses.config.machine_type.azure.generic" --tags="series.xenial,series.lts,series.all" --tags="~upgrade" behave-azuregeneric-18.04: behave -v {posargs} --tags="uses.config.machine_type.azure.generic" --tags="series.bionic,series.lts,series.all" --tags="~upgrade" behave-azuregeneric-20.04: behave -v {posargs} --tags="uses.config.machine_type.azure.generic" --tags="series.focal,series.lts,series.all" --tags="~upgrade" behave-azurepro-16.04: behave -v {posargs} --tags="uses.config.machine_type.azure.pro" --tags="series.xenial,series.lts,series.all" behave-azurepro-18.04: behave -v {posargs} --tags="uses.config.machine_type.azure.pro" --tags="series.bionic,series.lts,series.all" behave-azurepro-20.04: behave -v {posargs} --tags="uses.config.machine_type.azure.pro" --tags="series.focal,series.lts,series.all" + behave-azurepro-fips-16.04: behave -v {posargs} --tags="uses.config.machine_type.azure.pro.fips" --tags="series.xenial,series.lts,series.all" + behave-azurepro-fips-18.04: behave -v {posargs} --tags="uses.config.machine_type.azure.pro.fips" --tags="series.bionic,series.lts,series.all" behave-gcpgeneric-16.04: behave -v {posargs} --tags="uses.config.machine_type.gcp.generic" --tags="series.xenial,series.lts,series.all" --tags="~upgrade" behave-gcpgeneric-18.04: behave -v {posargs} --tags="uses.config.machine_type.gcp.generic" --tags="series.bionic,series.lts,series.all" --tags="~upgrade" behave-gcpgeneric-20.04: behave -v {posargs} --tags="uses.config.machine_type.gcp.generic" --tags="series.focal,series.lts,series.all" --tags="~upgrade" - behave-gcpgeneric-21.04: behave -v {posargs} --tags="uses.config.machine_type.gcp.generic" --tags="series.hirsute,series.all" --tags="~upgrade" + behave-gcpgeneric-21.10: behave -v {posargs} --tags="uses.config.machine_type.gcp.generic" --tags="series.impish,series.all" --tags="~upgrade" + behave-gcpgeneric-22.04: behave -v {posargs} --tags="uses.config.machine_type.gcp.generic" --tags="series.jammy,series.lts,series.all" --tags="~upgrade" behave-gcppro-16.04: behave -v {posargs} --tags="uses.config.machine_type.gcp.pro" --tags="series.xenial,series.lts,series.all" --tags="~upgrade" behave-gcppro-18.04: behave -v {posargs} --tags="uses.config.machine_type.gcp.pro" --tags="series.bionic,series.lts,series.all" --tags="~upgrade" behave-gcppro-20.04: behave -v {posargs} --tags="uses.config.machine_type.gcp.pro" --tags="series.focal,series.lts,series.all" --tags="~upgrade" diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/actions.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/actions.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/actions.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/actions.py 2022-03-10 17:17:29.000000000 +0000 @@ -1,9 +1,20 @@ import logging +import sys +from typing import Optional # noqa: F401 -from uaclient import clouds, config, contract, exceptions, status, util +from uaclient import ( + clouds, + config, + contract, + entitlements, + event_logger, + exceptions, + messages, +) from uaclient.clouds import identity LOG = logging.getLogger("ua.actions") +event = event_logger.get_event_logger() def attach_with_token( @@ -22,18 +33,20 @@ contract.request_updated_contract( cfg, token, allow_enable=allow_enable ) - except util.UrlError as exc: - with util.disable_log_to_console(): - LOG.exception(exc) + except exceptions.UrlError as exc: cfg.status() # Persist updated status in the event of partial attach update_apt_and_motd_messages(cfg) raise exc except exceptions.UserFacingError as exc: - LOG.warning(exc.msg) + event.info(exc.msg, file_type=sys.stderr) cfg.status() # Persist updated status in the event of partial attach update_apt_and_motd_messages(cfg) raise exc + current_iid = identity.get_instance_id() + if current_iid: + cfg.write_cache("instance-id", current_iid) + update_apt_and_motd_messages(cfg) @@ -53,16 +66,53 @@ tokenResponse = contract_client.request_auto_attach_contract_token( instance=cloud ) - except contract.ContractAPIError as e: + except exceptions.ContractAPIError as e: if e.code and 400 <= e.code < 500: raise exceptions.NonAutoAttachImageError( - status.MESSAGE_UNSUPPORTED_AUTO_ATTACH + messages.UNSUPPORTED_AUTO_ATTACH ) raise e - current_iid = identity.get_instance_id() - if current_iid: - cfg.write_cache("instance-id", current_iid) token = tokenResponse["contractToken"] attach_with_token(cfg, token=token, allow_enable=True) + + +def enable_entitlement_by_name( + cfg: config.UAConfig, + name: str, + *, + assume_yes: bool = False, + allow_beta: bool = False +): + """ + Constructs an entitlement based on the name provided. Passes kwargs onto + the entitlement constructor. + :raise EntitlementNotFoundError: If no entitlement with the given name is + found, then raises this error. + """ + ent_cls = entitlements.entitlement_factory(name) + entitlement = ent_cls( + cfg, assume_yes=assume_yes, allow_beta=allow_beta, called_name=name + ) + return entitlement.enable() + + +def status( + cfg: config.UAConfig, + *, + simulate_with_token: Optional[str] = None, + show_beta: bool = False +): + """ + Construct the current UA status dictionary. + """ + if simulate_with_token: + status, ret = cfg.simulate_status( + token=simulate_with_token, show_beta=show_beta + ) + else: + status = cfg.status(show_beta=show_beta) + ret = 0 + + return status, ret diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/apt.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/apt.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/apt.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/apt.py 2022-03-10 17:17:29.000000000 +0000 @@ -6,7 +6,7 @@ import tempfile from typing import Dict, List, Optional -from uaclient import exceptions, gpg, status, util +from uaclient import event_logger, exceptions, gpg, messages, util APT_HELPER_TIMEOUT = 60.0 # 60 second timeout used for apt-helper call APT_AUTH_COMMENT = " # ubuntu-advantage-tools" @@ -27,6 +27,8 @@ # Hope for an optimal first try. APT_RETRIES = [1.0, 5.0, 10.0] +event = event_logger.get_event_logger() + def assert_valid_apt_credentials(repo_url, username, password): """Validate apt credentials for a PPA. @@ -55,7 +57,7 @@ timeout=APT_HELPER_TIMEOUT, retry_sleeps=APT_RETRIES, ) - except util.ProcessExecutionError as e: + except exceptions.ProcessExecutionError as e: if e.exit_code == 100: stderr = str(e.stderr).lower() if re.search(r"401\s+unauthorized|httperror401", stderr): @@ -80,7 +82,9 @@ ) -def _parse_apt_update_for_invalid_apt_config(apt_error: str) -> str: +def _parse_apt_update_for_invalid_apt_config( + apt_error: str +) -> Optional[messages.NamedMessage]: """Parse apt update errors for invalid apt config in user machine. This functions parses apt update errors regarding the presence of @@ -96,9 +100,9 @@ message. :param apt_error: The apt error string - :return: a string containing the parsed error message. + :return: a NamedMessage containing the error message """ - error_msg = "" + error_msg = None failed_repos = set() for line in apt_error.strip().split("\n"): @@ -115,17 +119,18 @@ failed_repos.add(repo_url_match) if failed_repos: - error_msg += "\n" - error_msg += status.MESSAGE_APT_UPDATE_INVALID_URL_CONFIG.format( - "s" if len(failed_repos) > 1 else "", - "\n".join(sorted(failed_repos)), + error_msg = messages.APT_UPDATE_INVALID_URL_CONFIG.format( + plural="s" if len(failed_repos) > 1 else "", + failed_repos="\n".join(sorted(failed_repos)), ) return error_msg def run_apt_command( - cmd: List[str], error_msg: str, env: Optional[Dict[str, str]] = {} + cmd: List[str], + error_msg: Optional[str] = None, + env: Optional[Dict[str, str]] = {}, ) -> str: """Run an apt command, retrying upon failure APT_RETRIES times. @@ -141,18 +146,66 @@ out, _err = util.subp( cmd, capture=True, retry_sleeps=APT_RETRIES, env=env ) - except util.ProcessExecutionError as e: + except exceptions.ProcessExecutionError as e: if "Could not get lock /var/lib/dpkg/lock" in str(e.stderr): - error_msg += " Another process is running APT." + raise exceptions.APTProcessConflictError() else: """ Treat errors where one of the APT repositories is invalid or unreachable. In that situation, we alert which repository is causing the error """ - error_msg += _parse_apt_update_for_invalid_apt_config(e.stderr) + repo_error_msg = _parse_apt_update_for_invalid_apt_config(e.stderr) + if repo_error_msg: + raise exceptions.APTInvalidRepoError( + error_msg=repo_error_msg.msg + ) + + msg = error_msg if error_msg else str(e) + raise exceptions.UserFacingError(msg) + return out + + +def run_apt_update_command(env: Optional[Dict[str, str]] = {}) -> str: + try: + out = run_apt_command(cmd=["apt-get", "update"], env=env) + except exceptions.APTProcessConflictError: + raise exceptions.APTUpdateProcessConflictError() + except exceptions.APTInvalidRepoError as e: + raise exceptions.APTUpdateInvalidRepoError(repo_msg=e.msg) + except exceptions.UserFacingError as e: + raise exceptions.UserFacingError( + msg=messages.APT_UPDATE_FAILED.msg + "\n" + e.msg, + msg_code=messages.APT_UPDATE_FAILED.name, + ) + + return out + + +def run_apt_install_command( + packages: List[str], + apt_options: Optional[List[str]] = None, + error_msg: Optional[str] = None, + env: Optional[Dict[str, str]] = {}, +) -> str: + if apt_options is None: + apt_options = [] + + try: + out = run_apt_command( + cmd=["apt-get", "install", "--assume-yes"] + + apt_options + + packages, + error_msg=error_msg, + env=env, + ) + except exceptions.APTProcessConflictError: + raise exceptions.APTInstallProcessConflictError(header_msg=error_msg) + except exceptions.APTInvalidRepoError as e: + raise exceptions.APTInstallInvalidRepoError( + repo_msg=e.msg, header_msg=error_msg + ) - raise exceptions.UserFacingError(error_msg) return out @@ -181,7 +234,7 @@ # Does this system have updates suite enabled? updates_enabled = False policy = run_apt_command( - ["apt-cache", "policy"], status.MESSAGE_APT_POLICY_FAILED + ["apt-cache", "policy"], messages.APT_POLICY_FAILED.msg ) for line in policy.splitlines(): # We only care about $suite-updates lines @@ -403,7 +456,7 @@ :return: None """ if http_proxy or https_proxy: - print(status.MESSAGE_SETTING_SERVICE_PROXY.format(service="APT")) + event.info(messages.SETTING_SERVICE_PROXY.format(service="APT")) apt_proxy_config = "" if http_proxy: @@ -413,9 +466,7 @@ proxy_url=https_proxy ) if apt_proxy_config != "": - apt_proxy_config = ( - status.MESSAGE_APT_PROXY_CONFIG_HEADER + apt_proxy_config - ) + apt_proxy_config = messages.APT_PROXY_CONFIG_HEADER + apt_proxy_config if apt_proxy_config == "": util.remove_file(APT_PROXY_CONF_FILE) diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/cli.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/cli.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/cli.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/cli.py 2022-03-10 17:17:29.000000000 +0000 @@ -15,8 +15,7 @@ import textwrap import time from functools import wraps -from typing import Optional # noqa: F401 -from typing import List, Tuple +from typing import List, Optional, Tuple # noqa import yaml @@ -25,9 +24,11 @@ config, contract, entitlements, + event_logger, exceptions, jobs, lock, + messages, security, security_status, ) @@ -35,6 +36,7 @@ from uaclient import util, version from uaclient.clouds import AutoAttachCloudInstance # noqa: F401 from uaclient.clouds import identity +from uaclient.data_types import AttachActionsConfigFile, IncorrectTypeError from uaclient.defaults import ( CLOUD_BUILD_INFO, CONFIG_FIELD_ENVVAR_ALLOWLIST, @@ -82,6 +84,8 @@ "ua-license-check.timer", ) +event = event_logger.get_event_logger() + class UAArgumentParser(argparse.ArgumentParser): def __init__( @@ -128,31 +132,31 @@ resources = contract.get_available_resources(config.UAConfig()) for resource in resources: - ent_cls = entitlements.entitlement_factory(resource["name"]) - if ent_cls: - # Because we don't know the presentation name if unattached - presentation_name = resource.get( - "presentedAs", resource["name"] - ) - if ent_cls.help_doc_url: - url = " ({})".format(ent_cls.help_doc_url) - else: - url = "" - service_info = textwrap.fill( - service_info_tmpl.format( - name=presentation_name, - description=ent_cls.description, - url=url, - ), - width=PRINT_WRAP_WIDTH, - subsequent_indent=" ", - break_long_words=False, - break_on_hyphens=False, - ) - if ent_cls.is_beta: - beta_services_desc.append(service_info) - else: - non_beta_services_desc.append(service_info) + try: + ent_cls = entitlements.entitlement_factory(resource["name"]) + except exceptions.EntitlementNotFoundError: + continue + # Because we don't know the presentation name if unattached + presentation_name = resource.get("presentedAs", resource["name"]) + if ent_cls.help_doc_url: + url = " ({})".format(ent_cls.help_doc_url) + else: + url = "" + service_info = textwrap.fill( + service_info_tmpl.format( + name=presentation_name, + description=ent_cls.description, + url=url, + ), + width=PRINT_WRAP_WIDTH, + subsequent_indent=" ", + break_long_words=False, + break_on_hyphens=False, + ) + if ent_cls.is_beta: + beta_services_desc.append(service_info) + else: + non_beta_services_desc.append(service_info) return (non_beta_services_desc, beta_services_desc) @@ -179,7 +183,25 @@ def new_f(*args, **kwargs): if os.getuid() != 0: raise exceptions.NonRootUserError() - return f(*args, **kwargs) + else: + return f(*args, **kwargs) + + return new_f + + +def verify_json_format_args(f): + """Decorator to verify if correct params are used for json format""" + + @wraps(f) + def new_f(cmd_args, *args, **kwargs): + if not cmd_args: + return f(cmd_args, *args, **kwargs) + + if cmd_args.format == "json" and not cmd_args.assume_yes: + msg = messages.JSON_FORMAT_REQUIRE_ASSUME_YES + raise exceptions.UserFacingError(msg=msg.msg, msg_code=msg.name) + else: + return f(cmd_args, *args, **kwargs) return new_f @@ -352,6 +374,21 @@ dest="auto_enable", help="do not enable any recommended services automatically", ) + parser.add_argument( + "--attach-config", + type=argparse.FileType("r"), + help=( + "use the provided attach config file instead of passing the token" + " on the cli" + ), + ) + parser.add_argument( + "--format", + action="store", + choices=["cli", "json"], + default="cli", + help=("output enable in the specified format (default: cli)"), + ) return parser @@ -389,15 +426,6 @@ choices=("json", "yaml"), required=True, ) - parser.add_argument( - "--beta", - help=( - "Acknowledge that this output is not final and may change in the" - " next version" - ), - action="store_true", - required=True, - ) return parser @@ -465,6 +493,13 @@ action="store_true", help="do not prompt for confirmation before performing the detach", ) + parser.add_argument( + "--format", + action="store", + choices=["cli", "json"], + default="cli", + help=("output enable in the specified format (default: cli)"), + ) return parser @@ -534,6 +569,13 @@ parser.add_argument( "--beta", action="store_true", help="allow beta service to be enabled" ) + parser.add_argument( + "--format", + action="store", + choices=["cli", "json"], + default="cli", + help=("output enable in the specified format (default: cli)"), + ) return parser @@ -561,6 +603,13 @@ action="store_true", help="do not prompt for confirmation before performing the disable", ) + parser.add_argument( + "--format", + action="store", + choices=["cli", "json"], + default="cli", + help=("output disable in the specified format (default: cli)"), + ) return parser @@ -643,7 +692,7 @@ return parser -def _perform_disable(entitlement_name, cfg, *, assume_yes): +def _perform_disable(entitlement, cfg, *, assume_yes, update_status=True): """Perform the disable action on a named entitlement. :param entitlement_name: the name of the entitlement to enable @@ -653,10 +702,27 @@ @return: True on success, False otherwise """ - ent_cls = entitlements.entitlement_factory(entitlement_name) - entitlement = ent_cls(cfg, assume_yes=assume_yes) - ret = entitlement.disable() - cfg.status() # Update the status cache + ret, reason = entitlement.disable() + + if not ret: + event.service_failed(entitlement.name) + + if reason is not None and isinstance( + reason, ua_status.CanDisableFailure + ): + if reason.message is not None: + event.info(reason.message.msg) + event.error( + error_msg=reason.message.msg, + error_code=reason.message.name, + service=entitlement.name, + ) + else: + event.service_processed(entitlement.name) + + if update_status: + cfg.status() # Update the status cache + return ret @@ -709,7 +775,7 @@ raise exceptions.UserFacingError( textwrap.fill( msg, - width=ua_status.PRINT_WRAP_WIDTH, + width=PRINT_WRAP_WIDTH, subsequent_indent=" " * indent_position, ) ) @@ -844,8 +910,9 @@ return 0 +@verify_json_format_args @assert_root -@assert_attached(ua_status.MESSAGE_ENABLE_FAILURE_UNATTACHED_TMPL) +@assert_attached(messages.ENABLE_FAILURE_UNATTACHED) @assert_lock_file("ua disable") def action_disable(args, *, cfg, **kwargs): """Perform the disable action on a list of entitlements. @@ -856,11 +923,13 @@ entitlements_found, entitlements_not_found = get_valid_entitlement_names( names ) - tmpl = ua_status.MESSAGE_INVALID_SERVICE_OP_FAILURE_TMPL ret = True - for entitlement in entitlements_found: - ret &= _perform_disable(entitlement, cfg, assume_yes=args.assume_yes) + for ent_name in entitlements_found: + ent_cls = entitlements.entitlement_factory(ent_name) + ent = ent_cls(cfg, assume_yes=args.assume_yes) + + ret &= _perform_disable(ent, cfg, assume_yes=args.assume_yes) if entitlements_not_found: valid_names = ( @@ -876,50 +945,68 @@ break_on_hyphens=False, ) ) - raise exceptions.UserFacingError( - tmpl.format( - operation="disable", - name=", ".join(entitlements_not_found), - service_msg=service_msg, - ) + raise exceptions.InvalidServiceToDisableError( + operation="disable", + name=", ".join(entitlements_not_found), + service_msg=service_msg, ) + event.process_events() return 0 if ret else 1 +def _create_enable_entitlements_not_found_message( + entitlements_not_found, *, allow_beta: bool +) -> messages.NamedMessage: + """ + Constructs the MESSAGE_INVALID_SERVICE_OP_FAILURE message + based on the attempted services and valid services. + """ + valid_services_names = entitlements.valid_services(allow_beta=allow_beta) + valid_names = ", ".join(valid_services_names) + service_msg = "\n".join( + textwrap.wrap( + "Try " + valid_names + ".", + width=80, + break_long_words=False, + break_on_hyphens=False, + ) + ) + + return messages.INVALID_SERVICE_OP_FAILURE.format( + operation="enable", + name=", ".join(entitlements_not_found), + service_msg=service_msg, + ) + + +@verify_json_format_args @assert_root -@assert_attached(ua_status.MESSAGE_ENABLE_FAILURE_UNATTACHED_TMPL) +@assert_attached(messages.ENABLE_FAILURE_UNATTACHED) @assert_lock_file("ua enable") def action_enable(args, *, cfg, **kwargs): """Perform the enable action on a named entitlement. @return: 0 on success, 1 otherwise """ - print(ua_status.MESSAGE_REFRESH_CONTRACT_ENABLE) + event.info(messages.REFRESH_CONTRACT_ENABLE) try: contract.request_updated_contract(cfg) - except (util.UrlError, exceptions.UserFacingError): + except (exceptions.UrlError, exceptions.UserFacingError): # Inability to refresh is not a critical issue during enable - logging.debug( - ua_status.MESSAGE_REFRESH_CONTRACT_FAILURE, exc_info=True - ) + logging.debug(messages.REFRESH_CONTRACT_FAILURE, exc_info=True) + event.warning(warning_msg=messages.REFRESH_CONTRACT_FAILURE) names = getattr(args, "service", []) entitlements_found, entitlements_not_found = get_valid_entitlement_names( names ) - valid_services_names = entitlements.valid_services(allow_beta=args.beta) ret = True for ent_name in entitlements_found: try: - ent_cls = entitlements.entitlement_factory(ent_name) - entitlement = ent_cls( - cfg, - assume_yes=args.assume_yes, - allow_beta=args.beta, - called_name=ent_name, + ent_ret, reason = actions.enable_entitlement_by_name( + cfg, ent_name, assume_yes=args.assume_yes, allow_beta=args.beta ) - ent_ret, reason = entitlement.enable() cfg.status() # Update the status cache if ( @@ -928,39 +1015,41 @@ and isinstance(reason, ua_status.CanEnableFailure) ): if reason.message is not None: - print(reason.message) + event.info(reason.message.msg) + event.error( + error_msg=reason.message.msg, + error_code=reason.message.name, + service=ent_name, + ) if reason.reason == ua_status.CanEnableFailureReason.IS_BETA: # if we failed because ent is in beta and there was no # allow_beta flag/config, pretend it doesn't exist entitlements_not_found.append(ent_name) + elif ent_ret: + event.service_processed(service=ent_name) + elif not ent_ret and reason is None: + event.service_failed(service=ent_name) ret &= ent_ret except exceptions.UserFacingError as e: - print(e) + event.info(e.msg) + event.error( + error_msg=e.msg, error_code=e.msg_code, service=ent_name + ) ret = False if entitlements_not_found: - valid_names = ", ".join(valid_services_names) - service_msg = "\n".join( - textwrap.wrap( - "Try " + valid_names + ".", - width=80, - break_long_words=False, - break_on_hyphens=False, - ) - ) - tmpl = ua_status.MESSAGE_INVALID_SERVICE_OP_FAILURE_TMPL - raise exceptions.UserFacingError( - tmpl.format( - operation="enable", - name=", ".join(entitlements_not_found), - service_msg=service_msg, - ) + msg = _create_enable_entitlements_not_found_message( + entitlements_not_found, allow_beta=args.beta ) + event.services_failed(entitlements_not_found) + raise exceptions.UserFacingError(msg=msg.msg, msg_code=msg.name) + event.process_events() return 0 if ret else 1 +@verify_json_format_args @assert_root @assert_attached() @assert_lock_file("ua detach") @@ -985,11 +1074,15 @@ to_disable = [] for ent_cls in entitlements.ENTITLEMENT_CLASSES: ent = ent_cls(cfg=cfg, assume_yes=assume_yes) - if ent.can_disable(silent=True): + # For detach, we should not consider that a service + # cannot be disabled because of dependent services, + # since we are going to disable all of them anyway + ret, _ = ent.can_disable(ignore_dependent_services=True) + if ret: to_disable.append(ent) """ - We will nake sure that services without dependencies are disabled first + We will make sure that services without dependencies are disabled first PS: This will only work because we have only three services with reverse dependencies: * ros: ros-updates @@ -1007,40 +1100,47 @@ if to_disable: suffix = "s" if len(to_disable) > 1 else "" - print("Detach will disable the following service{}:".format(suffix)) + event.info( + "Detach will disable the following service{}:".format(suffix) + ) for ent in to_disable: - print(" {}".format(ent.name)) + event.info(" {}".format(ent.name)) if not util.prompt_for_confirmation(assume_yes=assume_yes): return 1 for ent in to_disable: - ent.disable(silent=False) - contract_client = contract.UAContractClient(cfg) - machine_token = cfg.machine_token["machineToken"] - contract_id = cfg.machine_token["machineTokenInfo"]["contractInfo"]["id"] - contract_client.detach_machine_from_contract(machine_token, contract_id) + _perform_disable(ent, cfg, assume_yes=assume_yes, update_status=False) + cfg.delete_cache() jobs.enable_license_check_if_applicable(cfg) update_apt_and_motd_messages(cfg) - print(ua_status.MESSAGE_DETACH_SUCCESS) + event.info(messages.DETACH_SUCCESS) + event.process_events() return 0 def _post_cli_attach(cfg: config.UAConfig) -> None: - contract_name = cfg.machine_token["machineTokenInfo"]["contractInfo"][ - "name" - ] + contract_name = None + + if cfg.machine_token: + contract_name = ( + cfg.machine_token.get("machineTokenInfo", {}) + .get("contractInfo", {}) + .get("name") + ) if contract_name: - print( - ua_status.MESSAGE_ATTACH_SUCCESS_TMPL.format( - contract_name=contract_name - ) + event.info( + messages.ATTACH_SUCCESS_TMPL.format(contract_name=contract_name) ) else: - print(ua_status.MESSAGE_ATTACH_SUCCESS_NO_CONTRACT_NAME) + event.info(messages.ATTACH_SUCCESS_NO_CONTRACT_NAME) jobs.disable_license_check_if_applicable(cfg) - action_status(args=None, cfg=cfg) + + status, _ret = actions.status(cfg) + output = ua_status.format_tabular(status) + event.info(util.handle_unicode_characters(output)) + event.process_events() @assert_root @@ -1064,27 +1164,25 @@ raise exceptions.AlreadyAttachedError(cfg) if isinstance(e, exceptions.CloudFactoryNoCloudError): raise exceptions.UserFacingError( - ua_status.MESSAGE_UNABLE_TO_DETERMINE_CLOUD_TYPE + messages.UNABLE_TO_DETERMINE_CLOUD_TYPE ) if isinstance(e, exceptions.CloudFactoryNonViableCloudError): - raise exceptions.UserFacingError( - ua_status.MESSAGE_UNSUPPORTED_AUTO_ATTACH - ) + raise exceptions.UserFacingError(messages.UNSUPPORTED_AUTO_ATTACH) if isinstance(e, exceptions.CloudFactoryUnsupportedCloudError): raise exceptions.NonAutoAttachImageError( - ua_status.MESSAGE_UNSUPPORTED_AUTO_ATTACH_CLOUD_TYPE.format( + messages.UNSUPPORTED_AUTO_ATTACH_CLOUD_TYPE.format( cloud_type=e.cloud_type ) ) # we shouldn't get here, but this is a reasonable default just in case raise exceptions.UserFacingError( - ua_status.MESSAGE_UNABLE_TO_DETERMINE_CLOUD_TYPE + messages.UNABLE_TO_DETERMINE_CLOUD_TYPE ) if not instance: # we shouldn't get here, but this is a reasonable default just in case raise exceptions.UserFacingError( - ua_status.MESSAGE_UNABLE_TO_DETERMINE_CLOUD_TYPE + messages.UNABLE_TO_DETERMINE_CLOUD_TYPE ) current_iid = identity.get_instance_id() @@ -1095,13 +1193,13 @@ print("Re-attaching Ubuntu Advantage subscription on new instance") if _detach(cfg, assume_yes=True) != 0: raise exceptions.UserFacingError( - ua_status.MESSAGE_DETACH_AUTOMATION_FAILURE + messages.DETACH_AUTOMATION_FAILURE ) try: actions.auto_attach(cfg, instance) - except util.UrlError: - print(ua_status.MESSAGE_ATTACH_FAILURE) + except exceptions.UrlError: + event.info(messages.ATTACH_FAILURE) return 1 except exceptions.UserFacingError: return 1 @@ -1114,22 +1212,83 @@ @assert_root @assert_lock_file("ua attach") def action_attach(args, *, cfg): - if not args.token: + if not args.token and not args.attach_config: raise exceptions.UserFacingError( - ua_status.MESSAGE_ATTACH_REQUIRES_TOKEN + msg=messages.ATTACH_REQUIRES_TOKEN.msg, + msg_code=messages.ATTACH_REQUIRES_TOKEN.name, ) - try: - actions.attach_with_token( - cfg, token=args.token, allow_enable=args.auto_enable + if args.token and args.attach_config: + raise exceptions.UserFacingError( + msg=messages.ATTACH_TOKEN_ARG_XOR_CONFIG.msg, + msg_code=messages.ATTACH_TOKEN_ARG_XOR_CONFIG.name, ) - except util.UrlError: - print(ua_status.MESSAGE_ATTACH_FAILURE) + + if args.token: + token = args.token + enable_services_override = None + else: + try: + attach_config = AttachActionsConfigFile.from_dict( + yaml.safe_load(args.attach_config) + ) + except IncorrectTypeError as e: + raise exceptions.AttachInvalidConfigFileError( + config_name=args.attach_config.name, error=e.msg + ) + + token = attach_config.token + enable_services_override = attach_config.enable_services + + allow_enable = args.auto_enable and enable_services_override is None + + try: + actions.attach_with_token(cfg, token=token, allow_enable=allow_enable) + except exceptions.UrlError: + msg = messages.ATTACH_FAILURE + event.info(msg.msg) + event.error(error_msg=msg.msg, error_code=msg.name) + event.process_events() return 1 - except exceptions.UserFacingError: + except exceptions.UserFacingError as exc: + event.info(exc.msg) + event.error(error_msg=exc.msg, error_code=exc.msg_code) + event.process_events() return 1 else: + ret = 0 + if enable_services_override is not None and args.auto_enable: + found, not_found = get_valid_entitlement_names( + enable_services_override + ) + for name in found: + ent_ret, reason = actions.enable_entitlement_by_name( + cfg, name, assume_yes=True, allow_beta=True + ) + if not ent_ret: + ret = 1 + if ( + reason is not None + and isinstance(reason, ua_status.CanEnableFailure) + and reason.message is not None + ): + event.info(reason.message.msg) + event.error( + error_msg=reason.message.msg, + error_code=reason.message.name, + service=name, + ) + else: + event.service_processed(name) + + if not_found: + msg = _create_enable_entitlements_not_found_message( + not_found, allow_beta=True + ) + event.info(msg.msg, file_type=sys.stderr) + event.error(error_msg=msg.msg, error_code=msg.name) + ret = 1 _post_cli_attach(cfg) - return 0 + return ret def _write_command_output_to_file( @@ -1138,7 +1297,7 @@ """Helper which runs a command and writes output or error to filename.""" try: out, _ = util.subp(cmd.split(), rcs=return_codes) - except util.ProcessExecutionError as e: + except exceptions.ProcessExecutionError as e: util.write_file("{}-error".format(filename), str(e)) else: util.write_file(filename, out) @@ -1326,26 +1485,27 @@ cfg = config.UAConfig() show_beta = args.all if args else False token = args.simulate_with_token if args else None - if token: - status = cfg.simulate_status(token=token, show_beta=show_beta) - else: - status = cfg.status(show_beta=show_beta) active_value = ua_status.UserFacingConfigStatus.ACTIVE.value + + status, ret = actions.status( + cfg, simulate_with_token=token, show_beta=show_beta + ) config_active = bool(status["execution_status"] == active_value) + if args and args.wait and config_active: while status["execution_status"] == active_value: - print(".", end="") + event.info(".", end="") time.sleep(1) - status = cfg.status(show_beta=show_beta) - print("") - if args and args.format == "json": - print(ua_status.format_json_status(status)) - elif args and args.format == "yaml": - print(ua_status.format_yaml_status(status)) - else: - output = ua_status.format_tabular(status) - print(util.handle_unicode_characters(output)) - return 0 + status, ret = actions.status( + cfg, simulate_with_token=token, show_beta=show_beta + ) + event.info("") + + event.set_output_content(status) + output = ua_status.format_tabular(status) + event.info(util.handle_unicode_characters(output)) + event.process_events() + return ret def get_version(_args=None, _cfg=None): @@ -1365,23 +1525,19 @@ except RuntimeError as exc: with util.disable_log_to_console(): logging.exception(exc) - raise exceptions.UserFacingError( - ua_status.MESSAGE_REFRESH_CONFIG_FAILURE - ) - print(ua_status.MESSAGE_REFRESH_CONFIG_SUCCESS) + raise exceptions.UserFacingError(messages.REFRESH_CONFIG_FAILURE) + print(messages.REFRESH_CONFIG_SUCCESS) @assert_attached() def _action_refresh_contract(_args, cfg: config.UAConfig): try: contract.request_updated_contract(cfg) - except util.UrlError as exc: + except exceptions.UrlError as exc: with util.disable_log_to_console(): logging.exception(exc) - raise exceptions.UserFacingError( - ua_status.MESSAGE_REFRESH_CONTRACT_FAILURE - ) - print(ua_status.MESSAGE_REFRESH_CONTRACT_SUCCESS) + raise exceptions.UserFacingError(messages.REFRESH_CONTRACT_FAILURE) + print(messages.REFRESH_CONTRACT_SUCCESS) @assert_root @@ -1444,8 +1600,10 @@ if os.getuid() == 0: # Setup readable-by-root-only debug file logging if running as root log_file_path = pathlib.Path(log_file) - log_file_path.touch() - log_file_path.chmod(0o600) + + if not log_file_path.exists(): + log_file_path.touch() + log_file_path.chmod(0o644) file_handler = logging.FileHandler(log_file) file_handler.setLevel(log_level) @@ -1454,6 +1612,17 @@ logger.addHandler(file_handler) +def set_event_mode(cmd_args): + """Set the right event mode based on the args provided""" + if cmd_args.command in ("attach", "detach", "enable", "disable", "status"): + event.set_command(cmd_args.command) + if hasattr(cmd_args, "format"): + if cmd_args.format == "json": + event.set_event_mode(event_logger.EventLoggerMode.JSON) + if cmd_args.format == "yaml": + event.set_event_mode(event_logger.EventLoggerMode.YAML) + + def main_error_handler(func): def wrapper(*args, **kwargs): try: @@ -1464,40 +1633,53 @@ print("Interrupt received; exiting.", file=sys.stderr) lock.clear_lock_file_if_present() sys.exit(1) - except util.UrlError as exc: + except exceptions.UrlError as exc: if "CERTIFICATE_VERIFY_FAILED" in str(exc): - tmpl = ua_status.MESSAGE_SSL_VERIFICATION_ERROR_CA_CERTIFICATES + tmpl = messages.SSL_VERIFICATION_ERROR_CA_CERTIFICATES if util.is_installed("ca-certificates"): - tmpl = ( - ua_status.MESSAGE_SSL_VERIFICATION_ERROR_OPENSSL_CONFIG - ) - print(tmpl.format(url=exc.url), file=sys.stderr) + tmpl = messages.SSL_VERIFICATION_ERROR_OPENSSL_CONFIG + msg = tmpl.format(url=exc.url) + event.error(error_msg=msg.msg, error_code=msg.name) + event.info(info_msg=msg.msg, file_type=sys.stderr) else: with util.disable_log_to_console(): msg_args = {"url": exc.url, "error": exc} if exc.url: msg_tmpl = ( - ua_status.LOG_CONNECTIVITY_ERROR_WITH_URL_TMPL + messages.LOG_CONNECTIVITY_ERROR_WITH_URL_TMPL ) else: - msg_tmpl = ua_status.LOG_CONNECTIVITY_ERROR_TMPL + msg_tmpl = messages.LOG_CONNECTIVITY_ERROR_TMPL logging.exception(msg_tmpl.format(**msg_args)) - print(ua_status.MESSAGE_CONNECTIVITY_ERROR, file=sys.stderr) + + msg = messages.CONNECTIVITY_ERROR + event.error(error_msg=msg.msg, error_code=msg.name) + event.info(info_msg=msg.msg, file_type=sys.stderr) + lock.clear_lock_file_if_present() + event.process_events() sys.exit(1) except exceptions.UserFacingError as exc: with util.disable_log_to_console(): logging.error(exc.msg) - print("{}".format(exc.msg), file=sys.stderr) + event.error(error_msg=exc.msg, error_code=exc.msg_code) + event.info(info_msg="{}".format(exc.msg), file_type=sys.stderr) if not isinstance(exc, exceptions.LockHeldError): # Only clear the lock if it is ours. lock.clear_lock_file_if_present() + event.process_events() sys.exit(exc.exit_code) - except Exception: + except Exception as e: with util.disable_log_to_console(): logging.exception("Unhandled exception, please file a bug") lock.clear_lock_file_if_present() - print(ua_status.MESSAGE_UNEXPECTED_ERROR, file=sys.stderr) + event.info( + info_msg=messages.UNEXPECTED_ERROR.msg, file_type=sys.stderr + ) + event.error( + error_msg=getattr(e, "msg", str(e)), error_type="exception" + ) + event.process_events() sys.exit(1) return wrapper @@ -1514,6 +1696,7 @@ print("Try 'ua --help' for more information.") sys.exit(1) args = parser.parse_args(args=cli_arguments) + set_event_mode(args) cfg = config.UAConfig() http_proxy = cfg.http_proxy diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/clouds/gcp.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/clouds/gcp.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/clouds/gcp.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/clouds/gcp.py 2022-03-10 17:17:29.000000000 +0000 @@ -20,6 +20,7 @@ "xenial": "8045211386737108299", "bionic": "6022427724719891830", "focal": "599959289349842382", + "jammy": "2592866803419978320", } diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/clouds/identity.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/clouds/identity.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/clouds/identity.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/clouds/identity.py 2022-03-10 17:17:29.000000000 +0000 @@ -30,7 +30,7 @@ # Present in cloud-init on >= Xenial out, _err = util.subp(["cloud-init", "query", "instance_id"]) return out.strip() - except util.ProcessExecutionError: + except exceptions.ProcessExecutionError: pass logging.warning("Unable to determine current instance-id") return None @@ -43,7 +43,7 @@ try: out, _err = util.subp(["cloud-id"]) return (out.strip(), None) - except util.ProcessExecutionError: + except exceptions.ProcessExecutionError: return (None, NoCloudTypeReason.CLOUD_ID_ERROR) # If no cloud-id command, assume not on cloud return (None, NoCloudTypeReason.NO_CLOUD_DETECTED) diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/clouds/tests/test_aws.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/clouds/tests/test_aws.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/clouds/tests/test_aws.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/clouds/tests/test_aws.py 2022-03-10 17:17:29.000000000 +0000 @@ -6,6 +6,7 @@ import mock import pytest +from uaclient import exceptions from uaclient.clouds.aws import ( AWS_TOKEN_PUT_HEADER, AWS_TOKEN_REQ_HEADER, @@ -16,7 +17,6 @@ IMDS_V2_TOKEN_URL, UAAutoAttachAWSInstance, ) -from uaclient.exceptions import UserFacingError M_PATH = "uaclient.clouds.aws." @@ -274,7 +274,9 @@ "No valid AWS IMDS endpoint discovered at " "addresses: {}, {}".format(IMDS_IPV4_ADDRESS, IMDS_IPV6_ADDRESS) ) - with pytest.raises(UserFacingError, match=re.escape(expected_error)): + with pytest.raises( + exceptions.UserFacingError, match=re.escape(expected_error) + ): instance.identity_doc expected = [ diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/clouds/tests/test_identity.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/clouds/tests/test_identity.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/clouds/tests/test_identity.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/clouds/tests/test_identity.py 2022-03-10 17:17:29.000000000 +0000 @@ -1,18 +1,13 @@ import mock import pytest +from uaclient import exceptions from uaclient.clouds.identity import ( NoCloudTypeReason, cloud_instance_factory, get_cloud_type, get_instance_id, ) -from uaclient.exceptions import ( - CloudFactoryNoCloudError, - CloudFactoryNonViableCloudError, - CloudFactoryUnsupportedCloudError, -) -from uaclient.util import ProcessExecutionError M_PATH = "uaclient.clouds.identity." @@ -28,7 +23,9 @@ @mock.patch( M_PATH + "util.subp", - side_effect=ProcessExecutionError("cloud-init query instance_id"), + side_effect=exceptions.ProcessExecutionError( + "cloud-init query instance_id" + ), ) def test_none_when_cloud_init_query_fails(self, m_subp): """Return None when cloud-init query fails.""" @@ -48,7 +45,8 @@ @mock.patch(M_PATH + "util.which", return_value="/usr/bin/cloud-id") @mock.patch( - M_PATH + "util.subp", side_effect=ProcessExecutionError("cloud-id") + M_PATH + "util.subp", + side_effect=exceptions.ProcessExecutionError("cloud-id"), ) def test_error_when_cloud_id_fails(self, m_subp, m_which): assert (None, NoCloudTypeReason.CLOUD_ID_ERROR) == get_cloud_type() @@ -93,14 +91,14 @@ None, NoCloudTypeReason.NO_CLOUD_DETECTED, ) - with pytest.raises(CloudFactoryNoCloudError): + with pytest.raises(exceptions.CloudFactoryNoCloudError): cloud_instance_factory() assert 1 == m_get_cloud_type.call_count def test_raise_error_when_not_supported(self, m_get_cloud_type): """Raise appropriate error when unable to determine cloud_type.""" m_get_cloud_type.return_value = ("unsupported-cloud", None) - with pytest.raises(CloudFactoryUnsupportedCloudError): + with pytest.raises(exceptions.CloudFactoryUnsupportedCloudError): cloud_instance_factory() @pytest.mark.parametrize("cloud_type", ("aws", "azure")) @@ -122,7 +120,7 @@ with mock.patch(M_INSTANCE_PATH) as m_instance: m_instance.side_effect = fake_invalid_instance - with pytest.raises(CloudFactoryNonViableCloudError): + with pytest.raises(exceptions.CloudFactoryNonViableCloudError): cloud_instance_factory() @pytest.mark.parametrize( diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/config.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/config.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/config.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/config.py 2022-03-10 17:17:29.000000000 +0000 @@ -4,14 +4,24 @@ import os import re from collections import OrderedDict, namedtuple -from datetime import datetime +from datetime import datetime, timezone from functools import wraps from typing import Any, Dict, List, Optional, Tuple, cast import yaml -from uaclient import apt, exceptions, snap, status, util, version +from uaclient import ( + apt, + event_logger, + exceptions, + messages, + snap, + status, + util, + version, +) from uaclient.defaults import ( + ATTACH_FAIL_DATE_FORMAT, BASE_CONTRACT_URL, BASE_SECURITY_URL, CONFIG_DEFAULTS, @@ -31,7 +41,7 @@ "origin": None, "services": [], "execution_status": status.UserFacingConfigStatus.INACTIVE.value, - "execution_details": status.MESSAGE_NO_ACTIVE_OPERATIONS, + "execution_details": messages.NO_ACTIVE_OPERATIONS, "notices": [], "contract": { "id": "", @@ -83,12 +93,13 @@ "ua_config", ) - # A data path is a filename, an attribute ("private") indicating whether it # should only be readable by root, and an attribute ("permanent") indicating # whether it should stick around even when detached. DataPath = namedtuple("DataPath", ("filename", "private", "permanent")) +event = event_logger.get_event_logger() + class UAConfig: @@ -237,7 +248,7 @@ try: util.subp(["ps", lock_pid]) return (int(lock_pid), lock_holder) - except util.ProcessExecutionError: + except exceptions.ProcessExecutionError: if os.getuid() != 0: logging.debug( "Found stale lock file previously held by %s:%s", @@ -451,7 +462,7 @@ def parse_machine_token_overlay(self, machine_token_overlay_path): if not os.path.exists(machine_token_overlay_path): raise exceptions.UserFacingError( - status.INVALID_PATH_FOR_MACHINE_TOKEN_OVERLAY.format( + messages.INVALID_PATH_FOR_MACHINE_TOKEN_OVERLAY.format( file_path=machine_token_overlay_path ) ) @@ -464,7 +475,7 @@ return json.loads(machine_token_overlay_content) except ValueError as e: raise exceptions.UserFacingError( - status.ERROR_JSON_DECODING_IN_FILE.format( + messages.ERROR_JSON_DECODING_IN_FILE.format( error=str(e), file_path=machine_token_overlay_path ) ) @@ -573,9 +584,9 @@ released_resources = [] for resource in new_response.get("services", {}): resource_name = resource["name"] - ent_cls = entitlement_factory(resource_name) - - if ent_cls is None: + try: + ent_cls = entitlement_factory(resource_name) + except exceptions.EntitlementNotFoundError: """ Here we cannot know the status of a service, since it is not listed as a valid entitlement. @@ -608,14 +619,14 @@ """ userStatus = status.UserFacingConfigStatus status_val = userStatus.INACTIVE.value - status_desc = status.MESSAGE_NO_ACTIVE_OPERATIONS + status_desc = messages.NO_ACTIVE_OPERATIONS (lock_pid, lock_holder) = self.check_lock_info() notices = self.read_cache("notices") or [] if lock_pid > 0: status_val = userStatus.ACTIVE.value - status_desc = status.MESSAGE_LOCK_HELD.format( + status_desc = messages.LOCK_HELD.format( pid=lock_pid, lock_holder=lock_holder - ) + ).msg elif os.path.exists(self.data_path("marker-reboot-cmds")): status_val = userStatus.REBOOTREQUIRED.value operation = "configuration changes" @@ -623,7 +634,7 @@ if label == "Reboot required": operation = description break - status_desc = status.MESSAGE_ENABLE_REBOOT_REQUIRED_TMPL.format( + status_desc = messages.ENABLE_REBOOT_REQUIRED_TMPL.format( operation=operation ) return { @@ -648,9 +659,9 @@ available = status.UserFacingAvailability.AVAILABLE.value else: available = status.UserFacingAvailability.UNAVAILABLE.value - ent_cls = entitlement_factory(resource.get("name", "")) - - if not ent_cls: + try: + ent_cls = entitlement_factory(resource.get("name", "")) + except exceptions.EntitlementNotFoundError: LOG.debug( "Ignoring availability of unknown service %s" " from contract server", @@ -671,8 +682,8 @@ def _attached_service_status( self, ent, inapplicable_resources - ) -> Dict[str, Optional[str]]: - details = "" + ) -> Dict[str, Any]: + status_details = "" description_override = None contract_status = ent.contract_status() if contract_status == status.ContractStatus.UNENTITLED: @@ -683,17 +694,29 @@ description_override = inapplicable_resources[ent.name] else: ent_status, details = ent.user_facing_status() + if details: + status_details = details.msg + + blocked_by = [ + { + "name": service.entitlement.name, + "reason_code": service.named_msg.name, + "reason": service.named_msg.msg, + } + for service in ent.blocking_incompatible_services() + ] return { "name": ent.presentation_name, "description": ent.description, "entitled": contract_status.value, "status": ent_status.value, - "status_details": details, + "status_details": status_details, "description_override": description_override, "available": "yes" if ent.name not in inapplicable_resources else "no", + "blocked_by": blocked_by, } def _attached_status(self) -> Dict[str, Any]: @@ -745,12 +768,14 @@ } for resource in resources: - ent_cls = entitlement_factory(resource.get("name", "")) - if ent_cls: - ent = ent_cls(self) - response["services"].append( - self._attached_service_status(ent, inapplicable_resources) - ) + try: + ent_cls = entitlement_factory(resource.get("name", "")) + except exceptions.EntitlementNotFoundError: + continue + ent = ent_cls(self) + response["services"].append( + self._attached_service_status(ent, inapplicable_resources) + ) response["services"].sort(key=lambda x: x.get("name", "")) support = self.entitlements.get("support", {}).get("entitlement") @@ -779,17 +804,30 @@ def simulate_status( self, token: str, show_beta: bool = False - ) -> Dict[str, Any]: - """Return a status dictionary based on a token.""" + ) -> Tuple[Dict[str, Any], int]: + """Get a status dictionary based on a token. + + Returns a tuple with the status dictionary and an integer value - 0 for + success, 1 for failure + """ from uaclient.contract import ( get_available_resources, get_contract_information, ) from uaclient.entitlements import entitlement_factory + ret = 0 response = copy.deepcopy(DEFAULT_STATUS) - contract_information = get_contract_information(self, token) + try: + contract_information = get_contract_information(self, token) + except exceptions.ContractAPIError as e: + if hasattr(e, "code") and e.code == 401: + raise exceptions.UserFacingError( + msg=messages.ATTACH_INVALID_TOKEN.msg, + msg_code=messages.ATTACH_INVALID_TOKEN.name, + ) + raise e contract_info = contract_information.get("contractInfo", {}) account_info = contract_information.get("accountInfo", {}) @@ -815,10 +853,31 @@ } ) + now = datetime.now(timezone.utc) if contract_info.get("effectiveTo"): response["expires"] = contract_info.get("effectiveTo") + expiration_datetime = util.parse_rfc3339_date(response["expires"]) + delta = expiration_datetime - now + if delta.total_seconds() <= 0: + message = messages.ATTACH_FORBIDDEN_EXPIRED.format( + contract_id=response["contract"]["id"], + date=expiration_datetime.strftime(ATTACH_FAIL_DATE_FORMAT), + ) + event.error(error_msg=message.msg, error_code=message.name) + event.info("This token is not valid.\n" + message.msg + "\n") + ret = 1 if contract_info.get("effectiveFrom"): response["effective"] = contract_info.get("effectiveFrom") + effective_datetime = util.parse_rfc3339_date(response["effective"]) + delta = now - effective_datetime + if delta.total_seconds() <= 0: + message = messages.ATTACH_FORBIDDEN_NOT_YET.format( + contract_id=response["contract"]["id"], + date=effective_datetime.strftime(ATTACH_FAIL_DATE_FORMAT), + ) + event.error(error_msg=message.msg, error_code=message.name) + event.info("This token is not valid.\n" + message.msg + "\n") + ret = 1 status_cache = self.read_cache("status-cache") if status_cache: @@ -836,25 +895,25 @@ for resource in resources: entitlement_name = resource.get("name", "") - ent_cls = entitlement_factory(entitlement_name) - if ent_cls: - ent = ent_cls(self) - entitlement_information = self._get_entitlement_information( - entitlements, entitlement_name - ) - response["services"].append( - { - "name": resource.get("presentedAs", ent.name), - "description": ent.description, - "entitled": entitlement_information["entitled"], - "auto_enabled": entitlement_information[ - "auto_enabled" - ], - "available": "yes" - if ent.name not in inapplicable_resources - else "no", - } - ) + try: + ent_cls = entitlement_factory(entitlement_name) + except exceptions.EntitlementNotFoundError: + continue + ent = ent_cls(self) + entitlement_information = self._get_entitlement_information( + entitlements, entitlement_name + ) + response["services"].append( + { + "name": resource.get("presentedAs", ent.name), + "description": ent.description, + "entitled": entitlement_information["entitled"], + "auto_enabled": entitlement_information["auto_enabled"], + "available": "yes" + if ent.name not in inapplicable_resources + else "no", + } + ) response["services"].sort(key=lambda x: x.get("name", "")) support = self._get_entitlement_information(entitlements, "support") @@ -866,7 +925,7 @@ response.update(self._get_config_status()) response = self._handle_beta_resources(show_beta, response) - return response + return response, ret def status(self, show_beta: bool = False) -> Dict[str, Any]: """Return status as a dict, using a cache for non-root users @@ -895,7 +954,7 @@ if not util.should_reboot(): self.remove_notice( "", - status.MESSAGE_ENABLE_REBOOT_REQUIRED_TMPL.format( + messages.ENABLE_REBOOT_REQUIRED_TMPL.format( operation="fix operation" ), ) @@ -925,11 +984,13 @@ for resource in resources: if resource["name"] == name or resource.get("presentedAs") == name: - help_ent_cls = entitlement_factory(resource["name"]) - if help_ent_cls: - help_resource = resource - help_ent = help_ent_cls(self) - break + try: + help_ent_cls = entitlement_factory(resource["name"]) + except exceptions.EntitlementNotFoundError: + continue + help_resource = resource + help_ent = help_ent_cls(self) + break if help_resource is None: raise exceptions.UserFacingError( @@ -1026,8 +1087,8 @@ if len(services_with_proxies) > 0: services = ", ".join(services_with_proxies) - print( - status.MESSAGE_PROXY_DETECTED_BUT_NOT_CONFIGURED.format( + event.info( + messages.PROXY_DETECTED_BUT_NOT_CONFIGURED.format( services=services ) ) @@ -1036,7 +1097,7 @@ """Write config values back to config_path or DEFAULT_CONFIG_FILE.""" if not config_path: config_path = DEFAULT_CONFIG_FILE - content = status.MESSAGE_UACLIENT_CONF_HEADER + content = messages.UACLIENT_CONF_HEADER cfg_dict = copy.deepcopy(self.cfg) if "log_level" not in cfg_dict: cfg_dict["log_level"] = CONFIG_DEFAULTS["log_level"] diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/conftest.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/conftest.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/conftest.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/conftest.py 2022-03-10 17:17:29.000000000 +0000 @@ -6,6 +6,7 @@ import mock import pytest +from uaclient import event_logger from uaclient.config import UAConfig # We are doing this because we are sure that python3-apt comes with the distro, @@ -13,6 +14,22 @@ sys.modules["apt"] = mock.MagicMock() +@pytest.yield_fixture(scope="session", autouse=True) +def _subp(): + """ + A fixture that mocks util._subp for all tests. + If a test needs the actual _subp, this fixture yields it, + so just add an argument to the test named "_subp". + """ + from uaclient.util import _subp + + original = _subp + with mock.patch( + "uaclient.util._subp", return_value=("mockstdout", "mockstderr") + ): + yield original + + @pytest.fixture def caplog_text(request): """ @@ -126,3 +143,11 @@ self.cfg.update({"features": features_override}) return _FakeConfig + + +@pytest.fixture +def event(): + event = event_logger.get_event_logger() + event.reset() + + return event diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/contract.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/contract.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/contract.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/contract.py 2022-03-10 17:17:29.000000000 +0000 @@ -1,8 +1,17 @@ import logging -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple -from uaclient import clouds, exceptions, serviceclient, status, util +from uaclient import ( + clouds, + event_logger, + exceptions, + messages, + serviceclient, + util, +) from uaclient.config import UAConfig +from uaclient.defaults import ATTACH_FAIL_DATE_FORMAT +from uaclient.status import UserFacingStatus API_V1_CONTEXT_MACHINE_TOKEN = "/v1/context/machines/token" API_V1_TMPL_CONTEXT_MACHINE_TOKEN_RESOURCE = ( @@ -15,57 +24,18 @@ API_V1_AUTO_ATTACH_CLOUD_TOKEN = "/v1/clouds/{cloud_type}/token" API_V1_MACHINE_ACTIVITY = "/v1/contracts/{contract}/machine-activity/{machine}" API_V1_CONTRACT_INFORMATION = "/v1/contract" -ATTACH_FAIL_DATE_FORMAT = "%B %d, %Y" - -class ContractAPIError(util.UrlError): - def __init__(self, e, error_response): - super().__init__(e, e.code, e.headers, e.url) - if "error_list" in error_response: - self.api_errors = error_response["error_list"] - else: - self.api_errors = [error_response] - for error in self.api_errors: - error["code"] = error.get("title", error.get("code")) - - def __contains__(self, error_code): - for error in self.api_errors: - if error_code == error.get("code"): - return True - if error.get("message", "").startswith(error_code): - return True - return False - - def __get__(self, error_code, default=None): - for error in self.api_errors: - if error["code"] == error_code: - return error["detail"] - return default - - def __str__(self): - prefix = super().__str__() - details = [] - for err in self.api_errors: - if not err.get("extra"): - details.append(err.get("detail", err.get("message", ""))) - else: - for extra in err["extra"].values(): - if isinstance(extra, list): - details.extend(extra) - else: - details.append(extra) - return prefix + ": [" + self.url + "]" + ", ".join(details) +event = event_logger.get_event_logger() class UAContractClient(serviceclient.UAServiceClient): cfg_url_base_attr = "contract_url" - api_error_cls = ContractAPIError + api_error_cls = exceptions.ContractAPIError def request_contract_machine_attach(self, contract_token, machine_id=None): - """Requests machine attach to the provided contact_id. + """Requests machine attach to the provided machine_id. - @param contract_id: Unique contract id provided by contract service. @param contract_token: Token string providing authentication to ContractBearer service endpoint. @param machine_id: Optional unique system machine id. When absent, @@ -172,26 +142,17 @@ detach=False, ) - def report_machine_activity(self, enabled_services: "List[str]"): + def report_machine_activity(self): """Report current activity token and enabled services. This will report to the contracts backend all the current enabled services in the system. """ contract_id = self.cfg.contract_id - activity_token = self.cfg.activity_token machine_token = self.cfg.machine_token.get("machineToken") machine_id = util.get_machine_id(self.cfg) - # If the activityID is null we should provide the endpoint - # with the instance machine id as the activityID - activity_id = self.cfg.activity_id or machine_id - - request_data = { - "activityToken": activity_token, - "activityID": activity_id, - "resources": enabled_services, - } + request_data = self._get_activity_info(machine_id) url = API_V1_MACHINE_ACTIVITY.format( contract=contract_id, machine=machine_id ) @@ -216,28 +177,6 @@ machine_token["activityInfo"] = response self.cfg.write_cache("machine-token", machine_token) - def detach_machine_from_contract( - self, machine_token: str, contract_id: str, machine_id: str = None - ) -> Dict: - """Report the attached machine should be detached from the contract.""" - curr_machine_id = self._get_platform_data(machine_id=None).get( - "machineId", "" - ) - past_machine_id = self.cfg.read_cache("machine-id") - - if str(curr_machine_id) != str(past_machine_id): - logging.debug( - "Found new machine-id. Do not call detach on contract backend" - ) - return {} - - return self._request_machine_token_update( - machine_token=machine_token, - contract_id=contract_id, - machine_id=machine_id, - detach=True, - ) - def _request_machine_token_update( self, machine_token: str, @@ -260,6 +199,7 @@ headers = self.headers() headers.update({"Authorization": "Bearer {}".format(machine_token)}) data = self._get_platform_data(machine_id) + data["activityInfo"] = self._get_activity_info() url = API_V1_TMPL_CONTEXT_MACHINE_TOKEN_RESOURCE.format( contract=contract_id, machine=data["machineId"] ) @@ -294,6 +234,29 @@ "os": platform_os, } + def _get_activity_info(self, machine_id: Optional[str] = None): + """Return a dict of activity info data for contract requests""" + from uaclient.entitlements import ENTITLEMENT_CLASSES + + if not machine_id: + machine_id = util.get_machine_id(self.cfg) + + # If the activityID is null we should provide the endpoint + # with the instance machine id as the activityID + activity_id = self.cfg.activity_id or machine_id + + enabled_services = [ + ent(self.cfg).name + for ent in ENTITLEMENT_CLASSES + if ent(self.cfg).user_facing_status()[0] == UserFacingStatus.ACTIVE + ] + + return { + "activityID": activity_id, + "activityToken": self.cfg.activity_token, + "resources": enabled_services, + } + def process_entitlements_delta( past_entitlements: Dict[str, Any], @@ -318,14 +281,17 @@ unexpected_error = False for name, new_entitlement in sorted(new_entitlements.items()): try: - process_entitlement_delta( + deltas, service_enabled = process_entitlement_delta( past_entitlements.get(name, {}), new_entitlement, allow_enable=allow_enable, series_overrides=series_overrides, ) + except exceptions.EntitlementNotFoundError: + continue except exceptions.UserFacingError: delta_error = True + event.service_failed(name) with util.disable_log_to_console(): logging.error( "Failed to process contract delta for {name}:" @@ -333,16 +299,26 @@ ) except Exception: unexpected_error = True + event.service_failed(name) with util.disable_log_to_console(): logging.exception( "Unexpected error processing contract delta for {name}:" " {delta}".format(name=name, delta=new_entitlement) ) + else: + # If we have any deltas to process and we were able to process + # them, then we will mark that service as successfully enabled + if service_enabled and deltas: + event.service_processed(name) if unexpected_error: - raise exceptions.UserFacingError(status.MESSAGE_UNEXPECTED_ERROR) + raise exceptions.UserFacingError( + msg=messages.UNEXPECTED_ERROR.msg, + msg_code=messages.UNEXPECTED_ERROR.name, + ) elif delta_error: raise exceptions.UserFacingError( - status.MESSAGE_ATTACH_FAILURE_DEFAULT_SERVICES + msg=messages.ATTACH_FAILURE_DEFAULT_SERVICES.msg, + msg_code=messages.ATTACH_FAILURE_DEFAULT_SERVICES.name, ) @@ -351,7 +327,7 @@ new_access: Dict[str, Any], allow_enable: bool = False, series_overrides: bool = True, -) -> Dict: +) -> Tuple[Dict, bool]: """Process a entitlement access dictionary deltas if they exist. :param orig_access: Dict with original entitlement access details before @@ -365,7 +341,8 @@ applied to the new_access dict. :raise UserFacingError: on failure to process deltas. - :return: Dict of processed deltas + :return: A tuple containing a dict of processed deltas and a + boolean indicating if the service was fully processed """ from uaclient.entitlements import entitlement_factory @@ -373,31 +350,35 @@ util.apply_series_overrides(new_access) deltas = util.get_dict_deltas(orig_access, new_access) + ret = False if deltas: name = orig_access.get("entitlement", {}).get("type") if not name: name = deltas.get("entitlement", {}).get("type") if not name: - raise RuntimeError( - "Could not determine contract delta service type {} {}".format( - orig_access, new_access - ) + msg = messages.INVALID_CONTRACT_DELTAS_SERVICE_TYPE.format( + orig=orig_access, new=new_access ) - ent_cls = entitlement_factory(name) - if not ent_cls: + raise exceptions.UserFacingError(msg=msg.msg, msg_code=msg.name) + try: + ent_cls = entitlement_factory(name) + except exceptions.EntitlementNotFoundError as exc: logging.debug( 'Skipping entitlement deltas for "%s". No such class', name ) - return deltas + raise exc + entitlement = ent_cls(assume_yes=allow_enable) - entitlement.process_contract_deltas( + ret = entitlement.process_contract_deltas( orig_access, deltas, allow_enable=allow_enable ) - return deltas + return deltas, ret -def _create_attach_forbidden_message(e: ContractAPIError) -> str: - msg = status.MESSAGE_ATTACH_EXPIRED_TOKEN +def _create_attach_forbidden_message( + e: exceptions.ContractAPIError +) -> messages.NamedMessage: + msg = messages.ATTACH_EXPIRED_TOKEN if ( hasattr(e, "api_errors") and len(e.api_errors) > 0 @@ -406,22 +387,27 @@ info = e.api_errors[0]["info"] contract_id = info["contractId"] reason = info["reason"] - reason_msg = "" + reason_msg = None + if reason == "no-longer-effective": date = info["time"].strftime(ATTACH_FAIL_DATE_FORMAT) - reason_msg = status.MESSAGE_ATTACH_FORBIDDEN_EXPIRED.format( + reason_msg = messages.ATTACH_FORBIDDEN_EXPIRED.format( contract_id=contract_id, date=date ) elif reason == "not-effective-yet": date = info["time"].strftime(ATTACH_FAIL_DATE_FORMAT) - reason_msg = status.MESSAGE_ATTACH_FORBIDDEN_NOT_YET.format( + reason_msg = messages.ATTACH_FORBIDDEN_NOT_YET.format( contract_id=contract_id, date=date ) elif reason == "never-effective": - reason_msg = status.MESSAGE_ATTACH_FORBIDDEN_NEVER.format( + reason_msg = messages.ATTACH_FORBIDDEN_NEVER.format( contract_id=contract_id ) - msg = status.MESSAGE_ATTACH_FORBIDDEN.format(reason=reason_msg) + + if reason_msg: + msg = messages.ATTACH_FORBIDDEN.format(reason=reason_msg.msg) + msg.name = reason_msg.name + return msg @@ -445,29 +431,31 @@ orig_token = cfg.machine_token orig_entitlements = cfg.entitlements if orig_token and contract_token: - raise RuntimeError( - "Got unexpected contract_token on an already attached machine" - ) + msg = messages.UNEXPECTED_CONTRACT_TOKEN_ON_ATTACHED_MACHINE + raise exceptions.UserFacingError(msg=msg.msg, msg_code=msg.name) contract_client = UAContractClient(cfg) if contract_token: # We are a mid ua-attach and need to get machinetoken try: contract_client.request_contract_machine_attach( contract_token=contract_token ) - except util.UrlError as e: - if isinstance(e, ContractAPIError): + except exceptions.UrlError as e: + if isinstance(e, exceptions.ContractAPIError): if hasattr(e, "code"): if e.code == 401: - raise exceptions.UserFacingError( - status.MESSAGE_ATTACH_INVALID_TOKEN - ) + raise exceptions.AttachInvalidTokenError() elif e.code == 403: msg = _create_attach_forbidden_message(e) - raise exceptions.UserFacingError(msg) + raise exceptions.UserFacingError( + msg=msg.msg, msg_code=msg.name + ) raise e with util.disable_log_to_console(): logging.exception(str(e)) - raise exceptions.UserFacingError(status.MESSAGE_CONNECTIVITY_ERROR) + raise exceptions.UserFacingError( + msg=messages.CONNECTIVITY_ERROR.msg, + msg_code=messages.CONNECTIVITY_ERROR.name, + ) else: machine_token = orig_token["machineToken"] contract_id = orig_token["machineTokenInfo"]["contractInfo"]["id"] diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/data_types.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/data_types.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/data_types.py 1970-01-01 00:00:00.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/data_types.py 2022-03-10 17:17:29.000000000 +0000 @@ -0,0 +1,196 @@ +from typing import Any, List, Optional, Type, TypeVar + +from uaclient import exceptions + +INCORRECT_TYPE_ERROR_MESSAGE = ( + "Expected value with type {type} but got value: {value}" +) +INCORRECT_LIST_ELEMENT_TYPE_ERROR_MESSAGE = ( + "Got value with incorrect type at index {index}: {nested_msg}" +) +INCORRECT_FIELD_TYPE_ERROR_MESSAGE = ( + 'Got value with incorrect type for field "{key}": {nested_msg}' +) + + +class IncorrectTypeError(exceptions.UserFacingError): + def __init__(self, expected_type: str, got_value: Any): + super().__init__( + INCORRECT_TYPE_ERROR_MESSAGE.format( + type=expected_type, value=repr(got_value) + ) + ) + + +class IncorrectListElementTypeError(IncorrectTypeError): + def __init__(self, err: IncorrectTypeError, at_index: int): + self.msg = INCORRECT_LIST_ELEMENT_TYPE_ERROR_MESSAGE.format( + index=at_index, nested_msg=err.msg + ) + + +class IncorrectFieldTypeError(IncorrectTypeError): + def __init__(self, err: IncorrectTypeError, key: str): + self.msg = INCORRECT_FIELD_TYPE_ERROR_MESSAGE.format( + key=key, nested_msg=err.msg + ) + + +class DataValue: + """ + Generic data value to be extended by more specific typed data values. + This establishes the interface of a static/class method called `from_value` + that returns the parsed value if appropriate. + """ + + @staticmethod + def from_value(val: Any) -> Any: + return val + + +class StringDataValue(DataValue): + """ + To be used for parsing string values + from_value raises an error if the value is not a string and returns + the string itself if it is a string. + """ + + @staticmethod + def from_value(val: Any) -> str: + if not isinstance(val, str): + raise IncorrectTypeError("string", val) + return val + + +class IntDataValue(DataValue): + """ + To be used for parsing int values + from_value raises an error if the value is not a int and returns + the int itself if it is a int. + """ + + @staticmethod + def from_value(val: Any) -> int: + if not isinstance(val, int) or isinstance(val, bool): + raise IncorrectTypeError("int", val) + return val + + +class BoolDataValue(DataValue): + """ + To be used for parsing bool values + from_value raises an error if the value is not a bool and returns + the bool itself if it is a bool. + """ + + @staticmethod + def from_value(val: Any) -> bool: + if not isinstance(val, bool): + raise IncorrectTypeError("bool", val) + return val + + +def data_list(data_cls: Type[DataValue]) -> Type[DataValue]: + """ + To be used for parsing lists of a certain DataValue type. + Returns a class that extends DataValue and validates that + each item in a list is the correct type in its from_value. + """ + + class _DataList(DataValue): + @staticmethod + def from_value(val: Any) -> List: + if not isinstance(val, list): + raise IncorrectTypeError("list", val) + for i, item in enumerate(val): + try: + val[i] = data_cls.from_value(item) + except IncorrectTypeError as e: + raise IncorrectListElementTypeError(e, i) + return val + + return _DataList + + +class Field: + """ + For defining the fields static property of a DataObject. + """ + + def __init__( + self, key: str, data_cls: Type[DataValue], required: bool = True + ): + self.key = key + self.data_cls = data_cls + self.required = required + + +T = TypeVar("T", bound="DataObject") + + +class DataObject(DataValue): + """ + For defining a python object that can be parsed from a dict. + Validates that a set of expected fields are present in the dict + that is parsed and that the values of those fields are the correct + DataValue by calling from_value on each. + The fields are defined using the `fields` static property. + DataObjects can be used in Fields of other DataObjects. + To define a new DataObject: + 1. Create a new class that extends DataObject. + 2. Define the `fields` static property to be a list of Field objects + 3. Define the constructor to take kwargs that match the list of Field + objects. + a. Example 1: Field("keyname", StringDataValue) -> keyname: str + b. Example 2: Field("keyname", data_list(IntDataValue), required=False) -> keyname: Optional[List[int]] # noqa: E501 + 4. Use from_value or from_dict to parse a dict into the python object. + """ + + fields = [] # type: List[Field] + + def __init__(self, **_kwargs): + pass + + @classmethod + def from_dict(cls: Type[T], d: dict) -> T: + kwargs = {} + for field in cls.fields: + try: + val = d[field.key] + except KeyError: + if field.required: + raise IncorrectFieldTypeError( + IncorrectTypeError(field.data_cls.__name__, None), + field.key, + ) + else: + val = None + if val is not None: + try: + val = field.data_cls.from_value(val) + except IncorrectTypeError as e: + raise IncorrectFieldTypeError(e, field.key) + kwargs[field.key] = val + return cls(**kwargs) + + @classmethod + def from_value(cls, val: Any): + if not isinstance(val, dict): + raise IncorrectTypeError("dict", val) + return cls.from_dict(val) + + +class AttachActionsConfigFile(DataObject): + """ + The format of the yaml file that can be passed with + ua attach --attach-config /path/to/file + """ + + fields = [ + Field("token", StringDataValue), + Field("enable_services", data_list(StringDataValue), required=False), + ] + + def __init__(self, *, token: str, enable_services: Optional[List[str]]): + self.token = token + self.enable_services = enable_services diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/defaults.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/defaults.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/defaults.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/defaults.py 2022-03-10 17:17:29.000000000 +0000 @@ -23,6 +23,7 @@ PRINT_WRAP_WIDTH = 80 CONTRACT_EXPIRY_GRACE_PERIOD_DAYS = 14 CONTRACT_EXPIRY_PENDING_DAYS = 20 +ATTACH_FAIL_DATE_FORMAT = "%B %d, %Y" CONFIG_DEFAULTS = { "contract_url": BASE_CONTRACT_URL, diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/base.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/base.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/base.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/base.py 2022-03-10 17:17:29.000000000 +0000 @@ -2,24 +2,32 @@ import logging import os import re +import sys from datetime import datetime -from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, Type, Union import yaml -from uaclient import config, contract, status, util +from uaclient import ( + config, + contract, + event_logger, + exceptions, + messages, + status, + util, +) from uaclient.defaults import DEFAULT_HELP_FILE from uaclient.status import ( - MESSAGE_DEPENDENT_SERVICE_STOPS_DISABLE, - MESSAGE_INCOMPATIBLE_SERVICE_STOPS_ENABLE, - MESSAGE_REQUIRED_SERVICE_STOPS_ENABLE, ApplicabilityStatus, + CanDisableFailure, + CanDisableFailureReason, CanEnableFailure, CanEnableFailureReason, ContractStatus, UserFacingStatus, ) -from uaclient.types import StaticAffordance +from uaclient.types import MessagingOperationsDict, StaticAffordance from uaclient.util import is_config_value_true RE_KERNEL_UNAME = ( @@ -27,6 +35,18 @@ r"-(?P[A-Za-z0-9_-]+)" ) +event = event_logger.get_event_logger() + + +class IncompatibleService: + def __init__( + self, + entitlement: Type["UAEntitlement"], + named_msg: messages.NamedMessage, + ): + self.entitlement = entitlement + self.named_msg = named_msg + class UAEntitlement(metaclass=abc.ABCMeta): @@ -43,7 +63,7 @@ _help_info = None # type: str # List of services that are incompatible with this service - _incompatible_services = () # type: Tuple[str, ...] + _incompatible_services = () # type: Tuple[IncompatibleService, ...] # List of services that must be active before enabling this service _required_services = () # type: Tuple[str, ...] @@ -109,7 +129,7 @@ return () @property - def incompatible_services(self) -> Tuple[str, ...]: + def incompatible_services(self) -> Tuple[IncompatibleService, ...]: """ Return a list of packages that aren't compatible with the entitlement. When we are enabling the entitlement we can directly ask the user @@ -142,7 +162,7 @@ # Any custom messages to emit to the console or callables which are # handled at pre_enable, pre_disable, pre_install or post_enable stages @property - def messaging(self,) -> Dict[str, List[Union[str, Tuple[Callable, Dict]]]]: + def messaging(self,) -> MessagingOperationsDict: return {} def __init__( @@ -200,15 +220,18 @@ return False, None elif fail.reason == CanEnableFailureReason.INCOMPATIBLE_SERVICE: # Try to disable those services before proceeding with enable - handle_incompat_ret = self.handle_incompatible_services() - if not handle_incompat_ret: + incompat_ret, error = self.handle_incompatible_services() + if not incompat_ret: + fail.message = error return False, fail elif ( fail.reason == CanEnableFailureReason.INACTIVE_REQUIRED_SERVICES ): # Try to enable those services before proceeding with enable - if not self._enable_required_services(): + req_ret, error = self._enable_required_services() + if not req_ret: + fail.message = error return False, fail else: # every other reason means we can't continue @@ -235,22 +258,36 @@ """ pass - def can_disable(self, silent: bool = False) -> bool: + def can_disable( + self, ignore_dependent_services: bool = False + ) -> Tuple[bool, Optional[CanDisableFailure]]: """Report whether or not disabling is possible for the entitlement. - @param silent: Boolean set True to silence printed messages/warnings. + :return: + (True, None) if can disable + (False, CanDisableFailure) if can't disable """ application_status, _ = self.application_status() if application_status == status.ApplicationStatus.DISABLED: - if not silent: - print( - status.MESSAGE_ALREADY_DISABLED_TMPL.format( - title=self.title - ) + return ( + False, + CanDisableFailure( + CanDisableFailureReason.ALREADY_DISABLED, + message=messages.ALREADY_DISABLED.format(title=self.title), + ), + ) + + if self.dependent_services and not ignore_dependent_services: + if self.detect_dependent_services(): + return ( + False, + CanDisableFailure( + CanDisableFailureReason.ACTIVE_DEPENDENT_SERVICES + ), ) - return False - return True + + return True, None def can_enable(self) -> Tuple[bool, Optional[CanEnableFailure]]: """ @@ -272,9 +309,7 @@ False, CanEnableFailure( CanEnableFailureReason.NOT_ENTITLED, - message=status.MESSAGE_UNENTITLED_TMPL.format( - title=self.title - ), + message=messages.UNENTITLED.format(title=self.title), ), ) @@ -284,9 +319,7 @@ False, CanEnableFailure( CanEnableFailureReason.ALREADY_ENABLED, - message=status.MESSAGE_ALREADY_ENABLED_TMPL.format( - title=self.title - ), + message=messages.ALREADY_ENABLED.format(title=self.title), ), ) @@ -322,6 +355,35 @@ return (True, None) + def _check_any_service_is_active(self, services: Tuple[str, ...]) -> bool: + from uaclient.entitlements import ( + EntitlementNotFoundError, + entitlement_factory, + ) + + for service in services: + try: + ent_cls = entitlement_factory(service) + except EntitlementNotFoundError: + continue + ent_status, _ = ent_cls(self.cfg).application_status() + if ent_status == status.ApplicationStatus.ENABLED: + return True + + return False + + def detect_dependent_services(self) -> bool: + """ + Check for depedent services. + + :return: + True if there are dependent services enabled + False if there are no dependent services enabled + """ + return self._check_any_service_is_active( + services=self.dependent_services + ) + def check_required_services_active(self): """ Check if all required services are active @@ -333,14 +395,28 @@ from uaclient.entitlements import entitlement_factory for required_service in self.required_services: - ent_cls = entitlement_factory(required_service) - if ent_cls: + try: + ent_cls = entitlement_factory(required_service) ent_status, _ = ent_cls(self.cfg).application_status() if ent_status != status.ApplicationStatus.ENABLED: return False + except exceptions.EntitlementNotFoundError: + pass return True + def blocking_incompatible_services(self) -> List[IncompatibleService]: + """ + :return: List of incompatible services that are enabled + """ + ret = [] + for service in self.incompatible_services: + ent_status, _ = service.entitlement(self.cfg).application_status() + if ent_status == status.ApplicationStatus.ENABLED: + ret.append(service) + + return ret + def detect_incompatible_services(self) -> bool: """ Check for incompatible services. @@ -349,18 +425,11 @@ True if there are incompatible services enabled False if there are no incompatible services enabled """ - from uaclient.entitlements import entitlement_factory - - for incompatible_service in self.incompatible_services: - ent_cls = entitlement_factory(incompatible_service) - if ent_cls: - ent_status, _ = ent_cls(self.cfg).application_status() - if ent_status == status.ApplicationStatus.ENABLED: - return True + return len(self.blocking_incompatible_services()) > 0 - return False - - def handle_incompatible_services(self) -> bool: + def handle_incompatible_services( + self + ) -> Tuple[bool, Optional[messages.NamedMessage]]: """ Prompt user when incompatible services are found during enable. @@ -375,56 +444,45 @@ features: block_disable_on_enable: true """ - from uaclient.entitlements import entitlement_factory - cfg_block_disable_on_enable = util.is_config_value_true( config=self.cfg.cfg, path_to_value="features.block_disable_on_enable", ) - for incompatible_service in self.incompatible_services: - ent_cls = entitlement_factory(incompatible_service) + for service in self.blocking_incompatible_services(): + ent = service.entitlement(self.cfg) - if ent_cls: - ent = ent_cls(self.cfg) - enabled_status = status.ApplicationStatus.ENABLED - - is_service_enabled = ( - ent.application_status()[0] == enabled_status - ) + user_msg = messages.INCOMPATIBLE_SERVICE.format( + service_being_enabled=self.title, + incompatible_service=ent.title, + ) - if is_service_enabled: - user_msg = status.MESSAGE_INCOMPATIBLE_SERVICE.format( - service_being_enabled=self.title, - incompatible_service=ent.title, - ) + e_msg = messages.INCOMPATIBLE_SERVICE_STOPS_ENABLE.format( + service_being_enabled=self.title, + incompatible_service=ent.title, + ) - e_msg = MESSAGE_INCOMPATIBLE_SERVICE_STOPS_ENABLE.format( - service_being_enabled=self.title, - incompatible_service=ent.title, - ) + if cfg_block_disable_on_enable: + return False, e_msg - if cfg_block_disable_on_enable: - logging.info(e_msg) - return False - - if not util.prompt_for_confirmation( - msg=user_msg, assume_yes=self.assume_yes - ): - print(e_msg) - return False + if not util.prompt_for_confirmation( + msg=user_msg, assume_yes=self.assume_yes + ): + return False, e_msg - disable_msg = "Disabling incompatible service: {}".format( - ent.title - ) - logging.info(disable_msg) + disable_msg = "Disabling incompatible service: {}".format( + ent.title + ) + event.info(disable_msg) - ret = ent.disable() - if not ret: - return ret + ret = ent.disable() + if not ret: + return ret, None - return True + return True, None - def _enable_required_services(self) -> bool: + def _enable_required_services( + self + ) -> Tuple[bool, Optional[messages.NamedMessage]]: """ Prompt user when required services are found during enable. @@ -435,11 +493,13 @@ from uaclient.entitlements import entitlement_factory for required_service in self.required_services: - ent_cls = entitlement_factory(required_service) - if not ent_cls: - msg = "Required service {} not found.".format(required_service) - logging.error(msg) - return False + try: + ent_cls = entitlement_factory(required_service) + except exceptions.EntitlementNotFoundError: + msg = messages.REQUIRED_SERVICE_NOT_FOUND.format( + service=required_service + ) + return False, msg ent = ent_cls(self.cfg, allow_beta=True) @@ -449,12 +509,12 @@ ) if is_service_disabled: - user_msg = status.MESSAGE_REQUIRED_SERVICE.format( + user_msg = messages.REQUIRED_SERVICE.format( service_being_enabled=self.title, required_service=ent.title, ) - e_msg = MESSAGE_REQUIRED_SERVICE_STOPS_ENABLE.format( + e_msg = messages.REQUIRED_SERVICE_STOPS_ENABLE.format( service_being_enabled=self.title, required_service=ent.title, ) @@ -462,17 +522,25 @@ if not util.prompt_for_confirmation( msg=user_msg, assume_yes=self.assume_yes ): - print(e_msg) - return False + return False, e_msg - print("Enabling required service: {}".format(ent.title)) - ret, _ = ent.enable(silent=True) + event.info("Enabling required service: {}".format(ent.title)) + ret, fail = ent.enable(silent=True) if not ret: - return ret + error_msg = "" + if fail.message and fail.message.msg: + error_msg = "\n" + fail.message.msg - return True + msg = messages.ERROR_ENABLING_REQUIRED_SERVICE.format( + error=error_msg, service=ent.title + ) + return ret, msg - def applicability_status(self) -> Tuple[ApplicabilityStatus, str]: + return True, None + + def applicability_status( + self + ) -> Tuple[ApplicabilityStatus, Optional[messages.NamedMessage]]: """Check all contract affordances to vet current platform Affordances are a list of support constraints for the entitlement. @@ -480,7 +548,7 @@ revisions. :return: - tuple of (ApplicabilityStatus, detailed_message). APPLICABLE if + tuple of (ApplicabilityStatus, NamedMessage). APPLICABLE if platform passes all defined affordances, INAPPLICABLE if it doesn't meet all of the provided constraints. """ @@ -488,7 +556,7 @@ if not entitlement_cfg: return ( ApplicabilityStatus.APPLICABLE, - "no entitlement affordances checked", + messages.NO_ENTITLEMENT_AFFORDANCES_CHECKED, ) for error_message, functor, expected_result in self.static_affordances: if functor() != expected_result: @@ -499,7 +567,7 @@ if affordance_arches and platform["arch"] not in affordance_arches: return ( ApplicabilityStatus.INAPPLICABLE, - status.MESSAGE_INAPPLICABLE_ARCH_TMPL.format( + messages.INAPPLICABLE_ARCH.format( title=self.title, arch=platform["arch"], supported_arches=", ".join(affordance_arches), @@ -509,7 +577,7 @@ if affordance_series and platform["series"] not in affordance_series: return ( ApplicabilityStatus.INAPPLICABLE, - status.MESSAGE_INAPPLICABLE_SERIES_TMPL.format( + messages.INAPPLICABLE_SERIES.format( title=self.title, series=platform["version"] ), ) @@ -521,14 +589,14 @@ if not match or match.group("flavor") not in affordance_kernels: return ( ApplicabilityStatus.INAPPLICABLE, - status.MESSAGE_INAPPLICABLE_KERNEL_TMPL.format( + messages.INAPPLICABLE_KERNEL.format( title=self.title, kernel=kernel, supported_kernels=", ".join(affordance_kernels), ), ) if affordance_min_kernel: - invalid_msg = status.MESSAGE_INAPPLICABLE_KERNEL_VER_TMPL.format( + invalid_msg = messages.INAPPLICABLE_KERNEL_VER.format( title=self.title, kernel=kernel, min_kernel=affordance_min_kernel, @@ -555,7 +623,7 @@ and kernel_minor < min_kern_minor ): return ApplicabilityStatus.INAPPLICABLE, invalid_msg - return ApplicabilityStatus.APPLICABLE, "" + return ApplicabilityStatus.APPLICABLE, None @abc.abstractmethod def _perform_disable(self, silent: bool = False) -> bool: @@ -570,7 +638,9 @@ """ pass - def _disable_dependent_services(self): + def _disable_dependent_services( + self, silent: bool + ) -> Tuple[bool, Optional[messages.NamedMessage]]: """ Disable dependent services @@ -579,11 +649,21 @@ If that is true, we will alert the user about this and prompt for confirmation to disable these services as well. + + @param silent: Boolean set True to silence print/log of messages """ from uaclient.entitlements import entitlement_factory for dependent_service in self.dependent_services: - ent_cls = entitlement_factory(dependent_service) + try: + ent_cls = entitlement_factory(dependent_service) + except exceptions.EntitlementNotFoundError: + msg = messages.DEPENDENT_SERVICE_NOT_FOUND.format( + service=dependent_service + ) + event.info(info_msg=msg.msg, file_type=sys.stderr) + return False, msg + ent = ent_cls(self.cfg) is_service_enabled = ( @@ -591,12 +671,12 @@ ) if is_service_enabled: - user_msg = status.MESSAGE_DEPENDENT_SERVICE.format( + user_msg = messages.DEPENDENT_SERVICE.format( dependent_service=ent.title, service_being_disabled=self.title, ) - e_msg = MESSAGE_DEPENDENT_SERVICE_STOPS_DISABLE.format( + e_msg = messages.DEPENDENT_SERVICE_STOPS_DISABLE.format( service_being_disabled=self.title, dependent_service=ent.title, ) @@ -604,15 +684,27 @@ if not util.prompt_for_confirmation( msg=user_msg, assume_yes=self.assume_yes ): - print(e_msg) - return False + return False, e_msg + + if not silent: + event.info( + messages.DISABLING_DEPENDENT_SERVICE.format( + required_service=ent.title + ) + ) - print("Disabling dependent service: {}".format(ent.title)) - ret = ent.disable(silent=True) + ret, fail = ent.disable(silent=True) if not ret: - return ret + error_msg = "" + if fail.message and fail.message.msg: + error_msg = "\n" + fail.message.msg - return True + msg = messages.FAILED_DISABLING_DEPENDENT_SERVICE.format( + error=error_msg, required_service=ent.title + ) + return False, msg + + return True, None def _check_for_reboot(self) -> bool: """Check if system needs to be rebooted.""" @@ -625,35 +717,54 @@ """ if self._check_for_reboot(): print( - status.MESSAGE_ENABLE_REBOOT_REQUIRED_TMPL.format( + messages.ENABLE_REBOOT_REQUIRED_TMPL.format( operation=operation ) ) - def disable(self, silent: bool = False) -> bool: + def disable( + self, silent: bool = False + ) -> Tuple[bool, Optional[CanDisableFailure]]: """Disable specific entitlement @param silent: Boolean set True to silence print/log of messages - @return: True on success, False otherwise. + @return: tuple of (success, optional reason) + (True, None) on success. + (False, reason) otherwise. reason is only non-None if it is a + populated CanDisableFailure reason. This may expand to + include other types of reasons in the future. """ msg_ops = self.messaging.get("pre_disable", []) if not util.handle_message_operations(msg_ops): - return False - if not self.can_disable(silent): - return False - if not self._disable_dependent_services(): - return False + return False, None + + can_disable, fail = self.can_disable() + if not can_disable: + if fail is None: + # this shouldn't happen, but if it does we shouldn't continue + return False, None + elif ( + fail.reason + == CanDisableFailureReason.ACTIVE_DEPENDENT_SERVICES + ): + ret, msg = self._disable_dependent_services(silent=silent) + if not ret: + fail.message = msg + return False, fail + else: + # every other reason means we can't continue + return False, fail if not self._perform_disable(silent=silent): - return False + return False, None msg_ops = self.messaging.get("post_disable", []) if not util.handle_message_operations(msg_ops): - return False - self._check_for_reboot_msg(operation="disable operation") + return False, None - return True + self._check_for_reboot_msg(operation="disable operation") + return True, None def contract_status(self) -> ContractStatus: """Return whether the user is entitled to the entitlement or not""" @@ -739,7 +850,7 @@ application_status, _ = self.application_status() if application_status != status.ApplicationStatus.DISABLED: - if self.can_disable(silent=True): + if self.can_disable(): self.disable() logging.info( "Due to contract refresh, '%s' is now disabled.", @@ -772,21 +883,22 @@ can_enable, _ = self.can_enable() if can_enable and enable_by_default: if allow_enable: - msg = status.MESSAGE_ENABLE_BY_DEFAULT_TMPL.format( - name=self.name - ) - logging.info(msg) + msg = messages.ENABLE_BY_DEFAULT_TMPL.format(name=self.name) + + event.info(msg, file_type=sys.stderr) self.enable() else: - msg = status.MESSAGE_ENABLE_BY_DEFAULT_MANUAL_TMPL.format( + msg = messages.ENABLE_BY_DEFAULT_MANUAL_TMPL.format( name=self.name ) - logging.info(msg) + event.info(msg, file_type=sys.stderr) return True return False - def user_facing_status(self) -> Tuple[UserFacingStatus, str]: + def user_facing_status( + self + ) -> Tuple[UserFacingStatus, Optional[messages.NamedMessage]]: """Return (user-facing status, details) for entitlement""" applicability, details = self.applicability_status() if applicability != ApplicabilityStatus.APPLICABLE: @@ -795,12 +907,12 @@ if not entitlement_cfg: return ( UserFacingStatus.UNAVAILABLE, - "{} is not entitled".format(self.title), + messages.SERVICE_NOT_ENTITLED.format(title=self.title), ) elif entitlement_cfg["entitlement"].get("entitled", False) is False: return ( UserFacingStatus.UNAVAILABLE, - "{} is not entitled".format(self.title), + messages.SERVICE_NOT_ENTITLED.format(title=self.title), ) application_status, explanation = self.application_status() @@ -811,7 +923,9 @@ return user_facing_status, explanation @abc.abstractmethod - def application_status(self) -> Tuple[status.ApplicationStatus, str]: + def application_status( + self + ) -> Tuple[status.ApplicationStatus, Optional[messages.NamedMessage]]: """ The current status of application of this entitlement diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/cc.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/cc.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/cc.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/cc.py 2022-03-10 17:17:29.000000000 +0000 @@ -1,6 +1,5 @@ -from typing import Callable, Dict, List, Tuple, Union - from uaclient.entitlements import repo +from uaclient.types import MessagingOperationsDict CC_README = "/usr/share/doc/ubuntu-commoncriteria/README" @@ -15,7 +14,7 @@ apt_noninteractive = True @property - def messaging(self) -> Dict[str, List[Union[str, Tuple[Callable, Dict]]]]: + def messaging(self) -> MessagingOperationsDict: return { "pre_install": [ "(This will download more than 500MB of packages, so may take" diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/cis.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/cis.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/cis.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/cis.py 2022-03-10 17:17:29.000000000 +0000 @@ -1,6 +1,7 @@ -from typing import Callable, Dict, List, Tuple, Union +from typing import List from uaclient.entitlements import repo +from uaclient.types import MessagingOperationsDict CIS_DOCS_URL = "https://ubuntu.com/security/cis" USG_DOCS_URL = "https://ubuntu.com/security/certifications/docs/usg" @@ -15,7 +16,7 @@ apt_noninteractive = True @property - def messaging(self,) -> Dict[str, List[Union[str, Tuple[Callable, Dict]]]]: + def messaging(self,) -> MessagingOperationsDict: if self._called_name == "usg": return { "post_enable": [ @@ -26,7 +27,7 @@ "post_enable": [ "Visit {} to learn how to use CIS".format(CIS_DOCS_URL) ] - } # type: Dict[str, List[Union[str, Tuple[Callable, Dict]]]] + } # type: MessagingOperationsDict if "usg" in self.valid_names: messages["pre_enable"] = [ "From Ubuntu 20.04 and onwards 'ua enable cis' has been", diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/esm.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/esm.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/esm.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/esm.py 2022-03-10 17:17:29.000000000 +0000 @@ -1,8 +1,9 @@ -from typing import Optional, Tuple # noqa: F401 +from typing import Optional, Tuple, Union # noqa: F401 from uaclient import util from uaclient.entitlements import repo from uaclient.jobs.update_messaging import update_apt_and_motd_messages +from uaclient.status import CanDisableFailure class ESMBaseEntitlement(repo.RepoEntitlement): @@ -15,11 +16,13 @@ update_apt_and_motd_messages(self.cfg) return enable_performed - def disable(self, silent=False) -> bool: - disable_performed = super().disable(silent=silent) + def disable( + self, silent=False + ) -> Tuple[bool, Union[None, CanDisableFailure]]: + disable_performed, fail = super().disable(silent=silent) if disable_performed: update_apt_and_motd_messages(self.cfg) - return disable_performed + return disable_performed, fail class ESMAppsEntitlement(ESMBaseEntitlement): diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/fips.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/fips.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/fips.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/fips.py 2022-03-10 17:17:29.000000000 +0000 @@ -2,12 +2,76 @@ import os import re from itertools import groupby -from typing import Callable, Dict, List, Tuple, Union +from typing import List, Optional, Tuple # noqa: F401 -from uaclient import apt, exceptions, status, util +from uaclient import apt, event_logger, exceptions, messages, status, util from uaclient.clouds.identity import NoCloudTypeReason, get_cloud_type from uaclient.entitlements import repo -from uaclient.types import StaticAffordance +from uaclient.entitlements.base import IncompatibleService +from uaclient.types import ( # noqa: F401 + MessagingOperations, + MessagingOperationsDict, + StaticAffordance, +) + +event = event_logger.get_event_logger() + +CONDITIONAL_PACKAGES_EVERYWHERE = [ + "strongswan", + "strongswan-hmac", + "openssh-client", + "openssh-server", +] +CONDITIONAL_PACKAGES_OPENSSH_HMAC = [ + "openssh-client-hmac", + "openssh-server-hmac", +] +FIPS_CONDITIONAL_PACKAGES = { + "xenial": CONDITIONAL_PACKAGES_EVERYWHERE + + CONDITIONAL_PACKAGES_OPENSSH_HMAC, + "bionic": CONDITIONAL_PACKAGES_EVERYWHERE + + CONDITIONAL_PACKAGES_OPENSSH_HMAC, + "focal": CONDITIONAL_PACKAGES_EVERYWHERE, +} + + +# In containers, we don't install the ubuntu-fips +# metapackage, but we do want to auto-upgrade any +# fips related packages that are already installed. +# These lists need to be kept up to date with the +# Depends of ubuntu-fips. +# Note that these lists only include those packages +# that are relevant for a container to upgrade +# after enabling fips or fips-updates. +UBUNTU_FIPS_METAPACKAGE_DEPENDS_XENIAL = [ + "openssl", + "libssl1.0.0", + "libssl1.0.0-hmac", +] +UBUNTU_FIPS_METAPACKAGE_DEPENDS_BIONIC = [ + "openssl", + "libssl1.1", + "libssl1.1-hmac", + "libgcrypt20", + "libgcrypt20-hmac", +] +UBUNTU_FIPS_METAPACKAGE_DEPENDS_FOCAL = [ + "openssl", + "libssl1.1", + "libssl1.1-hmac", + "libgcrypt20", + "libgcrypt20-hmac", +] +FIPS_CONTAINER_CONDITIONAL_PACKAGES = { + "xenial": CONDITIONAL_PACKAGES_EVERYWHERE + + CONDITIONAL_PACKAGES_OPENSSH_HMAC + + UBUNTU_FIPS_METAPACKAGE_DEPENDS_XENIAL, + "bionic": CONDITIONAL_PACKAGES_EVERYWHERE + + CONDITIONAL_PACKAGES_OPENSSH_HMAC + + UBUNTU_FIPS_METAPACKAGE_DEPENDS_BIONIC, + "focal": CONDITIONAL_PACKAGES_EVERYWHERE + + UBUNTU_FIPS_METAPACKAGE_DEPENDS_FOCAL, +} class FIPSCommonEntitlement(repo.RepoEntitlement): @@ -35,24 +99,12 @@ 2. Install the corresponding hmac version of that package when available. """ - conditional_packages = [ - "strongswan", - "strongswan-hmac", - "openssh-client", - "openssh-server", - ] - series = util.get_platform_info().get("series", "") - # On Focal, we don't have the openssh hmac packages. - # Therefore, we will not try to install them during - # when enabling any FIPS service - if series in ("xenial", "bionic"): - conditional_packages += [ - "openssh-client-hmac", - "openssh-server-hmac", - ] - return conditional_packages + if util.is_container(): + return FIPS_CONTAINER_CONDITIONAL_PACKAGES.get(series, []) + + return FIPS_CONDITIONAL_PACKAGES.get(series, []) def install_packages( self, @@ -68,7 +120,7 @@ :param verbose: If true, print messages to stdout """ if verbose: - print("Installing {title} packages".format(title=self.title)) + event.info("Installing {title} packages".format(title=self.title)) # We need to guarantee that the metapackage is installed. # While the other packages should still be installed, if they @@ -97,8 +149,8 @@ package_list=[pkg], cleanup_on_failure=False, verbose=False ) except exceptions.UserFacingError: - print( - status.MESSAGE_FIPS_PACKAGE_NOT_AVAILABLE.format( + event.info( + messages.FIPS_PACKAGE_NOT_AVAILABLE.format( service=self.title, pkg=pkg ) ) @@ -108,18 +160,20 @@ @param operation: The operation being executed. """ - if util.should_reboot(): - print( - status.MESSAGE_ENABLE_REBOOT_REQUIRED_TMPL.format( + reboot_required = util.should_reboot() + event.needs_reboot(reboot_required) + if reboot_required: + event.info( + messages.ENABLE_REBOOT_REQUIRED_TMPL.format( operation=operation ) ) if operation == "install": - self.cfg.add_notice("", status.MESSAGE_FIPS_REBOOT_REQUIRED) - elif operation == "disable operation": self.cfg.add_notice( - "", status.MESSAGE_FIPS_DISABLE_REBOOT_REQUIRED + "", messages.FIPS_SYSTEM_REBOOT_REQUIRED.msg ) + elif operation == "disable operation": + self.cfg.add_notice("", messages.FIPS_DISABLE_REBOOT_REQUIRED) def _allow_fips_on_cloud_instance( self, series: str, cloud_id: str @@ -167,23 +221,17 @@ @property def static_affordances(self) -> Tuple[StaticAffordance, ...]: - # Use a lambda so we can mock util.is_container in tests cloud_titles = {"aws": "an AWS", "azure": "an Azure", "gce": "a GCP"} cloud_id, _ = get_cloud_type() if cloud_id is None: cloud_id = "" series = util.get_platform_info().get("series", "") - blocked_message = status.MESSAGE_FIPS_BLOCK_ON_CLOUD.format( + blocked_message = messages.FIPS_BLOCK_ON_CLOUD.format( series=series.title(), cloud=cloud_titles.get(cloud_id) ) return ( ( - "Cannot install {} on a container.".format(self.title), - lambda: util.is_container(), - False, - ), - ( blocked_message, lambda: self._allow_fips_on_cloud_instance(series, cloud_id), True, @@ -233,14 +281,26 @@ @property def packages(self) -> List[str]: + if util.is_container(): + return [] packages = super().packages return self._replace_metapackage_on_cloud_instance(packages) - def application_status(self) -> Tuple[status.ApplicationStatus, str]: + def application_status( + self + ) -> Tuple[status.ApplicationStatus, Optional[messages.NamedMessage]]: super_status, super_msg = super().application_status() + if util.is_container() and not util.should_reboot(): + self.cfg.remove_notice( + "", messages.FIPS_SYSTEM_REBOOT_REQUIRED.msg + ) + return super_status, super_msg + if os.path.exists(self.FIPS_PROC_FILE): - self.cfg.remove_notice("", status.MESSAGE_FIPS_REBOOT_REQUIRED) + self.cfg.remove_notice( + "", messages.FIPS_SYSTEM_REBOOT_REQUIRED.msg + ) if util.load_file(self.FIPS_PROC_FILE).strip() == "1": self.cfg.remove_notice( "", status.NOTICE_FIPS_MANUAL_DISABLE_URL @@ -248,23 +308,23 @@ return super_status, super_msg else: self.cfg.remove_notice( - "", status.MESSAGE_FIPS_DISABLE_REBOOT_REQUIRED + "", messages.FIPS_DISABLE_REBOOT_REQUIRED ) self.cfg.add_notice("", status.NOTICE_FIPS_MANUAL_DISABLE_URL) return ( status.ApplicationStatus.DISABLED, - "{} is not set to 1".format(self.FIPS_PROC_FILE), + messages.FIPS_PROC_FILE_ERROR.format( + file_name=self.FIPS_PROC_FILE + ), ) else: - self.cfg.remove_notice( - "", status.MESSAGE_FIPS_DISABLE_REBOOT_REQUIRED - ) + self.cfg.remove_notice("", messages.FIPS_DISABLE_REBOOT_REQUIRED) if super_status != status.ApplicationStatus.ENABLED: return super_status, super_msg return ( status.ApplicationStatus.ENABLED, - "Reboot to FIPS kernel required", + messages.FIPS_REBOOT_REQUIRED, ) def remove_packages(self) -> None: @@ -288,7 +348,7 @@ ["apt-get", "remove", "--assume-yes"] + apt_options + list(remove_packages), - status.MESSAGE_DISABLE_FAILED_TMPL.format(title=self.title), + messages.DISABLE_FAILED_TMPL.format(title=self.title), env=env, ) @@ -308,7 +368,6 @@ title = "FIPS" description = "NIST-certified core packages" origin = "UbuntuFIPS" - _incompatible_services = ("livepatch",) # type: Tuple[str, ...] fips_pro_package_holds = [ "fips-initramfs", @@ -329,6 +388,19 @@ ] @property + def incompatible_services(self) -> Tuple[IncompatibleService, ...]: + from uaclient.entitlements.livepatch import LivepatchEntitlement + + return ( + IncompatibleService( + LivepatchEntitlement, messages.LIVEPATCH_INVALIDATES_FIPS + ), + IncompatibleService( + FIPSUpdatesEntitlement, messages.FIPS_UPDATES_INVALIDATES_FIPS + ), + ) + + @property def static_affordances(self) -> Tuple[StaticAffordance, ...]: static_affordances = super().static_affordances @@ -347,15 +419,15 @@ return static_affordances + ( ( - "Cannot enable {} when {} is enabled.".format( - self.title, fips_update.title + messages.FIPS_ERROR_WHEN_FIPS_UPDATES_ENABLED.format( + fips=self.title, fips_updates=fips_update.title ), lambda: is_fips_update_enabled, False, ), ( - "Cannot enable {} because {} was once enabled.".format( - self.title, fips_update.title + messages.FIPS_ERROR_WHEN_FIPS_UPDATES_ONCE_ENABLED.format( + fips=self.title, fips_updates=fips_update.title ), lambda: fips_updates_once_enabled, False, @@ -363,23 +435,30 @@ ) @property - def messaging(self,) -> Dict[str, List[Union[str, Tuple[Callable, Dict]]]]: + def messaging(self,) -> MessagingOperationsDict: + post_enable = None # type: Optional[MessagingOperations] + if util.is_container(): + pre_enable_prompt = status.PROMPT_FIPS_CONTAINER_PRE_ENABLE.format( + title=self.title + ) + post_enable = [messages.FIPS_RUN_APT_UPGRADE] + else: + pre_enable_prompt = status.PROMPT_FIPS_PRE_ENABLE + return { "pre_enable": [ ( util.prompt_for_confirmation, - { - "msg": status.PROMPT_FIPS_PRE_ENABLE, - "assume_yes": self.assume_yes, - }, + {"msg": pre_enable_prompt, "assume_yes": self.assume_yes}, ) ], + "post_enable": post_enable, "pre_disable": [ ( util.prompt_for_confirmation, { - "assume_yes": self.assume_yes, "msg": status.PROMPT_FIPS_PRE_DISABLE, + "assume_yes": self.assume_yes, }, ) ], @@ -414,7 +493,7 @@ "defaulting to generic FIPS package." ) if super()._perform_enable(silent=silent): - self.cfg.remove_notice("", status.MESSAGE_FIPS_INSTALL_OUT_OF_DATE) + self.cfg.remove_notice("", messages.FIPS_INSTALL_OUT_OF_DATE) return True return False @@ -428,23 +507,30 @@ description = "NIST-certified core packages with priority security updates" @property - def messaging(self,) -> Dict[str, List[Union[str, Tuple[Callable, Dict]]]]: + def messaging(self,) -> MessagingOperationsDict: + post_enable = None # type: Optional[MessagingOperations] + if util.is_container(): + pre_enable_prompt = status.PROMPT_FIPS_CONTAINER_PRE_ENABLE.format( + title=self.title + ) + post_enable = [messages.FIPS_RUN_APT_UPGRADE] + else: + pre_enable_prompt = status.PROMPT_FIPS_UPDATES_PRE_ENABLE + return { "pre_enable": [ ( util.prompt_for_confirmation, - { - "msg": status.PROMPT_FIPS_UPDATES_PRE_ENABLE, - "assume_yes": self.assume_yes, - }, + {"msg": pre_enable_prompt, "assume_yes": self.assume_yes}, ) ], + "post_enable": post_enable, "pre_disable": [ ( util.prompt_for_confirmation, { - "assume_yes": self.assume_yes, "msg": status.PROMPT_FIPS_PRE_DISABLE, + "assume_yes": self.assume_yes, }, ) ], diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/__init__.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/__init__.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/__init__.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/__init__.py 2022-03-10 17:17:29.000000000 +0000 @@ -8,6 +8,7 @@ from uaclient.entitlements.esm import ESMAppsEntitlement, ESMInfraEntitlement from uaclient.entitlements.livepatch import LivepatchEntitlement from uaclient.entitlements.ros import ROSEntitlement, ROSUpdatesEntitlement +from uaclient.exceptions import EntitlementNotFoundError from uaclient.util import is_config_value_true ENTITLEMENT_CLASSES = [ @@ -28,11 +29,16 @@ The return type is Optional[Type[UAEntitlement]]. It cannot be explicit because of the Python version on Xenial (3.5.2). + :param name: The name of the entitlement to return + :param not_found_okay: If True and no entitlement with the given name is + found, then returns None. + :raise EntitlementNotFoundError: If not_found_okay is False and no + entitlement with the given name is found, then raises this error. """ for entitlement in ENTITLEMENT_CLASSES: if name in entitlement().valid_names: return entitlement - return None + raise EntitlementNotFoundError() def valid_services( diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/livepatch.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/livepatch.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/livepatch.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/livepatch.py 2022-03-10 17:17:29.000000000 +0000 @@ -2,8 +2,16 @@ import re from typing import Any, Dict, List, Optional, Tuple -from uaclient import apt, exceptions, snap, status, util -from uaclient.entitlements import base +from uaclient import ( + apt, + event_logger, + exceptions, + messages, + snap, + status, + util, +) +from uaclient.entitlements.base import IncompatibleService, UAEntitlement from uaclient.status import ApplicationStatus from uaclient.types import StaticAffordance @@ -18,6 +26,8 @@ LIVEPATCH_CMD = "/snap/bin/canonical-livepatch" +event = event_logger.get_event_logger() + def unconfigure_livepatch_proxy( protocol_type: str, retry_sleeps: Optional[List[float]] = None @@ -55,8 +65,8 @@ snap calls """ if http_proxy or https_proxy: - print( - status.MESSAGE_SETTING_SERVICE_PROXY.format( + event.info( + messages.SETTING_SERVICE_PROXY.format( service=LivepatchEntitlement.title ) ) @@ -89,7 +99,7 @@ return value.strip() if value else None -class LivepatchEntitlement(base.UAEntitlement): +class LivepatchEntitlement(UAEntitlement): help_doc_url = "https://ubuntu.com/security/livepatch" name = "livepatch" @@ -97,6 +107,16 @@ description = "Canonical Livepatch service" @property + def incompatible_services(self) -> Tuple[IncompatibleService, ...]: + from uaclient.entitlements.fips import FIPSEntitlement + + return ( + IncompatibleService( + FIPSEntitlement, messages.LIVEPATCH_INVALIDATES_FIPS + ), + ) + + @property def static_affordances(self) -> Tuple[StaticAffordance, ...]: # Use a lambda so we can mock util.is_container in tests from uaclient.entitlements.fips import FIPSEntitlement @@ -109,12 +129,12 @@ return ( ( - "Cannot install Livepatch on a container.", + messages.LIVEPATCH_ERROR_INSTALL_ON_CONTAINER, lambda: util.is_container(), False, ), ( - "Cannot enable Livepatch when FIPS is enabled.", + messages.LIVEPATCH_ERROR_WHEN_FIPS_ENABLED, lambda: is_fips_enabled, False, ), @@ -126,12 +146,10 @@ @return: True on success, False otherwise. """ if not util.which(snap.SNAP_CMD): - print("Installing snapd") - print(status.MESSAGE_APT_UPDATING_LISTS) + event.info("Installing snapd") + event.info(messages.APT_UPDATING_LISTS) try: - apt.run_apt_command( - ["apt-get", "update"], status.MESSAGE_APT_UPDATE_FAILED - ) + apt.run_apt_update_command() except exceptions.UserFacingError as e: logging.debug( "Trying to install snapd." @@ -144,18 +162,17 @@ retry_sleeps=apt.APT_RETRIES, ) elif "snapd" not in apt.get_installed_packages(): - raise exceptions.UserFacingError( - "{} is present but snapd is not installed;" - " cannot enable {}".format(snap.SNAP_CMD, self.title) + raise exceptions.SnapdNotProperlyInstalledError( + snap_cmd=snap.SNAP_CMD, service=self.title ) try: util.subp( [snap.SNAP_CMD, "wait", "system", "seed.loaded"], capture=True ) - except util.ProcessExecutionError as e: + except exceptions.ProcessExecutionError as e: if re.search(r"unknown command .*wait", str(e).lower()): - logging.warning(status.MESSAGE_SNAPD_DOES_NOT_HAVE_WAIT_CMD) + logging.warning(messages.SNAPD_DOES_NOT_HAVE_WAIT_CMD) else: raise @@ -172,16 +189,15 @@ ) if not util.which(LIVEPATCH_CMD): - print("Installing canonical-livepatch snap") + event.info("Installing canonical-livepatch snap") try: util.subp( [snap.SNAP_CMD, "install", "canonical-livepatch"], capture=True, retry_sleeps=snap.SNAP_INSTALL_RETRIES, ) - except util.ProcessExecutionError as e: - msg = "Unable to install Livepatch client: " + str(e) - raise exceptions.UserFacingError(msg) + except exceptions.ProcessExecutionError as e: + raise exceptions.ErrorInstallingLivepatch(error_msg=str(e)) configure_livepatch_proxy(http_proxy, https_proxy) @@ -203,9 +219,9 @@ if process_directives: try: process_config_directives(entitlement_cfg) - except util.ProcessExecutionError as e: + except exceptions.ProcessExecutionError as e: msg = "Unable to configure Livepatch: " + str(e) - print(msg) + event.info(msg) logging.error(msg) return False if process_token: @@ -225,14 +241,14 @@ ) try: util.subp([LIVEPATCH_CMD, "disable"]) - except util.ProcessExecutionError as e: + except exceptions.ProcessExecutionError as e: logging.error(str(e)) return False try: util.subp( [LIVEPATCH_CMD, "enable", livepatch_token], capture=True ) - except util.ProcessExecutionError as e: + except exceptions.ProcessExecutionError as e: msg = "Unable to enable Livepatch: " for error_message, print_message in ERROR_MSG_MAP.items(): if error_message in str(e): @@ -240,9 +256,9 @@ break if msg == "Unable to enable Livepatch: ": msg += str(e) - print(msg) + event.info(msg) return False - print("Canonical livepatch enabled.") + event.info("Canonical livepatch enabled.") return True def _perform_disable(self, silent=False): @@ -255,23 +271,25 @@ util.subp([LIVEPATCH_CMD, "disable"], capture=True) return True - def application_status(self) -> Tuple[ApplicationStatus, str]: - status = (ApplicationStatus.ENABLED, "") + def application_status( + self + ) -> Tuple[ApplicationStatus, Optional[messages.NamedMessage]]: + status = (ApplicationStatus.ENABLED, None) if not util.which(LIVEPATCH_CMD): - return ( - ApplicationStatus.DISABLED, - "canonical-livepatch snap is not installed.", - ) + return (ApplicationStatus.DISABLED, messages.LIVEPATCH_NOT_ENABLED) try: util.subp( [LIVEPATCH_CMD, "status"], retry_sleeps=LIVEPATCH_RETRIES ) - except util.ProcessExecutionError as e: + except exceptions.ProcessExecutionError as e: # TODO(May want to parse INACTIVE/failure assessment) logging.debug("Livepatch not enabled. %s", str(e)) - status = (ApplicationStatus.DISABLED, str(e)) + return ( + ApplicationStatus.DISABLED, + messages.NamedMessage(name="", msg=str(e)), + ) return status def process_contract_deltas( @@ -306,7 +324,7 @@ application_status, _ = self.application_status() if application_status == status.ApplicationStatus.DISABLED: - return True # only operate on changed directives when ACTIVE + return False # only operate on changed directives when ACTIVE delta_directives = delta_entitlement.get("directives", {}) supported_deltas = set(["caCerts", "remoteServer"]) process_directives = bool( diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/repo.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/repo.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/repo.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/repo.py 2022-03-10 17:17:29.000000000 +0000 @@ -5,12 +5,22 @@ import re from typing import Any, Dict, List, Optional, Tuple, Union # noqa: F401 -from uaclient import apt, contract, exceptions, status, util +from uaclient import ( + apt, + contract, + event_logger, + exceptions, + messages, + status, + util, +) from uaclient.entitlements import base from uaclient.status import ApplicationStatus APT_DISABLED_PIN = "-32768" +event = event_logger.get_event_logger() + class RepoEntitlement(base.UAEntitlement): @@ -54,7 +64,9 @@ def _check_for_reboot(self) -> bool: """Check if system needs to be rebooted.""" - return util.should_reboot(installed_pkgs=set(self.packages)) + reboot_required = util.should_reboot(installed_pkgs=set(self.packages)) + event.needs_reboot(reboot_required) + return reboot_required @property @abc.abstractmethod @@ -68,12 +80,10 @@ @raises: UserFacingError on failure to install suggested packages """ self.setup_apt_config(silent=silent) - if self.packages: - msg_ops = self.messaging.get("pre_install", []) - if not util.handle_message_operations(msg_ops): - return False - self.install_packages() - print(status.MESSAGE_ENABLED_TMPL.format(title=self.title)) + + self.install_packages() + + event.info(messages.ENABLED_TMPL.format(title=self.title)) self._check_for_reboot_msg(operation="install") return True @@ -87,7 +97,9 @@ """Clean up the entitlement without checks or messaging""" self.remove_apt_config(silent=silent) - def application_status(self) -> Tuple[ApplicationStatus, str]: + def application_status( + self + ) -> Tuple[ApplicationStatus, Optional[messages.NamedMessage]]: entitlement_cfg = self.cfg.entitlements.get(self.name, {}) directives = entitlement_cfg.get("entitlement", {}).get( "directives", {} @@ -96,20 +108,23 @@ if not repo_url: return ( ApplicationStatus.DISABLED, - "{} does not have an aptURL directive".format(self.title), + messages.NO_APT_URL_FOR_SERVICE.format(title=self.title), ) protocol, repo_path = repo_url.split("://") policy = apt.run_apt_command( - ["apt-cache", "policy"], status.MESSAGE_APT_POLICY_FAILED + ["apt-cache", "policy"], messages.APT_POLICY_FAILED.msg ) match = re.search( r"(?P(-)?\d+) {}/ubuntu".format(repo_url), policy ) if match and match.group("pin") != APT_DISABLED_PIN: - return ApplicationStatus.ENABLED, "{} is active".format(self.title) + return ( + ApplicationStatus.ENABLED, + messages.SERVICE_IS_ACTIVE.format(title=self.title), + ) return ( ApplicationStatus.DISABLED, - "{} is not configured".format(self.title), + messages.SERVICE_NOT_CONFIGURED.format(title=self.title), ) def _check_apt_url_is_applied(self, apt_url): @@ -171,7 +186,7 @@ application_status, _ = self.application_status() if application_status == status.ApplicationStatus.DISABLED: - return True + return False if not self._check_apt_url_is_applied(delta_apt_url): logging.info( @@ -213,8 +228,18 @@ :param verbose: If true, print messages to stdout """ + if not package_list: + package_list = self.packages + + if not package_list: + return + + msg_ops = self.messaging.get("pre_install", []) + if not util.handle_message_operations(msg_ops): + return + if verbose: - print("Installing {title} packages".format(title=self.title)) + event.info("Installing {title} packages".format(title=self.title)) if self.apt_noninteractive: env = {"DEBIAN_FRONTEND": "noninteractive"} @@ -226,14 +251,13 @@ else: env = {} apt_options = [] - if not package_list: - package_list = self.packages + try: - apt.run_apt_command( - ["apt-get", "install", "--assume-yes"] - + apt_options - + package_list, - status.MESSAGE_ENABLED_FAILED_TMPL.format(title=self.title), + msg = messages.ENABLED_FAILED.format(title=self.title) + apt.run_apt_install_command( + packages=package_list, + apt_options=apt_options, + error_msg=msg.msg, env=env, ) except exceptions.UserFacingError: @@ -303,9 +327,7 @@ "Cannot setup apt pin. Empty apt repo origin value '{}'.\n" "{}".format( self.origin, - status.MESSAGE_ENABLED_FAILED_TMPL.format( - title=self.title - ), + messages.ENABLED_FAILED.format(title=self.title).msg, ) ) repo_pref_file = self.repo_pref_file_tmpl.format(name=self.name) @@ -327,16 +349,13 @@ if prerequisite_pkgs: if not silent: - print( + event.info( "Installing prerequisites: {}".format( ", ".join(prerequisite_pkgs) ) ) try: - apt.run_apt_command( - ["apt-get", "install", "--assume-yes"] + prerequisite_pkgs, - status.MESSAGE_APT_INSTALL_FAILED, - ) + apt.run_apt_install_command(packages=prerequisite_pkgs) except exceptions.UserFacingError: self.remove_apt_config() raise @@ -348,11 +367,9 @@ # Side-effect is that apt policy will now report the repo as accessible # which allows ua status to report correct info if not silent: - print(status.MESSAGE_APT_UPDATING_LISTS) + event.info(messages.APT_UPDATING_LISTS) try: - apt.run_apt_command( - ["apt-get", "update"], status.MESSAGE_APT_UPDATE_FAILED - ) + apt.run_apt_update_command() except exceptions.UserFacingError: self.remove_apt_config(run_apt_update=False) raise @@ -398,7 +415,5 @@ if run_apt_update: if not silent: - print(status.MESSAGE_APT_UPDATING_LISTS) - apt.run_apt_command( - ["apt-get", "update"], status.MESSAGE_APT_UPDATE_FAILED - ) + event.info(messages.APT_UPDATING_LISTS) + apt.run_apt_update_command() diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/tests/test_base.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/tests/test_base.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/tests/test_base.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/tests/test_base.py 2022-03-10 17:17:29.000000000 +0000 @@ -5,8 +5,8 @@ import mock import pytest -from uaclient import config, status, util -from uaclient.entitlements import base +from uaclient import config, messages, status, util +from uaclient.entitlements import EntitlementNotFoundError, base from uaclient.status import ContractStatus @@ -24,12 +24,17 @@ applicability_status=None, application_status=None, allow_beta=False, + dependent_services=None, + blocking_incompatible_services=None, + **kwargs ): super().__init__(cfg, allow_beta=allow_beta) self._disable = disable self._enable = enable self._applicability_status = applicability_status self._application_status = application_status + self._dependent_services = (dependent_services,) + self._blocking_incompatible_services = blocking_incompatible_services def _perform_disable(self, **kwargs): self._application_status = ( @@ -47,6 +52,12 @@ def application_status(self): return self._application_status + def blocking_incompatible_services(self): + if self._blocking_incompatible_services is not None: + return self._blocking_incompatible_services + else: + return super().blocking_incompatible_services() + @pytest.fixture def concrete_entitlement_factory(tmpdir): @@ -58,7 +69,8 @@ feature_overrides: Optional[Dict[str, str]] = None, allow_beta: bool = False, enable: bool = False, - disable: bool = False + disable: bool = False, + dependent_services: Tuple[str, ...] = None ) -> ConcreteTestEntitlement: cfg = config.UAConfig(cfg={"data_dir": tmpdir.strpath}) machineToken = { @@ -86,6 +98,7 @@ allow_beta=allow_beta, enable=enable, disable=disable, + dependent_services=dependent_services, ) return factory @@ -115,7 +128,7 @@ assert "/some/path" == entitlement.cfg.data_dir def test_can_disable_false_on_entitlement_inactive( - self, capsys, concrete_entitlement_factory + self, concrete_entitlement_factory ): """When status is INACTIVE, can_disable returns False.""" entitlement = concrete_entitlement_factory( @@ -123,14 +136,64 @@ application_status=(status.ApplicationStatus.DISABLED, ""), ) - assert not entitlement.can_disable() + ret, fail = entitlement.can_disable() + assert not ret - expected_stdout = ( + expected_msg = ( "Test Concrete Entitlement is not currently enabled\n" - "See: sudo ua status\n" + "See: sudo ua status" + ) + assert expected_msg == fail.message.msg + + @mock.patch("uaclient.entitlements.entitlement_factory") + def test_can_disable_false_on_dependent_service( + self, m_ent_factory, concrete_entitlement_factory + ): + """When status is INACTIVE, can_disable returns False.""" + entitlement = concrete_entitlement_factory( + entitled=True, + application_status=(status.ApplicationStatus.ENABLED, ""), + dependent_services=("test",), + ) + + m_ent_cls = mock.Mock() + m_ent_obj = m_ent_cls.return_value + m_ent_obj.application_status.return_value = ( + status.ApplicationStatus.ENABLED, + None, + ) + m_ent_factory.return_value = m_ent_cls + + ret, fail = entitlement.can_disable() + assert not ret + assert ( + fail.reason + == status.CanDisableFailureReason.ACTIVE_DEPENDENT_SERVICES + ) + assert fail.message is None + + @mock.patch("uaclient.entitlements.entitlement_factory") + def test_can_disable_when_ignoring_dependent_service( + self, m_ent_factory, concrete_entitlement_factory + ): + """When status is INACTIVE, can_disable returns False.""" + entitlement = concrete_entitlement_factory( + entitled=True, + application_status=(status.ApplicationStatus.ENABLED, ""), + dependent_services=("test",), ) - stdout, _ = capsys.readouterr() - assert expected_stdout == stdout + + m_ent_cls = mock.Mock() + m_ent_obj = m_ent_cls.return_value + m_ent_obj.application_status.return_value = ( + status.ApplicationStatus.ENABLED, + None, + ) + m_ent_factory.return_value = m_ent_cls + + ret, fail = entitlement.can_disable(ignore_dependent_services=True) + assert ret is True + assert fail is None def test_can_disable_true_on_entitlement_active( self, capsys, concrete_entitlement_factory @@ -155,8 +218,11 @@ can_enable, reason = entitlement.can_enable() assert not can_enable assert reason.reason == status.CanEnableFailureReason.NOT_ENTITLED - assert reason.message == status.MESSAGE_UNENTITLED_TMPL.format( - title=ConcreteTestEntitlement.title + assert ( + reason.message.msg + == messages.UNENTITLED.format( + title=ConcreteTestEntitlement.title + ).msg ) @pytest.mark.parametrize("caplog_text", [logging.DEBUG], indirect=True) @@ -194,8 +260,11 @@ can_enable, reason = entitlement.can_enable() assert not can_enable assert reason.reason == status.CanEnableFailureReason.ALREADY_ENABLED - assert reason.message == status.MESSAGE_ALREADY_ENABLED_TMPL.format( - title=ConcreteTestEntitlement.title + assert ( + reason.message.msg + == messages.ALREADY_ENABLED.format( + title=ConcreteTestEntitlement.title + ).msg ) def test_can_enable_false_on_entitlement_inapplicable( @@ -291,7 +360,6 @@ applicability_status=(status.ApplicabilityStatus.APPLICABLE, ""), application_status=(status.ApplicationStatus.DISABLED, ""), ) - base_ent._incompatible_services = ["test"] m_entitlement_cls = mock.MagicMock() m_entitlement_obj = m_entitlement_cls.return_value @@ -299,16 +367,13 @@ status.ApplicationStatus.ENABLED, "", ] - type(m_entitlement_obj).title = mock.PropertyMock(return_value="test") + base_ent._incompatible_services = ( + base.IncompatibleService( + m_entitlement_cls, messages.NamedMessage("test", "test") + ), + ) - with mock.patch.object( - base_ent, "is_access_expired", return_value=False - ): - with mock.patch( - "uaclient.entitlements.entitlement_factory", - return_value=m_entitlement_cls, - ): - ret, reason = base_ent.can_enable() + ret, reason = base_ent.can_enable() assert ret is False assert ( @@ -369,7 +434,6 @@ applicability_status=(status.ApplicabilityStatus.APPLICABLE, ""), application_status=(status.ApplicationStatus.DISABLED, ""), ) - base_ent._incompatible_services = ["test"] m_entitlement_cls = mock.MagicMock() m_entitlement_obj = m_entitlement_cls.return_value @@ -377,16 +441,13 @@ status.ApplicationStatus.ENABLED, "", ] - type(m_entitlement_obj).title = mock.PropertyMock(return_value="test") + base_ent._incompatible_services = ( + base.IncompatibleService( + m_entitlement_cls, messages.NamedMessage("test", "test") + ), + ) - with mock.patch.object( - base_ent, "is_access_expired", return_value=False - ): - with mock.patch( - "uaclient.entitlements.entitlement_factory", - return_value=m_entitlement_cls, - ): - ret, reason = base_ent.enable() + ret, reason = base_ent.enable() expected_prompt_call = 1 if block_disable_on_enable: @@ -504,7 +565,7 @@ ) @mock.patch( "uaclient.entitlements.base.UAEntitlement._enable_required_services", - return_value=False, + return_value=(False, "required error msg"), ) @mock.patch( "uaclient.entitlements.base.UAEntitlement.handle_incompatible_services", # noqa: E501 @@ -523,6 +584,7 @@ ): """When can_enable returns False enable returns False.""" m_can_enable.return_value = (False, can_enable_fail) + m_handle_incompat.return_value = (False, None) entitlement = concrete_entitlement_factory(entitled=True) entitlement._perform_enable = mock.Mock() @@ -533,6 +595,66 @@ assert enable_req_calls == m_enable_required.call_count assert 0 == entitlement._perform_enable.call_count + if enable_req_calls: + assert can_enable_fail.message == "required error msg" + + @pytest.mark.parametrize("enable_fail_message", (("not entitled"), (None))) + @mock.patch("uaclient.util.handle_message_operations") + @mock.patch("uaclient.entitlements.entitlement_factory") + @mock.patch("uaclient.util.prompt_for_confirmation") + def test_enable_false_when_fails_to_enable_required_service( + self, + m_handle_msg, + m_ent_factory, + m_prompt_for_confirmation, + enable_fail_message, + concrete_entitlement_factory, + ): + m_handle_msg.return_value = True + + fail_reason = status.CanEnableFailure( + status.CanEnableFailureReason.INACTIVE_REQUIRED_SERVICES + ) + + if enable_fail_message: + msg = messages.NamedMessage("test-code", enable_fail_message) + else: + msg = None + + enable_fail_reason = status.CanEnableFailure( + status.CanEnableFailureReason.NOT_ENTITLED, message=msg + ) + + m_ent_cls = mock.Mock() + m_ent_obj = m_ent_cls.return_value + m_ent_obj.enable.return_value = (False, enable_fail_reason) + m_ent_obj.application_status.return_value = ( + status.ApplicationStatus.DISABLED, + None, + ) + type(m_ent_obj).title = mock.PropertyMock(return_value="Test") + m_ent_factory.return_value = m_ent_cls + + m_prompt_for_confirmation.return_vale = True + + entitlement = concrete_entitlement_factory( + entitled=True, + application_status=(status.ApplicationStatus.DISABLED, ""), + ) + entitlement._required_services = "test" + + with mock.patch.object(entitlement, "can_enable") as m_can_enable: + m_can_enable.return_value = (False, fail_reason) + ret, fail = entitlement.enable() + + assert not ret + expected_msg = "Cannot enable required service: Test" + if enable_fail_reason.message: + expected_msg += "\n" + enable_fail_reason.message.msg + assert expected_msg == fail.message.msg + assert 1 == m_can_enable.call_count + assert 1 == m_ent_factory.call_count + @pytest.mark.parametrize( "orig_access,delta", ( @@ -679,8 +801,8 @@ entitled=True, disable=True, application_status=(status.ApplicationStatus.ENABLED, ""), + dependent_services=("test",), ) - base_ent._dependent_services = ("test",) m_entitlement_cls = mock.MagicMock() m_entitlement_obj = m_entitlement_cls.return_value @@ -688,23 +810,109 @@ status.ApplicationStatus.ENABLED, "", ] - m_entitlement_obj.disable.return_value = True + m_entitlement_obj.disable.return_value = (True, None) type(m_entitlement_obj).title = mock.PropertyMock(return_value="test") with mock.patch( "uaclient.entitlements.entitlement_factory", return_value=m_entitlement_cls, ): - ret = base_ent.disable() + ret, fail = base_ent.disable() expected_prompt_call = 1 expected_ret = True expected_disable_call = 1 assert ret == expected_ret + assert fail is None assert m_prompt.call_count == expected_prompt_call assert m_entitlement_obj.disable.call_count == expected_disable_call + @pytest.mark.parametrize("disable_fail_message", (("error"), (None))) + @mock.patch("uaclient.util.handle_message_operations") + @mock.patch("uaclient.entitlements.entitlement_factory") + @mock.patch("uaclient.util.prompt_for_confirmation") + def test_disable_false_when_fails_to_disable_dependent_service( + self, + m_handle_msg, + m_ent_factory, + m_prompt_for_confirmation, + disable_fail_message, + concrete_entitlement_factory, + ): + m_handle_msg.return_value = True + + fail_reason = status.CanDisableFailure( + status.CanDisableFailureReason.ACTIVE_DEPENDENT_SERVICES + ) + if disable_fail_message: + msg = messages.NamedMessage("test-code", disable_fail_message) + else: + msg = None + + disable_fail_reason = status.CanDisableFailure( + status.CanDisableFailureReason.ALREADY_DISABLED, message=msg + ) + + m_ent_cls = mock.Mock() + m_ent_obj = m_ent_cls.return_value + m_ent_obj.disable.return_value = (False, disable_fail_reason) + m_ent_obj.application_status.return_value = ( + status.ApplicationStatus.ENABLED, + None, + ) + type(m_ent_obj).title = mock.PropertyMock(return_value="Test") + m_ent_factory.return_value = m_ent_cls + + m_prompt_for_confirmation.return_vale = True + + entitlement = concrete_entitlement_factory( + entitled=True, + application_status=(status.ApplicationStatus.DISABLED, ""), + dependent_services=("test"), + ) + + with mock.patch.object(entitlement, "can_disable") as m_can_disable: + m_can_disable.return_value = (False, fail_reason) + ret, fail = entitlement.disable() + + assert not ret + expected_msg = "Cannot disable dependent service: Test" + if disable_fail_reason.message: + expected_msg += "\n" + disable_fail_reason.message.msg + assert expected_msg == fail.message.msg + assert 1 == m_can_disable.call_count + assert 1 == m_ent_factory.call_count + + @mock.patch("uaclient.util.handle_message_operations") + @mock.patch("uaclient.entitlements.entitlement_factory") + def test_disable_false_when_dependent_service_doesnt_exist( + self, m_ent_factory, m_handle_msg, concrete_entitlement_factory + ): + m_handle_msg.return_value = True + + fail_reason = status.CanDisableFailure( + status.CanDisableFailureReason.ACTIVE_DEPENDENT_SERVICES + ) + + m_ent_factory.side_effect = EntitlementNotFoundError() + + entitlement = concrete_entitlement_factory( + entitled=True, + application_status=(status.ApplicationStatus.DISABLED, ""), + dependent_services=("test"), + ) + + with mock.patch.object(entitlement, "can_disable") as m_can_disable: + m_can_disable.return_value = (False, fail_reason) + ret, fail = entitlement.disable() + + assert not ret + expected_msg = "Dependent service test not found." + assert expected_msg == fail.message.msg + assert 1 == m_can_disable.call_count + assert 1 == m_ent_factory.call_count + @pytest.mark.parametrize( "p_name,expected", ( @@ -768,7 +976,7 @@ user_facing_status, details = entitlement.user_facing_status() assert status.UserFacingStatus.UNAVAILABLE == user_facing_status expected_details = "{} is not entitled".format(entitlement.title) - assert expected_details == details + assert expected_details == details.msg def test_unavailable_when_applicable_but_no_entitlement_cfg( self, concrete_entitlement_factory @@ -783,7 +991,7 @@ user_facing_status, details = entitlement.user_facing_status() assert status.UserFacingStatus.UNAVAILABLE == user_facing_status expected_details = "{} is not entitled".format(entitlement.title) - assert expected_details == details + assert expected_details == details.msg @pytest.mark.parametrize( "application_status,expected_uf_status", diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/tests/test_cc.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/tests/test_cc.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/tests/test_cc.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/tests/test_cc.py 2022-03-10 17:17:29.000000000 +0000 @@ -77,7 +77,7 @@ entitlement = CommonCriteriaEntitlement(cfg) uf_status, uf_status_details = entitlement.user_facing_status() assert status.UserFacingStatus.INAPPLICABLE == uf_status - assert details == uf_status_details + assert details == uf_status_details.msg class TestCommonCriteriaEntitlementCanEnable: @@ -94,7 +94,7 @@ uf_status, uf_status_details = entitlement.user_facing_status() assert status.UserFacingStatus.INACTIVE == uf_status details = "{} is not configured".format(entitlement.title) - assert details == uf_status_details + assert details == uf_status_details.msg assert (True, None) == entitlement.can_enable() assert ("", "") == capsys.readouterr() diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/tests/test_entitlements.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/tests/test_entitlements.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/tests/test_entitlements.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/tests/test_entitlements.py 2022-03-10 17:17:29.000000000 +0000 @@ -2,7 +2,7 @@ import mock import pytest -from uaclient import entitlements +from uaclient import entitlements, exceptions class TestValidServices: @@ -56,4 +56,5 @@ with mock.patch.object(entitlements, "ENTITLEMENT_CLASSES", ents): assert m_cls_1 == entitlements.entitlement_factory("othername") assert m_cls_2 == entitlements.entitlement_factory("ent2") - assert None is entitlements.entitlement_factory("nonexistent") + with pytest.raises(exceptions.EntitlementNotFoundError): + entitlements.entitlement_factory("nonexistent") diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/tests/test_esm.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/tests/test_esm.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/tests/test_esm.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/tests/test_esm.py 2022-03-10 17:17:29.000000000 +0000 @@ -4,7 +4,7 @@ import mock import pytest -from uaclient import apt, exceptions, util +from uaclient import apt, exceptions from uaclient.entitlements.esm import ESMAppsEntitlement, ESMInfraEntitlement M_PATH = "uaclient.entitlements.esm.ESMInfraEntitlement." @@ -170,8 +170,6 @@ inst = ESMAppsEntitlement(cfg) with mock.patch.object(ESMAppsEntitlement, "is_beta", is_beta): - print(is_beta, cfg_allow_beta) - print(inst.valid_service) assert disable_apt_auth_only is inst.disable_apt_auth_only is_lts_calls = [] if series != "trusty": @@ -329,7 +327,7 @@ def fake_subp(cmd, capture=None, retry_sleeps=None, env={}): if cmd == ["apt-get", "update"]: - raise util.ProcessExecutionError( + raise exceptions.ProcessExecutionError( "Failure", stderr="Could not get lock /var/lib/dpkg/lock" ) return "", "" @@ -413,7 +411,7 @@ class TestESMEntitlementDisable: @pytest.mark.parametrize("silent", [False, True]) @mock.patch("uaclient.util.get_platform_info") - @mock.patch(M_PATH + "can_disable", return_value=False) + @mock.patch(M_PATH + "can_disable", return_value=(False, None)) def test_disable_returns_false_on_can_disable_false_and_does_nothing( self, m_can_disable, @@ -425,8 +423,10 @@ entitlement = ESMInfraEntitlement({}) with mock.patch("uaclient.apt.remove_auth_apt_repo") as m_remove_apt: - assert False is entitlement.disable(silent) - assert [mock.call(silent)] == m_can_disable.call_args_list + ret, fail = entitlement.disable(silent) + assert ret is False + assert fail is None + assert [mock.call()] == m_can_disable.call_args_list assert 0 == m_remove_apt.call_count @mock.patch( @@ -437,7 +437,9 @@ ): """When can_disable, disable removes apt configuration""" - with mock.patch.object(entitlement, "can_disable", return_value=True): + with mock.patch.object( + entitlement, "can_disable", return_value=(True, None) + ): with mock.patch.object( entitlement, "remove_apt_config" ) as m_remove_apt_config: diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/tests/test_fips.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/tests/test_fips.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/tests/test_fips.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/tests/test_fips.py 2022-03-10 17:17:29.000000000 +0000 @@ -10,9 +10,17 @@ import mock import pytest -from uaclient import apt, defaults, exceptions, status, util +from uaclient import apt, defaults, exceptions, messages, status, util from uaclient.clouds.identity import NoCloudTypeReason -from uaclient.entitlements.fips import FIPSEntitlement, FIPSUpdatesEntitlement +from uaclient.entitlements.fips import ( + CONDITIONAL_PACKAGES_EVERYWHERE, + CONDITIONAL_PACKAGES_OPENSSH_HMAC, + UBUNTU_FIPS_METAPACKAGE_DEPENDS_BIONIC, + UBUNTU_FIPS_METAPACKAGE_DEPENDS_FOCAL, + UBUNTU_FIPS_METAPACKAGE_DEPENDS_XENIAL, + FIPSEntitlement, + FIPSUpdatesEntitlement, +) M_PATH = "uaclient.entitlements.fips." M_LIVEPATCH_PATH = "uaclient.entitlements.livepatch.LivepatchEntitlement." @@ -39,31 +47,61 @@ class TestFIPSEntitlementDefaults: - @pytest.mark.parametrize("series", (("xenial"), ("bionic"), ("focal"))) + @pytest.mark.parametrize( + "series, is_container, expected", + ( + ( + "xenial", + False, + CONDITIONAL_PACKAGES_EVERYWHERE + + CONDITIONAL_PACKAGES_OPENSSH_HMAC, + ), + ( + "xenial", + True, + CONDITIONAL_PACKAGES_EVERYWHERE + + CONDITIONAL_PACKAGES_OPENSSH_HMAC + + UBUNTU_FIPS_METAPACKAGE_DEPENDS_XENIAL, + ), + ( + "bionic", + False, + CONDITIONAL_PACKAGES_EVERYWHERE + + CONDITIONAL_PACKAGES_OPENSSH_HMAC, + ), + ( + "bionic", + True, + CONDITIONAL_PACKAGES_EVERYWHERE + + CONDITIONAL_PACKAGES_OPENSSH_HMAC + + UBUNTU_FIPS_METAPACKAGE_DEPENDS_BIONIC, + ), + ("focal", False, CONDITIONAL_PACKAGES_EVERYWHERE), + ( + "focal", + True, + CONDITIONAL_PACKAGES_EVERYWHERE + + UBUNTU_FIPS_METAPACKAGE_DEPENDS_FOCAL, + ), + ), + ) + @mock.patch("uaclient.util.is_container") @mock.patch("uaclient.util.get_platform_info") - def test_condiotional_packages( - self, m_get_platform_info, series, entitlement + def test_conditional_packages( + self, + m_get_platform_info, + m_is_container, + series, + is_container, + expected, + entitlement, ): """Test conditional package respect series restrictions""" m_get_platform_info.return_value = {"series": series} + m_is_container.return_value = is_container conditional_packages = entitlement.conditional_packages - if series == "focal": - assert [ - "strongswan", - "strongswan-hmac", - "openssh-client", - "openssh-server", - ] == conditional_packages - else: - assert [ - "strongswan", - "strongswan-hmac", - "openssh-client", - "openssh-server", - "openssh-client-hmac", - "openssh-server-hmac", - ] == conditional_packages + assert expected == conditional_packages def test_default_repo_key_file(self, entitlement): """GPG keyring file is the same for both FIPS and FIPS with Updates""" @@ -91,6 +129,7 @@ }, ) ], + "post_enable": None, "pre_disable": [ ( util.prompt_for_confirmation, @@ -111,6 +150,7 @@ }, ) ], + "post_enable": None, "pre_disable": [ ( util.prompt_for_confirmation, @@ -128,6 +168,67 @@ else: assert False, "Unknown entitlement {}".format(entitlement.name) + @mock.patch("uaclient.util.is_container", return_value=True) + def test_messaging_on_containers( + self, _m_is_container, fips_entitlement_factory + ): + """FIPS and FIPS Updates have different messaging on containers""" + entitlement = fips_entitlement_factory() + + expected_msging = { + "fips": { + "pre_enable": [ + ( + util.prompt_for_confirmation, + { + "assume_yes": False, + "msg": status.PROMPT_FIPS_CONTAINER_PRE_ENABLE.format( # noqa: E501 + title="FIPS" + ), + }, + ) + ], + "post_enable": [messages.FIPS_RUN_APT_UPGRADE], + "pre_disable": [ + ( + util.prompt_for_confirmation, + { + "assume_yes": False, + "msg": status.PROMPT_FIPS_PRE_DISABLE, + }, + ) + ], + }, + "fips-updates": { + "pre_enable": [ + ( + util.prompt_for_confirmation, + { + "msg": status.PROMPT_FIPS_CONTAINER_PRE_ENABLE.format( # noqa: E501 + title="FIPS Updates" + ), + "assume_yes": False, + }, + ) + ], + "post_enable": [messages.FIPS_RUN_APT_UPGRADE], + "pre_disable": [ + ( + util.prompt_for_confirmation, + { + "assume_yes": False, + "msg": status.PROMPT_FIPS_PRE_DISABLE, + }, + ) + ], + }, + } + + if entitlement.name in expected_msging: + assert expected_msging[entitlement.name] == entitlement.messaging + else: + assert False, "Unknown entitlement {}".format(entitlement.name) + class TestFIPSEntitlementCanEnable: @mock.patch("uaclient.util.is_config_value_true", return_value=False) @@ -293,13 +394,13 @@ assert apt_pinning_calls == m_add_pinning.call_args_list assert subp_calls == m_subp.call_args_list assert [ - ["", status.MESSAGE_FIPS_REBOOT_REQUIRED] + ["", messages.FIPS_SYSTEM_REBOOT_REQUIRED.msg] ] == entitlement.cfg.read_cache("notices") @pytest.mark.parametrize( "fips_common_enable_return_value, expected_remove_notice_calls", [ - (True, [mock.call("", status.MESSAGE_FIPS_INSTALL_OUT_OF_DATE)]), + (True, [mock.call("", messages.FIPS_INSTALL_OUT_OF_DATE)]), (False, []), ], ) @@ -428,7 +529,7 @@ def fake_subp(cmd, *args, **kwargs): if "install" in cmd: - raise util.ProcessExecutionError(cmd) + raise exceptions.ProcessExecutionError(cmd) return ("", "") with contextlib.ExitStack() as stack: @@ -500,13 +601,11 @@ status.ApplicationStatus.ENABLED, "", ) - fake_stdout = io.StringIO() - with contextlib.redirect_stdout(fake_stdout): - fips_ent.enable() - - expected_msg = "Cannot enable FIPS when Livepatch is enabled" - print(fake_stdout.getvalue()) - assert expected_msg in fake_stdout.getvalue().strip() + ret, fail = fips_ent.enable() + + assert not ret + expected_msg = "Cannot enable FIPS when Livepatch is enabled." + assert expected_msg == fail.message.msg @mock.patch("uaclient.util.handle_message_operations") @mock.patch( @@ -541,7 +640,7 @@ expected_msg = ( "Cannot enable FIPS when FIPS Updates is enabled." ) - assert expected_msg.strip() == reason.message.strip() + assert expected_msg.strip() == reason.message.msg.strip() @mock.patch("uaclient.util.handle_message_operations") @mock.patch( @@ -570,7 +669,7 @@ expected_msg = ( "Cannot enable FIPS because FIPS Updates was once enabled." ) - assert expected_msg.strip() == reason.message.strip() + assert expected_msg.strip() == reason.message.msg.strip() @mock.patch("uaclient.util.get_platform_info") @mock.patch("uaclient.entitlements.fips.get_cloud_type") @@ -597,7 +696,7 @@ assert not result expected_msg = """\ Ubuntu Xenial does not provide an Azure optimized FIPS kernel""" - assert expected_msg.strip() in reason.message.strip() + assert expected_msg.strip() in reason.message.msg.strip() @mock.patch("uaclient.util.get_platform_info") @mock.patch("uaclient.util.is_config_value_true", return_value=False) @@ -634,7 +733,7 @@ assert not result expected_msg = """\ Ubuntu Test does not provide a GCP optimized FIPS kernel""" - assert expected_msg.strip() in reason.message.strip() + assert expected_msg.strip() in reason.message.msg.strip() @pytest.mark.parametrize("allow_xenial_fips_on_cloud", ((True), (False))) @pytest.mark.parametrize("cloud_id", (("aws"), ("gce"), ("azure"), (None))) @@ -773,7 +872,7 @@ self, m_get_installed_packages, m_subp, _m_get_platform, entitlement ): m_get_installed_packages.return_value = ["ubuntu-fips"] - m_subp.side_effect = util.ProcessExecutionError(cmd="test") + m_subp.side_effect = exceptions.ProcessExecutionError(cmd="test") expected_msg = "Could not disable {}.".format(entitlement.title) with pytest.raises(exceptions.UserFacingError) as exc_info: @@ -797,7 +896,9 @@ tmpdir, ): """When can_disable, disable removes apt config and packages.""" - with mock.patch.object(entitlement, "can_disable", return_value=True): + with mock.patch.object( + entitlement, "can_disable", return_value=(True, None) + ): with mock.patch.object( entitlement, "remove_apt_config" ) as m_remove_apt_config: @@ -808,7 +909,7 @@ assert [mock.call(silent=True)] == m_remove_apt_config.call_args_list assert [mock.call()] == m_remove_packages.call_args_list assert [ - ["", status.MESSAGE_FIPS_DISABLE_REBOOT_REQUIRED] + ["", messages.FIPS_DISABLE_REBOOT_REQUIRED] ] == entitlement.cfg.read_cache("notices") @@ -852,12 +953,14 @@ return path_exists return orig_exists(path) - msg = "sure is some status here" - entitlement.cfg.add_notice("", status.MESSAGE_FIPS_REBOOT_REQUIRED) + msg = messages.NamedMessage("test-code", "sure is some status here") + entitlement.cfg.add_notice( + "", messages.FIPS_SYSTEM_REBOOT_REQUIRED.msg + ) if proc_content == "0": entitlement.cfg.add_notice( - "", status.MESSAGE_FIPS_DISABLE_REBOOT_REQUIRED + "", messages.FIPS_DISABLE_REBOOT_REQUIRED ) with mock.patch( @@ -868,25 +971,31 @@ m_load_file.side_effect = fake_load_file with mock.patch("os.path.exists") as m_path_exists: m_path_exists.side_effect = fake_exists - application_status = entitlement.application_status() + actual_status, actual_msg = ( + entitlement.application_status() + ) expected_status = status.ApplicationStatus.ENABLED if path_exists and proc_content == "1": expected_msg = msg assert entitlement.cfg.read_cache("notices") is None elif path_exists and proc_content == "0": - expected_msg = "/proc/sys/crypto/fips_enabled is not set to 1" + expected_msg = messages.FIPS_PROC_FILE_ERROR.format( + file_name=entitlement.FIPS_PROC_FILE + ) expected_status = status.ApplicationStatus.DISABLED assert [ ["", status.NOTICE_FIPS_MANUAL_DISABLE_URL] ] == entitlement.cfg.read_cache("notices") else: - expected_msg = "Reboot to FIPS kernel required" + expected_msg = messages.FIPS_REBOOT_REQUIRED assert [ - ["", status.MESSAGE_FIPS_REBOOT_REQUIRED] + ["", messages.FIPS_SYSTEM_REBOOT_REQUIRED.msg] ] == entitlement.cfg.read_cache("notices") - assert (expected_status, expected_msg) == application_status + assert actual_status == expected_status + assert expected_msg.msg == actual_msg.msg + assert expected_msg.name == actual_msg.name def test_fips_does_not_show_enabled_when_fips_updates_is( self, entitlement @@ -918,9 +1027,9 @@ entitlement.install_packages() @mock.patch(M_PATH + "apt.get_installed_packages") - @mock.patch(M_PATH + "apt.run_apt_command") + @mock.patch(M_PATH + "apt.run_apt_install_command") def test_install_packages_dont_fail_if_conditional_pkgs_not_installed( - self, m_run_apt, m_installed_pkgs, fips_entitlement_factory + self, m_run_apt_install, m_installed_pkgs, fips_entitlement_factory ): conditional_pkgs = ["b", "c"] @@ -928,7 +1037,7 @@ packages = ["a"] entitlement = fips_entitlement_factory(additional_packages=packages) - m_run_apt.side_effect = [ + m_run_apt_install.side_effect = [ True, exceptions.UserFacingError("error"), exceptions.UserFacingError("error"), @@ -946,16 +1055,13 @@ for pkg in all_pkgs: install_cmds.append( mock.call( - [ - "apt-get", - "install", - "--assume-yes", + packages=[pkg], + apt_options=[ "--allow-downgrades", '-o Dpkg::Options::="--force-confdef"', '-o Dpkg::Options::="--force-confold"', - pkg, ], - "Could not enable {}.".format(entitlement.title), + error_msg="Could not enable {}.".format(entitlement.title), env={"DEBIAN_FRONTEND": "noninteractive"}, ) ) @@ -963,16 +1069,16 @@ expected_msg = "\n".join( [ "Installing {} packages".format(entitlement.title), - status.MESSAGE_FIPS_PACKAGE_NOT_AVAILABLE.format( + messages.FIPS_PACKAGE_NOT_AVAILABLE.format( service=entitlement.title, pkg="b" ), - status.MESSAGE_FIPS_PACKAGE_NOT_AVAILABLE.format( + messages.FIPS_PACKAGE_NOT_AVAILABLE.format( service=entitlement.title, pkg="c" ), ] ) - assert install_cmds == m_run_apt.call_args_list + assert install_cmds == m_run_apt_install.call_args_list assert expected_msg.strip() in fake_stdout.getvalue().strip() diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/tests/test_livepatch.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/tests/test_livepatch.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/tests/test_livepatch.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/tests/test_livepatch.py 2022-03-10 17:17:29.000000000 +0000 @@ -10,7 +10,7 @@ import mock import pytest -from uaclient import apt, exceptions, status +from uaclient import apt, exceptions, messages, status from uaclient.entitlements.livepatch import ( LIVEPATCH_CMD, LivepatchEntitlement, @@ -22,7 +22,6 @@ from uaclient.entitlements.tests.conftest import machine_token from uaclient.snap import SNAP_CMD from uaclient.status import ApplicationStatus, ContractStatus -from uaclient.util import ProcessExecutionError PLATFORM_INFO_SUPPORTED = MappingProxyType( { @@ -109,7 +108,7 @@ out, _ = capsys.readouterr() if http_proxy or https_proxy: - assert out.strip() == status.MESSAGE_SETTING_SERVICE_PROXY.format( + assert out.strip() == messages.SETTING_SERVICE_PROXY.format( service=LivepatchEntitlement.title ) @@ -255,7 +254,7 @@ "Livepatch is not available for Ubuntu 16.04 LTS" " (Xenial Xerus)." ) - assert expected_details == details + assert expected_details == details.msg def test_user_facing_status_unavailable_on_unentitled(self, entitlement): """Status UNAVAILABLE on absent entitlement contract status.""" @@ -270,7 +269,7 @@ m_platform_info.return_value = PLATFORM_INFO_SUPPORTED uf_status, details = entitlement.user_facing_status() assert uf_status == status.UserFacingStatus.UNAVAILABLE - assert "Livepatch is not entitled" == details + assert "Livepatch is not entitled" == details.msg class TestLivepatchProcessConfigDirectives: @@ -328,6 +327,10 @@ assert 0 == m_subp.call_count +@mock.patch( + "uaclient.entitlements.fips.FIPSEntitlement.application_status", + return_value=DISABLED_APP_STATUS, +) @mock.patch(M_LIVEPATCH_STATUS, return_value=DISABLED_APP_STATUS) @mock.patch( "uaclient.entitlements.livepatch.util.is_container", return_value=False @@ -341,6 +344,7 @@ self, _m_is_container, _m_livepatch_status, + _m_fips_status, supported_kernel_ver, capsys, entitlement, @@ -357,7 +361,7 @@ assert [mock.call()] == m_container.call_args_list def test_can_enable_false_on_unsupported_kernel_min_version( - self, _m_is_container, _m_livepatch_status, entitlement + self, _m_is_container, _m_livepatch_status, _m_fips_status, entitlement ): """"False when on a kernel less or equal to minKernelVersion.""" unsupported_min_kernel = copy.deepcopy(dict(PLATFORM_INFO_SUPPORTED)) @@ -372,10 +376,10 @@ "Livepatch is not available for kernel 4.2.9-00-generic.\n" "Minimum kernel version required: 4.4." ) - assert msg == reason.message + assert msg == reason.message.msg def test_can_enable_false_on_unsupported_kernel_flavor( - self, _m_is_container, _m_livepatch_status, entitlement + self, _m_is_container, _m_livepatch_status, _m_fips_status, entitlement ): """"When on an unsupported kernel, can_enable returns False.""" unsupported_kernel = copy.deepcopy(dict(PLATFORM_INFO_SUPPORTED)) @@ -390,7 +394,7 @@ "Livepatch is not available for kernel 4.4.0-140-notgeneric.\n" "Supported flavors are: generic, lowlatency." ) - assert msg == reason.message + assert msg == reason.message.msg @pytest.mark.parametrize( "kernel_version,meets_min_version", @@ -406,6 +410,7 @@ self, _m_is_container, _m_livepatch_status, + _m_fips_status, kernel_version, meets_min_version, entitlement, @@ -430,10 +435,10 @@ kernel_version ) ) - assert msg == reason.message + assert msg == reason.message.msg def test_can_enable_false_on_unsupported_architecture( - self, _m_is_container, _m_livepatch_status, entitlement + self, _m_is_container, _m_livepatch_status, _m_fips_status, entitlement ): """"When on an unsupported architecture, can_enable returns False.""" unsupported_kernel = copy.deepcopy(dict(PLATFORM_INFO_SUPPORTED)) @@ -447,10 +452,10 @@ "Livepatch is not available for platform ppc64le.\n" "Supported platforms are: x86_64." ) - assert msg == reason.message + assert msg == reason.message.msg def test_can_enable_false_on_containers( - self, m_is_container, _m_livepatch_status, entitlement + self, m_is_container, _m_livepatch_status, _m_fips_status, entitlement ): """When is_container is True, can_enable returns False.""" unsupported_min_kernel = copy.deepcopy(dict(PLATFORM_INFO_SUPPORTED)) @@ -463,7 +468,7 @@ assert False is result assert status.CanEnableFailureReason.INAPPLICABLE == reason.reason msg = "Cannot install Livepatch on a container." - assert msg == reason.message + assert msg == reason.message.msg class TestLivepatchProcessContractDeltas: @@ -478,14 +483,14 @@ @mock.patch(M_PATH + "LivepatchEntitlement.setup_livepatch_config") @mock.patch(M_PATH + "LivepatchEntitlement.application_status") @mock.patch(M_PATH + "LivepatchEntitlement.applicability_status") - def test_true_on_inactive_livepatch_service( + def test_false_on_inactive_livepatch_service( self, m_applicability_status, m_application_status, m_setup_livepatch_config, entitlement, ): - """When livepatch is INACTIVE return True and do no setup.""" + """When livepatch is INACTIVE return False and do no setup.""" m_applicability_status.return_value = ( status.ApplicabilityStatus.APPLICABLE, "", @@ -495,7 +500,7 @@ "", ) deltas = {"entitlement": {"directives": {"caCerts": "new"}}} - assert entitlement.process_contract_deltas({}, deltas, False) + assert not entitlement.process_contract_deltas({}, deltas, False) assert [] == m_setup_livepatch_config.call_args_list @pytest.mark.parametrize( @@ -572,9 +577,7 @@ @mock.patch("uaclient.entitlements.livepatch.configure_livepatch_proxy") class TestLivepatchEntitlementEnable: - mocks_apt_update = [ - mock.call(["apt-get", "update"], status.MESSAGE_APT_UPDATE_FAILED) - ] + mocks_apt_update = [mock.call()] mocks_snapd_install = [ mock.call( ["apt-get", "install", "--assume-yes", "snapd"], @@ -614,7 +617,8 @@ @pytest.mark.parametrize("apt_update_success", (True, False)) @mock.patch("uaclient.util.get_platform_info") @mock.patch("uaclient.util.subp") - @mock.patch("uaclient.apt.run_apt_command") + @mock.patch("uaclient.apt.run_apt_install_command") + @mock.patch("uaclient.apt.run_apt_update_command") @mock.patch("uaclient.util.which", return_value=False) @mock.patch(M_PATH + "LivepatchEntitlement.application_status") @mock.patch( @@ -625,7 +629,8 @@ m_can_enable, m_app_status, m_which, - m_run_apt, + m_run_apt_update, + m_run_apt_install, m_subp, _m_get_platform_info, m_livepatch_proxy, @@ -640,16 +645,16 @@ application_status = status.ApplicationStatus.ENABLED m_app_status.return_value = application_status, "enabled" - def fake_run_apt(cmd, message): + def fake_run_apt_update(): if apt_update_success: return raise exceptions.UserFacingError("Apt go BOOM") - m_run_apt.side_effect = fake_run_apt + m_run_apt_update.side_effect = fake_run_apt_update assert entitlement.enable() assert self.mocks_install + self.mocks_config in m_subp.call_args_list - assert self.mocks_apt_update == m_run_apt.call_args_list + assert self.mocks_apt_update == m_run_apt_update.call_args_list msg = ( "Installing snapd\n" "Updating package lists\n" @@ -879,7 +884,7 @@ msg = "Cannot enable Livepatch when {} is enabled.".format( cls_title ) - assert msg.strip() == reason.message.strip() + assert msg.strip() == reason.message.msg.strip() assert m_validate_proxy.call_count == 0 assert m_snap_proxy.call_count == 0 @@ -911,7 +916,7 @@ ) m_subp.side_effect = [ - ProcessExecutionError( + exceptions.ProcessExecutionError( cmd="snapd wait system seed.loaded", exit_code=-1, stdout="", @@ -938,7 +943,7 @@ in fake_stdout.getvalue().strip() ) - for msg in status.MESSAGE_SNAPD_DOES_NOT_HAVE_WAIT_CMD.split("\n"): + for msg in messages.SNAPD_DOES_NOT_HAVE_WAIT_CMD.split("\n"): assert msg in caplog_text() assert m_validate_proxy.call_count == 2 @@ -962,7 +967,7 @@ m_installed_pkgs.return_value = ["snapd"] stderr_msg = "test error" - m_subp.side_effect = ProcessExecutionError( + m_subp.side_effect = exceptions.ProcessExecutionError( cmd="snapd wait system seed.loaded", exit_code=-1, stdout="", @@ -974,7 +979,9 @@ with mock.patch.object( entitlement, "setup_livepatch_config" ) as m_setup_livepatch: - with pytest.raises(ProcessExecutionError) as excinfo: + with pytest.raises( + exceptions.ProcessExecutionError + ) as excinfo: entitlement.enable() assert 1 == m_can_enable.call_count @@ -998,19 +1005,19 @@ m_which.return_value = which_result if subp_raise_exception: - m_subp.side_effect = ProcessExecutionError("error msg") + m_subp.side_effect = exceptions.ProcessExecutionError("error msg") status, details = entitlement.application_status() if not which_result: assert status == ApplicationStatus.DISABLED - assert "canonical-livepatch snap is not installed." in details + assert "canonical-livepatch snap is not installed." in details.msg elif subp_raise_exception: assert status == ApplicationStatus.DISABLED - assert "error msg" in details + assert "error msg" in details.msg else: assert status == ApplicationStatus.ENABLED - assert "" == details + assert details is None @mock.patch("time.sleep") @mock.patch("uaclient.util.which", return_value=True) @@ -1020,10 +1027,10 @@ from uaclient import util with mock.patch.object(util, "_subp") as m_subp: - m_subp.side_effect = ProcessExecutionError("error msg") + m_subp.side_effect = exceptions.ProcessExecutionError("error msg") status, details = entitlement.application_status() assert m_subp.call_count == 3 assert m_sleep.call_count == 2 assert status == ApplicationStatus.DISABLED - assert "error msg" in details + assert "error msg" in details.msg diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/tests/test_repo.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/tests/test_repo.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/entitlements/tests/test_repo.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/entitlements/tests/test_repo.py 2022-03-10 17:17:29.000000000 +0000 @@ -4,7 +4,7 @@ import mock import pytest -from uaclient import apt, exceptions, status, util +from uaclient import apt, exceptions, messages, status, util from uaclient.entitlements.repo import RepoEntitlement from uaclient.entitlements.tests.conftest import machine_token @@ -69,7 +69,7 @@ "Repo Test Class is not available for Ubuntu 14.04" " LTS (Trusty Tahr)." ) - assert expected_details == details + assert expected_details == details.msg uf_status, _ = entitlement.user_facing_status() assert status.UserFacingStatus.INAPPLICABLE == uf_status @@ -87,7 +87,7 @@ assert status.ApplicabilityStatus.APPLICABLE == applicability uf_status, uf_details = entitlement.user_facing_status() assert status.UserFacingStatus.UNAVAILABLE == uf_status - assert "Repo Test Class is not entitled" == uf_details + assert "Repo Test Class is not entitled" == uf_details.msg class TestProcessContractDeltas: @@ -145,7 +145,7 @@ status.ApplicabilityStatus.APPLICABLE, "", ) - assert entitlement.process_contract_deltas( + assert not entitlement.process_contract_deltas( {"entitlement": {"entitled": True}}, { "entitlement": {"obligations": {"enableByDefault": False}}, @@ -192,7 +192,7 @@ m_application_status, m_enable, entitlement, - caplog_text, + capsys, ): """Log a message when inactive, enableByDefault and allow_enable.""" m_application_status.return_value = ( @@ -212,10 +212,10 @@ allow_enable=False, ) assert [] == m_enable.call_args_list - expected_msg = status.MESSAGE_ENABLE_BY_DEFAULT_MANUAL_TMPL.format( + expected_msg = messages.ENABLE_BY_DEFAULT_MANUAL_TMPL.format( name="repotest" ) - assert expected_msg in caplog_text() + assert expected_msg in capsys.readouterr()[1] @pytest.mark.parametrize("packages", ([], ["extremetuxracer"])) @mock.patch.object(RepoTestEntitlement, "install_packages") @@ -443,7 +443,9 @@ @mock.patch(M_PATH + "util.subp", return_value=("", "")) @mock.patch(M_PATH + "util.should_reboot") @mock.patch.object(RepoTestEntitlement, "remove_apt_config") - @mock.patch.object(RepoTestEntitlement, "can_disable", return_value=True) + @mock.patch.object( + RepoTestEntitlement, "can_disable", return_value=(True, None) + ) def test_enable_can_exit_on_pre_or_post_disable_messaging_hooks( self, _can_disable, @@ -464,7 +466,8 @@ m_should_reboot.return_value = False with mock.patch.object(type(entitlement), "messaging", messaging): with mock.patch.object(type(entitlement), "packages", []): - assert retval is entitlement.disable() + ret, fail = entitlement.disable() + assert retval == ret stdout, _ = capsys.readouterr() assert output == stdout @@ -592,7 +595,7 @@ ): def fake_subp(args, *other_args, **kwargs): if "install" in args: - raise util.ProcessExecutionError(args) + raise exceptions.ProcessExecutionError(args) m_subp.side_effect = fake_subp @@ -626,14 +629,13 @@ @mock.patch(M_PATH + "apt.remove_auth_apt_repo") @mock.patch(M_PATH + "apt.remove_apt_list_files") - @mock.patch(M_PATH + "apt.run_apt_command") + @mock.patch(M_PATH + "apt.run_apt_update_command") def test_apt_get_update_called( - self, m_run_apt_command, _m_apt1, _m_apt2, entitlement + self, m_run_apt_update_command, _m_apt1, _m_apt2, entitlement ): entitlement.remove_apt_config() - expected_call = mock.call(["apt-get", "update"], mock.ANY) - assert expected_call in m_run_apt_command.call_args_list + assert mock.call() in m_run_apt_update_command.call_args_list @mock.patch(M_PATH + "apt.remove_auth_apt_repo") @mock.patch(M_PATH + "apt.remove_apt_list_files") @@ -859,10 +861,10 @@ @mock.patch("uaclient.apt.setup_apt_proxy") @mock.patch(M_PATH + "apt.add_auth_apt_repo") - @mock.patch(M_PATH + "apt.run_apt_command") + @mock.patch(M_PATH + "apt.run_apt_install_command") def test_install_prerequisite_packages( self, - m_run_apt_command, + m_run_apt_install_command, m_add_auth_repo, _m_setup_apt_proxy, entitlement, @@ -880,16 +882,9 @@ mock.call("/usr/sbin/update-ca-certificates"), ] == m_exists.call_args_list install_call = mock.call( - [ - "apt-get", - "install", - "--assume-yes", - "apt-transport-https", - "ca-certificates", - ], - "APT install failed.", + packages=["apt-transport-https", "ca-certificates"] ) - assert install_call in m_run_apt_command.call_args_list + assert install_call in m_run_apt_install_command.call_args_list @mock.patch("uaclient.apt.setup_apt_proxy") @mock.patch(M_PATH + "util.get_platform_info") @@ -942,14 +937,14 @@ @mock.patch("uaclient.apt.setup_apt_proxy") @mock.patch(M_PATH + "apt.add_auth_apt_repo") - @mock.patch(M_PATH + "apt.run_apt_command") + @mock.patch(M_PATH + "apt.run_apt_update_command") @mock.patch(M_PATH + "apt.add_ppa_pinning") @mock.patch(M_PATH + "util.get_platform_info") def test_setup_with_repo_pin_priority_int_adds_a_pins_repo_apt_preference( self, m_get_platform_info, m_add_ppa_pinning, - m_run_apt_command, + m_run_apt_update_command, m_add_auth_repo, _m_setup_apt_proxy, entitlement_factory, @@ -975,9 +970,7 @@ entitlement.repo_pin_priority, ) ] == m_add_ppa_pinning.call_args_list - assert [ - mock.call(["apt-get", "update"], "APT update failed.") - ] == m_run_apt_command.call_args_list + assert [mock.call()] == m_run_apt_update_command.call_args_list class TestCheckAptURLIsApplied: @@ -1018,7 +1011,8 @@ assert status.ApplicationStatus.DISABLED == application_status assert ( - "Repo Test Class does not have an aptURL directive" == explanation + "Repo Test Class does not have an aptURL directive" + == explanation.msg ) @pytest.mark.parametrize( @@ -1057,7 +1051,7 @@ expected_status = status.ApplicationStatus.DISABLED expected_explanation = "Repo Test Class is not configured" assert expected_status == application_status - assert expected_explanation == explanation + assert expected_explanation == explanation.msg def success_call(): diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/event_logger.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/event_logger.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/event_logger.py 1970-01-01 00:00:00.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/event_logger.py 2022-03-10 17:17:29.000000000 +0000 @@ -0,0 +1,221 @@ +""" +This module is responsible for handling all events +that must be raised to the user somehow. The main idea +behind this module is to centralize all events that happens +during the execution of UA commands and allows us to report +those events in real time or through a machine-readable format. +""" + +import enum +import json +import sys +from typing import Dict, List, Optional, Set # noqa: F401 + +from uaclient.status import format_machine_readable_output + +JSON_SCHEMA_VERSION = "0.1" +_event_logger = None + + +def get_event_logger(): + global _event_logger + + if _event_logger is None: + _event_logger = EventLogger() + + return _event_logger + + +@enum.unique +class EventLoggerMode(enum.Enum): + """ + Defines event logger supported modes. + Currently, we only support the cli and machine-readable mode. On cli mode, + we will print to stdout/stderr any event that we receive. Otherwise, we + will store those events and parse them for the specified format. + """ + + CLI = object() + JSON = object() + YAML = object() + + +class EventLogger: + def __init__(self): + self._error_events = [] # type: List[Dict[str, Optional[str]]] + self._warning_events = [] # type: List[Dict[str, Optional[str]]] + self._processed_services = set() # type: Set[str] + self._failed_services = set() # type: Set[str] + self._needs_reboot = False + self._command = "" + self._output_content = {} + + # By default, the event logger will be on CLI mode, + # printing every event it receives. + self._event_logger_mode = EventLoggerMode.CLI + + def reset(self): + """Reset the state of the event logger attributes.""" + self._error_events = [] + self._warning_events = [] + self._processed_services = set() + self._failed_services = set() + self._needs_reboot = False + self._command = "" + self._output_content = {} + self._event_logger_mode = EventLoggerMode.CLI + + def set_event_mode(self, event_mode: EventLoggerMode): + """Set the event logger mode. + + We currently support the CLI, JSON and YAML modes. + """ + self._event_logger_mode = event_mode + + def set_command(self, command: str): + """Set the event logger command. + + The command will tell the process_events method which output method + to use. + """ + self._command = command + + def set_output_content(self, output_content: Dict): + """Set the event logger output content. + + The command will tell the process_events method which content + to use. + """ + self._output_content = output_content + + def info(self, info_msg: str, file_type=None, end: Optional[str] = None): + """ + Print the info message if the event logger is on CLI mode. + """ + if not file_type: + file_type = sys.stdout + + if self._event_logger_mode == EventLoggerMode.CLI: + print(info_msg, file=file_type, end=end) + + def _record_dict_event( + self, + msg: str, + service: Optional[str], + event_dict: List[Dict[str, Optional[str]]], + code: Optional[str] = None, + event_type: Optional[str] = None, + ): + if event_type is None: + event_type = "service" if service else "system" + + event_dict.append( + { + "type": event_type, + "service": service, + "message": msg, + "message_code": code, + } + ) + + def error( + self, + error_msg: str, + error_code: Optional[str] = None, + service: Optional[str] = None, + error_type: Optional[str] = None, + ): + """ + Store an error in the event logger. + + However, the error will only be stored if the event logger + is not on CLI mode. + """ + if self._event_logger_mode != EventLoggerMode.CLI: + self._record_dict_event( + msg=error_msg, + service=service, + event_dict=self._error_events, + code=error_code, + event_type=error_type, + ) + + def warning(self, warning_msg: str, service: Optional[str] = None): + """ + Store a warning in the event logger. + + However, the warning will only be stored if the event logger + is not on CLI mode. + """ + if self._event_logger_mode != EventLoggerMode.CLI: + self._record_dict_event( + msg=warning_msg, + service=service, + event_dict=self._warning_events, + ) + + def service_processed(self, service: str): + self._processed_services.add(service) + + def services_failed(self, services: List[str]): + self._failed_services.update(services) + + def service_failed(self, service: str): + self._failed_services.add(service) + + def needs_reboot(self, reboot_required: bool): + self._needs_reboot = reboot_required + + def _generate_failed_services(self): + services_with_error = { + error["service"] + for error in self._error_events + if error["service"] + } + return list(set.union(self._failed_services, services_with_error)) + + def _process_events_services(self): + response = { + "_schema_version": JSON_SCHEMA_VERSION, + "result": "success" if not self._error_events else "failure", + "processed_services": sorted(self._processed_services), + "failed_services": sorted(self._generate_failed_services()), + "errors": self._error_events, + "warnings": self._warning_events, + "needs_reboot": self._needs_reboot, + } + + print(json.dumps(response, sort_keys=True)) + + def _process_events_status(self): + output = format_machine_readable_output(self._output_content) + output["result"] = "success" if not self._error_events else "failure" + output["errors"] = self._error_events + output["warnings"] = self._warning_events + + if self._event_logger_mode == EventLoggerMode.JSON: + from uaclient.util import DatetimeAwareJSONEncoder + + print( + json.dumps( + output, cls=DatetimeAwareJSONEncoder, sort_keys=True + ) + ) + elif self._event_logger_mode == EventLoggerMode.YAML: + import yaml + + print(yaml.dump(output, default_flow_style=False)) + + def process_events(self) -> None: + """ + Creates a json response based on all of the + events stored in the event logger. + + The json response will only be created if the event logger + is not on CLI mode. + """ + if self._event_logger_mode != EventLoggerMode.CLI: + if self._command == "status": + self._process_events_status() + else: + self._process_events_services() diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/exceptions.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/exceptions.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/exceptions.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/exceptions.py 2022-03-10 17:17:29.000000000 +0000 @@ -1,6 +1,9 @@ -from typing import Optional +import textwrap +from typing import Dict, Optional +from urllib import error -from uaclient import status +from uaclient import messages +from uaclient.defaults import PRINT_WRAP_WIDTH class UserFacingError(Exception): @@ -14,8 +17,108 @@ exit_code = 1 - def __init__(self, msg: str) -> None: + def __init__(self, msg: str, msg_code: Optional[str] = None) -> None: self.msg = msg + self.msg_code = msg_code + + +class APTInstallError(UserFacingError): + def __init__(self, name: str, service_msg: str) -> None: + super().__init__( + msg=messages.APT_INSTALL_FAILED.msg, + msg_code=messages.APT_INSTALL_FAILED.name, + ) + + +class APTProcessConflictError(UserFacingError): + def __init__(self): + super().__init__( + msg=messages.APT_PROCESS_CONFLICT.msg, + msg_code=messages.APT_PROCESS_CONFLICT.name, + ) + + +class APTInvalidRepoError(UserFacingError): + def __init__(self, error_msg: str) -> None: + super().__init__(msg=error_msg) + + +class APTUpdateProcessConflictError(UserFacingError): + def __init__(self) -> None: + super().__init__( + msg=messages.APT_UPDATE_PROCESS_CONFLICT.msg, + msg_code=messages.APT_UPDATE_PROCESS_CONFLICT.name, + ) + + +class APTUpdateInvalidRepoError(UserFacingError): + def __init__(self, repo_msg: str) -> None: + msg = messages.APT_UPDATE_INVALID_REPO.format(repo_msg=repo_msg) + super().__init__(msg=msg.msg, msg_code=msg.name) + + +class APTInstallProcessConflictError(UserFacingError): + def __init__(self, header_msg: Optional[str] = None) -> None: + if header_msg: + header_msg += ".\n" + + msg = messages.APT_INSTALL_PROCESS_CONFLICT.format( + header_msg=header_msg + ) + + super().__init__(msg=msg.msg, msg_code=msg.name) + + +class APTInstallInvalidRepoError(UserFacingError): + def __init__( + self, repo_msg: str, header_msg: Optional[str] = None + ) -> None: + if header_msg: + header_msg += ".\n" + + msg = messages.APT_INSTALL_INVALID_REPO.format( + header_msg=header_msg, repo_msg=repo_msg + ) + super().__init__(msg=msg.msg, msg_code=msg.name) + + +class SnapdNotProperlyInstalledError(UserFacingError): + def __init__(self, snap_cmd: str, service: str) -> None: + msg = messages.SNAPD_NOT_PROPERLY_INSTALLED.format( + snap_cmd=snap_cmd, service=service + ) + + super().__init__(msg=msg.msg, msg_code=msg.name) + + +class ErrorInstallingLivepatch(UserFacingError): + def __init__(self, error_msg: str) -> None: + msg = messages.ERROR_INSTALLING_LIVEPATCH.format(error_msg=error_msg) + super().__init__(msg=msg.msg, msg_code=msg.name) + + +class InvalidServiceToDisableError(UserFacingError): + def __init__(self, operation: str, name: str, service_msg: str) -> None: + msg = messages.INVALID_SERVICE_OP_FAILURE.format( + operation=operation, name=name, service_msg=service_msg + ) + super().__init__(msg=msg.msg, msg_code=msg.name) + + +class ProxyNotWorkingError(UserFacingError): + def __init__(self, proxy: str): + super().__init__( + msg=messages.NOT_SETTING_PROXY_NOT_WORKING.format(proxy=proxy).msg, + msg_code=messages.NOT_SETTING_PROXY_NOT_WORKING.name, + ) + + +class ProxyInvalidUrl(UserFacingError): + def __init__(self, proxy: str): + super().__init__( + msg=messages.NOT_SETTING_PROXY_INVALID_URL.format(proxy=proxy).msg, + msg_code=messages.NOT_SETTING_PROXY_INVALID_URL.name, + ) class BetaServiceError(UserFacingError): @@ -43,11 +146,8 @@ exit_code = 0 def __init__(self, instance_id: str): - super().__init__( - status.MESSAGE_ALREADY_ATTACHED_ON_PRO.format( - instance_id=instance_id - ) - ) + msg = messages.ALREADY_ATTACHED_ON_PRO.format(instance_id=instance_id) + super().__init__(msg=msg.msg, msg_code=msg.name) class AlreadyAttachedError(UserFacingError): @@ -56,10 +156,29 @@ exit_code = 2 def __init__(self, cfg): + msg = messages.ALREADY_ATTACHED.format( + account_name=cfg.accounts[0].get("name", "") + ) + super().__init__(msg=msg.msg, msg_code=msg.name) + + +class AttachInvalidConfigFileError(UserFacingError): + def __init__(self, config_name: str, error: str) -> None: + msg = messages.ATTACH_CONFIG_READ_ERROR.format( + config_name=config_name, error=error + ) + super().__init__( - status.MESSAGE_ALREADY_ATTACHED.format( - account_name=cfg.accounts[0]["name"] - ) + msg=textwrap.fill(msg.msg, width=PRINT_WRAP_WIDTH), + msg_code=msg.name, + ) + + +class AttachInvalidTokenError(UserFacingError): + def __init__(self): + super().__init__( + msg=messages.ATTACH_INVALID_TOKEN.msg, + msg_code=messages.ATTACH_INVALID_TOKEN.name, ) @@ -72,39 +191,38 @@ """ def __init__(self, lock_request: str, lock_holder: str, pid: int): - lock_request = lock_request - msg = "Unable to perform: {lock_request}.\n".format( - lock_request=lock_request - ) - msg += status.MESSAGE_LOCK_HELD.format( - pid=pid, lock_holder=lock_holder + msg = messages.LOCK_HELD_ERROR.format( + lock_request=lock_request, lock_holder=lock_holder, pid=pid ) - super().__init__(msg) + super().__init__(msg=msg.msg, msg_code=msg.name) class MissingAptURLDirective(UserFacingError): """An exception for when the contract server doesn't include aptURL""" def __init__(self, entitlement_name): - super().__init__( - status.MESSAGE_MISSING_APT_URL_DIRECTIVE.format( - entitlement_name=entitlement_name - ) + msg = messages.MISSING_APT_URL_DIRECTIVE.format( + entitlement_name=entitlement_name ) + super().__init__(msg=msg.msg, msg_code=msg.name) class NonRootUserError(UserFacingError): """An exception to be raised when a user needs to be root.""" def __init__(self) -> None: - super().__init__(status.MESSAGE_NONROOT_USER) + super().__init__( + msg=messages.NONROOT_USER.msg, msg_code=messages.NONROOT_USER.name + ) class UnattachedError(UserFacingError): """An exception to be raised when a machine needs to be attached.""" - def __init__(self, msg: str = status.MESSAGE_UNATTACHED) -> None: - super().__init__(msg) + def __init__( + self, msg: messages.NamedMessage = messages.UNATTACHED + ) -> None: + super().__init__(msg=msg.msg, msg_code=msg.name) class SecurityAPIMetadataError(UserFacingError): @@ -115,7 +233,7 @@ "Error: " + msg + "\n" - + status.MESSAGE_SECURITY_ISSUE_NOT_RESOLVED.format(issue=issue_id) + + messages.SECURITY_ISSUE_NOT_RESOLVED.format(issue=issue_id) ) @@ -134,3 +252,110 @@ class CloudFactoryNonViableCloudError(CloudFactoryError): pass + + +class EntitlementNotFoundError(Exception): + pass + + +class UrlError(IOError): + def __init__( + self, + cause: error.URLError, + code: Optional[int] = None, + headers: Optional[Dict[str, str]] = None, + url: Optional[str] = None, + ): + if getattr(cause, "reason", None): + cause_error = str(cause.reason) + else: + cause_error = str(cause) + super().__init__(cause_error) + self.code = code + self.headers = headers + if self.headers is None: + self.headers = {} + self.url = url + + +class ProcessExecutionError(IOError): + def __init__( + self, + cmd: str, + exit_code: Optional[int] = None, + stdout: str = "", + stderr: str = "", + ) -> None: + self.stdout = stdout + self.stderr = stderr + self.exit_code = exit_code + if not exit_code: + message_tmpl = "Invalid command specified '{cmd}'." + else: + message_tmpl = ( + "Failed running command '{cmd}' [exit({exit_code})]." + " Message: {stderr}" + ) + super().__init__( + message_tmpl.format(cmd=cmd, stderr=stderr, exit_code=exit_code) + ) + + +class ContractAPIError(UrlError): + def __init__(self, e, error_response): + super().__init__(e, e.code, e.headers, e.url) + if "error_list" in error_response: + self.api_errors = error_response["error_list"] + else: + self.api_errors = [error_response] + for api_error in self.api_errors: + api_error["code"] = api_error.get("title", api_error.get("code")) + + def __contains__(self, error_code): + for api_error in self.api_errors: + if error_code == api_error.get("code"): + return True + if api_error.get("message", "").startswith(error_code): + return True + return False + + def __get__(self, error_code, default=None): + for api_error in self.api_errors: + if api_error["code"] == error_code: + return api_error["detail"] + return default + + def __str__(self): + prefix = super().__str__() + details = [] + for err in self.api_errors: + if not err.get("extra"): + details.append(err.get("detail", err.get("message", ""))) + else: + for extra in err["extra"].values(): + if isinstance(extra, list): + details.extend(extra) + else: + details.append(extra) + return prefix + ": [" + self.url + "]" + ", ".join(details) + + +class SecurityAPIError(UrlError): + def __init__(self, e, error_response): + super().__init__(e, e.code, e.headers, e.url) + self.message = error_response.get("message", "") + + def __contains__(self, error_code): + return bool(error_code in self.message) + + def __get__(self, error_str, default=None): + if error_str in self.message: + return self.message + return default + + def __str__(self): + prefix = super().__str__() + details = [self.message] + if details: + return prefix + ": [" + self.url + "] " + ", ".join(details) + return prefix + ": [" + self.url + "]" diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/jobs/metering.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/jobs/metering.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/jobs/metering.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/jobs/metering.py 2022-03-10 17:17:29.000000000 +0000 @@ -5,8 +5,6 @@ from uaclient import config from uaclient.cli import assert_lock_file from uaclient.contract import UAContractClient -from uaclient.entitlements import ENTITLEMENT_CLASSES -from uaclient.status import UserFacingStatus @assert_lock_file("timer metering job") @@ -18,12 +16,7 @@ if not cfg.is_attached: return False - enabled_services = [ - ent(cfg).name - for ent in ENTITLEMENT_CLASSES - if ent(cfg).user_facing_status()[0] == UserFacingStatus.ACTIVE - ] - contract = UAContractClient(cfg) - contract.report_machine_activity(enabled_services=enabled_services) + contract.report_machine_activity() + return True diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/jobs/update_messaging.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/jobs/update_messaging.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/jobs/update_messaging.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/jobs/update_messaging.py 2022-03-10 17:17:29.000000000 +0000 @@ -12,18 +12,18 @@ from typing import List, Tuple from uaclient import config, defaults, entitlements, util -from uaclient.status import ( - MESSAGE_ANNOUNCE_ESM_TMPL, - MESSAGE_CONTRACT_EXPIRED_APT_NO_PKGS_TMPL, - MESSAGE_CONTRACT_EXPIRED_APT_PKGS_TMPL, - MESSAGE_CONTRACT_EXPIRED_GRACE_PERIOD_TMPL, - MESSAGE_CONTRACT_EXPIRED_MOTD_PKGS_TMPL, - MESSAGE_CONTRACT_EXPIRED_SOON_TMPL, - MESSAGE_DISABLED_APT_PKGS_TMPL, - MESSAGE_DISABLED_MOTD_NO_PKGS_TMPL, - MESSAGE_UBUNTU_NO_WARRANTY, - ApplicationStatus, +from uaclient.messages import ( + ANNOUNCE_ESM_TMPL, + CONTRACT_EXPIRED_APT_NO_PKGS_TMPL, + CONTRACT_EXPIRED_APT_PKGS_TMPL, + CONTRACT_EXPIRED_GRACE_PERIOD_TMPL, + CONTRACT_EXPIRED_MOTD_PKGS_TMPL, + CONTRACT_EXPIRED_SOON_TMPL, + DISABLED_APT_PKGS_TMPL, + DISABLED_MOTD_NO_PKGS_TMPL, + UBUNTU_NO_WARRANTY, ) +from uaclient.status import ApplicationStatus @enum.unique @@ -130,7 +130,7 @@ ua_esm_url = defaults.BASE_ESM_URL if ent.application_status()[0] == ApplicationStatus.ENABLED: if expiry_status == ContractExpiryStatus.ACTIVE_EXPIRED_SOON: - pkgs_msg = MESSAGE_CONTRACT_EXPIRED_SOON_TMPL.format( + pkgs_msg = CONTRACT_EXPIRED_SOON_TMPL.format( title=ent.title, remaining_days=remaining_days, url=defaults.BASE_UA_URL, @@ -141,7 +141,7 @@ grace_period_remaining = ( defaults.CONTRACT_EXPIRY_GRACE_PERIOD_DAYS + remaining_days ) - pkgs_msg = MESSAGE_CONTRACT_EXPIRED_GRACE_PERIOD_TMPL.format( + pkgs_msg = CONTRACT_EXPIRED_GRACE_PERIOD_TMPL.format( title=ent.title, expired_date=cfg.contract_expiry_datetime.strftime("%d %b %Y"), remaining_days=grace_period_remaining, @@ -150,31 +150,31 @@ # Same cautionary message when in grace period motd_pkgs_msg = motd_no_pkgs_msg = no_pkgs_msg = pkgs_msg elif expiry_status == ContractExpiryStatus.EXPIRED: - pkgs_msg = MESSAGE_CONTRACT_EXPIRED_APT_PKGS_TMPL.format( + pkgs_msg = CONTRACT_EXPIRED_APT_PKGS_TMPL.format( pkg_num=tmpl_pkg_count_var, pkg_names=tmpl_pkg_names_var, title=ent.title, name=ent.name, url=defaults.BASE_UA_URL, ) - no_pkgs_msg = MESSAGE_CONTRACT_EXPIRED_APT_NO_PKGS_TMPL.format( + no_pkgs_msg = CONTRACT_EXPIRED_APT_NO_PKGS_TMPL.format( title=ent.title, url=defaults.BASE_UA_URL ) motd_no_pkgs_msg = no_pkgs_msg - motd_pkgs_msg = MESSAGE_CONTRACT_EXPIRED_MOTD_PKGS_TMPL.format( + motd_pkgs_msg = CONTRACT_EXPIRED_MOTD_PKGS_TMPL.format( title=ent.title, pkg_num=tmpl_pkg_count_var, url=defaults.BASE_UA_URL, ) elif expiry_status != ContractExpiryStatus.EXPIRED: # Service not enabled - pkgs_msg = MESSAGE_DISABLED_APT_PKGS_TMPL.format( + pkgs_msg = DISABLED_APT_PKGS_TMPL.format( title=ent.title, pkg_num=tmpl_pkg_count_var, pkg_names=tmpl_pkg_names_var, eol_release=eol_release, url=ua_esm_url, ) - no_pkgs_msg = MESSAGE_DISABLED_MOTD_NO_PKGS_TMPL.format( + no_pkgs_msg = DISABLED_MOTD_NO_PKGS_TMPL.format( title=ent.title, url=ua_esm_url ) @@ -226,10 +226,10 @@ ContractExpiryStatus.EXPIRED, ContractExpiryStatus.NONE, ): - no_warranty_msg = MESSAGE_UBUNTU_NO_WARRANTY + no_warranty_msg = UBUNTU_NO_WARRANTY if infra_inst.application_status()[0] != enabled_status: msg_esm_infra = True - no_warranty_msg = MESSAGE_UBUNTU_NO_WARRANTY + no_warranty_msg = UBUNTU_NO_WARRANTY elif remaining_days <= defaults.CONTRACT_EXPIRY_PENDING_DAYS: msg_esm_infra = True _write_template_or_remove( @@ -313,8 +313,7 @@ ua_esm_url = defaults.BASE_ESM_URL if all([series != "trusty", apps_not_beta, apps_not_enabled]): util.write_file( - esm_news_file, - "\n" + MESSAGE_ANNOUNCE_ESM_TMPL.format(url=ua_esm_url), + esm_news_file, "\n" + ANNOUNCE_ESM_TMPL.format(url=ua_esm_url) ) else: util.remove_file(esm_news_file) diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/messages.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/messages.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/messages.py 1970-01-01 00:00:00.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/messages.py 2022-03-10 17:17:29.000000000 +0000 @@ -0,0 +1,675 @@ +from uaclient.defaults import BASE_UA_URL, DOCUMENTATION_URL + + +class NamedMessage: + def __init__(self, name: str, msg: str): + self.name = name + self.msg = msg + + +class FormattedNamedMessage(NamedMessage): + def __init__(self, name: str, msg: str): + self.name = name + self.tmpl_msg = msg + + def format(self, **msg_params): + return NamedMessage( + name=self.name, msg=self.tmpl_msg.format(**msg_params) + ) + + +class TxtColor: + OKGREEN = "\033[92m" + DISABLEGREY = "\033[37m" + FAIL = "\033[91m" + ENDC = "\033[0m" + + +OKGREEN_CHECK = TxtColor.OKGREEN + "✔" + TxtColor.ENDC +FAIL_X = TxtColor.FAIL + "✘" + TxtColor.ENDC + +ERROR_INVALID_CONFIG_VALUE = """\ +Invalid value for {path_to_value} in /etc/ubuntu-advantage/uaclient.conf. \ +Expected {expected_value}, found {value}.""" +INVALID_PATH_FOR_MACHINE_TOKEN_OVERLAY = """\ +Failed to find the machine token overlay file: {file_path}""" +ERROR_JSON_DECODING_IN_FILE = """\ +Found error: {error} when reading json file: {file_path}""" + +SECURITY_FIX_NOT_FOUND_ISSUE = "Error: {issue_id} not found." +SECURITY_FIX_RELEASE_STREAM = "A fix is available in {fix_stream}." +SECURITY_UPDATE_NOT_INSTALLED = "The update is not yet installed." +SECURITY_UPDATE_NOT_INSTALLED_SUBSCRIPTION = """\ +The update is not installed because this system is not attached to a +subscription. +""" +SECURITY_UPDATE_NOT_INSTALLED_EXPIRED = """\ +The update is not installed because this system is attached to an +expired subscription. +""" +SECURITY_SERVICE_DISABLED = """\ +The update is not installed because this system does not have +{service} enabled. +""" +SECURITY_UPDATE_INSTALLED = "The update is already installed." +SECURITY_USE_PRO_TMPL = ( + "For easiest security on {title}, use Ubuntu Pro." + " https://ubuntu.com/{cloud}/pro." +) +SECURITY_ISSUE_RESOLVED = OKGREEN_CHECK + " {issue} is resolved." +SECURITY_ISSUE_NOT_RESOLVED = FAIL_X + " {issue} is not resolved." +SECURITY_ISSUE_UNAFFECTED = ( + OKGREEN_CHECK + " {issue} does not affect your system." +) +SECURITY_AFFECTED_PKGS = ( + "{count} affected source package{plural_str} installed" +) +USN_FIXED = "{issue} is addressed." +CVE_FIXED = "{issue} is resolved." +SECURITY_URL = "{issue}: {title}\nhttps://ubuntu.com/security/{url_path}" +SECURITY_UA_SERVICE_NOT_ENABLED = """\ +Error: UA service: {service} is not enabled. +Without it, we cannot fix the system.""" +SECURITY_UA_SERVICE_NOT_ENTITLED = """\ +Error: The current UA subscription is not entitled to: {service}. +Without it, we cannot fix the system.""" +APT_UPDATING_LISTS = "Updating package lists" +DISABLE_FAILED_TMPL = "Could not disable {title}." +ENABLED_TMPL = "{title} enabled" +UNABLE_TO_DETERMINE_CLOUD_TYPE = ( + """\ +Unable to determine auto-attach platform support +For more information see: """ + + BASE_UA_URL + + "." +) +UNSUPPORTED_AUTO_ATTACH_CLOUD_TYPE = ( + """\ +Auto-attach image support is not available on {cloud_type} +See: """ + + BASE_UA_URL +) +UNSUPPORTED_AUTO_ATTACH = ( + """\ +Auto-attach image support is not available on this image +See: """ + + BASE_UA_URL +) +NO_ACTIVE_OPERATIONS = """No Ubuntu Advantage operations are running""" +REBOOT_SCRIPT_FAILED = ( + "Failed running reboot_cmds script. See: /var/log/ubuntu-advantage.log" +) +LIVEPATCH_LTS_REBOOT_REQUIRED = ( + "Livepatch support requires a system reboot across LTS upgrade." +) +FIPS_REBOOT_REQUIRED_MSG = "Reboot to FIPS kernel required" +SNAPD_DOES_NOT_HAVE_WAIT_CMD = ( + "snapd does not have wait command.\n" + "Enabling Livepatch can fail under this scenario\n" + "Please, upgrade snapd if Livepatch enable fails and try again." +) +FIPS_INSTALL_OUT_OF_DATE = ( + "This FIPS install is out of date, run: sudo ua enable fips" +) +FIPS_DISABLE_REBOOT_REQUIRED = ( + "Disabling FIPS requires system reboot to complete operation." +) +FIPS_PACKAGE_NOT_AVAILABLE = "{service} {pkg} package could not be installed" +FIPS_RUN_APT_UPGRADE = """\ +Please run `apt upgrade` to ensure all FIPS packages are updated to the correct +version. +""" +ATTACH_SUCCESS_TMPL = """\ +This machine is now attached to '{contract_name}' +""" +ATTACH_SUCCESS_NO_CONTRACT_NAME = """\ +This machine is now successfully attached' +""" + +ENABLE_BY_DEFAULT_TMPL = "Enabling default service {name}" +ENABLE_REBOOT_REQUIRED_TMPL = """\ +A reboot is required to complete {operation}.""" +ENABLE_BY_DEFAULT_MANUAL_TMPL = """\ +Service {name} is recommended by default. Run: sudo ua enable {name}""" +DETACH_SUCCESS = "This machine is now detached." +DETACH_AUTOMATION_FAILURE = "Unable to automatically detach machine" + +REFRESH_CONTRACT_ENABLE = "One moment, checking your subscription first" +REFRESH_CONTRACT_SUCCESS = "Successfully refreshed your subscription." +REFRESH_CONTRACT_FAILURE = "Unable to refresh your subscription" +REFRESH_CONFIG_SUCCESS = "Successfully processed your ua configuration." +REFRESH_CONFIG_FAILURE = "Unable to process uaclient.conf" + +INCOMPATIBLE_SERVICE = """\ +{service_being_enabled} cannot be enabled with {incompatible_service}. +Disable {incompatible_service} and proceed to enable {service_being_enabled}? \ +(y/N) """ + +REQUIRED_SERVICE = """\ +{service_being_enabled} cannot be enabled with {required_service} disabled. +Enable {required_service} and proceed to enable {service_being_enabled}? \ +(y/N) """ + +DEPENDENT_SERVICE = """\ +{dependent_service} depends on {service_being_disabled}. +Disable {dependent_service} and proceed to disable {service_being_disabled}? \ +(y/N) """ + +DISABLING_DEPENDENT_SERVICE = """\ +Disabling dependent service: {required_service}""" + +SECURITY_APT_NON_ROOT = """\ +Package fixes cannot be installed. +To install them, run this command as root (try using sudo)""" + +# MOTD and APT command messaging +ANNOUNCE_ESM_TMPL = """\ + * Introducing Extended Security Maintenance for Applications. + Receive updates to over 30,000 software packages with your + Ubuntu Advantage subscription. Free for personal use. + + {url} +""" + +CONTRACT_EXPIRED_SOON_TMPL = """\ +CAUTION: Your {title} service will expire in {remaining_days} days. +Renew UA subscription at {url} to ensure +continued security coverage for your applications. +""" + +CONTRACT_EXPIRED_GRACE_PERIOD_TMPL = """\ +CAUTION: Your {title} service expired on {expired_date}. +Renew UA subscription at {url} to ensure +continued security coverage for your applications. +Your grace period will expire in {remaining_days} days. +""" + +CONTRACT_EXPIRED_MOTD_PKGS_TMPL = """\ +*Your {title} subscription has EXPIRED* + +{pkg_num} additional security update(s) could have been applied via {title}. + +Renew your UA services at {url} +""" + +CONTRACT_EXPIRED_APT_PKGS_TMPL = """\ +*Your {title} subscription has EXPIRED* +Enabling {title} service would provide security updates for following packages: + {pkg_names} +{pkg_num} {name} security update(s) NOT APPLIED. Renew your UA services at +{url} +""" + +DISABLED_MOTD_NO_PKGS_TMPL = """\ +Enable {title} to receive additional future security updates. +See {url} or run: sudo ua status +""" + +CONTRACT_EXPIRED_APT_NO_PKGS_TMPL = ( + """\ +*Your {title} subscription has EXPIRED* +""" + + DISABLED_MOTD_NO_PKGS_TMPL +) + + +DISABLED_APT_PKGS_TMPL = """\ +*The following packages could receive security updates \ +with {title} service enabled: + {pkg_names} +Learn more about {title} service {eol_release}at {url} +""" + +UBUNTU_NO_WARRANTY = """\ +Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by +applicable law. +""" + +APT_PROXY_CONFIG_HEADER = """\ +/* + * Autogenerated by ubuntu-advantage-tools + * Do not edit this file directly + * + * To change what ubuntu-advantage-tools sets, run one of the following: + * Substitute "apt_https_proxy" for "apt_http_proxy" as necessary. + * sudo ua config set apt_http_proxy= + * sudo ua config unset apt_http_proxy + */ +""" + +UACLIENT_CONF_HEADER = """\ +# Ubuntu-Advantage client config file. +# If you modify this file, run "ua refresh config" to ensure changes are +# picked up by Ubuntu-Advantage client. + +""" + +SETTING_SERVICE_PROXY = "Setting {service} proxy" +ERROR_USING_PROXY = ( + 'Error trying to use "{proxy}" as proxy to reach "{test_url}": {error}' +) + +PROXY_DETECTED_BUT_NOT_CONFIGURED = """\ +No proxy set in config; however, proxy is configured for: {{services}}. +See {docs_url} for more information on ua proxy configuration. +""".format( + docs_url=DOCUMENTATION_URL +) + +FIPS_BLOCK_ON_CLOUD = FormattedNamedMessage( + "cloud-non-optimized-fips-kernel", + """\ +Ubuntu {series} does not provide {cloud} optimized FIPS kernel +For help see: """ + + BASE_UA_URL + + ".", +) + +UNATTACHED = NamedMessage( + "unattached", + """\ +This machine is not attached to a UA subscription. +See """ + + BASE_UA_URL, +) + +ENABLE_FAILURE_UNATTACHED = FormattedNamedMessage( + "enable-failure-unattached", + """\ +To use '{name}' you need an Ubuntu Advantage subscription +Personal and community subscriptions are available at no charge +See """ + + BASE_UA_URL, +) + +FAILED_DISABLING_DEPENDENT_SERVICE = FormattedNamedMessage( + "failed-disabling-dependent-service", + """\ +Cannot disable dependent service: {required_service}{error}""", +) + +DEPENDENT_SERVICE_NOT_FOUND = FormattedNamedMessage( + "dependent-service-not-found", "Dependent service {service} not found." +) + +DEPENDENT_SERVICE_STOPS_DISABLE = FormattedNamedMessage( + "depedent-service-stops-disable", + """\ +Cannot disable {service_being_disabled} when {dependent_service} is enabled. +""", +) + +ERROR_ENABLING_REQUIRED_SERVICE = FormattedNamedMessage( + "error-enabling-required-service", + "Cannot enable required service: {service}{error}", +) + +REQUIRED_SERVICE_STOPS_ENABLE = FormattedNamedMessage( + "required-service-stops-enable", + """\ +Cannot enable {service_being_enabled} when {required_service} is disabled. +""", +) + +INCOMPATIBLE_SERVICE_STOPS_ENABLE = FormattedNamedMessage( + "incompatible-service-stops-enable", + """\ +Cannot enable {service_being_enabled} when \ +{incompatible_service} is enabled.""", +) + +SERVICE_NOT_CONFIGURED = FormattedNamedMessage( + "service-not-configured", "{title} is not configured" +) + +SERVICE_IS_ACTIVE = FormattedNamedMessage( + "service-is-active", "{title} is active" +) + +NO_APT_URL_FOR_SERVICE = FormattedNamedMessage( + "no-apt-url-for-service", "{title} does not have an aptURL directive" +) + +ALREADY_DISABLED = FormattedNamedMessage( + "service-already-disabled", + """\ +{title} is not currently enabled\nSee: sudo ua status""", +) + +ALREADY_ENABLED = FormattedNamedMessage( + "service-already-enabled", + """\ +{title} is already enabled.\nSee: sudo ua status""", +) + +ENABLED_FAILED = FormattedNamedMessage( + "enable-failes", "Could not enable {title}." +) + +UNENTITLED = FormattedNamedMessage( + "subscription-not-entitled-to-service", + """\ +This subscription is not entitled to {title} +For more information see: """ + + BASE_UA_URL + + ".", +) + +SERVICE_NOT_ENTITLED = FormattedNamedMessage( + "service-not-entitled", "{title} is not entitled" +) + +INAPPLICABLE_KERNEL_VER = FormattedNamedMessage( + "inapplicable-kernel-version", + """\ +{title} is not available for kernel {kernel}. +Minimum kernel version required: {min_kernel}.""", +) + +INAPPLICABLE_KERNEL = FormattedNamedMessage( + "inapplicable-kernel", + """\ +{title} is not available for kernel {kernel}. +Supported flavors are: {supported_kernels}.""", +) + +INAPPLICABLE_SERIES = FormattedNamedMessage( + "inapplicable-series", + """\ +{title} is not available for Ubuntu {series}.""", +) + +INAPPLICABLE_ARCH = FormattedNamedMessage( + "inapplicable-arch", + """\ +{title} is not available for platform {arch}. +Supported platforms are: {supported_arches}.""", +) + +NO_ENTITLEMENT_AFFORDANCES_CHECKED = NamedMessage( + "no-entitlement-affordances-checked", "no entitlement affordances checked" +) + +NOT_SETTING_PROXY_INVALID_URL = FormattedNamedMessage( + "proxy-invalid-url", '"{proxy}" is not a valid url. Not setting as proxy.' +) + +NOT_SETTING_PROXY_NOT_WORKING = FormattedNamedMessage( + "proxy-not-working", '"{proxy}" is not working. Not setting as proxy.' +) + +ATTACH_INVALID_TOKEN = NamedMessage( + "attach-invalid-token", + """\ +Invalid token. See """ + + BASE_UA_URL, +) + +REQUIRED_SERVICE_NOT_FOUND = FormattedNamedMessage( + "required-service-not-found", "Required service {service} not found." +) + +UNEXPECTED_CONTRACT_TOKEN_ON_ATTACHED_MACHINE = NamedMessage( + "unexpeced-contract-token-on-attached-machine", + "Got unexpected contract_token on an already attached machine", +) + +APT_UPDATE_INVALID_REPO = FormattedNamedMessage( + "apt-update-invalid-repo", "APT update failed.\n{repo_msg}" +) + +APT_INSTALL_FAILED = NamedMessage("apt-install-failes", "APT install failed.") + +APT_UPDATE_INVALID_URL_CONFIG = FormattedNamedMessage( + "apt-update-invalid-url-config", + ( + "APT update failed to read APT config for the following " + "URL{plural}:\n{failed_repos}." + ), +) + +APT_PROCESS_CONFLICT = NamedMessage( + "apt-process-conflict", "Another process is running APT." +) + +APT_UPDATE_PROCESS_CONFLICT = NamedMessage( + "apt-update-failed-process-conflict", + "APT update failed. " + APT_PROCESS_CONFLICT.msg, +) + +APT_UPDATE_FAILED = NamedMessage("apt-update-failed", "APT Update failed") + +APT_INSTALL_PROCESS_CONFLICT = FormattedNamedMessage( + "apt-install-failed-process-conflict", + "{header_msg}APT install failed. " + APT_PROCESS_CONFLICT.msg, +) + +APT_INSTALL_INVALID_REPO = FormattedNamedMessage( + "apt-install-invalid-repo", "{header_msg}APT install failed.{repo_msg}" +) + +SNAPD_NOT_PROPERLY_INSTALLED = FormattedNamedMessage( + "snapd-not-properly-installed-for-livepatch", + ( + "{snap_cmd} is present but snapd is not installed;" + " cannot enable {service}" + ), +) + +SSL_VERIFICATION_ERROR_CA_CERTIFICATES = FormattedNamedMessage( + "ssl-verification-error-ca-certificate", + """\ +Failed to access URL: {url} +Cannot verify certificate of server +Please install "ca-certificates" and try again.""", +) + +SSL_VERIFICATION_ERROR_OPENSSL_CONFIG = FormattedNamedMessage( + "ssl-verification-error-openssl-config", + """\ +Failed to access URL: {url} +Cannot verify certificate of server +Please check your openssl configuration.""", +) + +MISSING_APT_URL_DIRECTIVE = FormattedNamedMessage( + "missing-apt-url-directive", + """\ +Ubuntu Advantage server provided no aptURL directive for {entitlement_name}""", +) + +ALREADY_ATTACHED = FormattedNamedMessage( + name="already-attached", + msg=( + "This machine is already attached to '{account_name}'\n" + "To use a different subscription first run: sudo ua detach." + ), +) + +ALREADY_ATTACHED_ON_PRO = FormattedNamedMessage( + "already-attached-on-pro", + """\ +Skipping attach: Instance '{instance_id}' is already attached.""", +) + +CONNECTIVITY_ERROR = NamedMessage( + "connectivity-error", + """\ +Failed to connect to authentication server +Check your Internet connection and try again.""", +) + +NONROOT_USER = NamedMessage( + "nonroot-user", "This command must be run as root (try using sudo)." +) + +ERROR_INSTALLING_LIVEPATCH = FormattedNamedMessage( + "error-installing-livepatch", + "Unable to install Livepatch client: {error_msg}", +) + +APT_POLICY_FAILED = NamedMessage( + "apt-policy-failed", "Failure checking APT policy." +) + +ATTACH_FORBIDDEN_EXPIRED = FormattedNamedMessage( + "attach-forbidden-expired", + """\ +Contract \"{contract_id}\" expired on {date}""", +) + +ATTACH_FORBIDDEN_NOT_YET = FormattedNamedMessage( + "attach-forbidden-not-yet", + """\ +Contract \"{contract_id}\" is not effective until {date}""", +) +ATTACH_FORBIDDEN_NEVER = FormattedNamedMessage( + "attach-forbidden-never", + """\ +Contract \"{contract_id}\" has never been effective""", +) + +ATTACH_FORBIDDEN = FormattedNamedMessage( + "attach-forbidden", + """\ +Attach denied: +{{reason}} +Visit {url} to manage contract tokens.""".format( + url=BASE_UA_URL + ), +) + +ATTACH_EXPIRED_TOKEN = NamedMessage( + "attach-experied-token", + """\ +Expired token or contract. To obtain a new token visit: """ + + BASE_UA_URL, +) + +ATTACH_TOKEN_ARG_XOR_CONFIG = NamedMessage( + "attach-token-xor-config", + """\ +Do not pass the TOKEN arg if you are using --attach-config. +Include the token in the attach-config file instead. + """, +) + +ATTACH_REQUIRES_TOKEN = NamedMessage( + "attach-requires-token", + """\ +Attach requires a token: sudo ua attach +To obtain a token please visit: """ + + BASE_UA_URL + + ".", +) + +ATTACH_FAILURE = NamedMessage( + "attach-failure", + """\ +Failed to attach machine. See """ + + BASE_UA_URL, +) + +ATTACH_FAILURE_DEFAULT_SERVICES = NamedMessage( + "attach-failure-default-service", + """\ +Failed to enable default services, check: sudo ua status""", +) + +INVALID_CONTRACT_DELTAS_SERVICE_TYPE = FormattedNamedMessage( + "invalid-contract-deltas-service-type", + "Could not determine contract delta service type {orig} {new}", +) + +INVALID_SERVICE_OP_FAILURE = FormattedNamedMessage( + "invalid-service-or-failure", + """\ +Cannot {operation} unknown service '{name}'. +{service_msg}""", +) + +LOCK_HELD = FormattedNamedMessage( + "lock-held", """Operation in progress: {lock_holder} (pid:{pid})""" +) + +LOCK_HELD_ERROR = FormattedNamedMessage( + "lock-held-error", + """\ +Unable to perform: {lock_request}. +""" + + LOCK_HELD.tmpl_msg, +) + +UNEXPECTED_ERROR = NamedMessage( + "unexpected-error", + """\ +Unexpected error(s) occurred. +For more details, see the log: /var/log/ubuntu-advantage.log +To file a bug run: ubuntu-bug ubuntu-advantage-tools""", +) + +ATTACH_CONFIG_READ_ERROR = FormattedNamedMessage( + "attach-config-read-error", "Error while reading {config_name}: {error}" +) + +JSON_FORMAT_REQUIRE_ASSUME_YES = NamedMessage( + "json-format-require-assume-yes", + """\ +json formatted response requires --assume-yes flag.""", +) + +LIVEPATCH_NOT_ENABLED = NamedMessage( + "livepatch-not-enabled", "canonical-livepatch snap is not installed." +) + +LIVEPATCH_ERROR_INSTALL_ON_CONTAINER = NamedMessage( + "livepatch-error-install-on-container", + "Cannot install Livepatch on a container.", +) + +LIVEPATCH_ERROR_WHEN_FIPS_ENABLED = NamedMessage( + "livepatch-error-when-fips-enabled", + "Cannot enable Livepatch when FIPS is enabled.", +) + +FIPS_REBOOT_REQUIRED = NamedMessage( + "fips-reboot-required", "Reboot to FIPS kernel required" +) + +FIPS_SYSTEM_REBOOT_REQUIRED = NamedMessage( + "fips-system-reboot-required", + "FIPS support requires system reboot to complete configuration.", +) + +FIPS_ERROR_WHEN_FIPS_UPDATES_ENABLED = FormattedNamedMessage( + "fips-enable-when-fips-updates-enabled", + "Cannot enable {fips} when {fips_updates} is enabled.", +) + +FIPS_PROC_FILE_ERROR = FormattedNamedMessage( + "fips-proc-file-error", "{file_name} is not set to 1" +) + +FIPS_ERROR_WHEN_FIPS_UPDATES_ONCE_ENABLED = FormattedNamedMessage( + "fips-enable-when-fips-updates-once-enabled", + "Cannot enable {fips} because {fips_updates} was once enabled.", +) + +FIPS_UPDATES_INVALIDATES_FIPS = NamedMessage( + "fips-updates-invalidates-fips", + "FIPS cannot be enabled if FIPS Updates has ever been enabled because" + " FIPS Updates installs security patches that aren't officially" + " certified.", +) +LIVEPATCH_INVALIDATES_FIPS = NamedMessage( + "livepatch-invalidates-fips", + "Livepatch cannot be enabled while running the official FIPS" + " certified kernel. If you would like a FIPS compliant kernel" + " with additional bug fixes and security updates, you can use" + " the FIPS Updates service with Livepatch.", +) + +LOG_CONNECTIVITY_ERROR_TMPL = CONNECTIVITY_ERROR.msg + " {error}" +LOG_CONNECTIVITY_ERROR_WITH_URL_TMPL = ( + CONNECTIVITY_ERROR.msg + " Failed to access URL: {url}. {error}" +) diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/security.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/security.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/security.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/security.py 2022-03-10 17:17:29.000000000 +0000 @@ -1,6 +1,5 @@ import copy import enum -import itertools import os import socket import textwrap @@ -8,7 +7,7 @@ from datetime import datetime from typing import Any, Dict, List, NamedTuple, Optional, Set, Tuple -from uaclient import apt, exceptions, serviceclient, status, util +from uaclient import apt, exceptions, messages, serviceclient, status, util from uaclient.clouds.identity import ( CLOUD_TYPE_TO_TITLE, PRO_CLOUDS, @@ -54,32 +53,11 @@ SYSTEM_VULNERABLE_UNTIL_REBOOT = 2 -class SecurityAPIError(util.UrlError): - def __init__(self, e, error_response): - super().__init__(e, e.code, e.headers, e.url) - self.message = error_response.get("message", "") - - def __contains__(self, error_code): - return bool(error_code in self.message) - - def __get__(self, error_str, default=None): - if error_str in self.message: - return self.message - return default - - def __str__(self): - prefix = super().__str__() - details = [self.message] - if details: - return prefix + ": [" + self.url + "] " + ", ".join(details) - return prefix + ": [" + self.url + "]" - - class UASecurityClient(serviceclient.UAServiceClient): url_timeout = 20 cfg_url_base_attr = "security_url" - api_error_cls = SecurityAPIError + api_error_cls = exceptions.SecurityAPIError def _get_query_params( self, query_params: Dict[str, Any] @@ -234,7 +212,7 @@ elif self.status == "not-affected": return "Source package is not affected on this release." elif self.status == "released": - return status.MESSAGE_SECURITY_FIX_RELEASE_STREAM.format( + return messages.SECURITY_FIX_RELEASE_STREAM.format( fix_stream=self.pocket_source ) return "UNKNOWN: {}".format(self.status) @@ -378,14 +356,23 @@ def title(self): return self.response.get("title") + @property + def references(self): + return self.response.get("references") + def get_url_header(self): """Return a string representing the URL for this notice.""" - lines = [ - "{issue}: {title}".format(issue=self.id, title=self.title), - "Found CVEs:", - ] - for cve in self.cves_ids: - lines.append("https://ubuntu.com/security/{}".format(cve)) + lines = ["{issue}: {title}".format(issue=self.id, title=self.title)] + + if self.cves_ids: + lines.append("Found CVEs:") + for cve in self.cves_ids: + lines.append("https://ubuntu.com/security/{}".format(cve)) + elif self.references: + lines.append("Found Launchpad bugs:") + for reference in self.references: + lines.append(reference) + return "\n".join(lines) @property @@ -491,7 +478,7 @@ For each USN, iterate over release_packages to collect released binary package names and required fix version. If multiple related USNs - require differnt version fixes to the same binary package, track the + require different version fixes to the same binary package, track the maximum version required across all USNs. :param usns: List of USN response instances from which to calculate merge. @@ -531,6 +518,31 @@ return usn_pkg_versions +def get_related_usns(usn, client): + """For a give usn, get the related USNs for it. + + For each CVE associated with the given USN, we capture + other USNs that are related to the CVE. We consider those + USNs related to the original USN. + """ + + # If the usn does not have any associated cves on it, + # we must consider that only the current usn should be + # evaluated. + if not usn.cves: + return [usn] + + related_usns = {} + for cve in usn.cves: + for related_usn_id in cve.notices_ids: + if related_usn_id not in related_usns: + related_usns[related_usn_id] = client.get_notice( + notice_id=related_usn_id + ) + + return list(sorted(related_usns.values(), key=lambda x: x.id)) + + def fix_security_issue_id(cfg: UAConfig, issue_id: str) -> FixStatus: issue_id = issue_id.upper() client = UASecurityClient(cfg=cfg) @@ -538,18 +550,18 @@ # Used to filter out beta pockets during merge_usns beta_pockets = { - "esm-apps": _is_pocket_used_by_beta_service("esm-apps", cfg), - "esm-infra": _is_pocket_used_by_beta_service("esm-infra", cfg), + "esm-apps": _is_pocket_used_by_beta_service(UA_APPS_POCKET, cfg), + "esm-infra": _is_pocket_used_by_beta_service(UA_INFRA_POCKET, cfg), } if "CVE" in issue_id: try: cve = client.get_cve(cve_id=issue_id) usns = client.get_notices(details=issue_id) - except SecurityAPIError as e: + except exceptions.SecurityAPIError as e: msg = str(e) if "not found" in msg.lower(): - msg = status.MESSAGE_SECURITY_FIX_NOT_FOUND_ISSUE.format( + msg = messages.SECURITY_FIX_NOT_FOUND_ISSUE.format( issue_id=issue_id ) raise exceptions.UserFacingError(msg) @@ -561,20 +573,13 @@ usns, beta_pockets ) else: # USN - related_usns = {} try: usn = client.get_notice(notice_id=issue_id) - for cve in usn.cves: - for related_usn_id in cve.notices_ids: - if related_usn_id not in related_usns: - related_usns[related_usn_id] = client.get_notice( - notice_id=related_usn_id - ) - usns = list(sorted(related_usns.values(), key=lambda x: x.id)) - except SecurityAPIError as e: + usns = get_related_usns(usn, client) + except exceptions.SecurityAPIError as e: msg = str(e) if "not found" in msg.lower(): - msg = status.MESSAGE_SECURITY_FIX_NOT_FOUND_ISSUE.format( + msg = messages.SECURITY_FIX_NOT_FOUND_ISSUE.format( issue_id=issue_id ) raise exceptions.UserFacingError(msg) @@ -585,12 +590,6 @@ usns, beta_pockets ) print(usn.get_url_header()) - related_cves = set(itertools.chain(*[u.cves_ids for u in usns])) - if not related_cves: - raise exceptions.SecurityAPIMetadataError( - "{} metadata defines no related CVEs.".format(issue_id), - issue_id=issue_id, - ) if not usn.response["release_packages"]: # Since usn.release_packages filters to our current release only # check overall metadata and error if empty. @@ -609,16 +608,10 @@ ) -def get_usn_affected_packages_status( - usn: USN, installed_packages: Dict[str, Dict[str, str]] -) -> Dict[str, CVEPackageStatus]: - """Walk CVEs related to a USN and return a dict of all affected packages. - - :return: Dict keyed on source package name, with active CVEPackageStatus - for the current Ubuntu release. - """ +def get_affected_packages_from_cves(cves, installed_packages): affected_pkgs = {} # type: Dict[str, CVEPackageStatus] - for cve in usn.cves: + + for cve in cves: for pkg_name, pkg_status in get_cve_affected_source_packages_status( cve, installed_packages ).items(): @@ -628,9 +621,54 @@ current_ver = affected_pkgs[pkg_name].fixed_version if not version_cmp_le(current_ver, pkg_status.fixed_version): affected_pkgs[pkg_name] = pkg_status + + return affected_pkgs + + +def get_affected_packages_from_usn(usn, installed_packages): + affected_pkgs = {} # type: Dict[str, CVEPackageStatus] + for pkg_name, pkg_info in usn.release_packages.items(): + if pkg_name not in installed_packages: + continue + + cve_response = defaultdict(str) + cve_response["status"] = "released" + # Here we are assuming that the pocket will be the same one across + # the different binary packages. + all_pockets = { + pkg_bin_info["pocket"] + for _, pkg_bin_info in pkg_info.items() + if pkg_bin_info.get("pocket") + } + if not all_pockets: + msg = ( + "{} metadata defines no pocket information for " + "any release packages." + ) + raise exceptions.SecurityAPIMetadataError( + msg.format(usn.id), issue_id=usn.id + ) + cve_response["pocket"] = all_pockets.pop() + + affected_pkgs[pkg_name] = CVEPackageStatus(cve_response=cve_response) + return affected_pkgs +def get_usn_affected_packages_status( + usn: USN, installed_packages: Dict[str, Dict[str, str]] +) -> Dict[str, CVEPackageStatus]: + """Walk CVEs related to a USN and return a dict of all affected packages. + + :return: Dict keyed on source package name, with active CVEPackageStatus + for the current Ubuntu release. + """ + if usn.cves: + return get_affected_packages_from_cves(usn.cves, installed_packages) + else: + return get_affected_packages_from_usn(usn, installed_packages) + + def get_cve_affected_source_packages_status( cve: CVE, installed_packages: Dict[str, Dict[str, str]] ) -> Dict[str, CVEPackageStatus]: @@ -659,12 +697,12 @@ count = len(affected_pkg_status) if count == 0: print( - status.MESSAGE_SECURITY_AFFECTED_PKGS.format( + messages.SECURITY_AFFECTED_PKGS.format( count="No", plural_str="s are" ) + "." ) - print(status.MESSAGE_SECURITY_ISSUE_UNAFFECTED.format(issue=issue_id)) + print(messages.SECURITY_ISSUE_UNAFFECTED.format(issue=issue_id)) return if count == 1: @@ -672,7 +710,7 @@ else: plural_str = "s are" msg = ( - status.MESSAGE_SECURITY_AFFECTED_PKGS.format( + messages.SECURITY_AFFECTED_PKGS.format( count=count, plural_str=plural_str ) + ": " @@ -778,7 +816,7 @@ if ent_status == status.UserFacingStatus.ACTIVE: return False - return ent.valid_service + return not ent.valid_service return False @@ -821,7 +859,7 @@ print(msg) if not binary_pkgs: - print(status.MESSAGE_SECURITY_UPDATE_INSTALLED) + print(messages.SECURITY_UPDATE_INSTALLED) continue else: # if even one pocket has binary_pkgs to install @@ -885,7 +923,7 @@ print_affected_packages_header(issue_id, affected_pkg_status) if count == 0: return FixStatus.SYSTEM_NON_VULNERABLE - fix_message = status.MESSAGE_SECURITY_ISSUE_RESOLVED.format(issue=issue_id) + fix_message = messages.SECURITY_ISSUE_RESOLVED.format(issue=issue_id) src_pocket_pkgs = defaultdict(list) binary_pocket_pkgs = defaultdict(list) pkg_index = 0 @@ -897,7 +935,7 @@ unfixed_pkgs = [] for status_value, pkg_status_group in sorted(pkg_status_groups.items()): if status_value != "released": - fix_message = status.MESSAGE_SECURITY_ISSUE_NOT_RESOLVED.format( + fix_message = messages.SECURITY_ISSUE_NOT_RESOLVED.format( issue=issue_id ) print( @@ -968,16 +1006,14 @@ # we successfully installed some packages, but # system reboot-required. This might be because # or our installations. - reboot_msg = status.MESSAGE_ENABLE_REBOOT_REQUIRED_TMPL.format( + reboot_msg = messages.ENABLE_REBOOT_REQUIRED_TMPL.format( operation="fix operation" ) print(reboot_msg) cfg.add_notice("", reboot_msg) print( util.handle_unicode_characters( - status.MESSAGE_SECURITY_ISSUE_NOT_RESOLVED.format( - issue=issue_id - ) + messages.SECURITY_ISSUE_NOT_RESOLVED.format(issue=issue_id) ) ) return FixStatus.SYSTEM_VULNERABLE_UNTIL_REBOOT @@ -993,9 +1029,7 @@ else: print( util.handle_unicode_characters( - status.MESSAGE_SECURITY_ISSUE_NOT_RESOLVED.format( - issue=issue_id - ) + messages.SECURITY_ISSUE_NOT_RESOLVED.format(issue=issue_id) ) ) return FixStatus.SYSTEM_STILL_VULNERABLE @@ -1006,7 +1040,7 @@ cloud_type, _ = get_cloud_type() if cloud_type in PRO_CLOUDS: print( - status.MESSAGE_SECURITY_USE_PRO_TMPL.format( + messages.SECURITY_USE_PRO_TMPL.format( title=CLOUD_TYPE_TO_TITLE.get(cloud_type), cloud=cloud_type ) ) @@ -1025,7 +1059,10 @@ return bool( 0 == cli.action_attach( - argparse.Namespace(token=token, auto_enable=True), cfg + argparse.Namespace( + token=token, auto_enable=True, format="cli", attach_config=None + ), + cfg, ) ) @@ -1036,7 +1073,7 @@ :return: True if attach performed. """ _inform_ubuntu_pro_existence_if_applicable() - print(status.MESSAGE_SECURITY_UPDATE_NOT_INSTALLED_SUBSCRIPTION) + print(messages.SECURITY_UPDATE_NOT_INSTALLED_SUBSCRIPTION) choice = util.prompt_choices( "Choose: [S]ubscribe at ubuntu.com [A]ttach existing token [C]ancel", valid_choices=["s", "a", "c"], @@ -1064,7 +1101,7 @@ from uaclient import cli - print(status.MESSAGE_SECURITY_SERVICE_DISABLED.format(service=service)) + print(messages.SECURITY_SERVICE_DISABLED.format(service=service)) choice = util.prompt_choices( "Choose: [E]nable {} [C]ancel".format(service), valid_choices=["e", "c"], @@ -1103,13 +1140,13 @@ return True else: print( - status.MESSAGE_SECURITY_UA_SERVICE_NOT_ENABLED.format( + messages.SECURITY_UA_SERVICE_NOT_ENABLED.format( service=ent.name ) ) else: print( - status.MESSAGE_SECURITY_UA_SERVICE_NOT_ENTITLED.format( + messages.SECURITY_UA_SERVICE_NOT_ENTITLED.format( service=ent.name ) ) @@ -1127,7 +1164,7 @@ from uaclient import cli _inform_ubuntu_pro_existence_if_applicable() - print(status.MESSAGE_SECURITY_UPDATE_NOT_INSTALLED_EXPIRED) + print(messages.SECURITY_UPDATE_NOT_INSTALLED_EXPIRED) choice = util.prompt_choices( "Choose: [R]enew your subscription (at {}) [C]ancel".format( BASE_UA_URL @@ -1171,7 +1208,7 @@ return True if os.getuid() != 0: - print(status.MESSAGE_SECURITY_APT_NON_ROOT) + print(messages.SECURITY_APT_NON_ROOT) return False if pocket != UBUNTU_STANDARD_UPDATES_POCKET: @@ -1197,11 +1234,11 @@ ) ) apt.run_apt_command( - cmd=["apt-get", "update"], error_msg=status.MESSAGE_APT_UPDATE_FAILED + cmd=["apt-get", "update"], error_msg=messages.APT_UPDATE_FAILED.msg ) apt.run_apt_command( cmd=["apt-get", "install", "--only-upgrade", "-y"] + upgrade_packages, - error_msg=status.MESSAGE_APT_INSTALL_FAILED, + error_msg=messages.APT_INSTALL_FAILED.msg, env={"DEBIAN_FRONTEND": "noninteractive"}, ) return True @@ -1212,5 +1249,5 @@ try: util.subp(["dpkg", "--compare-versions", version1, "le", version2]) return True - except util.ProcessExecutionError: + except exceptions.ProcessExecutionError: return False diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/security_status.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/security_status.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/security_status.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/security_status.py 2022-03-10 17:17:29.000000000 +0000 @@ -1,5 +1,6 @@ +from collections import defaultdict from enum import Enum -from typing import Any, Dict, List +from typing import Any, DefaultDict, Dict, List, Tuple # noqa: F401 from apt import Cache # type: ignore from apt import package as apt_package @@ -7,15 +8,19 @@ from uaclient.config import UAConfig from uaclient.util import get_platform_info +series = get_platform_info()["series"] + ESM_SERVICES = ("esm-infra", "esm-apps") -SECURITY_REPO_TEMPLATES = ( - "{}-security", - "{}-apps-security", - "{}-infra-security", -) -ESM_INFRA_ORIGIN = "UbuntuESM" -ESM_APPS_ORIGIN = "UbuntuESMApps" +SERVICE_TO_ORIGIN_INFORMATION = { + "standard-security": ("Ubuntu", "{}-security".format(series)), + "esm-apps": ("UbuntuESMApps", "{}-apps-security".format(series)), + "esm-infra": ("UbuntuESM", "{}-infra-security".format(series)), +} + +ORIGIN_INFORMATION_TO_SERVICE = { + v: k for k, v in SERVICE_TO_ORIGIN_INFORMATION.items() +} class UpdateStatus(Enum): @@ -26,20 +31,29 @@ UNAVAILABLE = "upgrade_unavailable" -def get_service_name(origins: List[apt_package.Origin]) -> str: +def list_esm_for_package(package: apt_package.Package) -> List[str]: + esm_services = [] + for origin in package.installed.origins: + if (origin.origin, origin.archive) == SERVICE_TO_ORIGIN_INFORMATION[ + "esm-infra" + ]: + esm_services.append("esm-infra") + if (origin.origin, origin.archive) == SERVICE_TO_ORIGIN_INFORMATION[ + "esm-apps" + ]: + esm_services.append("esm-apps") + return esm_services + + +def get_service_name(origins: List[apt_package.Origin]) -> Tuple[str, str]: "Translates the archive name in the version origin to a UA service name." for origin in origins: - if ( - "infra-security" in origin.archive - and origin.origin == ESM_INFRA_ORIGIN - ): - return "esm-infra" - if ( - "apps-security" in origin.archive - and origin.origin == ESM_APPS_ORIGIN - ): - return "esm-apps" - return "standard-security" + service = ORIGIN_INFORMATION_TO_SERVICE.get( + (origin.origin, origin.archive) + ) + if service: + return service, origin.site + return ("", "") def get_update_status(service_name: str, ua_info: Dict[str, Any]) -> str: @@ -67,17 +81,15 @@ Checks if the package has a greater version available, and if the origin of this version matches any of the series' security repositories. """ - series = get_platform_info()["series"] - security_repos = [ - template.format(series) for template in SECURITY_REPO_TEMPLATES - ] - return [ version for package in packages for version in package.versions if version > package.installed - and any(origin.archive in security_repos for origin in version.origins) + and any( + (origin.origin, origin.archive) in ORIGIN_INFORMATION_TO_SERVICE + for origin in version.origins + ) ] @@ -120,27 +132,36 @@ installed_packages = [package for package in cache if package.is_installed] summary["num_installed_packages"] = len(installed_packages) - security_upgradable_versions = filter_security_updates(installed_packages) + package_count = defaultdict(int) # type: DefaultDict[str, int] + update_count = defaultdict(int) # type: DefaultDict[str, int] - package_count = {"esm-infra": 0, "esm-apps": 0, "standard-security": 0} + for package in installed_packages: + esm_services = list_esm_for_package(package) + for service in esm_services: + package_count[service] += 1 + + security_upgradable_versions = filter_security_updates(installed_packages) for candidate in security_upgradable_versions: - service_name = get_service_name(candidate.origins) + service_name, origin_site = get_service_name(candidate.origins) status = get_update_status(service_name, ua_info) - package_count[service_name] += 1 + update_count[service_name] += 1 packages.append( { "package": candidate.package.name, "version": candidate.version, "service_name": service_name, "status": status, + "origin": origin_site, } ) - summary["num_esm_infra_updates"] = package_count["esm-infra"] - summary["num_esm_apps_updates"] = package_count["esm-apps"] - summary["num_standard_security_updates"] = package_count[ + summary["num_esm_infra_packages"] = package_count["esm-infra"] + summary["num_esm_apps_packages"] = package_count["esm-apps"] + summary["num_esm_infra_updates"] = update_count["esm-infra"] + summary["num_esm_apps_updates"] = update_count["esm-apps"] + summary["num_standard_security_updates"] = update_count[ "standard-security" ] - return {"_schema_version": "0", "summary": summary, "packages": packages} + return {"_schema_version": "0.1", "summary": summary, "packages": packages} diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/serviceclient.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/serviceclient.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/serviceclient.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/serviceclient.py 2022-03-10 17:17:29.000000000 +0000 @@ -6,7 +6,7 @@ from urllib import error from urllib.parse import urlencode -from uaclient import config, util, version +from uaclient import config, exceptions, util, version class UAServiceClient(metaclass=abc.ABCMeta): @@ -82,7 +82,7 @@ error_details = None if error_details: raise self.api_error_cls(e, error_details) - raise util.UrlError( + raise exceptions.UrlError( e, code=getattr(e, "code", None), headers=headers, url=url ) return response, headers @@ -129,7 +129,7 @@ :return: A tuple of response and header dicts if the URL has an overlay response defined. Return (None, {}) otherwise. - :raises util.URLError: When faked response "code" is != 200. + :raises exceptions.URLError: When faked response "code" is != 200. URLError reason will be "response" value and any optional "headers" provided. """ @@ -146,7 +146,7 @@ return response["response"], response.get("headers", {}) # Must be an error e = error.URLError(response["response"]) - raise util.UrlError( + raise exceptions.UrlError( e, code=response["code"], headers=response.get("headers", {}), diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/snap.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/snap.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/snap.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/snap.py 2022-03-10 17:17:29.000000000 +0000 @@ -1,13 +1,15 @@ import logging from typing import List, Optional -from uaclient import apt, status, util +from uaclient import apt, event_logger, exceptions, messages, util SNAP_CMD = "/usr/bin/snap" SNAP_INSTALL_RETRIES = [0.5, 1.0, 5.0] HTTP_PROXY_OPTION = "proxy.http" HTTPS_PROXY_OPTION = "proxy.https" +event = event_logger.get_event_logger() + def is_installed() -> bool: """Returns whether or not snap is installed""" @@ -40,7 +42,7 @@ return if http_proxy or https_proxy: - print(status.MESSAGE_SETTING_SERVICE_PROXY.format(service="snap")) + event.info(messages.SETTING_SERVICE_PROXY.format(service="snap")) if http_proxy: util.subp( @@ -89,5 +91,5 @@ try: out, _ = util.subp(["snap", "get", "system", key]) return out.strip() - except util.ProcessExecutionError: + except exceptions.ProcessExecutionError: return None diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/status.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/status.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/status.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/status.py 2022-03-10 17:17:29.000000000 +0000 @@ -1,5 +1,4 @@ import enum -import json import os import sys import textwrap @@ -8,20 +7,9 @@ from uaclient.defaults import ( BASE_UA_URL, CONFIG_FIELD_ENVVAR_ALLOWLIST, - DOCUMENTATION_URL, PRINT_WRAP_WIDTH, ) - - -class TxtColor: - OKGREEN = "\033[92m" - DISABLEGREY = "\033[37m" - FAIL = "\033[91m" - ENDC = "\033[0m" - - -OKGREEN_CHECK = TxtColor.OKGREEN + "✔" + TxtColor.ENDC -FAIL_X = TxtColor.FAIL + "✘" + TxtColor.ENDC +from uaclient.messages import UNATTACHED, NamedMessage, TxtColor @enum.unique @@ -118,7 +106,30 @@ class CanEnableFailure: def __init__( - self, reason: CanEnableFailureReason, message: Optional[str] = None + self, + reason: CanEnableFailureReason, + message: Optional[NamedMessage] = None, + ) -> None: + self.reason = reason + self.message = message + + +@enum.unique +class CanDisableFailureReason(enum.Enum): + """ + An enum representing the reasons an entitlement can't be disabled. + """ + + ALREADY_DISABLED = object() + ACTIVE_DEPENDENT_SERVICES = object() + NOT_FOUND_DEPENDENT_SERVICE = object() + + +class CanDisableFailure: + def __init__( + self, + reason: CanDisableFailureReason, + message: Optional[NamedMessage] = None, ) -> None: self.reason = reason self.message = message @@ -157,151 +168,7 @@ ADVANCED: TxtColor.OKGREEN + ADVANCED + TxtColor.ENDC, } -MESSAGE_SECURITY_FIX_NOT_FOUND_ISSUE = "Error: {issue_id} not found." -MESSAGE_SECURITY_FIX_RELEASE_STREAM = "A fix is available in {fix_stream}." -MESSAGE_SECURITY_UPDATE_NOT_INSTALLED = "The update is not yet installed." -MESSAGE_SECURITY_UPDATE_NOT_INSTALLED_SUBSCRIPTION = """\ -The update is not installed because this system is not attached to a -subscription. -""" -MESSAGE_SECURITY_UPDATE_NOT_INSTALLED_EXPIRED = """\ -The update is not installed because this system is attached to an -expired subscription. -""" -MESSAGE_SECURITY_SERVICE_DISABLED = """\ -The update is not installed because this system does not have -{service} enabled. -""" -MESSAGE_SECURITY_UPDATE_INSTALLED = "The update is already installed." -MESSAGE_SECURITY_USE_PRO_TMPL = ( - "For easiest security on {title}, use Ubuntu Pro." - " https://ubuntu.com/{cloud}/pro." -) -MESSAGE_SECURITY_ISSUE_RESOLVED = OKGREEN_CHECK + " {issue} is resolved." -MESSAGE_SECURITY_ISSUE_NOT_RESOLVED = FAIL_X + " {issue} is not resolved." -MESSAGE_SECURITY_ISSUE_UNAFFECTED = ( - OKGREEN_CHECK + " {issue} does not affect your system." -) -MESSAGE_SECURITY_AFFECTED_PKGS = ( - "{count} affected package{plural_str} installed" -) -MESSAGE_USN_FIXED = "{issue} is addressed." -MESSAGE_CVE_FIXED = "{issue} is resolved." -MESSAGE_SECURITY_URL = ( - "{issue}: {title}\nhttps://ubuntu.com/security/{url_path}" -) -MESSAGE_SECURITY_UA_SERVICE_NOT_ENABLED = """\ -Error: UA service: {service} is not enabled. -Without it, we cannot fix the system.""" -MESSAGE_SECURITY_UA_SERVICE_NOT_ENTITLED = """\ -Error: The current UA subscription is not entitled to: {service}. -Without it, we cannot fix the system.""" -MESSAGE_APT_INSTALL_FAILED = "APT install failed." -MESSAGE_APT_UPDATE_FAILED = "APT update failed." -MESSAGE_APT_UPDATE_INVALID_URL_CONFIG = ( - "APT update failed to read APT config for the following URL{}:\n{}." -) -MESSAGE_APT_POLICY_FAILED = "Failure checking APT policy." -MESSAGE_APT_UPDATING_LISTS = "Updating package lists" -MESSAGE_CONNECTIVITY_ERROR = """\ -Failed to connect to authentication server -Check your Internet connection and try again.""" -LOG_CONNECTIVITY_ERROR_TMPL = MESSAGE_CONNECTIVITY_ERROR + " {error}" -LOG_CONNECTIVITY_ERROR_WITH_URL_TMPL = ( - MESSAGE_CONNECTIVITY_ERROR + " Failed to access URL: {url}. {error}" -) -MESSAGE_SSL_VERIFICATION_ERROR_CA_CERTIFICATES = """\ -Failed to access URL: {url} -Cannot verify certificate of server -Please install "ca-certificates" and try again.""" -MESSAGE_SSL_VERIFICATION_ERROR_OPENSSL_CONFIG = """\ -Failed to access URL: {url} -Cannot verify certificate of server -Please check your openssl configuration.""" -MESSAGE_NONROOT_USER = "This command must be run as root (try using sudo)." -MESSAGE_ALREADY_DISABLED_TMPL = """\ -{title} is not currently enabled\nSee: sudo ua status""" -MESSAGE_ENABLED_FAILED_TMPL = "Could not enable {title}." -MESSAGE_DISABLE_FAILED_TMPL = "Could not disable {title}." -MESSAGE_ENABLED_TMPL = "{title} enabled" -MESSAGE_ALREADY_ATTACHED = """\ -This machine is already attached to '{account_name}' -To use a different subscription first run: sudo ua detach.""" -MESSAGE_ALREADY_ATTACHED_ON_PRO = """\ -Skipping attach: Instance '{instance_id}' is already attached.""" -MESSAGE_ALREADY_ENABLED_TMPL = """\ -{title} is already enabled.\nSee: sudo ua status""" -MESSAGE_INAPPLICABLE_ARCH_TMPL = """\ -{title} is not available for platform {arch}. -Supported platforms are: {supported_arches}.""" -MESSAGE_INAPPLICABLE_SERIES_TMPL = """\ -{title} is not available for Ubuntu {series}.""" -MESSAGE_INAPPLICABLE_KERNEL_TMPL = """\ -{title} is not available for kernel {kernel}. -Supported flavors are: {supported_kernels}.""" -MESSAGE_INAPPLICABLE_KERNEL_VER_TMPL = """\ -{title} is not available for kernel {kernel}. -Minimum kernel version required: {min_kernel}.""" -MESSAGE_UNENTITLED_TMPL = ( - """\ -This subscription is not entitled to {title} -For more information see: """ - + BASE_UA_URL - + "." -) -MESSAGE_UNABLE_TO_DETERMINE_CLOUD_TYPE = ( - """\ -Unable to determine auto-attach platform support -For more information see: """ - + BASE_UA_URL - + "." -) -MESSAGE_UNSUPPORTED_AUTO_ATTACH_CLOUD_TYPE = ( - """\ -Auto-attach image support is not available on {cloud_type} -See: """ - + BASE_UA_URL -) -MESSAGE_UNSUPPORTED_AUTO_ATTACH = ( - """\ -Auto-attach image support is not available on this image -See: """ - + BASE_UA_URL -) -MESSAGE_UNATTACHED = ( - """\ -This machine is not attached to a UA subscription. -See """ - + BASE_UA_URL -) -MESSAGE_MISSING_APT_URL_DIRECTIVE = """\ -Ubuntu Advantage server provided no aptURL directive for {entitlement_name}""" -MESSAGE_NO_ACTIVE_OPERATIONS = """No Ubuntu Advantage operations are running""" -MESSAGE_LOCK_HELD = """Operation in progress: {lock_holder} (pid:{pid})""" PROMPT_YES_NO = """Are you sure? (y/N) """ -MESSAGE_REBOOT_SCRIPT_FAILED = ( - "Failed running reboot_cmds script. See: /var/log/ubuntu-advantage.log" -) -MESSAGE_LIVEPATCH_LTS_REBOOT_REQUIRED = ( - "Livepatch support requires a system reboot across LTS upgrade." -) -MESSAGE_SNAPD_DOES_NOT_HAVE_WAIT_CMD = ( - "snapd does not have wait command.\n" - "Enabling Livepatch can fail under this scenario\n" - "Please, upgrade snapd if Livepatch enable fails and try again." -) -MESSAGE_FIPS_INSTALL_OUT_OF_DATE = ( - "This FIPS install is out of date, run: sudo ua enable fips" -) -MESSAGE_FIPS_REBOOT_REQUIRED = ( - "FIPS support requires system reboot to complete configuration." -) -MESSAGE_FIPS_DISABLE_REBOOT_REQUIRED = ( - "Disabling FIPS requires system reboot to complete operation." -) -MESSAGE_FIPS_PACKAGE_NOT_AVAILABLE = ( - "{service} {pkg} package could not be installed" -) NOTICE_FIPS_MANUAL_DISABLE_URL = """\ FIPS kernel is running in a disabled state. To manually remove fips kernel: https://discourse.ubuntu.com/t/20738 @@ -329,6 +196,17 @@ """ + PROMPT_YES_NO ) +PROMPT_FIPS_CONTAINER_PRE_ENABLE = ( + """\ +Warning: Enabling {title} in a container. + This will install the FIPS packages but not the kernel. + This container must run on a host with {title} enabled to be + compliant. +Warning: This action can take some time and cannot be undone. +""" + + PROMPT_YES_NO +) + PROMPT_FIPS_PRE_DISABLE = ( """\ This will disable the FIPS entitlement but the FIPS packages will remain installed. @@ -359,227 +237,6 @@ # that factor into formats len() calculations STATUS_TMPL = "{name: <14}{entitled: <19}{status: <19}{description}" -MESSAGE_ATTACH_FORBIDDEN_EXPIRED = """\ -Contract \"{contract_id}\" expired on {date}""" -MESSAGE_ATTACH_FORBIDDEN_NOT_YET = """\ -Contract \"{contract_id}\" is not effective until {date}""" -MESSAGE_ATTACH_FORBIDDEN_NEVER = """\ -Contract \"{contract_id}\" has never been effective""" -MESSAGE_ATTACH_FORBIDDEN = """\ -Attach denied: -{{reason}} -Visit {url} to manage contract tokens.""".format( - url=BASE_UA_URL -) -MESSAGE_ATTACH_EXPIRED_TOKEN = ( - """\ -Expired token or contract. To obtain a new token visit: """ - + BASE_UA_URL -) -MESSAGE_ATTACH_INVALID_TOKEN = ( - """\ -Invalid token. See """ - + BASE_UA_URL -) -MESSAGE_ATTACH_REQUIRES_TOKEN = ( - """\ -Attach requires a token: sudo ua attach -To obtain a token please visit: """ - + BASE_UA_URL - + "." -) -MESSAGE_ATTACH_FAILURE = ( - """\ -Failed to attach machine. See """ - + BASE_UA_URL -) -MESSAGE_ATTACH_FAILURE_DEFAULT_SERVICES = """\ -Failed to enable default services, check: sudo ua status""" -MESSAGE_ATTACH_SUCCESS_TMPL = """\ -This machine is now attached to '{contract_name}' -""" -MESSAGE_ATTACH_SUCCESS_NO_CONTRACT_NAME = """\ -This machine is now successfully attached' -""" - -MESSAGE_INVALID_SERVICE_OP_FAILURE_TMPL = """\ -Cannot {operation} unknown service '{name}'. -{service_msg}""" -MESSAGE_UNEXPECTED_ERROR = """\ -Unexpected error(s) occurred. -For more details, see the log: /var/log/ubuntu-advantage.log -To file a bug run: ubuntu-bug ubuntu-advantage-tools""" -MESSAGE_ENABLE_FAILURE_UNATTACHED_TMPL = ( - """\ -To use '{name}' you need an Ubuntu Advantage subscription -Personal and community subscriptions are available at no charge -See """ - + BASE_UA_URL -) -MESSAGE_ENABLE_BY_DEFAULT_TMPL = "Enabling default service {name}" -MESSAGE_ENABLE_REBOOT_REQUIRED_TMPL = """\ -A reboot is required to complete {operation}.""" -MESSAGE_ENABLE_BY_DEFAULT_MANUAL_TMPL = """\ -Service {name} is recommended by default. Run: sudo ua enable {name}""" -MESSAGE_DETACH_SUCCESS = "This machine is now detached." -MESSAGE_DETACH_AUTOMATION_FAILURE = "Unable to automatically detach machine" - -MESSAGE_REFRESH_CONTRACT_ENABLE = ( - "One moment, checking your subscription first" -) -MESSAGE_REFRESH_CONTRACT_SUCCESS = "Successfully refreshed your subscription." -MESSAGE_REFRESH_CONTRACT_FAILURE = "Unable to refresh your subscription" -MESSAGE_REFRESH_CONFIG_SUCCESS = ( - "Successfully processed your ua configuration." -) -MESSAGE_REFRESH_CONFIG_FAILURE = "Unable to process uaclient.conf" - -MESSAGE_INCOMPATIBLE_SERVICE = """\ -{service_being_enabled} cannot be enabled with {incompatible_service}. -Disable {incompatible_service} and proceed to enable {service_being_enabled}? \ -(y/N) """ - -MESSAGE_REQUIRED_SERVICE = """\ -{service_being_enabled} cannot be enabled with {required_service} disabled. -Enable {required_service} and proceed to enable {service_being_enabled}? \ -(y/N) """ - -MESSAGE_DEPENDENT_SERVICE = """\ -{dependent_service} depends on {service_being_disabled}. -Disable {dependent_service} and proceed to disable {service_being_disabled}? \ -(y/N) """ - -MESSAGE_INCOMPATIBLE_SERVICE_STOPS_ENABLE = """\ -Cannot enable {service_being_enabled} when {incompatible_service} is enabled. -""" - -MESSAGE_REQUIRED_SERVICE_STOPS_ENABLE = """\ -Cannot enable {service_being_enabled} when {required_service} is disabled. -""" - -MESSAGE_DEPENDENT_SERVICE_STOPS_DISABLE = """\ -Cannot disable {service_being_disabled} when {dependent_service} is enabled. -""" - -MESSAGE_FIPS_BLOCK_ON_CLOUD = ( - """\ -Ubuntu {series} does not provide {cloud} optimized FIPS kernel -For help see: """ - + BASE_UA_URL - + "." -) -ERROR_INVALID_CONFIG_VALUE = """\ -Invalid value for {path_to_value} in /etc/ubuntu-advantage/uaclient.conf. \ -Expected {expected_value}, found {value}.""" -INVALID_PATH_FOR_MACHINE_TOKEN_OVERLAY = """\ -Failed to find the machine token overlay file: {file_path}""" -ERROR_JSON_DECODING_IN_FILE = """\ -Found error: {error} when reading json file: {file_path}""" - -MESSAGE_SECURITY_APT_NON_ROOT = """\ -Package fixes cannot be installed. -To install them, run this command as root (try using sudo)""" - -# MOTD and APT command messaging -MESSAGE_ANNOUNCE_ESM_TMPL = """\ - * Introducing Extended Security Maintenance for Applications. - Receive updates to over 30,000 software packages with your - Ubuntu Advantage subscription. Free for personal use. - - {url} -""" - -MESSAGE_CONTRACT_EXPIRED_SOON_TMPL = """\ -CAUTION: Your {title} service will expire in {remaining_days} days. -Renew UA subscription at {url} to ensure -continued security coverage for your applications. -""" - -MESSAGE_CONTRACT_EXPIRED_GRACE_PERIOD_TMPL = """\ -CAUTION: Your {title} service expired on {expired_date}. -Renew UA subscription at {url} to ensure -continued security coverage for your applications. -Your grace period will expire in {remaining_days} days. -""" - -MESSAGE_CONTRACT_EXPIRED_MOTD_PKGS_TMPL = """\ -*Your {title} subscription has EXPIRED* - -{pkg_num} additional security update(s) could have been applied via {title}. - -Renew your UA services at {url} -""" - -MESSAGE_CONTRACT_EXPIRED_APT_PKGS_TMPL = """\ -*Your {title} subscription has EXPIRED* -Enabling {title} service would provide security updates for following packages: - {pkg_names} -{pkg_num} {name} security update(s) NOT APPLIED. Renew your UA services at -{url} -""" - -MESSAGE_DISABLED_MOTD_NO_PKGS_TMPL = """\ -Enable {title} to receive additional future security updates. -See {url} or run: sudo ua status -""" - -MESSAGE_CONTRACT_EXPIRED_APT_NO_PKGS_TMPL = ( - """\ -*Your {title} subscription has EXPIRED* -""" - + MESSAGE_DISABLED_MOTD_NO_PKGS_TMPL -) - - -MESSAGE_DISABLED_APT_PKGS_TMPL = """\ -*The following packages could receive security updates \ -with {title} service enabled: - {pkg_names} -Learn more about {title} service {eol_release}at {url} -""" - -MESSAGE_UBUNTU_NO_WARRANTY = """\ -Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by -applicable law. -""" - -MESSAGE_APT_PROXY_CONFIG_HEADER = """\ -/* - * Autogenerated by ubuntu-advantage-tools - * Do not edit this file directly - * - * To change what ubuntu-advantage-tools sets, run one of the following: - * Substitute "apt_https_proxy" for "apt_http_proxy" as necessary. - * sudo ua config set apt_http_proxy= - * sudo ua config unset apt_http_proxy - */ -""" - -MESSAGE_UACLIENT_CONF_HEADER = """\ -# Ubuntu-Advantage client config file. -# If you modify this file, run "ua refresh config" to ensure changes are -# picked up by Ubuntu-Advantage client. - -""" - -MESSAGE_SETTING_SERVICE_PROXY = "Setting {service} proxy" -MESSAGE_NOT_SETTING_PROXY_INVALID_URL = ( - '"{proxy}" is not a valid url. Not setting as proxy.' -) -MESSAGE_NOT_SETTING_PROXY_NOT_WORKING = ( - '"{proxy}" is not working. Not setting as proxy.' -) -MESSAGE_ERROR_USING_PROXY = ( - 'Error trying to use "{proxy}" as proxy to reach "{test_url}": {error}' -) - -MESSAGE_PROXY_DETECTED_BUT_NOT_CONFIGURED = """\ -No proxy set in config; however, proxy is configured for: {{services}}. -See {docs_url} for more information on ua proxy configuration. -""".format( - docs_url=DOCUMENTATION_URL -) - def colorize(string: str) -> str: """Return colorized string if using a tty, else original string.""" @@ -661,7 +318,7 @@ ] for service in status["services"]: content.append(STATUS_UNATTACHED_TMPL.format(**service)) - content.extend(["", MESSAGE_UNATTACHED]) + content.extend(["", UNATTACHED.msg]) return "\n".join(content) content = [STATUS_HEADER] @@ -707,7 +364,7 @@ return "\n".join(content) -def _format_status_output(status: Dict[str, Any]) -> Dict[str, Any]: +def format_machine_readable_output(status: Dict[str, Any]) -> Dict[str, Any]: status["environment_vars"] = [ {"name": name, "value": value} for name, value in sorted(os.environ.items()) @@ -728,17 +385,3 @@ status.pop("origin", "") return status - - -def format_json_status(status: Dict[str, Any]) -> str: - from uaclient.util import DatetimeAwareJSONEncoder - - return json.dumps( - _format_status_output(status), cls=DatetimeAwareJSONEncoder - ) - - -def format_yaml_status(status: Dict[str, Any]) -> str: - import yaml - - return yaml.dump(_format_status_output(status), default_flow_style=False) diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/testing/fakes.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/testing/fakes.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/testing/fakes.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/testing/fakes.py 2022-03-10 17:17:29.000000000 +0000 @@ -34,3 +34,20 @@ if isinstance(response, Exception): raise response return response, {"header1": ""} + + +class FakeFile: + def __init__(self, content: str, name: str = "fakefile"): + self.content = content + self.cursor = 0 + self.name = name + + def read(self, size=None): + if self.cursor == len(self.content): + return "" + if size is None or size >= len(self.content): + self.cursor = len(self.content) + return self.content + ret = self.content[self.cursor : size] + self.cursor += size + return ret diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_actions.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_actions.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_actions.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_actions.py 2022-03-10 17:17:29.000000000 +0000 @@ -1,10 +1,9 @@ import mock import pytest -from uaclient import exceptions, status, util +from uaclient import exceptions, messages from uaclient.actions import attach_with_token, auto_attach -from uaclient.contract import ContractAPIError -from uaclient.exceptions import NonAutoAttachImageError +from uaclient.exceptions import ContractAPIError, NonAutoAttachImageError from uaclient.tests.test_cli_auto_attach import fake_instance_factory M_PATH = "uaclient.actions." @@ -16,7 +15,7 @@ " expect_status_call", [ (None, None, False), - (util.UrlError("cause"), util.UrlError, True), + (exceptions.UrlError("cause"), exceptions.UrlError, True), ( exceptions.UserFacingError("test"), exceptions.UserFacingError, @@ -24,14 +23,18 @@ ), ], ) + @mock.patch(M_PATH + "identity.get_instance_id", return_value="my-iid") @mock.patch("uaclient.jobs.update_messaging.update_apt_and_motd_messages") @mock.patch(M_PATH + "config.UAConfig.status") @mock.patch(M_PATH + "contract.request_updated_contract") + @mock.patch(M_PATH + "config.UAConfig.write_cache") def test_attach_with_token( self, + m_write_cache, m_request_updated_contract, m_status, m_update_apt_and_motd_msgs, + _m_get_instance_id, request_updated_contract_side_effect, expected_error_class, expect_status_call, @@ -48,23 +51,24 @@ attach_with_token(cfg, "token", False) if expect_status_call: assert [mock.call()] == m_status.call_args_list + if not expect_status_call: + assert [ + mock.call("instance-id", "my-iid") + ] == m_write_cache.call_args_list + assert [mock.call(cfg)] == m_update_apt_and_motd_msgs.call_args_list class TestAutoAttach: @mock.patch(M_PATH + "attach_with_token") - @mock.patch(M_PATH + "identity.get_instance_id", return_value="my-iid") @mock.patch( M_PATH + "contract.UAContractClient.request_auto_attach_contract_token", return_value={"contractToken": "token"}, ) - @mock.patch(M_PATH + "config.UAConfig.write_cache") def test_happy_path_on_auto_attach( self, - m_write_cache, m_request_auto_attach_contract_token, - m_get_instance_id, m_attach_with_token, FakeConfig, ): @@ -76,10 +80,6 @@ mock.call(cfg, token="token", allow_enable=True) ] == m_attach_with_token.call_args_list - assert [ - mock.call("instance-id", "my-iid") - ] == m_write_cache.call_args_list - @pytest.mark.parametrize( "http_msg,http_code,http_response", ( @@ -107,14 +107,14 @@ """VMs running on non-auto-attach images do not return a token.""" cfg = FakeConfig() m_request_auto_attach_contract_token.side_effect = ContractAPIError( - util.UrlError( + exceptions.UrlError( http_msg, code=http_code, url="http://me", headers={} ), error_response=http_response, ) with pytest.raises(NonAutoAttachImageError) as excinfo: auto_attach(cfg, fake_instance_factory()) - assert status.MESSAGE_UNSUPPORTED_AUTO_ATTACH == str(excinfo.value) + assert messages.UNSUPPORTED_AUTO_ATTACH == str(excinfo.value) @mock.patch( M_PATH + "contract.UAContractClient.request_auto_attach_contract_token" @@ -130,7 +130,7 @@ cfg = FakeConfig() unexpected_error = ContractAPIError( - util.UrlError( + exceptions.UrlError( "Server error", code=500, url="http://me", headers={} ), error_response={"message": "something unexpected"}, diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_apt.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_apt.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_apt.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_apt.py 2022-03-10 17:17:29.000000000 +0000 @@ -9,7 +9,7 @@ import mock import pytest -from uaclient import apt, exceptions, status, util +from uaclient import apt, exceptions, messages, util from uaclient.apt import ( APT_AUTH_COMMENT, APT_CONFIG_PROXY_HTTP, @@ -28,7 +28,7 @@ remove_apt_list_files, remove_auth_apt_repo, remove_repo_from_apt_auth_file, - run_apt_command, + run_apt_update_command, setup_apt_proxy, ) from uaclient.entitlements.base import UAEntitlement @@ -199,7 +199,7 @@ "/does/not/exist" ) # Failure apt-helper response - m_subp.side_effect = util.ProcessExecutionError( + m_subp.side_effect = exceptions.ProcessExecutionError( cmd="apt-helper ", exit_code=exit_code, stdout="Err:1...", @@ -906,15 +906,12 @@ ): error_msg = "\n".join(error_list) - m_subp.side_effect = util.ProcessExecutionError( + m_subp.side_effect = exceptions.ProcessExecutionError( cmd="apt update", stderr=error_msg ) with pytest.raises(exceptions.UserFacingError) as excinfo: - run_apt_command( - cmd=["apt", "update"], - error_msg=status.MESSAGE_APT_UPDATE_FAILED, - ) + run_apt_update_command() expected_message = "\n".join(output_list) + "." assert expected_message == excinfo.value.msg @@ -931,13 +928,13 @@ [ mock.call( APT_PROXY_CONF_FILE, - status.MESSAGE_APT_PROXY_CONFIG_HEADER + messages.APT_PROXY_CONFIG_HEADER + APT_CONFIG_PROXY_HTTP.format( proxy_url="mock_http_proxy" ), ) ], - status.MESSAGE_SETTING_SERVICE_PROXY.format(service="APT"), + messages.SETTING_SERVICE_PROXY.format(service="APT"), ), ( {"https_proxy": "mock_https_proxy"}, @@ -945,13 +942,13 @@ [ mock.call( APT_PROXY_CONF_FILE, - status.MESSAGE_APT_PROXY_CONFIG_HEADER + messages.APT_PROXY_CONFIG_HEADER + APT_CONFIG_PROXY_HTTPS.format( proxy_url="mock_https_proxy" ), ) ], - status.MESSAGE_SETTING_SERVICE_PROXY.format(service="APT"), + messages.SETTING_SERVICE_PROXY.format(service="APT"), ), ( { @@ -962,7 +959,7 @@ [ mock.call( APT_PROXY_CONF_FILE, - status.MESSAGE_APT_PROXY_CONFIG_HEADER + messages.APT_PROXY_CONFIG_HEADER + APT_CONFIG_PROXY_HTTP.format( proxy_url="mock_http_proxy" ) @@ -971,7 +968,7 @@ ), ) ], - status.MESSAGE_SETTING_SERVICE_PROXY.format(service="APT"), + messages.SETTING_SERVICE_PROXY.format(service="APT"), ), ], ) diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_cli_attach.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_cli_attach.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_cli_attach.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_cli_attach.py 2022-03-10 17:17:29.000000000 +0000 @@ -1,22 +1,28 @@ +import contextlib import copy +import io +import json import mock import pytest +import yaml -from uaclient import status +from uaclient import event_logger, messages from uaclient.cli import ( UA_AUTH_TOKEN_URL, action_attach, attach_parser, get_parser, + main_error_handler, ) from uaclient.exceptions import ( AlreadyAttachedError, LockHeldError, NonRootUserError, + UrlError, UserFacingError, ) -from uaclient.util import UrlError +from uaclient.testing.fakes import FakeFile M_PATH = "uaclient.cli." @@ -72,7 +78,7 @@ @mock.patch(M_PATH + "os.getuid") -def test_non_root_users_are_rejected(getuid, FakeConfig): +def test_non_root_users_are_rejected(getuid, FakeConfig, capsys, event): """Check that a UID != 0 will receive a message and exit non-zero""" getuid.return_value = 1 @@ -80,11 +86,35 @@ with pytest.raises(NonRootUserError): action_attach(mock.MagicMock(), cfg) + with pytest.raises(SystemExit): + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + main_error_handler(action_attach)(mock.MagicMock(), cfg) + + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "failure", + "errors": [ + { + "message": messages.NONROOT_USER.msg, + "message_code": messages.NONROOT_USER.name, + "service": None, + "type": "system", + } + ], + "failed_services": [], + "needs_reboot": False, + "processed_services": [], + "warnings": [], + } + assert expected == json.loads(capsys.readouterr()[0]) + # For all of these tests we want to appear as root, so mock on the class @mock.patch(M_PATH + "os.getuid", return_value=0) class TestActionAttach: - def test_already_attached(self, _m_getuid, capsys, FakeConfig): + def test_already_attached(self, _m_getuid, capsys, FakeConfig, event): """Check that an already-attached machine emits message and exits 0""" account_name = "test_account" cfg = FakeConfig.for_attached_machine(account_name=account_name) @@ -92,11 +122,37 @@ with pytest.raises(AlreadyAttachedError): action_attach(mock.MagicMock(), cfg=cfg) + with pytest.raises(SystemExit): + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + main_error_handler(action_attach)(mock.MagicMock(), cfg) + + msg = messages.ALREADY_ATTACHED.format(account_name=account_name) + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "failure", + "errors": [ + { + "message": msg.msg, + "message_code": msg.name, + "service": None, + "type": "system", + } + ], + "failed_services": [], + "needs_reboot": False, + "processed_services": [], + "warnings": [], + } + assert expected == json.loads(capsys.readouterr()[0]) + @mock.patch(M_PATH + "util.subp") - def test_lock_file_exists(self, m_subp, _m_getuid, capsys, FakeConfig): + def test_lock_file_exists( + self, m_subp, _m_getuid, capsys, FakeConfig, event + ): """Check when an operation holds a lock file, attach cannot run.""" cfg = FakeConfig() - cfg.write_cache("lock", "123:ua disable") with pytest.raises(LockHeldError) as exc_info: action_attach(mock.MagicMock(), cfg=cfg) @@ -106,23 +162,84 @@ "Operation in progress: ua disable (pid:123)" ) == exc_info.value.msg - def test_token_is_a_required_argument(self, _m_getuid, FakeConfig): + with pytest.raises(SystemExit): + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + with mock.patch.object( + cfg, "check_lock_info" + ) as m_check_lock_info: + m_check_lock_info.return_value = (1, "lock_holder") + main_error_handler(action_attach)(mock.MagicMock(), cfg) + + expected_msg = messages.LOCK_HELD_ERROR.format( + lock_request="ua attach", lock_holder="lock_holder", pid=1 + ) + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "failure", + "errors": [ + { + "message": expected_msg.msg, + "message_code": expected_msg.name, + "service": None, + "type": "system", + } + ], + "failed_services": [], + "needs_reboot": False, + "processed_services": [], + "warnings": [], + } + assert expected == json.loads(capsys.readouterr()[0]) + + def test_token_is_a_required_argument( + self, _m_getuid, FakeConfig, capsys, event + ): """When missing the required token argument, raise a UserFacingError""" + args = mock.MagicMock(token=None, attach_config=None) + cfg = FakeConfig() + with pytest.raises(UserFacingError) as e: + action_attach(args, cfg=cfg) + assert messages.ATTACH_REQUIRES_TOKEN.msg == str(e.value.msg) + args = mock.MagicMock() args.token = None - with pytest.raises(UserFacingError) as e: - action_attach(args, cfg=FakeConfig()) - assert status.MESSAGE_ATTACH_REQUIRES_TOKEN == str(e.value) + args.attach_config = None + with pytest.raises(SystemExit): + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + with mock.patch.object( + cfg, "check_lock_info" + ) as m_check_lock_info: + m_check_lock_info.return_value = (0, "lock_holder") + main_error_handler(action_attach)(args, cfg) + + expected_msg = messages.ATTACH_REQUIRES_TOKEN + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "failure", + "errors": [ + { + "message": expected_msg.msg, + "message_code": expected_msg.name, + "service": None, + "type": "system", + } + ], + "failed_services": [], + "needs_reboot": False, + "processed_services": [], + "warnings": [], + } + assert expected == json.loads(capsys.readouterr()[0]) @pytest.mark.parametrize( - "error_class, error_str, expected_log", + "error_class, error_str", ( - (UrlError, "Forbidden", "Forbidden\nTraceback"), - ( - UserFacingError, - "Unable to attach default services", - "WARNING Unable to attach default services", - ), + (UrlError, "Forbidden"), + (UserFacingError, "Unable to attach default services"), ), ) @mock.patch("uaclient.util.should_reboot", return_value=False) @@ -140,13 +257,12 @@ _m_get_uid, error_class, error_str, - expected_log, - caplog_text, FakeConfig, + event, ): """If auto-enable of a service fails, attach status is updated.""" token = "contract-token" - args = mock.MagicMock(token=token) + args = mock.MagicMock(token=token, attach_config=None) cfg = FakeConfig() cfg.status() # persist unattached status # read persisted status cache from disk @@ -164,8 +280,6 @@ assert orig_unattached_status != cfg.read_cache( "status-cache" ), "Did not persist on disk status during attach failure" - logs = caplog_text() - assert expected_log in logs assert [mock.call(cfg)] == m_update_apt_and_motd_msgs.call_args_list @mock.patch("uaclient.util.should_reboot", return_value=False) @@ -174,22 +288,25 @@ @mock.patch( M_PATH + "contract.UAContractClient.request_contract_machine_attach" ) - @mock.patch(M_PATH + "action_status") + @mock.patch("uaclient.actions.status", return_value=("", 0)) + @mock.patch("uaclient.status.format_tabular") def test_happy_path_with_token_arg( self, - action_status, + m_format_tabular, + m_status, contract_machine_attach, m_update_apt_and_motd_msgs, _m_should_reboot, _m_remove_notice, _m_getuid, FakeConfig, + event, ): """A mock-heavy test for the happy path with the contract token arg""" # TODO: Improve this test with less general mocking and more # post-conditions token = "contract-token" - args = mock.MagicMock(token=token) + args = mock.MagicMock(token=token, attach_config=None) cfg = FakeConfig() def fake_contract_attach(contract_token): @@ -201,11 +318,43 @@ ret = action_attach(args, cfg) assert 0 == ret - assert 1 == action_status.call_count + assert 1 == m_status.call_count + assert 1 == m_format_tabular.call_count expected_calls = [mock.call(contract_token=token)] assert expected_calls == contract_machine_attach.call_args_list assert [mock.call(cfg)] == m_update_apt_and_motd_msgs.call_args_list + # We need to do that since all config objects in this + # test will share the same data dir. Since this will + # test a successful attach, in the end we write a machine token + # file, which will make all other cfg objects here to report + # as attached + cfg.delete_cache() + + cfg = FakeConfig() + args = mock.MagicMock(token=token, attach_config=None) + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + with mock.patch.object( + cfg, "check_lock_info" + ) as m_check_lock_info: + m_check_lock_info.return_value = (0, "lock_holder") + fake_stdout = io.StringIO() + with contextlib.redirect_stdout(fake_stdout): + main_error_handler(action_attach)(args, cfg) + + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "success", + "errors": [], + "failed_services": [], + "needs_reboot": False, + "processed_services": [], + "warnings": [], + } + assert expected == json.loads(fake_stdout.getvalue()) + @pytest.mark.parametrize("auto_enable", (True, False)) @mock.patch("uaclient.util.should_reboot", return_value=False) @mock.patch("uaclient.config.UAConfig.remove_notice") @@ -221,7 +370,7 @@ auto_enable, FakeConfig, ): - args = mock.MagicMock(auto_enable=auto_enable) + args = mock.MagicMock(auto_enable=auto_enable, attach_config=None) def fake_contract_updates(cfg, contract_token, allow_enable): cfg.write_cache("machine-token", BASIC_MACHINE_TOKEN) @@ -236,6 +385,229 @@ assert [expected_call] == m_ruc.call_args_list assert [mock.call(cfg)] == m_update_apt_and_motd_msgs.call_args_list + def test_attach_config_and_token_mutually_exclusive( + self, _m_getuid, FakeConfig + ): + args = mock.MagicMock( + token="something", attach_config=FakeFile("something") + ) + cfg = FakeConfig() + with pytest.raises(UserFacingError) as e: + action_attach(args, cfg=cfg) + assert e.value.msg == messages.ATTACH_TOKEN_ARG_XOR_CONFIG.msg + + @mock.patch(M_PATH + "_post_cli_attach") + @mock.patch(M_PATH + "actions.attach_with_token") + def test_token_from_attach_config( + self, m_attach_with_token, _m_post_cli_attach, _m_getuid, FakeConfig + ): + args = mock.MagicMock( + token=None, + attach_config=FakeFile(yaml.dump({"token": "faketoken"})), + ) + cfg = FakeConfig() + action_attach(args, cfg=cfg) + assert [ + mock.call(mock.ANY, token="faketoken", allow_enable=True) + ] == m_attach_with_token.call_args_list + + def test_attach_config_invalid_config( + self, _m_getuid, FakeConfig, capsys, event + ): + args = mock.MagicMock( + token=None, + attach_config=FakeFile( + yaml.dump({"token": "something", "enable_services": "cis"}), + name="fakename", + ), + ) + cfg = FakeConfig() + with pytest.raises(UserFacingError) as e: + action_attach(args, cfg=cfg) + assert "Error while reading fakename: " in e.value.msg + + args.attach_config = FakeFile( + yaml.dump({"token": "something", "enable_services": "cis"}), + name="fakename", + ) + with pytest.raises(SystemExit): + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + main_error_handler(action_attach)(args, cfg) + + expected_message = messages.ATTACH_CONFIG_READ_ERROR.format( + config_name="fakename", + error=( + "Got value with " + 'incorrect type for field\n"enable_services": ' + "Expected value with type list but got value: 'cis'" + ), + ) + + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "failure", + "errors": [ + { + "message": expected_message.msg, + "message_code": expected_message.name, + "service": None, + "type": "system", + } + ], + "failed_services": [], + "needs_reboot": False, + "processed_services": [], + "warnings": [], + } + assert expected == json.loads(capsys.readouterr()[0]) + + @pytest.mark.parametrize("auto_enable", (True, False)) + @mock.patch( + M_PATH + "actions.enable_entitlement_by_name", + return_value=(True, None), + ) + @mock.patch(M_PATH + "actions.attach_with_token") + @mock.patch("uaclient.util.handle_unicode_characters") + @mock.patch("uaclient.status.format_tabular") + @mock.patch(M_PATH + "actions.status") + @mock.patch("uaclient.jobs.disable_license_check_if_applicable") + def test_attach_config_enable_services( + self, + _m_disable_license_job, + m_status, + m_format_tabular, + m_handle_unicode, + m_attach_with_token, + m_enable, + _m_getuid, + auto_enable, + FakeConfig, + event, + ): + m_status.return_value = ("status", 0) + m_format_tabular.return_value = "status" + m_handle_unicode.return_value = "status" + + cfg = FakeConfig() + args = mock.MagicMock( + token=None, + attach_config=FakeFile( + yaml.dump({"token": "faketoken", "enable_services": ["cis"]}) + ), + auto_enable=auto_enable, + ) + action_attach(args, cfg=cfg) + assert [ + mock.call(mock.ANY, token="faketoken", allow_enable=False) + ] == m_attach_with_token.call_args_list + if auto_enable: + assert [ + mock.call(cfg, "cis", assume_yes=True, allow_beta=True) + ] == m_enable.call_args_list + else: + assert [] == m_enable.call_args_list + + args.attach_config = FakeFile( + yaml.dump({"token": "faketoken", "enable_services": ["cis"]}) + ) + + fake_stdout = io.StringIO() + with contextlib.redirect_stdout(fake_stdout): + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + main_error_handler(action_attach)(args, cfg) + + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "success", + "errors": [], + "failed_services": [], + "needs_reboot": False, + "processed_services": ["cis"] if auto_enable else [], + "warnings": [], + } + assert expected == json.loads(fake_stdout.getvalue()) + + @mock.patch("uaclient.contract.process_entitlement_delta") + @mock.patch("uaclient.util.apply_series_overrides") + @mock.patch("uaclient.contract.UAContractClient.request_url") + @mock.patch("uaclient.jobs.update_messaging.update_apt_and_motd_messages") + def test_attach_when_one_service_fails_to_enable( + self, + _m_update_messages, + m_request_url, + _m_apply_series_overrides, + m_process_entitlement_delta, + _m_getuid, + FakeConfig, + event, + ): + args = mock.MagicMock(token="token", attach_config=None) + cfg = FakeConfig() + + m_process_entitlement_delta.side_effect = [ + ({"test": 123}, True), + UserFacingError("error"), + ] + m_request_url.return_value = ( + { + "machineToken": "not-null", + "machineTokenInfo": { + "machineId": "machine-id", + "accountInfo": { + "id": "acct-1", + "name": "acc-name", + "createdAt": "2019-06-14T06:45:50Z", + "externalAccountIDs": [ + {"IDs": ["id1"], "Origin": "AWS"} + ], + }, + "contractInfo": { + "id": "cid", + "name": "test_contract", + "resourceTokens": [ + {"token": "token", "type": "test1"}, + {"token": "token", "type": "test2"}, + ], + "resourceEntitlements": [ + {"type": "test1", "aptURL": "apt"}, + {"type": "test2", "aptURL": "apt"}, + ], + }, + }, + }, + None, + ) + + fake_stdout = io.StringIO() + with contextlib.redirect_stdout(fake_stdout): + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + main_error_handler(action_attach)(args, cfg) + + expected_msg = messages.ATTACH_FAILURE_DEFAULT_SERVICES + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "failure", + "errors": [ + { + "message": expected_msg.msg, + "message_code": expected_msg.name, + "service": None, + "type": "system", + } + ], + "failed_services": ["test2"], + "needs_reboot": False, + "processed_services": ["test1"], + "warnings": [], + } + assert expected == json.loads(fake_stdout.getvalue()) + @mock.patch(M_PATH + "contract.get_available_resources") class TestParser: @@ -289,3 +661,17 @@ with mock.patch("sys.argv", ["ua", "attach", "token"]): args = full_parser.parse_args() assert args.auto_enable + + def test_attach_parser_default_to_cli_format(self, _m_resources): + full_parser = get_parser() + with mock.patch("sys.argv", ["ua", "attach", "token"]): + args = full_parser.parse_args() + assert "cli" == args.format + + def test_attach_parser_accepts_format_flag(self, _m_resources): + full_parser = get_parser() + with mock.patch( + "sys.argv", ["ua", "attach", "token", "--format", "json"] + ): + args = full_parser.parse_args() + assert "json" == args.format diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_cli_auto_attach.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_cli_auto_attach.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_cli_auto_attach.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_cli_auto_attach.py 2022-03-10 17:17:29.000000000 +0000 @@ -3,7 +3,7 @@ import mock import pytest -from uaclient import status +from uaclient import messages from uaclient.cli import ( action_auto_attach, auto_attach_parser, @@ -128,19 +128,19 @@ CloudFactoryNoCloudError("test"), False, UserFacingError, - status.MESSAGE_UNABLE_TO_DETERMINE_CLOUD_TYPE, + messages.UNABLE_TO_DETERMINE_CLOUD_TYPE, ), ( CloudFactoryNonViableCloudError("test"), False, UserFacingError, - status.MESSAGE_UNSUPPORTED_AUTO_ATTACH, + messages.UNSUPPORTED_AUTO_ATTACH, ), ( CloudFactoryUnsupportedCloudError("test"), False, NonAutoAttachImageError, - status.MESSAGE_UNSUPPORTED_AUTO_ATTACH_CLOUD_TYPE.format( + messages.UNSUPPORTED_AUTO_ATTACH_CLOUD_TYPE.format( cloud_type="test" ), ), @@ -148,13 +148,13 @@ CloudFactoryNoCloudError("test"), False, UserFacingError, - status.MESSAGE_UNABLE_TO_DETERMINE_CLOUD_TYPE, + messages.UNABLE_TO_DETERMINE_CLOUD_TYPE, ), ( CloudFactoryError("test"), False, UserFacingError, - status.MESSAGE_UNABLE_TO_DETERMINE_CLOUD_TYPE, + messages.UNABLE_TO_DETERMINE_CLOUD_TYPE, ), (CloudFactoryError("test"), True, AlreadyAttachedError, None), ], @@ -269,7 +269,7 @@ with pytest.raises(UserFacingError) as err: action_auto_attach(mock.MagicMock(), cfg=cfg) - assert status.MESSAGE_DETACH_AUTOMATION_FAILURE == str(err.value) + assert messages.DETACH_AUTOMATION_FAILURE == str(err.value) class TestParser: diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_cli_detach.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_cli_detach.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_cli_detach.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_cli_detach.py 2022-03-10 17:17:29.000000000 +0000 @@ -1,19 +1,29 @@ +import contextlib +import io +import json from textwrap import dedent import mock import pytest -from uaclient import exceptions, status -from uaclient.cli import action_detach, detach_parser, get_parser +from uaclient import event_logger, exceptions, messages +from uaclient.cli import ( + action_detach, + detach_parser, + get_parser, + main_error_handler, +) from uaclient.testing.fakes import FakeContractClient def entitlement_cls_mock_factory(can_disable, name=None): - m_instance = mock.Mock( - can_disable=mock.Mock(return_value=can_disable), dependent_services=() - ) + m_instance = mock.MagicMock() + m_instance.can_disable.return_value = (can_disable, None) + m_instance.disable.return_value = (can_disable, None) + type(m_instance).dependent_services = mock.PropertyMock(return_value=()) if name: - m_instance.name = name + type(m_instance).name = mock.PropertyMock(return_value=name) + return mock.Mock(return_value=m_instance) @@ -21,38 +31,119 @@ @mock.patch("uaclient.cli.os.getuid") class TestActionDetach: def test_non_root_users_are_rejected( - self, m_getuid, _m_prompt, FakeConfig + self, m_getuid, _m_prompt, FakeConfig, event, capsys ): """Check that a UID != 0 will receive a message and exit non-zero""" m_getuid.return_value = 1 + args = mock.MagicMock() cfg = FakeConfig.for_attached_machine() with pytest.raises(exceptions.NonRootUserError): - action_detach(mock.MagicMock(), cfg=cfg) + action_detach(args, cfg=cfg) + + with pytest.raises(SystemExit): + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + main_error_handler(action_detach)(args, cfg) + + expected_message = messages.NONROOT_USER + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "failure", + "errors": [ + { + "message": expected_message.msg, + "message_code": expected_message.name, + "service": None, + "type": "system", + } + ], + "failed_services": [], + "needs_reboot": False, + "processed_services": [], + "warnings": [], + } + assert expected == json.loads(capsys.readouterr()[0]) - def test_unattached_error_message(self, m_getuid, _m_prompt, FakeConfig): + def test_unattached_error_message( + self, m_getuid, _m_prompt, FakeConfig, capsys, event + ): """Check that root user gets unattached message.""" m_getuid.return_value = 0 cfg = FakeConfig() + args = mock.MagicMock() with pytest.raises(exceptions.UnattachedError) as err: - action_detach(mock.MagicMock(), cfg=cfg) - assert status.MESSAGE_UNATTACHED == err.value.msg + action_detach(args, cfg=cfg) + assert messages.UNATTACHED.msg == err.value.msg + + with pytest.raises(SystemExit): + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + main_error_handler(action_detach)(args, cfg) + + expected_message = messages.UNATTACHED + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "failure", + "errors": [ + { + "message": expected_message.msg, + "message_code": expected_message.name, + "service": None, + "type": "system", + } + ], + "failed_services": [], + "needs_reboot": False, + "processed_services": [], + "warnings": [], + } + assert expected == json.loads(capsys.readouterr()[0]) @mock.patch("uaclient.cli.util.subp") - def test_lock_file_exists(self, m_subp, m_getuid, m_prompt, FakeConfig): + def test_lock_file_exists( + self, m_subp, m_getuid, m_prompt, FakeConfig, capsys, event + ): """Check when an operation holds a lock file, detach cannot run.""" m_getuid.return_value = 0 cfg = FakeConfig.for_attached_machine() + args = mock.MagicMock() with open(cfg.data_path("lock"), "w") as stream: stream.write("123:ua enable") with pytest.raises(exceptions.LockHeldError) as err: - action_detach(mock.MagicMock(), cfg=cfg) + action_detach(args, cfg=cfg) assert [mock.call(["ps", "123"])] == m_subp.call_args_list - assert ( - "Unable to perform: ua detach.\n" - "Operation in progress: ua enable (pid:123)" - ) == err.value.msg + expected_error_msg = messages.LOCK_HELD_ERROR.format( + lock_request="ua detach", lock_holder="ua enable", pid="123" + ) + assert expected_error_msg.msg == err.value.msg + + with pytest.raises(SystemExit): + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + main_error_handler(action_detach)(args, cfg) + + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "failure", + "errors": [ + { + "message": expected_error_msg.msg, + "message_code": expected_error_msg.name, + "service": None, + "type": "system", + } + ], + "failed_services": [], + "needs_reboot": False, + "processed_services": [], + "warnings": [], + } + assert expected == json.loads(capsys.readouterr()[0]) @pytest.mark.parametrize( "prompt_response,assume_yes,expect_disable", @@ -72,6 +163,8 @@ assume_yes, expect_disable, FakeConfig, + event, + capsys, ): # The three parameters: # prompt_response: the user's response to the prompt @@ -89,7 +182,7 @@ m_entitlements.ENTITLEMENT_CLASSES = [ entitlement_cls_mock_factory(False), - entitlement_cls_mock_factory(True), + entitlement_cls_mock_factory(True, name="test"), entitlement_cls_mock_factory(False), ] @@ -99,7 +192,7 @@ # Check that can_disable is called correctly for ent_cls in m_entitlements.ENTITLEMENT_CLASSES: assert [ - mock.call(silent=True) + mock.call(ignore_dependent_services=True) ] == ent_cls.return_value.can_disable.call_args_list assert [ @@ -115,7 +208,7 @@ disabled_cls = m_entitlements.ENTITLEMENT_CLASSES[1] if expect_disable: assert [ - mock.call(silent=False) + mock.call() ] == disabled_cls.return_value.disable.call_args_list assert 0 == return_code else: @@ -127,6 +220,27 @@ mock.call(cfg) ] == m_update_apt_and_motd_msgs.call_args_list + cfg = FakeConfig.for_attached_machine() + fake_stdout = io.StringIO() + # On json response, we will never prompt the user + m_prompt.return_value = True + with contextlib.redirect_stdout(fake_stdout): + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + main_error_handler(action_detach)(args, cfg) + + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "success", + "errors": [], + "failed_services": [], + "needs_reboot": False, + "processed_services": ["test"], + "warnings": [], + } + assert expected == json.loads(fake_stdout.getvalue()) + @mock.patch("uaclient.cli.entitlements") @mock.patch("uaclient.contract.UAContractClient") @mock.patch("uaclient.cli.update_apt_and_motd_messages") @@ -181,7 +295,7 @@ out, _err = capsys.readouterr() - assert status.MESSAGE_DETACH_SUCCESS + "\n" == out + assert messages.DETACH_SUCCESS + "\n" == out assert [mock.call(m_cfg)] == m_update_apt_and_motd_msgs.call_args_list @mock.patch("uaclient.cli.entitlements") @@ -212,7 +326,7 @@ assert [mock.call(m_cfg)] == m_update_apt_and_motd_msgs.call_args_list @pytest.mark.parametrize( - "classes,expected_message", + "classes,expected_message,disabled_services", [ ( [ @@ -226,6 +340,7 @@ ent1 ent3""" ), + ["ent1", "ent3"], ), ( [ @@ -237,6 +352,7 @@ Detach will disable the following service: ent1""" ), + ["ent1"], ), ], ) @@ -253,8 +369,10 @@ capsys, classes, expected_message, + disabled_services, FakeConfig, tmpdir, + event, ): m_getuid.return_value = 0 m_entitlements.ENTITLEMENT_CLASSES = classes @@ -265,14 +383,34 @@ m_cfg = mock.MagicMock() m_cfg.check_lock_info.return_value = (-1, "") m_cfg.data_path.return_value = tmpdir.join("lock").strpath + args = mock.MagicMock() - action_detach(mock.MagicMock(), m_cfg) + action_detach(args, m_cfg) out, _err = capsys.readouterr() assert expected_message in out assert [mock.call(m_cfg)] == m_update_apt_and_motd_msgs.call_args_list + cfg = FakeConfig.for_attached_machine() + fake_stdout = io.StringIO() + with contextlib.redirect_stdout(fake_stdout): + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + main_error_handler(action_detach)(args, cfg) + + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "success", + "errors": [], + "failed_services": [], + "needs_reboot": False, + "processed_services": disabled_services, + "warnings": [], + } + assert expected == json.loads(fake_stdout.getvalue()) + class TestParser: def test_detach_parser_usage(self): @@ -302,3 +440,11 @@ args = full_parser.parse_args() assert not args.assume_yes + + @mock.patch("uaclient.cli.contract.get_available_resources") + def test_detach_parser_with_json_format(self, _m_resources): + full_parser = get_parser() + with mock.patch("sys.argv", ["ua", "detach", "--format", "json"]): + args = full_parser.parse_args() + + assert "json" == args.format diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_cli_disable.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_cli_disable.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_cli_disable.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_cli_disable.py 2022-03-10 17:17:29.000000000 +0000 @@ -1,10 +1,13 @@ +import contextlib +import io +import json import textwrap import mock import pytest -from uaclient import entitlements, exceptions, status -from uaclient.cli import action_disable, main +from uaclient import entitlements, event_logger, exceptions, messages, status +from uaclient.cli import action_disable, main, main_error_handler ALL_SERVICE_MSG = "\n".join( textwrap.wrap( @@ -22,12 +25,15 @@ Disable an Ubuntu Advantage service. Arguments: - service the name(s) of the Ubuntu Advantage services to disable One - of: cc-eal, cis, esm-infra, fips, fips-updates, livepatch + service the name(s) of the Ubuntu Advantage services to disable + One of: cc-eal, cis, esm-infra, fips, fips-updates, + livepatch Flags: - -h, --help show this help message and exit - --assume-yes do not prompt for confirmation before performing the disable + -h, --help show this help message and exit + --assume-yes do not prompt for confirmation before performing the + disable + --format {cli,json} output disable in the specified format (default: cli) """ ) @@ -59,22 +65,35 @@ assume_yes, service, tmpdir, + capsys, + event, ): entitlements_cls = [] entitlements_obj = [] ent_dict = {} m_valid_services.return_value = [] + if not disable_return: + fail = status.CanDisableFailure( + status.CanDisableFailureReason.ALREADY_DISABLED, + message=messages.NamedMessage("test-code", "test"), + ) + else: + fail = None + for entitlement_name in service: m_entitlement_cls = mock.Mock() m_entitlement = m_entitlement_cls.return_value - m_entitlement.disable.return_value = disable_return + m_entitlement.disable.return_value = (disable_return, fail) entitlements_obj.append(m_entitlement) entitlements_cls.append(m_entitlement_cls) m_valid_services.return_value.append(entitlement_name) ent_dict[entitlement_name] = m_entitlement_cls + type(m_entitlement).name = mock.PropertyMock( + return_value=entitlement_name + ) def factory_side_effect(name, ent_dict=ent_dict): return ent_dict.get(name) @@ -105,6 +124,40 @@ assert return_code == ret assert len(entitlements_cls) == m_cfg.status.call_count + args_mock.assume_yes = True + args_mock.format = "json" + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + with mock.patch.object(event, "set_event_mode"): + fake_stdout = io.StringIO() + with contextlib.redirect_stdout(fake_stdout): + ret = action_disable(args_mock, cfg=m_cfg) + + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "success" if disable_return else "failure", + "errors": [], + "failed_services": [] if disable_return else service, + "needs_reboot": False, + "processed_services": service if disable_return else [], + "warnings": [], + } + + if not disable_return: + expected["errors"] = [ + { + "message": "test", + "message_code": "test-code", + "service": ent_name, + "type": "service", + } + for ent_name in service + ] + + assert return_code == ret + assert expected == json.loads(fake_stdout.getvalue()) + @pytest.mark.parametrize("assume_yes", (True, False)) @mock.patch("uaclient.entitlements.entitlement_factory") @mock.patch("uaclient.entitlements.valid_services") @@ -115,21 +168,37 @@ _m_getuid, assume_yes, tmpdir, + event, ): - expected_error_tmpl = status.MESSAGE_INVALID_SERVICE_OP_FAILURE_TMPL + expected_error_tmpl = messages.INVALID_SERVICE_OP_FAILURE num_calls = 2 m_ent1_cls = mock.Mock() m_ent1_obj = m_ent1_cls.return_value - m_ent1_obj.disable.return_value = False + m_ent1_obj.disable.return_value = ( + False, + status.CanDisableFailure( + status.CanDisableFailureReason.ALREADY_DISABLED, + message=messages.NamedMessage("test-code", "test"), + ), + ) + type(m_ent1_obj).name = mock.PropertyMock(return_value="ent1") m_ent2_cls = mock.Mock() m_ent2_obj = m_ent2_cls.return_value - m_ent2_obj.disable.return_value = False + m_ent2_obj.disable.return_value = ( + False, + status.CanDisableFailure( + status.CanDisableFailureReason.ALREADY_DISABLED, + message=messages.NamedMessage("test-code2", "test2"), + ), + ) + type(m_ent2_obj).name = mock.PropertyMock(return_value="ent2") m_ent3_cls = mock.Mock() m_ent3_obj = m_ent3_cls.return_value - m_ent3_obj.disable.return_value = True + m_ent3_obj.disable.return_value = (True, None) + type(m_ent3_obj).name = mock.PropertyMock(return_value="ent3") def factory_side_effect(name): if name == "ent2": @@ -154,7 +223,7 @@ assert ( expected_error_tmpl.format( operation="disable", name="ent1", service_msg="Try ent2, ent3." - ) + ).msg == err.value.msg ) @@ -170,86 +239,280 @@ assert 0 == m_ent1_obj.call_count assert num_calls == m_cfg.status.call_count + args_mock.assume_yes = True + args_mock.format = "json" + with pytest.raises(SystemExit): + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + with mock.patch.object(event, "set_event_mode"): + fake_stdout = io.StringIO() + with contextlib.redirect_stdout(fake_stdout): + main_error_handler(action_disable)(args_mock, m_cfg) + + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "failure", + "errors": [ + { + "message": "test2", + "message_code": "test-code2", + "service": "ent2", + "type": "service", + }, + { + "message": ( + "Cannot disable unknown service 'ent1'.\n" + "Try ent2, ent3." + ), + "message_code": "invalid-service-or-failure", + "service": None, + "type": "system", + }, + ], + "failed_services": ["ent2"], + "needs_reboot": False, + "processed_services": ["ent3"], + "warnings": [], + } + + assert expected == json.loads(fake_stdout.getvalue()) + @pytest.mark.parametrize( "uid,expected_error_template", [ - (0, status.MESSAGE_INVALID_SERVICE_OP_FAILURE_TMPL), - (1000, status.MESSAGE_NONROOT_USER), + (0, messages.INVALID_SERVICE_OP_FAILURE), + (1000, messages.NONROOT_USER), ], ) def test_invalid_service_error_message( - self, m_getuid, uid, expected_error_template, FakeConfig + self, m_getuid, uid, expected_error_template, FakeConfig, event ): """Check invalid service name results in custom error message.""" m_getuid.return_value = uid cfg = FakeConfig.for_attached_machine() + args = mock.MagicMock() + + if not uid: + expected_error = expected_error_template.format( + operation="disable", name="bogus", service_msg=ALL_SERVICE_MSG + ) + else: + expected_error = expected_error_template + with pytest.raises(exceptions.UserFacingError) as err: - args = mock.MagicMock() args.service = ["bogus"] action_disable(args, cfg) - assert ( - expected_error_template.format( - operation="disable", name="bogus", service_msg=ALL_SERVICE_MSG - ) - == err.value.msg - ) + assert expected_error.msg == err.value.msg + + args.assume_yes = True + args.format = "json" + with pytest.raises(SystemExit): + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + with mock.patch.object(event, "set_event_mode"): + fake_stdout = io.StringIO() + with contextlib.redirect_stdout(fake_stdout): + main_error_handler(action_disable)(args, cfg) + + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "failure", + "errors": [ + { + "message": expected_error.msg, + "message_code": expected_error.name, + "service": None, + "type": "system", + } + ], + "failed_services": [], + "needs_reboot": False, + "processed_services": [], + "warnings": [], + } + assert expected == json.loads(fake_stdout.getvalue()) @pytest.mark.parametrize("service", [["bogus"], ["bogus1", "bogus2"]]) - def test_invalid_service_names(self, m_getuid, service, FakeConfig): + def test_invalid_service_names(self, m_getuid, service, FakeConfig, event): m_getuid.return_value = 0 - expected_error_tmpl = status.MESSAGE_INVALID_SERVICE_OP_FAILURE_TMPL + expected_error_tmpl = messages.INVALID_SERVICE_OP_FAILURE cfg = FakeConfig.for_attached_machine() + args = mock.MagicMock() + expected_error = expected_error_tmpl.format( + operation="disable", + name=", ".join(sorted(service)), + service_msg=ALL_SERVICE_MSG, + ) with pytest.raises(exceptions.UserFacingError) as err: - args = mock.MagicMock() args.service = service action_disable(args, cfg) - assert ( - expected_error_tmpl.format( - operation="disable", - name=", ".join(sorted(service)), - service_msg=ALL_SERVICE_MSG, - ) - == err.value.msg - ) + assert expected_error.msg == err.value.msg + + args.assume_yes = True + args.format = "json" + with pytest.raises(SystemExit): + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + with mock.patch.object(event, "set_event_mode"): + fake_stdout = io.StringIO() + with contextlib.redirect_stdout(fake_stdout): + main_error_handler(action_disable)(args, cfg) + + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "failure", + "errors": [ + { + "message": expected_error.msg, + "message_code": expected_error.name, + "service": None, + "type": "system", + } + ], + "failed_services": [], + "needs_reboot": False, + "processed_services": [], + "warnings": [], + } + assert expected == json.loads(fake_stdout.getvalue()) @pytest.mark.parametrize( "uid,expected_error_template", [ - (0, status.MESSAGE_ENABLE_FAILURE_UNATTACHED_TMPL), - (1000, status.MESSAGE_NONROOT_USER), + (0, messages.ENABLE_FAILURE_UNATTACHED), + (1000, messages.NONROOT_USER), ], ) def test_unattached_error_message( - self, m_getuid, uid, expected_error_template, FakeConfig + self, m_getuid, uid, expected_error_template, FakeConfig, event ): """Check that root user gets unattached message.""" m_getuid.return_value = uid cfg = FakeConfig() + args = mock.MagicMock() + if not uid: + expected_error = expected_error_template.format(name="esm-infra") + else: + expected_error = expected_error_template + with pytest.raises(exceptions.UserFacingError) as err: - args = mock.MagicMock() args.service = ["esm-infra"] action_disable(args, cfg) - assert ( - expected_error_template.format(name="esm-infra") == err.value.msg - ) + + assert expected_error.msg == err.value.msg + + args.assume_yes = True + args.format = "json" + with pytest.raises(SystemExit): + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + with mock.patch.object(event, "set_event_mode"): + fake_stdout = io.StringIO() + with contextlib.redirect_stdout(fake_stdout): + main_error_handler(action_disable)(args, cfg) + + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "failure", + "errors": [ + { + "message": expected_error.msg, + "message_code": expected_error.name, + "service": None, + "type": "system", + } + ], + "failed_services": [], + "needs_reboot": False, + "processed_services": [], + "warnings": [], + } + assert expected == json.loads(fake_stdout.getvalue()) @mock.patch("uaclient.cli.util.subp") - def test_lock_file_exists(self, m_subp, m_getuid, FakeConfig): + def test_lock_file_exists(self, m_subp, m_getuid, FakeConfig, event): """Check inability to disable if operation in progress holds lock.""" - cfg = FakeConfig().for_attached_machine() + args = mock.MagicMock() + expected_error = messages.LOCK_HELD_ERROR.format( + lock_request="ua disable", lock_holder="ua enable", pid="123" + ) with open(cfg.data_path("lock"), "w") as stream: stream.write("123:ua enable") with pytest.raises(exceptions.LockHeldError) as err: - args = mock.MagicMock() args.service = ["esm-infra"] action_disable(args, cfg) assert [mock.call(["ps", "123"])] == m_subp.call_args_list - assert ( - "Unable to perform: ua disable.\n" - "Operation in progress: ua enable (pid:123)" - ) == err.value.msg + assert expected_error.msg == err.value.msg + + args.assume_yes = True + args.format = "json" + with pytest.raises(SystemExit): + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + with mock.patch.object(event, "set_event_mode"): + fake_stdout = io.StringIO() + with contextlib.redirect_stdout(fake_stdout): + main_error_handler(action_disable)(args, cfg) + + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "failure", + "errors": [ + { + "message": expected_error.msg, + "message_code": expected_error.name, + "service": None, + "type": "system", + } + ], + "failed_services": [], + "needs_reboot": False, + "processed_services": [], + "warnings": [], + } + assert expected == json.loads(fake_stdout.getvalue()) + + def test_format_json_fails_when_assume_yes_flag_not_used( + self, _m_getuid, event + ): + cfg = mock.MagicMock() + args_mock = mock.MagicMock() + args_mock.format = "json" + args_mock.assume_yes = False + + with pytest.raises(SystemExit): + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + fake_stdout = io.StringIO() + with contextlib.redirect_stdout(fake_stdout): + main_error_handler(action_disable)(args_mock, cfg) + + expected_message = messages.JSON_FORMAT_REQUIRE_ASSUME_YES + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "failure", + "errors": [ + { + "message": expected_message.msg, + "message_code": expected_message.name, + "service": None, + "type": "system", + } + ], + "failed_services": [], + "needs_reboot": False, + "processed_services": [], + "warnings": [], + } + assert expected == json.loads(fake_stdout.getvalue()) diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_cli_enable.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_cli_enable.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_cli_enable.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_cli_enable.py 2022-03-10 17:17:29.000000000 +0000 @@ -1,12 +1,13 @@ import contextlib import io +import json import textwrap import mock import pytest -from uaclient import entitlements, exceptions, status -from uaclient.cli import action_enable, main +from uaclient import entitlements, event_logger, exceptions, messages, status +from uaclient.cli import action_enable, main, main_error_handler HELP_OUTPUT = textwrap.dedent( """\ @@ -15,13 +16,16 @@ Enable an Ubuntu Advantage service. Arguments: - service the name(s) of the Ubuntu Advantage services to enable. One - of: cc-eal, cis, esm-infra, fips, fips-updates, livepatch + service the name(s) of the Ubuntu Advantage services to enable. + One of: cc-eal, cis, esm-infra, fips, fips-updates, + livepatch Flags: - -h, --help show this help message and exit - --assume-yes do not prompt for confirmation before performing the enable - --beta allow beta service to be enabled + -h, --help show this help message and exit + --assume-yes do not prompt for confirmation before performing the + enable + --beta allow beta service to be enabled + --format {cli,json} output enable in the specified format (default: cli) """ ) @@ -39,37 +43,118 @@ out, _err = capsys.readouterr() assert HELP_OUTPUT == out + @mock.patch("uaclient.cli.contract.get_available_resources") def test_non_root_users_are_rejected( - self, _request_updated_contract, getuid, FakeConfig + self, + _m_resources, + _request_updated_contract, + getuid, + capsys, + event, + FakeConfig, ): """Check that a UID != 0 will receive a message and exit non-zero""" getuid.return_value = 1 + args = mock.MagicMock() cfg = FakeConfig.for_attached_machine() with pytest.raises(exceptions.NonRootUserError): - action_enable(mock.MagicMock(), cfg) + action_enable(args, cfg=cfg) + + with pytest.raises(SystemExit): + with mock.patch( + "sys.argv", + [ + "/usr/bin/ua", + "enable", + "foobar", + "--assume-yes", + "--format", + "json", + ], + ): + main() + + expected_message = messages.NONROOT_USER + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "failure", + "errors": [ + { + "message": expected_message.msg, + "message_code": expected_message.name, + "service": None, + "type": "system", + } + ], + "failed_services": [], + "needs_reboot": False, + "processed_services": [], + "warnings": [], + } + assert expected == json.loads(capsys.readouterr()[0]) @mock.patch("uaclient.cli.util.subp") def test_lock_file_exists( - self, m_subp, _request_updated_contract, getuid, FakeConfig + self, + m_subp, + _request_updated_contract, + getuid, + capsys, + event, + FakeConfig, ): """Check inability to enable if operation holds lock file.""" getuid.return_value = 0 cfg = FakeConfig.for_attached_machine() cfg.write_cache("lock", "123:ua disable") + args = mock.MagicMock() + with pytest.raises(exceptions.LockHeldError) as err: - action_enable(mock.MagicMock(), cfg) + action_enable(args, cfg=cfg) assert [mock.call(["ps", "123"])] == m_subp.call_args_list - assert ( - "Unable to perform: ua enable.\n" - "Operation in progress: ua disable (pid:123)" - ) == err.value.msg + + expected_message = messages.LOCK_HELD_ERROR.format( + lock_request="ua enable", lock_holder="ua disable", pid="123" + ) + assert expected_message.msg == err.value.msg + + with pytest.raises(SystemExit): + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + with mock.patch.object( + cfg, "check_lock_info" + ) as m_check_lock_info: + m_check_lock_info.return_value = (1, "lock_holder") + main_error_handler(action_enable)(args, cfg) + + expected_msg = messages.LOCK_HELD_ERROR.format( + lock_request="ua enable", lock_holder="lock_holder", pid=1 + ) + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "failure", + "errors": [ + { + "message": expected_msg.msg, + "message_code": expected_msg.name, + "service": None, + "type": "system", + } + ], + "failed_services": [], + "needs_reboot": False, + "processed_services": [], + "warnings": [], + } + assert expected == json.loads(capsys.readouterr()[0]) @pytest.mark.parametrize( "uid,expected_error_template", [ - (0, status.MESSAGE_ENABLE_FAILURE_UNATTACHED_TMPL), - (1000, status.MESSAGE_NONROOT_USER), + (0, messages.ENABLE_FAILURE_UNATTACHED), + (1000, messages.NONROOT_USER), ], ) def test_unattached_error_message( @@ -78,25 +163,56 @@ m_getuid, uid, expected_error_template, + capsys, + event, FakeConfig, ): """Check that root user gets unattached message.""" m_getuid.return_value = uid + cfg = FakeConfig() + args = mock.MagicMock() + args.service = ["esm-infra"] + + if not uid: + expected_error = expected_error_template.format(name="esm-infra") + else: + expected_error = expected_error_template + with pytest.raises(exceptions.UserFacingError) as err: - args = mock.MagicMock() - args.service = ["esm-infra"] action_enable(args, cfg) - assert ( - expected_error_template.format(name="esm-infra") == err.value.msg - ) + assert expected_error.msg == err.value.msg + + with pytest.raises(SystemExit): + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + main_error_handler(action_enable)(args, cfg) + + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "failure", + "errors": [ + { + "message": expected_error.msg, + "message_code": expected_error.name, + "service": None, + "type": "system", + } + ], + "failed_services": [], + "needs_reboot": False, + "processed_services": [], + "warnings": [], + } + assert expected == json.loads(capsys.readouterr()[0]) @pytest.mark.parametrize( "uid,expected_error_template", [ - (0, status.MESSAGE_INVALID_SERVICE_OP_FAILURE_TMPL), - (1000, status.MESSAGE_NONROOT_USER), + (0, messages.INVALID_SERVICE_OP_FAILURE), + (1000, messages.NONROOT_USER), ], ) def test_invalid_service_error_message( @@ -105,16 +221,18 @@ m_getuid, uid, expected_error_template, + event, FakeConfig, ): """Check invalid service name results in custom error message.""" m_getuid.return_value = uid cfg = FakeConfig.for_attached_machine() + args = mock.MagicMock() + args.service = ["bogus"] with pytest.raises(exceptions.UserFacingError) as err: - args = mock.MagicMock() - args.service = ["bogus"] action_enable(args, cfg) + service_msg = "\n".join( textwrap.wrap( ( @@ -127,12 +245,41 @@ break_on_hyphens=False, ) ) - assert ( - expected_error_template.format( + + if not uid: + expected_error = expected_error_template.format( operation="enable", name="bogus", service_msg=service_msg ) - == err.value.msg - ) + else: + expected_error = expected_error_template + + assert expected_error.msg == err.value.msg + + with pytest.raises(SystemExit): + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + fake_stdout = io.StringIO() + with contextlib.redirect_stdout(fake_stdout): + main_error_handler(action_enable)(args, cfg) + + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "failure", + "errors": [ + { + "message": expected_error.msg, + "message_code": expected_error.name, + "service": None, + "type": "system", + } + ], + "failed_services": ["bogus"] if not uid else [], + "needs_reboot": False, + "processed_services": [], + "warnings": [], + } + assert expected == json.loads(fake_stdout.getvalue()) @pytest.mark.parametrize("assume_yes", (True, False)) @mock.patch("uaclient.contract.get_available_resources", return_value={}) @@ -185,10 +332,11 @@ _m_get_available_resources, _m_request_updated_contract, m_getuid, + event, FakeConfig, ): m_getuid.return_value = 0 - expected_error_tmpl = status.MESSAGE_INVALID_SERVICE_OP_FAILURE_TMPL + expected_error_tmpl = messages.INVALID_SERVICE_OP_FAILURE m_ent1_cls = mock.Mock() m_ent1_obj = m_ent1_cls.return_value @@ -211,7 +359,7 @@ m_ent3_obj = m_ent3_cls.return_value m_ent3_obj.enable.return_value = (True, None) - def factory_side_effect(name): + def factory_side_effect(name, not_found_okay=True): if name == "ent2": return m_ent2_cls if name == "ent3": @@ -235,18 +383,16 @@ with contextlib.redirect_stdout(fake_stdout): action_enable(args_mock, cfg) - assert ( - expected_error_tmpl.format( - operation="enable", - name="ent1, ent2", - service_msg=( - "Try " - + ", ".join(entitlements.valid_services(allow_beta=False)) - + "." - ), - ) - == err.value.msg + expected_error = expected_error_tmpl.format( + operation="enable", + name="ent1, ent2", + service_msg=( + "Try " + + ", ".join(entitlements.valid_services(allow_beta=False)) + + "." + ), ) + assert expected_error.msg == err.value.msg assert expected_msg == fake_stdout.getvalue() for m_ent_cls in [m_ent2_cls, m_ent3_cls]: @@ -265,6 +411,33 @@ assert 0 == m_ent1_obj.call_count + event.reset() + with pytest.raises(SystemExit): + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + fake_stdout = io.StringIO() + with contextlib.redirect_stdout(fake_stdout): + main_error_handler(action_enable)(args_mock, cfg) + + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "failure", + "errors": [ + { + "message": expected_error.msg, + "message_code": expected_error.name, + "service": None, + "type": "system", + } + ], + "failed_services": ["ent1", "ent2"], + "needs_reboot": False, + "processed_services": ["ent3"], + "warnings": [], + } + assert expected == json.loads(fake_stdout.getvalue()) + @pytest.mark.parametrize("beta_flag", ((False), (True))) @mock.patch("uaclient.contract.get_available_resources", return_value={}) @mock.patch("uaclient.entitlements.entitlement_factory") @@ -277,10 +450,11 @@ _m_request_updated_contract, m_getuid, beta_flag, + event, FakeConfig, ): m_getuid.return_value = 0 - expected_error_tmpl = status.MESSAGE_INVALID_SERVICE_OP_FAILURE_TMPL + expected_error_tmpl = messages.INVALID_SERVICE_OP_FAILURE m_ent1_cls = mock.Mock() m_ent1_obj = m_ent1_cls.return_value @@ -291,13 +465,13 @@ m_ent2_is_beta = mock.PropertyMock(return_value=True) type(m_ent2_cls)._is_beta = m_ent2_is_beta m_ent2_obj = m_ent2_cls.return_value + failure_reason = status.CanEnableFailure( + status.CanEnableFailureReason.IS_BETA + ) if beta_flag: m_ent2_obj.enable.return_value = (True, None) else: - m_ent2_obj.enable.return_value = ( - False, - status.CanEnableFailure(status.CanEnableFailureReason.IS_BETA), - ) + m_ent2_obj.enable.return_value = (False, failure_reason) m_ent3_cls = mock.Mock() m_ent3_cls.name = "ent3" @@ -313,7 +487,7 @@ args_mock.assume_yes = assume_yes args_mock.beta = beta_flag - def factory_side_effect(name): + def factory_side_effect(name, not_found_okay=True): if name == "ent2": return m_ent2_cls if name == "ent3": @@ -335,11 +509,10 @@ mock_obj_list = [m_ent3_obj] service_names = entitlements.valid_services(allow_beta=beta_flag) + ent_str = "Try " + ", ".join(service_names) + "." if not beta_flag: not_found_name += ", ent2" - ent_str = "Try " + ", ".join(service_names) + "." else: - ent_str = "Try " + ", ".join(service_names) + "." mock_ent_list.append(m_ent2_cls) mock_obj_list.append(m_ent3_obj) service_msg = "\n".join( @@ -356,14 +529,10 @@ with contextlib.redirect_stdout(fake_stdout): action_enable(args_mock, cfg) - assert ( - expected_error_tmpl.format( - operation="enable", - name=not_found_name, - service_msg=service_msg, - ) - == err.value.msg + expected_error = expected_error_tmpl.format( + operation="enable", name=not_found_name, service_msg=service_msg ) + assert expected_error.msg == err.value.msg assert expected_msg == fake_stdout.getvalue() for m_ent_cls in mock_ent_list: @@ -382,12 +551,44 @@ assert 0 == m_ent1_obj.call_count + event.reset() + with pytest.raises(SystemExit): + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + fake_stdout = io.StringIO() + with contextlib.redirect_stdout(fake_stdout): + main_error_handler(action_enable)(args_mock, cfg=cfg) + + expected_failed_services = ["ent1", "ent2"] + if beta_flag: + expected_failed_services = ["ent1"] + + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "failure", + "errors": [ + { + "message": expected_error.msg, + "message_code": expected_error.name, + "service": None, + "type": "system", + } + ], + "failed_services": expected_failed_services, + "needs_reboot": False, + "processed_services": ["ent2", "ent3"] if beta_flag else ["ent3"], + "warnings": [], + } + assert expected == json.loads(fake_stdout.getvalue()) + @mock.patch("uaclient.contract.get_available_resources", return_value={}) def test_print_message_when_can_enable_fails( self, _m_get_available_resources, _m_request_updated_contract, m_getuid, + event, FakeConfig, ): m_getuid.return_value = 0 @@ -397,7 +598,8 @@ m_entitlement_obj.enable.return_value = ( False, status.CanEnableFailure( - status.CanEnableFailureReason.ALREADY_ENABLED, "msg" + status.CanEnableFailureReason.ALREADY_ENABLED, + message=messages.NamedMessage("test-code", "msg"), ), ) @@ -422,33 +624,69 @@ == fake_stdout.getvalue() ) + with mock.patch( + "uaclient.entitlements.entitlement_factory", + return_value=m_entitlement_cls, + ), mock.patch( + "uaclient.entitlements.valid_services", return_value=["ent1"] + ), mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + fake_stdout = io.StringIO() + with contextlib.redirect_stdout(fake_stdout): + ret = action_enable(args_mock, cfg=cfg) + + expected_ret = 1 + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "failure", + "errors": [ + { + "message": "msg", + "message_code": "test-code", + "service": "ent1", + "type": "service", + } + ], + "failed_services": ["ent1"], + "needs_reboot": False, + "processed_services": [], + "warnings": [], + } + assert expected == json.loads(fake_stdout.getvalue()) + assert expected_ret == ret + @pytest.mark.parametrize( "service, beta", ((["bogus"], False), (["bogus"], True), (["bogus1", "bogus2"], False)), ) def test_invalid_service_names( - self, _m_request_updated_contract, m_getuid, service, beta, FakeConfig + self, + _m_request_updated_contract, + m_getuid, + service, + beta, + event, + FakeConfig, ): m_getuid.return_value = 0 - expected_error_tmpl = status.MESSAGE_INVALID_SERVICE_OP_FAILURE_TMPL + expected_error_tmpl = messages.INVALID_SERVICE_OP_FAILURE expected_msg = "One moment, checking your subscription first\n" cfg = FakeConfig.for_attached_machine() + args_mock = mock.MagicMock() + args_mock.service = service + args_mock.beta = beta + with pytest.raises(exceptions.UserFacingError) as err: fake_stdout = io.StringIO() with contextlib.redirect_stdout(fake_stdout): - args = mock.MagicMock() - args.service = service - args.beta = beta - action_enable(args, cfg) + action_enable(args_mock, cfg) assert expected_msg == fake_stdout.getvalue() service_names = entitlements.valid_services(allow_beta=beta) - if beta: - ent_str = "Try " + ", ".join(service_names) + "." - else: - ent_str = "Try " + ", ".join(service_names) + "." + ent_str = "Try " + ", ".join(service_names) + "." service_msg = "\n".join( textwrap.wrap( ent_str, @@ -457,14 +695,38 @@ break_on_hyphens=False, ) ) - assert ( - expected_error_tmpl.format( - operation="enable", - name=", ".join(sorted(service)), - service_msg=service_msg, - ) - == err.value.msg + expected_error = expected_error_tmpl.format( + operation="enable", + name=", ".join(sorted(service)), + service_msg=service_msg, ) + assert expected_error.msg == err.value.msg + + with pytest.raises(SystemExit): + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + fake_stdout = io.StringIO() + with contextlib.redirect_stdout(fake_stdout): + main_error_handler(action_enable)(args_mock, cfg) + + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "failure", + "errors": [ + { + "message": expected_error.msg, + "message_code": expected_error.name, + "service": None, + "type": "system", + } + ], + "failed_services": service, + "needs_reboot": False, + "processed_services": [], + "warnings": [], + } + assert expected == json.loads(fake_stdout.getvalue()) @pytest.mark.parametrize("allow_beta", ((True), (False))) @mock.patch("uaclient.contract.get_available_resources", return_value={}) @@ -474,6 +736,7 @@ _m_request_updated_contract, m_getuid, allow_beta, + event, FakeConfig, ): m_getuid.return_value = 0 @@ -484,10 +747,10 @@ cfg = FakeConfig.for_attached_machine() cfg.status = mock.Mock() - args = mock.MagicMock() - args.assume_yes = False - args.beta = allow_beta - args.service = ["testitlement"] + args_mock = mock.MagicMock() + args_mock.assume_yes = False + args_mock.beta = allow_beta + args_mock.service = ["testitlement"] with mock.patch( "uaclient.entitlements.entitlement_factory", @@ -496,7 +759,7 @@ "uaclient.entitlements.valid_services", return_value=["testitlement"], ): - ret = action_enable(args, cfg) + ret = action_enable(args_mock, cfg) assert [ mock.call( @@ -509,7 +772,67 @@ m_entitlement = m_entitlement_cls.return_value expected_enable_call = mock.call() + expected_ret = 0 assert [expected_enable_call] == m_entitlement.enable.call_args_list - assert ret == 0 - + assert expected_ret == ret assert 1 == cfg.status.call_count + + with mock.patch( + "uaclient.entitlements.entitlement_factory", + return_value=m_entitlement_cls, + ), mock.patch( + "uaclient.entitlements.valid_services", + return_value=["testitlement"], + ), mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + fake_stdout = io.StringIO() + with contextlib.redirect_stdout(fake_stdout): + ret = action_enable(args_mock, cfg=cfg) + + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "success", + "errors": [], + "failed_services": [], + "needs_reboot": False, + "processed_services": ["testitlement"], + "warnings": [], + } + assert expected == json.loads(fake_stdout.getvalue()) + assert expected_ret == ret + + def test_format_json_fails_when_assume_yes_flag_not_used( + self, _m_get_available_resources, _m_getuid, event + ): + cfg = mock.MagicMock() + args_mock = mock.MagicMock() + args_mock.format = "json" + args_mock.assume_yes = False + + with pytest.raises(SystemExit): + with mock.patch.object( + event, "_event_logger_mode", event_logger.EventLoggerMode.JSON + ): + fake_stdout = io.StringIO() + with contextlib.redirect_stdout(fake_stdout): + main_error_handler(action_enable)(args_mock, cfg) + + expected_message = messages.JSON_FORMAT_REQUIRE_ASSUME_YES + expected = { + "_schema_version": event_logger.JSON_SCHEMA_VERSION, + "result": "failure", + "errors": [ + { + "message": expected_message.msg, + "message_code": expected_message.name, + "service": None, + "type": "system", + } + ], + "failed_services": [], + "needs_reboot": False, + "processed_services": [], + "warnings": [], + } + assert expected == json.loads(fake_stdout.getvalue()) diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_cli.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_cli.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_cli.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_cli.py 2022-03-10 17:17:29.000000000 +0000 @@ -11,7 +11,7 @@ import mock import pytest -from uaclient import status, util +from uaclient import exceptions, messages, status from uaclient.cli import ( action_help, assert_attached, @@ -257,7 +257,7 @@ m_entitlement_obj.contract_status.return_value = ent_status m_entitlement_obj.user_facing_status.return_value = ( status.UserFacingStatus.ACTIVE, - "active", + messages.NamedMessage("test-code", "active"), ) m_ent_name = mock.PropertyMock(return_value="test") type(m_entitlement_obj).name = m_ent_name @@ -459,7 +459,7 @@ ( ( TypeError("'NoneType' object is not subscriptable"), - status.MESSAGE_UNEXPECTED_ERROR + "\n", + messages.UNEXPECTED_ERROR.msg + "\n", "Unhandled exception, please file a bug", ), ), @@ -621,7 +621,7 @@ ): m_args = m_get_parser.return_value.parse_args.return_value - m_args.action.side_effect = util.UrlError( + m_args.action.side_effect = exceptions.UrlError( socket.gaierror(-2, "Name or service not known"), url=error_url ) @@ -633,11 +633,9 @@ out, err = capsys.readouterr() assert "" == out - assert "{}\n".format(status.MESSAGE_CONNECTIVITY_ERROR) == err + assert "{}\n".format(messages.CONNECTIVITY_ERROR.msg) == err error_log = caplog_text() - print(expected_log) - print(error_log) assert expected_log in error_log assert "Traceback (most recent call last):" in error_log @@ -830,20 +828,22 @@ @pytest.mark.parametrize("pre_existing", (True, False)) @mock.patch("uaclient.cli.os.getuid", return_value=0) @mock.patch("uaclient.cli.config") - def test_file_log_only_readable_by_root( + def test_file_log_is_world_readable( self, m_config, _m_getuid, logging_sandbox, tmpdir, pre_existing ): log_file = tmpdir.join("root-only.log") log_path = log_file.strpath - + expected_mode = 0o644 if pre_existing: + expected_mode = 0o640 log_file.write("existing content\n") - assert 0o600 != stat.S_IMODE(os.lstat(log_path).st_mode) + os.chmod(log_path, expected_mode) + assert 0o644 != stat.S_IMODE(os.lstat(log_path).st_mode) setup_logging(logging.INFO, logging.INFO, log_file=log_path) logging.info("after setup") - assert 0o600 == stat.S_IMODE(os.lstat(log_path).st_mode) + assert expected_mode == stat.S_IMODE(os.lstat(log_path).st_mode) log_content = log_file.read() assert "after setup" in log_content if pre_existing: diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_cli_refresh.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_cli_refresh.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_cli_refresh.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_cli_refresh.py 2022-03-10 17:17:29.000000000 +0000 @@ -1,7 +1,7 @@ import mock import pytest -from uaclient import exceptions, status, util +from uaclient import exceptions, messages from uaclient.cli import action_refresh, main HELP_OUTPUT = """\ @@ -81,14 +81,16 @@ self, request_updated_contract, logging_error, getuid, FakeConfig ): """On failure in request_updates_contract emit an error.""" - request_updated_contract.side_effect = util.UrlError(mock.MagicMock()) + request_updated_contract.side_effect = exceptions.UrlError( + mock.MagicMock() + ) cfg = FakeConfig.for_attached_machine() with pytest.raises(exceptions.UserFacingError) as excinfo: action_refresh(mock.MagicMock(target="contract"), cfg=cfg) - assert status.MESSAGE_REFRESH_CONTRACT_FAILURE == excinfo.value.msg + assert messages.REFRESH_CONTRACT_FAILURE == excinfo.value.msg @mock.patch("uaclient.contract.request_updated_contract") def test_refresh_contract_happy_path( @@ -101,9 +103,7 @@ ret = action_refresh(mock.MagicMock(target="contract"), cfg=cfg) assert 0 == ret - assert ( - status.MESSAGE_REFRESH_CONTRACT_SUCCESS in capsys.readouterr()[0] - ) + assert messages.REFRESH_CONTRACT_SUCCESS in capsys.readouterr()[0] assert [mock.call(cfg)] == request_updated_contract.call_args_list @mock.patch("logging.exception") @@ -120,7 +120,7 @@ with pytest.raises(exceptions.UserFacingError) as excinfo: action_refresh(mock.MagicMock(target="config"), cfg=cfg) - assert status.MESSAGE_REFRESH_CONFIG_FAILURE == excinfo.value.msg + assert messages.REFRESH_CONFIG_FAILURE == excinfo.value.msg @mock.patch("uaclient.config.UAConfig.process_config") def test_refresh_config_happy_path( @@ -132,7 +132,7 @@ ret = action_refresh(mock.MagicMock(target="config"), cfg=cfg) assert 0 == ret - assert status.MESSAGE_REFRESH_CONFIG_SUCCESS in capsys.readouterr()[0] + assert messages.REFRESH_CONFIG_SUCCESS in capsys.readouterr()[0] assert [mock.call()] == m_process_config.call_args_list @mock.patch("uaclient.contract.request_updated_contract") @@ -152,7 +152,7 @@ out, err = capsys.readouterr() assert 0 == ret - assert status.MESSAGE_REFRESH_CONFIG_SUCCESS in out - assert status.MESSAGE_REFRESH_CONTRACT_SUCCESS in out + assert messages.REFRESH_CONFIG_SUCCESS in out + assert messages.REFRESH_CONTRACT_SUCCESS in out assert [mock.call()] == m_process_config.call_args_list assert [mock.call(cfg)] == m_request_updated_contract.call_args_list diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_cli_security_status.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_cli_security_status.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_cli_security_status.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_cli_security_status.py 2022-03-10 17:17:29.000000000 +0000 @@ -15,7 +15,7 @@ HELP_OUTPUT = textwrap.dedent( """\ -usage: security-status \[-h\] --format {json,yaml} --beta +usage: security-status \[-h\] --format {json,yaml} Show security updates for packages in the system, including all available ESM related content. @@ -23,8 +23,6 @@ (optional arguments|options): -h, --help show this help message and exit --format {json,yaml} Format for the output \(json or yaml\) - --beta Acknowledge that this output is not final and may - change in the next version """ # noqa ) @@ -104,29 +102,6 @@ else: assert "the following arguments are required: --format" in err - # Remove this once we are no-longer beta - @pytest.mark.parametrize( - "beta_flag, expected_err", - ((False, "the following arguments are required: --beta"), (True, "")), - ) - def test_require_beta_flag( - self, _m_resources, m_security_status, beta_flag, expected_err, capsys - ): - m_security_status.return_value = {} - cmdline_args = ["/usr/bin/ua", "security-status", "--format", "json"] - if beta_flag: - cmdline_args.extend(["--beta"]) - - try: - with mock.patch("sys.argv", cmdline_args): - main() - except SystemExit: - assert not beta_flag - - _, err = capsys.readouterr() - - assert expected_err in err - class TestParser: @mock.patch(M_PATH + "contract.get_available_resources") @@ -137,7 +112,7 @@ full_parser = get_parser() with mock.patch( - "sys.argv", ["ua", "security-status", "--format", "json", "--beta"] + "sys.argv", ["ua", "security-status", "--format", "json"] ): args = full_parser.parse_args() assert "security-status" == args.command diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_cli_status.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_cli_status.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_cli_status.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_cli_status.py 2022-03-10 17:17:29.000000000 +0000 @@ -1,3 +1,4 @@ +import copy import datetime import io import json @@ -10,8 +11,9 @@ import pytest import yaml -from uaclient import status, util, version +from uaclient import exceptions, messages, status, version from uaclient.cli import action_status, get_parser, main, status_parser +from uaclient.event_logger import EventLoggerMode M_PATH = "uaclient.cli." @@ -139,6 +141,7 @@ "status": "—", "status_details": "", "available": "yes", + "blocked_by": [], }, { "description": "UA Infra: Extended Security Maintenance (ESM)", @@ -148,6 +151,7 @@ "status": "—", "status_details": "", "available": "yes", + "blocked_by": [], }, { "description": "NIST-certified core packages", @@ -157,6 +161,7 @@ "status": "—", "status_details": "", "available": "no", + "blocked_by": [], }, { "description": ( @@ -168,6 +173,7 @@ "status": "—", "status_details": "", "available": "yes", + "blocked_by": [], }, { "description": "Canonical Livepatch service", @@ -177,6 +183,7 @@ "status": "—", "status_details": "", "available": "yes", + "blocked_by": [], }, { "description": "Security Updates for the Robot Operating System", @@ -186,6 +193,7 @@ "status": "—", "status_details": "", "available": "yes", + "blocked_by": [], }, { "description": "All Updates for the Robot Operating System", @@ -195,6 +203,7 @@ "status": "—", "status_details": "", "available": "yes", + "blocked_by": [], }, ] @@ -248,8 +257,8 @@ ) -@mock.patch("uaclient.util.should_reboot", return_value=False) @mock.patch("uaclient.config.UAConfig.remove_notice") +@mock.patch("uaclient.util.should_reboot", return_value=False) @mock.patch( M_PATH + "contract.get_available_resources", return_value=RESPONSE_AVAILABLE_SERVICES, @@ -394,7 +403,10 @@ assert [mock.call(1)] * 3 == m_sleep.call_args_list assert "...\n" + UNATTACHED_STATUS == capsys.readouterr()[0] - @pytest.mark.parametrize("format_type", ("json", "yaml")) + @pytest.mark.parametrize( + "format_type,event_logger_mode", + (("json", EventLoggerMode.JSON), ("yaml", EventLoggerMode.YAML)), + ) @pytest.mark.parametrize( "environ", ( @@ -418,8 +430,10 @@ use_all, environ, format_type, + event_logger_mode, capsys, FakeConfig, + event, ): """Check that unattached status json output is emitted to console""" cfg = FakeConfig() @@ -428,7 +442,10 @@ format=format_type, all=use_all, simulate_with_token=None ) with mock.patch.object(os, "environ", environ): - assert 0 == action_status(args, cfg=cfg) + with mock.patch.object( + event, "_event_logger_mode", event_logger_mode + ), mock.patch.object(event, "_command", "status"): + assert 0 == action_status(args, cfg=cfg) expected_environment = [] if environ: @@ -461,7 +478,7 @@ "_schema_version": "0.1", "version": version.get_version(features=cfg.features), "execution_status": status.UserFacingConfigStatus.INACTIVE.value, - "execution_details": status.MESSAGE_NO_ACTIVE_OPERATIONS, + "execution_details": messages.NO_ACTIVE_OPERATIONS, "attached": False, "machine_id": None, "effective": None, @@ -485,6 +502,9 @@ "config_path": None, "config": {"data_dir": mock.ANY}, "simulated": False, + "errors": [], + "warnings": [], + "result": "success", } if format_type == "json": @@ -492,7 +512,10 @@ else: assert expected == yaml.safe_load(capsys.readouterr()[0]) - @pytest.mark.parametrize("format_type", ("json", "yaml")) + @pytest.mark.parametrize( + "format_type,event_logger_mode", + (("json", EventLoggerMode.JSON), ("yaml", EventLoggerMode.YAML)), + ) @pytest.mark.parametrize( "environ", ( @@ -516,8 +539,10 @@ use_all, environ, format_type, + event_logger_mode, capsys, FakeConfig, + event, ): """Check that unattached status json output is emitted to console""" cfg = FakeConfig.for_attached_machine() @@ -527,7 +552,10 @@ ) with mock.patch.object(os, "environ", environ): - assert 0 == action_status(args, cfg=cfg) + with mock.patch.object( + event, "_event_logger_mode", event_logger_mode + ), mock.patch.object(event, "_command", "status"): + assert 0 == action_status(args, cfg=cfg) expected_environment = [] if environ: @@ -586,7 +614,7 @@ "_schema_version": "0.1", "version": version.get_version(features=cfg.features), "execution_status": status.UserFacingConfigStatus.INACTIVE.value, - "execution_details": status.MESSAGE_NO_ACTIVE_OPERATIONS, + "execution_details": messages.NO_ACTIVE_OPERATIONS, "attached": True, "machine_id": "test_machine_id", "effective": effective, @@ -610,6 +638,9 @@ "config_path": None, "config": {"data_dir": mock.ANY}, "simulated": False, + "errors": [], + "warnings": [], + "result": "success", } if format_type == "json": @@ -637,7 +668,10 @@ assert expected == yaml_output - @pytest.mark.parametrize("format_type", ("json", "yaml")) + @pytest.mark.parametrize( + "format_type,event_logger_mode", + (("json", EventLoggerMode.JSON), ("yaml", EventLoggerMode.YAML)), + ) @pytest.mark.parametrize("use_all", (True, False)) def test_simulated_formats( self, @@ -648,17 +682,22 @@ _m_remove_notice, use_all, format_type, + event_logger_mode, capsys, FakeConfig, + event, ): - """Check that unattached status json output is emitted to console""" + """Check that simulated status json output is emitted to console""" cfg = FakeConfig() args = mock.MagicMock( format=format_type, all=use_all, simulate_with_token="some_token" ) - assert 0 == action_status(args, cfg=cfg) + with mock.patch.object( + event, "_event_logger_mode", event_logger_mode + ), mock.patch.object(event, "_command", "status"): + assert 0 == action_status(args, cfg=cfg) expected_services = [ { @@ -747,6 +786,9 @@ "version": version.get_version(features=cfg.features), "config_path": None, "config": {"data_dir": mock.ANY}, + "errors": [], + "warnings": [], + "result": "success", } if format_type == "json": @@ -764,13 +806,13 @@ FakeConfig, ): """Raise UrlError on connectivity issues""" - m_get_avail_resources.side_effect = util.UrlError( + m_get_avail_resources.side_effect = exceptions.UrlError( socket.gaierror(-2, "Name or service not known") ) cfg = FakeConfig() - with pytest.raises(util.UrlError): + with pytest.raises(exceptions.UrlError): action_status( mock.MagicMock(all=False, simulate_with_token=None), cfg=cfg ) @@ -811,6 +853,118 @@ expected_out = ATTACHED_STATUS.format(dash=expected_dash, notices="") assert expected_out == out + @pytest.mark.parametrize( + "exception_to_throw,exception_type,exception_message", + ( + ( + exceptions.UrlError("Not found", 404), + exceptions.UrlError, + "Not found", + ), + ( + exceptions.ContractAPIError( + exceptions.UrlError("Unauthorized", 401), + {"message": "unauthorized"}, + ), + exceptions.UserFacingError, + "Invalid token. See https://ubuntu.com/advantage", + ), + ), + ) + def test_errors_are_raised_appropriately( + self, + _m_getuid, + m_get_contract_information, + _m_get_avail_resources, + _m_should_reboot, + _m_remove_notice, + exception_to_throw, + exception_type, + exception_message, + capsys, + FakeConfig, + ): + """Check that simulated status json/yaml output raises errors.""" + + m_get_contract_information.side_effect = exception_to_throw + + cfg = FakeConfig() + + args = mock.MagicMock( + format="json", all=False, simulate_with_token="some_token" + ) + + with pytest.raises(exception_type) as exc: + action_status(args, cfg=cfg) + + assert exc.type == exception_type + assert exception_message in getattr(exc.value, "msg", exc.value.args) + + @pytest.mark.parametrize( + "token_to_use,warning_message,contract_field,date_value", + ( + ( + "expired_token", + 'Contract "some_id" expired on December 31, 2019', + "effectiveTo", + "2019-12-31T00:00:00Z", + ), + ( + "token_not_valid_yet", + 'Contract "some_id" is not effective until December 31, 9999', + "effectiveFrom", + "9999-12-31T00:00:00Z", + ), + ), + ) + @pytest.mark.parametrize( + "format_type,event_logger_mode", + (("json", EventLoggerMode.JSON), ("yaml", EventLoggerMode.YAML)), + ) + def test_errors_for_token_dates( + self, + _m_getuid, + m_get_contract_information, + _m_get_avail_resources, + _m_should_reboot, + _m_remove_notice, + format_type, + event_logger_mode, + token_to_use, + warning_message, + contract_field, + date_value, + capsys, + FakeConfig, + event, + ): + """Check errors for expired tokens, and not valid yet tokens.""" + + def contract_info_side_effect(cfg, token): + response = copy.deepcopy(RESPONSE_CONTRACT_INFO) + response["contractInfo"][contract_field] = date_value + return response + + m_get_contract_information.side_effect = contract_info_side_effect + + cfg = FakeConfig() + + args = mock.MagicMock( + format=format_type, all=False, simulate_with_token=token_to_use + ) + + with mock.patch.object( + event, "_event_logger_mode", event_logger_mode + ), mock.patch.object(event, "_command", "status"): + assert 1 == action_status(args, cfg=cfg) + + if format_type == "json": + output = json.loads(capsys.readouterr()[0]) + else: + output = yaml.safe_load(capsys.readouterr()[0]) + + assert output["errors"][0]["message"] == warning_message + class TestStatusParser: @mock.patch(M_PATH + "contract.get_available_resources") diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_config.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_config.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_config.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_config.py 2022-03-10 17:17:29.000000000 +0000 @@ -10,7 +10,7 @@ import pytest import yaml -from uaclient import entitlements, exceptions, status, util, version +from uaclient import entitlements, exceptions, messages, status, util, version from uaclient.config import ( DEFAULT_STATUS, PRIVATE_SUBDIR, @@ -28,8 +28,11 @@ entitlement_factory, valid_services, ) +from uaclient.entitlements.base import IncompatibleService +from uaclient.entitlements.fips import FIPSEntitlement +from uaclient.entitlements.ros import ROSEntitlement +from uaclient.entitlements.tests.test_base import ConcreteTestEntitlement from uaclient.status import ( - MESSAGE_ENABLE_REBOOT_REQUIRED_TMPL, ContractStatus, UserFacingConfigStatus, UserFacingStatus, @@ -771,6 +774,7 @@ {"name": "ros", "available": False}, ] expected = copy.deepcopy(DEFAULT_STATUS) + expected["version"] = mock.ANY expected["services"] = expected_services with mock.patch( "uaclient.config.UAConfig._get_config_status" @@ -781,7 +785,7 @@ expected_calls = [ mock.call( "", - status.MESSAGE_ENABLE_REBOOT_REQUIRED_TMPL.format( + messages.ENABLE_REBOOT_REQUIRED_TMPL.format( operation="fix operation" ), ) @@ -822,12 +826,17 @@ ), ), ) + @mock.patch( + M_PATH + "livepatch.LivepatchEntitlement.application_status", + return_value=(status.ApplicationStatus.DISABLED, ""), + ) @mock.patch("uaclient.contract.get_available_resources") @mock.patch("uaclient.config.os.getuid", return_value=0) def test_root_attached( self, _m_getuid, m_get_avail_resources, + _m_livepatch_status, _m_should_reboot, _m_remove_notice, avail_res, @@ -895,6 +904,7 @@ "status_details": mock.ANY, "description_override": None, "available": mock.ANY, + "blocked_by": [], } for cls in entitlements.ENTITLEMENT_CLASSES if not self.check_beta(cls, show_beta, cfg) @@ -1023,7 +1033,7 @@ expected_calls = [ mock.call( "", - status.MESSAGE_ENABLE_REBOOT_REQUIRED_TMPL.format( + messages.ENABLE_REBOOT_REQUIRED_TMPL.format( operation="fix operation" ), ) @@ -1049,6 +1059,14 @@ ), ) @mock.patch("uaclient.config.os.getuid", return_value=0) + @mock.patch( + M_PATH + "fips.FIPSCommonEntitlement.application_status", + return_value=(status.ApplicationStatus.DISABLED, ""), + ) + @mock.patch( + M_PATH + "livepatch.LivepatchEntitlement.application_status", + return_value=(status.ApplicationStatus.DISABLED, ""), + ) @mock.patch(M_PATH + "livepatch.LivepatchEntitlement.user_facing_status") @mock.patch(M_PATH + "livepatch.LivepatchEntitlement.contract_status") @mock.patch(M_PATH + "esm.ESMAppsEntitlement.user_facing_status") @@ -1063,6 +1081,8 @@ m_esm_uf_status, m_livepatch_contract_status, m_livepatch_uf_status, + _m_livepatch_status, + _m_fips_status, _m_getuid, _m_should_reboot, m_remove_notice, @@ -1075,19 +1095,19 @@ m_repo_contract_status.return_value = status.ContractStatus.ENTITLED m_repo_uf_status.return_value = ( status.UserFacingStatus.INAPPLICABLE, - "repo details", + messages.NamedMessage("test-code", "repo details"), ) m_livepatch_contract_status.return_value = ( status.ContractStatus.ENTITLED ) m_livepatch_uf_status.return_value = ( status.UserFacingStatus.ACTIVE, - "livepatch details", + messages.NamedMessage("test-code", "livepatch details"), ) m_esm_contract_status.return_value = status.ContractStatus.ENTITLED m_esm_uf_status.return_value = ( status.UserFacingStatus.ACTIVE, - "esm-apps details", + messages.NamedMessage("test-code", "esm-apps details"), ) token = { "availableResources": ALL_RESOURCES_AVAILABLE, @@ -1167,6 +1187,7 @@ "status_details": details, "description_override": None, "available": mock.ANY, + "blocked_by": [], } ) with mock.patch( @@ -1181,7 +1202,7 @@ expected_calls = [ mock.call( "", - status.MESSAGE_ENABLE_REBOOT_REQUIRED_TMPL.format( + messages.ENABLE_REBOOT_REQUIRED_TMPL.format( operation="fix operation" ), ) @@ -1242,7 +1263,7 @@ cfg.write_cache("status-cache", expected_status) # Even non-root users can update execution_status details - details = MESSAGE_ENABLE_REBOOT_REQUIRED_TMPL.format( + details = messages.ENABLE_REBOOT_REQUIRED_TMPL.format( operation="configuration changes" ) reboot_required = UserFacingConfigStatus.REBOOTREQUIRED.value @@ -1299,7 +1320,10 @@ ent = mock.MagicMock() ent.name = "test_entitlement" ent.contract_status.return_value = contract_status - ent.user_facing_status.return_value = (uf_status, "") + ent.user_facing_status.return_value = ( + uf_status, + messages.NamedMessage("test-code", ""), + ) unavailable_resources = ( {ent.name: ""} if in_inapplicable_resources else {} @@ -1308,6 +1332,48 @@ assert expected_status == ret["status"] + @pytest.mark.parametrize( + "blocking_incompatible_services, expected_blocked_by", + ( + ([], []), + ( + [ + IncompatibleService( + FIPSEntitlement, messages.NamedMessage("code", "msg") + ) + ], + [{"name": "fips", "reason": "msg", "reason_code": "code"}], + ), + ( + [ + IncompatibleService( + FIPSEntitlement, messages.NamedMessage("code", "msg") + ), + IncompatibleService( + ROSEntitlement, messages.NamedMessage("code2", "msg2") + ), + ], + [ + {"name": "fips", "reason": "msg", "reason_code": "code"}, + {"name": "ros", "reason": "msg2", "reason_code": "code2"}, + ], + ), + ), + ) + def test_blocked_by( + self, + blocking_incompatible_services, + expected_blocked_by, + tmpdir, + FakeConfig, + ): + cfg = UAConfig({"data_dir": tmpdir.strpath}) + ent = ConcreteTestEntitlement( + blocking_incompatible_services=blocking_incompatible_services + ) + service_status = cfg._attached_service_status(ent, []) + assert service_status["blocked_by"] == expected_blocked_by + class TestProcessConfig: @pytest.mark.parametrize( @@ -1442,7 +1508,7 @@ expected_out = "" if snap_livepatch_msg: - expected_out = status.MESSAGE_PROXY_DETECTED_BUT_NOT_CONFIGURED.format( # noqa: E501 + expected_out = messages.PROXY_DETECTED_BUT_NOT_CONFIGURED.format( # noqa: E501 services=snap_livepatch_msg ) @@ -1755,8 +1821,6 @@ ) cfg = UAConfig(cfg=user_cfg) - print(expected) - print(cfg.machine_token) assert expected == cfg.machine_token @mock.patch("uaclient.config.UAConfig.read_cache") @@ -1774,7 +1838,7 @@ m_read_cache.return_value = self.machine_token_dict cfg = UAConfig(cfg=user_cfg) - expected_msg = status.INVALID_PATH_FOR_MACHINE_TOKEN_OVERLAY.format( + expected_msg = messages.INVALID_PATH_FOR_MACHINE_TOKEN_OVERLAY.format( file_path=invalid_path ) @@ -1795,7 +1859,7 @@ json_str = '{"directives": {"remoteServer": "overlay"}' m_load_file.return_value = json_str - expected_msg = status.ERROR_JSON_DECODING_IN_FILE.format( + expected_msg = messages.ERROR_JSON_DECODING_IN_FILE.format( error="Expecting ',' delimiter: line 1 column 43 (char 42)", file_path=invalid_json_path, ) diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_contract.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_contract.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_contract.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_contract.py 2022-03-10 17:17:29.000000000 +0000 @@ -1,6 +1,5 @@ import copy import json -import logging import socket import mock @@ -13,23 +12,24 @@ API_V1_RESOURCES, API_V1_TMPL_CONTEXT_MACHINE_TOKEN_RESOURCE, API_V1_TMPL_RESOURCE_MACHINE_ACCESS, - ContractAPIError, UAContractClient, get_available_resources, get_contract_information, process_entitlement_delta, request_updated_contract, ) -from uaclient.status import ( - MESSAGE_ATTACH_EXPIRED_TOKEN, - MESSAGE_ATTACH_FAILURE_DEFAULT_SERVICES, - MESSAGE_ATTACH_FORBIDDEN, - MESSAGE_ATTACH_FORBIDDEN_EXPIRED, - MESSAGE_ATTACH_FORBIDDEN_NEVER, - MESSAGE_ATTACH_FORBIDDEN_NOT_YET, - MESSAGE_ATTACH_INVALID_TOKEN, - MESSAGE_UNEXPECTED_ERROR, +from uaclient.entitlements.base import UAEntitlement +from uaclient.messages import ( + ATTACH_EXPIRED_TOKEN, + ATTACH_FAILURE_DEFAULT_SERVICES, + ATTACH_FORBIDDEN, + ATTACH_FORBIDDEN_EXPIRED, + ATTACH_FORBIDDEN_NEVER, + ATTACH_FORBIDDEN_NOT_YET, + ATTACH_INVALID_TOKEN, + UNEXPECTED_ERROR, ) +from uaclient.status import UserFacingStatus from uaclient.testing.fakes import FakeContractClient from uaclient.version import get_version @@ -47,6 +47,7 @@ "detach,expected_http_method", ((None, "POST"), (False, "POST"), (True, "DELETE")), ) + @pytest.mark.parametrize("activity_id", ((None), ("test-acid"))) @mock.patch("uaclient.contract.util.get_platform_info") def test__request_machine_token_update( self, @@ -56,6 +57,7 @@ detach, expected_http_method, machine_id_response, + activity_id, FakeConfig, ): """POST or DELETE to ua-contracts and write machine-token cache. @@ -77,13 +79,26 @@ kwargs = {"machine_token": "mToken", "contract_id": "cId"} if detach is not None: kwargs["detach"] = detach - client._request_machine_token_update(**kwargs) + enabled_services = ["esm-apps", "livepatch"] + + def entitlement_user_facing_status(self): + if self.name in enabled_services: + return (UserFacingStatus.ACTIVE, "") + return (UserFacingStatus.INACTIVE, "") + + with mock.patch.object(type(cfg), "activity_id", activity_id): + with mock.patch.object( + UAEntitlement, + "user_facing_status", + new=entitlement_user_facing_status, + ): + client._request_machine_token_update(**kwargs) if not detach: # Then we have written the updated cache assert machine_token == cfg.read_cache("machine-token") expected_machine_id = "machineId" if machine_id_response: - expected_machine_id = "contract-machine-id" + expected_machine_id = machine_id_response assert expected_machine_id == cfg.read_cache("machine-id") params = { @@ -96,14 +111,20 @@ "method": expected_http_method, } if expected_http_method != "DELETE": + expected_activity_id = activity_id if activity_id else "machineId" params["data"] = { "machineId": "machineId", "architecture": "arch", "os": {"kernel": "kernel"}, + "activityInfo": { + "activityToken": None, + "activityID": expected_activity_id, + "resources": enabled_services, + }, } - assert [ + assert request_url.call_args_list == [ mock.call("/v1/contracts/cId/context/machines/machineId", **params) - ] == request_url.call_args_list + ] def test_request_resource_machine_access( self, get_machine_id, request_url, FakeConfig @@ -150,8 +171,16 @@ ] == m_request_url.call_args_list @pytest.mark.parametrize("activity_id", ((None), ("test-acid"))) + @pytest.mark.parametrize( + "enabled_services", (([]), (["esm-apps", "livepatch"])) + ) def test_report_machine_activity( - self, get_machine_id, request_url, activity_id, FakeConfig + self, + get_machine_id, + request_url, + activity_id, + enabled_services, + FakeConfig, ): """POST machine activity report to the server.""" machine_id = "machineId" @@ -166,14 +195,22 @@ ) cfg = FakeConfig.for_attached_machine() client = UAContractClient(cfg) - enabled_services = ["test1", "test2"] + + def entitlement_user_facing_status(self): + if self.name in enabled_services: + return (UserFacingStatus.ACTIVE, "") + return (UserFacingStatus.INACTIVE, "") + with mock.patch.object(type(cfg), "activity_id", activity_id): - with mock.patch( - "uaclient.config.UAConfig.write_cache" - ) as m_write_cache: - client.report_machine_activity( - enabled_services=enabled_services - ) + with mock.patch.object( + UAEntitlement, + "user_facing_status", + new=entitlement_user_facing_status, + ): + with mock.patch( + "uaclient.config.UAConfig.write_cache" + ) as m_write_cache: + client.report_machine_activity() expected_write_calls = 1 assert expected_write_calls == m_write_cache.call_count @@ -263,13 +300,13 @@ "Could not determine contract delta service type" " {{}} {}".format(new_access) ) - with pytest.raises(RuntimeError) as exc: + with pytest.raises(exceptions.UserFacingError) as exc: process_entitlement_delta({}, new_access) - assert error_msg == str(exc.value) + assert error_msg == str(exc.value.msg) def test_no_delta_on_equal_dicts(self): """No deltas are reported or processed when dicts are equal.""" - assert {} == process_entitlement_delta( + assert ({}, False) == process_entitlement_delta( {"entitlement": {"no": "diff"}}, {"entitlement": {"no": "diff"}} ) @@ -278,11 +315,12 @@ self, m_process_contract_deltas ): """Call entitlement.process_contract_deltas to handle any deltas.""" + m_process_contract_deltas.return_value = True original_access = {"entitlement": {"type": "esm-infra"}} new_access = copy.deepcopy(original_access) new_access["entitlement"]["newkey"] = "newvalue" expected = {"entitlement": {"newkey": "newvalue"}} - assert expected == process_entitlement_delta( + assert (expected, True) == process_entitlement_delta( original_access, new_access ) expected_calls = [ @@ -296,7 +334,8 @@ # Limit delta processing logic to handle attached state-A to state-B # Fresh installs will have empty/unset new_access = {"entitlement": {"type": "esm-infra", "other": "val2"}} - assert new_access == process_entitlement_delta({}, new_access) + actual, _ = process_entitlement_delta({}, new_access) + assert new_access == actual expected_calls = [mock.call({}, new_access, allow_enable=False)] assert expected_calls == m_process_contract_deltas.call_args_list @@ -330,12 +369,12 @@ """Raise error get_available_resources can't contact backend""" cfg = FakeConfig() - urlerror = util.UrlError( + urlerror = exceptions.UrlError( socket.gaierror(-2, "Name or service not known") ) m_request_resources.side_effect = urlerror - with pytest.raises(util.UrlError) as exc: + with pytest.raises(exceptions.UrlError) as exc: get_available_resources(cfg) assert urlerror == exc.value @@ -401,25 +440,25 @@ client.side_effect = fake_contract_client cfg = FakeConfig.for_attached_machine() - with pytest.raises(RuntimeError) as exc: + with pytest.raises(exceptions.UserFacingError) as exc: request_updated_contract(cfg, contract_token="something") expected_msg = ( "Got unexpected contract_token on an already attached machine" ) - assert expected_msg == str(exc.value) + assert expected_msg == str(exc.value.msg) @pytest.mark.parametrize( "error_code, error_msg, error_response", ( - (401, MESSAGE_ATTACH_INVALID_TOKEN, '{"message": "unauthorized"}'), - (403, MESSAGE_ATTACH_EXPIRED_TOKEN, "{}"), + (401, ATTACH_INVALID_TOKEN, '{"message": "unauthorized"}'), + (403, ATTACH_EXPIRED_TOKEN, "{}"), ( 403, - MESSAGE_ATTACH_FORBIDDEN.format( - reason=MESSAGE_ATTACH_FORBIDDEN_EXPIRED.format( + ATTACH_FORBIDDEN.format( + reason=ATTACH_FORBIDDEN_EXPIRED.format( contract_id="contract-id", date="May 07, 2021" - ) + ).msg ), """{ "code": "forbidden", @@ -434,10 +473,10 @@ ), ( 403, - MESSAGE_ATTACH_FORBIDDEN.format( - reason=MESSAGE_ATTACH_FORBIDDEN_NOT_YET.format( + ATTACH_FORBIDDEN.format( + reason=ATTACH_FORBIDDEN_NOT_YET.format( contract_id="contract-id", date="May 07, 2021" - ) + ).msg ), """{ "code": "forbidden", @@ -452,10 +491,10 @@ ), ( 403, - MESSAGE_ATTACH_FORBIDDEN.format( - reason=MESSAGE_ATTACH_FORBIDDEN_NEVER.format( + ATTACH_FORBIDDEN.format( + reason=ATTACH_FORBIDDEN_NEVER.format( contract_id="contract-id" - ) + ).msg ), """{ "code": "forbidden", @@ -485,8 +524,8 @@ def fake_contract_client(cfg): fake_client = FakeContractClient(cfg) fake_client._responses = { - API_V1_CONTEXT_MACHINE_TOKEN: ContractAPIError( - util.UrlError( + API_V1_CONTEXT_MACHINE_TOKEN: exceptions.ContractAPIError( + exceptions.UrlError( "Server error", code=error_code, url="http://me", @@ -504,7 +543,7 @@ with pytest.raises(exceptions.UserFacingError) as exc: request_updated_contract(cfg, contract_token="yep") - assert error_msg == str(exc.value) + assert error_msg.msg == str(exc.value.msg) @mock.patch("uaclient.util.get_machine_id", return_value="mid") @mock.patch(M_PATH + "UAContractClient") @@ -564,7 +603,7 @@ with pytest.raises(exceptions.UserFacingError) as exc: request_updated_contract(cfg) - assert MESSAGE_ATTACH_FAILURE_DEFAULT_SERVICES == str(exc.value) + assert ATTACH_FAILURE_DEFAULT_SERVICES.msg == str(exc.value.msg) @pytest.mark.parametrize( "first_error, second_error, ux_error_msg", @@ -574,10 +613,10 @@ "Ubuntu Advantage server provided no aptKey directive for" " esm-infra" ), - None, - MESSAGE_ATTACH_FAILURE_DEFAULT_SERVICES, + (None, False), + ATTACH_FAILURE_DEFAULT_SERVICES, ), - (RuntimeError("some APT error"), None, MESSAGE_UNEXPECTED_ERROR), + (RuntimeError("some APT error"), None, UNEXPECTED_ERROR), # Order high-priority RuntimeError as second_error to ensure it # is raised as primary error_msg ( @@ -586,7 +625,7 @@ " esm-infra" ), RuntimeError("some APT error"), # High-priority ordered 2 - MESSAGE_UNEXPECTED_ERROR, + UNEXPECTED_ERROR, ), ), ) @@ -614,7 +653,7 @@ process_entitlement_delta.side_effect = ( first_error, second_error, - None, + (None, False), ) # resourceEntitlements specifically ordered reverse alphabetically @@ -650,7 +689,7 @@ with pytest.raises(exceptions.UserFacingError) as exc: assert None is request_updated_contract(cfg) assert 3 == process_entitlement_delta.call_count - assert ux_error_msg == str(exc.value) + assert ux_error_msg.msg == str(exc.value.msg) @mock.patch(M_PATH + "process_entitlement_delta") @mock.patch("uaclient.util.get_machine_id", return_value="mid") @@ -690,6 +729,7 @@ client.side_effect = fake_contract_client cfg = FakeConfig.for_attached_machine(machine_token=machine_token) + process_entitlement_delta.return_value = (None, False) assert None is request_updated_contract(cfg) assert new_token == cfg.read_cache("machine-token") @@ -717,43 +757,3 @@ ), ] assert process_calls == process_entitlement_delta.call_args_list - - -class TestDetachMachineFromContract: - @pytest.mark.parametrize("caplog_text", [logging.DEBUG], indirect=True) - @pytest.mark.parametrize( - "curr_machine_id,past_machine_id", - (("123", "124"), (123, "124"), ("123", 124), ("123", "123")), - ) - @mock.patch.object(UAContractClient, "_request_machine_token_update") - @mock.patch.object(UAContractClient, "_get_platform_data") - def test_do_not_make_make_detach_call_when_machine_id_is_different( - self, - m_platform_data, - m_request_machine_token_update, - curr_machine_id, - past_machine_id, - caplog_text, - FakeConfig, - ): - m_platform_data.return_value = {"machineId": curr_machine_id} - cfg = FakeConfig.for_attached_machine() - cfg.write_cache("machine-id", past_machine_id) - client = UAContractClient(cfg) - - actual_value = client.detach_machine_from_contract( - machine_token="machine_token", - contract_id="contract_id", - machine_id="machine_id", - ) - - expected_msg = """\ - Found new machine-id. Do not call detach on contract backend - """ - if str(past_machine_id) != str(curr_machine_id): - assert actual_value == {} - assert m_request_machine_token_update.call_count == 0 - assert expected_msg.strip() in caplog_text() - else: - assert m_request_machine_token_update.call_count == 1 - assert expected_msg.strip() not in caplog_text() diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_data_types.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_data_types.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_data_types.py 1970-01-01 00:00:00.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_data_types.py 2022-03-10 17:17:29.000000000 +0000 @@ -0,0 +1,386 @@ +from typing import List, Optional + +import pytest + +from uaclient.data_types import ( + BoolDataValue, + DataObject, + DataValue, + Field, + IncorrectFieldTypeError, + IncorrectListElementTypeError, + IncorrectTypeError, + IntDataValue, + StringDataValue, + data_list, +) + +M_PATH = "uaclient.data_types" + + +class TestDataValues: + @pytest.mark.parametrize( + "val", (1, "hello", {"key": "value"}, ["one", "two"]) + ) + def test_data_value(self, val): + assert val == DataValue.from_value(val) + + @pytest.mark.parametrize("val", ("hello", "key", "one", "two")) + def test_string_data_value_success(self, val): + result = StringDataValue.from_value(val) + assert val == result + assert isinstance(result, str) + + @pytest.mark.parametrize( + "val, error", + ( + (True, IncorrectTypeError("string", True)), + (1, IncorrectTypeError("string", 1)), + ([], IncorrectTypeError("string", [])), + ({}, IncorrectTypeError("string", {})), + ), + ) + def test_string_data_value_error(self, val, error): + with pytest.raises(type(error)) as e: + StringDataValue.from_value(val) + assert e.value.msg == error.msg + + @pytest.mark.parametrize("val", (1, 0, -1)) + def test_int_data_value_success(self, val): + result = IntDataValue.from_value(val) + assert val == result + assert isinstance(result, int) + + @pytest.mark.parametrize( + "val, error", + ( + (True, IncorrectTypeError("int", True)), + ("hello", IncorrectTypeError("int", "hello")), + ("1", IncorrectTypeError("int", "1")), + ([], IncorrectTypeError("int", [])), + ({}, IncorrectTypeError("int", {})), + ), + ) + def test_int_data_value_error(self, val, error): + with pytest.raises(type(error)) as e: + IntDataValue.from_value(val) + assert e.value.msg == error.msg + + @pytest.mark.parametrize("val", (True, False)) + def test_bool_data_value_success(self, val): + result = BoolDataValue.from_value(val) + assert val == result + assert isinstance(result, bool) + + @pytest.mark.parametrize( + "val, error", + ( + ("hello", IncorrectTypeError("bool", "hello")), + (1, IncorrectTypeError("bool", 1)), + ([], IncorrectTypeError("bool", [])), + ({}, IncorrectTypeError("bool", {})), + ), + ) + def test_bool_data_value_error(self, val, error): + with pytest.raises(type(error)) as e: + BoolDataValue.from_value(val) + assert e.value.msg == error.msg + + +class TestDataList: + @pytest.mark.parametrize( + "data_cls, val", + ( + (IntDataValue, []), + (IntDataValue, [0]), + (IntDataValue, [0, 4, -3, 2, 1]), + (StringDataValue, []), + (StringDataValue, ["hello"]), + (StringDataValue, ["one", "two", "three"]), + ), + ) + def test_success(self, data_cls, val): + result = data_list(data_cls).from_value(val) + assert val == result + + @pytest.mark.parametrize( + "data_cls, val, error", + ( + (IntDataValue, "hello", IncorrectTypeError("list", "hello")), + (IntDataValue, 1, IncorrectTypeError("list", 1)), + (IntDataValue, {}, IncorrectTypeError("list", {})), + ( + IntDataValue, + ["one"], + IncorrectListElementTypeError( + IncorrectTypeError("int", "one"), 0 + ), + ), + ( + IntDataValue, + [1, 2, 3, []], + IncorrectListElementTypeError( + IncorrectTypeError("int", []), 3 + ), + ), + ( + StringDataValue, + ["one", "two", "three", {}], + IncorrectListElementTypeError( + IncorrectTypeError("string", {}), 3 + ), + ), + ( + data_list(StringDataValue), + [["one", "two"], ["three"], ["four", 5]], + IncorrectListElementTypeError( + IncorrectListElementTypeError( + IncorrectTypeError("string", 5), 1 + ), + 2, + ), + ), + ), + ) + def test_error(self, data_cls, val, error): + with pytest.raises(type(error)) as e: + data_list(data_cls).from_value(val) + assert e.value.msg == error.msg + + +class ExampleNestedObject(DataObject): + fields = [Field("string", StringDataValue), Field("integer", IntDataValue)] + + def __init__(self, *, string: str, integer: int): + self.string = string + self.integer = integer + + +class ExampleDataObject(DataObject): + fields = [ + Field("string", StringDataValue), + Field("string_opt", StringDataValue, required=False), + Field("integer", IntDataValue), + Field("integer_opt", IntDataValue, required=False), + Field("obj", ExampleNestedObject), + Field("obj_opt", ExampleNestedObject, required=False), + Field("stringlist", data_list(StringDataValue)), + Field("stringlist_opt", data_list(StringDataValue), required=False), + Field("integerlist", data_list(IntDataValue)), + Field("integerlist_opt", data_list(IntDataValue), required=False), + Field("objlist", data_list(ExampleNestedObject)), + Field("objlist_opt", data_list(ExampleNestedObject), required=False), + ] + + def __init__( + self, + *, + string: str, + string_opt: Optional[str], + integer: int, + integer_opt: Optional[int], + obj: ExampleNestedObject, + obj_opt: Optional[ExampleNestedObject], + stringlist: List[StringDataValue], + stringlist_opt: Optional[List[StringDataValue]], + integerlist: List[IntDataValue], + integerlist_opt: Optional[List[IntDataValue]], + objlist: List[ExampleNestedObject], + objlist_opt: Optional[List[ExampleNestedObject]] + ): + self.string = string + self.string_opt = string_opt + self.integer = integer + self.integer_opt = integer_opt + self.obj = obj + self.obj_opt = obj_opt + self.stringlist = stringlist + self.stringlist_opt = stringlist_opt + self.integerlist = integerlist + self.integerlist_opt = integerlist_opt + self.objlist = objlist + self.objlist_opt = objlist_opt + + +class TestDataObject: + def test_success_no_optionals(self): + result = ExampleDataObject.from_dict( + { + "string": "string", + "integer": 1, + "obj": {"string": "nestedstring", "integer": 2}, + "stringlist": ["one", "two"], + "integerlist": [3, 4, 5], + "objlist": [ + {"string": "nestedstring2", "integer": 6}, + {"string": "nestedstring3", "integer": 7}, + ], + } + ) + assert result.string == "string" + assert result.string_opt is None + assert result.integer == 1 + assert result.integer_opt is None + assert result.obj.string == "nestedstring" + assert result.obj.integer == 2 + assert result.obj_opt is None + assert result.stringlist == ["one", "two"] + assert result.stringlist_opt is None + assert result.integerlist == [3, 4, 5] + assert result.integerlist_opt is None + assert result.objlist[0].string == "nestedstring2" + assert result.objlist[0].integer == 6 + assert result.objlist[1].string == "nestedstring3" + assert result.objlist[1].integer == 7 + assert result.objlist_opt is None + + def test_success_with_optionals(self): + result = ExampleDataObject.from_dict( + { + "string": "string", + "string_opt": "string_opt", + "integer": 1, + "integer_opt": 11, + "obj": {"string": "nestedstring", "integer": 2}, + "obj_opt": {"string": "nestedstring_opt", "integer": 22}, + "stringlist": ["one", "two"], + "stringlist_opt": ["one_opt", "two_opt"], + "integerlist": [3, 4, 5], + "integerlist_opt": [33, 44, 55], + "objlist": [ + {"string": "nestedstring2", "integer": 6}, + {"string": "nestedstring3", "integer": 7}, + ], + "objlist_opt": [ + {"string": "nestedstring2_opt", "integer": 66}, + {"string": "nestedstring3_opt", "integer": 77}, + ], + } + ) + assert result.string == "string" + assert result.string_opt == "string_opt" + assert result.integer == 1 + assert result.integer_opt == 11 + assert result.obj.string == "nestedstring" + assert result.obj.integer == 2 + assert result.obj_opt is not None + assert result.obj_opt.string == "nestedstring_opt" + assert result.obj_opt.integer == 22 + assert result.stringlist == ["one", "two"] + assert result.stringlist_opt == ["one_opt", "two_opt"] + assert result.integerlist == [3, 4, 5] + assert result.integerlist_opt == [33, 44, 55] + assert result.objlist[0].string == "nestedstring2" + assert result.objlist[0].integer == 6 + assert result.objlist[1].string == "nestedstring3" + assert result.objlist[1].integer == 7 + assert result.objlist_opt is not None + assert result.objlist_opt[0].string == "nestedstring2_opt" + assert result.objlist_opt[0].integer == 66 + assert result.objlist_opt[1].string == "nestedstring3_opt" + assert result.objlist_opt[1].integer == 77 + + @pytest.mark.parametrize( + "val, error", + ( + ( + { + "integer": 1, + "obj": {"string": "nestedstring", "integer": 2}, + "stringlist": ["one", "two"], + "integerlist": [3, 4, 5], + "objlist": [ + {"string": "nestedstring2", "integer": 6}, + {"string": "nestedstring3", "integer": 7}, + ], + }, + IncorrectFieldTypeError( + IncorrectTypeError("StringDataValue", None), "string" + ), + ), + ( + { + "string": "string", + "integer": "1", + "obj": {"string": "nestedstring", "integer": 2}, + "stringlist": ["one", "two"], + "integerlist": [3, 4, 5], + "objlist": [ + {"string": "nestedstring2", "integer": 6}, + {"string": "nestedstring3", "integer": 7}, + ], + }, + IncorrectFieldTypeError( + IncorrectTypeError("int", "1"), "integer" + ), + ), + ( + { + "string": "string", + "integer": 1, + "obj": {"string": 8, "integer": 2}, + "stringlist": ["one", "two"], + "integerlist": [3, 4, 5], + "objlist": [ + {"string": "nestedstring2", "integer": 6}, + {"string": "nestedstring3", "integer": 7}, + ], + }, + IncorrectFieldTypeError( + IncorrectFieldTypeError( + IncorrectTypeError("string", 8), "string" + ), + "obj", + ), + ), + ( + { + "string": "string", + "integer": 1, + "obj": {"string": "nestedstring", "integer": 2}, + "stringlist": ["one", 2], + "integerlist": [3, 4, 5], + "objlist": [ + {"string": "nestedstring2", "integer": 6}, + {"string": "nestedstring3", "integer": 7}, + ], + }, + IncorrectFieldTypeError( + IncorrectListElementTypeError( + IncorrectTypeError("string", 2), 1 + ), + "stringlist", + ), + ), + ( + { + "string": "string", + "integer": 1, + "obj": {"string": "nestedstring", "integer": 2}, + "stringlist": ["one", "two"], + "integerlist": [3, 4, 5], + "objlist": [ + {"string": "nestedstring2", "integer": "6"}, + {"string": "nestedstring3", "integer": 7}, + ], + }, + IncorrectFieldTypeError( + IncorrectListElementTypeError( + IncorrectFieldTypeError( + IncorrectTypeError("int", "6"), "integer" + ), + 0, + ), + "objlist", + ), + ), + ("string", IncorrectTypeError("dict", "string")), + (1, IncorrectTypeError("dict", 1)), + (True, IncorrectTypeError("dict", True)), + ([], IncorrectTypeError("dict", [])), + ), + ) + def test_error(self, val, error): + with pytest.raises(type(error)) as e: + ExampleDataObject.from_value(val) + assert e.value.msg == error.msg diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_event_logger.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_event_logger.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_event_logger.py 1970-01-01 00:00:00.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_event_logger.py 2022-03-10 17:17:29.000000000 +0000 @@ -0,0 +1,141 @@ +import contextlib +import io +import json + +import mock +import pytest +import yaml + +from uaclient.event_logger import JSON_SCHEMA_VERSION, EventLoggerMode + + +class TestEventLogger: + @pytest.mark.parametrize( + "event_mode", + (EventLoggerMode.CLI, EventLoggerMode.JSON, EventLoggerMode.YAML), + ) + def test_process_events(self, event_mode, event): + with mock.patch.object(event, "_event_logger_mode", event_mode): + fake_stdout = io.StringIO() + with contextlib.redirect_stdout(fake_stdout): + event.info(info_msg="test") + event.needs_reboot(reboot_required=True) + event.service_processed("test") + event.services_failed(["esm"]) + event.error(error_msg="error1", error_code="error1-code") + event.error(error_msg="error2", service="esm") + event.error(error_msg="error3", error_type="exception") + event.warning(warning_msg="warning1") + event.warning(warning_msg="warning2", service="esm") + event.process_events() + + expected_cli_out = "test" + expected_machine_out = { + "_schema_version": JSON_SCHEMA_VERSION, + "result": "failure", + "errors": [ + { + "message": "error1", + "message_code": "error1-code", + "service": None, + "type": "system", + }, + { + "message": "error2", + "message_code": None, + "service": "esm", + "type": "service", + }, + { + "message": "error3", + "message_code": None, + "service": None, + "type": "exception", + }, + ], + "warnings": [ + { + "message": "warning1", + "message_code": None, + "service": None, + "type": "system", + }, + { + "message": "warning2", + "message_code": None, + "service": "esm", + "type": "service", + }, + ], + "failed_services": ["esm"], + "needs_reboot": True, + "processed_services": ["test"], + } + + if event_mode == EventLoggerMode.CLI: + assert expected_cli_out == fake_stdout.getvalue().strip() + elif event_mode == EventLoggerMode.JSON: + assert expected_machine_out == json.loads( + fake_stdout.getvalue().strip() + ) + else: + assert expected_machine_out == yaml.safe_load( + fake_stdout.getvalue().strip() + ) + + @pytest.mark.parametrize( + "event_mode", + (EventLoggerMode.CLI, EventLoggerMode.JSON, EventLoggerMode.YAML), + ) + def test_process_events_for_status(self, event_mode, event): + with mock.patch.object(event, "_event_logger_mode", event_mode): + fake_stdout = io.StringIO() + with contextlib.redirect_stdout(fake_stdout): + event.set_command("status") + event.set_output_content( + { + "some_status_key": "some_status_information", + "a_list_of_things": ["first", "second", "third"], + } + ) + event.info(info_msg="test") + event.error(error_msg="error1") + event.warning(warning_msg="warning1") + event.process_events() + + expected_machine_out = { + "some_status_key": "some_status_information", + "a_list_of_things": ["first", "second", "third"], + "environment_vars": [], + "services": [], + "result": "failure", + "errors": [ + { + "message": "error1", + "message_code": None, + "service": None, + "type": "system", + } + ], + "warnings": [ + { + "message": "warning1", + "message_code": None, + "service": None, + "type": "system", + } + ], + } + + expected_cli_out = "test" + + if event_mode == EventLoggerMode.CLI: + assert expected_cli_out == fake_stdout.getvalue().strip() + elif event_mode == EventLoggerMode.JSON: + assert expected_machine_out == json.loads( + fake_stdout.getvalue().strip() + ) + else: + assert expected_machine_out == yaml.safe_load( + fake_stdout.getvalue().strip() + ) diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_gpg.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_gpg.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_gpg.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_gpg.py 2022-03-10 17:17:29.000000000 +0000 @@ -1,5 +1,6 @@ import os +import mock import pytest from uaclient import exceptions, gpg, util @@ -39,7 +40,7 @@ assert error_msg in str(excinfo.value) assert not os.path.exists(destination_keyfile) - def test_export_single_key_from_keyring_dir(self, home_dir, tmpdir): + def test_export_single_key_from_keyring_dir(self, home_dir, tmpdir, _subp): """Only a single key is exported from a multi-key source keyring.""" source_key1 = tmpdir.join( "ubuntu-advantage-esm-{}.gpg".format(data.GPG_KEY1_ID) @@ -51,10 +52,11 @@ # Create keyring with both ESM and CC-EAL2 keys source_key1.write(data.GPG_KEY1, "wb") source_key2.write(data.GPG_KEY2, "wb") - gpg.export_gpg_key( - source_keyfile=source_key1.strpath, - destination_keyfile=destination_keyfile, - ) + with mock.patch("uaclient.util._subp", side_effect=_subp): + gpg.export_gpg_key( + source_keyfile=source_key1.strpath, + destination_keyfile=destination_keyfile, + ) gpg_dest_list_keys = [ "gpg", "--no-auto-check-trustdb", @@ -65,7 +67,8 @@ destination_keyfile, "--list-keys", ] - dest_out, _err = util.subp(gpg_dest_list_keys) + with mock.patch("uaclient.util._subp", side_effect=_subp): + dest_out, _err = util.subp(gpg_dest_list_keys) assert "Ubuntu Common Criteria EAL2" in dest_out # ESM didn't get exported diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_lock.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_lock.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_lock.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_lock.py 2022-03-10 17:17:29.000000000 +0000 @@ -3,7 +3,7 @@ from uaclient.exceptions import LockHeldError from uaclient.lock import SingleAttemptLock, SpinLock -from uaclient.status import MESSAGE_LOCK_HELD +from uaclient.messages import LOCK_HELD M_PATH = "uaclient.lock." M_PATH_UACONFIG = "uaclient.config.UAConfig." @@ -97,7 +97,7 @@ assert ( "Unable to perform: some operation.\n" - + MESSAGE_LOCK_HELD.format(lock_holder="held", pid=10) + + LOCK_HELD.format(lock_holder="held", pid=10).msg == exc.value.msg ) @@ -155,7 +155,7 @@ assert ( "Unable to perform: request.\n" - + MESSAGE_LOCK_HELD.format(lock_holder="holder", pid=10) + + LOCK_HELD.format(lock_holder="holder", pid=10).msg == exc.value.msg ) diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_reboot_cmds.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_reboot_cmds.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_reboot_cmds.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_reboot_cmds.py 2022-03-10 17:17:29.000000000 +0000 @@ -9,8 +9,8 @@ process_reboot_operations, run_command, ) -from uaclient.status import MESSAGE_REBOOT_SCRIPT_FAILED -from uaclient.util import ProcessExecutionError +from uaclient.exceptions import ProcessExecutionError +from uaclient.messages import REBOOT_SCRIPT_FAILED M_FIPS_PATH = "uaclient.entitlements.fips.FIPSEntitlement." @@ -157,7 +157,7 @@ with mock.patch("uaclient.config.UAConfig.write_cache"): process_reboot_operations(cfg=cfg) - expected_calls = [mock.call("", MESSAGE_REBOOT_SCRIPT_FAILED)] + expected_calls = [mock.call("", REBOOT_SCRIPT_FAILED)] assert expected_calls == m_add_notice.call_args_list diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_security.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_security.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_security.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_security.py 2022-03-10 17:17:29.000000000 +0000 @@ -1,11 +1,25 @@ import copy import textwrap +from collections import defaultdict import mock import pytest from uaclient import exceptions from uaclient.clouds.identity import NoCloudTypeReason +from uaclient.messages import ( + ENABLE_REBOOT_REQUIRED_TMPL, + FAIL_X, + OKGREEN_CHECK, + SECURITY_APT_NON_ROOT, + SECURITY_ISSUE_NOT_RESOLVED, + SECURITY_SERVICE_DISABLED, + SECURITY_UA_SERVICE_NOT_ENABLED, + SECURITY_UA_SERVICE_NOT_ENTITLED, + SECURITY_UPDATE_NOT_INSTALLED_EXPIRED, + SECURITY_UPDATE_NOT_INSTALLED_SUBSCRIPTION, + SECURITY_USE_PRO_TMPL, +) from uaclient.security import ( API_V1_CVE_TMPL, API_V1_CVES, @@ -15,10 +29,11 @@ USN, CVEPackageStatus, FixStatus, - SecurityAPIError, UASecurityClient, fix_security_issue_id, get_cve_affected_source_packages_status, + get_related_usns, + get_usn_affected_packages_status, merge_usn_released_binary_package_versions, override_usn_release_package_status, prompt_for_affected_packages, @@ -27,28 +42,12 @@ version_cmp_le, ) from uaclient.status import ( - FAIL_X, - MESSAGE_ENABLE_REBOOT_REQUIRED_TMPL, - MESSAGE_SECURITY_APT_NON_ROOT, - MESSAGE_SECURITY_ISSUE_NOT_RESOLVED, - MESSAGE_SECURITY_SERVICE_DISABLED, - MESSAGE_SECURITY_UA_SERVICE_NOT_ENABLED, - MESSAGE_SECURITY_UA_SERVICE_NOT_ENTITLED, - MESSAGE_SECURITY_UPDATE_NOT_INSTALLED_EXPIRED, -) -from uaclient.status import ( - MESSAGE_SECURITY_UPDATE_NOT_INSTALLED_SUBSCRIPTION as MSG_SUBSCRIPTION, -) -from uaclient.status import ( - MESSAGE_SECURITY_USE_PRO_TMPL, - OKGREEN_CHECK, PROMPT_ENTER_TOKEN, PROMPT_EXPIRED_ENTER_TOKEN, ApplicabilityStatus, UserFacingStatus, colorize_commands, ) -from uaclient.util import UrlError M_PATH = "uaclient.contract." M_REPO_PATH = "uaclient.entitlements.repo.RepoEntitlement." @@ -168,6 +167,34 @@ "type": "USN", } +SAMPLE_USN_RESPONSE_NO_CVES = { + "cves_ids": [], + "id": "USN-4038-3", + "instructions": "In general, a standard system update will make all ...\n", + "references": ["https://launchpad.net/bugs/1834494"], + "release_packages": { + "bionic": [ + { + "description": "high-level 3D graphics kit implementing ...", + "is_source": True, + "name": "coin3", + "version": "3.1.4~abc9f50-4ubuntu2+esm1", + }, + { + "is_source": False, + "name": "libcoin80-runtime", + "source_link": "https://launchpad.net/ubuntu/+source/coin3", + "version": "3~18.04.1+esm2", + "version_link": "https://coin3...18.04.1+esm2", + "pocket": "security", + }, + ] + }, + "summary": "", + "title": "USN vulnerability", + "type": "USN", +} + def shallow_merge_dicts(a, b): c = a.copy() @@ -222,9 +249,10 @@ ("2.1", "2.0", False), ), ) - def test_version_cmp_le(self, ver1, ver2, is_lessorequal): + def test_version_cmp_le(self, ver1, ver2, is_lessorequal, _subp): """version_cmp_le returns True when ver1 less than or equal to ver2.""" - assert is_lessorequal is version_cmp_le(ver1, ver2) + with mock.patch("uaclient.util._subp", side_effect=_subp): + assert is_lessorequal is version_cmp_le(ver1, ver2) class TestCVE: @@ -516,6 +544,15 @@ https://ubuntu.com/security/CVE-2020-1473 https://ubuntu.com/security/CVE-2020-1472""", ), + ( + SAMPLE_USN_RESPONSE_NO_CVES, + textwrap.dedent( + """\ + USN-4038-3: USN vulnerability + Found Launchpad bugs: + https://launchpad.net/bugs/1834494""" + ), + ), ), ) def test_get_url_header(self, FakeConfig, usn_response, expected): @@ -959,7 +996,7 @@ "Error: USN-### metadata defines no fixed version for sl.\n" "1 package is still affected: slsrc\n" "{msg}".format( - msg=MESSAGE_SECURITY_ISSUE_NOT_RESOLVED.format(issue="USN-###") + msg=SECURITY_ISSUE_NOT_RESOLVED.format(issue="USN-###") ) == exc.value.msg ) @@ -975,7 +1012,7 @@ (None, NoCloudTypeReason.NO_CLOUD_DETECTED), textwrap.dedent( """\ - No affected packages are installed. + No affected source packages are installed. {check} USN-### does not affect your system. """.format( check=OKGREEN_CHECK # noqa: E126 @@ -990,7 +1027,7 @@ (None, NoCloudTypeReason.NO_CLOUD_DETECTED), textwrap.dedent( """\ - 1 affected package is installed: slsrc + 1 affected source package is installed: slsrc (1/1) slsrc: A fix is available in Ubuntu standard updates. The update is already installed. @@ -1008,7 +1045,7 @@ (None, NoCloudTypeReason.NO_CLOUD_DETECTED), textwrap.dedent( """\ - 1 affected package is installed: slsrc + 1 affected source package is installed: slsrc (1/1) slsrc: A fix is available in Ubuntu standard updates. """ @@ -1027,7 +1064,7 @@ (None, NoCloudTypeReason.NO_CLOUD_DETECTED), textwrap.dedent( """\ - 1 affected package is installed: slsrc + 1 affected source package is installed: slsrc (1/1) slsrc: A fix is available in Ubuntu standard updates. """ @@ -1056,17 +1093,17 @@ ("azure", None), textwrap.dedent( """\ - 1 affected package is installed: slsrc + 1 affected source package is installed: slsrc (1/1) slsrc: A fix is available in UA Infra. """ ) + "\n".join( [ - MESSAGE_SECURITY_USE_PRO_TMPL.format( + SECURITY_USE_PRO_TMPL.format( title="Azure", cloud="azure" ), - MSG_SUBSCRIPTION, + SECURITY_UPDATE_NOT_INSTALLED_SUBSCRIPTION, ] ), FixStatus.SYSTEM_STILL_VULNERABLE, @@ -1078,17 +1115,15 @@ ("aws", None), textwrap.dedent( """\ - 1 affected package is installed: slsrc + 1 affected source package is installed: slsrc (1/1) slsrc: A fix is available in UA Infra. """ ) + "\n".join( [ - MESSAGE_SECURITY_USE_PRO_TMPL.format( - title="AWS", cloud="aws" - ), - MSG_SUBSCRIPTION, + SECURITY_USE_PRO_TMPL.format(title="AWS", cloud="aws"), + SECURITY_UPDATE_NOT_INSTALLED_SUBSCRIPTION, ] ), FixStatus.SYSTEM_STILL_VULNERABLE, @@ -1108,7 +1143,7 @@ ("gcp", None), textwrap.dedent( """\ - 2 affected packages are installed: curl, slsrc + 2 affected source packages are installed: curl, slsrc (1/2) curl: A fix is available in Ubuntu standard updates. """ @@ -1125,10 +1160,8 @@ ) + "\n".join( [ - MESSAGE_SECURITY_USE_PRO_TMPL.format( - title="GCP", cloud="gcp" - ), - MSG_SUBSCRIPTION, + SECURITY_USE_PRO_TMPL.format(title="GCP", cloud="gcp"), + SECURITY_UPDATE_NOT_INSTALLED_SUBSCRIPTION, ] ) + "\n" @@ -1180,7 +1213,7 @@ ("gcp", None), textwrap.dedent( """\ - 15 affected packages are installed: {} + 15 affected source packages are installed: {} (1/15, 2/15, 3/15) pkg1, pkg2, pkg9: Sorry, no fix is available. (4/15, 5/15) pkg7, pkg8: @@ -1194,9 +1227,9 @@ """ ).format( ( - "pkg1, pkg10, pkg11, pkg12, pkg13, pkg14,\n" - " pkg15, pkg2, pkg3, pkg4, pkg5, pkg6, pkg7, pkg8, " - "pkg9" + "pkg1, pkg10, pkg11, pkg12, pkg13,\n" + " pkg14, pkg15, pkg2, pkg3, pkg4, pkg5, pkg6, pkg7," + " pkg8, pkg9" ) ) + colorize_commands( @@ -1216,10 +1249,8 @@ ) + "\n".join( [ - MESSAGE_SECURITY_USE_PRO_TMPL.format( - title="GCP", cloud="gcp" - ), - MSG_SUBSCRIPTION, + SECURITY_USE_PRO_TMPL.format(title="GCP", cloud="gcp"), + SECURITY_UPDATE_NOT_INSTALLED_SUBSCRIPTION, ] ) + "\n" @@ -1248,7 +1279,7 @@ ("gcp", None), textwrap.dedent( """\ - 9 affected packages are installed: {} + 9 affected source packages are installed: {} (1/9, 2/9, 3/9) pkg1, pkg2, pkg9: Sorry, no fix is available. (4/9, 5/9) pkg7, pkg8: @@ -1259,8 +1290,8 @@ A fix is coming soon. Try again tomorrow. """ ).format( - "pkg1, pkg2, pkg3, pkg4, pkg5, pkg6, pkg7,\n" - " pkg8, pkg9" + "pkg1, pkg2, pkg3, pkg4, pkg5, pkg6,\n" + " pkg7, pkg8, pkg9" ) + "9 packages are still affected: {}".format( "pkg1, pkg2, pkg3, pkg4, pkg5, pkg6, pkg7, pkg8,\n" @@ -1314,7 +1345,7 @@ }, ("gcp", None), """\ -5 affected packages are installed: longpackagename1, longpackagename2, +5 affected source packages are installed: longpackagename1, longpackagename2, longpackagename3, longpackagename4, longpackagename5 (1/5, 2/5, 3/5, 4/5, 5/5) longpackagename1, longpackagename2, longpackagename3, longpackagename4, longpackagename5: @@ -1357,23 +1388,27 @@ expected_ret, FakeConfig, capsys, + _subp, ): """Messaging is based on affected status and installed packages.""" get_cloud_type.return_value = cloud_type m_user_facing_status.return_value = (UserFacingStatus.INACTIVE, "") cfg = FakeConfig() - with mock.patch("uaclient.util.sys") as m_sys: - m_stdout = mock.MagicMock() - type(m_sys).stdout = m_stdout - type(m_stdout).encoding = mock.PropertyMock(return_value="utf-8") - actual_ret = prompt_for_affected_packages( - cfg=cfg, - issue_id="USN-###", - affected_pkg_status=affected_pkg_status, - installed_packages=installed_packages, - usn_released_pkgs=usn_released_pkgs, - ) - assert expected_ret == actual_ret + with mock.patch("uaclient.util._subp", side_effect=_subp): + with mock.patch("uaclient.util.sys") as m_sys: + m_stdout = mock.MagicMock() + type(m_sys).stdout = m_stdout + type(m_stdout).encoding = mock.PropertyMock( + return_value="utf-8" + ) + actual_ret = prompt_for_affected_packages( + cfg=cfg, + issue_id="USN-###", + affected_pkg_status=affected_pkg_status, + installed_packages=installed_packages, + usn_released_pkgs=usn_released_pkgs, + ) + assert expected_ret == actual_ret out, err = capsys.readouterr() assert expected in out @@ -1400,7 +1435,7 @@ }, textwrap.dedent( """\ - 3 affected packages are installed: pkg1, pkg2, pkg3 + 3 affected source packages are installed: pkg1, pkg2, pkg3 (1/3) pkg2: A fix is available in Ubuntu standard updates. """ @@ -1415,7 +1450,7 @@ A fix is available in UA Infra. """ ) - + MSG_SUBSCRIPTION + + SECURITY_UPDATE_NOT_INSTALLED_SUBSCRIPTION + "\n" + PROMPT_ENTER_TOKEN + "\n" @@ -1467,6 +1502,7 @@ expected, FakeConfig, capsys, + _subp, ): m_get_cloud_type.return_value = ("cloud", None) m_check_subscription_for_service.return_value = True @@ -1479,17 +1515,20 @@ m_action_attach.side_effect = fake_attach cfg = FakeConfig() - with mock.patch("uaclient.util.sys") as m_sys: - m_stdout = mock.MagicMock() - type(m_sys).stdout = m_stdout - type(m_stdout).encoding = mock.PropertyMock(return_value="utf-8") - prompt_for_affected_packages( - cfg=cfg, - issue_id="USN-###", - affected_pkg_status=affected_pkg_status, - installed_packages=installed_packages, - usn_released_pkgs=usn_released_pkgs, - ) + with mock.patch("uaclient.util._subp", side_effect=_subp): + with mock.patch("uaclient.util.sys") as m_sys: + m_stdout = mock.MagicMock() + type(m_sys).stdout = m_stdout + type(m_stdout).encoding = mock.PropertyMock( + return_value="utf-8" + ) + prompt_for_affected_packages( + cfg=cfg, + issue_id="USN-###", + affected_pkg_status=affected_pkg_status, + installed_packages=installed_packages, + usn_released_pkgs=usn_released_pkgs, + ) out, err = capsys.readouterr() assert expected in out @@ -1516,7 +1555,7 @@ }, textwrap.dedent( """\ - 3 affected packages are installed: pkg1, pkg2, pkg3 + 3 affected source packages are installed: pkg1, pkg2, pkg3 (1/3) pkg1: A fix is available in Ubuntu standard updates. """ @@ -1539,21 +1578,25 @@ expected, FakeConfig, capsys, + _subp, ): m_upgrade_packages.return_value = False cfg = FakeConfig() - with mock.patch("uaclient.util.sys") as m_sys: - m_stdout = mock.MagicMock() - type(m_sys).stdout = m_stdout - type(m_stdout).encoding = mock.PropertyMock(return_value="utf-8") - prompt_for_affected_packages( - cfg=cfg, - issue_id="USN-###", - affected_pkg_status=affected_pkg_status, - installed_packages=installed_packages, - usn_released_pkgs=usn_released_pkgs, - ) + with mock.patch("uaclient.util._subp", side_effect=_subp): + with mock.patch("uaclient.util.sys") as m_sys: + m_stdout = mock.MagicMock() + type(m_sys).stdout = m_stdout + type(m_stdout).encoding = mock.PropertyMock( + return_value="utf-8" + ) + prompt_for_affected_packages( + cfg=cfg, + issue_id="USN-###", + affected_pkg_status=affected_pkg_status, + installed_packages=installed_packages, + usn_released_pkgs=usn_released_pkgs, + ) out, err = capsys.readouterr() assert expected in out @@ -1575,20 +1618,18 @@ {"pkg1": {"pkg1": {"version": "2.0"}}}, textwrap.dedent( """\ - 1 affected package is installed: pkg1 + 1 affected source package is installed: pkg1 (1/1) pkg1: A fix is available in UA Infra. """ ) - + MSG_SUBSCRIPTION + + SECURITY_UPDATE_NOT_INSTALLED_SUBSCRIPTION + "\n" + PROMPT_ENTER_TOKEN + "\n" + colorize_commands([["ua attach token"]]) + "\n" - + MESSAGE_SECURITY_UA_SERVICE_NOT_ENTITLED.format( - service="esm-infra" - ) + + SECURITY_UA_SERVICE_NOT_ENTITLED.format(service="esm-infra") + "\n" + "1 package is still affected: pkg1" + "\n" @@ -1620,6 +1661,7 @@ should_reboot, FakeConfig, capsys, + _subp, ): m_should_reboot.return_value = should_reboot m_get_cloud_type.return_value = ("cloud", None) @@ -1646,19 +1688,20 @@ "uaclient.security.entitlement_factory", return_value=m_entitlement_cls, ): - with mock.patch("uaclient.util.sys") as m_sys: - m_stdout = mock.MagicMock() - type(m_sys).stdout = m_stdout - type(m_stdout).encoding = mock.PropertyMock( - return_value="utf-8" - ) - prompt_for_affected_packages( - cfg=cfg, - issue_id="USN-###", - affected_pkg_status=affected_pkg_status, - installed_packages=installed_packages, - usn_released_pkgs=usn_released_pkgs, - ) + with mock.patch("uaclient.util._subp", side_effect=_subp): + with mock.patch("uaclient.util.sys") as m_sys: + m_stdout = mock.MagicMock() + type(m_sys).stdout = m_stdout + type(m_stdout).encoding = mock.PropertyMock( + return_value="utf-8" + ) + prompt_for_affected_packages( + cfg=cfg, + issue_id="USN-###", + affected_pkg_status=affected_pkg_status, + installed_packages=installed_packages, + usn_released_pkgs=usn_released_pkgs, + ) out, err = capsys.readouterr() assert expected in out @@ -1671,12 +1714,12 @@ {"pkg1": {"pkg1": {"version": "2.0"}}}, textwrap.dedent( """\ - 1 affected package is installed: pkg1 + 1 affected source package is installed: pkg1 (1/1) pkg1: A fix is available in UA Infra. """ ) - + MESSAGE_SECURITY_SERVICE_DISABLED.format(service="esm-infra") + + SECURITY_SERVICE_DISABLED.format(service="esm-infra") + "\n" + colorize_commands([["ua enable esm-infra"]]) + "\n" @@ -1714,6 +1757,7 @@ expected, FakeConfig, capsys, + _subp, ): m_get_cloud_type.return_value = ("cloud", None) m_check_subscription_expired.return_value = False @@ -1739,19 +1783,20 @@ "uaclient.entitlements.entitlement_factory", return_value=m_entitlement_cls, ): - with mock.patch("uaclient.util.sys") as m_sys: - m_stdout = mock.MagicMock() - type(m_sys).stdout = m_stdout - type(m_stdout).encoding = mock.PropertyMock( - return_value="utf-8" - ) - prompt_for_affected_packages( - cfg=cfg, - issue_id="USN-###", - affected_pkg_status=affected_pkg_status, - installed_packages=installed_packages, - usn_released_pkgs=usn_released_pkgs, - ) + with mock.patch("uaclient.util._subp", side_effect=_subp): + with mock.patch("uaclient.util.sys") as m_sys: + m_stdout = mock.MagicMock() + type(m_sys).stdout = m_stdout + type(m_stdout).encoding = mock.PropertyMock( + return_value="utf-8" + ) + prompt_for_affected_packages( + cfg=cfg, + issue_id="USN-###", + affected_pkg_status=affected_pkg_status, + installed_packages=installed_packages, + usn_released_pkgs=usn_released_pkgs, + ) out, err = capsys.readouterr() assert expected in out @@ -1764,16 +1809,14 @@ {"pkg1": {"pkg1": {"version": "2.0"}}}, textwrap.dedent( """\ - 1 affected package is installed: pkg1 + 1 affected source package is installed: pkg1 (1/1) pkg1: A fix is available in UA Infra. """ ) - + MESSAGE_SECURITY_SERVICE_DISABLED.format(service="esm-infra") + + SECURITY_SERVICE_DISABLED.format(service="esm-infra") + "\n" - + MESSAGE_SECURITY_UA_SERVICE_NOT_ENABLED.format( - service="esm-infra" - ) + + SECURITY_UA_SERVICE_NOT_ENABLED.format(service="esm-infra") + "\n" + "1 package is still affected: pkg1" + "\n" @@ -1803,6 +1846,7 @@ expected, FakeConfig, capsys, + _subp, ): m_get_cloud_type.return_value = ("cloud", None) m_check_subscription_expired.return_value = False @@ -1828,19 +1872,20 @@ "uaclient.entitlements.entitlement_factory", return_value=m_entitlement_cls, ): - with mock.patch("uaclient.util.sys") as m_sys: - m_stdout = mock.MagicMock() - type(m_sys).stdout = m_stdout - type(m_stdout).encoding = mock.PropertyMock( - return_value="utf-8" - ) - prompt_for_affected_packages( - cfg=cfg, - issue_id="USN-###", - affected_pkg_status=affected_pkg_status, - installed_packages=installed_packages, - usn_released_pkgs=usn_released_pkgs, - ) + with mock.patch("uaclient.util._subp", side_effect=_subp): + with mock.patch("uaclient.util.sys") as m_sys: + m_stdout = mock.MagicMock() + type(m_sys).stdout = m_stdout + type(m_stdout).encoding = mock.PropertyMock( + return_value="utf-8" + ) + prompt_for_affected_packages( + cfg=cfg, + issue_id="USN-###", + affected_pkg_status=affected_pkg_status, + installed_packages=installed_packages, + usn_released_pkgs=usn_released_pkgs, + ) out, err = capsys.readouterr() assert expected in out @@ -1853,12 +1898,12 @@ {"pkg1": {"pkg1": {"version": "2.0"}}}, textwrap.dedent( """\ - 1 affected package is installed: pkg1 + 1 affected source package is installed: pkg1 (1/1) pkg1: A fix is available in UA Infra. """ ) - + MESSAGE_SECURITY_UPDATE_NOT_INSTALLED_EXPIRED + + SECURITY_UPDATE_NOT_INSTALLED_EXPIRED + "\n" + PROMPT_EXPIRED_ENTER_TOKEN + "\n" @@ -1902,6 +1947,7 @@ expected, FakeConfig, capsys, + _subp, ): m_get_cloud_type.return_value = ("cloud", None) m_check_subscription_for_service.return_value = True @@ -1916,17 +1962,20 @@ } } ) - with mock.patch("uaclient.util.sys") as m_sys: - m_stdout = mock.MagicMock() - type(m_sys).stdout = m_stdout - type(m_stdout).encoding = mock.PropertyMock(return_value="utf-8") - prompt_for_affected_packages( - cfg=cfg, - issue_id="USN-###", - affected_pkg_status=affected_pkg_status, - installed_packages=installed_packages, - usn_released_pkgs=usn_released_pkgs, - ) + with mock.patch("uaclient.util._subp", side_effect=_subp): + with mock.patch("uaclient.util.sys") as m_sys: + m_stdout = mock.MagicMock() + type(m_sys).stdout = m_stdout + type(m_stdout).encoding = mock.PropertyMock( + return_value="utf-8" + ) + prompt_for_affected_packages( + cfg=cfg, + issue_id="USN-###", + affected_pkg_status=affected_pkg_status, + installed_packages=installed_packages, + usn_released_pkgs=usn_released_pkgs, + ) out, err = capsys.readouterr() assert expected in out @@ -1940,12 +1989,12 @@ {"pkg1": {"pkg1": {"version": "2.0"}}}, textwrap.dedent( """\ - 1 affected package is installed: pkg1 + 1 affected source package is installed: pkg1 (1/1) pkg1: A fix is available in UA Infra. """ ) - + MESSAGE_SECURITY_UPDATE_NOT_INSTALLED_EXPIRED + + SECURITY_UPDATE_NOT_INSTALLED_EXPIRED + "\n" + "1 package is still affected: pkg1" + "\n" @@ -1971,6 +2020,7 @@ expected, FakeConfig, capsys, + _subp, ): m_get_cloud_type.return_value = ("cloud", None) m_is_pocket_beta_service.return_value = False @@ -1984,17 +2034,20 @@ } ) - with mock.patch("uaclient.util.sys") as m_sys: - m_stdout = mock.MagicMock() - type(m_sys).stdout = m_stdout - type(m_stdout).encoding = mock.PropertyMock(return_value="utf-8") - prompt_for_affected_packages( - cfg=cfg, - issue_id="USN-###", - affected_pkg_status=affected_pkg_status, - installed_packages=installed_packages, - usn_released_pkgs=usn_released_pkgs, - ) + with mock.patch("uaclient.util._subp", side_effect=_subp): + with mock.patch("uaclient.util.sys") as m_sys: + m_stdout = mock.MagicMock() + type(m_sys).stdout = m_stdout + type(m_stdout).encoding = mock.PropertyMock( + return_value="utf-8" + ) + prompt_for_affected_packages( + cfg=cfg, + issue_id="USN-###", + affected_pkg_status=affected_pkg_status, + installed_packages=installed_packages, + usn_released_pkgs=usn_released_pkgs, + ) out, err = capsys.readouterr() assert expected in out @@ -2008,7 +2061,7 @@ {"pkg1": {"pkg1": {"version": "2.0"}}}, textwrap.dedent( """\ - 1 affected package is installed: pkg1 + 1 affected source package is installed: pkg1 (1/1) pkg1: A fix is available in Ubuntu standard updates. """ @@ -2043,31 +2096,33 @@ exp_ret, FakeConfig, capsys, + _subp, ): m_get_cloud_type.return_value = ("cloud", None) cfg = FakeConfig() - with mock.patch("uaclient.util.sys") as m_sys: - m_stdout = mock.MagicMock() - type(m_sys).stdout = m_stdout - type(m_stdout).encoding = mock.PropertyMock(return_value="utf-8") - actual_ret = prompt_for_affected_packages( - cfg=cfg, - issue_id="USN-###", - affected_pkg_status=affected_pkg_status, - installed_packages=installed_pkgs, - usn_released_pkgs=usn_released_pkgs, - ) - assert exp_ret == actual_ret + with mock.patch("uaclient.util._subp", side_effect=_subp): + with mock.patch("uaclient.util.sys") as m_sys: + m_stdout = mock.MagicMock() + type(m_sys).stdout = m_stdout + type(m_stdout).encoding = mock.PropertyMock( + return_value="utf-8" + ) + actual_ret = prompt_for_affected_packages( + cfg=cfg, + issue_id="USN-###", + affected_pkg_status=affected_pkg_status, + installed_packages=installed_pkgs, + usn_released_pkgs=usn_released_pkgs, + ) + assert exp_ret == actual_ret out, err = capsys.readouterr() assert exp_msg in out assert [ mock.call( "", - MESSAGE_ENABLE_REBOOT_REQUIRED_TMPL.format( - operation="fix operation" - ), + ENABLE_REBOOT_REQUIRED_TMPL.format(operation="fix operation"), ) ] == m_add_notice.call_args_list @@ -2080,7 +2135,7 @@ {"slsrc": {"sl": {"version": "2.1"}}}, textwrap.dedent( """\ - 1 affected package is installed: slsrc + 1 affected source package is installed: slsrc (1/1) slsrc: A fix is available in Ubuntu standard updates. The update is already installed. @@ -2151,15 +2206,74 @@ assert "apt update" in out assert "apt install --only-upgrade -y t1 t2" in out else: - assert MESSAGE_SECURITY_APT_NON_ROOT in out + assert SECURITY_APT_NON_ROOT in out assert m_subp.call_count == 0 +class TestGetRelatedUSNs: + def test_original_usn_returned_when_no_cves_are_found(self, FakeConfig): + cfg = FakeConfig() + client = UASecurityClient(cfg=cfg) + usn = USN(client, SAMPLE_USN_RESPONSE_NO_CVES) + + assert [usn] == get_related_usns(usn, client) + + +class TestGetUSNAffectedPackagesStatus: + @pytest.mark.parametrize( + "installed_packages, affected_packages", + ( + ( + {"coin3": {"libcoin80-runtime", "1.0"}}, + { + "coin3": CVEPackageStatus( + defaultdict( + str, {"status": "released", "pocket": "security"} + ) + ) + }, + ), + ), + ) + @mock.patch("uaclient.util.get_platform_info") + def test_pkgs_come_from_release_packages_if_usn_has_no_cves( + self, + m_platform_info, + installed_packages, + affected_packages, + FakeConfig, + ): + m_platform_info.return_value = {"series": "bionic"} + + cfg = FakeConfig() + client = UASecurityClient(cfg=cfg) + usn = USN(client, SAMPLE_USN_RESPONSE_NO_CVES) + actual_value = get_usn_affected_packages_status( + usn, installed_packages + ) + + if not affected_packages: + assert actual_value is {} + else: + assert "coin3" in actual_value + assert ( + affected_packages["coin3"].status + == actual_value["coin3"].status + ) + assert ( + affected_packages["coin3"].pocket_source + == actual_value["coin3"].pocket_source + ) + + class TestFixSecurityIssueId: @pytest.mark.parametrize( "issue_id", (("CVE-1800-123456"), ("USN-12345-12")) ) - def test_error_msg_when_issue_id_is_not_found(self, issue_id, FakeConfig): + @mock.patch("uaclient.security.query_installed_source_pkg_versions") + def test_error_msg_when_issue_id_is_not_found( + self, _m_query_versions, issue_id, FakeConfig + ): expected_message = "Error: {} not found.".format(issue_id) if "CVE" in issue_id: mock_func = "get_cve" @@ -2168,14 +2282,14 @@ mock_func = "get_notice" issue_type = "USN" - with mock.patch.object(UrlError, "__str__") as m_str: + with mock.patch.object(exceptions.UrlError, "__str__") as m_str: with mock.patch.object(UASecurityClient, mock_func) as m_func: m_str.return_value = "NOT FOUND" msg = "{} with id 'ID' does not exist".format(issue_type) error_mock = mock.Mock() type(error_mock).url = mock.PropertyMock(return_value="URL") - m_func.side_effect = SecurityAPIError( + m_func.side_effect = exceptions.SecurityAPIError( e=error_mock, error_response={"message": msg} ) @@ -2187,33 +2301,6 @@ @mock.patch("uaclient.security.query_installed_source_pkg_versions") @mock.patch("uaclient.security.get_usn_affected_packages_status") @mock.patch("uaclient.security.merge_usn_released_binary_package_versions") - def test_error_msg_when_usn_does_not_define_any_cves( - self, - m_merge_usn, - m_usn_affected_pkgs, - m_query_installed_pkgs, - FakeConfig, - ): - m_query_installed_pkgs.return_value = {} - m_usn_affected_pkgs.return_value = {} - m_merge_usn.return_value = {} - with mock.patch.object(UASecurityClient, "get_notice") as m_notice: - usn_mock = mock.MagicMock() - type(usn_mock).release_packages = mock.PropertyMock( - return_value={"a": {}} - ) - type(usn_mock).cves_ids = mock.PropertyMock(return_value=[]) - m_notice.return_value = usn_mock - - with pytest.raises(exceptions.SecurityAPIMetadataError) as exc: - fix_security_issue_id(FakeConfig(), "USN-123") - - expected_msg = "Error: USN-123 metadata defines no related CVEs." - assert expected_msg in exc.value.msg - - @mock.patch("uaclient.security.query_installed_source_pkg_versions") - @mock.patch("uaclient.security.get_usn_affected_packages_status") - @mock.patch("uaclient.security.merge_usn_released_binary_package_versions") def test_error_msg_when_usn_does_not_have_any_related_usns( self, m_merge_usn, @@ -2342,7 +2429,7 @@ ), ) def test_merge_usn_released_binary_package_versions( - self, usns_released_packages, expected_pkgs_dict + self, usns_released_packages, expected_pkgs_dict, _subp ): usns = [] beta_packages = {"esm-infra": False, "esm-apps": True} @@ -2354,9 +2441,10 @@ ) usns.append(usn) - usn_pkgs_dict = merge_usn_released_binary_package_versions( - usns, beta_packages - ) + with mock.patch("uaclient.util._subp", side_effect=_subp): + usn_pkgs_dict = merge_usn_released_binary_package_versions( + usns, beta_packages + ) assert expected_pkgs_dict == usn_pkgs_dict diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_security_status.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_security_status.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_security_status.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_security_status.py 2022-03-10 17:17:29.000000000 +0000 @@ -13,9 +13,11 @@ M_PATH = "uaclient.security_status." -# Each candidate is a tuple of (version, archive, origin) +# Each candidate/installed is a tuple of (version, archive, origin, site) def mock_package( - name, installed=None, candidates: List[Tuple[str, str, str]] = [] + name, + installed: Tuple[str, str, str, str] = None, + candidates: List[Tuple[str, str, str, str]] = [], ): mock_package = mock.MagicMock() mock_package.name = name @@ -27,7 +29,13 @@ mock_installed.__gt__ = ( lambda self, other: self.version > other.version ) - mock_installed.version = installed + mock_installed.version = installed[0] + + mock_origin = mock.MagicMock() + mock_origin.archive = installed[1] + mock_origin.origin = installed[2] + mock_origin.site = installed[3] + mock_installed.origins = [mock_origin] mock_package.installed = mock_installed mock_package.versions.append(mock_installed) @@ -44,6 +52,7 @@ mock_origin = mock.MagicMock() mock_origin.archive = candidate[1] mock_origin.origin = candidate[2] + mock_origin.site = candidate[3] mock_candidate.origins = [mock_origin] mock_package.versions.append(mock_candidate) @@ -121,64 +130,108 @@ "entitled_services": [], } - @mock.patch( - M_PATH + "get_platform_info", return_value={"series": "example"} - ) @mock.patch(M_PATH + "UAConfig.status", return_value={"attached": False}) @mock.patch(M_PATH + "Cache") def test_finds_updates_for_installed_packages( - self, m_cache, _m_status, _m_platform_info, FakeConfig + self, m_cache, _m_status, FakeConfig ): m_cache.return_value = [ mock_package(name="not_installed"), - mock_package(name="there_is_no_update", installed="1.0"), + mock_package( + name="there_is_no_update", + installed=("1.0", "somewhere", "somehow", ""), + ), mock_package( name="latest_is_installed", - installed="2.0", - candidates=[("1.0", "example-infra-security", "UbuntuESM")], + installed=("2.0", "standard-packages", "Ubuntu", ""), + candidates=[ + ( + "1.0", + "example-infra-security", + "UbuntuESM", + "some.url.for.esm", + ) + ], ), mock_package( name="update_available", - installed="1.0", - candidates=[("2.0", "example-infra-security", "UbuntuESM")], + # this is an ESM-INFRA example for the counters + installed=("1.0", "example-infra-security", "UbuntuESM", ""), + candidates=[ + ( + "2.0", + "example-infra-security", + "UbuntuESM", + "some.url.for.esm", + ) + ], ), mock_package( name="not_a_security_update", - installed="1.0", - candidates=[("2.0", "example-notsecurity", "NotUbuntuESM")], + installed=("1.0", "somewhere", "somehow", ""), + candidates=[ + ( + "2.0", + "example-notsecurity", + "NotUbuntuESM", + "some.url.for.esm", + ) + ], ), mock_package( name="more_than_one_update_available", - installed="1.0", + installed=("1.0", "somewhere", "somehow", ""), candidates=[ - ("2.0", "example-security", "Ubuntu"), - ("3.0", "example-infra-security", "UbuntuESM"), + ( + "2.0", + "example-security", + "Ubuntu", + "some.url.for.standard", + ), + ( + "3.0", + "example-infra-security", + "UbuntuESM", + "some.url.for.esm", + ), ], ), ] + service_to_origin_dict = { + "esm-infra": ("UbuntuESM", "example-infra-security"), + "standard-security": ("Ubuntu", "example-security"), + "esm-apps": ("UbuntuESMApps", "example-apps-security"), + } + origin_to_service_dict = { + v: k for k, v in service_to_origin_dict.items() + } + cfg = FakeConfig() expected_output = { - "_schema_version": "0", + "_schema_version": "0.1", "packages": [ { "package": "update_available", "version": "2.0", "service_name": "esm-infra", "status": "pending_attach", + "origin": "some.url.for.esm", }, { "package": "more_than_one_update_available", "version": "2.0", "service_name": "standard-security", "status": "upgrade_available", + "origin": "some.url.for.standard", }, { "package": "more_than_one_update_available", "version": "3.0", "service_name": "esm-infra", "status": "pending_attach", + "origin": "some.url.for.esm", }, ], "summary": { @@ -190,9 +243,16 @@ "num_installed_packages": 5, "num_esm_infra_updates": 2, "num_esm_apps_updates": 0, + "num_esm_infra_packages": 1, + "num_esm_apps_packages": 0, "num_standard_security_updates": 1, }, } - output = security_status(cfg) + with mock.patch( + M_PATH + "SERVICE_TO_ORIGIN_INFORMATION", service_to_origin_dict + ), mock.patch( + M_PATH + "ORIGIN_INFORMATION_TO_SERVICE", origin_to_service_dict + ): + output = security_status(cfg) assert output == expected_output diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_serviceclient.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_serviceclient.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_serviceclient.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_serviceclient.py 2022-03-10 17:17:29.000000000 +0000 @@ -6,7 +6,7 @@ import mock import pytest -from uaclient import util +from uaclient import exceptions from uaclient.serviceclient import UAServiceClient @@ -33,7 +33,7 @@ @pytest.mark.parametrize( "fp,expected_exception,expected_attrs", ( - (BytesIO(), util.UrlError, {"code": 619}), + (BytesIO(), exceptions.UrlError, {"code": 619}), ( BytesIO(b'{"a": "b"}'), OurServiceClientException, @@ -62,7 +62,7 @@ cfg = FakeConfig() cfg.cfg["contract_url"] = "http://example.com" client = OurServiceClient(cfg=cfg) - with pytest.raises(util.UrlError) as excinfo: + with pytest.raises(exceptions.UrlError) as excinfo: client.request_url("/") assert excinfo.value.code is None @@ -177,7 +177,7 @@ client = OurServiceClient(cfg=cfg) for response, headers in responses: if isinstance(response, Exception): - with pytest.raises(util.UrlError) as excinfo: + with pytest.raises(exceptions.UrlError) as excinfo: client._get_fake_responses(url) assert 404 == excinfo.value.code assert "nothing to see" == str(excinfo.value) diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_snap.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_snap.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_snap.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_snap.py 2022-03-10 17:17:29.000000000 +0000 @@ -3,13 +3,12 @@ import mock import pytest -from uaclient import status +from uaclient import exceptions, messages from uaclient.snap import ( configure_snap_proxy, get_config_option_value, unconfigure_snap_proxy, ) -from uaclient.util import ProcessExecutionError class TestConfigureSnapProxy: @@ -61,14 +60,18 @@ out, _ = capsys.readouterr() if http_proxy or https_proxy: - assert out.strip() == status.MESSAGE_SETTING_SERVICE_PROXY.format( + assert out.strip() == messages.SETTING_SERVICE_PROXY.format( service="snap" ) @pytest.mark.parametrize( "key, subp_side_effect, expected_ret", [ - ("proxy.http", ProcessExecutionError("doesn't matter"), None), + ( + "proxy.http", + exceptions.ProcessExecutionError("doesn't matter"), + None, + ), ("proxy.https", ("value", ""), "value"), ], ) diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_update_messaging.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_update_messaging.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_update_messaging.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_update_messaging.py 2022-03-10 17:17:29.000000000 +0000 @@ -19,17 +19,17 @@ write_apt_and_motd_templates, write_esm_announcement_message, ) -from uaclient.status import ( - MESSAGE_ANNOUNCE_ESM_TMPL, - MESSAGE_CONTRACT_EXPIRED_APT_NO_PKGS_TMPL, - MESSAGE_CONTRACT_EXPIRED_APT_PKGS_TMPL, - MESSAGE_CONTRACT_EXPIRED_GRACE_PERIOD_TMPL, - MESSAGE_CONTRACT_EXPIRED_SOON_TMPL, - MESSAGE_DISABLED_APT_PKGS_TMPL, - MESSAGE_DISABLED_MOTD_NO_PKGS_TMPL, - MESSAGE_UBUNTU_NO_WARRANTY, - ApplicationStatus, +from uaclient.messages import ( + ANNOUNCE_ESM_TMPL, + CONTRACT_EXPIRED_APT_NO_PKGS_TMPL, + CONTRACT_EXPIRED_APT_PKGS_TMPL, + CONTRACT_EXPIRED_GRACE_PERIOD_TMPL, + CONTRACT_EXPIRED_SOON_TMPL, + DISABLED_APT_PKGS_TMPL, + DISABLED_MOTD_NO_PKGS_TMPL, + UBUNTU_NO_WARRANTY, ) +from uaclient.status import ApplicationStatus M_PATH = "uaclient.jobs.update_messaging." @@ -122,9 +122,7 @@ assert [mock.call("xenial")] == util_is_active_esm.call_args_list no_warranty_file = os.path.join(msg_dir, "ubuntu-no-warranty") if no_warranty: - assert MESSAGE_UBUNTU_NO_WARRANTY == util.load_file( - no_warranty_file - ) + assert UBUNTU_NO_WARRANTY == util.load_file(no_warranty_file) else: assert False is os.path.exists(no_warranty_file) @@ -358,7 +356,7 @@ ) if expect_messages: assert ( - MESSAGE_DISABLED_APT_PKGS_TMPL.format( + DISABLED_APT_PKGS_TMPL.format( title=title, pkg_num=pkg_count_var, pkg_names=pkg_names_var, @@ -368,7 +366,7 @@ == pkgs_file.read() ) assert ( - MESSAGE_DISABLED_MOTD_NO_PKGS_TMPL.format(title=title, url=url) + DISABLED_MOTD_NO_PKGS_TMPL.format(title=title, url=url) == no_pkgs_file.read() ) else: @@ -475,7 +473,7 @@ assert False is os.path.exists(pkgs_msg_file) assert False is os.path.exists(no_pkgs_msg_file) elif contract_status == ContractExpiryStatus.ACTIVE_EXPIRED_SOON: - pkgs_msg = MESSAGE_CONTRACT_EXPIRED_SOON_TMPL.format( + pkgs_msg = CONTRACT_EXPIRED_SOON_TMPL.format( title="UA Apps: ESM", remaining_days=remaining_days, url=BASE_UA_URL, @@ -483,7 +481,7 @@ assert pkgs_msg == pkgs_tmpl.read() assert pkgs_msg == no_pkgs_tmpl.read() elif contract_status == ContractExpiryStatus.EXPIRED_GRACE_PERIOD: - pkgs_msg = MESSAGE_CONTRACT_EXPIRED_GRACE_PERIOD_TMPL.format( + pkgs_msg = CONTRACT_EXPIRED_GRACE_PERIOD_TMPL.format( title="UA Apps: ESM", expired_date=cfg.contract_expiry_datetime.strftime("%d %b %Y"), remaining_days=remaining_days @@ -493,14 +491,14 @@ assert pkgs_msg == pkgs_tmpl.read() assert pkgs_msg == no_pkgs_tmpl.read() elif contract_status == ContractExpiryStatus.EXPIRED: - pkgs_msg = MESSAGE_CONTRACT_EXPIRED_APT_PKGS_TMPL.format( + pkgs_msg = CONTRACT_EXPIRED_APT_PKGS_TMPL.format( pkg_num="{ESM_APPS_PKG_COUNT}", pkg_names="{ESM_APPS_PACKAGES}", title="UA Apps: ESM", name="esm-apps", url=BASE_UA_URL, ) - no_pkgs_msg = MESSAGE_CONTRACT_EXPIRED_APT_NO_PKGS_TMPL.format( + no_pkgs_msg = CONTRACT_EXPIRED_APT_NO_PKGS_TMPL.format( title="UA Apps: ESM", url=BASE_UA_URL ) assert pkgs_msg == pkgs_tmpl.read() @@ -525,9 +523,7 @@ None, False, "\n" - + MESSAGE_ANNOUNCE_ESM_TMPL.format( - url="https://ubuntu.com/16-04" - ), + + ANNOUNCE_ESM_TMPL.format(url="https://ubuntu.com/16-04"), ), # allow_beta uaclient.config overrides is_beta and days_until_esm ( @@ -538,9 +534,7 @@ True, False, "\n" - + MESSAGE_ANNOUNCE_ESM_TMPL.format( - url="https://ubuntu.com/16-04" - ), + + ANNOUNCE_ESM_TMPL.format(url="https://ubuntu.com/16-04"), ), # when esm-apps already enabled don't show ("xenial", "16.04", True, False, True, True, None), @@ -551,10 +545,7 @@ False, None, False, - "\n" - + MESSAGE_ANNOUNCE_ESM_TMPL.format( - url="https://ubuntu.com/esm" - ), + "\n" + ANNOUNCE_ESM_TMPL.format(url="https://ubuntu.com/esm"), ), # Once Bionic transitions to ESM support, emit 18-04 messaging ( @@ -565,9 +556,7 @@ None, False, "\n" - + MESSAGE_ANNOUNCE_ESM_TMPL.format( - url="https://ubuntu.com/18-04" - ), + + ANNOUNCE_ESM_TMPL.format(url="https://ubuntu.com/18-04"), ), ( "focal", @@ -576,10 +565,7 @@ False, None, False, - "\n" - + MESSAGE_ANNOUNCE_ESM_TMPL.format( - url="https://ubuntu.com/esm" - ), + "\n" + ANNOUNCE_ESM_TMPL.format(url="https://ubuntu.com/esm"), ), ), ) diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_util.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_util.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_util.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_util.py 2022-03-10 17:17:29.000000000 +0000 @@ -11,7 +11,7 @@ import mock import pytest -from uaclient import cli, exceptions, status, util +from uaclient import cli, exceptions, messages, util PRIVACY_POLICY_URL = ( "https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" @@ -188,7 +188,7 @@ """Return True when systemd-detect virt exits success.""" util.is_container.cache_clear() m_subp.side_effect = [ - util.ProcessExecutionError( + exceptions.ProcessExecutionError( "Failed running command 'ischroot' [exit(1)]" ), "", @@ -208,7 +208,7 @@ """Return True when /run/container_type exists.""" util.is_container.cache_clear() m_subp.side_effect = [ - util.ProcessExecutionError( + exceptions.ProcessExecutionError( "Failed running command 'ischroot' [exit(1)]" ), OSError("No systemd-detect-virt utility"), @@ -227,7 +227,7 @@ """Return True when /run/systemd/container exists.""" util.is_container.cache_clear() m_subp.side_effect = [ - util.ProcessExecutionError( + exceptions.ProcessExecutionError( "Failed running command 'ischroot' [exit(1)]" ), OSError("No systemd-detect-virt utility"), @@ -248,7 +248,7 @@ """Return False when sytemd-detect-virt erros and no /run/* files.""" util.is_container.cache_clear() m_subp.side_effect = [ - util.ProcessExecutionError( + exceptions.ProcessExecutionError( "Failed running command 'ischroot' [exit(1)]" ), OSError("No systemd-detect-virt utility"), @@ -280,18 +280,20 @@ class TestSubp: - def test_raise_error_on_timeout(self): + def test_raise_error_on_timeout(self, _subp): """When cmd exceeds the timeout raises a TimeoutExpired error.""" - with pytest.raises(subprocess.TimeoutExpired) as excinfo: - util.subp(["sleep", "2"], timeout=0) + with mock.patch("uaclient.util._subp", side_effect=_subp): + with pytest.raises(subprocess.TimeoutExpired) as excinfo: + util.subp(["sleep", "2"], timeout=0) msg = "Command '[b'sleep', b'2']' timed out after 0 seconds" assert msg == str(excinfo.value) @mock.patch("uaclient.util.time.sleep") - def test_default_do_not_retry_on_failure_return_code(self, m_sleep): + def test_default_do_not_retry_on_failure_return_code(self, m_sleep, _subp): """When no retry_sleeps are specified, do not retry failures.""" - with pytest.raises(util.ProcessExecutionError) as excinfo: - util.subp(["ls", "--bogus"]) + with mock.patch("uaclient.util._subp", side_effect=_subp): + with pytest.raises(exceptions.ProcessExecutionError) as excinfo: + util.subp(["ls", "--bogus"]) expected_errors = [ "Failed running command 'ls --bogus' [exit(2)].", @@ -302,19 +304,21 @@ assert 0 == m_sleep.call_count # no retries @mock.patch("uaclient.util.time.sleep") - def test_no_error_on_accepted_return_codes(self, m_sleep): + def test_no_error_on_accepted_return_codes(self, m_sleep, _subp): """When rcs list includes the exit code, do not raise an error.""" - out, err = util.subp(["ls", "--bogus"], rcs=[2]) + with mock.patch("uaclient.util._subp", side_effect=_subp): + out, err = util.subp(["ls", "--bogus"], rcs=[2]) assert "" == out assert "ls: unrecognized option '--bogus'" in err assert 0 == m_sleep.call_count # no retries @mock.patch("uaclient.util.time.sleep") - def test_retry_with_specified_sleeps_on_error(self, m_sleep): + def test_retry_with_specified_sleeps_on_error(self, m_sleep, _subp): """When retry_sleeps given, use defined sleeps between each retry.""" - with pytest.raises(util.ProcessExecutionError) as excinfo: - util.subp(["ls", "--bogus"], retry_sleeps=[1, 3, 0.4]) + with mock.patch("uaclient.util._subp", side_effect=_subp): + with pytest.raises(exceptions.ProcessExecutionError) as excinfo: + util.subp(["ls", "--bogus"], retry_sleeps=[1, 3, 0.4]) expected_error = "Failed running command 'ls --bogus' [exit(2)]" assert expected_error in str(excinfo.value) @@ -322,12 +326,13 @@ assert expected_sleeps == m_sleep.call_args_list @mock.patch("uaclient.util.time.sleep") - def test_retry_doesnt_consume_retry_sleeps(self, m_sleep): + def test_retry_doesnt_consume_retry_sleeps(self, m_sleep, _subp): """When retry_sleeps given, use defined sleeps between each retry.""" sleeps = [1, 3, 0.4] expected_sleeps = sleeps.copy() - with pytest.raises(util.ProcessExecutionError): - util.subp(["ls", "--bogus"], retry_sleeps=sleeps) + with mock.patch("uaclient.util._subp", side_effect=_subp): + with pytest.raises(exceptions.ProcessExecutionError): + util.subp(["ls", "--bogus"], retry_sleeps=sleeps) assert expected_sleeps == sleeps @@ -337,8 +342,10 @@ def test_retry_logs_remaining_retries(self, m_sleep, m_subp, caplog_text): """When retry_sleeps given, use defined sleeps between each retry.""" sleeps = [1, 3, 0.4] - m_subp.side_effect = util.ProcessExecutionError("Funky apt %d error") - with pytest.raises(util.ProcessExecutionError): + m_subp.side_effect = exceptions.ProcessExecutionError( + "Funky apt %d error" + ) + with pytest.raises(exceptions.ProcessExecutionError): util.subp(["apt", "dostuff"], retry_sleeps=sleeps) logs = caplog_text() @@ -359,10 +366,10 @@ """When subp fails, capture the logs in stdout/stderr""" out = "Tried downloading file" err = "Network error" - m_subp.side_effect = util.ProcessExecutionError( + m_subp.side_effect = exceptions.ProcessExecutionError( "Serious apt error", stdout=out, stderr=err ) - with pytest.raises(util.ProcessExecutionError): + with pytest.raises(exceptions.ProcessExecutionError): util.subp(["apt", "nothing"], capture=capture) logs = caplog_text() @@ -904,7 +911,7 @@ config=cfg.cfg, path_to_value=path_to_value ) - expected_msg = status.ERROR_INVALID_CONFIG_VALUE.format( + expected_msg = messages.ERROR_INVALID_CONFIG_VALUE.format( path_to_value=path_to_value, expected_value="boolean string: true or false", value=key_val, @@ -1132,7 +1139,7 @@ assert ( e.value.msg - == status.MESSAGE_NOT_SETTING_PROXY_INVALID_URL.format(proxy=proxy) + == messages.NOT_SETTING_PROXY_INVALID_URL.format(proxy=proxy).msg ) @pytest.mark.parametrize( @@ -1193,13 +1200,13 @@ assert ( e.value.msg - == status.MESSAGE_NOT_SETTING_PROXY_NOT_WORKING.format( + == messages.NOT_SETTING_PROXY_NOT_WORKING.format( proxy="http://localhost:1234" - ) + ).msg ) assert ( - status.MESSAGE_ERROR_USING_PROXY.format( + messages.ERROR_USING_PROXY.format( proxy="http://localhost:1234", test_url="http://example.com", error=expected_message, @@ -1215,8 +1222,8 @@ @pytest.mark.parametrize( "message,modified_message", ( - (status.OKGREEN_CHECK + " test", "test"), - (status.FAIL_X + " fail", "fail"), + (messages.OKGREEN_CHECK + " test", "test"), + (messages.FAIL_X + " fail", "fail"), ("\u2014 blah", "- blah"), ), ) diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_version.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_version.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/tests/test_version.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/tests/test_version.py 2022-03-10 17:17:29.000000000 +0000 @@ -3,7 +3,7 @@ import mock import pytest -from uaclient import util +from uaclient import exceptions from uaclient.version import get_version @@ -53,7 +53,7 @@ def fake_subp(cmd): if cmd[0] == "git": # Not matching tag on git-ubuntu pkg branches - raise util.ProcessExecutionError( + raise exceptions.ProcessExecutionError( "fatal: No names found, cannot describe anything." ) if cmd[0] == "dpkg-parsechangelog": diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/types.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/types.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/types.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/types.py 2022-03-10 17:17:29.000000000 +0000 @@ -1,3 +1,8 @@ -from typing import Any, Callable, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple, Union -StaticAffordance = Tuple[str, Callable[[], Any], bool] +from uaclient.messages import NamedMessage + +StaticAffordance = Tuple[NamedMessage, Callable[[], Any], bool] + +MessagingOperations = List[Union[str, Tuple[Callable, Dict]]] +MessagingOperationsDict = Dict[str, Optional[MessagingOperations]] diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/util.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/util.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/util.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/util.py 2022-03-10 17:17:29.000000000 +0000 @@ -14,7 +14,6 @@ from http.client import HTTPMessage from typing import ( Any, - Callable, Dict, List, Mapping, @@ -27,7 +26,8 @@ from urllib import error, request from urllib.parse import urlparse -from uaclient import exceptions, status +from uaclient import event_logger, exceptions, messages, status +from uaclient.types import MessagingOperations REBOOT_FILE_CHECK_PATH = "/var/run/reboot-required" REBOOT_PKGS_FILE_PATH = "/var/run/reboot-required.pkgs" @@ -44,6 +44,8 @@ PROXY_VALIDATION_SNAP_HTTP_URL = "http://api.snapcraft.io" PROXY_VALIDATION_SNAP_HTTPS_URL = "https://api.snapcraft.io" +event = event_logger.get_event_logger() + class LogFormatter(logging.Formatter): @@ -57,49 +59,6 @@ return logging.Formatter(log_fmt).format(record) -class UrlError(IOError): - def __init__( - self, - cause: error.URLError, - code: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - url: Optional[str] = None, - ): - if getattr(cause, "reason", None): - cause_error = str(cause.reason) - else: - cause_error = str(cause) - super().__init__(cause_error) - self.code = code - self.headers = headers - if self.headers is None: - self.headers = {} - self.url = url - - -class ProcessExecutionError(IOError): - def __init__( - self, - cmd: str, - exit_code: Optional[int] = None, - stdout: str = "", - stderr: str = "", - ) -> None: - self.stdout = stdout - self.stderr = stderr - self.exit_code = exit_code - if not exit_code: - message_tmpl = "Invalid command specified '{cmd}'." - else: - message_tmpl = ( - "Failed running command '{cmd}' [exit({exit_code})]." - " Message: {stderr}" - ) - super().__init__( - message_tmpl.format(cmd=cmd, stderr=stderr, exit_code=exit_code) - ) - - class DatetimeAwareJSONEncoder(json.JSONEncoder): """A json.JSONEncoder subclass that writes out isoformat'd datetimes.""" @@ -391,7 +350,7 @@ try: subp(["ischroot"]) return False - except ProcessExecutionError: + except exceptions.ProcessExecutionError: pass try: @@ -464,11 +423,11 @@ value, ", ".join([choice.upper() for choice in valid_choices]) ) while True: - print(msg) + event.info(msg) value = input("> ").lower() if value in valid_choices: break - print(error_msg) + event.info(error_msg) return value @@ -618,16 +577,16 @@ (out, err) = proc.communicate(timeout=timeout) except OSError: try: - raise ProcessExecutionError( + raise exceptions.ProcessExecutionError( cmd=redacted_cmd, exit_code=proc.returncode, stdout=out.decode("utf-8"), stderr=err.decode("utf-8"), ) except UnboundLocalError: - raise ProcessExecutionError(cmd=redacted_cmd) + raise exceptions.ProcessExecutionError(cmd=redacted_cmd) if proc.returncode not in rcs: - raise ProcessExecutionError( + raise exceptions.ProcessExecutionError( cmd=redacted_cmd, exit_code=proc.returncode, stdout=out.decode("utf-8"), @@ -676,7 +635,7 @@ try: out, err = _subp(args, rcs, capture, timeout, env=env) break - except ProcessExecutionError as e: + except exceptions.ProcessExecutionError as e: if capture: logging.debug(redact_sensitive_logs(str(e))) msg = "Stderr: {}\nStdout: {}".format(e.stderr, e.stdout) @@ -760,7 +719,7 @@ return False else: raise exceptions.UserFacingError( - status.ERROR_INVALID_CONFIG_VALUE.format( + messages.ERROR_INVALID_CONFIG_VALUE.format( path_to_value=path_to_value, expected_value="boolean string: true or false", value=value_str, @@ -833,13 +792,11 @@ try: out, _ = subp(["dpkg", "-l", package_name]) return "ii {} ".format(package_name) in out - except ProcessExecutionError: + except exceptions.ProcessExecutionError: return False -def handle_message_operations( - msg_ops: List[Union[str, Tuple[Callable, Dict]]], -) -> bool: +def handle_message_operations(msg_ops: Optional[MessagingOperations],) -> bool: """Emit messages to the console for user interaction :param msg_op: A list of strings or tuples. Any string items are printed. @@ -849,9 +806,12 @@ :return: True upon success, False on failure. """ + if not msg_ops: + return True + for msg_op in msg_ops: if isinstance(msg_op, str): - print(msg_op) + event.info(msg_op) else: # Then we are a callable and dict of args functor, args = msg_op if not functor(**args): @@ -921,9 +881,7 @@ return None if not is_service_url(proxy): - raise exceptions.UserFacingError( - status.MESSAGE_NOT_SETTING_PROXY_INVALID_URL.format(proxy=proxy) - ) + raise exceptions.ProxyInvalidUrl(proxy) req = request.Request(test_url, method="HEAD") proxy_handler = request.ProxyHandler({protocol: proxy}) @@ -936,13 +894,11 @@ with disable_log_to_console(): msg = getattr(e, "reason", str(e)) logging.error( - status.MESSAGE_ERROR_USING_PROXY.format( + messages.ERROR_USING_PROXY.format( proxy=proxy, test_url=test_url, error=msg ) ) - raise exceptions.UserFacingError( - status.MESSAGE_NOT_SETTING_PROXY_NOT_WORKING.format(proxy=proxy) - ) + raise exceptions.ProxyNotWorkingError(proxy) def handle_unicode_characters(message: str) -> str: @@ -962,7 +918,7 @@ # Remove our unicode success/failure marks if we aren't going to be # writing to a utf-8 output; see # https://github.com/CanonicalLtd/ubuntu-advantage-client/issues/1463 - message = message.replace(status.OKGREEN_CHECK + " ", "") - message = message.replace(status.FAIL_X + " ", "") + message = message.replace(messages.OKGREEN_CHECK + " ", "") + message = message.replace(messages.FAIL_X + " ", "") return message diff -Nru ubuntu-advantage-tools-27.6~16.04.1/uaclient/version.py ubuntu-advantage-tools-27.7~16.04.1/uaclient/version.py --- ubuntu-advantage-tools-27.6~16.04.1/uaclient/version.py 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/uaclient/version.py 2022-03-10 17:17:29.000000000 +0000 @@ -6,9 +6,9 @@ """ import os.path -from uaclient import util +from uaclient import exceptions, util -__VERSION__ = "27.6" +__VERSION__ = "27.7" PACKAGED_VERSION = "@@PACKAGED_VERSION@@" VERSION_TMPL = "{version}{feature_suffix}" @@ -39,7 +39,7 @@ try: out, _ = util.subp(cmd) return out.strip() + feature_suffix - except util.ProcessExecutionError: + except exceptions.ProcessExecutionError: # Rely on debian/changelog because we are in a git-ubuntu or other # packaging repo cmd = ["dpkg-parsechangelog", "-S", "version"] diff -Nru ubuntu-advantage-tools-27.6~16.04.1/ubuntu-advantage.1 ubuntu-advantage-tools-27.7~16.04.1/ubuntu-advantage.1 --- ubuntu-advantage-tools-27.6~16.04.1/ubuntu-advantage.1 2022-01-20 21:02:17.000000000 +0000 +++ ubuntu-advantage-tools-27.7~16.04.1/ubuntu-advantage.1 2022-03-10 17:17:29.000000000 +0000 @@ -21,12 +21,22 @@ .SH COMMANDS .TP -.BR "attach" " [--no-auto-enable] " +.BR "attach" " [--no-auto-enable] [--attach-config=/path/to/file.yaml] " Connect an Ubuntu Advantage support contract to this machine. The \fItoken\fR parameter can be obtained from https://auth.contracts.canonical.com/. +The \fI--attach-config\fR option can be used to provide a file with the token +and optionally, a list of services to enable after attaching. The \fItoken\fR +parameter should not be used if this option is provided. An attach config file +looks like the following: + token: YOUR_TOKEN_HERE # required + enable_services: # optional list of service names to auto-enable + - esm-infra + - esm-apps + - cis + The optional \fI--no-auto-enable\fR flag will disable the automatic enablement of recommended entitlements which usually happens immediately after a successful attach.