From 45d86424b2a1f56bb96619e6e11a37b6378d4b0d Mon Sep 17 00:00:00 2001 From: lyc8503 Date: Tue, 30 Dec 2025 11:40:13 +0800 Subject: [PATCH] [WIP] terraform cloudflare provider v5 works! more performance/migration/deployment work todo --- .github/workflows/deploy.yml | 60 ++------------------------ .terraform.lock.hcl | 30 ++++++------- deploy.tf | 84 +++++++++++++++++++++++++++--------- init.sql | 8 ++++ pages/index.tsx | 7 +-- worker/src/index.ts | 16 +++---- worker/src/store.ts | 17 ++++++++ 7 files changed, 115 insertions(+), 107 deletions(-) create mode 100644 init.sql create mode 100644 worker/src/store.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1986a14..4b6eeaf 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -41,6 +41,8 @@ jobs: env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + + - nname: TODO migrate and init D1 - name: Install packages run: | @@ -57,26 +59,6 @@ jobs: run: | npx @cloudflare/next-on-pages - - name: Remove durable objects bindings (temporary workaround) - continue-on-error: true - # This is a workaround to fix Cloudflare provider 4.x import crash when there's a durable object binding. - run: | - # Get bindings without durable objects - NEW_BINDINGS=$(curl --request GET --fail-with-body \ - --url https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts/uptimeflare_worker/settings \ - --header 'Authorization: Bearer '$CLOUDFLARE_API_TOKEN \ - --header 'Content-Type: application/json' | jq '.result.bindings | map(select(.type != "durable_object_namespace"))' -jc) - echo "New bindings: $NEW_BINDINGS" - - # Remove durable objects bindings - curl --request PATCH --fail-with-body \ - --url https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts/uptimeflare_worker/settings \ - --header 'Authorization: Bearer '$CLOUDFLARE_API_TOKEN \ - -F 'settings={"bindings":'$NEW_BINDINGS'}' - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ steps.fetch_account_id.outputs.account_id }} - - name: Deploy using Terraform # As we don't save terraform state somewhere, we need to import the existing resources run: | @@ -86,8 +68,8 @@ jobs: if [ -n "$KV_ID" ]; then echo "Importing existing resources..." terraform import cloudflare_workers_kv_namespace.uptimeflare_kv "$TF_VAR_CLOUDFLARE_ACCOUNT_ID/$KV_ID" - terraform import cloudflare_worker_script.uptimeflare "$TF_VAR_CLOUDFLARE_ACCOUNT_ID/uptimeflare_worker" || echo "WARNING: Worker script import failed, continuing..." - terraform import cloudflare_worker_cron_trigger.uptimeflare_worker_cron "$TF_VAR_CLOUDFLARE_ACCOUNT_ID/uptimeflare_worker" || echo "WARNING: Cron trigger import failed, continuing..." + terraform import cloudflare_workers_script.uptimeflare_worker "$TF_VAR_CLOUDFLARE_ACCOUNT_ID/uptimeflare_worker" || echo "WARNING: Worker script import failed, continuing..." + terraform import cloudflare_workers_cron_trigger.uptimeflare_worker_cron "$TF_VAR_CLOUDFLARE_ACCOUNT_ID/uptimeflare_worker" || echo "WARNING: Cron trigger import failed, continuing..." terraform import cloudflare_pages_project.uptimeflare "$TF_VAR_CLOUDFLARE_ACCOUNT_ID/uptimeflare" || echo "WARNING: Pages project import failed, continuing..." else echo "KV namespace not found, first-time setup." @@ -98,40 +80,6 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} TF_VAR_CLOUDFLARE_ACCOUNT_ID: ${{ steps.fetch_account_id.outputs.account_id }} - # Terraform Cloudflare provider 4.x doesn't support durable objects, provider 5.x has unresolved issues blocking the deployment. (cloudflare/terraform-provider-cloudflare#5412) - # So I have to manually add durable objects bindings here. - - name: Add durable objects bindings - run: | - # Get current bindings - CURRENT_BINDINGS=$(curl --request GET --fail-with-body \ - --url https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts/uptimeflare_worker/settings \ - --header 'Authorization: Bearer '$CLOUDFLARE_API_TOKEN \ - --header 'Content-Type: application/json' | jq '.result.bindings' -jc) - CURRENT_BINDINGS="${CURRENT_BINDINGS:1:-1}" - echo "Current bindings: $CURRENT_BINDINGS" - - # Try migration first (required for the new durable object class, ignore failures) - curl --request PATCH \ - --url https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts/uptimeflare_worker/settings \ - --header 'Authorization: Bearer '$CLOUDFLARE_API_TOKEN \ - -F 'settings={"bindings":[{"type":"durable_object_namespace","name":"REMOTE_CHECKER_DO","class_name":"RemoteChecker"},'$CURRENT_BINDINGS'],"migrations":{"new_sqlite_classes":["RemoteChecker"],"new_tag":"v1"}}' - - # Actually add the binding - curl --request PATCH --fail-with-body \ - --url https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts/uptimeflare_worker/settings \ - --header 'Authorization: Bearer '$CLOUDFLARE_API_TOKEN \ - -F 'settings={"bindings":[{"type":"durable_object_namespace","name":"REMOTE_CHECKER_DO","class_name":"RemoteChecker"},'$CURRENT_BINDINGS']}' - - # By the ways enable logs - curl --request PATCH \ - --url https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts/uptimeflare_worker/script-settings \ - --header 'Authorization: Bearer '$CLOUDFLARE_API_TOKEN \ - --header 'Content-Type: application/json' \ - -d '{"observability":{"enabled":true}}' - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ steps.fetch_account_id.outputs.account_id }} - # Currently Terraform Cloudflare provider doesn't support direct upload, use wrangler to upload instead. - name: Upload pages run: | diff --git a/.terraform.lock.hcl b/.terraform.lock.hcl index b4fb449..858088a 100644 --- a/.terraform.lock.hcl +++ b/.terraform.lock.hcl @@ -2,24 +2,18 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/cloudflare/cloudflare" { - version = "4.19.0" - constraints = "~> 4.0" + version = "5.15.0" + constraints = "~> 5.0" hashes = [ - "h1:tgDfKsBL4SxCgq3sz28oLZ2q+Elzlt1Anpkm7kVC3q8=", - "zh:1d5315dcbd8187a3a978dc1fb08e80b6cdd353de10afe531b3d1ecb834d0dbae", - "zh:2a6e5b2e5072e442b35ce6142172e15afb835e16799d04a0054a79d3067f7560", - "zh:308c5690024a1f6797300018456a1ac781c8699fa4bc4892a8c36eb992604a26", - "zh:4286969a594396ef09ff6f6840428eef9c7dac037a3a3ef1ccae12a7a21b6655", - "zh:55cfe536e4fd76ca9a256b905ffa2823b21b5ab6288245c5295a16b03ac4d0b8", - "zh:58c74a26eef114d59d371f978131d78daa88260df2f75a2b6ec908f61dad2754", - "zh:5c8dd7ff7820fd96f64e37f5611ad8e265a9b54a04d25e03fe589470ad5d2a0f", - "zh:6501f10ee5e73ebe3cfe87d4141942c9c784c4ddcde15c6f500b8a41ca3cb174", - "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:97f83ab5ff249bd195cfae3b2521f037dea2ad600d70e2917b35895db67c034f", - "zh:b340b815cc53f197b42d9e64acb6729914706d93dcf60da02e3ac53aeadfde14", - "zh:c49c8f4908b5776f52211e41880a98a18ebf558363b69ed6af461f2c0d5c9e00", - "zh:d0ec9cec6a169b160825b0c585b56d42175871549bb291b9409b36086e9e6756", - "zh:f6912037890a4777a2f8b55858f38fb3428ac53610fb7297d99ced1e73531d5c", - "zh:f95ca4b80b66b02f1762d314172523ad57176369b7b1aaf4e5018a32f4525582", + "h1:prHyv+irfadmobKZm1LKEBgJKSpcdWQtjUhyJdC9R1E=", + "zh:20a72bdbb28435f11d165b367732369e8f8163100a214e89ad720dae03fafa0c", + "zh:2eabd7a51fd7aafcab9861631d85c895914857e4fcd6fe2dd80bac22e74a1f47", + "zh:62828afbc1ba0e0a64bbb7d5d42ae3c2fbbaabb793010b07eba770ba91bae94f", + "zh:6693f1021e52c34a629300fbcd91f8bd4ca386fda3b45aec746b9c200c28a42c", + "zh:6873a15454b289e5baecc1d36ce8997266438761386a320753c63f13407f4a6b", + "zh:afbf4e56b3a5e5950b35b02b553313e4a2008415920b23f536682269c64ca549", + "zh:db367612900bc2e5a01c6a325e4cff9b1b04960ce9de3dd41671dda5a627ca1d", + "zh:eb7365eafc6160c3b304a9ce6a598e5400a2e779e9e2bd27976df244f79f774f", + "zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32", ] } diff --git a/deploy.tf b/deploy.tf index 76fd7de..a73972f 100644 --- a/deploy.tf +++ b/deploy.tf @@ -2,7 +2,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "~> 4" + version = "~> 5" } } } @@ -21,26 +21,53 @@ resource "cloudflare_workers_kv_namespace" "uptimeflare_kv" { title = "uptimeflare_kv" } -resource "cloudflare_worker_script" "uptimeflare" { - account_id = var.CLOUDFLARE_ACCOUNT_ID - name = "uptimeflare_worker" - content = file("worker/dist/index.js") - module = true - compatibility_date = "2025-04-02" - compatibility_flags = ["nodejs_compat"] - - kv_namespace_binding { - name = "UPTIMEFLARE_STATE" - namespace_id = cloudflare_workers_kv_namespace.uptimeflare_kv.id +resource "cloudflare_d1_database" "uptimeflare_d1" { + account_id = var.CLOUDFLARE_ACCOUNT_ID + name = "uptimeflare_d1" + primary_location_hint = "wnam" + read_replication = { + mode = "auto" } } -resource "cloudflare_worker_cron_trigger" "uptimeflare_worker_cron" { +resource "cloudflare_workers_script" "uptimeflare_worker" { + account_id = var.CLOUDFLARE_ACCOUNT_ID + script_name = "uptimeflare_worker" + main_module = "worker/dist/index.js" + content_file = "worker/dist/index.js" + content_sha256 = filesha256("worker/dist/index.js") + compatibility_date = "2025-04-02" + compatibility_flags = ["nodejs_compat"] + + observability = { + enabled = true + logs = { + enabled = true + invocation_logs = true + } + } + + bindings = [{ + name = "REMOTE_CHECKER_DO" + class_name = "RemoteChecker" + type = "durable_object_namespace" + }, { + name = "UPTIMEFLARE_STATE" + type = "kv_namespace" + namespace_id = cloudflare_workers_kv_namespace.uptimeflare_kv.id + }, { + name = "UPTIMEFLARE_D1" + type = "d1" + id = cloudflare_d1_database.uptimeflare_d1.id + }] +} + +resource "cloudflare_workers_cron_trigger" "uptimeflare_worker_cron" { account_id = var.CLOUDFLARE_ACCOUNT_ID - script_name = cloudflare_worker_script.uptimeflare.name - schedules = [ - "* * * * *", # every 1 minute, you can reduce the KV write by increase the worker settings of `kvWriteCooldownMinutes` - ] + script_name = cloudflare_workers_script.uptimeflare_worker.script_name + schedules = [{ + cron = "* * * * *" # every 1 minute, you can reduce the KV write by increase the worker settings of `kvWriteCooldownMinutes` + }] } resource "cloudflare_pages_project" "uptimeflare" { @@ -48,13 +75,30 @@ resource "cloudflare_pages_project" "uptimeflare" { name = "uptimeflare" production_branch = "main" - deployment_configs { - production { + deployment_configs = { + # SMH Cloudflare provider will throw an error without preview config + preview = { + fail_open = false + } + production = { kv_namespaces = { - UPTIMEFLARE_STATE = cloudflare_workers_kv_namespace.uptimeflare_kv.id + UPTIMEFLARE_STATE = { + namespace_id = cloudflare_workers_kv_namespace.uptimeflare_kv.id + } + } + d1_databases = { + UPTIMEFLARE_D1 = { + id = cloudflare_d1_database.uptimeflare_d1.id + } } compatibility_date = "2025-04-02" compatibility_flags = ["nodejs_compat"] + fail_open = false } } + + # SMH it will error without this build_config + build_config = { + root_dir = "/" + } } diff --git a/init.sql b/init.sql new file mode 100644 index 0000000..2cb27f4 --- /dev/null +++ b/init.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS uptimeflare ( + key VARCHAR(255) PRIMARY KEY, + value BLOB NOT NULL +); + +REPLACE INTO uptimeflare (key, value) VALUES ('state', '{"test": 1}'); + +INSERT INTO uptimeflare (key, value) VALUES ('state', '{"test": 1}') ON CONFLICT(key) DO UPDATE SET value = excluded.value; diff --git a/pages/index.tsx b/pages/index.tsx index ee6dc09..94ca3cf 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -11,6 +11,7 @@ import { Center, Text } from '@mantine/core' import MonitorDetail from '@/components/MonitorDetail' import Footer from '@/components/Footer' import { useTranslation } from 'react-i18next' +import { getFromStore } from '@/worker/src/store' export const runtime = 'experimental-edge' const inter = Inter({ subsets: ['latin'] }) @@ -74,12 +75,8 @@ export default function Home({ } export async function getServerSideProps() { - const { UPTIMEFLARE_STATE } = process.env as unknown as { - UPTIMEFLARE_STATE: KVNamespace - } - // Read state as string from KV, to avoid hitting server-side cpu time limit - const state = (await UPTIMEFLARE_STATE?.get('state')) as unknown as MonitorState + const state = await getFromStore(process.env as any, 'state') as unknown as MonitorState // Only present these values to client const monitors = workerConfig.monitors.map((monitor) => { diff --git a/worker/src/index.ts b/worker/src/index.ts index 9791593..ae02ef1 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -3,10 +3,12 @@ import { MonitorState, MonitorTarget } from '../../types/config' import { maintenances, workerConfig } from '../../uptime.config' import { getStatus, getStatusWithGlobalPing } from './monitor' import { formatStatusChangeNotification, getWorkerLocation, webhookNotify } from './util' +import { getFromStore, setToStore } from './store' export interface Env { UPTIMEFLARE_STATE: KVNamespace REMOTE_CHECKER_DO: DurableObjectNamespace + UPTIMEFLARE_D1: D1Database } const Worker = { @@ -62,18 +64,16 @@ const Worker = { } // Read state, set init state if it doesn't exist - let state = - ((await env.UPTIMEFLARE_STATE.get('state', { - type: 'json', - })) as unknown as MonitorState) || - ({ - version: 1, + let state = JSON.parse(await getFromStore(env, 'state') || '{}') as unknown as MonitorState + if (!state || Object.keys(state).length === 0) { + state = { lastUpdate: 0, overallUp: 0, overallDown: 0, incident: {}, latency: {}, - } as MonitorState) + } + } state.overallDown = 0 state.overallUp = 0 @@ -340,7 +340,7 @@ const Worker = { ) { console.log('Updating state...') state.lastUpdate = currentTimeSecond - await env.UPTIMEFLARE_STATE.put('state', JSON.stringify(state)) + await setToStore(env, 'state', JSON.stringify(state)) } else { console.log('Skipping state update due to cooldown period.') } diff --git a/worker/src/store.ts b/worker/src/store.ts new file mode 100644 index 0000000..fed56f2 --- /dev/null +++ b/worker/src/store.ts @@ -0,0 +1,17 @@ +import { Env } from "."; + +export async function getFromStore(env: Env, key: string): Promise { + const stmt = env.UPTIMEFLARE_D1.prepare("SELECT value FROM uptimeflare WHERE key = ?") + const result = await stmt.bind(key).first<{ value: string }>() + + if (!result) { + return await env.UPTIMEFLARE_STATE.get(key) + } + + return result?.value || null +} + +export async function setToStore(env: Env, key: string, value: string): Promise { + const stmt = env.UPTIMEFLARE_D1.prepare("INSERT INTO uptimeflare (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value;") + await stmt.bind(key, value).run() +}