Compare commits

..

93 Commits

Author SHA1 Message Date
Donal McBreen
45197e46f6 Bump version for 2.5.0 2025-02-04 11:19:44 +00:00
Donal McBreen
6b40a64b9a Merge pull request #1398 from basecamp/kamal-2.5-doc-changes
Doc changes for 2.5
2025-02-04 11:18:40 +00:00
Donal McBreen
9af2425fbd Doc changes for 2.5
Sync up the accessory.yml file with the latest changes in the
kamal-site repo.
2025-02-04 11:05:25 +00:00
Donal McBreen
854dd925ba Merge pull request #1397 from basecamp/prep-for-2.5
Prep for 2.5
2025-02-04 09:18:30 +00:00
Donal McBreen
8775d202bd Prep for 2.5
- Reset KAMAL on alias command, rather than relying on checking
  "invoked_via_subcommand"
- Validate the accessories roles when loading the configuration
  not later on when trying to access them
2025-02-04 09:08:57 +00:00
Donal McBreen
bae7c56e74 Merge pull request #1392 from neiljohari/feature/allow-omitting-aws-account
Allow omitting AWS account parameter while fetching secrets
2025-02-04 08:45:06 +00:00
Neil Johari
07d05ad58a Run rubocop auto correct 2025-02-03 09:44:39 -08:00
Neil Johari
e69611efb6 Add final newline 2025-02-03 08:56:06 -08:00
Donal McBreen
ba6dd6ff14 Merge pull request #1396 from basecamp/allow-accessory-roles-with-no-hosts
Allow accessory roles with no hosts
2025-02-03 16:54:28 +00:00
Donal McBreen
04a96aa5be Allow accessory roles with no hosts
Only raise an exception if the role is not found, not it it has no
hosts.
2025-02-03 16:44:01 +00:00
Donal McBreen
dba3a115bd Merge pull request #1395 from basecamp/app-boot-hooks
Add pre and post app boot hooks
2025-02-03 16:04:14 +00:00
Donal McBreen
cd73cea850 Add pre and post app boot hooks
Add two new hooks pre-app-boot and post-app-boot. They are analagous
to the pre/post proxy reboot hooks.

If the boot strategy deploys in groups, then the hooks are called once
per group of hosts and `KAMAL_HOSTS` contains a comma delimited list of
the hosts in that group.

If all hosts are deployed to at once, then they are called once with
`KAMAL_HOSTS` containing all the hosts.

It is possible to have pauses between groups of hosts in the boot config,
where this is the case the pause happens after the post-app-boot hook is
called.
2025-02-03 15:54:45 +00:00
Donal McBreen
09d020e9bb Merge pull request #1357 from flavorjones/flavorjones-kamal-build-local
Introduce a "build dev" command
2025-02-03 09:07:00 +00:00
Neil Johari
ff3538f81d Undo accidental line deletion 2025-02-02 23:54:53 -08:00
Neil Johari
c7d1711e30 Remove unnecessary var 2025-02-02 23:46:09 -08:00
Neil Johari
d710b5a22b Allow ommitting AWS account while fetching secrets 2025-02-02 23:43:51 -08:00
Mike Dalessio
214d4fd321 The build dev command warns about untracked and uncommitted files
so there's a complete picture of what is being packaged that's not in git.
2025-02-01 22:19:05 -05:00
Donal McBreen
5ddaa3810d Merge pull request #1370 from flavorjones/flavorjones-ci-matrix
ci: use `fail-fast: false` instead of `continue-on-error: true`
2025-01-22 09:28:42 +00:00
Mike Dalessio
3c01dc75fd ci: use fail-fast: false instead of continue-on-error: true
This will give us proper red/green signal on the test suite while
still running the entire matrix.
2025-01-20 18:59:11 -05:00
Mike Dalessio
2127f1708a feat: Introduce a build dev command
which will build a "dirty" image using the working directory.

This command is different from `build push` in two important ways:

- the image tags will have a suffix of `-dirty`
- the export action is "docker", pushing to the local docker image store

The command also supports the `--output` option just added to `build
push` to override that default.

This command is intended to allow developers to quickly iterate on a
docker image built from their local working directory while avoiding
any confusion with a pristine image built from a git clone, and
keeping those images on the local dev system by default.
2025-01-20 18:52:21 -05:00
Mike Dalessio
24e4347c45 feat: Introduce a build push --output option
which controls where the build result is exported.

The default value is "registry" to reflect the current behavior of
`build push`.

Any value provided to this option will be passed to the `buildx build`
command as a `--output=type=<VALUE>` flag.

For example, the following command will push to the local docker image
store:

    kamal build push --output=docker

squash
2025-01-20 18:37:15 -05:00
Donal McBreen
5f04e4266b Merge pull request #1369 from basecamp/dont-cleanup-traefik-on-reboot
Don't cleanup traefik on reboot
2025-01-20 15:23:49 +00:00
Donal McBreen
35a29cc538 Merge pull request #1331 from guillaumebriday/patch-1
Fixing log command on role in example
2025-01-20 15:23:31 +00:00
Donal McBreen
f187080db5 Don't cleanup traefik on reboot
This was designed to help with upgrading from Kamal 1 to Kamal 2
but it causes issues if you have a traefik container you don't want
to be shut down.
2025-01-20 15:06:06 +00:00
Donal McBreen
080fa49fdf Merge pull request #1368 from basecamp/error-free-ruby-version-comment
Don't read a file in sample deploy.yml
2025-01-20 14:38:34 +00:00
Donal McBreen
34050f1036 Don't read a file in sample deploy.yml
The ERB runs first so it does matter if it in a comment. If the file
doesn't exist (e.g. if not using Ruby, you'll get an error).

We'll change the example to match the Rails deploy.yml template won't
have than problem.
2025-01-20 14:26:43 +00:00
Donal McBreen
459c7366ec Merge pull request #1367 from brettabamonte/fix_lastpass_err_msg_typo
Fix LastPass error message typo
2025-01-20 11:43:33 +00:00
Donal McBreen
f8db5de5eb Merge pull request #1354 from matthewbjones/feature/docker-build-cloud
Adds support for Docker Build Cloud
2025-01-20 08:24:27 +00:00
brettabamonte
4d67a1671a Change LassPass to LastPass 2025-01-18 19:11:34 -05:00
Donal McBreen
2c9bba3f88 Merge branch 'main' into feature/docker-build-cloud 2025-01-17 15:49:28 +00:00
Donal McBreen
a388937de8 Merge pull request #1363 from basecamp/check-for-docker-locally
Check for docker locally before registry login
2025-01-17 15:45:18 +00:00
Donal McBreen
9ef6c2f893 Merge pull request #1361 from basecamp/ruby-3.4
Update to Ruby 3.4 from the preview version
2025-01-17 15:27:34 +00:00
Donal McBreen
eee9d67691 Merge pull request #1319 from ShPakvel/fix_bug_in_role_validate_servers
Fix bugs for role validate servers
2025-01-17 15:19:25 +00:00
Donal McBreen
5bd9bc8576 Merge pull request #1320 from ShPakvel/add_optional_accessory_registry
[Feature] Registry for accessory
2025-01-17 15:18:50 +00:00
Donal McBreen
a5b9c69838 Update to Ruby 3.4 from the preview version 2025-01-17 15:17:37 +00:00
Donal McBreen
dc9a95db2c Check for docker locally before registry login
We were checking before `kamal build push`, but not `kamal registry login`.
Since `kamal registry login` is called first by a deploy we don't
get the nice error message.
2025-01-17 15:17:22 +00:00
Donal McBreen
0174b872bf Merge pull request #1362 from basecamp/boot-accessories-after-pre-deploy-hook
Boot accessories after pre-deploy hook
2025-01-17 15:13:04 +00:00
Donal McBreen
1db44c402c Boot accessories after pre-deploy hook
That allows you to set proxy config in the hook before booting
the proxy.
2025-01-17 15:04:16 +00:00
Matthew Jones
b420b2613d Adds support for Docker Build Cloud 2025-01-17 07:14:31 -07:00
Donal McBreen
4ffa772201 Don't boot proxy twice when setting up 2025-01-17 13:04:37 +00:00
Donal McBreen
e081414849 Merge pull request #1308 from pokonski/proxy-accessory-fix
Boot proxy on server setup
2025-01-17 13:04:07 +00:00
Donal McBreen
85c1c47c2f Merge pull request #1360 from basecamp/secret-adapter-tidy
Secret adapter tidy
2025-01-17 13:01:21 +00:00
Donal McBreen
9f1688da7a Fix test 2025-01-17 12:52:23 +00:00
Donal McBreen
2bd716ece4 Drop the TestOptionalAccount adapter
It's included in the gem lib which is best to avoid and we can infer
that it works account optional adapters.
2025-01-17 12:37:12 +00:00
Donal McBreen
f9a78f4fcb gcloud login tidy
Use unless instead of if !, don't suggest running gcloud auth login,
we've just tried that.
2025-01-17 12:34:38 +00:00
Donal McBreen
10dafc058a Extract secrets_get_flags 2025-01-17 12:31:24 +00:00
Donal McBreen
5e2678dece Ensure external input is shell escaped 2025-01-17 12:28:59 +00:00
Donal McBreen
a1708f687f Prefix secrets in fetch_secrets
This allows us to remove the custom fetch method for enpass.
2025-01-17 12:24:46 +00:00
Donal McBreen
db7556ed99 Fix enpass adapter
There were changes in main that meant the tests failed after merging.

Adding the new `requires_account?` method to the enpass adapter fixed it.
2025-01-17 12:07:56 +00:00
Donal McBreen
93133cd7a9 Merge pull request #1236 from andrelaszlo/gcp_secret_manager_adapter
Add GCP Secret Manager adapter
2025-01-17 12:07:33 +00:00
Donal McBreen
a7b2ef56c7 Merge pull request #1189 from egze/enpass
Add support for Enpass - a password manager for secrets
2025-01-17 12:01:24 +00:00
Donal McBreen
06f2cb223e Merge branch 'main' into gcp_secret_manager_adapter 2025-01-17 11:57:52 +00:00
Donal McBreen
ea7e72d75f Merge pull request #1186 from oandalib/bitwarden-secrets-manager
feat: add Bitwarden Secrets Manager adapter
2025-01-17 11:43:19 +00:00
Donal McBreen
9035bd0d88 Merge pull request #1359 from basecamp/add-env-precedence-tests
Add tests for env/secret file precedence
2025-01-17 11:19:32 +00:00
Donal McBreen
dd8cadf743 Add tests for env/secret file precedence 2025-01-17 11:06:29 +00:00
Donal McBreen
f1a9a09929 Merge pull request #1265 from phoozle/proxy-bind-ip
Add proxy boot_config --publish-ip argument
2025-01-17 08:49:17 +00:00
Donal McBreen
620b132138 Merge pull request #1313 from emmceemoore/patch-1
Configure the CLI to exit non-zero on failures.
2025-01-17 08:31:58 +00:00
Donal McBreen
2e7d0ddc44 Merge pull request #1358 from basecamp/dont-run-assets-container
Create but don't run the assets container
2025-01-17 08:09:01 +00:00
Donal McBreen
ab8396fbb2 Merge pull request #1032 from basecamp/set-config-file-and-deploy-in-aliases
Allow destination and config-file in aliases
2025-01-17 08:07:06 +00:00
Donal McBreen
2cdca4596c Create but don't run the assets container
We don't need to run the assets container to copy the assets out,
instead we can just create, copy and remove.
2025-01-16 16:28:02 +00:00
Donal McBreen
78fcc3d88f Allow destination and config-file in aliases
We only loaded the configuration once, which meant that aliases always
used the initial configuration file and destination.

We don't want to load the configuration in subcommands as it is not
passed all the options we need. But just checking if we are in a
subcommand is enough - the alias reloads and the subcommand does not.

One thing to note is that anything passed on the command line overrides
what is in the alias, so if an alias says
`other_config: config -c config/deploy2.yml` and you run
`kamal other_config -c config/deploy.yml`, it won't switch.
2025-01-16 15:51:18 +00:00
Guillaume Briday
2b9d5c2b19 Fixing log command on role 2025-01-02 22:51:01 +01:00
Pavel Shpak
d59c274208 Fix typo in configuration initializer method. 2024-12-22 04:37:15 +02:00
Pavel Shpak
bd8689c185 Fix bug in role validate_servers.
There were typo-bug during `validate_servers!` invocation for role.
It wasn't discovered, because it never met condition. Because role_config wasn't correctly extracted for validation.

Also remove not used anymore `accessories_on`. Leftover from previous changes.
2024-12-22 03:28:12 +02:00
Pavel Shpak
b5aee11a40 [Feature] Add optional accessory registry.
Add test cases to cover new option.
2024-12-22 02:50:53 +02:00
Mike Moore
2943c4a301 Use the newer option name. 2024-12-20 08:45:47 -07:00
Mike Moore
32e1b6504d Re-trigger GitHub actions. 2024-12-20 08:26:14 -07:00
Mike Moore
39e2c4f848 Trying the new method for setting proxy boot config. 2024-12-19 12:14:00 -07:00
Mike Moore
89db5025a0 Configure Thor to "exit on failure". 2024-12-19 09:28:37 -07:00
Piotrek O
c56edba4a9 Boot proxy on server setup 2024-12-18 11:35:57 +01:00
André Laszlo
8103d68688 Shellescape all interpolated strings in commands 2024-12-06 17:43:47 +01:00
André Laszlo
eb82b4a753 Keep the 'default' prefix for secret items 2024-12-06 17:40:08 +01:00
André Laszlo
19b4359b17 Use a nil session 2024-12-06 17:32:31 +01:00
André Laszlo
dc64aaa0de Add gcloud auth login invocation to test 2024-12-06 17:32:01 +01:00
André Laszlo
ea170fbe5e Run gcloud auth login if user is not authenticated 2024-12-06 17:22:03 +01:00
André Laszlo
18f2aae936 Simplify parsing by changing account separators 2024-12-06 17:15:22 +01:00
André Laszlo
e314f38bdc Merge remote-tracking branch 'origin/main' into gcp_secret_manager_adapter 2024-12-06 17:08:26 +01:00
Matthew Croall
1c8a56b8cf Change invalid publish ip exception class 2024-12-04 10:44:16 +10:30
Matthew Croall
e597ae6155 Add support for multiple publish ip addresses 2024-12-04 10:42:50 +10:30
Omid Andalib
aa9fe4c525 feat: add Bitwarden Secrets Manager adapter 2024-12-03 00:41:16 -08:00
Matthew Croall
0bafa02e7d Rename proxy bind cli argument to publish_host_ip 2024-12-03 08:13:20 +10:30
Matthew Croall
ffe1ac3483 Refactor proxy_publish_args argument concatenation 2024-12-03 08:11:19 +10:30
Matthew Croall
11e4f37409 Add proxy boot_config --publish-ip argument 2024-11-30 11:10:49 +10:30
André Laszlo
b87bcae6a3 Merge remote-tracking branch 'origin/main' into gcp_secret_manager_adapter 2024-11-27 13:42:21 +01:00
André Laszlo
0c9a367efc Remove overly generic 'secret_manager' alias 2024-11-27 13:33:04 +01:00
André Laszlo
a07ef64fad Fix --account documentation 2024-11-20 15:27:51 +01:00
André Laszlo
3793bdc2c3 Add GCP Secret Manager adapter 2024-11-20 14:10:20 +01:00
Aleksandr Lossenko
79bc7584ca make --account optional and pass Enpass vault in --from 2024-11-14 09:16:23 +01:00
Aleksandr Lossenko
c9dec8c79a no need for open3 anymore 2024-11-14 09:16:23 +01:00
Aleksandr Lossenko
8d7a6403b5 enpass-cli now has JSON support 2024-11-14 09:16:23 +01:00
Aleksandr Lossenko
b356b08069 improve password parsing 2024-11-14 09:16:23 +01:00
Aleksandr Lossenko
4d09f3c242 add more docs 2024-11-14 09:16:23 +01:00
Aleksandr Lossenko
d6c4411e97 add support to enpass 2024-11-14 09:16:23 +01:00
78 changed files with 1599 additions and 352 deletions

View File

@@ -23,12 +23,13 @@ jobs:
run: bundle exec rubocop --parallel run: bundle exec rubocop --parallel
tests: tests:
strategy: strategy:
fail-fast: false
matrix: matrix:
ruby-version: ruby-version:
- "3.1" - "3.1"
- "3.2" - "3.2"
- "3.3" - "3.3"
- "3.4.0-preview2" - "3.4"
gemfile: gemfile:
- Gemfile - Gemfile
- gemfiles/rails_edge.gemfile - gemfiles/rails_edge.gemfile
@@ -37,7 +38,6 @@ jobs:
gemfile: gemfiles/rails_edge.gemfile gemfile: gemfiles/rails_edge.gemfile
name: ${{ format('Tests (Ruby {0})', matrix.ruby-version) }} name: ${{ format('Tests (Ruby {0})', matrix.ruby-version) }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
continue-on-error: true
env: env:
BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }} BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}
steps: steps:

View File

@@ -1,7 +1,7 @@
PATH PATH
remote: . remote: .
specs: specs:
kamal (2.4.0) kamal (2.5.0)
activesupport (>= 7.0) activesupport (>= 7.0)
base64 (~> 0.2) base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0) bcrypt_pbkdf (~> 1.0)

View File

@@ -2,6 +2,7 @@ module Kamal::Cli
class BootError < StandardError; end class BootError < StandardError; end
class HookError < StandardError; end class HookError < StandardError; end
class LockError < StandardError; end class LockError < StandardError; end
class DependencyError < StandardError; end
end end
# SSHKit uses instance eval, so we need a global const for ergonomics # SSHKit uses instance eval, so we need a global const for ergonomics

View File

@@ -292,7 +292,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
def prepare(name) def prepare(name)
with_accessory(name) do |accessory, hosts| with_accessory(name) do |accessory, hosts|
on(hosts) do on(hosts) do
execute *KAMAL.registry.login execute *KAMAL.registry.login(registry_config: accessory.registry)
execute *KAMAL.docker.create_network execute *KAMAL.docker.create_network
rescue SSHKit::Command::Failed => e rescue SSHKit::Command::Failed => e
raise unless e.message.include?("already exists") raise unless e.message.include?("already exists")

View File

@@ -1,6 +1,7 @@
class Kamal::Cli::Alias::Command < Thor::DynamicCommand class Kamal::Cli::Alias::Command < Thor::DynamicCommand
def run(instance, args = []) def run(instance, args = [])
if (_alias = KAMAL.config.aliases[name]) if (_alias = KAMAL.config.aliases[name])
KAMAL.reset
Kamal::Cli::Main.start(Shellwords.split(_alias.command) + ARGV[1..-1]) Kamal::Cli::Main.start(Shellwords.split(_alias.command) + ARGV[1..-1])
else else
super super

View File

@@ -16,12 +16,20 @@ class Kamal::Cli::App < Kamal::Cli::Base
# Primary hosts and roles are returned first, so they can open the barrier # Primary hosts and roles are returned first, so they can open the barrier
barrier = Kamal::Cli::Healthcheck::Barrier.new barrier = Kamal::Cli::Healthcheck::Barrier.new
on(KAMAL.hosts, **KAMAL.boot_strategy) do |host| host_boot_groups.each do |hosts|
host_list = Array(hosts).join(",")
run_hook "pre-app-boot", hosts: host_list
on(hosts) do |host|
KAMAL.roles_on(host).each do |role| KAMAL.roles_on(host).each do |role|
Kamal::Cli::App::Boot.new(host, role, self, version, barrier).run Kamal::Cli::App::Boot.new(host, role, self, version, barrier).run
end end
end end
run_hook "post-app-boot", hosts: host_list
sleep KAMAL.config.boot.wait if KAMAL.config.boot.wait
end
# Tag once the app booted on all hosts # Tag once the app booted on all hosts
on(KAMAL.hosts) do |host| on(KAMAL.hosts) do |host|
execute *KAMAL.auditor.record("Tagging #{KAMAL.config.absolute_image} as the latest image"), verbosity: :debug execute *KAMAL.auditor.record("Tagging #{KAMAL.config.absolute_image} as the latest image"), verbosity: :debug
@@ -340,4 +348,8 @@ class Kamal::Cli::App < Kamal::Cli::Base
yield yield
end end
end end
def host_boot_groups
KAMAL.config.boot.limit ? KAMAL.hosts.each_slice(KAMAL.config.boot.limit).to_a : [ KAMAL.hosts ]
end
end end

View File

@@ -5,7 +5,7 @@ module Kamal::Cli
class Base < Thor class Base < Thor
include SSHKit::DSL include SSHKit::DSL
def self.exit_on_failure?() false end def self.exit_on_failure?() true end
def self.dynamic_command_class() Kamal::Cli::Alias::Command end def self.dynamic_command_class() Kamal::Cli::Alias::Command end
class_option :verbose, type: :boolean, aliases: "-v", desc: "Detailed logging" class_option :verbose, type: :boolean, aliases: "-v", desc: "Detailed logging"
@@ -30,6 +30,7 @@ module Kamal::Cli
else else
super super
end end
initialize_commander unless KAMAL.configured? initialize_commander unless KAMAL.configured?
end end
@@ -194,5 +195,19 @@ module Kamal::Cli
ENV.clear ENV.clear
ENV.update(current_env) ENV.update(current_env)
end end
def ensure_docker_installed
run_locally do
begin
execute *KAMAL.builder.ensure_docker_installed
rescue SSHKit::Command::Failed => e
error = e.message =~ /command not found/ ?
"Docker is not installed locally" :
"Docker buildx plugin is not installed locally"
raise DependencyError, error
end
end
end
end end
end end

View File

@@ -5,15 +5,16 @@ class Kamal::Cli::Build < Kamal::Cli::Base
desc "deliver", "Build app and push app image to registry then pull image on servers" desc "deliver", "Build app and push app image to registry then pull image on servers"
def deliver def deliver
push invoke :push
pull invoke :pull
end end
desc "push", "Build and push app image to registry" desc "push", "Build and push app image to registry"
option :output, type: :string, default: "registry", banner: "export_type", desc: "Exported type for the build result, and may be any exported type supported by 'buildx --output'."
def push def push
cli = self cli = self
verify_local_dependencies ensure_docker_installed
run_hook "pre-build" run_hook "pre-build"
uncommitted_changes = Kamal::Git.uncommitted_changes uncommitted_changes = Kamal::Git.uncommitted_changes
@@ -49,7 +50,7 @@ class Kamal::Cli::Build < Kamal::Cli::Base
end end
# Get the command here to ensure the Dir.chdir doesn't interfere with it # Get the command here to ensure the Dir.chdir doesn't interfere with it
push = KAMAL.builder.push push = KAMAL.builder.push(cli.options[:output])
KAMAL.with_verbosity(:debug) do KAMAL.with_verbosity(:debug) do
Dir.chdir(KAMAL.config.builder.build_directory) { execute *push } Dir.chdir(KAMAL.config.builder.build_directory) { execute *push }
@@ -108,21 +109,42 @@ class Kamal::Cli::Build < Kamal::Cli::Base
end end
end end
private desc "dev", "Build using the working directory, tag it as dirty, and push to local image store."
def verify_local_dependencies option :output, type: :string, default: "docker", banner: "export_type", desc: "Exported type for the build result, and may be any exported type supported by 'buildx --output'."
def dev
cli = self
ensure_docker_installed
docker_included_files = Set.new(Kamal::Docker.included_files)
git_uncommitted_files = Set.new(Kamal::Git.uncommitted_files)
git_untracked_files = Set.new(Kamal::Git.untracked_files)
docker_uncommitted_files = docker_included_files & git_uncommitted_files
if docker_uncommitted_files.any?
say "WARNING: Files with uncommitted changes will be present in the dev container:", :yellow
docker_uncommitted_files.sort.each { |f| say " #{f}", :yellow }
say
end
docker_untracked_files = docker_included_files & git_untracked_files
if docker_untracked_files.any?
say "WARNING: Untracked files will be present in the dev container:", :yellow
docker_untracked_files.sort.each { |f| say " #{f}", :yellow }
say
end
with_env(KAMAL.config.builder.secrets) do
run_locally do run_locally do
begin build = KAMAL.builder.push(cli.options[:output], tag_as_dirty: true)
execute *KAMAL.builder.ensure_local_dependencies_installed KAMAL.with_verbosity(:debug) do
rescue SSHKit::Command::Failed => e execute(*build)
build_error = e.message =~ /command not found/ ? end
"Docker is not installed locally" :
"Docker buildx plugin is not installed locally"
raise BuildError, build_error
end end
end end
end end
private
def connect_to_remote_host(remote_host) def connect_to_remote_host(remote_host)
remote_uri = URI.parse(remote_host) remote_uri = URI.parse(remote_host)
if remote_uri.scheme == "ssh" if remote_uri.scheme == "ssh"

View File

@@ -9,15 +9,14 @@ class Kamal::Cli::Main < Kamal::Cli::Base
say "Ensure Docker is installed...", :magenta say "Ensure Docker is installed...", :magenta
invoke "kamal:cli:server:bootstrap", [], invoke_options invoke "kamal:cli:server:bootstrap", [], invoke_options
invoke "kamal:cli:accessory:boot", [ "all" ], invoke_options deploy(boot_accessories: true)
deploy
end end
end end
end end
desc "deploy", "Deploy app to servers" desc "deploy", "Deploy app to servers"
option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push" option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push"
def deploy def deploy(boot_accessories: false)
runtime = print_runtime do runtime = print_runtime do
invoke_options = deploy_options invoke_options = deploy_options
@@ -38,6 +37,8 @@ class Kamal::Cli::Main < Kamal::Cli::Base
say "Ensure kamal-proxy is running...", :magenta say "Ensure kamal-proxy is running...", :magenta
invoke "kamal:cli:proxy:boot", [], invoke_options invoke "kamal:cli:proxy:boot", [], invoke_options
invoke "kamal:cli:accessory:boot", [ "all" ], invoke_options if boot_accessories
say "Detect stale containers...", :magenta say "Detect stale containers...", :magenta
invoke "kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true) invoke "kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)

View File

@@ -23,6 +23,7 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
desc "boot_config <set|get|reset>", "Manage kamal-proxy boot configuration" desc "boot_config <set|get|reset>", "Manage kamal-proxy boot configuration"
option :publish, type: :boolean, default: true, desc: "Publish the proxy ports on the host" option :publish, type: :boolean, default: true, desc: "Publish the proxy ports on the host"
option :publish_host_ip, type: :string, repeatable: true, default: nil, desc: "Host IP address to bind HTTP/HTTPS traffic to. Defaults to all interfaces"
option :http_port, type: :numeric, default: Kamal::Configuration::PROXY_HTTP_PORT, desc: "HTTP port to publish on the host" option :http_port, type: :numeric, default: Kamal::Configuration::PROXY_HTTP_PORT, desc: "HTTP port to publish on the host"
option :https_port, type: :numeric, default: Kamal::Configuration::PROXY_HTTPS_PORT, desc: "HTTPS port to publish on the host" option :https_port, type: :numeric, default: Kamal::Configuration::PROXY_HTTPS_PORT, desc: "HTTPS port to publish on the host"
option :log_max_size, type: :string, default: Kamal::Configuration::PROXY_LOG_MAX_SIZE, desc: "Max size of proxy logs" option :log_max_size, type: :string, default: Kamal::Configuration::PROXY_LOG_MAX_SIZE, desc: "Max size of proxy logs"
@@ -31,7 +32,7 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
case subcommand case subcommand
when "set" when "set"
boot_options = [ boot_options = [
*(KAMAL.config.proxy_publish_args(options[:http_port], options[:https_port]) if options[:publish]), *(KAMAL.config.proxy_publish_args(options[:http_port], options[:https_port], options[:publish_host_ip]) if options[:publish]),
*(KAMAL.config.proxy_logging_args(options[:log_max_size])), *(KAMAL.config.proxy_logging_args(options[:log_max_size])),
*options[:docker_options].map { |option| "--#{option}" } *options[:docker_options].map { |option| "--#{option}" }
] ]
@@ -67,9 +68,6 @@ class Kamal::Cli::Proxy < Kamal::Cli::Base
execute *KAMAL.auditor.record("Rebooted proxy"), verbosity: :debug execute *KAMAL.auditor.record("Rebooted proxy"), verbosity: :debug
execute *KAMAL.registry.login execute *KAMAL.registry.login
"Stopping and removing Traefik on #{host}, if running..."
execute *KAMAL.proxy.cleanup_traefik
"Stopping and removing kamal-proxy on #{host}, if running..." "Stopping and removing kamal-proxy on #{host}, if running..."
execute *KAMAL.proxy.stop, raise_on_non_zero_exit: false execute *KAMAL.proxy.stop, raise_on_non_zero_exit: false
execute *KAMAL.proxy.remove_container execute *KAMAL.proxy.remove_container

View File

@@ -3,6 +3,8 @@ class Kamal::Cli::Registry < Kamal::Cli::Base
option :skip_local, aliases: "-L", type: :boolean, default: false, desc: "Skip local login" 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" option :skip_remote, aliases: "-R", type: :boolean, default: false, desc: "Skip remote login"
def login def login
ensure_docker_installed
run_locally { execute *KAMAL.registry.login } unless options[:skip_local] run_locally { execute *KAMAL.registry.login } unless options[:skip_local]
on(KAMAL.hosts) { execute *KAMAL.registry.login } unless options[:skip_remote] on(KAMAL.hosts) { execute *KAMAL.registry.login } unless options[:skip_remote]
end end

View File

@@ -38,7 +38,7 @@ builder:
arch: amd64 arch: amd64
# Pass in additional build args needed for your Dockerfile. # Pass in additional build args needed for your Dockerfile.
# args: # args:
# RUBY_VERSION: <%= File.read('.ruby-version').strip %> # RUBY_VERSION: <%= ENV["RBENV_VERSION"] || ENV["rvm_ruby_string"] || "#{RUBY_ENGINE}-#{RUBY_ENGINE_VERSION}" %>
# Inject ENV variables into containers (secrets come from .kamal/secrets). # Inject ENV variables into containers (secrets come from .kamal/secrets).
# #
@@ -49,7 +49,7 @@ builder:
# - RAILS_MASTER_KEY # - RAILS_MASTER_KEY
# Aliases are triggered with "bin/kamal <alias>". You can overwrite arguments on invocation: # Aliases are triggered with "bin/kamal <alias>". You can overwrite arguments on invocation:
# "bin/kamal logs -r job" will tail logs from the first server in the job section. # "bin/kamal app logs -r job" will tail logs from the first server in the job section.
# #
# aliases: # aliases:
# shell: app exec --interactive --reuse "bash" # shell: app exec --interactive --reuse "bash"

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..."

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..."

View File

@@ -4,13 +4,20 @@ require "active_support/core_ext/object/blank"
class Kamal::Commander class Kamal::Commander
attr_accessor :verbosity, :holding_lock, :connected attr_accessor :verbosity, :holding_lock, :connected
attr_reader :specific_roles, :specific_hosts
delegate :hosts, :roles, :primary_host, :primary_role, :roles_on, :proxy_hosts, :accessory_hosts, to: :specifics delegate :hosts, :roles, :primary_host, :primary_role, :roles_on, :proxy_hosts, :accessory_hosts, to: :specifics
def initialize def initialize
reset
end
def reset
self.verbosity = :info self.verbosity = :info
self.holding_lock = false self.holding_lock = false
self.connected = false self.connected = false
@specifics = nil @specifics = @specific_roles = @specific_hosts = nil
@config = @config_kwargs = nil
@commands = {}
end end
def config def config
@@ -28,8 +35,6 @@ class Kamal::Commander
@config || @config_kwargs @config || @config_kwargs
end end
attr_reader :specific_roles, :specific_hosts
def specific_primary! def specific_primary!
@specifics = nil @specifics = nil
if specific_roles.present? if specific_roles.present?
@@ -76,11 +81,6 @@ class Kamal::Commander
config.accessories&.collect(&:name) || [] config.accessories&.collect(&:name) || []
end end
def accessories_on(host)
config.accessories.select { |accessory| accessory.hosts.include?(host.to_s) }.map(&:name)
end
def app(role: nil, host: nil) def app(role: nil, host: nil)
Kamal::Commands::App.new(config, role: role, host: host) Kamal::Commands::App.new(config, role: role, host: host)
end end
@@ -94,42 +94,41 @@ class Kamal::Commander
end end
def builder def builder
@builder ||= Kamal::Commands::Builder.new(config) @commands[:builder] ||= Kamal::Commands::Builder.new(config)
end end
def docker def docker
@docker ||= Kamal::Commands::Docker.new(config) @commands[:docker] ||= Kamal::Commands::Docker.new(config)
end end
def hook def hook
@hook ||= Kamal::Commands::Hook.new(config) @commands[:hook] ||= Kamal::Commands::Hook.new(config)
end end
def lock def lock
@lock ||= Kamal::Commands::Lock.new(config) @commands[:lock] ||= Kamal::Commands::Lock.new(config)
end end
def proxy def proxy
@proxy ||= Kamal::Commands::Proxy.new(config) @commands[:proxy] ||= Kamal::Commands::Proxy.new(config)
end end
def prune def prune
@prune ||= Kamal::Commands::Prune.new(config) @commands[:prune] ||= Kamal::Commands::Prune.new(config)
end end
def registry def registry
@registry ||= Kamal::Commands::Registry.new(config) @commands[:registry] ||= Kamal::Commands::Registry.new(config)
end end
def server def server
@server ||= Kamal::Commands::Server.new(config) @commands[:server] ||= Kamal::Commands::Server.new(config)
end end
def alias(name) def alias(name)
config.aliases[name] config.aliases[name]
end end
def with_verbosity(level) def with_verbosity(level)
old_level = self.verbosity old_level = self.verbosity
@@ -142,14 +141,6 @@ class Kamal::Commander
SSHKit.config.output_verbosity = old_level SSHKit.config.output_verbosity = old_level
end end
def boot_strategy
if config.boot.limit.present?
{ in: :groups, limit: config.boot.limit, wait: config.boot.wait }
else
{}
end
end
def holding_lock? def holding_lock?
self.holding_lock self.holding_lock
end end

View File

@@ -4,11 +4,10 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base
attr_reader :accessory_config attr_reader :accessory_config
delegate :service_name, :image, :hosts, :port, :files, :directories, :cmd, delegate :service_name, :image, :hosts, :port, :files, :directories, :cmd,
:network_args, :publish_args, :env_args, :volume_args, :label_args, :option_args, :network_args, :publish_args, :env_args, :volume_args, :label_args, :option_args,
:secrets_io, :secrets_path, :env_directory, :proxy, :running_proxy?, :secrets_io, :secrets_path, :env_directory, :proxy, :running_proxy?, :registry,
to: :accessory_config to: :accessory_config
delegate :proxy_container_name, to: :config delegate :proxy_container_name, to: :config
def initialize(config, name:) def initialize(config, name:)
super(config) super(config)
@accessory_config = config.accessory(name) @accessory_config = config.accessory(name)
@@ -42,7 +41,6 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base
docker :ps, *service_filter docker :ps, *service_filter
end end
def logs(timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil) def logs(timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil)
pipe \ pipe \
docker(:logs, service_name, (" --since #{since}" if since), (" --tail #{lines}" if lines), ("--timestamps" if timestamps), "2>&1"), docker(:logs, service_name, (" --since #{since}" if since), (" --tail #{lines}" if lines), ("--timestamps" if timestamps), "2>&1"),
@@ -56,7 +54,6 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base
(%(grep "#{grep}"#{" #{grep_options}" if grep_options}) if grep) (%(grep "#{grep}"#{" #{grep_options}" if grep_options}) if grep)
end end
def execute_in_existing_container(*command, interactive: false) def execute_in_existing_container(*command, interactive: false)
docker :exec, docker :exec,
("-it" if interactive), ("-it" if interactive),
@@ -87,7 +84,6 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base
super command, host: hosts.first super command, host: hosts.first
end end
def ensure_local_file_present(local_file) def ensure_local_file_present(local_file)
if !local_file.is_a?(StringIO) && !Pathname.new(local_file).exist? if !local_file.is_a?(StringIO) && !Pathname.new(local_file).exist?
raise "Missing file: #{local_file}" raise "Missing file: #{local_file}"

View File

@@ -4,10 +4,10 @@ module Kamal::Commands::App::Assets
combine \ combine \
make_directory(role.asset_extracted_directory), make_directory(role.asset_extracted_directory),
[ *docker(:stop, "-t 1", asset_container, "2> /dev/null"), "|| true" ], [ *docker(:container, :rm, asset_container, "2> /dev/null"), "|| true" ],
docker(:run, "--name", asset_container, "--detach", "--rm", "--entrypoint", "sleep", config.absolute_image, "1000000"), docker(:container, :create, "--name", asset_container, config.absolute_image),
docker(:cp, "-L", "#{asset_container}:#{role.asset_path}/.", role.asset_extracted_directory), docker(:container, :cp, "-L", "#{asset_container}:#{role.asset_path}/.", role.asset_extracted_directory),
docker(:stop, "-t 1", asset_container), docker(:container, :rm, asset_container),
by: "&&" by: "&&"
end end

View File

@@ -34,6 +34,12 @@ module Kamal::Commands
[ :rm, path ] [ :rm, path ]
end end
def ensure_docker_installed
combine \
ensure_local_docker_installed,
ensure_local_buildx_installed
end
private private
def combine(*commands, by: "&&") def combine(*commands, by: "&&")
commands commands
@@ -104,5 +110,13 @@ module Kamal::Commands
" -i #{key}" " -i #{key}"
end end
end end
def ensure_local_docker_installed
docker "--version"
end
def ensure_local_buildx_installed
docker :buildx, "version"
end
end end
end end

View File

@@ -1,8 +1,8 @@
require "active_support/core_ext/string/filters" require "active_support/core_ext/string/filters"
class Kamal::Commands::Builder < Kamal::Commands::Base class Kamal::Commands::Builder < Kamal::Commands::Base
delegate :create, :remove, :push, :clean, :pull, :info, :inspect_builder, :validate_image, :first_mirror, to: :target delegate :create, :remove, :dev, :push, :clean, :pull, :info, :inspect_builder, :validate_image, :first_mirror, to: :target
delegate :local?, :remote?, to: "config.builder" delegate :local?, :remote?, :cloud?, to: "config.builder"
include Clone include Clone
@@ -17,6 +17,8 @@ class Kamal::Commands::Builder < Kamal::Commands::Base
else else
remote remote
end end
elsif cloud?
cloud
else else
local local
end end
@@ -34,23 +36,7 @@ class Kamal::Commands::Builder < Kamal::Commands::Base
@hybrid ||= Kamal::Commands::Builder::Hybrid.new(config) @hybrid ||= Kamal::Commands::Builder::Hybrid.new(config)
end end
def cloud
def ensure_local_dependencies_installed @cloud ||= Kamal::Commands::Builder::Cloud.new(config)
if name.native?
ensure_local_docker_installed
else
combine \
ensure_local_docker_installed,
ensure_local_buildx_installed
end
end
private
def ensure_local_docker_installed
docker "--version"
end
def ensure_local_buildx_installed
docker :buildx, "version"
end end
end end

View File

@@ -13,11 +13,12 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
docker :image, :rm, "--force", config.absolute_image docker :image, :rm, "--force", config.absolute_image
end end
def push def push(export_action = "registry", tag_as_dirty: false)
docker :buildx, :build, docker :buildx, :build,
"--push", "--output=type=#{export_action}",
*platform_options(arches), *platform_options(arches),
*([ "--builder", builder_name ] unless docker_driver?), *([ "--builder", builder_name ] unless docker_driver?),
*build_tag_options(tag_as_dirty: tag_as_dirty),
*build_options, *build_options,
build_context build_context
end end
@@ -37,7 +38,7 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
end end
def build_options def build_options
[ *build_tags, *build_cache, *build_labels, *build_args, *build_secrets, *build_dockerfile, *build_target, *build_ssh, *builder_provenance, *builder_sbom ] [ *build_cache, *build_labels, *build_args, *build_secrets, *build_dockerfile, *build_target, *build_ssh, *builder_provenance, *builder_sbom ]
end end
def build_context def build_context
@@ -58,8 +59,14 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
end end
private private
def build_tags def build_tag_names(tag_as_dirty: false)
[ "-t", config.absolute_image, "-t", config.latest_image ] tag_names = [ config.absolute_image, config.latest_image ]
tag_names.map! { |t| "#{t}-dirty" } if tag_as_dirty
tag_names
end
def build_tag_options(tag_as_dirty: false)
build_tag_names(tag_as_dirty: tag_as_dirty).flat_map { |name| [ "-t", name ] }
end end
def build_cache def build_cache

View File

@@ -0,0 +1,22 @@
class Kamal::Commands::Builder::Cloud < Kamal::Commands::Builder::Base
# Expects `driver` to be of format "cloud docker-org-name/builder-name"
def create
docker :buildx, :create, "--driver", driver
end
def remove
docker :buildx, :rm, builder_name
end
private
def builder_name
driver.gsub(/[ \/]/, "-")
end
def inspect_buildx
pipe \
docker(:buildx, :inspect, builder_name),
grep("-q", "Endpoint:.*cloud://.*")
end
end

View File

@@ -1,14 +1,16 @@
class Kamal::Commands::Registry < Kamal::Commands::Base class Kamal::Commands::Registry < Kamal::Commands::Base
delegate :registry, to: :config def login(registry_config: nil)
registry_config ||= config.registry
def login
docker :login, docker :login,
registry.server, registry_config.server,
"-u", sensitive(Kamal::Utils.escape_shell_value(registry.username)), "-u", sensitive(Kamal::Utils.escape_shell_value(registry_config.username)),
"-p", sensitive(Kamal::Utils.escape_shell_value(registry.password)) "-p", sensitive(Kamal::Utils.escape_shell_value(registry_config.password))
end end
def logout def logout(registry_config: nil)
docker :logout, registry.server registry_config ||= config.registry
docker :logout, registry_config.server
end end
end end

View File

@@ -59,7 +59,7 @@ class Kamal::Configuration
# Eager load config to validate it, these are first as they have dependencies later on # Eager load config to validate it, these are first as they have dependencies later on
@servers = Servers.new(config: self) @servers = Servers.new(config: self)
@registry = Registry.new(config: self) @registry = Registry.new(config: @raw_config, secrets: secrets)
@accessories = @raw_config.accessories&.keys&.collect { |name| Accessory.new(name, config: self) } || [] @accessories = @raw_config.accessories&.keys&.collect { |name| Accessory.new(name, config: self) } || []
@aliases = @raw_config.aliases&.keys&.to_h { |name| [ name, Alias.new(name, config: self) ] } || {} @aliases = @raw_config.aliases&.keys&.to_h { |name| [ name, Alias.new(name, config: self) ] } || {}
@@ -82,7 +82,6 @@ class Kamal::Configuration
ensure_unique_hosts_for_ssl_roles ensure_unique_hosts_for_ssl_roles
end end
def version=(version) def version=(version)
@declared_version = version @declared_version = version
end end
@@ -106,7 +105,6 @@ class Kamal::Configuration
raw_config.minimum_version raw_config.minimum_version
end end
def roles def roles
servers.roles servers.roles
end end
@@ -119,7 +117,6 @@ class Kamal::Configuration
accessories.detect { |a| a.name == name.to_s } accessories.detect { |a| a.name == name.to_s }
end end
def all_hosts def all_hosts
(roles + accessories).flat_map(&:hosts).uniq (roles + accessories).flat_map(&:hosts).uniq
end end
@@ -180,7 +177,6 @@ class Kamal::Configuration
raw_config.retain_containers || 5 raw_config.retain_containers || 5
end end
def volume_args def volume_args
if raw_config.volumes.present? if raw_config.volumes.present?
argumentize "--volume", raw_config.volumes argumentize "--volume", raw_config.volumes
@@ -193,7 +189,6 @@ class Kamal::Configuration
logging.args logging.args
end end
def readiness_delay def readiness_delay
raw_config.readiness_delay || 7 raw_config.readiness_delay || 7
end end
@@ -206,7 +201,6 @@ class Kamal::Configuration
raw_config.drain_timeout || 30 raw_config.drain_timeout || 30
end end
def run_directory def run_directory
".kamal" ".kamal"
end end
@@ -227,7 +221,6 @@ class Kamal::Configuration
File.join app_directory, "assets" File.join app_directory, "assets"
end end
def hooks_path def hooks_path
raw_config.hooks_path || ".kamal/hooks" raw_config.hooks_path || ".kamal/hooks"
end end
@@ -236,7 +229,6 @@ class Kamal::Configuration
raw_config.asset_path raw_config.asset_path
end end
def env_tags def env_tags
@env_tags ||= if (tags = raw_config.env["tags"]) @env_tags ||= if (tags = raw_config.env["tags"])
tags.collect { |name, config| Env::Tag.new(name, config: config, secrets: secrets) } tags.collect { |name, config| Env::Tag.new(name, config: config, secrets: secrets) }
@@ -249,8 +241,16 @@ class Kamal::Configuration
env_tags.detect { |t| t.name == name.to_s } env_tags.detect { |t| t.name == name.to_s }
end end
def proxy_publish_args(http_port, https_port) def proxy_publish_args(http_port, https_port, bind_ips = nil)
argumentize "--publish", [ "#{http_port}:#{PROXY_HTTP_PORT}", "#{https_port}:#{PROXY_HTTPS_PORT}" ] ensure_valid_bind_ips(bind_ips)
(bind_ips || [ nil ]).map do |bind_ip|
bind_ip = format_bind_ip(bind_ip)
publish_http = [ bind_ip, http_port, PROXY_HTTP_PORT ].compact.join(":")
publish_https = [ bind_ip, https_port, PROXY_HTTPS_PORT ].compact.join(":")
argumentize "--publish", [ publish_http, publish_https ]
end.join(" ")
end end
def proxy_logging_args(max_size) def proxy_logging_args(max_size)
@@ -277,7 +277,6 @@ class Kamal::Configuration
File.join proxy_directory, "options" File.join proxy_directory, "options"
end end
def to_h def to_h
{ {
roles: role_names, roles: role_names,
@@ -344,6 +343,15 @@ class Kamal::Configuration
true true
end end
def ensure_valid_bind_ips(bind_ips)
bind_ips.present? && bind_ips.each do |ip|
next if ip =~ Resolv::IPv4::Regex || ip =~ Resolv::IPv6::Regex
raise ArgumentError, "Invalid publish IP address: #{ip}"
end
true
end
def ensure_retain_containers_valid def ensure_retain_containers_valid
raise Kamal::ConfigurationError, "Must retain at least 1 container" if retain_containers < 1 raise Kamal::ConfigurationError, "Must retain at least 1 container" if retain_containers < 1
@@ -375,6 +383,15 @@ class Kamal::Configuration
true true
end end
def format_bind_ip(ip)
# Ensure IPv6 address inside square brackets - e.g. [::1]
if ip =~ Resolv::IPv6::Regex && ip !~ /\[.*\]/
"[#{ip}]"
else
ip
end
end
def role_names def role_names
raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort
end end

View File

@@ -5,7 +5,7 @@ class Kamal::Configuration::Accessory
delegate :argumentize, :optionize, to: Kamal::Utils delegate :argumentize, :optionize, to: Kamal::Utils
attr_reader :name, :accessory_config, :env, :proxy attr_reader :name, :env, :proxy, :registry
def initialize(name, config:) def initialize(name, config:)
@name, @config, @accessory_config = name.inquiry, config, config.raw_config["accessories"][name] @name, @config, @accessory_config = name.inquiry, config, config.raw_config["accessories"][name]
@@ -16,12 +16,11 @@ class Kamal::Configuration::Accessory
context: "accessories/#{name}", context: "accessories/#{name}",
with: Kamal::Configuration::Validator::Accessory with: Kamal::Configuration::Validator::Accessory
@env = Kamal::Configuration::Env.new \ ensure_valid_roles
config: accessory_config.fetch("env", {}),
secrets: config.secrets,
context: "accessories/#{name}/env"
initialize_proxy if running_proxy? @env = initialize_env
@proxy = initialize_proxy if running_proxy?
@registry = initialize_registry if accessory_config["registry"].present?
end end
def service_name def service_name
@@ -29,7 +28,7 @@ class Kamal::Configuration::Accessory
end end
def image def image
accessory_config["image"] [ registry&.server, accessory_config["image"] ].compact.join("/")
end end
def hosts def hosts
@@ -109,18 +108,32 @@ class Kamal::Configuration::Accessory
end end
def running_proxy? def running_proxy?
@accessory_config["proxy"].present? accessory_config["proxy"].present?
end
private
attr_reader :config, :accessory_config
def initialize_env
Kamal::Configuration::Env.new \
config: accessory_config.fetch("env", {}),
secrets: config.secrets,
context: "accessories/#{name}/env"
end end
def initialize_proxy def initialize_proxy
@proxy = Kamal::Configuration::Proxy.new \ Kamal::Configuration::Proxy.new \
config: config, config: config,
proxy_config: accessory_config["proxy"], proxy_config: accessory_config["proxy"],
context: "accessories/#{name}/proxy" context: "accessories/#{name}/proxy"
end end
private def initialize_registry
attr_accessor :config Kamal::Configuration::Registry.new \
config: accessory_config,
secrets: config.secrets,
context: "accessories/#{name}/registry"
end
def default_labels def default_labels
{ "service" => service_name } { "service" => service_name }
@@ -189,13 +202,17 @@ class Kamal::Configuration::Accessory
def hosts_from_roles def hosts_from_roles
if accessory_config.key?("roles") if accessory_config.key?("roles")
accessory_config["roles"].flat_map do |role| accessory_config["roles"].flat_map { |role| config.role(role)&.hosts }
config.role(role)&.hosts || raise(Kamal::ConfigurationError, "Unknown role in accessories config: '#{role}'")
end
end end
end end
def network def network
accessory_config["network"] || DEFAULT_NETWORK accessory_config["network"] || DEFAULT_NETWORK
end end
def ensure_valid_roles
if accessory_config["roles"] && (missing_roles = accessory_config["roles"] - config.roles.map(&:name)).any?
raise Kamal::ConfigurationError, "accessories/#{name}: unknown roles #{missing_roles.join(", ")}"
end
end
end end

View File

@@ -53,6 +53,10 @@ class Kamal::Configuration::Builder
!local_disabled? && (arches.empty? || local_arches.any?) !local_disabled? && (arches.empty? || local_arches.any?)
end end
def cloud?
driver.start_with? "cloud"
end
def cached? def cached?
!!builder_config["cache"] !!builder_config["cache"]
end end

View File

@@ -23,9 +23,27 @@ accessories:
# Image # Image
# #
# The Docker image to use, prefix it with a registry if not using Docker Hub: # The Docker image to use.
# Prefix it with its server when using root level registry different from Docker Hub.
# Define registry directly or via anchors when it differs from root level registry.
image: mysql:8.0 image: mysql:8.0
# Registry
#
# By default accessories use Docker Hub registry.
# You can specify different registry per accessory with this option.
# Don't prefix image with this registry server.
# Use anchors if you need to set the same specific registry for several accessories.
#
# ```yml
# registry:
# <<: *specific-registry
# ```
#
# See kamal docs registry for more information:
registry:
...
# Accessory hosts # Accessory hosts
# #
# Specify one of `host`, `hosts`, or `roles`: # Specify one of `host`, `hosts`, or `roles`:
@@ -100,5 +118,6 @@ accessories:
# Proxy # Proxy
# #
# You can run your accessory behind the Kamal proxy. See kamal docs proxy for more information
proxy: proxy:
... ...

View File

@@ -102,6 +102,9 @@ builder:
# #
# The build driver to use, defaults to `docker-container`: # The build driver to use, defaults to `docker-container`:
driver: docker driver: docker
#
# If you want to use Docker Build Cloud (https://www.docker.com/products/build-cloud/), you can set the driver to:
driver: cloud org-name/builder-name
# Provenance # Provenance
# #

View File

@@ -1,12 +1,10 @@
class Kamal::Configuration::Registry class Kamal::Configuration::Registry
include Kamal::Configuration::Validation include Kamal::Configuration::Validation
attr_reader :registry_config, :secrets def initialize(config:, secrets:, context: "registry")
@registry_config = config["registry"] || {}
def initialize(config:) @secrets = secrets
@registry_config = config.raw_config.registry || {} validate! registry_config, context: context, with: Kamal::Configuration::Validator::Registry
@secrets = config.secrets
validate! registry_config, with: Kamal::Configuration::Validator::Registry
end end
def server def server
@@ -22,6 +20,8 @@ class Kamal::Configuration::Registry
end end
private private
attr_reader :registry_config, :secrets
def lookup(key) def lookup(key)
if registry_config[key].is_a?(Array) if registry_config[key].is_a?(Array)
secrets[registry_config[key].first] secrets[registry_config[key].first]

View File

@@ -10,7 +10,7 @@ class Kamal::Configuration::Role
def initialize(name, config:) def initialize(name, config:)
@name, @config = name.inquiry, config @name, @config = name.inquiry, config
validate! \ validate! \
specializations, role_config,
example: validation_yml["servers"]["workers"], example: validation_yml["servers"]["workers"],
context: "servers/#{name}", context: "servers/#{name}",
with: Kamal::Configuration::Validator::Role with: Kamal::Configuration::Validator::Role
@@ -204,11 +204,11 @@ class Kamal::Configuration::Role
end end
def specializations def specializations
if config.raw_config.servers.is_a?(Array) || config.raw_config.servers[name].is_a?(Array) @specializations ||= role_config.is_a?(Array) ? {} : role_config
{}
else
config.raw_config.servers[name]
end end
def role_config
@role_config ||= config.raw_config.servers.is_a?(Array) ? {} : config.raw_config.servers[name]
end end
def custom_labels def custom_labels

View File

@@ -3,7 +3,7 @@ class Kamal::Configuration::Validator::Role < Kamal::Configuration::Validator
validate_type! config, Array, Hash validate_type! config, Array, Hash
if config.is_a?(Array) if config.is_a?(Array)
validate_servers! "servers", config validate_servers!(config)
else else
super super
end end

30
lib/kamal/docker.rb Normal file
View File

@@ -0,0 +1,30 @@
require "tempfile"
require "open3"
module Kamal::Docker
extend self
BUILD_CHECK_TAG = "kamal-local-build-check"
def included_files
Tempfile.create do |dockerfile|
dockerfile.write(<<~DOCKERFILE)
FROM busybox
COPY . app
WORKDIR app
CMD find . -type f | sed "s|^\./||"
DOCKERFILE
dockerfile.close
cmd = "docker buildx build -t=#{BUILD_CHECK_TAG} -f=#{dockerfile.path} ."
system(cmd) || raise("failed to build check image")
end
cmd = "docker run --rm #{BUILD_CHECK_TAG}"
out, err, status = Open3.capture3(cmd)
unless status
raise "failed to run check image:\n#{err}"
end
out.lines.map(&:strip)
end
end

View File

@@ -24,4 +24,14 @@ module Kamal::Git
def root def root
`git rev-parse --show-toplevel`.strip `git rev-parse --show-toplevel`.strip
end end
# returns an array of relative path names of files with uncommitted changes
def uncommitted_files
`git ls-files --modified`.lines.map(&:strip)
end
# returns an array of relative path names of untracked files, including gitignored files
def untracked_files
`git ls-files --others`.lines.map(&:strip)
end
end end

View File

@@ -3,6 +3,8 @@ module Kamal::Secrets::Adapters
def self.lookup(name) def self.lookup(name)
name = "one_password" if name.downcase == "1password" name = "one_password" if name.downcase == "1password"
name = "last_pass" if name.downcase == "lastpass" name = "last_pass" if name.downcase == "lastpass"
name = "gcp_secret_manager" if name.downcase == "gcp"
name = "bitwarden_secrets_manager" if name.downcase == "bitwarden-sm"
adapter_class(name) adapter_class(name)
end end

View File

@@ -1,12 +1,16 @@
class Kamal::Secrets::Adapters::AwsSecretsManager < Kamal::Secrets::Adapters::Base class Kamal::Secrets::Adapters::AwsSecretsManager < Kamal::Secrets::Adapters::Base
def requires_account?
false
end
private private
def login(_account) def login(_account)
nil nil
end end
def fetch_secrets(secrets, account:, session:) def fetch_secrets(secrets, from:, account: nil, session:)
{}.tap do |results| {}.tap do |results|
get_from_secrets_manager(secrets, account: account).each do |secret| get_from_secrets_manager(prefixed_secrets(secrets, from: from), account: account).each do |secret|
secret_name = secret["Name"] secret_name = secret["Name"]
secret_string = JSON.parse(secret["SecretString"]) secret_string = JSON.parse(secret["SecretString"])
@@ -19,8 +23,12 @@ class Kamal::Secrets::Adapters::AwsSecretsManager < Kamal::Secrets::Adapters::Ba
end end
end end
def get_from_secrets_manager(secrets, account:) def get_from_secrets_manager(secrets, account: nil)
`aws secretsmanager batch-get-secret-value --secret-id-list #{secrets.map(&:shellescape).join(" ")} --profile #{account.shellescape}`.tap do |secrets| args = [ "aws", "secretsmanager", "batch-get-secret-value", "--secret-id-list" ] + secrets.map(&:shellescape)
args += [ "--profile", account.shellescape ] if account
cmd = args.join(" ")
`#{cmd}`.tap do |secrets|
raise RuntimeError, "Could not read #{secrets} from AWS Secrets Manager" unless $?.success? raise RuntimeError, "Could not read #{secrets} from AWS Secrets Manager" unless $?.success?
secrets = JSON.parse(secrets) secrets = JSON.parse(secrets)

View File

@@ -7,8 +7,7 @@ class Kamal::Secrets::Adapters::Base
check_dependencies! check_dependencies!
session = login(account) session = login(account)
full_secrets = secrets.map { |secret| [ from, secret ].compact.join("/") } fetch_secrets(secrets, from: from, account: account, session: session)
fetch_secrets(full_secrets, account: account, session: session)
end end
def requires_account? def requires_account?
@@ -27,4 +26,8 @@ class Kamal::Secrets::Adapters::Base
def check_dependencies! def check_dependencies!
raise NotImplementedError raise NotImplementedError
end end
def prefixed_secrets(secrets, from:)
secrets.map { |secret| [ from, secret ].compact.join("/") }
end
end end

View File

@@ -21,9 +21,9 @@ class Kamal::Secrets::Adapters::Bitwarden < Kamal::Secrets::Adapters::Base
session session
end end
def fetch_secrets(secrets, account:, session:) def fetch_secrets(secrets, from:, account:, session:)
{}.tap do |results| {}.tap do |results|
items_fields(secrets).each do |item, fields| items_fields(prefixed_secrets(secrets, from: from)).each do |item, fields|
item_json = run_command("get item #{item.shellescape}", session: session, raw: true) item_json = run_command("get item #{item.shellescape}", session: session, raw: true)
raise RuntimeError, "Could not read #{item} from Bitwarden" unless $?.success? raise RuntimeError, "Could not read #{item} from Bitwarden" unless $?.success?
item_json = JSON.parse(item_json) item_json = JSON.parse(item_json)

View File

@@ -0,0 +1,72 @@
class Kamal::Secrets::Adapters::BitwardenSecretsManager < Kamal::Secrets::Adapters::Base
def requires_account?
false
end
private
LIST_ALL_SELECTOR = "all"
LIST_ALL_FROM_PROJECT_SUFFIX = "/all"
LIST_COMMAND = "secret list -o env"
GET_COMMAND = "secret get -o env"
def fetch_secrets(secrets, from:, account:, session:)
raise RuntimeError, "You must specify what to retrieve from Bitwarden Secrets Manager" if secrets.length == 0
secrets = prefixed_secrets(secrets, from: from)
command, project = extract_command_and_project(secrets)
{}.tap do |results|
if command.nil?
secrets.each do |secret_uuid|
secret = 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
end
else
secrets = 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
end
end
end
end
def extract_command_and_project(secrets)
if secrets.length == 1
if secrets[0] == LIST_ALL_SELECTOR
[ LIST_COMMAND, nil ]
elsif secrets[0].end_with?(LIST_ALL_FROM_PROJECT_SUFFIX)
project = secrets[0].split(LIST_ALL_FROM_PROJECT_SUFFIX).first
[ "#{LIST_COMMAND} #{project.shellescape}", project ]
end
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'")
raise RuntimeError, "Could not authenticate to Bitwarden Secrets Manager. Did you set a valid access token?" unless $?.success?
end
def check_dependencies!
raise RuntimeError, "Bitwarden Secrets Manager CLI is not installed" unless cli_installed?
end
def cli_installed?
`bws --version 2> /dev/null`
$?.success?
end
end

View File

@@ -16,8 +16,21 @@ class Kamal::Secrets::Adapters::Doppler < Kamal::Secrets::Adapters::Base
$?.success? $?.success?
end end
def fetch_secrets(secrets, **) def fetch_secrets(secrets, from:, **)
project_and_config_flags = "" secrets = prefixed_secrets(secrets, from: from)
flags = secrets_get_flags(secrets)
secret_names = secrets.collect { |s| s.split("/").last }
items = `doppler secrets get #{secret_names.map(&:shellescape).join(" ")} --json #{flags}`
raise RuntimeError, "Could not read #{secrets} from Doppler" unless $?.success?
items = JSON.parse(items)
items.transform_values { |value| value["computed"] }
end
def secrets_get_flags(secrets)
unless service_token_set? unless service_token_set?
project, config, _ = secrets.first.split("/") project, config, _ = secrets.first.split("/")
@@ -27,15 +40,6 @@ class Kamal::Secrets::Adapters::Doppler < Kamal::Secrets::Adapters::Base
project_and_config_flags = "-p #{project.shellescape} -c #{config.shellescape}" project_and_config_flags = "-p #{project.shellescape} -c #{config.shellescape}"
end end
secret_names = secrets.collect { |s| s.split("/").last }
items = `doppler secrets get #{secret_names.map(&:shellescape).join(" ")} --json #{project_and_config_flags}`
raise RuntimeError, "Could not read #{secrets} from Doppler" unless $?.success?
items = JSON.parse(items)
items.transform_values { |value| value["computed"] }
end end
def service_token_set? def service_token_set?

View File

@@ -0,0 +1,71 @@
##
# Enpass is different from most password managers, in a way that it's offline and doesn't need an account.
#
# Usage
#
# Fetch all password from FooBar item
# `kamal secrets fetch --adapter enpass --from /Users/YOUR_USERNAME/Library/Containers/in.sinew.Enpass-Desktop/Data/Documents/Vaults/primary FooBar`
#
# Fetch only DB_PASSWORD from FooBar item
# `kamal secrets fetch --adapter enpass --from /Users/YOUR_USERNAME/Library/Containers/in.sinew.Enpass-Desktop/Data/Documents/Vaults/primary FooBar/DB_PASSWORD`
class Kamal::Secrets::Adapters::Enpass < Kamal::Secrets::Adapters::Base
def requires_account?
false
end
private
def fetch_secrets(secrets, from:, account:, session:)
secrets_titles = fetch_secret_titles(secrets)
result = `enpass-cli -json -vault #{from.shellescape} show #{secrets_titles.map(&:shellescape).join(" ")}`.strip
parse_result_and_take_secrets(result, secrets)
end
def check_dependencies!
raise RuntimeError, "Enpass CLI is not installed" unless cli_installed?
end
def cli_installed?
`enpass-cli version 2> /dev/null`
$?.success?
end
def login(account)
nil
end
def fetch_secret_titles(secrets)
secrets.reduce(Set.new) do |secret_titles, secret|
# Sometimes secrets contain a '/', when the intent is to fetch a single password for an item. Example: FooBar/DB_PASSWORD
# Another case is, when the intent is to fetch all passwords for an item. Example: FooBar (and FooBar may have multiple different passwords)
key, separator, value = secret.rpartition("/")
if key.empty?
secret_titles << value
else
secret_titles << key
end
end.to_a
end
def parse_result_and_take_secrets(unparsed_result, secrets)
result = JSON.parse(unparsed_result)
result.reduce({}) do |secrets_with_passwords, item|
title = item["title"]
label = item["label"]
password = item["password"]
if title && password.present?
key = [ title, label ].compact.reject(&:empty?).join("/")
if secrets.include?(title) || secrets.include?(key)
raise RuntimeError, "#{key} is present more than once" if secrets_with_passwords[key]
secrets_with_passwords[key] = password
end
end
secrets_with_passwords
end
end
end

View File

@@ -0,0 +1,112 @@
class Kamal::Secrets::Adapters::GcpSecretManager < Kamal::Secrets::Adapters::Base
private
def login(account)
# Since only the account option is passed from the cli, we'll use it for both account and service account
# impersonation.
#
# Syntax:
# ACCOUNT: USER | USER "|" DELEGATION_CHAIN
# USER: DEFAULT_USER | EMAIL
# DELEGATION_CHAIN: EMAIL | EMAIL "," DELEGATION_CHAIN
# EMAIL: <The email address of the user or service account, like "my-user@example.com" >
# DEFAULT_USER: "default"
#
# Some valid examples:
# - "my-user@example.com" sets the user
# - "my-user@example.com|my-service-user@example.com" will use my-user and enable service account impersonation as my-service-user
# - "default" will use the default user and no impersonation
# - "default|my-service-user@example.com" will use the default user, and enable service account impersonation as my-service-user
# - "default|my-service-user@example.com,another-service-user@example.com" same as above, but with an impersonation delegation chain
unless logged_in?
`gcloud auth login`
raise RuntimeError, "could not login to gcloud" unless logged_in?
end
nil
end
def fetch_secrets(secrets, from:, account:, session:)
user, service_account = parse_account(account)
{}.tap do |results|
secrets_with_metadata(prefixed_secrets(secrets, from: from)).each do |secret, (project, secret_name, secret_version)|
item_name = "#{project}/#{secret_name}"
results[item_name] = fetch_secret(project, secret_name, secret_version, user, service_account)
raise RuntimeError, "Could not read #{item_name} from Google Secret Manager" unless $?.success?
end
end
end
def fetch_secret(project, secret_name, secret_version, user, service_account)
secret = run_command(
"secrets versions access #{secret_version.shellescape} --secret=#{secret_name.shellescape}",
project: project,
user: user,
service_account: service_account
)
Base64.decode64(secret.dig("payload", "data"))
end
# The secret needs to at least contain a secret name, but project name, and secret version can also be specified.
#
# The string "default" can be used to refer to the default project configured for gcloud.
#
# The version can be either the string "latest", or a version number.
#
# The following formats are valid:
#
# - The following are all equivalent, and sets project: default, secret name: my-secret, version: latest
# - "my-secret"
# - "default/my-secret"
# - "default/my-secret/latest"
# - "my-secret/latest" in combination with --from=default
# - "my-secret/123" (only in combination with --from=some-project) -> project: some-project, secret name: my-secret, version: 123
# - "some-project/my-secret/123" -> project: some-project, secret name: my-secret, version: 123
def secrets_with_metadata(secrets)
{}.tap do |items|
secrets.each do |secret|
parts = secret.split("/")
parts.unshift("default") if parts.length == 1
project = parts.shift
secret_name = parts.shift
secret_version = parts.shift || "latest"
items[secret] = [ project, secret_name, secret_version ]
end
end
end
def run_command(command, project: "default", user: "default", service_account: nil)
full_command = [ "gcloud", command ]
full_command << "--project=#{project.shellescape}" unless project == "default"
full_command << "--account=#{user.shellescape}" unless user == "default"
full_command << "--impersonate-service-account=#{service_account.shellescape}" if service_account
full_command << "--format=json"
full_command = full_command.join(" ")
result = `#{full_command}`.strip
JSON.parse(result)
end
def check_dependencies!
raise RuntimeError, "gcloud CLI is not installed" unless cli_installed?
end
def cli_installed?
`gcloud --version 2> /dev/null`
$?.success?
end
def logged_in?
JSON.parse(`gcloud auth list --format=json`).any?
end
def parse_account(account)
account.split("|", 2)
end
def is_user?(candidate)
candidate.include?("@")
end
end

View File

@@ -11,7 +11,8 @@ class Kamal::Secrets::Adapters::LastPass < Kamal::Secrets::Adapters::Base
`lpass status --color never`.strip == "Logged in as #{account}." `lpass status --color never`.strip == "Logged in as #{account}."
end end
def fetch_secrets(secrets, account:, session:) def fetch_secrets(secrets, from:, account:, session:)
secrets = prefixed_secrets(secrets, from: from)
items = `lpass show #{secrets.map(&:shellescape).join(" ")} --json` items = `lpass show #{secrets.map(&:shellescape).join(" ")} --json`
raise RuntimeError, "Could not read #{secrets} from LastPass" unless $?.success? raise RuntimeError, "Could not read #{secrets} from LastPass" unless $?.success?
@@ -23,7 +24,7 @@ class Kamal::Secrets::Adapters::LastPass < Kamal::Secrets::Adapters::Base
end end
if (missing_items = secrets - results.keys).any? if (missing_items = secrets - results.keys).any?
raise RuntimeError, "Could not find #{missing_items.join(", ")} in LassPass" raise RuntimeError, "Could not find #{missing_items.join(", ")} in LastPass"
end end
end end
end end

View File

@@ -15,9 +15,9 @@ class Kamal::Secrets::Adapters::OnePassword < Kamal::Secrets::Adapters::Base
$?.success? $?.success?
end end
def fetch_secrets(secrets, account:, session:) def fetch_secrets(secrets, from:, account:, session:)
{}.tap do |results| {}.tap do |results|
vaults_items_fields(secrets).map do |vault, items| vaults_items_fields(prefixed_secrets(secrets, from: from)).map do |vault, items|
items.each do |item, fields| 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, account: account, session: session))
fields_json = [ fields_json ] if fields.one? fields_json = [ fields_json ] if fields.one?

View File

@@ -4,8 +4,8 @@ class Kamal::Secrets::Adapters::Test < Kamal::Secrets::Adapters::Base
true true
end end
def fetch_secrets(secrets, account:, session:) def fetch_secrets(secrets, from:, account:, session:)
secrets.to_h { |secret| [ secret, secret.reverse ] } prefixed_secrets(secrets, from: from).to_h { |secret| [ secret, secret.reverse ] }
end end
def check_dependencies! def check_dependencies!

View File

@@ -1,5 +0,0 @@
class Kamal::Secrets::Adapters::TestOptionalAccount < Kamal::Secrets::Adapters::Test
def requires_account?
false
end
end

View File

@@ -1,3 +1,3 @@
module Kamal module Kamal
VERSION = "2.4.0" VERSION = "2.5.0"
end end

View File

@@ -14,8 +14,8 @@ class CliAccessoryTest < CliTestCase
Kamal::Cli::Accessory.any_instance.expects(:upload).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:upload).with("mysql")
run_command("boot", "mysql").tap do |output| run_command("boot", "mysql").tap do |output|
assert_match /docker login.*on 1.1.1.3/, 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\" 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 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
end end
@@ -24,17 +24,21 @@ class CliAccessoryTest < CliTestCase
Kamal::Cli::Accessory.any_instance.expects(:upload).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:upload).with("mysql")
Kamal::Cli::Accessory.any_instance.expects(:directories).with("redis") Kamal::Cli::Accessory.any_instance.expects(:directories).with("redis")
Kamal::Cli::Accessory.any_instance.expects(:upload).with("redis") Kamal::Cli::Accessory.any_instance.expects(:upload).with("redis")
Kamal::Cli::Accessory.any_instance.expects(:directories).with("busybox")
Kamal::Cli::Accessory.any_instance.expects(:upload).with("busybox")
run_command("boot", "all").tap do |output| run_command("boot", "all").tap do |output|
assert_match /docker login.*on 1.1.1.3/, output assert_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.3", output
assert_match /docker login.*on 1.1.1.1/, output assert_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.1", output
assert_match /docker login.*on 1.1.1.2/, output assert_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.2", output
assert_match "docker login other.registry -u [REDACTED] -p [REDACTED] on 1.1.1.3", output
assert_match /docker network create kamal.*on 1.1.1.1/, output 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.2/, output
assert_match /docker network create kamal.*on 1.1.1.3/, 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\" 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 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.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-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
end end
end end
@@ -60,13 +64,16 @@ class CliAccessoryTest < CliTestCase
end end
test "reboot all" do test "reboot all" do
Kamal::Commands::Registry.any_instance.expects(:login).times(3) Kamal::Commands::Registry.any_instance.expects(:login).times(4)
Kamal::Cli::Accessory.any_instance.expects(:stop).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:stop).with("mysql")
Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("mysql")
Kamal::Cli::Accessory.any_instance.expects(:boot).with("mysql", prepare: false) Kamal::Cli::Accessory.any_instance.expects(:boot).with("mysql", prepare: false)
Kamal::Cli::Accessory.any_instance.expects(:stop).with("redis") Kamal::Cli::Accessory.any_instance.expects(:stop).with("redis")
Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("redis") Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("redis")
Kamal::Cli::Accessory.any_instance.expects(:boot).with("redis", prepare: false) Kamal::Cli::Accessory.any_instance.expects(:boot).with("redis", prepare: false)
Kamal::Cli::Accessory.any_instance.expects(:stop).with("busybox")
Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("busybox")
Kamal::Cli::Accessory.any_instance.expects(:boot).with("busybox", prepare: false)
run_command("reboot", "all") run_command("reboot", "all")
end end
@@ -94,7 +101,7 @@ class CliAccessoryTest < CliTestCase
end end
test "details with non-existent accessory" do test "details with non-existent accessory" do
assert_equal "No accessory by the name of 'hello' (options: mysql and redis)", stderred { run_command("details", "hello") } assert_equal "No accessory by the name of 'hello' (options: mysql, redis, and busybox)", stderred { run_command("details", "hello") }
end end
test "details with all" do test "details with all" do
@@ -180,6 +187,10 @@ class CliAccessoryTest < CliTestCase
Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("redis") Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("redis")
Kamal::Cli::Accessory.any_instance.expects(:remove_image).with("redis") Kamal::Cli::Accessory.any_instance.expects(:remove_image).with("redis")
Kamal::Cli::Accessory.any_instance.expects(:remove_service_directory).with("redis") Kamal::Cli::Accessory.any_instance.expects(:remove_service_directory).with("redis")
Kamal::Cli::Accessory.any_instance.expects(:stop).with("busybox")
Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("busybox")
Kamal::Cli::Accessory.any_instance.expects(:remove_image).with("busybox")
Kamal::Cli::Accessory.any_instance.expects(:remove_service_directory).with("busybox")
run_command("remove", "all", "-y") run_command("remove", "all", "-y")
end end
@@ -189,7 +200,7 @@ class CliAccessoryTest < CliTestCase
end end
test "remove_image" do test "remove_image" do
assert_match "docker image rm --force mysql", run_command("remove_image", "mysql") assert_match "docker image rm --force private.registry/mysql:5.7", run_command("remove_image", "mysql")
end end
test "remove_service_directory" do test "remove_service_directory" do
@@ -201,8 +212,8 @@ class CliAccessoryTest < CliTestCase
Kamal::Cli::Accessory.any_instance.expects(:upload).with("redis") Kamal::Cli::Accessory.any_instance.expects(:upload).with("redis")
run_command("boot", "redis", "--hosts", "1.1.1.1").tap do |output| run_command("boot", "redis", "--hosts", "1.1.1.1").tap do |output|
assert_match /docker login.*on 1.1.1.1/, output assert_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.1", output
assert_no_match /docker login.*on 1.1.1.2/, 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_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_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
end end
@@ -213,8 +224,8 @@ class CliAccessoryTest < CliTestCase
Kamal::Cli::Accessory.any_instance.expects(:upload).with("redis") Kamal::Cli::Accessory.any_instance.expects(:upload).with("redis")
run_command("boot", "redis", "--hosts", "1.1.1.1,1.1.1.3").tap do |output| run_command("boot", "redis", "--hosts", "1.1.1.1,1.1.1.3").tap do |output|
assert_match /docker login.*on 1.1.1.1/, output assert_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.1", output
assert_no_match /docker login.*on 1.1.1.3/, 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_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_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
end end
@@ -225,7 +236,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 "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 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 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\" 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 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 assert_match "Upgraded all accessories on 1.1.1.3,1.1.1.1,1.1.1.2...", output
end end
end end
@@ -235,14 +246,13 @@ class CliAccessoryTest < CliTestCase
assert_match "Upgrading all accessories on 1.1.1.3...", output 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 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 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\" 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 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 assert_match "Upgraded all accessories on 1.1.1.3", output
end end
end end
private private
def run_command(*command) def run_command(*command)
stdouted { Kamal::Cli::Accessory.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) } stdouted { Kamal::Cli::Accessory.start([ *command, "-c", "test/fixtures/deploy_with_accessories_with_different_registries.yml" ]) }
end end
end end

View File

@@ -37,13 +37,20 @@ class CliAppTest < CliTestCase
end end
test "boot uses group strategy when specified" do test "boot uses group strategy when specified" do
Kamal::Cli::App.any_instance.stubs(:on).with("1.1.1.1").times(2) # ensure locks dir, acquire & release lock Kamal::Cli::App.any_instance.stubs(:on).with("1.1.1.1").twice
Kamal::Cli::App.any_instance.stubs(:on).with([ "1.1.1.1" ]) # tag container Kamal::Cli::App.any_instance.stubs(:on).with([ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ]).times(3)
# Strategy is used when booting the containers # Strategy is used when booting the containers
Kamal::Cli::App.any_instance.expects(:on).with([ "1.1.1.1" ], in: :groups, limit: 3, wait: 2).with_block_given Kamal::Cli::App.any_instance.expects(:on).with([ "1.1.1.1", "1.1.1.2", "1.1.1.3" ]).with_block_given
Kamal::Cli::App.any_instance.expects(:on).with([ "1.1.1.4" ]).with_block_given
Object.any_instance.expects(:sleep).with(2).twice
run_command("boot", config: :with_boot_strategy) Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
run_command("boot", config: :with_boot_strategy, host: nil).tap do |output|
assert_hook_ran "pre-app-boot", output, count: 2
assert_hook_ran "post-app-boot", output, count: 2
end
end end
test "boot errors don't leave lock in place" do test "boot errors don't leave lock in place" do
@@ -73,7 +80,7 @@ class CliAppTest < CliTestCase
run_command("boot", config: :with_assets).tap do |output| run_command("boot", config: :with_assets).tap do |output|
assert_match "docker tag dhh/app:latest dhh/app:latest", output assert_match "docker tag dhh/app:latest dhh/app:latest", output
assert_match "/usr/bin/env mkdir -p .kamal/apps/app/assets/volumes/web-latest ; cp -rnT .kamal/apps/app/assets/extracted/web-latest .kamal/apps/app/assets/volumes/web-latest ; cp -rnT .kamal/apps/app/assets/extracted/web-latest .kamal/apps/app/assets/volumes/web-123 || true ; cp -rnT .kamal/apps/app/assets/extracted/web-123 .kamal/apps/app/assets/volumes/web-latest || true", output assert_match "/usr/bin/env mkdir -p .kamal/apps/app/assets/volumes/web-latest ; cp -rnT .kamal/apps/app/assets/extracted/web-latest .kamal/apps/app/assets/volumes/web-latest ; cp -rnT .kamal/apps/app/assets/extracted/web-latest .kamal/apps/app/assets/volumes/web-123 || true ; cp -rnT .kamal/apps/app/assets/extracted/web-123 .kamal/apps/app/assets/volumes/web-latest || true", output
assert_match "/usr/bin/env mkdir -p .kamal/apps/app/assets/extracted/web-latest && docker stop -t 1 app-web-assets 2> /dev/null || true && docker run --name app-web-assets --detach --rm --entrypoint sleep dhh/app:latest 1000000 && docker cp -L app-web-assets:/public/assets/. .kamal/apps/app/assets/extracted/web-latest && docker stop -t 1 app-web-assets", output assert_match "/usr/bin/env mkdir -p .kamal/apps/app/assets/extracted/web-latest && docker container rm app-web-assets 2> /dev/null || true && docker container create --name app-web-assets dhh/app:latest && docker container cp -L app-web-assets:/public/assets/. .kamal/apps/app/assets/extracted/web-latest && docker container rm app-web-assets", output
assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12} /, output assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12} /, output
assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output assert_match "docker container ls --all --filter name=^app-web-123$ --quiet | xargs docker stop", output
assert_match "/usr/bin/env find .kamal/apps/app/assets/extracted -maxdepth 1 -name 'web-*' ! -name web-latest -exec rm -rf \"{}\" + ; find .kamal/apps/app/assets/volumes -maxdepth 1 -name 'web-*' ! -name web-latest -exec rm -rf \"{}\" +", output assert_match "/usr/bin/env find .kamal/apps/app/assets/extracted -maxdepth 1 -name 'web-*' ! -name web-latest -exec rm -rf \"{}\" + ; find .kamal/apps/app/assets/volumes -maxdepth 1 -name 'web-*' ! -name web-latest -exec rm -rf \"{}\" +", output
@@ -382,10 +389,12 @@ class CliAppTest < CliTestCase
test "version through main" do test "version through main" do
stdouted { Kamal::Cli::Main.start([ "app", "version", "-c", "test/fixtures/deploy_with_accessories.yml", "--hosts", "1.1.1.1" ]) }.tap do |output| with_argv([ "app", "version", "-c", "test/fixtures/deploy_with_accessories.yml", "--hosts", "1.1.1.1" ]) do
stdouted { Kamal::Cli::Main.start }.tap do |output|
assert_match "sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --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 --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done", output assert_match "sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --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 --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done", output
end end
end end
end
test "long hostname" do test "long hostname" do
stub_running stub_running

View File

@@ -11,7 +11,6 @@ class CliBuildTest < CliTestCase
test "push" do test "push" do
with_build_directory do |build_directory| with_build_directory do |build_directory|
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4", command: "build", subcommand: "push" }
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:git, "-C", anything, :"rev-parse", :HEAD) .with(:git, "-C", anything, :"rev-parse", :HEAD)
@@ -22,11 +21,33 @@ class CliBuildTest < CliTestCase
.returns("") .returns("")
run_command("push", "--verbose").tap do |output| run_command("push", "--verbose").tap do |output|
assert_hook_ran "pre-build", output, **hook_variables assert_hook_ran "pre-build", output
assert_match /Cloning repo into build directory/, output assert_match /Cloning repo into build directory/, output
assert_match /git -C #{Dir.tmpdir}\/kamal-clones\/app-#{pwd_sha} clone #{Dir.pwd}/, output assert_match /git -C #{Dir.tmpdir}\/kamal-clones\/app-#{pwd_sha} clone #{Dir.pwd}/, output
assert_match /docker --version && docker buildx version/, output assert_match /docker --version && docker buildx version/, output
assert_match /docker buildx build --push --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile \. as .*@localhost/, output assert_match /docker buildx build --output=type=registry --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile \. as .*@localhost/, 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)
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", "--output=docker", "--verbose").tap do |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
assert_match /docker --version && docker buildx version/, output
assert_match /docker buildx build --output=type=docker --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile \. as .*@localhost/, output
end end
end end
end end
@@ -49,7 +70,7 @@ class CliBuildTest < CliTestCase
SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :submodule, :update, "--init") SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :submodule, :update, "--init")
SSHKit::Backend::Abstract.any_instance.expects(:execute) SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with(:docker, :buildx, :build, "--push", "--platform", "linux/amd64", "--builder", "kamal-local-docker-container", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".") .with(:docker, :buildx, :build, "--output=type=registry", "--platform", "linux/amd64", "--builder", "kamal-local-docker-container", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".")
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:git, "-C", anything, :"rev-parse", :HEAD) .with(:git, "-C", anything, :"rev-parse", :HEAD)
@@ -68,13 +89,12 @@ class CliBuildTest < CliTestCase
test "push without clone" do test "push without clone" do
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4", command: "build", subcommand: "push" }
run_command("push", "--verbose", fixture: :without_clone).tap do |output| run_command("push", "--verbose", fixture: :without_clone).tap do |output|
assert_no_match /Cloning repo into build directory/, output assert_no_match /Cloning repo into build directory/, output
assert_hook_ran "pre-build", output, **hook_variables assert_hook_ran "pre-build", output
assert_match /docker --version && docker buildx version/, output assert_match /docker --version && docker buildx version/, output
assert_match /docker buildx build --push --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile . as .*@localhost/, output assert_match /docker buildx build --output=type=registry --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile . as .*@localhost/, output
end end
end end
@@ -140,7 +160,7 @@ class CliBuildTest < CliTestCase
.returns("") .returns("")
SSHKit::Backend::Abstract.any_instance.expects(:execute) SSHKit::Backend::Abstract.any_instance.expects(:execute)
.with(:docker, :buildx, :build, "--push", "--platform", "linux/amd64", "--builder", "kamal-local-docker-container", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".") .with(:docker, :buildx, :build, "--output=type=registry", "--platform", "linux/amd64", "--builder", "kamal-local-docker-container", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".")
run_command("push").tap do |output| run_command("push").tap do |output|
assert_match /WARN Missing compatible builder, so creating a new one first/, output assert_match /WARN Missing compatible builder, so creating a new one first/, output
@@ -155,7 +175,7 @@ class CliBuildTest < CliTestCase
.raises(SSHKit::Command::Failed.new("no buildx")) .raises(SSHKit::Command::Failed.new("no buildx"))
Kamal::Commands::Builder.any_instance.stubs(:native_and_local?).returns(false) Kamal::Commands::Builder.any_instance.stubs(:native_and_local?).returns(false)
assert_raises(Kamal::Cli::Build::BuildError) { run_command("push") } assert_raises(Kamal::Cli::DependencyError) { run_command("push") }
end end
test "push pre-build hook failure" do test "push pre-build hook failure" do
@@ -235,6 +255,12 @@ class CliBuildTest < CliTestCase
end end
end end
test "create cloud" do
run_command("create", fixture: :with_cloud_builder).tap do |output|
assert_match /docker buildx create --driver cloud example_org\/cloud_builder/, output
end
end
test "create with error" do test "create with error" do
stub_setup stub_setup
SSHKit::Backend::Abstract.any_instance.stubs(:execute) SSHKit::Backend::Abstract.any_instance.stubs(:execute)
@@ -252,6 +278,12 @@ class CliBuildTest < CliTestCase
end end
end end
test "remove cloud" do
run_command("remove", fixture: :with_cloud_builder).tap do |output|
assert_match /docker buildx rm cloud-example_org-cloud_builder/, output
end
end
test "details" do test "details" do
SSHKit::Backend::Abstract.any_instance.stubs(:capture) SSHKit::Backend::Abstract.any_instance.stubs(:capture)
.with(:docker, :context, :ls, "&&", :docker, :buildx, :ls) .with(:docker, :context, :ls, "&&", :docker, :buildx, :ls)
@@ -263,6 +295,30 @@ class CliBuildTest < CliTestCase
end end
end end
test "dev" do
with_build_directory do |build_directory|
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
run_command("dev", "--verbose").tap do |output|
assert_no_match(/Cloning repo into build directory/, output)
assert_match(/docker --version && docker buildx version/, output)
assert_match(/docker buildx build --output=type=docker --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999-dirty -t dhh\/app:latest-dirty --label service="app" --file Dockerfile \. as .*@localhost/, output)
end
end
end
test "dev --output=local" do
with_build_directory do |build_directory|
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
run_command("dev", "--output=local", "--verbose").tap do |output|
assert_no_match(/Cloning repo into build directory/, output)
assert_match(/docker --version && docker buildx version/, output)
assert_match(/docker buildx build --output=type=local --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999-dirty -t dhh\/app:latest-dirty --label service="app" --file Dockerfile \. as .*@localhost/, output)
end
end
end
private private
def run_command(*command, fixture: :with_accessories) def run_command(*command, fixture: :with_accessories)
stdouted { Kamal::Cli::Build.start([ *command, "-c", "test/fixtures/deploy_#{fixture}.yml" ]) } stdouted { Kamal::Cli::Build.start([ *command, "-c", "test/fixtures/deploy_#{fixture}.yml" ]) }
@@ -274,17 +330,4 @@ class CliBuildTest < CliTestCase
SSHKit::Backend::Abstract.any_instance.stubs(:execute) SSHKit::Backend::Abstract.any_instance.stubs(:execute)
.with { |*args| args[0..1] == [ :docker, :buildx ] } .with { |*args| args[0..1] == [ :docker, :buildx ] }
end end
def with_build_directory
build_directory = File.join Dir.tmpdir, "kamal-clones", "app-#{pwd_sha}", "kamal"
FileUtils.mkdir_p build_directory
FileUtils.touch File.join build_directory, "Dockerfile"
yield build_directory + "/"
ensure
FileUtils.rm_rf build_directory
end
def pwd_sha
Digest::SHA256.hexdigest(Dir.pwd)[0..12]
end
end end

View File

@@ -40,8 +40,9 @@ class CliTestCase < ActiveSupport::TestCase
.with(:docker, :buildx, :inspect, "kamal-local-docker-container") .with(:docker, :buildx, :inspect, "kamal-local-docker-container")
end end
def assert_hook_ran(hook, output, version:, service_version:, hosts:, command:, subcommand: nil, runtime: false, secrets: false) def assert_hook_ran(hook, output, count: 1)
assert_match %r{usr/bin/env\s\.kamal/hooks/#{hook}}, output regexp = ([ "/usr/bin/env .kamal/hooks/#{hook}" ] * count).join(".*")
assert_match /#{regexp}/m, output
end end
def with_argv(*argv) def with_argv(*argv)
@@ -51,4 +52,17 @@ class CliTestCase < ActiveSupport::TestCase
ensure ensure
ARGV.replace(old_argv) ARGV.replace(old_argv)
end end
def with_build_directory
build_directory = File.join Dir.tmpdir, "kamal-clones", "app-#{pwd_sha}", "kamal"
FileUtils.mkdir_p build_directory
FileUtils.touch File.join build_directory, "Dockerfile"
yield build_directory + "/"
ensure
FileUtils.rm_rf build_directory
end
def pwd_sha
Digest::SHA256.hexdigest(Dir.pwd)[0..12]
end
end end

View File

@@ -8,8 +8,7 @@ class CliMainTest < CliTestCase
invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false } invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false }
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:server:bootstrap", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:server:bootstrap", [], invoke_options)
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:boot", [ "all" ], invoke_options) Kamal::Cli::Main.any_instance.expects(:deploy).with(boot_accessories: true)
Kamal::Cli::Main.any_instance.expects(:deploy)
run_command("setup").tap do |output| run_command("setup").tap do |output|
assert_match /Ensure Docker is installed.../, output assert_match /Ensure Docker is installed.../, output
@@ -54,17 +53,16 @@ class CliMainTest < CliTestCase
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], 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) Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2", command: "deploy" }
run_command("deploy", "--verbose").tap do |output| run_command("deploy", "--verbose").tap do |output|
assert_hook_ran "pre-connect", output, **hook_variables assert_hook_ran "pre-connect", output
assert_match /Log into image registry/, output assert_match /Log into image registry/, output
assert_match /Build and push app image/, output assert_match /Build and push app image/, output
assert_hook_ran "pre-deploy", output, **hook_variables, secrets: true assert_hook_ran "pre-deploy", output
assert_match /Ensure kamal-proxy is running/, output assert_match /Ensure kamal-proxy is running/, output
assert_match /Detect stale containers/, output assert_match /Detect stale containers/, output
assert_match /Prune old containers and images/, output assert_match /Prune old containers and images/, output
assert_hook_ran "post-deploy", output, **hook_variables, runtime: true, secrets: true assert_hook_ran "post-deploy", output
end end
end end
end end
@@ -206,14 +204,12 @@ class CliMainTest < CliTestCase
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
hook_variables = { version: 999, service_version: "app@999", hosts: "1.1.1.1,1.1.1.2", command: "redeploy" }
run_command("redeploy", "--verbose").tap do |output| run_command("redeploy", "--verbose").tap do |output|
assert_hook_ran "pre-connect", output, **hook_variables assert_hook_ran "pre-connect", output
assert_match /Build and push app image/, output assert_match /Build and push app image/, output
assert_hook_ran "pre-deploy", output, **hook_variables assert_hook_ran "pre-deploy", output
assert_match /Running the pre-deploy hook.../, output assert_match /Running the pre-deploy hook.../, output
assert_hook_ran "post-deploy", output, **hook_variables, runtime: true assert_hook_ran "post-deploy", output
end end
end end
@@ -259,14 +255,13 @@ class CliMainTest < CliTestCase
.returns("running").at_least_once # health check .returns("running").at_least_once # health check
Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)
hook_variables = { version: 123, service_version: "app@123", hosts: "1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4", command: "rollback" }
run_command("rollback", "--verbose", "123", config_file: "deploy_with_accessories").tap do |output| run_command("rollback", "--verbose", "123", config_file: "deploy_with_accessories").tap do |output|
assert_hook_ran "pre-deploy", output, **hook_variables assert_hook_ran "pre-deploy", output
assert_match "docker tag dhh/app:123 dhh/app:latest", output assert_match "docker tag dhh/app:123 dhh/app:latest", output
assert_match "docker run --detach --restart unless-stopped --name app-web-123", output assert_match "docker run --detach --restart unless-stopped --name app-web-123", output
assert_match "docker container ls --all --filter name=^app-web-version-to-rollback$ --quiet | xargs docker stop", output, "Should stop the container that was previously running" assert_match "docker container ls --all --filter name=^app-web-version-to-rollback$ --quiet | xargs docker stop", output, "Should stop the container that was previously running"
assert_hook_ran "post-deploy", output, **hook_variables, runtime: true assert_hook_ran "post-deploy", output
end end
end end
@@ -460,6 +455,7 @@ class CliMainTest < CliTestCase
test "run an alias for a console" do test "run an alias for a console" do
run_command("console", config_file: "deploy_with_aliases").tap do |output| run_command("console", config_file: "deploy_with_aliases").tap do |output|
assert_no_match "App Host: 1.1.1.4", output
assert_match "docker exec app-console-999 bin/console on 1.1.1.5", output assert_match "docker exec app-console-999 bin/console on 1.1.1.5", output
assert_match "App Host: 1.1.1.5", output assert_match "App Host: 1.1.1.5", output
end end
@@ -486,6 +482,33 @@ class CliMainTest < CliTestCase
end end
end end
test "switch config file with an alias" do
with_config_files do
with_argv([ "other_config" ]) do
stdouted { Kamal::Cli::Main.start }.tap do |output|
assert_match ":service_with_version: app2-999", output
end
end
end
end
test "switch destination with an alias" do
with_config_files do
with_argv([ "other_destination_config" ]) do
stdouted { Kamal::Cli::Main.start }.tap do |output|
assert_match ":service_with_version: app3-999", output
end
end
end
end
test "run on primary via alias" do
run_command("primary_details", config_file: "deploy_with_aliases").tap do |output|
assert_match "App Host: 1.1.1.1", output
assert_no_match "App Host: 1.1.1.2", output
end
end
test "upgrade" do test "upgrade" do
invoke_options = { "config_file" => "test/fixtures/deploy_with_accessories.yml", "skip_hooks" => false, "confirmed" => true, "rolling" => false } invoke_options = { "config_file" => "test/fixtures/deploy_with_accessories.yml", "skip_hooks" => false, "confirmed" => true, "rolling" => false }
Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:upgrade", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:upgrade", [], invoke_options)
@@ -530,6 +553,20 @@ class CliMainTest < CliTestCase
end end
end end
def with_config_files
Dir.mktmpdir do |tmpdir|
config_dir = File.join(tmpdir, "config")
FileUtils.mkdir_p(config_dir)
FileUtils.cp "test/fixtures/deploy.yml", config_dir
FileUtils.cp "test/fixtures/deploy2.yml", config_dir
FileUtils.cp "test/fixtures/deploy.elsewhere.yml", config_dir
Dir.chdir(tmpdir) do
yield
end
end
end
def assert_file(file, content) def assert_file(file, content)
assert_match content, File.read(file) assert_match content, File.read(file)
end end

View File

@@ -55,13 +55,11 @@ class CliProxyTest < CliTestCase
run_command("reboot", "-y").tap do |output| run_command("reboot", "-y").tap do |output|
assert_match "docker container stop kamal-proxy on 1.1.1.1", output assert_match "docker container stop kamal-proxy on 1.1.1.1", output
assert_match "Running docker container stop traefik ; docker container prune --force --filter label=org.opencontainers.image.title=Traefik && docker image prune --all --force --filter label=org.opencontainers.image.title=Traefik 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 "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy on 1.1.1.1", output
assert_match "docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy $(cat .kamal/proxy/options || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") #{KAMAL.config.proxy_image} on 1.1.1.1", output assert_match "docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy $(cat .kamal/proxy/options || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") #{KAMAL.config.proxy_image} 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 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 stop kamal-proxy on 1.1.1.2", output
assert_match "Running docker container stop traefik ; docker container prune --force --filter label=org.opencontainers.image.title=Traefik && docker image prune --all --force --filter label=org.opencontainers.image.title=Traefik 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 "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy on 1.1.1.2", output
assert_match "docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy $(cat .kamal/proxy/options || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") #{KAMAL.config.proxy_image} on 1.1.1.2", output assert_match "docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy $(cat .kamal/proxy/options || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") #{KAMAL.config.proxy_image} 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 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
@@ -281,6 +279,32 @@ class CliProxyTest < CliTestCase
end end
end end
test "boot_config set bind IP" do
run_command("boot_config", "set", "--publish-host-ip", "127.0.0.1").tap do |output|
%w[ 1.1.1.1 1.1.1.2 ].each do |host|
assert_match "Running /usr/bin/env mkdir -p .kamal/proxy on #{host}", output
assert_match "Uploading \"--publish 127.0.0.1:80:80 --publish 127.0.0.1:443:443 --log-opt max-size=10m\" to .kamal/proxy/options on #{host}", output
end
end
end
test "boot_config set multiple bind IPs" do
run_command("boot_config", "set", "--publish-host-ip", "127.0.0.1", "--publish-host-ip", "::1").tap do |output|
%w[ 1.1.1.1 1.1.1.2 ].each do |host|
assert_match "Running /usr/bin/env mkdir -p .kamal/proxy on #{host}", output
assert_match "Uploading \"--publish 127.0.0.1:80:80 --publish 127.0.0.1:443:443 --publish [::1]:80:80 --publish [::1]:443:443 --log-opt max-size=10m\" to .kamal/proxy/options on #{host}", output
end
end
end
test "boot_config set invalid bind IPs" do
exception = assert_raises do
run_command("boot_config", "set", "--publish-host-ip", "1.2.3.invalidIP", "--publish-host-ip", "::1")
end
assert_includes exception.message, "Invalid publish IP address: 1.2.3.invalidIP"
end
test "boot_config set docker options" do test "boot_config set docker options" do
run_command("boot_config", "set", "--docker_options", "label=foo=bar", "add_host=thishost:thathost").tap do |output| run_command("boot_config", "set", "--docker_options", "label=foo=bar", "add_host=thishost:thathost").tap do |output|
%w[ 1.1.1.1 1.1.1.2 ].each do |host| %w[ 1.1.1.1 1.1.1.2 ].each do |host|

View File

@@ -43,6 +43,16 @@ class CliRegistryTest < CliTestCase
end end
end end
test "login 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") }
end
private private
def run_command(*command) def run_command(*command)
stdouted { Kamal::Cli::Registry.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) } stdouted { Kamal::Cli::Registry.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) }

View File

@@ -13,12 +13,6 @@ class CliSecretsTest < CliTestCase
run_command("fetch", "foo", "bar", "baz", "--adapter", "test") run_command("fetch", "foo", "bar", "baz", "--adapter", "test")
end end
test "fetch without required --account" do
assert_equal \
"\\{\\\"foo\\\":\\\"oof\\\",\\\"bar\\\":\\\"rab\\\",\\\"baz\\\":\\\"zab\\\"\\}",
run_command("fetch", "foo", "bar", "baz", "--adapter", "test_optional_account")
end
test "extract" do test "extract" do
assert_equal "oof", run_command("extract", "foo", "{\"foo\":\"oof\", \"bar\":\"rab\", \"baz\":\"zab\"}") assert_equal "oof", run_command("extract", "foo", "{\"foo\":\"oof\", \"bar\":\"rab\", \"baz\":\"zab\"}")
end end

View File

@@ -104,28 +104,6 @@ class CommanderTest < ActiveSupport::TestCase
assert_equal [ "web", "workers" ], @kamal.roles_on("1.1.1.1").map(&:name) assert_equal [ "web", "workers" ], @kamal.roles_on("1.1.1.1").map(&:name)
end end
test "default group strategy" do
assert_empty @kamal.boot_strategy
end
test "specific limit group strategy" do
configure_with(:deploy_with_boot_strategy)
assert_equal({ in: :groups, limit: 3, wait: 2 }, @kamal.boot_strategy)
end
test "percentage-based group strategy" do
configure_with(:deploy_with_percentage_boot_strategy)
assert_equal({ in: :groups, limit: 1, wait: 2 }, @kamal.boot_strategy)
end
test "percentage-based group strategy limit is at least 1" do
configure_with(:deploy_with_low_percentage_boot_strategy)
assert_equal({ in: :groups, limit: 1, wait: 2 }, @kamal.boot_strategy)
end
test "try to match the primary role from a list of specific roles" do test "try to match the primary role from a list of specific roles" do
configure_with(:deploy_primary_web_role_override) configure_with(:deploy_primary_web_role_override)

View File

@@ -5,7 +5,9 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
setup_test_secrets("secrets" => "MYSQL_ROOT_PASSWORD=secret123") setup_test_secrets("secrets" => "MYSQL_ROOT_PASSWORD=secret123")
@config = { @config = {
service: "app", image: "dhh/app", registry: { "server" => "private.registry", "username" => "dhh", "password" => "secret" }, service: "app",
image: "dhh/app",
registry: { "server" => "private.registry", "username" => "dhh", "password" => "secret" },
servers: [ "1.1.1.1" ], servers: [ "1.1.1.1" ],
builder: { "arch" => "amd64" }, builder: { "arch" => "amd64" },
accessories: { accessories: {
@@ -39,6 +41,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
"busybox" => { "busybox" => {
"service" => "custom-busybox", "service" => "custom-busybox",
"image" => "busybox:latest", "image" => "busybox:latest",
"registry" => { "server" => "other.registry", "username" => "user", "password" => "pw" },
"host" => "1.1.1.7", "host" => "1.1.1.7",
"proxy" => { "proxy" => {
"host" => "busybox.example.com" "host" => "busybox.example.com"
@@ -62,7 +65,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
new_command(:redis).run.join(" ") new_command(:redis).run.join(" ")
assert_equal \ assert_equal \
"docker run --name custom-busybox --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --env-file .kamal/apps/app/env/accessories/busybox.env --label service=\"custom-busybox\" busybox:latest", "docker run --name custom-busybox --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --env-file .kamal/apps/app/env/accessories/busybox.env --label service=\"custom-busybox\" other.registry/busybox:latest",
new_command(:busybox).run.join(" ") new_command(:busybox).run.join(" ")
end end
@@ -70,7 +73,7 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
@config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } } @config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } }
assert_equal \ assert_equal \
"docker run --name custom-busybox --detach --restart unless-stopped --network kamal --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --env-file .kamal/apps/app/env/accessories/busybox.env --label service=\"custom-busybox\" busybox:latest", "docker run --name custom-busybox --detach --restart unless-stopped --network kamal --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --env-file .kamal/apps/app/env/accessories/busybox.env --label service=\"custom-busybox\" other.registry/busybox:latest",
new_command(:busybox).run.join(" ") new_command(:busybox).run.join(" ")
end end
@@ -100,7 +103,6 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
new_command(:mysql).info.join(" ") new_command(:mysql).info.join(" ")
end end
test "execute in new container" do test "execute in new container" do
assert_equal \ assert_equal \
"docker run --rm --network kamal --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env private.registry/mysql:8.0 mysql -u root", "docker run --rm --network kamal --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env private.registry/mysql:8.0 mysql -u root",
@@ -127,8 +129,6 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
end end
end end
test "logs" do test "logs" do
assert_equal \ assert_equal \
"docker logs app-mysql --timestamps 2>&1", "docker logs app-mysql --timestamps 2>&1",

View File

@@ -469,10 +469,10 @@ class CommandsAppTest < ActiveSupport::TestCase
test "extract assets" do test "extract assets" do
assert_equal [ assert_equal [
:mkdir, "-p", ".kamal/apps/app/assets/extracted/web-999", "&&", :mkdir, "-p", ".kamal/apps/app/assets/extracted/web-999", "&&",
:docker, :stop, "-t 1", "app-web-assets", "2> /dev/null", "|| true", "&&", :docker, :container, :rm, "app-web-assets", "2> /dev/null", "|| true", "&&",
:docker, :run, "--name", "app-web-assets", "--detach", "--rm", "--entrypoint", "sleep", "dhh/app:999", "1000000", "&&", :docker, :container, :create, "--name", "app-web-assets", "dhh/app:999", "&&",
:docker, :cp, "-L", "app-web-assets:/public/assets/.", ".kamal/apps/app/assets/extracted/web-999", "&&", :docker, :container, :cp, "-L", "app-web-assets:/public/assets/.", ".kamal/apps/app/assets/extracted/web-999", "&&",
:docker, :stop, "-t 1", "app-web-assets" :docker, :container, :rm, "app-web-assets"
], new_command(asset_path: "/public/assets").extract_assets ], new_command(asset_path: "/public/assets").extract_assets
end end

View File

@@ -9,7 +9,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "cache" => { "type" => "gha" } }) builder = new_builder_command(builder: { "cache" => { "type" => "gha" } })
assert_equal "local", builder.name assert_equal "local", builder.name
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .", "docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
builder.push.join(" ") builder.push.join(" ")
end end
@@ -17,7 +17,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "arch" => [ "amd64" ] }) builder = new_builder_command(builder: { "arch" => [ "amd64" ] })
assert_equal "local", builder.name assert_equal "local", builder.name
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile .", "docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile .",
builder.push.join(" ") builder.push.join(" ")
end end
@@ -25,7 +25,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "cache" => { "type" => "gha" } }) builder = new_builder_command(builder: { "cache" => { "type" => "gha" } })
assert_equal "local", builder.name assert_equal "local", builder.name
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .", "docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
builder.push.join(" ") builder.push.join(" ")
end end
@@ -33,7 +33,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "arch" => [ "amd64", "arm64" ], "remote" => "ssh://app@127.0.0.1", "cache" => { "type" => "gha" } }) builder = new_builder_command(builder: { "arch" => [ "amd64", "arm64" ], "remote" => "ssh://app@127.0.0.1", "cache" => { "type" => "gha" } })
assert_equal "hybrid", builder.name assert_equal "hybrid", builder.name
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64,linux/arm64 --builder kamal-hybrid-docker-container-ssh---app-127-0-0-1 -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .", "docker buildx build --output=type=registry --platform linux/amd64,linux/arm64 --builder kamal-hybrid-docker-container-ssh---app-127-0-0-1 -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
builder.push.join(" ") builder.push.join(" ")
end end
@@ -41,7 +41,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "arch" => [ "amd64", "arm64" ], "remote" => "ssh://app@127.0.0.1", "cache" => { "type" => "gha" }, "local" => false }) builder = new_builder_command(builder: { "arch" => [ "amd64", "arm64" ], "remote" => "ssh://app@127.0.0.1", "cache" => { "type" => "gha" }, "local" => false })
assert_equal "remote", builder.name assert_equal "remote", builder.name
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64,linux/arm64 --builder kamal-remote-ssh---app-127-0-0-1 -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .", "docker buildx build --output=type=registry --platform linux/amd64,linux/arm64 --builder kamal-remote-ssh---app-127-0-0-1 -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
builder.push.join(" ") builder.push.join(" ")
end end
@@ -49,7 +49,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "arch" => [ "#{remote_arch}" ], "remote" => "ssh://app@host", "cache" => { "type" => "gha" } }) builder = new_builder_command(builder: { "arch" => [ "#{remote_arch}" ], "remote" => "ssh://app@host", "cache" => { "type" => "gha" } })
assert_equal "remote", builder.name assert_equal "remote", builder.name
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/#{remote_arch} --builder kamal-remote-ssh---app-host -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .", "docker buildx build --output=type=registry --platform linux/#{remote_arch} --builder kamal-remote-ssh---app-host -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
builder.push.join(" ") builder.push.join(" ")
end end
@@ -57,14 +57,22 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "arch" => [ "#{local_arch}" ], "remote" => "ssh://app@host", "cache" => { "type" => "gha" } }) builder = new_builder_command(builder: { "arch" => [ "#{local_arch}" ], "remote" => "ssh://app@host", "cache" => { "type" => "gha" } })
assert_equal "local", builder.name assert_equal "local", builder.name
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/#{local_arch} --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .", "docker buildx build --output=type=registry --platform linux/#{local_arch} --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile .",
builder.push.join(" ")
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
assert_equal \
"docker buildx build --output=type=registry --platform linux/#{local_arch} --builder cloud-docker-org-name-builder-name -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile .",
builder.push.join(" ") builder.push.join(" ")
end end
test "build args" do test "build args" do
builder = new_builder_command(builder: { "args" => { "a" => 1, "b" => 2 } }) builder = new_builder_command(builder: { "args" => { "a" => 1, "b" => 2 } })
assert_equal \ assert_equal \
"-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" --file Dockerfile", "--label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" --file Dockerfile",
builder.target.build_options.join(" ") builder.target.build_options.join(" ")
end end
@@ -73,7 +81,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
FileUtils.touch("Dockerfile") FileUtils.touch("Dockerfile")
builder = new_builder_command(builder: { "secrets" => [ "token_a", "token_b" ] }) builder = new_builder_command(builder: { "secrets" => [ "token_a", "token_b" ] })
assert_equal \ assert_equal \
"-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --secret id=\"token_a\" --secret id=\"token_b\" --file Dockerfile", "--label service=\"app\" --secret id=\"token_a\" --secret id=\"token_b\" --file Dockerfile",
builder.target.build_options.join(" ") builder.target.build_options.join(" ")
end end
end end
@@ -82,7 +90,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
Pathname.any_instance.expects(:exist?).returns(true).once Pathname.any_instance.expects(:exist?).returns(true).once
builder = new_builder_command(builder: { "dockerfile" => "Dockerfile.xyz" }) builder = new_builder_command(builder: { "dockerfile" => "Dockerfile.xyz" })
assert_equal \ assert_equal \
"-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile.xyz", "--label service=\"app\" --file Dockerfile.xyz",
builder.target.build_options.join(" ") builder.target.build_options.join(" ")
end end
@@ -97,21 +105,21 @@ class CommandsBuilderTest < ActiveSupport::TestCase
test "build target" do test "build target" do
builder = new_builder_command(builder: { "target" => "prod" }) builder = new_builder_command(builder: { "target" => "prod" })
assert_equal \ assert_equal \
"-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --target prod", "--label service=\"app\" --file Dockerfile --target prod",
builder.target.build_options.join(" ") builder.target.build_options.join(" ")
end end
test "build context" do test "build context" do
builder = new_builder_command(builder: { "context" => ".." }) builder = new_builder_command(builder: { "context" => ".." })
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile ..", "docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile ..",
builder.push.join(" ") builder.push.join(" ")
end end
test "push with build args" do test "push with build args" do
builder = new_builder_command(builder: { "args" => { "a" => 1, "b" => 2 } }) builder = new_builder_command(builder: { "args" => { "a" => 1, "b" => 2 } })
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" --file Dockerfile .", "docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" --file Dockerfile .",
builder.push.join(" ") builder.push.join(" ")
end end
@@ -120,7 +128,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
FileUtils.touch("Dockerfile") FileUtils.touch("Dockerfile")
builder = new_builder_command(builder: { "secrets" => [ "a", "b" ] }) builder = new_builder_command(builder: { "secrets" => [ "a", "b" ] })
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --secret id=\"a\" --secret id=\"b\" --file Dockerfile .", "docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --secret id=\"a\" --secret id=\"b\" --file Dockerfile .",
builder.push.join(" ") builder.push.join(" ")
end end
end end
@@ -129,7 +137,7 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder = new_builder_command(builder: { "ssh" => "default=$SSH_AUTH_SOCK" }) builder = new_builder_command(builder: { "ssh" => "default=$SSH_AUTH_SOCK" })
assert_equal \ assert_equal \
"-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --ssh default=$SSH_AUTH_SOCK", "--label service=\"app\" --file Dockerfile --ssh default=$SSH_AUTH_SOCK",
builder.target.build_options.join(" ") builder.target.build_options.join(" ")
end end
@@ -140,35 +148,35 @@ class CommandsBuilderTest < ActiveSupport::TestCase
test "context build" do test "context build" do
builder = new_builder_command(builder: { "context" => "./foo" }) builder = new_builder_command(builder: { "context" => "./foo" })
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile ./foo", "docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile ./foo",
builder.push.join(" ") builder.push.join(" ")
end end
test "push with provenance" do test "push with provenance" do
builder = new_builder_command(builder: { "provenance" => "mode=max" }) builder = new_builder_command(builder: { "provenance" => "mode=max" })
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --provenance mode=max .", "docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --provenance mode=max .",
builder.push.join(" ") builder.push.join(" ")
end end
test "push with provenance false" do test "push with provenance false" do
builder = new_builder_command(builder: { "provenance" => false }) builder = new_builder_command(builder: { "provenance" => false })
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --provenance false .", "docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --provenance false .",
builder.push.join(" ") builder.push.join(" ")
end end
test "push with sbom" do test "push with sbom" do
builder = new_builder_command(builder: { "sbom" => true }) builder = new_builder_command(builder: { "sbom" => true })
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --sbom true .", "docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --sbom true .",
builder.push.join(" ") builder.push.join(" ")
end end
test "push with sbom false" do test "push with sbom false" do
builder = new_builder_command(builder: { "sbom" => false }) builder = new_builder_command(builder: { "sbom" => false })
assert_equal \ assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --sbom false .", "docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --sbom false .",
builder.push.join(" ") builder.push.join(" ")
end end

View File

@@ -2,14 +2,27 @@ require "test_helper"
class CommandsRegistryTest < ActiveSupport::TestCase class CommandsRegistryTest < ActiveSupport::TestCase
setup do setup do
@config = { service: "app", @config = {
service: "app",
image: "dhh/app", image: "dhh/app",
registry: { "username" => "dhh", registry: {
"username" => "dhh",
"password" => "secret", "password" => "secret",
"server" => "hub.docker.com" "server" => "hub.docker.com"
}, },
builder: { "arch" => "amd64" }, builder: { "arch" => "amd64" },
servers: [ "1.1.1.1" ] servers: [ "1.1.1.1" ],
accessories: {
"db" => {
"image" => "mysql:8.0",
"hosts" => [ "1.1.1.1" ],
"registry" => {
"username" => "user",
"password" => "pw",
"server" => "other.hub.docker.com"
}
}
}
} }
end end
@@ -19,13 +32,24 @@ class CommandsRegistryTest < ActiveSupport::TestCase
registry.login.join(" ") registry.login.join(" ")
end end
test "given registry login" do
assert_equal \
"docker login other.hub.docker.com -u \"user\" -p \"pw\"",
registry.login(registry_config: accessory_registry_config).join(" ")
end
test "registry login with ENV password" do test "registry login with ENV password" do
with_test_secrets("secrets" => "KAMAL_REGISTRY_PASSWORD=more-secret") do with_test_secrets("secrets" => "KAMAL_REGISTRY_PASSWORD=more-secret\nKAMAL_MYSQL_REGISTRY_PASSWORD=secret-pw") do
@config[:registry]["password"] = [ "KAMAL_REGISTRY_PASSWORD" ] @config[:registry]["password"] = [ "KAMAL_REGISTRY_PASSWORD" ]
@config[:accessories]["db"]["registry"]["password"] = [ "KAMAL_MYSQL_REGISTRY_PASSWORD" ]
assert_equal \ assert_equal \
"docker login hub.docker.com -u \"dhh\" -p \"more-secret\"", "docker login hub.docker.com -u \"dhh\" -p \"more-secret\"",
registry.login.join(" ") registry.login.join(" ")
assert_equal \
"docker login other.hub.docker.com -u \"user\" -p \"secret-pw\"",
registry.login(registry_config: accessory_registry_config).join(" ")
end end
end end
@@ -55,8 +79,22 @@ class CommandsRegistryTest < ActiveSupport::TestCase
registry.logout.join(" ") registry.logout.join(" ")
end end
test "given registry logout" do
assert_equal \
"docker logout other.hub.docker.com",
registry.logout(registry_config: accessory_registry_config).join(" ")
end
private private
def registry def registry
Kamal::Commands::Registry.new Kamal::Configuration.new(@config) Kamal::Commands::Registry.new main_config
end
def main_config
Kamal::Configuration.new(@config)
end
def accessory_registry_config
main_config.accessory("db").registry
end end
end end

View File

@@ -3,7 +3,9 @@ require "test_helper"
class ConfigurationAccessoryTest < ActiveSupport::TestCase class ConfigurationAccessoryTest < ActiveSupport::TestCase
setup do setup do
@deploy = { @deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, service: "app",
image: "dhh/app",
registry: { "username" => "dhh", "password" => "secret" },
servers: { servers: {
"web" => [ "1.1.1.1", "1.1.1.2" ], "web" => [ "1.1.1.1", "1.1.1.2" ],
"workers" => [ "1.1.1.3", "1.1.1.4" ] "workers" => [ "1.1.1.3", "1.1.1.4" ]
@@ -12,7 +14,7 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase
env: { "REDIS_URL" => "redis://x/y" }, env: { "REDIS_URL" => "redis://x/y" },
accessories: { accessories: {
"mysql" => { "mysql" => {
"image" => "mysql:8.0", "image" => "public.registry/mysql:8.0",
"host" => "1.1.1.5", "host" => "1.1.1.5",
"port" => "3306", "port" => "3306",
"env" => { "env" => {
@@ -52,6 +54,7 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase
"monitoring" => { "monitoring" => {
"service" => "custom-monitoring", "service" => "custom-monitoring",
"image" => "monitoring:latest", "image" => "monitoring:latest",
"registry" => { "server" => "other.registry", "username" => "user", "password" => "pw" },
"roles" => [ "web" ], "roles" => [ "web" ],
"port" => "4321:4321", "port" => "4321:4321",
"labels" => { "labels" => {
@@ -80,6 +83,21 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase
assert_equal "custom-monitoring", @config.accessory(:monitoring).service_name assert_equal "custom-monitoring", @config.accessory(:monitoring).service_name
end end
test "image" do
assert_equal "public.registry/mysql:8.0", @config.accessory(:mysql).image
assert_equal "redis:latest", @config.accessory(:redis).image
assert_equal "other.registry/monitoring:latest", @config.accessory(:monitoring).image
end
test "registry" do
assert_nil @config.accessory(:mysql).registry
assert_nil @config.accessory(:redis).registry
monitoring_registry = @config.accessory(:monitoring).registry
assert_equal "other.registry", monitoring_registry.server
assert_equal "user", monitoring_registry.username
assert_equal "pw", monitoring_registry.password
end
test "port" do test "port" do
assert_equal "3306:3306", @config.accessory(:mysql).port assert_equal "3306:3306", @config.accessory(:mysql).port
assert_equal "6379:6379", @config.accessory(:redis).port assert_equal "6379:6379", @config.accessory(:redis).port

View File

@@ -0,0 +1,54 @@
require "test_helper"
class ConfigurationBootTest < ActiveSupport::TestCase
test "no group strategy" do
deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, builder: { "arch" => "amd64" },
servers: { "web" => [ "1.1.1.1", "1.1.1.2" ], "workers" => [ "1.1.1.3", "1.1.1.4" ] }
}
config = Kamal::Configuration.new(deploy)
assert_nil config.boot.limit
assert_nil config.boot.wait
end
test "specific limit group strategy" do
deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, builder: { "arch" => "amd64" },
servers: { "web" => [ "1.1.1.1", "1.1.1.2" ], "workers" => [ "1.1.1.3", "1.1.1.4" ] },
boot: { "limit" => 3, "wait" => 2 }
}
config = Kamal::Configuration.new(deploy)
assert_equal 3, config.boot.limit
assert_equal 2, config.boot.wait
end
test "percentage-based group strategy" do
deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, builder: { "arch" => "amd64" },
servers: { "web" => [ "1.1.1.1", "1.1.1.2" ], "workers" => [ "1.1.1.3", "1.1.1.4" ] },
boot: { "limit" => "50%", "wait" => 2 }
}
config = Kamal::Configuration.new(deploy)
assert_equal 2, config.boot.limit
assert_equal 2, config.boot.wait
end
test "percentage-based group strategy limit is at least 1" do
deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, builder: { "arch" => "amd64" },
servers: { "web" => [ "1.1.1.1", "1.1.1.2" ], "workers" => [ "1.1.1.3", "1.1.1.4" ] },
boot: { "limit" => "1%", "wait" => 2 }
}
config = Kamal::Configuration.new(deploy)
assert_equal 1, config.boot.limit
assert_equal 2, config.boot.wait
end
end

12
test/fixtures/deploy.elsewhere.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
service: app3
image: dhh/app3
servers:
- "1.1.1.3"
- "1.1.1.4"
registry:
username: user
password: pw
builder:
arch: amd64
aliases:
other_config: config -c config/deploy2.yml

13
test/fixtures/deploy.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
service: app
image: dhh/app
servers:
- "1.1.1.1"
- "1.1.1.2"
registry:
username: user
password: pw
builder:
arch: amd64
aliases:
other_config: config -c config/deploy2.yml
other_destination_config: config -d elsewhere

12
test/fixtures/deploy2.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
service: app2
image: dhh/app2
servers:
- "1.1.1.1"
- "1.1.1.2"
registry:
username: user2
password: pw2
builder:
arch: amd64
aliases:
other_config: config -c config/deploy2.yml

View File

@@ -0,0 +1,47 @@
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: private.registry
username: user
password: pw
builder:
arch: amd64
accessories:
mysql:
image: private.registry/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

View File

@@ -21,3 +21,6 @@ aliases:
console: app exec --reuse -p -r console "bin/console" console: app exec --reuse -p -r console "bin/console"
exec: app exec --reuse -p -r console exec: app exec --reuse -p -r console
rails: app exec --reuse -p -r console rails rails: app exec --reuse -p -r console rails
primary_details: details -p
deploy_secondary: deploy -d secondary

View File

@@ -0,0 +1,40 @@
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:
username: user
password: pw
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
readiness_delay: 0
builder:
arch: <%= Kamal::Utils.docker_arch == "arm64" ? "amd64" : "arm64" %>
driver: cloud example_org/cloud_builder

View File

@@ -1,19 +0,0 @@
service: app
image: dhh/app
servers:
web:
- "1.1.1.1"
- "1.1.1.2"
workers:
- "1.1.1.3"
- "1.1.1.4"
builder:
arch: amd64
registry:
username: user
password: pw
boot:
limit: 1%
wait: 2

View File

@@ -1,19 +0,0 @@
service: app
image: dhh/app
servers:
web:
- "1.1.1.1"
- "1.1.1.2"
workers:
- "1.1.1.3"
- "1.1.1.4"
builder:
arch: amd64
registry:
username: user
password: pw
boot:
limit: 1%
wait: 2

View File

@@ -15,7 +15,9 @@ class AppTest < IntegrationTest
# kamal app start does not wait # kamal app start does not wait
wait_for_app_to_be_up wait_for_app_to_be_up
kamal :app, :boot output = kamal :app, :boot, "--verbose", capture: true
assert_match "Booting app on vm1,vm2...", output
assert_match "Booted app on vm1,vm2...", output
wait_for_app_to_be_up wait_for_app_to_be_up

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "Booted app on ${KAMAL_HOSTS}..."
mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/post-app-boot

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "Booting app on ${KAMAL_HOSTS}..."
mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-app-boot

View File

@@ -8,19 +8,19 @@ class MainTest < IntegrationTest
kamal :deploy kamal :deploy
assert_app_is_up version: first_version assert_app_is_up version: first_version
assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "post-deploy" assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "pre-app-boot", "post-app-boot", "post-deploy"
assert_envs version: first_version assert_envs version: first_version
second_version = update_app_rev second_version = update_app_rev
kamal :redeploy kamal :redeploy
assert_app_is_up version: second_version assert_app_is_up version: second_version
assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "post-deploy" assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "pre-app-boot", "post-app-boot", "post-deploy"
assert_accumulated_assets first_version, second_version assert_accumulated_assets first_version, second_version
kamal :rollback, first_version kamal :rollback, first_version
assert_hooks_ran "pre-connect", "pre-deploy", "post-deploy" assert_hooks_ran "pre-connect", "pre-deploy", "pre-app-boot", "post-app-boot", "post-deploy"
assert_app_is_up version: first_version assert_app_is_up version: first_version
details = kamal :details, capture: true details = kamal :details, capture: true
@@ -90,9 +90,9 @@ class MainTest < IntegrationTest
test "setup and remove" do test "setup and remove" do
@app = "app_with_roles" @app = "app_with_roles"
kamal :proxy, :set_config, kamal :proxy, :boot_config, "set",
"--publish=false", "--publish=false",
"--options=label=traefik.http.services.kamal_proxy.loadbalancer.server.scheme=http", "--docker-options=label=traefik.http.services.kamal_proxy.loadbalancer.server.scheme=http",
"label=traefik.http.routers.kamal_proxy.rule=PathPrefix\\\(\\\`/\\\`\\\)", "label=traefik.http.routers.kamal_proxy.rule=PathPrefix\\\(\\\`/\\\`\\\)",
"label=traefik.http.routers.kamal_proxy.priority=2" "label=traefik.http.routers.kamal_proxy.priority=2"

View File

@@ -156,14 +156,45 @@ class AwsSecretsManagerAdapterTest < SecretAdapterTestCase
assert_equal "AWS CLI is not installed", error.message assert_equal "AWS CLI is not installed", error.message
end end
test "fetch without account option omits --profile" do
stub_ticks.with("aws --version 2> /dev/null")
stub_ticks
.with("aws secretsmanager batch-get-secret-value --secret-id-list secret/KEY1 secret/KEY2")
.returns(<<~JSON)
{
"SecretValues": [
{
"ARN": "arn:aws:secretsmanager:us-east-1:aaaaaaaaaaaa:secret:secret",
"Name": "secret",
"VersionId": "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv",
"SecretString": "{\\"KEY1\\":\\"VALUE1\\", \\"KEY2\\":\\"VALUE2\\"}",
"VersionStages": [
"AWSCURRENT"
],
"CreatedDate": "2024-01-01T00:00:00.000000"
}
],
"Errors": []
}
JSON
json = JSON.parse(shellunescape(run_command("fetch", "--from", "secret", "KEY1", "KEY2", account: nil)))
expected_json = {
"secret/KEY1"=>"VALUE1",
"secret/KEY2"=>"VALUE2"
}
assert_equal expected_json, json
end
private private
def run_command(*command) def run_command(*command, account: "default")
stdouted do stdouted do
Kamal::Cli::Secrets.start \ args = [ *command,
[ *command,
"-c", "test/fixtures/deploy_with_accessories.yml", "-c", "test/fixtures/deploy_with_accessories.yml",
"--adapter", "aws_secrets_manager", "--adapter", "aws_secrets_manager" ]
"--account", "default" ] args += [ "--account", account ] if account
Kamal::Cli::Secrets.start(args)
end end
end end
end end

View File

@@ -0,0 +1,119 @@
require "test_helper"
class BitwardenSecretsManagerAdapterTest < SecretAdapterTestCase
test "fetch with no parameters" do
stub_ticks.with("bws --version 2> /dev/null")
stub_login
error = assert_raises RuntimeError do
(shellunescape(run_command("fetch")))
end
assert_equal("You must specify what to retrieve from Bitwarden Secrets Manager", error.message)
end
test "fetch all" do
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\"")
expected = '{"KAMAL_REGISTRY_PASSWORD":"some_password","MY_OTHER_SECRET":"my\=weird\"secret"}'
actual = shellunescape(run_command("fetch", "all"))
assert_equal expected, actual
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\"")
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
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\"")
expected = '{"KAMAL_REGISTRY_PASSWORD":"some_password"}'
actual = shellunescape(run_command("fetch", "82aeb5bd-6958-4a89-8197-eacab758acce"))
assert_equal expected, actual
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\"")
stub_ticks
.with("bws secret get -o env 6f8cdf27-de2b-4c77-a35d-07df8050e332")
.returns("MY_OTHER_SECRET=\"my=weird\"secret\"")
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
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")
error = assert_raises RuntimeError do
(shellunescape(run_command("fetch", "all")))
end
assert_equal("Could not read secrets from Bitwarden Secrets Manager", error.message)
end
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")
error = assert_raises RuntimeError do
(shellunescape(run_command("fetch", "82aeb5bd-6958-4a89-8197-eacab758acce")))
end
assert_equal("Could not read 82aeb5bd-6958-4a89-8197-eacab758acce from Bitwarden Secrets Manager", error.message)
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)
error = assert_raises RuntimeError do
(shellunescape(run_command("fetch", "all")))
end
assert_equal("Could not authenticate to Bitwarden Secrets Manager. Did you set a valid access token?", error.message)
end
test "fetch without CLI installed" do
stub_ticks_with("bws --version 2> /dev/null", succeed: false)
error = assert_raises RuntimeError do
shellunescape(run_command("fetch"))
end
assert_equal "Bitwarden Secrets Manager CLI is not installed", error.message
end
private
def stub_login
stub_ticks.with("bws run 'echo OK'").returns("OK")
end
def run_command(*command)
stdouted do
Kamal::Cli::Secrets.start \
[ *command,
"--adapter", "bitwarden-sm" ]
end
end
end

View File

@@ -0,0 +1,81 @@
require "test_helper"
class EnpassAdapterTest < SecretAdapterTestCase
test "fetch without CLI installed" do
stub_ticks_with("enpass-cli version 2> /dev/null", succeed: false)
error = assert_raises RuntimeError do
JSON.parse(shellunescape(run_command("fetch", "mynote")))
end
assert_equal "Enpass CLI is not installed", error.message
end
test "fetch one item" do
stub_ticks_with("enpass-cli version 2> /dev/null")
stub_ticks
.with("enpass-cli -json -vault vault-path show FooBar")
.returns(<<~JSON)
[{"category":"computer","label":"SECRET_1","login":"","password":"my-password-1","title":"FooBar","type":"password"}]
JSON
json = JSON.parse(shellunescape(run_command("fetch", "FooBar/SECRET_1")))
expected_json = { "FooBar/SECRET_1" => "my-password-1" }
assert_equal expected_json, json
end
test "fetch multiple items" do
stub_ticks_with("enpass-cli version 2> /dev/null")
stub_ticks
.with("enpass-cli -json -vault vault-path show FooBar")
.returns(<<~JSON)
[
{"category":"computer","label":"SECRET_1","login":"","password":"my-password-1","title":"FooBar","type":"password"},
{"category":"computer","label":"SECRET_2","login":"","password":"my-password-2","title":"FooBar","type":"password"},
{"category":"computer","label":"SECRET_3","login":"","password":"my-password-1","title":"Hello","type":"password"}
]
JSON
json = JSON.parse(shellunescape(run_command("fetch", "FooBar/SECRET_1", "FooBar/SECRET_2")))
expected_json = { "FooBar/SECRET_1" => "my-password-1", "FooBar/SECRET_2" => "my-password-2" }
assert_equal expected_json, json
end
test "fetch all with from" do
stub_ticks_with("enpass-cli version 2> /dev/null")
stub_ticks
.with("enpass-cli -json -vault vault-path show FooBar")
.returns(<<~JSON)
[
{"category":"computer","label":"SECRET_1","login":"","password":"my-password-1","title":"FooBar","type":"password"},
{"category":"computer","label":"SECRET_2","login":"","password":"my-password-2","title":"FooBar","type":"password"},
{"category":"computer","label":"SECRET_3","login":"","password":"my-password-1","title":"Hello","type":"password"},
{"category":"computer","label":"","login":"","password":"my-password-3","title":"FooBar","type":"password"}
]
JSON
json = JSON.parse(shellunescape(run_command("fetch", "FooBar")))
expected_json = { "FooBar/SECRET_1" => "my-password-1", "FooBar/SECRET_2" => "my-password-2", "FooBar" => "my-password-3" }
assert_equal expected_json, json
end
private
def run_command(*command)
stdouted do
Kamal::Cli::Secrets.start \
[ *command,
"-c", "test/fixtures/deploy_with_accessories.yml",
"--adapter", "enpass",
"--from", "vault-path" ]
end
end
end

View File

@@ -0,0 +1,220 @@
require "test_helper"
class GcpSecretManagerAdapterTest < SecretAdapterTestCase
test "fetch" do
stub_gcloud_version
stub_authenticated
stub_mypassword
json = JSON.parse(shellunescape(run_command("fetch", "mypassword")))
expected_json = { "default/mypassword"=>"secret123" }
assert_equal expected_json, json
end
test "fetch unauthenticated" do
stub_ticks.with("gcloud --version 2> /dev/null")
stub_mypassword
stub_unauthenticated
error = assert_raises RuntimeError do
JSON.parse(shellunescape(run_command("fetch", "mypassword")))
end
assert_match(/could not login to gcloud/, error.message)
end
test "fetch with from" do
stub_gcloud_version
stub_authenticated
stub_items(0, project: "other-project")
stub_items(1, project: "other-project")
stub_items(2, project: "other-project")
json = JSON.parse(shellunescape(run_command("fetch", "--from", "other-project", "item1", "item2", "item3")))
expected_json = {
"other-project/item1"=>"secret1", "other-project/item2"=>"secret2", "other-project/item3"=>"secret3"
}
assert_equal expected_json, json
end
test "fetch with multiple projects" do
stub_gcloud_version
stub_authenticated
stub_items(0, project: "some-project")
stub_items(1, project: "project-confidence")
stub_items(2, project: "manhattan-project")
json = JSON.parse(shellunescape(run_command("fetch", "some-project/item1", "project-confidence/item2", "manhattan-project/item3")))
expected_json = {
"some-project/item1"=>"secret1", "project-confidence/item2"=>"secret2", "manhattan-project/item3"=>"secret3"
}
assert_equal expected_json, json
end
test "fetch with specific version" do
stub_gcloud_version
stub_authenticated
stub_items(0, project: "some-project", version: "123")
json = JSON.parse(shellunescape(run_command("fetch", "some-project/item1/123")))
expected_json = {
"some-project/item1"=>"secret1"
}
assert_equal expected_json, json
end
test "fetch with non-default account" do
stub_gcloud_version
stub_authenticated
stub_items(0, project: "some-project", version: "123", account: "email@example.com")
json = JSON.parse(shellunescape(run_command("fetch", "some-project/item1/123", account: "email@example.com")))
expected_json = {
"some-project/item1"=>"secret1"
}
assert_equal expected_json, json
end
test "fetch with service account impersonation" do
stub_gcloud_version
stub_authenticated
stub_items(0, project: "some-project", version: "123", impersonate_service_account: "service-user@example.com")
json = JSON.parse(shellunescape(run_command("fetch", "some-project/item1/123", account: "default|service-user@example.com")))
expected_json = {
"some-project/item1"=>"secret1"
}
assert_equal expected_json, json
end
test "fetch with delegation chain and specific user" do
stub_gcloud_version
stub_authenticated
stub_items(0, project: "some-project", version: "123", account: "user@example.com", impersonate_service_account: "service-user@example.com,service-user2@example.com")
json = JSON.parse(shellunescape(run_command("fetch", "some-project/item1/123", account: "user@example.com|service-user@example.com,service-user2@example.com")))
expected_json = {
"some-project/item1"=>"secret1"
}
assert_equal expected_json, json
end
test "fetch with non-default account and service account impersonation" do
stub_gcloud_version
stub_authenticated
stub_items(0, project: "some-project", version: "123", account: "email@example.com", impersonate_service_account: "service-user@example.com")
json = JSON.parse(shellunescape(run_command("fetch", "some-project/item1/123", account: "email@example.com|service-user@example.com")))
expected_json = {
"some-project/item1"=>"secret1"
}
assert_equal expected_json, json
end
test "fetch without CLI installed" do
stub_gcloud_version(succeed: false)
error = assert_raises RuntimeError do
JSON.parse(shellunescape(run_command("fetch", "item1")))
end
assert_equal "gcloud CLI is not installed", error.message
end
private
def run_command(*command, account: "default")
stdouted do
Kamal::Cli::Secrets.start \
[ *command,
"-c", "test/fixtures/deploy_with_accessories.yml",
"--adapter", "gcp_secret_manager",
"--account", account ]
end
end
def stub_gcloud_version(succeed: true)
stub_ticks_with("gcloud --version 2> /dev/null", succeed: succeed)
end
def stub_authenticated
stub_ticks
.with("gcloud auth list --format=json")
.returns(<<~JSON)
[
{
"account": "email@example.com",
"status": "ACTIVE"
}
]
JSON
end
def stub_unauthenticated
stub_ticks
.with("gcloud auth list --format=json")
.returns("[]")
stub_ticks
.with("gcloud auth login")
.returns(<<~JSON)
{
"expired": false,
"valid": true
}
JSON
end
def stub_mypassword
stub_ticks
.with("gcloud secrets versions access latest --secret=mypassword --format=json")
.returns(<<~JSON)
{
"name": "projects/000000000/secrets/mypassword/versions/1",
"payload": {
"data": "c2VjcmV0MTIz",
"dataCrc32c": "2522602764"
}
}
JSON
end
def stub_items(n, project: nil, account: nil, version: "latest", impersonate_service_account: nil)
payloads = [
{ data: "c2VjcmV0MQ==", checksum: 1846998209 },
{ data: "c2VjcmV0Mg==", checksum: 2101741365 },
{ data: "c2VjcmV0Mw==", checksum: 2402124854 }
]
stub_ticks
.with("gcloud secrets versions access #{version} " \
"--secret=item#{n + 1}" \
"#{" --project=#{project}" if project}" \
"#{" --account=#{account}" if account}" \
"#{" --impersonate-service-account=#{impersonate_service_account}" if impersonate_service_account} " \
"--format=json")
.returns(<<~JSON)
{
"name": "projects/000000001/secrets/item1/versions/1",
"payload": {
"data": "#{payloads[n][:data]}",
"dataCrc32c": "#{payloads[n][:checksum]}"
}
}
JSON
end
end

View File

@@ -20,6 +20,20 @@ class SecretsTest < ActiveSupport::TestCase
end end
end end
test "env references" do
with_test_secrets("secrets" => "SECRET1=$SECRET1") do
ENV["SECRET1"] = "ABC"
assert_equal "ABC", Kamal::Secrets.new["SECRET1"]
end
end
test "secrets file value overrides env" do
with_test_secrets("secrets" => "SECRET1=DEF") do
ENV["SECRET1"] = "ABC"
assert_equal "DEF", Kamal::Secrets.new["SECRET1"]
end
end
test "destinations" do test "destinations" do
with_test_secrets("secrets.dest" => "SECRET=DEF", "secrets" => "SECRET=ABC", "secrets-common" => "SECRET=GHI\nSECRET2=JKL") do with_test_secrets("secrets.dest" => "SECRET=DEF", "secrets" => "SECRET=ABC", "secrets-common" => "SECRET=GHI\nSECRET2=JKL") do
assert_equal "ABC", Kamal::Secrets.new["SECRET"] assert_equal "ABC", Kamal::Secrets.new["SECRET"]

View File

@@ -36,6 +36,10 @@ class ActiveSupport::TestCase
extend Rails::LineFiltering extend Rails::LineFiltering
private private
setup do
SSHKit::Backend::Netssh.pool.close_connections
end
def stdouted def stdouted
capture(:stdout) { yield }.strip capture(:stdout) { yield }.strip
end end