feat: add survey app (#422)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Zack Pollard <zackpollard@Zacks-MBP.local>
This commit is contained in:
Zack Pollard
2026-08-15 11:18:16 -04:00
committed by GitHub
co-authored by Claude Opus 4.6 Zack Pollard
parent cb47e2344a
commit f4b32da07a
214 changed files with 31512 additions and 13 deletions
+7
View File
@@ -2,3 +2,10 @@ export TF_VAR_cloudflare_account_id="op://tf/cloudflare/account_id"
export TF_VAR_cloudflare_api_token="op://tf/cloudflare/api_token"
export TF_VAR_tf_state_postgres_conn_str="op://tf/tf_state/postgres_conn_str"
export TF_VAR_env=$ENVIRONMENT
export TF_VAR_oidc_issuer="https://auth.internal.futo.org"
export TF_VAR_oidc_client_id="op://tf/FUTO_ZITADEL_OAUTH_CLIENT_ID_SURVEY/password"
export TF_VAR_oidc_client_secret="op://tf/FUTO_ZITADEL_OAUTH_CLIENT_SECRET_SURVEY/password"
export TF_VAR_oidc_client_id_dev="op://tf/FUTO_ZITADEL_OAUTH_CLIENT_ID_SURVEY_DEV/password"
export TF_VAR_oidc_client_secret_dev="op://tf/FUTO_ZITADEL_OAUTH_CLIENT_SECRET_SURVEY_DEV/password"
@@ -0,0 +1,53 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "5.18.0"
constraints = "~> 5.0"
hashes = [
"h1:B9eoAx4QKNVuKHDahNl8JzuSLCCeIGAJiS0MckJu5wQ=",
"zh:47e7bdfd8eddd2685f383269c0b6936ef62edd6d8383c8d7757b0cce0a689737",
"zh:aa23eb6aa128667883cabc449ceca4072d0181f574cd727e08ebd6d69a4bfd48",
"zh:c3da673e05d3bd933c82e2b6ba0f85aa23c5e24fadd3932f7c066314feeb65a3",
"zh:c59f07c017fc78b79e80554a0737c9db2a2e681c3e46ff637942d28d1f1a3924",
"zh:d559074612835a37fa684d8d7d0cf68911487b71f4067acc59069cb00bb8baf0",
"zh:e12290a4eda757c183a4258230245dd170f0def389c37eb771db144ce3b382dd",
"zh:ed47e484432ba1bbbb4802061f395ebd253ae8e20be9b72552d3d830fd2ca268",
"zh:f35e08d468408697b3e7c4a7f548b874141ac8f8d395ab8edded322201cc7047",
"zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32",
]
}
provider "registry.opentofu.org/hashicorp/null" {
version = "3.2.4"
hashes = [
"h1:jsKjBiLb+v3OIC3xuDiY4sR0r1OHUMSWPYKult9MhT0=",
"zh:1769783386610bed8bb1e861a119fe25058be41895e3996d9216dd6bb8a7aee3",
"zh:32c62a9387ad0b861b5262b41c5e9ed6e940eda729c2a0e58100e6629af27ddb",
"zh:339bf8c2f9733fce068eb6d5612701144c752425cebeafab36563a16be460fb2",
"zh:36731f23343aee12a7e078067a98644c0126714c4fe9ac930eecb0f2361788c4",
"zh:3d106c7e32a929e2843f732625a582e562ff09120021e510a51a6f5d01175b8d",
"zh:74bcb3567708171ad83b234b92c9d63ab441ef882b770b0210c2b14fdbe3b1b6",
"zh:90b55bdbffa35df9204282251059e62c178b0ac7035958b93a647839643c0072",
"zh:ae24c0e5adc692b8f94cb23a000f91a316070fdc19418578dcf2134ff57cf447",
"zh:b5c10d4ad860c4c21273203d1de6d2f0286845edf1c64319fa2362df526b5f58",
"zh:e05bbd88e82e1d6234988c85db62fd66f11502645838fff594a2ec25352ecd80",
]
}
provider "registry.opentofu.org/hashicorp/random" {
version = "3.8.1"
constraints = ">= 3.0.0"
hashes = [
"h1:EHn3jsqOKhWjbg0X+psk0Ww96yz3N7ASqEKKuFvDFwo=",
"zh:25c458c7c676f15705e872202dad7dcd0982e4a48e7ea1800afa5fc64e77f4c8",
"zh:2edeaf6f1b20435b2f81855ad98a2e70956d473be9e52a5fdf57ccd0098ba476",
"zh:44becb9d5f75d55e36dfed0c5beabaf4c92e0a2bc61a3814d698271c646d48e7",
"zh:7699032612c3b16cc69928add8973de47b10ce81b1141f30644a0e8a895b5cd3",
"zh:86d07aa98d17703de9fbf402c89590dc1e01dbe5671dd6bc5e487eb8fe87eee0",
"zh:8c411c77b8390a49a8a1bc9f176529e6b32369dd33a723606c8533e5ca4d68c1",
"zh:a5ecc8255a612652a56b28149994985e2c4dc046e5d34d416d47fa7767f5c28f",
"zh:aea3fe1a5669b932eda9c5c72e5f327db8da707fe514aaca0d0ef60cb24892f9",
"zh:f56e26e6977f755d7ae56fa6320af96ecf4bb09580d47cb481efbf27f1c5afff",
]
}
@@ -0,0 +1,10 @@
# Temporary: destroy orphaned analytics resources from state.
# Remove this file, the token_creator provider, and cloudflare_api_token variable
# after the first successful apply.
removed {
from = cloudflare_api_token.analytics_read
lifecycle {
destroy = true
}
}
@@ -0,0 +1,15 @@
terraform {
backend "pg" {}
required_version = "~> 1.7"
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 5.0"
}
random = {
source = "hashicorp/random"
version = ">= 3.0"
}
}
}
@@ -0,0 +1,48 @@
resource "cloudflare_d1_database" "survey" {
account_id = var.cloudflare_account_id
name = "survey${local.resource_suffix}"
read_replication = {
mode = "disabled"
}
}
resource "null_resource" "d1_migrations" {
triggers = {
migrations_hash = sha256(join("", [for f in fileset(var.migrations_dir, "*.sql") : filesha256("${var.migrations_dir}/${f}")]))
}
provisioner "local-exec" {
# Re-running an already-applied migration is benign ("already exists" /
# "duplicate column"); every other error must exit non-zero so the failure is
# visible in the workflow instead of being masked.
command = <<-EOT
set -eo pipefail
for f in $(ls ${var.migrations_dir}/*.sql | sort); do
echo "Applying migration: $f"
response=$(curl -sS -X POST \
"https://api.cloudflare.com/client/v4/accounts/${var.cloudflare_account_id}/d1/database/${cloudflare_d1_database.survey.id}/query" \
-H "Authorization: Bearer ${data.terraform_remote_state.api_keys_state.outputs.terraform_key_cloudflare_account}" \
-H "Content-Type: application/json" \
-d "{\"sql\": $(cat "$f" | jq -Rs .)}")
success=$(echo "$response" | jq -r '.success // false')
if [ "$success" = "true" ]; then
continue
fi
errors=$(echo "$response" | jq -r '.errors // [] | map(.message) | join("; ")')
if echo "$errors" | grep -qiE "already exists|duplicate column"; then
echo " (already applied): $errors"
continue
fi
echo "Migration $f failed:" >&2
echo "$response" | jq . >&2
exit 1
done
EOT
}
depends_on = [cloudflare_d1_database.survey]
}
@@ -0,0 +1,5 @@
locals {
resource_stage = var.stage != "" ? "-${var.stage}" : ""
resource_env = "-${var.env}"
resource_suffix = "${local.resource_env}${local.resource_stage}"
}
@@ -0,0 +1,9 @@
provider "cloudflare" {
api_token = data.terraform_remote_state.api_keys_state.outputs.terraform_key_cloudflare_account
}
# Temporary: needed to destroy orphaned analytics token resource from state
provider "cloudflare" {
alias = "token_creator"
api_token = var.cloudflare_api_token
}
@@ -0,0 +1,17 @@
data "terraform_remote_state" "api_keys_state" {
backend = "pg"
config = {
conn_str = var.tf_state_postgres_conn_str
schema_name = "prod_cloudflare_api_keys"
}
}
data "terraform_remote_state" "cloudflare_account" {
backend = "pg"
config = {
conn_str = var.tf_state_postgres_conn_str
schema_name = "prod_cloudflare_account"
}
}
@@ -0,0 +1,20 @@
resource "random_password" "session_secret" {
length = 64
special = false
}
resource "random_password" "password_secret" {
length = 64
special = false
}
resource "random_password" "admin_setup_token" {
length = 48
special = false
}
output "admin_setup_token" {
description = "Token required in the X-Setup-Token header when claiming the admin account."
value = random_password.admin_setup_token.result
sensitive = true
}
@@ -0,0 +1,26 @@
terraform {
source = "."
extra_arguments custom_vars {
commands = get_terraform_commands_that_need_vars()
}
}
include {
path = find_in_parent_folders("state.hcl")
}
locals {
env = get_env("TF_VAR_env")
stage = get_env("TF_VAR_stage")
app_name = replace(get_env("TF_VAR_app_name"), "-", "_")
}
remote_state {
backend = "pg"
config = {
conn_str = get_env("TF_VAR_tf_state_postgres_conn_str")
schema_name = "cloudflare_workers_immich_app_${local.app_name}_${local.env}${local.stage}"
}
}
@@ -0,0 +1,66 @@
variable "tf_state_postgres_conn_str" {}
variable "stage" {}
variable "env" {}
variable "app_name" {}
variable "cloudflare_account_id" {}
variable "dist_dir" {}
variable "migrations_dir" {}
# Leave these empty to disable OIDC.
variable "oidc_issuer" {
description = "OIDC provider issuer URL (e.g., https://auth.example.com/realms/immich)"
type = string
default = ""
}
variable "oidc_client_id" {
description = "OIDC client ID"
type = string
default = ""
}
variable "oidc_client_secret" {
description = "OIDC client secret"
type = string
sensitive = true
default = ""
}
variable "oidc_client_id_dev" {
description = "OIDC client ID of the dev_mode application used by non-production stages"
type = string
default = ""
}
variable "oidc_client_secret_dev" {
description = "OIDC client secret of the dev_mode application used by non-production stages"
type = string
sensitive = true
default = ""
}
variable "oidc_role_claim" {
description = "Claim containing the user's role. Zitadel flattens project roles into a top-level `role` claim (a single string); it never emits `groups`."
type = string
default = "role"
}
variable "oidc_role_map_admin" {
description = "Claim value that maps to admin role"
type = string
default = "survey-admin"
}
variable "oidc_role_map_editor" {
description = "Claim value that maps to editor role"
type = string
default = "survey-editor"
}
# Temporary: needed to destroy orphaned analytics token resource
variable "cloudflare_api_token" {
description = "Cloudflare API token (temporary, remove after analytics token is destroyed from state)"
type = string
sensitive = true
}
@@ -0,0 +1,205 @@
locals {
is_production = var.env == "prod" && var.stage == ""
# Production and preview authenticate against different Zitadel applications.
# Preview hostnames are per-PR and unbounded, so they can only be registered
# as a glob, which Zitadel permits only on a dev_mode application — and
# dev_mode also drops the https requirement, so it must not be enabled on the
# application production uses.
oidc_client_id = local.is_production ? var.oidc_client_id : var.oidc_client_id_dev
oidc_client_secret = local.is_production ? var.oidc_client_secret : var.oidc_client_secret_dev
# All-or-nothing: a half-populated config yields a worker that advertises
# oidcEnabled and then builds an authorize URL with an empty redirect_uri.
oidc_enabled = var.oidc_issuer != "" && local.oidc_client_id != "" && local.oidc_client_secret != ""
# Derived so it always matches the hostname this stage actually serves.
oidc_redirect_uri = "https://${module.domain.fqdn}/api/auth/callback"
oidc_bindings = concat(
local.oidc_enabled ? [
{
name = "OIDC_ISSUER"
type = "plain_text"
text = var.oidc_issuer
},
{
name = "OIDC_CLIENT_ID"
type = "plain_text"
text = local.oidc_client_id
},
{
name = "OIDC_CLIENT_SECRET"
type = "secret_text"
text = local.oidc_client_secret
},
{
name = "OIDC_REDIRECT_URI"
type = "plain_text"
text = local.oidc_redirect_uri
},
] : [],
# Only production goes SSO-only. Previews keep password auth alongside OIDC
# so a misconfigured claim leaves the stage debuggable instead of shut.
local.oidc_enabled && local.is_production ? [
{
name = "DISABLE_PASSWORD_AUTH"
type = "plain_text"
text = "true"
},
] : [],
)
api_bindings = concat(
[
{
name = "DB"
type = "d1"
id = cloudflare_d1_database.survey.id
},
{
name = "SURVEY_SESSIONS"
type = "durable_object_namespace"
class_name = "SurveyDO"
script_name = cloudflare_worker.sessions.name
},
{
name = "SESSION_SECRET"
type = "secret_text"
text = random_password.session_secret.result
},
{
name = "PASSWORD_SECRET"
type = "secret_text"
text = random_password.password_secret.result
},
{
name = "ADMIN_SETUP_TOKEN"
type = "secret_text"
text = random_password.admin_setup_token.result
},
{
name = "OIDC_ROLE_CLAIM"
type = "plain_text"
text = var.oidc_role_claim
},
{
name = "OIDC_ROLE_MAP_ADMIN"
type = "plain_text"
text = var.oidc_role_map_admin
},
{
name = "OIDC_ROLE_MAP_EDITOR"
type = "plain_text"
text = var.oidc_role_map_editor
},
],
local.oidc_bindings,
)
}
# --- Durable Object worker (deploys first) ---
resource "cloudflare_worker" "sessions" {
account_id = var.cloudflare_account_id
name = "survey-sessions${local.resource_suffix}"
observability = {
enabled = true
}
}
resource "cloudflare_worker_version" "sessions" {
account_id = var.cloudflare_account_id
worker_id = cloudflare_worker.sessions.id
compatibility_date = "2025-06-03"
main_module = "sessions.js"
modules = [{
name = "sessions.js"
content_file = "${var.dist_dir}/sessions.js"
content_type = "application/javascript+module"
}]
migrations = {
old_tag = "v2"
new_tag = "v2"
}
}
resource "cloudflare_workers_deployment" "sessions" {
account_id = var.cloudflare_account_id
script_name = cloudflare_worker.sessions.name
strategy = "percentage"
versions = [{
version_id = cloudflare_worker_version.sessions.id
percentage = 100
}]
}
resource "cloudflare_worker" "api" {
account_id = var.cloudflare_account_id
name = "survey-api${local.resource_suffix}"
observability = {
enabled = true
}
}
resource "cloudflare_worker_version" "api" {
account_id = var.cloudflare_account_id
worker_id = cloudflare_worker.api.id
compatibility_date = "2025-06-03"
main_module = "index.js"
modules = [{
name = "index.js"
content_file = "${var.dist_dir}/index.js"
content_type = "application/javascript+module"
}]
bindings = local.api_bindings
depends_on = [cloudflare_workers_deployment.sessions]
}
resource "cloudflare_workers_deployment" "api" {
account_id = var.cloudflare_account_id
script_name = cloudflare_worker.api.name
strategy = "percentage"
versions = [{
version_id = cloudflare_worker_version.api.id
percentage = 100
}]
}
data "cloudflare_zone" "immich_app" {
filter = {
account_id = var.cloudflare_account_id
name = "immich.app"
}
}
resource "cloudflare_workers_route" "survey_api_root" {
zone_id = data.cloudflare_zone.immich_app.zone_id
pattern = "${module.domain.fqdn}/api"
script = cloudflare_worker.api.name
}
resource "cloudflare_workers_route" "survey_api_wildcard" {
zone_id = data.cloudflare_zone.immich_app.zone_id
pattern = "${module.domain.fqdn}/api/*"
script = cloudflare_worker.api.name
}
module "domain" {
source = "git::https://github.com/immich-app/devtools.git//tf/shared/modules/domain?ref=main"
app_name = var.app_name
stage = var.stage
env = var.env
}