diff --git a/docs/data-sources/gpg_key.md b/docs/data-sources/gpg_key.md new file mode 100644 index 0000000..92a14c3 --- /dev/null +++ b/docs/data-sources/gpg_key.md @@ -0,0 +1,56 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "forgejo_gpg_key Data Source - forgejo" +subcategory: "" +description: |- + Forgejo user GPG key data source. +--- + +# forgejo_gpg_key (Data Source) + +Forgejo user GPG key data source. + +## Example Usage + +```terraform +terraform { + required_providers { + forgejo = { + source = "svalabs/forgejo" + } + } +} + +provider "forgejo" { + host = "http://localhost:3000" +} + +# Existing GPG key +data "forgejo_gpg_key" "this" { + user = "test_user" # Optional, uses api key's user if not provided + key_id = "test_key" +} +``` + + +## Schema + +### Required + +- `key_id` (String) ID of the GPG key. + +### Optional + +- `user` (String) Name of the user. + +### Read-Only + +- `can_certify` (Boolean) Can this key certify. +- `can_encrypt_comms` (Boolean) Can this key encrypt communications. +- `can_encrypt_storage` (Boolean) Can this key encrypt storage. +- `can_sign` (Boolean) Can this key sign. +- `created_at` (String) Time at which the GPG key was created. +- `expires_at` (String) Time at which the GPG key expires. +- `id` (Number) Numeric identifier of the GPG key. +- `primary_key_id` (String) Primary ID of the GPG key. +- `public_key` (String) The public key. diff --git a/docs/resources/gpg_key.md b/docs/resources/gpg_key.md new file mode 100644 index 0000000..a754fc6 --- /dev/null +++ b/docs/resources/gpg_key.md @@ -0,0 +1,66 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "forgejo_gpg_key Resource - forgejo" +subcategory: "" +description: |- + Forgejo user GPG key resource. +--- + +# forgejo_gpg_key (Resource) + +Forgejo user GPG key resource. + +## Example Usage + +```terraform +terraform { + required_providers { + forgejo = { + source = "svalabs/forgejo" + } + gpg = { + source = "terraform-provider-gpg/gpg" + } + } +} + +variable "gpg_key_password" { sensitive = true } + +provider "forgejo" { + host = "http://localhost:3000" +} + +# GPG key +resource "gpg_key_pair" "test" { + identities = [{ + name = "Test User" + email = "test_user@localhost.localdomain" + }] + passphrase = var.gpg_key_password +} + +# Forgejo GPG key +resource "forgejo_gpg_key" "this" { + armored_public_key = gpg_key_pair.test.public_key +} +``` + + +## Schema + +### Required + +- `armored_public_key` (String) Armored GPG Public key. + +### Read-Only + +- `can_certify` (Boolean) Can this key certify. +- `can_encrypt_comms` (Boolean) Can this key encrypt communications. +- `can_encrypt_storage` (Boolean) Can this key encrypt storage. +- `can_sign` (Boolean) Can this key sign. +- `created_at` (String) Time at which the GPG key was created. +- `expires_at` (String) Time at which the GPG key expires. +- `id` (Number) Numeric identifier of the GPG key. +- `key_id` (String) ID of the GPG key. +- `primary_key_id` (String) Primary ID of the GPG key. +- `public_key` (String) The public key. diff --git a/examples/data-sources/forgejo_gpg_key/data-source.tf b/examples/data-sources/forgejo_gpg_key/data-source.tf new file mode 100644 index 0000000..bc70c49 --- /dev/null +++ b/examples/data-sources/forgejo_gpg_key/data-source.tf @@ -0,0 +1,17 @@ +terraform { + required_providers { + forgejo = { + source = "svalabs/forgejo" + } + } +} + +provider "forgejo" { + host = "http://localhost:3000" +} + +# Existing GPG key +data "forgejo_gpg_key" "this" { + user = "test_user" # Optional, uses api key's user if not provided + key_id = "test_key" +} diff --git a/examples/resources/forgejo_gpg_key/resource.tf b/examples/resources/forgejo_gpg_key/resource.tf new file mode 100644 index 0000000..ec8ed72 --- /dev/null +++ b/examples/resources/forgejo_gpg_key/resource.tf @@ -0,0 +1,30 @@ +terraform { + required_providers { + forgejo = { + source = "svalabs/forgejo" + } + gpg = { + source = "terraform-provider-gpg/gpg" + } + } +} + +variable "gpg_key_password" { sensitive = true } + +provider "forgejo" { + host = "http://localhost:3000" +} + +# GPG key +resource "gpg_key_pair" "test" { + identities = [{ + name = "Test User" + email = "test_user@localhost.localdomain" + }] + passphrase = var.gpg_key_password +} + +# Forgejo GPG key +resource "forgejo_gpg_key" "this" { + armored_public_key = gpg_key_pair.test.public_key +} diff --git a/go.mod b/go.mod index 921acb4..f824384 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( require ( github.com/42wim/httpsig v1.2.3 // indirect - github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/agext/levenshtein v1.2.2 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect diff --git a/go.sum b/go.sum index b94d839..157d3bf 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= -github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/agext/levenshtein v1.2.2 h1:0S/Yg6LYmFJ5stwQeRp6EeOcCbj7xiqQSdNelsXvaqE= github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec= diff --git a/internal/provider/gpg_key_data_source.go b/internal/provider/gpg_key_data_source.go new file mode 100644 index 0000000..bc5b750 --- /dev/null +++ b/internal/provider/gpg_key_data_source.go @@ -0,0 +1,224 @@ +package provider + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + + "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &gpgKeyDataSource{} + _ datasource.DataSourceWithConfigure = &gpgKeyDataSource{} +) + +// gpgKeyDataSource is the data source implementation. +type gpgKeyDataSource struct { + client *forgejo.Client +} + +// gpgKeyDataSourceModel maps the data source schema data. +// https://pkg.go.dev/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2#GPGKey +type gpgKeyDataSourceModel struct { + User types.String `tfsdk:"user"` + KeyID types.String `tfsdk:"key_id"` + ID types.Int64 `tfsdk:"id"` + PrimaryKeyID types.String `tfsdk:"primary_key_id"` + PublicKey types.String `tfsdk:"public_key"` + CanSign types.Bool `tfsdk:"can_sign"` + CanEncryptComms types.Bool `tfsdk:"can_encrypt_comms"` + CanEncryptStorage types.Bool `tfsdk:"can_encrypt_storage"` + CanCertify types.Bool `tfsdk:"can_certify"` + Created types.String `tfsdk:"created_at"` + Expires types.String `tfsdk:"expires_at"` +} + +// Metadata returns the data source type name. +func (d *gpgKeyDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_gpg_key" +} + +// Schema defines the schema for the data source. +func (d *gpgKeyDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Forgejo user GPG key data source.", + + Attributes: map[string]schema.Attribute{ + "key_id": schema.StringAttribute{ + Description: "ID of the GPG key.", + Required: true, + }, + "user": schema.StringAttribute{ + Description: "Name of the user.", + Optional: true, + }, + "id": schema.Int64Attribute{ + Description: "Numeric identifier of the GPG key.", + Computed: true, + }, + "primary_key_id": schema.StringAttribute{ + Description: "Primary ID of the GPG key.", + Computed: true, + }, + "public_key": schema.StringAttribute{ + Description: "The public key.", + Computed: true, + }, + "can_sign": schema.BoolAttribute{ + Description: "Can this key sign.", + Computed: true, + }, + "can_encrypt_comms": schema.BoolAttribute{ + Description: "Can this key encrypt communications.", + Computed: true, + }, + "can_encrypt_storage": schema.BoolAttribute{ + Description: "Can this key encrypt storage.", + Computed: true, + }, + "can_certify": schema.BoolAttribute{ + Description: "Can this key certify.", + Computed: true, + }, + "created_at": schema.StringAttribute{ + Description: "Time at which the GPG key was created.", + Computed: true, + }, + "expires_at": schema.StringAttribute{ + Description: "Time at which the GPG key expires.", + Computed: true, + }, + }, + } +} + +// Configure adds the provider configured client to the data source. +func (d *gpgKeyDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*forgejo.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf( + "Expected *forgejo.Client, got: %T. Please report this issue to the provider developers.", + req.ProviderData, + ), + ) + + return + } + + d.client = client +} + +// Read refreshes the Terraform state with the latest data. +func (d *gpgKeyDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + defer un(trace(ctx, "Read GPG key data source")) + + var data gpgKeyDataSourceModel + + // Read Terraform configuration data into model + diags := req.Config.Get(ctx, &data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + user := data.User.ValueString() + + tflog.Info(ctx, "List GPG keys", map[string]any{ + "user": user, + }) + + // Use Forgejo client to list GPG keys + var keys []*forgejo.GPGKey + var res *forgejo.Response + var err error + if user != "" { + keys, res, err = d.client.ListGPGKeys( + user, + forgejo.ListGPGKeysOptions{}, + ) + } else { + keys, res, err = d.client.ListMyGPGKeys( + &forgejo.ListGPGKeysOptions{}, + ) + } + if err != nil { + tflog.Error(ctx, "Error", map[string]any{ + "status": res.Status, + }) + + var msg string + switch res.StatusCode { + case 404: + // If the user was not provided, we should never get a 404, so the message here should always have a user. + msg = fmt.Sprintf( + `GPG keys for user "%s" not found: %s`, + user, + err, + ) + default: + msg = fmt.Sprintf("Unknown error: %s", err) + } + resp.Diagnostics.AddError("Unable to list GPG keys", msg) + + return + } + + // Search for GPG key with given title + idx := slices.IndexFunc(keys, func(k *forgejo.GPGKey) bool { + return strings.EqualFold(k.KeyID, data.KeyID.ValueString()) + }) + if idx == -1 { + var msg string + if user != "" { + msg = fmt.Sprintf( + `GPG key with user "%s" and key_id %s not found.`, + user, + data.KeyID.String(), + ) + } else { + msg = fmt.Sprintf( + "GPG key with key_id %s not found.", + data.KeyID.String(), + ) + } + resp.Diagnostics.AddError("Unable to get GPG key by key_id", msg) + + return + } + + // Map response body to model + data.ID = types.Int64Value(keys[idx].ID) + data.KeyID = types.StringValue(keys[idx].KeyID) + data.PrimaryKeyID = types.StringValue(keys[idx].PrimaryKeyID) + data.PublicKey = types.StringValue(keys[idx].PublicKey) + data.CanSign = types.BoolValue(keys[idx].CanSign) + data.CanEncryptComms = types.BoolValue(keys[idx].CanEncryptComms) + data.CanEncryptStorage = types.BoolValue(keys[idx].CanEncryptStorage) + data.CanCertify = types.BoolValue(keys[idx].CanCertify) + data.Created = types.StringValue(keys[idx].Created.String()) + data.Expires = types.StringValue(keys[idx].Expires.String()) + + // Save data into Terraform state + diags = resp.State.Set(ctx, &data) + resp.Diagnostics.Append(diags...) +} + +// NewGPGKeyDataSource is a helper function to simplify the provider implementation. +func NewGPGKeyDataSource() datasource.DataSource { + return &gpgKeyDataSource{} +} diff --git a/internal/provider/gpg_key_data_source_test.go b/internal/provider/gpg_key_data_source_test.go new file mode 100644 index 0000000..ff514a3 --- /dev/null +++ b/internal/provider/gpg_key_data_source_test.go @@ -0,0 +1,115 @@ +package provider_test + +import ( + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func TestAccGPGKeyDataSource(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + ExternalProviders: map[string]resource.ExternalProvider{ + "gpg": { + Source: "terraform-provider-gpg/gpg", + }, + }, + Steps: []resource.TestStep{ + // Read testing (non-existent resource) + { + Config: providerConfig + ` +data "forgejo_gpg_key" "test" { + key_id = "non_existent" +}`, + ExpectError: regexp.MustCompile(`GPG key with key_id "non_existent" not found`), + }, + // Read testing (valid user, non-existent resource) + { + Config: providerConfig + ` +data "forgejo_gpg_key" "test" { + user = "tfadmin" + key_id = "non_existent" +}`, + ExpectError: regexp.MustCompile(`GPG key with user "tfadmin" and key_id "non_existent" not found`), + }, + // Read testing (non-existent user) + { + Config: providerConfig + ` +data "forgejo_gpg_key" "test" { + user = "invalid" + key_id = "non_existent" +}`, + ExpectError: regexp.MustCompile(`GPG keys for user "invalid" not found`), + }, + // Read testing (current user) + { + Config: providerConfig + fmt.Sprintf(` +resource "gpg_key_pair" "test" { + identities = [{ + name = "TF Admin" + email = "%s" + }] + passphrase = "supersecret" +} +resource "forgejo_gpg_key" "test" { + armored_public_key = gpg_key_pair.test.public_key +} +data "forgejo_gpg_key" "test" { + key_id = gpg_key_pair.test.id + + depends_on = [forgejo_gpg_key.test] +}`, forgejoEmail), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("key_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("primary_key_id"), knownvalue.StringExact("")), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("public_key"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("can_sign"), knownvalue.Bool(true)), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("can_encrypt_comms"), knownvalue.Bool(false)), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("can_encrypt_storage"), knownvalue.Bool(false)), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("can_certify"), knownvalue.Bool(true)), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("created_at"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("expires_at"), knownvalue.NotNull()), + }, + }, + // Read testing (explicit user) + { + Config: providerConfig + fmt.Sprintf(` +resource "gpg_key_pair" "test" { + identities = [{ + name = "TF Admin" + email = "%s" + }] + passphrase = "supersecret" +} +resource "forgejo_gpg_key" "test" { + armored_public_key = gpg_key_pair.test.public_key +} +data "forgejo_gpg_key" "test" { + user = "tfadmin" + key_id = gpg_key_pair.test.id + + depends_on = [forgejo_gpg_key.test] +}`, forgejoEmail), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("key_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("primary_key_id"), knownvalue.StringExact("")), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("public_key"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("can_sign"), knownvalue.Bool(true)), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("can_encrypt_comms"), knownvalue.Bool(false)), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("can_encrypt_storage"), knownvalue.Bool(false)), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("can_certify"), knownvalue.Bool(true)), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("created_at"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("expires_at"), knownvalue.NotNull()), + }, + }, + }, + }) +} diff --git a/internal/provider/gpg_key_resource.go b/internal/provider/gpg_key_resource.go new file mode 100644 index 0000000..ccb491a --- /dev/null +++ b/internal/provider/gpg_key_resource.go @@ -0,0 +1,356 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "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/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + + "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &gpgKeyResource{} + _ resource.ResourceWithConfigure = &gpgKeyResource{} +) + +// gpgKeyResource is the resource implementation. +type gpgKeyResource struct { + client *forgejo.Client +} + +// gpgKeyResourceModel maps the resource schema data. +// https://pkg.go.dev/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2#GPGKey +type gpgKeyResourceModel struct { + ArmoredPublicKey types.String `tfsdk:"armored_public_key"` + ID types.Int64 `tfsdk:"id"` + KeyID types.String `tfsdk:"key_id"` + PrimaryKeyID types.String `tfsdk:"primary_key_id"` + PublicKey types.String `tfsdk:"public_key"` + CanSign types.Bool `tfsdk:"can_sign"` + CanEncryptComms types.Bool `tfsdk:"can_encrypt_comms"` + CanEncryptStorage types.Bool `tfsdk:"can_encrypt_storage"` + CanCertify types.Bool `tfsdk:"can_certify"` + Created types.String `tfsdk:"created_at"` + Expires types.String `tfsdk:"expires_at"` +} + +// from is a helper function to load an API struct into Terraform data model. +func (m *gpgKeyResourceModel) from(k *forgejo.GPGKey) { + m.ID = types.Int64Value(k.ID) + m.KeyID = types.StringValue(k.KeyID) + m.PrimaryKeyID = types.StringValue(k.PrimaryKeyID) + m.PublicKey = types.StringValue(k.PublicKey) + m.CanSign = types.BoolValue(k.CanSign) + m.CanEncryptComms = types.BoolValue(k.CanEncryptComms) + m.CanEncryptStorage = types.BoolValue(k.CanEncryptStorage) + m.CanCertify = types.BoolValue(k.CanCertify) + m.Created = types.StringValue(k.Created.String()) + m.Expires = types.StringValue(k.Expires.String()) +} + +// to is a helper function to save Terraform data model into an API struct. +func (m *gpgKeyResourceModel) to(o *forgejo.CreateGPGKeyOption) { + if o == nil { + o = new(forgejo.CreateGPGKeyOption) + } + + o.ArmoredKey = m.ArmoredPublicKey.ValueString() +} + +// Metadata returns the resource type name. +func (r *gpgKeyResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_gpg_key" +} + +// Schema defines the schema for the resource. +func (r *gpgKeyResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Forgejo user GPG key resource.", + + Attributes: map[string]schema.Attribute{ + "armored_public_key": schema.StringAttribute{ + Description: "Armored GPG Public key.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "id": schema.Int64Attribute{ + Description: "Numeric identifier of the GPG key.", + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "key_id": schema.StringAttribute{ + Description: "ID of the GPG key.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "primary_key_id": schema.StringAttribute{ + Description: "Primary ID of the GPG key.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "public_key": schema.StringAttribute{ + Description: "The public key.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "can_sign": schema.BoolAttribute{ + Description: "Can this key sign.", + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "can_encrypt_comms": schema.BoolAttribute{ + Description: "Can this key encrypt communications.", + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "can_encrypt_storage": schema.BoolAttribute{ + Description: "Can this key encrypt storage.", + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "can_certify": schema.BoolAttribute{ + Description: "Can this key certify.", + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "created_at": schema.StringAttribute{ + Description: "Time at which the GPG key was created.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "expires_at": schema.StringAttribute{ + Description: "Time at which the GPG key expires.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +// Configure adds the provider configured client to the resource. +func (r *gpgKeyResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*forgejo.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf( + "Expected *forgejo.Client, got: %T. Please report this issue to the provider developers.", + req.ProviderData, + ), + ) + + return + } + + r.client = client +} + +// Create creates the resource and sets the initial Terraform state. +func (r *gpgKeyResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + defer un(trace(ctx, "Create GPG key resource")) + + var data gpgKeyResourceModel + + // Read Terraform plan data into model + diags := req.Plan.Get(ctx, &data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Create GPG key", map[string]any{ + "armored_public_key": data.ArmoredPublicKey.ValueString(), + }) + + // Generate API request body from plan + opts := forgejo.CreateGPGKeyOption{} + data.to(&opts) + + // Use Forgejo client to create new GPG key + key, res, err := r.client.CreateGPGKey(opts) + if err != nil { + tflog.Error(ctx, "Error", map[string]any{ + "status": res.Status, + }) + + var msg string + switch res.StatusCode { + case 403: + msg = fmt.Sprintf( + "GPG key creation forbidden: %s", + err, + ) + case 404: + msg = fmt.Sprintf( + "GPG key creation not found: %s", + err, + ) + case 422: + msg = fmt.Sprintf("Input validation error: %s", err) + default: + msg = fmt.Sprintf("Unknown error: %s", err) + } + resp.Diagnostics.AddError("Unable to create GPG key", msg) + + return + } + + // Map response body to model + data.from(key) + + // Save data into Terraform state + diags = resp.State.Set(ctx, &data) + resp.Diagnostics.Append(diags...) +} + +// Read refreshes the Terraform state with the latest data. +func (r *gpgKeyResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + defer un(trace(ctx, "Read GPG key resource")) + + var data gpgKeyResourceModel + + // Read Terraform prior state data into the model + diags := req.State.Get(ctx, &data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Get GPG key by id", map[string]any{ + "key_id": data.KeyID.ValueString(), + }) + + // Use Forgejo client to get GPG key + key, res, err := r.client.GetGPGKey(data.ID.ValueInt64()) + if err != nil { + tflog.Error(ctx, "Error", map[string]any{ + "status": res.Status, + }) + + var msg string + switch res.StatusCode { + case 403: + msg = fmt.Sprintf( + "GPG key with id %d forbidden: %s", + data.ID.ValueInt64(), + err, + ) + case 404: + msg = fmt.Sprintf( + "GPG key with id %d not found: %s", + data.ID.ValueInt64(), + err, + ) + default: + msg = fmt.Sprintf("Unknown error: %s", err) + } + resp.Diagnostics.AddError("Unable to get GPG key by id", msg) + + return + } + + // Map response body to model + data.from(key) + + // Save data into Terraform state + diags = resp.State.Set(ctx, &data) + resp.Diagnostics.Append(diags...) +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *gpgKeyResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + defer un(trace(ctx, "Update GPG key resource")) + + /* + * GPG keys can not be updated in-place. All writable attributes have + * 'RequiresReplace' plan modifier set. + */ +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *gpgKeyResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + defer un(trace(ctx, "Delete GPG key resource")) + + var data gpgKeyResourceModel + + // Read Terraform prior state data into the model + diags := req.State.Get(ctx, &data) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Delete GPG key", map[string]any{ + "key_id": data.KeyID.ValueString(), + }) + + // Use Forgejo client to delete existing GPG key + res, err := r.client.DeleteGPGKey(data.ID.ValueInt64()) + if err != nil { + tflog.Error(ctx, "Error", map[string]any{ + "status": res.Status, + }) + + var msg string + switch res.StatusCode { + case 403: + msg = fmt.Sprintf( + "GPG key with id %d forbidden: %s", + data.ID.ValueInt64(), + err, + ) + case 404: + msg = fmt.Sprintf( + "GPG key with id %d not found: %s", + data.ID.ValueInt64(), + err, + ) + default: + msg = fmt.Sprintf("Unknown error: %s", err) + } + resp.Diagnostics.AddError("Unable to delete GPG key", msg) + + return + } +} + +// NewGPGKeyResource is a helper function to simplify the provider implementation. +func NewGPGKeyResource() resource.Resource { + return &gpgKeyResource{} +} diff --git a/internal/provider/gpg_key_resource_test.go b/internal/provider/gpg_key_resource_test.go new file mode 100644 index 0000000..713ad03 --- /dev/null +++ b/internal/provider/gpg_key_resource_test.go @@ -0,0 +1,94 @@ +package provider_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +const forgejoEmail = "tfadmin@localhost" + +func TestAccGPGKeyResource(t *testing.T) { + validateIsArmoredGPGKey := func(value string) error { + lines := strings.Split(value, "\n") + if lines[0] != "-----BEGIN PGP PUBLIC KEY BLOCK-----" { + return fmt.Errorf(`expected "%s" to start with "-----BEGIN PGP PUBLIC KEY BLOCK-----"`, value) + } + if lines[len(lines)-1] != "-----END PGP PUBLIC KEY BLOCK-----" { + return fmt.Errorf(`expected "%s" to end with "-----END PGP PUBLIC KEY BLOCK-----"`, value) + } + return nil + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + ExternalProviders: map[string]resource.ExternalProvider{ + "gpg": { + Source: "terraform-provider-gpg/gpg", + }, + }, + Steps: []resource.TestStep{ + // Create and Read testing + { + Config: providerConfig + fmt.Sprintf(` +resource "gpg_key_pair" "test" { + identities = [{ + name = "TF Admin" + email = "%s" + }] + passphrase = "supersecret" +} +resource "forgejo_gpg_key" "test" { + armored_public_key = gpg_key_pair.test.public_key +}`, forgejoEmail), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("armored_public_key"), knownvalue.StringFunc(validateIsArmoredGPGKey)), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("key_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("primary_key_id"), knownvalue.StringExact("")), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("public_key"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("can_sign"), knownvalue.Bool(true)), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("can_encrypt_comms"), knownvalue.Bool(false)), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("can_encrypt_storage"), knownvalue.Bool(false)), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("can_certify"), knownvalue.Bool(true)), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("created_at"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("expires_at"), knownvalue.NotNull()), + }, + }, + // Recreate and Read testing + { + Config: providerConfig + fmt.Sprintf(` +resource "gpg_key_pair" "test" { + identities = [{ + name = "TF Admin" + email = "%s" + }] + passphrase = "supersecret" +} +resource "forgejo_gpg_key" "test" { + armored_public_key = gpg_key_pair.test.public_key +}`, forgejoEmail), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("armored_public_key"), knownvalue.StringFunc(validateIsArmoredGPGKey)), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("key_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("primary_key_id"), knownvalue.StringExact("")), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("public_key"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("can_sign"), knownvalue.Bool(true)), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("can_encrypt_comms"), knownvalue.Bool(false)), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("can_encrypt_storage"), knownvalue.Bool(false)), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("can_certify"), knownvalue.Bool(true)), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("created_at"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("forgejo_gpg_key.test", tfjsonpath.New("expires_at"), knownvalue.NotNull()), + }, + }, + // Delete testing automatically occurs in TestCase + }, + }) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 14dc8d3..89fb654 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -241,6 +241,7 @@ func (p *forgejoProvider) DataSources(_ context.Context) []func() datasource.Dat return []func() datasource.DataSource{ NewCollaboratorDataSource, NewDeployKeyDataSource, + NewGPGKeyDataSource, NewOrganizationDataSource, NewRepositoryDataSource, NewSSHKeyDataSource, @@ -253,6 +254,7 @@ func (p *forgejoProvider) Resources(_ context.Context) []func() resource.Resourc return []func() resource.Resource{ NewCollaboratorResource, NewDeployKeyResource, + NewGPGKeyResource, NewOrganizationActionSecretResource, NewOrganizationResource, NewRepositoryActionSecretResource,