cpflow 5.3.0 → 6.0.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/agent-workflow.yml +16 -25
  3. data/.agents/bin/README.md +19 -9
  4. data/.agents/bin/docs +1 -1
  5. data/.agents/bin/lint +1 -1
  6. data/.agents/bin/setup +1 -1
  7. data/.agents/bin/test +1 -1
  8. data/.agents/bin/validate +2 -1
  9. data/.agents/legacy-workflow-policy.yml +11 -0
  10. data/.agents/shaka.md +20 -0
  11. data/.agents/trusted-actions.yml +12 -0
  12. data/.coderabbit.yaml +14 -0
  13. data/.github/actions/cpflow-build-docker-image/action.yml +153 -22
  14. data/.github/actions/cpflow-delete-control-plane-app/action.yml +2 -1
  15. data/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +3 -0
  16. data/.github/actions/cpflow-preview-promotion/action.yml +82 -0
  17. data/.github/actions/cpflow-preview-promotion/preview-promotion.sh +90 -0
  18. data/.github/actions/cpflow-setup-environment/action.yml +8 -6
  19. data/.github/dependabot.yml +10 -0
  20. data/.github/workflows/check_cpln_links.yml +6 -1
  21. data/.github/workflows/claude-code-review.yml +5 -2
  22. data/.github/workflows/claude.yml +6 -4
  23. data/.github/workflows/coderabbit-lifecycle-review.yml +29 -0
  24. data/.github/workflows/command_docs.yml +7 -2
  25. data/.github/workflows/cpflow-cleanup-stale-review-apps.yml +12 -5
  26. data/.github/workflows/cpflow-delete-review-app.yml +18 -11
  27. data/.github/workflows/cpflow-deploy-review-app.yml +38 -17
  28. data/.github/workflows/cpflow-deploy-staging.yml +28 -11
  29. data/.github/workflows/cpflow-help-command.yml +3 -3
  30. data/.github/workflows/cpflow-promote-staging-to-production.yml +135 -16
  31. data/.github/workflows/cpflow-review-app-help.yml +1 -1
  32. data/.github/workflows/rspec-shared.yml +34 -26
  33. data/.github/workflows/rspec-specific.yml +10 -2
  34. data/.github/workflows/rspec.yml +95 -4
  35. data/.github/workflows/rubocop.yml +9 -2
  36. data/.github/workflows/trigger-docs-site.yml +2 -0
  37. data/.rubocop.yml +6 -1
  38. data/AGENTS.md +18 -3
  39. data/CHANGELOG.md +39 -1
  40. data/CONTRIBUTING.md +39 -2
  41. data/Gemfile.lock +1 -1
  42. data/README.md +10 -2
  43. data/cpflow.gemspec +3 -15
  44. data/docs/ai-github-flow-prompt.md +3 -3
  45. data/docs/ci-automation.md +284 -68
  46. data/docs/commands.md +38 -15
  47. data/docs/postgres-review-apps.md +90 -0
  48. data/docs/postgres.md +233 -375
  49. data/docs/rds-private-networking.md +12 -12
  50. data/docs/releasing.md +15 -4
  51. data/docs/secrets-and-env-values.md +42 -0
  52. data/docs/sidebars.ts +1 -0
  53. data/examples/controlplane.yml +5 -0
  54. data/lib/command/ai_github_flow_prompt.rb +2 -2
  55. data/lib/command/apply_template.rb +36 -4
  56. data/lib/command/base.rb +56 -3
  57. data/lib/command/cleanup_stale_apps.rb +28 -4
  58. data/lib/command/copy_image_from_upstream.rb +3 -0
  59. data/lib/command/delete.rb +174 -4
  60. data/lib/command/deploy_image.rb +44 -1
  61. data/lib/command/generate.rb +247 -3
  62. data/lib/command/generate_github_actions.rb +43 -13
  63. data/lib/command/run.rb +224 -34
  64. data/lib/command/setup_app.rb +92 -10
  65. data/lib/command/update_github_actions.rb +82 -43
  66. data/lib/core/config.rb +73 -0
  67. data/lib/core/controlplane.rb +167 -80
  68. data/lib/core/controlplane_api.rb +28 -2
  69. data/lib/core/controlplane_api_direct.rb +17 -0
  70. data/lib/core/disposable_postgres_helpers.rb +167 -0
  71. data/lib/core/doctor_service.rb +7 -0
  72. data/lib/core/github_flow_readiness/checks.rb +2 -1
  73. data/lib/core/github_flow_readiness_service.rb +4 -0
  74. data/lib/core/repo_introspection.rb +112 -2
  75. data/lib/core/shell.rb +35 -10
  76. data/lib/core/timed_command.rb +111 -0
  77. data/lib/cpflow/version.rb +1 -1
  78. data/lib/cpflow.rb +1 -1
  79. data/lib/generator_templates/.dockerignore +16 -0
  80. data/lib/generator_templates/Dockerfile +9 -5
  81. data/lib/generator_templates/entrypoint.sh +2 -0
  82. data/lib/generator_templates/templates/postgres.yml +9 -0
  83. data/lib/generator_templates_sqlite/controlplane.yml +2 -1
  84. data/lib/generator_templates_sqlite/release_script.sh +1 -1
  85. data/lib/generator_templates_sqlite/templates/rails.yml +2 -1
  86. data/lib/github_flow_templates/.github/cpflow-help.md +39 -12
  87. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +1 -0
  88. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +1 -0
  89. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +135 -16
  90. data/lib/github_flow_templates/bin/pin-cpflow-github-ref +37 -13
  91. data/lib/github_flow_templates/bin/test-cpflow-github-flow +125 -46
  92. data/lib/patches/hash.rb +2 -2
  93. data/rakelib/create_release.rake +14 -14
  94. data/script/check_shell_scripts +66 -0
  95. metadata +18 -17
@@ -6,13 +6,14 @@ path — not the public internet.
6
6
 
7
7
  This guide covers the recommended setup: **CPLN Cloud Wormhole via an Agent**.
8
8
 
9
- > **Sourcing note — verify field casing before you apply.** Field names, schema, and limits in this guide
10
- > are sourced from the public Control Plane documentation at <https://shakadocs.controlplane.com> as of
11
- > May 2026 and have **not** been end-to-end verified against a live org. This matters because a casing
12
- > mismatch **fails silently**: `cpln apply` accepts the file, ignores the unrecognized field, and the
13
- > workload then can't reach the database — with no error pointing back to the YAML. Before applying in
14
- > production, diff your edited files against a fresh `cpln identity get <name> -o yaml-slim` (and
15
- > `cpln agent get <name> -o yaml`) export to confirm the exact field names, and consult
9
+ > **Field casing verified.** Sanitized `cpln identity get <name> -o yaml-slim` exports from live orgs
10
+ > confirmed the `networkResources` nesting and the common `name`, `agentLink`, `FQDN`, and `ports` field casing.
11
+ > The current Control Plane [Identity reference](https://shakadocs.controlplane.com/reference/identity) and
12
+ > [Identity API](https://shakadocs.controlplane.com/api-reference/identity/get-an-identity-by-gvc-and-name)
13
+ > confirm the alternative `IPs` form and optional `resolverIP` spelling; neither appeared in the inspected
14
+ > live resources. Field names are case-sensitive: `cpln apply` can accept an unrecognized field while omitting
15
+ > the intended route, without pointing back to the identity YAML. Before applying in production, check the new
16
+ > block against these casing sources, diff your edited file against a fresh identity export, and consult
16
17
  > `cpln <command> --help` for the latest CLI flags.
17
18
 
18
19
  ## Why private networking
@@ -234,11 +235,10 @@ cpln identity get my-app-production-identity \
234
235
  Edit `identity-db.yaml` and **add** the `networkResources` block — keep every other apply-safe field
235
236
  exactly as exported:
236
237
 
237
- > ⚠️ **Verify field casing against your org before applying.** The field names below (`FQDN`, `IPs`,
238
- > `agentLink`, `resolverIP`) are sourced from public CPLN docs. If the live API uses different casing,
239
- > `cpln apply` will accept the file but silently ignore the resource — workloads will hit
240
- > `could not translate host name` with no obvious link to the identity YAML. Diff your edited file
241
- > against the original `yaml-slim` export from `cpln identity get` to confirm.
238
+ > **Apply-safe editing:** start from a fresh `yaml-slim` export and diff your edited file against the
239
+ > original before applying. This helps detect unintended changes to exported fields, but it does not validate
240
+ > the newly added `networkResources` block. Check every key in that block, including `agentLink`, `FQDN`/`IPs`,
241
+ > `resolverIP`, and `ports`, against the verified casing sources above.
242
242
 
243
243
  ```yaml
244
244
  # identity-db.yaml (after edit — abbreviated, your file will have more fields)
data/docs/releasing.md CHANGED
@@ -23,6 +23,11 @@ Always update `CHANGELOG.md` before running the release task.
23
23
  - Fixes, improvements, deprecations, removals, or security updates: patch
24
24
  4. Merge the changelog PR before releasing.
25
25
 
26
+ A minimum Ruby version change is a breaking change. Merge the gemspec floor,
27
+ CI coverage for each supported Ruby minor, and an `Unreleased` breaking-change
28
+ entry together. Keep `Cpflow::VERSION` unchanged in that implementation PR.
29
+ The release task applies the required major version when the change ships.
30
+
26
31
  If updating the changelog manually, move the relevant `Unreleased` entries into
27
32
  a versioned header:
28
33
 
@@ -49,9 +54,13 @@ With no arguments, `rake release`:
49
54
 
50
55
  1. Reads the first versioned `CHANGELOG.md` header, such as `## [4.2.0]`.
51
56
  2. Uses that version when it is newer than the current gem version.
52
- 3. Uses the current version if the changelog version matches the gem version
53
- but has not been tagged yet.
54
- 4. Falls back to a patch bump if no new changelog version is found.
57
+ 3. Keeps using that version when it matches the current gem version, including
58
+ on a retry after a release attempt created the version commit or tag.
59
+ 4. Lets the existing-tag policy stop an incomplete retry instead of silently
60
+ selecting a different version.
61
+ 5. Falls back to a patch bump only from a stable current version when the
62
+ changelog does not name the current or a newer version. Prereleases require
63
+ an explicit target if the changelog cannot supply one.
55
64
 
56
65
  Other supported forms:
57
66
 
@@ -152,4 +161,6 @@ bundle exec rake "sync_github_release[4.2.0]"
152
161
  ```
153
162
 
154
163
  If the tag was pushed but the gem was not published, delete or correct the tag
155
- and version commit intentionally before trying again.
164
+ and version commit intentionally before trying again. A no-argument retry keeps
165
+ the changelog version authoritative and refuses to reinterpret a prerelease as
166
+ its stable version.
@@ -11,6 +11,48 @@ set these values directly at the GVC or workload levels, so none of these ENV va
11
11
 
12
12
  ## Review app secrets
13
13
 
14
+ For credentials and initialization scripts created by the PostgreSQL template, see
15
+ [PostgreSQL for disposable review apps](./postgres-review-apps.md).
16
+
17
+ For same-repository review apps that need only disposable credentials, opt in on
18
+ the dynamically matched review-app entry in `.controlplane/controlplane.yml`:
19
+
20
+ ```yaml
21
+ apps:
22
+ my-app-review:
23
+ match_if_app_name_starts_with: true
24
+ generated_review_secret_keys:
25
+ - SECRET_KEY_BASE
26
+ - RENDERER_PASSWORD
27
+ ```
28
+
29
+ `cpflow setup-app` then creates a separate `APP_NAME-secrets` dictionary and
30
+ policy for each PR app, tags both resources with the exact app name, fills each
31
+ configured key with a random 256-bit hex value, and never prints the values.
32
+ `--refresh-templates` fills missing keys without rotating existing values.
33
+ Initial template application skips templates named for the generated dictionary
34
+ and policy, so templates cannot replace its values or broaden its access.
35
+ Refresh also skips the generated policy template.
36
+ Setup refuses to reuse a dictionary without the exact app tag or a policy that
37
+ targets another secret or binds another principal; it rechecks the policy
38
+ immediately before granting the app identity. Keep this
39
+ setting off persistent apps and do
40
+ not combine it with custom `secrets_name` or `secrets_policy_name`. Templates
41
+ that use `{{APP_SECRETS}}` automatically point at the PR-specific dictionary.
42
+ `cpflow delete` and delete-mode stale cleanup remove that dictionary and policy
43
+ before removing the app, provided the policy still targets the dictionary and
44
+ has no other bindings. Generated dictionaries carry an app marker; if a prior
45
+ attempt removed the policy first, cleanup can use that marker to remove only the
46
+ matching disposable dictionary. An unmarked policy is also left for inspection
47
+ if its dictionary is already gone. If the GVC was removed separately before disposable credential cleanup finished, rerun
48
+ `cpflow delete` with the same app name to finish secret cleanup. The generated
49
+ delete action calls that same recovery path when the GVC is already absent.
50
+ Cleanup still checks the app-specific names and tag if the key-generation option
51
+ was later removed. Unexpected grants or unmarked secrets leave those resources
52
+ for inspection.
53
+ This generates credentials only; external licenses, database accounts, and
54
+ provider tokens still require their own review-only provisioning.
55
+
14
56
  Review apps run application code from pull requests. For repositories with external contributors, do not put sensitive
15
57
  production or long-lived staging secrets in review apps. A secret reference such as `cpln://secret/...` protects the
16
58
  value while it is stored in Control Plane configuration, but the value becomes readable by application code after it is
data/docs/sidebars.ts CHANGED
@@ -44,6 +44,7 @@ const sidebars: SidebarsConfig = {
44
44
  label: 'Data Services',
45
45
  items: [
46
46
  'postgres',
47
+ 'postgres-review-apps',
47
48
  'rds-private-networking',
48
49
  'redis',
49
50
  ],
@@ -104,6 +104,11 @@ aliases:
104
104
  # If not specified, defaults to 21600 (6 hours).
105
105
  runner_job_timeout: 1000
106
106
 
107
+ # Sets how long `cpflow run` waits for Control Plane to reconcile a cron job status
108
+ # after the non-interactive command prints its completion marker.
109
+ # If not specified, defaults to 1200 (20 minutes).
110
+ runner_job_status_reconciliation_timeout: 1200
111
+
107
112
  # Apps with a deployed image created before this amount of days will be listed for deletion
108
113
  # when running the command `cpflow cleanup-stale-apps`.
109
114
  stale_app_image_deployed_days: 5
@@ -32,9 +32,9 @@ module Command
32
32
  <<~PROMPT
33
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 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.
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. Keep every generated Control Plane Flow source pin synchronized at the same `vX.Y.Z`: each reusable-workflow `uses: ...@vX.Y.Z` ref, the production promotion `.cpflow` checkout ref, and its `control_plane_flow_ref` setup input. By default, `cpflow generate-github-actions` derives these values from the installed `cpflow` gem version; temporary explicit ref overrides are for unreleased testing and must keep all three values synchronized. Keep the generated `.github/actions/cpflow-*` files from that same gem unchanged; upstream reusable workflows invoke those checked-in local actions and separately check out the matching runtime source at `.cpflow`. When bumping the `cpflow` gem in a downstream repo, `cpflow update-github-actions` refreshes local actions and helpers while preserving all top-level workflows, including their refs and triggers. Use `cpflow update-github-actions --workflows FILE...` to explicitly select wrappers for replacement, then review the diff and reapply downstream customizations. A differing existing validator blocks all writes: preserve its downstream checks in executable `bin/test-cpflow-github-flow-custom`, then remove or rename the old generated validator before retrying. The generated validator invokes that preserved extension and propagates its failures. Follow `docs/ci-automation.md` for the migration procedure, keep source pins compatible with the updated actions, and run `bin/test-cpflow-github-flow` in the same PR (or `bin/test-cpflow-github-flow bundle exec cpflow` when using Bundler). 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
- 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`.
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 redirects every production database file into persistent `/app/data` (or keeps it under persistent `/app/storage`) without mounting over the image's `/app/db` migrations, stores Active Storage under `/app/storage`, and 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
 
39
39
  Run the real local validations you can: Docker build if feasible, repo tests or smoke checks, YAML validation, and any CI-equivalent build steps. Push the branch and check the GitHub Actions results. Only stop early for a real external blocker or a product decision that changes scope.
40
40
  PROMPT
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../core/disposable_postgres_helpers"
4
+
3
5
  module Command
4
6
  class ApplyTemplate < Base # rubocop:disable Metrics/ClassLength
5
7
  NAME = "apply-template"
@@ -10,7 +12,9 @@ module Command
10
12
  location_option,
11
13
  skip_confirm_option,
12
14
  add_app_identity_option,
13
- preserve_existing_runtime_option
15
+ preserve_existing_runtime_option,
16
+ exclude_secret_template_option,
17
+ exclude_policy_template_option
14
18
  ].freeze
15
19
  DESCRIPTION = "Applies application-specific configs from templates"
16
20
  LONG_DESCRIPTION = <<~DESC
@@ -19,6 +23,7 @@ module Command
19
23
  - Picks templates from the `.controlplane/templates` directory
20
24
  - Templates are ordinary Control Plane templates but with variable preprocessing
21
25
  - Use `--preserve-existing-runtime` to retain each workload container's configured app image, even when the workload is unready, and skip existing secret resources entirely while applying other template changes
26
+ - Use `--exclude-secret-template NAME` and `--exclude-policy-template NAME` to exclude exact named templates without changing workload image handling
22
27
  - Missing or invalid workload images use only an unambiguous app image from ready workloads; refresh fails before applying templates when no safe fallback exists
23
28
 
24
29
  **Preprocessed template variables:**
@@ -61,7 +66,8 @@ module Command
61
66
  @skipped_templates = []
62
67
 
63
68
  templates = @template_parser.parse(@names_to_filenames.values)
64
- templates = preserve_existing_runtime(templates) if config.options[:preserve_existing_runtime]
69
+ templates = filter_existing_resources(templates)
70
+ DisposablePostgresHelpers.new(config, cp).prepare_templates!(templates)
65
71
  pending_templates = confirm_templates(templates)
66
72
  add_app_identity_template(pending_templates) if config.options[:add_app_identity]
67
73
  pending_templates.each do |template|
@@ -77,6 +83,27 @@ module Command
77
83
 
78
84
  private
79
85
 
86
+ def filter_existing_resources(templates)
87
+ templates = preserve_existing_runtime(templates) if config.options[:preserve_existing_runtime]
88
+ templates = exclude_named_template(templates, :exclude_secret_template, "secret")
89
+ exclude_named_template(templates, :exclude_policy_template, "policy")
90
+ end
91
+
92
+ def exclude_named_template(templates, option, kind)
93
+ name = config.options[option]
94
+ return templates unless name
95
+ raise "--#{option.to_s.tr('_', '-')} requires a #{kind} name." if name.empty?
96
+
97
+ templates.filter_map do |template|
98
+ if template["kind"] == kind && template["name"] == name
99
+ report_skipped(template)
100
+ next
101
+ end
102
+
103
+ template
104
+ end
105
+ end
106
+
80
107
  def template_kind(template)
81
108
  case template["kind"]
82
109
  when "gvc"
@@ -154,13 +181,18 @@ module Command
154
181
  cache_existing_workloads(templates)
155
182
  ready_fallback_image = unambiguous_ready_app_image
156
183
 
184
+ skip_existing_secret_resources(templates).each do |template|
185
+ preserve_workload_images(template, ready_fallback_image) if template["kind"] == "workload"
186
+ end
187
+ end
188
+
189
+ def skip_existing_secret_resources(templates)
157
190
  templates.filter_map do |template|
158
191
  if template["kind"] == "secret" && cp.fetch_secret(template["name"])
159
192
  report_skipped(template)
160
193
  next
161
194
  end
162
195
 
163
- preserve_workload_images(template, ready_fallback_image) if template["kind"] == "workload"
164
196
  template
165
197
  end
166
198
  end
@@ -312,7 +344,7 @@ module Command
312
344
  return unless @skipped_templates.any?
313
345
 
314
346
  skipped = @skipped_templates.map { |template| " - [#{template_kind(template)}] #{template['name']}" }.join("\n")
315
- progress.puts("\n#{Shell.color('Skipped templates (already exist):', :blue)}\n#{skipped}")
347
+ progress.puts("\n#{Shell.color('Skipped templates:', :blue)}\n#{skipped}")
316
348
  end
317
349
  end
318
350
  end
data/lib/command/base.rb CHANGED
@@ -483,6 +483,28 @@ module Command
483
483
  }
484
484
  end
485
485
 
486
+ def self.exclude_secret_template_option(required: false)
487
+ {
488
+ name: :exclude_secret_template,
489
+ params: {
490
+ desc: "Excludes one named secret template while applying templates",
491
+ type: :string,
492
+ required: required
493
+ }
494
+ }
495
+ end
496
+
497
+ def self.exclude_policy_template_option(required: false)
498
+ {
499
+ name: :exclude_policy_template,
500
+ params: {
501
+ desc: "Excludes one named policy template while applying templates",
502
+ type: :string,
503
+ required: required
504
+ }
505
+ }
506
+ end
507
+
486
508
  def self.skip_pre_deletion_hook_option(required: false)
487
509
  {
488
510
  name: :skip_pre_deletion_hook,
@@ -688,15 +710,46 @@ module Command
688
710
  end
689
711
 
690
712
  def shared_secret_policy_targets_secret?(grant, policy)
713
+ policy_targets_secret?(policy, grant.fetch(:secret_name))
714
+ end
715
+
716
+ def policy_targets_secret?(policy, secret_name)
691
717
  target_links = Array(policy["targetLinks"])
692
718
 
693
719
  policy["targetKind"] == "secret" &&
694
720
  target_links.one? &&
695
- shared_secret_policy_target_links(grant).include?(target_links.first)
721
+ secret_policy_target_links(secret_name).include?(target_links.first)
696
722
  end
697
723
 
698
- def shared_secret_policy_target_links(grant)
699
- secret_name = grant.fetch(:secret_name)
724
+ def generated_review_app_tag(resource)
725
+ tags = resource["tags"]
726
+ return nil unless tags.is_a?(Hash)
727
+
728
+ tags[::Config::GENERATED_REVIEW_APP_TAG]
729
+ end
730
+
731
+ def generated_review_secret_owned_by_app?(secret, secret_name = config.secrets)
732
+ secret.is_a?(Hash) && secret["name"] == secret_name && secret["type"] == "dictionary" &&
733
+ generated_review_app_tag(secret) == config.app
734
+ end
735
+
736
+ def generated_review_policy_binding_state(policy)
737
+ bindings = Array(policy["bindings"])
738
+ return :unbound if bindings.empty?
739
+ return :unsafe unless generated_review_policy_bindings_for_app?(bindings)
740
+
741
+ case bindings.map { |binding| binding["permissions"] }.uniq
742
+ when [[]] then :unbound
743
+ when [["reveal"]] then :bound
744
+ else :unsafe
745
+ end
746
+ end
747
+
748
+ def generated_review_policy_bindings_for_app?(bindings)
749
+ bindings.all? { |binding| Array(binding["principalLinks"]) == [config.identity_link] }
750
+ end
751
+
752
+ def secret_policy_target_links(secret_name)
700
753
  [
701
754
  "//secret/#{secret_name}",
702
755
  "/org/#{config.org}/secret/#{secret_name}"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Command
4
- class CleanupStaleApps < Base
4
+ class CleanupStaleApps < Base # rubocop:disable Metrics/ClassLength
5
5
  CLEANUP_MODE_OPTION = {
6
6
  name: :mode,
7
7
  params: {
@@ -24,7 +24,7 @@ module Command
24
24
  LONG_DESCRIPTION = <<~DESC
25
25
  - Acts on stale apps based on the creation date of the latest image, or the GVC if no images exist
26
26
  - With `--mode=delete` (default): deletes the whole app (GVC with all workloads, all volumesets and all images), and unbinds the app from the secrets policy and any configured `shared_secret_grants` policies as long as both the identity and each policy exist (and are bound)
27
- - With `--mode=stop`: suspends all workloads via `cpflow ps:stop` — no GVC, volumeset, or image is removed; resume with `cpflow ps:start`
27
+ - With `--mode=stop`: suspends configured workloads that exist in the live GVC through Control Plane — no GVC, volumeset, or image is removed; resume with `cpflow ps:start`
28
28
  - `--mode=stop` only suspends workloads listed in `app_workloads` + `additional_workloads`; workloads present in the live GVC but missing from the config are skipped silently
29
29
  - `--mode=stop` returns once each workload is marked suspended; it does not wait for the workload to reach a not-ready state
30
30
  - Specify the amount of days after an app should be considered stale through `stale_app_image_deployed_days` in the `.controlplane/controlplane.yml` file
@@ -101,14 +101,38 @@ module Command
101
101
  def process_app(app)
102
102
  if mode == "stop"
103
103
  progress.puts("Stopping app '#{app}'")
104
- run_cpflow_command("ps:stop", "-a", app)
104
+ stop_configured_live_workloads(app)
105
105
  else
106
106
  run_cpflow_command("delete", "-a", app, "--yes")
107
107
  end
108
108
  end
109
109
 
110
+ def stop_configured_live_workloads(app)
111
+ app_config = config.find_app_config(app)
112
+ raise "Can't find config for stale app '#{app}'." unless app_config
113
+
114
+ configured_workloads = required_app_option(app_config, app, :app_workloads) +
115
+ required_app_option(app_config, app, :additional_workloads)
116
+ live_workloads = (cp.fetch_workloads(app)&.fetch("items", []) || []).map { |workload| workload.fetch("name") }
117
+
118
+ (configured_workloads & live_workloads).each do |workload|
119
+ step("Stopping workload '#{workload}'") do
120
+ cp.set_workload_suspend(workload, true, app, missing_ok: true)
121
+ end
122
+ end
123
+ end
124
+
125
+ def required_app_option(app_config, app, option)
126
+ raise "Can't find option '#{option}' for app '#{app}' in 'controlplane.yml'." unless app_config.key?(option)
127
+
128
+ value = app_config.fetch(option)
129
+ raise "Option '#{option}' for app '#{app}' in 'controlplane.yml' must be an array." unless value.is_a?(Array)
130
+
131
+ value
132
+ end
133
+
110
134
  def action_description
111
- mode == "stop" ? "suspend all workloads in" : "delete"
135
+ mode == "stop" ? "suspend configured workloads in" : "delete"
112
136
  end
113
137
 
114
138
  def mode
@@ -80,6 +80,9 @@ module Command
80
80
  upstream_image = cp.latest_image(@upstream, @upstream_org) if !upstream_image || upstream_image == "latest"
81
81
  @commit = cp.extract_image_commit(upstream_image)
82
82
  @upstream_image_url = "#{@upstream_org}.registry.cpln.io/#{upstream_image}"
83
+ rescue ControlplaneApiDirect::ForbiddenError => e
84
+ Shell.write_to_tmp_stderr(e.message)
85
+ false
83
86
  end
84
87
  end
85
88