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.
- checksums.yaml +4 -4
- data/.agents/agent-workflow.yml +26 -0
- data/.agents/bin/README.md +20 -0
- data/.agents/bin/docs +5 -0
- data/.agents/bin/lint +5 -0
- data/.agents/bin/setup +5 -0
- data/.agents/bin/test +5 -0
- data/.agents/bin/validate +5 -0
- data/.agents/trusted-github-actors.yml +32 -0
- data/.agents/workflows/ai-rollout-e2e-test.md +166 -0
- data/.github/actions/cpflow-setup-environment/action.yml +1 -1
- data/.github/actions/cpflow-wait-for-health/action.yml +87 -15
- data/.github/pull_request_template.md +18 -0
- data/.github/workflows/claude-code-review.yml +2 -0
- data/.github/workflows/claude.yml +94 -1
- data/.github/workflows/cpflow-delete-review-app.yml +621 -33
- data/.github/workflows/cpflow-deploy-review-app.yml +656 -21
- data/.github/workflows/cpflow-review-app-help.yml +5 -13
- data/.github/workflows/rspec-shared.yml +10 -3
- data/.github/workflows/rspec-specific.yml +1 -0
- data/.github/workflows/rspec.yml +58 -1
- data/AGENTS.md +14 -0
- data/CHANGELOG.md +54 -1
- data/CLAUDE.md +3 -0
- data/CONTRIBUTING.md +15 -3
- data/Gemfile.lock +1 -1
- data/README.md +21 -7
- data/docs/ai-github-flow-prompt.md +18 -16
- data/docs/ci-automation.md +239 -27
- data/docs/commands.md +30 -2
- data/docs/grafana-opentelemetry.md +699 -0
- data/docs/secrets-and-env-values.md +37 -2
- data/docs/sidebars.ts +70 -0
- data/docs/telemetry/application-instrumentation.md +161 -0
- data/docs/telemetry/collector.md +297 -0
- data/docs/telemetry/index.md +152 -0
- data/docs/telemetry/pipelines.md +98 -0
- data/docs/telemetry/review-apps.md +55 -0
- data/docs/telemetry/troubleshooting.md +92 -0
- data/docs/terraform/example/.controlplane/controlplane.yml +0 -1
- data/docs/terraform/overview.md +11 -0
- data/docs/tips.md +475 -28
- data/examples/controlplane.yml +2 -0
- data/lib/command/ai_github_flow_prompt.rb +2 -2
- data/lib/command/apply_template.rb +104 -2
- data/lib/command/base.rb +69 -5
- data/lib/command/deploy_image.rb +93 -7
- data/lib/command/promote_app_from_upstream.rb +1 -0
- data/lib/command/ps_wait.rb +2 -10
- data/lib/command/run.rb +133 -10
- data/lib/command/setup_app.rb +10 -5
- data/lib/core/config.rb +94 -0
- data/lib/core/controlplane.rb +38 -5
- data/lib/core/controlplane_api.rb +8 -0
- data/lib/core/controlplane_api_direct.rb +257 -63
- data/lib/core/doctor_service.rb +44 -3
- data/lib/core/shell.rb +9 -2
- data/lib/core/template_parser.rb +43 -9
- data/lib/cpflow/version.rb +1 -1
- data/lib/generator_templates/controlplane.yml +1 -2
- data/lib/github_flow_templates/.github/cpflow-help.md +34 -10
- data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +10 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +9 -0
- metadata +22 -2
data/lib/command/setup_app.rb
CHANGED
|
@@ -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
|
|
data/lib/core/controlplane.rb
CHANGED
|
@@ -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 =
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
96
|
+
def reset
|
|
97
|
+
@mutex.synchronize { @data = nil }
|
|
98
|
+
end
|
|
65
99
|
|
|
66
|
-
|
|
67
|
-
request.body = body.to_json if body
|
|
100
|
+
private
|
|
68
101
|
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
http.set_debug_output(RedactedDebugOutput.new) if trace
|
|
106
|
+
validate!(token: fetch_profile_token, comes_from_profile: true)
|
|
107
|
+
end
|
|
74
108
|
|
|
75
|
-
|
|
109
|
+
def refresh!
|
|
110
|
+
@data = validate!(token: fetch_profile_token, comes_from_profile: true)
|
|
111
|
+
end
|
|
76
112
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
272
|
+
def self.parse_org(url)
|
|
273
|
+
url.match(%r{^/org/([^/]+)})&.[](1)
|
|
129
274
|
end
|
|
130
275
|
|
|
131
|
-
|
|
132
|
-
def should_refresh_api_token?
|
|
133
|
-
return false unless api_token[:comes_from_profile]
|
|
276
|
+
private
|
|
134
277
|
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
287
|
+
nil
|
|
288
|
+
rescue *Retrier::TRANSIENT_NETWORK_ERRORS
|
|
289
|
+
raise unless request_policy.retry_transient && retrier.retry_exception?(idempotent, request_sent)
|
|
139
290
|
|
|
140
|
-
|
|
141
|
-
rescue JWT::DecodeError
|
|
142
|
-
false
|
|
291
|
+
nil
|
|
143
292
|
end
|
|
144
293
|
|
|
145
|
-
def
|
|
146
|
-
|
|
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
|
|
150
|
-
|
|
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
|
|
155
|
-
|
|
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
|