in cli/azd/pkg/pipeline/pipeline_manager.go [227:542]
func (pm *PipelineManager) Configure(ctx context.Context, projectName string) (result *PipelineConfigResult, err error) {
// check all required tools are installed
requiredTools, err := pm.requiredTools(ctx)
if err != nil {
return result, err
}
if err := tools.EnsureInstalled(ctx, requiredTools...); err != nil {
return result, err
}
userConfig, err := pm.userConfigManager.Load()
if err != nil {
return result, fmt.Errorf("loading user configuration: %w", err)
}
smr := resolveSmr(pm.args.ServiceManagementReference, pm.env.Config, userConfig)
if smr != nil {
if _, err := uuid.Parse(*smr); err != nil {
return result, fmt.Errorf("Invalid service management reference %s: %w", *smr, err)
}
}
infra := pm.infra
// run pre-config validations.
rootPath := pm.azdCtx.ProjectDirectory()
updatedConfig, errorsFromPreConfig := pm.preConfigureCheck(ctx, infra.Options, rootPath)
if errorsFromPreConfig != nil {
return result, errorsFromPreConfig
}
if updatedConfig {
pm.console.Message(ctx, "")
}
// Get git repo details
gitRepoInfo, err := pm.getGitRepoDetails(ctx)
if err != nil {
return result, fmt.Errorf("ensuring git remote: %w", err)
}
if pm.args.PipelineServicePrincipalName != "" && pm.args.PipelineServicePrincipalId != "" {
//nolint:lll
return result, fmt.Errorf(
"you have specified both --principal-id and --principal-name, but only one of these parameters should be used at a time.",
)
}
// see if SP already exists - This step will not create the SP if it doesn't exist.
spConfig, err := servicePrincipal(
ctx, pm.env.Getenv(AzurePipelineClientIdEnvVarName), pm.env.GetSubscriptionId(), pm.args, pm.entraIdService)
if err != nil {
return result, err
}
// Update the message depending on the SP already exists or not
var displayMsg string
if spConfig.servicePrincipal == nil {
displayMsg = fmt.Sprintf("Creating service principal %s", spConfig.applicationName)
} else {
displayMsg = fmt.Sprintf("Updating service principal %s (%s)",
spConfig.servicePrincipal.DisplayName,
spConfig.servicePrincipal.AppId)
}
pm.console.ShowSpinner(ctx, displayMsg, input.Step)
description := fmt.Sprintf("Created by Azure Developer CLI for project: %s", projectName)
options := entraid.CreateOrUpdateServicePrincipalOptions{
RolesToAssign: pm.args.PipelineRoleNames,
Description: &description,
ServiceManagementReference: smr,
}
servicePrincipal, err := pm.entraIdService.CreateOrUpdateServicePrincipal(
ctx,
pm.env.GetSubscriptionId(),
spConfig.appIdOrName,
options)
if err != nil {
pm.console.StopSpinner(ctx, displayMsg, input.GetStepResultFormat(err))
return result, fmt.Errorf("failed to create or update service principal: %w", err)
}
if !strings.Contains(displayMsg, servicePrincipal.AppId) {
displayMsg += fmt.Sprintf(" (%s)", servicePrincipal.AppId)
}
pm.console.StopSpinner(ctx, displayMsg, input.GetStepResultFormat(err))
// Set in .env to be retrieved for any additional runs
pm.env.DotenvSet(AzurePipelineClientIdEnvVarName, servicePrincipal.AppId)
if err := pm.envManager.Save(ctx, pm.env); err != nil {
return result, fmt.Errorf("failed to save environment: %w", err)
}
repoSlug := gitRepoInfo.owner + "/" + gitRepoInfo.repoName
displayMsg = fmt.Sprintf("Configuring repository %s to use credentials for %s", repoSlug, spConfig.applicationName)
pm.console.ShowSpinner(ctx, displayMsg, input.Step)
subscriptionId := pm.env.GetSubscriptionId()
credentials := &entraid.AzureCredentials{
ClientId: servicePrincipal.AppId,
TenantId: *servicePrincipal.AppOwnerOrganizationId,
SubscriptionId: subscriptionId,
}
// Get the requested credential options from the CI provider
credentialOptions, err := pm.ciProvider.credentialOptions(
ctx,
gitRepoInfo,
infra.Options,
PipelineAuthType(pm.args.PipelineAuthTypeName),
credentials,
)
if err != nil {
return result, fmt.Errorf("failed to get credential options: %w", err)
}
// Enable client credentials if requested
if credentialOptions.EnableClientCredentials {
spinnerMessage := "Configuring client credentials for service principal"
pm.console.ShowSpinner(ctx, spinnerMessage, input.Step)
creds, err := pm.entraIdService.ResetPasswordCredentials(ctx, subscriptionId, servicePrincipal.AppId)
pm.console.StopSpinner(ctx, spinnerMessage, input.GetStepResultFormat(err))
if err != nil {
return result, fmt.Errorf("failed to reset password credentials: %w", err)
}
credentials = creds
}
// Enable federated credentials if requested
if credentialOptions.EnableFederatedCredentials {
createdCredentials, err := pm.entraIdService.ApplyFederatedCredentials(
ctx, subscriptionId,
servicePrincipal.AppId,
credentialOptions.FederatedCredentialOptions,
)
if err != nil {
return result, fmt.Errorf("failed to create federated credentials: %w", err)
}
for _, credential := range createdCredentials {
pm.console.MessageUxItem(
ctx,
&ux.DisplayedResource{
Type: fmt.Sprintf("Federated identity credential for %s", pm.ciProvider.Name()),
Name: fmt.Sprintf("subject %s", credential.Subject),
},
)
}
}
err = pm.ciProvider.configureConnection(
ctx,
gitRepoInfo,
infra.Options,
servicePrincipal,
credentialOptions,
credentials,
)
pm.console.StopSpinner(ctx, "", input.GetStepResultFormat(err))
if err != nil {
return result, err
}
// Adding environment.AzdInitialEnvironmentConfigName as a secret to the pipeline as the base configuration for
// whenever a new environment is created. This means loading the local environment config into a pipeline secret which
// azd will use to restore the the config on CI
localEnvConfig, err := json.Marshal(pm.env.Config.ResolvedRaw())
if err != nil {
return result, fmt.Errorf("failed to marshal environment config: %w", err)
}
defaultAzdSecrets := map[string]string{
environment.AzdInitialEnvironmentConfigName: string(localEnvConfig),
}
defaultAzdVariables := map[string]string{}
// If the user has set the resource group name as an environment variable, we need to pass it to the pipeline
// as this likely means rg-deployment
if rgGroup, exists := pm.env.LookupEnv(environment.ResourceGroupEnvVarName); exists {
defaultAzdVariables[environment.ResourceGroupEnvVarName] = rgGroup
}
// Merge azd default variables and secrets with the ones defined on azure.yaml
pm.configOptions.variables, pm.configOptions.secrets = mergeProjectVariablesAndSecrets(
pm.configOptions.projectVariables, pm.configOptions.projectSecrets,
defaultAzdVariables, defaultAzdSecrets, pm.env.Dotenv())
// resolve akvs secrets
// For each akvs in the secrets array:
// azd gets the value from Azure Key Vault and use it as a secret in the pipeline
for key, value := range pm.configOptions.secrets {
if !strings.HasPrefix(value, "akvs://") {
continue
}
kvSecret, err := pm.keyVaultService.SecretFromAkvs(ctx, value)
if err != nil {
return result, fmt.Errorf("failed to resolve akvs '%s': %w", key, err)
}
pm.configOptions.secrets[key] = kvSecret
}
// For each akvs in the variables array:
// azd must grant read access role to the pipelines's identity to read the akvs
displayMsg = "Assigning read access role for Key Vault to service principal"
pm.console.ShowSpinner(ctx, displayMsg, input.Step)
kvAccounts := make(map[string]struct{})
for key, value := range pm.configOptions.variables {
if !strings.HasPrefix(value, "akvs://") {
continue
}
akvs, err := keyvault.ParseAzureKeyVaultSecret(value)
if err != nil {
return result, fmt.Errorf("failed to parse akvs '%s': %w", key, err)
}
kvId := akvs.SubscriptionId + akvs.VaultName
if _, ok := kvAccounts[kvId]; ok {
// skip if already assigned role for this key vault
continue
}
// can't use keyvaultService.Get() because it requires the resource group name and we don't save it for akvs
allKvFromSub, err := pm.keyVaultService.ListSubscriptionVaults(ctx, akvs.SubscriptionId)
if err != nil {
return result, fmt.Errorf(
"assigning read access role for Key Vault to service principal: %w", err)
}
var vaultResourceId string
foundKeyVault := slices.ContainsFunc(allKvFromSub, func(kv keyvault.Vault) bool {
if kv.Name == akvs.VaultName {
vaultResourceId = kv.Id
return true
}
return false
})
if !foundKeyVault {
return result, fmt.Errorf(
"assigning read access role for Key Vault to service principal: "+
"key vault '%s' not found in subscription '%s'", akvs.VaultName, akvs.SubscriptionId)
}
// CreateRbac uses the azure-sdk RoleAssignmentsClient.Create() which creates or updates the role assignment
// We don't need to check if the role assignment already exists, the method will handle it.
err = pm.entraIdService.CreateRbac(
ctx, akvs.SubscriptionId, vaultResourceId, keyvault.RoleIdKeyVaultSecretsUser, *servicePrincipal.Id)
if err != nil {
return result, fmt.Errorf(
"assigning read access role for Key Vault to service principal: %w", err)
}
// save the kvId to avoid assigning the role multiple times for the same key vault
kvAccounts[kvId] = struct{}{}
}
pm.console.StopSpinner(ctx, displayMsg, input.GetStepResultFormat(err))
// config pipeline handles setting or creating the provider pipeline to be used
ciPipeline, err := pm.ciProvider.configurePipeline(ctx, gitRepoInfo, pm.configOptions)
if err != nil {
return result, 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 := pm.console.Confirm(ctx, input.ConsoleOptions{
Message: "Would you like to commit and push your local changes to start the configured CI pipeline?",
DefaultValue: true,
})
if err != nil {
return result, fmt.Errorf("prompting to push: %w", err)
}
// scm provider can prevent from pushing changes and/or use the
// interactive console for setting up any missing details.
// For example, GitHub provider would check if GH-actions are disabled.
if doPush {
preventPush, err := pm.scmProvider.preventGitPush(
ctx,
gitRepoInfo,
pm.args.PipelineRemoteName,
gitRepoInfo.branch)
if err != nil {
return result, fmt.Errorf("check git push prevent: %w", err)
}
// revert user's choice when prevent git push returns true
doPush = !preventPush
}
if doPush {
err = pm.pushGitRepo(ctx, gitRepoInfo, gitRepoInfo.branch)
if err != nil {
return result, fmt.Errorf("git push: %w", err)
}
// The spinner can't run during `pushing changes` the next UX messages are purely simulated
displayMsg := "Pushing changes"
pm.console.Message(ctx, "") // new line before the step
pm.console.ShowSpinner(ctx, displayMsg, input.Step)
pm.console.StopSpinner(ctx, displayMsg, input.GetStepResultFormat(err))
displayMsg = "Queuing pipeline"
pm.console.ShowSpinner(ctx, displayMsg, input.Step)
gitRepoInfo.pushStatus = true
pm.console.StopSpinner(ctx, displayMsg, input.GetStepResultFormat(err))
} else {
pm.console.Message(ctx,
fmt.Sprintf(
"To fully enable pipeline you need to push this repo to the upstream "+
"using 'git push --set-upstream %s %s'.\n",
pm.args.PipelineRemoteName,
gitRepoInfo.branch))
}
return &PipelineConfigResult{
RepositoryLink: gitRepoInfo.url,
PipelineLink: ciPipeline.url(),
}, nil
}