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