Compare commits
112 Commits
v2.6.0
...
7da03fd94c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7da03fd94c | ||
|
|
2eed47d464 | ||
|
|
95cbc62ef1 | ||
|
|
18f1bbbeac | ||
|
|
5dd8eba182 | ||
|
|
75754e4b7b | ||
|
|
8e470ed051 | ||
|
|
4b88852aea | ||
|
|
cfaa4fb0db | ||
|
|
2bcb313590 | ||
|
|
3cf510bc8f | ||
|
|
e61d96d154 | ||
|
|
aa2ceaa92a | ||
|
|
c3e7721da5 | ||
|
|
0656e02375 | ||
|
|
aed77a78fb | ||
|
|
9244247389 | ||
|
|
6e517665e8 | ||
|
|
4b0afdf42b | ||
|
|
5aa3f7bd4c | ||
|
|
ccbcbbc8c5 | ||
|
|
8a7260d1e9 | ||
|
|
89c56910c9 | ||
|
|
52e06c1351 | ||
|
|
9bcc953cd6 | ||
|
|
e2015b47f9 | ||
|
|
23f2bf71f9 | ||
|
|
054a85d3c0 | ||
|
|
5a0da160b4 | ||
|
|
72d9fcbaaa | ||
|
|
a201a6ca68 | ||
|
|
1d81d9ec15 | ||
|
|
aa67564dc5 | ||
|
|
fd6ac4f84b | ||
|
|
c8f232b64f | ||
|
|
7f3dd59a73 | ||
|
|
6672e3e77d | ||
|
|
b164d50ff1 | ||
|
|
1d88281fee | ||
|
|
a004232ffc | ||
|
|
487aa306c9 | ||
|
|
cbf94fa7f5 | ||
|
|
344e2d7995 | ||
|
|
b387df0e4f | ||
|
|
9c8a44eec4 | ||
|
|
99f763d742 | ||
|
|
4bd1f0536c | ||
|
|
e217332cde | ||
|
|
30d630ce4d | ||
|
|
22e7243b10 | ||
|
|
259a018d5a | ||
|
|
a82e88d5c9 | ||
|
|
d6459e869a | ||
|
|
ad21c7e984 | ||
|
|
87965281a3 | ||
|
|
dca96eafaa | ||
|
|
7b1439c3c6 | ||
|
|
b9e5ce7ca7 | ||
|
|
f62c1a50c4 | ||
|
|
2c1d6ed891 | ||
|
|
1331e7b9c7 | ||
|
|
c5e5f5d7cc | ||
|
|
6a573c19a6 | ||
|
|
0ab0649d07 | ||
|
|
d62c35e63e | ||
|
|
9a14fbb048 | ||
|
|
092ca425d7 | ||
|
|
68404e2673 | ||
|
|
681439f122 | ||
|
|
a1c6ac41d0 | ||
|
|
9219b87630 | ||
|
|
1f847299c0 | ||
|
|
a525d45b4d | ||
|
|
045410368d | ||
|
|
045da87219 | ||
|
|
fc67cdea33 | ||
|
|
38cfc4488b | ||
|
|
0e453a02de | ||
|
|
aa12dc1d12 | ||
|
|
8acd35c4b7 | ||
|
|
104914bf14 | ||
|
|
913f07bbf2 | ||
|
|
9b63ad5cb8 | ||
|
|
8c17b1ebc6 | ||
|
|
f8f7c6ec57 | ||
|
|
da26457d52 | ||
|
|
95b606a427 | ||
|
|
d249b9a431 | ||
|
|
9f6660dfbf | ||
|
|
9ac3d57b29 | ||
|
|
8354fbee06 | ||
|
|
cde5c7abbf | ||
|
|
1ebc8b8daa | ||
|
|
145b73c4f0 | ||
|
|
d538447973 | ||
|
|
4822a9d950 | ||
|
|
1d55c5941b | ||
|
|
89b44153bb | ||
|
|
5482052e19 | ||
|
|
dda8efe39a | ||
|
|
c60124188f | ||
|
|
f7147e07d4 | ||
|
|
71741742ff | ||
|
|
e252004eef | ||
|
|
85a5a09aac | ||
|
|
548452aa12 | ||
|
|
2c5f2a7ce0 | ||
|
|
ae68193f99 | ||
|
|
24f4308372 | ||
|
|
d0ffb850da | ||
|
|
826308aabd | ||
|
|
897b3b4e46 |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -26,16 +26,12 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
ruby-version:
|
||||
- "3.1"
|
||||
- "3.2"
|
||||
- "3.3"
|
||||
- "3.4"
|
||||
gemfile:
|
||||
- Gemfile
|
||||
- gemfiles/rails_edge.gemfile
|
||||
exclude:
|
||||
- ruby-version: "3.1"
|
||||
gemfile: gemfiles/rails_edge.gemfile
|
||||
name: ${{ format('Tests (Ruby {0})', matrix.ruby-version) }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
|
||||
@@ -13,8 +13,7 @@ COPY Gemfile Gemfile.lock kamal.gemspec ./
|
||||
COPY lib/kamal/version.rb /kamal/lib/kamal/version.rb
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache build-base git docker openrc openssh-client-default yaml-dev \
|
||||
&& rc-update add docker boot \
|
||||
RUN apk add --no-cache build-base git docker-cli openssh-client-default yaml-dev \
|
||||
&& gem install bundler --version=2.6.5 \
|
||||
&& bundle install
|
||||
|
||||
|
||||
19
Gemfile.lock
19
Gemfile.lock
@@ -1,7 +1,7 @@
|
||||
PATH
|
||||
remote: .
|
||||
specs:
|
||||
kamal (2.6.0)
|
||||
kamal (2.7.0)
|
||||
activesupport (>= 7.0)
|
||||
base64 (~> 0.2)
|
||||
bcrypt_pbkdf (~> 1.0)
|
||||
@@ -82,15 +82,15 @@ GEM
|
||||
net-sftp (4.0.0)
|
||||
net-ssh (>= 5.0.0, < 8.0.0)
|
||||
net-ssh (7.3.0)
|
||||
nokogiri (1.18.8-aarch64-linux-musl)
|
||||
nokogiri (1.18.9-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-arm64-darwin)
|
||||
nokogiri (1.18.9-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-x86_64-darwin)
|
||||
nokogiri (1.18.9-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-x86_64-linux-gnu)
|
||||
nokogiri (1.18.9-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-x86_64-linux-musl)
|
||||
nokogiri (1.18.9-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
ostruct (0.6.1)
|
||||
parallel (1.26.3)
|
||||
@@ -101,8 +101,9 @@ GEM
|
||||
date
|
||||
stringio
|
||||
racc (1.8.1)
|
||||
rack (3.1.12)
|
||||
rack-session (2.0.0)
|
||||
rack (3.1.16)
|
||||
rack-session (2.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.1.0)
|
||||
rack (>= 1.3)
|
||||
@@ -168,7 +169,7 @@ GEM
|
||||
net-ssh (>= 2.8.0)
|
||||
ostruct
|
||||
stringio (3.1.2)
|
||||
thor (1.3.2)
|
||||
thor (1.4.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode-display_width (3.1.2)
|
||||
|
||||
@@ -24,11 +24,11 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
|
||||
directories(name)
|
||||
upload(name)
|
||||
|
||||
on(hosts) do
|
||||
on(hosts) do |host|
|
||||
execute *KAMAL.auditor.record("Booted #{name} accessory"), verbosity: :debug
|
||||
execute *accessory.ensure_env_directory
|
||||
upload! accessory.secrets_io, accessory.secrets_path, mode: "0600"
|
||||
execute *accessory.run
|
||||
execute *accessory.run(host: host)
|
||||
|
||||
if accessory.running_proxy?
|
||||
target = capture_with_info(*accessory.container_id_for(container_name: accessory.service_name, only_running: true)).strip
|
||||
|
||||
@@ -12,6 +12,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
|
||||
|
||||
KAMAL.roles_on(host).each do |role|
|
||||
Kamal::Cli::App::Assets.new(host, role, self).run
|
||||
Kamal::Cli::App::SslCertificates.new(host, role, self).run
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
28
lib/kamal/cli/app/ssl_certificates.rb
Normal file
28
lib/kamal/cli/app/ssl_certificates.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
class Kamal::Cli::App::SslCertificates
|
||||
attr_reader :host, :role, :sshkit
|
||||
delegate :execute, :info, :upload!, to: :sshkit
|
||||
|
||||
def initialize(host, role, sshkit)
|
||||
@host = host
|
||||
@role = role
|
||||
@sshkit = sshkit
|
||||
end
|
||||
|
||||
def run
|
||||
if role.running_proxy? && role.proxy.custom_ssl_certificate?
|
||||
info "Writing SSL certificates for #{role.name} on #{host}"
|
||||
execute *app.create_ssl_directory
|
||||
if cert_content = role.proxy.certificate_pem_content
|
||||
upload!(StringIO.new(cert_content), role.proxy.host_tls_cert, mode: "0644")
|
||||
end
|
||||
if key_content = role.proxy.private_key_pem_content
|
||||
upload!(StringIO.new(key_content), role.proxy.host_tls_key, mode: "0644")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def app
|
||||
@app ||= KAMAL.app(role: role, host: host)
|
||||
end
|
||||
end
|
||||
@@ -14,6 +14,10 @@ class Kamal::Cli::Build < Kamal::Cli::Base
|
||||
def push
|
||||
cli = self
|
||||
|
||||
# Ensure pre-connect hooks run before the build, they may needed for a remote builder
|
||||
# or the pre-build hooks.
|
||||
pre_connect_if_required
|
||||
|
||||
ensure_docker_installed
|
||||
login_to_registry_locally
|
||||
|
||||
@@ -63,10 +67,11 @@ class Kamal::Cli::Build < Kamal::Cli::Base
|
||||
|
||||
desc "pull", "Pull app image from registry onto servers"
|
||||
def pull
|
||||
login_to_registry_remotely
|
||||
login_to_registry_remotely unless KAMAL.registry.local?
|
||||
|
||||
forward_local_registry_port do
|
||||
if (first_hosts = mirror_hosts).any?
|
||||
# Pull on a single host per mirror first to seed them
|
||||
# Pull on a single host per mirror first to seed them
|
||||
say "Pulling image on #{first_hosts.join(", ")} to seed the #{"mirror".pluralize(first_hosts.count)}...", :magenta
|
||||
pull_on_hosts(first_hosts)
|
||||
say "Pulling image on remaining hosts...", :magenta
|
||||
@@ -75,6 +80,7 @@ class Kamal::Cli::Build < Kamal::Cli::Base
|
||||
pull_on_hosts(KAMAL.app_hosts)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
desc "create", "Create a build setup"
|
||||
def create
|
||||
@@ -188,13 +194,27 @@ class Kamal::Cli::Build < Kamal::Cli::Base
|
||||
|
||||
def login_to_registry_locally
|
||||
run_locally do
|
||||
if KAMAL.registry.local?
|
||||
execute *KAMAL.registry.setup
|
||||
else
|
||||
execute *KAMAL.registry.login
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def login_to_registry_remotely
|
||||
on(KAMAL.app_hosts) do
|
||||
execute *KAMAL.registry.login
|
||||
end
|
||||
end
|
||||
|
||||
def forward_local_registry_port(&block)
|
||||
if KAMAL.config.registry.local?
|
||||
Kamal::Cli::PortForwarding.
|
||||
new(KAMAL.hosts, KAMAL.config.registry.local_port).
|
||||
forward(&block)
|
||||
else
|
||||
yield
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -182,7 +182,7 @@ class Kamal::Cli::Main < Kamal::Cli::Base
|
||||
invoke "kamal:cli:app:remove", [], options.without(:confirmed)
|
||||
invoke "kamal:cli:proxy:remove", [], options.without(:confirmed)
|
||||
invoke "kamal:cli:accessory:remove", [ "all" ], options
|
||||
invoke "kamal:cli:registry:logout", [], options.without(:confirmed).merge(skip_local: true)
|
||||
invoke "kamal:cli:registry:remove", [], options.without(:confirmed).merge(skip_local: true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
42
lib/kamal/cli/port_forwarding.rb
Normal file
42
lib/kamal/cli/port_forwarding.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
class Kamal::Cli::PortForwarding
|
||||
attr_reader :hosts, :port
|
||||
|
||||
def initialize(hosts, port)
|
||||
@hosts = hosts
|
||||
@port = port
|
||||
end
|
||||
|
||||
def forward
|
||||
@done = false
|
||||
forward_ports
|
||||
|
||||
yield
|
||||
ensure
|
||||
stop
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def stop
|
||||
@done = true
|
||||
@threads.to_a.each(&:join)
|
||||
end
|
||||
|
||||
def forward_ports
|
||||
@threads = hosts.map do |host|
|
||||
Thread.new do
|
||||
Net::SSH.start(host, KAMAL.config.ssh.user) do |ssh|
|
||||
ssh.forward.remote(port, "127.0.0.1", port)
|
||||
ssh.loop(0.1) do
|
||||
if @done
|
||||
ssh.forward.cancel_remote(port)
|
||||
break
|
||||
else
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -120,18 +120,6 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
|
||||
execute *KAMAL.proxy.ensure_apps_config_directory
|
||||
|
||||
execute *KAMAL.proxy.run
|
||||
|
||||
KAMAL.roles_on(host).select(&:running_proxy?).each do |role|
|
||||
app = KAMAL.app(role: role, host: host)
|
||||
|
||||
version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip
|
||||
endpoint = capture_with_info(*app.container_id_for_version(version)).strip
|
||||
|
||||
if endpoint.present?
|
||||
info "Deploying #{endpoint} for role `#{role}` on #{host}..."
|
||||
execute *app.deploy(target: endpoint)
|
||||
end
|
||||
end
|
||||
end
|
||||
run_hook "post-proxy-reboot", hosts: host_list
|
||||
end
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
class Kamal::Cli::Registry < Kamal::Cli::Base
|
||||
desc "login", "Log in to registry locally and remotely"
|
||||
desc "setup", "Setup local registry or log in to remote registry locally and remotely"
|
||||
option :skip_local, aliases: "-L", type: :boolean, default: false, desc: "Skip local login"
|
||||
option :skip_remote, aliases: "-R", type: :boolean, default: false, desc: "Skip remote login"
|
||||
def login
|
||||
def setup
|
||||
ensure_docker_installed unless options[:skip_local]
|
||||
|
||||
if KAMAL.registry.local?
|
||||
run_locally { execute *KAMAL.registry.setup } unless options[:skip_local]
|
||||
else
|
||||
run_locally { execute *KAMAL.registry.login } unless options[:skip_local]
|
||||
on(KAMAL.hosts) { execute *KAMAL.registry.login } unless options[:skip_remote]
|
||||
end
|
||||
end
|
||||
|
||||
desc "logout", "Log out of registry locally and remotely"
|
||||
desc "remove", "Remove local registry or log out of remote registry locally and remotely"
|
||||
option :skip_local, aliases: "-L", type: :boolean, default: false, desc: "Skip local login"
|
||||
option :skip_remote, aliases: "-R", type: :boolean, default: false, desc: "Skip remote login"
|
||||
def logout
|
||||
def remove
|
||||
if KAMAL.registry.local?
|
||||
run_locally { execute *KAMAL.registry.remove, raise_on_non_zero_exit: false } unless options[:skip_local]
|
||||
else
|
||||
run_locally { execute *KAMAL.registry.logout } unless options[:skip_local]
|
||||
on(KAMAL.hosts) { execute *KAMAL.registry.logout } unless options[:skip_remote]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,13 +25,14 @@ proxy:
|
||||
|
||||
# Credentials for your image host.
|
||||
registry:
|
||||
server: localhost:5555
|
||||
# Specify the registry server, if you're not using Docker Hub
|
||||
# server: registry.digitalocean.com / ghcr.io / ...
|
||||
username: my-user
|
||||
# username: my-user
|
||||
|
||||
# Always use an access token rather than real password (pulled from .kamal/secrets).
|
||||
password:
|
||||
- KAMAL_REGISTRY_PASSWORD
|
||||
# password:
|
||||
# - KAMAL_REGISTRY_PASSWORD
|
||||
|
||||
# Configure builder setup.
|
||||
builder:
|
||||
@@ -39,7 +40,6 @@ builder:
|
||||
# Pass in additional build args needed for your Dockerfile.
|
||||
# args:
|
||||
# RUBY_VERSION: <%= ENV["RBENV_VERSION"] || ENV["rvm_ruby_string"] || "#{RUBY_ENGINE}-#{RUBY_ENGINE_VERSION}" %>
|
||||
|
||||
# Inject ENV variables into containers (secrets come from .kamal/secrets).
|
||||
#
|
||||
# env:
|
||||
|
||||
@@ -43,7 +43,7 @@ class GithubStatusChecks
|
||||
attr_reader :remote_url, :git_sha, :github_client, :combined_status
|
||||
|
||||
def initialize
|
||||
@remote_url = `git config --get remote.origin.url`.strip.delete_prefix("https://github.com/")
|
||||
@remote_url = github_repo_from_remote_url
|
||||
@git_sha = `git rev-parse HEAD`.strip
|
||||
@github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"])
|
||||
refresh!
|
||||
@@ -77,6 +77,18 @@ class GithubStatusChecks
|
||||
"Build not started..."
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def github_repo_from_remote_url
|
||||
url = `git config --get remote.origin.url`.strip.delete_suffix(".git")
|
||||
if url.start_with?("https://github.com/")
|
||||
url.delete_prefix("https://github.com/")
|
||||
elsif url.start_with?("git@github.com:")
|
||||
url.delete_prefix("git@github.com:")
|
||||
else
|
||||
url
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git.
|
||||
|
||||
# Option 1: Read secrets from the environment
|
||||
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
|
||||
# KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
|
||||
|
||||
# Option 2: Read secrets via a command
|
||||
# RAILS_MASTER_KEY=$(cat config/master.key)
|
||||
|
||||
@@ -21,7 +21,7 @@ class Kamal::Commander
|
||||
end
|
||||
|
||||
def config
|
||||
@config ||= Kamal::Configuration.create_from(**@config_kwargs).tap do |config|
|
||||
@config ||= Kamal::Configuration.create_from(**@config_kwargs.to_h).tap do |config|
|
||||
@config_kwargs = nil
|
||||
configure_sshkit_with(config)
|
||||
end
|
||||
|
||||
@@ -11,7 +11,7 @@ class Kamal::Commander::Specifics
|
||||
@primary_role = primary_or_first_role(roles_on(primary_host))
|
||||
|
||||
stable_sort!(roles) { |role| role == primary_role ? 0 : 1 }
|
||||
stable_sort!(hosts) { |host| roles_on(host).any? { |role| role == primary_role } ? 0 : 1 }
|
||||
sort_primary_role_hosts_first!(hosts)
|
||||
end
|
||||
|
||||
def roles_on(host)
|
||||
@@ -19,7 +19,7 @@ class Kamal::Commander::Specifics
|
||||
end
|
||||
|
||||
def app_hosts
|
||||
config.app_hosts & specified_hosts
|
||||
@app_hosts ||= sort_primary_role_hosts_first!(config.app_hosts & specified_hosts)
|
||||
end
|
||||
|
||||
def proxy_hosts
|
||||
@@ -55,4 +55,8 @@ class Kamal::Commander::Specifics
|
||||
specified_hosts
|
||||
end
|
||||
end
|
||||
|
||||
def sort_primary_role_hosts_first!(hosts)
|
||||
stable_sort!(hosts) { |host| roles_on(host).any? { |role| role == primary_role } ? 0 : 1 }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,7 +12,7 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base
|
||||
@accessory_config = config.accessory(name)
|
||||
end
|
||||
|
||||
def run
|
||||
def run(host: nil)
|
||||
docker :run,
|
||||
"--name", service_name,
|
||||
"--detach",
|
||||
@@ -20,6 +20,7 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base
|
||||
*network_args,
|
||||
*config.logging_args,
|
||||
*publish_args,
|
||||
*([ "--env", "KAMAL_HOST=\"#{host}\"" ] if host),
|
||||
*env_args,
|
||||
*volume_args,
|
||||
*label_args,
|
||||
@@ -55,14 +56,14 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base
|
||||
|
||||
def execute_in_existing_container(*command, interactive: false)
|
||||
docker :exec,
|
||||
("-it" if interactive),
|
||||
(docker_interactive_args if interactive),
|
||||
service_name,
|
||||
*command
|
||||
end
|
||||
|
||||
def execute_in_new_container(*command, interactive: false)
|
||||
docker :run,
|
||||
("-it" if interactive),
|
||||
(docker_interactive_args if interactive),
|
||||
"--rm",
|
||||
*network_args,
|
||||
*env_args,
|
||||
|
||||
@@ -20,8 +20,9 @@ class Kamal::Commands::App < Kamal::Commands::Base
|
||||
"--name", container_name,
|
||||
"--network", "kamal",
|
||||
*([ "--hostname", hostname ] if hostname),
|
||||
"-e", "KAMAL_CONTAINER_NAME=\"#{container_name}\"",
|
||||
"-e", "KAMAL_VERSION=\"#{config.version}\"",
|
||||
"--env", "KAMAL_CONTAINER_NAME=\"#{container_name}\"",
|
||||
"--env", "KAMAL_VERSION=\"#{config.version}\"",
|
||||
"--env", "KAMAL_HOST=\"#{host}\"",
|
||||
*role.env_args(host),
|
||||
*role.logging_args,
|
||||
*config.volume_args,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
module Kamal::Commands::App::Execution
|
||||
def execute_in_existing_container(*command, interactive: false, env:)
|
||||
docker :exec,
|
||||
("-it" if interactive),
|
||||
(docker_interactive_args if interactive),
|
||||
*argumentize("--env", env),
|
||||
container_name,
|
||||
*command
|
||||
@@ -9,7 +9,7 @@ module Kamal::Commands::App::Execution
|
||||
|
||||
def execute_in_new_container(*command, interactive: false, detach: false, env:)
|
||||
docker :run,
|
||||
("-it" if interactive),
|
||||
(docker_interactive_args if interactive),
|
||||
("--detach" if detach),
|
||||
("--rm" unless detach),
|
||||
"--network", "kamal",
|
||||
|
||||
@@ -21,6 +21,10 @@ module Kamal::Commands::App::Proxy
|
||||
remove_directory config.proxy_boot.app_directory
|
||||
end
|
||||
|
||||
def create_ssl_directory
|
||||
make_directory(File.join(config.proxy_boot.tls_directory, role.name))
|
||||
end
|
||||
|
||||
private
|
||||
def proxy_exec(*command)
|
||||
docker :exec, proxy_container_name, "kamal-proxy", *command
|
||||
|
||||
@@ -84,6 +84,10 @@ module Kamal::Commands
|
||||
args.compact.unshift :docker
|
||||
end
|
||||
|
||||
def pack(*args)
|
||||
args.compact.unshift :pack
|
||||
end
|
||||
|
||||
def git(*args, path: nil)
|
||||
[ :git, *([ "-C", path ] if path), *args.compact ]
|
||||
end
|
||||
@@ -122,5 +126,9 @@ module Kamal::Commands
|
||||
def ensure_local_buildx_installed
|
||||
docker :buildx, "version"
|
||||
end
|
||||
|
||||
def docker_interactive_args
|
||||
STDIN.isatty ? "-it" : "-i"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,7 @@ require "active_support/core_ext/string/filters"
|
||||
|
||||
class Kamal::Commands::Builder < Kamal::Commands::Base
|
||||
delegate :create, :remove, :dev, :push, :clean, :pull, :info, :inspect_builder, :validate_image, :first_mirror, to: :target
|
||||
delegate :local?, :remote?, :cloud?, to: "config.builder"
|
||||
delegate :local?, :remote?, :pack?, :cloud?, to: "config.builder"
|
||||
|
||||
include Clone
|
||||
|
||||
@@ -17,6 +17,8 @@ class Kamal::Commands::Builder < Kamal::Commands::Base
|
||||
else
|
||||
remote
|
||||
end
|
||||
elsif pack?
|
||||
pack
|
||||
elsif cloud?
|
||||
cloud
|
||||
else
|
||||
@@ -36,6 +38,10 @@ class Kamal::Commands::Builder < Kamal::Commands::Base
|
||||
@hybrid ||= Kamal::Commands::Builder::Hybrid.new(config)
|
||||
end
|
||||
|
||||
def pack
|
||||
@pack ||= Kamal::Commands::Builder::Pack.new(config)
|
||||
end
|
||||
|
||||
def cloud
|
||||
@cloud ||= Kamal::Commands::Builder::Cloud.new(config)
|
||||
end
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
require "shellwords"
|
||||
|
||||
class Kamal::Commands::Builder::Base < Kamal::Commands::Base
|
||||
class BuilderError < StandardError; end
|
||||
|
||||
@@ -6,6 +8,7 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
|
||||
delegate :argumentize, to: Kamal::Utils
|
||||
delegate \
|
||||
:args, :secrets, :dockerfile, :target, :arches, :local_arches, :remote_arches, :remote,
|
||||
:pack?, :pack_builder, :pack_buildpacks,
|
||||
:cache_from, :cache_to, :ssh, :provenance, :sbom, :driver, :docker_driver?,
|
||||
to: :builder_config
|
||||
|
||||
@@ -43,7 +46,7 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
|
||||
end
|
||||
|
||||
def build_context
|
||||
config.builder.context
|
||||
Shellwords.escape(config.builder.context)
|
||||
end
|
||||
|
||||
def validate_image
|
||||
@@ -91,7 +94,7 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
|
||||
|
||||
def build_dockerfile
|
||||
if Pathname.new(File.expand_path(dockerfile)).exist?
|
||||
argumentize "--file", dockerfile
|
||||
argumentize "--file", Shellwords.escape(dockerfile)
|
||||
else
|
||||
raise BuilderError, "Missing #{dockerfile}"
|
||||
end
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
class Kamal::Commands::Builder::Local < Kamal::Commands::Builder::Base
|
||||
def create
|
||||
docker :buildx, :create, "--name", builder_name, "--driver=#{driver}" unless docker_driver?
|
||||
return if docker_driver?
|
||||
|
||||
options =
|
||||
if KAMAL.registry.local?
|
||||
"--driver=#{driver} --driver-opt network=host"
|
||||
else
|
||||
"--driver=#{driver}"
|
||||
end
|
||||
|
||||
docker :buildx, :create, "--name", builder_name, options
|
||||
end
|
||||
|
||||
def remove
|
||||
@@ -9,6 +18,10 @@ class Kamal::Commands::Builder::Local < Kamal::Commands::Builder::Base
|
||||
|
||||
private
|
||||
def builder_name
|
||||
if KAMAL.registry.local?
|
||||
"kamal-local-registry-#{driver}"
|
||||
else
|
||||
"kamal-local-#{driver}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
46
lib/kamal/commands/builder/pack.rb
Normal file
46
lib/kamal/commands/builder/pack.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
class Kamal::Commands::Builder::Pack < Kamal::Commands::Builder::Base
|
||||
def push(export_action = "registry")
|
||||
combine \
|
||||
build,
|
||||
export(export_action)
|
||||
end
|
||||
|
||||
def remove;end
|
||||
|
||||
def info
|
||||
pack :builder, :inspect, pack_builder
|
||||
end
|
||||
alias_method :inspect_builder, :info
|
||||
|
||||
private
|
||||
def build
|
||||
pack(:build,
|
||||
config.repository,
|
||||
"--platform", platform,
|
||||
"--creation-time", "now",
|
||||
"--builder", pack_builder,
|
||||
buildpacks,
|
||||
"-t", config.absolute_image,
|
||||
"-t", config.latest_image,
|
||||
"--env", "BP_IMAGE_LABELS=service=#{config.service}",
|
||||
*argumentize("--env", args),
|
||||
*argumentize("--env", secrets, sensitive: true),
|
||||
"--path", build_context)
|
||||
end
|
||||
|
||||
def export(export_action)
|
||||
return unless export_action == "registry"
|
||||
|
||||
combine \
|
||||
docker(:push, config.absolute_image),
|
||||
docker(:push, config.latest_image)
|
||||
end
|
||||
|
||||
def platform
|
||||
"linux/#{local_arches.first}"
|
||||
end
|
||||
|
||||
def buildpacks
|
||||
(pack_buildpacks << "paketo-buildpacks/image-labels").map { |buildpack| [ "--buildpack", buildpack ] }
|
||||
end
|
||||
end
|
||||
@@ -19,7 +19,7 @@ class Kamal::Commands::Builder::Remote < Kamal::Commands::Builder::Base
|
||||
|
||||
def inspect_builder
|
||||
combine \
|
||||
combine inspect_buildx, inspect_remote_context,
|
||||
combine(inspect_buildx, inspect_remote_context),
|
||||
[ "(echo no compatible builder && exit 1)" ],
|
||||
by: "||"
|
||||
end
|
||||
|
||||
@@ -2,6 +2,8 @@ class Kamal::Commands::Registry < Kamal::Commands::Base
|
||||
def login(registry_config: nil)
|
||||
registry_config ||= config.registry
|
||||
|
||||
return if registry_config.local?
|
||||
|
||||
docker :login,
|
||||
registry_config.server,
|
||||
"-u", sensitive(Kamal::Utils.escape_shell_value(registry_config.username)),
|
||||
@@ -13,4 +15,24 @@ class Kamal::Commands::Registry < Kamal::Commands::Base
|
||||
|
||||
docker :logout, registry_config.server
|
||||
end
|
||||
|
||||
def setup(registry_config: nil)
|
||||
registry_config ||= config.registry
|
||||
|
||||
combine \
|
||||
docker(:start, "kamal-docker-registry"),
|
||||
docker(:run, "--detach", "-p", "127.0.0.1:#{registry_config.local_port}:5000", "--name", "kamal-docker-registry", "registry:3"),
|
||||
by: "||"
|
||||
end
|
||||
|
||||
def remove
|
||||
combine \
|
||||
docker(:stop, "kamal-docker-registry"),
|
||||
docker(:rm, "kamal-docker-registry"),
|
||||
by: "&&"
|
||||
end
|
||||
|
||||
def local?
|
||||
config.registry.local?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,7 +6,7 @@ require "erb"
|
||||
require "net/ssh/proxy/jump"
|
||||
|
||||
class Kamal::Configuration
|
||||
delegate :service, :image, :labels, :hooks_path, to: :raw_config, allow_nil: true
|
||||
delegate :service, :labels, :hooks_path, to: :raw_config, allow_nil: true
|
||||
delegate :argumentize, :optionize, to: Kamal::Utils
|
||||
|
||||
attr_reader :destination, :raw_config, :secrets
|
||||
@@ -63,7 +63,7 @@ class Kamal::Configuration
|
||||
@env = Env.new(config: @raw_config.env || {}, secrets: secrets)
|
||||
|
||||
@logging = Logging.new(logging_config: @raw_config.logging)
|
||||
@proxy = Proxy.new(config: self, proxy_config: @raw_config.key?(:proxy) ? @raw_config.proxy : {})
|
||||
@proxy = Proxy.new(config: self, proxy_config: @raw_config.proxy, secrets: secrets)
|
||||
@proxy_boot = Proxy::Boot.new(config: self)
|
||||
@ssh = Ssh.new(config: self)
|
||||
@sshkit = Sshkit.new(config: self)
|
||||
@@ -157,6 +157,13 @@ class Kamal::Configuration
|
||||
(proxy_roles.flat_map(&:hosts) + proxy_accessories.flat_map(&:hosts)).uniq
|
||||
end
|
||||
|
||||
def image
|
||||
name = raw_config&.image.presence
|
||||
name ||= raw_config&.service if registry.local?
|
||||
|
||||
name
|
||||
end
|
||||
|
||||
def repository
|
||||
[ registry.server, image ].compact.join("/")
|
||||
end
|
||||
@@ -282,10 +289,12 @@ class Kamal::Configuration
|
||||
end
|
||||
|
||||
def ensure_required_keys_present
|
||||
%i[ service image registry ].each do |key|
|
||||
%i[ service registry ].each do |key|
|
||||
raise Kamal::ConfigurationError, "Missing required configuration for #{key}" unless raw_config[key].present?
|
||||
end
|
||||
|
||||
raise Kamal::ConfigurationError, "Missing required configuration for image" if image.blank?
|
||||
|
||||
if raw_config.servers.nil?
|
||||
raise Kamal::ConfigurationError, "No servers or accessories specified" unless raw_config.accessories.present?
|
||||
else
|
||||
|
||||
@@ -125,7 +125,8 @@ class Kamal::Configuration::Accessory
|
||||
Kamal::Configuration::Proxy.new \
|
||||
config: config,
|
||||
proxy_config: accessory_config["proxy"],
|
||||
context: "accessories/#{name}/proxy"
|
||||
context: "accessories/#{name}/proxy",
|
||||
secrets: config.secrets
|
||||
end
|
||||
|
||||
def initialize_registry
|
||||
|
||||
@@ -61,6 +61,10 @@ class Kamal::Configuration::Builder
|
||||
!!builder_config["cache"]
|
||||
end
|
||||
|
||||
def pack?
|
||||
!!builder_config["pack"]
|
||||
end
|
||||
|
||||
def args
|
||||
builder_config["args"] || {}
|
||||
end
|
||||
@@ -85,6 +89,14 @@ class Kamal::Configuration::Builder
|
||||
builder_config.fetch("driver", "docker-container")
|
||||
end
|
||||
|
||||
def pack_builder
|
||||
builder_config["pack"]["builder"] if pack?
|
||||
end
|
||||
|
||||
def pack_buildpacks
|
||||
builder_config["pack"]["buildpacks"] if pack?
|
||||
end
|
||||
|
||||
def local_disabled?
|
||||
builder_config["local"] == false
|
||||
end
|
||||
|
||||
@@ -31,6 +31,19 @@ builder:
|
||||
# Defaults to true:
|
||||
local: true
|
||||
|
||||
# Buildpack configuration
|
||||
#
|
||||
# The build configuration for using pack to build a Cloud Native Buildpack image.
|
||||
#
|
||||
# For additional buildpack customization options you can create a project descriptor
|
||||
# file(project.toml) that the Pack CLI will automatically use.
|
||||
# See https://buildpacks.io/docs/for-app-developers/how-to/build-inputs/use-project-toml/ for more information.
|
||||
pack:
|
||||
builder: heroku/builder:24
|
||||
buildpacks:
|
||||
- heroku/ruby
|
||||
- heroku/procfile
|
||||
|
||||
# Builder cache
|
||||
#
|
||||
# The type must be either 'gha' or 'registry'.
|
||||
|
||||
@@ -10,11 +10,6 @@
|
||||
# They are application-specific, so they are not shared when multiple applications
|
||||
# run on the same proxy.
|
||||
#
|
||||
# The proxy is enabled by default on the primary role but can be disabled by
|
||||
# setting `proxy: false`.
|
||||
#
|
||||
# It is disabled by default on all other roles but can be enabled by setting
|
||||
# `proxy: true` or providing a proxy configuration.
|
||||
proxy:
|
||||
|
||||
# Hosts
|
||||
@@ -52,6 +47,22 @@ proxy:
|
||||
# Defaults to `false`:
|
||||
ssl: true
|
||||
|
||||
# Custom SSL certificate
|
||||
#
|
||||
# In some cases, using Let's Encrypt for automatic certificate management is not an
|
||||
# option, for example if you are running from more than one host.
|
||||
#
|
||||
# Or you may already have SSL certificates issued by a different Certificate Authority (CA).
|
||||
#
|
||||
# Kamal supports loading custom SSL certificates directly from secrets. You should
|
||||
# pass a hash mapping the `certificate_pem` and `private_key_pem` to the secret names.
|
||||
ssl:
|
||||
certificate_pem: CERTIFICATE_PEM
|
||||
private_key_pem: PRIVATE_KEY_PEM
|
||||
# ### Notes
|
||||
# - If the certificate or key is missing or invalid, deployments will fail.
|
||||
# - Always handle SSL certificates and private keys securely. Avoid hard-coding them in source control.
|
||||
|
||||
# SSL redirect
|
||||
#
|
||||
# By default, kamal-proxy will redirect all HTTP requests to HTTPS when SSL is enabled.
|
||||
@@ -74,6 +85,17 @@ proxy:
|
||||
# How long to wait for requests to complete before timing out, defaults to 30 seconds:
|
||||
response_timeout: 10
|
||||
|
||||
# Path-based routing
|
||||
#
|
||||
# For applications that split their traffic to different services based on the request path,
|
||||
# you can use path-based routing to mount services under different path prefixes.
|
||||
path_prefix: '/api'
|
||||
# By default, the path prefix will be stripped from the request before it is forwarded upstream.
|
||||
# So in the example above, a request to /api/users/123 will be forwarded to web-1 as /users/123.
|
||||
# To instead forward the request with the original path (including the prefix),
|
||||
# specify --strip-path-prefix=false
|
||||
strip_path_prefix: false
|
||||
|
||||
# Healthcheck
|
||||
#
|
||||
# When deploying, the proxy will by default hit `/up` once every second until we hit
|
||||
@@ -113,3 +135,30 @@ proxy:
|
||||
response_headers:
|
||||
- X-Request-ID
|
||||
- X-Request-Start
|
||||
|
||||
# Enabling/disabling the proxy on roles
|
||||
#
|
||||
# The proxy is enabled by default on the primary role but can be disabled by
|
||||
# setting `proxy: false` in the primary role's configuration.
|
||||
#
|
||||
# ```yaml
|
||||
# servers:
|
||||
# web:
|
||||
# hosts:
|
||||
# - ...
|
||||
# proxy: false
|
||||
# ```
|
||||
#
|
||||
# It is disabled by default on all other roles but can be enabled by setting
|
||||
# `proxy: true` or providing a proxy configuration for that role.
|
||||
#
|
||||
# ```yaml
|
||||
# servers:
|
||||
# web:
|
||||
# hosts:
|
||||
# - ...
|
||||
# web2:
|
||||
# hosts:
|
||||
# - ...
|
||||
# proxy: true
|
||||
# ```
|
||||
|
||||
@@ -6,11 +6,14 @@ class Kamal::Configuration::Proxy
|
||||
|
||||
delegate :argumentize, :optionize, to: Kamal::Utils
|
||||
|
||||
attr_reader :config, :proxy_config
|
||||
attr_reader :config, :proxy_config, :role_name, :secrets
|
||||
|
||||
def initialize(config:, proxy_config:, context: "proxy")
|
||||
def initialize(config:, proxy_config:, role_name: nil, secrets:, context: "proxy")
|
||||
@config = config
|
||||
@proxy_config = proxy_config
|
||||
@proxy_config = {} if @proxy_config.nil?
|
||||
@role_name = role_name
|
||||
@secrets = secrets
|
||||
validate! @proxy_config, with: Kamal::Configuration::Validator::Proxy, context: context
|
||||
end
|
||||
|
||||
@@ -26,10 +29,46 @@ class Kamal::Configuration::Proxy
|
||||
proxy_config["hosts"] || proxy_config["host"]&.split(",") || []
|
||||
end
|
||||
|
||||
def custom_ssl_certificate?
|
||||
ssl = proxy_config["ssl"]
|
||||
return false unless ssl.is_a?(Hash)
|
||||
ssl["certificate_pem"].present? && ssl["private_key_pem"].present?
|
||||
end
|
||||
|
||||
def certificate_pem_content
|
||||
ssl = proxy_config["ssl"]
|
||||
return nil unless ssl.is_a?(Hash)
|
||||
secrets[ssl["certificate_pem"]]
|
||||
end
|
||||
|
||||
def private_key_pem_content
|
||||
ssl = proxy_config["ssl"]
|
||||
return nil unless ssl.is_a?(Hash)
|
||||
secrets[ssl["private_key_pem"]]
|
||||
end
|
||||
|
||||
def host_tls_cert
|
||||
tls_path(config.proxy_boot.tls_directory, "cert.pem")
|
||||
end
|
||||
|
||||
def host_tls_key
|
||||
tls_path(config.proxy_boot.tls_directory, "key.pem")
|
||||
end
|
||||
|
||||
def container_tls_cert
|
||||
tls_path(config.proxy_boot.tls_container_directory, "cert.pem")
|
||||
end
|
||||
|
||||
def container_tls_key
|
||||
tls_path(config.proxy_boot.tls_container_directory, "key.pem") if custom_ssl_certificate?
|
||||
end
|
||||
|
||||
def deploy_options
|
||||
{
|
||||
host: hosts,
|
||||
tls: proxy_config["ssl"].presence,
|
||||
tls: ssl? ? true : nil,
|
||||
"tls-certificate-path": container_tls_cert,
|
||||
"tls-private-key-path": container_tls_key,
|
||||
"deploy-timeout": seconds_duration(config.deploy_timeout),
|
||||
"drain-timeout": seconds_duration(config.drain_timeout),
|
||||
"health-check-interval": seconds_duration(proxy_config.dig("healthcheck", "interval")),
|
||||
@@ -41,6 +80,8 @@ class Kamal::Configuration::Proxy
|
||||
"buffer-memory": proxy_config.dig("buffering", "memory"),
|
||||
"max-request-body": proxy_config.dig("buffering", "max_request_body"),
|
||||
"max-response-body": proxy_config.dig("buffering", "max_response_body"),
|
||||
"path-prefix": proxy_config.dig("path_prefix"),
|
||||
"strip-path-prefix": proxy_config.dig("strip_path_prefix"),
|
||||
"forward-headers": proxy_config.dig("forward_headers"),
|
||||
"tls-redirect": proxy_config.dig("ssl_redirect"),
|
||||
"log-request-header": proxy_config.dig("logging", "request_headers") || DEFAULT_LOG_REQUEST_HEADERS,
|
||||
@@ -65,10 +106,14 @@ class Kamal::Configuration::Proxy
|
||||
end
|
||||
|
||||
def merge(other)
|
||||
self.class.new config: config, proxy_config: proxy_config.deep_merge(other.proxy_config)
|
||||
self.class.new config: config, proxy_config: other.proxy_config.deep_merge(proxy_config), role_name: role_name, secrets: secrets
|
||||
end
|
||||
|
||||
private
|
||||
def tls_path(directory, filename)
|
||||
File.join([ directory, role_name, filename ].compact) if custom_ssl_certificate?
|
||||
end
|
||||
|
||||
def seconds_duration(value)
|
||||
value ? "#{value}s" : nil
|
||||
end
|
||||
|
||||
@@ -100,6 +100,14 @@ class Kamal::Configuration::Proxy::Boot
|
||||
File.join app_container_directory, "error_pages"
|
||||
end
|
||||
|
||||
def tls_directory
|
||||
File.join app_directory, "tls"
|
||||
end
|
||||
|
||||
def tls_container_directory
|
||||
File.join app_container_directory, "tls"
|
||||
end
|
||||
|
||||
private
|
||||
def ensure_valid_bind_ips(bind_ips)
|
||||
bind_ips.present? && bind_ips.each do |ip|
|
||||
|
||||
@@ -19,6 +19,14 @@ class Kamal::Configuration::Registry
|
||||
lookup("password")
|
||||
end
|
||||
|
||||
def local?
|
||||
server.to_s.match?("^localhost[:$]")
|
||||
end
|
||||
|
||||
def local_port
|
||||
local? ? (server.split(":").last.to_i || 80) : nil
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :registry_config, :secrets
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ class Kamal::Configuration::Role
|
||||
end
|
||||
|
||||
def proxy
|
||||
@proxy ||= config.proxy.merge(specialized_proxy) if running_proxy?
|
||||
@proxy ||= specialized_proxy.merge(config.proxy) if running_proxy?
|
||||
end
|
||||
|
||||
def running_proxy?
|
||||
@@ -150,8 +150,8 @@ class Kamal::Configuration::Role
|
||||
end
|
||||
|
||||
def ensure_one_host_for_ssl
|
||||
if running_proxy? && proxy.ssl? && hosts.size > 1
|
||||
raise Kamal::ConfigurationError, "SSL is only supported on a single server, found #{hosts.size} servers for role #{name}"
|
||||
if running_proxy? && proxy.ssl? && hosts.size > 1 && !proxy.custom_ssl_certificate?
|
||||
raise Kamal::ConfigurationError, "SSL is only supported on a single server unless you provide custom certificates, found #{hosts.size} servers for role #{name}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -173,6 +173,8 @@ class Kamal::Configuration::Role
|
||||
@specialized_proxy = Kamal::Configuration::Proxy.new \
|
||||
config: config,
|
||||
proxy_config: proxy_config,
|
||||
secrets: config.secrets,
|
||||
role_name: name,
|
||||
context: "servers/#{name}/proxy"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -27,6 +27,8 @@ class Kamal::Configuration::Validator
|
||||
unless key.to_s == "proxy" && boolean?(value.class)
|
||||
validate_type! value, *(Array if key == :servers), Hash
|
||||
end
|
||||
elsif key.to_s == "ssl"
|
||||
validate_type! value, TrueClass, FalseClass, Hash
|
||||
elsif key == "hosts"
|
||||
validate_servers! value
|
||||
elsif example_value.is_a?(Array)
|
||||
@@ -169,6 +171,18 @@ class Kamal::Configuration::Validator
|
||||
unknown_keys_error unknown_keys if unknown_keys.present?
|
||||
end
|
||||
|
||||
def validate_labels!(labels)
|
||||
return true if labels.blank?
|
||||
|
||||
with_context("labels") do
|
||||
labels.each do |key, _|
|
||||
with_context(key) do
|
||||
error "invalid label. destination, role, and service are reserved labels" if %w[destination role service].include?(key)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def validate_docker_options!(options)
|
||||
if options
|
||||
error "Cannot set restart policy in docker options, unless-stopped is required" if options["restart"]
|
||||
|
||||
@@ -6,6 +6,8 @@ class Kamal::Configuration::Validator::Accessory < Kamal::Configuration::Validat
|
||||
error "specify one of `host`, `hosts`, `role`, `roles`, `tag` or `tags`"
|
||||
end
|
||||
|
||||
validate_labels!(config["labels"])
|
||||
|
||||
validate_docker_options!(config["options"])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,6 +8,8 @@ class Kamal::Configuration::Validator::Builder < Kamal::Configuration::Validator
|
||||
|
||||
error "Builder arch not set" unless config["arch"].present?
|
||||
|
||||
error "buildpacks only support building for one arch" if config["pack"] && config["arch"].is_a?(Array) && config["arch"].size > 1
|
||||
|
||||
error "Cannot disable local builds, no remote is set" if config["local"] == false && config["remote"].blank?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,6 +10,16 @@ class Kamal::Configuration::Validator::Proxy < Kamal::Configuration::Validator
|
||||
if (config.keys & [ "host", "hosts" ]).size > 1
|
||||
error "Specify one of 'host' or 'hosts', not both"
|
||||
end
|
||||
|
||||
if config["ssl"].is_a?(Hash)
|
||||
if config["ssl"]["certificate_pem"].present? && config["ssl"]["private_key_pem"].blank?
|
||||
error "Missing private_key_pem setting (required when certificate_pem is present)"
|
||||
end
|
||||
|
||||
if config["ssl"]["private_key_pem"].present? && config["ssl"]["certificate_pem"].blank?
|
||||
error "Missing certificate_pem setting (required when private_key_pem is present)"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,6 +15,7 @@ class Kamal::Configuration::Validator::Registry < Kamal::Configuration::Validato
|
||||
with_context(key) do
|
||||
value = config[key]
|
||||
|
||||
unless config["server"]&.match?("^localhost[:$]")
|
||||
error "is required" unless value.present?
|
||||
|
||||
unless value.is_a?(String) || (value.is_a?(Array) && value.size == 1 && value.first.is_a?(String))
|
||||
@@ -22,4 +23,5 @@ class Kamal::Configuration::Validator::Registry < Kamal::Configuration::Validato
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,7 @@ class Kamal::Configuration::Validator::Role < Kamal::Configuration::Validator
|
||||
validate_servers!(config)
|
||||
else
|
||||
super
|
||||
validate_labels!(config["labels"])
|
||||
validate_docker_options!(config["options"])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
require "tempfile"
|
||||
require "open3"
|
||||
require "shellwords"
|
||||
|
||||
module Kamal::Docker
|
||||
extend self
|
||||
@@ -15,7 +16,7 @@ module Kamal::Docker
|
||||
DOCKERFILE
|
||||
dockerfile.close
|
||||
|
||||
cmd = "docker buildx build -t=#{BUILD_CHECK_TAG} -f=#{dockerfile.path} ."
|
||||
cmd = "docker buildx build -t=#{BUILD_CHECK_TAG} -f=#{Shellwords.escape(dockerfile.path)} ."
|
||||
system(cmd) || raise("failed to build check image")
|
||||
end
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ class Kamal::Secrets::Adapters::BitwardenSecretsManager < Kamal::Secrets::Adapte
|
||||
private
|
||||
LIST_ALL_SELECTOR = "all"
|
||||
LIST_ALL_FROM_PROJECT_SUFFIX = "/all"
|
||||
LIST_COMMAND = "secret list -o env"
|
||||
GET_COMMAND = "secret get -o env"
|
||||
LIST_COMMAND = "secret list"
|
||||
GET_COMMAND = "secret get"
|
||||
|
||||
def fetch_secrets(secrets, from:, account:, session:)
|
||||
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
|
||||
{}.tap do |results|
|
||||
if command.nil?
|
||||
secrets.each do |secret_uuid|
|
||||
secret = run_command("#{GET_COMMAND} #{secret_uuid.shellescape}")
|
||||
item_json = run_command("#{GET_COMMAND} #{secret_uuid.shellescape}")
|
||||
raise RuntimeError, "Could not read #{secret_uuid} from Bitwarden Secrets Manager" unless $?.success?
|
||||
key, value = parse_secret(secret)
|
||||
results[key] = value
|
||||
item_json = JSON.parse(item_json)
|
||||
results[item_json["key"]] = item_json["value"]
|
||||
end
|
||||
else
|
||||
secrets = run_command(command)
|
||||
items_json = run_command(command)
|
||||
raise RuntimeError, "Could not read secrets from Bitwarden Secrets Manager" unless $?.success?
|
||||
secrets.split("\n").each do |secret|
|
||||
key, value = parse_secret(secret)
|
||||
results[key] = value
|
||||
|
||||
JSON.parse(items_json).each do |item_json|
|
||||
results[item_json["key"]] = item_json["value"]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -45,19 +45,13 @@ class Kamal::Secrets::Adapters::BitwardenSecretsManager < Kamal::Secrets::Adapte
|
||||
end
|
||||
end
|
||||
|
||||
def parse_secret(secret)
|
||||
key, value = secret.split("=", 2)
|
||||
value = value.gsub(/^"|"$/, "")
|
||||
[ key, value ]
|
||||
end
|
||||
|
||||
def run_command(command, session: nil)
|
||||
full_command = [ "bws", command ].join(" ")
|
||||
`#{full_command}`
|
||||
end
|
||||
|
||||
def login(account)
|
||||
run_command("run 'echo OK'")
|
||||
run_command("project list")
|
||||
raise RuntimeError, "Could not authenticate to Bitwarden Secrets Manager. Did you set a valid access token?" unless $?.success?
|
||||
end
|
||||
|
||||
|
||||
@@ -16,20 +16,36 @@ class Kamal::Secrets::Adapters::OnePassword < Kamal::Secrets::Adapters::Base
|
||||
end
|
||||
|
||||
def fetch_secrets(secrets, from:, account:, session:)
|
||||
if secrets.blank?
|
||||
fetch_all_secrets(from: from, account: account, session: session)
|
||||
else
|
||||
fetch_specified_secrets(secrets, from: from, account: account, session: session)
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_specified_secrets(secrets, from:, account:, session:)
|
||||
{}.tap do |results|
|
||||
vaults_items_fields(prefixed_secrets(secrets, from: from)).map do |vault, items|
|
||||
items.each do |item, fields|
|
||||
fields_json = JSON.parse(op_item_get(vault, item, fields, account: account, session: session))
|
||||
fields_json = JSON.parse(op_item_get(vault, item, fields: fields, account: account, session: session))
|
||||
fields_json = [ fields_json ] if fields.one?
|
||||
|
||||
fields_json.each do |field_json|
|
||||
# The reference is in the form `op://vault/item/field[/field]`
|
||||
field = field_json["reference"].delete_prefix("op://").delete_suffix("/password")
|
||||
results[field] = field_json["value"]
|
||||
results.merge!(fields_map(fields_json))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_all_secrets(from:, account:, session:)
|
||||
{}.tap do |results|
|
||||
vault_items(from).each do |vault, items|
|
||||
items.each do |item|
|
||||
fields_json = JSON.parse(op_item_get(vault, item, account: account, session: session)).fetch("fields")
|
||||
|
||||
results.merge!(fields_map(fields_json))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def to_options(**options)
|
||||
@@ -50,12 +66,30 @@ class Kamal::Secrets::Adapters::OnePassword < Kamal::Secrets::Adapters::Base
|
||||
end
|
||||
end
|
||||
|
||||
def op_item_get(vault, item, fields, account:, session:)
|
||||
labels = fields.map { |field| "label=#{field}" }.join(",")
|
||||
options = to_options(vault: vault, fields: labels, format: "json", account: account, session: session.presence)
|
||||
def vault_items(from)
|
||||
from = from.delete_prefix("op://")
|
||||
vault, item = from.split("/")
|
||||
{ vault => [ item ] }
|
||||
end
|
||||
|
||||
`op item get #{item.shellescape} #{options}`.tap do
|
||||
raise RuntimeError, "Could not read #{fields.join(", ")} from #{item} in the #{vault} 1Password vault" unless $?.success?
|
||||
def fields_map(fields_json)
|
||||
fields_json.to_h do |field_json|
|
||||
# The reference is in the form `op://vault/item/field[/field]`
|
||||
field = field_json["reference"].delete_prefix("op://").delete_suffix("/password")
|
||||
[ field, field_json["value"] ]
|
||||
end
|
||||
end
|
||||
|
||||
def op_item_get(vault, item, fields: nil, account:, session:)
|
||||
options = { vault: vault, format: "json", account: account, session: session.presence }
|
||||
|
||||
if fields.present?
|
||||
labels = fields.map { |field| "label=#{field}" }.join(",")
|
||||
options.merge!(fields: labels)
|
||||
end
|
||||
|
||||
`op item get #{item.shellescape} #{to_options(**options)}`.tap do
|
||||
raise RuntimeError, "Could not read #{"#{fields.join(", ")} " if fields.present?}from #{item} in the #{vault} 1Password vault" unless $?.success?
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
130
lib/kamal/secrets/adapters/passbolt.rb
Normal file
130
lib/kamal/secrets/adapters/passbolt.rb
Normal file
@@ -0,0 +1,130 @@
|
||||
class Kamal::Secrets::Adapters::Passbolt < Kamal::Secrets::Adapters::Base
|
||||
def requires_account?
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def login(*)
|
||||
`passbolt verify`
|
||||
raise RuntimeError, "Failed to login to Passbolt" unless $?.success?
|
||||
end
|
||||
|
||||
def fetch_secrets(secrets, from:, **)
|
||||
secrets = prefixed_secrets(secrets, from: from)
|
||||
raise ArgumentError, "No secrets given to fetch" if secrets.empty?
|
||||
|
||||
secret_names = secrets.collect { |s| s.split("/").last }
|
||||
folders = secrets_get_folders(secrets)
|
||||
|
||||
# build filter conditions for each secret with its corresponding folder
|
||||
filter_conditions = []
|
||||
secrets.each do |secret|
|
||||
parts = secret.split("/")
|
||||
secret_name = parts.last
|
||||
|
||||
if parts.size > 1
|
||||
# get the folder path without the secret name
|
||||
folder_path = parts[0..-2]
|
||||
|
||||
# find the most nested folder for this path
|
||||
current_folder = nil
|
||||
current_path = []
|
||||
|
||||
folder_path.each do |folder_name|
|
||||
current_path << folder_name
|
||||
matching_folders = folders.select { |f| get_folder_path(f, folders) == current_path.join("/") }
|
||||
current_folder = matching_folders.first if matching_folders.any?
|
||||
end
|
||||
|
||||
if current_folder
|
||||
filter_conditions << "(Name == #{secret_name.shellescape.inspect} && FolderParentID == #{current_folder["id"].shellescape.inspect})"
|
||||
end
|
||||
else
|
||||
# for root level secrets (no folders)
|
||||
filter_conditions << "Name == #{secret_name.shellescape.inspect}"
|
||||
end
|
||||
end
|
||||
|
||||
filter_condition = filter_conditions.any? ? "--filter '#{filter_conditions.join(" || ")}'" : ""
|
||||
items = `passbolt list resources #{filter_condition} #{folders.map { |item| "--folder #{item["id"]}" }.join(" ")} --json`
|
||||
raise RuntimeError, "Could not read #{secrets} from Passbolt" unless $?.success?
|
||||
|
||||
items = JSON.parse(items)
|
||||
found_names = items.map { |item| item["name"] }
|
||||
missing_secrets = secret_names - found_names
|
||||
raise RuntimeError, "Could not find the following secrets in Passbolt: #{missing_secrets.join(", ")}" if missing_secrets.any?
|
||||
|
||||
items.to_h { |item| [ item["name"], item["password"] ] }
|
||||
end
|
||||
|
||||
def secrets_get_folders(secrets)
|
||||
# extract all folder paths (both parent and nested)
|
||||
folder_paths = secrets
|
||||
.select { |s| s.include?("/") }
|
||||
.map { |s| s.split("/")[0..-2] } # get all parts except the secret name
|
||||
.uniq
|
||||
|
||||
return [] if folder_paths.empty?
|
||||
|
||||
all_folders = []
|
||||
|
||||
# first get all top-level folders
|
||||
parent_folders = folder_paths.map(&:first).uniq
|
||||
filter_condition = "--filter '#{parent_folders.map { |name| "Name == #{name.shellescape.inspect}" }.join(" || ")}'"
|
||||
fetch_folders = `passbolt list folders #{filter_condition} --json`
|
||||
raise RuntimeError, "Could not read folders from Passbolt" unless $?.success?
|
||||
|
||||
parent_folder_items = JSON.parse(fetch_folders)
|
||||
all_folders.concat(parent_folder_items)
|
||||
|
||||
# get nested folders for each parent
|
||||
folder_paths.each do |path|
|
||||
next if path.size <= 1 # skip non-nested folders
|
||||
|
||||
parent = path[0]
|
||||
parent_folder = parent_folder_items.find { |f| f["name"] == parent }
|
||||
next unless parent_folder
|
||||
|
||||
# for each nested level, get the folders using the parent's ID
|
||||
current_parent = parent_folder
|
||||
path[1..-1].each do |folder_name|
|
||||
filter_condition = "--filter 'Name == #{folder_name.shellescape.inspect} && FolderParentID == #{current_parent["id"].shellescape.inspect}'"
|
||||
fetch_nested = `passbolt list folders #{filter_condition} --json`
|
||||
next unless $?.success?
|
||||
|
||||
nested_folders = JSON.parse(fetch_nested)
|
||||
break if nested_folders.empty?
|
||||
|
||||
all_folders.concat(nested_folders)
|
||||
current_parent = nested_folders.first
|
||||
end
|
||||
end
|
||||
|
||||
# check if we found all required folders
|
||||
found_paths = all_folders.map { |f| get_folder_path(f, all_folders) }
|
||||
missing_paths = folder_paths.map { |path| path.join("/") } - found_paths
|
||||
raise RuntimeError, "Could not find the following folders in Passbolt: #{missing_paths.join(", ")}" if missing_paths.any?
|
||||
|
||||
all_folders
|
||||
end
|
||||
|
||||
def get_folder_path(folder, all_folders, path = [])
|
||||
path.unshift(folder["name"])
|
||||
return path.join("/") if folder["folder_parent_id"].to_s.empty?
|
||||
|
||||
parent = all_folders.find { |f| f["id"] == folder["folder_parent_id"] }
|
||||
return path.join("/") unless parent
|
||||
|
||||
get_folder_path(parent, all_folders, path)
|
||||
end
|
||||
|
||||
def check_dependencies!
|
||||
raise RuntimeError, "Passbolt CLI is not installed" unless cli_installed?
|
||||
end
|
||||
|
||||
def cli_installed?
|
||||
`passbolt --version 2> /dev/null`
|
||||
$?.success?
|
||||
end
|
||||
end
|
||||
@@ -1,3 +1,3 @@
|
||||
module Kamal
|
||||
VERSION = "2.6.0"
|
||||
VERSION = "2.7.0"
|
||||
end
|
||||
|
||||
@@ -15,7 +15,7 @@ class CliAccessoryTest < CliTestCase
|
||||
|
||||
run_command("boot", "mysql").tap do |output|
|
||||
assert_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.3", output
|
||||
assert_match "docker run --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" private.registry/mysql:5.7 on 1.1.1.3", output
|
||||
assert_match "docker run --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env KAMAL_HOST=\"1.1.1.3\" --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" private.registry/mysql:5.7 on 1.1.1.3", output
|
||||
end
|
||||
end
|
||||
|
||||
@@ -35,10 +35,10 @@ class CliAccessoryTest < CliTestCase
|
||||
assert_match /docker network create kamal.*on 1.1.1.1/, output
|
||||
assert_match /docker network create kamal.*on 1.1.1.2/, output
|
||||
assert_match /docker network create kamal.*on 1.1.1.3/, output
|
||||
assert_match "docker run --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" private.registry/mysql:5.7 on 1.1.1.3", output
|
||||
assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output
|
||||
assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.2", output
|
||||
assert_match "docker run --name custom-box --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --env-file .kamal/apps/app/env/accessories/busybox.env --label service=\"custom-box\" other.registry/busybox:latest on 1.1.1.3", output
|
||||
assert_match "docker run --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env KAMAL_HOST=\"1.1.1.3\" --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" private.registry/mysql:5.7 on 1.1.1.3", output
|
||||
assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env KAMAL_HOST=\"1.1.1.1\" --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output
|
||||
assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env KAMAL_HOST=\"1.1.1.2\" --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.2", output
|
||||
assert_match "docker run --name custom-box --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --env KAMAL_HOST=\"1.1.1.3\" --env-file .kamal/apps/app/env/accessories/busybox.env --label service=\"custom-box\" other.registry/busybox:latest on 1.1.1.3", output
|
||||
end
|
||||
end
|
||||
|
||||
@@ -215,8 +215,8 @@ class CliAccessoryTest < CliTestCase
|
||||
run_command("boot", "redis", "--hosts", "1.1.1.1").tap do |output|
|
||||
assert_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.1", output
|
||||
assert_no_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.2", output
|
||||
assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output
|
||||
assert_no_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.2", output
|
||||
assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env KAMAL_HOST=\"1.1.1.1\" --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output
|
||||
assert_no_match /docker run --name app-redis .* on 1.1.1.2/, output
|
||||
end
|
||||
end
|
||||
|
||||
@@ -227,8 +227,8 @@ class CliAccessoryTest < CliTestCase
|
||||
run_command("boot", "redis", "--hosts", "1.1.1.1,1.1.1.3").tap do |output|
|
||||
assert_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.1", output
|
||||
assert_no_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.3", output
|
||||
assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output
|
||||
assert_no_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.3", output
|
||||
assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env KAMAL_HOST=\"1.1.1.1\" --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output
|
||||
assert_no_match /docker run --name app-redis .* on 1.1.1.3/, output
|
||||
end
|
||||
end
|
||||
|
||||
@@ -237,7 +237,7 @@ class CliAccessoryTest < CliTestCase
|
||||
assert_match "Upgrading all accessories on 1.1.1.3,1.1.1.1,1.1.1.2...", output
|
||||
assert_match "docker network create kamal on 1.1.1.3", output
|
||||
assert_match "docker container stop app-mysql on 1.1.1.3", output
|
||||
assert_match "docker run --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST="%" --env-file .kamal/apps/app/env/accessories/mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" private.registry/mysql:5.7 on 1.1.1.3", output
|
||||
assert_match "docker run --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env KAMAL_HOST=\"1.1.1.3\" --env MYSQL_ROOT_HOST="%" --env-file .kamal/apps/app/env/accessories/mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" private.registry/mysql:5.7 on 1.1.1.3", output
|
||||
assert_match "Upgraded all accessories on 1.1.1.3,1.1.1.1,1.1.1.2...", output
|
||||
end
|
||||
end
|
||||
@@ -247,15 +247,15 @@ class CliAccessoryTest < CliTestCase
|
||||
assert_match "Upgrading all accessories on 1.1.1.3...", output
|
||||
assert_match "docker network create kamal on 1.1.1.3", output
|
||||
assert_match "docker container stop app-mysql on 1.1.1.3", output
|
||||
assert_match "docker run --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST="%" --env-file .kamal/apps/app/env/accessories/mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" private.registry/mysql:5.7 on 1.1.1.3", output
|
||||
assert_match "docker run --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env KAMAL_HOST=\"1.1.1.3\" --env MYSQL_ROOT_HOST="%" --env-file .kamal/apps/app/env/accessories/mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" private.registry/mysql:5.7 on 1.1.1.3", output
|
||||
assert_match "Upgraded all accessories on 1.1.1.3", output
|
||||
end
|
||||
end
|
||||
|
||||
test "boot with web role filter" do
|
||||
run_command("boot", "redis", "-r", "web").tap do |output|
|
||||
assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output
|
||||
assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.2", output
|
||||
assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env KAMAL_HOST=\"1.1.1.1\" --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output
|
||||
assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env KAMAL_HOST=\"1.1.1.2\" --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.2", output
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ class CliAppTest < CliTestCase
|
||||
|
||||
run_command("boot", config: :with_env_tags).tap do |output|
|
||||
assert_match "docker tag dhh/app:latest dhh/app:latest", output
|
||||
assert_match %r{docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12} -e KAMAL_CONTAINER_NAME="app-web-latest" -e KAMAL_VERSION="latest" --env TEST="root" --env EXPERIMENT="disabled" --env SITE="site1"}, output
|
||||
assert_match %r{docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12} --env KAMAL_CONTAINER_NAME="app-web-latest" --env KAMAL_VERSION="latest" --env KAMAL_HOST="1.1.1.1" --env TEST="root" --env EXPERIMENT="disabled" --env SITE="site1"}, output
|
||||
assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output
|
||||
end
|
||||
end
|
||||
@@ -220,6 +220,21 @@ class CliAppTest < CliTestCase
|
||||
end
|
||||
end
|
||||
|
||||
test "boot with custom ssl certificate" do
|
||||
Kamal::Configuration::Proxy.any_instance.stubs(:custom_ssl_certificate?).returns(true)
|
||||
Kamal::Configuration::Proxy.any_instance.stubs(:certificate_pem_content).returns("CERTIFICATE CONTENT")
|
||||
Kamal::Configuration::Proxy.any_instance.stubs(:private_key_pem_content).returns("PRIVATE KEY CONTENT")
|
||||
|
||||
stub_running
|
||||
run_command("boot", config: :with_proxy).tap do |output|
|
||||
assert_match "Writing SSL certificates for web on 1.1.1.1", output
|
||||
assert_match "mkdir -p .kamal/proxy/apps-config/app/tls", output
|
||||
assert_match "Uploading \"CERTIFICATE CONTENT\" to .kamal/proxy/apps-config/app/tls/web/cert.pem", output
|
||||
assert_match "--tls-certificate-path=\"/home/kamal-proxy/.apps-config/app/tls/web/cert.pem\"", output
|
||||
assert_match "--tls-private-key-path=\"/home/kamal-proxy/.apps-config/app/tls/web/key.pem\"", output
|
||||
end
|
||||
end
|
||||
|
||||
test "start" do
|
||||
SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("999") # old version
|
||||
|
||||
@@ -361,6 +376,7 @@ class CliAppTest < CliTestCase
|
||||
SSHKit::Backend::Abstract.any_instance.expects(:exec)
|
||||
.with("ssh -t root@1.1.1.1 -p 22 'docker run -it --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" dhh/app:latest ruby -v'")
|
||||
|
||||
stub_stdin_tty do
|
||||
run_command("exec", "-i", "ruby -v").tap do |output|
|
||||
assert_hook_ran "pre-connect", output
|
||||
assert_match "docker login -u [REDACTED] -p [REDACTED]", output
|
||||
@@ -368,12 +384,14 @@ class CliAppTest < CliTestCase
|
||||
assert_match "Launching interactive command with version latest via SSH from new container on 1.1.1.1...", output
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "exec interactive with reuse" do
|
||||
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
|
||||
SSHKit::Backend::Abstract.any_instance.expects(:exec)
|
||||
.with("ssh -t root@1.1.1.1 -p 22 'docker exec -it app-web-999 ruby -v'")
|
||||
|
||||
stub_stdin_tty do
|
||||
run_command("exec", "-i", "--reuse", "ruby -v").tap do |output|
|
||||
assert_hook_ran "pre-connect", output
|
||||
assert_match "Get current version of running container...", output
|
||||
@@ -381,6 +399,20 @@ class CliAppTest < CliTestCase
|
||||
assert_match "Launching interactive command with version 999 via SSH from existing container on 1.1.1.1...", output
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "exec interactive with pipe on STDIN" do
|
||||
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
|
||||
SSHKit::Backend::Abstract.any_instance.expects(:exec)
|
||||
.with("ssh -t root@1.1.1.1 -p 22 'docker exec -i app-web-999 ruby -v'")
|
||||
|
||||
stub_stdin_file do
|
||||
run_command("exec", "-i", "--reuse", "ruby -v").tap do |output|
|
||||
assert_hook_ran "pre-connect", output
|
||||
assert_match "Launching interactive command with version 999 via SSH from existing container on 1.1.1.1...", output
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "containers" do
|
||||
run_command("containers").tap do |output|
|
||||
@@ -474,7 +506,7 @@ class CliAppTest < CliTestCase
|
||||
run_command("boot", config: :with_proxy).tap do |output|
|
||||
assert_match /Renaming container .* to .* as already deployed on 1.1.1.1/, output # Rename
|
||||
assert_match /docker rename app-web-latest app-web-latest_replaced_[0-9a-f]{16}/, output
|
||||
assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12} -e KAMAL_CONTAINER_NAME="app-web-latest" -e KAMAL_VERSION="latest" --env-file .kamal\/apps\/app\/env\/roles\/web.env --log-opt max-size="10m" --label service="app" --label role="web" --label destination dhh\/app:latest/, output
|
||||
assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12} --env KAMAL_CONTAINER_NAME="app-web-latest" --env KAMAL_VERSION="latest" --env KAMAL_HOST="1.1.1.1" --env-file .kamal\/apps\/app\/env\/roles\/web.env --log-opt max-size="10m" --label service="app" --label role="web" --label destination dhh\/app:latest/, output
|
||||
assert_match /docker exec kamal-proxy kamal-proxy deploy app-web --target="123:80"/, output
|
||||
assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output
|
||||
end
|
||||
|
||||
@@ -21,6 +21,7 @@ class CliBuildTest < CliTestCase
|
||||
.returns("")
|
||||
|
||||
run_command("push", "--verbose").tap do |output|
|
||||
assert_hook_ran "pre-connect", output
|
||||
assert_hook_ran "pre-build", output
|
||||
assert_match /Cloning repo into build directory/, output
|
||||
assert_match /git -C #{Dir.tmpdir}\/kamal-clones\/app-#{pwd_sha} clone #{Dir.pwd}/, output
|
||||
@@ -30,6 +31,24 @@ class CliBuildTest < CliTestCase
|
||||
end
|
||||
end
|
||||
|
||||
test "push with remote builder checks both the builder and the remote context" do
|
||||
with_build_directory do |build_directory|
|
||||
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
|
||||
|
||||
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
|
||||
.with(:git, "-C", anything, :"rev-parse", :HEAD)
|
||||
.returns(Kamal::Git.revision)
|
||||
|
||||
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
|
||||
.with(:git, "-C", anything, :status, "--porcelain")
|
||||
.returns("")
|
||||
|
||||
run_command("push", "--verbose", fixture: :with_remote_builder).tap do |output|
|
||||
assert_match "docker buildx inspect kamal-remote-ssh---app-1-1-1-5 | grep -q Endpoint:.*kamal-remote-ssh---app-1-1-1-5-context && docker context inspect kamal-remote-ssh---app-1-1-1-5-context --format '{{.Endpoints.docker.Host}}' | grep -xq ssh://app@1.1.1.5 || (echo no compatible builder && exit 1)", output
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "push --output=docker" do
|
||||
with_build_directory do |build_directory|
|
||||
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
|
||||
@@ -134,6 +153,48 @@ class CliBuildTest < CliTestCase
|
||||
end
|
||||
end
|
||||
|
||||
test "push without builder for local registry" do
|
||||
with_build_directory do |build_directory|
|
||||
stub_setup
|
||||
|
||||
SSHKit::Backend::Abstract.any_instance.expects(:execute)
|
||||
.with(:docker, "--version", "&&", :docker, :buildx, "version")
|
||||
|
||||
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
|
||||
.with { |*args| args[0..1] == [ :docker, :login ] }
|
||||
|
||||
SSHKit::Backend::Abstract.any_instance.expects(:execute)
|
||||
.with(:docker, :start, "kamal-docker-registry", "||", :docker, :run, "--detach", "-p", "127.0.0.1:5000:5000", "--name", "kamal-docker-registry", "registry:3")
|
||||
|
||||
SSHKit::Backend::Abstract.any_instance.expects(:execute)
|
||||
.with(:docker, :buildx, :rm, "kamal-local-registry-docker-container")
|
||||
|
||||
SSHKit::Backend::Abstract.any_instance.expects(:execute)
|
||||
.with(:docker, :buildx, :create, "--name", "kamal-local-registry-docker-container", "--driver=docker-container --driver-opt network=host")
|
||||
|
||||
SSHKit::Backend::Abstract.any_instance.expects(:execute)
|
||||
.with(:docker, :buildx, :inspect, "kamal-local-registry-docker-container")
|
||||
.raises(SSHKit::Command::Failed.new("no builder"))
|
||||
|
||||
SSHKit::Backend::Abstract.any_instance.expects(:execute).with { |*args| args.first.to_s.start_with?("git") }
|
||||
|
||||
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
|
||||
.with(:git, "-C", anything, :"rev-parse", :HEAD)
|
||||
.returns(Kamal::Git.revision)
|
||||
|
||||
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
|
||||
.with(:git, "-C", anything, :status, "--porcelain")
|
||||
.returns("")
|
||||
|
||||
SSHKit::Backend::Abstract.any_instance.expects(:execute)
|
||||
.with(:docker, :buildx, :build, "--output=type=registry", "--platform", "linux/amd64", "--builder", "kamal-local-registry-docker-container", "-t", "localhost:5000/dhh/app:999", "-t", "localhost:5000/dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".", "2>&1")
|
||||
|
||||
run_command("push", fixture: :with_local_registry_and_accessories).tap do |output|
|
||||
assert_match /WARN Missing compatible builder, so creating a new one first/, output
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "push without builder" do
|
||||
with_build_directory do |build_directory|
|
||||
stub_setup
|
||||
|
||||
@@ -39,6 +39,30 @@ class CliMainTest < CliTestCase
|
||||
end
|
||||
end
|
||||
|
||||
test "deploy with local registry" do
|
||||
with_test_secrets("secrets" => "DB_PASSWORD=secret") do
|
||||
invoke_options = { "config_file" => "test/fixtures/deploy_with_local_registry.yml", "version" => "999", "skip_hooks" => false, "verbose" => true }
|
||||
|
||||
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options)
|
||||
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options)
|
||||
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true))
|
||||
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options)
|
||||
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options)
|
||||
|
||||
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
|
||||
|
||||
run_command("deploy", "--verbose", config_file: "deploy_with_local_registry").tap do |output|
|
||||
assert_hook_ran "pre-connect", output
|
||||
assert_match /Build and push app image/, output
|
||||
assert_hook_ran "pre-deploy", output
|
||||
assert_match /Ensure kamal-proxy is running/, output
|
||||
assert_match /Detect stale containers/, output
|
||||
assert_match /Prune old containers and images/, output
|
||||
assert_hook_ran "post-deploy", output
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "deploy" do
|
||||
with_test_secrets("secrets" => "DB_PASSWORD=secret") do
|
||||
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false, "verbose" => true }
|
||||
@@ -302,6 +326,16 @@ class CliMainTest < CliTestCase
|
||||
end
|
||||
end
|
||||
|
||||
test "remove" do
|
||||
options = { "config_file" => "test/fixtures/deploy_simple.yml", "skip_hooks" => false, "confirmed" => true }
|
||||
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:remove", [], options)
|
||||
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:remove", [], options)
|
||||
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:remove", [ "all" ], options)
|
||||
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:remove", [], options.merge(skip_local: true))
|
||||
|
||||
run_command("remove", "-y")
|
||||
end
|
||||
|
||||
test "details" do
|
||||
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:details")
|
||||
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:details")
|
||||
|
||||
@@ -44,42 +44,20 @@ class CliProxyTest < CliTestCase
|
||||
end
|
||||
|
||||
test "reboot" do
|
||||
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
|
||||
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet")
|
||||
.returns("abcdefabcdef")
|
||||
.at_least_once
|
||||
|
||||
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
|
||||
.with { |*args| args[0..1] == [ :sh, "-c" ] }
|
||||
.returns("123")
|
||||
.at_least_once
|
||||
|
||||
run_command("reboot", "-y").tap do |output|
|
||||
assert_match "docker container stop kamal-proxy on 1.1.1.1", output
|
||||
assert_match "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy on 1.1.1.1", output
|
||||
assert_match "mkdir -p .kamal/proxy/apps-config on 1.1.1.1", output
|
||||
assert_match "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::Proxy::Boot::MINIMUM_VERSION}\") $(cat .kamal/proxy/run_command 2> /dev/null || echo \"\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $(pwd)/.kamal/proxy/apps-config:/home/kamal-proxy/.apps-config on 1.1.1.1", output
|
||||
assert_match "docker exec kamal-proxy kamal-proxy deploy app-web --target=\"abcdefabcdef:80\" --deploy-timeout=\"6s\" --drain-timeout=\"30s\" --buffer-requests --buffer-responses --log-request-header=\"Cache-Control\" --log-request-header=\"Last-Modified\" --log-request-header=\"User-Agent\" on 1.1.1.1", output
|
||||
|
||||
assert_match "docker container stop kamal-proxy on 1.1.1.2", output
|
||||
assert_match "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy on 1.1.1.2", output
|
||||
assert_match "mkdir -p .kamal/proxy/apps-config on 1.1.1.1", output
|
||||
assert_match "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::Proxy::Boot::MINIMUM_VERSION}\") $(cat .kamal/proxy/run_command 2> /dev/null || echo \"\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $(pwd)/.kamal/proxy/apps-config:/home/kamal-proxy/.apps-config on 1.1.1.2", output
|
||||
assert_match "docker exec kamal-proxy kamal-proxy deploy app-web --target=\"abcdefabcdef:80\" --deploy-timeout=\"6s\" --drain-timeout=\"30s\" --buffer-requests --buffer-responses --log-request-header=\"Cache-Control\" --log-request-header=\"Last-Modified\" --log-request-header=\"User-Agent\" on 1.1.1.2", output
|
||||
end
|
||||
end
|
||||
|
||||
test "reboot --rolling" do
|
||||
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
|
||||
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet")
|
||||
.returns("abcdefabcdef")
|
||||
.at_least_once
|
||||
|
||||
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
|
||||
.with { |*args| args[0..1] == [ :sh, "-c" ] }
|
||||
.returns("123")
|
||||
.at_least_once
|
||||
|
||||
run_command("reboot", "--rolling", "-y").tap do |output|
|
||||
assert_match "Running docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy on 1.1.1.1", output
|
||||
end
|
||||
@@ -204,7 +182,7 @@ class CliProxyTest < CliTestCase
|
||||
assert_match %r{docker rename app-web-latest app-web-latest_replaced_.*}, output
|
||||
assert_match "/usr/bin/env mkdir -p .kamal/apps/app/env/roles", output
|
||||
assert_match "Uploading \"\\n\" to .kamal/apps/app/env/roles/web.env", output
|
||||
assert_match %r{docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-.* -e KAMAL_CONTAINER_NAME="app-web-latest" -e KAMAL_VERSION="latest" --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size="10m" --label service="app" --label role="web" --label destination dhh/app:latest}, output
|
||||
assert_match %r{docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-.* --env KAMAL_CONTAINER_NAME="app-web-latest" --env KAMAL_VERSION="latest" --env KAMAL_HOST="1.1.1.1" --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size="10m" --label service="app" --label role="web" --label destination dhh/app:latest}, output
|
||||
assert_match "docker exec kamal-proxy kamal-proxy deploy app-web --target=\"12345678:80\" --deploy-timeout=\"6s\" --drain-timeout=\"30s\" --buffer-requests --buffer-responses --log-request-header=\"Cache-Control\" --log-request-header=\"Last-Modified\" --log-request-header=\"User-Agent\"", output
|
||||
assert_match "docker container ls --all --filter name=^app-web-12345678$ --quiet | xargs docker stop", output
|
||||
assert_match "docker tag dhh/app:latest dhh/app:latest", output
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
require_relative "cli_test_case"
|
||||
|
||||
class CliRegistryTest < CliTestCase
|
||||
test "login" do
|
||||
run_command("login").tap do |output|
|
||||
test "setup" do
|
||||
run_command("setup").tap do |output|
|
||||
assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] as .*@localhost/, output
|
||||
assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] on 1.1.1.\d/, output
|
||||
end
|
||||
end
|
||||
|
||||
test "login skip local" do
|
||||
run_command("login", "-L").tap do |output|
|
||||
test "setup skip local" do
|
||||
run_command("setup", "-L").tap do |output|
|
||||
assert_no_match /docker login -u \[REDACTED\] -p \[REDACTED\] as .*@localhost/, output
|
||||
assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] on 1.1.1.\d/, output
|
||||
end
|
||||
end
|
||||
|
||||
test "login skip remote" do
|
||||
run_command("login", "-R").tap do |output|
|
||||
test "setup skip remote" do
|
||||
run_command("setup", "-R").tap do |output|
|
||||
assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] as .*@localhost/, output
|
||||
assert_no_match /docker login -u \[REDACTED\] -p \[REDACTED\] on 1.1.1.\d/, output
|
||||
end
|
||||
end
|
||||
|
||||
test "logout" do
|
||||
run_command("logout").tap do |output|
|
||||
test "remove" do
|
||||
run_command("remove").tap do |output|
|
||||
assert_match /docker logout as .*@localhost/, output
|
||||
assert_match /docker logout on 1.1.1.\d/, output
|
||||
end
|
||||
end
|
||||
|
||||
test "logout skip local" do
|
||||
run_command("logout", "-L").tap do |output|
|
||||
test "remove skip local" do
|
||||
run_command("remove", "-L").tap do |output|
|
||||
assert_no_match /docker logout as .*@localhost/, output
|
||||
assert_match /docker logout on 1.1.1.\d/, output
|
||||
end
|
||||
end
|
||||
|
||||
test "logout skip remote" do
|
||||
run_command("logout", "-R").tap do |output|
|
||||
test "remove skip remote" do
|
||||
run_command("remove", "-R").tap do |output|
|
||||
assert_match /docker logout as .*@localhost/, output
|
||||
assert_no_match /docker logout on 1.1.1.\d/, output
|
||||
end
|
||||
end
|
||||
|
||||
test "login with no docker" do
|
||||
test "setup with no docker" do
|
||||
stub_setup
|
||||
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
|
||||
.with(:docker, "--version", "&&", :docker, :buildx, "version")
|
||||
.raises(SSHKit::Command::Failed.new("command not found"))
|
||||
|
||||
assert_raises(Kamal::Cli::DependencyError) { run_command("login") }
|
||||
assert_raises(Kamal::Cli::DependencyError) { run_command("setup") }
|
||||
end
|
||||
|
||||
test "allow remote login with no docker" do
|
||||
@@ -61,12 +61,23 @@ class CliRegistryTest < CliTestCase
|
||||
SSHKit::Backend::Abstract.any_instance.stubs(:execute)
|
||||
.with { |*args| args[0..1] == [ :docker, :login ] }
|
||||
|
||||
assert_nothing_raised { run_command("login", "--skip-local") }
|
||||
assert_nothing_raised { run_command("setup", "--skip-local") }
|
||||
end
|
||||
|
||||
test "setup local registry" do
|
||||
run_command("setup", fixture: :with_local_registry).tap do |output|
|
||||
assert_match /docker start kamal-docker-registry || docker run --detach -p 127.0.0.1:5000:5000 --name kamal-docker-registry registry:2 as .*@localhost/, output
|
||||
end
|
||||
end
|
||||
|
||||
test "remove local registry" do
|
||||
run_command("remove", fixture: :with_local_registry).tap do |output|
|
||||
assert_match /docker stop kamal-docker-registry && docker rm kamal-docker-registry as .*@localhost/, output
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def run_command(*command)
|
||||
stdouted { Kamal::Cli::Registry.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) }
|
||||
def run_command(*command, fixture: :with_accessories)
|
||||
stdouted { Kamal::Cli::Registry.start([ *command, "-c", "test/fixtures/deploy_#{fixture}.yml" ]) }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -149,6 +149,12 @@ class CommanderTest < ActiveSupport::TestCase
|
||||
assert_equal [], @kamal.accessory_hosts
|
||||
end
|
||||
|
||||
test "primary role hosts are first" do
|
||||
configure_with(:deploy_with_roles_workers_primary)
|
||||
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @kamal.hosts
|
||||
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @kamal.app_hosts
|
||||
end
|
||||
|
||||
private
|
||||
def configure_with(variant)
|
||||
@kamal = Kamal::Commander.new.tap do |kamal|
|
||||
|
||||
@@ -118,14 +118,21 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
|
||||
test "execute in new container over ssh" do
|
||||
new_command(:mysql).stub(:run_over_ssh, ->(cmd) { cmd.join(" ") }) do
|
||||
assert_match %r{docker run -it --rm --network kamal --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env private.registry/mysql:8.0 mysql -u root},
|
||||
new_command(:mysql).execute_in_new_container_over_ssh("mysql", "-u", "root")
|
||||
stub_stdin_tty { new_command(:mysql).execute_in_new_container_over_ssh("mysql", "-u", "root") }
|
||||
end
|
||||
end
|
||||
|
||||
test "execute in existing container over ssh" do
|
||||
new_command(:mysql).stub(:run_over_ssh, ->(cmd) { cmd.join(" ") }) do
|
||||
assert_match %r{docker exec -it app-mysql mysql -u root},
|
||||
new_command(:mysql).execute_in_existing_container_over_ssh("mysql", "-u", "root")
|
||||
stub_stdin_tty { new_command(:mysql).execute_in_existing_container_over_ssh("mysql", "-u", "root") }
|
||||
end
|
||||
end
|
||||
|
||||
test "execute in existing container with piped input over ssh" do
|
||||
new_command(:mysql).stub(:run_over_ssh, ->(cmd) { cmd.join(" ") }) do
|
||||
assert_match %r{docker exec -i app-mysql mysql -u root},
|
||||
stub_stdin_file { new_command(:mysql).execute_in_existing_container_over_ssh("mysql", "-u", "root") }
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -13,13 +13,13 @@ class CommandsAppTest < ActiveSupport::TestCase
|
||||
|
||||
test "run" do
|
||||
assert_equal \
|
||||
"docker run --detach --restart unless-stopped --name app-web-999 --network kamal -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999",
|
||||
"docker run --detach --restart unless-stopped --name app-web-999 --network kamal --env KAMAL_CONTAINER_NAME=\"app-web-999\" --env KAMAL_VERSION=\"999\" --env KAMAL_HOST=\"1.1.1.1\" --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999",
|
||||
new_command.run.join(" ")
|
||||
end
|
||||
|
||||
test "run with hostname" do
|
||||
assert_equal \
|
||||
"docker run --detach --restart unless-stopped --name app-web-999 --network kamal --hostname myhost -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999",
|
||||
"docker run --detach --restart unless-stopped --name app-web-999 --network kamal --hostname myhost --env KAMAL_CONTAINER_NAME=\"app-web-999\" --env KAMAL_VERSION=\"999\" --env KAMAL_HOST=\"1.1.1.1\" --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999",
|
||||
new_command.run(hostname: "myhost").join(" ")
|
||||
end
|
||||
|
||||
@@ -27,14 +27,14 @@ class CommandsAppTest < ActiveSupport::TestCase
|
||||
@config[:volumes] = [ "/local/path:/container/path" ]
|
||||
|
||||
assert_equal \
|
||||
"docker run --detach --restart unless-stopped --name app-web-999 --network kamal -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" --volume /local/path:/container/path --label service=\"app\" --label role=\"web\" --label destination dhh/app:999",
|
||||
"docker run --detach --restart unless-stopped --name app-web-999 --network kamal --env KAMAL_CONTAINER_NAME=\"app-web-999\" --env KAMAL_VERSION=\"999\" --env KAMAL_HOST=\"1.1.1.1\" --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" --volume /local/path:/container/path --label service=\"app\" --label role=\"web\" --label destination dhh/app:999",
|
||||
new_command.run.join(" ")
|
||||
end
|
||||
|
||||
test "run with custom options" do
|
||||
@config[:servers] = { "web" => [ "1.1.1.1" ], "jobs" => { "hosts" => [ "1.1.1.2" ], "cmd" => "bin/jobs", "options" => { "mount" => "somewhere", "cap-add" => true } } }
|
||||
assert_equal \
|
||||
"docker run --detach --restart unless-stopped --name app-jobs-999 --network kamal -e KAMAL_CONTAINER_NAME=\"app-jobs-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/apps/app/env/roles/jobs.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"jobs\" --label destination --mount \"somewhere\" --cap-add dhh/app:999 bin/jobs",
|
||||
"docker run --detach --restart unless-stopped --name app-jobs-999 --network kamal --env KAMAL_CONTAINER_NAME=\"app-jobs-999\" --env KAMAL_VERSION=\"999\" --env KAMAL_HOST=\"1.1.1.2\" --env-file .kamal/apps/app/env/roles/jobs.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"jobs\" --label destination --mount \"somewhere\" --cap-add dhh/app:999 bin/jobs",
|
||||
new_command(role: "jobs", host: "1.1.1.2").run.join(" ")
|
||||
end
|
||||
|
||||
@@ -42,7 +42,7 @@ class CommandsAppTest < ActiveSupport::TestCase
|
||||
@config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } }
|
||||
|
||||
assert_equal \
|
||||
"docker run --detach --restart unless-stopped --name app-web-999 --network kamal -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/apps/app/env/roles/web.env --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999",
|
||||
"docker run --detach --restart unless-stopped --name app-web-999 --network kamal --env KAMAL_CONTAINER_NAME=\"app-web-999\" --env KAMAL_VERSION=\"999\" --env KAMAL_HOST=\"1.1.1.1\" --env-file .kamal/apps/app/env/roles/web.env --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999",
|
||||
new_command.run.join(" ")
|
||||
end
|
||||
|
||||
@@ -51,7 +51,7 @@ class CommandsAppTest < ActiveSupport::TestCase
|
||||
@config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "logging" => { "driver" => "local", "options" => { "max-size" => "100m" } } } }
|
||||
|
||||
assert_equal \
|
||||
"docker run --detach --restart unless-stopped --name app-web-999 --network kamal -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env-file .kamal/apps/app/env/roles/web.env --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999",
|
||||
"docker run --detach --restart unless-stopped --name app-web-999 --network kamal --env KAMAL_CONTAINER_NAME=\"app-web-999\" --env KAMAL_VERSION=\"999\" --env KAMAL_HOST=\"1.1.1.1\" --env-file .kamal/apps/app/env/roles/web.env --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999",
|
||||
new_command.run.join(" ")
|
||||
end
|
||||
|
||||
@@ -60,7 +60,7 @@ class CommandsAppTest < ActiveSupport::TestCase
|
||||
@config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } }
|
||||
|
||||
assert_equal \
|
||||
"docker run --detach --restart unless-stopped --name app-web-999 --network kamal -e KAMAL_CONTAINER_NAME=\"app-web-999\" -e KAMAL_VERSION=\"999\" --env ENV1=\"value1\" --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999",
|
||||
"docker run --detach --restart unless-stopped --name app-web-999 --network kamal --env KAMAL_CONTAINER_NAME=\"app-web-999\" --env KAMAL_VERSION=\"999\" --env KAMAL_HOST=\"1.1.1.1\" --env ENV1=\"value1\" --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999",
|
||||
new_command.run.join(" ")
|
||||
end
|
||||
|
||||
@@ -149,8 +149,6 @@ class CommandsAppTest < ActiveSupport::TestCase
|
||||
new_command.remove.join(" ")
|
||||
end
|
||||
|
||||
|
||||
|
||||
test "logs" do
|
||||
assert_equal \
|
||||
"sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps 2>&1",
|
||||
@@ -288,7 +286,7 @@ class CommandsAppTest < ActiveSupport::TestCase
|
||||
|
||||
test "execute in new container over ssh" do
|
||||
assert_match %r{docker run -it --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size="10m" dhh/app:999 bin/rails c},
|
||||
new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {})
|
||||
stub_stdin_tty { new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {}) }
|
||||
end
|
||||
|
||||
test "execute in new container over ssh with tags" do
|
||||
@@ -296,18 +294,23 @@ class CommandsAppTest < ActiveSupport::TestCase
|
||||
@config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } }
|
||||
|
||||
assert_equal "ssh -t root@1.1.1.1 -p 22 'docker run -it --rm --network kamal --env ENV1=\"value1\" --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" dhh/app:999 bin/rails c'",
|
||||
new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {})
|
||||
stub_stdin_tty { new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {}) }
|
||||
end
|
||||
|
||||
test "execute in new container with custom options over ssh" do
|
||||
@config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere", "cap-add" => true } } }
|
||||
assert_match %r{docker run -it --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" --mount \"somewhere\" --cap-add dhh/app:999 bin/rails c},
|
||||
new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {})
|
||||
stub_stdin_tty { new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {}) }
|
||||
end
|
||||
|
||||
test "execute in existing container over ssh" do
|
||||
assert_match %r{docker exec -it app-web-999 bin/rails c},
|
||||
new_command.execute_in_existing_container_over_ssh("bin/rails", "c", env: {})
|
||||
stub_stdin_tty { new_command.execute_in_existing_container_over_ssh("bin/rails", "c", env: {}) }
|
||||
end
|
||||
|
||||
test "execute in existing container with piped input over ssh" do
|
||||
assert_match %r{docker exec -i app-web-999 bin/rails c},
|
||||
stub_stdin_file { new_command.execute_in_existing_container_over_ssh("bin/rails", "c", env: {}) }
|
||||
end
|
||||
|
||||
test "run over ssh" do
|
||||
|
||||
@@ -61,6 +61,32 @@ class CommandsBuilderTest < ActiveSupport::TestCase
|
||||
builder.push.join(" ")
|
||||
end
|
||||
|
||||
test "target pack when pack is set" do
|
||||
builder = new_builder_command(image: "dhh/app", builder: { "arch" => "amd64", "pack" => { "builder" => "heroku/builder:24", "buildpacks" => [ "heroku/ruby", "heroku/procfile" ] } })
|
||||
assert_equal "pack", builder.name
|
||||
assert_equal \
|
||||
"pack build dhh/app --platform linux/amd64 --creation-time now --builder heroku/builder:24 --buildpack heroku/ruby --buildpack heroku/procfile --buildpack paketo-buildpacks/image-labels -t dhh/app:123 -t dhh/app:latest --env BP_IMAGE_LABELS=service=app --path . && docker push dhh/app:123 && docker push dhh/app:latest",
|
||||
builder.push.join(" ")
|
||||
end
|
||||
|
||||
test "pack build args passed as env" do
|
||||
builder = new_builder_command(image: "dhh/app", builder: { "args" => { "a" => 1, "b" => 2 }, "arch" => "amd64", "pack" => { "builder" => "heroku/builder:24", "buildpacks" => [ "heroku/ruby", "heroku/procfile" ] } })
|
||||
|
||||
assert_equal \
|
||||
"pack build dhh/app --platform linux/amd64 --creation-time now --builder heroku/builder:24 --buildpack heroku/ruby --buildpack heroku/procfile --buildpack paketo-buildpacks/image-labels -t dhh/app:123 -t dhh/app:latest --env BP_IMAGE_LABELS=service=app --env a=\"1\" --env b=\"2\" --path . && docker push dhh/app:123 && docker push dhh/app:latest",
|
||||
builder.push.join(" ")
|
||||
end
|
||||
|
||||
test "pack build secrets as env" do
|
||||
with_test_secrets("secrets" => "token_a=foo\ntoken_b=bar") do
|
||||
builder = new_builder_command(image: "dhh/app", builder: { "secrets" => [ "token_a", "token_b" ], "arch" => "amd64", "pack" => { "builder" => "heroku/builder:24", "buildpacks" => [ "heroku/ruby", "heroku/procfile" ] } })
|
||||
|
||||
assert_equal \
|
||||
"pack build dhh/app --platform linux/amd64 --creation-time now --builder heroku/builder:24 --buildpack heroku/ruby --buildpack heroku/procfile --buildpack paketo-buildpacks/image-labels -t dhh/app:123 -t dhh/app:latest --env BP_IMAGE_LABELS=service=app --env token_a=\"foo\" --env token_b=\"bar\" --path . && docker push dhh/app:123 && docker push dhh/app:latest",
|
||||
builder.push.join(" ")
|
||||
end
|
||||
end
|
||||
|
||||
test "cloud builder" do
|
||||
builder = new_builder_command(builder: { "arch" => [ "#{local_arch}" ], "driver" => "cloud docker-org-name/builder-name" })
|
||||
assert_equal "cloud", builder.name
|
||||
@@ -202,7 +228,11 @@ class CommandsBuilderTest < ActiveSupport::TestCase
|
||||
|
||||
private
|
||||
def new_builder_command(additional_config = {})
|
||||
Kamal::Commands::Builder.new(Kamal::Configuration.new(@config.deep_merge(additional_config), version: "123"))
|
||||
Kamal::Configuration.new(@config.deep_merge(additional_config), version: "123").then do |config|
|
||||
KAMAL.reset
|
||||
KAMAL.stubs(:config).returns(config)
|
||||
Kamal::Commands::Builder.new(config)
|
||||
end
|
||||
end
|
||||
|
||||
def local_arch
|
||||
|
||||
@@ -85,6 +85,15 @@ class CommandsRegistryTest < ActiveSupport::TestCase
|
||||
registry.logout(registry_config: accessory_registry_config).join(" ")
|
||||
end
|
||||
|
||||
test "registry setup" do
|
||||
@config[:registry] = { "server" => "localhost:5000" }
|
||||
assert_equal "docker start kamal-docker-registry || docker run --detach -p 127.0.0.1:5000:5000 --name kamal-docker-registry registry:3", registry.setup.join(" ")
|
||||
end
|
||||
|
||||
test "registry remove" do
|
||||
assert_equal "docker stop kamal-docker-registry && docker rm kamal-docker-registry", registry.remove.join(" ")
|
||||
end
|
||||
|
||||
private
|
||||
def registry
|
||||
Kamal::Commands::Registry.new main_config
|
||||
|
||||
@@ -16,6 +16,23 @@ class ConfigurationBuilderTest < ActiveSupport::TestCase
|
||||
assert_equal false, config.builder.remote?
|
||||
end
|
||||
|
||||
test "pack?" do
|
||||
assert_not config.builder.pack?
|
||||
end
|
||||
|
||||
test "pack? with pack builder" do
|
||||
@deploy[:builder] = { "arch" => "arm64", "pack" => { "builder" => "heroku/builder:24" } }
|
||||
|
||||
assert config.builder.pack?
|
||||
end
|
||||
|
||||
test "pack details" do
|
||||
@deploy[:builder] = { "arch" => "amd64", "pack" => { "builder" => "heroku/builder:24", "buildpacks" => [ "heroku/ruby", "heroku/procfile" ] } }
|
||||
|
||||
assert_equal "heroku/builder:24", config.builder.pack_builder
|
||||
assert_equal [ "heroku/ruby", "heroku/procfile" ], config.builder.pack_buildpacks
|
||||
end
|
||||
|
||||
test "remote" do
|
||||
assert_nil config.builder.remote
|
||||
end
|
||||
|
||||
@@ -25,5 +25,7 @@ class ConfigurationProxyBootTest < ActiveSupport::TestCase
|
||||
assert_equal "/home/kamal-proxy/.apps-config/app", @proxy_boot_config.app_container_directory
|
||||
assert_equal ".kamal/proxy/apps-config/app/error_pages", @proxy_boot_config.error_pages_directory
|
||||
assert_equal "/home/kamal-proxy/.apps-config/app/error_pages", @proxy_boot_config.error_pages_container_directory
|
||||
assert_equal ".kamal/proxy/apps-config/app/tls", @proxy_boot_config.tls_directory
|
||||
assert_equal "/home/kamal-proxy/.apps-config/app/tls", @proxy_boot_config.tls_container_directory
|
||||
end
|
||||
end
|
||||
|
||||
@@ -45,6 +45,66 @@ class ConfigurationProxyTest < ActiveSupport::TestCase
|
||||
end
|
||||
end
|
||||
|
||||
test "ssl with certificate and private key from secrets" do
|
||||
with_test_secrets("secrets" => "CERT_PEM=certificate\nKEY_PEM=private_key") do
|
||||
@deploy[:proxy] = {
|
||||
"ssl" => {
|
||||
"certificate_pem" => "CERT_PEM",
|
||||
"private_key_pem" => "KEY_PEM"
|
||||
},
|
||||
"host" => "example.com"
|
||||
}
|
||||
|
||||
proxy = config.proxy
|
||||
assert_equal ".kamal/proxy/apps-config/app/tls/cert.pem", proxy.host_tls_cert
|
||||
assert_equal ".kamal/proxy/apps-config/app/tls/key.pem", proxy.host_tls_key
|
||||
assert_equal "/home/kamal-proxy/.apps-config/app/tls/cert.pem", proxy.container_tls_cert
|
||||
assert_equal "/home/kamal-proxy/.apps-config/app/tls/key.pem", proxy.container_tls_key
|
||||
end
|
||||
end
|
||||
|
||||
test "deploy options with custom ssl certificates" do
|
||||
with_test_secrets("secrets" => "CERT_PEM=certificate\nKEY_PEM=private_key") do
|
||||
@deploy[:proxy] = {
|
||||
"ssl" => {
|
||||
"certificate_pem" => "CERT_PEM",
|
||||
"private_key_pem" => "KEY_PEM"
|
||||
},
|
||||
"host" => "example.com"
|
||||
}
|
||||
|
||||
proxy = config.proxy
|
||||
options = proxy.deploy_options
|
||||
assert_equal true, options[:tls]
|
||||
assert_equal "/home/kamal-proxy/.apps-config/app/tls/cert.pem", options[:"tls-certificate-path"]
|
||||
assert_equal "/home/kamal-proxy/.apps-config/app/tls/key.pem", options[:"tls-private-key-path"]
|
||||
end
|
||||
end
|
||||
|
||||
test "ssl with certificate and no private key" do
|
||||
with_test_secrets("secrets" => "CERT_PEM=certificate") do
|
||||
@deploy[:proxy] = {
|
||||
"ssl" => {
|
||||
"certificate_pem" => "CERT_PEM"
|
||||
},
|
||||
"host" => "example.com"
|
||||
}
|
||||
assert_raises(Kamal::ConfigurationError) { config.proxy.ssl? }
|
||||
end
|
||||
end
|
||||
|
||||
test "ssl with private key and no certificate" do
|
||||
with_test_secrets("secrets" => "KEY_PEM=private_key") do
|
||||
@deploy[:proxy] = {
|
||||
"ssl" => {
|
||||
"private_key_pem" => "KEY_PEM"
|
||||
},
|
||||
"host" => "example.com"
|
||||
}
|
||||
assert_raises(Kamal::ConfigurationError) { config.proxy.ssl? }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def config
|
||||
Kamal::Configuration.new(@deploy)
|
||||
|
||||
@@ -42,6 +42,7 @@ class ConfigurationValidationTest < ActiveSupport::TestCase
|
||||
assert_error "servers/web/options: should be a hash", servers: { "web" => { "options" => "" } }
|
||||
assert_error "servers/web/logging/options: should be a hash", servers: { "web" => { "logging" => { "options" => "" } } }
|
||||
assert_error "servers/web/logging/driver: should be a string", servers: { "web" => { "logging" => { "driver" => [] } } }
|
||||
assert_error "servers/web/labels/service: invalid label. destination, role, and service are reserved labels", servers: { "web" => { "labels" => { "service" => "foo" } } }
|
||||
assert_error "servers/web/labels: should be a hash", servers: { "web" => { "labels" => [] } }
|
||||
assert_error "servers/web/env: should be a hash", servers: { "web" => { "env" => [] } }
|
||||
assert_error "servers/web/env: tags are only allowed in the root env", servers: { "web" => { "hosts" => [ "1.1.1.1" ], "env" => { "tags" => {} } } }
|
||||
@@ -58,6 +59,7 @@ class ConfigurationValidationTest < ActiveSupport::TestCase
|
||||
assert_error "accessories/accessory1: should be a hash", accessories: { "accessory1" => [] }
|
||||
assert_error "accessories/accessory1: unknown key: unknown", accessories: { "accessory1" => { "unknown" => "baz" } }
|
||||
assert_error "accessories/accessory1/options: should be a hash", accessories: { "accessory1" => { "options" => [] } }
|
||||
assert_error "accessories/accessory1/labels/destination: invalid label. destination, role, and service are reserved labels", accessories: { "accessory1" => { "host" => "host", "labels" => { "destination" => "foo" } } }
|
||||
assert_error "accessories/accessory1/host: should be a string", accessories: { "accessory1" => { "host" => [] } }
|
||||
assert_error "accessories/accessory1/env: should be a hash", accessories: { "accessory1" => { "env" => [] } }
|
||||
assert_error "accessories/accessory1/env: tags are only allowed in the root env", accessories: { "accessory1" => { "host" => "host", "env" => { "tags" => {} } } }
|
||||
@@ -94,6 +96,7 @@ class ConfigurationValidationTest < ActiveSupport::TestCase
|
||||
assert_error "builder/arch: should be an array or a string", builder: { "arch" => {} }
|
||||
assert_error "builder/args: should be a hash", builder: { "args" => [ "foo" ] }
|
||||
assert_error "builder/cache/options: should be a string", builder: { "cache" => { "options" => [] } }
|
||||
assert_error "builder: buildpacks only support building for one arch", builder: { "arch" => [ "amd64", "arm64" ], "pack" => { "builder" => "heroku/builder:24" } }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -43,6 +43,19 @@ class ConfigurationTest < ActiveSupport::TestCase
|
||||
end
|
||||
end
|
||||
|
||||
test "image uses service name if registry is local" do
|
||||
assert_equal "app", Kamal::Configuration.new(@deploy.tap {
|
||||
_1[:registry] = { "server" => "localhost:5000" }
|
||||
_1.delete(:image)
|
||||
}).image
|
||||
end
|
||||
|
||||
test "image uses image if registry is local" do
|
||||
assert_equal "dhh/app", Kamal::Configuration.new(@deploy.tap {
|
||||
_1[:registry] = { "server" => "localhost:5000" }
|
||||
}).image
|
||||
end
|
||||
|
||||
test "service name valid" do
|
||||
assert_nothing_raised do
|
||||
Kamal::Configuration.new(@deploy.tap { |config| config[:service] = "hey-app1_primary" })
|
||||
@@ -386,7 +399,7 @@ class ConfigurationTest < ActiveSupport::TestCase
|
||||
Kamal::Configuration.new(@deploy_with_roles)
|
||||
end
|
||||
|
||||
assert_equal "SSL is only supported on a single server, found 2 servers for role workers", exception.message
|
||||
assert_equal "SSL is only supported on a single server unless you provide custom certificates, found 2 servers for role workers", exception.message
|
||||
end
|
||||
|
||||
test "two proxy ssl roles with same host" do
|
||||
|
||||
10
test/fixtures/deploy_with_local_registry.yml
vendored
Normal file
10
test/fixtures/deploy_with_local_registry.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
service: app
|
||||
image: dhh/app
|
||||
servers:
|
||||
web:
|
||||
- '1.1.1.1'
|
||||
- '1.1.1.2'
|
||||
registry:
|
||||
server: localhost:5000
|
||||
builder:
|
||||
arch: amd64
|
||||
45
test/fixtures/deploy_with_local_registry_and_accessories.yml
vendored
Normal file
45
test/fixtures/deploy_with_local_registry_and_accessories.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
service: app
|
||||
image: dhh/app
|
||||
servers:
|
||||
web:
|
||||
- '1.1.1.1'
|
||||
- '1.1.1.2'
|
||||
workers:
|
||||
- '1.1.1.3'
|
||||
- '1.1.1.4'
|
||||
registry:
|
||||
server: localhost:5000
|
||||
builder:
|
||||
arch: amd64
|
||||
|
||||
accessories:
|
||||
mysql:
|
||||
image: mysql:5.7
|
||||
host: 1.1.1.3
|
||||
port: 3306
|
||||
env:
|
||||
clear:
|
||||
MYSQL_ROOT_HOST: '%'
|
||||
secret:
|
||||
- MYSQL_ROOT_PASSWORD
|
||||
files:
|
||||
- test/fixtures/files/my.cnf:/etc/mysql/my.cnf
|
||||
directories:
|
||||
- data:/var/lib/mysql
|
||||
redis:
|
||||
image: redis:latest
|
||||
roles:
|
||||
- web
|
||||
port: 6379
|
||||
directories:
|
||||
- data:/data
|
||||
busybox:
|
||||
service: custom-box
|
||||
image: busybox:latest
|
||||
host: 1.1.1.3
|
||||
registry:
|
||||
server: other.registry
|
||||
username: other_user
|
||||
password: other_pw
|
||||
|
||||
readiness_delay: 0
|
||||
19
test/fixtures/deploy_with_roles_workers_primary.yml
vendored
Normal file
19
test/fixtures/deploy_with_roles_workers_primary.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
service: app
|
||||
image: dhh/app
|
||||
servers:
|
||||
workers:
|
||||
- 1.1.1.1
|
||||
- 1.1.1.2
|
||||
web:
|
||||
- 1.1.1.3
|
||||
- 1.1.1.4
|
||||
env:
|
||||
REDIS_URL: redis://x/y
|
||||
registry:
|
||||
server: registry.digitalocean.com
|
||||
username: user
|
||||
password: pw
|
||||
builder:
|
||||
arch: amd64
|
||||
deploy_timeout: 1
|
||||
primary_role: workers
|
||||
@@ -29,14 +29,14 @@ class AppTest < IntegrationTest
|
||||
images = kamal :app, :images, capture: true
|
||||
assert_match "App Host: vm1", images
|
||||
assert_match "App Host: vm2", images
|
||||
assert_match /registry:4443\/app\s+#{latest_app_version}/, images
|
||||
assert_match /registry:4443\/app\s+latest/, images
|
||||
assert_match /localhost:5000\/app\s+#{latest_app_version}/, images
|
||||
assert_match /localhost:5000\/app\s+latest/, images
|
||||
|
||||
containers = kamal :app, :containers, capture: true
|
||||
assert_match "App Host: vm1", containers
|
||||
assert_match "App Host: vm2", containers
|
||||
assert_match "registry:4443/app:#{latest_app_version}", containers
|
||||
assert_match "registry:4443/app:latest", containers
|
||||
assert_match "localhost:5000/app:#{latest_app_version}", containers
|
||||
assert_match "localhost:5000/app:latest", containers
|
||||
|
||||
exec_output = kamal :app, :exec, :ps, capture: true
|
||||
assert_match "App Host: vm1", exec_output
|
||||
|
||||
@@ -41,6 +41,8 @@ services:
|
||||
context: docker/vm
|
||||
volumes:
|
||||
- shared:/shared
|
||||
ports:
|
||||
- "22443:443"
|
||||
|
||||
vm2:
|
||||
privileged: true
|
||||
@@ -61,6 +63,7 @@ services:
|
||||
context: docker/load_balancer
|
||||
ports:
|
||||
- "12345:80"
|
||||
- "12443:443"
|
||||
depends_on:
|
||||
- vm1
|
||||
- vm2
|
||||
|
||||
@@ -18,6 +18,7 @@ RUN apt-get update --fix-missing && apt-get install -y docker-ce docker-ce-cli c
|
||||
|
||||
COPY *.sh .
|
||||
COPY app/ app/
|
||||
COPY app_with_custom_certificate/ app_with_custom_certificate/
|
||||
COPY app_with_roles/ app_with_roles/
|
||||
COPY app_with_traefik/ app_with_traefik/
|
||||
COPY app_with_proxied_accessory/ app_with_proxied_accessory/
|
||||
@@ -29,6 +30,7 @@ RUN mkdir -p /etc/docker/certs.d/registry:4443 && ln -s /shared/certs/domain.crt
|
||||
RUN git config --global user.email "deployer@example.com"
|
||||
RUN git config --global user.name "Deployer"
|
||||
RUN cd app && git init && git add . && git commit -am "Initial version"
|
||||
RUN cd app_with_custom_certificate && git init && git add . && git commit -am "Initial version"
|
||||
RUN cd app_with_roles && git init && git add . && git commit -am "Initial version"
|
||||
RUN cd app_with_traefik && git init && git add . && git commit -am "Initial version"
|
||||
RUN cd app_with_proxied_accessory && git init && git add . && git commit -am "Initial version"
|
||||
|
||||
@@ -2,12 +2,12 @@ service: app
|
||||
image: app
|
||||
servers:
|
||||
- vm1
|
||||
- vm2: [ tag1, tag2 ]
|
||||
- vm2: [tag1, tag2]
|
||||
env:
|
||||
clear:
|
||||
CLEAR_TOKEN: 4321
|
||||
CLEAR_TAG: ""
|
||||
HOST_TOKEN: "${HOST_TOKEN}"
|
||||
CLEAR_TAG: ''
|
||||
HOST_TOKEN: '${HOST_TOKEN}'
|
||||
secret:
|
||||
- SECRET_TOKEN
|
||||
- INTERPOLATED_SECRET1
|
||||
@@ -26,9 +26,7 @@ readiness_delay: 0
|
||||
proxy:
|
||||
host: 127.0.0.1
|
||||
registry:
|
||||
server: registry:4443
|
||||
username: root
|
||||
password: root
|
||||
server: localhost:5000
|
||||
builder:
|
||||
driver: docker
|
||||
arch: <%= Kamal::Utils.docker_arch %>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
CUSTOM_CERT=$(cat certs/cert.pem)
|
||||
CUSTOM_KEY=$(cat certs/key.pem)
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM registry:4443/nginx:1-alpine-slim
|
||||
|
||||
COPY default.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
ARG COMMIT_SHA
|
||||
|
||||
RUN echo $COMMIT_SHA > /usr/share/nginx/html/version
|
||||
RUN mkdir -p /usr/share/nginx/html/versions && echo "version" > /usr/share/nginx/html/versions/$COMMIT_SHA
|
||||
RUN mkdir -p /usr/share/nginx/html/versions && echo "hidden" > /usr/share/nginx/html/versions/.hidden
|
||||
RUN echo "Up!" > /usr/share/nginx/html/up
|
||||
@@ -0,0 +1,19 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDCzCCAfOgAwIBAgIUJHOADjhddzCAdXFfZvhXAsVMwhowDQYJKoZIhvcNAQEL
|
||||
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI1MDYxNzA5MDYxOVoYDzIxMjUw
|
||||
NTI0MDkwNjE5WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB
|
||||
AQUAA4IBDwAwggEKAoIBAQDQaLWwoLZ3/cZdiW/m4pqOe228wCx/CRU9/E2AT9NS
|
||||
ofuJNtUaxw7QAAFEWIrnf9y3M09lZeox1CNmXe2GADnnx/n906zSGX18SdDmWrxa
|
||||
L/1t5OZiXl3we5PM3UNvbFPSq1MCnOtvo6jTPM7shIpJ/5/KuuqovyrO31VCnc2+
|
||||
ycEzJ2BOcKFUFAeyT/8bk9lAI+1971PLqC6ut9dfy8PVHSPyGrxGiQCpStU7NiQj
|
||||
LUkqte7x9GcIKTJUjMkWIsvGke9oGoGgEl5gEfqxFAs3ZkA1aYkiHhwFtrUkGOOf
|
||||
O1C6sqfwnnAhtG8LnULGlFYi3GoKALF2XSIagGpaQM5HAgMBAAGjUzBRMB0GA1Ud
|
||||
DgQWBBQg2m871YSI220bQEG5APeGzeaz4zAfBgNVHSMEGDAWgBQg2m871YSI220b
|
||||
QEG5APeGzeaz4zAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBc
|
||||
yQvjLV+Uym+SI/bmKNKafW7ioWSkWAfTl/bvCB8xCX2OJsSqh1vjiKhkcJ6t0Tcj
|
||||
cEiYs7Q+2NVC+s+0ztrN1y4Ve8iX9K9D6o/09bD23zTKpftxCMv8NqoBicNVJ7O9
|
||||
sINcTqzrIPb+jawE47ogNvlorsU1hi1GTmDHtIqVJPQwiNCIWd8frBLf+WfCHCCK
|
||||
xRJb4hh5wR05v94L0/QdfKQ8qqCRG0VLyoGGcUyQgC8PLLlHRIWIYuwo3xhUK9nN
|
||||
Gn8WNiACY4ry1wRauqIp54N3fM1a5sgzpgPKc8++KLVBpxhDy8nRoFAD0k6y1iM0
|
||||
2EoVLhbMvwhYwHOHkktp
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDQaLWwoLZ3/cZd
|
||||
iW/m4pqOe228wCx/CRU9/E2AT9NSofuJNtUaxw7QAAFEWIrnf9y3M09lZeox1CNm
|
||||
Xe2GADnnx/n906zSGX18SdDmWrxaL/1t5OZiXl3we5PM3UNvbFPSq1MCnOtvo6jT
|
||||
PM7shIpJ/5/KuuqovyrO31VCnc2+ycEzJ2BOcKFUFAeyT/8bk9lAI+1971PLqC6u
|
||||
t9dfy8PVHSPyGrxGiQCpStU7NiQjLUkqte7x9GcIKTJUjMkWIsvGke9oGoGgEl5g
|
||||
EfqxFAs3ZkA1aYkiHhwFtrUkGOOfO1C6sqfwnnAhtG8LnULGlFYi3GoKALF2XSIa
|
||||
gGpaQM5HAgMBAAECggEAM2dIPRb+uozU8vg1qhCFR5RpBi+uKe0vGJlU8kt+F3kN
|
||||
hhQIrvCfFi2SIm3mYOAYK/WTZTKkd4LX8mVDcxQ2NBWOcw1VKIMSAOhiBpclsub4
|
||||
TrUxH90ftXN9in+epOpmqGUKdfAHYANRXjy22v5773GF06aTv2hbYigSqvoqJ57A
|
||||
PCdpw9q9sTwJqR9reU3f9fHsUyIwLCQpbtFyQc8aU9LHqgs4SAkaogY+4mPmlCrl
|
||||
pQ5wGljTXmK5g1o/v+mu1WdeGNOzd5//xp0YImkGtyiqh8Ab891MI1wPgivNP5Lo
|
||||
Ru1wKhegj89XamT/LUCtn6NCcokE/9pqEXrKK7JeVQKBgQD98kGUkdAm+zHjRZsr
|
||||
KTeQQ/wszFrNcbP9irE5MqnASWskIXcAhGVJrqbtinLPLIeT22BTsJkCUjVJdfX2
|
||||
MObjiJP0LMrMVpGQC0b+i4boS8W/lY5T4fM97B+ILc3Y1OYiUedg0gVsFspSR4ef
|
||||
luNfbKbmdzYYqFz6a/q5vExqBQKBgQDSGC2MJXYAewRJ9Mk3fNvll/6yz73rGCct
|
||||
tljwNXUgC7y2nEabDverPd74olSxojQwus/kA8JrMVa2IkXo+lKAwLV+nyj3PGHw
|
||||
3szTeAVWrGIRveWuW6IQ5zOP2IGkX5Jm+XSPVihnMz7SZA6k6qCtWVVywfBubSpi
|
||||
1dMNWAhs2wKBgBvMVw1yYLzDppRgXDn/SwvJxWMKA66VkcRhWEEQoLBh2Q6dcy9l
|
||||
TskgCznZe/PdxgGTdBn1LOqqIRcniIMomz2xB7Ek7hYsK8b+1QisMVpgYQc10dyw
|
||||
0TWoEVOQ4AWqWH7NRGy+0MUiQYd8OQZpN/6MIED+L7fHRlZLV6jZSewZAoGBAJwo
|
||||
bHJmxbbFuQJfd9BOdgPJXf76emdrpHNNvf2NPml7T+FLdw95qI0Xh8u2nM0Li09N
|
||||
C4inYrLaEWF/SAdLSFd65WwgUQqzTvkCIaxs4UrzBlG5nCZk5ak6sBCTFIlgoCj5
|
||||
8bE4kP9kD6XByUC7RIKUi/aoQFVTvtWHqT+Z12lRAoGAAVoZVxE+xPAfzVyAatpH
|
||||
M8WwgB23r07thNDiJCUMOQUT8LRFKg/Hyj6jB2W7gj669G/Bvoar++nXJVw7QCiv
|
||||
MlOk1pfaKuW82rCPnTeUzJwf2KQ8Jg2avasD4GFWZBJVvlHN1ONySViIpb67hhAK
|
||||
1OcbfGutFiGWhUwXNVkVc4U=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,36 @@
|
||||
service: app_with_custom_certificate
|
||||
image: app_with_custom_certificate
|
||||
servers:
|
||||
web:
|
||||
hosts:
|
||||
- vm1
|
||||
- vm2
|
||||
workers:
|
||||
hosts:
|
||||
- vm3
|
||||
cmd: sleep infinity
|
||||
deploy_timeout: 2
|
||||
drain_timeout: 2
|
||||
readiness_delay: 0
|
||||
|
||||
proxy:
|
||||
host: localhost
|
||||
ssl:
|
||||
certificate_pem: CUSTOM_CERT
|
||||
private_key_pem: CUSTOM_KEY
|
||||
healthcheck:
|
||||
interval: 1
|
||||
timeout: 1
|
||||
path: "/up"
|
||||
|
||||
asset_path: /usr/share/nginx/html/versions
|
||||
|
||||
registry:
|
||||
server: registry:4443
|
||||
username: root
|
||||
password: root
|
||||
builder:
|
||||
driver: docker
|
||||
arch: <%= Kamal::Utils.docker_arch %>
|
||||
args:
|
||||
COMMIT_SHA: <%= `git rev-parse HEAD` %>
|
||||
@@ -0,0 +1,17 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name localhost;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
}
|
||||
|
||||
# redirect server error pages to the static page /50x.html
|
||||
#
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
@@ -63,8 +63,8 @@ class IntegrationTest < ActiveSupport::TestCase
|
||||
assert_match message, response.body.strip if message
|
||||
end
|
||||
|
||||
def assert_app_is_up(version: nil, app: @app)
|
||||
response = app_response(app: app)
|
||||
def assert_app_is_up(version: nil, app: @app, cert: nil)
|
||||
response = app_response(app: app, cert: cert)
|
||||
debug_response_code(response, "200")
|
||||
assert_equal "200", response.code
|
||||
assert_app_version(version, response) if version
|
||||
@@ -82,8 +82,14 @@ class IntegrationTest < ActiveSupport::TestCase
|
||||
assert_equal up_times, up_count
|
||||
end
|
||||
|
||||
def app_response(app: @app)
|
||||
Net::HTTP.get_response(URI.parse("http://#{app_host(app)}:12345/version"))
|
||||
def app_response(app: @app, cert: nil)
|
||||
uri = cert ? URI.parse("https://#{app_host(app)}:22443/version") : URI.parse("http://#{app_host(app)}:12345/version")
|
||||
|
||||
if cert
|
||||
https_response_with_cert(uri, cert)
|
||||
else
|
||||
Net::HTTP.get_response(uri)
|
||||
end
|
||||
end
|
||||
|
||||
def update_app_rev
|
||||
@@ -125,7 +131,9 @@ class IntegrationTest < ActiveSupport::TestCase
|
||||
|
||||
def wait_for_healthy(timeout: 30)
|
||||
timeout_at = Time.now + timeout
|
||||
while docker_compose("ps -a | tail -n +2 | grep -v '(healthy)' | wc -l", capture: true) != "0"
|
||||
loop do
|
||||
result = docker_compose("ps -a | tail -n +2 | grep -v '(healthy)' | wc -l", capture: true)
|
||||
break if result.split.last == "0" || result == "0"
|
||||
if timeout_at < Time.now
|
||||
docker_compose("ps -a | tail -n +2 | grep -v '(healthy)'")
|
||||
raise "Container not healthy after #{timeout} seconds" if timeout_at < Time.now
|
||||
@@ -186,4 +194,19 @@ class IntegrationTest < ActiveSupport::TestCase
|
||||
"localhost"
|
||||
end
|
||||
end
|
||||
|
||||
def https_response_with_cert(uri, cert)
|
||||
host = uri.host
|
||||
port = uri.port
|
||||
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = true
|
||||
|
||||
store = OpenSSL::X509::Store.new
|
||||
store.add_cert(OpenSSL::X509::Certificate.new(File.read(cert)))
|
||||
http.cert_store = store
|
||||
|
||||
request = Net::HTTP::Get.new(uri)
|
||||
http.request(request)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -33,7 +33,7 @@ class MainTest < IntegrationTest
|
||||
assert_match /App Host: vm1/, details
|
||||
assert_match /App Host: vm2/, details
|
||||
assert_match /basecamp\/kamal-proxy:#{Kamal::Configuration::Proxy::Boot::MINIMUM_VERSION}/, details
|
||||
assert_match /registry:4443\/app:#{first_version}/, details
|
||||
assert_match /localhost:5000\/app:#{first_version}/, details
|
||||
|
||||
audit = kamal :audit, capture: true
|
||||
assert_match /Booted app version #{first_version}.*Booted app version #{second_version}.*Booted app version #{first_version}.*/m, audit
|
||||
@@ -67,8 +67,8 @@ class MainTest < IntegrationTest
|
||||
assert_equal [ "vm1", "vm2", "vm3" ], config[:hosts]
|
||||
assert_equal "vm1", config[:primary_host]
|
||||
assert_equal version, config[:version]
|
||||
assert_equal "registry:4443/app", config[:repository]
|
||||
assert_equal "registry:4443/app:#{version}", config[:absolute_image]
|
||||
assert_equal "localhost:5000/app", config[:repository]
|
||||
assert_equal "localhost:5000/app:#{version}", config[:absolute_image]
|
||||
assert_equal "app-#{version}", config[:service_with_version]
|
||||
assert_equal [], config[:volume_args]
|
||||
assert_equal({ user: "root", port: 22, keepalive: true, keepalive_interval: 30, log_level: :fatal }, config[:ssh_options])
|
||||
@@ -142,8 +142,19 @@ class MainTest < IntegrationTest
|
||||
assert_app_is_up version: first_version
|
||||
end
|
||||
|
||||
test "deploy with a custom certificate" do
|
||||
@app = "app_with_custom_certificate"
|
||||
|
||||
first_version = latest_app_version
|
||||
|
||||
kamal :setup
|
||||
|
||||
assert_app_is_up version: first_version, cert: "test/integration/docker/deployer/app_with_custom_certificate/certs/cert.pem"
|
||||
end
|
||||
|
||||
private
|
||||
def assert_envs(version:)
|
||||
assert_env :KAMAL_HOST, "vm1", version: version, vm: :vm1
|
||||
assert_env :CLEAR_TOKEN, "4321", version: version, vm: :vm1
|
||||
assert_env :HOST_TOKEN, "abcd", version: version, vm: :vm1
|
||||
assert_env :SECRET_TOKEN, "1234 with \"中文\"", version: version, vm: :vm1
|
||||
|
||||
@@ -15,57 +15,111 @@ class BitwardenSecretsManagerAdapterTest < SecretAdapterTestCase
|
||||
stub_ticks.with("bws --version 2> /dev/null")
|
||||
stub_login
|
||||
stub_ticks
|
||||
.with("bws secret list -o env")
|
||||
.returns("KAMAL_REGISTRY_PASSWORD=\"some_password\"\nMY_OTHER_SECRET=\"my=weird\"secret\"")
|
||||
.with("bws secret list")
|
||||
.returns(<<~JSON)
|
||||
[
|
||||
{
|
||||
"key": "KAMAL_REGISTRY_PASSWORD",
|
||||
"value": "some_password"
|
||||
},
|
||||
{
|
||||
"key": "MY_OTHER_SECRET",
|
||||
"value": "my=wierd\\"secret"
|
||||
}
|
||||
]
|
||||
JSON
|
||||
|
||||
expected = '{"KAMAL_REGISTRY_PASSWORD":"some_password","MY_OTHER_SECRET":"my\=weird\"secret"}'
|
||||
actual = shellunescape(run_command("fetch", "all"))
|
||||
assert_equal expected, actual
|
||||
json = JSON.parse(shellunescape(run_command("fetch", "all")))
|
||||
|
||||
expected_json = {
|
||||
"KAMAL_REGISTRY_PASSWORD"=>"some_password",
|
||||
"MY_OTHER_SECRET"=>"my=wierd\"secret"
|
||||
}
|
||||
|
||||
assert_equal expected_json, json
|
||||
end
|
||||
|
||||
test "fetch all with from" do
|
||||
stub_ticks.with("bws --version 2> /dev/null")
|
||||
stub_login
|
||||
stub_ticks
|
||||
.with("bws secret list -o env 82aeb5bd-6958-4a89-8197-eacab758acce")
|
||||
.returns("KAMAL_REGISTRY_PASSWORD=\"some_password\"\nMY_OTHER_SECRET=\"my=weird\"secret\"")
|
||||
.with("bws secret list 82aeb5bd-6958-4a89-8197-eacab758acce")
|
||||
.returns(<<~JSON)
|
||||
[
|
||||
{
|
||||
"key": "KAMAL_REGISTRY_PASSWORD",
|
||||
"value": "some_password"
|
||||
},
|
||||
{
|
||||
"key": "MY_OTHER_SECRET",
|
||||
"value": "my=wierd\\"secret"
|
||||
}
|
||||
]
|
||||
JSON
|
||||
|
||||
expected = '{"KAMAL_REGISTRY_PASSWORD":"some_password","MY_OTHER_SECRET":"my\=weird\"secret"}'
|
||||
actual = shellunescape(run_command("fetch", "all", "--from", "82aeb5bd-6958-4a89-8197-eacab758acce"))
|
||||
assert_equal expected, actual
|
||||
json = JSON.parse(shellunescape(run_command("fetch", "all", "--from", "82aeb5bd-6958-4a89-8197-eacab758acce")))
|
||||
|
||||
expected_json = {
|
||||
"KAMAL_REGISTRY_PASSWORD"=>"some_password",
|
||||
"MY_OTHER_SECRET"=>"my=wierd\"secret"
|
||||
}
|
||||
|
||||
assert_equal expected_json, json
|
||||
end
|
||||
|
||||
test "fetch item" do
|
||||
stub_ticks.with("bws --version 2> /dev/null")
|
||||
stub_login
|
||||
stub_ticks
|
||||
.with("bws secret get -o env 82aeb5bd-6958-4a89-8197-eacab758acce")
|
||||
.returns("KAMAL_REGISTRY_PASSWORD=\"some_password\"")
|
||||
.with("bws secret get 82aeb5bd-6958-4a89-8197-eacab758acce")
|
||||
.returns(<<~JSON)
|
||||
{
|
||||
"key": "KAMAL_REGISTRY_PASSWORD",
|
||||
"value": "some_password"
|
||||
}
|
||||
JSON
|
||||
|
||||
expected = '{"KAMAL_REGISTRY_PASSWORD":"some_password"}'
|
||||
actual = shellunescape(run_command("fetch", "82aeb5bd-6958-4a89-8197-eacab758acce"))
|
||||
assert_equal expected, actual
|
||||
json = JSON.parse(shellunescape(run_command("fetch", "82aeb5bd-6958-4a89-8197-eacab758acce")))
|
||||
expected_json = {
|
||||
"KAMAL_REGISTRY_PASSWORD"=>"some_password"
|
||||
}
|
||||
|
||||
assert_equal expected_json, json
|
||||
end
|
||||
|
||||
test "fetch with multiple items" do
|
||||
stub_ticks.with("bws --version 2> /dev/null")
|
||||
stub_login
|
||||
stub_ticks
|
||||
.with("bws secret get -o env 82aeb5bd-6958-4a89-8197-eacab758acce")
|
||||
.returns("KAMAL_REGISTRY_PASSWORD=\"some_password\"")
|
||||
.with("bws secret get 82aeb5bd-6958-4a89-8197-eacab758acce")
|
||||
.returns(<<~JSON)
|
||||
{
|
||||
"key": "KAMAL_REGISTRY_PASSWORD",
|
||||
"value": "some_password"
|
||||
}
|
||||
JSON
|
||||
stub_ticks
|
||||
.with("bws secret get -o env 6f8cdf27-de2b-4c77-a35d-07df8050e332")
|
||||
.returns("MY_OTHER_SECRET=\"my=weird\"secret\"")
|
||||
.with("bws secret get 6f8cdf27-de2b-4c77-a35d-07df8050e332")
|
||||
.returns(<<~JSON)
|
||||
{
|
||||
"key": "MY_OTHER_SECRET",
|
||||
"value": "my=wierd\\"secret"
|
||||
}
|
||||
JSON
|
||||
|
||||
expected = '{"KAMAL_REGISTRY_PASSWORD":"some_password","MY_OTHER_SECRET":"my\=weird\"secret"}'
|
||||
actual = shellunescape(run_command("fetch", "82aeb5bd-6958-4a89-8197-eacab758acce", "6f8cdf27-de2b-4c77-a35d-07df8050e332"))
|
||||
assert_equal expected, actual
|
||||
json = JSON.parse(shellunescape(run_command("fetch", "82aeb5bd-6958-4a89-8197-eacab758acce", "6f8cdf27-de2b-4c77-a35d-07df8050e332")))
|
||||
expected_json = {
|
||||
"KAMAL_REGISTRY_PASSWORD"=>"some_password",
|
||||
"MY_OTHER_SECRET"=>"my=wierd\"secret"
|
||||
}
|
||||
|
||||
assert_equal expected_json, json
|
||||
end
|
||||
|
||||
test "fetch all empty" do
|
||||
stub_ticks.with("bws --version 2> /dev/null")
|
||||
stub_login
|
||||
stub_ticks_with("bws secret list -o env", succeed: false).returns("Error:\n0: Received error message from server")
|
||||
stub_ticks_with("bws secret list", succeed: false).returns("Error:\n0: Received error message from server")
|
||||
|
||||
error = assert_raises RuntimeError do
|
||||
(shellunescape(run_command("fetch", "all")))
|
||||
@@ -76,8 +130,8 @@ class BitwardenSecretsManagerAdapterTest < SecretAdapterTestCase
|
||||
test "fetch nonexistent item" do
|
||||
stub_ticks.with("bws --version 2> /dev/null")
|
||||
stub_login
|
||||
stub_ticks_with("bws secret get -o env 82aeb5bd-6958-4a89-8197-eacab758acce", succeed: false)
|
||||
.returns("ERROR (RuntimeError): Could not read 82aeb5bd-6958-4a89-8197-eacab758acce from Bitwarden Secrets Manager")
|
||||
stub_ticks_with("bws secret get 82aeb5bd-6958-4a89-8197-eacab758acce", succeed: false)
|
||||
.returns("Error:\n0: Received error message from server")
|
||||
|
||||
error = assert_raises RuntimeError do
|
||||
(shellunescape(run_command("fetch", "82aeb5bd-6958-4a89-8197-eacab758acce")))
|
||||
@@ -85,9 +139,29 @@ class BitwardenSecretsManagerAdapterTest < SecretAdapterTestCase
|
||||
assert_equal("Could not read 82aeb5bd-6958-4a89-8197-eacab758acce from Bitwarden Secrets Manager", error.message)
|
||||
end
|
||||
|
||||
test "fetch item with linebreak in value" do
|
||||
stub_ticks.with("bws --version 2> /dev/null")
|
||||
stub_login
|
||||
stub_ticks
|
||||
.with("bws secret get 82aeb5bd-6958-4a89-8197-eacab758acce")
|
||||
.returns(<<~JSON)
|
||||
{
|
||||
"key": "SSH_PRIVATE_KEY",
|
||||
"value": "some_key\\nwith_linebreak"
|
||||
}
|
||||
JSON
|
||||
|
||||
json = JSON.parse(shellunescape(run_command("fetch", "82aeb5bd-6958-4a89-8197-eacab758acce")))
|
||||
expected_json = {
|
||||
"SSH_PRIVATE_KEY"=>"some_key\nwith_linebreak"
|
||||
}
|
||||
|
||||
assert_equal expected_json, json
|
||||
end
|
||||
|
||||
test "fetch with no access token" do
|
||||
stub_ticks.with("bws --version 2> /dev/null")
|
||||
stub_ticks_with("bws run 'echo OK'", succeed: false)
|
||||
stub_ticks_with("bws project list", succeed: false)
|
||||
|
||||
error = assert_raises RuntimeError do
|
||||
(shellunescape(run_command("fetch", "all")))
|
||||
@@ -106,7 +180,7 @@ class BitwardenSecretsManagerAdapterTest < SecretAdapterTestCase
|
||||
|
||||
private
|
||||
def stub_login
|
||||
stub_ticks.with("bws run 'echo OK'").returns("OK")
|
||||
stub_ticks.with("bws project list").returns("OK")
|
||||
end
|
||||
|
||||
def run_command(*command)
|
||||
|
||||
@@ -6,7 +6,7 @@ class SecretsOnePasswordAdapterTest < SecretAdapterTestCase
|
||||
stub_ticks.with("op account get --account myaccount 2> /dev/null")
|
||||
|
||||
stub_ticks
|
||||
.with("op item get myitem --vault \"myvault\" --fields \"label=section.SECRET1,label=section.SECRET2,label=section2.SECRET3\" --format \"json\" --account \"myaccount\"")
|
||||
.with("op item get myitem --vault \"myvault\" --format \"json\" --account \"myaccount\" --fields \"label=section.SECRET1,label=section.SECRET2,label=section2.SECRET3\"")
|
||||
.returns(<<~JSON)
|
||||
[
|
||||
{
|
||||
@@ -61,7 +61,7 @@ class SecretsOnePasswordAdapterTest < SecretAdapterTestCase
|
||||
stub_ticks.with("op account get --account myaccount 2> /dev/null")
|
||||
|
||||
stub_ticks
|
||||
.with("op item get myitem --vault \"myvault\" --fields \"label=section.SECRET1,label=section.SECRET2\" --format \"json\" --account \"myaccount\"")
|
||||
.with("op item get myitem --vault \"myvault\" --format \"json\" --account \"myaccount\" --fields \"label=section.SECRET1,label=section.SECRET2\"")
|
||||
.returns(<<~JSON)
|
||||
[
|
||||
{
|
||||
@@ -90,7 +90,7 @@ class SecretsOnePasswordAdapterTest < SecretAdapterTestCase
|
||||
JSON
|
||||
|
||||
stub_ticks
|
||||
.with("op item get myitem2 --vault \"myvault\" --fields \"label=section2.SECRET3\" --format \"json\" --account \"myaccount\"")
|
||||
.with("op item get myitem2 --vault \"myvault\" --format \"json\" --account \"myaccount\" --fields \"label=section2.SECRET3\"")
|
||||
.returns(<<~JSON)
|
||||
{
|
||||
"id": "aaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
@@ -116,6 +116,63 @@ class SecretsOnePasswordAdapterTest < SecretAdapterTestCase
|
||||
assert_equal expected_json, json
|
||||
end
|
||||
|
||||
test "fetch all fields" do
|
||||
stub_ticks.with("op --version 2> /dev/null")
|
||||
stub_ticks.with("op account get --account myaccount 2> /dev/null")
|
||||
|
||||
stub_ticks
|
||||
.with("op item get myitem --vault \"myvault\" --format \"json\" --account \"myaccount\"")
|
||||
.returns(<<~JSON)
|
||||
{
|
||||
"id": "ucbtiii777",
|
||||
"title": "A title",
|
||||
"version": 45,
|
||||
"vault": {
|
||||
"id": "vu7ki98do",
|
||||
"name": "Vault"
|
||||
},
|
||||
"category": "LOGIN",
|
||||
"last_edited_by": "ABCT3684BC",
|
||||
"created_at": "2025-05-22T06:47:01Z",
|
||||
"updated_at": "2025-05-22T00:36:48.02598-07:00",
|
||||
"additional_information": "—",
|
||||
"fields": [
|
||||
{
|
||||
"id": "aaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"section": {
|
||||
"id": "cccccccccccccccccccccccccc",
|
||||
"label": "section"
|
||||
},
|
||||
"type": "CONCEALED",
|
||||
"label": "SECRET1",
|
||||
"value": "VALUE1",
|
||||
"reference": "op://myvault/myitem/section/SECRET1"
|
||||
},
|
||||
{
|
||||
"id": "bbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"section": {
|
||||
"id": "cccccccccccccccccccccccccc",
|
||||
"label": "section"
|
||||
},
|
||||
"type": "CONCEALED",
|
||||
"label": "SECRET2",
|
||||
"value": "VALUE2",
|
||||
"reference": "op://myvault/myitem/section/SECRET2"
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
json = JSON.parse(shellunescape(run_command("fetch", "--from", "op://myvault/myitem")))
|
||||
|
||||
expected_json = {
|
||||
"myvault/myitem/section/SECRET1"=>"VALUE1",
|
||||
"myvault/myitem/section/SECRET2"=>"VALUE2"
|
||||
}
|
||||
|
||||
assert_equal expected_json, json
|
||||
end
|
||||
|
||||
test "fetch with signin, no session" do
|
||||
stub_ticks.with("op --version 2> /dev/null")
|
||||
|
||||
@@ -123,7 +180,7 @@ class SecretsOnePasswordAdapterTest < SecretAdapterTestCase
|
||||
stub_ticks_with("op signin --account \"myaccount\" --force --raw", succeed: true).returns("")
|
||||
|
||||
stub_ticks
|
||||
.with("op item get myitem --vault \"myvault\" --fields \"label=section.SECRET1\" --format \"json\" --account \"myaccount\"")
|
||||
.with("op item get myitem --vault \"myvault\" --format \"json\" --account \"myaccount\" --fields \"label=section.SECRET1\"")
|
||||
.returns(single_item_json)
|
||||
|
||||
json = JSON.parse(shellunescape(run_command("fetch", "--from", "op://myvault/myitem", "section/SECRET1")))
|
||||
@@ -142,7 +199,7 @@ class SecretsOnePasswordAdapterTest < SecretAdapterTestCase
|
||||
stub_ticks_with("op signin --account \"myaccount\" --force --raw", succeed: true).returns("1234567890")
|
||||
|
||||
stub_ticks
|
||||
.with("op item get myitem --vault \"myvault\" --fields \"label=section.SECRET1\" --format \"json\" --account \"myaccount\" --session \"1234567890\"")
|
||||
.with("op item get myitem --vault \"myvault\" --format \"json\" --account \"myaccount\" --session \"1234567890\" --fields \"label=section.SECRET1\"")
|
||||
.returns(single_item_json)
|
||||
|
||||
json = JSON.parse(shellunescape(run_command("fetch", "--from", "op://myvault/myitem", "section/SECRET1")))
|
||||
|
||||
474
test/secrets/passbolt_adapter.rb
Normal file
474
test/secrets/passbolt_adapter.rb
Normal file
@@ -0,0 +1,474 @@
|
||||
require "test_helper"
|
||||
|
||||
class PassboltAdapterTest < SecretAdapterTestCase
|
||||
setup do
|
||||
`true` # Ensure $? is 0
|
||||
end
|
||||
|
||||
test "fetch" do
|
||||
stub_ticks_with("passbolt --version 2> /dev/null", succeed: true)
|
||||
stub_ticks.with("passbolt verify 2> /dev/null", succeed: true)
|
||||
|
||||
stub_ticks
|
||||
.with("passbolt list resources --filter 'Name == \"SECRET1\" || Name == \"FSECRET1\" || Name == \"FSECRET2\"' --json")
|
||||
.returns(<<~JSON)
|
||||
[
|
||||
{
|
||||
"id": "4c116996-f6d0-4342-9572-0d676f75b3ac",
|
||||
"folder_parent_id": "",
|
||||
"name": "FSECRET1",
|
||||
"username": "",
|
||||
"uri": "",
|
||||
"password": "fsecret1",
|
||||
"description": "",
|
||||
"created_timestamp": "2025-02-21T06:04:29Z",
|
||||
"modified_timestamp": "2025-02-21T06:04:29Z"
|
||||
},
|
||||
{
|
||||
"id": "62949b26-4957-43fe-9523-294d66861499",
|
||||
"folder_parent_id": "",
|
||||
"name": "FSECRET2",
|
||||
"username": "",
|
||||
"uri": "",
|
||||
"password": "fsecret2",
|
||||
"description": "",
|
||||
"created_timestamp": "2025-02-21T06:04:34Z",
|
||||
"modified_timestamp": "2025-02-21T06:04:34Z"
|
||||
},
|
||||
{
|
||||
"id": "dd32963c-0db5-4303-a6fc-22c5229dabef",
|
||||
"folder_parent_id": "",
|
||||
"name": "SECRET1",
|
||||
"username": "",
|
||||
"uri": "",
|
||||
"password": "secret1",
|
||||
"description": "",
|
||||
"created_timestamp": "2025-02-21T06:04:23Z",
|
||||
"modified_timestamp": "2025-02-21T06:04:23Z"
|
||||
}
|
||||
]
|
||||
JSON
|
||||
|
||||
json = JSON.parse(
|
||||
shellunescape run_command("fetch", "SECRET1", "FSECRET1", "FSECRET2")
|
||||
)
|
||||
|
||||
expected_json = {
|
||||
"SECRET1"=>"secret1",
|
||||
"FSECRET1"=>"fsecret1",
|
||||
"FSECRET2"=>"fsecret2"
|
||||
}
|
||||
|
||||
assert_equal expected_json, json
|
||||
end
|
||||
|
||||
test "fetch with --from" do
|
||||
stub_ticks_with("passbolt --version 2> /dev/null", succeed: true)
|
||||
stub_ticks.with("passbolt verify 2> /dev/null", succeed: true)
|
||||
|
||||
stub_ticks
|
||||
.with("passbolt list folders --filter 'Name == \"my-project\"' --json")
|
||||
.returns(<<~JSON)
|
||||
[
|
||||
{
|
||||
"id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3",
|
||||
"folder_parent_id": "",
|
||||
"name": "my-project",
|
||||
"created_timestamp": "2025-02-21T19:52:50Z",
|
||||
"modified_timestamp": "2025-02-21T19:52:50Z"
|
||||
}
|
||||
]
|
||||
JSON
|
||||
|
||||
stub_ticks
|
||||
.with("passbolt list resources --filter '(Name == \"SECRET1\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\") || (Name == \"FSECRET1\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\") || (Name == \"FSECRET2\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\")' --folder dcbe0e39-42d8-42db-9637-8256b9f2f8e3 --json")
|
||||
.returns(<<~JSON)
|
||||
[
|
||||
{
|
||||
"id": "4c116996-f6d0-4342-9572-0d676f75b3ac",
|
||||
"folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3",
|
||||
"name": "FSECRET1",
|
||||
"username": "",
|
||||
"uri": "",
|
||||
"password": "fsecret1",
|
||||
"description": "",
|
||||
"created_timestamp": "2025-02-21T06:04:29Z",
|
||||
"modified_timestamp": "2025-02-21T06:04:29Z"
|
||||
},
|
||||
{
|
||||
"id": "62949b26-4957-43fe-9523-294d66861499",
|
||||
"folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3",
|
||||
"name": "FSECRET2",
|
||||
"username": "",
|
||||
"uri": "",
|
||||
"password": "fsecret2",
|
||||
"description": "",
|
||||
"created_timestamp": "2025-02-21T06:04:34Z",
|
||||
"modified_timestamp": "2025-02-21T06:04:34Z"
|
||||
},
|
||||
{
|
||||
"id": "dd32963c-0db5-4303-a6fc-22c5229dabef",
|
||||
"folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3",
|
||||
"name": "SECRET1",
|
||||
"username": "",
|
||||
"uri": "",
|
||||
"password": "secret1",
|
||||
"description": "",
|
||||
"created_timestamp": "2025-02-21T06:04:23Z",
|
||||
"modified_timestamp": "2025-02-21T06:04:23Z"
|
||||
}
|
||||
]
|
||||
JSON
|
||||
|
||||
json = JSON.parse(
|
||||
shellunescape run_command("fetch", "--from", "my-project", "SECRET1", "FSECRET1", "FSECRET2")
|
||||
)
|
||||
|
||||
expected_json = {
|
||||
"SECRET1"=>"secret1",
|
||||
"FSECRET1"=>"fsecret1",
|
||||
"FSECRET2"=>"fsecret2"
|
||||
}
|
||||
|
||||
assert_equal expected_json, json
|
||||
end
|
||||
|
||||
test "fetch with folder in secret" do
|
||||
stub_ticks_with("passbolt --version 2> /dev/null", succeed: true)
|
||||
stub_ticks.with("passbolt verify 2> /dev/null", succeed: true)
|
||||
|
||||
stub_ticks
|
||||
.with("passbolt list folders --filter 'Name == \"my-project\"' --json")
|
||||
.returns(<<~JSON)
|
||||
[
|
||||
{
|
||||
"id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3",
|
||||
"folder_parent_id": "",
|
||||
"name": "my-project",
|
||||
"created_timestamp": "2025-02-21T19:52:50Z",
|
||||
"modified_timestamp": "2025-02-21T19:52:50Z"
|
||||
}
|
||||
]
|
||||
JSON
|
||||
|
||||
stub_ticks
|
||||
.with("passbolt list resources --filter '(Name == \"SECRET1\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\") || (Name == \"FSECRET1\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\") || (Name == \"FSECRET2\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\")' --folder dcbe0e39-42d8-42db-9637-8256b9f2f8e3 --json")
|
||||
.returns(<<~JSON)
|
||||
[
|
||||
{
|
||||
"id": "4c116996-f6d0-4342-9572-0d676f75b3ac",
|
||||
"folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3",
|
||||
"name": "FSECRET1",
|
||||
"username": "",
|
||||
"uri": "",
|
||||
"password": "fsecret1",
|
||||
"description": "",
|
||||
"created_timestamp": "2025-02-21T06:04:29Z",
|
||||
"modified_timestamp": "2025-02-21T06:04:29Z"
|
||||
},
|
||||
{
|
||||
"id": "62949b26-4957-43fe-9523-294d66861499",
|
||||
"folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3",
|
||||
"name": "FSECRET2",
|
||||
"username": "",
|
||||
"uri": "",
|
||||
"password": "fsecret2",
|
||||
"description": "",
|
||||
"created_timestamp": "2025-02-21T06:04:34Z",
|
||||
"modified_timestamp": "2025-02-21T06:04:34Z"
|
||||
},
|
||||
{
|
||||
"id": "dd32963c-0db5-4303-a6fc-22c5229dabef",
|
||||
"folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3",
|
||||
"name": "SECRET1",
|
||||
"username": "",
|
||||
"uri": "",
|
||||
"password": "secret1",
|
||||
"description": "",
|
||||
"created_timestamp": "2025-02-21T06:04:23Z",
|
||||
"modified_timestamp": "2025-02-21T06:04:23Z"
|
||||
}
|
||||
]
|
||||
JSON
|
||||
|
||||
json = JSON.parse(
|
||||
shellunescape run_command("fetch", "my-project/SECRET1", "my-project/FSECRET1", "my-project/FSECRET2")
|
||||
)
|
||||
|
||||
expected_json = {
|
||||
"SECRET1"=>"secret1",
|
||||
"FSECRET1"=>"fsecret1",
|
||||
"FSECRET2"=>"fsecret2"
|
||||
}
|
||||
|
||||
assert_equal expected_json, json
|
||||
end
|
||||
|
||||
test "fetch from multiple folders" do
|
||||
stub_ticks_with("passbolt --version 2> /dev/null", succeed: true)
|
||||
stub_ticks.with("passbolt verify 2> /dev/null", succeed: true)
|
||||
|
||||
stub_ticks
|
||||
.with("passbolt list folders --filter 'Name == \"my-project\" || Name == \"other-project\"' --json")
|
||||
.returns(<<~JSON)
|
||||
[
|
||||
{
|
||||
"id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3",
|
||||
"folder_parent_id": "",
|
||||
"name": "my-project",
|
||||
"created_timestamp": "2025-02-21T19:52:50Z",
|
||||
"modified_timestamp": "2025-02-21T19:52:50Z"
|
||||
},
|
||||
{
|
||||
"id": "14e11dd8-b279-4689-8bd9-fa33ebb527da",
|
||||
"folder_parent_id": "",
|
||||
"name": "other-project",
|
||||
"created_timestamp": "2025-02-21T20:00:29Z",
|
||||
"modified_timestamp": "2025-02-21T20:00:29Z"
|
||||
}
|
||||
]
|
||||
JSON
|
||||
|
||||
stub_ticks
|
||||
.with("passbolt list resources --filter '(Name == \"SECRET1\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\") || (Name == \"FSECRET1\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\") || (Name == \"FSECRET2\" && FolderParentID == \"14e11dd8-b279-4689-8bd9-fa33ebb527da\")' --folder dcbe0e39-42d8-42db-9637-8256b9f2f8e3 --folder 14e11dd8-b279-4689-8bd9-fa33ebb527da --json")
|
||||
.returns(<<~JSON)
|
||||
[
|
||||
{
|
||||
"id": "4c116996-f6d0-4342-9572-0d676f75b3ac",
|
||||
"folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3",
|
||||
"name": "FSECRET1",
|
||||
"username": "",
|
||||
"uri": "",
|
||||
"password": "fsecret1",
|
||||
"description": "",
|
||||
"created_timestamp": "2025-02-21T06:04:29Z",
|
||||
"modified_timestamp": "2025-02-21T06:04:29Z"
|
||||
},
|
||||
{
|
||||
"id": "62949b26-4957-43fe-9523-294d66861499",
|
||||
"folder_parent_id": "14e11dd8-b279-4689-8bd9-fa33ebb527da",
|
||||
"name": "FSECRET2",
|
||||
"username": "",
|
||||
"uri": "",
|
||||
"password": "fsecret2",
|
||||
"description": "",
|
||||
"created_timestamp": "2025-02-21T06:04:34Z",
|
||||
"modified_timestamp": "2025-02-21T06:04:34Z"
|
||||
},
|
||||
{
|
||||
"id": "dd32963c-0db5-4303-a6fc-22c5229dabef",
|
||||
"folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3",
|
||||
"name": "SECRET1",
|
||||
"username": "",
|
||||
"uri": "",
|
||||
"password": "secret1",
|
||||
"description": "",
|
||||
"created_timestamp": "2025-02-21T06:04:23Z",
|
||||
"modified_timestamp": "2025-02-21T06:04:23Z"
|
||||
}
|
||||
]
|
||||
JSON
|
||||
|
||||
json = JSON.parse(
|
||||
shellunescape run_command("fetch", "my-project/SECRET1", "my-project/FSECRET1", "other-project/FSECRET2")
|
||||
)
|
||||
|
||||
expected_json = {
|
||||
"SECRET1"=>"secret1",
|
||||
"FSECRET1"=>"fsecret1",
|
||||
"FSECRET2"=>"fsecret2"
|
||||
}
|
||||
|
||||
assert_equal expected_json, json
|
||||
end
|
||||
|
||||
test "fetch from nested folder" do
|
||||
stub_ticks_with("passbolt --version 2> /dev/null", succeed: true)
|
||||
stub_ticks.with("passbolt verify 2> /dev/null", succeed: true)
|
||||
|
||||
stub_ticks
|
||||
.with("passbolt list folders --filter 'Name == \"my-project\"' --json")
|
||||
.returns(<<~JSON)
|
||||
[
|
||||
{
|
||||
"id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3",
|
||||
"folder_parent_id": "",
|
||||
"name": "my-project",
|
||||
"created_timestamp": "2025-02-21T19:52:50Z",
|
||||
"modified_timestamp": "2025-02-21T19:52:50Z"
|
||||
}
|
||||
]
|
||||
JSON
|
||||
|
||||
stub_ticks
|
||||
.with("passbolt list folders --filter 'Name == \"subfolder\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\"' --json")
|
||||
.returns(<<~JSON)
|
||||
[
|
||||
{
|
||||
"id": "6a3f21fc-aa40-4ba9-852c-7477fdd0310d",
|
||||
"folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3",
|
||||
"name": "subfolder",
|
||||
"created_timestamp": "2025-02-21T19:52:50Z",
|
||||
"modified_timestamp": "2025-02-21T19:52:50Z"
|
||||
}
|
||||
]
|
||||
JSON
|
||||
|
||||
stub_ticks
|
||||
.with("passbolt list resources --filter '(Name == \"SECRET1\" && FolderParentID == \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\") || (Name == \"FSECRET1\" && FolderParentID == \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\") || (Name == \"FSECRET2\" && FolderParentID == \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\")' --folder dcbe0e39-42d8-42db-9637-8256b9f2f8e3 --folder 6a3f21fc-aa40-4ba9-852c-7477fdd0310d --json")
|
||||
.returns(<<~JSON)
|
||||
[
|
||||
{
|
||||
"id": "4c116996-f6d0-4342-9572-0d676f75b3ac",
|
||||
"folder_parent_id": "6a3f21fc-aa40-4ba9-852c-7477fdd0310d",
|
||||
"name": "FSECRET1",
|
||||
"username": "",
|
||||
"uri": "",
|
||||
"password": "fsecret1",
|
||||
"description": "",
|
||||
"created_timestamp": "2025-02-21T06:04:29Z",
|
||||
"modified_timestamp": "2025-02-21T06:04:29Z"
|
||||
},
|
||||
{
|
||||
"id": "62949b26-4957-43fe-9523-294d66861499",
|
||||
"folder_parent_id": "6a3f21fc-aa40-4ba9-852c-7477fdd0310d",
|
||||
"name": "FSECRET2",
|
||||
"username": "",
|
||||
"uri": "",
|
||||
"password": "fsecret2",
|
||||
"description": "",
|
||||
"created_timestamp": "2025-02-21T06:04:34Z",
|
||||
"modified_timestamp": "2025-02-21T06:04:34Z"
|
||||
},
|
||||
{
|
||||
"id": "dd32963c-0db5-4303-a6fc-22c5229dabef",
|
||||
"folder_parent_id": "6a3f21fc-aa40-4ba9-852c-7477fdd0310d",
|
||||
"name": "SECRET1",
|
||||
"username": "",
|
||||
"uri": "",
|
||||
"password": "secret1",
|
||||
"description": "",
|
||||
"created_timestamp": "2025-02-21T06:04:23Z",
|
||||
"modified_timestamp": "2025-02-21T06:04:23Z"
|
||||
}
|
||||
]
|
||||
JSON
|
||||
|
||||
json = JSON.parse(
|
||||
shellunescape run_command("fetch", "--from", "my-project/subfolder", "SECRET1", "FSECRET1", "FSECRET2")
|
||||
)
|
||||
|
||||
expected_json = {
|
||||
"SECRET1"=>"secret1",
|
||||
"FSECRET1"=>"fsecret1",
|
||||
"FSECRET2"=>"fsecret2"
|
||||
}
|
||||
|
||||
assert_equal expected_json, json
|
||||
end
|
||||
|
||||
test "fetch from nested folder in secret" do
|
||||
stub_ticks_with("passbolt --version 2> /dev/null", succeed: true)
|
||||
stub_ticks.with("passbolt verify 2> /dev/null", succeed: true)
|
||||
|
||||
stub_ticks
|
||||
.with("passbolt list folders --filter 'Name == \"my-project\"' --json")
|
||||
.returns(<<~JSON)
|
||||
[
|
||||
{
|
||||
"id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3",
|
||||
"folder_parent_id": "",
|
||||
"name": "my-project",
|
||||
"created_timestamp": "2025-02-21T19:52:50Z",
|
||||
"modified_timestamp": "2025-02-21T19:52:50Z"
|
||||
}
|
||||
]
|
||||
JSON
|
||||
|
||||
stub_ticks
|
||||
.with("passbolt list folders --filter 'Name == \"subfolder\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\"' --json")
|
||||
.returns(<<~JSON)
|
||||
[
|
||||
{
|
||||
"id": "6a3f21fc-aa40-4ba9-852c-7477fdd0310d",
|
||||
"folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3",
|
||||
"name": "subfolder",
|
||||
"created_timestamp": "2025-02-21T19:52:50Z",
|
||||
"modified_timestamp": "2025-02-21T19:52:50Z"
|
||||
}
|
||||
]
|
||||
JSON
|
||||
|
||||
stub_ticks
|
||||
.with("passbolt list resources --filter '(Name == \"SECRET1\" && FolderParentID == \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\") || (Name == \"FSECRET1\" && FolderParentID == \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\") || (Name == \"FSECRET2\" && FolderParentID == \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\")' --folder dcbe0e39-42d8-42db-9637-8256b9f2f8e3 --folder 6a3f21fc-aa40-4ba9-852c-7477fdd0310d --json")
|
||||
.returns(<<~JSON)
|
||||
[
|
||||
{
|
||||
"id": "4c116996-f6d0-4342-9572-0d676f75b3ac",
|
||||
"folder_parent_id": "6a3f21fc-aa40-4ba9-852c-7477fdd0310d",
|
||||
"name": "FSECRET1",
|
||||
"username": "",
|
||||
"uri": "",
|
||||
"password": "fsecret1",
|
||||
"description": "",
|
||||
"created_timestamp": "2025-02-21T06:04:29Z",
|
||||
"modified_timestamp": "2025-02-21T06:04:29Z"
|
||||
},
|
||||
{
|
||||
"id": "62949b26-4957-43fe-9523-294d66861499",
|
||||
"folder_parent_id": "6a3f21fc-aa40-4ba9-852c-7477fdd0310d",
|
||||
"name": "FSECRET2",
|
||||
"username": "",
|
||||
"uri": "",
|
||||
"password": "fsecret2",
|
||||
"description": "",
|
||||
"created_timestamp": "2025-02-21T06:04:34Z",
|
||||
"modified_timestamp": "2025-02-21T06:04:34Z"
|
||||
},
|
||||
{
|
||||
"id": "dd32963c-0db5-4303-a6fc-22c5229dabef",
|
||||
"folder_parent_id": "6a3f21fc-aa40-4ba9-852c-7477fdd0310d",
|
||||
"name": "SECRET1",
|
||||
"username": "",
|
||||
"uri": "",
|
||||
"password": "secret1",
|
||||
"description": "",
|
||||
"created_timestamp": "2025-02-21T06:04:23Z",
|
||||
"modified_timestamp": "2025-02-21T06:04:23Z"
|
||||
}
|
||||
]
|
||||
JSON
|
||||
|
||||
json = JSON.parse(
|
||||
shellunescape run_command("fetch", "my-project/subfolder/SECRET1", "my-project/subfolder/FSECRET1", "my-project/subfolder/FSECRET2")
|
||||
)
|
||||
|
||||
expected_json = {
|
||||
"SECRET1"=>"secret1",
|
||||
"FSECRET1"=>"fsecret1",
|
||||
"FSECRET2"=>"fsecret2"
|
||||
}
|
||||
|
||||
assert_equal expected_json, json
|
||||
end
|
||||
|
||||
test "fetch without CLI installed" do
|
||||
stub_ticks_with("passbolt --version 2> /dev/null", succeed: false)
|
||||
|
||||
error = assert_raises RuntimeError do
|
||||
JSON.parse(shellunescape(run_command("fetch", "HOST", "PORT")))
|
||||
end
|
||||
|
||||
assert_equal "Passbolt CLI is not installed", error.message
|
||||
end
|
||||
|
||||
private
|
||||
def run_command(*command)
|
||||
stdouted do
|
||||
Kamal::Cli::Secrets.start \
|
||||
[ *command,
|
||||
"-c", "test/fixtures/deploy_with_accessories.yml",
|
||||
"--adapter", "passbolt" ]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,7 @@ require "active_support/test_case"
|
||||
require "active_support/testing/autorun"
|
||||
require "active_support/testing/stream"
|
||||
require "rails/test_unit/line_filtering"
|
||||
require "pty"
|
||||
require "debug"
|
||||
require "mocha/minitest" # using #stubs that can alter returns
|
||||
require "minitest/autorun" # using #stub that take args
|
||||
@@ -48,6 +49,27 @@ class ActiveSupport::TestCase
|
||||
capture(:stderr) { yield }.strip
|
||||
end
|
||||
|
||||
def stub_stdin_tty
|
||||
PTY.open do |master, slave|
|
||||
stub_stdin(master) { yield }
|
||||
end
|
||||
end
|
||||
|
||||
def stub_stdin_file
|
||||
File.open("/dev/null", "r") do |file|
|
||||
stub_stdin(file) { yield }
|
||||
end
|
||||
end
|
||||
|
||||
def stub_stdin(io)
|
||||
original_stdin = STDIN.dup
|
||||
STDIN.reopen(io)
|
||||
yield
|
||||
ensure
|
||||
STDIN.reopen(original_stdin)
|
||||
original_stdin.close
|
||||
end
|
||||
|
||||
def with_test_secrets(**files)
|
||||
setup_test_secrets(**files)
|
||||
yield
|
||||
|
||||
Reference in New Issue
Block a user