Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ local.settings.json
.terraform.tfstate.lock.info
.terraform.lock.hcl
crash.log
.DS_Store
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
31 changes: 31 additions & 0 deletions docs/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions providers/cloudflare-workers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<name>-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/)
Expand Down
59 changes: 59 additions & 0 deletions providers/cloudflare-workers/terraform/main.tf
Original file line number Diff line number Diff line change
@@ -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
}]
}
7 changes: 7 additions & 0 deletions providers/cloudflare-workers/terraform/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
output "kv_namespace_id" {
value = cloudflare_workers_kv_namespace.notes.id
}

output "version_id" {
value = cloudflare_worker_version.api.id
}
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions providers/cloudflare-workers/terraform/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
variable "account_id" {
description = "Cloudflare account ID"
type = string
}

variable "name" {
description = "Worker name; the KV namespace is <name>-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
}
9 changes: 9 additions & 0 deletions providers/cloudflare-workers/terraform/versions.tf
Original file line number Diff line number Diff line change
@@ -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" {}
27 changes: 27 additions & 0 deletions providers/google-cloud-functions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 117 additions & 0 deletions providers/google-cloud-functions/terraform/main.tf
Original file line number Diff line number Diff line change
@@ -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
}
11 changes: 11 additions & 0 deletions providers/google-cloud-functions/terraform/outputs.tf
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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"]
Loading
Loading