cpl 1.4.0 → 2.0.0.rc.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/command_docs.yml +1 -1
  3. data/.github/workflows/rspec-shared.yml +56 -0
  4. data/.github/workflows/rspec.yml +19 -31
  5. data/.github/workflows/rubocop.yml +2 -10
  6. data/.gitignore +2 -0
  7. data/.simplecov_spawn.rb +10 -0
  8. data/CHANGELOG.md +8 -0
  9. data/CONTRIBUTING.md +32 -2
  10. data/Gemfile.lock +34 -29
  11. data/README.md +34 -25
  12. data/cpl.gemspec +1 -1
  13. data/docs/commands.md +54 -54
  14. data/docs/dns.md +6 -0
  15. data/docs/migrating.md +10 -10
  16. data/docs/tips.md +12 -10
  17. data/examples/circleci.yml +3 -3
  18. data/examples/controlplane.yml +25 -16
  19. data/lib/command/apply_template.rb +9 -9
  20. data/lib/command/base.rb +132 -37
  21. data/lib/command/build_image.rb +4 -9
  22. data/lib/command/cleanup_stale_apps.rb +1 -1
  23. data/lib/command/copy_image_from_upstream.rb +0 -7
  24. data/lib/command/delete.rb +39 -7
  25. data/lib/command/deploy_image.rb +18 -3
  26. data/lib/command/exists.rb +1 -1
  27. data/lib/command/generate.rb +1 -1
  28. data/lib/command/info.rb +7 -3
  29. data/lib/command/logs.rb +22 -2
  30. data/lib/command/maintenance_off.rb +1 -1
  31. data/lib/command/maintenance_on.rb +1 -1
  32. data/lib/command/open.rb +2 -2
  33. data/lib/command/open_console.rb +2 -2
  34. data/lib/command/ps.rb +1 -1
  35. data/lib/command/ps_start.rb +2 -1
  36. data/lib/command/ps_stop.rb +40 -8
  37. data/lib/command/ps_wait.rb +3 -2
  38. data/lib/command/run.rb +430 -69
  39. data/lib/command/setup_app.rb +4 -1
  40. data/lib/constants/exit_code.rb +7 -0
  41. data/lib/core/config.rb +1 -1
  42. data/lib/core/controlplane.rb +109 -48
  43. data/lib/core/controlplane_api.rb +7 -1
  44. data/lib/core/controlplane_api_cli.rb +3 -3
  45. data/lib/core/controlplane_api_direct.rb +1 -1
  46. data/lib/core/shell.rb +15 -9
  47. data/lib/cpl/version.rb +1 -1
  48. data/lib/cpl.rb +48 -9
  49. data/lib/deprecated_commands.json +2 -1
  50. data/lib/generator_templates/controlplane.yml +2 -2
  51. data/script/check_cpln_links +3 -3
  52. data/templates/{gvc.yml → app.yml} +5 -0
  53. data/templates/secrets.yml +8 -0
  54. metadata +23 -26
  55. data/.rspec +0 -1
  56. data/lib/command/run_cleanup.rb +0 -116
  57. data/lib/command/run_detached.rb +0 -176
  58. data/lib/core/scripts.rb +0 -34
  59. data/templates/identity.yml +0 -3
  60. data/templates/secrets-policy.yml +0 -4
  61. /data/lib/generator_templates/templates/{gvc.yml → app.yml} +0 -0
@@ -6,6 +6,8 @@ module Command
6
6
  OPTIONS = [
7
7
  app_option(required: true),
8
8
  workload_option,
9
+ replica_option,
10
+ location_option,
9
11
  wait_option("workload to not be ready")
10
12
  ].freeze
11
13
  DESCRIPTION = "Stops workloads in app"
@@ -19,32 +21,62 @@ module Command
19
21
 
20
22
  # Stops a specific workload in app.
21
23
  cpl ps:stop -a $APP_NAME -w $WORKLOAD_NAME
24
+
25
+ # Stops a specific replica of a workload.
26
+ cpl ps:stop -a $APP_NAME -w $WORKLOAD_NAME -r $REPLICA_NAME
22
27
  ```
23
28
  EX
24
29
 
25
30
  def call
26
- @workloads = [config.options[:workload]] if config.options[:workload]
27
- @workloads ||= config[:app_workloads] + config[:additional_workloads]
31
+ workload = config.options[:workload]
32
+ replica = config.options[:replica]
33
+ if replica
34
+ stop_replica(workload, replica)
35
+ else
36
+ workloads = [workload] if workload
37
+ workloads ||= config[:app_workloads] + config[:additional_workloads]
38
+
39
+ stop_workloads(workloads)
40
+ end
41
+ end
28
42
 
29
- @workloads.each do |workload|
43
+ private
44
+
45
+ def stop_workloads(workloads)
46
+ workloads.each do |workload|
30
47
  step("Stopping workload '#{workload}'") do
31
48
  cp.set_workload_suspend(workload, true)
32
49
  end
33
50
  end
34
51
 
35
- wait_for_not_ready if config.options[:wait]
52
+ wait_for_workloads_not_ready(workloads) if config.options[:wait]
36
53
  end
37
54
 
38
- private
55
+ def stop_replica(workload, replica)
56
+ step("Stopping replica '#{replica}'", retry_on_failure: true) do
57
+ cp.stop_workload_replica(workload, replica, location: config.location)
58
+ end
39
59
 
40
- def wait_for_not_ready
60
+ wait_for_replica_not_ready(workload, replica) if config.options[:wait]
61
+ end
62
+
63
+ def wait_for_workloads_not_ready(workloads)
41
64
  progress.puts
42
65
 
43
- @workloads.each do |workload|
66
+ workloads.each do |workload|
44
67
  step("Waiting for workload '#{workload}' to not be ready", retry_on_failure: true) do
45
- cp.workload_deployments_ready?(workload, expected_status: false)
68
+ cp.workload_deployments_ready?(workload, location: config.location, expected_status: false)
46
69
  end
47
70
  end
48
71
  end
72
+
73
+ def wait_for_replica_not_ready(workload, replica)
74
+ progress.puts
75
+
76
+ step("Waiting for replica '#{replica}' to not be ready", retry_on_failure: true) do
77
+ result = cp.fetch_workload_replicas(workload, location: config.location)
78
+ !result["items"].include?(replica)
79
+ end
80
+ end
49
81
  end
50
82
  end
@@ -5,7 +5,8 @@ module Command
5
5
  NAME = "ps:wait"
6
6
  OPTIONS = [
7
7
  app_option(required: true),
8
- workload_option
8
+ workload_option,
9
+ location_option
9
10
  ].freeze
10
11
  DESCRIPTION = "Waits for workloads in app to be ready after re-deployment"
11
12
  LONG_DESCRIPTION = <<~DESC
@@ -27,7 +28,7 @@ module Command
27
28
 
28
29
  @workloads.reverse_each do |workload|
29
30
  step("Waiting for workload '#{workload}' to be ready", retry_on_failure: true) do
30
- cp.workload_deployments_ready?(workload, expected_status: true)
31
+ cp.workload_deployments_ready?(workload, location: config.location, expected_status: true)
31
32
  end
32
33
  end
33
34
  end
data/lib/command/run.rb CHANGED
@@ -1,7 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Command
4
- class Run < Base
4
+ class Run < Base # rubocop:disable Metrics/ClassLength
5
+ INTERACTIVE_COMMANDS = [
6
+ "bash",
7
+ "rails console",
8
+ "rails c",
9
+ "rails dbconsole",
10
+ "rails db"
11
+ ].freeze
12
+
5
13
  NAME = "run"
6
14
  USAGE = "run COMMAND"
7
15
  REQUIRES_ARGS = true
@@ -9,34 +17,53 @@ module Command
9
17
  OPTIONS = [
10
18
  app_option(required: true),
11
19
  image_option,
20
+ log_method_option,
12
21
  workload_option,
13
22
  location_option,
14
23
  use_local_token_option,
15
- terminal_size_option
24
+ terminal_size_option,
25
+ interactive_option,
26
+ detached_option,
27
+ cpu_option,
28
+ memory_option,
29
+ entrypoint_option
16
30
  ].freeze
17
- DESCRIPTION = "Runs one-off **_interactive_** replicas (analog of `heroku run`)"
31
+ DESCRIPTION = "Runs one-off interactive or non-interactive replicas (analog of `heroku run`)"
18
32
  LONG_DESCRIPTION = <<~DESC
19
- - Runs one-off **_interactive_** replicas (analog of `heroku run`)
20
- - Uses `Standard` workload type and `cpln exec` as the execution method, with CLI streaming
21
- - If `fix_terminal_size` is `true` in the `.controlplane/controlplane.yml` file, the remote terminal size will be fixed to match the local terminal size (may also be overriden through `--terminal-size`)
22
-
23
- > **IMPORTANT:** Useful for development where it's needed for interaction, and where network connection drops and
24
- > task crashing are tolerable. For production tasks, it's better to use `cpl run:detached`.
33
+ - Runs one-off interactive or non-interactive replicas (analog of `heroku run`)
34
+ - Uses `Cron` workload type and either:
35
+ - - `cpln workload exec` for interactive mode, with CLI streaming
36
+ - - log async fetching for non-interactive mode
37
+ - The Dockerfile entrypoint is used as the command by default, which assumes `exec "${@}"` to be present,
38
+ and the args ["bash", "-c", cmd_to_run] are passed
39
+ - The entrypoint can be overriden through `--entrypoint`, which must be a single command or a script path that exists in the container,
40
+ and the args ["bash", "-c", cmd_to_run] are passed,
41
+ unless the entrypoint is `bash`, in which case the args ["-c", cmd_to_run] are passed
42
+ - Providing `--entrypoint none` sets the entrypoint to `bash` by default
43
+ - If `fix_terminal_size` is `true` in the `.controlplane/controlplane.yml` file,
44
+ the remote terminal size will be fixed to match the local terminal size (may also be overriden through `--terminal-size`)
25
45
  DESC
26
46
  EXAMPLES = <<~EX
27
47
  ```sh
28
48
  # Opens shell (bash by default).
29
49
  cpl run -a $APP_NAME
30
50
 
31
- # Need to quote COMMAND if setting ENV value or passing args.
32
- cpl run -a $APP_NAME -- 'LOG_LEVEL=warn rails db:migrate'
51
+ # Runs interactive command, keeps shell open, and stops job when exiting.
52
+ cpl run -a $APP_NAME --interactive -- rails c
53
+
54
+ # Some commands are automatically detected as interactive, so no need to pass `--interactive`.
55
+ #{INTERACTIVE_COMMANDS.map { |cmd| "cpl run -a $APP_NAME -- #{cmd}" }.join("\n ")}
33
56
 
34
- # Runs command, displays output, and exits shell.
35
- cpl run -a $APP_NAME -- ls /
36
- cpl run -a $APP_NAME -- rails db:migrate:status
57
+ # Runs non-interactive command, outputs logs, exits with the exit code of the command and stops job.
58
+ cpl run -a $APP_NAME -- rails db:migrate
37
59
 
38
- # Runs command and keeps shell open.
39
- cpl run -a $APP_NAME -- rails c
60
+ # Runs non-iteractive command, detaches, exits with 0, and prints commands to:
61
+ # - see logs from the job
62
+ # - stop the job
63
+ cpl run -a $APP_NAME --detached -- rails db:migrate
64
+
65
+ # The command needs to be quoted if setting an env variable or passing args.
66
+ cpl run -a $APP_NAME -- 'SOME_ENV_VAR=some_value rails db:migrate'
40
67
 
41
68
  # Uses a different image (which may not be promoted yet).
42
69
  cpl run -a $APP_NAME --image appimage:123 -- rails db:migrate # Exact image name
@@ -48,98 +75,432 @@ module Command
48
75
  # Overrides remote CPLN_TOKEN env variable with local token.
49
76
  # Useful when superuser rights are needed in remote container.
50
77
  cpl run -a $APP_NAME --use-local-token -- bash
78
+
79
+ # Replaces the existing Dockerfile entrypoint with `bash`.
80
+ cpl run -a $APP_NAME --entrypoint none -- rails db:migrate
81
+
82
+ # Replaces the existing Dockerfile entrypoint.
83
+ cpl run -a $APP_NAME --entrypoint /app/alternative-entrypoint.sh -- rails db:migrate
51
84
  ```
52
85
  EX
53
86
 
54
- attr_reader :location, :workload_to_clone, :workload_clone, :container
87
+ MAGIC_END = "---cpl run command finished---"
88
+
89
+ attr_reader :interactive, :detached, :location, :original_workload, :runner_workload,
90
+ :container, :image_link, :image_changed, :job, :replica, :command
91
+
92
+ def call # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
93
+ @interactive = config.options[:interactive] || interactive_command?
94
+ @detached = config.options[:detached]
95
+ @log_method = config.options[:log_method]
55
96
 
56
- def call # rubocop:disable Metrics/MethodLength
57
97
  @location = config.location
58
- @workload_to_clone = config.options["workload"] || config[:one_off_workload]
59
- @workload_clone = "#{workload_to_clone}-run-#{random_four_digits}"
98
+ @original_workload = config.options[:workload] || config[:one_off_workload]
99
+ @runner_workload = "#{original_workload}-runner"
100
+
101
+ unless interactive
102
+ @internal_sigint = false
103
+
104
+ # Catch Ctrl+C in the main process
105
+ trap("SIGINT") do
106
+ unless @internal_sigint
107
+ print_detached_commands
108
+ exit(ExitCode::INTERRUPT)
109
+ end
110
+ end
111
+ end
60
112
 
61
- step("Cloning workload '#{workload_to_clone}' on app '#{config.options[:app]}' to '#{workload_clone}'") do
62
- clone_workload
113
+ if cp.fetch_workload(runner_workload).nil?
114
+ create_runner_workload
115
+ wait_for_runner_workload_create
63
116
  end
117
+ update_runner_workload
118
+ wait_for_runner_workload_update
119
+
120
+ # NOTE: need to wait some time before starting the job,
121
+ # otherwise the image may not be updated yet
122
+ # TODO: need to figure out if there's a better way to do this
123
+ sleep 1 if image_changed
124
+
125
+ start_job
126
+ wait_for_replica_for_job
64
127
 
65
- wait_for_workload(workload_clone)
66
- wait_for_replica(workload_clone, location)
67
- run_in_replica
68
- ensure
69
128
  progress.puts
70
- ensure_workload_deleted(workload_clone)
129
+ if interactive
130
+ run_interactive
131
+ else
132
+ run_non_interactive
133
+ end
71
134
  end
72
135
 
73
136
  private
74
137
 
75
- def clone_workload # rubocop:disable Metrics/MethodLength
76
- # Create a base copy of workload props
77
- spec = cp.fetch_workload!(workload_to_clone).fetch("spec")
78
- container_spec = spec["containers"].detect { _1["name"] == workload_to_clone } || spec["containers"].first
79
- @container = container_spec["name"]
138
+ def interactive_command?
139
+ INTERACTIVE_COMMANDS.include?(args_join(config.args))
140
+ end
141
+
142
+ def app_workload_replica_args
143
+ ["-a", config.app, "--workload", runner_workload, "--replica", replica]
144
+ end
80
145
 
81
- # remove other containers if any
82
- spec["containers"] = [container_spec]
146
+ def create_runner_workload # rubocop:disable Metrics/MethodLength
147
+ step("Creating runner workload '#{runner_workload}' based on '#{original_workload}'") do
148
+ spec, container_spec = base_workload_specs(original_workload)
83
149
 
84
- # Stub workload command with dummy server that just responds to port
85
- # Needed to avoid execution of ENTRYPOINT and CMD of Dockerfile
86
- container_spec["command"] = "ruby"
87
- container_spec["args"] = ["-e", Scripts.http_dummy_server_ruby]
150
+ # Remove other containers if any
151
+ spec["containers"] = [container_spec]
88
152
 
89
- # Ensure one-off workload will be running
90
- spec["defaultOptions"]["suspend"] = false
153
+ # Default to using existing Dockerfile entrypoint
154
+ container_spec.delete("command")
91
155
 
92
- # Ensure no scaling
93
- spec["defaultOptions"]["autoscaling"]["minScale"] = 1
94
- spec["defaultOptions"]["autoscaling"]["maxScale"] = 1
95
- spec["defaultOptions"]["capacityAI"] = false
156
+ # Remove props that conflict with job
157
+ container_spec.delete("ports")
158
+ container_spec.delete("lifecycle")
159
+ container_spec.delete("livenessProbe")
160
+ container_spec.delete("readinessProbe")
96
161
 
97
- # Override image if specified
98
- image = config.options[:image]
99
- image = latest_image if image == "latest"
100
- container_spec["image"] = "/org/#{config.org}/image/#{image}" if image
162
+ # Ensure cron workload won't run per schedule
163
+ spec["defaultOptions"]["suspend"] = true
101
164
 
102
- # Set runner
103
- container_spec["env"] ||= []
104
- container_spec["env"] << { "name" => "CONTROLPLANE_RUNNER", "value" => runner_script }
165
+ # Ensure no scaling
166
+ spec["defaultOptions"]["autoscaling"] = {}
167
+ spec["defaultOptions"]["capacityAI"] = false
105
168
 
106
- if config.options["use_local_token"]
107
- container_spec["env"] << { "name" => "CONTROLPLANE_TOKEN",
108
- "value" => ControlplaneApiDirect.new.api_token[:token] }
169
+ # Set cron job props
170
+ spec["type"] = "cron"
171
+
172
+ # Next job set to run on January 1st, 2029
173
+ spec["job"] = { "schedule" => "0 0 1 1 1", "restartPolicy" => "Never" }
174
+
175
+ # Create runner workload
176
+ cp.apply_hash("kind" => "workload", "name" => runner_workload, "spec" => spec)
109
177
  end
178
+ end
179
+
180
+ def update_runner_workload # rubocop:disable Metrics/MethodLength
181
+ step("Updating runner workload '#{runner_workload}' based on '#{original_workload}'") do
182
+ _, original_container_spec = base_workload_specs(original_workload)
183
+ spec, container_spec = base_workload_specs(runner_workload)
110
184
 
111
- # Create workload clone
112
- cp.apply_hash("kind" => "workload", "name" => workload_clone, "spec" => spec)
185
+ # Override image if specified
186
+ image = config.options[:image]
187
+ if image
188
+ image = latest_image if image == "latest"
189
+ @image_link = "/org/#{config.org}/image/#{image}"
190
+ else
191
+ @image_link = original_container_spec["image"]
192
+ end
193
+ @image_changed = container_spec["image"] != image_link
194
+ container_spec["image"] = image_link
195
+
196
+ # Container overrides
197
+ container_spec["cpu"] = config.options[:cpu] if config.options[:cpu]
198
+ container_spec["memory"] = config.options[:memory] if config.options[:memory]
199
+
200
+ # Update runner workload
201
+ cp.apply_hash("kind" => "workload", "name" => runner_workload, "spec" => spec)
202
+ end
113
203
  end
114
204
 
115
- def runner_script # rubocop:disable Metrics/MethodLength
116
- script = Scripts.helpers_cleanup
205
+ def wait_for_runner_workload_create
206
+ step("Waiting for runner workload '#{runner_workload}' to be created", retry_on_failure: true) do
207
+ cp.fetch_workload(runner_workload)
208
+ end
209
+ end
210
+
211
+ def wait_for_runner_workload_update
212
+ step("Waiting for runner workload '#{runner_workload}' to be updated", retry_on_failure: true) do
213
+ _, container_spec = base_workload_specs(runner_workload)
214
+ container_spec["image"] == image_link
215
+ end
216
+ end
217
+
218
+ def start_job
219
+ job_start_yaml = build_job_start_yaml
220
+
221
+ step("Starting job for runner workload '#{runner_workload}'", retry_on_failure: true) do
222
+ result = cp.start_cron_workload(runner_workload, job_start_yaml, location: location)
223
+ @job = result&.dig("items", 0, "id")
224
+
225
+ job || false
226
+ end
227
+ end
228
+
229
+ def wait_for_replica_for_job
230
+ step("Waiting for replica to start, which runs job '#{job}'", retry_on_failure: true) do
231
+ result = cp.fetch_workload_replicas(runner_workload, location: location)
232
+ @replica = result["items"].find { |item| item.include?(job) }
233
+
234
+ replica || false
235
+ end
236
+ end
237
+
238
+ def run_interactive
239
+ progress.puts("Connecting to replica '#{replica}'...\n\n")
240
+ cp.workload_exec(runner_workload, replica, location: location, container: container, command: command)
241
+ end
117
242
 
118
- if config.options["use_local_token"]
119
- script += <<~SHELL
120
- CPLN_TOKEN=$CONTROLPLANE_TOKEN
121
- unset CONTROLPLANE_TOKEN
122
- SHELL
243
+ def run_non_interactive
244
+ if detached
245
+ print_detached_commands
246
+ exit(ExitCode::SUCCESS)
123
247
  end
124
248
 
249
+ case @log_method
250
+ when 1 then run_non_interactive_v1
251
+ when 2 then run_non_interactive_v2
252
+ when 3 then run_non_interactive_v3
253
+ else raise "Invalid log method: #{@log_method}"
254
+ end
255
+ end
256
+
257
+ def run_non_interactive_v1 # rubocop:disable Metrics/MethodLength
258
+ logs_pid = Process.fork do
259
+ # Catch Ctrl+C in the forked process
260
+ trap("SIGINT") do
261
+ exit(ExitCode::SUCCESS)
262
+ end
263
+
264
+ Cpl::Cli.start(["logs", *app_workload_replica_args])
265
+ end
266
+ Process.detach(logs_pid)
267
+
268
+ exit_status = wait_for_job_status
269
+
270
+ # We need to wait a bit for the logs to appear,
271
+ # otherwise it may exit without showing them
272
+ Kernel.sleep(30)
273
+
274
+ @internal_sigint = true
275
+ Process.kill("INT", logs_pid)
276
+ exit(exit_status)
277
+ end
278
+
279
+ def run_non_interactive_v2
280
+ current_cpl = File.expand_path("cpl", "#{__dir__}/../..")
281
+ logs_pipe = IO.popen([current_cpl, "logs", *app_workload_replica_args])
282
+
283
+ exit_status = wait_for_job_status_and_log(logs_pipe)
284
+
285
+ @internal_sigint = true
286
+ Process.kill("INT", logs_pipe.pid)
287
+ exit(exit_status)
288
+ end
289
+
290
+ def run_non_interactive_v3
291
+ exit(show_logs_waiting)
292
+ end
293
+
294
+ def base_workload_specs(workload)
295
+ spec = cp.fetch_workload!(workload).fetch("spec")
296
+ container_spec = spec["containers"].detect { _1["name"] == original_workload } || spec["containers"].first
297
+ @container = container_spec["name"]
298
+
299
+ [spec, container_spec]
300
+ end
301
+
302
+ def build_job_start_yaml # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
303
+ job_start_hash = { "name" => container }
304
+
305
+ if config.options[:use_local_token]
306
+ job_start_hash["env"] ||= []
307
+ job_start_hash["env"].push({ "name" => "CPL_TOKEN", "value" => ControlplaneApiDirect.new.api_token[:token] })
308
+ end
309
+
310
+ entrypoint = nil
311
+ if config.options[:entrypoint]
312
+ entrypoint = config.options[:entrypoint] == "none" ? "bash" : config.options[:entrypoint]
313
+ end
314
+
315
+ job_start_hash["command"] = entrypoint if entrypoint
316
+ job_start_hash["args"] ||= []
317
+ job_start_hash["args"].push("bash") unless entrypoint == "bash"
318
+ job_start_hash["args"].push("-c")
319
+ job_start_hash["env"] ||= []
320
+ job_start_hash["env"].push({ "name" => "CPL_RUNNER_SCRIPT", "value" => runner_script })
321
+ if interactive
322
+ job_start_hash["env"].push({ "name" => "CPL_MONITORING_SCRIPT", "value" => interactive_monitoring_script })
323
+
324
+ job_start_hash["args"].push('eval "$CPL_MONITORING_SCRIPT"')
325
+ @command = %(bash -c 'eval "$CPL_RUNNER_SCRIPT"')
326
+ else
327
+ job_start_hash["args"].push('eval "$CPL_RUNNER_SCRIPT"')
328
+ end
329
+
330
+ job_start_hash.to_yaml
331
+ end
332
+
333
+ def interactive_monitoring_script
334
+ <<~SCRIPT
335
+ primary_pid=""
336
+
337
+ check_primary() {
338
+ if ! kill -0 $primary_pid 2>/dev/null; then
339
+ echo "Primary process has exited. Shutting down."
340
+ exit 0
341
+ fi
342
+ }
343
+
344
+ while true; do
345
+ if [[ -z "$primary_pid" ]]; then
346
+ primary_pid=$(ps -eo pid,etime,cmd --sort=etime | grep -v "$$" | grep -v 'ps -eo' | grep -v 'grep' | grep 'CPL_RUNNER_SCRIPT' | head -n 1 | awk '{print $1}')
347
+ if [[ ! -z "$primary_pid" ]]; then
348
+ echo "Primary process set with PID: $primary_pid"
349
+ fi
350
+ else
351
+ check_primary
352
+ fi
353
+
354
+ sleep 1
355
+ done
356
+ SCRIPT
357
+ end
358
+
359
+ def interactive_runner_script
360
+ script = ""
361
+
125
362
  # NOTE: fixes terminal size to match local terminal
126
363
  if config.current[:fix_terminal_size] || config.options[:terminal_size]
127
364
  if config.options[:terminal_size]
128
365
  rows, cols = config.options[:terminal_size].split(",")
129
366
  else
367
+ # NOTE: cannot use `Shell.cmd` here, as `stty size` has to run in a terminal environment
130
368
  rows, cols = `stty size`.split(/\s+/)
131
369
  end
132
- script += "stty rows #{rows}\nstty cols #{cols}\n" if rows && cols
370
+ script += "stty rows #{rows}\nstty cols #{cols}\n"
133
371
  end
134
372
 
135
- script += args_join(config.args)
136
373
  script
137
374
  end
138
375
 
139
- def run_in_replica
140
- progress.puts("Connecting...\n\n")
141
- command = %(bash -c 'eval "$CONTROLPLANE_RUNNER"')
142
- cp.workload_exec(workload_clone, location: location, container: container, command: command)
376
+ def runner_script # rubocop:disable Metrics/MethodLength
377
+ script = <<~SCRIPT
378
+ unset CPL_RUNNER_SCRIPT
379
+ unset CPL_MONITORING_SCRIPT
380
+
381
+ if [ -n "$CPL_TOKEN" ]; then
382
+ CPLN_TOKEN=$CPL_TOKEN
383
+ unset CPL_TOKEN
384
+ fi
385
+ SCRIPT
386
+
387
+ script += interactive_runner_script if interactive
388
+
389
+ script +=
390
+ if @log_method == 1
391
+ args_join(config.args)
392
+ else
393
+ <<~SCRIPT
394
+ ( #{args_join(config.args)} )
395
+ CPL_EXIT_CODE=$?
396
+ echo '#{MAGIC_END}'
397
+ exit $CPL_EXIT_CODE
398
+ SCRIPT
399
+ end
400
+
401
+ script
402
+ end
403
+
404
+ def wait_for_job_status
405
+ Kernel.sleep(1) until (exit_code = resolve_job_status)
406
+ exit_code
407
+ end
408
+
409
+ def wait_for_job_status_and_log(logs_pipe) # rubocop:disable Metrics/MethodLength
410
+ no_logs_counter = 0
411
+
412
+ loop do
413
+ no_logs_counter += 1
414
+ break if no_logs_counter > 60 # 30s
415
+ break if logs_pipe.eof?
416
+ next Kernel.sleep(0.5) unless logs_pipe.ready?
417
+
418
+ no_logs_counter = 0
419
+ line = logs_pipe.gets
420
+ break if line.chomp == MAGIC_END
421
+
422
+ puts(line)
423
+ end
424
+
425
+ resolve_job_status
426
+ end
427
+
428
+ def print_detached_commands
429
+ app_workload_replica_config = app_workload_replica_args.join(" ")
430
+ progress.puts(
431
+ "\n\n" \
432
+ "- To view logs from the job, run:\n `cpl logs #{app_workload_replica_config}`\n" \
433
+ "- To stop the job, run:\n `cpl ps:stop #{app_workload_replica_config}`\n"
434
+ )
435
+ end
436
+
437
+ def resolve_job_status
438
+ result = cp.fetch_cron_workload(runner_workload, location: location)
439
+ job_details = result&.dig("items")&.find { |item| item["id"] == job }
440
+ status = job_details&.dig("status")
441
+
442
+ case status
443
+ when "failed"
444
+ ExitCode::ERROR_DEFAULT
445
+ when "successful"
446
+ ExitCode::SUCCESS
447
+ end
448
+ end
449
+
450
+ ###########################################
451
+ ### temporary extaction from run:detached
452
+ ###########################################
453
+ def show_logs_waiting # rubocop:disable Metrics/MethodLength
454
+ retries = 0
455
+ begin
456
+ job_finished_count = 0
457
+ loop do
458
+ case print_uniq_logs
459
+ when :finished
460
+ break
461
+ when :changed
462
+ next
463
+ else
464
+ job_finished_count += 1 if resolve_job_status
465
+ break if job_finished_count > 5
466
+
467
+ sleep(1)
468
+ end
469
+ end
470
+
471
+ resolve_job_status
472
+ rescue RuntimeError => e
473
+ raise "#{e} Exiting..." unless retries < 10 # MAX_RETRIES
474
+
475
+ progress.puts(Shell.color("ERROR: #{e} Retrying...", :red))
476
+ retries += 1
477
+ retry
478
+ end
479
+ end
480
+
481
+ def print_uniq_logs
482
+ status = nil
483
+
484
+ @printed_log_entries ||= []
485
+ ts = Time.now.to_i
486
+ entries = normalized_log_entries(from: ts - 60, to: ts)
487
+
488
+ (entries - @printed_log_entries).sort.each do |(_ts, val)|
489
+ status ||= :changed
490
+ val.chomp == MAGIC_END ? status = :finished : progress.puts(val)
491
+ end
492
+
493
+ @printed_log_entries = entries # as well truncate old entries if any
494
+
495
+ status || :unchanged
496
+ end
497
+
498
+ def normalized_log_entries(from:, to:)
499
+ log = cp.log_get(workload: runner_workload, from: from, to: to, replica: replica)
500
+
501
+ log["data"]["result"]
502
+ .each_with_object([]) { |obj, result| result.concat(obj["values"]) }
503
+ .select { |ts, _val| ts[..-10].to_i > from }
143
504
  end
144
505
  end
145
506
  end
@@ -35,7 +35,10 @@ module Command
35
35
  if cp.fetch_identity(app_identity).nil? || cp.fetch_policy(app_secrets_policy).nil?
36
36
  raise "Can't bind identity to policy: identity '#{app_identity}' or " \
37
37
  "policy '#{app_secrets_policy}' doesn't exist. " \
38
- "Please create them or use `--skip-secret-access-binding` to ignore this message."
38
+ "Please create them or use `--skip-secret-access-binding` to ignore this message." \
39
+ "You can also set a custom secrets name with `secrets_name` " \
40
+ "and a custom secrets policy name with `secrets_policy_name` " \
41
+ "in the `.controlplane/controlplane.yml` file."
39
42
  end
40
43
 
41
44
  step("Binding identity to policy") do