diff --git a/.vscode/cspell.global.yaml b/.vscode/cspell.global.yaml index 6b082ae791e..fe63463ea95 100644 --- a/.vscode/cspell.global.yaml +++ b/.vscode/cspell.global.yaml @@ -95,6 +95,16 @@ ignoreWords: - aztfmod - menuid - PLACEHOLDERIACTOOLS + - Azdo + - azconnection + - azuredevops + - taskagent + - tfsgit + - serviceendpoint + - serviceprincipalid + - serviceprincipalkey + - tenantid + - versioncontrol useGitignore: true dictionaryDefinitions: - name: gitHubUserAliases diff --git a/cli/azd/cmd/pipeline.go b/cli/azd/cmd/pipeline.go index a70f45b3fea..d1998035edf 100644 --- a/cli/azd/cmd/pipeline.go +++ b/cli/azd/cmd/pipeline.go @@ -7,10 +7,12 @@ import ( "context" "fmt" "log" + "strings" "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/pkg/commands" "github.com/azure/azure-dev/cli/azd/pkg/commands/pipeline" + "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/spf13/cobra" @@ -68,6 +70,7 @@ func (p *pipelineConfigAction) SetupFlags( local.StringVar(&p.manager.PipelineServicePrincipalName, "principal-name", "", "The name of the service principal to use to grant access to Azure resources as part of the pipeline.") local.StringVar(&p.manager.PipelineRemoteName, "remote-name", "origin", "The name of the git remote to configure the pipeline to run on.") local.StringVar(&p.manager.PipelineRoleName, "principal-role", "Contributor", "The role to assign to the service principal.") + local.StringVar(&p.manager.PipelineProvider, "provider", "github", "The pipeline provider to use (GitHub and Azdo supported).") } // Run implements action interface @@ -94,13 +97,36 @@ func (p *pipelineConfigAction) Run( return fmt.Errorf("loading environment: %w", err) } - // Detect the SCM and CI providers based on the project directory - p.manager.ScmProvider, - p.manager.CiProvider, - err = pipeline.DetectProviders(ctx, console) - - if err != nil { - return err + overrideProvider := strings.ToLower(p.manager.PipelineProvider) + if overrideProvider == "" { + // Detect the SCM and CI providers based on the project directory + p.manager.ScmProvider, + p.manager.CiProvider, + err = pipeline.DetectProviders(ctx, console, env) + if err != nil { + return err + } + } else if overrideProvider == "github" { + p.manager.ScmProvider = &pipeline.GitHubScmProvider{} + p.manager.CiProvider = &pipeline.GitHubCiProvider{} + err := savePipelineProviderToEnv(ctx, "github", env) + if err != nil { + return nil + } + } else if overrideProvider == "azdo" { + p.manager.ScmProvider = &pipeline.AzdoHubScmProvider{ + Env: env, + AzdContext: azdCtx} + p.manager.CiProvider = &pipeline.AzdoCiProvider{ + Env: env, + AzdContext: azdCtx, + } + err := savePipelineProviderToEnv(ctx, "azdo", env) + if err != nil { + return err + } + } else { + return fmt.Errorf("unknown pipeline provider: %s. Supported providers: [github, azdo]", overrideProvider) } // set context for manager @@ -109,3 +135,13 @@ func (p *pipelineConfigAction) Run( return p.manager.Configure(ctx) } + +func savePipelineProviderToEnv(ctx context.Context, provider string, env *environment.Environment) error { + env.Values["PIPELINE_PROVIDER"] = provider + err := env.Save() + if err != nil { + return err + } + + return nil +} diff --git a/cli/azd/pkg/azdo/azdo.go b/cli/azd/pkg/azdo/azdo.go new file mode 100644 index 00000000000..4ccd9dfdbd9 --- /dev/null +++ b/cli/azd/pkg/azdo/azdo.go @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdo + +import ( + "context" + "fmt" + + "github.com/microsoft/azure-devops-go-api/azuredevops" +) + +var ( + AzDoHostName = "dev.azure.com" // hostname of the AzDo PaaS service. + AzDoPatName = "AZURE_DEVOPS_EXT_PAT" // environment variable that holds the Azure DevOps PAT + AzDoEnvironmentOrgName = "AZURE_DEVOPS_ORG_NAME" // environment variable that holds the Azure DevOps Organization Name + AzDoEnvironmentProjectIdName = "AZURE_DEVOPS_PROJECT_ID" // Environment Configuration name used to store the project Id + AzDoEnvironmentProjectName = "AZURE_DEVOPS_PROJECT_NAME" // Environment Configuration name used to store the project name + AzDoEnvironmentRepoIdName = "AZURE_DEVOPS_REPOSITORY_ID" // Environment Configuration name used to store repo ID + AzDoEnvironmentRepoName = "AZURE_DEVOPS_REPOSITORY_NAME" // Environment Configuration name used to store the Repo Name + AzDoEnvironmentRepoWebUrl = "AZURE_DEVOPS_REPOSITORY_WEB_URL" // web url for the configured repo. This is displayed on a the command line after a successful invocation of azd pipeline config + AzdoConfigSuccessMessage = "\nSuccessfully configured Azure DevOps Repository %s\n" // success message after azd pipeline config is successful + AzurePipelineName = "Azure Dev Deploy" // name of the azure pipeline that will be created + AzurePipelineYamlPath = ".azdo/pipelines/azure-dev.yml" // path to the azure pipeline yaml + CloudEnvironment = "AzureCloud" // target Azure Cloud + DefaultBranch = "master" // default branch for pipeline and branch policy + AzDoProjectDescription = "Azure Developer CLI Project" // azure devops project description + ServiceConnectionName = "azconnection" // name of the service connection that will be used in the AzDo project. This will store the Azure service principal +) + +type AzureServicePrincipalCredentials struct { + TenantId string `json:"tenantId"` + ClientId string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + SubscriptionId string `json:"subscriptionId"` +} + +// helper method to return an Azure DevOps connection used the AzDo go sdk +func GetAzdoConnection(ctx context.Context, organization string, personalAccessToken string) (*azuredevops.Connection, error) { + if organization == "" { + return nil, fmt.Errorf("organization name is required") + } + + if personalAccessToken == "" { + return nil, fmt.Errorf("personal access token is required") + } + + organizationUrl := fmt.Sprintf("https://%s/%s", AzDoHostName, organization) + connection := azuredevops.NewPatConnection(organizationUrl, personalAccessToken) + + return connection, nil +} diff --git a/cli/azd/pkg/azdo/azdo_test.go b/cli/azd/pkg/azdo/azdo_test.go new file mode 100644 index 00000000000..a5067a52d3d --- /dev/null +++ b/cli/azd/pkg/azdo/azdo_test.go @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdo + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_getAzdoConnection(t *testing.T) { + ctx := context.Background() + t.Run("empty organization name error", func(t *testing.T) { + _, err := GetAzdoConnection(ctx, "", "") + assert.EqualError(t, err, "organization name is required") + }) + + t.Run("empty pat error", func(t *testing.T) { + _, err := GetAzdoConnection(ctx, "fake_org", "") + assert.EqualError(t, err, "personal access token is required") + }) + t.Run("returns a connection", func(t *testing.T) { + connection, err := GetAzdoConnection(ctx, "fake_org", "fake_pat") + assert.Nil(t, err) + assert.NotNil(t, connection) + }) +} diff --git a/cli/azd/pkg/azdo/build_policy.go b/cli/azd/pkg/azdo/build_policy.go new file mode 100644 index 00000000000..62c7b77dcab --- /dev/null +++ b/cli/azd/pkg/azdo/build_policy.go @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdo + +import ( + "context" + "fmt" + + "github.com/microsoft/azure-devops-go-api/azuredevops" + "github.com/microsoft/azure-devops-go-api/azuredevops/build" + "github.com/microsoft/azure-devops-go-api/azuredevops/policy" +) + +// returns a build policy type named "Build." Used to created the PR build policy on the default branch +func getBuildType(ctx context.Context, projectId *string, policyClient policy.Client) (*policy.PolicyType, error) { + getPolicyTypesArgs := policy.GetPolicyTypesArgs{ + Project: projectId, + } + policyTypes, err := policyClient.GetPolicyTypes(ctx, getPolicyTypesArgs) + if err != nil { + return nil, err + } + for _, policy := range *policyTypes { + if *policy.DisplayName == "Build" { + return &policy, nil + } + } + return nil, fmt.Errorf("could not find 'Build' policy type in project") +} + +// create the PR build policy to ensure that the pipeline runs on a new pull request +// this also disables direct pushes to the default branch and requires changes to go through a PR. +func CreateBuildPolicy( + ctx context.Context, + connection *azuredevops.Connection, + projectId string, + repoId string, + buildDefinition *build.BuildDefinition) error { + client, err := policy.NewClient(ctx, connection) + if err != nil { + return err + } + + buildPolicyType, err := getBuildType(ctx, &projectId, client) + if err != nil { + return err + } + + policyTypeRef := &policy.PolicyTypeRef{ + Id: buildPolicyType.Id, + } + policyRevision := 1 + policyIsDeleted := false + policyIsBlocking := true + policyIsEnabled := true + + policySettingsScope := make(map[string]interface{}) + policySettingsScope["repositoryId"] = repoId + policySettingsScope["refName"] = fmt.Sprintf("refs/heads/%s", DefaultBranch) + policySettingsScope["matchKind"] = "Exact" + + policySettingsScopes := make([]map[string]interface{}, 1) + policySettingsScopes[0] = policySettingsScope + + policySettings := make(map[string]interface{}) + policySettings["buildDefinitionId"] = buildDefinition.Id + policySettings["displayName"] = "Azure Dev Deploy PR" + policySettings["manualQueueOnly"] = false + policySettings["queueOnSourceUpdateOnly"] = true + policySettings["validDuration"] = 720 + policySettings["scope"] = policySettingsScopes + + policyConfiguration := &policy.PolicyConfiguration{ + Type: policyTypeRef, + Revision: &policyRevision, + IsDeleted: &policyIsDeleted, + IsBlocking: &policyIsBlocking, + IsEnabled: &policyIsEnabled, + Settings: policySettings, + } + + createPolicyConfigurationArgs := policy.CreatePolicyConfigurationArgs{ + Project: &projectId, + Configuration: policyConfiguration, + } + + _, err = client.CreatePolicyConfiguration(ctx, createPolicyConfigurationArgs) + if err != nil { + return err + } + + return nil +} diff --git a/cli/azd/pkg/azdo/build_policy_test.go b/cli/azd/pkg/azdo/build_policy_test.go new file mode 100644 index 00000000000..1408e95498d --- /dev/null +++ b/cli/azd/pkg/azdo/build_policy_test.go @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdo + +import ( + "context" + "testing" + + "github.com/microsoft/azure-devops-go-api/azuredevops/policy" + "github.com/stretchr/testify/require" +) + +func Test_getBuildType(t *testing.T) { + ctx := context.Background() + + t.Run("getPolicyTypesArgs contains projectId", func(t *testing.T) { + //arrange + mockClient := MockPolicyClient{} + projectId := "111222" + //act + policyType, err := getBuildType(ctx, &projectId, &mockClient) + //assert + require.NoError(t, err) + require.NotNil(t, policyType) + require.EqualValues(t, *mockClient.getPolicyTypesArgs.Project, projectId) + }) + + t.Run("returns only 'Build' policy", func(t *testing.T) { + //arrange + mockClient := MockPolicyClient{} + projectId := "111222" + //act + policyType, err := getBuildType(ctx, &projectId, &mockClient) + //assert + require.NoError(t, err) + require.NotNil(t, policyType) + require.EqualValues(t, *policyType.DisplayName, "Build") + + }) + +} + +type MockPolicyClient struct { + getPolicyTypesArgs policy.GetPolicyTypesArgs +} + +func (c *MockPolicyClient) CreatePolicyConfiguration( + context.Context, + policy.CreatePolicyConfigurationArgs) (*policy.PolicyConfiguration, error) { + return nil, nil +} + +func (c *MockPolicyClient) DeletePolicyConfiguration( + context.Context, + policy.DeletePolicyConfigurationArgs) error { + return nil +} + +func (c *MockPolicyClient) GetPolicyConfiguration( + context.Context, + policy.GetPolicyConfigurationArgs) (*policy.PolicyConfiguration, error) { + return nil, nil +} + +func (c *MockPolicyClient) GetPolicyConfigurationRevision( + context.Context, + policy.GetPolicyConfigurationRevisionArgs) (*policy.PolicyConfiguration, error) { + return nil, nil +} + +func (c *MockPolicyClient) GetPolicyConfigurationRevisions(context.Context, + policy.GetPolicyConfigurationRevisionsArgs) (*[]policy.PolicyConfiguration, error) { + return nil, nil +} + +func (c *MockPolicyClient) GetPolicyConfigurations(context.Context, + policy.GetPolicyConfigurationsArgs) (*policy.GetPolicyConfigurationsResponseValue, error) { + return nil, nil +} + +func (c *MockPolicyClient) GetPolicyEvaluation( + context.Context, + policy.GetPolicyEvaluationArgs) (*policy.PolicyEvaluationRecord, error) { + return nil, nil +} + +func (c *MockPolicyClient) GetPolicyEvaluations( + context.Context, + policy.GetPolicyEvaluationsArgs) (*[]policy.PolicyEvaluationRecord, error) { + return nil, nil +} + +func (c *MockPolicyClient) GetPolicyType( + context.Context, + policy.GetPolicyTypeArgs) (*policy.PolicyType, error) { + return nil, nil +} + +func (c *MockPolicyClient) GetPolicyTypes(ctx context.Context, args policy.GetPolicyTypesArgs) (*[]policy.PolicyType, error) { + c.getPolicyTypesArgs = args + policyTypes := make([]policy.PolicyType, 2) + buildPolicyType := "Build" + testPolicyType := "Test" + policyTypes[0] = policy.PolicyType{ + DisplayName: &buildPolicyType, + } + policyTypes[1] = policy.PolicyType{ + DisplayName: &testPolicyType, + } + return &policyTypes, nil +} + +func (c *MockPolicyClient) RequeuePolicyEvaluation( + context.Context, + policy.RequeuePolicyEvaluationArgs) (*policy.PolicyEvaluationRecord, error) { + return nil, nil +} + +func (c *MockPolicyClient) UpdatePolicyConfiguration( + context.Context, + policy.UpdatePolicyConfigurationArgs) (*policy.PolicyConfiguration, error) { + return nil, nil +} diff --git a/cli/azd/pkg/azdo/pipeline.go b/cli/azd/pkg/azdo/pipeline.go new file mode 100644 index 00000000000..a63e9157dff --- /dev/null +++ b/cli/azd/pkg/azdo/pipeline.go @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdo + +import ( + "context" + "fmt" + + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/microsoft/azure-devops-go-api/azuredevops" + "github.com/microsoft/azure-devops-go-api/azuredevops/build" + "github.com/microsoft/azure-devops-go-api/azuredevops/taskagent" +) + +// Creates a variable to be associated with a Pipeline +func createBuildDefinitionVariable(value string, isSecret bool, allowOverride bool) build.BuildDefinitionVariable { + return build.BuildDefinitionVariable{ + AllowOverride: &allowOverride, + IsSecret: &isSecret, + Value: &value, + } +} + +// returns the default agent queue. This is used to associate a Pipeline with a default agent pool queue +func getAgentQueue(ctx context.Context, projectId string, connection *azuredevops.Connection) (*taskagent.TaskAgentQueue, error) { + client, err := taskagent.NewClient(ctx, connection) + if err != nil { + return nil, err + } + getAgentQueuesArgs := taskagent.GetAgentQueuesArgs{ + Project: &projectId, + } + queues, err := client.GetAgentQueues(ctx, getAgentQueuesArgs) + if err != nil { + return nil, err + } + for _, queue := range *queues { + if *queue.Name == "Default" { + return &queue, nil + } + } + return nil, fmt.Errorf("could not find a default agent queue in project %s", projectId) +} + +// find pipeline by name +func pipelineExists( + ctx context.Context, + client *build.Client, + projectId *string, + pipelineName *string, +) (bool, error) { + getDefinitionsArgs := build.GetDefinitionsArgs{ + Project: projectId, + Name: pipelineName, + } + + buildDefinitionsResponse, err := (*client).GetDefinitions(ctx, getDefinitionsArgs) + if err != nil { + return false, err + } + buildDefinitions := buildDefinitionsResponse.Value + for _, definition := range buildDefinitions { + if *definition.Name == *pipelineName { + return true, nil + } + } + return false, nil +} + +// create a new Azure DevOps pipeline +func CreatePipeline( + ctx context.Context, + projectId string, + name string, + repoName string, + connection *azuredevops.Connection, + credentials AzureServicePrincipalCredentials, + env environment.Environment, + console input.Console, + provisioningProvider provisioning.Options) (*build.BuildDefinition, error) { + + client, err := build.NewClient(ctx, connection) + if err != nil { + return nil, err + } + + var exists bool = true + var count = 0 + var maxTries = 4 + for exists { + exists, err = pipelineExists(ctx, &client, &projectId, &name) + if err != nil { + return nil, err + } + count = count + 1 + + if exists { + name = fmt.Sprintf("%s - %s (%d)", name, repoName, count) + } else { + continue + } + + if count >= maxTries { + return nil, fmt.Errorf("error creating new pipeline") + } + } + + queue, err := getAgentQueue(ctx, projectId, connection) + if err != nil { + return nil, err + } + + createDefinitionArgs, err := createAzureDevPipelineArgs( + ctx, projectId, name, repoName, credentials, env, queue, provisioningProvider) + if err != nil { + return nil, err + } + + newBuildDefinition, err := client.CreateDefinition(ctx, *createDefinitionArgs) + if err != nil { + return nil, err + } + + return newBuildDefinition, nil +} + +// create Azure Deploy Pipeline parameters +func createAzureDevPipelineArgs( + ctx context.Context, + projectId string, + name string, + repoName string, + credentials AzureServicePrincipalCredentials, + env environment.Environment, + queue *taskagent.TaskAgentQueue, + provisioningProvider provisioning.Options, +) (*build.CreateDefinitionArgs, error) { + + repoType := "tfsgit" + buildDefinitionType := build.DefinitionType("build") + definitionQueueStatus := build.DefinitionQueueStatus("enabled") + defaultBranch := fmt.Sprintf("refs/heads/%s", DefaultBranch) + buildRepository := &build.BuildRepository{ + Type: &repoType, + Name: &repoName, + DefaultBranch: &defaultBranch, + } + + process := make(map[string]interface{}) + process["type"] = 2 + process["yamlFilename"] = AzurePipelineYamlPath + + variables := make(map[string]build.BuildDefinitionVariable) + variables["AZURE_SUBSCRIPTION_ID"] = createBuildDefinitionVariable(credentials.SubscriptionId, false, false) + if provisioningProvider.Provider == provisioning.Terraform { + variables["ARM_TENANT_ID"] = createBuildDefinitionVariable(credentials.TenantId, false, false) + variables["ARM_CLIENT_ID"] = createBuildDefinitionVariable(credentials.ClientId, true, false) + variables["ARM_CLIENT_SECRET"] = createBuildDefinitionVariable(credentials.ClientSecret, true, false) + } + variables["AZURE_LOCATION"] = createBuildDefinitionVariable(env.GetLocation(), false, false) + variables["AZURE_ENV_NAME"] = createBuildDefinitionVariable(env.GetEnvName(), false, false) + variables["AZURE_SERVICE_CONNECTION"] = createBuildDefinitionVariable(ServiceConnectionName, false, false) + + agentPoolQueue := &build.AgentPoolQueue{ + Id: queue.Id, + Name: queue.Name, + } + + trigger := make(map[string]interface{}) + trigger["batchChanges"] = false + trigger["maxConcurrentBuildsPerBranch"] = 1 + trigger["pollingInterval"] = 0 + trigger["isSettingsSourceOptionSupported"] = true + trigger["defaultSettingsSourceType"] = 2 + trigger["settingsSourceType"] = 2 + trigger["defaultSettingsSourceType"] = 2 + trigger["triggerType"] = 2 + + triggers := make([]interface{}, 1) + triggers[0] = trigger + + buildDefinition := &build.BuildDefinition{ + Name: &name, + Type: &buildDefinitionType, + QueueStatus: &definitionQueueStatus, + Repository: buildRepository, + Process: process, + Queue: agentPoolQueue, + Variables: &variables, + Triggers: &triggers, + } + + createDefinitionArgs := &build.CreateDefinitionArgs{ + Project: &projectId, + Definition: buildDefinition, + } + return createDefinitionArgs, nil +} + +// run a pipeline. This is used to invoke the deploy pipeline after a successful push of the code +func QueueBuild( + ctx context.Context, + connection *azuredevops.Connection, + projectId string, + buildDefinition *build.BuildDefinition) error { + client, err := build.NewClient(ctx, connection) + if err != nil { + return err + } + definitionReference := &build.DefinitionReference{ + Id: buildDefinition.Id, + } + + newBuild := &build.Build{ + Definition: definitionReference, + } + queueBuildArgs := build.QueueBuildArgs{ + Project: &projectId, + Build: newBuild, + } + + _, err = client.QueueBuild(ctx, queueBuildArgs) + if err != nil { + return err + } + + return nil +} diff --git a/cli/azd/pkg/azdo/project.go b/cli/azd/pkg/azdo/project.go new file mode 100644 index 00000000000..c5824fc7330 --- /dev/null +++ b/cli/azd/pkg/azdo/project.go @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdo + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/microsoft/azure-devops-go-api/azuredevops" + "github.com/microsoft/azure-devops-go-api/azuredevops/core" + "github.com/microsoft/azure-devops-go-api/azuredevops/operations" +) + +// returns a process template (basic, agile etc) used in the new project creation flow +func getProcessTemplateId(ctx context.Context, client core.Client) (string, error) { + processArgs := core.GetProcessesArgs{} + processes, err := client.GetProcesses(ctx, processArgs) + if err != nil { + return "", err + } + process := (*processes)[0] + return process.Id.String(), nil +} + +// creates a new Azure Devops project +func createProject(ctx context.Context, connection *azuredevops.Connection, name string, description string, console input.Console) (*core.TeamProjectReference, error) { + coreClient, err := core.NewClient(ctx, connection) + if err != nil { + return nil, err + } + + processTemplateId, err := getProcessTemplateId(ctx, coreClient) + if err != nil { + return nil, fmt.Errorf("error fetching process template id %w", err) + } + + capabilities := map[string]map[string]string{ + "versioncontrol": { + "sourceControlType": "git", + }, + "processTemplate": { + "templateTypeId": processTemplateId, + }, + } + args := core.QueueCreateProjectArgs{ + ProjectToCreate: &core.TeamProject{ + Description: &description, + Name: &name, + Visibility: &core.ProjectVisibilityValues.Private, + Capabilities: &capabilities, + }, + } + res, err := coreClient.QueueCreateProject(ctx, args) + if err != nil { + return nil, err + } + + operationsClient := operations.NewClient(ctx, connection) + + getOperationsArgs := operations.GetOperationArgs{ + OperationId: res.Id, + } + + projectCreated := false + maxCheck := 10 + count := 0 + + for !projectCreated { + operation, err := operationsClient.GetOperation(ctx, getOperationsArgs) + if err != nil { + return nil, err + } + + if *operation.Status == "succeeded" { + projectCreated = true + } + + if count >= maxCheck { + return nil, fmt.Errorf("error creating azure devops project %s", name) + } + + count++ + time.Sleep(800 * time.Millisecond) + } + + project, err := getAzdoProjectByName(ctx, connection, name) + if err != nil { + return nil, err + } + return project, nil +} + +// prompts the user for a new AzDo project name and creates the project +// returns project name, project id, error +func GetAzdoProjectFromNew(ctx context.Context, repoPath string, connection *azuredevops.Connection, env *environment.Environment, console input.Console) (string, string, error) { + var project *core.TeamProjectReference + currentFolderName := filepath.Base(repoPath) + var projectDescription string = AzDoProjectDescription + + for { + name, err := console.Prompt(ctx, input.ConsoleOptions{ + Message: "Enter the name for your new Azure DevOps Project OR Hit enter to use this name:", + DefaultValue: currentFolderName, + }) + if err != nil { + return "", "", fmt.Errorf("asking for new project name: %w", err) + } + var message string = "" + newProject, err := createProject(ctx, connection, name, projectDescription, console) + if err != nil { + message = err.Error() + } + if strings.Contains(message, fmt.Sprintf("The following project already exists on the Azure DevOps Server: %s", name)) { + console.Message(ctx, fmt.Sprintf("error: the project name '%s' is already in use\n", name)) + continue // try again + } else if strings.Contains(message, "The following name is not valid") { + console.Message(ctx, fmt.Sprintf("error: the project name '%s' is not a valid Azure DevOps project Name. See https://aka.ms/azure-dev/azdo-project-naming\n", name)) + continue // try again + } else if err != nil { + return "", "", fmt.Errorf("creating project: %w", err) + } else { + project = newProject + break + } + } + + return *project.Name, project.Id.String(), nil +} + +// return an azdo project by name +func getAzdoProjectByName(ctx context.Context, connection *azuredevops.Connection, name string) (*core.TeamProjectReference, error) { + coreClient, err := core.NewClient(ctx, connection) + if err != nil { + return nil, err + } + + args := core.GetProjectsArgs{} + getProjectsResponse, err := coreClient.GetProjects(ctx, args) + if err != nil { + return nil, err + } + + projects := getProjectsResponse.Value + for _, project := range projects { + if *project.Name == name { + return &project, nil + } + } + + return nil, fmt.Errorf("azure devops project %s not found", name) +} + +// prompt the user to select form a list of existing Azure DevOps projects +func GetAzdoProjectFromExisting(ctx context.Context, connection *azuredevops.Connection, console input.Console) (string, string, error) { + coreClient, err := core.NewClient(ctx, connection) + if err != nil { + return "", "", err + } + + args := core.GetProjectsArgs{} + getProjectsResponse, err := coreClient.GetProjects(ctx, args) + if err != nil { + return "", "", err + } + + projects := getProjectsResponse.Value + projectsList := make([]core.TeamProjectReference, len(projects)) + options := make([]string, len(projects)) + for idx, project := range projects { + options[idx] = *project.Name + projectsList[idx] = project + } + + projectIdx, err := console.Select(ctx, input.ConsoleOptions{ + Message: "Please choose an existing Azure DevOps Project", + Options: options, + }) + + if err != nil { + return "", "", fmt.Errorf("prompting for azdo project: %w", err) + } + + return options[projectIdx], projectsList[projectIdx].Id.String(), nil +} diff --git a/cli/azd/pkg/azdo/repository.go b/cli/azd/pkg/azdo/repository.go new file mode 100644 index 00000000000..4d81285563d --- /dev/null +++ b/cli/azd/pkg/azdo/repository.go @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdo + +import ( + "context" + "fmt" + + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/microsoft/azure-devops-go-api/azuredevops" + "github.com/microsoft/azure-devops-go-api/azuredevops/git" +) + +// create a new repository in the current project +func CreateRepository(ctx context.Context, projectId string, repoName string, connection *azuredevops.Connection) (*git.GitRepository, error) { + gitClient, err := git.NewClient(ctx, connection) + if err != nil { + return nil, err + } + + gitRepositoryCreateOptions := git.GitRepositoryCreateOptions{ + Name: &repoName, + } + + createRepositoryArgs := git.CreateRepositoryArgs{ + Project: &projectId, + GitRepositoryToCreate: &gitRepositoryCreateOptions, + } + repo, err := gitClient.CreateRepository(ctx, createRepositoryArgs) + if err != nil { + return nil, err + } + return repo, nil +} + +// returns a default repo from a newly created AzDo project. +// this relies on the fact that new projects automatically get a repo named the same as the project +func GetAzDoDefaultGitRepositoriesInProject(ctx context.Context, projectName string, connection *azuredevops.Connection) (*git.GitRepository, error) { + gitClient, err := git.NewClient(ctx, connection) + if err != nil { + return nil, err + } + + includeLinks := true + includeAllUrls := true + repoArgs := git.GetRepositoriesArgs{ + Project: &projectName, + IncludeLinks: &includeLinks, + IncludeAllUrls: &includeAllUrls, + } + + getRepositoriesResult, err := gitClient.GetRepositories(ctx, repoArgs) + if err != nil { + return nil, err + } + repos := *getRepositoriesResult + + for _, repo := range repos { + if *repo.Name == projectName { + return &repo, nil + } + } + + return nil, fmt.Errorf("error finding default git repository in project %s", projectName) +} + +// prompt the user to select a repo and return a repository object +func GetAzDoGitRepositoriesInProject(ctx context.Context, projectName string, orgName string, connection *azuredevops.Connection, console input.Console) (*git.GitRepository, error) { + gitClient, err := git.NewClient(ctx, connection) + if err != nil { + return nil, err + } + + includeLinks := true + includeAllUrls := true + repoArgs := git.GetRepositoriesArgs{ + Project: &projectName, + IncludeLinks: &includeLinks, + IncludeAllUrls: &includeAllUrls, + } + + getRepositoriesResult, err := gitClient.GetRepositories(ctx, repoArgs) + if err != nil { + return nil, err + } + repos := *getRepositoriesResult + + options := make([]string, len(repos)) + for idx, repo := range repos { + options[idx] = *repo.Name + } + repoIdx, err := console.Select(ctx, input.ConsoleOptions{ + Message: "Please choose an existing Azure DevOps Repository", + Options: options, + }) + + if err != nil { + return nil, fmt.Errorf("prompting for azdo project: %w", err) + } + selectedRepoName := options[repoIdx] + for _, repo := range repos { + if selectedRepoName == *repo.Name { + return &repo, nil + } + } + + return nil, fmt.Errorf("error finding git repository %s in organization %s", selectedRepoName, orgName) +} diff --git a/cli/azd/pkg/azdo/service_connection.go b/cli/azd/pkg/azdo/service_connection.go new file mode 100644 index 00000000000..77ae87ab51f --- /dev/null +++ b/cli/azd/pkg/azdo/service_connection.go @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdo + +import ( + "context" + + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/microsoft/azure-devops-go-api/azuredevops" + "github.com/microsoft/azure-devops-go-api/azuredevops/build" + "github.com/microsoft/azure-devops-go-api/azuredevops/serviceendpoint" +) + +// authorize a service connection to be used in all pipelines +func authorizeServiceConnectionToAllPipelines( + ctx context.Context, + projectId string, + endpoint *serviceendpoint.ServiceEndpoint, + connection *azuredevops.Connection) error { + buildClient, err := build.NewClient(ctx, connection) + if err != nil { + return err + } + + endpointResource := "endpoint" + endpointAuthorized := true + endpointId := endpoint.Id.String() + resources := make([]build.DefinitionResourceReference, 1) + resources[0] = build.DefinitionResourceReference{ + Type: &endpointResource, + Authorized: &endpointAuthorized, + Id: &endpointId, + } + + authorizeProjectResourcesArgs := build.AuthorizeProjectResourcesArgs{ + Project: &projectId, + Resources: &resources, + } + + _, err = buildClient.AuthorizeProjectResources(ctx, authorizeProjectResourcesArgs) + + if err != nil { + return err + } + return nil +} + +// find service connection by name. +func serviceConnectionExists(ctx context.Context, + client *serviceendpoint.Client, + projectId *string, + serviceConnectionName *string) (bool, error) { + + endpointNames := make([]string, 1) + endpointNames[0] = *serviceConnectionName + getServiceEndpointsByNamesArgs := serviceendpoint.GetServiceEndpointsByNamesArgs{ + Project: projectId, + EndpointNames: &endpointNames, + } + + serviceEndpoints, err := (*client).GetServiceEndpointsByNames(ctx, getServiceEndpointsByNamesArgs) + if err != nil { + return false, err + } + + for _, endpoint := range *serviceEndpoints { + if *endpoint.Name == *serviceConnectionName && *endpoint.IsReady { + return true, nil + } + } + + return false, nil +} + +// create a new service connection that will be used in the deployment pipeline +func CreateServiceConnection( + ctx context.Context, + connection *azuredevops.Connection, + projectId string, + azdEnvironment environment.Environment, + credentials AzureServicePrincipalCredentials, + console input.Console) error { + + client, err := serviceendpoint.NewClient(ctx, connection) + if err != nil { + return err + } + + foundServiceConnection, err := serviceConnectionExists(ctx, &client, &projectId, &ServiceConnectionName) + if err != nil { + return err + } + + // if a service connection exists, skip creation. + if foundServiceConnection { + console.Message(ctx, output.WithWarningFormat("Service Connection %s already exists. Skipping Service Connection Creation", ServiceConnectionName)) + return nil + } + + createServiceEndpointArgs, err := createAzureRMServiceEndPointArgs(ctx, &projectId, credentials) + if err != nil { + return err + } + endpoint, err := client.CreateServiceEndpoint(ctx, createServiceEndpointArgs) + if err != nil { + return err + } + + err = authorizeServiceConnectionToAllPipelines(ctx, projectId, endpoint, connection) + if err != nil { + return err + } + + return nil +} + +// creates input parameter needed to create the azure rm service connection +func createAzureRMServiceEndPointArgs( + ctx context.Context, + projectId *string, + credentials AzureServicePrincipalCredentials, +) (serviceendpoint.CreateServiceEndpointArgs, error) { + endpointType := "azurerm" + endpointOwner := "library" + endpointUrl := "https://management.azure.com/" + endpointName := ServiceConnectionName + endpointIsShared := false + endpointScheme := "ServicePrincipal" + + endpointAuthorizationParameters := make(map[string]string) + endpointAuthorizationParameters["serviceprincipalid"] = credentials.ClientId + endpointAuthorizationParameters["serviceprincipalkey"] = credentials.ClientSecret + endpointAuthorizationParameters["authenticationType"] = "spnKey" + endpointAuthorizationParameters["tenantid"] = credentials.TenantId + + endpointData := make(map[string]string) + endpointData["environment"] = CloudEnvironment + endpointData["subscriptionId"] = credentials.SubscriptionId + endpointData["subscriptionName"] = "azure subscription" + endpointData["scopeLevel"] = "Subscription" + endpointData["creationMode"] = "Manual" + + endpointAuthorization := serviceendpoint.EndpointAuthorization{ + Scheme: &endpointScheme, + Parameters: &endpointAuthorizationParameters, + } + serviceEndpoint := &serviceendpoint.ServiceEndpoint{ + Type: &endpointType, + Owner: &endpointOwner, + Url: &endpointUrl, + Name: &endpointName, + IsShared: &endpointIsShared, + Authorization: &endpointAuthorization, + Data: &endpointData, + } + + createServiceEndpointArgs := serviceendpoint.CreateServiceEndpointArgs{ + Project: projectId, + Endpoint: serviceEndpoint, + } + return createServiceEndpointArgs, nil +} diff --git a/cli/azd/pkg/azdo/utils.go b/cli/azd/pkg/azdo/utils.go new file mode 100644 index 00000000000..cdcf3cce0e2 --- /dev/null +++ b/cli/azd/pkg/azdo/utils.go @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdo + +import ( + "context" + "fmt" + "os" + + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/output" +) + +// helper method to verify that a configuration exists in the .env file or in system environment variables +func ensureAzdoConfigExists(ctx context.Context, env *environment.Environment, key string, label string) (string, error) { + value := env.Values[key] + if value != "" { + return value, nil + } + + value, exists := os.LookupEnv(key) + if !exists || value == "" { + return value, fmt.Errorf("%s not found in environment variable %s", label, key) + } + return value, nil +} + +// helper method to ensure an Azure DevOps PAT exists either in .env or system environment variables +func EnsureAzdoPatExists(ctx context.Context, env *environment.Environment, console input.Console) (string, error) { + value, err := ensureAzdoConfigExists(ctx, env, AzDoPatName, "azure devops personal access token") + if err != nil { + console.Message(ctx, output.WithWarningFormat("You need an Azure DevOps Personal Access Token (PAT). Please create a PAT by following the instructions here https://aka.ms/azure-dev/azdo-pat")) + pat, err := console.Prompt(ctx, input.ConsoleOptions{ + Message: "Personal Access Token (PAT):", + DefaultValue: "", + }) + if err != nil { + return "", fmt.Errorf("asking for pat: %w", err) + } + // set the pat as an environment variable for this cmd run + // note: the scope of this env var is only this shell invocation and won't be available in the caller parent shell + os.Setenv(AzDoPatName, pat) + value = pat + + idx, err := console.Select(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf("Would you like to PAT to the %s environment file (.env)?", env.GetEnvName()), + Options: []string{ + "Yes, save this PAT to the current .env file", + "No, do not store the PAT.", + }, + DefaultValue: "Yes, save this PAT to the current .env file", + }) + if err != nil { + return "", fmt.Errorf("prompting for pat storage: %w", err) + } + + switch idx { + // save pat to env file + case 0: + err = saveEnvironmentConfig(AzDoPatName, value, env) + if err != nil { + return "", err + } + // user rejected pat storage, skip + case 1: + break + default: + panic(fmt.Sprintf("unexpected selection index %d", idx)) + } + } + return value, nil +} + +// helper method to ensure an Azure DevOps organization name exists either in .env or system environment variables +func EnsureAzdoOrgNameExists(ctx context.Context, env *environment.Environment, console input.Console) (string, error) { + value, err := ensureAzdoConfigExists(ctx, env, AzDoEnvironmentOrgName, "azure devops organization name") + if err != nil { + orgName, err := console.Prompt(ctx, input.ConsoleOptions{ + Message: "Please enter an Azure DevOps Organization Name:", + DefaultValue: "", + }) + if err != nil { + return "", fmt.Errorf("asking for new project name: %w", err) + } + + err = saveEnvironmentConfig(AzDoEnvironmentOrgName, orgName, env) + if err != nil { + return "", err + } + + value = orgName + } + return value, nil +} + +// helper function to save configuration values to .env file +func saveEnvironmentConfig(key string, value string, env *environment.Environment) error { + env.Values[key] = value + err := env.Save() + + if err != nil { + return err + } + return nil +} diff --git a/cli/azd/pkg/commands/pipeline/azdo_provider.go b/cli/azd/pkg/commands/pipeline/azdo_provider.go index 8f0c211ff9f..6ccb1a03cb1 100644 --- a/cli/azd/pkg/commands/pipeline/azdo_provider.go +++ b/cli/azd/pkg/commands/pipeline/azdo_provider.go @@ -7,16 +7,44 @@ import ( "context" "encoding/json" "errors" + "fmt" + "regexp" + "strings" + "github.com/azure/azure-dev/cli/azd/pkg/azdo" "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/tools" + "github.com/azure/azure-dev/cli/azd/pkg/tools/git" + "github.com/microsoft/azure-devops-go-api/azuredevops" + "github.com/microsoft/azure-devops-go-api/azuredevops/build" + azdoGit "github.com/microsoft/azure-devops-go-api/azuredevops/git" ) // AzdoHubScmProvider implements ScmProvider using Azure DevOps as the provider // for source control manager. type AzdoHubScmProvider struct { + repoDetails *AzdoRepositoryDetails + Env *environment.Environment + AzdContext *azdcontext.AzdContext + azdoConnection *azuredevops.Connection +} + +// AzdoRepositoryDetails provides extra state needed for the AzDo provider. +// this is stored as the details property in repoDetails +type AzdoRepositoryDetails struct { + projectName string + projectId string + repoId string + orgName string + repoName string + repoWebUrl string + remoteUrl string + sshUrl string + buildDefinition *build.BuildDefinition } // *** subareaProvider implementation ****** @@ -30,7 +58,23 @@ func (p *AzdoHubScmProvider) requiredTools() []tools.ExternalTool { // preConfigureCheck check the current state of external tools and any // other dependency to be as expected for execution. func (p *AzdoHubScmProvider) preConfigureCheck(ctx context.Context, console input.Console) error { - return errors.New("not implemented") + _, err := azdo.EnsureAzdoPatExists(ctx, p.Env, console) + if err != nil { + return err + } + + _, err = azdo.EnsureAzdoOrgNameExists(ctx, p.Env, console) + return err +} + +// helper function to save configuration values to .env file +func (p *AzdoHubScmProvider) saveEnvironmentConfig(key string, value string) error { + p.Env.Values[key] = value + err := p.Env.Save() + if err != nil { + return err + } + return nil } // name returns the name of the provider @@ -40,32 +84,451 @@ func (p *AzdoHubScmProvider) name() string { // *** scmProvider implementation ****** +// stores repo details in state for use in other functions. Also saves AzDo project details to .env +func (p *AzdoHubScmProvider) StoreRepoDetails(ctx context.Context, repo *azdoGit.GitRepository) error { + repoDetails := p.getRepoDetails() + repoDetails.repoName = *repo.Name + repoDetails.remoteUrl = *repo.RemoteUrl + repoDetails.repoWebUrl = *repo.WebUrl + repoDetails.sshUrl = *repo.SshUrl + repoDetails.repoId = repo.Id.String() + + err := p.saveEnvironmentConfig(azdo.AzDoEnvironmentRepoIdName, p.repoDetails.repoId) + if err != nil { + return fmt.Errorf("error saving repo id to environment %w", err) + } + + err = p.saveEnvironmentConfig(azdo.AzDoEnvironmentRepoName, p.repoDetails.repoName) + if err != nil { + return fmt.Errorf("error saving repo name to environment %w", err) + } + + err = p.saveEnvironmentConfig(azdo.AzDoEnvironmentRepoWebUrl, p.repoDetails.repoWebUrl) + if err != nil { + return fmt.Errorf("error saving repo web url to environment %w", err) + } + + return nil +} + +// prompts the user for a new AzDo Git repo and creates the repo +func (p *AzdoHubScmProvider) createNewGitRepositoryFromInput(ctx context.Context, console input.Console) (string, error) { + connection, err := p.getAzdoConnection(ctx) + if err != nil { + return "", err + } + + var repo *azdoGit.GitRepository + for { + name, err := console.Prompt(ctx, input.ConsoleOptions{ + Message: "Enter the name for your new Azure Devops Repository OR Hit enter to use this name:", + DefaultValue: p.repoDetails.projectName, + }) + if err != nil { + return "", fmt.Errorf("asking for new project name: %w", err) + } + + var message string + newRepo, err := azdo.CreateRepository(ctx, p.repoDetails.projectId, name, connection) + if err != nil { + message = err.Error() + } + if strings.Contains(message, fmt.Sprintf("A Git repository with the name %s already exists.", name)) { + console.Message(ctx, fmt.Sprintf("error: the repo name '%s' is already in use\n", name)) + continue // try again + } else if strings.Contains(message, "TF401025: 'repoName' is not a valid name for a Git repository.") { + console.Message(ctx, fmt.Sprintf("error: '%s' is not a valid Azure DevOps repo name. See https://aka.ms/azure-dev/azdo-repo-naming\n", name)) + continue // try again + } else if err != nil { + return "", fmt.Errorf("creating repository: %w", err) + } else { + repo = newRepo + break + } + } + + err = p.StoreRepoDetails(ctx, repo) + if err != nil { + return "", err + + } + return *repo.RemoteUrl, nil +} + +// verifies that a repo exists or prompts the user to select from a list of existing AzDo repos +func (p *AzdoHubScmProvider) ensureGitRepositoryExists(ctx context.Context, console input.Console) (string, error) { + if p.repoDetails != nil && p.repoDetails.repoName != "" { + return p.repoDetails.remoteUrl, nil + } + + connection, err := p.getAzdoConnection(ctx) + if err != nil { + return "", err + } + + repo, err := azdo.GetAzDoGitRepositoriesInProject(ctx, p.repoDetails.projectName, p.repoDetails.orgName, connection, console) + if err != nil { + return "", err + } + + err = p.StoreRepoDetails(ctx, repo) + if err != nil { + return "", err + } + + remoteParts := strings.Split(p.repoDetails.remoteUrl, "@") + if len(remoteParts) < 2 { + return "", fmt.Errorf("invalid azure devops remote") + } + remoteUser := remoteParts[0] + remoteHost := remoteParts[1] + pat, err := azdo.EnsureAzdoPatExists(ctx, p.Env, console) + if err != nil { + return "", err + } + + updatedRemote := fmt.Sprintf("%s:%s@%s", remoteUser, pat, remoteHost) + console.Message(ctx, fmt.Sprintf("using Azure DevOps repo: %s", p.repoDetails.repoWebUrl)) + + return updatedRemote, nil +} + +// helper function to return repoDetails from state +func (p *AzdoHubScmProvider) getRepoDetails() *AzdoRepositoryDetails { + if p.repoDetails != nil { + return p.repoDetails + } + repoDetails := &AzdoRepositoryDetails{} + p.repoDetails = repoDetails + return p.repoDetails +} + +// helper function to return an azuredevops.Connection for use with AzDo Go SDK +func (p *AzdoHubScmProvider) getAzdoConnection(ctx context.Context) (*azuredevops.Connection, error) { + console := input.GetConsole(ctx) + if p.azdoConnection != nil { + return p.azdoConnection, nil + } + + org, err := azdo.EnsureAzdoOrgNameExists(ctx, p.Env, console) + if err != nil { + return nil, err + } + + repoDetails := p.getRepoDetails() + repoDetails.orgName = org + + pat, err := azdo.EnsureAzdoPatExists(ctx, p.Env, console) + if err != nil { + return nil, err + } + + connection, err := azdo.GetAzdoConnection(ctx, org, pat) + if err != nil { + return nil, err + } + + return connection, nil +} + +// returns an existing project or prompts the user to either select a project or a create a new AzDo project +func (p *AzdoHubScmProvider) ensureProjectExists(ctx context.Context, console input.Console) (string, string, bool, error) { + if p.repoDetails != nil && p.repoDetails.projectName != "" { + return p.repoDetails.projectName, p.repoDetails.projectId, false, nil + } + idx, err := console.Select(ctx, input.ConsoleOptions{ + Message: "How would you like to configure your project?", + Options: []string{ + "Select an existing Azure DevOps project", + "Create a new Azure DevOps Project", + }, + DefaultValue: "Create a new Azure DevOps Project", + }) + if err != nil { + return "", "", false, fmt.Errorf("prompting for azdo project type: %w", err) + } + + connection, err := p.getAzdoConnection(ctx) + if err != nil { + return "", "", false, err + } + + var projectName string + var projectId string + var newProject bool = false + switch idx { + // Select from an existing AzDo project + case 0: + projectName, projectId, err = azdo.GetAzdoProjectFromExisting(ctx, connection, console) + if err != nil { + return "", "", false, err + } + // Create a new AzDo project + case 1: + projectName, projectId, err = azdo.GetAzdoProjectFromNew(ctx, p.AzdContext.ProjectDirectory(), connection, p.Env, console) + newProject = true + if err != nil { + return "", "", false, err + } + default: + panic(fmt.Sprintf("unexpected selection index %d", idx)) + } + return projectName, projectId, newProject, nil +} + // configureGitRemote set up or create the git project and git remote func (p *AzdoHubScmProvider) configureGitRemote(ctx context.Context, repoPath string, remoteName string, console input.Console) (string, error) { - return "remoteUrl", errors.New("not implemented") + projectName, projectId, newProject, err := p.ensureProjectExists(ctx, console) + if err != nil { + return "", err + } + + repoDetails := p.getRepoDetails() + repoDetails.projectName = projectName + repoDetails.projectId = projectId + + err = p.saveEnvironmentConfig(azdo.AzDoEnvironmentProjectIdName, projectId) + if err != nil { + return "", fmt.Errorf("error saving project id to environment %w", err) + } + + err = p.saveEnvironmentConfig(azdo.AzDoEnvironmentProjectName, projectName) + if err != nil { + return "", fmt.Errorf("error saving project name to environment %w", err) + } + var remoteUrl string + + if !newProject { + remoteUrl, err = p.promptForAzdoRepository(ctx, console) + if err != nil { + return "", err + } + } else { + remoteUrl, err = p.getDefaultRepoRemote(ctx, projectName, projectId, console) + if err != nil { + return "", err + } + } + return remoteUrl, nil +} + +// returns the git remote for a newly created repo that is part of a newly created AzDo project +func (p *AzdoHubScmProvider) getDefaultRepoRemote(ctx context.Context, projectName string, projectId string, console input.Console) (string, error) { + connection, err := p.getAzdoConnection(ctx) + if err != nil { + return "", err + } + repo, err := azdo.GetAzDoDefaultGitRepositoriesInProject(ctx, projectName, connection) + if err != nil { + return "", err + } + + err = p.StoreRepoDetails(ctx, repo) + if err != nil { + return "", err + } + + message := fmt.Sprintf("using default repo (%s) in newly created project(%s)", *repo.Name, projectName) + console.Message(ctx, message) + + return *repo.RemoteUrl, nil +} + +// prompt the user to select azdo repo or create a new one +func (p *AzdoHubScmProvider) promptForAzdoRepository(ctx context.Context, console input.Console) (string, error) { + var remoteUrl string + // There are a few ways to configure the remote so offer a choice to the user. + idx, err := console.Select(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf("How would you like to configure your remote? (Organization: %s)", p.repoDetails.projectName), + Options: []string{ + "Select an existing Azure DevOps Repository", + "Create a new private Azure DevOps Repository", + }, + DefaultValue: "Create a new private Azure DevOps Repository", + }) + + if err != nil { + return "", fmt.Errorf("prompting for remote configuration type: %w", err) + } + + switch idx { + // Select from an existing Azure Devops project + case 0: + remoteUrl, err = p.ensureGitRepositoryExists(ctx, console) + if err != nil { + return "", err + } + + // Create a new project + case 1: + remoteUrl, err = p.createNewGitRepositoryFromInput(ctx, console) + if err != nil { + return "", err + } + + default: + panic(fmt.Sprintf("unexpected selection index %d", idx)) + } + + return remoteUrl, nil +} + +// defines the structure of an ssl git remote +var azdoRemoteGitUrlRegex = regexp.MustCompile(`^git@ssh.dev.azure\.com:(.*?)(?:\.git)?$`) + +// defines the structure of an HTTPS git remote +var azdoRemoteHttpsUrlRegex = regexp.MustCompile(`^https://[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*:*.+@dev.azure\.com/(.*?)$`) + +// ErrRemoteHostIsNotAzDo the error used when a non Azure DevOps remote is found +var ErrRemoteHostIsNotAzDo = errors.New("existing remote is not an Azure DevOps host") + +// helper function to determine if the provided remoteUrl is an azure devops repo. +// currently supports AzDo PaaS +func isAzDoRemote(remoteUrl string) error { + slug := "" + for _, r := range []*regexp.Regexp{azdoRemoteGitUrlRegex, azdoRemoteHttpsUrlRegex} { + captures := r.FindStringSubmatch(remoteUrl) + if captures != nil { + slug = captures[1] + } + } + if slug == "" { + return ErrRemoteHostIsNotAzDo + } + return nil } // gitRepoDetails extracts the information from an Azure DevOps remote url into general scm concepts // like owner, name and path func (p *AzdoHubScmProvider) gitRepoDetails(ctx context.Context, remoteUrl string) (*gitRepositoryDetails, error) { + err := isAzDoRemote(remoteUrl) + if err != nil { + return nil, err + } + + repoDetails := p.getRepoDetails() + if repoDetails.orgName == "" { + repoDetails.orgName = p.Env.Values[azdo.AzDoEnvironmentOrgName] + } + if repoDetails.projectName == "" { + repoDetails.projectName = p.Env.Values[azdo.AzDoEnvironmentProjectName] + } + if repoDetails.projectId == "" { + repoDetails.projectId = p.Env.Values[azdo.AzDoEnvironmentProjectIdName] + } + if repoDetails.repoName == "" { + repoDetails.repoName = p.Env.Values[azdo.AzDoEnvironmentRepoName] + } + if repoDetails.repoId == "" { + repoDetails.repoId = p.Env.Values[azdo.AzDoEnvironmentRepoIdName] + } + if repoDetails.repoWebUrl == "" { + repoDetails.repoWebUrl = p.Env.Values[azdo.AzDoEnvironmentRepoWebUrl] + } + if repoDetails.remoteUrl == "" { + repoDetails.remoteUrl = remoteUrl + } + return &gitRepositoryDetails{ - owner: "", - repoName: "", - }, errors.New("not implemented") + owner: p.repoDetails.orgName, + repoName: p.repoDetails.repoName, + details: repoDetails, + }, nil } // preventGitPush is nil for Azure DevOps +// this method also checks for a credential helper and prompts the user to set a helper to make subsequent pushes easier func (p *AzdoHubScmProvider) preventGitPush( ctx context.Context, gitRepo *gitRepositoryDetails, remoteName string, branchName string, console input.Console) (bool, error) { - return false, errors.New("not implemented") + + gitCli := git.NewGitCli(ctx) + foundHelper, err := gitCli.CheckConfigCredentialHelper(ctx) + if err != nil { + return false, err + } + if !foundHelper { + console.Message(ctx, ` + A credential helper is not configured for git. + This will require you to enter your Azure DevOps PAT when executing a git push. + https://aka.ms/azure-dev/git-store + `) + idx, err := console.Select(ctx, input.ConsoleOptions{ + Message: "Would you like to enable credential.helper store for this local git repository?", + Options: []string{ + "Yes - set credential.helper = store", + "No - do not configure credential.helper", + }, + DefaultValue: "Yes - set credential.helper = store", + }) + if err != nil { + return false, fmt.Errorf("prompting for credential helper: %w ", err) + } + switch idx { + // Configure Credential Store + case 0: + err = gitCli.SetCredentialStore(ctx, p.AzdContext.ProjectDirectory()) + if err != nil { + return false, fmt.Errorf("storing credentials in env: %w ", err) + } + // Skip + case 1: + break + default: + panic(fmt.Sprintf("unexpected selection index %d", idx)) + } + } + + return false, nil +} + +// hook function that fires after a git push +// allows the provider to perform certain tasks after push including +// cleanup on the remote url, creating the build policy for PRs and queuing an initial deployment +func (p *AzdoHubScmProvider) postGitPush( + ctx context.Context, + gitRepo *gitRepositoryDetails, + remoteName string, + branchName string, + console input.Console) error { + + gitCli := git.NewGitCli(ctx) + + //Reset remote to original url without PAT + err := gitCli.UpdateRemote(ctx, p.AzdContext.ProjectDirectory(), remoteName, p.repoDetails.remoteUrl) + if err != nil { + return err + } + if gitRepo.pushStatus { + console.Message(ctx, output.WithSuccessFormat(azdo.AzdoConfigSuccessMessage, p.repoDetails.repoWebUrl)) + } + + connection, err := p.getAzdoConnection(ctx) + if err != nil { + return err + } + + err = azdo.CreateBuildPolicy(ctx, connection, p.repoDetails.projectId, p.repoDetails.repoId, p.repoDetails.buildDefinition) + if err != nil { + return err + } + + err = azdo.QueueBuild(ctx, connection, p.repoDetails.projectId, p.repoDetails.buildDefinition) + if err != nil { + return err + } + + return nil } // AzdoCiProvider implements a CiProvider using Azure DevOps to manage CI with azdo pipelines. type AzdoCiProvider struct { + Env *environment.Environment + AzdContext *azdcontext.AzdContext + credentials *azdo.AzureServicePrincipalCredentials } // *** subareaProvider implementation ****** @@ -77,7 +540,13 @@ func (p *AzdoCiProvider) requiredTools() []tools.ExternalTool { // preConfigureCheck nil for Azdo func (p *AzdoCiProvider) preConfigureCheck(ctx context.Context, console input.Console) error { - return errors.New("not implemented") + _, err := azdo.EnsureAzdoPatExists(ctx, p.Env, console) + if err != nil { + return err + } + + _, err = azdo.EnsureAzdoOrgNameExists(ctx, p.Env, console) + return err } // name returns the name of the provider. @@ -96,10 +565,65 @@ func (p *AzdoCiProvider) configureConnection( credentials json.RawMessage, console input.Console) error { - return errors.New("not implemented") + azureCredentials, err := parseCredentials(ctx, credentials) + if err != nil { + return err + } + + p.credentials = azureCredentials + details := repoDetails.details.(*AzdoRepositoryDetails) + org, err := azdo.EnsureAzdoOrgNameExists(ctx, p.Env, console) + if err != nil { + return err + } + pat, err := azdo.EnsureAzdoPatExists(ctx, p.Env, console) + if err != nil { + return err + } + connection, err := azdo.GetAzdoConnection(ctx, org, pat) + if err != nil { + return err + } + err = azdo.CreateServiceConnection(ctx, connection, details.projectId, *p.Env, *p.credentials, console) + if err != nil { + return err + } + return nil +} + +// struct used to deserialize service principal json object + +// parses the incoming json object and deserializes it to a struct +func parseCredentials(ctx context.Context, credentials json.RawMessage) (*azdo.AzureServicePrincipalCredentials, error) { + azureCredentials := azdo.AzureServicePrincipalCredentials{} + if e := json.Unmarshal(credentials, &azureCredentials); e != nil { + return nil, fmt.Errorf("setting terraform env var credentials: %w", e) + } + return &azureCredentials, nil } // configurePipeline create Azdo pipeline -func (p *AzdoCiProvider) configurePipeline(ctx context.Context) error { - return errors.New("not implemented") +func (p *AzdoCiProvider) configurePipeline(ctx context.Context, repoDetails *gitRepositoryDetails, provisioningProvider provisioning.Options) error { + details := repoDetails.details.(*AzdoRepositoryDetails) + console := input.GetConsole(ctx) + + org, err := azdo.EnsureAzdoOrgNameExists(ctx, p.Env, console) + if err != nil { + return err + } + pat, err := azdo.EnsureAzdoPatExists(ctx, p.Env, console) + if err != nil { + return err + } + connection, err := azdo.GetAzdoConnection(ctx, org, pat) + if err != nil { + return err + } + buildDefinition, err := azdo.CreatePipeline( + ctx, details.projectId, azdo.AzurePipelineName, details.repoName, connection, *p.credentials, *p.Env, console, provisioningProvider) + if err != nil { + return err + } + details.buildDefinition = buildDefinition + return nil } diff --git a/cli/azd/pkg/commands/pipeline/azdo_provider_test.go b/cli/azd/pkg/commands/pipeline/azdo_provider_test.go new file mode 100644 index 00000000000..bdb060905d2 --- /dev/null +++ b/cli/azd/pkg/commands/pipeline/azdo_provider_test.go @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package pipeline + +import ( + "context" + "io" + "os" + "path" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdo" + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/stretchr/testify/require" +) + +func Test_azdo_provider_getRepoDetails(t *testing.T) { + t.Run("error", func(t *testing.T) { + // arrange + provider := getAzdoScmProviderTestHarness() + testOrgName := provider.Env.Values[azdo.AzDoEnvironmentOrgName] + testRepoName := provider.Env.Values[azdo.AzDoEnvironmentRepoName] + ctx := context.Background() + + // act + details, e := provider.gitRepoDetails(ctx, "https://fake_org@dev.azure.com/fake_org/repo1/_git/repo1") + + // assert + require.NoError(t, e) + require.EqualValues(t, testOrgName, details.owner) + require.EqualValues(t, testRepoName, details.repoName) + require.EqualValues(t, false, details.pushStatus) + }) + + t.Run("ssh", func(t *testing.T) { + // arrange + provider := getAzdoScmProviderTestHarness() + testOrgName := provider.Env.Values[azdo.AzDoEnvironmentOrgName] + testRepoName := provider.Env.Values[azdo.AzDoEnvironmentRepoName] + ctx := context.Background() + + // act + details, e := provider.gitRepoDetails(ctx, "git@ssh.dev.azure.com:v3/fake_org/repo1/repo1") + + // assert + require.NoError(t, e) + require.EqualValues(t, testOrgName, details.owner) + require.EqualValues(t, testRepoName, details.repoName) + require.EqualValues(t, false, details.pushStatus) + }) + + t.Run("non azure devops https remote", func(t *testing.T) { + //arrange + provider := &AzdoHubScmProvider{} + ctx := context.Background() + + //act + details, e := provider.gitRepoDetails(ctx, "https://github.com/Azure/azure-dev.git") + + //asserts + require.Error(t, e, ErrRemoteHostIsNotAzDo) + require.EqualValues(t, (*gitRepositoryDetails)(nil), details) + }) + + t.Run("non azure devops git remote", func(t *testing.T) { + //arrange + provider := &AzdoHubScmProvider{} + ctx := context.Background() + + //act + details, e := provider.gitRepoDetails(ctx, "git@github.com:Azure/azure-dev.git") + + //asserts + require.Error(t, e, ErrRemoteHostIsNotAzDo) + require.EqualValues(t, (*gitRepositoryDetails)(nil), details) + }) +} + +func Test_azdo_provider_preConfigureCheck(t *testing.T) { + t.Run("accepts a PAT via system environment variables", func(t *testing.T) { + // arrange + testPat := "12345" + provider := getEmptyAzdoScmProviderTestHarness() + os.Setenv(azdo.AzDoEnvironmentOrgName, "testOrg") + os.Setenv(azdo.AzDoPatName, testPat) + testConsole := &circularConsole{} + ctx := context.Background() + + // act + e := provider.preConfigureCheck(ctx, testConsole) + + // assert + require.NoError(t, e) + + //cleanup + os.Unsetenv(azdo.AzDoPatName) + }) + + t.Run("returns an error if no pat is provided", func(t *testing.T) { + // arrange + os.Unsetenv(azdo.AzDoPatName) + os.Setenv(azdo.AzDoEnvironmentOrgName, "testOrg") + provider := getEmptyAzdoScmProviderTestHarness() + testConsole := &configurablePromptConsole{} + testPat := "testPAT12345" + testConsole.promptResponse = testPat + ctx := context.Background() + + // act + e := provider.preConfigureCheck(ctx, testConsole) + + // assert + require.Nil(t, e) + require.EqualValues(t, provider.Env.Values[azdo.AzDoPatName], testPat) + }) + +} + +func Test_saveEnvironmentConfig(t *testing.T) { + tempDir := t.TempDir() + + t.Run("saves to environment file", func(t *testing.T) { + // arrange + key := "test" + value := "12345" + provider := getEmptyAzdoScmProviderTestHarness() + envPath := path.Join(tempDir, ".test.env") + provider.Env = environment.EmptyWithFile(envPath) + // act + e := provider.saveEnvironmentConfig(key, value) + // assert + writtenEnv, err := environment.FromFile(envPath) + require.NoError(t, err) + + require.EqualValues(t, writtenEnv.Values[key], value) + require.NoError(t, e) + }) + +} +func getEmptyAzdoScmProviderTestHarness() *AzdoHubScmProvider { + return &AzdoHubScmProvider{ + Env: &environment.Environment{ + Values: map[string]string{}, + }, + } +} + +func getAzdoScmProviderTestHarness() *AzdoHubScmProvider { + return &AzdoHubScmProvider{ + Env: &environment.Environment{ + Values: map[string]string{ + azdo.AzDoEnvironmentOrgName: "fake_org", + azdo.AzDoEnvironmentProjectName: "project1", + azdo.AzDoEnvironmentProjectIdName: "12345", + azdo.AzDoEnvironmentRepoName: "repo1", + azdo.AzDoEnvironmentRepoIdName: "9876", + azdo.AzDoEnvironmentRepoWebUrl: "https://repo", + }, + }, + } +} + +// For tests where the console won't matter at all +type configurablePromptConsole struct { + promptResponse string +} + +func (console *configurablePromptConsole) Message(ctx context.Context, message string) {} +func (console *configurablePromptConsole) Prompt(ctx context.Context, options input.ConsoleOptions) (string, error) { + return console.promptResponse, nil +} +func (console *configurablePromptConsole) Select(ctx context.Context, options input.ConsoleOptions) (int, error) { + return 0, nil +} +func (console *configurablePromptConsole) Confirm(ctx context.Context, options input.ConsoleOptions) (bool, error) { + return false, nil +} +func (console *configurablePromptConsole) SetWriter(writer io.Writer) {} diff --git a/cli/azd/pkg/commands/pipeline/github_provider.go b/cli/azd/pkg/commands/pipeline/github_provider.go index 55361417416..327b54ed67a 100644 --- a/cli/azd/pkg/commands/pipeline/github_provider.go +++ b/cli/azd/pkg/commands/pipeline/github_provider.go @@ -149,6 +149,15 @@ func (p *GitHubScmProvider) preventGitPush( return false, nil } +func (p *GitHubScmProvider) postGitPush( + ctx context.Context, + gitRepo *gitRepositoryDetails, + remoteName string, + branchName string, + console input.Console) error { + return nil +} + // enum type for taking a choice after finding GitHub actions disabled. type gitHubActionsEnablingChoice int @@ -312,7 +321,7 @@ func (p *GitHubCiProvider) configureConnection( return fmt.Errorf("failed setting AZURE_CREDENTIALS secret: %w", err) } - if infraOptions.Provider == "terraform" { + if infraOptions.Provider == provisioning.Terraform { // terraform expect the credential info to be set in the env individually type credentialParse struct { Tenant string `json:"tenantId"` @@ -370,7 +379,7 @@ func (p *GitHubCiProvider) configureConnection( // configurePipeline is a no-op for GitHub, as the pipeline is automatically // created by creating the workflow files in .github folder. -func (p *GitHubCiProvider) configurePipeline(ctx context.Context) error { +func (p *GitHubCiProvider) configurePipeline(ctx context.Context, repoDetails *gitRepositoryDetails, provisioningProvider provisioning.Options) error { return nil } diff --git a/cli/azd/pkg/commands/pipeline/github_provider_test.go b/cli/azd/pkg/commands/pipeline/github_provider_test.go index c4e2b879b0f..d012ea513af 100644 --- a/cli/azd/pkg/commands/pipeline/github_provider_test.go +++ b/cli/azd/pkg/commands/pipeline/github_provider_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/require" ) -func Test_getRepoDetails(t *testing.T) { +func Test_gitHub_provider_getRepoDetails(t *testing.T) { t.Run("https", func(t *testing.T) { provider := &GitHubScmProvider{} ctx := context.Background() diff --git a/cli/azd/pkg/commands/pipeline/pipeline.go b/cli/azd/pkg/commands/pipeline/pipeline.go index 8ed996c5dcf..7c958fc4ce4 100644 --- a/cli/azd/pkg/commands/pipeline/pipeline.go +++ b/cli/azd/pkg/commands/pipeline/pipeline.go @@ -38,6 +38,10 @@ type gitRepositoryDetails struct { repoName string // System path where the git project is gitProjectPath string + //Indicates if the repo was successfully pushed a remote + pushStatus bool + + details interface{} } // ScmProvider defines the base behavior for a source control manager provider. @@ -58,6 +62,12 @@ type ScmProvider interface { remoteName string, branchName string, console input.Console) (bool, error) + //Hook function to allow SCM providers to handle scenarios after the git push is complete + postGitPush(ctx context.Context, + gitRepo *gitRepositoryDetails, + remoteName string, + branchName string, + console input.Console) error } // CiProvider defines the base behavior for a continuous integration provider. @@ -65,7 +75,7 @@ type CiProvider interface { // compose the behavior from subareaProvider subareaProvider // configurePipeline set up or create the CI pipeline. - configurePipeline(ctx context.Context) error + configurePipeline(ctx context.Context, repoDetails *gitRepositoryDetails, provisioningProvider provisioning.Options) error // configureConnection use the credential to set up the connection from the pipeline // to Azure configureConnection( @@ -91,7 +101,7 @@ func folderExists(folderPath string) bool { // - both .github and .azdo folders found: prompt user to choose provider for scm and ci // - none of the folders found: return error // - no azd context in the ctx: return error -func DetectProviders(ctx context.Context, console input.Console) (ScmProvider, CiProvider, error) { +func DetectProviders(ctx context.Context, console input.Console, env *environment.Environment) (ScmProvider, CiProvider, error) { azdContext, err := azdcontext.GetAzdContext(ctx) if err != nil { return nil, nil, err @@ -103,7 +113,7 @@ func DetectProviders(ctx context.Context, console input.Console) (ScmProvider, C hasAzDevOpsFolder := folderExists(path.Join(projectDir, ".azdo")) if !hasGitHubFolder && !hasAzDevOpsFolder { - return nil, nil, fmt.Errorf("no CI/CD provider configuration found. Expecting either .github and/or .azdo folder in the project root directory.") + return nil, nil, fmt.Errorf("no CI/CD provider configuration found. Expecting either .github and/or .azdo folder in the project root directory") } if !hasAzDevOpsFolder && hasGitHubFolder { @@ -113,7 +123,7 @@ func DetectProviders(ctx context.Context, console input.Console) (ScmProvider, C if hasAzDevOpsFolder && !hasGitHubFolder { // Azdo only - return &AzdoHubScmProvider{}, &AzdoCiProvider{}, nil + return createAzdoScmProvider(env, azdContext), createAzdoCiProvider(env, azdContext), nil } // Both folders exist. Prompt to select SCM first @@ -132,7 +142,7 @@ func DetectProviders(ctx context.Context, console input.Console) (ScmProvider, C if scmElection == 1 { // using azdo for scm would only support using azdo pipelines - return &AzdoHubScmProvider{}, &AzdoCiProvider{}, nil + return createAzdoScmProvider(env, azdContext), createAzdoCiProvider(env, azdContext), nil } // GitHub selected for SCM, prompt for CI provider @@ -154,5 +164,19 @@ func DetectProviders(ctx context.Context, console input.Console) (ScmProvider, C } // GitHub plus azdo pipelines otherwise - return &GitHubScmProvider{}, &AzdoCiProvider{}, nil + return &GitHubScmProvider{}, createAzdoCiProvider(env, azdContext), nil +} + +func createAzdoCiProvider(env *environment.Environment, azdCtx *azdcontext.AzdContext) *AzdoCiProvider { + return &AzdoCiProvider{ + Env: env, + AzdContext: azdCtx, + } +} + +func createAzdoScmProvider(env *environment.Environment, azdCtx *azdcontext.AzdContext) *AzdoHubScmProvider { + return &AzdoHubScmProvider{ + Env: env, + AzdContext: azdCtx, + } } diff --git a/cli/azd/pkg/commands/pipeline/pipeline_manager.go b/cli/azd/pkg/commands/pipeline/pipeline_manager.go index ca1ea0944dc..c96bf91e1cc 100644 --- a/cli/azd/pkg/commands/pipeline/pipeline_manager.go +++ b/cli/azd/pkg/commands/pipeline/pipeline_manager.go @@ -5,9 +5,11 @@ package pipeline import ( "context" + "encoding/json" "errors" "fmt" "log" + "os" "time" "github.com/azure/azure-dev/cli/azd/internal" @@ -30,6 +32,7 @@ type PipelineManager struct { PipelineServicePrincipalName string PipelineRemoteName string PipelineRoleName string + PipelineProvider string Environment *environment.Environment } @@ -200,26 +203,31 @@ func (manager *PipelineManager) Configure(ctx context.Context) error { // changed from "az-cli" to "az-dev" manager.PipelineServicePrincipalName = fmt.Sprintf("az-dev-%s", time.Now().UTC().Format("01-02-2006-15-04-05")) } + inputConsole.Message(ctx, fmt.Sprintf("Creating or updating service principal %s.\n", manager.PipelineServicePrincipalName)) - credentials, err := azCli.CreateOrUpdateServicePrincipal( - ctx, - manager.Environment.GetSubscriptionId(), - manager.PipelineServicePrincipalName, - manager.PipelineRoleName) - if err != nil { - return fmt.Errorf("failed to create or update service principal: %w", err) + var credentials json.RawMessage = nil + var err error + + existingSP := os.Getenv("AZURE_CREDENTIALS") + if existingSP != "" { + credentials = json.RawMessage(existingSP) + } + + if credentials == nil { + credentials, err = azCli.CreateOrUpdateServicePrincipal( + ctx, + manager.Environment.GetSubscriptionId(), + manager.PipelineServicePrincipalName, + manager.PipelineRoleName) + if err != nil { + return fmt.Errorf("failed to create or update service principal: %w", err) + } } // Get git repo details gitRepoInfo, err := manager.getGitRepoDetails(ctx) if err != nil { - return fmt.Errorf("ensuring github remote: %w", err) - } - - // config pipeline handles setting or creating the provider pipeline to be used - err = manager.CiProvider.configurePipeline(ctx) - if err != nil { - return err + return fmt.Errorf("ensuring git remote: %w", err) } // Figure out what is the expected provider to use for provisioning @@ -239,6 +247,12 @@ func (manager *PipelineManager) Configure(ctx context.Context) error { return err } + // config pipeline handles setting or creating the provider pipeline to be used + err = manager.CiProvider.configurePipeline(ctx, gitRepoInfo, prj.Infra) + if err != nil { + return err + } + // The CI pipeline should be set-up and ready at this point. // azd offers to push changes to the scm to start a new pipeline run doPush, err := inputConsole.Confirm(ctx, input.ConsoleOptions{ @@ -273,8 +287,17 @@ func (manager *PipelineManager) Configure(ctx context.Context) error { if doPush { err = manager.pushGitRepo(ctx, currentBranch) + if err == nil { + gitRepoInfo.pushStatus = true + } + err = manager.ScmProvider.postGitPush( + ctx, + gitRepoInfo, + manager.PipelineRemoteName, + currentBranch, + inputConsole) if err != nil { - return err + return fmt.Errorf(" post git push hook : ") } } else { inputConsole.Message(ctx, diff --git a/cli/azd/pkg/commands/pipeline/pipeline_test.go b/cli/azd/pkg/commands/pipeline/pipeline_test.go index 78a1966133a..5503650a190 100644 --- a/cli/azd/pkg/commands/pipeline/pipeline_test.go +++ b/cli/azd/pkg/commands/pipeline/pipeline_test.go @@ -22,7 +22,7 @@ func Test_detectProviders(t *testing.T) { ctx := context.Background() t.Run("no azd context within context", func(t *testing.T) { - scmProvider, ciProvider, err := DetectProviders(ctx, &nullConsole{}) + scmProvider, ciProvider, err := DetectProviders(ctx, &nullConsole{}, nil) assert.Nil(t, scmProvider) assert.Nil(t, ciProvider) assert.EqualError(t, err, "cannot find AzdContext on go context") @@ -33,17 +33,17 @@ func Test_detectProviders(t *testing.T) { ctx = azdcontext.WithAzdContext(ctx, azdContext) t.Run("no folders error", func(t *testing.T) { - scmProvider, ciProvider, err := DetectProviders(ctx, &nullConsole{}) + scmProvider, ciProvider, err := DetectProviders(ctx, &nullConsole{}, nil) assert.Nil(t, scmProvider) assert.Nil(t, ciProvider) - assert.EqualError(t, err, "no CI/CD provider configuration found. Expecting either .github and/or .azdo folder in the project root directory.") + assert.EqualError(t, err, "no CI/CD provider configuration found. Expecting either .github and/or .azdo folder in the project root directory") }) t.Run("github folder only", func(t *testing.T) { ghFolder := path.Join(tempDir, ".github") err := os.Mkdir(ghFolder, osutil.PermissionDirectory) assert.NoError(t, err) - scmProvider, ciProvider, err := DetectProviders(ctx, &nullConsole{}) + scmProvider, ciProvider, err := DetectProviders(ctx, &nullConsole{}, nil) assert.IsType(t, &GitHubScmProvider{}, scmProvider) assert.IsType(t, &GitHubCiProvider{}, ciProvider) assert.NoError(t, err) @@ -55,7 +55,7 @@ func Test_detectProviders(t *testing.T) { err := os.Mkdir(azdoFolder, osutil.PermissionDirectory) assert.NoError(t, err) - scmProvider, ciProvider, err := DetectProviders(ctx, &nullConsole{}) + scmProvider, ciProvider, err := DetectProviders(ctx, &nullConsole{}, nil) assert.IsType(t, &AzdoHubScmProvider{}, scmProvider) assert.IsType(t, &AzdoCiProvider{}, ciProvider) assert.NoError(t, err) @@ -70,7 +70,7 @@ func Test_detectProviders(t *testing.T) { err = os.Mkdir(ghFolder, osutil.PermissionDirectory) assert.NoError(t, err) - scmProvider, ciProvider, err := DetectProviders(ctx, &selectDefaultConsole{}) + scmProvider, ciProvider, err := DetectProviders(ctx, &selectDefaultConsole{}, nil) assert.IsType(t, &GitHubScmProvider{}, scmProvider) assert.IsType(t, &GitHubCiProvider{}, ciProvider) assert.NoError(t, err) @@ -89,7 +89,7 @@ func Test_detectProviders(t *testing.T) { scmProvider, ciProvider, err := DetectProviders(ctx, &circularConsole{ selectReturnValues: []int{1}, - }) + }, nil) assert.IsType(t, &AzdoHubScmProvider{}, scmProvider) assert.IsType(t, &AzdoCiProvider{}, ciProvider) assert.NoError(t, err) @@ -108,7 +108,7 @@ func Test_detectProviders(t *testing.T) { scmProvider, ciProvider, err := DetectProviders(ctx, &circularConsole{ selectReturnValues: []int{0, 1}, - }) + }, nil) assert.IsType(t, &GitHubScmProvider{}, scmProvider) assert.IsType(t, &AzdoCiProvider{}, ciProvider) assert.NoError(t, err) diff --git a/cli/azd/pkg/tools/git/git.go b/cli/azd/pkg/tools/git/git.go index 15f8f835c5e..d549056c43b 100644 --- a/cli/azd/pkg/tools/git/git.go +++ b/cli/azd/pkg/tools/git/git.go @@ -23,11 +23,14 @@ type GitCli interface { FetchCode(ctx context.Context, repositoryPath string, branch string, target string) error InitRepo(ctx context.Context, repositoryPath string) error AddRemote(ctx context.Context, repositoryPath string, remoteName string, remoteUrl string) error + UpdateRemote(ctx context.Context, repositoryPath string, remoteName string, remoteUrl string) error GetCurrentBranch(ctx context.Context, repositoryPath string) (string, error) AddFile(ctx context.Context, repositoryPath string, filespec string) error Commit(ctx context.Context, repositoryPath string, message string) error PushUpstream(ctx context.Context, repositoryPath string, origin string, branch string) error IsUntrackedFile(ctx context.Context, repositoryPath string, filePath string) (bool, error) + SetCredentialStore(ctx context.Context, repositoryPath string) error + CheckConfigCredentialHelper(ctx context.Context) (bool, error) } type gitCli struct { @@ -143,6 +146,29 @@ func (cli *gitCli) InitRepo(ctx context.Context, repositoryPath string) error { return nil } +func (cli *gitCli) CheckConfigCredentialHelper(ctx context.Context) (bool, error) { + found, err := tools.ToolInPath("git") + if !found { + return false, err + } + gitRes, err := tools.ExecuteCommand(ctx, "git", "config", "credential.helper") + if err != nil { + return false, fmt.Errorf("checking %s credential.helper: %w", cli.Name(), err) + } + + return gitRes != "", nil +} + +func (cli *gitCli) SetCredentialStore(ctx context.Context, repositoryPath string) error { + runArgs := exec.NewRunArgs("git", "-C", repositoryPath, "config", "credential.helper", "store") + res, err := cli.commandRunner.Run(ctx, runArgs) + if err != nil { + return fmt.Errorf("failed to set credential store repository: %s: %w", res.String(), err) + } + + return nil +} + func (cli *gitCli) AddRemote(ctx context.Context, repositoryPath string, remoteName string, remoteUrl string) error { runArgs := exec.NewRunArgs("git", "-C", repositoryPath, "remote", "add", remoteName, remoteUrl) res, err := cli.commandRunner.Run(ctx, runArgs) @@ -153,6 +179,16 @@ func (cli *gitCli) AddRemote(ctx context.Context, repositoryPath string, remoteN return nil } +func (cli *gitCli) UpdateRemote(ctx context.Context, repositoryPath string, remoteName string, remoteUrl string) error { + runArgs := exec.NewRunArgs("git", "-C", repositoryPath, "remote", "set-url", remoteName, remoteUrl) + res, err := cli.commandRunner.Run(ctx, runArgs) + if err != nil { + return fmt.Errorf("failed to add remote: %s: %w", res.String(), err) + } + + return nil +} + func (cli *gitCli) AddFile(ctx context.Context, repositoryPath string, filespec string) error { runArgs := exec.NewRunArgs("git", "-C", repositoryPath, "add", filespec) res, err := cli.commandRunner.Run(ctx, runArgs) diff --git a/go.mod b/go.mod index 7af60a71c86..1a0a6429e25 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/mattn/go-colorable v0.1.12 github.com/mattn/go-isatty v0.0.14 github.com/microsoft/ApplicationInsights-Go v0.4.4 + github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5 github.com/otiai10/copy v1.7.0 github.com/pbnj/go-open v0.1.1 github.com/sethvargo/go-retry v0.2.3 diff --git a/go.sum b/go.sum index fac5f902652..8629cc14adc 100644 --- a/go.sum +++ b/go.sum @@ -188,7 +188,6 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= -github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -209,6 +208,7 @@ github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -304,6 +304,8 @@ github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1f github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/microsoft/ApplicationInsights-Go v0.4.4 h1:G4+H9WNs6ygSCe6sUyxRc2U81TI5Es90b2t/MwX5KqY= github.com/microsoft/ApplicationInsights-Go v0.4.4/go.mod h1:fKRUseBqkw6bDiXTs3ESTiU/4YTIHsQS4W3fP2ieF4U= +github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5 h1:YH424zrwLTlyHSH/GzLMJeu5zhYVZSx5RQxGKm1h96s= +github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5/go.mod h1:PoGiBqKSQK1vIfQ+yVaFcGjDySHvym6FM1cNYnwzbrY= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= @@ -397,7 +399,6 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs= @@ -433,7 +434,6 @@ golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -472,8 +472,6 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= -golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= -golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -516,7 +514,6 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -597,7 +594,6 @@ golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -610,7 +606,6 @@ golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211205182925-97ca703d548d h1:FjkYO/PPp4Wi0EAUOVLxePm7qVW4r4ctbWpURyuOD0E= golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -684,7 +679,6 @@ golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/templates/common/.azdo/pipelines/README.md b/templates/common/.azdo/pipelines/README.md index e42eed047e2..c27b8884cf3 100644 --- a/templates/common/.azdo/pipelines/README.md +++ b/templates/common/.azdo/pipelines/README.md @@ -1,71 +1,11 @@ # Azure DevOps Pipeline Configuration -This document will show you how to configure an Azure DevOps pipeline that uses the Azure Developer CLI. This logic will eventually be integrated into the `azd pipeline config` command. +This document will show you how to configure an Azure DevOps pipeline that uses the Azure Developer CLI. This can be configured by running the `azd pipeline config --provider azdo` command. You will find a default Azure DevOps pipeline file in `./.azdo/pipelines/azure-dev.yml`. It will provision your Azure resources and deploy your code upon pushes and pull requests. You are welcome to use that file as-is or modify it to suit your needs. -The following doc has many `bash` commands that need to be executed to get this setup. You can do them one-by-one, or you can [**scroll to the bottom of this page**](#script) to copy and paste them all. - -## Set Azure Developer CLI Environment Variables - -Your pipeline will need to know which Azure Developer environment and location you are targeting. Update the values below and run the following commands to set those values to be used later in this doc. - -```bash -export AZURE_ENV_NAME= -export AZURE_LOCATION= -``` - -> AZURE_ENV_NAME: This is the name of the environment you want your pipeline to work with. You can find this in the .azure folder. - -> AZURE_LOCATION: This is the location you used to deploy your azure developer environment to. You can find this in the .env file in the .azure folder for your environment. - -## Set AZURE Environment Variables - -Your pipeline will also need to know what subscription you are targeting. Let's configure those environment variables now. - -1. Run the following command to get the values needed: - -```bash -az account show -``` - -2. Copy and paste those values after the '=' and run those commands: - -```bash -export AZURE_SUBSCRIPTION_NAME= -export AZURE_SUBSCRIPTION_ID= -export AZURE_TENANT_ID= -``` - -## Create or Use Existing Service Principal - -You'll need to create or use an existing Service Principal to manipulate Azure resources from the Azure DevOps pipeline. - -If you have an existing Service Principal you'd like to use then you can skip creating one now and just set those environment variable values in the next step. - -### Create Service Principal - -1. Run the following command to create a new service principal - -```bash -az ad sp create-for-rbac --role Contributor --scopes /subscriptions/${AZURE_SUBSCRIPTION_ID} -``` - -### Set Service Principal Environment Variables - -1. Using the output from the `create-for-rbac` command above or your existing service principal, copy the values to the following environment variables and execute the commands to set them. - -```bash -export AZURE_SERVICE_PRINCIPAL_ID= -export AZURE_DEVOPS_EXT_AZURE_RM_SERVICE_PRINCIPAL_KEY= -``` - -> AZURE_SERVICE_PRINCIPAL_ID: The ID of the service principal you just created or existing one that you want to use. It will be the `appId` that is returned from the `create-for-rbac` command. - -> AZURE_DEVOPS_EXT_AZURE_RM_SERVICE_PRINCIPAL_KEY: The service principal password. It will be the `password` that is returned from the `create-for-rbac` command. - ## Create or Use Existing Azure DevOps Organization To run a pipeline in Azure DevOps, you'll first need an Azure DevOps organization. You must create an organization using the Azure DevOps portal here: https://dev.azure.com. @@ -73,256 +13,33 @@ To run a pipeline in Azure DevOps, you'll first need an Azure DevOps organizatio Once you have that organization, copy and paste it below, then run the commands to set those environment variables. ```bash -export AZURE_DEVOPS_ORG_NAME= -export AZURE_DEVOPS_ORG_URI=https://dev.azure.com/${AZURE_DEVOPS_ORG_NAME} -``` - -> AZURE_DEVOPS_ORG_NAME: The name of the Azure DevOps organization that you just created or existing one that you want to use. - -## Create or Use an Existing Azure DevOps Project - -To run a pipeline in Azure DevOps, you're organization will need a project and a repo. - -First, set the environment variable with the name of the project you want to create or use: - -> You don't have to change this value if you want to use the same name as your env name. - -```bash -export AZURE_DEVOPS_PROJECT_NAME=${AZURE_ENV_NAME} -``` - -> AZURE_DEVOPS_PROJECT_NAME: The name of the Azure DevOps project that you want to create or use an existing one. - -Then, if you want to create a new project, you can do so with the following command: - -```bash -az devops project create --name ${AZURE_DEVOPS_PROJECT_NAME} --org ${AZURE_DEVOPS_ORG_URI} -``` - -## Create or Use an Existing Azure DevOps Repo - -If you created the Project with the CLI command above then it has also created a new repo with the same name as your project. You can use that repo for this pipeline, or you can select a different one. - -If you want to use the repo that was created, then run this command. - -```bash -export AZURE_DEVOPS_REPO_NAME=${AZURE_DEVOPS_PROJECT_NAME} -``` - -If you want to use a different repo, then input that repo name below and run that command: - -```bash -export AZURE_DEVOPS_REPO_NAME= -``` - -> AZURE_DEVOPS_REPO_NAME: The name of the Azure DevOps repo that you want to use, if you don't want to use the default. - -NOTE: You can get repo info using the following command: - -```bash -az repos show --repository ${AZURE_DEVOPS_REPO_NAME} --project ${AZURE_DEVOPS_PROJECT_NAME} --org ${AZURE_DEVOPS_ORG_URI} -``` - -## Push Code to Repo - -Now that we have the Azure DevOps org, project, and repo setup, we can push the code to that repo. - -Let's get the remote url into an environment variable: - -```bash -export BRANCH_NAME=main -export AZURE_REPO_REMOTE_URL=$(az repos show --repository ${AZURE_DEVOPS_REPO_NAME} --project ${AZURE_DEVOPS_PROJECT_NAME} --org ${AZURE_DEVOPS_ORG_URI} --query remoteUrl -o tsv) -``` - -Initialize the current directory as a git repo (if not already done): - -> This script assumes your default branch is 'main'. You can set your default branch with this command: `git config --global init.defaultBranch main` and see what your current default branch is with this command: `git config --list`. - -```bash -git init -git checkout -b ${BRANCH_NAME} -git add . -git commit -m "init" -``` - -Then add that as a remote: - -```bash -git remote add ${AZURE_DEVOPS_REPO_NAME} ${AZURE_REPO_REMOTE_URL} -``` - -Then push the code: - -```bash -git push -u ${AZURE_DEVOPS_REPO_NAME} ${BRANCH_NAME} -``` - -> You may get prompted to enter a password to Azure DevOps here. Go to https://dev.azure.com and get a personal access token and then paste it into the console. - -## Create Azure DevOps Service Connection - -Azure DevOps uses a ["Service Connection"](https://docs.microsoft.com/azure/devops/pipelines/library/service-endpoints?view=azure-devops&tabs=yaml) to communicate with Azure from an Azure DevOps pipeline. - -First set the connection name by running the following command: - -```bash -export AZURE_DEVOPS_SERVICE_CONNECTION_NAME=azconnection -``` - -> If you change `AZURE_DEVOPS_SERVICE_CONNECTION_NAME` to something other than `azconnection`, then you also need to update the `azureSubscription` value in `./.azdo/pipelines/azure-dev.yml` to match that new name. - -Now, let's create the service connection: - -```bash -az devops service-endpoint azurerm create --azure-rm-service-principal-id ${AZURE_SERVICE_PRINCIPAL_ID} --azure-rm-subscription-id ${AZURE_SUBSCRIPTION_ID} --azure-rm-subscription-name ${AZURE_SUBSCRIPTION_NAME} --azure-rm-tenant-id ${AZURE_TENANT_ID} --name ${AZURE_DEVOPS_SERVICE_CONNECTION_NAME} --project ${AZURE_DEVOPS_PROJECT_NAME} --org ${AZURE_DEVOPS_ORG_URI} -``` - -If you didn't set the `AZURE_DEVOPS_EXT_AZURE_RM_SERVICE_PRINCIPAL_KEY` environment variable to your Service Principal's password, then you'll be prompted to enter that password. - -Next, we need to update the service connection to allow connections from any pipeline. We can do so with the following command: - -```bash -export AZURE_DEVOPS_SERVICE_CONNECTION_ID=$(az devops service-endpoint list --project ${AZURE_DEVOPS_PROJECT_NAME} --org ${AZURE_DEVOPS_ORG_URI} --query "[?contains(name, '${AZURE_DEVOPS_SERVICE_CONNECTION_NAME}')].id" -o tsv) -az devops service-endpoint update --id ${AZURE_DEVOPS_SERVICE_CONNECTION_ID} --project ${AZURE_DEVOPS_PROJECT_NAME} --org ${AZURE_DEVOPS_ORG_URI} --enable-for-all true -``` - -## Create an Azure DevOps Pipeline - -Now we are ready to create the pipeline in Azure DevOps. - -Let's first set the pipeline name environment variable. You can change this value if you'd like to. - -```bash -export AZURE_DEVOPS_PIPELINE_NAME=azdpipeline +export AZURE_DEVOPS_ORG_NAME="" ``` -Now let's create the pipeline by running the following command: +This can also be set as an Azure Developer CLI environment via the command: ```bash -az pipelines create --name ${AZURE_DEVOPS_PIPELINE_NAME} --branch ${BRANCH_NAME} --project ${AZURE_DEVOPS_PROJECT_NAME} --org ${AZURE_DEVOPS_ORG_URI} --repository ${AZURE_DEVOPS_REPO_NAME} --repository-type tfsgit --yml-path "./.azdo/pipelines/azure-dev.yml" --service-connection ${AZURE_DEVOPS_SERVICE_CONNECTION_NAME} --skip-first-run -``` - -## Set Pipeline Environment Variables - -Run the following commands to set the environment variables in the Azure DevOps pipeline needed to run the pipeline and target the appropriate environment. - -```bash -az pipelines variable create --name AZURE_SUBSCRIPTION_ID --org ${AZURE_DEVOPS_ORG_URI} --pipeline-name ${AZURE_DEVOPS_PIPELINE_NAME} --project ${AZURE_DEVOPS_PROJECT_NAME} --value ${AZURE_SUBSCRIPTION_ID} --allow-override true -az pipelines variable create --name AZURE_LOCATION --org ${AZURE_DEVOPS_ORG_URI} --pipeline-name ${AZURE_DEVOPS_PIPELINE_NAME} --project ${AZURE_DEVOPS_PROJECT_NAME} --value ${AZURE_LOCATION} --allow-override true -az pipelines variable create --name AZURE_ENV_NAME --org ${AZURE_DEVOPS_ORG_URI} --pipeline-name ${AZURE_DEVOPS_PIPELINE_NAME} --project ${AZURE_DEVOPS_PROJECT_NAME} --value ${AZURE_ENV_NAME} --allow-override true -az pipeline variable create --name ARM_TENANT_ID --org ${AZURE_DEVOPS_ORG_URI} --pipeline-name ${AZURE_DEVOPS_PIPELINE_NAME} --project ${AZURE_DEVOPS_PROJECT_NAME} --value ${AZURE_TENANT_ID} --secret -az pipeline variable create --name ARM_CLIENT_ID --org ${AZURE_DEVOPS_ORG_URI} --pipeline-name ${AZURE_DEVOPS_PIPELINE_NAME} --project ${AZURE_DEVOPS_PROJECT_NAME} --value ${AZURE_SERVICE_PRINCIPAL_ID} --secret -az pipeline variable create --name ARM_CLIENT_SECRET --org ${AZURE_DEVOPS_ORG_URI} --pipeline-name ${AZURE_DEVOPS_PIPELINE_NAME} --project ${AZURE_DEVOPS_PROJECT_NAME} --value ${AZURE_DEVOPS_EXT_AZURE_RM_SERVICE_PRINCIPAL_KEY} --secret +azd env set AZURE_DEVOPS_ORG_NAME "" ``` +> AZURE_DEVOPS_ORG_NAME: The name of the Azure DevOps organization that you just created or existing one that you want to use. -## Create Azure DevOps Build Policy +## Create a Personal Access Token -If you would like the pipeline to run every time a PR is created, then you'll need to create a build policy to do so. +The Azure Developer CLI relies on an Azure DevOps Personal Access Token (PAT) to configure an Azure DevOps project. The Azure Developer CLI will prompt you to create a PAT and provide [documentation on the PAT creation process](https://aka.ms/azure-dev/azdo-pat). -Run these commands to set the environment variables needed for the next command: ```bash -export AZURE_DEVOPS_REPO_ID=$(az repos show --repository ${AZURE_DEVOPS_REPO_NAME} --project ${AZURE_DEVOPS_PROJECT_NAME} --org ${AZURE_DEVOPS_ORG_URI} --query id -o tsv) -export AZURE_DEVOPS_PIPELINE_BUILD_DEFINITION_ID=$(az pipelines build definition show --project ${AZURE_DEVOPS_PROJECT_NAME} --org ${AZURE_DEVOPS_ORG_URI} --name ${AZURE_DEVOPS_PIPELINE_NAME} --query id) +export AZURE_DEVOPS_EXT_PAT= ``` +> AZURE_DEVOPS_EXT_PAT: The Azure DevOps Personal Access Token that you just created or existing one that you want to use. -Run this command to create the policy: +## Invoke the Pipeline configure command -```bash -az repos policy build create --blocking true --project ${AZURE_DEVOPS_PROJECT_NAME} --org ${AZURE_DEVOPS_ORG_URI} --branch ${BRANCH_NAME} --repository-id ${AZURE_DEVOPS_REPO_ID} --enabled true --build-definition-id ${AZURE_DEVOPS_PIPELINE_BUILD_DEFINITION_ID} --queue-on-source-update-only false --manual-queue-only false --display-name "PR Build Policy" --valid-duration 0 -``` +By running `azd pipeline config --provider azdo` you can instruct the Azure Developer CLI to configure an Azure DevOps Project and Repository with a deployment Pipeline. ## Conclusion -That is everything you need to have in place to get the Azure DevOps pipeline running. You can verify that it is working by going to the Azure DevOps portal (https://dev.azure.com) and finding the pipeline you just created. - -## Script - -You can copy and paste the following into a text editor so you can easily fill in the env var values. - -```bash - -# AZURE DEV ENV VARS - # Get the following values from .azure folder - export AZURE_ENV_NAME= - export AZURE_LOCATION= - -# AZURE ENV VARS - # Run this: - az account show - # Put values here: - export AZURE_SUBSCRIPTION_NAME= - export AZURE_SUBSCRIPTION_ID= - export AZURE_TENANT_ID= - -# SERVICE PRINCIPAL - # Run this to create SP - az ad sp create-for-rbac --role Contributor --scopes /subscriptions/${AZURE_SUBSCRIPTION_ID} - - # Save the above output just in case you need it later - - # Put values here: - export AZURE_SERVICE_PRINCIPAL_ID= - export AZURE_DEVOPS_EXT_AZURE_RM_SERVICE_PRINCIPAL_KEY= +That is everything you need to have in place to get the Azure DevOps pipeline running. You can verify that it is working by going to the Azure DevOps portal (https://dev.azure.com) and finding the project you just created. -# AZURE DEVOPS ORG - export AZURE_DEVOPS_ORG_NAME= - # You don't need to update the following line: - export AZURE_DEVOPS_ORG_URI=https://dev.azure.com/${AZURE_DEVOPS_ORG_NAME} -# AZURE DEVOPS PROJECT - # You can change the AZURE_DEVOPS_PROJECT_NAME value if you don't want it to be the same as your env name - export AZURE_DEVOPS_PROJECT_NAME=${AZURE_ENV_NAME} - - az devops project create --name ${AZURE_DEVOPS_PROJECT_NAME} --org ${AZURE_DEVOPS_ORG_URI} - -# AZURE DEVOPS REPO - export AZURE_DEVOPS_REPO_NAME=${AZURE_DEVOPS_PROJECT_NAME} - - # Use following if you change the repo name to something other than the project name - # export AZURE_DEVOPS_REPO_NAME= - -# PUSH CODE - # Change branch name if necessary - export BRANCH_NAME=main - export AZURE_REPO_REMOTE_URL=$(az repos show --repository ${AZURE_DEVOPS_REPO_NAME} --project ${AZURE_DEVOPS_PROJECT_NAME} --org ${AZURE_DEVOPS_ORG_URI} --query remoteUrl -o tsv) - - git init - git checkout -b ${BRANCH_NAME} - git add . - git commit -m "init" - git remote add ${AZURE_DEVOPS_REPO_NAME} ${AZURE_REPO_REMOTE_URL} - git push -u ${AZURE_DEVOPS_REPO_NAME} ${BRANCH_NAME} - - # You may get prompted to enter a password to Azure DevOps here. Go to https://dev.azure.com and get a personal access token and then paste it into the console. - -# AZURE DEVOPS SERVICE CONNECTION - export AZURE_DEVOPS_SERVICE_CONNECTION_NAME=azconnection - - az devops service-endpoint azurerm create --azure-rm-service-principal-id ${AZURE_SERVICE_PRINCIPAL_ID} --azure-rm-subscription-id ${AZURE_SUBSCRIPTION_ID} --azure-rm-subscription-name ${AZURE_SUBSCRIPTION_NAME} --azure-rm-tenant-id ${AZURE_TENANT_ID} --name ${AZURE_DEVOPS_SERVICE_CONNECTION_NAME} --project ${AZURE_DEVOPS_PROJECT_NAME} --org ${AZURE_DEVOPS_ORG_URI} - -# AZURE DEVOPS SERVICE CONNECTION PERMISSIONS - export AZURE_DEVOPS_SERVICE_CONNECTION_ID=$(az devops service-endpoint list --project ${AZURE_DEVOPS_PROJECT_NAME} --org ${AZURE_DEVOPS_ORG_URI} --query "[?contains(name, '${AZURE_DEVOPS_SERVICE_CONNECTION_NAME}')].id" -o tsv) - - az devops service-endpoint update --id ${AZURE_DEVOPS_SERVICE_CONNECTION_ID} --project ${AZURE_DEVOPS_PROJECT_NAME} --org ${AZURE_DEVOPS_ORG_URI} --enable-for-all true - -# AZURE DEVOPS PIPELINE - export AZURE_DEVOPS_PIPELINE_NAME=azdpipeline - - az pipelines create --name ${AZURE_DEVOPS_PIPELINE_NAME} --branch ${BRANCH_NAME} --project ${AZURE_DEVOPS_PROJECT_NAME} --org ${AZURE_DEVOPS_ORG_URI} --repository ${AZURE_DEVOPS_REPO_NAME} --repository-type tfsgit --yml-path "./.azdo/pipelines/azure-dev.yml" --service-connection ${AZURE_DEVOPS_SERVICE_CONNECTION_NAME} --skip-first-run - -# AZURE DEVOPS PIPELINE VARIABLES - az pipelines variable create --name AZURE_SUBSCRIPTION_ID --org ${AZURE_DEVOPS_ORG_URI} --pipeline-name ${AZURE_DEVOPS_PIPELINE_NAME} --project ${AZURE_DEVOPS_PROJECT_NAME} --value ${AZURE_SUBSCRIPTION_ID} --allow-override true - az pipelines variable create --name AZURE_LOCATION --org ${AZURE_DEVOPS_ORG_URI} --pipeline-name ${AZURE_DEVOPS_PIPELINE_NAME} --project ${AZURE_DEVOPS_PROJECT_NAME} --value ${AZURE_LOCATION} --allow-override true - az pipelines variable create --name AZURE_ENV_NAME --org ${AZURE_DEVOPS_ORG_URI} --pipeline-name ${AZURE_DEVOPS_PIPELINE_NAME} --project ${AZURE_DEVOPS_PROJECT_NAME} --value ${AZURE_ENV_NAME} --allow-override true - -# AZURE DEVOPS BUILD POLICY - export AZURE_DEVOPS_REPO_ID=$(az repos show --repository ${AZURE_DEVOPS_REPO_NAME} --project ${AZURE_DEVOPS_PROJECT_NAME} --org ${AZURE_DEVOPS_ORG_URI} --query id -o tsv) - export AZURE_DEVOPS_PIPELINE_BUILD_DEFINITION_ID=$(az pipelines build definition show --project ${AZURE_DEVOPS_PROJECT_NAME} --org ${AZURE_DEVOPS_ORG_URI} --name ${AZURE_DEVOPS_PIPELINE_NAME} --query id) - - az repos policy build create --blocking true --project ${AZURE_DEVOPS_PROJECT_NAME} --org ${AZURE_DEVOPS_ORG_URI} --branch ${BRANCH_NAME} --repository-id ${AZURE_DEVOPS_REPO_ID} --enabled true --build-definition-id ${AZURE_DEVOPS_PIPELINE_BUILD_DEFINITION_ID} --queue-on-source-update-only false --manual-queue-only false --display-name "PR Build Policy" --valid-duration 0 - -# RUN AZURE DEVOPS PIPELINE - az pipelines run --name ${AZURE_DEVOPS_PIPELINE_NAME} --project ${AZURE_DEVOPS_PROJECT_NAME} --org ${AZURE_DEVOPS_ORG_URI} -```