Skip to content

Commit

Permalink
Merge pull request #1294 from hashicorp/f-push
Browse files Browse the repository at this point in the history
command/push: for remote TF configuration runs
  • Loading branch information
mitchellh committed Mar 25, 2015
2 parents 55d6824 + 280635d commit 28ecdac
Show file tree
Hide file tree
Showing 25 changed files with 1,040 additions and 8 deletions.
21 changes: 21 additions & 0 deletions command/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,27 @@ func testStateFileDefault(t *testing.T, s *terraform.State) string {
return DefaultStateFilename
}

// testStateFileRemote writes the state out to the remote statefile
// in the cwd. Use `testCwd` to change into a temp cwd.
func testStateFileRemote(t *testing.T, s *terraform.State) string {
path := filepath.Join(DefaultDataDir, DefaultStateFilename)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatalf("err: %s", err)
}

f, err := os.Create(path)
if err != nil {
t.Fatalf("err: %s", err)
}
defer f.Close()

if err := terraform.WriteState(s, f); err != nil {
t.Fatalf("err: %s", err)
}

return path
}

// testStateOutput tests that the state at the given path contains
// the expected state string.
func testStateOutput(t *testing.T, path string, expected string) {
Expand Down
19 changes: 13 additions & 6 deletions command/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,7 @@ func (m *Meta) Context(copts contextOpts) (*terraform.Context, bool, error) {
return nil, false, fmt.Errorf("Error loading config: %s", err)
}

dataDir := DefaultDataDirectory
if m.dataDir != "" {
dataDir = m.dataDir
}
err = mod.Load(m.moduleStorage(dataDir), copts.GetMode)
err = mod.Load(m.moduleStorage(m.DataDir()), copts.GetMode)
if err != nil {
return nil, false, fmt.Errorf("Error downloading modules: %s", err)
}
Expand All @@ -153,6 +149,16 @@ func (m *Meta) Context(copts contextOpts) (*terraform.Context, bool, error) {
return ctx, false, nil
}

// DataDir returns the directory where local data will be stored.
func (m *Meta) DataDir() string {
dataDir := DefaultDataDirectory
if m.dataDir != "" {
dataDir = m.dataDir
}

return dataDir
}

// InputMode returns the type of input we should ask for in the form of
// terraform.InputMode which is passed directly to Context.Input.
func (m *Meta) InputMode() terraform.InputMode {
Expand All @@ -164,6 +170,7 @@ func (m *Meta) InputMode() terraform.InputMode {
mode |= terraform.InputModeProvider
if len(m.variables) == 0 && m.autoKey == "" {
mode |= terraform.InputModeVar
mode |= terraform.InputModeVarUnset
}

return mode
Expand Down Expand Up @@ -205,7 +212,7 @@ func (m *Meta) StateOpts() *StateOpts {
if localPath == "" {
localPath = DefaultStateFilename
}
remotePath := filepath.Join(DefaultDataDir, DefaultStateFilename)
remotePath := filepath.Join(m.DataDir(), DefaultStateFilename)

return &StateOpts{
LocalPath: localPath,
Expand Down
2 changes: 1 addition & 1 deletion command/meta_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func TestMetaInputMode(t *testing.T) {
t.Fatalf("err: %s", err)
}

if m.InputMode() != terraform.InputModeStd {
if m.InputMode() != terraform.InputModeStd|terraform.InputModeVarUnset {
t.Fatalf("bad: %#v", m.InputMode())
}
}
Expand Down
312 changes: 312 additions & 0 deletions command/push.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
package command

import (
"fmt"
"io"
"os"
"path/filepath"
"strings"

"github.com/hashicorp/atlas-go/archive"
"github.com/hashicorp/atlas-go/v1"
)

type PushCommand struct {
Meta

// client is the client to use for the actual push operations.
// If this isn't set, then the Atlas client is used. This should
// really only be set for testing reasons (and is hence not exported).
client pushClient
}

func (c *PushCommand) Run(args []string) int {
var atlasAddress, atlasToken string
var archiveVCS, moduleUpload bool
var name string
args = c.Meta.process(args, false)
cmdFlags := c.Meta.flagSet("push")
cmdFlags.StringVar(&atlasAddress, "atlas-address", "", "")
cmdFlags.StringVar(&c.Meta.statePath, "state", DefaultStateFilename, "path")
cmdFlags.StringVar(&atlasToken, "token", "", "")
cmdFlags.BoolVar(&moduleUpload, "upload-modules", true, "")
cmdFlags.StringVar(&name, "name", "", "")
cmdFlags.BoolVar(&archiveVCS, "vcs", true, "")
cmdFlags.Usage = func() { c.Ui.Error(c.Help()) }
if err := cmdFlags.Parse(args); err != nil {
return 1
}

// The pwd is used for the configuration path if one is not given
pwd, err := os.Getwd()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error getting pwd: %s", err))
return 1
}

// Get the path to the configuration depending on the args.
var configPath string
args = cmdFlags.Args()
if len(args) > 1 {
c.Ui.Error("The apply command expects at most one argument.")
cmdFlags.Usage()
return 1
} else if len(args) == 1 {
configPath = args[0]
} else {
configPath = pwd
}

// Verify the state is remote, we can't push without a remote state
s, err := c.State()
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed to read state: %s", err))
return 1
}
if !s.State().IsRemote() {
c.Ui.Error(
"Remote state is not enabled. For Atlas to run Terraform\n" +
"for you, remote state must be used and configured. Remote\n" +
"state via any backend is accepted, not just Atlas. To\n" +
"configure remote state, use the `terraform remote config`\n" +
"command.")
return 1
}

// Build the context based on the arguments given
ctx, planned, err := c.Context(contextOpts{
Path: configPath,
StatePath: c.Meta.statePath,
})
if err != nil {
c.Ui.Error(err.Error())
return 1
}
if planned {
c.Ui.Error(
"A plan file cannot be given as the path to the configuration.\n" +
"A path to a module (directory with configuration) must be given.")
return 1
}

// Get the configuration
config := ctx.Module().Config()
if name == "" {
if config.Atlas == nil || config.Atlas.Name == "" {
c.Ui.Error(
"The name of this Terraform configuration in Atlas must be\n" +
"specified within your configuration or the command-line. To\n" +
"set it on the command-line, use the `-name` parameter.")
return 1
}
name = config.Atlas.Name
}

// Initialize the client if it isn't given.
if c.client == nil {
// Make sure to nil out our client so our token isn't sitting around
defer func() { c.client = nil }()

// Initialize it to the default client, we set custom settings later
client := atlas.DefaultClient()
if atlasAddress != "" {
client, err = atlas.NewClient(atlasAddress)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error initializing Atlas client: %s", err))
return 1
}
}

if atlasToken != "" {
client.Token = atlasToken
}

c.client = &atlasPushClient{Client: client}
}

// Get the variables we might already have
vars, err := c.client.Get(name)
if err != nil {
c.Ui.Error(fmt.Sprintf(
"Error looking up previously pushed configuration: %s", err))
return 1
}
for k, v := range vars {
ctx.SetVariable(k, v)
}

// Ask for input
if err := ctx.Input(c.InputMode()); err != nil {
c.Ui.Error(fmt.Sprintf(
"Error while asking for variable input:\n\n%s", err))
return 1
}

// Build the archiving options, which includes everything it can
// by default according to VCS rules but forcing the data directory.
archiveOpts := &archive.ArchiveOpts{
VCS: archiveVCS,
Extra: map[string]string{
DefaultDataDir: c.DataDir(),
},
}
if !moduleUpload {
// If we're not uploading modules, then exclude the modules dir.
archiveOpts.Exclude = append(
archiveOpts.Exclude,
filepath.Join(c.DataDir(), "modules"))
}

archiveR, err := archive.CreateArchive(configPath, archiveOpts)
if err != nil {
c.Ui.Error(fmt.Sprintf(
"An error has occurred while archiving the module for uploading:\n"+
"%s", err))
return 1
}

// Upsert!
opts := &pushUpsertOptions{
Name: name,
Archive: archiveR,
Variables: ctx.Variables(),
}
vsn, err := c.client.Upsert(opts)
if err != nil {
c.Ui.Error(fmt.Sprintf(
"An error occurred while uploading the module:\n\n%s", err))
return 1
}

c.Ui.Output(c.Colorize().Color(fmt.Sprintf(
"[reset][bold][green]Configuration %q uploaded! (v%d)",
name, vsn)))
return 0
}

func (c *PushCommand) Help() string {
helpText := `
Usage: terraform push [options] [DIR]
Upload this Terraform module to an Atlas server for remote
infrastructure management.
Options:
-atlas-address=<url> An alternate address to an Atlas instance. Defaults
to https://atlas.hashicorp.com
-upload-modules=true If true (default), then the modules are locked at
their current checkout and uploaded completely. This
prevents Atlas from running "terraform get".
-name=<name> Name of the configuration in Atlas. This can also
be set in the configuration itself. Format is
typically: "username/name".
-token=<token> Access token to use to upload. If blank or unspecified,
the ATLAS_TOKEN environmental variable will be used.
-vcs=true If true (default), push will upload only files
comitted to your VCS, if detected.
`
return strings.TrimSpace(helpText)
}

func (c *PushCommand) Synopsis() string {
return "Upload this Terraform module to Atlas to run"
}

// pushClient is implementd internally to control where pushes go. This is
// either to Atlas or a mock for testing.
type pushClient interface {
Get(string) (map[string]string, error)
Upsert(*pushUpsertOptions) (int, error)
}

type pushUpsertOptions struct {
Name string
Archive *archive.Archive
Variables map[string]string
}

type atlasPushClient struct {
Client *atlas.Client
}

func (c *atlasPushClient) Get(name string) (map[string]string, error) {
user, name, err := atlas.ParseSlug(name)
if err != nil {
return nil, err
}

version, err := c.Client.TerraformConfigLatest(user, name)
if err != nil {
return nil, err
}

var variables map[string]string
if version != nil {
variables = version.Variables
}

return variables, nil
}

func (c *atlasPushClient) Upsert(opts *pushUpsertOptions) (int, error) {
user, name, err := atlas.ParseSlug(opts.Name)
if err != nil {
return 0, err
}

data := &atlas.TerraformConfigVersion{
Variables: opts.Variables,
}

version, err := c.Client.CreateTerraformConfigVersion(
user, name, data, opts.Archive, opts.Archive.Size)
if err != nil {
return 0, err
}

return version, nil
}

type mockPushClient struct {
File string

GetCalled bool
GetName string
GetResult map[string]string
GetError error

UpsertCalled bool
UpsertOptions *pushUpsertOptions
UpsertVersion int
UpsertError error
}

func (c *mockPushClient) Get(name string) (map[string]string, error) {
c.GetCalled = true
c.GetName = name
return c.GetResult, c.GetError
}

func (c *mockPushClient) Upsert(opts *pushUpsertOptions) (int, error) {
f, err := os.Create(c.File)
if err != nil {
return 0, err
}
defer f.Close()

data := opts.Archive
size := opts.Archive.Size
if _, err := io.CopyN(f, data, size); err != nil {
return 0, err
}

c.UpsertCalled = true
c.UpsertOptions = opts
return c.UpsertVersion, c.UpsertError
}
Loading

0 comments on commit 28ecdac

Please sign in to comment.