Skip to content

Commit

Permalink
Merge pull request #28479 from hashicorp/motd-for-tfc
Browse files Browse the repository at this point in the history
New login success output for TFC/E
  • Loading branch information
chrisarcand committed Apr 22, 2021
2 parents 98ce186 + 58ec680 commit 15b6a16
Show file tree
Hide file tree
Showing 4 changed files with 142 additions and 25 deletions.
105 changes: 97 additions & 8 deletions command/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"log"
"math/rand"
"net"
Expand Down Expand Up @@ -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!
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand All @@ -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.
Expand Down
46 changes: 30 additions & 16 deletions command/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand All @@ -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())
}
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 7 additions & 1 deletion command/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
8 changes: 8 additions & 0 deletions command/testdata/login-tfe-server/tfeserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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{}
}

0 comments on commit 15b6a16

Please sign in to comment.