diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 008e0100..0b574ee1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -113,6 +113,8 @@ jobs: VERCEL_TERRAFORM_TESTING_DOMAIN: "dgls.dev" VERCEL_TERRAFORM_TESTING_ADDITIONAL_USER: ${{ secrets.VERCEL_TERRAFORM_TESTING_ADDITIONAL_USER }} VERCEL_TERRAFORM_TESTING_EXISTING_INTEGRATION: ${{ secrets.VERCEL_TERRAFORM_TESTING_EXISTING_INTEGRATION }} + VERCEL_TERRAFORM_TESTING_CERTIFICATE: ${{ secrets.VERCEL_TERRAFORM_TESTING_CERTIFICATE }} + VERCEL_TERRAFORM_TESTING_CERTIFICATE_KEY: ${{ secrets.VERCEL_TERRAFORM_TESTING_CERTIFICATE_KEY }} run: | go test ./... diff --git a/client/custom_certificate.go b/client/custom_certificate.go new file mode 100644 index 00000000..0f40c871 --- /dev/null +++ b/client/custom_certificate.go @@ -0,0 +1,73 @@ +package client + +import ( + "context" + "fmt" +) + +type UploadCustomCertificateRequest struct { + TeamID string `json:"-"` + PrivateKey string `json:"key"` + Certificate string `json:"cert"` + CertificateAuthorityCertificate string `json:"ca"` +} + +type CertificateResponse struct { + ID string `json:"id"` +} + +func (c *Client) UploadCustomCertificate(ctx context.Context, request UploadCustomCertificateRequest) (cr CertificateResponse, err error) { + url := fmt.Sprintf("%s/v8/certs", c.baseURL) + if c.TeamID(request.TeamID) != "" { + url = fmt.Sprintf("%s?teamId=%s", url, c.TeamID(request.TeamID)) + } + + payload := string(mustMarshal(request)) + err = c.doRequest(clientRequest{ + ctx: ctx, + method: "PUT", + url: url, + body: payload, + }, &cr) + return cr, err +} + +type GetCustomCertificateRequest struct { + TeamID string `json:"-"` + ID string `json:"-"` +} + +func (c *Client) GetCustomCertificate(ctx context.Context, request GetCustomCertificateRequest) (cr CertificateResponse, err error) { + url := fmt.Sprintf("%s/v8/certs/%s", c.baseURL, request.ID) + if c.TeamID(request.TeamID) != "" { + url = fmt.Sprintf("%s?teamId=%s", url, c.TeamID(request.TeamID)) + } + + err = c.doRequest(clientRequest{ + ctx: ctx, + method: "GET", + url: url, + body: "", + }, &cr) + return cr, err +} + +type DeleteCustomCertificateRequest struct { + TeamID string `json:"-"` + ID string `json:"-"` +} + +func (c *Client) DeleteCustomCertificate(ctx context.Context, request DeleteCustomCertificateRequest) error { + url := fmt.Sprintf("%s/v8/certs/%s", c.baseURL, request.ID) + if c.TeamID(request.TeamID) != "" { + url = fmt.Sprintf("%s?teamId=%s", url, c.TeamID(request.TeamID)) + } + + err := c.doRequest(clientRequest{ + ctx: ctx, + method: "DELETE", + url: url, + body: "", + }, nil) + return err +} diff --git a/docs/resources/custom_certificate.md b/docs/resources/custom_certificate.md new file mode 100644 index 00000000..8eff66c3 --- /dev/null +++ b/docs/resources/custom_certificate.md @@ -0,0 +1,44 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "vercel_custom_certificate Resource - terraform-provider-vercel" +subcategory: "" +description: |- + Provides a Custom Certificate Resource, allowing Custom Certificates to be uploaded to Vercel. + By default, Vercel provides all domains with a custom SSL certificates. However, Enterprise teams can upload their own custom SSL certificate. + For more detailed information, please see the Vercel documentation https://vercel.com/docs/domains/custom-SSL-certificate. +--- + +# vercel_custom_certificate (Resource) + +Provides a Custom Certificate Resource, allowing Custom Certificates to be uploaded to Vercel. + +By default, Vercel provides all domains with a custom SSL certificates. However, Enterprise teams can upload their own custom SSL certificate. + +For more detailed information, please see the [Vercel documentation](https://vercel.com/docs/domains/custom-SSL-certificate). + +## Example Usage + +```terraform +resource "vercel_custom_certificate" "example" { + private_key = file("private.key") + certificate = file("certificate.crt") + certificate_authority_certificate = file("ca.crt") +} +``` + + +## Schema + +### Required + +- `certificate` (String) The certificate itself. Should be in PEM format. +- `certificate_authority_certificate` (String) The Certificate Authority root certificate such as one of Let's Encrypt's ISRG root certificates. This will be provided by your certificate issuer and is different to the core certificate. This may be included in their download process or available for download on their website. Should be in PEM format. +- `private_key` (String) The private key of the Certificate. Should be in PEM format. + +### Optional + +- `team_id` (String) The ID of the team the Custom Certificate should exist under. Required when configuring a team resource if a default team has not been set in the provider. + +### Read-Only + +- `id` (String) The ID of the Custom Certificate. diff --git a/examples/resources/vercel_custom_certificate/resource.tf b/examples/resources/vercel_custom_certificate/resource.tf new file mode 100644 index 00000000..22cb8727 --- /dev/null +++ b/examples/resources/vercel_custom_certificate/resource.tf @@ -0,0 +1,5 @@ +resource "vercel_custom_certificate" "example" { + private_key = file("private.key") + certificate = file("certificate.crt") + certificate_authority_certificate = file("ca.crt") +} diff --git a/vercel/provider.go b/vercel/provider.go index 2def273a..ac97d9a7 100644 --- a/vercel/provider.go +++ b/vercel/provider.go @@ -55,30 +55,31 @@ func (p *vercelProvider) Resources(_ context.Context) []func() resource.Resource newAccessGroupResource, newAliasResource, newAttackChallengeModeResource, + newCustomCertificateResource, newCustomEnvironmentResource, - newDNSRecordResource, newDeploymentResource, + newDNSRecordResource, newEdgeConfigItemResource, newEdgeConfigResource, newEdgeConfigSchemaResource, newEdgeConfigTokenResource, - newFirewallConfigResource, newFirewallBypassResource, + newFirewallConfigResource, newIntegrationProjectAccessResource, newLogDrainResource, + newMicrofrontendGroupMembershipResource, + newMicrofrontendGroupResource, newProjectDeploymentRetentionResource, newProjectDomainResource, newProjectEnvironmentVariableResource, newProjectEnvironmentVariablesResource, newProjectMembersResource, newProjectResource, - newSharedEnvironmentVariableResource, newSharedEnvironmentVariableProjectLinkResource, + newSharedEnvironmentVariableResource, newTeamConfigResource, newTeamMemberResource, newWebhookResource, - newMicrofrontendGroupResource, - newMicrofrontendGroupMembershipResource, } } diff --git a/vercel/resource_custom_certificate.go b/vercel/resource_custom_certificate.go new file mode 100644 index 00000000..a78e0e4e --- /dev/null +++ b/vercel/resource_custom_certificate.go @@ -0,0 +1,216 @@ +package vercel + +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/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/vercel/terraform-provider-vercel/v3/client" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &customCertificateResource{} + _ resource.ResourceWithConfigure = &customCertificateResource{} +) + +func newCustomCertificateResource() resource.Resource { + return &customCertificateResource{} +} + +type customCertificateResource struct { + client *client.Client +} + +func (r *customCertificateResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_custom_certificate" +} + +func (r *customCertificateResource) Configure(ctx 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.(*client.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + r.client = client +} + +func (r *customCertificateResource) Schema(_ context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: ` +Provides a Custom Certificate Resource, allowing Custom Certificates to be uploaded to Vercel. + +By default, Vercel provides all domains with a custom SSL certificates. However, Enterprise teams can upload their own custom SSL certificate. + +For more detailed information, please see the [Vercel documentation](https://vercel.com/docs/domains/custom-SSL-certificate). +`, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The ID of the Custom Certificate.", + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "team_id": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The ID of the team the Custom Certificate should exist under. Required when configuring a team resource if a default team has not been set in the provider.", + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplaceIfConfigured(), stringplanmodifier.UseStateForUnknown()}, + }, + "private_key": schema.StringAttribute{ + Description: "The private key of the Certificate. Should be in PEM format.", + Required: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "certificate": schema.StringAttribute{ + Description: "The certificate itself. Should be in PEM format.", + Required: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "certificate_authority_certificate": schema.StringAttribute{ + Description: "The Certificate Authority root certificate such as one of Let's Encrypt's ISRG root certificates. This will be provided by your certificate issuer and is different to the core certificate. This may be included in their download process or available for download on their website. Should be in PEM format.", + Required: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + }, + } +} + +type CustomCertificate struct { + ID types.String `tfsdk:"id"` + TeamID types.String `tfsdk:"team_id"` + PrivateKey types.String `tfsdk:"private_key"` + Certificate types.String `tfsdk:"certificate"` + CertificateAuthorityCertificate types.String `tfsdk:"certificate_authority_certificate"` +} + +func (r *customCertificateResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan CustomCertificate + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + out, err := r.client.UploadCustomCertificate(ctx, client.UploadCustomCertificateRequest{ + TeamID: plan.TeamID.ValueString(), + PrivateKey: plan.PrivateKey.ValueString(), + Certificate: plan.Certificate.ValueString(), + CertificateAuthorityCertificate: plan.CertificateAuthorityCertificate.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError( + "Error uploading Custom Certificate", + "Could not upload Custom Certificate, unexpected error: "+err.Error(), + ) + return + } + plan.ID = types.StringValue(out.ID) + plan.TeamID = types.StringValue(r.client.TeamID(plan.TeamID.ValueString())) + + tflog.Info(ctx, "uploaded custom certificate", map[string]any{ + "team_id": plan.TeamID.ValueString(), + "id": out.ID, + }) + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func (r *customCertificateResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state CustomCertificate + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // This is basically a check that it still exists, as the cert itself is immutable. + out, err := r.client.GetCustomCertificate(ctx, client.GetCustomCertificateRequest{ + ID: state.ID.ValueString(), + TeamID: state.TeamID.ValueString(), + }) + if client.NotFound(err) { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error reading Custom Certificate", + fmt.Sprintf("Could not get Custom Certificate %s %s, unexpected error: %s", + state.TeamID.ValueString(), + state.ID.ValueString(), + err, + ), + ) + return + } + + tflog.Info(ctx, "read certificate", map[string]any{ + "team_id": state.TeamID.ValueString(), + "id": out.ID, + }) + + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func (r *customCertificateResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + resp.Diagnostics.AddError( + "Updating a Custom Certificate is not supported", + "Updating a Custom Certificate is not supported", + ) +} + +func (r *customCertificateResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state CustomCertificate + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteCustomCertificate(ctx, client.DeleteCustomCertificateRequest{ + TeamID: state.TeamID.ValueString(), + ID: state.ID.ValueString(), + }) + if client.NotFound(err) { + return + } + if err != nil { + resp.Diagnostics.AddError( + "Error deleting Custom Certificate", + fmt.Sprintf( + "Could not delete Custom Certificate %s %s, unexpected error: %s", + state.TeamID.ValueString(), + state.ID.ValueString(), + err, + ), + ) + return + } + + tflog.Info(ctx, "deleted custom certificate", map[string]any{ + "team_id": state.TeamID.ValueString(), + "id": state.ID.ValueString(), + }) +} diff --git a/vercel/resource_custom_certificate_test.go b/vercel/resource_custom_certificate_test.go new file mode 100644 index 00000000..a6abc5ad --- /dev/null +++ b/vercel/resource_custom_certificate_test.go @@ -0,0 +1,79 @@ +package vercel_test + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/vercel/terraform-provider-vercel/v3/client" +) + +func testCheckCustomCertificateDoesNotExist(testClient *client.Client, teamID string, n string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("no ID is set") + } + + _, err := testClient.GetCustomCertificate(context.TODO(), client.GetCustomCertificateRequest{ + TeamID: teamID, + ID: rs.Primary.ID, + }) + if err == nil { + return fmt.Errorf("expected not_found error, but got no error") + } + if !client.NotFound(err) { + return fmt.Errorf("Unexpected error checking for deleted certificate: %s", err) + } + + return nil + } +} + +func testCert(t *testing.T) string { + v := os.Getenv("VERCEL_TERRAFORM_TESTING_CERTIFICATE") + if v == "" { + t.Fatalf("Missing required environment variable VERCEL_TERRAFORM_TESTING_CERTIFICATE") + } + return v +} + +func testCertKey(t *testing.T) string { + v := os.Getenv("VERCEL_TERRAFORM_TESTING_CERTIFICATE_KEY") + if v == "" { + t.Fatalf("Missing required environment variable VERCEL_TERRAFORM_TESTING_CERTIFICATE_KEY") + } + return v +} + +func TestAcc_CustomCertificateResource(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: testCheckCustomCertificateDoesNotExist(testClient(t), testTeam(t), "vercel_custom_certificate.test"), + Steps: []resource.TestStep{ + { + Config: cfg(fmt.Sprintf(` +resource "vercel_custom_certificate" "test" { + private_key = <