azure-devops/azext_devops/dev/pipelines/pipeline_create.py (390 lines of code) (raw):

# -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- import tempfile import os from knack.log import get_logger from knack.util import CLIError from knack.prompting import prompt from azext_devops.dev.common.services import (get_new_pipeline_client, get_new_cix_client, get_git_client, resolve_instance_and_project, resolve_instance_project_and_repo) from azext_devops.dev.common.uri import uri_parse from azext_devops.dev.common.utils import open_file, delete_dir from azext_devops.dev.common.git import get_remote_url, get_current_branch_name from azext_devops.dev.common.arguments import should_detect from azext_devops.dev.common.prompting import (prompt_user_friendly_choice_list, verify_is_a_tty_or_raise_error, prompt_not_empty) from azext_devops.dev.pipelines.pipeline_create_helpers.github_api_helper import ( push_files_github, get_github_repos_api_url, Files) from azext_devops.dev.pipelines.pipeline_create_helpers.pipelines_resource_provider import ( get_azure_rm_service_connection, get_azure_rm_service_connection_id, get_github_service_endpoint, get_kubernetes_environment_resource, get_container_registry_service_connection, get_webapp_from_list_selection) from azext_devops.dev.pipelines.pipeline_create_helpers.azure_repos_helper import push_files_to_azure_repo from azext_devops.devops_sdk.v5_1.build.models import Build, BuildDefinition, BuildRepository, AgentPoolQueue from .build_definition import fix_path_for_api logger = get_logger(__name__) # pylint: disable=too-few-public-methods class YmlOptions: def __init__(self, name, id, content, description='Custom yaml', params=None, path=None, assets=None): # pylint: disable=redefined-builtin self.name = name self.id = id self.description = description self.content = content self.path = path self.params = params self.assets = assets _GITHUB_REPO_TYPE = 'github' _AZURE_GIT_REPO_TYPE = 'TfsGit' # pylint: disable=too-many-statements def pipeline_create(name, description=None, repository=None, branch=None, yml_path=None, repository_type=None, service_connection=None, organization=None, project=None, detect=None, queue_id=None, skip_first_run=None, folder_path=None): """Create a new Azure Pipeline (YAML based) :param name: Name of the new pipeline :type name: str :param description: Description for the new pipeline :type description: str :param repository: Repository for which the pipeline needs to be configured. Can be clone url of the git repository or name of the repository for a Azure Repos or Owner/RepoName in case of GitHub repository. If omitted it will be auto-detected from the remote url of local git repository. If name is mentioned instead of url, --repository-type argument is also required. :type repository: str :param branch: Branch name for which the pipeline will be configured. If omitted, it will be auto-detected from local repository :type branch: str :param yml_path: Path of the pipelines yaml file in the repo (if yaml is already present in the repo). :type yml_path: str :param repository_type: Type of repository. If omitted, it will be auto-detected from remote url of local repository. 'tfsgit' for Azure Repos, 'github' for GitHub repository. :type repository_type: str :param service_connection: Id of the Service connection created for the repository for GitHub repository. Use command az devops service-endpoint -h for creating/listing service_connections. Not required for Azure Repos. :type service_connection: str :param queue_id: Id of the queue in the available agent pools. Will be auto detected if not specified. :type queue_id: str :param skip_first_run: Specify this flag to prevent the first run being triggered by the command. Command will return a pipeline if run is skipped else it will output a pipeline run. :type skip_first_run: bool :param folder_path: Path of the folder where the pipeline needs to be created. Default is root folder. e.g. "user1/test_pipelines" :type folder_path: str """ repository_name = None if repository: organization, project = resolve_instance_and_project( detect=detect, organization=organization, project=project) else: organization, project, repository_name = resolve_instance_project_and_repo( detect=detect, organization=organization, project=project) # resolve repository if local repo for azure repo if repository_name: repository = repository_name repository_type = _AZURE_GIT_REPO_TYPE # resolve repository from local repo for github repo if not repository: repository = _get_repository_url_from_local_repo(detect=detect) if not repository: raise CLIError('The following arguments are required: --repository.') if not repository_type: repository_type = try_get_repository_type(repository) if not repository_type: raise CLIError('The following arguments are required: --repository-type. ' 'Check command help for valid values.') if not branch and should_detect(detect): branch = get_current_branch_name() if not branch: raise CLIError('The following arguments are required: --branch.') # repository, repository-type, branch should be set by now if not repository_name and is_valid_url(repository): repository_name = _get_repo_name_from_repo_url(repository) else: repository_name = repository # Validate name availability so user does not face name conflicts after going through the whole process if not validate_name_is_available(name, folder_path, organization, project): raise CLIError('Pipeline with name {name} already exists.'.format(name=name)) # Parse repository information according to repository type repo_id = None api_url = None repository_url = None if repository_type.lower() == _GITHUB_REPO_TYPE: repo_id = repository_name repository_url = 'https://github.com/' + repository_name api_url = get_github_repos_api_url(repository_name) if repository_type.lower() == _AZURE_GIT_REPO_TYPE.lower(): repo_id = _get_repository_id_from_name(organization, project, repository_name) if not service_connection and repository_type.lower() != _AZURE_GIT_REPO_TYPE.lower(): service_connection = get_github_service_endpoint(organization, project) new_cix_client = get_new_cix_client(organization=organization) # No yml path => find or recommend yml scenario queue_branch = branch if not yml_path: yml_path, queue_branch = _create_and_get_yml_path(new_cix_client, repository_type, repo_id, repository_name, branch, service_connection, project, organization) if not queue_id: queue_id = _get_agent_queue_by_heuristic(organization=organization, project=project) if queue_id is None: logger.warning('Cannot find a hosted pool queue in the project. Provide a --queue-id in command params.') # Create build definition definition = _create_pipeline_build_object(name, description, repo_id, repository_name, repository_url, api_url, branch, service_connection, repository_type, yml_path, queue_id, folder_path) client = get_new_pipeline_client(organization) created_definition = client.create_definition(definition=definition, project=project) logger.warning('Successfully created a pipeline with Name: %s, Id: %s.', created_definition.name, created_definition.id) if skip_first_run: return created_definition return client.queue_build(build=Build(definition=created_definition, source_branch=queue_branch), project=project) def pipeline_update(id, description=None, new_name=None, # pylint: disable=redefined-builtin branch=None, yml_path=None, queue_id=None, organization=None, project=None, detect=None, new_folder_path=None): """Update a pipeline :param id: Id of the pipeline to update. :type id: str :param new_name: New updated name of the pipeline. :type new_name: str :param description: New description for the pipeline. :type description: str :param branch: Branch name for which the pipeline will be configured. :type branch: str :param yml_path: Path of the pipelines yaml file in the repo. :type yml_path: str :param queue_id: Queue id of the agent pool where the pipeline needs to run. :type queue_id: int :param new_folder_path: New full path of the folder to move the pipeline to. e.g. "user1/production_pipelines" :type new_folder_path: str """ # pylint: disable=too-many-branches organization, project = resolve_instance_and_project( detect=detect, organization=organization, project=project) pipeline_client = get_new_pipeline_client(organization=organization) definition = pipeline_client.get_definition(definition_id=id, project=project) if new_name: definition.name = new_name if description: definition.description = description if branch: definition.repository.default_branch = branch if queue_id: definition.queue = AgentPoolQueue() definition.queue.id = queue_id if yml_path: definition.process = _create_process_object(yml_path) if new_folder_path: definition.path = new_folder_path return pipeline_client.update_definition(project=project, definition_id=id, definition=definition) def validate_name_is_available(name, path, organization, project): client = get_new_pipeline_client(organization=organization) path = fix_path_for_api(path) definition_references = client.get_definitions(project=project, name=name, path=path) if len(definition_references.value) == 0: return True return False def _get_repository_url_from_local_repo(detect): if should_detect(detect): return get_remote_url(is_github_url_candidate) return None def is_github_url_candidate(url): if url is None: return False components = uri_parse(url.lower()) if components.netloc == 'github.com': return True return False def is_valid_url(url): if ('github.com' in url or 'visualstudio.com' in url or 'dev.azure.com' in url): return True return False def _get_repo_name_from_repo_url(repository_url): """ Should be called with a valid github or azure repo url returns owner/reponame for github repos, repo_name for azure repo type """ repo_type = try_get_repository_type(repository_url) if repo_type == _GITHUB_REPO_TYPE: parsed_url = uri_parse(repository_url) logger.debug('Parsing GitHub url: %s', parsed_url) if parsed_url.scheme == 'https' and parsed_url.netloc == 'github.com': logger.debug('Parsing path in the url to find repo id.') stripped_path = parsed_url.path.strip('/') if stripped_path.endswith('.git'): stripped_path = stripped_path[:-4] return stripped_path if repo_type == _AZURE_GIT_REPO_TYPE: parsed_list = repository_url.split('/') index = 0 for item in parsed_list: if ('visualstudio.com' in item or 'dev.azure.com' in item) and len(parsed_list) > index + 4: return parsed_list[index + 4] index = index + 1 raise CLIError('Could not parse repository url.') def _create_repo_properties_object(service_endpoint, branch, api_url): return { "connectedServiceId": service_endpoint, "defaultBranch": branch, "apiUrl": api_url } def _create_process_object(yaml_path): return { "yamlFilename": yaml_path, "type": 2 } def try_get_repository_type(url): if 'https://github.com' in url: return _GITHUB_REPO_TYPE if 'dev.azure.com' in url or '.visualstudio.com' in url: return _AZURE_GIT_REPO_TYPE return None def _create_and_get_yml_path(cix_client, repository_type, repo_id, repo_name, branch, # pylint: disable=too-many-locals, too-many-statements service_endpoint, project, organization): logger.debug('No yaml file was given. Trying to find the yaml file in the repo.') queue_branch = branch default_yml_exists = False yml_names = [] yml_options = [] configurations = cix_client.get_configurations( project=project, repository_type=repository_type, repository_id=repo_id, branch=branch, service_connection_id=service_endpoint) for configuration in configurations: if configuration.path.strip('/') == 'azure-pipelines.yml': default_yml_exists = True logger.debug('The repo has a yaml pipeline definition. Path: %s', configuration.path) custom_name = 'Existing yaml (path={})'.format(configuration.path) yml_names.append(custom_name) yml_options.append(YmlOptions(name=custom_name, content=configuration.content, id='customid', path=configuration.path)) recommendations = cix_client.get_template_recommendations( project=project, repository_type=repository_type, repository_id=repo_id, branch=branch, service_connection_id=service_endpoint) logger.debug('List of recommended templates..') # sort recommendations from operator import attrgetter recommendations = sorted(recommendations, key=attrgetter('recommended_weight'), reverse=True) for recommendation in recommendations: yml_names.append(recommendation.name) yml_options.append(YmlOptions(name=recommendation.name, content=recommendation.content, id=recommendation.id, description=recommendation.description, params=recommendation.parameters, assets=recommendation.assets)) temp_filename = None files = [] yml_selection_index = 0 proceed_selection = 1 while proceed_selection == 1: proceed_selection = 0 # Clear files since user can change the template now del files[:] yml_selection_index = prompt_user_friendly_choice_list("Which template do you want to use for this pipeline?", yml_names) if yml_options[yml_selection_index].params: yml_options[yml_selection_index].content, yml_options[yml_selection_index].assets = _handle_yml_props( params_required=yml_options[yml_selection_index].params, template_id=yml_options[yml_selection_index].id, cix_client=cix_client, repo_name=repo_name, organization=organization, project=project) temp_dir = tempfile.mkdtemp(prefix='AzurePipelines_') temp_filename = os.path.join(temp_dir, 'azure-pipelines.yml') f = open(temp_filename, mode='w') f.write(yml_options[yml_selection_index].content) f.close() assets = yml_options[yml_selection_index].assets if assets: for asset in assets: files.append(Files(asset.destination_path, asset.content)) view_choice = prompt_user_friendly_choice_list( 'Do you want to view/edit the template yaml before proceeding?', ['Continue with generated yaml', 'View or edit the yaml']) if view_choice == 1: open_file(temp_filename) proceed_selection = prompt_user_friendly_choice_list( 'Do you want to proceed creating a pipeline?', ['Proceed with this yaml', 'Choose another template']) # Read updated data from the file f = open(temp_filename, mode='r') content = f.read() f.close() delete_dir(temp_dir) checkin_path = 'azure-pipelines.yml' if default_yml_exists and not yml_options[yml_selection_index].path: # We need yml path from user logger.warning('A yaml file azure-pipelines.yml already exists in the repository root.') checkin_path = prompt_not_empty( msg='Enter a yaml file path to checkin the new pipeline yaml in the repository? ', help_string='e.g. /new_azure-pipeline.yml to add in the root folder.') print('') files.append(Files(checkin_path, content)) print('Files to be added to your repository ({numfiles})'.format(numfiles=len(files))) count_file = 1 for file in files: print('{index}) {file}'.format(index=count_file, file=file.path)) count_file = count_file + 1 print('') if default_yml_exists and checkin_path.strip('/') == 'azure-pipelines.yml': print('Edits on the existing yaml can be done in the code repository.') else: queue_branch = push_files_to_repository(organization, project, repo_name, branch, files, repository_type) return checkin_path, queue_branch def push_files_to_repository(organization, project, repo_name, branch, files, repository_type): commit_strategy_choice_list = ['Commit directly to the {branch} branch.'.format(branch=branch), 'Create a new branch for this commit and start a pull request.'] commit_choice = prompt_user_friendly_choice_list("How do you want to commit the files to the repository?", commit_strategy_choice_list) commit_direct_to_branch = commit_choice == 0 if repository_type == _GITHUB_REPO_TYPE: return push_files_github(files, repo_name, branch, commit_direct_to_branch) if repository_type.lower() == _AZURE_GIT_REPO_TYPE.lower(): return push_files_to_azure_repo(files, repo_name, branch, commit_direct_to_branch, organization, project) raise CLIError('File push failed: Repository type not supported.') def _get_pipelines_trigger(repo_type): if repo_type.lower() == _GITHUB_REPO_TYPE: return [{"settingsSourceType": 2, "triggerType": 2}, {"forks": {"enabled": "true", "allowSecrets": "false"}, "settingsSourceType": 2, "triggerType": "pullRequest"}] return [{"settingsSourceType": 2, "triggerType": 2}] def _handle_yml_props(params_required, template_id, cix_client, repo_name, organization, project): logger.warning('The template requires a few inputs. We will help you fill them out') params_to_render = {} for param in params_required: param_name_for_user = param.name # override with more user friendly name if available if param.display_name: param_name_for_user = param.display_name logger.debug('Looking for param %s in props', param.name) prop_found = False if param.default_value: prop_found = True user_input_val = prompt(msg='Enter a value for {param_name} [Press Enter for default: {param_default}]:' .format(param_name=param_name_for_user, param_default=param.default_value)) print('') if user_input_val: params_to_render[param.name] = user_input_val else: params_to_render[param.name] = param.default_value elif _is_intelligent_handling_enabled_for_prop_type(prop_name=param.name, prop_type=param.type): logger.debug('This property is handled intelligently (Name: %s) (Type: %s)', param.name, param.type) fetched_value = fetch_yaml_prop_intelligently(param.name, param.type, organization, project, repo_name) if fetched_value is not None: logger.debug('Auto filling param %s with value %s', param.name, fetched_value) params_to_render[param.name] = fetched_value prop_found = True if not prop_found: input_value = _prompt_for_prop_input(param_name_for_user, param.type) params_to_render[param.name] = input_value prop_found = True rendered_template = cix_client.render_template(template_id=template_id, template_parameters={'tokens': params_to_render}) return rendered_template.content, rendered_template.assets def fetch_yaml_prop_intelligently(prop_name, prop_type, organization, project, repo_name): if prop_type.lower() == 'endpoint:azurerm': return get_azure_rm_service_connection(organization, project) if prop_type.lower() == 'connectedservice:azurerm': return get_azure_rm_service_connection_id(organization, project) if prop_type.lower() == 'environmentresource:kubernetes': return get_kubernetes_environment_resource(organization, project, repo_name) if prop_type.lower() == 'endpoint:containerregistry': return get_container_registry_service_connection(organization, project) if prop_name.lower() == 'webappname': return get_webapp_from_list_selection() return None def _is_intelligent_handling_enabled_for_prop_type(prop_name, prop_type): SMART_HANDLING_FOR_PROP_TYPES = ['connectedservice:azurerm', 'endpoint:azurerm', 'environmentresource:kubernetes', 'endpoint:containerregistry'] SMART_HANDLING_FOR_PROP_NAMES = ['webappname'] if prop_type.lower() in SMART_HANDLING_FOR_PROP_TYPES: return True if prop_name.lower() in SMART_HANDLING_FOR_PROP_NAMES: return True return False def _prompt_for_prop_input(prop_name, prop_type): verify_is_a_tty_or_raise_error('The template requires a few inputs. These cannot be provided as in command ' 'arguments. It can only be input interatively.') val = prompt(msg='Please enter a value for {prop_name}: '.format(prop_name=prop_name), help_string='Value of type {prop_type} is required.'.format(prop_type=prop_type)) print('') return val def _create_pipeline_build_object(name, description, repo_id, repo_name, repository_url, api_url, branch, service_endpoint, repository_type, yml_path, queue_id, path): definition = BuildDefinition() definition.name = name if description: definition.description = description # Set build repo definition.repository = BuildRepository() if repo_id: definition.repository.id = repo_id if repo_name: definition.repository.name = repo_name if repository_url: definition.repository.url = repository_url if branch: definition.repository.default_branch = branch if service_endpoint: definition.repository.properties = _create_repo_properties_object(service_endpoint, branch, api_url) if path: definition.path = path # Hack to avoid the case sensitive GitHub type for service hooks. if repository_type.lower() == _GITHUB_REPO_TYPE: definition.repository.type = 'GitHub' else: definition.repository.type = repository_type # Set build process definition.process = _create_process_object(yml_path) # set agent queue definition.queue = AgentPoolQueue() definition.triggers = _get_pipelines_trigger(repository_type) if queue_id: definition.queue.id = queue_id return definition def _get_repository_id_from_name(organization, project, repository): git_client = get_git_client(organization) repository = git_client.get_repository(project=project, repository_id=repository) return repository.id def _get_agent_queue_by_heuristic(organization, project): """ Tries to detect a queue in the agent pool in a project Returns id of Hosted Ubuntu 16.04, first hosted pool queue, first queue in that order None if no queues are returned """ from azext_devops.dev.common.services import get_new_task_agent_client choosen_queue = None agent_client = get_new_task_agent_client(organization=organization) queues = agent_client.get_agent_queues(project=project) if queues: choosen_queue = queues[0] found_first_hosted_pool_queue = False for queue in queues: if queue.name == 'Hosted Ubuntu 1604': choosen_queue = queue break if not found_first_hosted_pool_queue and queue.pool.is_hosted: choosen_queue = queue found_first_hosted_pool_queue = True logger.debug('Auto detecting agent pool. Queue: %s, Pool: %s', choosen_queue.name, choosen_queue.pool.name) return choosen_queue.id return None