The cloud-dev Terraform root creates an isolated, scale-to-zero development
environment. It is intentionally separate from the permanent Terraform-state
bootstrap root. GitCode is the source repository; immutable commit SHA images
are built by an operator and stored in Yandex Container Registry.
flowchart LR
Telegram["Telegram Bot API"] -->|"HTTPS webhook"| Gateway["API Gateway<br/>dev-api.example.com"]
Gateway -->|"stable/canary variables"| Blue["control-api blue<br/>private container"]
Gateway -.->|"weighted canary"| Green["control-api green<br/>private container"]
Blue --> YDB["YDB Serverless<br/>authoritative state"]
Green --> YDB
Blue --> Objects["Object Storage<br/>tenant-prefixed artifacts"]
Green --> Objects
Timer["Timer trigger"] --> Reconciler["bounded reconciler container"]
Reconciler --> YDB
Reconciler --> Dispatch["YMQ dispatch queue + DLQ"]
Dispatch -->|"YMQ trigger<br/>batch size 1"| Worker["isolated worker container<br/>concurrency 1"]
Worker --> YDB
Worker --> Objects
Timer --> Sender["bounded Telegram sender container"]
Sender --> YDB
Sender --> Telegram
Lockbox["Lockbox + KMS"] -.-> Blue
Lockbox -.-> Green
Lockbox -.-> Sender
The control slots are private and invocable only by the gateway service account. Timer and YMQ triggers use a separate invoker identity. Runtime service accounts are split by responsibility. The worker accepts a normalized YMQ trigger event over HTTP; it does not long-poll YMQ inside a serverless container. A successful HTTP response acknowledges the trigger delivery, while a non-2xx response leaves retry and DLQ behavior to the trigger.
The delivery queue is provisioned for the future push-driven sender. The MVP sender remains a bounded timer-driven scan of the ready index, which preserves the transactional YDB delivery claim contract already exercised locally.
| Concern | Owner | Lifecycle |
|---|---|---|
| Terraform state bucket and deployment-lock YDB | bootstrap/ root |
Permanent; never part of environment destroy |
| Dev folder, YDB, queues, bucket, registry, KMS, Lockbox, log group | cloud-dev foundation module |
Terraform |
| Serverless containers and triggers | runtime module | Terraform |
| Managed certificate, DNS records, API Gateway and canary variables | edge module | Terraform |
| Lockbox payload values | Operator credential store and cloud-secret-load.sh |
Outside Terraform state |
| Billing budget | Billing API or console | External prerequisite; provider has no budget resource |
| Monitoring alerts | Monitoring API or console | External prerequisite; provider has no alert resource |
The environment defaults to deletion protection for YDB, KMS, Lockbox, and
the managed certificate. Object Storage does not use force_destroy.
Copy these examples to a restricted operator directory and set mode 0600:
infra/terraform/cloud-dev/backend.hcl.example;infra/terraform/cloud-dev/cloud-dev.tfvars.example.
Neither file contains credentials, but both expose deployment identifiers. Inject provider and S3-backend credentials from the OS credential store. Never put IAM tokens, service-account keys, Telegram secrets, or subscription credentials into Terraform variables, plans, shell history, or repository files.
The procedures below assume:
export CLOUD_DEV_BACKEND_CONFIG=/secure/path/cloud-dev.backend.hcl
export CLOUD_DEV_TFVARS=/secure/path/cloud-dev.tfvars
export TERRAFORM_LOCK_YDB_CONNECTION_STRING='grpcs://...bootstrap lock database...'
export DEPLOYMENT_ENVIRONMENT=cloud-devThe budget must reference the folder, while all billable resources must remain behind the budget gate. The only intentional targeted apply in this runbook is therefore the first folder creation:
CONFIRM_FOLDER_BOOTSTRAP=sessionless-cloud-dev:folder \
./scripts/cloud-terraform.sh folder-bootstrap
export CLOUD_DEV_FOLDER_ID="$(./scripts/cloud-terraform.sh output -raw folder_id)"Do not reuse -target for ordinary deployments.
Create an active billing budget whose filter contains exactly
CLOUD_DEV_FOLDER_ID, and configure threshold notifications for the project
owners. Put its non-secret IDs into the external tfvars file, then export them:
export BILLING_ACCOUNT_ID=replace-billing-account-id
export BUDGET_ID=replace-folder-scoped-budget-id
./scripts/cloud-preflight.shThe preflight calls the Billing API and fails unless the budget is active, belongs to the configured billing account, and contains the dev folder ID. It also initializes and validates the Terraform root. This is an admission gate, not a cost cutoff: Yandex Cloud budgets notify but do not automatically stop resources.
Create a saved plan in a restricted, untracked directory, review it, and apply that exact plan under the fenced YDB deployment lock:
./scripts/cloud-terraform.sh plan \
-target=module.foundation \
-out=/secure/path/cloud-dev-foundation.tfplan
terraform -chdir=infra/terraform/cloud-dev show /secure/path/cloud-dev-foundation.tfplan
./scripts/cloud-terraform.sh apply /secure/path/cloud-dev-foundation.tfplanThis creates the registry and the empty Lockbox secret before any runtime revision refers to them.
Load values from the operator credential store into environment variables, then stream them to Lockbox. The script never puts payload values in argv or Terraform state:
export TELEGRAM_SECRET_ID="$(./scripts/cloud-terraform.sh output -raw telegram_secret_id)"
export TELEGRAM_BOT_TOKEN='loaded by credential-store command'
export TELEGRAM_WEBHOOK_SECRET='loaded by credential-store command'
export TELEGRAM_IDENTITY_HMAC_KEY='loaded by credential-store command'
./scripts/cloud-secret-load.sh
unset TELEGRAM_BOT_TOKEN TELEGRAM_WEBHOOK_SECRET TELEGRAM_IDENTITY_HMAC_KEYCopy the returned version ID into the external tfvars file. Configure Docker authentication and push all four images under the current commit SHA:
yc container registry configure-docker
CLOUD_IMAGE_TAG="$(git rev-parse HEAD)" ./scripts/cloud-images.shSet the three image-tag variables in the tfvars file to that immutable SHA.
Use a short-lived operator IAM token only in the child process environment:
export YDB_CONNECTION_STRING="$(./scripts/cloud-terraform.sh output -raw ydb_connection_string)"
export YDB_ACCESS_TOKEN_CREDENTIALS="$(yc iam create-token)"
go run ./cmd/schema-migrate
go run ./cmd/schema-migrate status
unset YDB_ACCESS_TOKEN_CREDENTIALS
./scripts/cloud-terraform.sh plan -out=/secure/path/cloud-dev.tfplan
terraform -chdir=infra/terraform/cloud-dev show /secure/path/cloud-dev.tfplan
./scripts/cloud-terraform.sh apply /secure/path/cloud-dev.tfplanCertificate issuance can remain pending while the managed DNS challenge propagates. Wait for the certificate to become issued, rerun the full plan, and apply only if the second plan contains the expected edge completion.
export CLOUD_API_URL="$(./scripts/cloud-terraform.sh output -raw api_url)"
export CONTROL_CONTAINER_URL="$(./scripts/cloud-terraform.sh output -json control_slot_urls | jq -r .blue)"
./scripts/cloud-smoke.sh
export TELEGRAM_BOT_TOKEN='loaded by credential-store command'
export TELEGRAM_WEBHOOK_SECRET='loaded by credential-store command'
./scripts/cloud-webhook.sh
unset TELEGRAM_BOT_TOKEN TELEGRAM_WEBHOOK_SECRETRecord the Git commit, image digests, migration head, reviewed plan, Terraform outputs, and smoke-test result as deployment evidence. Do not attach plans or secret-bearing command output to a public issue.
- Run
make ciandmake terraform-cilocally and wait for mirrored GitHub Actions to pass for the same commit SHA. - Push immutable images with
cloud-images.sh. - Set the inactive control slot to the new SHA and apply a reviewed plan with
canary_weight = 0. - Smoke-test the inactive slot through its private IAM URL.
- Increase
canary_weightgradually (for example 5, 25, then 100), using a separate reviewed plan and smoke/metric gate for every step. - Promote by changing
stable_slotto the candidate and returningcanary_weightto zero.
Rollback does not rebuild an image. Set stable_slot back to the previous
known-good slot, set canary_weight = 0, review the plan, apply it under the
deployment lock, and repeat smoke tests. Database changes remain
expand/migrate/contract and forward-compatible; never use an automatic down
migration as an application rollback.
Before routing real Telegram traffic, configure external Monitoring alerts for:
- control API 5xx rate and latency;
- trigger errors, retry growth, and DLQ message count;
- container execution errors, timeout, and concurrency saturation;
- YDB throttling, RU consumption, storage, and transaction errors;
- Object Storage capacity;
- Telegram delivery terminal failures.
Link alert IDs and the budget ID in the private deployment record. The Terraform provider currently exposes monitoring dashboards but not alert resources, so a successful Terraform apply alone is not proof that alerts exist. Validate them with the Monitoring API/console and execute one test alert before accepting the environment.
Destroy is deliberately two-step. First change deletion_protection = false
in the external tfvars, create and apply a normal reviewed plan, and verify that
only protection flags change. Empty or archive the artifact bucket according
to the retention decision; Terraform will not force-delete objects.
Then create a saved destroy plan and apply that exact file:
export CONFIRM_CLOUD_DESTROY="sessionless-cloud-dev:${CLOUD_DEV_FOLDER_ID}"
./scripts/cloud-terraform.sh destroy-plan -out=/secure/path/cloud-dev-destroy.tfplan
terraform -chdir=infra/terraform/cloud-dev show /secure/path/cloud-dev-destroy.tfplan
./scripts/cloud-terraform.sh destroy-apply /secure/path/cloud-dev-destroy.tfplan
unset CONFIRM_CLOUD_DESTROYThe state bucket and deployment-lock database belong to bootstrap/ and are
not destroyed by this procedure.
make terraform-ci formats and validates both roots without cloud credentials.
make test covers normalized trigger events and HTTP success/failure
semantics. make images builds every runtime image. These checks prove the
configuration contract, not Yandex Cloud behavior. The first real cloud-dev
deployment must additionally prove IAM-only private invocation, YMQ retry/DLQ,
timer triggers, certificate/DNS propagation, schema migration, canary routing,
budget scope, alert delivery, and a rollback to the previous slot.