ubuntu-advantage-tools-19.7ubuntu0.1/0000775000000000000000000000000015223224570014436 5ustar ubuntu-advantage-tools-19.7ubuntu0.1/MANIFEST.in0000664000000000000000000000010314072115230016157 0ustar include MANIFEST.in requirements.txt test-requirements.txt tox.ini ubuntu-advantage-tools-19.7ubuntu0.1/setup.py0000664000000000000000000000262614601326072016155 0ustar # Copyright (C) 2019 Canonical Ltd. # This file is part of ubuntu-advantage-client. See LICENSE file for license. import glob import setuptools from uaclient import defaults, version NAME = 'ubuntu-advantage-tools' INSTALL_REQUIRES = open('requirements.txt').read().rstrip('\n').split('\n') TEST_REQUIRES = open('test-requirements.txt').read().rstrip('\n').split('\n') def _get_version(): parts = version.get_version().split('-') if len(parts) == 1: return parts[0] major_minor, _subrev, _commitish = parts return major_minor setuptools.setup( name=NAME, version=_get_version(), packages=setuptools.find_packages( exclude=['*.testing', 'tests.*', '*.tests', 'tests'] ), data_files=[ ('/etc/apt/apt.conf.d', ['apt.conf.d/51ubuntu-advantage-esm']), ('/etc/ubuntu-advantage', ['uaclient.conf']), ('/usr/share/keyrings', glob.glob('keyrings/*')), (defaults.CONFIG_DEFAULTS['data_dir'], []) ], install_requires=INSTALL_REQUIRES, extras_require=dict(test=TEST_REQUIRES), author='Ubuntu Server Team', author_email='ubuntu-server@lists.ubuntu.com', description=('Manage Ubuntu Advantage support entitlements'), license='GPLv3', url='https://ubuntu.com/support', entry_points={ 'console_scripts': [ 'ubuntu-advantage=uaclient.cli:main', 'ua=uaclient.cli:main' ] } ) ubuntu-advantage-tools-19.7ubuntu0.1/ubuntu-advantage.10000664000000000000000000001230714601326072017774 0ustar .TH "UBUNTU-ADVANTAGE" "1" "29 March 2019" "Canonical Ltd." "Ubuntu Advantage" .SH NAME ubuntu-advantage \- Manage Ubuntu Advantage services from Canonical .SH SYNOPSIS .BR "ua" " []" .br .BR "ubuntu-advantage" " []" .SH DESCRIPTION Ubuntu Advantage is a collection of services offered by Canonical to Ubuntu users. The Ubuntu Advantage command line tool is used to attach a system to an Ubuntu Advantage contract to then enable and disable services from Canonical. The available commands and services are described in more detail below. .SH COMMANDS .TP .BR "attach" " [--no-auto-enable] " Connect an Ubuntu Advantage support contract to this machine. The \fItoken\fR parameter can be obtained from https://auth.contracts.canonical.com/. The optional \fI--no-auto-enable\fR flag will disable the automatic enablement of recommended entitlements which usually happens immediately after a successful attach. .TP .B detach Remove the Ubuntu Advantage support contract from this machine. This also disables all enabled services that can be. .TP .BR "disable" " [cc-eal|cis-audit|esm|fips|fips-updates|livepatch]" Disable this machine's access to an Ubuntu Advantage service. .TP .BR "enable" " [cc-eal|cis-audit|esm|fips|fips-updates|livepatch]" Activate and configure this machine's access to an Ubuntu Advantage service. .TP .B refresh Refresh contract and service details from Canonical. .TP .BR "status" " [--format=tabular|json]" Report current status of Ubuntu Advantage services on system. This shows whether this machine is attached to an Ubuntu Advantage support contract. When attached, the report includes the specific support contract details including contract name, expiry dates, and the status of each service on this system. The attached status output has four columns: .BR "SERVICE" ":" name of the service .BR "ENTITLED" ":" whether the contract to which this machine is attached entitles use of this service. Possible values are: \fIyes\fR or \fIno\fR .BR "STATUS" ":" whether the service is enabled on this machine. Possible values are: \fIenabled\fR, \fIdisabled\fR, \fIn/a\fR (if your contract entitles you to the service, but it isn't available for this machine) or \fI—\fR (if you aren't entitled to this service) .BR "DESCRIPTION" ":" a brief description of the service The unattached status output instead has three columns. \fBSERVICE\fR and \fBDESCRIPTION\fR are the same as above, and there is the addition of: .BR "AVAILABLE" ":" whether this service would be available if this machine were attached. The possible values are \fIyes\fR or \fIno\fR. .TP .B version Show version of the Ubuntu Advantage package. .SH CONFIGURATION By default, Ubuntu Advantage client configuration options are read from \fB/etc/ubuntu-advantage/uaclient.conf\fB. The following configuration options are available: .TP .B \fBcontract_url\fP The ubuntu advantage contract server URL .TP .B \fBdata_dir\fP Where Ubuntu Advantage client stores its data files .TP .B \fBlog_level\fP The logging level used when writing to \fBlog_file\fP .TP .B \fBlog_file\fP The log file for the Ubuntu Advantage client .P Additionally, any configuration option can be overridden in the environment by setting an environment variable prefaced by \fBUA_\fP. Both uppercase and lowercase environment variables are allowed. For example, the following overrides the log_level found in uaclient.conf: .PP .nf .fam C UA_LOG_LEVEL=info ua attach .fam T .fi .SH SERVICES .TP .B "Common Criteria EAL2 Provisioning (cc-eal)" Enables and install the Common Criteria artifacts. The artifacts include a configure script, a tarball with additional packages, and post install scripts. The artifacts will be installed in /usr/lib/common-criteria directory and the README and configuration guide are available in /usr/share/doc/ubuntu-commoncriteria directory. .TP .B "CIS Audit (cis-audit)" Enables and installs the CIS Audit artifacts. .TP .B "Extended Security Maintenance (esm)" Extended Security Maintenance ensures the ongoing security and integrity of systems running Ubuntu Long Term Support (LTS) releases through Ubuntu Advantage for Infrastructure. See https://ubuntu.com/esm for more information. .TP .B "FIPS 140-2 certified modules (fips)" Install, configure, and enable FIPS 140-2 certified modules. After successfully enabling FIPS, the system MUST be rebooted. Failing to reboot will result in the system not running the updated FIPS kernel. Disabling FIPS is not currently supported. .TP .B "FIPS 140-2 certified modules with updates (fips-updates)" Install, configure, and enable FIPS 140-2 certified modules with updates. Enabling FIPS with updates will take the system out of FIPS compliance as the updated modules are not FIPS certified. After successfully enabling FIPS with updates, the system MUST be rebooted. Failing to reboot will result in the system not running the updated FIPS kernel. Disabling FIPS with updates is not currently supported. .TP .B "Livepatch Service (livepatch)" Automatically apply critical kernel patches without rebooting. Reduces downtime, keeping your Ubuntu LTS systems secure and compliant. See https://ubuntu.com/livepatch for more information. .SH COPYRIGHT Copyright (C) 2019 Canonical Ltd. ubuntu-advantage-tools-19.7ubuntu0.1/debian/0000775000000000000000000000000015223224570015660 5ustar ubuntu-advantage-tools-19.7ubuntu0.1/debian/postinst0000664000000000000000000000711714601326072017473 0ustar #!/bin/sh set -e . /etc/os-release # For VERSION_ID APT_TRUSTED_KEY_DIR="/etc/apt/trusted.gpg.d" UA_KEYRING_DIR="/usr/share/keyrings/" ESM_INFRA_KEY_TRUSTY="ubuntu-advantage-esm-infra-trusty.gpg" APT_SRC_DIR="/etc/apt/sources.list.d" ESM_APT_SOURCE_FILE_PRECISE="$APT_SRC_DIR/ubuntu-esm-precise.list" ESM_APT_SOURCE_FILE_TRUSTY="$APT_SRC_DIR/ubuntu-esm-trusty.list" ESM_INFRA_APT_SOURCE_FILE_TRUSTY="$APT_SRC_DIR/ubuntu-esm-infra-trusty.list" ESM_APT_PREF_FILE_TRUSTY="/etc/apt/preferences.d/ubuntu-esm-trusty" ESM_INFRA_APT_PREF_FILE_TRUSTY="/etc/apt/preferences.d/ubuntu-esm-infra-trusty" MYARCH="$(dpkg --print-architecture)" ESM_SUPPORTED_ARCHS="i386 amd64" unconfigure_esm() { rm -f $APT_TRUSTED_KEY_DIR/ubuntu-esm*gpg # Remove previous esm keys rm -f $APT_TRUSTED_KEY_DIR/$ESM_INFRA_KEY_TRUSTY rm -f $ESM_INFRA_APT_SOURCE_FILE_TRUSTY rm -f $ESM_APT_PREF_FILE_TRUSTY $ESM_INFRA_APT_PREF_FILE_TRUSTY } configure_esm() { rm -f $APT_TRUSTED_KEY_DIR/ubuntu-esm*gpg # Remove previous esm keys if [ ! -f "$APT_TRUSTED_KEY_DIR/$ESM_INFRA_KEY_TRUSTY" ]; then cp $UA_KEYRING_DIR/$ESM_INFRA_KEY_TRUSTY $APT_TRUSTED_KEY_DIR fi if [ -e "$ESM_APT_SOURCE_FILE_TRUSTY" ]; then mv $ESM_APT_SOURCE_FILE_TRUSTY $ESM_INFRA_APT_SOURCE_FILE_TRUSTY fi if [ -e "$ESM_APT_PREF_FILE_TRUSTY" ]; then mv $ESM_APT_PREF_FILE_TRUSTY $ESM_INFRA_APT_PREF_FILE_TRUSTY fi if [ ! -e "$ESM_INFRA_APT_SOURCE_FILE_TRUSTY" ]; then cat > $ESM_INFRA_APT_SOURCE_FILE_TRUSTY < $ESM_INFRA_APT_PREF_FILE_TRUSTY <> debian/ubuntu-advantage-tools.substvars dh_gencontrol override_dh_auto_install: dh_auto_install --destdir=debian/ubuntu-advantage-tools flist=$$(find $(CURDIR)/debian/ -type f -name version.py) && sed -i 's,@@PACKAGED_VERSION@@,$(DEB_VERSION),' $${flist:-did-not-find-version-py-for-replacement} make -C apt-hook DESTDIR=$(CURDIR)/debian/ubuntu-advantage-tools install override_dh_auto_clean: dh_auto_clean make clean ubuntu-advantage-tools-19.7ubuntu0.1/debian/control0000664000000000000000000000222214601326072017260 0ustar Source: ubuntu-advantage-tools Section: misc Priority: important Maintainer: Ubuntu Developers Build-Depends: bash-completion, debhelper (>=9), dh-python, gettext, git, libapt-pkg-dev, python3 (>= 3.4), python3-mock, python3-pytest, python3-setuptools, python3-yaml Standards-Version: 4.3.0 Homepage: https://buy.ubuntu.com Vcs-Git: https://github.com/CanonicalLtd/ubuntu-advantage-script.git Vcs-Browser: https://github.com/CanonicalLtd/ubuntu-advantage-script Package: ubuntu-advantage-tools Architecture: any Depends: ${misc:Depends}, ${python3:Depends}, ${shlibs:Depends}, python3-pkg-resources, ${extra:Depends}, Description: management tools for Ubuntu Advantage Ubuntu Advantage is the professional package of tooling, technology and expertise from Canonical, helping organisations around the world manage their Ubuntu deployments. . Subscribers to Ubuntu Advantage will find helpful tools for accessing services in this package. ubuntu-advantage-tools-19.7ubuntu0.1/debian/links0000664000000000000000000000014214601326072016717 0ustar usr/bin/ubuntu-advantage usr/bin/ua usr/share/man/man1/ubuntu-advantage.1 usr/share/man/man1/ua.1 ubuntu-advantage-tools-19.7ubuntu0.1/debian/copyright0000664000000000000000000000167114072115230017611 0ustar Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Upstream-Name: ubuntu-advantage-script Source: https://github.com/CanonicalLtd/ubuntu-advantage-script Files: * Copyright: 2017, Canonical Ltd License: GPL-3.0 This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 of the License. . This package is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. . You should have received a copy of the GNU General Public License along with this program. If not, see . . On Debian systems, the complete text of the GNU General Public License version 3 can be found in "/usr/share/common-licenses/GPL-3". ubuntu-advantage-tools-19.7ubuntu0.1/debian/manpages0000664000000000000000000000002314601326072017370 0ustar ubuntu-advantage.1 ubuntu-advantage-tools-19.7ubuntu0.1/debian/ubuntu-advantage-tools.maintscript0000664000000000000000000000043714601326072024552 0ustar rm_conffile /etc/update-motd.d/99-esm 19.1~ ubuntu-advantage-tools rm_conffile /etc/update-motd.d/80-esm 19.1~ ubuntu-advantage-tools rm_conffile /etc/update-motd.d/80-livepatch 19.1~ ubuntu-advantage-tools rm_conffile /etc/cron.daily/ubuntu-advantage-tools 19.1~ ubuntu-advantage-tools ubuntu-advantage-tools-19.7ubuntu0.1/debian/changelog0000664000000000000000000002776315223224517017552 0ustar ubuntu-advantage-tools (19.7ubuntu0.1) trusty-security; urgency=medium * SECURITY UPDATE: Information disclosure - Remove credentials out of argv into apt's own auth.conf.d facility. - CVE-2026-9494 * SECURITY UPDATE: Improper input validation - enforce StrictStringDataValue to applicable fields in directives - reject newline, carriage return, spaces, and shell meta characters in StrictStringDataValue - CVE-2026-11386 -- Eduardo Barretto Tue, 07 Jul 2026 18:28:29 +0200 ubuntu-advantage-tools (19.7) trusty; urgency=medium * Add support to new esm-infra-legacy service (LP: #2060566) -- Lucas Moura Thu, 28 Mar 2024 14:43:22 -0300 ubuntu-advantage-tools (19.6~ubuntu14.04.4) trusty; urgency=medium * d/postinst: only configure ESM on supported architectures (LP: #1851858) -- Andreas Hasenack Thu, 14 Nov 2019 14:34:50 -0300 ubuntu-advantage-tools (19.6~ubuntu14.04.3) trusty; urgency=medium * d/postinst: rename existing ubuntu-esm-precise.list file to trusty. This fixes the upgrade path from precise to trusty and to this client while esm is enabled (LP: #1850672) -- Andreas Hasenack Wed, 30 Oct 2019 10:01:58 -0300 ubuntu-advantage-tools (19.6~ubuntu14.04.2) trusty; urgency=medium * d/control, d/rules: don't run flake8 tests since python3-flake8 is in universe (LP: #1849851) -- Andreas Hasenack Fri, 25 Oct 2019 11:37:31 -0300 ubuntu-advantage-tools (19.6~ubuntu14.04.1) trusty; urgency=medium * New upstream release (LP: #1832757). Main changes: - drop SSO interactive login support - d/control: no longer depend on pymacaroons, which was only needed for the SSO interactive login support - drop keyrings for services not supported in trusty: cc-eal, fips, fips-updates, cis audit - make sure /var/lib/ubuntu-advantage/private has 0700 perms - rename esm to esm-infra. Also handle upgrades - don't unecessarily remove config files that are already handled by dpkg - expand the apt related runtime dependencies - handle sources.list.d esm snippet when release upgrading from precise - ua status now reports availability of services even in unattached state - the "ua status" output was changed, including the json format option - drop "ua status" call in postinst as it now requires internet access and that is restricted in LP builders and test runners. - fix the d/t/usage DEP8 test that was also using status -- Andreas Hasenack Wed, 23 Oct 2019 13:12:08 -0300 ubuntu-advantage-tools (19.5.1) eoan; urgency=medium * d/t/usage: fix dep8 test ("entitlements" was renamed to "services") -- Andreas Hasenack Wed, 03 Jul 2019 21:55:25 -0300 ubuntu-advantage-tools (19.5) eoan; urgency=medium * New upstream release (LP: #1832757): - packaging: + d/control: depend on libapt-pkg to use pin-priority never + d/postinst: adjust logfile permissions + d/postinst: remove public files and generate status cache on upgrade + d/postinst: Remove the old CACHE_DIR in postinst + d/postrm: remove log files on package purge + d/postrm: remove the ESM pinning file on purge + trusty should remove v1 esm key if present after upgrade + keyrings: regenerate keyrings on a trusty host + refresh keyrings to match current production for fips and cc-eal - apt: + all repo entitlements now call apt-get update on enable + enable -updates if -updates from the Ubuntu archive is enabled + Add basic i18n (good enough for lang packs) + retry apt install and update commands 3 times simple backoff + write commented -updates lines instead of omitting them - attach/detach: + added --no-auto-enable option + suppress messages from inapplicable default entitlements + two-factor auth reprompt only two-factor auth on failed 2fa + honour enableByDefault obligations from contract server + livepatch: no auto-enable on attach for trusty + don't attempt to disable inapplicable entitlements during detach + check for root before checking for attach in assert_attached_root - status: + add --json cli formatting option + emit a SERVICE header in status output + redact technical support and expiry for free contracts + unentitled services will report n/a - cc-eal: + add a warning about download size before install + change cc to cc-eal in docs, parameters and commandline help - esm: + add esm-v2 gpg keyring, drop old keyring, ignore aptKey directive + and livepatch auto enabled on attach where supported + on upgrade do not install preferences to pin never if esm enabled + remove only the apt auth entry on disable, leaving sources.list + use Pin-Priority never apt preference file to disable esm initially - fips: + display as pending when linux-fips is not the running kernel + only install/upgrade optional packages that are already on the system - logs: + no longer redact secrets as logfile is root read-only + separate console log devel from logfile level + remove level from messages to the console - add subcommand to refresh all contract details - config: allow contract_url and sso_auth_url to have a trailing slash - docker: fix persisting generated uuid on images without machine-id files - environ: allow lowercase ua_ overrides - repo: un-comment ESM sources.list lines on repo disable - updated manpage and help docs -- Andreas Hasenack Wed, 03 Jul 2019 15:55:11 -0300 ubuntu-advantage-tools (19.4.1) eoan; urgency=medium * apt-hook: Add missing headers for APT 1.9 -- Andreas Hasenack Tue, 18 Jun 2019 18:21:19 -0300 ubuntu-advantage-tools (19.4) disco; urgency=medium * Drop the self-test assert in the apt-hook, it's making the subiquity server install fail (LP: #1824523) -- Andreas Hasenack Fri, 12 Apr 2019 17:59:16 -0300 ubuntu-advantage-tools (19.3) disco; urgency=medium * apt-hook: Do not crash/fail if we can't read /proc/self/status (LP: #1824523) -- Andreas Hasenack Fri, 12 Apr 2019 09:25:15 -0300 ubuntu-advantage-tools (19.2) disco; urgency=medium * Ubuntu Advantage Tools rewrite in Python (LP: #1814157): - Allow attaching a system to a contract or account - More complete status output, dropping MOTD updates - Easily enable and disable services offered -- Andreas Hasenack Mon, 08 Apr 2019 16:55:12 -0300 ubuntu-advantage-tools (18) bionic; urgency=medium * Have ua status cope with the additional livepatch of running a kernel that is not supported for livepatches. * Have an option for enable-livepatch to install a compatible kernel if needed. [ Vineetha Kamath ] * Add support to common criteria EAL2 artifacts installation #144 -- Andreas Hasenack Thu, 21 Jun 2018 14:30:57 -0300 ubuntu-advantage-tools (17) bionic; urgency=medium * New upstream release - added enable-fips-updates command. This command enables the fips-updates repository to install updates to FIPS modules. The updated modules from fips-updates repository are non-certified. -- Andreas Hasenack Wed, 21 Mar 2018 14:20:04 -0300 ubuntu-advantage-tools (16) bionic; urgency=medium * d/t/update-motd-run: fix path to the esm motd (LP: #1757490) -- Andreas Hasenack Wed, 21 Mar 2018 13:58:34 -0300 ubuntu-advantage-tools (15) bionic; urgency=medium * Rename motd scripts so they are shown a bit earlier (LP: #1757171) * Move empty line placement in the livepatch motd to the beginning of the message to avoid double blank lines. -- Andreas Hasenack Tue, 20 Mar 2018 16:44:31 -0300 ubuntu-advantage-tools (14) bionic; urgency=medium * New upstream release: - repositories are only added after credentials are verified (LP: #1730361) - Livepatch MOTD script (LP: #1710976) - better "status" command output formatting (LP: #1719034) - sources.list.d files no longer contain credentials. The "auth.conf" facility is used instead. (LP: #1700611) - enabled Livepatch support for Bionic 18.04 LTS -- Andreas Hasenack Tue, 06 Feb 2018 09:58:03 -0200 ubuntu-advantage-tools (13) bionic; urgency=medium * New upstream release: - run tests during package build -- Andreas Hasenack Mon, 30 Oct 2017 16:37:30 -0200 ubuntu-advantage-tools (12) artful; urgency=medium * New upstream release: - revert the latest name changes - instead of "advantage", add a "ua" symlink pointing at the ubuntu-advantage script. Likewise for its manpage. (LP: #1721272) -- Andreas Hasenack Fri, 13 Oct 2017 14:22:20 -0300 ubuntu-advantage-tools (11) artful; urgency=medium * New upstream release: - rename the ubuntu-advantage script to advantage, including where it's mentioned in the documentation. Also provide symlinks pointing at the previous name. (LP: #1721272) - slightly reword some of the FIPS messages -- Andreas Hasenack Wed, 04 Oct 2017 14:31:33 -0300 ubuntu-advantage-tools (10) artful; urgency=medium * New upstream release with FIPS support (LP: #1718291) -- Andreas Hasenack Tue, 19 Sep 2017 18:33:03 -0300 ubuntu-advantage-tools (9) artful; urgency=medium * New upstream release: - call apt-get with the non-interactive frontend variable set, and tell dpkg to keep the old config file by default should there be any prompts about that. (LP: #1715012) - split the one big test file into multiple smaller files, for better maintainability. -- Andreas Hasenack Mon, 04 Sep 2017 15:15:58 -0300 ubuntu-advantage-tools (8) artful; urgency=medium * Release to artful (LP: #1711369) -- Andreas Hasenack Tue, 22 Aug 2017 12:55:13 -0300 ubuntu-advantage-tools (7) trusty; urgency=medium * d/control: update package description -- Andreas Hasenack Thu, 17 Aug 2017 11:31:37 -0300 ubuntu-advantage-tools (6) trusty; urgency=medium * New release version 6. Main changes: - document return codes on the manpage (Fixes: #33) - new status command (Fixes: #40) - restrict esm to precise only (Fixes: #43) - drop the livepatch motd update, only esm has motd output now (Fixes: #44) - skip tests during package building (Fixes #49) -- Andreas Hasenack Wed, 16 Aug 2017 15:21:16 -0300 ubuntu-advantage-tools (5) trusty; urgency=medium * Only display apt output in the case of errors (Fixes #34). -- Andreas Hasenack Tue, 08 Aug 2017 12:41:25 -0300 ubuntu-advantage-tools (4) trusty; urgency=medium * Check running kernel version before enabling the Livepatch service (Fixes #30). -- Andreas Hasenack Mon, 07 Aug 2017 18:45:23 -0300 ubuntu-advantage-tools (3) trusty; urgency=medium * Add livepatch support: - New commands: + enable-livepatch + disable-livepatch + is-livepatch-enabled - new tests - new manpage - new help output - new README.md - new MOTD -- Andreas Hasenack Mon, 07 Aug 2017 11:09:31 -0300 ubuntu-advantage-tools (2) trusty; urgency=medium * ubuntu-advantage & /etc/update-motd.d/99-esm now build, run and are quiet on non-precise release. (LP: #1686183) * Add simple dep8 tests. * Also install ca-certificates (LP: #1690270) -- David Britton Fri, 30 Jun 2017 15:20:00 -0600 ubuntu-advantage-tools (1) precise; urgency=medium * Initial Release. LP: #1686183 -- Dimitri John Ledkov Fri, 28 Apr 2017 15:04:47 +0100 ubuntu-advantage-tools-19.7ubuntu0.1/debian/compat0000664000000000000000000000000214072115230017047 0ustar 9 ubuntu-advantage-tools-19.7ubuntu0.1/debian/prerm0000664000000000000000000000033614601326072016731 0ustar #!/bin/sh set -e remove_apt_files() { python3 -c ' from uaclient.apt import clean_apt_sources clean_apt_sources() ' } case "$1" in purge|remove) remove_apt_files ;; esac #DEBHELPER# exit 0 ubuntu-advantage-tools-19.7ubuntu0.1/debian/ubuntu-advantage-tools.bash-completion0000664000000000000000000000002114601326072025266 0ustar tools/ua.bash ua ubuntu-advantage-tools-19.7ubuntu0.1/debian/ubuntu-advantage-tools.logrotate0000664000000000000000000000016714601326072024215 0ustar /var/log/ubuntu-advantage.log { rotate 6 monthly compress delaycompress missingok notifempty } ubuntu-advantage-tools-19.7ubuntu0.1/debian/tests/0000775000000000000000000000000014072115230017013 5ustar ubuntu-advantage-tools-19.7ubuntu0.1/debian/tests/control0000664000000000000000000000005014072115230020411 0ustar Tests: usage Restrictions: allow-stderr ubuntu-advantage-tools-19.7ubuntu0.1/debian/tests/usage0000664000000000000000000000010114072115230020032 0ustar #/bin/sh set -ex ua --help | grep --silent services ua version ubuntu-advantage-tools-19.7ubuntu0.1/debian/source/0000775000000000000000000000000014601326072017157 5ustar ubuntu-advantage-tools-19.7ubuntu0.1/debian/source/format0000664000000000000000000000001514072115230020360 0ustar 3.0 (native) ubuntu-advantage-tools-19.7ubuntu0.1/LICENSE.txt0000664000000000000000000010451314072115230016256 0ustar GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ubuntu-advantage-tools-19.7ubuntu0.1/uaclient.conf0000664000000000000000000000026414601326072017112 0ustar # Ubuntu-Advantage client config file. contract_url: 'https://contracts.canonical.com' data_dir: /var/lib/ubuntu-advantage log_level: debug log_file: /var/log/ubuntu-advantage.log ubuntu-advantage-tools-19.7ubuntu0.1/.travis.yml0000664000000000000000000000242614601326072016552 0ustar language: python dist: bionic install: # Required so `git describe` will definitely find a tag; see # https://github.com/travis-ci/travis-ci/issues/7422 - git fetch --unshallow - make testdeps script: - make test matrix: fast_finish: true include: - env: PACKAGE_BUILD_SERIES=trusty install: - make travis-deb-install script: - make travis-deb-script - env: PACKAGE_BUILD_SERIES=xenial install: - make travis-deb-install script: - make travis-deb-script - env: PACKAGE_BUILD_SERIES=bionic install: - make travis-deb-install script: - make travis-deb-script - env: PACKAGE_BUILD_SERIES=disco install: - make travis-deb-install script: - make travis-deb-script - python: 3.4 env: TOXENV=py3-trusty,flake8-trusty dist: trusty - python: 3.5 env: TOXENV=py3-xenial,flake8-xenial dist: xenial - python: 3.6 env: TOXENV=py3-bionic,flake8-bionic - python: 3.7 env: TOXENV=py3-disco,flake8-disco - python: 3.7 env: TOXENV=py3-eoan,flake8-eoan - python: 3.8 env: TOXENV=py3,flake8 - python: 3.7 env: TOXENV=mypy - python: 3.7 env: TOXENV=black ubuntu-advantage-tools-19.7ubuntu0.1/tools/0000775000000000000000000000000014601326072015575 5ustar ubuntu-advantage-tools-19.7ubuntu0.1/tools/constraints-bionic.txt0000664000000000000000000000012714601326072022146 0ustar attrs==17.4 flake8==3.5.0 py==1.5.2 pycodestyle==2.3.1 pytest==3.3.2 pytest-cov==2.5.1 ubuntu-advantage-tools-19.7ubuntu0.1/tools/constraints-disco.txt0000664000000000000000000000013014601326072021776 0ustar attrs==18.2 flake8==3.6.0 py==1.7.0 pycodestyle==2.4.0 pytest==3.10.1 pytest-cov==2.6.0 ubuntu-advantage-tools-19.7ubuntu0.1/tools/multipass.md0000664000000000000000000000356714072115230020145 0ustar # Using multipass For Development This is a document outlining how one developer has configured their system to enable the easy use of multipass for UA client development. It is intended to be used as a reference point for other developers to build their own workflows, rather than as a single, mandated workflow. ## Create cloud-config for instance configuration This is the cloud-config that I use (stored in `~/rc/multipass-cloud-config`): ```yaml #cloud-config apt: http_proxy: http://10.76.88.1:3142 https_proxy: "DIRECT" apt_http_proxy: http://10.76.88.1:3142 # support trusty apt_https_proxy: "DIRECT" packages: - devscripts - equivs - git - libpython3-dev - libffi-dev - sshfs # for `multipass mount`; save time by installing it ourselves - virtualenvwrapper runcmd: # The expectation is that we will mount our local development repo in to the # VM, but to install the build-deps at launch time we clone the public repo # temporarily - 'git clone https://github.com/CanonicalLtd/ubuntu-advantage-client /var/tmp/uac' - 'make -f /var/tmp/uac/Makefile deps' - 'rm -rf /var/tmp/uac' - 'echo alias pytest=py.test-3 >> /home/multipass/.bashrc' ssh_import_id: - daniel-thewatkins ``` ## Create an alias to launch multipass instances Adding these lines to my `.aliases` file (which is source'd by my `.zshrc`) means I can launch a multipass VM ready for development with a single command: ```sh alias mpl="multipass launch --cloud-init ~/rc/multipass-cloud-config" uamultipass() { SERIES="$1" if [ -z "$SERIES" ]; then echo "needs argument" return 1 fi name="$SERIES-$(date +%y%m%d-%H%M)" mpl -n "$name" $SERIES while ! multipass exec "$name" -- test -e /run/cloud-init/result.json; do sleep 5 done multipass mount /home/daniel/dev/ubuntu-advantage-client $name:/home/multipass/ubuntu-advantage-client } ``` ubuntu-advantage-tools-19.7ubuntu0.1/tools/install-staging-keyrings.sh0000775000000000000000000000262714072115230023066 0ustar #!/bin/sh -eu # Pull down the keys used in staging, and install them in the current machine # # The intended use here is to push this script on to a system and then run it # in situ. As this should never be used in production, to simplify # implementation this script puts all relevant keys in to a single keyring and # install that keyring multiple times. # # !! WARNING !! # This will install insecure keys in to the machine on which you run it, # overwriting known-good keys. DO NOT RUN IT ON YOUR LAPTOP! KEY_IDS="B220D065" TARGET_PATHS="ubuntu-cc-keyring.gpg ubuntu-esm-v2-keyring.gpg ubuntu-fips-keyring.gpg ubuntu-fips-updates-keyring.gpg ubuntu-securitybenchmarks-keyring.gpg" # Create a temporary directory for keyring generation TMPDIR="$(mktemp -d)" echo "Working in $TMPDIR..." cleanup () { echo "Cleaning up $TMPDIR..." rm -rf "$TMPDIR" echo "Removed $TMPDIR." } trap cleanup EXIT KEYRING_FILE="$TMPDIR/keyring.gpg" for KEY_ID in $KEY_IDS; do gpg \ --homedir "$TMPDIR" \ --keyring "$KEYRING_FILE" \ --no-default-keyring \ --keyserver keyserver.ubuntu.com \ --recv-keys "$KEY_ID" done for TARGET_PATH in $TARGET_PATHS; do FULL_TARGET_PATH="/usr/share/keyrings/$TARGET_PATH" if [ -w "$FULL_TARGET_PATH" ]; then cp "$KEYRING_FILE" "$FULL_TARGET_PATH" else echo "!!! Not copying to unwriteable path: $FULL_TARGET_PATH" fi done ubuntu-advantage-tools-19.7ubuntu0.1/tools/ua.bash0000664000000000000000000000175114601326072017045 0ustar # bash completion for ubuntu-advantage-tools SERVICES="cc-eal cis-audit esm-infra esm-infra-legacy fips fips-updates livepatch" _ua_complete() { local cur_word prev_word cur_word="${COMP_WORDS[COMP_CWORD]}" prev_word="${COMP_WORDS[COMP_CWORD-1]}" subcmds=$(ua --help | awk '/^\s*$|Available|Use/ {next;} /Flags:/{flag=1;next}/Use ubuntu-avantage/{flag=0}flag{ if ( $1 ~ /,/ ) { print $2} else print $1}') base_params="" case ${COMP_CWORD} in 1) COMPREPLY=($(compgen -W "$base_params $subcmds" -- $cur_word)) ;; 2) case ${prev_word} in disable) COMPREPLY=($(compgen -W "$SERVICES" -- $cur_word)) ;; enable) COMPREPLY=($(compgen -W "$SERVICES" -- $cur_word)) ;; esac ;; *) COMPREPLY=() ;; esac } complete -F _ua_complete ua # vi: syntax=sh expandtab ubuntu-advantage-tools-19.7ubuntu0.1/tools/constraints-trusty.txt0000664000000000000000000000044014601326072022253 0ustar flake8==2.1.0 pep8==1.4.6 py==1.4.20 pytest==2.5.1 # pytest-cov isn't available in trusty; the package build tests don't require # it, but including it here allows us to keep a consistent test command in # tox.ini; we need the version constraint to work with pytest 2.5.1 pytest-cov<1.7 ubuntu-advantage-tools-19.7ubuntu0.1/tools/constraints-xenial.txt0000664000000000000000000000012114601326072022155 0ustar attrs==15.2 flake8==2.5.4 pep8==1.7.0 py==1.4.31 pytest==2.8.7 pytest-cov==2.2.1 ubuntu-advantage-tools-19.7ubuntu0.1/tools/constraints-eoan.txt0000664000000000000000000000013014601326072021617 0ustar attrs==18.2 flake8==3.7.8 py==1.8.0 pycodestyle==2.5.0 pytest==3.10.1 pytest-cov==2.7.1 ubuntu-advantage-tools-19.7ubuntu0.1/tools/README.md0000664000000000000000000000051714601326072017057 0ustar # Tools The 'tools' directory is reserved for test tools and scripts. Nothing in this directory will be shipped as part of the packaging ## Files - constraints-bionic: Bionic versions of packages used by Tox - constraints-trusty: Trusty versions of packages used by Tox - constraints-xenial: Xenial versions of packages used by Tox ubuntu-advantage-tools-19.7ubuntu0.1/tools/refresh-keyrings.sh0000775000000000000000000000457714601326072021440 0ustar #!/bin/sh -eux # Refresh the keyring files that we store in the repo # # Takes a single argument, the directory in which the keyrings should be # (re-)created # # This statically captures the keys we expect to put in our keyrings, so that # we have an easy-to-read way of tracking what we currently have in the repo # # N.B. This will rename any existing keyrings with the suffix .old. tmp_dir=$(mktemp -d -t ci-XXXXXXXXXX) if [ $# != 1 -o ! -d $1 ]; then echo "Usage: $0 " exit 1 fi if [ $(lsb_release -sc) != "trusty" ]; then echo "ERROR: must run on trusty to ensure compatibility" exit 1 fi TARGET_DIR="$1" EAL_KEY_ID_XENIAL="9F912DADD99EE1CC6BFFFF243A186E733F491C46" ESM_KEY_ID_PRECISE="74AE092F7629ACDF4FB17310B4C2AF7A67C7A026" ESM_KEY_ID_TRUSTY="56F7650A24C9E9ECF87C4D8D4067E40313CB4B13" ESM_KEY_ID_XENIAL="3CB3DF682220A643B43065E9B30EDAA63D8F61D0" ESM_KEY_ID_BIONIC="2926E7D347A1955504000A983121D2531EF59819" # fips and fips-updates are same key ID FIPS_KEY_ID_XENIAL="E23341B2A1467EDBF07057D6C1997C40EDE22758" generate_keyrings() { KEYRING_DIR="$1" shift KEYS="$@" # Intentionally unquoted so GPG gets the keys as separate arguments for key in $KEYS; do case $key in $EAL_KEY_ID_XENIAL) service_name="cc-eal-xenial";; $ESM_KEY_ID_PRECISE) service_name="esm-infra-precise";; $ESM_KEY_ID_TRUSTY) service_name="esm-infra-trusty";; $ESM_KEY_ID_XENIAL) service_name="esm-infra-xenial";; $ESM_KEY_ID_BIONIC) service_name="esm-infra-bionic";; $FIPS_KEY_ID_XENIAL) service_name="fips-xenial";; *) echo "Unhandled key id provided: " $key exit 1; esac keyring_file="$KEYRING_DIR/ubuntu-advantage-$service_name.gpg" if [ -e "$keyring_file" ]; then mv "$keyring_file" "$keyring_file.old" fi gpg \ --keyring "$keyring_file" \ --no-default-keyring \ --homedir $tmp_dir \ --no-options \ --keyserver keyserver.ubuntu.com \ --recv-keys $key done rm $KEYRING_DIR/*gpg~ } generate_keyrings $TARGET_DIR $EAL_KEY_ID_XENIAL $ESM_KEY_ID_PRECISE $ESM_KEY_ID_TRUSTY $ESM_KEY_ID_XENIAL $ESM_KEY_ID_BIONIC $FIPS_KEY_ID_XENIAL rm -rf $tmp_dir ubuntu-advantage-tools-19.7ubuntu0.1/Makefile0000664000000000000000000000336314601326072016102 0ustar mkfile_path := $(abspath $(lastword $(MAKEFILE_LIST))) mkfile_dir := $(dir $(mkfile_path)) build: $(MAKE) -C apt-hook build clean: rm -f *.build *.buildinfo *.changes .coverage *.deb *.dsc *.tar.gz *.tar.xz rm -rf *.egg-info/ .tox/ .cache/ .mypy_cache/ find . -type f -name '*.pyc' -delete find . -type d -name '*__pycache__' -delete $(MAKE) -C apt-hook clean demo: @echo Creating contract-bionic-demo container with ua-contracts server @./demo/demo-contract-service deps: @which mk-build-deps > /dev/null || { \ echo "Missing mk-build-deps; installing devscripts, equivs."; \ apt-get install --no-install-recommends --yes devscripts equivs; \ } mk-build-deps --tool "apt-get --no-install-recommends --yes" \ --install --remove ${mkfile_dir}/debian/control test: @tox testdeps: pip install tox travis-deb-install: git fetch --unshallow sudo apt-get update sudo apt-get build-dep -y ubuntu-advantage-tools sudo apt-get install -y --install-recommends sbuild ubuntu-dev-tools # Missing build-deps sudo apt-get install -y --install-recommends libapt-pkg-dev python3-mock python3-pytest # Use the mirror for a GCE region, to speed things up. (Travis build VMs use # DataSourceNone so we can't dynamically determine the correct region.) travis-deb-script: export DEBOOTSTRAP_MIRROR=http://us-central1.gce.archive.ubuntu.com/ubuntu/ travis-deb-script: debuild -S -uc -us sudo sbuild-adduser ${USER} cp /usr/share/doc/sbuild/examples/example.sbuildrc /home/${USER}/.sbuildrc # Use this to get a new shell where we're in the sbuild group sudo -E su ${USER} -c 'mk-sbuild ${PACKAGE_BUILD_SERIES}' sudo -E su ${USER} -c 'sbuild --nolog --verbose --dist=${PACKAGE_BUILD_SERIES} ../ubuntu-advantage-tools*.dsc' .PHONY: build clean test testdeps demo ubuntu-advantage-tools-19.7ubuntu0.1/apt.conf.d/0000775000000000000000000000000014601326072016367 5ustar ubuntu-advantage-tools-19.7ubuntu0.1/apt.conf.d/51ubuntu-advantage-esm0000664000000000000000000000014014601326072022507 0ustar Unattended-Upgrade::Allowed-Origins { "${distro_id}ESM:${distro_codename}-infra-security"; }; ubuntu-advantage-tools-19.7ubuntu0.1/keyrings/0000775000000000000000000000000014601326072016270 5ustar ubuntu-advantage-tools-19.7ubuntu0.1/keyrings/ubuntu-advantage-esm-infra-legacy-trusty.gpg0000664000000000000000000000433014601326072026732 0ustar  \}gBi$h-\VMytvf*دzX O.It=siUڜmEs7Ee߱"qlz_gЪ89lǞA b:^xU[-N SZ7>_adkfn߼w/3/em^bz|jniq\ua~үr+=i(뛕7 L"hl!eFsגK2 1 d+H9;#" Cw,˓D;C ^xL iw,LF$.Pc̈I}ǰpajDOHP#VK}bNGޅSͅw{/VHjbN/76Vޅ-#QۢFf$)z|+G%l㑏G_BX{)M9; jNݵ^rC*_z8" 5=J1H~#`GXJ< =DzAb6DR%?-hd`tY90nMQUbuntu Extended Security Maintenance Automatic Signing Key v2 8"\}   @gKB6$g|S0 Rcmz }n7M:S6g/@|[A67 k!$G߅uv#qG\Z\ANҜ4ߐ ~@9&RI_fŠ&CVū?i,{ԑ=VlkkML/c]O3wi<甍1\*0ug0(B̅Ru}HˋC&q9f5Y0`h dIżˆ5i7KDPgI.|~ĎirB l]³&^#KQA#R*3kb~<=Oh!w?rF#=,PuF!uS*?6\ 0`YgWp%IU\ᓶ5\愛QlTI?'\ \}ƿZnvYpتEV` JyC|vm\a9u_l'FNgW= bc +Ozw T?iEbPf=X$+TZIsxe?(^;b=qZ^ ]iX'!33>z>-/;5V( R皵^O~>Y77* ujiF1ȹts؞lTSӛ|0gChhfHKEAx*rW8ѳno-cűe[3?ˢ8hAev"wbhT̗a,SEHPw݉07A$W_ u ]/ǧBɒ BtWk pƬ"qVds 2vpz)`L& yH*z?)s\x+ꎴ* W*haUq~,Smk8TqחVS \} @gKh\uø~Oub{Ȥj=aE"o1TKŹsjyfl6.Ҏx;UL^FujfM__iJmIPtfnr^HwAPpak%UM V45 * y?݅;ˣ½*JSXS+I)8:S0xy^j˰ϗ.-5÷NXUy uMb mH\ v`ozR;r:h*T`}yZ_adkfn߼w/3/em^bz|jniq\ua~үr+=i(뛕7 L"hl!eFsגK2 1 d+H9;#" Cw,˓D;C ^xL iw,LF$.Pc̈I}ǰpajDOHP#VK}bNGޅSͅw{/VHjbN/76Vޅ-#QۢFf$)z|+G%l㑏G_BX{)M9; jNݵ^rC*_z8" 5=J1H~#`GXJ< =DzAb6DR%?-hd`tY90nMQUbuntu Extended Security Maintenance Automatic Signing Key v2 8"\}   @gKB6$g|S0 Rcmz }n7M:S6g/@|[A67 k!$G߅uv#qG\Z\ANҜ4ߐ ~@9&RI_fŠ&CVū?i,{ԑ=VlkkML/c]O3wi<甍1\*0ug0(B̅Ru}HˋC&q9f5Y0`h dIżˆ5i7KDPgI.|~ĎirB l]³&^#KQA#R*3kb~<=Oh!w?rF#=,PuF!uS*?6\ 0`YgWp%IU\ᓶ5\愛QlTI?'\ \}ƿZnvYpتEV` JyC|vm\a9u_l'FNgW= bc +Ozw T?iEbPf=X$+TZIsxe?(^;b=qZ^ ]iX'!33>z>-/;5V( R皵^O~>Y77* ujiF1ȹts؞lTSӛ|0gChhfHKEAx*rW8ѳno-cűe[3?ˢ8hAev"wbhT̗a,SEHPw݉07A$W_ u ]/ǧBɒ BtWk pƬ"qVds 2vpz)`L& yH*z?)s\x+ꎴ* W*haUq~,Smk8TqחVS \} @gKh\uø~Oub{Ȥj=aE"o1TKŹsjyfl6.Ҏx;UL^FujfM__iJmIPtfnr^HwAPpak%UM V45 * y?݅;ˣ½*JSXS+I)8:S0xy^j˰ϗ.-5÷NXUy uMb mH\ v`ozR;r:h*T`}yZ * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, version 3 of the License. * * This package is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . * */ #include #include #include #include #include #include #include #include #include #include #include #include #include struct result { int enabled_esms; int disabled_esms; }; // Return parent pid of specified pid, using /proc (pid might be self) static std::string getppid_of(std::string pid) { std::string status_path; std::string line; if (pid == "") return ""; strprintf(status_path, "/proc/%s/status", pid.c_str()); std::ifstream stream(status_path.c_str(), std::ios::in); while (not stream.fail()) { getline(stream, line); if (line.find("PPid:") != 0) continue; // Erase everything before a number line.erase(0, line.find_first_of("0123456789")); return line; } return ""; } // Get cmdline of specified pid. Arguments terminated with 0 static std::string getcmdline(std::string pid) { std::string cmdline_path; if (pid == "") return ""; strprintf(cmdline_path, "/proc/%s/cmdline", pid.c_str()); std::ifstream stream(cmdline_path.c_str(), std::ios::in); std::ostringstream cmdline; char buf[4096]; do { stream.read(buf, sizeof(buf)); cmdline.write(buf, stream.gcount()); } while (stream.gcount() > 0); return cmdline.str(); } // Check if a cmdline is eligible for showing ESM updates. Only apt // update and the various upgrade commands are static std::string command_used; static bool cmdline_eligible(std::string const &cmdline) { const std::string commands[] = {"update", "upgrade", "dist-upgrade", "full-upgrade", "safe-upgrade"}; for (size_t i = 0; i < sizeof(commands) / sizeof(commands[0]); i++) { if (cmdline.find('\0' + commands[i] + '\0') != std::string::npos) { command_used = commands[i]; return true; } } return false; } // Check if we have an ESM upgrade for the specified package static void check_esm_upgrade(pkgCache::PkgIterator pkg, pkgPolicy *policy, result &res) { pkgCache::VerIterator cur = pkg.CurrentVer(); if (cur.end()) return; // Search all versions >= cur (list in decreasing order) for (pkgCache::VerIterator ver = pkg.VersionList(); !ver.end() && ver->ID != cur->ID; ver++) { for (pkgCache::VerFileIterator pf = ver.FileList(); !pf.end(); pf++) { // TODO: Just look at the origin, not pinning. if (pf.File().Archive() != 0 && pf.File().Origin() == std::string("UbuntuESM")) { if (policy->GetPriority(pf.File()) == -32768) res.disabled_esms++; else res.enabled_esms++; return; } } } } // Calculate the update count static int get_update_count(result &res) { int count = 0; if (!pkgInitConfig(*_config)) return -1; if (!pkgInitSystem(*_config, _system)) return -1; pkgCacheFile cachefile; pkgCache *cache = cachefile.GetPkgCache(); pkgPolicy *policy = cachefile.GetPolicy(); if (cache == NULL || policy == NULL) return -1; for (pkgCache::PkgIterator pkg = cache->PkgBegin(); !pkg.end(); pkg++) { check_esm_upgrade(pkg, policy, res); } return count; } // Preserves \0 bytes in a string literal template std::string make_cmdline(const char (&s)[n]) { return std::string(s, n); } bool has_arg(char **argv, const char *arg) { for (; *argv; argv++) { if (strcmp(*argv, arg) == 0) return true; } return false; } int main(int argc, char *argv[]) { (void) argc; // unused setlocale(LC_ALL, ""); textdomain("ubuntu-advantage"); // Self testing // Dropped: see #1824523 // if (access("/proc/self/status", R_OK) == 0) { // std::string ppid; // strprintf(ppid, "%d", getppid()); // assert(ppid == getppid_of("self")); // } assert(cmdline_eligible(make_cmdline("apt\0update\0"))); assert(cmdline_eligible(make_cmdline("apt-get\0update\0"))); assert(!cmdline_eligible(make_cmdline("apt-get\0install\0"))); assert(!cmdline_eligible(make_cmdline("apt\0install\0"))); assert(!cmdline_eligible(make_cmdline("apt\0install\0"))); assert(cmdline_eligible(make_cmdline("aptitude\0upgrade\0"))); assert(cmdline_eligible(make_cmdline("aptitude\0update\0"))); command_used = ""; result res = {0, 0}; if (has_arg(argv, "test") || cmdline_eligible(getcmdline(getppid_of(getppid_of("self"))))) get_update_count(res); if (_error->PendingError()) { _error->DumpErrors(); return 1; } if (res.enabled_esms > 0 && (command_used == "update")) { ioprintf(std::cout, ngettext("%d of the updates is from UA Infrastructure ESM.", "%d of the updates are from UA Infrastructure ESM.", res.enabled_esms), res.enabled_esms); ioprintf(std::cout, "\n"); } if (res.disabled_esms > 0) { if (command_used != "update") std::cout << std::endl; ioprintf(std::cout, ngettext("%d additional update is available with UA Infrastructure ESM.", "%d additional updates are available with UA Infrastructure ESM.", res.disabled_esms), res.disabled_esms); ioprintf(std::cout, "\n"); ioprintf(std::cout, gettext("To see these additional updates run: apt list --upgradable")); ioprintf(std::cout, "\n"); ioprintf(std::cout, gettext("See https://ubuntu.com/advantage or run: sudo ua status")); ioprintf(std::cout, "\n"); } return 0; } ubuntu-advantage-tools-19.7ubuntu0.1/apt-hook/Makefile0000664000000000000000000000071114601326072017616 0ustar all: build build: hook ubuntu-advantage.pot ubuntu-advantage.pot: hook.cc xgettext hook.cc -o ubuntu-advantage.pot hook: hook.cc $(CXX) -Wall -Wextra -pedantic $(CXXFLAGS) $(CPPFLAGS) $(LDFLAGS) -g -o hook hook.cc -lapt-pkg $(LDLIBS) install: hook install -D -m 644 20apt-esm-hook.conf $(DESTDIR)/etc/apt/apt.conf.d/20apt-esm-hook.conf install -D -m 755 hook $(DESTDIR)/usr/lib/ubuntu-advantage/apt-esm-hook clean: rm -f hook ubuntu-advantage.pot ubuntu-advantage-tools-19.7ubuntu0.1/uaclient-devel.conf0000664000000000000000000000026414601326072020207 0ustar # Development UA Client config file. YAML contract_url: 'https://contracts.staging.canonical.com' data_dir: /var/tmp/uaclient log_level: debug log_file: ubuntu-advantage-devel.log ubuntu-advantage-tools-19.7ubuntu0.1/tox.ini0000664000000000000000000000250214601326072015747 0ustar [tox] envlist = py3, flake8, py3-{xenial,bionic,disco,eoan}, flake8-{trusty,xenial,bionic,disco,eoan}, mypy, black [testenv] deps = -rrequirements.txt -rtest-requirements.txt trusty: -ctools/constraints-trusty.txt xenial: -ctools/constraints-xenial.txt bionic: -ctools/constraints-bionic.txt disco: -ctools/constraints-disco.txt eoan: -ctools/constraints-eoan.txt mypy: mypy black: -rdev-requirements.txt commands = py3: py.test {posargs:--cov uaclient uaclient} flake8: flake8 uaclient setup.py features mypy: mypy --python-version 3.4 uaclient/ features/steps/ mypy: mypy --python-version 3.5 uaclient/ features/steps/ mypy: mypy --python-version 3.6 uaclient/ features/steps/ mypy: mypy --python-version 3.7 uaclient/ features/steps/ black: black --check --diff uaclient/ features/ behave: behave {posargs} [flake8] # E251: Older versions of flake8 et al don't permit the # now-recommended-by-PEP-8 parameter spacing for annotated function # arguments with defaults (e.g. `def spam(ham: str = "eggs"):`). # W503: Per https://black.readthedocs.io/en/stable/the_black_code_style.html, # W503 is not PEP 8 compliant and is therefore incompatible with black. ignore = E251, W503 [pytest] log_format = %(filename)-25s %(lineno)4d %(levelname)-8s %(message)s ubuntu-advantage-tools-19.7ubuntu0.1/features/0000775000000000000000000000000014601326072016253 5ustar ubuntu-advantage-tools-19.7ubuntu0.1/features/steps/0000775000000000000000000000000014601326072017411 5ustar ubuntu-advantage-tools-19.7ubuntu0.1/features/steps/steps.py0000664000000000000000000000455414601326072021131 0ustar import datetime import shlex import subprocess import time from typing import List from behave import given, then, when from behave.runner import Context from hamcrest import assert_that, equal_to CONTAINER_PREFIX = "behave-test-" def _lxc_exec(context: Context, cmd: List[str], *args, **kwargs): return subprocess.run( ["lxc", "exec", context.container_name, "--"] + cmd, *args, **kwargs ) def _wait_for_boot(context: Context) -> None: retries = [2] * 5 for sleep_time in retries: process = _lxc_exec( context, ["runlevel"], capture_output=True, text=True ) try: _, runlevel = process.stdout.strip().split(" ", 2) except ValueError: print("Unexpected runlevel output: ", process.stdout.strip()) runlevel = None if runlevel == "2": break time.sleep(sleep_time) else: raise Exception("System did not boot in {}s".format(sum(retries))) @given("a trusty lxd container") def given_a_trusty_lxd_container(context): now = datetime.datetime.now() context.container_name = CONTAINER_PREFIX + now.strftime("%s%f") subprocess.run(["lxc", "launch", "ubuntu:trusty", context.container_name]) def cleanup_container(): subprocess.run(["lxc", "stop", context.container_name]) subprocess.run(["lxc", "delete", context.container_name]) context.add_cleanup(cleanup_container) _wait_for_boot(context) @given("ubuntu-advantage-tools is installed") def given_uat_is_installed(context): _lxc_exec( context, [ "add-apt-repository", "--yes", "ppa:canonical-server/ua-client-daily", ], ) _lxc_exec(context, ["apt-get", "update", "-qq"]) _lxc_exec( context, ["apt-get", "install", "-qq", "-y", "ubuntu-advantage-tools"] ) @when("I run `{command}` as non-root") def when_i_run_command(context, command): process = _lxc_exec( context, shlex.split(command), capture_output=True, text=True ) context.process = process @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)) @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)) ubuntu-advantage-tools-19.7ubuntu0.1/features/unattached_status.feature0000664000000000000000000000147014601326072023355 0ustar Feature: Unattached status Scenario: Unattached status in a trusty lxd container Given a trusty lxd container And ubuntu-advantage-tools is installed When I run `ua status` as non-root Then I will see the following on stdout: """ SERVICE AVAILABLE DESCRIPTION cc-eal no Common Criteria EAL2 Provisioning Packages esm-infra yes UA Infra: Extended Security Maintenance fips no NIST-certified FIPS modules fips-updates no Uncertified security updates to FIPS modules livepatch yes Canonical Livepatch service This machine is not attached to a UA subscription. See https://ubuntu.com/advantage """ ubuntu-advantage-tools-19.7ubuntu0.1/features/unattached_commands.feature0000664000000000000000000000501414601326072023631 0ustar Feature: Command behaviour when unattached Scenario: Unattached detach in a trusty lxd container Given a trusty lxd container And ubuntu-advantage-tools is installed When I run `ua detach` as non-root Then I will see the following on stderr: """ This machine is not attached to a UA subscription. See https://ubuntu.com/advantage """ Scenario: Unattached refresh in a trusty lxd container Given a trusty lxd container And ubuntu-advantage-tools is installed When I run `ua refresh` as non-root Then I will see the following on stderr: """ This machine is not attached to a UA subscription. See https://ubuntu.com/advantage """ Scenario: Unattached enable of a known service in a trusty lxd container Given a trusty lxd container And ubuntu-advantage-tools is installed When I run `ua enable livepatch` as non-root Then I will see the following on stderr: """ To use 'livepatch' you need an Ubuntu Advantage subscription. Personal and community subscriptions are available at no charge See https://ubuntu.com/advantage """ Scenario: Unattached enable of an unknown service in a trusty lxd container Given a trusty lxd container And ubuntu-advantage-tools is installed When I run `ua enable foobar` as non-root Then I will see the following on stderr: """ Cannot enable 'foobar' For a list of services see: sudo ua status """ Scenario: Unattached disable of a known service in a trusty lxd container Given a trusty lxd container And ubuntu-advantage-tools is installed When I run `ua disable livepatch` as non-root Then I will see the following on stderr: """ To use 'livepatch' you need an Ubuntu Advantage subscription. Personal and community subscriptions are available at no charge See https://ubuntu.com/advantage """ Scenario: Unattached disable of an unknown service in a trusty lxd container Given a trusty lxd container And ubuntu-advantage-tools is installed When I run `ua disable foobar` as non-root Then I will see the following on stderr: """ Cannot disable 'foobar' For a list of services see: sudo ua status """ ubuntu-advantage-tools-19.7ubuntu0.1/pyproject.toml0000664000000000000000000000012014601326072017342 0ustar [tool.black] line-length = 79 target_version = ['py34', 'py35', 'py36', 'py37'] ubuntu-advantage-tools-19.7ubuntu0.1/mypy.ini0000664000000000000000000000043114072115230016124 0ustar [mypy] [mypy-uaclient.conftest] ignore_errors = True [mypy-*.tests.*] ignore_errors = True [mypy-uaclient.testing.*] ignore_errors = True [mypy-apt_pkg] ignore_missing_imports = True [mypy-behave.*] ignore_missing_imports = True [mypy-hamcrest] ignore_missing_imports = True ubuntu-advantage-tools-19.7ubuntu0.1/README.md0000664000000000000000000000727414601326072015726 0ustar # Ubuntu Advantage Client [![Build Status](https://travis-ci.org/CanonicalLtd/ubuntu-advantage-client.svg?branch=master)](https://travis-ci.org/CanonicalLtd/ubuntu-advantage-client) The Ubuntu Advantage client provides users with a simple mechanism to view, enable, and disable offerings from Canonical on their system. The following entitlements are supported: - [Common Criteria EAL2 certification artifacts provisioning](https://ubuntu.com/cc-eal) - [Canonical CIS Benchmark Audit Tool](https://ubuntu.com/cis) - [Ubuntu Extended Security Maintenance](https://ubuntu.com/esm) - [FIPS 140-2 Certified Modules](https://ubuntu.com/fips) - [FIPS 140-2 Non-Certified Module Updates](https://ubuntu.com/fips-updates) - [Livepatch Service](https://www.ubuntu.com/livepatch) ## Obtaining the Client The client comes pre-installed on all Ubuntu systems. Users can run the `ua` command to learn more or view the manpage. ## Testing All unit and lint tests are run using tox: ```shell tox ``` The client also includes built-in dep8 tests. These are run as follows: ```shell autopkgtest -U --shell-fail . -- lxd ubuntu:xenial ``` ### Integration Tests ubuntu-advantage-client uses [behave](https://behave.readthedocs.io) for its integration testing. The integration test definitions are stored in the `features/` directory and consist of two parts: `.feature` files that define the tests we want to run, and `.py` files which implement the underlying logic for those tests. To run the tests, you can use `tox`: ```shell tox -e behave ``` or, if you just want to run a specific file, or a test within a file: ```shell tox -e behave features/unattached_commands.feature tox -e behave features/unattached_commands.feature:55 ``` (If you're getting started with behave, we recommend at least reading through [the behave tutorial](https://behave.readthedocs.io/en/latest/tutorial.html) to get an idea of how it works, and how tests are written.) ## Building The packaging for the UA client package (ubuntu-advantage-tools) is in-tree, so you can build the package the way you would normally build a Debian package: ```shell dpkg-buildpackage ``` or, if you want to build for a target release other than the release you're on, [configure sbuild](https://wiki.ubuntu.com/SimpleSbuild) and use that for the build: ```shell debuild -S sbuild --dist= ../ubuntu-advantage-tools_*.dsc ``` ## Code Formatting The `ubuntu-advantage-client` code base is formatted using [black](https://github.com/psf/black). When making changes, you should ensure that your code is blackened, or it will be rejected by CI. Formatting the whole codebase is as simple as running: ```shell black uaclient/ ``` To make it easier to avoid committing incorrectly formatted code, this repo includes configuration for [pre-commit](https://pre-commit.com/) which will stop you from committing any code that isn't blackened. To install the project's pre-commit hook, install `pre-commit` and run: ```shell pre-commit install ``` (To install `black` and `pre-commit` at the appropriate versions for the project, you should install them via `dev-requirements.txt`.) ## 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). ## Demo Users can demo the client with a local backend. This can be done with the following: ```shell # Set up ua-contracts in a docker container in a bionic lxc on port 3000 make demo # Set up two clients pointing at the local contract server ./demo/run-uaclient --series disco ./demo/run-uaclient --series xenial -b multipass ``` ubuntu-advantage-tools-19.7ubuntu0.1/requirements.txt0000664000000000000000000000000714072115230017710 0ustar pyyaml ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/0000775000000000000000000000000015223224570016242 5ustar ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/exceptions.py0000664000000000000000000000141514601326072020775 0ustar from uaclient import status class UserFacingError(Exception): """ An exception to be raised when an execution-ending error is encountered. :param msg: Takes a single parameter, which is the user-facing error message that should be emitted before exiting non-zero. """ def __init__(self, msg: str) -> None: self.msg = msg 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) 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) ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/gpg.py0000664000000000000000000000145414601326072017374 0ustar import logging import os import shutil from uaclient import exceptions def export_gpg_key(source_keyfile: str, destination_keyfile: str) -> None: """Copy a specific key from source_keyring_dir into destination_keyfile :param source_keyfile: Path of source keyring file to export. :param destination_keyfile: The filename created with the single exported key. :raise UserFacingError: Any GPG errors or if specific key does not exist in the source_keyring_file. """ logging.debug("Exporting GPG key %s", source_keyfile) if not os.path.exists(source_keyfile): raise exceptions.UserFacingError( "GPG key '{}' not found".format(source_keyfile) ) shutil.copy(source_keyfile, destination_keyfile) os.chmod(destination_keyfile, 0o644) ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/contract.py0000664000000000000000000002616614601326072020443 0ustar from datetime import datetime import logging import urllib from uaclient import exceptions from uaclient import status from uaclient import serviceclient from uaclient import util try: from typing import Any, Dict, List, Optional # noqa: F401 except ImportError: # typing isn't available on trusty, so ignore its absence pass API_ERROR_INVALID_TOKEN = "invalid token" API_V1_CONTEXT_MACHINE_TOKEN = "/v1/context/machines/token" API_V1_TMPL_CONTEXT_MACHINE_TOKEN_REFRESH = ( "/v1/contracts/{contract}/context/machines/{machine}" ) API_V1_RESOURCES = "/v1/resources" API_V1_TMPL_RESOURCE_MACHINE_ACCESS = ( "/v1/resources/{resource}/context/machines/{machine}" ) 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) class UAContractClient(serviceclient.UAServiceClient): cfg_url_base_attr = "contract_url" api_error_cls = ContractAPIError def request_contract_machine_attach(self, contract_token, machine_id=None): """Requests machine attach to the provided contact_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, contents of /etc/machine-id will be used. @return: Dict of the JSON response containing the machine-token. """ if not machine_id: machine_id = util.get_machine_id(self.cfg.data_dir) os = util.get_platform_info() arch = os.pop("arch") headers = self.headers() headers.update({"Authorization": "Bearer {}".format(contract_token)}) data = {"machineId": machine_id, "architecture": arch, "os": os} machine_token, _headers = self.request_url( API_V1_CONTEXT_MACHINE_TOKEN, data=data, headers=headers ) self.cfg.write_cache("machine-token", machine_token) return machine_token def request_resources(self) -> "Dict[str, Any]": """Requests list of entitlements available to this machine type.""" platform = util.get_platform_info() query_params = { "architecture": platform["arch"], "series": platform["series"], "kernel": platform["kernel"], } resource_response, headers = self.request_url( API_V1_RESOURCES + "?" + urllib.parse.urlencode(query_params) ) return resource_response def request_resource_machine_access( self, machine_token: str, resource: str, machine_id: "Optional[str]" = None, ) -> "Dict[str, Any]": """Requests machine access context for a given resource @param machine_token: The authentication token needed to talk to this contract service endpoint. @param resource: Entitlement name. @param machine_id: Optional unique system machine id. When absent, contents of /etc/machine-id will be used. @return: Dict of the JSON response containing entitlement accessInfo. """ if not machine_id: machine_id = util.get_machine_id(self.cfg.data_dir) headers = self.headers() headers.update({"Authorization": "Bearer {}".format(machine_token)}) url = API_V1_TMPL_RESOURCE_MACHINE_ACCESS.format( resource=resource, machine=machine_id ) resource_access, headers = self.request_url(url, headers=headers) if headers.get("expires"): resource_access["expires"] = headers["expires"] self.cfg.write_cache( "machine-access-{}".format(resource), resource_access ) return resource_access def request_machine_token_refresh( self, machine_token, contract_id, machine_id=None ): """Request machine token refresh from contract server. @param machine_token: The machine token needed to talk to this contract service endpoint. @param contract_id: Unique contract id provided by contract service. @param machine_id: Optional unique system machine id. When absent, contents of /etc/machine-id will be used. @return: Dict of the JSON response containing refreshed machine-token """ if not machine_id: machine_id = util.get_machine_id(self.cfg.data_dir) headers = self.headers() headers.update({"Authorization": "Bearer {}".format(machine_token)}) url = API_V1_TMPL_CONTEXT_MACHINE_TOKEN_REFRESH.format( contract=contract_id, machine=machine_id ) response, headers = self.request_url(url, headers=headers) if headers.get("expires"): response["expires"] = headers["expires"] self.cfg.write_cache("machine-token", response) return response def process_entitlement_delta(orig_access, new_access, allow_enable=False): """Process a entitlement access dictionary deltas if they exist. :param orig_access: Dict with original entitlement access details before contract refresh deltas :param orig_access: Dict with updated entitlement access details after contract refresh :param allow_enable: Boolean set True if allowed to perform the enable operation. When False, a message will be logged to inform the user about the recommended enabled service. :raise UserFacingError: on failure to process deltas. :return: Dict of processed deltas """ from uaclient.entitlements import ENTITLEMENT_CLASS_BY_NAME util.apply_series_overrides(new_access) deltas = util.get_dict_deltas(orig_access, new_access) 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 ) ) try: ent_cls = ENTITLEMENT_CLASS_BY_NAME[name] except KeyError: logging.debug( 'Skipping entitlement deltas for "%s". No such class', name ) return deltas entitlement = ent_cls() entitlement.process_contract_deltas( orig_access, deltas, allow_enable=allow_enable ) return deltas def request_updated_contract( cfg, contract_token: "Optional[str]" = None, allow_enable=False ): """Request contract refresh from ua-contracts service. Compare original token to new token and react to entitlement deltas. :param cfg: Instance of UAConfig for this machine. :param contract_token: String contraining an optional contract token. :param allow_enable: Boolean set True if allowed to perform the enable operation. When False, a message will be logged to inform the user about the recommended enabled service. :raise UserFacingError: on failure to update contract or error processing contract deltas :raise UrlError: On failure to contact the server """ 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" ) contract_client = UAContractClient(cfg) if contract_token: # We are a mid ua-attach and need to get machinetoken try: new_token = contract_client.request_contract_machine_attach( contract_token=contract_token ) except util.UrlError as e: if isinstance(e, ContractAPIError): if API_ERROR_INVALID_TOKEN in e: raise exceptions.UserFacingError( status.MESSAGE_ATTACH_INVALID_TOKEN ) raise e with util.disable_log_to_console(): logging.exception(str(e)) raise exceptions.UserFacingError(status.MESSAGE_CONNECTIVITY_ERROR) else: machine_token = orig_token["machineToken"] contract_id = orig_token["machineTokenInfo"]["contractInfo"]["id"] new_token = contract_client.request_machine_token_refresh( machine_token=machine_token, contract_id=contract_id ) expiry = new_token["machineTokenInfo"]["contractInfo"].get("effectiveTo") if expiry: if datetime.strptime(expiry, "%Y-%m-%dT%H:%M:%SZ") < datetime.utcnow(): raise exceptions.UserFacingError( status.MESSAGE_CONTRACT_EXPIRED_ERROR ) user_errors = [] for name, entitlement in sorted(cfg.entitlements.items()): if entitlement["entitlement"].get("entitled"): # Obtain each entitlement's accessContext for this machine new_access = contract_client.request_resource_machine_access( new_token["machineToken"], name ) else: new_access = entitlement try: process_entitlement_delta( orig_entitlements.get(name, {}), new_access, allow_enable=allow_enable, ) except exceptions.UserFacingError as e: user_errors.append(e) except Exception as e: with util.disable_log_to_console(): logging.exception(str(e)) raise exceptions.UserFacingError( "Unexpected error handling Ubuntu Advantage contract changes" ) if user_errors: raise exceptions.UserFacingError( status.MESSAGE_ATTACH_FAILURE_DEFAULT_SERVICES ) def get_available_resources(cfg) -> "List[Dict]": """Query available resources from the contrct server for this machine.""" client = UAContractClient(cfg) resources = client.request_resources() return resources.get("resources", []) ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/__init__.py0000664000000000000000000000000014072115230020332 0ustar ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/entitlements/0000775000000000000000000000000014601326072020754 5ustar ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/entitlements/__init__.py0000664000000000000000000000166414601326072023074 0ustar from uaclient.entitlements.base import UAEntitlement # noqa: F401 from uaclient.entitlements.cis import CISEntitlement from uaclient.entitlements.cc import CommonCriteriaEntitlement from uaclient.entitlements.esm import ESMEntitlement, ESMLegacyEntitlement from uaclient.entitlements import fips from uaclient.entitlements.livepatch import LivepatchEntitlement try: from typing import cast, Dict, List, Type # noqa: F401 except ImportError: # typing isn't available on trusty, so ignore its absence def cast(_, x): # type: ignore return x ENTITLEMENT_CLASSES = [ CommonCriteriaEntitlement, CISEntitlement, ESMEntitlement, ESMLegacyEntitlement, fips.FIPSEntitlement, fips.FIPSUpdatesEntitlement, LivepatchEntitlement, ] # type: List[Type[UAEntitlement]] ENTITLEMENT_CLASS_BY_NAME = dict( (cast(str, cls.name), cls) for cls in ENTITLEMENT_CLASSES ) # type: Dict[str, Type[UAEntitlement]] ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/entitlements/fips.py0000664000000000000000000000576014601326072022277 0ustar from uaclient.entitlements import repo from uaclient import apt, status, util try: from typing import Dict, List, Set, Tuple # noqa except ImportError: # typing isn't available on trusty, so ignore its absence pass class FIPSCommonEntitlement(repo.RepoEntitlement): repo_pin_priority = 1001 fips_required_packages = frozenset({"fips-initramfs", "linux-fips"}) fips_packages = { "libssl1.0.0": {"libssl1.0.0-hmac"}, "openssh-client": {"openssh-client-hmac"}, "openssh-server": {"openssh-server-hmac"}, "openssl": set(), "strongswan": {"strongswan-hmac"}, } # type: Dict[str, Set[str]] @property def packages(self) -> "List[str]": packages = list(self.fips_required_packages) installed_packages = apt.get_installed_packages() for pkg_name, extra_pkgs in self.fips_packages.items(): if pkg_name in installed_packages: packages.append(pkg_name) packages.extend(extra_pkgs) return packages def application_status(self) -> "Tuple[status.ApplicationStatus, str]": super_status, super_msg = super().application_status() if super_status != status.ApplicationStatus.ENABLED: return super_status, super_msg running_kernel = util.get_platform_info()["kernel"] if running_kernel.endswith("-fips"): return super_status, super_msg return ( status.ApplicationStatus.ENABLED, "Reboot to FIPS kernel required", ) def disable(self, silent: bool = False) -> bool: """FIPS cannot be disabled, so simply display a message to the user""" if not silent: print("Warning: no option to disable {}".format(self.title)) return False def _cleanup(self) -> None: """FIPS can't be cleaned up automatically, so don't do anything""" pass class FIPSEntitlement(FIPSCommonEntitlement): help_doc_url = "https://ubuntu.com/fips" name = "fips" title = "FIPS" description = "NIST-certified FIPS modules" messaging = { "post_enable": ["A reboot is required to complete the install"] } origin = "UbuntuFIPS" repo_url = "https://esm.ubuntu.com/fips" repo_key_file = "ubuntu-fips-keyring.gpg" static_affordances = ( ("Cannot install FIPS on a container", util.is_container, False), ) class FIPSUpdatesEntitlement(FIPSCommonEntitlement): name = "fips-updates" title = "FIPS Updates" messaging = { "post_enable": [ "FIPS Updates configured and pending, please reboot to make" " active." ] } origin = "UbuntuFIPSUpdates" description = "Uncertified security updates to FIPS modules" repo_url = "https://esm.ubuntu.com/fips-updates" repo_key_file = "ubuntu-fips-updates-keyring.gpg" static_affordances = ( ( "Cannot install FIPS Updates on a container", util.is_container, False, ), ) ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/entitlements/livepatch.py0000664000000000000000000002274714601326072023321 0ustar import logging from uaclient.entitlements import base from uaclient import apt, exceptions, status from uaclient import util from uaclient.status import ApplicationStatus SNAP_CMD = "/usr/bin/snap" SNAP_INSTALL_RETRIES = [0.5, 1.0, 5.0] try: from typing import Any, Dict, Tuple # noqa: F401 except ImportError: # typing isn't available on trusty, so ignore its absence pass ERROR_MSG_MAP = { "Unknown Auth-Token": "Invalid Auth-Token provided to livepatch.", "unsupported kernel": "Your running kernel is not supported by Livepatch.", } class LivepatchEntitlement(base.UAEntitlement): help_doc_url = "https://ubuntu.com/livepatch" name = "livepatch" title = "Livepatch" description = "Canonical Livepatch service" # Use a lambda so we can mock util.is_container in tests static_affordances = ( ( "Cannot install Livepatch on a container", lambda: util.is_container(), False, ), ) def enable(self, *, silent_if_inapplicable: bool = False) -> bool: """Enable specific entitlement. :param silent_if_inapplicable: Don't emit any messages until after it has been determined that this entitlement is applicable to the current machine. @return: True on success, False otherwise. """ if not self.can_enable(silent=silent_if_inapplicable): return False if not util.which("/snap/bin/canonical-livepatch"): if not util.which(SNAP_CMD): print("Installing snapd") print(status.MESSAGE_APT_UPDATING_LISTS) try: apt.run_apt_command( ["apt-get", "update"], status.MESSAGE_APT_UPDATE_FAILED ) except exceptions.UserFacingError as e: logging.debug( "Trying to install snapd." " Ignoring apt-get update failure: %s", str(e), ) util.subp( ["apt-get", "install", "--assume-yes", "snapd"], capture=True, retry_sleeps=apt.APT_RETRIES, ) util.subp( [SNAP_CMD, "wait", "system", "seed.loaded"], capture=True ) elif "snapd" not in apt.get_installed_packages(): raise exceptions.UserFacingError( "/usr/bin/snap is present but snapd is not installed;" " cannot enable {}".format(self.title) ) print("Installing canonical-livepatch snap") try: util.subp( [SNAP_CMD, "install", "canonical-livepatch"], capture=True, retry_sleeps=SNAP_INSTALL_RETRIES, ) except util.ProcessExecutionError as e: msg = "Unable to install Livepatch client: " + str(e) raise exceptions.UserFacingError(msg) return self.setup_livepatch_config( process_directives=True, process_token=True ) def setup_livepatch_config( self, process_directives: bool = True, process_token: bool = True ) -> bool: """Processs configuration setup for livepatch directives. :param process_directives: Boolean set True when directives should be processsed. :param process_token: Boolean set True when token should be processsed. """ entitlement_cfg = self.cfg.entitlements.get(self.name) if process_directives: try: process_config_directives(entitlement_cfg) except util.ProcessExecutionError as e: msg = "Unable to configure Livepatch: " + str(e) print(msg) logging.error(msg) return False if process_token: livepatch_token = entitlement_cfg.get("resourceToken") if not livepatch_token: logging.debug( "No specific resourceToken present. Using machine token as" " %s credentials", self.title, ) livepatch_token = self.cfg.machine_token["machineToken"] application_status, _details = self.application_status() if application_status != status.ApplicationStatus.DISABLED: logging.info( "Disabling %s prior to re-attach with new token", self.title, ) try: util.subp(["/snap/bin/canonical-livepatch", "disable"]) except util.ProcessExecutionError as e: logging.error(str(e)) return False try: util.subp( [ "/snap/bin/canonical-livepatch", "enable", livepatch_token, ], capture=True, ) except util.ProcessExecutionError as e: msg = "Unable to enable Livepatch: " for error_message, print_message in ERROR_MSG_MAP.items(): if error_message in str(e): msg += print_message break if msg == "Unable to enable Livepatch: ": msg += str(e) print(msg) return False print("Canonical livepatch enabled.") return True def disable(self, silent=False): """Disable specific entitlement @return: True on success, False otherwise. """ if not self.can_disable(silent): return False if not util.which("/snap/bin/canonical-livepatch"): return True util.subp(["/snap/bin/canonical-livepatch", "disable"], capture=True) logging.debug("Removing canonical-livepatch snap") if not silent: print("Removing canonical-livepatch snap") util.subp([SNAP_CMD, "remove", "canonical-livepatch"], capture=True) return True def application_status(self) -> "Tuple[ApplicationStatus, str]": status = (ApplicationStatus.ENABLED, "") try: util.subp(["/snap/bin/canonical-livepatch", "status"]) except util.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 status def process_contract_deltas( self, orig_access: "Dict[str, Any]", deltas: "Dict[str, Any]", allow_enable: bool = False, ) -> bool: """Process any contract access deltas for this entitlement. :param orig_access: Dictionary containing the original resourceEntitlement access details. :param deltas: Dictionary which contains only the changed access keys and values. :param allow_enable: Boolean set True if allowed to perform the enable operation. When False, a message will be logged to inform the user about the recommended enabled service. :return: True when delta operations are processed; False when noop. """ if super().process_contract_deltas(orig_access, deltas, allow_enable): return True # Already processed parent class deltas application_status, _ = self.application_status() if application_status == status.ApplicationStatus.DISABLED: return True # only operate on changed directives when ACTIVE delta_entitlement = deltas.get("entitlement", {}) delta_directives = delta_entitlement.get("directives", {}) supported_deltas = set(["caCerts", "remoteServer"]) process_directives = bool( supported_deltas.intersection(delta_directives) ) process_token = bool(deltas.get("resourceToken", False)) if any([process_directives, process_token]): logging.info("Updating '%s' on changed directives.", self.name) return self.setup_livepatch_config( process_directives=process_directives, process_token=process_token, ) return True def process_config_directives(cfg): """Process livepatch configuration directives. We process caCerts before remoteServer because changing remote-server in the canonical-livepatch CLI performs a PUT against the new server name. If new caCerts were required for the new remoteServer, this canonical-livepatch client PUT could fail on unmatched old caCerts. @raises: ProcessExecutionError if unable to configure livepatch. """ if not cfg: return directives = cfg.get("entitlement", {}).get("directives", {}) ca_certs = directives.get("caCerts") if ca_certs: util.subp( [ "/snap/bin/canonical-livepatch", "config", "ca-certs={}".format(ca_certs), ], capture=True, ) remote_server = directives.get("remoteServer", "") if remote_server.endswith("/"): remote_server = remote_server[:-1] if remote_server: util.subp( [ "/snap/bin/canonical-livepatch", "config", "remote-server={}".format(remote_server), ], capture=True, ) ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/entitlements/repo.py0000664000000000000000000002643714601326072022307 0ustar import abc import logging import os import re try: from typing import Any, Dict, List, Optional, Tuple, Union # noqa: F401 except ImportError: # typing isn't available on trusty, so ignore its absence pass from uaclient import apt from uaclient import exceptions from uaclient.entitlements import base from uaclient import status from uaclient import util from uaclient.status import ApplicationStatus APT_DISABLED_PIN = "-32768" class RepoEntitlement(base.UAEntitlement): repo_list_file_tmpl = "/etc/apt/sources.list.d/ubuntu-{name}-{series}.list" repo_pref_file_tmpl = "/etc/apt/preferences.d/ubuntu-{name}-{series}" # The repo Origin value for setting pinning origin = None # type: Optional[str] # Optional repo pin priority in subclass repo_pin_priority = None # type: Union[int, str, None] # disable_apt_auth_only (ESM) to only remove apt auth files on disable disable_apt_auth_only = False # Set True on ESM to only remove apt auth # Any custom messages to emit pre or post enable or disable operations; # currently post_enable is used in CommonCriteria messaging = {} # type: Dict[str, List[str]] @property def packages(self) -> "List[str]": """debs to install on enablement""" return [] @property @abc.abstractmethod def repo_url(self) -> str: pass @property @abc.abstractmethod def repo_key_file(self) -> str: pass def enable(self, *, silent_if_inapplicable: bool = False) -> bool: """Enable specific entitlement. :param silent_if_inapplicable: Don't emit any messages until after it has been determined that this entitlement is applicable to the current machine. @return: True on success, False otherwise. @raises: UserFacingError on failure to install suggested packages """ if not self.can_enable(silent=silent_if_inapplicable): return False self.setup_apt_config() if self.packages: try: print("Installing {title} packages".format(title=self.title)) for msg in self.messaging.get("pre_install", []): print(msg) apt.run_apt_command( ["apt-get", "install", "--assume-yes"] + self.packages, status.MESSAGE_ENABLED_FAILED_TMPL.format( title=self.title ), ) except exceptions.UserFacingError: self._cleanup() raise print(status.MESSAGE_ENABLED_TMPL.format(title=self.title)) for msg in self.messaging.get("post_enable", []): print(msg) return True def disable(self, silent=False): if not self.can_disable(silent): return False self._cleanup() return True def _cleanup(self) -> None: """Clean up the entitlement without checks or messaging""" self.remove_apt_config() try: util.subp(["apt-get", "remove", "--assume-yes"] + self.packages) except util.ProcessExecutionError: pass def application_status(self) -> "Tuple[ApplicationStatus, str]": entitlement_cfg = self.cfg.entitlements.get(self.name, {}) directives = entitlement_cfg.get("entitlement", {}).get( "directives", {} ) repo_url = directives.get("aptURL") if not repo_url: repo_url = self.repo_url protocol, repo_path = repo_url.split("://") policy = apt.run_apt_command( ["apt-cache", "policy"], status.MESSAGE_APT_POLICY_FAILED ) match = re.search(r"(?P(-)?\d+) {}[^-]".format(repo_url), policy) if match and match.group("pin") != APT_DISABLED_PIN: return ApplicationStatus.ENABLED, "{} is active".format(self.title) return ( ApplicationStatus.DISABLED, "{} is not configured".format(self.title), ) def process_contract_deltas( self, orig_access: "Dict[str, Any]", deltas: "Dict[str, Any]", allow_enable: bool = False, ) -> bool: """Process any contract access deltas for this entitlement. :param orig_access: Dictionary containing the original resourceEntitlement access details. :param deltas: Dictionary which contains only the changed access keys and values. :param allow_enable: Boolean set True if allowed to perform the enable operation. When False, a message will be logged to inform the user about the recommended enabled service. :return: True when delta operations are processed; False when noop. """ if super().process_contract_deltas(orig_access, deltas, allow_enable): return True # Already processed parent class deltas application_status, _ = self.application_status() if application_status == status.ApplicationStatus.DISABLED: return True logging.info( "Updating '%s' apt sources list on changed directives.", self.name ) delta_entitlement = deltas.get("entitlement", {}) if delta_entitlement.get("directives", {}).get("aptURL"): orig_entitlement = orig_access.get("entitlement", {}) old_url = orig_entitlement.get("directives", {}).get("aptURL") if old_url: # Remove original aptURL and auth and rewrite series = util.get_platform_info()["series"] repo_filename = self.repo_list_file_tmpl.format( name=self.name, series=series ) apt.remove_auth_apt_repo(repo_filename, old_url) self.remove_apt_config() self.setup_apt_config() return True def setup_apt_config(self) -> None: """Setup apt config based on the resourceToken and directives. :raise UserFacingError: on failure to setup any aspect of this apt configuration """ series = util.get_platform_info()["series"] repo_filename = self.repo_list_file_tmpl.format( name=self.name, series=series ) resource_cfg = self.cfg.entitlements.get(self.name) directives = resource_cfg["entitlement"].get("directives", {}) token = resource_cfg.get("resourceToken") if not token: logging.debug( "No specific resourceToken present. Using machine token" " as %s credentials", self.title, ) token = self.cfg.machine_token["machineToken"] aptKey = directives.get("aptKey") if not aptKey: raise exceptions.UserFacingError( "Ubuntu Advantage server provided no aptKey directive for" " {}.".format(self.name) ) repo_url = directives.get("aptURL") if not repo_url: repo_url = self.repo_url repo_suites = directives.get("suites") if not repo_suites: raise exceptions.UserFacingError( "Empty {} apt suites directive from {}".format( self.name, self.cfg.contract_url ) ) if self.repo_pin_priority: if not self.origin: raise exceptions.UserFacingError( "Cannot setup apt pin. Empty apt repo origin value '{}'.\n" "{}".format( self.origin, status.MESSAGE_ENABLED_FAILED_TMPL.format( title=self.title ), ) ) repo_pref_file = self.repo_pref_file_tmpl.format( name=self.name, series=series ) if self.repo_pin_priority != "never": apt.add_ppa_pinning( repo_pref_file, repo_url, self.origin, self.repo_pin_priority, ) elif os.path.exists(repo_pref_file): os.unlink(repo_pref_file) # Remove disabling apt pref file prerequisite_pkgs = [] if not os.path.exists(apt.APT_METHOD_HTTPS_FILE): prerequisite_pkgs.append("apt-transport-https") if not os.path.exists(apt.CA_CERTIFICATES_FILE): prerequisite_pkgs.append("ca-certificates") if prerequisite_pkgs: print( "Installing prerequisites: {}".format( ", ".join(prerequisite_pkgs) ) ) try: apt.run_apt_command( ["apt-get", "install", "--assume-yes"] + prerequisite_pkgs, status.MESSAGE_APT_INSTALL_FAILED, ) except exceptions.UserFacingError: self.remove_apt_config() raise apt.add_auth_apt_repo( repo_filename, repo_url, token, repo_suites, self.repo_key_file ) # Run apt-update on any repo-entitlement enable because the machine # probably wants access to the repo that was just enabled. # Side-effect is that apt policy will now report the repo as accessible # which allows ua status to report correct info print(status.MESSAGE_APT_UPDATING_LISTS) try: apt.run_apt_command( ["apt-get", "update"], status.MESSAGE_APT_UPDATE_FAILED ) except exceptions.UserFacingError: self.remove_apt_config() raise def remove_apt_config(self): """Remove any repository apt configuration files.""" series = util.get_platform_info()["series"] repo_filename = self.repo_list_file_tmpl.format( name=self.name, series=series ) entitlement = self.cfg.read_cache( "machine-access-{}".format(self.name) ).get("entitlement", {}) access_directives = entitlement.get("directives", {}) repo_url = access_directives.get("aptURL", self.repo_url) if not repo_url: repo_url = self.repo_url if self.disable_apt_auth_only: # We only remove the repo from the apt auth file, because ESM # is a special-case: we want to be able to report on the # available ESM updates even when it's disabled apt.remove_repo_from_apt_auth_file(repo_url) apt.restore_commented_apt_list_file(repo_filename) else: apt.remove_auth_apt_repo( repo_filename, repo_url, self.repo_key_file ) apt.remove_apt_list_files(repo_url, series) if self.repo_pin_priority: repo_pref_file = self.repo_pref_file_tmpl.format( name=self.name, series=series ) if self.repo_pin_priority == "never": # Disable the repo with a pinning file apt.add_ppa_pinning( repo_pref_file, repo_url, self.origin, self.repo_pin_priority, ) elif os.path.exists(repo_pref_file): os.unlink(repo_pref_file) print(status.MESSAGE_APT_UPDATING_LISTS) apt.run_apt_command( ["apt-get", "update"], status.MESSAGE_APT_UPDATE_FAILED ) ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/entitlements/cis.py0000664000000000000000000000072014601326072022103 0ustar from uaclient.entitlements import repo class CISEntitlement(repo.RepoEntitlement): help_doc_url = "https://ubuntu.com/cis-audit" name = "cis-audit" title = "CIS Audit" description = "Center for Internet Security Audit Tools" repo_url = ( "https://private-ppa.launchpad.net/ubuntu-advantage/" "security-benchmarks" ) repo_key_file = "ubuntu-securitybenchmarks-keyring.gpg" packages = ["ubuntu-cisbenchmark-16.04"] ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/entitlements/cc.py0000664000000000000000000000144314601326072021715 0ustar from uaclient.entitlements import repo CC_README = "/usr/share/doc/ubuntu-commoncriteria/README" class CommonCriteriaEntitlement(repo.RepoEntitlement): help_doc_url = "https://ubuntu.com/cc-eal" name = "cc-eal" title = "CC EAL2" description = "Common Criteria EAL2 Provisioning Packages" repo_url = ( "https://private-ppa.launchpad.net/ubuntu-advantage/commoncriteria" ) repo_key_file = "ubuntu-cc-keyring.gpg" packages = ["ubuntu-commoncriteria"] messaging = { "pre_install": [ "(This will download more than 500MB of packages, so may take some" " time.)" ], "post_enable": [ "Please follow instructions in {} to configure EAL2".format( CC_README ) ], } ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/entitlements/base.py0000664000000000000000000003264014601326072022245 0ustar import abc from datetime import datetime import logging import re try: from typing import Any, Callable, Dict, Optional, Tuple # noqa: F401 StaticAffordance = Tuple[str, Callable[[], Any], bool] except ImportError: # typing isn't available on trusty, so ignore its absence pass from uaclient import config from uaclient import contract from uaclient import status from uaclient import util from uaclient.status import ( ApplicabilityStatus, ContractStatus, UserFacingStatus, ) RE_KERNEL_UNAME = ( r"(?P[\d]+)[.-](?P[\d]+)[.-](?P[\d]+\-[\d]+)" r"-(?P[A-Za-z0-9_-]+)" ) class UAEntitlement(metaclass=abc.ABCMeta): # Optional URL for top-level product service information help_doc_url = None # type: str @property @abc.abstractmethod def name(self) -> str: """The lowercase name of this entitlement""" pass @property @abc.abstractmethod def title(self) -> str: """The human readable title of this entitlement""" pass @property @abc.abstractmethod def description(self) -> str: """A sentence describing this entitlement""" pass # A tuple of 3-tuples with (failure_message, functor, expected_results) # If any static_affordance does not match expected_results fail with # . Overridden in livepatch and fips static_affordances = () # type: Tuple[StaticAffordance, ...] def __init__(self, cfg: "Optional[config.UAConfig]" = None) -> None: """Setup UAEntitlement instance @param config: Parsed configuration dictionary """ if not cfg: cfg = config.UAConfig() self.cfg = cfg @abc.abstractmethod def enable(self, *, silent_if_inapplicable: bool = False) -> bool: """Enable specific entitlement. :param silent_if_inapplicable: Don't emit any messages until after it has been determined that this entitlement is applicable to the current machine. @return: True on success, False otherwise. """ pass def can_disable(self, silent: bool = False) -> bool: """Report whether or not disabling is possible for the entitlement. @param silent: Boolean set True to silence printed messages/warnings. """ 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 return True def can_enable(self, silent: bool = False) -> bool: """ Report whether or not enabling is possible for the entitlement. :param silent: if True, suppress output """ if self.is_access_expired(): token = self.cfg.machine_token["machineToken"] contract_client = contract.UAContractClient(self.cfg) contract_client.request_resource_machine_access(token, self.name) if not self.contract_status() == ContractStatus.ENTITLED: if not silent: print(status.MESSAGE_UNENTITLED_TMPL.format(title=self.title)) return False application_status, _ = self.application_status() if application_status != status.ApplicationStatus.DISABLED: if not silent: print( status.MESSAGE_ALREADY_ENABLED_TMPL.format( title=self.title ) ) return False applicability_status, details = self.applicability_status() if applicability_status == status.ApplicabilityStatus.INAPPLICABLE: if not silent: print(details) return False return True def applicability_status(self) -> "Tuple[ApplicabilityStatus, str]": """Check all contract affordances to vet current platform Affordances are a list of support constraints for the entitlement. Examples include a list of supported series, architectures for kernel revisions. :return: tuple of (ApplicabilityStatus, detailed_message). APPLICABLE if platform passes all defined affordances, INAPPLICABLE if it doesn't meet all of the provided constraints. """ entitlement_cfg = self.cfg.entitlements.get(self.name) if not entitlement_cfg: return ( ApplicabilityStatus.APPLICABLE, "no entitlement affordances checked", ) for error_message, functor, expected_result in self.static_affordances: if functor() != expected_result: return ApplicabilityStatus.INAPPLICABLE, error_message affordances = entitlement_cfg["entitlement"].get("affordances", {}) platform = util.get_platform_info() affordance_arches = affordances.get("architectures", []) if affordance_arches and platform["arch"] not in affordance_arches: return ( ApplicabilityStatus.INAPPLICABLE, status.MESSAGE_INAPPLICABLE_ARCH_TMPL.format( title=self.title, arch=platform["arch"], supported_arches=", ".join(affordance_arches), ), ) affordance_series = affordances.get("series", []) if affordance_series and platform["series"] not in affordance_series: return ( ApplicabilityStatus.INAPPLICABLE, status.MESSAGE_INAPPLICABLE_SERIES_TMPL.format( title=self.title, series=platform["version"] ), ) kernel = platform["kernel"] affordance_kernels = affordances.get("kernelFlavors", []) affordance_min_kernel = affordances.get("minKernelVersion") match = re.match(RE_KERNEL_UNAME, kernel) if affordance_kernels: if not match or match.group("flavor") not in affordance_kernels: return ( ApplicabilityStatus.INAPPLICABLE, status.MESSAGE_INAPPLICABLE_KERNEL_TMPL.format( title=self.title, kernel=kernel, supported_kernels=", ".join(affordance_kernels), ), ) if affordance_min_kernel: invalid_msg = status.MESSAGE_INAPPLICABLE_KERNEL_VER_TMPL.format( title=self.title, kernel=kernel, min_kernel=affordance_min_kernel, ) try: kernel_major, kernel_minor = affordance_min_kernel.split(".") min_kern_major = int(kernel_major) min_kern_minor = int(kernel_minor) except ValueError: logging.warning( "Could not parse minKernelVersion: %s", affordance_min_kernel, ) return (ApplicabilityStatus.INAPPLICABLE, invalid_msg) if not match: return ApplicabilityStatus.INAPPLICABLE, invalid_msg kernel_major = int(match.group("major")) kernel_minor = int(match.group("minor")) if kernel_major < min_kern_major: return ApplicabilityStatus.INAPPLICABLE, invalid_msg elif ( kernel_major == min_kern_major and kernel_minor < min_kern_minor ): return ApplicabilityStatus.INAPPLICABLE, invalid_msg return ApplicabilityStatus.APPLICABLE, "" @abc.abstractmethod def disable(self, silent: bool = False) -> bool: """Disable specific entitlement @param silent: Boolean set True to silence print/log of messages @return: True on success, False otherwise. """ pass def contract_status(self) -> ContractStatus: """Return whether the user is entitled to the entitlement or not""" if not self.cfg.is_attached: return ContractStatus.UNENTITLED entitlement_cfg = self.cfg.entitlements.get(self.name, {}) if entitlement_cfg and entitlement_cfg["entitlement"].get("entitled"): return ContractStatus.ENTITLED return ContractStatus.UNENTITLED def is_access_expired(self) -> bool: """Return entitlement access info as stale and needing refresh.""" entitlement_contract = self.cfg.entitlements.get(self.name, {}) # TODO(No expiry per resource in MVP yet) expire_str = entitlement_contract.get("expires") if not expire_str: return False expiry = datetime.strptime(expire_str, "%Y-%m-%dT%H:%M:%S.%fZ") if expiry >= datetime.utcnow(): return False return True def process_contract_deltas( self, orig_access: "Dict[str, Any]", deltas: "Dict[str, Any]", allow_enable: bool = False, ) -> bool: """Process any contract access deltas for this entitlement. :param orig_access: Dictionary containing the original resourceEntitlement access details. :param deltas: Dictionary which contains only the changed access keys and values. :param allow_enable: Boolean set True if allowed to perform the enable operation. When False, a message will be logged to inform the user about the recommended enabled service. :return: True when delta operations are processed; False when noop. :raise: UserFacingError when auto-enable fails unexpectedly. """ if not deltas: return True # We processed all deltas that needed processing delta_entitlement = deltas.get("entitlement", {}) transition_to_unentitled = bool(delta_entitlement == util.DROPPED_KEY) if not transition_to_unentitled: if delta_entitlement: util.apply_series_overrides(deltas) delta_entitlement = deltas["entitlement"] if orig_access and "entitled" in delta_entitlement: transition_to_unentitled = delta_entitlement["entitled"] in ( False, util.DROPPED_KEY, ) if transition_to_unentitled: application_status, _ = self.application_status() if application_status != status.ApplicationStatus.DISABLED: if self.can_disable(silent=True): self.disable() logging.info( "Due to contract refresh, '%s' is now disabled.", self.name, ) else: logging.warning( "Unable to disable '%s' as recommended during contract" " refresh. Service is still active. See" " `ua status`", self.name, ) # Clean up former entitled machine-access- response cache # file because uaclient doesn't access machine-access-* routes or # responses on unentitled services. self.cfg.delete_cache_key("machine-access-{}".format(self.name)) return True resourceToken = orig_access.get("resourceToken") if not resourceToken: resourceToken = deltas.get("resourceToken") delta_obligations = delta_entitlement.get("obligations", {}) can_enable = self.can_enable(silent=True) enableByDefault = bool( delta_obligations.get("enableByDefault") and resourceToken ) if can_enable and enableByDefault: if allow_enable: msg = status.MESSAGE_ENABLE_BY_DEFAULT_TMPL.format( name=self.name ) logging.info(msg) self.enable() else: msg = status.MESSAGE_ENABLE_BY_DEFAULT_MANUAL_TMPL.format( name=self.name ) logging.info(msg) return True return False def user_facing_status(self) -> "Tuple[UserFacingStatus, str]": """Return (user-facing status, details) for entitlement""" applicability, details = self.applicability_status() if applicability != ApplicabilityStatus.APPLICABLE: return UserFacingStatus.INAPPLICABLE, details entitlement_cfg = self.cfg.entitlements.get(self.name) if not entitlement_cfg: return ( UserFacingStatus.UNAVAILABLE, "{} is not entitled".format(self.title), ) elif entitlement_cfg["entitlement"].get("entitled", False) is False: return ( UserFacingStatus.UNAVAILABLE, "{} is not entitled".format(self.title), ) application_status, explanation = self.application_status() user_facing_status = { status.ApplicationStatus.ENABLED: UserFacingStatus.ACTIVE, status.ApplicationStatus.DISABLED: UserFacingStatus.INACTIVE, }[application_status] return user_facing_status, explanation @abc.abstractmethod def application_status(self) -> "Tuple[status.ApplicationStatus, str]": """ The current status of application of this entitlement :return: A tuple of (ApplicationStatus, human-friendly reason) """ pass ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/entitlements/esm.py0000664000000000000000000000163114601326072022113 0ustar from uaclient.entitlements import repo class ESMEntitlement(repo.RepoEntitlement): help_doc_url = "https://ubuntu.com/esm" name = "esm-infra" title = "ESM Infra" origin = "UbuntuESM" description = "UA Infra: Extended Security Maintenance" repo_url = "https://esm.ubuntu.com" repo_key_file = "ubuntu-advantage-esm-infra-trusty.gpg" repo_pin_priority = "never" disable_apt_auth_only = True # Only remove apt auth files when disabling class ESMLegacyEntitlement(repo.RepoEntitlement): help_doc_url = "https://ubuntu.com/esm" name = "esm-infra-legacy" title = "Ubuntu Pro: ESM Infra (Legacy)" origin = "UbuntuESM" description = "Expanded Security Maintenance for Infrastructure on Legacy Instances" # noqa repo_url = "https://esm.ubuntu.com/infra-legacy/" repo_key_file = "ubuntu-advantage-esm-infra-legacy-trusty.gpg" disable_apt_auth_only = False ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/entitlements/tests/0000775000000000000000000000000014601326072022116 5ustar ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/entitlements/tests/test_repo.py0000664000000000000000000004052714601326072024504 0ustar import copy from textwrap import dedent import mock import pytest from types import MappingProxyType from uaclient import apt from uaclient import config from uaclient.entitlements.repo import RepoEntitlement from uaclient.entitlements.tests.conftest import machine_token from uaclient import exceptions from uaclient import status from uaclient import util M_PATH = "uaclient.entitlements.repo." PLATFORM_INFO_SUPPORTED = MappingProxyType( { "arch": "x86_64", "kernel": "4.4.0-00-generic", "series": "xenial", "version": "16.04 LTS (Xenial Xerus)", } ) class RepoTestEntitlement(RepoEntitlement): """Subclass so we can test shared repo functionality""" name = "repotest" title = "Repo Test Class" description = "Repo entitlement for testing" repo_url = "http://example.com/ubuntu" repo_key_file = "test.gpg" @pytest.fixture def entitlement(entitlement_factory): return entitlement_factory( RepoTestEntitlement, affordances={"series": ["xenial"]} ) class TestUserFacingStatus: @mock.patch(M_PATH + "util.get_platform_info") def test_inapplicable_on_inapplicable_applicability_status( self, m_platform_info, entitlement ): """When applicability_status is INAPPLICABLE, return INAPPLICABLE.""" platform_unsupported = copy.deepcopy(dict(PLATFORM_INFO_SUPPORTED)) platform_unsupported["series"] = "trusty" platform_unsupported["version"] = "14.04 LTS (Trusty Tahr)" m_platform_info.return_value = platform_unsupported applicability, details = entitlement.applicability_status() assert status.ApplicabilityStatus.INAPPLICABLE == applicability expected_details = ( "Repo Test Class is not available for Ubuntu 14.04" " LTS (Trusty Tahr)." ) assert expected_details == details uf_status, _ = entitlement.user_facing_status() assert status.UserFacingStatus.INAPPLICABLE == uf_status @mock.patch(M_PATH + "util.get_platform_info") def test_unavailable_on_unentitled(self, m_platform_info, entitlement): """When unentitled, return UNAVAILABLE.""" no_entitlements = copy.deepcopy(machine_token(RepoTestEntitlement)) # delete all enttlements no_entitlements["machineTokenInfo"]["contractInfo"][ "resourceEntitlements" ].pop() entitlement.cfg.write_cache("machine-token", no_entitlements) entitlement.cfg.delete_cache_key("machine-access-repotest") m_platform_info.return_value = dict(PLATFORM_INFO_SUPPORTED) applicability, _details = entitlement.applicability_status() 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 class TestProcessContractDeltas: @pytest.mark.parametrize("orig_access", ({}, {"entitlement": {}})) def test_on_no_deltas(self, orig_access): """Return True when no deltas are available to process.""" entitlement = RepoTestEntitlement() with mock.patch.object( entitlement, "remove_apt_config" ) as m_remove_apt_config: with mock.patch.object( entitlement, "setup_apt_config" ) as m_setup_apt_config: assert entitlement.process_contract_deltas(orig_access, {}) assert [] == m_remove_apt_config.call_args_list assert [] == m_setup_apt_config.call_args_list @pytest.mark.parametrize("entitled", (False, util.DROPPED_KEY)) @mock.patch.object(RepoTestEntitlement, "disable") @mock.patch.object(RepoTestEntitlement, "can_disable", return_value=True) @mock.patch.object(RepoTestEntitlement, "application_status") def test_disable_when_delta_to_unentitled( self, m_application_status, m_can_disable, m_disable, entitlement, entitled, ): """Disable the service on contract transitions to unentitled.""" application_status = status.ApplicationStatus.ENABLED m_application_status.return_value = (application_status, "") assert entitlement.process_contract_deltas( {"entitlement": {"entitled": True}}, {"entitlement": {"entitled": entitled}}, ) assert [mock.call()] == m_disable.call_args_list @mock.patch.object(RepoTestEntitlement, "remove_apt_config") @mock.patch.object(RepoTestEntitlement, "application_status") @mock.patch.object(RepoTestEntitlement, "applicability_status") def test_no_changes_when_service_inactive_and_not_enable_by_default( self, m_applicability_status, m_application_status, m_remove_apt_config, entitlement, ): """Noop when service is inactive and not enableByDefault.""" m_application_status.return_value = ( status.ApplicationStatus.DISABLED, "", ) m_applicability_status.return_value = ( status.ApplicabilityStatus.APPLICABLE, "", ) assert entitlement.process_contract_deltas( {"entitlement": {"entitled": True}}, { "entitlement": {"obligations": {"enableByDefault": False}}, "resourceToken": "TOKEN", }, ) assert [] == m_remove_apt_config.call_args_list @mock.patch.object(RepoTestEntitlement, "enable") @mock.patch.object(RepoTestEntitlement, "application_status") @mock.patch.object(RepoTestEntitlement, "applicability_status") def test_allow_enable_when_inactive_enable_by_default_and_resource_token( self, m_applicability_status, m_application_status, m_enable, entitlement, ): """Update apt when inactive, enableByDefault and allow_enable.""" m_application_status.return_value = ( status.ApplicationStatus.DISABLED, "", ) m_applicability_status.return_value = ( status.ApplicabilityStatus.APPLICABLE, "", ) assert entitlement.process_contract_deltas( {"entitlement": {"entitled": True}}, { "entitlement": {"obligations": {"enableByDefault": True}}, "resourceToken": "TOKEN", }, allow_enable=True, ) assert [mock.call()] == m_enable.call_args_list @mock.patch.object(RepoTestEntitlement, "enable") @mock.patch.object(RepoTestEntitlement, "application_status") @mock.patch.object(RepoTestEntitlement, "applicability_status") def test_not_allow_enable_logs_message_when_inactive_enable_by_default( self, m_applicability_status, m_application_status, m_enable, entitlement, caplog_text, ): """Log a message when inactive, enableByDefault and allow_enable.""" m_application_status.return_value = ( status.ApplicationStatus.DISABLED, "", ) m_applicability_status.return_value = ( status.ApplicabilityStatus.APPLICABLE, "", ) assert entitlement.process_contract_deltas( {"entitlement": {"entitled": True}}, { "entitlement": {"obligations": {"enableByDefault": True}}, "resourceToken": "TOKEN", }, allow_enable=False, ) assert [] == m_enable.call_args_list expected_msg = status.MESSAGE_ENABLE_BY_DEFAULT_MANUAL_TMPL.format( name="repotest" ) assert expected_msg in caplog_text() @mock.patch(M_PATH + "apt.remove_auth_apt_repo") @mock.patch.object(RepoTestEntitlement, "setup_apt_config") @mock.patch.object(RepoTestEntitlement, "remove_apt_config") @mock.patch.object(RepoTestEntitlement, "application_status") def test_update_apt_config_when_active( self, m_application_status, m_remove_apt_config, m_setup_apt_config, m_remove_auth_apt_repo, entitlement, ): """Update_apt_config when service is active and not enableByDefault.""" application_status = status.ApplicationStatus.ENABLED m_application_status.return_value = (application_status, "") assert entitlement.process_contract_deltas( {"entitlement": {"entitled": True}}, { "entitlement": {"obligations": {"enableByDefault": False}}, "resourceToken": "TOKEN", }, ) assert [mock.call()] == m_remove_apt_config.call_args_list assert [mock.call()] == m_setup_apt_config.call_args_list assert [] == m_remove_auth_apt_repo.call_args_list @mock.patch( M_PATH + "util.get_platform_info", return_value={"series": "trusty"} ) @mock.patch(M_PATH + "apt.remove_auth_apt_repo") @mock.patch.object(RepoTestEntitlement, "setup_apt_config") @mock.patch.object(RepoTestEntitlement, "remove_apt_config") @mock.patch.object(RepoTestEntitlement, "application_status") def test_remove_old_auth_apt_repo_when_active_and_apt_url_delta( self, m_application_status, m_remove_apt_config, m_setup_apt_config, m_remove_auth_apt_repo, m_platform_info, entitlement, ): """Remove old apt url when aptURL delta occurs on active service.""" application_status = status.ApplicationStatus.ENABLED m_application_status.return_value = (application_status, "") assert entitlement.process_contract_deltas( { "entitlement": { "entitled": True, "directives": {"aptURL": "http://old"}, } }, { "entitlement": { "obligations": {"enableByDefault": False}, "directives": {"aptURL": "http://new"}, }, "resourceToken": "TOKEN", }, ) assert [mock.call()] == m_remove_apt_config.call_args_list assert [mock.call()] == m_setup_apt_config.call_args_list apt_auth_remove_calls = [ mock.call( "/etc/apt/sources.list.d/ubuntu-repotest-trusty.list", "http://old", ) ] assert apt_auth_remove_calls == m_remove_auth_apt_repo.call_args_list apt_auth_remove_calls = [ mock.call( "/etc/apt/sources.list.d/ubuntu-repotest-trusty.list", "http://old", ) ] assert apt_auth_remove_calls == m_remove_auth_apt_repo.call_args_list class TestRepoEnable: @pytest.mark.parametrize("silent_if_inapplicable", (True, False, None)) @mock.patch.object(RepoTestEntitlement, "can_enable", return_value=False) def test_enable_passes_silent_if_inapplicable_through( self, m_can_enable, caplog_text, tmpdir, silent_if_inapplicable ): """When can_enable returns False enable returns False.""" cfg = config.UAConfig(cfg={"data_dir": tmpdir.strpath}) entitlement = RepoTestEntitlement(cfg) kwargs = {} if silent_if_inapplicable is not None: kwargs["silent_if_inapplicable"] = silent_if_inapplicable entitlement.enable(**kwargs) expected_call = mock.call(silent=bool(silent_if_inapplicable)) assert [expected_call] == m_can_enable.call_args_list @pytest.mark.parametrize("with_pre_install_msg", (False, True)) @pytest.mark.parametrize("packages", (["a"], [], None)) @mock.patch(M_PATH + "util.subp", return_value=("", "")) @mock.patch(M_PATH + "apt.add_auth_apt_repo") @mock.patch(M_PATH + "os.path.exists", return_value=True) @mock.patch(M_PATH + "util.get_platform_info") @mock.patch.object(RepoTestEntitlement, "can_enable", return_value=True) def test_enable_calls_adds_apt_repo_and_calls_apt_update( self, m_can_enable, m_platform, m_exists, m_apt_add, m_subp, entitlement, capsys, caplog_text, tmpdir, packages, with_pre_install_msg, ): """On enable add authenticated apt repo and refresh package lists.""" m_platform.return_value = {"series": "xenial"} pre_install_msgs = ["Some pre-install information", "Some more info"] if with_pre_install_msg: messaging_patch = mock.patch.object( entitlement, "messaging", {"pre_install": pre_install_msgs} ) else: messaging_patch = mock.MagicMock() expected_apt_calls = [ mock.call( ["apt-get", "update"], capture=True, retry_sleeps=apt.APT_RETRIES, ) ] expected_output = dedent( """\ Updating package lists Repo Test Class enabled """ ) if packages is not None: if len(packages) > 0: expected_apt_calls.append( mock.call( [ "apt-get", "install", "--assume-yes", " ".join(packages), ], capture=True, retry_sleeps=apt.APT_RETRIES, ) ) expected_output = ( "\n".join( [ "Updating package lists", "Installing Repo Test Class packages", ] + (pre_install_msgs if with_pre_install_msg else []) + ["Repo Test Class enabled"] ) + "\n" ) else: packages = entitlement.packages # We patch the type of entitlement because packages is a property with mock.patch.object(type(entitlement), "packages", packages): with messaging_patch: entitlement.enable() expected_calls = [ mock.call(apt.APT_METHOD_HTTPS_FILE), mock.call(apt.CA_CERTIFICATES_FILE), ] assert expected_calls in m_exists.call_args_list assert expected_apt_calls == m_subp.call_args_list add_apt_calls = [ mock.call( "/etc/apt/sources.list.d/ubuntu-repotest-xenial.list", "http://REPOTEST", "TOKEN", ["xenial"], entitlement.repo_key_file, ) ] assert add_apt_calls == m_apt_add.call_args_list stdout, _ = capsys.readouterr() assert expected_output == stdout @mock.patch(M_PATH + "util.subp") def test_failed_install_removes_apt_config_and_packages( self, m_subp, entitlement ): def fake_subp(args, *other_args, **kwargs): if "install" in args: raise util.ProcessExecutionError(args) m_subp.side_effect = fake_subp packages = ["fake_pkg", "and_another"] with mock.patch.object(entitlement, "setup_apt_config"): with mock.patch.object( entitlement, "can_enable", return_value=True ): with mock.patch.object( type(entitlement), "packages", packages ): with pytest.raises(exceptions.UserFacingError) as excinfo: with mock.patch.object( entitlement, "remove_apt_config" ) as m_rac: entitlement.enable() assert "Could not enable Repo Test Class." == excinfo.value.msg expected_call = mock.call( ["apt-get", "remove", "--assume-yes"] + packages ) assert expected_call in m_subp.call_args_list assert 1 == m_rac.call_count class TestRemoveAptConfig: @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") def test_apt_get_update_called( self, m_run_apt_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 ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/entitlements/tests/test_cc.py0000664000000000000000000001762114601326072024123 0ustar """Tests related to uaclient.entitlement.base module.""" import copy import itertools import mock import os.path from types import MappingProxyType import pytest from uaclient import apt from uaclient import config from uaclient import status from uaclient.entitlements.cc import CC_README, CommonCriteriaEntitlement M_REPOPATH = "uaclient.entitlements.repo." CC_MACHINE_TOKEN = { "machineToken": "blah", "machineTokenInfo": { "contractInfo": { "resourceEntitlements": [{"type": "cc-eal", "entitled": True}] } }, } CC_RESOURCE_ENTITLED = { "resourceToken": "TOKEN", "entitlement": { "obligations": {"enableByDefault": False}, "type": "cc-eal", "entitled": True, "directives": { "aptURL": "http://CC", "aptKey": "APTKEY", "suites": ["xenial"], }, "affordances": { "architectures": ["x86_64", "ppc64le", "s390x"], "series": ["xenial"], }, }, } PLATFORM_INFO_SUPPORTED = MappingProxyType( { "arch": "s390x", "series": "xenial", "kernel": "4.15.0-00-generic", "version": "16.04 LTS (Xenial Xerus)", } ) class TestCommonCriteriaEntitlementUserFacingStatus: @pytest.mark.parametrize( "arch,series,version,details", ( ( "arm64", "xenial", "16.04 LTS (Xenial Xerus)", "CC EAL2 is not available for platform arm64.\n" "Supported platforms are: x86_64, ppc64le, s390x", ), ( "s390x", "trusty", "14.04 LTS (Trusty Tahr)", "CC EAL2 is not available for Ubuntu 14.04 LTS" " (Trusty Tahr).", ), ), ) @mock.patch(M_REPOPATH + "os.getuid", return_value=0) @mock.patch("uaclient.util.get_platform_info") def test_inapplicable_on_invalid_affordances( self, m_platform_info, m_getuid, arch, series, version, details, tmpdir ): """Test invalid affordances result in inapplicable status.""" unsupported_info = copy.deepcopy(dict(PLATFORM_INFO_SUPPORTED)) unsupported_info["arch"] = arch unsupported_info["series"] = series unsupported_info["version"] = version m_platform_info.return_value = unsupported_info cfg = config.UAConfig(cfg={"data_dir": tmpdir.strpath}) cfg.write_cache("machine-token", CC_MACHINE_TOKEN) cfg.write_cache("machine-access-cc-eal", CC_RESOURCE_ENTITLED) entitlement = CommonCriteriaEntitlement(cfg) uf_status, uf_status_details = entitlement.user_facing_status() assert status.UserFacingStatus.INAPPLICABLE == uf_status assert details == uf_status_details class TestCommonCriteriaEntitlementCanEnable: @mock.patch("uaclient.util.subp", return_value=("", "")) @mock.patch("uaclient.util.get_platform_info") def test_can_enable_true_on_entitlement_inactive( self, m_platform_info, _m_subp, capsys, tmpdir ): """When entitlement is INACTIVE, can_enable returns True.""" m_platform_info.return_value = PLATFORM_INFO_SUPPORTED cfg = config.UAConfig(cfg={"data_dir": tmpdir.strpath}) cfg.write_cache("machine-token", CC_MACHINE_TOKEN) cfg.write_cache("machine-access-cc-eal", CC_RESOURCE_ENTITLED) entitlement = CommonCriteriaEntitlement(cfg) 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 True is entitlement.can_enable() assert ("", "") == capsys.readouterr() class TestCommonCriteriaEntitlementEnable: # Paramterize True/False for apt_transport_https and ca_certificates @pytest.mark.parametrize( "apt_transport_https,ca_certificates", itertools.product([False, True], repeat=2), ) @mock.patch("uaclient.util.subp") @mock.patch("uaclient.util.get_platform_info") def test_enable_configures_apt_sources_and_auth_files( self, m_platform_info, m_subp, capsys, tmpdir, apt_transport_https, ca_certificates, ): """When entitled, configure apt repo auth token, pinning and url.""" m_subp.return_value = ("fakeout", "") original_exists = os.path.exists def fake_platform(key=None): if key == "series": return PLATFORM_INFO_SUPPORTED[key] return PLATFORM_INFO_SUPPORTED def exists(path): if path == apt.APT_METHOD_HTTPS_FILE: return not apt_transport_https elif path == apt.CA_CERTIFICATES_FILE: return not ca_certificates elif not path.startswith(tmpdir.strpath): raise Exception( "os.path.exists call outside of tmpdir: {}".format(path) ) return original_exists(path) m_platform_info.side_effect = fake_platform cfg = config.UAConfig(cfg={"data_dir": tmpdir.strpath}) cfg.write_cache("machine-token", CC_MACHINE_TOKEN) cfg.write_cache("machine-access-cc-eal", CC_RESOURCE_ENTITLED) entitlement = CommonCriteriaEntitlement(cfg) with mock.patch("uaclient.apt.add_auth_apt_repo") as m_add_apt: with mock.patch("uaclient.apt.add_ppa_pinning") as m_add_pin: with mock.patch( M_REPOPATH + "os.path.exists", side_effect=exists ): assert True is entitlement.enable() add_apt_calls = [ mock.call( "/etc/apt/sources.list.d/ubuntu-cc-eal-xenial.list", "http://CC", "TOKEN", ["xenial"], entitlement.repo_key_file, ) ] subp_apt_cmds = [ mock.call( ["apt-cache", "policy"], capture=True, retry_sleeps=apt.APT_RETRIES, ) ] prerequisite_pkgs = [] if apt_transport_https: prerequisite_pkgs.append("apt-transport-https") if ca_certificates: prerequisite_pkgs.append("ca-certificates") if prerequisite_pkgs: expected_stdout = "Installing prerequisites: {}\n".format( ", ".join(prerequisite_pkgs) ) subp_apt_cmds.append( mock.call( ["apt-get", "install", "--assume-yes"] + prerequisite_pkgs, capture=True, retry_sleeps=apt.APT_RETRIES, ) ) else: expected_stdout = "" subp_apt_cmds.extend( [ mock.call( ["apt-get", "update"], capture=True, retry_sleeps=apt.APT_RETRIES, ), mock.call( ["apt-get", "install", "--assume-yes"] + entitlement.packages, capture=True, retry_sleeps=apt.APT_RETRIES, ), ] ) assert add_apt_calls == m_add_apt.call_args_list # No apt pinning for cc assert [] == m_add_pin.call_args_list assert subp_apt_cmds == m_subp.call_args_list expected_stdout += "\n".join( [ "Updating package lists", "Installing CC EAL2 packages", "(This will download more than 500MB of packages, so may take" " some time.)", "CC EAL2 enabled", "Please follow instructions in {} to configure EAL2\n".format( CC_README ), ] ) assert (expected_stdout, "") == capsys.readouterr() ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/entitlements/tests/__init__.py0000664000000000000000000000000014072115230024207 0ustar ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/entitlements/tests/test_esm.py0000664000000000000000000002115514601326072024317 0ustar import contextlib import mock import os.path import pytest from uaclient import apt from uaclient.entitlements.esm import ESMEntitlement from uaclient import exceptions from uaclient import util M_PATH = "uaclient.entitlements.esm.ESMEntitlement." M_REPOPATH = "uaclient.entitlements.repo." M_GETPLATFORM = M_REPOPATH + "util.get_platform_info" @pytest.fixture def entitlement(entitlement_factory): return entitlement_factory(ESMEntitlement, suites=["trusty"]) class TestESMEntitlementEnable: def test_enable_configures_apt_sources_and_auth_files(self, entitlement): """When entitled, configure apt repo auth token, pinning and url.""" patched_packages = ["a", "b"] original_exists = os.path.exists def fake_exists(path): if path == "/etc/apt/preferences.d/ubuntu-esm-infra-trusty": return True if path in (apt.APT_METHOD_HTTPS_FILE, apt.CA_CERTIFICATES_FILE): return True return original_exists(path) with contextlib.ExitStack() as stack: m_add_apt = stack.enter_context( mock.patch("uaclient.apt.add_auth_apt_repo") ) m_add_pinning = stack.enter_context( mock.patch("uaclient.apt.add_ppa_pinning") ) m_subp = stack.enter_context( mock.patch("uaclient.util.subp", return_value=("", "")) ) m_can_enable = stack.enter_context( mock.patch.object(entitlement, "can_enable") ) stack.enter_context( mock.patch(M_GETPLATFORM, return_value={"series": "trusty"}) ) stack.enter_context( mock.patch( M_REPOPATH + "os.path.exists", side_effect=fake_exists ) ) m_unlink = stack.enter_context( mock.patch("uaclient.apt.os.unlink") ) # Note that this patch uses a PropertyMock and happens on the # entitlement's type because packages is a property m_packages = mock.PropertyMock(return_value=patched_packages) stack.enter_context( mock.patch.object(type(entitlement), "packages", m_packages) ) m_can_enable.return_value = True assert True is entitlement.enable() add_apt_calls = [ mock.call( "/etc/apt/sources.list.d/ubuntu-{}-trusty.list".format( entitlement.name ), "http://ESM-INFRA", "TOKEN", ["trusty"], entitlement.repo_key_file, ) ] install_cmd = mock.call( ["apt-get", "install", "--assume-yes"] + patched_packages, capture=True, retry_sleeps=apt.APT_RETRIES, ) subp_calls = [ mock.call( ["apt-get", "update"], capture=True, retry_sleeps=apt.APT_RETRIES, ), install_cmd, ] assert [mock.call(silent=mock.ANY)] == m_can_enable.call_args_list assert add_apt_calls == m_add_apt.call_args_list assert 0 == m_add_pinning.call_count assert subp_calls == m_subp.call_args_list unlink_calls = [ mock.call("/etc/apt/preferences.d/ubuntu-esm-infra-trusty") ] assert unlink_calls == m_unlink.call_args_list def test_enable_cleans_up_apt_sources_and_auth_files_on_error( self, entitlement, caplog_text ): """When setup_apt_config fails, cleanup any apt artifacts.""" original_exists = os.path.exists def fake_exists(path): if path == "/etc/apt/preferences.d/ubuntu-esm-infra-trusty": return True if path in (apt.APT_METHOD_HTTPS_FILE, apt.CA_CERTIFICATES_FILE): return True return original_exists(path) def fake_subp(cmd, capture=None, retry_sleeps=None): if cmd == ["apt-get", "update"]: raise util.ProcessExecutionError( "Failure", stderr="Could not get lock /var/lib/dpkg/lock" ) return "", "" with contextlib.ExitStack() as stack: m_add_apt = stack.enter_context( mock.patch("uaclient.apt.add_auth_apt_repo") ) m_add_pinning = stack.enter_context( mock.patch("uaclient.apt.add_ppa_pinning") ) m_subp = stack.enter_context( mock.patch("uaclient.util.subp", side_effect=fake_subp) ) m_can_enable = stack.enter_context( mock.patch.object(entitlement, "can_enable") ) m_remove_apt_config = stack.enter_context( mock.patch.object(entitlement, "remove_apt_config") ) stack.enter_context( mock.patch(M_GETPLATFORM, return_value={"series": "trusty"}) ) stack.enter_context( mock.patch( M_REPOPATH + "os.path.exists", side_effect=fake_exists ) ) m_unlink = stack.enter_context( mock.patch("uaclient.apt.os.unlink") ) m_can_enable.return_value = True with pytest.raises(exceptions.UserFacingError) as excinfo: entitlement.enable() add_apt_calls = [ mock.call( "/etc/apt/sources.list.d/ubuntu-{}-trusty.list".format( entitlement.name ), "http://ESM-INFRA", "TOKEN", ["trusty"], entitlement.repo_key_file, ) ] subp_calls = [ mock.call( ["apt-get", "update"], capture=True, retry_sleeps=apt.APT_RETRIES, ) ] error_msg = "APT update failed. Another process is running APT." assert error_msg == excinfo.value.msg assert [mock.call(silent=mock.ANY)] == m_can_enable.call_args_list assert add_apt_calls == m_add_apt.call_args_list assert 0 == m_add_pinning.call_count assert subp_calls == m_subp.call_args_list unlink_calls = [ mock.call("/etc/apt/preferences.d/ubuntu-esm-infra-trusty") ] assert unlink_calls == m_unlink.call_args_list assert [mock.call()] == m_remove_apt_config.call_args_list class TestESMEntitlementDisable: @pytest.mark.parametrize("silent", [False, True]) @mock.patch("uaclient.util.get_platform_info") @mock.patch(M_PATH + "can_disable", return_value=False) def test_disable_returns_false_on_can_disable_false_and_does_nothing( self, m_can_disable, m_platform_info, silent ): """When can_disable is false disable returns false and noops.""" entitlement = ESMEntitlement({}) 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 assert 0 == m_remove_apt.call_count @mock.patch("uaclient.apt.restore_commented_apt_list_file") @mock.patch("uaclient.apt.remove_repo_from_apt_auth_file") @mock.patch( "uaclient.util.get_platform_info", return_value={"series": "trusty"} ) @mock.patch(M_PATH + "can_disable", return_value=True) def test_disable_removes_apt_config( self, m_can_disable, m_platform_info, m_rm_repo_from_auth, m_restore_commented_apt_list_file, entitlement, tmpdir, ): """When can_disable, disable removes apt configuration""" with mock.patch("uaclient.entitlements.repo.apt.run_apt_command"): with mock.patch("uaclient.util.subp"): with mock.patch("uaclient.util.write_file") as m_write: assert entitlement.disable(True) # Disable esm repo again write_calls = [ mock.call( "/etc/apt/preferences.d/ubuntu-esm-infra-trusty", "Package: *\nPin: release o=UbuntuESM, n=trusty\n" "Pin-Priority: never\n", ) ] assert write_calls == m_write.call_args_list assert [mock.call(True)] == m_can_disable.call_args_list auth_call = mock.call("http://ESM-INFRA") assert [auth_call] == m_rm_repo_from_auth.call_args_list assert [ mock.call("/etc/apt/sources.list.d/ubuntu-esm-infra-trusty.list") ] == m_restore_commented_apt_list_file.call_args_list ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/entitlements/tests/conftest.py0000664000000000000000000000430214601326072024314 0ustar import pytest from uaclient import config try: from typing import Any, Dict, List # noqa except ImportError: # typing isn't available on trusty, so ignore its absence pass def machine_token(entitlement_type: str) -> "Dict[str, Any]": return { "machineToken": "blah", "machineTokenInfo": { "contractInfo": { "resourceEntitlements": [ {"type": entitlement_type, "entitled": True} ] } }, } def machine_access( entitlement_type: str, *, affordances: "Dict[str, Any]" = None, directives: "Dict[str, Any]" = None, suites: "List[str]" = None ) -> "Dict[str, Any]": if affordances is None: affordances = {"series": []} # Will match all series if suites is None: suites = ["xenial"] if directives is None: directives = { "aptURL": "http://{}".format(entitlement_type.upper()), "aptKey": "APTKEY", "suites": suites, } return { "resourceToken": "TOKEN", "entitlement": { "obligations": {"enableByDefault": True}, "type": entitlement_type, "entitled": True, "directives": directives, "affordances": affordances, }, } @pytest.fixture def entitlement_factory(tmpdir): """ A pytest fixture that returns a function that instantiates an entitlement The function requires an entitlement class as its first argument, and takes keyword arguments for affordances, directives and suites which, if given, replace the default values in the machine-access-*.json file for the entitlement. """ def factory_func(cls, *, affordances=None, directives=None, suites=None): cfg = config.UAConfig(cfg={"data_dir": tmpdir.strpath}) cfg.write_cache("machine-token", machine_token(cls.name)) cfg.write_cache( "machine-access-{}".format(cls.name), machine_access( cls.name, affordances=affordances, directives=directives, suites=suites, ), ) return cls(cfg) return factory_func ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/entitlements/tests/test_cis.py0000664000000000000000000000610314601326072024305 0ustar """Tests related to uaclient.entitlement.base module.""" import mock import pytest from uaclient import apt from uaclient import status from uaclient.entitlements.cis import CISEntitlement M_REPOPATH = "uaclient.entitlements.repo." @pytest.fixture def entitlement(entitlement_factory): return entitlement_factory(CISEntitlement) class TestCISEntitlementCanEnable: def test_can_enable_true_on_entitlement_inactive( self, capsys, entitlement ): """When entitlement is INACTIVE, can_enable returns True.""" # Unset static affordance container check entitlement.static_affordances = () with mock.patch.object( entitlement, "application_status", return_value=(status.ApplicationStatus.DISABLED, ""), ): assert entitlement.can_enable() assert ("", "") == capsys.readouterr() class TestCISEntitlementEnable: @mock.patch("uaclient.util.subp") @mock.patch("uaclient.util.get_platform_info") def test_enable_configures_apt_sources_and_auth_files( self, m_platform_info, m_subp, capsys, entitlement ): """When entitled, configure apt repo auth token, pinning and url.""" def fake_platform(key=None): info = {"series": "xenial", "kernel": "4.15.0-00-generic"} if key: return info[key] return info m_platform_info.side_effect = fake_platform m_subp.return_value = ("fakeout", "") # Unset static affordance container check entitlement.static_affordances = () with mock.patch( M_REPOPATH + "os.path.exists", mock.Mock(return_value=True) ): with mock.patch("uaclient.apt.add_auth_apt_repo") as m_add_apt: with mock.patch("uaclient.apt.add_ppa_pinning") as m_add_pin: assert entitlement.enable() add_apt_calls = [ mock.call( "/etc/apt/sources.list.d/ubuntu-cis-audit-xenial.list", "http://CIS-AUDIT", "TOKEN", ["xenial"], entitlement.repo_key_file, ) ] subp_apt_cmds = [ mock.call( ["apt-cache", "policy"], capture=True, retry_sleeps=apt.APT_RETRIES, ), mock.call( ["apt-get", "update"], capture=True, retry_sleeps=apt.APT_RETRIES, ), mock.call( ["apt-get", "install", "--assume-yes"] + entitlement.packages, capture=True, retry_sleeps=apt.APT_RETRIES, ), ] assert add_apt_calls == m_add_apt.call_args_list # No apt pinning for cis-audit assert [] == m_add_pin.call_args_list assert subp_apt_cmds == m_subp.call_args_list expected_stdout = ( "Updating package lists\n" "Installing CIS Audit packages\n" "CIS Audit enabled\n" ) assert (expected_stdout, "") == capsys.readouterr() ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/entitlements/tests/test_base.py0000664000000000000000000004014314601326072024443 0ustar """Tests related to uaclient.entitlement.base module.""" import mock import pytest from uaclient import config from uaclient.entitlements import base from uaclient import status from uaclient import util from uaclient.status import ContractStatus try: from typing import Tuple # noqa except ImportError: pass class ConcreteTestEntitlement(base.UAEntitlement): name = "testconcreteentitlement" title = "Test Concrete Entitlement" description = "Entitlement for testing" def __init__( self, cfg=None, disable=None, enable=None, applicability_status=None, application_status=None, ): super().__init__(cfg) self._disable = disable self._enable = enable self._applicability_status = applicability_status self._application_status = application_status def disable(self): self._application_status = ( status.ApplicationStatus.DISABLED, "disable() called", ) return self._disable def enable(self, silent_if_inapplicable: bool = False): return self._enable def applicability_status(self): return self._applicability_status def application_status(self): return self._application_status @pytest.fixture def concrete_entitlement_factory(tmpdir): def factory( *, entitled: bool, applicability_status: "Tuple[status.ApplicabilityStatus, str]" = None, application_status: "Tuple[status.ApplicationStatus, str]" = None ) -> ConcreteTestEntitlement: cfg = config.UAConfig(cfg={"data_dir": tmpdir.strpath}) machineToken = { "machineToken": "blah", "machineTokenInfo": { "contractInfo": { "resourceEntitlements": [ { "type": "testconcreteentitlement", "entitled": entitled, } ] } }, } cfg.write_cache("machine-token", machineToken) cfg.write_cache( "machine-access-testconcreteentitlement", {"entitlement": {"entitled": entitled}}, ) return ConcreteTestEntitlement( cfg, applicability_status=applicability_status, application_status=application_status, ) return factory class TestUaEntitlement: def test_entitlement_abstract_class(self): """UAEntitlement is abstract requiring concrete methods.""" with pytest.raises(TypeError) as excinfo: base.UAEntitlement() expected_msg = ( "Can't instantiate abstract class UAEntitlement with abstract" " methods application_status, description, disable, enable, name," " title" ) assert expected_msg == str(excinfo.value) def test_init_default_sets_up_uaconfig(self): """UAEntitlement sets up a uaconfig instance upon init.""" entitlement = ConcreteTestEntitlement() assert "/var/lib/ubuntu-advantage" == entitlement.cfg.data_dir def test_init_accepts_a_uaconfig(self): """An instance of UAConfig can be passed to UAEntitlement.""" cfg = config.UAConfig(cfg={"data_dir": "/some/path"}) entitlement = ConcreteTestEntitlement(cfg) assert "/some/path" == entitlement.cfg.data_dir def test_can_disable_false_on_entitlement_inactive( self, capsys, concrete_entitlement_factory ): """When status is INACTIVE, can_disable returns False.""" entitlement = concrete_entitlement_factory( entitled=True, application_status=(status.ApplicationStatus.DISABLED, ""), ) assert not entitlement.can_disable() expected_stdout = ( "Test Concrete Entitlement is not currently enabled\n" "See: sudo ua status\n" ) stdout, _ = capsys.readouterr() assert expected_stdout == stdout def test_can_disable_true_on_entitlement_active( self, capsys, concrete_entitlement_factory ): """When entitlement is ENABLED, can_disable returns True.""" entitlement = concrete_entitlement_factory( entitled=True, application_status=(status.ApplicationStatus.ENABLED, ""), ) assert entitlement.can_disable() stdout, _ = capsys.readouterr() assert "" == stdout @pytest.mark.parametrize("silent", (True, False, None)) def test_can_enable_false_on_unentitled( self, capsys, concrete_entitlement_factory, silent ): """When entitlement contract is not enabled, can_enable is False.""" entitlement = concrete_entitlement_factory(entitled=False) kwargs = {} if silent is not None: kwargs["silent"] = silent assert not entitlement.can_enable(**kwargs) expected_stdout = ( "This subscription is not entitled to Test Concrete Entitlement.\n" "For more information see: https://ubuntu.com/advantage\n" ) if silent: expected_stdout = "" stdout, _ = capsys.readouterr() assert expected_stdout == stdout @pytest.mark.parametrize("silent", (True, False, None)) def test_can_enable_false_on_entitlement_active( self, capsys, concrete_entitlement_factory, silent ): """When entitlement is ENABLED, can_enable returns False.""" application_status = status.ApplicationStatus.ENABLED entitlement = concrete_entitlement_factory( entitled=True, application_status=(application_status, "") ) kwargs = {} if silent is not None: kwargs["silent"] = silent assert not entitlement.can_enable(**kwargs) expected_stdout = ( "Test Concrete Entitlement is already enabled.\n" "See: sudo ua status\n" ) if silent: expected_stdout = "" stdout, _ = capsys.readouterr() assert expected_stdout == stdout @pytest.mark.parametrize("silent", (True, False, None)) def test_can_enable_false_on_entitlement_inapplicable( self, capsys, concrete_entitlement_factory, silent ): """When entitlement is INAPPLICABLE, can_enable returns False.""" entitlement = concrete_entitlement_factory( entitled=True, applicability_status=( status.ApplicabilityStatus.INAPPLICABLE, "msg", ), application_status=(status.ApplicationStatus.DISABLED, ""), ) kwargs = {} if silent is not None: kwargs["silent"] = silent assert not entitlement.can_enable(**kwargs) expected_stdout = "msg\n" if silent: expected_stdout = "" stdout, _ = capsys.readouterr() assert expected_stdout == stdout @pytest.mark.parametrize("silent", (True, False, None)) def test_can_enable_true_on_entitlement_inactive( self, capsys, concrete_entitlement_factory, silent ): """When an entitlement is applicable and disabled, we can_enable""" entitlement = concrete_entitlement_factory( entitled=True, applicability_status=(status.ApplicabilityStatus.APPLICABLE, ""), application_status=(status.ApplicationStatus.DISABLED, ""), ) kwargs = {} if silent is not None: kwargs["silent"] = silent assert entitlement.can_enable(**kwargs) stdout, _ = capsys.readouterr() assert "" == stdout def test_contract_status_entitled(self, concrete_entitlement_factory): """The contract_status returns ENTITLED when entitlement enabled.""" entitlement = concrete_entitlement_factory(entitled=True) assert ContractStatus.ENTITLED == entitlement.contract_status() def test_contract_status_unentitled(self, concrete_entitlement_factory): """The contract_status returns NONE when entitlement is unentitled.""" entitlement = concrete_entitlement_factory(entitled=False) assert ContractStatus.UNENTITLED == entitlement.contract_status() @pytest.mark.parametrize( "orig_access,delta", (({}, {}), ({}, {"entitlement": {"entitled": False}})), ) def test_process_contract_deltas_does_nothing_on_empty_orig_access( self, concrete_entitlement_factory, orig_access, delta ): """When orig_acccess dict is empty perform no work.""" entitlement = concrete_entitlement_factory( entitled=True, applicability_status=(status.ApplicabilityStatus.APPLICABLE, ""), application_status=(status.ApplicationStatus.DISABLED, ""), ) expected = {"entitlement": {"entitled": True}} assert expected == entitlement.cfg.read_cache( "machine-access-testconcreteentitlement" ) entitlement.process_contract_deltas(orig_access, delta) # Cache was not cleaned assert expected == entitlement.cfg.read_cache( "machine-access-testconcreteentitlement" ) @pytest.mark.parametrize( "orig_access,delta", ( ({"entitlement": {"entitled": True}}, {}), # no deltas ( {"entitlement": {"entitled": False}}, {"entitlement": {"entitled": True}}, ), # transition to entitled ( {"entitlement": {"entitled": False}}, { "entitlement": { "entitled": False, # overridden True by series trusty "series": {"trusty": {"entitled": True}}, } }, ), ), ) @mock.patch( "uaclient.util.get_platform_info", return_value={"series": "trusty"} ) def test_process_contract_deltas_does_nothing_when_delta_remains_entitled( self, m_platform_info, concrete_entitlement_factory, orig_access, delta ): """If deltas do not represent transition to unentitled, do nothing.""" entitlement = concrete_entitlement_factory( entitled=True, applicability_status=(status.ApplicabilityStatus.APPLICABLE, ""), application_status=(status.ApplicationStatus.DISABLED, ""), ) expected = {"entitlement": {"entitled": True}} assert expected == entitlement.cfg.read_cache( "machine-access-testconcreteentitlement" ) entitlement.process_contract_deltas(orig_access, delta) # Cache was not cleaned assert expected == entitlement.cfg.read_cache( "machine-access-testconcreteentitlement" ) @pytest.mark.parametrize( "orig_access,delta", ( ( { "entitlement": {"entitled": True} }, # Full entitlement dropped {"entitlement": {"entitled": util.DROPPED_KEY}}, ), ( {"entitlement": {"entitled": True}}, {"entitlement": {"entitled": False}}, ), # transition to unentitled ), ) def test_process_contract_deltas_clean_cache_on_inactive_unentitled( self, concrete_entitlement_factory, orig_access, delta, caplog_text ): """Only clear cache when deltas transition inactive to unentitled.""" entitlement = concrete_entitlement_factory( entitled=True, application_status=(status.ApplicationStatus.DISABLED, ""), ) expected = {"entitlement": {"entitled": True}} assert expected == entitlement.cfg.read_cache( "machine-access-testconcreteentitlement" ) entitlement.process_contract_deltas(orig_access, delta) # Cache was cleaned assert None is entitlement.cfg.read_cache( "machine-access-testconcreteentitlement" ) # If an entitlement is disabled, we don't need to tell the user # anything about it becoming unentitled # (FIXME: Something on bionic means that DEBUG log lines are being # picked up by caplog_text(), so work around that here) assert [] == [ line for line in caplog_text().splitlines() if "DEBUG" not in line ] @pytest.mark.parametrize( "orig_access,delta", ( ( { "entitlement": {"entitled": True} }, # Full entitlement dropped {"entitlement": {"entitled": util.DROPPED_KEY}}, ), ( {"entitlement": {"entitled": True}}, {"entitlement": {"entitled": False}}, ), # transition to unentitled ), ) def test_process_contract_deltas_disable_clean_cache_on_active_unentitled( self, concrete_entitlement_factory, orig_access, delta ): """Disable and clear cache when transition active to unentitled.""" entitlement = concrete_entitlement_factory( entitled=True, application_status=(status.ApplicationStatus.ENABLED, ""), ) expected = {"entitlement": {"entitled": True}} assert expected == entitlement.cfg.read_cache( "machine-access-testconcreteentitlement" ) entitlement.process_contract_deltas(orig_access, delta) # Cache was cleaned assert None is entitlement.cfg.read_cache( "machine-access-testconcreteentitlement" ) assert ( status.ApplicationStatus.DISABLED, mock.ANY, ) == entitlement.application_status() class TestUaEntitlementUserFacingStatus: def test_inapplicable_when_not_applicable( self, concrete_entitlement_factory ): msg = "inapplicable for very good reasons" entitlement = concrete_entitlement_factory( entitled=True, applicability_status=( status.ApplicabilityStatus.INAPPLICABLE, msg, ), ) user_facing_status, details = entitlement.user_facing_status() assert status.UserFacingStatus.INAPPLICABLE == user_facing_status assert msg == details def test_unavailable_when_applicable_but_not_entitled( self, concrete_entitlement_factory ): entitlement = concrete_entitlement_factory( entitled=False, applicability_status=(status.ApplicabilityStatus.APPLICABLE, ""), ) 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 def test_unavailable_when_applicable_but_no_entitlement_cfg( self, concrete_entitlement_factory ): entitlement = concrete_entitlement_factory( entitled=False, applicability_status=(status.ApplicabilityStatus.APPLICABLE, ""), ) entitlement.cfg._entitlements = {} 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 @pytest.mark.parametrize( "application_status,expected_uf_status", ( (status.ApplicationStatus.ENABLED, status.UserFacingStatus.ACTIVE), ( status.ApplicationStatus.DISABLED, status.UserFacingStatus.INACTIVE, ), ), ) def test_application_status_used_if_not_inapplicable( self, concrete_entitlement_factory, application_status, expected_uf_status, ): msg = "application status details" entitlement = concrete_entitlement_factory( entitled=True, applicability_status=(status.ApplicabilityStatus.APPLICABLE, ""), application_status=(application_status, msg), ) user_facing_status, details = entitlement.user_facing_status() assert expected_uf_status == user_facing_status assert msg == details ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/entitlements/tests/test_fips.py0000664000000000000000000003431514601326072024476 0ustar """Tests related to uaclient.entitlement.base module.""" import contextlib import copy import itertools import mock import pytest from uaclient import apt from uaclient import defaults from uaclient import status, util from uaclient.entitlements.fips import ( FIPSCommonEntitlement, FIPSEntitlement, FIPSUpdatesEntitlement, ) from uaclient.entitlements.tests.conftest import machine_access from uaclient import exceptions M_PATH = "uaclient.entitlements.fips." M_REPOPATH = "uaclient.entitlements.repo." M_GETPLATFORM = M_REPOPATH + "util.get_platform_info" @pytest.fixture(params=[FIPSEntitlement, FIPSUpdatesEntitlement]) def entitlement(request, entitlement_factory): """Parameterized fixture so we apply all tests to both FIPS and Updates""" return entitlement_factory(request.param) class TestFIPSEntitlementCanEnable: def test_can_enable_true_on_entitlement_inactive( self, capsys, entitlement ): """When entitlement is disabled, can_enable returns True.""" with mock.patch.object( entitlement, "applicability_status", return_value=(status.ApplicabilityStatus.APPLICABLE, ""), ): with mock.patch.object( entitlement, "application_status", return_value=(status.ApplicationStatus.DISABLED, ""), ): assert True is entitlement.can_enable() assert ("", "") == capsys.readouterr() class TestFIPSEntitlementEnable: def test_enable_configures_apt_sources_and_auth_files(self, entitlement): """When entitled, configure apt repo auth token, pinning and url.""" patched_packages = ["a", "b"] with contextlib.ExitStack() as stack: m_add_apt = stack.enter_context( mock.patch("uaclient.apt.add_auth_apt_repo") ) m_add_pinning = stack.enter_context( mock.patch("uaclient.apt.add_ppa_pinning") ) m_subp = stack.enter_context( mock.patch("uaclient.util.subp", return_value=("", "")) ) m_can_enable = stack.enter_context( mock.patch.object(entitlement, "can_enable") ) stack.enter_context( mock.patch(M_GETPLATFORM, return_value={"series": "xenial"}) ) stack.enter_context(mock.patch(M_REPOPATH + "os.path.exists")) # Note that this patch uses a PropertyMock and happens on the # entitlement's type because packages is a property m_packages = mock.PropertyMock(return_value=patched_packages) stack.enter_context( mock.patch.object(type(entitlement), "packages", m_packages) ) m_can_enable.return_value = True assert True is entitlement.enable() repo_url = "http://{}".format(entitlement.name.upper()) add_apt_calls = [ mock.call( "/etc/apt/sources.list.d/ubuntu-{}-xenial.list".format( entitlement.name ), repo_url, "TOKEN", ["xenial"], entitlement.repo_key_file, ) ] apt_pinning_calls = [ mock.call( "/etc/apt/preferences.d/ubuntu-{}-xenial".format( entitlement.name ), repo_url, entitlement.origin, 1001, ) ] install_cmd = mock.call( ["apt-get", "install", "--assume-yes"] + patched_packages, capture=True, retry_sleeps=apt.APT_RETRIES, ) subp_calls = [ mock.call( ["apt-get", "update"], capture=True, retry_sleeps=apt.APT_RETRIES, ), install_cmd, ] assert [mock.call(silent=mock.ANY)] == m_can_enable.call_args_list assert add_apt_calls == m_add_apt.call_args_list assert apt_pinning_calls == m_add_pinning.call_args_list assert subp_calls == m_subp.call_args_list @mock.patch( "uaclient.util.get_platform_info", return_value={"series": "xenial"} ) def test_enable_returns_false_on_can_enable_false( self, m_platform_info, entitlement ): """When can_enable is false enable returns false and noops.""" with mock.patch.object(entitlement, "can_enable", return_value=False): assert False is entitlement.enable() assert 0 == m_platform_info.call_count @mock.patch("uaclient.apt.add_auth_apt_repo") @mock.patch( "uaclient.util.get_platform_info", return_value={"series": "xenial"} ) def test_enable_returns_false_on_missing_suites_directive( self, m_platform_info, m_add_apt, entitlement ): """When directives do not contain suites returns false.""" # Unset suites directive fips_entitled_no_suites = copy.deepcopy( machine_access(entitlement.name) ) fips_entitled_no_suites["entitlement"]["directives"]["suites"] = [] entitlement.cfg.write_cache( "machine-access-{}".format(entitlement.name), fips_entitled_no_suites, ) with mock.patch.object(entitlement, "can_enable", return_value=True): with pytest.raises(exceptions.UserFacingError) as excinfo: entitlement.enable() error_msg = "Empty {} apt suites directive from {}".format( entitlement.name, defaults.BASE_CONTRACT_URL ) assert error_msg == excinfo.value.msg assert 0 == m_add_apt.call_count def test_enable_errors_on_repo_pin_but_invalid_origin(self, entitlement): """Error when no valid origin is provided on a pinned entitlemnt.""" entitlement.origin = None # invalid value with contextlib.ExitStack() as stack: m_add_apt = stack.enter_context( mock.patch("uaclient.apt.add_auth_apt_repo") ) m_add_pinning = stack.enter_context( mock.patch("uaclient.apt.add_ppa_pinning") ) stack.enter_context(mock.patch.object(entitlement, "can_enable")) m_remove_apt_config = stack.enter_context( mock.patch.object(entitlement, "remove_apt_config") ) stack.enter_context( mock.patch(M_GETPLATFORM, return_value={"series": "xenial"}) ) stack.enter_context(mock.patch(M_REPOPATH + "os.path.exists")) with pytest.raises(exceptions.UserFacingError) as excinfo: entitlement.enable() error_msg = ( "Cannot setup apt pin. Empty apt repo origin value 'None'.\n" "Could not enable {}.".format(entitlement.title) ) assert error_msg == excinfo.value.msg assert 0 == m_add_apt.call_count assert 0 == m_add_pinning.call_count assert 0 == m_remove_apt_config.call_count def test_failure_to_install_doesnt_remove_packages(self, entitlement): def fake_subp(cmd, *args, **kwargs): if "install" in cmd: raise util.ProcessExecutionError(cmd) return ("", "") with contextlib.ExitStack() as stack: m_subp = stack.enter_context( mock.patch("uaclient.util.subp", side_effect=fake_subp) ) stack.enter_context( mock.patch.object(entitlement, "can_enable", return_value=True) ) stack.enter_context( mock.patch.object( entitlement, "setup_apt_config", return_value=True ) ) stack.enter_context( mock.patch(M_GETPLATFORM, return_value={"series": "xenial"}) ) stack.enter_context(mock.patch(M_REPOPATH + "os.path.exists")) with pytest.raises(exceptions.UserFacingError) as excinfo: entitlement.enable() error_msg = "Could not enable {}.".format(entitlement.title) assert error_msg == excinfo.value.msg for call in m_subp.call_args_list: assert "remove" not in call[0][0] def _fips_pkg_combinations(): """Construct all combinations of fips_packages and expected installs""" items = [ # These are the items that we will combine together (pkg_name, [pkg_name] + list(extra_pkgs)) for pkg_name, extra_pkgs in FIPSCommonEntitlement.fips_packages.items() ] # This produces combinations in all possible combination lengths combinations = itertools.chain.from_iterable( itertools.combinations(items, n) for n in range(1, len(items)) ) ret = [] # This for loop flattens each combination together in to a single # (installed_packages, expected_installs) item for combination in combinations: installed_packages, expected_installs = [], [] for pkg, installs in combination: installed_packages.append(pkg) expected_installs.extend(installs) ret.append((installed_packages, expected_installs)) return ret class TestFipsEntitlementPackages: @mock.patch(M_PATH + "apt.get_installed_packages", return_value=[]) def test_packages_is_list(self, _mock, entitlement): """RepoEntitlement.enable will fail if it isn't""" assert isinstance(entitlement.packages, list) @mock.patch(M_PATH + "apt.get_installed_packages", return_value=[]) def test_fips_required_packages_included(self, _mock, entitlement): """The fips_required_packages should always be in .packages""" assert entitlement.fips_required_packages.issubset( entitlement.packages ) @pytest.mark.parametrize( "installed_packages,expected_installs", _fips_pkg_combinations() ) @mock.patch(M_PATH + "apt.get_installed_packages") def test_currently_installed_packages_are_included_in_packages( self, m_get_installed_packages, entitlement, installed_packages, expected_installs, ): """If FIPS packages are already installed, upgrade them""" m_get_installed_packages.return_value = list(installed_packages) full_expected_installs = ( list(entitlement.fips_required_packages) + expected_installs ) assert full_expected_installs == entitlement.packages @mock.patch(M_PATH + "apt.get_installed_packages") def test_multiple_packages_calls_dont_mutate_state( self, m_get_installed_packages, entitlement ): # Make it appear like all packages are installed m_get_installed_packages.return_value.__contains__.return_value = True before = ( copy.deepcopy(entitlement.fips_required_packages), copy.deepcopy(entitlement.fips_packages), ) assert entitlement.packages after = ( copy.deepcopy(entitlement.fips_required_packages), copy.deepcopy(entitlement.fips_packages), ) assert before == after class TestFIPSEntitlementDisable: @pytest.mark.parametrize("silent", [False, True]) @mock.patch("uaclient.util.get_platform_info") def test_disable_returns_false_and_does_nothing( self, m_platform_info, entitlement, silent, capsys ): """When can_disable is false disable returns false and noops.""" with mock.patch("uaclient.apt.remove_auth_apt_repo") as m_remove_apt: assert False is entitlement.disable(silent) assert 0 == m_remove_apt.call_count expected_stdout = "" if not silent: expected_stdout = "Warning: no option to disable {}\n".format( entitlement.title ) assert (expected_stdout, "") == capsys.readouterr() class TestFIPSEntitlementApplicationStatus: @pytest.mark.parametrize( "super_application_status", [ s for s in status.ApplicationStatus if s is not status.ApplicationStatus.ENABLED ], ) def test_non_enabled_passed_through( self, entitlement, super_application_status ): msg = "sure is some status here" with mock.patch( M_PATH + "repo.RepoEntitlement.application_status", return_value=(super_application_status, msg), ): application_status = entitlement.application_status() assert (super_application_status, msg) == application_status @pytest.mark.parametrize( "platform_info,expected_status,expected_msg", ( ( {"kernel": "4.4.0-1002-fips"}, status.ApplicationStatus.ENABLED, None, ), ( {"kernel": "4.4.0-148-generic"}, status.ApplicationStatus.ENABLED, "Reboot to FIPS kernel required", ), ), ) def test_kernels_are_used_to_detemine_application_status_message( self, entitlement, platform_info, expected_status, expected_msg ): msg = "sure is some status here" with mock.patch( M_PATH + "repo.RepoEntitlement.application_status", return_value=(status.ApplicationStatus.ENABLED, msg), ): with mock.patch( M_PATH + "util.get_platform_info", return_value=platform_info ): application_status = entitlement.application_status() if expected_msg is None: # None indicates that we expect the super-class message to be # passed through expected_msg = msg assert (expected_status, expected_msg) == application_status def test_fips_does_not_show_enabled_when_fips_updates_is( self, entitlement ): with mock.patch(M_PATH + "util.subp") as m_subp: m_subp.return_value = ( "1001 http://FIPS-UPDATES/ubuntu" " xenial-updates/main amd64 Packages\n", "", ) application_status, _ = entitlement.application_status() expected_status = status.ApplicationStatus.DISABLED if isinstance(entitlement, FIPSUpdatesEntitlement): expected_status = status.ApplicationStatus.ENABLED assert expected_status == application_status ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/entitlements/tests/test_livepatch.py0000664000000000000000000005611214601326072025513 0ustar """Tests related to uaclient.entitlement.base module.""" import copy import logging import mock from types import MappingProxyType import pytest from uaclient import apt from uaclient import exceptions from uaclient.entitlements.livepatch import ( LivepatchEntitlement, process_config_directives, ) from uaclient.entitlements.tests.conftest import machine_token from uaclient import status from uaclient.status import ContractStatus PLATFORM_INFO_SUPPORTED = MappingProxyType( { "arch": "x86_64", "kernel": "4.4.0-00-generic", "series": "xenial", "version": "16.04 LTS (Xenial Xerus)", } ) M_PATH = "uaclient.entitlements.livepatch." # mock path M_BASE_PATH = "uaclient.entitlements.base.UAEntitlement." @pytest.fixture def entitlement(entitlement_factory): affordances = { "architectures": ["x86_64"], "minKernelVersion": "4.4", "kernelFlavors": ["generic", "lowlatency"], "tier": "stable", } directives = {"caCerts": "", "remoteServer": "https://alt.livepatch.com"} return entitlement_factory( LivepatchEntitlement, affordances=affordances, directives=directives ) class TestLivepatchContractStatus: def test_contract_status_entitled(self, entitlement): """The contract_status returns ENTITLED when entitled is True.""" assert ContractStatus.ENTITLED == entitlement.contract_status() def test_contract_status_unentitled(self, entitlement): """The contract_status returns NONE when entitled is False.""" entitlement.cfg.write_cache( "machine-access-livepatch", {"entitlement": {"entitled": False}} ) assert ContractStatus.UNENTITLED == entitlement.contract_status() class TestLivepatchUserFacingStatus: @mock.patch( "uaclient.entitlements.livepatch.util.is_container", return_value=False ) def test_user_facing_status_inapplicable_on_inapplicable_status( self, _m_is_container, entitlement ): """The user-facing details INAPPLICABLE applicability_status""" livepatch_bionic = entitlement.cfg.read_cache( "machine-access-livepatch" ) livepatch_bionic["entitlement"]["affordances"]["series"] = ["bionic"] entitlement.cfg.write_cache( "machine-access-livepatch", livepatch_bionic ) with mock.patch("uaclient.util.get_platform_info") as m_platform_info: m_platform_info.return_value = PLATFORM_INFO_SUPPORTED uf_status, details = entitlement.user_facing_status() assert uf_status == status.UserFacingStatus.INAPPLICABLE expected_details = ( "Livepatch is not available for Ubuntu 16.04 LTS" " (Xenial Xerus)." ) assert expected_details == details def test_user_facing_status_unavailable_on_unentitled(self, entitlement): """Status UNAVAILABLE on absent entitlement contract status.""" no_entitlements = machine_token(LivepatchEntitlement.name) # Delete livepatch entitlement info no_entitlements["machineTokenInfo"]["contractInfo"][ "resourceEntitlements" ].pop() entitlement.cfg.write_cache("machine-token", no_entitlements) with mock.patch("uaclient.util.get_platform_info") as m_platform_info: 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 class TestLivepatchProcessConfigDirectives: @pytest.mark.parametrize( "directive_key,livepatch_param_tmpl", (("remoteServer", "remote-server={}"), ("caCerts", "ca-certs={}")), ) def test_call_livepatch_config_command( self, directive_key, livepatch_param_tmpl ): """Livepatch config directives are passed to livepatch config.""" directive_value = "{}-value".format(directive_key) cfg = {"entitlement": {"directives": {directive_key: directive_value}}} with mock.patch("uaclient.util.subp") as m_subp: process_config_directives(cfg) expected_subp = mock.call( [ "/snap/bin/canonical-livepatch", "config", livepatch_param_tmpl.format(directive_value), ], capture=True, ) assert [expected_subp] == m_subp.call_args_list def test_handle_multiple_directives(self): """Handle multiple Livepatch directives using livepatch config.""" cfg = { "entitlement": { "directives": { "remoteServer": "value1", "caCerts": "value2", "ignored": "ignoredvalue", } } } with mock.patch("uaclient.util.subp") as m_subp: process_config_directives(cfg) expected_calls = [ mock.call( ["/snap/bin/canonical-livepatch", "config", "ca-certs=value2"], capture=True, ), mock.call( [ "/snap/bin/canonical-livepatch", "config", "remote-server=value1", ], capture=True, ), ] assert expected_calls == m_subp.call_args_list @pytest.mark.parametrize("directives", ({}, {"otherkey": "othervalue"})) def test_ignores_other_or_absent(self, directives): """Ignore empty or unexpected directives and do not call livepatch.""" cfg = {"entitlement": {"directives": directives}} with mock.patch("uaclient.util.subp") as m_subp: process_config_directives(cfg) assert 0 == m_subp.call_count @mock.patch( "uaclient.entitlements.livepatch.util.is_container", return_value=False ) class TestLivepatchEntitlementCanEnable: @pytest.mark.parametrize( "supported_kernel_ver", ("4.4.0-00-generic", "5.0.0-00-generic", "4.19.0-00-generic"), ) def test_can_enable_true_on_entitlement_inactive( self, _m_is_container, supported_kernel_ver, capsys, entitlement ): """When entitlement is INACTIVE, can_enable returns True.""" supported_kernel = copy.deepcopy(dict(PLATFORM_INFO_SUPPORTED)) supported_kernel["kernel"] = supported_kernel_ver with mock.patch("uaclient.util.get_platform_info") as m_platform: with mock.patch("uaclient.util.is_container") as m_container: m_platform.return_value = supported_kernel m_container.return_value = False assert entitlement.can_enable() assert ("", "") == capsys.readouterr() assert [mock.call()] == m_container.call_args_list def test_can_enable_false_on_unsupported_kernel_min_version( self, _m_is_container, capsys, entitlement ): """"False when on a kernel less or equal to minKernelVersion.""" unsupported_min_kernel = copy.deepcopy(dict(PLATFORM_INFO_SUPPORTED)) unsupported_min_kernel["kernel"] = "4.2.9-00-generic" with mock.patch("uaclient.util.get_platform_info") as m_platform: m_platform.return_value = unsupported_min_kernel entitlement = LivepatchEntitlement(entitlement.cfg) assert not entitlement.can_enable() msg = ( "Livepatch is not available for kernel 4.2.9-00-generic.\n" "Minimum kernel version required: 4.4\n" ) assert (msg, "") == capsys.readouterr() def test_can_enable_false_on_unsupported_kernel_flavor( self, _m_is_container, capsys, entitlement ): """"When on an unsupported kernel, can_enable returns False.""" unsupported_kernel = copy.deepcopy(dict(PLATFORM_INFO_SUPPORTED)) unsupported_kernel["kernel"] = "4.4.0-140-notgeneric" with mock.patch("uaclient.util.get_platform_info") as m_platform: m_platform.return_value = unsupported_kernel entitlement = LivepatchEntitlement(entitlement.cfg) assert not entitlement.can_enable() msg = ( "Livepatch is not available for kernel 4.4.0-140-notgeneric.\n" "Supported flavors are: generic, lowlatency\n" ) assert (msg, "") == capsys.readouterr() @pytest.mark.parametrize( "kernel_version,meets_min_version", ( ("3.5.0-00-generic", False), ("4.3.0-00-generic", False), ("4.4.0-00-generic", True), ("4.10.0-00-generic", True), ("5.0.0-00-generic", True), ), ) def test_can_enable_false_on_unsupported_min_kernel_version( self, _m_is_container, kernel_version, meets_min_version, capsys, entitlement, ): """"When on an unsupported kernel version, can_enable returns False.""" unsupported_kernel = copy.deepcopy(dict(PLATFORM_INFO_SUPPORTED)) unsupported_kernel["kernel"] = kernel_version with mock.patch("uaclient.util.get_platform_info") as m_platform: m_platform.return_value = unsupported_kernel entitlement = LivepatchEntitlement(entitlement.cfg) if meets_min_version: assert entitlement.can_enable() else: assert not entitlement.can_enable() if meets_min_version: msg = "" else: msg = ( "Livepatch is not available for kernel {}.\n" "Minimum kernel version required: 4.4\n".format(kernel_version) ) assert (msg, "") == capsys.readouterr() def test_can_enable_false_on_unsupported_architecture( self, _m_is_container, capsys, entitlement ): """"When on an unsupported architecture, can_enable returns False.""" unsupported_kernel = copy.deepcopy(dict(PLATFORM_INFO_SUPPORTED)) unsupported_kernel["arch"] = "ppc64le" with mock.patch("uaclient.util.get_platform_info") as m_platform: m_platform.return_value = unsupported_kernel assert not entitlement.can_enable() msg = ( "Livepatch is not available for platform ppc64le.\n" "Supported platforms are: x86_64\n" ) assert (msg, "") == capsys.readouterr() def test_can_enable_false_on_containers( self, m_is_container, capsys, entitlement ): """When is_container is True, can_enable returns False.""" unsupported_min_kernel = copy.deepcopy(dict(PLATFORM_INFO_SUPPORTED)) unsupported_min_kernel["kernel"] = "4.2.9-00-generic" with mock.patch("uaclient.util.get_platform_info") as m_platform: m_platform.return_value = unsupported_min_kernel m_is_container.return_value = True entitlement = LivepatchEntitlement(entitlement.cfg) assert not entitlement.can_enable() msg = "Cannot install Livepatch on a container\n" assert (msg, "") == capsys.readouterr() class TestLivepatchProcessContractDeltas: @mock.patch(M_PATH + "LivepatchEntitlement.setup_livepatch_config") def test_true_on_parent_process_deltas( self, m_setup_livepatch_config, entitlement ): """When parent's process_contract_deltas returns True do no setup.""" assert entitlement.process_contract_deltas({}, {}, False) assert [] == m_setup_livepatch_config.call_args_list @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( self, m_applicability_status, m_application_status, m_setup_livepatch_config, entitlement, ): """When livepatch is INACTIVE return True and do no setup.""" m_applicability_status.return_value = ( status.ApplicabilityStatus.APPLICABLE, "", ) m_application_status.return_value = ( status.ApplicationStatus.DISABLED, "", ) deltas = {"entitlement": {"directives": {"caCerts": "new"}}} assert entitlement.process_contract_deltas({}, deltas, False) assert [] == m_setup_livepatch_config.call_args_list @pytest.mark.parametrize( "directives,process_directives,process_token", ( ({"caCerts": "new"}, True, False), ({"remoteServer": "new"}, True, False), ({"unhandledKey": "new"}, False, False), ), ) @mock.patch(M_PATH + "LivepatchEntitlement.setup_livepatch_config") @mock.patch(M_PATH + "LivepatchEntitlement.application_status") def test_setup_performed_when_active_and_supported_deltas( self, m_application_status, m_setup_livepatch_config, entitlement, directives, process_directives, process_token, ): """Run setup when livepatch ACTIVE and deltas are supported keys.""" application_status = status.ApplicationStatus.ENABLED m_application_status.return_value = (application_status, "") deltas = {"entitlement": {"directives": directives}} assert entitlement.process_contract_deltas({}, deltas, False) if any([process_directives, process_token]): setup_calls = [ mock.call( process_directives=process_directives, process_token=process_token, ) ] else: setup_calls = [] assert setup_calls == m_setup_livepatch_config.call_args_list @pytest.mark.parametrize( "deltas,process_directives,process_token", ( ({"entitlement": {"something": 1}}, False, False), ({"resourceToken": "new"}, False, True), ), ) @mock.patch(M_PATH + "LivepatchEntitlement.setup_livepatch_config") @mock.patch(M_PATH + "LivepatchEntitlement.application_status") def test_livepatch_disable_and_setup_performed_when_resource_token_changes( self, m_application_status, m_setup_livepatch_config, entitlement, deltas, process_directives, process_token, ): """Run livepatch calls setup when resourceToken changes.""" application_status = status.ApplicationStatus.ENABLED m_application_status.return_value = (application_status, "") entitlement.process_contract_deltas({}, deltas, False) if any([process_directives, process_token]): setup_calls = [ mock.call( process_directives=process_directives, process_token=process_token, ) ] else: setup_calls = [] assert setup_calls == m_setup_livepatch_config.call_args_list class TestLivepatchEntitlementEnable: mocks_apt_update = [ mock.call(["apt-get", "update"], status.MESSAGE_APT_UPDATE_FAILED) ] mocks_snapd_install = [ mock.call( ["apt-get", "install", "--assume-yes", "snapd"], capture=True, retry_sleeps=apt.APT_RETRIES, ), mock.call( ["/usr/bin/snap", "wait", "system", "seed.loaded"], capture=True ), ] mocks_livepatch_install = [ mock.call( ["/usr/bin/snap", "install", "canonical-livepatch"], capture=True, retry_sleeps=[0.5, 1, 5], ) ] mocks_install = mocks_snapd_install + mocks_livepatch_install mocks_config = [ mock.call( [ "/snap/bin/canonical-livepatch", "config", "remote-server=https://alt.livepatch.com", ], capture=True, ), mock.call(["/snap/bin/canonical-livepatch", "disable"]), mock.call( ["/snap/bin/canonical-livepatch", "enable", "TOKEN"], capture=True ), ] @mock.patch(M_PATH + "LivepatchEntitlement.can_enable", return_value=False) def test_enable_false_when_can_enable_false( self, m_can_enable, caplog_text, capsys, entitlement ): """When can_enable returns False enable returns False.""" assert not entitlement.enable() assert "" == caplog_text() assert ("", "") == capsys.readouterr() # No additional prints assert [mock.call(silent=mock.ANY)] == m_can_enable.call_args_list @pytest.mark.parametrize("silent_if_inapplicable", (True, False, None)) @mock.patch(M_PATH + "LivepatchEntitlement.can_enable", return_value=False) def test_enable_passes_silent_if_inapplicable_through( self, m_can_enable, caplog_text, entitlement, silent_if_inapplicable ): """When can_enable returns False enable returns False.""" kwargs = {} if silent_if_inapplicable is not None: kwargs["silent_if_inapplicable"] = silent_if_inapplicable entitlement.enable(**kwargs) expected_call = mock.call(silent=bool(silent_if_inapplicable)) assert [expected_call] == m_can_enable.call_args_list @pytest.mark.parametrize("caplog_text", [logging.DEBUG], indirect=True) @pytest.mark.parametrize("apt_update_success", (True, False)) @mock.patch("uaclient.util.subp") @mock.patch("uaclient.apt.run_apt_command") @mock.patch("uaclient.util.which", return_value=False) @mock.patch(M_PATH + "LivepatchEntitlement.application_status") @mock.patch(M_PATH + "LivepatchEntitlement.can_enable", return_value=True) def test_enable_installs_snapd_and_livepatch_snap_when_absent( self, m_can_enable, m_app_status, m_which, m_run_apt, m_subp, capsys, caplog_text, entitlement, apt_update_success, ): """Install snapd and canonical-livepatch snap when not on system.""" application_status = status.ApplicationStatus.ENABLED m_app_status.return_value = application_status, "enabled" def fake_run_apt(cmd, message): if apt_update_success: return raise exceptions.UserFacingError("Apt go BOOM") m_run_apt.side_effect = fake_run_apt 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 msg = ( "Installing snapd\n" "Updating package lists\n" "Installing canonical-livepatch snap\n" "Canonical livepatch enabled.\n" ) assert (msg, "") == capsys.readouterr() expected_log = ( "DEBUG Trying to install snapd." " Ignoring apt-get update failure: Apt go BOOM" ) if apt_update_success: assert expected_log not in caplog_text() else: assert expected_log in caplog_text() expected_calls = [ mock.call("/snap/bin/canonical-livepatch"), mock.call("/usr/bin/snap"), ] assert expected_calls == m_which.call_args_list @mock.patch("uaclient.util.subp", return_value=("snapd", "")) @mock.patch( "uaclient.util.which", side_effect=lambda cmd: cmd == "/usr/bin/snap" ) @mock.patch(M_PATH + "LivepatchEntitlement.application_status") @mock.patch(M_PATH + "LivepatchEntitlement.can_enable", return_value=True) def test_enable_installs_only_livepatch_snap_when_absent_but_snapd_present( self, m_can_enable, m_app_status, m_which, m_subp, capsys, entitlement ): """Install canonical-livepatch snap when not present on the system.""" application_status = status.ApplicationStatus.ENABLED m_app_status.return_value = application_status, "enabled" assert entitlement.enable() assert ( self.mocks_livepatch_install + self.mocks_config in m_subp.call_args_list ) msg = ( "Installing canonical-livepatch snap\n" "Canonical livepatch enabled.\n" ) assert (msg, "") == capsys.readouterr() expected_calls = [ mock.call("/snap/bin/canonical-livepatch"), mock.call("/usr/bin/snap"), ] assert expected_calls == m_which.call_args_list @mock.patch("uaclient.util.subp") @mock.patch( "uaclient.util.which", side_effect=lambda cmd: cmd == "/usr/bin/snap" ) @mock.patch(M_PATH + "LivepatchEntitlement.application_status") @mock.patch(M_PATH + "LivepatchEntitlement.can_enable", return_value=True) def test_enable_bails_if_snap_cmd_exists_but_snapd_pkg_not_installed( self, m_can_enable, m_app_status, m_which, m_subp, capsys, entitlement ): """Install canonical-livepatch snap when not present on the system.""" m_app_status.return_value = status.ApplicationStatus.ENABLED, "enabled" with mock.patch( M_PATH + "apt.get_installed_packages", return_value=[] ): with pytest.raises(exceptions.UserFacingError) as excinfo: entitlement.enable() expected_msg = ( "/usr/bin/snap is present but snapd is not installed;" " cannot enable {}".format(entitlement.title) ) assert expected_msg == excinfo.value.msg @mock.patch("uaclient.util.subp") @mock.patch("uaclient.util.which", return_value="/found/livepatch") @mock.patch(M_PATH + "LivepatchEntitlement.application_status") @mock.patch(M_PATH + "LivepatchEntitlement.can_enable", return_value=True) def test_enable_does_not_install_livepatch_snap_when_present( self, m_can_enable, m_app_status, m_which, m_subp, capsys, entitlement ): """Do not attempt to install livepatch snap when it is present.""" application_status = status.ApplicationStatus.ENABLED m_app_status.return_value = application_status, "enabled" assert entitlement.enable() assert self.mocks_config == m_subp.call_args_list assert ("Canonical livepatch enabled.\n", "") == capsys.readouterr() @mock.patch("uaclient.util.subp") @mock.patch("uaclient.util.which", return_value="/found/livepatch") @mock.patch(M_PATH + "LivepatchEntitlement.application_status") @mock.patch(M_PATH + "LivepatchEntitlement.can_enable", return_value=True) def test_enable_does_not_disable_inactive_livepatch_snap_when_present( self, m_can_enable, m_app_status, m_which, m_subp, capsys, entitlement ): """Do not attempt to disable livepatch snap when it is inactive.""" m_app_status.return_value = status.ApplicationStatus.DISABLED, "nope" assert entitlement.enable() subp_no_livepatch_disable = [ mock.call( [ "/snap/bin/canonical-livepatch", "config", "remote-server=https://alt.livepatch.com", ], capture=True, ), mock.call( ["/snap/bin/canonical-livepatch", "enable", "TOKEN"], capture=True, ), ] assert subp_no_livepatch_disable == m_subp.call_args_list assert ("Canonical livepatch enabled.\n", "") == capsys.readouterr() ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/cli.py0000664000000000000000000004214714601326072017372 0ustar #!/usr/bin/env python """Client to manage Ubuntu Advantage services on a machine.""" import argparse from functools import wraps import json import logging import os import pathlib import sys import textwrap from uaclient import config from uaclient import contract from uaclient import entitlements from uaclient import exceptions from uaclient import status as ua_status from uaclient import util from uaclient import version NAME = "ua" USAGE_TMPL = "{name} {command} [flags]" EPILOG_TMPL = ( "Use {name} {command} --help for more information about a command." ) STATUS_HEADER_TMPL = """\ Account: {account} Subscription: {subscription} Valid until: {contract_expiry} Technical support level: {tech_support_level} """ UA_AUTH_TOKEN_URL = "https://auth.contracts.canonical.com" DEFAULT_LOG_FORMAT = ( "%(asctime)s - %(filename)s:(%(lineno)d) [%(levelname)s]: %(message)s" ) STATUS_FORMATS = ["tabular", "json"] def assert_root(f): """Decorator asserting root user""" @wraps(f) def new_f(*args, **kwargs): if os.getuid() != 0: raise exceptions.NonRootUserError() return f(*args, **kwargs) return new_f def assert_attached(unattached_msg_tmpl=None): """Decorator asserting attached config. :param unattached_msg_tmpl: Optional msg template to format if raising an UnattachedError """ def wrapper(f): @wraps(f) def new_f(args, cfg): if not cfg.is_attached: if unattached_msg_tmpl: name = getattr(args, "name", "None") msg = unattached_msg_tmpl.format(name=name) exception = exceptions.UnattachedError(msg) else: exception = exceptions.UnattachedError() raise exception return f(args, cfg) return new_f return wrapper def require_valid_entitlement_name(operation: str): """Decorator ensuring that args.name is a valid service. :param operation: the operation name to use in error messages """ def wrapper(f): @wraps(f) def new_f(args, cfg): if hasattr(args, "name"): name = args.name tmpl = ua_status.MESSAGE_INVALID_SERVICE_OP_FAILURE_TMPL if name not in entitlements.ENTITLEMENT_CLASS_BY_NAME: raise exceptions.UserFacingError( tmpl.format(operation=operation, name=name) ) return f(args, cfg) return new_f return wrapper def attach_parser(parser): """Build or extend an arg parser for attach subcommand.""" usage = USAGE_TMPL.format(name=NAME, command="attach ") parser.usage = usage parser.prog = "attach" parser._optionals.title = "Flags" parser.add_argument( "token", nargs="?", # action_attach asserts this required argument help="token obtained for Ubuntu Advantage authentication: {}".format( UA_AUTH_TOKEN_URL ), ) parser.add_argument( "--no-auto-enable", action="store_false", dest="auto_enable", help="do not enable any recommended services automatically", ) return parser def detach_parser(parser): """Build or extend an arg parser for detach subcommand.""" usage = USAGE_TMPL.format(name=NAME, command="detach") parser.usage = usage parser.prog = "detach" parser._optionals.title = "Flags" return parser def enable_parser(parser): """Build or extend an arg parser for enable subcommand.""" usage = USAGE_TMPL.format(name=NAME, command="enable") + " " parser.usage = usage parser.prog = "enable" parser._positionals.title = "Arguments" parser._optionals.title = "Flags" parser.add_argument( "name", action="store", help="the name of the Ubuntu Advantage service to enable", ) return parser def disable_parser(parser): """Build or extend an arg parser for disable subcommand.""" usage = USAGE_TMPL.format(name=NAME, command="disable") + " " parser.usage = usage parser.prog = "disable" parser._positionals.title = "Arguments" parser._optionals.title = "Flags" parser.add_argument( "name", action="store", help="the name of the Ubuntu Advantage service to disable", ) return parser def status_parser(parser): """Build or extend an arg parser for status subcommand.""" usage = USAGE_TMPL.format(name=NAME, command="status") parser.usage = usage parser.prog = "status" # This formatter_class ensures that our formatting below isn't lost parser.formatter_class = argparse.RawDescriptionHelpFormatter parser.description = textwrap.dedent( """\ Report current status of Ubuntu Advantage services on system. This shows whether this machine is attached to an Ubuntu Advantage support contract. When attached, the report includes the specific support contract details including contract name, expiry dates, and the status of each service on this system. The attached status output has four columns: * SERVICE: name of the service * ENTITLED: whether the contract to which this machine is attached entitles use of this service. Possible values are: yes or no * STATUS: whether the service is enabled on this machine. Possible values are: enabled, disabled, n/a (if your contract entitles you to the service, but it isn't available for this machine) or — (if you aren't entitled to this service) * DESCRIPTION: a brief description of the service The unattached status output instead has three columns. SERVICE and DESCRIPTION are the same as above, and there is the addition of: * AVAILABLE: whether this service would be available if this machine were attached. The possible values are yes or no. """ ) parser.add_argument( "--format", action="store", choices=STATUS_FORMATS, default=STATUS_FORMATS[0], help=( "output status in the specified format (default: {})".format( STATUS_FORMATS[0] ) ), ) parser._optionals.title = "Flags" return parser @assert_root @require_valid_entitlement_name("disable") @assert_attached(ua_status.MESSAGE_ENABLE_FAILURE_UNATTACHED_TMPL) def action_disable(args, cfg): """Perform the disable action on a named entitlement. @return: 0 on success, 1 otherwise """ ent_cls = entitlements.ENTITLEMENT_CLASS_BY_NAME[args.name] entitlement = ent_cls(cfg) ret = 0 if entitlement.disable() else 1 cfg.status() # Update the status cache return ret def _perform_enable( entitlement_name: str, cfg: config.UAConfig, *, silent_if_inapplicable: bool = False ) -> bool: """Perform the enable action on a named entitlement. (This helper excludes any messaging, so that different enablement code paths can message themselves.) :param entitlement_name: the name of the entitlement to enable :param cfg: the UAConfig to pass to the entitlement :param silent_if_inapplicable: don't output messages when determining if an entitlement can be enabled on this system @return: True on success, False otherwise """ ent_cls = entitlements.ENTITLEMENT_CLASS_BY_NAME[entitlement_name] entitlement = ent_cls(cfg) ret = entitlement.enable(silent_if_inapplicable=silent_if_inapplicable) cfg.status() # Update the status cache return ret @assert_root @require_valid_entitlement_name("enable") @assert_attached(ua_status.MESSAGE_ENABLE_FAILURE_UNATTACHED_TMPL) def action_enable(args, cfg): """Perform the enable action on a named entitlement. @return: 0 on success, 1 otherwise """ print(ua_status.MESSAGE_REFRESH_ENABLE) try: contract.request_updated_contract(cfg) except (util.UrlError, exceptions.UserFacingError): # Inability to refresh is not a critical issue during enable logging.debug(ua_status.MESSAGE_REFRESH_FAILURE, exc_info=True) return 0 if _perform_enable(args.name, cfg) else 1 @assert_root @assert_attached() def action_detach(args, cfg): """Perform the detach action for this machine. @return: 0 on success, 1 otherwise """ to_disable = [] for ent_cls in entitlements.ENTITLEMENT_CLASSES: ent = ent_cls(cfg) if ent.can_disable(silent=True): to_disable.append(ent) if to_disable: suffix = "s" if len(to_disable) > 1 else "" print("Detach will disable the following service{}:".format(suffix)) for ent in to_disable: print(" {}".format(ent.name)) if not util.prompt_for_confirmation(): return 1 for ent in to_disable: ent.disable(silent=True) cfg.delete_cache() print(ua_status.MESSAGE_DETACH_SUCCESS) return 0 def action_attach(args, cfg): if cfg.is_attached: print( "This machine is already attached to '{}'.".format( cfg.accounts[0]["name"] ) ) return 0 if os.getuid() != 0: raise exceptions.NonRootUserError() if not args.token: raise exceptions.UserFacingError( ua_status.MESSAGE_ATTACH_REQUIRES_TOKEN ) try: contract.request_updated_contract( cfg, args.token, allow_enable=args.auto_enable ) except util.UrlError as exc: with util.disable_log_to_console(): logging.exception(exc) print(ua_status.MESSAGE_ATTACH_FAILURE) return 1 except exceptions.UserFacingError as exc: logging.warning(exc.msg) return 1 contract_name = cfg.machine_token["machineTokenInfo"]["contractInfo"][ "name" ] print( ua_status.MESSAGE_ATTACH_SUCCESS_TMPL.format( contract_name=contract_name ) ) action_status(args=None, cfg=cfg) return 0 def get_parser(): service_line_tmpl = " - {name}: {description}{url}" description_lines = [__doc__] sorted_classes = sorted(entitlements.ENTITLEMENT_CLASS_BY_NAME.items()) for name, ent_cls in sorted_classes: if ent_cls.help_doc_url: url = " ({})".format(ent_cls.help_doc_url) else: url = "" service_line = service_line_tmpl.format( name=name, description=ent_cls.description, url=url ) if len(service_line) <= 80: description_lines.append(service_line) else: wrapped_words = [] line = service_line while len(line) > 80: [line, wrapped_word] = line.rsplit(" ", 1) wrapped_words.insert(0, wrapped_word) description_lines.extend([line, " " + " ".join(wrapped_words)]) parser = argparse.ArgumentParser( prog=NAME, formatter_class=argparse.RawDescriptionHelpFormatter, description="\n".join(description_lines), usage=USAGE_TMPL.format(name=NAME, command="[command]"), epilog=EPILOG_TMPL.format(name=NAME, command="[command]"), ) parser.add_argument( "--debug", action="store_true", help="show all debug log messages to console", ) parser._optionals.title = "Flags" subparsers = parser.add_subparsers( title="Available Commands", dest="command", metavar="" ) subparsers.required = True parser_status = subparsers.add_parser( "status", help="current status of all Ubuntu Advantage services" ) parser_status.set_defaults(action=action_status) status_parser(parser_status) parser_attach = subparsers.add_parser( "attach", help="attach this machine to an Ubuntu Advantage subscription", ) attach_parser(parser_attach) parser_attach.set_defaults(action=action_attach) parser_detach = subparsers.add_parser( "detach", help="remove this machine from an Ubuntu Advantage subscription", ) detach_parser(parser_detach) parser_detach.set_defaults(action=action_detach) parser_enable = subparsers.add_parser( "enable", help="enable a specific Ubuntu Advantage service on this machine", ) enable_parser(parser_enable) parser_enable.set_defaults(action=action_enable) parser_disable = subparsers.add_parser( "disable", help="disable a specific Ubuntu Advantage service on this machine", ) disable_parser(parser_disable) parser_disable.set_defaults(action=action_disable) parser_refresh = subparsers.add_parser( "refresh", help="refresh Ubuntu Advantage services from contracts server", ) parser_refresh.set_defaults(action=action_refresh) parser_version = subparsers.add_parser( "version", help="show version of {}".format(NAME) ) parser_version.set_defaults(action=print_version) parser_help = subparsers.add_parser( "help", help="show this help message and exit" ) parser_help.set_defaults(action=action_help) return parser def action_status(args, cfg): if not cfg: cfg = config.UAConfig() if args and args.format == "json": status = cfg.status() if status["expires"] != ua_status.UserFacingStatus.INAPPLICABLE.value: status["expires"] = str(status["expires"]) print(json.dumps(status)) else: output = ua_status.format_tabular(cfg.status()) # Replace our Unicode dash with an ASCII dash if we aren't going to be # writing to a utf-8 output; see # https://github.com/CanonicalLtd/ubuntu-advantage-client/issues/859 if ( sys.stdout.encoding is None or "UTF-8" not in sys.stdout.encoding.upper() ): output = output.replace("\u2014", "-") print(output) return 0 def print_version(_args=None, _cfg=None): print(version.get_version()) @assert_root @assert_attached() def action_refresh(args, cfg): try: contract.request_updated_contract(cfg) except util.UrlError as exc: with util.disable_log_to_console(): logging.exception(exc) raise exceptions.UserFacingError(ua_status.MESSAGE_REFRESH_FAILURE) print(ua_status.MESSAGE_REFRESH_SUCCESS) return 0 def action_help(_args, _cfg): get_parser().print_help() return 0 def setup_logging(console_level, log_level, log_file=None): """Setup console logging and debug logging to log_file""" if log_file is None: log_file = config.CONFIG_DEFAULTS["log_file"] console_formatter = util.LogFormatter() log_formatter = logging.Formatter(DEFAULT_LOG_FORMAT) root = logging.getLogger() root.setLevel(log_level) # Setup console logging stderr_found = False for handler in root.handlers: if hasattr(handler, "stream") and hasattr(handler.stream, "name"): if handler.stream.name == "": handler.setLevel(console_level) handler.setFormatter(console_formatter) handler.set_name("console") # Used to disable console logging stderr_found = True break if not stderr_found: console = logging.StreamHandler(sys.stderr) console.setFormatter(console_formatter) console.setLevel(console_level) console.set_name("console") # Used to disable console logging root.addHandler(console) 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) filehandler = logging.FileHandler(log_file) filehandler.setLevel(log_level) filehandler.setFormatter(log_formatter) root.addHandler(filehandler) def main_error_handler(func): def wrapper(*args, **kwargs): try: return func(*args, **kwargs) except KeyboardInterrupt: with util.disable_log_to_console(): logging.exception("KeyboardInterrupt") print("Interrupt received; exiting.", file=sys.stderr) sys.exit(1) except util.UrlError as exc: with util.disable_log_to_console(): logging.exception(exc) print(ua_status.MESSAGE_CONNECTIVITY_ERROR, file=sys.stderr) sys.exit(1) except exceptions.UserFacingError as exc: with util.disable_log_to_console(): logging.exception(exc.msg) print("{}".format(exc.msg), file=sys.stderr) sys.exit(1) return wrapper @main_error_handler def main(sys_argv=None): if not sys_argv: sys_argv = sys.argv parser = get_parser() cli_arguments = sys_argv[1:] if not cli_arguments: parser.print_usage() print("Try 'ua --help' for more information.") sys.exit(1) args = parser.parse_args(args=cli_arguments) cfg = config.UAConfig() log_level = cfg.log_level console_level = logging.DEBUG if args.debug else logging.INFO setup_logging(console_level, log_level, cfg.log_file) return args.action(args, cfg) if __name__ == "__main__": sys.exit(main()) ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/apt.py0000664000000000000000000003135615223224541017406 0ustar import glob import logging import os import re import subprocess import tempfile from uaclient import exceptions from uaclient import gpg from uaclient import status from uaclient import util try: from typing import List # noqa except ImportError: # typing isn't available on trusty, so ignore its absence pass APT_HELPER_TIMEOUT = 20.0 # 20 second timeout used for apt-helper call APT_AUTH_COMMENT = " # ubuntu-advantage-tools" APT_CONFIG_AUTH_FILE = "Dir::Etc::netrc/" APT_CONFIG_AUTH_PARTS_DIR = "Dir::Etc::netrcparts/" APT_CONFIG_LISTS_DIR = "Dir::State::lists/" APT_KEYS_DIR = "/etc/apt/trusted.gpg.d" KEYRINGS_DIR = "/usr/share/keyrings" APT_METHOD_HTTPS_FILE = "/usr/lib/apt/methods/https" CA_CERTIFICATES_FILE = "/usr/sbin/update-ca-certificates" # Since we generally have a person at the command line prompt. Don't loop # for 5 minutes like charmhelpers because we expect the human to notice and # resolve to apt conflict or try again. # Hope for an optimal first try. APT_RETRIES = [1.0, 5.0, 10.0] _UNSAFE_APT_CHARS = re.compile(r"[\n\r\x00 ]") def assert_valid_apt_directives(repo_url, suites): """Validate that apt directive values contain no injected content. @param repo_url: The apt repository URL from contract directives. @param suites: List of suite strings from contract directives. @raises: UserFacingError if any value contains newline, carriage return, or null byte characters which could inject additional apt sources. """ if _UNSAFE_APT_CHARS.search(repo_url): raise exceptions.UserFacingError( "APT repository URL directive contains invalid characters" ) for suite in suites: if _UNSAFE_APT_CHARS.search(suite): raise exceptions.UserFacingError( "APT suite directive contains invalid characters" ) def assert_valid_apt_credentials(repo_url, username, password): """Validate apt credentials for a PPA. @param repo_url: private-ppa url path @param username: PPA login username. @param password: PPA login password or resource token. @raises: UserFacingError for invalid credentials, timeout or unexpected errors. """ if not os.path.exists("/usr/lib/apt/apt-helper"): return protocol, repo_path = repo_url.split("://") apt_auth_file = get_apt_auth_file_from_apt_config() auth_path = apt_auth_file + "-validation" if not repo_path.endswith("/"): repo_path_slash = repo_path + "/" else: repo_path_slash = repo_path auth_line = ( "machine {repo_path} login {username}" " password {password}\n".format( repo_path=repo_path_slash, username=username, password=password, ) ) try: util.write_file(auth_path, auth_line, mode=0o600) with tempfile.TemporaryDirectory() as tmpd: util.subp( [ "/usr/lib/apt/apt-helper", "download-file", "{}://{}/ubuntu/pool/".format(protocol, repo_path), os.path.join(tmpd, "apt-helper-output"), ], timeout=APT_HELPER_TIMEOUT, ) except util.ProcessExecutionError as e: if e.exit_code == 100: stderr = str(e.stderr).lower() if re.search(r"401\s+unauthorized|httperror401", stderr): raise exceptions.UserFacingError( "Invalid APT credentials provided for {}".format(repo_url) ) elif re.search(r"connection timed out", stderr): raise exceptions.UserFacingError( "Timeout trying to access APT repository at {}".format( repo_url ) ) raise exceptions.UserFacingError( "Unexpected APT error. See /var/log/ubuntu-advantage.log" ) except subprocess.TimeoutExpired: raise exceptions.UserFacingError( "Cannot validate credentials for APT repo." " Timeout after {} seconds trying to reach {}.".format( APT_HELPER_TIMEOUT, repo_path ) ) finally: util.del_file(auth_path) def run_apt_command(cmd, error_msg) -> str: """Run an apt command, retrying upon failure APT_RETRIES times. :return: stdout from successful run of the apt command. :raise UserFacingError: on issues running apt-cache policy. """ try: out, _err = util.subp(cmd, capture=True, retry_sleeps=APT_RETRIES) except util.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.UserFacingError(error_msg) return out def add_auth_apt_repo( repo_filename: str, repo_url: str, credentials: str, suites: "List[str]", keyring_file: str, ) -> None: """Add an authenticated apt repo and credentials to the system. @raises: InvalidAPTCredentialsError when the token provided can't access the repo PPA. """ try: username, password = credentials.split(":") except ValueError: # Then we have a bearer token username = "bearer" password = credentials series = util.get_platform_info()["series"] if repo_url.endswith("/"): repo_url = repo_url[:-1] assert_valid_apt_directives(repo_url, suites) assert_valid_apt_credentials(repo_url, username, password) # Does this system have updates suite enabled? updates_enabled = False policy = run_apt_command( ["apt-cache", "policy"], status.MESSAGE_APT_POLICY_FAILED ) for line in policy.splitlines(): # We only care about $suite-updates lines if "a={}-updates".format(series) not in line: continue # We only care about $suite-updates from the Ubuntu archive if "o=Ubuntu," not in line: continue updates_enabled = True break content = "" for suite in suites: if series not in suite: continue # Only enable suites matching this current series maybe_comment = "" if "-updates" in suite and not updates_enabled: logging.debug( 'Not enabling apt suite "%s" because "%s-updates" is not' " enabled", suite, series, ) maybe_comment = "# " content += ( "{maybe_comment}deb {url}/ubuntu {suite} main\n" "# deb-src {url}/ubuntu {suite} main\n".format( maybe_comment=maybe_comment, url=repo_url, suite=suite ) ) util.write_file(repo_filename, content) add_apt_auth_conf_entry(repo_url, username, password) source_keyring_file = os.path.join(KEYRINGS_DIR, keyring_file) destination_keyring_file = os.path.join(APT_KEYS_DIR, keyring_file) gpg.export_gpg_key(source_keyring_file, destination_keyring_file) def add_apt_auth_conf_entry(repo_url, login, password): """Add or replace an apt auth line in apt's auth.conf file or conf.d.""" apt_auth_file = get_apt_auth_file_from_apt_config() _protocol, repo_path = repo_url.split("://") if repo_path.endswith("/"): # strip trailing slash repo_path = repo_path[:-1] if os.path.exists(apt_auth_file): orig_content = util.load_file(apt_auth_file) else: orig_content = "" repo_auth_line = ( "machine {repo_path}/ login {login} password {password}" "{cmt}".format( repo_path=repo_path, login=login, password=password, cmt=APT_AUTH_COMMENT, ) ) added_new_auth = False new_lines = [] for line in orig_content.splitlines(): machine_match = re.match(r"machine\s+(?P[.\-\w]+)/?.*", line) if machine_match: matched_repo = machine_match.group("repo_url") if matched_repo == repo_path: # Replace old auth with new auth at same line new_lines.append(repo_auth_line) added_new_auth = True continue if matched_repo in repo_path: # Insert our repo before. We are a more specific apt repo match new_lines.append(repo_auth_line) added_new_auth = True new_lines.append(line) if not added_new_auth: new_lines.append(repo_auth_line) new_lines.append("") util.write_file(apt_auth_file, "\n".join(new_lines), mode=0o600) def remove_repo_from_apt_auth_file(repo_url): """Remove a repo from the shared apt auth file""" _protocol, repo_path = repo_url.split("://") if repo_path.endswith("/"): # strip trailing slash repo_path = repo_path[:-1] apt_auth_file = get_apt_auth_file_from_apt_config() if os.path.exists(apt_auth_file): apt_auth = util.load_file(apt_auth_file) auth_prefix = "machine {repo_path}/ login".format(repo_path=repo_path) content = "\n".join( [line for line in apt_auth.splitlines() if auth_prefix not in line] ) if not content: os.unlink(apt_auth_file) else: util.write_file(apt_auth_file, content, mode=0o600) def remove_auth_apt_repo( repo_filename: str, repo_url: str, keyring_file: str = None ) -> None: """Remove an authenticated apt repo and credentials to the system""" util.del_file(repo_filename) if keyring_file: keyring_file = os.path.join(APT_KEYS_DIR, keyring_file) util.del_file(keyring_file) remove_repo_from_apt_auth_file(repo_url) def restore_commented_apt_list_file(filename: str) -> None: """Uncomment commented deb lines in the given file.""" file_content = util.load_file(filename) file_content = file_content.replace("# deb ", "deb ") util.write_file(filename, file_content) def add_ppa_pinning(apt_preference_file, repo_url, origin, priority): """Add an apt preferences file and pin for a PPA.""" series = util.get_platform_info()["series"] _protocol, repo_path = repo_url.split("://") if repo_path.endswith("/"): # strip trailing slash repo_path = repo_path[:-1] content = ( "Package: *\n" "Pin: release o={origin}, n={series}\n" "Pin-Priority: {priority}\n".format( origin=origin, priority=priority, series=series ) ) util.write_file(apt_preference_file, content) def get_apt_auth_file_from_apt_config(): """Return to patch to the system configured APT auth file.""" out, _err = util.subp( ["apt-config", "shell", "key", APT_CONFIG_AUTH_PARTS_DIR] ) if out: # then auth.conf.d parts is present return out.split("'")[1] + "90ubuntu-advantage" else: # then use configured /etc/apt/auth.conf out, _err = util.subp( ["apt-config", "shell", "key", APT_CONFIG_AUTH_FILE] ) return out.split("'")[1].rstrip("/") def find_apt_list_files(repo_url, series): """List any apt files in APT_CONFIG_LISTS_DIR given repo_url and series.""" _protocol, repo_path = repo_url.split("://") if repo_path.endswith("/"): # strip trailing slash repo_path = repo_path[:-1] lists_dir = "/var/lib/apt/lists" out, _err = util.subp(["apt-config", "shell", "key", APT_CONFIG_LISTS_DIR]) if out: # then lists dir is present in config lists_dir = out.split("'")[1] aptlist_filename = repo_path.replace("/", "_") return sorted( glob.glob( os.path.join( lists_dir, aptlist_filename + "_dists_{}*".format(series) ) ) ) def remove_apt_list_files(repo_url, series): """Remove any apt list files present for this repo_url and series.""" for path in find_apt_list_files(repo_url, series): if os.path.exists(path): os.unlink(path) def clean_apt_sources(*, _entitlements=None): """ Clean apt sources list files written by uaclient :param _entitlements: The uaclient.entitlements module to use, defaults to uaclient.entitlements. (This is only present for testing, because the import happens within the function to avoid circular imports.) """ if _entitlements is None: from uaclient import entitlements as _entitlements for ent_cls in _entitlements.ENTITLEMENT_CLASSES: if not hasattr(ent_cls, "repo_url"): continue repo_list_glob = ent_cls.repo_list_file_tmpl.format( name=ent_cls.name, series="*" ) # Remove list files for path in glob.glob(repo_list_glob): logging.info("Removing apt source file: %s", path) os.unlink(path) def get_installed_packages() -> "List[str]": out, _ = util.subp(["dpkg-query", "-W", "--showformat=${Package}\\n"]) return out.splitlines() ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/testing/0000775000000000000000000000000014601326072017716 5ustar ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/testing/__init__.py0000664000000000000000000000000014072115230022007 0ustar ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/testing/fakes.py0000664000000000000000000000546714601326072021375 0ustar import json from uaclient.config import UAConfig from uaclient.contract import UAContractClient from uaclient.util import DatetimeAwareJSONDecoder, DatetimeAwareJSONEncoder try: from typing import Any, Dict, Optional # noqa: F401 except ImportError: # typing isn't available on trusty, so ignore its absence pass class FakeContractClient(UAContractClient): _requests = [] _responses = {} def __init___(self, cfg, responses=None): super().__init__(cfg) if responses: self._responses = responses def request_url(self, path, data=None, headers=None, method=None): request = { "path": path, "data": data, "headers": headers, "method": method, } self._requests.append(request) # Return a response if we have one or empty response = self._responses.get(path, {}) if isinstance(response, Exception): raise response return response, {"header1": ""} class FakeConfig(UAConfig): def __init__(self, cache_contents: "Dict[str, Any]" = None) -> None: self._cache_contents = {} if cache_contents: self._cache_contents = { k: json.dumps(v, cls=DatetimeAwareJSONEncoder) for k, v in cache_contents.items() } super().__init__({}) def _perform_delete(self, cache_path: str) -> None: pass def delete_cache_key(self, key: str) -> None: super().delete_cache_key(key) if key in self._cache_contents: del self._cache_contents[key] def read_cache(self, key: str, silent: bool = False) -> "Optional[str]": value = self._cache_contents.get(key) if value: value = json.loads(value, cls=DatetimeAwareJSONDecoder) return value def write_cache( self, key: str, content: "Any", private: bool = True ) -> None: content = json.dumps(content, cls=DatetimeAwareJSONEncoder) if private: self._cache_contents[key] = content else: self._cache_contents["public-" + key] = content @classmethod def for_attached_machine( cls, account_name: str = "test_account", machine_token: "Dict[str, Any]" = None, ): value = { "machine-token": { "machineToken": "not-null", "machineTokenInfo": { "accountInfo": {"id": "acct-1", "name": account_name}, "contractInfo": { "id": "cid", "name": "test_contract", "resourceEntitlements": [], }, }, } } if machine_token: value["machine-token"] = machine_token return cls(value) ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/testing/sample-contract-api.yaml0000664000000000000000000001743214072115230024446 0ustar # Copy this into https://editor.swagger.io/ for a simple API representation swagger: '2.0' info: version: '1.0.0' title: Ubuntu Advantage Service API description: Backend API services for the Ubuntu Advantage entitlement management contact: name: Canonical Support email: support@canonical.com url: http://support.canonical.com/ host: ua-entitlements.canonical.com schemes: - https consumes: - application/json produces: - application/json paths: /dashboard/macaroon: post: summary: Generate a 3rd party macaroon for discharge with Ubuntu SSO description: | Create a root macaroon with an encrypted third party caveat This macaroon will need to be discharged @ login.ubuntu.com/api/v2/tokens/discharge parameters: - in: body name: uamacaroon schema: $ref: '#/definitions/reqUAMacaroon' responses: 200: description: Successful macaroon response for USSO discharge schema: $ref: '#/definitions/respUAMacaroon' 500: description: Unexpected error schema: $ref: '#/definitions/respError' /contract/machine/attach: post: summary: Attach a machine to an existing UA account parameters: - in: body name: machine schema: $ref: '#/definitions/reqMachine' responses: 200: description: Machine-Token response, existing machine-token is returned if already attached schema: $ref: '#/definitions/respMachineToken' 403: description: "Unauthorized error. This will occur if the user-token isn't known." schema: $ref: '#/definitions/respError' '409': description: "Conflict error due to a machine-token already existing for this machine-id but the machine-id is part of another account." schema: $ref: '#/definitions/respError' 500: description: Unexpected error schema: $ref: '#/definitions/respError' /contract/entitlement/enable: post: summary: Enable a specific Ubuntu Advantage entitlement on a machine parameters: - in: body name: entitlement schema: $ref: '#/definitions/reqEntitlementChange' responses: 200: description: Optional pointer to URL of livepatch, or empty document if there is none. schema: $ref: '#/definitions/respSuccessEnable' 403: description: "Unauthorized error. This will occur if the Machine-Token isn't known." schema: $ref: '#/definitions/respError' 500: description: Unexpected error schema: $ref: '#/definitions/respError' /account/machine/entitlements: post: summary: Get the status of all entitlements authorized for a machine parameters: - in: body name: entitlement schema: $ref: '#/definitions/reqStatus' responses: 200: description: Optional pointer to URL of livepatch, or empty document if there is none. schema: $ref: '#/definitions/respSuccessStatus' 403: description: "Unauthorized error. This will occur if the Machine-Token isn't known." schema: $ref: '#/definitions/respError' 500: description: Unexpected error schema: $ref: '#/definitions/respError' /contract/entitlement/disable: post: summary: Disable a specific entitlement on a machine parameters: - in: body name: entitlement schema: $ref: '#/definitions/reqEntitlementChange' responses: 200: description: Disabed the requested entitlement for this machine schema: $ref: '#/definitions/respSuccess' 403: description: Unauthorized error. Occurs if the Machine-Token isn't known. schema: $ref: '#/definitions/respError' 500: description: Unexpected error schema: $ref: '#/definitions/respError' definitions: reqUAMacaroon: type: object required: - permissions properties: permissions: type: array items: type: string enum: - CREATE_USER_TOKEN - CREATE_MACHINE_TOKEN - ENABLE_ENTITLEMENT - DISABLE_ENTITLEMENT reqEntitlementChange: type: object required: - entitlement-name - machine-id - machine-token properties: entitlement-name: type: string enum: - esm - livepatch - fips - fips-updates description: Name of the unique UA entitilements supported machine-id: type: string description: A stable identifier from /etc/machine-id of the Ubuntu machine running UA client entitlements. machine-token: type: string description: This token is obtained from Contract API via a /contract/machine/attach and authenticates the machine in all Contract API interactions. reqStatus: type: object required: - machine-id - machine-token properties: machine-id: type: string description: A stable identifier from /etc/machine-id of the Ubuntu machine running UA client entitlements. machine-token: type: string description: This token is obtained from Contract API via a /contract/machine/attach and authenticates the machine in all Contract API interactions. reqMachine: type: object required: - user-token - machine-id properties: user-token: type: string description: This token is obtained from UA Dashboard and identifies the user that should be used to generate a machine-token. machine-id: type: string description: A stable identifier from /etc/machine-id of the Ubuntu machine running UA client entitlements. respUAMacaroon: type: object required: - macaroon properties: macaroon: type: string description: Serialized Macaroon containing a third party caveat for login.ubuntu.com or login.staging.ubuntu.com to discharge via UbuntuSSO. respMachineToken: type: object required: - machine-token properties: machine-token: type: string description: This token is unique for each machine and ties the machine to a contract. It is used for all subsequent authentication to contract and account service endpoints. respSuccessEnable: type: object properties: message: type: string description: Optional human readable message describing the operation performed token: type: string description: Serialized Macaroon containing all caveats authorized for this entitlement respSuccessStatus: type: object properties: subscription: type: string description: Unique key of the account subscription contract-expiry: type: string description: Date at which the UA contract expires refresh-expiry: type: string description: Date at which the entitlement status should be refreshed entitlements: type: object properties: esm: type: object properties: token: type: string description: If authorized, contains token encoding username:password:url of ESM ppa. livepatch: type: object properties: token: type: string description: If authorized, contains livepatch enable token. fips: type: object properties: token: type: string description: If authorized, contains token encoding username:password:url of fips ppa. fips-updates: type: object properties: token: type: string description: If authorized, contains token encoding username:password:url of fips ppa. respSuccess: type: object properties: message: type: string description: Optional human readable message describing respError: type: object required: - error properties: error: type: string description: Unique error code defined for the specific failure mode enum: - INVALID_USER_TOKEN - EXPIRED_USER_TOKEN - DUPLICATE_MACHINE_ID - ... details: type: string description: Optional English, human-readable detailed message about the specific failure. example: There is another machine currrently registered with a duplicate machine-id under another account. Please reset ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/testing/data.py0000664000000000000000000003167114072115230021203 0ustar """ Test data variables for reference. Avoid bloating unit tests """ # Real gpg key for fingerprint 9F912DADD99EE1CC6BFFFF243A186E733F491C46 GPG_KEY1_ID = "9F912DADD99EE1CC6BFFFF243A186E733F491C46" GPG_KEY1 = b"\x99\x02\r\x04\\\xe2\xfd\x19\x01\x10\x00\xc5\xd5\xf0\xde\x96\x13\xece~\xf6\xd1!\x93\xc0q\xf0\xd6F\x0b5/[\x82\x10|c\xb3s5f#\x92\xe9\x98I\x01\x99\t\x0b\xedV6\x86\x16\xb5\x93}DO\xa6Z\x04\xd9UH\xb2\xbe\xe4l\xd6\x99\xa5\xce\xf6.!\xd6\x81\xa3\xa3C\xe2\xd0\xfc\xf9T\xfc\xb1\x19_'wi\x0eh4\xc6\xd9\x1e\xb2]nx\xc0\r\x9bh\xf5\x148\xa4\x04\xdb3@\xb2\xd9\xfe~\x9b\xe9\xb8\xcaqUe,A\x01$\xec\xd5_\x9d\xe0\xe9\xfb\xc4S\xb6nu\xda\xee\xc73&\x022q+\xb4\x9d\x18\xadw<\x9e\x08\xafC\xd7\x9f\n\x1c\xaeg\xac:$j\xef?.\xc5\xb0-/\xfe\xd1\x1dB99\xf6Qt\xe2\xf8\xde\x83\xca]\x918{\xc9\xe7r\x8eI\x0f\xd8\xc2\xb2\xc4\xbaW]`7\x81\x8a\x19\xb9\xbbx\xdd\x07\xe4P9\x8b\x01\xcb\xe2\x94\x1el\x1d\x81\xed\xdd\x17&\x8b\x12\xaeW\xfd!*\xc0G.\xb5x\xf4\x1f\x97\xbb*Cx\xa7#d\x14\x800H\xf4?u\xec\xdeU\xa6\xe85/\x14\xfcI\x1b\xccy\x80E\xb8\x9cY\xb82\xb1\xd7af\xf7\xed\x96m\x92\x98\xe7\xee%\xfa4\x86\xb5+\xf3\x84\xdd'yH\x0c\x08\x82\xe0\xaf\x86\x90O\x16\xc4:\xd0_\xf5\xe9;\xb3`\x11:\x1dn)u\xaeV\xcb\xb9\xe1\x0e\xed\xdeji)_\xd4\x85\x84\nh\x1c\xaa\x14\xfa(\xf8Cz\xac\x83\x10N9\xc1\xf07E\n\n\xb1\xfe\x1d\xa1\x07G\xeb\x96D\xff\xfb\xf7bM\xfd\x1f\x94\x93Y\x03;\xfb|\x01:x\x95\xee\x17#\n\xd8\x9f'\x08\xd3\x82;\x88\x95\xa8\x83\xbdqJ\xbe\x01\x00\xc5\x01Qaj\x99\x0e>\x0cxz\xbc3!\xcbz\xc9\xee\xbb\xe1oS\xe7\x89\xe6\x17\xa9j~\xab\xd7\x1b{\x93\xd3\xc0\xfc.\x0b\x7f'\x0c\xfc!>3Co\x14\x95\x7fCH\xb6\xea\xa9\xce\xa6\x8bU\xcc(+\x11\x9e\xaf?*'\xc5Zc\xfa\x87?nG\x8b\x04\x91\xd5\xf45\xa5\xf9\n\\V\xfe\xa2\xa5n\x83\xfaoi\xeaI\xa5\x12\xd7\xd9\xea\x0b\x0bw\x00\x11\x01\x00\x01\xb4HUbuntu Common Criteria EAL2 Automatic Signing Key V1 \x89\x028\x04\x13\x01\x02\x00\"\x05\x02\\\xe2\xfd\x19\x02\x1b\x03\x06\x0b\t\x08\x07\x03\x02\x06\x15\x08\x02\t\n\x0b\x04\x16\x02\x03\x01\x02\x1e\x01\x02\x17\x80\x00\n\t\x10:\x18ns?I\x1cF\xe6\x0b\x10\x00\xb6\xbfW\x1d\xb2C\xf4(\xdd\xd3C\xcb\xa2\x96M=\xder8~\xa5\r#\xdd\xd0\xf3\xd9\xe7\xab\x95\x01.\xda\x85\x93W?\xf5\x18!i\xfcZ;\"{/\xc2\xdf\xf6+Gr\xe5\x8f\xec\xf0A\xb6\xa9\x966}\xebO\x10\x8f?u\x8c\xda\xd2\xddX!\xf6\xff\xbd\x9d\xa2Pq8\xa74X\xa5\xa8\xea\x99\x19\xc9i\xe4\xc6\xf3VQ\xaa\x19\xa9\xc8\xfe\x07^\x82\xf8\x04\x1a\xa1\x1d\x8b\xa3x\xa4/\x89,\x0b\x0f\x8a4\xd7n\xc0\x94\x89Tl\x90\xe5C6\xc4i\r\x947 \x10\x9a\xa8\xb0v\x99\xec\x85\xf4\"]Z2\xf46\xdc\xc7\xa0\xbd\x94?\x7f\x89\xfe\xb3\xdb\xccc\xaa\x0eU\xab\x08{\x80\xd0\x1d#\xb6\x02\xf2\x0c\x15\xf6\x18Y\xea_\xdf\x81\xb1\xcd\x97)\xac0}\xb9\xf1g\r\x83\xb1\x15\r\"Wp\x04\xb5\xc3[3\x05\xcc\xf2\xfa(\xc7\x11>0\xd7\xaa]\xa7\xff\xbf\r\x8a\xd1\xcd\xd4\xe6Z\xeb\xe6\x92$\xab\xc5_3\xf0\xd1\xf6\xbaq\xaa\xfb\xa2+0j~\xc7\xbd\xd2\xd51\xa8\xfa\x15 \x86\xc6o\xbdc\xcb\xa2`\xde*i\xbfi+X&\xbc\xbeE?N\\]yh\xd2\x94\xa1\xe4|\xc1\x87\x92\xb4\xb0\xfd\xcf\x07\xaf\xd0\x848\xe7\xc6Qe`\xed4\xa2\xec\xa1H\x0c\xf8f\xbb{K\x83\xc4\xb0\x8d\xc0\x13\xcb\xc0*`+Cz\xe73\xf2.\xcd\xabc\xffE6h\x87\r2\xbf\x1d\xafg\x81\xe6\xf6\x17\x80\x8b\xe7\xdd\x95\x93\xba\xce\x03\xf6\xb4\xb8\xecax~\xc4\x06y\xf1\x8b\x98\xac\xb6\xd6\xd6$j\x0cs\n\x8a\xf9d+\xfb+\xdeF)\xd0p\xff\x87\x12\x91\x903o_9\x83\xea\x0ePR\xae\t\xfe\r\xdfK\xf9\xb5\xfaR\x08\xf0:PC\r\x1d\x07g\xdd\xbegC\x13\x95\xa5\xf9\xbfS\x14\x83\x90\x14\xd3\x98\xb6\xfe\xb0\xb6V<5\x9a\xfb]\x8b\xf5\x81\xe8\xfcW:\xd0\xac\xdf\x9e-\xa3\xc5]\xd6\xe0\x98X\xf7\x84\x1a|\x12\xf0g\xfa\xb2\xb9\x1a\xa4w\xd5\x81\xb9\x01R\xf2V\xfd\xe3\t\x8c\xf8\x07\x1a\r\r\xaa\xdc\x98\x19\x05\x08\xb0\x02\x00\x03\xb9\x02\r\x04\\\xe2\xfd\x19\x01\x10\x00\xba}u\r}T\xbc\xae\x9d\xc1\xe5\x97\x0f\xe9\xea\x8d\xbd\x1e\x1eV\xb1\xdb\xbbC\xf3\xf0!u%\xaf\xce\x1d(\xe8q\xc8\xf0\xfd\x9d;\xcdi<\xa8\xa5\x8e$\xda6\xce$\xf4\xad\xdb\x1e\x89\x84AA\x83)\x07\x7f\xc0i\xedD\x10\x90\xcd\xa4\xd8)\xe3e^\xf1k\x93'\xb0\xd0\xc81:\x07>L\xaf\xf2\x8bu \x9c\xda\x1bp\\C .\xe6\x81H\x07\xa7\xc8OY\xb9\xc0\xc8\xd8\xcf\xebh9s_\x99\xb7\xb1:8CB\xe0;\x87T\xbbd\x92\x021\xdb\x9d\xc6\xd7\xdc\xb2@\xec\xf3R\x88\x08\xf2\xf3k\xe7OI\xa2\xcf\xbaI=;\xb5?9-\xc0:.\x90O\xd1\xd7u]\x1f\x13=\x19\x05e3EX\xb2\"\\c\xc0 _H=\x1d\xb2\xcdqJ\x93Z\x12\xc9\xbf\x10\x8b\x8d\xce\r\x9f\xd44\x82\xd5\xed5.\xcd\xaa*:H>\xe1@\x9c\x8cz\xcb\xc4D\xe7\x10\xb1R\xcc\xabbQ\x86\xb5(u\xa1\xbe\xb6`5\x9c\x8f\xab\x16^\x84SD\xabq\xd04\xb4N)\x08\x81\x11oh\xdc45=\xaa\xae\xa0\xff\xfa8R\x1b\x95\x02C\x8b\xa9<\xfc=\x12\x03g\x11S\xb9\xaeN\x03\x93\x96\xd7[h\x94\xe6\x9c6Dh\xac6Uj\xf4Y\x85(\xdcL\xc2j\xcf\xad2\xb6#\xaaR\xcb\x9ai\r0\xbd\xbc\xef\xe7$\xa5\xed\xf4q\xd8aY\x9e|xl\xc4~CD\x0e2y\xf2\x88\xa6\x1c`\xdd\xf9\xba\x8e\x93p\x06 \x8d\xe1\xa4\x0e\xea\xd69\xce\x0b\xfa\xcd\n\x96\xf9\x98\xac\xb1\xe5\xf0h\x08\xe6\x80\x89\xce>\x04lM\xc9\xbf\xe4\xcf\xc8\xcc\xeb-4+\x95\xa3\xa7kL4&#k\x86\x1cn\xe5\x9f\x88\xe5\xdfT\xce\x0c \x8b\xc00\x90\x8av\xebt\x90\xc1\xad7\x10\xe9\xcb^\x148J\\\x02\xcf5\xba\xba\x970\xbe\xfe\x98\xfe\x03B\x13\x9e!\xd1\x0b\x84\xc8E\x00\x11\x01\x00\x01\x89\x02\x1f\x04\x18\x01\x02\x00\t\x05\x02\\\xe2\xfd\x19\x02\x1b\x0c\x00\n\t\x10:\x18ns?I\x1cF\xcc\x92\x0f\xff_2w\xb5i\xc4\"\xe4\xdb\x9d\xd9\nk\x8e\r%-a/\xf8/WG0\xc1\x98\xa4\xca\xa7\xfd\x81\xa5#\x00x\x9a&\xffc\x87S\xf3\x15\xca\xaf\xbe\xa9\x1d\xdc\xae\xf6\x8bW;\xf5C:\xf3A\x9e\x8e<\xfa\x19\xadb\x8e\x9c,\xca\xeez\xdf\xa0\xe3\xa6#'\xeb(e^\x02\xc5\x01\x99\x07\xc9\x0f\xbb\xc4\xb3\xc1\x98\x15C\xe9sL-\xe1\xcb\x04\xde\xe4y5s\xa4\xb1=\xfc+\x08\xe5R\x948\x19\xb0bg>~\xae\xce\x89\xf8j\xfdNM\xa6\x08\xf6\xa5\xc6\x01\xe5=V\xb1w\xb0\xd2X\xfd(6\xfa\xaaQb&\x10\xcc\xed\xda\x86\x8c}8`\x16\xabn\xbb\x837^\xb0p\xfa\xa1\x8bo\x96%\xeb\x1a\xd4\xa4\x96\xcfI\xe8\x95\xa6_\xb6\xb4^\xda\x94m\x07\x83\xde\xdc\x9b]\xe9\xd2Z\x99'\xecK\xbb\xd64\xcb\xf4\x92\xc5C\x8c\x9b%\x84O9\x9f\xc8\xfc\x17\x1b\xb0\x06\xc6Z\xa5j\xd5I\xfb+\xbc\xf5\xd2-\xfct\x0f$\x95\xe7\x1e\x00 0\xf1\xe5\x18|p\x13\xec\xe6`\x02\xf4*\x08Y\xd6?&\xf4O\xf7\xed\x00\x02;\x11Lb\xaf\x8b\xfa\x0e\x85u\xac\x0f\xfc\x9b\xa2\x10\xea\x0f\xbeI\xcb\x9c\t\xc7\x81G\x13\xe3D\x14Dn\xc7\x99\xbcg%\xf1\xbb\xeb\xc1\x9c\x01t\xf7\xd8\x84\xf1\"n\x9c\xe0G\xc6\x00;\xd6\x9a\xa03\xc97\x7f\xc7\xae[\xd0\xf5\xe7\x9c\xcc\xd3\xa5_\x80>\xe5\x9c\xef$\x94\x9c\t\x07-\xa38M(\xfe\xa4\xcdA\xc1y\x97\xc4\x14^\xc7\xcb)\x9aP\xc4\xce\x07\xf8+\x04\xb1#,Cq\xb7 u\xb8{\x17t\xb9\xb3I\x88\xc3\xeb\xbc~\xd5\x07\xd3S\x8f\x9a\xde5\x9d\xc0\x8dd\xc2\xd6d?e\xe9Hun_4\xbf\x02\xc4\xd8\x1d\x88D\xbc\xd9\xd5\xdd\xd1\xd3QaG\xb8\xf3h\xfb\x02R\xa7\xf6\xa2\x95\xb5\x9b\xaa\x15\x0f\xd78\xb59\x1aS<\xa6\x81\xea\xfd\xd1\x0b\xef\xe1\x14Z\xfe\xe4#\xb2\x03xR29\x85\x8b\x19\\0\x14?\x1b\xe4\xb0\xdd\xd8\xca\xf8\x82M\xedI\xdd\xa6\xfa\x18\xf4\xcd\xafc\xd0\x04F\x90\xaa\xf6\x9c`lj\xe3Z\xcb\x16\x06\xfb\xaf<^C\xd5\x91\xa0\xb8\xcd6{Z\xb3lF\xe4\xe0y\xbc\x89\xfc\xe9\x86Q\xfc-:yNqX\x8f\xbd\x02\xf7l\xb0\xbd\x80v \x11@\xf2]\xb5\xc5\x83\xe7\xa08\xf4\x93&\xdf\x0bO\xc0\xa3.\xa3\xb2\xcdZ<\x17\xb1[\x88\xc2\x1c\t\xca\x82w\x9e\xa0*\xed\x1b\x93\n\xa2\xbbAm.\x13\xe3\xce\xbdZZ\x19\xa6\x00\xa81vz\x0f6\x14A!\xe1\x88\xfb\xe0\xcb\xdcK\xea*\xce\xb1[\xd5V\xd7\x1a\xc8\x02Q\xd3\x9f\xc40\x8e\x87\xb8.G\xc6*)\xdc\xbbcTj\x83\x14:iK'\xf0t~\xa8/\xce\xe5\xb95\x1f\x80\x86\xdc8\xad\xf6\xe6JVn\x80\xb2Z\xbff\xfd\xb9\xb4\x17`s6\xbeN\xd9\x00\xc2\x7f\xb0\x18\x9fa\xa4\xdf\x9d\xa2m\x114u\xcc\xf7\x95\xf8.\xff\xca_d\xbeK(w\x9a\x19\xb2\xa2\x9b\xffW\xa0p#}>\xcd\x9c\xc8\x01m\x1d\xa4\xb4x?\xc3.F\xb0\xc2\xba\x19f1\xfc+\x99\xe2\xd1\x9bB\x87a\x9b\xd3\x19\xe3\xcc\xf3\xcf\xb8\x94M\xa7F0\x890\xe6?\xa2\xcc\x9b\x99?W\x8d\xb2T*AO\xb2\xb2\xf3\xdb\"\xa3\xce\xa5\xdczg\xa0\x13\xf1\xe4?\xb9\x00\x11\x01\x00\x01\xb4TUbuntu 16.04 Extended Security Maintenance Automatic Signing Key \x89\x028\x04\x13\x01\x02\x00\"\x05\x02]\t\xfb\xe3\x02\x1b\x03\x06\x0b\t\x08\x07\x03\x02\x06\x15\x08\x02\t\n\x0b\x04\x16\x02\x03\x01\x02\x1e\x01\x02\x17\x80\x00\n\t\x10\xb3\x0e\xda\xa6=\x8fa\xd03?\x0f\xfd\x15\x9f\xb2\xdc\x07\xe1\xc2\xa7_\x03\xaa\xd9\xdd@k\xd5xf\xbc\xaf#\xb7\xd9(\xf3M\n\xb2[H\xfcH\xc6\xac7\xd6\xa5O\xe5\xcc\xa5\xc0\x86\x02\xc17\t\xfd+\xe3\xd6n4\x81/-\xfdo:5\xc1Q\xbe\x16\x15\xc1+<\xf7\x90|?o\xb6\x0eS3Z\xe1Z\x9f\xbd\xee\xe1\xb2O\xba\xac\xee\xce\xbd\xce\xd3[\xa4US\xce\x9ff\x80\xbd\x95\xe9\x81\x15%\tb\xce!\xc3\x90i$w\x8c\xdf\tX\xb8\xcb\x80\xae\xed\xf5\xc6\x10\x83+\xe5-\xf8\x8d\xab\xc5\x18\\%x\xcd\x13,\x0f\x16\xfd\xad+\xcc\xb1U\x16id\x10\r\x0e`\x80\xf0\xa5X;\x9eT\x03\xcd&\xb4\xa6C/M\xa9(\xd4\xd7\xa0\xdc\x16\xe3Fw\xcc\x8a\x83F\x88\x9dRt7\x95\x13\xe9\x96_\n|\x03\xa9\xa8%\xf0\xaa+\xc2S\xd7\xce\x1f\xaeIQ\xa4\xcds\xebc\xbc\xca\x84\xcf\x95\xef\x98\xe1wD8\x11\x03\xae!&\xb1\x89\x84\x0c\xbbWHH\x07p\xact\xf3\x13\"\x1a\xa4:\xcd\xf9\xf6\x02\xb8[\xa9\xb7%\xa5T\xa4\xe8\x08\xdaL|\xf4\xd4L\xde\x8aT25\xa3\x04\xb6C9L7\xc5\xab2\xeb\xf2 \xcb\xd1S\xf1\x93\xf7$LO\xb2Z\x08\x99N\xbe@\xe5\x1f\xa2\xb6\xc7\xfb\xaar9\x0c\xc2\x90\x9eB\x82^\\~5\xb0\x17\x15H\xcb\xa9d\xfe\x10\xc8\x90\xda{\x1d\x84\xfb\x90\xb7i:\x06\xae\\S\xa9P\xf2XGXY\xc0;\xe2\x1ac\x9cDG\n\xd9\x88+\xc2 _b\xda\x89\x93\xaa\\\xef\x8d\x90|;\xff>N/k\\\xbc\xdar\x86\x9aL\x8b_+\xfc\xd2\xe6\xa1\x8d\x16\x05\x12\x86\xb7\xe5\x88\x89\x1c\xd8\xa1\xc6\x85\xeab\xd1\xe7\x98\xf1yH:+\xaa\xc7\xe8/\xee\x86\x0c\xe8*f\xe5\x7f\x90\x1b\xfa\x05\x87\x95\xa3\xf04\x95\x8c\xd3(\x93`\xf3K\xb0\x02\x00\x03\xb9\x02\r\x04]\t\xfb\xe3\x01\x10\x00\xcb$\xaa(\xfdm\x13\xd1\x0b\xde\x90>%\xd7\xebZ\x0c\xe0\x8ey\x9ad4\x93\x04\xc5\xbf\xb0\xfb\xf46\xc81\xb4f2%\x11e\x18\xdf\xc0\r\xebMxx0\x7f\xd6\xf1\x11\xf0\x968w\x1a\x93 \x80\xa7\xe7\xe9\xe2 \x15\xd8\x9b\n]!S\xd9\xc3\xef\x9f\xdc\xea\xee\n=\x8c,\x18p\xaf \x11\x9f\x9c\xd3.\x0e\x9eAY>\xf0\xbf\x1bf\xf1 \xd9\x1b\xf9{\x9f\xa7\xb9\x03\xbd9\xee\x0c\x87\x16\xb3\xa2#\x88\x18I\xc7\x8f\xaf{5\xc2\xab\xf1\xf1\x80\xab\xad\x9foL\xaa\xeeLu\x18\x99=\xc2\xa9e\x0f\x06\xab\x07J\xe9\xa6KQ\xe9\xf3\x89\xa9\xac\xc3\x82\xce\xac\xb0\t\x86\x13\xae\xb3\x06Z\xe3.s\x0f\xc0\xf0\xd6%\x92\xc5Gx\xbb\xf3\x98\xdb\\#\x94M\r\x1dp\xe9\xc2\x97\xe0oX\xddV\"/_\x8a\xff\xf6^\x84c`z\x9a\xa4O\x01\xf41\xfb\x0e\x0f\xa9\xe1\x02\xb1\x93\xfec6\xc6\x11iT\xe5A\xee\xa5\xbc8\xde\xda\x03\xddF>,\xba\xa0Z\x80\xffK\xfcX(\x08\xa5\x0f\xdc\xb1\x8f\xa3\xca\x1b\xaa\x13\x96\x97M\x88\xfb\xfe9\xc9K\xb0\xc8\xf5\xae;'w\xde+\\\xe3\xebh\xe3\xcc\x98u\x87\x16\xea@\x17'\xfc*ewo\xeb\xe7\x86f\xf2\t\x86\x04\xec\xc7\x82\xce\xccT\xc6\xda\xfd\xf8\x16&\xb8l[7\x9d\x97\x0e>d\xa3'\xa4\xbdT\x01\xeb\x90\x98\x0f2\xee\xa66\x81\xe1\x97\x93\x1e\x93\x1f`I\rR'b'0\xe9\x91I,\xb2d\x1c[\xa6=6\xeb\x8a\x0e}\x88c?\xbc\xa4\x01\xd9\xc5\x12S$\x88@:\"\x94#\xf4\xe7\xda&I\xb1e\xdd\x10\xe0\xe1\x05\xb65\xe0\xe3\x9f\x80\xd6y\xf5W\xddU\xfa\x1f\x8fqFz\x04a\xffa\xc0s\xf6'ID~E\xdc`p\xcec\x8f,\xf6\xcd\xaa\xf8\xf1\xe8x\x03\x9d#\x9f,0\xf0\xa5n*[b\xb0\xe2\x19N\x9d\x1bC\x9b/\x08\xca\xe0\xeb\xf9\x9a\x1a5\xaa\xcbQ]vgCh\x9f\x8c]\xb7\xcd|\x9f\x06\x1a\x98\x97dod\xf0\xbeh)\x95\x1ck\xf3\x00\x11\x01\x00\x01\x89\x02\x1f\x04\x18\x01\x02\x00\t\x05\x02]\t\xfb\xe3\x02\x1b\x0c\x00\n\t\x10\xb3\x0e\xda\xa6=\x8fa\xd0 \xc9\x0f\xfe9 \xfbRPmc\x96\xc2\xa2\x0b'e\xa8\x1b<\x12\xb5\xf1\xb6\xfaaKU\x18\xd6\x1ak^g\xcdr\xaa\x86\x16\x89vG\x9b1\t\xbcT9\x8f\xfb\x19>\x99\x00\x82\x8f\xd8\xbc\xa1\xbdr\xab\x03\xd9\xf2[_w.0/\xb1\xa9{b\x8a\x1b\xa2\xa3\xb9\x81U\xd5\x8e\xe5qzLD\x15\x1a\x7f\x9aA\x89q\xab\n\x12\xb7t\xb8y\x81q\x1d\x06\x95\x16*$\x92\x15\x0e\xcb\x0e\xa2\t\xc2%\xfb\xf4,\xd7\xb3.\x04\xf8{5\xed\x0c}&\x8d\xa6\x1f\xfeaB\x1e\x01\x8f\x96]\xc9\xda\x9f\x9d\x02(flm\x89[q\xb4\xbc\xb5\xf5\x87\xcf\x00D\x8e\xbc\r\xd4\xce\xdaZC>]@\x9f\xc9\x9cU\x89\xcb\xadR+\xa73\x9d\xbd\xb0\xf3\xa0\xc4\x03\xc6\x12n\xb7\x9e\xf0 F\x9fV\xd1\xd3K\xa3\xf5b\xb9\xd6\xecK\xeaKH\xb3\xbd\x89\xe4\xef\xf0\x03`\xeb<\x84s\x11\x04'\xa7\xc8>\x04\xcc\xdb\xadqf/\xe8\xfaA\xf9pCX\xf5u\x8flB\x96G\x13\xf0&t\x9b\xaa\x805=\xb9\x85`\xc5uK\xd7\xbf\x1bR\xf2^EV\x17\x08\xc9\xf2\xa0\x82\x16o\xf0~SHk\x9c\xb6A\xd9u~m\x89\xbe/=\x8c\x8a\xab\x1a*\xf4C\x1bN\xa5\x84\x96Isov\xdd\x02\xbe\xf5\x83\xb8+R\xaf\x81\"\xce\x84X\xce\xa2\x8f\xf9E\xabI\xdf'x\x8fF\x89C\xaf\xd7\x99\x00\x84\x8az\x16\x8d8\xda\xd0\xd6\xbf\x0f\x7fK\xbf\x8a\xf7\xf0p\x8e\xcelZjF\xddx6\xd6\xf2\xd3+]\x92zD\xb0\x02\x00\x03" # noqa: E501 ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/config.py0000664000000000000000000003231714601326072020066 0ustar import copy from datetime import datetime import json import logging import os import yaml from collections import namedtuple from uaclient import status, util from uaclient.defaults import CONFIG_DEFAULTS, DEFAULT_CONFIG_FILE from uaclient import exceptions try: from typing import Any, cast, Dict, Optional # noqa: F401 except ImportError: # typing isn't available on trusty, so ignore its absence def cast(_, x): # type: ignore return x DEFAULT_STATUS = { "_doc": "Content provided in json response is currently considered" " Experimental and may change", "attached": False, "expires": status.UserFacingStatus.INAPPLICABLE.value, "origin": None, "services": [], "techSupportLevel": status.UserFacingStatus.INAPPLICABLE.value, } # type: Dict[str, Any] LOG = logging.getLogger(__name__) PRIVATE_SUBDIR = "private" # A data path is a filename, and an attribute ("private") indicating whether it # should only be readable by root DataPath = namedtuple("DataPath", ("filename", "private")) class UAConfig: data_paths = { "machine-access-cc-eal": DataPath("machine-access-cc-eal.json", True), "machine-access-cis-audit": DataPath( "machine-access-cis-audit.json", True ), "machine-access-esm-infra": DataPath( "machine-access-esm-infra.json", True ), "machine-access-fips": DataPath("machine-access-fips.json", True), "machine-access-fips-updates": DataPath( "machine-access-fips-updates.json", True ), "machine-access-livepatch": DataPath( "machine-access-livepatch.json", True ), "machine-access-support": DataPath( "machine-access-support.json", True ), "machine-id": DataPath("machine-id", True), "machine-token": DataPath("machine-token.json", True), "status-cache": DataPath("status.json", False), } # type: Dict[str, DataPath] _entitlements = None # caching to avoid repetitive file reads _machine_token = None # caching to avoid repetitive file reading def __init__(self, cfg: "Dict[str, Any]" = None) -> None: """""" if cfg: self.cfg = cfg else: self.cfg = parse_config() @property def accounts(self): """Return the list of accounts that apply to this authorized user.""" if self.is_attached: accountInfo = self.machine_token["machineTokenInfo"]["accountInfo"] return [accountInfo] return [] @property def contract_url(self): return self.cfg.get("contract_url", "https://contracts.canonical.com") @property def data_dir(self): return self.cfg["data_dir"] @property def log_level(self): log_level = self.cfg.get("log_level") try: return getattr(logging, log_level.upper()) except AttributeError: return getattr(logging, CONFIG_DEFAULTS["log_level"]) @property def log_file(self): return self.cfg.get("log_file", CONFIG_DEFAULTS["log_file"]) @property def entitlements(self): """Return a dictionary of entitlements keyed by entitlement name. Return an empty dict if no entitlements are present. """ if self._entitlements: return self._entitlements machine_token = self.machine_token if not machine_token: return {} self._entitlements = {} contractInfo = machine_token["machineTokenInfo"]["contractInfo"] ent_by_name = dict( (e["type"], e) for e in contractInfo["resourceEntitlements"] ) for entitlement_name, ent_value in ent_by_name.items(): entitlement_cfg = {} if ent_value.get("entitled"): entitlement_cfg = self.read_cache( "machine-access-{}".format(entitlement_name), silent=True ) if not entitlement_cfg: # Fallback to machine-token info on unentitled entitlement_cfg = {"entitlement": ent_value} util.apply_series_overrides(entitlement_cfg) self._entitlements[entitlement_name] = entitlement_cfg return self._entitlements @property def is_attached(self): """Report whether this machine configuration is attached to UA.""" return bool(self.machine_token) # machine_token is removed on detach @property def machine_token(self): """Return the machine-token if cached in the machine token response.""" if not self._machine_token: self._machine_token = self.read_cache("machine-token") return self._machine_token def data_path(self, key: "Optional[str]" = None) -> str: """Return the file path in the data directory represented by the key""" data_dir = self.cfg["data_dir"] if not key: return os.path.join(data_dir, PRIVATE_SUBDIR) if key in self.data_paths: data_path = self.data_paths[key] if data_path.private: return os.path.join( data_dir, PRIVATE_SUBDIR, data_path.filename ) return os.path.join(data_dir, data_path.filename) return os.path.join(data_dir, PRIVATE_SUBDIR, key) def _perform_delete(self, cache_path: str) -> None: """Delete the given cache_path if it exists. (This is a separate method to allow easier disabling of deletion during tests.) """ if os.path.exists(cache_path): os.unlink(cache_path) def delete_cache_key(self, key: str) -> None: """Remove specific cache file.""" if not key: raise RuntimeError( "Invalid or empty key provided to delete_cache_key" ) if key.startswith("machine-access") or key == "machine-token": self._entitlements = None self._machine_token = None cache_path = self.data_path(key) self._perform_delete(cache_path) def delete_cache(self): """Remove configuration cached response files class attributes.""" for path_key in self.data_paths.keys(): self.delete_cache_key(path_key) def read_cache(self, key: str, silent: bool = False) -> "Optional[Any]": cache_path = self.data_path(key) try: content = util.load_file(cache_path) except Exception: if not os.path.exists(cache_path) and not silent: logging.debug("File does not exist: %s", cache_path) return None try: return json.loads(content, cls=util.DatetimeAwareJSONDecoder) except ValueError: return content def write_cache(self, key: str, content: "Any") -> None: filepath = self.data_path(key) data_dir = os.path.dirname(filepath) if not os.path.exists(data_dir): os.makedirs(data_dir) if os.path.basename(data_dir) == PRIVATE_SUBDIR: os.chmod(data_dir, 0o700) if key.startswith("machine-access") or key == "machine-token": self._machine_token = None self._entitlements = None if not isinstance(content, str): content = json.dumps(content, cls=util.DatetimeAwareJSONEncoder) mode = 0o600 if key in self.data_paths: if not self.data_paths[key].private: mode = 0o644 util.write_file(filepath, content, mode=mode) def _unattached_status(self) -> "Dict[str, Any]": """Return unattached status as a dict.""" from uaclient.contract import get_available_resources from uaclient.entitlements import ENTITLEMENT_CLASS_BY_NAME response = copy.deepcopy(DEFAULT_STATUS) resources = get_available_resources(self) for resource in sorted(resources, key=lambda x: x["name"]): if resource["available"]: available = status.UserFacingAvailability.AVAILABLE.value else: available = status.UserFacingAvailability.UNAVAILABLE.value ent_cls = ENTITLEMENT_CLASS_BY_NAME.get(resource["name"]) if not ent_cls: LOG.debug( "Ignoring availability of unknown service %s" " from contract server", resource["name"], ) continue response["services"].append( { "name": resource["name"], "description": ent_cls.description, "available": available, } ) return response def _attached_service_status( self, ent, inapplicable_resources ) -> "Dict[str, str]": details = "" contract_status = ent.contract_status() if contract_status == status.ContractStatus.UNENTITLED: ent_status = status.UserFacingStatus.UNAVAILABLE else: if ent.name in inapplicable_resources: ent_status = status.UserFacingStatus.INAPPLICABLE else: ent_status, details = ent.user_facing_status() return { "name": ent.name, "description": ent.description, "entitled": contract_status.value, "status": ent_status.value, "statusDetails": details, } def _attached_status(self) -> "Dict[str, Any]": """Return configuration of attached status as a dictionary.""" from uaclient.contract import get_available_resources from uaclient.entitlements import ENTITLEMENT_CLASSES response = copy.deepcopy(DEFAULT_STATUS) contractInfo = self.machine_token["machineTokenInfo"]["contractInfo"] response.update( { "attached": True, "account": self.accounts[0]["name"], "account-id": self.accounts[0]["id"], "origin": contractInfo.get("origin"), "subscription": contractInfo["name"], "subscription-id": contractInfo["id"], } ) if contractInfo.get("effectiveTo"): response["expires"] = datetime.strptime( contractInfo["effectiveTo"], "%Y-%m-%dT%H:%M:%SZ" ) resources = get_available_resources(self) inapplicable_resources = [ resource["name"] for resource in sorted(resources, key=lambda x: x["name"]) if not resource["available"] ] for ent_cls in ENTITLEMENT_CLASSES: ent = ent_cls(self) response["services"].append( self._attached_service_status(ent, inapplicable_resources) ) support = self.entitlements.get("support", {}).get("entitlement") if support: supportLevel = support.get("affordances", {}).get("supportLevel") if not supportLevel: supportLevel = DEFAULT_STATUS["techSupportLevel"] response["techSupportLevel"] = supportLevel return response def status(self) -> "Dict[str, Any]": """Return status as a dict, using a cache for non-root users When unattached, get available resources from the contract service to report detailed availability of different resources for this machine. Write the status-cache when called by root. """ if os.getuid() != 0: response = cast("Dict[str, Any]", self.read_cache("status-cache")) if not response: response = self._unattached_status() elif not self.is_attached: response = self._unattached_status() else: response = self._attached_status() if os.getuid() == 0: self.write_cache("status-cache", response) return response def parse_config(config_path=None): """Parse known UA config file Attempt to find configuration in cwd and fallback to DEFAULT_CONFIG_FILE. Any missing configuration keys will be set to CONFIG_DEFAULTS. Values are overridden by any environment variable with prefix 'UA_'. @param config_path: Fullpath to ua configfile. If unspecified, use DEFAULT_CONFIG_FILE. @return: Dict of configuration values. """ if not config_path: config_path = DEFAULT_CONFIG_FILE cfg = copy.copy(CONFIG_DEFAULTS) local_cfg = os.path.join(os.getcwd(), os.path.basename(config_path)) if os.path.exists(local_cfg): config_path = local_cfg if os.environ.get("UA_CONFIG_FILE"): config_path = os.environ.get("UA_CONFIG_FILE") LOG.debug("Using UA client configuration file at %s", config_path) if os.path.exists(config_path): cfg.update(yaml.safe_load(util.load_file(config_path))) env_keys = {} for key, value in os.environ.items(): key = key.lower() if key.startswith("ua_"): env_keys[key[3:]] = value # Strip leading UA_ cfg.update(env_keys) cfg["log_level"] = cfg["log_level"].upper() cfg["data_dir"] = os.path.expanduser(cfg["data_dir"]) if not util.is_service_url(cfg["contract_url"]): raise exceptions.UserFacingError( "Invalid url in config. contract_url: {}".format( cfg["contract_url"] ) ) return cfg ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/conftest.py0000664000000000000000000000453614601326072020450 0ustar import io import logging import mock import pytest @pytest.fixture def caplog_text(request): """ A fixture that returns a function that returns caplog.text caplog isn't available in pytest in all of our target releases; this either uses caplog.text if available, or a shim which replicates what it does. Specifically, bionic is the first Ubuntu release to contain a version of pytest new enough for the caplog fixture to be present. In xenial, the python3-pytest-catchlog package provides the same functionality (this is the code that was later integrated in to pytest). For trusty, there is no packaged alternative to this shim. (It returns a function so that the requester can decide when to examine the logs; if it returned caplog.text directly, that would always be empty.) """ log_level = getattr(request, "param", logging.INFO) try: try: caplog = request.getfixturevalue("caplog") except AttributeError: # Older versions of pytest only have getfuncargvalue, which is now # deprecated in favour of getfixturevalue caplog = request.getfuncargvalue("caplog") caplog.set_level(log_level) def _func(): return caplog.text except LookupError: # If the caplog fixture isn't available, shim something in ourselves root = logging.getLogger() root.setLevel(log_level) handler = logging.StreamHandler(io.StringIO()) handler.setFormatter( logging.Formatter( "%(filename)-25s %(lineno)4d %(levelname)-8s %(message)s" ) ) root.addHandler(handler) def _func(): return handler.stream.getvalue() def clear_handlers(): logging.root.handlers = [] request.addfinalizer(clear_handlers) return _func @pytest.yield_fixture def logging_sandbox(): # Monkeypatch a replacement root logger, so that our changes to logging # configuration don't persist outside of the test root_logger = logging.RootLogger(logging.WARNING) with mock.patch.object(logging, "root", root_logger): with mock.patch.object(logging.Logger, "root", root_logger): with mock.patch.object( logging.Logger, "manager", logging.Manager(root_logger) ): yield ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/status.py0000664000000000000000000001744414601326072020150 0ustar import enum import sys try: from typing import Any, Dict # noqa: F401 except ImportError: # typing isn't available on trusty, so ignore its absence pass class TxtColor: OKGREEN = "\033[92m" DISABLEGREY = "\033[37m" FAIL = "\033[91m" ENDC = "\033[0m" @enum.unique class ApplicationStatus(enum.Enum): """ An enum to represent the current application status of an entitlement """ ENABLED = object() DISABLED = object() @enum.unique class ContractStatus(enum.Enum): """ An enum to represent whether a user is entitled to an entitlement (The value of each member is the string that will be used in status output.) """ ENTITLED = "yes" UNENTITLED = "no" @enum.unique class ApplicabilityStatus(enum.Enum): """ An enum to represent whether an entitlement could apply to this machine """ APPLICABLE = object() INAPPLICABLE = object() @enum.unique class UserFacingAvailability(enum.Enum): """ An enum representing whether a service could be available for a machine. 'Availability' means whether a service is available to machines with this architecture, series and kernel. Whether a contract is entitled to use the specific service is determined by the contract level. This enum should only be used in display code, it should not be used in business logic. """ AVAILABLE = "yes" UNAVAILABLE = "no" @enum.unique class UserFacingStatus(enum.Enum): """ An enum representing the states we will display in status output. This enum should only be used in display code, it should not be used in business logic. """ ACTIVE = "enabled" INACTIVE = "disabled" INAPPLICABLE = "n/a" UNAVAILABLE = "—" ESSENTIAL = "essential" STANDARD = "standard" ADVANCED = "advanced" # Colorized status output for terminal STATUS_COLOR = { UserFacingStatus.ACTIVE.value: ( TxtColor.OKGREEN + UserFacingStatus.ACTIVE.value + TxtColor.ENDC ), UserFacingStatus.INACTIVE.value: ( TxtColor.FAIL + UserFacingStatus.INACTIVE.value + TxtColor.ENDC ), UserFacingStatus.INAPPLICABLE.value: ( TxtColor.DISABLEGREY + UserFacingStatus.INAPPLICABLE.value + TxtColor.ENDC ), # noqa: E501 UserFacingStatus.UNAVAILABLE.value: ( TxtColor.DISABLEGREY + UserFacingStatus.UNAVAILABLE.value + TxtColor.ENDC ), ContractStatus.ENTITLED.value: ( TxtColor.OKGREEN + ContractStatus.ENTITLED.value + TxtColor.ENDC ), ContractStatus.UNENTITLED.value: ( TxtColor.DISABLEGREY + ContractStatus.UNENTITLED.value + TxtColor.ENDC ), # noqa: E501 ESSENTIAL: TxtColor.OKGREEN + ESSENTIAL + TxtColor.ENDC, STANDARD: TxtColor.OKGREEN + STANDARD + TxtColor.ENDC, ADVANCED: TxtColor.OKGREEN + ADVANCED + TxtColor.ENDC, } MESSAGE_APT_INSTALL_FAILED = "APT install failed." MESSAGE_APT_UPDATE_FAILED = "APT update failed." 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""" 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_ENABLED_TMPL = "{title} enabled" 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: https://ubuntu.com/advantage""" MESSAGE_UNATTACHED = """\ This machine is not attached to a UA subscription. See https://ubuntu.com/advantage""" STATUS_UNATTACHED_TMPL = "{name: <18}{available: <11}{description}" STATUS_HEADER = "SERVICE ENTITLED STATUS DESCRIPTION" # The widths listed below for entitled and status are actually 9 characters # less than reality because we colorize the values in entitled and status # columns. Colorizing has an opening and closing set of unprintable characters # that factor into formats len() calculations STATUS_TMPL = "{name: <18}{entitled: <19}{status: <19}{description}" MESSAGE_ATTACH_INVALID_TOKEN = """\ Invalid token. See https://ubuntu.com/advantage""" MESSAGE_ATTACH_REQUIRES_TOKEN = """\ Attach requires a token: sudo ua attach To obtain a token please visit: https://ubuntu.com/advantage""" MESSAGE_ATTACH_FAILURE = """\ Failed to attach machine. See https://ubuntu.com/advantage""" 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_CONTRACT_EXPIRED_ERROR = """\ Subscription has expired To obtain a token please visit: https://ubuntu.com/advantage""" MESSAGE_INVALID_SERVICE_OP_FAILURE_TMPL = """\ Cannot {operation} '{name}' For a list of services see: sudo ua status""" MESSAGE_ENABLE_FAILURE_UNATTACHED_TMPL = """\ To use '{name}' you need an Ubuntu Advantage subscription Personal and community subscriptions are available at no charge See https://ubuntu.com/advantage""" MESSAGE_ENABLE_BY_DEFAULT_TMPL = "Enabling default service {name}" 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_REFRESH_ENABLE = "One moment, checking your subscription first" MESSAGE_REFRESH_SUCCESS = "Successfully refreshed your subscription" MESSAGE_REFRESH_FAILURE = "Unable to refresh your subscription" def colorize(string: str) -> str: """Return colorized string if using a tty, else original string.""" return STATUS_COLOR.get(string, string) if sys.stdout.isatty() else string def format_tabular(status: "Dict[str, Any]") -> str: """Format status dict for tabular output.""" if not status["attached"]: content = [ STATUS_UNATTACHED_TMPL.format( name="SERVICE", available="AVAILABLE", description="DESCRIPTION", ) ] for service in status["services"]: content.append(STATUS_UNATTACHED_TMPL.format(**service)) content.extend(["", MESSAGE_UNATTACHED]) return "\n".join(content) content = [STATUS_HEADER] for service_status in status["services"]: entitled = service_status["entitled"] fmt_args = { "name": service_status["name"], "entitled": colorize(entitled), "status": colorize(service_status["status"]), "description": service_status["description"], } content.append(STATUS_TMPL.format(**fmt_args)) content.append("\nEnable services with: ua enable \n") tech_support_level = status["techSupportLevel"] pairs = [ ("Account", status["account"]), ("Subscription", status["subscription"]), ] if status["origin"] != "free": pairs.append(("Valid until", str(status["expires"]))) pairs.append(("Technical support level", colorize(tech_support_level))) template_length = max([len(pair[0]) for pair in pairs]) template = "{{:>{}}}: {{}}".format(template_length) content.extend([template.format(*pair) for pair in pairs]) return "\n".join(content) ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/util.py0000664000000000000000000004161214601326072017574 0ustar from errno import ENOENT import datetime import json import logging import os import re import subprocess import time from urllib import error, request from urllib.parse import urlparse import uuid from contextlib import contextmanager from http.client import HTTPMessage # noqa: F401 try: from typing import ( # noqa: F401 Any, Dict, List, Mapping, Optional, Sequence, Tuple, Union, ) except ImportError: # typing isn't available on trusty, so ignore its absence pass ETC_MACHINE_ID = "/etc/machine-id" DBUS_MACHINE_ID = "/var/lib/dbus/machine-id" DROPPED_KEY = object() # N.B. this relies on the version normalisation we perform in get_platform_info REGEX_OS_RELEASE_VERSION = r"(?P\d+\.\d+) (LTS )?\((?P\w+).*" class LogFormatter(logging.Formatter): FORMATS = { logging.ERROR: "ERROR: %(message)s", logging.DEBUG: "DEBUG: %(message)s", } def format(self, record: logging.LogRecord) -> str: log_fmt = self.FORMATS.get(record.levelno, "%(message)s") 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.""" def default(self, o): if isinstance(o, datetime.datetime): return o.isoformat() return super().default(o) class DatetimeAwareJSONDecoder(json.JSONDecoder): """ A JSONDecoder that parses some ISO datetime strings to datetime objects. Important note: the "some" is because we seem to only be able extend Python's json library in a way that lets us convert string values within JSON objects (e.g. '{"lastModified": "2019-07-25T14:35:51"}'). Strings outside of JSON objects (e.g. '"2019-07-25T14:35:51"') will not be passed through our decoder. (N.B. This will override any object_hook specified using arguments to it, or used in load or loads calls that specify this as the cls.) """ def __init__(self, *args, **kwargs): if "object_hook" in kwargs: kwargs.pop("object_hook") super().__init__(*args, object_hook=self.object_hook, **kwargs) @staticmethod def object_hook(o): for key, value in o.items(): if isinstance(value, str): try: new_value = datetime.datetime.strptime( value, "%Y-%m-%dT%H:%M:%S" ) except ValueError: # This isn't a string containing a valid ISO 8601 datetime new_value = value o[key] = new_value return o def apply_series_overrides(orig_access: "Dict[str, Any]") -> None: """Apply series-specific overrides to an entitlement dict. This function mutates orig_access dict by applying any series-overrides to the top-level keys under 'entitlement'. The series-overrides are sparse and intended to supplement existing top-level dict values. So, sub-keys under the top-level directives, obligations and affordance sub-key values will be preserved if unspecified in series-overrides. To more clearly indicate that orig_access in memory has already had the overrides applied, the 'series' key is also removed from the orig_access dict. :param orig_access: Dict with original entitlement access details """ if not all([isinstance(orig_access, dict), "entitlement" in orig_access]): raise RuntimeError( 'Expected entitlement access dict. Missing "entitlement" key:' " {}".format(orig_access) ) series_name = get_platform_info()["series"] orig_entitlement = orig_access.get("entitlement", {}) overrides = orig_entitlement.pop("series", {}).pop(series_name, {}) for key, value in overrides.items(): current = orig_access["entitlement"].get(key) if isinstance(current, dict): # If the key already exists and is a dict, update that dict using # the override current.update(value) else: # Otherwise, replace it wholesale orig_access["entitlement"][key] = value def del_file(path: str) -> None: try: os.unlink(path) except OSError as e: if e.errno != ENOENT: raise e @contextmanager def disable_log_to_console(): """ A context manager that disables logging to console in its body N.B. This _will not_ disable console logging if it finds the console handler is configured at DEBUG level; the assumption is that this means we want as much output as possible, even if it risks duplication. This context manager will allow us to gradually move away from using the logging framework for user-facing output, by applying it to parts of the codebase piece-wise. (Once the conversion is complete, we should have no further use for it and it can be removed.) (Note that the @contextmanager decorator also allows this function to be used as a decorator.) """ potential_handlers = [ handler for handler in logging.getLogger().handlers if handler.name == "console" ] if not potential_handlers: # We didn't find a handler, so execute the body as normal then end # execution logging.debug("disable_log_to_console: no console handler found") yield return console_handler = potential_handlers[0] old_level = console_handler.level if old_level == logging.DEBUG: yield return console_handler.setLevel(1000) try: yield finally: console_handler.setLevel(old_level) def get_dict_deltas( orig_dict: "Dict[str, Any]", new_dict: "Dict[str, Any]", path: str = "" ) -> "Dict[str, Any]": """Return a dictionary of delta between orig_dict and new_dict.""" deltas = {} # type: Dict[str, Any] for key, value in orig_dict.items(): new_value = new_dict.get(key, DROPPED_KEY) key_path = key if not path else path + "." + key if isinstance(value, dict): if key in new_dict: sub_delta = get_dict_deltas( value, new_dict[key], path=key_path ) if sub_delta: deltas[key] = sub_delta else: deltas[key] = DROPPED_KEY elif value != new_value: logging.debug( "Contract value for '%s' changed to '%s'", key_path, new_value ) deltas[key] = new_value for key, value in new_dict.items(): if key not in orig_dict: deltas[key] = value return deltas def get_machine_id(data_dir: str) -> str: """Get system's unique machine-id or create our own in data_dir.""" # Generate, cache our own uuid if not present on the system # Docker images do not define ETC_MACHINE_ID or DBUS_MACHINE_ID on trusty # per Issue: #489 fallback_machine_id_file = os.path.join(data_dir, "machine-id") for path in [ETC_MACHINE_ID, DBUS_MACHINE_ID, fallback_machine_id_file]: if os.path.exists(path): content = load_file(path).rstrip("\n") if content: return content machine_id = str(uuid.uuid4()) write_file(fallback_machine_id_file, machine_id) return machine_id def get_platform_info() -> "Dict[str, str]": """ Returns a dict of platform information. N.B. This dict is sent to the contract server, which requires the distribution, type and release keys. """ os_release = parse_os_release() platform_info = { "distribution": os_release.get("NAME", "UNKNOWN"), "type": "Linux", } version = os_release["VERSION"] if ", " in version: # Fix up trusty's version formatting version = "{} ({})".format(*version.split(", ")) # Strip off an LTS point release (14.04.1 LTS -> 14.04 LTS) version = re.sub(r"\.\d LTS", " LTS", version) platform_info["version"] = version match = re.match(REGEX_OS_RELEASE_VERSION, version) if not match: raise RuntimeError( "Could not parse /etc/os-release VERSION: {} (modified to" " {})".format(os_release["VERSION"], version) ) match_dict = match.groupdict() platform_info.update( { "release": match_dict["release"], "series": match_dict["series"].lower(), } ) uname = os.uname() platform_info["kernel"] = uname.release platform_info["arch"] = uname.machine return platform_info def is_container(run_path: str = "/run") -> bool: """Checks to see if this code running in a container of some sort""" try: subp(["systemd-detect-virt", "--quiet", "--container"]) return True except (IOError, OSError): pass for filename in ("container_type", "systemd/container"): path = os.path.join(run_path, filename) if os.path.exists(path): return True return False def is_exe(path: str) -> bool: # return boolean indicating if path exists and is executable. return os.path.isfile(path) and os.access(path, os.X_OK) def is_service_url(url: str) -> bool: try: parsed_url = urlparse(url) except ValueError: return False if parsed_url.scheme not in ("https", "http"): return False return True def load_file(filename: str, decode: bool = True) -> str: """Read filename and decode content.""" logging.debug("Reading file: %s", filename) with open(filename, "rb") as stream: content = stream.read() return content.decode("utf-8") def parse_os_release(release_file: "Optional[str]" = None) -> "Dict[str, str]": if not release_file: release_file = "/etc/os-release" data = {} for line in load_file(release_file).splitlines(): key, value = line.split("=", 1) if value: data[key] = value.strip().strip('"') return data def prompt_for_confirmation() -> bool: """ Display a confirmation prompt, returning a bool indicating the response This function will only prompt a single time, and defaults to "no" (i.e. it returns False). """ value = input("Are you sure? (y/N) ") if value.lower().strip() in ["y", "yes"]: return True return False def readurl( url: str, data: "Optional[bytes]" = None, headers: "Dict[str, str]" = {}, method: "Optional[str]" = None, ) -> "Tuple[Any, Union[HTTPMessage, Mapping[str, str]]]": if data and not method: method = "POST" req = request.Request(url, data=data, headers=headers, method=method) logging.debug( "URL [%s]: %s, headers: %s, data: %s", method or "GET", url, headers, data, ) resp = request.urlopen(req) content = resp.read().decode("utf-8") if "application/json" in str(resp.headers.get("Content-type", "")): content = json.loads(content) logging.debug( "URL [%s] response: %s, headers: %s, data: %s", method or "GET", url, resp.headers, content, ) return content, resp.headers def _subp( args: "Sequence[str]", rcs: "Optional[List[int]]" = None, capture: bool = False, timeout: "Optional[float]" = None, ) -> "Tuple[str, str]": """Run a command and return a tuple of decoded stdout, stderr. @param args: A list of arguments to feed to subprocess.Popen @param rcs: A list of allowed return_codes. If returncode not in rcs raise a ProcessExecutionError. @param capture: Boolean set True to log the command and response. @param timeout: Optional float indicating number of seconds to wait for subp to return. @return: Tuple of utf-8 decoded stdout, stderr @raises ProcessExecutionError on invalid command or returncode not in rcs. @raises subprocess.TimeoutError when timeout specified and the command exceeds that number of seconds. """ bytes_args = [ x if isinstance(x, bytes) else x.encode("utf-8") for x in args ] if rcs is None: rcs = [0] try: proc = subprocess.Popen( bytes_args, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) (out, err) = proc.communicate(timeout=timeout) except OSError: try: raise ProcessExecutionError( cmd=" ".join(args), exit_code=proc.returncode, stdout=out.decode("utf-8"), stderr=err.decode("utf-8"), ) except UnboundLocalError: raise ProcessExecutionError(cmd=" ".join(args)) if proc.returncode not in rcs: raise ProcessExecutionError( cmd=" ".join(args), exit_code=proc.returncode, stdout=out.decode("utf-8"), stderr=err.decode("utf-8"), ) if capture: logging.debug( "Ran cmd: %s, rc: %s stderr: %s", " ".join(args), proc.returncode, err, ) return out.decode("utf-8"), err.decode("utf-8") def subp( args: "Sequence[str]", rcs: "Optional[List[int]]" = None, capture: bool = False, timeout: "Optional[float]" = None, retry_sleeps: "Optional[List[float]]" = None, ) -> "Tuple[str, str]": """Run a command and return a tuple of decoded stdout, stderr. @param subp: A list of arguments to feed to subprocess.Popen @param rcs: A list of allowed return_codes. If returncode not in rcs raise a ProcessExecutionError. @param capture: Boolean set True to log the command and response. @param timeout: Optional float indicating number of seconds to wait for a subp call to return. @param retry_sleeps: Optional list of sleep lengths to apply between retries. Specifying a list of [0.5, 1] instructs subp to retry twice on failure; sleeping half a second before the first retry and 1 second before the next retry. @return: Tuple of utf-8 decoded stdout, stderr @raises ProcessExecutionError on invalid command or returncode not in rcs. @raises subprocess.TimeoutError when timeout specified and the command exceeds that number of seconds. """ retry_sleeps = retry_sleeps.copy() if retry_sleeps is not None else None while True: try: out, err = _subp(args, rcs, capture, timeout) break except ProcessExecutionError as e: if capture: logging.debug(str(e)) if not retry_sleeps: raise logging.debug( str(e) + " Retrying %d more times.", len(retry_sleeps) ) time.sleep(retry_sleeps.pop(0)) return out, err def which(program: str) -> "Optional[str]": """Find whether the provided program is executable in our PATH""" if os.path.sep in program: # if program had a '/' in it, then do not search PATH if is_exe(program): return program paths = [ p.strip('"') for p in os.environ.get("PATH", "").split(os.pathsep) ] normalized_paths = [os.path.abspath(p) for p in paths] for path in normalized_paths: program_path = os.path.join(path, program) if is_exe(program_path): return program_path return None def write_file(filename: str, content: str, mode: int = 0o644) -> None: """Write content to the provided filename encoding it if necessary. @param filename: The full path of the file to write. @param content: The content to write to the file. @param mode: The filesystem mode to set on the file. @param omode: The open mode used when opening the file (w, wb, a, etc.) """ logging.debug("Writing file: %s", filename) with open(filename, "wb") as fh: fh.write(content.encode("utf-8")) fh.flush() os.chmod(filename, mode) ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/defaults.py0000664000000000000000000000070114601326072020420 0ustar """ Project-wide default settings These are in their own file so they can be imported by setup.py before we have any of our dependencies installed. """ DEFAULT_CONFIG_FILE = "/etc/ubuntu-advantage/uaclient.conf" BASE_CONTRACT_URL = "https://contracts.canonical.com" CONFIG_DEFAULTS = { "contract_url": BASE_CONTRACT_URL, "data_dir": "/var/lib/ubuntu-advantage", "log_level": "INFO", "log_file": "/var/log/ubuntu-advantage.log", } ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/version.py0000664000000000000000000000135214601326072020301 0ustar """ Version determination functions These are in their own file so they can be imported by setup.py before we have any of our dependencies installed. """ import os.path from subprocess import check_output __VERSION__ = "19.7" PACKAGED_VERSION = "@@PACKAGED_VERSION@@" def get_version(_args=None): """Return the package version if set, otherwise return git describe.""" if not PACKAGED_VERSION.startswith("@@PACKAGED_VERSION"): return PACKAGED_VERSION topdir = os.path.dirname(os.path.dirname(__file__)) if os.path.exists(os.path.join(topdir, ".git")): cmd = ["git", "describe", "--abbrev=8", "--match=[0-9]*", "--long"] return check_output(cmd, universal_newlines=True).strip() return __VERSION__ ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/tests/0000775000000000000000000000000015223224541017402 5ustar ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/tests/test_cli_attach.py0000664000000000000000000001246614601326072023120 0ustar import mock from uaclient.testing.fakes import FakeConfig import pytest from uaclient.cli import ( UA_AUTH_TOKEN_URL, action_attach, attach_parser, get_parser, ) from uaclient.exceptions import NonRootUserError, UserFacingError from uaclient import status M_PATH = "uaclient.cli." BASIC_MACHINE_TOKEN = { "machineTokenInfo": { "contractInfo": { "name": "mycontract", "id": "contract-1", "resourceEntitlements": [], }, "accountInfo": {"id": "acct-1", "name": "accountName"}, } } @mock.patch(M_PATH + "os.getuid") def test_non_root_users_are_rejected(getuid): """Check that a UID != 0 will receive a message and exit non-zero""" getuid.return_value = 1 cfg = FakeConfig() with pytest.raises(NonRootUserError): action_attach(mock.MagicMock(), cfg) # 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): """Check that an already-attached machine emits message and exits 0""" account_name = "test_account" cfg = FakeConfig.for_attached_machine(account_name=account_name) ret = action_attach(mock.MagicMock(), cfg) assert 0 == ret expected_msg = "This machine is already attached to '{}'.".format( account_name ) assert expected_msg in capsys.readouterr()[0] def test_token_is_a_required_argument(self, _m_getuid): """When missing the required token argument, raise a UserFacingError""" args = mock.MagicMock() args.token = None with pytest.raises(UserFacingError) as e: action_attach(args, FakeConfig()) assert status.MESSAGE_ATTACH_REQUIRES_TOKEN == str(e.value) @mock.patch( M_PATH + "contract.UAContractClient.request_contract_machine_attach" ) @mock.patch(M_PATH + "action_status") def test_happy_path_with_token_arg( self, action_status, contract_machine_attach, _m_getuid ): """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) cfg = FakeConfig() def fake_contract_attach(contract_token): cfg.write_cache("machine-token", BASIC_MACHINE_TOKEN) return BASIC_MACHINE_TOKEN contract_machine_attach.side_effect = fake_contract_attach ret = action_attach(args, cfg) assert 0 == ret assert 1 == action_status.call_count expected_calls = [mock.call(contract_token=token)] assert expected_calls == contract_machine_attach.call_args_list @pytest.mark.parametrize("auto_enable", (True, False)) @mock.patch("uaclient.contract.get_available_resources") def test_auto_enable_passed_through_to_request_updated_contract( self, _m_getuid, _m_get_available_resources, auto_enable ): args = mock.MagicMock(auto_enable=auto_enable) def fake_contract_updates(cfg, contract_token, allow_enable): cfg.write_cache("machine-token", BASIC_MACHINE_TOKEN) return True with mock.patch(M_PATH + "contract.request_updated_contract") as m_ruc: m_ruc.side_effect = fake_contract_updates action_attach(args, FakeConfig()) expected_call = mock.call(mock.ANY, mock.ANY, allow_enable=auto_enable) assert [expected_call] == m_ruc.call_args_list class TestParser: def test_attach_parser_creates_a_parser_when_not_provided(self): """Create a named parser configured for 'attach'.""" m_parser = attach_parser(mock.Mock()) assert "ua attach [flags]" == m_parser.usage assert "attach" == m_parser.prog assert "Flags" == m_parser._optionals.title full_parser = get_parser() with mock.patch("sys.argv", ["ua", "attach", "token"]): args = full_parser.parse_args() assert "token" == args.token def test_attach_parser_allows_empty_required_token(self): """Token required but parse_args allows none due to action_attach""" full_parser = get_parser() with mock.patch("sys.argv", ["ua", "attach"]): args = full_parser.parse_args() assert None is args.token def test_attach_parser_help_points_to_ua_contract_dashboard_url( self, capsys ): """Contracts' dashboard URL is referenced by ua attach --help.""" full_parser = get_parser() with mock.patch("sys.argv", ["ua", "attach", "--help"]): with pytest.raises(SystemExit): full_parser.parse_args() assert UA_AUTH_TOKEN_URL in capsys.readouterr()[0] def test_attach_parser_accepts_and_stores_no_auto_enable(self): full_parser = get_parser() with mock.patch( "sys.argv", ["ua", "attach", "--no-auto-enable", "token"] ): args = full_parser.parse_args() assert not args.auto_enable def test_attach_parser_defaults_to_auto_enable(self): full_parser = get_parser() with mock.patch("sys.argv", ["ua", "attach", "token"]): args = full_parser.parse_args() assert args.auto_enable ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/tests/test_util.py0000664000000000000000000004670314601326072022003 0ustar """Tests related to uaclient.util module.""" import datetime import json import logging import posix import subprocess import uuid import mock import pytest from uaclient import cli, util PRIVACY_POLICY_URL = ( "https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" ) OS_RELEASE_DISCO = """\ NAME="Ubuntu" VERSION="19.04 (Disco Dingo)" ID=ubuntu ID_LIKE=debian PRETTY_NAME="Ubuntu Disco Dingo (development branch)" VERSION_ID="19.04" HOME_URL="https://www.ubuntu.com/" SUPPORT_URL="https://help.ubuntu.com/" BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" PRIVACY_POLICY_URL="{}" VERSION_CODENAME=disco UBUNTU_CODENAME=disco """.format( PRIVACY_POLICY_URL ) OS_RELEASE_BIONIC = """\ NAME="Ubuntu" VERSION="18.04.1 LTS (Bionic Beaver)" ID=ubuntu ID_LIKE=debian PRETTY_NAME="Ubuntu 18.04.1 LTS" VERSION_ID="18.04" HOME_URL="https://www.ubuntu.com/" SUPPORT_URL="https://help.ubuntu.com/" BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" PRIVACY_POLICY_URL="{}" VERSION_CODENAME=bionic UBUNTU_CODENAME=bionic """.format( PRIVACY_POLICY_URL ) OS_RELEASE_XENIAL = """\ NAME="Ubuntu" VERSION="16.04.5 LTS (Xenial Xerus)" ID=ubuntu ID_LIKE=debian PRETTY_NAME="Ubuntu 16.04.5 LTS" VERSION_ID="16.04" HOME_URL="http://www.ubuntu.com/" SUPPORT_URL="http://help.ubuntu.com/" BUG_REPORT_URL="http://bugs.launchpad.net/ubuntu/" VERSION_CODENAME=xenial UBUNTU_CODENAME=xenial """ OS_RELEASE_TRUSTY = """\ NAME="Ubuntu" VERSION="14.04.5 LTS, Trusty Tahr" ID=ubuntu ID_LIKE=debian PRETTY_NAME="Ubuntu 14.04.5 LTS" VERSION_ID="14.04" HOME_URL="http://www.ubuntu.com/" SUPPORT_URL="http://help.ubuntu.com/" BUG_REPORT_URL="http://bugs.launchpad.net/ubuntu/" """ class TestGetDictDeltas: @pytest.mark.parametrize( "value1,value2", (("val1", "val2"), ([1], [2]), ((1, 2), (3, 4))) ) def test_non_dict_diffs_return_new_value(self, value1, value2): """When two values differ and are not a dict return the new value.""" expected = {"key": value2} assert expected == util.get_dict_deltas( {"key": value1}, {"key": value2} ) def test_diffs_return_new_keys_and_values(self): """New keys previously absent will be returned in the delta.""" expected = {"newkey": "val"} assert expected == util.get_dict_deltas( {"k": "v"}, {"newkey": "val", "k": "v"} ) def test_diffs_return_dropped_keys_set_dropped(self): """Old keys which are now dropped are returned as DROPPED_KEY.""" expected = {"oldkey": util.DROPPED_KEY, "oldkey2": util.DROPPED_KEY} assert expected == util.get_dict_deltas( {"oldkey": "v", "k": "v", "oldkey2": {}}, {"k": "v"} ) def test_return_only_keys_which_represent_deltas(self): """Only return specific keys which have deltas.""" orig_dict = { "1": "1", "2": "orig2", "3": {"3.1": "3.1", "3.2": "orig3.2"}, "4": {"4.1": "4.1"}, } new_dict = { "1": "1", "2": "new2", "3": {"3.1": "3.1", "3.2": "new3.2"}, "4": {"4.1": "4.1"}, } expected = {"2": "new2", "3": {"3.2": "new3.2"}} assert expected == util.get_dict_deltas(orig_dict, new_dict) class TestIsContainer: @mock.patch("uaclient.util.subp") def test_true_systemd_detect_virt_success(self, m_subp): """Return True when systemd-detect virt exits success.""" m_subp.return_value = "", "" assert True is util.is_container() calls = [mock.call(["systemd-detect-virt", "--quiet", "--container"])] assert calls == m_subp.call_args_list @mock.patch("uaclient.util.subp") def test_true_on_run_container_type(self, m_subp, tmpdir): """Return True when /run/container_type exists.""" m_subp.side_effect = OSError("No systemd-detect-virt utility") tmpdir.join("container_type").write("") assert True is util.is_container(run_path=tmpdir.strpath) calls = [mock.call(["systemd-detect-virt", "--quiet", "--container"])] assert calls == m_subp.call_args_list @mock.patch("uaclient.util.subp") def test_true_on_run_systemd_container(self, m_subp, tmpdir): """Return True when /run/systemd/container exists.""" m_subp.side_effect = OSError("No systemd-detect-virt utility") tmpdir.join("systemd/container").write("", ensure=True) assert True is util.is_container(run_path=tmpdir.strpath) calls = [mock.call(["systemd-detect-virt", "--quiet", "--container"])] assert calls == m_subp.call_args_list @mock.patch("uaclient.util.subp") def test_false_on_non_sytemd_detect_virt_and_no_runfiles( self, m_subp, tmpdir ): """Return False when sytemd-detect-virt erros and no /run/* files.""" m_subp.side_effect = OSError("No systemd-detect-virt utility") with mock.patch("uaclient.util.os.path.exists") as m_exists: m_exists.return_value = False assert False is util.is_container(run_path=tmpdir.strpath) calls = [mock.call(["systemd-detect-virt", "--quiet", "--container"])] assert calls == m_subp.call_args_list exists_calls = [ mock.call(tmpdir.join("container_type").strpath), mock.call(tmpdir.join("systemd/container").strpath), ] assert exists_calls == m_exists.call_args_list class TestSubp: def test_raise_error_on_timeout(self): """When cmd exceeds the timeout raises a TimeoutExpired error.""" 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): """When no retry_sleeps are specified, do not retry failures.""" with pytest.raises(util.ProcessExecutionError) as excinfo: util.subp(["ls", "--bogus"]) expected_error = ( "Failed running command 'ls --bogus' [exit(2)]." " Message: ls: unrecognized option" ) assert expected_error in str(excinfo.value) 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): """When rcs list includes the exit code, do not raise an error.""" 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): """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]) expected_error = "Failed running command 'ls --bogus' [exit(2)]" assert expected_error in str(excinfo.value) expected_sleeps = [mock.call(1), mock.call(3), mock.call(0.4)] assert expected_sleeps == m_sleep.call_args_list @mock.patch("uaclient.util.time.sleep") def test_retry_doesnt_consume_retry_sleeps(self, m_sleep): """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) assert expected_sleeps == sleeps class TestParseOSRelease: def test_parse_os_release(self, tmpdir): """parse_os_release returns a dict of values from /etc/os-release.""" release_file = tmpdir.join("os-release") release_file.write(OS_RELEASE_TRUSTY) expected = { "BUG_REPORT_URL": "http://bugs.launchpad.net/ubuntu/", "HOME_URL": "http://www.ubuntu.com/", "ID": "ubuntu", "ID_LIKE": "debian", "NAME": "Ubuntu", "PRETTY_NAME": "Ubuntu 14.04.5 LTS", "SUPPORT_URL": "http://help.ubuntu.com/", "VERSION": "14.04.5 LTS, Trusty Tahr", "VERSION_ID": "14.04", } assert expected == util.parse_os_release(release_file.strpath) class TestGetPlatformInfo: @mock.patch("uaclient.util.subp") @mock.patch("uaclient.util.parse_os_release") def test_get_platform_info_error_no_version(self, m_parse, m_subp): """get_platform_info errors when it cannot parse os-release.""" m_parse.return_value = {"VERSION": "junk"} with pytest.raises(RuntimeError) as excinfo: util.get_platform_info() expected_msg = ( "Could not parse /etc/os-release VERSION: junk (modified to junk)" ) assert expected_msg == str(excinfo.value) @pytest.mark.parametrize( "series,release,version,os_release_content", [ ("trusty", "14.04", "14.04 LTS (Trusty Tahr)", OS_RELEASE_TRUSTY), ("xenial", "16.04", "16.04 LTS (Xenial Xerus)", OS_RELEASE_XENIAL), ( "bionic", "18.04", "18.04 LTS (Bionic Beaver)", OS_RELEASE_BIONIC, ), ("disco", "19.04", "19.04 (Disco Dingo)", OS_RELEASE_DISCO), ], ) def test_get_platform_info_with_version( self, series, release, version, os_release_content, tmpdir ): release_file = tmpdir.join("os-release") release_file.write(os_release_content) parse_dict = util.parse_os_release(release_file.strpath) expected = { "arch": "arm64", "distribution": "Ubuntu", "kernel": "kernel-ver", "release": release, "series": series, "type": "Linux", "version": version, } with mock.patch("uaclient.util.parse_os_release") as m_parse: with mock.patch("uaclient.util.os.uname") as m_uname: m_parse.return_value = parse_dict # (sysname, nodename, release, version, machine) m_uname.return_value = posix.uname_result( ("", "", "kernel-ver", "", "arm64") ) assert expected == util.get_platform_info() class TestApplySeriesOverrides: def test_error_on_non_entitlement_dict(self): """Raise a runtime error when seeing invalid dict type.""" with pytest.raises(RuntimeError) as exc: util.apply_series_overrides({"some": "dict"}) error = ( 'Expected entitlement access dict. Missing "entitlement" key:' " {'some': 'dict'}" ) assert error == str(exc.value) @mock.patch( "uaclient.util.get_platform_info", return_value={"series": "xenial"} ) def test_mutates_orig_access_dict(self, _): """Mutate orig_access dict when called.""" orig_access = { "entitlement": { "a": {"a1": "av1", "a2": {"aa2": "aav2"}}, "b": "b1", "c": "c1", "series": { "trusty": {"a": "t1"}, "xenial": {"a": {"a2": {"aa2": "xxv2"}}, "b": "bx1"}, }, } } expected = { "entitlement": { "a": {"a1": "av1", "a2": {"aa2": "xxv2"}}, "b": "bx1", "c": "c1", } } util.apply_series_overrides(orig_access) assert orig_access == expected @mock.patch( "uaclient.util.get_platform_info", return_value={"series": "xenial"} ) def test_missing_keys_are_handled(self, _): orig_access = { "entitlement": { "series": {"xenial": {"directives": {"suites": ["xenial"]}}} } } expected = {"entitlement": {"directives": {"suites": ["xenial"]}}} util.apply_series_overrides(orig_access) assert expected == orig_access class TestGetMachineId: def test_get_machine_id_from_etc_machine_id(self, tmpdir): """Presence of /etc/machine-id is returned if it exists.""" etc_machine_id = tmpdir.join("etc-machine-id") assert "/etc/machine-id" == util.ETC_MACHINE_ID etc_machine_id.write("etc-machine-id") with mock.patch( "uaclient.util.ETC_MACHINE_ID", etc_machine_id.strpath ): value = util.get_machine_id( data_dir=tmpdir.join("non-existent").strpath ) assert "etc-machine-id" == value def test_get_machine_id_from_var_lib_dbus_machine_id(self, tmpdir): """On trusty, machine id lives in of /var/lib/dbus/machine-id.""" etc_machine_id = tmpdir.join("etc-machine-id") dbus_machine_id = tmpdir.join("dbus-machine-id") assert "/var/lib/dbus/machine-id" == util.DBUS_MACHINE_ID dbus_machine_id.write("dbus-machine-id") with mock.patch( "uaclient.util.DBUS_MACHINE_ID", dbus_machine_id.strpath ): with mock.patch( "uaclient.util.ETC_MACHINE_ID", etc_machine_id.strpath ): value = util.get_machine_id( data_dir=tmpdir.join("non-existent").strpath ) assert "dbus-machine-id" == value def test_get_machine_id_uses_machine_id_from_data_dir(self, tmpdir): """When no machine-id is found, use machine-id from data_dir.""" data_machine_id = tmpdir.join("machine-id") data_machine_id.write("data-machine-id") def fake_exists(path): return bool(path == data_machine_id.strpath) with mock.patch("uaclient.util.os.path.exists") as m_exists: m_exists.side_effect = fake_exists value = util.get_machine_id(data_dir=tmpdir.strpath) assert "data-machine-id" == value def test_get_machine_id_create_machine_id_in_data_dir(self, tmpdir): """When no machine-id is found, create one in data_dir using uuid4.""" data_machine_id = tmpdir.join("machine-id") with mock.patch("uaclient.util.os.path.exists") as m_exists: with mock.patch("uaclient.util.uuid.uuid4") as m_uuid4: m_exists.return_value = False m_uuid4.return_value = uuid.UUID( "0123456789abcdef0123456789abcdef" ) value = util.get_machine_id(data_dir=tmpdir.strpath) assert "01234567-89ab-cdef-0123-456789abcdef" == value assert "01234567-89ab-cdef-0123-456789abcdef" == data_machine_id.read() @pytest.mark.parametrize("empty_value", ["", "\n"]) def test_fallback_used_if_all_other_files_are_empty( self, tmpdir, empty_value ): data_machine_id = tmpdir.join("machine-id") with mock.patch("uaclient.util.os.path.exists") as m_exists: m_exists.return_value = True with mock.patch( "uaclient.util.load_file", return_value=empty_value ): with mock.patch("uaclient.util.uuid.uuid4") as m_uuid4: m_uuid4.return_value = uuid.UUID( "0123456789abcdef0123456789abcdef" ) value = util.get_machine_id(data_dir=tmpdir.strpath) assert "01234567-89ab-cdef-0123-456789abcdef" == value assert "01234567-89ab-cdef-0123-456789abcdef" == data_machine_id.read() class TestIsServiceUrl: @pytest.mark.parametrize( "url,is_valid", (("http://asdf", True), ("http://asdf/", True), ("asdf", False)), ) def test_is_valid_url(self, url, is_valid): ret = util.is_service_url(url) assert is_valid is ret class TestReadurl: def test_simple_call_with_url_works(self): with mock.patch("uaclient.util.request.urlopen") as m_urlopen: util.readurl("http://some_url") assert 1 == m_urlopen.call_count @pytest.mark.parametrize( "data", [b"{}", b"not a dict", b'{"caveat_id": "dict"}'] ) def test_data_passed_through_unchanged(self, data): with mock.patch("uaclient.util.request.urlopen") as m_urlopen: util.readurl("http://some_url", data=data) assert 1 == m_urlopen.call_count req = m_urlopen.call_args[0][0] # the first positional argument assert data == req.data class TestDisableLogToConsole: @pytest.mark.parametrize("caplog_text", [logging.DEBUG], indirect=True) def test_no_error_if_console_handler_not_found(self, caplog_text): with mock.patch("uaclient.util.logging.getLogger") as m_getlogger: m_getlogger.return_value.handlers = [] with util.disable_log_to_console(): pass assert "no console handler found" in caplog_text() @pytest.mark.parametrize("disable_log", (True, False)) def test_disable_log_to_console( self, logging_sandbox, capsys, disable_log ): # This test is parameterised so that we are sure that the context # manager is suppressing the output, not some other config change cli.setup_logging(logging.INFO, logging.INFO) if disable_log: context_manager = util.disable_log_to_console else: context_manager = mock.MagicMock with context_manager(): logging.error("test error") logging.info("test info") out, err = capsys.readouterr() combined_output = out + err if disable_log: assert not combined_output else: assert "test error" in combined_output assert "test info" in combined_output def test_disable_log_to_console_does_nothing_at_debug_level( self, logging_sandbox, capsys ): cli.setup_logging(logging.DEBUG, logging.DEBUG) with util.disable_log_to_console(): logging.error("test error") logging.info("test info") out, err = capsys.readouterr() combined_output = out + err assert "test error" in combined_output assert "test info" in combined_output JSON_TEST_PAIRS = ( ("a", '"a"'), (1, "1"), ({"a": 1}, '{"a": 1}'), # See the note in DatetimeAwareJSONDecoder for why this datetime is in a # dict ( {"dt": datetime.datetime(2019, 7, 25, 14, 35, 51)}, '{"dt": "2019-07-25T14:35:51"}', ), ) class TestDatetimeAwareJSONEncoder: @pytest.mark.parametrize("input,out", JSON_TEST_PAIRS) def test_encode(self, input, out): assert out == json.dumps(input, cls=util.DatetimeAwareJSONEncoder) class TestDatetimeAwareJSONDecoder: # Note that the parameter names are flipped from # TestDatetimeAwareJSONEncoder @pytest.mark.parametrize("out,input", JSON_TEST_PAIRS) def test_encode(self, input, out): assert out == json.loads(input, cls=util.DatetimeAwareJSONDecoder) @mock.patch("builtins.input") class TestPromptForConfirmation: @pytest.mark.parametrize( "return_value,user_input", [(True, yes_input) for yes_input in ["y", "Y", "yes", "YES", "YeS"]] + [ (False, no_input) for no_input in ["n", "N", "no", "NO", "No", "asdf", "", "\nfoo\n"] ], ) def test_input_conversion(self, m_input, return_value, user_input): m_input.return_value = user_input assert return_value == util.prompt_for_confirmation() def test_prompt_text(self, m_input): util.prompt_for_confirmation() assert [mock.call("Are you sure? (y/N) ")] == m_input.call_args_list ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/tests/test_apt.py0000664000000000000000000010517715223224541021612 0ustar """Tests related to uaclient.apt module.""" import glob import mock import os import stat import subprocess from textwrap import dedent import pytest from uaclient.apt import ( APT_KEYS_DIR, APT_AUTH_COMMENT, KEYRINGS_DIR, add_apt_auth_conf_entry, add_auth_apt_repo, add_ppa_pinning, assert_valid_apt_credentials, assert_valid_apt_directives, clean_apt_sources, find_apt_list_files, get_installed_packages, remove_apt_list_files, remove_auth_apt_repo, remove_repo_from_apt_auth_file, ) from uaclient import apt, exceptions, util from uaclient.entitlements.tests.test_base import ConcreteTestEntitlement POST_INSTALL_APT_CACHE_NO_UPDATES = """ -32768 https://esm.ubuntu.com/ubuntu/ {0}-updates/main amd64 Packages release v=14.04,o={1},a={0}-updates,n={0},l=UbuntuESM,c=main origin esm.ubuntu.com """ class TestAddPPAPinning: @mock.patch("uaclient.util.get_platform_info") def test_write_apt_pin_file_to_apt_preferences(self, m_platform, tmpdir): """Write proper apt pin file to specified apt_preference_file.""" m_platform.return_value = {"series": "xenial"} pref_file = tmpdir.join("preffile").strpath assert None is add_ppa_pinning( pref_file, repo_url="http://fakerepo", origin="MYORIG", priority=1003, ) expected_pref = dedent( """\ Package: * Pin: release o=MYORIG, n=xenial Pin-Priority: 1003\n""" ) assert expected_pref == util.load_file(pref_file) class TestFindAptListFilesFromRepoSeries: @mock.patch("uaclient.util.subp") def test_find_all_apt_list_files_from_apt_config_key(self, m_subp, tmpdir): """Find all matching apt list files from apt-config dir.""" m_subp.return_value = ("key='{}'".format(tmpdir.strpath), "") repo_url = "http://c.com/fips-updates/" _protocol, repo_path = repo_url.split("://") prefix = repo_path.rstrip("/").replace("/", "_") paths = sorted( [ tmpdir.join(prefix + "_dists_nomatch").strpath, tmpdir.join(prefix + "_dists_xenial_InRelease").strpath, tmpdir.join( prefix + "_dists_xenial_main_binary-amd64_Packages" ).strpath, ] ) for path in paths: util.write_file(path, "") assert paths[1:] == find_apt_list_files(repo_url, "xenial") class TestRemoveAptListFiles: @mock.patch("uaclient.util.subp") def test_remove_all_apt_list_files_from_apt_config_key( self, m_subp, tmpdir ): """Remove all matching apt list files from apt-config dir.""" m_subp.return_value = ("key='{}'".format(tmpdir.strpath), "") repo_url = "http://c.com/fips-updates/" _protocol, repo_path = repo_url.split("://") prefix = repo_path.rstrip("/").replace("/", "_") nomatch_file = tmpdir.join(prefix + "_dists_nomatch").strpath paths = [ nomatch_file, tmpdir.join(prefix + "_dists_xenial_InRelease").strpath, tmpdir.join( prefix + "_dists_xenial_main_binary-amd64_Packages" ).strpath, ] for path in paths: util.write_file(path, "") assert None is remove_apt_list_files(repo_url, "xenial") assert [nomatch_file] == glob.glob("{}/*".format(tmpdir.strpath)) class TestValidAptDirectives: """Tests for assert_valid_apt_directives. Regression tests for CVE-2026-11386 (newline injection in apt sources via contract directives). """ @pytest.mark.parametrize( "repo_url,suites", ( ( "https://esm.ubuntu.com/infra/ubuntu", ["trusty", "trusty-updates"], ), ("http://fakerepo", ["trusty"]), ( "https://esm.ubuntu.com/apps/ubuntu", ["xenial", "xenial-updates"], ), ), ) def test_accepts_valid_directives(self, repo_url, suites): """Well-formed directive values pass validation.""" assert None is assert_valid_apt_directives(repo_url, suites) @pytest.mark.parametrize( "repo_url", ( "https://esm.ubuntu.com\nhttps://evil.example.com", "https://esm.ubuntu.com\revil", "https://esm.ubuntu.com\x00/evil", "https://esm.ubuntu.com /evil", ), ) def test_rejects_invalid_repo_url(self, repo_url): """Reject repo URLs containing control characters or spaces.""" with pytest.raises(exceptions.UserFacingError) as exc_info: assert_valid_apt_directives(repo_url, ["trusty"]) assert "URL directive contains invalid characters" in str( exc_info.value ) @pytest.mark.parametrize( "suite", ( "trusty\ndeb [trusted=yes] http://attacker.example.com/repo" " trusty main", "trusty\revil", "trusty\x00evil", "trusty main", " ", ), ) def test_rejects_invalid_suite(self, suite): """Reject suite values containing control characters or spaces.""" with pytest.raises(exceptions.UserFacingError) as exc_info: assert_valid_apt_directives( "https://esm.ubuntu.com/infra/ubuntu", [suite] ) assert "suite directive contains invalid characters" in str( exc_info.value ) class TestValidAptCredentials: """Tests for assert_valid_apt_credentials. Includes regression tests for CVE-2026-9494 (credential leakage via /proc//cmdline). """ @mock.patch("uaclient.util.subp") @mock.patch("os.path.exists", return_value=False) def test_passes_when_missing_apt_helper(self, m_exists, m_subp): """When apt-helper tool is absent perform no validation.""" assert None is assert_valid_apt_credentials( repo_url="http://fakerepo", username="username", password="pass" ) expected_calls = [mock.call("/usr/lib/apt/apt-helper")] assert expected_calls == m_exists.call_args_list assert 0 == m_subp.call_count @mock.patch("uaclient.apt.tempfile.TemporaryDirectory") @mock.patch("uaclient.util.subp") @mock.patch("uaclient.util.del_file") @mock.patch("uaclient.util.write_file") @mock.patch( "uaclient.apt.get_apt_auth_file_from_apt_config", return_value="/etc/apt/auth.conf.d/90ubuntu-advantage", ) @mock.patch("uaclient.apt.os.path.exists", return_value=True) def test_passes_on_valid_creds( self, m_exists, m_get_apt_auth, m_write_file, m_del_file, m_subp, m_temporary_directory, ): """Succeed when apt-helper succeeds in authenticating to repo.""" m_temporary_directory.return_value.__enter__.return_value = ( "/does/not/exist" ) # Success apt-helper response m_subp.return_value = "Get:1 https://fakerepo\nFetched 285 B in 1s", "" assert None is assert_valid_apt_credentials( repo_url="http://fakerepo", username="user", password="pwd" ) exists_calls = [mock.call("/usr/lib/apt/apt-helper")] assert exists_calls == m_exists.call_args_list expected_path = os.path.join( m_temporary_directory.return_value.__enter__.return_value, "apt-helper-output", ) apt_helper_call = mock.call( [ "/usr/lib/apt/apt-helper", "download-file", "http://fakerepo/ubuntu/pool/", expected_path, ], timeout=20, ) assert [apt_helper_call] == m_subp.call_args_list # Verify credentials are written to auth file, not in argv auth_path = "/etc/apt/auth.conf.d/90ubuntu-advantage-validation" m_write_file.assert_called_once_with( auth_path, "machine fakerepo/ login user password pwd\n", mode=0o600, ) m_del_file.assert_called_once_with(auth_path) @pytest.mark.parametrize( "exit_code,stderr,error_msg", ( ( 1, "something broke", "Unexpected APT error. See /var/log/ubuntu-advantage.log", ), ( 100, "E: Failed to fetch ... HttpError401 on trusty", "Invalid APT credentials provided for http://fakerepo", ), ( 100, "E: Failed to fetch ... 401 Unauthorized on xenial", "Invalid APT credentials provided for http://fakerepo", ), ( 100, "E: Failed to fetch ... Connection timed out", "Timeout trying to access APT repository at http://fakerepo", ), ), ) @mock.patch("uaclient.apt.tempfile.TemporaryDirectory") @mock.patch("uaclient.util.subp") @mock.patch("uaclient.util.del_file") @mock.patch("uaclient.util.write_file") @mock.patch( "uaclient.apt.get_apt_auth_file_from_apt_config", return_value="/etc/apt/auth.conf.d/90ubuntu-advantage", ) @mock.patch("uaclient.apt.os.path.exists", return_value=True) def test_errors_on_process_execution_errors( self, m_exists, m_get_apt_auth, m_write_file, m_del_file, m_subp, m_temporary_directory, exit_code, stderr, error_msg, ): """Raise the appropriate user facing error from apt-helper failure.""" m_temporary_directory.return_value.__enter__.return_value = ( "/does/not/exist" ) # Failure apt-helper response m_subp.side_effect = util.ProcessExecutionError( cmd="apt-helper ", exit_code=exit_code, stdout="Err:1...", stderr=stderr, ) with pytest.raises(exceptions.UserFacingError) as excinfo: assert_valid_apt_credentials( repo_url="http://fakerepo", username="user", password="pwd" ) assert error_msg == str(excinfo.value) exists_calls = [mock.call("/usr/lib/apt/apt-helper")] assert exists_calls == m_exists.call_args_list expected_path = os.path.join( m_temporary_directory.return_value.__enter__.return_value, "apt-helper-output", ) apt_helper_call = mock.call( [ "/usr/lib/apt/apt-helper", "download-file", "http://fakerepo/ubuntu/pool/", expected_path, ], timeout=20, ) assert [apt_helper_call] == m_subp.call_args_list # Verify auth file is always cleaned up even on error auth_path = "/etc/apt/auth.conf.d/90ubuntu-advantage-validation" m_del_file.assert_called_once_with(auth_path) @mock.patch("uaclient.apt.tempfile.TemporaryDirectory") @mock.patch("uaclient.util.subp") @mock.patch("uaclient.util.del_file") @mock.patch("uaclient.util.write_file") @mock.patch( "uaclient.apt.get_apt_auth_file_from_apt_config", return_value="/etc/apt/auth.conf.d/90ubuntu-advantage", ) @mock.patch("uaclient.apt.os.path.exists", return_value=True) def test_errors_on_apt_helper_process_timeout( self, m_exists, m_get_apt_auth, m_write_file, m_del_file, m_subp, m_temporary_directory, ): """Raise the appropriate user facing error from apt-helper timeout.""" m_temporary_directory.return_value.__enter__.return_value = ( "/does/not/exist" ) # Failure apt-helper response m_subp.side_effect = subprocess.TimeoutExpired( "something timed out", timeout=1000000 ) with pytest.raises(exceptions.UserFacingError) as excinfo: assert_valid_apt_credentials( repo_url="http://fakerepo", username="user", password="pwd" ) error_msg = ( "Cannot validate credentials for APT repo. Timeout" " after {} seconds trying to reach fakerepo.".format( apt.APT_HELPER_TIMEOUT ) ) assert error_msg == excinfo.value.msg exists_calls = [mock.call("/usr/lib/apt/apt-helper")] assert exists_calls == m_exists.call_args_list expected_path = os.path.join( m_temporary_directory.return_value.__enter__.return_value, "apt-helper-output", ) apt_helper_call = mock.call( [ "/usr/lib/apt/apt-helper", "download-file", "http://fakerepo/ubuntu/pool/", expected_path, ], timeout=apt.APT_HELPER_TIMEOUT, ) assert [apt_helper_call] == m_subp.call_args_list # Verify auth file is always cleaned up even on timeout auth_path = "/etc/apt/auth.conf.d/90ubuntu-advantage-validation" m_del_file.assert_called_once_with(auth_path) @mock.patch("uaclient.apt.tempfile.TemporaryDirectory") @mock.patch("uaclient.util.subp") @mock.patch("uaclient.util.del_file") @mock.patch("uaclient.util.write_file") @mock.patch( "uaclient.apt.get_apt_auth_file_from_apt_config", return_value="/etc/apt/auth.conf.d/90ubuntu-advantage", ) @mock.patch("uaclient.apt.os.path.exists", return_value=True) def test_credentials_not_in_subprocess_argv( self, m_exists, m_get_apt_auth, m_write_file, m_del_file, m_subp, m_temporary_directory, ): """Credentials must not appear in apt-helper argv (CVE fix). Verify that the bearer token / password is never passed as part of the subprocess command line, preventing leakage via /proc//cmdline. """ m_temporary_directory.return_value.__enter__.return_value = ( "/does/not/exist" ) m_subp.return_value = ( "Get:1 https://esm.ubuntu.com\nFetched 285 B", "", ) secret_token = "super-secret-bearer-token-value" assert_valid_apt_credentials( repo_url="https://esm.ubuntu.com/infra/ubuntu", username="bearer", password=secret_token, ) # Assert the secret never appears in any argument to subp subp_call_args = m_subp.call_args[0][0] # first positional: argv list for arg in subp_call_args: assert secret_token not in arg, ( "Credential leaked into subprocess argv: %s" % arg ) assert "bearer" not in arg or arg == "/usr/lib/apt/apt-helper", ( "Username leaked into subprocess argv: %s" % arg ) # Assert credentials are instead written to the auth file auth_path = "/etc/apt/auth.conf.d/90ubuntu-advantage-validation" m_write_file.assert_called_once_with( auth_path, "machine esm.ubuntu.com/infra/ubuntu/ login bearer" " password super-secret-bearer-token-value\n", mode=0o600, ) # Assert auth file is cleaned up m_del_file.assert_called_once_with(auth_path) class TestAddAuthAptRepo: @mock.patch("uaclient.apt.gpg.export_gpg_key") @mock.patch("uaclient.util.subp") @mock.patch("uaclient.apt.get_apt_auth_file_from_apt_config") @mock.patch("uaclient.apt.assert_valid_apt_credentials") @mock.patch( "uaclient.util.get_platform_info", return_value={"series": "xenial"} ) def test_add_auth_apt_repo_writes_sources_file( self, m_platform, m_valid_creds, m_get_apt_auth_file, m_subp, m_gpg_export, tmpdir, ): """Write a properly configured sources file to repo_filename.""" repo_file = tmpdir.join("repo.conf").strpath auth_file = tmpdir.join("auth.conf").strpath m_get_apt_auth_file.return_value = auth_file m_subp.return_value = "500 esm.canonical.com...", "" # apt policy add_auth_apt_repo( repo_filename=repo_file, repo_url="http://fakerepo", credentials="mycreds", suites=("xenial",), keyring_file="keyring", ) expected_content = ( "deb http://fakerepo/ubuntu xenial main\n" "# deb-src http://fakerepo/ubuntu xenial main\n" ) assert expected_content == util.load_file(repo_file) src_keyfile = os.path.join(KEYRINGS_DIR, "keyring") dest_keyfile = os.path.join(APT_KEYS_DIR, "keyring") gpg_export_calls = [mock.call(src_keyfile, dest_keyfile)] assert gpg_export_calls == m_gpg_export.call_args_list @mock.patch("uaclient.apt.gpg.export_gpg_key") @mock.patch("uaclient.util.subp") @mock.patch("uaclient.apt.get_apt_auth_file_from_apt_config") @mock.patch("uaclient.apt.assert_valid_apt_credentials") @mock.patch( "uaclient.util.get_platform_info", return_value={"series": "xenial"} ) def test_add_auth_apt_repo_ignores_suites_not_matching_series( self, m_platform, m_valid_creds, m_get_apt_auth_file, m_subp, m_gpg_export, tmpdir, ): """Skip any apt suites that don't match the current series.""" repo_file = tmpdir.join("repo.conf").strpath auth_file = tmpdir.join("auth.conf").strpath m_get_apt_auth_file.return_value = auth_file # apt policy with xenial-updates enabled stdout = dedent( """\ 500 http://archive.ubuntu.com/ubuntu/ xenial-updates/main amd64 \ Packages release v=16.04,o=Ubuntu,a=xenial-updates,n=xenial,l=Ubuntu\ ,c=main""" ) m_subp.return_value = stdout, "" add_auth_apt_repo( repo_filename=repo_file, repo_url="http://fakerepo", credentials="mycreds", suites=("xenial-one", "xenial-updates", "trusty-gone"), keyring_file="keyring", ) expected_content = dedent( """\ deb http://fakerepo/ubuntu xenial-one main # deb-src http://fakerepo/ubuntu xenial-one main deb http://fakerepo/ubuntu xenial-updates main # deb-src http://fakerepo/ubuntu xenial-updates main """ ) assert expected_content == util.load_file(repo_file) @mock.patch("uaclient.apt.gpg.export_gpg_key") @mock.patch("uaclient.util.subp") @mock.patch("uaclient.apt.get_apt_auth_file_from_apt_config") @mock.patch("uaclient.apt.assert_valid_apt_credentials") @mock.patch( "uaclient.util.get_platform_info", return_value={"series": "xenial"} ) def test_add_auth_apt_repo_comments_updates_suites_on_non_update_machine( self, m_platform, m_valid_creds, m_get_apt_auth_file, m_subp, m_gpg_export, tmpdir, ): """Skip any apt suites that don't match the current series.""" repo_file = tmpdir.join("repo.conf").strpath auth_file = tmpdir.join("auth.conf").strpath m_get_apt_auth_file.return_value = auth_file # apt policy without xenial-updates enabled origin = "test-origin" m_subp.return_value = ( POST_INSTALL_APT_CACHE_NO_UPDATES.format("xenial", origin), "", ) add_auth_apt_repo( repo_filename=repo_file, repo_url="http://fakerepo", credentials="mycreds", suites=("xenial-one", "xenial-updates", "trusty-gone"), keyring_file="keyring", ) expected_content = dedent( """\ deb http://fakerepo/ubuntu xenial-one main # deb-src http://fakerepo/ubuntu xenial-one main # deb http://fakerepo/ubuntu xenial-updates main # deb-src http://fakerepo/ubuntu xenial-updates main """ ) assert expected_content == util.load_file(repo_file) @mock.patch("uaclient.apt.gpg.export_gpg_key") @mock.patch("uaclient.util.subp") @mock.patch("uaclient.apt.get_apt_auth_file_from_apt_config") @mock.patch("uaclient.apt.assert_valid_apt_credentials") @mock.patch( "uaclient.util.get_platform_info", return_value={"series": "xenial"} ) def test_add_auth_apt_repo_writes_username_password_to_auth_file( self, m_platform, m_valid_creds, m_get_apt_auth_file, m_subp, m_gpg_export, tmpdir, ): """Write apt authentication file when credentials are user:pwd.""" repo_file = tmpdir.join("repo.conf").strpath auth_file = tmpdir.join("auth.conf").strpath m_get_apt_auth_file.return_value = auth_file m_subp.return_value = "500 esm.canonical.com...", "" # apt policy add_auth_apt_repo( repo_filename=repo_file, repo_url="http://fakerepo", credentials="user:password", suites=("xenial",), keyring_file="keyring", ) expected_content = ( "machine fakerepo/ login user password password" "{}\n".format(APT_AUTH_COMMENT) ) assert expected_content == util.load_file(auth_file) @mock.patch("uaclient.apt.gpg.export_gpg_key") @mock.patch("uaclient.util.subp") @mock.patch("uaclient.apt.get_apt_auth_file_from_apt_config") @mock.patch("uaclient.apt.assert_valid_apt_credentials") @mock.patch( "uaclient.util.get_platform_info", return_value={"series": "xenial"} ) def test_add_auth_apt_repo_writes_bearer_resource_token_to_auth_file( self, m_platform, m_valid_creds, m_get_apt_auth_file, m_subp, m_gpg_export, tmpdir, ): """Write apt authentication file when credentials are bearer token.""" repo_file = tmpdir.join("repo.conf").strpath auth_file = tmpdir.join("auth.conf").strpath m_get_apt_auth_file.return_value = auth_file m_subp.return_value = "500 esm.canonical.com...", "" # apt policy add_auth_apt_repo( repo_filename=repo_file, repo_url="http://fakerepo/", credentials="SOMELONGTOKEN", suites=("xenia",), keyring_file="keyring", ) expected_content = ( "machine fakerepo/ login bearer password" " SOMELONGTOKEN{}\n".format(APT_AUTH_COMMENT) ) assert expected_content == util.load_file(auth_file) class TestAddAptAuthConfEntry: @mock.patch("uaclient.apt.get_apt_auth_file_from_apt_config") def test_replaces_old_credentials_with_new( self, m_get_apt_auth_file, tmpdir ): """Replace old credentials for this repo_url on the same line.""" auth_file = tmpdir.join("auth.conf").strpath util.write_file( auth_file, dedent( """\ machine fakerepo1/ login me password password1 machine fakerepo/ login old password oldpassword machine fakerepo2/ login other password otherpass """ ), ) m_get_apt_auth_file.return_value = auth_file add_apt_auth_conf_entry( login="newlogin", password="newpass", repo_url="http://fakerepo/" ) content_template = dedent( """\ machine fakerepo1/ login me password password1 machine fakerepo/ login newlogin password newpass{} machine fakerepo2/ login other password otherpass """ ) expected_content = content_template.format(APT_AUTH_COMMENT) assert expected_content == util.load_file(auth_file) @mock.patch("uaclient.apt.get_apt_auth_file_from_apt_config") def test_insert_repo_subroutes_before_existing_repo_basepath( self, m_get_apt_auth_file, tmpdir ): """Insert new repo_url before first matching url base path.""" auth_file = tmpdir.join("auth.conf").strpath util.write_file( auth_file, dedent( """\ machine fakerepo1/ login me password password1 machine fakerepo/ login old password oldpassword machine fakerepo2/ login other password otherpass """ ), ) m_get_apt_auth_file.return_value = auth_file add_apt_auth_conf_entry( login="new", password="newpass", repo_url="http://fakerepo/subroute", ) content_template = dedent( """\ machine fakerepo1/ login me password password1 machine fakerepo/subroute/ login new password newpass{} machine fakerepo/ login old password oldpassword machine fakerepo2/ login other password otherpass """ ) expected_content = content_template.format(APT_AUTH_COMMENT) assert expected_content == util.load_file(auth_file) class TestCleanAptSources: @pytest.fixture def mock_apt_entitlement(self, tmpdir): # Set up our tmpdir with some fake list files entitlement_name = "test_ent" file_tmpl = tmpdir.join("{name}-{series}").strpath for series in ["acidic", "base"]: file_name = file_tmpl.format(name=entitlement_name, series=series) with open(file_name, "w") as f: f.write("") m_entitlement = mock.Mock(spec=ConcreteTestEntitlement) m_entitlement.configure_mock( name=entitlement_name, repo_url="some url", repo_list_file_tmpl=file_tmpl, ) return m_entitlement @mock.patch("uaclient.apt.os.unlink") def test_no_removals_for_no_repo_entitlements(self, m_os_unlink): m_entitlements = mock.Mock() m_entitlements.ENTITLEMENT_CLASSES = [ConcreteTestEntitlement] clean_apt_sources(_entitlements=m_entitlements) assert 0 == m_os_unlink.call_count def test_files_for_all_series_removed(self, mock_apt_entitlement, tmpdir): m_entitlements = mock.Mock() m_entitlements.ENTITLEMENT_CLASSES = [mock_apt_entitlement] clean_apt_sources(_entitlements=m_entitlements) assert [] == tmpdir.listdir() def test_other_files_not_removed(self, mock_apt_entitlement, tmpdir): other_filename = "other_file-acidic" tmpdir.join(other_filename).ensure() m_entitlements = mock.Mock() m_entitlements.ENTITLEMENT_CLASSES = [mock_apt_entitlement] clean_apt_sources(_entitlements=m_entitlements) assert [tmpdir.join(other_filename)] == tmpdir.listdir() @pytest.fixture(params=(mock.sentinel.default, None, "some_string")) def remove_auth_apt_repo_kwargs(request): """ Parameterized fixture to generate all permutations of kwargs we need Note that this tests three states for keyring_file: using the default, explicitly passing None and explicitly passing a string. """ keyring_file = request.param kwargs = {} if keyring_file != mock.sentinel.default: kwargs["keyring_file"] = keyring_file return kwargs class TestRemoveAuthAptRepo: @mock.patch("uaclient.apt.util.subp") @mock.patch("uaclient.apt.remove_repo_from_apt_auth_file") @mock.patch("uaclient.apt.util.del_file") def test_repo_file_deleted( self, m_del_file, _mock, __mock, remove_auth_apt_repo_kwargs ): """Ensure that repo_filename is deleted, regardless of other params.""" repo_filename, repo_url = mock.sentinel.filename, mock.sentinel.url remove_auth_apt_repo( repo_filename, repo_url, **remove_auth_apt_repo_kwargs ) assert mock.call(repo_filename) in m_del_file.call_args_list @mock.patch("uaclient.apt.util.subp") @mock.patch("uaclient.apt.util.del_file") @mock.patch("uaclient.apt.remove_repo_from_apt_auth_file") def test_remove_from_auth_file_called( self, m_remove_repo, _mock, __mock, remove_auth_apt_repo_kwargs ): """Ensure that remove_repo_from_apt_auth_file is called.""" repo_filename, repo_url = mock.sentinel.filename, mock.sentinel.url remove_auth_apt_repo( repo_filename, repo_url, **remove_auth_apt_repo_kwargs ) assert mock.call(repo_url) in m_remove_repo.call_args_list @mock.patch("uaclient.apt.util.subp") @mock.patch("uaclient.apt.remove_repo_from_apt_auth_file") @mock.patch("uaclient.apt.util.del_file") def test_keyring_file_deleted_if_given( self, m_del_file, _mock, __mock, remove_auth_apt_repo_kwargs ): """We should always delete the keyring file if it is given""" repo_filename, repo_url = mock.sentinel.filename, mock.sentinel.url remove_auth_apt_repo( repo_filename, repo_url, **remove_auth_apt_repo_kwargs ) keyring_file = remove_auth_apt_repo_kwargs.get("keyring_file") if keyring_file: assert ( mock.call(os.path.join(APT_KEYS_DIR, keyring_file)) in m_del_file.call_args_list ) else: assert mock.call(keyring_file) not in m_del_file.call_args_list class TestRemoveRepoFromAptAuthFile: @mock.patch("uaclient.apt.os.unlink") @mock.patch("uaclient.apt.util.write_file") @mock.patch("uaclient.apt.get_apt_auth_file_from_apt_config") def test_auth_file_doesnt_exist_means_we_dont_remove_or_write_it( self, m_get_apt_auth_file, m_write_file, m_unlink, tmpdir ): """If the auth file doesn't exist, we shouldn't do anything to it""" m_get_apt_auth_file.return_value = tmpdir.join("nonexistent").strpath remove_repo_from_apt_auth_file("http://url") assert 0 == m_write_file.call_count assert 0 == m_unlink.call_count @pytest.mark.parametrize("trailing_slash", (True, False)) @pytest.mark.parametrize( "repo_url,auth_file_content", ( ("http://url1", b""), ("http://url2", b"machine url2/ login trailing content"), ("http://url3", b"machine url3/ login"), ("http://url4", b"leading content machine url4/ login"), ( "http://url4", b"leading content machine url4/ login trailing content", ), ), ) @mock.patch("uaclient.apt.os.unlink") @mock.patch("uaclient.apt.util.write_file") @mock.patch("uaclient.apt.get_apt_auth_file_from_apt_config") def test_file_removal( self, m_get_apt_auth_file, m_write_file, m_unlink, tmpdir, trailing_slash, repo_url, auth_file_content, ): """Check that auth file is rm'd if empty or contains just our line""" auth_file = tmpdir.join("auth_file") auth_file.write(auth_file_content, "wb") m_get_apt_auth_file.return_value = auth_file.strpath remove_repo_from_apt_auth_file( repo_url + ("" if not trailing_slash else "/") ) assert 0 == m_write_file.call_count assert [mock.call(auth_file.strpath)] == m_unlink.call_args_list @pytest.mark.parametrize("trailing_slash", (True, False)) @pytest.mark.parametrize( "repo_url,before_content,after_content", ( ( "http://url1", b"should not be changed", b"should not be changed", ), ( "http://url1", b"line before\nmachine url1/ login", b"line before", ), ("http://url1", b"machine url1/ login\nline after", b"line after"), ( "http://url1", b"line before\nmachine url1/ login\nline after", b"line before\nline after", ), ( "http://url1", b"unicode \xe2\x98\x83\nmachine url1/ login", b"unicode \xe2\x98\x83", ), ), ) @mock.patch("uaclient.apt.os.unlink") @mock.patch("uaclient.apt.get_apt_auth_file_from_apt_config") def test_file_rewrite( self, m_get_apt_auth_file, m_unlink, tmpdir, repo_url, before_content, after_content, trailing_slash, ): """Check that auth file is rewritten to only exclude our line""" auth_file = tmpdir.join("auth_file") auth_file.write(before_content, "wb") m_get_apt_auth_file.return_value = auth_file.strpath remove_repo_from_apt_auth_file( repo_url + ("" if not trailing_slash else "/") ) assert 0 == m_unlink.call_count assert 0o600 == stat.S_IMODE(os.lstat(auth_file.strpath).st_mode) assert after_content == auth_file.read("rb") class TestRestoreCommentAptListFile: @pytest.mark.parametrize( "before,expected", ( ("", ""), ("some other content", "some other content"), ("deb uncommented", "deb uncommented"), ("# deb-src comment", "# deb-src comment"), ("# deb comment", "deb comment"), ( "deb uncommented\n# deb commented", "deb uncommented\ndeb commented", ), ( "# deb commented\n# deb-src commented", "deb commented\n# deb-src commented", ), ( "deb uncommented\n# deb-src commented\n" "# deb commented\n# deb-src commented", "deb uncommented\n# deb-src commented\n" "deb commented\n# deb-src commented", ), ), ) def test_permutations(self, tmpdir, before, expected): list_file = tmpdir.join("list_file") list_file.write(before) apt.restore_commented_apt_list_file(list_file.strpath) assert expected == list_file.read() class TestGetInstalledPackages: @mock.patch("uaclient.apt.util.subp", return_value=("", "")) def test_correct_command_called(self, m_subp): get_installed_packages() expected_call = mock.call( ["dpkg-query", "-W", "--showformat=${Package}\\n"] ) assert [expected_call] == m_subp.call_args_list @mock.patch("uaclient.apt.util.subp", return_value=("", "")) def test_empty_output_means_empty_list(self, m_subp): assert [] == get_installed_packages() @mock.patch("uaclient.apt.util.subp", return_value=("a\nb\n", "")) def test_lines_are_split(self, m_subp): assert ["a", "b"] == get_installed_packages() @mock.patch("uaclient.apt.util.subp", return_value=("a\nb", "")) def test_assert_missing_eof_newline_works(self, m_subp): assert ["a", "b"] == get_installed_packages() ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/tests/test_cli_enable.py0000664000000000000000000000711014601326072023070 0ustar import mock import pytest from uaclient.cli import _perform_enable, action_enable from uaclient import exceptions from uaclient import status from uaclient.testing.fakes import FakeConfig @mock.patch("uaclient.cli.os.getuid") class TestActionEnable: def test_non_root_users_are_rejected(self, getuid): """Check that a UID != 0 will receive a message and exit non-zero""" getuid.return_value = 1 cfg = FakeConfig.for_attached_machine() with pytest.raises(exceptions.NonRootUserError): action_enable(mock.MagicMock(), cfg) @pytest.mark.parametrize( "uid,expected_error_template", [ (0, status.MESSAGE_ENABLE_FAILURE_UNATTACHED_TMPL), (1000, status.MESSAGE_NONROOT_USER), ], ) def test_unattached_error_message( self, m_getuid, uid, expected_error_template ): """Check that root user gets unattached message.""" m_getuid.return_value = uid cfg = FakeConfig() with pytest.raises(exceptions.UserFacingError) as err: args = mock.MagicMock() args.name = "esm-infra" action_enable(args, cfg) assert ( expected_error_template.format(name="esm-infra") == err.value.msg ) @pytest.mark.parametrize( "uid,expected_error_template", [ (0, status.MESSAGE_INVALID_SERVICE_OP_FAILURE_TMPL), (1000, status.MESSAGE_NONROOT_USER), ], ) def test_invalid_service_error_message( self, m_getuid, uid, expected_error_template ): """Check invalid service name results in custom error message.""" m_getuid.return_value = uid cfg = FakeConfig.for_attached_machine() with pytest.raises(exceptions.UserFacingError) as err: args = mock.MagicMock() args.name = "bogus" action_enable(args, cfg) assert ( expected_error_template.format(operation="enable", name="bogus") == err.value.msg ) class TestPerformEnable: @mock.patch("uaclient.cli.entitlements") def test_missing_entitlement_raises_keyerror(self, m_entitlements): """We raise a KeyError on missing entitlements (This isn't a problem because any callers of _perform_enable should already have rejected invalid names.) """ m_entitlements.ENTITLEMENT_CLASS_BY_NAME = {} with pytest.raises(KeyError): _perform_enable("entitlement", mock.Mock()) @pytest.mark.parametrize("silent_if_inapplicable", (True, False, None)) @mock.patch("uaclient.cli.entitlements") def test_entitlement_instantiated_and_enabled( self, m_entitlements, silent_if_inapplicable ): m_entitlement_cls = mock.Mock() m_cfg = mock.Mock() m_entitlements.ENTITLEMENT_CLASS_BY_NAME = { "testitlement": m_entitlement_cls } kwargs = {} if silent_if_inapplicable is not None: kwargs["silent_if_inapplicable"] = silent_if_inapplicable ret = _perform_enable("testitlement", m_cfg, **kwargs) assert [mock.call(m_cfg)] == m_entitlement_cls.call_args_list m_entitlement = m_entitlement_cls.return_value if silent_if_inapplicable: expected_enable_call = mock.call(silent_if_inapplicable=True) else: expected_enable_call = mock.call(silent_if_inapplicable=False) assert [expected_enable_call] == m_entitlement.enable.call_args_list assert ret == m_entitlement.enable.return_value assert 1 == m_cfg.status.call_count ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/tests/__init__.py0000664000000000000000000000000014072115230021474 0ustar ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/tests/test_gpg.py0000664000000000000000000000451214601326072021573 0ustar import os import pytest from uaclient import exceptions, gpg, util from uaclient.testing import data @pytest.yield_fixture def home_dir(tmpdir): home = tmpdir.join("home") home.mkdir() unset = object() old_home = os.environ.get("HOME", unset) os.environ["HOME"] = home.strpath yield if old_home is unset: del os.environ["HOME"] else: os.environ["HOME"] = old_home class TestExportGPGKey: def test_key_error_on_missing_keyfile(self, home_dir, tmpdir): """Raise UserFacingError when source_keyfile is not found.""" src_keyfile = tmpdir.join("nothere").strpath destination_keyfile = tmpdir.join("destination_keyfile").strpath # known valid gpg key which will not exist in source_keyring_dir with pytest.raises(exceptions.UserFacingError) as excinfo: gpg.export_gpg_key( source_keyfile=src_keyfile, destination_keyfile=destination_keyfile, ) error_msg = "GPG key '{}' not found".format(src_keyfile) 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): """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) ) source_key2 = tmpdir.join( "ubuntu-advantage-cc-eal-{}.gpg".format(data.GPG_KEY2_ID) ) destination_keyfile = tmpdir.join("destination_key").strpath # 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, ) gpg_dest_list_keys = [ "gpg", "--no-auto-check-trustdb", "--options", "/dev/null", "--no-default-keyring", "--keyring", destination_keyfile, "--list-keys", ] dest_out, _err = util.subp(gpg_dest_list_keys) assert "Ubuntu Common Criteria EAL2" in dest_out # ESM didn't get exported assert "Extended Security Maintenance" not in dest_out ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/tests/test_cli_detach.py0000664000000000000000000001213614601326072023076 0ustar import mock from textwrap import dedent import pytest from uaclient.cli import action_detach from uaclient import exceptions from uaclient import status from uaclient.testing.fakes import FakeConfig def entitlement_cls_mock_factory(can_disable, name=None): m_instance = mock.Mock(can_disable=mock.Mock(return_value=can_disable)) if name: m_instance.name = name return mock.Mock(return_value=m_instance) @mock.patch("uaclient.cli.util.prompt_for_confirmation", return_value=True) @mock.patch("uaclient.cli.os.getuid") class TestActionDetach: def test_non_root_users_are_rejected(self, m_getuid, _m_prompt): """Check that a UID != 0 will receive a message and exit non-zero""" m_getuid.return_value = 1 cfg = FakeConfig.for_attached_machine() with pytest.raises(exceptions.NonRootUserError): action_detach(mock.MagicMock(), cfg) def test_unattached_error_message(self, m_getuid, _m_prompt): """Check that root user gets unattached message.""" m_getuid.return_value = 0 cfg = FakeConfig() with pytest.raises(exceptions.UnattachedError) as err: action_detach(mock.MagicMock(), cfg) assert status.MESSAGE_UNATTACHED == err.value.msg @pytest.mark.parametrize("prompt_response", [True, False]) @mock.patch("uaclient.cli.entitlements") def test_entitlements_disabled_if_can_disable_and_prompt_true( self, m_entitlements, m_getuid, m_prompt, prompt_response ): m_getuid.return_value = 0 m_prompt.return_value = prompt_response m_entitlements.ENTITLEMENT_CLASSES = [ entitlement_cls_mock_factory(False), entitlement_cls_mock_factory(True), entitlement_cls_mock_factory(False), ] return_code = action_detach( mock.MagicMock(), FakeConfig.for_attached_machine() ) # Check that can_disable is called correctly for ent_cls in m_entitlements.ENTITLEMENT_CLASSES: assert [ mock.call(silent=True) ] == ent_cls.return_value.can_disable.call_args_list # Check that disable is only called when can_disable is true for undisabled_cls in [ m_entitlements.ENTITLEMENT_CLASSES[0], m_entitlements.ENTITLEMENT_CLASSES[2], ]: assert 0 == undisabled_cls.return_value.disable.call_count disabled_cls = m_entitlements.ENTITLEMENT_CLASSES[1] if prompt_response: assert [ mock.call(silent=True) ] == disabled_cls.return_value.disable.call_args_list assert 0 == return_code else: assert 0 == disabled_cls.return_value.disable.call_count assert 1 == return_code @mock.patch("uaclient.cli.entitlements") def test_config_cache_deleted(self, m_entitlements, m_getuid, _m_prompt): m_getuid.return_value = 0 m_entitlements.ENTITLEMENT_CLASSES = [] cfg = mock.MagicMock() action_detach(mock.MagicMock(), cfg) assert [mock.call()] == cfg.delete_cache.call_args_list @mock.patch("uaclient.cli.entitlements") def test_correct_message_emitted( self, m_entitlements, m_getuid, _m_prompt, capsys ): m_getuid.return_value = 0 m_entitlements.ENTITLEMENT_CLASSES = [] action_detach(mock.MagicMock(), mock.MagicMock()) out, _err = capsys.readouterr() assert status.MESSAGE_DETACH_SUCCESS + "\n" == out @mock.patch("uaclient.cli.entitlements") def test_returns_zero(self, m_entitlements, m_getuid, _m_prompt): m_getuid.return_value = 0 m_entitlements.ENTITLEMENT_CLASSES = [] ret = action_detach(mock.MagicMock(), mock.MagicMock()) assert 0 == ret @pytest.mark.parametrize( "classes,expected_message", [ ( [ entitlement_cls_mock_factory(True, name="ent1"), entitlement_cls_mock_factory(False, name="ent2"), entitlement_cls_mock_factory(True, name="ent3"), ], dedent( """\ Detach will disable the following services: ent1 ent3""" ), ), ( [ entitlement_cls_mock_factory(True, name="ent1"), entitlement_cls_mock_factory(False, name="ent2"), ], dedent( """\ Detach will disable the following service: ent1""" ), ), ], ) @mock.patch("uaclient.cli.entitlements") def test_informational_message_emitted( self, m_entitlements, m_getuid, _m_prompt, capsys, classes, expected_message, ): m_getuid.return_value = 0 m_entitlements.ENTITLEMENT_CLASSES = classes action_detach(mock.MagicMock(), mock.MagicMock()) out, _err = capsys.readouterr() assert expected_message in out ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/tests/test_status.py0000664000000000000000000001064114601326072022341 0ustar import mock import pytest import string from uaclient import config from uaclient.status import format_tabular, TxtColor @pytest.fixture def status_dict_attached(): status = config.DEFAULT_STATUS.copy() # The following are required so we don't get an "unattached" error status["attached"] = True status["account"] = "account" status["subscription"] = "subscription" status["expires"] = "expires" return status @pytest.fixture def status_dict_unattached(): status = config.DEFAULT_STATUS.copy() status["services"] = [ { "name": "cc-eal", "description": "Common Criteria EAL2 Provisioning Packages", "available": "no", } ] return status class TestFormatTabular: @pytest.mark.parametrize( "support_level,expected_colour,istty", [ ("n/a", TxtColor.DISABLEGREY, True), ("essential", TxtColor.OKGREEN, True), ("standard", TxtColor.OKGREEN, True), ("advanced", TxtColor.OKGREEN, True), ("something else", None, True), ("n/a", TxtColor.DISABLEGREY, True), ("essential", None, False), ("standard", None, False), ("advanced", None, False), ("something else", None, False), ("n/a", None, False), ], ) @mock.patch("sys.stdout.isatty") def test_support_colouring( self, m_isatty, support_level, expected_colour, istty, status_dict_attached, ): status_dict_attached["techSupportLevel"] = support_level m_isatty.return_value = istty tabular_output = format_tabular(status_dict_attached) expected_string = "Technical support level: {}".format( support_level if not expected_colour else expected_colour + support_level + TxtColor.ENDC ) assert expected_string in tabular_output @pytest.mark.parametrize("origin", ["free", "not-free"]) def test_header_alignment(self, origin, status_dict_attached): status_dict_attached["origin"] = origin tabular_output = format_tabular(status_dict_attached) colon_idx = None for line in tabular_output.splitlines(): if ":" not in line or "Enable services" in line: # This isn't a header line continue if colon_idx is None: # This is the first header line, record where the colon is colon_idx = line.index(":") continue # Ensure that the colon in this line is aligned with previous ones assert line.index(":") == colon_idx @pytest.mark.parametrize( "origin,expected_headers", [ ("free", ("Account", "Subscription")), ( "not-free", ( "Account", "Subscription", "Valid until", "Technical support level", ), ), ], ) def test_correct_header_keys_included( self, origin, expected_headers, status_dict_attached ): status_dict_attached["origin"] = origin tabular_output = format_tabular(status_dict_attached) headers = [ line.split(":")[0].strip() for line in tabular_output.splitlines() if ":" in line and "Enable services" not in line ] assert list(expected_headers) == headers def test_correct_unattached_column_alignment(self, status_dict_unattached): tabular_output = format_tabular(status_dict_unattached) [header, eal_service_line] = [ line for line in tabular_output.splitlines() if "eal" in line or "AVAILABLE" in line ] printable_eal_line = "".join( filter(lambda x: x in string.printable, eal_service_line) ) assert header.find("AVAILABLE") == printable_eal_line.find("no") assert header.find("DESCRIPTION") == printable_eal_line.find("Common") @pytest.mark.parametrize("attached", [True, False]) def test_no_leading_newline( self, attached, status_dict_attached, status_dict_unattached ): if attached: status_dict = status_dict_attached else: status_dict = status_dict_unattached assert not format_tabular(status_dict).startswith("\n") ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/tests/test_cli_disable.py0000664000000000000000000000520614601326072023251 0ustar import mock import pytest from uaclient.cli import action_disable from uaclient import exceptions from uaclient import status from uaclient.testing.fakes import FakeConfig @mock.patch("uaclient.cli.os.getuid", return_value=0) class TestDisable: @pytest.mark.parametrize( "disable_return,return_code", ((True, 0), (False, 1)) ) @mock.patch("uaclient.cli.entitlements") def test_entitlement_instantiated_and_disabled( self, m_entitlements, _m_getuid, disable_return, return_code ): m_entitlement_cls = mock.Mock() m_entitlement = m_entitlement_cls.return_value m_entitlement.disable.return_value = disable_return m_cfg = mock.Mock() m_entitlements.ENTITLEMENT_CLASS_BY_NAME = { "testitlement": m_entitlement_cls } args_mock = mock.Mock() args_mock.name = "testitlement" ret = action_disable(args_mock, m_cfg) assert [mock.call(m_cfg)] == m_entitlement_cls.call_args_list expected_disable_call = mock.call() assert [expected_disable_call] == m_entitlement.disable.call_args_list assert return_code == ret assert 1 == m_cfg.status.call_count @pytest.mark.parametrize( "uid,expected_error_template", [ (0, status.MESSAGE_INVALID_SERVICE_OP_FAILURE_TMPL), (1000, status.MESSAGE_NONROOT_USER), ], ) def test_invalid_service_error_message( self, m_getuid, uid, expected_error_template ): """Check invalid service name results in custom error message.""" m_getuid.return_value = uid cfg = FakeConfig.for_attached_machine() with pytest.raises(exceptions.UserFacingError) as err: args = mock.MagicMock() args.name = "bogus" action_disable(args, cfg) assert ( expected_error_template.format(operation="disable", name="bogus") == err.value.msg ) @pytest.mark.parametrize( "uid,expected_error_template", [ (0, status.MESSAGE_ENABLE_FAILURE_UNATTACHED_TMPL), (1000, status.MESSAGE_NONROOT_USER), ], ) def test_unattached_error_message( self, m_getuid, uid, expected_error_template ): """Check that root user gets unattached message.""" m_getuid.return_value = uid cfg = FakeConfig() with pytest.raises(exceptions.UserFacingError) as err: args = mock.MagicMock() args.name = "esm-infra" action_disable(args, cfg) assert ( expected_error_template.format(name="esm-infra") == err.value.msg ) ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/tests/test_cli_status.py0000664000000000000000000001204314601326072023166 0ustar import io import json import mock import socket import sys import pytest from uaclient.testing.fakes import FakeConfig from uaclient import util from uaclient.cli import action_status M_PATH = "uaclient.cli." RESPONSE_LIVEPATCH_AVAILABLE = [{"name": "livepatch", "available": True}] UNATTACHED_STATUS = """\ SERVICE AVAILABLE DESCRIPTION livepatch yes Canonical Livepatch service This machine is not attached to a UA subscription. See https://ubuntu.com/advantage """ ATTACHED_STATUS = """\ SERVICE ENTITLED STATUS DESCRIPTION cc-eal no {dash} Common Criteria EAL2 Provisioning Packages cis-audit no {dash} Center for Internet Security Audit Tools esm-infra no {dash} UA Infra: Extended Security Maintenance esm-infra-legacy no {dash} Expanded Security Maintenance for Infrastructure on Legacy Instances fips no {dash} NIST-certified FIPS modules fips-updates no {dash} Uncertified security updates to FIPS modules livepatch no {dash} Canonical Livepatch service Enable services with: ua enable Account: test_account Subscription: test_contract Valid until: n/a Technical support level: n/a """ # noqa @mock.patch( M_PATH + "contract.get_available_resources", return_value=RESPONSE_LIVEPATCH_AVAILABLE, ) @mock.patch(M_PATH + "os.getuid", return_value=0) class TestActionStatus: def test_attached(self, m_getuid, m_get_avail_resources, capsys): """Check that root and non-root will emit attached status""" cfg = FakeConfig.for_attached_machine() assert 0 == action_status(mock.MagicMock(), cfg) # capsys already converts colorized non-printable chars to space # Strip non-printables from output printable_stdout = capsys.readouterr()[0].replace(" " * 17, " " * 8) # On older versions of pytest, capsys doesn't set sys.stdout.encoding # to something that Python parses as UTF-8 compatible, so we get the # ASCII dash; testing for the "wrong" dash here is OK, because we have # a specific test that the correct one is used in # test_unicode_dash_replacement_when_unprintable expected_dash = "-" if sys.stdout.encoding and "UTF-8" in sys.stdout.encoding.upper(): expected_dash = "\u2014" assert ATTACHED_STATUS.format(dash=expected_dash) == printable_stdout def test_unattached(self, m_getuid, m_get_avail_resources, capsys): """Check that unattached status is emitted to console""" cfg = FakeConfig() assert 0 == action_status(mock.MagicMock(), cfg) assert UNATTACHED_STATUS == capsys.readouterr()[0] def test_unattached_json(self, m_getuid, m_get_avail_resources, capsys): """Check that unattached status json output is emitted to console""" cfg = FakeConfig() args = mock.MagicMock(format="json") assert 0 == action_status(args, cfg) expected = { "_doc": ( "Content provided in json response is currently " "considered Experimental and may change" ), "attached": False, "expires": "n/a", "origin": None, "services": [ { "name": "livepatch", "description": "Canonical Livepatch service", "available": "yes", } ], "techSupportLevel": "n/a", } assert expected == json.loads(capsys.readouterr()[0]) def test_error_on_connectivity_errors( self, m_getuid, m_get_avail_resources, capsys ): """Raise UrlError on connectivity issues""" m_get_avail_resources.side_effect = util.UrlError( socket.gaierror(-2, "Name or service not known") ) cfg = FakeConfig() with pytest.raises(util.UrlError): action_status(mock.MagicMock(), cfg) @pytest.mark.parametrize( "encoding,expected_dash", (("utf-8", "\u2014"), ("UTF-8", "\u2014"), ("ascii", "-")), ) def test_unicode_dash_replacement_when_unprintable( self, _m_getuid, _m_get_avail_resources, encoding, expected_dash ): # This test can't use capsys because it doesn't emulate sys.stdout # encoding accurately in older versions of pytest underlying_stdout = io.BytesIO() fake_stdout = io.TextIOWrapper(underlying_stdout, encoding=encoding) with mock.patch("sys.stdout", fake_stdout): action_status(mock.MagicMock(), FakeConfig.for_attached_machine()) fake_stdout.flush() # Make sure all output is in underlying_stdout out = underlying_stdout.getvalue().decode(encoding) # Colour codes are converted to spaces, so strip them out for # comparison out = out.replace(" " * 17, " " * 8) expected_out = ATTACHED_STATUS.format(dash=expected_dash) assert expected_out == out ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/tests/test_serviceclient.py0000664000000000000000000000336514601326072023662 0ustar import mock from urllib.error import HTTPError, URLError from io import BytesIO import pytest from uaclient import util from uaclient.serviceclient import UAServiceClient class OurServiceClientException(Exception): def __init__(self, exc, details): self.exc = exc self.details = details class OurServiceClient(UAServiceClient): @property def api_error_cls(self): return OurServiceClientException @property def cfg_url_base_attr(self): return "url_attr" class TestRequestUrl: # TODO: Non error-path tests @pytest.mark.parametrize( "fp,expected_exception,expected_attrs", ( (BytesIO(), util.UrlError, {"code": 619}), ( BytesIO(b'{"a": "b"}'), OurServiceClientException, {"details": {"a": "b"}}, ), ), ) @mock.patch("uaclient.serviceclient.util.readurl") def test_httperror_handling( self, m_readurl, fp, expected_exception, expected_attrs ): m_readurl.side_effect = HTTPError(None, 619, None, None, fp) client = OurServiceClient(cfg=mock.Mock(url_attr="http://example.com")) with pytest.raises(expected_exception) as excinfo: client.request_url("/") for attr, expected_value in expected_attrs.items(): assert expected_value == getattr(excinfo.value, attr) @mock.patch("uaclient.serviceclient.util.readurl") def test_urlerror_handling(self, m_readurl): m_readurl.side_effect = URLError(None) client = OurServiceClient(cfg=mock.Mock(url_attr="http://example.com")) with pytest.raises(util.UrlError) as excinfo: client.request_url("/") assert excinfo.value.code is None ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/tests/test_contract.py0000664000000000000000000003353114601326072022636 0ustar import copy import mock import pytest import socket import urllib from uaclient.contract import ( API_V1_CONTEXT_MACHINE_TOKEN, API_V1_RESOURCES, API_V1_TMPL_CONTEXT_MACHINE_TOKEN_REFRESH, API_V1_TMPL_RESOURCE_MACHINE_ACCESS, ContractAPIError, UAContractClient, get_available_resources, process_entitlement_delta, request_updated_contract, ) from uaclient import exceptions from uaclient import util from uaclient.status import ( MESSAGE_CONTRACT_EXPIRED_ERROR, MESSAGE_ATTACH_INVALID_TOKEN, ) from uaclient.testing.fakes import FakeConfig, FakeContractClient M_PATH = "uaclient.contract." M_REPO_PATH = "uaclient.entitlements.repo.RepoEntitlement." class TestProcessEntitlementDeltas: def test_error_on_missing_entitlement_type(self): """Raise an error when neither dict contains entitlement type.""" new_access = {"entitlement": {"something": "non-empty"}} error_msg = ( "Could not determine contract delta service type" " {{}} {}".format(new_access) ) with pytest.raises(RuntimeError) as exc: process_entitlement_delta({}, new_access) assert error_msg == str(exc.value) def test_no_delta_on_equal_dicts(self): """No deltas are reported or processed when dicts are equal.""" assert {} == process_entitlement_delta( {"entitlement": {"no": "diff"}}, {"entitlement": {"no": "diff"}} ) @mock.patch(M_REPO_PATH + "process_contract_deltas") def test_deltas_handled_by_entitlement_process_contract_deltas( self, m_process_contract_deltas ): """Call entitlement.process_contract_deltas to handle any deltas.""" 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( original_access, new_access ) expected_calls = [ mock.call(original_access, expected, allow_enable=False) ] assert expected_calls == m_process_contract_deltas.call_args_list @mock.patch(M_REPO_PATH + "process_contract_deltas") def test_full_delta_on_empty_orig_dict(self, m_process_contract_deltas): """Process and report full deltas on empty original access dict.""" # 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) expected_calls = [mock.call({}, new_access, allow_enable=False)] assert expected_calls == m_process_contract_deltas.call_args_list @mock.patch( "uaclient.util.get_platform_info", return_value={"series": "fake_series"}, ) @mock.patch(M_REPO_PATH + "process_contract_deltas") def test_overrides_applied_before_comparison( self, m_process_contract_deltas, _ ): old_access = {"entitlement": {"type": "esm", "some_key": "some_value"}} new_access = { "entitlement": { "type": "esm", "some_key": "will be overridden", "series": {"fake_series": {"some_key": "some_value"}}, } } process_entitlement_delta(old_access, new_access) assert 0 == m_process_contract_deltas.call_count class TestGetAvailableResources: @mock.patch.object(UAContractClient, "request_resources") def test_request_resources_error_on_network_disconnected( self, m_request_resources ): """Raise error get_available_resources can't contact backend""" cfg = FakeConfig() urlerror = util.UrlError( socket.gaierror(-2, "Name or service not known") ) m_request_resources.side_effect = urlerror with pytest.raises(util.UrlError) as exc: get_available_resources(cfg) assert urlerror == exc.value @mock.patch(M_PATH + "UAContractClient") def test_request_resources_from_contract_server(self, client): """Call UAContractClient.request_resources to get updated resources.""" cfg = FakeConfig() platform = util.get_platform_info() resource_params = { "architecture": platform["arch"], "series": platform["series"], "kernel": platform["kernel"], } url = API_V1_RESOURCES + "?" + urllib.parse.urlencode(resource_params) new_resources = [{"name": "new_resource", "available": False}] def fake_contract_client(cfg): fake_client = FakeContractClient(cfg) fake_client._responses = {url: {"resources": new_resources}} return fake_client client.side_effect = fake_contract_client assert new_resources == get_available_resources(cfg) class TestRequestUpdatedContract: refresh_route = API_V1_TMPL_CONTEXT_MACHINE_TOKEN_REFRESH.format( contract="cid", machine="mid" ) access_route_ent1 = API_V1_TMPL_RESOURCE_MACHINE_ACCESS.format( resource="ent1", machine="mid" ) access_route_ent2 = API_V1_TMPL_RESOURCE_MACHINE_ACCESS.format( resource="ent2", machine="mid" ) @mock.patch(M_PATH + "UAContractClient") def test_attached_config_and_contract_token_runtime_error(self, client): """When attached, error if called with a contract_token.""" def fake_contract_client(cfg): return FakeContractClient(cfg) client.side_effect = fake_contract_client cfg = FakeConfig.for_attached_machine() with pytest.raises(RuntimeError) 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) @mock.patch("uaclient.util.get_machine_id", return_value="mid") @mock.patch(M_PATH + "UAContractClient") def test_invalid_token_user_facing_error_on_invalid_token_refresh_failure( self, client, get_machine_id ): """When attaching, invalid token errors result in proper user error.""" def fake_contract_client(cfg): fake_client = FakeContractClient(cfg) fake_client._responses = { API_V1_CONTEXT_MACHINE_TOKEN: ContractAPIError( util.UrlError( "Server error", code=500, url="http://me", headers={} ), error_response={ "message": "invalid token: checksum error" }, ) } return fake_client client.side_effect = fake_contract_client cfg = FakeConfig() with pytest.raises(exceptions.UserFacingError) as exc: request_updated_contract(cfg, contract_token="yep") assert MESSAGE_ATTACH_INVALID_TOKEN == str(exc.value) @mock.patch("uaclient.util.get_machine_id", return_value="mid") @mock.patch(M_PATH + "UAContractClient") def test_user_facing_error_on_machine_token_refresh_failure( self, client, get_machine_id ): """When attaching, error on failure to refresh the machine token.""" def fake_contract_client(cfg): fake_client = FakeContractClient(cfg) fake_client._responses = { self.refresh_route: exceptions.UserFacingError( "Machine token refresh fail" ), self.access_route_ent1: exceptions.UserFacingError( "Broken ent1 route" ), } return fake_client client.side_effect = fake_contract_client cfg = FakeConfig.for_attached_machine() with pytest.raises(exceptions.UserFacingError) as exc: request_updated_contract(cfg) assert "Machine token refresh fail" == str(exc.value) @mock.patch("uaclient.util.get_machine_id", return_value="mid") @mock.patch(M_PATH + "UAContractClient") def test_user_facing_error_on_service_token_refresh_failure( self, client, get_machine_id ): """When attaching, error on any failed specific service refresh.""" machine_token = { "machineToken": "mToken", "machineTokenInfo": { "contractInfo": { "id": "cid", "resourceEntitlements": [ {"entitled": True, "type": "ent2"}, {"entitled": True, "type": "ent1"}, ], } }, } def fake_contract_client(cfg): fake_client = FakeContractClient(cfg) fake_client._responses = { self.refresh_route: machine_token, self.access_route_ent1: exceptions.UserFacingError( "Broken ent1 route" ), self.access_route_ent2: exceptions.UserFacingError( "Broken ent2 route" ), } return fake_client client.side_effect = fake_contract_client cfg = FakeConfig.for_attached_machine(machine_token=machine_token) with pytest.raises(exceptions.UserFacingError) as exc: request_updated_contract(cfg) assert "Broken ent1 route" == str(exc.value) @mock.patch(M_PATH + "process_entitlement_delta") @mock.patch("uaclient.util.get_machine_id", return_value="mid") @mock.patch(M_PATH + "UAContractClient") def test_attached_config_refresh_machine_token_and_services( self, client, get_machine_id, process_entitlement_delta ): """When attached, refresh machine token and entitled services. Processing service deltas are processed in a sorted order based on name to ensure operations occur the same regardless of dict ordering. """ # resourceEntitlements specifically ordered reverse alphabetically # to ensure proper sorting for process_contract_delta calls below machine_token = { "machineToken": "mToken", "machineTokenInfo": { "contractInfo": { "id": "cid", "resourceEntitlements": [ {"entitled": False, "type": "ent2"}, {"entitled": True, "type": "ent1"}, ], } }, } def fake_contract_client(cfg): client = FakeContractClient(cfg) # Note ent2 access route is not called client._responses = { self.refresh_route: machine_token, self.access_route_ent1: { "entitlement": { "entitled": True, "type": "ent1", "new": "newval", } }, } return client client.side_effect = fake_contract_client cfg = FakeConfig.for_attached_machine(machine_token=machine_token) assert None is request_updated_contract(cfg) assert machine_token == cfg.read_cache("machine-token") # Deltas are processed in a sorted fashion so that if enableByDefault # is true, the order of enablement operations is the same regardless # of dict key ordering. process_calls = [ mock.call( {"entitlement": {"entitled": True, "type": "ent1"}}, { "entitlement": { "entitled": True, "type": "ent1", "new": "newval", } }, allow_enable=False, ), mock.call( {"entitlement": {"entitled": False, "type": "ent2"}}, {"entitlement": {"entitled": False, "type": "ent2"}}, allow_enable=False, ), ] assert process_calls == process_entitlement_delta.call_args_list @mock.patch(M_PATH + "process_entitlement_delta") @mock.patch("uaclient.util.get_machine_id", return_value="mid") @mock.patch(M_PATH + "UAContractClient") def test_attached_config_refresh_errors_on_expired_contract( self, client, get_machine_id, process_entitlement_delta ): """Error when refreshing contract parses an expired contract token.""" machine_token = { "machineToken": "mToken", "machineTokenInfo": { "contractInfo": { "effectiveTo": "2018-07-18T00:00:00Z", # Expired date "id": "cid", "resourceEntitlements": [ {"entitled": False, "type": "ent2"}, {"entitled": True, "type": "ent1"}, ], } }, } def fake_contract_client(cfg): client = FakeContractClient(cfg) # Note ent2 access route is not called client._responses = { self.refresh_route: machine_token, self.access_route_ent1: { "entitlement": { "entitled": True, "type": "ent1", "new": "newval", } }, } return client client.side_effect = fake_contract_client cfg = FakeConfig.for_attached_machine(machine_token=machine_token) with pytest.raises(exceptions.UserFacingError) as exc: request_updated_contract(cfg) assert MESSAGE_CONTRACT_EXPIRED_ERROR == str(exc.value) assert machine_token == cfg.read_cache("machine-token") # No deltas are processed when contract is expired assert 0 == process_entitlement_delta.call_count ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/tests/test_cli.py0000664000000000000000000002624214601326072021571 0ustar import io import logging import mock import os import socket import stat import sys import textwrap import pytest from uaclient.cli import ( assert_attached, assert_root, get_parser, main, require_valid_entitlement_name, setup_logging, ) from uaclient.exceptions import ( NonRootUserError, UserFacingError, UnattachedError, ) from uaclient.testing.fakes import FakeConfig from uaclient import status from uaclient import util BIG_DESC = "123456789 " * 7 + "next line" BIG_URL = "http://" + "adsf" * 10 SERVICES_WRAPPED_HELP = textwrap.dedent( """ Client to manage Ubuntu Advantage services on a machine. - cc-eal: Common Criteria EAL2 Provisioning Packages (https://ubuntu.com/cc-eal) - cis-audit: Center for Internet Security Audit Tools (https://ubuntu.com/cis-audit) - esm-infra: UA Infra: Extended Security Maintenance (https://ubuntu.com/esm) - esm-infra-legacy: Expanded Security Maintenance for Infrastructure on Legacy Instances (https://ubuntu.com/esm) - fips: NIST-certified FIPS modules (https://ubuntu.com/fips) - fips-updates: Uncertified security updates to FIPS modules - livepatch: Canonical Livepatch service (https://ubuntu.com/livepatch) """ ) @pytest.fixture(params=["direct", "--help", "ua help"]) def get_help(request, capsys): if request.param == "direct": def _get_help_output(): parser = get_parser() help_file = io.StringIO() parser.print_help(file=help_file) return help_file.getvalue() elif request.param == "--help": def _get_help_output(): parser = get_parser() with mock.patch("sys.argv", ["ua", "--help"]): with pytest.raises(SystemExit): parser.parse_args() out, _err = capsys.readouterr() return out elif request.param == "ua help": def _get_help_output(): with mock.patch("sys.argv", ["ua", "help"]): main() out, _err = capsys.readouterr() return out else: raise NotImplementedError("Unknown help source: {}", request.param) return _get_help_output class TestCLIParser: maxDiff = None @mock.patch("uaclient.cli.entitlements") def test_help_descr_and_url_is_wrapped_at_eighty_chars( self, m_entitlements, get_help ): """Help lines are wrapped at 80 chars""" def cls_mock_factory(desc, url): return mock.Mock(description=desc, help_doc_url=url) m_entitlements.ENTITLEMENT_CLASS_BY_NAME = { "test": cls_mock_factory(BIG_DESC, BIG_URL) } lines = [ " - test: " + " ".join(["123456789"] * 7), " next line ({url})".format(url=BIG_URL), ] assert "\n".join(lines) in get_help() def test_help_sourced_dynamically_from_each_entitlement(self, get_help): """Help output is sourced from entitlement name and description.""" assert SERVICES_WRAPPED_HELP in get_help() class TestAssertRoot: def test_assert_root_when_root(self): arg, kwarg = mock.sentinel.arg, mock.sentinel.kwarg @assert_root def test_function(arg, *, kwarg): assert arg == mock.sentinel.arg assert kwarg == mock.sentinel.kwarg return mock.sentinel.success with mock.patch("uaclient.cli.os.getuid", return_value=0): ret = test_function(arg, kwarg=kwarg) assert mock.sentinel.success == ret def test_assert_root_when_not_root(self): @assert_root def test_function(): pass with mock.patch("uaclient.cli.os.getuid", return_value=1000): with pytest.raises(NonRootUserError): test_function() # Test multiple uids, to be sure that the root checking is absent @pytest.mark.parametrize("uid", [0, 1000]) class TestAssertAttached: def test_assert_attached_when_attached(self, capsys, uid): @assert_attached() def test_function(args, cfg): return mock.sentinel.success cfg = FakeConfig.for_attached_machine() with mock.patch("uaclient.cli.os.getuid", return_value=uid): ret = test_function(mock.Mock(), cfg) assert mock.sentinel.success == ret out, _err = capsys.readouterr() assert "" == out.strip() def test_assert_attached_when_unattached(self, uid): @assert_attached() def test_function(args, cfg): pass cfg = FakeConfig() with mock.patch("uaclient.cli.os.getuid", return_value=uid): with pytest.raises(UnattachedError): test_function(mock.Mock(), cfg) class TestRequireValidEntitlementName: # rven used in test names as short hand for require_valid_entitlement_name def test_rven_without_name(self): @require_valid_entitlement_name("operation") def test_function(args, cfg): return mock.sentinel.success assert mock.sentinel.success == test_function(object(), object()) def test_rven_with_valid_name(self): @require_valid_entitlement_name("operation") def test_function(args, cfg): return mock.sentinel.success m_args = mock.Mock() m_args.name = "esm-infra" assert mock.sentinel.success == test_function(m_args, object()) @pytest.mark.parametrize("operation_name", ["operation1", "operation2"]) def test_rven_with_invalid_name(self, operation_name): @require_valid_entitlement_name(operation_name) def test_function(args, cfg): pass m_args = mock.Mock() m_args.name = "invalid_entitlement" with pytest.raises(UserFacingError) as excinfo: test_function(m_args, object()) assert ( "Cannot {} 'invalid_entitlement'".format(operation_name) in excinfo.value.msg ) class TestMain: @mock.patch("uaclient.cli.setup_logging") @mock.patch("uaclient.cli.get_parser") def test_keyboard_interrupt_handled_gracefully( self, m_get_parser, _m_setup_logging, capsys, logging_sandbox, caplog_text, ): m_args = m_get_parser.return_value.parse_args.return_value m_args.action.side_effect = KeyboardInterrupt with pytest.raises(SystemExit) as excinfo: main(["some", "args"]) exc = excinfo.value assert 1 == exc.code out, err = capsys.readouterr() assert "" == out assert "Interrupt received; exiting.\n" == err error_log = caplog_text() assert "Traceback (most recent call last):" in error_log @pytest.mark.parametrize("caplog_text", [logging.ERROR], indirect=True) @mock.patch("uaclient.cli.setup_logging") @mock.patch("uaclient.cli.get_parser") def test_user_facing_error_handled_gracefully( self, m_get_parser, _m_setup_logging, capsys, logging_sandbox, caplog_text, ): msg = "You need to know about this." m_args = m_get_parser.return_value.parse_args.return_value m_args.action.side_effect = UserFacingError(msg) with pytest.raises(SystemExit) as excinfo: main(["some", "args"]) exc = excinfo.value assert 1 == exc.code out, err = capsys.readouterr() assert "" == out assert "{}\n".format(msg) == err error_log = caplog_text() assert msg in error_log assert "Traceback (most recent call last):" in error_log @mock.patch("uaclient.cli.setup_logging") @mock.patch("uaclient.cli.get_parser") def test_url_error_handled_gracefully( self, m_get_parser, _m_setup_logging, capsys, logging_sandbox, caplog_text, ): m_args = m_get_parser.return_value.parse_args.return_value m_args.action.side_effect = util.UrlError( socket.gaierror(-2, "Name or service not known") ) with pytest.raises(SystemExit) as excinfo: main(["some", "args"]) exc = excinfo.value assert 1 == exc.code out, err = capsys.readouterr() assert "" == out assert "{}\n".format(status.MESSAGE_CONNECTIVITY_ERROR) == err error_log = caplog_text() assert " [Errno -2] Name or service not known" in error_log assert "Traceback (most recent call last):" in error_log class TestSetupLogging: @pytest.mark.parametrize("level", (logging.INFO, logging.ERROR)) def test_console_log_configured_if_not_present( self, level, capsys, logging_sandbox ): setup_logging(level, logging.INFO) logging.log(level, "after setup") logging.log(level - 1, "not present") _, err = capsys.readouterr() assert "after setup" in err assert "not present" not in err def test_console_log_configured_if_already_present( self, capsys, logging_sandbox ): logging.getLogger().addHandler(logging.StreamHandler(sys.stderr)) logging.error("before setup") setup_logging(logging.INFO, logging.INFO) logging.error("after setup") # 'before setup' will be in stderr, so check that setup_logging # configures the format _, err = capsys.readouterr() assert "ERROR: before setup" not in err assert "ERROR: after setup" in err @mock.patch("uaclient.cli.os.getuid", return_value=100) def test_file_log_not_configured_if_not_root( self, m_getuid, tmpdir, logging_sandbox ): log_file = tmpdir.join("log_file") setup_logging(logging.INFO, logging.INFO, log_file=log_file.strpath) logging.info("after setup") assert not log_file.exists() @pytest.mark.parametrize("log_filename", (None, "file.log")) @mock.patch("uaclient.cli.os.getuid", return_value=0) @mock.patch("uaclient.cli.config") def test_file_log_configured_if_root( self, m_config, _m_getuid, log_filename, logging_sandbox, tmpdir ): if log_filename is None: log_filename = "default.log" log_file = tmpdir.join(log_filename) m_config.CONFIG_DEFAULTS = {"log_file": log_file.strpath} else: log_file = tmpdir.join(log_filename) setup_logging(logging.INFO, logging.INFO, log_file=log_file.strpath) logging.info("after setup") assert "after setup" in log_file.read() @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( self, m_config, _m_getuid, logging_sandbox, tmpdir, pre_existing ): log_file = tmpdir.join("root-only.log") log_path = log_file.strpath if pre_existing: log_file.write("existing content\n") assert 0o600 != 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) log_content = log_file.read() assert "after setup" in log_content if pre_existing: assert "existing content" in log_content ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/tests/test_cli_refresh.py0000664000000000000000000000427014601326072023304 0ustar import mock import pytest from uaclient import exceptions from uaclient.testing.fakes import FakeConfig try: from typing import Any, Dict, Optional # noqa: F401 except ImportError: # typing isn't available on trusty, so ignore its absence pass from uaclient import status from uaclient.cli import action_refresh M_PATH = "uaclient.cli." @mock.patch(M_PATH + "os.getuid", return_value=0) class TestActionRefresh: def test_non_root_users_are_rejected(self, getuid): """Check that a UID != 0 will receive a message and exit non-zero""" getuid.return_value = 1 cfg = FakeConfig.for_attached_machine() with pytest.raises(exceptions.NonRootUserError): action_refresh(mock.MagicMock(), cfg) def test_not_attached_errors(self, getuid): """Check that an unattached machine emits message and exits 1""" cfg = FakeConfig() with pytest.raises(exceptions.UnattachedError): action_refresh(mock.MagicMock(), cfg) @mock.patch(M_PATH + "logging.error") @mock.patch(M_PATH + "contract.request_updated_contract") def test_refresh_contract_error_on_failure_to_update_contract( self, request_updated_contract, logging_error, getuid ): """On failure in request_updates_contract emit an error.""" request_updated_contract.side_effect = exceptions.UserFacingError( "Failure to refresh" ) cfg = FakeConfig.for_attached_machine() with pytest.raises(exceptions.UserFacingError) as excinfo: action_refresh(mock.MagicMock(), cfg) assert "Failure to refresh" == excinfo.value.msg @mock.patch(M_PATH + "contract.request_updated_contract") def test_refresh_contract_happy_path( self, request_updated_contract, getuid, capsys ): """On success from request_updates_contract root user can refresh.""" request_updated_contract.return_value = True cfg = FakeConfig.for_attached_machine() ret = action_refresh(mock.MagicMock(), cfg) assert 0 == ret assert status.MESSAGE_REFRESH_SUCCESS in capsys.readouterr()[0] assert [mock.call(cfg)] == request_updated_contract.call_args_list ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/tests/test_config.py0000664000000000000000000006555114601326072022275 0ustar import copy import datetime import itertools import json import os import stat import mock import pytest from uaclient import entitlements, exceptions, status from uaclient.config import ( DataPath, DEFAULT_STATUS, PRIVATE_SUBDIR, UAConfig, parse_config, ) from uaclient.entitlements import ( ENTITLEMENT_CLASSES, ENTITLEMENT_CLASS_BY_NAME, ) from uaclient.testing.fakes import FakeConfig from uaclient.status import ContractStatus, UserFacingStatus KNOWN_DATA_PATHS = (("machine-token", "machine-token.json"),) M_PATH = "uaclient.entitlements." RESP_ALL_RESOURCES_AVAILABLE = [ {"name": name, "available": True} for name in ENTITLEMENT_CLASS_BY_NAME ] RESP_ONLY_FIPS_RESOURCE_AVAILABLE = [ {"name": name, "available": name == "fips"} for name in ENTITLEMENT_CLASS_BY_NAME ] class TestEntitlements: def test_entitlements_property_keyed_by_entitlement_name(self, tmpdir): """Return machine_token resourceEntitlements, keyed by name.""" cfg = UAConfig({"data_dir": tmpdir.strpath}) token = { "machineTokenInfo": { "contractInfo": { "resourceEntitlements": [ {"type": "entitlement1", "entitled": True}, {"type": "entitlement2", "entitled": True}, ] } } } cfg.write_cache("machine-token", token) expected = { "entitlement1": { "entitlement": {"entitled": True, "type": "entitlement1"} }, "entitlement2": { "entitlement": {"entitled": True, "type": "entitlement2"} }, } assert expected == cfg.entitlements def test_entitlements_use_machine_access_when_present(self, tmpdir): """Return specific machine-access info if present.""" cfg = UAConfig({"data_dir": tmpdir.strpath}) token = { "machineTokenInfo": { "contractInfo": { "resourceEntitlements": [ {"type": "entitlement1", "entitled": True}, {"type": "entitlement2", "entitled": True}, ] } } } cfg.write_cache("machine-token", token) cfg.write_cache( "machine-access-entitlement1", { "entitlement": { "type": "entitlement1", "entitled": True, "more": "data", } }, ) expected = { "entitlement1": { "entitlement": { "entitled": True, "type": "entitlement1", "more": "data", } }, "entitlement2": { "entitlement": {"entitled": True, "type": "entitlement2"} }, } assert expected == cfg.entitlements class TestAccounts: def test_accounts_returns_empty_list_when_no_cached_account_value( self, tmpdir ): """Config.accounts property returns an empty list when no cache.""" cfg = UAConfig({"data_dir": tmpdir.strpath}) assert [] == cfg.accounts def test_accounts_extracts_accounts_key_from_machine_token_cache( self, tmpdir ): """Use machine_token cached accountInfo when no accounts cache.""" cfg = UAConfig({"data_dir": tmpdir.strpath}) accountInfo = {"id": "1", "name": "accountname"} cfg.write_cache( "machine-token", {"machineTokenInfo": {"accountInfo": accountInfo}} ) assert [accountInfo] == cfg.accounts class TestDataPath: def test_data_path_returns_data_dir_path_without_key(self): """The data_path method returns the data_dir when key is absent.""" cfg = UAConfig({"data_dir": "/my/dir"}) assert "/my/dir/{}".format(PRIVATE_SUBDIR) == cfg.data_path() @pytest.mark.parametrize("key,path_basename", KNOWN_DATA_PATHS) def test_data_path_returns_file_path_with_defined_data_paths( self, key, path_basename ): """When key is defined in Config.data_paths return data_path value.""" cfg = UAConfig({"data_dir": "/my/dir"}) private_path = "/my/dir/{}/{}".format(PRIVATE_SUBDIR, path_basename) assert private_path == cfg.data_path(key=key) @pytest.mark.parametrize( "key,path_basename", (("notHere", "notHere"), ("anything", "anything")) ) def test_data_path_returns_file_path_with_undefined_data_paths( self, key, path_basename ): """When key is not in Config.data_paths the key is used to data_dir""" cfg = UAConfig({"data_dir": "/my/d"}) assert "/my/d/{}/{}".format(PRIVATE_SUBDIR, key) == cfg.data_path( key=key ) def test_data_path_returns_public_path_for_public_datapath(self): cfg = UAConfig({"data_dir": "/my/d"}) cfg.data_paths["test_path"] = DataPath("test_path", False) assert "/my/d/test_path" == cfg.data_path("test_path") class TestWriteCache: @pytest.mark.parametrize( "key,content", (("unknownkey", "content1"), ("another-one", "content2")), ) def test_write_cache_write_key_name_in_data_dir_when_data_path_absent( self, tmpdir, key, content ): """When key is not in data_paths, write content to data_dir/key.""" cfg = UAConfig({"data_dir": tmpdir.strpath}) expected_path = tmpdir.join(PRIVATE_SUBDIR, key) assert not expected_path.check(), "Found unexpected file {}".format( expected_path ) assert None is cfg.write_cache(key, content) assert expected_path.check(), "Missing expected file {}".format( expected_path ) assert content == cfg.read_cache(key) def test_write_cache_creates_secure_private_dir(self, tmpdir): """private_dir is created with permission 0o700.""" cfg = UAConfig({"data_dir": tmpdir.strpath}) # unknown keys are written to the private dir expected_dir = tmpdir.join(PRIVATE_SUBDIR) assert None is cfg.write_cache("somekey", "somevalue") assert True is os.path.isdir( expected_dir.strpath ), "Missing expected directory {}".format(expected_dir) assert 0o700 == stat.S_IMODE(os.lstat(expected_dir.strpath).st_mode) def test_write_cache_creates_dir_when_data_dir_does_not_exist( self, tmpdir ): """When data_dir doesn't exist, create it.""" tmp_subdir = tmpdir.join("does/not/exist") cfg = UAConfig({"data_dir": tmp_subdir.strpath}) assert False is os.path.isdir( tmp_subdir.strpath ), "Found unexpected directory {}".format(tmp_subdir) assert None is cfg.write_cache("somekey", "someval") assert True is os.path.isdir( tmp_subdir.strpath ), "Missing expected directory {}".format(tmp_subdir) assert "someval" == cfg.read_cache("somekey") @pytest.mark.parametrize( "key,value", (("dictkey", {"1": "v1"}), ("listkey", [1, 2, 3])) ) def test_write_cache_writes_json_string_when_content_not_a_string( self, tmpdir, key, value ): """When content is not a string, write a json string.""" cfg = UAConfig({"data_dir": tmpdir.strpath}) expected_json_content = json.dumps(value) assert None is cfg.write_cache(key, value) with open(tmpdir.join(PRIVATE_SUBDIR, key).strpath, "r") as stream: assert expected_json_content == stream.read() assert value == cfg.read_cache(key) @pytest.mark.parametrize( "datapath,mode", ((DataPath("path", False), 0o644), (DataPath("path", True), 0o600)), ) def test_permissions(self, tmpdir, datapath, mode): cfg = UAConfig({"data_dir": tmpdir.strpath}) cfg.data_paths = {"path": datapath} cfg.write_cache("path", "") assert mode == stat.S_IMODE(os.lstat(cfg.data_path("path")).st_mode) def test_write_datetime(self, tmpdir): cfg = UAConfig({"data_dir": tmpdir.strpath}) key = "test_key" dt = datetime.datetime.now() cfg.write_cache(key, dt) with open(cfg.data_path(key)) as f: assert dt.isoformat() == f.read().strip('"') class TestReadCache: @pytest.mark.parametrize("key,path_basename", KNOWN_DATA_PATHS) def test_read_cache_returns_none_when_data_path_absent( self, tmpdir, key, path_basename ): """Return None when the specified key data_path is not cached.""" cfg = UAConfig({"data_dir": tmpdir.strpath}) assert None is cfg.read_cache(key) assert not tmpdir.join(path_basename).check() @pytest.mark.parametrize("key,path_basename", KNOWN_DATA_PATHS) def test_read_cache_returns_content_when_data_path_present( self, tmpdir, key, path_basename ): cfg = UAConfig({"data_dir": tmpdir.strpath}) os.makedirs(tmpdir.join(PRIVATE_SUBDIR).strpath) data_path = tmpdir.join(PRIVATE_SUBDIR, path_basename) with open(data_path.strpath, "w") as f: f.write("content{}".format(key)) assert "content{}".format(key) == cfg.read_cache(key) @pytest.mark.parametrize("key,path_basename", KNOWN_DATA_PATHS) def test_read_cache_returns_stuctured_content_when_json_data_path_present( self, tmpdir, key, path_basename ): cfg = UAConfig({"data_dir": tmpdir.strpath}) os.makedirs(tmpdir.join(PRIVATE_SUBDIR).strpath) data_path = tmpdir.join(PRIVATE_SUBDIR, path_basename) expected = {key: "content{}".format(key)} with open(data_path.strpath, "w") as f: f.write(json.dumps(expected)) assert expected == cfg.read_cache(key) def test_datetimes_are_unserialised(self, tmpdir): cfg = UAConfig({"data_dir": tmpdir.strpath}) os.makedirs(tmpdir.join(PRIVATE_SUBDIR).strpath) data_path = tmpdir.join(PRIVATE_SUBDIR, "dt_test") with open(data_path.strpath, "w") as f: f.write('{"dt": "2019-07-25T14:35:51"}') actual = cfg.read_cache("dt_test") assert {"dt": datetime.datetime(2019, 7, 25, 14, 35, 51)} == actual class TestDeleteCache: @pytest.mark.parametrize( "property_name,data_path_name,expected_null_value", (("machine_token", "machine-token", None),), ) def test_delete_cache_properly_clears_all_caches_simple( self, tmpdir, property_name, data_path_name, expected_null_value ): """ Ensure that delete_cache clears the cache for simple attributes (Simple in this context means those that are simply read from the filesystem and returned.) """ property_value = "our-value" cfg = UAConfig({"data_dir": tmpdir.strpath}) data_path = cfg.data_path(data_path_name) os.makedirs(os.path.dirname(data_path)) with open(data_path, "w") as f: f.write(property_value) before_prop_value = getattr(cfg, property_name) assert before_prop_value == property_value cfg.delete_cache() after_prop_value = getattr(cfg, property_name) assert expected_null_value == after_prop_value def test_delete_cache_unsets_entitlements(self, tmpdir): """The delete_cache unsets any cached entitlements content.""" cfg = UAConfig({"data_dir": tmpdir.strpath}) token = { "machineTokenInfo": { "contractInfo": { "resourceEntitlements": [ {"type": "entitlement1", "entitled": True} ] } } } cfg.write_cache("machine-token", token) previous_entitlements = { "entitlement1": { "entitlement": {"type": "entitlement1", "entitled": True} } } assert previous_entitlements == cfg.entitlements cfg.delete_cache() assert {} == cfg.entitlements def test_delete_cache_removes_any_cached_data_path_files(self, tmpdir): """Any cached files defined in cfg.data_paths will be removed.""" cfg = UAConfig({"data_dir": tmpdir.strpath}) # Create half of the cached files, but not all odd_keys = list(cfg.data_paths.keys())[::2] for odd_key in odd_keys: cfg.write_cache(odd_key, odd_key) present_files = list( itertools.chain( *[walk_entry[2] for walk_entry in os.walk(tmpdir.strpath)] ) ) assert len(odd_keys) == len(present_files) cfg.delete_cache() dirty_files = list( itertools.chain( *[walk_entry[2] for walk_entry in os.walk(tmpdir.strpath)] ) ) assert 0 == len(dirty_files), "{} files not deleted".format( len(dirty_files) ) def test_delete_cache_ignores_files_not_defined_in_data_paths( self, tmpdir ): """Any files in data_dir undefined in cfg.data_paths will remain.""" cfg = UAConfig({"data_dir": tmpdir.strpath}) t_file = tmpdir.join(PRIVATE_SUBDIR, "otherfile") os.makedirs(os.path.dirname(t_file.strpath)) with open(t_file.strpath, "w") as f: f.write("content") assert [os.path.basename(t_file.strpath)] == os.listdir( tmpdir.join(PRIVATE_SUBDIR).strpath ) cfg.delete_cache() assert [os.path.basename(t_file.strpath)] == os.listdir( tmpdir.join(PRIVATE_SUBDIR).strpath ) class TestStatus: @mock.patch("uaclient.contract.get_available_resources") @mock.patch("uaclient.config.os.getuid", return_value=0) def test_root_unattached(self, _m_getuid, m_get_available_resources): """Test we get the correct status dict when unattached""" cfg = FakeConfig({}) m_get_available_resources.return_value = [ {"name": "esm-infra", "available": True}, {"name": "fips", "available": False}, ] esm_desc = ENTITLEMENT_CLASS_BY_NAME["esm-infra"].description fips_desc = ENTITLEMENT_CLASS_BY_NAME["fips"].description expected = copy.deepcopy(DEFAULT_STATUS) expected["services"] = [ {"available": "yes", "name": "esm-infra", "description": esm_desc}, {"available": "no", "name": "fips", "description": fips_desc}, ] assert expected == cfg.status() @pytest.mark.parametrize( "resources,get_avail_resp", ( ( entitlements.ENTITLEMENT_CLASS_BY_NAME.keys(), RESP_ALL_RESOURCES_AVAILABLE, ), ("fips", RESP_ONLY_FIPS_RESOURCE_AVAILABLE), ), ) @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_available_resources, resources, get_avail_resp ): """Test we get the correct status dict when attached with basic conf""" cfg = FakeConfig.for_attached_machine() expected_services = [ { "description": cls.description, "entitled": status.ContractStatus.UNENTITLED.value, "name": cls.name, "status": status.UserFacingStatus.UNAVAILABLE.value, "statusDetails": mock.ANY, } for cls in entitlements.ENTITLEMENT_CLASSES ] m_get_available_resources.return_value = get_avail_resp expected = copy.deepcopy(DEFAULT_STATUS) expected.update( { "account-id": "acct-1", "account": "test_account", "attached": True, "services": expected_services, "subscription": "test_contract", "subscription-id": "cid", } ) assert expected == cfg.status() # cfg.status() idempotent assert expected == cfg.status() @mock.patch("uaclient.contract.get_available_resources") @mock.patch("uaclient.config.os.getuid") def test_nonroot_unattached_is_same_as_unattached_root( self, m_getuid, m_get_available_resources ): m_get_available_resources.return_value = [ {"name": "esm-infra", "available": True} ] m_getuid.return_value = 1000 cfg = FakeConfig() nonroot_status = cfg.status() m_getuid.return_value = 0 root_unattached_status = cfg.status() assert root_unattached_status == nonroot_status @mock.patch("uaclient.contract.get_available_resources") @mock.patch("uaclient.config.os.getuid") def test_root_followed_by_nonroot( self, m_getuid, m_get_available_resources, tmpdir ): """Ensure that non-root run after root returns data""" cfg = UAConfig({"data_dir": tmpdir.strpath}) # Run as root m_getuid.return_value = 0 before = copy.deepcopy(cfg.status()) # Replicate an attach by modifying the underlying config and confirm # that we see different status other_cfg = FakeConfig.for_attached_machine() cfg.write_cache("accounts", {"accounts": other_cfg.accounts}) cfg.write_cache("machine-token", other_cfg.machine_token) assert cfg._attached_status() != before # Run as regular user and confirm that we see the result from # last time we called .status() m_getuid.return_value = 1000 after = cfg.status() assert before == after @mock.patch("uaclient.contract.get_available_resources", return_value=[]) @mock.patch("uaclient.config.os.getuid", return_value=0) def test_cache_file_is_written_world_readable( self, _m_getuid, _m_get_available_resources, tmpdir ): cfg = UAConfig({"data_dir": tmpdir.strpath}) cfg.status() assert 0o644 == stat.S_IMODE( os.lstat(cfg.data_path("status-cache")).st_mode ) @pytest.mark.parametrize( "entitlements", ( [], [ { "type": "support", "entitled": True, "affordances": {"supportLevel": "anything"}, } ], ), ) @mock.patch("uaclient.contract.get_available_resources") @mock.patch("uaclient.config.os.getuid", return_value=0) @mock.patch(M_PATH + "livepatch.LivepatchEntitlement.user_facing_status") @mock.patch(M_PATH + "livepatch.LivepatchEntitlement.contract_status") @mock.patch(M_PATH + "repo.RepoEntitlement.user_facing_status") @mock.patch(M_PATH + "repo.RepoEntitlement.contract_status") def test_attached_reports_contract_and_service_status( self, m_repo_contract_status, m_repo_uf_status, m_livepatch_contract_status, m_livepatch_uf_status, _m_getuid, m_get_available_resources, entitlements, ): """When attached, return contract and service user-facing status.""" m_get_available_resources.return_value = RESP_ALL_RESOURCES_AVAILABLE m_repo_contract_status.return_value = status.ContractStatus.ENTITLED m_repo_uf_status.return_value = ( status.UserFacingStatus.INAPPLICABLE, "repo details", ) m_livepatch_contract_status.return_value = ( status.ContractStatus.ENTITLED ) m_livepatch_uf_status.return_value = ( status.UserFacingStatus.ACTIVE, "livepatch details", ) token = { "machineTokenInfo": { "accountInfo": {"id": "1", "name": "accountname"}, "contractInfo": { "id": "contract-1", "name": "contractname", "resourceEntitlements": entitlements, }, } } cfg = FakeConfig.for_attached_machine( account_name="accountname", machine_token=token ) if not entitlements: support_level = status.UserFacingStatus.INAPPLICABLE.value else: support_level = entitlements[0]["affordances"]["supportLevel"] expected = copy.deepcopy(DEFAULT_STATUS) expected.update( { "attached": True, "account": "accountname", "account-id": "1", "subscription": "contractname", "subscription-id": "contract-1", "techSupportLevel": support_level, } ) for cls in ENTITLEMENT_CLASSES: if cls.name == "livepatch": expected_status = status.UserFacingStatus.ACTIVE.value details = "livepatch details" else: expected_status = status.UserFacingStatus.INAPPLICABLE.value details = "repo details" expected["services"].append( { "name": cls.name, "description": cls.description, "entitled": status.ContractStatus.ENTITLED.value, "status": expected_status, "statusDetails": details, } ) assert expected == cfg.status() assert len(ENTITLEMENT_CLASSES) - 1 == m_repo_uf_status.call_count assert 1 == m_livepatch_uf_status.call_count @mock.patch("uaclient.contract.get_available_resources") @mock.patch("uaclient.config.os.getuid") def test_expires_handled_appropriately( self, m_getuid, _m_get_available_resources ): token = { "machineTokenInfo": { "accountInfo": {"id": "1", "name": "accountname"}, "contractInfo": { "name": "contractname", "id": "contract-1", "effectiveTo": "2020-07-18T00:00:00Z", "resourceEntitlements": [], }, } } cfg = FakeConfig.for_attached_machine( account_name="accountname", machine_token=token ) # Test that root's status works as expected (including the cache write) m_getuid.return_value = 0 expected_dt = datetime.datetime(2020, 7, 18, 0, 0, 0) assert expected_dt == cfg.status()["expires"] # Test that the read from the status cache work properly for non-root # users m_getuid.return_value = 1000 assert expected_dt == cfg.status()["expires"] @mock.patch("uaclient.config.os.getuid") def test_nonroot_user_uses_cache_if_available(self, m_getuid, tmpdir): m_getuid.return_value = 1000 status = {"pass": True} cfg = UAConfig({"data_dir": tmpdir.strpath}) cfg.write_cache("status-cache", status) assert status == cfg.status() ATTACHED_SERVICE_STATUS_PARAMETERS = [ # ENTITLED => display the given user-facing status (ContractStatus.ENTITLED, UserFacingStatus.ACTIVE, False, "enabled"), (ContractStatus.ENTITLED, UserFacingStatus.INACTIVE, False, "disabled"), (ContractStatus.ENTITLED, UserFacingStatus.INAPPLICABLE, False, "n/a"), (ContractStatus.ENTITLED, UserFacingStatus.UNAVAILABLE, False, "—"), # UNENTITLED => UNAVAILABLE (ContractStatus.UNENTITLED, UserFacingStatus.ACTIVE, False, "—"), (ContractStatus.UNENTITLED, UserFacingStatus.INACTIVE, False, "—"), (ContractStatus.UNENTITLED, UserFacingStatus.INAPPLICABLE, False, "—"), (ContractStatus.UNENTITLED, UserFacingStatus.UNAVAILABLE, [], "—"), # ENTITLED but in unavailable_resources => INAPPLICABLE (ContractStatus.ENTITLED, UserFacingStatus.ACTIVE, True, "n/a"), (ContractStatus.ENTITLED, UserFacingStatus.INACTIVE, True, "n/a"), (ContractStatus.ENTITLED, UserFacingStatus.INAPPLICABLE, True, "n/a"), (ContractStatus.ENTITLED, UserFacingStatus.UNAVAILABLE, True, "n/a"), # UNENTITLED and in unavailable_resources => UNAVAILABLE (ContractStatus.UNENTITLED, UserFacingStatus.ACTIVE, True, "—"), (ContractStatus.UNENTITLED, UserFacingStatus.INACTIVE, True, "—"), (ContractStatus.UNENTITLED, UserFacingStatus.INAPPLICABLE, True, "—"), (ContractStatus.UNENTITLED, UserFacingStatus.UNAVAILABLE, True, "—"), ] class TestAttachedServiceStatus: @pytest.mark.parametrize( "contract_status,uf_status,in_inapplicable_resources,expected_status", ATTACHED_SERVICE_STATUS_PARAMETERS, ) def test_status( self, contract_status, uf_status, in_inapplicable_resources, expected_status, ): ent = mock.MagicMock() ent.name = "test_entitlement" ent.contract_status.return_value = contract_status ent.user_facing_status.return_value = (uf_status, "") unavailable_resources = [ent.name] if in_inapplicable_resources else [] ret = FakeConfig()._attached_service_status(ent, unavailable_resources) assert expected_status == ret["status"] class TestParseConfig: @mock.patch("uaclient.config.os.path.exists", return_value=False) def test_parse_config_uses_defaults_when_no_config_present(self, m_exists): cwd = os.getcwd() with mock.patch.dict("uaclient.config.os.environ", values={}): config = parse_config() expected_calls = [ mock.call("{}/uaclient.conf".format(cwd)), mock.call("/etc/ubuntu-advantage/uaclient.conf"), ] assert expected_calls == m_exists.call_args_list expected_default_config = { "contract_url": "https://contracts.canonical.com", "data_dir": "/var/lib/ubuntu-advantage", "log_file": "/var/log/ubuntu-advantage.log", "log_level": "INFO", } assert expected_default_config == config @mock.patch("uaclient.config.os.path.exists", return_value=False) def test_parse_config_scrubs_user_environ_values(self, m_exists): user_values = { "UA_CONTRACT_URL": "https://contract", "ua_data_dir": "~/somedir", "Ua_LoG_FiLe": "some.log", "UA_LOG_LEVEL": "debug", } with mock.patch.dict("uaclient.config.os.environ", values=user_values): config = parse_config() expanded_dir = os.path.expanduser("~") expected_default_config = { "contract_url": "https://contract", "data_dir": "{}/somedir".format(expanded_dir), "log_file": "some.log", "log_level": "DEBUG", } assert expected_default_config == config @mock.patch("uaclient.config.os.path.exists", return_value=False) def test_parse_raises_errors_on_invalid_urls(self, m_exists): user_values = { "UA_CONTRACT_URL": "htp://contract" # no acceptable url scheme } with mock.patch.dict("uaclient.config.os.environ", values=user_values): with pytest.raises(exceptions.UserFacingError) as excinfo: parse_config() expected_msg = "Invalid url in config. contract_url: htp://contract" assert expected_msg == excinfo.value.msg ubuntu-advantage-tools-19.7ubuntu0.1/uaclient/serviceclient.py0000664000000000000000000000403614601326072021455 0ustar import abc import json from urllib import error from posixpath import join as urljoin from uaclient import config from uaclient import util from uaclient import version try: from typing import Optional, Type # noqa except ImportError: # typing isn't available on trusty, so ignore its absence pass class UAServiceClient(metaclass=abc.ABCMeta): @property @abc.abstractmethod def api_error_cls(self) -> "Type[Exception]": """Set in subclasses to the type of API error raised""" pass @property @abc.abstractmethod def cfg_url_base_attr(self) -> str: """String in subclasses, the UAConfig attribute containing base url""" pass def __init__(self, cfg: "Optional[config.UAConfig]" = None) -> None: if not cfg: self.cfg = config.UAConfig() else: self.cfg = cfg def headers(self): return { "user-agent": "UA-Client/{}".format(version.get_version()), "accept": "application/json", "content-type": "application/json", } def request_url(self, path, data=None, headers=None, method=None): path = path.lstrip("/") if not headers: headers = self.headers() if headers.get("content-type") == "application/json" and data: data = json.dumps(data).encode("utf-8") url = urljoin(getattr(self.cfg, self.cfg_url_base_attr), path) try: response, headers = util.readurl( url=url, data=data, headers=headers, method=method ) except error.URLError as e: if hasattr(e, "read"): try: error_details = json.loads(e.read().decode("utf-8")) except ValueError: error_details = None if error_details: raise self.api_error_cls(e, error_details) raise util.UrlError( e, code=getattr(e, "code", None), headers=headers, url=url ) return response, headers ubuntu-advantage-tools-19.7ubuntu0.1/.pre-commit-config.yaml0000664000000000000000000000022715223224541020716 0ustar repos: - repo: https://github.com/psf/black rev: 22.3.0 # Also stored in dev-requirements.txt; update both together! hooks: - id: black ubuntu-advantage-tools-19.7ubuntu0.1/demo/0000775000000000000000000000000014601326072015361 5ustar ubuntu-advantage-tools-19.7ubuntu0.1/demo/runserver.sh0000775000000000000000000000016314601326072017753 0ustar #!/bin/bash export GOPATH=/root/go export PATH=$GOPATH/bin:/usr/local/go/bin:$PATH cd /root/ua-contracts make demo ubuntu-advantage-tools-19.7ubuntu0.1/demo/entitlement-creds.json0000664000000000000000000000016614601326072021705 0ustar {"esm": "%ESM_CRED%", "fips": "%FIPS_CRED%", "fips-updates": "%FIPS_UPDATES_CRED%", "livepatch": "%LIVEPATCH_CRED%"} ubuntu-advantage-tools-19.7ubuntu0.1/demo/run-uaclient0000775000000000000000000001012314601326072017712 0ustar #!/usr/bin/env python """Use multipass or lxc to setup uaclients running ubuntu-advantage-client""" import argparse import glob import os import re import sys MACAROON_DEPS = { 'trusty': ['python3-libnacl', 'libsodium18'], 'xenial': ['python3-libnacl', 'libsodium18'], 'bionic': ['python3-libnacl', 'libsodium23'], 'disco': ['python3-libnacl', 'libsodium23']} try: from uaclient import util except ImportError: # Add out cwd to path for dev tools _tdir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) sys.path.insert(0, _tdir) from uaclient import util PROG='run-uaclient' def get_parser(): """Build an arg parser for run-uaclient utility.""" parser = argparse.ArgumentParser( prog=PROG, description='Create an lxc or kvm runing uaclient') parser.add_argument( '--backend', '-b', required=False, default='lxc', choices=['multipass', 'lxc'], help=('The backend to talk to when creating a vm: multipass or lxc. ' 'Default:"lxc"')) parser.add_argument( '--series', '-s', required=False, default='bionic', help='The Ubuntu series to deploy in the vm. Default: bionic') parser.add_argument( '--name', '-n', help='The name of the vm to create. Default: uaclient-') return parser def get_vm_name(backend, desired_name=None): """Find the appropriate unique vm name which doesn't aleady exist.""" vm_list, _err = util.subp([backend, 'list']) if desired_name not in vm_list: return desired_name match = re.match(r'[^\d]+(\d+)', desired_name) if not match: base_id = 1 desired_basename = desired_name else: base_id = match[0] desired_basename = desired_name.replace(base_id, '') while desired_name in vm_list: base_id = int(base_id) + 1 desired_name = '%s%d' % (desired_basename, base_id) return desired_name def create_uaclient_vm(backend, series, name=None): """Create a uaclient named uaclient vm if absent. @param backend: multipass or lxc @param series: Ubuntu series to deploy @param name: Name of the vm """ cmd = [] if not name: name = 'uaclient-%s' % series name = get_vm_name(backend, name) if series == 'trusty': debs = glob.glob('./ubuntu-advantage-tools*14.04.1_all.deb') else: debs = glob.glob('./ubuntu-advantage-tools*bddeb_all.deb') if not debs: raise RuntimeError( 'Found no ubuntu-advantage-debs in ./,' ' try make deb and make deb-trusty') deb = os.path.basename(debs[0]) if backend == 'multipass': util.subp(['multipass', 'launch', 'daily:%s' % series, '-n', name]) util.subp(['multipass', 'copy-files', './%s' % deb, '%s:.' % name]) util.subp(['multipass', 'exec', name, '--', 'sudo', 'apt-get', 'install'] + MACAROON_DEPS[series]) util.subp(['multipass', 'exec', name, '--', 'sudo', 'dpkg', '-i', deb]) util.subp(['multipass', 'copy-files', 'uaclient-devel.conf', '%s:.' % name]) util.subp(['multipass', 'exec', name, '--', 'sudo', 'mv', './uaclient-devel.conf', '/etc/ubuntu-advantage/uaclient.conf']) print('Access demo uaclient with:\nmultipass exec %s -- bash -l' % name) elif backend == 'lxc': util.subp(['lxc', 'launch', 'ubuntu-daily:%s' % series, name]) util.subp(['lxc', 'file', 'push', '%s' % deb, '%s/root/' % name]) util.subp(['lxc', 'exec', name, '--', 'sudo', 'dpkg', '-i', '/root/%s' % deb]) util.subp(['lxc', 'file', 'push', 'uaclient-devel.conf', '%s/etc/ubuntu-advantage/uaclient.conf' % name]) print('Access demo uaclient with:\nlxc exec %s ua status' % name) else: raise ValueError("Invalid backend %s. Not multipass|lxc" % backend) def main(): """Tool to collect and tar all related logs.""" parser = get_parser() args = parser.parse_args() create_uaclient_vm(args.backend, args.series, args.name) if __name__ == '__main__': sys.exit(main()) ubuntu-advantage-tools-19.7ubuntu0.1/demo/demo-contract-service0000775000000000000000000001166314601326072021513 0ustar #!/bin/bash # Simple script used to setup and patch up ua-service contract backend api with # sample data in an lxc. # This script will be killed once implementation of Contract Service API is # functional in the repo https://github.com/CanonicalLtd/ua-service # 01/07/2019: ATM only an OpenAPI spec is available for the contract service. upstream=${1:-CanonicalLtd} server_type=${2:-docker} if [ "$upstream" != "canonical-server" -a "$upstream" != "CanonicalLtd" ]; then echo "Invalid upstream value $upstream, expected canonical-server or CanonicalLtd" exit 1 fi NETRC=~/.netrc LXC_NAME=contract-demo-bionic if [ ! -d ua-contracts ]; then git clone git@github.com:$upstream/ua-contracts.git fi CREDS_FILE="./demo/entitlement-creds.json" echo -n "Enter your LaunchpadID: " read LP_ID USERCREDS_FILE="$CREDS_FILE.$LP_ID" if [ ! -f $USERCREDS_FILE ]; then echo -n "Configuring local $CREDS_FILE to seed demo contract service" echo "Find PPA credentials (user:passwd) by clicking the 'View' links next to the named PPA at: https://launchpad.net/~$LP_ID/+archivesubscriptions/" echo -n "Enter your CIS Security Benchmarks (ppa:ubuntu-advantage/security-benchmarks) (user.name:key): " read CIS_TOKEN echo -n "Enter your ESM Staging creds (user.name:key): " read ESM_TOKEN echo -n "Enter your FIPS ppa creds (user.name:key): " read FIPS_TOKEN echo -n "Enter your FIPS Updates ppa creds (user.name:key): " read FIPS_UPDATES_TOKEN echo -n "Enter your Livepatch token from https://auth.livepatch.canonical.com/: " read LIVEPATCH_TOKEN sed "s/%LIVEPATCH_CRED%/${LIVEPATCH_TOKEN}/; s/%FIPS_CRED%/$FIPS_TOKEN/; s/%FIPS_UPDATES_CRED%/$FIPS_UPDATES_TOKEN/; s/%ESM_CRED%/$ESM_TOKEN/; s/%CIS_CRED%/$CIS_TOKEN/" $CREDS_FILE > $USERCREDS_FILE cat > ${CREDS_FILE/json/sh} <> .bashrc' lxc exec $LXC_NAME -- sh -c 'echo export PATH=\$GOPATH/bin:/usr/local/go/bin:\$PATH >> .bashrc' echo -e "Running demo contract server API with:\nlxc exec $LXC_NAME /root/runserver.sh" lxc exec $LXC_NAME /root/runserver.sh fi VM_IP=`lxc list -c n4 $LXC_NAME | grep eth0 | awk '{print $3}'` CONTRACT_URL="http:\/\/$VM_IP:3000" echo "Changing uaclient-devel.conf to point to your lxc @ $CONTRACT_URL" sed -i "s/contract_url.*/contract_url: '$CONTRACT_URL'/" uaclient-devel.conf # Rename devel config to $LXC_NAME/etc/ubuntu-advantage/uaclient.conf lxc file push uaclient-devel.conf $LXC_NAME/etc/ubuntu-advantage/uaclient.conf echo "To enable bootstrapped admin user to change contract details..." echo curl -X PUT -u \"admin:password1234\" ${CONTRACT_URL//\\/}/acl/product -H \"Content-Type: application/json\" -d \'{\"users\": [\"admin\"]}\' echo echo "To read free product:" echo curl -u \"admin:password1234\" ${CONTRACT_URL//\\/}/v1/products/free echo echo "To manipulate free contract, change values in free-contract.json and run the folowing:" echo curl -X POST -u \"admin:password1234\" ${CONTRACT_URL//\\/}/v1/products/free -H \"Content-Type: application/json\" -d @free-contract.json ubuntu-advantage-tools-19.7ubuntu0.1/demo/contracts-controller.patch0000664000000000000000000001260714601326072022571 0ustar --- openapi_server/controllers/default_controller.py 2019-02-04 23:34:10.227503021 +0000 +++ openapi_server/controllers/default_controller.py.new 2019-02-04 23:34:05.791511018 +0000 @@ -24,6 +24,58 @@ from openapi_server.models.user_contracts_response import UserContractsResponse # noqa: E501 from openapi_server import util +import datetime +import json + +from openapi_server.models.entitlement_apt_repository import EntitlementAptRepository # noqa: E501 +from openapi_server.models.entitlement_livepatch import EntitlementLivepatch # noqa: E501 + + + +CREDS_FILE = '/root/entitlement-creds.json' +with open(CREDS_FILE) as stream: + creds = json.loads(stream.read()) + +now = datetime.datetime.utcnow() +contract_expiry = now + datetime.timedelta(days=100) +entitlement_expiry = now + datetime.timedelta(days=1) +entitlement_expiry_str = entitlement_expiry.strftime('%Y-%m-%dT%H:%M:%S.%fZ') +revoked_date = now + datetime.timedelta(hours=1) +machine_token_expiry = now + datetime.timedelta(days=5) + + +entitlementESM = EntitlementAptRepository( + entitled=True, type='esm', affordances=[{'series': ['trusty', 'xenial', 'bionic']}], + directives={'serviceURL': 'https://private-ppa.launchpad.net/canonical-server/uaclient-test', 'aptKey': '94E187AD53A59D1847E4880F8A295C4FB8B190B7'}) +entitlementFIPS = EntitlementAptRepository(entitled=True, type='fips', affordances=[{'series': ['xenial']}], directives={'serviceURL': 'https://private-ppa.launchpad.net/ubuntu-advantage/fips', 'aptKey': 'A166877412DAC26E73CEBF3FF6C280178D13028C'}) +entitlementFIPSUpdates = EntitlementAptRepository(entitled=True, type='fips-updates', affordances=[{'series': ['xenial']}], directives={'serviceURL': 'https://private-ppa.launchpad.net/ubuntu-advantage/fips-updates', 'aptKey': 'A166877412DAC26E73CEBF3FF6C280178D13028C'}) +entitlementLivepatch = EntitlementLivepatch(entitled=True, type='livepatch', affordances=[ + {'kernelFlavors': ['generic', 'aws', 'gcp', 'azure', 'ibm'], + 'series': ['trusty', 'xenial', 'bionic', 'cosmic', 'disco']}]) +contract1 = ContractInfo( + name='blackberry/desktop', + id='cid_1', + created_at=now, + effective_from=now, + effective_to=contract_expiry, + resource_entitlements={ + 'fips': entitlementFIPS, 'esm': entitlementESM, + 'fips-updates': entitlementFIPSUpdates, 'livepatch': entitlementLivepatch}) + + + +machinetokeninfo1 = MachineTokenInfo( + created_at = now, + expires = machine_token_expiry, + machine_id='remote_machine_1', contract_info=contract1) +machinetokeninfo2 = MachineTokenInfo( # disabled + revoked_at = revoked_date, + created_at = now, + expires = machine_token_expiry, + machine_id='remote_machine_1', contract_info=contract1) +addContractMachineResponse = AddContractMachineResponse(machine_token='sekret1', machine_token_info=machinetokeninfo1) +account1 = AccountInfo(id='aid_1', name='Blackberry Limited') + def add_account(new_account_params=None): # noqa: E501 """add_account @@ -86,7 +138,7 @@ """ if connexion.request.is_json: add_contract_machine_body = AddContractMachineBody.from_dict(connexion.request.get_json()) # noqa: E501 - return 'do some magic!' + return addContractMachineResponse def add_contract_token(contract, body=None): # noqa: E501 @@ -131,7 +183,7 @@ :rtype: None """ - return 'do some magic!' + return 'Do some magic!' def find_account(name=None, id=None, admin_user=None, user=None): # noqa: E501 @@ -150,7 +202,7 @@ :rtype: List[AccountInfo] """ - return 'do some magic!' + return [account1] def find_account_contract(account): # noqa: E501 @@ -163,7 +215,7 @@ :rtype: List[AccountContractInfo] """ - return 'do some magic!' + return [AccountContractInfo(account_info=account1, contract_info=contract1)] def find_account_id(account): # noqa: E501 @@ -189,7 +241,7 @@ :rtype: List[AccountUserAccess] """ - return 'do some magic!' + return [AccountUserAccess(user_id=42, user_access='delegated')] def find_account_user_access_id(account, user): # noqa: E501 @@ -230,7 +282,7 @@ :rtype: List[MachineTokenInfo] """ - return 'do some magic!' + return [machinetokeninfo1] def find_contract_token(contract, user): # noqa: E501 @@ -348,7 +400,23 @@ :rtype: GetResourceMachineAccessResponse """ - return 'do some magic!' + responses = { + 'fips': GetResourceMachineAccessResponse( + entitlement=entitlementFIPS, + resource_token=creds['fips']), + 'fips-updates': GetResourceMachineAccessResponse( + entitlement=entitlementFIPSUpdates, + resource_token=creds['fips-updates']), + 'esm': GetResourceMachineAccessResponse( + entitlement=entitlementESM, + resource_token=creds['esm']), + 'livepatch': GetResourceMachineAccessResponse( + entitlement=entitlementLivepatch, + resource_token=creds['livepatch'])} + + if resource in responses: + return responses[resource], 200, {'Expires': entitlement_expiry_str} + return 'invalid resource requested %s' % resource def get_user_accounts(): # noqa: E501 @@ -411,7 +479,7 @@ :rtype: MachineTokenInfo """ - return 'do some magic!' + return machinetokeninfo2 def revoke_contract_token_id(contract, user, token): # noqa: E501 ubuntu-advantage-tools-19.7ubuntu0.1/demo/README.md0000664000000000000000000000157114601326072016644 0ustar # Demo The 'demo' directory is reserved to developer environment test tools and scripts. Nothing in this directory will be shipped as part of the packaging. ## Files - contract*patch: patch files applied to the generated demo Contract API service to create a sample API server: To be dropped when Contract Service API is functional - demo-contract-service: script which will launch a new bionic lxc and install a https://github.com/CanonicalLtd/ua-service API backend with real PPA/livepatch credentials and sample response data - entitlement-creds.json: Template file containing placeholders for esm, fips, fips-updates and livepatch credentials used in seeding ua-service API responses - install-contract-server: script to be run within a newly launched lxc to patch and generate a ua-service openapi server with sample data - run-uaclient: TODO - runserver.sh: TODO - uaclient: TODO ubuntu-advantage-tools-19.7ubuntu0.1/demo/contracts-implemented-controller.patch0000664000000000000000000001462714601326072025076 0ustar --- openapi_server/controllers/default_controller.py.orig 2019-02-08 17:08:43.611137024 +0000 +++ openapi_server/controllers/default_controller.py 2019-02-08 20:47:58.262713336 +0000 @@ -28,6 +28,62 @@ add_contract_body = AddContractBody.from_dict(connexion.request.get_json()) # noqa: E501 return 'do some magic!' +import datetime +import json + +from openapi_server.models.account_contract_info import AccountContractInfo # noqa: E501 +from openapi_server.models.account_info import AccountInfo # noqa: E501 +from openapi_server.models.contract_info import ContractInfo # noqa: E501 +from openapi_server.models.contract_token_info import ContractTokenInfo # noqa: E501 +from openapi_server.models.entitlement_apt_repository import EntitlementAptRepository # noqa: E501 +from openapi_server.models.entitlement_livepatch import EntitlementLivepatch # noqa: E501 +from openapi_server.models.machine_token_info import MachineTokenInfo # noqa: E501 + + + +CREDS_FILE = '/root/entitlement-creds.json' +with open(CREDS_FILE) as stream: + creds = json.loads(stream.read()) + +now = datetime.datetime.utcnow() +contract_expiry = now + datetime.timedelta(days=100) +entitlement_expiry = now + datetime.timedelta(days=1) +entitlement_expiry_str = entitlement_expiry.strftime('%Y-%m-%dT%H:%M:%S.%fZ') +machine_token_expiry = now + datetime.timedelta(days=5) + + +entitlementESM = EntitlementAptRepository( + entitled=True, type='esm', affordances=[{'series': ['trusty', 'xenial', 'bionic']}], + directives={'serviceURL': 'https://private-ppa.launchpad.net/canonical-server/uaclient-test', 'aptKey': '94E187AD53A59D1847E4880F8A295C4FB8B190B7'}) +entitlementFIPS = EntitlementAptRepository(entitled=True, type='fips', affordances=[{'series': ['xenial']}], directives={'serviceURL': 'https://private-ppa.launchpad.net/ubuntu-advantage/fips', 'aptKey': 'A166877412DAC26E73CEBF3FF6C280178D13028C'}) +entitlementFIPSUpdates = EntitlementAptRepository(entitled=True, type='fips-updates', affordances=[{'series': ['xenial']}], directives={'serviceURL': 'https://private-ppa.launchpad.net/ubuntu-advantage/fips-updates', 'aptKey': 'A166877412DAC26E73CEBF3FF6C280178D13028C'}) +entitlementLivepatch = EntitlementLivepatch(entitled=True, type='livepatch', affordances=[ + {'kernelFlavors': ['generic', 'aws', 'gcp', 'azure', 'ibm'], + 'series': ['trusty', 'xenial', 'bionic', 'cosmic', 'disco']}]) +contract1 = ContractInfo( + name='blackberry/desktop', + id='cid_1', + created_at=now, + effective_from=now, + effective_to=contract_expiry, + resource_entitlements={ + 'fips': entitlementFIPS, 'esm': entitlementESM, + 'fips-updates': entitlementFIPSUpdates, 'livepatch': entitlementLivepatch}) + + +contracttokeninfo = ContractTokenInfo( + contract_info=contract1, expires=machine_token_expiry) +addContractTokenResponse = AddContractTokenResponse(contract_token='contract_sekret1', contract_token_info=contracttokeninfo) + +machinetokeninfo1 = MachineTokenInfo( + expires = machine_token_expiry, + machine_id='remote_machine_1', contract_info=contract1) +machinetokeninfo2 = MachineTokenInfo( # disabled + expires = machine_token_expiry, + machine_id='remote_machine_1', contract_info=contract1) +addContractMachineResponse = AddContractMachineResponse(machine_token='sekret1', machine_token_info=machinetokeninfo1) +account1 = AccountInfo(id='aid_1', name='Blackberry Limited') + def add_contract_machine(add_contract_machine_body=None): # noqa: E501 """add_contract_machine @@ -41,7 +97,7 @@ """ if connexion.request.is_json: add_contract_machine_body = AddContractMachineBody.from_dict(connexion.request.get_json()) # noqa: E501 - return 'do some magic!' + return addContractMachineResponse def add_contract_token(contract, body=None): # noqa: E501 @@ -56,7 +112,7 @@ :rtype: AddContractTokenResponse """ - return 'do some magic!' + return addContractTokenResponse def get_account_contracts(account): # noqa: E501 @@ -69,7 +125,7 @@ :rtype: GetContractsResponse """ - return 'do some magic!' + return GetContractsResponse(contracts=[AccountContractInfo(account_info=account1, contract_info=contract1)]) def get_accounts(): # noqa: E501 @@ -80,7 +136,7 @@ :rtype: GetAccountsResponse """ - return 'do some magic!' + return GetAccountsResponse(accounts=[account1]) def get_canonical_sso_macaroon(): # noqa: E501 @@ -91,7 +147,7 @@ :rtype: GetCanonicalSSOMacaroonResponse """ - return 'do some magic!' + return GetCanonicalSSOMacaroonResponse(macaroon='MDAwZWxvY2F0aW9uIAowMDQ3aWRlbnRpZmllciBBd29RNEdTQkI5RzRJYmFjeGh0eUZudU5hUklCTUJvUkNnaHpjMjlzYjJkcGJoSUZiRzluYVc0CjAwMzFjaWQgdGltZS1iZWZvcmUgMjAxOS0wMi0xNVQxNzoyODowNC4xMjAxMDg5WgowMTdhY2lkIHsic2VjcmV0Ijoic2ZMc0Y4c1VSUDJBV3Q4NnJva0g0M1NWQXdjcUo2aE1QdTExY3lnQUswR1dDamN4N0REYktKUWFMRSsxUnpSSXlLSWUxS05kVXpGb0ZoNXZNY1FHRnpKVXNaRlZjUlBsMkFCUjgydU9FVnZXK1FNMGJuNWxHVjZ1UWhiUGFLdDdINmd3OGxMV3JreGw0N0FZOVd3THNhR244MFRNdVB1Ym9zc3kweGNsTVVmVS9lR2ZNTTZuQ1JIUzZnMDJ6cUI1YXJkWGFqQTZHQU5NemRpRDhUU2J1dnNlaVhINExVYUFyRTlKNVdSck5sVndYazkrcTZwMzZNUUFsUlg0N1FxZWZuOGpkWkkxTnFENWxHaUh0SzV2TFN2Rk1wMkk0L29zZ01ObHBQWFk1VENTRkJHM2dNU09SM3dpNzVPREZ3N1RUQ2E4cjFkRGcyV1BtWjU3VjZWR3hRPT0iLCJ2ZXJzaW9uIjoxfQowMDUxdmlkIA48kKeLV6UH_kWeflAt6HpgAjI9MEIXOZi4tEMeHYHMWpxGddwpfsgIobKnhJHFoEx00unMqrFHFmaNM4mTyHZ-ATJ6EcjGigowMDE4Y2wgbG9naW4udWJ1bnR1LmNvbQowMDJmc2lnbmF0dXJlINKtgxrlCulP1BT3sUKITGSIp5yRm64fhhe6Iv8iGq-9Cg') def get_resource_machine_access(resource, machine): # noqa: E501 @@ -106,4 +162,20 @@ :rtype: GetResourceMachineAccessResponse """ - return 'do some magic!' + responses = { + 'fips': GetResourceMachineAccessResponse( + entitlement=entitlementFIPS, + resource_token=creds['fips']), + 'fips-updates': GetResourceMachineAccessResponse( + entitlement=entitlementFIPSUpdates, + resource_token=creds['fips-updates']), + 'esm': GetResourceMachineAccessResponse( + entitlement=entitlementESM, + resource_token=creds['esm']), + 'livepatch': GetResourceMachineAccessResponse( + entitlement=entitlementLivepatch, + resource_token=creds['livepatch'])} + + if resource in responses: + return responses[resource], 200, {'Expires': entitlement_expiry_str} + return 'invalid resource requested %s' % resource ubuntu-advantage-tools-19.7ubuntu0.1/demo/uaclient0000664000000000000000000000017314601326072017111 0ustar # rc file to setup an alias for uaclient testing alias ua='sudo UA_CONFIG_FILE=uaclient-devel.conf python -m uaclient.cli' ubuntu-advantage-tools-19.7ubuntu0.1/test-requirements.txt0000664000000000000000000000015514601326072020677 0ustar # Linting flake8 pycodestyle # Unit testing mock pytest pytest-cov # Integration testing behave PyHamcrest