From f61d69c778b800ffc1eab97397e7c299a6cf29e3 Mon Sep 17 00:00:00 2001 From: Givi Khojanashvili Date: Thu, 28 Nov 2024 17:05:51 +0400 Subject: [PATCH] feat: sys-444 add credential resource support --- docs/resources/credential.md | 44 +++ go.mod | 2 +- go.sum | 8 +- internal/provider/provider.go | 2 +- internal/provider/resource_credentials.go | 311 ++++++++++++++++++ .../provider/resource_credentials_test.go | 84 +++++ .../resource_credential/_basic/main.tf | 21 ++ .../resource_credential/validation/main.tf | 28 ++ 8 files changed, 492 insertions(+), 8 deletions(-) create mode 100644 docs/resources/credential.md create mode 100644 internal/provider/resource_credentials.go create mode 100644 internal/provider/resource_credentials_test.go create mode 100644 internal/provider/testdata/resource_credential/_basic/main.tf create mode 100644 internal/provider/testdata/resource_credential/validation/main.tf diff --git a/docs/resources/credential.md b/docs/resources/credential.md new file mode 100644 index 0000000..58348be --- /dev/null +++ b/docs/resources/credential.md @@ -0,0 +1,44 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "uptime_credential Resource - terraform-provider-uptime" +subcategory: "" +description: |- + +--- + +# uptime_credential (Resource) + + + + + + +## Schema + +### Required + +- `credential_type` (String) +- `display_name` (String) +- `secret` (Attributes) (see [below for nested schema](#nestedatt--secret)) + +### Optional + +- `description` (String) +- `username` (String) + +### Read-Only + +- `id` (Number) The ID of this resource. + + +### Nested Schema for `secret` + +Optional: + +- `certificate` (String, Sensitive) +- `key` (String, Sensitive) +- `passphrase` (String, Sensitive) +- `password` (String, Sensitive) +- `secret` (String, Sensitive) + + diff --git a/go.mod b/go.mod index 5775b0d..5fd76fa 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/shopspring/decimal v1.4.0 github.com/stretchr/testify v1.9.0 - github.com/uptime-com/uptime-client-go/v2 v2.0.0-20241125121723-7c0ae58b90f0 + github.com/uptime-com/uptime-client-go/v2 v2.0.0-20241203124913-62cfc1744b9a ) require ( diff --git a/go.sum b/go.sum index f65b489..45fa1ba 100644 --- a/go.sum +++ b/go.sum @@ -157,12 +157,8 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/uptime-com/uptime-client-go/v2 v2.0.0-20241003114205-5aa12ad3c97a h1:bMIbbcsD3mnLzVD0fxnJk1sW+fRiT/yFw9gW2Rz1rsw= -github.com/uptime-com/uptime-client-go/v2 v2.0.0-20241003114205-5aa12ad3c97a/go.mod h1:jtWeB/tQ00fLX2r9OwKfTnxQ/PMR0YjmhTuc9RZH2h0= -github.com/uptime-com/uptime-client-go/v2 v2.0.0-20241121151606-4174b6056aa2 h1:qPVLJNbG1+BshnIsykJGzAxYfQaBuPe3MFi7h5yNDbg= -github.com/uptime-com/uptime-client-go/v2 v2.0.0-20241121151606-4174b6056aa2/go.mod h1:jtWeB/tQ00fLX2r9OwKfTnxQ/PMR0YjmhTuc9RZH2h0= -github.com/uptime-com/uptime-client-go/v2 v2.0.0-20241125121723-7c0ae58b90f0 h1:NorS7ES2ekYYZQjSpePzYj/QrTen/P7Cc1E1rGsnBQ0= -github.com/uptime-com/uptime-client-go/v2 v2.0.0-20241125121723-7c0ae58b90f0/go.mod h1:jtWeB/tQ00fLX2r9OwKfTnxQ/PMR0YjmhTuc9RZH2h0= +github.com/uptime-com/uptime-client-go/v2 v2.0.0-20241203124913-62cfc1744b9a h1:SUXIUdqiAvW42db1yTE+aIuYAdVyMuv7o3VcsCoInsM= +github.com/uptime-com/uptime-client-go/v2 v2.0.0-20241203124913-62cfc1744b9a/go.mod h1:jtWeB/tQ00fLX2r9OwKfTnxQ/PMR0YjmhTuc9RZH2h0= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= diff --git a/internal/provider/provider.go b/internal/provider/provider.go index e572ef3..f5dc79a 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -138,8 +138,8 @@ func (p *providerImpl) Resources(ctx context.Context) []func() resource.Resource func() resource.Resource { return NewCheckWHOISResource(ctx, p) }, func() resource.Resource { return NewCheckWebhookResource(ctx, p) }, func() resource.Resource { return NewCheckMaintenanceResource(ctx, p) }, - func() resource.Resource { return NewContactResource(ctx, p) }, + func() resource.Resource { return NewCredentialResource(ctx, p) }, func() resource.Resource { return NewStatusPageResource(ctx, p) }, func() resource.Resource { return NewStatusPageComponentResource(ctx, p) }, func() resource.Resource { return NewStatusPageIncidentResource(ctx, p) }, diff --git a/internal/provider/resource_credentials.go b/internal/provider/resource_credentials.go new file mode 100644 index 0000000..54fcd23 --- /dev/null +++ b/internal/provider/resource_credentials.go @@ -0,0 +1,311 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "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/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/uptime-com/uptime-client-go/v2/pkg/upapi" +) + +func NewCredentialResource(_ context.Context, p *providerImpl) resource.Resource { + return APIResource[CredentialResourceModel, upapi.Credential, upapi.Credential]{ + api: CredentialResourceAPI{provider: p}, + mod: CredentialResourceModelAdapter{}, + meta: APIResourceMetadata{ + TypeNameSuffix: "credential", + Schema: schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": IDSchemaAttribute(), + "display_name": schema.StringAttribute{ + Required: true, + }, + "description": schema.StringAttribute{ + Computed: true, + Optional: true, + }, + "credential_type": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + OneOfStringValidator([]string{"BASIC", "CERTIFICATE", "TOKEN"}), + }, + }, + "username": schema.StringAttribute{ + Computed: true, + Optional: true, + }, + "secret": schema.SingleNestedAttribute{ + Required: true, + Attributes: map[string]schema.Attribute{ + "certificate": schema.StringAttribute{ + Computed: true, + Optional: true, + Sensitive: true, + }, + "key": schema.StringAttribute{ + Computed: true, + Optional: true, + Sensitive: true, + }, + "password": schema.StringAttribute{ + Computed: true, + Optional: true, + Sensitive: true, + }, + "passphrase": schema.StringAttribute{ + Computed: true, + Optional: true, + Sensitive: true, + }, + "secret": schema.StringAttribute{ + Computed: true, + Optional: true, + Sensitive: true, + }, + }, + }, + }, + }, + ConfigValidators: func(context.Context) []resource.ConfigValidator { + return []resource.ConfigValidator{NewCredentialTypeValidator()} + }, + }, + } +} + +type CredentialResourceModel struct { + ID types.Int64 `tfsdk:"id" ref:"PK,opt"` + DisplayName types.String `tfsdk:"display_name"` + Description types.String `tfsdk:"description"` + CredentialType types.String `tfsdk:"credential_type"` + Username types.String `tfsdk:"username"` + Secret types.Object `tfsdk:"secret"` + + secret *CredentialSecretAttribute +} + +func (m CredentialResourceModel) PrimaryKey() upapi.PrimaryKey { + return upapi.PrimaryKey(m.ID.ValueInt64()) +} + +type CredentialSecretAttribute struct { + Certificate types.String `tfsdk:"certificate"` + Key types.String `tfsdk:"key"` + Password types.String `tfsdk:"password"` + Passphrase types.String `tfsdk:"passphrase"` + Secret types.String `tfsdk:"secret"` +} + +type CredentialResourceModelAdapter struct { + SetAttributeAdapter[string] +} + +func (a CredentialResourceModelAdapter) Get(ctx context.Context, sg StateGetter) (*CredentialResourceModel, diag.Diagnostics) { + model := *new(CredentialResourceModel) + diags := sg.Get(ctx, &model) + if diags.HasError() { + return nil, diags + } + model.secret, diags = a.SecretAttributeContext(ctx, model.Secret) + if diags.HasError() { + return nil, diags + } + + return &model, nil +} + +func (a CredentialResourceModelAdapter) ToAPIArgument(model CredentialResourceModel) (*upapi.Credential, error) { + api := upapi.Credential{ + PK: model.ID.ValueInt64(), + DisplayName: model.DisplayName.ValueString(), + Description: model.Description.ValueString(), + CredentialType: model.CredentialType.ValueString(), + Username: model.Username.ValueString(), + Secret: upapi.CredentialSecret{ + Certificate: model.secret.Certificate.ValueString(), + Key: model.secret.Key.ValueString(), + Password: model.secret.Password.ValueString(), + Passphrase: model.secret.Passphrase.ValueString(), + Secret: model.secret.Secret.ValueString(), + }, + } + return &api, nil +} + +func (a CredentialResourceModelAdapter) FromAPIResult(api upapi.Credential) (*CredentialResourceModel, error) { + model := CredentialResourceModel{ + ID: types.Int64Value(api.PK), + DisplayName: types.StringValue(api.DisplayName), + Description: types.StringValue(api.Description), + CredentialType: types.StringValue(api.CredentialType), + Username: types.StringValue(api.Username), + Secret: a.SecretAttributeValue(CredentialSecretAttribute{ + Certificate: types.StringValue(api.Secret.Certificate), + Key: types.StringValue(api.Secret.Key), + Passphrase: types.StringValue(api.Secret.Passphrase), + Password: types.StringValue(api.Secret.Password), + Secret: types.StringValue(api.Secret.Secret), + }), + } + return &model, nil +} + +func (a CredentialResourceModelAdapter) secretAttributeTypes() map[string]attr.Type { + return map[string]attr.Type{ + "certificate": types.StringType, + "key": types.StringType, + "password": types.StringType, + "passphrase": types.StringType, + "secret": types.StringType, + } +} + +func (a CredentialResourceModelAdapter) secretAttributeValues(m CredentialSecretAttribute) map[string]attr.Value { + return map[string]attr.Value{ + "certificate": m.Certificate, + "key": m.Key, + "password": m.Password, + "passphrase": m.Passphrase, + "secret": m.Secret, + } +} + +func (a CredentialResourceModelAdapter) SecretAttributeContext(ctx context.Context, v types.Object) (*CredentialSecretAttribute, diag.Diagnostics) { + if v.IsNull() || v.IsUnknown() { + return nil, nil + } + m := new(CredentialSecretAttribute) + diags := v.As(ctx, m, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, diags + } + return m, nil +} + +func (a CredentialResourceModelAdapter) SecretAttributeValue(m CredentialSecretAttribute) types.Object { + return types.ObjectValueMust(a.secretAttributeTypes(), a.secretAttributeValues(m)) +} + +type CredentialResourceAPI struct { + provider *providerImpl +} + +func (c CredentialResourceAPI) Create(ctx context.Context, arg upapi.Credential) (*upapi.Credential, error) { + obj, err := c.provider.api.Credentials().Create(ctx, arg) + obj.Secret = arg.Secret + return obj, err +} + +func (c CredentialResourceAPI) Read(ctx context.Context, pk upapi.PrimaryKeyable) (*upapi.Credential, error) { + obj, err := c.provider.api.Credentials().Get(ctx, pk) + secret := pk.(CredentialResourceModel).secret + obj.Secret = upapi.CredentialSecret{ + Certificate: secret.Certificate.ValueString(), + Key: secret.Key.ValueString(), + Passphrase: secret.Passphrase.ValueString(), + Password: secret.Password.ValueString(), + Secret: secret.Secret.ValueString(), + } + return obj, err +} + +func (c CredentialResourceAPI) Update(ctx context.Context, pk upapi.PrimaryKeyable, arg upapi.Credential) (*upapi.Credential, error) { + if err := c.Delete(ctx, pk); err != nil { + return nil, err + } + return c.Create(ctx, arg) +} + +func (c CredentialResourceAPI) Delete(ctx context.Context, pk upapi.PrimaryKeyable) error { + return c.provider.api.Credentials().Delete(ctx, pk) +} + +type credentialTypeValidator struct{} + +func NewCredentialTypeValidator() resource.ConfigValidator { + return &credentialTypeValidator{} +} + +func (v *credentialTypeValidator) Description(ctx context.Context) string { + return "Validates that the credential_type field has valid values and corresponding secret fields are set correctly." +} + +func (v *credentialTypeValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v *credentialTypeValidator) ValidateResource(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + var model CredentialResourceModel + diags := req.Config.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if model.CredentialType.IsNull() || model.CredentialType.IsUnknown() { + return + } + + // var a attr.Value + var secretAttr CredentialSecretAttribute + p := path.Root("secret") + diags = req.Config.GetAttribute(ctx, p, &secretAttr) + if diags.HasError() { + resp.Diagnostics = diags + return + } + + switch model.CredentialType.ValueString() { + case "BASIC": + if secretAttr.Password.IsNull() { + resp.Diagnostics.AddError( + "Invalid Configuration", + "When credential_type is BASIC, the password field must be set.", + ) + } + if !secretAttr.Certificate.IsNull() || !secretAttr.Key.IsNull() || !secretAttr.Passphrase.IsNull() || !secretAttr.Secret.IsNull() { + resp.Diagnostics.AddError( + "Invalid Configuration", + "When credential_type is BASIC, only the password field should be set.", + ) + } + case "CERTIFICATE": + if secretAttr.Certificate.IsNull() || secretAttr.Key.IsNull() || secretAttr.Passphrase.IsNull() { + resp.Diagnostics.AddError( + "Invalid Configuration", + "When credential_type is CERTIFICATE, the certificate, key, and passphrase fields must be set.", + ) + } + if !secretAttr.Password.IsNull() || !secretAttr.Secret.IsNull() { + resp.Diagnostics.AddError( + "Invalid Configuration", + "When credential_type is CERTIFICATE, only the certificate, key, and passphrase fields should be set.", + ) + } + case "TOKEN": + if model.Secret.IsNull() { + resp.Diagnostics.AddError( + "Invalid Configuration", + "When credential_type is TOKEN, the secret field must be set.", + ) + } + if !secretAttr.Password.IsNull() || !secretAttr.Certificate.IsNull() || !secretAttr.Key.IsNull() || !secretAttr.Passphrase.IsNull() { + resp.Diagnostics.AddError( + "Invalid Configuration", + "When credential_type is TOKEN, only the secret field should be set.", + ) + } + default: + resp.Diagnostics.AddError( + "Invalid Configuration", + fmt.Sprintf("Invalid credential_type: %s", model.CredentialType.ValueString()), + ) + } +} diff --git a/internal/provider/resource_credentials_test.go b/internal/provider/resource_credentials_test.go new file mode 100644 index 0000000..384ae85 --- /dev/null +++ b/internal/provider/resource_credentials_test.go @@ -0,0 +1,84 @@ +package provider + +import ( + "regexp" + "testing" + + petname "github.com/dustinkirkland/golang-petname" + "github.com/hashicorp/terraform-plugin-testing/config" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccCredentialResource(t *testing.T) { + names := [3]string{ + petname.Generate(3, "-"), + petname.Generate(3, "-"), + } + passwords := [3]string{ + petname.Generate(1, "-"), + petname.Generate(1, "-"), + } + resource.Test(t, testCaseFromSteps(t, []resource.TestStep{ + { + ConfigDirectory: config.StaticDirectory("testdata/resource_credential/_basic"), + ConfigVariables: config.Variables{ + "display_name": config.StringVariable(names[0]), + "credential_type": config.StringVariable("BASIC"), + "password": config.StringVariable(passwords[0]), + }, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("uptime_credential.test", "display_name", names[0]), + resource.TestCheckResourceAttr("uptime_credential.test", "credential_type", "BASIC"), + resource.TestCheckResourceAttr("uptime_credential.test", "secret.password", passwords[0]), + ), + }, + { + ConfigDirectory: config.StaticDirectory("testdata/resource_credential/_basic"), + ConfigVariables: config.Variables{ + "display_name": config.StringVariable(names[1]), + "credential_type": config.StringVariable("BASIC"), + "password": config.StringVariable(passwords[1]), + }, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("uptime_credential.test", "display_name", names[1]), + resource.TestCheckResourceAttr("uptime_credential.test", "credential_type", "BASIC"), + resource.TestCheckResourceAttr("uptime_credential.test", "secret.password", passwords[1]), + ), + }, + })) +} + +func TestAccCredentialResource_Validation(t *testing.T) { + names := [3]string{ + petname.Generate(3, "-"), + petname.Generate(3, "-"), + } + passwords := [3]string{ + petname.Generate(1, "-"), + petname.Generate(1, "-"), + } + resource.Test(t, testCaseFromSteps(t, []resource.TestStep{ + { + ConfigDirectory: config.StaticDirectory("testdata/resource_credential/validation"), + ConfigVariables: config.Variables{ + "display_name": config.StringVariable(names[0]), + "credential_type": config.StringVariable("BASIC"), + "password": config.StringVariable(passwords[0]), + }, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("uptime_credential.test", "display_name", names[0]), + resource.TestCheckResourceAttr("uptime_credential.test", "credential_type", "BASIC"), + resource.TestCheckResourceAttr("uptime_credential.test", "secret.password", passwords[0]), + ), + }, + { + ConfigDirectory: config.StaticDirectory("testdata/resource_credential/validation"), + ConfigVariables: config.Variables{ + "display_name": config.StringVariable(names[2]), + "credential_type": config.StringVariable("TOKEN"), + "token": config.StringVariable(passwords[2]), + }, + ExpectError: regexp.MustCompile("When credential_type is TOKEN, only the secret field should be set."), + }, + })) +} diff --git a/internal/provider/testdata/resource_credential/_basic/main.tf b/internal/provider/testdata/resource_credential/_basic/main.tf new file mode 100644 index 0000000..b2303cd --- /dev/null +++ b/internal/provider/testdata/resource_credential/_basic/main.tf @@ -0,0 +1,21 @@ +variable display_name { + type = string +} + +variable credential_type { + type = string +} + +variable password { + type = string + default = "" + sensitive = true +} + +resource uptime_credential test { + display_name = var.display_name + credential_type = var.credential_type + secret = { + password = var.password + } +} diff --git a/internal/provider/testdata/resource_credential/validation/main.tf b/internal/provider/testdata/resource_credential/validation/main.tf new file mode 100644 index 0000000..74c409f --- /dev/null +++ b/internal/provider/testdata/resource_credential/validation/main.tf @@ -0,0 +1,28 @@ +variable display_name { + type = string +} + +variable credential_type { + type = string +} + +variable password { + type = string + default = "" + sensitive = true +} + +variable token { + type = string + default = "" + sensitive = true +} + +resource uptime_credential test { + display_name = var.display_name + credential_type = var.credential_type + secret = { + password = var.password + token = var.token + } +}