Merge pull request #18596 from hashicorp/svh/f-remote-backend
backend/remote: add a new backend for storing state and running operations remotely
This commit is contained in:
commit
f4da82a023
|
@ -39,59 +39,14 @@ type Backend struct {
|
|||
|
||||
// schema is the schema for configuration, set by init
|
||||
schema *schema.Backend
|
||||
once sync.Once
|
||||
|
||||
// opLock locks operations
|
||||
opLock sync.Mutex
|
||||
}
|
||||
|
||||
func (b *Backend) Input(
|
||||
ui terraform.UIInput, c *terraform.ResourceConfig) (*terraform.ResourceConfig, error) {
|
||||
b.once.Do(b.init)
|
||||
return b.schema.Input(ui, c)
|
||||
}
|
||||
|
||||
func (b *Backend) Validate(c *terraform.ResourceConfig) ([]string, []error) {
|
||||
b.once.Do(b.init)
|
||||
return b.schema.Validate(c)
|
||||
}
|
||||
|
||||
func (b *Backend) Configure(c *terraform.ResourceConfig) error {
|
||||
b.once.Do(b.init)
|
||||
return b.schema.Configure(c)
|
||||
}
|
||||
|
||||
func (b *Backend) States() ([]string, error) {
|
||||
return nil, backend.ErrNamedStatesNotSupported
|
||||
}
|
||||
|
||||
func (b *Backend) DeleteState(name string) error {
|
||||
return backend.ErrNamedStatesNotSupported
|
||||
}
|
||||
|
||||
func (b *Backend) State(name string) (state.State, error) {
|
||||
if name != backend.DefaultStateName {
|
||||
return nil, backend.ErrNamedStatesNotSupported
|
||||
}
|
||||
|
||||
return &remote.State{Client: b.stateClient}, nil
|
||||
}
|
||||
|
||||
// Colorize returns the Colorize structure that can be used for colorizing
|
||||
// output. This is gauranteed to always return a non-nil value and so is useful
|
||||
// as a helper to wrap any potentially colored strings.
|
||||
func (b *Backend) Colorize() *colorstring.Colorize {
|
||||
if b.CLIColor != nil {
|
||||
return b.CLIColor
|
||||
}
|
||||
|
||||
return &colorstring.Colorize{
|
||||
Colors: colorstring.DefaultColors,
|
||||
Disable: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Backend) init() {
|
||||
// New returns a new initialized Atlas backend.
|
||||
func New() *Backend {
|
||||
b := &Backend{}
|
||||
b.schema = &schema.Backend{
|
||||
Schema: map[string]*schema.Schema{
|
||||
"name": &schema.Schema{
|
||||
|
@ -115,11 +70,13 @@ func (b *Backend) init() {
|
|||
},
|
||||
},
|
||||
|
||||
ConfigureFunc: b.schemaConfigure,
|
||||
ConfigureFunc: b.configure,
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *Backend) schemaConfigure(ctx context.Context) error {
|
||||
func (b *Backend) configure(ctx context.Context) error {
|
||||
d := schema.FromContextBackendConfig(ctx)
|
||||
|
||||
// Parse the address
|
||||
|
@ -153,6 +110,47 @@ func (b *Backend) schemaConfigure(ctx context.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) Input(ui terraform.UIInput, c *terraform.ResourceConfig) (*terraform.ResourceConfig, error) {
|
||||
return b.schema.Input(ui, c)
|
||||
}
|
||||
|
||||
func (b *Backend) Validate(c *terraform.ResourceConfig) ([]string, []error) {
|
||||
return b.schema.Validate(c)
|
||||
}
|
||||
|
||||
func (b *Backend) Configure(c *terraform.ResourceConfig) error {
|
||||
return b.schema.Configure(c)
|
||||
}
|
||||
|
||||
func (b *Backend) State(name string) (state.State, error) {
|
||||
if name != backend.DefaultStateName {
|
||||
return nil, backend.ErrNamedStatesNotSupported
|
||||
}
|
||||
return &remote.State{Client: b.stateClient}, nil
|
||||
}
|
||||
|
||||
func (b *Backend) DeleteState(name string) error {
|
||||
return backend.ErrNamedStatesNotSupported
|
||||
}
|
||||
|
||||
func (b *Backend) States() ([]string, error) {
|
||||
return nil, backend.ErrNamedStatesNotSupported
|
||||
}
|
||||
|
||||
// Colorize returns the Colorize structure that can be used for colorizing
|
||||
// output. This is gauranteed to always return a non-nil value and so is useful
|
||||
// as a helper to wrap any potentially colored strings.
|
||||
func (b *Backend) Colorize() *colorstring.Colorize {
|
||||
if b.CLIColor != nil {
|
||||
return b.CLIColor
|
||||
}
|
||||
|
||||
return &colorstring.Colorize{
|
||||
Colors: colorstring.DefaultColors,
|
||||
Disable: true,
|
||||
}
|
||||
}
|
||||
|
||||
var schemaDescriptions = map[string]string{
|
||||
"name": "Full name of the environment in Atlas, such as 'hashicorp/myenv'",
|
||||
"access_token": "Access token to use to access Atlas. If ATLAS_TOKEN is set then\n" +
|
||||
|
|
|
@ -18,7 +18,7 @@ func TestConfigure_envAddr(t *testing.T) {
|
|||
defer os.Setenv("ATLAS_ADDRESS", os.Getenv("ATLAS_ADDRESS"))
|
||||
os.Setenv("ATLAS_ADDRESS", "http://foo.com")
|
||||
|
||||
b := &Backend{}
|
||||
b := New()
|
||||
err := b.Configure(terraform.NewResourceConfig(config.TestRawConfig(t, map[string]interface{}{
|
||||
"name": "foo/bar",
|
||||
})))
|
||||
|
@ -35,7 +35,7 @@ func TestConfigure_envToken(t *testing.T) {
|
|||
defer os.Setenv("ATLAS_TOKEN", os.Getenv("ATLAS_TOKEN"))
|
||||
os.Setenv("ATLAS_TOKEN", "foo")
|
||||
|
||||
b := &Backend{}
|
||||
b := New()
|
||||
err := b.Configure(terraform.NewResourceConfig(config.TestRawConfig(t, map[string]interface{}{
|
||||
"name": "foo/bar",
|
||||
})))
|
||||
|
|
|
@ -20,7 +20,7 @@ import (
|
|||
)
|
||||
|
||||
func testStateClient(t *testing.T, c map[string]interface{}) remote.Client {
|
||||
b := backend.TestBackendConfig(t, &Backend{}, c)
|
||||
b := backend.TestBackendConfig(t, New(), c)
|
||||
raw, err := b.State(backend.DefaultStateName)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
|
|
|
@ -15,14 +15,29 @@ import (
|
|||
"github.com/hashicorp/terraform/terraform"
|
||||
)
|
||||
|
||||
// This is the name of the default, initial state that every backend
|
||||
// must have. This state cannot be deleted.
|
||||
// DefaultStateName is the name of the default, initial state that every
|
||||
// backend must have. This state cannot be deleted.
|
||||
const DefaultStateName = "default"
|
||||
|
||||
// Error value to return when a named state operation isn't supported.
|
||||
// This must be returned rather than a custom error so that the Terraform
|
||||
// CLI can detect it and handle it appropriately.
|
||||
var ErrNamedStatesNotSupported = errors.New("named states not supported")
|
||||
var (
|
||||
// ErrNamedStatesNotSupported is returned when a named state operation
|
||||
// isn't supported.
|
||||
ErrNamedStatesNotSupported = errors.New("named states not supported")
|
||||
|
||||
// ErrDefaultStateNotSupported is returned when an operation does not support
|
||||
// using the default state, but requires a named state to be selected.
|
||||
ErrDefaultStateNotSupported = errors.New("default state not supported\n\n" +
|
||||
"You can create a new workspace wth the \"workspace new\" command")
|
||||
|
||||
// ErrOperationNotSupported is returned when an unsupported operation
|
||||
// is detected by the configured backend.
|
||||
ErrOperationNotSupported = errors.New("operation not supported")
|
||||
)
|
||||
|
||||
// InitFn is used to initialize a new backend.
|
||||
type InitFn func() Backend
|
||||
|
||||
// Backend is the minimal interface that must be implemented to enable Terraform.
|
||||
type Backend interface {
|
||||
|
|
|
@ -22,7 +22,7 @@ import (
|
|||
type CLI interface {
|
||||
Backend
|
||||
|
||||
// CLIIinit is called once with options. The options passed to this
|
||||
// CLIInit is called once with options. The options passed to this
|
||||
// function may not be modified after calling this since they can be
|
||||
// read/written at any time by the Backend implementation.
|
||||
//
|
||||
|
|
|
@ -3,19 +3,22 @@
|
|||
package init
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/hashicorp/terraform/backend"
|
||||
"github.com/hashicorp/terraform/svchost/disco"
|
||||
"github.com/hashicorp/terraform/terraform"
|
||||
|
||||
backendatlas "github.com/hashicorp/terraform/backend/atlas"
|
||||
backendlegacy "github.com/hashicorp/terraform/backend/legacy"
|
||||
backendlocal "github.com/hashicorp/terraform/backend/local"
|
||||
backendAtlas "github.com/hashicorp/terraform/backend/atlas"
|
||||
backendLegacy "github.com/hashicorp/terraform/backend/legacy"
|
||||
backendLocal "github.com/hashicorp/terraform/backend/local"
|
||||
backendRemote "github.com/hashicorp/terraform/backend/remote"
|
||||
backendAzure "github.com/hashicorp/terraform/backend/remote-state/azure"
|
||||
backendconsul "github.com/hashicorp/terraform/backend/remote-state/consul"
|
||||
backendetcdv3 "github.com/hashicorp/terraform/backend/remote-state/etcdv3"
|
||||
backendConsul "github.com/hashicorp/terraform/backend/remote-state/consul"
|
||||
backendEtcdv3 "github.com/hashicorp/terraform/backend/remote-state/etcdv3"
|
||||
backendGCS "github.com/hashicorp/terraform/backend/remote-state/gcs"
|
||||
backendinmem "github.com/hashicorp/terraform/backend/remote-state/inmem"
|
||||
backendInmem "github.com/hashicorp/terraform/backend/remote-state/inmem"
|
||||
backendManta "github.com/hashicorp/terraform/backend/remote-state/manta"
|
||||
backendS3 "github.com/hashicorp/terraform/backend/remote-state/s3"
|
||||
backendSwift "github.com/hashicorp/terraform/backend/remote-state/swift"
|
||||
|
@ -32,35 +35,49 @@ import (
|
|||
// complex structures and supporting that over the plugin system is currently
|
||||
// prohibitively difficult. For those wanting to implement a custom backend,
|
||||
// they can do so with recompilation.
|
||||
var backends map[string]func() backend.Backend
|
||||
var backends map[string]backend.InitFn
|
||||
var backendsLock sync.Mutex
|
||||
|
||||
func init() {
|
||||
// Our hardcoded backends. We don't need to acquire a lock here
|
||||
// since init() code is serial and can't spawn goroutines.
|
||||
backends = map[string]func() backend.Backend{
|
||||
"atlas": func() backend.Backend { return &backendatlas.Backend{} },
|
||||
"local": func() backend.Backend { return &backendlocal.Local{} },
|
||||
"consul": func() backend.Backend { return backendconsul.New() },
|
||||
"inmem": func() backend.Backend { return backendinmem.New() },
|
||||
"swift": func() backend.Backend { return backendSwift.New() },
|
||||
// Init initializes the backends map with all our hardcoded backends.
|
||||
func Init(services *disco.Disco) {
|
||||
backendsLock.Lock()
|
||||
defer backendsLock.Unlock()
|
||||
|
||||
backends = map[string]backend.InitFn{
|
||||
// Enhanced backends.
|
||||
"local": func() backend.Backend { return backendLocal.New() },
|
||||
"remote": func() backend.Backend {
|
||||
b := backendRemote.New(services)
|
||||
if os.Getenv("TF_FORCE_LOCAL_BACKEND") != "" {
|
||||
return backendLocal.NewWithBackend(b)
|
||||
}
|
||||
return b
|
||||
},
|
||||
|
||||
// Remote State backends.
|
||||
"atlas": func() backend.Backend { return backendAtlas.New() },
|
||||
"azurerm": func() backend.Backend { return backendAzure.New() },
|
||||
"consul": func() backend.Backend { return backendConsul.New() },
|
||||
"etcdv3": func() backend.Backend { return backendEtcdv3.New() },
|
||||
"gcs": func() backend.Backend { return backendGCS.New() },
|
||||
"inmem": func() backend.Backend { return backendInmem.New() },
|
||||
"manta": func() backend.Backend { return backendManta.New() },
|
||||
"s3": func() backend.Backend { return backendS3.New() },
|
||||
"swift": func() backend.Backend { return backendSwift.New() },
|
||||
|
||||
// Deprecated backends.
|
||||
"azure": deprecateBackend(backendAzure.New(),
|
||||
`Warning: "azure" name is deprecated, please use "azurerm"`),
|
||||
"azurerm": func() backend.Backend { return backendAzure.New() },
|
||||
"etcdv3": func() backend.Backend { return backendetcdv3.New() },
|
||||
"gcs": func() backend.Backend { return backendGCS.New() },
|
||||
"manta": func() backend.Backend { return backendManta.New() },
|
||||
}
|
||||
|
||||
// Add the legacy remote backends that haven't yet been convertd to
|
||||
// Add the legacy remote backends that haven't yet been converted to
|
||||
// the new backend API.
|
||||
backendlegacy.Init(backends)
|
||||
backendLegacy.Init(backends)
|
||||
}
|
||||
|
||||
// Backend returns the initialization factory for the given backend, or
|
||||
// nil if none exists.
|
||||
func Backend(name string) func() backend.Backend {
|
||||
func Backend(name string) backend.InitFn {
|
||||
backendsLock.Lock()
|
||||
defer backendsLock.Unlock()
|
||||
return backends[name]
|
||||
|
@ -73,7 +90,7 @@ func Backend(name string) func() backend.Backend {
|
|||
// This method sets this backend globally and care should be taken to do
|
||||
// this only before Terraform is executing to prevent odd behavior of backends
|
||||
// changing mid-execution.
|
||||
func Set(name string, f func() backend.Backend) {
|
||||
func Set(name string, f backend.InitFn) {
|
||||
backendsLock.Lock()
|
||||
defer backendsLock.Unlock()
|
||||
|
||||
|
@ -101,7 +118,7 @@ func (b deprecatedBackendShim) Validate(c *terraform.ResourceConfig) ([]string,
|
|||
|
||||
// DeprecateBackend can be used to wrap a backend to retrun a deprecation
|
||||
// warning during validation.
|
||||
func deprecateBackend(b backend.Backend, message string) func() backend.Backend {
|
||||
func deprecateBackend(b backend.Backend, message string) backend.InitFn {
|
||||
// Since a Backend wrapped by deprecatedBackendShim can no longer be
|
||||
// asserted as an Enhanced or Local backend, disallow those types here
|
||||
// entirely. If something other than a basic backend.Backend needs to be
|
||||
|
|
|
@ -0,0 +1,110 @@
|
|||
package init
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
backendLocal "github.com/hashicorp/terraform/backend/local"
|
||||
)
|
||||
|
||||
func TestInit_backend(t *testing.T) {
|
||||
// Initialize the backends map
|
||||
Init(nil)
|
||||
|
||||
backends := []struct {
|
||||
Name string
|
||||
Type string
|
||||
}{
|
||||
{
|
||||
"local",
|
||||
"*local.Local",
|
||||
}, {
|
||||
"remote",
|
||||
"*remote.Remote",
|
||||
}, {
|
||||
"atlas",
|
||||
"*atlas.Backend",
|
||||
}, {
|
||||
"azurerm",
|
||||
"*azure.Backend",
|
||||
}, {
|
||||
"consul",
|
||||
"*consul.Backend",
|
||||
}, {
|
||||
"etcdv3",
|
||||
"*etcd.Backend",
|
||||
}, {
|
||||
"gcs",
|
||||
"*gcs.Backend",
|
||||
}, {
|
||||
"inmem",
|
||||
"*inmem.Backend",
|
||||
}, {
|
||||
"manta",
|
||||
"*manta.Backend",
|
||||
}, {
|
||||
"s3",
|
||||
"*s3.Backend",
|
||||
}, {
|
||||
"swift",
|
||||
"*swift.Backend",
|
||||
}, {
|
||||
"azure",
|
||||
"init.deprecatedBackendShim",
|
||||
},
|
||||
}
|
||||
|
||||
// Make sure we get the requested backend
|
||||
for _, b := range backends {
|
||||
f := Backend(b.Name)
|
||||
bType := reflect.TypeOf(f()).String()
|
||||
|
||||
if bType != b.Type {
|
||||
t.Fatalf("expected backend %q to be %q, got: %q", b.Name, b.Type, bType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_forceLocalBackend(t *testing.T) {
|
||||
// Initialize the backends map
|
||||
Init(nil)
|
||||
|
||||
enhancedBackends := []struct {
|
||||
Name string
|
||||
Type string
|
||||
}{
|
||||
{
|
||||
"local",
|
||||
"nil",
|
||||
}, {
|
||||
"remote",
|
||||
"*remote.Remote",
|
||||
},
|
||||
}
|
||||
|
||||
// Set the TF_FORCE_LOCAL_BACKEND flag so all enhanced backends will
|
||||
// return a local.Local backend with themselves as embedded backend.
|
||||
if err := os.Setenv("TF_FORCE_LOCAL_BACKEND", "1"); err != nil {
|
||||
t.Fatalf("error setting environment variable TF_FORCE_LOCAL_BACKEND: %v", err)
|
||||
}
|
||||
|
||||
// Make sure we always get the local backend.
|
||||
for _, b := range enhancedBackends {
|
||||
f := Backend(b.Name)
|
||||
|
||||
local, ok := f().(*backendLocal.Local)
|
||||
if !ok {
|
||||
t.Fatalf("expected backend %q to be \"*local.Local\", got: %T", b.Name, f())
|
||||
}
|
||||
|
||||
bType := "nil"
|
||||
if local.Backend != nil {
|
||||
bType = reflect.TypeOf(local.Backend).String()
|
||||
}
|
||||
|
||||
if bType != b.Type {
|
||||
t.Fatalf("expected local.Backend to be %s, got: %s", b.Type, bType)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -12,8 +12,8 @@ import (
|
|||
//
|
||||
// If a type is already in the map, it will not be added. This will allow
|
||||
// us to slowly convert the legacy types to first-class backends.
|
||||
func Init(m map[string]func() backend.Backend) {
|
||||
for k, _ := range remote.BuiltinClients {
|
||||
func Init(m map[string]backend.InitFn) {
|
||||
for k := range remote.BuiltinClients {
|
||||
if _, ok := m[k]; !ok {
|
||||
// Copy the "k" value since the variable "k" is reused for
|
||||
// each key (address doesn't change).
|
||||
|
|
|
@ -8,7 +8,7 @@ import (
|
|||
)
|
||||
|
||||
func TestInit(t *testing.T) {
|
||||
m := make(map[string]func() backend.Backend)
|
||||
m := make(map[string]backend.InitFn)
|
||||
Init(m)
|
||||
|
||||
for k, _ := range remote.BuiltinClients {
|
||||
|
@ -24,7 +24,7 @@ func TestInit(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestInit_ignoreExisting(t *testing.T) {
|
||||
m := make(map[string]func() backend.Backend)
|
||||
m := make(map[string]backend.InitFn)
|
||||
m["local"] = nil
|
||||
Init(m)
|
||||
|
||||
|
|
|
@ -91,94 +91,106 @@ type Local struct {
|
|||
|
||||
schema *schema.Backend
|
||||
opLock sync.Mutex
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (b *Local) Input(
|
||||
ui terraform.UIInput, c *terraform.ResourceConfig) (*terraform.ResourceConfig, error) {
|
||||
b.once.Do(b.init)
|
||||
// New returns a new initialized local backend.
|
||||
func New() *Local {
|
||||
return NewWithBackend(nil)
|
||||
}
|
||||
|
||||
// NewWithBackend returns a new local backend initialized with a
|
||||
// dedicated backend for non-enhanced behavior.
|
||||
func NewWithBackend(backend backend.Backend) *Local {
|
||||
b := &Local{
|
||||
Backend: backend,
|
||||
}
|
||||
|
||||
b.schema = &schema.Backend{
|
||||
Schema: map[string]*schema.Schema{
|
||||
"path": &schema.Schema{
|
||||
Type: schema.TypeString,
|
||||
Optional: true,
|
||||
Default: "",
|
||||
},
|
||||
|
||||
"workspace_dir": &schema.Schema{
|
||||
Type: schema.TypeString,
|
||||
Optional: true,
|
||||
Default: "",
|
||||
},
|
||||
|
||||
"environment_dir": &schema.Schema{
|
||||
Type: schema.TypeString,
|
||||
Optional: true,
|
||||
Default: "",
|
||||
ConflictsWith: []string{"workspace_dir"},
|
||||
Deprecated: "workspace_dir should be used instead, with the same meaning",
|
||||
},
|
||||
},
|
||||
|
||||
ConfigureFunc: b.configure,
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *Local) configure(ctx context.Context) error {
|
||||
d := schema.FromContextBackendConfig(ctx)
|
||||
|
||||
// Set the path if it is set
|
||||
pathRaw, ok := d.GetOk("path")
|
||||
if ok {
|
||||
path := pathRaw.(string)
|
||||
if path == "" {
|
||||
return fmt.Errorf("configured path is empty")
|
||||
}
|
||||
|
||||
b.StatePath = path
|
||||
b.StateOutPath = path
|
||||
}
|
||||
|
||||
if raw, ok := d.GetOk("workspace_dir"); ok {
|
||||
path := raw.(string)
|
||||
if path != "" {
|
||||
b.StateWorkspaceDir = path
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy name, which ConflictsWith workspace_dir
|
||||
if raw, ok := d.GetOk("environment_dir"); ok {
|
||||
path := raw.(string)
|
||||
if path != "" {
|
||||
b.StateWorkspaceDir = path
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Local) Input(ui terraform.UIInput, c *terraform.ResourceConfig) (*terraform.ResourceConfig, error) {
|
||||
f := b.schema.Input
|
||||
if b.Backend != nil {
|
||||
f = b.Backend.Input
|
||||
}
|
||||
|
||||
return f(ui, c)
|
||||
}
|
||||
|
||||
func (b *Local) Validate(c *terraform.ResourceConfig) ([]string, []error) {
|
||||
b.once.Do(b.init)
|
||||
|
||||
f := b.schema.Validate
|
||||
if b.Backend != nil {
|
||||
f = b.Backend.Validate
|
||||
}
|
||||
|
||||
return f(c)
|
||||
}
|
||||
|
||||
func (b *Local) Configure(c *terraform.ResourceConfig) error {
|
||||
b.once.Do(b.init)
|
||||
|
||||
f := b.schema.Configure
|
||||
if b.Backend != nil {
|
||||
f = b.Backend.Configure
|
||||
}
|
||||
|
||||
return f(c)
|
||||
}
|
||||
|
||||
func (b *Local) States() ([]string, error) {
|
||||
// If we have a backend handling state, defer to that.
|
||||
if b.Backend != nil {
|
||||
return b.Backend.States()
|
||||
}
|
||||
|
||||
// the listing always start with "default"
|
||||
envs := []string{backend.DefaultStateName}
|
||||
|
||||
entries, err := ioutil.ReadDir(b.stateWorkspaceDir())
|
||||
// no error if there's no envs configured
|
||||
if os.IsNotExist(err) {
|
||||
return envs, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var listed []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
listed = append(listed, filepath.Base(entry.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(listed)
|
||||
envs = append(envs, listed...)
|
||||
|
||||
return envs, nil
|
||||
}
|
||||
|
||||
// DeleteState removes a named state.
|
||||
// The "default" state cannot be removed.
|
||||
func (b *Local) DeleteState(name string) error {
|
||||
// If we have a backend handling state, defer to that.
|
||||
if b.Backend != nil {
|
||||
return b.Backend.DeleteState(name)
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
return errors.New("empty state name")
|
||||
}
|
||||
|
||||
if name == backend.DefaultStateName {
|
||||
return errors.New("cannot delete default state")
|
||||
}
|
||||
|
||||
delete(b.states, name)
|
||||
return os.RemoveAll(filepath.Join(b.stateWorkspaceDir(), name))
|
||||
}
|
||||
|
||||
func (b *Local) State(name string) (state.State, error) {
|
||||
statePath, stateOutPath, backupPath := b.StatePaths(name)
|
||||
|
||||
|
@ -216,6 +228,57 @@ func (b *Local) State(name string) (state.State, error) {
|
|||
return s, nil
|
||||
}
|
||||
|
||||
// DeleteState removes a named state.
|
||||
// The "default" state cannot be removed.
|
||||
func (b *Local) DeleteState(name string) error {
|
||||
// If we have a backend handling state, defer to that.
|
||||
if b.Backend != nil {
|
||||
return b.Backend.DeleteState(name)
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
return errors.New("empty state name")
|
||||
}
|
||||
|
||||
if name == backend.DefaultStateName {
|
||||
return errors.New("cannot delete default state")
|
||||
}
|
||||
|
||||
delete(b.states, name)
|
||||
return os.RemoveAll(filepath.Join(b.stateWorkspaceDir(), name))
|
||||
}
|
||||
|
||||
func (b *Local) States() ([]string, error) {
|
||||
// If we have a backend handling state, defer to that.
|
||||
if b.Backend != nil {
|
||||
return b.Backend.States()
|
||||
}
|
||||
|
||||
// the listing always start with "default"
|
||||
envs := []string{backend.DefaultStateName}
|
||||
|
||||
entries, err := ioutil.ReadDir(b.stateWorkspaceDir())
|
||||
// no error if there's no envs configured
|
||||
if os.IsNotExist(err) {
|
||||
return envs, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var listed []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
listed = append(listed, filepath.Base(entry.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(listed)
|
||||
envs = append(envs, listed...)
|
||||
|
||||
return envs, nil
|
||||
}
|
||||
|
||||
// Operation implements backend.Enhanced
|
||||
//
|
||||
// This will initialize an in-memory terraform.Context to perform the
|
||||
|
@ -348,68 +411,6 @@ func (b *Local) Colorize() *colorstring.Colorize {
|
|||
}
|
||||
}
|
||||
|
||||
func (b *Local) init() {
|
||||
b.schema = &schema.Backend{
|
||||
Schema: map[string]*schema.Schema{
|
||||
"path": &schema.Schema{
|
||||
Type: schema.TypeString,
|
||||
Optional: true,
|
||||
Default: "",
|
||||
},
|
||||
|
||||
"workspace_dir": &schema.Schema{
|
||||
Type: schema.TypeString,
|
||||
Optional: true,
|
||||
Default: "",
|
||||
},
|
||||
|
||||
"environment_dir": &schema.Schema{
|
||||
Type: schema.TypeString,
|
||||
Optional: true,
|
||||
Default: "",
|
||||
ConflictsWith: []string{"workspace_dir"},
|
||||
|
||||
Deprecated: "workspace_dir should be used instead, with the same meaning",
|
||||
},
|
||||
},
|
||||
|
||||
ConfigureFunc: b.schemaConfigure,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Local) schemaConfigure(ctx context.Context) error {
|
||||
d := schema.FromContextBackendConfig(ctx)
|
||||
|
||||
// Set the path if it is set
|
||||
pathRaw, ok := d.GetOk("path")
|
||||
if ok {
|
||||
path := pathRaw.(string)
|
||||
if path == "" {
|
||||
return fmt.Errorf("configured path is empty")
|
||||
}
|
||||
|
||||
b.StatePath = path
|
||||
b.StateOutPath = path
|
||||
}
|
||||
|
||||
if raw, ok := d.GetOk("workspace_dir"); ok {
|
||||
path := raw.(string)
|
||||
if path != "" {
|
||||
b.StateWorkspaceDir = path
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy name, which ConflictsWith workspace_dir
|
||||
if raw, ok := d.GetOk("environment_dir"); ok {
|
||||
path := raw.(string)
|
||||
if path != "" {
|
||||
b.StateWorkspaceDir = path
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StatePaths returns the StatePath, StateOutPath, and StateBackupPath as
|
||||
// configured from the CLI.
|
||||
func (b *Local) StatePaths(name string) (string, string, string) {
|
||||
|
|
|
@ -5,15 +5,13 @@ import (
|
|||
"errors"
|
||||
"log"
|
||||
|
||||
"github.com/hashicorp/terraform/command/clistate"
|
||||
"github.com/hashicorp/terraform/command/format"
|
||||
|
||||
"github.com/hashicorp/terraform/tfdiags"
|
||||
|
||||
"github.com/hashicorp/errwrap"
|
||||
"github.com/hashicorp/terraform/backend"
|
||||
"github.com/hashicorp/terraform/command/clistate"
|
||||
"github.com/hashicorp/terraform/command/format"
|
||||
"github.com/hashicorp/terraform/state"
|
||||
"github.com/hashicorp/terraform/terraform"
|
||||
"github.com/hashicorp/terraform/tfdiags"
|
||||
)
|
||||
|
||||
// backend.Local implementation.
|
||||
|
|
|
@ -15,14 +15,14 @@ import (
|
|||
)
|
||||
|
||||
func TestLocal_impl(t *testing.T) {
|
||||
var _ backend.Enhanced = new(Local)
|
||||
var _ backend.Local = new(Local)
|
||||
var _ backend.CLI = new(Local)
|
||||
var _ backend.Enhanced = New()
|
||||
var _ backend.Local = New()
|
||||
var _ backend.CLI = New()
|
||||
}
|
||||
|
||||
func TestLocal_backend(t *testing.T) {
|
||||
defer testTmpDir(t)()
|
||||
b := &Local{}
|
||||
b := New()
|
||||
backend.TestBackendStates(t, b)
|
||||
backend.TestBackendStateLocks(t, b, b)
|
||||
}
|
||||
|
@ -49,7 +49,7 @@ func checkState(t *testing.T, path, expected string) {
|
|||
}
|
||||
|
||||
func TestLocal_StatePaths(t *testing.T) {
|
||||
b := &Local{}
|
||||
b := New()
|
||||
|
||||
// Test the defaults
|
||||
path, out, back := b.StatePaths("")
|
||||
|
@ -94,7 +94,7 @@ func TestLocal_addAndRemoveStates(t *testing.T) {
|
|||
dflt := backend.DefaultStateName
|
||||
expectedStates := []string{dflt}
|
||||
|
||||
b := &Local{}
|
||||
b := New()
|
||||
states, err := b.States()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
@ -210,13 +210,11 @@ func (b *testDelegateBackend) DeleteState(name string) error {
|
|||
// verify that the MultiState methods are dispatched to the correct Backend.
|
||||
func TestLocal_multiStateBackend(t *testing.T) {
|
||||
// assign a separate backend where we can read the state
|
||||
b := &Local{
|
||||
Backend: &testDelegateBackend{
|
||||
b := NewWithBackend(&testDelegateBackend{
|
||||
stateErr: true,
|
||||
statesErr: true,
|
||||
deleteErr: true,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
if _, err := b.State("test"); err != errTestDelegateState {
|
||||
t.Fatal("expected errTestDelegateState, got:", err)
|
||||
|
|
|
@ -18,13 +18,14 @@ import (
|
|||
// public fields without any locks.
|
||||
func TestLocal(t *testing.T) (*Local, func()) {
|
||||
tempDir := testTempDir(t)
|
||||
local := &Local{
|
||||
StatePath: filepath.Join(tempDir, "state.tfstate"),
|
||||
StateOutPath: filepath.Join(tempDir, "state.tfstate"),
|
||||
StateBackupPath: filepath.Join(tempDir, "state.tfstate.bak"),
|
||||
StateWorkspaceDir: filepath.Join(tempDir, "state.tfstate.d"),
|
||||
ContextOpts: &terraform.ContextOpts{},
|
||||
}
|
||||
|
||||
local := New()
|
||||
local.StatePath = filepath.Join(tempDir, "state.tfstate")
|
||||
local.StateOutPath = filepath.Join(tempDir, "state.tfstate")
|
||||
local.StateBackupPath = filepath.Join(tempDir, "state.tfstate.bak")
|
||||
local.StateWorkspaceDir = filepath.Join(tempDir, "state.tfstate.d")
|
||||
local.ContextOpts = &terraform.ContextOpts{}
|
||||
|
||||
cleanup := func() {
|
||||
if err := os.RemoveAll(tempDir); err != nil {
|
||||
t.Fatal("error clecanup up test:", err)
|
||||
|
@ -69,7 +70,7 @@ func TestLocalProvider(t *testing.T, b *Local, name string) *terraform.MockResou
|
|||
// TestNewLocalSingle is a factory for creating a TestLocalSingleState.
|
||||
// This function matches the signature required for backend/init.
|
||||
func TestNewLocalSingle() backend.Backend {
|
||||
return &TestLocalSingleState{}
|
||||
return &TestLocalSingleState{Local: New()}
|
||||
}
|
||||
|
||||
// TestLocalSingleState is a backend implementation that wraps Local
|
||||
|
@ -79,7 +80,7 @@ func TestNewLocalSingle() backend.Backend {
|
|||
// This isn't an actual use case, this is exported just to provide a
|
||||
// easy way to test that behavior.
|
||||
type TestLocalSingleState struct {
|
||||
Local
|
||||
*Local
|
||||
}
|
||||
|
||||
func (b *TestLocalSingleState) State(name string) (state.State, error) {
|
||||
|
@ -98,6 +99,50 @@ func (b *TestLocalSingleState) DeleteState(string) error {
|
|||
return backend.ErrNamedStatesNotSupported
|
||||
}
|
||||
|
||||
// TestNewLocalNoDefault is a factory for creating a TestLocalNoDefaultState.
|
||||
// This function matches the signature required for backend/init.
|
||||
func TestNewLocalNoDefault() backend.Backend {
|
||||
return &TestLocalNoDefaultState{Local: New()}
|
||||
}
|
||||
|
||||
// TestLocalNoDefaultState is a backend implementation that wraps
|
||||
// Local and modifies it to support named states, but not the
|
||||
// default state. It returns ErrDefaultStateNotSupported when the
|
||||
// DefaultStateName is used.
|
||||
type TestLocalNoDefaultState struct {
|
||||
*Local
|
||||
}
|
||||
|
||||
func (b *TestLocalNoDefaultState) State(name string) (state.State, error) {
|
||||
if name == backend.DefaultStateName {
|
||||
return nil, backend.ErrDefaultStateNotSupported
|
||||
}
|
||||
return b.Local.State(name)
|
||||
}
|
||||
|
||||
func (b *TestLocalNoDefaultState) States() ([]string, error) {
|
||||
states, err := b.Local.States()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filtered := states[:0]
|
||||
for _, name := range states {
|
||||
if name != backend.DefaultStateName {
|
||||
filtered = append(filtered, name)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func (b *TestLocalNoDefaultState) DeleteState(name string) error {
|
||||
if name == backend.DefaultStateName {
|
||||
return backend.ErrDefaultStateNotSupported
|
||||
}
|
||||
return b.Local.DeleteState(name)
|
||||
}
|
||||
|
||||
func testTempDir(t *testing.T) string {
|
||||
d, err := ioutil.TempDir("", "tf")
|
||||
if err != nil {
|
||||
|
|
|
@ -18,10 +18,10 @@ import (
|
|||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
// gcsBackend implements "backend".Backend for GCS.
|
||||
// Backend implements "backend".Backend for GCS.
|
||||
// Input(), Validate() and Configure() are implemented by embedding *schema.Backend.
|
||||
// State(), DeleteState() and States() are implemented explicitly.
|
||||
type gcsBackend struct {
|
||||
type Backend struct {
|
||||
*schema.Backend
|
||||
|
||||
storageClient *storage.Client
|
||||
|
@ -38,9 +38,9 @@ type gcsBackend struct {
|
|||
}
|
||||
|
||||
func New() backend.Backend {
|
||||
be := &gcsBackend{}
|
||||
be.Backend = &schema.Backend{
|
||||
ConfigureFunc: be.configure,
|
||||
b := &Backend{}
|
||||
b.Backend = &schema.Backend{
|
||||
ConfigureFunc: b.configure,
|
||||
Schema: map[string]*schema.Schema{
|
||||
"bucket": {
|
||||
Type: schema.TypeString,
|
||||
|
@ -91,10 +91,10 @@ func New() backend.Backend {
|
|||
},
|
||||
}
|
||||
|
||||
return be
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *gcsBackend) configure(ctx context.Context) error {
|
||||
func (b *Backend) configure(ctx context.Context) error {
|
||||
if b.storageClient != nil {
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ const (
|
|||
|
||||
// States returns a list of names for the states found on GCS. The default
|
||||
// state is always returned as the first element in the slice.
|
||||
func (b *gcsBackend) States() ([]string, error) {
|
||||
func (b *Backend) States() ([]string, error) {
|
||||
states := []string{backend.DefaultStateName}
|
||||
|
||||
bucket := b.storageClient.Bucket(b.bucketName)
|
||||
|
@ -54,7 +54,7 @@ func (b *gcsBackend) States() ([]string, error) {
|
|||
}
|
||||
|
||||
// DeleteState deletes the named state. The "default" state cannot be deleted.
|
||||
func (b *gcsBackend) DeleteState(name string) error {
|
||||
func (b *Backend) DeleteState(name string) error {
|
||||
if name == backend.DefaultStateName {
|
||||
return fmt.Errorf("cowardly refusing to delete the %q state", name)
|
||||
}
|
||||
|
@ -68,7 +68,7 @@ func (b *gcsBackend) DeleteState(name string) error {
|
|||
}
|
||||
|
||||
// client returns a remoteClient for the named state.
|
||||
func (b *gcsBackend) client(name string) (*remoteClient, error) {
|
||||
func (b *Backend) client(name string) (*remoteClient, error) {
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("%q is not a valid state name", name)
|
||||
}
|
||||
|
@ -85,7 +85,7 @@ func (b *gcsBackend) client(name string) (*remoteClient, error) {
|
|||
|
||||
// State reads and returns the named state from GCS. If the named state does
|
||||
// not yet exist, a new state file is created.
|
||||
func (b *gcsBackend) State(name string) (state.State, error) {
|
||||
func (b *Backend) State(name string) (state.State, error) {
|
||||
c, err := b.client(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
@ -144,14 +144,14 @@ func (b *gcsBackend) State(name string) (state.State, error) {
|
|||
return st, nil
|
||||
}
|
||||
|
||||
func (b *gcsBackend) stateFile(name string) string {
|
||||
func (b *Backend) stateFile(name string) string {
|
||||
if name == backend.DefaultStateName && b.defaultStateFile != "" {
|
||||
return b.defaultStateFile
|
||||
}
|
||||
return path.Join(b.prefix, name+stateFileSuffix)
|
||||
}
|
||||
|
||||
func (b *gcsBackend) lockFile(name string) string {
|
||||
func (b *Backend) lockFile(name string) string {
|
||||
if name == backend.DefaultStateName && b.defaultStateFile != "" {
|
||||
return strings.TrimSuffix(b.defaultStateFile, stateFileSuffix) + lockFileSuffix
|
||||
}
|
||||
|
|
|
@ -39,7 +39,7 @@ func TestStateFile(t *testing.T) {
|
|||
{"state", "legacy.state", "test", "state/test.tfstate", "state/test.tflock"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
b := &gcsBackend{
|
||||
b := &Backend{
|
||||
prefix: c.prefix,
|
||||
defaultStateFile: c.defaultStateFile,
|
||||
}
|
||||
|
@ -188,7 +188,7 @@ func setupBackend(t *testing.T, bucket, prefix, key string) backend.Backend {
|
|||
}
|
||||
|
||||
b := backend.TestBackendConfig(t, New(), config)
|
||||
be := b.(*gcsBackend)
|
||||
be := b.(*Backend)
|
||||
|
||||
// create the bucket if it doesn't exist
|
||||
bkt := be.storageClient.Bucket(bucket)
|
||||
|
@ -213,7 +213,7 @@ func setupBackend(t *testing.T, bucket, prefix, key string) backend.Backend {
|
|||
// teardownBackend deletes all states from be except the default state.
|
||||
func teardownBackend(t *testing.T, be backend.Backend, prefix string) {
|
||||
t.Helper()
|
||||
gcsBE, ok := be.(*gcsBackend)
|
||||
gcsBE, ok := be.(*Backend)
|
||||
if !ok {
|
||||
t.Fatalf("be is a %T, want a *gcsBackend", be)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,453 @@
|
|||
package remote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
tfe "github.com/hashicorp/go-tfe"
|
||||
"github.com/hashicorp/terraform/backend"
|
||||
"github.com/hashicorp/terraform/helper/schema"
|
||||
"github.com/hashicorp/terraform/state"
|
||||
"github.com/hashicorp/terraform/state/remote"
|
||||
"github.com/hashicorp/terraform/svchost"
|
||||
"github.com/hashicorp/terraform/svchost/disco"
|
||||
"github.com/hashicorp/terraform/terraform"
|
||||
"github.com/hashicorp/terraform/version"
|
||||
"github.com/mitchellh/cli"
|
||||
"github.com/mitchellh/colorstring"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHostname = "app.terraform.io"
|
||||
serviceID = "tfe.v2"
|
||||
)
|
||||
|
||||
// Remote is an implementation of EnhancedBackend that performs all
|
||||
// operations in a remote backend.
|
||||
type Remote struct {
|
||||
// CLI and Colorize control the CLI output. If CLI is nil then no CLI
|
||||
// output will be done. If CLIColor is nil then no coloring will be done.
|
||||
CLI cli.Ui
|
||||
CLIColor *colorstring.Colorize
|
||||
|
||||
// ContextOpts are the base context options to set when initializing a
|
||||
// new Terraform context. Many of these will be overridden or merged by
|
||||
// Operation. See Operation for more details.
|
||||
ContextOpts *terraform.ContextOpts
|
||||
|
||||
// client is the remote backend API client
|
||||
client *tfe.Client
|
||||
|
||||
// hostname of the remote backend server
|
||||
hostname string
|
||||
|
||||
// organization is the organization that contains the target workspaces
|
||||
organization string
|
||||
|
||||
// workspace is used to map the default workspace to a remote workspace
|
||||
workspace string
|
||||
|
||||
// prefix is used to filter down a set of workspaces that use a single
|
||||
// configuration
|
||||
prefix string
|
||||
|
||||
// schema defines the configuration for the backend
|
||||
schema *schema.Backend
|
||||
|
||||
// services is used for service discovery
|
||||
services *disco.Disco
|
||||
|
||||
// opLock locks operations
|
||||
opLock sync.Mutex
|
||||
}
|
||||
|
||||
// New creates a new initialized remote backend.
|
||||
func New(services *disco.Disco) *Remote {
|
||||
b := &Remote{
|
||||
services: services,
|
||||
}
|
||||
|
||||
b.schema = &schema.Backend{
|
||||
Schema: map[string]*schema.Schema{
|
||||
"hostname": &schema.Schema{
|
||||
Type: schema.TypeString,
|
||||
Optional: true,
|
||||
Description: schemaDescriptions["hostname"],
|
||||
Default: defaultHostname,
|
||||
},
|
||||
|
||||
"organization": &schema.Schema{
|
||||
Type: schema.TypeString,
|
||||
Required: true,
|
||||
Description: schemaDescriptions["organization"],
|
||||
},
|
||||
|
||||
"token": &schema.Schema{
|
||||
Type: schema.TypeString,
|
||||
Optional: true,
|
||||
Description: schemaDescriptions["token"],
|
||||
DefaultFunc: schema.EnvDefaultFunc("TFE_TOKEN", ""),
|
||||
},
|
||||
|
||||
"workspaces": &schema.Schema{
|
||||
Type: schema.TypeSet,
|
||||
Required: true,
|
||||
Description: schemaDescriptions["workspaces"],
|
||||
Elem: &schema.Resource{
|
||||
Schema: map[string]*schema.Schema{
|
||||
"name": &schema.Schema{
|
||||
Type: schema.TypeString,
|
||||
Optional: true,
|
||||
Description: schemaDescriptions["name"],
|
||||
},
|
||||
|
||||
"prefix": &schema.Schema{
|
||||
Type: schema.TypeString,
|
||||
Optional: true,
|
||||
Description: schemaDescriptions["prefix"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
ConfigureFunc: b.configure,
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *Remote) configure(ctx context.Context) error {
|
||||
d := schema.FromContextBackendConfig(ctx)
|
||||
|
||||
// Get the hostname and organization.
|
||||
b.hostname = d.Get("hostname").(string)
|
||||
b.organization = d.Get("organization").(string)
|
||||
|
||||
// Get the workspaces configuration.
|
||||
workspaces := d.Get("workspaces").(*schema.Set)
|
||||
if workspaces.Len() != 1 {
|
||||
return fmt.Errorf("only one 'workspaces' block allowed")
|
||||
}
|
||||
|
||||
// After checking that we have exactly one workspace block, we can now get
|
||||
// and assert that one workspace from the set.
|
||||
workspace := workspaces.List()[0].(map[string]interface{})
|
||||
|
||||
// Get the default workspace name and prefix.
|
||||
b.workspace = workspace["name"].(string)
|
||||
b.prefix = workspace["prefix"].(string)
|
||||
|
||||
// Make sure that we have either a workspace name or a prefix.
|
||||
if b.workspace == "" && b.prefix == "" {
|
||||
return fmt.Errorf("either workspace 'name' or 'prefix' is required")
|
||||
}
|
||||
|
||||
// Make sure that only one of workspace name or a prefix is configured.
|
||||
if b.workspace != "" && b.prefix != "" {
|
||||
return fmt.Errorf("only one of workspace 'name' or 'prefix' is allowed")
|
||||
}
|
||||
|
||||
// Discover the service URL for this host to confirm that it provides
|
||||
// a remote backend API and to discover the required base path.
|
||||
service, err := b.discover(b.hostname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Retrieve the token for this host as configured in the credentials
|
||||
// section of the CLI Config File.
|
||||
token, err := b.token(b.hostname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if token == "" {
|
||||
token = d.Get("token").(string)
|
||||
}
|
||||
|
||||
cfg := &tfe.Config{
|
||||
Address: service.String(),
|
||||
BasePath: service.Path,
|
||||
Token: token,
|
||||
}
|
||||
|
||||
// Create the remote backend API client.
|
||||
b.client, err = tfe.NewClient(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// discover the remote backend API service URL and token.
|
||||
func (b *Remote) discover(hostname string) (*url.URL, error) {
|
||||
host, err := svchost.ForComparison(hostname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
service := b.services.DiscoverServiceURL(host, serviceID)
|
||||
if service == nil {
|
||||
return nil, fmt.Errorf("host %s does not provide a remote backend API", host)
|
||||
}
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// token returns the token for this host as configured in the credentials
|
||||
// section of the CLI Config File. If no token was configured, an empty
|
||||
// string will be returned instead.
|
||||
func (b *Remote) token(hostname string) (string, error) {
|
||||
host, err := svchost.ForComparison(hostname)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
creds, err := b.services.CredentialsForHost(host)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to get credentials for %s: %s (ignoring)", host, err)
|
||||
return "", nil
|
||||
}
|
||||
if creds != nil {
|
||||
return creds.Token(), nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Input is called to ask the user for input for completing the configuration.
|
||||
func (b *Remote) Input(ui terraform.UIInput, c *terraform.ResourceConfig) (*terraform.ResourceConfig, error) {
|
||||
return b.schema.Input(ui, c)
|
||||
}
|
||||
|
||||
// Validate is called once at the beginning with the raw configuration and
|
||||
// can return a list of warnings and/or errors.
|
||||
func (b *Remote) Validate(c *terraform.ResourceConfig) ([]string, []error) {
|
||||
return b.schema.Validate(c)
|
||||
}
|
||||
|
||||
// Configure configures the backend itself with the configuration given.
|
||||
func (b *Remote) Configure(c *terraform.ResourceConfig) error {
|
||||
return b.schema.Configure(c)
|
||||
}
|
||||
|
||||
// State returns the latest state of the given remote workspace. The workspace
|
||||
// will be created if it doesn't exist.
|
||||
func (b *Remote) State(workspace string) (state.State, error) {
|
||||
if b.workspace == "" && workspace == backend.DefaultStateName {
|
||||
return nil, backend.ErrDefaultStateNotSupported
|
||||
}
|
||||
if b.prefix == "" && workspace != backend.DefaultStateName {
|
||||
return nil, backend.ErrNamedStatesNotSupported
|
||||
}
|
||||
|
||||
workspaces, err := b.states()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Error retrieving workspaces: %v", err)
|
||||
}
|
||||
|
||||
exists := false
|
||||
for _, name := range workspaces {
|
||||
if workspace == name {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Configure the remote workspace name.
|
||||
if workspace == backend.DefaultStateName {
|
||||
workspace = b.workspace
|
||||
} else if b.prefix != "" && !strings.HasPrefix(workspace, b.prefix) {
|
||||
workspace = b.prefix + workspace
|
||||
}
|
||||
|
||||
if !exists {
|
||||
options := tfe.WorkspaceCreateOptions{
|
||||
Name: tfe.String(workspace),
|
||||
TerraformVersion: tfe.String(version.Version),
|
||||
}
|
||||
_, err = b.client.Workspaces.Create(context.Background(), b.organization, options)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Error creating workspace %s: %v", workspace, err)
|
||||
}
|
||||
}
|
||||
|
||||
client := &remoteClient{
|
||||
client: b.client,
|
||||
organization: b.organization,
|
||||
workspace: workspace,
|
||||
}
|
||||
|
||||
return &remote.State{Client: client}, nil
|
||||
}
|
||||
|
||||
// DeleteState removes the remote workspace if it exists.
|
||||
func (b *Remote) DeleteState(workspace string) error {
|
||||
if b.workspace == "" && workspace == backend.DefaultStateName {
|
||||
return backend.ErrDefaultStateNotSupported
|
||||
}
|
||||
if b.prefix == "" && workspace != backend.DefaultStateName {
|
||||
return backend.ErrNamedStatesNotSupported
|
||||
}
|
||||
|
||||
// Configure the remote workspace name.
|
||||
if workspace == backend.DefaultStateName {
|
||||
workspace = b.workspace
|
||||
} else if b.prefix != "" && !strings.HasPrefix(workspace, b.prefix) {
|
||||
workspace = b.prefix + workspace
|
||||
}
|
||||
|
||||
// Check if the configured organization exists.
|
||||
_, err := b.client.Organizations.Read(context.Background(), b.organization)
|
||||
if err != nil {
|
||||
if err == tfe.ErrResourceNotFound {
|
||||
return fmt.Errorf("organization %s does not exist", b.organization)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
client := &remoteClient{
|
||||
client: b.client,
|
||||
organization: b.organization,
|
||||
workspace: workspace,
|
||||
}
|
||||
|
||||
return client.Delete()
|
||||
}
|
||||
|
||||
// States returns a filtered list of remote workspace names.
|
||||
func (b *Remote) States() ([]string, error) {
|
||||
if b.prefix == "" {
|
||||
return nil, backend.ErrNamedStatesNotSupported
|
||||
}
|
||||
return b.states()
|
||||
}
|
||||
|
||||
func (b *Remote) states() ([]string, error) {
|
||||
// Check if the configured organization exists.
|
||||
_, err := b.client.Organizations.Read(context.Background(), b.organization)
|
||||
if err != nil {
|
||||
if err == tfe.ErrResourceNotFound {
|
||||
return nil, fmt.Errorf("organization %s does not exist", b.organization)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
options := tfe.WorkspaceListOptions{}
|
||||
ws, err := b.client.Workspaces.List(context.Background(), b.organization, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var names []string
|
||||
for _, w := range ws {
|
||||
if b.workspace != "" && w.Name == b.workspace {
|
||||
names = append(names, backend.DefaultStateName)
|
||||
continue
|
||||
}
|
||||
if b.prefix != "" && strings.HasPrefix(w.Name, b.prefix) {
|
||||
names = append(names, strings.TrimPrefix(w.Name, b.prefix))
|
||||
}
|
||||
}
|
||||
|
||||
// Sort the result so we have consistent output.
|
||||
sort.StringSlice(names).Sort()
|
||||
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// Operation implements backend.Enhanced
|
||||
func (b *Remote) Operation(ctx context.Context, op *backend.Operation) (*backend.RunningOperation, error) {
|
||||
// Configure the remote workspace name.
|
||||
if op.Workspace == backend.DefaultStateName {
|
||||
op.Workspace = b.workspace
|
||||
} else if b.prefix != "" && !strings.HasPrefix(op.Workspace, b.prefix) {
|
||||
op.Workspace = b.prefix + op.Workspace
|
||||
}
|
||||
|
||||
// Determine the function to call for our operation
|
||||
var f func(context.Context, context.Context, *backend.Operation, *backend.RunningOperation)
|
||||
switch op.Type {
|
||||
case backend.OperationTypePlan:
|
||||
f = b.opPlan
|
||||
default:
|
||||
return nil, fmt.Errorf(
|
||||
"\n\nThe \"remote\" backend currently only supports the \"plan\" operation.\n"+
|
||||
"Please use the remote backend web UI for all other operations:\n"+
|
||||
"https://%s/app/%s/%s", b.hostname, b.organization, op.Workspace)
|
||||
// return nil, backend.ErrOperationNotSupported
|
||||
}
|
||||
|
||||
// Lock
|
||||
b.opLock.Lock()
|
||||
|
||||
// Build our running operation
|
||||
// the runninCtx is only used to block until the operation returns.
|
||||
runningCtx, done := context.WithCancel(context.Background())
|
||||
runningOp := &backend.RunningOperation{
|
||||
Context: runningCtx,
|
||||
}
|
||||
|
||||
// stopCtx wraps the context passed in, and is used to signal a graceful Stop.
|
||||
stopCtx, stop := context.WithCancel(ctx)
|
||||
runningOp.Stop = stop
|
||||
|
||||
// cancelCtx is used to cancel the operation immediately, usually
|
||||
// indicating that the process is exiting.
|
||||
cancelCtx, cancel := context.WithCancel(context.Background())
|
||||
runningOp.Cancel = cancel
|
||||
|
||||
// Do it
|
||||
go func() {
|
||||
defer done()
|
||||
defer stop()
|
||||
defer cancel()
|
||||
|
||||
defer b.opLock.Unlock()
|
||||
f(stopCtx, cancelCtx, op, runningOp)
|
||||
}()
|
||||
|
||||
// Return
|
||||
return runningOp, nil
|
||||
}
|
||||
|
||||
// Colorize returns the Colorize structure that can be used for colorizing
|
||||
// output. This is gauranteed to always return a non-nil value and so is useful
|
||||
// as a helper to wrap any potentially colored strings.
|
||||
func (b *Remote) Colorize() *colorstring.Colorize {
|
||||
if b.CLIColor != nil {
|
||||
return b.CLIColor
|
||||
}
|
||||
|
||||
return &colorstring.Colorize{
|
||||
Colors: colorstring.DefaultColors,
|
||||
Disable: true,
|
||||
}
|
||||
}
|
||||
|
||||
const generalErr = `
|
||||
%s: %v
|
||||
|
||||
The "remote" backend encountered an unexpected error while communicating
|
||||
with remote backend. In some cases this could be caused by a network
|
||||
connection problem, in which case you could retry the command. If the issue
|
||||
persists please open a support ticket to get help resolving the problem.
|
||||
`
|
||||
|
||||
var schemaDescriptions = map[string]string{
|
||||
"hostname": "The remote backend hostname to connect to (defaults to app.terraform.io).",
|
||||
"organization": "The name of the organization containing the targeted workspace(s).",
|
||||
"token": "The token used to authenticate with the remote backend. If TFE_TOKEN is set\n" +
|
||||
"or credentials for the host are configured in the CLI Config File, then this\n" +
|
||||
"this will override any saved value for this.",
|
||||
"workspaces": "Workspaces contains arguments used to filter down to a set of workspaces\n" +
|
||||
"to work on.",
|
||||
"name": "A workspace name used to map the default workspace to a named remote workspace.\n" +
|
||||
"When configured only the default workspace can be used. This option conflicts\n" +
|
||||
"with \"prefix\"",
|
||||
"prefix": "A prefix used to filter workspaces using a single configuration. New workspaces\n" +
|
||||
"will automatically be prefixed with this prefix. If omitted only the default\n" +
|
||||
"workspace can be used. This option conflicts with \"name\"",
|
||||
}
|
|
@ -0,0 +1,384 @@
|
|||
package remote
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
|
||||
tfe "github.com/hashicorp/go-tfe"
|
||||
)
|
||||
|
||||
type mockConfigurationVersions struct {
|
||||
configVersions map[string]*tfe.ConfigurationVersion
|
||||
uploadURLs map[string]*tfe.ConfigurationVersion
|
||||
workspaces map[string]*tfe.ConfigurationVersion
|
||||
}
|
||||
|
||||
func newMockConfigurationVersions() *mockConfigurationVersions {
|
||||
return &mockConfigurationVersions{
|
||||
configVersions: make(map[string]*tfe.ConfigurationVersion),
|
||||
uploadURLs: make(map[string]*tfe.ConfigurationVersion),
|
||||
workspaces: make(map[string]*tfe.ConfigurationVersion),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockConfigurationVersions) List(ctx context.Context, workspaceID string, options tfe.ConfigurationVersionListOptions) ([]*tfe.ConfigurationVersion, error) {
|
||||
var cvs []*tfe.ConfigurationVersion
|
||||
for _, cv := range m.configVersions {
|
||||
cvs = append(cvs, cv)
|
||||
}
|
||||
return cvs, nil
|
||||
}
|
||||
|
||||
func (m *mockConfigurationVersions) Create(ctx context.Context, workspaceID string, options tfe.ConfigurationVersionCreateOptions) (*tfe.ConfigurationVersion, error) {
|
||||
id := generateID("cv-")
|
||||
url := fmt.Sprintf("https://app.terraform.io/_archivist/%s", id)
|
||||
|
||||
cv := &tfe.ConfigurationVersion{
|
||||
ID: id,
|
||||
Status: tfe.ConfigurationPending,
|
||||
UploadURL: url,
|
||||
}
|
||||
|
||||
m.configVersions[cv.ID] = cv
|
||||
m.uploadURLs[url] = cv
|
||||
m.workspaces[workspaceID] = cv
|
||||
|
||||
return cv, nil
|
||||
}
|
||||
|
||||
func (m *mockConfigurationVersions) Read(ctx context.Context, cvID string) (*tfe.ConfigurationVersion, error) {
|
||||
cv, ok := m.configVersions[cvID]
|
||||
if !ok {
|
||||
return nil, tfe.ErrResourceNotFound
|
||||
}
|
||||
return cv, nil
|
||||
}
|
||||
|
||||
func (m *mockConfigurationVersions) Upload(ctx context.Context, url, path string) error {
|
||||
cv, ok := m.uploadURLs[url]
|
||||
if !ok {
|
||||
return errors.New("404 not found")
|
||||
}
|
||||
cv.Status = tfe.ConfigurationUploaded
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockOrganizations struct {
|
||||
organizations map[string]*tfe.Organization
|
||||
}
|
||||
|
||||
func newMockOrganizations() *mockOrganizations {
|
||||
return &mockOrganizations{
|
||||
organizations: make(map[string]*tfe.Organization),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockOrganizations) List(ctx context.Context, options tfe.OrganizationListOptions) ([]*tfe.Organization, error) {
|
||||
var orgs []*tfe.Organization
|
||||
for _, org := range m.organizations {
|
||||
orgs = append(orgs, org)
|
||||
}
|
||||
return orgs, nil
|
||||
}
|
||||
|
||||
func (m *mockOrganizations) Create(ctx context.Context, options tfe.OrganizationCreateOptions) (*tfe.Organization, error) {
|
||||
org := &tfe.Organization{Name: *options.Name}
|
||||
m.organizations[org.Name] = org
|
||||
return org, nil
|
||||
}
|
||||
|
||||
func (m *mockOrganizations) Read(ctx context.Context, name string) (*tfe.Organization, error) {
|
||||
org, ok := m.organizations[name]
|
||||
if !ok {
|
||||
return nil, tfe.ErrResourceNotFound
|
||||
}
|
||||
return org, nil
|
||||
}
|
||||
|
||||
func (m *mockOrganizations) Update(ctx context.Context, name string, options tfe.OrganizationUpdateOptions) (*tfe.Organization, error) {
|
||||
org, ok := m.organizations[name]
|
||||
if !ok {
|
||||
return nil, tfe.ErrResourceNotFound
|
||||
}
|
||||
org.Name = *options.Name
|
||||
return org, nil
|
||||
|
||||
}
|
||||
|
||||
func (m *mockOrganizations) Delete(ctx context.Context, name string) error {
|
||||
delete(m.organizations, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockPlans struct {
|
||||
logs map[string]string
|
||||
plans map[string]*tfe.Plan
|
||||
}
|
||||
|
||||
func newMockPlans() *mockPlans {
|
||||
return &mockPlans{
|
||||
logs: make(map[string]string),
|
||||
plans: make(map[string]*tfe.Plan),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockPlans) Read(ctx context.Context, planID string) (*tfe.Plan, error) {
|
||||
p, ok := m.plans[planID]
|
||||
if !ok {
|
||||
url := fmt.Sprintf("https://app.terraform.io/_archivist/%s", planID)
|
||||
|
||||
p = &tfe.Plan{
|
||||
ID: planID,
|
||||
LogReadURL: url,
|
||||
Status: tfe.PlanFinished,
|
||||
}
|
||||
|
||||
m.logs[url] = "plan/output.log"
|
||||
m.plans[p.ID] = p
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (m *mockPlans) Logs(ctx context.Context, planID string) (io.Reader, error) {
|
||||
p, err := m.Read(ctx, planID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logfile, ok := m.logs[p.LogReadURL]
|
||||
if !ok {
|
||||
return nil, tfe.ErrResourceNotFound
|
||||
}
|
||||
|
||||
logs, err := ioutil.ReadFile("./test-fixtures/" + logfile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return bytes.NewBuffer(logs), nil
|
||||
}
|
||||
|
||||
type mockRuns struct {
|
||||
runs map[string]*tfe.Run
|
||||
workspaces map[string][]*tfe.Run
|
||||
}
|
||||
|
||||
func newMockRuns() *mockRuns {
|
||||
return &mockRuns{
|
||||
runs: make(map[string]*tfe.Run),
|
||||
workspaces: make(map[string][]*tfe.Run),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockRuns) List(ctx context.Context, workspaceID string, options tfe.RunListOptions) ([]*tfe.Run, error) {
|
||||
var rs []*tfe.Run
|
||||
for _, r := range m.workspaces[workspaceID] {
|
||||
rs = append(rs, r)
|
||||
}
|
||||
return rs, nil
|
||||
}
|
||||
|
||||
func (m *mockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*tfe.Run, error) {
|
||||
id := generateID("run-")
|
||||
p := &tfe.Plan{
|
||||
ID: generateID("plan-"),
|
||||
Status: tfe.PlanPending,
|
||||
}
|
||||
|
||||
r := &tfe.Run{
|
||||
ID: id,
|
||||
Plan: p,
|
||||
Status: tfe.RunPending,
|
||||
}
|
||||
|
||||
m.runs[r.ID] = r
|
||||
m.workspaces[options.Workspace.ID] = append(m.workspaces[options.Workspace.ID], r)
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (m *mockRuns) Read(ctx context.Context, runID string) (*tfe.Run, error) {
|
||||
r, ok := m.runs[runID]
|
||||
if !ok {
|
||||
return nil, tfe.ErrResourceNotFound
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (m *mockRuns) Apply(ctx context.Context, runID string, options tfe.RunApplyOptions) error {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (m *mockRuns) Cancel(ctx context.Context, runID string, options tfe.RunCancelOptions) error {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (m *mockRuns) Discard(ctx context.Context, runID string, options tfe.RunDiscardOptions) error {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
type mockStateVersions struct {
|
||||
states map[string][]byte
|
||||
stateVersions map[string]*tfe.StateVersion
|
||||
workspaces map[string][]string
|
||||
}
|
||||
|
||||
func newMockStateVersions() *mockStateVersions {
|
||||
return &mockStateVersions{
|
||||
states: make(map[string][]byte),
|
||||
stateVersions: make(map[string]*tfe.StateVersion),
|
||||
workspaces: make(map[string][]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockStateVersions) List(ctx context.Context, options tfe.StateVersionListOptions) ([]*tfe.StateVersion, error) {
|
||||
var svs []*tfe.StateVersion
|
||||
for _, sv := range m.stateVersions {
|
||||
svs = append(svs, sv)
|
||||
}
|
||||
return svs, nil
|
||||
}
|
||||
|
||||
func (m *mockStateVersions) Create(ctx context.Context, workspaceID string, options tfe.StateVersionCreateOptions) (*tfe.StateVersion, error) {
|
||||
id := generateID("sv-")
|
||||
url := fmt.Sprintf("https://app.terraform.io/_archivist/%s", id)
|
||||
|
||||
sv := &tfe.StateVersion{
|
||||
ID: id,
|
||||
DownloadURL: url,
|
||||
Serial: *options.Serial,
|
||||
}
|
||||
|
||||
state, err := base64.StdEncoding.DecodeString(*options.State)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.states[sv.DownloadURL] = state
|
||||
m.stateVersions[sv.ID] = sv
|
||||
m.workspaces[workspaceID] = append(m.workspaces[workspaceID], sv.ID)
|
||||
|
||||
return sv, nil
|
||||
}
|
||||
|
||||
func (m *mockStateVersions) Read(ctx context.Context, svID string) (*tfe.StateVersion, error) {
|
||||
sv, ok := m.stateVersions[svID]
|
||||
if !ok {
|
||||
return nil, tfe.ErrResourceNotFound
|
||||
}
|
||||
return sv, nil
|
||||
}
|
||||
|
||||
func (m *mockStateVersions) Current(ctx context.Context, workspaceID string) (*tfe.StateVersion, error) {
|
||||
svs, ok := m.workspaces[workspaceID]
|
||||
if !ok || len(svs) == 0 {
|
||||
return nil, tfe.ErrResourceNotFound
|
||||
}
|
||||
sv, ok := m.stateVersions[svs[len(svs)-1]]
|
||||
if !ok {
|
||||
return nil, tfe.ErrResourceNotFound
|
||||
}
|
||||
return sv, nil
|
||||
}
|
||||
|
||||
func (m *mockStateVersions) Download(ctx context.Context, url string) ([]byte, error) {
|
||||
state, ok := m.states[url]
|
||||
if !ok {
|
||||
return nil, tfe.ErrResourceNotFound
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
type mockWorkspaces struct {
|
||||
workspaceIDs map[string]*tfe.Workspace
|
||||
workspaceNames map[string]*tfe.Workspace
|
||||
}
|
||||
|
||||
func newMockWorkspaces() *mockWorkspaces {
|
||||
return &mockWorkspaces{
|
||||
workspaceIDs: make(map[string]*tfe.Workspace),
|
||||
workspaceNames: make(map[string]*tfe.Workspace),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockWorkspaces) List(ctx context.Context, organization string, options tfe.WorkspaceListOptions) ([]*tfe.Workspace, error) {
|
||||
var ws []*tfe.Workspace
|
||||
for _, w := range m.workspaceIDs {
|
||||
ws = append(ws, w)
|
||||
}
|
||||
return ws, nil
|
||||
}
|
||||
|
||||
func (m *mockWorkspaces) Create(ctx context.Context, organization string, options tfe.WorkspaceCreateOptions) (*tfe.Workspace, error) {
|
||||
id := generateID("ws-")
|
||||
w := &tfe.Workspace{
|
||||
ID: id,
|
||||
Name: *options.Name,
|
||||
}
|
||||
m.workspaceIDs[w.ID] = w
|
||||
m.workspaceNames[w.Name] = w
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (m *mockWorkspaces) Read(ctx context.Context, organization, workspace string) (*tfe.Workspace, error) {
|
||||
w, ok := m.workspaceNames[workspace]
|
||||
if !ok {
|
||||
return nil, tfe.ErrResourceNotFound
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (m *mockWorkspaces) Update(ctx context.Context, organization, workspace string, options tfe.WorkspaceUpdateOptions) (*tfe.Workspace, error) {
|
||||
w, ok := m.workspaceNames[workspace]
|
||||
if !ok {
|
||||
return nil, tfe.ErrResourceNotFound
|
||||
}
|
||||
w.Name = *options.Name
|
||||
w.TerraformVersion = *options.TerraformVersion
|
||||
|
||||
delete(m.workspaceNames, workspace)
|
||||
m.workspaceNames[w.Name] = w
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (m *mockWorkspaces) Delete(ctx context.Context, organization, workspace string) error {
|
||||
if w, ok := m.workspaceNames[workspace]; ok {
|
||||
delete(m.workspaceIDs, w.ID)
|
||||
}
|
||||
delete(m.workspaceNames, workspace)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockWorkspaces) Lock(ctx context.Context, workspaceID string, options tfe.WorkspaceLockOptions) (*tfe.Workspace, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (m *mockWorkspaces) Unlock(ctx context.Context, workspaceID string) (*tfe.Workspace, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (m *mockWorkspaces) AssignSSHKey(ctx context.Context, workspaceID string, options tfe.WorkspaceAssignSSHKeyOptions) (*tfe.Workspace, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (m *mockWorkspaces) UnassignSSHKey(ctx context.Context, workspaceID string) (*tfe.Workspace, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
const alphanumeric = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
func generateID(s string) string {
|
||||
b := make([]byte, 16)
|
||||
for i := range b {
|
||||
b[i] = alphanumeric[rand.Intn(len(alphanumeric))]
|
||||
}
|
||||
return s + string(b)
|
||||
}
|
|
@ -0,0 +1,206 @@
|
|||
package remote
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tfe "github.com/hashicorp/go-tfe"
|
||||
"github.com/hashicorp/terraform/backend"
|
||||
)
|
||||
|
||||
func (b *Remote) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation, runningOp *backend.RunningOperation) {
|
||||
log.Printf("[INFO] backend/remote: starting Plan operation")
|
||||
|
||||
if op.Plan != nil {
|
||||
runningOp.Err = fmt.Errorf(strings.TrimSpace(planErrPlanNotSupported))
|
||||
return
|
||||
}
|
||||
|
||||
if op.PlanOutPath != "" {
|
||||
runningOp.Err = fmt.Errorf(strings.TrimSpace(planErrOutPathNotSupported))
|
||||
return
|
||||
}
|
||||
|
||||
if op.Targets != nil {
|
||||
runningOp.Err = fmt.Errorf(strings.TrimSpace(planErrTargetsNotSupported))
|
||||
return
|
||||
}
|
||||
|
||||
if (op.Module == nil || op.Module.Config().Dir == "") && !op.Destroy {
|
||||
runningOp.Err = fmt.Errorf(strings.TrimSpace(planErrNoConfig))
|
||||
return
|
||||
}
|
||||
|
||||
// Retrieve the workspace used to run this operation in.
|
||||
w, err := b.client.Workspaces.Read(stopCtx, b.organization, op.Workspace)
|
||||
if err != nil {
|
||||
if err != context.Canceled {
|
||||
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
|
||||
generalErr, "error retrieving workspace", err)))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
configOptions := tfe.ConfigurationVersionCreateOptions{
|
||||
AutoQueueRuns: tfe.Bool(false),
|
||||
Speculative: tfe.Bool(true),
|
||||
}
|
||||
|
||||
cv, err := b.client.ConfigurationVersions.Create(stopCtx, w.ID, configOptions)
|
||||
if err != nil {
|
||||
if err != context.Canceled {
|
||||
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
|
||||
generalErr, "error creating configuration version", err)))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var configDir string
|
||||
if op.Module != nil && op.Module.Config().Dir != "" {
|
||||
configDir = op.Module.Config().Dir
|
||||
} else {
|
||||
configDir, err = ioutil.TempDir("", "tf")
|
||||
if err != nil {
|
||||
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
|
||||
generalErr, "error creating temp directory", err)))
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(configDir)
|
||||
}
|
||||
|
||||
err = b.client.ConfigurationVersions.Upload(stopCtx, cv.UploadURL, configDir)
|
||||
if err != nil {
|
||||
if err != context.Canceled {
|
||||
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
|
||||
generalErr, "error uploading configuration files", err)))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
uploaded := false
|
||||
for i := 0; i < 60 && !uploaded; i++ {
|
||||
select {
|
||||
case <-stopCtx.Done():
|
||||
return
|
||||
case <-cancelCtx.Done():
|
||||
return
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
cv, err = b.client.ConfigurationVersions.Read(stopCtx, cv.ID)
|
||||
if err != nil {
|
||||
if err != context.Canceled {
|
||||
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
|
||||
generalErr, "error retrieving configuration version", err)))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if cv.Status == tfe.ConfigurationUploaded {
|
||||
uploaded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !uploaded {
|
||||
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
|
||||
generalErr, "error uploading configuration files", "operation timed out")))
|
||||
return
|
||||
}
|
||||
|
||||
runOptions := tfe.RunCreateOptions{
|
||||
IsDestroy: tfe.Bool(op.Destroy),
|
||||
Message: tfe.String("Queued manually using Terraform"),
|
||||
ConfigurationVersion: cv,
|
||||
Workspace: w,
|
||||
}
|
||||
|
||||
r, err := b.client.Runs.Create(stopCtx, runOptions)
|
||||
if err != nil {
|
||||
if err != context.Canceled {
|
||||
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
|
||||
generalErr, "error creating run", err)))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
r, err = b.client.Runs.Read(stopCtx, r.ID)
|
||||
if err != nil {
|
||||
if err != context.Canceled {
|
||||
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
|
||||
generalErr, "error retrieving run", err)))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if b.CLI != nil {
|
||||
b.CLI.Output(b.Colorize().Color(strings.TrimSpace(fmt.Sprintf(
|
||||
planDefaultHeader, b.hostname, b.organization, op.Workspace, r.ID)) + "\n"))
|
||||
}
|
||||
|
||||
logs, err := b.client.Plans.Logs(stopCtx, r.Plan.ID)
|
||||
if err != nil {
|
||||
if err != context.Canceled {
|
||||
runningOp.Err = fmt.Errorf(strings.TrimSpace(fmt.Sprintf(
|
||||
generalErr, "error retrieving logs", err)))
|
||||
}
|
||||
return
|
||||
}
|
||||
scanner := bufio.NewScanner(logs)
|
||||
|
||||
for scanner.Scan() {
|
||||
if b.CLI != nil {
|
||||
b.CLI.Output(b.Colorize().Color(scanner.Text()))
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
if err != context.Canceled && err != io.EOF {
|
||||
runningOp.Err = fmt.Errorf("Error reading logs: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const planErrPlanNotSupported = `
|
||||
Displaying a saved plan is currently not supported!
|
||||
|
||||
The "remote" backend currently requires configuration to be present
|
||||
and does not accept an existing saved plan as an argument at this time.
|
||||
`
|
||||
|
||||
const planErrOutPathNotSupported = `
|
||||
Saving a generated plan is currently not supported!
|
||||
|
||||
The "remote" backend does not support saving the generated execution
|
||||
plan locally at this time.
|
||||
`
|
||||
|
||||
const planErrTargetsNotSupported = `
|
||||
Resource targeting is currently not supported!
|
||||
|
||||
The "remote" backend does not support resource targeting at this time.
|
||||
`
|
||||
|
||||
const planErrNoConfig = `
|
||||
No configuration files found!
|
||||
|
||||
Plan requires configuration to be present. Planning without a configuration
|
||||
would mark everything for destruction, which is normally not what is desired.
|
||||
If you would like to destroy everything, please run plan with the "-destroy"
|
||||
flag or create a single empty configuration file. Otherwise, please create
|
||||
a Terraform configuration file in the path being executed and try again.
|
||||
`
|
||||
|
||||
const planDefaultHeader = `
|
||||
[reset][yellow]Running plan in the remote backend. Output will stream here. Pressing Ctrl-C
|
||||
will stop streaming the logs, but will not stop the plan running remotely.
|
||||
To view this plan in a browser, visit:
|
||||
https://%s/app/%s/%s/runs/%s[reset]
|
||||
|
||||
Waiting for the plan to start...
|
||||
`
|
|
@ -0,0 +1,181 @@
|
|||
package remote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform/backend"
|
||||
"github.com/hashicorp/terraform/config/module"
|
||||
"github.com/hashicorp/terraform/terraform"
|
||||
"github.com/mitchellh/cli"
|
||||
)
|
||||
|
||||
func testOperationPlan() *backend.Operation {
|
||||
return &backend.Operation{
|
||||
Type: backend.OperationTypePlan,
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemote_planBasic(t *testing.T) {
|
||||
b := testBackendDefault(t)
|
||||
|
||||
mod, modCleanup := module.TestTree(t, "./test-fixtures/plan")
|
||||
defer modCleanup()
|
||||
|
||||
op := testOperationPlan()
|
||||
op.Module = mod
|
||||
op.Workspace = backend.DefaultStateName
|
||||
|
||||
run, err := b.Operation(context.Background(), op)
|
||||
if err != nil {
|
||||
t.Fatalf("error starting operation: %v", err)
|
||||
}
|
||||
|
||||
<-run.Done()
|
||||
if run.Err != nil {
|
||||
t.Fatalf("error running operation: %v", run.Err)
|
||||
}
|
||||
|
||||
output := b.CLI.(*cli.MockUi).OutputWriter.String()
|
||||
if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") {
|
||||
t.Fatalf("missing plan summery in output: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemote_planWithPlan(t *testing.T) {
|
||||
b := testBackendDefault(t)
|
||||
|
||||
mod, modCleanup := module.TestTree(t, "./test-fixtures/plan")
|
||||
defer modCleanup()
|
||||
|
||||
op := testOperationPlan()
|
||||
op.Module = mod
|
||||
op.Plan = &terraform.Plan{}
|
||||
op.Workspace = backend.DefaultStateName
|
||||
|
||||
run, err := b.Operation(context.Background(), op)
|
||||
if err != nil {
|
||||
t.Fatalf("error starting operation: %v", err)
|
||||
}
|
||||
<-run.Done()
|
||||
|
||||
if run.Err == nil {
|
||||
t.Fatalf("expected a plan error, got: %v", run.Err)
|
||||
}
|
||||
if !strings.Contains(run.Err.Error(), "saved plan is currently not supported") {
|
||||
t.Fatalf("expected a saved plan error, got: %v", run.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemote_planWithPath(t *testing.T) {
|
||||
b := testBackendDefault(t)
|
||||
|
||||
mod, modCleanup := module.TestTree(t, "./test-fixtures/plan")
|
||||
defer modCleanup()
|
||||
|
||||
op := testOperationPlan()
|
||||
op.Module = mod
|
||||
op.PlanOutPath = "./test-fixtures/plan"
|
||||
op.Workspace = backend.DefaultStateName
|
||||
|
||||
run, err := b.Operation(context.Background(), op)
|
||||
if err != nil {
|
||||
t.Fatalf("error starting operation: %v", err)
|
||||
}
|
||||
<-run.Done()
|
||||
|
||||
if run.Err == nil {
|
||||
t.Fatalf("expected a plan error, got: %v", run.Err)
|
||||
}
|
||||
if !strings.Contains(run.Err.Error(), "generated plan is currently not supported") {
|
||||
t.Fatalf("expected a generated plan error, got: %v", run.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemote_planWithTarget(t *testing.T) {
|
||||
b := testBackendDefault(t)
|
||||
|
||||
mod, modCleanup := module.TestTree(t, "./test-fixtures/plan")
|
||||
defer modCleanup()
|
||||
|
||||
op := testOperationPlan()
|
||||
op.Module = mod
|
||||
op.Targets = []string{"null_resource.foo"}
|
||||
op.Workspace = backend.DefaultStateName
|
||||
|
||||
run, err := b.Operation(context.Background(), op)
|
||||
if err != nil {
|
||||
t.Fatalf("error starting operation: %v", err)
|
||||
}
|
||||
<-run.Done()
|
||||
|
||||
if run.Err == nil {
|
||||
t.Fatalf("expected a plan error, got: %v", run.Err)
|
||||
}
|
||||
if !strings.Contains(run.Err.Error(), "targeting is currently not supported") {
|
||||
t.Fatalf("expected a targeting error, got: %v", run.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemote_planNoConfig(t *testing.T) {
|
||||
b := testBackendDefault(t)
|
||||
|
||||
op := testOperationPlan()
|
||||
op.Module = nil
|
||||
op.Workspace = backend.DefaultStateName
|
||||
|
||||
run, err := b.Operation(context.Background(), op)
|
||||
if err != nil {
|
||||
t.Fatalf("error starting operation: %v", err)
|
||||
}
|
||||
<-run.Done()
|
||||
|
||||
if run.Err == nil {
|
||||
t.Fatalf("expected a plan error, got: %v", run.Err)
|
||||
}
|
||||
if !strings.Contains(run.Err.Error(), "configuration files found") {
|
||||
t.Fatalf("expected configuration files error, got: %v", run.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemote_planDestroy(t *testing.T) {
|
||||
b := testBackendDefault(t)
|
||||
|
||||
mod, modCleanup := module.TestTree(t, "./test-fixtures/plan")
|
||||
defer modCleanup()
|
||||
|
||||
op := testOperationPlan()
|
||||
op.Destroy = true
|
||||
op.Module = mod
|
||||
op.Workspace = backend.DefaultStateName
|
||||
|
||||
run, err := b.Operation(context.Background(), op)
|
||||
if err != nil {
|
||||
t.Fatalf("error starting operation: %v", err)
|
||||
}
|
||||
|
||||
<-run.Done()
|
||||
if run.Err != nil {
|
||||
t.Fatalf("unexpected plan error: %v", run.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemote_planDestroyNoConfig(t *testing.T) {
|
||||
b := testBackendDefault(t)
|
||||
|
||||
op := testOperationPlan()
|
||||
op.Destroy = true
|
||||
op.Module = nil
|
||||
op.Workspace = backend.DefaultStateName
|
||||
|
||||
run, err := b.Operation(context.Background(), op)
|
||||
if err != nil {
|
||||
t.Fatalf("error starting operation: %v", err)
|
||||
}
|
||||
|
||||
<-run.Done()
|
||||
if run.Err != nil {
|
||||
t.Fatalf("unexpected plan error: %v", run.Err)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,103 @@
|
|||
package remote
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
tfe "github.com/hashicorp/go-tfe"
|
||||
"github.com/hashicorp/terraform/state/remote"
|
||||
"github.com/hashicorp/terraform/terraform"
|
||||
)
|
||||
|
||||
type remoteClient struct {
|
||||
client *tfe.Client
|
||||
organization string
|
||||
workspace string
|
||||
}
|
||||
|
||||
// Get the remote state.
|
||||
func (r *remoteClient) Get() (*remote.Payload, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Retrieve the workspace for which to create a new state.
|
||||
w, err := r.client.Workspaces.Read(ctx, r.organization, r.workspace)
|
||||
if err != nil {
|
||||
if err == tfe.ErrResourceNotFound {
|
||||
// If no state exists, then return nil.
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("Error retrieving workspace: %v", err)
|
||||
}
|
||||
|
||||
sv, err := r.client.StateVersions.Current(ctx, w.ID)
|
||||
if err != nil {
|
||||
if err == tfe.ErrResourceNotFound {
|
||||
// If no state exists, then return nil.
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("Error retrieving remote state: %v", err)
|
||||
}
|
||||
|
||||
state, err := r.client.StateVersions.Download(ctx, sv.DownloadURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Error downloading remote state: %v", err)
|
||||
}
|
||||
|
||||
// If the state is empty, then return nil.
|
||||
if len(state) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Get the MD5 checksum of the state.
|
||||
sum := md5.Sum(state)
|
||||
|
||||
return &remote.Payload{
|
||||
Data: state,
|
||||
MD5: sum[:],
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Put the remote state.
|
||||
func (r *remoteClient) Put(state []byte) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Retrieve the workspace for which to create a new state.
|
||||
w, err := r.client.Workspaces.Read(ctx, r.organization, r.workspace)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Error retrieving workspace: %v", err)
|
||||
}
|
||||
|
||||
// the state into a buffer.
|
||||
tfState, err := terraform.ReadState(bytes.NewReader(state))
|
||||
if err != nil {
|
||||
return fmt.Errorf("Error reading state: %s", err)
|
||||
}
|
||||
|
||||
options := tfe.StateVersionCreateOptions{
|
||||
Lineage: tfe.String(tfState.Lineage),
|
||||
Serial: tfe.Int64(tfState.Serial),
|
||||
MD5: tfe.String(fmt.Sprintf("%x", md5.Sum(state))),
|
||||
State: tfe.String(base64.StdEncoding.EncodeToString(state)),
|
||||
}
|
||||
|
||||
// Create the new state.
|
||||
_, err = r.client.StateVersions.Create(ctx, w.ID, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Error creating remote state: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete the remote state.
|
||||
func (r *remoteClient) Delete() error {
|
||||
err := r.client.Workspaces.Delete(context.Background(), r.organization, r.workspace)
|
||||
if err != nil && err != tfe.ErrResourceNotFound {
|
||||
return fmt.Errorf("Error deleting workspace %s: %v", r.workspace, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
package remote
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform/state/remote"
|
||||
)
|
||||
|
||||
func TestRemoteClient_impl(t *testing.T) {
|
||||
var _ remote.Client = new(remoteClient)
|
||||
}
|
||||
|
||||
func TestRemoteClient(t *testing.T) {
|
||||
client := testRemoteClient(t)
|
||||
remote.TestClient(t, client)
|
||||
}
|
|
@ -0,0 +1,254 @@
|
|||
package remote
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform/backend"
|
||||
"github.com/hashicorp/terraform/config"
|
||||
"github.com/hashicorp/terraform/terraform"
|
||||
)
|
||||
|
||||
func TestRemote(t *testing.T) {
|
||||
var _ backend.Enhanced = New(nil)
|
||||
var _ backend.CLI = New(nil)
|
||||
}
|
||||
|
||||
func TestRemote_config(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
config map[string]interface{}
|
||||
err error
|
||||
}{
|
||||
"with_a_name": {
|
||||
config: map[string]interface{}{
|
||||
"organization": "hashicorp",
|
||||
"workspaces": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "prod",
|
||||
},
|
||||
},
|
||||
},
|
||||
err: nil,
|
||||
},
|
||||
"with_a_prefix": {
|
||||
config: map[string]interface{}{
|
||||
"organization": "hashicorp",
|
||||
"workspaces": []interface{}{
|
||||
map[string]interface{}{
|
||||
"prefix": "my-app-",
|
||||
},
|
||||
},
|
||||
},
|
||||
err: nil,
|
||||
},
|
||||
"with_two_workspace_entries": {
|
||||
config: map[string]interface{}{
|
||||
"organization": "hashicorp",
|
||||
"workspaces": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "prod",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"prefix": "my-app-",
|
||||
},
|
||||
},
|
||||
},
|
||||
err: errors.New("only one 'workspaces' block allowed"),
|
||||
},
|
||||
"without_either_a_name_and_a_prefix": {
|
||||
config: map[string]interface{}{
|
||||
"organization": "hashicorp",
|
||||
"workspaces": []interface{}{
|
||||
map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
err: errors.New("either workspace 'name' or 'prefix' is required"),
|
||||
},
|
||||
"with_both_a_name_and_a_prefix": {
|
||||
config: map[string]interface{}{
|
||||
"organization": "hashicorp",
|
||||
"workspaces": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "prod",
|
||||
"prefix": "my-app-",
|
||||
},
|
||||
},
|
||||
},
|
||||
err: errors.New("only one of workspace 'name' or 'prefix' is allowed"),
|
||||
},
|
||||
"with_an_unknown_host": {
|
||||
config: map[string]interface{}{
|
||||
"hostname": "nonexisting.local",
|
||||
"organization": "hashicorp",
|
||||
"workspaces": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "prod",
|
||||
},
|
||||
},
|
||||
},
|
||||
err: errors.New("host nonexisting.local does not provide a remote backend API"),
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range cases {
|
||||
s := testServer(t)
|
||||
b := New(testDisco(s))
|
||||
|
||||
// Get the proper config structure
|
||||
rc, err := config.NewRawConfig(tc.config)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: error creating raw config: %v", name, err)
|
||||
}
|
||||
conf := terraform.NewResourceConfig(rc)
|
||||
|
||||
// Validate
|
||||
warns, errs := b.Validate(conf)
|
||||
if len(warns) > 0 {
|
||||
t.Fatalf("%s: validation warnings: %v", name, warns)
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
t.Fatalf("%s: validation errors: %v", name, errs)
|
||||
}
|
||||
|
||||
// Configure
|
||||
err = b.Configure(conf)
|
||||
if err != tc.err && err != nil && tc.err != nil && err.Error() != tc.err.Error() {
|
||||
t.Fatalf("%s: expected error %q, got: %q", name, tc.err, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemote_nonexistingOrganization(t *testing.T) {
|
||||
msg := "does not exist"
|
||||
|
||||
b := testBackendNoDefault(t)
|
||||
b.organization = "nonexisting"
|
||||
|
||||
if _, err := b.State("prod"); err == nil || !strings.Contains(err.Error(), msg) {
|
||||
t.Fatalf("expected %q error, got: %v", msg, err)
|
||||
}
|
||||
|
||||
if err := b.DeleteState("prod"); err == nil || !strings.Contains(err.Error(), msg) {
|
||||
t.Fatalf("expected %q error, got: %v", msg, err)
|
||||
}
|
||||
|
||||
if _, err := b.States(); err == nil || !strings.Contains(err.Error(), msg) {
|
||||
t.Fatalf("expected %q error, got: %v", msg, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemote_backendDefault(t *testing.T) {
|
||||
b := testBackendDefault(t)
|
||||
backend.TestBackendStates(t, b)
|
||||
backend.TestBackendStateLocks(t, b, b)
|
||||
backend.TestBackendStateForceUnlock(t, b, b)
|
||||
}
|
||||
|
||||
func TestRemote_backendNoDefault(t *testing.T) {
|
||||
b := testBackendNoDefault(t)
|
||||
backend.TestBackendStates(t, b)
|
||||
}
|
||||
|
||||
func TestRemote_addAndRemoveStatesDefault(t *testing.T) {
|
||||
b := testBackendDefault(t)
|
||||
if _, err := b.States(); err != backend.ErrNamedStatesNotSupported {
|
||||
t.Fatalf("expected error %v, got %v", backend.ErrNamedStatesNotSupported, err)
|
||||
}
|
||||
|
||||
if _, err := b.State(backend.DefaultStateName); err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if _, err := b.State("prod"); err != backend.ErrNamedStatesNotSupported {
|
||||
t.Fatalf("expected error %v, got %v", backend.ErrNamedStatesNotSupported, err)
|
||||
}
|
||||
|
||||
if err := b.DeleteState(backend.DefaultStateName); err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if err := b.DeleteState("prod"); err != backend.ErrNamedStatesNotSupported {
|
||||
t.Fatalf("expected error %v, got %v", backend.ErrNamedStatesNotSupported, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemote_addAndRemoveStatesNoDefault(t *testing.T) {
|
||||
b := testBackendNoDefault(t)
|
||||
states, err := b.States()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expectedStates := []string(nil)
|
||||
if !reflect.DeepEqual(states, expectedStates) {
|
||||
t.Fatalf("expected states %#+v, got %#+v", expectedStates, states)
|
||||
}
|
||||
|
||||
if _, err := b.State(backend.DefaultStateName); err != backend.ErrDefaultStateNotSupported {
|
||||
t.Fatalf("expected error %v, got %v", backend.ErrDefaultStateNotSupported, err)
|
||||
}
|
||||
|
||||
expectedA := "test_A"
|
||||
if _, err := b.State(expectedA); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
states, err = b.States()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expectedStates = append(expectedStates, expectedA)
|
||||
if !reflect.DeepEqual(states, expectedStates) {
|
||||
t.Fatalf("expected %#+v, got %#+v", expectedStates, states)
|
||||
}
|
||||
|
||||
expectedB := "test_B"
|
||||
if _, err := b.State(expectedB); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
states, err = b.States()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expectedStates = append(expectedStates, expectedB)
|
||||
if !reflect.DeepEqual(states, expectedStates) {
|
||||
t.Fatalf("expected %#+v, got %#+v", expectedStates, states)
|
||||
}
|
||||
|
||||
if err := b.DeleteState(backend.DefaultStateName); err != backend.ErrDefaultStateNotSupported {
|
||||
t.Fatalf("expected error %v, got %v", backend.ErrDefaultStateNotSupported, err)
|
||||
}
|
||||
|
||||
if err := b.DeleteState(expectedA); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
states, err = b.States()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expectedStates = []string{expectedB}
|
||||
if !reflect.DeepEqual(states, expectedStates) {
|
||||
t.Fatalf("expected %#+v got %#+v", expectedStates, states)
|
||||
}
|
||||
|
||||
if err := b.DeleteState(expectedB); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
states, err = b.States()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expectedStates = []string(nil)
|
||||
if !reflect.DeepEqual(states, expectedStates) {
|
||||
t.Fatalf("expected %#+v, got %#+v", expectedStates, states)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package remote
|
||||
|
||||
import (
|
||||
"github.com/hashicorp/terraform/backend"
|
||||
)
|
||||
|
||||
// CLIInit implements backend.CLI
|
||||
func (b *Remote) CLIInit(opts *backend.CLIOpts) error {
|
||||
b.CLI = opts.CLI
|
||||
b.CLIColor = opts.CLIColor
|
||||
b.ContextOpts = opts.ContextOpts
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
resource "test_instance" "foo" {
|
||||
count = 3
|
||||
ami = "bar"
|
||||
|
||||
# This is here because at some point it caused a test failure
|
||||
network_interface {
|
||||
device_index = 0
|
||||
description = "Main network interface"
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
resource "null_resource" "foo" {}
|
|
@ -0,0 +1,29 @@
|
|||
Running plan in the remote backend. Output will stream here. Pressing Ctrl-C
|
||||
will stop streaming the logs, but will not stop the plan running remotely.
|
||||
To view this plan in a browser, visit:
|
||||
https://atlas.local/app/demo1/my-app-web/runs/run-cPK6EnfTpqwy6ucU
|
||||
|
||||
Waiting for the plan to start...
|
||||
|
||||
Terraform v0.11.7
|
||||
|
||||
Configuring remote state backend...
|
||||
Initializing Terraform configuration...
|
||||
Refreshing Terraform state in-memory prior to plan...
|
||||
The refreshed state will be used to calculate this plan, but will not be
|
||||
persisted to local or remote state storage.
|
||||
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
An execution plan has been generated and is shown below.
|
||||
Resource actions are indicated with the following symbols:
|
||||
+ create
|
||||
|
||||
Terraform will perform the following actions:
|
||||
|
||||
+ null_resource.foo
|
||||
id: <computed>
|
||||
|
||||
|
||||
Plan: 1 to add, 0 to change, 0 to destroy.
|
|
@ -0,0 +1,128 @@
|
|||
package remote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
tfe "github.com/hashicorp/go-tfe"
|
||||
"github.com/hashicorp/terraform/backend"
|
||||
"github.com/hashicorp/terraform/state/remote"
|
||||
"github.com/hashicorp/terraform/svchost"
|
||||
"github.com/hashicorp/terraform/svchost/auth"
|
||||
"github.com/hashicorp/terraform/svchost/disco"
|
||||
"github.com/mitchellh/cli"
|
||||
)
|
||||
|
||||
const (
|
||||
testCred = "test-auth-token"
|
||||
)
|
||||
|
||||
var (
|
||||
tfeHost = svchost.Hostname(defaultHostname)
|
||||
credsSrc = auth.StaticCredentialsSource(map[svchost.Hostname]map[string]interface{}{
|
||||
tfeHost: {"token": testCred},
|
||||
})
|
||||
)
|
||||
|
||||
func testBackendDefault(t *testing.T) *Remote {
|
||||
c := map[string]interface{}{
|
||||
"organization": "hashicorp",
|
||||
"workspaces": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "prod",
|
||||
},
|
||||
},
|
||||
}
|
||||
return testBackend(t, c)
|
||||
}
|
||||
|
||||
func testBackendNoDefault(t *testing.T) *Remote {
|
||||
c := map[string]interface{}{
|
||||
"organization": "hashicorp",
|
||||
"workspaces": []interface{}{
|
||||
map[string]interface{}{
|
||||
"prefix": "my-app-",
|
||||
},
|
||||
},
|
||||
}
|
||||
return testBackend(t, c)
|
||||
}
|
||||
|
||||
func testRemoteClient(t *testing.T) remote.Client {
|
||||
b := testBackendDefault(t)
|
||||
raw, err := b.State(backend.DefaultStateName)
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
s := raw.(*remote.State)
|
||||
return s.Client
|
||||
}
|
||||
|
||||
func testBackend(t *testing.T, c map[string]interface{}) *Remote {
|
||||
s := testServer(t)
|
||||
b := New(testDisco(s))
|
||||
|
||||
// Configure the backend so the client is created.
|
||||
backend.TestBackendConfig(t, b, c)
|
||||
|
||||
// Once the client exists, mock the services we use..
|
||||
b.CLI = cli.NewMockUi()
|
||||
b.client.ConfigurationVersions = newMockConfigurationVersions()
|
||||
b.client.Organizations = newMockOrganizations()
|
||||
b.client.Plans = newMockPlans()
|
||||
b.client.Runs = newMockRuns()
|
||||
b.client.StateVersions = newMockStateVersions()
|
||||
b.client.Workspaces = newMockWorkspaces()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create the organization.
|
||||
_, err := b.client.Organizations.Create(ctx, tfe.OrganizationCreateOptions{
|
||||
Name: tfe.String(b.organization),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
|
||||
// Create the default workspace if required.
|
||||
if b.workspace != "" {
|
||||
_, err = b.client.Workspaces.Create(ctx, b.organization, tfe.WorkspaceCreateOptions{
|
||||
Name: tfe.String(b.workspace),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// testServer returns a *httptest.Server used for local testing.
|
||||
func testServer(t *testing.T) *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Respond to service discovery calls.
|
||||
mux.HandleFunc("/well-known/terraform.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
io.WriteString(w, `{"tfe.v2":"/api/v2/"}`)
|
||||
})
|
||||
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
// testDisco returns a *disco.Disco mapping app.terraform.io and
|
||||
// localhost to a local test server.
|
||||
func testDisco(s *httptest.Server) *disco.Disco {
|
||||
services := map[string]interface{}{
|
||||
"tfe.v2": fmt.Sprintf("%s/api/v2/", s.URL),
|
||||
}
|
||||
d := disco.NewWithCredentialsSource(credsSrc)
|
||||
|
||||
d.ForceHostServices(svchost.Hostname(defaultHostname), services)
|
||||
d.ForceHostServices(svchost.Hostname("localhost"), services)
|
||||
return d
|
||||
}
|
|
@ -47,15 +47,27 @@ func TestBackendConfig(t *testing.T, b Backend, c map[string]interface{}) Backen
|
|||
func TestBackendStates(t *testing.T, b Backend) {
|
||||
t.Helper()
|
||||
|
||||
noDefault := false
|
||||
if _, err := b.State(DefaultStateName); err != nil {
|
||||
if err == ErrDefaultStateNotSupported {
|
||||
noDefault = true
|
||||
} else {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
states, err := b.States()
|
||||
if err != nil {
|
||||
if err == ErrNamedStatesNotSupported {
|
||||
t.Logf("TestBackend: named states not supported in %T, skipping", b)
|
||||
return
|
||||
}
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
|
||||
// Test it starts with only the default
|
||||
if len(states) != 1 || states[0] != DefaultStateName {
|
||||
t.Fatalf("should only have default to start: %#v", states)
|
||||
if !noDefault && (len(states) != 1 || states[0] != DefaultStateName) {
|
||||
t.Fatalf("should have default to start: %#v", states)
|
||||
}
|
||||
|
||||
// Create a couple states
|
||||
|
@ -175,6 +187,9 @@ func TestBackendStates(t *testing.T, b Backend) {
|
|||
|
||||
sort.Strings(states)
|
||||
expected := []string{"bar", "default", "foo"}
|
||||
if noDefault {
|
||||
expected = []string{"bar", "foo"}
|
||||
}
|
||||
if !reflect.DeepEqual(states, expected) {
|
||||
t.Fatalf("bad: %#v", states)
|
||||
}
|
||||
|
@ -218,6 +233,9 @@ func TestBackendStates(t *testing.T, b Backend) {
|
|||
|
||||
sort.Strings(states)
|
||||
expected := []string{"bar", "default"}
|
||||
if noDefault {
|
||||
expected = []string{"bar"}
|
||||
}
|
||||
if !reflect.DeepEqual(states, expected) {
|
||||
t.Fatalf("bad: %#v", states)
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/hashicorp/terraform/backend"
|
||||
backendinit "github.com/hashicorp/terraform/backend/init"
|
||||
backendInit "github.com/hashicorp/terraform/backend/init"
|
||||
"github.com/hashicorp/terraform/config"
|
||||
"github.com/hashicorp/terraform/helper/schema"
|
||||
"github.com/hashicorp/terraform/terraform"
|
||||
|
@ -80,7 +80,7 @@ func dataSourceRemoteStateRead(d *schema.ResourceData, meta interface{}) error {
|
|||
|
||||
// Create the client to access our remote state
|
||||
log.Printf("[DEBUG] Initializing remote state backend: %s", backendType)
|
||||
f := backendinit.Backend(backendType)
|
||||
f := backendInit.Backend(backendType)
|
||||
if f == nil {
|
||||
return fmt.Errorf("Unknown backend type: %s", backendType)
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import (
|
|||
"fmt"
|
||||
"testing"
|
||||
|
||||
backendinit "github.com/hashicorp/terraform/backend/init"
|
||||
backendInit "github.com/hashicorp/terraform/backend/init"
|
||||
"github.com/hashicorp/terraform/helper/resource"
|
||||
"github.com/hashicorp/terraform/terraform"
|
||||
)
|
||||
|
@ -26,8 +26,8 @@ func TestState_basic(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestState_backends(t *testing.T) {
|
||||
backendinit.Set("_ds_test", backendinit.Backend("local"))
|
||||
defer backendinit.Set("_ds_test", nil)
|
||||
backendInit.Set("_ds_test", backendInit.Backend("local"))
|
||||
defer backendInit.Set("_ds_test", nil)
|
||||
|
||||
resource.UnitTest(t, resource.TestCase{
|
||||
PreCheck: func() { testAccPreCheck(t) },
|
||||
|
|
|
@ -3,6 +3,7 @@ package terraform
|
|||
import (
|
||||
"testing"
|
||||
|
||||
backendInit "github.com/hashicorp/terraform/backend/init"
|
||||
"github.com/hashicorp/terraform/helper/schema"
|
||||
"github.com/hashicorp/terraform/terraform"
|
||||
)
|
||||
|
@ -11,6 +12,9 @@ var testAccProviders map[string]terraform.ResourceProvider
|
|||
var testAccProvider *schema.Provider
|
||||
|
||||
func init() {
|
||||
// Initialize the backends
|
||||
backendInit.Init(nil)
|
||||
|
||||
testAccProvider = Provider().(*schema.Provider)
|
||||
testAccProviders = map[string]terraform.ResourceProvider{
|
||||
"terraform": testAccProvider,
|
||||
|
|
|
@ -48,10 +48,6 @@ The "backend" in Terraform defines how Terraform operates. The default
|
|||
backend performs all operations locally on your machine. Your configuration
|
||||
is configured to use a non-local backend. This backend doesn't support this
|
||||
operation.
|
||||
|
||||
If you want to use the state from the backend but force all other data
|
||||
(configuration, variables, etc.) to come locally, you can force local
|
||||
behavior with the "-local" flag.
|
||||
`
|
||||
|
||||
// ModulePath returns the path to the root module from the CLI args.
|
||||
|
|
|
@ -19,6 +19,7 @@ import (
|
|||
"syscall"
|
||||
"testing"
|
||||
|
||||
backendInit "github.com/hashicorp/terraform/backend/init"
|
||||
"github.com/hashicorp/terraform/config/module"
|
||||
"github.com/hashicorp/terraform/helper/logging"
|
||||
"github.com/hashicorp/terraform/terraform"
|
||||
|
@ -33,6 +34,9 @@ var testingDir string
|
|||
func init() {
|
||||
test = true
|
||||
|
||||
// Initialize the backends
|
||||
backendInit.Init(nil)
|
||||
|
||||
// Expand the fixture dir on init because we change the working
|
||||
// directory in some tests.
|
||||
var err error
|
||||
|
@ -117,7 +121,7 @@ func testModule(t *testing.T, name string) *module.Tree {
|
|||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
|
||||
s := module.NewStorage(tempDir(t), nil, nil)
|
||||
s := module.NewStorage(tempDir(t), nil)
|
||||
s.Mode = module.GetModeGet
|
||||
if err := mod.Load(s); err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
|
|
|
@ -129,7 +129,7 @@ func (c *InitCommand) Run(args []string) int {
|
|||
)))
|
||||
header = true
|
||||
|
||||
s := module.NewStorage("", c.Services, c.Credentials)
|
||||
s := module.NewStorage("", c.Services)
|
||||
if err := s.GetModule(path, src); err != nil {
|
||||
c.Ui.Error(fmt.Sprintf("Error copying source module: %s", err))
|
||||
return 1
|
||||
|
@ -138,11 +138,12 @@ func (c *InitCommand) Run(args []string) int {
|
|||
|
||||
// If our directory is empty, then we're done. We can't get or setup
|
||||
// the backend with an empty directory.
|
||||
if empty, err := config.IsEmptyDir(path); err != nil {
|
||||
c.Ui.Error(fmt.Sprintf(
|
||||
"Error checking configuration: %s", err))
|
||||
empty, err := config.IsEmptyDir(path)
|
||||
if err != nil {
|
||||
c.Ui.Error(fmt.Sprintf("Error checking configuration: %s", err))
|
||||
return 1
|
||||
} else if empty {
|
||||
}
|
||||
if empty {
|
||||
c.Ui.Output(c.Colorize().Color(strings.TrimSpace(outputInitEmpty)))
|
||||
return 0
|
||||
}
|
||||
|
@ -227,14 +228,12 @@ func (c *InitCommand) Run(args []string) int {
|
|||
if back != nil {
|
||||
sMgr, err := back.State(c.Workspace())
|
||||
if err != nil {
|
||||
c.Ui.Error(fmt.Sprintf(
|
||||
"Error loading state: %s", err))
|
||||
c.Ui.Error(fmt.Sprintf("Error loading state: %s", err))
|
||||
return 1
|
||||
}
|
||||
|
||||
if err := sMgr.RefreshState(); err != nil {
|
||||
c.Ui.Error(fmt.Sprintf(
|
||||
"Error refreshing state: %s", err))
|
||||
c.Ui.Error(fmt.Sprintf("Error refreshing state: %s", err))
|
||||
return 1
|
||||
}
|
||||
|
||||
|
|
|
@ -25,7 +25,6 @@ import (
|
|||
"github.com/hashicorp/terraform/helper/experiment"
|
||||
"github.com/hashicorp/terraform/helper/variables"
|
||||
"github.com/hashicorp/terraform/helper/wrappedstreams"
|
||||
"github.com/hashicorp/terraform/svchost/auth"
|
||||
"github.com/hashicorp/terraform/svchost/disco"
|
||||
"github.com/hashicorp/terraform/terraform"
|
||||
"github.com/hashicorp/terraform/tfdiags"
|
||||
|
@ -51,10 +50,6 @@ type Meta struct {
|
|||
// "terraform-native' services running at a specific user-facing hostname.
|
||||
Services *disco.Disco
|
||||
|
||||
// Credentials provides access to credentials for "terraform-native"
|
||||
// services, which are accessed by a service hostname.
|
||||
Credentials auth.CredentialsSource
|
||||
|
||||
// RunningInAutomation indicates that commands are being run by an
|
||||
// automated system rather than directly at a command prompt.
|
||||
//
|
||||
|
@ -410,7 +405,7 @@ func (m *Meta) flagSet(n string) *flag.FlagSet {
|
|||
// moduleStorage returns the module.Storage implementation used to store
|
||||
// modules for commands.
|
||||
func (m *Meta) moduleStorage(root string, mode module.GetMode) *module.Storage {
|
||||
s := module.NewStorage(filepath.Join(root, "modules"), m.Services, m.Credentials)
|
||||
s := module.NewStorage(filepath.Join(root, "modules"), m.Services)
|
||||
s.Ui = m.Ui
|
||||
s.Mode = mode
|
||||
return s
|
||||
|
|
|
@ -21,8 +21,8 @@ import (
|
|||
"github.com/hashicorp/terraform/terraform"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
|
||||
backendinit "github.com/hashicorp/terraform/backend/init"
|
||||
backendlocal "github.com/hashicorp/terraform/backend/local"
|
||||
backendInit "github.com/hashicorp/terraform/backend/init"
|
||||
backendLocal "github.com/hashicorp/terraform/backend/local"
|
||||
)
|
||||
|
||||
// BackendOpts are the options used to initialize a backend.Backend.
|
||||
|
@ -94,7 +94,7 @@ func (m *Meta) Backend(opts *BackendOpts) (backend.Enhanced, error) {
|
|||
log.Printf("[INFO] command: backend initialized: %T", b)
|
||||
}
|
||||
|
||||
// Setup the CLI opts we pass into backends that support it
|
||||
// Setup the CLI opts we pass into backends that support it.
|
||||
cliOpts := &backend.CLIOpts{
|
||||
CLI: m.Ui,
|
||||
CLIColor: m.Colorize(),
|
||||
|
@ -136,7 +136,7 @@ func (m *Meta) Backend(opts *BackendOpts) (backend.Enhanced, error) {
|
|||
}
|
||||
|
||||
// Build the local backend
|
||||
local := &backendlocal.Local{Backend: b}
|
||||
local := backendLocal.NewWithBackend(b)
|
||||
if err := local.CLIInit(cliOpts); err != nil {
|
||||
// Local backend isn't allowed to fail. It would be a bug.
|
||||
panic(err)
|
||||
|
@ -149,7 +149,7 @@ func (m *Meta) Backend(opts *BackendOpts) (backend.Enhanced, error) {
|
|||
// for some checks that require a remote backend.
|
||||
func (m *Meta) IsLocalBackend(b backend.Backend) bool {
|
||||
// Is it a local backend?
|
||||
bLocal, ok := b.(*backendlocal.Local)
|
||||
bLocal, ok := b.(*backendLocal.Local)
|
||||
|
||||
// If it is, does it not have an alternate state backend?
|
||||
if ok {
|
||||
|
@ -231,7 +231,7 @@ func (m *Meta) backendConfig(opts *BackendOpts) (*config.Backend, error) {
|
|||
rc, err := config.NewRawConfig(opts.ConfigExtra)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"Error adding extra configuration file for backend: %s", err)
|
||||
"Error adding extra backend configuration from CLI: %s", err)
|
||||
}
|
||||
|
||||
// Merge in the configuration
|
||||
|
@ -739,7 +739,7 @@ func (m *Meta) backend_c_R_s(
|
|||
config := terraform.NewResourceConfig(rawC)
|
||||
|
||||
// Get the backend
|
||||
f := backendinit.Backend(s.Remote.Type)
|
||||
f := backendInit.Backend(s.Remote.Type)
|
||||
if f == nil {
|
||||
return nil, fmt.Errorf(strings.TrimSpace(errBackendLegacyUnknown), s.Remote.Type)
|
||||
}
|
||||
|
@ -937,8 +937,8 @@ func (m *Meta) backend_C_r_s(
|
|||
// can get us here too. Don't delete our state if the old and new paths
|
||||
// are the same.
|
||||
erase := true
|
||||
if newLocalB, ok := b.(*backendlocal.Local); ok {
|
||||
if localB, ok := localB.(*backendlocal.Local); ok {
|
||||
if newLocalB, ok := b.(*backendLocal.Local); ok {
|
||||
if localB, ok := localB.(*backendLocal.Local); ok {
|
||||
if newLocalB.StatePath == localB.StatePath {
|
||||
erase = false
|
||||
}
|
||||
|
@ -1091,7 +1091,7 @@ func (m *Meta) backend_C_r_S_unchanged(
|
|||
config := terraform.NewResourceConfig(rawC)
|
||||
|
||||
// Get the backend
|
||||
f := backendinit.Backend(s.Backend.Type)
|
||||
f := backendInit.Backend(s.Backend.Type)
|
||||
if f == nil {
|
||||
return nil, fmt.Errorf(strings.TrimSpace(errBackendSavedUnknown), s.Backend.Type)
|
||||
}
|
||||
|
@ -1208,7 +1208,7 @@ func (m *Meta) backendInitFromConfig(c *config.Backend) (backend.Backend, error)
|
|||
config := terraform.NewResourceConfig(c.RawConfig)
|
||||
|
||||
// Get the backend
|
||||
f := backendinit.Backend(c.Type)
|
||||
f := backendInit.Backend(c.Type)
|
||||
if f == nil {
|
||||
return nil, fmt.Errorf(strings.TrimSpace(errBackendNewUnknown), c.Type)
|
||||
}
|
||||
|
@ -1265,7 +1265,7 @@ func (m *Meta) backendInitFromLegacy(s *terraform.RemoteState) (backend.Backend,
|
|||
config := terraform.NewResourceConfig(rawC)
|
||||
|
||||
// Get the backend
|
||||
f := backendinit.Backend(s.Type)
|
||||
f := backendInit.Backend(s.Type)
|
||||
if f == nil {
|
||||
return nil, fmt.Errorf(strings.TrimSpace(errBackendLegacyUnknown), s.Type)
|
||||
}
|
||||
|
@ -1290,7 +1290,7 @@ func (m *Meta) backendInitFromSaved(s *terraform.BackendState) (backend.Backend,
|
|||
config := terraform.NewResourceConfig(rawC)
|
||||
|
||||
// Get the backend
|
||||
f := backendinit.Backend(s.Type)
|
||||
f := backendInit.Backend(s.Type)
|
||||
if f == nil {
|
||||
return nil, fmt.Errorf(strings.TrimSpace(errBackendSavedUnknown), s.Type)
|
||||
}
|
||||
|
|
|
@ -211,6 +211,13 @@ func (m *Meta) backendMigrateState_s_s(opts *backendMigrateOpts) error {
|
|||
|
||||
stateTwo, err := opts.Two.State(opts.twoEnv)
|
||||
if err != nil {
|
||||
if err == backend.ErrDefaultStateNotSupported && stateOne.State() == nil {
|
||||
// When using named workspaces it is common that the default
|
||||
// workspace is not actually used. So we first check if there
|
||||
// actually is a state to be migrated, if not we just return
|
||||
// and silently ignore the unused default worksopace.
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf(strings.TrimSpace(
|
||||
errMigrateSingleLoadDefault), opts.TwoType, err)
|
||||
}
|
||||
|
@ -418,8 +425,8 @@ above error and try again.
|
|||
`
|
||||
|
||||
const errMigrateMulti = `
|
||||
Error migrating the workspace %q from the previous %q backend to the newly
|
||||
configured %q backend:
|
||||
Error migrating the workspace %q from the previous %q backend
|
||||
to the newly configured %q backend:
|
||||
%s
|
||||
|
||||
Terraform copies workspaces in alphabetical order. Any workspaces
|
||||
|
@ -432,7 +439,8 @@ This will attempt to copy (with permission) all workspaces again.
|
|||
`
|
||||
|
||||
const errBackendStateCopy = `
|
||||
Error copying state from the previous %q backend to the newly configured %q backend:
|
||||
Error copying state from the previous %q backend to the newly configured
|
||||
%q backend:
|
||||
%s
|
||||
|
||||
The state in the previous backend remains intact and unmodified. Please resolve
|
||||
|
|
|
@ -10,8 +10,8 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform/backend"
|
||||
backendinit "github.com/hashicorp/terraform/backend/init"
|
||||
backendlocal "github.com/hashicorp/terraform/backend/local"
|
||||
backendInit "github.com/hashicorp/terraform/backend/init"
|
||||
backendLocal "github.com/hashicorp/terraform/backend/local"
|
||||
"github.com/hashicorp/terraform/helper/copy"
|
||||
"github.com/hashicorp/terraform/state"
|
||||
"github.com/hashicorp/terraform/terraform"
|
||||
|
@ -994,8 +994,8 @@ func TestMetaBackend_reconfigureChange(t *testing.T) {
|
|||
defer testChdir(t, td)()
|
||||
|
||||
// Register the single-state backend
|
||||
backendinit.Set("local-single", backendlocal.TestNewLocalSingle)
|
||||
defer backendinit.Set("local-single", nil)
|
||||
backendInit.Set("local-single", backendLocal.TestNewLocalSingle)
|
||||
defer backendInit.Set("local-single", nil)
|
||||
|
||||
// Setup the meta
|
||||
m := testMetaBackend(t, nil)
|
||||
|
@ -1093,8 +1093,8 @@ func TestMetaBackend_configuredChangeCopy_singleState(t *testing.T) {
|
|||
defer testChdir(t, td)()
|
||||
|
||||
// Register the single-state backend
|
||||
backendinit.Set("local-single", backendlocal.TestNewLocalSingle)
|
||||
defer backendinit.Set("local-single", nil)
|
||||
backendInit.Set("local-single", backendLocal.TestNewLocalSingle)
|
||||
defer backendInit.Set("local-single", nil)
|
||||
|
||||
// Ask input
|
||||
defer testInputMap(t, map[string]string{
|
||||
|
@ -1149,8 +1149,8 @@ func TestMetaBackend_configuredChangeCopy_multiToSingleDefault(t *testing.T) {
|
|||
defer testChdir(t, td)()
|
||||
|
||||
// Register the single-state backend
|
||||
backendinit.Set("local-single", backendlocal.TestNewLocalSingle)
|
||||
defer backendinit.Set("local-single", nil)
|
||||
backendInit.Set("local-single", backendLocal.TestNewLocalSingle)
|
||||
defer backendInit.Set("local-single", nil)
|
||||
|
||||
// Ask input
|
||||
defer testInputMap(t, map[string]string{
|
||||
|
@ -1204,8 +1204,8 @@ func TestMetaBackend_configuredChangeCopy_multiToSingle(t *testing.T) {
|
|||
defer testChdir(t, td)()
|
||||
|
||||
// Register the single-state backend
|
||||
backendinit.Set("local-single", backendlocal.TestNewLocalSingle)
|
||||
defer backendinit.Set("local-single", nil)
|
||||
backendInit.Set("local-single", backendLocal.TestNewLocalSingle)
|
||||
defer backendInit.Set("local-single", nil)
|
||||
|
||||
// Ask input
|
||||
defer testInputMap(t, map[string]string{
|
||||
|
@ -1250,7 +1250,7 @@ func TestMetaBackend_configuredChangeCopy_multiToSingle(t *testing.T) {
|
|||
}
|
||||
|
||||
// Verify existing workspaces exist
|
||||
envPath := filepath.Join(backendlocal.DefaultWorkspaceDir, "env2", backendlocal.DefaultStateFilename)
|
||||
envPath := filepath.Join(backendLocal.DefaultWorkspaceDir, "env2", backendLocal.DefaultStateFilename)
|
||||
if _, err := os.Stat(envPath); err != nil {
|
||||
t.Fatal("env should exist")
|
||||
}
|
||||
|
@ -1271,8 +1271,8 @@ func TestMetaBackend_configuredChangeCopy_multiToSingleCurrentEnv(t *testing.T)
|
|||
defer testChdir(t, td)()
|
||||
|
||||
// Register the single-state backend
|
||||
backendinit.Set("local-single", backendlocal.TestNewLocalSingle)
|
||||
defer backendinit.Set("local-single", nil)
|
||||
backendInit.Set("local-single", backendLocal.TestNewLocalSingle)
|
||||
defer backendInit.Set("local-single", nil)
|
||||
|
||||
// Ask input
|
||||
defer testInputMap(t, map[string]string{
|
||||
|
@ -1322,7 +1322,7 @@ func TestMetaBackend_configuredChangeCopy_multiToSingleCurrentEnv(t *testing.T)
|
|||
}
|
||||
|
||||
// Verify existing workspaces exist
|
||||
envPath := filepath.Join(backendlocal.DefaultWorkspaceDir, "env2", backendlocal.DefaultStateFilename)
|
||||
envPath := filepath.Join(backendLocal.DefaultWorkspaceDir, "env2", backendLocal.DefaultStateFilename)
|
||||
if _, err := os.Stat(envPath); err != nil {
|
||||
t.Fatal("env should exist")
|
||||
}
|
||||
|
@ -1407,7 +1407,7 @@ func TestMetaBackend_configuredChangeCopy_multiToMulti(t *testing.T) {
|
|||
|
||||
{
|
||||
// Verify existing workspaces exist
|
||||
envPath := filepath.Join(backendlocal.DefaultWorkspaceDir, "env2", backendlocal.DefaultStateFilename)
|
||||
envPath := filepath.Join(backendLocal.DefaultWorkspaceDir, "env2", backendLocal.DefaultStateFilename)
|
||||
if _, err := os.Stat(envPath); err != nil {
|
||||
t.Fatal("env should exist")
|
||||
}
|
||||
|
@ -1415,7 +1415,113 @@ func TestMetaBackend_configuredChangeCopy_multiToMulti(t *testing.T) {
|
|||
|
||||
{
|
||||
// Verify new workspaces exist
|
||||
envPath := filepath.Join("envdir-new", "env2", backendlocal.DefaultStateFilename)
|
||||
envPath := filepath.Join("envdir-new", "env2", backendLocal.DefaultStateFilename)
|
||||
if _, err := os.Stat(envPath); err != nil {
|
||||
t.Fatal("env should exist")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Changing a configured backend that supports multi-state to a
|
||||
// backend that also supports multi-state, but doesn't allow a
|
||||
// default state while the default state is non-empty.
|
||||
func TestMetaBackend_configuredChangeCopy_multiToNoDefaultWithDefault(t *testing.T) {
|
||||
// Create a temporary working directory that is empty
|
||||
td := tempDir(t)
|
||||
copy.CopyDir(testFixturePath("backend-change-multi-to-no-default-with-default"), td)
|
||||
defer os.RemoveAll(td)
|
||||
defer testChdir(t, td)()
|
||||
|
||||
// Register the single-state backend
|
||||
backendInit.Set("local-no-default", backendLocal.TestNewLocalNoDefault)
|
||||
defer backendInit.Set("local-no-default", nil)
|
||||
|
||||
// Ask input
|
||||
defer testInputMap(t, map[string]string{
|
||||
"backend-migrate-to-new": "yes",
|
||||
"backend-migrate-multistate-to-multistate": "yes",
|
||||
})()
|
||||
|
||||
// Setup the meta
|
||||
m := testMetaBackend(t, nil)
|
||||
|
||||
// Get the backend
|
||||
_, err := m.Backend(&BackendOpts{Init: true})
|
||||
if err == nil || !strings.Contains(err.Error(), "default state not supported") {
|
||||
t.Fatalf("expected error to contain %q\ngot: %s", "default state not supported", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Changing a configured backend that supports multi-state to a
|
||||
// backend that also supports multi-state, but doesn't allow a
|
||||
// default state while the default state is empty.
|
||||
func TestMetaBackend_configuredChangeCopy_multiToNoDefaultWithoutDefault(t *testing.T) {
|
||||
// Create a temporary working directory that is empty
|
||||
td := tempDir(t)
|
||||
copy.CopyDir(testFixturePath("backend-change-multi-to-no-default-without-default"), td)
|
||||
defer os.RemoveAll(td)
|
||||
defer testChdir(t, td)()
|
||||
|
||||
// Register the single-state backend
|
||||
backendInit.Set("local-no-default", backendLocal.TestNewLocalNoDefault)
|
||||
defer backendInit.Set("local-no-default", nil)
|
||||
|
||||
// Ask input
|
||||
defer testInputMap(t, map[string]string{
|
||||
"backend-migrate-to-new": "yes",
|
||||
"backend-migrate-multistate-to-multistate": "yes",
|
||||
})()
|
||||
|
||||
// Setup the meta
|
||||
m := testMetaBackend(t, nil)
|
||||
|
||||
// Get the backend
|
||||
b, err := m.Backend(&BackendOpts{Init: true})
|
||||
if err != nil {
|
||||
t.Fatalf("bad: %s", err)
|
||||
}
|
||||
|
||||
// Check resulting states
|
||||
states, err := b.States()
|
||||
if err != nil {
|
||||
t.Fatalf("bad: %s", err)
|
||||
}
|
||||
|
||||
sort.Strings(states)
|
||||
expected := []string{"env2"}
|
||||
if !reflect.DeepEqual(states, expected) {
|
||||
t.Fatalf("bad: %#v", states)
|
||||
}
|
||||
|
||||
{
|
||||
// Check the named state
|
||||
s, err := b.State("env2")
|
||||
if err != nil {
|
||||
t.Fatalf("bad: %s", err)
|
||||
}
|
||||
if err := s.RefreshState(); err != nil {
|
||||
t.Fatalf("bad: %s", err)
|
||||
}
|
||||
state := s.State()
|
||||
if state == nil {
|
||||
t.Fatal("state should not be nil")
|
||||
}
|
||||
if state.Lineage != "backend-change-env2" {
|
||||
t.Fatalf("bad: %#v", state)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
// Verify existing workspaces exist
|
||||
envPath := filepath.Join(backendLocal.DefaultWorkspaceDir, "env2", backendLocal.DefaultStateFilename)
|
||||
if _, err := os.Stat(envPath); err != nil {
|
||||
t.Fatal("env should exist")
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
// Verify new workspaces exist
|
||||
envPath := filepath.Join("envdir-new", "env2", backendLocal.DefaultStateFilename)
|
||||
if _, err := os.Stat(envPath); err != nil {
|
||||
t.Fatal("env should exist")
|
||||
}
|
||||
|
@ -3351,7 +3457,7 @@ func TestMetaBackend_configureWithExtra(t *testing.T) {
|
|||
}
|
||||
|
||||
// Check the state
|
||||
s := testStateRead(t, filepath.Join(DefaultDataDir, backendlocal.DefaultStateFilename))
|
||||
s := testStateRead(t, filepath.Join(DefaultDataDir, backendLocal.DefaultStateFilename))
|
||||
if s.Backend.Hash != backendCfg.Hash {
|
||||
t.Fatal("mismatched state and config backend hashes")
|
||||
}
|
||||
|
@ -3373,7 +3479,7 @@ func TestMetaBackend_configureWithExtra(t *testing.T) {
|
|||
}
|
||||
|
||||
// Check the state
|
||||
s = testStateRead(t, filepath.Join(DefaultDataDir, backendlocal.DefaultStateFilename))
|
||||
s = testStateRead(t, filepath.Join(DefaultDataDir, backendLocal.DefaultStateFilename))
|
||||
if s.Backend.Hash != backendCfg.Hash {
|
||||
t.Fatal("mismatched state and config backend hashes")
|
||||
}
|
||||
|
@ -3442,7 +3548,7 @@ func TestMetaBackend_configToExtra(t *testing.T) {
|
|||
}
|
||||
|
||||
// Check the state
|
||||
s := testStateRead(t, filepath.Join(DefaultDataDir, backendlocal.DefaultStateFilename))
|
||||
s := testStateRead(t, filepath.Join(DefaultDataDir, backendLocal.DefaultStateFilename))
|
||||
backendHash := s.Backend.Hash
|
||||
|
||||
// init again but remove the path option from the config
|
||||
|
@ -3463,7 +3569,7 @@ func TestMetaBackend_configToExtra(t *testing.T) {
|
|||
t.Fatalf("bad: %s", err)
|
||||
}
|
||||
|
||||
s = testStateRead(t, filepath.Join(DefaultDataDir, backendlocal.DefaultStateFilename))
|
||||
s = testStateRead(t, filepath.Join(DefaultDataDir, backendLocal.DefaultStateFilename))
|
||||
|
||||
if s.Backend.Hash == backendHash {
|
||||
t.Fatal("state.Backend.Hash was not updated")
|
||||
|
|
|
@ -5,7 +5,7 @@ import (
|
|||
"fmt"
|
||||
"time"
|
||||
|
||||
backendlocal "github.com/hashicorp/terraform/backend/local"
|
||||
backendLocal "github.com/hashicorp/terraform/backend/local"
|
||||
"github.com/hashicorp/terraform/state"
|
||||
"github.com/hashicorp/terraform/terraform"
|
||||
)
|
||||
|
@ -49,7 +49,7 @@ func (c *StateMeta) State() (state.State, error) {
|
|||
// This should never fail
|
||||
panic(err)
|
||||
}
|
||||
localB := localRaw.(*backendlocal.Local)
|
||||
localB := localRaw.(*backendLocal.Local)
|
||||
_, stateOutPath, _ = localB.StatePaths(env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"version": 3,
|
||||
"serial": 0,
|
||||
"lineage": "666f9301-7e65-4b19-ae23-71184bb19b03",
|
||||
"backend": {
|
||||
"type": "local",
|
||||
"config": {
|
||||
"path": "local-state.tfstate"
|
||||
},
|
||||
"hash": 9073424445967744180
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"path": [
|
||||
"root"
|
||||
],
|
||||
"outputs": {},
|
||||
"resources": {},
|
||||
"depends_on": []
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"version": 3,
|
||||
"terraform_version": "0.8.2",
|
||||
"serial": 7,
|
||||
"lineage": "backend-change"
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
terraform {
|
||||
backend "local-no-default" {
|
||||
environment_dir = "envdir-new"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"version": 3,
|
||||
"terraform_version": "0.8.2",
|
||||
"serial": 7,
|
||||
"lineage": "backend-change-env2"
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"version": 3,
|
||||
"serial": 0,
|
||||
"lineage": "666f9301-7e65-4b19-ae23-71184bb19b03",
|
||||
"backend": {
|
||||
"type": "local",
|
||||
"config": {
|
||||
"path": "local-state.tfstate"
|
||||
},
|
||||
"hash": 9073424445967744180
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"path": [
|
||||
"root"
|
||||
],
|
||||
"outputs": {},
|
||||
"resources": {},
|
||||
"depends_on": []
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
terraform {
|
||||
backend "local-no-default" {
|
||||
environment_dir = "envdir-new"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"version": 3,
|
||||
"terraform_version": "0.8.2",
|
||||
"serial": 7,
|
||||
"lineage": "backend-change-env2"
|
||||
}
|
|
@ -30,15 +30,12 @@ const (
|
|||
OutputPrefix = "o:"
|
||||
)
|
||||
|
||||
func initCommands(config *Config) {
|
||||
func initCommands(config *Config, services *disco.Disco) {
|
||||
var inAutomation bool
|
||||
if v := os.Getenv(runningInAutomationEnvName); v != "" {
|
||||
inAutomation = true
|
||||
}
|
||||
|
||||
credsSrc := credentialsSource(config)
|
||||
services := disco.NewDisco()
|
||||
services.SetCredentialsSource(credsSrc)
|
||||
for userHost, hostConfig := range config.Hosts {
|
||||
host, err := svchost.ForComparison(userHost)
|
||||
if err != nil {
|
||||
|
@ -58,7 +55,6 @@ func initCommands(config *Config) {
|
|||
Ui: Ui,
|
||||
|
||||
Services: services,
|
||||
Credentials: credsSrc,
|
||||
|
||||
RunningInAutomation: inAutomation,
|
||||
PluginCacheDir: config.PluginCacheDir,
|
||||
|
|
|
@ -44,5 +44,5 @@ func testConfig(t *testing.T, n string) *config.Config {
|
|||
|
||||
func testStorage(t *testing.T, d *disco.Disco) *Storage {
|
||||
t.Helper()
|
||||
return NewStorage(tempDir(t), d, nil)
|
||||
return NewStorage(tempDir(t), d)
|
||||
}
|
||||
|
|
|
@ -11,7 +11,6 @@ import (
|
|||
getter "github.com/hashicorp/go-getter"
|
||||
"github.com/hashicorp/terraform/registry"
|
||||
"github.com/hashicorp/terraform/registry/regsrc"
|
||||
"github.com/hashicorp/terraform/svchost/auth"
|
||||
"github.com/hashicorp/terraform/svchost/disco"
|
||||
"github.com/mitchellh/cli"
|
||||
)
|
||||
|
@ -64,14 +63,10 @@ type Storage struct {
|
|||
// StorageDir is the full path to the directory where all modules will be
|
||||
// stored.
|
||||
StorageDir string
|
||||
// Services is a required *disco.Disco, which may have services and
|
||||
// credentials pre-loaded.
|
||||
Services *disco.Disco
|
||||
// Creds optionally provides credentials for communicating with service
|
||||
// providers.
|
||||
Creds auth.CredentialsSource
|
||||
|
||||
// Ui is an optional cli.Ui for user output
|
||||
Ui cli.Ui
|
||||
|
||||
// Mode is the GetMode that will be used for various operations.
|
||||
Mode GetMode
|
||||
|
||||
|
@ -79,8 +74,8 @@ type Storage struct {
|
|||
}
|
||||
|
||||
// NewStorage returns a new initialized Storage object.
|
||||
func NewStorage(dir string, services *disco.Disco, creds auth.CredentialsSource) *Storage {
|
||||
regClient := registry.NewClient(services, creds, nil)
|
||||
func NewStorage(dir string, services *disco.Disco) *Storage {
|
||||
regClient := registry.NewClient(services, nil)
|
||||
|
||||
return &Storage{
|
||||
StorageDir: dir,
|
||||
|
|
|
@ -22,7 +22,7 @@ func TestGetModule(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(td)
|
||||
storage := NewStorage(td, disco, nil)
|
||||
storage := NewStorage(td, disco)
|
||||
|
||||
// this module exists in a test fixture, and is known by the test.Registry
|
||||
// relative to our cwd.
|
||||
|
@ -139,7 +139,7 @@ func TestAccRegistryDiscover(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := NewStorage("/tmp", nil, nil)
|
||||
s := NewStorage("/tmp", nil)
|
||||
loc, err := s.registry.Location(module, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
|
@ -5,7 +5,6 @@ import (
|
|||
|
||||
"github.com/hashicorp/terraform/configs"
|
||||
"github.com/hashicorp/terraform/registry"
|
||||
"github.com/hashicorp/terraform/svchost/auth"
|
||||
"github.com/hashicorp/terraform/svchost/disco"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
@ -39,10 +38,6 @@ type Config struct {
|
|||
// not supported, which should be true only in specialized circumstances
|
||||
// such as in tests.
|
||||
Services *disco.Disco
|
||||
|
||||
// Creds is a credentials store for communicating with remote module
|
||||
// registry endpoints. If this is nil then no credentials will be used.
|
||||
Creds auth.CredentialsSource
|
||||
}
|
||||
|
||||
// NewLoader creates and returns a loader that reads configuration from the
|
||||
|
@ -54,7 +49,7 @@ type Config struct {
|
|||
func NewLoader(config *Config) (*Loader, error) {
|
||||
fs := afero.NewOsFs()
|
||||
parser := configs.NewParser(fs)
|
||||
reg := registry.NewClient(config.Services, config.Creds, nil)
|
||||
reg := registry.NewClient(config.Services, nil)
|
||||
|
||||
ret := &Loader{
|
||||
parser: parser,
|
||||
|
@ -63,7 +58,6 @@ func NewLoader(config *Config) (*Loader, error) {
|
|||
CanInstall: true,
|
||||
Dir: config.ModulesDir,
|
||||
Services: config.Services,
|
||||
Creds: config.Creds,
|
||||
Registry: reg,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@ package configload
|
|||
|
||||
import (
|
||||
"github.com/hashicorp/terraform/registry"
|
||||
"github.com/hashicorp/terraform/svchost/auth"
|
||||
"github.com/hashicorp/terraform/svchost/disco"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
@ -25,9 +24,6 @@ type moduleMgr struct {
|
|||
// cached discovery information.
|
||||
Services *disco.Disco
|
||||
|
||||
// Creds provides optional credentials for communicating with service hosts.
|
||||
Creds auth.CredentialsSource
|
||||
|
||||
// Registry is a client for the module registry protocol, which is used
|
||||
// when a module is requested from a registry source.
|
||||
Registry *registry.Client
|
||||
|
|
15
main.go
15
main.go
|
@ -11,15 +11,16 @@ import (
|
|||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mitchellh/colorstring"
|
||||
|
||||
"github.com/hashicorp/go-plugin"
|
||||
backendInit "github.com/hashicorp/terraform/backend/init"
|
||||
"github.com/hashicorp/terraform/command/format"
|
||||
"github.com/hashicorp/terraform/helper/logging"
|
||||
"github.com/hashicorp/terraform/svchost/disco"
|
||||
"github.com/hashicorp/terraform/terraform"
|
||||
"github.com/mattn/go-colorable"
|
||||
"github.com/mattn/go-shellwords"
|
||||
"github.com/mitchellh/cli"
|
||||
"github.com/mitchellh/colorstring"
|
||||
"github.com/mitchellh/panicwrap"
|
||||
"github.com/mitchellh/prefixedio"
|
||||
)
|
||||
|
@ -142,9 +143,17 @@ func wrappedMain() int {
|
|||
}
|
||||
}
|
||||
|
||||
// Get any configured credentials from the config and initialize
|
||||
// a service discovery object.
|
||||
credsSrc := credentialsSource(config)
|
||||
services := disco.NewWithCredentialsSource(credsSrc)
|
||||
|
||||
// Initialize the backends.
|
||||
backendInit.Init(services)
|
||||
|
||||
// In tests, Commands may already be set to provide mock commands
|
||||
if Commands == nil {
|
||||
initCommands(config)
|
||||
initCommands(config, services)
|
||||
}
|
||||
|
||||
// Run checkpoint
|
||||
|
|
|
@ -15,7 +15,6 @@ import (
|
|||
"github.com/hashicorp/terraform/registry/regsrc"
|
||||
"github.com/hashicorp/terraform/registry/response"
|
||||
"github.com/hashicorp/terraform/svchost"
|
||||
"github.com/hashicorp/terraform/svchost/auth"
|
||||
"github.com/hashicorp/terraform/svchost/disco"
|
||||
"github.com/hashicorp/terraform/version"
|
||||
)
|
||||
|
@ -37,20 +36,14 @@ type Client struct {
|
|||
// services is a required *disco.Disco, which may have services and
|
||||
// credentials pre-loaded.
|
||||
services *disco.Disco
|
||||
|
||||
// Creds optionally provides credentials for communicating with service
|
||||
// providers.
|
||||
creds auth.CredentialsSource
|
||||
}
|
||||
|
||||
// NewClient returns a new initialized registry client.
|
||||
func NewClient(services *disco.Disco, creds auth.CredentialsSource, client *http.Client) *Client {
|
||||
func NewClient(services *disco.Disco, client *http.Client) *Client {
|
||||
if services == nil {
|
||||
services = disco.NewDisco()
|
||||
services = disco.New()
|
||||
}
|
||||
|
||||
services.SetCredentialsSource(creds)
|
||||
|
||||
if client == nil {
|
||||
client = httpclient.New()
|
||||
client.Timeout = requestTimeout
|
||||
|
@ -61,7 +54,6 @@ func NewClient(services *disco.Disco, creds auth.CredentialsSource, client *http
|
|||
return &Client{
|
||||
client: client,
|
||||
services: services,
|
||||
creds: creds,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -138,11 +130,7 @@ func (c *Client) Versions(module *regsrc.Module) (*response.ModuleVersions, erro
|
|||
}
|
||||
|
||||
func (c *Client) addRequestCreds(host svchost.Hostname, req *http.Request) {
|
||||
if c.creds == nil {
|
||||
return
|
||||
}
|
||||
|
||||
creds, err := c.creds.ForHost(host)
|
||||
creds, err := c.services.CredentialsForHost(host)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to get credentials for %s: %s (ignoring)", host, err)
|
||||
return
|
||||
|
|
|
@ -15,7 +15,7 @@ func TestLookupModuleVersions(t *testing.T) {
|
|||
server := test.Registry()
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(test.Disco(server), nil, nil)
|
||||
client := NewClient(test.Disco(server), nil)
|
||||
|
||||
// test with and without a hostname
|
||||
for _, src := range []string{
|
||||
|
@ -59,7 +59,7 @@ func TestInvalidRegistry(t *testing.T) {
|
|||
server := test.Registry()
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(test.Disco(server), nil, nil)
|
||||
client := NewClient(test.Disco(server), nil)
|
||||
|
||||
src := "non-existent.localhost.localdomain/test-versions/name/provider"
|
||||
modsrc, err := regsrc.ParseModuleSource(src)
|
||||
|
@ -76,7 +76,7 @@ func TestRegistryAuth(t *testing.T) {
|
|||
server := test.Registry()
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(test.Disco(server), nil, nil)
|
||||
client := NewClient(test.Disco(server), nil)
|
||||
|
||||
src := "private/name/provider"
|
||||
mod, err := regsrc.ParseModuleSource(src)
|
||||
|
@ -84,6 +84,18 @@ func TestRegistryAuth(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = client.Versions(mod)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = client.Location(mod, "1.0.0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Also test without a credentials source
|
||||
client.services.SetCredentialsSource(nil)
|
||||
|
||||
// both should fail without auth
|
||||
_, err = client.Versions(mod)
|
||||
if err == nil {
|
||||
|
@ -93,24 +105,13 @@ func TestRegistryAuth(t *testing.T) {
|
|||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
client = NewClient(test.Disco(server), test.Credentials, nil)
|
||||
|
||||
_, err = client.Versions(mod)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = client.Location(mod, "1.0.0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupModuleLocationRelative(t *testing.T) {
|
||||
server := test.Registry()
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(test.Disco(server), nil, nil)
|
||||
client := NewClient(test.Disco(server), nil)
|
||||
|
||||
src := "relative/foo/bar"
|
||||
mod, err := regsrc.ParseModuleSource(src)
|
||||
|
@ -133,7 +134,7 @@ func TestAccLookupModuleVersions(t *testing.T) {
|
|||
if os.Getenv("TF_ACC") == "" {
|
||||
t.Skip()
|
||||
}
|
||||
regDisco := disco.NewDisco()
|
||||
regDisco := disco.New()
|
||||
|
||||
// test with and without a hostname
|
||||
for _, src := range []string{
|
||||
|
@ -145,7 +146,7 @@ func TestAccLookupModuleVersions(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := NewClient(regDisco, nil, nil)
|
||||
s := NewClient(regDisco, nil)
|
||||
resp, err := s.Versions(modsrc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
@ -179,7 +180,7 @@ func TestLookupLookupModuleError(t *testing.T) {
|
|||
server := test.Registry()
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(test.Disco(server), nil, nil)
|
||||
client := NewClient(test.Disco(server), nil)
|
||||
|
||||
// this should not be found in teh registry
|
||||
src := "bad/local/path"
|
||||
|
|
|
@ -27,7 +27,7 @@ func Disco(s *httptest.Server) *disco.Disco {
|
|||
// TODO: add specific tests to enumerate both possibilities.
|
||||
"modules.v1": fmt.Sprintf("%s/v1/modules", s.URL),
|
||||
}
|
||||
d := disco.NewDisco()
|
||||
d := disco.NewWithCredentialsSource(credsSrc)
|
||||
|
||||
d.ForceHostServices(svchost.Hostname("registry.terraform.io"), services)
|
||||
d.ForceHostServices(svchost.Hostname("localhost"), services)
|
||||
|
@ -49,7 +49,7 @@ const (
|
|||
|
||||
var (
|
||||
regHost = svchost.Hostname(regsrc.PublicRegistryHost.Normalized())
|
||||
Credentials = auth.StaticCredentialsSource(map[svchost.Hostname]map[string]interface{}{
|
||||
credsSrc = auth.StaticCredentialsSource(map[svchost.Hostname]map[string]interface{}{
|
||||
regHost: {"token": testCred},
|
||||
})
|
||||
)
|
||||
|
|
|
@ -26,7 +26,7 @@ func TestClient(t *testing.T, c Client) {
|
|||
t.Fatalf("get: %s", err)
|
||||
}
|
||||
if !bytes.Equal(p.Data, data) {
|
||||
t.Fatalf("bad: %#v", p)
|
||||
t.Fatalf("expected full state %q\n\ngot: %q", string(p.Data), string(data))
|
||||
}
|
||||
|
||||
if err := c.Delete(); err != nil {
|
||||
|
@ -38,7 +38,7 @@ func TestClient(t *testing.T, c Client) {
|
|||
t.Fatalf("get: %s", err)
|
||||
}
|
||||
if p != nil {
|
||||
t.Fatalf("bad: %#v", p)
|
||||
t.Fatalf("expected empty state, got: %q", string(p.Data))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -42,6 +42,9 @@ type HostCredentials interface {
|
|||
// receiving credentials. The usual behavior of this method is to
|
||||
// add some sort of Authorization header to the request.
|
||||
PrepareRequest(req *http.Request)
|
||||
|
||||
// Token returns the authentication token.
|
||||
Token() string
|
||||
}
|
||||
|
||||
// ForHost iterates over the contained CredentialsSource objects and
|
||||
|
|
|
@ -18,3 +18,8 @@ func (tc HostCredentialsToken) PrepareRequest(req *http.Request) {
|
|||
}
|
||||
req.Header.Set("Authorization", "Bearer "+string(tc))
|
||||
}
|
||||
|
||||
// Token returns the authentication token.
|
||||
func (tc HostCredentialsToken) Token() string {
|
||||
return string(tc)
|
||||
}
|
||||
|
|
|
@ -42,9 +42,15 @@ type Disco struct {
|
|||
Transport http.RoundTripper
|
||||
}
|
||||
|
||||
// NewDisco returns a new initialized Disco object.
|
||||
func NewDisco() *Disco {
|
||||
return &Disco{}
|
||||
// New returns a new initialized discovery object.
|
||||
func New() *Disco {
|
||||
return NewWithCredentialsSource(nil)
|
||||
}
|
||||
|
||||
// NewWithCredentialsSource returns a new discovery object initialized with
|
||||
// the given credentials source.
|
||||
func NewWithCredentialsSource(credsSrc auth.CredentialsSource) *Disco {
|
||||
return &Disco{credsSrc: credsSrc}
|
||||
}
|
||||
|
||||
// SetCredentialsSource provides a credentials source that will be used to
|
||||
|
@ -56,6 +62,15 @@ func (d *Disco) SetCredentialsSource(src auth.CredentialsSource) {
|
|||
d.credsSrc = src
|
||||
}
|
||||
|
||||
// CredentialsForHost returns a non-nil HostCredentials if the embedded source has
|
||||
// credentials available for the host, and a nil HostCredentials if it does not.
|
||||
func (d *Disco) CredentialsForHost(host svchost.Hostname) (auth.HostCredentials, error) {
|
||||
if d.credsSrc == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return d.credsSrc.ForHost(host)
|
||||
}
|
||||
|
||||
// ForceHostServices provides a pre-defined set of services for a given
|
||||
// host, which prevents the receiver from attempting network-based discovery
|
||||
// for the given host. Instead, the given services map will be returned
|
||||
|
@ -145,15 +160,10 @@ func (d *Disco) discover(host svchost.Hostname) Host {
|
|||
URL: discoURL,
|
||||
}
|
||||
|
||||
if d.credsSrc != nil {
|
||||
creds, err := d.credsSrc.ForHost(host)
|
||||
if err == nil {
|
||||
if creds != nil {
|
||||
creds.PrepareRequest(req) // alters req to include credentials
|
||||
}
|
||||
} else {
|
||||
if creds, err := d.CredentialsForHost(host); err != nil {
|
||||
log.Printf("[WARN] Failed to get credentials for %s: %s (ignoring)", host, err)
|
||||
}
|
||||
} else if creds != nil {
|
||||
creds.PrepareRequest(req) // alters req to include credentials
|
||||
}
|
||||
|
||||
log.Printf("[DEBUG] Service discovery for %s at %s", host, discoURL)
|
||||
|
|
|
@ -45,7 +45,7 @@ func TestDiscover(t *testing.T) {
|
|||
t.Fatalf("test server hostname is invalid: %s", err)
|
||||
}
|
||||
|
||||
d := NewDisco()
|
||||
d := New()
|
||||
discovered := d.Discover(host)
|
||||
gotURL := discovered.ServiceURL("thingy.v1")
|
||||
if gotURL == nil {
|
||||
|
@ -80,7 +80,7 @@ func TestDiscover(t *testing.T) {
|
|||
t.Fatalf("test server hostname is invalid: %s", err)
|
||||
}
|
||||
|
||||
d := NewDisco()
|
||||
d := New()
|
||||
discovered := d.Discover(host)
|
||||
gotURL := discovered.ServiceURL("wotsit.v2")
|
||||
if gotURL == nil {
|
||||
|
@ -107,7 +107,7 @@ func TestDiscover(t *testing.T) {
|
|||
t.Fatalf("test server hostname is invalid: %s", err)
|
||||
}
|
||||
|
||||
d := NewDisco()
|
||||
d := New()
|
||||
d.SetCredentialsSource(auth.StaticCredentialsSource(map[svchost.Hostname]map[string]interface{}{
|
||||
host: map[string]interface{}{
|
||||
"token": "abc123",
|
||||
|
@ -124,7 +124,7 @@ func TestDiscover(t *testing.T) {
|
|||
"wotsit.v2": "/foo",
|
||||
}
|
||||
|
||||
d := NewDisco()
|
||||
d := New()
|
||||
d.ForceHostServices(svchost.Hostname("example.com"), forced)
|
||||
|
||||
givenHost := "example.com"
|
||||
|
@ -167,7 +167,7 @@ func TestDiscover(t *testing.T) {
|
|||
t.Fatalf("test server hostname is invalid: %s", err)
|
||||
}
|
||||
|
||||
d := NewDisco()
|
||||
d := New()
|
||||
discovered := d.Discover(host)
|
||||
|
||||
// result should be empty, which we can verify only by reaching into
|
||||
|
@ -190,7 +190,7 @@ func TestDiscover(t *testing.T) {
|
|||
t.Fatalf("test server hostname is invalid: %s", err)
|
||||
}
|
||||
|
||||
d := NewDisco()
|
||||
d := New()
|
||||
discovered := d.Discover(host)
|
||||
|
||||
// result should be empty, which we can verify only by reaching into
|
||||
|
@ -217,7 +217,7 @@ func TestDiscover(t *testing.T) {
|
|||
t.Fatalf("test server hostname is invalid: %s", err)
|
||||
}
|
||||
|
||||
d := NewDisco()
|
||||
d := New()
|
||||
discovered := d.Discover(host)
|
||||
|
||||
if discovered.services == nil {
|
||||
|
@ -236,7 +236,7 @@ func TestDiscover(t *testing.T) {
|
|||
t.Fatalf("test server hostname is invalid: %s", err)
|
||||
}
|
||||
|
||||
d := NewDisco()
|
||||
d := New()
|
||||
discovered := d.Discover(host)
|
||||
|
||||
// result should be empty, which we can verify only by reaching into
|
||||
|
@ -267,7 +267,7 @@ func TestDiscover(t *testing.T) {
|
|||
t.Fatalf("test server hostname is invalid: %s", err)
|
||||
}
|
||||
|
||||
d := NewDisco()
|
||||
d := New()
|
||||
discovered := d.Discover(host)
|
||||
|
||||
gotURL := discovered.ServiceURL("thingy.v1")
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
Copyright (c) 2013 Google. 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.
|
||||
* Neither the name of Google Inc. nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
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
|
||||
OWNER 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,320 @@
|
|||
// Copyright 2013 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package query implements encoding of structs into URL query parameters.
|
||||
//
|
||||
// As a simple example:
|
||||
//
|
||||
// type Options struct {
|
||||
// Query string `url:"q"`
|
||||
// ShowAll bool `url:"all"`
|
||||
// Page int `url:"page"`
|
||||
// }
|
||||
//
|
||||
// opt := Options{ "foo", true, 2 }
|
||||
// v, _ := query.Values(opt)
|
||||
// fmt.Print(v.Encode()) // will output: "q=foo&all=true&page=2"
|
||||
//
|
||||
// The exact mapping between Go values and url.Values is described in the
|
||||
// documentation for the Values() function.
|
||||
package query
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var timeType = reflect.TypeOf(time.Time{})
|
||||
|
||||
var encoderType = reflect.TypeOf(new(Encoder)).Elem()
|
||||
|
||||
// Encoder is an interface implemented by any type that wishes to encode
|
||||
// itself into URL values in a non-standard way.
|
||||
type Encoder interface {
|
||||
EncodeValues(key string, v *url.Values) error
|
||||
}
|
||||
|
||||
// Values returns the url.Values encoding of v.
|
||||
//
|
||||
// Values expects to be passed a struct, and traverses it recursively using the
|
||||
// following encoding rules.
|
||||
//
|
||||
// Each exported struct field is encoded as a URL parameter unless
|
||||
//
|
||||
// - the field's tag is "-", or
|
||||
// - the field is empty and its tag specifies the "omitempty" option
|
||||
//
|
||||
// The empty values are false, 0, any nil pointer or interface value, any array
|
||||
// slice, map, or string of length zero, and any time.Time that returns true
|
||||
// for IsZero().
|
||||
//
|
||||
// The URL parameter name defaults to the struct field name but can be
|
||||
// specified in the struct field's tag value. The "url" key in the struct
|
||||
// field's tag value is the key name, followed by an optional comma and
|
||||
// options. For example:
|
||||
//
|
||||
// // Field is ignored by this package.
|
||||
// Field int `url:"-"`
|
||||
//
|
||||
// // Field appears as URL parameter "myName".
|
||||
// Field int `url:"myName"`
|
||||
//
|
||||
// // Field appears as URL parameter "myName" and the field is omitted if
|
||||
// // its value is empty
|
||||
// Field int `url:"myName,omitempty"`
|
||||
//
|
||||
// // Field appears as URL parameter "Field" (the default), but the field
|
||||
// // is skipped if empty. Note the leading comma.
|
||||
// Field int `url:",omitempty"`
|
||||
//
|
||||
// For encoding individual field values, the following type-dependent rules
|
||||
// apply:
|
||||
//
|
||||
// Boolean values default to encoding as the strings "true" or "false".
|
||||
// Including the "int" option signals that the field should be encoded as the
|
||||
// strings "1" or "0".
|
||||
//
|
||||
// time.Time values default to encoding as RFC3339 timestamps. Including the
|
||||
// "unix" option signals that the field should be encoded as a Unix time (see
|
||||
// time.Unix())
|
||||
//
|
||||
// Slice and Array values default to encoding as multiple URL values of the
|
||||
// same name. Including the "comma" option signals that the field should be
|
||||
// encoded as a single comma-delimited value. Including the "space" option
|
||||
// similarly encodes the value as a single space-delimited string. Including
|
||||
// the "semicolon" option will encode the value as a semicolon-delimited string.
|
||||
// Including the "brackets" option signals that the multiple URL values should
|
||||
// have "[]" appended to the value name. "numbered" will append a number to
|
||||
// the end of each incidence of the value name, example:
|
||||
// name0=value0&name1=value1, etc.
|
||||
//
|
||||
// Anonymous struct fields are usually encoded as if their inner exported
|
||||
// fields were fields in the outer struct, subject to the standard Go
|
||||
// visibility rules. An anonymous struct field with a name given in its URL
|
||||
// tag is treated as having that name, rather than being anonymous.
|
||||
//
|
||||
// Non-nil pointer values are encoded as the value pointed to.
|
||||
//
|
||||
// Nested structs are encoded including parent fields in value names for
|
||||
// scoping. e.g:
|
||||
//
|
||||
// "user[name]=acme&user[addr][postcode]=1234&user[addr][city]=SFO"
|
||||
//
|
||||
// All other values are encoded using their default string representation.
|
||||
//
|
||||
// Multiple fields that encode to the same URL parameter name will be included
|
||||
// as multiple URL values of the same name.
|
||||
func Values(v interface{}) (url.Values, error) {
|
||||
values := make(url.Values)
|
||||
val := reflect.ValueOf(v)
|
||||
for val.Kind() == reflect.Ptr {
|
||||
if val.IsNil() {
|
||||
return values, nil
|
||||
}
|
||||
val = val.Elem()
|
||||
}
|
||||
|
||||
if v == nil {
|
||||
return values, nil
|
||||
}
|
||||
|
||||
if val.Kind() != reflect.Struct {
|
||||
return nil, fmt.Errorf("query: Values() expects struct input. Got %v", val.Kind())
|
||||
}
|
||||
|
||||
err := reflectValue(values, val, "")
|
||||
return values, err
|
||||
}
|
||||
|
||||
// reflectValue populates the values parameter from the struct fields in val.
|
||||
// Embedded structs are followed recursively (using the rules defined in the
|
||||
// Values function documentation) breadth-first.
|
||||
func reflectValue(values url.Values, val reflect.Value, scope string) error {
|
||||
var embedded []reflect.Value
|
||||
|
||||
typ := val.Type()
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
sf := typ.Field(i)
|
||||
if sf.PkgPath != "" && !sf.Anonymous { // unexported
|
||||
continue
|
||||
}
|
||||
|
||||
sv := val.Field(i)
|
||||
tag := sf.Tag.Get("url")
|
||||
if tag == "-" {
|
||||
continue
|
||||
}
|
||||
name, opts := parseTag(tag)
|
||||
if name == "" {
|
||||
if sf.Anonymous && sv.Kind() == reflect.Struct {
|
||||
// save embedded struct for later processing
|
||||
embedded = append(embedded, sv)
|
||||
continue
|
||||
}
|
||||
|
||||
name = sf.Name
|
||||
}
|
||||
|
||||
if scope != "" {
|
||||
name = scope + "[" + name + "]"
|
||||
}
|
||||
|
||||
if opts.Contains("omitempty") && isEmptyValue(sv) {
|
||||
continue
|
||||
}
|
||||
|
||||
if sv.Type().Implements(encoderType) {
|
||||
if !reflect.Indirect(sv).IsValid() {
|
||||
sv = reflect.New(sv.Type().Elem())
|
||||
}
|
||||
|
||||
m := sv.Interface().(Encoder)
|
||||
if err := m.EncodeValues(name, &values); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if sv.Kind() == reflect.Slice || sv.Kind() == reflect.Array {
|
||||
var del byte
|
||||
if opts.Contains("comma") {
|
||||
del = ','
|
||||
} else if opts.Contains("space") {
|
||||
del = ' '
|
||||
} else if opts.Contains("semicolon") {
|
||||
del = ';'
|
||||
} else if opts.Contains("brackets") {
|
||||
name = name + "[]"
|
||||
}
|
||||
|
||||
if del != 0 {
|
||||
s := new(bytes.Buffer)
|
||||
first := true
|
||||
for i := 0; i < sv.Len(); i++ {
|
||||
if first {
|
||||
first = false
|
||||
} else {
|
||||
s.WriteByte(del)
|
||||
}
|
||||
s.WriteString(valueString(sv.Index(i), opts))
|
||||
}
|
||||
values.Add(name, s.String())
|
||||
} else {
|
||||
for i := 0; i < sv.Len(); i++ {
|
||||
k := name
|
||||
if opts.Contains("numbered") {
|
||||
k = fmt.Sprintf("%s%d", name, i)
|
||||
}
|
||||
values.Add(k, valueString(sv.Index(i), opts))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
for sv.Kind() == reflect.Ptr {
|
||||
if sv.IsNil() {
|
||||
break
|
||||
}
|
||||
sv = sv.Elem()
|
||||
}
|
||||
|
||||
if sv.Type() == timeType {
|
||||
values.Add(name, valueString(sv, opts))
|
||||
continue
|
||||
}
|
||||
|
||||
if sv.Kind() == reflect.Struct {
|
||||
reflectValue(values, sv, name)
|
||||
continue
|
||||
}
|
||||
|
||||
values.Add(name, valueString(sv, opts))
|
||||
}
|
||||
|
||||
for _, f := range embedded {
|
||||
if err := reflectValue(values, f, scope); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// valueString returns the string representation of a value.
|
||||
func valueString(v reflect.Value, opts tagOptions) string {
|
||||
for v.Kind() == reflect.Ptr {
|
||||
if v.IsNil() {
|
||||
return ""
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
|
||||
if v.Kind() == reflect.Bool && opts.Contains("int") {
|
||||
if v.Bool() {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
if v.Type() == timeType {
|
||||
t := v.Interface().(time.Time)
|
||||
if opts.Contains("unix") {
|
||||
return strconv.FormatInt(t.Unix(), 10)
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
return fmt.Sprint(v.Interface())
|
||||
}
|
||||
|
||||
// isEmptyValue checks if a value should be considered empty for the purposes
|
||||
// of omitting fields with the "omitempty" option.
|
||||
func isEmptyValue(v reflect.Value) bool {
|
||||
switch v.Kind() {
|
||||
case reflect.Array, reflect.Map, reflect.Slice, reflect.String:
|
||||
return v.Len() == 0
|
||||
case reflect.Bool:
|
||||
return !v.Bool()
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return v.Int() == 0
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||
return v.Uint() == 0
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return v.Float() == 0
|
||||
case reflect.Interface, reflect.Ptr:
|
||||
return v.IsNil()
|
||||
}
|
||||
|
||||
if v.Type() == timeType {
|
||||
return v.Interface().(time.Time).IsZero()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// tagOptions is the string following a comma in a struct field's "url" tag, or
|
||||
// the empty string. It does not include the leading comma.
|
||||
type tagOptions []string
|
||||
|
||||
// parseTag splits a struct field's url tag into its name and comma-separated
|
||||
// options.
|
||||
func parseTag(tag string) (string, tagOptions) {
|
||||
s := strings.Split(tag, ",")
|
||||
return s[0], s[1:]
|
||||
}
|
||||
|
||||
// Contains checks whether the tagOptions contains the specified option.
|
||||
func (o tagOptions) Contains(option string) bool {
|
||||
for _, s := range o {
|
||||
if s == option {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
|
@ -0,0 +1,373 @@
|
|||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
(a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
(b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. "License"
|
||||
means this document.
|
||||
|
||||
1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
(a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
(b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
--------------------------------
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
(a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
(a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
(b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
-------------------
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
(a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
(b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
---------------------------------------------------
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under this License. Except to the extent prohibited by statute
|
||||
or regulation, such description must be sufficiently detailed for a
|
||||
recipient of ordinary skill to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
--------------
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 6. Disclaimer of Warranty *
|
||||
* ------------------------- *
|
||||
* *
|
||||
* Covered Software is provided under this License on an "as is" *
|
||||
* basis, without warranty of any kind, either expressed, implied, or *
|
||||
* statutory, including, without limitation, warranties that the *
|
||||
* Covered Software is free of defects, merchantable, fit for a *
|
||||
* particular purpose or non-infringing. The entire risk as to the *
|
||||
* quality and performance of the Covered Software is with You. *
|
||||
* Should any Covered Software prove defective in any respect, You *
|
||||
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||
* essential part of this License. No use of any Covered Software is *
|
||||
* authorized under this License except under this disclaimer. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 7. Limitation of Liability *
|
||||
* -------------------------- *
|
||||
* *
|
||||
* Under no circumstances and under no legal theory, whether tort *
|
||||
* (including negligence), contract, or otherwise, shall any *
|
||||
* Contributor, or anyone who distributes Covered Software as *
|
||||
* permitted above, be liable to You for any direct, indirect, *
|
||||
* special, incidental, or consequential damages of any character *
|
||||
* including, without limitation, damages for lost profits, loss of *
|
||||
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||
* and all other commercial damages or losses, even if such party *
|
||||
* shall have been informed of the possibility of such damages. This *
|
||||
* limitation of liability shall not apply to liability for death or *
|
||||
* personal injury resulting from such party's negligence to the *
|
||||
* extent applicable law prohibits such limitation. Some *
|
||||
* jurisdictions do not allow the exclusion or limitation of *
|
||||
* incidental or consequential damages, so this exclusion and *
|
||||
* limitation may not apply to You. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
8. Litigation
|
||||
-------------
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
----------------
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
---------------------------
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
-------------------------------------------
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
---------------------------------------------------------
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
|
@ -0,0 +1,70 @@
|
|||
# go-slug
|
||||
|
||||
[![Build Status](https://travis-ci.org/hashicorp/go-slug.svg?branch=master)](https://travis-ci.org/hashicorp/go-slug)
|
||||
[![GitHub license](https://img.shields.io/github/license/hashicorp/go-slug.svg)](https://github.com/hashicorp/go-slug/blob/master/LICENSE)
|
||||
[![GoDoc](https://godoc.org/github.com/hashicorp/go-slug?status.svg)](https://godoc.org/github.com/hashicorp/go-slug)
|
||||
[![Go Report Card](https://goreportcard.com/badge/github.com/hashicorp/go-slug)](https://goreportcard.com/report/github.com/hashicorp/go-slug)
|
||||
[![GitHub issues](https://img.shields.io/github/issues/hashicorp/go-slug.svg)](https://github.com/hashicorp/go-slug/issues)
|
||||
|
||||
Package `go-slug` offers functions for packing and unpacking Terraform Enterprise
|
||||
compatible slugs. Slugs are gzip compressed tar files containing Terraform configuration files.
|
||||
|
||||
## Installation
|
||||
|
||||
Installation can be done with a normal `go get`:
|
||||
|
||||
```
|
||||
go get -u github.com/hashicorp/go-slug
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
For the complete usage of `go-slug`, see the full [package docs](https://godoc.org/github.com/hashicorp/go-slug).
|
||||
|
||||
## Example
|
||||
|
||||
Packing or unpacking a slug is pretty straight forward as shown in the
|
||||
following example:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"ioutil"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
slug "github.com/hashicorp/go-slug"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// First create a buffer for storing the slug.
|
||||
slug := bytes.NewBuffer(nil)
|
||||
|
||||
// Then call the Pack function with a directory path containing the
|
||||
// configuration files and an io.Writer to write the slug to.
|
||||
if _, err := Pack("test-fixtures/archive-dir", slug); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a directory to unpack the slug contents into.
|
||||
dst, err := ioutil.TempDir("", "slug")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dst)
|
||||
|
||||
// Unpacking a slug is done by calling the Unpack function with an
|
||||
// io.Reader to read the slug from and a directory path of an existing
|
||||
// directory to store the unpacked configuration files.
|
||||
if err := Unpack(slug, dst); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Issues and Contributing
|
||||
|
||||
If you find an issue with this package, please report an issue. If you'd like,
|
||||
we welcome any contributions. Fork this repository and submit a pull request.
|
|
@ -0,0 +1,215 @@
|
|||
package slug
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Meta provides detailed information about a slug.
|
||||
type Meta struct {
|
||||
// The list of files contained in the slug.
|
||||
Files []string
|
||||
|
||||
// Total size of the slug in bytes.
|
||||
Size int64
|
||||
}
|
||||
|
||||
// Pack creates a slug from a directory src, and writes the new
|
||||
// slug to w. Returns metadata about the slug and any error.
|
||||
func Pack(src string, w io.Writer) (*Meta, error) {
|
||||
// Gzip compress all the output data
|
||||
gzipW := gzip.NewWriter(w)
|
||||
|
||||
// Tar the file contents
|
||||
tarW := tar.NewWriter(gzipW)
|
||||
|
||||
// Track the metadata details as we go.
|
||||
meta := &Meta{}
|
||||
|
||||
// Walk the tree of files
|
||||
err := filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check the file type and if we need to write the body
|
||||
keepFile, writeBody := checkFileMode(info.Mode())
|
||||
if !keepFile {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get the relative path from the unpack directory
|
||||
subpath, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to get relative path for file %q: %v", path, err)
|
||||
}
|
||||
if subpath == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read the symlink target. We don't track the error because
|
||||
// it doesn't matter if there is an error.
|
||||
target, _ := os.Readlink(path)
|
||||
|
||||
// Build the file header for the tar entry
|
||||
header, err := tar.FileInfoHeader(info, target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed creating archive header for file %q: %v", path, err)
|
||||
}
|
||||
|
||||
// Modify the header to properly be the full subpath
|
||||
header.Name = subpath
|
||||
if info.IsDir() {
|
||||
header.Name += "/"
|
||||
}
|
||||
|
||||
// Write the header first to the archive.
|
||||
if err := tarW.WriteHeader(header); err != nil {
|
||||
return fmt.Errorf("Failed writing archive header for file %q: %v", path, err)
|
||||
}
|
||||
|
||||
// Account for the file in the list
|
||||
meta.Files = append(meta.Files, header.Name)
|
||||
|
||||
// Skip writing file data for certain file types (above).
|
||||
if !writeBody {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add the size since we are going to write the body.
|
||||
meta.Size += info.Size()
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed opening file %q for archiving: %v", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err = io.Copy(tarW, f); err != nil {
|
||||
return fmt.Errorf("Failed copying file %q to archive: %v", path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Flush the tar writer
|
||||
if err := tarW.Close(); err != nil {
|
||||
return nil, fmt.Errorf("Failed to close the tar archive: %v", err)
|
||||
}
|
||||
|
||||
// Flush the gzip writer
|
||||
if err := gzipW.Close(); err != nil {
|
||||
return nil, fmt.Errorf("Failed to close the gzip writer: %v", err)
|
||||
}
|
||||
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// Unpack is used to read and extract the contents of a slug to
|
||||
// directory dst. Returns any error.
|
||||
func Unpack(r io.Reader, dst string) error {
|
||||
// Decompress as we read
|
||||
uncompressed, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to uncompress slug: %v", err)
|
||||
}
|
||||
|
||||
// Untar as we read
|
||||
untar := tar.NewReader(uncompressed)
|
||||
|
||||
// Unpackage all the contents into the directory
|
||||
for {
|
||||
header, err := untar.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to untar slug: %v", err)
|
||||
}
|
||||
|
||||
// Get rid of absolute paths
|
||||
path := header.Name
|
||||
if path[0] == '/' {
|
||||
path = path[1:]
|
||||
}
|
||||
path = filepath.Join(dst, path)
|
||||
|
||||
// Make the directories to the path
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("Failed to create directory %q: %v", dir, err)
|
||||
}
|
||||
|
||||
// If we have a symlink, just link it.
|
||||
if header.Typeflag == tar.TypeSymlink {
|
||||
if err := os.Symlink(header.Linkname, path); err != nil {
|
||||
return fmt.Errorf("Failed creating symlink %q => %q: %v",
|
||||
path, header.Linkname, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Only unpack regular files from this point on
|
||||
if header.Typeflag == tar.TypeDir {
|
||||
continue
|
||||
} else if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeRegA {
|
||||
return fmt.Errorf("Failed creating %q: unsupported type %c", path,
|
||||
header.Typeflag)
|
||||
}
|
||||
|
||||
// Open a handle to the destination
|
||||
fh, err := os.Create(path)
|
||||
if err != nil {
|
||||
// This mimics tar's behavior wrt the tar file containing duplicate files
|
||||
// and it allowing later ones to clobber earlier ones even if the file
|
||||
// has perms that don't allow overwriting
|
||||
if os.IsPermission(err) {
|
||||
os.Chmod(path, 0600)
|
||||
fh, err = os.Create(path)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed creating file %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Copy the contents
|
||||
_, err = io.Copy(fh, untar)
|
||||
fh.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to copy slug file %q: %v", path, err)
|
||||
}
|
||||
|
||||
// Restore the file mode. We have to do this after writing the file,
|
||||
// since it is possible we have a read-only mode.
|
||||
mode := header.FileInfo().Mode()
|
||||
if err := os.Chmod(path, mode); err != nil {
|
||||
return fmt.Errorf("Failed setting permissions on %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkFileMode is used to examine an os.FileMode and determine if it should
|
||||
// be included in the archive, and if it has a data body which needs writing.
|
||||
func checkFileMode(m os.FileMode) (keep, body bool) {
|
||||
switch {
|
||||
case m.IsRegular():
|
||||
return true, true
|
||||
|
||||
case m.IsDir():
|
||||
return true, false
|
||||
|
||||
case m&os.ModeSymlink != 0:
|
||||
return true, false
|
||||
}
|
||||
|
||||
return false, false
|
||||
}
|
|
@ -0,0 +1,354 @@
|
|||
Mozilla Public License, version 2.0
|
||||
|
||||
1. Definitions
|
||||
|
||||
1.1. “Contributor”
|
||||
|
||||
means each individual or legal entity that creates, contributes to the
|
||||
creation of, or owns Covered Software.
|
||||
|
||||
1.2. “Contributor Version”
|
||||
|
||||
means the combination of the Contributions of others (if any) used by a
|
||||
Contributor and that particular Contributor’s Contribution.
|
||||
|
||||
1.3. “Contribution”
|
||||
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. “Covered Software”
|
||||
|
||||
means Source Code Form to which the initial Contributor has attached the
|
||||
notice in Exhibit A, the Executable Form of such Source Code Form, and
|
||||
Modifications of such Source Code Form, in each case including portions
|
||||
thereof.
|
||||
|
||||
1.5. “Incompatible With Secondary Licenses”
|
||||
means
|
||||
|
||||
a. that the initial Contributor has attached the notice described in
|
||||
Exhibit B to the Covered Software; or
|
||||
|
||||
b. that the Covered Software was made available under the terms of version
|
||||
1.1 or earlier of the License, but not also under the terms of a
|
||||
Secondary License.
|
||||
|
||||
1.6. “Executable Form”
|
||||
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. “Larger Work”
|
||||
|
||||
means a work that combines Covered Software with other material, in a separate
|
||||
file or files, that is not Covered Software.
|
||||
|
||||
1.8. “License”
|
||||
|
||||
means this document.
|
||||
|
||||
1.9. “Licensable”
|
||||
|
||||
means having the right to grant, to the maximum extent possible, whether at the
|
||||
time of the initial grant or subsequently, any and all of the rights conveyed by
|
||||
this License.
|
||||
|
||||
1.10. “Modifications”
|
||||
|
||||
means any of the following:
|
||||
|
||||
a. any file in Source Code Form that results from an addition to, deletion
|
||||
from, or modification of the contents of Covered Software; or
|
||||
|
||||
b. any new file in Source Code Form that contains any Covered Software.
|
||||
|
||||
1.11. “Patent Claims” of a Contributor
|
||||
|
||||
means any patent claim(s), including without limitation, method, process,
|
||||
and apparatus claims, in any patent Licensable by such Contributor that
|
||||
would be infringed, but for the grant of the License, by the making,
|
||||
using, selling, offering for sale, having made, import, or transfer of
|
||||
either its Contributions or its Contributor Version.
|
||||
|
||||
1.12. “Secondary License”
|
||||
|
||||
means either the GNU General Public License, Version 2.0, the GNU Lesser
|
||||
General Public License, Version 2.1, the GNU Affero General Public
|
||||
License, Version 3.0, or any later versions of those licenses.
|
||||
|
||||
1.13. “Source Code Form”
|
||||
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. “You” (or “Your”)
|
||||
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, “You” includes any entity that controls, is
|
||||
controlled by, or is under common control with You. For purposes of this
|
||||
definition, “control” means (a) the power, direct or indirect, to cause
|
||||
the direction or management of such entity, whether by contract or
|
||||
otherwise, or (b) ownership of more than fifty percent (50%) of the
|
||||
outstanding shares or beneficial ownership of such entity.
|
||||
|
||||
|
||||
2. License Grants and Conditions
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
a. under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or as
|
||||
part of a Larger Work; and
|
||||
|
||||
b. under Patent Claims of such Contributor to make, use, sell, offer for
|
||||
sale, have made, import, and otherwise transfer either its Contributions
|
||||
or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution become
|
||||
effective for each Contribution on the date the Contributor first distributes
|
||||
such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under this
|
||||
License. No additional rights or licenses will be implied from the distribution
|
||||
or licensing of Covered Software under this License. Notwithstanding Section
|
||||
2.1(b) above, no patent license is granted by a Contributor:
|
||||
|
||||
a. for any code that a Contributor has removed from Covered Software; or
|
||||
|
||||
b. for infringements caused by: (i) Your and any other third party’s
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
c. under Patent Claims infringed by Covered Software in the absence of its
|
||||
Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks, or
|
||||
logos of any Contributor (except as may be necessary to comply with the
|
||||
notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this License
|
||||
(see Section 10.2) or under the terms of a Secondary License (if permitted
|
||||
under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its Contributions
|
||||
are its original creation(s) or it has sufficient rights to grant the
|
||||
rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under applicable
|
||||
copyright doctrines of fair use, fair dealing, or other equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in
|
||||
Section 2.1.
|
||||
|
||||
|
||||
3. Responsibilities
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under the
|
||||
terms of this License. You must inform recipients that the Source Code Form
|
||||
of the Covered Software is governed by the terms of this License, and how
|
||||
they can obtain a copy of this License. You may not attempt to alter or
|
||||
restrict the recipients’ rights in the Source Code Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
a. such Covered Software must also be made available in Source Code Form,
|
||||
as described in Section 3.1, and You must inform recipients of the
|
||||
Executable Form how they can obtain a copy of such Source Code Form by
|
||||
reasonable means in a timely manner, at a charge no more than the cost
|
||||
of distribution to the recipient; and
|
||||
|
||||
b. You may distribute such Executable Form under the terms of this License,
|
||||
or sublicense it under different terms, provided that the license for
|
||||
the Executable Form does not attempt to limit or alter the recipients’
|
||||
rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for the
|
||||
Covered Software. If the Larger Work is a combination of Covered Software
|
||||
with a work governed by one or more Secondary Licenses, and the Covered
|
||||
Software is not Incompatible With Secondary Licenses, this License permits
|
||||
You to additionally distribute such Covered Software under the terms of
|
||||
such Secondary License(s), so that the recipient of the Larger Work may, at
|
||||
their option, further distribute the Covered Software under the terms of
|
||||
either this License or such Secondary License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices (including
|
||||
copyright notices, patent notices, disclaimers of warranty, or limitations
|
||||
of liability) contained within the Source Code Form of the Covered
|
||||
Software, except that You may alter any license notices to the extent
|
||||
required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on behalf
|
||||
of any Contributor. You must make it absolutely clear that any such
|
||||
warranty, support, indemnity, or liability obligation is offered by You
|
||||
alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this License
|
||||
with respect to some or all of the Covered Software due to statute, judicial
|
||||
order, or regulation then You must: (a) comply with the terms of this License
|
||||
to the maximum extent possible; and (b) describe the limitations and the code
|
||||
they affect. Such description must be placed in a text file included with all
|
||||
distributions of the Covered Software under this License. Except to the
|
||||
extent prohibited by statute or regulation, such description must be
|
||||
sufficiently detailed for a recipient of ordinary skill to be able to
|
||||
understand it.
|
||||
|
||||
5. Termination
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically if You
|
||||
fail to comply with any of its terms. However, if You become compliant,
|
||||
then the rights granted under this License from a particular Contributor
|
||||
are reinstated (a) provisionally, unless and until such Contributor
|
||||
explicitly and finally terminates Your grants, and (b) on an ongoing basis,
|
||||
if such Contributor fails to notify You of the non-compliance by some
|
||||
reasonable means prior to 60 days after You have come back into compliance.
|
||||
Moreover, Your grants from a particular Contributor are reinstated on an
|
||||
ongoing basis if such Contributor notifies You of the non-compliance by
|
||||
some reasonable means, this is the first time You have received notice of
|
||||
non-compliance with this License from such Contributor, and You become
|
||||
compliant prior to 30 days after Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions, counter-claims,
|
||||
and cross-claims) alleging that a Contributor Version directly or
|
||||
indirectly infringes any patent, then the rights granted to You by any and
|
||||
all Contributors for the Covered Software under Section 2.1 of this License
|
||||
shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user
|
||||
license agreements (excluding distributors and resellers) which have been
|
||||
validly granted by You or Your distributors under this License prior to
|
||||
termination shall survive termination.
|
||||
|
||||
6. Disclaimer of Warranty
|
||||
|
||||
Covered Software is provided under this License on an “as is” basis, without
|
||||
warranty of any kind, either expressed, implied, or statutory, including,
|
||||
without limitation, warranties that the Covered Software is free of defects,
|
||||
merchantable, fit for a particular purpose or non-infringing. The entire
|
||||
risk as to the quality and performance of the Covered Software is with You.
|
||||
Should any Covered Software prove defective in any respect, You (not any
|
||||
Contributor) assume the cost of any necessary servicing, repair, or
|
||||
correction. This disclaimer of warranty constitutes an essential part of this
|
||||
License. No use of any Covered Software is authorized under this License
|
||||
except under this disclaimer.
|
||||
|
||||
7. Limitation of Liability
|
||||
|
||||
Under no circumstances and under no legal theory, whether tort (including
|
||||
negligence), contract, or otherwise, shall any Contributor, or anyone who
|
||||
distributes Covered Software as permitted above, be liable to You for any
|
||||
direct, indirect, special, incidental, or consequential damages of any
|
||||
character including, without limitation, damages for lost profits, loss of
|
||||
goodwill, work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses, even if such party shall have been
|
||||
informed of the possibility of such damages. This limitation of liability
|
||||
shall not apply to liability for death or personal injury resulting from such
|
||||
party’s negligence to the extent applicable law prohibits such limitation.
|
||||
Some jurisdictions do not allow the exclusion or limitation of incidental or
|
||||
consequential damages, so this exclusion and limitation may not apply to You.
|
||||
|
||||
8. Litigation
|
||||
|
||||
Any litigation relating to this License may be brought only in the courts of
|
||||
a jurisdiction where the defendant maintains its principal place of business
|
||||
and such litigation shall be governed by laws of that jurisdiction, without
|
||||
reference to its conflict-of-law provisions. Nothing in this Section shall
|
||||
prevent a party’s ability to bring cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
|
||||
This License represents the complete agreement concerning the subject matter
|
||||
hereof. If any provision of this License is held to be unenforceable, such
|
||||
provision shall be reformed only to the extent necessary to make it
|
||||
enforceable. Any law or regulation which provides that the language of a
|
||||
contract shall be construed against the drafter shall not be used to construe
|
||||
this License against a Contributor.
|
||||
|
||||
|
||||
10. Versions of the License
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version of
|
||||
the License under which You originally received the Covered Software, or
|
||||
under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a modified
|
||||
version of this License if you rename the license and remove any
|
||||
references to the name of the license steward (except to note that such
|
||||
modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
|
||||
This Source Code Form is subject to the
|
||||
terms of the Mozilla Public License, v.
|
||||
2.0. If a copy of the MPL was not
|
||||
distributed with this file, You can
|
||||
obtain one at
|
||||
http://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular file, then
|
||||
You may include the notice in a location (such as a LICENSE file in a relevant
|
||||
directory) where a recipient would be likely to look for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - “Incompatible With Secondary Licenses” Notice
|
||||
|
||||
This Source Code Form is “Incompatible
|
||||
With Secondary Licenses”, as defined by
|
||||
the Mozilla Public License, v. 2.0.
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
Terraform Enterprise Go Client
|
||||
==============================
|
||||
|
||||
[![Build Status](https://travis-ci.org/hashicorp/go-tfe.svg?branch=master)](https://travis-ci.org/hashicorp/go-tfe)
|
||||
[![GitHub license](https://img.shields.io/github/license/hashicorp/go-tfe.svg)](https://github.com/hashicorp/go-tfe/blob/master/LICENSE)
|
||||
[![GoDoc](https://godoc.org/github.com/hashicorp/go-tfe?status.svg)](https://godoc.org/github.com/hashicorp/go-tfe)
|
||||
[![Go Report Card](https://goreportcard.com/badge/github.com/hashicorp/go-tfe)](https://goreportcard.com/report/github.com/hashicorp/go-tfe)
|
||||
[![GitHub issues](https://img.shields.io/github/issues/hashicorp/go-tfe.svg)](https://github.com/hashicorp/go-tfe/issues)
|
||||
|
||||
This is an API client for [Terraform Enterprise](https://www.hashicorp.com/products/terraform).
|
||||
|
||||
## NOTE
|
||||
|
||||
The Terraform Enterprise API endpoints are in beta and are subject to change!
|
||||
So that means this API client is also in beta and is also subject to change. We
|
||||
will indicate any breaking changes by releasing new versions. Until the release
|
||||
of v1.0, any minor version changes will indicate possible breaking changes. Patch
|
||||
version changes will be used for both bugfixes and non-breaking changes.
|
||||
|
||||
## Coverage
|
||||
|
||||
Currently the following endpoints are supported:
|
||||
|
||||
- [x] [Accounts](https://www.terraform.io/docs/enterprise/api/account.html)
|
||||
- [x] [Configuration Versions](https://www.terraform.io/docs/enterprise/api/configuration-versions.html)
|
||||
- [x] [OAuth Clients](https://www.terraform.io/docs/enterprise/api/oauth-clients.html)
|
||||
- [x] [OAuth Tokens](https://www.terraform.io/docs/enterprise/api/oauth-tokens.html)
|
||||
- [x] [Organizations](https://www.terraform.io/docs/enterprise/api/organizations.html)
|
||||
- [x] [Organization Tokens](https://www.terraform.io/docs/enterprise/api/organization-tokens.html)
|
||||
- [x] [Policies](https://www.terraform.io/docs/enterprise/api/policies.html)
|
||||
- [x] [Policy Checks](https://www.terraform.io/docs/enterprise/api/policy-checks.html)
|
||||
- [ ] [Registry Modules](https://www.terraform.io/docs/enterprise/api/modules.html)
|
||||
- [x] [Runs](https://www.terraform.io/docs/enterprise/api/run.html)
|
||||
- [x] [SSH Keys](https://www.terraform.io/docs/enterprise/api/ssh-keys.html)
|
||||
- [x] [State Versions](https://www.terraform.io/docs/enterprise/api/state-versions.html)
|
||||
- [x] [Team Access](https://www.terraform.io/docs/enterprise/api/team-access.html)
|
||||
- [x] [Team Memberships](https://www.terraform.io/docs/enterprise/api/team-members.html)
|
||||
- [x] [Team Tokens](https://www.terraform.io/docs/enterprise/api/team-tokens.html)
|
||||
- [x] [Teams](https://www.terraform.io/docs/enterprise/api/teams.html)
|
||||
- [x] [Variables](https://www.terraform.io/docs/enterprise/api/variables.html)
|
||||
- [x] [Workspaces](https://www.terraform.io/docs/enterprise/api/workspaces.html)
|
||||
- [ ] [Admin](https://www.terraform.io/docs/enterprise/api/admin/index.html)
|
||||
|
||||
## Installation
|
||||
|
||||
Installation can be done with a normal `go get`:
|
||||
|
||||
```
|
||||
go get -u github.com/hashicorp/go-tfe
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
For complete usage of the API client, see the full [package docs](https://godoc.org/github.com/hashicorp/go-tfe).
|
||||
|
||||
## Usage
|
||||
|
||||
```go
|
||||
import tfe "github.com/hashicorp/go-tfe"
|
||||
```
|
||||
|
||||
Construct a new TFE client, then use the various endpoints on the client to
|
||||
access different parts of the Terraform Enterprise API. For example, to list
|
||||
all organizations:
|
||||
|
||||
```go
|
||||
config := &tfe.Config{
|
||||
Token: "insert-your-token-here",
|
||||
}
|
||||
|
||||
client, err := tfe.NewClient(config)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
orgs, err := client.Organizations.List(context.Background(), OrganizationListOptions{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
The [examples](https://github.com/hashicorp/go-tfe/tree/master/examples) directory
|
||||
contains a couple of examples. One of which is listed here as well:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
tfe "github.com/hashicorp/go-tfe"
|
||||
)
|
||||
|
||||
func main() {
|
||||
config := &tfe.Config{
|
||||
Token: "insert-your-token-here",
|
||||
}
|
||||
|
||||
client, err := tfe.NewClient(config)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a context
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a new organization
|
||||
options := tfe.OrganizationCreateOptions{
|
||||
Name: tfe.String("example"),
|
||||
Email: tfe.String("info@example.com"),
|
||||
}
|
||||
|
||||
org, err := client.Organizations.Create(ctx, options)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Delete an organization
|
||||
err = client.Organizations.Delete(ctx, org.Name)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Issues and Contributing
|
||||
|
||||
If you find an issue with this package, please report an issue. If you'd like,
|
||||
we welcome any contributions. Fork this repository and submit a pull request.
|
|
@ -0,0 +1,193 @@
|
|||
package tfe
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
slug "github.com/hashicorp/go-slug"
|
||||
)
|
||||
|
||||
// Compile-time proof of interface implementation.
|
||||
var _ ConfigurationVersions = (*configurationVersions)(nil)
|
||||
|
||||
// ConfigurationVersions describes all the configuration version related
|
||||
// methods that the Terraform Enterprise API supports.
|
||||
//
|
||||
// TFE API docs:
|
||||
// https://www.terraform.io/docs/enterprise/api/configuration-versions.html
|
||||
type ConfigurationVersions interface {
|
||||
// List returns all configuration versions of a workspace.
|
||||
List(ctx context.Context, workspaceID string, options ConfigurationVersionListOptions) ([]*ConfigurationVersion, error)
|
||||
|
||||
// Create is used to create a new configuration version. The created
|
||||
// configuration version will be usable once data is uploaded to it.
|
||||
Create(ctx context.Context, workspaceID string, options ConfigurationVersionCreateOptions) (*ConfigurationVersion, error)
|
||||
|
||||
// Read a configuration version by its ID.
|
||||
Read(ctx context.Context, cvID string) (*ConfigurationVersion, error)
|
||||
|
||||
// Upload packages and uploads Terraform configuration files. It requires
|
||||
// the upload URL from a configuration version and the full path to the
|
||||
// configuration files on disk.
|
||||
Upload(ctx context.Context, url string, path string) error
|
||||
}
|
||||
|
||||
// configurationVersions implements ConfigurationVersions.
|
||||
type configurationVersions struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// ConfigurationStatus represents a configuration version status.
|
||||
type ConfigurationStatus string
|
||||
|
||||
//List all available configuration version statuses.
|
||||
const (
|
||||
ConfigurationErrored ConfigurationStatus = "errored"
|
||||
ConfigurationPending ConfigurationStatus = "pending"
|
||||
ConfigurationUploaded ConfigurationStatus = "uploaded"
|
||||
)
|
||||
|
||||
// ConfigurationSource represents a source of a configuration version.
|
||||
type ConfigurationSource string
|
||||
|
||||
// List all available configuration version sources.
|
||||
const (
|
||||
ConfigurationSourceAPI ConfigurationSource = "tfe-api"
|
||||
ConfigurationSourceBitbucket ConfigurationSource = "bitbucket"
|
||||
ConfigurationSourceGithub ConfigurationSource = "github"
|
||||
ConfigurationSourceGitlab ConfigurationSource = "gitlab"
|
||||
ConfigurationSourceTerraform ConfigurationSource = "terraform"
|
||||
)
|
||||
|
||||
// ConfigurationVersion is a representation of an uploaded or ingressed
|
||||
// Terraform configuration in TFE. A workspace must have at least one
|
||||
// configuration version before any runs may be queued on it.
|
||||
type ConfigurationVersion struct {
|
||||
ID string `jsonapi:"primary,configuration-versions"`
|
||||
AutoQueueRuns bool `jsonapi:"attr,auto-queue-runs"`
|
||||
Error string `jsonapi:"attr,error"`
|
||||
ErrorMessage string `jsonapi:"attr,error-message"`
|
||||
Source ConfigurationSource `jsonapi:"attr,source"`
|
||||
Speculative bool `jsonapi:"attr,speculative "`
|
||||
Status ConfigurationStatus `jsonapi:"attr,status"`
|
||||
StatusTimestamps *CVStatusTimestamps `jsonapi:"attr,status-timestamps"`
|
||||
UploadURL string `jsonapi:"attr,upload-url"`
|
||||
}
|
||||
|
||||
// CVStatusTimestamps holds the timestamps for individual configuration version
|
||||
// statuses.
|
||||
type CVStatusTimestamps struct {
|
||||
FinishedAt time.Time `json:"finished-at"`
|
||||
QueuedAt time.Time `json:"queued-at"`
|
||||
StartedAt time.Time `json:"started-at"`
|
||||
}
|
||||
|
||||
// ConfigurationVersionListOptions represents the options for listing
|
||||
// configuration versions.
|
||||
type ConfigurationVersionListOptions struct {
|
||||
ListOptions
|
||||
}
|
||||
|
||||
// List returns all configuration versions of a workspace.
|
||||
func (s *configurationVersions) List(ctx context.Context, workspaceID string, options ConfigurationVersionListOptions) ([]*ConfigurationVersion, error) {
|
||||
if !validStringID(&workspaceID) {
|
||||
return nil, errors.New("Invalid value for workspace ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("workspaces/%s/configuration-versions", url.QueryEscape(workspaceID))
|
||||
req, err := s.client.newRequest("GET", u, &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cvs []*ConfigurationVersion
|
||||
err = s.client.do(ctx, req, &cvs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cvs, nil
|
||||
}
|
||||
|
||||
// ConfigurationVersionCreateOptions represents the options for creating a
|
||||
// configuration version.
|
||||
type ConfigurationVersionCreateOptions struct {
|
||||
// For internal use only!
|
||||
ID string `jsonapi:"primary,configuration-versions"`
|
||||
|
||||
// When true, runs are queued automatically when the configuration version
|
||||
// is uploaded.
|
||||
AutoQueueRuns *bool `jsonapi:"attr,auto-queue-runs,omitempty"`
|
||||
|
||||
// When true, this configuration version can only be used for planning.
|
||||
Speculative *bool `jsonapi:"attr,speculative,omitempty"`
|
||||
}
|
||||
|
||||
// Create is used to create a new configuration version. The created
|
||||
// configuration version will be usable once data is uploaded to it.
|
||||
func (s *configurationVersions) Create(ctx context.Context, workspaceID string, options ConfigurationVersionCreateOptions) (*ConfigurationVersion, error) {
|
||||
if !validStringID(&workspaceID) {
|
||||
return nil, errors.New("Invalid value for workspace ID")
|
||||
}
|
||||
|
||||
// Make sure we don't send a user provided ID.
|
||||
options.ID = ""
|
||||
|
||||
u := fmt.Sprintf("workspaces/%s/configuration-versions", url.QueryEscape(workspaceID))
|
||||
req, err := s.client.newRequest("POST", u, &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cv := &ConfigurationVersion{}
|
||||
err = s.client.do(ctx, req, cv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cv, nil
|
||||
}
|
||||
|
||||
// Read a configuration version by its ID.
|
||||
func (s *configurationVersions) Read(ctx context.Context, cvID string) (*ConfigurationVersion, error) {
|
||||
if !validStringID(&cvID) {
|
||||
return nil, errors.New("Invalid value for configuration version ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("configuration-versions/%s", url.QueryEscape(cvID))
|
||||
req, err := s.client.newRequest("GET", u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cv := &ConfigurationVersion{}
|
||||
err = s.client.do(ctx, req, cv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cv, nil
|
||||
}
|
||||
|
||||
// Upload packages and uploads Terraform configuration files. It requires the
|
||||
// upload URL from a configuration version and the path to the configuration
|
||||
// files on disk.
|
||||
func (s *configurationVersions) Upload(ctx context.Context, url, path string) error {
|
||||
body := bytes.NewBuffer(nil)
|
||||
|
||||
_, err := slug.Pack(path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := s.client.newRequest("PUT", url, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.client.do(ctx, req, nil)
|
||||
}
|
|
@ -0,0 +1,127 @@
|
|||
package tfe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Compile-time proof of interface implementation.
|
||||
var _ OAuthClients = (*oAuthClients)(nil)
|
||||
|
||||
// OAuthClients describes all the OAuth client related methods that the
|
||||
// Terraform Enterprise API supports.
|
||||
//
|
||||
// TFE API docs:
|
||||
// https://www.terraform.io/docs/enterprise/api/oauth-clients.html
|
||||
type OAuthClients interface {
|
||||
// Create a VCS connection between an organization and a VCS provider.
|
||||
Create(ctx context.Context, organization string, options OAuthClientCreateOptions) (*OAuthClient, error)
|
||||
}
|
||||
|
||||
// oAuthClients implements OAuthClients.
|
||||
type oAuthClients struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// ServiceProviderType represents a VCS type.
|
||||
type ServiceProviderType string
|
||||
|
||||
// List of available VCS types.
|
||||
const (
|
||||
ServiceProviderBitbucket ServiceProviderType = "bitbucket_hosted"
|
||||
ServiceProviderBitbucketServer ServiceProviderType = "bitbucket_server"
|
||||
ServiceProviderGithub ServiceProviderType = "github"
|
||||
ServiceProviderGithubEE ServiceProviderType = "github_enterprise"
|
||||
ServiceProviderGitlab ServiceProviderType = "gitlab_hosted"
|
||||
ServiceProviderGitlabCE ServiceProviderType = "gitlab_community_edition"
|
||||
ServiceProviderGitlabEE ServiceProviderType = "gitlab_enterprise_edition"
|
||||
)
|
||||
|
||||
// OAuthClient represents a connection between an organization and a VCS
|
||||
// provider.
|
||||
type OAuthClient struct {
|
||||
ID string `jsonapi:"primary,oauth-clients"`
|
||||
APIURL string `jsonapi:"attr,api-url"`
|
||||
CallbackURL string `jsonapi:"attr,callback-url"`
|
||||
ConnectPath string `jsonapi:"attr,connect-path"`
|
||||
CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"`
|
||||
HTTPURL string `jsonapi:"attr,http-url"`
|
||||
Key string `jsonapi:"attr,key"`
|
||||
RSAPublicKey string `jsonapi:"attr,rsa-public-key"`
|
||||
ServiceProvider ServiceProviderType `jsonapi:"attr,service-provider"`
|
||||
ServiceProviderName string `jsonapi:"attr,service-provider-display-name"`
|
||||
|
||||
// Relations
|
||||
Organization *Organization `jsonapi:"relation,organization"`
|
||||
OAuthToken []*OAuthToken `jsonapi:"relation,oauth-token"`
|
||||
}
|
||||
|
||||
// OAuthClientCreateOptions represents the options for creating an OAuth client.
|
||||
type OAuthClientCreateOptions struct {
|
||||
// For internal use only!
|
||||
ID string `jsonapi:"primary,oauth-clients"`
|
||||
|
||||
// The base URL of your VCS provider's API.
|
||||
APIURL *string `jsonapi:"attr,api-url"`
|
||||
|
||||
// The homepage of your VCS provider.
|
||||
HTTPURL *string `jsonapi:"attr,http-url"`
|
||||
|
||||
// The key you were given by your VCS provider.
|
||||
Key *string `jsonapi:"attr,key"`
|
||||
|
||||
// The secret you were given by your VCS provider.
|
||||
Secret *string `jsonapi:"attr,secret"`
|
||||
|
||||
// The VCS provider being connected with.
|
||||
ServiceProvider *ServiceProviderType `jsonapi:"attr,service-provider"`
|
||||
}
|
||||
|
||||
func (o OAuthClientCreateOptions) valid() error {
|
||||
if !validString(o.APIURL) {
|
||||
return errors.New("APIURL is required")
|
||||
}
|
||||
if !validString(o.HTTPURL) {
|
||||
return errors.New("HTTPURL is required")
|
||||
}
|
||||
if !validString(o.Key) {
|
||||
return errors.New("Key is required")
|
||||
}
|
||||
if !validString(o.Secret) {
|
||||
return errors.New("Secret is required")
|
||||
}
|
||||
if o.ServiceProvider == nil {
|
||||
return errors.New("ServiceProvider is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a VCS connection between an organization and a VCS provider.
|
||||
func (s *oAuthClients) Create(ctx context.Context, organization string, options OAuthClientCreateOptions) (*OAuthClient, error) {
|
||||
if !validStringID(&organization) {
|
||||
return nil, errors.New("Invalid value for organization")
|
||||
}
|
||||
if err := options.valid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Make sure we don't send a user provided ID.
|
||||
options.ID = ""
|
||||
|
||||
u := fmt.Sprintf("organizations/%s/oauth-clients", url.QueryEscape(organization))
|
||||
req, err := s.client.newRequest("POST", u, &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
oc := &OAuthClient{}
|
||||
err = s.client.do(ctx, req, oc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return oc, nil
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
package tfe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Compile-time proof of interface implementation.
|
||||
var _ OAuthTokens = (*oAuthTokens)(nil)
|
||||
|
||||
// OAuthTokens describes all the OAuth token related methods that the
|
||||
// Terraform Enterprise API supports.
|
||||
//
|
||||
// TFE API docs:
|
||||
// https://www.terraform.io/docs/enterprise/api/oauth-tokens.html
|
||||
type OAuthTokens interface {
|
||||
// List all the OAuth Tokens for a given organization.
|
||||
List(ctx context.Context, organization string) ([]*OAuthToken, error)
|
||||
}
|
||||
|
||||
// oAuthTokens implements OAuthTokens.
|
||||
type oAuthTokens struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// OAuthToken represents a VCS configuration including the associated
|
||||
// OAuth token
|
||||
type OAuthToken struct {
|
||||
ID string `jsonapi:"primary,oauth-tokens"`
|
||||
UID string `jsonapi:"attr,uid"`
|
||||
CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"`
|
||||
HasSSHKey bool `jsonapi:"attr,has-ssh-key"`
|
||||
ServiceProviderUser string `jsonapi:"attr,service-provider-user"`
|
||||
|
||||
// Relations
|
||||
OAuthClient *OAuthClient `jsonapi:"relation,oauth-client"`
|
||||
}
|
||||
|
||||
// List all the OAuth Tokens for a given organization.
|
||||
func (s *oAuthTokens) List(ctx context.Context, organization string) ([]*OAuthToken, error) {
|
||||
if !validStringID(&organization) {
|
||||
return nil, errors.New("Invalid value for organization")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("organizations/%s/oauth-tokens", url.QueryEscape(organization))
|
||||
req, err := s.client.newRequest("GET", u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var ots []*OAuthToken
|
||||
err = s.client.do(ctx, req, &ots)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ots, nil
|
||||
}
|
|
@ -0,0 +1,238 @@
|
|||
package tfe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Compile-time proof of interface implementation.
|
||||
var _ Organizations = (*organizations)(nil)
|
||||
|
||||
// Organizations describes all the organization related methods that the
|
||||
// Terraform Enterprise API supports.
|
||||
//
|
||||
// TFE API docs:
|
||||
// https://www.terraform.io/docs/enterprise/api/organizations.html
|
||||
type Organizations interface {
|
||||
// List all the organizations visible to the current user.
|
||||
List(ctx context.Context, options OrganizationListOptions) ([]*Organization, error)
|
||||
|
||||
// Create a new organization with the given options.
|
||||
Create(ctx context.Context, options OrganizationCreateOptions) (*Organization, error)
|
||||
|
||||
// Read an organization by its name.
|
||||
Read(ctx context.Context, organization string) (*Organization, error)
|
||||
|
||||
// Update attributes of an existing organization.
|
||||
Update(ctx context.Context, organization string, options OrganizationUpdateOptions) (*Organization, error)
|
||||
|
||||
// Delete an organization by its name.
|
||||
Delete(ctx context.Context, organization string) error
|
||||
}
|
||||
|
||||
// organizations implements Organizations.
|
||||
type organizations struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// AuthPolicyType represents an authentication policy type.
|
||||
type AuthPolicyType string
|
||||
|
||||
// List of available authentication policies.
|
||||
const (
|
||||
AuthPolicyPassword AuthPolicyType = "password"
|
||||
AuthPolicyTwoFactor AuthPolicyType = "two_factor_mandatory"
|
||||
)
|
||||
|
||||
// EnterprisePlanType represents an enterprise plan type.
|
||||
type EnterprisePlanType string
|
||||
|
||||
// List of available enterprise plan types.
|
||||
const (
|
||||
EnterprisePlanDisabled EnterprisePlanType = "disabled"
|
||||
EnterprisePlanPremium EnterprisePlanType = "premium"
|
||||
EnterprisePlanPro EnterprisePlanType = "pro"
|
||||
EnterprisePlanTrial EnterprisePlanType = "trial"
|
||||
)
|
||||
|
||||
// Organization represents a Terraform Enterprise organization.
|
||||
type Organization struct {
|
||||
Name string `jsonapi:"primary,organizations"`
|
||||
CollaboratorAuthPolicy AuthPolicyType `jsonapi:"attr,collaborator-auth-policy"`
|
||||
CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"`
|
||||
Email string `jsonapi:"attr,email"`
|
||||
EnterprisePlan EnterprisePlanType `jsonapi:"attr,enterprise-plan"`
|
||||
OwnersTeamSamlRoleID string `jsonapi:"attr,owners-team-saml-role-id"`
|
||||
Permissions *OrganizationPermissions `jsonapi:"attr,permissions"`
|
||||
SAMLEnabled bool `jsonapi:"attr,saml-enabled"`
|
||||
SessionRemember int `jsonapi:"attr,session-remember"`
|
||||
SessionTimeout int `jsonapi:"attr,session-timeout"`
|
||||
TrialExpiresAt time.Time `jsonapi:"attr,trial-expires-at,iso8601"`
|
||||
TwoFactorConformant bool `jsonapi:"attr,two-factor-conformant"`
|
||||
}
|
||||
|
||||
// OrganizationPermissions represents the organization permissions.
|
||||
type OrganizationPermissions struct {
|
||||
CanCreateTeam bool `json:"can-create-team"`
|
||||
CanCreateWorkspace bool `json:"can-create-workspace"`
|
||||
CanCreateWorkspaceMigration bool `json:"can-create-workspace-migration"`
|
||||
CanDestroy bool `json:"can-destroy"`
|
||||
CanTraverse bool `json:"can-traverse"`
|
||||
CanUpdate bool `json:"can-update"`
|
||||
CanUpdateAPIToken bool `json:"can-update-api-token"`
|
||||
CanUpdateOAuth bool `json:"can-update-oauth"`
|
||||
CanUpdateSentinel bool `json:"can-update-sentinel"`
|
||||
}
|
||||
|
||||
// OrganizationListOptions represents the options for listing organizations.
|
||||
type OrganizationListOptions struct {
|
||||
ListOptions
|
||||
}
|
||||
|
||||
// List all the organizations visible to the current user.
|
||||
func (s *organizations) List(ctx context.Context, options OrganizationListOptions) ([]*Organization, error) {
|
||||
req, err := s.client.newRequest("GET", "organizations", &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var orgs []*Organization
|
||||
err = s.client.do(ctx, req, &orgs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return orgs, nil
|
||||
}
|
||||
|
||||
// OrganizationCreateOptions represents the options for creating an organization.
|
||||
type OrganizationCreateOptions struct {
|
||||
// For internal use only!
|
||||
ID string `jsonapi:"primary,organizations"`
|
||||
|
||||
// Name of the organization.
|
||||
Name *string `jsonapi:"attr,name"`
|
||||
|
||||
// Admin email address.
|
||||
Email *string `jsonapi:"attr,email"`
|
||||
}
|
||||
|
||||
func (o OrganizationCreateOptions) valid() error {
|
||||
if !validString(o.Name) {
|
||||
return errors.New("Name is required")
|
||||
}
|
||||
if !validStringID(o.Name) {
|
||||
return errors.New("Invalid value for name")
|
||||
}
|
||||
if !validString(o.Email) {
|
||||
return errors.New("Email is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a new organization with the given options.
|
||||
func (s *organizations) Create(ctx context.Context, options OrganizationCreateOptions) (*Organization, error) {
|
||||
if err := options.valid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Make sure we don't send a user provided ID.
|
||||
options.ID = ""
|
||||
|
||||
req, err := s.client.newRequest("POST", "organizations", &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
org := &Organization{}
|
||||
err = s.client.do(ctx, req, org)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return org, nil
|
||||
}
|
||||
|
||||
// Read an organization by its name.
|
||||
func (s *organizations) Read(ctx context.Context, organization string) (*Organization, error) {
|
||||
if !validStringID(&organization) {
|
||||
return nil, errors.New("Invalid value for organization")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("organizations/%s", url.QueryEscape(organization))
|
||||
req, err := s.client.newRequest("GET", u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
org := &Organization{}
|
||||
err = s.client.do(ctx, req, org)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return org, nil
|
||||
}
|
||||
|
||||
// OrganizationUpdateOptions represents the options for updating an organization.
|
||||
type OrganizationUpdateOptions struct {
|
||||
// For internal use only!
|
||||
ID string `jsonapi:"primary,organizations"`
|
||||
|
||||
// New name for the organization.
|
||||
Name *string `jsonapi:"attr,name,omitempty"`
|
||||
|
||||
// New admin email address.
|
||||
Email *string `jsonapi:"attr,email,omitempty"`
|
||||
|
||||
// Session expiration (minutes).
|
||||
SessionRemember *int `jsonapi:"attr,session-remember,omitempty"`
|
||||
|
||||
// Session timeout after inactivity (minutes).
|
||||
SessionTimeout *int `jsonapi:"attr,session-timeout,omitempty"`
|
||||
|
||||
// Authentication policy.
|
||||
CollaboratorAuthPolicy *AuthPolicyType `jsonapi:"attr,collaborator-auth-policy,omitempty"`
|
||||
}
|
||||
|
||||
// Update attributes of an existing organization.
|
||||
func (s *organizations) Update(ctx context.Context, organization string, options OrganizationUpdateOptions) (*Organization, error) {
|
||||
if !validStringID(&organization) {
|
||||
return nil, errors.New("Invalid value for organization")
|
||||
}
|
||||
|
||||
// Make sure we don't send a user provided ID.
|
||||
options.ID = ""
|
||||
|
||||
u := fmt.Sprintf("organizations/%s", url.QueryEscape(organization))
|
||||
req, err := s.client.newRequest("PATCH", u, &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
org := &Organization{}
|
||||
err = s.client.do(ctx, req, org)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return org, nil
|
||||
}
|
||||
|
||||
// Delete an organization by its name.
|
||||
func (s *organizations) Delete(ctx context.Context, organization string) error {
|
||||
if !validStringID(&organization) {
|
||||
return errors.New("Invalid value for organization")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("organizations/%s", url.QueryEscape(organization))
|
||||
req, err := s.client.newRequest("DELETE", u, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.client.do(ctx, req, nil)
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
package tfe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Compile-time proof of interface implementation.
|
||||
var _ OrganizationTokens = (*organizationTokens)(nil)
|
||||
|
||||
// OrganizationTokens describes all the organization token related methods
|
||||
// that the Terraform Enterprise API supports.
|
||||
//
|
||||
// TFE API docs:
|
||||
// https://www.terraform.io/docs/enterprise/api/organization-tokens.html
|
||||
type OrganizationTokens interface {
|
||||
// Generate a new organization token, replacing any existing token.
|
||||
Generate(ctx context.Context, organization string) (*OrganizationToken, error)
|
||||
|
||||
// Delete an organization token.
|
||||
Delete(ctx context.Context, organization string) error
|
||||
}
|
||||
|
||||
// organizationTokens implements OrganizationTokens.
|
||||
type organizationTokens struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// OrganizationToken represents a Terraform Enterprise organization token.
|
||||
type OrganizationToken struct {
|
||||
ID string `jsonapi:"primary,authentication-tokens"`
|
||||
CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"`
|
||||
Description string `jsonapi:"attr,description"`
|
||||
LastUsedAt time.Time `jsonapi:"attr,last-used-at,iso8601"`
|
||||
Token string `jsonapi:"attr,token"`
|
||||
}
|
||||
|
||||
// Generate a new organization token, replacing any existing token.
|
||||
func (s *organizationTokens) Generate(ctx context.Context, organization string) (*OrganizationToken, error) {
|
||||
if !validStringID(&organization) {
|
||||
return nil, errors.New("Invalid value for organization")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("organizations/%s/authentication-token", url.QueryEscape(organization))
|
||||
req, err := s.client.newRequest("POST", u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ot := &OrganizationToken{}
|
||||
err = s.client.do(ctx, req, ot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ot, err
|
||||
}
|
||||
|
||||
// Delete an organization token.
|
||||
func (s *organizationTokens) Delete(ctx context.Context, organization string) error {
|
||||
if !validStringID(&organization) {
|
||||
return errors.New("Invalid value for organization")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("organizations/%s/authentication-token", url.QueryEscape(organization))
|
||||
req, err := s.client.newRequest("DELETE", u, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.client.do(ctx, req, nil)
|
||||
}
|
|
@ -0,0 +1,206 @@
|
|||
package tfe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Compile-time proof of interface implementation.
|
||||
var _ Plans = (*plans)(nil)
|
||||
|
||||
// Plans describes all the plan related methods that the Terraform Enterprise
|
||||
// API supports.
|
||||
//
|
||||
// TFE API docs: https://www.terraform.io/docs/enterprise/api/plan.html
|
||||
type Plans interface {
|
||||
// Read a plan by its ID.
|
||||
Read(ctx context.Context, planID string) (*Plan, error)
|
||||
|
||||
// Logs retrieves the logs of a plan.
|
||||
Logs(ctx context.Context, planID string) (io.Reader, error)
|
||||
}
|
||||
|
||||
// plans implements Plans.
|
||||
type plans struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// PlanStatus represents a plan state.
|
||||
type PlanStatus string
|
||||
|
||||
//List all available plan statuses.
|
||||
const (
|
||||
PlanCanceled PlanStatus = "canceled"
|
||||
PlanCreated PlanStatus = "created"
|
||||
PlanErrored PlanStatus = "errored"
|
||||
PlanFinished PlanStatus = "finished"
|
||||
PlanMFAWaiting PlanStatus = "mfa_waiting"
|
||||
PlanPending PlanStatus = "pending"
|
||||
PlanQueued PlanStatus = "queued"
|
||||
PlanRunning PlanStatus = "running"
|
||||
)
|
||||
|
||||
// Plan represents a Terraform Enterprise plan.
|
||||
type Plan struct {
|
||||
ID string `jsonapi:"primary,plans"`
|
||||
HasChanges bool `jsonapi:"attr,has-changes"`
|
||||
LogReadURL string `jsonapi:"attr,log-read-url"`
|
||||
Status PlanStatus `jsonapi:"attr,status"`
|
||||
StatusTimestamps *PlanStatusTimestamps `jsonapi:"attr,status-timestamps"`
|
||||
}
|
||||
|
||||
// PlanStatusTimestamps holds the timestamps for individual plan statuses.
|
||||
type PlanStatusTimestamps struct {
|
||||
CanceledAt time.Time `json:"canceled-at"`
|
||||
CreatedAt time.Time `json:"created-at"`
|
||||
ErroredAt time.Time `json:"errored-at"`
|
||||
FinishedAt time.Time `json:"finished-at"`
|
||||
MFAWaitingAt time.Time `json:"mfa_waiting-at"`
|
||||
PendingAt time.Time `json:"pending-at"`
|
||||
QueuedAt time.Time `json:"queued-at"`
|
||||
RunningAt time.Time `json:"running-at"`
|
||||
}
|
||||
|
||||
// Read a plan by its ID.
|
||||
func (s *plans) Read(ctx context.Context, planID string) (*Plan, error) {
|
||||
if !validStringID(&planID) {
|
||||
return nil, errors.New("Invalid value for plan ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("plans/%s", url.QueryEscape(planID))
|
||||
req, err := s.client.newRequest("GET", u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p := &Plan{}
|
||||
err = s.client.do(ctx, req, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Logs retrieves the logs of a plan.
|
||||
func (s *plans) Logs(ctx context.Context, planID string) (io.Reader, error) {
|
||||
if !validStringID(&planID) {
|
||||
return nil, errors.New("Invalid value for plan ID")
|
||||
}
|
||||
|
||||
// Get the plan to make sure it exists.
|
||||
p, err := s.Read(ctx, planID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Return an error if the log URL is empty.
|
||||
if p.LogReadURL == "" {
|
||||
return nil, fmt.Errorf("Plan %s does not have a log URL", planID)
|
||||
}
|
||||
|
||||
u, err := url.Parse(p.LogReadURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Invalid log URL: %v", err)
|
||||
}
|
||||
|
||||
return &LogReader{
|
||||
client: s.client,
|
||||
ctx: ctx,
|
||||
logURL: u,
|
||||
plan: p,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LogReader implements io.Reader for streaming plan logs.
|
||||
type LogReader struct {
|
||||
client *Client
|
||||
ctx context.Context
|
||||
logURL *url.URL
|
||||
offset int64
|
||||
plan *Plan
|
||||
reads uint64
|
||||
}
|
||||
|
||||
func (r *LogReader) Read(l []byte) (int, error) {
|
||||
if written, err := r.read(l); err != io.ErrNoProgress {
|
||||
return written, err
|
||||
}
|
||||
|
||||
// Loop until we can any data, the context is canceled or the plan
|
||||
// is finsished running. If we would return right away without any
|
||||
// data, we could and up causing a io.ErrNoProgress error.
|
||||
for {
|
||||
select {
|
||||
case <-r.ctx.Done():
|
||||
return 0, r.ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
if written, err := r.read(l); err != io.ErrNoProgress {
|
||||
return written, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *LogReader) read(l []byte) (int, error) {
|
||||
// Update the query string.
|
||||
r.logURL.RawQuery = fmt.Sprintf("limit=%d&offset=%d", len(l), r.offset)
|
||||
|
||||
// Create a new request.
|
||||
req, err := http.NewRequest("GET", r.logURL.String(), nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req = req.WithContext(r.ctx)
|
||||
|
||||
// Retrieve the next chunk.
|
||||
resp, err := r.client.http.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Basic response checking.
|
||||
if err := checkResponseCode(resp); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Check if we need to continue the loop and wait 500 miliseconds
|
||||
// before checking if there is a new chunk available or that the
|
||||
// plan is finished and we are done reading all chunks.
|
||||
if resp.ContentLength == 0 {
|
||||
if r.reads%2 == 0 {
|
||||
r.plan, err = r.client.Plans.Read(r.ctx, r.plan.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
switch r.plan.Status {
|
||||
case PlanCanceled, PlanErrored, PlanFinished:
|
||||
return 0, io.EOF
|
||||
default:
|
||||
r.reads++
|
||||
return 0, io.ErrNoProgress
|
||||
}
|
||||
}
|
||||
|
||||
// Read the retrieved chunk.
|
||||
written, err := resp.Body.Read(l)
|
||||
if err == io.EOF {
|
||||
// Ignore io.EOF errors returned when reading from the response
|
||||
// body as this indicates the end of the chunk and not the end
|
||||
// of the logfile.
|
||||
err = nil
|
||||
}
|
||||
|
||||
// Update the offset for the next read.
|
||||
r.offset += int64(written)
|
||||
|
||||
return written, err
|
||||
}
|
|
@ -0,0 +1,251 @@
|
|||
package tfe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Compile-time proof of interface implementation.
|
||||
var _ Policies = (*policies)(nil)
|
||||
|
||||
// Policies describes all the policy related methods that the Terraform
|
||||
// Enterprise API supports.
|
||||
//
|
||||
// TFE API docs: https://www.terraform.io/docs/enterprise/api/policies.html
|
||||
type Policies interface {
|
||||
// List all the policies for a given organization
|
||||
List(ctx context.Context, organization string, options PolicyListOptions) ([]*Policy, error)
|
||||
|
||||
// Create a policy and associate it with an organization.
|
||||
Create(ctx context.Context, organization string, options PolicyCreateOptions) (*Policy, error)
|
||||
|
||||
// Read a policy by its ID.
|
||||
Read(ctx context.Context, policyID string) (*Policy, error)
|
||||
|
||||
// Upload the policy content of the policy.
|
||||
Upload(ctx context.Context, policyID string, content []byte) error
|
||||
|
||||
// Update an existing policy.
|
||||
Update(ctx context.Context, policyID string, options PolicyUpdateOptions) (*Policy, error)
|
||||
|
||||
// Delete a policy by its ID.
|
||||
Delete(ctx context.Context, policyID string) error
|
||||
}
|
||||
|
||||
// policies implements Policies.
|
||||
type policies struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// EnforcementLevel represents an enforcement level.
|
||||
type EnforcementLevel string
|
||||
|
||||
// List the available enforcement types.
|
||||
const (
|
||||
EnforcementAdvisory EnforcementLevel = "advisory"
|
||||
EnforcementHard EnforcementLevel = "hard-mandatory"
|
||||
EnforcementSoft EnforcementLevel = "soft-mandatory"
|
||||
)
|
||||
|
||||
// Policy represents a Terraform Enterprise policy.
|
||||
type Policy struct {
|
||||
ID string `jsonapi:"primary,policies"`
|
||||
Name string `jsonapi:"attr,name"`
|
||||
Enforce []*Enforcement `jsonapi:"attr,enforce"`
|
||||
UpdatedAt time.Time `jsonapi:"attr,updated-at,iso8601"`
|
||||
}
|
||||
|
||||
// Enforcement describes a enforcement.
|
||||
type Enforcement struct {
|
||||
Path string `json:"path"`
|
||||
Mode EnforcementLevel `json:"mode"`
|
||||
}
|
||||
|
||||
// PolicyListOptions represents the options for listing policies.
|
||||
type PolicyListOptions struct {
|
||||
ListOptions
|
||||
}
|
||||
|
||||
// List all the policies for a given organization
|
||||
func (s *policies) List(ctx context.Context, organization string, options PolicyListOptions) ([]*Policy, error) {
|
||||
if !validStringID(&organization) {
|
||||
return nil, errors.New("Invalid value for organization")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("organizations/%s/policies", url.QueryEscape(organization))
|
||||
req, err := s.client.newRequest("GET", u, &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var ps []*Policy
|
||||
err = s.client.do(ctx, req, &ps)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ps, nil
|
||||
}
|
||||
|
||||
// PolicyCreateOptions represents the options for creating a new policy.
|
||||
type PolicyCreateOptions struct {
|
||||
// For internal use only!
|
||||
ID string `jsonapi:"primary,policies"`
|
||||
|
||||
// The name of the policy.
|
||||
Name *string `jsonapi:"attr,name"`
|
||||
|
||||
// The enforcements of the policy.
|
||||
Enforce []*EnforcementOptions `jsonapi:"attr,enforce"`
|
||||
}
|
||||
|
||||
// EnforcementOptions represents the enforcement options of a policy.
|
||||
type EnforcementOptions struct {
|
||||
Path *string `json:"path,omitempty"`
|
||||
Mode *EnforcementLevel `json:"mode"`
|
||||
}
|
||||
|
||||
func (o PolicyCreateOptions) valid() error {
|
||||
if !validString(o.Name) {
|
||||
return errors.New("Name is required")
|
||||
}
|
||||
if !validStringID(o.Name) {
|
||||
return errors.New("Invalid value for name")
|
||||
}
|
||||
if o.Enforce == nil {
|
||||
return errors.New("Enforce is required")
|
||||
}
|
||||
for _, e := range o.Enforce {
|
||||
if !validString(e.Path) {
|
||||
return errors.New("Enforcement path is required")
|
||||
}
|
||||
if e.Mode == nil {
|
||||
return errors.New("Enforcement mode is required")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a policy and associate it with an organization.
|
||||
func (s *policies) Create(ctx context.Context, organization string, options PolicyCreateOptions) (*Policy, error) {
|
||||
if !validStringID(&organization) {
|
||||
return nil, errors.New("Invalid value for organization")
|
||||
}
|
||||
if err := options.valid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Make sure we don't send a user provided ID.
|
||||
options.ID = ""
|
||||
|
||||
u := fmt.Sprintf("organizations/%s/policies", url.QueryEscape(organization))
|
||||
req, err := s.client.newRequest("POST", u, &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p := &Policy{}
|
||||
err = s.client.do(ctx, req, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p, err
|
||||
}
|
||||
|
||||
// Read a policy by its ID.
|
||||
func (s *policies) Read(ctx context.Context, policyID string) (*Policy, error) {
|
||||
if !validStringID(&policyID) {
|
||||
return nil, errors.New("Invalid value for policy ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("policies/%s", url.QueryEscape(policyID))
|
||||
req, err := s.client.newRequest("GET", u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p := &Policy{}
|
||||
err = s.client.do(ctx, req, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p, err
|
||||
}
|
||||
|
||||
// Upload the policy content of the policy.
|
||||
func (s *policies) Upload(ctx context.Context, policyID string, content []byte) error {
|
||||
if !validStringID(&policyID) {
|
||||
return errors.New("Invalid value for policy ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("policies/%s/upload", url.QueryEscape(policyID))
|
||||
req, err := s.client.newRequest("PUT", u, content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.client.do(ctx, req, nil)
|
||||
}
|
||||
|
||||
// PolicyUpdateOptions represents the options for updating a policy.
|
||||
type PolicyUpdateOptions struct {
|
||||
// For internal use only!
|
||||
ID string `jsonapi:"primary,policies"`
|
||||
|
||||
// The enforcements of the policy.
|
||||
Enforce []*EnforcementOptions `jsonapi:"attr,enforce"`
|
||||
}
|
||||
|
||||
func (o PolicyUpdateOptions) valid() error {
|
||||
if o.Enforce == nil {
|
||||
return errors.New("Enforce is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update an existing policy.
|
||||
func (s *policies) Update(ctx context.Context, policyID string, options PolicyUpdateOptions) (*Policy, error) {
|
||||
if !validStringID(&policyID) {
|
||||
return nil, errors.New("Invalid value for policy ID")
|
||||
}
|
||||
if err := options.valid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Make sure we don't send a user provided ID.
|
||||
options.ID = ""
|
||||
|
||||
u := fmt.Sprintf("policies/%s", url.QueryEscape(policyID))
|
||||
req, err := s.client.newRequest("PATCH", u, &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p := &Policy{}
|
||||
err = s.client.do(ctx, req, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p, err
|
||||
}
|
||||
|
||||
// Delete a policy by its ID.
|
||||
func (s *policies) Delete(ctx context.Context, policyID string) error {
|
||||
if !validStringID(&policyID) {
|
||||
return errors.New("Invalid value for policy ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("policies/%s", url.QueryEscape(policyID))
|
||||
req, err := s.client.newRequest("DELETE", u, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.client.do(ctx, req, nil)
|
||||
}
|
|
@ -0,0 +1,143 @@
|
|||
package tfe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Compile-time proof of interface implementation.
|
||||
var _ PolicyChecks = (*policyChecks)(nil)
|
||||
|
||||
// PolicyChecks describes all the policy check related methods that the
|
||||
// Terraform Enterprise API supports.
|
||||
//
|
||||
// TFE API docs:
|
||||
// https://www.terraform.io/docs/enterprise/api/policy-checks.html
|
||||
type PolicyChecks interface {
|
||||
// List all policy checks of the given run.
|
||||
List(ctx context.Context, runID string, options PolicyCheckListOptions) ([]*PolicyCheck, error)
|
||||
|
||||
// Override a soft-mandatory or warning policy.
|
||||
Override(ctx context.Context, policyCheckID string) (*PolicyCheck, error)
|
||||
}
|
||||
|
||||
// policyChecks implements PolicyChecks.
|
||||
type policyChecks struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// PolicyScope represents a policy scope.
|
||||
type PolicyScope string
|
||||
|
||||
// List all available policy scopes.
|
||||
const (
|
||||
PolicyScopeOrganization PolicyScope = "organization"
|
||||
PolicyScopeWorkspace PolicyScope = "workspace"
|
||||
)
|
||||
|
||||
// PolicyStatus represents a policy check state.
|
||||
type PolicyStatus string
|
||||
|
||||
//List all available policy check statuses.
|
||||
const (
|
||||
PolicyErrored PolicyStatus = "errored"
|
||||
PolicyHardFailed PolicyStatus = "hard_failed"
|
||||
PolicyOverridden PolicyStatus = "overridden"
|
||||
PolicyPasses PolicyStatus = "passed"
|
||||
PolicyPending PolicyStatus = "pending"
|
||||
PolicyQueued PolicyStatus = "queued"
|
||||
PolicySoftFailed PolicyStatus = "soft_failed"
|
||||
)
|
||||
|
||||
// PolicyCheck represents a Terraform Enterprise policy check..
|
||||
type PolicyCheck struct {
|
||||
ID string `jsonapi:"primary,policy-checks"`
|
||||
Actions *PolicyActions `jsonapi:"attr,actions"`
|
||||
Permissions *PolicyPermissions `jsonapi:"attr,permissions"`
|
||||
Result *PolicyResult `jsonapi:"attr,result"`
|
||||
Scope PolicyScope `jsonapi:"attr,source"`
|
||||
Status PolicyStatus `jsonapi:"attr,status"`
|
||||
StatusTimestamps *PolicyStatusTimestamps `jsonapi:"attr,status-timestamps"`
|
||||
}
|
||||
|
||||
// PolicyActions represents the policy check actions.
|
||||
type PolicyActions struct {
|
||||
IsOverridable bool `json:"is-overridable"`
|
||||
}
|
||||
|
||||
// PolicyPermissions represents the policy check permissions.
|
||||
type PolicyPermissions struct {
|
||||
CanOverride bool `json:"can-override"`
|
||||
}
|
||||
|
||||
// PolicyResult represents the complete policy check result,
|
||||
type PolicyResult struct {
|
||||
AdvisoryFailed int `json:"advisory-failed"`
|
||||
Duration int `json:"duration"`
|
||||
HardFailed int `json:"hard-failed"`
|
||||
Passed int `json:"passed"`
|
||||
Result bool `json:"result"`
|
||||
// Sentinel *sentinel.EvalResult `json:"sentinel"`
|
||||
SoftFailed int `json:"soft-failed"`
|
||||
TotalFailed int `json:"total-failed"`
|
||||
}
|
||||
|
||||
// PolicyStatusTimestamps holds the timestamps for individual policy check
|
||||
// statuses.
|
||||
type PolicyStatusTimestamps struct {
|
||||
ErroredAt time.Time `json:"errored-at"`
|
||||
HardFailedAt time.Time `json:"hard-failed-at"`
|
||||
PassedAt time.Time `json:"passed-at"`
|
||||
QueuedAt time.Time `json:"queued-at"`
|
||||
SoftFailedAt time.Time `json:"soft-failed-at"`
|
||||
}
|
||||
|
||||
// PolicyCheckListOptions represents the options for listing policy checks.
|
||||
type PolicyCheckListOptions struct {
|
||||
ListOptions
|
||||
}
|
||||
|
||||
// List all policy checks of the given run.
|
||||
func (s *policyChecks) List(ctx context.Context, runID string, options PolicyCheckListOptions) ([]*PolicyCheck, error) {
|
||||
if !validStringID(&runID) {
|
||||
return nil, errors.New("Invalid value for run ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("runs/%s/policy-checks", url.QueryEscape(runID))
|
||||
req, err := s.client.newRequest("GET", u, &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var pcs []*PolicyCheck
|
||||
err = s.client.do(ctx, req, &pcs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return pcs, nil
|
||||
}
|
||||
|
||||
// Override a soft-mandatory or warning policy.
|
||||
func (s *policyChecks) Override(ctx context.Context, policyCheckID string) (*PolicyCheck, error) {
|
||||
if !validStringID(&policyCheckID) {
|
||||
return nil, errors.New("Invalid value for policy check ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("policy-checks/%s/actions/override", url.QueryEscape(policyCheckID))
|
||||
req, err := s.client.newRequest("POST", u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pc := &PolicyCheck{}
|
||||
err = s.client.do(ctx, req, pc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return pc, nil
|
||||
}
|
|
@ -0,0 +1,273 @@
|
|||
package tfe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Compile-time proof of interface implementation.
|
||||
var _ Runs = (*runs)(nil)
|
||||
|
||||
// Runs describes all the run related methods that the Terraform Enterprise
|
||||
// API supports.
|
||||
//
|
||||
// TFE API docs: https://www.terraform.io/docs/enterprise/api/run.html
|
||||
type Runs interface {
|
||||
// List all the runs of the given workspace.
|
||||
List(ctx context.Context, workspaceID string, options RunListOptions) ([]*Run, error)
|
||||
|
||||
// Create a new run with the given options.
|
||||
Create(ctx context.Context, options RunCreateOptions) (*Run, error)
|
||||
|
||||
// Read a run by its ID.
|
||||
Read(ctx context.Context, runID string) (*Run, error)
|
||||
|
||||
// Apply a run by its ID.
|
||||
Apply(ctx context.Context, runID string, options RunApplyOptions) error
|
||||
|
||||
// Cancel a run by its ID.
|
||||
Cancel(ctx context.Context, runID string, options RunCancelOptions) error
|
||||
|
||||
// Discard a run by its ID.
|
||||
Discard(ctx context.Context, runID string, options RunDiscardOptions) error
|
||||
}
|
||||
|
||||
// runs implements Runs.
|
||||
type runs struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// RunStatus represents a run state.
|
||||
type RunStatus string
|
||||
|
||||
//List all available run statuses.
|
||||
const (
|
||||
RunApplied RunStatus = "applied"
|
||||
RunApplying RunStatus = "applying"
|
||||
RunCanceled RunStatus = "canceled"
|
||||
RunConfirmed RunStatus = "confirmed"
|
||||
RunDiscarded RunStatus = "discarded"
|
||||
RunErrored RunStatus = "errored"
|
||||
RunPending RunStatus = "pending"
|
||||
RunPlanned RunStatus = "planned"
|
||||
RunPlanning RunStatus = "planning"
|
||||
RunPolicyChecked RunStatus = "policy_checked"
|
||||
RunPolicyChecking RunStatus = "policy_checking"
|
||||
RunPolicyOverride RunStatus = "policy_override"
|
||||
)
|
||||
|
||||
// RunSource represents a source type of a run.
|
||||
type RunSource string
|
||||
|
||||
// List all available run sources.
|
||||
const (
|
||||
RunSourceAPI RunSource = "tfe-api"
|
||||
RunSourceConfigurationVersion RunSource = "tfe-configuration-version"
|
||||
RunSourceUI RunSource = "tfe-ui"
|
||||
)
|
||||
|
||||
// Run represents a Terraform Enterprise run.
|
||||
type Run struct {
|
||||
ID string `jsonapi:"primary,runs"`
|
||||
Actions *RunActions `jsonapi:"attr,actions"`
|
||||
CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"`
|
||||
HasChanges bool `jsonapi:"attr,has-changes"`
|
||||
IsDestroy bool `jsonapi:"attr,is-destroy"`
|
||||
Message string `jsonapi:"attr,message"`
|
||||
Permissions *RunPermissions `jsonapi:"attr,permissions"`
|
||||
Source RunSource `jsonapi:"attr,source"`
|
||||
Status RunStatus `jsonapi:"attr,status"`
|
||||
StatusTimestamps *RunStatusTimestamps `jsonapi:"attr,status-timestamps"`
|
||||
|
||||
// Relations
|
||||
ConfigurationVersion *ConfigurationVersion `jsonapi:"relation,configuration-version"`
|
||||
Plan *Plan `jsonapi:"relation,plan"`
|
||||
Workspace *Workspace `jsonapi:"relation,workspace"`
|
||||
}
|
||||
|
||||
// RunActions represents the run actions.
|
||||
type RunActions struct {
|
||||
IsCancelable bool `json:"is-cancelable"`
|
||||
IsComfirmable bool `json:"is-comfirmable"`
|
||||
IsDiscardable bool `json:"is-discardable"`
|
||||
}
|
||||
|
||||
// RunPermissions represents the run permissions.
|
||||
type RunPermissions struct {
|
||||
CanApply bool `json:"can-apply"`
|
||||
CanCancel bool `json:"can-cancel"`
|
||||
CanDiscard bool `json:"can-discard"`
|
||||
CanForceExecute bool `json:"can-force-execute"`
|
||||
}
|
||||
|
||||
// RunStatusTimestamps holds the timestamps for individual run statuses.
|
||||
type RunStatusTimestamps struct {
|
||||
ErroredAt time.Time `json:"errored-at"`
|
||||
FinishedAt time.Time `json:"finished-at"`
|
||||
QueuedAt time.Time `json:"queued-at"`
|
||||
StartedAt time.Time `json:"started-at"`
|
||||
}
|
||||
|
||||
// RunListOptions represents the options for listing runs.
|
||||
type RunListOptions struct {
|
||||
ListOptions
|
||||
}
|
||||
|
||||
// List all the runs of the given workspace.
|
||||
func (s *runs) List(ctx context.Context, workspaceID string, options RunListOptions) ([]*Run, error) {
|
||||
if !validStringID(&workspaceID) {
|
||||
return nil, errors.New("Invalid value for workspace ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("workspaces/%s/runs", url.QueryEscape(workspaceID))
|
||||
req, err := s.client.newRequest("GET", u, &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rs []*Run
|
||||
err = s.client.do(ctx, req, &rs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rs, nil
|
||||
}
|
||||
|
||||
// RunCreateOptions represents the options for creating a new run.
|
||||
type RunCreateOptions struct {
|
||||
// For internal use only!
|
||||
ID string `jsonapi:"primary,runs"`
|
||||
|
||||
// Specifies if this plan is a destroy plan, which will destroy all
|
||||
// provisioned resources.
|
||||
IsDestroy *bool `jsonapi:"attr,is-destroy,omitempty"`
|
||||
|
||||
// Specifies the message to be associated with this run.
|
||||
Message *string `jsonapi:"attr,message,omitempty"`
|
||||
|
||||
// Specifies the configuration version to use for this run. If the
|
||||
// configuration version object is omitted, the run will be created using the
|
||||
// workspace's latest configuration version.
|
||||
ConfigurationVersion *ConfigurationVersion `jsonapi:"relation,configuration-version"`
|
||||
|
||||
// Specifies the workspace where the run will be executed.
|
||||
Workspace *Workspace `jsonapi:"relation,workspace"`
|
||||
}
|
||||
|
||||
func (o RunCreateOptions) valid() error {
|
||||
if o.Workspace == nil {
|
||||
return errors.New("Workspace is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a new run with the given options.
|
||||
func (s *runs) Create(ctx context.Context, options RunCreateOptions) (*Run, error) {
|
||||
if err := options.valid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Make sure we don't send a user provided ID.
|
||||
options.ID = ""
|
||||
|
||||
req, err := s.client.newRequest("POST", "runs", &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r := &Run{}
|
||||
err = s.client.do(ctx, req, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Read a run by its ID.
|
||||
func (s *runs) Read(ctx context.Context, runID string) (*Run, error) {
|
||||
if !validStringID(&runID) {
|
||||
return nil, errors.New("Invalid value for run ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("runs/%s", url.QueryEscape(runID))
|
||||
req, err := s.client.newRequest("GET", u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r := &Run{}
|
||||
err = s.client.do(ctx, req, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// RunApplyOptions represents the options for applying a run.
|
||||
type RunApplyOptions struct {
|
||||
// An optional comment about the run.
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// Apply a run by its ID.
|
||||
func (s *runs) Apply(ctx context.Context, runID string, options RunApplyOptions) error {
|
||||
if !validStringID(&runID) {
|
||||
return errors.New("Invalid value for run ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("runs/%s/actions/apply", url.QueryEscape(runID))
|
||||
req, err := s.client.newRequest("POST", u, &options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.client.do(ctx, req, nil)
|
||||
}
|
||||
|
||||
// RunCancelOptions represents the options for canceling a run.
|
||||
type RunCancelOptions struct {
|
||||
// An optional explanation for why the run was canceled.
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// Cancel a run by its ID.
|
||||
func (s *runs) Cancel(ctx context.Context, runID string, options RunCancelOptions) error {
|
||||
if !validStringID(&runID) {
|
||||
return errors.New("Invalid value for run ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("runs/%s/actions/cancel", url.QueryEscape(runID))
|
||||
req, err := s.client.newRequest("POST", u, &options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.client.do(ctx, req, nil)
|
||||
}
|
||||
|
||||
// RunDiscardOptions represents the options for discarding a run.
|
||||
type RunDiscardOptions struct {
|
||||
// An optional explanation for why the run was discarded.
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// Discard a run by its ID.
|
||||
func (s *runs) Discard(ctx context.Context, runID string, options RunDiscardOptions) error {
|
||||
if !validStringID(&runID) {
|
||||
return errors.New("Invalid value for run ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("runs/%s/actions/discard", url.QueryEscape(runID))
|
||||
req, err := s.client.newRequest("POST", u, &options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.client.do(ctx, req, nil)
|
||||
}
|
|
@ -0,0 +1,192 @@
|
|||
package tfe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// Compile-time proof of interface implementation.
|
||||
var _ SSHKeys = (*sshKeys)(nil)
|
||||
|
||||
// SSHKeys describes all the SSH key related methods that the Terraform
|
||||
// Enterprise API supports.
|
||||
//
|
||||
// TFE API docs:
|
||||
// https://www.terraform.io/docs/enterprise/api/ssh-keys.html
|
||||
type SSHKeys interface {
|
||||
// List all the SSH keys for a given organization
|
||||
List(ctx context.Context, organization string, options SSHKeyListOptions) ([]*SSHKey, error)
|
||||
|
||||
// Create an SSH key and associate it with an organization.
|
||||
Create(ctx context.Context, organization string, options SSHKeyCreateOptions) (*SSHKey, error)
|
||||
|
||||
// Read an SSH key by its ID.
|
||||
Read(ctx context.Context, sshKeyID string) (*SSHKey, error)
|
||||
|
||||
// Update an SSH key by its ID.
|
||||
Update(ctx context.Context, sshKeyID string, options SSHKeyUpdateOptions) (*SSHKey, error)
|
||||
|
||||
// Delete an SSH key by its ID.
|
||||
Delete(ctx context.Context, sshKeyID string) error
|
||||
}
|
||||
|
||||
// sshKeys implements SSHKeys.
|
||||
type sshKeys struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// SSHKey represents a SSH key.
|
||||
type SSHKey struct {
|
||||
ID string `jsonapi:"primary,ssh-keys"`
|
||||
Name string `jsonapi:"attr,name"`
|
||||
}
|
||||
|
||||
// SSHKeyListOptions represents the options for listing SSH keys.
|
||||
type SSHKeyListOptions struct {
|
||||
ListOptions
|
||||
}
|
||||
|
||||
// List all the SSH keys for a given organization
|
||||
func (s *sshKeys) List(ctx context.Context, organization string, options SSHKeyListOptions) ([]*SSHKey, error) {
|
||||
if !validStringID(&organization) {
|
||||
return nil, errors.New("Invalid value for organization")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("organizations/%s/ssh-keys", url.QueryEscape(organization))
|
||||
req, err := s.client.newRequest("GET", u, &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var ks []*SSHKey
|
||||
err = s.client.do(ctx, req, &ks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ks, nil
|
||||
}
|
||||
|
||||
// SSHKeyCreateOptions represents the options for creating an SSH key.
|
||||
type SSHKeyCreateOptions struct {
|
||||
// For internal use only!
|
||||
ID string `jsonapi:"primary,ssh-keys"`
|
||||
|
||||
// A name to identify the SSH key.
|
||||
Name *string `jsonapi:"attr,name"`
|
||||
|
||||
// The content of the SSH private key.
|
||||
Value *string `jsonapi:"attr,value"`
|
||||
}
|
||||
|
||||
func (o SSHKeyCreateOptions) valid() error {
|
||||
if !validString(o.Name) {
|
||||
return errors.New("Name is required")
|
||||
}
|
||||
if !validString(o.Value) {
|
||||
return errors.New("Value is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create an SSH key and associate it with an organization.
|
||||
func (s *sshKeys) Create(ctx context.Context, organization string, options SSHKeyCreateOptions) (*SSHKey, error) {
|
||||
if !validStringID(&organization) {
|
||||
return nil, errors.New("Invalid value for organization")
|
||||
}
|
||||
|
||||
if err := options.valid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Make sure we don't send a user provided ID.
|
||||
options.ID = ""
|
||||
|
||||
u := fmt.Sprintf("organizations/%s/ssh-keys", url.QueryEscape(organization))
|
||||
req, err := s.client.newRequest("POST", u, &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
k := &SSHKey{}
|
||||
err = s.client.do(ctx, req, k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// Read an SSH key by its ID.
|
||||
func (s *sshKeys) Read(ctx context.Context, sshKeyID string) (*SSHKey, error) {
|
||||
if !validStringID(&sshKeyID) {
|
||||
return nil, errors.New("Invalid value for SSH key ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("ssh-keys/%s", url.QueryEscape(sshKeyID))
|
||||
req, err := s.client.newRequest("GET", u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
k := &SSHKey{}
|
||||
err = s.client.do(ctx, req, k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// SSHKeyUpdateOptions represents the options for updating an SSH key.
|
||||
type SSHKeyUpdateOptions struct {
|
||||
// For internal use only!
|
||||
ID string `jsonapi:"primary,ssh-keys"`
|
||||
|
||||
// A new name to identify the SSH key.
|
||||
Name *string `jsonapi:"attr,name,omitempty"`
|
||||
|
||||
// Updated content of the SSH private key.
|
||||
Value *string `jsonapi:"attr,value,omitempty"`
|
||||
}
|
||||
|
||||
// Update an SSH key by its ID.
|
||||
func (s *sshKeys) Update(ctx context.Context, sshKeyID string, options SSHKeyUpdateOptions) (*SSHKey, error) {
|
||||
if !validStringID(&sshKeyID) {
|
||||
return nil, errors.New("Invalid value for SSH key ID")
|
||||
}
|
||||
|
||||
// Make sure we don't send a user provided ID.
|
||||
options.ID = ""
|
||||
|
||||
u := fmt.Sprintf("ssh-keys/%s", url.QueryEscape(sshKeyID))
|
||||
req, err := s.client.newRequest("PATCH", u, &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
k := &SSHKey{}
|
||||
err = s.client.do(ctx, req, k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// Delete an SSH key by its ID.
|
||||
func (s *sshKeys) Delete(ctx context.Context, sshKeyID string) error {
|
||||
if !validStringID(&sshKeyID) {
|
||||
return errors.New("Invalid value for SSH key ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("ssh-keys/%s", url.QueryEscape(sshKeyID))
|
||||
req, err := s.client.newRequest("DELETE", u, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.client.do(ctx, req, nil)
|
||||
}
|
|
@ -0,0 +1,207 @@
|
|||
package tfe
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Compile-time proof of interface implementation.
|
||||
var _ StateVersions = (*stateVersions)(nil)
|
||||
|
||||
// StateVersions describes all the state version related methods that
|
||||
// the Terraform Enterprise API supports.
|
||||
//
|
||||
// TFE API docs:
|
||||
// https://www.terraform.io/docs/enterprise/api/state-versions.html
|
||||
type StateVersions interface {
|
||||
// List all the state versions for a given workspace.
|
||||
List(ctx context.Context, options StateVersionListOptions) ([]*StateVersion, error)
|
||||
|
||||
// Create a new state version for the given workspace.
|
||||
Create(ctx context.Context, workspaceID string, options StateVersionCreateOptions) (*StateVersion, error)
|
||||
|
||||
// Read a state version by its ID.
|
||||
Read(ctx context.Context, svID string) (*StateVersion, error)
|
||||
|
||||
// Current reads the latest available state from the given workspace.
|
||||
Current(ctx context.Context, workspaceID string) (*StateVersion, error)
|
||||
|
||||
// Download retrieves the actual stored state of a state version
|
||||
Download(ctx context.Context, url string) ([]byte, error)
|
||||
}
|
||||
|
||||
// stateVersions implements StateVersions.
|
||||
type stateVersions struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// StateVersion represents a Terraform Enterprise state version.
|
||||
type StateVersion struct {
|
||||
ID string `jsonapi:"primary,state-versions"`
|
||||
CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"`
|
||||
DownloadURL string `jsonapi:"attr,hosted-state-download-url"`
|
||||
Serial int64 `jsonapi:"attr,serial"`
|
||||
VCSCommitSHA string `jsonapi:"attr,vcs-commit-sha"`
|
||||
VCSCommitURL string `jsonapi:"attr,vcs-commit-url"`
|
||||
|
||||
// Relations
|
||||
Run *Run `jsonapi:"relation,run"`
|
||||
}
|
||||
|
||||
// StateVersionListOptions represents the options for listing state versions.
|
||||
type StateVersionListOptions struct {
|
||||
ListOptions
|
||||
Organization *string `url:"filter[organization][name]"`
|
||||
Workspace *string `url:"filter[workspace][name]"`
|
||||
}
|
||||
|
||||
func (o StateVersionListOptions) valid() error {
|
||||
if !validString(o.Organization) {
|
||||
return errors.New("Organization is required")
|
||||
}
|
||||
if !validString(o.Workspace) {
|
||||
return errors.New("Workspace is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List all the state versions for a given workspace.
|
||||
func (s *stateVersions) List(ctx context.Context, options StateVersionListOptions) ([]*StateVersion, error) {
|
||||
if err := options.valid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := s.client.newRequest("GET", "state-versions", &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var svs []*StateVersion
|
||||
err = s.client.do(ctx, req, &svs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return svs, nil
|
||||
}
|
||||
|
||||
// StateVersionCreateOptions represents the options for creating a state version.
|
||||
type StateVersionCreateOptions struct {
|
||||
// For internal use only!
|
||||
ID string `jsonapi:"primary,state-versions"`
|
||||
|
||||
// The lineage of the state.
|
||||
Lineage *string `jsonapi:"attr,lineage,omitempty"`
|
||||
|
||||
// The MD5 hash of the state version.
|
||||
MD5 *string `jsonapi:"attr,md5"`
|
||||
|
||||
// The serial of the state.
|
||||
Serial *int64 `jsonapi:"attr,serial"`
|
||||
|
||||
// The base64 encoded state.
|
||||
State *string `jsonapi:"attr,state"`
|
||||
}
|
||||
|
||||
func (o StateVersionCreateOptions) valid() error {
|
||||
if !validString(o.MD5) {
|
||||
return errors.New("MD5 is required")
|
||||
}
|
||||
if o.Serial == nil {
|
||||
return errors.New("Serial is required")
|
||||
}
|
||||
if !validString(o.State) {
|
||||
return errors.New("State is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a new state version for the given workspace.
|
||||
func (s *stateVersions) Create(ctx context.Context, workspaceID string, options StateVersionCreateOptions) (*StateVersion, error) {
|
||||
if !validStringID(&workspaceID) {
|
||||
return nil, errors.New("Invalid value for workspace ID")
|
||||
}
|
||||
if err := options.valid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Make sure we don't send a user provided ID.
|
||||
options.ID = ""
|
||||
|
||||
u := fmt.Sprintf("workspaces/%s/state-versions", url.QueryEscape(workspaceID))
|
||||
req, err := s.client.newRequest("POST", u, &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sv := &StateVersion{}
|
||||
err = s.client.do(ctx, req, sv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sv, nil
|
||||
}
|
||||
|
||||
// Read a state version by its ID.
|
||||
func (s *stateVersions) Read(ctx context.Context, svID string) (*StateVersion, error) {
|
||||
if !validStringID(&svID) {
|
||||
return nil, errors.New("Invalid value for state version ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("state-versions/%s", url.QueryEscape(svID))
|
||||
req, err := s.client.newRequest("GET", u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sv := &StateVersion{}
|
||||
err = s.client.do(ctx, req, sv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sv, nil
|
||||
}
|
||||
|
||||
// Current reads the latest available state from the given workspace.
|
||||
func (s *stateVersions) Current(ctx context.Context, workspaceID string) (*StateVersion, error) {
|
||||
if !validStringID(&workspaceID) {
|
||||
return nil, errors.New("Invalid value for workspace ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("workspaces/%s/current-state-version", url.QueryEscape(workspaceID))
|
||||
req, err := s.client.newRequest("GET", u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sv := &StateVersion{}
|
||||
err = s.client.do(ctx, req, sv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sv, nil
|
||||
}
|
||||
|
||||
// Download retrieves the actual stored state of a state version
|
||||
func (s *stateVersions) Download(ctx context.Context, url string) ([]byte, error) {
|
||||
req, err := s.client.newRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = s.client.do(ctx, req, &buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
|
@ -0,0 +1,159 @@
|
|||
package tfe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// Compile-time proof of interface implementation.
|
||||
var _ Teams = (*teams)(nil)
|
||||
|
||||
// Teams describes all the team related methods that the Terraform
|
||||
// Enterprise API supports.
|
||||
//
|
||||
// TFE API docs: https://www.terraform.io/docs/enterprise/api/teams.html
|
||||
type Teams interface {
|
||||
// List all the teams of the given organization.
|
||||
List(ctx context.Context, organization string, options TeamListOptions) ([]*Team, error)
|
||||
|
||||
// Create a new team with the given options.
|
||||
Create(ctx context.Context, organization string, options TeamCreateOptions) (*Team, error)
|
||||
|
||||
// Read a team by its ID.
|
||||
Read(ctx context.Context, teamID string) (*Team, error)
|
||||
|
||||
// Delete a team by its ID.
|
||||
Delete(ctx context.Context, teamID string) error
|
||||
}
|
||||
|
||||
// teams implements Teams.
|
||||
type teams struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// Team represents a Terraform Enterprise team.
|
||||
type Team struct {
|
||||
ID string `jsonapi:"primary,teams"`
|
||||
Name string `jsonapi:"attr,name"`
|
||||
Permissions *TeamPermissions `jsonapi:"attr,permissions"`
|
||||
UserCount int `jsonapi:"attr,users-count"`
|
||||
|
||||
// Relations
|
||||
//User []*User `jsonapi:"relation,users"`
|
||||
}
|
||||
|
||||
// TeamPermissions represents the team permissions.
|
||||
type TeamPermissions struct {
|
||||
CanDestroy bool `json:"can-destroy"`
|
||||
CanUpdateMembership bool `json:"can-update-membership"`
|
||||
}
|
||||
|
||||
// TeamListOptions represents the options for listing teams.
|
||||
type TeamListOptions struct {
|
||||
ListOptions
|
||||
}
|
||||
|
||||
// List all the teams of the given organization.
|
||||
func (s *teams) List(ctx context.Context, organization string, options TeamListOptions) ([]*Team, error) {
|
||||
if !validStringID(&organization) {
|
||||
return nil, errors.New("Invalid value for organization")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("organizations/%s/teams", url.QueryEscape(organization))
|
||||
req, err := s.client.newRequest("GET", u, &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var ts []*Team
|
||||
err = s.client.do(ctx, req, &ts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ts, nil
|
||||
}
|
||||
|
||||
// TeamCreateOptions represents the options for creating a team.
|
||||
type TeamCreateOptions struct {
|
||||
// For internal use only!
|
||||
ID string `jsonapi:"primary,teams"`
|
||||
|
||||
// Name of the team.
|
||||
Name *string `jsonapi:"attr,name"`
|
||||
}
|
||||
|
||||
func (o TeamCreateOptions) valid() error {
|
||||
if !validString(o.Name) {
|
||||
return errors.New("Name is required")
|
||||
}
|
||||
if !validStringID(o.Name) {
|
||||
return errors.New("Invalid value for name")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a new team with the given options.
|
||||
func (s *teams) Create(ctx context.Context, organization string, options TeamCreateOptions) (*Team, error) {
|
||||
if !validStringID(&organization) {
|
||||
return nil, errors.New("Invalid value for organization")
|
||||
}
|
||||
if err := options.valid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Make sure we don't send a user provided ID.
|
||||
options.ID = ""
|
||||
|
||||
u := fmt.Sprintf("organizations/%s/teams", url.QueryEscape(organization))
|
||||
req, err := s.client.newRequest("POST", u, &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t := &Team{}
|
||||
err = s.client.do(ctx, req, t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Read a single team by its ID.
|
||||
func (s *teams) Read(ctx context.Context, teamID string) (*Team, error) {
|
||||
if !validStringID(&teamID) {
|
||||
return nil, errors.New("Invalid value for team ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("teams/%s", url.QueryEscape(teamID))
|
||||
req, err := s.client.newRequest("GET", u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t := &Team{}
|
||||
err = s.client.do(ctx, req, t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Delete a team by its ID.
|
||||
func (s *teams) Delete(ctx context.Context, teamID string) error {
|
||||
if !validStringID(&teamID) {
|
||||
return errors.New("Invalid value for team ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("teams/%s", url.QueryEscape(teamID))
|
||||
req, err := s.client.newRequest("DELETE", u, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.client.do(ctx, req, nil)
|
||||
}
|
|
@ -0,0 +1,178 @@
|
|||
package tfe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// Compile-time proof of interface implementation.
|
||||
var _ TeamAccesses = (*teamAccesses)(nil)
|
||||
|
||||
// TeamAccesses describes all the team access related methods that the
|
||||
// Terraform Enterprise API supports.
|
||||
//
|
||||
// TFE API docs:
|
||||
// https://www.terraform.io/docs/enterprise/api/team-access.html
|
||||
type TeamAccesses interface {
|
||||
// List all the team accesses for a given workspace.
|
||||
List(ctx context.Context, options TeamAccessListOptions) ([]*TeamAccess, error)
|
||||
|
||||
// Add team access for a workspace.
|
||||
Add(ctx context.Context, options TeamAccessAddOptions) (*TeamAccess, error)
|
||||
|
||||
// Read a team access by its ID.
|
||||
Read(ctx context.Context, teamAccessID string) (*TeamAccess, error)
|
||||
|
||||
// Remove team access from a workspace.
|
||||
Remove(ctx context.Context, teamAccessID string) error
|
||||
}
|
||||
|
||||
// teamAccesses implements TeamAccesses.
|
||||
type teamAccesses struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// TeamAccessType represents a team access type.
|
||||
type TeamAccessType string
|
||||
|
||||
// List all available team access types.
|
||||
const (
|
||||
TeamAccessAdmin TeamAccessType = "admin"
|
||||
TeamAccessRead TeamAccessType = "read"
|
||||
TeamAccessWrite TeamAccessType = "write"
|
||||
)
|
||||
|
||||
// TeamAccess represents the workspace access for a team.
|
||||
type TeamAccess struct {
|
||||
ID string `jsonapi:"primary,team-workspaces"`
|
||||
Access TeamAccessType `jsonapi:"attr,access"`
|
||||
|
||||
// Relations
|
||||
Team *Team `jsonapi:"relation,team"`
|
||||
Workspace *Workspace `jsonapi:"relation,workspace"`
|
||||
}
|
||||
|
||||
// TeamAccessListOptions represents the options for listing team accesses.
|
||||
type TeamAccessListOptions struct {
|
||||
ListOptions
|
||||
WorkspaceID *string `url:"filter[workspace][id],omitempty"`
|
||||
}
|
||||
|
||||
func (o TeamAccessListOptions) valid() error {
|
||||
if !validString(o.WorkspaceID) {
|
||||
return errors.New("Workspace ID is required")
|
||||
}
|
||||
if !validStringID(o.WorkspaceID) {
|
||||
return errors.New("Invalid value for workspace ID")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List all the team accesses for a given workspace.
|
||||
func (s *teamAccesses) List(ctx context.Context, options TeamAccessListOptions) ([]*TeamAccess, error) {
|
||||
if err := options.valid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := s.client.newRequest("GET", "team-workspaces", &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var tas []*TeamAccess
|
||||
err = s.client.do(ctx, req, &tas)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tas, nil
|
||||
}
|
||||
|
||||
// TeamAccessAddOptions represents the options for adding team access.
|
||||
type TeamAccessAddOptions struct {
|
||||
// For internal use only!
|
||||
ID string `jsonapi:"primary,team-workspaces"`
|
||||
|
||||
// The type of access to grant.
|
||||
Access *TeamAccessType `jsonapi:"attr,access"`
|
||||
|
||||
// The team to add to the workspace
|
||||
Team *Team `jsonapi:"relation,team"`
|
||||
|
||||
// The workspace to which the team is to be added.
|
||||
Workspace *Workspace `jsonapi:"relation,workspace"`
|
||||
}
|
||||
|
||||
func (o TeamAccessAddOptions) valid() error {
|
||||
if o.Access == nil {
|
||||
return errors.New("Access is required")
|
||||
}
|
||||
if o.Team == nil {
|
||||
return errors.New("Team is required")
|
||||
}
|
||||
if o.Workspace == nil {
|
||||
return errors.New("Workspace is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add team access for a workspace.
|
||||
func (s *teamAccesses) Add(ctx context.Context, options TeamAccessAddOptions) (*TeamAccess, error) {
|
||||
if err := options.valid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Make sure we don't send a user provided ID.
|
||||
options.ID = ""
|
||||
|
||||
req, err := s.client.newRequest("POST", "team-workspaces", &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ta := &TeamAccess{}
|
||||
err = s.client.do(ctx, req, ta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ta, nil
|
||||
}
|
||||
|
||||
// Read a team access by its ID.
|
||||
func (s *teamAccesses) Read(ctx context.Context, teamAccessID string) (*TeamAccess, error) {
|
||||
if !validStringID(&teamAccessID) {
|
||||
return nil, errors.New("Invalid value for team access ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("team-workspaces/%s", url.QueryEscape(teamAccessID))
|
||||
req, err := s.client.newRequest("GET", u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ta := &TeamAccess{}
|
||||
err = s.client.do(ctx, req, ta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ta, nil
|
||||
}
|
||||
|
||||
// Remove team access from a workspace.
|
||||
func (s *teamAccesses) Remove(ctx context.Context, teamAccessID string) error {
|
||||
if !validStringID(&teamAccessID) {
|
||||
return errors.New("Invalid value for team access ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("team-workspaces/%s", url.QueryEscape(teamAccessID))
|
||||
req, err := s.client.newRequest("DELETE", u, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.client.do(ctx, req, nil)
|
||||
}
|
|
@ -0,0 +1,109 @@
|
|||
package tfe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// Compile-time proof of interface implementation.
|
||||
var _ TeamMembers = (*teamMembers)(nil)
|
||||
|
||||
// TeamMembers describes all the team member related methods that the
|
||||
// Terraform Enterprise API supports.
|
||||
//
|
||||
// TFE API docs:
|
||||
// https://www.terraform.io/docs/enterprise/api/team-members.html
|
||||
type TeamMembers interface {
|
||||
// Add multiple users to a team.
|
||||
Add(ctx context.Context, teamID string, options TeamMemberAddOptions) error
|
||||
|
||||
// Remove multiple users from a team.
|
||||
Remove(ctx context.Context, teamID string, options TeamMemberRemoveOptions) error
|
||||
}
|
||||
|
||||
// teamMembers implements TeamMembers.
|
||||
type teamMembers struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
type teamMember struct {
|
||||
Username string `jsonapi:"primary,users"`
|
||||
}
|
||||
|
||||
// TeamMemberAddOptions represents the options for adding team members.
|
||||
type TeamMemberAddOptions struct {
|
||||
Usernames []string
|
||||
}
|
||||
|
||||
func (o *TeamMemberAddOptions) valid() error {
|
||||
if o.Usernames == nil {
|
||||
return errors.New("Usernames is required")
|
||||
}
|
||||
if len(o.Usernames) == 0 {
|
||||
return errors.New("Invalid value for usernames")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add multiple users to a team.
|
||||
func (s *teamMembers) Add(ctx context.Context, teamID string, options TeamMemberAddOptions) error {
|
||||
if !validStringID(&teamID) {
|
||||
return errors.New("Invalid value for team ID")
|
||||
}
|
||||
if err := options.valid(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var tms []*teamMember
|
||||
for _, name := range options.Usernames {
|
||||
tms = append(tms, &teamMember{Username: name})
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("teams/%s/relationships/users", url.QueryEscape(teamID))
|
||||
req, err := s.client.newRequest("POST", u, tms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.client.do(ctx, req, nil)
|
||||
}
|
||||
|
||||
// TeamMemberRemoveOptions represents the options for deleting team members.
|
||||
type TeamMemberRemoveOptions struct {
|
||||
Usernames []string
|
||||
}
|
||||
|
||||
func (o *TeamMemberRemoveOptions) valid() error {
|
||||
if o.Usernames == nil {
|
||||
return errors.New("Usernames is required")
|
||||
}
|
||||
if len(o.Usernames) == 0 {
|
||||
return errors.New("Invalid value for usernames")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove multiple users from a team.
|
||||
func (s *teamMembers) Remove(ctx context.Context, teamID string, options TeamMemberRemoveOptions) error {
|
||||
if !validStringID(&teamID) {
|
||||
return errors.New("Invalid value for team ID")
|
||||
}
|
||||
if err := options.valid(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var tms []*teamMember
|
||||
for _, name := range options.Usernames {
|
||||
tms = append(tms, &teamMember{Username: name})
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("teams/%s/relationships/users", url.QueryEscape(teamID))
|
||||
req, err := s.client.newRequest("DELETE", u, tms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.client.do(ctx, req, nil)
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
package tfe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Compile-time proof of interface implementation.
|
||||
var _ TeamTokens = (*teamTokens)(nil)
|
||||
|
||||
// TeamTokens describes all the team token related methods that the
|
||||
// Terraform Enterprise API supports.
|
||||
//
|
||||
// TFE API docs:
|
||||
// https://www.terraform.io/docs/enterprise/api/team-tokens.html
|
||||
type TeamTokens interface {
|
||||
// Generate a new team token, replacing any existing token.
|
||||
Generate(ctx context.Context, teamID string) (*TeamToken, error)
|
||||
|
||||
// Delete a team token by its ID.
|
||||
Delete(ctx context.Context, teamID string) error
|
||||
}
|
||||
|
||||
// teamTokens implements TeamTokens.
|
||||
type teamTokens struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// TeamToken represents a Terraform Enterprise team token.
|
||||
type TeamToken struct {
|
||||
ID string `jsonapi:"primary,authentication-tokens"`
|
||||
CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"`
|
||||
Description string `jsonapi:"attr,description"`
|
||||
LastUsedAt time.Time `jsonapi:"attr,last-used-at,iso8601"`
|
||||
Token string `jsonapi:"attr,token"`
|
||||
}
|
||||
|
||||
// Generate a new team token, replacing any existing token.
|
||||
func (s *teamTokens) Generate(ctx context.Context, teamID string) (*TeamToken, error) {
|
||||
if !validStringID(&teamID) {
|
||||
return nil, errors.New("Invalid value for team ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("teams/%s/authentication-token", url.QueryEscape(teamID))
|
||||
req, err := s.client.newRequest("POST", u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tt := &TeamToken{}
|
||||
err = s.client.do(ctx, req, tt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tt, err
|
||||
}
|
||||
|
||||
// Delete a team token by its ID.
|
||||
func (s *teamTokens) Delete(ctx context.Context, teamID string) error {
|
||||
if !validStringID(&teamID) {
|
||||
return errors.New("Invalid value for team ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("teams/%s/authentication-token", url.QueryEscape(teamID))
|
||||
req, err := s.client.newRequest("DELETE", u, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.client.do(ctx, req, nil)
|
||||
}
|
|
@ -0,0 +1,349 @@
|
|||
package tfe
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/google/go-querystring/query"
|
||||
"github.com/hashicorp/go-cleanhttp"
|
||||
"github.com/svanharmelen/jsonapi"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultAddress of Terraform Enterprise.
|
||||
DefaultAddress = "https://app.terraform.io"
|
||||
// DefaultBasePath on which the API is served.
|
||||
DefaultBasePath = "/api/v2/"
|
||||
)
|
||||
|
||||
const (
|
||||
userAgent = "go-tfe"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrUnauthorized is returned when a receiving a 401.
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
// ErrResourceNotFound is returned when a receiving a 404.
|
||||
ErrResourceNotFound = errors.New("resource not found")
|
||||
)
|
||||
|
||||
// Config provides configuration details to the API client.
|
||||
type Config struct {
|
||||
// The address of the Terraform Enterprise API.
|
||||
Address string
|
||||
|
||||
// The base path on which the API is served.
|
||||
BasePath string
|
||||
|
||||
// API token used to access the Terraform Enterprise API.
|
||||
Token string
|
||||
|
||||
// A custom HTTP client to use.
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// DefaultConfig returns a default config structure.
|
||||
func DefaultConfig() *Config {
|
||||
config := &Config{
|
||||
Address: os.Getenv("TFE_ADDRESS"),
|
||||
BasePath: DefaultBasePath,
|
||||
Token: os.Getenv("TFE_TOKEN"),
|
||||
HTTPClient: cleanhttp.DefaultClient(),
|
||||
}
|
||||
|
||||
// Set the default address if none is given.
|
||||
if config.Address == "" {
|
||||
config.Address = DefaultAddress
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// Client is the Terraform Enterprise API client. It provides the basic
|
||||
// connectivity and configuration for accessing the TFE API.
|
||||
type Client struct {
|
||||
baseURL *url.URL
|
||||
token string
|
||||
http *http.Client
|
||||
userAgent string
|
||||
|
||||
ConfigurationVersions ConfigurationVersions
|
||||
OAuthClients OAuthClients
|
||||
OAuthTokens OAuthTokens
|
||||
Organizations Organizations
|
||||
OrganizationTokens OrganizationTokens
|
||||
Plans Plans
|
||||
Policies Policies
|
||||
PolicyChecks PolicyChecks
|
||||
Runs Runs
|
||||
SSHKeys SSHKeys
|
||||
StateVersions StateVersions
|
||||
Teams Teams
|
||||
TeamAccess TeamAccesses
|
||||
TeamMembers TeamMembers
|
||||
TeamTokens TeamTokens
|
||||
Users Users
|
||||
Variables Variables
|
||||
Workspaces Workspaces
|
||||
}
|
||||
|
||||
// NewClient creates a new Terraform Enterprise API client.
|
||||
func NewClient(cfg *Config) (*Client, error) {
|
||||
config := DefaultConfig()
|
||||
|
||||
// Layer in the provided config for any non-blank values.
|
||||
if cfg != nil {
|
||||
if cfg.Address != "" {
|
||||
config.Address = cfg.Address
|
||||
}
|
||||
if cfg.BasePath != "" {
|
||||
config.BasePath = cfg.BasePath
|
||||
}
|
||||
if cfg.Token != "" {
|
||||
config.Token = cfg.Token
|
||||
}
|
||||
if cfg.HTTPClient != nil {
|
||||
config.HTTPClient = cfg.HTTPClient
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the address to make sure its a valid URL.
|
||||
baseURL, err := url.Parse(config.Address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Invalid address: %v", err)
|
||||
}
|
||||
|
||||
baseURL.Path = config.BasePath
|
||||
if !strings.HasSuffix(baseURL.Path, "/") {
|
||||
baseURL.Path += "/"
|
||||
}
|
||||
|
||||
// This value must be provided by the user.
|
||||
if config.Token == "" {
|
||||
return nil, fmt.Errorf("Missing API token")
|
||||
}
|
||||
|
||||
// Create the client.
|
||||
client := &Client{
|
||||
baseURL: baseURL,
|
||||
token: config.Token,
|
||||
http: config.HTTPClient,
|
||||
userAgent: userAgent,
|
||||
}
|
||||
|
||||
// Create the services.
|
||||
client.ConfigurationVersions = &configurationVersions{client: client}
|
||||
client.OAuthClients = &oAuthClients{client: client}
|
||||
client.OAuthTokens = &oAuthTokens{client: client}
|
||||
client.Organizations = &organizations{client: client}
|
||||
client.OrganizationTokens = &organizationTokens{client: client}
|
||||
client.Plans = &plans{client: client}
|
||||
client.Policies = &policies{client: client}
|
||||
client.PolicyChecks = &policyChecks{client: client}
|
||||
client.Runs = &runs{client: client}
|
||||
client.SSHKeys = &sshKeys{client: client}
|
||||
client.StateVersions = &stateVersions{client: client}
|
||||
client.Teams = &teams{client: client}
|
||||
client.TeamAccess = &teamAccesses{client: client}
|
||||
client.TeamMembers = &teamMembers{client: client}
|
||||
client.TeamTokens = &teamTokens{client: client}
|
||||
client.Users = &users{client: client}
|
||||
client.Variables = &variables{client: client}
|
||||
client.Workspaces = &workspaces{client: client}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// ListOptions is used to specify pagination options when making API requests.
|
||||
// Pagination allows breaking up large result sets into chunks, or "pages".
|
||||
type ListOptions struct {
|
||||
// The page number to request. The results vary based on the PageSize.
|
||||
PageNumber int `url:"page[number],omitempty"`
|
||||
|
||||
// The number of elements returned in a single page.
|
||||
PageSize int `url:"page[size],omitempty"`
|
||||
}
|
||||
|
||||
// newRequest creates an API request. A relative URL path can be provided in
|
||||
// path, in which case it is resolved relative to the apiVersionPath of the
|
||||
// Client. Relative URL paths should always be specified without a preceding
|
||||
// slash.
|
||||
// If v is supplied, the value will be JSONAPI encoded and included as the
|
||||
// request body. If the method is GET, the value will be parsed and added as
|
||||
// query parameters.
|
||||
func (c *Client) newRequest(method, path string, v interface{}) (*http.Request, error) {
|
||||
u, err := c.baseURL.Parse(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req := &http.Request{
|
||||
Method: method,
|
||||
URL: u,
|
||||
Proto: "HTTP/1.1",
|
||||
ProtoMajor: 1,
|
||||
ProtoMinor: 1,
|
||||
Header: make(http.Header),
|
||||
Host: u.Host,
|
||||
}
|
||||
|
||||
switch method {
|
||||
case "GET":
|
||||
req.Header.Set("Accept", "application/vnd.api+json")
|
||||
|
||||
if v != nil {
|
||||
q, err := query.Values(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
case "PATCH", "POST":
|
||||
req.Header.Set("Accept", "application/vnd.api+json")
|
||||
req.Header.Set("Content-Type", "application/vnd.api+json")
|
||||
|
||||
if v != nil {
|
||||
var body bytes.Buffer
|
||||
if err := jsonapi.MarshalPayloadWithoutIncluded(&body, v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Body = ioutil.NopCloser(&body)
|
||||
req.ContentLength = int64(body.Len())
|
||||
}
|
||||
case "PUT":
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
|
||||
if v != nil {
|
||||
switch v := v.(type) {
|
||||
case *bytes.Buffer:
|
||||
req.Body = ioutil.NopCloser(v)
|
||||
req.ContentLength = int64(v.Len())
|
||||
case []byte:
|
||||
req.Body = ioutil.NopCloser(bytes.NewReader(v))
|
||||
req.ContentLength = int64(len(v))
|
||||
default:
|
||||
return nil, fmt.Errorf("Unexpected type: %T", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set required headers.
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("User-Agent", c.userAgent)
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// do sends an API request and returns the API response. The API response is
|
||||
// JSONAPI decoded and stored in the value pointed to by v, or returned as an
|
||||
// error if an API error has occurred.
|
||||
// If v implements the io.Writer interface, the raw response body will be
|
||||
// written to v, without attempting to first decode it.
|
||||
// The provided ctx must be non-nil. If it is canceled or times out, ctx.Err()
|
||||
// will be returned.
|
||||
func (c *Client) do(ctx context.Context, req *http.Request, v interface{}) error {
|
||||
// Add the context to the request.
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
// Execute the request and check the response.
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
// If we got an error, and the context has been canceled,
|
||||
// the context's error is probably more useful.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Basic response checking.
|
||||
if err := checkResponseCode(resp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Return here if decoding the response isn't needed.
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If v implements io.Writer, write the raw response body.
|
||||
if w, ok := v.(io.Writer); ok {
|
||||
_, err = io.Copy(w, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the value of v so we can test if it's a slice.
|
||||
dst := reflect.Indirect(reflect.ValueOf(v))
|
||||
|
||||
// Unmarshal a single value if v isn't a slice.
|
||||
if dst.Type().Kind() != reflect.Slice {
|
||||
return jsonapi.UnmarshalPayload(resp.Body, v)
|
||||
}
|
||||
|
||||
// Unmarshal as a list of values if v is a slice.
|
||||
raw, err := jsonapi.UnmarshalManyPayload(resp.Body, dst.Type().Elem())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Make a new slice to hold the results.
|
||||
sliceType := reflect.SliceOf(dst.Type().Elem())
|
||||
result := reflect.MakeSlice(sliceType, 0, len(raw))
|
||||
|
||||
// Add all of the results to the new slice.
|
||||
for _, v := range raw {
|
||||
result = reflect.Append(result, reflect.ValueOf(v))
|
||||
}
|
||||
|
||||
// Pointer-swap the result.
|
||||
dst.Set(result)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkResponseCode can be used to check the status code of an HTTP request.
|
||||
func checkResponseCode(r *http.Response) error {
|
||||
if r.StatusCode >= 200 && r.StatusCode <= 299 {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch r.StatusCode {
|
||||
case 401:
|
||||
return ErrUnauthorized
|
||||
case 404:
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
// Decode the error payload.
|
||||
errPayload := &jsonapi.ErrorsPayload{}
|
||||
err := json.NewDecoder(r.Body).Decode(errPayload)
|
||||
if err != nil || len(errPayload.Errors) == 0 {
|
||||
return fmt.Errorf(r.Status)
|
||||
}
|
||||
|
||||
// Parse and format the errors.
|
||||
var errs []string
|
||||
for _, e := range errPayload.Errors {
|
||||
if e.Detail == "" {
|
||||
errs = append(errs, e.Title)
|
||||
} else {
|
||||
errs = append(errs, fmt.Sprintf("%s %s", e.Title, e.Detail))
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf(strings.Join(errs, "\n"))
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
package tfe
|
||||
|
||||
// Access returns a pointer to the given team access type.
|
||||
func Access(v TeamAccessType) *TeamAccessType {
|
||||
return &v
|
||||
}
|
||||
|
||||
// AuthPolicy returns a pointer to the given authentication poliy.
|
||||
func AuthPolicy(v AuthPolicyType) *AuthPolicyType {
|
||||
return &v
|
||||
}
|
||||
|
||||
// Bool returns a pointer to the given bool
|
||||
func Bool(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
|
||||
// Category returns a pointer to the given category type.
|
||||
func Category(v CategoryType) *CategoryType {
|
||||
return &v
|
||||
}
|
||||
|
||||
// EnforcementMode returns a pointer to the given enforcement level.
|
||||
func EnforcementMode(v EnforcementLevel) *EnforcementLevel {
|
||||
return &v
|
||||
}
|
||||
|
||||
// Int64 returns a pointer to the given int64.
|
||||
func Int64(v int64) *int64 {
|
||||
return &v
|
||||
}
|
||||
|
||||
// ServiceProvider returns a pointer to the given service provider type.
|
||||
func ServiceProvider(v ServiceProviderType) *ServiceProviderType {
|
||||
return &v
|
||||
}
|
||||
|
||||
// String returns a pointer to the given string.
|
||||
func String(v string) *string {
|
||||
return &v
|
||||
}
|
|
@ -0,0 +1,93 @@
|
|||
package tfe
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Compile-time proof of interface implementation.
|
||||
var _ Users = (*users)(nil)
|
||||
|
||||
// Users describes all the user related methods that the Terraform
|
||||
// Enterprise API supports.
|
||||
//
|
||||
// TFE API docs: https://www.terraform.io/docs/enterprise/api/user.html
|
||||
type Users interface {
|
||||
// ReadCurrent reads the details of the currently authenticated user.
|
||||
ReadCurrent(ctx context.Context) (*User, error)
|
||||
|
||||
// Update attributes of the currently authenticated user.
|
||||
Update(ctx context.Context, options UserUpdateOptions) (*User, error)
|
||||
}
|
||||
|
||||
// users implements Users.
|
||||
type users struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// User represents a Terraform Enterprise user.
|
||||
type User struct {
|
||||
ID string `jsonapi:"primary,users"`
|
||||
AvatarURL string `jsonapi:"attr,avatar-url"`
|
||||
Email string `jsonapi:"attr,email"`
|
||||
IsServiceAccount bool `jsonapi:"attr,is-service-account"`
|
||||
TwoFactor *TwoFactor `jsonapi:"attr,two-factor"`
|
||||
UnconfirmedEmail string `jsonapi:"attr,unconfirmed-email"`
|
||||
Username string `jsonapi:"attr,username"`
|
||||
V2Only bool `jsonapi:"attr,v2-only"`
|
||||
|
||||
// Relations
|
||||
// AuthenticationTokens *AuthenticationTokens `jsonapi:"relation,authentication-tokens"`
|
||||
}
|
||||
|
||||
// TwoFactor represents the organization permissions.
|
||||
type TwoFactor struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
// ReadCurrent reads the details of the currently authenticated user.
|
||||
func (s *users) ReadCurrent(ctx context.Context) (*User, error) {
|
||||
req, err := s.client.newRequest("GET", "account/details", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u := &User{}
|
||||
err = s.client.do(ctx, req, u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// UserUpdateOptions represents the options for updating a user.
|
||||
type UserUpdateOptions struct {
|
||||
// For internal use only!
|
||||
ID string `jsonapi:"primary,users"`
|
||||
|
||||
// New username.
|
||||
Username *string `jsonapi:"attr,username,omitempty"`
|
||||
|
||||
// New email address (must be consumed afterwards to take effect).
|
||||
Email *string `jsonapi:"attr,email,omitempty"`
|
||||
}
|
||||
|
||||
// Update attributes of the currently authenticated user.
|
||||
func (s *users) Update(ctx context.Context, options UserUpdateOptions) (*User, error) {
|
||||
// Make sure we don't send a user provided ID.
|
||||
options.ID = ""
|
||||
|
||||
req, err := s.client.newRequest("PATCH", "account/update", &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u := &User{}
|
||||
err = s.client.do(ctx, req, u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package tfe
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// A regular expression used to validate common string ID patterns.
|
||||
var reStringID = regexp.MustCompile(`^[a-zA-Z0-9\-\._]+$`)
|
||||
|
||||
// validString checks if the given input is present and non-empty.
|
||||
func validString(v *string) bool {
|
||||
return v != nil && *v != ""
|
||||
}
|
||||
|
||||
// validStringID checks if the given string pointer is non-nil and
|
||||
// contains a typical string identifier.
|
||||
func validStringID(v *string) bool {
|
||||
return v != nil && reStringID.MatchString(*v)
|
||||
}
|
|
@ -0,0 +1,216 @@
|
|||
package tfe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// Compile-time proof of interface implementation.
|
||||
var _ Variables = (*variables)(nil)
|
||||
|
||||
// Variables describes all the variable related methods that the Terraform
|
||||
// Enterprise API supports.
|
||||
//
|
||||
// TFE API docs: https://www.terraform.io/docs/enterprise/api/variables.html
|
||||
type Variables interface {
|
||||
// List all the variables associated with the given workspace.
|
||||
List(ctx context.Context, options VariableListOptions) ([]*Variable, error)
|
||||
|
||||
// Create is used to create a new variable.
|
||||
Create(ctx context.Context, options VariableCreateOptions) (*Variable, error)
|
||||
|
||||
// Update values of an existing variable.
|
||||
Update(ctx context.Context, variableID string, options VariableUpdateOptions) (*Variable, error)
|
||||
|
||||
// Delete a variable by its ID.
|
||||
Delete(ctx context.Context, variableID string) error
|
||||
}
|
||||
|
||||
// variables implements Variables.
|
||||
type variables struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// CategoryType represents a category type.
|
||||
type CategoryType string
|
||||
|
||||
//List all available categories.
|
||||
const (
|
||||
CategoryEnv CategoryType = "env"
|
||||
CategoryTerraform CategoryType = "terraform"
|
||||
)
|
||||
|
||||
// Variable represents a Terraform Enterprise variable.
|
||||
type Variable struct {
|
||||
ID string `jsonapi:"primary,vars"`
|
||||
Key string `jsonapi:"attr,key"`
|
||||
Value string `jsonapi:"attr,value"`
|
||||
Category CategoryType `jsonapi:"attr,category"`
|
||||
HCL bool `jsonapi:"attr,hcl"`
|
||||
Sensitive bool `jsonapi:"attr,sensitive"`
|
||||
|
||||
// Relations
|
||||
Workspace *Workspace `jsonapi:"relation,workspace"`
|
||||
}
|
||||
|
||||
// VariableListOptions represents the options for listing variables.
|
||||
type VariableListOptions struct {
|
||||
ListOptions
|
||||
Organization *string `url:"filter[organization][name]"`
|
||||
Workspace *string `url:"filter[workspace][name]"`
|
||||
}
|
||||
|
||||
func (o VariableListOptions) valid() error {
|
||||
if !validString(o.Organization) {
|
||||
return errors.New("Organization is required")
|
||||
}
|
||||
if !validString(o.Workspace) {
|
||||
return errors.New("Workspace is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List all the variables associated with the given workspace.
|
||||
func (s *variables) List(ctx context.Context, options VariableListOptions) ([]*Variable, error) {
|
||||
if err := options.valid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := s.client.newRequest("GET", "vars", &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var vs []*Variable
|
||||
err = s.client.do(ctx, req, &vs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return vs, nil
|
||||
}
|
||||
|
||||
// VariableCreateOptions represents the options for creating a new variable.
|
||||
type VariableCreateOptions struct {
|
||||
// For internal use only!
|
||||
ID string `jsonapi:"primary,vars"`
|
||||
|
||||
// The name of the variable.
|
||||
Key *string `jsonapi:"attr,key"`
|
||||
|
||||
// The value of the variable.
|
||||
Value *string `jsonapi:"attr,value"`
|
||||
|
||||
// Whether this is a Terraform or environment variable.
|
||||
Category *CategoryType `jsonapi:"attr,category"`
|
||||
|
||||
// Whether to evaluate the value of the variable as a string of HCL code.
|
||||
HCL *bool `jsonapi:"attr,hcl,omitempty"`
|
||||
|
||||
// Whether the value is sensitive.
|
||||
Sensitive *bool `jsonapi:"attr,sensitive,omitempty"`
|
||||
|
||||
// The workspace that owns the variable.
|
||||
Workspace *Workspace `jsonapi:"relation,workspace"`
|
||||
}
|
||||
|
||||
func (o VariableCreateOptions) valid() error {
|
||||
if !validString(o.Key) {
|
||||
return errors.New("Key is required")
|
||||
}
|
||||
if !validString(o.Value) {
|
||||
return errors.New("Value is required")
|
||||
}
|
||||
if o.Category == nil {
|
||||
return errors.New("Category is required")
|
||||
}
|
||||
if o.Workspace == nil {
|
||||
return errors.New("Workspace is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create is used to create a new variable.
|
||||
func (s *variables) Create(ctx context.Context, options VariableCreateOptions) (*Variable, error) {
|
||||
if err := options.valid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Make sure we don't send a user provided ID.
|
||||
options.ID = ""
|
||||
|
||||
req, err := s.client.newRequest("POST", "vars", &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v := &Variable{}
|
||||
err = s.client.do(ctx, req, v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// VariableUpdateOptions represents the options for updating a variable.
|
||||
type VariableUpdateOptions struct {
|
||||
// For internal use only!
|
||||
ID string `jsonapi:"primary,vars"`
|
||||
|
||||
// The name of the variable.
|
||||
Key *string `jsonapi:"attr,key,omitempty"`
|
||||
|
||||
// The value of the variable.
|
||||
Value *string `jsonapi:"attr,value,omitempty"`
|
||||
|
||||
// Whether this is a Terraform or environment variable.
|
||||
Category *CategoryType `jsonapi:"attr,category,omitempty"`
|
||||
|
||||
// Whether to evaluate the value of the variable as a string of HCL code.
|
||||
HCL *bool `jsonapi:"attr,hcl,omitempty"`
|
||||
|
||||
// Whether the value is sensitive.
|
||||
Sensitive *bool `jsonapi:"attr,sensitive,omitempty"`
|
||||
}
|
||||
|
||||
// Update values of an existing variable.
|
||||
func (s *variables) Update(ctx context.Context, variableID string, options VariableUpdateOptions) (*Variable, error) {
|
||||
if !validStringID(&variableID) {
|
||||
return nil, errors.New("Invalid value for variable ID")
|
||||
}
|
||||
|
||||
// Make sure we don't send a user provided ID.
|
||||
options.ID = variableID
|
||||
|
||||
u := fmt.Sprintf("vars/%s", url.QueryEscape(variableID))
|
||||
req, err := s.client.newRequest("PATCH", u, &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v := &Variable{}
|
||||
err = s.client.do(ctx, req, v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// Delete a variable by its ID.
|
||||
func (s *variables) Delete(ctx context.Context, variableID string) error {
|
||||
if !validStringID(&variableID) {
|
||||
return errors.New("Invalid value for variable ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("vars/%s", url.QueryEscape(variableID))
|
||||
req, err := s.client.newRequest("DELETE", u, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.client.do(ctx, req, nil)
|
||||
}
|
|
@ -0,0 +1,437 @@
|
|||
package tfe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Compile-time proof of interface implementation.
|
||||
var _ Workspaces = (*workspaces)(nil)
|
||||
|
||||
// Workspaces describes all the workspace related methods that the Terraform
|
||||
// Enterprise API supports.
|
||||
//
|
||||
// TFE API docs: https://www.terraform.io/docs/enterprise/api/workspaces.html
|
||||
type Workspaces interface {
|
||||
// List all the workspaces within an organization.
|
||||
List(ctx context.Context, organization string, options WorkspaceListOptions) ([]*Workspace, error)
|
||||
|
||||
// Create is used to create a new workspace.
|
||||
Create(ctx context.Context, organization string, options WorkspaceCreateOptions) (*Workspace, error)
|
||||
|
||||
// Read a workspace by its name.
|
||||
Read(ctx context.Context, organization string, workspace string) (*Workspace, error)
|
||||
|
||||
// Update settings of an existing workspace.
|
||||
Update(ctx context.Context, organization string, workspace string, options WorkspaceUpdateOptions) (*Workspace, error)
|
||||
|
||||
// Delete a workspace by its name.
|
||||
Delete(ctx context.Context, organization string, workspace string) error
|
||||
|
||||
// Lock a workspace by its ID.
|
||||
Lock(ctx context.Context, workspaceID string, options WorkspaceLockOptions) (*Workspace, error)
|
||||
|
||||
// Unlock a workspace by its ID.
|
||||
Unlock(ctx context.Context, workspaceID string) (*Workspace, error)
|
||||
|
||||
// AssignSSHKey to a workspace.
|
||||
AssignSSHKey(ctx context.Context, workspaceID string, options WorkspaceAssignSSHKeyOptions) (*Workspace, error)
|
||||
|
||||
// UnassignSSHKey from a workspace.
|
||||
UnassignSSHKey(ctx context.Context, workspaceID string) (*Workspace, error)
|
||||
}
|
||||
|
||||
// workspaces implements Workspaces.
|
||||
type workspaces struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// Workspace represents a Terraform Enterprise workspace.
|
||||
type Workspace struct {
|
||||
ID string `jsonapi:"primary,workspaces"`
|
||||
Actions *WorkspaceActions `jsonapi:"attr,actions"`
|
||||
AutoApply bool `jsonapi:"attr,auto-apply"`
|
||||
CanQueueDestroyPlan bool `jsonapi:"attr,can-queue-destroy-plan"`
|
||||
CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"`
|
||||
Environment string `jsonapi:"attr,environment"`
|
||||
Locked bool `jsonapi:"attr,locked"`
|
||||
MigrationEnvironment string `jsonapi:"attr,migration-environment"`
|
||||
Name string `jsonapi:"attr,name"`
|
||||
Permissions *WorkspacePermissions `jsonapi:"attr,permissions"`
|
||||
TerraformVersion string `jsonapi:"attr,terraform-version"`
|
||||
VCSRepo *VCSRepo `jsonapi:"attr,vcs-repo"`
|
||||
WorkingDirectory string `jsonapi:"attr,working-directory"`
|
||||
|
||||
// Relations
|
||||
Organization *Organization `jsonapi:"relation,organization"`
|
||||
SSHKey *SSHKey `jsonapi:"relation,ssh-key"`
|
||||
}
|
||||
|
||||
// VCSRepo contains the configuration of a VCS integration.
|
||||
type VCSRepo struct {
|
||||
Branch string `json:"branch"`
|
||||
Identifier string `json:"identifier"`
|
||||
IncludeSubmodules bool `json:"ingress-submodules"`
|
||||
OAuthTokenID string `json:"oauth-token-id"`
|
||||
}
|
||||
|
||||
// WorkspaceActions represents the workspace actions.
|
||||
type WorkspaceActions struct {
|
||||
IsDestroyable bool `json:"is-destroyable"`
|
||||
}
|
||||
|
||||
// WorkspacePermissions represents the workspace permissions.
|
||||
type WorkspacePermissions struct {
|
||||
CanDestroy bool `json:"can-destroy"`
|
||||
CanLock bool `json:"can-lock"`
|
||||
CanQueueDestroy bool `json:"can-queue-destroy"`
|
||||
CanQueueRun bool `json:"can-queue-run"`
|
||||
CanReadSettings bool `json:"can-read-settings"`
|
||||
CanUpdate bool `json:"can-update"`
|
||||
CanUpdateVariable bool `json:"can-update-variable"`
|
||||
}
|
||||
|
||||
// WorkspaceListOptions represents the options for listing workspaces.
|
||||
type WorkspaceListOptions struct {
|
||||
ListOptions
|
||||
}
|
||||
|
||||
// List all the workspaces within an organization.
|
||||
func (s *workspaces) List(ctx context.Context, organization string, options WorkspaceListOptions) ([]*Workspace, error) {
|
||||
if !validStringID(&organization) {
|
||||
return nil, errors.New("Invalid value for organization")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("organizations/%s/workspaces", url.QueryEscape(organization))
|
||||
req, err := s.client.newRequest("GET", u, &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var ws []*Workspace
|
||||
err = s.client.do(ctx, req, &ws)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ws, nil
|
||||
}
|
||||
|
||||
// WorkspaceCreateOptions represents the options for creating a new workspace.
|
||||
type WorkspaceCreateOptions struct {
|
||||
// For internal use only!
|
||||
ID string `jsonapi:"primary,workspaces"`
|
||||
|
||||
// Whether to automatically apply changes when a Terraform plan is successful.
|
||||
AutoApply *bool `jsonapi:"attr,auto-apply,omitempty"`
|
||||
|
||||
// The legacy TFE environment to use as the source of the migration, in the
|
||||
// form organization/environment. Omit this unless you are migrating a legacy
|
||||
// environment.
|
||||
MigrationEnvironment *string `jsonapi:"attr,migration-environment,omitempty"`
|
||||
|
||||
// The name of the workspace, which can only include letters, numbers, -,
|
||||
// and _. This will be used as an identifier and must be unique in the
|
||||
// organization.
|
||||
Name *string `jsonapi:"attr,name"`
|
||||
|
||||
// The version of Terraform to use for this workspace. Upon creating a
|
||||
// workspace, the latest version is selected unless otherwise specified.
|
||||
TerraformVersion *string `jsonapi:"attr,terraform-version,omitempty"`
|
||||
|
||||
// Settings for the workspace's VCS repository. If omitted, the workspace is
|
||||
// created without a VCS repo. If included, you must specify at least the
|
||||
// oauth-token-id and identifier keys below.
|
||||
VCSRepo *VCSRepoOptions `jsonapi:"attr,vcs-repo,omitempty"`
|
||||
|
||||
// A relative path that Terraform will execute within. This defaults to the
|
||||
// root of your repository and is typically set to a subdirectory matching the
|
||||
// environment when multiple environments exist within the same repository.
|
||||
WorkingDirectory *string `jsonapi:"attr,working-directory,omitempty"`
|
||||
}
|
||||
|
||||
// VCSRepoOptions represents the configuration options of a VCS integration.
|
||||
type VCSRepoOptions struct {
|
||||
Branch *string `json:"branch,omitempty"`
|
||||
Identifier *string `json:"identifier,omitempty"`
|
||||
IncludeSubmodules *bool `json:"ingress-submodules,omitempty"`
|
||||
OAuthTokenID *string `json:"oauth-token-id,omitempty"`
|
||||
}
|
||||
|
||||
func (o WorkspaceCreateOptions) valid() error {
|
||||
if !validString(o.Name) {
|
||||
return errors.New("Name is required")
|
||||
}
|
||||
if !validStringID(o.Name) {
|
||||
return errors.New("Invalid value for name")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create is used to create a new workspace.
|
||||
func (s *workspaces) Create(ctx context.Context, organization string, options WorkspaceCreateOptions) (*Workspace, error) {
|
||||
if !validStringID(&organization) {
|
||||
return nil, errors.New("Invalid value for organization")
|
||||
}
|
||||
if err := options.valid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Make sure we don't send a user provided ID.
|
||||
options.ID = ""
|
||||
|
||||
u := fmt.Sprintf("organizations/%s/workspaces", url.QueryEscape(organization))
|
||||
req, err := s.client.newRequest("POST", u, &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
w := &Workspace{}
|
||||
err = s.client.do(ctx, req, w)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// Read a workspace by its name.
|
||||
func (s *workspaces) Read(ctx context.Context, organization, workspace string) (*Workspace, error) {
|
||||
if !validStringID(&organization) {
|
||||
return nil, errors.New("Invalid value for organization")
|
||||
}
|
||||
if !validStringID(&workspace) {
|
||||
return nil, errors.New("Invalid value for workspace")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf(
|
||||
"organizations/%s/workspaces/%s",
|
||||
url.QueryEscape(organization),
|
||||
url.QueryEscape(workspace),
|
||||
)
|
||||
req, err := s.client.newRequest("GET", u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
w := &Workspace{}
|
||||
err = s.client.do(ctx, req, w)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// WorkspaceUpdateOptions represents the options for updating a workspace.
|
||||
type WorkspaceUpdateOptions struct {
|
||||
// For internal use only!
|
||||
ID string `jsonapi:"primary,workspaces"`
|
||||
|
||||
// Whether to automatically apply changes when a Terraform plan is successful.
|
||||
AutoApply *bool `jsonapi:"attr,auto-apply,omitempty"`
|
||||
|
||||
// A new name for the workspace, which can only include letters, numbers, -,
|
||||
// and _. This will be used as an identifier and must be unique in the
|
||||
// organization. Warning: Changing a workspace's name changes its URL in the
|
||||
// API and UI.
|
||||
Name *string `jsonapi:"attr,name,omitempty"`
|
||||
|
||||
// The version of Terraform to use for this workspace.
|
||||
TerraformVersion *string `jsonapi:"attr,terraform-version,omitempty"`
|
||||
|
||||
// To delete a workspace's existing VCS repo, specify null instead of an
|
||||
// object. To modify a workspace's existing VCS repo, include whichever of
|
||||
// the keys below you wish to modify. To add a new VCS repo to a workspace
|
||||
// that didn't previously have one, include at least the oauth-token-id and
|
||||
// identifier keys. VCSRepo *VCSRepo `jsonapi:"relation,vcs-repo,om-tempty"`
|
||||
VCSRepo *VCSRepoOptions `jsonapi:"attr,vcs-repo,omitempty"`
|
||||
|
||||
// A relative path that Terraform will execute within. This defaults to the
|
||||
// root of your repository and is typically set to a subdirectory matching
|
||||
// the environment when multiple environments exist within the same
|
||||
// repository.
|
||||
WorkingDirectory *string `jsonapi:"attr,working-directory,omitempty"`
|
||||
}
|
||||
|
||||
// Update settings of an existing workspace.
|
||||
func (s *workspaces) Update(ctx context.Context, organization, workspace string, options WorkspaceUpdateOptions) (*Workspace, error) {
|
||||
if !validStringID(&organization) {
|
||||
return nil, errors.New("Invalid value for organization")
|
||||
}
|
||||
if !validStringID(&workspace) {
|
||||
return nil, errors.New("Invalid value for workspace")
|
||||
}
|
||||
|
||||
// Make sure we don't send a user provided ID.
|
||||
options.ID = ""
|
||||
|
||||
u := fmt.Sprintf(
|
||||
"organizations/%s/workspaces/%s",
|
||||
url.QueryEscape(organization),
|
||||
url.QueryEscape(workspace),
|
||||
)
|
||||
req, err := s.client.newRequest("PATCH", u, &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
w := &Workspace{}
|
||||
err = s.client.do(ctx, req, w)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// Delete a workspace by its name.
|
||||
func (s *workspaces) Delete(ctx context.Context, organization, workspace string) error {
|
||||
if !validStringID(&organization) {
|
||||
return errors.New("Invalid value for organization")
|
||||
}
|
||||
if !validStringID(&workspace) {
|
||||
return errors.New("Invalid value for workspace")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf(
|
||||
"organizations/%s/workspaces/%s",
|
||||
url.QueryEscape(organization),
|
||||
url.QueryEscape(workspace),
|
||||
)
|
||||
req, err := s.client.newRequest("DELETE", u, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.client.do(ctx, req, nil)
|
||||
}
|
||||
|
||||
// WorkspaceLockOptions represents the options for locking a workspace.
|
||||
type WorkspaceLockOptions struct {
|
||||
// Specifies the reason for locking the workspace.
|
||||
Reason *string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Lock a workspace by its ID.
|
||||
func (s *workspaces) Lock(ctx context.Context, workspaceID string, options WorkspaceLockOptions) (*Workspace, error) {
|
||||
if !validStringID(&workspaceID) {
|
||||
return nil, errors.New("Invalid value for workspace ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("workspaces/%s/actions/lock", url.QueryEscape(workspaceID))
|
||||
req, err := s.client.newRequest("POST", u, &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
w := &Workspace{}
|
||||
err = s.client.do(ctx, req, w)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// Unlock a workspace by its ID.
|
||||
func (s *workspaces) Unlock(ctx context.Context, workspaceID string) (*Workspace, error) {
|
||||
if !validStringID(&workspaceID) {
|
||||
return nil, errors.New("Invalid value for workspace ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("workspaces/%s/actions/unlock", url.QueryEscape(workspaceID))
|
||||
req, err := s.client.newRequest("POST", u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
w := &Workspace{}
|
||||
err = s.client.do(ctx, req, w)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// WorkspaceAssignSSHKeyOptions represents the options to assign an SSH key to
|
||||
// a workspace.
|
||||
type WorkspaceAssignSSHKeyOptions struct {
|
||||
// For internal use only!
|
||||
ID string `jsonapi:"primary,workspaces"`
|
||||
|
||||
// The SSH key ID to assign.
|
||||
SSHKeyID *string `jsonapi:"attr,id"`
|
||||
}
|
||||
|
||||
func (o WorkspaceAssignSSHKeyOptions) valid() error {
|
||||
if !validString(o.SSHKeyID) {
|
||||
return errors.New("SSH key ID is required")
|
||||
}
|
||||
if !validStringID(o.SSHKeyID) {
|
||||
return errors.New("Invalid value for SSH key ID")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AssignSSHKey to a workspace.
|
||||
func (s *workspaces) AssignSSHKey(ctx context.Context, workspaceID string, options WorkspaceAssignSSHKeyOptions) (*Workspace, error) {
|
||||
if !validStringID(&workspaceID) {
|
||||
return nil, errors.New("Invalid value for workspace ID")
|
||||
}
|
||||
if err := options.valid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Make sure we don't send a user provided ID.
|
||||
options.ID = ""
|
||||
|
||||
u := fmt.Sprintf("workspaces/%s/relationships/ssh-key", url.QueryEscape(workspaceID))
|
||||
req, err := s.client.newRequest("PATCH", u, &options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
w := &Workspace{}
|
||||
err = s.client.do(ctx, req, w)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// workspaceUnassignSSHKeyOptions represents the options to unassign an SSH key
|
||||
// to a workspace.
|
||||
type workspaceUnassignSSHKeyOptions struct {
|
||||
// For internal use only!
|
||||
ID string `jsonapi:"primary,workspaces"`
|
||||
|
||||
// Must be nil to unset the currently assigned SSH key.
|
||||
SSHKeyID *string `jsonapi:"attr,id"`
|
||||
}
|
||||
|
||||
// UnassignSSHKey from a workspace.
|
||||
func (s *workspaces) UnassignSSHKey(ctx context.Context, workspaceID string) (*Workspace, error) {
|
||||
if !validStringID(&workspaceID) {
|
||||
return nil, errors.New("Invalid value for workspace ID")
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("workspaces/%s/relationships/ssh-key", url.QueryEscape(workspaceID))
|
||||
req, err := s.client.newRequest("PATCH", u, &workspaceUnassignSSHKeyOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
w := &Workspace{}
|
||||
err = s.client.do(ctx, req, w)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return w, nil
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015 Google Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
|
@ -0,0 +1,457 @@
|
|||
# jsonapi
|
||||
|
||||
[![Build Status](https://travis-ci.org/google/jsonapi.svg?branch=master)](https://travis-ci.org/google/jsonapi)
|
||||
[![Go Report Card](https://goreportcard.com/badge/github.com/google/jsonapi)](https://goreportcard.com/report/github.com/google/jsonapi)
|
||||
[![GoDoc](https://godoc.org/github.com/google/jsonapi?status.svg)](http://godoc.org/github.com/google/jsonapi)
|
||||
|
||||
A serializer/deserializer for JSON payloads that comply to the
|
||||
[JSON API - jsonapi.org](http://jsonapi.org) spec in go.
|
||||
|
||||
## Installation
|
||||
|
||||
```
|
||||
go get -u github.com/google/jsonapi
|
||||
```
|
||||
|
||||
Or, see [Alternative Installation](#alternative-installation).
|
||||
|
||||
## Background
|
||||
|
||||
You are working in your Go web application and you have a struct that is
|
||||
organized similarly to your database schema. You need to send and
|
||||
receive json payloads that adhere to the JSON API spec. Once you realize that
|
||||
your json needed to take on this special form, you go down the path of
|
||||
creating more structs to be able to serialize and deserialize JSON API
|
||||
payloads. Then there are more models required with this additional
|
||||
structure. Ugh! With JSON API, you can keep your model structs as is and
|
||||
use [StructTags](http://golang.org/pkg/reflect/#StructTag) to indicate
|
||||
to JSON API how you want your response built or your request
|
||||
deserialized. What about your relationships? JSON API supports
|
||||
relationships out of the box and will even put them in your response
|
||||
into an `included` side-loaded slice--that contains associated records.
|
||||
|
||||
## Introduction
|
||||
|
||||
JSON API uses [StructField](http://golang.org/pkg/reflect/#StructField)
|
||||
tags to annotate the structs fields that you already have and use in
|
||||
your app and then reads and writes [JSON API](http://jsonapi.org)
|
||||
output based on the instructions you give the library in your JSON API
|
||||
tags. Let's take an example. In your app, you most likely have structs
|
||||
that look similar to these:
|
||||
|
||||
|
||||
```go
|
||||
type Blog struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Posts []*Post `json:"posts"`
|
||||
CurrentPost *Post `json:"current_post"`
|
||||
CurrentPostId int `json:"current_post_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ViewCount int `json:"view_count"`
|
||||
}
|
||||
|
||||
type Post struct {
|
||||
ID int `json:"id"`
|
||||
BlogID int `json:"blog_id"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Comments []*Comment `json:"comments"`
|
||||
}
|
||||
|
||||
type Comment struct {
|
||||
Id int `json:"id"`
|
||||
PostID int `json:"post_id"`
|
||||
Body string `json:"body"`
|
||||
Likes uint `json:"likes_count,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
These structs may or may not resemble the layout of your database. But
|
||||
these are the ones that you want to use right? You wouldn't want to use
|
||||
structs like those that JSON API sends because it is difficult to get at
|
||||
all of your data easily.
|
||||
|
||||
## Example App
|
||||
|
||||
[examples/app.go](https://github.com/google/jsonapi/blob/master/examples/app.go)
|
||||
|
||||
This program demonstrates the implementation of a create, a show,
|
||||
and a list [http.Handler](http://golang.org/pkg/net/http#Handler). It
|
||||
outputs some example requests and responses as well as serialized
|
||||
examples of the source/target structs to json. That is to say, I show
|
||||
you that the library has successfully taken your JSON API request and
|
||||
turned it into your struct types.
|
||||
|
||||
To run,
|
||||
|
||||
* Make sure you have [Go installed](https://golang.org/doc/install)
|
||||
* Create the following directories or similar: `~/go`
|
||||
* Set `GOPATH` to `PWD` in your shell session, `export GOPATH=$PWD`
|
||||
* `go get github.com/google/jsonapi`. (Append `-u` after `get` if you
|
||||
are updating.)
|
||||
* `cd $GOPATH/src/github.com/google/jsonapi/examples`
|
||||
* `go build && ./examples`
|
||||
|
||||
## `jsonapi` Tag Reference
|
||||
|
||||
### Example
|
||||
|
||||
The `jsonapi` [StructTags](http://golang.org/pkg/reflect/#StructTag)
|
||||
tells this library how to marshal and unmarshal your structs into
|
||||
JSON API payloads and your JSON API payloads to structs, respectively.
|
||||
Then Use JSON API's Marshal and Unmarshal methods to construct and read
|
||||
your responses and replies. Here's an example of the structs above
|
||||
using JSON API tags:
|
||||
|
||||
```go
|
||||
type Blog struct {
|
||||
ID int `jsonapi:"primary,blogs"`
|
||||
Title string `jsonapi:"attr,title"`
|
||||
Posts []*Post `jsonapi:"relation,posts"`
|
||||
CurrentPost *Post `jsonapi:"relation,current_post"`
|
||||
CurrentPostID int `jsonapi:"attr,current_post_id"`
|
||||
CreatedAt time.Time `jsonapi:"attr,created_at"`
|
||||
ViewCount int `jsonapi:"attr,view_count"`
|
||||
}
|
||||
|
||||
type Post struct {
|
||||
ID int `jsonapi:"primary,posts"`
|
||||
BlogID int `jsonapi:"attr,blog_id"`
|
||||
Title string `jsonapi:"attr,title"`
|
||||
Body string `jsonapi:"attr,body"`
|
||||
Comments []*Comment `jsonapi:"relation,comments"`
|
||||
}
|
||||
|
||||
type Comment struct {
|
||||
ID int `jsonapi:"primary,comments"`
|
||||
PostID int `jsonapi:"attr,post_id"`
|
||||
Body string `jsonapi:"attr,body"`
|
||||
Likes uint `jsonapi:"attr,likes-count,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
### Permitted Tag Values
|
||||
|
||||
#### `primary`
|
||||
|
||||
```
|
||||
`jsonapi:"primary,<type field output>"`
|
||||
```
|
||||
|
||||
This indicates this is the primary key field for this struct type.
|
||||
Tag value arguments are comma separated. The first argument must be,
|
||||
`primary`, and the second must be the name that should appear in the
|
||||
`type`\* field for all data objects that represent this type of model.
|
||||
|
||||
\* According the [JSON API](http://jsonapi.org) spec, the plural record
|
||||
types are shown in the examples, but not required.
|
||||
|
||||
#### `attr`
|
||||
|
||||
```
|
||||
`jsonapi:"attr,<key name in attributes hash>,<optional: omitempty>"`
|
||||
```
|
||||
|
||||
These fields' values will end up in the `attributes`hash for a record.
|
||||
The first argument must be, `attr`, and the second should be the name
|
||||
for the key to display in the `attributes` hash for that record. The optional
|
||||
third argument is `omitempty` - if it is present the field will not be present
|
||||
in the `"attributes"` if the field's value is equivalent to the field types
|
||||
empty value (ie if the `count` field is of type `int`, `omitempty` will omit the
|
||||
field when `count` has a value of `0`). Lastly, the spec indicates that
|
||||
`attributes` key names should be dasherized for multiple word field names.
|
||||
|
||||
#### `relation`
|
||||
|
||||
```
|
||||
`jsonapi:"relation,<key name in relationships hash>,<optional: omitempty>"`
|
||||
```
|
||||
|
||||
Relations are struct fields that represent a one-to-one or one-to-many
|
||||
relationship with other structs. JSON API will traverse the graph of
|
||||
relationships and marshal or unmarshal records. The first argument must
|
||||
be, `relation`, and the second should be the name of the relationship,
|
||||
used as the key in the `relationships` hash for the record. The optional
|
||||
third argument is `omitempty` - if present will prevent non existent to-one and
|
||||
to-many from being serialized.
|
||||
|
||||
## Methods Reference
|
||||
|
||||
**All `Marshal` and `Unmarshal` methods expect pointers to struct
|
||||
instance or slices of the same contained with the `interface{}`s**
|
||||
|
||||
Now you have your structs prepared to be seralized or materialized, What
|
||||
about the rest?
|
||||
|
||||
### Create Record Example
|
||||
|
||||
You can Unmarshal a JSON API payload using
|
||||
[jsonapi.UnmarshalPayload](http://godoc.org/github.com/google/jsonapi#UnmarshalPayload).
|
||||
It reads from an [io.Reader](https://golang.org/pkg/io/#Reader)
|
||||
containing a JSON API payload for one record (but can have related
|
||||
records). Then, it materializes a struct that you created and passed in
|
||||
(using new or &). Again, the method supports single records only, at
|
||||
the top level, in request payloads at the moment. Bulk creates and
|
||||
updates are not supported yet.
|
||||
|
||||
After saving your record, you can use,
|
||||
[MarshalOnePayload](http://godoc.org/github.com/google/jsonapi#MarshalOnePayload),
|
||||
to write the JSON API response to an
|
||||
[io.Writer](https://golang.org/pkg/io/#Writer).
|
||||
|
||||
#### `UnmarshalPayload`
|
||||
|
||||
```go
|
||||
UnmarshalPayload(in io.Reader, model interface{})
|
||||
```
|
||||
|
||||
Visit [godoc](http://godoc.org/github.com/google/jsonapi#UnmarshalPayload)
|
||||
|
||||
#### `MarshalPayload`
|
||||
|
||||
```go
|
||||
MarshalPayload(w io.Writer, models interface{}) error
|
||||
```
|
||||
|
||||
Visit [godoc](http://godoc.org/github.com/google/jsonapi#MarshalPayload)
|
||||
|
||||
Writes a JSON API response, with related records sideloaded, into an
|
||||
`included` array. This method encodes a response for either a single record or
|
||||
many records.
|
||||
|
||||
##### Handler Example Code
|
||||
|
||||
```go
|
||||
func CreateBlog(w http.ResponseWriter, r *http.Request) {
|
||||
blog := new(Blog)
|
||||
|
||||
if err := jsonapi.UnmarshalPayload(r.Body, blog); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// ...save your blog...
|
||||
|
||||
w.Header().Set("Content-Type", jsonapi.MediaType)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
|
||||
if err := jsonapi.MarshalPayload(w, blog); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Create Records Example
|
||||
|
||||
#### `UnmarshalManyPayload`
|
||||
|
||||
```go
|
||||
UnmarshalManyPayload(in io.Reader, t reflect.Type) ([]interface{}, error)
|
||||
```
|
||||
|
||||
Visit [godoc](http://godoc.org/github.com/google/jsonapi#UnmarshalManyPayload)
|
||||
|
||||
Takes an `io.Reader` and a `reflect.Type` representing the uniform type
|
||||
contained within the `"data"` JSON API member.
|
||||
|
||||
##### Handler Example Code
|
||||
|
||||
```go
|
||||
func CreateBlogs(w http.ResponseWriter, r *http.Request) {
|
||||
// ...create many blogs at once
|
||||
|
||||
blogs, err := UnmarshalManyPayload(r.Body, reflect.TypeOf(new(Blog)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, blog := range blogs {
|
||||
b, ok := blog.(*Blog)
|
||||
// ...save each of your blogs
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", jsonapi.MediaType)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
|
||||
if err := jsonapi.MarshalPayload(w, blogs); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Links
|
||||
|
||||
If you need to include [link objects](http://jsonapi.org/format/#document-links) along with response data, implement the `Linkable` interface for document-links, and `RelationshipLinkable` for relationship links:
|
||||
|
||||
```go
|
||||
func (post Post) JSONAPILinks() *Links {
|
||||
return &Links{
|
||||
"self": "href": fmt.Sprintf("https://example.com/posts/%d", post.ID),
|
||||
"comments": Link{
|
||||
Href: fmt.Sprintf("https://example.com/api/blogs/%d/comments", post.ID),
|
||||
Meta: map[string]interface{}{
|
||||
"counts": map[string]uint{
|
||||
"likes": 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Invoked for each relationship defined on the Post struct when marshaled
|
||||
func (post Post) JSONAPIRelationshipLinks(relation string) *Links {
|
||||
if relation == "comments" {
|
||||
return &Links{
|
||||
"related": fmt.Sprintf("https://example.com/posts/%d/comments", post.ID),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Meta
|
||||
|
||||
If you need to include [meta objects](http://jsonapi.org/format/#document-meta) along with response data, implement the `Metable` interface for document-meta, and `RelationshipMetable` for relationship meta:
|
||||
|
||||
```go
|
||||
func (post Post) JSONAPIMeta() *Meta {
|
||||
return &Meta{
|
||||
"details": "sample details here",
|
||||
}
|
||||
}
|
||||
|
||||
// Invoked for each relationship defined on the Post struct when marshaled
|
||||
func (post Post) JSONAPIRelationshipMeta(relation string) *Meta {
|
||||
if relation == "comments" {
|
||||
return &Meta{
|
||||
"this": map[string]interface{}{
|
||||
"can": map[string]interface{}{
|
||||
"go": []interface{}{
|
||||
"as",
|
||||
"deep",
|
||||
map[string]interface{}{
|
||||
"as": "required",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Errors
|
||||
This package also implements support for JSON API compatible `errors` payloads using the following types.
|
||||
|
||||
#### `MarshalErrors`
|
||||
```go
|
||||
MarshalErrors(w io.Writer, errs []*ErrorObject) error
|
||||
```
|
||||
|
||||
Writes a JSON API response using the given `[]error`.
|
||||
|
||||
#### `ErrorsPayload`
|
||||
```go
|
||||
type ErrorsPayload struct {
|
||||
Errors []*ErrorObject `json:"errors"`
|
||||
}
|
||||
```
|
||||
|
||||
ErrorsPayload is a serializer struct for representing a valid JSON API errors payload.
|
||||
|
||||
#### `ErrorObject`
|
||||
```go
|
||||
type ErrorObject struct { ... }
|
||||
|
||||
// Error implements the `Error` interface.
|
||||
func (e *ErrorObject) Error() string {
|
||||
return fmt.Sprintf("Error: %s %s\n", e.Title, e.Detail)
|
||||
}
|
||||
```
|
||||
|
||||
ErrorObject is an `Error` implementation as well as an implementation of the JSON API error object.
|
||||
|
||||
The main idea behind this struct is that you can use it directly in your code as an error type and pass it directly to `MarshalErrors` to get a valid JSON API errors payload.
|
||||
|
||||
##### Errors Example Code
|
||||
```go
|
||||
// An error has come up in your code, so set an appropriate status, and serialize the error.
|
||||
if err := validate(&myStructToValidate); err != nil {
|
||||
context.SetStatusCode(http.StatusBadRequest) // Or however you need to set a status.
|
||||
jsonapi.MarshalErrors(w, []*ErrorObject{{
|
||||
Title: "Validation Error",
|
||||
Detail: "Given request body was invalid.",
|
||||
Status: "400",
|
||||
Meta: map[string]interface{}{"field": "some_field", "error": "bad type", "expected": "string", "received": "float64"},
|
||||
}})
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### `MarshalOnePayloadEmbedded`
|
||||
|
||||
```go
|
||||
MarshalOnePayloadEmbedded(w io.Writer, model interface{}) error
|
||||
```
|
||||
|
||||
Visit [godoc](http://godoc.org/github.com/google/jsonapi#MarshalOnePayloadEmbedded)
|
||||
|
||||
This method is not strictly meant to for use in implementation code,
|
||||
although feel free. It was mainly created for use in tests; in most cases,
|
||||
your request payloads for create will be embedded rather than sideloaded
|
||||
for related records. This method will serialize a single struct pointer
|
||||
into an embedded json response. In other words, there will be no,
|
||||
`included`, array in the json; all relationships will be serialized
|
||||
inline with the data.
|
||||
|
||||
However, in tests, you may want to construct payloads to post to create
|
||||
methods that are embedded to most closely model the payloads that will
|
||||
be produced by the client. This method aims to enable that.
|
||||
|
||||
### Example
|
||||
|
||||
```go
|
||||
out := bytes.NewBuffer(nil)
|
||||
|
||||
// testModel returns a pointer to a Blog
|
||||
jsonapi.MarshalOnePayloadEmbedded(out, testModel())
|
||||
|
||||
h := new(BlogsHandler)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r, _ := http.NewRequest(http.MethodPost, "/blogs", out)
|
||||
|
||||
h.CreateBlog(w, r)
|
||||
|
||||
blog := new(Blog)
|
||||
jsonapi.UnmarshalPayload(w.Body, blog)
|
||||
|
||||
// ... assert stuff about blog here ...
|
||||
```
|
||||
|
||||
## Alternative Installation
|
||||
I use git subtrees to manage dependencies rather than `go get` so that
|
||||
the src is committed to my repo.
|
||||
|
||||
```
|
||||
git subtree add --squash --prefix=src/github.com/google/jsonapi https://github.com/google/jsonapi.git master
|
||||
```
|
||||
|
||||
To update,
|
||||
|
||||
```
|
||||
git subtree pull --squash --prefix=src/github.com/google/jsonapi https://github.com/google/jsonapi.git master
|
||||
```
|
||||
|
||||
This assumes that I have my repo structured with a `src` dir containing
|
||||
a collection of packages and `GOPATH` is set to the root
|
||||
folder--containing `src`.
|
||||
|
||||
## Contributing
|
||||
|
||||
Fork, Change, Pull Request *with tests*.
|
|
@ -0,0 +1,55 @@
|
|||
package jsonapi
|
||||
|
||||
const (
|
||||
// StructTag annotation strings
|
||||
annotationJSONAPI = "jsonapi"
|
||||
annotationPrimary = "primary"
|
||||
annotationClientID = "client-id"
|
||||
annotationAttribute = "attr"
|
||||
annotationRelation = "relation"
|
||||
annotationOmitEmpty = "omitempty"
|
||||
annotationISO8601 = "iso8601"
|
||||
annotationSeperator = ","
|
||||
|
||||
iso8601TimeFormat = "2006-01-02T15:04:05Z"
|
||||
|
||||
// MediaType is the identifier for the JSON API media type
|
||||
//
|
||||
// see http://jsonapi.org/format/#document-structure
|
||||
MediaType = "application/vnd.api+json"
|
||||
|
||||
// Pagination Constants
|
||||
//
|
||||
// http://jsonapi.org/format/#fetching-pagination
|
||||
|
||||
// KeyFirstPage is the key to the links object whose value contains a link to
|
||||
// the first page of data
|
||||
KeyFirstPage = "first"
|
||||
// KeyLastPage is the key to the links object whose value contains a link to
|
||||
// the last page of data
|
||||
KeyLastPage = "last"
|
||||
// KeyPreviousPage is the key to the links object whose value contains a link
|
||||
// to the previous page of data
|
||||
KeyPreviousPage = "prev"
|
||||
// KeyNextPage is the key to the links object whose value contains a link to
|
||||
// the next page of data
|
||||
KeyNextPage = "next"
|
||||
|
||||
// QueryParamPageNumber is a JSON API query parameter used in a page based
|
||||
// pagination strategy in conjunction with QueryParamPageSize
|
||||
QueryParamPageNumber = "page[number]"
|
||||
// QueryParamPageSize is a JSON API query parameter used in a page based
|
||||
// pagination strategy in conjunction with QueryParamPageNumber
|
||||
QueryParamPageSize = "page[size]"
|
||||
|
||||
// QueryParamPageOffset is a JSON API query parameter used in an offset based
|
||||
// pagination strategy in conjunction with QueryParamPageLimit
|
||||
QueryParamPageOffset = "page[offset]"
|
||||
// QueryParamPageLimit is a JSON API query parameter used in an offset based
|
||||
// pagination strategy in conjunction with QueryParamPageOffset
|
||||
QueryParamPageLimit = "page[limit]"
|
||||
|
||||
// QueryParamPageCursor is a JSON API query parameter used with a cursor-based
|
||||
// strategy
|
||||
QueryParamPageCursor = "page[cursor]"
|
||||
)
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
Package jsonapi provides a serializer and deserializer for jsonapi.org spec payloads.
|
||||
|
||||
You can keep your model structs as is and use struct field tags to indicate to jsonapi
|
||||
how you want your response built or your request deserialzied. What about my relationships?
|
||||
jsonapi supports relationships out of the box and will even side load them in your response
|
||||
into an "included" array--that contains associated objects.
|
||||
|
||||
jsonapi uses StructField tags to annotate the structs fields that you already have and use
|
||||
in your app and then reads and writes jsonapi.org output based on the instructions you give
|
||||
the library in your jsonapi tags.
|
||||
|
||||
Example structs using a Blog > Post > Comment structure,
|
||||
|
||||
type Blog struct {
|
||||
ID int `jsonapi:"primary,blogs"`
|
||||
Title string `jsonapi:"attr,title"`
|
||||
Posts []*Post `jsonapi:"relation,posts"`
|
||||
CurrentPost *Post `jsonapi:"relation,current_post"`
|
||||
CurrentPostID int `jsonapi:"attr,current_post_id"`
|
||||
CreatedAt time.Time `jsonapi:"attr,created_at"`
|
||||
ViewCount int `jsonapi:"attr,view_count"`
|
||||
}
|
||||
|
||||
type Post struct {
|
||||
ID int `jsonapi:"primary,posts"`
|
||||
BlogID int `jsonapi:"attr,blog_id"`
|
||||
Title string `jsonapi:"attr,title"`
|
||||
Body string `jsonapi:"attr,body"`
|
||||
Comments []*Comment `jsonapi:"relation,comments"`
|
||||
}
|
||||
|
||||
type Comment struct {
|
||||
ID int `jsonapi:"primary,comments"`
|
||||
PostID int `jsonapi:"attr,post_id"`
|
||||
Body string `jsonapi:"attr,body"`
|
||||
}
|
||||
|
||||
jsonapi Tag Reference
|
||||
|
||||
Value, primary: "primary,<type field output>"
|
||||
|
||||
This indicates that this is the primary key field for this struct type. Tag
|
||||
value arguments are comma separated. The first argument must be, "primary", and
|
||||
the second must be the name that should appear in the "type" field for all data
|
||||
objects that represent this type of model.
|
||||
|
||||
Value, attr: "attr,<key name in attributes hash>[,<extra arguments>]"
|
||||
|
||||
These fields' values should end up in the "attribute" hash for a record. The first
|
||||
argument must be, "attr', and the second should be the name for the key to display in
|
||||
the the "attributes" hash for that record.
|
||||
|
||||
The following extra arguments are also supported:
|
||||
|
||||
"omitempty": excludes the fields value from the "attribute" hash.
|
||||
"iso8601": uses the ISO8601 timestamp format when serialising or deserialising the time.Time value.
|
||||
|
||||
Value, relation: "relation,<key name in relationships hash>"
|
||||
|
||||
Relations are struct fields that represent a one-to-one or one-to-many to other structs.
|
||||
jsonapi will traverse the graph of relationships and marshal or unmarshal records. The first
|
||||
argument must be, "relation", and the second should be the name of the relationship, used as
|
||||
the key in the "relationships" hash for the record.
|
||||
|
||||
Use the methods below to Marshal and Unmarshal jsonapi.org json payloads.
|
||||
|
||||
Visit the readme at https://github.com/google/jsonapi
|
||||
*/
|
||||
package jsonapi
|
|
@ -0,0 +1,55 @@
|
|||
package jsonapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// MarshalErrors writes a JSON API response using the given `[]error`.
|
||||
//
|
||||
// For more information on JSON API error payloads, see the spec here:
|
||||
// http://jsonapi.org/format/#document-top-level
|
||||
// and here: http://jsonapi.org/format/#error-objects.
|
||||
func MarshalErrors(w io.Writer, errorObjects []*ErrorObject) error {
|
||||
if err := json.NewEncoder(w).Encode(&ErrorsPayload{Errors: errorObjects}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrorsPayload is a serializer struct for representing a valid JSON API errors payload.
|
||||
type ErrorsPayload struct {
|
||||
Errors []*ErrorObject `json:"errors"`
|
||||
}
|
||||
|
||||
// ErrorObject is an `Error` implementation as well as an implementation of the JSON API error object.
|
||||
//
|
||||
// The main idea behind this struct is that you can use it directly in your code as an error type
|
||||
// and pass it directly to `MarshalErrors` to get a valid JSON API errors payload.
|
||||
// For more information on Golang errors, see: https://golang.org/pkg/errors/
|
||||
// For more information on the JSON API spec's error objects, see: http://jsonapi.org/format/#error-objects
|
||||
type ErrorObject struct {
|
||||
// ID is a unique identifier for this particular occurrence of a problem.
|
||||
ID string `json:"id,omitempty"`
|
||||
|
||||
// Title is a short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization.
|
||||
Title string `json:"title,omitempty"`
|
||||
|
||||
// Detail is a human-readable explanation specific to this occurrence of the problem. Like title, this field’s value can be localized.
|
||||
Detail string `json:"detail,omitempty"`
|
||||
|
||||
// Status is the HTTP status code applicable to this problem, expressed as a string value.
|
||||
Status string `json:"status,omitempty"`
|
||||
|
||||
// Code is an application-specific error code, expressed as a string value.
|
||||
Code string `json:"code,omitempty"`
|
||||
|
||||
// Meta is an object containing non-standard meta-information about the error.
|
||||
Meta *map[string]interface{} `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// Error implements the `Error` interface.
|
||||
func (e *ErrorObject) Error() string {
|
||||
return fmt.Sprintf("Error: %s %s\n", e.Title, e.Detail)
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
package jsonapi
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Payloader is used to encapsulate the One and Many payload types
|
||||
type Payloader interface {
|
||||
clearIncluded()
|
||||
}
|
||||
|
||||
// OnePayload is used to represent a generic JSON API payload where a single
|
||||
// resource (Node) was included as an {} in the "data" key
|
||||
type OnePayload struct {
|
||||
Data *Node `json:"data"`
|
||||
Included []*Node `json:"included,omitempty"`
|
||||
Links *Links `json:"links,omitempty"`
|
||||
Meta *Meta `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
func (p *OnePayload) clearIncluded() {
|
||||
p.Included = []*Node{}
|
||||
}
|
||||
|
||||
// ManyPayload is used to represent a generic JSON API payload where many
|
||||
// resources (Nodes) were included in an [] in the "data" key
|
||||
type ManyPayload struct {
|
||||
Data []*Node `json:"data"`
|
||||
Included []*Node `json:"included,omitempty"`
|
||||
Links *Links `json:"links,omitempty"`
|
||||
Meta *Meta `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
func (p *ManyPayload) clearIncluded() {
|
||||
p.Included = []*Node{}
|
||||
}
|
||||
|
||||
// Node is used to represent a generic JSON API Resource
|
||||
type Node struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
ClientID string `json:"client-id,omitempty"`
|
||||
Attributes map[string]interface{} `json:"attributes,omitempty"`
|
||||
Relationships map[string]interface{} `json:"relationships,omitempty"`
|
||||
Links *Links `json:"links,omitempty"`
|
||||
Meta *Meta `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// RelationshipOneNode is used to represent a generic has one JSON API relation
|
||||
type RelationshipOneNode struct {
|
||||
Data *Node `json:"data"`
|
||||
Links *Links `json:"links,omitempty"`
|
||||
Meta *Meta `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// RelationshipManyNode is used to represent a generic has many JSON API
|
||||
// relation
|
||||
type RelationshipManyNode struct {
|
||||
Data []*Node `json:"data"`
|
||||
Links *Links `json:"links,omitempty"`
|
||||
Meta *Meta `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// Links is used to represent a `links` object.
|
||||
// http://jsonapi.org/format/#document-links
|
||||
type Links map[string]interface{}
|
||||
|
||||
func (l *Links) validate() (err error) {
|
||||
// Each member of a links object is a “link”. A link MUST be represented as
|
||||
// either:
|
||||
// - a string containing the link’s URL.
|
||||
// - an object (“link object”) which can contain the following members:
|
||||
// - href: a string containing the link’s URL.
|
||||
// - meta: a meta object containing non-standard meta-information about the
|
||||
// link.
|
||||
for k, v := range *l {
|
||||
_, isString := v.(string)
|
||||
_, isLink := v.(Link)
|
||||
|
||||
if !(isString || isLink) {
|
||||
return fmt.Errorf(
|
||||
"The %s member of the links object was not a string or link object",
|
||||
k,
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Link is used to represent a member of the `links` object.
|
||||
type Link struct {
|
||||
Href string `json:"href"`
|
||||
Meta Meta `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// Linkable is used to include document links in response data
|
||||
// e.g. {"self": "http://example.com/posts/1"}
|
||||
type Linkable interface {
|
||||
JSONAPILinks() *Links
|
||||
}
|
||||
|
||||
// RelationshipLinkable is used to include relationship links in response data
|
||||
// e.g. {"related": "http://example.com/posts/1/comments"}
|
||||
type RelationshipLinkable interface {
|
||||
// JSONAPIRelationshipLinks will be invoked for each relationship with the corresponding relation name (e.g. `comments`)
|
||||
JSONAPIRelationshipLinks(relation string) *Links
|
||||
}
|
||||
|
||||
// Meta is used to represent a `meta` object.
|
||||
// http://jsonapi.org/format/#document-meta
|
||||
type Meta map[string]interface{}
|
||||
|
||||
// Metable is used to include document meta in response data
|
||||
// e.g. {"foo": "bar"}
|
||||
type Metable interface {
|
||||
JSONAPIMeta() *Meta
|
||||
}
|
||||
|
||||
// RelationshipMetable is used to include relationship meta in response data
|
||||
type RelationshipMetable interface {
|
||||
// JSONRelationshipMeta will be invoked for each relationship with the corresponding relation name (e.g. `comments`)
|
||||
JSONAPIRelationshipMeta(relation string) *Meta
|
||||
}
|
|
@ -0,0 +1,680 @@
|
|||
package jsonapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
unsuportedStructTagMsg = "Unsupported jsonapi tag annotation, %s"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidTime is returned when a struct has a time.Time type field, but
|
||||
// the JSON value was not a unix timestamp integer.
|
||||
ErrInvalidTime = errors.New("Only numbers can be parsed as dates, unix timestamps")
|
||||
// ErrInvalidISO8601 is returned when a struct has a time.Time type field and includes
|
||||
// "iso8601" in the tag spec, but the JSON value was not an ISO8601 timestamp string.
|
||||
ErrInvalidISO8601 = errors.New("Only strings can be parsed as dates, ISO8601 timestamps")
|
||||
// ErrUnknownFieldNumberType is returned when the JSON value was a float
|
||||
// (numeric) but the Struct field was a non numeric type (i.e. not int, uint,
|
||||
// float, etc)
|
||||
ErrUnknownFieldNumberType = errors.New("The struct field was not of a known number type")
|
||||
// ErrInvalidType is returned when the given type is incompatible with the expected type.
|
||||
ErrInvalidType = errors.New("Invalid type provided") // I wish we used punctuation.
|
||||
|
||||
)
|
||||
|
||||
// ErrUnsupportedPtrType is returned when the Struct field was a pointer but
|
||||
// the JSON value was of a different type
|
||||
type ErrUnsupportedPtrType struct {
|
||||
rf reflect.Value
|
||||
t reflect.Type
|
||||
structField reflect.StructField
|
||||
}
|
||||
|
||||
func (eupt ErrUnsupportedPtrType) Error() string {
|
||||
typeName := eupt.t.Elem().Name()
|
||||
kind := eupt.t.Elem().Kind()
|
||||
if kind.String() != "" && kind.String() != typeName {
|
||||
typeName = fmt.Sprintf("%s (%s)", typeName, kind.String())
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"jsonapi: Can't unmarshal %+v (%s) to struct field `%s`, which is a pointer to `%s`",
|
||||
eupt.rf, eupt.rf.Type().Kind(), eupt.structField.Name, typeName,
|
||||
)
|
||||
}
|
||||
|
||||
func newErrUnsupportedPtrType(rf reflect.Value, t reflect.Type, structField reflect.StructField) error {
|
||||
return ErrUnsupportedPtrType{rf, t, structField}
|
||||
}
|
||||
|
||||
// UnmarshalPayload converts an io into a struct instance using jsonapi tags on
|
||||
// struct fields. This method supports single request payloads only, at the
|
||||
// moment. Bulk creates and updates are not supported yet.
|
||||
//
|
||||
// Will Unmarshal embedded and sideloaded payloads. The latter is only possible if the
|
||||
// object graph is complete. That is, in the "relationships" data there are type and id,
|
||||
// keys that correspond to records in the "included" array.
|
||||
//
|
||||
// For example you could pass it, in, req.Body and, model, a BlogPost
|
||||
// struct instance to populate in an http handler,
|
||||
//
|
||||
// func CreateBlog(w http.ResponseWriter, r *http.Request) {
|
||||
// blog := new(Blog)
|
||||
//
|
||||
// if err := jsonapi.UnmarshalPayload(r.Body, blog); err != nil {
|
||||
// http.Error(w, err.Error(), 500)
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// // ...do stuff with your blog...
|
||||
//
|
||||
// w.Header().Set("Content-Type", jsonapi.MediaType)
|
||||
// w.WriteHeader(201)
|
||||
//
|
||||
// if err := jsonapi.MarshalPayload(w, blog); err != nil {
|
||||
// http.Error(w, err.Error(), 500)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
//
|
||||
// Visit https://github.com/google/jsonapi#create for more info.
|
||||
//
|
||||
// model interface{} should be a pointer to a struct.
|
||||
func UnmarshalPayload(in io.Reader, model interface{}) error {
|
||||
payload := new(OnePayload)
|
||||
|
||||
if err := json.NewDecoder(in).Decode(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if payload.Included != nil {
|
||||
includedMap := make(map[string]*Node)
|
||||
for _, included := range payload.Included {
|
||||
key := fmt.Sprintf("%s,%s", included.Type, included.ID)
|
||||
includedMap[key] = included
|
||||
}
|
||||
|
||||
return unmarshalNode(payload.Data, reflect.ValueOf(model), &includedMap)
|
||||
}
|
||||
return unmarshalNode(payload.Data, reflect.ValueOf(model), nil)
|
||||
}
|
||||
|
||||
// UnmarshalManyPayload converts an io into a set of struct instances using
|
||||
// jsonapi tags on the type's struct fields.
|
||||
func UnmarshalManyPayload(in io.Reader, t reflect.Type) ([]interface{}, error) {
|
||||
payload := new(ManyPayload)
|
||||
|
||||
if err := json.NewDecoder(in).Decode(payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
models := []interface{}{} // will be populated from the "data"
|
||||
includedMap := map[string]*Node{} // will be populate from the "included"
|
||||
|
||||
if payload.Included != nil {
|
||||
for _, included := range payload.Included {
|
||||
key := fmt.Sprintf("%s,%s", included.Type, included.ID)
|
||||
includedMap[key] = included
|
||||
}
|
||||
}
|
||||
|
||||
for _, data := range payload.Data {
|
||||
model := reflect.New(t.Elem())
|
||||
err := unmarshalNode(data, model, &includedMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
models = append(models, model.Interface())
|
||||
}
|
||||
|
||||
return models, nil
|
||||
}
|
||||
|
||||
func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("data is not a jsonapi representation of '%v'", model.Type())
|
||||
}
|
||||
}()
|
||||
|
||||
modelValue := model.Elem()
|
||||
modelType := model.Type().Elem()
|
||||
|
||||
var er error
|
||||
|
||||
for i := 0; i < modelValue.NumField(); i++ {
|
||||
fieldType := modelType.Field(i)
|
||||
tag := fieldType.Tag.Get("jsonapi")
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldValue := modelValue.Field(i)
|
||||
|
||||
args := strings.Split(tag, ",")
|
||||
if len(args) < 1 {
|
||||
er = ErrBadJSONAPIStructTag
|
||||
break
|
||||
}
|
||||
|
||||
annotation := args[0]
|
||||
|
||||
if (annotation == annotationClientID && len(args) != 1) ||
|
||||
(annotation != annotationClientID && len(args) < 2) {
|
||||
er = ErrBadJSONAPIStructTag
|
||||
break
|
||||
}
|
||||
|
||||
if annotation == annotationPrimary {
|
||||
if data.ID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check the JSON API Type
|
||||
if data.Type != args[1] {
|
||||
er = fmt.Errorf(
|
||||
"Trying to Unmarshal an object of type %#v, but %#v does not match",
|
||||
data.Type,
|
||||
args[1],
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
// ID will have to be transmitted as astring per the JSON API spec
|
||||
v := reflect.ValueOf(data.ID)
|
||||
|
||||
// Deal with PTRS
|
||||
var kind reflect.Kind
|
||||
if fieldValue.Kind() == reflect.Ptr {
|
||||
kind = fieldType.Type.Elem().Kind()
|
||||
} else {
|
||||
kind = fieldType.Type.Kind()
|
||||
}
|
||||
|
||||
// Handle String case
|
||||
if kind == reflect.String {
|
||||
assign(fieldValue, v)
|
||||
continue
|
||||
}
|
||||
|
||||
// Value was not a string... only other supported type was a numeric,
|
||||
// which would have been sent as a float value.
|
||||
floatValue, err := strconv.ParseFloat(data.ID, 64)
|
||||
if err != nil {
|
||||
// Could not convert the value in the "id" attr to a float
|
||||
er = ErrBadJSONAPIID
|
||||
break
|
||||
}
|
||||
|
||||
// Convert the numeric float to one of the supported ID numeric types
|
||||
// (int[8,16,32,64] or uint[8,16,32,64])
|
||||
var idValue reflect.Value
|
||||
switch kind {
|
||||
case reflect.Int:
|
||||
n := int(floatValue)
|
||||
idValue = reflect.ValueOf(&n)
|
||||
case reflect.Int8:
|
||||
n := int8(floatValue)
|
||||
idValue = reflect.ValueOf(&n)
|
||||
case reflect.Int16:
|
||||
n := int16(floatValue)
|
||||
idValue = reflect.ValueOf(&n)
|
||||
case reflect.Int32:
|
||||
n := int32(floatValue)
|
||||
idValue = reflect.ValueOf(&n)
|
||||
case reflect.Int64:
|
||||
n := int64(floatValue)
|
||||
idValue = reflect.ValueOf(&n)
|
||||
case reflect.Uint:
|
||||
n := uint(floatValue)
|
||||
idValue = reflect.ValueOf(&n)
|
||||
case reflect.Uint8:
|
||||
n := uint8(floatValue)
|
||||
idValue = reflect.ValueOf(&n)
|
||||
case reflect.Uint16:
|
||||
n := uint16(floatValue)
|
||||
idValue = reflect.ValueOf(&n)
|
||||
case reflect.Uint32:
|
||||
n := uint32(floatValue)
|
||||
idValue = reflect.ValueOf(&n)
|
||||
case reflect.Uint64:
|
||||
n := uint64(floatValue)
|
||||
idValue = reflect.ValueOf(&n)
|
||||
default:
|
||||
// We had a JSON float (numeric), but our field was not one of the
|
||||
// allowed numeric types
|
||||
er = ErrBadJSONAPIID
|
||||
break
|
||||
}
|
||||
|
||||
assign(fieldValue, idValue)
|
||||
} else if annotation == annotationClientID {
|
||||
if data.ClientID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldValue.Set(reflect.ValueOf(data.ClientID))
|
||||
} else if annotation == annotationAttribute {
|
||||
attributes := data.Attributes
|
||||
|
||||
if attributes == nil || len(data.Attributes) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
attribute := attributes[args[1]]
|
||||
|
||||
// continue if the attribute was not included in the request
|
||||
if attribute == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
structField := fieldType
|
||||
value, err := unmarshalAttribute(attribute, args, structField, fieldValue)
|
||||
if err != nil {
|
||||
er = err
|
||||
break
|
||||
}
|
||||
|
||||
assign(fieldValue, value)
|
||||
continue
|
||||
|
||||
} else if annotation == annotationRelation {
|
||||
isSlice := fieldValue.Type().Kind() == reflect.Slice
|
||||
|
||||
if data.Relationships == nil || data.Relationships[args[1]] == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if isSlice {
|
||||
// to-many relationship
|
||||
relationship := new(RelationshipManyNode)
|
||||
|
||||
buf := bytes.NewBuffer(nil)
|
||||
|
||||
json.NewEncoder(buf).Encode(data.Relationships[args[1]])
|
||||
json.NewDecoder(buf).Decode(relationship)
|
||||
|
||||
data := relationship.Data
|
||||
models := reflect.New(fieldValue.Type()).Elem()
|
||||
|
||||
for _, n := range data {
|
||||
m := reflect.New(fieldValue.Type().Elem().Elem())
|
||||
|
||||
if err := unmarshalNode(
|
||||
fullNode(n, included),
|
||||
m,
|
||||
included,
|
||||
); err != nil {
|
||||
er = err
|
||||
break
|
||||
}
|
||||
|
||||
models = reflect.Append(models, m)
|
||||
}
|
||||
|
||||
fieldValue.Set(models)
|
||||
} else {
|
||||
// to-one relationships
|
||||
relationship := new(RelationshipOneNode)
|
||||
|
||||
buf := bytes.NewBuffer(nil)
|
||||
|
||||
json.NewEncoder(buf).Encode(
|
||||
data.Relationships[args[1]],
|
||||
)
|
||||
json.NewDecoder(buf).Decode(relationship)
|
||||
|
||||
/*
|
||||
http://jsonapi.org/format/#document-resource-object-relationships
|
||||
http://jsonapi.org/format/#document-resource-object-linkage
|
||||
relationship can have a data node set to null (e.g. to disassociate the relationship)
|
||||
so unmarshal and set fieldValue only if data obj is not null
|
||||
*/
|
||||
if relationship.Data == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
m := reflect.New(fieldValue.Type().Elem())
|
||||
if err := unmarshalNode(
|
||||
fullNode(relationship.Data, included),
|
||||
m,
|
||||
included,
|
||||
); err != nil {
|
||||
er = err
|
||||
break
|
||||
}
|
||||
|
||||
fieldValue.Set(m)
|
||||
|
||||
}
|
||||
|
||||
} else {
|
||||
er = fmt.Errorf(unsuportedStructTagMsg, annotation)
|
||||
}
|
||||
}
|
||||
|
||||
return er
|
||||
}
|
||||
|
||||
func fullNode(n *Node, included *map[string]*Node) *Node {
|
||||
includedKey := fmt.Sprintf("%s,%s", n.Type, n.ID)
|
||||
|
||||
if included != nil && (*included)[includedKey] != nil {
|
||||
return (*included)[includedKey]
|
||||
}
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
// assign will take the value specified and assign it to the field; if
|
||||
// field is expecting a ptr assign will assign a ptr.
|
||||
func assign(field, value reflect.Value) {
|
||||
value = reflect.Indirect(value)
|
||||
|
||||
if field.Kind() == reflect.Ptr {
|
||||
// initialize pointer so it's value
|
||||
// can be set by assignValue
|
||||
field.Set(reflect.New(field.Type().Elem()))
|
||||
assignValue(field.Elem(), value)
|
||||
} else {
|
||||
assignValue(field, value)
|
||||
}
|
||||
}
|
||||
|
||||
// assign assigns the specified value to the field,
|
||||
// expecting both values not to be pointer types.
|
||||
func assignValue(field, value reflect.Value) {
|
||||
switch field.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16,
|
||||
reflect.Int32, reflect.Int64:
|
||||
field.SetInt(value.Int())
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16,
|
||||
reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||
field.SetUint(value.Uint())
|
||||
case reflect.Float32, reflect.Float64:
|
||||
field.SetFloat(value.Float())
|
||||
case reflect.String:
|
||||
field.SetString(value.String())
|
||||
case reflect.Bool:
|
||||
field.SetBool(value.Bool())
|
||||
default:
|
||||
field.Set(value)
|
||||
}
|
||||
}
|
||||
|
||||
func unmarshalAttribute(
|
||||
attribute interface{},
|
||||
args []string,
|
||||
structField reflect.StructField,
|
||||
fieldValue reflect.Value) (value reflect.Value, err error) {
|
||||
value = reflect.ValueOf(attribute)
|
||||
fieldType := structField.Type
|
||||
|
||||
// Handle field of type []string
|
||||
if fieldValue.Type() == reflect.TypeOf([]string{}) {
|
||||
value, err = handleStringSlice(attribute, args, fieldType, fieldValue)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle field of type time.Time
|
||||
if fieldValue.Type() == reflect.TypeOf(time.Time{}) ||
|
||||
fieldValue.Type() == reflect.TypeOf(new(time.Time)) {
|
||||
value, err = handleTime(attribute, args, fieldType, fieldValue)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle field of type struct
|
||||
if fieldValue.Type().Kind() == reflect.Struct {
|
||||
value, err = handleStruct(attribute, args, fieldType, fieldValue)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle field containing slice of structs
|
||||
if fieldValue.Type().Kind() == reflect.Slice {
|
||||
elem := reflect.TypeOf(fieldValue.Interface()).Elem()
|
||||
if elem.Kind() == reflect.Ptr {
|
||||
elem = elem.Elem()
|
||||
}
|
||||
|
||||
if elem.Kind() == reflect.Struct {
|
||||
value, err = handleStructSlice(attribute, args, fieldType, fieldValue)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// JSON value was a float (numeric)
|
||||
if value.Kind() == reflect.Float64 {
|
||||
value, err = handleNumeric(attribute, args, fieldType, fieldValue)
|
||||
return
|
||||
}
|
||||
|
||||
// Field was a Pointer type
|
||||
if fieldValue.Kind() == reflect.Ptr {
|
||||
value, err = handlePointer(attribute, args, fieldType, fieldValue, structField)
|
||||
return
|
||||
}
|
||||
|
||||
// As a final catch-all, ensure types line up to avoid a runtime panic.
|
||||
if fieldValue.Kind() != value.Kind() {
|
||||
err = ErrInvalidType
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func handleStringSlice(
|
||||
attribute interface{},
|
||||
args []string,
|
||||
fieldType reflect.Type,
|
||||
fieldValue reflect.Value) (reflect.Value, error) {
|
||||
v := reflect.ValueOf(attribute)
|
||||
values := make([]string, v.Len())
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
values[i] = v.Index(i).Interface().(string)
|
||||
}
|
||||
|
||||
return reflect.ValueOf(values), nil
|
||||
}
|
||||
|
||||
func handleTime(
|
||||
attribute interface{},
|
||||
args []string,
|
||||
fieldType reflect.Type,
|
||||
fieldValue reflect.Value) (reflect.Value, error) {
|
||||
var isIso8601 bool
|
||||
v := reflect.ValueOf(attribute)
|
||||
|
||||
if len(args) > 2 {
|
||||
for _, arg := range args[2:] {
|
||||
if arg == annotationISO8601 {
|
||||
isIso8601 = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isIso8601 {
|
||||
var tm string
|
||||
if v.Kind() == reflect.String {
|
||||
tm = v.Interface().(string)
|
||||
} else {
|
||||
return reflect.ValueOf(time.Now()), ErrInvalidISO8601
|
||||
}
|
||||
|
||||
t, err := time.Parse(iso8601TimeFormat, tm)
|
||||
if err != nil {
|
||||
return reflect.ValueOf(time.Now()), ErrInvalidISO8601
|
||||
}
|
||||
|
||||
if fieldValue.Kind() == reflect.Ptr {
|
||||
return reflect.ValueOf(&t), nil
|
||||
}
|
||||
|
||||
return reflect.ValueOf(t), nil
|
||||
}
|
||||
|
||||
var at int64
|
||||
|
||||
if v.Kind() == reflect.Float64 {
|
||||
at = int64(v.Interface().(float64))
|
||||
} else if v.Kind() == reflect.Int {
|
||||
at = v.Int()
|
||||
} else {
|
||||
return reflect.ValueOf(time.Now()), ErrInvalidTime
|
||||
}
|
||||
|
||||
t := time.Unix(at, 0)
|
||||
|
||||
return reflect.ValueOf(t), nil
|
||||
}
|
||||
|
||||
func handleNumeric(
|
||||
attribute interface{},
|
||||
args []string,
|
||||
fieldType reflect.Type,
|
||||
fieldValue reflect.Value) (reflect.Value, error) {
|
||||
v := reflect.ValueOf(attribute)
|
||||
floatValue := v.Interface().(float64)
|
||||
|
||||
var kind reflect.Kind
|
||||
if fieldValue.Kind() == reflect.Ptr {
|
||||
kind = fieldType.Elem().Kind()
|
||||
} else {
|
||||
kind = fieldType.Kind()
|
||||
}
|
||||
|
||||
var numericValue reflect.Value
|
||||
|
||||
switch kind {
|
||||
case reflect.Int:
|
||||
n := int(floatValue)
|
||||
numericValue = reflect.ValueOf(&n)
|
||||
case reflect.Int8:
|
||||
n := int8(floatValue)
|
||||
numericValue = reflect.ValueOf(&n)
|
||||
case reflect.Int16:
|
||||
n := int16(floatValue)
|
||||
numericValue = reflect.ValueOf(&n)
|
||||
case reflect.Int32:
|
||||
n := int32(floatValue)
|
||||
numericValue = reflect.ValueOf(&n)
|
||||
case reflect.Int64:
|
||||
n := int64(floatValue)
|
||||
numericValue = reflect.ValueOf(&n)
|
||||
case reflect.Uint:
|
||||
n := uint(floatValue)
|
||||
numericValue = reflect.ValueOf(&n)
|
||||
case reflect.Uint8:
|
||||
n := uint8(floatValue)
|
||||
numericValue = reflect.ValueOf(&n)
|
||||
case reflect.Uint16:
|
||||
n := uint16(floatValue)
|
||||
numericValue = reflect.ValueOf(&n)
|
||||
case reflect.Uint32:
|
||||
n := uint32(floatValue)
|
||||
numericValue = reflect.ValueOf(&n)
|
||||
case reflect.Uint64:
|
||||
n := uint64(floatValue)
|
||||
numericValue = reflect.ValueOf(&n)
|
||||
case reflect.Float32:
|
||||
n := float32(floatValue)
|
||||
numericValue = reflect.ValueOf(&n)
|
||||
case reflect.Float64:
|
||||
n := floatValue
|
||||
numericValue = reflect.ValueOf(&n)
|
||||
default:
|
||||
return reflect.Value{}, ErrUnknownFieldNumberType
|
||||
}
|
||||
|
||||
return numericValue, nil
|
||||
}
|
||||
|
||||
func handlePointer(
|
||||
attribute interface{},
|
||||
args []string,
|
||||
fieldType reflect.Type,
|
||||
fieldValue reflect.Value,
|
||||
structField reflect.StructField) (reflect.Value, error) {
|
||||
t := fieldValue.Type()
|
||||
var concreteVal reflect.Value
|
||||
|
||||
switch cVal := attribute.(type) {
|
||||
case string:
|
||||
concreteVal = reflect.ValueOf(&cVal)
|
||||
case bool:
|
||||
concreteVal = reflect.ValueOf(&cVal)
|
||||
case complex64, complex128, uintptr:
|
||||
concreteVal = reflect.ValueOf(&cVal)
|
||||
case map[string]interface{}:
|
||||
var err error
|
||||
concreteVal, err = handleStruct(attribute, args, fieldType, fieldValue)
|
||||
if err != nil {
|
||||
return reflect.Value{}, newErrUnsupportedPtrType(
|
||||
reflect.ValueOf(attribute), fieldType, structField)
|
||||
}
|
||||
return concreteVal.Elem(), err
|
||||
default:
|
||||
return reflect.Value{}, newErrUnsupportedPtrType(
|
||||
reflect.ValueOf(attribute), fieldType, structField)
|
||||
}
|
||||
|
||||
if t != concreteVal.Type() {
|
||||
return reflect.Value{}, newErrUnsupportedPtrType(
|
||||
reflect.ValueOf(attribute), fieldType, structField)
|
||||
}
|
||||
|
||||
return concreteVal, nil
|
||||
}
|
||||
|
||||
func handleStruct(
|
||||
attribute interface{},
|
||||
args []string,
|
||||
fieldType reflect.Type,
|
||||
fieldValue reflect.Value) (reflect.Value, error) {
|
||||
model := reflect.New(fieldValue.Type())
|
||||
|
||||
data, err := json.Marshal(attribute)
|
||||
if err != nil {
|
||||
return model, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(data, model.Interface())
|
||||
|
||||
if err != nil {
|
||||
return model, err
|
||||
}
|
||||
|
||||
return model, err
|
||||
}
|
||||
|
||||
func handleStructSlice(
|
||||
attribute interface{},
|
||||
args []string,
|
||||
fieldType reflect.Type,
|
||||
fieldValue reflect.Value) (reflect.Value, error) {
|
||||
models := reflect.New(fieldValue.Type()).Elem()
|
||||
dataMap := reflect.ValueOf(attribute).Interface().([]interface{})
|
||||
for _, data := range dataMap {
|
||||
model := reflect.New(fieldValue.Type().Elem()).Elem()
|
||||
modelType := model.Type()
|
||||
|
||||
value, err := handleStruct(data, []string{}, modelType, model)
|
||||
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
models = reflect.Append(models, reflect.Indirect(value))
|
||||
}
|
||||
|
||||
return models, nil
|
||||
}
|
|
@ -0,0 +1,539 @@
|
|||
package jsonapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrBadJSONAPIStructTag is returned when the Struct field's JSON API
|
||||
// annotation is invalid.
|
||||
ErrBadJSONAPIStructTag = errors.New("Bad jsonapi struct tag format")
|
||||
// ErrBadJSONAPIID is returned when the Struct JSON API annotated "id" field
|
||||
// was not a valid numeric type.
|
||||
ErrBadJSONAPIID = errors.New(
|
||||
"id should be either string, int(8,16,32,64) or uint(8,16,32,64)")
|
||||
// ErrExpectedSlice is returned when a variable or argument was expected to
|
||||
// be a slice of *Structs; MarshalMany will return this error when its
|
||||
// interface{} argument is invalid.
|
||||
ErrExpectedSlice = errors.New("models should be a slice of struct pointers")
|
||||
// ErrUnexpectedType is returned when marshalling an interface; the interface
|
||||
// had to be a pointer or a slice; otherwise this error is returned.
|
||||
ErrUnexpectedType = errors.New("models should be a struct pointer or slice of struct pointers")
|
||||
)
|
||||
|
||||
// MarshalPayload writes a jsonapi response for one or many records. The
|
||||
// related records are sideloaded into the "included" array. If this method is
|
||||
// given a struct pointer as an argument it will serialize in the form
|
||||
// "data": {...}. If this method is given a slice of pointers, this method will
|
||||
// serialize in the form "data": [...]
|
||||
//
|
||||
// One Example: you could pass it, w, your http.ResponseWriter, and, models, a
|
||||
// ptr to a Blog to be written to the response body:
|
||||
//
|
||||
// func ShowBlog(w http.ResponseWriter, r *http.Request) {
|
||||
// blog := &Blog{}
|
||||
//
|
||||
// w.Header().Set("Content-Type", jsonapi.MediaType)
|
||||
// w.WriteHeader(http.StatusOK)
|
||||
//
|
||||
// if err := jsonapi.MarshalPayload(w, blog); err != nil {
|
||||
// http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Many Example: you could pass it, w, your http.ResponseWriter, and, models, a
|
||||
// slice of Blog struct instance pointers to be written to the response body:
|
||||
//
|
||||
// func ListBlogs(w http.ResponseWriter, r *http.Request) {
|
||||
// blogs := []*Blog{}
|
||||
//
|
||||
// w.Header().Set("Content-Type", jsonapi.MediaType)
|
||||
// w.WriteHeader(http.StatusOK)
|
||||
//
|
||||
// if err := jsonapi.MarshalPayload(w, blogs); err != nil {
|
||||
// http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
func MarshalPayload(w io.Writer, models interface{}) error {
|
||||
payload, err := Marshal(models)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Marshal does the same as MarshalPayload except it just returns the payload
|
||||
// and doesn't write out results. Useful if you use your own JSON rendering
|
||||
// library.
|
||||
func Marshal(models interface{}) (Payloader, error) {
|
||||
switch vals := reflect.ValueOf(models); vals.Kind() {
|
||||
case reflect.Slice:
|
||||
m, err := convertToSliceInterface(&models)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
payload, err := marshalMany(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if linkableModels, isLinkable := models.(Linkable); isLinkable {
|
||||
jl := linkableModels.JSONAPILinks()
|
||||
if er := jl.validate(); er != nil {
|
||||
return nil, er
|
||||
}
|
||||
payload.Links = linkableModels.JSONAPILinks()
|
||||
}
|
||||
|
||||
if metableModels, ok := models.(Metable); ok {
|
||||
payload.Meta = metableModels.JSONAPIMeta()
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
case reflect.Ptr:
|
||||
// Check that the pointer was to a struct
|
||||
if reflect.Indirect(vals).Kind() != reflect.Struct {
|
||||
return nil, ErrUnexpectedType
|
||||
}
|
||||
return marshalOne(models)
|
||||
default:
|
||||
return nil, ErrUnexpectedType
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalPayloadWithoutIncluded writes a jsonapi response with one or many
|
||||
// records, without the related records sideloaded into "included" array.
|
||||
// If you want to serialize the relations into the "included" array see
|
||||
// MarshalPayload.
|
||||
//
|
||||
// models interface{} should be either a struct pointer or a slice of struct
|
||||
// pointers.
|
||||
func MarshalPayloadWithoutIncluded(w io.Writer, model interface{}) error {
|
||||
payload, err := Marshal(model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload.clearIncluded()
|
||||
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// marshalOne does the same as MarshalOnePayload except it just returns the
|
||||
// payload and doesn't write out results. Useful is you use your JSON rendering
|
||||
// library.
|
||||
func marshalOne(model interface{}) (*OnePayload, error) {
|
||||
included := make(map[string]*Node)
|
||||
|
||||
rootNode, err := visitModelNode(model, &included, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload := &OnePayload{Data: rootNode}
|
||||
|
||||
payload.Included = nodeMapValues(&included)
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// marshalMany does the same as MarshalManyPayload except it just returns the
|
||||
// payload and doesn't write out results. Useful is you use your JSON rendering
|
||||
// library.
|
||||
func marshalMany(models []interface{}) (*ManyPayload, error) {
|
||||
payload := &ManyPayload{
|
||||
Data: []*Node{},
|
||||
}
|
||||
included := map[string]*Node{}
|
||||
|
||||
for _, model := range models {
|
||||
node, err := visitModelNode(model, &included, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload.Data = append(payload.Data, node)
|
||||
}
|
||||
payload.Included = nodeMapValues(&included)
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// MarshalOnePayloadEmbedded - This method not meant to for use in
|
||||
// implementation code, although feel free. The purpose of this
|
||||
// method is for use in tests. In most cases, your request
|
||||
// payloads for create will be embedded rather than sideloaded for
|
||||
// related records. This method will serialize a single struct
|
||||
// pointer into an embedded json response. In other words, there
|
||||
// will be no, "included", array in the json all relationships will
|
||||
// be serailized inline in the data.
|
||||
//
|
||||
// However, in tests, you may want to construct payloads to post
|
||||
// to create methods that are embedded to most closely resemble
|
||||
// the payloads that will be produced by the client. This is what
|
||||
// this method is intended for.
|
||||
//
|
||||
// model interface{} should be a pointer to a struct.
|
||||
func MarshalOnePayloadEmbedded(w io.Writer, model interface{}) error {
|
||||
rootNode, err := visitModelNode(model, nil, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
payload := &OnePayload{Data: rootNode}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func visitModelNode(model interface{}, included *map[string]*Node,
|
||||
sideload bool) (*Node, error) {
|
||||
node := new(Node)
|
||||
|
||||
var er error
|
||||
value := reflect.ValueOf(model)
|
||||
if value.IsNil() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
modelValue := value.Elem()
|
||||
modelType := value.Type().Elem()
|
||||
|
||||
for i := 0; i < modelValue.NumField(); i++ {
|
||||
structField := modelValue.Type().Field(i)
|
||||
tag := structField.Tag.Get(annotationJSONAPI)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldValue := modelValue.Field(i)
|
||||
fieldType := modelType.Field(i)
|
||||
|
||||
args := strings.Split(tag, annotationSeperator)
|
||||
|
||||
if len(args) < 1 {
|
||||
er = ErrBadJSONAPIStructTag
|
||||
break
|
||||
}
|
||||
|
||||
annotation := args[0]
|
||||
|
||||
if (annotation == annotationClientID && len(args) != 1) ||
|
||||
(annotation != annotationClientID && len(args) < 2) {
|
||||
er = ErrBadJSONAPIStructTag
|
||||
break
|
||||
}
|
||||
|
||||
if annotation == annotationPrimary {
|
||||
v := fieldValue
|
||||
|
||||
// Deal with PTRS
|
||||
var kind reflect.Kind
|
||||
if fieldValue.Kind() == reflect.Ptr {
|
||||
kind = fieldType.Type.Elem().Kind()
|
||||
v = reflect.Indirect(fieldValue)
|
||||
} else {
|
||||
kind = fieldType.Type.Kind()
|
||||
}
|
||||
|
||||
// Handle allowed types
|
||||
switch kind {
|
||||
case reflect.String:
|
||||
node.ID = v.Interface().(string)
|
||||
case reflect.Int:
|
||||
node.ID = strconv.FormatInt(int64(v.Interface().(int)), 10)
|
||||
case reflect.Int8:
|
||||
node.ID = strconv.FormatInt(int64(v.Interface().(int8)), 10)
|
||||
case reflect.Int16:
|
||||
node.ID = strconv.FormatInt(int64(v.Interface().(int16)), 10)
|
||||
case reflect.Int32:
|
||||
node.ID = strconv.FormatInt(int64(v.Interface().(int32)), 10)
|
||||
case reflect.Int64:
|
||||
node.ID = strconv.FormatInt(v.Interface().(int64), 10)
|
||||
case reflect.Uint:
|
||||
node.ID = strconv.FormatUint(uint64(v.Interface().(uint)), 10)
|
||||
case reflect.Uint8:
|
||||
node.ID = strconv.FormatUint(uint64(v.Interface().(uint8)), 10)
|
||||
case reflect.Uint16:
|
||||
node.ID = strconv.FormatUint(uint64(v.Interface().(uint16)), 10)
|
||||
case reflect.Uint32:
|
||||
node.ID = strconv.FormatUint(uint64(v.Interface().(uint32)), 10)
|
||||
case reflect.Uint64:
|
||||
node.ID = strconv.FormatUint(v.Interface().(uint64), 10)
|
||||
default:
|
||||
// We had a JSON float (numeric), but our field was not one of the
|
||||
// allowed numeric types
|
||||
er = ErrBadJSONAPIID
|
||||
break
|
||||
}
|
||||
|
||||
node.Type = args[1]
|
||||
} else if annotation == annotationClientID {
|
||||
clientID := fieldValue.String()
|
||||
if clientID != "" {
|
||||
node.ClientID = clientID
|
||||
}
|
||||
} else if annotation == annotationAttribute {
|
||||
var omitEmpty, iso8601 bool
|
||||
|
||||
if len(args) > 2 {
|
||||
for _, arg := range args[2:] {
|
||||
switch arg {
|
||||
case annotationOmitEmpty:
|
||||
omitEmpty = true
|
||||
case annotationISO8601:
|
||||
iso8601 = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if node.Attributes == nil {
|
||||
node.Attributes = make(map[string]interface{})
|
||||
}
|
||||
|
||||
if fieldValue.Type() == reflect.TypeOf(time.Time{}) {
|
||||
t := fieldValue.Interface().(time.Time)
|
||||
|
||||
if t.IsZero() {
|
||||
continue
|
||||
}
|
||||
|
||||
if iso8601 {
|
||||
node.Attributes[args[1]] = t.UTC().Format(iso8601TimeFormat)
|
||||
} else {
|
||||
node.Attributes[args[1]] = t.Unix()
|
||||
}
|
||||
} else if fieldValue.Type() == reflect.TypeOf(new(time.Time)) {
|
||||
// A time pointer may be nil
|
||||
if fieldValue.IsNil() {
|
||||
if omitEmpty {
|
||||
continue
|
||||
}
|
||||
|
||||
node.Attributes[args[1]] = nil
|
||||
} else {
|
||||
tm := fieldValue.Interface().(*time.Time)
|
||||
|
||||
if tm.IsZero() && omitEmpty {
|
||||
continue
|
||||
}
|
||||
|
||||
if iso8601 {
|
||||
node.Attributes[args[1]] = tm.UTC().Format(iso8601TimeFormat)
|
||||
} else {
|
||||
node.Attributes[args[1]] = tm.Unix()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Dealing with a fieldValue that is not a time
|
||||
emptyValue := reflect.Zero(fieldValue.Type())
|
||||
|
||||
// See if we need to omit this field
|
||||
if omitEmpty && reflect.DeepEqual(fieldValue.Interface(), emptyValue.Interface()) {
|
||||
continue
|
||||
}
|
||||
|
||||
strAttr, ok := fieldValue.Interface().(string)
|
||||
if ok {
|
||||
node.Attributes[args[1]] = strAttr
|
||||
} else {
|
||||
node.Attributes[args[1]] = fieldValue.Interface()
|
||||
}
|
||||
}
|
||||
} else if annotation == annotationRelation {
|
||||
var omitEmpty bool
|
||||
|
||||
//add support for 'omitempty' struct tag for marshaling as absent
|
||||
if len(args) > 2 {
|
||||
omitEmpty = args[2] == annotationOmitEmpty
|
||||
}
|
||||
|
||||
isSlice := fieldValue.Type().Kind() == reflect.Slice
|
||||
if omitEmpty &&
|
||||
(isSlice && fieldValue.Len() < 1 ||
|
||||
(!isSlice && fieldValue.IsNil())) {
|
||||
continue
|
||||
}
|
||||
|
||||
if node.Relationships == nil {
|
||||
node.Relationships = make(map[string]interface{})
|
||||
}
|
||||
|
||||
var relLinks *Links
|
||||
if linkableModel, ok := model.(RelationshipLinkable); ok {
|
||||
relLinks = linkableModel.JSONAPIRelationshipLinks(args[1])
|
||||
}
|
||||
|
||||
var relMeta *Meta
|
||||
if metableModel, ok := model.(RelationshipMetable); ok {
|
||||
relMeta = metableModel.JSONAPIRelationshipMeta(args[1])
|
||||
}
|
||||
|
||||
if isSlice {
|
||||
// to-many relationship
|
||||
relationship, err := visitModelNodeRelationships(
|
||||
fieldValue,
|
||||
included,
|
||||
sideload,
|
||||
)
|
||||
if err != nil {
|
||||
er = err
|
||||
break
|
||||
}
|
||||
relationship.Links = relLinks
|
||||
relationship.Meta = relMeta
|
||||
|
||||
if sideload {
|
||||
shallowNodes := []*Node{}
|
||||
for _, n := range relationship.Data {
|
||||
appendIncluded(included, n)
|
||||
shallowNodes = append(shallowNodes, toShallowNode(n))
|
||||
}
|
||||
|
||||
node.Relationships[args[1]] = &RelationshipManyNode{
|
||||
Data: shallowNodes,
|
||||
Links: relationship.Links,
|
||||
Meta: relationship.Meta,
|
||||
}
|
||||
} else {
|
||||
node.Relationships[args[1]] = relationship
|
||||
}
|
||||
} else {
|
||||
// to-one relationships
|
||||
|
||||
// Handle null relationship case
|
||||
if fieldValue.IsNil() {
|
||||
node.Relationships[args[1]] = &RelationshipOneNode{Data: nil}
|
||||
continue
|
||||
}
|
||||
|
||||
relationship, err := visitModelNode(
|
||||
fieldValue.Interface(),
|
||||
included,
|
||||
sideload,
|
||||
)
|
||||
if err != nil {
|
||||
er = err
|
||||
break
|
||||
}
|
||||
|
||||
if sideload {
|
||||
appendIncluded(included, relationship)
|
||||
node.Relationships[args[1]] = &RelationshipOneNode{
|
||||
Data: toShallowNode(relationship),
|
||||
Links: relLinks,
|
||||
Meta: relMeta,
|
||||
}
|
||||
} else {
|
||||
node.Relationships[args[1]] = &RelationshipOneNode{
|
||||
Data: relationship,
|
||||
Links: relLinks,
|
||||
Meta: relMeta,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
er = ErrBadJSONAPIStructTag
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if er != nil {
|
||||
return nil, er
|
||||
}
|
||||
|
||||
if linkableModel, isLinkable := model.(Linkable); isLinkable {
|
||||
jl := linkableModel.JSONAPILinks()
|
||||
if er := jl.validate(); er != nil {
|
||||
return nil, er
|
||||
}
|
||||
node.Links = linkableModel.JSONAPILinks()
|
||||
}
|
||||
|
||||
if metableModel, ok := model.(Metable); ok {
|
||||
node.Meta = metableModel.JSONAPIMeta()
|
||||
}
|
||||
|
||||
return node, nil
|
||||
}
|
||||
|
||||
func toShallowNode(node *Node) *Node {
|
||||
return &Node{
|
||||
ID: node.ID,
|
||||
Type: node.Type,
|
||||
}
|
||||
}
|
||||
|
||||
func visitModelNodeRelationships(models reflect.Value, included *map[string]*Node,
|
||||
sideload bool) (*RelationshipManyNode, error) {
|
||||
nodes := []*Node{}
|
||||
|
||||
for i := 0; i < models.Len(); i++ {
|
||||
n := models.Index(i).Interface()
|
||||
|
||||
node, err := visitModelNode(n, included, sideload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
|
||||
return &RelationshipManyNode{Data: nodes}, nil
|
||||
}
|
||||
|
||||
func appendIncluded(m *map[string]*Node, nodes ...*Node) {
|
||||
included := *m
|
||||
|
||||
for _, n := range nodes {
|
||||
k := fmt.Sprintf("%s,%s", n.Type, n.ID)
|
||||
|
||||
if _, hasNode := included[k]; hasNode {
|
||||
continue
|
||||
}
|
||||
|
||||
included[k] = n
|
||||
}
|
||||
}
|
||||
|
||||
func nodeMapValues(m *map[string]*Node) []*Node {
|
||||
mp := *m
|
||||
nodes := make([]*Node, len(mp))
|
||||
|
||||
i := 0
|
||||
for _, n := range mp {
|
||||
nodes[i] = n
|
||||
i++
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
func convertToSliceInterface(i *interface{}) ([]interface{}, error) {
|
||||
vals := reflect.ValueOf(*i)
|
||||
if vals.Kind() != reflect.Slice {
|
||||
return nil, ErrExpectedSlice
|
||||
}
|
||||
var response []interface{}
|
||||
for x := 0; x < vals.Len(); x++ {
|
||||
response = append(response, vals.Index(x).Interface())
|
||||
}
|
||||
return response, nil
|
||||
}
|
|
@ -0,0 +1,103 @@
|
|||
package jsonapi
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Event int
|
||||
|
||||
const (
|
||||
UnmarshalStart Event = iota
|
||||
UnmarshalStop
|
||||
MarshalStart
|
||||
MarshalStop
|
||||
)
|
||||
|
||||
type Runtime struct {
|
||||
ctx map[string]interface{}
|
||||
}
|
||||
|
||||
type Events func(*Runtime, Event, string, time.Duration)
|
||||
|
||||
var Instrumentation Events
|
||||
|
||||
func NewRuntime() *Runtime { return &Runtime{make(map[string]interface{})} }
|
||||
|
||||
func (r *Runtime) WithValue(key string, value interface{}) *Runtime {
|
||||
r.ctx[key] = value
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Runtime) Value(key string) interface{} {
|
||||
return r.ctx[key]
|
||||
}
|
||||
|
||||
func (r *Runtime) Instrument(key string) *Runtime {
|
||||
return r.WithValue("instrument", key)
|
||||
}
|
||||
|
||||
func (r *Runtime) shouldInstrument() bool {
|
||||
return Instrumentation != nil
|
||||
}
|
||||
|
||||
func (r *Runtime) UnmarshalPayload(reader io.Reader, model interface{}) error {
|
||||
return r.instrumentCall(UnmarshalStart, UnmarshalStop, func() error {
|
||||
return UnmarshalPayload(reader, model)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Runtime) UnmarshalManyPayload(reader io.Reader, kind reflect.Type) (elems []interface{}, err error) {
|
||||
r.instrumentCall(UnmarshalStart, UnmarshalStop, func() error {
|
||||
elems, err = UnmarshalManyPayload(reader, kind)
|
||||
return err
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (r *Runtime) MarshalPayload(w io.Writer, model interface{}) error {
|
||||
return r.instrumentCall(MarshalStart, MarshalStop, func() error {
|
||||
return MarshalPayload(w, model)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Runtime) instrumentCall(start Event, stop Event, c func() error) error {
|
||||
if !r.shouldInstrument() {
|
||||
return c()
|
||||
}
|
||||
|
||||
instrumentationGUID, err := newUUID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
begin := time.Now()
|
||||
Instrumentation(r, start, instrumentationGUID, time.Duration(0))
|
||||
|
||||
if err := c(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
diff := time.Duration(time.Now().UnixNano() - begin.UnixNano())
|
||||
Instrumentation(r, stop, instrumentationGUID, diff)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// citation: http://play.golang.org/p/4FkNSiUDMg
|
||||
func newUUID() (string, error) {
|
||||
uuid := make([]byte, 16)
|
||||
if _, err := io.ReadFull(rand.Reader, uuid); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// variant bits; see section 4.1.1
|
||||
uuid[8] = uuid[8]&^0xc0 | 0x80
|
||||
// version 4 (pseudo-random); see section 4.1.3
|
||||
uuid[6] = uuid[6]&^0xf0 | 0x40
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:]), nil
|
||||
}
|
|
@ -1362,6 +1362,12 @@
|
|||
"revision": "1909bc2f63dc92bb931deace8b8312c4db72d12f",
|
||||
"revisionTime": "2017-08-08T02:16:21Z"
|
||||
},
|
||||
{
|
||||
"checksumSHA1": "p3IB18uJRs4dL2K5yx24MrLYE9A=",
|
||||
"path": "github.com/google/go-querystring/query",
|
||||
"revision": "53e6ce116135b80d037921a7fdd5138cf32d7a8a",
|
||||
"revisionTime": "2017-01-11T10:11:55Z"
|
||||
},
|
||||
{
|
||||
"checksumSHA1": "V/53BpqgOkSDZCX6snQCAkdO2fM=",
|
||||
"path": "github.com/googleapis/gax-go",
|
||||
|
@ -1791,6 +1797,18 @@
|
|||
"revision": "b1a1dbde6fdc11e3ae79efd9039009e22d4ae240",
|
||||
"revisionTime": "2018-03-26T21:11:50Z"
|
||||
},
|
||||
{
|
||||
"checksumSHA1": "CdPSnO0sFf6G9CtXBzLEI9Pcmsw=",
|
||||
"path": "github.com/hashicorp/go-slug",
|
||||
"revision": "cc9d70694ef317c2e6443e20b7927363d69c8b1e",
|
||||
"revisionTime": "2018-07-12T07:51:27Z"
|
||||
},
|
||||
{
|
||||
"checksumSHA1": "926/ijhO8KTdgb02B/++x3w+Ykc=",
|
||||
"path": "github.com/hashicorp/go-tfe",
|
||||
"revision": "0e7cd8ef626181232db2d6886263a3db937708cd",
|
||||
"revisionTime": "2018-08-01T08:24:33Z"
|
||||
},
|
||||
{
|
||||
"checksumSHA1": "85XUnluYJL7F55ptcwdmN8eSOsk=",
|
||||
"path": "github.com/hashicorp/go-uuid",
|
||||
|
@ -2337,6 +2355,12 @@
|
|||
"revision": "bb8f1927f2a9d3ab41c9340aa034f6b803f4359c",
|
||||
"revisionTime": "2018-01-15T19:27:20Z"
|
||||
},
|
||||
{
|
||||
"checksumSHA1": "byoZ+QptbiPnkoOXvGZulTFS9/M=",
|
||||
"path": "github.com/svanharmelen/jsonapi",
|
||||
"revision": "0c0828c3f16d3732cc7edecf49934d95550894e7",
|
||||
"revisionTime": "2018-06-18T11:39:44Z"
|
||||
},
|
||||
{
|
||||
"checksumSHA1": "GwjOkJpLvKznbv5zS2hFbg9fI4g=",
|
||||
"path": "github.com/terraform-providers/terraform-provider-aws",
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue