diff --git a/docs/resources/addon.md b/docs/resources/addon.md new file mode 100644 index 0000000..fe02d03 --- /dev/null +++ b/docs/resources/addon.md @@ -0,0 +1,38 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "clevercloud_addon Resource - terraform-provider-clevercloud" +subcategory: "" +description: |- + Manage any addon through the addon-provider API https://developers.clever-cloud.com/doc/extend/add-ons-api/#add-on-provider-api + List of available providers: + Mailpace https://mailpace.com/ +--- + +# clevercloud_addon (Resource) + +Manage any addon through the [addon-provider API](https://developers.clever-cloud.com/doc/extend/add-ons-api/#add-on-provider-api) + + +List of available providers: + +* [Mailpace](https://mailpace.com/) + + + + +## Schema + +### Required + +- `name` (String) Name of the addon +- `plan` (String) billing plan +- `region` (String) Geographical region where the addon will be deployed (when relevant) +- `third_party_provider` (String) Provider ID + +### Read-Only + +- `configurations` (Map of String, Sensitive) Any configuration exposed by the addon +- `creation_date` (Number) Date of database creation +- `id` (String) Generated unique identifier + + diff --git a/pkg/provider.go b/pkg/provider.go new file mode 100644 index 0000000..fe72886 --- /dev/null +++ b/pkg/provider.go @@ -0,0 +1,31 @@ +package pkg + +import "go.clever-cloud.com/terraform-provider/pkg/tmp" + +func AddonProvidersAsList(providers []tmp.AddonProvider) []string { + return Map(providers, func(provider tmp.AddonProvider) string { + return provider.ID + }) +} + +func LookupAddonProvider(providers []tmp.AddonProvider, providerId string) *tmp.AddonProvider { + return First(providers, func(provider tmp.AddonProvider) bool { + return provider.ID == providerId + }) +} + +func LookupProviderPlan(provider *tmp.AddonProvider, planId string) *tmp.AddonPlan { + if provider == nil { + return nil + } + + return First(provider.Plans, func(plan tmp.AddonPlan) bool { + return plan.Slug == planId + }) +} + +func ProviderPlansAsList(provider *tmp.AddonProvider) []string { + return Map(provider.Plans, func(plan tmp.AddonPlan) string { + return plan.Slug + }) +} diff --git a/pkg/provider/impl/provider.md b/pkg/provider/impl/provider.md index 41bcaeb..cdc11f2 100644 --- a/pkg/provider/impl/provider.md +++ b/pkg/provider/impl/provider.md @@ -1 +1 @@ -CleverCloud provider allow you to interract with CleverCloud platform. +CleverCloud provider allow you to interact with CleverCloud platform. diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index b73d108..11b9686 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -3,6 +3,7 @@ package registry import ( "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/resource" + "go.clever-cloud.com/terraform-provider/pkg/resources/addon" "go.clever-cloud.com/terraform-provider/pkg/resources/cellar" "go.clever-cloud.com/terraform-provider/pkg/resources/cellar/bucket" "go.clever-cloud.com/terraform-provider/pkg/resources/java" @@ -18,9 +19,10 @@ var Datasources = []func() datasource.DataSource{} var Resources = []func() resource.Resource{ cellar.NewResourceCellar, bucket.NewResourceCellarBucket, + addon.NewResourceAddon, + postgresql.NewResourcePostgreSQL, nodejs.NewResourceNodeJS, php.NewResourcePHP, - postgresql.NewResourcePostgreSQL, java.NewResourceJava("war"), scala.NewResourceScala(), static.NewResourceStatic(), diff --git a/pkg/resources/addon/provider_test_block.tf b/pkg/resources/addon/provider_test_block.tf new file mode 100644 index 0000000..ad9d8c2 --- /dev/null +++ b/pkg/resources/addon/provider_test_block.tf @@ -0,0 +1,3 @@ +provider "clevercloud" { + organisation = "%s" +} diff --git a/pkg/resources/addon/resource_addon.go b/pkg/resources/addon/resource_addon.go new file mode 100644 index 0000000..cf1c6c2 --- /dev/null +++ b/pkg/resources/addon/resource_addon.go @@ -0,0 +1,21 @@ +package addon + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "go.clever-cloud.dev/client" +) + +type ResourceAddon struct { + cc *client.Client + org string +} + +func NewResourceAddon() resource.Resource { + return &ResourceAddon{} +} + +func (r *ResourceAddon) Metadata(ctx context.Context, req resource.MetadataRequest, res *resource.MetadataResponse) { + res.TypeName = req.ProviderTypeName + "_addon" +} diff --git a/pkg/resources/addon/resource_addon.md b/pkg/resources/addon/resource_addon.md new file mode 100644 index 0000000..520cbca --- /dev/null +++ b/pkg/resources/addon/resource_addon.md @@ -0,0 +1,6 @@ +Manage any addon through the [addon-provider API](https://developers.clever-cloud.com/doc/extend/add-ons-api/#add-on-provider-api) + + +List of available providers: + +* [Mailpace](https://mailpace.com/) diff --git a/pkg/resources/addon/resource_addon_crud.go b/pkg/resources/addon/resource_addon_crud.go new file mode 100644 index 0000000..bf743aa --- /dev/null +++ b/pkg/resources/addon/resource_addon_crud.go @@ -0,0 +1,188 @@ +package addon + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "go.clever-cloud.com/terraform-provider/pkg" + "go.clever-cloud.com/terraform-provider/pkg/provider" + "go.clever-cloud.com/terraform-provider/pkg/tmp" +) + +// Weird behaviour, but TF can ask for a Resource without having configured a Provider (maybe for Meta and Schema) +// So we need to handle the case there is no ProviderData +func (r *ResourceAddon) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + tflog.Info(ctx, "ResourceAddon.Configure()") + + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + provider, ok := req.ProviderData.(provider.Provider) + if ok { + r.cc = provider.Client() + r.org = provider.Organization() + } +} + +// Create a new resource +func (r *ResourceAddon) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + ad := Addon{} + + resp.Diagnostics.Append(req.Plan.Get(ctx, &ad)...) + if resp.Diagnostics.HasError() { + return + } + + addonsProvidersRes := tmp.GetAddonsProviders(ctx, r.cc) + if addonsProvidersRes.HasError() { + resp.Diagnostics.AddError("failed to get addon providers", addonsProvidersRes.Error().Error()) + return + } + + addonsProviders := addonsProvidersRes.Payload() + + provider := pkg.LookupAddonProvider(*addonsProviders, ad.ThirdPartyProvider.ValueString()) + if provider == nil { + resp.Diagnostics.AddError("This provider does not exists", fmt.Sprintf("available providers are: %s", strings.Join(pkg.AddonProvidersAsList(*addonsProviders), ", "))) + return + } + + plan := pkg.LookupProviderPlan(provider, ad.Plan.ValueString()) + if plan == nil { + resp.Diagnostics.AddError("This plan does not exists", "available plans are: "+strings.Join(pkg.ProviderPlansAsList(provider), ", ")) + return + } + + addonReq := tmp.AddonRequest{ + Name: ad.Name.ValueString(), + Plan: plan.ID, + ProviderID: provider.ID, + Region: ad.Region.ValueString(), + } + + res := tmp.CreateAddon(ctx, r.cc, r.org, addonReq) + if res.HasError() { + resp.Diagnostics.AddError("failed to create addon", res.Error().Error()) + return + } + + ad.ID = pkg.FromStr(res.Payload().ID) + ad.CreationDate = pkg.FromI(res.Payload().CreationDate) + + envRes := tmp.GetAddonEnv(ctx, r.cc, r.org, res.Payload().ID) + if res.HasError() { + resp.Diagnostics.AddError("failed to get addon env", res.Error().Error()) + return + } + + envAsMap := pkg.Reduce(*envRes.Payload(), map[string]attr.Value{}, func(acc map[string]attr.Value, v tmp.EnvVar) map[string]attr.Value { + acc[v.Name] = pkg.FromStr(v.Value) + return acc + }) + ad.Configurations = types.MapValueMust(types.StringType, envAsMap) + + resp.Diagnostics.Append(resp.State.Set(ctx, ad)...) + if resp.Diagnostics.HasError() { + return + } +} + +// Read resource information +func (r *ResourceAddon) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + tflog.Info(ctx, "Addon READ", map[string]interface{}{"request": req}) + + var ad Addon + diags := req.State.Get(ctx, &ad) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + addonRes := tmp.GetAddon(ctx, r.cc, r.org, ad.ID.ValueString()) + if addonRes.HasError() { + resp.Diagnostics.AddError("failed to get addon", addonRes.Error().Error()) + return + } + + addonEnvRes := tmp.GetAddonEnv(ctx, r.cc, r.org, ad.ID.ValueString()) + if addonEnvRes.HasError() { + resp.Diagnostics.AddError("failed to get addon env", addonEnvRes.Error().Error()) + return + } + + envAsMap := pkg.Reduce(*addonEnvRes.Payload(), map[string]attr.Value{}, func(acc map[string]attr.Value, v tmp.EnvVar) map[string]attr.Value { + acc[v.Name] = pkg.FromStr(v.Value) + return acc + }) + + a := addonRes.Payload() + ad.Name = pkg.FromStr(a.Name) + ad.Plan = pkg.FromStr(a.Plan.Slug) + ad.Region = pkg.FromStr(a.Region) + ad.ThirdPartyProvider = pkg.FromStr(a.Provider.ID) + ad.CreationDate = pkg.FromI(a.CreationDate) + ad.Configurations = types.MapValueMust(types.StringType, envAsMap) + + /*addonPGRes := tmp.GetPostgreSQL(ctx, r.cc, pg.ID.ValueString()) + if addonPGRes.IsNotFoundError() { + diags = resp.State.SetAttribute(ctx, path.Root("id"), types.StringUnknown()) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } + if addonPGRes.HasError() { + resp.Diagnostics.AddError("failed to get Postgres resource", addonPGRes.Error().Error()) + }*/ + + diags = resp.State.Set(ctx, ad) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Update resource +func (r *ResourceAddon) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // TODO +} + +// Delete resource +func (r *ResourceAddon) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var ad Addon + + diags := req.State.Get(ctx, &ad) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Addon DELETE", map[string]interface{}{"addon": ad}) + + res := tmp.DeleteAddon(ctx, r.cc, r.org, ad.ID.ValueString()) + if res.IsNotFoundError() { + resp.State.RemoveResource(ctx) + return + } + if res.HasError() { + resp.Diagnostics.AddError("failed to delete addon", res.Error().Error()) + return + } + + resp.State.RemoveResource(ctx) +} + +// Import resource +func (r *ResourceAddon) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + // Save the import identifier in the id attribute + // and call Read() to fill fields + attr := path.Root("id") + resource.ImportStatePassthroughID(ctx, attr, req, resp) +} diff --git a/pkg/resources/addon/resource_addon_schema.go b/pkg/resources/addon/resource_addon_schema.go new file mode 100644 index 0000000..233a762 --- /dev/null +++ b/pkg/resources/addon/resource_addon_schema.go @@ -0,0 +1,52 @@ +package addon + +import ( + "context" + _ "embed" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type Addon struct { + ID types.String `tfsdk:"id"` + ThirdPartyProvider types.String `tfsdk:"third_party_provider"` + Name types.String `tfsdk:"name"` + CreationDate types.Int64 `tfsdk:"creation_date"` + Plan types.String `tfsdk:"plan"` + Region types.String `tfsdk:"region"` + Configurations types.Map `tfsdk:"configurations"` +} + +//go:embed resource_addon.md +var resourcePostgresqlDoc string + +func (r ResourceAddon) Schema(_ context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Version: 0, + MarkdownDescription: resourcePostgresqlDoc, + Attributes: map[string]schema.Attribute{ + // customer provided + "name": schema.StringAttribute{Required: true, MarkdownDescription: "Name of the addon"}, + "plan": schema.StringAttribute{Required: true, MarkdownDescription: "billing plan"}, + "region": schema.StringAttribute{Required: true, MarkdownDescription: "Geographical region where the addon will be deployed (when relevant)"}, + "third_party_provider": schema.StringAttribute{Required: true, MarkdownDescription: "Provider ID"}, + + // provider + "id": schema.StringAttribute{Computed: true, MarkdownDescription: "Generated unique identifier"}, + "creation_date": schema.Int64Attribute{Computed: true, MarkdownDescription: "Date of database creation"}, + "configurations": schema.MapAttribute{ + Computed: true, + Sensitive: true, + MarkdownDescription: "Any configuration exposed by the addon", + ElementType: types.StringType, + }, + }, + } +} + +// https://developer.hashicorp.com/terraform/plugin/framework/resources/state-upgrade#implementing-state-upgrade-support +func (r ResourceAddon) UpgradeState(ctx context.Context) map[int64]resource.StateUpgrader { + return map[int64]resource.StateUpgrader{} +} diff --git a/pkg/resources/addon/resource_addon_test.go b/pkg/resources/addon/resource_addon_test.go new file mode 100644 index 0000000..06b20bc --- /dev/null +++ b/pkg/resources/addon/resource_addon_test.go @@ -0,0 +1,68 @@ +package addon_test + +import ( + "context" + _ "embed" + "fmt" + "os" + "regexp" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "go.clever-cloud.com/terraform-provider/pkg/provider/impl" + "go.clever-cloud.com/terraform-provider/pkg/tmp" + "go.clever-cloud.dev/client" +) + +//go:embed resource_addon_test_block.tf +var addonBlock string + +//go:embed provider_test_block.tf +var providerBlock string + +var protoV6Provider = map[string]func() (tfprotov6.ProviderServer, error){ + "clevercloud": providerserver.NewProtocol6WithError(impl.New("test")()), +} + +func TestAccAddon_basic(t *testing.T) { + rName := fmt.Sprintf("tf-test-mp-%d", time.Now().UnixMilli()) + fullName := fmt.Sprintf("clevercloud_addon.%s", rName) + cc := client.New(client.WithAutoOauthConfig()) + org := os.Getenv("ORGANISATION") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + if org == "" { + t.Fatalf("missing ORGANISATION env var") + } + }, + ProtoV6ProviderFactories: protoV6Provider, + CheckDestroy: func(state *terraform.State) error { + for _, resource := range state.RootModule().Resources { + res := tmp.GetAddon(context.Background(), cc, org, resource.Primary.ID) + if res.IsNotFoundError() { + continue + } + if res.HasError() { + return fmt.Errorf("unexpectd error: %s", res.Error().Error()) + } + + return fmt.Errorf("expect resource '%s' to be deleted", resource.Primary.ID) + } + return nil + }, + Steps: []resource.TestStep{{ + ResourceName: rName, + Config: fmt.Sprintf(providerBlock, org) + fmt.Sprintf(addonBlock, rName, rName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestMatchResourceAttr(fullName, "id", regexp.MustCompile(`^addon_.*`)), + //resource.TestMatchResourceAttr(fullName, "password", regexp.MustCompile(`^[a-zA-Z0-9]+$`)), + // TODO test env var existance + ), + }}, + }) +} diff --git a/pkg/resources/addon/resource_addon_test_block.tf b/pkg/resources/addon/resource_addon_test_block.tf new file mode 100644 index 0000000..b116082 --- /dev/null +++ b/pkg/resources/addon/resource_addon_test_block.tf @@ -0,0 +1,7 @@ + +resource "clevercloud_addon" "%s" { + name = "%s" + third_party_provider = "mailpace" + plan = "clever_solo" + region = "par" +} diff --git a/pkg/resources/cellar/resource_cellar_crud.go b/pkg/resources/cellar/resource_cellar_crud.go index 4dbd724..8225880 100644 --- a/pkg/resources/cellar/resource_cellar_crud.go +++ b/pkg/resources/cellar/resource_cellar_crud.go @@ -45,20 +45,12 @@ func (r *ResourceCellar) Create(ctx context.Context, req resource.CreateRequest, } addonsProviders := addonsProvidersRes.Payload() - var plan tmp.AddonPlan - for i := range *addonsProviders { - addonsProvider := (*addonsProviders)[i] - if addonsProvider.ID == "cellar-addon" { - // Special case because there is only 1 plan for now - for _, pl := range addonsProvider.Plans { - plan = pl - } - } - } - if plan.ID == "" { - resp.Diagnostics.AddError("no plans found", "plan list is empty") + prov := pkg.LookupAddonProvider(*addonsProviders, "cellar-addon") + if prov == nil { + resp.Diagnostics.AddError("failed to fin provider", "") return } + plan := prov.Plans[0] addonReq := tmp.AddonRequest{ Name: cellar.Name.ValueString(), diff --git a/pkg/resources/postgresql/resource_postgresql_crud.go b/pkg/resources/postgresql/resource_postgresql_crud.go index 4348e2f..4d39afc 100644 --- a/pkg/resources/postgresql/resource_postgresql_crud.go +++ b/pkg/resources/postgresql/resource_postgresql_crud.go @@ -45,19 +45,9 @@ func (r *ResourcePostgreSQL) Create(ctx context.Context, req resource.CreateRequ return } - var plan tmp.AddonPlan addonsProviders := addonsProvidersRes.Payload() - for i := range *addonsProviders { - addonsProvider := (*addonsProviders)[i] - if addonsProvider.ID == "postgresql-addon" { - for _, pl := range addonsProvider.Plans { - if pl.Slug == pg.Plan.ValueString() { - tflog.Info(ctx, "Plan matched", map[string]interface{}{"name": pg.Plan.ValueString(), "plan": pl.Slug}) - plan = pl - } - } - } - } + prov := pkg.LookupAddonProvider(*addonsProviders, "postgresql-addon") + plan := pkg.LookupProviderPlan(prov, pg.Plan.ValueString()) if plan.ID == "" { resp.Diagnostics.AddError("failed to find plan", "expect:, got: "+pg.Plan.String()) return diff --git a/pkg/tmp/addon.go b/pkg/tmp/addon.go index f4bc2a6..c991518 100644 --- a/pkg/tmp/addon.go +++ b/pkg/tmp/addon.go @@ -17,13 +17,18 @@ type AddonRequest struct { } type AddonResponse struct { - ID string `json:"id"` - Name string `json:"name"` - RealID string `json:"realId"` - Region string `json:"region"` - Plan AddonPlan `json:"plan"` - CreationDate int64 `json:"creationDate"` - ConfigKeys []string `json:"configKeys"` + ID string `json:"id"` + Name string `json:"name"` + RealID string `json:"realId"` + Region string `json:"region"` + Plan AddonPlan `json:"plan"` + Provider AddonResponseProvider `json:"provider"` + CreationDate int64 `json:"creationDate"` + ConfigKeys []string `json:"configKeys"` +} + +type AddonResponseProvider struct { + ID string `json:"id"` } type AddonPlan struct {