diff --git a/.gitignore b/.gitignore index 794d844..1389164 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ *.log -.terraform.d/ -.terraform.lock.hcl -.terraform/ -terraform.tfstate +.terraform* +terraform.tfstate* diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..c6f1023 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Attach to provider", + "type": "go", + "request": "attach", + "mode": "local", + "processId": "${command:pickGoProcess}" + }, + ] +} diff --git a/examples/create_cluster/main.tf b/examples/create_cluster/main.tf index ebecc57..50430f3 100644 --- a/examples/create_cluster/main.tf +++ b/examples/create_cluster/main.tf @@ -35,3 +35,28 @@ resource "ocm_cluster" "my_cluster" { fake_cluster = "true" } } + +resource "ocm_identity_provider" "my_htpasswd" { + cluster_id = ocm_cluster.my_cluster.id + name = "my-htpasswd" + htpasswd { + user = "my-user" + password = "my-password" + } +} + +resource "ocm_identity_provider" "my_ldap" { + cluster_id = ocm_cluster.my_cluster.id + name = "my-ldap" + ldap { + bind_dn = "my-bind-dn" + bind_password = "my-bind-password" + url = "https://my-server.com" + attributes { + id = ["my-id"] + email = ["my-email"] + name = ["my-name"] + preferred_username = ["my-preferred-username"] + } + } +} diff --git a/ocm/keys.go b/ocm/keys.go index d7580dd..2849f3b 100644 --- a/ocm/keys.go +++ b/ocm/keys.go @@ -20,21 +20,30 @@ limitations under the License. package ocm const ( - clientIDKey = "client_id" - clientSecretKey = "client_secret" - cloudProviderKey = "cloud_provider" - cloudRegionKey = "cloud_region" - desiredStateKey = "desired_state" - idsKey = "ids" - insecureKey = "insecure" - nameKey = "name" - passwordKey = "password" - propertiesKey = "properties" - stateKey = "state" - tokenKey = "token" - tokenURLKey = "token_url" - trustedCAsKey = "trusted_cas" - urlKey = "url" - userKey = "user" - waitKey = "wait" + attributesKey = "attributes" + bindDNKey = "bind_dn" + bindPasswordKey = "bind_password" + caKey = "ca" + clientIDKey = "client_id" + clientSecretKey = "client_secret" + cloudProviderKey = "cloud_provider" + cloudRegionKey = "cloud_region" + clusterIDKey = "cluster_id" + emailKey = "email" + htpasswdKey = "htpasswd" + idKey = "id" + idsKey = "ids" + insecureKey = "insecure" + ldapKey = "ldap" + nameKey = "name" + passwordKey = "password" + preferredUsernameKey = "preferred_username" + propertiesKey = "properties" + stateKey = "state" + tokenKey = "token" + tokenURLKey = "token_url" + trustedCAsKey = "trusted_cas" + urlKey = "url" + userKey = "user" + waitKey = "wait" ) diff --git a/ocm/provider.go b/ocm/provider.go index 9668dc2..b06b493 100644 --- a/ocm/provider.go +++ b/ocm/provider.go @@ -119,7 +119,8 @@ func Provider() *schema.Provider { }, }, ResourcesMap: map[string]*schema.Resource{ - "ocm_cluster": resourceCluster(), + "ocm_cluster": resourceCluster(), + "ocm_identity_provider": resourceIdentityProvider(), }, DataSourcesMap: map[string]*schema.Resource{ "ocm_cloud_providers": dataSourceCloudProviders(), diff --git a/ocm/resource_cluster.go b/ocm/resource_cluster.go index a83659a..bc88ed7 100644 --- a/ocm/resource_cluster.go +++ b/ocm/resource_cluster.go @@ -104,10 +104,6 @@ func resourceClusterCreate(ctx context.Context, data *schema.ResourceData, return } cluster = addResponse.Body() - result = resourceClusterParse(cluster, data) - if result.HasError() { - return - } } // Wait till the cluster is ready: @@ -126,10 +122,6 @@ func resourceClusterCreate(ctx context.Context, data *schema.ResourceData, // Copy the cluster data: result = resourceClusterParse(cluster, data) - if result.HasError() { - return - } - return } @@ -320,8 +312,9 @@ func resourceClusterLookup(ctx context.Context, connection *sdk.Connection, } // Try to locate the cluster using the name: - clusterName := data.Get("name").(string) - if clusterName != "" { + value, ok := data.GetOk(nameKey) + if ok { + clusterName := value.(string) listResponse, err := clustersResource.List(). Search(fmt.Sprintf("name = '%s'", clusterName)). Size(1). @@ -330,7 +323,7 @@ func resourceClusterLookup(ctx context.Context, connection *sdk.Connection, result = append(result, diag.Diagnostic{ Severity: diag.Error, Summary: fmt.Sprintf( - "can't fetch clusters with name '%s'", + "can't find clusters with name '%s'", clusterName, ), Detail: err.Error(), diff --git a/ocm/resource_identity_provider.go b/ocm/resource_identity_provider.go new file mode 100644 index 0000000..309bd65 --- /dev/null +++ b/ocm/resource_identity_provider.go @@ -0,0 +1,499 @@ +/* +Copyright (c) 2021 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ocm + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + sdk "github.com/openshift-online/ocm-sdk-go" + cmv1 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1" +) + +func resourceIdentityProvider() *schema.Resource { + return &schema.Resource{ + Description: "Creates an identity provider.", + Schema: map[string]*schema.Schema{ + clusterIDKey: { + Description: "Identifier of the cluster.", + Type: schema.TypeString, + Required: true, + }, + nameKey: { + Description: "Name of the identity provider.", + Type: schema.TypeString, + Required: true, + }, + htpasswdKey: { + Description: "Details for the 'htpassw' identity provider.", + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + ConflictsWith: []string{ + ldapKey, + }, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + userKey: { + Description: "User name.", + Type: schema.TypeString, + Required: true, + }, + passwordKey: { + Description: "User password.", + Type: schema.TypeString, + Required: true, + Sensitive: true, + }, + }, + }, + }, + ldapKey: { + Description: "Details for the LDAP identity provider.", + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + ConflictsWith: []string{ + htpasswdKey, + }, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + attributesKey: { + Type: schema.TypeList, + Required: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + emailKey: { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + idKey: { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + nameKey: { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + preferredUsernameKey: { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + }, + }, + bindDNKey: { + Type: schema.TypeString, + Required: true, + }, + bindPasswordKey: { + Type: schema.TypeString, + Required: true, + }, + caKey: { + Type: schema.TypeString, + Optional: true, + }, + insecureKey: { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + urlKey: { + Type: schema.TypeString, + Required: true, + }, + }, + }, + }, + }, + CreateContext: resourceIdentityProviderCreate, + ReadContext: resourceIdentityProviderRead, + UpdateContext: resourceIdentityProviderUpdate, + DeleteContext: resourceIdentityProviderDelete, + } +} + +func resourceIdentityProviderCreate(ctx context.Context, data *schema.ResourceData, + config interface{}) (result diag.Diagnostics) { + // Get the connection: + connection := config.(*sdk.Connection) + + // Check if the identity provider already exists. If it does exist then we don't need to do + // anything else. + var ip *cmv1.IdentityProvider + ip, result = resourceIdentityProviderLookup(ctx, connection, data) + if result.HasError() { + return + } + + // Currently we need to wait till the cluster is ready because the server explicitly rejects + // requests to create identity providers when the cluster isn't ready yet: + clusterID := data.Get(clusterIDKey).(string) + clusterResource := connection.ClustersMgmt().V1().Clusters().Cluster(clusterID) + clusterResource.Poll(). + Interval(1 * time.Minute). + Predicate(func(getResponse *cmv1.ClusterGetResponse) bool { + cluster := getResponse.Body() + return cluster.State() == cmv1.ClusterStateReady + }). + StartContext(ctx) + + // If the identity provider doesn't exist yet then try to create it: + if ip == nil { + ip, result = resourceIdentityProviderRender(data) + if result.HasError() { + return + } + ipsResource := clusterResource.IdentityProviders() + addResponse, err := ipsResource.Add(). + Body(ip). + SendContext(ctx) + if err != nil { + result = diag.FromErr(err) + return + } + ip = addResponse.Body() + } + + // Copy the identity provider data: + result = resourceIdentityProviderParse(ip, data) + return +} + +func resourceIdentityProviderRead(ctx context.Context, data *schema.ResourceData, + config interface{}) (result diag.Diagnostics) { + // Get the connection: + connection := config.(*sdk.Connection) + + // Try to find the identity provider: + var ip *cmv1.IdentityProvider + ip, result = resourceIdentityProviderLookup(ctx, connection, data) + if result.HasError() { + return + } + + // If there is no matching identity provider the mark it for creation: + if ip == nil { + data.SetId("") + return + } + + // Parse the identity provider data: + result = resourceIdentityProviderParse(ip, data) + if result.HasError() { + return + } + + return +} + +func resourceIdentityProviderUpdate(ctx context.Context, data *schema.ResourceData, + config interface{}) (result diag.Diagnostics) { + return +} + +func resourceIdentityProviderDelete(ctx context.Context, data *schema.ResourceData, + config interface{}) (result diag.Diagnostics) { + // Get the connection: + connection := config.(*sdk.Connection) + + // Send the request to delete the identity provider: + clusterID := data.Get(clusterIDKey).(string) + ipID := data.Id() + clusterResource := connection.ClustersMgmt().V1().Clusters().Cluster(clusterID) + ipResource := clusterResource.IdentityProviders().IdentityProvider(ipID) + deleteResponse, err := ipResource.Delete().SendContext(ctx) + if deleteResponse != nil && deleteResponse.Status() == http.StatusNotFound { + return + } + if err != nil { + result = diag.FromErr(err) + return + } + + return +} + +// resourceIdentityProviderRender converts the internal representation of an identity provider into +// corresponding SDK identity provider object. +func resourceIdentityProviderRender(data *schema.ResourceData) (ip *cmv1.IdentityProvider, + result diag.Diagnostics) { + // Prepare the builder and set the basic attributes: + builder := cmv1.NewIdentityProvider() + var value interface{} + var ok bool + value, ok = data.GetOk(nameKey) + if ok { + builder.Name(value.(string)) + } + + // Attributes for the `htpasswd` type: + value, ok = data.GetOk(htpasswdKey) + if ok { + htpasswdList := value.([]interface{}) + htpasswdMap := htpasswdList[0].(map[string]interface{}) + htpasswdBuilder := cmv1.NewHTPasswdIdentityProvider() + value, ok = htpasswdMap[userKey] + if ok { + htpasswdBuilder.Username(value.(string)) + } + value, ok = htpasswdMap[passwordKey] + if ok { + htpasswdBuilder.Password(value.(string)) + } + builder.Type(cmv1.IdentityProviderType("HTPasswdIdentityProvider")) + builder.Htpasswd(htpasswdBuilder) + } + + // Attributes for the `ldap` type: + value, ok = data.GetOk(ldapKey) + if ok { + ldapList := value.([]interface{}) + ldapMap := ldapList[0].(map[string]interface{}) + ldapBuilder := cmv1.NewLDAPIdentityProvider() + value, ok = ldapMap[attributesKey] + if ok { + attributesList := value.([]interface{}) + attributesMap := attributesList[0].(map[string]interface{}) + attributesBuilder := cmv1.NewLDAPAttributes() + value, ok = attributesMap[emailKey] + if ok { + values := value.([]interface{}) + texts := make([]string, len(values)) + for i, value := range values { + texts[i] = value.(string) + } + attributesBuilder.Email(texts...) + } + value, ok = attributesMap[idKey] + if ok { + values := value.([]interface{}) + texts := make([]string, len(values)) + for i, value := range values { + texts[i] = value.(string) + } + attributesBuilder.ID(texts...) + } + value, ok = attributesMap[nameKey] + if ok { + values := value.([]interface{}) + texts := make([]string, len(values)) + for i, value := range values { + texts[i] = value.(string) + } + attributesBuilder.Name(texts...) + } + value, ok = attributesMap[preferredUsernameKey] + if ok { + values := value.([]interface{}) + texts := make([]string, len(values)) + for i, value := range values { + texts[i] = value.(string) + } + attributesBuilder.PreferredUsername(texts...) + } + ldapBuilder.Attributes(attributesBuilder) + } + value, ok = ldapMap[bindDNKey] + if ok { + ldapBuilder.BindDN(value.(string)) + } + value, ok = ldapMap[bindPasswordKey] + if ok { + ldapBuilder.BindPassword(value.(string)) + } + value, ok = ldapMap[caKey] + if ok { + ldapBuilder.CA(value.(string)) + } + value, ok = ldapMap[insecureKey] + if ok { + ldapBuilder.Insecure(value.(bool)) + } + value, ok = ldapMap[urlKey] + if ok { + ldapBuilder.URL(value.(string)) + } + builder.Type(cmv1.IdentityProviderType("LDAPIdentityProvider")) + builder.LDAP(ldapBuilder) + } + + // Build the object: + ip, err := builder.Build() + if err != nil { + result = diag.FromErr(err) + } + return +} + +// resourceIdentityProviderParse converts a SDK identity provider into the internal representation. +func resourceIdentityProviderParse(ip *cmv1.IdentityProvider, + data *schema.ResourceData) (result diag.Diagnostics) { + // Basic attributes: + data.SetId(ip.ID()) + data.Set(nameKey, ip.Name()) + + // Attributres for the `htpasswd` type: + htpasswd, ok := ip.GetHtpasswd() + if ok { + htpasswdData := map[string]interface{}{} + user, ok := htpasswd.GetUsername() + if ok { + htpasswdData[userKey] = user + } + password, ok := htpasswd.GetPassword() + if ok { + htpasswdData[passwordKey] = password + } + data.Set(htpasswdKey, htpasswdData) + } + + // Attributes for the LDAP type: + ldap, ok := ip.GetLDAP() + if ok { + ldapData := map[string]interface{}{} + attributes, ok := ldap.GetAttributes() + if ok { + attributesData := map[string]interface{}{} + email, ok := attributes.GetEmail() + if ok { + attributesData[emailKey] = email + } + id, ok := attributes.GetID() + if ok { + attributesData[idKey] = id + } + name, ok := attributes.GetName() + if ok { + attributesData[nameKey] = name + } + preferredUsername, ok := attributes.GetPreferredUsername() + if ok { + attributesData[preferredUsernameKey] = preferredUsername + } + ldapData[attributesKey] = attributesData + } + bindDN, ok := ldap.GetBindDN() + if ok { + ldapData[bindDNKey] = bindDN + } + bindPassword, ok := ldap.GetBindPassword() + if ok { + ldapData[bindPasswordKey] = bindPassword + } + ca, ok := ldap.GetCA() + if ok { + ldapData[caKey] = ca + } + insecure, ok := ldap.GetInsecure() + if ok { + ldapData[insecureKey] = insecure + } + url, ok := ldap.GetURL() + if ok { + ldapData[urlKey] = url + } + data.Set(ldapKey, ldapData) + } + + return +} + +// resourceIdentityProviderLookup tries to find an identity provider that matches the given data. +// Returns nil if no such identity provider exists. +func resourceIdentityProviderLookup(ctx context.Context, connection *sdk.Connection, + data *schema.ResourceData) (ip *cmv1.IdentityProvider, result diag.Diagnostics) { + // Get the resource that manages the collection of identity providers of the cluster: + clusterID := data.Get(clusterIDKey).(string) + ipsResource := connection.ClustersMgmt().V1(). + Clusters(). + Cluster(clusterID). + IdentityProviders() + + // If the we know the identifier of the identity provider then use it: + ipID := data.Id() + if ipID != "" { + ipResource := ipsResource.IdentityProvider(ipID) + getResponse, err := ipResource.Get().SendContext(ctx) + if getResponse != nil && getResponse.Status() == http.StatusNotFound { + return + } + if err != nil { + result = append(result, diag.Diagnostic{ + Severity: diag.Error, + Summary: fmt.Sprintf( + "can't find identity provider with identifier '%s' for "+ + "cluster '%s'", + ipID, clusterID, + ), + Detail: err.Error(), + }) + return + } + ip = getResponse.Body() + return + } + + // If we are here then we don't know the identifier of the identity provider, but we may + // know the name. If we do then we need to fetch all the existing identity providers and + // search locally because the identity providers collection doesn't support search. + value, ok := data.GetOk(nameKey) + if ok { + ipName := value.(string) + listResponse, err := ipsResource.List().SendContext(ctx) + if err != nil { + result = append(result, diag.Diagnostic{ + Severity: diag.Error, + Summary: fmt.Sprintf( + "can't find identity provider with name '%s' for "+ + "cluster '%s'", + ipName, ipID, + ), + Detail: err.Error(), + }) + return + } + listResponse.Items().Each(func(item *cmv1.IdentityProvider) bool { + if item.Name() == ipName { + ip = item + return false + } + return true + }) + return + } + + return +} diff --git a/tests/identity_provider_creation_test.go b/tests/identity_provider_creation_test.go new file mode 100644 index 0000000..50b1770 --- /dev/null +++ b/tests/identity_provider_creation_test.go @@ -0,0 +1,222 @@ +/* +Copyright (c) 2021 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tests + +import ( + "context" + "net/http" + "os" + "time" + + . "github.com/onsi/ginkgo" // nolint + . "github.com/onsi/gomega" // nolint + . "github.com/onsi/gomega/ghttp" // nolint + + . "github.com/openshift-online/ocm-sdk-go/testing" // nolint +) + +var _ = Describe("Identity provider creation", func() { + var ctx context.Context + var server *Server + var ca string + var token string + + BeforeEach(func() { + // Create a contet: + ctx = context.Background() + + // Create an access token: + token = MakeTokenString("Bearer", 10*time.Minute) + + // Start the server: + server, ca = MakeTCPTLSServer() + }) + + AfterEach(func() { + // Stop the server: + server.Close() + + // Remove the server CA file: + err := os.Remove(ca) + Expect(err).ToNot(HaveOccurred()) + }) + + When("There is no identity provider yet", func() { + BeforeEach(func() { + // Prepare the server: + server.AppendHandlers( + // First thing the provider will do is check if the identity + // provider exists, and to do so it will fetch all the identity + // providers of the cluster: + CombineHandlers( + VerifyRequest( + http.MethodGet, + "/api/clusters_mgmt/v1/clusters/123/identity_providers", + ), + RespondWithJSON( + http.StatusOK, + `{ + "page": 1, + "size": 0, + "total": 0, + "items": [] + }`, + ), + ), + + // Then it will retrieve the cluster to check that it is ready: + CombineHandlers( + VerifyRequest(http.MethodGet, "/api/clusters_mgmt/v1/clusters/123"), + RespondWithJSON( + http.StatusOK, + `{ + "id": "123", + "name": "my-cluster", + "state": "ready" + }`, + ), + ), + ) + }) + + It("Can create a 'htpasswd' identity provider", func() { + // Prepare the server: + server.AppendHandlers( + CombineHandlers( + VerifyRequest( + http.MethodPost, + "/api/clusters_mgmt/v1/clusters/123/identity_providers", + ), + RespondWithJSON( + http.StatusOK, + `{ + "id": "456", + "name": "my-ip", + "htpasswd": { + "user": "my-user" + } + }`, + ), + ), + ) + + // Run the apply command: + result := NewCommand(). + File( + "main.tf", ` + terraform { + required_providers { + ocm = { + source = "localhost/redhat/ocm" + } + } + } + + provider "ocm" { + url = "{{ .URL }}" + token = "{{ .Token }}" + trusted_cas = file("{{ .CA }}") + } + + resource "ocm_identity_provider" "my_ip" { + cluster_id = "123" + name = "my-ip" + htpasswd { + user = "my-user" + password = "my-password" + } + } + `, + "URL", server.URL(), + "Token", token, + "CA", ca, + ). + Args( + "apply", + "-auto-approve", + ). + Run(ctx) + Expect(result.ExitCode()).To(BeZero()) + }) + + It("Can create an LDAP identity provider", func() { + // Prepare the server: + server.AppendHandlers( + CombineHandlers( + VerifyRequest( + http.MethodPost, + "/api/clusters_mgmt/v1/clusters/123/identity_providers", + ), + RespondWithJSON( + http.StatusOK, + `{ + "id": "456", + "name": "my-ip", + "ldap": { + } + }`, + ), + ), + ) + + // Run the apply command: + result := NewCommand(). + File( + "main.tf", ` + terraform { + required_providers { + ocm = { + source = "localhost/redhat/ocm" + } + } + } + + provider "ocm" { + url = "{{ .URL }}" + token = "{{ .Token }}" + trusted_cas = file("{{ .CA }}") + } + + resource "ocm_identity_provider" "my_ip" { + cluster_id = "123" + name = "my-ip" + ldap { + bind_dn = "my-bind-dn" + bind_password = "my-bind-password" + url = "https://my-server.com" + attributes { + id = ["my-id"] + email = ["my-email"] + name = ["my-name"] + preferred_username = ["my-preferred-username"] + } + } + } + `, + "URL", server.URL(), + "Token", token, + "CA", ca, + ). + Args( + "apply", + "-auto-approve", + ). + Run(ctx) + Expect(result.ExitCode()).To(BeZero()) + }) + }) +})