cpflow 6.0.0.rc.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/agent-workflow.yml +16 -38
  3. data/.agents/bin/README.md +7 -8
  4. data/.agents/legacy-workflow-policy.yml +11 -0
  5. data/.agents/shaka.md +20 -0
  6. data/.agents/trusted-actions.yml +12 -0
  7. data/.coderabbit.yaml +1 -0
  8. data/.github/actions/cpflow-build-docker-image/action.yml +153 -22
  9. data/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +3 -0
  10. data/.github/actions/cpflow-preview-promotion/action.yml +82 -0
  11. data/.github/actions/cpflow-preview-promotion/preview-promotion.sh +90 -0
  12. data/.github/workflows/cpflow-deploy-review-app.yml +13 -1
  13. data/.github/workflows/cpflow-deploy-staging.yml +12 -1
  14. data/.github/workflows/cpflow-promote-staging-to-production.yml +128 -9
  15. data/.github/workflows/rspec.yml +24 -0
  16. data/AGENTS.md +18 -3
  17. data/CHANGELOG.md +18 -3
  18. data/CONTRIBUTING.md +1 -1
  19. data/Gemfile.lock +1 -1
  20. data/README.md +5 -2
  21. data/docs/ai-github-flow-prompt.md +2 -2
  22. data/docs/ci-automation.md +178 -14
  23. data/docs/commands.md +26 -13
  24. data/docs/postgres-review-apps.md +90 -0
  25. data/docs/postgres.md +233 -375
  26. data/docs/secrets-and-env-values.md +42 -0
  27. data/docs/sidebars.ts +1 -0
  28. data/lib/command/ai_github_flow_prompt.rb +2 -2
  29. data/lib/command/apply_template.rb +36 -4
  30. data/lib/command/base.rb +56 -3
  31. data/lib/command/delete.rb +174 -4
  32. data/lib/command/deploy_image.rb +6 -0
  33. data/lib/command/generate.rb +247 -3
  34. data/lib/command/generate_github_actions.rb +7 -1
  35. data/lib/command/setup_app.rb +92 -10
  36. data/lib/command/update_github_actions.rb +81 -43
  37. data/lib/core/config.rb +73 -0
  38. data/lib/core/controlplane.rb +22 -2
  39. data/lib/core/controlplane_api.rb +28 -2
  40. data/lib/core/controlplane_api_direct.rb +17 -0
  41. data/lib/core/disposable_postgres_helpers.rb +167 -0
  42. data/lib/core/doctor_service.rb +7 -0
  43. data/lib/core/github_flow_readiness/checks.rb +2 -1
  44. data/lib/core/github_flow_readiness_service.rb +4 -0
  45. data/lib/core/repo_introspection.rb +112 -2
  46. data/lib/cpflow/version.rb +1 -1
  47. data/lib/generator_templates/.dockerignore +16 -0
  48. data/lib/generator_templates/Dockerfile +9 -5
  49. data/lib/generator_templates/entrypoint.sh +2 -0
  50. data/lib/generator_templates/templates/postgres.yml +9 -0
  51. data/lib/generator_templates_sqlite/controlplane.yml +2 -1
  52. data/lib/generator_templates_sqlite/release_script.sh +1 -1
  53. data/lib/generator_templates_sqlite/templates/rails.yml +2 -1
  54. data/lib/github_flow_templates/.github/cpflow-help.md +39 -12
  55. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +1 -0
  56. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +1 -0
  57. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +128 -9
  58. data/lib/github_flow_templates/bin/pin-cpflow-github-ref +37 -13
  59. data/lib/github_flow_templates/bin/test-cpflow-github-flow +62 -43
  60. metadata +11 -3
@@ -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
  ],
@@ -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. 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, 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 workflows and local actions move together. 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,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 Delete < Base # rubocop:disable Metrics/ClassLength
5
7
  NAME = "delete"
@@ -14,6 +16,7 @@ module Command
14
16
  - Deletes the whole app (GVC with all workloads, all volumesets and all images) or a specific workload
15
17
  - Also 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)
16
18
  - For the app-specific secrets policy, removes every permission held by the app identity; for `shared_secret_grants`, removes only `reveal`
19
+ - Removes a marked per-app dictionary and exact-target unbound policy for generated review credentials, including after the opt-in is removed from a dynamically matched review-app entry
17
20
  - Will ask for explicit user confirmation
18
21
  - Runs a pre-deletion hook before the app is deleted if `hooks.pre_deletion` is specified in the `.controlplane/controlplane.yml` file
19
22
  - If the hook exits with a non-zero code, the command will stop executing and also exit with a non-zero code
@@ -50,8 +53,10 @@ module Command
50
53
  end
51
54
 
52
55
  def delete_whole_app
53
- return progress.puts("App '#{config.app}' does not exist.") if cp.fetch_gvc.nil?
56
+ return handle_missing_app if cp.fetch_gvc.nil?
54
57
 
58
+ postgres_helpers.validate!
59
+ validate_disposable_review_secret_resources!
55
60
  check_volumesets
56
61
  check_images
57
62
  return unless confirm_delete(config.app)
@@ -61,11 +66,104 @@ module Command
61
66
  policy_unbinds = secret_policy_unbinds
62
67
  run_pre_deletion_hook unless config.options[:skip_pre_deletion_hook]
63
68
  unbind_identity_from_policy(policy_unbinds)
69
+ delete_app_resources
70
+ end
71
+
72
+ def handle_missing_app
73
+ progress.puts("App '#{config.app}' does not exist.")
74
+ cleanup_state = validate_disposable_review_secret_resources!
75
+ postgres_resources = postgres_helpers.validate!
76
+ return if cleanup_state == :none && postgres_resources.empty?
77
+ return unless confirm_delete("disposable secrets for app #{config.app}")
78
+
79
+ unbind_missing_app_disposable_policy! if cleanup_state == :own_binding
80
+ delete_generated_review_secret_resources
81
+ delete_postgres_helpers
82
+ end
83
+
84
+ def validate_disposable_review_secret_resources!
85
+ cleanup_state = disposable_review_secret_cleanup_state
86
+ if cleanup_state == :unsafe
87
+ raise "Review app secret resources have unexpected ownership, grants, or target; leaving them for inspection."
88
+ end
89
+
90
+ cleanup_state
91
+ end
92
+
93
+ def disposable_review_secret_cleanup_state
94
+ names = config.disposable_review_secret_resource_names
95
+ return :none unless names
96
+
97
+ secret_name, policy_name = names
98
+ policy = cp.fetch_policy(policy_name)
99
+ secret = cp.fetch_secret(secret_name)
100
+ return :none if policy.nil? && secret.nil?
101
+
102
+ classify_disposable_review_secret_resources(policy, secret, secret_name)
103
+ end
104
+
105
+ def classify_disposable_review_secret_resources(policy, secret, secret_name)
106
+ return generated_review_secret_owned_by_app?(secret, secret_name) ? :safe : :unsafe if policy.nil?
107
+ return :unsafe unless generated_review_secret_policy?(policy, secret_name)
108
+ return :unsafe unless generated_review_secret_or_absent?(secret, secret_name)
109
+ return :safe if unbound_review_policy?(policy)
110
+
111
+ only_app_identity_bindings?(policy) ? :own_binding : :unsafe
112
+ end
113
+
114
+ def generated_review_secret_or_absent?(secret, secret_name)
115
+ secret.nil? || generated_review_secret_owned_by_app?(secret, secret_name)
116
+ end
117
+
118
+ def only_app_identity_bindings?(policy)
119
+ generated_review_policy_binding_state(policy) == :bound
120
+ end
121
+
122
+ def unbound_review_policy?(policy)
123
+ generated_review_policy_binding_state(policy) == :unbound
124
+ end
125
+
126
+ def unbind_missing_app_disposable_policy!
127
+ secret_name, policy_name = config.disposable_review_secret_resource_names
128
+ policy = cp.fetch_policy(policy_name)
129
+ return if policy.nil?
130
+
131
+ unless generated_review_secret_policy?(policy, secret_name) &&
132
+ (unbound_review_policy?(policy) || only_app_identity_bindings?(policy))
133
+ refuse_unsafe_review_secret_resources!
134
+ end
135
+
136
+ remove_missing_app_policy_bindings(policy, policy_name)
137
+ end
138
+
139
+ def remove_missing_app_policy_bindings(policy, policy_name)
140
+ identity_policy_permissions(policy).each do |permission|
141
+ step("Unbinding deleted app identity from disposable review app policy") do
142
+ cp.unbind_identity_from_policy(config.identity_link, policy_name, permission: permission)
143
+ end
144
+ end
145
+ end
146
+
147
+ def delete_app_resources
64
148
  delete_volumesets
149
+ # Keep the GVC discoverable by stale-app cleanup if disposable resource deletion fails.
150
+ # The app identity has already been unbound from its secret policy.
151
+ delete_generated_review_secret_resources
152
+ delete_postgres_helpers
65
153
  delete_gvc
66
154
  delete_images
67
155
  end
68
156
 
157
+ def postgres_helpers
158
+ @postgres_helpers ||= DisposablePostgresHelpers.new(config, cp)
159
+ end
160
+
161
+ def delete_postgres_helpers
162
+ postgres_helpers.delete do |kind, name, &operation|
163
+ step("Deleting disposable PostgreSQL #{kind} '#{name}'", &operation)
164
+ end
165
+ end
166
+
69
167
  def check_volumesets
70
168
  @volumesets = cp.fetch_volumesets["items"]
71
169
  return progress.puts("No volumesets to delete from app '#{config.app}'.") unless @volumesets.any?
@@ -103,6 +201,53 @@ module Command
103
201
  end
104
202
  end
105
203
 
204
+ def delete_generated_review_secret_resources
205
+ names = config.disposable_review_secret_resource_names
206
+ return unless names
207
+
208
+ secret_name, policy_name = names
209
+
210
+ policy = cp.fetch_policy(policy_name)
211
+ secret = cp.fetch_secret(secret_name)
212
+ return delete_generated_review_secret_without_policy(secret, secret_name) if policy.nil?
213
+
214
+ delete_generated_review_secret_with_policy(policy, secret, secret_name, policy_name)
215
+ end
216
+
217
+ def delete_generated_review_secret_without_policy(secret, secret_name)
218
+ return if secret.nil?
219
+
220
+ refuse_unsafe_review_secret_resources! unless generated_review_secret_owned_by_app?(secret, secret_name)
221
+
222
+ step("Deleting orphaned disposable review app secret dictionary") { cp.delete_secret(secret_name) }
223
+ end
224
+
225
+ def delete_generated_review_secret_with_policy(policy, secret, secret_name, policy_name)
226
+ refuse_unsafe_review_secret_resources! unless disposable_review_secret_policy?(policy, secret_name)
227
+
228
+ if secret
229
+ refuse_unsafe_review_secret_resources! unless generated_review_secret_owned_by_app?(secret, secret_name)
230
+
231
+ step("Deleting disposable review app secret dictionary") { cp.delete_secret(secret_name) }
232
+ end
233
+ step("Deleting disposable review app secret policy") { cp.delete_policy(policy_name) }
234
+ end
235
+
236
+ def refuse_unsafe_review_secret_resources!
237
+ raise "Review app secret resources have unexpected ownership, grants, or target; " \
238
+ "leaving secret resources for inspection."
239
+ end
240
+
241
+ def disposable_review_secret_policy?(policy, secret_name)
242
+ generated_review_secret_policy?(policy, secret_name) && unbound_review_policy?(policy)
243
+ end
244
+
245
+ def generated_review_secret_policy?(policy, secret_name)
246
+ policy_targets_secret?(policy, secret_name) &&
247
+ generated_review_app_tag(policy) == config.app &&
248
+ %w[target targetQuery gvc].all? { |key| policy[key].nil? }
249
+ end
250
+
106
251
  def delete_workload(workload)
107
252
  step("Deleting workload '#{workload}' from app '#{config.app}'") do
108
253
  cp.delete_workload(workload)
@@ -136,10 +281,13 @@ module Command
136
281
  end
137
282
 
138
283
  def secret_policy_unbinds
139
- return [] if cp.fetch_identity(config.identity).nil?
284
+ unless cp.fetch_identity(config.identity)
285
+ return [disposable_review_secret_policy_unbind(include_current: true)].compact
286
+ end
140
287
 
141
288
  [
142
289
  app_secret_policy_unbind,
290
+ disposable_review_secret_policy_unbind,
143
291
  *shared_secret_policy_unbinds
144
292
  ].compact
145
293
  end
@@ -151,6 +299,28 @@ module Command
151
299
  )
152
300
  end
153
301
 
302
+ def disposable_review_secret_policy_unbind(include_current: false)
303
+ names = config.disposable_review_secret_resource_names
304
+ return unless names && (include_current || names.last != config.secrets_policy)
305
+
306
+ secret_name, policy_name = names
307
+ policy = verified_disposable_review_secret_policy(secret_name, policy_name)
308
+ return unless policy
309
+
310
+ policy_unbind_for(policy_name, "Unbinding identity from disposable review app policy '#{policy_name}'", policy)
311
+ end
312
+
313
+ def verified_disposable_review_secret_policy(secret_name, policy_name)
314
+ secret = cp.fetch_secret(secret_name)
315
+ return if secret && !generated_review_secret_owned_by_app?(secret, secret_name)
316
+
317
+ policy = cp.fetch_policy(policy_name)
318
+ return unless policy && generated_review_secret_policy?(policy, secret_name) &&
319
+ (unbound_review_policy?(policy) || only_app_identity_bindings?(policy))
320
+
321
+ policy
322
+ end
323
+
154
324
  def shared_secret_policy_unbinds
155
325
  config.shared_secret_grants.filter_map do |grant|
156
326
  shared_secret_policy_unbind(grant)
@@ -190,8 +360,8 @@ module Command
190
360
  )
191
361
  end
192
362
 
193
- def policy_unbind_for(policy_name, message)
194
- policy = cp.fetch_policy(policy_name)
363
+ def policy_unbind_for(policy_name, message, policy = nil)
364
+ policy ||= cp.fetch_policy(policy_name)
195
365
  return if policy.nil?
196
366
 
197
367
  permissions = identity_policy_permissions(policy)
@@ -145,6 +145,7 @@ module Command
145
145
 
146
146
  def update_workload_image_ref(workload, container, image)
147
147
  attempts = 0
148
+ mark_deployment_started
148
149
 
149
150
  loop do
150
151
  attempts += 1
@@ -158,6 +159,11 @@ module Command
158
159
  end
159
160
  end
160
161
 
162
+ def mark_deployment_started
163
+ marker_path = ENV.fetch("CPFLOW_DEPLOYMENT_STARTED_FILE", "")
164
+ File.write(marker_path, "started\n") unless marker_path.empty?
165
+ end
166
+
161
167
  def workload_image_update_limit(output)
162
168
  return WORKLOAD_IMAGE_UPDATE_CONFLICT_MAX_ATTEMPTS if output.match?(WORKLOAD_IMAGE_UPDATE_CONFLICT_PATTERN)
163
169