Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: multiple buckets / providers support (#48) #177

Merged
merged 7 commits into from Jul 20, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
57 changes: 54 additions & 3 deletions README.md
Expand Up @@ -41,6 +41,7 @@
- [AWS S3 (state) + DynamoDB (lock)](#aws-s3-state--dynamodb-lock)
- [Terraform Cloud](#terraform-cloud)
- [Configuration](#configuration)
- [Multiple buckets/providers](#multiple-bucketsproviders)
- [Available parameters](#available-parameters)
- [Application Options](#application-options)
- [General Provider Options](#general-provider-options)
Expand Down Expand Up @@ -89,6 +90,9 @@ It currently supports several remote state backend providers:
- [Terraform Cloud (remote)](https://www.terraform.io/docs/backends/types/remote.html)
- [GitLab](https://docs.gitlab.com/ee/user/infrastructure/terraform_state.html)

With the upcoming **v1.2.0** update, Terraboard will be now able to handle multiple buckets/providers configuration! 🥳
hbollon marked this conversation as resolved.
Show resolved Hide resolved
Check *configuration* section for more details.

### Overview

The overview presents all the state files in the S3 bucket, by most recent
Expand Down Expand Up @@ -142,12 +146,50 @@ Data resiliency is not paramount though as this dataset can be rebuilt upon your

Terraboard currently supports configuration in three different ways:

1. Environment variables
2. CLI parameters
3. Configuration file (YAML). A configuration file example can be found in the root directory of this repository.
1. Environment variables **(only usable for single provider configuration)**
2. CLI parameters **(only usable for single provider configuration)**
3. Configuration file (YAML). A configuration file example can be found in the root directory of this repository and in the `test/` subdirectory.

**Important: all flags/environment variables related to the providers settings aren't compatible with multi-provider configuration! Instead, you must use the YAML config file to be able to configure multiples buckets/providers.**

The precedence of configurations is as described below.

### Multiple buckets/providers

In order for Terraboard to import states from multiples buckets or even providers, you must use the YAML configuration method:

- Set the `CONFIG_FILE` environment variable or the `-c`/`--config-file` flag to point to a valid YAML config file.
- In the YAML file, specify your desired providers configuration. For example with two MinIO buckets (using the AWS provider with compatible mode):

```yaml
# Needed since MinIO doesn't support versioning or locking
provider:
no-locks: true
no-versioning: true

aws:
- endpoint: http://minio:9000/
region: eu-west-1
s3:
- bucket: test-bucket
force-path-style: true
file-extension:
- .tfstate

- endpoint: http://minio:9000/
region: eu-west-1
s3:
- bucket: test-bucket2
force-path-style: true
file-extension:
- .tfstate
```

In the case of AWS, don't forget to set the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables.

That's it! Terraboard will now fetch these two buckets on DB refresh. You can also mix providers like AWS and Gitlab or anything else.
You can find a ready-to-use Docker example with two *MinIO* buckets in the `test/multiple-minio-buckets/` sub-folder.

### Available parameters

#### Application Options
Expand Down Expand Up @@ -201,6 +243,15 @@ The precedence of configurations is as described below.

#### AWS (and S3 compatible providers) Options

- `--aws-access-key` <default: *$AWS_ACCESS_KEY_ID*> AWS account access key.
- Env: *AWS_ACCESS_KEY_ID*
- Yaml: *aws.access-key*
- `--aws-secret-access-key` <default: *$AWS_SECRET_ACCESS_KEY*> AWS secret account access key.
- Env: *AWS_SECRET_ACCESS_KEY*
- Yaml: *aws.secret-access-key*
- `--aws-session-token` <default: *$AWS_SESSION_TOKEN*> AWS session token.
- Env: *AWS_SESSION_TOKEN*
- Yaml: *aws.session-token*
- `--dynamodb-table` <default: *$AWS_DYNAMODB_TABLE*> AWS DynamoDB table for locks.
- Env: *AWS_DYNAMODB_TABLE*
- Yaml: *aws.dynamodb-table*
Expand Down
18 changes: 12 additions & 6 deletions api/api.go
Expand Up @@ -147,15 +147,21 @@ func StateCompare(w http.ResponseWriter, r *http.Request, d *db.Database) {
}

// GetLocks returns information on locked States
func GetLocks(w http.ResponseWriter, _ *http.Request, sp state.Provider) {
func GetLocks(w http.ResponseWriter, _ *http.Request, sps []state.Provider) {
w.Header().Set("Access-Control-Allow-Origin", "*")
locks, err := sp.GetLocks()
if err != nil {
JSONError(w, "Failed to get locks", err)
return
allLocks := make(map[string]state.LockInfo)
for _, sp := range sps {
locks, err := sp.GetLocks()
if err != nil {
JSONError(w, "Failed to get locks on a provider", err)
return
}
for k, v := range locks {
allLocks[k] = v
}
}

j, err := json.Marshal(locks)
j, err := json.Marshal(allLocks)
if err != nil {
JSONError(w, "Failed to marshal locks", err)
return
Expand Down
63 changes: 50 additions & 13 deletions config/config.go
Expand Up @@ -40,12 +40,15 @@ type S3BucketConfig struct {

// AWSConfig stores the DynamoDB table and S3 Bucket configuration
type AWSConfig struct {
DynamoDBTable string `long:"dynamodb-table" env:"AWS_DYNAMODB_TABLE" yaml:"dynamodb-table" description:"AWS DynamoDB table for locks."`
S3 S3BucketConfig `group:"S3 Options" yaml:"s3"`
Endpoint string `long:"aws-endpoint" env:"AWS_ENDPOINT" yaml:"endpoint" description:"AWS endpoint."`
Region string `long:"aws-region" env:"AWS_REGION" yaml:"region" description:"AWS region."`
APPRoleArn string `long:"aws-role-arn" env:"APP_ROLE_ARN" yaml:"app-role-arn" description:"Role ARN to Assume."`
ExternalID string `long:"aws-external-id" env:"AWS_EXTERNAL_ID" yaml:"external-id" description:"External ID to use when assuming role."`
AccessKey string `long:"aws-access-key" env:"AWS_ACCESS_KEY_ID" yaml:"access-key" description:"AWS account access key."`
SecretAccessKey string `long:"aws-secret-access-key" env:"AWS_SECRET_ACCESS_KEY" yaml:"secret-access-key" description:"AWS secret account access key."`
SessionToken string `long:"aws-session-token" env:"AWS_SESSION_TOKEN" yaml:"session-token" description:"AWS session token."`
DynamoDBTable string `long:"dynamodb-table" env:"AWS_DYNAMODB_TABLE" yaml:"dynamodb-table" description:"AWS DynamoDB table for locks."`
S3 []S3BucketConfig `group:"S3 Options" yaml:"s3"`
Endpoint string `long:"aws-endpoint" env:"AWS_ENDPOINT" yaml:"endpoint" description:"AWS endpoint."`
Region string `long:"aws-region" env:"AWS_REGION" yaml:"region" description:"AWS region."`
APPRoleArn string `long:"aws-role-arn" env:"APP_ROLE_ARN" yaml:"app-role-arn" description:"Role ARN to Assume."`
ExternalID string `long:"aws-external-id" env:"AWS_EXTERNAL_ID" yaml:"external-id" description:"External ID to use when assuming role."`
}

// TFEConfig stores the Terraform Enterprise configuration
Expand Down Expand Up @@ -92,13 +95,13 @@ type Config struct {

DB DBConfig `group:"Database Options" yaml:"database"`

AWS AWSConfig `group:"AWS Options" yaml:"aws"`
AWS []AWSConfig `group:"AWS Options" yaml:"aws"`

TFE TFEConfig `group:"Terraform Enterprise Options" yaml:"tfe"`
TFE []TFEConfig `group:"Terraform Enterprise Options" yaml:"tfe"`

GCP GCPConfig `group:"Google Cloud Platform Options" yaml:"gcp"`
GCP []GCPConfig `group:"Google Cloud Platform Options" yaml:"gcp"`

Gitlab GitlabConfig `group:"GitLab Options" yaml:"gitlab"`
Gitlab []GitlabConfig `group:"GitLab Options" yaml:"gitlab"`

Web WebConfig `group:"Web" yaml:"web"`
}
Expand All @@ -119,14 +122,48 @@ func (c *Config) LoadConfigFromYaml() *Config {
return c
}

// LoadConfig loads the config from flags & environment
func LoadConfig(version string) *Config {
// Initialize Config with one obj per providers arrays
// to allow usage of flags / env variables on single provider configuration
func initDefaultConfig() Config {
var c Config
parser := flags.NewParser(&c, flags.Default)
var awsInitialConfig AWSConfig
var s3InitialConfig S3BucketConfig
var tfeInitialConfig TFEConfig
var gcpInitialConfig GCPConfig
var gitlabInitialConfig GitlabConfig

parseStructFlagsAndEnv(&awsInitialConfig)
c.AWS = append(c.AWS, awsInitialConfig)

parseStructFlagsAndEnv(&s3InitialConfig)
c.AWS[0].S3 = append(c.AWS[0].S3, s3InitialConfig)

parseStructFlagsAndEnv(&tfeInitialConfig)
c.TFE = append(c.TFE, tfeInitialConfig)

parseStructFlagsAndEnv(&gcpInitialConfig)
c.GCP = append(c.GCP, gcpInitialConfig)

parseStructFlagsAndEnv(&gitlabInitialConfig)
c.Gitlab = append(c.Gitlab, gitlabInitialConfig)

return c
}

// Parse flags and env variables to given struct using go-flags
// parser
func parseStructFlagsAndEnv(obj interface{}) {
parser := flags.NewParser(obj, flags.Default)
if _, err := parser.Parse(); err != nil {
fmt.Printf("Failed to parse flags: %s", err)
os.Exit(1)
}
}

// LoadConfig loads the config from flags & environment
func LoadConfig(version string) *Config {
c := initDefaultConfig()
parseStructFlagsAndEnv(&c)
Comment on lines +165 to +166
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this necessary?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's for keeping the env / flags configuration possible with single provider Terraboard.
Indeed, now we have providers array in the config struct:

AWS []AWSConfig `group:"AWS Options" yaml:"aws"`

TFE []TFEConfig `group:"Terraform Enterprise Options" yaml:"tfe"`

GCP []GCPConfig `group:"Google Cloud Platform Options" yaml:"gcp"`

Gitlab []GitlabConfig `group:"GitLab Options" yaml:"gitlab"`

And so they don't have any elements in them on Config instanciation which prevent from using env variables or flags.
c := initDefaultConfig() add an empty struct in each of them and parse go-flags stuff in them. If they're still empty or invalid they will be ignored but with that process we can still use Terraboard with others configuration methods as before (although they are incompatible with multi buckets).

parseStructFlagsAndEnv(interface{}) is just a DRY function for go-flags parsing


if c.ConfigFilePath != "" {
if _, err := os.Stat(c.ConfigFilePath); err == nil {
Expand Down
44 changes: 27 additions & 17 deletions config/config_test.go
Expand Up @@ -22,27 +22,37 @@ func TestLoadConfigFromYaml(t *testing.T) {
Name: "terraboard-db",
NoSync: true,
},
AWS: AWSConfig{
DynamoDBTable: "terraboard-dynamodb",
S3: S3BucketConfig{
Bucket: "terraboard-bucket",
KeyPrefix: "test/",
FileExtension: []string{".tfstate"},
ForcePathStyle: true,
AWS: []AWSConfig{
{
AccessKey: "root",
SecretAccessKey: "mypassword",
DynamoDBTable: "terraboard-dynamodb",
S3: []S3BucketConfig{{
Bucket: "terraboard-bucket",
KeyPrefix: "test/",
FileExtension: []string{".tfstate"},
ForcePathStyle: true,
}},
},
},
TFE: TFEConfig{
Address: "https://tfe.example.com",
Token: "foo",
Organization: "bar",
TFE: []TFEConfig{
{
Address: "https://tfe.example.com",
Token: "foo",
Organization: "bar",
},
},
GCP: GCPConfig{
GCSBuckets: []string{"my-bucket-1", "my-bucket-2"},
GCPSAKey: "/path/to/key",
GCP: []GCPConfig{
{
GCSBuckets: []string{"my-bucket-1", "my-bucket-2"},
GCPSAKey: "/path/to/key",
},
},
Gitlab: GitlabConfig{
Address: "https://gitlab.example.com",
Token: "foo",
Gitlab: []GitlabConfig{
{
Address: "https://gitlab.example.com",
Token: "foo",
},
},
Web: WebConfig{
Port: 39090,
Expand Down
32 changes: 17 additions & 15 deletions config/config_test.yml
Expand Up @@ -11,27 +11,29 @@ database:
no-sync: true

aws:
dynamodb-table: terraboard-dynamodb
s3:
bucket: terraboard-bucket
key-prefix: test/
file-extension: [.tfstate]
force-path-style: true
- access-key: root
secret-access-key: mypassword
dynamodb-table: terraboard-dynamodb
s3:
- bucket: terraboard-bucket
key-prefix: test/
file-extension: [.tfstate]
force-path-style: true

tfe:
address: https://tfe.example.com
token: foo
organization: bar
- address: https://tfe.example.com
token: foo
organization: bar

gcp:
gcs-bucket:
- my-bucket-1
- my-bucket-2
gcp-sa-key-path: /path/to/key
- gcs-bucket:
- my-bucket-1
- my-bucket-2
gcp-sa-key-path: /path/to/key

gitlab:
address: https://gitlab.example.com
token: foo
- address: https://gitlab.example.com
token: foo

web:
port: 39090
Expand Down
33 changes: 33 additions & 0 deletions config/yaml.go
@@ -0,0 +1,33 @@
package config

/*********************************************
* Custom UnmarshalYAML used to define some struct fields
* default values where go-flags ones aren't applicable
* (and so makes them optional)
*********************************************/

func (s *S3BucketConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
type rawS3BucketConfig S3BucketConfig
raw := rawS3BucketConfig{
FileExtension: []string{".tfstate"},
}
if err := unmarshal(&raw); err != nil {
return err
}

*s = S3BucketConfig(raw)
return nil
}

func (s *GitlabConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
type rawGitlabConfig GitlabConfig
raw := rawGitlabConfig{
Address: "https://gitlab.com",
}
if err := unmarshal(&raw); err != nil {
return err
}

*s = GitlabConfig(raw)
return nil
}
raphink marked this conversation as resolved.
Show resolved Hide resolved
11 changes: 9 additions & 2 deletions db/db.go
Expand Up @@ -7,6 +7,7 @@ import (
"net/url"
"strconv"
"strings"
"sync"

"github.com/camptocamp/terraboard/config"
"github.com/camptocamp/terraboard/state"
Expand All @@ -25,6 +26,7 @@ import (
// Database is a wrapping structure to *gorm.DB
type Database struct {
*gorm.DB
lock sync.Mutex
}

var pageSize = 20
Expand Down Expand Up @@ -79,7 +81,7 @@ func Init(config config.DBConfig, debug bool) *Database {
db.Config.Logger.LogMode(logger.Info)
}

d := &Database{db}
d := &Database{DB: db}
raphink marked this conversation as resolved.
Show resolved Hide resolved
if err = d.MigrateLineage(); err != nil {
log.Fatalf("Lineage migration failed: %v\n", err)
}
Expand Down Expand Up @@ -124,11 +126,14 @@ func (db *Database) stateS3toDB(sf *statefile.File, path string, versionID strin
// Check if the associated lineage is already present in lineages table
// If so, it recovers its ID otherwise it inserts it at the same time as the state
var lineage types.Lineage
db.lock.Lock()
err = db.FirstOrCreate(&lineage, types.Lineage{Value: sf.Lineage}).Error
if err != nil || lineage.ID == 0 {
log.Error("Unknown error in stateS3toDB during lineage finding")
log.WithField("error", err).
Error("Unknown error in stateS3toDB during lineage finding", err)
return types.State{}, err
}
db.lock.Unlock()

st = types.State{
Path: path,
Expand Down Expand Up @@ -242,10 +247,12 @@ func (db *Database) UpdateState(st types.State) error {
// InsertVersion inserts an AWS S3 Version in the Database
func (db *Database) InsertVersion(version *state.Version) error {
var v types.Version
db.lock.Lock()
db.FirstOrCreate(&v, types.Version{
VersionID: version.ID,
LastModified: version.LastModified,
})
db.lock.Unlock()
return nil
}

Expand Down