Merge pull request #23995 from hashicorp/alisdair/terraform-login
Enable login subcommand, add manual token support
This commit is contained in:
commit
f34cba407f
161
command/login.go
161
command/login.go
|
@ -10,10 +10,11 @@ import (
|
|||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/terraform-svchost"
|
||||
svchost "github.com/hashicorp/terraform-svchost"
|
||||
svcauth "github.com/hashicorp/terraform-svchost/auth"
|
||||
"github.com/hashicorp/terraform-svchost/disco"
|
||||
"github.com/hashicorp/terraform/command/cliconfig"
|
||||
|
@ -125,25 +126,49 @@ func (c *LoginCommand) Run(args []string) int {
|
|||
case nil:
|
||||
// Great! No problem, then.
|
||||
case *disco.ErrServiceNotProvided:
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
"Host does not support Terraform login",
|
||||
fmt.Sprintf("The given hostname %q does not allow creating Terraform authorization tokens.", dispHostname),
|
||||
))
|
||||
// This is also fine! We'll try the manual token creation process.
|
||||
case *disco.ErrVersionNotSupported:
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
tfdiags.Warning,
|
||||
"Host does not support Terraform login",
|
||||
fmt.Sprintf("The given hostname %q allows creating Terraform authorization tokens, but requires a newer version of Terraform CLI to do so.", dispHostname),
|
||||
))
|
||||
default:
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
tfdiags.Warning,
|
||||
"Host does not support Terraform login",
|
||||
fmt.Sprintf("The given hostname %q cannot support \"terraform login\": %s.", dispHostname, err),
|
||||
))
|
||||
}
|
||||
|
||||
// If login service is unavailable, check for a TFE v2 API as fallback
|
||||
var service *url.URL
|
||||
if clientConfig == nil {
|
||||
service, err = host.ServiceURL("tfe.v2")
|
||||
switch err.(type) {
|
||||
case nil:
|
||||
// Success!
|
||||
case *disco.ErrServiceNotProvided:
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
"Host does not support Terraform tokens API",
|
||||
fmt.Sprintf("The given hostname %q does not support creating Terraform authorization tokens.", dispHostname),
|
||||
))
|
||||
case *disco.ErrVersionNotSupported:
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
"Host does not support Terraform tokens API",
|
||||
fmt.Sprintf("The given hostname %q allows creating Terraform authorization tokens, but requires a newer version of Terraform CLI to do so.", dispHostname),
|
||||
))
|
||||
default:
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
"Host does not support Terraform tokens API",
|
||||
fmt.Sprintf("The given hostname %q cannot support \"terraform login\": %s.", dispHostname, err),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
if credsCtx.Location == cliconfig.CredentialsInOtherFile {
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
|
@ -157,37 +182,41 @@ func (c *LoginCommand) Run(args []string) int {
|
|||
return 1
|
||||
}
|
||||
|
||||
var token *oauth2.Token
|
||||
switch {
|
||||
case clientConfig.SupportedGrantTypes.Has(disco.OAuthAuthzCodeGrant):
|
||||
// We prefer an OAuth code grant if the server supports it.
|
||||
var tokenDiags tfdiags.Diagnostics
|
||||
token, tokenDiags = c.interactiveGetTokenByCode(hostname, credsCtx, clientConfig)
|
||||
diags = diags.Append(tokenDiags)
|
||||
if tokenDiags.HasErrors() {
|
||||
c.showDiagnostics(diags)
|
||||
return 1
|
||||
var token svcauth.HostCredentialsToken
|
||||
var tokenDiags tfdiags.Diagnostics
|
||||
|
||||
// Prefer Terraform login if available
|
||||
if clientConfig != nil {
|
||||
var oauthToken *oauth2.Token
|
||||
|
||||
switch {
|
||||
case clientConfig.SupportedGrantTypes.Has(disco.OAuthAuthzCodeGrant):
|
||||
// We prefer an OAuth code grant if the server supports it.
|
||||
oauthToken, tokenDiags = c.interactiveGetTokenByCode(hostname, credsCtx, clientConfig)
|
||||
case clientConfig.SupportedGrantTypes.Has(disco.OAuthOwnerPasswordGrant) && hostname == svchost.Hostname("app.terraform.io"):
|
||||
// The password grant type is allowed only for Terraform Cloud SaaS.
|
||||
oauthToken, tokenDiags = c.interactiveGetTokenByPassword(hostname, credsCtx, clientConfig)
|
||||
default:
|
||||
tokenDiags = tokenDiags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
"Host does not support Terraform login",
|
||||
fmt.Sprintf("The given hostname %q does not allow any OAuth grant types that are supported by this version of Terraform.", dispHostname),
|
||||
))
|
||||
}
|
||||
case clientConfig.SupportedGrantTypes.Has(disco.OAuthOwnerPasswordGrant) && hostname == svchost.Hostname("app.terraform.io"):
|
||||
// The password grant type is allowed only for Terraform Cloud SaaS.
|
||||
var tokenDiags tfdiags.Diagnostics
|
||||
token, tokenDiags = c.interactiveGetTokenByPassword(hostname, credsCtx, clientConfig)
|
||||
diags = diags.Append(tokenDiags)
|
||||
if tokenDiags.HasErrors() {
|
||||
c.showDiagnostics(diags)
|
||||
return 1
|
||||
if oauthToken != nil {
|
||||
token = svcauth.HostCredentialsToken(oauthToken.AccessToken)
|
||||
}
|
||||
default:
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
"Host does not support Terraform login",
|
||||
fmt.Sprintf("The given hostname %q does not allow any OAuth grant types that are supported by this version of Terraform.", dispHostname),
|
||||
))
|
||||
} else if service != nil {
|
||||
token, tokenDiags = c.interactiveGetTokenByUI(hostname, credsCtx, service)
|
||||
}
|
||||
|
||||
diags = diags.Append(tokenDiags)
|
||||
if diags.HasErrors() {
|
||||
c.showDiagnostics(diags)
|
||||
return 1
|
||||
}
|
||||
|
||||
err = creds.StoreForHost(hostname, svcauth.HostCredentialsToken(token.AccessToken))
|
||||
err = creds.StoreForHost(hostname, token)
|
||||
if err != nil {
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
|
@ -468,10 +497,72 @@ func (c *LoginCommand) interactiveGetTokenByPassword(hostname svchost.Hostname,
|
|||
return token, diags
|
||||
}
|
||||
|
||||
func (c *LoginCommand) interactiveContextConsent(hostname svchost.Hostname, grantType disco.OAuthGrantType, credsCtx *loginCredentialsContext) (bool, tfdiags.Diagnostics) {
|
||||
func (c *LoginCommand) interactiveGetTokenByUI(hostname svchost.Hostname, credsCtx *loginCredentialsContext, service *url.URL) (svcauth.HostCredentialsToken, tfdiags.Diagnostics) {
|
||||
var diags tfdiags.Diagnostics
|
||||
|
||||
c.Ui.Output(fmt.Sprintf("Terraform will request an API token for %s using OAuth.\n", hostname.ForDisplay()))
|
||||
confirm, confirmDiags := c.interactiveContextConsent(hostname, disco.OAuthGrantType(""), credsCtx)
|
||||
diags = diags.Append(confirmDiags)
|
||||
if !confirm {
|
||||
diags = diags.Append(errors.New("Login cancelled"))
|
||||
return "", diags
|
||||
}
|
||||
|
||||
tokensURL := url.URL{
|
||||
Scheme: "https",
|
||||
Host: service.Hostname(),
|
||||
Path: "/app/settings/tokens",
|
||||
RawQuery: "source=terraform-login",
|
||||
}
|
||||
|
||||
launchBrowserManually := false
|
||||
if c.BrowserLauncher != nil {
|
||||
err := c.BrowserLauncher.OpenURL(tokensURL.String())
|
||||
if err == nil {
|
||||
c.Ui.Output(fmt.Sprintf("Terraform must now open a web browser to the tokens page for %s.\n", hostname.ForDisplay()))
|
||||
c.Ui.Output(fmt.Sprintf("If a browser does not open this automatically, open the following URL to proceed:\n %s\n", tokensURL.String()))
|
||||
} else {
|
||||
// Assume we're on a platform where opening a browser isn't possible.
|
||||
launchBrowserManually = true
|
||||
}
|
||||
} else {
|
||||
launchBrowserManually = true
|
||||
}
|
||||
|
||||
if launchBrowserManually {
|
||||
c.Ui.Output(fmt.Sprintf("Open the following URL to access the tokens page for %s:\n %s\n", hostname.ForDisplay(), tokensURL.String()))
|
||||
}
|
||||
|
||||
c.Ui.Output("\n---------------------------------------------------------------------------------\n")
|
||||
c.Ui.Output("Generate a token using your browser, and copy-paste it into this prompt.\n")
|
||||
|
||||
// credsCtx might not be set if we're using a mock credentials source
|
||||
// in a test, but it should always be set in normal use.
|
||||
if credsCtx != nil {
|
||||
switch credsCtx.Location {
|
||||
case cliconfig.CredentialsViaHelper:
|
||||
c.Ui.Output(fmt.Sprintf("Terraform will store the token in the configured %q credentials helper\nfor use by subsequent commands.\n", credsCtx.HelperType))
|
||||
case cliconfig.CredentialsInPrimaryFile, cliconfig.CredentialsNotAvailable:
|
||||
c.Ui.Output(fmt.Sprintf("Terraform will store the token in plain text in the following file\nfor use by subsequent commands:\n %s\n", credsCtx.LocalFilename))
|
||||
}
|
||||
}
|
||||
|
||||
token, err := c.Ui.AskSecret(fmt.Sprintf("Token for %s:", hostname.ForDisplay()))
|
||||
if err != nil {
|
||||
diags := diags.Append(fmt.Errorf("Failed to retrieve token: %s", err))
|
||||
return "", diags
|
||||
}
|
||||
|
||||
return svcauth.HostCredentialsToken(token), nil
|
||||
}
|
||||
|
||||
func (c *LoginCommand) interactiveContextConsent(hostname svchost.Hostname, grantType disco.OAuthGrantType, credsCtx *loginCredentialsContext) (bool, tfdiags.Diagnostics) {
|
||||
var diags tfdiags.Diagnostics
|
||||
mechanism := "OAuth"
|
||||
if grantType == "" {
|
||||
mechanism = "your browser"
|
||||
}
|
||||
|
||||
c.Ui.Output(fmt.Sprintf("Terraform will request an API token for %s using %s.\n", hostname.ForDisplay(), mechanism))
|
||||
|
||||
if grantType.UsesAuthorizationEndpoint() {
|
||||
c.Ui.Output(
|
||||
|
|
|
@ -12,7 +12,7 @@ import (
|
|||
|
||||
"github.com/mitchellh/cli"
|
||||
|
||||
"github.com/hashicorp/terraform-svchost"
|
||||
svchost "github.com/hashicorp/terraform-svchost"
|
||||
"github.com/hashicorp/terraform-svchost/disco"
|
||||
"github.com/hashicorp/terraform/command/cliconfig"
|
||||
oauthserver "github.com/hashicorp/terraform/command/testdata/login-oauth-server"
|
||||
|
@ -70,6 +70,13 @@ func TestLogin(t *testing.T) {
|
|||
"token": s.URL + "/token",
|
||||
},
|
||||
})
|
||||
svcs.ForceHostServices(svchost.Hostname("tfe.acme.com"), map[string]interface{}{
|
||||
// This represents a Terraform Enterprise instance which does not
|
||||
// yet support the login API, but does support the TFE tokens API.
|
||||
"tfe.v2": "/api/v2",
|
||||
"tfe.v2.1": "/api/v2",
|
||||
"tfe.v2.2": "/api/v2",
|
||||
})
|
||||
svcs.ForceHostServices(svchost.Hostname("unsupported.example.net"), map[string]interface{}{
|
||||
// This host intentionally left blank.
|
||||
})
|
||||
|
@ -125,13 +132,31 @@ func TestLogin(t *testing.T) {
|
|||
}
|
||||
}))
|
||||
|
||||
t.Run("host without login support", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi, inp func(string)) {
|
||||
t.Run("TFE host without login support", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi, inp func(string)) {
|
||||
// Enter "yes" at the consent prompt, then paste a token.
|
||||
inp("yes\npasted-token\n")
|
||||
status := c.Run([]string{"tfe.acme.com"})
|
||||
if status != 0 {
|
||||
t.Fatalf("unexpected error code %d\nstderr:\n%s", status, ui.ErrorWriter.String())
|
||||
}
|
||||
|
||||
credsSrc := c.Services.CredentialsSource()
|
||||
creds, err := credsSrc.ForHost(svchost.Hostname("tfe.acme.com"))
|
||||
if err != nil {
|
||||
t.Errorf("failed to retrieve credentials: %s", err)
|
||||
}
|
||||
if got, want := creds.Token(), "pasted-token"; got != want {
|
||||
t.Errorf("wrong token %q; want %q", got, want)
|
||||
}
|
||||
}))
|
||||
|
||||
t.Run("host without login or TFE API support", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi, inp func(string)) {
|
||||
status := c.Run([]string{"unsupported.example.net"})
|
||||
if status == 0 {
|
||||
t.Fatalf("successful exit; want error")
|
||||
}
|
||||
|
||||
if got, want := ui.ErrorWriter.String(), "Error: Host does not support Terraform login"; !strings.Contains(got, want) {
|
||||
if got, want := ui.ErrorWriter.String(), "Error: Host does not support Terraform tokens API"; !strings.Contains(got, want) {
|
||||
t.Fatalf("missing expected error message\nwant: %s\nfull output:\n%s", want, got)
|
||||
}
|
||||
}))
|
||||
|
|
14
commands.go
14
commands.go
|
@ -184,15 +184,11 @@ func initCommands(config *cliconfig.Config, services *disco.Disco, providerSrc g
|
|||
}, nil
|
||||
},
|
||||
|
||||
// "terraform login" is disabled until Terraform Cloud is ready to
|
||||
// support it.
|
||||
/*
|
||||
"login": func() (cli.Command, error) {
|
||||
return &command.LoginCommand{
|
||||
Meta: meta,
|
||||
}, nil
|
||||
},
|
||||
*/
|
||||
"login": func() (cli.Command, error) {
|
||||
return &command.LoginCommand{
|
||||
Meta: meta,
|
||||
}, nil
|
||||
},
|
||||
|
||||
"output": func() (cli.Command, error) {
|
||||
return &command.OutputCommand{
|
||||
|
|
Loading…
Reference in New Issue