Repository: ovh/terraform-provider-ovh

Issue:
[FEATURE](domain) Manage dynHost (dyndns) logins and records
### Description

Current terraform provider does not manage dyndns (dynHost) resources. They can be created only from the web console.

The API endoints exists and could be used by the provider:

 /domain/zone/{zoneName}/dynHost/login
 /domain/zone/{zoneName}/dynHost/record


### New Resource(s) and/or Data Source(s) that would be available:

* ovh_domain_dynhost_login
* ovh_domain_dynhost_record
`
### Potential Terraform Configuration

```
resource "ovh_domain_dynhost_login" "john" {
  zone      "example.org"
  subdomain "foo"
  password  "s3cr3t"
}

resource "ovh_domain_dynhost_record" "foo" {
  zone      "example.org"
  ip        "192.0.2.0"
  subdomain "foo"

  lifecycle {
    # this should be an implicit default in the provider
    ignore_changes [ ip ]
}
```




Discussion:

Solution patch:
diff --git a/docs/resources/domain_zone_dynhost_login.md b/docs/resources/domain_zone_dynhost_login.md
new file mode 100644
index 00000000..252d6c13
--- /dev/null
+++ b/docs/resources/domain_zone_dynhost_login.md
@@ -0,0 +1,33 @@
+---
+subcategory: "Domain names"
+---
+
+# ovh_domain_zone_dynhost_login (Resource)
+
+Create a dynhost login for a given domain zone.
+
+## Example Usage
+
+```terraform
+resource "ovh_domain_zone_dynhost_login" "dynhost_user" {
+  zone_name    = "mydomain.ovh"
+  sub_domain   = "dynhost"
+  login_suffix = "dynhostUser"
+  password     = "thisIsMyPassword"
+}
+```
+
+<!-- schema generated by tfplugindocs -->
+## Schema
+
+### Required
+
+- `login_suffix` (String) Suffix that will be concatenated to the zoneName to create the login
+- `password` (String, Sensitive) Password of the login
+- `sub_domain` (String) Subdomain that the login will be allowed to update (use * to allow all)
+- `zone_name` (String) Zone name
+
+### Read-Only
+
+- `login` (String) Login name
+- `zone` (String) Login zone
diff --git a/docs/resources/domain_zone_dynhost_record.md b/docs/resources/domain_zone_dynhost_record.md
new file mode 100644
index 00000000..47b1c98f
--- /dev/null
+++ b/docs/resources/domain_zone_dynhost_record.md
@@ -0,0 +1,35 @@
+---
+subcategory: "Domain names"
+---
+
+# ovh_domain_zone_dynhost_record (Resource)
+
+Create a dynhost record for a given domain zone.
+
+## Example Usage
+
+```terraform
+resource "ovh_domain_zone_dynhost_record" "dynhost_record" {
+  zone_name  = "mydomain.ovh"
+  sub_domain = "dynhost"
+  ip         = "1.2.3.4"
+}
+```
+
+<!-- schema generated by tfplugindocs -->
+## Schema
+
+### Required
+
+- `zone_name` (String) Zone name
+
+### Optional
+
+- `ip` (String) Record IP
+- `sub_domain` (String) Record sub-domain
+
+### Read-Only
+
+- `id` (Number) Record ID
+- `ttl` (Number) Record TTL (Time to Live)
+- `zone` (String) Record zone
diff --git a/examples/resources/domain_zone_dynhost_login/resource.tf b/examples/resources/domain_zone_dynhost_login/resource.tf
new file mode 100644
index 00000000..f9dc866a
--- /dev/null
+++ b/examples/resources/domain_zone_dynhost_login/resource.tf
@@ -0,0 +1,6 @@
+resource "ovh_domain_zone_dynhost_login" "dynhost_user" {
+  zone_name    = "mydomain.ovh"
+  sub_domain   = "dynhost"
+  login_suffix = "dynhostUser"
+  password     = "thisIsMyPassword"
+}
diff --git a/examples/resources/domain_zone_dynhost_record/resource.tf b/examples/resources/domain_zone_dynhost_record/resource.tf
new file mode 100644
index 00000000..fdcd8bd2
--- /dev/null
+++ b/examples/resources/domain_zone_dynhost_record/resource.tf
@@ -0,0 +1,5 @@
+resource "ovh_domain_zone_dynhost_record" "dynhost_record" {
+  zone_name  = "mydomain.ovh"
+  sub_domain = "dynhost"
+  ip         = "1.2.3.4"
+}
diff --git a/ovh/provider_new.go b/ovh/provider_new.go
index 6df3f8cd..1ebdbec8 100644
--- a/ovh/provider_new.go
+++ b/ovh/provider_new.go
@@ -277,6 +277,8 @@ func (p *OvhProvider) Resources(_ context.Context) []func() resource.Resource {
 		NewDomainNameResource,
 		NewDomainZoneDnssecResource,
 		NewDomainZoneImportResource,
+		NewDomainZoneDynhostLoginResource,
+		NewDomainZoneDynhostRecordResource,
 		NewIpFirewallResource,
 		NewIpFirewallRuleResource,
 		NewIploadbalancingSslResource,
diff --git a/ovh/resource_domain_zone_dynhost_login.go b/ovh/resource_domain_zone_dynhost_login.go
new file mode 100644
index 00000000..41c60c2e
--- /dev/null
+++ b/ovh/resource_domain_zone_dynhost_login.go
@@ -0,0 +1,154 @@
+package ovh
+
+import (
+	"context"
+	"fmt"
+	"net/url"
+
+	"github.com/hashicorp/terraform-plugin-framework/resource"
+)
+
+var _ resource.ResourceWithConfigure = (*domainZoneDynhostLoginResource)(nil)
+
+func NewDomainZoneDynhostLoginResource() resource.Resource {
+	return &domainZoneDynhostLoginResource{}
+}
+
+type domainZoneDynhostLoginResource struct {
+	config *Config
+}
+
+func (r *domainZoneDynhostLoginResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
+	resp.TypeName = req.ProviderTypeName + "_domain_zone_dynhost_login"
+}
+
+func (d *domainZoneDynhostLoginResource) 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 *domainZoneDynhostLoginResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
+	resp.Schema = DomainZoneDynhostLoginResourceSchema(ctx)
+}
+
+func (r *domainZoneDynhostLoginResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
+	var data, responseData DomainZoneDynhostLoginModel
+
+	// Read Terraform plan data into the model
+	resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	endpoint := "/domain/zone/" + url.PathEscape(data.ZoneName.ValueString()) + "/dynHost/login"
+	if err := r.config.OVHClient.Post(endpoint, data.ToCreate(), &responseData); err != nil {
+		resp.Diagnostics.AddError(
+			fmt.Sprintf("Error calling Post %s", endpoint),
+			err.Error(),
+		)
+		return
+	}
+
+	responseData.MergeWith(&data)
+
+	// Save data into Terraform state
+	resp.Diagnostics.Append(resp.State.Set(ctx, &responseData)...)
+}
+
+func (r *domainZoneDynhostLoginResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
+	var data, responseData DomainZoneDynhostLoginModel
+
+	// Read Terraform prior state data into the model
+	resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	endpoint := "/domain/zone/" + url.PathEscape(data.ZoneName.ValueString()) + "/dynHost/login/" + url.PathEscape(data.Login.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 *domainZoneDynhostLoginResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
+	var data, planData, responseData DomainZoneDynhostLoginModel
+
+	// 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 := "/domain/zone/" + url.PathEscape(data.ZoneName.ValueString()) + "/dynHost/login/" + url.PathEscape(data.Login.ValueString()) + ""
+	if err := r.config.OVHClient.Put(endpoint, planData.ToUpdate(), nil); err != nil {
+		resp.Diagnostics.AddError(
+			fmt.Sprintf("Error calling Put %s", endpoint),
+			err.Error(),
+		)
+		return
+	}
+
+	// Read updated resource
+	endpoint = "/domain/zone/" + url.PathEscape(data.ZoneName.ValueString()) + "/dynHost/login/" + url.PathEscape(data.Login.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)
+
+	// Save updated data into Terraform state
+	resp.Diagnostics.Append(resp.State.Set(ctx, &responseData)...)
+}
+
+func (r *domainZoneDynhostLoginResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
+	var data DomainZoneDynhostLoginModel
+
+	// 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 := "/domain/zone/" + url.PathEscape(data.ZoneName.ValueString()) + "/dynHost/login/" + url.PathEscape(data.Login.ValueString()) + ""
+	if err := r.config.OVHClient.Delete(endpoint, nil); err != nil {
+		resp.Diagnostics.AddError(
+			fmt.Sprintf("Error calling Delete %s", endpoint),
+			err.Error(),
+		)
+	}
+}
diff --git a/ovh/resource_domain_zone_dynhost_login_gen.go b/ovh/resource_domain_zone_dynhost_login_gen.go
new file mode 100644
index 00000000..4e3111e1
--- /dev/null
+++ b/ovh/resource_domain_zone_dynhost_login_gen.go
@@ -0,0 +1,138 @@
+// Code generated by terraform-plugin-framework-generator DO NOT EDIT.
+
+package ovh
+
+import (
+	"context"
+	"encoding/json"
+	ovhtypes "github.com/ovh/terraform-provider-ovh/v2/ovh/types"
+
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema"
+)
+
+func DomainZoneDynhostLoginResourceSchema(ctx context.Context) schema.Schema {
+	attrs := map[string]schema.Attribute{
+		"login": schema.StringAttribute{
+			CustomType:          ovhtypes.TfStringType{},
+			Computed:            true,
+			Description:         "Login name",
+			MarkdownDescription: "Login name",
+		},
+		"login_suffix": schema.StringAttribute{
+			CustomType:          ovhtypes.TfStringType{},
+			Required:            true,
+			Description:         "Suffix that will be concatenated to the zoneName to create the login",
+			MarkdownDescription: "Suffix that will be concatenated to the zoneName to create the login",
+		},
+		"password": schema.StringAttribute{
+			CustomType:          ovhtypes.TfStringType{},
+			Required:            true,
+			Sensitive:           true,
+			Description:         "Password of the login",
+			MarkdownDescription: "Password of the login",
+		},
+		"sub_domain": schema.StringAttribute{
+			CustomType:          ovhtypes.TfStringType{},
+			Required:            true,
+			Description:         "Subdomain that the login will be allowed to update (use * to allow all)",
+			MarkdownDescription: "Subdomain that the login will be allowed to update (use * to allow all)",
+		},
+		"zone": schema.StringAttribute{
+			CustomType:          ovhtypes.TfStringType{},
+			Computed:            true,
+			Description:         "Login zone",
+			MarkdownDescription: "Login zone",
+		},
+		"zone_name": schema.StringAttribute{
+			CustomType:          ovhtypes.TfStringType{},
+			Required:            true,
+			Description:         "Zone name",
+			MarkdownDescription: "Zone name",
+		},
+	}
+
+	return schema.Schema{
+		Description: "",
+		Attributes:  attrs,
+	}
+}
+
+type DomainZoneDynhostLoginModel struct {
+	Login       ovhtypes.TfStringValue `tfsdk:"login" json:"login"`
+	LoginSuffix ovhtypes.TfStringValue `tfsdk:"login_suffix" json:"loginSuffix"`
+	Password    ovhtypes.TfStringValue `tfsdk:"password" json:"password"`
+	SubDomain   ovhtypes.TfStringValue `tfsdk:"sub_domain" json:"subDomain"`
+	Zone        ovhtypes.TfStringValue `tfsdk:"zone" json:"zone"`
+	ZoneName    ovhtypes.TfStringValue `tfsdk:"zone_name" json:"zoneName"`
+}
+
+func (v *DomainZoneDynhostLoginModel) MergeWith(other *DomainZoneDynhostLoginModel) {
+
+	if (v.Login.IsUnknown() || v.Login.IsNull()) && !other.Login.IsUnknown() {
+		v.Login = other.Login
+	}
+
+	if (v.LoginSuffix.IsUnknown() || v.LoginSuffix.IsNull()) && !other.LoginSuffix.IsUnknown() {
+		v.LoginSuffix = other.LoginSuffix
+	}
+
+	if (v.Password.IsUnknown() || v.Password.IsNull()) && !other.Password.IsUnknown() {
+		v.Password = other.Password
+	}
+
+	if (v.SubDomain.IsUnknown() || v.SubDomain.IsNull()) && !other.SubDomain.IsUnknown() {
+		v.SubDomain = other.SubDomain
+	}
+
+	if (v.Zone.IsUnknown() || v.Zone.IsNull()) && !other.Zone.IsUnknown() {
+		v.Zone = other.Zone
+	}
+
+	if (v.ZoneName.IsUnknown() || v.ZoneName.IsNull()) && !other.ZoneName.IsUnknown() {
+		v.ZoneName = other.ZoneName
+	}
+
+}
+
+func (v DomainZoneDynhostLoginModel) ToCreate() *DomainZoneDynhostLoginModel {
+	res := &DomainZoneDynhostLoginModel{}
+
+	if !v.LoginSuffix.IsUnknown() {
+		res.LoginSuffix = v.LoginSuffix
+	}
+
+	if !v.Password.IsUnknown() {
+		res.Password = v.Password
+	}
+
+	if !v.SubDomain.IsUnknown() {
+		res.SubDomain = v.SubDomain
+	}
+
+	return res
+}
+
+func (v DomainZoneDynhostLoginModel) ToUpdate() *DomainZoneDynhostLoginModel {
+	res := &DomainZoneDynhostLoginModel{}
+
+	if !v.SubDomain.IsUnknown() {
+		res.SubDomain = v.SubDomain
+	}
+
+	return res
+}
+
+func (v *DomainZoneDynhostLoginModel) MarshalJSON() ([]byte, error) {
+	toMarshal := map[string]any{}
+	if !v.LoginSuffix.IsNull() && !v.LoginSuffix.IsUnknown() {
+		toMarshal["loginSuffix"] = v.LoginSuffix
+	}
+	if !v.Password.IsNull() && !v.Password.IsUnknown() {
+		toMarshal["password"] = v.Password
+	}
+	if !v.SubDomain.IsNull() && !v.SubDomain.IsUnknown() {
+		toMarshal["subDomain"] = v.SubDomain
+	}
+
+	return json.Marshal(toMarshal)
+}
diff --git a/ovh/resource_domain_zone_dynhost_record.go b/ovh/resource_domain_zone_dynhost_record.go
new file mode 100644
index 00000000..fd022e97
--- /dev/null
+++ b/ovh/resource_domain_zone_dynhost_record.go
@@ -0,0 +1,155 @@
+package ovh
+
+import (
+	"context"
+	"fmt"
+	"net/url"
+	"strconv"
+
+	"github.com/hashicorp/terraform-plugin-framework/resource"
+)
+
+var _ resource.ResourceWithConfigure = (*domainZoneDynhostRecordResource)(nil)
+
+func NewDomainZoneDynhostRecordResource() resource.Resource {
+	return &domainZoneDynhostRecordResource{}
+}
+
+type domainZoneDynhostRecordResource struct {
+	config *Config
+}
+
+func (r *domainZoneDynhostRecordResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
+	resp.TypeName = req.ProviderTypeName + "_domain_zone_dynhost_record"
+}
+
+func (d *domainZoneDynhostRecordResource) 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 *domainZoneDynhostRecordResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
+	resp.Schema = DomainZoneDynhostRecordResourceSchema(ctx)
+}
+
+func (r *domainZoneDynhostRecordResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
+	var data, responseData DomainZoneDynhostRecordModel
+
+	// Read Terraform plan data into the model
+	resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	endpoint := "/domain/zone/" + url.PathEscape(data.ZoneName.ValueString()) + "/dynHost/record"
+	if err := r.config.OVHClient.Post(endpoint, data.ToCreate(), &responseData); err != nil {
+		resp.Diagnostics.AddError(
+			fmt.Sprintf("Error calling Post %s", endpoint),
+			err.Error(),
+		)
+		return
+	}
+
+	responseData.MergeWith(&data)
+
+	// Save data into Terraform state
+	resp.Diagnostics.Append(resp.State.Set(ctx, &responseData)...)
+}
+
+func (r *domainZoneDynhostRecordResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
+	var data, responseData DomainZoneDynhostRecordModel
+
+	// Read Terraform prior state data into the model
+	resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	endpoint := "/domain/zone/" + url.PathEscape(data.ZoneName.ValueString()) + "/dynHost/record/" + strconv.FormatInt(data.Id.ValueInt64(), 10)
+
+	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 *domainZoneDynhostRecordResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
+	var data, planData, responseData DomainZoneDynhostRecordModel
+
+	// 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 := "/domain/zone/" + url.PathEscape(data.ZoneName.ValueString()) + "/dynHost/record/" + strconv.FormatInt(data.Id.ValueInt64(), 10)
+	if err := r.config.OVHClient.Put(endpoint, planData.ToUpdate(), nil); err != nil {
+		resp.Diagnostics.AddError(
+			fmt.Sprintf("Error calling Put %s", endpoint),
+			err.Error(),
+		)
+		return
+	}
+
+	// Read updated resource
+	endpoint = "/domain/zone/" + url.PathEscape(data.ZoneName.ValueString()) + "/dynHost/record/" + strconv.FormatInt(data.Id.ValueInt64(), 10)
+	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)
+
+	// Save updated data into Terraform state
+	resp.Diagnostics.Append(resp.State.Set(ctx, &responseData)...)
+}
+
+func (r *domainZoneDynhostRecordResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
+	var data DomainZoneDynhostRecordModel
+
+	// 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 := "/domain/zone/" + url.PathEscape(data.ZoneName.ValueString()) + "/dynHost/record/" + strconv.FormatInt(data.Id.ValueInt64(), 10)
+	if err := r.config.OVHClient.Delete(endpoint, nil); err != nil {
+		resp.Diagnostics.AddError(
+			fmt.Sprintf("Error calling Delete %s", endpoint),
+			err.Error(),
+		)
+	}
+}
diff --git a/ovh/resource_domain_zone_dynhost_record_gen.go b/ovh/resource_domain_zone_dynhost_record_gen.go
new file mode 100644
index 00000000..0fbae79d
--- /dev/null
+++ b/ovh/resource_domain_zone_dynhost_record_gen.go
@@ -0,0 +1,136 @@
+// Code generated by terraform-plugin-framework-generator DO NOT EDIT.
+
+package ovh
+
+import (
+	"context"
+	"encoding/json"
+	ovhtypes "github.com/ovh/terraform-provider-ovh/v2/ovh/types"
+
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema"
+)
+
+func DomainZoneDynhostRecordResourceSchema(ctx context.Context) schema.Schema {
+	attrs := map[string]schema.Attribute{
+		"id": schema.Int64Attribute{
+			CustomType:          ovhtypes.TfInt64Type{},
+			Computed:            true,
+			Description:         "Record ID",
+			MarkdownDescription: "Record ID",
+		},
+		"ip": schema.StringAttribute{
+			CustomType:          ovhtypes.TfStringType{},
+			Optional:            true,
+			Computed:            true,
+			Description:         "Record IP",
+			MarkdownDescription: "Record IP",
+		},
+		"sub_domain": schema.StringAttribute{
+			CustomType:          ovhtypes.TfStringType{},
+			Optional:            true,
+			Computed:            true,
+			Description:         "Record sub-domain",
+			MarkdownDescription: "Record sub-domain",
+		},
+		"ttl": schema.Int64Attribute{
+			CustomType:          ovhtypes.TfInt64Type{},
+			Computed:            true,
+			Description:         "Record TTL (Time to Live)",
+			MarkdownDescription: "Record TTL (Time to Live)",
+		},
+		"zone": schema.StringAttribute{
+			CustomType:          ovhtypes.TfStringType{},
+			Computed:            true,
+			Description:         "Record zone",
+			MarkdownDescription: "Record zone",
+		},
+		"zone_name": schema.StringAttribute{
+			CustomType:          ovhtypes.TfStringType{},
+			Required:            true,
+			Description:         "Zone name",
+			MarkdownDescription: "Zone name",
+		},
+	}
+
+	return schema.Schema{
+		Description: "",
+		Attributes:  attrs,
+	}
+}
+
+type DomainZoneDynhostRecordModel struct {
+	Id        ovhtypes.TfInt64Value  `tfsdk:"id" json:"id"`
+	Ip        ovhtypes.TfStringValue `tfsdk:"ip" json:"ip"`
+	SubDomain ovhtypes.TfStringValue `tfsdk:"sub_domain" json:"subDomain"`
+	Ttl       ovhtypes.TfInt64Value  `tfsdk:"ttl" json:"ttl"`
+	Zone      ovhtypes.TfStringValue `tfsdk:"zone" json:"zone"`
+	ZoneName  ovhtypes.TfStringValue `tfsdk:"zone_name" json:"zoneName"`
+}
+
+func (v *DomainZoneDynhostRecordModel) MergeWith(other *DomainZoneDynhostRecordModel) {
+
+	if (v.Id.IsUnknown() || v.Id.IsNull()) && !other.Id.IsUnknown() {
+		v.Id = other.Id
+	}
+
+	if (v.Ip.IsUnknown() || v.Ip.IsNull()) && !other.Ip.IsUnknown() {
+		v.Ip = other.Ip
+	}
+
+	if (v.SubDomain.IsUnknown() || v.SubDomain.IsNull()) && !other.SubDomain.IsUnknown() {
+		v.SubDomain = other.SubDomain
+	}
+
+	if (v.Ttl.IsUnknown() || v.Ttl.IsNull()) && !other.Ttl.IsUnknown() {
+		v.Ttl = other.Ttl
+	}
+
+	if (v.Zone.IsUnknown() || v.Zone.IsNull()) && !other.Zone.IsUnknown() {
+		v.Zone = other.Zone
+	}
+
+	if (v.ZoneName.IsUnknown() || v.ZoneName.IsNull()) && !other.ZoneName.IsUnknown() {
+		v.ZoneName = other.ZoneName
+	}
+
+}
+
+func (v DomainZoneDynhostRecordModel) ToCreate() *DomainZoneDynhostRecordModel {
+	res := &DomainZoneDynhostRecordModel{}
+
+	if !v.Ip.IsUnknown() {
+		res.Ip = v.Ip
+	}
+
+	if !v.SubDomain.IsUnknown() {
+		res.SubDomain = v.SubDomain
+	}
+
+	return res
+}
+
+func (v DomainZoneDynhostRecordModel) ToUpdate() *DomainZoneDynhostRecordModel {
+	res := &DomainZoneDynhostRecordModel{}
+
+	if !v.Ip.IsUnknown() {
+		res.Ip = v.Ip
+	}
+
+	if !v.SubDomain.IsUnknown() {
+		res.SubDomain = v.SubDomain
+	}
+
+	return res
+}
+
+func (v *DomainZoneDynhostRecordModel) MarshalJSON() ([]byte, error) {
+	toMarshal := map[string]any{}
+	if !v.Ip.IsNull() && !v.Ip.IsUnknown() {
+		toMarshal["ip"] = v.Ip
+	}
+	if !v.SubDomain.IsNull() && !v.SubDomain.IsUnknown() {
+		toMarshal["subDomain"] = v.SubDomain
+	}
+
+	return json.Marshal(toMarshal)
+}
diff --git a/templates/resources/domain_zone_dynhost_login.md.tmpl b/templates/resources/domain_zone_dynhost_login.md.tmpl
new file mode 100644
index 00000000..805bb7d5
--- /dev/null
+++ b/templates/resources/domain_zone_dynhost_login.md.tmpl
@@ -0,0 +1,26 @@
+---
+subcategory: "Domain names"
+---
+
+# ovh_domain_zone_dynhost_login (Resource)
+
+Create a dynhost login for a given domain zone.
+
+## Example Usage
+
+{{tffile "examples/resources/domain_zone_dynhost_login/resource.tf"}}
+
+<!-- schema generated by tfplugindocs -->
+## Schema
+
+### Required
+
+- `login_suffix` (String) Suffix that will be concatenated to the zoneName to create the login
+- `password` (String, Sensitive) Password of the login
+- `sub_domain` (String) Subdomain that the login will be allowed to update (use * to allow all)
+- `zone_name` (String) Zone name
+
+### Read-Only
+
+- `login` (String) Login name
+- `zone` (String) Login zone
diff --git a/templates/resources/domain_zone_dynhost_record.md.tmpl b/templates/resources/domain_zone_dynhost_record.md.tmpl
new file mode 100644
index 00000000..a8c59cdc
--- /dev/null
+++ b/templates/resources/domain_zone_dynhost_record.md.tmpl
@@ -0,0 +1,29 @@
+---
+subcategory: "Domain names"
+---
+
+# ovh_domain_zone_dynhost_record (Resource)
+
+Create a dynhost record for a given domain zone.
+
+## Example Usage
+
+{{tffile "examples/resources/domain_zone_dynhost_record/resource.tf"}}
+
+<!-- schema generated by tfplugindocs -->
+## Schema
+
+### Required
+
+- `zone_name` (String) Zone name
+
+### Optional
+
+- `ip` (String) Record IP
+- `sub_domain` (String) Record sub-domain
+
+### Read-Only
+
+- `id` (Number) Record ID
+- `ttl` (Number) Record TTL (Time to Live)
+- `zone` (String) Record zone



Test patch:
diff --git a/ovh/resource_domain_zone_dynhost_login_test.go b/ovh/resource_domain_zone_dynhost_login_test.go
new file mode 100644
index 00000000..42b82192
--- /dev/null
+++ b/ovh/resource_domain_zone_dynhost_login_test.go
@@ -0,0 +1,148 @@
+package ovh
+
+import (
+	"fmt"
+	"log"
+	"net/url"
+	"os"
+	"regexp"
+	"testing"
+	"time"
+
+	"github.com/hashicorp/terraform-plugin-testing/helper/acctest"
+	"github.com/hashicorp/terraform-plugin-testing/helper/resource"
+)
+
+func init() {
+	resource.AddTestSweepers("ovh_domain_zone_dynhost_login", &resource.Sweeper{
+		Name: "ovh_domain_zone_dynhost_login",
+		F:    testSweepDomainZoneDynhostLogin,
+	})
+}
+
+func testSweepDomainZoneDynhostLogin(region string) error {
+	client, err := sharedClientForRegion(region)
+	if err != nil {
+		return fmt.Errorf("error getting client: %s", err)
+	}
+
+	zoneName := os.Getenv("OVH_ZONE_TEST")
+	if zoneName == "" {
+		log.Print("[DEBUG] OVH_ZONE_TEST is not set. No zone dynhost logins to sweep")
+		return nil
+	}
+
+	logins := make([]string, 0)
+	if err := client.Get(fmt.Sprintf("/domain/zone/%s/dynhost/login", url.PathEscape(zoneName)), &logins); err != nil {
+		return fmt.Errorf("Error calling /domain/zone/%s/dynhost/login:\n\t %q", zoneName, err)
+	}
+
+	if len(logins) == 0 {
+		log.Print("[DEBUG] No record to sweep")
+		return nil
+	}
+
+	for _, l := range logins {
+		login := &DomainZoneDynhostLoginModel{}
+
+		if err := client.Get(fmt.Sprintf("/domain/zone/%s/dynhost/login/%s", url.PathEscape(zoneName), url.PathEscape(l)), &login); err != nil {
+			return fmt.Errorf("Error calling /domain/zone/%s/dynhost/login/%s:\n\t %q", zoneName, l, err)
+		}
+
+		log.Printf("[DEBUG] login found %v", login)
+
+		err = resource.Retry(5*time.Minute, func() *resource.RetryError {
+			log.Printf("[INFO] Deleting login %v", login)
+			if err := client.Delete(fmt.Sprintf("/domain/zone/%s/dynhost/login/%s", url.PathEscape(zoneName), url.PathEscape(l)), nil); err != nil {
+				return resource.RetryableError(err)
+			}
+			// Successful delete
+			return nil
+		})
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func TestAccDomainZoneDynHostLogin_Basic(t *testing.T) {
+	zone := os.Getenv("OVH_ZONE_TEST")
+	subdomain := acctest.RandomWithPrefix(test_prefix)
+
+	resource.Test(t, resource.TestCase{
+		PreCheck:                 func() { testAccPreCheckDomain(t) },
+		ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+		Steps: []resource.TestStep{
+			// provider should send an error if no zone is given
+			{
+				Config:      testAccOvhDomainZoneDynhostLoginNoZoneNameConfig(subdomain, "suffix", "aStrongPassword"),
+				ExpectError: regexp.MustCompile(`The argument "zone_name" is required, but no definition was found.`),
+			},
+			// provider should send an error if zone does not exist
+			{
+				Config:      testAccOvhDomainZoneDynhostLoginConfig("non-existing-domain.com", subdomain, "suffix", "aStrongPassword"),
+				ExpectError: regexp.MustCompile(`Client::NotFound`),
+			},
+			// provider should send an error if password is not strong password (i.e. < 12char)
+			{
+				Config:      testAccOvhDomainZoneDynhostLoginConfig(zone, subdomain, "suffix", "weakPwd"),
+				ExpectError: regexp.MustCompile(`'Length must be between 12 and 512.'`),
+			},
+
+			// resource creation
+			{
+				Config: testAccOvhDomainZoneDynhostLoginConfig(zone, subdomain, "suffix", "aStrongPassword"),
+				Check: resource.ComposeTestCheckFunc(
+					resource.TestCheckResourceAttr("ovh_domain_zone_dynhost_login.this", "login", zone+"-suffix"),
+					resource.TestCheckResourceAttr("ovh_domain_zone_dynhost_login.this", "login_suffix", "suffix"),
+					resource.TestCheckResourceAttr("ovh_domain_zone_dynhost_login.this", "password", "aStrongPassword"),
+					resource.TestCheckResourceAttr("ovh_domain_zone_dynhost_login.this", "sub_domain", subdomain),
+					resource.TestCheckResourceAttr("ovh_domain_zone_dynhost_login.this", "zone", zone),
+				),
+			},
+			// resource password update
+			{
+				Config: testAccOvhDomainZoneDynhostLoginConfig(zone, subdomain, "suffix", "anotherStrongPassword"),
+				Check: resource.ComposeTestCheckFunc(
+					resource.TestCheckResourceAttr("ovh_domain_zone_dynhost_login.this", "login", zone+"-suffix"),
+					resource.TestCheckResourceAttr("ovh_domain_zone_dynhost_login.this", "login_suffix", "suffix"),
+					resource.TestCheckResourceAttr("ovh_domain_zone_dynhost_login.this", "password", "anotherStrongPassword"),
+					resource.TestCheckResourceAttr("ovh_domain_zone_dynhost_login.this", "sub_domain", subdomain),
+					resource.TestCheckResourceAttr("ovh_domain_zone_dynhost_login.this", "zone", zone),
+				),
+			},
+			// resource suffix update
+			{
+				Config: testAccOvhDomainZoneDynhostLoginConfig(zone, subdomain, "another_suffix", "anotherStrongPassword"),
+				Check: resource.ComposeTestCheckFunc(
+					resource.TestCheckResourceAttr("ovh_domain_zone_dynhost_login.this", "login", zone+"-suffix"),
+					resource.TestCheckResourceAttr("ovh_domain_zone_dynhost_login.this", "login_suffix", "another_suffix"),
+					resource.TestCheckResourceAttr("ovh_domain_zone_dynhost_login.this", "password", "anotherStrongPassword"),
+					resource.TestCheckResourceAttr("ovh_domain_zone_dynhost_login.this", "sub_domain", subdomain),
+					resource.TestCheckResourceAttr("ovh_domain_zone_dynhost_login.this", "zone", zone),
+				),
+			},
+		},
+	})
+}
+
+func testAccOvhDomainZoneDynhostLoginNoZoneNameConfig(subDomain, loginSuffix, password string) string {
+	return fmt.Sprintf(`	
+resource "ovh_domain_zone_dynhost_login" "this" {
+  sub_domain   = %q
+  login_suffix = %q
+  password     = %q
+}`, subDomain, loginSuffix, password)
+}
+
+func testAccOvhDomainZoneDynhostLoginConfig(zoneName, subDomain, loginSuffix, password string) string {
+	return fmt.Sprintf(`	
+resource "ovh_domain_zone_dynhost_login" "this" {
+  zone_name    = %q
+  sub_domain   = %q
+  login_suffix = %q
+  password     = %q
+}`, zoneName, subDomain, loginSuffix, password)
+}
diff --git a/ovh/resource_domain_zone_dynhost_record_test.go b/ovh/resource_domain_zone_dynhost_record_test.go
new file mode 100644
index 00000000..87348f80
--- /dev/null
+++ b/ovh/resource_domain_zone_dynhost_record_test.go
@@ -0,0 +1,139 @@
+package ovh
+
+import (
+	"fmt"
+	"log"
+	"net/url"
+	"os"
+	"regexp"
+	"testing"
+	"time"
+
+	"github.com/hashicorp/terraform-plugin-testing/helper/acctest"
+	"github.com/hashicorp/terraform-plugin-testing/helper/resource"
+)
+
+func init() {
+	resource.AddTestSweepers("ovh_domain_zone_dynhost_record", &resource.Sweeper{
+		Name: "ovh_domain_zone_dynhost_record",
+		F:    testSweepDomainZoneDynhostRecord,
+	})
+}
+
+func testSweepDomainZoneDynhostRecord(region string) error {
+	client, err := sharedClientForRegion(region)
+	if err != nil {
+		return fmt.Errorf("error getting client: %s", err)
+	}
+
+	zoneName := os.Getenv("OVH_ZONE_TEST")
+	if zoneName == "" {
+		log.Print("[DEBUG] OVH_ZONE_TEST is not set. No zone dynhost records to sweep")
+		return nil
+	}
+
+	records := make([]string, 0)
+	if err := client.Get(fmt.Sprintf("/domain/zone/%s/dynhost/record", url.PathEscape(zoneName)), &records); err != nil {
+		return fmt.Errorf("Error calling /domain/zone/%s/dynhost/record:\n\t %q", zoneName, err)
+	}
+
+	if len(records) == 0 {
+		log.Print("[DEBUG] No record to sweep")
+		return nil
+	}
+
+	for _, rec := range records {
+		record := &DomainZoneDynhostRecordModel{}
+
+		if err := client.Get(fmt.Sprintf("/domain/zone/%s/dynhost/record/%s", url.PathEscape(zoneName), url.PathEscape(rec)), &record); err != nil {
+			return fmt.Errorf("Error calling /domain/zone/%s/dynhost/record/%s:\n\t %q", zoneName, rec, err)
+		}
+
+		log.Printf("[DEBUG] record found %v", record)
+
+		err = resource.Retry(5*time.Minute, func() *resource.RetryError {
+			log.Printf("[INFO] Deleting record %v", record)
+			if err := client.Delete(fmt.Sprintf("/domain/zone/%s/dynhost/record/%v", zoneName, rec), nil); err != nil {
+				return resource.RetryableError(err)
+			}
+			// Successful delete
+			return nil
+		})
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func TestAccDomainZoneDynHostRecord_Basic(t *testing.T) {
+	zone := os.Getenv("OVH_ZONE_TEST")
+	subdomain := acctest.RandomWithPrefix(test_prefix)
+
+	resource.Test(t, resource.TestCase{
+		PreCheck:                 func() { testAccPreCheckDomain(t) },
+		ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+		Steps: []resource.TestStep{
+			// provider should send an error if no zone is given
+			{
+				Config:      testAccOvhDomainZoneDynhostRecordNoZoneNameConfig(subdomain, "1.2.3.4"),
+				ExpectError: regexp.MustCompile(`The argument "zone_name" is required, but no definition was found.`),
+			},
+			// provider should send an error if zone does not exist
+			{
+				Config:      testAccOvhDomainZoneDynhostRecordConfig("non-existing-domain.com", subdomain, "1.2.3.4"),
+				ExpectError: regexp.MustCompile("Client::NotFound: \"This service does\nnot exist\""),
+			},
+			// provider should send an error if password IP is not valid
+			{
+				Config:      testAccOvhDomainZoneDynhostRecordConfig(zone, subdomain, "1.2.3.4.5"),
+				ExpectError: regexp.MustCompile("'Invalid\nip'"),
+			},
+			// resource creation
+			{
+				Config: testAccOvhDomainZoneDynhostRecordConfig(zone, subdomain, "1.2.3.4"),
+				Check: resource.ComposeTestCheckFunc(
+					resource.TestCheckResourceAttr("ovh_domain_zone_dynhost_record.this", "ip", "1.2.3.4"),
+					resource.TestCheckResourceAttr("ovh_domain_zone_dynhost_record.this", "sub_domain", subdomain),
+					resource.TestCheckResourceAttr("ovh_domain_zone_dynhost_record.this", "zone", zone),
+				),
+			},
+			// resource ip update
+			{
+				Config: testAccOvhDomainZoneDynhostRecordConfig(zone, subdomain, "5.6.7.8"),
+				Check: resource.ComposeTestCheckFunc(
+					resource.TestCheckResourceAttr("ovh_domain_zone_dynhost_record.this", "ip", "5.6.7.8"),
+					resource.TestCheckResourceAttr("ovh_domain_zone_dynhost_record.this", "sub_domain", subdomain),
+					resource.TestCheckResourceAttr("ovh_domain_zone_dynhost_record.this", "zone", zone),
+				),
+			},
+			// resource subdomain update
+			{
+				Config: testAccOvhDomainZoneDynhostRecordConfig(zone, "other-subdomain", "5.6.7.8"),
+				Check: resource.ComposeTestCheckFunc(
+					resource.TestCheckResourceAttr("ovh_domain_zone_dynhost_record.this", "ip", "5.6.7.8"),
+					resource.TestCheckResourceAttr("ovh_domain_zone_dynhost_record.this", "sub_domain", "other-subdomain"),
+					resource.TestCheckResourceAttr("ovh_domain_zone_dynhost_record.this", "zone", zone),
+				),
+			},
+		},
+	})
+}
+
+func testAccOvhDomainZoneDynhostRecordNoZoneNameConfig(subDomain, ip string) string {
+	return fmt.Sprintf(`	
+resource "ovh_domain_zone_dynhost_record" "this" {
+  sub_domain = %q
+  ip         = %q
+}`, subDomain, ip)
+}
+
+func testAccOvhDomainZoneDynhostRecordConfig(zoneName, subDomain, ip string) string {
+	return fmt.Sprintf(`	
+resource "ovh_domain_zone_dynhost_record" "this" {
+  zone_name  = %q
+  sub_domain = %q
+  ip         = %q
+}`, zoneName, subDomain, ip)
+}