Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow custom domains to be associated with a deployment #19

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
19 changes: 16 additions & 3 deletions docs/resources/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ page_title: "deno_deployment Resource - terraform-provider-deno"
subcategory: ""
description: |-
A resource for a Deno Deploy deployment.
A deployment belongs to a project, is an immutable, invokable snapshot of the project's assets, can be assigned a custom domain.
A deployment belongs to a project, is an immutable, invokable snapshot of the project's assets, can be assigned custom domain(s).
---

# deno_deployment (Resource)

A resource for a Deno Deploy deployment.

A deployment belongs to a project, is an immutable, invokable snapshot of the project's assets, can be assigned a custom domain.
A deployment belongs to a project, is an immutable, invokable snapshot of the project's assets, can be assigned custom domain(s).

## Example Usage

Expand Down Expand Up @@ -49,7 +49,7 @@ data "deno_assets" "my_assets" {
assets_glob = "../**/*.{ts,tsx,json,ico,svg,css}"
}

resource "deno_deployment" "example1" {
resource "deno_deployment" "example" {
# Project ID that the created deployment belongs to.
project_id = deno_project.myproject.id
# File path for the deployments' entry point.
Expand All @@ -62,6 +62,18 @@ resource "deno_deployment" "example1" {
env_vars = {
FOO = "42"
}

###############################################
# Custom domain association
###############################################
#
# Custom domain(s) can be associated with the deployment (optional).
# Note the domains must be verified for their ownership and certificates must be ready.
# See the doc of deno_domain resource for the full example of the entire process of domain setup.

# `depends_on` may be useful to ensure the domain is ready.
depends_on = [deno_certificate_provisioning.example]
domain_ids = [deno_domain.example.id]
}
```

Expand All @@ -78,6 +90,7 @@ resource "deno_deployment" "example1" {
### Optional

- `compiler_options` (Attributes) Compiler options to be used when building the deployment. If this is omitted and a deno config file (`deno.json` or `deno.jsonc`) is found in the assets, the value in the config file will be used. (see [below for nested schema](#nestedatt--compiler_options))
- `domain_ids` (Set of String) The custom domain IDs to associate with the deployment. To associate, the domains must be verified for their ownership and their certificates must be ready. For further information, please refer to the doc of deno_domain resource.
- `import_map_url` (String) The path to the import map file. If this is omitted and a deno config file (`deno.json` or `deno.jsonc`) is found in the assets, the value in the config file will be used.
- `lock_file_url` (String) The path to the lock file. If this is omitted and a deno config file (`deno.json` or `deno.jsonc`) is found in the assets, the value in the config will be used.
- `timeouts` (Attributes) (see [below for nested schema](#nestedatt--timeouts))
Expand Down
14 changes: 13 additions & 1 deletion examples/resources/deno_deployment/resource.tf
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ data "deno_assets" "my_assets" {
assets_glob = "../**/*.{ts,tsx,json,ico,svg,css}"
}

resource "deno_deployment" "example1" {
resource "deno_deployment" "example" {
# Project ID that the created deployment belongs to.
project_id = deno_project.myproject.id
# File path for the deployments' entry point.
Expand All @@ -44,4 +44,16 @@ resource "deno_deployment" "example1" {
env_vars = {
FOO = "42"
}

###############################################
# Custom domain association
###############################################
#
# Custom domain(s) can be associated with the deployment (optional).
# Note the domains must be verified for their ownership and certificates must be ready.
# See the doc of deno_domain resource for the full example of the entire process of domain setup.

# `depends_on` may be useful to ensure the domain is ready.
depends_on = [deno_certificate_provisioning.example]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we don't do this, does it still work? I would suspect that the lack of verification for the domain would make it not work.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deployment <-> custom domain association API checks if the requested domain is owned by the requester; therefore, at least depends_on = [deno_domain_verification.example_domain_verification] is needed to get successful association. However, waiting on deno_certificate_provisioning is not a requirement for the association to succeed.
Here I put depends_on = [deno_certificate_provisioning.example] because deno_certificate_provisioning in turn depends on deno_domain_verification, so we don't need to explicitly specify the grandchild dependency. But this is a documentation, so I'm wondering what information should be mentioned here

domain_ids = [deno_domain.example.id]
}
222 changes: 154 additions & 68 deletions internal/provider/deployment_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ type deploymentResourceModel struct {
DeploymentID types.String `tfsdk:"deployment_id"`
ProjectID types.String `tfsdk:"project_id"`
Status types.String `tfsdk:"status"`
DomainIDs types.Set `tfsdk:"domain_ids"`
Domains types.Set `tfsdk:"domains"`
EntryPointURL types.String `tfsdk:"entry_point_url"`
ImportMapURL types.String `tfsdk:"import_map_url"`
Expand Down Expand Up @@ -77,7 +78,7 @@ func (r *deploymentResource) Schema(ctx context.Context, _ resource.SchemaReques
Description: `
A resource for a Deno Deploy deployment.

A deployment belongs to a project, is an immutable, invokable snapshot of the project's assets, can be assigned a custom domain.
A deployment belongs to a project, is an immutable, invokable snapshot of the project's assets, can be assigned custom domain(s).
`,
Attributes: map[string]schema.Attribute{
"deployment_id": schema.StringAttribute{
Expand All @@ -92,6 +93,11 @@ A deployment belongs to a project, is an immutable, invokable snapshot of the pr
Computed: true,
Description: `The status of the deployment, indicating whether the deployment succeeded or not. It can be "failed", "pending", or "success"`,
},
"domain_ids": schema.SetAttribute{
Optional: true,
ElementType: types.StringType,
Description: `The custom domain IDs to associate with the deployment. To associate, the domains must be verified for their ownership and their certificates must be ready. For further information, please refer to the doc of deno_domain resource.`,
},
"domains": schema.SetAttribute{
Computed: true,
ElementType: types.StringType,
Expand Down Expand Up @@ -359,34 +365,11 @@ func (r *deploymentResource) Read(ctx context.Context, req resource.ReadRequest,

deploymentID := state.DeploymentID.ValueString()

deployment, err := r.client.GetDeploymentWithResponse(ctx, deploymentID)
if err != nil {
resp.Diagnostics.AddError(
"Failed to Get Deployment Details",
fmt.Sprintf("Deployment ID: %s, Error: %s", deploymentID, err.Error()),
)
return
}
if client.RespIsError(deployment) {
resp.Diagnostics.AddError(
"Failed to Get Deployment Details",
fmt.Sprintf("Deployment ID: %s, Error: %s", deploymentID, client.APIErrorDetail(deployment.HTTPResponse, deployment.Body)),
)
return
}

state.Status = types.StringValue(string(deployment.JSON200.Status))
domainElems := make([]attr.Value, len(*deployment.JSON200.Domains))
for i, d := range *deployment.JSON200.Domains {
domainElems[i] = types.StringValue(d)
}
domainSet, diags := types.SetValue(basetypes.StringType{}, domainElems)
diags = r.updateModel(ctx, deploymentID, &state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
state.Domains = domainSet
state.UpdatedAt = types.StringValue(deployment.JSON200.UpdatedAt.Format(time.RFC3339))

// Set refreshed state
diags = resp.State.Set(ctx, &state)
Expand Down Expand Up @@ -425,7 +408,59 @@ func (r *deploymentResource) Update(ctx context.Context, req resource.UpdateRequ

// Delete deletes the resource and removes the Terraform state on success.
func (r *deploymentResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
// noop
// No opeation is needed for deployment since it is immutable,
// but we disassociate the domain (if any) from the deployment.

// Get current state
var state deploymentResourceModel
diags := req.State.Get(ctx, &state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}

// No domain association; nothing to do
domainIDs := []string{}
diags = state.DomainIDs.ElementsAs(ctx, &domainIDs, true)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}

// No domain association; nothing to do
if len(domainIDs) == 0 {
return
}

for _, rawDomainID := range domainIDs {
domainID, err := uuid.Parse(rawDomainID)
if err != nil {
resp.Diagnostics.AddError(
fmt.Sprintf("Unable to Disassociate the Domain %s from the Deployment %s", rawDomainID, state.DeploymentID),
fmt.Sprintf("Could not parse domain ID %s: %s", rawDomainID, err.Error()),
)
continue
}

// Call the API to disassociate the domain
result, err := r.client.UpdateDomainAssociationWithResponse(ctx, domainID, client.UpdateDomainAssociationRequest{
DeploymentId: nil,
})
if err != nil {
resp.Diagnostics.AddError(
fmt.Sprintf("Unable to Disassociate the Domain %s from the Deployment %s", rawDomainID, state.DeploymentID),
err.Error(),
)
continue
}
if client.RespIsError(result) {
resp.Diagnostics.AddError(
fmt.Sprintf("Unable to Disassociate the Domain %s from the Deployment %s", rawDomainID, state.DeploymentID),
client.APIErrorDetail(result.HTTPResponse, result.Body),
)
continue
}
}
}

// Configure adds the provider configured client to the resource.
Expand All @@ -452,6 +487,7 @@ func (r *deploymentResource) Configure(_ context.Context, req resource.Configure
func (r *deploymentResource) doDeployment(ctx context.Context, plan *deploymentResourceModel) diag.Diagnostics {
accumulatedDiags := diag.Diagnostics{}

// Validate and parse project ID as UUID
projectID, err := uuid.Parse(plan.ProjectID.ValueString())
if err != nil {
accumulatedDiags.AddError(
Expand All @@ -461,14 +497,34 @@ func (r *deploymentResource) doDeployment(ctx context.Context, plan *deploymentR
return accumulatedDiags
}

// Validate and parse domain IDs as UUID (if present)
var rawDomainIDs []string
var domainIDs []uuid.UUID
diags := plan.DomainIDs.ElementsAs(ctx, &rawDomainIDs, true)
accumulatedDiags.Append(diags...)
if accumulatedDiags.HasError() {
return accumulatedDiags
}
for _, rawDomainID := range rawDomainIDs {
d, err := uuid.Parse(rawDomainID)
if err != nil {
accumulatedDiags.AddError(
fmt.Sprintf("Unable to Create Deployment for Project %s", plan.ProjectID),
fmt.Sprintf("Could not parse domain ID %s: %s", rawDomainID, err.Error()),
)
return accumulatedDiags
}
domainIDs = append(domainIDs, d)
}

assets, diag := prepareAssetsForUpload(ctx, plan.Assets)
accumulatedDiags.Append(diag)
if accumulatedDiags.HasError() {
return accumulatedDiags
}

var envVars map[string]string
diags := plan.EnvVars.ElementsAs(ctx, &envVars, true)
diags = plan.EnvVars.ElementsAs(ctx, &envVars, true)
accumulatedDiags.Append(diags...)
if accumulatedDiags.HasError() {
return accumulatedDiags
Expand Down Expand Up @@ -528,60 +584,27 @@ func (r *deploymentResource) doDeployment(ctx context.Context, plan *deploymentR
logs[i] = fmt.Sprintf("[%s] %s", logline.Level, logline.Message)
}

deployment, err := r.client.GetDeploymentWithResponse(ctx, deploymentID)
if err != nil {
accumulatedDiags.AddError(
"Deployment Initiated, but Failed to Get Deployment Details",
fmt.Sprintf(`Deployment ID: %s
Error: %s

Build logs:
%s
`, deploymentID, err.Error(), strings.Join(logs, "\n")),
)
return accumulatedDiags
}
if client.RespIsError(deployment) {
accumulatedDiags.AddError(
"Deployment Initiated, but Failed to Get Deployment Details",
fmt.Sprintf(`Deployment ID: %s
Error: %s

Build logs:
%s
`, deploymentID, client.APIErrorDetail(deployment.HTTPResponse, deployment.Body), strings.Join(logs, "\n")),
)
diags = r.updateModel(ctx, deploymentID, plan)
accumulatedDiags.Append(diags...)
if accumulatedDiags.HasError() {
return accumulatedDiags
}

// Ensure the deployment has succeeded
if deployment.JSON200.Status != client.DeploymentStatusSuccess {
if plan.Status.ValueString() != string(client.DeploymentStatusSuccess) {
accumulatedDiags.AddError(
"Deployment Failed",
fmt.Sprintf(`Deployment ID: %s
Status: %s

Build logs:
%s
`, deploymentID, deployment.JSON200.Status, strings.Join(logs, "\n")),
`, deploymentID, plan.Status, strings.Join(logs, "\n")),
)
return accumulatedDiags
}

// Deployment succeeded
plan.DeploymentID = types.StringValue(deployment.JSON200.Id)
plan.Status = types.StringValue(string(deployment.JSON200.Status))
domainElems := make([]attr.Value, len(*deployment.JSON200.Domains))
for i, d := range *deployment.JSON200.Domains {
domainElems[i] = types.StringValue(d)
}
domainSet, diags := types.SetValue(basetypes.StringType{}, domainElems)
accumulatedDiags.Append(diags...)
if accumulatedDiags.HasError() {
return accumulatedDiags
}
plan.Domains = domainSet

// Save the uploaded assets to the state so we can avoid duplicate uploads in future deployments.
// TODO: we haven't implemented the logic to avoid duplicate uploads
uploadedAssets, diags := types.MapValue(types.ObjectType{
AttrTypes: map[string]attr.Type{
Expand All @@ -596,8 +619,71 @@ Build logs:
}
plan.UploadedAssets = uploadedAssets

plan.CreatedAt = types.StringValue(deployment.JSON200.CreatedAt.Format(time.RFC3339))
plan.UpdatedAt = types.StringValue(deployment.JSON200.UpdatedAt.Format(time.RFC3339))
// Associate the custom domain (if present)
if len(domainIDs) > 0 {
for _, domainID := range domainIDs {
result, err := r.client.UpdateDomainAssociationWithResponse(ctx, domainID, client.UpdateDomainAssociationRequest{
DeploymentId: &deploymentID,
})
if err != nil {
accumulatedDiags.AddError(
fmt.Sprintf("Unable to Associate the Domain %s with the Deployment %s", domainID, deploymentID),
err.Error(),
)
return accumulatedDiags
}
if client.RespIsError(result) {
accumulatedDiags.AddError(
fmt.Sprintf("Unable to Associate the Domain %s with the Deployment %s", domainID, deploymentID),
client.APIErrorDetail(result.HTTPResponse, result.Body),
)
return accumulatedDiags
}
}

// Custom domain association succeeded; call the get deployment API again to obtain the updated domain list
r.updateModel(ctx, deploymentID, plan)
}

return accumulatedDiags
}

// UpdateModel updates the resource model with the latest information obtained by making a API call.
func (r *deploymentResource) updateModel(ctx context.Context, deploymentID string, model *deploymentResourceModel) diag.Diagnostics {
accumulatedDiags := diag.Diagnostics{}

deployment, err := r.client.GetDeploymentWithResponse(ctx, deploymentID)
if err != nil {
accumulatedDiags.AddError(
"Failed to Get Deployment Details",
fmt.Sprintf("Deployment ID: %s, Error: %s", deploymentID, err.Error()),
)
return accumulatedDiags
}
if client.RespIsError(deployment) {
accumulatedDiags.AddError(
"Failed to Get Deployment Details",
fmt.Sprintf("Deployment ID: %s, Error: %s", deploymentID, client.APIErrorDetail(deployment.HTTPResponse, deployment.Body)),
)
return accumulatedDiags
}

model.DeploymentID = types.StringValue(deployment.JSON200.Id)
model.Status = types.StringValue(string(deployment.JSON200.Status))
domainElems := []attr.Value{}
if deployment.JSON200.Domains != nil {
for _, d := range *deployment.JSON200.Domains {
domainElems = append(domainElems, types.StringValue(d))
}
}
domainSet, diags := types.SetValue(basetypes.StringType{}, domainElems)
accumulatedDiags.Append(diags...)
if accumulatedDiags.HasError() {
return accumulatedDiags
}
model.Domains = domainSet
model.CreatedAt = types.StringValue(deployment.JSON200.CreatedAt.Format(time.RFC3339))
model.UpdatedAt = types.StringValue(deployment.JSON200.UpdatedAt.Format(time.RFC3339))

return accumulatedDiags
}