cpl 1.4.0 → 2.0.0.rc.1

Sign up to get free protection for your applications and to get access to all the features.
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 +24 -1
  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 +9 -9
  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 +437 -68
  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 +119 -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,440 @@ 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, :expected_deployed_version, :job, :replica, :command
55
91
 
56
92
  def call # rubocop:disable Metrics/MethodLength
93
+ @interactive = config.options[:interactive] || interactive_command?
94
+ @detached = config.options[:detached]
95
+ @log_method = config.options[:log_method]
96
+
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
+ start_job
121
+ wait_for_replica_for_job
64
122
 
65
- wait_for_workload(workload_clone)
66
- wait_for_replica(workload_clone, location)
67
- run_in_replica
68
- ensure
69
123
  progress.puts
70
- ensure_workload_deleted(workload_clone)
124
+ if interactive
125
+ run_interactive
126
+ else
127
+ run_non_interactive
128
+ end
71
129
  end
72
130
 
73
131
  private
74
132
 
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"]
133
+ def interactive_command?
134
+ INTERACTIVE_COMMANDS.include?(args_join(config.args))
135
+ end
136
+
137
+ def app_workload_replica_args
138
+ ["-a", config.app, "--workload", runner_workload, "--replica", replica]
139
+ end
140
+
141
+ def create_runner_workload # rubocop:disable Metrics/MethodLength
142
+ step("Creating runner workload '#{runner_workload}' based on '#{original_workload}'") do
143
+ spec, container_spec = base_workload_specs(original_workload)
80
144
 
81
- # remove other containers if any
82
- spec["containers"] = [container_spec]
145
+ # Remove other containers if any
146
+ spec["containers"] = [container_spec]
83
147
 
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]
148
+ # Default to using existing Dockerfile entrypoint
149
+ container_spec.delete("command")
88
150
 
89
- # Ensure one-off workload will be running
90
- spec["defaultOptions"]["suspend"] = false
151
+ # Remove props that conflict with job
152
+ container_spec.delete("ports")
153
+ container_spec.delete("lifecycle")
154
+ container_spec.delete("livenessProbe")
155
+ container_spec.delete("readinessProbe")
91
156
 
92
- # Ensure no scaling
93
- spec["defaultOptions"]["autoscaling"]["minScale"] = 1
94
- spec["defaultOptions"]["autoscaling"]["maxScale"] = 1
95
- spec["defaultOptions"]["capacityAI"] = false
157
+ # Ensure cron workload won't run per schedule
158
+ spec["defaultOptions"]["suspend"] = true
96
159
 
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
160
+ # Ensure no scaling
161
+ spec["defaultOptions"]["autoscaling"] = {}
162
+ spec["defaultOptions"]["capacityAI"] = false
101
163
 
102
- # Set runner
103
- container_spec["env"] ||= []
104
- container_spec["env"] << { "name" => "CONTROLPLANE_RUNNER", "value" => runner_script }
164
+ # Set cron job props
165
+ spec["type"] = "cron"
105
166
 
106
- if config.options["use_local_token"]
107
- container_spec["env"] << { "name" => "CONTROLPLANE_TOKEN",
108
- "value" => ControlplaneApiDirect.new.api_token[:token] }
167
+ # Next job set to run on January 1st, 2029
168
+ spec["job"] = { "schedule" => "0 0 1 1 1", "restartPolicy" => "Never" }
169
+
170
+ # Create runner workload
171
+ cp.apply_hash("kind" => "workload", "name" => runner_workload, "spec" => spec)
109
172
  end
173
+ end
174
+
175
+ def update_runner_workload # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
176
+ step("Updating runner workload '#{runner_workload}' based on '#{original_workload}'") do # rubocop:disable Metrics/BlockLength
177
+ @expected_deployed_version = cp.cron_workload_deployed_version(runner_workload)
178
+ should_update = false
179
+
180
+ _, original_container_spec = base_workload_specs(original_workload)
181
+ spec, container_spec = base_workload_specs(runner_workload)
110
182
 
111
- # Create workload clone
112
- cp.apply_hash("kind" => "workload", "name" => workload_clone, "spec" => spec)
183
+ # Override image if specified
184
+ image = config.options[:image]
185
+ image_link = if image
186
+ image = latest_image if image == "latest"
187
+ "/org/#{config.org}/image/#{image}"
188
+ else
189
+ original_container_spec["image"]
190
+ end
191
+ if container_spec["image"] != image_link
192
+ container_spec["image"] = image_link
193
+ should_update = true
194
+ end
195
+
196
+ # Container overrides
197
+ if config.options[:cpu] && container_spec["cpu"] != config.options[:cpu]
198
+ container_spec["cpu"] = config.options[:cpu]
199
+ should_update = true
200
+ end
201
+ if config.options[:memory] && container_spec["memory"] != config.options[:memory]
202
+ container_spec["memory"] = config.options[:memory]
203
+ should_update = true
204
+ end
205
+
206
+ next true unless should_update
207
+
208
+ # Update runner workload
209
+ @expected_deployed_version += 1
210
+ cp.apply_hash("kind" => "workload", "name" => runner_workload, "spec" => spec)
211
+ end
113
212
  end
114
213
 
115
- def runner_script # rubocop:disable Metrics/MethodLength
116
- script = Scripts.helpers_cleanup
214
+ def wait_for_runner_workload_create
215
+ step("Waiting for runner workload '#{runner_workload}' to be created", retry_on_failure: true) do
216
+ !cp.cron_workload_deployed_version(runner_workload).nil?
217
+ end
218
+ end
219
+
220
+ def wait_for_runner_workload_update
221
+ step("Waiting for runner workload '#{runner_workload}' to be updated", retry_on_failure: true) do
222
+ cp.cron_workload_deployed_version(runner_workload) >= expected_deployed_version
223
+ end
224
+ end
225
+
226
+ def start_job
227
+ job_start_yaml = build_job_start_yaml
228
+
229
+ step("Starting job for runner workload '#{runner_workload}'", retry_on_failure: true) do
230
+ result = cp.start_cron_workload(runner_workload, job_start_yaml, location: location)
231
+ @job = result&.dig("items", 0, "id")
232
+
233
+ job || false
234
+ end
235
+ end
236
+
237
+ def wait_for_replica_for_job
238
+ step("Waiting for replica to start, which runs job '#{job}'", retry_on_failure: true) do
239
+ result = cp.fetch_workload_replicas(runner_workload, location: location)
240
+ @replica = result["items"].find { |item| item.include?(job) }
241
+
242
+ replica || false
243
+ end
244
+ end
245
+
246
+ def run_interactive
247
+ progress.puts("Connecting to replica '#{replica}'...\n\n")
248
+ cp.workload_exec(runner_workload, replica, location: location, container: container, command: command)
249
+ end
117
250
 
118
- if config.options["use_local_token"]
119
- script += <<~SHELL
120
- CPLN_TOKEN=$CONTROLPLANE_TOKEN
121
- unset CONTROLPLANE_TOKEN
122
- SHELL
251
+ def run_non_interactive
252
+ if detached
253
+ print_detached_commands
254
+ exit(ExitCode::SUCCESS)
123
255
  end
124
256
 
257
+ case @log_method
258
+ when 1 then run_non_interactive_v1
259
+ when 2 then run_non_interactive_v2
260
+ when 3 then run_non_interactive_v3
261
+ else raise "Invalid log method: #{@log_method}"
262
+ end
263
+ end
264
+
265
+ def run_non_interactive_v1 # rubocop:disable Metrics/MethodLength
266
+ logs_pid = Process.fork do
267
+ # Catch Ctrl+C in the forked process
268
+ trap("SIGINT") do
269
+ exit(ExitCode::SUCCESS)
270
+ end
271
+
272
+ Cpl::Cli.start(["logs", *app_workload_replica_args])
273
+ end
274
+ Process.detach(logs_pid)
275
+
276
+ exit_status = wait_for_job_status
277
+
278
+ # We need to wait a bit for the logs to appear,
279
+ # otherwise it may exit without showing them
280
+ Kernel.sleep(30)
281
+
282
+ @internal_sigint = true
283
+ Process.kill("INT", logs_pid)
284
+ exit(exit_status)
285
+ end
286
+
287
+ def run_non_interactive_v2
288
+ current_cpl = File.expand_path("cpl", "#{__dir__}/../..")
289
+ logs_pipe = IO.popen([current_cpl, "logs", *app_workload_replica_args])
290
+
291
+ exit_status = wait_for_job_status_and_log(logs_pipe)
292
+
293
+ @internal_sigint = true
294
+ Process.kill("INT", logs_pipe.pid)
295
+ exit(exit_status)
296
+ end
297
+
298
+ def run_non_interactive_v3
299
+ exit(show_logs_waiting)
300
+ end
301
+
302
+ def base_workload_specs(workload)
303
+ spec = cp.fetch_workload!(workload).fetch("spec")
304
+ container_spec = spec["containers"].detect { _1["name"] == original_workload } || spec["containers"].first
305
+ @container = container_spec["name"]
306
+
307
+ [spec, container_spec]
308
+ end
309
+
310
+ def build_job_start_yaml # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
311
+ job_start_hash = { "name" => container }
312
+
313
+ if config.options[:use_local_token]
314
+ job_start_hash["env"] ||= []
315
+ job_start_hash["env"].push({ "name" => "CPL_TOKEN", "value" => ControlplaneApiDirect.new.api_token[:token] })
316
+ end
317
+
318
+ entrypoint = nil
319
+ if config.options[:entrypoint]
320
+ entrypoint = config.options[:entrypoint] == "none" ? "bash" : config.options[:entrypoint]
321
+ end
322
+
323
+ job_start_hash["command"] = entrypoint if entrypoint
324
+ job_start_hash["args"] ||= []
325
+ job_start_hash["args"].push("bash") unless entrypoint == "bash"
326
+ job_start_hash["args"].push("-c")
327
+ job_start_hash["env"] ||= []
328
+ job_start_hash["env"].push({ "name" => "CPL_RUNNER_SCRIPT", "value" => runner_script })
329
+ if interactive
330
+ job_start_hash["env"].push({ "name" => "CPL_MONITORING_SCRIPT", "value" => interactive_monitoring_script })
331
+
332
+ job_start_hash["args"].push('eval "$CPL_MONITORING_SCRIPT"')
333
+ @command = %(bash -c 'eval "$CPL_RUNNER_SCRIPT"')
334
+ else
335
+ job_start_hash["args"].push('eval "$CPL_RUNNER_SCRIPT"')
336
+ end
337
+
338
+ job_start_hash.to_yaml
339
+ end
340
+
341
+ def interactive_monitoring_script
342
+ <<~SCRIPT
343
+ primary_pid=""
344
+
345
+ check_primary() {
346
+ if ! kill -0 $primary_pid 2>/dev/null; then
347
+ echo "Primary process has exited. Shutting down."
348
+ exit 0
349
+ fi
350
+ }
351
+
352
+ while true; do
353
+ if [[ -z "$primary_pid" ]]; then
354
+ 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}')
355
+ if [[ ! -z "$primary_pid" ]]; then
356
+ echo "Primary process set with PID: $primary_pid"
357
+ fi
358
+ else
359
+ check_primary
360
+ fi
361
+
362
+ sleep 1
363
+ done
364
+ SCRIPT
365
+ end
366
+
367
+ def interactive_runner_script
368
+ script = ""
369
+
125
370
  # NOTE: fixes terminal size to match local terminal
126
371
  if config.current[:fix_terminal_size] || config.options[:terminal_size]
127
372
  if config.options[:terminal_size]
128
373
  rows, cols = config.options[:terminal_size].split(",")
129
374
  else
375
+ # NOTE: cannot use `Shell.cmd` here, as `stty size` has to run in a terminal environment
130
376
  rows, cols = `stty size`.split(/\s+/)
131
377
  end
132
- script += "stty rows #{rows}\nstty cols #{cols}\n" if rows && cols
378
+ script += "stty rows #{rows}\nstty cols #{cols}\n"
133
379
  end
134
380
 
135
- script += args_join(config.args)
136
381
  script
137
382
  end
138
383
 
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)
384
+ def runner_script # rubocop:disable Metrics/MethodLength
385
+ script = <<~SCRIPT
386
+ unset CPL_RUNNER_SCRIPT
387
+ unset CPL_MONITORING_SCRIPT
388
+
389
+ if [ -n "$CPL_TOKEN" ]; then
390
+ CPLN_TOKEN=$CPL_TOKEN
391
+ unset CPL_TOKEN
392
+ fi
393
+ SCRIPT
394
+
395
+ script += interactive_runner_script if interactive
396
+
397
+ script +=
398
+ if @log_method == 1
399
+ args_join(config.args)
400
+ else
401
+ <<~SCRIPT
402
+ ( #{args_join(config.args)} )
403
+ CPL_EXIT_CODE=$?
404
+ echo '#{MAGIC_END}'
405
+ exit $CPL_EXIT_CODE
406
+ SCRIPT
407
+ end
408
+
409
+ script
410
+ end
411
+
412
+ def wait_for_job_status
413
+ Kernel.sleep(1) until (exit_code = resolve_job_status)
414
+ exit_code
415
+ end
416
+
417
+ def wait_for_job_status_and_log(logs_pipe) # rubocop:disable Metrics/MethodLength
418
+ no_logs_counter = 0
419
+
420
+ loop do
421
+ no_logs_counter += 1
422
+ break if no_logs_counter > 60 # 30s
423
+ break if logs_pipe.eof?
424
+ next Kernel.sleep(0.5) unless logs_pipe.ready?
425
+
426
+ no_logs_counter = 0
427
+ line = logs_pipe.gets
428
+ break if line.chomp == MAGIC_END
429
+
430
+ puts(line)
431
+ end
432
+
433
+ resolve_job_status
434
+ end
435
+
436
+ def print_detached_commands
437
+ app_workload_replica_config = app_workload_replica_args.join(" ")
438
+ progress.puts(
439
+ "\n\n" \
440
+ "- To view logs from the job, run:\n `cpl logs #{app_workload_replica_config}`\n" \
441
+ "- To stop the job, run:\n `cpl ps:stop #{app_workload_replica_config}`\n"
442
+ )
443
+ end
444
+
445
+ def resolve_job_status
446
+ result = cp.fetch_cron_workload(runner_workload, location: location)
447
+ job_details = result&.dig("items")&.find { |item| item["id"] == job }
448
+ status = job_details&.dig("status")
449
+
450
+ case status
451
+ when "failed"
452
+ ExitCode::ERROR_DEFAULT
453
+ when "successful"
454
+ ExitCode::SUCCESS
455
+ end
456
+ end
457
+
458
+ ###########################################
459
+ ### temporary extaction from run:detached
460
+ ###########################################
461
+ def show_logs_waiting # rubocop:disable Metrics/MethodLength
462
+ retries = 0
463
+ begin
464
+ job_finished_count = 0
465
+ loop do
466
+ case print_uniq_logs
467
+ when :finished
468
+ break
469
+ when :changed
470
+ next
471
+ else
472
+ job_finished_count += 1 if resolve_job_status
473
+ break if job_finished_count > 5
474
+
475
+ sleep(1)
476
+ end
477
+ end
478
+
479
+ resolve_job_status
480
+ rescue RuntimeError => e
481
+ raise "#{e} Exiting..." unless retries < 10 # MAX_RETRIES
482
+
483
+ progress.puts(Shell.color("ERROR: #{e} Retrying...", :red))
484
+ retries += 1
485
+ retry
486
+ end
487
+ end
488
+
489
+ def print_uniq_logs
490
+ status = nil
491
+
492
+ @printed_log_entries ||= []
493
+ ts = Time.now.to_i
494
+ entries = normalized_log_entries(from: ts - 60, to: ts)
495
+
496
+ (entries - @printed_log_entries).sort.each do |(_ts, val)|
497
+ status ||= :changed
498
+ val.chomp == MAGIC_END ? status = :finished : progress.puts(val)
499
+ end
500
+
501
+ @printed_log_entries = entries # as well truncate old entries if any
502
+
503
+ status || :unchanged
504
+ end
505
+
506
+ def normalized_log_entries(from:, to:)
507
+ log = cp.log_get(workload: runner_workload, from: from, to: to, replica: replica)
508
+
509
+ log["data"]["result"]
510
+ .each_with_object([]) { |obj, result| result.concat(obj["values"]) }
511
+ .select { |ts, _val| ts[..-10].to_i > from }
143
512
  end
144
513
  end
145
514
  end