From d9df803de7ade0a60bd69e6febbfd8f5fc056c3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Collignon-Ducret=20R=C3=A9mi?= Date: Wed, 6 Nov 2024 15:56:39 +0100 Subject: [PATCH] feat: keycloak (#90) Closes #86 --- docs/resources/keycloak.md | 30 +++++ pkg/provider.go | 8 +- pkg/registry/registry.go | 2 + pkg/resources/keycloak/crud.go | 162 ++++++++++++++++++++++++ pkg/resources/keycloak/doc.md | 1 + pkg/resources/keycloak/keycloak.go | 21 +++ pkg/resources/keycloak/keycloak_test.go | 65 ++++++++++ pkg/resources/keycloak/schema.go | 46 +++++++ pkg/tmp/addon.go | 21 +++ 9 files changed, 354 insertions(+), 2 deletions(-) create mode 100644 docs/resources/keycloak.md create mode 100644 pkg/resources/keycloak/crud.go create mode 100644 pkg/resources/keycloak/doc.md create mode 100644 pkg/resources/keycloak/keycloak.go create mode 100644 pkg/resources/keycloak/keycloak_test.go create mode 100644 pkg/resources/keycloak/schema.go diff --git a/docs/resources/keycloak.md b/docs/resources/keycloak.md new file mode 100644 index 0000000..fcfb868 --- /dev/null +++ b/docs/resources/keycloak.md @@ -0,0 +1,30 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "clevercloud_keycloak Resource - terraform-provider-clevercloud" +subcategory: "" +description: |- + Manage Keycloak +--- + +# clevercloud_keycloak (Resource) + +Manage Keycloak + + + + +## Schema + +### Required + +- `name` (String) Name of the service + +### Optional + +- `region` (String) Geographical region where the data will be stored + +### Read-Only + +- `creation_date` (Number) Date of database creation +- `host` (String) URL to access Keycloak +- `id` (String) Generated unique identifier diff --git a/pkg/provider.go b/pkg/provider.go index fe72886..f02f7c6 100644 --- a/pkg/provider.go +++ b/pkg/provider.go @@ -1,6 +1,10 @@ package pkg -import "go.clever-cloud.com/terraform-provider/pkg/tmp" +import ( + "strings" + + "go.clever-cloud.com/terraform-provider/pkg/tmp" +) func AddonProvidersAsList(providers []tmp.AddonProvider) []string { return Map(providers, func(provider tmp.AddonProvider) string { @@ -20,7 +24,7 @@ func LookupProviderPlan(provider *tmp.AddonProvider, planId string) *tmp.AddonPl } return First(provider.Plans, func(plan tmp.AddonPlan) bool { - return plan.Slug == planId + return strings.EqualFold(plan.Slug, planId) }) } diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index d65b94f..8defd37 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -8,6 +8,7 @@ import ( "go.clever-cloud.com/terraform-provider/pkg/resources/cellar/bucket" "go.clever-cloud.com/terraform-provider/pkg/resources/docker" "go.clever-cloud.com/terraform-provider/pkg/resources/java" + "go.clever-cloud.com/terraform-provider/pkg/resources/keycloak" "go.clever-cloud.com/terraform-provider/pkg/resources/materiakv" "go.clever-cloud.com/terraform-provider/pkg/resources/mongodb" "go.clever-cloud.com/terraform-provider/pkg/resources/nodejs" @@ -34,4 +35,5 @@ var Resources = []func() resource.Resource{ scala.NewResourceScala(), static.NewResourceStatic(), docker.NewResourceDocker, + keycloak.NewResourceKeycloak, } diff --git a/pkg/resources/keycloak/crud.go b/pkg/resources/keycloak/crud.go new file mode 100644 index 0000000..1d5fa16 --- /dev/null +++ b/pkg/resources/keycloak/crud.go @@ -0,0 +1,162 @@ +package keycloak + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "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 *ResourceKeycloak) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + tflog.Debug(ctx, "ResourceKeycloak.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() + } + + tflog.Warn(ctx, "Keycloak product is still in beta, use it with care") +} + +// Create a new resource +func (r *ResourceKeycloak) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + kc := Keycloak{} + + resp.Diagnostics.Append(req.Plan.Get(ctx, &kc)...) + 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, "keycloak") + + plan := pkg.LookupProviderPlan(provider, "beta") + if plan == nil { + resp.Diagnostics.AddError("This plan does not exists", "available plans are: "+strings.Join(pkg.ProviderPlansAsList(provider), ", ")) + return + } + + addonReq := tmp.AddonRequest{ + Name: kc.Name.ValueString(), + Plan: plan.ID, + ProviderID: "keycloak", + Region: kc.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 + } + + kc.ID = pkg.FromStr(res.Payload().RealID) + kc.CreationDate = pkg.FromI(res.Payload().CreationDate) + + resp.Diagnostics.Append(resp.State.Set(ctx, kc)...) + if resp.Diagnostics.HasError() { + return + } + + kcEnvRes := tmp.GetAddonEnv(ctx, r.cc, r.org, kc.ID.ValueString()) + if kcEnvRes.HasError() { + resp.Diagnostics.AddError("failed to get Keycloak connection infos", kcEnvRes.Error().Error()) + return + } + + kcEnv := *kcEnvRes.Payload() + tflog.Debug(ctx, "API response", map[string]interface{}{ + "payload": fmt.Sprintf("%+v", kcEnv), + }) + + hostEnvVar := pkg.First(kcEnv, func(v tmp.EnvVar) bool { + return v.Name == "CC_KEYCLOAK_URL" + }) + if hostEnvVar == nil { + resp.Diagnostics.AddError("cannot get Keycloak infos", "missing CC_KEYCLOAK_URL env var on created addon") + return + } + + kc.Host = pkg.FromStr(hostEnvVar.Value) + + resp.Diagnostics.Append(resp.State.Set(ctx, kc)...) + if resp.Diagnostics.HasError() { + return + } +} + +// Read resource information +func (r *ResourceKeycloak) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + tflog.Debug(ctx, "Keycloak READ", map[string]interface{}{"request": req}) + + var kc Keycloak + diags := req.State.Get(ctx, &kc) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // TODO + + diags = resp.State.Set(ctx, kc) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Update resource +func (r *ResourceKeycloak) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // TODO +} + +// Delete resource +func (r *ResourceKeycloak) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + kc := Keycloak{} + + diags := req.State.Get(ctx, &kc) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Debug(ctx, "Keycloak DELETE", map[string]interface{}{"keycloak": kc}) + + res := tmp.DeleteAddon(ctx, r.cc, r.org, kc.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 *ResourceKeycloak) 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/keycloak/doc.md b/pkg/resources/keycloak/doc.md new file mode 100644 index 0000000..6978742 --- /dev/null +++ b/pkg/resources/keycloak/doc.md @@ -0,0 +1 @@ +Manage Keycloak \ No newline at end of file diff --git a/pkg/resources/keycloak/keycloak.go b/pkg/resources/keycloak/keycloak.go new file mode 100644 index 0000000..366dfae --- /dev/null +++ b/pkg/resources/keycloak/keycloak.go @@ -0,0 +1,21 @@ +package keycloak + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "go.clever-cloud.dev/client" +) + +type ResourceKeycloak struct { + cc *client.Client + org string +} + +func NewResourceKeycloak() resource.Resource { + return &ResourceKeycloak{} +} + +func (r *ResourceKeycloak) Metadata(ctx context.Context, req resource.MetadataRequest, res *resource.MetadataResponse) { + res.TypeName = req.ProviderTypeName + "_keycloak" +} diff --git a/pkg/resources/keycloak/keycloak_test.go b/pkg/resources/keycloak/keycloak_test.go new file mode 100644 index 0000000..afc913d --- /dev/null +++ b/pkg/resources/keycloak/keycloak_test.go @@ -0,0 +1,65 @@ +package keycloak_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/helper" + "go.clever-cloud.com/terraform-provider/pkg/provider/impl" + "go.clever-cloud.com/terraform-provider/pkg/tmp" + "go.clever-cloud.dev/client" +) + +var protoV6Provider = map[string]func() (tfprotov6.ProviderServer, error){ + "clevercloud": providerserver.NewProtocol6WithError(impl.New("test")()), +} + +func TestAccKeycloak_basic(t *testing.T) { + ctx := context.Background() + rName := fmt.Sprintf("tf-test-kc-%d", time.Now().UnixMilli()) + fullName := fmt.Sprintf("clevercloud_keycloak.%s", rName) + cc := client.New(client.WithAutoOauthConfig()) + org := os.Getenv("ORGANISATION") + providerBlock := helper.NewProvider("clevercloud").SetOrganisation(org).String() + materiakvBlock := helper.NewRessource("clevercloud_keycloak", rName, helper.SetKeyValues(map[string]any{"name": rName, "region": "par"})).String() + + 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(ctx, 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: %+v", resource.Primary.ID, res.Payload()) + } + return nil + }, + Steps: []resource.TestStep{{ + ResourceName: rName, + Config: providerBlock + materiakvBlock, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestMatchResourceAttr(fullName, "id", regexp.MustCompile(`^keycloak_.*`)), + resource.TestMatchResourceAttr(fullName, "host", regexp.MustCompile(`^.*clever-cloud.com$`)), + ), + }}, + }) +} diff --git a/pkg/resources/keycloak/schema.go b/pkg/resources/keycloak/schema.go new file mode 100644 index 0000000..50d82ce --- /dev/null +++ b/pkg/resources/keycloak/schema.go @@ -0,0 +1,46 @@ +package keycloak + +import ( + "context" + _ "embed" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type Keycloak struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + CreationDate types.Int64 `tfsdk:"creation_date"` + Region types.String `tfsdk:"region"` + Host types.String `tfsdk:"host"` +} + +//go:embed doc.md +var resourceKeycloakDoc string + +func (r ResourceKeycloak) Schema(_ context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Version: 0, + MarkdownDescription: resourceKeycloakDoc, + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{Required: true, MarkdownDescription: "Name of the service"}, + "region": schema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString("par"), + MarkdownDescription: "Geographical region where the data will be stored", + }, + "id": schema.StringAttribute{Computed: true, MarkdownDescription: "Generated unique identifier"}, + "creation_date": schema.Int64Attribute{Computed: true, MarkdownDescription: "Date of database creation"}, + "host": schema.StringAttribute{Computed: true, MarkdownDescription: "URL to access Keycloak"}, + }, + } +} + +// https://developer.hashicorp.com/terraform/plugin/framework/resources/state-upgrade#implementing-state-upgrade-support +func (r ResourceKeycloak) UpgradeState(ctx context.Context) map[int64]resource.StateUpgrader { + return map[int64]resource.StateUpgrader{} +} diff --git a/pkg/tmp/addon.go b/pkg/tmp/addon.go index b2ed122..81e3c26 100644 --- a/pkg/tmp/addon.go +++ b/pkg/tmp/addon.go @@ -107,6 +107,27 @@ func GetMongoDB(ctx context.Context, cc *client.Client, mongodbID string) client return client.Get[MongoDB](ctx, cc, path) } +type Keycloak struct { + OwnerID string `json:"ownerId"` + ID string `json:"addonId"` + NetworkgroupID *string `json:"networkgroupId"` + PostgresID string `json:"postgresId"` + FSBucketID string `json:"fsBucketId"` + Applications []KeycloakApplication `json:"applications"` +} +type KeycloakApplication struct { + KeycloakID string `json:"appId"` + KeycloakPlan string `json:"planIdentifier"` + Host string `json:"host"` + JavaApplicationID string `json:"javaId"` +} + +// Not working ? +func GetKeycloak(ctx context.Context, cc *client.Client, organisationID, keycloakID string) client.Response[Keycloak] { + path := fmt.Sprintf("v4/keycloaks/organisations/%s/keycloaks/%s", organisationID, keycloakID) + return client.Get[Keycloak](ctx, cc, path) +} + type DeleteAddonResponse struct { ID int64 `json:"id"` Message string `json:"message"`