Skip to content

Commit

Permalink
Merge pull request #4 from oaknational/feat/ENG-957-serverless-module
Browse files Browse the repository at this point in the history
[ENG-957] A module for hosting APIs in Google Cloud
  • Loading branch information
tweakster authored Oct 10, 2024
2 parents 209a342 + bfc98fb commit 68d0538
Show file tree
Hide file tree
Showing 9 changed files with 462 additions and 9 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Modules

* [API](modules/gcp_api)
* [Firestore](modules/gcp_firestore)

## Developing with modules
Expand Down
121 changes: 121 additions & 0 deletions modules/gcp_api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Google Cloud Firestore

Deploys one or more Cloud run functions via an API gateway to a domain.

## Example

### The Open API YAML file

In order to use API Gateway you will need a valid Open API YAML file.

Note. Although this does need to include the paths section of the file schemas are not necessary
in the responses section for the file to be valid.

```yaml
swagger: "2.0"
info:
title: Example file
description: An example for documentation purposes
version: 1.0.0
schemes:
- https
produces:
- application/json
paths:
/v1/response:
get:
operationId: getResponse
summary: Responds to a GET request
produces:
- application/json
x-google-backend:
address: ${get_response_url}
responses:
"200":
description: Everyone is happy
/v1/update:
post:
operationId: setValue
summary: Handles a POST request
produces:
- application/json
x-google-backend:
address: ${set_value_url}
responses:
"201":
description: Everyone is happy

```

### The Terraform config

The above would be stored in a file called `example.yaml`.

In the same directory as that file should be the Terraform config...

```hcl
locals {
entrypoints = [ "getResponse", "setValue" ]
}
module "example_api" {
# tflint-ignore: terraform_module_pinned_source
source = "github.com/oaknational/oak-terraform-modules//modules/gcp_api"
name_parts = {
domain = "eg"
app = "example"
}
env = "prod"
cloudflare_account_name = var.cloudflare_account_name
cloudflare_zone_domain = var.cloudflare_zone_domain
sub_domain = "eg" # This would resolve to eg.{cloudflare_zone_domain_name}
function_source_bucket = "example-code-storage-bucket"
# Although you can code each function separately you will find most share common configs so a loop
# with a merge function similar to this may help simplify config management
functions = [for ep in local.entrypoints : merge(
{ entrypoint = ep },
{
runtime = "nodejs20"
source_object = "example/api.zip"
service_account_email = "example-api@example-project.iam.gserviceaccount.com"
environment_variables = [
{
name = "DATABASE_URL",
value = "db.example.com",
},
]
})
]
gateway = {
config_file = "${path.module}/example.yaml"
# The file name of the above yaml, assuming it is stored in the config root dir
config_version = 1
entrypoint_map = [
{
variable = "get_response_url", # This can be found in the example YAML above
entrypoint = "getResponse", # This is from local.entrypoints
},
{
variable = "set_value_url",
entrypoint = "setValue",
},
]
}
}
output "function_uri" {
value = module.hosting.function_uri
}
```
41 changes: 41 additions & 0 deletions modules/gcp_api/domain.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
data "cloudflare_accounts" "this" {
name = var.cloudflare_account_name
}

data "cloudflare_zone" "this" {
account_id = data.cloudflare_accounts.this.accounts[0].id
name = var.cloudflare_zone_domain
}

locals {
public_domain_name = join("-", compact([var.sub_domain, var.env == "prod" ? null : var.env]))
}

resource "cloudflare_record" "cname" {
zone_id = data.cloudflare_zone.this.id
name = local.public_domain_name
type = "CNAME"
value = google_api_gateway_gateway.this.default_hostname
ttl = 1
proxied = true
}

resource "cloudflare_page_rule" "this" {
zone_id = data.cloudflare_zone.this.id
target = "${local.public_domain_name}.${data.cloudflare_zone.this.name}/*"

# Priority will never be this value but by setting it high it won't interfere with the values
# in the cloudflare-page-rules workspace (See that config for a more detailed explanation).
priority = 999

# The 999 priority will be reduced to a lower value by Cloudflare
lifecycle {
ignore_changes = [
priority,
]
}

actions {
host_header_override = google_api_gateway_gateway.this.default_hostname
}
}
69 changes: 69 additions & 0 deletions modules/gcp_api/functions.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
data "google_project" "current" {}

locals {
function_records = { for f in var.functions : f.entrypoint => f }

memory_lookup = [
"128M",
"256M",
"512M",
"1G",
"2G",
"4G",
"8G",
"16G",
"32G",
]
}

resource "google_cloudfunctions2_function" "this" {
for_each = local.function_records

name = "${var.name_parts.domain}-${var.env}-${var.name_parts.region}-${var.name_parts.app}-${lower(each.value.entrypoint)}"
location = var.google_cloud_region
description = "The API endpint for ${var.env} ${join(" ", split("-", var.name_parts.app))}, ${each.key}"

build_config {
runtime = each.value.runtime
entry_point = each.value.entrypoint
docker_repository = "${data.google_project.current.id}/locations/${var.google_cloud_region}/repositories/gcf-artifacts"
source {
storage_source {
bucket = var.function_source_bucket
object = each.value.source_object
}
}
}

service_config {
max_instance_count = each.value.max_instance_count
available_memory = local.memory_lookup[each.value.available_memory_pwr]
timeout_seconds = each.value.timeout_seconds

available_cpu = each.value.available_cpu == 0 ? null : each.value.available_cpu
max_instance_request_concurrency = each.value.max_request_concurrency

service_account_email = each.value.service_account_email

environment_variables = {
for e in each.value.environment_variables : e.name => e.value
}
}
}

data "google_iam_policy" "all_users" {
binding {
role = "roles/run.invoker"
members = [
"allUsers",
]
}
}

resource "google_cloud_run_v2_service_iam_policy" "policy" {
for_each = local.function_records

location = google_cloudfunctions2_function.this[each.key].location
name = google_cloudfunctions2_function.this[each.key].name
policy_data = data.google_iam_policy.all_users.policy_data
}
40 changes: 40 additions & 0 deletions modules/gcp_api/gateway.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
locals {
gateway_template_map = {
for e in var.gateway.entrypoint_map : e.variable => google_cloudfunctions2_function.this[e.entrypoint].url
}
}

resource "google_api_gateway_api" "this" {
provider = google-beta

api_id = "${var.name_parts.domain}-${var.env}-${var.name_parts.region}-${var.name_parts.app}-api"
}

resource "google_api_gateway_api_config" "this" {
provider = google-beta

api = google_api_gateway_api.this.api_id
api_config_id = "${var.name_parts.domain}-${var.env}-${var.name_parts.region}-${var.name_parts.app}-api-v${var.gateway.config_version}"

openapi_documents {
document {
path = "openapi.yaml"
contents = base64encode(
templatefile(var.gateway.config_file, local.gateway_template_map)
)
}
}

lifecycle {
create_before_destroy = true
}
}

resource "google_api_gateway_gateway" "this" {
provider = google-beta

region = var.google_cloud_region

api_config = google_api_gateway_api_config.this.id
gateway_id = "${var.name_parts.domain}-${var.env}-${var.name_parts.region}-${var.name_parts.app}-api"
}
3 changes: 3 additions & 0 deletions modules/gcp_api/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
output "function_uri" {
value = "https://${cloudflare_record.cname.hostname}"
}
Loading

0 comments on commit 68d0538

Please sign in to comment.