diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e57e11a..98ac9e77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,6 +131,7 @@ FEATURES: * **New Resource:** `netapp-ontap_qtree` ([#82](https://github.com/NetApp/terraform-provider-netapp-ontap/issues/82)) * **New Resource:** `netapp-ontap_qos_policy` ([#76](https://github.com/NetApp/terraform-provider-netapp-ontap/issues/76)) * **New Resource:** `netapp-security_login_message` ([#18](https://github.com/NetApp/terraform-provider-netapp-ontap/issues/18)) +* **New Resource:** `netapp-ontap_security_certificate` ([#138](https://github.com/NetApp/terraform-provider-netapp-ontap/issues/138)) ENHANCEMENTS: * **netapp-ontap_lun**: added `size_unit` option. ([#227](https://github.com/NetApp/terraform-provider-netapp-ontap/issues/227)) diff --git a/docs/resources/security_certificate.md b/docs/resources/security_certificate.md new file mode 100644 index 00000000..d6b6c8fe --- /dev/null +++ b/docs/resources/security_certificate.md @@ -0,0 +1,163 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "netapp-ontap_security_certificate Resource - terraform-provider-netapp-ontap" +subcategory: "" +description: |- + SecurityCertificate resource +--- + +# netapp-ontap_security_certificate (Resource) + +Create/ install/ sign a certificate + +### Related ONTAP commands +```commandline +* security certificate show +* security certificate create +* security certificate install +* security certificate sign +``` + +## Supported Platforms +* On-prem ONTAP system 9.6 or higher + +## Example Usage + +```terraform +# creating a cluster-scoped certificate +resource "netapp-ontap_security_certificate" "create_certificate1" { + cx_profile_name = "cluster5" + name = "test_ca_cert1" + common_name = "test_ca_cert" + type = "root_ca" + expiry_time = "P365DT" +} + +# creating a certificate +resource "netapp-ontap_security_certificate" "create_certificate2" { + cx_profile_name = "cluster5" + name = "tfsvm_ca_cert1" + common_name = "tfsvm_ca_cert" + type = "root_ca" + svm_name = "tfsvm" + expiry_time = "P365DT" +} + +# signing a certificate +resource "netapp-ontap_security_certificate" "sign_certificate" { + cx_profile_name = "cluster5" + name = "tfsvm_ca_cert1" + common_name = "tfsvm_ca_cert" + type = "root_ca" + svm_name = "svm1" # SVM on which the signed certificate will exist + expiry_time = "P90DT" + signing_request = <<-EOT +-----BEGIN CERTIFICATE REQUEST----- +signing-request +-----END CERTIFICATE REQUEST----- +EOT +} + +# installing a certificate +resource "netapp-ontap_security_certificate" "install_certificate" { + cx_profile_name = "cluster5" + common_name = "svm1_cert1" + type = "server" + svm_name = "svm1" + expiry_time = "P90DT" + public_certificate = <<-EOT +-----BEGIN CERTIFICATE----- +certificate +-----END CERTIFICATE----- +EOT + + private_key = <<-EOT +-----BEGIN PRIVATE KEY----- +private-key +-----END PRIVATE KEY----- +EOT +} +``` + + +## Schema + +### Required + +- `cx_profile_name` (String) Connection profile name. +- `common_name` (String) Common name of the certificate. +- `type` (String) Type of certificate. + +### Optional + +- `expiry_time` (String) Certificate expiration time, in ISO 8601 duration format or date and time format. +- `hash_function` (String) Hashing function. +- `key_size` (Number) Key size of the certificate in bits. +- `name` (String) The unique name of the security certificate per SVM. +- `private_key` (String, Sensitive) Private key Certificate in PEM format. Only valid when installing a CA-signed certificate. +- `public_certificate` (String) Public key Certificate in PEM format. If this is not provided during create action, a self-signed certificate is created. +- `signing_request` (String) Certificate signing request to be signed by the given certificate authority. Request should be in X509 PEM format. +- `svm_name` (String) Name of the SVM in which the certificate is created or installed or the SVM on which the signed certificate will exist. + +### Read-Only + +- `ca` (String) Certificate authority. +- `id` (String) UUID of the certificate. +- `scope` (String) Set to 'svm' for certificates installed in a SVM. Otherwise, set to 'cluster'. +- `serial_number` (String) Serial number of the certificate. +- `signed_certificate` (String) Signed public key Certificate in PEM format that is returned while signing a certificate. + +## Import +This resource supports import, which allows you to import existing security certificate into the state of this resource. +Import require a unique ID composed of the security certificate name, common name, type and connection profile, separated by a comma or security certificate common name, type, and connection profile, separated by a comma. + +id = `name`,`common_name`,`type`,`cx_profile_name` + +### Terraform Import + + For example + + Import with certificate name; recommended for ONTAP 9.8 or later + ```shell + terraform import netapp-ontap_security_certificate.cert_import tfsvm_ca_cert1,tfsvm_ca_cert,root_ca,cluster5 + ``` + + Import with certificate common name & type; applicable for ONTAP 9.6 or 9.7 + ```shell + terraform import netapp-ontap_security_certificate.cert_import svm1_cert1,server,cluster5 + ``` + +### Terraform Import Block +This requires Terraform 1.5 or higher, and will auto create the configuration for you + +First create the block +```terraform +import { + to = netapp-ontap_security_certificate.cert_import + id = "tfsvm_ca_cert1,tfsvm_ca_cert,root_ca,cluster5" +} +``` +Next run, this will auto create the configuration for you +```shell +terraform plan -generate-config-out=generated.tf +``` +This will generate a file called generated.tf, which will contain the configuration for the imported resource +```terraform +# __generated__ by Terraform +# Please review these resources and move them into your main configuration files. + +# __generated__ by Terraform from "tfsvm_ca_cert1,tfsvm_ca_cert,root_ca,cluster5" +resource "netapp-ontap_security_certificate" "cert_import" { + common_name = "tfsvm_ca_cert" + cx_profile_name = "cluster5" + expiry_time = "2025-10-04T01:24:54-04:00" + hash_function = "sha256" + key_size = 2048 + name = "tfsvm_ca_cert1" + private_key = null # sensitive + public_certificate = "-----BEGIN CERTIFICATE-----\ncertificate\n-----END CERTIFICATE-----\n" + signing_request = null + svm_name = "tfsvm" + type = "root_ca" +} +``` \ No newline at end of file diff --git a/examples/resources/netapp-ontap_security_certificate/provider.tf b/examples/resources/netapp-ontap_security_certificate/provider.tf new file mode 120000 index 00000000..c6b7138f --- /dev/null +++ b/examples/resources/netapp-ontap_security_certificate/provider.tf @@ -0,0 +1 @@ +../../provider/provider.tf \ No newline at end of file diff --git a/examples/resources/netapp-ontap_security_certificate/resource.tf b/examples/resources/netapp-ontap_security_certificate/resource.tf new file mode 100644 index 00000000..f0a3bbdf --- /dev/null +++ b/examples/resources/netapp-ontap_security_certificate/resource.tf @@ -0,0 +1,53 @@ +# creating a cluster-scoped certificate +resource "netapp-ontap_security_certificate" "create_certificate1" { + cx_profile_name = "cluster5" + name = "test_ca_cert1" + common_name = "test_ca_cert" + type = "root_ca" + expiry_time = "P365DT" +} + +# creating a certificate +resource "netapp-ontap_security_certificate" "create_certificate2" { + cx_profile_name = "cluster5" + name = "tfsvm_ca_cert1" + common_name = "tfsvm_ca_cert" + type = "root_ca" + svm_name = "tfsvm" + expiry_time = "P365DT" +} + +# signing a certificate +resource "netapp-ontap_security_certificate" "sign_certificate" { + cx_profile_name = "cluster5" + name = "tfsvm_ca_cert1" + common_name = "tfsvm_ca_cert" + type = "root_ca" + svm_name = "svm1" # SVM on which the signed certificate will exist + expiry_time = "P90DT" + signing_request = <<-EOT +-----BEGIN CERTIFICATE REQUEST----- +signing-request +-----END CERTIFICATE REQUEST----- +EOT +} + +# installing a certificate +resource "netapp-ontap_security_certificate" "install_certificate" { + cx_profile_name = "cluster5" + common_name = "svm1_cert1" + type = "server" + svm_name = "svm1" + expiry_time = "P90DT" + public_certificate = <<-EOT +-----BEGIN CERTIFICATE----- +certificate +-----END CERTIFICATE----- +EOT + + private_key = <<-EOT +-----BEGIN PRIVATE KEY----- +private-key +-----END PRIVATE KEY----- +EOT +} diff --git a/examples/resources/netapp-ontap_security_certificate/terraform.tfvars b/examples/resources/netapp-ontap_security_certificate/terraform.tfvars new file mode 120000 index 00000000..8d9d1c96 --- /dev/null +++ b/examples/resources/netapp-ontap_security_certificate/terraform.tfvars @@ -0,0 +1 @@ +../../provider/terraform.tfvars \ No newline at end of file diff --git a/examples/resources/netapp-ontap_security_certificate/variables.tf b/examples/resources/netapp-ontap_security_certificate/variables.tf new file mode 120000 index 00000000..395ce618 --- /dev/null +++ b/examples/resources/netapp-ontap_security_certificate/variables.tf @@ -0,0 +1 @@ +../../provider/variables.tf \ No newline at end of file diff --git a/internal/interfaces/security_certificate.go b/internal/interfaces/security_certificate.go index 78951943..d4970474 100644 --- a/internal/interfaces/security_certificate.go +++ b/internal/interfaces/security_certificate.go @@ -26,6 +26,32 @@ type SecurityCertificateGetDataModelONTAP struct { PublicCertificate string `mapstructure:"public_certificate"` } +// SignedSecurityCertificateGetDataModelONTAP describes the GET record data model using go types for mapping. +type SignedSecurityCertificateGetDataModelONTAP struct { + SignedCertificate string `mapstructure:"public_certificate"` +} + +// SecurityCertificateResourceCreateBodyDataModelONTAP describes the create/install body data model using go types for mapping. +type SecurityCertificateResourceCreateBodyDataModelONTAP struct { + Name string `mapstructure:"name,omitempty"` + CommonName string `mapstructure:"common_name"` + Type string `mapstructure:"type"` + SVM svm `mapstructure:"svm,omitempty"` + Scope string `mapstructure:"scope,omitempty"` + PublicCertificate string `mapstructure:"public_certificate,omitempty"` + PrivateKey string `mapstructure:"private_key,omitempty"` + HashFunction string `mapstructure:"hash_function,omitempty"` + KeySize int64 `mapstructure:"key_size,omitempty"` + ExpiryTime string `mapstructure:"expiry_time,omitempty"` +} + +// SecurityCertificateResourceSignBodyDataModelONTAP describes the signing body data model using go types for mapping. +type SecurityCertificateResourceSignBodyDataModelONTAP struct { + SigningRequest string `mapstructure:"signing_request"` + HashFunction string `mapstructure:"hash_function,omitempty"` + ExpiryTime string `mapstructure:"expiry_time,omitempty"` +} + // SecurityCertificateDataSourceFilterModel describes the data source data model for queries. type SecurityCertificateDataSourceFilterModel struct { SVMName string `mapstructure:"svm.name"` @@ -166,3 +192,59 @@ func GetSecurityCertificates(errorHandler *utils.ErrorHandler, r restclient.Rest tflog.Debug(errorHandler.Ctx, fmt.Sprintf("Read security_certificates data source: %#v", dataONTAP)) return dataONTAP, nil } + +// CreateOrInstallSecurityCertificate to create/ install a security certificate +func CreateOrInstallSecurityCertificate(errorHandler *utils.ErrorHandler, r restclient.RestClient, body SecurityCertificateResourceCreateBodyDataModelONTAP, operation string) (*SecurityCertificateGetDataModelONTAP, error) { + api := "security/certificates" + var bodyMap map[string]interface{} + if err := mapstructure.Decode(body, &bodyMap); err != nil { + return nil, errorHandler.MakeAndReportError("error encoding security certificate body", fmt.Sprintf("error on encoding %s body: %s, body: %#v", api, err, body)) + } + query := r.NewQuery() + query.Add("return_records", "true") + + statusCode, response, err := r.CallCreateMethod(api, query, bodyMap) + if err != nil { + return nil, errorHandler.MakeAndReportError(fmt.Sprintf("error %s security certificate", operation), fmt.Sprintf("error on POST %s: %s, statusCode %d", api, err, statusCode)) + } + + var dataONTAP SecurityCertificateGetDataModelONTAP + if err := mapstructure.Decode(response.Records[0], &dataONTAP); err != nil { + return nil, errorHandler.MakeAndReportError("error decoding security certificate info", fmt.Sprintf("error on decode storage/security_certificatess info: %s, statusCode %d, response %#v", err, statusCode, response)) + } + tflog.Debug(errorHandler.Ctx, fmt.Sprintf("Created security certificate: %#v", dataONTAP)) + return &dataONTAP, nil +} + +// SignSecurityCertificate to sign a security_certificate +func SignSecurityCertificate(errorHandler *utils.ErrorHandler, r restclient.RestClient, uuid string, body SecurityCertificateResourceSignBodyDataModelONTAP) (*SignedSecurityCertificateGetDataModelONTAP, error) { + api := "security/certificates" + var bodyMap map[string]interface{} + if err := mapstructure.Decode(body, &bodyMap); err != nil { + return nil, errorHandler.MakeAndReportError("error encoding security certificate body", fmt.Sprintf("error on encoding %s body: %s, body: %#v", api, err, body)) + } + query := r.NewQuery() + query.Add("return_records", "true") + + statusCode, response, err := r.CallCreateMethod(api+"/"+uuid+"/sign", query, bodyMap) + if err != nil { + return nil, errorHandler.MakeAndReportError("error signing security certificate", fmt.Sprintf("error on POST %s: %s, statusCode %d", api, err, statusCode)) + } + + var dataONTAP SignedSecurityCertificateGetDataModelONTAP + if err := mapstructure.Decode(response.Records[0], &dataONTAP); err != nil { + return nil, errorHandler.MakeAndReportError("error decoding signed security certificate info", fmt.Sprintf("error on decode storage/security_certificatess/{ca.uuid}/sign info: %s, statusCode %d, response %#v", err, statusCode, response)) + } + tflog.Debug(errorHandler.Ctx, fmt.Sprintf("Signed security certificate: %#v", dataONTAP)) + return &dataONTAP, nil +} + +// DeleteSecurityCertificate to delete a security_certificate +func DeleteSecurityCertificate(errorHandler *utils.ErrorHandler, r restclient.RestClient, uuid string) error { + api := "security/certificates" + statusCode, _, err := r.CallDeleteMethod(api+"/"+uuid, nil, nil) + if err != nil { + return errorHandler.MakeAndReportError("error deleting security certificate", fmt.Sprintf("error on DELETE %s: %s, statusCode %d", api, err, statusCode)) + } + return nil +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index bd8d50bb..f654cfac 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -236,6 +236,7 @@ func (p *ONTAPProvider) Resources(ctx context.Context) []func() resource.Resourc protocols.NewProtocolsSanIgroupResource, protocols.NewProtocolsSanLunMapResource, security.NewSecurityAccountResource, + security.NewSecurityCertificateResource, security.NewSecurityRoleResource, security.NewSecurityLoginMessageResource, snapmirror.NewSnapmirrorResource, diff --git a/internal/provider/security/security_certificate_resource.go b/internal/provider/security/security_certificate_resource.go new file mode 100644 index 00000000..1c901b60 --- /dev/null +++ b/internal/provider/security/security_certificate_resource.go @@ -0,0 +1,511 @@ +package security + +import ( + "context" + "fmt" + "strings" + + "github.com/netapp/terraform-provider-netapp-ontap/internal/provider/connection" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/netapp/terraform-provider-netapp-ontap/internal/interfaces" + "github.com/netapp/terraform-provider-netapp-ontap/internal/utils" +) + +// Ensure provider defined types fully satisfy framework interfaces +var _ resource.Resource = &SecurityCertificateResource{} +var _ resource.ResourceWithImportState = &SecurityCertificateResource{} + +// NewSecurityCertificateResource is a helper function to simplify the provider implementation. +func NewSecurityCertificateResource() resource.Resource { + return &SecurityCertificateResource{ + config: connection.ResourceOrDataSourceConfig{ + Name: "security_certificate", + }, + } +} + +// SecurityCertificateResource defines the resource implementation. +type SecurityCertificateResource struct { + config connection.ResourceOrDataSourceConfig +} + +// SecurityCertificateResourceModel describes the resource data model. +type SecurityCertificateResourceModel struct { + CxProfileName types.String `tfsdk:"cx_profile_name"` + Name types.String `tfsdk:"name"` + CommonName types.String `tfsdk:"common_name"` + Type types.String `tfsdk:"type"` + SVMName types.String `tfsdk:"svm_name"` + Scope types.String `tfsdk:"scope"` + SerialNumber types.String `tfsdk:"serial_number"` + CA types.String `tfsdk:"ca"` + PublicCertificate types.String `tfsdk:"public_certificate"` + SignedCertificate types.String `tfsdk:"signed_certificate"` + PrivateKey types.String `tfsdk:"private_key"` + SigningRequest types.String `tfsdk:"signing_request"` + HashFunction types.String `tfsdk:"hash_function"` + KeySize types.Int64 `tfsdk:"key_size"` + ExpiryTime types.String `tfsdk:"expiry_time"` + ID types.String `tfsdk:"id"` +} + +// Metadata returns the resource type name. +func (r *SecurityCertificateResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_" + r.config.Name +} + +// Schema defines the schema for the resource. +func (r *SecurityCertificateResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + // This description is used by the documentation generator and the language server. + MarkdownDescription: "SecurityCertificate resource", + + Attributes: map[string]schema.Attribute{ + "cx_profile_name": schema.StringAttribute{ + MarkdownDescription: "Connection profile name.", + Required: true, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "The unique name of the security certificate per SVM.", + Optional: true, + Computed: true, + }, + "common_name": schema.StringAttribute{ + MarkdownDescription: "Common name of the certificate.", + Required: true, + }, + "type": schema.StringAttribute{ + MarkdownDescription: "Type of certificate.", + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf("client", "server", "client_ca", "server_ca", "root_ca"), + }, + }, + "svm_name": schema.StringAttribute{ + MarkdownDescription: "Name of the SVM in which the certificate is created or installed or the SVM on which the signed certificate will exist.", + Optional: true, + Computed: true, + }, + "scope": schema.StringAttribute{ + MarkdownDescription: "Set to 'svm' for certificates installed in a SVM. Otherwise, set to 'cluster'.", + Optional: false, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "serial_number": schema.StringAttribute{ + MarkdownDescription: "Serial number of the certificate.", + Optional: false, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "ca": schema.StringAttribute{ + MarkdownDescription: "Certificate authority.", + Optional: false, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "public_certificate": schema.StringAttribute{ + MarkdownDescription: "Public key Certificate in PEM format. If this is not provided during create action, a self-signed certificate is created.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "signed_certificate": schema.StringAttribute{ + MarkdownDescription: "Signed public key Certificate in PEM format that is returned while signing a certificate.", + Optional: false, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "private_key": schema.StringAttribute{ + MarkdownDescription: "Private key Certificate in PEM format. Only valid when installing a CA-signed certificate.", + Optional: true, + Sensitive: true, + }, + "signing_request": schema.StringAttribute{ + MarkdownDescription: "Certificate signing request to be signed by the given certificate authority. Request should be in X509 PEM format.", + Optional: true, + }, + "hash_function": schema.StringAttribute{ + MarkdownDescription: "Hashing function.", + Optional: true, + Computed: true, + Validators: []validator.String{ + stringvalidator.OneOf("sha1", "sha256", "md5", "sha224", "sha384", "sha512"), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "key_size": schema.Int64Attribute{ + MarkdownDescription: "Key size of the certificate in bits.", + Optional: true, + Computed: true, + Validators: []validator.Int64{ + int64validator.OneOf(512, 1024, 1536, 2048, 3072), + }, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "expiry_time": schema.StringAttribute{ + MarkdownDescription: "Certificate expiration time, in ISO 8601 duration format or date and time format.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "id": schema.StringAttribute{ + MarkdownDescription: "UUID of the certificate.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +// Configure adds the provider configured client to the resource. +func (r *SecurityCertificateResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + config, ok := req.ProviderData.(connection.Config) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected Config, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + } + r.config.ProviderConfig = config +} + +// Read refreshes the Terraform state with the latest data. +func (r *SecurityCertificateResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data SecurityCertificateResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + errorHandler := utils.NewErrorHandler(ctx, &resp.Diagnostics) + + // we need to defer setting the client until we can read the connection profile name + client, err := connection.GetRestClient(errorHandler, r.config, data.CxProfileName) + if err != nil { + // error reporting done inside NewClient + return + } + + cluster, err := interfaces.GetCluster(errorHandler, *client) + if err != nil { + // error reporting done inside GetCluster + return + } + if cluster == nil { + errorHandler.MakeAndReportError("No cluster found", "cluster not found") + return + } + + var restInfo *interfaces.SecurityCertificateGetDataModelONTAP + if data.ID.ValueString() != "" { + restInfo, err = interfaces.GetSecurityCertificateByUUID(errorHandler, *client, cluster.Version, data.ID.ValueString()) + if err != nil { + // error reporting done inside GetSecurityCertificateByUUID + return + } + } else { + restInfo, err = interfaces.GetSecurityCertificate(errorHandler, *client, cluster.Version, data.Name.ValueString(), data.CommonName.ValueString(), data.Type.ValueString()) + if err != nil { + // error reporting done inside GetSecurityCertificate + return + } + } + + if restInfo == nil { + errorHandler.MakeAndReportError("error reading info", "No Certificate found") + return + } + + // Set the values from the response into the data model + data.ID = types.StringValue(restInfo.UUID) + data.Name = types.StringValue(restInfo.Name) + data.CommonName = types.StringValue(restInfo.CommonName) + data.Scope = types.StringValue(restInfo.Scope) + data.Type = types.StringValue(restInfo.Type) + data.SerialNumber = types.StringValue(restInfo.SerialNumber) + data.CA = types.StringValue(restInfo.CA) + data.HashFunction = types.StringValue(restInfo.HashFunction) + data.KeySize = types.Int64Value(restInfo.KeySize) + data.PublicCertificate = types.StringValue(restInfo.PublicCertificate) + if data.ExpiryTime.IsNull() { + data.ExpiryTime = types.StringValue(restInfo.ExpiryTime) + } + if data.SVMName.IsUnknown() { + data.SVMName = types.StringValue(restInfo.SVM.Name) + } + + // Write logs using the tflog package + // Documentation: https://terraform.io/plugin/log + tflog.Debug(ctx, fmt.Sprintf("read a resource: %#v", data)) + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +// Create a resource and retrieve UUID +func (r *SecurityCertificateResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data *SecurityCertificateResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + errorHandler := utils.NewErrorHandler(ctx, &resp.Diagnostics) + client, err := connection.GetRestClient(errorHandler, r.config, data.CxProfileName) + if err != nil { + // error reporting done inside NewClient + return + } + + cluster, err := interfaces.GetCluster(errorHandler, *client) + if cluster == nil { + errorHandler.MakeAndReportError("No cluster found", "Cluster not found.") + return + } + if err != nil { + // error reporting done inside GetCluster + return + } + + name_supported := false + if !data.Name.IsNull() && data.Name.ValueString() != "" { + if cluster.Version.Generation == 9 && cluster.Version.Major < 8 { + tflog.Error(ctx, "'name' is supported with ONTAP 9.8 or higher.") + errorHandler.MakeAndReportError("Unsupported parameter", "'name' is supported with ONTAP 9.8 or higher.") + return + } else { + name_supported = true + } + } + + if !data.SigningRequest.IsNull() { + // this if block is for signing security certificate + var body interfaces.SecurityCertificateResourceSignBodyDataModelONTAP + + // Read the updated data from the API + restInfo, err := interfaces.GetSecurityCertificate(errorHandler, *client, cluster.Version, data.Name.ValueString(), data.CommonName.ValueString(), data.Type.ValueString()) + if err != nil { + // error reporting done inside GetSecurityCertificate + return + } + + data.ID = types.StringValue(restInfo.UUID) + data.Name = types.StringValue(restInfo.Name) + data.CommonName = types.StringValue(restInfo.CommonName) + data.Type = types.StringValue(restInfo.Type) + data.Scope = types.StringValue(restInfo.Scope) + data.SerialNumber = types.StringValue(restInfo.SerialNumber) + data.CA = types.StringValue(restInfo.CA) + data.PublicCertificate = types.StringValue(restInfo.PublicCertificate) + data.HashFunction = types.StringValue(restInfo.HashFunction) + data.KeySize = types.Int64Value(restInfo.KeySize) + if data.SVMName.IsUnknown() { + data.SVMName = types.StringValue(restInfo.SVM.Name) + } + + body.SigningRequest = data.SigningRequest.ValueString() + if !data.HashFunction.IsUnknown() { + body.HashFunction = data.HashFunction.ValueString() + } + if !data.ExpiryTime.IsUnknown() { + body.ExpiryTime = data.ExpiryTime.ValueString() + } + + resource, err := interfaces.SignSecurityCertificate(errorHandler, *client, restInfo.UUID, body) + if err != nil { + // error reporting done inside SignSecurityCertificate + return + } + + // Save public_certificate returned while signing certificate into Terraform state + data.SignedCertificate = types.StringValue(resource.SignedCertificate) + + tflog.Trace(ctx, "signed a resource") + } else { + // This else block is for creating or installing security certificate + var body interfaces.SecurityCertificateResourceCreateBodyDataModelONTAP + + if !data.Name.IsNull() && data.Name.ValueString() != "" { + if name_supported { + body.Name = data.Name.ValueString() + } + } + body.CommonName = data.CommonName.ValueString() + body.Type = data.Type.ValueString() + if !data.SVMName.IsUnknown() { + body.SVM.Name = data.SVMName.ValueString() + } + if !data.PublicCertificate.IsUnknown() { + body.PublicCertificate = data.PublicCertificate.ValueString() + } + if !data.PrivateKey.IsUnknown() { + body.PrivateKey = data.PrivateKey.ValueString() + } + if !data.HashFunction.IsUnknown() { + body.HashFunction = data.HashFunction.ValueString() + } + if !data.KeySize.IsUnknown() { + body.KeySize = data.KeySize.ValueInt64() + } + if !data.ExpiryTime.IsUnknown() { + body.ExpiryTime = data.ExpiryTime.ValueString() + } + + var operation string + if !data.PublicCertificate.IsUnknown() || !data.PrivateKey.IsNull() { + operation = "installing" + } else { + operation = "creating" + } + resource, err := interfaces.CreateOrInstallSecurityCertificate(errorHandler, *client, body, operation) + if err != nil { + // error reporting done inside CreateOrInstallSecurityCertificate + return + } + tflog.Trace(ctx, "created/ installed a resource") + data.ID = types.StringValue(resource.UUID) + + // Read the updated data from the API + restInfo, err := interfaces.GetSecurityCertificateByUUID(errorHandler, *client, cluster.Version, resource.UUID) + if err != nil { + // error reporting done inside GetSecurityCertificateByUUID + return + } + + data.Name = types.StringValue(restInfo.Name) + data.CommonName = types.StringValue(restInfo.CommonName) + data.Type = types.StringValue(restInfo.Type) + data.SVMName = types.StringValue(restInfo.SVM.Name) + data.Scope = types.StringValue(restInfo.Scope) + data.PublicCertificate = types.StringValue(restInfo.PublicCertificate) + data.SerialNumber = types.StringValue(restInfo.SerialNumber) + data.CA = types.StringValue(restInfo.CA) + data.HashFunction = types.StringValue(restInfo.HashFunction) + data.KeySize = types.Int64Value(restInfo.KeySize) + if data.ExpiryTime.IsUnknown() { + data.ExpiryTime = types.StringValue(restInfo.ExpiryTime) + } + // SignedCertificate would be available only while signing a certificate + data.SignedCertificate = types.StringValue("NA") + + tflog.Trace(ctx, "read newly created resource") + } + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *SecurityCertificateResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data *SecurityCertificateResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Add an error diagnostic indicating that Update is not supported + resp.Diagnostics.AddError( + "Update Not Supported", + "The update operation is not supported for the security_certificate resource.", + ) + tflog.Error(ctx, "Update not supported for resource security_certificate") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *SecurityCertificateResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data *SecurityCertificateResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + errorHandler := utils.NewErrorHandler(ctx, &resp.Diagnostics) + client, err := connection.GetRestClient(errorHandler, r.config, data.CxProfileName) + if err != nil { + // error reporting done inside NewClient + return + } + + if data.ID.IsUnknown() { + errorHandler.MakeAndReportError("UUID is null", "security certificate UUID is null") + return + } + + err = interfaces.DeleteSecurityCertificate(errorHandler, *client, data.ID.ValueString()) + if err != nil { + return + } + +} + +// ImportState imports a resource using ID from terraform import command by calling the Read method. +func (r *SecurityCertificateResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + tflog.Debug(ctx, fmt.Sprintf("import req security certificate resource: %#v", req)) + // Parse the ID + idParts := strings.Split(req.ID, ",") + + // import name, common_name, type and cx_profile + if len(idParts) == 4 { + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), idParts[0])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("common_name"), idParts[1])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("type"), idParts[2])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("cx_profile_name"), idParts[3])...) + return + } + + // import common_name, type, and cx_profile + if len(idParts) == 3 { + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("common_name"), idParts[0])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("type"), idParts[1])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("cx_profile_name"), idParts[2])...) + return + } + + resp.Diagnostics.AddError( + "Unexpected Import Identifier", + fmt.Sprintf("Expected import identifier with format: name,common_name,type,cx_profile_name or common_name,type,cx_profile_name. Got: %q", req.ID), + ) +} diff --git a/internal/provider/security/security_certificate_resource_test.go b/internal/provider/security/security_certificate_resource_test.go new file mode 100644 index 00000000..dd022955 --- /dev/null +++ b/internal/provider/security/security_certificate_resource_test.go @@ -0,0 +1,67 @@ +package security_test + +import ( + "fmt" + "os" + "testing" + + ntest "github.com/netapp/terraform-provider-netapp-ontap/internal/provider" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccSecurityCertificateResource(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { ntest.TestAccPreCheck(t) }, + ProtoV6ProviderFactories: ntest.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create security certificate and read + { + Config: testAccSecurityCertificateResourceCertificateConfig(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("netapp-ontap_security_certificate.example", "name", "acc_test_ca_cert2"), + ), + }, + // Import and read + { + ResourceName: "netapp-ontap_security_certificate.example", + ImportState: true, + ImportStateId: fmt.Sprintf("%s,%s,%s,%s", "acc_test_ca_cert1", "acc_test_ca_cert", "root_ca", "cluster1"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("netapp-ontap_security_certificate.example", "name", "acc_test_ca_cert1"), + ), + }, + // Delete testing automatically occurs in TestCase + }, + }) +} + +func testAccSecurityCertificateResourceCertificateConfig() string { + host := os.Getenv("TF_ACC_NETAPP_HOST_CIFS") + admin := os.Getenv("TF_ACC_NETAPP_USER") + password := os.Getenv("TF_ACC_NETAPP_PASS2") + if host == "" || admin == "" || password == "" { + fmt.Println("TF_ACC_NETAPP_HOST_CIFS, TF_ACC_NETAPP_USER, and TF_ACC_NETAPP_PASS2 must be set for acceptance tests") + os.Exit(1) + } + return fmt.Sprintf(` +provider "netapp-ontap" { + connection_profiles = [ + { + name = "cluster1" + hostname = "%s" + username = "%s" + password = "%s" + validate_certs = false + }, + ] +} + +resource "netapp-ontap_security_certificate" "example" { + cx_profile_name = "cluster1" + name = "acc_test_ca_cert2" + common_name = "acc_test_ca_cert" + type = "root_ca" + svm_name = "acc_test" +}`, host, admin, password) +} diff --git a/internal/restclient/rest_response.go b/internal/restclient/rest_response.go index 2bb64ac3..56d646a1 100644 --- a/internal/restclient/rest_response.go +++ b/internal/restclient/rest_response.go @@ -188,13 +188,19 @@ func (c *RestClient) unmarshalResponse(statusCode int, responseJSON []byte, http // If Other is present, add it to records. // But ignore it if we already have some records. - // Other will always have 1 element called _link, so only do this if Other has more than 1 element + // Add the records under Other if Other has more than 1 element + // and if Other has only one element and it is not _links // Examples: // {NumRecords:0 Records:[] Error:{Code: Message: Target:} Job:map[] Jobs:[] Other:map[_links:map[self:map[href:/api/cluster/schedules?fields=name%2Cuuid%2Ccron%2Cinterval%2Ctype%2Cscope&name=mytest]]]} // {NumRecords:0 Records:[] Error:{Code: Message: Target:} Job:map[] Jobs:[] Other:map[_links:map[self:map[href:/api/cluster]] certificate:map[_links:map[self:map[href:/api/security/certificates/2f632ea7-92cd-11ed-8f2b-005056b3357c]] uuid:2f632ea7-92cd-11ed-8f2b-005056b3357c] metric:map[duration:PT15S iops:map[other:0 read:0 total:0 write:0] latency:map[other:0 read:0 total:0 write:0] status:ok throughput:map[other:0 read:0 total:0 write:0] timestamp:2023-03-16T18:36:30Z] name:laurentncluster-2 peering_policy:map[authentication_required:true encryption_required:false minimum_passphrase_length:8] san_optimized:false statistics:map[iops_raw:map[other:0 read:0 total:0 write:0] latency_raw:map[other:0 read:0 total:0 write:0] status:ok throughput_raw:map[other:0 read:0 total:0 write:0] timestamp:2023-03-16T18:36:31Z] timezone:map[name:Etc/UTC] uuid:2115008a-92cd-11ed-8f2b-005056b3357c version:map[full:NetApp Release Metropolitan__9.11.1: Sat Dec 10 19:08:07 UTC 2022 generation:9 major:11 minor:1]]} - if rawResponse.NumRecords == 0 && len(rawResponse.Records) == 0 && len(rawResponse.Other) > 1 { - rawResponse.NumRecords = 1 - rawResponse.Records = append(rawResponse.Records, rawResponse.Other) + // {NumRecords:0 Records:[] Error:{Code: Message: Target:} Job:map[] Jobs:[] Other:map[public_certificate:-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n]} + // if rawResponse.NumRecords == 0 && len(rawResponse.Records) == 0 && len(rawResponse.Other) > 1 { + if rawResponse.NumRecords == 0 && len(rawResponse.Records) == 0 { + _, found := rawResponse.Other["_links"] + if (len(rawResponse.Other) == 1 && !found) || len(rawResponse.Other) > 1 { + rawResponse.NumRecords = 1 + rawResponse.Records = append(rawResponse.Records, rawResponse.Other) + } } var finalResponse RestResponse diff --git a/scripts/generate_docs.py b/scripts/generate_docs.py index cbb76433..1377697a 100755 --- a/scripts/generate_docs.py +++ b/scripts/generate_docs.py @@ -86,6 +86,7 @@ "security_login_message_resource.md", "security_certificate_data_source.md", "security_certificates_data_source.md", + "security_certificate_resource.md", ], 'snaplock': [],