diff --git a/internal/provider/data_dns_caa_record_set.go b/internal/provider/data_dns_caa_record_set.go new file mode 100644 index 00000000..8a69aca0 --- /dev/null +++ b/internal/provider/data_dns_caa_record_set.go @@ -0,0 +1,122 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "context" + "fmt" + "net" + "sort" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ datasource.DataSource = (*dnsCAARecordSetDataSource)(nil) +) + +func NewDnsCAARecordSetDataSource() datasource.DataSource { + return &dnsCAARecordSetDataSource{} +} + +type dnsCAARecordSetDataSource struct{} + +func (d *dnsCAARecordSetDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_caa_record_set" +} + +func (d *dnsCAARecordSetDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Use this data source to get DNS CAA records for a domain.", + Attributes: map[string]schema.Attribute{ + "domain": schema.StringAttribute{ + Required: true, + Description: "Domain to look up.", + }, + "caa": schema.ListAttribute{ + Computed: true, + ElementType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "flags": types.Int64Type, + "tags": types.StringType, + "value": types.StringType, + }, + }, + Description: "A list of records. They are sorted to stay consistent across runs.", + }, + "id": schema.StringAttribute{ + Computed: true, + Description: "Always set to the domain.", + }, + }, + } +} + +func (d *dnsCAARecordSetDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config caaRecordSetConfig + + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + domain := config.Domain.ValueString() + records, err := net.LookupCAA(domain) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("error looking up CAA records for %q: ", domain), err.Error()) + return + } + + // Sort by flags ascending, tags alphabetically, and value alphabetically + sort.Slice(records, func(i, j int) bool { + if records[i].Flags < records[j].Flags { + return true + } + if records[i].Flags > records[j].Flags { + return false + } + if records[i].Tag < records[j].Tag { + return true + } + if records[i].Tag > records[j].Tag { + return false + } + return records[i].Value < records[j].Value + }) + + caa := make([]caaBlockConfig, len(records)) + for i, record := range records { + caa[i] = caaBlockConfig{ + Flags: types.Int64Value(int64(record.Flags)), + Tag: types.StringValue(record.Tag), + Value: types.StringValue(record.Value), + } + } + + var convertDiags diag.Diagnostics + config.CAA, convertDiags = types.ListValueFrom(ctx, config.CAA.ElementType(ctx), caa) + if convertDiags.HasError() { + resp.Diagnostics.Append(convertDiags...) + return + } + + config.ID = config.Domain + resp.Diagnostics.Append(resp.State.Set(ctx, config)...) +} + +type caaRecordSetConfig struct { + ID types.String `tfsdk:"id"` + Domain types.String `tfsdk:"domain"` + CAA types.List `tfsdk:"caa"` //caaBlockConfig +} + +type caaBlockConfig struct { + Flags types.Int64 `tfsdk:"flags"` + Tag types.String `tfsdk:"tag"` + Value types.String `tfsdk:"value"` +} diff --git a/internal/provider/resource_dns_caa_record_set.go b/internal/provider/resource_dns_caa_record_set.go new file mode 100644 index 00000000..b4b0bc03 --- /dev/null +++ b/internal/provider/resource_dns_caa_record_set.go @@ -0,0 +1,445 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "context" + "fmt" + "strconv" + "sort" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/miekg/dns" + + "github.com/hashicorp/terraform-provider-dns/internal/validators/dnsvalidator" +) + +var ( + _ resource.Resource = (*dnsCAARecordSetResource)(nil) + _ resource.ResourceWithImportState = (*dnsCAARecordSetResource)(nil) + _ resource.ResourceWithConfigure = (*dnsCAARecordSetResource)(nil) +) + +func NewDnsCAARecordSetResource() resource.Resource { + return &dnsCAARecordSetResource{} +} + +type dnsCAARecordSetResource struct { + client *DNSClient +} + +func (d *dnsCAARecordSetResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_caa_record_set" +} + +func (d *dnsCAARecordSetResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Creates an CAA type DNS record set.", + Attributes: map[string]schema.Attribute{ + "zone": schema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + dnsvalidator.IsZoneNameValid(), + }, + Description: "DNS zone the record set belongs to. It must be an FQDN, that is, include the trailing dot.", + }, + "name": schema.StringAttribute{ + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + dnsvalidator.IsRecordNameValid(), + }, + Description: "The name of the record set. The `zone` argument will be appended to this value to create " + + "the full record path.", + }, + "ttl": schema.Int64Attribute{ + Optional: true, + Computed: true, + Default: int64default.StaticInt64(3600), + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + Description: "The TTL of the record set. Defaults to `3600`.", + }, + "id": schema.StringAttribute{ + Computed: true, + Description: "Always set to the fully qualified domain name of the record set.", + }, + }, + Blocks: map[string]schema.Block{ + "caa": schema.SetNestedBlock{ + Description: "Can be specified multiple times for each CAA record.", + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "flags": schema.Int64Attribute{ + Required: true, + Description: "The flags for the record.", + }, + "tag": schema.StringAttribute{ + Required: true, + // Validators: []validator.String{ + // dnsvalidator.IsCAATagValid(), + //}, + Description: "The tag of the CAA record, must be one of 'issue', 'issuewild', 'iodef'.", + }, + "value": schema.StringAttribute{ + Required: true, + Description: "The value for the record. Do not include outer quotes, escape inner quotes.", + }, + }, + }, + }, + }, + } +} + +func (d *dnsCAARecordSetResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*DNSClient) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *DNSClient, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + d.client = client +} + +func (d *dnsCAARecordSetResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan caaRecordSetResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + config := dnsConfig{ + Name: plan.Name.ValueString(), + Zone: plan.Zone.ValueString(), + } + fqdn := resourceFQDN_framework(config) + plan.ID = types.StringValue(fqdn) + + msg := new(dns.Msg) + msg.SetUpdate(plan.Zone.ValueString()) + + var planCAA []caaBlockConfig + var diags diag.Diagnostics + + diags.Append(plan.CAA.ElementsAs(ctx, &planCAA, false)...) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + } + + // Loop through all the new addresses and insert them + for _, caa := range planCAA { + rrStr := fmt.Sprintf("%s %d CAA %d %s %s", fqdn, plan.TTL.ValueInt64(), caa.Flags.ValueInt64(), caa.Tag.ValueString(), strconv.Quote(caa.Value.ValueString())) + + rr_insert, err := dns.NewRR(rrStr) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Error reading DNS record (%s):", rrStr), err.Error()) + return + } + + msg.Insert([]dns.RR{rr_insert}) + } + + r, err := exchange(msg, true, d.client) + if err != nil { + resp.Diagnostics.AddError("Error updating DNS record:", err.Error()) + return + } + if r.Rcode != dns.RcodeSuccess { + resp.Diagnostics.AddError(fmt.Sprintf("Error updating DNS record: %v", r.Rcode), dns.RcodeToString[r.Rcode]) + return + } + + answers, diags := resourceDnsRead_framework(config, d.client, dns.TypeCAA) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + + if len(answers) > 0 { + var ttl sort.IntSlice + + var caa []caaBlockConfig + for _, record := range answers { + switch r := record.(type) { + case *dns.CAA: + m := caaBlockConfig{ + Flags: types.Int64Value(int64(r.Flags)), + Tag: types.StringValue(r.Tag), + Value: types.StringValue(r.Value), + } + caa = append(caa, m) + ttl = append(ttl, int(r.Hdr.Ttl)) + default: + resp.Diagnostics.AddError("Error querying DNS record:", "didn't get a CAA record") + return + } + } + sort.Sort(ttl) + + var convertDiags diag.Diagnostics + plan.CAA, convertDiags = types.SetValueFrom(ctx, plan.CAA.ElementType(ctx), caa) + if convertDiags.HasError() { + resp.Diagnostics.Append(convertDiags...) + return + } + + plan.TTL = types.Int64Value(int64(ttl[0])) + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) + } +} + +func (d *dnsCAARecordSetResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state caaRecordSetResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + config := dnsConfig{ + Name: state.Name.ValueString(), + Zone: state.Zone.ValueString(), + } + + answers, diags := resourceDnsRead_framework(config, d.client, dns.TypeCAA) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + + if len(answers) > 0 { + var ttl sort.IntSlice + + var caa []caaBlockConfig + for _, record := range answers { + switch r := record.(type) { + case *dns.CAA: + m := caaBlockConfig{ + Flags: types.Int64Value(int64(r.Flags)), + Tag: types.StringValue(r.Tag), + Value: types.StringValue(r.Value), + } + caa = append(caa, m) + ttl = append(ttl, int(r.Hdr.Ttl)) + default: + resp.Diagnostics.AddError("Error querying DNS record:", "didn't get a CAA record") + return + } + } + sort.Sort(ttl) + + var convertDiags diag.Diagnostics + state.CAA, convertDiags = types.SetValueFrom(ctx, state.CAA.ElementType(ctx), caa) + if convertDiags.HasError() { + resp.Diagnostics.Append(convertDiags...) + return + } + + state.TTL = types.Int64Value(int64(ttl[0])) + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) + } else { + resp.State.RemoveResource(ctx) + } +} + +func (d *dnsCAARecordSetResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state caaRecordSetResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + config := dnsConfig{ + Name: plan.Name.ValueString(), + Zone: plan.Zone.ValueString(), + } + fqdn := resourceFQDN_framework(config) + + msg := new(dns.Msg) + msg.SetUpdate(plan.Zone.ValueString()) + + if !plan.CAA.Equal(state.CAA) { + + var planCAA, stateCAA []caaBlockConfig + + resp.Diagnostics.Append(plan.CAA.ElementsAs(ctx, &planCAA, false)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(state.CAA.ElementsAs(ctx, &stateCAA, false)...) + if resp.Diagnostics.HasError() { + return + } + + var add []caaBlockConfig + for _, newCAA := range planCAA { + for _, oldCAA := range stateCAA { + if oldCAA == newCAA { + continue + } + } + add = append(add, newCAA) + } + + var remove []caaBlockConfig + for _, oldCAA := range stateCAA { + for _, newCAA := range planCAA { + if oldCAA == newCAA { + continue + } + } + remove = append(remove, oldCAA) + } + + // Loop through all the old addresses and remove them + for _, caa := range remove { + rrStr := fmt.Sprintf("%s %d CAA %d %s %s", fqdn, plan.TTL.ValueInt64(), caa.Flags.ValueInt64(), caa.Tag.ValueString(), strconv.Quote(caa.Value.ValueString())) + + rr_remove, err := dns.NewRR(rrStr) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Error reading DNS record (%s):", rrStr), err.Error()) + return + } + + msg.Remove([]dns.RR{rr_remove}) + } + // Loop through all the new addresses and insert them + for _, caa := range add { + rrStr := fmt.Sprintf("%s %d CAA %d %s %s", fqdn, plan.TTL.ValueInt64(), caa.Flags.ValueInt64(), caa.Tag.ValueString(), strconv.Quote(caa.Value.ValueString())) + + rr_insert, err := dns.NewRR(rrStr) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Error reading DNS record (%s):", rrStr), err.Error()) + return + } + + msg.Insert([]dns.RR{rr_insert}) + } + + r, err := exchange(msg, true, d.client) + if err != nil { + resp.Diagnostics.AddError("Error updating DNS record:", err.Error()) + return + } + if r.Rcode != dns.RcodeSuccess { + resp.Diagnostics.AddError(fmt.Sprintf("Error updating DNS record: %v", r.Rcode), + dns.RcodeToString[r.Rcode]) + return + } + } + + answers, diags := resourceDnsRead_framework(config, d.client, dns.TypeCAA) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + + if len(answers) > 0 { + var ttl sort.IntSlice + + var caa []caaBlockConfig + for _, record := range answers { + switch r := record.(type) { + case *dns.CAA: + m := caaBlockConfig{ + Flags: types.Int64Value(int64(r.Flags)), + Tag: types.StringValue(r.Tag), + Value: types.StringValue(r.Value), + } + caa = append(caa, m) + ttl = append(ttl, int(r.Hdr.Ttl)) + default: + resp.Diagnostics.AddError("Error querying DNS record:", + "didn't get an CAA record") + return + } + } + sort.Sort(ttl) + + var convertDiags diag.Diagnostics + state.CAA, convertDiags = types.SetValueFrom(ctx, state.CAA.ElementType(ctx), caa) + if convertDiags.HasError() { + resp.Diagnostics.Append(convertDiags...) + return + } + + state.TTL = types.Int64Value(int64(ttl[0])) + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) + } +} + +func (d *dnsCAARecordSetResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state caaRecordSetResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + config := dnsConfig{ + Name: state.Name.ValueString(), + Zone: state.Zone.ValueString(), + } + + resp.Diagnostics.Append(resourceDnsDelete_framework(config, d.client, dns.TypeCAA)...) +} + +func (d *dnsCAARecordSetResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + + config, diags := resourceDnsImport_framework(req.ID, d.client) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), req.ID)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("zone"), config.Zone)...) + if config.Name != "" { + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), config.Name)...) + } +} + +type caaRecordSetResourceModel struct { + ID types.String `tfsdk:"id"` + Zone types.String `tfsdk:"zone"` + Name types.String `tfsdk:"name"` + CAA types.Set `tfsdk:"caa"` //caaBlockConfig + TTL types.Int64 `tfsdk:"ttl"` +} diff --git a/internal/provider/resource_dns_caa_record_set_test.go b/internal/provider/resource_dns_caa_record_set_test.go new file mode 100644 index 00000000..5d4b9b89 --- /dev/null +++ b/internal/provider/resource_dns_caa_record_set_test.go @@ -0,0 +1,166 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/miekg/dns" +) + +func TestAccDnsCAARecordSet_Basic(t *testing.T) { + resourceName := "dns_caa_record_set.foo" + resourceRoot := "dns_caa_record_set.root" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV5ProviderFactories: testProtoV5ProviderFactories, + CheckDestroy: testAccCheckDnsCAARecordSetDestroy, + Steps: []resource.TestStep{ + { + Config: testAccDnsCAARecordSet_basic, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "caa.#", "1"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "caa.*", map[string]string{"flags": "0", "tag": "issue", "value": ";"}), + ), + }, + { + Config: testAccDnsCAARecordSet_update, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "caa.#", "2"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "caa.*", map[string]string{"flags": "0", "tag": "issue", "value": "example.com;"}), + ), + }, + { + PreConfig: func() { testRemoveRecord(t, "CAA", "foo") }, + Config: testAccDnsCAARecordSet_update, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "caa.#", "2"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "caa.*", map[string]string{"flags": "0", "tag": "issue", "value": "example.com;"}), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccDNSCAARecordSet_root, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceRoot, "caa.#", "1"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "caa.*", map[string]string{"flags": "0", "tag": "issue", "value": ";"}), + ), + }, + { + ResourceName: resourceRoot, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccDnsCAARecordSet_Basic_Upgrade(t *testing.T) { + resourceName := "dns_caa_record_set.foo" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + CheckDestroy: testAccCheckDnsCAARecordSetDestroy, + Steps: []resource.TestStep{ + { + ExternalProviders: providerVersion324(), + Config: testAccDnsCAARecordSet_basic, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "caa.#", "1"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "caa.*", map[string]string{"flags": "0", "tag": "issue", "value": ";"}), + ), + }, + { + ProtoV5ProviderFactories: testProtoV5ProviderFactories, + Config: testAccDnsCAARecordSet_basic, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + }, + { + ExternalProviders: providerVersion324(), + Config: testAccDnsCAARecordSet_update, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "caa.#", "2"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "caa.*", map[string]string{"flags": "0", "tag": "issue", "value": "example.com;"}), + ), + }, + { + ProtoV5ProviderFactories: testProtoV5ProviderFactories, + Config: testAccDnsCAARecordSet_update, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + }, + { + ExternalProviders: providerVersion324(), + PreConfig: func() { testRemoveRecord(t, "CAA", "foo") }, + Config: testAccDnsCAARecordSet_update, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "caa.#", "2"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "caa.*", map[string]string{"flags": "0", "tag": "issue", "value": "example.com;"}), + ), + }, + { + ProtoV5ProviderFactories: testProtoV5ProviderFactories, + Config: testAccDnsCAARecordSet_update, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + }, + }, + }) +} + +func testAccCheckDnsCAARecordSetDestroy(s *terraform.State) error { + return testAccCheckDnsDestroy(s, "dns_caa_record_set", dns.TypeCAA) +} + +var testAccDnsCAARecordSet_basic = ` + resource "dns_caa_record_set" "foo" { + zone = "example.com." + name = "foo" + caa { + flags = 0 + tag = "issue" + value = ";" + } + ttl = 300 + }` + +var testAccDnsCAARecordSet_update = ` + resource "dns_caa_record_set" "foo" { + zone = "example.com." + name = "foo" + caa { + flags = 0 + tag = "issue" + value = "example.com;" + } + ttl = 300 + }` + +var testAccDNSCAARecordSet_root = ` + zone = "example.com." + caa { + flags = 0 + tag = "issue" + value = ";" + } + ttl = 300 + }`