# reqctl
Turn a vague business requirement into deployed Google Cloud infrastructure —
with a human confirming the architecture, in diagram form, before anything
gets built.
Scope: **Google Cloud only**, **Terraform** (or Ansible — see "Automation
stacks" below) as the automation stack. This is deliberate — see the parent
brief this was built from, and "Scope: single-cloud by design" below for why
it stays that way.
## Scope: single-cloud by design
reqctl only ever targets Google Cloud, and it's staying that way rather
than becoming a multi-cloud tool with AWS/Azure providers bolted on.
Reasons, stated plainly rather than left implicit:
- **Every deterministic rule in `reqctl.engine.rules` is GCP-shaped.**
CIDR carving, firewall rule semantics, machine-type sizing, required
APIs, PSA/PSC/NAT — none of this generalizes to AWS security groups or
Azure NSGs without becoming either a lowest-common-denominator abstraction
(losing the provider-specific correctness this tool is built around) or
three parallel rule sets (tripling the surface a bug in "no guessing an
API shape" could hide in).
- **The generators (`terraform_gen.py`, `ansible_gen.py`) are written
against real, verified GCP resource shapes** — `google_cloud_run_v2_service`,
`google_container_cluster`, `gcloud` CLI flags. Multi-cloud support would
mean maintaining the same "never guess an API shape" discipline against
two or three providers' resource models instead of one, with no
proportional increase in what the tool is actually for (turning a
business requirement into a *specific*, deployable architecture — not a
cloud-agnostic one).
- **This caps the addressable comparison** against multi-cloud
"emerging platforms" tooling, and that's a real, named limitation — not
something to paper over. If your organization needs AWS or Azure support,
this isn't the tool for that; it's a GCP-specific one, deliberately.
## The interactive-and-deterministic contract
This is the one rule the whole codebase is built around:
> The generated *code* may vary run to run — that's fine, that's what LLMs do.
> The *intent, architecture, and topology* must be **identical** given the
> same inputs.
Concretely:
1. **`spec.yaml` is the single source of truth.** Every decision that affects
the architecture — compute choice, region, CIDR ranges, state bucket — is
a field in `reqctl.spec.schema.RequirementSpec`. If it's not in the spec,
it cannot affect the deployed infrastructure. Chat transcripts, LLM
reasoning, and Stage 1 drafts never influence anything after Stage 2 —
only the saved spec file does.
2. **The architecture is derived by plain code, not by the LLM.**
`reqctl.engine.builder.build_architecture(spec)` takes a `RequirementSpec`
and returns an `ArchitectureGraph` with zero API calls. Run it twice on
the same spec and you get byte-identical output (`tests/test_engine_builder.py::test_build_is_deterministic`
asserts exactly this). The LLM only ever touches two things: turning free
text into *proposed* spec fields (Stage 1), and turning a *finished*
architecture into a plain-language explanation (Stage 3, optional). It
never decides subnet layout, firewall posture, or naming — that's
`reqctl.engine.rules`.
3. **A fixed, versioned rule set is applied every time.** Naming convention,
CIDR-carving algorithm, and mandatory security defaults (deny-by-default
firewall, no public IP unless explicitly requested, one least-privilege
service account per workload) live in `reqctl/engine/rules.py` as plain
functions and constants, not prose the LLM might reinterpret. Every
generated diagram and Terraform file is stamped with the ruleset's
fingerprint (`reqctl.engine.rules.ruleset_fingerprint()`) so drift between
runs is detectable.
4. **`reqctl plan <spec.yaml>` reproduces a run with zero new questions.**
Given a saved spec, Stage 3 (diagrams + confidence score) is fully
reproducible offline — no LLM call required unless you pass `--explain`.
## Deterministic technical fields (the "second hop")
The LLM's job stops at business requirement → structured spec fields
(project name, compute choice, region, CIDR base, etc. — see "Requirement
spec schema" below). Everything past that — the actual technical design —
is derived by plain code in `reqctl/engine/rules.py` and
`reqctl/engine/builder.py`. What that currently covers:
- **VPC + subnets.** One VPC, subnets carved as non-overlapping `/24`s from
`networking.cidr_base`, in sorted-name order — same spec always yields
the same ranges (`reqctl.engine.rules.carve_subnets`).
- **Firewall rules — explicit ingress *and* egress.** Every architecture
gets exactly these rules, always in this order, rendered as real
`google_compute_firewall` resources (`reqctl.engine.rules.firewall_rules`):
- `allow-internal-ingress` (priority 1000) — the VPC's own traffic can
always reach the workload. *(This was a real gap fixed while building
this: without it, an internal-only workload had no rule permitting
anything to reach it, contradicting the business diagram's own "Internal
network users → app" claim.)*
- `allow-public-ingress` (priority 900, TCP 443) — only when
`security.allow_public_ingress` is true.
- `deny-all-ingress` (priority 65534) — the mandatory floor.
- `allow-egress-default` (priority 65534) — egress is allowed by default
in this ruleset version, but stated as a real, visible rule rather than
left to GCP's undocumented implicit default.
- **Machine sizing — vCPU/RAM/disk, not just a bare type string.**
`reqctl.engine.rules.machine_type_specs()` maps GKE machine types
(`e2-micro` through `e2-standard-8`) to actual vCPU/RAM figures; unknown
types fall back to `e2-standard-2` rather than crashing. Boot disk type
(`pd-balanced`) and size (`100GB`) are deterministic defaults, applied
automatically rather than via yet another Stage 2 question — the question
set stays small on purpose (see the interactive-and-deterministic
contract above). The GCP low-level diagram now shows this directly, e.g.
*"3x e2-standard-2 (2.0 vCPU / 8.0GB RAM, 100GB pd-balanced disk)"*.
- **Cloud Run / Cloud Functions sizing** — min/max instance counts, shown
the same way (*"0-5 instances"*).
- **Resource naming, including Cloud Functions.** Every resource name is
`{project}-{resource_type}[-{qualifier}]`, normalized to GCP's naming
rules (lowercase, `[a-z0-9-]`, length caps per resource type — 63 chars
general, 30 for service accounts, 25 for VPC connectors) by
`reqctl.engine.rules.resource_name()`. A Cloud Function named from project
`acme-finance-reporting` becomes `acme-finance-reporting-fn` — always,
deterministically, no LLM involved in picking it.
- **Route tables — GCP doesn't have them the way you might expect.**
Unlike route-table-per-subnet clouds, GCP generates subnet routes and a
default internet route automatically per VPC; there's no separate route
table resource to declare for the common case, which is why `reqctl`
doesn't generate one. The one *real* related gap: this ruleset builds
**private GKE nodes** (no public IP, per the mandatory rule above), which
means they have no path to pull container images or reach the internet
without **Cloud NAT** — not yet wired up (tracked in `agenda.md`). Custom
static routes are file-for-file when a real scenario needs one (e.g.
routing PSC traffic through a specific next-hop); nothing in the current
spec schema calls for one yet.
## Pipeline
| Stage | Command | What happens |
|---|---|---|
| 1. Intake | `reqctl run <requirement.md>` or the web UI | LLM reads free text, proposes spec fields — never commits anything |
| 2. Clarify | same | A small, controlled question set (`reqctl.spec.questions.QUESTION_DEFS`) fills in whatever the LLM didn't confidently propose. Skips questions the requirement already answered. Driven identically by the CLI (`rich` prompts) and the web UI (an HTML form) — both funnel through the same `build_spec()` function, so there's one place a `RequirementSpec` gets assembled, not two. |
| 3. Business view, then GCP architecture, then confirm | `reqctl plan <spec.yaml>` or the web UI | First a **business-level** HLD/LLD (actors, the system, data flow — no cloud resource names, so a non-technical reviewer can sanity-check intent). Only then the **GCP-level** HLD/LLD (Mermaid) built by the deterministic engine, plus a confidence score. **You must explicitly approve** before anything downstream happens. |
| 4. Deploy | `reqctl deploy <spec.yaml>` (CLI only) | Generates Terraform, runs `plan`, shows it, asks for a **second** confirmation, then `apply`s. Reports exactly what was configured, mapped to the confirmed diagram. |
| 5. Ops | `reqctl ops <spec.yaml>` (CLI only) | Interactive monitoring setup (e.g. where Grafana runs); wires the interface for log/metric fetching (stubbed in this slice — see `reqctl/ops/monitoring.py`) |
Stages 1–3 are available two ways — a terminal session (`reqctl run`) or a
local browser UI (`reqctl serve`) — both calling the exact same
`reqctl.spec`/`reqctl.engine`/`reqctl.viz` functions. Stages 4–5 are
CLI-only, on purpose (see "Interactive web UI" below).
## Module boundaries
```
reqctl/
intake/ LLM only: free text -> proposed spec fields; finished plan -> explanation
spec/ The schema (source of truth), YAML load/save, Stage 2 question definitions
engine/ Deterministic: fixed rule set + spec -> ArchitectureGraph; spec -> cost estimate
(chosen + alternatives). No LLM calls.
viz/ ArchitectureGraph -> business + GCP Mermaid diagrams; spec provenance -> confidence;
static read-only HTML report
deploy/ ArchitectureGraph -> Terraform files; terraform init/plan/apply wrapper
ops/ Stage 5 monitoring setup
knowledge/ Static point-in-time GCP/Terraform facts + pricing, each behind an interface
(refreshable later)
web/ FastAPI app: thin HTTP adapter over spec/engine/viz for the interactive UI.
No spec-building, topology, or diagram logic lives here.
devtools/ Auto-generated map of reqctl's own modules (ast-based, not hand-drawn)
```
The hard rule enforced by this layout: `engine/` never imports `intake/`, and
`viz/`/`deploy/` never call the LLM. If a future change needs the LLM to
influence topology, that's a sign the spec schema is missing a field — add
the field, don't reach into the engine from intake. `reqctl/web/app.py`
follows the same rule from the other direction: every endpoint is a few
lines of glue around a function that already exists in `spec`/`engine`/`viz`
— if a web feature needs new logic, that logic goes in the shared module,
not inline in the route handler, so the CLI and the browser never drift.
Run `reqctl codemap` any time to see this derived from the actual imports,
not asserted in prose (see "Codebase map" below).
## Interactive web UI (local server)
```bash
reqctl serve
```
Starts a FastAPI app on `127.0.0.1:8765` (binds to localhost only) and opens
it in your browser with a per-session auth token embedded in the URL — see
"Auth & audit log" below. Walks all four stages, in the browser:
1. Paste a requirement (or click "Use example" — loads `examples/sample_requirement.md`
verbatim, so there's always a working, human-pasteable example) and click
**Continue**, or click **Skip** to fill in every field by hand —
the whole flow works with zero LLM calls if you'd rather not use one.
2. Answer whatever the requirement didn't already answer. Fields already known
are shown read-only with an "already told us" badge — nothing is silently
overwritten.
3. See what's changed since this project's last **approved** version (if
any — see "Spec history & versioning" below), the estimated cost (chosen
option vs. every alternative), the **business view** (plain-language
actors/system/data-flow), then the **GCP architecture** (Mermaid HLD/LLD),
then the confidence score. Optionally click **Explain this design** for an
LLM explanation of the already-built, already-deterministic architecture.
Download `spec.yaml`, or click **Approve & continue to deploy** — this
records the current spec as the newly-approved version for its project
before moving to Step 4.
4. **Deploy.** Preflight (state bucket + required-API checks, with a button
to fix either gap), generate Terraform, run `plan`, review it, then
**Apply** — gated behind both a disabled-until-planned button *and* a
native browser confirm dialog, mirroring the CLI's plan → confirm →
apply sequence. Afterward, **Check for drift** re-runs `terraform plan`
against live GCP to show whether reality still matches what was applied.
### This reverses an earlier design decision — on purpose, not quietly
Earlier versions of this README said *"Terraform generation and deploy are
not in this UI, on purpose"* — the reasoning was that `reqctl deploy` on the
CLI already *is* the confirmation gate (plan shown, second confirmation,
apply), and a browser button would be a second copy of that logic to keep in
sync, for an action that deserves a deliberate terminal session. That
decision has been **explicitly reversed** at the requester's direction. The
mitigations that made the original decision reasonable are still enforced,
just relocated to the browser:
- **`/api/deploy/apply` requires `confirm: true` in the request body.** There
is no default that applies anything. The frontend's Apply button is
disabled until a plan has actually run, and a native `confirm()` dialog is
the last gate before the request is sent.
- **The plan is always shown before apply is possible** — same as the CLI,
just rendered in the browser instead of the terminal.
- **The trust boundary is real, and stated plainly in the UI**: anything
that can reach this process can now deploy or destroy real, billable
infrastructure. That's exactly why every request now needs the per-session
token described below — do not run `reqctl serve --no-auth` on anything
other than your own machine.
## Auth & audit log
`reqctl serve` generates a fresh token at startup (`reqctl.web.auth`,
`secrets.token_urlsafe(24)`), prints it, and embeds it in the URL it opens
(`?token=...`). An `@app.middleware("http")` gate in `reqctl/web/app.py`
rejects any `/api/*` request that doesn't present the same token via an
`X-Reqctl-Token` header or `?token=` query param (constant-time compared,
`secrets.compare_digest`) — the frontend (`app.js`) reads the token once from
its own URL and attaches it to every subsequent call. `reqctl serve
--no-auth` disables this entirely; it is an explicit opt-out, never the
default.
This is deliberately **not** a user-account system — one operator, one
token, regenerated every time `serve` starts. It exists to close the
"malicious page open in another browser tab" localhost attack (anything that
can silently make a request to `127.0.0.1:8765` on your behalf), not to
support multiple distinct users or permission levels. If you need multiple
distinct users with their own isolated data, see `agenda2.md` — an
additive, optional per-client account system (`reqctl.accounts`, backed by
MongoDB) sits alongside this single-operator token, unchanged: signing in
switches spec history/audit to that client's own isolated data; staying
signed out keeps the exact single-operator behavior described above.
Every deploy-affecting action — bucket creation, API enablement, Terraform
generate/plan/apply, drift checks, spec approval — is appended to a local
JSONL log (`reqctl.audit.log_event`, written to `~/.reqctl/audit.jsonl`) from
both the CLI and the web UI, recording a timestamp, the action, the actor
(the web client's host, or `"cli"`), the project, and the outcome. The web
UI's Step 4 has an "Activity log" panel (`GET /api/audit/recent`) that reads
it back. This is **not** a tamper-evident or compliance-grade audit trail —
it's a plain file anyone with filesystem access to this machine can edit.
It's good enough to answer "did I actually run this, and when" for a single
trusted operator, which is the tool's stated scope; nothing more.
### Spec history & versioning
`reqctl.spec.history` appends each **approved** spec (not every run) to a
local, append-only JSONL file per project, keyed off a normalized
`business.project_name` (`~/.reqctl/history/<project>.jsonl`) — approval
happens at `reqctl run`'s `Confirm.ask("Approve this architecture?")` prompt
on the CLI, and at the web UI's **Approve & continue to deploy** button
(`POST /api/spec/approve`).
Before asking for approval again, both surfaces diff the current spec
against that project's last approved version (`history.diff_against_last`):
`reqctl run` prints a table of changed fields; the web UI's Step 3 shows the
same thing (`POST /api/spec/diff`, read-only — doesn't record anything by
itself). No prior approved version means nothing to diff against (shown
distinctly from "diffed and nothing changed").
This is deliberately **not** a full version-control system: append-only, one
file per project, no branching or merge. It answers "what changed since I
last approved this" and "when was this last approved" without pulling in a
database for what's meant to stay a local, single-operator tool.
## Desired vs. actual state (drift detection)
**Check for drift** on the Deploy step re-runs `terraform plan
-detailed-exitcode` against the state left by the last apply *in that same
browser session's working directory* (see `reqctl.web.deploy_session` —
keyed off a hash of the spec's own content, so revisiting the same spec's
deploy screen finds the same working directory and its state). If GCP
reality has diverged from what Terraform last applied — someone hand-edited
a resource, something was deleted outside Terraform — the plan reports
changes even though the generated `.tf` files haven't changed; that's drift,
and it's rendered in the browser next to the raw plan output. Requires a
prior successful apply in the same session; there's nothing to compare
against otherwise. **Not tested against a live GCP project** — this
environment has neither `terraform` nor `gcloud` installed, so every code
path here is reviewed and unit-tested with the subprocess call mocked, not
exercised for real. Flagged, not hidden.
## Static HTML report (read-only, no server)
Pass `--html <path>` to `reqctl plan` (or `reqctl run`) to also write a
single static `.html` file — business + GCP diagrams and the confidence
panel, rendered with Mermaid.js from a CDN, no running process required
to view it later:
```bash
reqctl plan examples/sample_spec.yaml --html report.html
```
Opens in your default browser automatically (`--no-open` to skip). Unlike
`reqctl serve`, this is not a server and has no forms — it's a snapshot of
what `reqctl plan` already computed, for sharing or archiving.
## Codebase map
```bash
reqctl codemap --html codemap.html
```
A Mermaid diagram of every `reqctl` module, its top-level functions/classes,
and which other `reqctl` modules it imports — generated by walking the
source with Python's `ast` module (`reqctl/devtools/codebase_map.py`), not
hand-drawn. Re-run it after any change and it's exact, because it's derived
from the same source you just edited, not a separate document that can go
stale. (Deliberately not a full call graph — statically inferring which
function calls which without executing the code is unreliable in Python;
the import graph plus per-module contents is something `ast` can answer
correctly every time.)
## Cost estimate (chosen vs. alternatives)
Every Stage 3 view (CLI, web UI, static HTML report) shows a rough monthly
cost range for the confirmed compute choice, side by side with what the
*other* compute options would have cost under the same shared sizing
defaults (`reqctl.engine.rules.DEFAULT_GKE_NODE_COUNT`, etc.) — so the
tradeoff is visible before you deploy anything, not something you have to
go find in a separate pricing calculator. Most requirement-to-infrastructure
tools skip this entirely.
Same determinism contract as the rest of the engine: `reqctl.engine.cost_estimator.estimate_cost(spec)`
is plain code with no LLM involvement, pulling figures from
`reqctl.knowledge.pricing` — a static, versioned snapshot behind a
`PricingSource` interface (same refreshable-later pattern as
`reqctl.knowledge.gcp_facts`). It deliberately does not model real traffic,
data transfer, or discounts — that would be false precision. What it gives
instead: a directional low/high range per option, with the reasoning for
each shown alongside it (e.g. *"0 min instances always-on (floor) to 5 max
instances at peak (ceiling), $0.05/instance-hour"*), and a plain statement
when a number can't be estimated honestly (a `managed_service` compute
choice shows "varies too widely to estimate" rather than a made-up figure).
Beyond the compute range itself, `CostEstimate.line_items` adds:
- **Cloud NAT** (GKE only, since that's the only compute type reqctl
generates a NAT gateway for) — the flat per-gateway-hour charge; the
per-GB data-processing charge isn't estimated, since it depends on actual
traffic this tool has no visibility into.
- **Network egress** and **Cloud Logging** — small, explicitly-labeled
traffic/volume *assumptions* (e.g. "assumes 10 GB/month egress"), not
measurements. Logging is netted against the real free tier, so a light
workload correctly shows $0.
- **Grafana** — a *real* cost, not a guess: since `cloud_run`/
`compute_engine_vm` Grafana deployments are now actually generated
resources (see "Stage 5: logs, metrics, Grafana"), their cost is the same
sizing math as any other compute line, not a placeholder.
No load-balancer line item: reqctl doesn't generate an actual load-balancer
resource for any compute type, so costing one would mean inventing a charge
for infrastructure that isn't there. The underlying pricing table
(`reqctl.knowledge.pricing`) is still a static, hand-maintained snapshot —
a live GCP Pricing API refresh remains unbuilt.
## Compliance checklist (not a certified assessment)
Every Stage 3 view (CLI, web UI, static HTML report) also shows a fixed,
six-control checklist (`reqctl.engine.compliance.compute_compliance`) —
deny-by-default firewall, reviewed public exposure, least-privilege
service account, no long-lived credential key, encryption at rest, and the
audit trail's own honest limits — each tagged with which of SOC2/HIPAA/PCI
it's commonly relevant to. Deterministic, same contract as everything else
in `reqctl.engine`: checked against the spec and the generated
`ArchitectureGraph`, no LLM involved.
**This is explicitly not a certified compliance assessment**, and the
disclaimer shown alongside the score says so on every render: it's six
controls this tool can actually verify from its own generated
architecture, not a claim of certification against any framework's full
control set. Some controls (the mandatory ones — deny-by-default,
least-privilege service accounts) will always PASS, since they're
enforced by `reqctl.engine.rules` regardless of spec choices; the audit
trail control is deliberately capped at WARN, never PASS, since
`reqctl.audit`'s local JSONL log is real but not tamper-evident.
## Requirement spec schema
See `reqctl/spec/schema.py` (heavily commented) for the authoritative
definition, and `examples/sample_spec.yaml` for a filled-in example produced
from `examples/sample_requirement.md`.
Every field also has an entry in `spec.field_sources` recording whether it
was `user`-confirmed, `requirement`-inferred by the LLM, or left at a
`default` — this provenance is what `reqctl.viz.confidence` scores, so the
confidence number is computed from data you can inspect, not from asking the
LLM to grade its own homework.
## LLM access (Stage 1 intake + Stage 3 reasoning)
Two ways to authenticate — reqctl auto-detects which is available and prefers
the first:
1. **Claude Code CLI** (`reqctl.intake.backends.ClaudeCodeCLIBackend`) — if
the `claude` binary is on `PATH` (or `CLAUDE_CODE_EXECPATH` is set, as it
is inside a Claude Code session), reqctl shells out to `claude -p` and
reuses whatever that CLI is already logged in with. **No
`ANTHROPIC_API_KEY` required** on a machine that already has Claude Code
set up.
2. **Direct Anthropic API** (`AnthropicSDKBackend`) — falls back to this if
the CLI isn't found. Needs `ANTHROPIC_API_KEY` in the environment.
Force one explicitly with `REQCTL_LLM_BACKEND=claude_code` or
`REQCTL_LLM_BACKEND=anthropic_sdk`. Either way, both backends implement the
exact same interface (`reqctl.intake.backends.LLMBackend`) and share the same
prompt/schema/rules text — which one answered is invisible to every caller.
## Running it
```bash
pip install -e .
# LLM access: nothing to set up if the `claude` CLI is already installed and
# logged in (see above). Otherwise: export ANTHROPIC_API_KEY=...
# Neither is required at all if you use "Skip" in the UI / manual answers.
# Optional: per-client accounts (agenda2.md Feature 1) need a MongoDB instance.
# Not required to use reqctl at all -- signing in is opt-in; staying signed out
# keeps the single-operator behavior described above unchanged.
# docker run -d -p 27017:27017 mongo:7
# Local browser UI: intake -> clarify -> business + GCP diagrams -> confirm -> download spec.yaml
reqctl serve
# Or the terminal equivalent, full pipeline: intake -> clarify -> visualize -> confirm
reqctl run examples/sample_requirement.md --spec-out spec.yaml
# Reproduce Stage 3 from a saved spec (no LLM call unless --explain)
reqctl plan examples/sample_spec.yaml
reqctl plan examples/sample_spec.yaml --explain
reqctl plan examples/sample_spec.yaml --html report.html # static viewer, see below
# Stage 4: preflight (bucket + required APIs) + generate + (optionally) apply Terraform
reqctl deploy examples/sample_spec.yaml --out-dir terraform/
reqctl deploy examples/sample_spec.yaml --skip-preflight # CI use: skip the bucket/API checks
# Stage 5: monitoring setup (CLI only)
reqctl ops examples/sample_spec.yaml
# Codebase map (dev-facing, not part of the pipeline)
reqctl codemap --html codemap.html
```
`reqctl deploy` degrades gracefully if the `terraform` binary isn't on
`PATH`: it still generates the `.tf` files and tells you to review them by
hand, rather than pretending a deploy happened. `reqctl serve`'s **Continue**
(AI) step degrades the same way if neither LLM backend is available — a
clear in-page error, and **Skip** needs no LLM access at all.
## Preflight checks (state bucket + required GCP APIs)
Two of the most common first-deploy failures — the Terraform state bucket
doesn't exist yet, or a required API (`servicenetworking.googleapis.com` for
PSA, `container.googleapis.com` for GKE, etc.) isn't enabled on the target
project — used to fail silently deep inside `terraform apply` with no
guidance. `reqctl deploy` (and the web UI's Deploy step) now checks both
first, via `reqctl.deploy.gcloud_preflight` — same policy as
`reqctl.deploy.runner` for Terraform: shell out to a CLI (`gcloud` here),
degrade gracefully with a clear message if it isn't installed, never claim a
check passed that wasn't actually run.
- **Required APIs are computed deterministically**, not guessed —
`reqctl.engine.rules.required_apis(compute_type, enable_private_service_access)`
derives the exact list from the spec's own choices (e.g. Cloud Functions
needs `cloudfunctions`, `run`, `cloudbuild`, `artifactregistry`, and
`vpcaccess` — it's backed by Cloud Run and needs a VPC connector, both
invisible if you only think "Cloud Functions").
- **Read-only by default; every fix is a separate, explicit action.**
`reqctl deploy` prompts before creating the bucket and before enabling
APIs (two separate `Confirm.ask()`s); the web UI's "Fix the gaps above"
button does the same via `/api/deploy/bootstrap`, gated by
`create_bucket`/`enable_apis` flags the browser sets explicitly.
- **Not tested against a live GCP project.** This environment has no
`gcloud` install, so — like the Terraform templates — this is reviewed and
unit-tested with the subprocess call mocked, not exercised for real.
## What's generated vs. what's stubbed
`reqctl.deploy.terraform_gen` renders the VPC + subnet + PSA + firewall +
service account, **plus the actual compute resource** for the workload:
| `compute.type` | What gets generated |
|---|---|
| `cloud_run` | `google_cloud_run_v2_service` — direct VPC egress via `network_interfaces`, min/max instance scaling from the spec, `allUsers` invoker binding only if `allow_public_ingress` |
| `gke` | `google_container_cluster` (private nodes, private control-plane endpoint, master-authorized-networks) + `google_container_node_pool` sized from the spec |
| `cloud_functions` | `google_cloudfunctions2_function` (2nd gen) + a `google_vpc_access_connector` for VPC reachability + a placeholder source archive (`reqctl` ships a minimal "replace me" handler so the stack is genuinely deployable, not referencing a source that doesn't exist) |
| `managed_service` | **Not generated.** Too heterogeneous (Cloud SQL vs. Memorystore vs. anything else) to produce a generic resource for honestly — `reqctl deploy` fails with a clear message rather than a made-up resource, same policy as `reqctl.engine.cost_estimator`'s "varies too widely to estimate." |
Beyond the compute resource itself, two more real gaps have since been
closed:
- **Private Service Connect endpoints are deployed, not just diagrammed.**
Each `spec.networking.private_service_connect_endpoints` entry generates a
real consumer-side endpoint (`google_compute_address` + a
`google_compute_forwarding_rule` at the target service attachment, or the
`gcloud` equivalent for the Ansible stack). Producer-side service
attachments (this VPC *being* a service another VPC connects to) remain
out of scope.
- **Cloud NAT for private GKE nodes.** GKE clusters are deployed with
`enable_private_nodes = true` and no public IP — without Cloud NAT
(`google_compute_router` + `google_compute_router_nat`, or the `gcloud`
equivalent) those nodes had no path to pull container images or reach the
internet at all. Scoped to GKE only: Cloud Run/Cloud Functions use
`PRIVATE_RANGES_ONLY` egress, so only RFC1918-destined traffic goes
through the VPC and they don't need it.
## Post-deploy validation
`reqctl deploy` and the web UI's Apply button both run a real health check
immediately after a successful apply (`reqctl.deploy.validate`), instead of
treating "terraform/ansible exited 0" as proof the workload actually works:
- **Cloud Run**: `gcloud run services describe` — checks the `Ready`
condition.
- **GKE**: `gcloud container clusters describe` — checks `status ==
RUNNING`.
- **Cloud Functions**: `gcloud functions describe --gen2` — checks
`state == ACTIVE`.
- **HTTP smoke test**: when the workload has a reachable URI *and*
`security.allow_public_ingress` is true (Cloud Run/Cloud Functions only),
an HTTP request confirms it actually responds. An internal-only service
isn't reachable from wherever `reqctl` is running, so validation there is
limited to the status check above.
`--skip-validation` opts out (CI use, or when you'd rather check by hand).
`managed_service` isn't covered — same heterogeneity exception as
everywhere else in `deploy/`. Degrades to a clear "skipped, no gcloud"
message rather than failing an otherwise-successful apply. Not tested
against a live GCP project — reviewed and unit-tested with the
subprocess/HTTP calls mocked, same caveat as the rest of `deploy/`.
## Teardown (`reqctl destroy`)
`reqctl destroy <spec.yaml>` tears down everything a prior `reqctl deploy`
created, with the same shown-plan-then-confirm discipline as deploy itself
— never a silent destroy:
- **Terraform**: `terraform plan -destroy` (a real destroy plan) is shown,
then, on confirmation, `terraform apply` on that plan file
(`reqctl.deploy.runner.plan_destroy`).
- **Ansible**: a generated `destroy.yml` (importing `destroy_network.yml`
and `destroy_compute_<type>.yml`) deletes every resource in the reverse of
its creation order — compute first, then NAT/PSC, firewall rules, PSA, subnets,
the VPC itself. Every delete task tolerates "already gone" (`NOT_FOUND` in
`gcloud`'s stderr), so re-running destroy is safe.
- **Web UI**: Step 4 has a "Teardown" section mirroring Apply — "Show what
would be destroyed" (`/api/deploy/destroy-plan`), then "Destroy
everything" (`/api/deploy/destroy`, gated by `confirm: true` in the
request body plus a native browser confirm dialog).
Requires a prior successful apply in the target directory (CLI:
`--out-dir`; web: the same server-side deploy session directory apply used)
— there's nothing to destroy otherwise. Not tested against a live GCP
project, same caveat as the rest of `deploy/`.
Every generated compute resource uses a placeholder application (Google's
public "hello" image for Cloud Run, a trivial HTTP handler for Cloud
Functions, no workload for GKE beyond the node pool) — reqctl has no
application source of its own to deploy. Swap in your real image/code and
re-apply.
**Caveat:** this repo's environment has no `terraform` binary, so these
templates have not been run through `terraform validate` against a live
provider schema. They follow well-documented, standard resource shapes and
are covered by generation-level tests (subnet references resolve to real
subnets, CIDR ranges don't collide, resource names are deterministic — not
the graph's internal node id, a real bug caught while building this), but
treat them as a strong starting point for `terraform plan`, not a guarantee.
## Automation stacks: Terraform, Ansible, and Python
`spec.automation.stack` picks which tool `reqctl deploy` (and the web UI's
Deploy step) generates and runs — `reqctl.deploy.stack` dispatches to the
right generator/runner pair. All three stacks (`terraform`, `ansible`,
`python`) are real generators today — none of them silently falls back to
a different stack or fails loudly with an "unimplemented" message anymore.
- **`terraform`** (default) — `reqctl.deploy.terraform_gen` / `runner.py`, as
described above.
- **`ansible`** — `reqctl.deploy.ansible_gen` / `ansible_runner.py`. Same
resource coverage as Terraform (VPC, subnets, PSA, firewall rules, service
account, plus the real compute resource per `compute.type`), generated
from the same `ArchitectureGraph` so the two stacks can't describe
different topologies for the same spec. Every task shells out to the
`gcloud` CLI rather than the `google.cloud` Ansible collection's typed
modules (`gcp_compute_network` and friends) — this repo's sandbox has no
network access to verify that collection's exact parameters against live
documentation, and reqctl's own rule (see the `managed_service` exception
above) is to never generate infrastructure from a guessed API shape.
`gcloud` flags are stable, officially documented, and already reasoned
about deterministically elsewhere in reqctl. Each resource is
describe-checked before creation for idempotency; `gcloud run deploy` is
the one exception, since it's natively create-or-update.
**Caveat, stated plainly**: `ansible-playbook --check` does not give a
real desired-vs-actual diff the way `terraform plan` does — `command`
tasks don't execute in check mode by default, so the web UI's Step 4
"plan" for the Ansible stack shows what tasks *would* run, not a query
against live GCP state. Drift detection (`/api/deploy/drift`) is not
implemented for Ansible at all — there's no local state file to diff
against the way Terraform has one; re-running Apply is safe instead (every
task is idempotent-checked). Not tested against a live `ansible-playbook`
or `gcloud` — this environment has neither installed, so this is reviewed
and generation/unit-tested, not exercised for real.
- **`python`** — `reqctl.deploy.python_gen` / `python_runner.py`. Generates
a plain `deploy.py` + `destroy.py` (stdlib `argparse`/`subprocess` only, no
new dependency) that shell out to the `gcloud` CLI for the same resource
set as the other two stacks (VPC, subnets, PSA, firewall rules, service
account, plus the compute resource for `cloud_run`/`gke`/`cloud_functions`)
— generated from the same `_build_ansible_context()` shape Ansible uses,
so all three stacks describe one topology, never three. `deploy.py
--check` prints the `gcloud` commands that would run without executing
any of them (same caveat as Ansible's `--check`: this is a much shallower
signal than `terraform plan`'s real diff, and every module says so).
`destroy.py` tears down in reverse order and tolerates `NOT_FOUND` so
re-running is safe. `managed_service` still isn't covered — same
heterogeneity exception as the other two stacks.
Still stubbed:
- **Grafana on GKE.** `cloud_run`/`compute_engine_vm` deploy Grafana for
real (see "Stage 5: logs, metrics, Grafana" below); `gke` still fails
loudly rather than generating an unverified Kubernetes manifest. Grafana
generation for the `python` stack is also not built — documented gap, not
a silent omission.
- **Remote knowledge refresh.** `reqctl.knowledge.gcp_facts` is a hand-maintained
static snapshot behind a `KnowledgeSource` protocol; a scheduled job that
refreshes it from GCP/Terraform docs can implement that protocol without
touching any caller.
## Optional IPAM source of truth: Nautobot
`reqctl.deploy.nautobot_ipam` is an optional integration with
[Nautobot](https://docs.nautobot.com/), an open-source Network Source of
Truth / IPAM platform — not a multi-provider abstraction, just this one.
reqctl's own deterministic CIDR carving
(`reqctl.engine.rules.carve_subnets`/`carve_psa_range`/
`carve_vpc_connector_range`) is always the default and is never bypassed by
the engine itself (see "interactive AND deterministic" above — a live
Nautobot lookup cannot be allowed to make `build_architecture()`
non-deterministic). Instead, Nautobot is a *suggestion* at spec-preparation
time: set `REQCTL_NAUTOBOT_URL` + `REQCTL_NAUTOBOT_TOKEN`, and the web UI's
Stage 2 "Network address range" field gets a "Suggest from Nautobot" button
(`GET /api/ipam/suggest-cidr`) that queries Nautobot's `available-prefixes`
endpoint for a given parent prefix UUID and pre-fills the suggested CIDR —
still a plain, human-editable field either way. Not configured, unreachable,
or nothing available all degrade to the same place: the existing
`10.0.0.0/16` default, no error.
## Stage 5: logs, metrics, Grafana
`reqctl ops <spec.yaml>` records the monitoring preference (same as before)
and then, unless `--no-fetch` is passed, actually fetches:
- **Recent logs** — `reqctl.ops.monitoring.fetch_recent_logs`: a real
`gcloud logging read`, filtered to the workload's resource type
(`cloud_run_revision` for Cloud Run and 2nd-gen Cloud Functions —
they're Cloud Run under the hood — `k8s_container` for GKE).
- **A headline metric** — `reqctl.ops.monitoring.fetch_key_metrics`: CPU
utilization for Cloud Run/GKE, invocation count for Cloud Functions, via
a direct call to the Cloud Monitoring REST API's `timeSeries.list`
endpoint (there's no `gcloud` CLI subcommand for arbitrary time-series
queries — inventing one would break reqctl's own "never guess an API
shape" rule, so this calls the documented REST endpoint directly using an
access token from `gcloud auth print-access-token`, no new client
dependency).
Both degrade to a clear message if `gcloud` isn't installed, same policy as
`reqctl.deploy.gcloud_preflight`. `managed_service` isn't covered — same
heterogeneity exception as the rest of `deploy/`.
If `spec.monitoring.grafana_location` is `cloud_run` or `compute_engine_vm`,
`reqctl deploy`/`reqctl serve` now generate a **real Grafana OSS
deployment** alongside the workload (a `google_cloud_run_v2_service`
running `grafana/grafana-oss:latest`, or a `google_compute_instance` with a
Docker-based startup script) — private-by-default, no public IP, same
posture as everything else. `gke` fails loudly instead: standing up
Grafana there needs a Kubernetes Deployment+Service, which needs the
Terraform kubernetes provider (or an equivalently verified Ansible
approach) — a dependency this project doesn't otherwise take on, so it's a
documented gap rather than a guessed manifest. This stands up Grafana
itself, not a full dashboards/datasources/alerting stack — no persistent
storage or dashboard-as-code configured.
## CI/CD (`reqctl init-ci`)
```bash
reqctl init-ci spec.yaml
```
Writes `.github/workflows/reqctl.yml` (`reqctl.cicd.github_actions`), a
GitHub Actions workflow with two jobs:
- **`plan`** — runs on every pull request touching the spec file: `reqctl
plan` (deterministic — rebuilds the architecture, diagrams, cost estimate,
fails the build if the spec doesn't parse or build) plus a real `reqctl
deploy --plan-only` (generates the actual Terraform/Ansible for the spec
and shows the plan — the `--plan-only` flag stops before the apply
confirmation, so this is safe to run on every PR without ever touching
real infrastructure).
- **`deploy`** — runs only on a push to `main`, with `--auto-approve`
(applies for real).
Authenticates to GCP via Workload Identity Federation
(`google-github-actions/auth`), not a committed JSON key — reqctl can't
know your WIF pool/provider/service account ahead of time, so you create
the `GCP_WORKLOAD_IDENTITY_PROVIDER` and `GCP_SERVICE_ACCOUNT` repo secrets
yourself (see [google-github-actions/auth#setup](https://github.com/google-github-actions/auth#setup)).
`automation.stack=python` now generates and runs `deploy.py`/`destroy.py`
the same way the CLI's `reqctl deploy`/`reqctl destroy` do (see "Automation
stacks" above) — nothing python-specific needed in the workflow beyond
`gcloud` being on the runner. Not exercised against a live GitHub Actions
runner in this environment — reviewed against the documented action APIs,
same caveat as the rest of `deploy/`.
## Tests
```bash
pip install -e ".[dev]"
pytest
```
The test suite is entirely offline — no LLM calls, including the web tests
(`tests/test_web_app.py` uses FastAPI's `TestClient` and mocks the one
LLM-touching endpoint to assert it degrades gracefully). Coverage includes
an explicit determinism assertion for both the GCP engine
(`test_build_is_deterministic`) and the business-view renderer
(`test_business_views_are_deterministic`), and a codebase-map test that
would fail if the auto-generated import graph missed a real dependency.