feat(okms): add datasource and resource okms_secret

Signed-off-by: alexGNX <alexandre.gagneux12@gmail.com>
This commit is contained in:
alexGNX 2025-08-28 14:52:51 +02:00
parent 85f6824a7e
commit 9180ef475c
No known key found for this signature in database
GPG key ID: 6FD495E17B094F50
17 changed files with 2537 additions and 0 deletions

View file

@ -130,6 +130,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))
@ -169,6 +170,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
```

View file

@ -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).

View file

@ -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) Checkandset 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 (readonly) 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 (readonly) 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).

View file

@ -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
}

View file

@ -0,0 +1,4 @@
data "ovh_okms_secret" "latest" {
okms_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
path = "app/api_credentials"
}

View file

@ -0,0 +1,6 @@
data "ovh_okms_secret" "v3" {
okms_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
path = "app/api_credentials"
version = 3
include_data = true
}

View file

@ -0,0 +1,32 @@
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
}

View file

@ -0,0 +1,19 @@
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
})
}
}

126
ovh/data_okms_secret.go Normal file
View file

@ -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.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)...)
}

1002
ovh/data_okms_secret_gen.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -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"),
),
},
},
})
}

View file

@ -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,

281
ovh/resource_okms_secret.go Normal file
View file

@ -0,0 +1,281 @@
package ovh
import (
"context"
"encoding/json"
"fmt"
"net/url"
"github.com/hashicorp/terraform-plugin-framework/resource"
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) {
resp.Schema = OkmsSecretResourceSchema(ctx)
}
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()
}
}

View file

@ -0,0 +1,295 @@
// 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/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"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",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"path": schema.StringAttribute{
CustomType: ovhtypes.TfStringType{},
Required: true,
Description: "Secret path",
MarkdownDescription: "Secret path",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"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
}

View file

@ -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"),
),
},
},
})
}

View file

@ -0,0 +1,63 @@
---
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):
{{tffile "examples/data-sources/okms_secret/example_2.tf"}}
Get the latest secret version including its data:
{{tffile "examples/data-sources/okms_secret/example_1.tf"}}
Get a specific version including its payload:
{{tffile "examples/data-sources/okms_secret/example_3.tf"}}
## 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).

View file

@ -0,0 +1,81 @@
---
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.
{{tffile "examples/resources/okms_secret/example_1.tf"}}
# Reading a field from the secret version data:
{{tffile "examples/data-sources/okms_secret/example_1.tf"}}
Updating the secret (new version) while enforcing optimistic concurrency control using CAS:
{{tffile "examples/resources/okms_secret/example_2.tf"}}
## 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) Checkandset 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 (readonly) 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 (readonly) 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).