Merge pull request #1294 from hashicorp/f-push
command/push: for remote TF configuration runs
This commit is contained in:
commit
28ecdacdf9
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1,337 @@
|
|||
package command
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform/terraform"
|
||||
"github.com/mitchellh/cli"
|
||||
)
|
||||
|
||||
func TestPush_good(t *testing.T) {
|
||||
tmp, cwd := testCwd(t)
|
||||
defer testFixCwd(t, tmp, cwd)
|
||||
|
||||
// Create remote state file, this should be pulled
|
||||
conf, srv := testRemoteState(t, testState(), 200)
|
||||
defer srv.Close()
|
||||
|
||||
// Persist local remote state
|
||||
s := terraform.NewState()
|
||||
s.Serial = 5
|
||||
s.Remote = conf
|
||||
testStateFileRemote(t, s)
|
||||
|
||||
// Path where the archive will be "uploaded" to
|
||||
archivePath := testTempFile(t)
|
||||
defer os.Remove(archivePath)
|
||||
|
||||
client := &mockPushClient{File: archivePath}
|
||||
ui := new(cli.MockUi)
|
||||
c := &PushCommand{
|
||||
Meta: Meta{
|
||||
ContextOpts: testCtxConfig(testProvider()),
|
||||
Ui: ui,
|
||||
},
|
||||
|
||||
client: client,
|
||||
}
|
||||
|
||||
args := []string{
|
||||
testFixturePath("push"),
|
||||
}
|
||||
if code := c.Run(args); code != 0 {
|
||||
t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String())
|
||||
}
|
||||
|
||||
actual := testArchiveStr(t, archivePath)
|
||||
expected := []string{
|
||||
".terraform/",
|
||||
".terraform/terraform.tfstate",
|
||||
"main.tf",
|
||||
}
|
||||
if !reflect.DeepEqual(actual, expected) {
|
||||
t.Fatalf("bad: %#v", actual)
|
||||
}
|
||||
|
||||
variables := make(map[string]string)
|
||||
if !reflect.DeepEqual(client.UpsertOptions.Variables, variables) {
|
||||
t.Fatalf("bad: %#v", client.UpsertOptions)
|
||||
}
|
||||
|
||||
if client.UpsertOptions.Name != "foo" {
|
||||
t.Fatalf("bad: %#v", client.UpsertOptions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPush_input(t *testing.T) {
|
||||
tmp, cwd := testCwd(t)
|
||||
defer testFixCwd(t, tmp, cwd)
|
||||
|
||||
// Create remote state file, this should be pulled
|
||||
conf, srv := testRemoteState(t, testState(), 200)
|
||||
defer srv.Close()
|
||||
|
||||
// Persist local remote state
|
||||
s := terraform.NewState()
|
||||
s.Serial = 5
|
||||
s.Remote = conf
|
||||
testStateFileRemote(t, s)
|
||||
|
||||
// Path where the archive will be "uploaded" to
|
||||
archivePath := testTempFile(t)
|
||||
defer os.Remove(archivePath)
|
||||
|
||||
client := &mockPushClient{File: archivePath}
|
||||
ui := new(cli.MockUi)
|
||||
c := &PushCommand{
|
||||
Meta: Meta{
|
||||
ContextOpts: testCtxConfig(testProvider()),
|
||||
Ui: ui,
|
||||
},
|
||||
|
||||
client: client,
|
||||
}
|
||||
|
||||
// Disable test mode so input would be asked and setup the
|
||||
// input reader/writers.
|
||||
test = false
|
||||
defer func() { test = true }()
|
||||
defaultInputReader = bytes.NewBufferString("foo\n")
|
||||
defaultInputWriter = new(bytes.Buffer)
|
||||
|
||||
args := []string{
|
||||
testFixturePath("push-input"),
|
||||
}
|
||||
if code := c.Run(args); code != 0 {
|
||||
t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String())
|
||||
}
|
||||
|
||||
variables := map[string]string{
|
||||
"foo": "foo",
|
||||
}
|
||||
if !reflect.DeepEqual(client.UpsertOptions.Variables, variables) {
|
||||
t.Fatalf("bad: %#v", client.UpsertOptions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPush_inputPartial(t *testing.T) {
|
||||
tmp, cwd := testCwd(t)
|
||||
defer testFixCwd(t, tmp, cwd)
|
||||
|
||||
// Create remote state file, this should be pulled
|
||||
conf, srv := testRemoteState(t, testState(), 200)
|
||||
defer srv.Close()
|
||||
|
||||
// Persist local remote state
|
||||
s := terraform.NewState()
|
||||
s.Serial = 5
|
||||
s.Remote = conf
|
||||
testStateFileRemote(t, s)
|
||||
|
||||
// Path where the archive will be "uploaded" to
|
||||
archivePath := testTempFile(t)
|
||||
defer os.Remove(archivePath)
|
||||
|
||||
client := &mockPushClient{
|
||||
File: archivePath,
|
||||
GetResult: map[string]string{"foo": "bar"},
|
||||
}
|
||||
ui := new(cli.MockUi)
|
||||
c := &PushCommand{
|
||||
Meta: Meta{
|
||||
ContextOpts: testCtxConfig(testProvider()),
|
||||
Ui: ui,
|
||||
},
|
||||
|
||||
client: client,
|
||||
}
|
||||
|
||||
// Disable test mode so input would be asked and setup the
|
||||
// input reader/writers.
|
||||
test = false
|
||||
defer func() { test = true }()
|
||||
defaultInputReader = bytes.NewBufferString("foo\n")
|
||||
defaultInputWriter = new(bytes.Buffer)
|
||||
|
||||
args := []string{
|
||||
testFixturePath("push-input-partial"),
|
||||
}
|
||||
if code := c.Run(args); code != 0 {
|
||||
t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String())
|
||||
}
|
||||
|
||||
variables := map[string]string{
|
||||
"foo": "bar",
|
||||
"bar": "foo",
|
||||
}
|
||||
if !reflect.DeepEqual(client.UpsertOptions.Variables, variables) {
|
||||
t.Fatalf("bad: %#v", client.UpsertOptions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPush_name(t *testing.T) {
|
||||
tmp, cwd := testCwd(t)
|
||||
defer testFixCwd(t, tmp, cwd)
|
||||
|
||||
// Create remote state file, this should be pulled
|
||||
conf, srv := testRemoteState(t, testState(), 200)
|
||||
defer srv.Close()
|
||||
|
||||
// Persist local remote state
|
||||
s := terraform.NewState()
|
||||
s.Serial = 5
|
||||
s.Remote = conf
|
||||
testStateFileRemote(t, s)
|
||||
|
||||
// Path where the archive will be "uploaded" to
|
||||
archivePath := testTempFile(t)
|
||||
defer os.Remove(archivePath)
|
||||
|
||||
client := &mockPushClient{File: archivePath}
|
||||
ui := new(cli.MockUi)
|
||||
c := &PushCommand{
|
||||
Meta: Meta{
|
||||
ContextOpts: testCtxConfig(testProvider()),
|
||||
Ui: ui,
|
||||
},
|
||||
|
||||
client: client,
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-name", "bar",
|
||||
testFixturePath("push"),
|
||||
}
|
||||
if code := c.Run(args); code != 0 {
|
||||
t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String())
|
||||
}
|
||||
|
||||
if client.UpsertOptions.Name != "bar" {
|
||||
t.Fatalf("bad: %#v", client.UpsertOptions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPush_noState(t *testing.T) {
|
||||
tmp, cwd := testCwd(t)
|
||||
defer testFixCwd(t, tmp, cwd)
|
||||
|
||||
ui := new(cli.MockUi)
|
||||
c := &PushCommand{
|
||||
Meta: Meta{
|
||||
ContextOpts: testCtxConfig(testProvider()),
|
||||
Ui: ui,
|
||||
},
|
||||
}
|
||||
|
||||
args := []string{}
|
||||
if code := c.Run(args); code != 1 {
|
||||
t.Fatalf("bad: \n%s", ui.ErrorWriter.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPush_noRemoteState(t *testing.T) {
|
||||
state := &terraform.State{
|
||||
Modules: []*terraform.ModuleState{
|
||||
&terraform.ModuleState{
|
||||
Path: []string{"root"},
|
||||
Resources: map[string]*terraform.ResourceState{
|
||||
"test_instance.foo": &terraform.ResourceState{
|
||||
Type: "test_instance",
|
||||
Primary: &terraform.InstanceState{
|
||||
ID: "bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
statePath := testStateFile(t, state)
|
||||
|
||||
ui := new(cli.MockUi)
|
||||
c := &PushCommand{
|
||||
Meta: Meta{
|
||||
Ui: ui,
|
||||
},
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-state", statePath,
|
||||
}
|
||||
if code := c.Run(args); code != 1 {
|
||||
t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPush_plan(t *testing.T) {
|
||||
tmp, cwd := testCwd(t)
|
||||
defer testFixCwd(t, tmp, cwd)
|
||||
|
||||
// Create remote state file, this should be pulled
|
||||
conf, srv := testRemoteState(t, testState(), 200)
|
||||
defer srv.Close()
|
||||
|
||||
// Persist local remote state
|
||||
s := terraform.NewState()
|
||||
s.Serial = 5
|
||||
s.Remote = conf
|
||||
testStateFileRemote(t, s)
|
||||
|
||||
// Create a plan
|
||||
planPath := testPlanFile(t, &terraform.Plan{
|
||||
Module: testModule(t, "apply"),
|
||||
})
|
||||
|
||||
ui := new(cli.MockUi)
|
||||
c := &PushCommand{
|
||||
Meta: Meta{
|
||||
ContextOpts: testCtxConfig(testProvider()),
|
||||
Ui: ui,
|
||||
},
|
||||
}
|
||||
|
||||
args := []string{planPath}
|
||||
if code := c.Run(args); code != 1 {
|
||||
t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String())
|
||||
}
|
||||
}
|
||||
|
||||
func testArchiveStr(t *testing.T, path string) []string {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Ungzip
|
||||
gzipR, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
|
||||
// Accumulator
|
||||
result := make([]string, 0, 10)
|
||||
|
||||
// Untar
|
||||
tarR := tar.NewReader(gzipR)
|
||||
for {
|
||||
header, err := tarR.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
|
||||
result = append(result, header.Name)
|
||||
}
|
||||
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
variable "foo" {}
|
||||
variable "bar" {}
|
||||
|
||||
resource "test_instance" "foo" {}
|
||||
|
||||
atlas {
|
||||
name = "foo"
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
variable "foo" {}
|
||||
|
||||
resource "test_instance" "foo" {}
|
||||
|
||||
atlas {
|
||||
name = "foo"
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
resource "aws_instance" "foo" {}
|
||||
|
||||
atlas {
|
||||
name = "foo"
|
||||
}
|
|
@ -80,6 +80,12 @@ func init() {
|
|||
}, nil
|
||||
},
|
||||
|
||||
"push": func() (cli.Command, error) {
|
||||
return &command.PushCommand{
|
||||
Meta: meta,
|
||||
}, nil
|
||||
},
|
||||
|
||||
"refresh": func() (cli.Command, error) {
|
||||
return &command.RefreshCommand{
|
||||
Meta: meta,
|
||||
|
|
|
@ -21,6 +21,7 @@ func Append(c1, c2 *Config) (*Config, error) {
|
|||
c.unknownKeys = append(c.unknownKeys, k)
|
||||
}
|
||||
}
|
||||
|
||||
for _, k := range c2.unknownKeys {
|
||||
_, present := unknowns[k]
|
||||
if !present {
|
||||
|
@ -29,6 +30,11 @@ func Append(c1, c2 *Config) (*Config, error) {
|
|||
}
|
||||
}
|
||||
|
||||
c.Atlas = c1.Atlas
|
||||
if c2.Atlas != nil {
|
||||
c.Atlas = c2.Atlas
|
||||
}
|
||||
|
||||
if len(c1.Modules) > 0 || len(c2.Modules) > 0 {
|
||||
c.Modules = make(
|
||||
[]*Module, 0, len(c1.Modules)+len(c2.Modules))
|
||||
|
|
|
@ -12,6 +12,9 @@ func TestAppend(t *testing.T) {
|
|||
}{
|
||||
{
|
||||
&Config{
|
||||
Atlas: &AtlasConfig{
|
||||
Name: "foo",
|
||||
},
|
||||
Modules: []*Module{
|
||||
&Module{Name: "foo"},
|
||||
},
|
||||
|
@ -32,6 +35,9 @@ func TestAppend(t *testing.T) {
|
|||
},
|
||||
|
||||
&Config{
|
||||
Atlas: &AtlasConfig{
|
||||
Name: "bar",
|
||||
},
|
||||
Modules: []*Module{
|
||||
&Module{Name: "bar"},
|
||||
},
|
||||
|
@ -52,6 +58,9 @@ func TestAppend(t *testing.T) {
|
|||
},
|
||||
|
||||
&Config{
|
||||
Atlas: &AtlasConfig{
|
||||
Name: "bar",
|
||||
},
|
||||
Modules: []*Module{
|
||||
&Module{Name: "foo"},
|
||||
&Module{Name: "bar"},
|
||||
|
|
|
@ -28,6 +28,7 @@ type Config struct {
|
|||
// any meaningful directory.
|
||||
Dir string
|
||||
|
||||
Atlas *AtlasConfig
|
||||
Modules []*Module
|
||||
ProviderConfigs []*ProviderConfig
|
||||
Resources []*Resource
|
||||
|
@ -39,6 +40,13 @@ type Config struct {
|
|||
unknownKeys []string
|
||||
}
|
||||
|
||||
// AtlasConfig is the configuration for building in HashiCorp's Atlas.
|
||||
type AtlasConfig struct {
|
||||
Name string
|
||||
Include []string
|
||||
Exclude []string
|
||||
}
|
||||
|
||||
// Module is a module used within a configuration.
|
||||
//
|
||||
// This does not represent a module itself, this represents a module
|
||||
|
|
|
@ -17,6 +17,7 @@ type hclConfigurable struct {
|
|||
|
||||
func (t *hclConfigurable) Config() (*Config, error) {
|
||||
validKeys := map[string]struct{}{
|
||||
"atlas": struct{}{},
|
||||
"module": struct{}{},
|
||||
"output": struct{}{},
|
||||
"provider": struct{}{},
|
||||
|
@ -70,6 +71,15 @@ func (t *hclConfigurable) Config() (*Config, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// Get Atlas configuration
|
||||
if atlas := t.Object.Get("atlas", false); atlas != nil {
|
||||
var err error
|
||||
config.Atlas, err = loadAtlasHcl(atlas)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Build the modules
|
||||
if modules := t.Object.Get("module", false); modules != nil {
|
||||
var err error
|
||||
|
@ -187,6 +197,19 @@ func loadFileHcl(root string) (configurable, []string, error) {
|
|||
return result, nil, nil
|
||||
}
|
||||
|
||||
// Given a handle to a HCL object, this transforms it into the Atlas
|
||||
// configuration.
|
||||
func loadAtlasHcl(obj *hclobj.Object) (*AtlasConfig, error) {
|
||||
var config AtlasConfig
|
||||
if err := hcl.DecodeObject(&config, obj); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"Error reading atlas config: %s",
|
||||
err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// Given a handle to a HCL object, this recurses into the structure
|
||||
// and pulls out a list of modules.
|
||||
//
|
||||
|
|
|
@ -2,6 +2,7 @@ package config
|
|||
|
||||
import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
@ -57,6 +58,11 @@ func TestLoadBasic(t *testing.T) {
|
|||
t.Fatalf("bad: %#v", c.Dir)
|
||||
}
|
||||
|
||||
expectedAtlas := &AtlasConfig{Name: "mitchellh/foo"}
|
||||
if !reflect.DeepEqual(c.Atlas, expectedAtlas) {
|
||||
t.Fatalf("bad: %#v", c.Atlas)
|
||||
}
|
||||
|
||||
actual := variablesStr(c.Variables)
|
||||
if actual != strings.TrimSpace(basicVariablesStr) {
|
||||
t.Fatalf("bad:\n%s", actual)
|
||||
|
@ -132,6 +138,11 @@ func TestLoadBasic_json(t *testing.T) {
|
|||
t.Fatalf("bad: %#v", c.Dir)
|
||||
}
|
||||
|
||||
expectedAtlas := &AtlasConfig{Name: "mitchellh/foo"}
|
||||
if !reflect.DeepEqual(c.Atlas, expectedAtlas) {
|
||||
t.Fatalf("bad: %#v", c.Atlas)
|
||||
}
|
||||
|
||||
actual := variablesStr(c.Variables)
|
||||
if actual != strings.TrimSpace(basicVariablesStr) {
|
||||
t.Fatalf("bad:\n%s", actual)
|
||||
|
|
|
@ -25,6 +25,13 @@ func Merge(c1, c2 *Config) (*Config, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// Merge Atlas configuration. This is a dumb one overrides the other
|
||||
// sort of merge.
|
||||
c.Atlas = c1.Atlas
|
||||
if c2.Atlas != nil {
|
||||
c.Atlas = c2.Atlas
|
||||
}
|
||||
|
||||
// NOTE: Everything below is pretty gross. Due to the lack of generics
|
||||
// in Go, there is some hoop-jumping involved to make this merging a
|
||||
// little more test-friendly and less repetitive. Ironically, making it
|
||||
|
|
|
@ -13,6 +13,9 @@ func TestMerge(t *testing.T) {
|
|||
// Normal good case.
|
||||
{
|
||||
&Config{
|
||||
Atlas: &AtlasConfig{
|
||||
Name: "foo",
|
||||
},
|
||||
Modules: []*Module{
|
||||
&Module{Name: "foo"},
|
||||
},
|
||||
|
@ -33,6 +36,9 @@ func TestMerge(t *testing.T) {
|
|||
},
|
||||
|
||||
&Config{
|
||||
Atlas: &AtlasConfig{
|
||||
Name: "bar",
|
||||
},
|
||||
Modules: []*Module{
|
||||
&Module{Name: "bar"},
|
||||
},
|
||||
|
@ -53,6 +59,9 @@ func TestMerge(t *testing.T) {
|
|||
},
|
||||
|
||||
&Config{
|
||||
Atlas: &AtlasConfig{
|
||||
Name: "bar",
|
||||
},
|
||||
Modules: []*Module{
|
||||
&Module{Name: "foo"},
|
||||
&Module{Name: "bar"},
|
||||
|
|
|
@ -49,3 +49,7 @@ resource "aws_instance" "db" {
|
|||
output "web_ip" {
|
||||
value = "${aws_instance.web.private_ip}"
|
||||
}
|
||||
|
||||
atlas {
|
||||
name = "mitchellh/foo"
|
||||
}
|
||||
|
|
|
@ -63,5 +63,9 @@
|
|||
"web_ip": {
|
||||
"value": "${aws_instance.web.private_ip}"
|
||||
}
|
||||
},
|
||||
|
||||
"atlas": {
|
||||
"name": "mitchellh/foo"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,9 +16,12 @@ import (
|
|||
type InputMode byte
|
||||
|
||||
const (
|
||||
// InputModeVar asks for variables
|
||||
// InputModeVar asks for all variables
|
||||
InputModeVar InputMode = 1 << iota
|
||||
|
||||
// InputModeVarUnset asks for variables which are not set yet
|
||||
InputModeVarUnset
|
||||
|
||||
// InputModeProvider asks for provider variables
|
||||
InputModeProvider
|
||||
|
||||
|
@ -154,6 +157,14 @@ func (c *Context) Input(mode InputMode) error {
|
|||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
// If we only care about unset variables, then if the variabel
|
||||
// is set, continue on.
|
||||
if mode&InputModeVarUnset != 0 {
|
||||
if _, ok := c.variables[n]; ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
v := m[n]
|
||||
switch v.Type() {
|
||||
case config.VariableTypeMap:
|
||||
|
@ -365,6 +376,23 @@ func (c *Context) Validate() ([]string, []error) {
|
|||
return walker.ValidationWarnings, rerrs.Errors
|
||||
}
|
||||
|
||||
// Module returns the module tree associated with this context.
|
||||
func (c *Context) Module() *module.Tree {
|
||||
return c.module
|
||||
}
|
||||
|
||||
// Variables will return the mapping of variables that were defined
|
||||
// for this Context. If Input was called, this mapping may be different
|
||||
// than what was given.
|
||||
func (c *Context) Variables() map[string]string {
|
||||
return c.variables
|
||||
}
|
||||
|
||||
// SetVariable sets a variable after a context has already been built.
|
||||
func (c *Context) SetVariable(k, v string) {
|
||||
c.variables[k] = v
|
||||
}
|
||||
|
||||
func (c *Context) acquireRun() chan<- struct{} {
|
||||
c.l.Lock()
|
||||
defer c.l.Unlock()
|
||||
|
|
|
@ -2758,6 +2758,48 @@ func TestContext2Input_varOnly(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestContext2Input_varOnlyUnset(t *testing.T) {
|
||||
input := new(MockUIInput)
|
||||
m := testModule(t, "input-vars-unset")
|
||||
p := testProvider("aws")
|
||||
p.ApplyFn = testApplyFn
|
||||
p.DiffFn = testDiffFn
|
||||
ctx := testContext2(t, &ContextOpts{
|
||||
Module: m,
|
||||
Providers: map[string]ResourceProviderFactory{
|
||||
"aws": testProviderFuncFixed(p),
|
||||
},
|
||||
Variables: map[string]string{
|
||||
"foo": "foovalue",
|
||||
},
|
||||
UIInput: input,
|
||||
})
|
||||
|
||||
input.InputReturnMap = map[string]string{
|
||||
"var.foo": "nope",
|
||||
"var.bar": "baz",
|
||||
}
|
||||
|
||||
if err := ctx.Input(InputModeVar | InputModeVarUnset); err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
|
||||
if _, err := ctx.Plan(nil); err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
|
||||
state, err := ctx.Apply()
|
||||
if err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
|
||||
actualStr := strings.TrimSpace(state.String())
|
||||
expectedStr := strings.TrimSpace(testTerraformInputVarOnlyUnsetStr)
|
||||
if actualStr != expectedStr {
|
||||
t.Fatalf("bad: \n%s", actualStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext2Apply(t *testing.T) {
|
||||
m := testModule(t, "apply-good")
|
||||
p := testProvider("aws")
|
||||
|
|
|
@ -150,6 +150,14 @@ aws_instance.foo:
|
|||
type = aws_instance
|
||||
`
|
||||
|
||||
const testTerraformInputVarOnlyUnsetStr = `
|
||||
aws_instance.foo:
|
||||
ID = foo
|
||||
bar = baz
|
||||
foo = foovalue
|
||||
type = aws_instance
|
||||
`
|
||||
|
||||
const testTerraformInputVarsStr = `
|
||||
aws_instance.bar:
|
||||
ID = foo
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
variable "foo" {}
|
||||
variable "bar" {}
|
||||
|
||||
resource "aws_instance" "foo" {
|
||||
foo = "${var.foo}"
|
||||
bar = "${var.bar}"
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
---
|
||||
layout: "docs"
|
||||
page_title: "Command: push"
|
||||
sidebar_current: "docs-commands-push"
|
||||
description: |-
|
||||
The `terraform push` command is used to upload the Terraform configuration to HashiCorp's Atlas service for automatically managing your infrastructure in the cloud.
|
||||
---
|
||||
|
||||
# Command: push
|
||||
|
||||
The `terraform push` command uploads your Terraform configuration to
|
||||
be managed by HashiCorp's [Atlas](https://atlas.hashicorp.com).
|
||||
By uploading your configuration to Atlas, Atlas can automatically run
|
||||
Terraform for you, will save all state transitions, will save plans,
|
||||
and will keep a history of all Terraform runs.
|
||||
|
||||
This makes it significantly easier to use Terraform as a team: team
|
||||
members modify the Terraform configurations locally and continue to
|
||||
use normal version control. When the Terraform configurations are ready
|
||||
to be run, they are pushed to Atlas, and any member of your team can
|
||||
run Terraform with the push of a button.
|
||||
|
||||
Atlas can also be used to set ACLs on who can run Terraform, and a
|
||||
future update of Atlas will allow parallel Terraform runs and automatically
|
||||
perform infrastructure locking so only one run is modifying the same
|
||||
infrastructure at a time.
|
||||
|
||||
## Usage
|
||||
|
||||
Usage: `terraform push [options] [path]`
|
||||
|
||||
The `path` argument is the same as for the
|
||||
[apply](/docs/commands/apply.html) command.
|
||||
|
||||
The command-line flags are all optional. The list of available flags are:
|
||||
|
||||
* `-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](/docs/modules/index.html)
|
||||
being used are all locked at their current checkout and uploaded
|
||||
completely to Atlas. This prevents Atlas from running `terraform get`
|
||||
for you.
|
||||
|
||||
* `-name=<name>` - Name of the infrastructure configuration in Atlas.
|
||||
The format of this is: "username/name" so that you can upload
|
||||
configurations not just to your account but to other accounts and
|
||||
organizations. This setting can also be set in the configuration
|
||||
in the
|
||||
[Atlas section](/docs/configuration/atlas.html).
|
||||
|
||||
* `-no-color` - Disables output with coloring
|
||||
|
||||
* `-token=<token>` - Atlas API token to use to authorize the upload.
|
||||
If blank or unspecified, the `ATLAS_TOKEN` environmental variable
|
||||
will be used.
|
||||
|
||||
* `-vcs=true` - If true (default), then Terraform will detect if a VCS
|
||||
is in use, such as Git, and will only upload files that are comitted to
|
||||
version control. If no version control system is detected, Terraform will
|
||||
upload all files in `path` (parameter to the command).
|
||||
|
||||
## Packaged Files
|
||||
|
||||
The files that are uploaded and packaged with a `push` are all the
|
||||
files in the `path` given as the parameter to the command, recursively.
|
||||
By default (unless `-vcs=false` is specified), Terraform will automatically
|
||||
detect when a VCS such as Git is being used, and in that case will only
|
||||
upload the files that are comitted. Because of this built-in intelligence,
|
||||
you don't have to worry about excluding folders such as ".git" or ".hg" usually.
|
||||
|
||||
If Terraform doesn't detect a VCS, it will upload all files.
|
||||
|
||||
The reason Terraform uploads all of these files is because Terraform
|
||||
cannot know what is and isn't being used for provisioning, so it uploads
|
||||
all the files to be safe. To exclude certain files, specify the `-exclude`
|
||||
flag when pushing, or specify the `exclude` parameter in the
|
||||
[Atlas configuration section](/docs/configuration/atlas.html).
|
||||
|
||||
## Remote State Requirement
|
||||
|
||||
`terraform push` requires that
|
||||
[remote state](/docs/commands/remote-config.html)
|
||||
is enabled. The reasoning for this is simple: `terraform push` sends your
|
||||
configuration to be managed remotely. For it to keep the state in sync
|
||||
and for you to be able to easily access that state, remote state must
|
||||
be enabled instead of juggling local files.
|
||||
|
||||
While `terraform push` sends your configuration to be managed by Atlas,
|
||||
the remote state backend _does not_ have to be Atlas. It can be anything
|
||||
as long as it is accessible by the public internet, since Atlas will need
|
||||
to be able to communicate to it.
|
||||
|
||||
**Warning:** The credentials for accessing the remote state will be
|
||||
sent up to Atlas as well. Therefore, we recommend you use access keys
|
||||
that are restricted if possible.
|
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
layout: "docs"
|
||||
page_title: "Configuring Atlas"
|
||||
sidebar_current: "docs-config-atlas"
|
||||
description: |-
|
||||
Atlas is the ideal way to use Terraform in a team environment. Atlas will run Terraform for you, safely handle parallelization across different team members, save run history along with plans, and more.
|
||||
---
|
||||
|
||||
# Atlas Configuration
|
||||
|
||||
Terraform can be configured to be able to upload to HashiCorp's
|
||||
[Atlas](https://atlas.hashicorp.com). This configuration doesn't change
|
||||
the behavior of Terraform itself, it only configures your Terraform
|
||||
configuration to support being uploaded to Atlas via the
|
||||
[push command](/docs/commands/push.html).
|
||||
|
||||
For more information on the benefits of uploading your Terraform
|
||||
configuration to Atlas, please see the
|
||||
[push command documentation](/docs/commands/push.html).
|
||||
|
||||
This page assumes you're familiar with the
|
||||
[configuration syntax](/docs/configuration/syntax.html)
|
||||
already.
|
||||
|
||||
## Example
|
||||
|
||||
Atlas configuration looks like the following:
|
||||
|
||||
```
|
||||
atlas {
|
||||
name = "mitchellh/production-example"
|
||||
}
|
||||
```
|
||||
|
||||
## Description
|
||||
|
||||
The `atlas` block configures the settings when Terraform is
|
||||
[pushed](/docs/commands/push.html) to Atlas. Only one `atlas` block
|
||||
is allowed.
|
||||
|
||||
Within the block (the `{ }`) is configuration for Atlas uploading.
|
||||
No keys are required, but the key typically set is `name`.
|
||||
|
||||
**No value within the `atlas` block can use interpolations.** Due
|
||||
to the nature of this configuration, interpolations are not possible.
|
||||
If you want to parameterize these settings, use the Atlas block to
|
||||
set defaults, then use the command-line flags of the
|
||||
[push command](/docs/commands/push.html) to override.
|
||||
|
||||
## Syntax
|
||||
|
||||
The full syntax is:
|
||||
|
||||
```
|
||||
atlas {
|
||||
name = VALUE
|
||||
}
|
||||
```
|
|
@ -45,6 +45,10 @@
|
|||
<a href="/docs/configuration/modules.html">Modules</a>
|
||||
</li>
|
||||
|
||||
<li<%= sidebar_current("docs-config-atlas") %>>
|
||||
<a href="/docs/configuration/atlas.html">Atlas</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
|
@ -79,6 +83,10 @@
|
|||
<a href="/docs/commands/plan.html">plan</a>
|
||||
</li>
|
||||
|
||||
<li<%= sidebar_current("docs-commands-push") %>>
|
||||
<a href="/docs/commands/push.html">push</a>
|
||||
</li>
|
||||
|
||||
<li<%= sidebar_current("docs-commands-refresh") %>>
|
||||
<a href="/docs/commands/refresh.html">refresh</a>
|
||||
</li>
|
||||
|
|
Loading…
Reference in New Issue