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
200 changes: 200 additions & 0 deletions infra/earnings_archive.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
# Phase 35 (35-06) — continuous earnings-call AUDIO ARCHIVE storage + scheduling.
#
# The LOCKED storage decision (35-CONTEXT) puts the growing internal earnings-call
# audio archive on the Phase 28 GCE platform, on the INGEST side of the audio
# firewall — audio must NEVER be reachable from the internet-facing serving
# project (projects.tf: ingest = "transient-audio island"; serving = "audio
# toolchain absent"). This file declares the four standing resources that make
# that real:
#
# 1. A PRIVATE GCS archive bucket in the ingest project (public access
# prevention enforced, uniform access, NO serving-project IAM binding).
# 2. A least-privilege harvest + transcription FLEET SA with objectAdmin on
# that bucket ONLY (mirrors batch.tf backfill_progress_rw least-priv shape).
# 3. The daily archive POLLER as a real Cloud Run Job (image at the
# operator-seeded Artifact Registry path, Phase 28 precedent) — declared
# FIRST so the scheduler + invoker binding reference a resource that exists.
# 4. A daily Cloud Scheduler job + its own dedicated invoker SA that :run's the
# poller Job (mirrors scheduler.tf sched_incremental → weather_incremental).
#
# D-27.9 amendment (35-01 GATE, signed `approve` 2026-07-14, NO conditions): audio
# may be RETAINED as an internal archive artifact. The operator attached NO
# retention lifecycle cap and NO source exclusions — so this bucket carries NO
# lifecycle-delete rule (a retained archive), unlike the transient
# earnings-audio-handoff bucket (cloud_run.tf, 1-day reaper) or the
# backfill-progress bucket (batch.tf, 90-day reaper). If the gate is ever
# re-signed `amend-with-conditions` with an age cap, add a lifecycle_rule here.

locals {
earnings_archive_image = {
poller = "${local.ar_image_base}/${var.image_earnings_archive_poller}:${var.image_tag}"
}
}

# =====================================================================
# Ingest-side PRIVATE archive bucket (35-06 Task 1) — audio firewall
# =====================================================================
# Retained earnings-call audio + our own SDK transcripts land here. Placed in the
# INGEST project (google_project.ingest) — never the serving project — and
# public_access_prevention = "enforced" + uniform access + NO serving-project IAM
# member is the structural firewall (T-35-07): the internet-facing serving
# surface has no path to a stored audio object. Co-located with the STT audio
# pipeline it receives copies from (upper(var.stt_region) = US-CENTRAL1, the same
# var the handoff bucket uses — keeping the D-27.9 server-side copy_blob
# same-region; review-iter-1 P3, opus). force_destroy = false: this is
# a retained archive, not a scratch bucket.
resource "google_storage_bucket" "earnings_archive" {
project = google_project.ingest.project_id
name = var.earnings_archive_bucket_name != "" ? var.earnings_archive_bucket_name : "mostlyright-earnings-archive-${google_project.ingest.number}"
location = upper(var.stt_region)
uniform_bucket_level_access = true
force_destroy = false
public_access_prevention = "enforced"

# NO lifecycle_rule: the 35-01 gate was signed `approve` with NO retention cap
# (D-27.9 amendment, no conditions) — the archive is retained indefinitely.
# Contrast the transient earnings-audio-handoff bucket (cloud_run.tf) which
# reaps at age = 1 day.

labels = {
phase = "35"
role = "earnings-archive"
}

depends_on = [google_project_service.enabled]
}

# =====================================================================
# Least-privilege harvest + transcription FLEET SA (35-06 Task 1)
# =====================================================================
# The single identity the harvest downloaders + the batch transcription fleet run
# as. It is bound to the archive bucket ONLY (objectAdmin, below) — nothing else.
# CRITICAL FIREWALL: no serving-project SA or principal is ever granted access to
# the archive bucket; audio must be unreachable from serving (T-35-07/T-35-08).
resource "google_service_account" "earnings_archive_fleet" {
project = google_project.ingest.project_id
account_id = "earnings-archive-fleet"
display_name = "Earnings archive harvest + transcription fleet runtime SA"
description = "Runs the Phase 35 harvest downloaders + batch transcription fleet + the daily archive poller. objectAdmin on the ingest-side earnings-archive bucket ONLY; no serving grant; audio never leaves ingest."

depends_on = [google_project_service.enabled]
}

# The fleet SA reads (dedup) + writes (upload) the archive bucket. objectAdmin on
# the ONE bucket only — least privilege, copied from batch.tf backfill_progress_rw.
resource "google_storage_bucket_iam_member" "earnings_archive_rw" {
bucket = google_storage_bucket.earnings_archive.name
role = "roles/storage.objectAdmin"
member = "serviceAccount:${google_service_account.earnings_archive_fleet.email}"
}

# The DEPLOYED live-capture STT service (cloud_run.tf `stt`, runtime SA
# `earnings_stt`) writes the D-27.9 retention copy (_archive_handoff_source in
# services/earnings/jobs/stt.py) into this bucket when EARNINGS_ARCHIVE_RETENTION
# is enabled — without this grant every live archive copy 403s (review-iter-1 P1,
# codex). Same objectAdmin-on-this-one-bucket shape as the fleet grant above.
# earnings_stt is an INGEST-project SA (service_accounts.tf:28), so the bucket
# still has ZERO serving-project principals — the T-35-07 audio firewall holds.
resource "google_storage_bucket_iam_member" "earnings_archive_stt_rw" {
bucket = google_storage_bucket.earnings_archive.name
role = "roles/storage.objectAdmin"
member = "serviceAccount:${google_service_account.earnings_stt.email}"
}

# =====================================================================
# Daily archive POLLER — Cloud Run Job, ingest project (35-06 Task 2)
# =====================================================================
# The 35-07 poller.py container: checks the source feeds (Castify RSS /
# AlphaStreet channel) for new calls, dedups against what is already in the
# archive bucket, and enqueues new harvest work. Declared as a REAL Cloud Run Job
# so the scheduler's :run target + invoker binding below reference a resource
# that actually exists (no dangling reference — terraform validate proves it).
# Mirrors the SHAPE of google_cloud_run_v2_job.capture (cloud_run.tf): ingest
# project, var.serving_region, deletion_protection = false, single container.
#
# OPERATOR-SEEDED IMAGE (Phase 28 precedent): exactly like the existing
# capture/stt/rolefact/weather-incremental jobs, NO Dockerfile for this job is
# committed in the SDK monorepo. The poller Dockerfile lives in the SEPARATE
# archive repo (added in 35-07 Task 2); the OPERATOR builds + pushes it to the
# reused Artifact Registry path (local.earnings_archive_image.poller) BEFORE the
# first `terraform apply`. The var.image_tag default "latest" matches the Phase
# 28 first-apply convention; CI/operator pins an immutable digest/tag per deploy.
# Reuses the fleet SA (Task 1): the poller reads the archive bucket to dedup.
resource "google_cloud_run_v2_job" "earnings_archive_poller" {
project = google_project.ingest.project_id
name = "earnings-archive-poller"
location = var.serving_region

# 35-full deploy: allow TF to replace a tainted/failed initial revision.
deletion_protection = false

template {
template {
service_account = google_service_account.earnings_archive_fleet.email
max_retries = 1

containers {
image = local.earnings_archive_image.poller

resources {
limits = {
cpu = "1"
memory = "4Gi"
}
}

# The poller targets the Task-1 archive bucket (dedup + enqueue).
env {
name = "EARNINGS_ARCHIVE_BUCKET"
value = google_storage_bucket.earnings_archive.name
}
}
}
}

depends_on = [google_project_service.enabled]
}

# =====================================================================
# Daily poller scheduler + dedicated invoker SA (35-06 Task 2)
# =====================================================================
# Cloud Scheduler is the monorepo precedent (scheduler.tf; Claude's-discretion
# choice per 35-CONTEXT resolved to Cloud Scheduler). Mirrors the SHAPE of the
# weather_incremental_daily block: a dedicated least-privilege invoker SA that
# holds run.invoker on the ONE poller Job only (T-35-09), and a daily cron that
# POSTs the Cloud Run Admin :run URI for that Job with an oauth_token minted for
# the invoker SA. Both the :run URI and the invoker binding reference the
# declared google_cloud_run_v2_job.earnings_archive_poller — no bare-name string,
# no dangling reference.
resource "google_service_account" "sched_earnings_archive" {
project = google_project.ingest.project_id
account_id = "sched-earnings-archive"
display_name = "Earnings archive daily poller scheduler invoker SA"

depends_on = [google_project_service.enabled]
}

resource "google_cloud_run_v2_job_iam_member" "sched_earnings_archive_invoker" {
project = google_project.ingest.project_id
location = var.serving_region
name = google_cloud_run_v2_job.earnings_archive_poller.name
role = "roles/run.invoker"
member = "serviceAccount:${google_service_account.sched_earnings_archive.email}"
}

resource "google_cloud_scheduler_job" "earnings_archive_daily" {
project = google_project.ingest.project_id
region = var.serving_region
name = "earnings-archive-daily"
schedule = var.earnings_archive_poll_schedule
time_zone = "Etc/UTC"

http_target {
http_method = "POST"
uri = "https://${var.serving_region}-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/${google_project.ingest.project_id}/jobs/${google_cloud_run_v2_job.earnings_archive_poller.name}:run"

oauth_token {
service_account_email = google_service_account.sched_earnings_archive.email
}
}
}
20 changes: 20 additions & 0 deletions infra/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,23 @@ output "r2_bucket" {
description = "The single platform R2 bucket derived parquet is written to / served from. Set as R2_BUCKET for the ingest deploy workflows."
value = var.r2_bucket
}

# --- Phase 35 earnings-call audio archive (35-06) ---
# The ingest-side private archive bucket, its least-priv fleet SA, and the daily
# poller Job. The archive repo's harvest + transcription tooling reads these
# (EARNINGS_ARCHIVE_BUCKET / the fleet SA); the operator seeds the poller image
# at the AR path before the first apply.
output "earnings_archive_bucket" {
description = "Ingest-side private earnings-call audio archive bucket (35-06). Audio firewall: public access prevention enforced, NO serving-project IAM. Set as EARNINGS_ARCHIVE_BUCKET for the archive repo's harvest + transcription fleet."
value = google_storage_bucket.earnings_archive.name
}

output "earnings_archive_fleet_sa_email" {
description = "Least-privilege harvest + transcription fleet SA email (35-06) — objectAdmin on the earnings-archive bucket ONLY."
value = google_service_account.earnings_archive_fleet.email
}

output "earnings_archive_poller_job" {
description = "Daily earnings-archive poller Cloud Run Job name (35-06), triggered by the earnings-archive-daily scheduler. Operator-seeded image (Phase 28 precedent)."
value = google_cloud_run_v2_job.earnings_archive_poller.name
}
23 changes: 23 additions & 0 deletions infra/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,12 @@ variable "image_weather_incremental" {
default = "weather-incremental"
}

variable "image_earnings_archive_poller" {
description = "Earnings archive daily poller image name (35-06/35-07). OPERATOR-SEEDED: no Dockerfile is committed in the SDK monorepo; the poller image is built + pushed from the separate archive repo to the reused Artifact Registry before the first apply (Phase 28 operator-seeded-image convention)."
type = string
default = "earnings-archive-poller"
}

# ---------------------------------------------------------------------------
# STT L4 GPU tuning (28-11).
# ---------------------------------------------------------------------------
Expand All @@ -397,3 +403,20 @@ variable "serving_global_rps_ceiling" {
type = number
default = 50
}

# ---------------------------------------------------------------------------
# Phase 35 — earnings-call audio archive (35-06). Private ingest-side bucket +
# daily poller cadence. The archive lands on the INGEST side of the audio
# firewall; the serving project gets NO IAM on it (35-01 GATE / D-27.9 amend).
# ---------------------------------------------------------------------------
variable "earnings_archive_bucket_name" {
description = "Optional override for the ingest-side earnings-audio archive bucket name (35-06). Empty = mostlyright-earnings-archive-<ingest project number>, matching the backfill_progress -<project number> naming convention. GCS bucket names are globally unique; set this if the default collides."
type = string
default = ""
}

variable "earnings_archive_poll_schedule" {
description = "Cloud Scheduler cron for the daily earnings-archive source poller (35-06). Default 07:00 UTC daily — after the incremental weather trigger (06:00). time_zone is pinned to Etc/UTC in earnings_archive.tf."
type = string
default = "0 7 * * *"
}
Loading
Loading