opentofu/command/push.go
James Bardin 0c714592f0 Fix variable handling on subsequent pushes
The handling of remote variables was completely disabled for push.
We still need to fetch variables from atlas for push, because if the
variable is only set remotely the Input walk will still prompt the user
for a value. We add the missing remote variables to the context
to disable input.

We now only handle remote variables as atlas.TFVar and explicitly pass
around that type rather than an `interface{}`.

Shorten the text fixture slightly to make the output a little more
readable on failures.
2016-07-28 15:06:53 -04:00

467 lines
12 KiB
Go

package command
import (
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"github.com/hashicorp/atlas-go/archive"
"github.com/hashicorp/atlas-go/v1"
"github.com/hashicorp/terraform/terraform"
)
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
var overwrite []string
args = c.Meta.process(args, true)
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.Var((*FlagStringSlice)(&overwrite), "overwrite", "")
cmdFlags.Usage = func() { c.Ui.Error(c.Help()) }
if err := cmdFlags.Parse(args); err != nil {
return 1
}
// Make a map of the set values
overwriteMap := make(map[string]struct{}, len(overwrite))
for _, v := range overwrite {
overwriteMap[v] = struct{}{}
}
// 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
}
}
client.DefaultHeader.Set(terraform.VersionHeader, terraform.Version)
if atlasToken != "" {
client.Token = atlasToken
}
c.client = &atlasPushClient{Client: client}
}
// Get the variables we already have in atlas
atlasVars, err := c.client.Get(name)
if err != nil {
c.Ui.Error(fmt.Sprintf(
"Error looking up previously pushed configuration: %s", err))
return 1
}
// filter any overwrites from the atlas vars
for k := range overwriteMap {
delete(atlasVars, k)
}
// Set remote variables in the context if we don't have a value here. These
// don't have to be correct, it just prevents the Input walk from prompting
// the user for input, The atlas variable may be an hcl-encoded object, but
// we're just going to set it as the raw string value.
ctxVars := ctx.Variables()
for k, av := range atlasVars {
if _, ok := ctxVars[k]; !ok {
ctx.SetVariable(k, av.Value)
}
}
// 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
}
// Now that we've gone through the input walk, we can be sure we have all
// the variables we're going to get.
// We are going to keep these separate from the atlas variables until
// upload, so we can notify the user which local variables we're sending.
serializedVars, err := tfVars(ctx.Variables())
if err != nil {
c.Ui.Error(fmt.Sprintf(
"An error has occurred while serializing the variables for uploading:\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
}
// Output to the user the variables that will be uploaded
var setVars []string
// variables to upload
var uploadVars []atlas.TFVar
// Now we can combine the vars for upload to atlas and list the variables
// we're uploading for the user
for _, sv := range serializedVars {
if av, ok := atlasVars[sv.Key]; ok {
// this belongs to Atlas
uploadVars = append(uploadVars, av)
} else {
// we're uploading our local version
setVars = append(setVars, sv.Key)
uploadVars = append(uploadVars, sv)
}
}
sort.Strings(setVars)
if len(setVars) > 0 {
c.Ui.Output(
"The following variables will be set or overwritten within Atlas from\n" +
"their local values. All other variables are already set within Atlas.\n" +
"If you want to modify the value of a variable, use the Atlas web\n" +
"interface or set it locally and use the -overwrite flag.\n\n")
for _, v := range setVars {
c.Ui.Output(fmt.Sprintf(" * %s", v))
}
// Newline
c.Ui.Output("")
}
// Upsert!
opts := &pushUpsertOptions{
Name: name,
Archive: archiveR,
Variables: ctx.Variables(),
TFVars: uploadVars,
}
c.Ui.Output("Uploading Terraform configuration...")
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.
-overwrite=foo Variable keys that should overwrite values in Atlas.
Otherwise, variables already set in Atlas will overwrite
local values. This flag can be repeated.
-var 'foo=bar' Set a variable in the Terraform configuration. This
flag can be set multiple times.
-var-file=foo Set variables in the Terraform configuration from
a file. If "terraform.tfvars" is present, it will be
automatically loaded if this flag is not specified.
-vcs=true If true (default), push will upload only files
committed to your VCS, if detected.
-no-color If specified, output won't contain any color.
`
return strings.TrimSpace(helpText)
}
func sortedKeys(m map[string]interface{}) []string {
var keys []string
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// build the set of TFVars for push
func tfVars(vars map[string]interface{}) ([]atlas.TFVar, error) {
var tfVars []atlas.TFVar
var err error
RANGE:
for _, k := range sortedKeys(vars) {
v := vars[k]
var hcl []byte
tfv := atlas.TFVar{Key: k}
switch v := v.(type) {
case string:
tfv.Value = v
case []interface{}:
hcl, err = encodeHCL(v)
if err != nil {
break RANGE
}
tfv.Value = string(hcl)
tfv.IsHCL = true
case map[string]interface{}:
hcl, err = encodeHCL(v)
if err != nil {
break RANGE
}
tfv.Value = string(hcl)
tfv.IsHCL = true
default:
err = fmt.Errorf("unknown type %T for variable %s", v, k)
}
tfVars = append(tfVars, tfv)
}
return tfVars, err
}
func (c *PushCommand) Synopsis() string {
return "Upload this Terraform module to Atlas to run"
}
// pushClient is implemented internally to control where pushes go. This is
// either to Atlas or a mock for testing. We still return a map to make it
// easier to check for variable existence when filtering the overrides.
type pushClient interface {
Get(string) (map[string]atlas.TFVar, error)
Upsert(*pushUpsertOptions) (int, error)
}
type pushUpsertOptions struct {
Name string
Archive *archive.Archive
Variables map[string]interface{}
TFVars []atlas.TFVar
}
type atlasPushClient struct {
Client *atlas.Client
}
func (c *atlasPushClient) Get(name string) (map[string]atlas.TFVar, 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
}
variables := make(map[string]atlas.TFVar)
if version == nil {
return variables, nil
}
// Variables is superseded by TFVars
if version.TFVars == nil {
for k, v := range version.Variables {
variables[k] = atlas.TFVar{Key: k, Value: v}
}
} else {
for _, v := range version.TFVars {
variables[v.Key] = v
}
}
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{
TFVars: opts.TFVars,
}
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]atlas.TFVar
GetError error
UpsertCalled bool
UpsertOptions *pushUpsertOptions
UpsertVersion int
UpsertError error
}
func (c *mockPushClient) Get(name string) (map[string]atlas.TFVar, 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
}