pulumi/cmd/new_test.go
Justin Van Patten c08714ffb4
Support lists and maps in config (#3342)
This change adds support for lists and maps in config. We now allow
lists/maps (and nested structures) in `Pulumi.<stack>.yaml` (or
`Pulumi.<stack>.json`; yes, we currently support that).

For example:

```yaml
config:
  proj:blah:
  - a
  - b
  - c
  proj:hello: world
  proj:outer:
    inner: value
  proj:servers:
  - port: 80
```

While such structures could be specified in the `.yaml` file manually,
we support setting values in maps/lists from the command line.

As always, you can specify single values with:

```shell
$ pulumi config set hello world
```

Which results in the following YAML:

```yaml
proj:hello world
```

And single value secrets via:

```shell
$ pulumi config set --secret token shhh
```

Which results in the following YAML:

```yaml
proj:token:
  secure: v1:VZAhuroR69FkEPTk:isKafsoZVMWA9pQayGzbWNynww==
```

Values in a list can be set from the command line using the new
`--path` flag, which indicates the config key contains a path to a
property in a map or list:

```shell
$ pulumi config set --path names[0] a
$ pulumi config set --path names[1] b
$ pulumi config set --path names[2] c
```

Which results in:

```yaml
proj:names
- a
- b
- c
```

Values can be obtained similarly:

```shell
$ pulumi config get --path names[1]
b
```

Or setting values in a map:

```shell
$ pulumi config set --path outer.inner value
```

Which results in:

```yaml
proj:outer:
  inner: value
```

Of course, setting values in nested structures is supported:

```shell
$ pulumi config set --path servers[0].port 80
```

Which results in:

```yaml
proj:servers:
- port: 80
```

If you want to include a period in the name of a property, it can be
specified as:

```
$ pulumi config set --path 'nested["foo.bar"]' baz
```

Which results in:

```yaml
proj:nested:
  foo.bar: baz
```

Examples of valid paths:

- root
- root.nested
- 'root["nested"]'
- root.double.nest
- 'root["double"].nest'
- 'root["double"]["nest"]'
- root.array[0]
- root.array[100]
- root.array[0].nested
- root.array[0][1].nested
- root.nested.array[0].double[1]
- 'root["key with \"escaped\" quotes"]'
- 'root["key with a ."]'
- '["root key with \"escaped\" quotes"].nested'
- '["root key with a ."][100]'

Note: paths that contain quotes can be surrounded by single quotes.

When setting values with `--path`, if the value is `"false"` or
`"true"`, it will be saved as the boolean value, and if it is
convertible to an integer, it will be saved as an integer.

Secure values are supported in lists/maps as well:

```shell
$ pulumi config set --path --secret tokens[0] shh
```

Will result in:

```yaml
proj:tokens:
- secure: v1:wpZRCe36sFg1RxwG:WzPeQrCn4n+m4Ks8ps15MxvFXg==
```

Note: maps of length 1 with a key of “secure” and string value are
reserved for storing secret values. Attempting to create such a value
manually will result in an error:

```shell
$ pulumi config set --path parent.secure foo
error: "secure" key in maps of length 1 are reserved
```

**Accessing config values from the command line with JSON**

```shell
$ pulumi config --json
```

Will output:

```json
{
  "proj:hello": {
    "value": "world",
    "secret": false,
    "object": false
  },
  "proj:names": {
    "value": "[\"a\",\"b\",\"c\"]",
    "secret": false,
    "object": true,
    "objectValue": [
      "a",
      "b",
      "c"
    ]
  },
  "proj:nested": {
    "value": "{\"foo.bar\":\"baz\"}",
    "secret": false,
    "object": true,
    "objectValue": {
      "foo.bar": "baz"
    }
  },
  "proj:outer": {
    "value": "{\"inner\":\"value\"}",
    "secret": false,
    "object": true,
    "objectValue": {
      "inner": "value"
    }
  },
  "proj:servers": {
    "value": "[{\"port\":80}]",
    "secret": false,
    "object": true,
    "objectValue": [
      {
        "port": 80
      }
    ]
  },
  "proj:token": {
    "secret": true,
    "object": false
  },
  "proj:tokens": {
    "secret": true,
    "object": true
  }
}
```

If the value is a map or list, `"object"` will be `true`. `"value"` will
contain the object as serialized JSON and a new `"objectValue"` property
will be available containing the value of the object.

If the object contains any secret values, `"secret"` will be `true`, and
just like with scalar values, the value will not be outputted unless
`--show-secrets` is specified.

**Accessing config values from Pulumi programs**

Map/list values are available to Pulumi programs as serialized JSON, so
the existing
`getObject`/`requireObject`/`getSecretObject`/`requireSecretObject`
functions can be used to retrieve such values, e.g.:

```typescript
import * as pulumi from "@pulumi/pulumi";

interface Server {
    port: number;
}

const config = new pulumi.Config();

const names = config.requireObject<string[]>("names");
for (const n of names) {
    console.log(n);
}

const servers = config.requireObject<Server[]>("servers");
for (const s of servers) {
    console.log(s.port);
}
```
2019-11-01 13:41:27 -07:00

780 lines
18 KiB
Go

// Copyright 2016-2018, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cmd
import (
"context"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"testing"
"github.com/pulumi/pulumi/pkg/backend"
"github.com/pulumi/pulumi/pkg/backend/display"
"github.com/pulumi/pulumi/pkg/resource/config"
"github.com/pulumi/pulumi/pkg/workspace"
"github.com/stretchr/testify/assert"
)
func TestCreatingStackWithArgsSpecifiedName(t *testing.T) {
skipIfShort(t)
tempdir, _ := ioutil.TempDir("", "test-env")
defer os.RemoveAll(tempdir)
assert.NoError(t, os.Chdir(tempdir))
var args = newArgs{
interactive: false,
prompt: promptForValue,
secretsProvider: "default",
stack: stackName,
templateNameOrURL: "typescript",
}
err := runNew(args)
assert.NoError(t, err)
assert.Equal(t, stackName, loadStackName(t))
removeStack(t, stackName)
}
func TestCreatingStackWithPromptedName(t *testing.T) {
skipIfShort(t)
tempdir, _ := ioutil.TempDir("", "test-env")
defer os.RemoveAll(tempdir)
assert.NoError(t, os.Chdir(tempdir))
uniqueProjectName := filepath.Base(tempdir)
var args = newArgs{
interactive: true,
prompt: promptMock(uniqueProjectName, stackName),
secretsProvider: "default",
templateNameOrURL: "typescript",
}
err := runNew(args)
assert.NoError(t, err)
assert.Equal(t, stackName, loadStackName(t))
removeStack(t, stackName)
}
func TestCreatingStackWithArgsSpecifiedOrgName(t *testing.T) {
skipIfShort(t)
tempdir, _ := ioutil.TempDir("", "test-env")
defer os.RemoveAll(tempdir)
assert.NoError(t, os.Chdir(tempdir))
orgStackName := fmt.Sprintf("%s/%s", currentUser(t), stackName)
var args = newArgs{
interactive: false,
prompt: promptForValue,
secretsProvider: "default",
stack: orgStackName,
templateNameOrURL: "typescript",
}
err := runNew(args)
assert.NoError(t, err)
assert.Equal(t, stackName, loadStackName(t))
removeStack(t, stackName)
}
func TestCreatingStackWithPromptedOrgName(t *testing.T) {
skipIfShort(t)
tempdir, _ := ioutil.TempDir("", "test-env")
defer os.RemoveAll(tempdir)
assert.NoError(t, os.Chdir(tempdir))
uniqueProjectName := filepath.Base(tempdir)
orgStackName := fmt.Sprintf("%s/%s", currentUser(t), stackName)
var args = newArgs{
interactive: true,
prompt: promptMock(uniqueProjectName, orgStackName),
secretsProvider: "default",
templateNameOrURL: "typescript",
}
err := runNew(args)
assert.NoError(t, err)
assert.Equal(t, stackName, loadStackName(t))
removeStack(t, stackName)
}
func TestCreatingStackWithArgsSpecifiedFullNameSucceeds(t *testing.T) {
skipIfShort(t)
tempdir, _ := ioutil.TempDir("", "test-env")
defer os.RemoveAll(tempdir)
assert.NoError(t, os.Chdir(tempdir))
fullStackName := fmt.Sprintf("%s/%s/%s", currentUser(t), projectName, stackName)
var args = newArgs{
interactive: false,
prompt: promptForValue,
secretsProvider: "default",
stack: fullStackName,
templateNameOrURL: "typescript",
}
err := runNew(args)
assert.NoError(t, err)
assert.Equal(t, fullStackName, loadStackName(t))
}
func TestCreatingProjectWithDefaultName(t *testing.T) {
skipIfShort(t)
tempdir, _ := ioutil.TempDir("", "test-env")
defer os.RemoveAll(tempdir)
assert.NoError(t, os.Chdir(tempdir))
defaultProjectName := filepath.Base(tempdir)
var args = newArgs{
interactive: true,
prompt: promptForValue,
secretsProvider: "default",
stack: stackName,
templateNameOrURL: "typescript",
yes: true,
}
err := runNew(args)
assert.NoError(t, err)
removeStack(t, stackName)
proj := loadProject(t, tempdir)
assert.Equal(t, defaultProjectName, proj.Name.String())
}
func TestCreatingProjectWithArgsSpecifiedName(t *testing.T) {
skipIfShort(t)
tempdir, _ := ioutil.TempDir("", "test-env")
defer os.RemoveAll(tempdir)
assert.NoError(t, os.Chdir(tempdir))
uniqueProjectName := filepath.Base(tempdir) + "test"
var args = newArgs{
interactive: false,
name: uniqueProjectName,
prompt: promptForValue,
secretsProvider: "default",
stack: stackName,
templateNameOrURL: "typescript",
}
err := runNew(args)
assert.NoError(t, err)
removeStack(t, stackName)
proj := loadProject(t, tempdir)
assert.Equal(t, uniqueProjectName, proj.Name.String())
}
func TestCreatingProjectWithPromptedName(t *testing.T) {
skipIfShort(t)
tempdir, _ := ioutil.TempDir("", "test-env")
defer os.RemoveAll(tempdir)
assert.NoError(t, os.Chdir(tempdir))
uniqueProjectName := filepath.Base(tempdir) + "test"
var args = newArgs{
interactive: true,
prompt: promptMock(uniqueProjectName, stackName),
secretsProvider: "default",
templateNameOrURL: "typescript",
}
err := runNew(args)
assert.NoError(t, err)
removeStack(t, stackName)
proj := loadProject(t, tempdir)
assert.Equal(t, uniqueProjectName, proj.Name.String())
}
func TestCreatingProjectWithExistingArgsSpecifiedNameFails(t *testing.T) {
skipIfShort(t)
tempdir, _ := ioutil.TempDir("", "test-env")
defer os.RemoveAll(tempdir)
assert.NoError(t, os.Chdir(tempdir))
backendInstance = &backend.MockBackend{
DoesProjectExistF: func(ctx context.Context, name string) (bool, error) {
return name == projectName, nil
},
}
var args = newArgs{
interactive: false,
name: projectName,
prompt: promptForValue,
secretsProvider: "default",
templateNameOrURL: "typescript",
}
err := runNew(args)
assert.Error(t, err)
assert.Contains(t, err.Error(), "project with this name already exists")
}
func TestCreatingProjectWithExistingPromptedNameFails(t *testing.T) {
skipIfShort(t)
tempdir, _ := ioutil.TempDir("", "test-env")
defer os.RemoveAll(tempdir)
assert.NoError(t, os.Chdir(tempdir))
backendInstance = &backend.MockBackend{
DoesProjectExistF: func(ctx context.Context, name string) (bool, error) {
return name == projectName, nil
},
}
var args = newArgs{
interactive: true,
prompt: promptMock(projectName, ""),
secretsProvider: "default",
templateNameOrURL: "typescript",
}
err := runNew(args)
assert.Error(t, err)
assert.Contains(t, err.Error(), "project with this name already exists")
}
func TestGeneratingProjectWithExistingArgsSpecifiedNameSucceeds(t *testing.T) {
skipIfShort(t)
tempdir, _ := ioutil.TempDir("", "test-env")
defer os.RemoveAll(tempdir)
assert.NoError(t, os.Chdir(tempdir))
backendInstance = &backend.MockBackend{
DoesProjectExistF: func(ctx context.Context, name string) (bool, error) {
return true, nil
},
}
// Generate-only command is not creating any stacks, so don't bother with with the name uniqueness check.
var args = newArgs{
generateOnly: true,
interactive: false,
name: projectName,
prompt: promptForValue,
secretsProvider: "default",
templateNameOrURL: "typescript",
}
err := runNew(args)
assert.NoError(t, err)
proj := loadProject(t, tempdir)
assert.Equal(t, projectName, proj.Name.String())
}
func TestGeneratingProjectWithExistingPromptedNameSucceeds(t *testing.T) {
skipIfShort(t)
tempdir, _ := ioutil.TempDir("", "test-env")
defer os.RemoveAll(tempdir)
assert.NoError(t, os.Chdir(tempdir))
backendInstance = &backend.MockBackend{
DoesProjectExistF: func(ctx context.Context, name string) (bool, error) {
return true, nil
},
}
// Generate-only command is not creating any stacks, so don't bother with with the name uniqueness check.
var args = newArgs{
generateOnly: true,
interactive: true,
prompt: promptMock(projectName, ""),
secretsProvider: "default",
templateNameOrURL: "typescript",
}
err := runNew(args)
assert.NoError(t, err)
proj := loadProject(t, tempdir)
assert.Equal(t, projectName, proj.Name.String())
}
func TestGeneratingProjectWithInvalidArgsSpecifiedNameFails(t *testing.T) {
skipIfShort(t)
tempdir, _ := ioutil.TempDir("", "test-env")
defer os.RemoveAll(tempdir)
assert.NoError(t, os.Chdir(tempdir))
backendInstance = &backend.MockBackend{
DoesProjectExistF: func(ctx context.Context, name string) (bool, error) {
return true, nil
},
}
// Generate-only command is not creating any stacks, so don't bother with with the name uniqueness check.
var args = newArgs{
generateOnly: true,
interactive: false,
name: "not#valid",
prompt: promptForValue,
secretsProvider: "default",
templateNameOrURL: "typescript",
}
err := runNew(args)
assert.Error(t, err)
assert.Contains(t, err.Error(), "project name may only contain")
}
func TestGeneratingProjectWithInvalidPromptedNameFails(t *testing.T) {
skipIfShort(t)
tempdir, _ := ioutil.TempDir("", "test-env")
defer os.RemoveAll(tempdir)
assert.NoError(t, os.Chdir(tempdir))
backendInstance = &backend.MockBackend{
DoesProjectExistF: func(ctx context.Context, name string) (bool, error) {
return true, nil
},
}
// Generate-only command is not creating any stacks, so don't bother with with the name uniqueness check.
var args = newArgs{
generateOnly: true,
interactive: true,
prompt: promptMock("not#valid", ""),
secretsProvider: "default",
templateNameOrURL: "typescript",
}
err := runNew(args)
assert.Error(t, err)
assert.Contains(t, err.Error(), "project name may only contain")
}
func TestInvalidTemplateName(t *testing.T) {
skipIfShort(t)
t.Run("NoTemplateSpecified", func(t *testing.T) {
t.Parallel()
tempdir, _ := ioutil.TempDir("", "test-env")
defer os.RemoveAll(tempdir)
assert.NoError(t, os.Chdir(tempdir))
var args = newArgs{
secretsProvider: "default",
templateNameOrURL: "",
}
err := runNew(args)
assert.Error(t, err)
assert.Contains(t, err.Error(), "no template selected")
})
t.Run("RemoteTemplateNotFound", func(t *testing.T) {
t.Parallel()
tempdir, _ := ioutil.TempDir("", "test-env")
defer os.RemoveAll(tempdir)
assert.NoError(t, os.Chdir(tempdir))
// A template that will never exist.
template := "this-is-not-the-template-youre-looking-for"
var args = newArgs{
secretsProvider: "default",
templateNameOrURL: template,
}
err := runNew(args)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
})
t.Run("LocalTemplateNotFound", func(t *testing.T) {
t.Parallel()
tempdir, _ := ioutil.TempDir("", "test-env")
defer os.RemoveAll(tempdir)
assert.NoError(t, os.Chdir(tempdir))
// A template that will never exist remotely.
template := "this-is-not-the-template-youre-looking-for"
var args = newArgs{
generateOnly: true,
offline: true,
secretsProvider: "default",
templateNameOrURL: template,
yes: true,
}
err := runNew(args)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
})
}
func TestParseConfigSuccess(t *testing.T) {
tests := []struct {
Array []string
Path bool
Expected config.Map
}{
{
Array: []string{},
Expected: config.Map{},
},
{
Array: []string{"my:testKey"},
Expected: config.Map{
config.MustMakeKey("my", "testKey"): config.NewValue(""),
},
},
{
Array: []string{"my:testKey="},
Expected: config.Map{
config.MustMakeKey("my", "testKey"): config.NewValue(""),
},
},
{
Array: []string{"my:testKey=testValue"},
Expected: config.Map{
config.MustMakeKey("my", "testKey"): config.NewValue("testValue"),
},
},
{
Array: []string{"my:testKey=test=Value"},
Expected: config.Map{
config.MustMakeKey("my", "testKey"): config.NewValue("test=Value"),
},
},
{
Array: []string{
"my:testKey=testValue",
"my:testKey=rewritten",
},
Expected: config.Map{
config.MustMakeKey("my", "testKey"): config.NewValue("rewritten"),
},
},
{
Array: []string{
"my:testKey=testValue",
},
Expected: config.Map{
config.MustMakeKey("my", "testKey"): config.NewValue("testValue"),
},
},
{
Array: []string{
"my:test.Key=testValue",
},
Expected: config.Map{
config.MustMakeKey("my", "test.Key"): config.NewValue("testValue"),
},
},
{
Array: []string{
"my:testKey=testValue",
},
Path: true,
Expected: config.Map{
config.MustMakeKey("my", "testKey"): config.NewValue("testValue"),
},
},
{
Array: []string{
"my:0=testValue",
},
Path: true,
Expected: config.Map{
config.MustMakeKey("my", "0"): config.NewValue("testValue"),
},
},
{
Array: []string{
"my:true=testValue",
},
Path: true,
Expected: config.Map{
config.MustMakeKey("my", "true"): config.NewValue("testValue"),
},
},
{
Array: []string{
`my:["test.Key"]=testValue`,
},
Path: true,
Expected: config.Map{
config.MustMakeKey("my", "test.Key"): config.NewValue("testValue"),
},
},
{
Array: []string{
`my:outer.inner=value`,
},
Path: true,
Expected: config.Map{
config.MustMakeKey("my", "outer"): config.NewObjectValue(`{"inner":"value"}`),
},
},
{
Array: []string{
`my:outer.inner.nested=value`,
},
Path: true,
Expected: config.Map{
config.MustMakeKey("my", "outer"): config.NewObjectValue(`{"inner":{"nested":"value"}}`),
},
},
{
Array: []string{
`my:name[0]=value`,
},
Path: true,
Expected: config.Map{
config.MustMakeKey("my", "name"): config.NewObjectValue(`["value"]`),
},
},
{
Array: []string{
`my:name[0][0]=value`,
},
Path: true,
Expected: config.Map{
config.MustMakeKey("my", "name"): config.NewObjectValue(`[["value"]]`),
},
},
{
Array: []string{
`my:servers[0].name=foo`,
},
Path: true,
Expected: config.Map{
config.MustMakeKey("my", "servers"): config.NewObjectValue(`[{"name":"foo"}]`),
},
},
{
Array: []string{
`my:testKey=false`,
},
Expected: config.Map{
config.MustMakeKey("my", "testKey"): config.NewValue("false"),
},
},
{
Array: []string{
`my:testKey=true`,
},
Expected: config.Map{
config.MustMakeKey("my", "testKey"): config.NewValue("true"),
},
},
{
Array: []string{
`my:testKey=10`,
},
Expected: config.Map{
config.MustMakeKey("my", "testKey"): config.NewValue("10"),
},
},
{
Array: []string{
`my:testKey=-1`,
},
Expected: config.Map{
config.MustMakeKey("my", "testKey"): config.NewValue("-1"),
},
},
{
Array: []string{
`my:testKey[0]=false`,
},
Path: true,
Expected: config.Map{
config.MustMakeKey("my", "testKey"): config.NewObjectValue(`[false]`),
},
},
{
Array: []string{
`my:testKey[0]=true`,
},
Path: true,
Expected: config.Map{
config.MustMakeKey("my", "testKey"): config.NewObjectValue(`[true]`),
},
},
{
Array: []string{
`my:testKey[0]=10`,
},
Path: true,
Expected: config.Map{
config.MustMakeKey("my", "testKey"): config.NewObjectValue(`[10]`),
},
},
{
Array: []string{
`my:testKey[0]=-1`,
},
Path: true,
Expected: config.Map{
config.MustMakeKey("my", "testKey"): config.NewObjectValue(`[-1]`),
},
},
{
Array: []string{
`my:names[0]=a`,
`my:names[1]=b`,
`my:names[2]=c`,
},
Path: true,
Expected: config.Map{
config.MustMakeKey("my", "names"): config.NewObjectValue(`["a","b","c"]`),
},
},
{
Array: []string{
`my:names[0]=a`,
`my:names[1]=b`,
`my:names[2]=c`,
`my:names[0]=rewritten`,
},
Path: true,
Expected: config.Map{
config.MustMakeKey("my", "names"): config.NewObjectValue(`["rewritten","b","c"]`),
},
},
}
for _, test := range tests {
t.Run(fmt.Sprintf("%v", test), func(t *testing.T) {
actual, err := parseConfig(test.Array, test.Path)
assert.NoError(t, err)
assert.Equal(t, test.Expected, actual)
})
}
}
func TestSetFail(t *testing.T) {
tests := []struct {
Array []string
Expected config.Map
}{
{
Array: []string{`my:[""]=value`},
},
{
Array: []string{"my:[0]=value"},
},
{
Array: []string{`my:name[-1]=value`},
},
{
Array: []string{`my:name[1]=value`},
},
{
Array: []string{`my:key.secure=value`},
},
}
for _, test := range tests {
t.Run(fmt.Sprintf("%v", test), func(t *testing.T) {
_, err := parseConfig(test.Array, true /*path*/)
assert.Error(t, err)
})
}
}
const projectName = "test_project"
const stackName = "test_stack"
func promptMock(name string, stackName string) promptForValueFunc {
return func(yes bool, valueType string, defaultValue string, secret bool,
isValidFn func(value string) error, opts display.Options) (string, error) {
if valueType == "project name" {
err := isValidFn(name)
return name, err
}
if valueType == "stack name" {
err := isValidFn(stackName)
return stackName, err
}
return defaultValue, nil
}
}
func loadProject(t *testing.T, dir string) *workspace.Project {
path, err := workspace.DetectProjectPathFrom(dir)
assert.NoError(t, err)
proj, err := workspace.LoadProject(path)
assert.NoError(t, err)
return proj
}
func currentUser(t *testing.T) string {
b, err := currentBackend(display.Options{})
assert.NoError(t, err)
currentUser, err := b.CurrentUser()
assert.NoError(t, err)
return currentUser
}
func loadStackName(t *testing.T) string {
w, err := workspace.New()
assert.NoError(t, err)
return w.Settings().Stack
}
func removeStack(t *testing.T, name string) {
b, err := currentBackend(display.Options{})
assert.NoError(t, err)
ref, err := b.ParseStackReference(name)
assert.NoError(t, err)
stack, err := b.GetStack(context.Background(), ref)
assert.NoError(t, err)
_, err = b.RemoveStack(context.Background(), stack, false)
assert.NoError(t, err)
}
func skipIfShort(t *testing.T) {
if testing.Short() {
t.Skip("Skipped in short test run")
}
}