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