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"
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/hashicorp/terraform-svchost"
|
svchost "github.com/hashicorp/terraform-svchost"
|
||||||
svcauth "github.com/hashicorp/terraform-svchost/auth"
|
svcauth "github.com/hashicorp/terraform-svchost/auth"
|
||||||
"github.com/hashicorp/terraform-svchost/disco"
|
"github.com/hashicorp/terraform-svchost/disco"
|
||||||
"github.com/hashicorp/terraform/command/cliconfig"
|
"github.com/hashicorp/terraform/command/cliconfig"
|
||||||
|
@ -125,25 +126,49 @@ func (c *LoginCommand) Run(args []string) int {
|
||||||
case nil:
|
case nil:
|
||||||
// Great! No problem, then.
|
// Great! No problem, then.
|
||||||
case *disco.ErrServiceNotProvided:
|
case *disco.ErrServiceNotProvided:
|
||||||
diags = diags.Append(tfdiags.Sourceless(
|
// This is also fine! We'll try the manual token creation process.
|
||||||
tfdiags.Error,
|
|
||||||
"Host does not support Terraform login",
|
|
||||||
fmt.Sprintf("The given hostname %q does not allow creating Terraform authorization tokens.", dispHostname),
|
|
||||||
))
|
|
||||||
case *disco.ErrVersionNotSupported:
|
case *disco.ErrVersionNotSupported:
|
||||||
diags = diags.Append(tfdiags.Sourceless(
|
diags = diags.Append(tfdiags.Sourceless(
|
||||||
tfdiags.Error,
|
tfdiags.Warning,
|
||||||
"Host does not support Terraform login",
|
"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),
|
fmt.Sprintf("The given hostname %q allows creating Terraform authorization tokens, but requires a newer version of Terraform CLI to do so.", dispHostname),
|
||||||
))
|
))
|
||||||
default:
|
default:
|
||||||
diags = diags.Append(tfdiags.Sourceless(
|
diags = diags.Append(tfdiags.Sourceless(
|
||||||
tfdiags.Error,
|
tfdiags.Warning,
|
||||||
"Host does not support Terraform login",
|
"Host does not support Terraform login",
|
||||||
fmt.Sprintf("The given hostname %q cannot support \"terraform login\": %s.", dispHostname, err),
|
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 {
|
if credsCtx.Location == cliconfig.CredentialsInOtherFile {
|
||||||
diags = diags.Append(tfdiags.Sourceless(
|
diags = diags.Append(tfdiags.Sourceless(
|
||||||
tfdiags.Error,
|
tfdiags.Error,
|
||||||
|
@ -157,37 +182,41 @@ func (c *LoginCommand) Run(args []string) int {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
var token *oauth2.Token
|
var token svcauth.HostCredentialsToken
|
||||||
switch {
|
var tokenDiags tfdiags.Diagnostics
|
||||||
case clientConfig.SupportedGrantTypes.Has(disco.OAuthAuthzCodeGrant):
|
|
||||||
// We prefer an OAuth code grant if the server supports it.
|
// Prefer Terraform login if available
|
||||||
var tokenDiags tfdiags.Diagnostics
|
if clientConfig != nil {
|
||||||
token, tokenDiags = c.interactiveGetTokenByCode(hostname, credsCtx, clientConfig)
|
var oauthToken *oauth2.Token
|
||||||
diags = diags.Append(tokenDiags)
|
|
||||||
if tokenDiags.HasErrors() {
|
switch {
|
||||||
c.showDiagnostics(diags)
|
case clientConfig.SupportedGrantTypes.Has(disco.OAuthAuthzCodeGrant):
|
||||||
return 1
|
// 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"):
|
if oauthToken != nil {
|
||||||
// The password grant type is allowed only for Terraform Cloud SaaS.
|
token = svcauth.HostCredentialsToken(oauthToken.AccessToken)
|
||||||
var tokenDiags tfdiags.Diagnostics
|
|
||||||
token, tokenDiags = c.interactiveGetTokenByPassword(hostname, credsCtx, clientConfig)
|
|
||||||
diags = diags.Append(tokenDiags)
|
|
||||||
if tokenDiags.HasErrors() {
|
|
||||||
c.showDiagnostics(diags)
|
|
||||||
return 1
|
|
||||||
}
|
}
|
||||||
default:
|
} else if service != nil {
|
||||||
diags = diags.Append(tfdiags.Sourceless(
|
token, tokenDiags = c.interactiveGetTokenByUI(hostname, credsCtx, service)
|
||||||
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),
|
diags = diags.Append(tokenDiags)
|
||||||
))
|
if diags.HasErrors() {
|
||||||
c.showDiagnostics(diags)
|
c.showDiagnostics(diags)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
err = creds.StoreForHost(hostname, svcauth.HostCredentialsToken(token.AccessToken))
|
err = creds.StoreForHost(hostname, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
diags = diags.Append(tfdiags.Sourceless(
|
diags = diags.Append(tfdiags.Sourceless(
|
||||||
tfdiags.Error,
|
tfdiags.Error,
|
||||||
|
@ -468,10 +497,72 @@ func (c *LoginCommand) interactiveGetTokenByPassword(hostname svchost.Hostname,
|
||||||
return token, diags
|
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
|
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() {
|
if grantType.UsesAuthorizationEndpoint() {
|
||||||
c.Ui.Output(
|
c.Ui.Output(
|
||||||
|
|
|
@ -12,7 +12,7 @@ import (
|
||||||
|
|
||||||
"github.com/mitchellh/cli"
|
"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-svchost/disco"
|
||||||
"github.com/hashicorp/terraform/command/cliconfig"
|
"github.com/hashicorp/terraform/command/cliconfig"
|
||||||
oauthserver "github.com/hashicorp/terraform/command/testdata/login-oauth-server"
|
oauthserver "github.com/hashicorp/terraform/command/testdata/login-oauth-server"
|
||||||
|
@ -70,6 +70,13 @@ func TestLogin(t *testing.T) {
|
||||||
"token": s.URL + "/token",
|
"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{}{
|
svcs.ForceHostServices(svchost.Hostname("unsupported.example.net"), map[string]interface{}{
|
||||||
// This host intentionally left blank.
|
// 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"})
|
status := c.Run([]string{"unsupported.example.net"})
|
||||||
if status == 0 {
|
if status == 0 {
|
||||||
t.Fatalf("successful exit; want error")
|
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)
|
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
|
}, nil
|
||||||
},
|
},
|
||||||
|
|
||||||
// "terraform login" is disabled until Terraform Cloud is ready to
|
"login": func() (cli.Command, error) {
|
||||||
// support it.
|
return &command.LoginCommand{
|
||||||
/*
|
Meta: meta,
|
||||||
"login": func() (cli.Command, error) {
|
}, nil
|
||||||
return &command.LoginCommand{
|
},
|
||||||
Meta: meta,
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
*/
|
|
||||||
|
|
||||||
"output": func() (cli.Command, error) {
|
"output": func() (cli.Command, error) {
|
||||||
return &command.OutputCommand{
|
return &command.OutputCommand{
|
||||||
|
|
Loading…
Reference in New Issue