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.
- checksums.yaml +4 -4
- data/.agents/agent-workflow.yml +16 -25
- data/.agents/bin/README.md +19 -9
- data/.agents/bin/docs +1 -1
- data/.agents/bin/lint +1 -1
- data/.agents/bin/setup +1 -1
- data/.agents/bin/test +1 -1
- data/.agents/bin/validate +2 -1
- data/.agents/legacy-workflow-policy.yml +11 -0
- data/.agents/shaka.md +20 -0
- data/.agents/trusted-actions.yml +12 -0
- data/.coderabbit.yaml +14 -0
- data/.github/actions/cpflow-build-docker-image/action.yml +153 -22
- data/.github/actions/cpflow-delete-control-plane-app/action.yml +2 -1
- data/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +3 -0
- data/.github/actions/cpflow-preview-promotion/action.yml +82 -0
- data/.github/actions/cpflow-preview-promotion/preview-promotion.sh +90 -0
- data/.github/actions/cpflow-setup-environment/action.yml +8 -6
- data/.github/dependabot.yml +10 -0
- data/.github/workflows/check_cpln_links.yml +6 -1
- data/.github/workflows/claude-code-review.yml +5 -2
- data/.github/workflows/claude.yml +6 -4
- data/.github/workflows/coderabbit-lifecycle-review.yml +29 -0
- data/.github/workflows/command_docs.yml +7 -2
- data/.github/workflows/cpflow-cleanup-stale-review-apps.yml +12 -5
- data/.github/workflows/cpflow-delete-review-app.yml +18 -11
- data/.github/workflows/cpflow-deploy-review-app.yml +38 -17
- data/.github/workflows/cpflow-deploy-staging.yml +28 -11
- data/.github/workflows/cpflow-help-command.yml +3 -3
- data/.github/workflows/cpflow-promote-staging-to-production.yml +135 -16
- data/.github/workflows/cpflow-review-app-help.yml +1 -1
- data/.github/workflows/rspec-shared.yml +34 -26
- data/.github/workflows/rspec-specific.yml +10 -2
- data/.github/workflows/rspec.yml +95 -4
- data/.github/workflows/rubocop.yml +9 -2
- data/.github/workflows/trigger-docs-site.yml +2 -0
- data/.rubocop.yml +6 -1
- data/AGENTS.md +18 -3
- data/CHANGELOG.md +39 -1
- data/CONTRIBUTING.md +39 -2
- data/Gemfile.lock +1 -1
- data/README.md +10 -2
- data/cpflow.gemspec +3 -15
- data/docs/ai-github-flow-prompt.md +3 -3
- data/docs/ci-automation.md +284 -68
- data/docs/commands.md +38 -15
- data/docs/postgres-review-apps.md +90 -0
- data/docs/postgres.md +233 -375
- data/docs/rds-private-networking.md +12 -12
- data/docs/releasing.md +15 -4
- data/docs/secrets-and-env-values.md +42 -0
- data/docs/sidebars.ts +1 -0
- data/examples/controlplane.yml +5 -0
- data/lib/command/ai_github_flow_prompt.rb +2 -2
- data/lib/command/apply_template.rb +36 -4
- data/lib/command/base.rb +56 -3
- data/lib/command/cleanup_stale_apps.rb +28 -4
- data/lib/command/copy_image_from_upstream.rb +3 -0
- data/lib/command/delete.rb +174 -4
- data/lib/command/deploy_image.rb +44 -1
- data/lib/command/generate.rb +247 -3
- data/lib/command/generate_github_actions.rb +43 -13
- data/lib/command/run.rb +224 -34
- data/lib/command/setup_app.rb +92 -10
- data/lib/command/update_github_actions.rb +82 -43
- data/lib/core/config.rb +73 -0
- data/lib/core/controlplane.rb +167 -80
- data/lib/core/controlplane_api.rb +28 -2
- data/lib/core/controlplane_api_direct.rb +17 -0
- data/lib/core/disposable_postgres_helpers.rb +167 -0
- data/lib/core/doctor_service.rb +7 -0
- data/lib/core/github_flow_readiness/checks.rb +2 -1
- data/lib/core/github_flow_readiness_service.rb +4 -0
- data/lib/core/repo_introspection.rb +112 -2
- data/lib/core/shell.rb +35 -10
- data/lib/core/timed_command.rb +111 -0
- data/lib/cpflow/version.rb +1 -1
- data/lib/cpflow.rb +1 -1
- data/lib/generator_templates/.dockerignore +16 -0
- data/lib/generator_templates/Dockerfile +9 -5
- data/lib/generator_templates/entrypoint.sh +2 -0
- data/lib/generator_templates/templates/postgres.yml +9 -0
- data/lib/generator_templates_sqlite/controlplane.yml +2 -1
- data/lib/generator_templates_sqlite/release_script.sh +1 -1
- data/lib/generator_templates_sqlite/templates/rails.yml +2 -1
- data/lib/github_flow_templates/.github/cpflow-help.md +39 -12
- data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +1 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +1 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +135 -16
- data/lib/github_flow_templates/bin/pin-cpflow-github-ref +37 -13
- data/lib/github_flow_templates/bin/test-cpflow-github-flow +125 -46
- data/lib/patches/hash.rb +2 -2
- data/rakelib/create_release.rake +14 -14
- data/script/check_shell_scripts +66 -0
- metadata +18 -17
data/lib/command/delete.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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)
|
data/lib/command/deploy_image.rb
CHANGED
|
@@ -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
|
-
|
|
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|
|
data/lib/command/generate.rb
CHANGED
|
@@ -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 `
|
|
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
|