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.
@@ -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 # rubocop:disable Metrics/MethodLength
23
- run_release_script if config.options[:run_release_phase]
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
- config[:app_workloads].each do |workload|
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 run_release_script
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
@@ -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 = config.options[:skip_secret_access_binding] ||
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
 
@@ -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 = "cpln policy add-binding #{policy} --org #{org} --identity #{identity_link} --permission reveal"
400
- perform!(cmd)
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 = "cpln policy remove-binding #{policy} --org #{org} --identity #{identity_link} --permission reveal"
405
- perform!(cmd)
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
@@ -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
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cpflow
4
- VERSION = "5.0.4"
4
+ VERSION = "5.1.0"
5
5
  MIN_CPLN_VERSION = "3.1.0"
6
6
  end
@@ -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. The generated promotion wrapper passes only the staging
70
- token from repository secrets; GitHub injects `CPLN_TOKEN_PRODUCTION` only after
71
- the environment approval gate passes.
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 once with the reusable workflow
79
- `uses:` ref, for example `@__CPFLOW_GITHUB_ACTIONS_REF__`. For stable releases,
80
- this ref should be a release tag. The upstream reusable workflow automatically
81
- loads its matching shared actions from GitHub's workflow context, so downstream
82
- wrappers should not pass a duplicate Control Plane Flow ref input. If your
83
- generated wrappers still include a `with:` block whose only purpose is to repeat
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, for example `CPFLOW_VERSION=5.0.1` with a wrapper pinned to
89
- `uses: ...@v5.0.1`.
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: