cpflow 5.1.1 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/agent-workflow.yml +26 -0
  3. data/.agents/bin/README.md +20 -0
  4. data/.agents/bin/docs +5 -0
  5. data/.agents/bin/lint +5 -0
  6. data/.agents/bin/setup +5 -0
  7. data/.agents/bin/test +5 -0
  8. data/.agents/bin/validate +5 -0
  9. data/.agents/trusted-github-actors.yml +32 -0
  10. data/.agents/workflows/ai-rollout-e2e-test.md +166 -0
  11. data/.github/actions/cpflow-setup-environment/action.yml +1 -1
  12. data/.github/actions/cpflow-wait-for-health/action.yml +87 -15
  13. data/.github/pull_request_template.md +18 -0
  14. data/.github/workflows/claude-code-review.yml +2 -0
  15. data/.github/workflows/claude.yml +94 -1
  16. data/.github/workflows/cpflow-delete-review-app.yml +621 -33
  17. data/.github/workflows/cpflow-deploy-review-app.yml +656 -21
  18. data/.github/workflows/cpflow-review-app-help.yml +5 -13
  19. data/.github/workflows/rspec-shared.yml +10 -3
  20. data/.github/workflows/rspec-specific.yml +1 -0
  21. data/.github/workflows/rspec.yml +58 -1
  22. data/AGENTS.md +14 -0
  23. data/CHANGELOG.md +54 -1
  24. data/CLAUDE.md +3 -0
  25. data/CONTRIBUTING.md +15 -3
  26. data/Gemfile.lock +1 -1
  27. data/README.md +21 -7
  28. data/docs/ai-github-flow-prompt.md +18 -16
  29. data/docs/ci-automation.md +239 -27
  30. data/docs/commands.md +30 -2
  31. data/docs/grafana-opentelemetry.md +699 -0
  32. data/docs/secrets-and-env-values.md +37 -2
  33. data/docs/sidebars.ts +70 -0
  34. data/docs/telemetry/application-instrumentation.md +161 -0
  35. data/docs/telemetry/collector.md +297 -0
  36. data/docs/telemetry/index.md +152 -0
  37. data/docs/telemetry/pipelines.md +98 -0
  38. data/docs/telemetry/review-apps.md +55 -0
  39. data/docs/telemetry/troubleshooting.md +92 -0
  40. data/docs/terraform/example/.controlplane/controlplane.yml +0 -1
  41. data/docs/terraform/overview.md +11 -0
  42. data/docs/tips.md +475 -28
  43. data/examples/controlplane.yml +2 -0
  44. data/lib/command/ai_github_flow_prompt.rb +2 -2
  45. data/lib/command/apply_template.rb +104 -2
  46. data/lib/command/base.rb +69 -5
  47. data/lib/command/deploy_image.rb +93 -7
  48. data/lib/command/promote_app_from_upstream.rb +1 -0
  49. data/lib/command/ps_wait.rb +2 -10
  50. data/lib/command/run.rb +133 -10
  51. data/lib/command/setup_app.rb +10 -5
  52. data/lib/core/config.rb +94 -0
  53. data/lib/core/controlplane.rb +38 -5
  54. data/lib/core/controlplane_api.rb +8 -0
  55. data/lib/core/controlplane_api_direct.rb +257 -63
  56. data/lib/core/doctor_service.rb +44 -3
  57. data/lib/core/shell.rb +9 -2
  58. data/lib/core/template_parser.rb +43 -9
  59. data/lib/cpflow/version.rb +1 -1
  60. data/lib/generator_templates/controlplane.yml +1 -2
  61. data/lib/github_flow_templates/.github/cpflow-help.md +34 -10
  62. data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +10 -0
  63. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +9 -0
  64. metadata +22 -2
data/docs/tips.md CHANGED
@@ -6,18 +6,29 @@
6
6
  4. [CPU](#cpu)
7
7
  5. [Remote IP](#remote-ip)
8
8
  6. [Secrets and ENV Values](/docs/secrets-and-env-values.md)
9
- 7. [CI](#ci)
10
- 8. [Logs](#logs)
11
- 9. [Memcached](#memcached)
12
- 10. [Sidekiq](#sidekiq)
9
+ 7. [Telemetry](#telemetry)
10
+ 8. [CI](#ci)
11
+ 9. [Logs](#logs)
12
+ 10. [Grafana and OpenTelemetry](#grafana-and-opentelemetry)
13
+ 11. [Memcached](#memcached)
14
+ 12. [Sidekiq](#sidekiq)
13
15
  - [Quieting Non-Critical Workers During Deployments](#quieting-non-critical-workers-during-deployments)
14
16
  - [Setting Up a Pre Stop Hook](#setting-up-a-pre-stop-hook)
15
17
  - [Setting Up a Liveness Probe](#setting-up-a-liveness-probe)
16
- 11. [Minimizing Review App Costs](#minimizing-review-app-costs)
17
- - [Scale the Web Workload to Zero](#scale-the-web-workload-to-zero)
18
+ 13. [Minimizing Non-Production App Costs](#minimizing-non-production-app-costs)
19
+ - [Share One Control Plane Postgres for Staging and Review Apps](#share-one-control-plane-postgres-for-staging-and-review-apps)
20
+ - [Enable Capacity AI for Demo and Starter Staging Apps](#enable-capacity-ai-for-demo-and-starter-staging-apps)
21
+ - [Use an Always-Available Landing Page for a Serverless App](#use-an-always-available-landing-page-for-a-serverless-app)
18
22
  - [Delete or Pause Abandoned Apps with `cleanup-stale-apps`](#delete-or-pause-abandoned-apps-with-cleanup-stale-apps)
19
23
  - [Pause and Resume with `ps:stop` / `ps:start`](#pause-and-resume-with-psstop--psstart)
20
- 12. [Useful Links](#useful-links)
24
+ 14. [Right-Sizing Non-Production Workloads](#right-sizing-non-production-workloads)
25
+ - [Enable Capacity AI on Idle Workloads](#enable-capacity-ai-on-idle-workloads)
26
+ - [Don't Autoscale Idle Workloads on CPU](#dont-autoscale-idle-workloads-on-cpu)
27
+ - [Right-Size Reserved CPU and Memory](#right-size-reserved-cpu-and-memory)
28
+ - [Drop Workloads You Don't Use](#drop-workloads-you-dont-use)
29
+ - [Share One Postgres Across Non-Production Apps](#share-one-postgres-across-non-production-apps)
30
+ - [Keep Templates as the Source of Truth](#keep-templates-as-the-source-of-truth)
31
+ 15. [Useful Links](#useful-links)
21
32
 
22
33
  ## GVCs vs. Orgs
23
34
 
@@ -114,6 +125,14 @@ So `REMOTE_ADDR` should not be used directly, only `request.remote_ip`.
114
125
  > **Warning:** Do not use `REMOTE_ADDR` for authentication, rate limiting, auditing, or IP allowlists. Always use
115
126
  > framework-specific mechanisms that understand proxy headers (such as Rails' `request.remote_ip`).
116
127
 
128
+ ## Telemetry
129
+
130
+ If your app emits OpenTelemetry, StatsD, or structured log signals, run an
131
+ OpenTelemetry Collector as a Control Plane workload in the same GVC and point
132
+ application env vars at the collector's internal service name. See the
133
+ [telemetry guide](https://www.shakacode.com/control-plane-flow/docs/telemetry/) for the template shape, recommended ports,
134
+ review-app guardrails, and troubleshooting commands.
135
+
117
136
  ## CI
118
137
 
119
138
  **Note:** Docker builds much slower on Apple Silicon, so try configuring CI to build the images when using Apple
@@ -202,6 +221,15 @@ To check for truncation, compare line count to the limit: `wc -l < incident.log`
202
221
  likely cut off. Prefer narrowing the time window (and concatenating the sub-ranges) over raising `--limit`, since the
203
222
  server-side cap may be lower than the flag value.
204
223
 
224
+ ## Grafana and OpenTelemetry
225
+
226
+ Control Plane's built-in Grafana gives useful workload metrics such as CPU, memory, restarts, and request rate. For
227
+ Rails applications that need app-level request latency, database spans, Redis spans, Sidekiq job metrics, or
228
+ trace-to-log correlation, add OpenTelemetry and an internal collector workload that exposes generated Prometheus
229
+ metrics.
230
+
231
+ See [Grafana and OpenTelemetry on Control Plane](/docs/grafana-opentelemetry.md) for the full setup guide.
232
+
205
233
  ## Memcached
206
234
 
207
235
  On the workload container for Memcached (using the `memcached:alpine` image), configure the command with the args
@@ -266,28 +294,311 @@ To do this:
266
294
 
267
295
  To set up a liveness probe on port 7433, see: https://github.com/arturictus/sidekiq_alive
268
296
 
269
- ## Minimizing Review App Costs
297
+ ## Minimizing Non-Production App Costs
270
298
 
271
299
  Long-tail review apps — PRs that linger for days or weeks with little traffic — can drive up Control Plane spend if every
272
300
  workload runs full-time. `cpflow` already provides several knobs to manage this without custom orchestration.
301
+ The same cost-control pass applies to public demos, starter staging apps, and long-lived review apps: start with
302
+ Capacity AI for app workloads, then reserve true scale-to-zero for apps where cold starts and planned migrations are
303
+ acceptable.
273
304
 
274
305
  > **Note:** Scaling workloads to zero or stopping review apps does not reduce costs from external databases, managed
275
306
  > Redis instances, object storage, or other third-party services. Those continue to bill independently of Control Plane
276
307
  > workload state.
277
308
 
278
- ### Scale the Web Workload to Zero
309
+ ### Share One Control Plane Postgres for Staging and Review Apps
310
+
311
+ For non-production Rails apps, a per-GVC Postgres workload is often the largest avoidable review-app cost. Each app can
312
+ end up with its own always-on Postgres replica and its own volume. If staging/review data can be reset, create one
313
+ shared Postgres GVC in the staging org, then point staging and review app GVCs at separate logical databases inside that
314
+ single Postgres instance.
315
+
316
+ Use separate logical databases per app or review app. Do not point multiple Rails apps at the same database/schema unless
317
+ they intentionally share migrations and data. For example:
318
+
319
+ ```text
320
+ Shared GVC (in the staging org):
321
+ staging-shared-postgres
322
+ postgres workload
323
+ shared-postgres-vs volume
324
+
325
+ Client GVCs (each points to a separate logical database):
326
+ react-webpack-rails-tutorial-staging
327
+ react-webpack-rails-tutorial-review-pr-123
328
+ react-on-rails-starter-staging
329
+ react-on-rails-starter-review-pr-123
330
+ ```
331
+
332
+ The shared Postgres workload must accept internal traffic from other GVCs. `same-gvc` is not enough when the database
333
+ lives in a separate GVC; use `same-org`, or `workload-list` if you can keep an explicit allowlist current. `same-org` is
334
+ convenient for trusted staging orgs, but every workload in the org can reach the database port, including production
335
+ workloads if production GVCs share the same org. Use `workload-list` for a tighter blast radius if you can automate
336
+ entries as review apps appear and disappear.
337
+
338
+ ```yaml
339
+ kind: volumeset
340
+ name: shared-postgres-vs
341
+ spec:
342
+ fileSystemType: ext4
343
+ initialCapacity: 10
344
+ performanceClass: general-purpose-ssd
345
+ snapshots:
346
+ createFinalSnapshot: true
347
+ retentionDuration: 7d
348
+ # Periodic snapshots need a schedule; without one, only a final snapshot is taken when the volumeset is deleted.
349
+ schedule: "0 2 * * *" # daily at 02:00 UTC; adjust to your retention needs
350
+
351
+ ---
352
+ kind: workload
353
+ name: postgres
354
+ spec:
355
+ type: stateful
356
+ containers:
357
+ - name: postgres
358
+ image: postgres:17 # pin a specific patch (e.g. postgres:17.x) for reproducible stateful deploys
359
+ cpu: 250m
360
+ memory: 512Mi
361
+ env:
362
+ - name: PGDATA
363
+ value: /var/lib/postgresql/data/pg_data
364
+ - name: POSTGRES_DB
365
+ value: postgres
366
+ - name: POSTGRES_USER
367
+ value: postgres
368
+ - name: POSTGRES_PASSWORD
369
+ # Recommended after adding the workload identity/policy binding:
370
+ # value: cpln://secret/shared-postgres-password.password
371
+ # Plain-value fallback for disposable non-production experiments only.
372
+ # Do not commit this file with a real password in place.
373
+ value: REPLACE_WITH_NON_PRODUCTION_PASSWORD
374
+ ports:
375
+ - number: 5432
376
+ protocol: tcp
377
+ volumes:
378
+ - uri: cpln://volumeset/shared-postgres-vs
379
+ path: /var/lib/postgresql/data
380
+ recoveryPolicy: retain # keep the volume if the workload is deleted; clean up manually when no longer needed
381
+ defaultOptions:
382
+ autoscaling:
383
+ metric: disabled
384
+ minScale: 1
385
+ maxScale: 1
386
+ capacityAI: false
387
+ firewallConfig:
388
+ internal:
389
+ inboundAllowType: same-org
390
+ ```
391
+
392
+ `POSTGRES_DB: postgres` initializes the administrative database for the server. Apps should use their own logical
393
+ databases, not the administrative `postgres` database.
394
+
395
+ Field note: `100m` CPU and `256Mi` memory were enough for tiny Rails migrations, but a real staging seed that inserted
396
+ hundreds of thousands of rows caused Postgres to log `server process ... terminated by signal 9: Killed`. `250m` and
397
+ `512Mi` handled the same seed while still replacing multiple always-on per-app Postgres workloads.
398
+
399
+ For review apps, keep the logical database name unique per app. `cpflow`'s default app template uses `{{APP_NAME}}` in
400
+ both the Postgres host and database name:
401
+
402
+ ```yaml
403
+ - name: DATABASE_URL
404
+ value: postgres://the_user:the_password@postgres.{{APP_NAME}}.cpln.local:5432/{{APP_NAME}}
405
+ ```
406
+
407
+ Create a review-only app template by copying `.controlplane/templates/app.yml` to
408
+ `.controlplane/templates/app-review.yml`. In that copy, point `DATABASE_URL` at a per-review-app Control Plane secret.
409
+ Trusted automation should create the secret with a full URL whose database name is still that review app's
410
+ `{{APP_NAME}}`:
411
+
412
+ ```yaml
413
+ spec:
414
+ env:
415
+ - name: DATABASE_URL
416
+ value: cpln://secret/{{APP_NAME}}-database.DATABASE_URL
417
+ ```
418
+
419
+ Control Plane's `cpln://secret/...` syntax replaces the entire env value; it is not substring interpolation, so avoid
420
+ forms such as `postgres://cpln://secret/...@...`. A full per-app URL secret also avoids depending on any runtime
421
+ ordering between secret resolution and `$(VAR)` env-var expansion. Because `{{APP_NAME}}-database` is a separate secret
422
+ from the app dictionary secret, trusted automation must create that secret and add it to the app identity's reveal policy
423
+ before the workload starts; otherwise workloads cannot resolve the `cpln://secret/...` value. For trusted staging/review
424
+ apps where a single shared database role is acceptable, you can still create one URL secret per app that reuses the same
425
+ database user/password while keeping the database name unique.
426
+
427
+ The `cpln://secret/NAME.FIELD` field syntax resolves only against **dictionary** secrets; an `opaque` or `tls` secret
428
+ leaves the workload with an empty or literal `cpln://...` string rather than a clear error. Define the database secret as
429
+ a dictionary, apply it with `cpln apply -f secret.yaml`, and confirm the app identity's policy grants `reveal` on it
430
+ before the workload starts:
431
+
432
+ ```yaml
433
+ kind: secret
434
+ name: my-app-review-pr-123-database
435
+ type: dictionary
436
+ data:
437
+ DATABASE_URL: postgres://the_user:the_password@postgres.staging-shared-postgres.cpln.local:5432/my-app-review-pr-123
438
+ ```
439
+
440
+ `cpflow` can automate this secret-and-policy wiring. Declare a `shared_secret_grants` entry on the review app and
441
+ reference the generated `{{SHARED_SECRET_DATABASE}}` placeholder in your templates instead of hardcoding the secret name;
442
+ `cpflow setup-app`, `deploy-image`, `delete`, and `cleanup-stale-apps` then keep the policy binding and cleanup automatic.
443
+ See [Shared Secrets for Review Apps](secrets-and-env-values.md#shared-secrets-for-review-apps) for the full setup.
444
+
445
+ `{{APP_NAME}}` keeps databases separate by convention, not by itself as a security boundary. If review apps can run
446
+ untrusted PR code, do not give every review app the same database role with `CREATEDB` or ownership of every review
447
+ database. Prefer one of these safer models:
448
+
449
+ 1. Create a database and role per review app, store that app's URL/credentials in its DB secret, and grant the role only to
450
+ its own database.
451
+ 2. Keep review app database roles low-privilege and run create/drop cleanup from trusted admin automation against the
452
+ shared Postgres workload.
453
+
454
+ A single shared role/password is acceptable only for trusted staging apps or review apps where database separation is a
455
+ cost-control convenience rather than a security boundary. The hook example below assumes the review app role is allowed
456
+ to create its own logical database; if you choose admin-owned cleanup, run create/drop steps from trusted automation
457
+ instead of from the review app workload.
458
+
459
+ Then point the review-app entry at the review-only template and remove the per-PR Postgres workload:
460
+
461
+ ```yaml
462
+ my-app-review:
463
+ match_if_app_name_starts_with: true
464
+ setup_app_templates:
465
+ # postgres removed, so no per-PR database workload is created
466
+ - app-review # was: app
467
+ - redis
468
+ - rails
469
+ additional_workloads:
470
+ - redis # postgres removed
471
+ hooks:
472
+ post_creation: bundle exec rails db:prepare
473
+ ```
474
+
475
+ The `post_creation` hook creates only that review app's logical database because the database name is still
476
+ `{{APP_NAME}}`. Do not rely on a generic `pre_deletion: rails db:drop` hook for shared databases: `cpflow delete` runs
477
+ the pre-deletion hook before it removes or suspends the app workloads, so live Rails/worker processes can still hold
478
+ connections and make PostgreSQL reject the drop. Stop the review app workloads first, or run trusted admin cleanup
479
+ against the shared Postgres workload with `DROP DATABASE ... WITH (FORCE)`.
480
+
481
+ Rails apps with multiple production databases need each connection isolated. Either set connection-specific URLs such as
482
+ `CACHE_DATABASE_URL`, `QUEUE_DATABASE_URL`, and `CABLE_DATABASE_URL`, or make the database names in `config/database.yml`
483
+ derive from an app-specific environment variable. If the database names are hard-coded, every review app for that repo
484
+ will collide inside the shared Postgres instance.
485
+
486
+ Suggested cutover order:
487
+
488
+ 1. Create the shared Postgres GVC, workload, and volume.
489
+ 2. Create the app roles and logical databases, or make sure the app role has `CREATEDB` and let `rails db:prepare`
490
+ create them. For admin-created databases, generate the password in trusted automation, store it in the matching app DB
491
+ secret, then run the setup from an interactive `psql` session so the password is not written into shell history or
492
+ process arguments:
493
+
494
+ ```sh
495
+ cpln workload exec postgres --org ORG --gvc staging-shared-postgres --stdin --tty -- psql -U postgres
496
+ ```
497
+
498
+ Then enter the SQL in `psql`:
499
+
500
+ ```sql
501
+ CREATE ROLE "my-app-staging" LOGIN;
502
+ \password "my-app-staging"
503
+ CREATE DATABASE "my-app-staging" OWNER "my-app-staging";
504
+ \connect "my-app-staging"
505
+ GRANT ALL ON SCHEMA public TO "my-app-staging";
506
+ ```
507
+
508
+ In CI, run equivalent SQL through a secret-aware step that does not echo the password, add it to process arguments, or
509
+ persist it in logs.
510
+
511
+ 3. Update staging/review GVC environment values to the shared host.
512
+ 4. Run `cpflow run -a APP -- bin/rails db:prepare` for each app.
513
+ 5. Force redeploy app workloads so live replicas pick up the new GVC env.
514
+ 6. Stop the old per-app Postgres workloads and smoke test the apps.
515
+ 7. Delete the old Postgres workloads and volumes only after smoke tests pass.
516
+ 8. When a review app is deleted, drop its logical database from the shared instance so orphaned review databases do not
517
+ accumulate. For the most reliable cleanup, stop the app workloads first, then run the drop from trusted admin
518
+ automation or directly against the shared Postgres workload. Use `WITH (FORCE)` on PostgreSQL 13+ to terminate
519
+ remaining sessions:
520
+
521
+ ```sh
522
+ cpln workload exec postgres --org ORG --gvc staging-shared-postgres -- \
523
+ psql -U postgres -c 'DROP DATABASE IF EXISTS "my-app-review-pr-123" WITH (FORCE);'
524
+ ```
525
+
526
+ `cpln workload exec` runs `psql` inside the container over its local Unix socket, which the official Postgres image
527
+ grants the `postgres` superuser `trust` auth — so no `PGPASSWORD` or `-W` flag is required here.
528
+
529
+ When updating URL-like env values, prefer applying a full GVC YAML update with `cpln apply`, then re-read the GVC env to
530
+ confirm the new reference took effect:
531
+
532
+ ```sh
533
+ cpln apply -f my-app-review-pr-123-gvc.yaml
534
+ cpln gvc get my-app-review-pr-123 -o yaml | grep DATABASE_URL
535
+ ```
536
+
537
+ `cpln gvc update --set` also works, but treat it as a known-fragile shortcut. Quote the entire `path=value` expression,
538
+ or the CLI can leave the old value in place while the command appears superficially successful. The `spec.env.NAME.value`
539
+ path relies on env-array lookup by name, so verify against your installed CLI version before relying on it:
540
+
541
+ ```sh
542
+ cpln gvc update my-app-review-pr-123 \
543
+ --set 'spec.env.DATABASE_URL.value=cpln://secret/my-app-review-pr-123-database.DATABASE_URL'
544
+ ```
545
+
546
+ A few tradeoffs remain even after the cost savings:
547
+
548
+ - **Noisy neighbor risk.** All staging/review apps share one server's CPU, RAM, disk, and connection pool. A runaway
549
+ query or connection leak in one app can affect the others; per-app connection caps or PgBouncer can help. Mind
550
+ Postgres's own `max_connections` (default 100): a staging seed running alongside several review apps, each at Rails'
551
+ default `pool: 5`, can exhaust it before any query runs. The official image ignores a `POSTGRES_MAX_CONNECTIONS` env
552
+ var; raise the server limit with a `-c max_connections=N` server argument or a custom `postgresql.conf`, and lower
553
+ `pool:` in `config/database.yml` for review apps as the simplest app-side lever.
554
+ - **Operational ownership.** Backups, restores, password rotation, sizing, and access control move to the shared server.
555
+ - **Other trusted services can use the same pattern.** Redis and Memcached can also be shared for trusted apps, but a
556
+ per-app key prefix or logical database index is only conventional separation when apps share credentials. If review
557
+ app code is not trusted, use enforced isolation such as per-app ACL users/credentials or separate instances.
558
+
559
+ ### Enable Capacity AI for Demo and Starter Staging Apps
560
+
561
+ `templates/rails.yml` ships with CPU autoscaling pinned to one replica (`minScale: 1`, `maxScale: 1`) and
562
+ `capacityAI: false`. That's a conservative production-safe default, but for public demos, starter staging apps, and
563
+ long-lived review apps, Capacity AI can right-size CPU and memory allocation while keeping the same warm replica count.
564
+ For these non-production apps, keep the Rails workload as `type: standard`, disable the explicit autoscaling metric,
565
+ and enable Capacity AI. Apply the snippet below to your project's `.controlplane/templates/rails.yml`, or create an
566
+ environment-specific template (for example `rails-review.yml` or `rails-demo-staging.yml`) and list it under
567
+ `setup_app_templates` for the matching app entry in `.controlplane/controlplane.yml`.
568
+
569
+ ```yaml
570
+ # Only `autoscaling.metric` and `capacityAI` change from templates/rails.yml.
571
+ # `type: standard` is shown here to confirm this is not a serverless migration.
572
+ # Keep containers, firewallConfig, identityLink, and everything else from the template intact.
573
+ kind: workload
574
+ name: rails
575
+ spec:
576
+ type: standard
577
+ defaultOptions:
578
+ autoscaling:
579
+ minScale: 1
580
+ maxScale: 1
581
+ metric: disabled
582
+ capacityAI: true
583
+ ```
584
+
585
+ See [`templates/rails.yml`](https://github.com/shakacode/control-plane-flow/blob/main/templates/rails.yml) for the full default — `containers`, `firewallConfig`,
586
+ `identityLink`, and the other required fields must be preserved when you copy the snippet above.
587
+
588
+ This is not the same as scale-to-zero. Capacity AI can reduce over-allocation for mostly idle demos, but it will not
589
+ make costs approach zero when a workload has steady RAM usage or background load. Expect it to settle over several
590
+ hours, and treat memory sizing as a separate cost lever.
591
+
592
+ Shared Postgres is the usual exception: keep shared databases manually sized rather than enabling Capacity AI
593
+ indiscriminately. Apply this guidance to stateless app/service workloads first (Rails, renderers, workers, and similar
594
+ staging-only services). Stateful workloads are not supported by Capacity AI, so keep stateful Redis, Elasticsearch,
595
+ Mongo, and similar support services manually sized unless you intentionally deploy them as supported stateless
596
+ workloads.
279
597
 
280
- `templates/rails.yml` ships with `type: standard`, `minScale: 1`, `maxScale: 1`. That's a safe default for production,
281
- but for review apps where cold-start latency is acceptable you can switch the web workload to a serverless type that
282
- scales to zero replicas when idle. Apply the snippet below to your project's `.controlplane/templates/rails.yml`, or
283
- create a review-app-specific template (for example `rails-review.yml`) and list it under `setup_app_templates` for the
284
- review-app entry in `.controlplane/controlplane.yml`.
598
+ If you intentionally need true idle scale-to-zero, use a separate `type: serverless` workload with `minScale: 0` and
599
+ an HTTP wake-up autoscaling metric such as `rps` or `concurrency`:
285
600
 
286
601
  ```yaml
287
- # Only `type` and `minScale` change from templates/rails.yml; `maxScale`, `capacityAI` and `timeoutSeconds`
288
- # are shown for context so the full `defaultOptions` block reaches the destination intact.
289
- # Update the relevant fields in your full templates/rails.yml (or a review-app-specific template); keep
290
- # containers, firewallConfig, identityLink, and everything else from that file intact.
291
602
  kind: workload
292
603
  name: rails
293
604
  spec:
@@ -296,19 +607,32 @@ spec:
296
607
  autoscaling:
297
608
  minScale: 0
298
609
  maxScale: 1
299
- capacityAI: false # keep your existing value
300
- timeoutSeconds: 60 # keep your existing value
610
+ metric: rps
611
+ target: 1
301
612
  ```
302
613
 
303
- See [`templates/rails.yml`](/templates/rails.yml) for the full default `containers`, `firewallConfig`,
304
- `identityLink`, and the other required fields must be preserved when you copy the snippet above.
614
+ Existing `type: standard` workloads cannot change to `serverless` in place; that requires a planned delete/recreate
615
+ migration and can interrupt traffic.
616
+
617
+ > **Warning:** Treat a `standard` to `serverless` conversion as an operational migration because deleting a running
618
+ > workload can interrupt traffic.
305
619
 
306
- Control Plane spins the workload back up on the next request. Only `type: serverless` workloads support `minScale: 0`;
307
- `type: standard` always keeps at least one replica running.
620
+ ### Use an Always-Available Landing Page for a Serverless App
308
621
 
309
- Tradeoff: the first request after a quiet period pays the cold-start cost (typically 15–60 seconds for a Rails
310
- image, depending on app size and boot configuration). For review apps that's usually fine; for production it
311
- usually isn't.
622
+ For a public demo, review app, or staging app where the first request's cold start would be a poor first impression,
623
+ serve a lightweight landing page independently of the app that scales to zero. Route visitors to that landing page
624
+ first, then have a button on that page request the separate serverless app. The page can immediately explain that the
625
+ app is starting while the serverless workload wakes; it does not remove the cold start, but it keeps that wait out of
626
+ the initial page render.
627
+
628
+ ```text
629
+ always-available landing page -> Open app request -> serverless app (minScale: 0) -> cold start -> app response
630
+ ```
631
+
632
+ The landing page, its hosting, and any DNS, proxy, rewrite, or redirect rules are application infrastructure you
633
+ choose and operate. `cpflow` does not create that routing infrastructure. Keep the app as a separate serverless
634
+ workload from its first deployment (or perform the planned delete/recreate migration above); it cannot convert an
635
+ existing standard workload in place. The wake-up path also requires the HTTP autoscaling configuration shown above.
312
636
 
313
637
  > **Note:** if you later suspend the app with `cpflow ps:stop`, Control Plane will not auto-wake it on the next
314
638
  > request. Run `cpflow ps:start` explicitly first. See
@@ -390,7 +714,130 @@ No re-deploy is needed; the workloads come back with the same images they had be
390
714
  > keep running while only the web tier sleeps. `cpflow ps:stop -a $APP_NAME` suspends every configured workload, web
391
715
  > included, and `cleanup-stale-apps --mode=stop` applies the same pause behavior to stale review apps.
392
716
 
717
+ ## Right-Sizing Non-Production Workloads
718
+
719
+ [Minimizing Non-Production App Costs](#minimizing-non-production-app-costs) above focuses on review-app lifecycle
720
+ controls: scale-to-zero, explicit pauses, and stale app cleanup. Long-lived staging and demo apps are the other common
721
+ source of avoidable Control Plane spend: they tend to keep generously-sized workloads running full-time. The levers
722
+ below apply to any non-production environment (staging, demos, and review apps alike).
723
+
724
+ ### Enable Capacity AI on Idle Workloads
725
+
726
+ Control Plane bills the CPU and memory a running replica *reserves*. With `minScale: 1` and
727
+ Capacity AI off, a workload reserves its full `cpu`/`memory` around the clock, even when the
728
+ app is idle. **Capacity AI** lets Control Plane right-size that reservation toward actual
729
+ usage, so an idle non-production workload costs a fraction of its ceiling.
730
+
731
+ Set it in `defaultOptions`:
732
+
733
+ ```yaml
734
+ kind: workload
735
+ name: rails
736
+ spec:
737
+ defaultOptions:
738
+ capacityAI: true
739
+ ```
740
+
741
+ Also disable CPU-utilization autoscaling for idle non-production workloads; the
742
+ next section shows the complete `capacityAI` and autoscaling shape together.
743
+
744
+ Tradeoff: Control Plane reprovisions the replica when it adjusts the reservation. For
745
+ stateless web/renderer workloads that's negligible. For stateful workloads, see the
746
+ [guidance above](#enable-capacity-ai-for-demo-and-starter-staging-apps) — Postgres,
747
+ Redis, Elasticsearch, Mongo, and similar services should remain manually sized.
748
+
749
+ ### Don't Autoscale Idle Workloads on CPU
750
+
751
+ CPU-utilization autoscaling adds nothing for an idle non-production app and works against
752
+ Capacity AI. Disable it and let Capacity AI handle right-sizing:
753
+
754
+ ```yaml
755
+ kind: workload
756
+ name: rails
757
+ spec:
758
+ defaultOptions:
759
+ capacityAI: true
760
+ autoscaling:
761
+ metric: disabled
762
+ minScale: 1
763
+ maxScale: 1
764
+ ```
765
+
766
+ (For the web tier you can go further and scale to zero — see
767
+ [Enable Capacity AI for Demo and Starter Staging Apps](#enable-capacity-ai-for-demo-and-starter-staging-apps).)
768
+
769
+ ### Right-Size Reserved CPU and Memory
770
+
771
+ The shipped templates use production-leaning defaults. Check each workload's reserved
772
+ `cpu`/`memory` against its real usage — the workload's **Metrics** tab in Control Plane
773
+ shows Grafana CPU/memory graphs — because non-production workloads are routinely
774
+ over-provisioned.
775
+
776
+ Postgres is the usual offender: a demo or staging database does **not** need a full core.
777
+ Pinning `cpu: 1000m` keeps a whole reserved CPU running 24/7, while an idle Postgres
778
+ typically sits at single-digit millicores. Something like `cpu: 250m` / `memory: 512Mi`
779
+ is a field-tested non-production starting point; raise memory toward `1Gi` if the
780
+ workload's Metrics tab shows pressure during seeds, imports, or larger staging datasets.
781
+
782
+ ```yaml
783
+ kind: workload
784
+ name: postgres
785
+ spec:
786
+ containers:
787
+ - name: postgres
788
+ cpu: 250m
789
+ memory: 512Mi
790
+ ```
791
+
792
+ ### Drop Workloads You Don't Use
793
+
794
+ Every workload listed under `app_workloads` / `additional_workloads` is another full-time
795
+ container. Remove the ones a non-production app doesn't actually need.
796
+
797
+ A common one is a separate background-job worker when the app has no jobs to run. On Rails
798
+ 8, [Solid Queue](https://github.com/rails/solid_queue) can run inside Puma instead of as its
799
+ own workload — set `SOLID_QUEUE_IN_PUMA=true` when the app uses the Rails 8 default
800
+ `config/puma.rb`, or add `plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]` manually for
801
+ apps upgraded from Rails 7. Then drop the `worker` workload from `app_workloads` and
802
+ `setup_app_templates` in `.controlplane/controlplane.yml`, and delete its template.
803
+ Solid Queue is database-backed, so job processing needs no Redis; if your app uses Redis for caching or Action Cable,
804
+ keep that workload.
805
+
806
+ ### Share One Postgres Across Non-Production Apps
807
+
808
+ Running a dedicated Postgres workload — and its SSD volume — for every staging and review
809
+ app multiplies standing cost. For non-production, several apps can share a single Postgres
810
+ server, each using its own database:
811
+
812
+ - Point each app's `DATABASE_URL` environment variable (in `.controlplane/templates/`) at the shared instance — for
813
+ example `postgres://user:pass@postgres.staging-shared-postgres.cpln.local:5432/my_app_staging` — and give each app a
814
+ distinct database name in the path.
815
+ - Set `inboundAllowType` to the narrowest scope that covers your use case — `workload-list` gives the tightest blast
816
+ radius when you can keep an explicit per-workload allowlist current, while `same-org` is the practical default when
817
+ client apps are too dynamic to maintain manually. `same-gvc` only works when the shared Postgres and every client app
818
+ live in the same GVC, which is not the cross-GVC setup described in
819
+ [Share One Control Plane Postgres for Staging and Review Apps](#share-one-control-plane-postgres-for-staging-and-review-apps).
820
+ Overly broad allow-types expand the attack surface, especially when review apps can run untrusted PR code.
821
+ - Store shared database credentials in Control Plane secrets for long-lived staging and demos; plaintext
822
+ `DATABASE_URL` values are only reasonable for disposable non-production experiments.
823
+ - Prefer per-app database roles over a shared superuser or broad `CREATEDB` role, especially when review apps can run
824
+ untrusted PR code.
825
+
826
+ A managed alternative is a single small RDS instance hosting many databases; see
827
+ [Hetzner RDS Postgres](https://pelle.io/posts/hetzner-rds-postgres).
828
+
829
+ ### Keep Templates as the Source of Truth
830
+
831
+ It's tempting to tune `cpu`, `capacityAI`, or autoscaling directly in the Control Plane UI.
832
+ Don't: `cpflow apply-template` reconciles workloads from your `.controlplane/templates/`, so console edits are
833
+ overwritten when it runs next; non-interactive CI runs with `--yes` do that silently, while interactive runs prompt
834
+ before re-creating existing workloads. Make cost changes in the templates and deploy them.
835
+
836
+ If you want drift caught automatically, manage long-lived environments with Terraform via
837
+ [`cpflow terraform`](/docs/terraform/overview.md) — `terraform plan` reports any difference
838
+ between the repo and live infrastructure before you apply.
839
+
393
840
  ## Useful Links
394
841
 
395
842
  - For best practices for the app's Dockerfile, see: https://lipanski.com/posts/dockerfile-ruby-best-practices
396
- - For migrating from Heroku Postgres to RDS, see: https://pelle.io/posts/hetzner-rds-postgres
843
+ - For Hetzner RDS Postgres, see: https://pelle.io/posts/hetzner-rds-postgres
@@ -135,6 +135,8 @@ apps:
135
135
  post_creation: bundle exec rake db:prepare
136
136
 
137
137
  # Used by the command `cpflow delete` to run a hook before deleting the app.
138
+ # For a shared database, prefer admin-side cleanup instead: `cpflow delete` runs this hook before removing the
139
+ # workloads, so live connections can block the drop. See docs/tips.md ("Share One Control Plane Postgres").
138
140
  pre_deletion: bundle exec rake db:drop
139
141
 
140
142
  my-app-production:
@@ -30,9 +30,9 @@ module Command
30
30
 
31
31
  def prompt
32
32
  <<~PROMPT
33
- Set up Control Plane GitHub Flow for this repo. Start with `cpflow github-flow-readiness` and stop on any reported blockers. The repo must be deployable from a clean clone: published package versions, complete runtime scaffold, and a production Dockerfile that can build the app. If any package version is unpublished, inaccessible from CI, or requires credentials that are not already modeled in the repo or GitHub settings, stop and report the blocker instead of generating workflow files. If the repo is a legacy sample pinned to an obsolete Ruby or Bundler toolchain, if it does not even have a production Dockerfile yet, or if it is a monorepo without an already-decided single app boundary for this flow, stop and report that as a prerequisite instead of forcing the rollout.
33
+ Set up Control Plane GitHub Flow for this repo. First make sure the `cpflow` CLI is available: use the repo's existing `bundle exec cpflow` if present, otherwise install the published `cpflow` Ruby gem with `gem install cpflow`; if neither is possible, stop and report that blocker. Use the same `cpflow` invocation for the rest of the rollout. Start with `cpflow github-flow-readiness` and stop on any reported blockers. The repo must be deployable from a clean clone: published package versions, complete runtime scaffold, and a production Dockerfile that can build the app. If any package version is unpublished, inaccessible from CI, or requires credentials that are not already modeled in the repo or GitHub settings, stop and report the blocker instead of generating workflow files. If the repo is a legacy sample pinned to an obsolete Ruby or Bundler toolchain, if it does not even have a production Dockerfile yet, or if it is a monorepo without an already-decided single app boundary for this flow, stop and report that as a prerequisite instead of forcing the rollout.
34
34
 
35
- If `.controlplane/` is missing, run `cpflow generate`. Treat the generated app names as the repo-name default (`#{inferred_app_prefix}`) and rename them only if the project needs a different prefix. Then run `cpflow generate-github-actions` (or `cpflow generate-github-actions --staging-branch BRANCH` when staging should deploy from a branch other than `main`/`master`), keep review apps opt-in via `+review-app-deploy`, make sure any `STAGING_APP_BRANCH` repository variable is also present in the generated staging workflow's `on.push.branches` filter, and list the GitHub secrets and variables that must be configured. Do not hand-edit duplicated upstream refs into the generated wrappers: the only downstream Control Plane Flow pin should be the reusable workflow `uses: ...@vX.Y.Z` value generated from the installed `cpflow` gem version, and upstream workflows load their matching shared actions automatically. When bumping the `cpflow` gem in a downstream repo, run `cpflow update-github-actions` (or `bundle exec cpflow update-github-actions`) and validate with `bin/test-cpflow-github-flow` in the same PR so the checked-in wrappers move to the matching release tag. Keep the standard path simple: review apps require only `CPLN_TOKEN_STAGING` when the generated review app config can be inferred. For shared review-app resources such as one staging database, use `shared_secret_grants` and `{{SHARED_SECRET_DATABASE}}` placeholders instead of hardcoding the base app secret name; this keeps review-app policy binding and cleanup automatic while avoiding per-PR database cost. Document the one-time Control Plane bootstrap command for persistent staging and production apps with `cpflow setup-app --skip-post-creation-hook`; for existing apps or later template updates, document `cpflow apply-template` and the need for the app identity to have `reveal` on the app secret policy. Do not imply the staging deploy or promotion workflows create those persistent GVCs. For production promotion, document a protected `production` GitHub Environment with required reviewers, prevent self-review, and `CPLN_TOKEN_PRODUCTION` stored as an environment secret, not as a repository or organization secret.
35
+ If `.controlplane/` is missing, run `cpflow generate`. Treat the generated app names as the repo-name default (`#{inferred_app_prefix}`) and rename them only if the project needs a different prefix. Then run `cpflow generate-github-actions` (or `cpflow generate-github-actions --staging-branch BRANCH` when staging should deploy from a branch other than `main`/`master`), keep review apps opt-in via `+review-app-deploy`, make sure any `STAGING_APP_BRANCH` repository variable is also present in the generated staging workflow's `on.push.branches` filter, and list the GitHub secrets and variables that must be configured. Do not hand-edit duplicated upstream refs into the generated wrappers: the only downstream Control Plane Flow pin should be the reusable workflow `uses: ...@vX.Y.Z` value generated from the installed `cpflow` gem version, and upstream workflows load their matching shared actions automatically. When bumping the `cpflow` gem in a downstream repo, run `cpflow update-github-actions` (or `bundle exec cpflow update-github-actions`) and validate with `bin/test-cpflow-github-flow` in the same PR so the checked-in wrappers move to the matching release tag. Keep the normal generated review-app setup simple: review apps require only `CPLN_TOKEN_STAGING` when the generated review app config can be inferred. For public demos, starter staging apps, and long-lived review apps, keep the app workload `type: standard` with one warm replica, set its autoscaling metric to `disabled`, and enable `capacityAI: true` so Control Plane can right-size CPU and memory allocation at that fixed replica count. Shared Postgres and other stateful workloads are the usual exceptions and should stay manually sized; Capacity AI is for supported stateless app/service workloads. If true idle scale-to-zero is explicitly required, create a separate `serverless` workload before first deploy or plan a delete/recreate migration because Control Plane will not change an existing `standard` workload to `serverless` in place. For shared review-app resources such as one staging database, use `shared_secret_grants` and `{{SHARED_SECRET_DATABASE}}` placeholders instead of hardcoding the base app secret name; this keeps review-app policy binding and cleanup automatic while avoiding per-PR database cost. Document the one-time Control Plane bootstrap command for persistent staging and production apps with `cpflow setup-app --skip-post-creation-hook`; for existing apps or later template updates, document `cpflow apply-template` and the need for the app identity to have `reveal` on the app secret policy. Do not imply the staging deploy or promotion workflows create those persistent GVCs. For production promotion, document a protected `production` GitHub Environment with required reviewers, prevent self-review, and `CPLN_TOKEN_PRODUCTION` stored as an environment secret, not as a repository or organization secret.
36
36
 
37
37
  Keep Node available in the final image if asset compilation or SSR depends on ExecJS, Yarn, `pnpm`, or npm after the main install layer. Make sure the generated Dockerfile uses a Ruby base image compatible with the app's declared Ruby requirement. Preserve repo-defined frontend build hooks: if `config/shakapacker.yml` defines a `precompile_hook`, or React on Rails enables `config.auto_load_bundle = true`, confirm the generated Dockerfile runs that codegen step before `rails assets:precompile`. If `config/database.yml` shows SQLite in production, confirm that the generated scaffold uses persistent `db` and `storage` volumes plus a release script that runs `rails db:prepare`; otherwise keep the default Postgres workload. If the public workload is not named `rails`, set `PRIMARY_WORKLOAD` or adjust the generated workflows. Inspect the Dockerfile and package sources for private GitHub dependencies or `RUN --mount=type=ssh`; if present, wire `DOCKER_BUILD_SSH_KEY`, optionally set `DOCKER_BUILD_SSH_KNOWN_HOSTS` for non-GitHub SSH hosts, and keep `DOCKER_BUILD_EXTRA_ARGS` to newline-delimited single tokens such as `--build-arg=FOO=bar`.
38
38