Add customized login success output for TFC/E

When logging in to Terraform Cloud or Terraform Enterprise, change the
success output to be a bit more customized for the platform. For
Terraform Cloud, fetch a dynamic welcome banner that intentionally fails
open and defaults to a hardcoded message if its not available for any
reason.
This commit is contained in:
Chris Arcand 2021-04-21 21:23:42 -05:00
parent 33e5d111fe
commit 58cba1dea3
3 changed files with 127 additions and 24 deletions

View File

@ -4,8 +4,10 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io/ioutil"
"log" "log"
"math/rand" "math/rand"
"net" "net"
@ -131,9 +133,9 @@ func (c *LoginCommand) Run(args []string) int {
} }
// If login service is unavailable, check for a TFE v2 API as fallback // If login service is unavailable, check for a TFE v2 API as fallback
var service *url.URL var tfeservice *url.URL
if clientConfig == nil { if clientConfig == nil {
service, err = host.ServiceURL("tfe.v2") tfeservice, err = host.ServiceURL("tfe.v2")
switch err.(type) { switch err.(type) {
case nil: case nil:
// Success! // Success!
@ -184,6 +186,8 @@ func (c *LoginCommand) Run(args []string) int {
oauthToken, tokenDiags = c.interactiveGetTokenByCode(hostname, credsCtx, clientConfig) oauthToken, tokenDiags = c.interactiveGetTokenByCode(hostname, credsCtx, clientConfig)
case clientConfig.SupportedGrantTypes.Has(disco.OAuthOwnerPasswordGrant) && hostname == svchost.Hostname("app.terraform.io"): case clientConfig.SupportedGrantTypes.Has(disco.OAuthOwnerPasswordGrant) && hostname == svchost.Hostname("app.terraform.io"):
// The password grant type is allowed only for Terraform Cloud SaaS. // The password grant type is allowed only for Terraform Cloud SaaS.
// Note this case is purely theoretical at this point, as TFC currently uses
// its own bespoke login protocol (tfe)
oauthToken, tokenDiags = c.interactiveGetTokenByPassword(hostname, credsCtx, clientConfig) oauthToken, tokenDiags = c.interactiveGetTokenByPassword(hostname, credsCtx, clientConfig)
default: default:
tokenDiags = tokenDiags.Append(tfdiags.Sourceless( tokenDiags = tokenDiags.Append(tfdiags.Sourceless(
@ -195,8 +199,8 @@ func (c *LoginCommand) Run(args []string) int {
if oauthToken != nil { if oauthToken != nil {
token = svcauth.HostCredentialsToken(oauthToken.AccessToken) token = svcauth.HostCredentialsToken(oauthToken.AccessToken)
} }
} else if service != nil { } else if tfeservice != nil {
token, tokenDiags = c.interactiveGetTokenByUI(hostname, credsCtx, service) token, tokenDiags = c.interactiveGetTokenByUI(hostname, credsCtx, tfeservice)
} }
diags = diags.Append(tokenDiags) diags = diags.Append(tokenDiags)
@ -220,19 +224,96 @@ func (c *LoginCommand) Run(args []string) int {
} }
c.Ui.Output("\n---------------------------------------------------------------------------------\n") c.Ui.Output("\n---------------------------------------------------------------------------------\n")
c.Ui.Output( if hostname == "app.terraform.io" { // Terraform Cloud
fmt.Sprintf( var motd struct {
c.Colorize().Color(strings.TrimSpace(` Message string `json:"msg"`
Errors []interface{} `json:"errors"`
}
// Throughout the entire process of fetching a MOTD from TFC, use a default
// message if the platform-provided message is unavailable for any reason -
// be it the service isn't provided, the request failed, or any sort of
// platform error returned.
motdServiceURL, err := host.ServiceURL("motd.v1")
if err != nil {
c.outputDefaultTFCLoginSuccess(err)
return 0
}
req, err := http.NewRequest("GET", motdServiceURL.String(), nil)
if err != nil {
c.outputDefaultTFCLoginSuccess(err)
return 0
}
req.Header.Set("Authorization", "Bearer "+token.Token())
resp, err := httpclient.New().Do(req)
if err != nil {
c.outputDefaultTFCLoginSuccess(err)
return 0
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
c.outputDefaultTFCLoginSuccess(err)
return 0
}
defer resp.Body.Close()
json.Unmarshal(body, &motd)
if motd.Errors == nil && motd.Message != "" {
c.Ui.Output(
c.Colorize().Color(motd.Message),
)
return 0
} else {
c.outputDefaultTFCLoginSuccess(fmt.Errorf("platform responded with errors or an empty message"))
return 0
}
}
if tfeservice != nil { // Terraform Enterprise
c.outputDefaultTFELoginSuccess(dispHostname)
} else {
c.Ui.Output(
fmt.Sprintf(
c.Colorize().Color(strings.TrimSpace(`
[green][bold]Success![reset] [bold]Terraform has obtained and saved an API token.[reset] [green][bold]Success![reset] [bold]Terraform has obtained and saved an API token.[reset]
The new API token will be used for any future Terraform command that must make The new API token will be used for any future Terraform command that must make
authenticated requests to %s. authenticated requests to %s.
`)),
dispHostname,
) + "\n",
)
}
return 0
}
func (c *LoginCommand) outputDefaultTFELoginSuccess(dispHostname string) {
c.Ui.Output(
fmt.Sprintf(
c.Colorize().Color(strings.TrimSpace(`
[green][bold]Success![reset] [bold]Logged in to Terraform Enterprise (%s)[reset]
`)), `)),
dispHostname, dispHostname,
) + "\n", ) + "\n",
) )
}
return 0 func (c *LoginCommand) outputDefaultTFCLoginSuccess(err error) {
log.Printf("[TRACE] login: An error occurred attempting to fetch a message of the day for Terraform Cloud: %s", err)
c.Ui.Output(
fmt.Sprintf(
c.Colorize().Color(strings.TrimSpace(`
[green][bold]Success![reset] [bold]Logged in to Terraform Cloud[reset]
`)),
) + "\n",
)
} }
// Help implements cli.Command. // Help implements cli.Command.

View File

@ -56,16 +56,6 @@ func TestLogin(t *testing.T) {
svcs := disco.NewWithCredentialsSource(creds) svcs := disco.NewWithCredentialsSource(creds)
svcs.SetUserAgent(httpclient.TerraformUserAgent(version.String())) svcs.SetUserAgent(httpclient.TerraformUserAgent(version.String()))
svcs.ForceHostServices(svchost.Hostname("app.terraform.io"), map[string]interface{}{
"login.v1": map[string]interface{}{
// On app.terraform.io we use password-based authorization.
// That's the only hostname that it's permitted for, so we can't
// use a fake hostname here.
"client": "terraformcli",
"token": s.URL + "/token",
"grant_types": []interface{}{"password"},
},
})
svcs.ForceHostServices(svchost.Hostname("example.com"), map[string]interface{}{ svcs.ForceHostServices(svchost.Hostname("example.com"), map[string]interface{}{
"login.v1": map[string]interface{}{ "login.v1": map[string]interface{}{
// For this fake hostname we'll use a conventional OAuth flow, // For this fake hostname we'll use a conventional OAuth flow,
@ -86,9 +76,17 @@ func TestLogin(t *testing.T) {
"scopes": []interface{}{"app1.full_access", "app2.read_only"}, "scopes": []interface{}{"app1.full_access", "app2.read_only"},
}, },
}) })
svcs.ForceHostServices(svchost.Hostname("app.terraform.io"), map[string]interface{}{
// This represents Terraform Cloud, which does not yet support the
// login API, but does support its own bespoke tokens API.
"tfe.v2": ts.URL + "/api/v2",
"tfe.v2.1": ts.URL + "/api/v2",
"tfe.v2.2": ts.URL + "/api/v2",
"motd.v1": ts.URL + "/api/terraform/motd",
})
svcs.ForceHostServices(svchost.Hostname("tfe.acme.com"), map[string]interface{}{ svcs.ForceHostServices(svchost.Hostname("tfe.acme.com"), map[string]interface{}{
// This represents a Terraform Enterprise instance which does not // This represents a Terraform Enterprise instance which does not
// yet support the login API, but does support the TFE tokens API. // yet support the login API, but does support its own bespoke tokens API.
"tfe.v2": ts.URL + "/api/v2", "tfe.v2": ts.URL + "/api/v2",
"tfe.v2.1": ts.URL + "/api/v2", "tfe.v2.1": ts.URL + "/api/v2",
"tfe.v2.2": ts.URL + "/api/v2", "tfe.v2.2": ts.URL + "/api/v2",
@ -109,13 +107,14 @@ func TestLogin(t *testing.T) {
} }
} }
t.Run("defaulting to app.terraform.io with password flow", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) { t.Run("app.terraform.io (no login support)", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) {
// Enter "yes" at the consent prompt, then paste a token with some
// accidental whitespace.
defer testInputMap(t, map[string]string{ defer testInputMap(t, map[string]string{
"approve": "yes", "approve": "yes",
"username": "foo", "token": " good-token ",
"password": "bar",
})() })()
status := c.Run(nil) status := c.Run([]string{"app.terraform.io"})
if status != 0 { if status != 0 {
t.Fatalf("unexpected error code %d\nstderr:\n%s", status, ui.ErrorWriter.String()) t.Fatalf("unexpected error code %d\nstderr:\n%s", status, ui.ErrorWriter.String())
} }
@ -128,6 +127,9 @@ func TestLogin(t *testing.T) {
if got, want := creds.Token(), "good-token"; got != want { if got, want := creds.Token(), "good-token"; got != want {
t.Errorf("wrong token %q; want %q", got, want) t.Errorf("wrong token %q; want %q", got, want)
} }
if got, want := ui.OutputWriter.String(), "Welcome to Terraform Cloud!"; !strings.Contains(got, want) {
t.Errorf("expected output to contain %q, but was:\n%s", want, got)
}
})) }))
t.Run("example.com with authorization code flow", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) { t.Run("example.com with authorization code flow", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) {
@ -148,6 +150,10 @@ func TestLogin(t *testing.T) {
if got, want := creds.Token(), "good-token"; got != want { if got, want := creds.Token(), "good-token"; got != want {
t.Errorf("wrong token %q; want %q", got, want) t.Errorf("wrong token %q; want %q", got, want)
} }
if got, want := ui.OutputWriter.String(), "Terraform has obtained and saved an API token."; !strings.Contains(got, want) {
t.Errorf("expected output to contain %q, but was:\n%s", want, got)
}
})) }))
t.Run("example.com results in no scopes", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) { t.Run("example.com results in no scopes", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) {
@ -179,6 +185,10 @@ func TestLogin(t *testing.T) {
if got, want := creds.Token(), "good-token"; got != want { if got, want := creds.Token(), "good-token"; got != want {
t.Errorf("wrong token %q; want %q", got, want) t.Errorf("wrong token %q; want %q", got, want)
} }
if got, want := ui.OutputWriter.String(), "Terraform has obtained and saved an API token."; !strings.Contains(got, want) {
t.Errorf("expected output to contain %q, but was:\n%s", want, got)
}
})) }))
t.Run("with-scopes.example.com results in expected scopes", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) { t.Run("with-scopes.example.com results in expected scopes", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) {
@ -216,6 +226,10 @@ func TestLogin(t *testing.T) {
if got, want := creds.Token(), "good-token"; got != want { if got, want := creds.Token(), "good-token"; got != want {
t.Errorf("wrong token %q; want %q", got, want) t.Errorf("wrong token %q; want %q", got, want)
} }
if got, want := ui.OutputWriter.String(), "Logged in to Terraform Enterprise"; !strings.Contains(got, want) {
t.Errorf("expected output to contain %q, but was:\n%s", want, got)
}
})) }))
t.Run("TFE host without login support, incorrectly pasted token", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) { t.Run("TFE host without login support, incorrectly pasted token", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) {

View File

@ -11,6 +11,7 @@ import (
const ( const (
goodToken = "good-token" goodToken = "good-token"
accountDetails = `{"data":{"id":"user-abc123","type":"users","attributes":{"username":"testuser","email":"testuser@example.com"}}}` accountDetails = `{"data":{"id":"user-abc123","type":"users","attributes":{"username":"testuser","email":"testuser@example.com"}}}`
MOTD = `{"msg":"Welcome to Terraform Cloud!"}`
) )
// Handler is an implementation of net/http.Handler that provides a stub // Handler is an implementation of net/http.Handler that provides a stub
@ -29,6 +30,8 @@ func (h handler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
h.servePing(resp, req) h.servePing(resp, req)
case "/api/v2/account/details": case "/api/v2/account/details":
h.serveAccountDetails(resp, req) h.serveAccountDetails(resp, req)
case "/api/terraform/motd":
h.serveMOTD(resp, req)
default: default:
fmt.Printf("404 when fetching %s\n", req.URL.String()) fmt.Printf("404 when fetching %s\n", req.URL.String())
http.Error(resp, `{"errors":[{"status":"404","title":"not found"}]}`, http.StatusNotFound) http.Error(resp, `{"errors":[{"status":"404","title":"not found"}]}`, http.StatusNotFound)
@ -49,6 +52,11 @@ func (h handler) serveAccountDetails(resp http.ResponseWriter, req *http.Request
resp.Write([]byte(accountDetails)) resp.Write([]byte(accountDetails))
} }
func (h handler) serveMOTD(resp http.ResponseWriter, req *http.Request) {
resp.WriteHeader(http.StatusOK)
resp.Write([]byte(MOTD))
}
func init() { func init() {
Handler = handler{} Handler = handler{}
} }