diff --git a/.gitignore b/.gitignore index 6dedf75..155f476 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ local.settings.json .terraform.tfstate.lock.info .terraform.lock.hcl crash.log +.DS_Store diff --git a/README.md b/README.md index d52360e..1f0383e 100644 --- a/README.md +++ b/README.md @@ -32,12 +32,12 @@ See [verification notes](docs/verification.md#compiler-pin-moved-to-the-v0660-re | Linux x86_64 native HTTP | Passed locally | 18 cases passed over HTTP | Not deployed | | macOS arm64 native HTTP | Passed locally with the `--release` installer | 18 cases passed over HTTP | Not applicable | | Wasm + generated JS, Node 24.19.0 | Passed locally | Same 18 cases + 1,000 repeated string calls | Not applicable | -| Workers, Wrangler 4.147.0 / local workerd | Dry-run bundle passed; real `wrangler deploy` uploaded | Same 18 cases passed over HTTP locally and on the workers.dev edge | Deployed temporarily to workers.dev, verified, deleted | +| Workers, Wrangler 4.147.0 / local workerd | Dry-run bundle passed; real `wrangler deploy` uploaded | Same 18 cases passed over HTTP locally and on the workers.dev edge | Deployed temporarily with Wrangler and with [Terraform](providers/cloudflare-workers/terraform/), verified, deleted | | ConoHa Docker / Compose | Image built and Compose started on macOS arm64 (Docker 29.6.1) and on a ConoHa VPS, x86_64 (Docker 29.2.1, Compose v5.0.2) | Same 18 cases passed against the container on both | VPS created with [Terraform](providers/conoha/terraform/), verified, destroyed | -| Google Cloud Run container | linux/amd64 image built (QEMU on Apple silicon), pushed by digest; `replace --dry-run` and deploy passed | Same 18 cases passed from a VM inside the VPC with an ID token | Deployed temporarily with internal ingress + IAM, verified, deleted | +| Google Cloud Run container | linux/amd64 image built (QEMU on Apple silicon), pushed by digest; `replace --dry-run` and deploy passed | Same 18 cases passed from a VM inside the VPC with an ID token | Deployed temporarily with internal ingress + IAM, by gcloud and by [Terraform](providers/google-cloud-run/terraform/), verified, deleted | | Azure Container Apps / ECS Fargate | Provider templates and local safety/shape checks passed; image not built for them | Shared native contract passed; provider runtime not run | Not deployed | | AWS Lambda, Node 24 | Source package generated; adapter/config tests passed | 18 common cases, base64/event/HEAD/warm-call checks; staged package executed locally | Not deployed | -| Google Cloud Run functions, Node 24 | Source package, local Functions Framework, and managed source build via `deploy.sh --execute` | 18 direct adapter cases; in the cloud, 18 octet-stream cases passed and JSON showed the same 3 framework rejections as locally | Deployed temporarily with internal ingress + IAM, verified, deleted | +| Google Cloud Run functions, Node 24 | Source package, local Functions Framework, and managed source build via `deploy.sh --execute` | 18 direct adapter cases; in the cloud, 18 octet-stream cases passed and JSON showed the same 3 framework rejections as locally | Deployed temporarily with internal ingress + IAM, by `deploy.sh` and by [Terraform](providers/google-cloud-functions/terraform/), verified, deleted | | Azure Functions v4, Node 24 | Source package, adapter/config tests and actual SDK request objects tested | 18 common cases; Functions host/key enforcement not run | Not deployed | The `/notes` storage scenario ([tests/notes.mjs](tests/notes.mjs), 10 requests in @@ -106,7 +106,8 @@ accounts or silently grant caller access. Read [deployment safety and cleanup](d before applying any example. The Cloudflare Workers, Google Cloud Run container, Cloud Run functions and ConoHa VPS examples have been deployed temporarily for verification and then deleted; the AWS and Azure examples have not been deployed. -The ConoHa VPS itself is created by the optional [Terraform](providers/conoha/terraform/). +Each of these also has optional Terraform (ConoHa, Cloud Run, Cloud Run functions, +Workers), applied, verified and destroyed once; see each provider guide. ### Prepare and test function packages locally diff --git a/docs/verification.md b/docs/verification.md index 37b35f5..e2a3bee 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -302,6 +302,37 @@ Live: Not shown: behavior under concurrent writers. The single-key read-modify-write has no conditional write, so concurrent instances can lose a note. +## Terraform for Cloudflare and Google + +Date: 2026-10-04, Terraform 1.14.9, providers cloudflare 5.26.0, google 8.5.0, +archive 2.8.1, time 0.14.2. Each configuration was applied, checked with the +same tests as the CLI deployments, planned again (no changes) and destroyed. +The application was main at `c904bf7`. + +1. Cloudflare (`providers/cloudflare-workers/terraform`), with the Wrangler + login's token as `CLOUDFLARE_API_TOKEN`: 4 resources (KV namespace, Worker, + version with `worker.js` and the imported Wasm, deployment). 18 cases and the + `/notes` scenario passed from the edge (29/29); KV held the two saved notes. + `destroy` removed all 4 including the namespace. The API reported the Worker + gone at once; the URL answered 200 for a few seconds, then 404 +2. Google, a new disposable project (deleted afterwards), credentials through + `GOOGLE_OAUTH_ACCESS_TOKEN`; the probe VM and caller account were made with + gcloud, outside Terraform: + - Cloud Run (`providers/google-cloud-run/terraform`): 8 resources, then the + image was pushed to the created repository and the second apply made the + service and the invoker grant (2). Ingress internal, concurrency 1, the + runtime account, invoker = the caller only. From the in-VPC VM: 18/18 and + the scenario 11/11; the bucket held the two notes; internet with a token + 404; no token 403 + - Cloud Run functions (`providers/google-cloud-functions/terraform`): 16 + resources in 183 s (including the 60-second wait for the build account's + grant). The first requests got 403 `run.routes.invoke` until the invoker + grant propagated a few minutes later; then 18/18 octet-stream, the 3 known + framework rejections as JSON, the scenario 11/11, and the two notes in GCS + - `destroy` removed 10 and 16 resources. Cloud Functions' own + `gcf-v2-sources-*` bucket and `gcf-artifacts` repository were not managed + by Terraform and remained until the project was deleted + ## Not established - Native x86_64 Docker build outside the ConoHa VPS diff --git a/package.json b/package.json index dcdef74..1bce0ff 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build": "bash scripts/build.sh", "test": "node --test tests/contract.test.mjs tests/installer.test.mjs tests/faas.test.mjs tests/provider-config.test.mjs tests/package-faas.test.mjs", "test:workers": "node --test tests/workers.test.mjs", - "check:workers": "WRANGLER_SEND_METRICS=false wrangler deploy --dry-run --config providers/cloudflare-workers/wrangler.jsonc --outdir \"$PWD/build/worker-bundle\"", + "check:workers": "rm -rf build/worker-bundle && WRANGLER_SEND_METRICS=false wrangler deploy --dry-run --config providers/cloudflare-workers/wrangler.jsonc --outdir \"$PWD/build/worker-bundle\"", "dev:workers": "WRANGLER_SEND_METRICS=false wrangler dev --local --config providers/cloudflare-workers/wrangler.jsonc", "package:faas": "node scripts/package-faas.mjs aws-lambda && node scripts/package-faas.mjs google-cloud-functions && node scripts/package-faas.mjs azure-functions", "test:google-framework": "node --test tests/google-framework.test.mjs", diff --git a/providers/cloudflare-workers/README.md b/providers/cloudflare-workers/README.md index bf9e39a..e6177cd 100644 --- a/providers/cloudflare-workers/README.md +++ b/providers/cloudflare-workers/README.md @@ -57,6 +57,29 @@ KV is eventually consistent across locations and has no conditional write, so concurrent POSTs from different isolates can lose one. Secrets, D1, R2 and outbound `fetch` remain outside the example. +## Optional: Terraform + +[terraform/](terraform/) deploys the Wrangler bundle with the Cloudflare provider: +a KV namespace `-notes`, the Worker (on workers.dev unless +`workers_dev = false`), a version with `worker.js` and only the Wasm module it +imports, the `NOTES` binding and the same compatibility date and flags, and a +deployment of that version. Unlike `wrangler delete`, `terraform destroy` also +removes the KV namespace. + +```sh +npm run build && npm run check:workers # writes build/worker-bundle +export CLOUDFLARE_API_TOKEN=... # Workers Scripts and Workers KV Storage: Edit +cd providers/cloudflare-workers/terraform +cp terraform.tfvars.example terraform.tfvars # account_id +terraform init && terraform apply +terraform destroy +``` + +On 2026-10-04 this created 4 resources; the 18 cases and the `/notes` scenario +passed from the edge (29/29) and KV held the two saved notes. A second `plan` +showed no changes. After `destroy` the API reported the Worker gone at once, +while the URL kept answering 200 for a few seconds before 404. + ## Official references - [Workers WebAssembly](https://developers.cloudflare.com/workers/runtime-apis/webassembly/javascript/) diff --git a/providers/cloudflare-workers/terraform/main.tf b/providers/cloudflare-workers/terraform/main.tf new file mode 100644 index 0000000..e874f5d --- /dev/null +++ b/providers/cloudflare-workers/terraform/main.tf @@ -0,0 +1,59 @@ +# The Worker from the Wrangler bundle, its KV namespace for /notes, and a +# deployment of that version. Same settings as ../wrangler.jsonc. + +locals { + worker_js = file("${var.bundle_dir}/worker.js") + # Only the Wasm module the bundle imports; a stale one in the directory is not uploaded. + wasm = regex("from \"\\./([0-9a-f]+-app\\.wasm)\"", local.worker_js)[0] +} + +resource "cloudflare_workers_kv_namespace" "notes" { + account_id = var.account_id + title = "${var.name}-notes" +} + +resource "cloudflare_worker" "api" { + account_id = var.account_id + name = var.name + subdomain = { + enabled = var.workers_dev + previews_enabled = false + } +} + +resource "cloudflare_worker_version" "api" { + account_id = var.account_id + worker_id = cloudflare_worker.api.id + compatibility_date = "2026-10-04" + # Almide's generated glue has a top-level import.meta.url and an unused + # node:fs/promises fallback; see ../README.md. + compatibility_flags = ["nodejs_compat", "new_module_registry"] + main_module = "worker.js" + modules = [ + { + name = "worker.js" + content_type = "application/javascript+module" + content_file = "${var.bundle_dir}/worker.js" + }, + { + name = local.wasm + content_type = "application/wasm" + content_file = "${var.bundle_dir}/${local.wasm}" + }, + ] + bindings = [{ + name = "NOTES" + type = "kv_namespace" + namespace_id = cloudflare_workers_kv_namespace.notes.id + }] +} + +resource "cloudflare_workers_deployment" "api" { + account_id = var.account_id + script_name = cloudflare_worker.api.name + strategy = "percentage" + versions = [{ + version_id = cloudflare_worker_version.api.id + percentage = 100 + }] +} diff --git a/providers/cloudflare-workers/terraform/outputs.tf b/providers/cloudflare-workers/terraform/outputs.tf new file mode 100644 index 0000000..cbf30f0 --- /dev/null +++ b/providers/cloudflare-workers/terraform/outputs.tf @@ -0,0 +1,7 @@ +output "kv_namespace_id" { + value = cloudflare_workers_kv_namespace.notes.id +} + +output "version_id" { + value = cloudflare_worker_version.api.id +} diff --git a/providers/cloudflare-workers/terraform/terraform.tfvars.example b/providers/cloudflare-workers/terraform/terraform.tfvars.example new file mode 100644 index 0000000..e56029b --- /dev/null +++ b/providers/cloudflare-workers/terraform/terraform.tfvars.example @@ -0,0 +1,3 @@ +# Copy to terraform.tfvars (ignored by Git) and fill in. +account_id = "your-cloudflare-account-id" +# workers_dev = true # public *.workers.dev URL; the Worker has no authentication diff --git a/providers/cloudflare-workers/terraform/variables.tf b/providers/cloudflare-workers/terraform/variables.tf new file mode 100644 index 0000000..0a6c21b --- /dev/null +++ b/providers/cloudflare-workers/terraform/variables.tf @@ -0,0 +1,27 @@ +variable "account_id" { + description = "Cloudflare account ID" + type = string +} + +variable "name" { + description = "Worker name; the KV namespace is -notes" + type = string + default = "almide-cloud-example" + validation { + condition = can(regex("^[a-z][a-z0-9-]{2,62}$", var.name)) + error_message = "Use 3-63 lowercase letters, digits or hyphens." + } +} + +variable "bundle_dir" { + description = "Output of `npm run check:workers`: worker.js and the Wasm module it imports" + type = string + default = "../../../build/worker-bundle" +} + +variable "workers_dev" { + # The Worker has no authentication: on workers.dev it is public. + description = "Serve the Worker on its public *.workers.dev URL" + type = bool + default = true +} diff --git a/providers/cloudflare-workers/terraform/versions.tf b/providers/cloudflare-workers/terraform/versions.tf new file mode 100644 index 0000000..56fadcb --- /dev/null +++ b/providers/cloudflare-workers/terraform/versions.tf @@ -0,0 +1,9 @@ +terraform { + required_version = ">= 1.5" + required_providers { + cloudflare = { source = "cloudflare/cloudflare", version = "~> 5.26" } + } +} + +# Credentials from CLOUDFLARE_API_TOKEN (Workers Scripts and Workers KV Storage: Edit). +provider "cloudflare" {} diff --git a/providers/google-cloud-functions/README.md b/providers/google-cloud-functions/README.md index 1aa699a..7ac2cd0 100644 --- a/providers/google-cloud-functions/README.md +++ b/providers/google-cloud-functions/README.md @@ -81,6 +81,33 @@ When finished, review `gcloud run services delete SERVICE --project PROJECT --region REGION` for the exact service you created. Build artifacts in Artifact Registry and logs may need separate cleanup; do not delete shared repositories. +## Optional: Terraform + +[terraform/](terraform/) deploys the staged package with the Cloud Functions v2 +API (`google_cloudfunctions2_function`), which serves it from a Cloud Run +service: Node 24 runtime, entry point `almideApi`, a build service account with +`roles/cloudbuild.builds.builder`, a runtime account with no project roles, +internal-only ingress, concurrency 1, at most 3 instances, a private `/notes` +bucket and `run.invoker` only for the members you list. The source zip is the +staged package without `node_modules`; the build installs from the lockfile. + +```sh +npm run build && npm run package:faas +cd providers/google-cloud-functions/terraform +cp terraform.tfvars.example terraform.tfvars # project_id, invoker_members +terraform init && terraform apply +terraform destroy +``` + +On 2026-10-04 this was applied in a disposable project (16 resources, about +3 minutes including a 60-second wait for the build account's grant). Requests +right after the apply got 403 (`run.routes.invoke`) until the invoker grant +propagated, a few minutes later. Then, from an in-VPC VM: 18/18 as +octet-stream, the same 3 framework rejections as JSON, and the `/notes` scenario +11/11. A second `plan` showed no changes and `destroy` removed all 16, but +Cloud Functions' own `gcf-v2-sources-*` bucket and `gcf-artifacts` repository +remained; delete them, or the project, separately. + ## Configuration, secrets and logs Cloud Run environment values are host-side `process.env` configuration; they diff --git a/providers/google-cloud-functions/terraform/main.tf b/providers/google-cloud-functions/terraform/main.tf new file mode 100644 index 0000000..699042d --- /dev/null +++ b/providers/google-cloud-functions/terraform/main.tf @@ -0,0 +1,117 @@ +# The Wasm function on Cloud Run functions (Cloud Functions v2 API): the staged +# package as source, a build identity, a runtime identity with no project roles, +# a private bucket for /notes, internal-only ingress and the invoker IAM check. +# Same settings as ../deploy.sh. + +resource "google_project_service" "apis" { + for_each = toset([ + "cloudfunctions.googleapis.com", "run.googleapis.com", "cloudbuild.googleapis.com", + "artifactregistry.googleapis.com", "storage.googleapis.com", "iam.googleapis.com", + ]) + service = each.value + disable_on_destroy = false +} + +resource "google_service_account" "runtime" { + account_id = "${var.name}-run" + display_name = "Runtime identity of ${var.name}; no project roles" + depends_on = [google_project_service.apis] +} + +resource "google_service_account" "build" { + account_id = "${var.name}-build" + display_name = "Builds ${var.name} from source" + depends_on = [google_project_service.apis] +} + +resource "google_project_iam_member" "build" { + project = var.project_id + role = "roles/cloudbuild.builds.builder" + member = google_service_account.build.member +} + +# New IAM grants take a minute to reach Cloud Build. +resource "time_sleep" "build_iam" { + depends_on = [google_project_iam_member.build] + create_duration = "60s" +} + +resource "google_storage_bucket" "source" { + name = "${var.project_id}-${var.name}-source" + location = var.region + uniform_bucket_level_access = true + public_access_prevention = "enforced" + force_destroy = true + depends_on = [google_project_service.apis] +} + +data "archive_file" "source" { + type = "zip" + source_dir = var.package_dir + output_path = "${path.module}/.terraform/${var.name}-source.zip" + excludes = ["node_modules/**"] +} + +resource "google_storage_bucket_object" "source" { + name = "${var.name}-${data.archive_file.source.output_sha256}.zip" + bucket = google_storage_bucket.source.name + source = data.archive_file.source.output_path +} + +resource "google_storage_bucket" "notes" { + name = "${var.project_id}-${var.name}-notes" + location = var.region + uniform_bucket_level_access = true + public_access_prevention = "enforced" + force_destroy = true # destroy removes the stored notes too + depends_on = [google_project_service.apis] +} + +resource "google_storage_bucket_iam_member" "runtime_notes" { + bucket = google_storage_bucket.notes.name + role = "roles/storage.objectUser" + member = google_service_account.runtime.member +} + +resource "google_cloudfunctions2_function" "api" { + name = var.name + location = var.region + + build_config { + runtime = "nodejs24" + entry_point = "almideApi" + service_account = google_service_account.build.id + source { + storage_source { + bucket = google_storage_bucket.source.name + object = google_storage_bucket_object.source.name + } + } + } + + service_config { + service_account_email = google_service_account.runtime.email + ingress_settings = "ALLOW_INTERNAL_ONLY" + max_instance_request_concurrency = 1 + min_instance_count = 0 + max_instance_count = 3 + available_memory = "256Mi" + available_cpu = "1" + timeout_seconds = 30 + all_traffic_on_latest_revision = true + environment_variables = { + GCS_BUCKET = google_storage_bucket.notes.name + } + } + + depends_on = [time_sleep.build_iam, google_storage_bucket_iam_member.runtime_notes] +} + +# The function is served by a Cloud Run service; invocation is its run.invoker. +resource "google_cloud_run_v2_service_iam_member" "invokers" { + for_each = toset(var.invoker_members) + name = google_cloudfunctions2_function.api.service_config[0].service + location = var.region + role = "roles/run.invoker" + member = each.value +} diff --git a/providers/google-cloud-functions/terraform/outputs.tf b/providers/google-cloud-functions/terraform/outputs.tf new file mode 100644 index 0000000..3d0d6c0 --- /dev/null +++ b/providers/google-cloud-functions/terraform/outputs.tf @@ -0,0 +1,11 @@ +output "function_url" { + value = google_cloudfunctions2_function.api.service_config[0].uri +} + +output "runtime_service_account" { + value = google_service_account.runtime.email +} + +output "notes_bucket" { + value = google_storage_bucket.notes.name +} diff --git a/providers/google-cloud-functions/terraform/terraform.tfvars.example b/providers/google-cloud-functions/terraform/terraform.tfvars.example new file mode 100644 index 0000000..54beb66 --- /dev/null +++ b/providers/google-cloud-functions/terraform/terraform.tfvars.example @@ -0,0 +1,4 @@ +# Copy to terraform.tfvars (ignored by Git) and fill in. +project_id = "your-dedicated-project" +# region = "asia-northeast1" +# invoker_members = ["serviceAccount:caller@your-dedicated-project.iam.gserviceaccount.com"] diff --git a/providers/google-cloud-functions/terraform/variables.tf b/providers/google-cloud-functions/terraform/variables.tf new file mode 100644 index 0000000..bf362b7 --- /dev/null +++ b/providers/google-cloud-functions/terraform/variables.tf @@ -0,0 +1,36 @@ +variable "project_id" { + description = "An existing project with billing; use a dedicated one for trying this out" + type = string +} + +variable "region" { + type = string + default = "asia-northeast1" +} + +variable "name" { + description = "Function name and prefix of what is created with it" + type = string + default = "almide-fn-demo" + validation { + condition = can(regex("^[a-z][a-z0-9-]{2,23}$", var.name)) + error_message = "Use 3-24 lowercase letters, digits or hyphens (service-account ids are limited to 30)." + } +} + +variable "package_dir" { + description = "The staged package from `npm run package:faas` (node_modules is left out; the build installs from the lockfile)" + type = string + default = "../../../build/packages/google-cloud-functions" +} + +variable "invoker_members" { + # Empty by default: no one can invoke the function until you name them. + description = "IAM members granted roles/run.invoker, e.g. serviceAccount:caller@PROJECT.iam.gserviceaccount.com" + type = list(string) + default = [] + validation { + condition = alltrue([for m in var.invoker_members : !contains(["allUsers", "allAuthenticatedUsers"], m)]) + error_message = "allUsers and allAuthenticatedUsers are refused; this example stays private." + } +} diff --git a/providers/google-cloud-functions/terraform/versions.tf b/providers/google-cloud-functions/terraform/versions.tf new file mode 100644 index 0000000..d298709 --- /dev/null +++ b/providers/google-cloud-functions/terraform/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.5" + required_providers { + google = { source = "hashicorp/google", version = "~> 8.5" } + archive = { source = "hashicorp/archive", version = "~> 2.8" } + time = { source = "hashicorp/time", version = "~> 0.13" } + } +} + +# Credentials from Application Default Credentials (gcloud auth application-default login). +provider "google" { + project = var.project_id + region = var.region +} diff --git a/providers/google-cloud-run/README.md b/providers/google-cloud-run/README.md index d901819..fccfd94 100644 --- a/providers/google-cloud-run/README.md +++ b/providers/google-cloud-run/README.md @@ -150,6 +150,34 @@ supports YAML specifications and optional validation without application. Both the dry run and the real replace succeeded on 2026-10-04. If deployment fails, inspect the returned error and revision logs; do not relax ingress or IAM as a shortcut. +## Optional: Terraform + +[terraform/](terraform/) creates the same service in an existing project: the APIs, +an Artifact Registry repository, a runtime service account with no project +roles, a private `/notes` bucket (public access prevention, uniform access, +`roles/storage.objectUser` for the runtime account on that bucket only), and the +service with internal ingress, the invoker IAM check, concurrency 1 and the same +probes. Invokers are only the members you list; `allUsers` is refused. The +repository must exist before the image can be pushed, so it takes two applies: + +```sh +gcloud auth application-default login # or GOOGLE_OAUTH_ACCESS_TOKEN=$(gcloud auth print-access-token) +cd providers/google-cloud-run/terraform +cp terraform.tfvars.example terraform.tfvars # project_id, invoker_members +terraform init && terraform apply # repository, identity, bucket +IMAGE=$(terraform output -raw image_path) +docker buildx build --platform linux/amd64 --load -t "$IMAGE:reviewed-build" ../../.. +docker push "$IMAGE:reviewed-build" +docker buildx imagetools inspect "$IMAGE:reviewed-build" # set image = "$IMAGE@sha256:..." +terraform apply # the service +terraform destroy # also deletes the bucket and its notes +``` + +On 2026-10-04 this was applied in a disposable project: 8 resources, then 2; +from an in-VPC VM with an ID token the 18 cases and the `/notes` scenario passed, +the bucket held the two saved notes, an internet request with a token got 404, +a second `plan` showed no changes, and `destroy` removed all 10. + ## Verify the deployment from an authorized network First review the deployed configuration and service IAM policy. Also have your diff --git a/providers/google-cloud-run/terraform/main.tf b/providers/google-cloud-run/terraform/main.tf new file mode 100644 index 0000000..b906731 --- /dev/null +++ b/providers/google-cloud-run/terraform/main.tf @@ -0,0 +1,103 @@ +# The native container on Cloud Run: an Artifact Registry repository, a runtime +# identity with no project roles, a private bucket for /notes, and the service +# with internal ingress and the invoker IAM check. Same settings as +# ../service.template.yaml. + +resource "google_project_service" "apis" { + for_each = toset(["run.googleapis.com", "artifactregistry.googleapis.com", "storage.googleapis.com", "iam.googleapis.com"]) + service = each.value + disable_on_destroy = false +} + +resource "google_artifact_registry_repository" "images" { + repository_id = "almide" + format = "DOCKER" + location = var.region + depends_on = [google_project_service.apis] +} + +resource "google_service_account" "runtime" { + account_id = "${var.name}-run" + display_name = "Runtime identity of ${var.name}; no project roles" + depends_on = [google_project_service.apis] +} + +resource "google_storage_bucket" "notes" { + name = "${var.project_id}-${var.name}-notes" + location = var.region + uniform_bucket_level_access = true + public_access_prevention = "enforced" + force_destroy = true # destroy removes the stored notes too + depends_on = [google_project_service.apis] +} + +# Read and overwrite objects in this bucket only. +resource "google_storage_bucket_iam_member" "runtime_notes" { + bucket = google_storage_bucket.notes.name + role = "roles/storage.objectUser" + member = google_service_account.runtime.member +} + +resource "google_cloud_run_v2_service" "api" { + count = var.image == null ? 0 : 1 + name = var.name + location = var.region + ingress = "INGRESS_TRAFFIC_INTERNAL_ONLY" + invoker_iam_disabled = false + deletion_protection = false + + template { + service_account = google_service_account.runtime.email + execution_environment = "EXECUTION_ENVIRONMENT_GEN2" + max_instance_request_concurrency = 1 # native http.serve is sequential + timeout = "60s" + scaling { + min_instance_count = 0 + max_instance_count = 2 + } + containers { + name = "api" + image = var.image + ports { + name = "http1" + container_port = 8080 + } + resources { + limits = { cpu = "1", memory = "512Mi" } + cpu_idle = true + } + env { + name = "GCS_BUCKET" + value = google_storage_bucket.notes.name + } + startup_probe { + http_get { + path = "/health" + port = 8080 + } + timeout_seconds = 5 + period_seconds = 10 + failure_threshold = 24 + } + liveness_probe { + http_get { + path = "/health" + port = 8080 + } + timeout_seconds = 5 + period_seconds = 30 + failure_threshold = 3 + } + } + } + + depends_on = [google_storage_bucket_iam_member.runtime_notes] +} + +resource "google_cloud_run_v2_service_iam_member" "invokers" { + for_each = var.image == null ? toset([]) : toset(var.invoker_members) + name = google_cloud_run_v2_service.api[0].name + location = var.region + role = "roles/run.invoker" + member = each.value +} diff --git a/providers/google-cloud-run/terraform/outputs.tf b/providers/google-cloud-run/terraform/outputs.tf new file mode 100644 index 0000000..865d324 --- /dev/null +++ b/providers/google-cloud-run/terraform/outputs.tf @@ -0,0 +1,16 @@ +output "image_path" { + description = "Tag and push the image here, then set var.image to its digest" + value = "${var.region}-docker.pkg.dev/${var.project_id}/${google_artifact_registry_repository.images.repository_id}/almide-api" +} + +output "service_url" { + value = var.image == null ? null : google_cloud_run_v2_service.api[0].uri +} + +output "runtime_service_account" { + value = google_service_account.runtime.email +} + +output "notes_bucket" { + value = google_storage_bucket.notes.name +} diff --git a/providers/google-cloud-run/terraform/terraform.tfvars.example b/providers/google-cloud-run/terraform/terraform.tfvars.example new file mode 100644 index 0000000..10dfae9 --- /dev/null +++ b/providers/google-cloud-run/terraform/terraform.tfvars.example @@ -0,0 +1,6 @@ +# Copy to terraform.tfvars (ignored by Git) and fill in. +project_id = "your-dedicated-project" +# region = "asia-northeast1" +# Second apply, after pushing: the digest printed by `docker buildx imagetools inspect`. +# image = "asia-northeast1-docker.pkg.dev/your-dedicated-project/almide/almide-api@sha256:..." +# invoker_members = ["serviceAccount:caller@your-dedicated-project.iam.gserviceaccount.com"] diff --git a/providers/google-cloud-run/terraform/variables.tf b/providers/google-cloud-run/terraform/variables.tf new file mode 100644 index 0000000..534e4e6 --- /dev/null +++ b/providers/google-cloud-run/terraform/variables.tf @@ -0,0 +1,41 @@ +variable "project_id" { + description = "An existing project with billing; use a dedicated one for trying this out" + type = string +} + +variable "region" { + type = string + default = "asia-northeast1" +} + +variable "name" { + description = "Service name and prefix of what is created with it" + type = string + default = "almide-api-demo" + validation { + condition = can(regex("^[a-z][a-z0-9-]{2,23}$", var.name)) + error_message = "Use 3-24 lowercase letters, digits or hyphens (service-account ids are limited to 30)." + } +} + +variable "image" { + # Null on the first apply: the repository must exist before the image is pushed. + description = "The pushed image pinned by digest; the service is created only once this is set" + type = string + default = null + validation { + condition = var.image == null || can(regex("^[a-z0-9-]+-docker\\.pkg\\.dev/[a-z0-9-]+/[a-z0-9._-]+/[a-z0-9._/-]+@sha256:[a-f0-9]{64}$", var.image)) + error_message = "Use an Artifact Registry image pinned to a full sha256 digest, not a tag." + } +} + +variable "invoker_members" { + # Empty by default: no one can invoke the service until you name them. + description = "IAM members granted roles/run.invoker on the service, e.g. serviceAccount:caller@PROJECT.iam.gserviceaccount.com" + type = list(string) + default = [] + validation { + condition = alltrue([for m in var.invoker_members : !contains(["allUsers", "allAuthenticatedUsers"], m)]) + error_message = "allUsers and allAuthenticatedUsers are refused; this example stays private." + } +} diff --git a/providers/google-cloud-run/terraform/versions.tf b/providers/google-cloud-run/terraform/versions.tf new file mode 100644 index 0000000..d901d61 --- /dev/null +++ b/providers/google-cloud-run/terraform/versions.tf @@ -0,0 +1,12 @@ +terraform { + required_version = ">= 1.5" + required_providers { + google = { source = "hashicorp/google", version = "~> 8.5" } + } +} + +# Credentials from Application Default Credentials (gcloud auth application-default login). +provider "google" { + project = var.project_id + region = var.region +} diff --git a/tests/provider-config.test.mjs b/tests/provider-config.test.mjs index c6475b1..bce33b9 100644 --- a/tests/provider-config.test.mjs +++ b/tests/provider-config.test.mjs @@ -147,6 +147,39 @@ test('ConoHa Terraform admits only operator SSH and keeps state and credentials for (const entry of ['.terraform/', '*.tfstate', '*.tfvars', '!*.tfvars.example']) assert.ok(gitIgnore.includes(entry), entry); }); +test('Google Terraform keeps internal ingress, the invoker check and private buckets', async () => { + const read = path => readFile(new URL(`../providers/${path}`, import.meta.url), 'utf8'); + const run = await read('google-cloud-run/terraform/main.tf'); + const fn = await read('google-cloud-functions/terraform/main.tf'); + assert.match(run, /ingress\s*=\s*"INGRESS_TRAFFIC_INTERNAL_ONLY"/); + assert.match(run, /invoker_iam_disabled\s*=\s*false/); + assert.match(run, /max_instance_request_concurrency\s*=\s*1/); + assert.match(fn, /ingress_settings\s*=\s*"ALLOW_INTERNAL_ONLY"/); + for (const main of [run, fn]) { + assert.doesNotMatch(main, /allUsers|allAuthenticatedUsers|roles\/(owner|editor)/); + for (const bucket of main.matchAll(/resource "google_storage_bucket" "[a-z_]+" \{[\s\S]*?\n\}/g)) { + assert.match(bucket[0], /public_access_prevention\s*=\s*"enforced"/); + assert.match(bucket[0], /uniform_bucket_level_access\s*=\s*true/); + } + // The runtime identity gets the bucket role only, never a project role. + assert.match(main, /resource "google_storage_bucket_iam_member" "runtime_notes"[\s\S]*?role\s*=\s*"roles\/storage\.objectUser"/); + assert.doesNotMatch(main, /google_project_iam_member"[^{]*\{[^}]*runtime/); + } + for (const dir of ['google-cloud-run', 'google-cloud-functions']) { + assert.match(await read(`${dir}/terraform/variables.tf`), /"allUsers", "allAuthenticatedUsers"/); + } +}); + +test('Cloudflare Terraform uploads only the imported Wasm and binds NOTES', async () => { + const main = await readFile(new URL('../providers/cloudflare-workers/terraform/main.tf', import.meta.url), 'utf8'); + const wrangler = await readFile(new URL('../providers/cloudflare-workers/wrangler.jsonc', import.meta.url), 'utf8'); + assert.match(main, /compatibility_flags = \["nodejs_compat", "new_module_registry"\]/); + assert.ok(wrangler.includes('"compatibility_flags": ["nodejs_compat", "new_module_registry"]')); + assert.equal(main.match(/compatibility_date = "([^"]+)"/)[1], wrangler.match(/"compatibility_date": "([^"]+)"/)[1]); + assert.match(main, /regex\("from \\"\\\\\.\/\(\[0-9a-f\]\+-app\\\\\.wasm\)\\""/); + assert.match(main, /name\s*=\s*"NOTES"\s*\n\s*type\s*=\s*"kv_namespace"/); +}); + test('local credential and tool outputs are excluded from source and Docker context', async () => { const gitIgnore = await readFile(new URL('../.gitignore', import.meta.url), 'utf8'); const dockerIgnore = await readFile(new URL('../.dockerignore', import.meta.url), 'utf8');