def get_version(_args=None, features={}): """Return the packaged version as a string Prefer the binary PACKAGED_VESION set by debian/rules to DEB_VERSION. If unavailable, check for a .git development environments: a. If run in our upstream repo `git describe` will gives a leading XX.Y so return the --long version to allow daily build recipes to count commit offset from upstream's XX.Y signed tag. b. If run in a git-ubuntu pkg repo, upstream tags aren't visible, parse the debian/changelog in that case """ feature_suffix = "" for key, value in sorted(features.items()): feature_suffix += " {enabled}{name}".format( enabled="+" if value else "-", name=key) if not PACKAGED_VERSION.startswith("@@PACKAGED_VERSION"): return VERSION_TMPL.format(version=PACKAGED_VERSION, feature_suffix=feature_suffix) 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"] try: out, _ = util.subp(cmd) return out.strip() + feature_suffix except exceptions.ProcessExecutionError: # Rely on debian/changelog because we are in a git-ubuntu or other # packaging repo cmd = ["dpkg-parsechangelog", "-S", "version"] out, _ = util.subp(cmd) return VERSION_TMPL.format(version=out.strip(), feature_suffix=feature_suffix) return VERSION_TMPL.format(version=__VERSION__, feature_suffix=feature_suffix)
def configure_livepatch_proxy( http_proxy: Optional[str] = None, https_proxy: Optional[str] = None, retry_sleeps: Optional[List[float]] = None, ) -> None: """ Configure livepatch to use http and https proxies. :param http_proxy: http proxy to be used by livepatch. If None, it will not be configured :param https_proxy: https proxy to be used by livepatch. If None, it will not be configured :@param retry_sleeps: Optional list of sleep lengths to apply between snap calls """ if http_proxy or https_proxy: event.info( messages.SETTING_SERVICE_PROXY.format( service=LivepatchEntitlement.title ) ) if http_proxy: util.subp( [LIVEPATCH_CMD, "config", "http-proxy={}".format(http_proxy)], retry_sleeps=retry_sleeps, ) if https_proxy: util.subp( [LIVEPATCH_CMD, "config", "https-proxy={}".format(https_proxy)], retry_sleeps=retry_sleeps, )
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 update_apt_and_motd_messages(cfg: config.UAConfig) -> bool: """Emit templates and human-readable status messages in msg_dir. These structured messages will be sourced by both /etc/update.motd.d and APT UA-configured hooks. APT hook content will orginate from apt-hook/hook.cc Call esm-apt-hook process-templates to render final human-readable messages. :param cfg: UAConfig instance for this environment. """ logging.debug("Updating UA messages for APT and MOTD.") msg_dir = os.path.join(cfg.data_dir, "messages") if not os.path.exists(msg_dir): os.makedirs(msg_dir) series = util.get_platform_info()["series"] if not util.is_lts(series): # ESM is only on LTS releases. Remove all messages and templates. for msg_enum in ExternalMessage: msg_path = os.path.join(msg_dir, msg_enum.value) util.remove_file(msg_path) if msg_path.endswith(".tmpl"): util.remove_file(msg_path.replace(".tmpl", "")) return True # Announce ESM availabilty on active ESM LTS releases write_esm_announcement_message(cfg, series) write_apt_and_motd_templates(cfg, series) # Now that we've setup/cleanedup templates render them with apt-hook util.subp(["/usr/lib/ubuntu-advantage/apt-esm-hook", "process-templates"]) return True
def enable(self): """Enable specific entitlement. @return: True on success, False otherwise. """ if not self.can_enable(): return False if not util.which('/snap/bin/canonical-livepatch'): print('Installing canonical-livepatch snap...') util.subp(['snap', 'install', 'canonical-livepatch'], capture=True) entitlement_cfg = self.cfg.entitlements.get(self.name) 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'] 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 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=%s' % 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=%s' % remote_server ], capture=True)
def version_cmp_le(version1: str, version2: str) -> bool: """Return True when version1 is less than or equal to version2.""" try: util.subp(["dpkg", "--compare-versions", version1, "le", version2]) return True except exceptions.ProcessExecutionError: return 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 self.setup_apt_config(): return False if self.packages: try: print( 'Installing {title} packages ...'.format(title=self.title)) util.subp(['apt-get', 'install', '--assume-yes'] + self.packages, capture=True) except util.ProcessExecutionError: self.disable(silent=True, force=True) logging.error( status.MESSAGE_ENABLED_FAILED_TMPL.format( title=self.title)) return False self.cfg.local_enabled_manager.set(self.name, True) print(status.MESSAGE_ENABLED_TMPL.format(title=self.title)) for msg in self.messaging.get('post_enable', []): print(msg) return True
def get_version(_args=None): """Return the packaged version as a string Prefer the binary PACKAGED_VESION set by debian/rules to DEB_VERSION. If unavailable, check for a .git development environments: a. If run in our upstream repo `git describe` will gives a leading XX.Y so return the --long version to allow daily build recipes to count commit offset from upstream's XX.Y signed tag. b. If run in a git-ubuntu pkg repo, upstream tags aren't visible, parse the debian/changelog in that case """ 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"] try: out, _ = util.subp(cmd) return out.strip() except util.ProcessExecutionError: # Rely on debian/changelog because we are in a git-ubuntu or other # packaging repo cmd = ["dpkg-parsechangelog", "-S", "version"] out, _ = util.subp(cmd) return out.strip() return __VERSION__
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, )
def disable(self, silent=False, force=False): """Disable specific entitlement @return: True on success, False otherwise. """ if not self.can_disable(silent, force): return False series = util.get_platform_info('series') repo_filename = self.repo_list_file_tmpl.format(name=self.name, series=series) keyring_file = os.path.join(apt.APT_KEYS_DIR, self.repo_key_file) entitlement_cfg = self.cfg.read_cache('machine-access-%s' % self.name)['entitlement'] access_directives = entitlement_cfg.get('directives', {}) repo_url = access_directives.get('aptURL', self.repo_url) if not repo_url: repo_url = self.repo_url apt.remove_auth_apt_repo(repo_filename, repo_url, keyring_file) apt.remove_apt_list_files(repo_url, series) print('Removing packages: %s' % ', '.join(self.packages)) try: util.subp(['apt-get', 'remove', '--assume-yes'] + self.packages) except util.ProcessExecutionError: pass return True
def disable(self, silent=False, force=False): if not self.can_disable(silent, force): return False if force: # Force config cleanup as broke during setup attempt. series = util.get_platform_info('series') repo_filename = self.repo_list_file_tmpl.format(name=self.name, series=series) keyring_file = os.path.join(apt.APT_KEYS_DIR, self.repo_key_file) entitlement = self.cfg.read_cache( 'machine-access-%s' % 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 apt.remove_auth_apt_repo(repo_filename, repo_url, keyring_file) if self.repo_pin_priority: repo_pref_file = self.repo_pref_file_tmpl.format( name=self.name, series=series) if os.path.exists(repo_pref_file): os.unlink(repo_pref_file) apt.remove_apt_list_files(repo_url, series) try: util.subp(['apt-get', 'remove', '--assume-yes'] + self.packages) except util.ProcessExecutionError: pass if not silent: print('Warning: no option to disable {title}'.format( title=self.title)) return False
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 remove_auth_apt_repo(repo_filename, repo_url, keyring_file=None, fingerprint=None): """Remove an authenticated apt repo and credentials to the system""" logging.info('Removing authenticated apt repo: %s', repo_url) util.del_file(repo_filename) if keyring_file: util.del_file(keyring_file) elif fingerprint: util.subp(['apt-key', 'del', fingerprint], capture=True) _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 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. @return: True if valid or unable to validate """ protocol, repo_path = repo_url.split('://') if not os.path.exists('/usr/lib/apt/apt-helper'): return True # Do not validate try: util.subp([ '/usr/lib/apt/apt-helper', 'download-file', '%s://%s:%s@%s/ubuntu/pool/' % (protocol, username, password, repo_path), '/tmp/uaclient-apt-test' ], capture=False) # Hide credentials from logs return True except util.ProcessExecutionError: pass finally: if os.path.exists('/tmp/uaclient-apt-test'): os.unlink('/tmp/uaclient-apt-test') return False
def valid_apt_credentials(repo_url, series, credentials): """Validate apt credentials for a PPA. @param repo_url: private-ppa url path @param credentials: PPA credentials string username:password. @param series: xenial, bionic ... @return: True if valid or unable to validate """ protocol, repo_path = repo_url.split('://') if not os.path.exists('/usr/lib/apt/apt-helper'): return True # Do not validate try: util.subp(['/usr/lib/apt/apt-helper', 'download-file', '%s://%s@%s/ubuntu/dists/%s/Release' % ( protocol, credentials, repo_path, series), '/tmp/uaclient-apt-test'], capture=False) # Hide credentials from logs os.unlink('/tmp/uaclient-apt-test') return True except util.ProcessExecutionError: pass if os.path.exists('/tmp/uaclient-apt-test'): os.unlink('/tmp/uaclient-apt-test') return False
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
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
def _perform_disable(self, silent=False): """Disable specific entitlement @return: True on success, False otherwise. """ if not util.which(LIVEPATCH_CMD): return True util.subp([LIVEPATCH_CMD, "disable"], 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 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
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 process_contract_delta_after_apt_lock() -> None: logging.debug("Check whether to upgrade-lts-contract") cfg = UAConfig() if not cfg.is_attached: logging.debug("Skipping upgrade-lts-contract. Machine is unattached") return out, _err = subp(["lsof", "/var/lib/apt/lists/lock"], rcs=[0, 1]) msg = "Starting upgrade-lts-contract." if out: msg += " Retrying every 10 seconds waiting on released apt lock" print(msg) logging.debug(msg) current_version = parse_os_release()["VERSION_ID"] current_release = version_to_codename.get(current_version) if current_release is None: msg = "Unable to get release codename for version: {}".format( current_version) print(msg) logging.warning(msg) sys.exit(1) past_release = current_codename_to_past_codename.get(current_release) if past_release is None: msg = "Could not find past release for: {}".format(current_release) print(msg) logging.warning(msg) sys.exit(1) past_entitlements = UAConfig(series=past_release).entitlements new_entitlements = UAConfig(series=current_release).entitlements retry_count = 0 while out: # Loop until apt hold is released at the end of `do-release-upgrade` time.sleep(10) out, _err = subp(["lsof", "/var/lib/apt/lists/lock"], rcs=[0, 1]) retry_count += 1 msg = "upgrade-lts-contract processing contract deltas: {} -> {}".format( past_release, current_release) print(msg) logging.debug(msg) process_entitlements_delta( cfg=cfg, past_entitlements=past_entitlements, new_entitlements=new_entitlements, allow_enable=True, series_overrides=False, ) msg = "upgrade-lts-contract succeeded after {} retries".format(retry_count) print(msg) logging.debug(msg)
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) return 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'] op_status, _details = self.operational_status() if op_status == status.ACTIVE: 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 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) if entitlement.disable(): if hasattr(entitlement, 'repo_url'): util.subp(['apt-get', 'update'], capture=True) return 0 else: return 1
def operational_status(self): """Return entitlement operational status as ACTIVE or INACTIVE.""" passed_affordances, details = self.check_affordances() if not passed_affordances: return status.INAPPLICABLE, details operational_status = (status.ACTIVE, '') 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)) operational_status = (status.INACTIVE, str(e)) return operational_status
def refresh_motd(): # If update-notifier is present, we might as well update # the package updates count related to MOTD if exists(UPDATE_NOTIFIER_MOTD_SCRIPT): # If this command fails, we shouldn't break the entire command, # since this command should already be triggered by # update-notifier apt hooks try: util.subp([UPDATE_NOTIFIER_MOTD_SCRIPT, "--force"]) except Exception as exc: logging.exception(exc) util.subp(["sudo", "systemctl", "restart", "motd-news.service"])
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. """ protocol, repo_path = repo_url.split("://") if not os.path.exists("/usr/lib/apt/apt-helper"): return try: util.subp( [ "/usr/lib/apt/apt-helper", "download-file", "{}://{}:{}@{}/ubuntu/pool/".format( protocol, username, password, repo_path ), "/tmp/uaclient-apt-test", ], 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: if os.path.exists("/tmp/uaclient-apt-test"): os.unlink("/tmp/uaclient-apt-test")
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