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
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "yaml"
4
+ require "pathname"
5
+ require "shellwords"
4
6
 
5
7
  require_relative "generator_helpers"
6
8
  require_relative "../core/repo_introspection"
@@ -34,10 +36,29 @@ module Command
34
36
  # `.tool-versions`, or the `Gemfile`. Keep this on a supported release line
35
37
  # (https://www.ruby-lang.org/en/downloads/branches/).
36
38
  DEFAULT_RUBY_VERSION = "3.3"
39
+ SQLITE_DATABASE_PREPARE_FUNCTION = <<~SH
40
+ prepare_sqlite_database() {
41
+ source_path="$1"
42
+ persistent_path="$2"
43
+ legacy_path="${3:-}"
44
+ if [ -n "${legacy_path}" ] && [ -e "${legacy_path}" ] && [ ! -e "${persistent_path}" ]; then
45
+ persistent_path="${legacy_path}"
46
+ fi
47
+ mkdir -p "$(dirname "${source_path}")" "$(dirname "${persistent_path}")"
48
+ if [ -e "${source_path}" ] && [ ! -L "${source_path}" ] && [ ! -e "${persistent_path}" ]; then
49
+ mv -n "${source_path}" "${persistent_path}"
50
+ fi
51
+ rm -f "${source_path}"
52
+ ln -s "${persistent_path}" "${source_path}"
53
+ }
54
+ SH
37
55
 
38
56
  def copy_files
57
+ validate_sqlite_database_paths!
39
58
  generated_paths = copy_template_files("generator_templates", base_template_files)
40
59
  generated_paths += copy_template_files("generator_templates_sqlite", SQLITE_TEMPLATE_FILES) if sqlite_project?
60
+ copy_dockerignore unless File.exist?(".dockerignore")
61
+ append_dockerignore_entries
41
62
  substitute_template_variables(generated_paths)
42
63
  make_shell_scripts_executable(generated_paths)
43
64
  end
@@ -64,6 +85,78 @@ module Command
64
85
  destination_path
65
86
  end
66
87
 
88
+ def copy_dockerignore
89
+ copy_file(
90
+ File.join("generator_templates", ".dockerignore"),
91
+ ".dockerignore",
92
+ verbose: ENV.fetch("HIDE_COMMAND_OUTPUT", nil) != "true"
93
+ )
94
+ end
95
+
96
+ def append_dockerignore_entries
97
+ contents = File.read(".dockerignore")
98
+ existing_lines = contents.lines(chomp: true)
99
+ additions = dockerignore_additions(existing_lines)
100
+ return if additions.empty?
101
+
102
+ File.open(".dockerignore", "a") do |file|
103
+ file.write("\n") unless contents.empty? || contents.end_with?("\n")
104
+ file.puts(additions)
105
+ end
106
+ end
107
+
108
+ def dockerignore_additions(existing_lines)
109
+ entries = File.readlines(File.join(self.class.source_root, "generator_templates", ".dockerignore"), chomp: true)
110
+ sqlite_entries = sqlite_project? ? sqlite_database_ignore_entries : []
111
+ entries += sqlite_entries
112
+ env_entries = [".env*", "!.env.example"]
113
+ mandatory_exclusions = ["config/master.key", "config/credentials/*.key", *sqlite_entries]
114
+ ordinary_entries = entries - env_entries - mandatory_exclusions
115
+ additions = ordinary_entries - existing_lines
116
+ additions += mandatory_exclusions.reject { |entry| dockerignore_exclusion_effective?(existing_lines, entry) }
117
+ additions += env_entries unless dockerignore_env_exception_preserved?(existing_lines)
118
+ additions
119
+ end
120
+
121
+ def dockerignore_exclusion_effective?(lines, exclusion)
122
+ exclusion_index = lines.rindex(exclusion)
123
+ exclusion_index && lines[(exclusion_index + 1)..].none? { |line| line.start_with?("!") }
124
+ end
125
+
126
+ def dockerignore_env_exception_preserved?(lines)
127
+ exclusion_index = lines.rindex(".env*")
128
+ exception_index = lines.rindex("!.env.example")
129
+ return false unless exclusion_index && exception_index && exception_index > exclusion_index
130
+
131
+ lines[(exception_index + 1)..].none? { |line| line.start_with?("!") }
132
+ end
133
+
134
+ def sqlite_database_ignore_entries
135
+ RepoIntrospection.sqlite_database_paths_in_production(Dir.pwd).flat_map do |database_path|
136
+ relative = docker_context_database_path(database_path)
137
+ next [] unless relative
138
+
139
+ base = "/#{escape_dockerignore_path(relative)}"
140
+ [base, "#{base}-wal", "#{base}-shm", "#{base}-journal"]
141
+ end.uniq
142
+ end
143
+
144
+ def escape_dockerignore_path(path)
145
+ path.gsub(/[\\*?\[\]]/) { |character| "\\#{character}" }
146
+ end
147
+
148
+ def docker_context_database_path(database_path)
149
+ path = Pathname.new(database_path).cleanpath
150
+ if path.absolute?
151
+ return unless path.to_s.start_with?("/app/")
152
+
153
+ return path.to_s.delete_prefix("/app/")
154
+ end
155
+
156
+ normalized = path.to_s
157
+ normalized unless normalized == "." || normalized.start_with?("../")
158
+ end
159
+
67
160
  def base_template_files
68
161
  COMMON_TEMPLATE_FILES + (sqlite_project? ? [] : POSTGRES_TEMPLATE_FILES)
69
162
  end
@@ -72,7 +165,8 @@ module Command
72
165
  {
73
166
  "__APP_PREFIX__" => inferred_app_prefix,
74
167
  "__RUBY_VERSION__" => inferred_ruby_version,
75
- "__ASSET_PRECOMPILE_HOOK_RUN__" => asset_precompile_hook_run
168
+ "__ASSET_PRECOMPILE_HOOK_RUN__" => asset_precompile_hook_run,
169
+ "__SQLITE_DATABASE_SETUP__" => sqlite_database_setup
76
170
  }
77
171
  end
78
172
 
@@ -99,7 +193,7 @@ module Command
99
193
  return "" if stripped.empty?
100
194
  return "" unless single_line_asset_precompile_hook?(stripped)
101
195
 
102
- "RUN #{stripped}\n\n"
196
+ "RUN export SECRET_KEY_BASE=NOT_USED_NON_BLANK && #{stripped}\n\n"
103
197
  end
104
198
 
105
199
  def single_line_asset_precompile_hook?(command)
@@ -113,6 +207,156 @@ module Command
113
207
  RepoIntrospection.sqlite_database_in_production?(Dir.pwd)
114
208
  end
115
209
 
210
+ def validate_sqlite_database_paths!
211
+ return unless sqlite_project?
212
+
213
+ validate_sqlite_database_locations!
214
+ validate_sqlite_persistence_targets!
215
+ return unless RepoIntrospection.unresolved_sqlite_database_paths_in_production?(Dir.pwd)
216
+
217
+ raise Cpflow::Error,
218
+ "Production SQLite database paths must be literal file paths in config/database.yml; " \
219
+ "runtime ERB paths cannot be persisted safely by the generated scaffold."
220
+ end
221
+
222
+ def validate_sqlite_database_locations!
223
+ database_paths = RepoIntrospection.sqlite_database_paths_in_production(Dir.pwd)
224
+ validate_sqlite_paths_without_control_characters!(database_paths)
225
+ validate_sqlite_paths_under_app!(database_paths)
226
+ validate_sqlite_paths_below_mount_roots!(database_paths)
227
+ end
228
+
229
+ def validate_sqlite_paths_without_control_characters!(database_paths)
230
+ unsafe_path = database_paths.find { |database_path| database_path.match?(/[[:cntrl:]]/) }
231
+ return unless unsafe_path
232
+
233
+ raise Cpflow::Error,
234
+ "Production SQLite database paths cannot contain control characters; update config/database.yml " \
235
+ "before generating the scaffold."
236
+ end
237
+
238
+ def validate_sqlite_paths_under_app!(database_paths)
239
+ unsupported_path = database_paths.find do |database_path|
240
+ path = absolute_app_database_path(database_path)
241
+ path != "/app" && !path.start_with?("/app/")
242
+ end
243
+ return unless unsupported_path
244
+
245
+ raise Cpflow::Error,
246
+ "Production SQLite database path #{unsupported_path.inspect} must resolve under /app so the generated " \
247
+ "scaffold can persist it safely."
248
+ end
249
+
250
+ def validate_sqlite_paths_below_mount_roots!(database_paths)
251
+ mount_root_path = database_paths.find do |database_path|
252
+ ["/app/data", "/app/storage"].include?(absolute_app_database_path(database_path))
253
+ end
254
+ return unless mount_root_path
255
+
256
+ raise Cpflow::Error,
257
+ "Production SQLite database path #{mount_root_path.inspect} resolves to a volume mount directory; " \
258
+ "use a file path beneath data/ or storage/."
259
+ end
260
+
261
+ def sqlite_database_setup
262
+ redirects = sqlite_database_redirects
263
+ return "" if redirects.empty?
264
+
265
+ setup_calls = redirects.map do |source, target, legacy|
266
+ arguments = [source, target, legacy].compact.map { |path| Shellwords.shellescape(path) }
267
+ "prepare_sqlite_database #{arguments.join(' ')}"
268
+ end
269
+ "#{SQLITE_DATABASE_PREPARE_FUNCTION}\n#{setup_calls.join("\n")}\n"
270
+ end
271
+
272
+ def sqlite_database_redirects
273
+ return [] unless sqlite_project?
274
+
275
+ RepoIntrospection.sqlite_database_paths_in_production(Dir.pwd).filter_map do |database_path|
276
+ source = absolute_app_database_path(database_path)
277
+ next if persistent_sqlite_path?(source)
278
+
279
+ target = persistent_sqlite_target(source)
280
+ [source, target, legacy_sqlite_database_path(source)]
281
+ end
282
+ end
283
+
284
+ def validate_sqlite_persistence_targets!
285
+ paths_by_target = sqlite_database_paths_by_persistence_target
286
+ validate_exact_sqlite_target_collisions!(paths_by_target)
287
+ validate_ancestor_sqlite_target_collisions!(paths_by_target.keys)
288
+ end
289
+
290
+ def validate_exact_sqlite_target_collisions!(paths_by_target)
291
+ collision = paths_by_target.find { |_target, paths| paths.uniq.size > 1 }
292
+ return unless collision
293
+
294
+ target = collision.first
295
+ paths = collision.last.uniq
296
+ raise Cpflow::Error,
297
+ "Production SQLite database paths #{paths.map(&:inspect).join(' and ')} resolve to the same persistent " \
298
+ "target #{target.inspect}; use distinct paths before generating the scaffold."
299
+ end
300
+
301
+ def validate_ancestor_sqlite_target_collisions!(targets)
302
+ collision = targets.combination(2).find do |first, second|
303
+ sqlite_path_ancestor?(first, second) || sqlite_path_ancestor?(second, first)
304
+ end
305
+ return unless collision
306
+
307
+ first, second = collision
308
+ raise Cpflow::Error,
309
+ "Production SQLite persistence targets #{first.inspect} and #{second.inspect} overlap; " \
310
+ "a database file cannot contain another database path."
311
+ end
312
+
313
+ def sqlite_path_ancestor?(ancestor, descendant)
314
+ descendant.start_with?("#{ancestor}/")
315
+ end
316
+
317
+ def sqlite_database_paths_by_persistence_target
318
+ paths_by_target = Hash.new { |hash, key| hash[key] = [] }
319
+ RepoIntrospection.sqlite_database_paths_in_production(Dir.pwd).each do |database_path|
320
+ source = absolute_app_database_path(database_path)
321
+ target = persistent_sqlite_path?(source) ? source : persistent_sqlite_target(source)
322
+ [target, legacy_sqlite_database_path(source)].compact.each do |claimed_path|
323
+ claim_sqlite_database_paths(paths_by_target, claimed_path, database_path)
324
+ end
325
+ end
326
+ paths_by_target
327
+ end
328
+
329
+ def claim_sqlite_database_paths(paths_by_target, claimed_path, database_path)
330
+ sqlite_database_owned_paths(claimed_path).each do |owned_path|
331
+ paths_by_target[owned_path] << database_path
332
+ end
333
+ end
334
+
335
+ def sqlite_database_owned_paths(database_path)
336
+ [database_path, "#{database_path}-wal", "#{database_path}-shm", "#{database_path}-journal"]
337
+ end
338
+
339
+ def persistent_sqlite_target(source)
340
+ relative = source.delete_prefix("/")
341
+ File.join("/app/data", relative.delete_prefix("app/"))
342
+ end
343
+
344
+ def legacy_sqlite_database_path(source)
345
+ return unless source.start_with?("/app/db/")
346
+
347
+ File.join("/app/data", source.delete_prefix("/app/db/"))
348
+ end
349
+
350
+ def absolute_app_database_path(database_path)
351
+ path = Pathname.new(database_path)
352
+ (path.absolute? ? path : Pathname.new("/app").join(path)).cleanpath.to_s
353
+ end
354
+
355
+ def persistent_sqlite_path?(path)
356
+ path == "/app/data" || path.start_with?("/app/data/") ||
357
+ path == "/app/storage" || path.start_with?("/app/storage/")
358
+ end
359
+
116
360
  def normalized_asset_precompile_hook_command
117
361
  command = shakapacker_precompile_hook || react_on_rails_auto_bundle_hook
118
362
  return unless command
@@ -169,7 +413,7 @@ module Command
169
413
  - infers the app prefix from the current directory and wires staging, review, and production entries
170
414
  - infers the Docker base Ruby version from `.ruby-version`, `.tool-versions`, or the app's `Gemfile`
171
415
  - preserves repo-defined asset precompile hooks, including React on Rails auto bundle generation
172
- - detects SQLite in `config/database.yml` and generates persistent `db` and `storage` volume templates instead of the default Postgres workload
416
+ - detects SQLite in `config/database.yml` and generates persistent `/app/data` and `/app/storage` volume templates without hiding image migrations under `/app/db`
173
417
  DESC
174
418
  EXAMPLES = <<~EX
175
419
  ```sh
@@ -12,6 +12,7 @@ module Command
12
12
  include GeneratorHelpers
13
13
 
14
14
  argument :staging_branch, type: :string, required: false
15
+ class_option :workflows, type: :array
15
16
 
16
17
  def copy_files
17
18
  relative_paths = generated_files
@@ -51,7 +52,12 @@ module Command
51
52
  def generated_files
52
53
  # Keep file discovery centralized on the command class so existence checks and
53
54
  # Thor's template copy list cannot drift.
54
- GenerateGithubActions.generated_files
55
+ paths = GenerateGithubActions.generated_files
56
+ return paths unless options.key?("workflows")
57
+
58
+ paths.reject do |path|
59
+ path.start_with?(".github/workflows/") && !options["workflows"].include?(File.basename(path))
60
+ end
55
61
  end
56
62
 
57
63
  def staging_branch_filter
@@ -99,8 +105,8 @@ module Command
99
105
  - manual promotion from staging to production
100
106
  - nightly cleanup and PR help workflows
101
107
 
102
- It also copies cpflow's composite actions into `.github/actions/cpflow-*`
103
- so every local `uses:` target is checked in and can be audited directly.
108
+ The workflows load cpflow's composite actions from this gem's repository at
109
+ the ref each workflow pins, so no `.github/actions/cpflow-*` copies are generated.
104
110
 
105
111
  Pass `--staging-branch BRANCH` when staging should auto-deploy from a branch
106
112
  other than `main` or `master`; the generator will bake that branch into the
@@ -111,7 +117,7 @@ module Command
111
117
  DESC
112
118
  EXAMPLES = <<~EX
113
119
  ```sh
114
- # Creates workflow wrappers, local composite actions, and validation helpers
120
+ # Creates workflow wrappers and validation helpers
115
121
  cpflow generate-github-actions
116
122
 
117
123
  # Creates the flow with staging deploys triggered from develop
@@ -125,23 +131,16 @@ module Command
125
131
  VALIDATIONS = [].freeze
126
132
  REQUIRES_STARTUP_CHECKS = false
127
133
 
128
- # Resolve source roots from __dir__ rather than Cpflow.root_path because this file is
134
+ # Resolve the source root from __dir__ rather than Cpflow.root_path because this file is
129
135
  # loaded before `module Cpflow` finishes defining its class methods.
130
- REPOSITORY_ROOT = Pathname.new(File.expand_path("../..", __dir__))
131
136
  TEMPLATE_ROOT = Pathname.new(File.expand_path("../github_flow_templates", __dir__))
132
- ACTIONS_ROOT = REPOSITORY_ROOT.join(".github/actions")
133
137
 
134
138
  def self.generated_file_sources
135
139
  ensure_template_root!
136
140
 
137
- template_sources = files_beneath(TEMPLATE_ROOT).to_h do |source|
141
+ files_beneath(TEMPLATE_ROOT).to_h do |source|
138
142
  [source.relative_path_from(TEMPLATE_ROOT).to_s, source]
139
- end
140
- action_sources = files_beneath(ACTIONS_ROOT, "cpflow-*/**/*").to_h do |source|
141
- [source.relative_path_from(REPOSITORY_ROOT).to_s, source]
142
- end
143
-
144
- template_sources.merge(action_sources).sort.to_h.freeze
143
+ end.sort.to_h.freeze
145
144
  end
146
145
 
147
146
  def self.generated_files
@@ -154,11 +153,10 @@ module Command
154
153
 
155
154
  def self.ensure_template_root!
156
155
  raise "cpflow template directory not found: #{TEMPLATE_ROOT}" unless TEMPLATE_ROOT.directory?
157
- raise "cpflow action directory not found: #{ACTIONS_ROOT}" unless ACTIONS_ROOT.directory?
158
156
  end
159
157
 
160
- def self.files_beneath(root, pattern = "**/*")
161
- Dir.glob(root.join(pattern).to_s, File::FNM_DOTMATCH)
158
+ def self.files_beneath(root)
159
+ Dir.glob(root.join("**/*").to_s, File::FNM_DOTMATCH)
162
160
  .select { |path| File.file?(path) }
163
161
  .map { |path| Pathname.new(path) }
164
162
  .sort
@@ -18,13 +18,14 @@ module Command
18
18
  - Configures app to have org-level secrets with default name `"{APP_PREFIX}-secrets"`
19
19
  using org-level policy with default name `"{APP_PREFIX}-secrets-policy"` (names can be customized, see docs)
20
20
  - Creates identity for secrets if it does not exist
21
+ - For dynamically named review apps with `generated_review_secret_keys`, checks an existing policy before writing credentials, creates a per-app dictionary, skips its secret template during initial setup, and fills missing disposable keys without printing or rotating values
21
22
  - Binds the app identity to any configured `shared_secret_grants` policies as part of the secrets setup flow; skipped when `--skip-secrets-setup` or `--skip-secret-access-binding` is provided, or `skip_secrets_setup` is set
22
23
  - Use `--skip-secrets-setup` to prevent the automatic setup of secrets,
23
24
  or set it through `skip_secrets_setup` in the `.controlplane/controlplane.yml` file
24
25
  - Runs a post-creation hook after the app is created if `hooks.post_creation` is specified in the `.controlplane/controlplane.yml` file
25
26
  - If the hook exits with a non-zero code, the command will stop executing and also exit with a non-zero code
26
27
  - Use `--skip-post-creation-hook` to skip the hook if specified in `controlplane.yml`
27
- - Use `--refresh-templates` to apply configured templates noninteractively to an existing app while preserving each workload's configured app image even when workloads are unready or use mixed image versions, skipping existing secret resources entirely, repairing secrets access bindings, and skipping the post-creation hook
28
+ - Use `--refresh-templates` to apply configured templates noninteractively to an existing app while preserving each workload's configured app image even when workloads are unready or use mixed image versions, skipping existing secret templates (but filling missing opt-in generated review keys), repairing secrets access bindings, and skipping the post-creation hook
28
29
  DESC
29
30
  VALIDATIONS = %w[config templates].freeze
30
31
 
@@ -49,8 +50,13 @@ module Command
49
50
 
50
51
  args = []
51
52
  args.push("--add-app-identity") unless skip_secrets_setup
52
- args.push("--yes", "--preserve-existing-runtime") if refresh_templates
53
- run_cpflow_command("apply-template", *templates, "-a", config.app, *args)
53
+ args.push("--yes") if refresh_templates
54
+ args.push("--preserve-existing-runtime") if refresh_templates
55
+ if !skip_secrets_setup && config.generated_review_secret_keys.any?
56
+ args.push("--exclude-policy-template", config.secrets_policy)
57
+ args.push("--exclude-secret-template", config.secrets) unless refresh_templates
58
+ end
59
+ run_cpflow_command("apply-template", "-a", config.app, *args, *templates)
54
60
 
55
61
  bind_identity_to_policy unless skip_secrets_setup
56
62
  bind_shared_secret_policy_grants(shared_secret_policy_grant_pairs) unless skip_secrets_setup
@@ -65,24 +71,81 @@ module Command
65
71
  end
66
72
 
67
73
  def create_secret_and_policy_if_not_exist
74
+ validate_existing_generated_review_policy_before_secret!
68
75
  create_secret_if_not_exists
69
76
  create_policy_if_not_exists
70
77
 
71
78
  progress.puts
72
79
  end
73
80
 
81
+ def validate_existing_generated_review_policy_before_secret!
82
+ return if config.generated_review_secret_keys.empty?
83
+
84
+ policy = cp.fetch_policy(config.secrets_policy)
85
+ verify_generated_review_policy!(policy) if policy
86
+ end
87
+
74
88
  def create_secret_if_not_exists
75
- if cp.fetch_secret(config.secrets)
76
- progress.puts("Secret '#{config.secrets}' already exists. Skipping creation...")
89
+ secret = cp.fetch_secret(config.secrets)
90
+ return existing_secret_if_any(secret) if secret
91
+
92
+ step("Creating secret '#{config.secrets}'") { create_new_secret }
93
+ end
94
+
95
+ def existing_secret_if_any(secret)
96
+ if config.generated_review_secret_keys.any? && !generated_review_secret_owned_by_app?(secret)
97
+ raise "Existing review app secret dictionary is not owned by this app."
98
+ end
99
+
100
+ progress.puts("Secret '#{config.secrets}' already exists. Skipping creation...")
101
+ fill_missing_generated_review_secrets
102
+ end
103
+
104
+ def create_new_secret
105
+ if config.generated_review_secret_keys.any?
106
+ cp.create_sensitive_secret(config.secrets, generated_review_secret_data)
77
107
  else
78
- step("Creating secret '#{config.secrets}'") do
79
- cp.apply_hash(build_secret_hash)
80
- end
108
+ cp.apply_hash(build_secret_hash)
109
+ end
110
+ end
111
+
112
+ def fill_missing_generated_review_secrets
113
+ keys = config.generated_review_secret_keys
114
+ return if keys.empty?
115
+
116
+ data = revealed_review_secret_data
117
+
118
+ missing_keys = keys.reject { |key| data[key].is_a?(String) && !data[key].empty? }
119
+ return if missing_keys.empty?
120
+
121
+ step("Adding missing generated review app secret fields") do
122
+ cp.patch_sensitive_secret_data(config.secrets, generated_review_secret_data(missing_keys))
123
+ end
124
+ end
125
+
126
+ def revealed_review_secret_data
127
+ revealed = begin
128
+ cp.reveal_secret(config.secrets, required: true)
129
+ rescue StandardError
130
+ raise "Cannot safely inspect existing review app secret dictionary.", cause: nil
81
131
  end
132
+ raise "Cannot safely inspect existing review app secret dictionary." unless revealed.is_a?(Hash)
133
+ raise "Existing review app secret is not a dictionary." unless revealed["type"] == "dictionary"
134
+
135
+ data = revealed["data"]
136
+ raise "Cannot safely inspect existing review app secret dictionary." unless data.is_a?(Hash)
137
+
138
+ data
139
+ end
140
+
141
+ def generated_review_secret_data(keys = config.generated_review_secret_keys)
142
+ keys.to_h { |key| [key, SecureRandom.hex(32)] }
82
143
  end
83
144
 
84
145
  def create_policy_if_not_exists
85
- if cp.fetch_policy(config.secrets_policy)
146
+ policy = cp.fetch_policy(config.secrets_policy)
147
+ if policy
148
+ verify_generated_review_policy!(policy) if config.generated_review_secret_keys.any?
86
149
  progress.puts("Policy '#{config.secrets_policy}' already exists. Skipping creation...")
87
150
  else
88
151
  step("Creating policy '#{config.secrets_policy}'") do
@@ -91,6 +154,16 @@ module Command
91
154
  end
92
155
  end
93
156
 
157
+ def verify_generated_review_policy!(policy)
158
+ expected_target = policy_targets_secret?(policy, config.secrets)
159
+ owned_policy = generated_review_app_tag(policy) == config.app
160
+ no_extra_selectors = %w[target targetQuery gvc].all? { |key| policy[key].nil? }
161
+ own_bindings = generated_review_policy_binding_state(policy) != :unsafe
162
+ return if expected_target && owned_policy && no_extra_selectors && own_bindings
163
+
164
+ raise "Existing review app secret policy has an unexpected target or binding."
165
+ end
166
+
94
167
  def build_secret_hash
95
168
  {
96
169
  "kind" => "secret",
@@ -101,17 +174,26 @@ module Command
101
174
  end
102
175
 
103
176
  def build_policy_hash
104
- {
177
+ policy = {
105
178
  "kind" => "policy",
106
179
  "name" => config.secrets_policy,
107
180
  "targetKind" => "secret",
108
181
  "targetLinks" => ["//secret/#{config.secrets}"]
109
182
  }
183
+ policy["tags"] = { ::Config::GENERATED_REVIEW_APP_TAG => config.app } if config.generated_review_secret_keys.any?
184
+ policy
110
185
  end
111
186
 
112
187
  def bind_identity_to_policy
113
188
  progress.puts
114
189
 
190
+ if config.generated_review_secret_keys.any?
191
+ policy = cp.fetch_policy(config.secrets_policy)
192
+ raise "Cannot safely inspect review app secret policy before binding." unless policy.is_a?(Hash)
193
+
194
+ verify_generated_review_policy!(policy)
195
+ end
196
+
115
197
  step("Binding identity '#{config.identity}' to policy '#{config.secrets_policy}'") do
116
198
  cp.bind_identity_to_policy(config.identity_link, config.secrets_policy)
117
199
  end