kamal 0.16.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 (57) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +1021 -0
  4. data/bin/kamal +18 -0
  5. data/lib/kamal/cli/accessory.rb +239 -0
  6. data/lib/kamal/cli/app.rb +296 -0
  7. data/lib/kamal/cli/base.rb +171 -0
  8. data/lib/kamal/cli/build.rb +106 -0
  9. data/lib/kamal/cli/healthcheck.rb +20 -0
  10. data/lib/kamal/cli/lock.rb +37 -0
  11. data/lib/kamal/cli/main.rb +249 -0
  12. data/lib/kamal/cli/prune.rb +30 -0
  13. data/lib/kamal/cli/registry.rb +18 -0
  14. data/lib/kamal/cli/server.rb +21 -0
  15. data/lib/kamal/cli/templates/deploy.yml +74 -0
  16. data/lib/kamal/cli/templates/sample_hooks/post-deploy.sample +14 -0
  17. data/lib/kamal/cli/templates/sample_hooks/pre-build.sample +51 -0
  18. data/lib/kamal/cli/templates/sample_hooks/pre-connect.sample +47 -0
  19. data/lib/kamal/cli/templates/sample_hooks/pre-deploy.sample +109 -0
  20. data/lib/kamal/cli/templates/template.env +2 -0
  21. data/lib/kamal/cli/traefik.rb +111 -0
  22. data/lib/kamal/cli.rb +7 -0
  23. data/lib/kamal/commander.rb +154 -0
  24. data/lib/kamal/commands/accessory.rb +113 -0
  25. data/lib/kamal/commands/app.rb +175 -0
  26. data/lib/kamal/commands/auditor.rb +28 -0
  27. data/lib/kamal/commands/base.rb +65 -0
  28. data/lib/kamal/commands/builder/base.rb +60 -0
  29. data/lib/kamal/commands/builder/multiarch/remote.rb +51 -0
  30. data/lib/kamal/commands/builder/multiarch.rb +29 -0
  31. data/lib/kamal/commands/builder/native/cached.rb +16 -0
  32. data/lib/kamal/commands/builder/native/remote.rb +59 -0
  33. data/lib/kamal/commands/builder/native.rb +20 -0
  34. data/lib/kamal/commands/builder.rb +62 -0
  35. data/lib/kamal/commands/docker.rb +21 -0
  36. data/lib/kamal/commands/healthcheck.rb +57 -0
  37. data/lib/kamal/commands/hook.rb +14 -0
  38. data/lib/kamal/commands/lock.rb +63 -0
  39. data/lib/kamal/commands/prune.rb +38 -0
  40. data/lib/kamal/commands/registry.rb +20 -0
  41. data/lib/kamal/commands/traefik.rb +104 -0
  42. data/lib/kamal/commands.rb +2 -0
  43. data/lib/kamal/configuration/accessory.rb +169 -0
  44. data/lib/kamal/configuration/boot.rb +20 -0
  45. data/lib/kamal/configuration/builder.rb +114 -0
  46. data/lib/kamal/configuration/role.rb +155 -0
  47. data/lib/kamal/configuration/ssh.rb +38 -0
  48. data/lib/kamal/configuration/sshkit.rb +20 -0
  49. data/lib/kamal/configuration.rb +251 -0
  50. data/lib/kamal/sshkit_with_ext.rb +104 -0
  51. data/lib/kamal/tags.rb +39 -0
  52. data/lib/kamal/utils/healthcheck_poller.rb +39 -0
  53. data/lib/kamal/utils/sensitive.rb +19 -0
  54. data/lib/kamal/utils.rb +100 -0
  55. data/lib/kamal/version.rb +3 -0
  56. data/lib/kamal.rb +10 -0
  57. metadata +266 -0
@@ -0,0 +1,63 @@
1
+ require "active_support/duration"
2
+ require "time"
3
+
4
+ class Kamal::Commands::Lock < Kamal::Commands::Base
5
+ def acquire(message, version)
6
+ combine \
7
+ [:mkdir, lock_dir],
8
+ write_lock_details(message, version)
9
+ end
10
+
11
+ def release
12
+ combine \
13
+ [:rm, lock_details_file],
14
+ [:rm, "-r", lock_dir]
15
+ end
16
+
17
+ def status
18
+ combine \
19
+ stat_lock_dir,
20
+ read_lock_details
21
+ end
22
+
23
+ private
24
+ def write_lock_details(message, version)
25
+ write \
26
+ [:echo, "\"#{Base64.encode64(lock_details(message, version))}\""],
27
+ lock_details_file
28
+ end
29
+
30
+ def read_lock_details
31
+ pipe \
32
+ [:cat, lock_details_file],
33
+ [:base64, "-d"]
34
+ end
35
+
36
+ def stat_lock_dir
37
+ write \
38
+ [:stat, lock_dir],
39
+ "/dev/null"
40
+ end
41
+
42
+ def lock_dir
43
+ "kamal_lock-#{config.service}"
44
+ end
45
+
46
+ def lock_details_file
47
+ [lock_dir, :details].join("/")
48
+ end
49
+
50
+ def lock_details(message, version)
51
+ <<~DETAILS.strip
52
+ Locked by: #{locked_by} at #{Time.now.utc.iso8601}
53
+ Version: #{version}
54
+ Message: #{message}
55
+ DETAILS
56
+ end
57
+
58
+ def locked_by
59
+ `git config user.name`.strip
60
+ rescue Errno::ENOENT
61
+ "Unknown"
62
+ end
63
+ end
@@ -0,0 +1,38 @@
1
+ require "active_support/duration"
2
+ require "active_support/core_ext/numeric/time"
3
+
4
+ class Kamal::Commands::Prune < Kamal::Commands::Base
5
+ def dangling_images
6
+ docker :image, :prune, "--force", "--filter", "label=service=#{config.service}", "--filter", "dangling=true"
7
+ end
8
+
9
+ def tagged_images
10
+ pipe \
11
+ docker(:image, :ls, *service_filter, "--format", "'{{.ID}} {{.Repository}}:{{.Tag}}'"),
12
+ "grep -v -w \"#{active_image_list}\"",
13
+ "while read image tag; do docker rmi $tag; done"
14
+ end
15
+
16
+ def containers(keep_last: 5)
17
+ pipe \
18
+ docker(:ps, "-q", "-a", *service_filter, *stopped_containers_filters),
19
+ "tail -n +#{keep_last + 1}",
20
+ "while read container_id; do docker rm $container_id; done"
21
+ end
22
+
23
+ private
24
+ def stopped_containers_filters
25
+ [ "created", "exited", "dead" ].flat_map { |status| ["--filter", "status=#{status}"] }
26
+ end
27
+
28
+ def active_image_list
29
+ # Pull the images that are used by any containers
30
+ # Append repo:latest - to avoid deleting the latest tag
31
+ # Append repo:<none> - to avoid deleting dangling images that are in use. Unused dangling images are deleted separately
32
+ "$(docker container ls -a --format '{{.Image}}\\|' --filter label=service=#{config.service} | tr -d '\\n')#{config.latest_image}\\|#{config.repository}:<none>"
33
+ end
34
+
35
+ def service_filter
36
+ [ "--filter", "label=service=#{config.service}" ]
37
+ end
38
+ end
@@ -0,0 +1,20 @@
1
+ class Kamal::Commands::Registry < Kamal::Commands::Base
2
+ delegate :registry, to: :config
3
+
4
+ def login
5
+ docker :login, registry["server"], "-u", sensitive(lookup("username")), "-p", sensitive(lookup("password"))
6
+ end
7
+
8
+ def logout
9
+ docker :logout, registry["server"]
10
+ end
11
+
12
+ private
13
+ def lookup(key)
14
+ if registry[key].is_a?(Array)
15
+ ENV.fetch(registry[key].first).dup
16
+ else
17
+ registry[key]
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,104 @@
1
+ class Kamal::Commands::Traefik < Kamal::Commands::Base
2
+ delegate :argumentize, :argumentize_env_with_secrets, :optionize, to: Kamal::Utils
3
+
4
+ DEFAULT_IMAGE = "traefik:v2.9"
5
+ CONTAINER_PORT = 80
6
+ DEFAULT_ARGS = {
7
+ 'log.level' => 'DEBUG'
8
+ }
9
+
10
+ def run
11
+ docker :run, "--name traefik",
12
+ "--detach",
13
+ "--restart", "unless-stopped",
14
+ "--publish", port,
15
+ "--volume", "/var/run/docker.sock:/var/run/docker.sock",
16
+ *env_args,
17
+ *config.logging_args,
18
+ *label_args,
19
+ *docker_options_args,
20
+ image,
21
+ "--providers.docker",
22
+ *cmd_option_args
23
+ end
24
+
25
+ def start
26
+ docker :container, :start, "traefik"
27
+ end
28
+
29
+ def stop
30
+ docker :container, :stop, "traefik"
31
+ end
32
+
33
+ def start_or_run
34
+ combine start, run, by: "||"
35
+ end
36
+
37
+ def info
38
+ docker :ps, "--filter", "name=^traefik$"
39
+ end
40
+
41
+ def logs(since: nil, lines: nil, grep: nil)
42
+ pipe \
43
+ docker(:logs, "traefik", (" --since #{since}" if since), (" --tail #{lines}" if lines), "--timestamps", "2>&1"),
44
+ ("grep '#{grep}'" if grep)
45
+ end
46
+
47
+ def follow_logs(host:, grep: nil)
48
+ run_over_ssh pipe(
49
+ docker(:logs, "traefik", "--timestamps", "--tail", "10", "--follow", "2>&1"),
50
+ (%(grep "#{grep}") if grep)
51
+ ).join(" "), host: host
52
+ end
53
+
54
+ def remove_container
55
+ docker :container, :prune, "--force", "--filter", "label=org.opencontainers.image.title=Traefik"
56
+ end
57
+
58
+ def remove_image
59
+ docker :image, :prune, "--all", "--force", "--filter", "label=org.opencontainers.image.title=Traefik"
60
+ end
61
+
62
+ def port
63
+ "#{host_port}:#{CONTAINER_PORT}"
64
+ end
65
+
66
+ private
67
+ def label_args
68
+ argumentize "--label", labels
69
+ end
70
+
71
+ def env_args
72
+ env_config = config.traefik["env"] || {}
73
+
74
+ if env_config.present?
75
+ argumentize_env_with_secrets(env_config)
76
+ else
77
+ []
78
+ end
79
+ end
80
+
81
+ def labels
82
+ config.traefik["labels"] || []
83
+ end
84
+
85
+ def image
86
+ config.traefik.fetch("image") { DEFAULT_IMAGE }
87
+ end
88
+
89
+ def docker_options_args
90
+ optionize(config.traefik["options"] || {})
91
+ end
92
+
93
+ def cmd_option_args
94
+ if args = config.traefik["args"]
95
+ optionize DEFAULT_ARGS.merge(args), with: "="
96
+ else
97
+ optionize DEFAULT_ARGS, with: "="
98
+ end
99
+ end
100
+
101
+ def host_port
102
+ config.traefik["host_port"] || CONTAINER_PORT
103
+ end
104
+ end
@@ -0,0 +1,2 @@
1
+ module Kamal::Commands
2
+ end
@@ -0,0 +1,169 @@
1
+ class Kamal::Configuration::Accessory
2
+ delegate :argumentize, :argumentize_env_with_secrets, :optionize, to: Kamal::Utils
3
+
4
+ attr_accessor :name, :specifics
5
+
6
+ def initialize(name, config:)
7
+ @name, @config, @specifics = name.inquiry, config, config.raw_config["accessories"][name]
8
+ end
9
+
10
+ def service_name
11
+ "#{config.service}-#{name}"
12
+ end
13
+
14
+ def image
15
+ specifics["image"]
16
+ end
17
+
18
+ def hosts
19
+ if (specifics.keys & ["host", "hosts", "roles"]).size != 1
20
+ raise ArgumentError, "Specify one of `host`, `hosts` or `roles` for accessory `#{name}`"
21
+ end
22
+
23
+ hosts_from_host || hosts_from_hosts || hosts_from_roles
24
+ end
25
+
26
+ def port
27
+ if port = specifics["port"]&.to_s
28
+ port.include?(":") ? port : "#{port}:#{port}"
29
+ end
30
+ end
31
+
32
+ def publish_args
33
+ argumentize "--publish", port if port
34
+ end
35
+
36
+ def labels
37
+ default_labels.merge(specifics["labels"] || {})
38
+ end
39
+
40
+ def label_args
41
+ argumentize "--label", labels
42
+ end
43
+
44
+ def env
45
+ specifics["env"] || {}
46
+ end
47
+
48
+ def env_args
49
+ argumentize_env_with_secrets env
50
+ end
51
+
52
+ def files
53
+ specifics["files"]&.to_h do |local_to_remote_mapping|
54
+ local_file, remote_file = local_to_remote_mapping.split(":")
55
+ [ expand_local_file(local_file), expand_remote_file(remote_file) ]
56
+ end || {}
57
+ end
58
+
59
+ def directories
60
+ specifics["directories"]&.to_h do |host_to_container_mapping|
61
+ host_relative_path, container_path = host_to_container_mapping.split(":")
62
+ [ expand_host_path(host_relative_path), container_path ]
63
+ end || {}
64
+ end
65
+
66
+ def volumes
67
+ specific_volumes + remote_files_as_volumes + remote_directories_as_volumes
68
+ end
69
+
70
+ def volume_args
71
+ argumentize "--volume", volumes
72
+ end
73
+
74
+ def option_args
75
+ if args = specifics["options"]
76
+ optionize args
77
+ else
78
+ []
79
+ end
80
+ end
81
+
82
+ def cmd
83
+ specifics["cmd"]
84
+ end
85
+
86
+ private
87
+ attr_accessor :config
88
+
89
+ def default_labels
90
+ { "service" => service_name }
91
+ end
92
+
93
+ def expand_local_file(local_file)
94
+ if local_file.end_with?("erb")
95
+ with_clear_env_loaded { read_dynamic_file(local_file) }
96
+ else
97
+ Pathname.new(File.expand_path(local_file)).to_s
98
+ end
99
+ end
100
+
101
+ def with_clear_env_loaded
102
+ (env["clear"] || env).each { |k, v| ENV[k] = v }
103
+ yield
104
+ ensure
105
+ (env["clear"] || env).each { |k, v| ENV.delete(k) }
106
+ end
107
+
108
+ def read_dynamic_file(local_file)
109
+ StringIO.new(ERB.new(IO.read(local_file)).result)
110
+ end
111
+
112
+ def expand_remote_file(remote_file)
113
+ service_name + remote_file
114
+ end
115
+
116
+ def specific_volumes
117
+ specifics["volumes"] || []
118
+ end
119
+
120
+ def remote_files_as_volumes
121
+ specifics["files"]&.collect do |local_to_remote_mapping|
122
+ _, remote_file = local_to_remote_mapping.split(":")
123
+ "#{service_data_directory + remote_file}:#{remote_file}"
124
+ end || []
125
+ end
126
+
127
+ def remote_directories_as_volumes
128
+ specifics["directories"]&.collect do |host_to_container_mapping|
129
+ host_relative_path, container_path = host_to_container_mapping.split(":")
130
+ [ expand_host_path(host_relative_path), container_path ].join(":")
131
+ end || []
132
+ end
133
+
134
+ def expand_host_path(host_relative_path)
135
+ "#{service_data_directory}/#{host_relative_path}"
136
+ end
137
+
138
+ def service_data_directory
139
+ "$PWD/#{service_name}"
140
+ end
141
+
142
+ def hosts_from_host
143
+ if specifics.key?("host")
144
+ host = specifics["host"]
145
+ if host
146
+ [host]
147
+ else
148
+ raise ArgumentError, "Missing host for accessory `#{name}`"
149
+ end
150
+ end
151
+ end
152
+
153
+ def hosts_from_hosts
154
+ if specifics.key?("hosts")
155
+ hosts = specifics["hosts"]
156
+ if hosts.is_a?(Array)
157
+ hosts
158
+ else
159
+ raise ArgumentError, "Hosts should be an Array for accessory `#{name}`"
160
+ end
161
+ end
162
+ end
163
+
164
+ def hosts_from_roles
165
+ if specifics.key?("roles")
166
+ specifics["roles"].flat_map { |role| config.role(role).hosts }
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,20 @@
1
+ class Kamal::Configuration::Boot
2
+ def initialize(config:)
3
+ @options = config.raw_config.boot || {}
4
+ @host_count = config.all_hosts.count
5
+ end
6
+
7
+ def limit
8
+ limit = @options["limit"]
9
+
10
+ if limit.to_s.end_with?("%")
11
+ @host_count * limit.to_i / 100
12
+ else
13
+ limit
14
+ end
15
+ end
16
+
17
+ def wait
18
+ @options["wait"]
19
+ end
20
+ end
@@ -0,0 +1,114 @@
1
+ class Kamal::Configuration::Builder
2
+ def initialize(config:)
3
+ @options = config.raw_config.builder || {}
4
+ @image = config.image
5
+ @server = config.registry["server"]
6
+
7
+ valid?
8
+ end
9
+
10
+ def to_h
11
+ @options
12
+ end
13
+
14
+ def multiarch?
15
+ @options["multiarch"] != false
16
+ end
17
+
18
+ def local?
19
+ !!@options["local"]
20
+ end
21
+
22
+ def remote?
23
+ !!@options["remote"]
24
+ end
25
+
26
+ def cached?
27
+ !!@options["cache"]
28
+ end
29
+
30
+ def args
31
+ @options["args"] || {}
32
+ end
33
+
34
+ def secrets
35
+ @options["secrets"] || []
36
+ end
37
+
38
+ def dockerfile
39
+ @options["dockerfile"] || "Dockerfile"
40
+ end
41
+
42
+ def context
43
+ @options["context"] || "."
44
+ end
45
+
46
+ def local_arch
47
+ @options["local"]["arch"] if local?
48
+ end
49
+
50
+ def local_host
51
+ @options["local"]["host"] if local?
52
+ end
53
+
54
+ def remote_arch
55
+ @options["remote"]["arch"] if remote?
56
+ end
57
+
58
+ def remote_host
59
+ @options["remote"]["host"] if remote?
60
+ end
61
+
62
+ def cache_from
63
+ if cached?
64
+ case @options["cache"]["type"]
65
+ when "gha"
66
+ cache_from_config_for_gha
67
+ when "registry"
68
+ cache_from_config_for_registry
69
+ end
70
+ end
71
+ end
72
+
73
+ def cache_to
74
+ if cached?
75
+ case @options["cache"]["type"]
76
+ when "gha"
77
+ cache_to_config_for_gha
78
+ when "registry"
79
+ cache_to_config_for_registry
80
+ end
81
+ end
82
+ end
83
+
84
+ private
85
+ def valid?
86
+ if @options["cache"] && @options["cache"]["type"]
87
+ raise ArgumentError, "Invalid cache type: #{@options["cache"]["type"]}" unless ["gha", "registry"].include?(@options["cache"]["type"])
88
+ end
89
+ end
90
+
91
+ def cache_image
92
+ @options["cache"]&.fetch("image", nil) || "#{@image}-build-cache"
93
+ end
94
+
95
+ def cache_image_ref
96
+ [ @server, cache_image ].compact.join("/")
97
+ end
98
+
99
+ def cache_from_config_for_gha
100
+ "type=gha"
101
+ end
102
+
103
+ def cache_from_config_for_registry
104
+ [ "type=registry", "ref=#{cache_image_ref}" ].compact.join(",")
105
+ end
106
+
107
+ def cache_to_config_for_gha
108
+ [ "type=gha", @options["cache"]&.fetch("options", nil)].compact.join(",")
109
+ end
110
+
111
+ def cache_to_config_for_registry
112
+ [ "type=registry", @options["cache"]&.fetch("options", nil), "ref=#{cache_image_ref}" ].compact.join(",")
113
+ end
114
+ end
@@ -0,0 +1,155 @@
1
+ class Kamal::Configuration::Role
2
+ delegate :argumentize, :argumentize_env_with_secrets, :optionize, to: Kamal::Utils
3
+
4
+ attr_accessor :name
5
+
6
+ def initialize(name, config:)
7
+ @name, @config = name.inquiry, config
8
+ end
9
+
10
+ def primary_host
11
+ hosts.first
12
+ end
13
+
14
+ def hosts
15
+ @hosts ||= extract_hosts_from_config
16
+ end
17
+
18
+ def labels
19
+ default_labels.merge(traefik_labels).merge(custom_labels)
20
+ end
21
+
22
+ def label_args
23
+ argumentize "--label", labels
24
+ end
25
+
26
+ def env
27
+ if config.env && config.env["secret"]
28
+ merged_env_with_secrets
29
+ else
30
+ merged_env
31
+ end
32
+ end
33
+
34
+ def env_args
35
+ argumentize_env_with_secrets env
36
+ end
37
+
38
+ def health_check_args
39
+ if health_check_cmd.present?
40
+ optionize({ "health-cmd" => health_check_cmd, "health-interval" => health_check_interval })
41
+ else
42
+ []
43
+ end
44
+ end
45
+
46
+ def health_check_cmd
47
+ options = specializations["healthcheck"] || {}
48
+ options = config.healthcheck.merge(options) if running_traefik?
49
+
50
+ options["cmd"] || http_health_check(port: options["port"], path: options["path"])
51
+ end
52
+
53
+ def health_check_interval
54
+ options = specializations["healthcheck"] || {}
55
+ options = config.healthcheck.merge(options) if running_traefik?
56
+
57
+ options["interval"] || "1s"
58
+ end
59
+
60
+ def cmd
61
+ specializations["cmd"]
62
+ end
63
+
64
+ def option_args
65
+ if args = specializations["options"]
66
+ optionize args
67
+ else
68
+ []
69
+ end
70
+ end
71
+
72
+ def running_traefik?
73
+ name.web? || specializations["traefik"]
74
+ end
75
+
76
+ private
77
+ attr_accessor :config
78
+
79
+ def extract_hosts_from_config
80
+ if config.servers.is_a?(Array)
81
+ config.servers
82
+ else
83
+ servers = config.servers[name]
84
+ servers.is_a?(Array) ? servers : Array(servers["hosts"])
85
+ end
86
+ end
87
+
88
+ def default_labels
89
+ if config.destination
90
+ { "service" => config.service, "role" => name, "destination" => config.destination }
91
+ else
92
+ { "service" => config.service, "role" => name }
93
+ end
94
+ end
95
+
96
+ def traefik_labels
97
+ if running_traefik?
98
+ {
99
+ # Setting a service property ensures that the generated service name will be consistent between versions
100
+ "traefik.http.services.#{traefik_service}.loadbalancer.server.scheme" => "http",
101
+
102
+ "traefik.http.routers.#{traefik_service}.rule" => "PathPrefix(`/`)",
103
+ "traefik.http.middlewares.#{traefik_service}-retry.retry.attempts" => "5",
104
+ "traefik.http.middlewares.#{traefik_service}-retry.retry.initialinterval" => "500ms",
105
+ "traefik.http.routers.#{traefik_service}.middlewares" => "#{traefik_service}-retry@docker"
106
+ }
107
+ else
108
+ {}
109
+ end
110
+ end
111
+
112
+ def traefik_service
113
+ [ config.service, name, config.destination ].compact.join("-")
114
+ end
115
+
116
+ def custom_labels
117
+ Hash.new.tap do |labels|
118
+ labels.merge!(config.labels) if config.labels.present?
119
+ labels.merge!(specializations["labels"]) if specializations["labels"].present?
120
+ end
121
+ end
122
+
123
+ def specializations
124
+ if config.servers.is_a?(Array) || config.servers[name].is_a?(Array)
125
+ { }
126
+ else
127
+ config.servers[name].except("hosts")
128
+ end
129
+ end
130
+
131
+ def specialized_env
132
+ specializations["env"] || {}
133
+ end
134
+
135
+ def merged_env
136
+ config.env&.merge(specialized_env) || {}
137
+ end
138
+
139
+ # Secrets are stored in an array, which won't merge by default, so have to do it by hand.
140
+ def merged_env_with_secrets
141
+ merged_env.tap do |new_env|
142
+ new_env["secret"] = Array(config.env["secret"]) + Array(specialized_env["secret"])
143
+
144
+ # If there's no secret/clear split, everything is clear
145
+ clear_app_env = config.env["secret"] ? Array(config.env["clear"]) : Array(config.env["clear"] || config.env)
146
+ clear_role_env = specialized_env["secret"] ? Array(specialized_env["clear"]) : Array(specialized_env["clear"] || specialized_env)
147
+
148
+ new_env["clear"] = (clear_app_env + clear_role_env).uniq
149
+ end
150
+ end
151
+
152
+ def http_health_check(port:, path:)
153
+ "curl -f #{URI.join("http://localhost:#{port}", path)} || exit 1" if path.present? || port.present?
154
+ end
155
+ end