kamal 2.6.0 → 2.7.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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/lib/kamal/cli/accessory.rb +2 -2
  3. data/lib/kamal/cli/app/ssl_certificates.rb +28 -0
  4. data/lib/kamal/cli/app.rb +1 -0
  5. data/lib/kamal/cli/build.rb +4 -0
  6. data/lib/kamal/cli/proxy.rb +0 -12
  7. data/lib/kamal/cli/templates/sample_hooks/pre-deploy.sample +13 -1
  8. data/lib/kamal/commander/specifics.rb +6 -2
  9. data/lib/kamal/commands/accessory.rb +4 -3
  10. data/lib/kamal/commands/app/execution.rb +2 -2
  11. data/lib/kamal/commands/app/proxy.rb +4 -0
  12. data/lib/kamal/commands/app.rb +3 -2
  13. data/lib/kamal/commands/base.rb +8 -0
  14. data/lib/kamal/commands/builder/base.rb +1 -0
  15. data/lib/kamal/commands/builder/pack.rb +46 -0
  16. data/lib/kamal/commands/builder.rb +7 -1
  17. data/lib/kamal/configuration/accessory.rb +2 -1
  18. data/lib/kamal/configuration/builder.rb +12 -0
  19. data/lib/kamal/configuration/docs/builder.yml +13 -0
  20. data/lib/kamal/configuration/docs/proxy.yml +59 -6
  21. data/lib/kamal/configuration/proxy/boot.rb +8 -0
  22. data/lib/kamal/configuration/proxy.rb +49 -4
  23. data/lib/kamal/configuration/role.rb +5 -3
  24. data/lib/kamal/configuration/validator/accessory.rb +2 -0
  25. data/lib/kamal/configuration/validator/builder.rb +2 -0
  26. data/lib/kamal/configuration/validator/proxy.rb +10 -0
  27. data/lib/kamal/configuration/validator/role.rb +1 -0
  28. data/lib/kamal/configuration/validator.rb +15 -1
  29. data/lib/kamal/configuration.rb +1 -1
  30. data/lib/kamal/secrets/adapters/bitwarden_secrets_manager.rb +10 -16
  31. data/lib/kamal/secrets/adapters/one_password.rb +45 -11
  32. data/lib/kamal/secrets/adapters/passbolt.rb +130 -0
  33. data/lib/kamal/version.rb +1 -1
  34. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3b89316447c79f85f3be2a7b1592d32e19350571b81f7438aab1ffd7602da86
4
- data.tar.gz: '065904d4073e24495c27b0f32d468b384a93ac3bfb0c9303cf9ceb060d7d68cc'
3
+ metadata.gz: 4e1cf57d731a8b129a8ccbb86faddd3e813bc4d17895e6e538fe904f5bb65d27
4
+ data.tar.gz: 2d7d81b3a34f42fb427bfed18f9cf0ed1955d38e4b4783c1407bc1db6de5cef6
5
5
  SHA512:
6
- metadata.gz: 58e4102f80bb090dbe76f325429b8e74fcf689f92fcf18ffe6ca46b6f67f2acd0ba93adee9411c4790e71f29b5989d7e3a3f7ec26a08bdab7d679e339dbd3cae
7
- data.tar.gz: 10038a978ad0755cdd25c732bd5886b9c6d59f80292041eaa8449cbaceaa2f8e9f61d1c3775c8eeb9a8a8d772046497f0b84016e0c8f7ab61b7907616182c617
6
+ metadata.gz: f3144c40082cfa24c78e2a1ebf2f433e491be3f5966e45cfc5488c3a11f5a3f513f097f7818cbc9e34d20b79986e64212055a87030fd4aa386a1d82bbb7d0efe
7
+ data.tar.gz: c6b796497a6f7a68815d340664b34fb9943f55ea7121122994aa3fdadaa5b12a18fb4078ff194cffb6060917a74611fdf31017afa75f2515d94ec259e0809251
@@ -24,11 +24,11 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
24
24
  directories(name)
25
25
  upload(name)
26
26
 
27
- on(hosts) do
27
+ on(hosts) do |host|
28
28
  execute *KAMAL.auditor.record("Booted #{name} accessory"), verbosity: :debug
29
29
  execute *accessory.ensure_env_directory
30
30
  upload! accessory.secrets_io, accessory.secrets_path, mode: "0600"
31
- execute *accessory.run
31
+ execute *accessory.run(host: host)
32
32
 
33
33
  if accessory.running_proxy?
34
34
  target = capture_with_info(*accessory.container_id_for(container_name: accessory.service_name, only_running: true)).strip
@@ -0,0 +1,28 @@
1
+ class Kamal::Cli::App::SslCertificates
2
+ attr_reader :host, :role, :sshkit
3
+ delegate :execute, :info, :upload!, to: :sshkit
4
+
5
+ def initialize(host, role, sshkit)
6
+ @host = host
7
+ @role = role
8
+ @sshkit = sshkit
9
+ end
10
+
11
+ def run
12
+ if role.running_proxy? && role.proxy.custom_ssl_certificate?
13
+ info "Writing SSL certificates for #{role.name} on #{host}"
14
+ execute *app.create_ssl_directory
15
+ if cert_content = role.proxy.certificate_pem_content
16
+ upload!(StringIO.new(cert_content), role.proxy.host_tls_cert, mode: "0644")
17
+ end
18
+ if key_content = role.proxy.private_key_pem_content
19
+ upload!(StringIO.new(key_content), role.proxy.host_tls_key, mode: "0644")
20
+ end
21
+ end
22
+ end
23
+
24
+ private
25
+ def app
26
+ @app ||= KAMAL.app(role: role, host: host)
27
+ end
28
+ end
data/lib/kamal/cli/app.rb CHANGED
@@ -12,6 +12,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
12
12
 
13
13
  KAMAL.roles_on(host).each do |role|
14
14
  Kamal::Cli::App::Assets.new(host, role, self).run
15
+ Kamal::Cli::App::SslCertificates.new(host, role, self).run
15
16
  end
16
17
  end
17
18
 
@@ -14,6 +14,10 @@ class Kamal::Cli::Build < Kamal::Cli::Base
14
14
  def push
15
15
  cli = self
16
16
 
17
+ # Ensure pre-connect hooks run before the build, they may needed for a remote builder
18
+ # or the pre-build hooks.
19
+ pre_connect_if_required
20
+
17
21
  ensure_docker_installed
18
22
  login_to_registry_locally
19
23
 
@@ -120,18 +120,6 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
120
120
  execute *KAMAL.proxy.ensure_apps_config_directory
121
121
 
122
122
  execute *KAMAL.proxy.run
123
-
124
- KAMAL.roles_on(host).select(&:running_proxy?).each do |role|
125
- app = KAMAL.app(role: role, host: host)
126
-
127
- version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip
128
- endpoint = capture_with_info(*app.container_id_for_version(version)).strip
129
-
130
- if endpoint.present?
131
- info "Deploying #{endpoint} for role `#{role}` on #{host}..."
132
- execute *app.deploy(target: endpoint)
133
- end
134
- end
135
123
  end
136
124
  run_hook "post-proxy-reboot", hosts: host_list
137
125
  end
@@ -43,7 +43,7 @@ class GithubStatusChecks
43
43
  attr_reader :remote_url, :git_sha, :github_client, :combined_status
44
44
 
45
45
  def initialize
46
- @remote_url = `git config --get remote.origin.url`.strip.delete_prefix("https://github.com/")
46
+ @remote_url = github_repo_from_remote_url
47
47
  @git_sha = `git rev-parse HEAD`.strip
48
48
  @github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"])
49
49
  refresh!
@@ -77,6 +77,18 @@ class GithubStatusChecks
77
77
  "Build not started..."
78
78
  end
79
79
  end
80
+
81
+ private
82
+ def github_repo_from_remote_url
83
+ url = `git config --get remote.origin.url`.strip.delete_suffix(".git")
84
+ if url.start_with?("https://github.com/")
85
+ url.delete_prefix("https://github.com/")
86
+ elsif url.start_with?("git@github.com:")
87
+ url.delete_prefix("git@github.com:")
88
+ else
89
+ url
90
+ end
91
+ end
80
92
  end
81
93
 
82
94
 
@@ -11,7 +11,7 @@ class Kamal::Commander::Specifics
11
11
  @primary_role = primary_or_first_role(roles_on(primary_host))
12
12
 
13
13
  stable_sort!(roles) { |role| role == primary_role ? 0 : 1 }
14
- stable_sort!(hosts) { |host| roles_on(host).any? { |role| role == primary_role } ? 0 : 1 }
14
+ sort_primary_role_hosts_first!(hosts)
15
15
  end
16
16
 
17
17
  def roles_on(host)
@@ -19,7 +19,7 @@ class Kamal::Commander::Specifics
19
19
  end
20
20
 
21
21
  def app_hosts
22
- config.app_hosts & specified_hosts
22
+ @app_hosts ||= sort_primary_role_hosts_first!(config.app_hosts & specified_hosts)
23
23
  end
24
24
 
25
25
  def proxy_hosts
@@ -55,4 +55,8 @@ class Kamal::Commander::Specifics
55
55
  specified_hosts
56
56
  end
57
57
  end
58
+
59
+ def sort_primary_role_hosts_first!(hosts)
60
+ stable_sort!(hosts) { |host| roles_on(host).any? { |role| role == primary_role } ? 0 : 1 }
61
+ end
58
62
  end
@@ -12,7 +12,7 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base
12
12
  @accessory_config = config.accessory(name)
13
13
  end
14
14
 
15
- def run
15
+ def run(host: nil)
16
16
  docker :run,
17
17
  "--name", service_name,
18
18
  "--detach",
@@ -20,6 +20,7 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base
20
20
  *network_args,
21
21
  *config.logging_args,
22
22
  *publish_args,
23
+ *([ "--env", "KAMAL_HOST=\"#{host}\"" ] if host),
23
24
  *env_args,
24
25
  *volume_args,
25
26
  *label_args,
@@ -55,14 +56,14 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base
55
56
 
56
57
  def execute_in_existing_container(*command, interactive: false)
57
58
  docker :exec,
58
- ("-it" if interactive),
59
+ (docker_interactive_args if interactive),
59
60
  service_name,
60
61
  *command
61
62
  end
62
63
 
63
64
  def execute_in_new_container(*command, interactive: false)
64
65
  docker :run,
65
- ("-it" if interactive),
66
+ (docker_interactive_args if interactive),
66
67
  "--rm",
67
68
  *network_args,
68
69
  *env_args,
@@ -1,7 +1,7 @@
1
1
  module Kamal::Commands::App::Execution
2
2
  def execute_in_existing_container(*command, interactive: false, env:)
3
3
  docker :exec,
4
- ("-it" if interactive),
4
+ (docker_interactive_args if interactive),
5
5
  *argumentize("--env", env),
6
6
  container_name,
7
7
  *command
@@ -9,7 +9,7 @@ module Kamal::Commands::App::Execution
9
9
 
10
10
  def execute_in_new_container(*command, interactive: false, detach: false, env:)
11
11
  docker :run,
12
- ("-it" if interactive),
12
+ (docker_interactive_args if interactive),
13
13
  ("--detach" if detach),
14
14
  ("--rm" unless detach),
15
15
  "--network", "kamal",
@@ -21,6 +21,10 @@ module Kamal::Commands::App::Proxy
21
21
  remove_directory config.proxy_boot.app_directory
22
22
  end
23
23
 
24
+ def create_ssl_directory
25
+ make_directory(File.join(config.proxy_boot.tls_directory, role.name))
26
+ end
27
+
24
28
  private
25
29
  def proxy_exec(*command)
26
30
  docker :exec, proxy_container_name, "kamal-proxy", *command
@@ -20,8 +20,9 @@ class Kamal::Commands::App < Kamal::Commands::Base
20
20
  "--name", container_name,
21
21
  "--network", "kamal",
22
22
  *([ "--hostname", hostname ] if hostname),
23
- "-e", "KAMAL_CONTAINER_NAME=\"#{container_name}\"",
24
- "-e", "KAMAL_VERSION=\"#{config.version}\"",
23
+ "--env", "KAMAL_CONTAINER_NAME=\"#{container_name}\"",
24
+ "--env", "KAMAL_VERSION=\"#{config.version}\"",
25
+ "--env", "KAMAL_HOST=\"#{host}\"",
25
26
  *role.env_args(host),
26
27
  *role.logging_args,
27
28
  *config.volume_args,
@@ -84,6 +84,10 @@ module Kamal::Commands
84
84
  args.compact.unshift :docker
85
85
  end
86
86
 
87
+ def pack(*args)
88
+ args.compact.unshift :pack
89
+ end
90
+
87
91
  def git(*args, path: nil)
88
92
  [ :git, *([ "-C", path ] if path), *args.compact ]
89
93
  end
@@ -122,5 +126,9 @@ module Kamal::Commands
122
126
  def ensure_local_buildx_installed
123
127
  docker :buildx, "version"
124
128
  end
129
+
130
+ def docker_interactive_args
131
+ STDIN.isatty ? "-it" : "-i"
132
+ end
125
133
  end
126
134
  end
@@ -6,6 +6,7 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
6
6
  delegate :argumentize, to: Kamal::Utils
7
7
  delegate \
8
8
  :args, :secrets, :dockerfile, :target, :arches, :local_arches, :remote_arches, :remote,
9
+ :pack?, :pack_builder, :pack_buildpacks,
9
10
  :cache_from, :cache_to, :ssh, :provenance, :sbom, :driver, :docker_driver?,
10
11
  to: :builder_config
11
12
 
@@ -0,0 +1,46 @@
1
+ class Kamal::Commands::Builder::Pack < Kamal::Commands::Builder::Base
2
+ def push(export_action = "registry")
3
+ combine \
4
+ build,
5
+ export(export_action)
6
+ end
7
+
8
+ def remove;end
9
+
10
+ def info
11
+ pack :builder, :inspect, pack_builder
12
+ end
13
+ alias_method :inspect_builder, :info
14
+
15
+ private
16
+ def build
17
+ pack(:build,
18
+ config.repository,
19
+ "--platform", platform,
20
+ "--creation-time", "now",
21
+ "--builder", pack_builder,
22
+ buildpacks,
23
+ "-t", config.absolute_image,
24
+ "-t", config.latest_image,
25
+ "--env", "BP_IMAGE_LABELS=service=#{config.service}",
26
+ *argumentize("--env", args),
27
+ *argumentize("--env", secrets, sensitive: true),
28
+ "--path", build_context)
29
+ end
30
+
31
+ def export(export_action)
32
+ return unless export_action == "registry"
33
+
34
+ combine \
35
+ docker(:push, config.absolute_image),
36
+ docker(:push, config.latest_image)
37
+ end
38
+
39
+ def platform
40
+ "linux/#{local_arches.first}"
41
+ end
42
+
43
+ def buildpacks
44
+ (pack_buildpacks << "paketo-buildpacks/image-labels").map { |buildpack| [ "--buildpack", buildpack ] }
45
+ end
46
+ end
@@ -2,7 +2,7 @@ require "active_support/core_ext/string/filters"
2
2
 
3
3
  class Kamal::Commands::Builder < Kamal::Commands::Base
4
4
  delegate :create, :remove, :dev, :push, :clean, :pull, :info, :inspect_builder, :validate_image, :first_mirror, to: :target
5
- delegate :local?, :remote?, :cloud?, to: "config.builder"
5
+ delegate :local?, :remote?, :pack?, :cloud?, to: "config.builder"
6
6
 
7
7
  include Clone
8
8
 
@@ -17,6 +17,8 @@ class Kamal::Commands::Builder < Kamal::Commands::Base
17
17
  else
18
18
  remote
19
19
  end
20
+ elsif pack?
21
+ pack
20
22
  elsif cloud?
21
23
  cloud
22
24
  else
@@ -36,6 +38,10 @@ class Kamal::Commands::Builder < Kamal::Commands::Base
36
38
  @hybrid ||= Kamal::Commands::Builder::Hybrid.new(config)
37
39
  end
38
40
 
41
+ def pack
42
+ @pack ||= Kamal::Commands::Builder::Pack.new(config)
43
+ end
44
+
39
45
  def cloud
40
46
  @cloud ||= Kamal::Commands::Builder::Cloud.new(config)
41
47
  end
@@ -125,7 +125,8 @@ class Kamal::Configuration::Accessory
125
125
  Kamal::Configuration::Proxy.new \
126
126
  config: config,
127
127
  proxy_config: accessory_config["proxy"],
128
- context: "accessories/#{name}/proxy"
128
+ context: "accessories/#{name}/proxy",
129
+ secrets: config.secrets
129
130
  end
130
131
 
131
132
  def initialize_registry
@@ -61,6 +61,10 @@ class Kamal::Configuration::Builder
61
61
  !!builder_config["cache"]
62
62
  end
63
63
 
64
+ def pack?
65
+ !!builder_config["pack"]
66
+ end
67
+
64
68
  def args
65
69
  builder_config["args"] || {}
66
70
  end
@@ -85,6 +89,14 @@ class Kamal::Configuration::Builder
85
89
  builder_config.fetch("driver", "docker-container")
86
90
  end
87
91
 
92
+ def pack_builder
93
+ builder_config["pack"]["builder"] if pack?
94
+ end
95
+
96
+ def pack_buildpacks
97
+ builder_config["pack"]["buildpacks"] if pack?
98
+ end
99
+
88
100
  def local_disabled?
89
101
  builder_config["local"] == false
90
102
  end
@@ -31,6 +31,19 @@ builder:
31
31
  # Defaults to true:
32
32
  local: true
33
33
 
34
+ # Buildpack configuration
35
+ #
36
+ # The build configuration for using pack to build a Cloud Native Buildpack image.
37
+ #
38
+ # For additional buildpack customization options you can create a project descriptor
39
+ # file(project.toml) that the Pack CLI will automatically use.
40
+ # See https://buildpacks.io/docs/for-app-developers/how-to/build-inputs/use-project-toml/ for more information.
41
+ pack:
42
+ builder: heroku/builder:24
43
+ buildpacks:
44
+ - heroku/ruby
45
+ - heroku/procfile
46
+
34
47
  # Builder cache
35
48
  #
36
49
  # The type must be either 'gha' or 'registry'.
@@ -10,11 +10,6 @@
10
10
  # They are application-specific, so they are not shared when multiple applications
11
11
  # run on the same proxy.
12
12
  #
13
- # The proxy is enabled by default on the primary role but can be disabled by
14
- # setting `proxy: false`.
15
- #
16
- # It is disabled by default on all other roles but can be enabled by setting
17
- # `proxy: true` or providing a proxy configuration.
18
13
  proxy:
19
14
 
20
15
  # Hosts
@@ -50,7 +45,27 @@ proxy:
50
45
  # unless you explicitly set `forward_headers: true`
51
46
  #
52
47
  # Defaults to `false`:
53
- ssl: true
48
+ ssl: ...
49
+
50
+ # Custom SSL certificate
51
+ #
52
+ # In some cases, using Let's Encrypt for automatic certificate management is not an
53
+ # option, for example if you are running from host than one host. Or you may already
54
+ # have SSL certificates issued by a different Certificate Authority (CA).
55
+ # Kamal supports loading custom SSL certificates
56
+ # directly from secrets.
57
+ #
58
+ # Examples:
59
+ # ssl: true # Enable SSL with Let's Encrypt
60
+ # ssl: false # Disable SSL
61
+ # ssl: # Enable custom SSL
62
+ # certificate_pem: CERTIFICATE_PEM
63
+ # private_key_pem: PRIVATE_KEY_PEM
64
+ #
65
+ # ### Notes
66
+ # - If the certificate or key is missing or invalid, kamal-proxy will fail to start.
67
+ # - Always handle SSL certificates and private keys securely. Avoid hard-coding them in deploy.yml files or source control.
68
+ # - For automated certificate management, consider using the built-in Let's Encrypt integration instead.
54
69
 
55
70
  # SSL redirect
56
71
  #
@@ -74,6 +89,17 @@ proxy:
74
89
  # How long to wait for requests to complete before timing out, defaults to 30 seconds:
75
90
  response_timeout: 10
76
91
 
92
+ # Path-based routing
93
+ #
94
+ # For applications that split their traffic to different services based on the request path,
95
+ # you can use path-based routing to mount services under different path prefixes.
96
+ path_prefix: '/api'
97
+ # By default, the path prefix will be stripped from the request before it is forwarded upstream.
98
+ # So in the example above, a request to /api/users/123 will be forwarded to web-1 as /users/123.
99
+ # To instead forward the request with the original path (including the prefix),
100
+ # specify --strip-path-prefix=false
101
+ strip_path_prefix: false
102
+
77
103
  # Healthcheck
78
104
  #
79
105
  # When deploying, the proxy will by default hit `/up` once every second until we hit
@@ -113,3 +139,30 @@ proxy:
113
139
  response_headers:
114
140
  - X-Request-ID
115
141
  - X-Request-Start
142
+
143
+ # Enabling/disabling the proxy on roles
144
+ #
145
+ # The proxy is enabled by default on the primary role but can be disabled by
146
+ # setting `proxy: false` in the primary role's configuration.
147
+ #
148
+ # ```yaml
149
+ # servers:
150
+ # web:
151
+ # hosts:
152
+ # - ...
153
+ # proxy: false
154
+ # ```
155
+ #
156
+ # It is disabled by default on all other roles but can be enabled by setting
157
+ # `proxy: true` or providing a proxy configuration for that role.
158
+ #
159
+ # ```yaml
160
+ # servers:
161
+ # web:
162
+ # hosts:
163
+ # - ...
164
+ # web2:
165
+ # hosts:
166
+ # - ...
167
+ # proxy: true
168
+ # ```
@@ -100,6 +100,14 @@ class Kamal::Configuration::Proxy::Boot
100
100
  File.join app_container_directory, "error_pages"
101
101
  end
102
102
 
103
+ def tls_directory
104
+ File.join app_directory, "tls"
105
+ end
106
+
107
+ def tls_container_directory
108
+ File.join app_container_directory, "tls"
109
+ end
110
+
103
111
  private
104
112
  def ensure_valid_bind_ips(bind_ips)
105
113
  bind_ips.present? && bind_ips.each do |ip|
@@ -6,11 +6,14 @@ class Kamal::Configuration::Proxy
6
6
 
7
7
  delegate :argumentize, :optionize, to: Kamal::Utils
8
8
 
9
- attr_reader :config, :proxy_config
9
+ attr_reader :config, :proxy_config, :role_name, :secrets
10
10
 
11
- def initialize(config:, proxy_config:, context: "proxy")
11
+ def initialize(config:, proxy_config:, role_name: nil, secrets:, context: "proxy")
12
12
  @config = config
13
13
  @proxy_config = proxy_config
14
+ @proxy_config = {} if @proxy_config.nil?
15
+ @role_name = role_name
16
+ @secrets = secrets
14
17
  validate! @proxy_config, with: Kamal::Configuration::Validator::Proxy, context: context
15
18
  end
16
19
 
@@ -26,10 +29,46 @@ class Kamal::Configuration::Proxy
26
29
  proxy_config["hosts"] || proxy_config["host"]&.split(",") || []
27
30
  end
28
31
 
32
+ def custom_ssl_certificate?
33
+ ssl = proxy_config["ssl"]
34
+ return false unless ssl.is_a?(Hash)
35
+ ssl["certificate_pem"].present? && ssl["private_key_pem"].present?
36
+ end
37
+
38
+ def certificate_pem_content
39
+ ssl = proxy_config["ssl"]
40
+ return nil unless ssl.is_a?(Hash)
41
+ secrets[ssl["certificate_pem"]]
42
+ end
43
+
44
+ def private_key_pem_content
45
+ ssl = proxy_config["ssl"]
46
+ return nil unless ssl.is_a?(Hash)
47
+ secrets[ssl["private_key_pem"]]
48
+ end
49
+
50
+ def host_tls_cert
51
+ tls_path(config.proxy_boot.tls_directory, "cert.pem")
52
+ end
53
+
54
+ def host_tls_key
55
+ tls_path(config.proxy_boot.tls_directory, "key.pem")
56
+ end
57
+
58
+ def container_tls_cert
59
+ tls_path(config.proxy_boot.tls_container_directory, "cert.pem")
60
+ end
61
+
62
+ def container_tls_key
63
+ tls_path(config.proxy_boot.tls_container_directory, "key.pem") if custom_ssl_certificate?
64
+ end
65
+
29
66
  def deploy_options
30
67
  {
31
68
  host: hosts,
32
- tls: proxy_config["ssl"].presence,
69
+ tls: ssl? ? true : nil,
70
+ "tls-certificate-path": container_tls_cert,
71
+ "tls-private-key-path": container_tls_key,
33
72
  "deploy-timeout": seconds_duration(config.deploy_timeout),
34
73
  "drain-timeout": seconds_duration(config.drain_timeout),
35
74
  "health-check-interval": seconds_duration(proxy_config.dig("healthcheck", "interval")),
@@ -41,6 +80,8 @@ class Kamal::Configuration::Proxy
41
80
  "buffer-memory": proxy_config.dig("buffering", "memory"),
42
81
  "max-request-body": proxy_config.dig("buffering", "max_request_body"),
43
82
  "max-response-body": proxy_config.dig("buffering", "max_response_body"),
83
+ "path-prefix": proxy_config.dig("path_prefix"),
84
+ "strip-path-prefix": proxy_config.dig("strip_path_prefix"),
44
85
  "forward-headers": proxy_config.dig("forward_headers"),
45
86
  "tls-redirect": proxy_config.dig("ssl_redirect"),
46
87
  "log-request-header": proxy_config.dig("logging", "request_headers") || DEFAULT_LOG_REQUEST_HEADERS,
@@ -65,10 +106,14 @@ class Kamal::Configuration::Proxy
65
106
  end
66
107
 
67
108
  def merge(other)
68
- self.class.new config: config, proxy_config: proxy_config.deep_merge(other.proxy_config)
109
+ self.class.new config: config, proxy_config: other.proxy_config.deep_merge(proxy_config), role_name: role_name, secrets: secrets
69
110
  end
70
111
 
71
112
  private
113
+ def tls_path(directory, filename)
114
+ File.join([ directory, role_name, filename ].compact) if custom_ssl_certificate?
115
+ end
116
+
72
117
  def seconds_duration(value)
73
118
  value ? "#{value}s" : nil
74
119
  end
@@ -68,7 +68,7 @@ class Kamal::Configuration::Role
68
68
  end
69
69
 
70
70
  def proxy
71
- @proxy ||= config.proxy.merge(specialized_proxy) if running_proxy?
71
+ @proxy ||= specialized_proxy.merge(config.proxy) if running_proxy?
72
72
  end
73
73
 
74
74
  def running_proxy?
@@ -150,8 +150,8 @@ class Kamal::Configuration::Role
150
150
  end
151
151
 
152
152
  def ensure_one_host_for_ssl
153
- if running_proxy? && proxy.ssl? && hosts.size > 1
154
- raise Kamal::ConfigurationError, "SSL is only supported on a single server, found #{hosts.size} servers for role #{name}"
153
+ if running_proxy? && proxy.ssl? && hosts.size > 1 && !proxy.custom_ssl_certificate?
154
+ raise Kamal::ConfigurationError, "SSL is only supported on a single server unless you provide custom certificates, found #{hosts.size} servers for role #{name}"
155
155
  end
156
156
  end
157
157
 
@@ -173,6 +173,8 @@ class Kamal::Configuration::Role
173
173
  @specialized_proxy = Kamal::Configuration::Proxy.new \
174
174
  config: config,
175
175
  proxy_config: proxy_config,
176
+ secrets: config.secrets,
177
+ role_name: name,
176
178
  context: "servers/#{name}/proxy"
177
179
  end
178
180
  end
@@ -6,6 +6,8 @@ class Kamal::Configuration::Validator::Accessory < Kamal::Configuration::Validat
6
6
  error "specify one of `host`, `hosts`, `role`, `roles`, `tag` or `tags`"
7
7
  end
8
8
 
9
+ validate_labels!(config["labels"])
10
+
9
11
  validate_docker_options!(config["options"])
10
12
  end
11
13
  end
@@ -8,6 +8,8 @@ class Kamal::Configuration::Validator::Builder < Kamal::Configuration::Validator
8
8
 
9
9
  error "Builder arch not set" unless config["arch"].present?
10
10
 
11
+ error "buildpacks only support building for one arch" if config["pack"] && config["arch"].is_a?(Array) && config["arch"].size > 1
12
+
11
13
  error "Cannot disable local builds, no remote is set" if config["local"] == false && config["remote"].blank?
12
14
  end
13
15
  end
@@ -10,6 +10,16 @@ class Kamal::Configuration::Validator::Proxy < Kamal::Configuration::Validator
10
10
  if (config.keys & [ "host", "hosts" ]).size > 1
11
11
  error "Specify one of 'host' or 'hosts', not both"
12
12
  end
13
+
14
+ if config["ssl"].is_a?(Hash)
15
+ if config["ssl"]["certificate_pem"].present? && config["ssl"]["private_key_pem"].blank?
16
+ error "Missing private_key_pem setting (required when certificate_pem is present)"
17
+ end
18
+
19
+ if config["ssl"]["private_key_pem"].present? && config["ssl"]["certificate_pem"].blank?
20
+ error "Missing certificate_pem setting (required when private_key_pem is present)"
21
+ end
22
+ end
13
23
  end
14
24
  end
15
25
  end
@@ -6,6 +6,7 @@ class Kamal::Configuration::Validator::Role < Kamal::Configuration::Validator
6
6
  validate_servers!(config)
7
7
  else
8
8
  super
9
+ validate_labels!(config["labels"])
9
10
  validate_docker_options!(config["options"])
10
11
  end
11
12
  end
@@ -24,7 +24,9 @@ class Kamal::Configuration::Validator
24
24
  example_value = example[key]
25
25
 
26
26
  if example_value == "..."
27
- unless key.to_s == "proxy" && boolean?(value.class)
27
+ if key.to_s == "ssl"
28
+ validate_type! value, TrueClass, FalseClass, Hash
29
+ elsif key.to_s != "proxy" || !boolean?(value.class)
28
30
  validate_type! value, *(Array if key == :servers), Hash
29
31
  end
30
32
  elsif key == "hosts"
@@ -169,6 +171,18 @@ class Kamal::Configuration::Validator
169
171
  unknown_keys_error unknown_keys if unknown_keys.present?
170
172
  end
171
173
 
174
+ def validate_labels!(labels)
175
+ return true if labels.blank?
176
+
177
+ with_context("labels") do
178
+ labels.each do |key, _|
179
+ with_context(key) do
180
+ error "invalid label. destination, role, and service are reserved labels" if %w[destination role service].include?(key)
181
+ end
182
+ end
183
+ end
184
+ end
185
+
172
186
  def validate_docker_options!(options)
173
187
  if options
174
188
  error "Cannot set restart policy in docker options, unless-stopped is required" if options["restart"]
@@ -63,7 +63,7 @@ class Kamal::Configuration
63
63
  @env = Env.new(config: @raw_config.env || {}, secrets: secrets)
64
64
 
65
65
  @logging = Logging.new(logging_config: @raw_config.logging)
66
- @proxy = Proxy.new(config: self, proxy_config: @raw_config.key?(:proxy) ? @raw_config.proxy : {})
66
+ @proxy = Proxy.new(config: self, proxy_config: @raw_config.proxy, secrets: secrets)
67
67
  @proxy_boot = Proxy::Boot.new(config: self)
68
68
  @ssh = Ssh.new(config: self)
69
69
  @sshkit = Sshkit.new(config: self)
@@ -6,8 +6,8 @@ class Kamal::Secrets::Adapters::BitwardenSecretsManager < Kamal::Secrets::Adapte
6
6
  private
7
7
  LIST_ALL_SELECTOR = "all"
8
8
  LIST_ALL_FROM_PROJECT_SUFFIX = "/all"
9
- LIST_COMMAND = "secret list -o env"
10
- GET_COMMAND = "secret get -o env"
9
+ LIST_COMMAND = "secret list"
10
+ GET_COMMAND = "secret get"
11
11
 
12
12
  def fetch_secrets(secrets, from:, account:, session:)
13
13
  raise RuntimeError, "You must specify what to retrieve from Bitwarden Secrets Manager" if secrets.length == 0
@@ -18,17 +18,17 @@ class Kamal::Secrets::Adapters::BitwardenSecretsManager < Kamal::Secrets::Adapte
18
18
  {}.tap do |results|
19
19
  if command.nil?
20
20
  secrets.each do |secret_uuid|
21
- secret = run_command("#{GET_COMMAND} #{secret_uuid.shellescape}")
21
+ item_json = run_command("#{GET_COMMAND} #{secret_uuid.shellescape}")
22
22
  raise RuntimeError, "Could not read #{secret_uuid} from Bitwarden Secrets Manager" unless $?.success?
23
- key, value = parse_secret(secret)
24
- results[key] = value
23
+ item_json = JSON.parse(item_json)
24
+ results[item_json["key"]] = item_json["value"]
25
25
  end
26
26
  else
27
- secrets = run_command(command)
27
+ items_json = run_command(command)
28
28
  raise RuntimeError, "Could not read secrets from Bitwarden Secrets Manager" unless $?.success?
29
- secrets.split("\n").each do |secret|
30
- key, value = parse_secret(secret)
31
- results[key] = value
29
+
30
+ JSON.parse(items_json).each do |item_json|
31
+ results[item_json["key"]] = item_json["value"]
32
32
  end
33
33
  end
34
34
  end
@@ -45,19 +45,13 @@ class Kamal::Secrets::Adapters::BitwardenSecretsManager < Kamal::Secrets::Adapte
45
45
  end
46
46
  end
47
47
 
48
- def parse_secret(secret)
49
- key, value = secret.split("=", 2)
50
- value = value.gsub(/^"|"$/, "")
51
- [ key, value ]
52
- end
53
-
54
48
  def run_command(command, session: nil)
55
49
  full_command = [ "bws", command ].join(" ")
56
50
  `#{full_command}`
57
51
  end
58
52
 
59
53
  def login(account)
60
- run_command("run 'echo OK'")
54
+ run_command("project list")
61
55
  raise RuntimeError, "Could not authenticate to Bitwarden Secrets Manager. Did you set a valid access token?" unless $?.success?
62
56
  end
63
57
 
@@ -16,17 +16,33 @@ class Kamal::Secrets::Adapters::OnePassword < Kamal::Secrets::Adapters::Base
16
16
  end
17
17
 
18
18
  def fetch_secrets(secrets, from:, account:, session:)
19
+ if secrets.blank?
20
+ fetch_all_secrets(from: from, account: account, session: session) if secrets.blank?
21
+ else
22
+ fetch_specified_secrets(secrets, from: from, account: account, session: session)
23
+ end
24
+ end
25
+
26
+ def fetch_specified_secrets(secrets, from:, account:, session:)
19
27
  {}.tap do |results|
20
28
  vaults_items_fields(prefixed_secrets(secrets, from: from)).map do |vault, items|
21
29
  items.each do |item, fields|
22
- fields_json = JSON.parse(op_item_get(vault, item, fields, account: account, session: session))
30
+ fields_json = JSON.parse(op_item_get(vault, item, fields: fields, account: account, session: session))
23
31
  fields_json = [ fields_json ] if fields.one?
24
32
 
25
- fields_json.each do |field_json|
26
- # The reference is in the form `op://vault/item/field[/field]`
27
- field = field_json["reference"].delete_prefix("op://").delete_suffix("/password")
28
- results[field] = field_json["value"]
29
- end
33
+ results.merge!(fields_map(fields_json))
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ def fetch_all_secrets(from:, account:, session:)
40
+ {}.tap do |results|
41
+ vault_items(from).each do |vault, items|
42
+ items.each do |item|
43
+ fields_json = JSON.parse(op_item_get(vault, item, account: account, session: session)).fetch("fields")
44
+
45
+ results.merge!(fields_map(fields_json))
30
46
  end
31
47
  end
32
48
  end
@@ -50,12 +66,30 @@ class Kamal::Secrets::Adapters::OnePassword < Kamal::Secrets::Adapters::Base
50
66
  end
51
67
  end
52
68
 
53
- def op_item_get(vault, item, fields, account:, session:)
54
- labels = fields.map { |field| "label=#{field}" }.join(",")
55
- options = to_options(vault: vault, fields: labels, format: "json", account: account, session: session.presence)
69
+ def vault_items(from)
70
+ from = from.delete_prefix("op://")
71
+ vault, item = from.split("/")
72
+ { vault => [ item ] }
73
+ end
74
+
75
+ def fields_map(fields_json)
76
+ fields_json.to_h do |field_json|
77
+ # The reference is in the form `op://vault/item/field[/field]`
78
+ field = field_json["reference"].delete_prefix("op://").delete_suffix("/password")
79
+ [ field, field_json["value"] ]
80
+ end
81
+ end
82
+
83
+ def op_item_get(vault, item, fields: nil, account:, session:)
84
+ options = { vault: vault, format: "json", account: account, session: session.presence }
85
+
86
+ if fields.present?
87
+ labels = fields.map { |field| "label=#{field}" }.join(",")
88
+ options.merge!(fields: labels)
89
+ end
56
90
 
57
- `op item get #{item.shellescape} #{options}`.tap do
58
- raise RuntimeError, "Could not read #{fields.join(", ")} from #{item} in the #{vault} 1Password vault" unless $?.success?
91
+ `op item get #{item.shellescape} #{to_options(**options)}`.tap do
92
+ raise RuntimeError, "Could not read #{"#{fields.join(", ")} " if fields.present?}from #{item} in the #{vault} 1Password vault" unless $?.success?
59
93
  end
60
94
  end
61
95
 
@@ -0,0 +1,130 @@
1
+ class Kamal::Secrets::Adapters::Passbolt < Kamal::Secrets::Adapters::Base
2
+ def requires_account?
3
+ false
4
+ end
5
+
6
+ private
7
+
8
+ def login(*)
9
+ `passbolt verify`
10
+ raise RuntimeError, "Failed to login to Passbolt" unless $?.success?
11
+ end
12
+
13
+ def fetch_secrets(secrets, from:, **)
14
+ secrets = prefixed_secrets(secrets, from: from)
15
+ raise ArgumentError, "No secrets given to fetch" if secrets.empty?
16
+
17
+ secret_names = secrets.collect { |s| s.split("/").last }
18
+ folders = secrets_get_folders(secrets)
19
+
20
+ # build filter conditions for each secret with its corresponding folder
21
+ filter_conditions = []
22
+ secrets.each do |secret|
23
+ parts = secret.split("/")
24
+ secret_name = parts.last
25
+
26
+ if parts.size > 1
27
+ # get the folder path without the secret name
28
+ folder_path = parts[0..-2]
29
+
30
+ # find the most nested folder for this path
31
+ current_folder = nil
32
+ current_path = []
33
+
34
+ folder_path.each do |folder_name|
35
+ current_path << folder_name
36
+ matching_folders = folders.select { |f| get_folder_path(f, folders) == current_path.join("/") }
37
+ current_folder = matching_folders.first if matching_folders.any?
38
+ end
39
+
40
+ if current_folder
41
+ filter_conditions << "(Name == #{secret_name.shellescape.inspect} && FolderParentID == #{current_folder["id"].shellescape.inspect})"
42
+ end
43
+ else
44
+ # for root level secrets (no folders)
45
+ filter_conditions << "Name == #{secret_name.shellescape.inspect}"
46
+ end
47
+ end
48
+
49
+ filter_condition = filter_conditions.any? ? "--filter '#{filter_conditions.join(" || ")}'" : ""
50
+ items = `passbolt list resources #{filter_condition} #{folders.map { |item| "--folder #{item["id"]}" }.join(" ")} --json`
51
+ raise RuntimeError, "Could not read #{secrets} from Passbolt" unless $?.success?
52
+
53
+ items = JSON.parse(items)
54
+ found_names = items.map { |item| item["name"] }
55
+ missing_secrets = secret_names - found_names
56
+ raise RuntimeError, "Could not find the following secrets in Passbolt: #{missing_secrets.join(", ")}" if missing_secrets.any?
57
+
58
+ items.to_h { |item| [ item["name"], item["password"] ] }
59
+ end
60
+
61
+ def secrets_get_folders(secrets)
62
+ # extract all folder paths (both parent and nested)
63
+ folder_paths = secrets
64
+ .select { |s| s.include?("/") }
65
+ .map { |s| s.split("/")[0..-2] } # get all parts except the secret name
66
+ .uniq
67
+
68
+ return [] if folder_paths.empty?
69
+
70
+ all_folders = []
71
+
72
+ # first get all top-level folders
73
+ parent_folders = folder_paths.map(&:first).uniq
74
+ filter_condition = "--filter '#{parent_folders.map { |name| "Name == #{name.shellescape.inspect}" }.join(" || ")}'"
75
+ fetch_folders = `passbolt list folders #{filter_condition} --json`
76
+ raise RuntimeError, "Could not read folders from Passbolt" unless $?.success?
77
+
78
+ parent_folder_items = JSON.parse(fetch_folders)
79
+ all_folders.concat(parent_folder_items)
80
+
81
+ # get nested folders for each parent
82
+ folder_paths.each do |path|
83
+ next if path.size <= 1 # skip non-nested folders
84
+
85
+ parent = path[0]
86
+ parent_folder = parent_folder_items.find { |f| f["name"] == parent }
87
+ next unless parent_folder
88
+
89
+ # for each nested level, get the folders using the parent's ID
90
+ current_parent = parent_folder
91
+ path[1..-1].each do |folder_name|
92
+ filter_condition = "--filter 'Name == #{folder_name.shellescape.inspect} && FolderParentID == #{current_parent["id"].shellescape.inspect}'"
93
+ fetch_nested = `passbolt list folders #{filter_condition} --json`
94
+ next unless $?.success?
95
+
96
+ nested_folders = JSON.parse(fetch_nested)
97
+ break if nested_folders.empty?
98
+
99
+ all_folders.concat(nested_folders)
100
+ current_parent = nested_folders.first
101
+ end
102
+ end
103
+
104
+ # check if we found all required folders
105
+ found_paths = all_folders.map { |f| get_folder_path(f, all_folders) }
106
+ missing_paths = folder_paths.map { |path| path.join("/") } - found_paths
107
+ raise RuntimeError, "Could not find the following folders in Passbolt: #{missing_paths.join(", ")}" if missing_paths.any?
108
+
109
+ all_folders
110
+ end
111
+
112
+ def get_folder_path(folder, all_folders, path = [])
113
+ path.unshift(folder["name"])
114
+ return path.join("/") if folder["folder_parent_id"].to_s.empty?
115
+
116
+ parent = all_folders.find { |f| f["id"] == folder["folder_parent_id"] }
117
+ return path.join("/") unless parent
118
+
119
+ get_folder_path(parent, all_folders, path)
120
+ end
121
+
122
+ def check_dependencies!
123
+ raise RuntimeError, "Passbolt CLI is not installed" unless cli_installed?
124
+ end
125
+
126
+ def cli_installed?
127
+ `passbolt --version 2> /dev/null`
128
+ $?.success?
129
+ end
130
+ end
data/lib/kamal/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Kamal
2
- VERSION = "2.6.0"
2
+ VERSION = "2.7.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kamal
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.6.0
4
+ version: 2.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Heinemeier Hansson
@@ -220,6 +220,7 @@ files:
220
220
  - lib/kamal/cli/app/assets.rb
221
221
  - lib/kamal/cli/app/boot.rb
222
222
  - lib/kamal/cli/app/error_pages.rb
223
+ - lib/kamal/cli/app/ssl_certificates.rb
223
224
  - lib/kamal/cli/base.rb
224
225
  - lib/kamal/cli/build.rb
225
226
  - lib/kamal/cli/build/clone.rb
@@ -265,6 +266,7 @@ files:
265
266
  - lib/kamal/commands/builder/cloud.rb
266
267
  - lib/kamal/commands/builder/hybrid.rb
267
268
  - lib/kamal/commands/builder/local.rb
269
+ - lib/kamal/commands/builder/pack.rb
268
270
  - lib/kamal/commands/builder/remote.rb
269
271
  - lib/kamal/commands/docker.rb
270
272
  - lib/kamal/commands/hook.rb
@@ -327,6 +329,7 @@ files:
327
329
  - lib/kamal/secrets/adapters/gcp_secret_manager.rb
328
330
  - lib/kamal/secrets/adapters/last_pass.rb
329
331
  - lib/kamal/secrets/adapters/one_password.rb
332
+ - lib/kamal/secrets/adapters/passbolt.rb
330
333
  - lib/kamal/secrets/adapters/test.rb
331
334
  - lib/kamal/secrets/dotenv/inline_command_substitution.rb
332
335
  - lib/kamal/sshkit_with_ext.rb