Skip to content

Commit

Permalink
Merge pull request #112 from DopplerHQ/andre/inheritance
Browse files Browse the repository at this point in the history
Add support for config inheritance
  • Loading branch information
nmanoogian authored Dec 12, 2024
2 parents 89c7414 + fb5ac51 commit 2a32bca
Show file tree
Hide file tree
Showing 4 changed files with 252 additions and 13 deletions.
6 changes: 6 additions & 0 deletions docs/resources/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,14 @@ resource "doppler_config" "backend_ci_github" {
- `name` (String) The name of the Doppler config
- `project` (String) The name of the Doppler project where the config is located

### Optional

- `inheritable` (Boolean) Whether or not the Doppler config can be inherited by other configs
- `inherits` (List of String) A list of other Doppler config descriptors that this config inherits from. Descriptors match the format "project.config" (e.g. backend.stg), which is most easily retrieved as the computed descriptor of a doppler_config resource (e.g. doppler_config.backend_stg.descriptor)

### Read-Only

- `descriptor` (String) The descriptor (project.config) of the Doppler config
- `id` (String) The ID of this resource.

## Import
Expand Down
49 changes: 49 additions & 0 deletions doppler/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,55 @@ func (client APIClient) RenameConfig(ctx context.Context, project string, curren
return &result.Config, nil
}

func (client APIClient) UpdateConfigInheritable(ctx context.Context, project string, config string, inheritable bool) (*Config, error) {
payload := map[string]interface{}{
"project": project,
"config": config,
"inheritable": inheritable,
}
body, err := json.Marshal(payload)
if err != nil {
return nil, &APIError{Err: err, Message: "Unable to serialize config"}
}
response, err := client.PerformRequestWithRetry(ctx, "POST", "/v3/configs/config/inheritable", []QueryParam{}, body)
if err != nil {
return nil, err
}
var result ConfigResponse
if err = json.Unmarshal(response.Body, &result); err != nil {
return nil, &APIError{Err: err, Message: "Unable to parse config"}
}
return &result.Config, nil
}

func (client APIClient) UpdateConfigInherits(ctx context.Context, project string, config string, inherits []ConfigDescriptor) (*Config, error) {
payload := map[string]interface{}{
"project": project,
"config": config,
"inherits": inherits,
}

if len(inherits) == 0 {
// If we don't manually instantiate an empty array here, go will marshal the inherits property as nil instead of []
payload["inherits"] = []ConfigDescriptor{}
}

body, err := json.Marshal(payload)

if err != nil {
return nil, &APIError{Err: err, Message: "Unable to serialize config"}
}
response, err := client.PerformRequestWithRetry(ctx, "POST", "/v3/configs/config/inherits", []QueryParam{}, body)
if err != nil {
return nil, err
}
var result ConfigResponse
if err = json.Unmarshal(response.Body, &result); err != nil {
return nil, &APIError{Err: err, Message: "Unable to parse config"}
}
return &result.Config, nil
}

func (client APIClient) DeleteConfig(ctx context.Context, project string, name string) error {
payload := map[string]interface{}{
"project": project,
Expand Down
21 changes: 14 additions & 7 deletions doppler/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,13 +171,20 @@ type WebhookResponse struct {
}

type Config struct {
Slug string `json:"slug"`
Name string `json:"name"`
Project string `json:"project"`
Environment string `json:"environment"`
Locked bool `json:"locked"`
Root bool `json:"root"`
CreatedAt string `json:"created_at"`
Slug string `json:"slug"`
Name string `json:"name"`
Project string `json:"project"`
Environment string `json:"environment"`
Locked bool `json:"locked"`
Root bool `json:"root"`
CreatedAt string `json:"created_at"`
Inheritable bool `json:"inheritable"`
Inherits []ConfigDescriptor `json:"inherits"`
}

type ConfigDescriptor struct {
Project string `json:"project"`
Config string `json:"config"`
}

type ConfigResponse struct {
Expand Down
189 changes: 183 additions & 6 deletions doppler/resource_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package doppler

import (
"context"
"fmt"
"reflect"
"strings"

"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
Expand Down Expand Up @@ -36,23 +39,118 @@ func resourceConfig() *schema.Resource {
Type: schema.TypeString,
Required: true,
},
"descriptor": {
Description: "The descriptor (project.config) of the Doppler config",
Type: schema.TypeString,
Required: false,
Computed: true,
},
"inheritable": {
Description: "Whether or not the Doppler config can be inherited by other configs",
Type: schema.TypeBool,
Optional: true,
},
"inherits": {
Description: "A list of other Doppler config descriptors that this config inherits from. Descriptors match the format \"project.config\" (e.g. backend.stg), which is most easily retrieved as the computed descriptor of a doppler_config resource (e.g. doppler_config.backend_stg.descriptor)",
Optional: true,
Type: schema.TypeList,
Elem: &schema.Schema{
Type: schema.TypeString,
},
},
},
}
}

func inheritsArgToDescriptors(inherits []interface{}) ([]ConfigDescriptor, error) {
var descriptors []ConfigDescriptor

for _, descriptor := range inherits {
split := strings.Split(descriptor.(string), ".")
if len(split) != 2 {
return nil, fmt.Errorf("Unable to parse [%s] as descriptor", descriptor)
}
descriptors = append(descriptors, ConfigDescriptor{Project: split[0], Config: split[1]})
}

return descriptors, nil
}

func resourceConfigCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
client := m.(APIClient)

var diags diag.Diagnostics
project := d.Get("project").(string)
environment := d.Get("environment").(string)
name := d.Get("name").(string)
inheritable := d.Get("inheritable").(bool)
inherits := d.Get("inherits").([]interface{})

var config *Config
var err error

if name == environment {
// By definition, root configs share the same name as their environment. If the user attempted to define
// a resource for the root config (which would have required an environment to already be created), we
// should just fetch the root config instead of attempting to create it, which would fail.
config, err = client.GetConfig(ctx, project, name)
} else {
config, err = client.CreateConfig(ctx, project, environment, name)
}

config, err := client.CreateConfig(ctx, project, environment, name)
if err != nil {
return diag.FromErr(err)
}

if err = d.Set("descriptor", fmt.Sprintf("%s.%s", config.Project, config.Name)); err != nil {
return diag.FromErr(err)
}

updateInheritable := func() diag.Diagnostics {
if config.Inheritable != inheritable {
// Configs are always created as not inheritable, and inheritability cannot be specified during the creation request.
config, err = client.UpdateConfigInheritable(ctx, project, name, inheritable)
if err != nil {
return diag.FromErr(err)
}
}
return nil
}

updateInherits := func() diag.Diagnostics {
descriptors, err := inheritsArgToDescriptors(inherits)
if err != nil {
return diag.FromErr(err)
}
if !reflect.DeepEqual(config.Inherits, descriptors) {
config, err = client.UpdateConfigInherits(ctx, project, name, descriptors)
if err != nil {
return diag.FromErr(err)
}
}
return nil
}

if inheritable {
// Regardless of change status, if the new state of the resource is inheritable, we must always update the inherits list first.
// In practice, this should be a no-op or an update to an empty list because inheritable configs may not inherit but we'll let the API enforce that.
if subdiags := updateInherits(); subdiags != nil {
return subdiags
}
if subdiags := updateInheritable(); subdiags != nil {
return subdiags
}
} else {
// If the new state has inheritable as false, we must update the inheritability first
// because if it is changing from true to false, we need to do that before we can update the inherits list.
if subdiags := updateInheritable(); subdiags != nil {
return subdiags
}
if subdiags := updateInherits(); subdiags != nil {
return subdiags
}
}

d.SetId(config.getResourceId())

return diags
Expand All @@ -68,11 +166,64 @@ func resourceConfigUpdate(ctx context.Context, d *schema.ResourceData, m interfa
}
newName := d.Get("name").(string)

config, err := client.RenameConfig(ctx, project, currentName, newName)
if err != nil {
return diag.FromErr(err)
if d.HasChange("name") {
config, err := client.RenameConfig(ctx, project, currentName, newName)
if err != nil {
return diag.FromErr(err)
}
d.SetId(config.getResourceId())
if err = d.Set("descriptor", fmt.Sprintf("%s.%s", config.Project, config.Name)); err != nil {
return diag.FromErr(err)
}
}
d.SetId(config.getResourceId())

updateInheritable := func() diag.Diagnostics {
if d.HasChange("inheritable") {
_, err = client.UpdateConfigInheritable(ctx, project, newName, d.Get("inheritable").(bool))
if err != nil {
return diag.FromErr(err)
}
}
return nil
}

updateInherits := func() diag.Diagnostics {
if d.HasChange("inherits") {
inherits := d.Get("inherits").([]interface{})

descriptors, nil := inheritsArgToDescriptors(inherits)
if err != nil {
return diag.FromErr(err)
}

_, err = client.UpdateConfigInherits(ctx, project, newName, descriptors)
if err != nil {
return diag.FromErr(err)
}
}
return nil
}

if d.Get("inheritable").(bool) {
// Regardless of change status, if the new state of the resource is inheritable, we must always update the inherits list first.
// In practice, this should be a no-op or an update to an empty list because inheritable configs may not inherit but we'll let the API enforce that.
if subdiags := updateInherits(); subdiags != nil {
return subdiags
}
if subdiags := updateInheritable(); subdiags != nil {
return subdiags
}
} else {
// If the new state has inheritable as false, we must update the inheritability first
// because if it is changing from true to false, we need to do that before we can update the inherits list.
if subdiags := updateInheritable(); subdiags != nil {
return subdiags
}
if subdiags := updateInherits(); subdiags != nil {
return subdiags
}
}

return diags
}

Expand Down Expand Up @@ -102,17 +253,43 @@ func resourceConfigRead(ctx context.Context, d *schema.ResourceData, m interface
return diag.FromErr(err)
}

if err = d.Set("descriptor", fmt.Sprintf("%s.%s", config.Project, config.Name)); err != nil {
return diag.FromErr(err)
}

if err = d.Set("inheritable", config.Inheritable); err != nil {
return diag.FromErr(err)
}

var descriptorsStrs []string

for _, descriptor := range config.Inherits {
descriptorsStrs = append(descriptorsStrs, fmt.Sprintf("%s.%s", descriptor.Project, descriptor.Config))
}

if err = d.Set("inherits", descriptorsStrs); err != nil {
return diag.FromErr(err)
}
return diags
}

func resourceConfigDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
client := m.(APIClient)

var diags diag.Diagnostics
project, _, name, err := parseConfigResourceId(d.Id())
project, env, name, err := parseConfigResourceId(d.Id())
if err != nil {
return diag.FromErr(err)
}
if env == name {
return diag.Diagnostics{
diag.Diagnostic{
Severity: diag.Warning,
Summary: "Root configs do not need to be manually deleted",
Detail: `Root configs are implicitly created/deleted along with their environments and cannot be manually deleted. Deleting the environment that contains this root config will result in the root config being deleted.`,
},
}
}

if err = client.DeleteConfig(ctx, project, name); err != nil {
return diag.FromErr(err)
Expand Down

0 comments on commit 2a32bca

Please sign in to comment.