Merge #22728: Stubbed-out "terraform login" subcommand
This commit is contained in:
commit
fb1aefe22b
|
@ -0,0 +1,595 @@
|
|||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/terraform/command/cliconfig"
|
||||
"github.com/hashicorp/terraform/httpclient"
|
||||
"github.com/hashicorp/terraform/svchost"
|
||||
svcauth "github.com/hashicorp/terraform/svchost/auth"
|
||||
"github.com/hashicorp/terraform/svchost/disco"
|
||||
"github.com/hashicorp/terraform/tfdiags"
|
||||
|
||||
uuid "github.com/hashicorp/go-uuid"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// LoginCommand is a Command implementation that runs an interactive login
|
||||
// flow for a remote service host. It then stashes credentials in a tfrc
|
||||
// file in the user's home directory.
|
||||
type LoginCommand struct {
|
||||
Meta
|
||||
}
|
||||
|
||||
// Run implements cli.Command.
|
||||
func (c *LoginCommand) Run(args []string) int {
|
||||
args, err := c.Meta.process(args, false)
|
||||
if err != nil {
|
||||
return 1
|
||||
}
|
||||
|
||||
cmdFlags := c.Meta.extendedFlagSet("login")
|
||||
cmdFlags.Usage = func() { c.Ui.Error(c.Help()) }
|
||||
if err := cmdFlags.Parse(args); err != nil {
|
||||
return 1
|
||||
}
|
||||
|
||||
args = cmdFlags.Args()
|
||||
if len(args) > 1 {
|
||||
c.Ui.Error(
|
||||
"The login command expects at most one argument: the host to log in to.")
|
||||
cmdFlags.Usage()
|
||||
return 1
|
||||
}
|
||||
|
||||
var diags tfdiags.Diagnostics
|
||||
|
||||
if !c.input {
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
"Login is an interactive command",
|
||||
"The \"terraform login\" command uses interactive prompts to obtain and record credentials, so it can't be run with input disabled.\n\nTo configure credentials in a non-interactive context, write existing credentials directly to a CLI configuration file.",
|
||||
))
|
||||
c.showDiagnostics(diags)
|
||||
return 1
|
||||
}
|
||||
|
||||
givenHostname := "app.terraform.io"
|
||||
if len(args) != 0 {
|
||||
givenHostname = args[0]
|
||||
}
|
||||
|
||||
hostname, err := svchost.ForComparison(givenHostname)
|
||||
if err != nil {
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
"Invalid hostname",
|
||||
fmt.Sprintf("The given hostname %q is not valid: %s.", givenHostname, err.Error()),
|
||||
))
|
||||
c.showDiagnostics(diags)
|
||||
return 1
|
||||
}
|
||||
|
||||
// From now on, since we've validated the given hostname, we should use
|
||||
// dispHostname in the UI to ensure we're presenting it in the canonical
|
||||
// form, in case that helpers users with debugging when things aren't
|
||||
// working as expected. (Perhaps the normalization is part of the cause.)
|
||||
dispHostname := hostname.ForDisplay()
|
||||
|
||||
host, err := c.Services.Discover(hostname)
|
||||
if err != nil {
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
"Service discovery failed for "+dispHostname,
|
||||
|
||||
// Contrary to usual Go idiom, the Discover function returns
|
||||
// full sentences with initial capitalization in its error messages,
|
||||
// and they are written with the end-user as the audience. We
|
||||
// only need to add the trailing period to make them consistent
|
||||
// with our usual error reporting standards.
|
||||
err.Error()+".",
|
||||
))
|
||||
c.showDiagnostics(diags)
|
||||
return 1
|
||||
}
|
||||
|
||||
creds := c.Services.CredentialsSource()
|
||||
|
||||
// In normal use (i.e. without test mocks/fakes) creds will be an instance
|
||||
// of the command/cliconfig.CredentialsSource type, which has some extra
|
||||
// methods we can use to give the user better feedback about what we're
|
||||
// going to do. credsCtx will be nil if it's any other implementation,
|
||||
// though.
|
||||
var credsCtx *loginCredentialsContext
|
||||
if c, ok := creds.(*cliconfig.CredentialsSource); ok {
|
||||
filename, _ := c.CredentialsFilePath()
|
||||
credsCtx = &loginCredentialsContext{
|
||||
Location: c.HostCredentialsLocation(hostname),
|
||||
LocalFilename: filename, // empty in the very unlikely event that we can't select a config directory for this user
|
||||
HelperType: c.CredentialsHelperType(),
|
||||
}
|
||||
}
|
||||
|
||||
clientConfig, err := host.ServiceOAuthClient("login.v1")
|
||||
switch err.(type) {
|
||||
case nil:
|
||||
// Great! No problem, then.
|
||||
case *disco.ErrServiceNotProvided:
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
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:
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
"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),
|
||||
))
|
||||
default:
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
"Host does not support Terraform login",
|
||||
fmt.Sprintf("The given hostname %q cannot support \"terraform login\": %s.", dispHostname, err),
|
||||
))
|
||||
}
|
||||
|
||||
if credsCtx.Location == cliconfig.CredentialsInOtherFile {
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
fmt.Sprintf("Credentials for %s are manually configured", dispHostname),
|
||||
"The \"terraform login\" command cannot log in because credentials for this host are already configured in a CLI configuration file.\n\nTo log in, first revoke the existing credentials and remove that block from the CLI configuration.",
|
||||
))
|
||||
}
|
||||
|
||||
if diags.HasErrors() {
|
||||
c.showDiagnostics(diags)
|
||||
return 1
|
||||
}
|
||||
|
||||
var token *oauth2.Token
|
||||
switch {
|
||||
case clientConfig.SupportedGrantTypes.Has(disco.OAuthAuthzCodeGrant):
|
||||
// We prefer an OAuth code grant if the server supports it.
|
||||
var tokenDiags tfdiags.Diagnostics
|
||||
token, tokenDiags = c.interactiveGetTokenByCode(hostname, credsCtx, clientConfig)
|
||||
diags = diags.Append(tokenDiags)
|
||||
if tokenDiags.HasErrors() {
|
||||
c.showDiagnostics(diags)
|
||||
return 1
|
||||
}
|
||||
case clientConfig.SupportedGrantTypes.Has(disco.OAuthOwnerPasswordGrant) && hostname == svchost.Hostname("app.terraform.io"):
|
||||
// The password grant type is allowed only for Terraform Cloud SaaS.
|
||||
var tokenDiags tfdiags.Diagnostics
|
||||
token, tokenDiags = c.interactiveGetTokenByPassword(hostname, credsCtx, clientConfig)
|
||||
diags = diags.Append(tokenDiags)
|
||||
if tokenDiags.HasErrors() {
|
||||
c.showDiagnostics(diags)
|
||||
return 1
|
||||
}
|
||||
default:
|
||||
diags = diags.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),
|
||||
))
|
||||
c.showDiagnostics(diags)
|
||||
return 1
|
||||
}
|
||||
|
||||
err = creds.StoreForHost(hostname, svcauth.HostCredentialsToken(token.AccessToken))
|
||||
if err != nil {
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
"Failed to save API token",
|
||||
fmt.Sprintf("The given host returned an API token, but Terraform failed to save it: %s.", err),
|
||||
))
|
||||
}
|
||||
|
||||
c.showDiagnostics(diags)
|
||||
if diags.HasErrors() {
|
||||
return 1
|
||||
}
|
||||
|
||||
c.Ui.Output("\n---------------------------------------------------------------------------------\n")
|
||||
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
|
||||
}
|
||||
|
||||
// Help implements cli.Command.
|
||||
func (c *LoginCommand) Help() string {
|
||||
defaultFile := c.defaultOutputFile()
|
||||
if defaultFile == "" {
|
||||
// Because this is just for the help message and it's very unlikely
|
||||
// that a user wouldn't have a functioning home directory anyway,
|
||||
// we'll just use a placeholder here. The real command has some
|
||||
// more complex behavior for this case. This result is not correct
|
||||
// on all platforms, but given how unlikely we are to hit this case
|
||||
// that seems okay.
|
||||
defaultFile = "~/.terraform/credentials.tfrc.json"
|
||||
}
|
||||
|
||||
helpText := fmt.Sprintf(`
|
||||
Usage: terraform login [hostname]
|
||||
|
||||
Retrieves an authentication token for the given hostname, if it supports
|
||||
automatic login, and saves it in a credentials file in your home directory.
|
||||
|
||||
If no hostname is provided, the default hostname is app.terraform.io, to
|
||||
log in to Terraform Cloud.
|
||||
|
||||
If not overridden by credentials helper settings in the CLI configuration,
|
||||
the credentials will be written to the following local file:
|
||||
%s
|
||||
`, defaultFile)
|
||||
return strings.TrimSpace(helpText)
|
||||
}
|
||||
|
||||
// Synopsis implements cli.Command.
|
||||
func (c *LoginCommand) Synopsis() string {
|
||||
return "Obtain and save credentials for a remote host"
|
||||
}
|
||||
|
||||
func (c *LoginCommand) defaultOutputFile() string {
|
||||
if c.CLIConfigDir == "" {
|
||||
return "" // no default available
|
||||
}
|
||||
return filepath.Join(c.CLIConfigDir, "credentials.tfrc.json")
|
||||
}
|
||||
|
||||
func (c *LoginCommand) interactiveGetTokenByCode(hostname svchost.Hostname, credsCtx *loginCredentialsContext, clientConfig *disco.OAuthClient) (*oauth2.Token, tfdiags.Diagnostics) {
|
||||
var diags tfdiags.Diagnostics
|
||||
|
||||
confirm, confirmDiags := c.interactiveContextConsent(hostname, disco.OAuthAuthzCodeGrant, credsCtx)
|
||||
diags = diags.Append(confirmDiags)
|
||||
if !confirm {
|
||||
diags = diags.Append(errors.New("Login cancelled"))
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
// We'll use an entirely pseudo-random UUID for our temporary request
|
||||
// state. The OAuth server must echo this back to us in the callback
|
||||
// request to make it difficult for some other running process to
|
||||
// interfere by sending its own request to our temporary server.
|
||||
reqState, err := uuid.GenerateUUID()
|
||||
if err != nil {
|
||||
// This should be very unlikely, but could potentially occur if e.g.
|
||||
// there's not enough pseudo-random entropy available.
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
"Can't generate login request state",
|
||||
fmt.Sprintf("Cannot generate random request identifier for login request: %s.", err),
|
||||
))
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
proofKey, proofKeyChallenge, err := c.proofKey()
|
||||
if err != nil {
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
"Can't generate login request state",
|
||||
fmt.Sprintf("Cannot generate random prrof key for login request: %s.", err),
|
||||
))
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
listener, callbackURL, err := c.listenerForCallback(clientConfig.MinPort, clientConfig.MaxPort)
|
||||
if err != nil {
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
"Can't start temporary login server",
|
||||
fmt.Sprintf(
|
||||
"The login process uses OAuth, which requires starting a temporary HTTP server on localhost. However, no TCP port numbers between %d and %d are available to create such a server.",
|
||||
clientConfig.MinPort, clientConfig.MaxPort,
|
||||
),
|
||||
))
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
// codeCh will allow our temporary HTTP server to transmit the OAuth code
|
||||
// to the main execution path that follows.
|
||||
codeCh := make(chan string)
|
||||
server := &http.Server{
|
||||
Handler: http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
|
||||
log.Printf("[TRACE] login: request to callback server")
|
||||
err := req.ParseForm()
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] login: cannot ParseForm on callback request: %s", err)
|
||||
resp.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
gotState := req.Form.Get("state")
|
||||
if gotState != reqState {
|
||||
log.Printf("[ERROR] login: incorrect \"state\" value in callback request")
|
||||
resp.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
gotCode := req.Form.Get("code")
|
||||
if gotCode == "" {
|
||||
log.Printf("[ERROR] login: no \"code\" argument in callback request")
|
||||
resp.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[TRACE] login: request contains an authorization code")
|
||||
|
||||
// Send the code to our blocking wait below, so that the token
|
||||
// fetching process can continue.
|
||||
codeCh <- gotCode
|
||||
close(codeCh)
|
||||
|
||||
log.Printf("[TRACE] login: returning response from callback server")
|
||||
|
||||
resp.Header().Add("Content-Type", "text/html")
|
||||
resp.WriteHeader(200)
|
||||
resp.Write([]byte(callbackSuccessMessage))
|
||||
}),
|
||||
}
|
||||
go func() {
|
||||
err = server.Serve(listener)
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
"Can't start temporary login server",
|
||||
fmt.Sprintf(
|
||||
"The login process uses OAuth, which requires starting a temporary HTTP server on localhost. However, no TCP port numbers between %d and %d are available to create such a server.",
|
||||
clientConfig.MinPort, clientConfig.MaxPort,
|
||||
),
|
||||
))
|
||||
close(codeCh)
|
||||
}
|
||||
}()
|
||||
|
||||
oauthConfig := &oauth2.Config{
|
||||
ClientID: clientConfig.ID,
|
||||
Endpoint: clientConfig.Endpoint(),
|
||||
RedirectURL: callbackURL,
|
||||
}
|
||||
|
||||
authCodeURL := oauthConfig.AuthCodeURL(
|
||||
reqState,
|
||||
oauth2.SetAuthURLParam("code_challenge", proofKeyChallenge),
|
||||
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
|
||||
)
|
||||
|
||||
launchBrowserManually := false
|
||||
if c.BrowserLauncher != nil {
|
||||
err = c.BrowserLauncher.OpenURL(authCodeURL)
|
||||
if err == nil {
|
||||
c.Ui.Output(fmt.Sprintf("Terraform must now open a web browser to the login 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", authCodeURL))
|
||||
} 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 login page for %s:\n %s\n", hostname.ForDisplay(), authCodeURL))
|
||||
}
|
||||
|
||||
c.Ui.Output("Terraform will now wait for the host to signal that login was successful.\n")
|
||||
|
||||
code, ok := <-codeCh
|
||||
if !ok {
|
||||
// If we got no code at all then the server wasn't able to start
|
||||
// up, so we'll just give up.
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
err = server.Close()
|
||||
if err != nil {
|
||||
// The server will close soon enough when our process exits anyway,
|
||||
// so we won't fuss about it for right now.
|
||||
log.Printf("[WARN] login: callback server can't shut down: %s", err)
|
||||
}
|
||||
|
||||
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpclient.New())
|
||||
token, err := oauthConfig.Exchange(
|
||||
ctx, code,
|
||||
oauth2.SetAuthURLParam("code_verifier", proofKey),
|
||||
)
|
||||
if err != nil {
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
"Failed to obtain auth token",
|
||||
fmt.Sprintf("The remote server did not assign an auth token: %s.", err),
|
||||
))
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
return token, diags
|
||||
}
|
||||
|
||||
func (c *LoginCommand) interactiveGetTokenByPassword(hostname svchost.Hostname, credsCtx *loginCredentialsContext, clientConfig *disco.OAuthClient) (*oauth2.Token, tfdiags.Diagnostics) {
|
||||
var diags tfdiags.Diagnostics
|
||||
|
||||
confirm, confirmDiags := c.interactiveContextConsent(hostname, disco.OAuthOwnerPasswordGrant, credsCtx)
|
||||
diags = diags.Append(confirmDiags)
|
||||
if !confirm {
|
||||
diags = diags.Append(errors.New("Login cancelled"))
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
c.Ui.Output("\n---------------------------------------------------------------------------------\n")
|
||||
c.Ui.Output("Terraform must temporarily use your password to request an API token.\nThis password will NOT be saved locally.\n")
|
||||
|
||||
username, err := c.Ui.Ask(fmt.Sprintf("Username for %s:", hostname.ForDisplay()))
|
||||
if err != nil {
|
||||
diags = diags.Append(fmt.Errorf("Failed to request username: %s", err))
|
||||
return nil, diags
|
||||
}
|
||||
password, err := c.Ui.AskSecret(fmt.Sprintf("Password for %s:", username))
|
||||
if err != nil {
|
||||
diags = diags.Append(fmt.Errorf("Failed to request password: %s", err))
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
oauthConfig := &oauth2.Config{
|
||||
ClientID: clientConfig.ID,
|
||||
Endpoint: clientConfig.Endpoint(),
|
||||
}
|
||||
token, err := oauthConfig.PasswordCredentialsToken(context.Background(), username, password)
|
||||
if err != nil {
|
||||
// FIXME: The OAuth2 library generates errors that are not appropriate
|
||||
// for a Terraform end-user audience, so once we have more experience
|
||||
// with which errors are most common we should try to recognize them
|
||||
// here and produce better error messages for them.
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
"Failed to retrieve API token",
|
||||
fmt.Sprintf("The remote host did not issue an API token: %s.", err),
|
||||
))
|
||||
}
|
||||
|
||||
return token, diags
|
||||
}
|
||||
|
||||
func (c *LoginCommand) interactiveContextConsent(hostname svchost.Hostname, grantType disco.OAuthGrantType, credsCtx *loginCredentialsContext) (bool, tfdiags.Diagnostics) {
|
||||
var diags tfdiags.Diagnostics
|
||||
|
||||
c.Ui.Output(fmt.Sprintf("Terraform will request an API token for %s using OAuth.\n", hostname.ForDisplay()))
|
||||
|
||||
if grantType.UsesAuthorizationEndpoint() {
|
||||
c.Ui.Output(
|
||||
"This will work only if you are able to use a web browser on this computer to\ncomplete a login process. If not, you must obtain an API token by another\nmeans and configure it in the CLI configuration manually.\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("If login is successful, Terraform will store the token in the configured\n%q credentials helper for use by subsequent commands.\n", credsCtx.HelperType))
|
||||
case cliconfig.CredentialsInPrimaryFile, cliconfig.CredentialsNotAvailable:
|
||||
c.Ui.Output(fmt.Sprintf("If login is successful, Terraform will store the token in plain text in\nthe following file for use by subsequent commands:\n %s\n", credsCtx.LocalFilename))
|
||||
}
|
||||
}
|
||||
|
||||
v, err := c.Ui.Ask("Do you want to proceed? (y/n)")
|
||||
if err != nil {
|
||||
// Should not happen because this command checks that input is enabled
|
||||
// before we get to this point.
|
||||
diags = diags.Append(err)
|
||||
return false, diags
|
||||
}
|
||||
|
||||
switch strings.ToLower(v) {
|
||||
case "y", "yes":
|
||||
return true, diags
|
||||
default:
|
||||
return false, diags
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LoginCommand) listenerForCallback(minPort, maxPort uint16) (net.Listener, string, error) {
|
||||
if minPort < 1024 || maxPort < 1024 {
|
||||
// This should never happen because it should've been checked by
|
||||
// the svchost/disco package when reading the service description,
|
||||
// but we'll prefer to fail hard rather than inadvertently trying
|
||||
// to open an unprivileged port if there are bugs at that layer.
|
||||
panic("listenerForCallback called with privileged port number")
|
||||
}
|
||||
|
||||
availCount := int(maxPort) - int(minPort)
|
||||
|
||||
// We're going to try port numbers within the range at random, so we need
|
||||
// to terminate eventually in case _none_ of the ports are available.
|
||||
// We'll make that 150% of the number of ports just to give us some room
|
||||
// for the random number generator to generate the same port more than
|
||||
// once.
|
||||
// Note that we don't really care about true randomness here... we're just
|
||||
// trying to hop around in the available port space rather than always
|
||||
// working up from the lowest, because we have no information to predict
|
||||
// that any particular number will be more likely to be available than
|
||||
// another.
|
||||
maxTries := availCount + (availCount / 2)
|
||||
|
||||
for tries := 0; tries < maxTries; tries++ {
|
||||
port := rand.Intn(availCount) + int(minPort)
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
log.Printf("[TRACE] login: trying %s as a listen address for temporary OAuth callback server", addr)
|
||||
l, err := net.Listen("tcp4", addr)
|
||||
if err == nil {
|
||||
// We use a path that doesn't end in a slash here because some
|
||||
// OAuth server implementations don't allow callback URLs to
|
||||
// end with slashes.
|
||||
callbackURL := fmt.Sprintf("http://localhost:%d/login", port)
|
||||
log.Printf("[TRACE] login: callback URL will be %s", callbackURL)
|
||||
return l, callbackURL, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf("no suitable TCP ports (between %d and %d) are available for the temporary OAuth callback server", minPort, maxPort)
|
||||
}
|
||||
|
||||
func (c *LoginCommand) proofKey() (key, challenge string, err error) {
|
||||
// Wel use a UUID-like string as the "proof key for code exchange" (PKCE)
|
||||
// that will eventually authenticate our request to the token endpoint.
|
||||
// Standard UUIDs are explicitly not suitable as secrets according to the
|
||||
// UUID spec, but our go-uuid just generates totally random number sequences
|
||||
// formatted in the conventional UUID syntax, so that concern does not
|
||||
// apply here: this is just a 128-bit crypto-random number.
|
||||
key, err = uuid.GenerateUUID()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
h := sha256.New()
|
||||
h.Write([]byte(key))
|
||||
challenge = base64.URLEncoding.EncodeToString(h.Sum(nil))
|
||||
|
||||
return key, challenge, nil
|
||||
}
|
||||
|
||||
type loginCredentialsContext struct {
|
||||
Location cliconfig.CredentialsLocation
|
||||
LocalFilename string
|
||||
HelperType string
|
||||
}
|
||||
|
||||
const callbackSuccessMessage = `
|
||||
<html>
|
||||
<head>
|
||||
<title>Terraform Login</title>
|
||||
<style type="text/css">
|
||||
body {
|
||||
font-family: monospace;
|
||||
color: #fff;
|
||||
background-color: #000;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<p>The login server has returned an authentication code to Terraform.</p>
|
||||
<p>Now close this page and return to the terminal where <tt>terraform login</tt>
|
||||
is running to see the result of the login process.</p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
`
|
|
@ -0,0 +1,135 @@
|
|||
package command
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io/ioutil"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mitchellh/cli"
|
||||
|
||||
"github.com/hashicorp/terraform/command/cliconfig"
|
||||
oauthserver "github.com/hashicorp/terraform/command/testdata/login-oauth-server"
|
||||
"github.com/hashicorp/terraform/command/webbrowser"
|
||||
"github.com/hashicorp/terraform/svchost"
|
||||
"github.com/hashicorp/terraform/svchost/disco"
|
||||
)
|
||||
|
||||
func TestLogin(t *testing.T) {
|
||||
// oauthserver.Handler is a stub OAuth server implementation that will,
|
||||
// on success, always issue a bearer token named "good-token".
|
||||
s := httptest.NewServer(oauthserver.Handler)
|
||||
defer s.Close()
|
||||
|
||||
loginTestCase := func(test func(t *testing.T, c *LoginCommand, ui *cli.MockUi, inp func(string))) func(t *testing.T) {
|
||||
return func(t *testing.T) {
|
||||
t.Helper()
|
||||
workDir, err := ioutil.TempDir("", "terraform-test-command-login")
|
||||
if err != nil {
|
||||
t.Fatalf("cannot create temporary directory: %s", err)
|
||||
}
|
||||
defer os.RemoveAll(workDir)
|
||||
|
||||
// We'll use this context to avoid asynchronous tasks outliving
|
||||
// a single test run.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
ui := cli.NewMockUi()
|
||||
browserLauncher := webbrowser.NewMockLauncher(ctx)
|
||||
creds := cliconfig.EmptyCredentialsSourceForTests(filepath.Join(workDir, "credentials.tfrc.json"))
|
||||
svcs := disco.NewWithCredentialsSource(creds)
|
||||
|
||||
inputBuf := &bytes.Buffer{}
|
||||
ui.InputReader = inputBuf
|
||||
|
||||
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,
|
||||
// with browser-based consent that we'll mock away using a
|
||||
// mock browser launcher below.
|
||||
"client": "anything-goes",
|
||||
"authz": s.URL + "/authz",
|
||||
"token": s.URL + "/token",
|
||||
},
|
||||
})
|
||||
svcs.ForceHostServices(svchost.Hostname("unsupported.example.net"), map[string]interface{}{
|
||||
// This host intentionally left blank.
|
||||
})
|
||||
|
||||
c := &LoginCommand{
|
||||
Meta: Meta{
|
||||
Ui: ui,
|
||||
BrowserLauncher: browserLauncher,
|
||||
Services: svcs,
|
||||
},
|
||||
}
|
||||
|
||||
test(t, c, ui, func(data string) {
|
||||
t.Helper()
|
||||
inputBuf.WriteString(data)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("defaulting to app.terraform.io with password flow", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi, inp func(string)) {
|
||||
// Enter "yes" at the consent prompt, then a username and then a password.
|
||||
inp("yes\nfoo\nbar\n")
|
||||
status := c.Run(nil)
|
||||
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("app.terraform.io"))
|
||||
if err != nil {
|
||||
t.Errorf("failed to retrieve credentials: %s", err)
|
||||
}
|
||||
if got, want := creds.Token(), "good-token"; got != want {
|
||||
t.Errorf("wrong token %q; want %q", got, want)
|
||||
}
|
||||
}))
|
||||
|
||||
t.Run("example.com with authorization code flow", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi, inp func(string)) {
|
||||
// Enter "yes" at the consent prompt.
|
||||
inp("yes\n")
|
||||
status := c.Run([]string{"example.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("example.com"))
|
||||
if err != nil {
|
||||
t.Errorf("failed to retrieve credentials: %s", err)
|
||||
}
|
||||
if got, want := creds.Token(), "good-token"; got != want {
|
||||
t.Errorf("wrong token %q; want %q", got, want)
|
||||
}
|
||||
}))
|
||||
|
||||
t.Run("host without login support", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi, inp func(string)) {
|
||||
status := c.Run([]string{"unsupported.example.net"})
|
||||
if status == 0 {
|
||||
t.Fatalf("successful exit; want error")
|
||||
}
|
||||
|
||||
if got, want := ui.ErrorWriter.String(), "Error: Host does not support Terraform login"; !strings.Contains(got, want) {
|
||||
t.Fatalf("missing expected error message\nwant: %s\nfull output:\n%s", want, got)
|
||||
}
|
||||
}))
|
||||
}
|
|
@ -18,6 +18,7 @@ import (
|
|||
"github.com/hashicorp/terraform/backend"
|
||||
"github.com/hashicorp/terraform/backend/local"
|
||||
"github.com/hashicorp/terraform/command/format"
|
||||
"github.com/hashicorp/terraform/command/webbrowser"
|
||||
"github.com/hashicorp/terraform/configs/configload"
|
||||
"github.com/hashicorp/terraform/helper/experiment"
|
||||
"github.com/hashicorp/terraform/helper/wrappedstreams"
|
||||
|
@ -61,6 +62,14 @@ type Meta struct {
|
|||
// the specific commands being run.
|
||||
RunningInAutomation bool
|
||||
|
||||
// CLIConfigDir is the directory from which CLI configuration files were
|
||||
// read by the caller and the directory where any changes to CLI
|
||||
// configuration files by commands should be made.
|
||||
//
|
||||
// If this is empty then no configuration directory is available and
|
||||
// commands which require one cannot proceed.
|
||||
CLIConfigDir string
|
||||
|
||||
// PluginCacheDir, if non-empty, enables caching of downloaded plugins
|
||||
// into the given directory.
|
||||
PluginCacheDir string
|
||||
|
@ -70,6 +79,10 @@ type Meta struct {
|
|||
// is not suitable, e.g. because of a read-only filesystem.
|
||||
OverrideDataDir string
|
||||
|
||||
// BrowserLauncher is used by commands that need to open a URL in a
|
||||
// web browser.
|
||||
BrowserLauncher webbrowser.Launcher
|
||||
|
||||
// When this channel is closed, the command will be cancelled.
|
||||
ShutdownCh <-chan struct{}
|
||||
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
// +build ignore
|
||||
|
||||
// This file is a helper for those doing _manual_ testing of "terraform login"
|
||||
// and/or "terraform logout" and want to start up a test OAuth server in a
|
||||
// separate process for convenience:
|
||||
//
|
||||
// go run ./command/testdata/login-oauth-server/main.go :8080
|
||||
//
|
||||
// This is _not_ the main way to use this oauthserver package. For automated
|
||||
// test code, import it as a normal Go package instead:
|
||||
//
|
||||
// import oauthserver "github.com/hashicorp/terraform/command/testdata/login-oauth-server"
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
oauthserver "github.com/hashicorp/terraform/command/testdata/login-oauth-server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "Usage: go run ./command/testdata/login-oauth-server/main.go <listen-address>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
host, port, err := net.SplitHostPort(os.Args[1])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Invalid address: %s", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
addr := fmt.Sprintf("%s:%s", host, port)
|
||||
|
||||
fmt.Printf("Will listen on %s...\n", addr)
|
||||
fmt.Printf(
|
||||
configExampleFmt,
|
||||
fmt.Sprintf("http://%s:%s/authz", host, port),
|
||||
fmt.Sprintf("http://%s:%s/token", host, port),
|
||||
fmt.Sprintf("http://%s:%s/revoke", host, port),
|
||||
)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: oauthserver.Handler,
|
||||
}
|
||||
err = server.ListenAndServe()
|
||||
fmt.Fprintln(os.Stderr, err.Error())
|
||||
}
|
||||
|
||||
const configExampleFmt = `
|
||||
host "login-test.example.com" {
|
||||
services = {
|
||||
"login.v1" = {
|
||||
authz = %q
|
||||
token = %q
|
||||
client = "placeholder"
|
||||
grant_types = ["code", "password"]
|
||||
}
|
||||
"logout.v1" = %q
|
||||
}
|
||||
}
|
||||
|
||||
`
|
|
@ -0,0 +1,179 @@
|
|||
// Package oauthserver is a very simplistic OAuth server used only for
|
||||
// the testing of the "terraform login" and "terraform logout" commands.
|
||||
package oauthserver
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Handler is an implementation of net/http.Handler that provides a stub
|
||||
// OAuth server implementation with the following endpoints:
|
||||
//
|
||||
// /authz - authorization endpoint
|
||||
// /token - token endpoint
|
||||
// /revoke - token revocation (logout) endpoint
|
||||
//
|
||||
// The authorization endpoint returns HTML per normal OAuth conventions, but
|
||||
// it also includes an HTTP header X-Redirect-To giving the same URL that the
|
||||
// link in the HTML indicates, allowing a non-browser user-agent to traverse
|
||||
// this robotically in automated tests.
|
||||
var Handler http.Handler
|
||||
|
||||
type handler struct{}
|
||||
|
||||
func (h handler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
|
||||
switch req.URL.Path {
|
||||
case "/authz":
|
||||
h.serveAuthz(resp, req)
|
||||
case "/token":
|
||||
h.serveToken(resp, req)
|
||||
case "/revoke":
|
||||
h.serveRevoke(resp, req)
|
||||
default:
|
||||
resp.WriteHeader(404)
|
||||
}
|
||||
}
|
||||
|
||||
func (h handler) serveAuthz(resp http.ResponseWriter, req *http.Request) {
|
||||
args := req.URL.Query()
|
||||
if rt := args.Get("response_type"); rt != "code" {
|
||||
resp.WriteHeader(400)
|
||||
resp.Write([]byte("wrong response_type"))
|
||||
log.Printf("/authz: incorrect response type %q", rt)
|
||||
return
|
||||
}
|
||||
redirectURL, err := url.Parse(args.Get("redirect_uri"))
|
||||
if err != nil {
|
||||
resp.WriteHeader(400)
|
||||
resp.Write([]byte(fmt.Sprintf("invalid redirect_uri %s: %s", args.Get("redirect_uri"), err)))
|
||||
return
|
||||
}
|
||||
|
||||
state := args.Get("state")
|
||||
challenge := args.Get("code_challenge")
|
||||
challengeMethod := args.Get("code_challenge_method")
|
||||
if challengeMethod == "" {
|
||||
challengeMethod = "plain"
|
||||
}
|
||||
|
||||
// NOTE: This is not a suitable implementation for a real OAuth server
|
||||
// because the code challenge is providing no security whatsoever. This
|
||||
// is just a simple implementation for this stub server.
|
||||
code := fmt.Sprintf("%s:%s", challengeMethod, challenge)
|
||||
|
||||
redirectQuery := redirectURL.Query()
|
||||
redirectQuery.Set("code", code)
|
||||
if state != "" {
|
||||
redirectQuery.Set("state", state)
|
||||
}
|
||||
redirectURL.RawQuery = redirectQuery.Encode()
|
||||
|
||||
respBody := fmt.Sprintf(`<a href="%s">Log In and Consent</a>`, html.EscapeString(redirectURL.String()))
|
||||
resp.Header().Set("Content-Type", "text/html")
|
||||
resp.Header().Set("Content-Length", fmt.Sprintf("%d", len(respBody)))
|
||||
resp.Header().Set("X-Redirect-To", redirectURL.String()) // For robotic clients, using webbrowser.MockLauncher
|
||||
resp.WriteHeader(200)
|
||||
resp.Write([]byte(respBody))
|
||||
}
|
||||
|
||||
func (h handler) serveToken(resp http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != "POST" {
|
||||
resp.WriteHeader(405)
|
||||
log.Printf("/token: unsupported request method %q", req.Method)
|
||||
return
|
||||
}
|
||||
|
||||
if err := req.ParseForm(); err != nil {
|
||||
resp.WriteHeader(500)
|
||||
log.Printf("/token: error parsing body: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
grantType := req.Form.Get("grant_type")
|
||||
log.Printf("/token: grant_type is %q", grantType)
|
||||
switch grantType {
|
||||
|
||||
case "authorization_code":
|
||||
code := req.Form.Get("code")
|
||||
codeParts := strings.SplitN(code, ":", 2)
|
||||
if len(codeParts) != 2 {
|
||||
log.Printf("/token: invalid code %q", code)
|
||||
resp.Header().Set("Content-Type", "application/json")
|
||||
resp.WriteHeader(400)
|
||||
resp.Write([]byte(`{"error":"invalid_grant"}`))
|
||||
return
|
||||
}
|
||||
|
||||
codeVerifier := req.Form.Get("code_verifier")
|
||||
|
||||
switch codeParts[0] {
|
||||
case "plain":
|
||||
if codeParts[1] != codeVerifier {
|
||||
log.Printf("/token: incorrect code verifier %q; want %q", codeParts[1], codeVerifier)
|
||||
resp.Header().Set("Content-Type", "application/json")
|
||||
resp.WriteHeader(400)
|
||||
resp.Write([]byte(`{"error":"invalid_grant"}`))
|
||||
return
|
||||
}
|
||||
case "S256":
|
||||
h := sha256.New()
|
||||
h.Write([]byte(codeVerifier))
|
||||
encVerifier := base64.URLEncoding.EncodeToString(h.Sum(nil))
|
||||
if codeParts[1] != encVerifier {
|
||||
log.Printf("/token: incorrect code verifier %q; want %q", codeParts[1], encVerifier)
|
||||
resp.Header().Set("Content-Type", "application/json")
|
||||
resp.WriteHeader(400)
|
||||
resp.Write([]byte(`{"error":"invalid_grant"}`))
|
||||
return
|
||||
}
|
||||
default:
|
||||
log.Printf("/token: unsupported challenge method %q", codeParts[0])
|
||||
resp.Header().Set("Content-Type", "application/json")
|
||||
resp.WriteHeader(400)
|
||||
resp.Write([]byte(`{"error":"invalid_grant"}`))
|
||||
return
|
||||
}
|
||||
|
||||
resp.Header().Set("Content-Type", "application/json")
|
||||
resp.WriteHeader(200)
|
||||
resp.Write([]byte(`{"access_token":"good-token","token_type":"bearer"}`))
|
||||
log.Println("/token: successful request")
|
||||
|
||||
case "password":
|
||||
username := req.Form.Get("username")
|
||||
password := req.Form.Get("password")
|
||||
|
||||
if username == "wrong" || password == "wrong" {
|
||||
// These special "credentials" allow testing for the error case.
|
||||
resp.Header().Set("Content-Type", "application/json")
|
||||
resp.WriteHeader(400)
|
||||
resp.Write([]byte(`{"error":"invalid_grant"}`))
|
||||
log.Println("/token: 'wrong' credentials")
|
||||
return
|
||||
}
|
||||
|
||||
resp.Header().Set("Content-Type", "application/json")
|
||||
resp.WriteHeader(200)
|
||||
resp.Write([]byte(`{"access_token":"good-token","token_type":"bearer"}`))
|
||||
log.Println("/token: successful request")
|
||||
|
||||
default:
|
||||
resp.WriteHeader(400)
|
||||
log.Printf("/token: unsupported grant type %q", grantType)
|
||||
}
|
||||
}
|
||||
|
||||
func (h handler) serveRevoke(resp http.ResponseWriter, req *http.Request) {
|
||||
resp.WriteHeader(404)
|
||||
}
|
||||
|
||||
func init() {
|
||||
Handler = handler{}
|
||||
}
|
|
@ -0,0 +1,155 @@
|
|||
package webbrowser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
|
||||
"github.com/hashicorp/terraform/httpclient"
|
||||
)
|
||||
|
||||
// NewMockLauncher creates and returns a mock implementation of Launcher,
|
||||
// with some special behavior designed for use in unit tests.
|
||||
//
|
||||
// See the documentation of MockLauncher itself for more information.
|
||||
func NewMockLauncher(ctx context.Context) *MockLauncher {
|
||||
client := httpclient.New()
|
||||
return &MockLauncher{
|
||||
Client: client,
|
||||
Context: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
// MockLauncher is a mock implementation of Launcher that has some special
|
||||
// behavior designed for use in unit tests.
|
||||
//
|
||||
// When OpenURL is called, MockLauncher will make an HTTP request to the given
|
||||
// URL rather than interacting with a "real" browser.
|
||||
//
|
||||
// In normal situations it will then return with no further action, but if
|
||||
// the response to the given URL is either a standard HTTP redirect response
|
||||
// or includes the custom HTTP header X-Redirect-To then MockLauncher will
|
||||
// send a follow-up request to that target URL, and continue in this manner
|
||||
// until it reaches a URL that is not a redirect. (The X-Redirect-To header
|
||||
// is there so that a server can potentially offer a normal HTML page to
|
||||
// an actual browser while also giving a next-hop hint for MockLauncher.)
|
||||
//
|
||||
// Since MockLauncher is not a full programmable user-agent implementation
|
||||
// it can't be used for testing of real-world web applications, but it can
|
||||
// be used for testing against specialized test servers that are written
|
||||
// with MockLauncher in mind and know how to drive the request flow through
|
||||
// whatever steps are required to complete the desired test.
|
||||
//
|
||||
// All of the actions taken by MockLauncher happen asynchronously in the
|
||||
// background, to simulate the concurrency of a separate web browser.
|
||||
// Test code using MockLauncher should provide a context which is cancelled
|
||||
// when the test completes, to help avoid leaking MockLaunchers.
|
||||
type MockLauncher struct {
|
||||
// Client is the HTTP client that MockLauncher will use to make requests.
|
||||
// By default (if you use NewMockLauncher) this is a new client created
|
||||
// via httpclient.New, but callers may override it if they need customized
|
||||
// behavior for a particular test.
|
||||
//
|
||||
// Do not use a client that is shared with any other subsystem, because
|
||||
// MockLauncher will customize the settings of the given client.
|
||||
Client *http.Client
|
||||
|
||||
// Context can be cancelled in order to abort an OpenURL call before it
|
||||
// would naturally complete.
|
||||
Context context.Context
|
||||
|
||||
// Responses is a log of all of the responses recieved from the launcher's
|
||||
// requests, in the order requested.
|
||||
Responses []*http.Response
|
||||
|
||||
// done is a waitgroup used internally to signal when the async work is
|
||||
// complete, in order to make this mock more convenient to use in tests.
|
||||
done sync.WaitGroup
|
||||
}
|
||||
|
||||
var _ Launcher = (*MockLauncher)(nil)
|
||||
|
||||
// OpenURL is the mock implementation of Launcher, which has the special
|
||||
// behavior described for type MockLauncher.
|
||||
func (l *MockLauncher) OpenURL(u string) error {
|
||||
// We run our operation in the background because it's supposed to be
|
||||
// behaving like a web browser running in a separate process.
|
||||
log.Printf("[TRACE] webbrowser.MockLauncher: OpenURL(%q) starting in the background", u)
|
||||
l.done.Add(1)
|
||||
go func() {
|
||||
err := l.openURL(u)
|
||||
if err != nil {
|
||||
// Can't really do anything with this asynchronously, so we'll
|
||||
// just log it so that someone debugging will be able to see it.
|
||||
log.Printf("[ERROR] webbrowser.MockLauncher: OpenURL(%q): %s", u, err)
|
||||
} else {
|
||||
log.Printf("[TRACE] webbrowser.MockLauncher: OpenURL(%q) has concluded", u)
|
||||
}
|
||||
l.done.Done()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *MockLauncher) openURL(u string) error {
|
||||
// We need to disable automatic redirect following so that we can implement
|
||||
// it ourselves below, and thus be able to see the redirects in our
|
||||
// responses log.
|
||||
l.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
|
||||
// We'll keep looping as long as the server keeps giving us new URLs to
|
||||
// request.
|
||||
for u != "" {
|
||||
log.Printf("[DEBUG] webbrowser.MockLauncher: requesting %s", u)
|
||||
req, err := http.NewRequest("GET", u, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to construct HTTP request for %s: %s", u, err)
|
||||
}
|
||||
resp, err := l.Client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("[DEBUG] webbrowser.MockLauncher: request failed: %s", err)
|
||||
return fmt.Errorf("error requesting %s: %s", u, err)
|
||||
}
|
||||
l.Responses = append(l.Responses, resp)
|
||||
if resp.StatusCode >= 400 {
|
||||
log.Printf("[DEBUG] webbrowser.MockLauncher: request failed: %s", resp.Status)
|
||||
return fmt.Errorf("error requesting %s: %s", u, resp.Status)
|
||||
}
|
||||
log.Printf("[DEBUG] webbrowser.MockLauncher: request succeeded: %s", resp.Status)
|
||||
|
||||
u = "" // unless it's a redirect, we'll stop after this
|
||||
if location := resp.Header.Get("Location"); location != "" {
|
||||
u = location
|
||||
} else if redirectTo := resp.Header.Get("X-Redirect-To"); redirectTo != "" {
|
||||
u = redirectTo
|
||||
}
|
||||
|
||||
if u != "" {
|
||||
// HTTP technically doesn't permit relative URLs in Location, but
|
||||
// browsers tolerate it and so real-world servers do it, and thus
|
||||
// we'll allow it here too.
|
||||
oldURL := resp.Request.URL
|
||||
givenURL, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid redirect URL %s: %s", u, err)
|
||||
}
|
||||
u = oldURL.ResolveReference(givenURL).String()
|
||||
log.Printf("[DEBUG] webbrowser.MockLauncher: redirected to %s", u)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[DEBUG] webbrowser.MockLauncher: all done")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait blocks until the MockLauncher has finished its asynchronous work of
|
||||
// making HTTP requests and following redirects, at which point it will have
|
||||
// reached a request that didn't redirect anywhere and stopped iterating.
|
||||
func (l *MockLauncher) Wait() {
|
||||
log.Printf("[TRACE] webbrowser.MockLauncher: Wait() for current work to complete")
|
||||
l.done.Wait()
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
package webbrowser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMockLauncher(t *testing.T) {
|
||||
s := httptest.NewServer(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
|
||||
resp.Header().Set("Content-Length", "0")
|
||||
switch req.URL.Path {
|
||||
case "/standard-redirect-source":
|
||||
resp.Header().Set("Location", "/standard-redirect-target")
|
||||
resp.WriteHeader(302)
|
||||
case "/custom-redirect-source":
|
||||
resp.Header().Set("X-Redirect-To", "/custom-redirect-target")
|
||||
resp.WriteHeader(200)
|
||||
case "/error":
|
||||
resp.WriteHeader(500)
|
||||
default:
|
||||
resp.WriteHeader(200)
|
||||
}
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
t.Run("no redirects", func(t *testing.T) {
|
||||
l := NewMockLauncher(context.Background())
|
||||
err := l.OpenURL(s.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l.Wait() // Let the async work complete
|
||||
if got, want := len(l.Responses), 1; got != want {
|
||||
t.Fatalf("wrong number of responses %d; want %d", got, want)
|
||||
}
|
||||
if got, want := l.Responses[0].Request.URL.Path, ""; got != want {
|
||||
t.Fatalf("wrong request URL %q; want %q", got, want)
|
||||
}
|
||||
})
|
||||
t.Run("error", func(t *testing.T) {
|
||||
l := NewMockLauncher(context.Background())
|
||||
err := l.OpenURL(s.URL + "/error")
|
||||
if err != nil {
|
||||
// Th is kind of error is supposed to happen asynchronously, so we
|
||||
// should not see it here.
|
||||
t.Fatal(err)
|
||||
}
|
||||
l.Wait() // Let the async work complete
|
||||
if got, want := len(l.Responses), 1; got != want {
|
||||
t.Fatalf("wrong number of responses %d; want %d", got, want)
|
||||
}
|
||||
if got, want := l.Responses[0].Request.URL.Path, "/error"; got != want {
|
||||
t.Fatalf("wrong request URL %q; want %q", got, want)
|
||||
}
|
||||
if got, want := l.Responses[0].StatusCode, 500; got != want {
|
||||
t.Fatalf("wrong response status %d; want %d", got, want)
|
||||
}
|
||||
})
|
||||
t.Run("standard redirect", func(t *testing.T) {
|
||||
l := NewMockLauncher(context.Background())
|
||||
err := l.OpenURL(s.URL + "/standard-redirect-source")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l.Wait() // Let the async work complete
|
||||
if got, want := len(l.Responses), 2; got != want {
|
||||
t.Fatalf("wrong number of responses %d; want %d", got, want)
|
||||
}
|
||||
if got, want := l.Responses[0].Request.URL.Path, "/standard-redirect-source"; got != want {
|
||||
t.Fatalf("wrong request 0 URL %q; want %q", got, want)
|
||||
}
|
||||
if got, want := l.Responses[1].Request.URL.Path, "/standard-redirect-target"; got != want {
|
||||
t.Fatalf("wrong request 1 URL %q; want %q", got, want)
|
||||
}
|
||||
})
|
||||
t.Run("custom redirect", func(t *testing.T) {
|
||||
l := NewMockLauncher(context.Background())
|
||||
err := l.OpenURL(s.URL + "/custom-redirect-source")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l.Wait() // Let the async work complete
|
||||
if got, want := len(l.Responses), 2; got != want {
|
||||
t.Fatalf("wrong number of responses %d; want %d", got, want)
|
||||
}
|
||||
if got, want := l.Responses[0].Request.URL.Path, "/custom-redirect-source"; got != want {
|
||||
t.Fatalf("wrong request 0 URL %q; want %q", got, want)
|
||||
}
|
||||
if got, want := l.Responses[1].Request.URL.Path, "/custom-redirect-target"; got != want {
|
||||
t.Fatalf("wrong request 1 URL %q; want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package webbrowser
|
||||
|
||||
import (
|
||||
"github.com/pkg/browser"
|
||||
)
|
||||
|
||||
// NewNativeLauncher creates and returns a Launcher that will attempt to interact
|
||||
// with the browser-launching mechanisms of the operating system where the
|
||||
// program is currently running.
|
||||
func NewNativeLauncher() Launcher {
|
||||
return nativeLauncher{}
|
||||
}
|
||||
|
||||
type nativeLauncher struct{}
|
||||
|
||||
func (l nativeLauncher) OpenURL(url string) error {
|
||||
return browser.OpenURL(url)
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package webbrowser
|
||||
|
||||
// Launcher is an object that knows how to open a given URL in a new tab in
|
||||
// some suitable browser on the current system.
|
||||
//
|
||||
// Launching of browsers is a very target-platform-sensitive activity, so
|
||||
// this interface serves as an abstraction over many possible implementations
|
||||
// which can be selected based on what is appropriate for a specific situation.
|
||||
type Launcher interface {
|
||||
// OpenURL opens the given URL in a web browser.
|
||||
//
|
||||
// Depending on the circumstances and on the target platform, this may or
|
||||
// may not cause the browser to take input focus. Because of this
|
||||
// uncertainty, any caller of this method must be sure to include some
|
||||
// language in its UI output to let the user know that a browser tab has
|
||||
// opened somewhere, so that they can go and find it if the focus didn't
|
||||
// switch automatically.
|
||||
OpenURL(url string) error
|
||||
}
|
24
commands.go
24
commands.go
|
@ -4,12 +4,15 @@ import (
|
|||
"os"
|
||||
"os/signal"
|
||||
|
||||
"github.com/mitchellh/cli"
|
||||
|
||||
"github.com/hashicorp/terraform/command"
|
||||
"github.com/hashicorp/terraform/command/cliconfig"
|
||||
"github.com/hashicorp/terraform/command/webbrowser"
|
||||
pluginDiscovery "github.com/hashicorp/terraform/plugin/discovery"
|
||||
"github.com/hashicorp/terraform/svchost"
|
||||
"github.com/hashicorp/terraform/svchost/auth"
|
||||
"github.com/hashicorp/terraform/svchost/disco"
|
||||
"github.com/mitchellh/cli"
|
||||
)
|
||||
|
||||
// runningInAutomationEnvName gives the name of an environment variable that
|
||||
|
@ -50,6 +53,11 @@ func initCommands(config *Config, services *disco.Disco) {
|
|||
services.ForceHostServices(host, hostConfig.Services)
|
||||
}
|
||||
|
||||
configDir, err := cliconfig.ConfigDir()
|
||||
if err != nil {
|
||||
configDir = "" // No config dir available (e.g. looking up a home directory failed)
|
||||
}
|
||||
|
||||
dataDir := os.Getenv("TF_DATA_DIR")
|
||||
|
||||
meta := command.Meta{
|
||||
|
@ -58,9 +66,11 @@ func initCommands(config *Config, services *disco.Disco) {
|
|||
PluginOverrides: &PluginOverrides,
|
||||
Ui: Ui,
|
||||
|
||||
Services: services,
|
||||
Services: services,
|
||||
BrowserLauncher: webbrowser.NewNativeLauncher(),
|
||||
|
||||
RunningInAutomation: inAutomation,
|
||||
CLIConfigDir: configDir,
|
||||
PluginCacheDir: config.PluginCacheDir,
|
||||
OverrideDataDir: dataDir,
|
||||
|
||||
|
@ -172,6 +182,16 @@ func initCommands(config *Config, services *disco.Disco) {
|
|||
}, nil
|
||||
},
|
||||
|
||||
// "terraform login" is disabled until Terraform Cloud is ready to
|
||||
// support it.
|
||||
/*
|
||||
"login": func() (cli.Command, error) {
|
||||
return &command.LoginCommand{
|
||||
Meta: meta,
|
||||
}, nil
|
||||
},
|
||||
*/
|
||||
|
||||
"output": func() (cli.Command, error) {
|
||||
return &command.OutputCommand{
|
||||
Meta: meta,
|
||||
|
|
1
go.mod
1
go.mod
|
@ -103,6 +103,7 @@ require (
|
|||
github.com/modern-go/reflect2 v1.0.1 // indirect
|
||||
github.com/packer-community/winrmcp v0.0.0-20180102160824-81144009af58
|
||||
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c // indirect
|
||||
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4
|
||||
github.com/pkg/errors v0.8.0
|
||||
github.com/posener/complete v1.2.1
|
||||
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829 // indirect
|
||||
|
|
42
go.sum
42
go.sum
|
@ -1,4 +1,3 @@
|
|||
cloud.google.com/go v0.26.0 h1:e0WKqKTd5BnrG8aKH3J3h+QvEIQtSUcf2n5UZ5ZgLtQ=
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||
|
@ -22,7 +21,6 @@ github.com/Unknwon/com v0.0.0-20151008135407-28b053d5a292 h1:tuQ7w+my8a8mkwN7x2T
|
|||
github.com/Unknwon/com v0.0.0-20151008135407-28b053d5a292/go.mod h1:KYCjqMOeHpNuTOiFQU6WEcTG7poCJrUs0YgyHNtn1no=
|
||||
github.com/abdullin/seq v0.0.0-20160510034733-d5467c17e7af h1:DBNMBMuMiWYu0b+8KMJuWmfCkcxl09JwdlqwDZZ6U14=
|
||||
github.com/abdullin/seq v0.0.0-20160510034733-d5467c17e7af/go.mod h1:5Jv4cbFiHJMsVxt52+i0Ha45fjshj6wxYr1r19tB9bw=
|
||||
github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8=
|
||||
github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
|
||||
github.com/agext/levenshtein v1.2.2 h1:0S/Yg6LYmFJ5stwQeRp6EeOcCbj7xiqQSdNelsXvaqE=
|
||||
github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
|
||||
|
@ -42,7 +40,6 @@ github.com/antchfx/xquery v0.0.0-20180515051857-ad5b8c7a47b0 h1:JaCC8jz0zdMLk2m+
|
|||
github.com/antchfx/xquery v0.0.0-20180515051857-ad5b8c7a47b0/go.mod h1:LzD22aAzDP8/dyiCKFp31He4m2GPjl0AFyzDtZzUu9M=
|
||||
github.com/apparentlymart/go-cidr v1.0.0 h1:lGDvXx8Lv9QHjrAVP7jyzleG4F9+FkRhJcEsDFxeb8w=
|
||||
github.com/apparentlymart/go-cidr v1.0.0/go.mod h1:EBcsNrHc3zQeuaeCeCtQruQm+n9/YjEn/vI25Lg7Gwc=
|
||||
github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3 h1:ZSTrOEhiM5J5RFxEaFvMZVEAM1KvT1YzbEOwB2EAGjA=
|
||||
github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM=
|
||||
github.com/apparentlymart/go-dump v0.0.0-20190214190832-042adf3cf4a0 h1:MzVXffFUye+ZcSR6opIgz9Co7WcDx6ZcY+RjfFHoA0I=
|
||||
github.com/apparentlymart/go-dump v0.0.0-20190214190832-042adf3cf4a0/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM=
|
||||
|
@ -56,7 +53,6 @@ github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj
|
|||
github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
|
||||
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/aws/aws-sdk-go v1.15.78/go.mod h1:E3/ieXAlvM0XWO57iftYVDLLvQ824smPP3ATZkfNZeM=
|
||||
github.com/aws/aws-sdk-go v1.16.36 h1:POeH34ZME++pr7GBGh+ZO6Y5kOwSMQpqp5BGUgooJ6k=
|
||||
github.com/aws/aws-sdk-go v1.16.36/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
|
||||
github.com/aws/aws-sdk-go v1.22.0 h1:e88V6+dSEyBibUy0ekOydtTfNWzqG3hrtCR8SF6UqqY=
|
||||
github.com/aws/aws-sdk-go v1.22.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
|
||||
|
@ -124,23 +120,19 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfU
|
|||
github.com/golang/groupcache v0.0.0-20180513044358-24b0969c4cb7 h1:u4bArs140e9+AfE52mFHOXVFnOSBJBRlzTHrOPLOIhE=
|
||||
github.com/golang/groupcache v0.0.0-20180513044358-24b0969c4cb7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.2.0 h1:28o5sBqPkBsMGnC6b4MvE2TzSr5/AT4c/1fLqVGIwlk=
|
||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.3.1 h1:qGJ6qTW+x6xX/my+8YUVl4WNpX9B7+/l2tRsHGZ7f2s=
|
||||
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
||||
github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db h1:woRePGFeVFfLKN/pOkfl+p/TAqKOfFu+7KPlMVpok/w=
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c h1:964Od4U6p2jUkFxvCydnIczKteheJEzHRToSGK3Bnlw=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
|
@ -208,13 +200,11 @@ github.com/hashicorp/go-sockaddr v0.0.0-20180320115054-6d291a969b86 h1:7YOlAIO2Y
|
|||
github.com/hashicorp/go-sockaddr v0.0.0-20180320115054-6d291a969b86/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
|
||||
github.com/hashicorp/go-tfe v0.3.16 h1:GS2yv580p0co4j3FBVaC6Zahd9mxdCGehhJ0qqzFMH0=
|
||||
github.com/hashicorp/go-tfe v0.3.16/go.mod h1:SuPHR+OcxvzBZNye7nGPfwZTEyd3rWPfLVbCgyZPezM=
|
||||
github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM=
|
||||
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE=
|
||||
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-version v1.1.0 h1:bPIoEKD27tNdebFGGxxYwcL4nepeY4j1QP23PFRGzg0=
|
||||
github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
|
@ -251,7 +241,6 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1
|
|||
github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE=
|
||||
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/kardianos/osext v0.0.0-20170510131534-ae77be60afb1 h1:PJPDf8OUfOK1bb/NeTKd4f1QXZItOX389VN3B6qC8ro=
|
||||
github.com/kardianos/osext v0.0.0-20170510131534-ae77be60afb1/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
|
||||
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA=
|
||||
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
|
||||
|
@ -265,7 +254,6 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
|
|||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 h1:MtvEpTB6LX3vkb4ax0b5D2DHbNAUsen0Gx5wZoq3lV4=
|
||||
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
|
@ -279,12 +267,10 @@ github.com/masterzen/simplexml v0.0.0-20160608183007-4572e39b1ab9 h1:SmVbOZFWAly
|
|||
github.com/masterzen/simplexml v0.0.0-20160608183007-4572e39b1ab9/go.mod h1:kCEbxUJlNDEBNbdQMkPSp6yaKcRXVI6f4ddk8Riv4bc=
|
||||
github.com/masterzen/winrm v0.0.0-20190223112901-5e5c9a7fe54b h1:/1RFh2SLCJ+tEnT73+Fh5R2AO89sQqs8ba7o+hx1G0Y=
|
||||
github.com/masterzen/winrm v0.0.0-20190223112901-5e5c9a7fe54b/go.mod h1:wr1VqkwW0AB5JS0QLy5GpVMS9E3VtRoSYXUYyVk46KY=
|
||||
github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-colorable v0.1.1 h1:G1f5SKeVxmagw/IyvzvtZE4Gybcc4Tr1tf7I8z0XgOg=
|
||||
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
|
||||
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs=
|
||||
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.5 h1:tHXDdz1cpzGaovsTB+TVB8q90WEokoVmfMqoVcrLUgw=
|
||||
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
|
@ -301,13 +287,11 @@ github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2Em
|
|||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
|
||||
github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ=
|
||||
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
|
||||
github.com/mitchellh/go-homedir v1.0.0 h1:vKb8ShqSby24Yrqr/yDYkuFz8d0WUjys40rvnGC8aR0=
|
||||
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/go-linereader v0.0.0-20190213213312-1b945b3263eb h1:GRiLv4rgyqjqzxbhJke65IYUf4NCOOvrPOJbV/sPxkM=
|
||||
github.com/mitchellh/go-linereader v0.0.0-20190213213312-1b945b3263eb/go.mod h1:OaY7UOoTkkrX3wRwjpYRKafIkkyeD0UtweSHAWWiqQM=
|
||||
github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77 h1:7GoSOOW2jpsfkntVKaS2rAr1TJqfcxotyaUcuxoZSzg=
|
||||
github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
|
||||
github.com/mitchellh/go-testing-interface v1.0.0 h1:fzU/JVNcaqHQEcVFAKeR41fkiLdIPrefOvVG1VZ96U0=
|
||||
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
|
||||
|
@ -340,18 +324,18 @@ github.com/packer-community/winrmcp v0.0.0-20180102160824-81144009af58 h1:m3CEgv
|
|||
github.com/packer-community/winrmcp v0.0.0-20180102160824-81144009af58/go.mod h1:f6Izs6JvFTdnRbziASagjZ2vmf55NSIkC/weStxCHqk=
|
||||
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c h1:Lgl0gzECD8GnQ5QCWA8o6BtfL6mDH5rQgM4/fX3avOs=
|
||||
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 h1:49lOXmGaUpV9Fz3gd7TFZY106KVlPVa5jcYD1gaQf98=
|
||||
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=
|
||||
github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/posener/complete v1.1.1 h1:ccV59UEOTzVDnDUEFdT95ZzHVZ+5+158q8+SJb2QV5w=
|
||||
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
||||
github.com/posener/complete v1.2.1 h1:LrvDIY//XNo65Lq84G/akBuMGlawHvGBABv8f/ZN6DI=
|
||||
github.com/posener/complete v1.2.1/go.mod h1:6gapUrK/U1TAN7ciCoNRIdVC5sbdBTUh1DKN0g6uH7E=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829 h1:D+CiwcpGTW6pL6bv6KI3KbyEyCKyS+1JWS2h8PNDnGA=
|
||||
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
|
||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 h1:idejC8f05m9MGOsuEi1ATq9shN03HrxNkD/luQvxCv8=
|
||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f h1:BVwpUVJDADN2ufcGik7W992pyps0wZ888b/y9GXcLTU=
|
||||
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
|
@ -377,9 +361,7 @@ github.com/soheilhy/cmux v0.1.4 h1:0HKaf1o97UwFjHH9o5XsHUOF+tqmdA7KEzXLpiyaw0E=
|
|||
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
|
||||
github.com/spf13/afero v1.2.1 h1:qgMbHoJbPbw579P+1zVY+6n4nIFuIchaIjzZ/I/Yq8M=
|
||||
github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
|
||||
github.com/spf13/pflag v1.0.2 h1:Fy0orTDgHdbnzHcsOgfCN4LtHf0ec3wwtiwJqwvf3Gc=
|
||||
github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
|
@ -405,7 +387,6 @@ github.com/xiang90/probing v0.0.0-20160813154853-07dd2e8dfe18 h1:MPPkRncZLN9Kh4M
|
|||
github.com/xiang90/probing v0.0.0-20160813154853-07dd2e8dfe18/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
|
||||
github.com/xlab/treeprint v0.0.0-20161029104018-1d6e34225557 h1:Jpn2j6wHkC9wJv5iMfJhKqrZJx3TahFx+7sbZ7zQdxs=
|
||||
github.com/xlab/treeprint v0.0.0-20161029104018-1d6e34225557/go.mod h1:ce1O1j6UtZfjr22oyGxGLbauSBp2YVXpARAosm7dHBg=
|
||||
github.com/zclconf/go-cty v1.0.0 h1:EWtv3gKe2wPLIB9hQRQJa7k/059oIfAqcEkCNnaVckk=
|
||||
github.com/zclconf/go-cty v1.0.0/go.mod h1:xnAOWiHeOqg2nWS62VtQ7pbOu17FtxJNW8RLEih+O3s=
|
||||
github.com/zclconf/go-cty v1.0.1-0.20190708163926-19588f92a98f h1:sq2p8SN6ji66CFEQFIWLlD/gFmGtr5hBrOzv5nLlGfA=
|
||||
github.com/zclconf/go-cty v1.0.1-0.20190708163926-19588f92a98f/go.mod h1:xnAOWiHeOqg2nWS62VtQ7pbOu17FtxJNW8RLEih+O3s=
|
||||
|
@ -422,13 +403,9 @@ go.uber.org/zap v1.9.1 h1:XCJQEf3W6eZaVwhRBof6ImoYGJSITeKWsyeh3HFu/5o=
|
|||
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2 h1:NwxKRvbkH5MsNkvOtPZi3/3kmI8CAzs3mtv+GLQMkNo=
|
||||
golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190222235706-ffb98f73852f h1:qWFY9ZxP3tfI37wYIs/MnIAqK0vlXp1xnYEa5HxFSSY=
|
||||
golang.org/x/crypto v0.0.0-20190222235706-ffb98f73852f/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734 h1:p/H982KKEjUnLJkM3tt/LemDnOc1GiZL5FCVlORJ5zo=
|
||||
golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc=
|
||||
|
@ -450,28 +427,23 @@ golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73r
|
|||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd h1:HuTn7WObtcDo9uEEU7rEqL0jYthdXAmZ6PP+meazmaU=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190502183928-7f726cade0ab h1:9RfW3ktsOZxgo9YNbBAjq1FWzc/igwEcUzZz8IXgSbk=
|
||||
golang.org/x/net v0.0.0-20190502183928-7f726cade0ab/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421 h1:Wo7BWFiOk0QRFMLYMqJGFMd9CgUAcGx7V+qEg/h5IBI=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
|
@ -479,30 +451,23 @@ golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5h
|
|||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc h1:WiYx1rIFmx8c0mXAFtv5D/mHyKe1+jmuP7PViuwqwuQ=
|
||||
golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0 h1:bzeyCHgoAyjZjAhvTpks+qM7sdlh4cCSitmXeCEO3B4=
|
||||
golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223 h1:DH4skfRX4EBpamg7iV4ZlCpblAHI6s6TDM39bFZumv8=
|
||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502175342-a43fa875dd82 h1:vsphBvatvfbhlb4PO1BYSr9dzugGxJ/SQHoNufZJq1w=
|
||||
golang.org/x/sys v0.0.0-20190502175342-a43fa875dd82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa h1:KIDDMLT1O0Nr7TSxp8xM5tJcdn8tgyAONntO829og1M=
|
||||
golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2 h1:z99zHgr7hKfrUcX/KsoJk5FJfjTceCKIp96+biqP4To=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c h1:fqgJT0MGcGpPgpWU7VRdRjuArfcOvC4AoJmILihzhDg=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
|
@ -522,13 +487,11 @@ google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEn
|
|||
google.golang.org/api v0.9.0 h1:jbyannxz0XFD3zdjgrSUsaJbgpH4eTrkdhRChkHPfO8=
|
||||
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.1 h1:QzqyMA1tlu6CgqCDUtU9V+ZKhLFT2dkJuANu5QaxI3I=
|
||||
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19 h1:Lj2SnHtxkRGJDqnGaSjo+CCdIieEnwVazbOXILwQemk=
|
||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
|
@ -536,7 +499,6 @@ google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRn
|
|||
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55 h1:gSJIx1SDwno+2ElGhA4+qG2zF97qiUzTM+rQ0klBOcE=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/grpc v1.14.0 h1:ArxJuB1NWfPY6r9Gp9gqwplT0Ge7nqv9msgu03lHLmo=
|
||||
google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
|
|
5
main.go
5
main.go
|
@ -106,7 +106,10 @@ func init() {
|
|||
OutputPrefix: OutputPrefix,
|
||||
InfoPrefix: OutputPrefix,
|
||||
ErrorPrefix: ErrorPrefix,
|
||||
Ui: &cli.BasicUi{Writer: os.Stdout},
|
||||
Ui: &cli.BasicUi{
|
||||
Writer: os.Stdout,
|
||||
Reader: os.Stdin,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
Copyright (c) 2014, Dave Cheney <dave@cheney.net>
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@ -0,0 +1,55 @@
|
|||
|
||||
# browser
|
||||
import "github.com/pkg/browser"
|
||||
|
||||
Package browser provides helpers to open files, readers, and urls in a browser window.
|
||||
|
||||
The choice of which browser is started is entirely client dependant.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## Variables
|
||||
``` go
|
||||
var Stderr io.Writer = os.Stderr
|
||||
```
|
||||
Stderr is the io.Writer to which executed commands write standard error.
|
||||
|
||||
``` go
|
||||
var Stdout io.Writer = os.Stdout
|
||||
```
|
||||
Stdout is the io.Writer to which executed commands write standard output.
|
||||
|
||||
|
||||
## func OpenFile
|
||||
``` go
|
||||
func OpenFile(path string) error
|
||||
```
|
||||
OpenFile opens new browser window for the file path.
|
||||
|
||||
|
||||
## func OpenReader
|
||||
``` go
|
||||
func OpenReader(r io.Reader) error
|
||||
```
|
||||
OpenReader consumes the contents of r and presents the
|
||||
results in a new browser window.
|
||||
|
||||
|
||||
## func OpenURL
|
||||
``` go
|
||||
func OpenURL(url string) error
|
||||
```
|
||||
OpenURL opens a new browser window pointing to url.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
- - -
|
||||
Generated by [godoc2md](http://godoc.org/github.com/davecheney/godoc2md)
|
|
@ -0,0 +1,63 @@
|
|||
// Package browser provides helpers to open files, readers, and urls in a browser window.
|
||||
//
|
||||
// The choice of which browser is started is entirely client dependant.
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Stdout is the io.Writer to which executed commands write standard output.
|
||||
var Stdout io.Writer = os.Stdout
|
||||
|
||||
// Stderr is the io.Writer to which executed commands write standard error.
|
||||
var Stderr io.Writer = os.Stderr
|
||||
|
||||
// OpenFile opens new browser window for the file path.
|
||||
func OpenFile(path string) error {
|
||||
path, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return OpenURL("file://" + path)
|
||||
}
|
||||
|
||||
// OpenReader consumes the contents of r and presents the
|
||||
// results in a new browser window.
|
||||
func OpenReader(r io.Reader) error {
|
||||
f, err := ioutil.TempFile("", "browser")
|
||||
if err != nil {
|
||||
return fmt.Errorf("browser: could not create temporary file: %v", err)
|
||||
}
|
||||
if _, err := io.Copy(f, r); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("browser: caching temporary file failed: %v", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return fmt.Errorf("browser: caching temporary file failed: %v", err)
|
||||
}
|
||||
oldname := f.Name()
|
||||
newname := oldname + ".html"
|
||||
if err := os.Rename(oldname, newname); err != nil {
|
||||
return fmt.Errorf("browser: renaming temporary file failed: %v", err)
|
||||
}
|
||||
return OpenFile(newname)
|
||||
}
|
||||
|
||||
// OpenURL opens a new browser window pointing to url.
|
||||
func OpenURL(url string) error {
|
||||
return openBrowser(url)
|
||||
}
|
||||
|
||||
func runCmd(prog string, args ...string) error {
|
||||
cmd := exec.Command(prog, args...)
|
||||
cmd.Stdout = Stdout
|
||||
cmd.Stderr = Stderr
|
||||
setFlags(cmd)
|
||||
return cmd.Run()
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package browser
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func openBrowser(url string) error {
|
||||
return runCmd("open", url)
|
||||
}
|
||||
|
||||
func setFlags(cmd *exec.Cmd) {}
|
|
@ -0,0 +1,9 @@
|
|||
package browser
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func openBrowser(url string) error {
|
||||
return runCmd("xdg-open", url)
|
||||
}
|
||||
|
||||
func setFlags(cmd *exec.Cmd) {}
|
|
@ -0,0 +1,16 @@
|
|||
package browser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func openBrowser(url string) error {
|
||||
err := runCmd("xdg-open", url)
|
||||
if e, ok := err.(*exec.Error); ok && e.Err == exec.ErrNotFound {
|
||||
return errors.New("xdg-open: command not found - install xdg-utils from ports(8)")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func setFlags(cmd *exec.Cmd) {}
|
|
@ -0,0 +1,15 @@
|
|||
// +build !linux,!windows,!darwin,!openbsd
|
||||
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func openBrowser(url string) error {
|
||||
return fmt.Errorf("openBrowser: unsupported operating system: %v", runtime.GOOS)
|
||||
}
|
||||
|
||||
func setFlags(cmd *exec.Cmd) {}
|
|
@ -0,0 +1,16 @@
|
|||
package browser
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func openBrowser(url string) error {
|
||||
r := strings.NewReplacer("&", "^&")
|
||||
return runCmd("cmd", "/c", "start", r.Replace(url))
|
||||
}
|
||||
|
||||
func setFlags(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
}
|
|
@ -437,6 +437,8 @@ github.com/nu7hatch/gouuid
|
|||
github.com/oklog/run
|
||||
# github.com/packer-community/winrmcp v0.0.0-20180102160824-81144009af58
|
||||
github.com/packer-community/winrmcp/winrmcp
|
||||
# github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4
|
||||
github.com/pkg/browser
|
||||
# github.com/pkg/errors v0.8.0
|
||||
github.com/pkg/errors
|
||||
# github.com/posener/complete v1.2.1
|
||||
|
|
Loading…
Reference in New Issue