cpflow 5.0.4 → 5.1.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/.github/workflows/cpflow-promote-staging-to-production.yml +48 -9
- data/CHANGELOG.md +14 -1
- data/Gemfile.lock +1 -1
- data/README.md +32 -11
- data/docs/ai-github-flow-prompt.md +1 -1
- data/docs/ci-automation.md +94 -45
- data/docs/commands.md +9 -3
- data/docs/postgres.md +6 -0
- data/docs/rds-private-networking.md +649 -0
- data/docs/secrets-and-env-values.md +49 -0
- data/docs/tips.md +256 -10
- data/examples/controlplane.yml +8 -0
- data/lib/command/ai_github_flow_prompt.rb +1 -1
- data/lib/command/apply_template.rb +3 -0
- data/lib/command/base.rb +69 -0
- data/lib/command/cleanup_stale_apps.rb +1 -1
- data/lib/command/delete.rb +85 -10
- data/lib/command/deploy_image.rb +30 -8
- data/lib/command/generate_github_actions.rb +6 -0
- data/lib/command/setup_app.rb +11 -2
- data/lib/core/config.rb +81 -0
- data/lib/core/controlplane.rb +15 -5
- data/lib/core/template_parser.rb +4 -0
- data/lib/cpflow/version.rb +1 -1
- data/lib/generator_templates/controlplane.yml +7 -0
- data/lib/generator_templates_sqlite/controlplane.yml +7 -0
- data/lib/github_flow_templates/.github/cpflow-help.md +35 -12
- data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +583 -15
- data/lib/github_flow_templates/bin/pin-cpflow-github-ref +17 -3
- data/lib/github_flow_templates/bin/test-cpflow-github-flow +61 -9
- metadata +3 -2
data/lib/command/deploy_image.rb
CHANGED
|
@@ -17,16 +17,32 @@ module Command
|
|
|
17
17
|
- The release script is run in the context of `cpflow run` with the latest image
|
|
18
18
|
- If the release script exits with a non-zero code, the command will stop executing and also exit with a non-zero code
|
|
19
19
|
- If `use_digest_image_ref` is `true` in the `.controlplane/controlplane.yml` file or `--use-digest-image-ref` option is provided, deployed image's reference will include its digest
|
|
20
|
+
- Repairs missing `shared_secret_grants` policy bindings before running a release phase or updating workloads
|
|
20
21
|
DESC
|
|
21
22
|
|
|
22
|
-
def call
|
|
23
|
-
|
|
23
|
+
def call
|
|
24
|
+
release_script = release_script_to_run
|
|
25
|
+
image = resolve_image_to_deploy
|
|
26
|
+
shared_secret_policy_grant_pairs = resolve_shared_secret_policy_grants
|
|
27
|
+
workload_data_by_name = app_workload_data
|
|
28
|
+
|
|
29
|
+
bind_shared_secret_policy_grants(shared_secret_policy_grant_pairs)
|
|
30
|
+
run_release_script(release_script) if release_script
|
|
31
|
+
deploy_image_to_workloads(image, workload_data_by_name)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def app_workload_data
|
|
37
|
+
config[:app_workloads].to_h do |workload|
|
|
38
|
+
[workload, cp.fetch_workload!(workload)]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
24
41
|
|
|
42
|
+
def deploy_image_to_workloads(image, workload_data_by_name) # rubocop:disable Metrics/MethodLength
|
|
25
43
|
deployed_endpoints = {}
|
|
26
|
-
image = resolve_image_to_deploy
|
|
27
44
|
|
|
28
|
-
|
|
29
|
-
workload_data = cp.fetch_workload!(workload)
|
|
45
|
+
workload_data_by_name.each do |workload, workload_data|
|
|
30
46
|
workload_data.dig("spec", "containers").each do |container|
|
|
31
47
|
next unless container["image"].match?(%r{^/org/#{config.org}/image/#{config.app}[:@]})
|
|
32
48
|
|
|
@@ -47,8 +63,6 @@ module Command
|
|
|
47
63
|
end
|
|
48
64
|
end
|
|
49
65
|
|
|
50
|
-
private
|
|
51
|
-
|
|
52
66
|
def resolve_image_to_deploy
|
|
53
67
|
image = cp.latest_image
|
|
54
68
|
# Preserve the pre-existing fail-fast check so missing images are reported
|
|
@@ -93,8 +107,16 @@ module Command
|
|
|
93
107
|
deployments.dig("items", 0, "status", "endpoint")
|
|
94
108
|
end
|
|
95
109
|
|
|
96
|
-
def
|
|
110
|
+
def release_script_to_run
|
|
111
|
+
return unless config.options[:run_release_phase]
|
|
112
|
+
|
|
97
113
|
release_script = config[:release_script]
|
|
114
|
+
return release_script if release_script.is_a?(String) && !release_script.strip.empty?
|
|
115
|
+
|
|
116
|
+
raise "release_script must be configured when --run-release-phase is provided."
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def run_release_script(release_script)
|
|
98
120
|
run_command_in_latest_image(release_script, title: "release script")
|
|
99
121
|
end
|
|
100
122
|
end
|
|
@@ -42,6 +42,7 @@ module Command
|
|
|
42
42
|
def template_variables
|
|
43
43
|
{
|
|
44
44
|
"__CPFLOW_GITHUB_ACTIONS_REF__" => cpflow_github_actions_ref,
|
|
45
|
+
"__CPFLOW_MINOR_SERIES__" => cpflow_minor_series,
|
|
45
46
|
"__STAGING_BRANCH_FILTER__" => staging_branch_filter,
|
|
46
47
|
"__STAGING_BRANCH_DEFAULT__" => staging_branch_default
|
|
47
48
|
}
|
|
@@ -78,6 +79,11 @@ module Command
|
|
|
78
79
|
def default_cpflow_github_actions_ref
|
|
79
80
|
"v#{::Cpflow::VERSION}"
|
|
80
81
|
end
|
|
82
|
+
|
|
83
|
+
# Returns e.g. "5.0.x" for the version-locking placeholder in cpflow-help.md.
|
|
84
|
+
def cpflow_minor_series
|
|
85
|
+
"#{::Cpflow::VERSION.split('.').first(2).join('.')}.x"
|
|
86
|
+
end
|
|
81
87
|
end
|
|
82
88
|
|
|
83
89
|
class GenerateGithubActions < Base
|
data/lib/command/setup_app.rb
CHANGED
|
@@ -17,6 +17,7 @@ module Command
|
|
|
17
17
|
- Configures app to have org-level secrets with default name `"{APP_PREFIX}-secrets"`
|
|
18
18
|
using org-level policy with default name `"{APP_PREFIX}-secrets-policy"` (names can be customized, see docs)
|
|
19
19
|
- Creates identity for secrets if it does not exist
|
|
20
|
+
- 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
|
|
20
21
|
- Use `--skip-secrets-setup` to prevent the automatic setup of secrets,
|
|
21
22
|
or set it through `skip_secrets_setup` in the `.controlplane/controlplane.yml` file
|
|
22
23
|
- Runs a post-creation hook after the app is created if `hooks.post_creation` is specified in the `.controlplane/controlplane.yml` file
|
|
@@ -35,9 +36,11 @@ module Command
|
|
|
35
36
|
"or run 'cpflow apply-template #{templates.join(' ')} -a #{config.app}'."
|
|
36
37
|
end
|
|
37
38
|
|
|
38
|
-
skip_secrets_setup =
|
|
39
|
-
config.options[:skip_secrets_setup] || config.current[:skip_secrets_setup]
|
|
39
|
+
skip_secrets_setup = skip_secrets_setup?
|
|
40
40
|
|
|
41
|
+
# Validate shared grants before app resource creation so config/policy
|
|
42
|
+
# drift does not leave a partially-created review app.
|
|
43
|
+
shared_secret_policy_grant_pairs = resolve_shared_secret_policy_grants unless skip_secrets_setup
|
|
41
44
|
create_secret_and_policy_if_not_exist unless skip_secrets_setup
|
|
42
45
|
|
|
43
46
|
args = []
|
|
@@ -45,11 +48,17 @@ module Command
|
|
|
45
48
|
run_cpflow_command("apply-template", *templates, "-a", config.app, *args)
|
|
46
49
|
|
|
47
50
|
bind_identity_to_policy unless skip_secrets_setup
|
|
51
|
+
bind_shared_secret_policy_grants(shared_secret_policy_grant_pairs) unless skip_secrets_setup
|
|
48
52
|
run_post_creation_hook unless config.options[:skip_post_creation_hook]
|
|
49
53
|
end
|
|
50
54
|
|
|
51
55
|
private
|
|
52
56
|
|
|
57
|
+
def skip_secrets_setup?
|
|
58
|
+
config.options[:skip_secret_access_binding] ||
|
|
59
|
+
config.options[:skip_secrets_setup] || config.current[:skip_secrets_setup]
|
|
60
|
+
end
|
|
61
|
+
|
|
53
62
|
def create_secret_and_policy_if_not_exist
|
|
54
63
|
create_secret_if_not_exists
|
|
55
64
|
create_policy_if_not_exists
|
data/lib/core/config.rb
CHANGED
|
@@ -10,6 +10,9 @@ class Config # rubocop:disable Metrics/ClassLength
|
|
|
10
10
|
include Helpers
|
|
11
11
|
|
|
12
12
|
CONFIG_FILE_LOCATION = ".controlplane/controlplane.yml"
|
|
13
|
+
REQUIRED_SHARED_SECRET_GRANT_KEYS = %i[name secret_name policy_name].freeze
|
|
14
|
+
SHARED_SECRET_RESOURCE_NAME_KEYS = %i[secret_name policy_name].freeze
|
|
15
|
+
CONTROL_PLANE_RESOURCE_NAME_REGEX = /\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/
|
|
13
16
|
|
|
14
17
|
def initialize(args, options, required_options)
|
|
15
18
|
@args = args
|
|
@@ -56,6 +59,16 @@ class Config # rubocop:disable Metrics/ClassLength
|
|
|
56
59
|
current&.dig(:secrets_policy_name) || "#{secrets}-policy"
|
|
57
60
|
end
|
|
58
61
|
|
|
62
|
+
def shared_secret_grants
|
|
63
|
+
@shared_secret_grants ||= normalize_shared_secret_grants(current&.dig(:shared_secret_grants))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def shared_secret_placeholders
|
|
67
|
+
shared_secret_grants.to_h do |grant|
|
|
68
|
+
["{{SHARED_SECRET_#{grant.fetch(:name).upcase}}}", grant.fetch(:secret_name)]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
59
72
|
def location
|
|
60
73
|
@location ||= load_location_from_options || load_location_from_env || load_location_from_file
|
|
61
74
|
end
|
|
@@ -171,6 +184,74 @@ class Config # rubocop:disable Metrics/ClassLength
|
|
|
171
184
|
raise "Can't find config for app '#{app_name}' in 'controlplane.yml'." unless app_options
|
|
172
185
|
end
|
|
173
186
|
|
|
187
|
+
def normalize_shared_secret_grants(grants)
|
|
188
|
+
return [] if grants.nil?
|
|
189
|
+
|
|
190
|
+
raise "shared_secret_grants for app config must be an array." unless grants.is_a?(Array)
|
|
191
|
+
|
|
192
|
+
normalized_grants = grants.map.with_index { |grant, index| normalize_shared_secret_grant(grant, index) }
|
|
193
|
+
ensure_unique_shared_secret_grant_names!(normalized_grants)
|
|
194
|
+
normalized_grants
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def normalize_shared_secret_grant(raw_grant, index)
|
|
198
|
+
ensure_shared_secret_grant_map!(raw_grant, index)
|
|
199
|
+
|
|
200
|
+
grant = raw_grant.transform_keys(&:to_sym)
|
|
201
|
+
label = grant[:name] || "##{index + 1}"
|
|
202
|
+
ensure_shared_secret_grant_keys!(grant, label)
|
|
203
|
+
ensure_shared_secret_resource_names!(grant, label)
|
|
204
|
+
build_shared_secret_grant(grant)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def build_shared_secret_grant(grant)
|
|
208
|
+
name = grant.fetch(:name).to_s
|
|
209
|
+
ensure_shared_secret_grant_name!(name)
|
|
210
|
+
{
|
|
211
|
+
name: name,
|
|
212
|
+
secret_name: grant.fetch(:secret_name).to_s,
|
|
213
|
+
policy_name: grant.fetch(:policy_name).to_s
|
|
214
|
+
}
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def ensure_shared_secret_grant_map!(raw_grant, index)
|
|
218
|
+
return if raw_grant.is_a?(Hash)
|
|
219
|
+
|
|
220
|
+
raise "shared_secret_grants entry ##{index + 1} must be a map."
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def ensure_shared_secret_grant_keys!(grant, label)
|
|
224
|
+
REQUIRED_SHARED_SECRET_GRANT_KEYS.each do |key|
|
|
225
|
+
value = grant[key]
|
|
226
|
+
raise "shared_secret_grants entry '#{label}' must include #{key}." if value.nil? || value.to_s.empty?
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def ensure_shared_secret_grant_name!(name)
|
|
231
|
+
return if name.match?(/\A[a-z](?:[a-z0-9_]*[a-z0-9])?\z/)
|
|
232
|
+
|
|
233
|
+
raise "shared_secret_grants entry name '#{name}' must be lower snake case."
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def ensure_shared_secret_resource_names!(grant, label)
|
|
237
|
+
SHARED_SECRET_RESOURCE_NAME_KEYS.each do |key|
|
|
238
|
+
value = grant.fetch(key).to_s
|
|
239
|
+
next if value.match?(CONTROL_PLANE_RESOURCE_NAME_REGEX)
|
|
240
|
+
|
|
241
|
+
raise "shared_secret_grants entry '#{label}' #{key} '#{value}' must be a Control Plane resource name."
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def ensure_unique_shared_secret_grant_names!(grants)
|
|
246
|
+
seen_names = {}
|
|
247
|
+
grants.each do |grant|
|
|
248
|
+
name = grant.fetch(:name)
|
|
249
|
+
raise "shared_secret_grants entry name '#{name}' must be unique." if seen_names[name]
|
|
250
|
+
|
|
251
|
+
seen_names[name] = true
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
174
255
|
def ensure_app!
|
|
175
256
|
return if app
|
|
176
257
|
|
data/lib/core/controlplane.rb
CHANGED
|
@@ -396,13 +396,23 @@ class Controlplane # rubocop:disable Metrics/ClassLength
|
|
|
396
396
|
end
|
|
397
397
|
|
|
398
398
|
def bind_identity_to_policy(identity_link, policy)
|
|
399
|
-
cmd =
|
|
400
|
-
|
|
399
|
+
cmd = [
|
|
400
|
+
"cpln", "policy", "add-binding", policy,
|
|
401
|
+
"--org", org,
|
|
402
|
+
"--identity", identity_link,
|
|
403
|
+
"--permission", "reveal"
|
|
404
|
+
]
|
|
405
|
+
perform!(Shellwords.join(cmd))
|
|
401
406
|
end
|
|
402
407
|
|
|
403
|
-
def unbind_identity_from_policy(identity_link, policy)
|
|
404
|
-
cmd =
|
|
405
|
-
|
|
408
|
+
def unbind_identity_from_policy(identity_link, policy, permission: "reveal")
|
|
409
|
+
cmd = [
|
|
410
|
+
"cpln", "policy", "remove-binding", policy,
|
|
411
|
+
"--org", org,
|
|
412
|
+
"--identity", identity_link,
|
|
413
|
+
"--permission", permission
|
|
414
|
+
]
|
|
415
|
+
perform!(Shellwords.join(cmd))
|
|
406
416
|
end
|
|
407
417
|
|
|
408
418
|
# apply
|
data/lib/core/template_parser.rb
CHANGED
|
@@ -49,6 +49,10 @@ class TemplateParser
|
|
|
49
49
|
.gsub("{{APP_SECRETS}}", config.secrets)
|
|
50
50
|
.gsub("{{APP_SECRETS_POLICY}}", config.secrets_policy)
|
|
51
51
|
|
|
52
|
+
config.shared_secret_placeholders.each do |placeholder, secret_name|
|
|
53
|
+
yaml_file = yaml_file.gsub(placeholder, secret_name)
|
|
54
|
+
end
|
|
55
|
+
|
|
52
56
|
find_deprecated_variables(yaml_file)
|
|
53
57
|
|
|
54
58
|
# Kept for backwards compatibility
|
data/lib/cpflow/version.rb
CHANGED
|
@@ -41,6 +41,13 @@ apps:
|
|
|
41
41
|
__APP_PREFIX__-review:
|
|
42
42
|
<<: *common
|
|
43
43
|
match_if_app_name_starts_with: true
|
|
44
|
+
# To save review-app database cost, create one shared staging database
|
|
45
|
+
# secret and policy, then uncomment this block and use
|
|
46
|
+
# {{SHARED_SECRET_DATABASE}} in templates that need DATABASE_URL.
|
|
47
|
+
# shared_secret_grants:
|
|
48
|
+
# - name: database
|
|
49
|
+
# secret_name: __APP_PREFIX__-review-database-secrets
|
|
50
|
+
# policy_name: __APP_PREFIX__-review-database-secrets-policy
|
|
44
51
|
# Uncomment to automatically initialize and tear down review-app databases:
|
|
45
52
|
# hooks:
|
|
46
53
|
# post_creation: bundle exec rails db:prepare
|
|
@@ -41,6 +41,13 @@ apps:
|
|
|
41
41
|
__APP_PREFIX__-review:
|
|
42
42
|
<<: *common
|
|
43
43
|
match_if_app_name_starts_with: true
|
|
44
|
+
# Optional: if a review app needs an existing shared org-level secret,
|
|
45
|
+
# declare its policy here and reference it in templates with
|
|
46
|
+
# {{SHARED_SECRET_<NAME>}}.
|
|
47
|
+
# shared_secret_grants:
|
|
48
|
+
# - name: database
|
|
49
|
+
# secret_name: __APP_PREFIX__-review-database-secrets
|
|
50
|
+
# policy_name: __APP_PREFIX__-review-database-secrets-policy
|
|
44
51
|
|
|
45
52
|
__APP_PREFIX__-production:
|
|
46
53
|
<<: *production
|
|
@@ -66,27 +66,50 @@ Production promotion is part of the generated flow, but keep it protected:
|
|
|
66
66
|
| `PRODUCTION_APP_NAME` | Prefer `production` Environment variable | Production app name from `controlplane.yml`. |
|
|
67
67
|
|
|
68
68
|
Configure the `production` GitHub Environment with required reviewers and
|
|
69
|
-
prevent self-review.
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
prevent self-review. Production promotion intentionally runs as a normal
|
|
70
|
+
caller-repo workflow job with `environment: production`, then checks out the
|
|
71
|
+
pinned `control-plane-flow` release for shared actions. Do not move production
|
|
72
|
+
promotion behind a cross-repo reusable workflow: GitHub does not expose this
|
|
73
|
+
repo's environment secrets to that called workflow.
|
|
74
|
+
|
|
75
|
+
Keep `CPLN_TOKEN_PRODUCTION` absent from repository and organization secrets. A
|
|
76
|
+
normal environment-gated job cannot tell which secret scope supplied a nonempty
|
|
77
|
+
value, so a broader secret with the same name can mask a missing environment
|
|
78
|
+
secret.
|
|
79
|
+
|
|
80
|
+
If the promotion workflow fails with
|
|
81
|
+
`CPLN_TOKEN_PRODUCTION is not set. Add it as a secret on the 'production' GitHub Environment.`,
|
|
82
|
+
the token is missing from the environment scope or the workflow job is no longer
|
|
83
|
+
declaring `environment: production`. Create or verify the environment secret
|
|
84
|
+
and confirm there is no same-named repository or organization secret:
|
|
85
|
+
You need permission to manage repository environments and secrets to run these
|
|
86
|
+
commands.
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
gh secret set CPLN_TOKEN_PRODUCTION --repo OWNER/REPO --env production
|
|
90
|
+
# Paste the token value when prompted.
|
|
91
|
+
gh secret list --repo OWNER/REPO --env production
|
|
92
|
+
gh secret list --repo OWNER/REPO
|
|
93
|
+
gh secret list --org OWNER | grep '^CPLN_TOKEN_PRODUCTION[[:space:]]' || true
|
|
94
|
+
```
|
|
72
95
|
|
|
73
96
|
Before the first promotion, bootstrap the production app the same way in the
|
|
74
97
|
production org, using production-only secrets and values.
|
|
75
98
|
|
|
76
99
|
## Version Locking
|
|
77
100
|
|
|
78
|
-
Generated wrappers pin Control Plane Flow
|
|
79
|
-
`
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
the same ref, regenerate them with a newer `cpflow`.
|
|
101
|
+
Generated wrappers pin Control Plane Flow with a release tag, for example
|
|
102
|
+
`__CPFLOW_GITHUB_ACTIONS_REF__`. Reusable review-app, staging, cleanup, and
|
|
103
|
+
helper workflows pin the tag in their `uses:` ref. Production promotion pins
|
|
104
|
+
the same tag in the `Checkout control-plane-flow actions` step so the
|
|
105
|
+
caller-owned job can keep `environment: production` and receive production
|
|
106
|
+
environment secrets directly.
|
|
85
107
|
|
|
86
108
|
Leave `CPFLOW_VERSION` unset so the workflow builds cpflow from the same
|
|
87
109
|
checked-out upstream source. If you set `CPFLOW_VERSION`, it must match the
|
|
88
|
-
release tag
|
|
89
|
-
`uses: ...@
|
|
110
|
+
release tag your wrappers are pinned to: a `CPFLOW_VERSION=__CPFLOW_MINOR_SERIES__` runtime
|
|
111
|
+
override goes with a wrapper pinned to `uses: ...@v__CPFLOW_MINOR_SERIES__` (substitute the
|
|
112
|
+
release you pinned above).
|
|
90
113
|
|
|
91
114
|
After updating the `cpflow` gem in this repo, update the generated wrappers in
|
|
92
115
|
the same PR:
|