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/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
|
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
|
|
@@ -5,33 +5,49 @@ 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 local composite actions and helper files from the installed gem.
|
|
22
|
+
All top-level workflows are preserved by default, including their refs,
|
|
23
|
+
triggers, permissions, and deployment ownership. Use --workflows FILE...
|
|
24
|
+
to explicitly add or replace named generated workflows. Replacement resets
|
|
25
|
+
each selected workflow to its template and matching v#{Cpflow::VERSION} ref;
|
|
26
|
+
review and reapply any downstream customizations in the same PR.
|
|
27
|
+
|
|
28
|
+
Selecting cpflow-deploy-staging.yml preserves a single existing push branch
|
|
29
|
+
or the default main/master pair. Missing or ambiguous branches require
|
|
30
|
+
--staging-branch BRANCH. This option requires selecting the staging workflow.
|
|
31
|
+
|
|
32
|
+
A differing existing bin/test-cpflow-github-flow blocks all writes. Move
|
|
33
|
+
downstream checks to executable bin/test-cpflow-github-flow-custom and
|
|
34
|
+
remove or rename the old validator before updating. The generated validator
|
|
35
|
+
invokes that extension after its baseline checks; the extension is never
|
|
36
|
+
generated or overwritten. See docs/ci-automation.md for migration guidance.
|
|
24
37
|
DESC
|
|
25
38
|
EXAMPLES = <<~EX
|
|
26
39
|
```sh
|
|
27
|
-
#
|
|
40
|
+
# Refresh actions/helpers while preserving downstream workflows
|
|
28
41
|
cpflow update-github-actions
|
|
29
42
|
|
|
30
43
|
# When running cpflow through Bundler
|
|
31
44
|
bundle exec cpflow update-github-actions
|
|
32
45
|
|
|
33
|
-
#
|
|
34
|
-
cpflow update-github-actions --
|
|
46
|
+
# Explicitly replace the review-app wrapper
|
|
47
|
+
cpflow update-github-actions --workflows cpflow-deploy-review-app.yml
|
|
48
|
+
|
|
49
|
+
# Explicitly add or replace staging deployment from develop
|
|
50
|
+
cpflow update-github-actions --workflows cpflow-deploy-staging.yml --staging-branch develop
|
|
35
51
|
```
|
|
36
52
|
EX
|
|
37
53
|
WITH_INFO_HEADER = false
|
|
@@ -44,66 +60,88 @@ module Command
|
|
|
44
60
|
def call
|
|
45
61
|
GenerateGithubActions.ensure_template_root!
|
|
46
62
|
abort_if_no_generated_files!
|
|
47
|
-
|
|
48
|
-
branch =
|
|
49
|
-
|
|
63
|
+
workflows = selected_workflows
|
|
64
|
+
branch = selected_staging_branch(workflows)
|
|
65
|
+
abort_if_custom_validator!
|
|
66
|
+
GithubActionsGenerator.new([branch].compact, workflows: workflows).invoke_all
|
|
50
67
|
|
|
51
68
|
print_post_update_message
|
|
52
69
|
end
|
|
53
70
|
|
|
54
71
|
private
|
|
55
72
|
|
|
73
|
+
def selected_workflows
|
|
74
|
+
available = GenerateGithubActions.generated_files.grep(%r{\A\.github/workflows/}).map do |path|
|
|
75
|
+
File.basename(path)
|
|
76
|
+
end
|
|
77
|
+
selected = Array(config.options[:workflows])
|
|
78
|
+
unknown = selected - available
|
|
79
|
+
Shell.abort("Unknown --workflows: #{unknown.join(', ')}. Available: #{available.join(', ')}") if unknown.any?
|
|
80
|
+
selected
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def selected_staging_branch(workflows)
|
|
84
|
+
branch = staging_branch
|
|
85
|
+
return branch || inferred_staging_branch if workflows.include?(STAGING_WORKFLOW_PATH.basename.to_s)
|
|
86
|
+
|
|
87
|
+
Shell.abort("--staging-branch requires --workflows cpflow-deploy-staging.yml") if branch
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def abort_if_custom_validator!
|
|
92
|
+
path = "bin/test-cpflow-github-flow"
|
|
93
|
+
return unless File.exist?(path)
|
|
94
|
+
return if File.binread(path) == File.binread(GenerateGithubActions.source_file(path))
|
|
95
|
+
|
|
96
|
+
Shell.abort(<<~MESSAGE)
|
|
97
|
+
#{path} differs from the current generated validator. No files were updated.
|
|
98
|
+
Move downstream checks to executable bin/test-cpflow-github-flow-custom, then remove or rename
|
|
99
|
+
#{path} and retry. See docs/ci-automation.md for the legacy validator migration.
|
|
100
|
+
MESSAGE
|
|
101
|
+
end
|
|
102
|
+
|
|
56
103
|
def abort_if_no_generated_files!
|
|
57
104
|
return if GenerateGithubActions.generated_files.any? { |path| File.exist?(path) }
|
|
58
105
|
|
|
59
|
-
Shell.abort(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
106
|
+
Shell.abort(<<~MESSAGE)
|
|
107
|
+
No generated cpflow GitHub Actions files found in this repository.
|
|
108
|
+
Run `cpflow generate-github-actions` first to create the wrappers,
|
|
109
|
+
then use `cpflow update-github-actions` after future gem upgrades.
|
|
110
|
+
MESSAGE
|
|
64
111
|
end
|
|
65
112
|
|
|
66
113
|
def print_post_update_message
|
|
67
114
|
Shell.info("")
|
|
68
|
-
Shell.info("Updated cpflow GitHub Actions
|
|
115
|
+
Shell.info("Updated cpflow GitHub Actions files for cpflow #{Cpflow::VERSION}.")
|
|
116
|
+
Shell.info("Preserved unselected workflows; review their refs and compatibility before committing.")
|
|
69
117
|
Shell.info("Next: review the diff and run `bin/test-cpflow-github-flow`.")
|
|
70
118
|
Shell.info("If you run cpflow through Bundler, use `bin/test-cpflow-github-flow bundle exec cpflow`.")
|
|
71
119
|
end
|
|
72
120
|
|
|
73
121
|
def inferred_staging_branch
|
|
74
122
|
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
|
-
)
|
|
123
|
+
return if branches.sort == DEFAULT_STAGING_BRANCHES.sort
|
|
124
|
+
return branches.first if branches.length == 1 && valid_staging_branch?(branches.first)
|
|
82
125
|
|
|
83
|
-
|
|
126
|
+
Shell.abort("Cannot infer staging deployment branch. Pass --staging-branch BRANCH to explicitly set it.")
|
|
84
127
|
end
|
|
85
128
|
|
|
86
129
|
def existing_staging_branches
|
|
87
|
-
|
|
130
|
+
push = parsed_staging_workflow_on["push"]
|
|
131
|
+
branches = push.is_a?(Hash) ? push["branches"] : nil
|
|
132
|
+
branches.is_a?(Array) && branches.all?(String) ? branches : []
|
|
88
133
|
end
|
|
89
134
|
|
|
90
135
|
def parsed_staging_workflow_on
|
|
91
136
|
return {} unless STAGING_WORKFLOW_PATH.file?
|
|
92
137
|
|
|
93
138
|
workflow = YAML.load_file(STAGING_WORKFLOW_PATH, aliases: true)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
workflow_on = workflow["on"] || workflow[true]
|
|
139
|
+
workflow_on = workflow.is_a?(Hash) ? workflow["on"] || workflow[true] : nil
|
|
97
140
|
workflow_on.is_a?(Hash) ? workflow_on : {}
|
|
98
141
|
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."
|
|
142
|
+
Shell.abort(
|
|
143
|
+
"Could not parse #{STAGING_WORKFLOW_PATH}: #{e.message}. " \
|
|
144
|
+
"Pass --staging-branch BRANCH to explicitly replace its staging deployment branch."
|
|
107
145
|
)
|
|
108
146
|
end
|
|
109
147
|
end
|