Path: blob/develop/awscli/customizations/codeartifact/login.py
2637 views
import errno1import os2import platform3import sys4import subprocess5import re67from datetime import datetime8from dateutil.tz import tzutc9from dateutil.relativedelta import relativedelta10from botocore.utils import parse_timestamp1112from awscli.compat import (13is_windows, urlparse, RawConfigParser, StringIO,14get_stderr_encoding, is_macos15)16from awscli.customizations import utils as cli_utils17from awscli.customizations.commands import BasicCommand18from awscli.customizations.utils import uni_print192021def get_relative_expiration_time(remaining):22values = []23prev_non_zero_attr = False24for attr in ["years", "months", "days", "hours", "minutes"]:25value = getattr(remaining, attr)26if value > 0:27if prev_non_zero_attr:28values.append("and")29values.append(str(value))30values.append(attr[:-1] if value == 1 else attr)31if prev_non_zero_attr:32break33prev_non_zero_attr = value > 03435message = " ".join(values)36return message373839class CommandFailedError(Exception):40def __init__(self, called_process_error, auth_token):41msg = str(called_process_error).replace(auth_token, '******')42if called_process_error.stderr is not None:43msg +=(44f' Stderr from command:\n'45f'{called_process_error.stderr.decode(get_stderr_encoding())}'46)47Exception.__init__(self, msg)484950class BaseLogin(object):51_TOOL_NOT_FOUND_MESSAGE = '%s was not found. Please verify installation.'5253def __init__(self, auth_token, expiration, repository_endpoint,54domain, repository, subprocess_utils, namespace=None):55self.auth_token = auth_token56self.expiration = expiration57self.repository_endpoint = repository_endpoint58self.domain = domain59self.repository = repository60self.subprocess_utils = subprocess_utils61self.namespace = namespace6263def login(self, dry_run=False):64raise NotImplementedError('login()')6566def _dry_run_commands(self, tool, commands):67for command in commands:68sys.stdout.write(' '.join(command))69sys.stdout.write(os.linesep)70sys.stdout.write(os.linesep)7172def _write_success_message(self, tool):73# add extra 30 seconds make expiration more reasonable74# for some corner case75# e.g. 11 hours 59 minutes 31 seconds should output --> 12 hours.76remaining = relativedelta(77self.expiration, datetime.now(tzutc())) + relativedelta(seconds=30)78expiration_message = get_relative_expiration_time(remaining)7980sys.stdout.write('Successfully configured {} to use '81'AWS CodeArtifact repository {} '82.format(tool, self.repository_endpoint))83sys.stdout.write(os.linesep)84sys.stdout.write('Login expires in {} at {}'.format(85expiration_message, self.expiration))86sys.stdout.write(os.linesep)8788def _run_commands(self, tool, commands, dry_run=False):89if dry_run:90self._dry_run_commands(tool, commands)91return9293for command in commands:94self._run_command(tool, command)9596self._write_success_message(tool)9798def _run_command(self, tool, command, *, ignore_errors=False):99try:100self.subprocess_utils.run(101command,102capture_output=True,103check=True104)105except subprocess.CalledProcessError as ex:106if not ignore_errors:107raise CommandFailedError(ex, self.auth_token)108except OSError as ex:109if ex.errno == errno.ENOENT:110raise ValueError(111self._TOOL_NOT_FOUND_MESSAGE % tool112)113raise ex114115@classmethod116def get_commands(cls, endpoint, auth_token, **kwargs):117raise NotImplementedError('get_commands()')118119120class SwiftLogin(BaseLogin):121122DEFAULT_NETRC_FMT = \123u'machine {hostname} login token password {auth_token}'124125NETRC_REGEX_FMT = \126r'(?P<entry_start>\bmachine\s+{escaped_hostname}\s+login\s+\S+\s+password\s+)' \127r'(?P<token>\S+)'128129def login(self, dry_run=False):130scope = self.get_scope(131self.namespace132)133commands = self.get_commands(134self.repository_endpoint, self.auth_token, scope=scope135)136137if not is_macos:138hostname = urlparse.urlparse(self.repository_endpoint).hostname139new_entry = self.DEFAULT_NETRC_FMT.format(140hostname=hostname,141auth_token=self.auth_token142)143if dry_run:144self._display_new_netrc_entry(new_entry, self.get_netrc_path())145else:146self._update_netrc_entry(hostname, new_entry, self.get_netrc_path())147148self._run_commands('swift', commands, dry_run)149150def _display_new_netrc_entry(self, new_entry, netrc_path):151sys.stdout.write('Dryrun mode is enabled, not writing to netrc.')152sys.stdout.write(os.linesep)153sys.stdout.write(154f'The following line would have been written to {netrc_path}:'155)156sys.stdout.write(os.linesep)157sys.stdout.write(os.linesep)158sys.stdout.write(new_entry)159sys.stdout.write(os.linesep)160sys.stdout.write(os.linesep)161sys.stdout.write('And would have run the following commands:')162sys.stdout.write(os.linesep)163sys.stdout.write(os.linesep)164165def _update_netrc_entry(self, hostname, new_entry, netrc_path):166pattern = re.compile(167self.NETRC_REGEX_FMT.format(escaped_hostname=re.escape(hostname)),168re.M169)170if not os.path.isfile(netrc_path):171self._create_netrc_file(netrc_path, new_entry)172else:173with open(netrc_path, 'r') as f:174contents = f.read()175escaped_auth_token = self.auth_token.replace('\\', r'\\')176new_contents = re.sub(177pattern,178rf"\g<entry_start>{escaped_auth_token}",179contents180)181182if new_contents == contents:183new_contents = self._append_netrc_entry(new_contents, new_entry)184185with open(netrc_path, 'w') as f:186f.write(new_contents)187188def _create_netrc_file(self, netrc_path, new_entry):189dirname = os.path.split(netrc_path)[0]190if not os.path.isdir(dirname):191os.makedirs(dirname)192with os.fdopen(os.open(netrc_path,193os.O_WRONLY | os.O_CREAT, 0o600), 'w') as f:194f.write(new_entry + '\n')195196def _append_netrc_entry(self, contents, new_entry):197if contents.endswith('\n'):198return contents + new_entry + '\n'199else:200return contents + '\n' + new_entry + '\n'201202@classmethod203def get_netrc_path(cls):204return os.path.join(os.path.expanduser("~"), ".netrc")205206@classmethod207def get_scope(cls, namespace):208# Regex for valid scope name209valid_scope_name = re.compile(210r'\A[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}\Z'211)212213if namespace is None:214return namespace215216if not valid_scope_name.match(namespace):217raise ValueError(218'Invalid scope name, scope must contain URL-safe '219'characters, no leading dots or underscores and no '220'more than 39 characters'221)222223return namespace224225@classmethod226def get_commands(cls, endpoint, auth_token, **kwargs):227commands = []228scope = kwargs.get('scope')229230# Set up the codeartifact repository as the swift registry.231set_registry_command = [232'swift', 'package-registry', 'set', endpoint233]234if scope is not None:235set_registry_command.extend(['--scope', scope])236commands.append(set_registry_command)237238# Authenticate against the repository.239# We will write token to .netrc for Linux and Windows240# MacOS will store the token from command line option to Keychain241login_registry_command = [242'swift', 'package-registry', 'login', f'{endpoint}login'243]244if is_macos:245login_registry_command.extend(['--token', auth_token])246commands.append(login_registry_command)247248return commands249250251class NuGetBaseLogin(BaseLogin):252_NUGET_INDEX_URL_FMT = '{endpoint}v3/index.json'253254# When adding new sources we can specify that we added the source to the255# user level NuGet.Config file. However, when updating an existing source256# we cannot be specific about which level NuGet.Config file was updated257# because it is possible that the existing source was not in the user258# level NuGet.Config. The source listing command returns all configured259# sources from all NuGet.Config levels. The update command updates the260# source in whichever NuGet.Config file the source was found.261_SOURCE_ADDED_MESSAGE = 'Added source %s to the user level NuGet.Config\n'262_SOURCE_UPDATED_MESSAGE = 'Updated source %s in the NuGet.Config\n'263# Example line the below regex should match:264# 1. nuget.org [Enabled]265_SOURCE_REGEX = re.compile(r'^\d+\.\s(?P<source_name>.+)\s\[.*\]')266267def login(self, dry_run=False):268try:269source_to_url_dict = self._get_source_to_url_dict()270except OSError as ex:271if ex.errno == errno.ENOENT:272raise ValueError(273self._TOOL_NOT_FOUND_MESSAGE % self._get_tool_name()274)275raise ex276277nuget_index_url = self._NUGET_INDEX_URL_FMT.format(278endpoint=self.repository_endpoint279)280source_name, already_exists = self._get_source_name(281nuget_index_url, source_to_url_dict282)283284if already_exists:285command = self._get_configure_command(286'update', nuget_index_url, source_name287)288source_configured_message = self._SOURCE_UPDATED_MESSAGE289else:290command = self._get_configure_command('add', nuget_index_url, source_name)291source_configured_message = self._SOURCE_ADDED_MESSAGE292293if dry_run:294dry_run_command = ' '.join([str(cd) for cd in command])295uni_print(dry_run_command)296uni_print('\n')297return298299try:300self.subprocess_utils.run(301command,302capture_output=True,303check=True304)305except subprocess.CalledProcessError as e:306uni_print('Failed to update the NuGet.Config\n')307raise CommandFailedError(e, self.auth_token)308309uni_print(source_configured_message % source_name)310self._write_success_message('nuget')311312def _get_source_to_url_dict(self):313"""314Parses the output of the nuget sources list command.315316A dict is created where the keys are the source names317and the values the corresponding URL.318319The output of the command can contain header and footer information320around the 'Registered Sources' section, which is ignored.321322Example output that is parsed:323324Registered Sources:3253261. Source Name 1 [Enabled]327https://source1.com/index.json3282. Source Name 2 [Disabled]329https://source2.com/index.json330100. Source Name 100 [Activé]331https://source100.com/index.json332"""333response = self.subprocess_utils.check_output(334self._get_list_command(),335stderr=self.subprocess_utils.PIPE336)337338lines = response.decode(os.device_encoding(1) or "utf-8").splitlines()339lines = [line for line in lines if line.strip() != '']340341source_to_url_dict = {}342for i in range(len(lines)):343result = self._SOURCE_REGEX.match(lines[i].strip())344if result:345source_to_url_dict[result["source_name"].strip()] = \346lines[i + 1].strip()347348return source_to_url_dict349350def _get_source_name(self, codeartifact_url, source_dict):351default_name = '{}/{}'.format(self.domain, self.repository)352353# Check if the CodeArtifact URL is already present in the354# NuGet.Config file. If the URL already exists, use the source name355# already assigned to the CodeArtifact URL.356for source_name, source_url in source_dict.items():357if source_url == codeartifact_url:358return source_name, True359360# If the CodeArtifact URL is not present in the NuGet.Config file,361# check if the default source name already exists so we can know362# whether we need to add a new entry or update the existing entry.363for source_name in source_dict.keys():364if source_name == default_name:365return source_name, True366367# If neither the source url nor the source name already exist in the368# NuGet.Config file, use the default source name.369return default_name, False370371def _get_tool_name(self):372raise NotImplementedError('_get_tool_name()')373374def _get_list_command(self):375raise NotImplementedError('_get_list_command()')376377def _get_configure_command(self, operation, nuget_index_url, source_name):378raise NotImplementedError('_get_configure_command()')379380381class NuGetLogin(NuGetBaseLogin):382383def _get_tool_name(self):384return 'nuget'385386def _get_list_command(self):387return ['nuget', 'sources', 'list', '-format', 'detailed']388389def _get_configure_command(self, operation, nuget_index_url, source_name):390return [391'nuget', 'sources', operation,392'-name', source_name,393'-source', nuget_index_url,394'-username', 'aws',395'-password', self.auth_token396]397398399class DotNetLogin(NuGetBaseLogin):400401def _get_tool_name(self):402return 'dotnet'403404def _get_list_command(self):405return ['dotnet', 'nuget', 'list', 'source', '--format', 'detailed']406407def _get_configure_command(self, operation, nuget_index_url, source_name):408command = ['dotnet', 'nuget', operation, 'source']409410if operation == 'add':411command.append(nuget_index_url)412command += ['--name', source_name]413else:414command.append(source_name)415command += ['--source', nuget_index_url]416417command += [418'--username', 'aws',419'--password', self.auth_token420]421422# Encryption is not supported on non-Windows platforms.423if not is_windows:424command.append('--store-password-in-clear-text')425426return command427428429class NpmLogin(BaseLogin):430431# On Windows we need to be explicit about the .cmd file to execute432# (unless we execute through the shell, i.e. with shell=True).433NPM_CMD = 'npm.cmd' if platform.system().lower() == 'windows' else 'npm'434435def login(self, dry_run=False):436scope = self.get_scope(437self.namespace438)439commands = self.get_commands(440self.repository_endpoint, self.auth_token, scope=scope441)442self._run_commands('npm', commands, dry_run)443444def _run_command(self, tool, command):445ignore_errors = any('always-auth' in arg for arg in command)446super()._run_command(tool, command, ignore_errors=ignore_errors)447448@classmethod449def get_scope(cls, namespace):450# Regex for valid scope name451valid_scope_name = re.compile('^(@[a-z0-9-~][a-z0-9-._~]*)')452453if namespace is None:454return namespace455456# Add @ prefix to scope if it doesn't exist457if namespace.startswith('@'):458scope = namespace459else:460scope = '@{}'.format(namespace)461462if not valid_scope_name.match(scope):463raise ValueError(464'Invalid scope name, scope must contain URL-safe '465'characters, no leading dots or underscores'466)467468return scope469470@classmethod471def get_commands(cls, endpoint, auth_token, **kwargs):472commands = []473scope = kwargs.get('scope')474475# prepend scope if it exists476registry = '{}:registry'.format(scope) if scope else 'registry'477478# set up the codeartifact repository as the npm registry.479commands.append(480[cls.NPM_CMD, 'config', 'set', registry, endpoint]481)482483repo_uri = urlparse.urlsplit(endpoint)484485# configure npm to always require auth for the repository.486always_auth_config = '//{}{}:always-auth'.format(487repo_uri.netloc, repo_uri.path488)489commands.append(490[cls.NPM_CMD, 'config', 'set', always_auth_config, 'true']491)492493# set auth info for the repository.494auth_token_config = '//{}{}:_authToken'.format(495repo_uri.netloc, repo_uri.path496)497commands.append(498[cls.NPM_CMD, 'config', 'set', auth_token_config, auth_token]499)500501return commands502503504class PipLogin(BaseLogin):505506PIP_INDEX_URL_FMT = '{scheme}://aws:{auth_token}@{netloc}{path}simple/'507508def login(self, dry_run=False):509commands = self.get_commands(510self.repository_endpoint, self.auth_token511)512self._run_commands('pip', commands, dry_run)513514@classmethod515def get_commands(cls, endpoint, auth_token, **kwargs):516repo_uri = urlparse.urlsplit(endpoint)517pip_index_url = cls.PIP_INDEX_URL_FMT.format(518scheme=repo_uri.scheme,519auth_token=auth_token,520netloc=repo_uri.netloc,521path=repo_uri.path522)523524return [['pip', 'config', 'set', 'global.index-url', pip_index_url]]525526527class TwineLogin(BaseLogin):528529DEFAULT_PYPI_RC_FMT = u'''\530[distutils]531index-servers=532pypi533codeartifact534535[codeartifact]536repository: {repository_endpoint}537username: aws538password: {auth_token}'''539540def __init__(541self,542auth_token,543expiration,544repository_endpoint,545domain,546repository,547subprocess_utils,548pypi_rc_path=None549):550if pypi_rc_path is None:551pypi_rc_path = self.get_pypi_rc_path()552self.pypi_rc_path = pypi_rc_path553super(TwineLogin, self).__init__(554auth_token, expiration, repository_endpoint,555domain, repository, subprocess_utils)556557@classmethod558def get_commands(cls, endpoint, auth_token, **kwargs):559# TODO(ujjwalpa@): We don't really have a command to execute for Twine560# as we directly write to the pypirc file (or to stdout for dryrun)561# with python itself instead. Nevertheless, we're using this method for562# testing so we'll keep the interface for now but return a string with563# the expected pypirc content instead of a list of commands to564# execute. This definitely reeks of code smell and there is probably565# room for rethinking and refactoring the interfaces of these adapter566# helper classes in the future.567568assert 'pypi_rc_path' in kwargs, 'pypi_rc_path must be provided.'569pypi_rc_path = kwargs['pypi_rc_path']570571default_pypi_rc = cls.DEFAULT_PYPI_RC_FMT.format(572repository_endpoint=endpoint,573auth_token=auth_token574)575576pypi_rc = RawConfigParser()577if os.path.exists(pypi_rc_path):578try:579pypi_rc.read(pypi_rc_path)580index_servers = pypi_rc.get('distutils', 'index-servers')581servers = [582server.strip()583for server in index_servers.split('\n')584if server.strip() != ''585]586587if 'codeartifact' not in servers:588servers.append('codeartifact')589pypi_rc.set(590'distutils', 'index-servers', '\n' + '\n'.join(servers)591)592593if 'codeartifact' not in pypi_rc.sections():594pypi_rc.add_section('codeartifact')595596pypi_rc.set('codeartifact', 'repository', endpoint)597pypi_rc.set('codeartifact', 'username', 'aws')598pypi_rc.set('codeartifact', 'password', auth_token)599except Exception as e: # invalid .pypirc file600sys.stdout.write('%s is in an invalid state.' % pypi_rc_path)601sys.stdout.write(os.linesep)602raise e603else:604pypi_rc.read_string(default_pypi_rc)605606pypi_rc_stream = StringIO()607pypi_rc.write(pypi_rc_stream)608pypi_rc_str = pypi_rc_stream.getvalue()609pypi_rc_stream.close()610611return pypi_rc_str612613def login(self, dry_run=False):614# No command to execute for Twine, we get the expected pypirc content615# instead.616pypi_rc_str = self.get_commands(617self.repository_endpoint,618self.auth_token,619pypi_rc_path=self.pypi_rc_path620)621622if dry_run:623sys.stdout.write('Dryrun mode is enabled, not writing to pypirc.')624sys.stdout.write(os.linesep)625sys.stdout.write(626'%s would have been set to the following:' % self.pypi_rc_path627)628sys.stdout.write(os.linesep)629sys.stdout.write(os.linesep)630sys.stdout.write(pypi_rc_str)631sys.stdout.write(os.linesep)632else:633with open(self.pypi_rc_path, 'w+') as fp:634fp.write(pypi_rc_str)635636self._write_success_message('twine')637638@classmethod639def get_pypi_rc_path(cls):640return os.path.join(os.path.expanduser("~"), ".pypirc")641642643class CodeArtifactLogin(BasicCommand):644'''Log in to the idiomatic tool for the requested package format.'''645646TOOL_MAP = {647'swift': {648'package_format': 'swift',649'login_cls': SwiftLogin,650'namespace_support': True,651},652'nuget': {653'package_format': 'nuget',654'login_cls': NuGetLogin,655'namespace_support': False,656},657'dotnet': {658'package_format': 'nuget',659'login_cls': DotNetLogin,660'namespace_support': False,661},662'npm': {663'package_format': 'npm',664'login_cls': NpmLogin,665'namespace_support': True,666},667'pip': {668'package_format': 'pypi',669'login_cls': PipLogin,670'namespace_support': False,671},672'twine': {673'package_format': 'pypi',674'login_cls': TwineLogin,675'namespace_support': False,676}677}678679NAME = 'login'680681DESCRIPTION = (682'Sets up the idiomatic tool for your package format to use your '683'CodeArtifact repository. Your login information is valid for up '684'to 12 hours after which you must login again.'685)686687ARG_TABLE = [688{689'name': 'tool',690'help_text': 'The tool you want to connect with your repository',691'choices': list(TOOL_MAP.keys()),692'required': True,693},694{695'name': 'domain',696'help_text': 'Your CodeArtifact domain name',697'required': True,698},699{700'name': 'domain-owner',701'help_text': 'The AWS account ID that owns your CodeArtifact '702'domain',703'required': False,704},705{706'name': 'namespace',707'help_text': 'Associates a namespace with your repository tool',708'required': False,709},710{711'name': 'duration-seconds',712'cli_type_name': 'integer',713'help_text': 'The time, in seconds, that the login information '714'is valid',715'required': False,716},717{718'name': 'repository',719'help_text': 'Your CodeArtifact repository name',720'required': True,721},722{723'name': 'endpoint-type',724'help_text': 'The type of endpoint you want the tool to interact with',725'required': False726},727{728'name': 'dry-run',729'action': 'store_true',730'help_text': 'Only print the commands that would be executed '731'to connect your tool with your repository without '732'making any changes to your configuration. Note that '733'this prints the unredacted auth token as part of the output',734'required': False,735'default': False736},737]738739def _get_namespace(self, tool, parsed_args):740namespace_compatible = self.TOOL_MAP[tool]['namespace_support']741742if not namespace_compatible and parsed_args.namespace:743raise ValueError(744'Argument --namespace is not supported for {}'.format(tool)745)746else:747return parsed_args.namespace748749def _get_repository_endpoint(750self, codeartifact_client, parsed_args, package_format751):752kwargs = {753'domain': parsed_args.domain,754'repository': parsed_args.repository,755'format': package_format756}757if parsed_args.endpoint_type:758kwargs['endpointType'] = parsed_args.endpoint_type759if parsed_args.domain_owner:760kwargs['domainOwner'] = parsed_args.domain_owner761762get_repository_endpoint_response = \763codeartifact_client.get_repository_endpoint(**kwargs)764765return get_repository_endpoint_response['repositoryEndpoint']766767def _get_authorization_token(self, codeartifact_client, parsed_args):768kwargs = {769'domain': parsed_args.domain770}771if parsed_args.domain_owner:772kwargs['domainOwner'] = parsed_args.domain_owner773774if parsed_args.duration_seconds:775kwargs['durationSeconds'] = parsed_args.duration_seconds776777get_authorization_token_response = \778codeartifact_client.get_authorization_token(**kwargs)779780return get_authorization_token_response781782def _run_main(self, parsed_args, parsed_globals):783tool = parsed_args.tool.lower()784785package_format = self.TOOL_MAP[tool]['package_format']786787codeartifact_client = cli_utils.create_client_from_parsed_globals(788self._session, 'codeartifact', parsed_globals789)790791auth_token_res = self._get_authorization_token(792codeartifact_client, parsed_args793)794795repository_endpoint = self._get_repository_endpoint(796codeartifact_client, parsed_args, package_format797)798799domain = parsed_args.domain800repository = parsed_args.repository801namespace = self._get_namespace(tool, parsed_args)802803auth_token = auth_token_res['authorizationToken']804expiration = parse_timestamp(auth_token_res['expiration'])805login = self.TOOL_MAP[tool]['login_cls'](806auth_token, expiration, repository_endpoint,807domain, repository, subprocess, namespace808)809810login.login(parsed_args.dry_run)811812return 0813814815