kamal 2.6.1 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2aa9cdc20d3b482b18687564bd8f45383a771d256153b69213923215ba94ebce
4
- data.tar.gz: b2b617c75e5b3324916f49f40e1ab1dac43186de0568000bb768eca2d21a38cf
3
+ metadata.gz: 4e1cf57d731a8b129a8ccbb86faddd3e813bc4d17895e6e538fe904f5bb65d27
4
+ data.tar.gz: 2d7d81b3a34f42fb427bfed18f9cf0ed1955d38e4b4783c1407bc1db6de5cef6
5
5
  SHA512:
6
- metadata.gz: 9f6a7b11fd418414669e0477c9d3c3a30e9bbd39433ccc68e85df7bba9f97f96361168a1d780740321fdff1211ecd4c13262377759d25f88f37c2964d858789a
7
- data.tar.gz: bafc9d379a59dc332f3e3f0a079d76db96a433742b3b17eb72b3b90dd28e37067d9f6d8db6c18af29723020bbc0aa53e49bacb203bdba76f6cee839379f886d2
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
 
@@ -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
 
@@ -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'.
@@ -45,7 +45,27 @@ proxy:
45
45
  # unless you explicitly set `forward_headers: true`
46
46
  #
47
47
  # Defaults to `false`:
48
- 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.
49
69
 
50
70
  # SSL redirect
51
71
  #
@@ -69,6 +89,17 @@ proxy:
69
89
  # How long to wait for requests to complete before timing out, defaults to 30 seconds:
70
90
  response_timeout: 10
71
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
+
72
103
  # Healthcheck
73
104
  #
74
105
  # When deploying, the proxy will by default hit `/up` once every second until we hit
@@ -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,12 +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
14
  @proxy_config = {} if @proxy_config.nil?
15
+ @role_name = role_name
16
+ @secrets = secrets
15
17
  validate! @proxy_config, with: Kamal::Configuration::Validator::Proxy, context: context
16
18
  end
17
19
 
@@ -27,10 +29,46 @@ class Kamal::Configuration::Proxy
27
29
  proxy_config["hosts"] || proxy_config["host"]&.split(",") || []
28
30
  end
29
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
+
30
66
  def deploy_options
31
67
  {
32
68
  host: hosts,
33
- 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,
34
72
  "deploy-timeout": seconds_duration(config.deploy_timeout),
35
73
  "drain-timeout": seconds_duration(config.drain_timeout),
36
74
  "health-check-interval": seconds_duration(proxy_config.dig("healthcheck", "interval")),
@@ -42,6 +80,8 @@ class Kamal::Configuration::Proxy
42
80
  "buffer-memory": proxy_config.dig("buffering", "memory"),
43
81
  "max-request-body": proxy_config.dig("buffering", "max_request_body"),
44
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"),
45
85
  "forward-headers": proxy_config.dig("forward_headers"),
46
86
  "tls-redirect": proxy_config.dig("ssl_redirect"),
47
87
  "log-request-header": proxy_config.dig("logging", "request_headers") || DEFAULT_LOG_REQUEST_HEADERS,
@@ -66,10 +106,14 @@ class Kamal::Configuration::Proxy
66
106
  end
67
107
 
68
108
  def merge(other)
69
- 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
70
110
  end
71
111
 
72
112
  private
113
+ def tls_path(directory, filename)
114
+ File.join([ directory, role_name, filename ].compact) if custom_ssl_certificate?
115
+ end
116
+
73
117
  def seconds_duration(value)
74
118
  value ? "#{value}s" : nil
75
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.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.1"
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.1
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