diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index 61a0c5194b2..f99eacabeae 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -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) diff --git a/cli/azd/cmd/init.go b/cli/azd/cmd/init.go index e828a0ac3fa..2de9d788179 100644 --- a/cli/azd/cmd/init.go +++ b/cli/azd/cmd/init.go @@ -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) @@ -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) @@ -424,6 +425,11 @@ 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 + i.console.Message(ctx, output.WithWarningFormat("CANCELLED: Initialization stopped.")) + return nil, nil + } return nil, err } diff --git a/cli/azd/cmd/init_test.go b/cli/azd/cmd/init_test.go index b9549f964d1..3fbf7dbd7e8 100644 --- a/cli/azd/cmd/init_test.go +++ b/cli/azd/cmd/init_test.go @@ -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" @@ -154,6 +155,48 @@ 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 == "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.Nil(t, result) + require.Contains(t, strings.Join(mockContext.Console.Output(), "\n"), "CANCELLED: Initialization stopped.") + 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()) diff --git a/cli/azd/docs/environment-variables.md b/cli/azd/docs/environment-variables.md index 37bfa1ac9be..72a7bf4c263 100644 --- a/cli/azd/docs/environment-variables.md +++ b/cli/azd/docs/environment-variables.md @@ -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` and GitHub Enterprise Cloud `*.ghe.com` hosts. Takes precedence over `GITHUB_TOKEN`. | +| `GITHUB_TOKEN` | Token used to request repository metadata from `github.com` and `*.ghe.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 Server host. Takes precedence over `GITHUB_ENTERPRISE_TOKEN`. | +| `GITHUB_ENTERPRISE_TOKEN` | Token used for a recognized GitHub Enterprise Server host when `GH_ENTERPRISE_TOKEN` is not set. | + ## Extension Configuration | Variable | Description | diff --git a/cli/azd/internal/cmd/errors_test.go b/cli/azd/internal/cmd/errors_test.go index 86ecf94754d..f4d51f03d1b 100644 --- a/cli/azd/internal/cmd/errors_test.go +++ b/cli/azd/internal/cmd/errors_test.go @@ -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", diff --git a/cli/azd/internal/repository/initializer.go b/cli/azd/internal/repository/initializer.go index b7a9d5fdb40..546e3ca473b 100644 --- a/cli/azd/internal/repository/initializer.go +++ b/cli/azd/internal/repository/initializer.go @@ -48,6 +48,7 @@ type Initializer struct { dotnetCli *dotnet.Cli features *alpha.FeatureManager lazyEnvManager *lazy.Lazy[environment.Manager] + statusChecker RepositoryStatusChecker } func NewInitializer( @@ -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. @@ -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) @@ -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( @@ -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 no longer maintained.", + ), + ) + i.console.Message( + ctx, + "It may not receive dependency updates, compatibility fixes, or security patches.\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: "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, diff --git a/cli/azd/internal/repository/repository_status.go b/cli/azd/internal/repository/repository_status.go new file mode 100644 index 00000000000..8d79d40b02b --- /dev/null +++ b/cli/azd/internal/repository/repository_status.go @@ -0,0 +1,199 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package repository + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" +) + +const ( + maxRepositoryMetadataSize = 1 << 20 + repositoryMetadataTimeout = 5 * time.Second +) + +// RepositoryStatus contains hosting-provider metadata relevant to template initialization. +type RepositoryStatus struct { + Archived bool +} + +// RepositoryStatusChecker retrieves repository metadata when the hosting provider supports it. +type RepositoryStatusChecker interface { + // Check returns nil when the repository host is not supported. + Check(ctx context.Context, repositoryURL string) (*RepositoryStatus, error) +} + +type githubRepositoryStatusChecker struct { + transport policy.Transporter + hosts map[string]githubHostConfig + requestTimeout time.Duration +} + +type githubHostConfig struct { + apiBaseURL string + token string +} + +// NewGitHubRepositoryStatusChecker creates a checker for GitHub-hosted repositories. +func NewGitHubRepositoryStatusChecker(transport policy.Transporter) RepositoryStatusChecker { + if transport == nil { + transport = http.DefaultClient + } + + cloudToken := firstSetEnvironmentVariable("GH_TOKEN", "GITHUB_TOKEN") + enterpriseServerToken := firstSetEnvironmentVariable("GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN") + hosts := map[string]githubHostConfig{ + "github.com": { + apiBaseURL: "https://api.github.com", + token: cloudToken, + }, + } + if host := normalizeGitHubHost(os.Getenv("GH_HOST")); host != "" && host != "github.com" { + hosts[host] = githubHostConfiguration(host, cloudToken, enterpriseServerToken) + } + if serverURL := os.Getenv("GITHUB_SERVER_URL"); serverURL != "" { + if parsed, err := url.Parse(serverURL); err == nil { + if host := normalizeGitHubHost(parsed.Hostname()); host != "" && host != "github.com" { + hosts[host] = githubHostConfiguration(host, cloudToken, enterpriseServerToken) + } + } + } + + return &githubRepositoryStatusChecker{ + transport: transport, + hosts: hosts, + requestTimeout: repositoryMetadataTimeout, + } +} + +func (c *githubRepositoryStatusChecker) Check( + ctx context.Context, + repositoryURL string, +) (*RepositoryStatus, error) { + host, slug, ok := parseGitHubRepositoryURL(repositoryURL, c.hosts) + if !ok { + return nil, nil + } + + hostConfig := c.hosts[host] + apiURL := fmt.Sprintf("%s/repos/%s", hostConfig.apiBaseURL, slug) + + requestCtx, cancel := context.WithTimeout(ctx, c.requestTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(requestCtx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, fmt.Errorf("creating GitHub repository metadata request: %w", err) + } + + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "azd") + if token := hostConfig.token; token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := c.transport.Do(req) + if err != nil { + return nil, fmt.Errorf("requesting GitHub repository metadata: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("requesting GitHub repository metadata: HTTP %d", resp.StatusCode) + } + + var metadata struct { + Archived bool `json:"archived"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, maxRepositoryMetadataSize)).Decode(&metadata); err != nil { + return nil, fmt.Errorf("decoding GitHub repository metadata: %w", err) + } + + return &RepositoryStatus{Archived: metadata.Archived}, nil +} + +func parseGitHubRepositoryURL( + repositoryURL string, + knownHosts map[string]githubHostConfig, +) (host string, slug string, ok bool) { + var path string + + if after, ok0 := strings.CutPrefix(repositoryURL, "git@"); ok0 { + hostAndPath := after + host, path, ok = strings.Cut(hostAndPath, ":") + if !ok { + return "", "", false + } + } else { + parsed, err := url.Parse(repositoryURL) + if err != nil { + return "", "", false + } + + switch parsed.Scheme { + case "http", "https", "ssh", "git": + default: + return "", "", false + } + + host = parsed.Hostname() + path = parsed.Path + } + + host = normalizeGitHubHost(host) + if _, known := knownHosts[host]; !known { + return "", "", false + } + + path = strings.Trim(strings.TrimSpace(path), "/") + path = strings.TrimSuffix(path, ".git") + parts := strings.Split(path, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + + return host, url.PathEscape(parts[0]) + "/" + url.PathEscape(parts[1]), true +} + +func normalizeGitHubHost(host string) string { + host = strings.ToLower(strings.TrimSpace(host)) + return strings.TrimPrefix(host, "www.") +} + +func githubHostConfiguration(host, cloudToken, enterpriseServerToken string) githubHostConfig { + // GitHub Enterprise Cloud data-residency hosts use the same GH_TOKEN/GITHUB_TOKEN + // variables as github.com and expose their REST API at api..ghe.com. + if strings.HasSuffix(host, ".ghe.com") { + return githubHostConfig{ + apiBaseURL: "https://api." + host, + token: cloudToken, + } + } + + // Custom GitHub Enterprise Server hosts use GH_ENTERPRISE_TOKEN/GITHUB_ENTERPRISE_TOKEN + // and serve the REST API below /api/v3 on the configured host. + return githubHostConfig{ + apiBaseURL: "https://" + host + "/api/v3", + token: enterpriseServerToken, + } +} + +func firstSetEnvironmentVariable(names ...string) string { + for _, name := range names { + if token := os.Getenv(name); token != "" { + return token + } + } + + return "" +} diff --git a/cli/azd/internal/repository/repository_status_test.go b/cli/azd/internal/repository/repository_status_test.go new file mode 100644 index 00000000000..01c0bbb4556 --- /dev/null +++ b/cli/azd/internal/repository/repository_status_test.go @@ -0,0 +1,227 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package repository + +import ( + "context" + "errors" + "net/http" + "testing" + "time" + + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockhttp" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockinput" + "github.com/stretchr/testify/require" +) + +func TestGitHubRepositoryStatusChecker(t *testing.T) { + t.Run("ArchivedGitHubRepository", func(t *testing.T) { + t.Setenv("GH_TOKEN", "") + t.Setenv("GITHUB_TOKEN", "") + mockTransport := mockhttp.NewMockHttpUtil() + mockTransport.When(func(req *http.Request) bool { + return req.Method == http.MethodGet && + req.URL.String() == "https://api.github.com/repos/Azure-Samples/todo-csharp-sql-swa-func" + }).RespondFn(func(req *http.Request) (*http.Response, error) { + return mocks.CreateHttpResponseWithBody(req, http.StatusOK, map[string]any{ + "archived": true, + }) + }) + + checker := NewGitHubRepositoryStatusChecker(mockTransport) + status, err := checker.Check( + t.Context(), + "https://github.com/Azure-Samples/todo-csharp-sql-swa-func.git", + ) + + require.NoError(t, err) + require.NotNil(t, status) + require.True(t, status.Archived) + }) + + t.Run("GitHubEnterpriseServerRepository", func(t *testing.T) { + t.Setenv("GH_HOST", "github.contoso.com") + t.Setenv("GH_TOKEN", "cloud-token") + t.Setenv("GH_ENTERPRISE_TOKEN", "server-token") + mockTransport := mockhttp.NewMockHttpUtil() + mockTransport.When(func(req *http.Request) bool { + return req.URL.String() == "https://github.contoso.com/api/v3/repos/contoso/template" && + req.Header.Get("Authorization") == "Bearer server-token" + }).RespondFn(func(req *http.Request) (*http.Response, error) { + return mocks.CreateHttpResponseWithBody(req, http.StatusOK, map[string]any{ + "archived": false, + }) + }) + + checker := NewGitHubRepositoryStatusChecker(mockTransport) + status, err := checker.Check(t.Context(), "git@github.contoso.com:contoso/template.git") + + require.NoError(t, err) + require.NotNil(t, status) + require.False(t, status.Archived) + }) + + t.Run("GitHubEnterpriseCloudRepository", func(t *testing.T) { + t.Setenv("GH_HOST", "octocorp.ghe.com") + t.Setenv("GH_TOKEN", "cloud-token") + t.Setenv("GH_ENTERPRISE_TOKEN", "server-token") + mockTransport := mockhttp.NewMockHttpUtil() + mockTransport.When(func(req *http.Request) bool { + return req.URL.String() == "https://api.octocorp.ghe.com/repos/contoso/template" && + req.Header.Get("Authorization") == "Bearer cloud-token" + }).RespondFn(func(req *http.Request) (*http.Response, error) { + return mocks.CreateHttpResponseWithBody(req, http.StatusOK, map[string]any{ + "archived": true, + }) + }) + + checker := NewGitHubRepositoryStatusChecker(mockTransport) + status, err := checker.Check(t.Context(), "https://octocorp.ghe.com/contoso/template") + + require.NoError(t, err) + require.NotNil(t, status) + require.True(t, status.Archived) + }) + + t.Run("UnsupportedRepositoryHost", func(t *testing.T) { + checker := NewGitHubRepositoryStatusChecker(mockhttp.NewMockHttpUtil()) + status, err := checker.Check(t.Context(), "https://gitlab.com/contoso/template") + + require.NoError(t, err) + require.Nil(t, status) + }) + + t.Run("MetadataRequestFailure", func(t *testing.T) { + mockTransport := mockhttp.NewMockHttpUtil() + mockTransport.When(func(req *http.Request) bool { + return true + }).RespondFn(func(req *http.Request) (*http.Response, error) { + return mocks.CreateEmptyHttpResponse(req, http.StatusForbidden) + }) + + checker := NewGitHubRepositoryStatusChecker(mockTransport) + status, err := checker.Check(t.Context(), "https://github.com/contoso/template") + + require.ErrorContains(t, err, "HTTP 403") + require.Nil(t, status) + }) + + t.Run("MetadataRequestTimeout", func(t *testing.T) { + mockTransport := mockhttp.NewMockHttpUtil() + mockTransport.When(func(req *http.Request) bool { + return true + }).RespondFn(func(req *http.Request) (*http.Response, error) { + <-req.Context().Done() + return nil, req.Context().Err() + }) + + checker := NewGitHubRepositoryStatusChecker(mockTransport).(*githubRepositoryStatusChecker) + checker.requestTimeout = time.Millisecond + status, err := checker.Check(t.Context(), "https://github.com/contoso/template") + + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Nil(t, status) + }) +} + +func TestInitializerConfirmArchivedTemplate(t *testing.T) { + t.Run("ActiveRepositoryContinuesWithoutPrompt", func(t *testing.T) { + console := mockinput.NewMockConsole() + initializer := &Initializer{ + console: console, + statusChecker: &fakeRepositoryStatusChecker{status: &RepositoryStatus{}}, + } + + err := initializer.confirmArchivedTemplate(t.Context(), "https://github.com/contoso/template") + + require.NoError(t, err) + require.Empty(t, console.Output()) + }) + + t.Run("ArchivedRepositoryAccepted", func(t *testing.T) { + console := mockinput.NewMockConsole() + console.WhenConfirm(func(options input.ConsoleOptions) bool { + require.Equal(t, "Continue using this archived template?", options.Message) + require.Equal(t, false, options.DefaultValue) + return true + }).Respond(true) + initializer := &Initializer{ + console: console, + statusChecker: &fakeRepositoryStatusChecker{status: &RepositoryStatus{Archived: true}}, + } + + err := initializer.confirmArchivedTemplate(t.Context(), "https://github.com/contoso/template") + + require.NoError(t, err) + require.Contains(t, console.Output()[0], "WARNING:") + require.Contains(t, console.Output()[0], "archived and no longer maintained") + require.Contains(t, console.Output()[1], "security patches") + require.NotContains(t, console.Output()[1], "support") + }) + + t.Run("ArchivedRepositoryDeclined", func(t *testing.T) { + console := mockinput.NewMockConsole() + console.WhenConfirm(func(options input.ConsoleOptions) bool { + return options.DefaultValue == false + }).Respond(false) + initializer := &Initializer{ + console: console, + statusChecker: &fakeRepositoryStatusChecker{status: &RepositoryStatus{Archived: true}}, + } + + err := initializer.confirmArchivedTemplate(t.Context(), "https://github.com/contoso/template") + + require.ErrorIs(t, err, ErrArchivedTemplateDeclined) + }) + + t.Run("ArchivedRepositoryNoPrompt", func(t *testing.T) { + console := mockinput.NewMockConsole() + console.SetNoPromptMode(true) + initializer := &Initializer{ + console: console, + statusChecker: &fakeRepositoryStatusChecker{status: &RepositoryStatus{Archived: true}}, + } + + err := initializer.confirmArchivedTemplate(t.Context(), "https://github.com/contoso/template") + + require.ErrorContains(t, err, "requires confirmation") + require.ErrorContains(t, err, "rerun without --no-prompt") + }) + + t.Run("MetadataFailureContinues", func(t *testing.T) { + console := mockinput.NewMockConsole() + initializer := &Initializer{ + console: console, + statusChecker: &fakeRepositoryStatusChecker{err: errors.New("rate limited")}, + } + + err := initializer.confirmArchivedTemplate(t.Context(), "https://github.com/contoso/template") + + require.NoError(t, err) + require.Empty(t, console.Output()) + }) + + t.Run("CancellationStopsInitialization", func(t *testing.T) { + console := mockinput.NewMockConsole() + initializer := &Initializer{ + console: console, + statusChecker: &fakeRepositoryStatusChecker{err: context.Canceled}, + } + + err := initializer.confirmArchivedTemplate(t.Context(), "https://github.com/contoso/template") + + require.ErrorIs(t, err, context.Canceled) + }) +} + +type fakeRepositoryStatusChecker struct { + status *RepositoryStatus + err error +} + +func (f *fakeRepositoryStatusChecker) Check(context.Context, string) (*RepositoryStatus, error) { + return f.status, f.err +} diff --git a/cli/azd/test/functional/init_test.go b/cli/azd/test/functional/init_test.go index 99b195e3898..8f22dad3789 100644 --- a/cli/azd/test/functional/init_test.go +++ b/cli/azd/test/functional/init_test.go @@ -309,9 +309,10 @@ func Test_CLI_Init_CanUseTemplate(t *testing.T) { cli.WorkingDirectory = dir cli.Env = append(os.Environ(), "AZURE_LOCATION=eastus2") + // The template is archived, so accept the warning before providing the environment name. _, err := cli.RunCommandWithStdIn( ctx, - "TESTENV\n", + "y\nTESTENV\n", "init", "--template", "cosmos-dotnet-core-todo-app", diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index 241eda4f1cb..baf44f2ee71 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -35,6 +35,16 @@ Override the path to external tools that azd invokes: | `AZD_GH_TOOL_PATH` | Path to the GitHub CLI binary | | `AZD_PACK_TOOL_PATH` | Path to the Cloud Native Buildpacks (`pack`) binary | +## GitHub Repository Access + +Used by `azd init --template` when checking GitHub repository metadata before cloning: + +| Variable | Description | +|---|---| +| `GH_TOKEN` / `GITHUB_TOKEN` | Authenticate repository metadata requests to `github.com` and GitHub Enterprise Cloud `*.ghe.com` hosts | +| `GH_HOST` / `GITHUB_SERVER_URL` | Identify a GitHub Enterprise host for repository metadata checks | +| `GH_ENTERPRISE_TOKEN` / `GITHUB_ENTERPRISE_TOKEN` | Authenticate repository metadata requests to a recognized GitHub Enterprise Server host | + ## Build Configuration | Variable | Description |