Skip to content
Draft
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
18 changes: 17 additions & 1 deletion cli/azd/cmd/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -620,7 +620,23 @@ func registerCommonDependencies(container *ioc.NestedContainer) {
return security.NewManager(cwd)
})

container.MustRegisterSingleton(repository.NewInitializer)
container.MustRegisterSingleton(func(
console input.Console,
gitCli *git.Cli,
dotnetCli *dotnet.Cli,
features *alpha.FeatureManager,
lazyEnvManager *lazy.Lazy[environment.Manager],
transport policy.Transporter,
) *repository.Initializer {
return repository.NewInitializerWithRepositoryStatusChecker(
console,
gitCli,
dotnetCli,
features,
lazyEnvManager,
repository.NewGitHubRepositoryStatusChecker(transport),
)
})
container.MustRegisterSingleton(alpha.NewFeaturesManager)
container.MustRegisterSingleton(config.NewUserConfigManager)
container.MustRegisterSingleton(config.NewManager)
Expand Down
7 changes: 6 additions & 1 deletion cli/azd/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ func (i *initAction) Run(ctx context.Context) (_ *actions.ActionResult, retErr e
// or pass "." to use the current directory (preserving existing behavior).
createdProjectDir := ""
originalWd := wd
cleanupProjectDir := false

if isTemplateInit {
targetDir, err := i.resolveTargetDirectory(wd)
Expand Down Expand Up @@ -308,7 +309,7 @@ func (i *initAction) Run(ctx context.Context) (_ *actions.ActionResult, retErr e
// Only remove the directory if we created it — don't delete
// pre-existing directories the user pointed at.
defer func() {
if retErr != nil {
if retErr != nil || cleanupProjectDir {
_ = os.Chdir(originalWd)
if !dirExistedBefore {
_ = os.RemoveAll(createdProjectDir)
Expand Down Expand Up @@ -424,6 +425,10 @@ func (i *initAction) Run(ctx context.Context) (_ *actions.ActionResult, retErr e
tracing.SetUsageAttributes(fields.InitMethod.String("template"))
template, err := i.initializeTemplate(ctx, azdCtx)
if err != nil {
if errors.Is(err, repository.ErrArchivedTemplateDeclined) {
cleanupProjectDir = true
return initCancelledResult(), nil
}
return nil, err
}

Expand Down
42 changes: 42 additions & 0 deletions cli/azd/cmd/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (

"github.com/azure/azure-dev/cli/azd/internal"
"github.com/azure/azure-dev/cli/azd/internal/agent/consent"
"github.com/azure/azure-dev/cli/azd/internal/repository"
"github.com/azure/azure-dev/cli/azd/pkg/account"
"github.com/azure/azure-dev/cli/azd/pkg/alpha"
"github.com/azure/azure-dev/cli/azd/pkg/config"
Expand Down Expand Up @@ -154,6 +155,47 @@ func TestInitNoPromptRequiresMode(t *testing.T) {
})
}

func TestInitArchivedTemplateDeclinedCleansCreatedDirectory(t *testing.T) {
mockContext := mocks.NewMockContext(t.Context())
mockContext.Console.SetTerminal(true)
mockContext.Console.WhenConfirm(func(options input.ConsoleOptions) bool {
return options.Message == "Do you want to continue using this archived template?" &&
options.DefaultValue == false
}).Respond(false)

flags := &initFlags{
templatePath: "Azure-Samples/todo-csharp-sql-swa-func",
global: &internal.GlobalCommandOptions{},
}
flags.EnvironmentName = "archive-test"

action := setupInitAction(t, mockContext, flags)
action.repoInitializer = repository.NewInitializerWithRepositoryStatusChecker(
mockContext.Console,
action.gitCli,
nil,
nil,
nil,
archivedRepositoryStatusChecker{},
)

wd, err := os.Getwd()
require.NoError(t, err)
targetDir := filepath.Join(wd, "todo-csharp-sql-swa-func")

result, err := action.Run(t.Context())

require.NoError(t, err)
require.Equal(t, "Init cancelled.", result.Message.Header)
require.NoDirExists(t, targetDir)
}

type archivedRepositoryStatusChecker struct{}

func (archivedRepositoryStatusChecker) Check(context.Context, string) (*repository.RepositoryStatus, error) {
return &repository.RepositoryStatus{Archived: true}, nil
}

func TestInitFailFastMissingEnvNonInteractive(t *testing.T) {
t.Run("NoLongerFailsWhenNoPromptWithTemplateAndNoEnv", func(t *testing.T) {
mockContext := mocks.NewMockContext(t.Context())
Expand Down
14 changes: 14 additions & 0 deletions cli/azd/docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,20 @@ specific version of the tool installed on the machine.
| `AZD_PACK_TOOL_PATH` | The `pack` tool override path. The direct path to `pack` or `pack.exe`. |
| `AZD_COPILOT_CLI_PATH` | The Copilot CLI tool override path. When set, skips automatic download and uses the specified path. |

### GitHub Repository Access

These GitHub-compatible variables are used when `azd init --template` checks repository metadata before cloning.
Metadata requests are unauthenticated when no matching token is set.

| Variable | Description |
| --- | --- |
| `GH_TOKEN` | Token used to request repository metadata from `github.com`. Takes precedence over `GITHUB_TOKEN`. |
| `GITHUB_TOKEN` | Token used to request repository metadata from `github.com` when `GH_TOKEN` is not set. |
| `GH_HOST` | GitHub Enterprise host recognized for repository metadata checks. |
| `GITHUB_SERVER_URL` | GitHub server URL recognized for repository metadata checks when `GH_HOST` is not set. |
| `GH_ENTERPRISE_TOKEN` | Token used to request repository metadata from a recognized GitHub Enterprise host. Takes precedence over `GITHUB_ENTERPRISE_TOKEN`. |
| `GITHUB_ENTERPRISE_TOKEN` | Token used to request repository metadata from a recognized GitHub Enterprise host when `GH_ENTERPRISE_TOKEN` is not set. |

## Extension Configuration

| Variable | Description |
Expand Down
1 change: 1 addition & 0 deletions cli/azd/internal/cmd/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1195,6 +1195,7 @@ func Test_PackageLevelErrorsMapped(t *testing.T) {
"ErrUnsupportedScriptType": "pkg/ext: hook script validation, caught before command level",

// Errors that are always caught/handled before reaching MapError
"ErrArchivedTemplateDeclined": "caught in cmd/init.go and converted to a successful cancellation result",
"ErrEnsureEnvPreReqBicepCompileFailed": "caught in cmd/env.go and cmd/up.go before reaching telemetry",
"ErrAzdOperationsNotEnabled": "caught in pkg/project/dotnet_importer.go before reaching telemetry",
"ErrAzCliSecretNotFound": "caught in pkg/cmdsubst before reaching telemetry",
Expand Down
96 changes: 84 additions & 12 deletions cli/azd/internal/repository/initializer.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type Initializer struct {
dotnetCli *dotnet.Cli
features *alpha.FeatureManager
lazyEnvManager *lazy.Lazy[environment.Manager]
statusChecker RepositoryStatusChecker
}

func NewInitializer(
Expand All @@ -66,6 +67,25 @@ func NewInitializer(
}
}

// NewInitializerWithRepositoryStatusChecker creates an initializer that checks repository metadata before cloning.
func NewInitializerWithRepositoryStatusChecker(
console input.Console,
gitCli *git.Cli,
dotnetCli *dotnet.Cli,
features *alpha.FeatureManager,
lazyEnvManager *lazy.Lazy[environment.Manager],
statusChecker RepositoryStatusChecker,
) *Initializer {
initializer := NewInitializer(console, gitCli, dotnetCli, features, lazyEnvManager)
initializer.statusChecker = statusChecker
return initializer
}

var (
// ErrArchivedTemplateDeclined indicates that the user chose not to initialize from an archived repository.
ErrArchivedTemplateDeclined = errors.New("archived template repository declined by user")
)

// Initializes a local repository in the project directory from a remote repository or local template directory.
//
// A confirmation prompt is displayed for any existing files to be overwritten.
Expand All @@ -76,18 +96,6 @@ func (i *Initializer) Initialize(
templateBranch string) error {
var err error

staging, err := os.MkdirTemp("", "az-dev-template")

if err != nil {
return fmt.Errorf("creating temp folder: %w", err)
}

// Attempt to remove the temporary directory we cloned the template into, but don't fail the
// overall operation if we can't.
defer func() {
_ = os.RemoveAll(staging)
}()

target := azdCtx.ProjectDirectory()

templateUrl, err := templates.Absolute(template.RepositoryPath)
Expand All @@ -107,6 +115,21 @@ func (i *Initializer) Initialize(
}
}

if err := i.confirmArchivedTemplate(ctx, templateUrl); err != nil {
return err
}

staging, err := os.MkdirTemp("", "az-dev-template")
if err != nil {
return fmt.Errorf("creating temp folder: %w", err)
}

// Attempt to remove the temporary directory we cloned the template into, but don't fail the
// overall operation if we can't.
defer func() {
_ = os.RemoveAll(staging)
}()

var stepMessage string
if templates.IsLocalPath(templateUrl) {
stepMessage = fmt.Sprintf(
Expand Down Expand Up @@ -184,6 +207,55 @@ func (i *Initializer) Initialize(
return nil
}

func (i *Initializer) confirmArchivedTemplate(ctx context.Context, templateURL string) error {
if i.statusChecker == nil {
return nil
}

status, err := i.statusChecker.Check(ctx, templateURL)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
log.Printf("unable to verify template repository archive status: %v", err)
return nil
}
if status == nil || !status.Archived {
return nil
}

i.console.Message(
ctx,
output.WithWarningFormat(
"WARNING: This template repository is archived and is no longer actively maintained.",
),
)
i.console.Message(
ctx,
"It may not receive dependency updates, compatibility fixes, security patches, or support.\n",
)

if i.console.IsNoPromptMode() {
return fmt.Errorf(
"template repository %s is archived and requires confirmation; rerun without --no-prompt",
templateURL,
)
}

confirmed, err := i.console.Confirm(ctx, input.ConsoleOptions{
Message: "Do you want to continue using this archived template?",
DefaultValue: false,
})
if err != nil {
return err
}
if !confirmed {
return ErrArchivedTemplateDeclined
}

return nil
}

func (i *Initializer) fetchCode(
ctx context.Context,
templateUrl string,
Expand Down
Loading
Loading