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.
- checksums.yaml +4 -4
- data/.agents/agent-workflow.yml +16 -38
- data/.agents/bin/README.md +7 -8
- data/.agents/legacy-workflow-policy.yml +11 -0
- data/.agents/shaka.md +20 -0
- data/.agents/trusted-actions.yml +12 -0
- data/.coderabbit.yaml +1 -0
- data/.github/actions/cpflow-build-docker-image/action.yml +153 -22
- 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/workflows/cpflow-deploy-review-app.yml +13 -1
- data/.github/workflows/cpflow-deploy-staging.yml +12 -1
- data/.github/workflows/cpflow-promote-staging-to-production.yml +128 -9
- data/.github/workflows/rspec.yml +24 -0
- data/AGENTS.md +18 -3
- data/CHANGELOG.md +18 -3
- data/CONTRIBUTING.md +1 -1
- data/Gemfile.lock +1 -1
- data/README.md +5 -2
- data/docs/ai-github-flow-prompt.md +2 -2
- data/docs/ci-automation.md +178 -14
- data/docs/commands.md +26 -13
- data/docs/postgres-review-apps.md +90 -0
- data/docs/postgres.md +233 -375
- data/docs/secrets-and-env-values.md +42 -0
- data/docs/sidebars.ts +1 -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/delete.rb +174 -4
- data/lib/command/deploy_image.rb +6 -0
- data/lib/command/generate.rb +247 -3
- data/lib/command/generate_github_actions.rb +7 -1
- data/lib/command/setup_app.rb +92 -10
- data/lib/command/update_github_actions.rb +81 -43
- data/lib/core/config.rb +73 -0
- data/lib/core/controlplane.rb +22 -2
- 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/cpflow/version.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 +128 -9
- data/lib/github_flow_templates/bin/pin-cpflow-github-ref +37 -13
- data/lib/github_flow_templates/bin/test-cpflow-github-flow +62 -43
- 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!
|
data/lib/core/controlplane.rb
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
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/cpflow/version.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
|