cpflow 6.0.0.rc.0 → 6.0.1
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-cleanup-stale-review-apps.yml +3 -10
- data/.github/workflows/cpflow-delete-review-app.yml +4 -11
- data/.github/workflows/cpflow-deploy-review-app.yml +19 -14
- data/.github/workflows/cpflow-deploy-staging.yml +17 -12
- data/.github/workflows/cpflow-promote-staging-to-production.yml +132 -13
- data/.github/workflows/rspec.yml +24 -0
- data/AGENTS.md +18 -3
- data/CHANGELOG.md +26 -3
- data/CONTRIBUTING.md +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +5 -2
- data/docs/ai-github-flow-prompt.md +104 -8
- data/docs/ci-automation.md +258 -60
- data/docs/commands.md +32 -16
- 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 +10 -13
- data/lib/command/ai_github_flow_prompt.txt +103 -0
- 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 +15 -17
- data/lib/command/setup_app.rb +92 -10
- data/lib/command/update_github_actions.rb +107 -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 +132 -13
- data/lib/github_flow_templates/bin/pin-cpflow-github-ref +37 -13
- data/lib/github_flow_templates/bin/test-cpflow-github-flow +88 -47
- metadata +12 -3
|
@@ -5,33 +5,52 @@ require "yaml"
|
|
|
5
5
|
require_relative "staging_branch_validation"
|
|
6
6
|
|
|
7
7
|
module Command
|
|
8
|
-
class UpdateGithubActions < Base
|
|
8
|
+
class UpdateGithubActions < Base # rubocop:disable Metrics/ClassLength
|
|
9
9
|
include StagingBranchValidation
|
|
10
10
|
|
|
11
11
|
NAME = "update-github-actions"
|
|
12
|
-
OPTIONS = [staging_branch_option
|
|
12
|
+
OPTIONS = [staging_branch_option, {
|
|
13
|
+
name: :workflows,
|
|
14
|
+
params: {
|
|
15
|
+
type: :array, banner: "FILE...",
|
|
16
|
+
desc: "Explicitly add or replace these generated workflow filenames (default: preserve all workflows)"
|
|
17
|
+
}
|
|
18
|
+
}].freeze
|
|
13
19
|
DESCRIPTION = "Regenerates GitHub Actions files for the installed cpflow version"
|
|
14
20
|
LONG_DESCRIPTION = <<~DESC.freeze
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
Refreshes helper files from the installed gem. Workflows load cpflow's
|
|
22
|
+
composite actions from the pinned cpflow ref, so the command reports any
|
|
23
|
+
`.github/actions/cpflow-*` copies left by cpflow 6.0.0 for you to delete
|
|
24
|
+
once no workflow is pinned to 6.0.0. It never deletes them itself.
|
|
25
|
+
All top-level workflows are preserved by default, including their refs,
|
|
26
|
+
triggers, permissions, and deployment ownership. Use --workflows FILE...
|
|
27
|
+
to explicitly add or replace named generated workflows. Replacement resets
|
|
28
|
+
each selected workflow to its template and matching v#{Cpflow::VERSION} ref;
|
|
29
|
+
review and reapply any downstream customizations in the same PR.
|
|
30
|
+
|
|
31
|
+
Selecting cpflow-deploy-staging.yml preserves a single existing push branch
|
|
32
|
+
or the default main/master pair. Missing or ambiguous branches require
|
|
33
|
+
--staging-branch BRANCH. This option requires selecting the staging workflow.
|
|
34
|
+
|
|
35
|
+
A differing existing bin/test-cpflow-github-flow blocks all writes. Move
|
|
36
|
+
downstream checks to executable bin/test-cpflow-github-flow-custom and
|
|
37
|
+
remove or rename the old validator before updating. The generated validator
|
|
38
|
+
invokes that extension after its baseline checks; the extension is never
|
|
39
|
+
generated or overwritten. See docs/ci-automation.md for migration guidance.
|
|
24
40
|
DESC
|
|
25
41
|
EXAMPLES = <<~EX
|
|
26
42
|
```sh
|
|
27
|
-
#
|
|
43
|
+
# Refresh helpers while preserving downstream workflows
|
|
28
44
|
cpflow update-github-actions
|
|
29
45
|
|
|
30
46
|
# When running cpflow through Bundler
|
|
31
47
|
bundle exec cpflow update-github-actions
|
|
32
48
|
|
|
33
|
-
#
|
|
34
|
-
cpflow update-github-actions --
|
|
49
|
+
# Explicitly replace the review-app wrapper
|
|
50
|
+
cpflow update-github-actions --workflows cpflow-deploy-review-app.yml
|
|
51
|
+
|
|
52
|
+
# Explicitly add or replace staging deployment from develop
|
|
53
|
+
cpflow update-github-actions --workflows cpflow-deploy-staging.yml --staging-branch develop
|
|
35
54
|
```
|
|
36
55
|
EX
|
|
37
56
|
WITH_INFO_HEADER = false
|
|
@@ -44,66 +63,111 @@ module Command
|
|
|
44
63
|
def call
|
|
45
64
|
GenerateGithubActions.ensure_template_root!
|
|
46
65
|
abort_if_no_generated_files!
|
|
47
|
-
|
|
48
|
-
branch =
|
|
49
|
-
|
|
66
|
+
workflows = selected_workflows
|
|
67
|
+
branch = selected_staging_branch(workflows)
|
|
68
|
+
abort_if_custom_validator!
|
|
69
|
+
GithubActionsGenerator.new([branch].compact, workflows: workflows).invoke_all
|
|
70
|
+
report_vendored_actions
|
|
50
71
|
|
|
51
72
|
print_post_update_message
|
|
52
73
|
end
|
|
53
74
|
|
|
54
75
|
private
|
|
55
76
|
|
|
77
|
+
def selected_workflows
|
|
78
|
+
available = GenerateGithubActions.generated_files.grep(%r{\A\.github/workflows/}).map do |path|
|
|
79
|
+
File.basename(path)
|
|
80
|
+
end
|
|
81
|
+
selected = Array(config.options[:workflows])
|
|
82
|
+
unknown = selected - available
|
|
83
|
+
Shell.abort("Unknown --workflows: #{unknown.join(', ')}. Available: #{available.join(', ')}") if unknown.any?
|
|
84
|
+
selected
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def selected_staging_branch(workflows)
|
|
88
|
+
branch = staging_branch
|
|
89
|
+
return branch || inferred_staging_branch if workflows.include?(STAGING_WORKFLOW_PATH.basename.to_s)
|
|
90
|
+
|
|
91
|
+
Shell.abort("--staging-branch requires --workflows cpflow-deploy-staging.yml") if branch
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def abort_if_custom_validator!
|
|
96
|
+
path = "bin/test-cpflow-github-flow"
|
|
97
|
+
return unless File.exist?(path)
|
|
98
|
+
return if File.binread(path) == File.binread(GenerateGithubActions.source_file(path))
|
|
99
|
+
|
|
100
|
+
Shell.abort(<<~MESSAGE)
|
|
101
|
+
#{path} differs from the current generated validator. No files were updated.
|
|
102
|
+
Move downstream checks to executable bin/test-cpflow-github-flow-custom, then remove or rename
|
|
103
|
+
#{path} and retry. See docs/ci-automation.md for the legacy validator migration.
|
|
104
|
+
MESSAGE
|
|
105
|
+
end
|
|
106
|
+
|
|
56
107
|
def abort_if_no_generated_files!
|
|
57
108
|
return if GenerateGithubActions.generated_files.any? { |path| File.exist?(path) }
|
|
109
|
+
return if vendored_action_directories.any?
|
|
58
110
|
|
|
59
|
-
Shell.abort(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
111
|
+
Shell.abort(<<~MESSAGE)
|
|
112
|
+
No generated cpflow GitHub Actions files found in this repository.
|
|
113
|
+
Run `cpflow generate-github-actions` first to create the wrappers,
|
|
114
|
+
then use `cpflow update-github-actions` after future gem upgrades.
|
|
115
|
+
MESSAGE
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# cpflow 6.0.0 copied its composite actions here. Scan the caller's tree rather than this
|
|
119
|
+
# gem's action names, so a copy is still found after an action is renamed or removed.
|
|
120
|
+
def vendored_action_directories
|
|
121
|
+
Dir.glob(".github/actions/cpflow-*").select { |path| File.directory?(path) }.sort
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Whether a copy is still needed depends on the cpflow release each wrapper pins, which a
|
|
125
|
+
# full-SHA pin does not reveal, so deletion stays a reviewed human step.
|
|
126
|
+
def report_vendored_actions
|
|
127
|
+
directories = vendored_action_directories
|
|
128
|
+
return if directories.empty?
|
|
129
|
+
|
|
130
|
+
Shell.warn(<<~MESSAGE)
|
|
131
|
+
cpflow no longer generates or updates these local action copies:
|
|
132
|
+
#{directories.map { |path| "- #{path}" }.join("\n")}
|
|
133
|
+
A workflow pinned to cpflow 6.0.0 still runs them. Delete them once every cpflow workflow
|
|
134
|
+
in this repository is pinned to a later release and none has a
|
|
135
|
+
`uses: ./.github/actions/cpflow-*` line. See docs/ci-automation.md.
|
|
136
|
+
MESSAGE
|
|
64
137
|
end
|
|
65
138
|
|
|
66
139
|
def print_post_update_message
|
|
67
140
|
Shell.info("")
|
|
68
|
-
Shell.info("Updated cpflow GitHub Actions
|
|
141
|
+
Shell.info("Updated cpflow GitHub Actions files for cpflow #{Cpflow::VERSION}.")
|
|
142
|
+
Shell.info("Preserved unselected workflows; review their refs and compatibility before committing.")
|
|
69
143
|
Shell.info("Next: review the diff and run `bin/test-cpflow-github-flow`.")
|
|
70
144
|
Shell.info("If you run cpflow through Bundler, use `bin/test-cpflow-github-flow bundle exec cpflow`.")
|
|
71
145
|
end
|
|
72
146
|
|
|
73
147
|
def inferred_staging_branch
|
|
74
148
|
branches = existing_staging_branches
|
|
75
|
-
return if branches.
|
|
76
|
-
return branches.first if branches.length == 1
|
|
77
|
-
|
|
78
|
-
Shell.warn(
|
|
79
|
-
"Existing staging workflow has multiple custom push branches: #{branches.join(', ')}. " \
|
|
80
|
-
"Run with --staging-branch BRANCH if only one branch should auto-deploy staging."
|
|
81
|
-
)
|
|
149
|
+
return if branches.sort == DEFAULT_STAGING_BRANCHES.sort
|
|
150
|
+
return branches.first if branches.length == 1 && valid_staging_branch?(branches.first)
|
|
82
151
|
|
|
83
|
-
|
|
152
|
+
Shell.abort("Cannot infer staging deployment branch. Pass --staging-branch BRANCH to explicitly set it.")
|
|
84
153
|
end
|
|
85
154
|
|
|
86
155
|
def existing_staging_branches
|
|
87
|
-
|
|
156
|
+
push = parsed_staging_workflow_on["push"]
|
|
157
|
+
branches = push.is_a?(Hash) ? push["branches"] : nil
|
|
158
|
+
branches.is_a?(Array) && branches.all?(String) ? branches : []
|
|
88
159
|
end
|
|
89
160
|
|
|
90
161
|
def parsed_staging_workflow_on
|
|
91
162
|
return {} unless STAGING_WORKFLOW_PATH.file?
|
|
92
163
|
|
|
93
164
|
workflow = YAML.load_file(STAGING_WORKFLOW_PATH, aliases: true)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
workflow_on = workflow["on"] || workflow[true]
|
|
165
|
+
workflow_on = workflow.is_a?(Hash) ? workflow["on"] || workflow[true] : nil
|
|
97
166
|
workflow_on.is_a?(Hash) ? workflow_on : {}
|
|
98
167
|
rescue Psych::SyntaxError => e
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def warn_unparseable_staging_workflow(error)
|
|
104
|
-
Shell.warn(
|
|
105
|
-
"Could not parse #{STAGING_WORKFLOW_PATH}: #{error.message}. " \
|
|
106
|
-
"Run with --staging-branch BRANCH if staging should use a custom branch."
|
|
168
|
+
Shell.abort(
|
|
169
|
+
"Could not parse #{STAGING_WORKFLOW_PATH}: #{e.message}. " \
|
|
170
|
+
"Pass --staging-branch BRANCH to explicitly replace its staging deployment branch."
|
|
107
171
|
)
|
|
108
172
|
end
|
|
109
173
|
end
|
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
|
|