cpflow 5.1.1 → 5.3.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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/agent-workflow.yml +26 -0
  3. data/.agents/bin/README.md +20 -0
  4. data/.agents/bin/docs +5 -0
  5. data/.agents/bin/lint +5 -0
  6. data/.agents/bin/setup +5 -0
  7. data/.agents/bin/test +5 -0
  8. data/.agents/bin/validate +5 -0
  9. data/.agents/trusted-github-actors.yml +32 -0
  10. data/.agents/workflows/ai-rollout-e2e-test.md +166 -0
  11. data/.github/actions/cpflow-setup-environment/action.yml +1 -1
  12. data/.github/actions/cpflow-wait-for-health/action.yml +87 -15
  13. data/.github/pull_request_template.md +18 -0
  14. data/.github/workflows/claude-code-review.yml +2 -0
  15. data/.github/workflows/claude.yml +94 -1
  16. data/.github/workflows/cpflow-delete-review-app.yml +621 -33
  17. data/.github/workflows/cpflow-deploy-review-app.yml +656 -21
  18. data/.github/workflows/cpflow-review-app-help.yml +5 -13
  19. data/.github/workflows/rspec-shared.yml +10 -3
  20. data/.github/workflows/rspec-specific.yml +1 -0
  21. data/.github/workflows/rspec.yml +58 -1
  22. data/AGENTS.md +14 -0
  23. data/CHANGELOG.md +54 -1
  24. data/CLAUDE.md +3 -0
  25. data/CONTRIBUTING.md +15 -3
  26. data/Gemfile.lock +1 -1
  27. data/README.md +21 -7
  28. data/docs/ai-github-flow-prompt.md +18 -16
  29. data/docs/ci-automation.md +239 -27
  30. data/docs/commands.md +30 -2
  31. data/docs/grafana-opentelemetry.md +699 -0
  32. data/docs/secrets-and-env-values.md +37 -2
  33. data/docs/sidebars.ts +70 -0
  34. data/docs/telemetry/application-instrumentation.md +161 -0
  35. data/docs/telemetry/collector.md +297 -0
  36. data/docs/telemetry/index.md +152 -0
  37. data/docs/telemetry/pipelines.md +98 -0
  38. data/docs/telemetry/review-apps.md +55 -0
  39. data/docs/telemetry/troubleshooting.md +92 -0
  40. data/docs/terraform/example/.controlplane/controlplane.yml +0 -1
  41. data/docs/terraform/overview.md +11 -0
  42. data/docs/tips.md +475 -28
  43. data/examples/controlplane.yml +2 -0
  44. data/lib/command/ai_github_flow_prompt.rb +2 -2
  45. data/lib/command/apply_template.rb +104 -2
  46. data/lib/command/base.rb +69 -5
  47. data/lib/command/deploy_image.rb +93 -7
  48. data/lib/command/promote_app_from_upstream.rb +1 -0
  49. data/lib/command/ps_wait.rb +2 -10
  50. data/lib/command/run.rb +133 -10
  51. data/lib/command/setup_app.rb +10 -5
  52. data/lib/core/config.rb +94 -0
  53. data/lib/core/controlplane.rb +38 -5
  54. data/lib/core/controlplane_api.rb +8 -0
  55. data/lib/core/controlplane_api_direct.rb +257 -63
  56. data/lib/core/doctor_service.rb +44 -3
  57. data/lib/core/shell.rb +9 -2
  58. data/lib/core/template_parser.rb +43 -9
  59. data/lib/cpflow/version.rb +1 -1
  60. data/lib/generator_templates/controlplane.yml +1 -2
  61. data/lib/github_flow_templates/.github/cpflow-help.md +34 -10
  62. data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +10 -0
  63. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +9 -0
  64. metadata +22 -2
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Command
4
- class SetupApp < Base
4
+ class SetupApp < Base # rubocop:disable Metrics/ClassLength
5
5
  NAME = "setup-app"
6
6
  OPTIONS = [
7
7
  app_option(required: true),
8
8
  skip_secret_access_binding_option,
9
9
  skip_secrets_setup_option,
10
- skip_post_creation_hook_option
10
+ skip_post_creation_hook_option,
11
+ refresh_templates_option
11
12
  ].freeze
12
13
  DESCRIPTION = "Creates an app and all its workloads"
13
14
  LONG_DESCRIPTION = <<~DESC
@@ -23,18 +24,21 @@ module Command
23
24
  - Runs a post-creation hook after the app is created if `hooks.post_creation` is specified in the `.controlplane/controlplane.yml` file
24
25
  - If the hook exits with a non-zero code, the command will stop executing and also exit with a non-zero code
25
26
  - 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 resources entirely, repairing secrets access bindings, and skipping the post-creation hook
26
28
  DESC
27
29
  VALIDATIONS = %w[config templates].freeze
28
30
 
29
- def call # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
31
+ def call # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
30
32
  templates = config[:setup_app_templates]
33
+ refresh_templates = config.options[:refresh_templates]
31
34
 
32
35
  app = cp.fetch_gvc
33
- if app
36
+ if app && !refresh_templates
34
37
  raise "App '#{config.app}' already exists. If you want to update this app, " \
35
38
  "either run 'cpflow delete -a #{config.app}' and then re-run this command, " \
36
39
  "or run 'cpflow apply-template #{templates.join(' ')} -a #{config.app}'."
37
40
  end
41
+ raise "App '#{config.app}' does not exist, so its templates cannot be refreshed." if !app && refresh_templates
38
42
 
39
43
  skip_secrets_setup = skip_secrets_setup?
40
44
 
@@ -45,11 +49,12 @@ module Command
45
49
 
46
50
  args = []
47
51
  args.push("--add-app-identity") unless skip_secrets_setup
52
+ args.push("--yes", "--preserve-existing-runtime") if refresh_templates
48
53
  run_cpflow_command("apply-template", *templates, "-a", config.app, *args)
49
54
 
50
55
  bind_identity_to_policy unless skip_secrets_setup
51
56
  bind_shared_secret_policy_grants(shared_secret_policy_grant_pairs) unless skip_secrets_setup
52
- run_post_creation_hook unless config.options[:skip_post_creation_hook]
57
+ run_post_creation_hook unless refresh_templates || config.options[:skip_post_creation_hook]
53
58
  end
54
59
 
55
60
  private
data/lib/core/config.rb CHANGED
@@ -166,6 +166,28 @@ class Config # rubocop:disable Metrics/ClassLength
166
166
  current&.dig(:use_digest_image_ref) == true
167
167
  end
168
168
 
169
+ def deploy_order
170
+ return nil unless current&.key?(:deploy_order)
171
+
172
+ @deploy_order ||= normalize_deploy_order(
173
+ current.fetch(:deploy_order),
174
+ app_workloads_for_deploy_order!(current, app),
175
+ app
176
+ )
177
+ end
178
+
179
+ def validate_deploy_orders!
180
+ apps.each do |app_name, app_options|
181
+ next unless app_options.key?(:deploy_order)
182
+
183
+ normalize_deploy_order(
184
+ app_options.fetch(:deploy_order),
185
+ app_workloads_for_deploy_order!(app_options, app_name),
186
+ app_name
187
+ )
188
+ end
189
+ end
190
+
169
191
  private
170
192
 
171
193
  def ensure_current_config!
@@ -252,6 +274,78 @@ class Config # rubocop:disable Metrics/ClassLength
252
274
  end
253
275
  end
254
276
 
277
+ def app_workloads_for_deploy_order!(app_options, app_name)
278
+ app_workloads = app_options[:app_workloads]
279
+ return app_workloads if app_workloads.is_a?(Array)
280
+
281
+ raise "deploy_order for app '#{app_name}' requires app_workloads to be an array."
282
+ end
283
+
284
+ def normalize_deploy_order(raw_deploy_order, app_workloads, app_name)
285
+ ensure_deploy_order_array!(raw_deploy_order, app_name)
286
+ context = {
287
+ app_workload_names: app_workloads.map(&:to_s),
288
+ seen_workloads: {},
289
+ app_name: app_name
290
+ }
291
+
292
+ raw_deploy_order.map.with_index do |group, index|
293
+ normalize_deploy_order_group(group, index, context)
294
+ end
295
+ end
296
+
297
+ def ensure_deploy_order_array!(raw_deploy_order, app_name)
298
+ raise "deploy_order for app '#{app_name}' must be an array of workload groups." unless raw_deploy_order.is_a?(Array)
299
+ return unless raw_deploy_order.empty?
300
+
301
+ raise "deploy_order for app '#{app_name}' must include at least one workload group."
302
+ end
303
+
304
+ def normalize_deploy_order_group(group, index, context)
305
+ group_number = index + 1
306
+ ensure_deploy_order_group_array!(group, group_number, context.fetch(:app_name))
307
+
308
+ group.map.with_index do |workload, workload_index|
309
+ normalize_deploy_order_workload(workload, group_number, workload_index, context)
310
+ end
311
+ end
312
+
313
+ def ensure_deploy_order_group_array!(group, group_number, app_name)
314
+ unless group.is_a?(Array)
315
+ raise "deploy_order group ##{group_number} for app '#{app_name}' must be an array of workload names."
316
+ end
317
+ return unless group.empty?
318
+
319
+ raise "deploy_order group ##{group_number} for app '#{app_name}' must include at least one workload."
320
+ end
321
+
322
+ def normalize_deploy_order_workload(workload, group_number, workload_index, context)
323
+ app_name = context.fetch(:app_name)
324
+ unless workload.is_a?(String) && !workload.strip.empty?
325
+ raise "deploy_order group ##{group_number} entry ##{workload_index + 1} " \
326
+ "for app '#{app_name}' must be a workload name."
327
+ end
328
+
329
+ workload_name = workload.strip
330
+ ensure_deploy_order_workload_configured!(workload_name, context.fetch(:app_workload_names), app_name)
331
+ ensure_deploy_order_workload_unique!(workload_name, context.fetch(:seen_workloads), app_name)
332
+ workload_name
333
+ end
334
+
335
+ def ensure_deploy_order_workload_configured!(workload_name, app_workload_names, app_name)
336
+ return if app_workload_names.include?(workload_name)
337
+
338
+ raise "deploy_order workload '#{workload_name}' must be listed in app_workloads for app '#{app_name}'."
339
+ end
340
+
341
+ def ensure_deploy_order_workload_unique!(workload_name, seen_workloads, app_name)
342
+ if seen_workloads[workload_name]
343
+ raise "deploy_order workload '#{workload_name}' must appear only once for app '#{app_name}'."
344
+ end
345
+
346
+ seen_workloads[workload_name] = true
347
+ end
348
+
255
349
  def ensure_app!
256
350
  return if app
257
351
 
@@ -177,6 +177,28 @@ class Controlplane # rubocop:disable Metrics/ClassLength
177
177
  api.workload_get(workload: workload, gvc: gvc, org: org)
178
178
  end
179
179
 
180
+ def fetch_workload_with_status(workload)
181
+ result = workload_status_result(workload)
182
+
183
+ unless result[:success]
184
+ Shell.warn("Failed to fetch status for '#{workload}': #{result[:error_output].to_s.strip}")
185
+ return
186
+ end
187
+
188
+ JSON.parse(result[:output])
189
+ rescue JSON::ParserError => e
190
+ Shell.warn("Failed to parse status for '#{workload}': #{e.message}")
191
+ nil
192
+ end
193
+
194
+ def workload_status_result(workload)
195
+ # The direct API response omits computed fields such as status.readyLatest.
196
+ args = ["cpln", "workload", "get", workload, "--gvc", gvc, "--org", org, "-o", "json"]
197
+ Shell.debug("CMD", Shellwords.join(args))
198
+ Shell.cmd(*args, separate_stderr: true)
199
+ end
200
+ private :workload_status_result
201
+
180
202
  def fetch_workload!(workload)
181
203
  workload_data = fetch_workload(workload)
182
204
  return workload_data if workload_data
@@ -279,9 +301,14 @@ class Controlplane # rubocop:disable Metrics/ClassLength
279
301
  end
280
302
 
281
303
  def workload_exec(workload, replica, location:, container: nil, command: nil)
282
- cmd = "cpln workload exec #{workload} #{gvc_org} --replica #{replica} --location #{location} -it"
283
- cmd += " --container #{container}" if container
284
- cmd += " -- #{command}"
304
+ cmd = [
305
+ "cpln", "workload", "exec", workload,
306
+ "--gvc", gvc, "--org", org,
307
+ "--replica", replica, "--location", location, "-it"
308
+ ]
309
+ cmd.push("--container", container) if container
310
+ cmd << "--"
311
+ cmd.concat(Array(command))
285
312
  perform(cmd, output_mode: :all)
286
313
  end
287
314
 
@@ -383,6 +410,10 @@ class Controlplane # rubocop:disable Metrics/ClassLength
383
410
  api.fetch_secret(org: org, secret: secret)
384
411
  end
385
412
 
413
+ def reveal_secret(secret)
414
+ api.reveal_secret(org: org, secret: secret)
415
+ end
416
+
386
417
  # identities
387
418
 
388
419
  def fetch_identity(identity, a_gvc = gvc)
@@ -509,6 +540,7 @@ class Controlplane # rubocop:disable Metrics/ClassLength
509
540
  # or the return value of `Shell.should_hide_output?`.
510
541
  def build_command(cmd, output_mode: nil) # rubocop:disable Metrics/MethodLength
511
542
  output_mode ||= determine_command_output_mode
543
+ raise "Array commands require output mode 'all'." if cmd.is_a?(Array) && %i[errors_only none].include?(output_mode)
512
544
 
513
545
  case output_mode
514
546
  when :all
@@ -535,7 +567,8 @@ class Controlplane # rubocop:disable Metrics/ClassLength
535
567
  def perform(cmd, output_mode: nil, sensitive_data_pattern: nil)
536
568
  cmd = build_command(cmd, output_mode: output_mode)
537
569
 
538
- Shell.debug("CMD", cmd, sensitive_data_pattern: sensitive_data_pattern)
570
+ debug_cmd = cmd.is_a?(Array) ? Shellwords.join(cmd) : cmd
571
+ Shell.debug("CMD", debug_cmd, sensitive_data_pattern: sensitive_data_pattern)
539
572
 
540
573
  kernel_system_with_pid_handling(cmd)
541
574
  end
@@ -544,7 +577,7 @@ class Controlplane # rubocop:disable Metrics/ClassLength
544
577
  # Returns true on zero exit, false on non-zero exit, nil when the process was signal-killed.
545
578
  # SystemCallError (e.g. cpln binary missing) propagates — startup checks ensure this is unreachable in practice.
546
579
  def kernel_system_with_pid_handling(cmd)
547
- pid = Process.spawn(cmd)
580
+ pid = cmd.is_a?(Array) ? Process.spawn(*cmd) : Process.spawn(cmd)
548
581
  $child_pids << pid # rubocop:disable Style/GlobalVars
549
582
 
550
583
  _, status = Process.wait2(pid)
@@ -122,6 +122,14 @@ class ControlplaneApi # rubocop:disable Metrics/ClassLength
122
122
  api_json("/org/#{org}/secret/#{secret}", method: :get)
123
123
  end
124
124
 
125
+ def reveal_secret(org:, secret:)
126
+ api_json(
127
+ "/org/#{org}/secret/#{secret}/-reveal",
128
+ method: :get,
129
+ request_policy: ControlplaneApiDirect::BEST_EFFORT_SENSITIVE_REQUEST_POLICY
130
+ )
131
+ end
132
+
125
133
  def delete_secret(org:, secret:)
126
134
  api_json("/org/#{org}/secret/#{secret}", method: :delete)
127
135
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class ControlplaneApiDirect
3
+ class ControlplaneApiDirect # rubocop:disable Metrics/ClassLength
4
4
  class RedactedDebugOutput
5
5
  SAFE_HEADERS = %w[Content-Type Content-Length Accept Host Date Cache-Control Connection].freeze
6
6
  HEADER_REGEX = /^([A-Za-z-]+): (.+)$/
@@ -40,6 +40,9 @@ class ControlplaneApiDirect
40
40
  end
41
41
  end
42
42
 
43
+ # Raised when fetching the API token via `cpln profile token` fails.
44
+ class TokenRefreshError < StandardError; end
45
+
43
46
  API_METHODS = {
44
47
  get: Net::HTTP::Get,
45
48
  patch: Net::HTTP::Patch,
@@ -51,40 +54,200 @@ class ControlplaneApiDirect
51
54
 
52
55
  API_TOKEN_EXPIRY_SECONDS = 300
53
56
 
54
- class << self
55
- attr_accessor :trace
57
+ # GET alone retries 5xx responses or transport failures after the request may
58
+ # have reached the server. Every method may retry an explicit 429 response.
59
+ # DELETE stays out of this set because after other errors the client cannot
60
+ # know whether deletion applied server-side.
61
+ IDEMPOTENT_METHODS = %i[get].freeze
62
+
63
+ # Bounded so a retried connect failure fits within the retry deadline.
64
+ OPEN_TIMEOUT_SECONDS = 10
65
+ BEST_EFFORT_TIMEOUT_SECONDS = 5
66
+ RequestPolicy = Struct.new(:sensitive, :retry_transient, :timeout, keyword_init: true) do
67
+ def initialize(sensitive:, retry_transient:, timeout:)
68
+ super
69
+ freeze
70
+ end
56
71
  end
72
+ DEFAULT_REQUEST_POLICY = RequestPolicy.new(sensitive: false, retry_transient: true, timeout: nil)
73
+ BEST_EFFORT_SENSITIVE_REQUEST_POLICY = RequestPolicy.new(
74
+ sensitive: true,
75
+ retry_transient: false,
76
+ timeout: BEST_EFFORT_TIMEOUT_SECONDS
77
+ )
78
+
79
+ # Thread-safe API token cache. A single instance is shared process-wide by
80
+ # default (see `default_token_provider`) so each `ControlplaneApiDirect.new`
81
+ # per API call reuses the cached token; tests inject a fresh instance.
82
+ class ApiToken
83
+ def initialize
84
+ @mutex = Mutex.new
85
+ @data = nil
86
+ end
57
87
 
58
- def call(url, method:, host: :api, body: nil) # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
59
- trace = ControlplaneApiDirect.trace
60
- uri = URI("#{api_host(host)}#{url}")
61
- request = API_METHODS[method].new(uri)
62
- request["Content-Type"] = "application/json"
88
+ def fetch
89
+ @mutex.synchronize do
90
+ @data = load_token if @data.nil?
91
+ refresh! if expiring_soon?
92
+ @data
93
+ end
94
+ end
63
95
 
64
- refresh_api_token if should_refresh_api_token?
96
+ def reset
97
+ @mutex.synchronize { @data = nil }
98
+ end
65
99
 
66
- request["Authorization"] = authorization_header
67
- request.body = body.to_json if body
100
+ private
68
101
 
69
- Shell.debug(method.upcase, "#{uri} #{body&.to_json}")
102
+ def load_token
103
+ token = ENV.fetch("CPLN_TOKEN", nil)
104
+ return validate!(token: token, comes_from_profile: false) if token
70
105
 
71
- http = Net::HTTP.new(uri.hostname, uri.port)
72
- http.use_ssl = uri.scheme == "https"
73
- http.set_debug_output(RedactedDebugOutput.new) if trace
106
+ validate!(token: fetch_profile_token, comes_from_profile: true)
107
+ end
74
108
 
75
- response = http.start { |ht| ht.request(request) }
109
+ def refresh!
110
+ @data = validate!(token: fetch_profile_token, comes_from_profile: true)
111
+ end
76
112
 
77
- case response
78
- when Net::HTTPOK
79
- JSON.parse(response.body)
80
- when Net::HTTPAccepted
113
+ def fetch_profile_token
114
+ result = Shell.cmd("cpln", "profile", "token")
115
+ unless result[:success]
116
+ raise TokenRefreshError,
117
+ "Failed to fetch the API token via 'cpln profile token'. " \
118
+ "Please re-run 'cpln profile login' or set the CPLN_TOKEN env variable."
119
+ end
120
+
121
+ result[:output].chomp
122
+ end
123
+
124
+ def validate!(data)
125
+ token = data[:token]
126
+ # Allow any token that does not contain line breaks. Scoped service-account
127
+ # tokens include punctuation such as '/', '+', ':', and '=', so format
128
+ # validation is deferred to the Control Plane API.
129
+ return data if token && !token.empty? && !token.match?(/[\r\n]/)
130
+
131
+ raise "Unknown API token format. " \
132
+ "Please re-run 'cpln profile login' or set the correct CPLN_TOKEN env variable."
133
+ end
134
+
135
+ # Returns `true` when a profile-sourced JWT expires within 5 minutes.
136
+ def expiring_soon?
137
+ return false unless @data[:comes_from_profile]
138
+
139
+ payload, = JWT.decode(@data[:token], nil, false, algorithms: [])
140
+ return false unless payload.is_a?(Hash) && payload["exp"]
141
+
142
+ payload["exp"].to_i - Time.now.to_i <= API_TOKEN_EXPIRY_SECONDS
143
+ rescue JWT::DecodeError
144
+ false
145
+ end
146
+ end
147
+
148
+ # Retry policy for transient failures: tracks attempts, enforces the attempt
149
+ # and elapsed-time caps, and sleeps between tries (capped `Retry-After` when
150
+ # the server provides one, exponential backoff with jitter otherwise).
151
+ # The deadline is checked only before starting a new attempt: no new attempt
152
+ # begins after `MAX_TOTAL_RETRY_SECONDS`, but an in-flight attempt may run
153
+ # past it (bounded by the per-attempt open/read timeouts).
154
+ class Retrier
155
+ MAX_ATTEMPTS = 3
156
+ MAX_TOTAL_RETRY_SECONDS = 120
157
+ BASE_RETRY_DELAY_SECONDS = 0.5
158
+ MAX_RETRY_DELAY_SECONDS = 10
159
+ TRANSIENT_NETWORK_ERRORS = [
160
+ Net::OpenTimeout, Net::ReadTimeout,
161
+ Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::EPIPE, Errno::ETIMEDOUT,
162
+ EOFError, OpenSSL::SSL::SSLError, SocketError
163
+ ].freeze
164
+
165
+ def initialize(sleeper:)
166
+ @sleeper = sleeper
167
+ @attempts_made = 1
168
+ @deadline = Retrier.monotonic_time + MAX_TOTAL_RETRY_SECONDS
169
+ end
170
+
171
+ def self.monotonic_time
172
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
173
+ end
174
+
175
+ # Retries 429 responses for every method, but limits 5xx retries to
176
+ # idempotent requests.
177
+ def retry_response?(response, idempotent)
178
+ retriable = response.is_a?(Net::HTTPTooManyRequests) ||
179
+ (idempotent && response.is_a?(Net::HTTPServerError))
180
+ return false unless retriable
181
+
182
+ retry?(retry_after: response["Retry-After"])
183
+ end
184
+
185
+ # Whether to retry a transient network error. Non-idempotent requests only
186
+ # retry when the failure happened before the request may have hit the wire.
187
+ def retry_exception?(idempotent, request_sent)
188
+ return false if request_sent && !idempotent
189
+
190
+ retry?
191
+ end
192
+
193
+ private
194
+
195
+ # When another attempt is allowed, sleeps through the delay and returns `true`.
196
+ def retry?(retry_after: nil)
197
+ return false if @attempts_made >= MAX_ATTEMPTS || Retrier.monotonic_time >= @deadline
198
+
199
+ @sleeper.call(next_delay(retry_after))
200
+ @attempts_made += 1
81
201
  true
82
- when Net::HTTPNotFound
83
- nil
84
- when Net::HTTPForbidden
85
- raise ForbiddenError.new(url: url, response: response)
86
- else
87
- raise("#{response} #{response.body}")
202
+ end
203
+
204
+ def next_delay(retry_after)
205
+ seconds = parse_retry_after(retry_after)
206
+ return seconds if seconds
207
+
208
+ backoff = [BASE_RETRY_DELAY_SECONDS * (2**(@attempts_made - 1)), MAX_RETRY_DELAY_SECONDS].min
209
+ (backoff / 2) + (rand * backoff / 2)
210
+ end
211
+
212
+ # Supports the delta-seconds form of `Retry-After`, capped so a slow or
213
+ # misconfigured server cannot stall the CLI.
214
+ def parse_retry_after(value)
215
+ return nil unless value.to_s.match?(/\A\d+\z/)
216
+
217
+ [Integer(value, 10), MAX_RETRY_DELAY_SECONDS].min
218
+ end
219
+ end
220
+
221
+ class << self
222
+ attr_accessor :trace
223
+ attr_reader :default_token_provider
224
+
225
+ # Preserved seam: `Controlplane#profile_switch` invalidates the cached
226
+ # token when the CPLN profile changes.
227
+ def reset_api_token = default_token_provider.reset
228
+ end
229
+
230
+ @default_token_provider = ApiToken.new
231
+
232
+ def initialize(token_provider: ControlplaneApiDirect.default_token_provider, sleeper: nil)
233
+ @token_provider = token_provider
234
+ @sleeper = sleeper || ->(seconds) { Kernel.sleep(seconds) }
235
+ end
236
+
237
+ def call(url, method:, host: :api, body: nil, request_policy: DEFAULT_REQUEST_POLICY)
238
+ uri = URI("#{api_host(host)}#{url}")
239
+ # Token fetch and request construction happen outside the transient rescue
240
+ # in attempt_request so their failures (e.g. TokenRefreshError from the
241
+ # `cpln` shell-out) are never misclassified as retryable network errors.
242
+ request = build_request(uri, method, body)
243
+ retrier = Retrier.new(sleeper: @sleeper)
244
+
245
+ debug_body = request_policy.sensitive && body ? "[REDACTED]" : body&.to_json
246
+ Shell.debug(method.upcase, "#{uri} #{debug_body}")
247
+
248
+ loop do
249
+ response = attempt_request(uri, request, method, retrier, request_policy)
250
+ return handle_response(response, url, request_policy) if response
88
251
  end
89
252
  end
90
253
 
@@ -104,54 +267,85 @@ class ControlplaneApiDirect
104
267
  "Bearer #{token}"
105
268
  end
106
269
 
107
- # rubocop:disable Style/ClassVars
108
- def api_token # rubocop:disable Metrics/MethodLength
109
- return @@api_token if defined?(@@api_token)
110
-
111
- @@api_token = {
112
- token: ENV.fetch("CPLN_TOKEN", nil),
113
- comes_from_profile: false
114
- }
115
- if @@api_token[:token].nil?
116
- @@api_token = {
117
- token: Shell.cmd("cpln", "profile", "token")[:output].chomp,
118
- comes_from_profile: true
119
- }
120
- end
121
- token = @@api_token[:token]
122
- # Allow any token that does not contain line breaks. Scoped service-account
123
- # tokens include punctuation such as '/', '+', ':', and '=', so format
124
- # validation is deferred to the Control Plane API.
125
- return @@api_token if token && !token.empty? && !token.match?(/[\r\n]/)
270
+ def api_token = @token_provider.fetch
126
271
 
127
- raise "Unknown API token format. " \
128
- "Please re-run 'cpln profile login' or set the correct CPLN_TOKEN env variable."
272
+ def self.parse_org(url)
273
+ url.match(%r{^/org/([^/]+)})&.[](1)
129
274
  end
130
275
 
131
- # Returns `true` when the token is about to expire in 5 minutes
132
- def should_refresh_api_token?
133
- return false unless api_token[:comes_from_profile]
276
+ private
134
277
 
135
- payload, = JWT.decode(api_token[:token], nil, false, algorithms: [])
136
- return false unless payload.is_a?(Hash) && payload["exp"]
278
+ # Returns the response, or `nil` when the attempt failed transiently and the
279
+ # retrier approved (and already slept before) another attempt. Only the
280
+ # transport phase (connect + request) is covered by the transient rescue.
281
+ def attempt_request(uri, request, method, retrier, request_policy)
282
+ request_sent = false
283
+ idempotent = IDEMPOTENT_METHODS.include?(method)
284
+ response = transport_request(uri, request, request_policy) { request_sent = true }
285
+ return response unless request_policy.retry_transient && retrier.retry_response?(response, idempotent)
137
286
 
138
- difference_in_seconds = payload["exp"].to_i - Time.now.to_i
287
+ nil
288
+ rescue *Retrier::TRANSIENT_NETWORK_ERRORS
289
+ raise unless request_policy.retry_transient && retrier.retry_exception?(idempotent, request_sent)
139
290
 
140
- difference_in_seconds <= API_TOKEN_EXPIRY_SECONDS
141
- rescue JWT::DecodeError
142
- false
291
+ nil
143
292
  end
144
293
 
145
- def refresh_api_token
146
- @@api_token[:token] = Shell.cmd("cpln", "profile", "token")[:output].chomp
294
+ def transport_request(uri, request, request_policy)
295
+ http = build_http(uri, request_policy)
296
+ http.start
297
+ # Set request_sent before `http.request` deliberately: a reset before bytes
298
+ # are written is indistinguishable from one after a partial write, so treat
299
+ # both as sent conservatively.
300
+ yield
301
+ begin
302
+ http.request(request)
303
+ ensure
304
+ http.finish if http.started?
305
+ end
147
306
  end
148
307
 
149
- def self.reset_api_token
150
- remove_class_variable(:@@api_token) if defined?(@@api_token)
308
+ def build_request(uri, method, body)
309
+ request = API_METHODS[method].new(uri)
310
+ request["Content-Type"] = "application/json"
311
+ request["Authorization"] = authorization_header
312
+ request.body = body.to_json if body
313
+ request
151
314
  end
152
- # rubocop:enable Style/ClassVars
153
315
 
154
- def self.parse_org(url)
155
- url.match(%r{^/org/([^/]+)})&.[](1)
316
+ def build_http(uri, request_policy)
317
+ http = Net::HTTP.new(uri.hostname, uri.port)
318
+ http.use_ssl = uri.scheme == "https"
319
+ # Net::HTTP transparently re-sends requests it deems idempotent once on
320
+ # mid-flight failures; disable so the Retrier owns the entire retry policy.
321
+ http.max_retries = 0
322
+ http.open_timeout = request_policy.timeout || OPEN_TIMEOUT_SECONDS
323
+ http.read_timeout = request_policy.timeout if request_policy.timeout
324
+ http.set_debug_output(RedactedDebugOutput.new) if ControlplaneApiDirect.trace && !request_policy.sensitive
325
+ http
326
+ end
327
+
328
+ def handle_response(response, url, request_policy)
329
+ case response
330
+ when Net::HTTPOK then parse_response_body(response, request_policy)
331
+ when Net::HTTPAccepted then true
332
+ when Net::HTTPNotFound then nil
333
+ when Net::HTTPForbidden then raise(ForbiddenError.new(url: url, response: response))
334
+ else raise(response_error_message(response, request_policy))
335
+ end
336
+ end
337
+
338
+ def parse_response_body(response, request_policy)
339
+ JSON.parse(response.body)
340
+ rescue JSON::ParserError
341
+ raise unless request_policy.sensitive
342
+
343
+ raise JSON::ParserError, "Control Plane API returned invalid JSON for a sensitive request.", cause: nil
344
+ end
345
+
346
+ def response_error_message(response, request_policy)
347
+ return response.to_s if request_policy.sensitive
348
+
349
+ "#{response} #{response.body}"
156
350
  end
157
351
  end