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
|
@@ -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
|
data/lib/core/doctor_service.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
yield
|
|
11
|
+
previous_tmp_stderr = @tmp_stderr
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
79
|
-
|
|
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
|
data/lib/cpflow/version.rb
CHANGED
data/lib/cpflow.rb
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
79
|
-
|
|
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 ./
|
|
@@ -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/
|
|
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
|
|
@@ -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
|
-
|
|
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
|