Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions cli/azd/pkg/pipeline/azdo_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,9 @@ var ErrSSHNotSupported = errors.New("ssh git remote is not supported. " +
type azdoRemote struct {
Project string
RepositoryName string
// IsNonStandardHost indicates if the remote URL is from a non-standard Azure DevOps host
// (e.g., self-hosted or on-premises installations)
IsNonStandardHost bool
}

// parseAzDoRemote extracts the organization, project and repository name from an Azure DevOps remote url
Expand All @@ -435,13 +438,13 @@ type azdoRemote struct {
// - git@ssh.dev.azure.com:v[1-3]/[user|org]/[project]/[repo]
// - git@vs-ssh.visualstudio.com:v[1-3]/[user|org]/[project]/[repo]
// - git@ssh.visualstudio.com:v[1-3]/[user|org]/[project]/[repo]
// - Self-hosted Azure DevOps Server: https://[custom-domain]/[collection]/[project]/_git/[repo]
func parseAzDoRemote(remoteUrl string) (*azdoRemote, error) {
// Initialize the azdoRemote struct
azdoRemote := &azdoRemote{}

if !strings.Contains(remoteUrl, "visualstudio.com") && !strings.Contains(remoteUrl, "dev.azure.com") {
return nil, fmt.Errorf("%w: %s", ErrRemoteHostIsNotAzDo, remoteUrl)
}
// Check if this is a standard Azure DevOps host
isStandardHost := strings.Contains(remoteUrl, "visualstudio.com") || strings.Contains(remoteUrl, "dev.azure.com")

if strings.Contains(remoteUrl, "/_git/") {
// applies to http or https
Expand All @@ -458,15 +461,21 @@ func parseAzDoRemote(remoteUrl string) (*azdoRemote, error) {

azdoRemote.Project = parts[0][projectNameStart+1:]
azdoRemote.RepositoryName = parts[1]
azdoRemote.IsNonStandardHost = !isStandardHost
return azdoRemote, nil
}

if strings.Contains(remoteUrl, "git@") {
// applies to git@ -> project and repo always in the last two parts
if !isStandardHost {
// For non-standard hosts with git@, we cannot reliably parse the URL
return nil, fmt.Errorf("%w: %s", ErrRemoteHostIsNotAzDo, remoteUrl)
}
parts := strings.Split(remoteUrl, "/")
partsLen := len(parts)
azdoRemote.Project = parts[partsLen-2]
azdoRemote.RepositoryName = parts[partsLen-1]
azdoRemote.IsNonStandardHost = false
return azdoRemote, nil
}

Expand Down Expand Up @@ -512,6 +521,27 @@ func (p *AzdoScmProvider) gitRepoDetails(ctx context.Context, remoteUrl string)
return nil, fmt.Errorf("parsing Azure DevOps remote url: %s: %w", remoteUrl, err)
}

// If this is a non-standard host (e.g., self-hosted Azure DevOps Server),
// prompt the user to confirm this is indeed an Azure DevOps remote
if azdoRemote.IsNonStandardHost {
confirmed, err := p.console.Confirm(ctx, input.ConsoleOptions{
Message: fmt.Sprintf(
"The remote URL '%s' does not appear to be a standard Azure DevOps host. "+
"Is this a self-hosted Azure DevOps Server or Azure DevOps Services instance?",
remoteUrl),
DefaultValue: false,
})
if err != nil {
return nil, fmt.Errorf("prompting for remote confirmation: %w", err)
}
if !confirmed {
return nil, fmt.Errorf(
"remote URL not confirmed as Azure DevOps: %s. "+
"Please use 'azd pipeline config' to configure a different remote",
remoteUrl)
}
}

repoDetails.projectName = azdoRemote.Project
p.env.DotenvSet(azdo.AzDoEnvironmentProjectName, repoDetails.projectName)
repoDetails.repoName = azdoRemote.RepositoryName
Expand Down
97 changes: 83 additions & 14 deletions cli/azd/pkg/pipeline/azdo_provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package pipeline
import (
"context"
"errors"
"strings"
"testing"

"github.com/azure/azure-dev/cli/azd/pkg/azdo"
Expand Down Expand Up @@ -67,6 +68,28 @@ func Test_azdo_provider_getRepoDetails(t *testing.T) {
require.Error(t, e, ErrRemoteHostIsNotAzDo)
require.EqualValues(t, (*gitRepositoryDetails)(nil), details)
})

t.Run("self-hosted Azure DevOps Server remote - user rejects", func(t *testing.T) {
//arrange
testConsole := mockinput.NewMockConsole()
testConsole.WhenConfirm(func(options input.ConsoleOptions) bool {
return strings.Contains(options.Message, "does not appear to be a standard Azure DevOps host")
}).Respond(false)

provider := &AzdoScmProvider{
env: environment.New("test"),
console: testConsole,
}
ctx := context.Background()

//act
details, e := provider.gitRepoDetails(ctx, "https://devops.example.com/Collection/Project/_git/Repo")

//asserts
require.Error(t, e)
require.Contains(t, e.Error(), "not confirmed as Azure DevOps")
require.Nil(t, details)
})
}

func Test_azdo_scm_provider_preConfigureCheck(t *testing.T) {
Expand Down Expand Up @@ -220,8 +243,9 @@ func Test_parseAzDoRemote(t *testing.T) {
t.Run("valid HTTPS remote", func(t *testing.T) {
remoteUrl := "https://dev.azure.com/org/project/_git/repo"
expected := &azdoRemote{
Project: "project",
RepositoryName: "repo",
Project: "project",
RepositoryName: "repo",
IsNonStandardHost: false,
}

result, err := parseAzDoRemote(remoteUrl)
Expand All @@ -235,8 +259,9 @@ func Test_parseAzDoRemote(t *testing.T) {
t.Run("valid user HTTPS remote", func(t *testing.T) {
remoteUrl := "https://user@visualstudio.com/org/project/_git/repo"
expected := &azdoRemote{
Project: "project",
RepositoryName: "repo",
Project: "project",
RepositoryName: "repo",
IsNonStandardHost: false,
}

result, err := parseAzDoRemote(remoteUrl)
Expand All @@ -250,8 +275,9 @@ func Test_parseAzDoRemote(t *testing.T) {
t.Run("valid legacy HTTPS remote", func(t *testing.T) {
remoteUrl := "https://visualstudio.com/org/project/_git/repo"
expected := &azdoRemote{
Project: "project",
RepositoryName: "repo",
Project: "project",
RepositoryName: "repo",
IsNonStandardHost: false,
}

result, err := parseAzDoRemote(remoteUrl)
Expand All @@ -263,8 +289,9 @@ func Test_parseAzDoRemote(t *testing.T) {
t.Run("valid legacy HTTPS remote with org", func(t *testing.T) {
remoteUrl := "https://org.visualstudio.com/org/project/_git/repo"
expected := &azdoRemote{
Project: "project",
RepositoryName: "repo",
Project: "project",
RepositoryName: "repo",
IsNonStandardHost: false,
}

result, err := parseAzDoRemote(remoteUrl)
Expand All @@ -280,8 +307,9 @@ func Test_parseAzDoRemote(t *testing.T) {
t.Run("valid SSH remote", func(t *testing.T) {
remoteUrl := "git@ssh.dev.azure.com:v3/org/project/repo"
expected := &azdoRemote{
Project: "project",
RepositoryName: "repo",
Project: "project",
RepositoryName: "repo",
IsNonStandardHost: false,
}

result, err := parseAzDoRemote(remoteUrl)
Expand All @@ -293,8 +321,9 @@ func Test_parseAzDoRemote(t *testing.T) {
t.Run("valid legacy SSH remote", func(t *testing.T) {
remoteUrl := "git@vs-ssh.visualstudio.com:v3/org/project/repo"
expected := &azdoRemote{
Project: "project",
RepositoryName: "repo",
Project: "project",
RepositoryName: "repo",
IsNonStandardHost: false,
}

result, err := parseAzDoRemote(remoteUrl)
Expand All @@ -306,8 +335,9 @@ func Test_parseAzDoRemote(t *testing.T) {
t.Run("valid legacy SSH remote", func(t *testing.T) {
remoteUrl := "git@ssh.visualstudio.com:v3/org/project/repo"
expected := &azdoRemote{
Project: "project",
RepositoryName: "repo",
Project: "project",
RepositoryName: "repo",
IsNonStandardHost: false,
}

result, err := parseAzDoRemote(remoteUrl)
Expand All @@ -316,6 +346,45 @@ func Test_parseAzDoRemote(t *testing.T) {
require.Equal(t, expected, result)
})

// Self-hosted Azure DevOps Server
t.Run("valid self-hosted Azure DevOps Server HTTPS remote", func(t *testing.T) {
remoteUrl := "https://devops1.mydomain.com/ABC/MyProject/_git/MyProject"
expected := &azdoRemote{
Project: "MyProject",
RepositoryName: "MyProject",
IsNonStandardHost: true,
}

result, err := parseAzDoRemote(remoteUrl)

require.NoError(t, err)
require.Equal(t, expected, result)
})

t.Run("valid self-hosted Azure DevOps Server with collection HTTPS remote", func(t *testing.T) {
remoteUrl := "https://azuredevops.example.org/DefaultCollection/Project/_git/Repo"
expected := &azdoRemote{
Project: "Project",
RepositoryName: "Repo",
IsNonStandardHost: true,
}

result, err := parseAzDoRemote(remoteUrl)

require.NoError(t, err)
require.Equal(t, expected, result)
})

t.Run("invalid SSH remote from non-standard host", func(t *testing.T) {
remoteUrl := "git@devops.example.com:v3/org/project/repo"

result, err := parseAzDoRemote(remoteUrl)

require.Error(t, err)
require.ErrorIs(t, err, ErrRemoteHostIsNotAzDo)
require.Nil(t, result)
})

t.Run("invalid remote", func(t *testing.T) {
remoteUrl := "https://github.com/user/repo"

Expand Down
Loading