cpflow 5.3.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/agent-workflow.yml +16 -25
  3. data/.agents/bin/README.md +19 -9
  4. data/.agents/bin/docs +1 -1
  5. data/.agents/bin/lint +1 -1
  6. data/.agents/bin/setup +1 -1
  7. data/.agents/bin/test +1 -1
  8. data/.agents/bin/validate +2 -1
  9. data/.agents/legacy-workflow-policy.yml +11 -0
  10. data/.agents/shaka.md +20 -0
  11. data/.agents/trusted-actions.yml +12 -0
  12. data/.coderabbit.yaml +14 -0
  13. data/.github/actions/cpflow-build-docker-image/action.yml +153 -22
  14. data/.github/actions/cpflow-delete-control-plane-app/action.yml +2 -1
  15. data/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +3 -0
  16. data/.github/actions/cpflow-preview-promotion/action.yml +82 -0
  17. data/.github/actions/cpflow-preview-promotion/preview-promotion.sh +90 -0
  18. data/.github/actions/cpflow-setup-environment/action.yml +8 -6
  19. data/.github/dependabot.yml +10 -0
  20. data/.github/workflows/check_cpln_links.yml +6 -1
  21. data/.github/workflows/claude-code-review.yml +5 -2
  22. data/.github/workflows/claude.yml +6 -4
  23. data/.github/workflows/coderabbit-lifecycle-review.yml +29 -0
  24. data/.github/workflows/command_docs.yml +7 -2
  25. data/.github/workflows/cpflow-cleanup-stale-review-apps.yml +12 -5
  26. data/.github/workflows/cpflow-delete-review-app.yml +18 -11
  27. data/.github/workflows/cpflow-deploy-review-app.yml +38 -17
  28. data/.github/workflows/cpflow-deploy-staging.yml +28 -11
  29. data/.github/workflows/cpflow-help-command.yml +3 -3
  30. data/.github/workflows/cpflow-promote-staging-to-production.yml +135 -16
  31. data/.github/workflows/cpflow-review-app-help.yml +1 -1
  32. data/.github/workflows/rspec-shared.yml +34 -26
  33. data/.github/workflows/rspec-specific.yml +10 -2
  34. data/.github/workflows/rspec.yml +95 -4
  35. data/.github/workflows/rubocop.yml +9 -2
  36. data/.github/workflows/trigger-docs-site.yml +2 -0
  37. data/.rubocop.yml +6 -1
  38. data/AGENTS.md +18 -3
  39. data/CHANGELOG.md +39 -1
  40. data/CONTRIBUTING.md +39 -2
  41. data/Gemfile.lock +1 -1
  42. data/README.md +10 -2
  43. data/cpflow.gemspec +3 -15
  44. data/docs/ai-github-flow-prompt.md +3 -3
  45. data/docs/ci-automation.md +284 -68
  46. data/docs/commands.md +38 -15
  47. data/docs/postgres-review-apps.md +90 -0
  48. data/docs/postgres.md +233 -375
  49. data/docs/rds-private-networking.md +12 -12
  50. data/docs/releasing.md +15 -4
  51. data/docs/secrets-and-env-values.md +42 -0
  52. data/docs/sidebars.ts +1 -0
  53. data/examples/controlplane.yml +5 -0
  54. data/lib/command/ai_github_flow_prompt.rb +2 -2
  55. data/lib/command/apply_template.rb +36 -4
  56. data/lib/command/base.rb +56 -3
  57. data/lib/command/cleanup_stale_apps.rb +28 -4
  58. data/lib/command/copy_image_from_upstream.rb +3 -0
  59. data/lib/command/delete.rb +174 -4
  60. data/lib/command/deploy_image.rb +44 -1
  61. data/lib/command/generate.rb +247 -3
  62. data/lib/command/generate_github_actions.rb +43 -13
  63. data/lib/command/run.rb +224 -34
  64. data/lib/command/setup_app.rb +92 -10
  65. data/lib/command/update_github_actions.rb +82 -43
  66. data/lib/core/config.rb +73 -0
  67. data/lib/core/controlplane.rb +167 -80
  68. data/lib/core/controlplane_api.rb +28 -2
  69. data/lib/core/controlplane_api_direct.rb +17 -0
  70. data/lib/core/disposable_postgres_helpers.rb +167 -0
  71. data/lib/core/doctor_service.rb +7 -0
  72. data/lib/core/github_flow_readiness/checks.rb +2 -1
  73. data/lib/core/github_flow_readiness_service.rb +4 -0
  74. data/lib/core/repo_introspection.rb +112 -2
  75. data/lib/core/shell.rb +35 -10
  76. data/lib/core/timed_command.rb +111 -0
  77. data/lib/cpflow/version.rb +1 -1
  78. data/lib/cpflow.rb +1 -1
  79. data/lib/generator_templates/.dockerignore +16 -0
  80. data/lib/generator_templates/Dockerfile +9 -5
  81. data/lib/generator_templates/entrypoint.sh +2 -0
  82. data/lib/generator_templates/templates/postgres.yml +9 -0
  83. data/lib/generator_templates_sqlite/controlplane.yml +2 -1
  84. data/lib/generator_templates_sqlite/release_script.sh +1 -1
  85. data/lib/generator_templates_sqlite/templates/rails.yml +2 -1
  86. data/lib/github_flow_templates/.github/cpflow-help.md +39 -12
  87. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +1 -0
  88. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +1 -0
  89. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +135 -16
  90. data/lib/github_flow_templates/bin/pin-cpflow-github-ref +37 -13
  91. data/lib/github_flow_templates/bin/test-cpflow-github-flow +125 -46
  92. data/lib/patches/hash.rb +2 -2
  93. data/rakelib/create_release.rake +14 -14
  94. data/script/check_shell_scripts +66 -0
  95. metadata +18 -17
@@ -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)
@@ -4,6 +4,12 @@ require "resolv"
4
4
 
5
5
  module Command
6
6
  class DeployImage < Base # rubocop:disable Metrics/ClassLength
7
+ WORKLOAD_IMAGE_UPDATE_MAX_ATTEMPTS = 30
8
+ # Preserve the general failure cap; only optimistic-concurrency conflicts get the longer convergence window.
9
+ WORKLOAD_IMAGE_UPDATE_CONFLICT_MAX_ATTEMPTS = 120
10
+ WORKLOAD_IMAGE_UPDATE_CONFLICT_PATTERN =
11
+ /(?:\b409\b|(?:\A|\n)[ \t]*(?:error:[ \t]*)?conflict[ \t]*(?:\r?\n|\z))/i
12
+
7
13
  NAME = "deploy-image"
8
14
  OPTIONS = [
9
15
  app_option(required: true),
@@ -122,7 +128,8 @@ module Command
122
128
 
123
129
  container_name = container["name"]
124
130
  step("Deploying image '#{image}' for workload '#{workload}'") do
125
- cp.workload_set_image_ref(workload, container: container_name, image: image)
131
+ update_workload_image_ref(workload, container_name, image)
132
+
126
133
  deployed_endpoints[workload] = endpoint_for_workload(workload_data)
127
134
  # A missing public endpoint is valid; the image update still completed successfully.
128
135
  true
@@ -136,6 +143,42 @@ module Command
136
143
  deployed_endpoints
137
144
  end
138
145
 
146
+ def update_workload_image_ref(workload, container, image)
147
+ attempts = 0
148
+ mark_deployment_started
149
+
150
+ loop do
151
+ attempts += 1
152
+ result = cp.workload_set_image_ref(workload, container: container, image: image)
153
+ return true if result.fetch(:success)
154
+
155
+ output = result.fetch(:output).to_s
156
+ raise workload_image_update_error(output) if attempts >= workload_image_update_limit(output)
157
+
158
+ wait_before_workload_image_update_retry
159
+ end
160
+ end
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
+
167
+ def workload_image_update_limit(output)
168
+ return WORKLOAD_IMAGE_UPDATE_CONFLICT_MAX_ATTEMPTS if output.match?(WORKLOAD_IMAGE_UPDATE_CONFLICT_PATTERN)
169
+
170
+ WORKLOAD_IMAGE_UPDATE_MAX_ATTEMPTS
171
+ end
172
+
173
+ def workload_image_update_error(output)
174
+ output.strip.empty? ? "Command exited with non-zero status." : output
175
+ end
176
+
177
+ def wait_before_workload_image_update_retry
178
+ progress.print(".")
179
+ Kernel.sleep(1)
180
+ end
181
+
139
182
  def print_deployed_endpoints(deployed_endpoints)
140
183
  progress.puts("\nDeployed endpoints:")
141
184
  deployed_endpoints.each do |workload, endpoint|
@@ -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