diff --git a/README.md b/README.md index 1d4902bea..91d3f9209 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ In order to run the full suite of Acceptance tests you will need to have the fol - a [Load Balancer](https://www.ovh.ie/solutions/load-balancer/) - a registered [Domain](https://www.ovh.ie/domains/) - a [Cloud Project](https://www.ovh.ie/public-cloud/instances/) +- a [KMS](https://www.ovhcloud.com/en-gb/identity-security-operations/key-management-service/) You will also need to setup your [OVH API](https://api.ovh.com) credentials. (see [documentation](https://www.terraform.io/docs/providers/ovh/index.html#configuration-reference)) @@ -158,6 +159,7 @@ export OVH_DOMAIN_NS3_HOST_TEST="..." export OVH_DOMAIN_DS_RECORD_ALGORITHM_TEST="..." export OVH_DOMAIN_DS_RECORD_PUBLIC_KEY_TEST="..." export OVH_DOMAIN_DS_RECORD_TAG_TEST="..." +export OVH_OKMS="..." $ make testacc ``` diff --git a/docs/data-sources/okms_secret.md b/docs/data-sources/okms_secret.md new file mode 100644 index 000000000..353019208 --- /dev/null +++ b/docs/data-sources/okms_secret.md @@ -0,0 +1,90 @@ +--- +subcategory : "Key Management Service (KMS)" +--- + +# ovh_okms_secret (Data Source) + +Retrieves metadata (and optionally the payload) of a secret stored in OVHcloud KMS. + +> WARNING: If `include_data = true` the secret value is stored in cleartext (JSON) in the Terraform state file. Marked **Sensitive** only hides it from CLI output. If you use this option it is recommended to protect your state with encryption and access controls. + +## Example Usage + +Get the latest secret version (metadata only): + +```terraform +data "ovh_okms_secret" "latest" { + okms_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + path = "app/api_credentials" +} +``` + +Get the latest secret version including its data: + +```terraform +data "ovh_okms_secret" "latest_with_data" { + okms_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + path = "app/api_credentials" + include_data = true +} + +locals { + secret_obj = jsondecode(data.ovh_okms_secret.latest_with_data.data) +} + +output "api_key" { + value = local.secret_obj.api_key + sensitive = true +} +``` + +Get a specific version including its payload: + +```terraform +data "ovh_okms_secret" "v3" { + okms_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + path = "app/api_credentials" + version = 3 + include_data = true +} +``` + +## Argument Reference + +The following arguments are supported: + +### Required + +- `okms_id` (String) OKMS service ID that owns the secret. +- `path` (String) Secret path (identifier within the OKMS instance). + +### Optional + +- `version` (Number) Specific version to retrieve. If omitted, the latest (current) version is selected. +- `include_data` (Boolean) If true, retrieves the secret payload (`data` attribute). Defaults to false. When false only metadata is returned. + +## Attributes Reference (Read-Only) + +In addition to the arguments above, the following attributes are exported: + +- `version` (Number) The resolved version number (requested or current latest). +- `data` (String, Sensitive) Raw JSON secret payload (present only if `include_data` is true). +- `metadata` (Block) Secret metadata: + - `cas_required` (Boolean) + - `created_at` (String) + - `updated_at` (String) + - `current_version` (Number) + - `oldest_version` (Number) + - `max_versions` (Number) + - `deactivate_version_after` (String) + - `custom_metadata` (Map of String) +- `iam` (Block) IAM resource metadata: + - `display_name` (String) + - `id` (String) + - `tags` (Map of String) + - `urn` (String) + +## Behavior & Notes + +- The `data` attribute retains the raw JSON returned by the API. Use `jsondecode()` to work with individual keys. +- Changing only `include_data` (true -> false) will cause the `data` attribute to become null in subsequent refreshes (state no longer holds the payload). \ No newline at end of file diff --git a/docs/resources/okms_secret.md b/docs/resources/okms_secret.md new file mode 100644 index 000000000..2a8d9fc02 --- /dev/null +++ b/docs/resources/okms_secret.md @@ -0,0 +1,131 @@ + +--- +subcategory : "Key Management Service (KMS)" +--- + +# ovh_okms_secret (Resource) + +Manages a secret stored in OVHcloud KMS. + +> WARNING: `version.data` is marked **Sensitive** but still ends up in the state file. To mitigate that, it is recommended to protect your state with encryption and access controls. Avoid committing it to source control. + +## Example Usage + +Create a secret whose value is a JSON object. Use `jsonencode()` to produce a deterministic JSON string (ordering/whitespace) to minimize diffs. + +```terraform +resource "ovh_okms_secret" "example" { + okms_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + path = "app/api_credentials" + + metadata = { + max_versions = 10 # keep last 10 versions + cas_required = true # enforce optimistic concurrency control (server will require current secret version on the cas attribute to allow update) + deactivate_version_after = "0s" # keep versions active indefinitely (example) + custom_metadata = { + environment = "prod" + owner = "payments-team" + } + } + + # Initial version (will create version 1) + version = { + data = jsonencode({ + api_key = var.api_key + api_secret = var.api_secret + }) + } +} + +# Reading a field from the secret version data +locals { + secret_json = jsondecode(ovh_okms_secret.example.version.data) +} + +output "api_key" { + value = local.secret_json.api_key + sensitive = true +} +``` + +Updating the secret (new version) while enforcing optimistic concurrency control using CAS: + +```terraform +resource "ovh_okms_secret" "example" { + okms_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + path = "app/api_credentials" + + # Ensure no concurrent update happened: set cas to the current version + # (metadata.current_version is populated after first apply) + cas = ovh_okms_secret.example.metadata.current_version + + metadata = { + cas_required = true + } + + version = { + data = jsonencode({ + api_key = var.api_key + api_secret = var.new_api_secret # changed value -> creates new version + }) + } +} +``` + +## Argument Reference + +The following arguments are supported: + +### Required + +- `okms_id` (String) ID of the OKMS service to create the secret in. +- `path` (String) Secret path (acts as the secret identifier within the OKMS instance). Immutable after creation. +- `version` (Block) Definition of the version to create/update. See Version Block below. (On updates providing a new `version.data` creates a new version.) + +### Optional + +- `cas` (Number) Check‑and‑set parameter used only on update (if `cas_required` metadata is set to true) to enforce optimistic concurrency control: its value must equal the current secret version (`metadata.current_version`) for the update to succeed. Ignored on create. +- `metadata` (Block) Secret metadata configuration (subset of fields are user-settable). See Metadata Block below. + +### Metadata Block + +User configurable attributes inside `metadata`: + +- `cas_required` (Boolean) If true, the server will enforce optimistic concurrency control by requiring the `cas` parameter to match the current version number on every write (update) request. +- `custom_metadata` (Map of String) Arbitrary key/value metadata. +- `deactivate_version_after` (String) Duration (e.g. `"24h"`) after which a version is deactivated. `"0s"` (default) means never automatically deactivate. +- `max_versions` (Number) Number of versions to retain (default 10). Older versions beyond this limit are pruned. + +Computed (read‑only) metadata attributes: + +- `created_at` (String) Creation timestamp of the secret. +- `updated_at` (String) Last update timestamp. +- `current_version` (Number) Current (latest) version number. +- `oldest_version` (Number) Oldest retained version number. + +### Version Block + +Required attribute: + +- `data` (String, Sensitive) Secret payload. Commonly set with `jsonencode(...)` so that Terraform comparisons are stable. Any valid JSON (object, array, string, number, bool) is accepted. Changing `data` creates a new secret version. + +Computed (read‑only) attributes: + +- `id` (Number) Version number. +- `created_at` (String) Version creation timestamp. +- `deactivated_at` (String) Deactivation timestamp if the version was deactivated. +- `state` (String) Version state (e.g. `ACTIVE`). + +## Attributes Reference (Read-Only) + +In addition to arguments above, the following attributes are exported: + +- `iam` (Block) IAM metadata: `display_name`, `id`, `tags`, `urn`. +- `metadata.*` computed fields as listed above. +- `version.*` computed fields as listed above. + +## Behavior & Notes + +- Updating with a new `version.data` performs an API PUT that creates a new version; the previous version remains (subject to `max_versions`). +- If `cas_required` is true, all write operations must include a correct `cas` query parameter (the expected current version number). Set `cas = ovh_okms_secret.example.metadata.current_version` to enforce optimistic concurrency. A mismatch causes the API to reject the update (preventing overwriting unseen changes). +- `cas` is ignored on create (no existing version). diff --git a/examples/data-sources/okms_secret/example_1.tf b/examples/data-sources/okms_secret/example_1.tf new file mode 100644 index 000000000..b9551e26f --- /dev/null +++ b/examples/data-sources/okms_secret/example_1.tf @@ -0,0 +1,14 @@ +data "ovh_okms_secret" "latest_with_data" { + okms_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + path = "app/api_credentials" + include_data = true +} + +locals { + secret_obj = jsondecode(data.ovh_okms_secret.latest_with_data.data) +} + +output "api_key" { + value = local.secret_obj.api_key + sensitive = true +} \ No newline at end of file diff --git a/examples/resources/okms_secret/example_1.tf b/examples/resources/okms_secret/example_1.tf new file mode 100644 index 000000000..0307f7b11 --- /dev/null +++ b/examples/resources/okms_secret/example_1.tf @@ -0,0 +1,37 @@ +resource "ovh_okms_secret" "example" { + okms_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + path = "app/api_credentials" + + # Check‑and‑set parameter used only on update (if `cas_required` metadata is set to true) + # to enforce optimistic concurrency control: its value must equal the current secret version (`metadata.current_version`) + # for the update to succeed. Ignored on create. + cas = 1 + + metadata = { + max_versions = 10 # keep last 10 versions + cas_required = true # enforce optimistic concurrency control (server will require current secret version on the cas attribute to allow update) + deactivate_version_after = "0s" # keep versions active indefinitely (example) + custom_metadata = { + environment = "prod" + appname = "helloworld" + } + } + + # Initial version (will create version 1) + version = { + data = jsonencode({ + api_key = "mykey" + api_secret = "mysecret" + }) + } +} + +# Reading a field from the secret version data +locals { + secret_json = jsondecode(ovh_okms_secret.example.version.data) +} + +output "api_key" { + value = local.secret_json.api_key + sensitive = true +} diff --git a/ovh/data_okms_secret.go b/ovh/data_okms_secret.go new file mode 100644 index 000000000..b011ca532 --- /dev/null +++ b/ovh/data_okms_secret.go @@ -0,0 +1,126 @@ +package ovh + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + ovhtypes "github.com/ovh/terraform-provider-ovh/v2/ovh/types" +) + +var _ datasource.DataSourceWithConfigure = (*okmsSecretDataSource)(nil) + +func NewOkmsSecretDataSource() datasource.DataSource { + return &okmsSecretDataSource{} +} + +type okmsSecretDataSource struct { + config *Config +} + +func (d *okmsSecretDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_okms_secret" +} + +func (d *okmsSecretDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + config, ok := req.ProviderData.(*Config) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *Config, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + d.config = config +} + +func (d *okmsSecretDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = OkmsSecretDataSourceSchema(ctx) +} + +func (d *okmsSecretDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var configModel OkmsSecretDataSourceModel + + // Read Terraform configuration into the lightweight DS model + resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...) + if resp.Diagnostics.HasError() { + return + } + + base := "/v2/okms/resource/" + url.PathEscape(configModel.OkmsId.ValueString()) + "/secret/" + url.PathEscape(configModel.Path.ValueString()) + versionProvided := !configModel.Version.IsNull() && !configModel.Version.IsUnknown() && configModel.Version.ValueInt64() > 0 + includeDataRequested := !configModel.IncludeData.IsNull() && !configModel.IncludeData.IsUnknown() && configModel.IncludeData.ValueBool() + + metaEndpoint := base + if !versionProvided { // only add includeData for latest case; version endpoint handled separately + if includeDataRequested { + metaEndpoint += "?includeData=true" + } + } + var apiModel OkmsSecretModel + if err := d.config.OVHClient.Get(metaEndpoint, &apiModel); err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Error calling Get %s", metaEndpoint), + err.Error(), + ) + return + } + + configModel.Iam = apiModel.Iam + configModel.Metadata = apiModel.Metadata + + if versionProvided { + verEndpoint := base + "/version/" + fmt.Sprintf("%d", configModel.Version.ValueInt64()) + if includeDataRequested { + verEndpoint += "?includeData=true" + } + var ver struct { + Id int64 `json:"id"` + CreatedAt string `json:"createdAt"` + Data json.RawMessage `json:"data"` + State string `json:"state"` + // deactivatedAt may appear + DeactivatedAt *string `json:"deactivatedAt"` + } + if err := d.config.OVHClient.Get(verEndpoint, &ver); err == nil { + if includeDataRequested && len(ver.Data) > 0 && string(ver.Data) != "null" { + configModel.Data = ovhtypes.NewTfStringValue(string(ver.Data)) + } + } else { + resp.Diagnostics.AddError( + fmt.Sprintf("Error calling Get %s", verEndpoint), + err.Error(), + ) + return + } + } else { + configModel.Version = apiModel.Metadata.CurrentVersion + if includeDataRequested { + if !apiModel.Version.Data.IsNull() && !apiModel.Version.Data.IsUnknown() { + configModel.Data = apiModel.Version.Data + } else { + if !apiModel.Metadata.CurrentVersion.IsNull() && !apiModel.Metadata.CurrentVersion.IsUnknown() && apiModel.Metadata.CurrentVersion.ValueInt64() > 0 { + verEndpoint := base + "/version/" + fmt.Sprintf("%d", apiModel.Metadata.CurrentVersion.ValueInt64()) + "?includeData=true" + var ver struct { + Data json.RawMessage `json:"data"` + } + if err := d.config.OVHClient.Get(verEndpoint, &ver); err == nil { + if len(ver.Data) > 0 && string(ver.Data) != "null" { + configModel.Data = ovhtypes.NewTfStringValue(string(ver.Data)) + } + } + } + } + } + } + + // Save state + resp.Diagnostics.Append(resp.State.Set(ctx, &configModel)...) +} diff --git a/ovh/data_okms_secret_gen.go b/ovh/data_okms_secret_gen.go new file mode 100644 index 000000000..baf96057e --- /dev/null +++ b/ovh/data_okms_secret_gen.go @@ -0,0 +1,1002 @@ +// Code generated by terraform-plugin-framework-generator DO NOT EDIT. + +package ovh + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" + ovhtypes "github.com/ovh/terraform-provider-ovh/v2/ovh/types" + + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" +) + +func OkmsSecretDataSourceSchema(ctx context.Context) schema.Schema { + attrs := map[string]schema.Attribute{ + "data": schema.StringAttribute{ + CustomType: ovhtypes.TfStringType{}, + Computed: true, + Sensitive: true, + Description: "Secret data as a JSON string when include_data is true (sensitive)", + MarkdownDescription: "Secret data as a JSON string when include_data is true (sensitive)", + }, + "iam": schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "display_name": schema.StringAttribute{ + CustomType: ovhtypes.TfStringType{}, + Computed: true, + Description: "Resource display name", + MarkdownDescription: "Resource display name", + }, + "id": schema.StringAttribute{ + CustomType: ovhtypes.TfStringType{}, + Computed: true, + Description: "Unique identifier of the resource", + MarkdownDescription: "Unique identifier of the resource", + }, + "tags": schema.MapAttribute{ + CustomType: ovhtypes.NewTfMapNestedType[ovhtypes.TfStringValue](ctx), + Computed: true, + Description: "Resource tags. Tags that were internally computed are prefixed with ovh:", + MarkdownDescription: "Resource tags. Tags that were internally computed are prefixed with ovh:", + }, + "urn": schema.StringAttribute{ + CustomType: ovhtypes.TfStringType{}, + Computed: true, + Description: "Unique resource name used in policies", + MarkdownDescription: "Unique resource name used in policies", + }, + }, + CustomType: IamType{ + ObjectType: types.ObjectType{ + AttrTypes: IamValue{}.AttributeTypes(ctx), + }, + }, + Computed: true, + Description: "IAM resource metadata embedded in services models", + MarkdownDescription: "IAM resource metadata embedded in services models", + }, + "include_data": schema.BoolAttribute{ + CustomType: ovhtypes.TfBoolType{}, + Optional: true, + Description: "Include secret data (warning: if true, secret data will be saved in the terraform state)", + MarkdownDescription: "Include secret data (warning: if true, secret data will be saved in the terraform state)", + }, + "metadata": schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "cas_required": schema.BoolAttribute{ + CustomType: ovhtypes.TfBoolType{}, + Computed: true, + Description: "The “Cas” parameter will be required for each write request if set to true. When the “cas” (Check and set) is specified, the current version of the secret is verified before updating it.", + MarkdownDescription: "The “Cas” parameter will be required for each write request if set to true. When the “cas” (Check and set) is specified, the current version of the secret is verified before updating it.", + }, + "created_at": schema.StringAttribute{ + CustomType: ovhtypes.TfStringType{}, + Computed: true, + Description: "Time of creation of the secret", + MarkdownDescription: "Time of creation of the secret", + }, + "current_version": schema.Int64Attribute{ + CustomType: ovhtypes.TfInt64Type{}, + Computed: true, + Description: "The secret version", + MarkdownDescription: "The secret version", + }, + "custom_metadata": schema.MapAttribute{ + CustomType: ovhtypes.NewTfMapNestedType[ovhtypes.TfStringValue](ctx), + Computed: true, + Description: "Custom metadata", + MarkdownDescription: "Custom metadata", + }, + "deactivate_version_after": schema.StringAttribute{ + CustomType: ovhtypes.TfStringType{}, + Computed: true, + Description: "Time duration before a version is deactivated", + MarkdownDescription: "Time duration before a version is deactivated", + }, + "max_versions": schema.Int64Attribute{ + CustomType: ovhtypes.TfInt64Type{}, + Computed: true, + Description: "The number of versions to keep (10 default)", + MarkdownDescription: "The number of versions to keep (10 default)", + }, + "oldest_version": schema.Int64Attribute{ + CustomType: ovhtypes.TfInt64Type{}, + Computed: true, + Description: "The secret oldest version", + MarkdownDescription: "The secret oldest version", + }, + "updated_at": schema.StringAttribute{ + CustomType: ovhtypes.TfStringType{}, + Computed: true, + Description: "Time of the last update of the secret", + MarkdownDescription: "Time of the last update of the secret", + }, + }, + CustomType: MetadataType{ + ObjectType: types.ObjectType{ + AttrTypes: MetadataValue{}.AttributeTypes(ctx), + }, + }, + Computed: true, + Description: "Secret metadata", + MarkdownDescription: "Secret metadata", + }, + "okms_id": schema.StringAttribute{ + CustomType: ovhtypes.TfStringType{}, + Required: true, + Description: "Okms ID", + MarkdownDescription: "Okms ID", + }, + "path": schema.StringAttribute{ + CustomType: ovhtypes.TfStringType{}, + Required: true, + Description: "Path", + MarkdownDescription: "Path", + }, + "version": schema.Int64Attribute{ + CustomType: ovhtypes.TfInt64Type{}, + Optional: true, + Computed: true, + Description: "Secret version. If not set, the latest version will be returned", + MarkdownDescription: "Secret version. If not set, the latest version will be returned", + }, + } + + return schema.Schema{ + Description: "Retrieve a secret", + Attributes: attrs, + } +} + +type OkmsSecretModel struct { + Cas ovhtypes.TfInt64Value `tfsdk:"cas" json:"cas"` + Iam IamValue `tfsdk:"iam" json:"iam"` + IncludeData ovhtypes.TfBoolValue `tfsdk:"include_data" json:"includeData"` + Metadata MetadataValue `tfsdk:"metadata" json:"metadata"` + OkmsId ovhtypes.TfStringValue `tfsdk:"okms_id" json:"okmsId"` + Path ovhtypes.TfStringValue `tfsdk:"path" json:"path"` + Version SecretVersionValue `tfsdk:"version" json:"version"` +} + +// OkmsSecretDataSourceModel intentionally differs from OkmsSecretModel (resource) by: +// - not including the CAS field (not exposed in data source schema) +// - exposing version as an int64 (selected or current version id) rather than nested object +// - include_data flag to request secret payload +type OkmsSecretDataSourceModel struct { + Iam IamValue `tfsdk:"iam" json:"iam"` + IncludeData ovhtypes.TfBoolValue `tfsdk:"include_data" json:"includeData"` + Metadata MetadataValue `tfsdk:"metadata" json:"metadata"` + OkmsId ovhtypes.TfStringValue `tfsdk:"okms_id" json:"okmsId"` + Path ovhtypes.TfStringValue `tfsdk:"path" json:"path"` + Data ovhtypes.TfStringValue `tfsdk:"data" json:"data"` + Version ovhtypes.TfInt64Value `tfsdk:"version" json:"version"` +} + +type MetadataValue struct { + CasRequired ovhtypes.TfBoolValue `tfsdk:"cas_required" json:"casRequired"` + CreatedAt ovhtypes.TfStringValue `tfsdk:"created_at" json:"createdAt"` + CurrentVersion ovhtypes.TfInt64Value `tfsdk:"current_version" json:"currentVersion"` + CustomMetadata ovhtypes.TfMapNestedValue[ovhtypes.TfStringValue] `tfsdk:"custom_metadata" json:"customMetadata"` + DeactivateVersionAfter ovhtypes.TfStringValue `tfsdk:"deactivate_version_after" json:"deactivateVersionAfter"` + MaxVersions ovhtypes.TfInt64Value `tfsdk:"max_versions" json:"maxVersions"` + OldestVersion ovhtypes.TfInt64Value `tfsdk:"oldest_version" json:"oldestVersion"` + UpdatedAt ovhtypes.TfStringValue `tfsdk:"updated_at" json:"updatedAt"` + state attr.ValueState +} + +func (v *OkmsSecretModel) MergeWith(other *OkmsSecretModel) { + + if (v.Cas.IsUnknown() || v.Cas.IsNull()) && !other.Cas.IsUnknown() { + v.Cas = other.Cas + } + + if (v.Iam.IsUnknown() || v.Iam.IsNull()) && !other.Iam.IsUnknown() { + v.Iam = other.Iam + } + + if (v.IncludeData.IsUnknown() || v.IncludeData.IsNull()) && !other.IncludeData.IsUnknown() { + v.IncludeData = other.IncludeData + } + + if (v.Metadata.IsUnknown() || v.Metadata.IsNull()) && !other.Metadata.IsUnknown() { + v.Metadata = other.Metadata + } + + if (v.OkmsId.IsUnknown() || v.OkmsId.IsNull()) && !other.OkmsId.IsUnknown() { + v.OkmsId = other.OkmsId + } + + if (v.Path.IsUnknown() || v.Path.IsNull()) && !other.Path.IsUnknown() { + v.Path = other.Path + } + + // The create/read OKMS API does not echo back the version payload (especially the data), + // so ensure we preserve an existing Version value from the state/plan when the response omits it. + if (v.Version.Data.IsUnknown() || v.Version.Data.IsNull()) && !other.Version.Data.IsUnknown() && !other.Version.Data.IsNull() { + v.Version = other.Version + } + +} + +func (v *OkmsSecretModel) MarshalJSON() ([]byte, error) { + toMarshal := map[string]any{} + if !v.Metadata.IsNull() && !v.Metadata.IsUnknown() { + toMarshal["metadata"] = v.Metadata + } + if !v.Path.IsNull() && !v.Path.IsUnknown() { + toMarshal["path"] = v.Path + } + + toMarshal["version"] = v.Version + + return json.Marshal(toMarshal) +} + +var _ basetypes.ObjectTypable = MetadataType{} + +type MetadataType struct { + basetypes.ObjectType +} + +func (t MetadataType) Equal(o attr.Type) bool { + other, ok := o.(MetadataType) + + if !ok { + return false + } + + return t.ObjectType.Equal(other.ObjectType) +} + +func (t MetadataType) String() string { + return "MetadataType" +} + +func (t MetadataType) ValueFromObject(ctx context.Context, in basetypes.ObjectValue) (basetypes.ObjectValuable, diag.Diagnostics) { + var diags diag.Diagnostics + + attributes := in.Attributes() + + casRequiredAttribute, ok := attributes["cas_required"] + + if !ok { + diags.AddError( + "Attribute Missing", + `cas_required is missing from object`) + + return nil, diags + } + + casRequiredVal, ok := casRequiredAttribute.(ovhtypes.TfBoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`cas_required expected to be ovhtypes.TfBoolValue, was: %T`, casRequiredAttribute)) + } + + createdAtAttribute, ok := attributes["created_at"] + + if !ok { + diags.AddError( + "Attribute Missing", + `created_at is missing from object`) + + return nil, diags + } + + createdAtVal, ok := createdAtAttribute.(ovhtypes.TfStringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`created_at expected to be ovhtypes.TfStringValue, was: %T`, createdAtAttribute)) + } + + currentVersionAttribute, ok := attributes["current_version"] + + if !ok { + diags.AddError( + "Attribute Missing", + `current_version is missing from object`) + + return nil, diags + } + + currentVersionVal, ok := currentVersionAttribute.(ovhtypes.TfInt64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`current_version expected to be ovhtypes.TfInt64Value, was: %T`, currentVersionAttribute)) + } + + customMetadataAttribute, ok := attributes["custom_metadata"] + + if !ok { + diags.AddError( + "Attribute Missing", + `custom_metadata is missing from object`) + + return nil, diags + } + + customMetadataVal, ok := customMetadataAttribute.(ovhtypes.TfMapNestedValue[ovhtypes.TfStringValue]) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`custom_metadata expected to be ovhtypes.TfMapNestedValue[ovhtypes.TfStringValue], was: %T`, customMetadataAttribute)) + } + + deactivateVersionAfterAttribute, ok := attributes["deactivate_version_after"] + + if !ok { + diags.AddError( + "Attribute Missing", + `deactivate_version_after is missing from object`) + + return nil, diags + } + + deactivateVersionAfterVal, ok := deactivateVersionAfterAttribute.(ovhtypes.TfStringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`deactivate_version_after expected to be ovhtypes.TfStringValue, was: %T`, deactivateVersionAfterAttribute)) + } + + maxVersionsAttribute, ok := attributes["max_versions"] + + if !ok { + diags.AddError( + "Attribute Missing", + `max_versions is missing from object`) + + return nil, diags + } + + maxVersionsVal, ok := maxVersionsAttribute.(ovhtypes.TfInt64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`max_versions expected to be ovhtypes.TfInt64Value, was: %T`, maxVersionsAttribute)) + } + + oldestVersionAttribute, ok := attributes["oldest_version"] + + if !ok { + diags.AddError( + "Attribute Missing", + `oldest_version is missing from object`) + + return nil, diags + } + + oldestVersionVal, ok := oldestVersionAttribute.(ovhtypes.TfInt64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`oldest_version expected to be ovhtypes.TfInt64Value, was: %T`, oldestVersionAttribute)) + } + + updatedAtAttribute, ok := attributes["updated_at"] + + if !ok { + diags.AddError( + "Attribute Missing", + `updated_at is missing from object`) + + return nil, diags + } + + updatedAtVal, ok := updatedAtAttribute.(ovhtypes.TfStringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`updated_at expected to be ovhtypes.TfStringValue, was: %T`, updatedAtAttribute)) + } + + if diags.HasError() { + return nil, diags + } + + return MetadataValue{ + CasRequired: casRequiredVal, + CreatedAt: createdAtVal, + CurrentVersion: currentVersionVal, + CustomMetadata: customMetadataVal, + DeactivateVersionAfter: deactivateVersionAfterVal, + MaxVersions: maxVersionsVal, + OldestVersion: oldestVersionVal, + UpdatedAt: updatedAtVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewMetadataValueNull() MetadataValue { + return MetadataValue{ + state: attr.ValueStateNull, + } +} + +func NewMetadataValueUnknown() MetadataValue { + return MetadataValue{ + state: attr.ValueStateUnknown, + } +} + +func NewMetadataValue(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) (MetadataValue, diag.Diagnostics) { + var diags diag.Diagnostics + + // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/521 + ctx := context.Background() + + for name, attributeType := range attributeTypes { + attribute, ok := attributes[name] + + if !ok { + diags.AddError( + "Missing MetadataValue Attribute Value", + "While creating a MetadataValue value, a missing attribute value was detected. "+ + "A MetadataValue must contain values for all attributes, even if null or unknown. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("MetadataValue Attribute Name (%s) Expected Type: %s", name, attributeType.String()), + ) + + continue + } + + if !attributeType.Equal(attribute.Type(ctx)) { + diags.AddError( + "Invalid MetadataValue Attribute Type", + "While creating a MetadataValue value, an invalid attribute value was detected. "+ + "A MetadataValue must use a matching attribute type for the value. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("MetadataValue Attribute Name (%s) Expected Type: %s\n", name, attributeType.String())+ + fmt.Sprintf("MetadataValue Attribute Name (%s) Given Type: %s", name, attribute.Type(ctx)), + ) + } + } + + for name := range attributes { + _, ok := attributeTypes[name] + + if !ok { + diags.AddError( + "Extra MetadataValue Attribute Value", + "While creating a MetadataValue value, an extra attribute value was detected. "+ + "A MetadataValue must not contain values beyond the expected attribute types. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Extra MetadataValue Attribute Name: %s", name), + ) + } + } + + if diags.HasError() { + return NewMetadataValueUnknown(), diags + } + + casRequiredAttribute, ok := attributes["cas_required"] + + if !ok { + diags.AddError( + "Attribute Missing", + `cas_required is missing from object`) + + return NewMetadataValueUnknown(), diags + } + + casRequiredVal, ok := casRequiredAttribute.(ovhtypes.TfBoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`cas_required expected to be ovhtypes.TfBoolValue, was: %T`, casRequiredAttribute)) + } + + createdAtAttribute, ok := attributes["created_at"] + + if !ok { + diags.AddError( + "Attribute Missing", + `created_at is missing from object`) + + return NewMetadataValueUnknown(), diags + } + + createdAtVal, ok := createdAtAttribute.(ovhtypes.TfStringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`created_at expected to be ovhtypes.TfStringValue, was: %T`, createdAtAttribute)) + } + + currentVersionAttribute, ok := attributes["current_version"] + + if !ok { + diags.AddError( + "Attribute Missing", + `current_version is missing from object`) + + return NewMetadataValueUnknown(), diags + } + + currentVersionVal, ok := currentVersionAttribute.(ovhtypes.TfInt64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`current_version expected to be ovhtypes.TfInt64Value, was: %T`, currentVersionAttribute)) + } + + customMetadataAttribute, ok := attributes["custom_metadata"] + + if !ok { + diags.AddError( + "Attribute Missing", + `custom_metadata is missing from object`) + + return NewMetadataValueUnknown(), diags + } + + customMetadataVal, ok := customMetadataAttribute.(ovhtypes.TfMapNestedValue[ovhtypes.TfStringValue]) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`custom_metadata expected to be ovhtypes.TfMapNestedValue[ovhtypes.TfStringValue], was: %T`, customMetadataAttribute)) + } + + deactivateVersionAfterAttribute, ok := attributes["deactivate_version_after"] + + if !ok { + diags.AddError( + "Attribute Missing", + `deactivate_version_after is missing from object`) + + return NewMetadataValueUnknown(), diags + } + + deactivateVersionAfterVal, ok := deactivateVersionAfterAttribute.(ovhtypes.TfStringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`deactivate_version_after expected to be ovhtypes.TfStringValue, was: %T`, deactivateVersionAfterAttribute)) + } + + maxVersionsAttribute, ok := attributes["max_versions"] + + if !ok { + diags.AddError( + "Attribute Missing", + `max_versions is missing from object`) + + return NewMetadataValueUnknown(), diags + } + + maxVersionsVal, ok := maxVersionsAttribute.(ovhtypes.TfInt64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`max_versions expected to be ovhtypes.TfInt64Value, was: %T`, maxVersionsAttribute)) + } + + oldestVersionAttribute, ok := attributes["oldest_version"] + + if !ok { + diags.AddError( + "Attribute Missing", + `oldest_version is missing from object`) + + return NewMetadataValueUnknown(), diags + } + + oldestVersionVal, ok := oldestVersionAttribute.(ovhtypes.TfInt64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`oldest_version expected to be ovhtypes.TfInt64Value, was: %T`, oldestVersionAttribute)) + } + + updatedAtAttribute, ok := attributes["updated_at"] + + if !ok { + diags.AddError( + "Attribute Missing", + `updated_at is missing from object`) + + return NewMetadataValueUnknown(), diags + } + + updatedAtVal, ok := updatedAtAttribute.(ovhtypes.TfStringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`updated_at expected to be ovhtypes.TfStringValue, was: %T`, updatedAtAttribute)) + } + + if diags.HasError() { + return NewMetadataValueUnknown(), diags + } + + return MetadataValue{ + CasRequired: casRequiredVal, + CreatedAt: createdAtVal, + CurrentVersion: currentVersionVal, + CustomMetadata: customMetadataVal, + DeactivateVersionAfter: deactivateVersionAfterVal, + MaxVersions: maxVersionsVal, + OldestVersion: oldestVersionVal, + UpdatedAt: updatedAtVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewMetadataValueMust(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) MetadataValue { + object, diags := NewMetadataValue(attributeTypes, attributes) + + if diags.HasError() { + // This could potentially be added to the diag package. + diagsStrings := make([]string, 0, len(diags)) + + for _, diagnostic := range diags { + diagsStrings = append(diagsStrings, fmt.Sprintf( + "%s | %s | %s", + diagnostic.Severity(), + diagnostic.Summary(), + diagnostic.Detail())) + } + + panic("NewMetadataValueMust received error(s): " + strings.Join(diagsStrings, "\n")) + } + + return object +} + +func (t MetadataType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + if in.Type() == nil { + return NewMetadataValueNull(), nil + } + + if !in.Type().Equal(t.TerraformType(ctx)) { + return nil, fmt.Errorf("expected %s, got %s", t.TerraformType(ctx), in.Type()) + } + + if !in.IsKnown() { + return NewMetadataValueUnknown(), nil + } + + if in.IsNull() { + return NewMetadataValueNull(), nil + } + + attributes := map[string]attr.Value{} + + val := map[string]tftypes.Value{} + + err := in.As(&val) + + if err != nil { + return nil, err + } + + for k, v := range val { + a, err := t.AttrTypes[k].ValueFromTerraform(ctx, v) + + if err != nil { + return nil, err + } + + attributes[k] = a + } + + return NewMetadataValueMust(MetadataValue{}.AttributeTypes(ctx), attributes), nil +} + +func (t MetadataType) ValueType(ctx context.Context) attr.Value { + return MetadataValue{} +} + +var _ basetypes.ObjectValuable = MetadataValue{} + +func (v *MetadataValue) UnmarshalJSON(data []byte) error { + type JsonMetadataValue MetadataValue + + var tmp JsonMetadataValue + if err := json.Unmarshal(data, &tmp); err != nil { + return err + } + v.CasRequired = tmp.CasRequired + v.CreatedAt = tmp.CreatedAt + v.CurrentVersion = tmp.CurrentVersion + v.CustomMetadata = tmp.CustomMetadata + v.DeactivateVersionAfter = tmp.DeactivateVersionAfter + v.MaxVersions = tmp.MaxVersions + v.OldestVersion = tmp.OldestVersion + v.UpdatedAt = tmp.UpdatedAt + + v.state = attr.ValueStateKnown + + return nil +} + +func (v *MetadataValue) MergeWith(other *MetadataValue) { + + if (v.CasRequired.IsUnknown() || v.CasRequired.IsNull()) && !other.CasRequired.IsUnknown() { + v.CasRequired = other.CasRequired + } + + if (v.CreatedAt.IsUnknown() || v.CreatedAt.IsNull()) && !other.CreatedAt.IsUnknown() { + v.CreatedAt = other.CreatedAt + } + + if (v.CurrentVersion.IsUnknown() || v.CurrentVersion.IsNull()) && !other.CurrentVersion.IsUnknown() { + v.CurrentVersion = other.CurrentVersion + } + + if (v.CustomMetadata.IsUnknown() || v.CustomMetadata.IsNull()) && !other.CustomMetadata.IsUnknown() { + v.CustomMetadata = other.CustomMetadata + } + + if (v.DeactivateVersionAfter.IsUnknown() || v.DeactivateVersionAfter.IsNull()) && !other.DeactivateVersionAfter.IsUnknown() { + v.DeactivateVersionAfter = other.DeactivateVersionAfter + } + + if (v.MaxVersions.IsUnknown() || v.MaxVersions.IsNull()) && !other.MaxVersions.IsUnknown() { + v.MaxVersions = other.MaxVersions + } + + if (v.OldestVersion.IsUnknown() || v.OldestVersion.IsNull()) && !other.OldestVersion.IsUnknown() { + v.OldestVersion = other.OldestVersion + } + + if (v.UpdatedAt.IsUnknown() || v.UpdatedAt.IsNull()) && !other.UpdatedAt.IsUnknown() { + v.UpdatedAt = other.UpdatedAt + } + + if (v.state == attr.ValueStateUnknown || v.state == attr.ValueStateNull) && other.state != attr.ValueStateUnknown { + v.state = other.state + } +} + +func (v MetadataValue) Attributes() map[string]attr.Value { + return map[string]attr.Value{ + "casRequired": v.CasRequired, + "createdAt": v.CreatedAt, + "currentVersion": v.CurrentVersion, + "customMetadata": v.CustomMetadata, + "deactivateVersionAfter": v.DeactivateVersionAfter, + "maxVersions": v.MaxVersions, + "oldestVersion": v.OldestVersion, + "updatedAt": v.UpdatedAt, + } +} +func (v MetadataValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error) { + attrTypes := make(map[string]tftypes.Type, 8) + + var val tftypes.Value + var err error + + attrTypes["cas_required"] = basetypes.BoolType{}.TerraformType(ctx) + attrTypes["created_at"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["current_version"] = basetypes.Int64Type{}.TerraformType(ctx) + attrTypes["custom_metadata"] = basetypes.MapType{ + ElemType: types.StringType, + }.TerraformType(ctx) + attrTypes["deactivate_version_after"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["max_versions"] = basetypes.Int64Type{}.TerraformType(ctx) + attrTypes["oldest_version"] = basetypes.Int64Type{}.TerraformType(ctx) + attrTypes["updated_at"] = basetypes.StringType{}.TerraformType(ctx) + + objectType := tftypes.Object{AttributeTypes: attrTypes} + + switch v.state { + case attr.ValueStateKnown: + vals := make(map[string]tftypes.Value, 8) + + val, err = v.CasRequired.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["cas_required"] = val + + val, err = v.CreatedAt.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["created_at"] = val + + val, err = v.CurrentVersion.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["current_version"] = val + + val, err = v.CustomMetadata.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["custom_metadata"] = val + + val, err = v.DeactivateVersionAfter.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["deactivate_version_after"] = val + + val, err = v.MaxVersions.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["max_versions"] = val + + val, err = v.OldestVersion.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["oldest_version"] = val + + val, err = v.UpdatedAt.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["updated_at"] = val + + if err := tftypes.ValidateValue(objectType, vals); err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + return tftypes.NewValue(objectType, vals), nil + case attr.ValueStateNull: + return tftypes.NewValue(objectType, nil), nil + case attr.ValueStateUnknown: + return tftypes.NewValue(objectType, tftypes.UnknownValue), nil + default: + panic(fmt.Sprintf("unhandled Object state in ToTerraformValue: %s", v.state)) + } +} + +func (v MetadataValue) IsNull() bool { + return v.state == attr.ValueStateNull +} + +func (v MetadataValue) IsUnknown() bool { + return v.state == attr.ValueStateUnknown +} + +func (v MetadataValue) String() string { + return "MetadataValue" +} + +func (v MetadataValue) ToObjectValue(ctx context.Context) (basetypes.ObjectValue, diag.Diagnostics) { + var diags diag.Diagnostics + + objVal, diags := types.ObjectValue( + map[string]attr.Type{ + "cas_required": ovhtypes.TfBoolType{}, + "created_at": ovhtypes.TfStringType{}, + "current_version": ovhtypes.TfInt64Type{}, + "custom_metadata": ovhtypes.NewTfMapNestedType[ovhtypes.TfStringValue](ctx), + "deactivate_version_after": ovhtypes.TfStringType{}, + "max_versions": ovhtypes.TfInt64Type{}, + "oldest_version": ovhtypes.TfInt64Type{}, + "updated_at": ovhtypes.TfStringType{}, + }, + map[string]attr.Value{ + "cas_required": v.CasRequired, + "created_at": v.CreatedAt, + "current_version": v.CurrentVersion, + "custom_metadata": v.CustomMetadata, + "deactivate_version_after": v.DeactivateVersionAfter, + "max_versions": v.MaxVersions, + "oldest_version": v.OldestVersion, + "updated_at": v.UpdatedAt, + }) + + return objVal, diags +} + +func (v MetadataValue) Equal(o attr.Value) bool { + other, ok := o.(MetadataValue) + + if !ok { + return false + } + + if v.state != other.state { + return false + } + + if v.state != attr.ValueStateKnown { + return true + } + + if !v.CasRequired.Equal(other.CasRequired) { + return false + } + + if !v.CreatedAt.Equal(other.CreatedAt) { + return false + } + + if !v.CurrentVersion.Equal(other.CurrentVersion) { + return false + } + + if !v.CustomMetadata.Equal(other.CustomMetadata) { + return false + } + + if !v.DeactivateVersionAfter.Equal(other.DeactivateVersionAfter) { + return false + } + + if !v.MaxVersions.Equal(other.MaxVersions) { + return false + } + + if !v.OldestVersion.Equal(other.OldestVersion) { + return false + } + + if !v.UpdatedAt.Equal(other.UpdatedAt) { + return false + } + + return true +} + +func (v MetadataValue) Type(ctx context.Context) attr.Type { + return MetadataType{ + basetypes.ObjectType{ + AttrTypes: v.AttributeTypes(ctx), + }, + } +} + +func (v MetadataValue) AttributeTypes(ctx context.Context) map[string]attr.Type { + return map[string]attr.Type{ + "cas_required": ovhtypes.TfBoolType{}, + "created_at": ovhtypes.TfStringType{}, + "current_version": ovhtypes.TfInt64Type{}, + "custom_metadata": ovhtypes.NewTfMapNestedType[ovhtypes.TfStringValue](ctx), + "deactivate_version_after": ovhtypes.TfStringType{}, + "max_versions": ovhtypes.TfInt64Type{}, + "oldest_version": ovhtypes.TfInt64Type{}, + "updated_at": ovhtypes.TfStringType{}, + } +} diff --git a/ovh/data_okms_secret_test.go b/ovh/data_okms_secret_test.go new file mode 100644 index 000000000..150aa8e63 --- /dev/null +++ b/ovh/data_okms_secret_test.go @@ -0,0 +1,96 @@ +package ovh + +import ( + "fmt" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +// Step 1 config: create secret version 1 and read latest with data. +// NOTE: resource and data source must be prefixed with provider name 'ovh_'. +const testAccOkmsSecretDataSourceConfigV1 = ` +resource "ovh_okms_secret" "test" { + okms_id = "%s" + path = "%s" + version = { + data = jsonencode({ initial = "v1" }) + } +} + +data "ovh_okms_secret" "latest" { + okms_id = ovh_okms_secret.test.okms_id + path = ovh_okms_secret.test.path + include_data = true +} +` + +// Step 2 config: update same resource (cas=1) to create version 2, then read latest, explicit v1 and v2. +const testAccOkmsSecretDataSourceConfigV2 = ` +resource "ovh_okms_secret" "test" { + okms_id = "%s" + path = "%s" + cas = 1 + version = { + data = jsonencode({ initial = "v1", second = "v2" }) + } +} + +data "ovh_okms_secret" "latest" { + okms_id = ovh_okms_secret.test.okms_id + path = ovh_okms_secret.test.path + include_data = true +} + +data "ovh_okms_secret" "v1" { + okms_id = ovh_okms_secret.test.okms_id + path = ovh_okms_secret.test.path + version = 1 + include_data = true +} + +data "ovh_okms_secret" "v2" { + okms_id = ovh_okms_secret.test.okms_id + path = ovh_okms_secret.test.path + version = 2 + include_data = true +} +` + +func TestAccOkmsSecretDataSource_latestAndVersions(t *testing.T) { + okmsID := os.Getenv("OVH_OKMS") + if okmsID == "" { + checkEnvOrSkip(t, "OVH_OKMS") + } + path := fmt.Sprintf("tfacc-%s", acctest.RandString(6)) + + configV1 := fmt.Sprintf(testAccOkmsSecretDataSourceConfigV1, okmsID, path) + configV2 := fmt.Sprintf(testAccOkmsSecretDataSourceConfigV2, okmsID, path) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheckOkms(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: configV1, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.ovh_okms_secret.latest", "version", "1"), + resource.TestCheckResourceAttrSet("data.ovh_okms_secret.latest", "data"), + ), + }, + { + Config: configV2, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.ovh_okms_secret.latest", "version", "2"), + resource.TestCheckResourceAttrSet("data.ovh_okms_secret.latest", "data"), + resource.TestCheckResourceAttr("data.ovh_okms_secret.v1", "version", "1"), + resource.TestCheckResourceAttrSet("data.ovh_okms_secret.v1", "data"), + resource.TestCheckResourceAttr("data.ovh_okms_secret.v2", "version", "2"), + resource.TestCheckResourceAttrSet("data.ovh_okms_secret.v2", "data"), + ), + }, + }, + }) +} diff --git a/ovh/provider_new.go b/ovh/provider_new.go index 043816209..ae4c1ce0f 100644 --- a/ovh/provider_new.go +++ b/ovh/provider_new.go @@ -250,6 +250,7 @@ func (p *OvhProvider) DataSources(_ context.Context) []func() datasource.DataSou NewOkmsServiceKeyDataSource, NewOkmsServiceKeyJwkDataSource, NewOkmsServiceKeyPemDataSource, + NewOkmsSecretDataSource, NewOvhcloudConnectDatacentersDataSource, NewOvhcloudConnectConfigPopDatacenterExtrasDataSource, NewOvhcloudConnectConfigPopDatacentersDataSource, @@ -296,6 +297,7 @@ func (p *OvhProvider) Resources(_ context.Context) []func() resource.Resource { NewOkmsCredentialResource, NewOkmsServiceKeyResource, NewOkmsServiceKeyJwkResource, + NewOkmsSecretResource, NewOvhcloudConnectPopConfigResource, NewOvhcloudConnectPopDatacenterConfigResource, NewOvhcloudConnectPopDatacenterExtraConfigResource, diff --git a/ovh/resource_okms_secret.go b/ovh/resource_okms_secret.go new file mode 100644 index 000000000..1a01635b2 --- /dev/null +++ b/ovh/resource_okms_secret.go @@ -0,0 +1,295 @@ +package ovh + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + ovhtypes "github.com/ovh/terraform-provider-ovh/v2/ovh/types" +) + +var _ resource.ResourceWithConfigure = (*okmsSecretResource)(nil) + +func NewOkmsSecretResource() resource.Resource { + return &okmsSecretResource{} +} + +type okmsSecretResource struct { + config *Config +} + +func (r *okmsSecretResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_okms_secret" +} + +func (d *okmsSecretResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + config, ok := req.ProviderData.(*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), + ) + return + } + + d.config = config +} + +func (d *okmsSecretResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + s := OkmsSecretResourceSchema(ctx) + + // Ensure changes to identifying attributes force recreation instead of in-place update. + if attr, ok := s.Attributes["okms_id"].(schema.StringAttribute); ok { + attr.PlanModifiers = append(attr.PlanModifiers, stringplanmodifier.RequiresReplace()) + s.Attributes["okms_id"] = attr + } + if attr, ok := s.Attributes["path"].(schema.StringAttribute); ok { + attr.PlanModifiers = append(attr.PlanModifiers, stringplanmodifier.RequiresReplace()) + s.Attributes["path"] = attr + } + + resp.Schema = s +} + +func (r *okmsSecretResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data, responseData OkmsSecretModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + if !data.Cas.IsNull() && !data.Cas.IsUnknown() { + resp.Diagnostics.AddWarning( + "CAS Ignored On Create", + "The 'cas' attribute is only used on update operations and ignored during creation.", + ) + } + + endpoint := "/v2/okms/resource/" + url.PathEscape(data.OkmsId.ValueString()) + "/secret" + createPayload := buildSecretPayload(&data, true) + if err := r.config.OVHClient.Post(endpoint, createPayload, &responseData); err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Error calling Post %s", endpoint), + err.Error(), + ) + return + } + + responseData.MergeWith(&data) + if responseData.Version.Data.IsNull() || responseData.Version.Data.IsUnknown() { + responseData.Version = data.Version + } + + populateVersionComputedFields(r, &responseData, data.OkmsId.ValueString(), data.Path.ValueString()) + + resp.Diagnostics.Append(resp.State.Set(ctx, &responseData)...) +} + +func (r *okmsSecretResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data, responseData OkmsSecretModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + endpoint := "/v2/okms/resource/" + url.PathEscape(data.OkmsId.ValueString()) + "/secret/" + url.PathEscape(data.Path.ValueString()) + "" + + if err := r.config.OVHClient.Get(endpoint, &responseData); err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Error calling Get %s", endpoint), + err.Error(), + ) + return + } + + data.MergeWith(&responseData) + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *okmsSecretResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data, planData, responseData OkmsSecretModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &planData)...) + if resp.Diagnostics.HasError() { + return + } + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Update resource + endpoint := "/v2/okms/resource/" + url.PathEscape(data.OkmsId.ValueString()) + "/secret/" + url.PathEscape(data.Path.ValueString()) + "" + + // Avoid creating a new secret version when only metadata (or other fields) changed and + // the version data itself is unchanged. The API creates a new version whenever a + // version payload is sent, even if the content is identical. We therefore don't include the + // version field in the update payload when the user-specified data matches the + // prior state value. + planForPayload := planData // shallow copy + if !planData.Version.Data.IsNull() && !planData.Version.Data.IsUnknown() && + !data.Version.Data.IsNull() && !data.Version.Data.IsUnknown() && + planData.Version.Data.ValueString() == data.Version.Data.ValueString() { + // Mark version data null in the payload model so buildSecretPayload skips it. + planForPayload.Version.Data = ovhtypes.NewTfStringNull() + } + updatePayload := buildSecretPayload(&planForPayload, false) + // cas (check-and-set) must be passed as query parameter + casQuery := "" + if !planData.Cas.IsNull() && !planData.Cas.IsUnknown() { + casQuery = "?cas=" + url.QueryEscape(fmt.Sprintf("%d", planData.Cas.ValueInt64())) + } + if err := r.config.OVHClient.Put(endpoint+casQuery, updatePayload, nil); err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Error calling Put %s", endpoint+casQuery), + err.Error(), + ) + return + } + + // Read updated resource + endpoint = "/v2/okms/resource/" + url.PathEscape(data.OkmsId.ValueString()) + "/secret/" + url.PathEscape(data.Path.ValueString()) + "" + if err := r.config.OVHClient.Get(endpoint, &responseData); err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Error calling Get %s", endpoint), + err.Error(), + ) + return + } + + responseData.MergeWith(&planData) + + populateVersionComputedFields(r, &responseData, data.OkmsId.ValueString(), data.Path.ValueString()) + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &responseData)...) +} + +func (r *okmsSecretResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data OkmsSecretModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + // Delete API call logic + endpoint := "/v2/okms/resource/" + url.PathEscape(data.OkmsId.ValueString()) + "/secret/" + url.PathEscape(data.Path.ValueString()) + "" + if err := r.config.OVHClient.Delete(endpoint, nil); err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Error calling Delete %s", endpoint), + err.Error(), + ) + } +} + +// buildSecretPayload constructs the payload for create/update. +// On create include path; on update path is immutable so omitted. +func buildSecretPayload(m *OkmsSecretModel, isCreate bool) map[string]any { + payload := map[string]any{} + if isCreate && !m.Path.IsNull() && !m.Path.IsUnknown() { + payload["path"] = m.Path.ValueString() + } + if !m.Version.Data.IsNull() && !m.Version.Data.IsUnknown() { + if vp := buildVersionData(m.Version.Data.ValueString()); vp != nil { + payload["version"] = vp + } + } + if meta := buildMetadataPayload(&m.Metadata); meta != nil { + payload["metadata"] = meta + } + return payload +} + +// buildVersionData attempts to JSON decode the user provided string; if structured returns structured form. +func buildVersionData(raw string) map[string]any { + versionPayload := map[string]any{} + var decoded any + if err := json.Unmarshal([]byte(raw), &decoded); err == nil { + switch decoded.(type) { + case map[string]any, []any: + versionPayload["data"] = decoded + default: + versionPayload["data"] = raw + } + } else { + versionPayload["data"] = raw + } + return versionPayload +} + +// buildMetadataPayload extracts settable metadata fields. +func buildMetadataPayload(meta *MetadataValue) map[string]any { + mp := map[string]any{} + if !meta.CustomMetadata.IsNull() && !meta.CustomMetadata.IsUnknown() { + mp["customMetadata"] = meta.CustomMetadata + } + if !meta.MaxVersions.IsNull() && !meta.MaxVersions.IsUnknown() { + mp["maxVersions"] = meta.MaxVersions + } + if !meta.DeactivateVersionAfter.IsNull() && !meta.DeactivateVersionAfter.IsUnknown() { + mp["deactivateVersionAfter"] = meta.DeactivateVersionAfter + } + if !meta.CasRequired.IsNull() && !meta.CasRequired.IsUnknown() { + mp["casRequired"] = meta.CasRequired + } + if len(mp) == 0 { + return nil + } + return mp +} + +// populateVersionComputedFields fills secret version attributes +func populateVersionComputedFields(r *okmsSecretResource, model *OkmsSecretModel, okmsId, path string) { + // If currentVersion unknown or zero, nothing to enrich + if model.Metadata.CurrentVersion.IsNull() || model.Metadata.CurrentVersion.IsUnknown() || model.Metadata.CurrentVersion.ValueInt64() == 0 { + return + } + + current := model.Metadata.CurrentVersion.ValueInt64() + + // First try the efficient direct version endpoint + versionEndpoint := "/v2/okms/resource/" + url.PathEscape(okmsId) + "/secret/" + url.PathEscape(path) + "/version/" + fmt.Sprintf("%d", current) + var ver struct { + Id *int64 `json:"id"` + CreatedAt *string `json:"createdAt"` + State *string `json:"state"` + DeactivatedAt *string `json:"deactivatedAt"` + } + if err := r.config.OVHClient.Get(versionEndpoint, &ver); err != nil || ver.Id == nil { + // Best-effort enrichment; silently skip on error + return + } + // Populate from direct call + model.Version.Id = ovhtypes.NewTfInt64Value(*ver.Id) + if ver.CreatedAt != nil { + model.Version.CreatedAt = ovhtypes.NewTfStringValue(*ver.CreatedAt) + } + if ver.State != nil { + model.Version.State = ovhtypes.NewTfStringValue(*ver.State) + } + if ver.DeactivatedAt != nil { + model.Version.DeactivatedAt = ovhtypes.NewTfStringValue(*ver.DeactivatedAt) + } else { + model.Version.DeactivatedAt = ovhtypes.NewTfStringNull() + } +} diff --git a/ovh/resource_okms_secret_gen.go b/ovh/resource_okms_secret_gen.go new file mode 100644 index 000000000..46712d082 --- /dev/null +++ b/ovh/resource_okms_secret_gen.go @@ -0,0 +1,287 @@ +// Code generated by terraform-plugin-framework-generator DO NOT EDIT. + +package ovh + +import ( + "context" + "encoding/json" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + ovhtypes "github.com/ovh/terraform-provider-ovh/v2/ovh/types" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema" +) + +func OkmsSecretResourceSchema(ctx context.Context) schema.Schema { + attrs := map[string]schema.Attribute{ + "cas": schema.Int64Attribute{ + CustomType: ovhtypes.TfInt64Type{}, + Optional: true, + Description: "Check-and-set guard. Only used on update operations: must equal the current secret version for the update to succeed. Ignored on create.", + MarkdownDescription: "Check-and-set guard. Only used on update operations: must equal the current secret version for the update to succeed. Ignored on create.", + }, + "iam": schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "display_name": schema.StringAttribute{ + CustomType: ovhtypes.TfStringType{}, + Computed: true, + Description: "Resource display name", + MarkdownDescription: "Resource display name", + }, + "id": schema.StringAttribute{ + CustomType: ovhtypes.TfStringType{}, + Computed: true, + Description: "Unique identifier of the resource", + MarkdownDescription: "Unique identifier of the resource", + }, + "tags": schema.MapAttribute{ + CustomType: ovhtypes.NewTfMapNestedType[ovhtypes.TfStringValue](ctx), + Computed: true, + Description: "Resource tags. Tags that were internally computed are prefixed with ovh:", + MarkdownDescription: "Resource tags. Tags that were internally computed are prefixed with ovh:", + }, + "urn": schema.StringAttribute{ + CustomType: ovhtypes.TfStringType{}, + Computed: true, + Description: "Unique resource name used in policies", + MarkdownDescription: "Unique resource name used in policies", + }, + }, + CustomType: IamType{ + ObjectType: types.ObjectType{ + AttrTypes: IamValue{}.AttributeTypes(ctx), + }, + }, + Computed: true, + Description: "IAM resource metadata embedded in services models", + MarkdownDescription: "IAM resource metadata embedded in services models", + }, + "include_data": schema.BoolAttribute{ + CustomType: ovhtypes.TfBoolType{}, + Optional: true, + Computed: true, + DeprecationMessage: "No effect; will be removed in a future version.", + }, + "metadata": schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "cas_required": schema.BoolAttribute{ + CustomType: ovhtypes.TfBoolType{}, + Optional: true, + Computed: true, + Description: "The “Cas” parameter will be required for each write request if set to true. When the “cas” (Check and set) is specified, the current version of the secret is verified before updating it.", + MarkdownDescription: "The “Cas” parameter will be required for each write request if set to true. When the “cas” (Check and set) is specified, the current version of the secret is verified before updating it.", + }, + "created_at": schema.StringAttribute{ + CustomType: ovhtypes.TfStringType{}, + Computed: true, + Description: "Time of creation of the secret", + MarkdownDescription: "Time of creation of the secret", + }, + "current_version": schema.Int64Attribute{ + CustomType: ovhtypes.TfInt64Type{}, + Computed: true, + Description: "The secret version", + MarkdownDescription: "The secret version", + }, + "custom_metadata": schema.MapAttribute{ + CustomType: ovhtypes.NewTfMapNestedType[ovhtypes.TfStringValue](ctx), + Optional: true, + Computed: true, + Description: "Custom metadata", + MarkdownDescription: "Custom metadata", + }, + "deactivate_version_after": schema.StringAttribute{ + CustomType: ovhtypes.TfStringType{}, + Optional: true, + Computed: true, + Description: "Time duration before a version is deactivated", + MarkdownDescription: "Time duration before a version is deactivated", + }, + "max_versions": schema.Int64Attribute{ + CustomType: ovhtypes.TfInt64Type{}, + Optional: true, + Computed: true, + Description: "The number of versions to keep (10 default)", + MarkdownDescription: "The number of versions to keep (10 default)", + }, + "oldest_version": schema.Int64Attribute{ + CustomType: ovhtypes.TfInt64Type{}, + Computed: true, + Description: "The secret oldest version", + MarkdownDescription: "The secret oldest version", + }, + "updated_at": schema.StringAttribute{ + CustomType: ovhtypes.TfStringType{}, + Computed: true, + Description: "Time of the last update of the secret", + MarkdownDescription: "Time of the last update of the secret", + }, + }, + CustomType: MetadataType{ + ObjectType: types.ObjectType{ + AttrTypes: MetadataValue{}.AttributeTypes(ctx), + }, + }, + Optional: true, + Computed: true, + Description: "Create a secret metadata", + MarkdownDescription: "Create a secret metadata", + }, + "okms_id": schema.StringAttribute{ + CustomType: ovhtypes.TfStringType{}, + Required: true, + Description: "Okms ID", + MarkdownDescription: "Okms ID", + }, + "path": schema.StringAttribute{ + CustomType: ovhtypes.TfStringType{}, + Required: true, + Description: "Secret path", + MarkdownDescription: "Secret path", + }, + "version": schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "created_at": schema.StringAttribute{ + CustomType: ovhtypes.TfStringType{}, + Computed: true, + Description: "Time of creation of the secret version", + MarkdownDescription: "Time of creation of the secret version", + }, + "data": schema.StringAttribute{ + CustomType: ovhtypes.TfStringType{}, + Required: true, + Sensitive: true, + }, + "deactivated_at": schema.StringAttribute{ + CustomType: ovhtypes.TfStringType{}, + Computed: true, + Description: "Time of deactivation of the secret version", + MarkdownDescription: "Time of deactivation of the secret version", + }, + "id": schema.Int64Attribute{ + CustomType: ovhtypes.TfInt64Type{}, + Computed: true, + Description: "Secret version", + MarkdownDescription: "Secret version", + }, + "state": schema.StringAttribute{ + CustomType: ovhtypes.TfStringType{}, + Computed: true, + Description: "State of the secret version", + MarkdownDescription: "State of the secret version", + }, + }, + Required: true, + Description: "Create an OKMS secret version", + MarkdownDescription: "Create an OKMS secret version", + }, + } + + return schema.Schema{ + Description: "", + Attributes: attrs, + } +} + +func (v MetadataValue) MarshalJSON() ([]byte, error) { + toMarshal := map[string]any{} + if !v.CasRequired.IsNull() && !v.CasRequired.IsUnknown() { + toMarshal["casRequired"] = v.CasRequired + } + if !v.CustomMetadata.IsNull() && !v.CustomMetadata.IsUnknown() { + toMarshal["customMetadata"] = v.CustomMetadata + } + if !v.DeactivateVersionAfter.IsNull() && !v.DeactivateVersionAfter.IsUnknown() { + toMarshal["deactivateVersionAfter"] = v.DeactivateVersionAfter + } + if !v.MaxVersions.IsNull() && !v.MaxVersions.IsUnknown() { + toMarshal["maxVersions"] = v.MaxVersions + } + + return json.Marshal(toMarshal) +} + +type SecretVersionValue struct { + CreatedAt ovhtypes.TfStringValue `tfsdk:"created_at" json:"createdAt"` + Data ovhtypes.TfStringValue `tfsdk:"data" json:"data"` + DeactivatedAt ovhtypes.TfStringValue `tfsdk:"deactivated_at" json:"deactivatedAt"` + Id ovhtypes.TfInt64Value `tfsdk:"id" json:"id"` + State ovhtypes.TfStringValue `tfsdk:"state" json:"state"` + state attr.ValueState +} + +func (v SecretVersionValue) MarshalJSON() ([]byte, error) { + toMarshal := map[string]any{} + if !v.CreatedAt.IsNull() && !v.CreatedAt.IsUnknown() { + toMarshal["createdAt"] = v.CreatedAt + } + if !v.Data.IsNull() && !v.Data.IsUnknown() { + toMarshal["data"] = v.Data + } + if !v.DeactivatedAt.IsNull() && !v.DeactivatedAt.IsUnknown() { + toMarshal["deactivatedAt"] = v.DeactivatedAt + } + if !v.Id.IsNull() && !v.Id.IsUnknown() { + toMarshal["id"] = v.Id + } + if !v.State.IsNull() && !v.State.IsUnknown() { + toMarshal["state"] = v.State + } + + return json.Marshal(toMarshal) +} + +// Custom unmarshal to accept either a JSON string or any JSON value (object/array/number/bool) +// for the secret version's data field. We always store it as its raw JSON string representation +// in the TfStringValue so Terraform diffing works against the jsonencode(...) input provided +// by the user. +func (v *SecretVersionValue) UnmarshalJSON(b []byte) error { + // Define an alias without method to avoid recursion + type rawAlias struct { + CreatedAt *string `json:"createdAt"` + Data json.RawMessage `json:"data"` + DeactivatedAt *string `json:"deactivatedAt"` + Id *int64 `json:"id"` + State *string `json:"state"` + } + var tmp rawAlias + if err := json.Unmarshal(b, &tmp); err != nil { + return err + } + + // CreatedAt + if tmp.CreatedAt != nil { + v.CreatedAt = ovhtypes.NewTfStringValue(*tmp.CreatedAt) + } else { + v.CreatedAt = ovhtypes.NewTfStringNull() + } + // Data: accept any JSON. If null -> null, else raw bytes as string + if len(tmp.Data) == 0 || string(tmp.Data) == "null" { + v.Data = ovhtypes.NewTfStringNull() + } else { + // Store exact raw JSON (no reformat) as string + v.Data = ovhtypes.NewTfStringValue(string(tmp.Data)) + } + // DeactivatedAt + if tmp.DeactivatedAt != nil { + v.DeactivatedAt = ovhtypes.NewTfStringValue(*tmp.DeactivatedAt) + } else { + v.DeactivatedAt = ovhtypes.NewTfStringNull() + } + // Id + if tmp.Id != nil { + v.Id = ovhtypes.NewTfInt64Value(*tmp.Id) + } else { + v.Id = ovhtypes.NewTfInt64ValueNull() + } + // State + if tmp.State != nil { + v.State = ovhtypes.NewTfStringValue(*tmp.State) + } else { + v.State = ovhtypes.NewTfStringNull() + } + + v.state = attr.ValueStateKnown + return nil +} diff --git a/ovh/resource_okms_secret_test.go b/ovh/resource_okms_secret_test.go new file mode 100644 index 000000000..bb2e88f48 --- /dev/null +++ b/ovh/resource_okms_secret_test.go @@ -0,0 +1,293 @@ +package ovh + +import ( + "fmt" + "os" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +// Step 1: create secret v1 +const testAccOkmsSecretResourceConfigV1 = ` +resource "ovh_okms_secret" "test" { + okms_id = "%s" + path = "%s" + version = { + data = jsonencode({ initial = "v1" }) + } +} +` + +// Step 2: update secret with cas=1 to create v2 +const testAccOkmsSecretResourceConfigV2 = ` +resource "ovh_okms_secret" "test" { + okms_id = "%s" + path = "%s" + cas = 1 + version = { + data = jsonencode({ initial = "v1", second = "v2" }) + } +} +` + +// Step 3: update again with cas=2 and change metadata (max_versions) to ensure metadata update and version 3 creation. +const testAccOkmsSecretResourceConfigV3 = ` +resource "ovh_okms_secret" "test" { + okms_id = "%s" + path = "%s" + cas = 2 + metadata = { + max_versions = 5 + } + version = { + data = jsonencode({ initial = "v1", second = "v2", third = "v3" }) + } +} +` + +// Update attempt with an incorrect CAS value (expected to fail) +const testAccOkmsSecretResourceConfigWrongCas = ` +resource "ovh_okms_secret" "test" { + okms_id = "%s" + path = "%s" + cas = 9999 + version = { + data = jsonencode({ initial = "v1", wrong = "cas" }) + } +} +` + +// Update to create v2 with cas=1 and set metadata.cas_required = true +const testAccOkmsSecretResourceConfigV2CasRequired = ` +resource "ovh_okms_secret" "test" { + okms_id = "%s" + path = "%s" + cas = 1 + metadata = { + cas_required = true + } + version = { + data = jsonencode({ initial = "v1", second = "v2" }) + } +} +` + +// Metadata-only update (same version data) should NOT create a new version (version.id stays constant) +const testAccOkmsSecretResourceConfigMetadataOnlyNoNewVersion = ` +resource "ovh_okms_secret" "test" { + okms_id = "%s" + path = "%s" + version = { + data = jsonencode({ initial = "v1" }) + } +} +` + +const testAccOkmsSecretResourceConfigMetadataOnlyNoNewVersionStep2 = ` +resource "ovh_okms_secret" "test" { + okms_id = "%s" + path = "%s" + cas = 1 + metadata = { + max_versions = 9 + } + // SAME DATA -> should not create a new version + version = { + data = jsonencode({ initial = "v1" }) + } +} +` + +// Create secret with initial metadata to test MetadataValue.ToCreate implementation +const testAccOkmsSecretResourceConfigCreateWithMetadata = ` +resource "ovh_okms_secret" "test" { + okms_id = "%s" + path = "%s" + metadata = { + cas_required = true + max_versions = 7 + deactivate_version_after = "0s" + custom_metadata = { + env = "acc" + } + } + version = { + data = jsonencode({ initial = "v1" }) + } +} +` + +func TestAccOkmsSecretResource_basicLifecycle(t *testing.T) { + okmsID := os.Getenv("OVH_OKMS") + if okmsID == "" { + checkEnvOrSkip(t, "OVH_OKMS") + } + path := fmt.Sprintf("tfacc-%s", acctest.RandString(6)) + + configV1 := fmt.Sprintf(testAccOkmsSecretResourceConfigV1, okmsID, path) + configV2 := fmt.Sprintf(testAccOkmsSecretResourceConfigV2, okmsID, path) + configV3 := fmt.Sprintf(testAccOkmsSecretResourceConfigV3, okmsID, path) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheckOkms(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: configV1, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("ovh_okms_secret.test", "version.id", "1"), + resource.TestCheckResourceAttrSet("ovh_okms_secret.test", "version.data"), + ), + }, + { + Config: configV2, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("ovh_okms_secret.test", "version.id", "2"), + resource.TestCheckResourceAttrSet("ovh_okms_secret.test", "version.data"), + ), + }, + { + Config: configV3, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("ovh_okms_secret.test", "version.id", "3"), + resource.TestCheckResourceAttr("ovh_okms_secret.test", "metadata.max_versions", "5"), + resource.TestCheckResourceAttrSet("ovh_okms_secret.test", "version.data"), + ), + }, + }, + }) +} + +// Verify CAS mismatch returns an error when wrong cas is provided. +func TestAccOkmsSecretResource_casMismatch(t *testing.T) { + okmsID := os.Getenv("OVH_OKMS") + if okmsID == "" { + checkEnvOrSkip(t, "OVH_OKMS") + } + path := fmt.Sprintf("tfacc-%s", acctest.RandString(6)) + + // Create v1 + configCreate := fmt.Sprintf(testAccOkmsSecretResourceConfigV1, okmsID, path) + // Attempt update with wrong cas (expect failure) + wrongCasConfig := fmt.Sprintf(testAccOkmsSecretResourceConfigWrongCas, okmsID, path) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheckOkms(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + {Config: configCreate}, + { + Config: wrongCasConfig, + ExpectError: regexp.MustCompile(`(?i)cas`), + }, + }, + }) +} + +// Verify updating only metadata (cas required) also creates a new version when version data changes +// and that changing path forces recreation. +func TestAccOkmsSecretResource_pathRecreateAndMetadata(t *testing.T) { + okmsID := os.Getenv("OVH_OKMS") + if okmsID == "" { + checkEnvOrSkip(t, "OVH_OKMS") + } + path1 := fmt.Sprintf("tfacc-%s", acctest.RandString(6)) + path2 := path1 + "-b" + + // initial create v1 + cfg1 := fmt.Sprintf(testAccOkmsSecretResourceConfigV1, okmsID, path1) + // update to v2 with cas=1 and set cas_required true via metadata + cfg2 := fmt.Sprintf(testAccOkmsSecretResourceConfigV2CasRequired, okmsID, path1) + // change path should force recreation (id reset to 1 again) because RequiresReplace + cfg3 := fmt.Sprintf(testAccOkmsSecretResourceConfigV1, okmsID, path2) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheckOkms(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg1, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("ovh_okms_secret.test", "version.id", "1"), + ), + }, + { + Config: cfg2, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("ovh_okms_secret.test", "version.id", "2"), + resource.TestCheckResourceAttr("ovh_okms_secret.test", "metadata.cas_required", "true"), + ), + }, + { + Config: cfg3, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("ovh_okms_secret.test", "version.id", "1"), + ), + }, + }, + }) +} + +// Test metadata-only update does not create a new version +func TestAccOkmsSecretResource_metadataOnlyNoNewVersion(t *testing.T) { + okmsID := os.Getenv("OVH_OKMS") + if okmsID == "" { + checkEnvOrSkip(t, "OVH_OKMS") + } + path := fmt.Sprintf("tfacc-%s", acctest.RandString(6)) + + cfg1 := fmt.Sprintf(testAccOkmsSecretResourceConfigMetadataOnlyNoNewVersion, okmsID, path) + cfg2 := fmt.Sprintf(testAccOkmsSecretResourceConfigMetadataOnlyNoNewVersionStep2, okmsID, path) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheckOkms(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg1, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("ovh_okms_secret.test", "version.id", "1"), + ), + }, + { + Config: cfg2, + Check: resource.ComposeTestCheckFunc( + // version should remain 1 because data unchanged + resource.TestCheckResourceAttr("ovh_okms_secret.test", "version.id", "1"), + resource.TestCheckResourceAttr("ovh_okms_secret.test", "metadata.max_versions", "9"), + ), + }, + }, + }) +} + +// Test creation with metadata supplied at creation time (no subsequent update needed) +func TestAccOkmsSecretResource_createWithMetadata(t *testing.T) { + okmsID := os.Getenv("OVH_OKMS") + if okmsID == "" { + checkEnvOrSkip(t, "OVH_OKMS") + } + path := fmt.Sprintf("tfacc-%s", acctest.RandString(6)) + + cfg := fmt.Sprintf(testAccOkmsSecretResourceConfigCreateWithMetadata, okmsID, path) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheckOkms(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("ovh_okms_secret.test", "version.id", "1"), + resource.TestCheckResourceAttr("ovh_okms_secret.test", "metadata.cas_required", "true"), + resource.TestCheckResourceAttr("ovh_okms_secret.test", "metadata.max_versions", "7"), + resource.TestCheckResourceAttr("ovh_okms_secret.test", "metadata.deactivate_version_after", "0s"), + resource.TestCheckResourceAttr("ovh_okms_secret.test", "metadata.custom_metadata.env", "acc"), + ), + }, + }, + }) +} diff --git a/templates/data-sources/okms_secret.md.tmpl b/templates/data-sources/okms_secret.md.tmpl new file mode 100644 index 000000000..818e82bbb --- /dev/null +++ b/templates/data-sources/okms_secret.md.tmpl @@ -0,0 +1,53 @@ +--- +subcategory : "Key Management Service (KMS)" +--- + +# ovh_okms_secret (Data Source) + +Retrieves metadata (and optionally the payload) of a secret stored in OVHcloud KMS. + +> WARNING: If `include_data = true` the secret value is stored in cleartext (JSON) in the Terraform state file. Marked **Sensitive** only hides it from CLI output. If you use this option it is recommended to protect your state with encryption and access controls. + +## Example Usage + +{{tffile "examples/data-sources/okms_secret/example_1.tf"}} + +## Argument Reference + +The following arguments are supported: + +### Required + +* `okms_id` - (Required) OKMS service ID that owns the secret. +* `path` - (Required) Secret path (identifier within the OKMS instance). + +### Optional + +* `version` - (Optional) Specific version to retrieve. If omitted, the latest (current) version is selected. +* `include_data` - (Optional) If true, retrieves the secret payload (`data` attribute). Defaults to false. When false only metadata is returned. + +## Attributes Reference + +In addition to the arguments above, the following attributes are exported: + +* `version` - The resolved version number (requested or current latest). +* `data` - (Sensitive) Raw JSON secret payload (present only if `include_data` is true). +* `metadata` - (Block) Secret metadata: + * `cas_required` - (Boolean) + * `created_at` - (String) + * `updated_at` - (String) + * `current_version` - (Number) + * `oldest_version` - (Number) + * `max_versions` - (Number) + * `deactivate_version_after` - (String) + * `custom_metadata` - (Map of String) +* `iam` - (Block) IAM resource metadata: + * `display_name` - (String) + * `id` - (String) + * `tags` - (Map of String) + * `urn` - (String) + +## Behavior & Notes + +* The `data` attribute retains the raw JSON returned by the API. Use `jsondecode()` to work with individual keys. +* Changing only `include_data` (true -> false) will cause the `data` attribute to become null in subsequent refreshes (state no longer holds the payload). diff --git a/templates/resources/okms_secret.md.tmpl b/templates/resources/okms_secret.md.tmpl new file mode 100644 index 000000000..9c0351e09 --- /dev/null +++ b/templates/resources/okms_secret.md.tmpl @@ -0,0 +1,71 @@ +--- +subcategory : "Key Management Service (KMS)" +--- + +# ovh_okms_secret + +Manages a secret stored in OVHcloud KMS. + +> WARNING: `version.data` is marked **Sensitive** but still ends up in the state file. To mitigate that, it is recommended to protect your state with encryption and access controls. Avoid committing it to source control. + +## Example Usage + +{{tffile "examples/resources/okms_secret/example_1.tf"}} + +## Argument Reference + +The following arguments are supported: + +### Required + +* `okms_id` - (Required) ID of the OKMS service to create the secret in. +* `path` - (Required, Forces new resource) Secret path (acts as the secret identifier within the OKMS instance). Immutable after creation. +* `version` - (Required) Block defining the secret version to create/update. See Version Block below. (On updates providing a new `version.data` creates a new version.) + +### Optional + +* `cas` - (Optional) Check‑and‑set parameter used only on update (if `cas_required` metadata is set to true) to enforce optimistic concurrency control: its value must equal the current secret version (`metadata.current_version`) for the update to succeed. Ignored on create. +* `metadata` - (Optional) Block of secret metadata configuration (subset of fields are user-settable). See Metadata Block below. + +### Metadata Block + +User configurable attributes inside `metadata`: + +* `cas_required` - (Optional) If true, the server enforces optimistic concurrency control by requiring the `cas` parameter to match the current version number on every write (update) request. +* `custom_metadata` - (Optional) Map of arbitrary key/value metadata. +* `deactivate_version_after` - (Optional) Duration (e.g. `"24h"`) after which a version is deactivated. `"0s"` (default) means never automatically deactivate. +* `max_versions` - (Optional) Number of versions to retain (default 10). Older versions beyond this limit are pruned. + +Computed (read‑only) metadata attributes: + +* `created_at` - Creation timestamp of the secret. +* `updated_at` - Last update timestamp. +* `current_version` - Current (latest) version number. +* `oldest_version` - Oldest retained version number. + +### Version Block + +Required attribute: + +* `data` - (Required, Sensitive) Secret payload. Commonly set with `jsonencode(...)` so that Terraform comparisons are stable. Any valid JSON (object, array, string, number, bool) is accepted. Changing `data` creates a new secret version. + +Computed (read‑only) attributes: + +* `id` - Version number. +* `created_at` - Version creation timestamp. +* `deactivated_at` - Deactivation timestamp if the version was deactivated. +* `state` - Version state (e.g. `ACTIVE`). + +## Attributes Reference + +In addition to arguments above, the following attributes are exported: + +* `iam` - (Block) IAM metadata: `display_name`, `id`, `tags`, `urn`. +* `metadata.*` - Computed fields as listed above. +* `version.*` - Computed fields as listed above. + +## Behavior & Notes + +* Updating with a new `version.data` performs an API PUT that creates a new version; the previous version remains (subject to `max_versions`). +* If `cas_required` is true, all write operations must include a correct `cas` query parameter (the expected current version number). Set `cas = ovh_okms_secret.example.metadata.current_version` to enforce optimistic concurrency. A mismatch causes the API to reject the update (preventing overwriting unseen changes). +* `cas` is ignored on create (no existing version).