cpflow 6.0.0.rc.0 → 6.0.1

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 (63) 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-cleanup-stale-review-apps.yml +3 -10
  13. data/.github/workflows/cpflow-delete-review-app.yml +4 -11
  14. data/.github/workflows/cpflow-deploy-review-app.yml +19 -14
  15. data/.github/workflows/cpflow-deploy-staging.yml +17 -12
  16. data/.github/workflows/cpflow-promote-staging-to-production.yml +132 -13
  17. data/.github/workflows/rspec.yml +24 -0
  18. data/AGENTS.md +18 -3
  19. data/CHANGELOG.md +26 -3
  20. data/CONTRIBUTING.md +4 -4
  21. data/Gemfile.lock +1 -1
  22. data/README.md +5 -2
  23. data/docs/ai-github-flow-prompt.md +104 -8
  24. data/docs/ci-automation.md +258 -60
  25. data/docs/commands.md +32 -16
  26. data/docs/postgres-review-apps.md +90 -0
  27. data/docs/postgres.md +233 -375
  28. data/docs/secrets-and-env-values.md +42 -0
  29. data/docs/sidebars.ts +1 -0
  30. data/lib/command/ai_github_flow_prompt.rb +10 -13
  31. data/lib/command/ai_github_flow_prompt.txt +103 -0
  32. data/lib/command/apply_template.rb +36 -4
  33. data/lib/command/base.rb +56 -3
  34. data/lib/command/delete.rb +174 -4
  35. data/lib/command/deploy_image.rb +6 -0
  36. data/lib/command/generate.rb +247 -3
  37. data/lib/command/generate_github_actions.rb +15 -17
  38. data/lib/command/setup_app.rb +92 -10
  39. data/lib/command/update_github_actions.rb +107 -43
  40. data/lib/core/config.rb +73 -0
  41. data/lib/core/controlplane.rb +22 -2
  42. data/lib/core/controlplane_api.rb +28 -2
  43. data/lib/core/controlplane_api_direct.rb +17 -0
  44. data/lib/core/disposable_postgres_helpers.rb +167 -0
  45. data/lib/core/doctor_service.rb +7 -0
  46. data/lib/core/github_flow_readiness/checks.rb +2 -1
  47. data/lib/core/github_flow_readiness_service.rb +4 -0
  48. data/lib/core/repo_introspection.rb +112 -2
  49. data/lib/cpflow/version.rb +1 -1
  50. data/lib/generator_templates/.dockerignore +16 -0
  51. data/lib/generator_templates/Dockerfile +9 -5
  52. data/lib/generator_templates/entrypoint.sh +2 -0
  53. data/lib/generator_templates/templates/postgres.yml +9 -0
  54. data/lib/generator_templates_sqlite/controlplane.yml +2 -1
  55. data/lib/generator_templates_sqlite/release_script.sh +1 -1
  56. data/lib/generator_templates_sqlite/templates/rails.yml +2 -1
  57. data/lib/github_flow_templates/.github/cpflow-help.md +39 -12
  58. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +1 -0
  59. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +1 -0
  60. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +132 -13
  61. data/lib/github_flow_templates/bin/pin-cpflow-github-ref +37 -13
  62. data/lib/github_flow_templates/bin/test-cpflow-github-flow +88 -47
  63. metadata +12 -3
@@ -5,33 +5,52 @@ require "yaml"
5
5
  require_relative "staging_branch_validation"
6
6
 
7
7
  module Command
8
- class UpdateGithubActions < Base
8
+ class UpdateGithubActions < Base # rubocop:disable Metrics/ClassLength
9
9
  include StagingBranchValidation
10
10
 
11
11
  NAME = "update-github-actions"
12
- OPTIONS = [staging_branch_option].freeze
12
+ OPTIONS = [staging_branch_option, {
13
+ name: :workflows,
14
+ params: {
15
+ type: :array, banner: "FILE...",
16
+ desc: "Explicitly add or replace these generated workflow filenames (default: preserve all workflows)"
17
+ }
18
+ }].freeze
13
19
  DESCRIPTION = "Regenerates GitHub Actions files for the installed cpflow version"
14
20
  LONG_DESCRIPTION = <<~DESC.freeze
15
- Regenerates the cpflow workflow wrappers, local composite actions, and
16
- helper files from the currently installed cpflow gem. Use this after
17
- updating the cpflow gem so checked-in workflow wrappers move to the
18
- matching upstream release tag, for example `v#{Cpflow::VERSION}`, and the
19
- generated action implementations move with them.
20
-
21
- If the existing generated staging workflow uses a custom single staging
22
- branch, the command preserves it. Pass `--staging-branch BRANCH` to set or
23
- replace the generated staging branch explicitly.
21
+ Refreshes helper files from the installed gem. Workflows load cpflow's
22
+ composite actions from the pinned cpflow ref, so the command reports any
23
+ `.github/actions/cpflow-*` copies left by cpflow 6.0.0 for you to delete
24
+ once no workflow is pinned to 6.0.0. It never deletes them itself.
25
+ All top-level workflows are preserved by default, including their refs,
26
+ triggers, permissions, and deployment ownership. Use --workflows FILE...
27
+ to explicitly add or replace named generated workflows. Replacement resets
28
+ each selected workflow to its template and matching v#{Cpflow::VERSION} ref;
29
+ review and reapply any downstream customizations in the same PR.
30
+
31
+ Selecting cpflow-deploy-staging.yml preserves a single existing push branch
32
+ or the default main/master pair. Missing or ambiguous branches require
33
+ --staging-branch BRANCH. This option requires selecting the staging workflow.
34
+
35
+ A differing existing bin/test-cpflow-github-flow blocks all writes. Move
36
+ downstream checks to executable bin/test-cpflow-github-flow-custom and
37
+ remove or rename the old validator before updating. The generated validator
38
+ invokes that extension after its baseline checks; the extension is never
39
+ generated or overwritten. See docs/ci-automation.md for migration guidance.
24
40
  DESC
25
41
  EXAMPLES = <<~EX
26
42
  ```sh
27
- # After updating the cpflow gem, refresh every generated GitHub Actions file
43
+ # Refresh helpers while preserving downstream workflows
28
44
  cpflow update-github-actions
29
45
 
30
46
  # When running cpflow through Bundler
31
47
  bundle exec cpflow update-github-actions
32
48
 
33
- # Preserve or set a custom staging branch
34
- cpflow update-github-actions --staging-branch develop
49
+ # Explicitly replace the review-app wrapper
50
+ cpflow update-github-actions --workflows cpflow-deploy-review-app.yml
51
+
52
+ # Explicitly add or replace staging deployment from develop
53
+ cpflow update-github-actions --workflows cpflow-deploy-staging.yml --staging-branch develop
35
54
  ```
36
55
  EX
37
56
  WITH_INFO_HEADER = false
@@ -44,66 +63,111 @@ module Command
44
63
  def call
45
64
  GenerateGithubActions.ensure_template_root!
46
65
  abort_if_no_generated_files!
47
-
48
- branch = staging_branch || inferred_staging_branch
49
- GithubActionsGenerator.start([branch].compact)
66
+ workflows = selected_workflows
67
+ branch = selected_staging_branch(workflows)
68
+ abort_if_custom_validator!
69
+ GithubActionsGenerator.new([branch].compact, workflows: workflows).invoke_all
70
+ report_vendored_actions
50
71
 
51
72
  print_post_update_message
52
73
  end
53
74
 
54
75
  private
55
76
 
77
+ def selected_workflows
78
+ available = GenerateGithubActions.generated_files.grep(%r{\A\.github/workflows/}).map do |path|
79
+ File.basename(path)
80
+ end
81
+ selected = Array(config.options[:workflows])
82
+ unknown = selected - available
83
+ Shell.abort("Unknown --workflows: #{unknown.join(', ')}. Available: #{available.join(', ')}") if unknown.any?
84
+ selected
85
+ end
86
+
87
+ def selected_staging_branch(workflows)
88
+ branch = staging_branch
89
+ return branch || inferred_staging_branch if workflows.include?(STAGING_WORKFLOW_PATH.basename.to_s)
90
+
91
+ Shell.abort("--staging-branch requires --workflows cpflow-deploy-staging.yml") if branch
92
+ nil
93
+ end
94
+
95
+ def abort_if_custom_validator!
96
+ path = "bin/test-cpflow-github-flow"
97
+ return unless File.exist?(path)
98
+ return if File.binread(path) == File.binread(GenerateGithubActions.source_file(path))
99
+
100
+ Shell.abort(<<~MESSAGE)
101
+ #{path} differs from the current generated validator. No files were updated.
102
+ Move downstream checks to executable bin/test-cpflow-github-flow-custom, then remove or rename
103
+ #{path} and retry. See docs/ci-automation.md for the legacy validator migration.
104
+ MESSAGE
105
+ end
106
+
56
107
  def abort_if_no_generated_files!
57
108
  return if GenerateGithubActions.generated_files.any? { |path| File.exist?(path) }
109
+ return if vendored_action_directories.any?
58
110
 
59
- Shell.abort(
60
- "No generated cpflow GitHub Actions files found in this repository. " \
61
- "Run `cpflow generate-github-actions` first to create the wrappers, " \
62
- "then use `cpflow update-github-actions` after future gem upgrades."
63
- )
111
+ Shell.abort(<<~MESSAGE)
112
+ No generated cpflow GitHub Actions files found in this repository.
113
+ Run `cpflow generate-github-actions` first to create the wrappers,
114
+ then use `cpflow update-github-actions` after future gem upgrades.
115
+ MESSAGE
116
+ end
117
+
118
+ # cpflow 6.0.0 copied its composite actions here. Scan the caller's tree rather than this
119
+ # gem's action names, so a copy is still found after an action is renamed or removed.
120
+ def vendored_action_directories
121
+ Dir.glob(".github/actions/cpflow-*").select { |path| File.directory?(path) }.sort
122
+ end
123
+
124
+ # Whether a copy is still needed depends on the cpflow release each wrapper pins, which a
125
+ # full-SHA pin does not reveal, so deletion stays a reviewed human step.
126
+ def report_vendored_actions
127
+ directories = vendored_action_directories
128
+ return if directories.empty?
129
+
130
+ Shell.warn(<<~MESSAGE)
131
+ cpflow no longer generates or updates these local action copies:
132
+ #{directories.map { |path| "- #{path}" }.join("\n")}
133
+ A workflow pinned to cpflow 6.0.0 still runs them. Delete them once every cpflow workflow
134
+ in this repository is pinned to a later release and none has a
135
+ `uses: ./.github/actions/cpflow-*` line. See docs/ci-automation.md.
136
+ MESSAGE
64
137
  end
65
138
 
66
139
  def print_post_update_message
67
140
  Shell.info("")
68
- Shell.info("Updated cpflow GitHub Actions wrappers for cpflow #{Cpflow::VERSION}.")
141
+ Shell.info("Updated cpflow GitHub Actions files for cpflow #{Cpflow::VERSION}.")
142
+ Shell.info("Preserved unselected workflows; review their refs and compatibility before committing.")
69
143
  Shell.info("Next: review the diff and run `bin/test-cpflow-github-flow`.")
70
144
  Shell.info("If you run cpflow through Bundler, use `bin/test-cpflow-github-flow bundle exec cpflow`.")
71
145
  end
72
146
 
73
147
  def inferred_staging_branch
74
148
  branches = existing_staging_branches
75
- return if branches.empty? || branches.sort == DEFAULT_STAGING_BRANCHES.sort
76
- return branches.first if branches.length == 1
77
-
78
- Shell.warn(
79
- "Existing staging workflow has multiple custom push branches: #{branches.join(', ')}. " \
80
- "Run with --staging-branch BRANCH if only one branch should auto-deploy staging."
81
- )
149
+ return if branches.sort == DEFAULT_STAGING_BRANCHES.sort
150
+ return branches.first if branches.length == 1 && valid_staging_branch?(branches.first)
82
151
 
83
- nil
152
+ Shell.abort("Cannot infer staging deployment branch. Pass --staging-branch BRANCH to explicitly set it.")
84
153
  end
85
154
 
86
155
  def existing_staging_branches
87
- Array(parsed_staging_workflow_on.dig("push", "branches")).map(&:to_s)
156
+ push = parsed_staging_workflow_on["push"]
157
+ branches = push.is_a?(Hash) ? push["branches"] : nil
158
+ branches.is_a?(Array) && branches.all?(String) ? branches : []
88
159
  end
89
160
 
90
161
  def parsed_staging_workflow_on
91
162
  return {} unless STAGING_WORKFLOW_PATH.file?
92
163
 
93
164
  workflow = YAML.load_file(STAGING_WORKFLOW_PATH, aliases: true)
94
- return {} unless workflow.is_a?(Hash)
95
-
96
- workflow_on = workflow["on"] || workflow[true]
165
+ workflow_on = workflow.is_a?(Hash) ? workflow["on"] || workflow[true] : nil
97
166
  workflow_on.is_a?(Hash) ? workflow_on : {}
98
167
  rescue Psych::SyntaxError => e
99
- warn_unparseable_staging_workflow(e)
100
- {}
101
- end
102
-
103
- def warn_unparseable_staging_workflow(error)
104
- Shell.warn(
105
- "Could not parse #{STAGING_WORKFLOW_PATH}: #{error.message}. " \
106
- "Run with --staging-branch BRANCH if staging should use a custom branch."
168
+ Shell.abort(
169
+ "Could not parse #{STAGING_WORKFLOW_PATH}: #{e.message}. " \
170
+ "Pass --staging-branch BRANCH to explicitly replace its staging deployment branch."
107
171
  )
108
172
  end
109
173
  end
data/lib/core/config.rb CHANGED
@@ -10,6 +10,7 @@ class Config # rubocop:disable Metrics/ClassLength
10
10
  include Helpers
11
11
 
12
12
  CONFIG_FILE_LOCATION = ".controlplane/controlplane.yml"
13
+ GENERATED_REVIEW_APP_TAG = "cpflow-generated-review-app"
13
14
  REQUIRED_SHARED_SECRET_GRANT_KEYS = %i[name secret_name policy_name].freeze
14
15
  SHARED_SECRET_RESOURCE_NAME_KEYS = %i[secret_name policy_name].freeze
15
16
  CONTROL_PLANE_RESOURCE_NAME_REGEX = /\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/
@@ -52,6 +53,8 @@ class Config # rubocop:disable Metrics/ClassLength
52
53
  end
53
54
 
54
55
  def secrets
56
+ return "#{app}-secrets" if generated_review_secret_keys.any?
57
+
55
58
  current&.dig(:secrets_name) || "#{app_prefix}-secrets"
56
59
  end
57
60
 
@@ -59,6 +62,54 @@ class Config # rubocop:disable Metrics/ClassLength
59
62
  current&.dig(:secrets_policy_name) || "#{secrets}-policy"
60
63
  end
61
64
 
65
+ def generated_review_secret_keys
66
+ @generated_review_secret_keys ||= begin
67
+ keys = current&.dig(:generated_review_secret_keys)
68
+ if keys.nil?
69
+ []
70
+ else
71
+ validate_generated_review_secret_scope!
72
+ validate_generated_review_secret_keys!(keys)
73
+ keys
74
+ end
75
+ end
76
+ end
77
+
78
+ # Cleanup must still find marked per-app resources after the opt-in is removed
79
+ # from the current configuration.
80
+ def disposable_review_secret_resource_names
81
+ return nil unless current&.dig(:match_if_app_name_starts_with) && app.to_s != app_prefix.to_s
82
+ return nil unless app.match?(CONTROL_PLANE_RESOURCE_NAME_REGEX)
83
+
84
+ secret = "#{app}-secrets"
85
+ policy = "#{secret}-policy"
86
+ return nil unless policy.length <= 64
87
+
88
+ [secret, policy]
89
+ end
90
+
91
+ def validate_generated_review_secret_scope!
92
+ dynamic_review_app = current[:match_if_app_name_starts_with] && app.to_s != app_prefix.to_s
93
+ raise "generated_review_secret_keys is only allowed for a dynamically named review app." unless dynamic_review_app
94
+
95
+ unless "#{app}-secrets-policy".length <= 64 && app.match?(CONTROL_PLANE_RESOURCE_NAME_REGEX)
96
+ raise "Review app name is too long or invalid for a per-app secret policy."
97
+ end
98
+ return unless current[:secrets_name] || current[:secrets_policy_name]
99
+
100
+ raise "generated_review_secret_keys cannot be combined with secrets_name or secrets_policy_name."
101
+ end
102
+
103
+ def validate_generated_review_secret_keys!(keys)
104
+ valid_size = keys.is_a?(Array) && keys.size.between?(1, 8)
105
+ valid_names = valid_size && keys.all? { |key| key.is_a?(String) && key.match?(/\A[A-Z][A-Z0-9_]*\z/) }
106
+ return if valid_names && keys.uniq == keys
107
+
108
+ raise "generated_review_secret_keys must contain 1-8 unique uppercase environment names."
109
+ end
110
+
111
+ private :validate_generated_review_secret_scope!, :validate_generated_review_secret_keys!
112
+
62
113
  def shared_secret_grants
63
114
  @shared_secret_grants ||= normalize_shared_secret_grants(current&.dig(:shared_secret_grants))
64
115
  end
@@ -188,6 +239,28 @@ class Config # rubocop:disable Metrics/ClassLength
188
239
  end
189
240
  end
190
241
 
242
+ def validate_generated_review_secret_settings!
243
+ apps.each do |app_name, app_options|
244
+ keys = app_options[:generated_review_secret_keys]
245
+ next if keys.nil?
246
+
247
+ validate_generated_review_secret_entry_scope!(app_name, app_options)
248
+ validate_generated_review_secret_keys!(keys)
249
+ end
250
+ end
251
+
252
+ def validate_generated_review_secret_entry_scope!(app_name, app_options)
253
+ unless app_options[:match_if_app_name_starts_with]
254
+ raise "generated_review_secret_keys for '#{app_name}' requires a dynamically matched review app."
255
+ end
256
+ return unless app_options[:secrets_name] || app_options[:secrets_policy_name]
257
+
258
+ raise "generated_review_secret_keys for '#{app_name}' cannot be combined with " \
259
+ "secrets_name or secrets_policy_name."
260
+ end
261
+
262
+ private :validate_generated_review_secret_entry_scope!
263
+
191
264
  private
192
265
 
193
266
  def ensure_current_config!
@@ -434,8 +434,24 @@ class Controlplane # rubocop:disable Metrics/ClassLength
434
434
  api.fetch_secret(org: org, secret: secret)
435
435
  end
436
436
 
437
- def reveal_secret(secret)
438
- api.reveal_secret(org: org, secret: secret)
437
+ def reveal_secret(secret, required: false)
438
+ api.reveal_secret(org: org, secret: secret, required: required)
439
+ end
440
+
441
+ def create_sensitive_secret(secret, data)
442
+ api.create_sensitive_secret(org: org, secret: secret, app: gvc, data: data)
443
+ end
444
+
445
+ def patch_sensitive_secret_data(secret, data)
446
+ api.patch_sensitive_secret_data(org: org, secret: secret, data: data)
447
+ end
448
+
449
+ def secret_access_report(secret)
450
+ api.secret_access_report(org: org, secret: secret)
451
+ end
452
+
453
+ def delete_secret(secret)
454
+ api.delete_secret(org: org, secret: secret)
439
455
  end
440
456
 
441
457
  # identities
@@ -450,6 +466,10 @@ class Controlplane # rubocop:disable Metrics/ClassLength
450
466
  api.fetch_policy(org: org, policy: policy)
451
467
  end
452
468
 
469
+ def delete_policy(policy)
470
+ api.delete_policy(org: org, policy: policy)
471
+ end
472
+
453
473
  def bind_identity_to_policy(identity_link, policy)
454
474
  cmd = [
455
475
  "cpln", "policy", "add-binding", policy,
@@ -122,14 +122,40 @@ class ControlplaneApi # rubocop:disable Metrics/ClassLength
122
122
  api_json("/org/#{org}/secret/#{secret}", method: :get)
123
123
  end
124
124
 
125
- def reveal_secret(org:, secret:)
125
+ def create_sensitive_secret(org:, secret:, app:, data:)
126
+ api_json(
127
+ "/org/#{org}/secret", method: :post,
128
+ body: { kind: "secret", name: secret, type: "dictionary", data: data,
129
+ tags: { Config::GENERATED_REVIEW_APP_TAG => app } },
130
+ request_policy: ControlplaneApiDirect::SENSITIVE_MUTATION_REQUEST_POLICY
131
+ )
132
+ end
133
+
134
+ def patch_sensitive_secret_data(org:, secret:, data:)
135
+ api_json(
136
+ "/org/#{org}/secret/#{secret}", method: :patch,
137
+ body: { data: data },
138
+ request_policy: ControlplaneApiDirect::SENSITIVE_MUTATION_REQUEST_POLICY
139
+ )
140
+ end
141
+
142
+ def reveal_secret(org:, secret:, required: false)
143
+ request_policy = if required
144
+ ControlplaneApiDirect::REQUIRED_SENSITIVE_READ_REQUEST_POLICY
145
+ else
146
+ ControlplaneApiDirect::BEST_EFFORT_SENSITIVE_REQUEST_POLICY
147
+ end
126
148
  api_json(
127
149
  "/org/#{org}/secret/#{secret}/-reveal",
128
150
  method: :get,
129
- request_policy: ControlplaneApiDirect::BEST_EFFORT_SENSITIVE_REQUEST_POLICY
151
+ request_policy: request_policy
130
152
  )
131
153
  end
132
154
 
155
+ def secret_access_report(org:, secret:)
156
+ api_json("/org/#{org}/secret/#{secret}/-accessreport", method: :get)
157
+ end
158
+
133
159
  def delete_secret(org:, secret:)
134
160
  api_json("/org/#{org}/secret/#{secret}", method: :delete)
135
161
  end
@@ -75,6 +75,16 @@ class ControlplaneApiDirect # rubocop:disable Metrics/ClassLength
75
75
  retry_transient: false,
76
76
  timeout: BEST_EFFORT_TIMEOUT_SECONDS
77
77
  )
78
+ REQUIRED_SENSITIVE_READ_REQUEST_POLICY = RequestPolicy.new(
79
+ sensitive: true,
80
+ retry_transient: true,
81
+ timeout: 30
82
+ )
83
+ SENSITIVE_MUTATION_REQUEST_POLICY = RequestPolicy.new(
84
+ sensitive: true,
85
+ retry_transient: false,
86
+ timeout: 30
87
+ )
78
88
 
79
89
  # Thread-safe API token cache. A single instance is shared process-wide by
80
90
  # default (see `default_token_provider`) so each `ControlplaneApiDirect.new`
@@ -328,6 +338,7 @@ class ControlplaneApiDirect # rubocop:disable Metrics/ClassLength
328
338
  def handle_response(response, url, request_policy)
329
339
  case response
330
340
  when Net::HTTPOK then parse_response_body(response, request_policy)
341
+ when Net::HTTPCreated then parse_created_response_body(response, request_policy)
331
342
  when Net::HTTPAccepted then true
332
343
  when Net::HTTPNotFound then nil
333
344
  when Net::HTTPForbidden then raise(ForbiddenError.new(url: url, response: response))
@@ -343,6 +354,12 @@ class ControlplaneApiDirect # rubocop:disable Metrics/ClassLength
343
354
  raise JSON::ParserError, "Control Plane API returned invalid JSON for a sensitive request.", cause: nil
344
355
  end
345
356
 
357
+ def parse_created_response_body(response, request_policy)
358
+ return true if response.body.to_s.empty?
359
+
360
+ parse_response_body(response, request_policy)
361
+ end
362
+
346
363
  def response_error_message(response, request_policy)
347
364
  return response.to_s if request_policy.sensitive
348
365
 
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ownership is asserted only by opted-in templates creating new resources. Names
4
+ # locate candidates; the marker, shape and exact policy scope authorize cleanup.
5
+ class DisposablePostgresHelpers # rubocop:disable Metrics/ClassLength
6
+ TAG = "cpflow-disposable-postgres-app"
7
+
8
+ def initialize(config, controlplane)
9
+ @config = config
10
+ @cp = controlplane
11
+ end
12
+
13
+ def prepare_templates!(templates) # rubocop:disable Metrics/CyclomaticComplexity
14
+ marked = templates.select { |template| marked?(template) }
15
+ return if marked.empty?
16
+
17
+ unless disposable?
18
+ marked.each { |template| template["tags"].delete(TAG) }
19
+ return
20
+ end
21
+ candidates = templates.select { |template| resource_types.key?(template.values_at("kind", "name")) }
22
+ existing = fetch_resources
23
+ refuse! unless (candidates + existing).all? { |resource| owned?(resource) }
24
+ ensure_not_shared!(existing)
25
+ end
26
+
27
+ def validate!
28
+ return [] unless disposable?
29
+
30
+ resources = fetch_resources
31
+ # Legacy templates did not assert ownership. Preserve them without adoption.
32
+ return [] unless resources.any? { |resource| marked?(resource) }
33
+
34
+ refuse! unless resources.all? { |resource| owned?(resource) }
35
+ ensure_not_shared!(resources)
36
+ resources
37
+ end
38
+
39
+ def delete(&operation)
40
+ # Remove access before secrets. Surviving marked secrets can be cleaned up
41
+ # after a failure even when the policy or GVC no longer exists.
42
+ validate!.sort_by { |resource| resource["kind"] == "policy" ? 0 : 1 }.each do |resource|
43
+ current = validate!.find { |item| item["name"] == resource["name"] }
44
+ next unless current
45
+
46
+ kind, name = current.values_at("kind", "name")
47
+ operation.call(kind, name) { kind == "policy" ? @cp.delete_policy(name) : @cp.delete_secret(name) }
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def disposable?
54
+ !@config.disposable_review_secret_resource_names.nil?
55
+ end
56
+
57
+ def marked?(resource)
58
+ resource["tags"].is_a?(Hash) && resource["tags"].key?(TAG)
59
+ end
60
+
61
+ def fetch_resources
62
+ resource_types.filter_map do |(kind, name), _type|
63
+ resource = fetch(kind, name)
64
+ resource&.merge("kind" => kind)
65
+ end
66
+ end
67
+
68
+ def shared?
69
+ names = resource_types.keys.map(&:last)
70
+ @config.shared_secret_grants.any? do |grant|
71
+ [grant[:secret_name], grant[:policy_name]].any? { |name| names.include?(name) }
72
+ end
73
+ end
74
+
75
+ def ensure_not_shared!(resources)
76
+ refuse! if shared?
77
+ resources.select { |resource| resource["kind"] == "secret" }.each do |secret|
78
+ report = @cp.secret_access_report(secret["name"])
79
+ refuse! unless exclusive_access_report?(report)
80
+ end
81
+ end
82
+
83
+ def exclusive_access_report?(report) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
84
+ return false unless report.is_a?(Hash) && report["kind"] == "accessreport"
85
+
86
+ permissions = report["permissions"]
87
+ return false unless permissions.is_a?(Array) && permissions.all?(Hash)
88
+
89
+ %w[reveal use].all? do |name|
90
+ entries = permissions.select { |item| item["name"] == name }
91
+ entries.one? && entries.first["bindings"].is_a?(Array) &&
92
+ entries.first["bindings"].all? { |binding| exclusive_access_binding?(binding) }
93
+ end
94
+ end
95
+
96
+ def exclusive_access_binding?(binding)
97
+ return false unless binding.is_a?(Hash)
98
+
99
+ principal = binding["principalLink"]
100
+ policy_link = "/org/#{@config.org}/policy/#{@config.app}-pg-access"
101
+ return true if binding["grantingPolicyLink"] == policy_link && app_principals.include?(principal)
102
+
103
+ # Global human/service administration is not an application dependency.
104
+ # A targeted/query grant, or ANY other GVC identity, still means shared use.
105
+ admin_path = %r{\A/org/#{Regexp.escape(@config.org)}/(?:group|user|serviceaccount)/[^/]+\z}
106
+ binding["match"] == "all" && binding["grantedPermissions"] == ["manage"] &&
107
+ principal.is_a?(String) && principal.match?(admin_path)
108
+ end
109
+
110
+ def app_principals
111
+ [@config.identity, "#{@config.app}-pg-identity"].flat_map do |identity|
112
+ path = "gvc/#{@config.app}/identity/#{identity}"
113
+ ["//#{path}", "/org/#{@config.org}/#{path}"]
114
+ end
115
+ end
116
+
117
+ def resource_types
118
+ app = @config.app
119
+ { ["secret", "#{app}-pg"] => "dictionary", ["secret", "#{app}-pg-script"] => "opaque",
120
+ ["policy", "#{app}-pg-access"] => nil }
121
+ end
122
+
123
+ def fetch(kind, name)
124
+ kind == "policy" ? @cp.fetch_policy(name) : @cp.fetch_secret(name)
125
+ end
126
+
127
+ def owned?(resource)
128
+ key = resource.values_at("kind", "name")
129
+ tags = resource["tags"]
130
+ return false unless resource_types.key?(key) && tags.is_a?(Hash) && tags[TAG] == @config.app
131
+
132
+ resource["kind"] == "secret" ? resource["type"] == resource_types[key] : policy_owned?(resource)
133
+ end
134
+
135
+ def policy_owned?(policy)
136
+ return false unless policy["targetKind"] == "secret" &&
137
+ %w[target targetQuery gvc].all? { |key| policy[key].nil? }
138
+
139
+ targets_owned?(policy["targetLinks"]) && bindings_owned?(policy["bindings"])
140
+ end
141
+
142
+ def targets_owned?(links)
143
+ names = ["#{@config.app}-pg", "#{@config.app}-pg-script"]
144
+ links.is_a?(Array) && links.size == 2 && names.all? do |name|
145
+ links.one? { |link| ["//secret/#{name}", "/org/#{@config.org}/secret/#{name}"].include?(link) }
146
+ end
147
+ end
148
+
149
+ def bindings_owned?(bindings)
150
+ return false unless bindings.is_a?(Array)
151
+
152
+ bindings.all? { |binding| binding_owned?(binding, app_principals) }
153
+ end
154
+
155
+ def binding_owned?(binding, principals)
156
+ return false unless binding.is_a?(Hash) && (binding.keys - %w[permissions principalLinks]).empty?
157
+
158
+ permissions, links = binding.values_at("permissions", "principalLinks")
159
+ permissions.is_a?(Array) && (permissions - ["reveal"]).empty? &&
160
+ links.is_a?(Array) && (links - principals).empty?
161
+ end
162
+
163
+ def refuse!
164
+ raise "PostgreSQL helper resources have unexpected ownership, grants, target, or access report; " \
165
+ "leaving them for inspection."
166
+ end
167
+ end
@@ -37,6 +37,7 @@ class DoctorService # rubocop:disable Metrics/ClassLength
37
37
  def validate_config
38
38
  check_for_app_names_contained_in_others
39
39
  check_deploy_orders
40
+ check_generated_review_secret_settings
40
41
  end
41
42
 
42
43
  def validate_templates
@@ -66,6 +67,12 @@ class DoctorService # rubocop:disable Metrics/ClassLength
66
67
  raise ValidationError, Shell.color("ERROR: #{e.message}", :red)
67
68
  end
68
69
 
70
+ def check_generated_review_secret_settings
71
+ config.validate_generated_review_secret_settings!
72
+ rescue RuntimeError => e
73
+ raise ValidationError, Shell.color("ERROR: #{e.message}", :red)
74
+ end
75
+
69
76
  def find_app_names_contained_in_others # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
70
77
  app_names = config.apps.keys.map(&:to_s).sort
71
78
  app_prefixes = config.apps
@@ -105,10 +105,11 @@ module GithubFlowReadiness
105
105
  class SqliteProduction < Base
106
106
  def call
107
107
  return unless service.sqlite_database_in_production?
108
+ return if service.dynamic_database_url_in_production?
108
109
 
109
110
  info_result(
110
111
  "Production database config uses SQLite. `cpflow generate` will scaffold " \
111
- "persistent `db` and `storage` volumes."
112
+ "persistent SQLite data and Active Storage volumes."
112
113
  )
113
114
  end
114
115
  end
@@ -105,6 +105,10 @@ class GithubFlowReadinessService # rubocop:disable Metrics/ClassLength
105
105
  RepoIntrospection.sqlite_database_in_production?(root_path.to_s)
106
106
  end
107
107
 
108
+ def dynamic_database_url_in_production?
109
+ RepoIntrospection.dynamic_database_url_in_production?(root_path.to_s)
110
+ end
111
+
108
112
  def parsed_package_json
109
113
  return @parsed_package_json if instance_variable_defined?(:@parsed_package_json)
110
114