Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pipeline Config - Reuse previous service principal when available #2521

Merged
merged 8 commits into from Jul 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 3 additions & 4 deletions cli/azd/CHANGELOG.md
Expand Up @@ -2,16 +2,15 @@

## 1.1.1 (Unreleased)

### Bugs Fixed

- [[2569]](https://github.com/Azure/azure-dev/pull/2569) Fix `azd down` so it works after a failed `azd provision`

### Features Added

- [[2550]](https://github.com/Azure/azure-dev/pull/2550) Add `--preview` to `azd provision` to get the changes
- [[2521]](https://github.com/Azure/azure-dev/pull/2521) Support `--principal-id` param for azd pipeline config to reuse existing service principal
- [[2455]](https://github.com/Azure/azure-dev/pull/2455) Adds optional support for text templates in AKS k8s manifests

### Bugs Fixed

- [[2569]](https://github.com/Azure/azure-dev/pull/2569) Fix `azd down` so it works after a failed `azd provision`
- [[2367]](https://github.com/Azure/azure-dev/pull/2367) Don't fail AKS deployment for failed environment substitution

## 1.1.0 (2023-07-12)
Expand Down
1 change: 1 addition & 0 deletions cli/azd/cmd/container.go
Expand Up @@ -276,6 +276,7 @@ func registerCommonDependencies(container *ioc.NestedContainer) {
container.RegisterSingleton(account.NewSubscriptionsManager)
container.RegisterSingleton(account.NewSubscriptionCredentialProvider)
container.RegisterSingleton(azcli.NewManagedClustersService)
container.RegisterSingleton(azcli.NewAdService)
container.RegisterSingleton(azcli.NewContainerRegistryService)
container.RegisterSingleton(containerapps.NewContainerAppService)
container.RegisterSingleton(project.NewContainerHelper)
Expand Down
6 changes: 6 additions & 0 deletions cli/azd/cmd/pipeline.go
Expand Up @@ -30,6 +30,12 @@ type pipelineConfigFlags struct {
}

func (pc *pipelineConfigFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommandOptions) {
local.StringVar(
&pc.PipelineServicePrincipalId,
"principal-id",
"",
"The client id of the service principal to use to grant access to Azure resources as part of the pipeline.",
)
local.StringVar(
&pc.PipelineServicePrincipalName,
"principal-name",
Expand Down
1 change: 1 addition & 0 deletions cli/azd/cmd/testdata/TestUsage-azd-pipeline-config.snap
Expand Up @@ -13,6 +13,7 @@ Flags
--docs : Opens the documentation for azd pipeline config in your web browser.
-e, --environment string : The name of the environment to use.
-h, --help : Gets help for config.
--principal-id string : The client id of the service principal to use to grant access to Azure resources as part of the pipeline.
--principal-name string : The name of the service principal to use to grant access to Azure resources as part of the pipeline.
--principal-role stringArray : The roles to assign to the service principal. By default the service principal will be granted the Contributor and User Access Administrator roles.
--provider string : The pipeline provider to use (github for Github Actions and azdo for Azure Pipelines).
Expand Down
18 changes: 18 additions & 0 deletions cli/azd/pkg/graphsdk/application_request_builders.go
Expand Up @@ -84,6 +84,24 @@ func (c *ApplicationItemRequestBuilder) FederatedIdentityCredentialById(
return NewFederatedIdentityCredentialItemRequestBuilder(c.client, c.id, id)
}

func (c *ApplicationItemRequestBuilder) GetByAppId(ctx context.Context) (*Application, error) {
req, err := runtime.NewRequest(ctx, http.MethodGet, fmt.Sprintf("%s/applications(appId='%s')", c.client.host, c.id))
if err != nil {
return nil, fmt.Errorf("failed creating request: %w", err)
}

res, err := c.client.pipeline.Do(req)
if err != nil {
return nil, err
}

if !runtime.HasStatusCode(res, http.StatusOK) {
return nil, runtime.NewResponseError(res)
}

return httputil.ReadRawResponse[Application](res)
}

// Gets a Microsoft Graph Application for the specified application identifier
func (c *ApplicationItemRequestBuilder) Get(ctx context.Context) (*Application, error) {
req, err := runtime.NewRequest(ctx, http.MethodGet, fmt.Sprintf("%s/applications/%s", c.client.host, c.id))
Expand Down
111 changes: 96 additions & 15 deletions cli/azd/pkg/pipeline/pipeline_manager.go
Expand Up @@ -14,6 +14,7 @@ import (

"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"
Expand All @@ -29,9 +30,16 @@ import (

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"
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 (
Expand All @@ -40,6 +48,7 @@ var (
)

type PipelineManagerArgs struct {
PipelineServicePrincipalId string
PipelineServicePrincipalName string
PipelineRemoteName string
PipelineRoleNames []string
Expand All @@ -60,15 +69,15 @@ type PipelineManager struct {
args *PipelineManagerArgs
azdCtx *azdcontext.AzdContext
env *environment.Environment
azCli azcli.AzCli
adService azcli.AdService
gitCli git.GitCli
console input.Console
serviceLocator ioc.ServiceLocator
}

func NewPipelineManager(
ctx context.Context,
azCli azcli.AzCli,
adService azcli.AdService,
gitCli git.GitCli,
azdCtx *azdcontext.AzdContext,
env *environment.Environment,
Expand All @@ -80,7 +89,7 @@ func NewPipelineManager(
azdCtx: azdCtx,
env: env,
args: args,
azCli: azCli,
adService: adService,
gitCli: gitCli,
console: console,
serviceLocator: serviceLocator,
Expand Down Expand Up @@ -138,28 +147,100 @@ func (pm *PipelineManager) Configure(ctx context.Context) (result *PipelineConfi
return result, fmt.Errorf("ensuring git remote: %w", err)
}

// *********** Create or update Azure Principal ***********
if pm.args.PipelineServicePrincipalName == "" {
// This format matches what the `az` cli uses when a name is not provided, with the prefix
// changed from "az-cli" to "az-dev"
pm.args.PipelineServicePrincipalName = fmt.Sprintf("az-dev-%s", time.Now().UTC().Format("01-02-2006-15-04-05"))
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.",
)
}

// Existing Service Principal Lookup strategy
// 1. --principal-id
// 2. --principal-name
wbreza marked this conversation as resolved.
Show resolved Hide resolved
// 3. AZURE_PIPELINE_CLIENT_ID environment variable
// 4. Create new service principal with default naming convention
envClientId := pm.env.Getenv(AzurePipelineClientIdEnvVarName)
var appIdOrName string
var lookupKind servicePrincipalLookupKind
if pm.args.PipelineServicePrincipalId != "" {
appIdOrName = pm.args.PipelineServicePrincipalId
lookupKind = lookupKindPrincipalId
}
if appIdOrName == "" && pm.args.PipelineServicePrincipalName != "" {
appIdOrName = pm.args.PipelineServicePrincipalName
lookupKind = lookupKindPrincipleName
}
if appIdOrName == "" && envClientId != "" {
appIdOrName = envClientId
lookupKind = lookupKindEnvironmentVariable
}
wbreza marked this conversation as resolved.
Show resolved Hide resolved

var application *graphsdk.Application
var displayMsg, applicationName string

if appIdOrName != "" {
application, _ = pm.adService.GetServicePrincipal(ctx, pm.env.GetSubscriptionId(), appIdOrName)
if application != nil {
appIdOrName = *application.AppId
applicationName = application.DisplayName
} else {
applicationName = pm.args.PipelineServicePrincipalName
}
} else {
// Fall back to convention based naming
applicationName = fmt.Sprintf("az-dev-%s", time.Now().UTC().Format("01-02-2006-15-04-05"))
appIdOrName = applicationName
}

// If an explicit client id was specified but not found then fail
if application == nil && lookupKind == lookupKindPrincipalId {
return nil, fmt.Errorf(
"service principal with client id '%s' specified in '--principal-id' parameter was not found",
pm.args.PipelineServicePrincipalId,
)
}

// If an explicit client id was specified but not found then fail
if application == nil && lookupKind == lookupKindEnvironmentVariable {
return nil, fmt.Errorf(
"service principal with client id '%s' specified in environment variable '%s' was not found",
envClientId,
AzurePipelineClientIdEnvVarName,
)
}

if application == nil {
displayMsg = fmt.Sprintf("Creating service principal %s", applicationName)
} else {
displayMsg = fmt.Sprintf("Updating service principal %s (%s)", application.DisplayName, *application.AppId)
}

displayMsg := fmt.Sprintf("Creating or updating service principal %s", pm.args.PipelineServicePrincipalName)
pm.console.ShowSpinner(ctx, displayMsg, input.Step)
credentials, err := pm.azCli.CreateOrUpdateServicePrincipal(
clientId, credentials, err := pm.adService.CreateOrUpdateServicePrincipal(
ctx,
pm.env.GetSubscriptionId(),
pm.args.PipelineServicePrincipalName,
appIdOrName,
pm.args.PipelineRoleNames)

// Update new service principal to include client id
if application == nil && clientId != nil {
displayMsg += fmt.Sprintf(" (%s)", *clientId)
}
pm.console.StopSpinner(ctx, displayMsg, input.GetStepResultFormat(err))
if err != nil {
return result, fmt.Errorf("failed to create or update service principal: %w", err)
}

// Set in .env to be retrieved for any additional runs
if clientId != nil {
pm.env.DotenvSet(AzurePipelineClientIdEnvVarName, *clientId)
if err := pm.env.Save(); 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, pm.args.PipelineServicePrincipalName)
displayMsg = fmt.Sprintf("Configuring repository %s to use credentials for %s", repoSlug, applicationName)
pm.console.ShowSpinner(ctx, displayMsg, input.Step)

err = pm.ciProvider.configureConnection(
Expand Down
4 changes: 2 additions & 2 deletions cli/azd/pkg/pipeline/pipeline_manager_test.go
Expand Up @@ -14,10 +14,10 @@ import (
"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/osutil"
"github.com/azure/azure-dev/cli/azd/pkg/tools/azcli"
"github.com/azure/azure-dev/cli/azd/pkg/tools/git"
"github.com/azure/azure-dev/cli/azd/pkg/tools/github"
"github.com/azure/azure-dev/cli/azd/test/mocks"
"github.com/azure/azure-dev/cli/azd/test/mocks/mockazcli"
"github.com/stretchr/testify/assert"
)

Expand Down Expand Up @@ -424,7 +424,7 @@ func createPipelineManager(

return NewPipelineManager(
*mockContext.Context,
mockazcli.NewAzCliFromMockContext(mockContext),
azcli.NewAdService(mockContext.SubscriptionCredentialProvider, mockContext.HttpClient),
git.NewGitCli(mockContext.CommandRunner),
azdContext,
env,
Expand Down