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:
parent
33e5d111fe
commit
58cba1dea3
|
@ -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.
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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{}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue