├── test ├── fixtures │ ├── files │ │ ├── my.cnf │ │ └── structure.sql.erb │ ├── deploy_for_dest.mars.yml │ ├── deploy_for_dest.world.yml │ ├── deploy_for_required_dest.world.yml │ ├── deploy_simple.yml │ ├── deploy_for_dest.yml │ ├── deploy_with_assets.yml │ ├── deploy_with_secrets.yml │ ├── deploy_for_required_dest.yml │ ├── deploy_workers_only.yml │ ├── deploy.erb.yml │ ├── deploy_with_uncommon_hostnames.yml │ ├── deploy_with_boot_strategy.yml │ ├── deploy_with_low_percentage_boot_strategy.yml │ ├── deploy_with_percentage_boot_strategy.yml │ ├── deploy_with_two_roles_one_host.yml │ ├── deploy_with_roles.yml │ ├── deploy_primary_web_role_override.yml │ ├── deploy_with_extensions.yml │ ├── deploy_with_aliases.yml │ ├── deploy_with_single_accessory.yml │ ├── deploy_with_env_tags.yml │ ├── deploy_with_multiple_proxy_roles.yml │ ├── deploy_with_accessories.yml │ ├── deploy_with_accessories_on_independent_server.yml │ ├── deploy_with_proxy.yml │ ├── deploy_without_clone.yml │ ├── deploy_with_hybrid_builder.yml │ ├── deploy_with_remote_builder.yml │ ├── deploy_with_proxy_roles.yml │ └── deploy_with_remote_builder_and_custom_ports.yml ├── integration │ ├── docker │ │ ├── deployer │ │ │ ├── app_with_roles │ │ │ │ ├── .kamal │ │ │ │ │ ├── secrets │ │ │ │ │ └── hooks │ │ │ │ │ │ ├── post-deploy │ │ │ │ │ │ ├── pre-deploy │ │ │ │ │ │ ├── docker-setup │ │ │ │ │ │ ├── pre-build │ │ │ │ │ │ ├── pre-connect │ │ │ │ │ │ ├── post-proxy-reboot │ │ │ │ │ │ └── pre-proxy-reboot │ │ │ │ ├── Dockerfile │ │ │ │ ├── default.conf │ │ │ │ └── config │ │ │ │ │ └── deploy.yml │ │ │ ├── app_with_traefik │ │ │ │ ├── .kamal │ │ │ │ │ ├── secrets │ │ │ │ │ └── hooks │ │ │ │ │ │ └── pre-deploy │ │ │ │ ├── Dockerfile │ │ │ │ ├── default.conf │ │ │ │ └── config │ │ │ │ │ └── deploy.yml │ │ │ ├── app │ │ │ │ ├── .kamal │ │ │ │ │ ├── secrets-common │ │ │ │ │ ├── hooks │ │ │ │ │ │ ├── post-deploy │ │ │ │ │ │ ├── pre-deploy │ │ │ │ │ │ ├── docker-setup │ │ │ │ │ │ ├── pre-build │ │ │ │ │ │ ├── pre-connect │ │ │ │ │ │ ├── post-proxy-reboot │ │ │ │ │ │ └── pre-proxy-reboot │ │ │ │ │ └── secrets │ │ │ │ ├── Dockerfile │ │ │ │ ├── default.conf │ │ │ │ └── config │ │ │ │ │ └── deploy.yml │ │ │ ├── update_app_rev.sh │ │ │ ├── boot.sh │ │ │ ├── break_app.sh │ │ │ ├── app_with_proxied_accessory │ │ │ │ ├── Dockerfile │ │ │ │ ├── default.conf │ │ │ │ └── config │ │ │ │ │ └── deploy.yml │ │ │ ├── setup.sh │ │ │ └── Dockerfile │ │ ├── shared │ │ │ ├── boot.sh │ │ │ ├── registry-dns.conf │ │ │ └── Dockerfile │ │ ├── registry │ │ │ ├── boot.sh │ │ │ └── Dockerfile │ │ ├── load_balancer │ │ │ ├── Dockerfile │ │ │ └── default.conf │ │ └── vm │ │ │ ├── boot.sh │ │ │ └── Dockerfile │ ├── lock_test.rb │ ├── accessory_test.rb │ ├── broken_deploy_test.rb │ ├── docker-compose.yml │ ├── proxy_test.rb │ ├── app_test.rb │ └── proxied_accessory_test.rb ├── git_test.rb ├── configuration │ ├── volume_test.rb │ ├── sshkit_test.rb │ ├── proxy_test.rb │ ├── ssh_test.rb │ └── env_test.rb ├── commands │ ├── server_test.rb │ ├── docker_test.rb │ ├── lock_test.rb │ ├── prune_test.rb │ ├── hook_test.rb │ ├── registry_test.rb │ └── auditor_test.rb ├── cli │ ├── lock_test.rb │ ├── secrets_test.rb │ ├── prune_test.rb │ ├── registry_test.rb │ └── cli_test_case.rb ├── secrets │ └── dotenv_inline_command_substitution_test.rb ├── secrets_test.rb ├── env_file_test.rb ├── utils_test.rb └── test_helper.rb ├── lib ├── kamal │ ├── commands.rb │ ├── version.rb │ ├── cli │ │ ├── healthcheck │ │ │ ├── error.rb │ │ │ ├── barrier.rb │ │ │ └── poller.rb │ │ ├── templates │ │ │ ├── sample_hooks │ │ │ │ ├── docker-setup.sample │ │ │ │ ├── post-proxy-reboot.sample │ │ │ │ ├── pre-proxy-reboot.sample │ │ │ │ ├── post-deploy.sample │ │ │ │ ├── pre-connect.sample │ │ │ │ ├── pre-build.sample │ │ │ │ └── pre-deploy.sample │ │ │ ├── secrets │ │ │ └── deploy.yml │ │ ├── alias │ │ │ └── command.rb │ │ ├── app │ │ │ └── prepare_assets.rb │ │ ├── registry.rb │ │ ├── prune.rb │ │ ├── lock.rb │ │ ├── server.rb │ │ ├── secrets.rb │ │ └── build │ │ │ └── clone.rb │ ├── secrets │ │ ├── adapters │ │ │ ├── test_optional_account.rb │ │ │ ├── test.rb │ │ │ ├── base.rb │ │ │ ├── aws_secrets_manager.rb │ │ │ ├── last_pass.rb │ │ │ ├── doppler.rb │ │ │ ├── one_password.rb │ │ │ └── bitwarden.rb │ │ ├── adapters.rb │ │ └── dotenv │ │ │ └── inline_command_substitution.rb │ ├── configuration │ │ ├── validator │ │ │ ├── configuration.rb │ │ │ ├── servers.rb │ │ │ ├── accessory.rb │ │ │ ├── role.rb │ │ │ ├── proxy.rb │ │ │ ├── alias.rb │ │ │ ├── builder.rb │ │ │ ├── registry.rb │ │ │ └── env.rb │ │ ├── env │ │ │ └── tag.rb │ │ ├── alias.rb │ │ ├── sshkit.rb │ │ ├── boot.rb │ │ ├── docs │ │ │ ├── logging.yml │ │ │ ├── boot.yml │ │ │ ├── alias.yml │ │ │ ├── servers.yml │ │ │ ├── sshkit.yml │ │ │ ├── role.yml │ │ │ ├── registry.yml │ │ │ ├── ssh.yml │ │ │ ├── env.yml │ │ │ └── accessory.yml │ │ ├── servers.rb │ │ ├── volume.rb │ │ ├── registry.rb │ │ ├── validation.rb │ │ ├── logging.rb │ │ ├── env.rb │ │ ├── ssh.rb │ │ └── proxy.rb │ ├── cli.rb │ ├── commands │ │ ├── app │ │ │ ├── images.rb │ │ │ ├── proxy.rb │ │ │ ├── logging.rb │ │ │ ├── containers.rb │ │ │ ├── execution.rb │ │ │ └── assets.rb │ │ ├── server.rb │ │ ├── builder │ │ │ ├── local.rb │ │ │ ├── hybrid.rb │ │ │ ├── clone.rb │ │ │ ├── remote.rb │ │ │ └── base.rb │ │ ├── registry.rb │ │ ├── accessory │ │ │ └── proxy.rb │ │ ├── hook.rb │ │ ├── auditor.rb │ │ ├── docker.rb │ │ ├── builder.rb │ │ ├── prune.rb │ │ ├── lock.rb │ │ ├── base.rb │ │ ├── proxy.rb │ │ └── accessory.rb │ ├── git.rb │ ├── utils │ │ └── sensitive.rb │ ├── tags.rb │ ├── secrets.rb │ ├── env_file.rb │ └── commander │ │ └── specifics.rb └── kamal.rb ├── .rubocop.yml ├── .gitignore ├── bin ├── test ├── release └── kamal ├── Gemfile ├── gemfiles └── rails_edge.gemfile ├── README.md ├── MIT-LICENSE ├── kamal.gemspec ├── Dockerfile ├── .github └── workflows │ ├── docker-publish.yml │ └── ci.yml ├── CODE_OF_CONDUCT.md └── CONTRIBUTING.md /test/fixtures/files/my.cnf: -------------------------------------------------------------------------------- 1 | # MySQL Config 2 | -------------------------------------------------------------------------------- /lib/kamal/commands.rb: -------------------------------------------------------------------------------- 1 | module Kamal::Commands 2 | end 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | rubocop-rails-omakase: rubocop.yml 3 | -------------------------------------------------------------------------------- /lib/kamal/version.rb: -------------------------------------------------------------------------------- 1 | module Kamal 2 | VERSION = "2.3.0" 3 | end 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .byebug_history 2 | *.gem 3 | coverage/* 4 | .DS_Store 5 | gemfiles/*.lock 6 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app_with_roles/.kamal/secrets: -------------------------------------------------------------------------------- 1 | SECRET_TOKEN='1234 with "中文"' 2 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app_with_traefik/.kamal/secrets: -------------------------------------------------------------------------------- 1 | SECRET_TOKEN='1234 with "中文"' 2 | -------------------------------------------------------------------------------- /lib/kamal/cli/healthcheck/error.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Cli::Healthcheck::Error < StandardError 2 | end 3 | -------------------------------------------------------------------------------- /test/integration/docker/shared/boot.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cp -r * /shared 4 | 5 | exec sleep infinity 6 | -------------------------------------------------------------------------------- /test/fixtures/files/structure.sql.erb: -------------------------------------------------------------------------------- 1 | <%= "This was dynamically expanded" %> 2 | <%= ENV["MYSQL_ROOT_HOST"] %> 3 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app/.kamal/secrets-common: -------------------------------------------------------------------------------- 1 | SECRET_TOKEN='1234 with "中文"' 2 | SECRET_TAG='TAGME' 3 | -------------------------------------------------------------------------------- /lib/kamal/cli/templates/sample_hooks/docker-setup.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Docker set up on $KAMAL_HOSTS..." 4 | -------------------------------------------------------------------------------- /test/fixtures/deploy_for_dest.mars.yml: -------------------------------------------------------------------------------- 1 | servers: 2 | - 1.1.1.3 3 | - 1.1.1.4 4 | env: 5 | REDIS_URL: redis://a/b 6 | -------------------------------------------------------------------------------- /test/fixtures/deploy_for_dest.world.yml: -------------------------------------------------------------------------------- 1 | servers: 2 | - 1.1.1.1 3 | - 1.1.1.2 4 | env: 5 | REDIS_URL: redis://x/y 6 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/update_app_rev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd $1 && git commit -am 'Update rev' --amend 4 | -------------------------------------------------------------------------------- /lib/kamal/cli/templates/sample_hooks/post-proxy-reboot.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Rebooted kamal-proxy on $KAMAL_HOSTS" 4 | -------------------------------------------------------------------------------- /lib/kamal/cli/templates/sample_hooks/pre-proxy-reboot.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Rebooting kamal-proxy on $KAMAL_HOSTS..." 4 | -------------------------------------------------------------------------------- /test/fixtures/deploy_for_required_dest.world.yml: -------------------------------------------------------------------------------- 1 | servers: 2 | - 1.1.1.1 3 | - 1.1.1.2 4 | env: 5 | REDIS_URL: redis://x/y 6 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/boot.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | dockerd --max-concurrent-downloads 1 & 4 | 5 | exec sleep infinity 6 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | $: << File.expand_path("../test", __dir__) 3 | 4 | require "bundler/setup" 5 | require "rails/plugin/test" 6 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/break_app.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd $1 && echo "bad nginx config" > default.conf && git commit -am 'Broken' 4 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app/.kamal/hooks/post-deploy: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo "Deployed!" 3 | mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/post-deploy 4 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app/.kamal/hooks/pre-deploy: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo "Deployed!" 3 | mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-deploy 4 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app/.kamal/hooks/docker-setup: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo "Docker set up!" 3 | mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/docker-setup 4 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app_with_roles/.kamal/hooks/post-deploy: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo "Deployed!" 3 | mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/post-deploy 4 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-deploy: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo "Deployed!" 3 | mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-deploy 4 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app/.kamal/hooks/pre-build: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo "About to build and push..." 3 | mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-build 4 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app/.kamal/hooks/pre-connect: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "About to lock..." 4 | mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-connect 5 | -------------------------------------------------------------------------------- /test/integration/docker/registry/boot.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | while [ ! -f /certs/domain.crt ]; do sleep 1; done 4 | 5 | exec /entrypoint.sh /etc/docker/registry/config.yml 6 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app_with_roles/.kamal/hooks/docker-setup: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo "Docker set up!" 3 | mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/docker-setup 4 | -------------------------------------------------------------------------------- /test/integration/docker/shared/registry-dns.conf: -------------------------------------------------------------------------------- 1 | [dn] 2 | CN=registry 3 | [req] 4 | distinguished_name = dn 5 | [EXT] 6 | subjectAltName=DNS:registry 7 | keyUsage=digitalSignature 8 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-build: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo "About to build and push..." 3 | mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-build 4 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-connect: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "About to lock..." 4 | mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-connect 5 | -------------------------------------------------------------------------------- /test/integration/docker/load_balancer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:1-alpine-slim 2 | 3 | COPY default.conf /etc/nginx/conf.d/default.conf 4 | 5 | HEALTHCHECK --interval=1s CMD pgrep nginx 6 | -------------------------------------------------------------------------------- /lib/kamal/secrets/adapters/test_optional_account.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Secrets::Adapters::TestOptionalAccount < Kamal::Secrets::Adapters::Test 2 | def requires_account? 3 | false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app/.kamal/hooks/post-proxy-reboot: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo "Rebooted kamal-proxy on ${KAMAL_HOSTS}" 3 | mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/post-proxy-reboot 4 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app/.kamal/hooks/pre-proxy-reboot: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo "Rebooting kamal-proxy on ${KAMAL_HOSTS}..." 3 | mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-proxy-reboot 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | gemspec 5 | 6 | group :rubocop do 7 | gem "rubocop-rails-omakase", require: false 8 | end 9 | -------------------------------------------------------------------------------- /test/fixtures/deploy_simple.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | servers: 4 | - "1.1.1.1" 5 | - "1.1.1.2" 6 | registry: 7 | username: user 8 | password: pw 9 | builder: 10 | arch: amd64 11 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app_with_roles/.kamal/hooks/post-proxy-reboot: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo "Rebooted kamal-proxy on ${KAMAL_HOSTS}" 3 | mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/post-proxy-reboot 4 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-proxy-reboot: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo "Rebooting kamal-proxy on ${KAMAL_HOSTS}..." 3 | mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-proxy-reboot 4 | -------------------------------------------------------------------------------- /lib/kamal/configuration/validator/configuration.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Configuration::Validator::Configuration < Kamal::Configuration::Validator 2 | private 3 | def allow_extensions? 4 | true 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /test/integration/docker/registry/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM registry 2 | 3 | COPY boot.sh . 4 | 5 | RUN ln -s /shared/certs /certs 6 | 7 | HEALTHCHECK --interval=1s CMD pgrep registry 8 | 9 | ENTRYPOINT ["./boot.sh"] 10 | -------------------------------------------------------------------------------- /test/fixtures/deploy_for_dest.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | registry: 4 | server: registry.digitalocean.com 5 | username: <%= "my-user" %> 6 | password: <%= "my-password" %> 7 | builder: 8 | arch: amd64 9 | -------------------------------------------------------------------------------- /test/fixtures/deploy_with_assets.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | servers: 4 | - "1.1.1.1" 5 | - "1.1.1.2" 6 | registry: 7 | username: user 8 | password: pw 9 | builder: 10 | arch: amd64 11 | asset_path: /public/assets 12 | -------------------------------------------------------------------------------- /test/fixtures/deploy_with_secrets.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | servers: 4 | - "1.1.1.1" 5 | - "1.1.1.2" 6 | registry: 7 | username: user 8 | password: pw 9 | env: 10 | secret: 11 | - PASSWORD 12 | builder: 13 | arch: amd64 14 | -------------------------------------------------------------------------------- /test/integration/docker/vm/boot.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | while [ ! -f /root/.ssh/authorized_keys ]; do echo "Waiting for ssh keys"; sleep 1; done 4 | 5 | service ssh restart 6 | 7 | dockerd --max-concurrent-downloads 1 & 8 | 9 | exec sleep infinity 10 | -------------------------------------------------------------------------------- /lib/kamal/configuration/validator/servers.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Configuration::Validator::Servers < Kamal::Configuration::Validator 2 | def validate! 3 | validate_type! config, Array, Hash 4 | 5 | validate_servers! config if config.is_a?(Array) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/fixtures/deploy_for_required_dest.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | registry: 4 | server: registry.digitalocean.com 5 | username: <%= "my-user" %> 6 | password: <%= "my-password" %> 7 | builder: 8 | arch: amd64 9 | require_destination: true 10 | -------------------------------------------------------------------------------- /gemfiles/rails_edge.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | git "https://github.com/rails/rails.git" do 5 | gem "railties" 6 | gem "activesupport" 7 | end 8 | 9 | gemspec path: "../" 10 | -------------------------------------------------------------------------------- /lib/kamal/cli.rb: -------------------------------------------------------------------------------- 1 | module Kamal::Cli 2 | class BootError < StandardError; end 3 | class HookError < StandardError; end 4 | class LockError < StandardError; end 5 | end 6 | 7 | # SSHKit uses instance eval, so we need a global const for ergonomics 8 | KAMAL = Kamal::Commander.new 9 | -------------------------------------------------------------------------------- /test/fixtures/deploy_workers_only.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | servers: 4 | workers: 5 | proxy: false 6 | hosts: 7 | - 1.1.1.1 8 | - 1.1.1.2 9 | primary_role: workers 10 | registry: 11 | username: user 12 | password: pw 13 | builder: 14 | arch: amd64 15 | -------------------------------------------------------------------------------- /test/fixtures/deploy.erb.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | servers: 4 | - 1.1.1.1 5 | - 1.1.1.2 6 | env: 7 | REDIS_URL: redis://x/y 8 | registry: 9 | server: registry.digitalocean.com 10 | username: <%= "my-user" %> 11 | password: <%= "my-password" %> 12 | builder: 13 | arch: amd64 14 | -------------------------------------------------------------------------------- /lib/kamal/cli/alias/command.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Cli::Alias::Command < Thor::DynamicCommand 2 | def run(instance, args = []) 3 | if (_alias = KAMAL.config.aliases[name]) 4 | Kamal::Cli::Main.start(Shellwords.split(_alias.command) + ARGV[1..-1]) 5 | else 6 | super 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/kamal/configuration/validator/accessory.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Configuration::Validator::Accessory < Kamal::Configuration::Validator 2 | def validate! 3 | super 4 | 5 | if (config.keys & [ "host", "hosts", "roles" ]).size != 1 6 | error "specify one of `host`, `hosts` or `roles`" 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/fixtures/deploy_with_uncommon_hostnames.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | servers: 4 | - "this-hostname-with-random-part-is-too-long.example.com" 5 | - "this-hostname-is-really-unacceptably-long-to-be-honest.example.com" 6 | registry: 7 | username: user 8 | password: pw 9 | builder: 10 | arch: amd64 11 | -------------------------------------------------------------------------------- /lib/kamal/configuration/validator/role.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Configuration::Validator::Role < Kamal::Configuration::Validator 2 | def validate! 3 | validate_type! config, Array, Hash 4 | 5 | if config.is_a?(Array) 6 | validate_servers! "servers", config 7 | else 8 | super 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/fixtures/deploy_with_boot_strategy.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | servers: 4 | web: 5 | - "1.1.1.1" 6 | - "1.1.1.2" 7 | workers: 8 | - "1.1.1.3" 9 | - "1.1.1.4" 10 | builder: 11 | arch: amd64 12 | 13 | registry: 14 | username: user 15 | password: pw 16 | 17 | boot: 18 | limit: 3 19 | wait: 2 20 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app_with_traefik/.kamal/hooks/pre-deploy: -------------------------------------------------------------------------------- 1 | kamal proxy boot_config set --publish false \ 2 | --docker_options label=traefik.http.services.kamal_proxy.loadbalancer.server.scheme=http \ 3 | label=traefik.http.routers.kamal_proxy.rule=PathPrefix\(\`/\`\) 4 | -------------------------------------------------------------------------------- /test/fixtures/deploy_with_low_percentage_boot_strategy.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | servers: 4 | web: 5 | - "1.1.1.1" 6 | - "1.1.1.2" 7 | workers: 8 | - "1.1.1.3" 9 | - "1.1.1.4" 10 | builder: 11 | arch: amd64 12 | 13 | registry: 14 | username: user 15 | password: pw 16 | 17 | boot: 18 | limit: 1% 19 | wait: 2 20 | -------------------------------------------------------------------------------- /test/fixtures/deploy_with_percentage_boot_strategy.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | servers: 4 | web: 5 | - "1.1.1.1" 6 | - "1.1.1.2" 7 | workers: 8 | - "1.1.1.3" 9 | - "1.1.1.4" 10 | builder: 11 | arch: amd64 12 | 13 | registry: 14 | username: user 15 | password: pw 16 | 17 | boot: 18 | limit: 1% 19 | wait: 2 20 | -------------------------------------------------------------------------------- /lib/kamal/configuration/env/tag.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Configuration::Env::Tag 2 | attr_reader :name, :config, :secrets 3 | 4 | def initialize(name, config:, secrets:) 5 | @name = name 6 | @config = config 7 | @secrets = secrets 8 | end 9 | 10 | def env 11 | Kamal::Configuration::Env.new(config: config, secrets: secrets) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/fixtures/deploy_with_two_roles_one_host.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | servers: 4 | workers: 5 | hosts: 6 | - 1.1.1.1 7 | web: 8 | hosts: 9 | - 1.1.1.1 10 | env: 11 | REDIS_URL: redis://x/y 12 | registry: 13 | server: registry.digitalocean.com 14 | username: user 15 | password: pw 16 | builder: 17 | arch: amd64 18 | -------------------------------------------------------------------------------- /lib/kamal/commands/app/images.rb: -------------------------------------------------------------------------------- 1 | module Kamal::Commands::App::Images 2 | def list_images 3 | docker :image, :ls, config.repository 4 | end 5 | 6 | def remove_images 7 | docker :image, :prune, "--all", "--force", *image_filter_args 8 | end 9 | 10 | def tag_latest_image 11 | docker :tag, config.absolute_image, config.latest_image 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/kamal/secrets/adapters/test.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Secrets::Adapters::Test < Kamal::Secrets::Adapters::Base 2 | private 3 | def login(account) 4 | true 5 | end 6 | 7 | def fetch_secrets(secrets, account:, session:) 8 | secrets.to_h { |secret| [ secret, secret.reverse ] } 9 | end 10 | 11 | def check_dependencies! 12 | # no op 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/fixtures/deploy_with_roles.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | servers: 4 | web: 5 | - 1.1.1.1 6 | - 1.1.1.2 7 | workers: 8 | hosts: 9 | - 1.1.1.3 10 | - 1.1.1.4 11 | env: 12 | REDIS_URL: redis://x/y 13 | registry: 14 | server: registry.digitalocean.com 15 | username: user 16 | password: pw 17 | builder: 18 | arch: amd64 19 | deploy_timeout: 1 20 | -------------------------------------------------------------------------------- /lib/kamal/commands/server.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Commands::Server < Kamal::Commands::Base 2 | def ensure_run_directory 3 | make_directory config.run_directory 4 | end 5 | 6 | def remove_app_directory 7 | remove_directory config.app_directory 8 | end 9 | 10 | def app_directory_count 11 | pipe \ 12 | [ :ls, config.apps_directory ], 13 | [ :wc, "-l" ] 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app/.kamal/secrets: -------------------------------------------------------------------------------- 1 | SECRETS=$(kamal secrets fetch --adapter test --account test INTERPOLATED_SECRET1 INTERPOLATED_SECRET2 INTERPOLATED_中文) 2 | INTERPOLATED_SECRET1=$(kamal secrets extract INTERPOLATED_SECRET1 ${SECRETS}) 3 | INTERPOLATED_SECRET2=$(kamal secrets extract INTERPOLATED_SECRET2 ${SECRETS}) 4 | INTERPOLATED_SECRET3=$(kamal secrets extract INTERPOLATED_中文 ${SECRETS}) 5 | -------------------------------------------------------------------------------- /lib/kamal.rb: -------------------------------------------------------------------------------- 1 | module Kamal 2 | class ConfigurationError < StandardError; end 3 | end 4 | 5 | require "active_support" 6 | require "zeitwerk" 7 | require "yaml" 8 | require "tmpdir" 9 | require "pathname" 10 | 11 | loader = Zeitwerk::Loader.for_gem 12 | loader.ignore(File.join(__dir__, "kamal", "sshkit_with_ext.rb")) 13 | loader.setup 14 | loader.eager_load_namespace(Kamal::Cli) # We need all commands loaded. 15 | -------------------------------------------------------------------------------- /bin/release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | VERSION=$1 4 | 5 | printf "module Kamal\n VERSION = \"$VERSION\"\nend\n" > ./lib/kamal/version.rb 6 | bundle 7 | git add Gemfile.lock lib/kamal/version.rb 8 | git commit -m "Bump version for $VERSION" 9 | git push 10 | git tag v$VERSION 11 | git push --tags 12 | gem build kamal.gemspec 13 | gem push "kamal-$VERSION.gem" --host https://rubygems.org 14 | rm "kamal-$VERSION.gem" 15 | -------------------------------------------------------------------------------- /lib/kamal/cli/templates/sample_hooks/post-deploy.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # A sample post-deploy hook 4 | # 5 | # These environment variables are available: 6 | # KAMAL_RECORDED_AT 7 | # KAMAL_PERFORMER 8 | # KAMAL_VERSION 9 | # KAMAL_HOSTS 10 | # KAMAL_ROLE (if set) 11 | # KAMAL_DESTINATION (if set) 12 | # KAMAL_RUNTIME 13 | 14 | echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" 15 | -------------------------------------------------------------------------------- /lib/kamal/commands/builder/local.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Commands::Builder::Local < Kamal::Commands::Builder::Base 2 | def create 3 | docker :buildx, :create, "--name", builder_name, "--driver=#{driver}" unless docker_driver? 4 | end 5 | 6 | def remove 7 | docker :buildx, :rm, builder_name unless docker_driver? 8 | end 9 | 10 | private 11 | def builder_name 12 | "kamal-local-#{driver}" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/kamal/commands/registry.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Commands::Registry < Kamal::Commands::Base 2 | delegate :registry, to: :config 3 | 4 | def login 5 | docker :login, 6 | registry.server, 7 | "-u", sensitive(Kamal::Utils.escape_shell_value(registry.username)), 8 | "-p", sensitive(Kamal::Utils.escape_shell_value(registry.password)) 9 | end 10 | 11 | def logout 12 | docker :logout, registry.server 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/integration/docker/load_balancer/default.conf: -------------------------------------------------------------------------------- 1 | upstream loadbalancer { 2 | server vm1:80; 3 | server vm2:80; 4 | } 5 | 6 | server { 7 | listen 80; 8 | 9 | location / { 10 | proxy_pass http://loadbalancer; 11 | proxy_set_header Host $host; 12 | 13 | proxy_connect_timeout 10; 14 | proxy_send_timeout 10; 15 | proxy_read_timeout 10; 16 | send_timeout 10; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/kamal/commands/accessory/proxy.rb: -------------------------------------------------------------------------------- 1 | module Kamal::Commands::Accessory::Proxy 2 | delegate :proxy_container_name, to: :config 3 | 4 | def deploy(target:) 5 | proxy_exec :deploy, service_name, *proxy.deploy_command_args(target: target) 6 | end 7 | 8 | def remove 9 | proxy_exec :remove, service_name 10 | end 11 | 12 | private 13 | def proxy_exec(*command) 14 | docker :exec, proxy_container_name, "kamal-proxy", *command 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/kamal/configuration/alias.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Configuration::Alias 2 | include Kamal::Configuration::Validation 3 | 4 | attr_reader :name, :command 5 | 6 | def initialize(name, config:) 7 | @name, @command = name.inquiry, config.raw_config["aliases"][name] 8 | 9 | validate! \ 10 | command, 11 | example: validation_yml["aliases"]["uname"], 12 | context: "aliases/#{name}", 13 | with: Kamal::Configuration::Validator::Alias 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/fixtures/deploy_primary_web_role_override.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | servers: 4 | web_chicago: 5 | proxy: {} 6 | hosts: 7 | - 1.1.1.1 8 | - 1.1.1.2 9 | web_tokyo: 10 | proxy: {} 11 | hosts: 12 | - 1.1.1.3 13 | - 1.1.1.4 14 | env: 15 | REDIS_URL: redis://x/y 16 | registry: 17 | server: registry.digitalocean.com 18 | username: user 19 | password: pw 20 | builder: 21 | arch: amd64 22 | primary_role: web_tokyo 23 | -------------------------------------------------------------------------------- /lib/kamal/commands/app/proxy.rb: -------------------------------------------------------------------------------- 1 | module Kamal::Commands::App::Proxy 2 | delegate :proxy_container_name, to: :config 3 | 4 | def deploy(target:) 5 | proxy_exec :deploy, role.container_prefix, *role.proxy.deploy_command_args(target: target) 6 | end 7 | 8 | def remove 9 | proxy_exec :remove, role.container_prefix 10 | end 11 | 12 | private 13 | def proxy_exec(*command) 14 | docker :exec, proxy_container_name, "kamal-proxy", *command 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM registry:4443/nginx:1-alpine-slim 2 | 3 | COPY default.conf /etc/nginx/conf.d/default.conf 4 | 5 | ARG COMMIT_SHA 6 | RUN echo $COMMIT_SHA > /usr/share/nginx/html/version 7 | RUN mkdir -p /usr/share/nginx/html/versions && echo "version" > /usr/share/nginx/html/versions/$COMMIT_SHA 8 | RUN mkdir -p /usr/share/nginx/html/versions && echo "hidden" > /usr/share/nginx/html/versions/.hidden 9 | RUN echo "Up!" > /usr/share/nginx/html/up 10 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | listen [::]:80; 4 | server_name localhost; 5 | 6 | location / { 7 | root /usr/share/nginx/html; 8 | index index.html index.htm; 9 | } 10 | 11 | # redirect server error pages to the static page /50x.html 12 | # 13 | error_page 500 502 503 504 /50x.html; 14 | location = /50x.html { 15 | root /usr/share/nginx/html; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/git_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class GitTest < ActiveSupport::TestCase 4 | test "uncommitted changes exist" do 5 | Kamal::Git.expects(:`).with("git status --porcelain").returns("M file\n") 6 | assert_equal "M file", Kamal::Git.uncommitted_changes 7 | end 8 | 9 | test "uncommitted changes do not exist" do 10 | Kamal::Git.expects(:`).with("git status --porcelain").returns("") 11 | assert_equal "", Kamal::Git.uncommitted_changes 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app_with_roles/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM registry:4443/nginx:1-alpine-slim 2 | 3 | COPY default.conf /etc/nginx/conf.d/default.conf 4 | 5 | ARG COMMIT_SHA 6 | RUN echo $COMMIT_SHA > /usr/share/nginx/html/version 7 | RUN mkdir -p /usr/share/nginx/html/versions && echo "version" > /usr/share/nginx/html/versions/$COMMIT_SHA 8 | RUN mkdir -p /usr/share/nginx/html/versions && echo "hidden" > /usr/share/nginx/html/versions/.hidden 9 | RUN echo "Up!" > /usr/share/nginx/html/up 10 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app_with_roles/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | listen [::]:80; 4 | server_name localhost; 5 | 6 | location / { 7 | root /usr/share/nginx/html; 8 | index index.html index.htm; 9 | } 10 | 11 | # redirect server error pages to the static page /50x.html 12 | # 13 | error_page 500 502 503 504 /50x.html; 14 | location = /50x.html { 15 | root /usr/share/nginx/html; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app_with_traefik/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM registry:4443/nginx:1-alpine-slim 2 | 3 | COPY default.conf /etc/nginx/conf.d/default.conf 4 | 5 | ARG COMMIT_SHA 6 | RUN echo $COMMIT_SHA > /usr/share/nginx/html/version 7 | RUN mkdir -p /usr/share/nginx/html/versions && echo "version" > /usr/share/nginx/html/versions/$COMMIT_SHA 8 | RUN mkdir -p /usr/share/nginx/html/versions && echo "hidden" > /usr/share/nginx/html/versions/.hidden 9 | RUN echo "Up!" > /usr/share/nginx/html/up 10 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app_with_traefik/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | listen [::]:80; 4 | server_name localhost; 5 | 6 | location / { 7 | root /usr/share/nginx/html; 8 | index index.html index.htm; 9 | } 10 | 11 | # redirect server error pages to the static page /50x.html 12 | # 13 | error_page 500 502 503 504 /50x.html; 14 | location = /50x.html { 15 | root /usr/share/nginx/html; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /bin/kamal: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # Prevent failures from being reported twice. 4 | Thread.report_on_exception = false 5 | 6 | require "kamal" 7 | 8 | begin 9 | Kamal::Cli::Main.start(ARGV) 10 | rescue SSHKit::Runner::ExecuteError => e 11 | puts " \e[31mERROR (#{e.cause.class}): #{e.message}\e[0m" 12 | puts e.cause.backtrace if ENV["VERBOSE"] 13 | exit 1 14 | rescue => e 15 | puts " \e[31mERROR (#{e.class}): #{e.message}\e[0m" 16 | puts e.backtrace if ENV["VERBOSE"] 17 | exit 1 18 | end 19 | -------------------------------------------------------------------------------- /lib/kamal/commands/hook.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Commands::Hook < Kamal::Commands::Base 2 | def run(hook) 3 | [ hook_file(hook) ] 4 | end 5 | 6 | def env(secrets: false, **details) 7 | tags(**details).env.tap do |env| 8 | env.merge!(config.secrets.to_h) if secrets 9 | end 10 | end 11 | 12 | def hook_exists?(hook) 13 | Pathname.new(hook_file(hook)).exist? 14 | end 15 | 16 | private 17 | def hook_file(hook) 18 | File.join(config.hooks_path, hook) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/kamal/git.rb: -------------------------------------------------------------------------------- 1 | module Kamal::Git 2 | extend self 3 | 4 | def used? 5 | system("git rev-parse") 6 | end 7 | 8 | def user_name 9 | `git config user.name`.strip 10 | end 11 | 12 | def email 13 | `git config user.email`.strip 14 | end 15 | 16 | def revision 17 | `git rev-parse HEAD`.strip 18 | end 19 | 20 | def uncommitted_changes 21 | `git status --porcelain`.strip 22 | end 23 | 24 | def root 25 | `git rev-parse --show-toplevel`.strip 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app_with_proxied_accessory/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM registry:4443/nginx:1-alpine-slim 2 | 3 | COPY default.conf /etc/nginx/conf.d/default.conf 4 | 5 | ARG COMMIT_SHA 6 | RUN echo $COMMIT_SHA > /usr/share/nginx/html/version 7 | RUN mkdir -p /usr/share/nginx/html/versions && echo "version" > /usr/share/nginx/html/versions/$COMMIT_SHA 8 | RUN mkdir -p /usr/share/nginx/html/versions && echo "hidden" > /usr/share/nginx/html/versions/.hidden 9 | RUN echo "Up!" > /usr/share/nginx/html/up 10 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app_with_proxied_accessory/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | listen [::]:80; 4 | server_name localhost; 5 | 6 | location / { 7 | root /usr/share/nginx/html; 8 | index index.html index.htm; 9 | } 10 | 11 | # redirect server error pages to the static page /50x.html 12 | # 13 | error_page 500 502 503 504 /50x.html; 14 | location = /50x.html { 15 | root /usr/share/nginx/html; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /lib/kamal/configuration/validator/proxy.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Configuration::Validator::Proxy < Kamal::Configuration::Validator 2 | def validate! 3 | unless config.nil? 4 | super 5 | 6 | if config["host"].blank? && config["hosts"].blank? && config["ssl"] 7 | error "Must set a host to enable automatic SSL" 8 | end 9 | 10 | if (config.keys & [ "host", "hosts" ]).size > 1 11 | error "Specify one of 'host' or 'hosts', not both" 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/fixtures/deploy_with_extensions.yml: -------------------------------------------------------------------------------- 1 | 2 | x-web: &web 3 | proxy: {} 4 | 5 | service: app 6 | image: dhh/app 7 | servers: 8 | web_chicago: 9 | <<: *web 10 | hosts: 11 | - 1.1.1.1 12 | - 1.1.1.2 13 | web_tokyo: 14 | <<: *web 15 | hosts: 16 | - 1.1.1.3 17 | - 1.1.1.4 18 | env: 19 | REDIS_URL: redis://x/y 20 | registry: 21 | server: registry.digitalocean.com 22 | username: user 23 | password: pw 24 | builder: 25 | arch: amd64 26 | primary_role: web_tokyo 27 | -------------------------------------------------------------------------------- /lib/kamal/secrets/adapters.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/string/inflections" 2 | module Kamal::Secrets::Adapters 3 | def self.lookup(name) 4 | name = "one_password" if name.downcase == "1password" 5 | name = "last_pass" if name.downcase == "lastpass" 6 | adapter_class(name) 7 | end 8 | 9 | def self.adapter_class(name) 10 | Object.const_get("Kamal::Secrets::Adapters::#{name.camelize}").new 11 | rescue NameError => e 12 | raise RuntimeError, "Unknown secrets adapter: #{name}" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/fixtures/deploy_with_aliases.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | servers: 4 | web: 5 | - 1.1.1.1 6 | - 1.1.1.2 7 | workers: 8 | hosts: 9 | - 1.1.1.3 10 | - 1.1.1.4 11 | console: 12 | hosts: 13 | - 1.1.1.5 14 | builder: 15 | arch: amd64 16 | registry: 17 | username: user 18 | password: pw 19 | aliases: 20 | info: details 21 | console: app exec --reuse -p -r console "bin/console" 22 | exec: app exec --reuse -p -r console 23 | rails: app exec --reuse -p -r console rails 24 | -------------------------------------------------------------------------------- /lib/kamal/configuration/validator/alias.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Configuration::Validator::Alias < Kamal::Configuration::Validator 2 | def validate! 3 | super 4 | 5 | name = context.delete_prefix("aliases/") 6 | 7 | if name !~ /\A[a-z0-9_-]+\z/ 8 | error "Invalid alias name: '#{name}'. Must only contain lowercase letters, alphanumeric, hyphens and underscores." 9 | end 10 | 11 | if Kamal::Cli::Main.commands.include?(name) 12 | error "Alias '#{name}' conflicts with a built-in command." 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/kamal/configuration/sshkit.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Configuration::Sshkit 2 | include Kamal::Configuration::Validation 3 | 4 | attr_reader :sshkit_config 5 | 6 | def initialize(config:) 7 | @sshkit_config = config.raw_config.sshkit || {} 8 | validate! sshkit_config 9 | end 10 | 11 | def max_concurrent_starts 12 | sshkit_config.fetch("max_concurrent_starts", 30) 13 | end 14 | 15 | def pool_idle_timeout 16 | sshkit_config.fetch("pool_idle_timeout", 900) 17 | end 18 | 19 | def to_h 20 | sshkit_config 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/integration/docker/vm/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:22.04 2 | 3 | WORKDIR /work 4 | 5 | RUN apt-get update --fix-missing && apt-get -y install openssh-client openssh-server docker.io 6 | 7 | RUN mkdir /root/.ssh && ln -s /shared/ssh/id_rsa.pub /root/.ssh/authorized_keys 8 | RUN mkdir -p /etc/docker/certs.d/registry:4443 && ln -s /shared/certs/domain.crt /etc/docker/certs.d/registry:4443/ca.crt 9 | 10 | RUN echo "HOST_TOKEN=abcd" >> /etc/environment 11 | 12 | COPY boot.sh . 13 | 14 | HEALTHCHECK --interval=1s CMD pgrep dockerd 15 | 16 | CMD ["./boot.sh"] 17 | -------------------------------------------------------------------------------- /lib/kamal/configuration/validator/builder.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Configuration::Validator::Builder < Kamal::Configuration::Validator 2 | def validate! 3 | super 4 | 5 | if config["cache"] && config["cache"]["type"] 6 | error "Invalid cache type: #{config["cache"]["type"]}" unless [ "gha", "registry" ].include?(config["cache"]["type"]) 7 | end 8 | 9 | error "Builder arch not set" unless config["arch"].present? 10 | 11 | error "Cannot disable local builds, no remote is set" if config["local"] == false && config["remote"].blank? 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/integration/docker/shared/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:22.04 2 | 3 | WORKDIR /work 4 | 5 | RUN apt-get update --fix-missing && apt-get -y install openssh-client openssl 6 | 7 | RUN mkdir ssh && \ 8 | ssh-keygen -t rsa -f ssh/id_rsa -N "" 9 | 10 | COPY registry-dns.conf . 11 | COPY boot.sh . 12 | 13 | RUN mkdir certs && openssl req -newkey rsa:4096 -nodes -sha256 -keyout certs/domain.key -x509 -days 365 -out certs/domain.crt -subj '/CN=registry' -extensions EXT -config registry-dns.conf 14 | 15 | HEALTHCHECK --interval=1s CMD pgrep sleep 16 | 17 | CMD ["./boot.sh"] 18 | -------------------------------------------------------------------------------- /test/configuration/volume_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ConfigurationVolumeTest < ActiveSupport::TestCase 4 | test "docker args absolute" do 5 | volume = Kamal::Configuration::Volume.new(host_path: "/root/foo/bar", container_path: "/assets") 6 | assert_equal [ "--volume", "/root/foo/bar:/assets" ], volume.docker_args 7 | end 8 | 9 | test "docker args relative" do 10 | volume = Kamal::Configuration::Volume.new(host_path: "foo/bar", container_path: "/assets") 11 | assert_equal [ "--volume", "$(pwd)/foo/bar:/assets" ], volume.docker_args 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/kamal/configuration/boot.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Configuration::Boot 2 | include Kamal::Configuration::Validation 3 | 4 | attr_reader :boot_config, :host_count 5 | 6 | def initialize(config:) 7 | @boot_config = config.raw_config.boot || {} 8 | @host_count = config.all_hosts.count 9 | validate! boot_config 10 | end 11 | 12 | def limit 13 | limit = boot_config["limit"] 14 | 15 | if limit.to_s.end_with?("%") 16 | [ host_count * limit.to_i / 100, 1 ].max 17 | else 18 | limit 19 | end 20 | end 21 | 22 | def wait 23 | boot_config["wait"] 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/kamal/configuration/docs/logging.yml: -------------------------------------------------------------------------------- 1 | # Custom logging configuration 2 | # 3 | # Set these to control the Docker logging driver and options. 4 | 5 | # Logging settings 6 | # 7 | # These go under the logging key in the configuration file. 8 | # 9 | # This can be specified at the root level or for a specific role. 10 | logging: 11 | 12 | # Driver 13 | # 14 | # The logging driver to use, passed to Docker via `--log-driver`: 15 | driver: json-file 16 | 17 | # Options 18 | # 19 | # Any logging options to pass to the driver, passed to Docker via `--log-opt`: 20 | options: 21 | max-size: 100m 22 | -------------------------------------------------------------------------------- /lib/kamal/utils/sensitive.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/module/delegation" 2 | require "sshkit" 3 | 4 | class Kamal::Utils::Sensitive 5 | # So SSHKit knows to redact these values. 6 | include SSHKit::Redaction 7 | 8 | attr_reader :unredacted, :redaction 9 | delegate :to_s, to: :unredacted 10 | delegate :inspect, to: :redaction 11 | 12 | def initialize(value, redaction: "[REDACTED]") 13 | @unredacted, @redaction = value, redaction 14 | end 15 | 16 | # Sensitive values won't leak into YAML output. 17 | def encode_with(coder) 18 | coder.represent_scalar nil, redaction 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/kamal/configuration/servers.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Configuration::Servers 2 | include Kamal::Configuration::Validation 3 | 4 | attr_reader :config, :servers_config, :roles 5 | 6 | def initialize(config:) 7 | @config = config 8 | @servers_config = config.raw_config.servers 9 | validate! servers_config, with: Kamal::Configuration::Validator::Servers 10 | 11 | @roles = role_names.map { |role_name| Kamal::Configuration::Role.new role_name, config: config } 12 | end 13 | 14 | private 15 | def role_names 16 | servers_config.is_a?(Array) ? [ "web" ] : servers_config.keys.sort 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/fixtures/deploy_with_single_accessory.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | servers: 4 | web: 5 | - "1.1.1.1" 6 | - "1.1.1.2" 7 | workers: 8 | - "1.1.1.3" 9 | - "1.1.1.4" 10 | registry: 11 | username: user 12 | password: pw 13 | builder: 14 | arch: amd64 15 | 16 | accessories: 17 | mysql: 18 | image: mysql:5.7 19 | host: 1.1.1.5 20 | port: 3306 21 | env: 22 | clear: 23 | MYSQL_ROOT_HOST: '%' 24 | secret: 25 | - MYSQL_ROOT_PASSWORD 26 | files: 27 | - test/fixtures/files/my.cnf:/etc/mysql/my.cnf 28 | directories: 29 | - data:/var/lib/mysql 30 | -------------------------------------------------------------------------------- /lib/kamal/configuration/docs/boot.yml: -------------------------------------------------------------------------------- 1 | # Booting 2 | # 3 | # When deploying to large numbers of hosts, you might prefer not to restart your services on every host at the same time. 4 | # 5 | # Kamal’s default is to boot new containers on all hosts in parallel. However, you can control this with the boot configuration. 6 | 7 | # Fixed group sizes 8 | # 9 | # Here, we boot 2 hosts at a time with a 10-second gap between each group: 10 | boot: 11 | limit: 2 12 | wait: 10 13 | 14 | # Percentage of hosts 15 | # 16 | # Here, we boot 25% of the hosts at a time with a 2-second gap between each group: 17 | boot: 18 | limit: 25% 19 | wait: 2 20 | -------------------------------------------------------------------------------- /lib/kamal/configuration/volume.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Configuration::Volume 2 | attr_reader :host_path, :container_path 3 | delegate :argumentize, to: Kamal::Utils 4 | 5 | def initialize(host_path:, container_path:) 6 | @host_path = host_path 7 | @container_path = container_path 8 | end 9 | 10 | def docker_args 11 | argumentize "--volume", "#{host_path_for_docker_volume}:#{container_path}" 12 | end 13 | 14 | private 15 | def host_path_for_docker_volume 16 | if Pathname.new(host_path).absolute? 17 | host_path 18 | else 19 | File.join "$(pwd)", host_path 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/commands/server_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class CommandsServerTest < ActiveSupport::TestCase 4 | setup do 5 | @config = { 6 | service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], 7 | builder: { "arch" => "amd64" } 8 | } 9 | end 10 | 11 | test "ensure run directory" do 12 | assert_equal "mkdir -p .kamal", new_command.ensure_run_directory.join(" ") 13 | end 14 | 15 | private 16 | def new_command(extra_config = {}) 17 | Kamal::Commands::Server.new(Kamal::Configuration.new(@config.merge(extra_config))) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/kamal/cli/healthcheck/barrier.rb: -------------------------------------------------------------------------------- 1 | require "concurrent/ivar" 2 | 3 | class Kamal::Cli::Healthcheck::Barrier 4 | def initialize 5 | @ivar = Concurrent::IVar.new 6 | end 7 | 8 | def close 9 | set(false) 10 | end 11 | 12 | def open 13 | set(true) 14 | end 15 | 16 | def wait 17 | unless opened? 18 | raise Kamal::Cli::Healthcheck::Error.new("Halted at barrier") 19 | end 20 | end 21 | 22 | private 23 | def opened? 24 | @ivar.value 25 | end 26 | 27 | def set(value) 28 | @ivar.set(value) 29 | true 30 | rescue Concurrent::MultipleAssignmentError 31 | false 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/fixtures/deploy_with_env_tags.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | servers: 4 | web: 5 | - 1.1.1.1: site1 6 | - 1.1.1.2: [ site1 experimental ] 7 | - 1.2.1.1: site2 8 | - 1.2.1.2: site2 9 | workers: 10 | - 1.1.1.3: site1 11 | - 1.1.1.4: site1 12 | - 1.2.1.3: site2 13 | - 1.2.1.4: [ site2 experimental ] 14 | builder: 15 | arch: amd64 16 | env: 17 | clear: 18 | TEST: "root" 19 | EXPERIMENT: "disabled" 20 | tags: 21 | site1: 22 | SITE: site1 23 | site2: 24 | SITE: site2 25 | experimental: 26 | EXPERIMENT: "enabled" 27 | 28 | registry: 29 | username: user 30 | password: pw 31 | -------------------------------------------------------------------------------- /test/cli/lock_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "cli_test_case" 2 | 3 | class CliLockTest < CliTestCase 4 | test "status" do 5 | run_command("status").tap do |output| 6 | assert_match "Running /usr/bin/env stat .kamal/lock-app > /dev/null && cat .kamal/lock-app/details | base64 -d on 1.1.1.1", output 7 | end 8 | end 9 | 10 | test "release" do 11 | run_command("release").tap do |output| 12 | assert_match "Released the deploy lock", output 13 | end 14 | end 15 | 16 | private 17 | def run_command(*command) 18 | stdouted { Kamal::Cli::Lock.start([ *command, "-v", "-c", "test/fixtures/deploy_with_accessories.yml" ]) } 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/integration/lock_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "integration_test" 2 | 3 | class LockTest < IntegrationTest 4 | test "acquire, release, status" do 5 | kamal :lock, :acquire, "-m 'Integration Tests'" 6 | 7 | status = kamal :lock, :status, capture: true 8 | assert_match /Locked by: Deployer at .*\nVersion: #{latest_app_version}\nMessage: Integration Tests/m, status 9 | 10 | error = kamal :deploy, capture: true, raise_on_error: false 11 | assert_match /Deploy lock found. Run 'kamal lock help' for more information/m, error 12 | 13 | kamal :lock, :release 14 | 15 | status = kamal :lock, :status, capture: true 16 | assert_match /There is no deploy lock/m, status 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/kamal/cli/app/prepare_assets.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Cli::App::PrepareAssets 2 | attr_reader :host, :role, :sshkit 3 | delegate :execute, :capture_with_info, :info, to: :sshkit 4 | delegate :assets?, to: :role 5 | 6 | def initialize(host, role, sshkit) 7 | @host = host 8 | @role = role 9 | @sshkit = sshkit 10 | end 11 | 12 | def run 13 | if assets? 14 | execute *app.extract_assets 15 | old_version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip 16 | execute *app.sync_asset_volumes(old_version: old_version) 17 | end 18 | end 19 | 20 | private 21 | def app 22 | @app ||= KAMAL.app(role: role, host: host) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/kamal/commands/builder/hybrid.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Commands::Builder::Hybrid < Kamal::Commands::Builder::Remote 2 | def create 3 | combine \ 4 | create_local_buildx, 5 | create_remote_context, 6 | append_remote_buildx 7 | end 8 | 9 | private 10 | def builder_name 11 | "kamal-hybrid-#{driver}-#{remote.gsub(/[^a-z0-9_-]/, "-")}" 12 | end 13 | 14 | def create_local_buildx 15 | docker :buildx, :create, *platform_options(local_arches), "--name", builder_name, "--driver=#{driver}" 16 | end 17 | 18 | def append_remote_buildx 19 | docker :buildx, :create, *platform_options(remote_arches), "--append", "--name", builder_name, remote_context_name 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app_with_traefik/config/deploy.yml: -------------------------------------------------------------------------------- 1 | service: app_with_traefik 2 | image: app_with_traefik 3 | servers: 4 | - vm1 5 | - vm2 6 | deploy_timeout: 2 7 | drain_timeout: 2 8 | readiness_delay: 0 9 | 10 | registry: 11 | server: registry:4443 12 | username: root 13 | password: root 14 | builder: 15 | driver: docker 16 | arch: <%= Kamal::Utils.docker_arch %> 17 | args: 18 | COMMIT_SHA: <%= `git rev-parse HEAD` %> 19 | accessories: 20 | traefik: 21 | service: traefik 22 | image: traefik:v2.10 23 | port: 80 24 | cmd: "--providers.docker" 25 | options: 26 | volume: 27 | - "/var/run/docker.sock:/var/run/docker.sock" 28 | roles: 29 | - web 30 | -------------------------------------------------------------------------------- /test/fixtures/deploy_with_multiple_proxy_roles.yml: -------------------------------------------------------------------------------- 1 | # actual config 2 | service: app 3 | image: dhh/app 4 | servers: 5 | web: 6 | hosts: 7 | - 1.1.1.1 8 | - 1.1.1.2 9 | env: 10 | ROLE: "web" 11 | proxy: true 12 | web_tokyo: 13 | hosts: 14 | - 1.1.1.3 15 | - 1.1.1.4 16 | env: 17 | ROLE: "web" 18 | proxy: true 19 | workers: 20 | cmd: bin/jobs 21 | hosts: 22 | - 1.1.1.1 23 | - 1.1.1.2 24 | workers_tokyo: 25 | cmd: bin/jobs 26 | hosts: 27 | - 1.1.1.3 28 | - 1.1.1.4 29 | builder: 30 | arch: amd64 31 | env: 32 | REDIS_URL: redis://x/y 33 | registry: 34 | server: registry.digitalocean.com 35 | username: user 36 | password: pw 37 | -------------------------------------------------------------------------------- /lib/kamal/configuration/docs/alias.yml: -------------------------------------------------------------------------------- 1 | # Aliases 2 | # 3 | # Aliases are shortcuts for Kamal commands. 4 | # 5 | # For example, for a Rails app, you might open a console with: 6 | # 7 | # ```shell 8 | # kamal app exec -i -r console "rails console" 9 | # ``` 10 | # 11 | # By defining an alias, like this: 12 | aliases: 13 | console: app exec -r console -i "rails console" 14 | # You can now open the console with: 15 | # 16 | # ```shell 17 | # kamal console 18 | # ``` 19 | 20 | # Configuring aliases 21 | # 22 | # Aliases are defined in the root config under the alias key. 23 | # 24 | # Each alias is named and can only contain lowercase letters, numbers, dashes, and underscores: 25 | aliases: 26 | uname: app exec -p -q -r web "uname -a" 27 | -------------------------------------------------------------------------------- /test/fixtures/deploy_with_accessories.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | servers: 4 | web: 5 | - "1.1.1.1" 6 | - "1.1.1.2" 7 | workers: 8 | - "1.1.1.3" 9 | - "1.1.1.4" 10 | registry: 11 | username: user 12 | password: pw 13 | builder: 14 | arch: amd64 15 | 16 | accessories: 17 | mysql: 18 | image: mysql:5.7 19 | host: 1.1.1.3 20 | port: 3306 21 | env: 22 | clear: 23 | MYSQL_ROOT_HOST: '%' 24 | secret: 25 | - MYSQL_ROOT_PASSWORD 26 | files: 27 | - test/fixtures/files/my.cnf:/etc/mysql/my.cnf 28 | directories: 29 | - data:/var/lib/mysql 30 | redis: 31 | image: redis:latest 32 | roles: 33 | - web 34 | port: 6379 35 | directories: 36 | - data:/data 37 | 38 | readiness_delay: 0 39 | -------------------------------------------------------------------------------- /lib/kamal/configuration/registry.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Configuration::Registry 2 | include Kamal::Configuration::Validation 3 | 4 | attr_reader :registry_config, :secrets 5 | 6 | def initialize(config:) 7 | @registry_config = config.raw_config.registry || {} 8 | @secrets = config.secrets 9 | validate! registry_config, with: Kamal::Configuration::Validator::Registry 10 | end 11 | 12 | def server 13 | registry_config["server"] 14 | end 15 | 16 | def username 17 | lookup("username") 18 | end 19 | 20 | def password 21 | lookup("password") 22 | end 23 | 24 | private 25 | def lookup(key) 26 | if registry_config[key].is_a?(Array) 27 | secrets[registry_config[key].first] 28 | else 29 | registry_config[key] 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/fixtures/deploy_with_accessories_on_independent_server.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | servers: 4 | web: 5 | - "1.1.1.1" 6 | - "1.1.1.2" 7 | workers: 8 | - "1.1.1.3" 9 | - "1.1.1.4" 10 | registry: 11 | username: user 12 | password: pw 13 | builder: 14 | arch: amd64 15 | 16 | accessories: 17 | mysql: 18 | image: mysql:5.7 19 | host: 1.1.1.5 20 | port: 3306 21 | env: 22 | clear: 23 | MYSQL_ROOT_HOST: '%' 24 | secret: 25 | - MYSQL_ROOT_PASSWORD 26 | files: 27 | - test/fixtures/files/my.cnf:/etc/mysql/my.cnf 28 | directories: 29 | - data:/var/lib/mysql 30 | redis: 31 | image: redis:latest 32 | roles: 33 | - web 34 | port: 6379 35 | directories: 36 | - data:/data 37 | 38 | readiness_delay: 0 39 | -------------------------------------------------------------------------------- /test/fixtures/deploy_with_proxy.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | servers: 4 | web: 5 | - "1.1.1.1" 6 | - "1.1.1.2" 7 | workers: 8 | - "1.1.1.3" 9 | - "1.1.1.4" 10 | registry: 11 | username: user 12 | password: pw 13 | builder: 14 | arch: amd64 15 | 16 | 17 | accessories: 18 | mysql: 19 | image: mysql:5.7 20 | host: 1.1.1.3 21 | port: 3306 22 | env: 23 | clear: 24 | MYSQL_ROOT_HOST: '%' 25 | secret: 26 | - MYSQL_ROOT_PASSWORD 27 | files: 28 | - test/fixtures/files/my.cnf:/etc/mysql/my.cnf 29 | directories: 30 | - data:/var/lib/mysql 31 | redis: 32 | image: redis:latest 33 | roles: 34 | - web 35 | port: 6379 36 | directories: 37 | - data:/data 38 | 39 | readiness_delay: 0 40 | deploy_timeout: 6 41 | -------------------------------------------------------------------------------- /test/fixtures/deploy_without_clone.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | servers: 4 | web: 5 | - "1.1.1.1" 6 | - "1.1.1.2" 7 | workers: 8 | - "1.1.1.3" 9 | - "1.1.1.4" 10 | registry: 11 | username: user 12 | password: pw 13 | 14 | accessories: 15 | mysql: 16 | image: mysql:5.7 17 | host: 1.1.1.3 18 | port: 3306 19 | env: 20 | clear: 21 | MYSQL_ROOT_HOST: '%' 22 | secret: 23 | - MYSQL_ROOT_PASSWORD 24 | files: 25 | - test/fixtures/files/my.cnf:/etc/mysql/my.cnf 26 | directories: 27 | - data:/var/lib/mysql 28 | redis: 29 | image: redis:latest 30 | roles: 31 | - web 32 | port: 6379 33 | directories: 34 | - data:/data 35 | 36 | readiness_delay: 0 37 | 38 | builder: 39 | arch: amd64 40 | context: "." 41 | -------------------------------------------------------------------------------- /test/secrets/dotenv_inline_command_substitution_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class SecretsInlineCommandSubstitution < SecretAdapterTestCase 4 | test "inlines kamal secrets commands" do 5 | Kamal::Cli::Main.expects(:start).with { |command| command == [ "secrets", "fetch", "...", "--inline" ] }.returns("results") 6 | substituted = Kamal::Secrets::Dotenv::InlineCommandSubstitution.call("FOO=$(kamal secrets fetch ...)", nil, overwrite: false) 7 | assert_equal "FOO=results", substituted 8 | end 9 | 10 | test "executes other commands" do 11 | Kamal::Secrets::Dotenv::InlineCommandSubstitution.stubs(:`).with("blah").returns("results") 12 | substituted = Kamal::Secrets::Dotenv::InlineCommandSubstitution.call("FOO=$(blah)", nil, overwrite: false) 13 | assert_equal "FOO=results", substituted 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/kamal/secrets/adapters/base.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Secrets::Adapters::Base 2 | delegate :optionize, to: Kamal::Utils 3 | 4 | def fetch(secrets, account: nil, from: nil) 5 | raise RuntimeError, "Missing required option '--account'" if requires_account? && account.blank? 6 | 7 | check_dependencies! 8 | 9 | session = login(account) 10 | full_secrets = secrets.map { |secret| [ from, secret ].compact.join("/") } 11 | fetch_secrets(full_secrets, account: account, session: session) 12 | end 13 | 14 | def requires_account? 15 | true 16 | end 17 | 18 | private 19 | def login(...) 20 | raise NotImplementedError 21 | end 22 | 23 | def fetch_secrets(...) 24 | raise NotImplementedError 25 | end 26 | 27 | def check_dependencies! 28 | raise NotImplementedError 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/kamal/configuration/docs/servers.yml: -------------------------------------------------------------------------------- 1 | # Servers 2 | # 3 | # Servers are split into different roles, with each role having its own configuration. 4 | # 5 | # For simpler deployments, though, where all servers are identical, you can just specify a list of servers. 6 | # They will be implicitly assigned to the `web` role. 7 | servers: 8 | - 172.0.0.1 9 | - 172.0.0.2 10 | - 172.0.0.3 11 | 12 | # Tagging servers 13 | # 14 | # Servers can be tagged, with the tags used to add custom env variables (see kamal docs env). 15 | servers: 16 | - 172.0.0.1 17 | - 172.0.0.2: experiments 18 | - 172.0.0.3: [ experiments, three ] 19 | 20 | # Roles 21 | # 22 | # For more complex deployments (e.g., if you are running job hosts), you can specify roles and configure each separately (see kamal docs role): 23 | servers: 24 | web: 25 | ... 26 | workers: 27 | ... 28 | -------------------------------------------------------------------------------- /test/fixtures/deploy_with_hybrid_builder.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | servers: 4 | web: 5 | - "1.1.1.1" 6 | - "1.1.1.2" 7 | workers: 8 | - "1.1.1.3" 9 | - "1.1.1.4" 10 | registry: 11 | username: user 12 | password: pw 13 | 14 | accessories: 15 | mysql: 16 | image: mysql:5.7 17 | host: 1.1.1.3 18 | port: 3306 19 | env: 20 | clear: 21 | MYSQL_ROOT_HOST: '%' 22 | secret: 23 | - MYSQL_ROOT_PASSWORD 24 | files: 25 | - test/fixtures/files/my.cnf:/etc/mysql/my.cnf 26 | directories: 27 | - data:/var/lib/mysql 28 | redis: 29 | image: redis:latest 30 | roles: 31 | - web 32 | port: 6379 33 | directories: 34 | - data:/data 35 | 36 | readiness_delay: 0 37 | 38 | builder: 39 | arch: 40 | - arm64 41 | - amd64 42 | remote: ssh://app@1.1.1.5 43 | -------------------------------------------------------------------------------- /lib/kamal/commands/app/logging.rb: -------------------------------------------------------------------------------- 1 | module Kamal::Commands::App::Logging 2 | def logs(version: nil, timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil) 3 | pipe \ 4 | version ? container_id_for_version(version) : current_running_container_id, 5 | "xargs docker logs#{" --timestamps" if timestamps}#{" --since #{since}" if since}#{" --tail #{lines}" if lines} 2>&1", 6 | ("grep '#{grep}'#{" #{grep_options}" if grep_options}" if grep) 7 | end 8 | 9 | def follow_logs(host:, timestamps: true, lines: nil, grep: nil, grep_options: nil) 10 | run_over_ssh \ 11 | pipe( 12 | current_running_container_id, 13 | "xargs docker logs#{" --timestamps" if timestamps}#{" --tail #{lines}" if lines} --follow 2>&1", 14 | (%(grep "#{grep}"#{" #{grep_options}" if grep_options}) if grep) 15 | ), 16 | host: host 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/fixtures/deploy_with_remote_builder.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | servers: 4 | web: 5 | - "1.1.1.1" 6 | - "1.1.1.2" 7 | workers: 8 | - "1.1.1.3" 9 | - "1.1.1.4" 10 | registry: 11 | username: user 12 | password: pw 13 | 14 | accessories: 15 | mysql: 16 | image: mysql:5.7 17 | host: 1.1.1.3 18 | port: 3306 19 | env: 20 | clear: 21 | MYSQL_ROOT_HOST: '%' 22 | secret: 23 | - MYSQL_ROOT_PASSWORD 24 | files: 25 | - test/fixtures/files/my.cnf:/etc/mysql/my.cnf 26 | directories: 27 | - data:/var/lib/mysql 28 | redis: 29 | image: redis:latest 30 | roles: 31 | - web 32 | port: 6379 33 | directories: 34 | - data:/data 35 | 36 | readiness_delay: 0 37 | 38 | builder: 39 | arch: <%= Kamal::Utils.docker_arch == "arm64" ? "amd64" : "arm64" %> 40 | remote: ssh://app@1.1.1.5 41 | -------------------------------------------------------------------------------- /lib/kamal/commands/auditor.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Commands::Auditor < Kamal::Commands::Base 2 | attr_reader :details 3 | 4 | def initialize(config, **details) 5 | super(config) 6 | @details = details 7 | end 8 | 9 | # Runs remotely 10 | def record(line, **details) 11 | combine \ 12 | [ :mkdir, "-p", config.run_directory ], 13 | append( 14 | [ :echo, audit_tags(**details).except(:version, :service_version, :service).to_s, line ], 15 | audit_log_file 16 | ) 17 | end 18 | 19 | def reveal 20 | [ :tail, "-n", 50, audit_log_file ] 21 | end 22 | 23 | private 24 | def audit_log_file 25 | file = [ config.service, config.destination, "audit.log" ].compact.join("-") 26 | 27 | File.join(config.run_directory, file) 28 | end 29 | 30 | def audit_tags(**details) 31 | tags(**self.details, **details) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/kamal/configuration/validation.rb: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | require "active_support/inflector" 3 | 4 | module Kamal::Configuration::Validation 5 | extend ActiveSupport::Concern 6 | 7 | class_methods do 8 | def validation_doc 9 | @validation_doc ||= File.read(File.join(File.dirname(__FILE__), "docs", "#{validation_config_key}.yml")) 10 | end 11 | 12 | def validation_config_key 13 | @validation_config_key ||= name.demodulize.underscore 14 | end 15 | end 16 | 17 | def validate!(config, example: nil, context: nil, with: Kamal::Configuration::Validator) 18 | context ||= self.class.validation_config_key 19 | example ||= validation_yml[self.class.validation_config_key] 20 | 21 | with.new(config, example: example, context: context).validate! 22 | end 23 | 24 | def validation_yml 25 | @validation_yml ||= YAML.load(self.class.validation_doc) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/kamal/configuration/docs/sshkit.yml: -------------------------------------------------------------------------------- 1 | # SSHKit 2 | # 3 | # [SSHKit](https://github.com/capistrano/sshkit) is the SSH toolkit used by Kamal. 4 | # 5 | # The default, settings should be sufficient for most use cases, but 6 | # when connecting to a large number of hosts, you may need to adjust. 7 | 8 | # SSHKit options 9 | # 10 | # The options are specified under the sshkit key in the configuration file. 11 | sshkit: 12 | 13 | # Max concurrent starts 14 | # 15 | # Creating SSH connections concurrently can be an issue when deploying to many servers. 16 | # By default, Kamal will limit concurrent connection starts to 30 at a time. 17 | max_concurrent_starts: 10 18 | 19 | # Pool idle timeout 20 | # 21 | # Kamal sets a long idle timeout of 900 seconds on connections to try to avoid 22 | # re-connection storms after an idle period, such as building an image or waiting for CI. 23 | pool_idle_timeout: 300 24 | -------------------------------------------------------------------------------- /lib/kamal/configuration/validator/registry.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Configuration::Validator::Registry < Kamal::Configuration::Validator 2 | STRING_OR_ONE_ITEM_ARRAY_KEYS = [ "username", "password" ] 3 | 4 | def validate! 5 | validate_against_example! \ 6 | config.except(*STRING_OR_ONE_ITEM_ARRAY_KEYS), 7 | example.except(*STRING_OR_ONE_ITEM_ARRAY_KEYS) 8 | 9 | validate_string_or_one_item_array! "username" 10 | validate_string_or_one_item_array! "password" 11 | end 12 | 13 | private 14 | def validate_string_or_one_item_array!(key) 15 | with_context(key) do 16 | value = config[key] 17 | 18 | error "is required" unless value.present? 19 | 20 | unless value.is_a?(String) || (value.is_a?(Array) && value.size == 1 && value.first.is_a?(String)) 21 | error "should be a string or an array with one string (for secret lookup)" 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/kamal/configuration/logging.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Configuration::Logging 2 | delegate :optionize, :argumentize, to: Kamal::Utils 3 | 4 | include Kamal::Configuration::Validation 5 | 6 | attr_reader :logging_config 7 | 8 | def initialize(logging_config:, context: "logging") 9 | @logging_config = logging_config || {} 10 | validate! @logging_config, context: context 11 | end 12 | 13 | def driver 14 | logging_config["driver"] 15 | end 16 | 17 | def options 18 | logging_config.fetch("options", {}) 19 | end 20 | 21 | def merge(other) 22 | self.class.new logging_config: logging_config.deep_merge(other.logging_config) 23 | end 24 | 25 | def args 26 | if driver.present? || options.present? 27 | optionize({ "log-driver" => driver }.compact) + 28 | argumentize("--log-opt", options) 29 | else 30 | argumentize("--log-opt", { "max-size" => "10m" }) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/fixtures/deploy_with_proxy_roles.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | servers: 4 | web: 5 | hosts: 6 | - "1.1.1.1" 7 | - "1.1.1.2" 8 | web2: 9 | hosts: 10 | - "1.1.1.3" 11 | - "1.1.1.4" 12 | proxy: 13 | response_timeout: 15 14 | registry: 15 | username: user 16 | password: pw 17 | builder: 18 | arch: amd64 19 | 20 | proxy: 21 | response_timeout: 10 22 | 23 | accessories: 24 | mysql: 25 | image: mysql:5.7 26 | host: 1.1.1.3 27 | port: 3306 28 | env: 29 | clear: 30 | MYSQL_ROOT_HOST: '%' 31 | secret: 32 | - MYSQL_ROOT_PASSWORD 33 | files: 34 | - test/fixtures/files/my.cnf:/etc/mysql/my.cnf 35 | directories: 36 | - data:/var/lib/mysql 37 | redis: 38 | image: redis:latest 39 | roles: 40 | - web 41 | port: 6379 42 | directories: 43 | - data:/data 44 | 45 | readiness_delay: 0 46 | deploy_timeout: 6 47 | -------------------------------------------------------------------------------- /test/fixtures/deploy_with_remote_builder_and_custom_ports.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | servers: 4 | web: 5 | - "1.1.1.1" 6 | - "1.1.1.2" 7 | workers: 8 | - "1.1.1.3" 9 | - "1.1.1.4" 10 | registry: 11 | username: user 12 | password: pw 13 | 14 | accessories: 15 | mysql: 16 | image: mysql:5.7 17 | host: 1.1.1.3 18 | port: 3306 19 | env: 20 | clear: 21 | MYSQL_ROOT_HOST: '%' 22 | secret: 23 | - MYSQL_ROOT_PASSWORD 24 | files: 25 | - test/fixtures/files/my.cnf:/etc/mysql/my.cnf 26 | directories: 27 | - data:/var/lib/mysql 28 | redis: 29 | image: redis:latest 30 | roles: 31 | - web 32 | port: 6379 33 | directories: 34 | - data:/data 35 | 36 | readiness_delay: 0 37 | 38 | ssh: 39 | user: root 40 | port: 22 41 | 42 | builder: 43 | arch: <%= Kamal::Utils.docker_arch == "arm64" ? "amd64" : "arm64" %> 44 | remote: ssh://app@1.1.1.5:2122 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kamal: Deploy web apps anywhere 2 | 3 | From bare metal to cloud VMs, deploy web apps anywhere with zero downtime. Kamal uses [kamal-proxy](https://github.com/basecamp/kamal-proxy) to seamlessly switch requests between containers. Works seamlessly across multiple servers, using SSHKit to execute commands. Originally built for Rails apps, Kamal will work with any type of web app that can be containerized with Docker. 4 | 5 | ➡️ See [kamal-deploy.org](https://kamal-deploy.org) for documentation on [installation](https://kamal-deploy.org/docs/installation), [configuration](https://kamal-deploy.org/docs/configuration), and [commands](https://kamal-deploy.org/docs/commands). 6 | 7 | ## Contributing to the documentation 8 | 9 | Please help us improve Kamal's documentation on the [the basecamp/kamal-site repository](https://github.com/basecamp/kamal-site). 10 | 11 | ## License 12 | 13 | Kamal is released under the [MIT License](https://opensource.org/licenses/MIT). 14 | -------------------------------------------------------------------------------- /lib/kamal/cli/registry.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Cli::Registry < Kamal::Cli::Base 2 | desc "login", "Log in to registry locally and remotely" 3 | option :skip_local, aliases: "-L", type: :boolean, default: false, desc: "Skip local login" 4 | option :skip_remote, aliases: "-R", type: :boolean, default: false, desc: "Skip remote login" 5 | def login 6 | run_locally { execute *KAMAL.registry.login } unless options[:skip_local] 7 | on(KAMAL.hosts) { execute *KAMAL.registry.login } unless options[:skip_remote] 8 | end 9 | 10 | desc "logout", "Log out of registry locally and remotely" 11 | option :skip_local, aliases: "-L", type: :boolean, default: false, desc: "Skip local login" 12 | option :skip_remote, aliases: "-R", type: :boolean, default: false, desc: "Skip remote login" 13 | def logout 14 | run_locally { execute *KAMAL.registry.logout } unless options[:skip_local] 15 | on(KAMAL.hosts) { execute *KAMAL.registry.logout } unless options[:skip_remote] 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/kamal/configuration/env.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Configuration::Env 2 | include Kamal::Configuration::Validation 3 | 4 | attr_reader :context, :secrets 5 | attr_reader :clear, :secret_keys 6 | delegate :argumentize, to: Kamal::Utils 7 | 8 | def initialize(config:, secrets:, context: "env") 9 | @clear = config.fetch("clear", config.key?("secret") || config.key?("tags") ? {} : config) 10 | @secrets = secrets 11 | @secret_keys = config.fetch("secret", []) 12 | @context = context 13 | validate! config, context: context, with: Kamal::Configuration::Validator::Env 14 | end 15 | 16 | def clear_args 17 | argumentize("--env", clear) 18 | end 19 | 20 | def secrets_io 21 | Kamal::EnvFile.new(secret_keys.to_h { |key| [ key, secrets[key] ] }).to_io 22 | end 23 | 24 | def merge(other) 25 | self.class.new \ 26 | config: { "clear" => clear.merge(other.clear), "secret" => secret_keys | other.secret_keys }, 27 | secrets: secrets 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/commands/docker_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class CommandsDockerTest < ActiveSupport::TestCase 4 | setup do 5 | @config = { 6 | service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], builder: { "arch" => "amd64" } 7 | } 8 | @docker = Kamal::Commands::Docker.new(Kamal::Configuration.new(@config)) 9 | end 10 | 11 | test "install" do 12 | assert_equal "sh -c 'curl -fsSL https://get.docker.com || wget -O - https://get.docker.com || echo \"exit 1\"' | sh", @docker.install.join(" ") 13 | end 14 | 15 | test "installed?" do 16 | assert_equal "docker -v", @docker.installed?.join(" ") 17 | end 18 | 19 | test "running?" do 20 | assert_equal "docker version", @docker.running?.join(" ") 21 | end 22 | 23 | test "superuser?" do 24 | assert_equal '[ "${EUID:-$(id -u)}" -eq 0 ] || command -v sudo >/dev/null || command -v su >/dev/null', @docker.superuser?.join(" ") 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/kamal/commands/app/containers.rb: -------------------------------------------------------------------------------- 1 | module Kamal::Commands::App::Containers 2 | DOCKER_HEALTH_LOG_FORMAT = "'{{json .State.Health}}'" 3 | 4 | def list_containers 5 | docker :container, :ls, "--all", *container_filter_args 6 | end 7 | 8 | def list_container_names 9 | [ *list_containers, "--format", "'{{ .Names }}'" ] 10 | end 11 | 12 | def remove_container(version:) 13 | pipe \ 14 | container_id_for(container_name: container_name(version)), 15 | xargs(docker(:container, :rm)) 16 | end 17 | 18 | def rename_container(version:, new_version:) 19 | docker :rename, container_name(version), container_name(new_version) 20 | end 21 | 22 | def remove_containers 23 | docker :container, :prune, "--force", *container_filter_args 24 | end 25 | 26 | def container_health_log(version:) 27 | pipe \ 28 | container_id_for(container_name: container_name(version)), 29 | xargs(docker(:inspect, "--format", DOCKER_HEALTH_LOG_FORMAT)) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | install_kamal() { 4 | cd /kamal && gem build kamal.gemspec -o /tmp/kamal.gem && gem install /tmp/kamal.gem 5 | } 6 | 7 | # Push the images to a persistent volume on the registry container 8 | # This is to work around docker hub rate limits 9 | push_image_to_registry_4443() { 10 | # Check if the image is in the registry without having to pull it 11 | if ! stat /registry/docker/registry/v2/repositories/$1/_manifests/tags/$2/current/link > /dev/null; then 12 | hub_tag=$1:$2 13 | registry_4443_tag=registry:4443/$1:$2 14 | docker pull $hub_tag 15 | docker tag $hub_tag $registry_4443_tag 16 | docker push $registry_4443_tag 17 | fi 18 | } 19 | 20 | install_kamal 21 | push_image_to_registry_4443 nginx 1-alpine-slim 22 | push_image_to_registry_4443 busybox 1.36.0 23 | 24 | # .ssh is on a shared volume that persists between runs. Clean it up as the 25 | # churn of temporary vm IPs can eventually create conflicts. 26 | rm -f /root/.ssh/known_hosts 27 | -------------------------------------------------------------------------------- /lib/kamal/commands/app/execution.rb: -------------------------------------------------------------------------------- 1 | module Kamal::Commands::App::Execution 2 | def execute_in_existing_container(*command, interactive: false, env:) 3 | docker :exec, 4 | ("-it" if interactive), 5 | *argumentize("--env", env), 6 | container_name, 7 | *command 8 | end 9 | 10 | def execute_in_new_container(*command, interactive: false, env:) 11 | docker :run, 12 | ("-it" if interactive), 13 | "--rm", 14 | "--network", "kamal", 15 | *role&.env_args(host), 16 | *argumentize("--env", env), 17 | *config.volume_args, 18 | *role&.option_args, 19 | config.absolute_image, 20 | *command 21 | end 22 | 23 | def execute_in_existing_container_over_ssh(*command, env:) 24 | run_over_ssh execute_in_existing_container(*command, interactive: true, env: env), host: host 25 | end 26 | 27 | def execute_in_new_container_over_ssh(*command, env:) 28 | run_over_ssh execute_in_new_container(*command, interactive: true, env: env), host: host 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/kamal/commands/builder/clone.rb: -------------------------------------------------------------------------------- 1 | module Kamal::Commands::Builder::Clone 2 | def clone 3 | git :clone, escaped_root, "--recurse-submodules", path: config.builder.clone_directory.shellescape 4 | end 5 | 6 | def clone_reset_steps 7 | [ 8 | git(:remote, "set-url", :origin, escaped_root, path: escaped_build_directory), 9 | git(:fetch, :origin, path: escaped_build_directory), 10 | git(:reset, "--hard", Kamal::Git.revision, path: escaped_build_directory), 11 | git(:clean, "-fdx", path: escaped_build_directory), 12 | git(:submodule, :update, "--init", path: escaped_build_directory) 13 | ] 14 | end 15 | 16 | def clone_status 17 | git :status, "--porcelain", path: escaped_build_directory 18 | end 19 | 20 | def clone_revision 21 | git :"rev-parse", :HEAD, path: escaped_build_directory 22 | end 23 | 24 | def escaped_root 25 | Kamal::Git.root.shellescape 26 | end 27 | 28 | def escaped_build_directory 29 | config.builder.build_directory.shellescape 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/kamal/cli/templates/secrets: -------------------------------------------------------------------------------- 1 | # Secrets defined here are available for reference under registry/password, env/secret, builder/secrets, 2 | # and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either 3 | # password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. 4 | 5 | # Option 1: Read secrets from the environment 6 | KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD 7 | 8 | # Option 2: Read secrets via a command 9 | # RAILS_MASTER_KEY=$(cat config/master.key) 10 | 11 | # Option 3: Read secrets via kamal secrets helpers 12 | # These will handle logging in and fetching the secrets in as few calls as possible 13 | # There are adapters for 1Password, LastPass + Bitwarden 14 | # 15 | # SECRETS=$(kamal secrets fetch --adapter 1password --account my-account --from MyVault/MyItem KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY) 16 | # KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD $SECRETS) 17 | # RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) 18 | -------------------------------------------------------------------------------- /lib/kamal/commands/docker.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Commands::Docker < Kamal::Commands::Base 2 | # Install Docker using the https://github.com/docker/docker-install convenience script. 3 | def install 4 | pipe get_docker, :sh 5 | end 6 | 7 | # Checks the Docker client version. Fails if Docker is not installed. 8 | def installed? 9 | docker "-v" 10 | end 11 | 12 | # Checks the Docker server version. Fails if Docker is not running. 13 | def running? 14 | docker :version 15 | end 16 | 17 | # Do we have superuser access to install Docker and start system services? 18 | def superuser? 19 | [ '[ "${EUID:-$(id -u)}" -eq 0 ] || command -v sudo >/dev/null || command -v su >/dev/null' ] 20 | end 21 | 22 | def create_network 23 | docker :network, :create, :kamal 24 | end 25 | 26 | private 27 | def get_docker 28 | shell \ 29 | any \ 30 | [ :curl, "-fsSL", "https://get.docker.com" ], 31 | [ :wget, "-O -", "https://get.docker.com" ], 32 | [ :echo, "\"exit 1\"" ] 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/kamal/tags.rb: -------------------------------------------------------------------------------- 1 | require "time" 2 | 3 | class Kamal::Tags 4 | attr_reader :config, :tags 5 | 6 | class << self 7 | def from_config(config, **extra) 8 | new(**default_tags(config), **extra) 9 | end 10 | 11 | def default_tags(config) 12 | { recorded_at: Time.now.utc.iso8601, 13 | performer: Kamal::Git.email.presence || `whoami`.chomp, 14 | destination: config.destination, 15 | version: config.version, 16 | service_version: service_version(config), 17 | service: config.service } 18 | end 19 | 20 | def service_version(config) 21 | [ config.service, config.abbreviated_version ].compact.join("@") 22 | end 23 | end 24 | 25 | def initialize(**tags) 26 | @tags = tags.compact 27 | end 28 | 29 | def env 30 | tags.transform_keys { |detail| "KAMAL_#{detail.upcase}" } 31 | end 32 | 33 | def to_s 34 | tags.values.map { |value| "[#{value}]" }.join(" ") 35 | end 36 | 37 | def except(*tags) 38 | self.class.new(**self.tags.except(*tags)) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app/config/deploy.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: app 3 | servers: 4 | - vm1 5 | - vm2: [ tag1, tag2 ] 6 | env: 7 | clear: 8 | CLEAR_TOKEN: 4321 9 | CLEAR_TAG: "" 10 | HOST_TOKEN: "${HOST_TOKEN}" 11 | secret: 12 | - SECRET_TOKEN 13 | - INTERPOLATED_SECRET1 14 | - INTERPOLATED_SECRET2 15 | - INTERPOLATED_SECRET3 16 | tags: 17 | tag1: 18 | CLEAR_TAG: tagged 19 | tag2: 20 | secret: 21 | - SECRET_TAG 22 | asset_path: /usr/share/nginx/html/versions 23 | deploy_timeout: 2 24 | drain_timeout: 2 25 | readiness_delay: 0 26 | proxy: 27 | host: 127.0.0.1 28 | registry: 29 | server: registry:4443 30 | username: root 31 | password: root 32 | builder: 33 | driver: docker 34 | arch: <%= Kamal::Utils.docker_arch %> 35 | args: 36 | COMMIT_SHA: <%= `git rev-parse HEAD` %> 37 | accessories: 38 | busybox: 39 | service: custom-busybox 40 | image: registry:4443/busybox:1.36.0 41 | cmd: sh -c 'echo "Starting busybox..."; trap exit term; while true; do sleep 1; done' 42 | roles: 43 | - web 44 | -------------------------------------------------------------------------------- /test/commands/lock_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class CommandsLockTest < ActiveSupport::TestCase 4 | setup do 5 | @config = { 6 | service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], 7 | builder: { "arch" => "amd64" } 8 | } 9 | end 10 | 11 | test "status" do 12 | assert_equal \ 13 | "stat .kamal/lock-app-production > /dev/null && cat .kamal/lock-app-production/details | base64 -d", 14 | new_command.status.join(" ") 15 | end 16 | 17 | test "acquire" do 18 | assert_match \ 19 | %r{mkdir \.kamal/lock-app-production && echo ".*" > \.kamal/lock-app-production/details}m, 20 | new_command.acquire("Hello", "123").join(" ") 21 | end 22 | 23 | test "release" do 24 | assert_match \ 25 | "rm .kamal/lock-app-production/details && rm -r .kamal/lock-app-production", 26 | new_command.release.join(" ") 27 | end 28 | 29 | private 30 | def new_command 31 | Kamal::Commands::Lock.new(Kamal::Configuration.new(@config, version: "123", destination: "production")) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/configuration/sshkit_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ConfigurationSshkitTest < ActiveSupport::TestCase 4 | setup do 5 | @deploy = { 6 | service: "app", image: "dhh/app", 7 | registry: { "username" => "dhh", "password" => "secret" }, 8 | env: { "REDIS_URL" => "redis://x/y" }, 9 | builder: { "arch" => "amd64" }, 10 | servers: [ "1.1.1.1", "1.1.1.2" ], 11 | volumes: [ "/local/path:/container/path" ] 12 | } 13 | 14 | @config = Kamal::Configuration.new(@deploy) 15 | end 16 | 17 | test "sshkit max concurrent starts" do 18 | assert_equal 30, @config.sshkit.max_concurrent_starts 19 | @config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(sshkit: { "max_concurrent_starts" => 50 }) }) 20 | assert_equal 50, @config.sshkit.max_concurrent_starts 21 | end 22 | 23 | test "sshkit pool idle timeout" do 24 | assert_equal 900, @config.sshkit.pool_idle_timeout 25 | @config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(sshkit: { "pool_idle_timeout" => 600 }) }) 26 | assert_equal 600, @config.sshkit.pool_idle_timeout 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/kamal/cli/prune.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Cli::Prune < Kamal::Cli::Base 2 | desc "all", "Prune unused images and stopped containers" 3 | def all 4 | with_lock do 5 | containers 6 | images 7 | end 8 | end 9 | 10 | desc "images", "Prune unused images" 11 | def images 12 | with_lock do 13 | on(KAMAL.hosts) do 14 | execute *KAMAL.auditor.record("Pruned images"), verbosity: :debug 15 | execute *KAMAL.prune.dangling_images 16 | execute *KAMAL.prune.tagged_images 17 | end 18 | end 19 | end 20 | 21 | desc "containers", "Prune all stopped containers, except the last n (default 5)" 22 | option :retain, type: :numeric, default: nil, desc: "Number of containers to retain" 23 | def containers 24 | retain = options.fetch(:retain, KAMAL.config.retain_containers) 25 | raise "retain must be at least 1" if retain < 1 26 | 27 | with_lock do 28 | on(KAMAL.hosts) do 29 | execute *KAMAL.auditor.record("Pruned containers"), verbosity: :debug 30 | execute *KAMAL.prune.app_containers(retain: retain) 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 David Heinemeier Hansson 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /kamal.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/kamal/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "kamal" 5 | spec.version = Kamal::VERSION 6 | spec.authors = [ "David Heinemeier Hansson" ] 7 | spec.email = "dhh@hey.com" 8 | spec.homepage = "https://github.com/basecamp/kamal" 9 | spec.summary = "Deploy web apps in containers to servers running Docker with zero downtime." 10 | spec.license = "MIT" 11 | spec.files = Dir["lib/**/*", "MIT-LICENSE", "README.md"] 12 | spec.executables = %w[ kamal ] 13 | 14 | spec.add_dependency "activesupport", ">= 7.0" 15 | spec.add_dependency "sshkit", ">= 1.23.0", "< 2.0" 16 | spec.add_dependency "net-ssh", "~> 7.3" 17 | spec.add_dependency "thor", "~> 1.3" 18 | spec.add_dependency "dotenv", "~> 3.1" 19 | spec.add_dependency "zeitwerk", ">= 2.6.18", "< 3.0" 20 | spec.add_dependency "ed25519", "~> 1.2" 21 | spec.add_dependency "bcrypt_pbkdf", "~> 1.0" 22 | spec.add_dependency "concurrent-ruby", "~> 1.2" 23 | spec.add_dependency "base64", "~> 0.2" 24 | 25 | spec.add_development_dependency "debug" 26 | spec.add_development_dependency "mocha" 27 | spec.add_development_dependency "railties" 28 | end 29 | -------------------------------------------------------------------------------- /lib/kamal/secrets/dotenv/inline_command_substitution.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Secrets::Dotenv::InlineCommandSubstitution 2 | class << self 3 | def install! 4 | ::Dotenv::Parser.substitutions.map! { |sub| sub == ::Dotenv::Substitutions::Command ? self : sub } 5 | end 6 | 7 | def call(value, _env, overwrite: false) 8 | # Process interpolated shell commands 9 | value.gsub(Dotenv::Substitutions::Command.singleton_class::INTERPOLATED_SHELL_COMMAND) do |*| 10 | # Eliminate opening and closing parentheses 11 | command = $LAST_MATCH_INFO[:cmd][1..-2] 12 | 13 | if $LAST_MATCH_INFO[:backslash] 14 | # Command is escaped, don't replace it. 15 | $LAST_MATCH_INFO[0][1..] 16 | else 17 | if command =~ /\A\s*kamal\s*secrets\s+/ 18 | # Inline the command 19 | inline_secrets_command(command) 20 | else 21 | # Execute the command and return the value 22 | `#{command}`.chomp 23 | end 24 | end 25 | end 26 | end 27 | 28 | def inline_secrets_command(command) 29 | Kamal::Cli::Main.start(command.shellsplit[1..] + [ "--inline" ]).chomp 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/integration/accessory_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "integration_test" 2 | 3 | class AccessoryTest < IntegrationTest 4 | test "boot, stop, start, restart, logs, remove" do 5 | kamal :accessory, :boot, :busybox 6 | assert_accessory_running :busybox 7 | 8 | kamal :accessory, :stop, :busybox 9 | assert_accessory_not_running :busybox 10 | 11 | kamal :accessory, :start, :busybox 12 | assert_accessory_running :busybox 13 | 14 | kamal :accessory, :restart, :busybox 15 | assert_accessory_running :busybox 16 | 17 | logs = kamal :accessory, :logs, :busybox, capture: true 18 | assert_match /Starting busybox.../, logs 19 | 20 | kamal :accessory, :remove, :busybox, "-y" 21 | assert_accessory_not_running :busybox 22 | end 23 | 24 | private 25 | def assert_accessory_running(name) 26 | assert_match /registry:4443\/busybox:1.36.0 "sh -c 'echo \\"Start/, accessory_details(name) 27 | end 28 | 29 | def assert_accessory_not_running(name) 30 | assert_no_match /registry:4443\/busybox:1.36.0 "sh -c 'echo \\"Start/, accessory_details(name) 31 | end 32 | 33 | def accessory_details(name) 34 | kamal :accessory, :details, name, capture: true 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/kamal/secrets/adapters/aws_secrets_manager.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Secrets::Adapters::AwsSecretsManager < Kamal::Secrets::Adapters::Base 2 | private 3 | def login(_account) 4 | nil 5 | end 6 | 7 | def fetch_secrets(secrets, account:, session:) 8 | {}.tap do |results| 9 | JSON.parse(get_from_secrets_manager(secrets, account: account))["SecretValues"].each do |secret| 10 | secret_name = secret["Name"] 11 | secret_string = JSON.parse(secret["SecretString"]) 12 | 13 | secret_string.each do |key, value| 14 | results["#{secret_name}/#{key}"] = value 15 | end 16 | end 17 | end 18 | end 19 | 20 | def get_from_secrets_manager(secrets, account:) 21 | `aws secretsmanager batch-get-secret-value --secret-id-list #{secrets.map(&:shellescape).join(" ")} --profile #{account.shellescape}`.tap do 22 | raise RuntimeError, "Could not read #{secret} from AWS Secrets Manager" unless $?.success? 23 | end 24 | end 25 | 26 | def check_dependencies! 27 | raise RuntimeError, "AWS CLI is not installed" unless cli_installed? 28 | end 29 | 30 | def cli_installed? 31 | `aws --version 2> /dev/null` 32 | $?.success? 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/kamal/secrets.rb: -------------------------------------------------------------------------------- 1 | require "dotenv" 2 | 3 | class Kamal::Secrets 4 | Kamal::Secrets::Dotenv::InlineCommandSubstitution.install! 5 | 6 | def initialize(destination: nil) 7 | @destination = destination 8 | @mutex = Mutex.new 9 | end 10 | 11 | def [](key) 12 | # Fetching secrets may ask the user for input, so ensure only one thread does that 13 | @mutex.synchronize do 14 | secrets.fetch(key) 15 | end 16 | rescue KeyError 17 | if secrets_files.present? 18 | raise Kamal::ConfigurationError, "Secret '#{key}' not found in #{secrets_files.join(", ")}" 19 | else 20 | raise Kamal::ConfigurationError, "Secret '#{key}' not found, no secret files (#{secrets_filenames.join(", ")}) provided" 21 | end 22 | end 23 | 24 | def to_h 25 | secrets 26 | end 27 | 28 | def secrets_files 29 | @secrets_files ||= secrets_filenames.select { |f| File.exist?(f) } 30 | end 31 | 32 | private 33 | def secrets 34 | @secrets ||= secrets_files.inject({}) do |secrets, secrets_file| 35 | secrets.merge!(::Dotenv.parse(secrets_file)) 36 | end 37 | end 38 | 39 | def secrets_filenames 40 | [ ".kamal/secrets-common", ".kamal/secrets#{(".#{@destination}" if @destination)}" ] 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app_with_proxied_accessory/config/deploy.yml: -------------------------------------------------------------------------------- 1 | service: app_with_proxied_accessory 2 | image: app_with_proxied_accessory 3 | servers: 4 | - vm1 5 | env: 6 | clear: 7 | CLEAR_TOKEN: 4321 8 | CLEAR_TAG: "" 9 | HOST_TOKEN: "${HOST_TOKEN}" 10 | asset_path: /usr/share/nginx/html/versions 11 | proxy: 12 | host: 127.0.0.1 13 | registry: 14 | server: registry:4443 15 | username: root 16 | password: root 17 | builder: 18 | driver: docker 19 | arch: <%= Kamal::Utils.docker_arch %> 20 | args: 21 | COMMIT_SHA: <%= `git rev-parse HEAD` %> 22 | accessories: 23 | busybox: 24 | service: custom-busybox 25 | image: registry:4443/busybox:1.36.0 26 | cmd: sh -c 'echo "Starting busybox..."; trap exit term; while true; do sleep 1; done' 27 | roles: 28 | - web 29 | netcat: 30 | service: netcat 31 | image: registry:4443/busybox:1.36.0 32 | cmd: > 33 | sh -c 'echo "Starting netcat..."; while true; do echo -e "HTTP/1.1 200 OK\r\nContent-Length: 11\r\n\r\nHello Ruby" | nc -l -p 80; done' 34 | roles: 35 | - web 36 | port: 12345:80 37 | proxy: 38 | host: netcat 39 | ssl: false 40 | healthcheck: 41 | interval: 1 42 | timeout: 1 43 | path: "/" 44 | 45 | -------------------------------------------------------------------------------- /lib/kamal/cli/templates/sample_hooks/pre-connect.sample: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # A sample pre-connect check 4 | # 5 | # Warms DNS before connecting to hosts in parallel 6 | # 7 | # These environment variables are available: 8 | # KAMAL_RECORDED_AT 9 | # KAMAL_PERFORMER 10 | # KAMAL_VERSION 11 | # KAMAL_HOSTS 12 | # KAMAL_ROLE (if set) 13 | # KAMAL_DESTINATION (if set) 14 | # KAMAL_RUNTIME 15 | 16 | hosts = ENV["KAMAL_HOSTS"].split(",") 17 | results = nil 18 | max = 3 19 | 20 | elapsed = Benchmark.realtime do 21 | results = hosts.map do |host| 22 | Thread.new do 23 | tries = 1 24 | 25 | begin 26 | Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME) 27 | rescue SocketError 28 | if tries < max 29 | puts "Retrying DNS warmup: #{host}" 30 | tries += 1 31 | sleep rand 32 | retry 33 | else 34 | puts "DNS warmup failed: #{host}" 35 | host 36 | end 37 | end 38 | 39 | tries 40 | end 41 | end.map(&:value) 42 | end 43 | 44 | retries = results.sum - hosts.size 45 | nopes = results.count { |r| r == max } 46 | 47 | puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ] 48 | -------------------------------------------------------------------------------- /lib/kamal/cli/lock.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Cli::Lock < Kamal::Cli::Base 2 | desc "status", "Report lock status" 3 | def status 4 | handle_missing_lock do 5 | on(KAMAL.primary_host) do 6 | puts capture_with_debug(*KAMAL.lock.status) 7 | end 8 | end 9 | end 10 | 11 | desc "acquire", "Acquire the deploy lock" 12 | option :message, aliases: "-m", type: :string, desc: "A lock message", required: true 13 | def acquire 14 | message = options[:message] 15 | ensure_run_directory 16 | 17 | raise_if_locked do 18 | on(KAMAL.primary_host) do 19 | execute *KAMAL.lock.acquire(message, KAMAL.config.version), verbosity: :debug 20 | end 21 | say "Acquired the deploy lock" 22 | end 23 | end 24 | 25 | desc "release", "Release the deploy lock" 26 | def release 27 | handle_missing_lock do 28 | on(KAMAL.primary_host) do 29 | execute *KAMAL.lock.release, verbosity: :debug 30 | end 31 | say "Released the deploy lock" 32 | end 33 | end 34 | 35 | private 36 | def handle_missing_lock 37 | yield 38 | rescue SSHKit::Runner::ExecuteError => e 39 | if e.message =~ /No such file or directory/ 40 | say "There is no deploy lock" 41 | else 42 | raise 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/kamal/cli/healthcheck/poller.rb: -------------------------------------------------------------------------------- 1 | module Kamal::Cli::Healthcheck::Poller 2 | extend self 3 | 4 | def wait_for_healthy(role, &block) 5 | attempt = 1 6 | timeout_at = Time.now + KAMAL.config.deploy_timeout 7 | readiness_delay = KAMAL.config.readiness_delay 8 | 9 | begin 10 | status = block.call 11 | 12 | if status == "running" 13 | # Wait for the readiness delay and confirm it is still running 14 | if readiness_delay > 0 15 | info "Container is running, waiting for readiness delay of #{readiness_delay} seconds" 16 | sleep readiness_delay 17 | status = block.call 18 | end 19 | end 20 | 21 | unless %w[ running healthy ].include?(status) 22 | raise Kamal::Cli::Healthcheck::Error, "container not ready after #{KAMAL.config.deploy_timeout} seconds (#{status})" 23 | end 24 | rescue Kamal::Cli::Healthcheck::Error => e 25 | time_left = timeout_at - Time.now 26 | if time_left > 0 27 | sleep [ attempt, time_left ].min 28 | attempt += 1 29 | retry 30 | else 31 | raise 32 | end 33 | end 34 | 35 | info "Container is healthy!" 36 | end 37 | 38 | private 39 | def info(message) 40 | SSHKit.config.output.info(message) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/kamal/secrets/adapters/last_pass.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Secrets::Adapters::LastPass < Kamal::Secrets::Adapters::Base 2 | private 3 | def login(account) 4 | unless loggedin?(account) 5 | `lpass login #{account.shellescape}` 6 | raise RuntimeError, "Failed to login to LastPass" unless $?.success? 7 | end 8 | end 9 | 10 | def loggedin?(account) 11 | `lpass status --color never`.strip == "Logged in as #{account}." 12 | end 13 | 14 | def fetch_secrets(secrets, account:, session:) 15 | items = `lpass show #{secrets.map(&:shellescape).join(" ")} --json` 16 | raise RuntimeError, "Could not read #{secrets} from LastPass" unless $?.success? 17 | 18 | items = JSON.parse(items) 19 | 20 | {}.tap do |results| 21 | items.each do |item| 22 | results[item["fullname"]] = item["password"] 23 | end 24 | 25 | if (missing_items = secrets - results.keys).any? 26 | raise RuntimeError, "Could not find #{missing_items.join(", ")} in LassPass" 27 | end 28 | end 29 | end 30 | 31 | def check_dependencies! 32 | raise RuntimeError, "LastPass CLI is not installed" unless cli_installed? 33 | end 34 | 35 | def cli_installed? 36 | `lpass --version 2> /dev/null` 37 | $?.success? 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/integration/broken_deploy_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "integration_test" 2 | 3 | class BrokenDeployTest < IntegrationTest 4 | test "deploying a bad image" do 5 | @app = "app_with_roles" 6 | 7 | first_version = latest_app_version 8 | 9 | kamal :deploy 10 | 11 | assert_app_is_up version: first_version 12 | assert_container_running host: :vm3, name: "app_with_roles-workers-#{first_version}" 13 | 14 | second_version = break_app 15 | 16 | output = kamal :deploy, raise_on_error: false, capture: true 17 | 18 | assert_failed_deploy output 19 | assert_app_is_up version: first_version 20 | assert_container_running host: :vm3, name: "app_with_roles-workers-#{first_version}" 21 | assert_container_not_running host: :vm3, name: "app_with_roles-workers-#{second_version}" 22 | end 23 | 24 | private 25 | def assert_failed_deploy(output) 26 | assert_match "Waiting for the first healthy web container before booting workers on vm3...", output 27 | assert_match /First web container is unhealthy on vm[12], not booting any other roles/, output 28 | assert_match "First web container is unhealthy, not booting workers on vm3", output 29 | assert_match "nginx: [emerg] unexpected end of file, expecting \";\" or \"}\" in /etc/nginx/conf.d/default.conf:2", output 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/kamal/cli/templates/sample_hooks/pre-build.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # A sample pre-build hook 4 | # 5 | # Checks: 6 | # 1. We have a clean checkout 7 | # 2. A remote is configured 8 | # 3. The branch has been pushed to the remote 9 | # 4. The version we are deploying matches the remote 10 | # 11 | # These environment variables are available: 12 | # KAMAL_RECORDED_AT 13 | # KAMAL_PERFORMER 14 | # KAMAL_VERSION 15 | # KAMAL_HOSTS 16 | # KAMAL_ROLE (if set) 17 | # KAMAL_DESTINATION (if set) 18 | 19 | if [ -n "$(git status --porcelain)" ]; then 20 | echo "Git checkout is not clean, aborting..." >&2 21 | git status --porcelain >&2 22 | exit 1 23 | fi 24 | 25 | first_remote=$(git remote) 26 | 27 | if [ -z "$first_remote" ]; then 28 | echo "No git remote set, aborting..." >&2 29 | exit 1 30 | fi 31 | 32 | current_branch=$(git branch --show-current) 33 | 34 | if [ -z "$current_branch" ]; then 35 | echo "Not on a git branch, aborting..." >&2 36 | exit 1 37 | fi 38 | 39 | remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1) 40 | 41 | if [ -z "$remote_head" ]; then 42 | echo "Branch not pushed to remote, aborting..." >&2 43 | exit 1 44 | fi 45 | 46 | if [ "$KAMAL_VERSION" != "$remote_head" ]; then 47 | echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2 48 | exit 1 49 | fi 50 | 51 | exit 0 52 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.3-alpine 2 | 3 | # Install docker/buildx-bin 4 | COPY --from=docker/buildx-bin /buildx /usr/libexec/docker/cli-plugins/docker-buildx 5 | 6 | # Set the working directory to /kamal 7 | WORKDIR /kamal 8 | 9 | # Copy the Gemfile, Gemfile.lock into the container 10 | COPY Gemfile Gemfile.lock kamal.gemspec ./ 11 | 12 | # Required in kamal.gemspec 13 | COPY lib/kamal/version.rb /kamal/lib/kamal/version.rb 14 | 15 | # Install system dependencies 16 | RUN apk add --no-cache build-base git docker openrc openssh-client-default \ 17 | && rc-update add docker boot \ 18 | && gem install bundler --version=2.4.3 \ 19 | && bundle install 20 | 21 | # Copy the rest of our application code into the container. 22 | # We do this after bundle install, to avoid having to run bundle 23 | # every time we do small fixes in the source code. 24 | COPY . . 25 | 26 | # Install the gem locally from the project folder 27 | RUN gem build kamal.gemspec && \ 28 | gem install ./kamal-*.gem --no-document 29 | 30 | # Set the working directory to /workdir 31 | WORKDIR /workdir 32 | 33 | # Tell git it's safe to access /workdir/.git even if 34 | # the directory is owned by a different user 35 | RUN git config --global --add safe.directory '*' 36 | 37 | # Set the entrypoint to run the installed binary in /workdir 38 | # Example: docker run -it -v "$PWD:/workdir" kamal init 39 | ENTRYPOINT ["kamal"] 40 | -------------------------------------------------------------------------------- /lib/kamal/commands/builder.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/string/filters" 2 | 3 | class Kamal::Commands::Builder < Kamal::Commands::Base 4 | delegate :create, :remove, :push, :clean, :pull, :info, :inspect_builder, :validate_image, :first_mirror, to: :target 5 | delegate :local?, :remote?, to: "config.builder" 6 | 7 | include Clone 8 | 9 | def name 10 | target.class.to_s.remove("Kamal::Commands::Builder::").underscore.inquiry 11 | end 12 | 13 | def target 14 | if remote? 15 | if local? 16 | hybrid 17 | else 18 | remote 19 | end 20 | else 21 | local 22 | end 23 | end 24 | 25 | def remote 26 | @remote ||= Kamal::Commands::Builder::Remote.new(config) 27 | end 28 | 29 | def local 30 | @local ||= Kamal::Commands::Builder::Local.new(config) 31 | end 32 | 33 | def hybrid 34 | @hybrid ||= Kamal::Commands::Builder::Hybrid.new(config) 35 | end 36 | 37 | 38 | def ensure_local_dependencies_installed 39 | if name.native? 40 | ensure_local_docker_installed 41 | else 42 | combine \ 43 | ensure_local_docker_installed, 44 | ensure_local_buildx_installed 45 | end 46 | end 47 | 48 | private 49 | def ensure_local_docker_installed 50 | docker "--version" 51 | end 52 | 53 | def ensure_local_buildx_installed 54 | docker :buildx, "version" 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/kamal/configuration/ssh.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Configuration::Ssh 2 | LOGGER = ::Logger.new(STDERR) 3 | 4 | include Kamal::Configuration::Validation 5 | 6 | attr_reader :ssh_config 7 | 8 | def initialize(config:) 9 | @ssh_config = config.raw_config.ssh || {} 10 | validate! ssh_config 11 | end 12 | 13 | def user 14 | ssh_config.fetch("user", "root") 15 | end 16 | 17 | def port 18 | ssh_config.fetch("port", 22) 19 | end 20 | 21 | def proxy 22 | if (proxy = ssh_config["proxy"]) 23 | Net::SSH::Proxy::Jump.new(proxy.include?("@") ? proxy : "root@#{proxy}") 24 | elsif (proxy_command = ssh_config["proxy_command"]) 25 | Net::SSH::Proxy::Command.new(proxy_command) 26 | end 27 | end 28 | 29 | def keys_only 30 | ssh_config["keys_only"] 31 | end 32 | 33 | def keys 34 | ssh_config["keys"] 35 | end 36 | 37 | def key_data 38 | ssh_config["key_data"] 39 | end 40 | 41 | def options 42 | { user: user, port: port, proxy: proxy, logger: logger, keepalive: true, keepalive_interval: 30, keys_only: keys_only, keys: keys, key_data: key_data }.compact 43 | end 44 | 45 | def to_h 46 | options.except(:logger).merge(log_level: log_level) 47 | end 48 | 49 | private 50 | def logger 51 | LOGGER.tap { |logger| logger.level = log_level } 52 | end 53 | 54 | def log_level 55 | ssh_config.fetch("log_level", :fatal) 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/kamal/env_file.rb: -------------------------------------------------------------------------------- 1 | # Encode an env hash as a string where secret values have been looked up and all values escaped for Docker. 2 | class Kamal::EnvFile 3 | def initialize(env) 4 | @env = env 5 | end 6 | 7 | def to_s 8 | env_file = StringIO.new.tap do |contents| 9 | @env.each do |key, value| 10 | contents << docker_env_file_line(key, value) 11 | end 12 | end.string 13 | 14 | # Ensure the file has some contents to avoid the SSHKIT empty file warning 15 | env_file.presence || "\n" 16 | end 17 | 18 | def to_io 19 | StringIO.new(to_s) 20 | end 21 | 22 | alias to_str to_s 23 | 24 | private 25 | def docker_env_file_line(key, value) 26 | "#{key}=#{escape_docker_env_file_value(value)}\n" 27 | end 28 | 29 | # Escape a value to make it safe to dump in a docker file. 30 | def escape_docker_env_file_value(value) 31 | # keep non-ascii(UTF-8) characters as it is 32 | value.to_s.scan(/[\x00-\x7F]+|[^\x00-\x7F]+/).map do |part| 33 | part.ascii_only? ? escape_docker_env_file_ascii_value(part) : part 34 | end.join 35 | end 36 | 37 | def escape_docker_env_file_ascii_value(value) 38 | # Doublequotes are treated literally in docker env files 39 | # so remove leading and trailing ones and unescape any others 40 | value.to_s.dump[1..-2] 41 | .gsub(/\\"/, "\"") 42 | .gsub(/\\#/, "#") 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/integration/docker-compose.yml: -------------------------------------------------------------------------------- 1 | name: "kamal-test" 2 | 3 | volumes: 4 | shared: 5 | registry: 6 | deployer_bundle: 7 | 8 | services: 9 | shared: 10 | build: 11 | context: docker/shared 12 | volumes: 13 | - shared:/shared 14 | 15 | deployer: 16 | privileged: true 17 | build: 18 | context: docker/deployer 19 | environment: 20 | - TEST_ID=${TEST_ID:-} 21 | volumes: 22 | - ../..:/kamal 23 | - shared:/shared 24 | - registry:/registry 25 | - deployer_bundle:/usr/local/bundle/ 26 | 27 | registry: 28 | build: 29 | context: docker/registry 30 | environment: 31 | - REGISTRY_HTTP_ADDR=0.0.0.0:4443 32 | - REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt 33 | - REGISTRY_HTTP_TLS_KEY=/certs/domain.key 34 | volumes: 35 | - shared:/shared 36 | - registry:/var/lib/registry/ 37 | 38 | vm1: 39 | privileged: true 40 | build: 41 | context: docker/vm 42 | volumes: 43 | - shared:/shared 44 | 45 | vm2: 46 | privileged: true 47 | build: 48 | context: docker/vm 49 | volumes: 50 | - shared:/shared 51 | 52 | vm3: 53 | privileged: true 54 | build: 55 | context: docker/vm 56 | volumes: 57 | - shared:/shared 58 | 59 | load_balancer: 60 | build: 61 | context: docker/load_balancer 62 | ports: 63 | - "12345:80" 64 | depends_on: 65 | - vm1 66 | - vm2 67 | - vm3 68 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app_with_roles/config/deploy.yml: -------------------------------------------------------------------------------- 1 | service: app_with_roles 2 | image: app_with_roles 3 | servers: 4 | web: 5 | hosts: 6 | - vm1 7 | - vm2 8 | workers: 9 | hosts: 10 | - vm3 11 | cmd: sleep infinity 12 | deploy_timeout: 2 13 | drain_timeout: 2 14 | readiness_delay: 0 15 | 16 | proxy: 17 | host: localhost 18 | ssl: false 19 | healthcheck: 20 | interval: 1 21 | timeout: 1 22 | path: "/up" 23 | response_timeout: 2 24 | buffering: 25 | requests: false 26 | responses: false 27 | memory: 400_000 28 | max_request_body: 40_000_000 29 | max_response_body: 40_000_000 30 | forward_headers: true 31 | logging: 32 | request_headers: 33 | - Cache-Control 34 | - X-Forwarded-Proto 35 | response_headers: 36 | - X-Request-ID 37 | - X-Request-Start 38 | 39 | asset_path: /usr/share/nginx/html/versions 40 | 41 | registry: 42 | server: registry:4443 43 | username: root 44 | password: root 45 | builder: 46 | driver: docker 47 | arch: <%= Kamal::Utils.docker_arch %> 48 | args: 49 | COMMIT_SHA: <%= `git rev-parse HEAD` %> 50 | accessories: 51 | busybox: 52 | service: custom-busybox 53 | image: registry:4443/busybox:1.36.0 54 | cmd: sh -c 'echo "Starting busybox..."; trap exit term; while true; do sleep 1; done' 55 | roles: 56 | - web 57 | aliases: 58 | whome: version 59 | worker_hostname: app exec -r workers -q --reuse hostname 60 | uname: server exec -q -p uname 61 | -------------------------------------------------------------------------------- /test/configuration/proxy_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ConfigurationProxyTest < ActiveSupport::TestCase 4 | setup do 5 | @deploy = { 6 | service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, 7 | builder: { "arch" => "amd64" }, servers: [ "1.1.1.1" ] 8 | } 9 | end 10 | 11 | test "ssl with host" do 12 | @deploy[:proxy] = { "ssl" => true, "host" => "example.com" } 13 | assert_equal true, config.proxy.ssl? 14 | end 15 | 16 | test "ssl with multiple hosts passed via host" do 17 | @deploy[:proxy] = { "ssl" => true, "host" => "example.com,anotherexample.com" } 18 | assert_equal true, config.proxy.ssl? 19 | end 20 | 21 | test "ssl with multiple hosts passed via hosts" do 22 | @deploy[:proxy] = { "ssl" => true, "hosts" => [ "example.com", "anotherexample.com" ] } 23 | assert_equal true, config.proxy.ssl? 24 | end 25 | 26 | test "ssl with no host" do 27 | @deploy[:proxy] = { "ssl" => true } 28 | assert_raises(Kamal::ConfigurationError) { config.proxy.ssl? } 29 | end 30 | 31 | test "ssl with both host and hosts" do 32 | @deploy[:proxy] = { "ssl" => true, host: "example.com", hosts: [ "anotherexample.com" ] } 33 | assert_raises(Kamal::ConfigurationError) { config.proxy.ssl? } 34 | end 35 | 36 | test "ssl false" do 37 | @deploy[:proxy] = { "ssl" => false } 38 | assert_not config.proxy.ssl? 39 | end 40 | 41 | private 42 | def config 43 | Kamal::Configuration.new(@deploy) 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/cli/secrets_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "cli_test_case" 2 | 3 | class CliSecretsTest < CliTestCase 4 | test "fetch" do 5 | assert_equal \ 6 | "\\{\\\"foo\\\":\\\"oof\\\",\\\"bar\\\":\\\"rab\\\",\\\"baz\\\":\\\"zab\\\"\\}", 7 | run_command("fetch", "foo", "bar", "baz", "--account", "myaccount", "--adapter", "test") 8 | end 9 | 10 | test "fetch missing --acount" do 11 | assert_equal \ 12 | "No value provided for required options '--account'", 13 | run_command("fetch", "foo", "bar", "baz", "--adapter", "test") 14 | end 15 | 16 | test "fetch without required --account" do 17 | assert_equal \ 18 | "\\{\\\"foo\\\":\\\"oof\\\",\\\"bar\\\":\\\"rab\\\",\\\"baz\\\":\\\"zab\\\"\\}", 19 | run_command("fetch", "foo", "bar", "baz", "--adapter", "test_optional_account") 20 | end 21 | 22 | test "extract" do 23 | assert_equal "oof", run_command("extract", "foo", "{\"foo\":\"oof\", \"bar\":\"rab\", \"baz\":\"zab\"}") 24 | end 25 | 26 | test "extract match from end" do 27 | assert_equal "oof", run_command("extract", "foo", "{\"abc/foo\":\"oof\", \"bar\":\"rab\", \"baz\":\"zab\"}") 28 | end 29 | 30 | test "print" do 31 | with_test_secrets("secrets" => "SECRET1=ABC\nSECRET2=${SECRET1}DEF\n") do 32 | assert_equal "SECRET1=ABC\nSECRET2=ABCDEF", run_command("print") 33 | end 34 | end 35 | 36 | private 37 | def run_command(*command) 38 | stdouted { Kamal::Cli::Secrets.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) } 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/kamal/commands/prune.rb: -------------------------------------------------------------------------------- 1 | require "active_support/duration" 2 | require "active_support/core_ext/numeric/time" 3 | 4 | class Kamal::Commands::Prune < Kamal::Commands::Base 5 | def dangling_images 6 | docker :image, :prune, "--force", "--filter", "label=service=#{config.service}" 7 | end 8 | 9 | def tagged_images 10 | pipe \ 11 | docker(:image, :ls, *service_filter, "--format", "'{{.ID}} {{.Repository}}:{{.Tag}}'"), 12 | grep("-v -w \"#{active_image_list}\""), 13 | "while read image tag; do docker rmi $tag; done" 14 | end 15 | 16 | def app_containers(retain:) 17 | pipe \ 18 | docker(:ps, "-q", "-a", *service_filter, *stopped_containers_filters), 19 | "tail -n +#{retain + 1}", 20 | "while read container_id; do docker rm $container_id; done" 21 | end 22 | 23 | private 24 | def stopped_containers_filters 25 | [ "created", "exited", "dead" ].flat_map { |status| [ "--filter", "status=#{status}" ] } 26 | end 27 | 28 | def active_image_list 29 | # Pull the images that are used by any containers 30 | # Append repo:latest - to avoid deleting the latest tag 31 | # Append repo: - to avoid deleting dangling images that are in use. Unused dangling images are deleted separately 32 | "$(docker container ls -a --format '{{.Image}}\\|' --filter label=service=#{config.service} | tr -d '\\n')#{config.latest_image}\\|#{config.repository}:" 33 | end 34 | 35 | def service_filter 36 | [ "--filter", "label=service=#{config.service}" ] 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | tagInput: 7 | description: 'Tag' 8 | required: true 9 | 10 | release: 11 | types: [created] 12 | tags: 13 | - 'v*' 14 | 15 | jobs: 16 | build-and-push-image: 17 | runs-on: ubuntu-latest 18 | permissions: 19 | contents: read 20 | packages: write 21 | steps: 22 | - 23 | name: Checkout 24 | uses: actions/checkout@v3 25 | - 26 | name: Set up QEMU 27 | uses: docker/setup-qemu-action@v2 28 | - 29 | name: Set up Docker Buildx 30 | uses: docker/setup-buildx-action@v2 31 | - 32 | name: Login to GitHub Container Registry 33 | uses: docker/login-action@v2 34 | with: 35 | registry: ghcr.io 36 | username: ${{ github.actor }} 37 | password: ${{ secrets.GITHUB_TOKEN }} 38 | - name: Determine version tag 39 | id: version-tag 40 | run: | 41 | INPUT_VALUE="${{ github.event.inputs.tagInput }}" 42 | if [ -z "$INPUT_VALUE" ]; then 43 | INPUT_VALUE="${{ github.ref_name }}" 44 | fi 45 | echo "::set-output name=value::$INPUT_VALUE" 46 | - 47 | name: Build and push 48 | uses: docker/build-push-action@v3 49 | with: 50 | context: . 51 | platforms: linux/amd64,linux/arm64 52 | push: true 53 | tags: | 54 | ghcr.io/basecamp/kamal:latest 55 | ghcr.io/basecamp/kamal:${{ steps.version-tag.outputs.value }} 56 | -------------------------------------------------------------------------------- /test/configuration/ssh_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ConfigurationSshTest < ActiveSupport::TestCase 4 | setup do 5 | @deploy = { 6 | service: "app", image: "dhh/app", 7 | registry: { "username" => "dhh", "password" => "secret" }, 8 | builder: { "arch" => "amd64" }, 9 | env: { "REDIS_URL" => "redis://x/y" }, 10 | servers: [ "1.1.1.1", "1.1.1.2" ], 11 | volumes: [ "/local/path:/container/path" ] 12 | } 13 | 14 | @config = Kamal::Configuration.new(@deploy) 15 | end 16 | 17 | test "ssh options" do 18 | assert_equal "root", @config.ssh.options[:user] 19 | 20 | config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "user" => "app" }) }) 21 | assert_equal "app", config.ssh.options[:user] 22 | assert_equal 4, config.ssh.options[:logger].level 23 | 24 | config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "log_level" => "debug" }) }) 25 | assert_equal 0, config.ssh.options[:logger].level 26 | 27 | config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "port" => 2222 }) }) 28 | assert_equal 2222, config.ssh.options[:port] 29 | end 30 | 31 | test "ssh options with proxy host" do 32 | config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "proxy" => "1.2.3.4" }) }) 33 | assert_equal "root@1.2.3.4", config.ssh.options[:proxy].jump_proxies 34 | end 35 | 36 | test "ssh options with proxy host and user" do 37 | config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "proxy" => "app@1.2.3.4" }) }) 38 | assert_equal "app@1.2.3.4", config.ssh.options[:proxy].jump_proxies 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | jobs: 8 | rubocop: 9 | name: RuboCop 10 | runs-on: ubuntu-latest 11 | env: 12 | BUNDLE_ONLY: rubocop 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | - name: Setup Ruby and install gems 17 | uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: 3.3.0 20 | bundler-cache: true 21 | - name: Run Rubocop 22 | run: bundle exec rubocop --parallel 23 | tests: 24 | strategy: 25 | matrix: 26 | ruby-version: 27 | - "3.1" 28 | - "3.2" 29 | - "3.3" 30 | - "3.4.0-preview2" 31 | gemfile: 32 | - Gemfile 33 | - gemfiles/rails_edge.gemfile 34 | exclude: 35 | - ruby-version: "3.1" 36 | gemfile: gemfiles/rails_edge.gemfile 37 | name: ${{ format('Tests (Ruby {0})', matrix.ruby-version) }} 38 | runs-on: ubuntu-latest 39 | continue-on-error: true 40 | env: 41 | BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }} 42 | steps: 43 | - uses: actions/checkout@v4 44 | 45 | - name: Remove gemfile.lock 46 | run: rm Gemfile.lock 47 | 48 | - name: Install Ruby 49 | uses: ruby/setup-ruby@v1 50 | with: 51 | ruby-version: ${{ matrix.ruby-version }} 52 | bundler-cache: true 53 | 54 | - name: Run tests 55 | run: bin/test 56 | env: 57 | RUBYOPT: ${{ startsWith(matrix.ruby-version, '3.4.') && '--enable=frozen-string-literal' || '' }} 58 | -------------------------------------------------------------------------------- /lib/kamal/configuration/validator/env.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Configuration::Validator::Env < Kamal::Configuration::Validator 2 | SPECIAL_KEYS = [ "clear", "secret", "tags" ] 3 | 4 | def validate! 5 | if known_keys.any? 6 | validate_complex_env! 7 | else 8 | validate_simple_env! 9 | end 10 | end 11 | 12 | private 13 | def validate_simple_env! 14 | validate_hash_of!(config, String) 15 | end 16 | 17 | def validate_complex_env! 18 | unknown_keys_error unknown_keys if unknown_keys.any? 19 | 20 | with_context("clear") { validate_hash_of!(config["clear"], String) } if config.key?("clear") 21 | with_context("secret") { validate_array_of!(config["secret"], String) } if config.key?("secret") 22 | validate_tags! if config.key?("tags") 23 | end 24 | 25 | def known_keys 26 | @known_keys ||= config.keys & SPECIAL_KEYS 27 | end 28 | 29 | def unknown_keys 30 | @unknown_keys ||= config.keys - SPECIAL_KEYS 31 | end 32 | 33 | def validate_tags! 34 | if context == "env" 35 | with_context("tags") do 36 | validate_type! config["tags"], Hash 37 | 38 | config["tags"].each do |tag, value| 39 | with_context(tag) do 40 | validate_type! value, Hash 41 | 42 | Kamal::Configuration::Validator::Env.new( 43 | value, 44 | example: example["tags"].values[1], 45 | context: context 46 | ).validate! 47 | end 48 | end 49 | end 50 | else 51 | error "tags are only allowed in the root env" 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/commands/prune_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class CommandsPruneTest < ActiveSupport::TestCase 4 | setup do 5 | @config = { 6 | service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], 7 | builder: { "arch" => "amd64" } 8 | } 9 | end 10 | 11 | test "dangling images" do 12 | assert_equal \ 13 | "docker image prune --force --filter label=service=app", 14 | new_command.dangling_images.join(" ") 15 | end 16 | 17 | test "tagged images" do 18 | assert_equal \ 19 | "docker image ls --filter label=service=app --format '{{.ID}} {{.Repository}}:{{.Tag}}' | grep -v -w \"$(docker container ls -a --format '{{.Image}}\\|' --filter label=service=app | tr -d '\\n')dhh/app:latest\\|dhh/app:\" | while read image tag; do docker rmi $tag; done", 20 | new_command.tagged_images.join(" ") 21 | end 22 | 23 | test "app containers" do 24 | assert_equal \ 25 | "docker ps -q -a --filter label=service=app --filter status=created --filter status=exited --filter status=dead | tail -n +6 | while read container_id; do docker rm $container_id; done", 26 | new_command.app_containers(retain: 5).join(" ") 27 | 28 | assert_equal \ 29 | "docker ps -q -a --filter label=service=app --filter status=created --filter status=exited --filter status=dead | tail -n +4 | while read container_id; do docker rm $container_id; done", 30 | new_command.app_containers(retain: 3).join(" ") 31 | end 32 | 33 | private 34 | def new_command 35 | Kamal::Commands::Prune.new(Kamal::Configuration.new(@config, version: "123")) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/kamal/secrets/adapters/doppler.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Secrets::Adapters::Doppler < Kamal::Secrets::Adapters::Base 2 | def requires_account? 3 | false 4 | end 5 | 6 | private 7 | def login(*) 8 | unless loggedin? 9 | `doppler login -y` 10 | raise RuntimeError, "Failed to login to Doppler" unless $?.success? 11 | end 12 | end 13 | 14 | def loggedin? 15 | `doppler me --json 2> /dev/null` 16 | $?.success? 17 | end 18 | 19 | def fetch_secrets(secrets, **) 20 | project_and_config_flags = "" 21 | unless service_token_set? 22 | project, config, _ = secrets.first.split("/") 23 | 24 | unless project && config 25 | raise RuntimeError, "Missing project or config from '--from=project/config' option" 26 | end 27 | 28 | project_and_config_flags = "-p #{project.shellescape} -c #{config.shellescape}" 29 | end 30 | 31 | secret_names = secrets.collect { |s| s.split("/").last } 32 | 33 | items = `doppler secrets get #{secret_names.map(&:shellescape).join(" ")} --json #{project_and_config_flags}` 34 | raise RuntimeError, "Could not read #{secrets} from Doppler" unless $?.success? 35 | 36 | items = JSON.parse(items) 37 | 38 | items.transform_values { |value| value["computed"] } 39 | end 40 | 41 | def service_token_set? 42 | ENV["DOPPLER_TOKEN"] && ENV["DOPPLER_TOKEN"][0, 5] == "dp.st" 43 | end 44 | 45 | def check_dependencies! 46 | raise RuntimeError, "Doppler CLI is not installed" unless cli_installed? 47 | end 48 | 49 | def cli_installed? 50 | `doppler --version 2> /dev/null` 51 | $?.success? 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/kamal/commands/builder/remote.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Commands::Builder::Remote < Kamal::Commands::Builder::Base 2 | def create 3 | chain \ 4 | create_remote_context, 5 | create_buildx 6 | end 7 | 8 | def remove 9 | chain \ 10 | remove_remote_context, 11 | remove_buildx 12 | end 13 | 14 | def info 15 | chain \ 16 | docker(:context, :ls), 17 | docker(:buildx, :ls) 18 | end 19 | 20 | def inspect_builder 21 | combine \ 22 | combine inspect_buildx, inspect_remote_context, 23 | [ "(echo no compatible builder && exit 1)" ], 24 | by: "||" 25 | end 26 | 27 | private 28 | def builder_name 29 | "kamal-remote-#{remote.gsub(/[^a-z0-9_-]/, "-")}" 30 | end 31 | 32 | def remote_context_name 33 | "#{builder_name}-context" 34 | end 35 | 36 | def inspect_buildx 37 | pipe \ 38 | docker(:buildx, :inspect, builder_name), 39 | grep("-q", "Endpoint:.*#{remote_context_name}") 40 | end 41 | 42 | def inspect_remote_context 43 | pipe \ 44 | docker(:context, :inspect, remote_context_name, "--format", ENDPOINT_DOCKER_HOST_INSPECT), 45 | grep("-xq", remote) 46 | end 47 | 48 | def create_remote_context 49 | docker :context, :create, remote_context_name, "--description", "'#{builder_name} host'", "--docker", "'host=#{remote}'" 50 | end 51 | 52 | def remove_remote_context 53 | docker :context, :rm, remote_context_name 54 | end 55 | 56 | def create_buildx 57 | docker :buildx, :create, "--name", builder_name, remote_context_name 58 | end 59 | 60 | def remove_buildx 61 | docker :buildx, :rm, builder_name 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/integration/proxy_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "integration_test" 2 | 3 | class ProxyTest < IntegrationTest 4 | setup do 5 | @app = "app_with_roles" 6 | end 7 | 8 | test "boot, reboot, stop, start, restart, logs, remove" do 9 | kamal :proxy, :boot 10 | assert_proxy_running 11 | 12 | output = kamal :proxy, :reboot, "-y", "--verbose", capture: true 13 | assert_proxy_running 14 | assert_hooks_ran "pre-proxy-reboot", "post-proxy-reboot" 15 | assert_match /Rebooting kamal-proxy on vm1,vm2.../, output 16 | assert_match /Rebooted kamal-proxy on vm1,vm2/, output 17 | 18 | output = kamal :proxy, :reboot, "--rolling", "-y", "--verbose", capture: true 19 | assert_proxy_running 20 | assert_hooks_ran "pre-proxy-reboot", "post-proxy-reboot" 21 | assert_match /Rebooting kamal-proxy on vm1.../, output 22 | assert_match /Rebooted kamal-proxy on vm1/, output 23 | assert_match /Rebooting kamal-proxy on vm2.../, output 24 | assert_match /Rebooted kamal-proxy on vm2/, output 25 | 26 | kamal :proxy, :boot 27 | assert_proxy_running 28 | 29 | # Check booting when booted doesn't raise an error 30 | kamal :proxy, :stop 31 | assert_proxy_not_running 32 | 33 | # Check booting when stopped works 34 | kamal :proxy, :boot 35 | assert_proxy_running 36 | 37 | kamal :proxy, :stop 38 | assert_proxy_not_running 39 | 40 | kamal :proxy, :start 41 | assert_proxy_running 42 | 43 | kamal :proxy, :restart 44 | assert_proxy_running 45 | 46 | logs = kamal :proxy, :logs, capture: true 47 | assert_match /No previous state to restore/, logs 48 | 49 | kamal :proxy, :remove 50 | assert_proxy_not_running 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/kamal/configuration/docs/role.yml: -------------------------------------------------------------------------------- 1 | # Roles 2 | # 3 | # Roles are used to configure different types of servers in the deployment. 4 | # The most common use for this is to run web servers and job servers. 5 | # 6 | # Kamal expects there to be a `web` role, unless you set a different `primary_role` 7 | # in the root configuration. 8 | 9 | # Role configuration 10 | # 11 | # Roles are specified under the servers key: 12 | servers: 13 | 14 | # Simple role configuration 15 | # 16 | # This can be a list of hosts if you don't need custom configuration for the role. 17 | # 18 | # You can set tags on the hosts for custom env variables (see kamal docs env): 19 | web: 20 | - 172.1.0.1 21 | - 172.1.0.2: experiment1 22 | - 172.1.0.2: [ experiment1, experiment2 ] 23 | 24 | # Custom role configuration 25 | # 26 | # When there are other options to set, the list of hosts goes under the `hosts` key. 27 | # 28 | # By default, only the primary role uses a proxy. 29 | # 30 | # For other roles, you can set it to `proxy: true` to enable it and inherit the root proxy 31 | # configuration or provide a map of options to override the root configuration. 32 | # 33 | # For the primary role, you can set `proxy: false` to disable the proxy. 34 | # 35 | # You can also set a custom `cmd` to run in the container and overwrite other settings 36 | # from the root configuration. 37 | workers: 38 | hosts: 39 | - 172.1.0.3 40 | - 172.1.0.4: experiment1 41 | cmd: "bin/jobs" 42 | options: 43 | memory: 2g 44 | cpus: 4 45 | logging: 46 | ... 47 | proxy: 48 | ... 49 | labels: 50 | my-label: workers 51 | env: 52 | ... 53 | asset_path: /public 54 | -------------------------------------------------------------------------------- /lib/kamal/commands/lock.rb: -------------------------------------------------------------------------------- 1 | require "active_support/duration" 2 | require "time" 3 | require "base64" 4 | 5 | class Kamal::Commands::Lock < Kamal::Commands::Base 6 | def acquire(message, version) 7 | combine \ 8 | [ :mkdir, lock_dir ], 9 | write_lock_details(message, version) 10 | end 11 | 12 | def release 13 | combine \ 14 | [ :rm, lock_details_file ], 15 | [ :rm, "-r", lock_dir ] 16 | end 17 | 18 | def status 19 | combine \ 20 | stat_lock_dir, 21 | read_lock_details 22 | end 23 | 24 | def ensure_locks_directory 25 | [ :mkdir, "-p", locks_dir ] 26 | end 27 | 28 | private 29 | def write_lock_details(message, version) 30 | write \ 31 | [ :echo, "\"#{Base64.encode64(lock_details(message, version))}\"" ], 32 | lock_details_file 33 | end 34 | 35 | def read_lock_details 36 | pipe \ 37 | [ :cat, lock_details_file ], 38 | [ :base64, "-d" ] 39 | end 40 | 41 | def stat_lock_dir 42 | write \ 43 | [ :stat, lock_dir ], 44 | "/dev/null" 45 | end 46 | 47 | def lock_dir 48 | dir_name = [ "lock", config.service, config.destination ].compact.join("-") 49 | 50 | File.join(config.run_directory, dir_name) 51 | end 52 | 53 | def lock_details_file 54 | File.join(lock_dir, "details") 55 | end 56 | 57 | def lock_details(message, version) 58 | <<~DETAILS.strip 59 | Locked by: #{locked_by} at #{Time.now.utc.iso8601} 60 | Version: #{version} 61 | Message: #{message} 62 | DETAILS 63 | end 64 | 65 | def locked_by 66 | Kamal::Git.user_name 67 | rescue Errno::ENOENT 68 | "Unknown" 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /test/cli/prune_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "cli_test_case" 2 | 3 | class CliPruneTest < CliTestCase 4 | test "all" do 5 | Kamal::Cli::Prune.any_instance.expects(:containers) 6 | Kamal::Cli::Prune.any_instance.expects(:images) 7 | 8 | run_command("all") 9 | end 10 | 11 | test "images" do 12 | run_command("images").tap do |output| 13 | assert_match "docker image prune --force --filter label=service=app on 1.1.1.", output 14 | assert_match "docker image ls --filter label=service=app --format '{{.ID}} {{.Repository}}:{{.Tag}}' | grep -v -w \"$(docker container ls -a --format '{{.Image}}\\|' --filter label=service=app | tr -d '\\n')dhh/app:latest\\|dhh/app:\" | while read image tag; do docker rmi $tag; done on 1.1.1.", output 15 | end 16 | end 17 | 18 | test "containers" do 19 | run_command("containers").tap do |output| 20 | assert_match /docker ps -q -a --filter label=service=app --filter status=created --filter status=exited --filter status=dead | tail -n +6 | while read container_id; do docker rm $container_id; done on 1.1.1.\d/, output 21 | end 22 | 23 | run_command("containers", "--retain", "10").tap do |output| 24 | assert_match /docker ps -q -a --filter label=service=app --filter status=created --filter status=exited --filter status=dead | tail -n +11 | while read container_id; do docker rm $container_id; done on 1.1.1.\d/, output 25 | end 26 | 27 | assert_raises(RuntimeError, "retain must be at least 1") do 28 | run_command("containers", "--retain", "0") 29 | end 30 | end 31 | 32 | private 33 | def run_command(*command) 34 | stdouted { Kamal::Cli::Prune.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) } 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/commands/hook_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class CommandsHookTest < ActiveSupport::TestCase 4 | include ActiveSupport::Testing::TimeHelpers 5 | 6 | setup do 7 | freeze_time 8 | 9 | @config = { 10 | service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], 11 | builder: { "arch" => "amd64" } 12 | } 13 | 14 | @performer = Kamal::Git.email.presence || `whoami`.chomp 15 | @recorded_at = Time.now.utc.iso8601 16 | end 17 | 18 | test "run" do 19 | assert_equal [ ".kamal/hooks/foo" ], new_command.run("foo") 20 | end 21 | 22 | test "env" do 23 | assert_equal ({ 24 | "KAMAL_RECORDED_AT" => @recorded_at, 25 | "KAMAL_PERFORMER" => @performer, 26 | "KAMAL_VERSION" => "123", 27 | "KAMAL_SERVICE_VERSION" => "app@123", 28 | "KAMAL_SERVICE" => "app" 29 | }), new_command.env 30 | end 31 | 32 | test "run with custom hooks_path" do 33 | assert_equal [ "custom/hooks/path/foo" ], new_command(hooks_path: "custom/hooks/path").run("foo") 34 | end 35 | 36 | test "env with secrets" do 37 | with_test_secrets("secrets" => "DB_PASSWORD=secret") do 38 | assert_equal ( 39 | { 40 | "KAMAL_RECORDED_AT" => @recorded_at, 41 | "KAMAL_PERFORMER" => @performer, 42 | "KAMAL_VERSION" => "123", 43 | "KAMAL_SERVICE_VERSION" => "app@123", 44 | "KAMAL_SERVICE" => "app", 45 | "DB_PASSWORD" => "secret" } 46 | ), new_command.env(secrets: true) 47 | end 48 | end 49 | 50 | private 51 | def new_command(**extra_config) 52 | Kamal::Commands::Hook.new(Kamal::Configuration.new(@config.merge(**extra_config), version: "123")) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.2 2 | 3 | WORKDIR / 4 | 5 | ENV VERBOSE=true 6 | 7 | RUN apt-get update --fix-missing && apt-get install -y ca-certificates openssh-client curl gnupg docker.io 8 | 9 | RUN install -m 0755 -d /etc/apt/keyrings 10 | RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg 11 | RUN chmod a+r /etc/apt/keyrings/docker.gpg 12 | RUN echo \ 13 | "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \ 14 | "$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \ 15 | tee /etc/apt/sources.list.d/docker.list > /dev/null 16 | 17 | RUN apt-get update --fix-missing && apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin 18 | 19 | COPY *.sh . 20 | COPY app/ app/ 21 | COPY app_with_roles/ app_with_roles/ 22 | COPY app_with_traefik/ app_with_traefik/ 23 | COPY app_with_proxied_accessory/ app_with_proxied_accessory/ 24 | 25 | RUN rm -rf /root/.ssh 26 | RUN ln -s /shared/ssh /root/.ssh 27 | RUN mkdir -p /etc/docker/certs.d/registry:4443 && ln -s /shared/certs/domain.crt /etc/docker/certs.d/registry:4443/ca.crt 28 | 29 | RUN git config --global user.email "deployer@example.com" 30 | RUN git config --global user.name "Deployer" 31 | RUN cd app && git init && git add . && git commit -am "Initial version" 32 | RUN cd app_with_roles && git init && git add . && git commit -am "Initial version" 33 | RUN cd app_with_traefik && git init && git add . && git commit -am "Initial version" 34 | RUN cd app_with_proxied_accessory && git init && git add . && git commit -am "Initial version" 35 | 36 | HEALTHCHECK --interval=1s CMD pgrep sleep 37 | 38 | CMD ["./boot.sh"] 39 | -------------------------------------------------------------------------------- /test/integration/app_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "integration_test" 2 | 3 | class AppTest < IntegrationTest 4 | test "stop, start, boot, logs, images, containers, exec, remove" do 5 | kamal :deploy 6 | 7 | assert_app_is_up 8 | 9 | kamal :app, :stop 10 | 11 | assert_app_not_found 12 | 13 | kamal :app, :start 14 | 15 | # kamal app start does not wait 16 | wait_for_app_to_be_up 17 | 18 | kamal :app, :boot 19 | 20 | wait_for_app_to_be_up 21 | 22 | logs = kamal :app, :logs, capture: true 23 | assert_match "App Host: vm1", logs 24 | assert_match "App Host: vm2", logs 25 | assert_match "GET /version HTTP/1.1", logs 26 | 27 | images = kamal :app, :images, capture: true 28 | assert_match "App Host: vm1", images 29 | assert_match "App Host: vm2", images 30 | assert_match /registry:4443\/app\s+#{latest_app_version}/, images 31 | assert_match /registry:4443\/app\s+latest/, images 32 | 33 | containers = kamal :app, :containers, capture: true 34 | assert_match "App Host: vm1", containers 35 | assert_match "App Host: vm2", containers 36 | assert_match "registry:4443/app:#{latest_app_version}", containers 37 | assert_match "registry:4443/app:latest", containers 38 | 39 | exec_output = kamal :app, :exec, :ps, capture: true 40 | assert_match "App Host: vm1", exec_output 41 | assert_match "App Host: vm2", exec_output 42 | assert_match /1 root 0:\d\d ps/, exec_output 43 | 44 | exec_output = kamal :app, :exec, "--reuse", :ps, capture: true 45 | assert_match "App Host: vm2", exec_output 46 | assert_match "App Host: vm1", exec_output 47 | assert_match /1 root 0:\d\d nginx/, exec_output 48 | 49 | kamal :app, :remove 50 | 51 | assert_app_not_found 52 | assert_app_directory_removed 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/kamal/cli/server.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Cli::Server < Kamal::Cli::Base 2 | desc "exec", "Run a custom command on the server (use --help to show options)" 3 | option :interactive, type: :boolean, aliases: "-i", default: false, desc: "Run the command interactively (use for console/bash)" 4 | def exec(*cmd) 5 | cmd = Kamal::Utils.join_commands(cmd) 6 | hosts = KAMAL.hosts | KAMAL.accessory_hosts 7 | 8 | case 9 | when options[:interactive] 10 | host = KAMAL.primary_host 11 | 12 | say "Running '#{cmd}' on #{host} interactively...", :magenta 13 | 14 | run_locally { exec KAMAL.server.run_over_ssh(cmd, host: host) } 15 | else 16 | say "Running '#{cmd}' on #{hosts.join(', ')}...", :magenta 17 | 18 | on(hosts) do |host| 19 | execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on #{host}"), verbosity: :debug 20 | puts_by_host host, capture_with_info(cmd) 21 | end 22 | end 23 | end 24 | 25 | desc "bootstrap", "Set up Docker to run Kamal apps" 26 | def bootstrap 27 | with_lock do 28 | missing = [] 29 | 30 | on(KAMAL.hosts | KAMAL.accessory_hosts) do |host| 31 | unless execute(*KAMAL.docker.installed?, raise_on_non_zero_exit: false) 32 | if execute(*KAMAL.docker.superuser?, raise_on_non_zero_exit: false) 33 | info "Missing Docker on #{host}. Installing…" 34 | execute *KAMAL.docker.install 35 | else 36 | missing << host 37 | end 38 | end 39 | end 40 | 41 | if missing.any? 42 | raise "Docker is not installed on #{missing.join(", ")} and can't be automatically installed without having root access and either `wget` or `curl`. Install Docker manually: https://docs.docker.com/engine/install/" 43 | end 44 | 45 | run_hook "docker-setup" 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/cli/registry_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "cli_test_case" 2 | 3 | class CliRegistryTest < CliTestCase 4 | test "login" do 5 | run_command("login").tap do |output| 6 | assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] as .*@localhost/, output 7 | assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] on 1.1.1.\d/, output 8 | end 9 | end 10 | 11 | test "login skip local" do 12 | run_command("login", "-L").tap do |output| 13 | assert_no_match /docker login -u \[REDACTED\] -p \[REDACTED\] as .*@localhost/, output 14 | assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] on 1.1.1.\d/, output 15 | end 16 | end 17 | 18 | test "login skip remote" do 19 | run_command("login", "-R").tap do |output| 20 | assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] as .*@localhost/, output 21 | assert_no_match /docker login -u \[REDACTED\] -p \[REDACTED\] on 1.1.1.\d/, output 22 | end 23 | end 24 | 25 | test "logout" do 26 | run_command("logout").tap do |output| 27 | assert_match /docker logout as .*@localhost/, output 28 | assert_match /docker logout on 1.1.1.\d/, output 29 | end 30 | end 31 | 32 | test "logout skip local" do 33 | run_command("logout", "-L").tap do |output| 34 | assert_no_match /docker logout as .*@localhost/, output 35 | assert_match /docker logout on 1.1.1.\d/, output 36 | end 37 | end 38 | 39 | test "logout skip remote" do 40 | run_command("logout", "-R").tap do |output| 41 | assert_match /docker logout as .*@localhost/, output 42 | assert_no_match /docker logout on 1.1.1.\d/, output 43 | end 44 | end 45 | 46 | private 47 | def run_command(*command) 48 | stdouted { Kamal::Cli::Registry.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) } 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/kamal/cli/secrets.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Cli::Secrets < Kamal::Cli::Base 2 | desc "fetch [SECRETS...]", "Fetch secrets from a vault" 3 | option :adapter, type: :string, aliases: "-a", required: true, desc: "Which vault adapter to use" 4 | option :account, type: :string, required: false, desc: "The account identifier or username" 5 | option :from, type: :string, required: false, desc: "A vault or folder to fetch the secrets from" 6 | option :inline, type: :boolean, required: false, hidden: true 7 | def fetch(*secrets) 8 | adapter = initialize_adapter(options[:adapter]) 9 | 10 | if adapter.requires_account? && options[:account].blank? 11 | return puts "No value provided for required options '--account'" 12 | end 13 | 14 | results = adapter.fetch(secrets, **options.slice(:account, :from).symbolize_keys) 15 | 16 | return_or_puts JSON.dump(results).shellescape, inline: options[:inline] 17 | end 18 | 19 | desc "extract", "Extract a single secret from the results of a fetch call" 20 | option :inline, type: :boolean, required: false, hidden: true 21 | def extract(name, secrets) 22 | parsed_secrets = JSON.parse(secrets) 23 | value = parsed_secrets[name] || parsed_secrets.find { |k, v| k.end_with?("/#{name}") }&.last 24 | 25 | raise "Could not find secret #{name}" if value.nil? 26 | 27 | return_or_puts value, inline: options[:inline] 28 | end 29 | 30 | desc "print", "Print the secrets (for debugging)" 31 | def print 32 | KAMAL.config.secrets.to_h.each do |key, value| 33 | puts "#{key}=#{value}" 34 | end 35 | end 36 | 37 | private 38 | def initialize_adapter(adapter) 39 | Kamal::Secrets::Adapters.lookup(adapter) 40 | end 41 | 42 | def return_or_puts(value, inline: nil) 43 | if inline 44 | value 45 | else 46 | puts value 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/kamal/commander/specifics.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Commander::Specifics 2 | attr_reader :primary_host, :primary_role, :hosts, :roles 3 | delegate :stable_sort!, to: Kamal::Utils 4 | 5 | def initialize(config, specific_hosts, specific_roles) 6 | @config, @specific_hosts, @specific_roles = config, specific_hosts, specific_roles 7 | 8 | @roles, @hosts = specified_roles, specified_hosts 9 | 10 | @primary_host = specific_hosts&.first || primary_specific_role&.primary_host || config.primary_host 11 | @primary_role = primary_or_first_role(roles_on(primary_host)) 12 | 13 | stable_sort!(roles) { |role| role == primary_role ? 0 : 1 } 14 | stable_sort!(hosts) { |host| roles_on(host).any? { |role| role == primary_role } ? 0 : 1 } 15 | end 16 | 17 | def roles_on(host) 18 | roles.select { |role| role.hosts.include?(host.to_s) } 19 | end 20 | 21 | def proxy_hosts 22 | config.proxy_hosts & specified_hosts 23 | end 24 | 25 | def accessory_hosts 26 | config.accessories.flat_map(&:hosts) & specified_hosts 27 | end 28 | 29 | private 30 | attr_reader :config, :specific_hosts, :specific_roles 31 | 32 | def primary_specific_role 33 | primary_or_first_role(specific_roles) if specific_roles.present? 34 | end 35 | 36 | def primary_or_first_role(roles) 37 | roles.detect { |role| role == config.primary_role } || roles.first 38 | end 39 | 40 | def specified_roles 41 | (specific_roles || config.roles) \ 42 | .select { |role| ((specific_hosts || config.all_hosts) & role.hosts).any? } 43 | end 44 | 45 | def specified_hosts 46 | specified_hosts = specific_hosts || config.all_hosts 47 | 48 | if (specific_role_hosts = specific_roles&.flat_map(&:hosts)).present? 49 | specified_hosts.select { |host| specific_role_hosts.include?(host) } 50 | else 51 | specified_hosts 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/secrets_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class SecretsTest < ActiveSupport::TestCase 4 | test "fetch" do 5 | with_test_secrets("secrets" => "SECRET=ABC") do 6 | assert_equal "ABC", Kamal::Secrets.new["SECRET"] 7 | end 8 | end 9 | 10 | test "command interpolation" do 11 | with_test_secrets("secrets" => "SECRET=$(echo ABC)") do 12 | assert_equal "ABC", Kamal::Secrets.new["SECRET"] 13 | end 14 | end 15 | 16 | test "variable references" do 17 | with_test_secrets("secrets" => "SECRET1=ABC\nSECRET2=${SECRET1}DEF") do 18 | assert_equal "ABC", Kamal::Secrets.new["SECRET1"] 19 | assert_equal "ABCDEF", Kamal::Secrets.new["SECRET2"] 20 | end 21 | end 22 | 23 | test "destinations" do 24 | with_test_secrets("secrets.dest" => "SECRET=DEF", "secrets" => "SECRET=ABC", "secrets-common" => "SECRET=GHI\nSECRET2=JKL") do 25 | assert_equal "ABC", Kamal::Secrets.new["SECRET"] 26 | assert_equal "DEF", Kamal::Secrets.new(destination: "dest")["SECRET"] 27 | assert_equal "GHI", Kamal::Secrets.new(destination: "nodest")["SECRET"] 28 | 29 | assert_equal "JKL", Kamal::Secrets.new["SECRET2"] 30 | assert_equal "JKL", Kamal::Secrets.new(destination: "dest")["SECRET2"] 31 | assert_equal "JKL", Kamal::Secrets.new(destination: "nodest")["SECRET2"] 32 | end 33 | end 34 | 35 | test "no secrets files" do 36 | with_test_secrets do 37 | error = assert_raises(Kamal::ConfigurationError) do 38 | Kamal::Secrets.new["SECRET"] 39 | end 40 | assert_equal "Secret 'SECRET' not found, no secret files (.kamal/secrets-common, .kamal/secrets) provided", error.message 41 | 42 | error = assert_raises(Kamal::ConfigurationError) do 43 | Kamal::Secrets.new(destination: "dest")["SECRET"] 44 | end 45 | assert_equal "Secret 'SECRET' not found, no secret files (.kamal/secrets-common, .kamal/secrets.dest) provided", error.message 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/integration/proxied_accessory_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "integration_test" 2 | 3 | class ProxiedAccessoryTest < IntegrationTest 4 | test "boot, stop, start, restart, logs, remove" do 5 | @app = "app_with_proxied_accessory" 6 | 7 | kamal :deploy 8 | 9 | kamal :accessory, :boot, :netcat 10 | assert_accessory_running :netcat 11 | assert_netcat_is_up 12 | 13 | kamal :accessory, :stop, :netcat 14 | assert_accessory_not_running :netcat 15 | assert_netcat_not_found 16 | 17 | kamal :accessory, :start, :netcat 18 | assert_accessory_running :netcat 19 | assert_netcat_is_up 20 | 21 | kamal :accessory, :restart, :netcat 22 | assert_accessory_running :netcat 23 | assert_netcat_is_up 24 | 25 | kamal :accessory, :remove, :netcat, "-y" 26 | assert_accessory_not_running :netcat 27 | assert_netcat_not_found 28 | end 29 | 30 | private 31 | def assert_accessory_running(name) 32 | assert_match /registry:4443\/busybox:1.36.0 "sh -c 'echo \\"Start/, accessory_details(name) 33 | end 34 | 35 | def assert_accessory_not_running(name) 36 | assert_no_match /registry:4443\/busybox:1.36.0 "sh -c 'echo \\"Start/, accessory_details(name) 37 | end 38 | 39 | def accessory_details(name) 40 | kamal :accessory, :details, name, capture: true 41 | end 42 | 43 | def assert_netcat_is_up 44 | response = netcat_response 45 | debug_response_code(response, "200") 46 | assert_equal "200", response.code 47 | end 48 | 49 | def assert_netcat_not_found 50 | response = netcat_response 51 | debug_response_code(response, "404") 52 | assert_equal "404", response.code 53 | end 54 | 55 | def netcat_response 56 | uri = URI.parse("http://127.0.0.1:12345/up") 57 | http = Net::HTTP.new(uri.host, uri.port) 58 | request = Net::HTTP::Get.new(uri) 59 | request["Host"] = "netcat" 60 | 61 | http.request(request) 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/commands/registry_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class CommandsRegistryTest < ActiveSupport::TestCase 4 | setup do 5 | @config = { service: "app", 6 | image: "dhh/app", 7 | registry: { "username" => "dhh", 8 | "password" => "secret", 9 | "server" => "hub.docker.com" 10 | }, 11 | builder: { "arch" => "amd64" }, 12 | servers: [ "1.1.1.1" ] 13 | } 14 | end 15 | 16 | test "registry login" do 17 | assert_equal \ 18 | "docker login hub.docker.com -u \"dhh\" -p \"secret\"", 19 | registry.login.join(" ") 20 | end 21 | 22 | test "registry login with ENV password" do 23 | with_test_secrets("secrets" => "KAMAL_REGISTRY_PASSWORD=more-secret") do 24 | @config[:registry]["password"] = [ "KAMAL_REGISTRY_PASSWORD" ] 25 | 26 | assert_equal \ 27 | "docker login hub.docker.com -u \"dhh\" -p \"more-secret\"", 28 | registry.login.join(" ") 29 | end 30 | end 31 | 32 | test "registry login escape password" do 33 | with_test_secrets("secrets" => "KAMAL_REGISTRY_PASSWORD=more-secret'\"") do 34 | @config[:registry]["password"] = [ "KAMAL_REGISTRY_PASSWORD" ] 35 | 36 | assert_equal \ 37 | "docker login hub.docker.com -u \"dhh\" -p \"more-secret'\\\"\"", 38 | registry.login.join(" ") 39 | end 40 | end 41 | 42 | test "registry login with ENV username" do 43 | with_test_secrets("secrets" => "KAMAL_REGISTRY_USERNAME=also-secret") do 44 | @config[:registry]["username"] = [ "KAMAL_REGISTRY_USERNAME" ] 45 | 46 | assert_equal \ 47 | "docker login hub.docker.com -u \"also-secret\" -p \"secret\"", 48 | registry.login.join(" ") 49 | end 50 | end 51 | 52 | test "registry logout" do 53 | assert_equal \ 54 | "docker logout hub.docker.com", 55 | registry.logout.join(" ") 56 | end 57 | 58 | private 59 | def registry 60 | Kamal::Commands::Registry.new Kamal::Configuration.new(@config) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/configuration/env_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ConfigurationEnvTest < ActiveSupport::TestCase 4 | require "test_helper" 5 | 6 | test "simple" do 7 | assert_config \ 8 | config: { "foo" => "bar", "baz" => "haz" }, 9 | clear: { "foo" => "bar", "baz" => "haz" } 10 | end 11 | 12 | test "clear" do 13 | assert_config \ 14 | config: { "clear" => { "foo" => "bar", "baz" => "haz" } }, 15 | clear: { "foo" => "bar", "baz" => "haz" } 16 | end 17 | 18 | test "secret" do 19 | with_test_secrets("secrets" => "PASSWORD=hello") do 20 | assert_config \ 21 | config: { "secret" => [ "PASSWORD" ] }, 22 | secrets: { "PASSWORD" => "hello" } 23 | end 24 | end 25 | 26 | test "missing secret" do 27 | env = { 28 | "secret" => [ "PASSWORD" ] 29 | } 30 | 31 | assert_raises(Kamal::ConfigurationError) { Kamal::Configuration::Env.new(config: { "secret" => [ "PASSWORD" ] }, secrets: Kamal::Secrets.new).secrets_io } 32 | end 33 | 34 | test "secret and clear" do 35 | with_test_secrets("secrets" => "PASSWORD=hello") do 36 | config = { 37 | "secret" => [ "PASSWORD" ], 38 | "clear" => { 39 | "foo" => "bar", 40 | "baz" => "haz" 41 | } 42 | } 43 | 44 | assert_config \ 45 | config: config, 46 | clear: { "foo" => "bar", "baz" => "haz" }, 47 | secrets: { "PASSWORD" => "hello" } 48 | end 49 | end 50 | 51 | private 52 | def assert_config(config:, clear: {}, secrets: {}) 53 | env = Kamal::Configuration::Env.new config: config, secrets: Kamal::Secrets.new 54 | expected_clear_args = clear.to_a.flat_map { |key, value| [ "--env", "#{key}=\"#{value}\"" ] } 55 | assert_equal expected_clear_args, env.clear_args.map(&:to_s) #  to_s removes the redactions 56 | expected_secrets = secrets.to_a.flat_map { |key, value| "#{key}=#{value}" }.join("\n") + "\n" 57 | assert_equal expected_secrets, env.secrets_io.string 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/kamal/commands/app/assets.rb: -------------------------------------------------------------------------------- 1 | module Kamal::Commands::App::Assets 2 | def extract_assets 3 | asset_container = "#{role.container_prefix}-assets" 4 | 5 | combine \ 6 | make_directory(role.asset_extracted_directory), 7 | [ *docker(:stop, "-t 1", asset_container, "2> /dev/null"), "|| true" ], 8 | docker(:run, "--name", asset_container, "--detach", "--rm", "--entrypoint", "sleep", config.absolute_image, "1000000"), 9 | docker(:cp, "-L", "#{asset_container}:#{role.asset_path}/.", role.asset_extracted_directory), 10 | docker(:stop, "-t 1", asset_container), 11 | by: "&&" 12 | end 13 | 14 | def sync_asset_volumes(old_version: nil) 15 | new_extracted_path, new_volume_path = role.asset_extracted_directory(config.version), role.asset_volume.host_path 16 | if old_version.present? 17 | old_extracted_path, old_volume_path = role.asset_extracted_directory(old_version), role.asset_volume(old_version).host_path 18 | end 19 | 20 | commands = [ make_directory(new_volume_path), copy_contents(new_extracted_path, new_volume_path) ] 21 | 22 | if old_version.present? 23 | commands << copy_contents(new_extracted_path, old_volume_path, continue_on_error: true) 24 | commands << copy_contents(old_extracted_path, new_volume_path, continue_on_error: true) 25 | end 26 | 27 | chain *commands 28 | end 29 | 30 | def clean_up_assets 31 | chain \ 32 | find_and_remove_older_siblings(role.asset_extracted_directory), 33 | find_and_remove_older_siblings(role.asset_volume_directory) 34 | end 35 | 36 | private 37 | def find_and_remove_older_siblings(path) 38 | [ 39 | :find, 40 | Pathname.new(path).dirname.to_s, 41 | "-maxdepth 1", 42 | "-name", "'#{role.name}-*'", 43 | "!", "-name", Pathname.new(path).basename.to_s, 44 | "-exec rm -rf \"{}\" +" 45 | ] 46 | end 47 | 48 | def copy_contents(source, destination, continue_on_error: false) 49 | [ :cp, "-rnT", "#{source}", destination, *("|| true" if continue_on_error) ] 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/kamal/configuration/docs/registry.yml: -------------------------------------------------------------------------------- 1 | # Registry 2 | # 3 | # The default registry is Docker Hub, but you can change it using `registry/server`. 4 | # 5 | # A reference to a secret (in this case, `DOCKER_REGISTRY_TOKEN`) will look up the secret 6 | # in the local environment: 7 | registry: 8 | server: registry.digitalocean.com 9 | username: 10 | - DOCKER_REGISTRY_TOKEN 11 | password: 12 | - DOCKER_REGISTRY_TOKEN 13 | 14 | # Using AWS ECR as the container registry 15 | # 16 | # You will need to have the AWS CLI installed locally for this to work. 17 | # AWS ECR’s access token is only valid for 12 hours. In order to avoid having to manually regenerate the token every time, you can use ERB in the `deploy.yml` file to shell out to the AWS CLI command and obtain the token: 18 | registry: 19 | server: .dkr.ecr..amazonaws.com 20 | username: AWS 21 | password: <%= %x(aws ecr get-login-password) %> 22 | 23 | # Using GCP Artifact Registry as the container registry 24 | # 25 | # To sign into Artifact Registry, you need to 26 | # [create a service account](https://cloud.google.com/iam/docs/service-accounts-create#creating) 27 | # and [set up roles and permissions](https://cloud.google.com/artifact-registry/docs/access-control#permissions). 28 | # Normally, assigning the `roles/artifactregistry.writer` role should be sufficient. 29 | # 30 | # Once the service account is ready, you need to generate and download a JSON key and base64 encode it: 31 | # 32 | # ```shell 33 | # base64 -i /path/to/key.json | tr -d "\\n" 34 | # ``` 35 | # 36 | # You'll then need to set the `KAMAL_REGISTRY_PASSWORD` secret to that value. 37 | # 38 | # Use the environment variable as the password along with `_json_key_base64` as the username. 39 | # Here’s the final configuration: 40 | registry: 41 | server: -docker.pkg.dev 42 | username: _json_key_base64 43 | password: 44 | - KAMAL_REGISTRY_PASSWORD 45 | 46 | # Validating the configuration 47 | # 48 | # You can validate the configuration by running: 49 | # 50 | # ```shell 51 | # kamal registry login 52 | # ``` 53 | -------------------------------------------------------------------------------- /lib/kamal/cli/build/clone.rb: -------------------------------------------------------------------------------- 1 | require "uri" 2 | 3 | class Kamal::Cli::Build::Clone 4 | attr_reader :sshkit 5 | delegate :info, :error, :execute, :capture_with_info, to: :sshkit 6 | 7 | def initialize(sshkit) 8 | @sshkit = sshkit 9 | end 10 | 11 | def prepare 12 | begin 13 | clone_repo 14 | rescue SSHKit::Command::Failed => e 15 | if e.message =~ /already exists and is not an empty directory/ 16 | reset 17 | else 18 | raise Kamal::Cli::Build::BuildError, "Failed to clone repo: #{e.message}" 19 | end 20 | end 21 | 22 | validate! 23 | rescue Kamal::Cli::Build::BuildError => e 24 | error "Error preparing clone: #{e.message}, deleting and retrying..." 25 | 26 | FileUtils.rm_rf KAMAL.config.builder.clone_directory 27 | clone_repo 28 | validate! 29 | end 30 | 31 | private 32 | def clone_repo 33 | info "Cloning repo into build directory `#{KAMAL.config.builder.build_directory}`..." 34 | 35 | FileUtils.mkdir_p KAMAL.config.builder.clone_directory 36 | execute *KAMAL.builder.clone 37 | end 38 | 39 | def reset 40 | info "Resetting local clone as `#{KAMAL.config.builder.build_directory}` already exists..." 41 | 42 | KAMAL.builder.clone_reset_steps.each { |step| execute *step } 43 | rescue SSHKit::Command::Failed => e 44 | raise Kamal::Cli::Build::BuildError, "Failed to clone repo: #{e.message}" 45 | end 46 | 47 | def validate! 48 | status = capture_with_info(*KAMAL.builder.clone_status).strip 49 | 50 | unless status.empty? 51 | raise Kamal::Cli::Build::BuildError, "Clone in #{KAMAL.config.builder.build_directory} is dirty, #{status}" 52 | end 53 | 54 | revision = capture_with_info(*KAMAL.builder.clone_revision).strip 55 | if revision != Kamal::Git.revision 56 | raise Kamal::Cli::Build::BuildError, "Clone in #{KAMAL.config.builder.build_directory} is not on the correct revision, expected `#{Kamal::Git.revision}` but got `#{revision}`" 57 | end 58 | rescue SSHKit::Command::Failed => e 59 | raise Kamal::Cli::Build::BuildError, "Failed to validate clone: #{e.message}" 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/cli/cli_test_case.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class CliTestCase < ActiveSupport::TestCase 4 | setup do 5 | ENV["VERSION"] = "999" 6 | ENV["RAILS_MASTER_KEY"] = "123" 7 | ENV["MYSQL_ROOT_PASSWORD"] = "secret123" 8 | Object.send(:remove_const, :KAMAL) 9 | Object.const_set(:KAMAL, Kamal::Commander.new) 10 | end 11 | 12 | teardown do 13 | ENV.delete("RAILS_MASTER_KEY") 14 | ENV.delete("MYSQL_ROOT_PASSWORD") 15 | ENV.delete("VERSION") 16 | end 17 | 18 | private 19 | def fail_hook(hook) 20 | @executions = [] 21 | Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) 22 | 23 | SSHKit::Backend::Abstract.any_instance.stubs(:execute) 24 | .with { |*args| @executions << args; args != [ ".kamal/hooks/#{hook}" ] } 25 | SSHKit::Backend::Abstract.any_instance.stubs(:execute) 26 | .with { |*args| args.first == ".kamal/hooks/#{hook}" } 27 | .raises(SSHKit::Command::Failed.new("failed")) 28 | end 29 | 30 | def stub_setup 31 | SSHKit::Backend::Abstract.any_instance.stubs(:execute) 32 | .with { |*args| args == [ :mkdir, "-p", ".kamal/apps/app" ] } 33 | SSHKit::Backend::Abstract.any_instance.stubs(:execute) 34 | .with { |arg1, arg2, arg3| arg1 == :mkdir && arg2 == "-p" && arg3 == ".kamal/lock-app" } 35 | SSHKit::Backend::Abstract.any_instance.stubs(:execute) 36 | .with { |arg1, arg2| arg1 == :mkdir && arg2 == ".kamal/lock-app" } 37 | SSHKit::Backend::Abstract.any_instance.stubs(:execute) 38 | .with { |arg1, arg2| arg1 == :rm && arg2 == ".kamal/lock-app/details" } 39 | SSHKit::Backend::Abstract.any_instance.stubs(:execute) 40 | .with(:docker, :buildx, :inspect, "kamal-local-docker-container") 41 | end 42 | 43 | def assert_hook_ran(hook, output, version:, service_version:, hosts:, command:, subcommand: nil, runtime: false, secrets: false) 44 | assert_match %r{usr/bin/env\s\.kamal/hooks/#{hook}}, output 45 | end 46 | 47 | def with_argv(*argv) 48 | old_argv = ARGV 49 | ARGV.replace(*argv) 50 | yield 51 | ensure 52 | ARGV.replace(old_argv) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/kamal/configuration/docs/ssh.yml: -------------------------------------------------------------------------------- 1 | # SSH configuration 2 | # 3 | # Kamal uses SSH to connect and run commands on your hosts. 4 | # By default, it will attempt to connect to the root user on port 22. 5 | # 6 | # If you are using a non-root user, you may need to bootstrap your servers manually before using them with Kamal. On Ubuntu, you’d do: 7 | # 8 | # ```shell 9 | # sudo apt update 10 | # sudo apt upgrade -y 11 | # sudo apt install -y docker.io curl git 12 | # sudo usermod -a -G docker app 13 | # ``` 14 | 15 | # SSH options 16 | # 17 | # The options are specified under the ssh key in the configuration file. 18 | ssh: 19 | 20 | # The SSH user 21 | # 22 | # Defaults to `root`: 23 | user: app 24 | 25 | # The SSH port 26 | # 27 | # Defaults to 22: 28 | port: "2222" 29 | 30 | # Proxy host 31 | # 32 | # Specified in the form or @: 33 | proxy: root@proxy-host 34 | 35 | # Proxy command 36 | # 37 | # A custom proxy command, required for older versions of SSH: 38 | proxy_command: "ssh -W %h:%p user@proxy" 39 | 40 | # Log level 41 | # 42 | # Defaults to `fatal`. Set this to `debug` if you are having SSH connection issues. 43 | log_level: debug 44 | 45 | # Keys only 46 | # 47 | # Set to `true` to use only private keys from the `keys` and `key_data` parameters, 48 | # even if ssh-agent offers more identities. This option is intended for 49 | # situations where ssh-agent offers many different identities or you 50 | # need to overwrite all identities and force a single one. 51 | keys_only: false 52 | 53 | # Keys 54 | # 55 | # An array of file names of private keys to use for public key 56 | # and host-based authentication: 57 | keys: [ "~/.ssh/id.pem" ] 58 | 59 | # Key data 60 | # 61 | # An array of strings, with each element of the array being 62 | # a raw private key in PEM format. 63 | key_data: [ "-----BEGIN OPENSSH PRIVATE KEY-----" ] 64 | 65 | # Config 66 | # 67 | # Set to true to load the default OpenSSH config files (~/.ssh/config, 68 | # /etc/ssh_config), to false ignore config files, or to a file path 69 | # (or array of paths) to load specific configuration. Defaults to true. 70 | config: true 71 | -------------------------------------------------------------------------------- /test/commands/auditor_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "active_support/testing/time_helpers" 3 | 4 | class CommandsAuditorTest < ActiveSupport::TestCase 5 | include ActiveSupport::Testing::TimeHelpers 6 | 7 | setup do 8 | freeze_time 9 | 10 | @config = { 11 | service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, builder: { "arch" => "amd64" }, servers: [ "1.1.1.1" ] 12 | } 13 | 14 | @auditor = new_command 15 | @performer = Kamal::Git.email.presence || `whoami`.chomp 16 | @recorded_at = Time.now.utc.iso8601 17 | end 18 | 19 | test "record" do 20 | assert_equal [ 21 | :mkdir, "-p", ".kamal", "&&", 22 | :echo, 23 | "[#{@recorded_at}] [#{@performer}]", 24 | "app removed container", 25 | ">>", ".kamal/app-audit.log" 26 | ], @auditor.record("app removed container") 27 | end 28 | 29 | test "record with destination" do 30 | new_command(destination: "staging").tap do |auditor| 31 | assert_equal [ 32 | :mkdir, "-p", ".kamal", "&&", 33 | :echo, 34 | "[#{@recorded_at}] [#{@performer}] [staging]", 35 | "app removed container", 36 | ">>", ".kamal/app-staging-audit.log" 37 | ], auditor.record("app removed container") 38 | end 39 | end 40 | 41 | test "record with command details" do 42 | new_command(role: "web").tap do |auditor| 43 | assert_equal [ 44 | :mkdir, "-p", ".kamal", "&&", 45 | :echo, 46 | "[#{@recorded_at}] [#{@performer}] [web]", 47 | "app removed container", 48 | ">>", ".kamal/app-audit.log" 49 | ], auditor.record("app removed container") 50 | end 51 | end 52 | 53 | test "record with arg details" do 54 | assert_equal [ 55 | :mkdir, "-p", ".kamal", "&&", 56 | :echo, 57 | "[#{@recorded_at}] [#{@performer}] [value]", 58 | "app removed container", 59 | ">>", ".kamal/app-audit.log" 60 | ], @auditor.record("app removed container", detail: "value") 61 | end 62 | 63 | 64 | private 65 | def new_command(destination: nil, **details) 66 | Kamal::Commands::Auditor.new(Kamal::Configuration.new(@config, destination: destination, version: "123"), **details) 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /test/env_file_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class EnvFileTest < ActiveSupport::TestCase 4 | test "to_s" do 5 | env = { 6 | "foo" => "bar", 7 | "baz" => "haz" 8 | } 9 | 10 | assert_equal "foo=bar\nbaz=haz\n", \ 11 | Kamal::EnvFile.new(env).to_s 12 | end 13 | 14 | test "to_s won't escape '#'" do 15 | env = { 16 | "foo" => '#$foo', 17 | "bar" => '#{bar}' 18 | } 19 | 20 | assert_equal "foo=\#$foo\nbar=\#{bar}\n", \ 21 | Kamal::EnvFile.new(env).to_s 22 | end 23 | 24 | test "to_str won't escape chinese characters" do 25 | env = { 26 | "foo" => '你好 means hello, "欢迎" means welcome, that\'s simple! 😃 {smile}' 27 | } 28 | 29 | assert_equal "foo=你好 means hello, \"欢迎\" means welcome, that's simple! 😃 {smile}\n", 30 | Kamal::EnvFile.new(env).to_s 31 | end 32 | 33 | test "to_s won't escape japanese characters" do 34 | env = { 35 | "foo" => 'こんにちは means hello, "ようこそ" means welcome, that\'s simple! 😃 {smile}' 36 | } 37 | 38 | assert_equal "foo=こんにちは means hello, \"ようこそ\" means welcome, that's simple! 😃 {smile}\n", \ 39 | Kamal::EnvFile.new(env).to_s 40 | end 41 | 42 | test "to_s won't escape korean characters" do 43 | env = { 44 | "foo" => '안녕하세요 means hello, "어서 오십시오" means welcome, that\'s simple! 😃 {smile}' 45 | } 46 | 47 | assert_equal "foo=안녕하세요 means hello, \"어서 오십시오\" means welcome, that's simple! 😃 {smile}\n", \ 48 | Kamal::EnvFile.new(env).to_s 49 | end 50 | 51 | test "to_s empty" do 52 | assert_equal "\n", Kamal::EnvFile.new({}).to_s 53 | end 54 | 55 | test "to_s escaped newline" do 56 | env = { 57 | "foo" => "hello\\nthere" 58 | } 59 | 60 | assert_equal "foo=hello\\\\nthere\n", \ 61 | Kamal::EnvFile.new(env).to_s 62 | ensure 63 | ENV.delete "PASSWORD" 64 | end 65 | 66 | test "to_s newline" do 67 | env = { 68 | "foo" => "hello\nthere" 69 | } 70 | 71 | assert_equal "foo=hello\\nthere\n", \ 72 | Kamal::EnvFile.new(env).to_s 73 | ensure 74 | ENV.delete "PASSWORD" 75 | end 76 | 77 | test "stringIO conversion" do 78 | env = { 79 | "foo" => "bar", 80 | "baz" => "haz" 81 | } 82 | 83 | assert_equal "foo=bar\nbaz=haz\n", \ 84 | StringIO.new(Kamal::EnvFile.new(env)).read 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /test/utils_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class UtilsTest < ActiveSupport::TestCase 4 | test "argumentize" do 5 | assert_equal [ "--label", "foo=\"\\`bar\\`\"", "--label", "baz=\"qux\"", "--label", :quux, "--label", "quuz=false" ], \ 6 | Kamal::Utils.argumentize("--label", { foo: "`bar`", baz: "qux", quux: nil, quuz: false }) 7 | end 8 | 9 | test "argumentize with redacted" do 10 | assert_kind_of SSHKit::Redaction, \ 11 | Kamal::Utils.argumentize("--label", { foo: "bar" }, sensitive: true).last 12 | end 13 | 14 | test "optionize" do 15 | assert_equal [ "--foo", "\"bar\"", "--baz", "\"qux\"", "--quux" ], \ 16 | Kamal::Utils.optionize({ foo: "bar", baz: "qux", quux: true }) 17 | end 18 | 19 | test "optionize with" do 20 | assert_equal [ "--foo=\"bar\"", "--baz=\"qux\"", "--quux" ], \ 21 | Kamal::Utils.optionize({ foo: "bar", baz: "qux", quux: true }, with: "=") 22 | end 23 | 24 | test "no redaction from #to_s" do 25 | assert_equal "secret", Kamal::Utils.sensitive("secret").to_s 26 | end 27 | 28 | test "redact from #inspect" do 29 | assert_equal "[REDACTED]".inspect, Kamal::Utils.sensitive("secret").inspect 30 | end 31 | 32 | test "redact from SSHKit output" do 33 | assert_kind_of SSHKit::Redaction, Kamal::Utils.sensitive("secret") 34 | end 35 | 36 | test "redact from YAML output" do 37 | assert_equal "--- ! '[REDACTED]'\n", YAML.dump(Kamal::Utils.sensitive("secret")) 38 | end 39 | 40 | test "escape_shell_value" do 41 | assert_equal "\"foo\"", Kamal::Utils.escape_shell_value("foo") 42 | assert_equal "\"\\`foo\\`\"", Kamal::Utils.escape_shell_value("`foo`") 43 | 44 | assert_equal "\"${PWD}\"", Kamal::Utils.escape_shell_value("${PWD}") 45 | assert_equal "\"${cat /etc/hostname}\"", Kamal::Utils.escape_shell_value("${cat /etc/hostname}") 46 | assert_equal "\"\\${PWD]\"", Kamal::Utils.escape_shell_value("${PWD]") 47 | assert_equal "\"\\$(PWD)\"", Kamal::Utils.escape_shell_value("$(PWD)") 48 | assert_equal "\"\\$PWD\"", Kamal::Utils.escape_shell_value("$PWD") 49 | 50 | assert_equal "\"^(https?://)www.example.com/(.*)\\$\"", 51 | Kamal::Utils.escape_shell_value("^(https?://)www.example.com/(.*)$") 52 | assert_equal "\"https://example.com/\\$2\"", 53 | Kamal::Utils.escape_shell_value("https://example.com/$2") 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/kamal/configuration/proxy.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Configuration::Proxy 2 | include Kamal::Configuration::Validation 3 | 4 | DEFAULT_LOG_REQUEST_HEADERS = [ "Cache-Control", "Last-Modified", "User-Agent" ] 5 | CONTAINER_NAME = "kamal-proxy" 6 | 7 | delegate :argumentize, :optionize, to: Kamal::Utils 8 | 9 | attr_reader :config, :proxy_config 10 | 11 | def initialize(config:, proxy_config:, context: "proxy") 12 | @config = config 13 | @proxy_config = proxy_config 14 | validate! @proxy_config, with: Kamal::Configuration::Validator::Proxy, context: context 15 | end 16 | 17 | def app_port 18 | proxy_config.fetch("app_port", 80) 19 | end 20 | 21 | def ssl? 22 | proxy_config.fetch("ssl", false) 23 | end 24 | 25 | def hosts 26 | proxy_config["hosts"] || proxy_config["host"]&.split(",") || [] 27 | end 28 | 29 | def deploy_options 30 | { 31 | host: hosts, 32 | tls: proxy_config["ssl"].presence, 33 | "deploy-timeout": seconds_duration(config.deploy_timeout), 34 | "drain-timeout": seconds_duration(config.drain_timeout), 35 | "health-check-interval": seconds_duration(proxy_config.dig("healthcheck", "interval")), 36 | "health-check-timeout": seconds_duration(proxy_config.dig("healthcheck", "timeout")), 37 | "health-check-path": proxy_config.dig("healthcheck", "path"), 38 | "target-timeout": seconds_duration(proxy_config["response_timeout"]), 39 | "buffer-requests": proxy_config.fetch("buffering", { "requests": true }).fetch("requests", true), 40 | "buffer-responses": proxy_config.fetch("buffering", { "responses": true }).fetch("responses", true), 41 | "buffer-memory": proxy_config.dig("buffering", "memory"), 42 | "max-request-body": proxy_config.dig("buffering", "max_request_body"), 43 | "max-response-body": proxy_config.dig("buffering", "max_response_body"), 44 | "forward-headers": proxy_config.dig("forward_headers"), 45 | "log-request-header": proxy_config.dig("logging", "request_headers") || DEFAULT_LOG_REQUEST_HEADERS, 46 | "log-response-header": proxy_config.dig("logging", "response_headers") 47 | }.compact 48 | end 49 | 50 | def deploy_command_args(target:) 51 | optionize ({ target: "#{target}:#{app_port}" }).merge(deploy_options), with: "=" 52 | end 53 | 54 | def merge(other) 55 | self.class.new config: config, proxy_config: proxy_config.deep_merge(other.proxy_config) 56 | end 57 | 58 | private 59 | def seconds_duration(value) 60 | value ? "#{value}s" : nil 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/kamal/secrets/adapters/one_password.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Secrets::Adapters::OnePassword < Kamal::Secrets::Adapters::Base 2 | delegate :optionize, to: Kamal::Utils 3 | 4 | private 5 | def login(account) 6 | unless loggedin?(account) 7 | `op signin #{to_options(account: account, force: true, raw: true)}`.tap do 8 | raise RuntimeError, "Failed to login to 1Password" unless $?.success? 9 | end 10 | end 11 | end 12 | 13 | def loggedin?(account) 14 | `op account get --account #{account.shellescape} 2> /dev/null` 15 | $?.success? 16 | end 17 | 18 | def fetch_secrets(secrets, account:, session:) 19 | {}.tap do |results| 20 | vaults_items_fields(secrets).map do |vault, items| 21 | items.each do |item, fields| 22 | fields_json = JSON.parse(op_item_get(vault, item, fields, account: account, session: session)) 23 | fields_json = [ fields_json ] if fields.one? 24 | 25 | fields_json.each do |field_json| 26 | # The reference is in the form `op://vault/item/field[/field]` 27 | field = field_json["reference"].delete_prefix("op://").delete_suffix("/password") 28 | results[field] = field_json["value"] 29 | end 30 | end 31 | end 32 | end 33 | end 34 | 35 | def to_options(**options) 36 | optionize(options.compact).join(" ") 37 | end 38 | 39 | def vaults_items_fields(secrets) 40 | {}.tap do |vaults| 41 | secrets.each do |secret| 42 | secret = secret.delete_prefix("op://") 43 | vault, item, *fields = secret.split("/") 44 | fields << "password" if fields.empty? 45 | 46 | vaults[vault] ||= {} 47 | vaults[vault][item] ||= [] 48 | vaults[vault][item] << fields.join(".") 49 | end 50 | end 51 | end 52 | 53 | def op_item_get(vault, item, fields, account:, session:) 54 | labels = fields.map { |field| "label=#{field}" }.join(",") 55 | options = to_options(vault: vault, fields: labels, format: "json", account: account, session: session.presence) 56 | 57 | `op item get #{item.shellescape} #{options}`.tap do 58 | raise RuntimeError, "Could not read #{fields.join(", ")} from #{item} in the #{vault} 1Password vault" unless $?.success? 59 | end 60 | end 61 | 62 | def check_dependencies! 63 | raise RuntimeError, "1Password CLI is not installed" unless cli_installed? 64 | end 65 | 66 | def cli_installed? 67 | `op --version 2> /dev/null` 68 | $?.success? 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/kamal/configuration/docs/env.yml: -------------------------------------------------------------------------------- 1 | # Environment variables 2 | # 3 | # Environment variables can be set directly in the Kamal configuration or 4 | # read from `.kamal/secrets`. 5 | 6 | # Reading environment variables from the configuration 7 | # 8 | # Environment variables can be set directly in the configuration file. 9 | # 10 | # These are passed to the `docker run` command when deploying. 11 | env: 12 | DATABASE_HOST: mysql-db1 13 | DATABASE_PORT: 3306 14 | 15 | # Secrets 16 | # 17 | # Kamal uses dotenv to automatically load environment variables set in the `.kamal/secrets` file. 18 | # 19 | # If you are using destinations, secrets will instead be read from `.kamal/secrets.` if 20 | # it exists. 21 | # 22 | # Common secrets across all destinations can be set in `.kamal/secrets-common`. 23 | # 24 | # This file can be used to set variables like `KAMAL_REGISTRY_PASSWORD` or database passwords. 25 | # You can use variable or command substitution in the secrets file. 26 | # 27 | # ```shell 28 | # KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD 29 | # RAILS_MASTER_KEY=$(cat config/master.key) 30 | # ``` 31 | # 32 | # You can also use [secret helpers](../../commands/secrets) for some common password managers. 33 | # 34 | # ```shell 35 | # SECRETS=$(kamal secrets fetch ...) 36 | # 37 | # REGISTRY_PASSWORD=$(kamal secrets extract REGISTRY_PASSWORD $SECRETS) 38 | # DB_PASSWORD=$(kamal secrets extract DB_PASSWORD $SECRETS) 39 | # ``` 40 | # 41 | # If you store secrets directly in `.kamal/secrets`, ensure that it is not checked into version control. 42 | # 43 | # To pass the secrets, you should list them under the `secret` key. When you do this, the 44 | # other variables need to be moved under the `clear` key. 45 | # 46 | # Unlike clear values, secrets are not passed directly to the container 47 | # but are stored in an env file on the host: 48 | env: 49 | clear: 50 | DB_USER: app 51 | secret: 52 | - DB_PASSWORD 53 | 54 | # Tags 55 | # 56 | # Tags are used to add extra env variables to specific hosts. 57 | # See kamal docs servers for how to tag hosts. 58 | # 59 | # Tags are only allowed in the top-level env configuration (i.e., not under a role-specific env). 60 | # 61 | # The env variables can be specified with secret and clear values as explained above. 62 | env: 63 | tags: 64 | : 65 | MYSQL_USER: monitoring 66 | : 67 | clear: 68 | MYSQL_USER: readonly 69 | secret: 70 | - MYSQL_PASSWORD 71 | 72 | # Example configuration 73 | env: 74 | clear: 75 | MYSQL_USER: app 76 | secret: 77 | - MYSQL_PASSWORD 78 | tags: 79 | monitoring: 80 | MYSQL_USER: monitoring 81 | replica: 82 | clear: 83 | MYSQL_USER: readonly 84 | secret: 85 | - READONLY_PASSWORD 86 | -------------------------------------------------------------------------------- /lib/kamal/commands/base.rb: -------------------------------------------------------------------------------- 1 | module Kamal::Commands 2 | class Base 3 | delegate :sensitive, :argumentize, to: Kamal::Utils 4 | 5 | DOCKER_HEALTH_STATUS_FORMAT = "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'" 6 | 7 | attr_accessor :config 8 | 9 | def initialize(config) 10 | @config = config 11 | end 12 | 13 | def run_over_ssh(*command, host:) 14 | "ssh#{ssh_proxy_args} -t #{config.ssh.user}@#{host} -p #{config.ssh.port} '#{command.join(" ").gsub("'", "'\\\\''")}'" 15 | end 16 | 17 | def container_id_for(container_name:, only_running: false) 18 | docker :container, :ls, *("--all" unless only_running), "--filter", "name=^#{container_name}$", "--quiet" 19 | end 20 | 21 | def make_directory_for(remote_file) 22 | make_directory Pathname.new(remote_file).dirname.to_s 23 | end 24 | 25 | def make_directory(path) 26 | [ :mkdir, "-p", path ] 27 | end 28 | 29 | def remove_directory(path) 30 | [ :rm, "-r", path ] 31 | end 32 | 33 | def remove_file(path) 34 | [ :rm, path ] 35 | end 36 | 37 | private 38 | def combine(*commands, by: "&&") 39 | commands 40 | .compact 41 | .collect { |command| Array(command) + [ by ] }.flatten # Join commands 42 | .tap { |commands| commands.pop } # Remove trailing combiner 43 | end 44 | 45 | def chain(*commands) 46 | combine *commands, by: ";" 47 | end 48 | 49 | def pipe(*commands) 50 | combine *commands, by: "|" 51 | end 52 | 53 | def append(*commands) 54 | combine *commands, by: ">>" 55 | end 56 | 57 | def write(*commands) 58 | combine *commands, by: ">" 59 | end 60 | 61 | def any(*commands) 62 | combine *commands, by: "||" 63 | end 64 | 65 | def xargs(command) 66 | [ :xargs, command ].flatten 67 | end 68 | 69 | def shell(command) 70 | [ :sh, "-c", "'#{command.flatten.join(" ").gsub("'", "'\\\\''")}'" ] 71 | end 72 | 73 | def docker(*args) 74 | args.compact.unshift :docker 75 | end 76 | 77 | def git(*args, path: nil) 78 | [ :git, *([ "-C", path ] if path), *args.compact ] 79 | end 80 | 81 | def grep(*args) 82 | args.compact.unshift :grep 83 | end 84 | 85 | def tags(**details) 86 | Kamal::Tags.from_config(config, **details) 87 | end 88 | 89 | def ssh_proxy_args 90 | case config.ssh.proxy 91 | when Net::SSH::Proxy::Jump 92 | " -J #{config.ssh.proxy.jump_proxies}" 93 | when Net::SSH::Proxy::Command 94 | " -o ProxyCommand='#{config.ssh.proxy.command_line_template}'" 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/kamal/commands/proxy.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Commands::Proxy < Kamal::Commands::Base 2 | delegate :argumentize, :optionize, to: Kamal::Utils 3 | 4 | def run 5 | docker :run, 6 | "--name", container_name, 7 | "--network", "kamal", 8 | "--detach", 9 | "--restart", "unless-stopped", 10 | "--volume", "kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy", 11 | "\$\(#{get_boot_options.join(" ")}\)", 12 | config.proxy_image 13 | end 14 | 15 | def start 16 | docker :container, :start, container_name 17 | end 18 | 19 | def stop(name: container_name) 20 | docker :container, :stop, name 21 | end 22 | 23 | def start_or_run 24 | combine start, run, by: "||" 25 | end 26 | 27 | def info 28 | docker :ps, "--filter", "name=^#{container_name}$" 29 | end 30 | 31 | def version 32 | pipe \ 33 | docker(:inspect, container_name, "--format '{{.Config.Image}}'"), 34 | [ :cut, "-d:", "-f2" ] 35 | end 36 | 37 | def logs(timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil) 38 | pipe \ 39 | docker(:logs, container_name, ("--since #{since}" if since), ("--tail #{lines}" if lines), ("--timestamps" if timestamps), "2>&1"), 40 | ("grep '#{grep}'#{" #{grep_options}" if grep_options}" if grep) 41 | end 42 | 43 | def follow_logs(host:, timestamps: true, grep: nil, grep_options: nil) 44 | run_over_ssh pipe( 45 | docker(:logs, container_name, ("--timestamps" if timestamps), "--tail", "10", "--follow", "2>&1"), 46 | (%(grep "#{grep}"#{" #{grep_options}" if grep_options}) if grep) 47 | ).join(" "), host: host 48 | end 49 | 50 | def remove_container 51 | docker :container, :prune, "--force", "--filter", "label=org.opencontainers.image.title=kamal-proxy" 52 | end 53 | 54 | def remove_image 55 | docker :image, :prune, "--all", "--force", "--filter", "label=org.opencontainers.image.title=kamal-proxy" 56 | end 57 | 58 | def cleanup_traefik 59 | chain \ 60 | docker(:container, :stop, "traefik"), 61 | combine( 62 | docker(:container, :prune, "--force", "--filter", "label=org.opencontainers.image.title=Traefik"), 63 | docker(:image, :prune, "--all", "--force", "--filter", "label=org.opencontainers.image.title=Traefik") 64 | ) 65 | end 66 | 67 | def ensure_proxy_directory 68 | make_directory config.proxy_directory 69 | end 70 | 71 | def remove_proxy_directory 72 | remove_directory config.proxy_directory 73 | end 74 | 75 | def get_boot_options 76 | combine [ :cat, config.proxy_options_file ], [ :echo, "\"#{config.proxy_options_default.join(" ")}\"" ], by: "||" 77 | end 78 | 79 | def reset_boot_options 80 | remove_file config.proxy_options_file 81 | end 82 | 83 | private 84 | def container_name 85 | config.proxy_container_name 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "active_support/test_case" 3 | require "active_support/testing/autorun" 4 | require "active_support/testing/stream" 5 | require "rails/test_unit/line_filtering" 6 | require "debug" 7 | require "mocha/minitest" # using #stubs that can alter returns 8 | require "minitest/autorun" # using #stub that take args 9 | require "sshkit" 10 | require "kamal" 11 | 12 | ActiveSupport::LogSubscriber.logger = ActiveSupport::Logger.new(STDOUT) if ENV["VERBOSE"] 13 | 14 | # Applies to remote commands only. 15 | SSHKit.config.backend = SSHKit::Backend::Printer 16 | 17 | class SSHKit::Backend::Printer 18 | def upload!(local, location, **kwargs) 19 | local = local.string.inspect if local.respond_to?(:string) 20 | puts "Uploading #{local} to #{location} on #{host}" 21 | end 22 | end 23 | 24 | # Ensure local commands use the printer backend too. 25 | # See https://github.com/capistrano/sshkit/blob/master/lib/sshkit/dsl.rb#L9 26 | module SSHKit 27 | module DSL 28 | def run_locally(&block) 29 | SSHKit::Backend::Printer.new(SSHKit::Host.new(:local), &block).run 30 | end 31 | end 32 | end 33 | 34 | class ActiveSupport::TestCase 35 | include ActiveSupport::Testing::Stream 36 | extend Rails::LineFiltering 37 | 38 | private 39 | def stdouted 40 | capture(:stdout) { yield }.strip 41 | end 42 | 43 | def stderred 44 | capture(:stderr) { yield }.strip 45 | end 46 | 47 | def with_test_secrets(**files) 48 | setup_test_secrets(**files) 49 | yield 50 | ensure 51 | teardown_test_secrets 52 | end 53 | 54 | def setup_test_secrets(**files) 55 | @original_pwd = Dir.pwd 56 | @secrets_tmpdir = Dir.mktmpdir 57 | fixtures_dup = File.join(@secrets_tmpdir, "test") 58 | FileUtils.mkdir_p(fixtures_dup) 59 | FileUtils.cp_r("test/fixtures/", fixtures_dup) 60 | 61 | Dir.chdir(@secrets_tmpdir) 62 | FileUtils.mkdir_p(".kamal") 63 | Dir.chdir(".kamal") do 64 | files.each do |filename, contents| 65 | File.binwrite(filename.to_s, contents) 66 | end 67 | end 68 | end 69 | 70 | def teardown_test_secrets 71 | Dir.chdir(@original_pwd) 72 | FileUtils.rm_rf(@secrets_tmpdir) 73 | end 74 | end 75 | 76 | class SecretAdapterTestCase < ActiveSupport::TestCase 77 | setup do 78 | `true` # Ensure $? is 0 79 | end 80 | 81 | private 82 | def stub_ticks 83 | Kamal::Secrets::Adapters::Base.any_instance.stubs(:`) 84 | end 85 | 86 | def stub_ticks_with(command, succeed: true) 87 | # Sneakily run `false`/`true` after a match to set $? to 1/0 88 | stub_ticks.with { |c| c == command && (succeed ? `true` : `false`) } 89 | Kamal::Secrets::Adapters::Base.any_instance.stubs(:`) 90 | end 91 | 92 | def shellunescape(string) 93 | "\"#{string}\"".undump.gsub(/\\([{}])/, "\\1") 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of the Kamal project, we pledge to create a welcoming and inclusive environment for everyone. We value the participation of each member of our community and want all contributors to feel respected and valued. 4 | 5 | We are committed to providing a harassment-free experience for everyone, regardless of gender, gender identity and expression, sexual orientation, disability, physical appearance, body size, race, age, or religion (or lack thereof). We do not tolerate harassment of participants in any form. 6 | 7 | This code of conduct applies to all Kamal project spaces, including but not limited to project code, issue trackers, chat rooms, and mailing lists. Violations of this code of conduct may result in removal from the project community. 8 | 9 | ## Our standards 10 | 11 | Examples of behavior that contributes to creating a positive environment include: 12 | 13 | - Using welcoming and inclusive language 14 | - Being respectful of differing viewpoints and experiences 15 | - Gracefully accepting constructive criticism 16 | - Focusing on what is best for the community 17 | - Showing empathy towards other community members 18 | 19 | Examples of unacceptable behavior by participants include: 20 | 21 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 22 | - Trolling, insulting/derogatory comments, and personal or political attacks 23 | - Public or private harassment 24 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 25 | - Other conduct which could reasonably be considered inappropriate in a professional setting 26 | 27 | ## Responsibilities 28 | 29 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 30 | 31 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned with this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 32 | 33 | ## Reporting 34 | 35 | If you are subject to or witness unacceptable behavior, or have any other concerns, please notify a project maintainer. All reports will be kept confidential and will be reviewed and investigated promptly. 36 | 37 | We will investigate every complaint and take appropriate action. We reserve the right to remove any content that violates this Code of Conduct, or to temporarily or permanently ban any contributor for other behaviors that we deem inappropriate, threatening, offensive, or harmful. 38 | 39 | ## Attribution 40 | 41 | This Code of Conduct is adapted from the Contributor Covenant, version 1.4, available at . 42 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Kamal development 2 | 3 | Thank you for considering contributing to Kamal! This document outlines some guidelines for contributing to this open source project. 4 | 5 | Please make sure to review our [Code of Conduct](CODE_OF_CONDUCT.md) before contributing to Kamal. 6 | 7 | There are several ways you can contribute to the betterment of the project: 8 | 9 | - **Report an issue?** - If the issue isn’t reported, we can’t fix it. Please report any bugs, feature, and/or improvement requests on the [Kamal GitHub Issues tracker](https://github.com/basecamp/kamal/issues). 10 | - **Submit patches** - Do you have a new feature or a fix you'd like to share? [Submit a pull request](https://github.com/basecamp/kamal/pulls)! 11 | - **Write blog articles** - Are you using Kamal? We'd love to hear how you're using it with your projects. Write a tutorial and post it on your blog! 12 | 13 | ## Issues 14 | 15 | If you encounter any issues with the project, please check the [existing issues](https://github.com/basecamp/kamal/issues) first to see if the issue has already been reported. If the issue hasn't been reported, please open a new issue with a clear description of the problem and steps to reproduce it. 16 | 17 | ## Pull Requests 18 | 19 | Please keep the following guidelines in mind when opening a pull request: 20 | 21 | - Ensure that your code passes the project's minitests by running ./bin/test. 22 | - Provide a clear and detailed description of your changes. 23 | - Keep your changes focused on a single concern. 24 | - Write clean and readable code that follows the project's code style. 25 | - Use descriptive variable and function names. 26 | - Write clear and concise commit messages. 27 | - Add tests for your changes, if possible. 28 | - Ensure that your changes don't break existing functionality. 29 | 30 | #### Commit message guidelines 31 | 32 | A good commit message should describe what changed and why. 33 | 34 | ## Development 35 | 36 | The `main` branch is regularly built and tested, but it is not guaranteed to be completely stable. Tags are created regularly from release branches to indicate new official, stable release versions of Kamal. 37 | 38 | Kamal is written in Ruby. You should have Ruby 3.2+ installed on your machine in order to work on Kamal. If that's already setup, run `bundle` in the root directory to install all dependencies. Then you can run `bin/test` to run all tests. 39 | 40 | 1. Fork the project repository. 41 | 2. Create a new branch for your contribution. 42 | 3. Write your code or make the desired changes. 43 | 4. **Ensure that your code passes the project's minitests by running ./bin/test.** 44 | 5. Commit your changes and push them to your forked repository. 45 | 6. [Open a pull request](https://github.com/basecamp/kamal/pulls) to the main project repository with a detailed description of your changes. 46 | 47 | ## License 48 | 49 | Kamal is released under the MIT License. By contributing to this project, you agree to license your contributions under the same license. 50 | -------------------------------------------------------------------------------- /lib/kamal/secrets/adapters/bitwarden.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Secrets::Adapters::Bitwarden < Kamal::Secrets::Adapters::Base 2 | private 3 | def login(account) 4 | status = run_command("status") 5 | 6 | if status["status"] == "unauthenticated" 7 | run_command("login #{account.shellescape}", raw: true) 8 | status = run_command("status") 9 | end 10 | 11 | if status["status"] == "locked" 12 | session = run_command("unlock --raw", raw: true).presence 13 | status = run_command("status", session: session) 14 | end 15 | 16 | raise RuntimeError, "Failed to login to and unlock Bitwarden" unless status["status"] == "unlocked" 17 | 18 | run_command("sync", session: session, raw: true) 19 | raise RuntimeError, "Failed to sync Bitwarden" unless $?.success? 20 | 21 | session 22 | end 23 | 24 | def fetch_secrets(secrets, account:, session:) 25 | {}.tap do |results| 26 | items_fields(secrets).each do |item, fields| 27 | item_json = run_command("get item #{item.shellescape}", session: session, raw: true) 28 | raise RuntimeError, "Could not read #{item} from Bitwarden" unless $?.success? 29 | item_json = JSON.parse(item_json) 30 | if fields.any? 31 | results.merge! fetch_secrets_from_fields(fields, item, item_json) 32 | elsif item_json.dig("login", "password") 33 | results[item] = item_json.dig("login", "password") 34 | elsif item_json["fields"]&.any? 35 | fields = item_json["fields"].pluck("name") 36 | results.merge! fetch_secrets_from_fields(fields, item, item_json) 37 | else 38 | raise RuntimeError, "Item #{item} is not a login type item and no fields were specified" 39 | end 40 | end 41 | end 42 | end 43 | 44 | def fetch_secrets_from_fields(fields, item, item_json) 45 | fields.to_h do |field| 46 | item_field = item_json["fields"].find { |f| f["name"] == field } 47 | raise RuntimeError, "Could not find field #{field} in item #{item} in Bitwarden" unless item_field 48 | value = item_field["value"] 49 | [ "#{item}/#{field}", value ] 50 | end 51 | end 52 | 53 | def items_fields(secrets) 54 | {}.tap do |items| 55 | secrets.each do |secret| 56 | item, field = secret.split("/") 57 | items[item] ||= [] 58 | items[item] << field 59 | end 60 | end 61 | end 62 | 63 | def signedin?(account) 64 | run_command("status")["status"] != "unauthenticated" 65 | end 66 | 67 | def run_command(command, session: nil, raw: false) 68 | full_command = [ *("BW_SESSION=#{session.shellescape}" if session), "bw", command ].join(" ") 69 | result = `#{full_command}`.strip 70 | raw ? result : JSON.parse(result) 71 | end 72 | 73 | def check_dependencies! 74 | raise RuntimeError, "Bitwarden CLI is not installed" unless cli_installed? 75 | end 76 | 77 | def cli_installed? 78 | `bw --version 2> /dev/null` 79 | $?.success? 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/kamal/cli/templates/sample_hooks/pre-deploy.sample: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # A sample pre-deploy hook 4 | # 5 | # Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds. 6 | # 7 | # Fails unless the combined status is "success" 8 | # 9 | # These environment variables are available: 10 | # KAMAL_RECORDED_AT 11 | # KAMAL_PERFORMER 12 | # KAMAL_VERSION 13 | # KAMAL_HOSTS 14 | # KAMAL_COMMAND 15 | # KAMAL_SUBCOMMAND 16 | # KAMAL_ROLE (if set) 17 | # KAMAL_DESTINATION (if set) 18 | 19 | # Only check the build status for production deployments 20 | if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production" 21 | exit 0 22 | end 23 | 24 | require "bundler/inline" 25 | 26 | # true = install gems so this is fast on repeat invocations 27 | gemfile(true, quiet: true) do 28 | source "https://rubygems.org" 29 | 30 | gem "octokit" 31 | gem "faraday-retry" 32 | end 33 | 34 | MAX_ATTEMPTS = 72 35 | ATTEMPTS_GAP = 10 36 | 37 | def exit_with_error(message) 38 | $stderr.puts message 39 | exit 1 40 | end 41 | 42 | class GithubStatusChecks 43 | attr_reader :remote_url, :git_sha, :github_client, :combined_status 44 | 45 | def initialize 46 | @remote_url = `git config --get remote.origin.url`.strip.delete_prefix("https://github.com/") 47 | @git_sha = `git rev-parse HEAD`.strip 48 | @github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"]) 49 | refresh! 50 | end 51 | 52 | def refresh! 53 | @combined_status = github_client.combined_status(remote_url, git_sha) 54 | end 55 | 56 | def state 57 | combined_status[:state] 58 | end 59 | 60 | def first_status_url 61 | first_status = combined_status[:statuses].find { |status| status[:state] == state } 62 | first_status && first_status[:target_url] 63 | end 64 | 65 | def complete_count 66 | combined_status[:statuses].count { |status| status[:state] != "pending"} 67 | end 68 | 69 | def total_count 70 | combined_status[:statuses].count 71 | end 72 | 73 | def current_status 74 | if total_count > 0 75 | "Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..." 76 | else 77 | "Build not started..." 78 | end 79 | end 80 | end 81 | 82 | 83 | $stdout.sync = true 84 | 85 | puts "Checking build status..." 86 | attempts = 0 87 | checks = GithubStatusChecks.new 88 | 89 | begin 90 | loop do 91 | case checks.state 92 | when "success" 93 | puts "Checks passed, see #{checks.first_status_url}" 94 | exit 0 95 | when "failure" 96 | exit_with_error "Checks failed, see #{checks.first_status_url}" 97 | when "pending" 98 | attempts += 1 99 | end 100 | 101 | exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS 102 | 103 | puts checks.current_status 104 | sleep(ATTEMPTS_GAP) 105 | checks.refresh! 106 | end 107 | rescue Octokit::NotFound 108 | exit_with_error "Build status could not be found" 109 | end 110 | -------------------------------------------------------------------------------- /lib/kamal/cli/templates/deploy.yml: -------------------------------------------------------------------------------- 1 | # Name of your application. Used to uniquely configure containers. 2 | service: my-app 3 | 4 | # Name of the container image. 5 | image: my-user/my-app 6 | 7 | # Deploy to these servers. 8 | servers: 9 | web: 10 | - 192.168.0.1 11 | # job: 12 | # hosts: 13 | # - 192.168.0.1 14 | # cmd: bin/jobs 15 | 16 | # Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server. 17 | # Remove this section when using multiple web servers and ensure you terminate SSL at your load balancer. 18 | # 19 | # Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. 20 | proxy: 21 | ssl: true 22 | host: app.example.com 23 | # Proxy connects to your container on port 80 by default. 24 | # app_port: 3000 25 | 26 | # Credentials for your image host. 27 | registry: 28 | # Specify the registry server, if you're not using Docker Hub 29 | # server: registry.digitalocean.com / ghcr.io / ... 30 | username: my-user 31 | 32 | # Always use an access token rather than real password (pulled from .kamal/secrets). 33 | password: 34 | - KAMAL_REGISTRY_PASSWORD 35 | 36 | # Configure builder setup. 37 | builder: 38 | arch: amd64 39 | # Pass in additional build args needed for your Dockerfile. 40 | # args: 41 | # RUBY_VERSION: <%= File.read('.ruby-version').strip %> 42 | 43 | # Inject ENV variables into containers (secrets come from .kamal/secrets). 44 | # 45 | # env: 46 | # clear: 47 | # DB_HOST: 192.168.0.2 48 | # secret: 49 | # - RAILS_MASTER_KEY 50 | 51 | # Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: 52 | # "bin/kamal logs -r job" will tail logs from the first server in the job section. 53 | # 54 | # aliases: 55 | # shell: app exec --interactive --reuse "bash" 56 | 57 | # Use a different ssh user than root 58 | # 59 | # ssh: 60 | # user: app 61 | 62 | # Use a persistent storage volume. 63 | # 64 | # volumes: 65 | # - "app_storage:/app/storage" 66 | 67 | # Bridge fingerprinted assets, like JS and CSS, between versions to avoid 68 | # hitting 404 on in-flight requests. Combines all files from new and old 69 | # version inside the asset_path. 70 | # 71 | # asset_path: /app/public/assets 72 | 73 | # Configure rolling deploys by setting a wait time between batches of restarts. 74 | # 75 | # boot: 76 | # limit: 10 # Can also specify as a percentage of total hosts, such as "25%" 77 | # wait: 2 78 | 79 | # Use accessory services (secrets come from .kamal/secrets). 80 | # 81 | # accessories: 82 | # db: 83 | # image: mysql:8.0 84 | # host: 192.168.0.2 85 | # port: 3306 86 | # env: 87 | # clear: 88 | # MYSQL_ROOT_HOST: '%' 89 | # secret: 90 | # - MYSQL_ROOT_PASSWORD 91 | # files: 92 | # - config/mysql/production.cnf:/etc/mysql/my.cnf 93 | # - db/production.sql:/docker-entrypoint-initdb.d/setup.sql 94 | # directories: 95 | # - data:/var/lib/mysql 96 | # redis: 97 | # image: valkey/valkey:8 98 | # host: 192.168.0.2 99 | # port: 6379 100 | # directories: 101 | # - data:/data 102 | -------------------------------------------------------------------------------- /lib/kamal/configuration/docs/accessory.yml: -------------------------------------------------------------------------------- 1 | # Accessories 2 | # 3 | # Accessories can be booted on a single host, a list of hosts, or on specific roles. 4 | # The hosts do not need to be defined in the Kamal servers configuration. 5 | # 6 | # Accessories are managed separately from the main service — they are not updated 7 | # when you deploy, and they do not have zero-downtime deployments. 8 | # 9 | # Run `kamal accessory boot ` to boot an accessory. 10 | # See `kamal accessory --help` for more information. 11 | 12 | # Configuring accessories 13 | # 14 | # First, define the accessory in the `accessories`: 15 | accessories: 16 | mysql: 17 | 18 | # Service name 19 | # 20 | # This is used in the service label and defaults to `-`, 21 | # where `` is the main service name from the root configuration: 22 | service: mysql 23 | 24 | # Image 25 | # 26 | # The Docker image to use, prefix it with a registry if not using Docker Hub: 27 | image: mysql:8.0 28 | 29 | # Accessory hosts 30 | # 31 | # Specify one of `host`, `hosts`, or `roles`: 32 | host: mysql-db1 33 | hosts: 34 | - mysql-db1 35 | - mysql-db2 36 | roles: 37 | - mysql 38 | 39 | # Custom command 40 | # 41 | # You can set a custom command to run in the container if you do not want to use the default: 42 | cmd: "bin/mysqld" 43 | 44 | # Port mappings 45 | # 46 | # See https://docs.docker.com/network/, and especially note the warning about the security 47 | # implications of exposing ports publicly. 48 | port: "127.0.0.1:3306:3306" 49 | 50 | # Labels 51 | labels: 52 | app: myapp 53 | 54 | # Options 55 | # 56 | # These are passed to the Docker run command in the form `-- `: 57 | options: 58 | restart: always 59 | cpus: 2 60 | 61 | # Environment variables 62 | # 63 | # See kamal docs env for more information: 64 | env: 65 | ... 66 | 67 | # Copying files 68 | # 69 | # You can specify files to mount into the container. 70 | # The format is `local:remote`, where `local` is the path to the file on the local machine 71 | # and `remote` is the path to the file in the container. 72 | # 73 | # They will be uploaded from the local repo to the host and then mounted. 74 | # 75 | # ERB files will be evaluated before being copied. 76 | files: 77 | - config/my.cnf.erb:/etc/mysql/my.cnf 78 | - config/myoptions.cnf:/etc/mysql/myoptions.cnf 79 | 80 | # Directories 81 | # 82 | # You can specify directories to mount into the container. They will be created on the host 83 | # before being mounted: 84 | directories: 85 | - mysql-logs:/var/log/mysql 86 | 87 | # Volumes 88 | # 89 | # Any other volumes to mount, in addition to the files and directories. 90 | # They are not created or copied before mounting: 91 | volumes: 92 | - /path/to/mysql-logs:/var/log/mysql 93 | 94 | # Network 95 | # 96 | # The network the accessory will be attached to. 97 | # 98 | # Defaults to kamal: 99 | network: custom 100 | 101 | # Proxy 102 | # 103 | proxy: 104 | ... -------------------------------------------------------------------------------- /lib/kamal/commands/builder/base.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Commands::Builder::Base < Kamal::Commands::Base 2 | class BuilderError < StandardError; end 3 | 4 | ENDPOINT_DOCKER_HOST_INSPECT = "'{{.Endpoints.docker.Host}}'" 5 | 6 | delegate :argumentize, to: Kamal::Utils 7 | delegate \ 8 | :args, :secrets, :dockerfile, :target, :arches, :local_arches, :remote_arches, :remote, 9 | :cache_from, :cache_to, :ssh, :provenance, :sbom, :driver, :docker_driver?, 10 | to: :builder_config 11 | 12 | def clean 13 | docker :image, :rm, "--force", config.absolute_image 14 | end 15 | 16 | def push 17 | docker :buildx, :build, 18 | "--push", 19 | *platform_options(arches), 20 | *([ "--builder", builder_name ] unless docker_driver?), 21 | *build_options, 22 | build_context 23 | end 24 | 25 | def pull 26 | docker :pull, config.absolute_image 27 | end 28 | 29 | def info 30 | combine \ 31 | docker(:context, :ls), 32 | docker(:buildx, :ls) 33 | end 34 | 35 | def inspect_builder 36 | docker :buildx, :inspect, builder_name unless docker_driver? 37 | end 38 | 39 | def build_options 40 | [ *build_tags, *build_cache, *build_labels, *build_args, *build_secrets, *build_dockerfile, *build_target, *build_ssh, *builder_provenance, *builder_sbom ] 41 | end 42 | 43 | def build_context 44 | config.builder.context 45 | end 46 | 47 | def validate_image 48 | pipe \ 49 | docker(:inspect, "-f", "'{{ .Config.Labels.service }}'", config.absolute_image), 50 | any( 51 | [ :grep, "-x", config.service ], 52 | "(echo \"Image #{config.absolute_image} is missing the 'service' label\" && exit 1)" 53 | ) 54 | end 55 | 56 | def first_mirror 57 | docker(:info, "--format '{{index .RegistryConfig.Mirrors 0}}'") 58 | end 59 | 60 | private 61 | def build_tags 62 | [ "-t", config.absolute_image, "-t", config.latest_image ] 63 | end 64 | 65 | def build_cache 66 | if cache_to && cache_from 67 | [ "--cache-to", cache_to, 68 | "--cache-from", cache_from ] 69 | end 70 | end 71 | 72 | def build_labels 73 | argumentize "--label", { service: config.service } 74 | end 75 | 76 | def build_args 77 | argumentize "--build-arg", args, sensitive: true 78 | end 79 | 80 | def build_secrets 81 | argumentize "--secret", secrets.keys.collect { |secret| [ "id", secret ] } 82 | end 83 | 84 | def build_dockerfile 85 | if Pathname.new(File.expand_path(dockerfile)).exist? 86 | argumentize "--file", dockerfile 87 | else 88 | raise BuilderError, "Missing #{dockerfile}" 89 | end 90 | end 91 | 92 | def build_target 93 | argumentize "--target", target if target.present? 94 | end 95 | 96 | def build_ssh 97 | argumentize "--ssh", ssh if ssh.present? 98 | end 99 | 100 | def builder_provenance 101 | argumentize "--provenance", provenance unless provenance.nil? 102 | end 103 | 104 | def builder_sbom 105 | argumentize "--sbom", sbom unless sbom.nil? 106 | end 107 | 108 | def builder_config 109 | config.builder 110 | end 111 | 112 | def platform_options(arches) 113 | argumentize "--platform", arches.map { |arch| "linux/#{arch}" }.join(",") if arches.any? 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/kamal/commands/accessory.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Commands::Accessory < Kamal::Commands::Base 2 | include Proxy 3 | 4 | attr_reader :accessory_config 5 | delegate :service_name, :image, :hosts, :port, :files, :directories, :cmd, 6 | :network_args, :publish_args, :env_args, :volume_args, :label_args, :option_args, 7 | :secrets_io, :secrets_path, :env_directory, :proxy, :running_proxy?, 8 | to: :accessory_config 9 | delegate :proxy_container_name, to: :config 10 | 11 | 12 | def initialize(config, name:) 13 | super(config) 14 | @accessory_config = config.accessory(name) 15 | end 16 | 17 | def run 18 | docker :run, 19 | "--name", service_name, 20 | "--detach", 21 | "--restart", "unless-stopped", 22 | *network_args, 23 | *config.logging_args, 24 | *publish_args, 25 | *env_args, 26 | *volume_args, 27 | *label_args, 28 | *option_args, 29 | image, 30 | cmd 31 | end 32 | 33 | def start 34 | docker :container, :start, service_name 35 | end 36 | 37 | def stop 38 | docker :container, :stop, service_name 39 | end 40 | 41 | def info 42 | docker :ps, *service_filter 43 | end 44 | 45 | 46 | def logs(timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil) 47 | pipe \ 48 | docker(:logs, service_name, (" --since #{since}" if since), (" --tail #{lines}" if lines), ("--timestamps" if timestamps), "2>&1"), 49 | ("grep '#{grep}'#{" #{grep_options}" if grep_options}" if grep) 50 | end 51 | 52 | def follow_logs(timestamps: true, grep: nil, grep_options: nil) 53 | run_over_ssh \ 54 | pipe \ 55 | docker(:logs, service_name, ("--timestamps" if timestamps), "--tail", "10", "--follow", "2>&1"), 56 | (%(grep "#{grep}"#{" #{grep_options}" if grep_options}) if grep) 57 | end 58 | 59 | 60 | def execute_in_existing_container(*command, interactive: false) 61 | docker :exec, 62 | ("-it" if interactive), 63 | service_name, 64 | *command 65 | end 66 | 67 | def execute_in_new_container(*command, interactive: false) 68 | docker :run, 69 | ("-it" if interactive), 70 | "--rm", 71 | *network_args, 72 | *env_args, 73 | *volume_args, 74 | image, 75 | *command 76 | end 77 | 78 | def execute_in_existing_container_over_ssh(*command) 79 | run_over_ssh execute_in_existing_container(*command, interactive: true) 80 | end 81 | 82 | def execute_in_new_container_over_ssh(*command) 83 | run_over_ssh execute_in_new_container(*command, interactive: true) 84 | end 85 | 86 | def run_over_ssh(command) 87 | super command, host: hosts.first 88 | end 89 | 90 | 91 | def ensure_local_file_present(local_file) 92 | if !local_file.is_a?(StringIO) && !Pathname.new(local_file).exist? 93 | raise "Missing file: #{local_file}" 94 | end 95 | end 96 | 97 | def remove_service_directory 98 | [ :rm, "-rf", service_name ] 99 | end 100 | 101 | def remove_container 102 | docker :container, :prune, "--force", *service_filter 103 | end 104 | 105 | def remove_image 106 | docker :image, :rm, "--force", image 107 | end 108 | 109 | def ensure_env_directory 110 | make_directory env_directory 111 | end 112 | 113 | private 114 | def service_filter 115 | [ "--filter", "label=service=#{service_name}" ] 116 | end 117 | end 118 | --------------------------------------------------------------------------------