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
data/lib/command/generate.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "yaml"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "shellwords"
|
|
4
6
|
|
|
5
7
|
require_relative "generator_helpers"
|
|
6
8
|
require_relative "../core/repo_introspection"
|
|
@@ -34,10 +36,29 @@ module Command
|
|
|
34
36
|
# `.tool-versions`, or the `Gemfile`. Keep this on a supported release line
|
|
35
37
|
# (https://www.ruby-lang.org/en/downloads/branches/).
|
|
36
38
|
DEFAULT_RUBY_VERSION = "3.3"
|
|
39
|
+
SQLITE_DATABASE_PREPARE_FUNCTION = <<~SH
|
|
40
|
+
prepare_sqlite_database() {
|
|
41
|
+
source_path="$1"
|
|
42
|
+
persistent_path="$2"
|
|
43
|
+
legacy_path="${3:-}"
|
|
44
|
+
if [ -n "${legacy_path}" ] && [ -e "${legacy_path}" ] && [ ! -e "${persistent_path}" ]; then
|
|
45
|
+
persistent_path="${legacy_path}"
|
|
46
|
+
fi
|
|
47
|
+
mkdir -p "$(dirname "${source_path}")" "$(dirname "${persistent_path}")"
|
|
48
|
+
if [ -e "${source_path}" ] && [ ! -L "${source_path}" ] && [ ! -e "${persistent_path}" ]; then
|
|
49
|
+
mv -n "${source_path}" "${persistent_path}"
|
|
50
|
+
fi
|
|
51
|
+
rm -f "${source_path}"
|
|
52
|
+
ln -s "${persistent_path}" "${source_path}"
|
|
53
|
+
}
|
|
54
|
+
SH
|
|
37
55
|
|
|
38
56
|
def copy_files
|
|
57
|
+
validate_sqlite_database_paths!
|
|
39
58
|
generated_paths = copy_template_files("generator_templates", base_template_files)
|
|
40
59
|
generated_paths += copy_template_files("generator_templates_sqlite", SQLITE_TEMPLATE_FILES) if sqlite_project?
|
|
60
|
+
copy_dockerignore unless File.exist?(".dockerignore")
|
|
61
|
+
append_dockerignore_entries
|
|
41
62
|
substitute_template_variables(generated_paths)
|
|
42
63
|
make_shell_scripts_executable(generated_paths)
|
|
43
64
|
end
|
|
@@ -64,6 +85,78 @@ module Command
|
|
|
64
85
|
destination_path
|
|
65
86
|
end
|
|
66
87
|
|
|
88
|
+
def copy_dockerignore
|
|
89
|
+
copy_file(
|
|
90
|
+
File.join("generator_templates", ".dockerignore"),
|
|
91
|
+
".dockerignore",
|
|
92
|
+
verbose: ENV.fetch("HIDE_COMMAND_OUTPUT", nil) != "true"
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def append_dockerignore_entries
|
|
97
|
+
contents = File.read(".dockerignore")
|
|
98
|
+
existing_lines = contents.lines(chomp: true)
|
|
99
|
+
additions = dockerignore_additions(existing_lines)
|
|
100
|
+
return if additions.empty?
|
|
101
|
+
|
|
102
|
+
File.open(".dockerignore", "a") do |file|
|
|
103
|
+
file.write("\n") unless contents.empty? || contents.end_with?("\n")
|
|
104
|
+
file.puts(additions)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def dockerignore_additions(existing_lines)
|
|
109
|
+
entries = File.readlines(File.join(self.class.source_root, "generator_templates", ".dockerignore"), chomp: true)
|
|
110
|
+
sqlite_entries = sqlite_project? ? sqlite_database_ignore_entries : []
|
|
111
|
+
entries += sqlite_entries
|
|
112
|
+
env_entries = [".env*", "!.env.example"]
|
|
113
|
+
mandatory_exclusions = ["config/master.key", "config/credentials/*.key", *sqlite_entries]
|
|
114
|
+
ordinary_entries = entries - env_entries - mandatory_exclusions
|
|
115
|
+
additions = ordinary_entries - existing_lines
|
|
116
|
+
additions += mandatory_exclusions.reject { |entry| dockerignore_exclusion_effective?(existing_lines, entry) }
|
|
117
|
+
additions += env_entries unless dockerignore_env_exception_preserved?(existing_lines)
|
|
118
|
+
additions
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def dockerignore_exclusion_effective?(lines, exclusion)
|
|
122
|
+
exclusion_index = lines.rindex(exclusion)
|
|
123
|
+
exclusion_index && lines[(exclusion_index + 1)..].none? { |line| line.start_with?("!") }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def dockerignore_env_exception_preserved?(lines)
|
|
127
|
+
exclusion_index = lines.rindex(".env*")
|
|
128
|
+
exception_index = lines.rindex("!.env.example")
|
|
129
|
+
return false unless exclusion_index && exception_index && exception_index > exclusion_index
|
|
130
|
+
|
|
131
|
+
lines[(exception_index + 1)..].none? { |line| line.start_with?("!") }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def sqlite_database_ignore_entries
|
|
135
|
+
RepoIntrospection.sqlite_database_paths_in_production(Dir.pwd).flat_map do |database_path|
|
|
136
|
+
relative = docker_context_database_path(database_path)
|
|
137
|
+
next [] unless relative
|
|
138
|
+
|
|
139
|
+
base = "/#{escape_dockerignore_path(relative)}"
|
|
140
|
+
[base, "#{base}-wal", "#{base}-shm", "#{base}-journal"]
|
|
141
|
+
end.uniq
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def escape_dockerignore_path(path)
|
|
145
|
+
path.gsub(/[\\*?\[\]]/) { |character| "\\#{character}" }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def docker_context_database_path(database_path)
|
|
149
|
+
path = Pathname.new(database_path).cleanpath
|
|
150
|
+
if path.absolute?
|
|
151
|
+
return unless path.to_s.start_with?("/app/")
|
|
152
|
+
|
|
153
|
+
return path.to_s.delete_prefix("/app/")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
normalized = path.to_s
|
|
157
|
+
normalized unless normalized == "." || normalized.start_with?("../")
|
|
158
|
+
end
|
|
159
|
+
|
|
67
160
|
def base_template_files
|
|
68
161
|
COMMON_TEMPLATE_FILES + (sqlite_project? ? [] : POSTGRES_TEMPLATE_FILES)
|
|
69
162
|
end
|
|
@@ -72,7 +165,8 @@ module Command
|
|
|
72
165
|
{
|
|
73
166
|
"__APP_PREFIX__" => inferred_app_prefix,
|
|
74
167
|
"__RUBY_VERSION__" => inferred_ruby_version,
|
|
75
|
-
"__ASSET_PRECOMPILE_HOOK_RUN__" => asset_precompile_hook_run
|
|
168
|
+
"__ASSET_PRECOMPILE_HOOK_RUN__" => asset_precompile_hook_run,
|
|
169
|
+
"__SQLITE_DATABASE_SETUP__" => sqlite_database_setup
|
|
76
170
|
}
|
|
77
171
|
end
|
|
78
172
|
|
|
@@ -99,7 +193,7 @@ module Command
|
|
|
99
193
|
return "" if stripped.empty?
|
|
100
194
|
return "" unless single_line_asset_precompile_hook?(stripped)
|
|
101
195
|
|
|
102
|
-
"RUN #{stripped}\n\n"
|
|
196
|
+
"RUN export SECRET_KEY_BASE=NOT_USED_NON_BLANK && #{stripped}\n\n"
|
|
103
197
|
end
|
|
104
198
|
|
|
105
199
|
def single_line_asset_precompile_hook?(command)
|
|
@@ -113,6 +207,156 @@ module Command
|
|
|
113
207
|
RepoIntrospection.sqlite_database_in_production?(Dir.pwd)
|
|
114
208
|
end
|
|
115
209
|
|
|
210
|
+
def validate_sqlite_database_paths!
|
|
211
|
+
return unless sqlite_project?
|
|
212
|
+
|
|
213
|
+
validate_sqlite_database_locations!
|
|
214
|
+
validate_sqlite_persistence_targets!
|
|
215
|
+
return unless RepoIntrospection.unresolved_sqlite_database_paths_in_production?(Dir.pwd)
|
|
216
|
+
|
|
217
|
+
raise Cpflow::Error,
|
|
218
|
+
"Production SQLite database paths must be literal file paths in config/database.yml; " \
|
|
219
|
+
"runtime ERB paths cannot be persisted safely by the generated scaffold."
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def validate_sqlite_database_locations!
|
|
223
|
+
database_paths = RepoIntrospection.sqlite_database_paths_in_production(Dir.pwd)
|
|
224
|
+
validate_sqlite_paths_without_control_characters!(database_paths)
|
|
225
|
+
validate_sqlite_paths_under_app!(database_paths)
|
|
226
|
+
validate_sqlite_paths_below_mount_roots!(database_paths)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def validate_sqlite_paths_without_control_characters!(database_paths)
|
|
230
|
+
unsafe_path = database_paths.find { |database_path| database_path.match?(/[[:cntrl:]]/) }
|
|
231
|
+
return unless unsafe_path
|
|
232
|
+
|
|
233
|
+
raise Cpflow::Error,
|
|
234
|
+
"Production SQLite database paths cannot contain control characters; update config/database.yml " \
|
|
235
|
+
"before generating the scaffold."
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def validate_sqlite_paths_under_app!(database_paths)
|
|
239
|
+
unsupported_path = database_paths.find do |database_path|
|
|
240
|
+
path = absolute_app_database_path(database_path)
|
|
241
|
+
path != "/app" && !path.start_with?("/app/")
|
|
242
|
+
end
|
|
243
|
+
return unless unsupported_path
|
|
244
|
+
|
|
245
|
+
raise Cpflow::Error,
|
|
246
|
+
"Production SQLite database path #{unsupported_path.inspect} must resolve under /app so the generated " \
|
|
247
|
+
"scaffold can persist it safely."
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def validate_sqlite_paths_below_mount_roots!(database_paths)
|
|
251
|
+
mount_root_path = database_paths.find do |database_path|
|
|
252
|
+
["/app/data", "/app/storage"].include?(absolute_app_database_path(database_path))
|
|
253
|
+
end
|
|
254
|
+
return unless mount_root_path
|
|
255
|
+
|
|
256
|
+
raise Cpflow::Error,
|
|
257
|
+
"Production SQLite database path #{mount_root_path.inspect} resolves to a volume mount directory; " \
|
|
258
|
+
"use a file path beneath data/ or storage/."
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def sqlite_database_setup
|
|
262
|
+
redirects = sqlite_database_redirects
|
|
263
|
+
return "" if redirects.empty?
|
|
264
|
+
|
|
265
|
+
setup_calls = redirects.map do |source, target, legacy|
|
|
266
|
+
arguments = [source, target, legacy].compact.map { |path| Shellwords.shellescape(path) }
|
|
267
|
+
"prepare_sqlite_database #{arguments.join(' ')}"
|
|
268
|
+
end
|
|
269
|
+
"#{SQLITE_DATABASE_PREPARE_FUNCTION}\n#{setup_calls.join("\n")}\n"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def sqlite_database_redirects
|
|
273
|
+
return [] unless sqlite_project?
|
|
274
|
+
|
|
275
|
+
RepoIntrospection.sqlite_database_paths_in_production(Dir.pwd).filter_map do |database_path|
|
|
276
|
+
source = absolute_app_database_path(database_path)
|
|
277
|
+
next if persistent_sqlite_path?(source)
|
|
278
|
+
|
|
279
|
+
target = persistent_sqlite_target(source)
|
|
280
|
+
[source, target, legacy_sqlite_database_path(source)]
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def validate_sqlite_persistence_targets!
|
|
285
|
+
paths_by_target = sqlite_database_paths_by_persistence_target
|
|
286
|
+
validate_exact_sqlite_target_collisions!(paths_by_target)
|
|
287
|
+
validate_ancestor_sqlite_target_collisions!(paths_by_target.keys)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def validate_exact_sqlite_target_collisions!(paths_by_target)
|
|
291
|
+
collision = paths_by_target.find { |_target, paths| paths.uniq.size > 1 }
|
|
292
|
+
return unless collision
|
|
293
|
+
|
|
294
|
+
target = collision.first
|
|
295
|
+
paths = collision.last.uniq
|
|
296
|
+
raise Cpflow::Error,
|
|
297
|
+
"Production SQLite database paths #{paths.map(&:inspect).join(' and ')} resolve to the same persistent " \
|
|
298
|
+
"target #{target.inspect}; use distinct paths before generating the scaffold."
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def validate_ancestor_sqlite_target_collisions!(targets)
|
|
302
|
+
collision = targets.combination(2).find do |first, second|
|
|
303
|
+
sqlite_path_ancestor?(first, second) || sqlite_path_ancestor?(second, first)
|
|
304
|
+
end
|
|
305
|
+
return unless collision
|
|
306
|
+
|
|
307
|
+
first, second = collision
|
|
308
|
+
raise Cpflow::Error,
|
|
309
|
+
"Production SQLite persistence targets #{first.inspect} and #{second.inspect} overlap; " \
|
|
310
|
+
"a database file cannot contain another database path."
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def sqlite_path_ancestor?(ancestor, descendant)
|
|
314
|
+
descendant.start_with?("#{ancestor}/")
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def sqlite_database_paths_by_persistence_target
|
|
318
|
+
paths_by_target = Hash.new { |hash, key| hash[key] = [] }
|
|
319
|
+
RepoIntrospection.sqlite_database_paths_in_production(Dir.pwd).each do |database_path|
|
|
320
|
+
source = absolute_app_database_path(database_path)
|
|
321
|
+
target = persistent_sqlite_path?(source) ? source : persistent_sqlite_target(source)
|
|
322
|
+
[target, legacy_sqlite_database_path(source)].compact.each do |claimed_path|
|
|
323
|
+
claim_sqlite_database_paths(paths_by_target, claimed_path, database_path)
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
paths_by_target
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def claim_sqlite_database_paths(paths_by_target, claimed_path, database_path)
|
|
330
|
+
sqlite_database_owned_paths(claimed_path).each do |owned_path|
|
|
331
|
+
paths_by_target[owned_path] << database_path
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def sqlite_database_owned_paths(database_path)
|
|
336
|
+
[database_path, "#{database_path}-wal", "#{database_path}-shm", "#{database_path}-journal"]
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def persistent_sqlite_target(source)
|
|
340
|
+
relative = source.delete_prefix("/")
|
|
341
|
+
File.join("/app/data", relative.delete_prefix("app/"))
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def legacy_sqlite_database_path(source)
|
|
345
|
+
return unless source.start_with?("/app/db/")
|
|
346
|
+
|
|
347
|
+
File.join("/app/data", source.delete_prefix("/app/db/"))
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def absolute_app_database_path(database_path)
|
|
351
|
+
path = Pathname.new(database_path)
|
|
352
|
+
(path.absolute? ? path : Pathname.new("/app").join(path)).cleanpath.to_s
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def persistent_sqlite_path?(path)
|
|
356
|
+
path == "/app/data" || path.start_with?("/app/data/") ||
|
|
357
|
+
path == "/app/storage" || path.start_with?("/app/storage/")
|
|
358
|
+
end
|
|
359
|
+
|
|
116
360
|
def normalized_asset_precompile_hook_command
|
|
117
361
|
command = shakapacker_precompile_hook || react_on_rails_auto_bundle_hook
|
|
118
362
|
return unless command
|
|
@@ -169,7 +413,7 @@ module Command
|
|
|
169
413
|
- infers the app prefix from the current directory and wires staging, review, and production entries
|
|
170
414
|
- infers the Docker base Ruby version from `.ruby-version`, `.tool-versions`, or the app's `Gemfile`
|
|
171
415
|
- preserves repo-defined asset precompile hooks, including React on Rails auto bundle generation
|
|
172
|
-
- detects SQLite in `config/database.yml` and generates persistent `
|
|
416
|
+
- detects SQLite in `config/database.yml` and generates persistent `/app/data` and `/app/storage` volume templates without hiding image migrations under `/app/db`
|
|
173
417
|
DESC
|
|
174
418
|
EXAMPLES = <<~EX
|
|
175
419
|
```sh
|
|
@@ -12,6 +12,7 @@ module Command
|
|
|
12
12
|
include GeneratorHelpers
|
|
13
13
|
|
|
14
14
|
argument :staging_branch, type: :string, required: false
|
|
15
|
+
class_option :workflows, type: :array
|
|
15
16
|
|
|
16
17
|
def copy_files
|
|
17
18
|
relative_paths = generated_files
|
|
@@ -51,7 +52,12 @@ module Command
|
|
|
51
52
|
def generated_files
|
|
52
53
|
# Keep file discovery centralized on the command class so existence checks and
|
|
53
54
|
# Thor's template copy list cannot drift.
|
|
54
|
-
GenerateGithubActions.generated_files
|
|
55
|
+
paths = GenerateGithubActions.generated_files
|
|
56
|
+
return paths unless options.key?("workflows")
|
|
57
|
+
|
|
58
|
+
paths.reject do |path|
|
|
59
|
+
path.start_with?(".github/workflows/") && !options["workflows"].include?(File.basename(path))
|
|
60
|
+
end
|
|
55
61
|
end
|
|
56
62
|
|
|
57
63
|
def staging_branch_filter
|
|
@@ -99,8 +105,8 @@ module Command
|
|
|
99
105
|
- manual promotion from staging to production
|
|
100
106
|
- nightly cleanup and PR help workflows
|
|
101
107
|
|
|
102
|
-
|
|
103
|
-
|
|
108
|
+
The workflows load cpflow's composite actions from this gem's repository at
|
|
109
|
+
the ref each workflow pins, so no `.github/actions/cpflow-*` copies are generated.
|
|
104
110
|
|
|
105
111
|
Pass `--staging-branch BRANCH` when staging should auto-deploy from a branch
|
|
106
112
|
other than `main` or `master`; the generator will bake that branch into the
|
|
@@ -111,7 +117,7 @@ module Command
|
|
|
111
117
|
DESC
|
|
112
118
|
EXAMPLES = <<~EX
|
|
113
119
|
```sh
|
|
114
|
-
# Creates workflow wrappers
|
|
120
|
+
# Creates workflow wrappers and validation helpers
|
|
115
121
|
cpflow generate-github-actions
|
|
116
122
|
|
|
117
123
|
# Creates the flow with staging deploys triggered from develop
|
|
@@ -125,23 +131,16 @@ module Command
|
|
|
125
131
|
VALIDATIONS = [].freeze
|
|
126
132
|
REQUIRES_STARTUP_CHECKS = false
|
|
127
133
|
|
|
128
|
-
# Resolve source
|
|
134
|
+
# Resolve the source root from __dir__ rather than Cpflow.root_path because this file is
|
|
129
135
|
# loaded before `module Cpflow` finishes defining its class methods.
|
|
130
|
-
REPOSITORY_ROOT = Pathname.new(File.expand_path("../..", __dir__))
|
|
131
136
|
TEMPLATE_ROOT = Pathname.new(File.expand_path("../github_flow_templates", __dir__))
|
|
132
|
-
ACTIONS_ROOT = REPOSITORY_ROOT.join(".github/actions")
|
|
133
137
|
|
|
134
138
|
def self.generated_file_sources
|
|
135
139
|
ensure_template_root!
|
|
136
140
|
|
|
137
|
-
|
|
141
|
+
files_beneath(TEMPLATE_ROOT).to_h do |source|
|
|
138
142
|
[source.relative_path_from(TEMPLATE_ROOT).to_s, source]
|
|
139
|
-
end
|
|
140
|
-
action_sources = files_beneath(ACTIONS_ROOT, "cpflow-*/**/*").to_h do |source|
|
|
141
|
-
[source.relative_path_from(REPOSITORY_ROOT).to_s, source]
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
template_sources.merge(action_sources).sort.to_h.freeze
|
|
143
|
+
end.sort.to_h.freeze
|
|
145
144
|
end
|
|
146
145
|
|
|
147
146
|
def self.generated_files
|
|
@@ -154,11 +153,10 @@ module Command
|
|
|
154
153
|
|
|
155
154
|
def self.ensure_template_root!
|
|
156
155
|
raise "cpflow template directory not found: #{TEMPLATE_ROOT}" unless TEMPLATE_ROOT.directory?
|
|
157
|
-
raise "cpflow action directory not found: #{ACTIONS_ROOT}" unless ACTIONS_ROOT.directory?
|
|
158
156
|
end
|
|
159
157
|
|
|
160
|
-
def self.files_beneath(root
|
|
161
|
-
Dir.glob(root.join(
|
|
158
|
+
def self.files_beneath(root)
|
|
159
|
+
Dir.glob(root.join("**/*").to_s, File::FNM_DOTMATCH)
|
|
162
160
|
.select { |path| File.file?(path) }
|
|
163
161
|
.map { |path| Pathname.new(path) }
|
|
164
162
|
.sort
|
data/lib/command/setup_app.rb
CHANGED
|
@@ -18,13 +18,14 @@ module Command
|
|
|
18
18
|
- Configures app to have org-level secrets with default name `"{APP_PREFIX}-secrets"`
|
|
19
19
|
using org-level policy with default name `"{APP_PREFIX}-secrets-policy"` (names can be customized, see docs)
|
|
20
20
|
- Creates identity for secrets if it does not exist
|
|
21
|
+
- For dynamically named review apps with `generated_review_secret_keys`, checks an existing policy before writing credentials, creates a per-app dictionary, skips its secret template during initial setup, and fills missing disposable keys without printing or rotating values
|
|
21
22
|
- Binds the app identity to any configured `shared_secret_grants` policies as part of the secrets setup flow; skipped when `--skip-secrets-setup` or `--skip-secret-access-binding` is provided, or `skip_secrets_setup` is set
|
|
22
23
|
- Use `--skip-secrets-setup` to prevent the automatic setup of secrets,
|
|
23
24
|
or set it through `skip_secrets_setup` in the `.controlplane/controlplane.yml` file
|
|
24
25
|
- Runs a post-creation hook after the app is created if `hooks.post_creation` is specified in the `.controlplane/controlplane.yml` file
|
|
25
26
|
- If the hook exits with a non-zero code, the command will stop executing and also exit with a non-zero code
|
|
26
27
|
- Use `--skip-post-creation-hook` to skip the hook if specified in `controlplane.yml`
|
|
27
|
-
- Use `--refresh-templates` to apply configured templates noninteractively to an existing app while preserving each workload's configured app image even when workloads are unready or use mixed image versions, skipping existing secret
|
|
28
|
+
- Use `--refresh-templates` to apply configured templates noninteractively to an existing app while preserving each workload's configured app image even when workloads are unready or use mixed image versions, skipping existing secret templates (but filling missing opt-in generated review keys), repairing secrets access bindings, and skipping the post-creation hook
|
|
28
29
|
DESC
|
|
29
30
|
VALIDATIONS = %w[config templates].freeze
|
|
30
31
|
|
|
@@ -49,8 +50,13 @@ module Command
|
|
|
49
50
|
|
|
50
51
|
args = []
|
|
51
52
|
args.push("--add-app-identity") unless skip_secrets_setup
|
|
52
|
-
args.push("--yes"
|
|
53
|
-
|
|
53
|
+
args.push("--yes") if refresh_templates
|
|
54
|
+
args.push("--preserve-existing-runtime") if refresh_templates
|
|
55
|
+
if !skip_secrets_setup && config.generated_review_secret_keys.any?
|
|
56
|
+
args.push("--exclude-policy-template", config.secrets_policy)
|
|
57
|
+
args.push("--exclude-secret-template", config.secrets) unless refresh_templates
|
|
58
|
+
end
|
|
59
|
+
run_cpflow_command("apply-template", "-a", config.app, *args, *templates)
|
|
54
60
|
|
|
55
61
|
bind_identity_to_policy unless skip_secrets_setup
|
|
56
62
|
bind_shared_secret_policy_grants(shared_secret_policy_grant_pairs) unless skip_secrets_setup
|
|
@@ -65,24 +71,81 @@ module Command
|
|
|
65
71
|
end
|
|
66
72
|
|
|
67
73
|
def create_secret_and_policy_if_not_exist
|
|
74
|
+
validate_existing_generated_review_policy_before_secret!
|
|
68
75
|
create_secret_if_not_exists
|
|
69
76
|
create_policy_if_not_exists
|
|
70
77
|
|
|
71
78
|
progress.puts
|
|
72
79
|
end
|
|
73
80
|
|
|
81
|
+
def validate_existing_generated_review_policy_before_secret!
|
|
82
|
+
return if config.generated_review_secret_keys.empty?
|
|
83
|
+
|
|
84
|
+
policy = cp.fetch_policy(config.secrets_policy)
|
|
85
|
+
verify_generated_review_policy!(policy) if policy
|
|
86
|
+
end
|
|
87
|
+
|
|
74
88
|
def create_secret_if_not_exists
|
|
75
|
-
|
|
76
|
-
|
|
89
|
+
secret = cp.fetch_secret(config.secrets)
|
|
90
|
+
return existing_secret_if_any(secret) if secret
|
|
91
|
+
|
|
92
|
+
step("Creating secret '#{config.secrets}'") { create_new_secret }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def existing_secret_if_any(secret)
|
|
96
|
+
if config.generated_review_secret_keys.any? && !generated_review_secret_owned_by_app?(secret)
|
|
97
|
+
raise "Existing review app secret dictionary is not owned by this app."
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
progress.puts("Secret '#{config.secrets}' already exists. Skipping creation...")
|
|
101
|
+
fill_missing_generated_review_secrets
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def create_new_secret
|
|
105
|
+
if config.generated_review_secret_keys.any?
|
|
106
|
+
cp.create_sensitive_secret(config.secrets, generated_review_secret_data)
|
|
77
107
|
else
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
108
|
+
cp.apply_hash(build_secret_hash)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def fill_missing_generated_review_secrets
|
|
113
|
+
keys = config.generated_review_secret_keys
|
|
114
|
+
return if keys.empty?
|
|
115
|
+
|
|
116
|
+
data = revealed_review_secret_data
|
|
117
|
+
|
|
118
|
+
missing_keys = keys.reject { |key| data[key].is_a?(String) && !data[key].empty? }
|
|
119
|
+
return if missing_keys.empty?
|
|
120
|
+
|
|
121
|
+
step("Adding missing generated review app secret fields") do
|
|
122
|
+
cp.patch_sensitive_secret_data(config.secrets, generated_review_secret_data(missing_keys))
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def revealed_review_secret_data
|
|
127
|
+
revealed = begin
|
|
128
|
+
cp.reveal_secret(config.secrets, required: true)
|
|
129
|
+
rescue StandardError
|
|
130
|
+
raise "Cannot safely inspect existing review app secret dictionary.", cause: nil
|
|
81
131
|
end
|
|
132
|
+
raise "Cannot safely inspect existing review app secret dictionary." unless revealed.is_a?(Hash)
|
|
133
|
+
raise "Existing review app secret is not a dictionary." unless revealed["type"] == "dictionary"
|
|
134
|
+
|
|
135
|
+
data = revealed["data"]
|
|
136
|
+
raise "Cannot safely inspect existing review app secret dictionary." unless data.is_a?(Hash)
|
|
137
|
+
|
|
138
|
+
data
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def generated_review_secret_data(keys = config.generated_review_secret_keys)
|
|
142
|
+
keys.to_h { |key| [key, SecureRandom.hex(32)] }
|
|
82
143
|
end
|
|
83
144
|
|
|
84
145
|
def create_policy_if_not_exists
|
|
85
|
-
|
|
146
|
+
policy = cp.fetch_policy(config.secrets_policy)
|
|
147
|
+
if policy
|
|
148
|
+
verify_generated_review_policy!(policy) if config.generated_review_secret_keys.any?
|
|
86
149
|
progress.puts("Policy '#{config.secrets_policy}' already exists. Skipping creation...")
|
|
87
150
|
else
|
|
88
151
|
step("Creating policy '#{config.secrets_policy}'") do
|
|
@@ -91,6 +154,16 @@ module Command
|
|
|
91
154
|
end
|
|
92
155
|
end
|
|
93
156
|
|
|
157
|
+
def verify_generated_review_policy!(policy)
|
|
158
|
+
expected_target = policy_targets_secret?(policy, config.secrets)
|
|
159
|
+
owned_policy = generated_review_app_tag(policy) == config.app
|
|
160
|
+
no_extra_selectors = %w[target targetQuery gvc].all? { |key| policy[key].nil? }
|
|
161
|
+
own_bindings = generated_review_policy_binding_state(policy) != :unsafe
|
|
162
|
+
return if expected_target && owned_policy && no_extra_selectors && own_bindings
|
|
163
|
+
|
|
164
|
+
raise "Existing review app secret policy has an unexpected target or binding."
|
|
165
|
+
end
|
|
166
|
+
|
|
94
167
|
def build_secret_hash
|
|
95
168
|
{
|
|
96
169
|
"kind" => "secret",
|
|
@@ -101,17 +174,26 @@ module Command
|
|
|
101
174
|
end
|
|
102
175
|
|
|
103
176
|
def build_policy_hash
|
|
104
|
-
{
|
|
177
|
+
policy = {
|
|
105
178
|
"kind" => "policy",
|
|
106
179
|
"name" => config.secrets_policy,
|
|
107
180
|
"targetKind" => "secret",
|
|
108
181
|
"targetLinks" => ["//secret/#{config.secrets}"]
|
|
109
182
|
}
|
|
183
|
+
policy["tags"] = { ::Config::GENERATED_REVIEW_APP_TAG => config.app } if config.generated_review_secret_keys.any?
|
|
184
|
+
policy
|
|
110
185
|
end
|
|
111
186
|
|
|
112
187
|
def bind_identity_to_policy
|
|
113
188
|
progress.puts
|
|
114
189
|
|
|
190
|
+
if config.generated_review_secret_keys.any?
|
|
191
|
+
policy = cp.fetch_policy(config.secrets_policy)
|
|
192
|
+
raise "Cannot safely inspect review app secret policy before binding." unless policy.is_a?(Hash)
|
|
193
|
+
|
|
194
|
+
verify_generated_review_policy!(policy)
|
|
195
|
+
end
|
|
196
|
+
|
|
115
197
|
step("Binding identity '#{config.identity}' to policy '#{config.secrets_policy}'") do
|
|
116
198
|
cp.bind_identity_to_policy(config.identity_link, config.secrets_policy)
|
|
117
199
|
end
|