cpl 1.4.0 → 2.0.0.rc.0

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 +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