Merge pull request #28479 from hashicorp/motd-for-tfc
New login success output for TFC/E
This commit is contained in:
commit
15b6a1614c
105
command/login.go
105
command/login.go
|
@ -4,8 +4,10 @@ import (
|
|||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"math/rand"
|
||||
"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
|
||||
var service *url.URL
|
||||
var tfeservice *url.URL
|
||||
if clientConfig == nil {
|
||||
service, err = host.ServiceURL("tfe.v2")
|
||||
tfeservice, err = host.ServiceURL("tfe.v2")
|
||||
switch err.(type) {
|
||||
case nil:
|
||||
// Success!
|
||||
|
@ -184,6 +186,8 @@ func (c *LoginCommand) Run(args []string) int {
|
|||
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.
|
||||
// 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)
|
||||
default:
|
||||
tokenDiags = tokenDiags.Append(tfdiags.Sourceless(
|
||||
|
@ -195,8 +199,8 @@ func (c *LoginCommand) Run(args []string) int {
|
|||
if oauthToken != nil {
|
||||
token = svcauth.HostCredentialsToken(oauthToken.AccessToken)
|
||||
}
|
||||
} else if service != nil {
|
||||
token, tokenDiags = c.interactiveGetTokenByUI(hostname, credsCtx, service)
|
||||
} else if tfeservice != nil {
|
||||
token, tokenDiags = c.interactiveGetTokenByUI(hostname, credsCtx, tfeservice)
|
||||
}
|
||||
|
||||
diags = diags.Append(tokenDiags)
|
||||
|
@ -220,19 +224,104 @@ func (c *LoginCommand) Run(args []string) int {
|
|||
}
|
||||
|
||||
c.Ui.Output("\n---------------------------------------------------------------------------------\n")
|
||||
c.Ui.Output(
|
||||
fmt.Sprintf(
|
||||
c.Colorize().Color(strings.TrimSpace(`
|
||||
if hostname == "app.terraform.io" { // Terraform Cloud
|
||||
var motd struct {
|
||||
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.logMOTDError(err)
|
||||
c.outputDefaultTFCLoginSuccess()
|
||||
return 0
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", motdServiceURL.String(), nil)
|
||||
if err != nil {
|
||||
c.logMOTDError(err)
|
||||
c.outputDefaultTFCLoginSuccess()
|
||||
return 0
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token.Token())
|
||||
|
||||
resp, err := httpclient.New().Do(req)
|
||||
if err != nil {
|
||||
c.logMOTDError(err)
|
||||
c.outputDefaultTFCLoginSuccess()
|
||||
return 0
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
c.logMOTDError(err)
|
||||
c.outputDefaultTFCLoginSuccess()
|
||||
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.logMOTDError(fmt.Errorf("platform responded with errors or an empty message"))
|
||||
c.outputDefaultTFCLoginSuccess()
|
||||
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]
|
||||
|
||||
The new API token will be used for any future Terraform command that must make
|
||||
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,
|
||||
) + "\n",
|
||||
)
|
||||
}
|
||||
|
||||
return 0
|
||||
func (c *LoginCommand) outputDefaultTFCLoginSuccess() {
|
||||
c.Ui.Output(
|
||||
fmt.Sprintf(
|
||||
c.Colorize().Color(strings.TrimSpace(`
|
||||
[green][bold]Success![reset] [bold]Logged in to Terraform Cloud[reset]
|
||||
`)),
|
||||
) + "\n",
|
||||
)
|
||||
}
|
||||
|
||||
func (c *LoginCommand) logMOTDError(err error) {
|
||||
log.Printf("[TRACE] login: An error occurred attempting to fetch a message of the day for Terraform Cloud: %s", err)
|
||||
}
|
||||
|
||||
// Help implements cli.Command.
|
||||
|
|
|
@ -56,16 +56,6 @@ func TestLogin(t *testing.T) {
|
|||
svcs := disco.NewWithCredentialsSource(creds)
|
||||
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{}{
|
||||
"login.v1": map[string]interface{}{
|
||||
// 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"},
|
||||
},
|
||||
})
|
||||
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{}{
|
||||
// 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.1": 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{
|
||||
"approve": "yes",
|
||||
"username": "foo",
|
||||
"password": "bar",
|
||||
"approve": "yes",
|
||||
"token": " good-token ",
|
||||
})()
|
||||
status := c.Run(nil)
|
||||
status := c.Run([]string{"app.terraform.io"})
|
||||
if status != 0 {
|
||||
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 {
|
||||
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) {
|
||||
|
@ -148,6 +150,10 @@ func TestLogin(t *testing.T) {
|
|||
if got, want := creds.Token(), "good-token"; 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) {
|
||||
|
@ -179,6 +185,10 @@ func TestLogin(t *testing.T) {
|
|||
if got, want := creds.Token(), "good-token"; 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) {
|
||||
|
@ -216,6 +226,10 @@ func TestLogin(t *testing.T) {
|
|||
if got, want := creds.Token(), "good-token"; 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) {
|
||||
|
|
|
@ -250,8 +250,14 @@ func (m *Meta) StateOutPath() string {
|
|||
|
||||
// Colorize returns the colorization structure for a command.
|
||||
func (m *Meta) Colorize() *colorstring.Colorize {
|
||||
colors := make(map[string]string)
|
||||
for k, v := range colorstring.DefaultColors {
|
||||
colors[k] = v
|
||||
}
|
||||
colors["purple"] = "38;5;57"
|
||||
|
||||
return &colorstring.Colorize{
|
||||
Colors: colorstring.DefaultColors,
|
||||
Colors: colors,
|
||||
Disable: !m.color,
|
||||
Reset: true,
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
const (
|
||||
goodToken = "good-token"
|
||||
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
|
||||
|
@ -29,6 +30,8 @@ func (h handler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
|
|||
h.servePing(resp, req)
|
||||
case "/api/v2/account/details":
|
||||
h.serveAccountDetails(resp, req)
|
||||
case "/api/terraform/motd":
|
||||
h.serveMOTD(resp, req)
|
||||
default:
|
||||
fmt.Printf("404 when fetching %s\n", req.URL.String())
|
||||
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))
|
||||
}
|
||||
|
||||
func (h handler) serveMOTD(resp http.ResponseWriter, req *http.Request) {
|
||||
resp.WriteHeader(http.StatusOK)
|
||||
resp.Write([]byte(MOTD))
|
||||
}
|
||||
|
||||
func init() {
|
||||
Handler = handler{}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue