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
@@ -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: <%= ... %>`
data/lib/core/shell.rb CHANGED
@@ -1,17 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Shell
4
+ class CommandTimeout < RuntimeError; end
5
+
4
6
  class << self
5
7
  attr_reader :tmp_stderr, :verbose
6
8
  end
7
9
 
8
10
  def self.use_tmp_stderr
9
- @tmp_stderr = Tempfile.create
10
-
11
- yield
11
+ previous_tmp_stderr = @tmp_stderr
12
12
 
13
- @tmp_stderr.close
14
- @tmp_stderr = nil
13
+ Tempfile.create do |tmp_stderr|
14
+ @tmp_stderr = tmp_stderr
15
+ yield
16
+ ensure
17
+ @tmp_stderr = previous_tmp_stderr
18
+ end
15
19
  end
16
20
 
17
21
  def self.write_to_tmp_stderr(message)
@@ -65,18 +69,39 @@ class Shell
65
69
  tmp_stderr && !verbose
66
70
  end
67
71
 
68
- def self.cmd(*cmd_to_run, capture_stderr: false, separate_stderr: false)
69
- return cmd_with_separate_stderr(*cmd_to_run) if separate_stderr
72
+ def self.cmd(*cmd_to_run, capture_stderr: false, separate_stderr: false, timeout_seconds: nil, **spawn_options)
73
+ return timed_cmd(cmd_to_run, capture_stderr, separate_stderr, timeout_seconds, spawn_options) if timeout_seconds
70
74
 
71
- output, status = capture_stderr ? Open3.capture2e(*cmd_to_run) : Open3.capture2(*cmd_to_run)
75
+ return cmd_with_separate_stderr(*cmd_to_run, **spawn_options) if separate_stderr
76
+
77
+ output, status = capture_output(cmd_to_run, capture_stderr, spawn_options)
72
78
  {
73
79
  output: output,
74
80
  success: status.success?
75
81
  }
76
82
  end
77
83
 
78
- def self.cmd_with_separate_stderr(*cmd_to_run)
79
- output, error_output, status = Open3.capture3(*cmd_to_run)
84
+ def self.capture_output(cmd_to_run, capture_stderr, spawn_options)
85
+ return Open3.capture2e(*cmd_to_run, **spawn_options) if capture_stderr
86
+
87
+ Open3.capture2(*cmd_to_run, **spawn_options)
88
+ end
89
+ private_class_method :capture_output
90
+
91
+ def self.timed_cmd(cmd_to_run, capture_stderr, separate_stderr, timeout_seconds, spawn_options)
92
+ raise ArgumentError, "Process options cannot be combined with timeout_seconds." unless spawn_options.empty?
93
+
94
+ TimedCommand.capture(
95
+ *cmd_to_run,
96
+ capture_stderr: capture_stderr,
97
+ separate_stderr: separate_stderr,
98
+ timeout_seconds: timeout_seconds
99
+ )
100
+ end
101
+ private_class_method :timed_cmd
102
+
103
+ def self.cmd_with_separate_stderr(*cmd_to_run, **spawn_options)
104
+ output, error_output, status = Open3.capture3(*cmd_to_run, **spawn_options)
80
105
  { output: output, error_output: error_output, success: status.success? }
81
106
  end
82
107
  private_class_method :cmd_with_separate_stderr
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TimedCommand
4
+ def self.capture(*command, capture_stderr:, separate_stderr:, timeout_seconds:)
5
+ new(command, capture_stderr, separate_stderr, timeout_seconds).capture
6
+ end
7
+
8
+ def initialize(command, capture_stderr, separate_stderr, timeout_seconds)
9
+ raise ArgumentError, "timeout_seconds must be positive" unless timeout_seconds.positive?
10
+
11
+ @command = command
12
+ @capture_stderr = capture_stderr
13
+ @separate_stderr = separate_stderr
14
+ @timeout_seconds = timeout_seconds
15
+ end
16
+
17
+ def capture
18
+ @deadline = monotonic_time + @timeout_seconds
19
+ @command_cleaned_up = false
20
+ return capture_separate_streams if @separate_stderr
21
+ return capture_merged_streams if @capture_stderr
22
+
23
+ capture_stdout
24
+ end
25
+
26
+ private
27
+
28
+ def capture_stdout # rubocop:disable Metrics/MethodLength
29
+ completed = false
30
+ Open3.popen2(*@command, pgroup: true) do |stdin, stdout, wait_thread|
31
+ stdin.close
32
+ output_reader = Thread.new { stdout.read }
33
+ wait_for_command(wait_thread, output_reader)
34
+ completed = true
35
+ { output: output_reader.value, success: wait_thread.value.success? }
36
+ ensure
37
+ cleanup_unfinished_command(wait_thread, output_reader) unless completed || @command_cleaned_up
38
+ output_reader&.join
39
+ end
40
+ end
41
+
42
+ def capture_merged_streams # rubocop:disable Metrics/MethodLength
43
+ completed = false
44
+ Open3.popen2e(*@command, pgroup: true) do |stdin, output, wait_thread|
45
+ stdin.close
46
+ output_reader = Thread.new { output.read }
47
+ wait_for_command(wait_thread, output_reader)
48
+ completed = true
49
+ { output: output_reader.value, success: wait_thread.value.success? }
50
+ ensure
51
+ cleanup_unfinished_command(wait_thread, output_reader) unless completed || @command_cleaned_up
52
+ output_reader&.join
53
+ end
54
+ end
55
+
56
+ def capture_separate_streams # rubocop:disable Metrics/MethodLength
57
+ completed = false
58
+ Open3.popen3(*@command, pgroup: true) do |stdin, stdout, stderr, wait_thread|
59
+ stdin.close
60
+ output_reader = Thread.new { stdout.read }
61
+ error_reader = Thread.new { stderr.read }
62
+ wait_for_command(wait_thread, output_reader, error_reader)
63
+ completed = true
64
+ {
65
+ output: output_reader.value,
66
+ error_output: error_reader.value,
67
+ success: wait_thread.value.success?
68
+ }
69
+ ensure
70
+ cleanup_unfinished_command(wait_thread, output_reader, error_reader) unless completed || @command_cleaned_up
71
+ output_reader&.join
72
+ error_reader&.join
73
+ end
74
+ end
75
+
76
+ def wait_for_command(wait_thread, *output_readers)
77
+ observed_threads = [wait_thread, *output_readers]
78
+ return if observed_threads.all? { |thread| thread.join(remaining_timeout) }
79
+
80
+ terminate_process_group(wait_thread)
81
+ output_readers.each(&:kill)
82
+ output_readers.each(&:join)
83
+ @command_cleaned_up = true
84
+ raise Shell::CommandTimeout, "Command exceeded the #{@timeout_seconds}-second timeout"
85
+ end
86
+
87
+ def remaining_timeout
88
+ [@deadline - monotonic_time, 0].max
89
+ end
90
+
91
+ def monotonic_time
92
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
93
+ end
94
+
95
+ def terminate_process_group(wait_thread)
96
+ Process.kill("TERM", -wait_thread.pid)
97
+ wait_thread.join(1)
98
+
99
+ Process.kill("KILL", -wait_thread.pid)
100
+ wait_thread.join
101
+ rescue Errno::ESRCH, Errno::ECHILD
102
+ nil
103
+ end
104
+
105
+ def cleanup_unfinished_command(wait_thread, *output_readers)
106
+ return unless wait_thread
107
+
108
+ terminate_process_group(wait_thread)
109
+ output_readers.compact.each(&:kill)
110
+ end
111
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cpflow
4
- VERSION = "5.3.0"
4
+ VERSION = "6.0.0"
5
5
  MIN_CPLN_VERSION = "3.1.0"
6
6
  end
data/lib/cpflow.rb CHANGED
@@ -122,7 +122,7 @@ module Cpflow
122
122
  end
123
123
 
124
124
  def self.subcommand?
125
- (subcommand_names & ARGV).any?
125
+ subcommand_names.intersect?(ARGV)
126
126
  end
127
127
  private_class_method :subcommand?
128
128
 
@@ -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
@@ -12,7 +12,7 @@ error_exit() {
12
12
 
13
13
  log "Running release_script.sh per controlplane.yml"
14
14
 
15
- mkdir -p db storage
15
+ mkdir -p data storage
16
16
 
17
17
  if [ -x ./bin/rails ]; then
18
18
  log "Run DB migrations"
@@ -16,7 +16,8 @@ spec:
16
16
  # See https://www.shakacode.com/control-plane-flow/docs/thruster/ for details.
17
17
  protocol: http
18
18
  volumes:
19
- - path: /app/db
19
+ # Keep migrations and schema from the image visible under /app/db.
20
+ - path: /app/data
20
21
  recoveryPolicy: retain
21
22
  uri: cpln://volumeset/app-db
22
23
  - path: /app/storage