cli/azd/pkg/pipeline/pipeline_manager.go (909 lines of code) (raw):

// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. package pipeline import ( "context" "encoding/json" "errors" "fmt" "html/template" "log" "os" "path/filepath" "slices" "strings" "time" "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/entraid" "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/graphsdk" "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/ioc" "github.com/azure/azure-dev/cli/azd/pkg/keyvault" "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/output/ux" "github.com/azure/azure-dev/cli/azd/pkg/project" "github.com/azure/azure-dev/cli/azd/pkg/tools" "github.com/azure/azure-dev/cli/azd/pkg/tools/git" "github.com/azure/azure-dev/cli/azd/resources" "github.com/google/uuid" "github.com/sethvargo/go-retry" ) type PipelineAuthType string // servicePrincipalLookupKind is the type of lookup to use when resolving the service principal. type servicePrincipalLookupKind string const ( AuthTypeFederated PipelineAuthType = "federated" AuthTypeClientCredentials PipelineAuthType = "client-credentials" lookupKindPrincipalId servicePrincipalLookupKind = "principal-id" lookupKindPrincipleName servicePrincipalLookupKind = "principal-name" lookupKindEnvironmentVariable servicePrincipalLookupKind = "environment-variable" AzurePipelineClientIdEnvVarName string = "AZURE_PIPELINE_CLIENT_ID" ) var ( ErrAuthNotSupported = errors.New("pipeline authentication configuration is not supported") DefaultRoleNames = []string{"Contributor", "User Access Administrator"} ) // PipelineManagerArgs represents the arguments passed to the pipeline manager from Azd CLI type PipelineManagerArgs struct { PipelineServicePrincipalId string PipelineServicePrincipalName string PipelineRemoteName string PipelineRoleNames []string PipelineProvider string PipelineAuthTypeName string ServiceManagementReference string } // CredentialOptions represents the options for configuring credentials for a pipeline. type CredentialOptions struct { EnableClientCredentials bool EnableFederatedCredentials bool FederatedCredentialOptions []*graphsdk.FederatedIdentityCredential } type PipelineConfigResult struct { RepositoryLink string PipelineLink string } // PipelineManager takes care of setting up the scm and pipeline. // The manager allows to use and test scm providers without a cobra command. type PipelineManager struct { envManager environment.Manager scmProvider ScmProvider ciProvider CiProvider args *PipelineManagerArgs azdCtx *azdcontext.AzdContext env *environment.Environment entraIdService entraid.EntraIdService gitCli *git.Cli console input.Console serviceLocator ioc.ServiceLocator importManager *project.ImportManager configOptions *configurePipelineOptions infra *project.Infra userConfigManager config.UserConfigManager keyVaultService keyvault.KeyVaultService } func NewPipelineManager( ctx context.Context, envManager environment.Manager, entraIdService entraid.EntraIdService, gitCli *git.Cli, azdCtx *azdcontext.AzdContext, env *environment.Environment, console input.Console, args *PipelineManagerArgs, serviceLocator ioc.ServiceLocator, importManager *project.ImportManager, userConfigManager config.UserConfigManager, keyVaultService keyvault.KeyVaultService, ) (*PipelineManager, error) { pipelineProvider := &PipelineManager{ azdCtx: azdCtx, envManager: envManager, env: env, args: args, entraIdService: entraIdService, gitCli: gitCli, console: console, serviceLocator: serviceLocator, importManager: importManager, userConfigManager: userConfigManager, keyVaultService: keyVaultService, } // check that scm and ci providers are set if err := pipelineProvider.initialize(ctx, args.PipelineProvider); err != nil { return nil, err } return pipelineProvider, nil } func (pm *PipelineManager) CiProviderName() string { return pm.ciProvider.Name() } func (pm *PipelineManager) ScmProviderName() string { return pm.scmProvider.Name() } type servicePrincipalResult struct { appIdOrName string applicationName string lookupKind servicePrincipalLookupKind servicePrincipal *graphsdk.ServicePrincipal } func servicePrincipal( ctx context.Context, envClientId, subscriptionId string, args *PipelineManagerArgs, entraIdService entraid.EntraIdService) (*servicePrincipalResult, error) { // Existing Service Principal Lookup strategy // 1. --principal-id // 2. --principal-name // 3. AZURE_PIPELINE_CLIENT_ID environment variable // 4. Create new service principal with default naming convention var appIdOrName, applicationName string var lookupKind servicePrincipalLookupKind if args.PipelineServicePrincipalId != "" { appIdOrName = args.PipelineServicePrincipalId lookupKind = lookupKindPrincipalId } else if args.PipelineServicePrincipalName != "" { appIdOrName = args.PipelineServicePrincipalName lookupKind = lookupKindPrincipleName } else if envClientId != "" { appIdOrName = envClientId lookupKind = lookupKindEnvironmentVariable } if appIdOrName == "" { // Fall back to convention based naming applicationName = fmt.Sprintf("az-dev-%s", time.Now().UTC().Format("01-02-2006-15-04-05")) return &servicePrincipalResult{ appIdOrName: applicationName, applicationName: applicationName, servicePrincipal: nil, lookupKind: lookupKind, }, nil } servicePrincipal, err := entraIdService.GetServicePrincipal(ctx, subscriptionId, appIdOrName) if err != nil { // If an explicit client id was specified but not found then fail if lookupKind == lookupKindPrincipalId { return nil, fmt.Errorf( "service principal with client id '%s' specified in '--principal-id' parameter was not found. Error: %w", args.PipelineServicePrincipalId, err, ) } // If an explicit client id was specified but not found then fail if lookupKind == lookupKindEnvironmentVariable { return nil, fmt.Errorf( "service principal with client id '%s' specified in environment variable '%s' was not found Error: %w", envClientId, AzurePipelineClientIdEnvVarName, err, ) } // Return the name of the service principal that was not found. It will be use to create a new one. return &servicePrincipalResult{ appIdOrName: appIdOrName, applicationName: appIdOrName, servicePrincipal: servicePrincipal, lookupKind: lookupKind, }, nil } return &servicePrincipalResult{ appIdOrName: servicePrincipal.AppId, applicationName: servicePrincipal.DisplayName, servicePrincipal: servicePrincipal, lookupKind: lookupKind, }, nil } // Configure is the main function from the pipeline manager which takes care // of creating or setting up the git project, the ci pipeline and the Azure connection. 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 } // requiredTools get all the provider's required tools. func (pm *PipelineManager) requiredTools(ctx context.Context) ([]tools.ExternalTool, error) { scmReqTools, err := pm.scmProvider.requiredTools(ctx) if err != nil { return nil, err } ciReqTools, err := pm.ciProvider.requiredTools(ctx) if err != nil { return nil, err } reqTools := append(scmReqTools, ciReqTools...) return reqTools, nil } // preConfigureCheck invoke the validations from each provider. // the returned configurationWasUpdated indicates if the current settings were updated during the check, // for example, if Azdo prompt for a PAT or OrgName to the user and updated. func (pm *PipelineManager) preConfigureCheck(ctx context.Context, infraOptions provisioning.Options, projectPath string) ( configurationWasUpdated bool, err error) { // Validate the authentication types // auth-type argument must either be an empty string or one of the following values. validAuthTypes := []string{string(AuthTypeFederated), string(AuthTypeClientCredentials)} pipelineAuthType := strings.TrimSpace(pm.args.PipelineAuthTypeName) if pipelineAuthType != "" && !slices.Contains(validAuthTypes, pipelineAuthType) { return configurationWasUpdated, fmt.Errorf( "pipeline authentication type '%s' is not valid. Valid authentication types are '%s'", pm.args.PipelineAuthTypeName, strings.Join(validAuthTypes, ", "), ) } ciConfigurationWasUpdated, err := pm.ciProvider.preConfigureCheck( ctx, *pm.args, infraOptions, projectPath) if err != nil { return configurationWasUpdated, fmt.Errorf("pre-config check error from %s provider: %w", pm.ciProvider.Name(), err) } scmConfigurationWasUpdated, err := pm.scmProvider.preConfigureCheck( ctx, *pm.args, infraOptions, projectPath) if err != nil { return configurationWasUpdated, fmt.Errorf("pre-config check error from %s provider: %w", pm.scmProvider.Name(), err) } configurationWasUpdated = ciConfigurationWasUpdated || scmConfigurationWasUpdated return configurationWasUpdated, nil } // ensureRemote get the git project details from a path and remote name using the scm provider. func (pm *PipelineManager) ensureRemote( ctx context.Context, repositoryPath string, remoteName string, ) (*gitRepositoryDetails, error) { remoteUrl, err := pm.gitCli.GetRemoteUrl(ctx, repositoryPath, remoteName) if err != nil { return nil, fmt.Errorf("failed to get remote url: %w", err) } currentBranch, err := pm.gitCli.GetCurrentBranch(ctx, repositoryPath) if err != nil { return nil, fmt.Errorf("getting current branch: %w", err) } // each provider knows how to extract the Owner and repo name from a remoteUrl gitRepoDetails, err := pm.scmProvider.gitRepoDetails(ctx, remoteUrl) if err != nil { return nil, err } gitRepoDetails.gitProjectPath = pm.azdCtx.ProjectDirectory() gitRepoDetails.branch = currentBranch return gitRepoDetails, nil } // getGitRepoDetails get the details about a git project using the azd context to discover the project path. func (pm *PipelineManager) getGitRepoDetails(ctx context.Context) (*gitRepositoryDetails, error) { repoPath := pm.azdCtx.ProjectDirectory() checkGitMessage := "Checking current directory for Git repository" var err error pm.console.ShowSpinner(ctx, checkGitMessage, input.Step) defer pm.console.StopSpinner(ctx, checkGitMessage, input.GetStepResultFormat(err)) // the warningCount makes sure we only ever show one single warning for the repo missing setup // if there is no git repo, the warning is for no git repo detected, but if there is a git repo // and the remote is not setup, the warning is for the remote. But we don't want double warning // if git repo and remote are missing. var warningCount int for { repoRemoteDetails, err := pm.ensureRemote(ctx, repoPath, pm.args.PipelineRemoteName) switch { case errors.Is(err, git.ErrNotRepository): // remove spinner and display warning pm.console.StopSpinner(ctx, checkGitMessage, input.StepWarning) pm.console.MessageUxItem(ctx, &ux.WarningMessage{ Description: "No GitHub repository detected.\n", HidePrefix: true, }) warningCount++ // Offer the user a chance to init a new repository if one does not exist. initRepo, err := pm.console.Confirm(ctx, input.ConsoleOptions{ Message: "Do you want to initialize a new Git repository in this directory?", DefaultValue: true, }) if err != nil { return nil, fmt.Errorf("prompting for git init: %w", err) } if !initRepo { return nil, errors.New("confirmation declined") } initRepoMsg := "Creating Git repository locally." pm.console.Message(ctx, "") pm.console.ShowSpinner(ctx, initRepoMsg, input.Step) if err := pm.gitCli.InitRepo(ctx, repoPath); err != nil { pm.console.StopSpinner(ctx, initRepoMsg, input.StepFailed) return nil, fmt.Errorf("initializing repository: %w", err) } pm.console.StopSpinner(ctx, initRepoMsg, input.StepDone) pm.console.Message(ctx, "") // any next line should be one line apart from the step finish // Recovered from this error, try again continue case errors.Is(err, git.ErrNoSuchRemote): // Show warning only if no other warning was shown before. if warningCount == 0 { pm.console.StopSpinner(ctx, checkGitMessage, input.StepWarning) pm.console.MessageUxItem(ctx, &ux.WarningMessage{ Description: fmt.Sprintf("Remote \"%s\" is not configured.\n", pm.args.PipelineRemoteName), HidePrefix: true, }) warningCount++ } // the scm provider returns the repo url that is used as git remote remoteUrl, err := pm.scmProvider.configureGitRemote(ctx, repoPath, pm.args.PipelineRemoteName) if err != nil { return nil, err } // set the git remote for local git project if err := pm.gitCli.AddRemote(ctx, repoPath, pm.args.PipelineRemoteName, remoteUrl); err != nil { return nil, fmt.Errorf("initializing repository: %w", err) } pm.console.Message(ctx, "") // any next line should be one line apart from the step finish continue case err != nil: return nil, err default: return repoRemoteDetails, nil } } } // pushGitRepo commit all changes in the git project and push it to upstream. func (pm *PipelineManager) pushGitRepo(ctx context.Context, gitRepoInfo *gitRepositoryDetails, currentBranch string) error { if err := pm.gitCli.AddFile(ctx, pm.azdCtx.ProjectDirectory(), "."); err != nil { return fmt.Errorf("adding files: %w", err) } if err := pm.gitCli.Commit(ctx, pm.azdCtx.ProjectDirectory(), "Configure Azure Developer Pipeline"); err != nil { return fmt.Errorf("commit changes: %w", err) } // If user has a git credential manager with some cached credentials // and the credentials are rotated, the push operation will fail and the credential manager would remove the cache // Then, on the next intent to push code, there should be a prompt for credentials. // Due to this, we use retry here, so we can run the second intent to prompt for credentials one more time return retry.Do(ctx, retry.WithMaxRetries(3, retry.NewConstant(100*time.Millisecond)), func(ctx context.Context) error { if err := pm.scmProvider.GitPush( ctx, gitRepoInfo, pm.args.PipelineRemoteName, currentBranch); err != nil { return retry.RetryableError(fmt.Errorf("pushing changes: %w", err)) } return nil }) } // resolveProviderAndDetermine resolves the pipeline provider based on project configuration and environment, // or determines it if not already set. func (pm *PipelineManager) resolveProviderAndDetermine( ctx context.Context, projectPath, repoRoot string) (ciProviderType, error) { log.Printf("Loading project configuration from: %s", projectPath) prjConfig, err := project.Load(ctx, projectPath) if err != nil { return "", fmt.Errorf("Loading project configuration: %w", err) } log.Printf("Loaded project configuration: %+v", prjConfig) // 1) Check if provider is set on azure.yaml, it should override the `lastUsedProvider` if prjConfig.Pipeline.Provider != "" { log.Printf("Provider set in project configuration: %s", prjConfig.Pipeline.Provider) return toCiProviderType(prjConfig.Pipeline.Provider) } // 2) Check if there is a persisted value from a previous run in the environment if lastUsedProvider, configExists := pm.env.LookupEnv(envPersistedKey); configExists { log.Printf("Using persisted provider from environment: %s", lastUsedProvider) return toCiProviderType(lastUsedProvider) } // 3) No config on azure.yaml or from previous run, so use the determineProvider logic log.Printf("No provider set in project configuration or environment. Determining provider based on repository.") return pm.determineProvider(ctx, repoRoot) } // initialize sets up the SCM and CI providers based on the provided override // or the detected configuration in the repository. // Logic: // - If the user specifies a provider through the arguments, that provider is used. // - If no provider is specified: // - If both GitHub and Azure DevOps configurations are detected, prompt the user to choose which one to use. // - If only GitHub configuration is found, use GitHub Actions. // - If only Azure DevOps configuration is found, use Azure DevOps. // - If no configuration is found, prompt the user to select which one to set up. // - Default to GitHub Actions if no provider is specified or selected. // - Prompt the user to confirm adding the azure-dev file if it’s missing, and inform them where the file is created. // - The provider is persisted in the environment so the next time the function is run, // the same provider is used directly, unless the overrideProvider is used to change the last used configuration. func (pm *PipelineManager) initialize(ctx context.Context, override string) error { projectDir := pm.azdCtx.ProjectDirectory() projectPath := pm.azdCtx.ProjectPath() repoRoot, err := pm.gitCli.GetRepoRoot(ctx, projectDir) if err != nil { repoRoot = projectDir log.Printf("using project root as repo root, since git repo wasn't available: %s", err) } // Use the provided pipeline provider if specified, otherwise resolve or determine the provider var pipelineProvider ciProviderType if override != "" { p, err := toCiProviderType(strings.ToLower(override)) if err != nil { return err } pipelineProvider = p } else { p, err := pm.resolveProviderAndDetermine(ctx, projectPath, repoRoot) if err != nil { return err } pipelineProvider = p } prjConfig, err := project.Load(ctx, projectPath) if err != nil { return fmt.Errorf("Loading project configuration: %w", err) } infra, err := pm.importManager.ProjectInfrastructure(ctx, prjConfig) if err != nil { return err } defer func() { _ = infra.Cleanup() }() pm.infra = infra hasAppHost := pm.importManager.HasAppHost(ctx, prjConfig) infraProvider, err := toInfraProviderType(string(pm.infra.Options.Provider)) if err != nil { return err } var requiredAlphaFeatures []string if infra.IsCompose { requiredAlphaFeatures = append(requiredAlphaFeatures, "compose") } // There are 2 possible options, for the git branch name, when running azd pipeline config: // - There is not a git repo, so the branch name is empty. In this case, we default to "main". // - There is a git repo and we can get the name of the current branch. branchName := "main" customBranchName, err := pm.gitCli.GetCurrentBranch(ctx, repoRoot) // It is fine if we can't get the branch name, we will default to "main" if err == nil { branchName = customBranchName } // default auth type for all providers authType := AuthTypeFederated if pm.args.PipelineAuthTypeName == "" && infraProvider == infraProviderTerraform { // empty arg for auth and terraform forces client credentials, otherwise, it will be federated authType = AuthTypeClientCredentials } // Check and prompt for missing CI/CD files if err := pm.checkAndPromptForProviderFiles( ctx, projectProperties{ CiProvider: pipelineProvider, RepoRoot: repoRoot, InfraProvider: infraProvider, HasAppHost: hasAppHost, BranchName: branchName, AuthType: authType, Variables: prjConfig.Pipeline.Variables, Secrets: prjConfig.Pipeline.Secrets, RequiredAlphaFeatures: requiredAlphaFeatures, }); err != nil { return err } // Save the provider to the environment if err := pm.savePipelineProviderToEnv(ctx, pipelineProvider, pm.env); err != nil { return err } var scmProviderName, ciProviderName, displayName string if pipelineProvider == ciProviderAzureDevOps { scmProviderName = string(ciProviderAzureDevOps) ciProviderName = scmProviderName displayName = azdoDisplayName } else { scmProviderName = string(ciProviderGitHubActions) ciProviderName = scmProviderName displayName = gitHubDisplayName } log.Printf("Using pipeline provider: %s", output.WithHighLightFormat(displayName)) var scmProvider ScmProvider if err := pm.serviceLocator.ResolveNamed(scmProviderName+"-scm", &scmProvider); err != nil { return fmt.Errorf("resolving scm provider: %w", err) } var ciProvider CiProvider if err := pm.serviceLocator.ResolveNamed(ciProviderName+"-ci", &ciProvider); err != nil { return fmt.Errorf("resolving ci provider: %w", err) } pm.scmProvider = scmProvider pm.ciProvider = ciProvider pm.configOptions = &configurePipelineOptions{ projectVariables: slices.Clone(prjConfig.Pipeline.Variables), projectSecrets: slices.Clone(prjConfig.Pipeline.Secrets), provisioningProvider: &pm.infra.Options, } return nil } func (pm *PipelineManager) savePipelineProviderToEnv( ctx context.Context, provider ciProviderType, env *environment.Environment, ) error { env.DotenvSet(envPersistedKey, string(provider)) err := pm.envManager.Save(ctx, env) if err != nil { return err } return nil } // checkAndPromptForProviderFiles checks if the provider files are present and prompts the user to create them if not. func (pm *PipelineManager) checkAndPromptForProviderFiles(ctx context.Context, props projectProperties) error { log.Printf("Checking for provider files for: %s", props.CiProvider) if !hasPipelineFile(props.CiProvider, props.RepoRoot) { log.Printf("%s YAML not found, prompting for creation", props.CiProvider) if err := pm.promptForCiFiles(ctx, props); err != nil { log.Println("Error prompting for CI files:", err) return err } log.Println("Prompt for CI files completed successfully.") } var dirPaths []string for _, dir := range pipelineProviderFiles[props.CiProvider].PipelineDirectories { dirPaths = append(dirPaths, filepath.Join(props.RepoRoot, dir)) } for _, dirPath := range dirPaths { log.Printf("Checking if directory %s is empty", dirPath) isEmpty, err := osutil.IsDirEmpty(dirPath, true) if err != nil { log.Println("Error checking if directory is empty:", err) return fmt.Errorf("error checking if directory is empty: %w", err) } if !isEmpty { log.Printf("Provider files are present in directory: %s", dirPath) return nil } } message := fmt.Sprintf( "%s provider selected, but no pipeline files were found in any expected directories:\n%s\n"+ "Please add pipeline files.", pipelineProviderFiles[props.CiProvider].DisplayName, strings.Join(pipelineProviderFiles[props.CiProvider].PipelineDirectories, "\n")) if props.CiProvider == ciProviderAzureDevOps { message = fmt.Sprintf( "%s provider selected, but no pipeline files were found in any expected directories:\n%s\n"+ "Please add pipeline files and try again.", pipelineProviderFiles[props.CiProvider].DisplayName, strings.Join(pipelineProviderFiles[props.CiProvider].PipelineDirectories, "\n")) log.Println("Error:", message) return errors.New(message) } log.Println("Info:", message) pm.console.Message(ctx, message) pm.console.Message(ctx, "") log.Printf("Provider files are not present for: %s", props.CiProvider) return nil } // promptForCiFiles creates CI/CD files for the specified provider, confirming with the user before creation. func (pm *PipelineManager) promptForCiFiles(ctx context.Context, props projectProperties) error { var dirPaths []string for _, dir := range pipelineProviderFiles[props.CiProvider].PipelineDirectories { dirPaths = append(dirPaths, filepath.Join(props.RepoRoot, dir)) } var defaultFilePath string for _, dirPath := range dirPaths { defaultFilePath = filepath.Join(dirPath, pipelineProviderFiles[props.CiProvider].DefaultFile) if osutil.DirExists(dirPath) || osutil.FileExists(defaultFilePath) { break } } log.Printf("Directory paths: %v", dirPaths) log.Printf("Default YAML path: %s", defaultFilePath) // Confirm with the user before adding the default file pm.console.Message(ctx, "") pm.console.Message( ctx, fmt.Sprintf( "The default %s file, which contains a basic workflow to help you get started, is missing from your project.", output.WithHighLightFormat("azure-dev.yml"), ), ) pm.console.Message(ctx, "") // Prompt the user for confirmation confirm, err := pm.console.Confirm(ctx, input.ConsoleOptions{ Message: "Would you like to add it now?", DefaultValue: true, }) if err != nil { return fmt.Errorf("prompting to create file: %w", err) } pm.console.Message(ctx, "") if confirm { log.Printf("Confirmed creation of %s file at %s", filepath.Base(defaultFilePath), dirPaths) created := false for _, dirPath := range dirPaths { if !osutil.DirExists(dirPath) { log.Printf("Creating directory %s", dirPath) if err := os.MkdirAll(dirPath, os.ModePerm); err != nil { return fmt.Errorf("creating directory %s: %w", dirPath, err) } created = true } if !osutil.FileExists(filepath.Join(dirPath, pipelineProviderFiles[props.CiProvider].DefaultFile)) { if err := generatePipelineDefinition(filepath.Join(dirPath, pipelineProviderFiles[props.CiProvider].DefaultFile), props); err != nil { return err } pm.console.Message(ctx, fmt.Sprintf( "The %s file has been created at %s. You can use it as-is or modify it to suit your needs.", output.WithHighLightFormat(filepath.Base(defaultFilePath)), output.WithHighLightFormat(filepath.Join(dirPath, pipelineProviderFiles[props.CiProvider].DefaultFile))), ) pm.console.Message(ctx, "") created = true } if created { break } } if !created { log.Printf("User declined creation of %s file at %s", filepath.Base(defaultFilePath), dirPaths) } return nil } log.Printf("User declined creation of %s file at %s", filepath.Base(defaultFilePath), dirPaths) return nil } func generatePipelineDefinition(path string, props projectProperties) error { embedFilePath := fmt.Sprintf("pipeline/.%s/azure-dev.ymlt", props.CiProvider) tmpl, err := template. New("azure-dev.yml"). Option("missingkey=error"). ParseFS(resources.PipelineFiles, embedFilePath) if err != nil { return fmt.Errorf("parsing embedded file %s: %w", embedFilePath, err) } builder := strings.Builder{} err = tmpl.Execute(&builder, struct { BranchName string FedCredLogIn bool InstallDotNetForAspire bool Variables []string Secrets []string AlphaFeatures []string }{ BranchName: props.BranchName, FedCredLogIn: props.AuthType == AuthTypeFederated, InstallDotNetForAspire: props.HasAppHost, Variables: props.Variables, Secrets: props.Secrets, AlphaFeatures: props.RequiredAlphaFeatures, }) if err != nil { return fmt.Errorf("executing template: %w", err) } contents := []byte(builder.String()) log.Printf("Creating file %s", path) if err := os.WriteFile(path, contents, osutil.PermissionFile); err != nil { return fmt.Errorf("creating file %s: %w", path, err) } return nil } // hasPipelineFile checks if any pipeline files exist for the given provider in the specified repository root. func hasPipelineFile(provider ciProviderType, repoRoot string) bool { for _, path := range pipelineProviderFiles[provider].Files { fullPath := filepath.Join(repoRoot, path) if osutil.FileExists(fullPath) { return true } } return false } func (pm *PipelineManager) determineProvider(ctx context.Context, repoRoot string) (ciProviderType, error) { log.Printf("Checking for CI/CD YAML files in the repository root: %s", repoRoot) // Check for existence of official YAML files in the repo root hasGitHubYml := hasPipelineFile(ciProviderGitHubActions, repoRoot) hasAzDevOpsYml := hasPipelineFile(ciProviderAzureDevOps, repoRoot) log.Printf("GitHub Actions YAML exists: %v", hasGitHubYml) log.Printf("Azure DevOps YAML exists: %v", hasAzDevOpsYml) switch { case (!hasGitHubYml && !hasAzDevOpsYml) || (hasGitHubYml && hasAzDevOpsYml): // No official YAML files found for either provider or both are found log.Printf("Neither or both YAML files found. Prompting user for provider selection.") return pm.promptForProvider(ctx) case hasGitHubYml && !hasAzDevOpsYml: // GitHub Actions YAML found, Azure DevOps YAML not found log.Printf("Only GitHub Actions YAML found. Selecting GitHub Actions as the provider.") return ciProviderGitHubActions, nil case hasAzDevOpsYml && !hasGitHubYml: // Azure DevOps YAML found, GitHub Actions YAML not found log.Printf("Only Azure DevOps YAML found. Selecting Azure DevOps as the provider.") return ciProviderAzureDevOps, nil default: // Default to GitHub Actions if no provider is specified log.Printf("Defaulting to GitHub Actions as the provider.") return ciProviderGitHubActions, nil } } // promptForProvider prompts the user to select a CI/CD provider. func (pm *PipelineManager) promptForProvider(ctx context.Context) (ciProviderType, error) { log.Printf("Prompting user to select a CI/CD provider.") pm.console.Message(ctx, "") choice, err := pm.console.Select(ctx, input.ConsoleOptions{ Message: "Select a provider:", Options: []string{gitHubDisplayName, azdoDisplayName}, }) if err != nil { return "", fmt.Errorf("prompting for CI/CD provider: %w", err) } log.Printf("User selected choice: %d", choice) if choice == 0 { return ciProviderGitHubActions, nil } else if choice == 1 { return ciProviderAzureDevOps, nil } return "", nil // This case should never occur with the current options. } // resolveSmr resolves the service management reference from the user, project, or environment configuration. func resolveSmr(smrArg string, projectConfig config.Config, userConfig config.Config) *string { if smrArg != "" { // If the user has provided a value for the --applicationServiceManagementReference flag, use it return &smrArg } smrFromConfig := func(config config.Config) *string { if smr, ok := config.GetString("pipeline.config.applicationServiceManagementReference"); ok { return &smr } return nil } // per environment configuration if smr := smrFromConfig(projectConfig); smr != nil { return smr } // per user configuration if smr := smrFromConfig(userConfig); smr != nil { return smr } // no smr configuration return nil }