autumn-cli 0.10.0

CLI tool for the Autumn web framework
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
# main.tf — generated by `autumn release init --target gcp-cloud-run`
#
# Provisions {{project_name}} on Google Cloud Run: an Artifact Registry
# repository (replaces the deprecated Container Registry), a VPC with a
# Serverless VPC Access connector, Cloud SQL for PostgreSQL on a private IP
# (no public exposure — reachable only through the connector), Secret
# Manager entries for the database URL and signing secret, a dedicated
# runtime service account scoped to exactly those secrets plus
# `roles/cloudsql.client`, the Cloud Run service itself, and a one-shot
# Cloud Run Job that runs `autumn migrate`. An optional Memorystore Redis
# instance is gated behind `enable_redis_cache` (off by default) — see the
# callout on it below.
#
# Secrets (`signing_secret`, `database_admin_password`) have no default in
# variables.tf. Supply them via `TF_VAR_*` environment variables or your
# CI's secret store — never commit real values here or in terraform.tfvars.
# The database connection string is NOT a variable: it's derived below from
# the Cloud SQL instance this same apply creates, so one `terraform apply`
# is enough — no second, targeted apply to fill in a value that depends on
# a resource the first apply was about to create.
#
# The Cloud Run service and migration job both start from Google's public
# Cloud Run "hello" quickstart image (`var.bootstrap_image`): Cloud Run must
# have SOME image to create the service's/job's first revision, and a
# brand-new Artifact Registry repository has none yet. Unlike App Runner's
# nginx placeholder (which needs its listening port hardcoded to match), the
# hello image already honors the `PORT` env var Cloud Run injects to match
# `containers.ports.container_port` below, so no bootstrap-specific port
# workaround is needed. CI (gcp-deploy.yml) swaps in the real image on every
# deploy via `gcloud run services update` / `gcloud run jobs update`, and
# Terraform is told to ignore that drift (see the `lifecycle` blocks below)
# so a later `terraform apply` doesn't revert a live deploy back to the
# placeholder.

terraform {
  required_version = ">= 1.5"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 6.0"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
  }
}

provider "google" {
  project = var.project_id
  region  = var.region
}

# Cloud Run's default URL includes the project NUMBER (not the project ID)
# since Google's 2022 URL format change — see local.service_url_host below.
# Reading it via a data source (rather than requiring the operator to look
# it up and pass it as a variable) means the trusted-hosts env var is
# derivable at plan time, with no chicken-and-egg problem the way App
# Runner's own assigned subdomain has (App Runner has nothing to derive its
# hostname from until the service is actually created).
#
# This read hits the Cloud Resource Manager API, which a fresh project may
# not have enabled yet — depends_on defers it until after
# google_project_service.apis has enabled cloudresourcemanager.googleapis.com,
# rather than letting Terraform evaluate it during the initial refresh/plan
# (before any resource in this configuration has run) and fail with
# SERVICE_DISABLED.
data "google_project" "this" {
  project_id = var.project_id

  depends_on = [google_project_service.apis]
}

# Secret Manager secret IDs and the Cloud SQL instance name are soft-deleted
# / reserved for a retention window after deletion — a bare re-apply under
# the same app_name inside that window would otherwise collide with the
# just-deleted resource. This suffix keeps repeat applies stable while
# avoiding that collision.
resource "random_id" "suffix" {
  byte_length = 4
}

locals {
  # Every GCP resource name this scaffold touches (Cloud Run service/job,
  # Artifact Registry repository, VPC/connector, Cloud SQL instance, the
  # service account) requires lowercase RFC 1035 labels — lowercase
  # alphanumerics and hyphens, must start with a letter, must not end in a
  # hyphen — well below what a Cargo package name allows (which may contain
  # underscores or uppercase letters). Sanitize once here instead of
  # re-deriving it per resource: map anything else to a hyphen, collapse
  # hyphen runs, trim a leading/trailing hyphen, and fall back to a fixed
  # "app" prefix when that leaves nothing or leaves a value not starting
  # with a letter (a leading digit survives sanitization untouched, but
  # every one of these resource types requires a letter first).
  app_name_hyphenated_raw = trim(
    replace(lower(replace(var.app_name, "/[^a-zA-Z0-9-]/", "-")), "/-+/", "-"),
    "-"
  )
  app_name_hyphenated = (
    local.app_name_hyphenated_raw == "" ? "app" : (
      can(regex("^[a-z]", local.app_name_hyphenated_raw)) ? local.app_name_hyphenated_raw : "app-${local.app_name_hyphenated_raw}"
    )
  )
  # Capped at 40 characters — comfortable headroom under Cloud Run
  # service/job (63-char RFC 1035 label) and Artifact Registry (63-char)
  # limits, leaving room for the longest suffix appended below ("-migrate",
  # 8 characters) plus Cloud SQL's own "-pg-" + 8-hex-char random_id suffix.
  app_name_safe = trim(substr(local.app_name_hyphenated, 0, 40), "-")

  # Service account IDs must be 6-30 characters, lowercase alphanumerics and
  # hyphens, and start with a lowercase letter. app_name_safe is capped at
  # 40 above, so trimming to 26 here plus the fixed "-sa" (3 chars) always
  # fits under 30; a short app name (e.g. the "a" in a single-letter Cargo
  # package) could otherwise produce a base under 6 characters, so pad it
  # out when needed.
  service_account_id_base = "${substr(local.app_name_hyphenated, 0, 26)}-sa"
  service_account_id = (
    length(local.service_account_id_base) < 6
    ? "${local.service_account_id_base}-000"
    : local.service_account_id_base
  )

  # Serverless VPC Access connector names must be under 21 characters where
  # each hyphen counts as TWO characters toward that limit (an undocumented
  # quirk of the underlying API, not the usual RFC 1035 label rule every
  # other resource here follows) — e.g. "abcdefghij-connector" (a 10-letter
  # base) is already at the 21-character weighted boundary and gets
  # rejected. Stripping hyphens from the base entirely sidesteps the
  # weighting question: with no hyphens left, weighted length equals raw
  # character count, so an N-character base plus the fixed "-connector"
  # suffix (weighted 2 + 9 = 11) just needs N <= 9 to stay under 21.
  connector_name_safe = substr(replace(local.app_name_hyphenated, "-", ""), 0, 9)

  # Memorystore instance IDs are capped at 40 characters — app_name_safe
  # alone is already capped at 40, so appending the fixed "-redis" suffix
  # (6 characters) can overflow it for any sanitized name longer than 34
  # characters. Its own shorter budget here leaves exactly that headroom.
  redis_name_safe = trim(substr(local.app_name_hyphenated, 0, 34), "-")

  # Cloud Run's default URL is "https://<service>-<project number>.<region>.run.app"
  # (not the project ID) — a format Google switched to in 2022 specifically
  # so a deleted-and-recreated service under someone else's project can't be
  # squatted at the old URL. Unlike App Runner's assigned-at-creation
  # subdomain, this is fully derivable at plan time from the project number
  # (data.google_project.this.number) plus the sanitized service name, so
  # AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS never needs a second apply or a
  # post-create CLI call the way App Runner's does.
  service_url_host = "${local.app_name_safe}-${data.google_project.this.number}.${var.region}.run.app"

  # A fresh Cloud SQL Postgres instance already reserves "postgres" and
  # "cloudsqladmin" (Cloud SQL's own management database), plus
  # "template0"/"template1" — every Postgres cluster, on any host, is
  # initialized with those two as the templates new databases are cloned
  # from. A Cargo package literally named one of those (or e.g.
  # "cloudsql-admin", which sanitizes to the same underscored name) would
  # otherwise collide with them and fail `terraform apply`.
  postgres_database_name_raw = substr(replace(local.app_name_hyphenated, "-", "_"), 0, 63)
  postgres_database_name = contains(
    ["postgres", "cloudsqladmin", "template0", "template1"],
    local.postgres_database_name_raw
    ) ? "${substr(local.postgres_database_name_raw, 0, 58)}_prod" : local.postgres_database_name_raw

  postgres_admin_username = "autumnadmin"

  # APIs this scaffold's resources depend on. Enabling them here (rather
  # than assuming the project already has them on) means a fresh GCP
  # project works on the first `terraform apply` with no manual `gcloud
  # services enable` step.
  # redis.googleapis.com is deliberately conditional, not always-on: every
  # core resource (the VPC, Artifact Registry, the service account, Secret
  # Manager) is ordered behind this ENTIRE for_each set via depends_on, so
  # unconditionally including an API here means a project/org policy that
  # merely disallows Redis (with enable_redis_cache left at its default
  # false — Redis never even requested) would fail the whole apply, not
  # just the optional Redis piece.
  required_apis = toset(concat(
    [
      "run.googleapis.com",
      "sqladmin.googleapis.com",
      "secretmanager.googleapis.com",
      "vpcaccess.googleapis.com",
      "artifactregistry.googleapis.com",
      "servicenetworking.googleapis.com",
      # google_compute_network / google_compute_global_address (below) are
      # Compute Engine resources — a fresh project without this API
      # already enabled fails the very first `terraform apply` at VPC
      # creation otherwise.
      "compute.googleapis.com",
      # google_service_account.cloud_run (below) fails with
      # SERVICE_DISABLED on a fresh project without the IAM API already
      # enabled.
      "iam.googleapis.com",
      # data.google_project.this (above) reads project metadata through the
      # Cloud Resource Manager API — a fresh project without this API
      # already enabled fails at plan time with SERVICE_DISABLED, before
      # any other resource here even starts creating.
      "cloudresourcemanager.googleapis.com",
    ],
    var.enable_redis_cache ? ["redis.googleapis.com"] : []
  ))
}

resource "google_project_service" "apis" {
  for_each = local.required_apis

  project = var.project_id
  service = each.value

  # Scaffold default: `terraform destroy` shouldn't disable APIs the project
  # may use for other purposes outside this scaffold.
  disable_on_destroy = false
}

# ── Networking ──────────────────────────────────────────────────────────────

resource "google_compute_network" "this" {
  name                    = "${local.app_name_safe}-vpc"
  auto_create_subnetworks = false

  # Without this, Terraform has no reason to order network creation after
  # API enablement (nothing here references an attribute of
  # google_project_service.apis) and may create it concurrently — on a
  # fresh project where Compute Engine isn't enabled yet, that races the
  # enablement and fails the first `terraform apply`. Every other resource
  # here that transitively depends on this network (the PSA global address,
  # the VPC connector, Cloud SQL, Redis) inherits the correct ordering
  # through their own reference to google_compute_network.this, so this is
  # the only explicit dependency needed.
  depends_on = [google_project_service.apis]
}

# Reserves an internal IP range for the private services access peering
# below — Cloud SQL's private IP and (when enabled) Memorystore Redis both
# live in this peered range, never on the public internet.
#
# `address` is set explicitly (not left for Google to auto-allocate) so
# this range is deterministically known and can never overlap
# `var.vpc_connector_cidr` (default 10.8.0.0/28): both this peering range
# and the connector's range are independently scheduled after network
# creation, so if Google auto-picked this /16 and it happened to contain
# the connector's CIDR, one of the two creations would fail with an
# overlap error. 10.100.0.0/16 is comfortably outside the 10.8.0.0/28
# default — if you change `vpc_connector_cidr`, pick something outside
# 10.100.0.0/16 too.
resource "google_compute_global_address" "private_service_access" {
  name          = "${local.app_name_safe}-psa"
  purpose       = "VPC_PEERING"
  address_type  = "INTERNAL"
  address       = "10.100.0.0"
  prefix_length = 16
  network       = google_compute_network.this.id
}

resource "google_service_networking_connection" "this" {
  network                 = google_compute_network.this.id
  service                 = "servicenetworking.googleapis.com"
  reserved_peering_ranges = [google_compute_global_address.private_service_access.name]

  depends_on = [google_project_service.apis]
}

# Serverless VPC Access connector — the ONLY path Cloud Run has into the VPC
# (and therefore into private-IP Cloud SQL/Redis). `ip_cidr_range` must be an
# unused /28 in the VPC, distinct from the private-service-access range
# above.
resource "google_vpc_access_connector" "this" {
  name          = "${local.connector_name_safe}-connector"
  region        = var.region
  network       = google_compute_network.this.name
  ip_cidr_range = var.vpc_connector_cidr

  depends_on = [google_project_service.apis]
}

# ── Artifact Registry ────────────────────────────────────────────────────────

resource "google_artifact_registry_repository" "this" {
  repository_id = local.app_name_safe
  location      = var.region
  format        = "DOCKER"

  depends_on = [google_project_service.apis]
}

# ── Service account ─────────────────────────────────────────────────────────
# Runtime identity for BOTH the Cloud Run service and the migration job.
# Least privilege: `roles/cloudsql.client` (needed to reach the private-IP
# instance through the connector) plus per-secret `secretAccessor` grants
# below (never a project-wide `roles/secretmanager.secretAccessor`, which
# would let a compromised container read every secret in the project).
resource "google_service_account" "cloud_run" {
  account_id   = local.service_account_id
  display_name = "Runtime identity for {{project_name}} on Cloud Run"

  # Without this, Terraform has no reason to order service-account creation
  # after the IAM API is enabled (nothing here references an attribute of
  # google_project_service.apis) — on a fresh project this fails with
  # SERVICE_DISABLED.
  depends_on = [google_project_service.apis]
}

resource "google_project_iam_member" "cloudsql_client" {
  project = var.project_id
  role    = "roles/cloudsql.client"
  member  = "serviceAccount:${google_service_account.cloud_run.email}"
}

# ── Cloud SQL ────────────────────────────────────────────────────────────────

resource "google_sql_database_instance" "this" {
  name                = "${local.app_name_safe}-pg-${random_id.suffix.hex}"
  database_version    = "POSTGRES_16"
  region              = var.region
  # Scaffold default: lets `terraform destroy`/recreate cycles run without
  # a manual `deletion_protection` override first. Set to `true` before a
  # real production cutover.
  deletion_protection = false

  settings {
    tier = var.db_tier

    ip_configuration {
      ipv4_enabled = false
      private_network = google_compute_network.this.id
      enable_private_path_for_google_cloud_services = true
    }

    backup_configuration {
      enabled = true
    }
  }

  depends_on = [google_service_networking_connection.this]
}

resource "google_sql_database" "this" {
  name     = local.postgres_database_name
  instance = google_sql_database_instance.this.name
}

resource "google_sql_user" "this" {
  name     = local.postgres_admin_username
  instance = google_sql_database_instance.this.name
  password = var.database_admin_password
}

# ── Secret Manager ──────────────────────────────────────────────────────────

# AUTUMN_DATABASE__PRIMARY_URL — derived from the Cloud SQL instance this
# same apply creates (not a variable), so a single `terraform apply`
# provisions the database AND the secret that points at it. Uses the
# instance's PRIVATE IP directly (not the Cloud SQL Auth Proxy's unix
# socket) since the VPC connector already gives Cloud Run a private network
# path to it.
resource "google_secret_manager_secret" "database_url" {
  secret_id = "${local.app_name_safe}-database-url-${random_id.suffix.hex}"

  replication {
    auto {}
  }

  depends_on = [google_project_service.apis]
}

resource "google_secret_manager_secret_version" "database_url" {
  secret      = google_secret_manager_secret.database_url.id
  secret_data = "postgres://${local.postgres_admin_username}:${urlencode(var.database_admin_password)}@${google_sql_database_instance.this.private_ip_address}:5432/${local.postgres_database_name}?sslmode=require"

  # Unlike AWS RDS (where the master password lives directly on the DB
  # instance resource, so referencing aws_db_instance.this.address here
  # already creates an implicit ordering edge), Cloud SQL splits the
  # instance and its user credentials into two independent resources —
  # google_sql_database_instance.this (referenced above, for the private
  # IP) and google_sql_user.this (which actually owns `password`). Without
  # this explicit dependency, rotating database_admin_password gives
  # Terraform no reason to update google_sql_user.this before this secret
  # version — the two can apply in either order or concurrently, so the
  # Cloud Run revision this secret version triggers (see the
  # secret_key_ref version pin below) could start using the NEW password
  # before Cloud SQL has actually accepted it. Ordering the secret version
  # after the user update closes that specific race, though it doesn't
  # eliminate a brief reconnect window for OLD revisions still holding the
  # password that's being replaced in place — a full zero-downtime rotation
  # would need a staged second database user, out of scope for this
  # scaffold.
  depends_on = [google_sql_user.this]
}

# AUTUMN_SECURITY__SIGNING_SECRET — required in production; see
# docs/guide/signing-secrets.md. Generate with `openssl rand -hex 32`.
resource "google_secret_manager_secret" "signing_secret" {
  secret_id = "${local.app_name_safe}-signing-secret-${random_id.suffix.hex}"

  replication {
    auto {}
  }

  depends_on = [google_project_service.apis]
}

resource "google_secret_manager_secret_version" "signing_secret" {
  secret      = google_secret_manager_secret.signing_secret.id
  secret_data = var.signing_secret
}

resource "google_secret_manager_secret_iam_member" "database_url_access" {
  secret_id = google_secret_manager_secret.database_url.secret_id
  role      = "roles/secretmanager.secretAccessor"
  member    = "serviceAccount:${google_service_account.cloud_run.email}"
}

resource "google_secret_manager_secret_iam_member" "signing_secret_access" {
  secret_id = google_secret_manager_secret.signing_secret.secret_id
  role      = "roles/secretmanager.secretAccessor"
  member    = "serviceAccount:${google_service_account.cloud_run.email}"
}

# ── Optional Memorystore Redis ───────────────────────────────────────────────
# Gated behind `enable_redis_cache`, off by default so a plain deploy incurs
# no extra cost.
#
# This provisions the infrastructure and wires AUTUMN_CACHE__BACKEND /
# AUTUMN_CACHE__REDIS__URL into the Cloud Run service, but that alone does
# NOT make the app use it: Autumn's cache subsystem has no built-in Redis
# implementation (unlike sessions/channels/jobs, which activate from config
# alone once compiled with the `redis` Cargo feature). Your application must
# ALSO depend on the separate `autumn-cache-redis` crate and register
# `.plugin(RedisCachePlugin::new())` in main.rs — without that, these env
# vars are parsed into config and never read by anything, the app silently
# keeps its default in-process cache, and you pay for a Memorystore instance
# that does nothing. See the "Shared Cache" section of
# docs/guide/cloud-native.md for the exact three steps.
resource "google_redis_instance" "this" {
  count = var.enable_redis_cache ? 1 : 0

  name               = "${local.redis_name_safe}-redis"
  tier               = "BASIC"
  memory_size_gb     = 1
  region             = var.region
  redis_version      = "REDIS_7_0"
  authorized_network = google_compute_network.this.id
  connect_mode       = "PRIVATE_SERVICE_ACCESS"

  # AUTH (a generated password, below) is enabled, but transit encryption is
  # deliberately left off. Memorystore's `SERVER_AUTHENTICATION` mode
  # presents a private, instance-specific CA certificate — not one chained
  # to a public root — and `autumn-cache-redis`'s `RedisCache::connect`
  # (rustls, via the `redis` crate's default TLS setup) only trusts the
  # standard public CA store, with no hook to supply a custom one. Unlike
  # AWS ElastiCache/Azure Redis Cache (both terminate TLS with
  # publicly-trusted certificates and work with the exact same client),
  # enabling this mode here would produce a `rediss://` URL the app can
  # never actually connect with — TLS handshake fails cert verification and
  # the app aborts at startup. Traffic to this instance never leaves the
  # private VPC (see `connect_mode` above and the VPC connector wiring on
  # the Cloud Run service/job), so AUTH-only is an acceptable trade-off
  # here, not a silent downgrade to an internet-exposed unencrypted link.
  auth_enabled            = true
  transit_encryption_mode = "DISABLED"

  depends_on = [google_service_networking_connection.this, google_project_service.apis]
}

resource "google_secret_manager_secret" "redis_url" {
  count = var.enable_redis_cache ? 1 : 0

  secret_id = "${local.app_name_safe}-redis-url-${random_id.suffix.hex}"

  replication {
    auto {}
  }

  depends_on = [google_project_service.apis]
}

resource "google_secret_manager_secret_version" "redis_url" {
  count = var.enable_redis_cache ? 1 : 0

  secret      = google_secret_manager_secret.redis_url[0].id
  # "redis://", not "rediss://" — see the transit_encryption_mode comment
  # on google_redis_instance.this above for why TLS is off here.
  secret_data = "redis://:${urlencode(google_redis_instance.this[0].auth_string)}@${google_redis_instance.this[0].host}:${google_redis_instance.this[0].port}"
}

resource "google_secret_manager_secret_iam_member" "redis_url_access" {
  count = var.enable_redis_cache ? 1 : 0

  secret_id = google_secret_manager_secret.redis_url[0].secret_id
  role      = "roles/secretmanager.secretAccessor"
  member    = "serviceAccount:${google_service_account.cloud_run.email}"
}

# ── Cloud Run service ─────────────────────────────────────────────────────────

resource "google_cloud_run_v2_service" "this" {
  name     = local.app_name_safe
  location = var.region
  # Scaffold default: lets `terraform destroy`/recreate cycles run without a
  # manual override first. Set to `true` before a real production cutover.
  deletion_protection = false
  # Managed, publicly reachable ingress with automatic TLS — the Cloud Run
  # analog to Azure Container Apps' `external_enabled = true` and App
  # Runner's public-by-default service URL.
  ingress = "INGRESS_TRAFFIC_ALL"

  template {
    service_account = google_service_account.cloud_run.email

    scaling {
      min_instance_count = var.min_instances
      max_instance_count = var.max_instances
    }

    # Requests per instance before Cloud Run starts another one. 80 is
    # Cloud Run's own default when this is omitted entirely; set explicitly
    # (rather than left implicit) so it's a visible, tunable knob instead of
    # an unstated platform default.
    max_instance_request_concurrency = 80

    vpc_access {
      connector = google_vpc_access_connector.this.id
      # Only route RFC 1918 (private) destinations through the connector —
      # general internet egress (a third-party API call, outbound mail)
      # keeps using Cloud Run's normal path instead of being forced through
      # the connector's own bandwidth ceiling.
      egress = "PRIVATE_RANGES_ONLY"
    }

    containers {
      # Terraform only ever sets the bootstrap placeholder here — see the
      # header comment. Real deploys land via `gcloud run services update
      # --image ...` (gcp-deploy.yml / the manual walkthrough in
      # docs/guide/deployment.md), which the `lifecycle` block below stops a
      # later `terraform apply` from reverting.
      image = var.bootstrap_image

      ports {
        container_port = 3000
      }

      # Cloud Run sends no traffic to an instance until its startup probe
      # passes, so the startup probe is the traffic gate and uses /ready.
      # /ready fails until startup is complete and while a readiness
      # dependency is down. Cloud Run stops traffic to an instance by itself
      # before SIGTERM. A slow first boot gets 2 minutes (24 x 5s). The
      # bootstrap "hello" image answers 200 on every path, so it passes too.
      startup_probe {
        period_seconds    = 5
        timeout_seconds   = 2
        failure_threshold = 24
        http_get {
          path = "/ready"
        }
      }

      # A failed liveness probe restarts the instance. /live does not fail
      # when a dependency, such as the database, is down.
      liveness_probe {
        period_seconds  = 10
        timeout_seconds = 3
        http_get {
          path = "/live"
        }
      }

      # CPU is only allocated while an instance is actively handling a
      # request by default (`cpu_idle = true`) — the cost-optimized setting,
      # and required for `min_instances = 0` scale-to-zero to actually save
      # cost between requests. Latency-sensitive workloads that do
      # background work between requests (e.g. an in-process scheduler, a
      # long-lived websocket keepalive) need the CPU allocated for the full
      # instance lifetime instead: set `cpu_idle = false` below (Cloud Run
      # then bills for the whole instance lifetime, not just request
      # processing).
      resources {
        limits = {
          cpu    = "1"
          memory = "512Mi"
        }
        cpu_idle = true
        # cpu_idle = false  # uncomment for always-allocated CPU
      }

      env {
        name  = "AUTUMN_PROFILE"
        value = "prod"
      }

      # Required: the prod profile fails fast at startup — the process
      # never binds — when security.trusted_hosts.hosts is empty (host-
      # header allow-list, see docs/guide/deployment.md#trusted-hosts-host-
      # header-allow-list). Comma-separate additional entries here (e.g. a
      # custom domain once one is bound) — the config parser reads this as
      # a CSV list.
      env {
        name  = "AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS"
        value = local.service_url_host
      }

      # version is pinned to the specific version number Terraform just
      # created, not the "latest" alias: Cloud Run resolves a secret
      # reference once, when a NEW revision is created — it never re-reads
      # "latest" for an already-running revision's already-running
      # instances. Rotating database_admin_password/signing_secret via
      # Terraform creates a new google_secret_manager_secret_version, but if
      # the env block here still read "latest", that's an unchanged literal
      # from Terraform's point of view (no diff), so no new revision would
      # be created at all — leaving every instance on the OLD secret value
      # while Cloud SQL had already started requiring the NEW password.
      # Pinning the version here means the rotation itself changes this
      # value, which Terraform sees as a diff and rolls out as a new
      # revision — keeping the whole fleet on one consistent secret value.
      env {
        name = "AUTUMN_DATABASE__PRIMARY_URL"
        value_source {
          secret_key_ref {
            secret  = google_secret_manager_secret.database_url.secret_id
            version = google_secret_manager_secret_version.database_url.version
          }
        }
      }

      env {
        name = "AUTUMN_SECURITY__SIGNING_SECRET"
        value_source {
          secret_key_ref {
            secret  = google_secret_manager_secret.signing_secret.secret_id
            version = google_secret_manager_secret_version.signing_secret.version
          }
        }
      }

      # Autumn's cache config is `[cache] backend` + `[cache.redis] url`
      # (env: AUTUMN_CACHE__BACKEND / AUTUMN_CACHE__REDIS__URL — note the
      # double underscore before URL, matching the nested `cache.redis.url`
      # TOML path). Without the backend switch, Autumn stays on its default
      # in-memory cache and never reads the URL at all, silently ignoring
      # this (paid) Memorystore instance.
      dynamic "env" {
        for_each = var.enable_redis_cache ? [1] : []
        content {
          name  = "AUTUMN_CACHE__BACKEND"
          value = "redis"
        }
      }

      dynamic "env" {
        for_each = var.enable_redis_cache ? [1] : []
        content {
          name = "AUTUMN_CACHE__REDIS__URL"
          value_source {
            secret_key_ref {
              secret  = google_secret_manager_secret.redis_url[0].secret_id
              version = google_secret_manager_secret_version.redis_url[0].version
            }
          }
        }
      }
    }
  }

  # The database_url/signing_secret/redis_url *version* resources (not just
  # the secret containers referenced by ID in the env blocks above) must
  # exist before the service is created — a `secret_key_ref` only implies a
  # dependency on the empty secret container, since that's the resource
  # actually referenced by `.secret_id` — without this, Terraform is free to
  # create the service as soon as the (still-valueless) secret exists,
  # racing the version write. The per-secret IAM grants must also exist
  # first: without `secretAccessor`, the bootstrap revision fails to resolve
  # the secret at startup and the first apply never stabilizes. Referencing
  # the redis_url resources without an index is deliberate and valid even
  # though they're `count`-gated: when `enable_redis_cache = false` they have
  # zero instances, so this depends on nothing extra; when `true`, it closes
  # the identical race for Redis that the comment above describes for the
  # other two secrets.
  depends_on = [
    google_secret_manager_secret_version.database_url,
    google_secret_manager_secret_version.signing_secret,
    google_secret_manager_secret_version.redis_url,
    google_secret_manager_secret_iam_member.database_url_access,
    google_secret_manager_secret_iam_member.signing_secret_access,
    google_secret_manager_secret_iam_member.redis_url_access,
  ]

  lifecycle {
    # CI manages the real image after bootstrap (see the header comment);
    # without this, `terraform apply` would revert every live deploy back
    # to `var.bootstrap_image`.
    ignore_changes = [template[0].containers[0].image]
  }
}

# Cloud Run services default to requiring IAM-authenticated invocations —
# without this, the deployed app would 403 every request from a browser.
# `roles/run.invoker` for `allUsers` mirrors Azure's
# `ingress.external_enabled = true` and App Runner's public-by-default
# service URL: a publicly reachable web app, same as every other target
# this scaffold supports.
resource "google_cloud_run_v2_service_iam_member" "public" {
  name     = google_cloud_run_v2_service.this.name
  location = var.region
  project  = var.project_id
  role     = "roles/run.invoker"
  member   = "allUsers"
}

# One-shot migration job — mirrors the docker-compose target's dedicated
# `migrate` service and Azure's one-shot Container Apps Job. CI
# (gcp-deploy.yml) registers the new release image on this job and runs it
# to completion via `gcloud run jobs execute --wait` *before* the app
# service is updated to the new image, so a schema-dependent release never
# serves traffic against the old schema (the generated production config
# sets `auto_migrate_in_production = false` — no running revision migrates
# on its own). Nothing ever runs this automatically; only an explicit
# `gcloud run jobs execute` call starts it.
resource "google_cloud_run_v2_job" "migrate" {
  name     = "${local.app_name_safe}-migrate"
  location = var.region
  # Scaffold default: lets `terraform destroy`/recreate cycles run without a
  # manual override first.
  deletion_protection = false

  template {
    template {
      service_account = google_service_account.cloud_run.email
      max_retries     = 0
      timeout         = "600s"

      vpc_access {
        connector = google_vpc_access_connector.this.id
        egress    = "PRIVATE_RANGES_ONLY"
      }

      containers {
        image   = var.bootstrap_image
        command = ["sh", "-lc", "autumn migrate"]

        env {
          name = "AUTUMN_DATABASE__PRIMARY_URL"
          value_source {
            secret_key_ref {
              secret  = google_secret_manager_secret.database_url.secret_id
              version = google_secret_manager_secret_version.database_url.version
            }
          }
        }
      }
    }
  }

  depends_on = [
    google_secret_manager_secret_version.database_url,
    google_secret_manager_secret_iam_member.database_url_access,
  ]

  lifecycle {
    ignore_changes = [template[0].template[0].containers[0].image]
  }
}