cpflow 6.0.0.rc.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/agent-workflow.yml +16 -38
  3. data/.agents/bin/README.md +7 -8
  4. data/.agents/legacy-workflow-policy.yml +11 -0
  5. data/.agents/shaka.md +20 -0
  6. data/.agents/trusted-actions.yml +12 -0
  7. data/.coderabbit.yaml +1 -0
  8. data/.github/actions/cpflow-build-docker-image/action.yml +153 -22
  9. data/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +3 -0
  10. data/.github/actions/cpflow-preview-promotion/action.yml +82 -0
  11. data/.github/actions/cpflow-preview-promotion/preview-promotion.sh +90 -0
  12. data/.github/workflows/cpflow-deploy-review-app.yml +13 -1
  13. data/.github/workflows/cpflow-deploy-staging.yml +12 -1
  14. data/.github/workflows/cpflow-promote-staging-to-production.yml +128 -9
  15. data/.github/workflows/rspec.yml +24 -0
  16. data/AGENTS.md +18 -3
  17. data/CHANGELOG.md +18 -3
  18. data/CONTRIBUTING.md +1 -1
  19. data/Gemfile.lock +1 -1
  20. data/README.md +5 -2
  21. data/docs/ai-github-flow-prompt.md +2 -2
  22. data/docs/ci-automation.md +178 -14
  23. data/docs/commands.md +26 -13
  24. data/docs/postgres-review-apps.md +90 -0
  25. data/docs/postgres.md +233 -375
  26. data/docs/secrets-and-env-values.md +42 -0
  27. data/docs/sidebars.ts +1 -0
  28. data/lib/command/ai_github_flow_prompt.rb +2 -2
  29. data/lib/command/apply_template.rb +36 -4
  30. data/lib/command/base.rb +56 -3
  31. data/lib/command/delete.rb +174 -4
  32. data/lib/command/deploy_image.rb +6 -0
  33. data/lib/command/generate.rb +247 -3
  34. data/lib/command/generate_github_actions.rb +7 -1
  35. data/lib/command/setup_app.rb +92 -10
  36. data/lib/command/update_github_actions.rb +81 -43
  37. data/lib/core/config.rb +73 -0
  38. data/lib/core/controlplane.rb +22 -2
  39. data/lib/core/controlplane_api.rb +28 -2
  40. data/lib/core/controlplane_api_direct.rb +17 -0
  41. data/lib/core/disposable_postgres_helpers.rb +167 -0
  42. data/lib/core/doctor_service.rb +7 -0
  43. data/lib/core/github_flow_readiness/checks.rb +2 -1
  44. data/lib/core/github_flow_readiness_service.rb +4 -0
  45. data/lib/core/repo_introspection.rb +112 -2
  46. data/lib/cpflow/version.rb +1 -1
  47. data/lib/generator_templates/.dockerignore +16 -0
  48. data/lib/generator_templates/Dockerfile +9 -5
  49. data/lib/generator_templates/entrypoint.sh +2 -0
  50. data/lib/generator_templates/templates/postgres.yml +9 -0
  51. data/lib/generator_templates_sqlite/controlplane.yml +2 -1
  52. data/lib/generator_templates_sqlite/release_script.sh +1 -1
  53. data/lib/generator_templates_sqlite/templates/rails.yml +2 -1
  54. data/lib/github_flow_templates/.github/cpflow-help.md +39 -12
  55. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +1 -0
  56. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +1 -0
  57. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +128 -9
  58. data/lib/github_flow_templates/bin/pin-cpflow-github-ref +37 -13
  59. data/lib/github_flow_templates/bin/test-cpflow-github-flow +62 -43
  60. metadata +11 -3
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
 
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "yaml"
4
+ require "uri"
4
5
 
5
- module RepoIntrospection
6
+ module RepoIntrospection # rubocop:disable Metrics/ModuleLength
6
7
  DEFAULT_APP_PREFIX = "my-app"
7
8
  RUBY_VERSION_DIRECTIVE_PATTERN = /^\s*ruby\s+['"\d]/
8
9
  RUBY_VERSION_DIRECTIVE_PREFIX = /^\s*ruby\s+/
@@ -87,6 +88,49 @@ module RepoIntrospection
87
88
  sqlite_database_config?(production)
88
89
  end
89
90
 
91
+ # Returns literal SQLite database paths from the production configuration.
92
+ # Dynamic ERB values are intentionally omitted because the generator cannot
93
+ # safely redirect a path that is only known at runtime.
94
+ def self.sqlite_database_paths_in_production(root)
95
+ path = File.join(root, "config/database.yml")
96
+ return [] unless File.file?(path)
97
+
98
+ parsed = safe_load_database_yml(File.read(path))
99
+ production = parsed.is_a?(Hash) ? parsed["production"] : nil
100
+ return [] unless production.is_a?(Hash) && sqlite_database_config?(production)
101
+
102
+ database_connection_configs(production).filter_map do |config|
103
+ sqlite_database_path(config)
104
+ end.uniq
105
+ end
106
+
107
+ def self.unresolved_sqlite_database_paths_in_production?(root)
108
+ path = File.join(root, "config/database.yml")
109
+ return false unless File.file?(path)
110
+
111
+ parsed = safe_load_database_yml(File.read(path))
112
+ production = parsed.is_a?(Hash) ? parsed["production"] : nil
113
+ return false unless production.is_a?(Hash) && sqlite_database_config?(production)
114
+
115
+ database_connection_configs(production).any? do |config|
116
+ sqlite_database_path(config).nil? && !sqlite_in_memory_database_config?(config)
117
+ end
118
+ end
119
+
120
+ def self.dynamic_database_url_in_production?(root)
121
+ path = File.join(root, "config/database.yml")
122
+ return false unless File.file?(path)
123
+
124
+ parsed = safe_load_database_yml(File.read(path))
125
+ production = parsed.is_a?(Hash) ? parsed["production"] : nil
126
+ return false unless production.is_a?(Hash)
127
+
128
+ database_connection_configs(production).any? do |config|
129
+ url = config["url"]
130
+ url.is_a?(String) && url.include?("__erb__") && !sqlite_database_url?(url)
131
+ end
132
+ end
133
+
90
134
  # Determines whether a database config hash uses SQLite. Handles both
91
135
  # the single-database shape (top-level `adapter`/`url`) and Rails 6.1+ multi-database
92
136
  # shape where each connection sits one level deeper (`primary:`, `cache:`, etc.).
@@ -106,7 +150,15 @@ module RepoIntrospection
106
150
  # rubocop:disable Style/ReturnNilInPredicateMethodDefinition -- ternary nil/true/false is load-bearing for the caller
107
151
  def self.direct_sqlite_database_config?(config)
108
152
  url = config["url"]
109
- return sqlite_database_url?(url) if url.is_a?(String) && !url.strip.empty?
153
+ if url.is_a?(String) && !url.strip.empty?
154
+ if url.include?("__erb__")
155
+ return true if sqlite_database_url?(url)
156
+
157
+ return sqlite_adapter_in_hash?(config)
158
+ end
159
+
160
+ return sqlite_database_url?(url)
161
+ end
110
162
 
111
163
  return sqlite_adapter_in_hash?(config) if config["adapter"].is_a?(String)
112
164
 
@@ -128,6 +180,64 @@ module RepoIntrospection
128
180
  config.is_a?(Hash) && (config.key?("adapter") || config.key?("url"))
129
181
  end
130
182
 
183
+ def self.database_connection_configs(config)
184
+ return [config] unless direct_sqlite_database_config?(config).nil?
185
+
186
+ config.values.select { |value| database_connection_config?(value) }
187
+ end
188
+
189
+ def self.sqlite_database_path(config)
190
+ return if sqlite_in_memory_database_config?(config)
191
+
192
+ url = config["url"]
193
+ if url.is_a?(String) && !url.strip.empty?
194
+ return sqlite_database_path_from_url(url) if sqlite_database_url?(url)
195
+
196
+ return
197
+ end
198
+
199
+ database = config["database"]
200
+ database.strip if literal_database_path?(database)
201
+ end
202
+
203
+ def self.sqlite_database_path_from_url(url)
204
+ parser = URI::RFC2396_Parser.new
205
+ uri = parser.parse(url.strip)
206
+ encoded_path = uri.opaque ? uri.opaque.split("?", 2).first : uri.path
207
+ encoded_path = sqlite_file_uri_path(encoded_path, parser)
208
+ parser.unescape(encoded_path) if literal_database_path?(encoded_path)
209
+ rescue URI::InvalidURIError
210
+ nil
211
+ end
212
+
213
+ def self.sqlite_file_uri_path(path, parser)
214
+ return path unless path&.downcase&.start_with?("file:")
215
+
216
+ uri = parser.parse(path)
217
+ uri.opaque || uri.path
218
+ end
219
+
220
+ def self.sqlite_in_memory_database_config?(config)
221
+ value = config["url"] || config["database"]
222
+ return false unless value.is_a?(String)
223
+
224
+ identifier = value.strip.sub(/\Asqlite3?:/i, "")
225
+ encoded_path, query = identifier.split("?", 2)
226
+ path = URI::RFC2396_Parser.new.unescape(encoded_path)
227
+ path == ":memory:" || path == "file::memory:" || memory_mode_query?(query)
228
+ end
229
+
230
+ def self.memory_mode_query?(query)
231
+ query.to_s.split("&").any? do |pair|
232
+ key, value = pair.split("=", 2)
233
+ key.casecmp?("mode") && value&.casecmp?("memory")
234
+ end
235
+ end
236
+
237
+ def self.literal_database_path?(value)
238
+ value.is_a?(String) && !value.strip.empty? && !value.include?("__erb__")
239
+ end
240
+
131
241
  def self.safe_load_database_yml(raw_contents)
132
242
  # ERB conditionals can change YAML structure, so avoid guessing. Output-only
133
243
  # ERB is stubbed as a scalar so common Rails defaults like `pool: <%= ... %>`
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cpflow
4
- VERSION = "6.0.0.rc.0"
4
+ VERSION = "6.0.0"
5
5
  MIN_CPLN_VERSION = "3.1.0"
6
6
  end
@@ -0,0 +1,16 @@
1
+ .git
2
+ .github
3
+ .env*
4
+ !.env.example
5
+ .bundle
6
+ .ruby-lsp
7
+ coverage
8
+ log/*
9
+ node_modules
10
+ storage/*
11
+ tmp/*
12
+ vendor/bundle
13
+ config/master.key
14
+ config/credentials/*.key
15
+ db/*.sqlite3
16
+ db/*.sqlite3-*
@@ -38,9 +38,13 @@ RUN printf '%s\n' '#!/bin/sh' 'exec corepack yarn "$@"' > /usr/bin/yarn && \
38
38
  # install ruby gems
39
39
  COPY Gemfile* ./
40
40
 
41
- RUN bundle config set without 'development test' && \
41
+ RUN apt-get update -qq && \
42
+ apt-get install --no-install-recommends -y build-essential && \
43
+ bundle config set without 'development test' && \
42
44
  bundle config set with 'production' && \
43
- bundle install --jobs=3 --retry=3
45
+ bundle install --jobs=3 --retry=3 && \
46
+ apt-get purge -y --auto-remove build-essential && \
47
+ rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
44
48
 
45
49
  COPY . ./
46
50
 
@@ -75,11 +79,11 @@ RUN if [ -f package.json ]; then \
75
79
 
76
80
  ENV RAILS_ENV=production
77
81
 
78
- # compiling assets requires any value for ENV of SECRET_KEY_BASE
79
- ENV SECRET_KEY_BASE=NOT_USED_NON_BLANK
82
+ # Asset compilation requires a non-blank secret, but the placeholder must not
83
+ # remain in the final image environment.
80
84
 
81
85
  __ASSET_PRECOMPILE_HOOK_RUN__
82
- RUN rails assets:precompile
86
+ RUN SECRET_KEY_BASE=NOT_USED_NON_BLANK rails assets:precompile
83
87
 
84
88
  # add entrypoint
85
89
  COPY .controlplane/entrypoint.sh ./
@@ -2,6 +2,8 @@
2
2
  set -e
3
3
  # Runs before the main command
4
4
 
5
+ __SQLITE_DATABASE_SETUP__
6
+
5
7
  is_rails_server_command() {
6
8
  if [ "${1:-}" = "env" ]; then
7
9
  shift
@@ -19,6 +19,9 @@ spec:
19
19
  ---
20
20
  kind: secret
21
21
  name: "{{APP_NAME}}-pg"
22
+ # Only newly created dynamically named review-app helpers acquire this marker.
23
+ tags:
24
+ cpflow-disposable-postgres-app: "{{APP_NAME}}"
22
25
  description: ''
23
26
  type: dictionary
24
27
  data:
@@ -28,6 +31,9 @@ data:
28
31
  ---
29
32
  kind: secret
30
33
  name: "{{APP_NAME}}-pg-script"
34
+ # Only newly created dynamically named review-app helpers acquire this marker.
35
+ tags:
36
+ cpflow-disposable-postgres-app: "{{APP_NAME}}"
31
37
  type: opaque
32
38
  data:
33
39
  encoding: base64
@@ -98,6 +104,9 @@ description: "{{APP_NAME}}-pg-identity"
98
104
  ---
99
105
  kind: policy
100
106
  name: "{{APP_NAME}}-pg-access"
107
+ # Only newly created dynamically named review-app helpers acquire this marker.
108
+ tags:
109
+ cpflow-disposable-postgres-app: "{{APP_NAME}}"
101
110
  description: "{{APP_NAME}}-pg-access"
102
111
  bindings:
103
112
  - permissions:
@@ -1,7 +1,8 @@
1
1
  # Keys beginning with "cpln_" correspond to your settings in Control Plane.
2
2
  #
3
3
  # Generated baseline for a Rails app that uses SQLite in production. This setup
4
- # keeps `/app/db` and `/app/storage` on persistent volumes.
4
+ # keeps SQLite data under `/app/data` and Active Storage under `/app/storage`
5
+ # on persistent volumes without hiding migrations shipped in `/app/db`.
5
6
 
6
7
  allow_org_override_by_env: true
7
8
  allow_app_override_by_env: true