├── .github └── workflows │ ├── ci.yml │ └── docker-publish.yml ├── .gitignore ├── .rubocop.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── Gemfile ├── Gemfile.lock ├── MIT-LICENSE ├── README.md ├── bin ├── docs ├── kamal ├── release └── test ├── gemfiles └── rails_edge.gemfile ├── kamal.gemspec ├── lib ├── kamal.rb └── kamal │ ├── cli.rb │ ├── cli │ ├── accessory.rb │ ├── alias │ │ └── command.rb │ ├── app.rb │ ├── app │ │ ├── assets.rb │ │ ├── boot.rb │ │ └── error_pages.rb │ ├── base.rb │ ├── build.rb │ ├── build │ │ └── clone.rb │ ├── healthcheck │ │ ├── barrier.rb │ │ ├── error.rb │ │ └── poller.rb │ ├── lock.rb │ ├── main.rb │ ├── proxy.rb │ ├── prune.rb │ ├── registry.rb │ ├── secrets.rb │ ├── server.rb │ └── templates │ │ ├── deploy.yml │ │ ├── sample_hooks │ │ ├── docker-setup.sample │ │ ├── post-app-boot.sample │ │ ├── post-deploy.sample │ │ ├── post-proxy-reboot.sample │ │ ├── pre-app-boot.sample │ │ ├── pre-build.sample │ │ ├── pre-connect.sample │ │ ├── pre-deploy.sample │ │ └── pre-proxy-reboot.sample │ │ └── secrets │ ├── commander.rb │ ├── commander │ └── specifics.rb │ ├── commands.rb │ ├── commands │ ├── accessory.rb │ ├── accessory │ │ └── proxy.rb │ ├── app.rb │ ├── app │ │ ├── assets.rb │ │ ├── containers.rb │ │ ├── error_pages.rb │ │ ├── execution.rb │ │ ├── images.rb │ │ ├── logging.rb │ │ └── proxy.rb │ ├── auditor.rb │ ├── base.rb │ ├── builder.rb │ ├── builder │ │ ├── base.rb │ │ ├── clone.rb │ │ ├── cloud.rb │ │ ├── hybrid.rb │ │ ├── local.rb │ │ └── remote.rb │ ├── docker.rb │ ├── hook.rb │ ├── lock.rb │ ├── proxy.rb │ ├── prune.rb │ ├── registry.rb │ └── server.rb │ ├── configuration.rb │ ├── configuration │ ├── accessory.rb │ ├── alias.rb │ ├── boot.rb │ ├── builder.rb │ ├── docs │ │ ├── accessory.yml │ │ ├── alias.yml │ │ ├── boot.yml │ │ ├── builder.yml │ │ ├── configuration.yml │ │ ├── env.yml │ │ ├── logging.yml │ │ ├── proxy.yml │ │ ├── registry.yml │ │ ├── role.yml │ │ ├── servers.yml │ │ ├── ssh.yml │ │ └── sshkit.yml │ ├── env.rb │ ├── env │ │ └── tag.rb │ ├── logging.rb │ ├── proxy.rb │ ├── proxy │ │ └── boot.rb │ ├── registry.rb │ ├── role.rb │ ├── servers.rb │ ├── ssh.rb │ ├── sshkit.rb │ ├── validation.rb │ ├── validator.rb │ ├── validator │ │ ├── accessory.rb │ │ ├── alias.rb │ │ ├── builder.rb │ │ ├── configuration.rb │ │ ├── env.rb │ │ ├── proxy.rb │ │ ├── registry.rb │ │ ├── role.rb │ │ └── servers.rb │ └── volume.rb │ ├── docker.rb │ ├── env_file.rb │ ├── git.rb │ ├── secrets.rb │ ├── secrets │ ├── adapters.rb │ ├── adapters │ │ ├── aws_secrets_manager.rb │ │ ├── base.rb │ │ ├── bitwarden.rb │ │ ├── bitwarden_secrets_manager.rb │ │ ├── doppler.rb │ │ ├── enpass.rb │ │ ├── gcp_secret_manager.rb │ │ ├── last_pass.rb │ │ ├── one_password.rb │ │ └── test.rb │ └── dotenv │ │ └── inline_command_substitution.rb │ ├── sshkit_with_ext.rb │ ├── tags.rb │ ├── utils.rb │ ├── utils │ └── sensitive.rb │ └── version.rb └── test ├── cli ├── accessory_test.rb ├── app_test.rb ├── build_test.rb ├── cli_test_case.rb ├── lock_test.rb ├── main_test.rb ├── proxy_test.rb ├── prune_test.rb ├── registry_test.rb ├── secrets_test.rb └── server_test.rb ├── commander_test.rb ├── commands ├── accessory_test.rb ├── app_test.rb ├── auditor_test.rb ├── builder_test.rb ├── docker_test.rb ├── hook_test.rb ├── lock_test.rb ├── proxy_test.rb ├── prune_test.rb ├── registry_test.rb └── server_test.rb ├── configuration ├── accessory_test.rb ├── boot_test.rb ├── builder_test.rb ├── env │ └── tags_test.rb ├── env_test.rb ├── proxy │ └── boot_test.rb ├── proxy_test.rb ├── role_test.rb ├── ssh_test.rb ├── sshkit_test.rb ├── validation_test.rb └── volume_test.rb ├── configuration_test.rb ├── env_file_test.rb ├── fixtures ├── deploy.elsewhere.yml ├── deploy.erb.yml ├── deploy.yml ├── deploy2.yml ├── deploy_for_dest.mars.yml ├── deploy_for_dest.world.yml ├── deploy_for_dest.yml ├── deploy_for_required_dest.world.yml ├── deploy_for_required_dest.yml ├── deploy_primary_web_role_override.yml ├── deploy_simple.yml ├── deploy_with_accessories.yml ├── deploy_with_accessories_on_independent_server.yml ├── deploy_with_accessories_with_different_registries.yml ├── deploy_with_aliases.yml ├── deploy_with_assets.yml ├── deploy_with_boot_strategy.yml ├── deploy_with_cloud_builder.yml ├── deploy_with_env_tags.yml ├── deploy_with_error_pages.yml ├── deploy_with_extensions.yml ├── deploy_with_hybrid_builder.yml ├── deploy_with_multiple_proxy_roles.yml ├── deploy_with_only_workers.yml ├── deploy_with_proxy.yml ├── deploy_with_proxy_roles.yml ├── deploy_with_remote_builder.yml ├── deploy_with_remote_builder_and_custom_ports.yml ├── deploy_with_roles.yml ├── deploy_with_roles_workers_primary.yml ├── deploy_with_secrets.yml ├── deploy_with_single_accessory.yml ├── deploy_with_two_roles_one_host.yml ├── deploy_with_uncommon_hostnames.yml ├── deploy_without_clone.yml └── files │ ├── my.cnf │ └── structure.sql.erb ├── git_test.rb ├── integration ├── accessory_test.rb ├── app_test.rb ├── broken_deploy_test.rb ├── docker-compose.yml ├── docker │ ├── deployer │ │ ├── Dockerfile │ │ ├── app │ │ │ ├── .kamal │ │ │ │ ├── hooks │ │ │ │ │ ├── docker-setup │ │ │ │ │ ├── post-app-boot │ │ │ │ │ ├── post-deploy │ │ │ │ │ ├── post-proxy-reboot │ │ │ │ │ ├── pre-app-boot │ │ │ │ │ ├── pre-build │ │ │ │ │ ├── pre-connect │ │ │ │ │ ├── pre-deploy │ │ │ │ │ └── pre-proxy-reboot │ │ │ │ ├── secrets │ │ │ │ └── secrets-common │ │ │ ├── Dockerfile │ │ │ ├── config │ │ │ │ └── deploy.yml │ │ │ └── default.conf │ │ ├── app_with_proxied_accessory │ │ │ ├── .kamal │ │ │ │ └── hooks │ │ │ │ │ └── pre-deploy │ │ │ ├── Dockerfile │ │ │ ├── config │ │ │ │ └── deploy.yml │ │ │ └── default.conf │ │ ├── app_with_roles │ │ │ ├── .kamal │ │ │ │ ├── hooks │ │ │ │ │ ├── docker-setup │ │ │ │ │ ├── post-deploy │ │ │ │ │ ├── post-proxy-reboot │ │ │ │ │ ├── pre-build │ │ │ │ │ ├── pre-connect │ │ │ │ │ ├── pre-deploy │ │ │ │ │ └── pre-proxy-reboot │ │ │ │ └── secrets │ │ │ ├── Dockerfile │ │ │ ├── config │ │ │ │ └── deploy.yml │ │ │ ├── default.conf │ │ │ └── error_pages │ │ │ │ └── 503.html │ │ ├── app_with_traefik │ │ │ ├── .kamal │ │ │ │ ├── hooks │ │ │ │ │ └── pre-deploy │ │ │ │ └── secrets │ │ │ ├── Dockerfile │ │ │ ├── config │ │ │ │ └── deploy.yml │ │ │ └── default.conf │ │ ├── boot.sh │ │ ├── break_app.sh │ │ ├── setup.sh │ │ └── update_app_rev.sh │ ├── load_balancer │ │ ├── Dockerfile │ │ └── default.conf │ ├── registry │ │ ├── Dockerfile │ │ └── boot.sh │ ├── shared │ │ ├── Dockerfile │ │ ├── boot.sh │ │ └── registry-dns.conf │ └── vm │ │ ├── Dockerfile │ │ └── boot.sh ├── integration_test.rb ├── lock_test.rb ├── main_test.rb └── proxy_test.rb ├── secrets ├── aws_secrets_manager_adapter_test.rb ├── bitwarden_adapter_test.rb ├── bitwarden_secrets_manager_adapter_test.rb ├── doppler_adapter_test.rb ├── dotenv_inline_command_substitution_test.rb ├── enpass_adapter_test.rb ├── gcp_secret_manager_adapter_test.rb ├── last_pass_adapter_test.rb └── one_password_adapter_test.rb ├── secrets_test.rb ├── test_helper.rb └── utils_test.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | workflow_dispatch: 8 | jobs: 9 | rubocop: 10 | name: RuboCop 11 | runs-on: ubuntu-latest 12 | env: 13 | BUNDLE_ONLY: rubocop 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | - name: Setup Ruby and install gems 18 | uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: 3.3.0 21 | bundler-cache: true 22 | - name: Run Rubocop 23 | run: bundle exec rubocop --parallel 24 | tests: 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | ruby-version: 29 | - "3.2" 30 | - "3.3" 31 | - "3.4" 32 | gemfile: 33 | - Gemfile 34 | - gemfiles/rails_edge.gemfile 35 | name: ${{ format('Tests (Ruby {0})', matrix.ruby-version) }} 36 | runs-on: ubuntu-latest 37 | env: 38 | BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }} 39 | steps: 40 | - uses: actions/checkout@v4 41 | 42 | - name: Remove gemfile.lock 43 | run: rm Gemfile.lock 44 | 45 | - name: Install Ruby 46 | uses: ruby/setup-ruby@v1 47 | with: 48 | ruby-version: ${{ matrix.ruby-version }} 49 | bundler-cache: true 50 | 51 | - name: Run tests 52 | run: bin/test 53 | env: 54 | RUBYOPT: ${{ startsWith(matrix.ruby-version, '3.4.') && '--enable=frozen-string-literal' || '' }} 55 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .byebug_history 2 | *.gem 3 | coverage/* 4 | .DS_Store 5 | gemfiles/*.lock 6 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | rubocop-rails-omakase: rubocop.yml 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.4-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 yaml-dev \ 17 | && rc-update add docker boot \ 18 | && gem install bundler --version=2.6.5 \ 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | $: << File.expand_path("../test", __dir__) 3 | 4 | require "bundler/setup" 5 | require "rails/plugin/test" 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.4" 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.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 | -------------------------------------------------------------------------------- /lib/kamal/cli.rb: -------------------------------------------------------------------------------- 1 | module Kamal::Cli 2 | class BootError < StandardError; end 3 | class HookError < StandardError; end 4 | class LockError < StandardError; end 5 | class DependencyError < StandardError; end 6 | end 7 | 8 | # SSHKit uses instance eval, so we need a global const for ergonomics 9 | KAMAL = Kamal::Commander.new 10 | -------------------------------------------------------------------------------- /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.reset 5 | Kamal::Cli::Main.start(Shellwords.split(_alias.command) + ARGV[1..-1]) 6 | else 7 | super 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/kamal/cli/app/assets.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Cli::App::Assets 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/cli/app/error_pages.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Cli::App::ErrorPages 2 | ERROR_PAGES_GLOB = "{4??.html,5??.html}" 3 | 4 | attr_reader :host, :sshkit 5 | delegate :upload!, :execute, to: :sshkit 6 | 7 | def initialize(host, sshkit) 8 | @host = host 9 | @sshkit = sshkit 10 | end 11 | 12 | def run 13 | if KAMAL.config.error_pages_path 14 | with_error_pages_tmpdir do |local_error_pages_dir| 15 | execute *KAMAL.app.create_error_pages_directory 16 | upload! local_error_pages_dir, KAMAL.config.proxy_boot.error_pages_directory, mode: "0700", recursive: true 17 | end 18 | end 19 | end 20 | 21 | private 22 | def with_error_pages_tmpdir 23 | Dir.mktmpdir("kamal-error-pages") do |tmpdir| 24 | error_pages_dir = File.join(tmpdir, KAMAL.config.version) 25 | FileUtils.mkdir(error_pages_dir) 26 | 27 | if (files = Dir[File.join(KAMAL.config.error_pages_path, ERROR_PAGES_GLOB)]).any? 28 | FileUtils.cp(files, error_pages_dir) 29 | yield error_pages_dir 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/kamal/cli/healthcheck/error.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Cli::Healthcheck::Error < StandardError 2 | end 3 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | ensure_docker_installed unless options[:skip_local] 7 | 8 | run_locally { execute *KAMAL.registry.login } unless options[:skip_local] 9 | on(KAMAL.hosts) { execute *KAMAL.registry.login } unless options[:skip_remote] 10 | end 11 | 12 | desc "logout", "Log out of registry locally and remotely" 13 | option :skip_local, aliases: "-L", type: :boolean, default: false, desc: "Skip local login" 14 | option :skip_remote, aliases: "-R", type: :boolean, default: false, desc: "Skip remote login" 15 | def logout 16 | run_locally { execute *KAMAL.registry.logout } unless options[:skip_local] 17 | on(KAMAL.hosts) { execute *KAMAL.registry.logout } unless options[:skip_remote] 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /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/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 | pre_connect_if_required 6 | 7 | cmd = Kamal::Utils.join_commands(cmd) 8 | hosts = KAMAL.hosts 9 | 10 | case 11 | when options[:interactive] 12 | host = KAMAL.primary_host 13 | 14 | say "Running '#{cmd}' on #{host} interactively...", :magenta 15 | 16 | run_locally { exec KAMAL.server.run_over_ssh(cmd, host: host) } 17 | else 18 | say "Running '#{cmd}' on #{hosts.join(', ')}...", :magenta 19 | 20 | on(hosts) do |host| 21 | execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on #{host}"), verbosity: :debug 22 | puts_by_host host, capture_with_info(cmd) 23 | end 24 | end 25 | end 26 | 27 | desc "bootstrap", "Set up Docker to run Kamal apps" 28 | def bootstrap 29 | with_lock do 30 | missing = [] 31 | 32 | on(KAMAL.hosts) do |host| 33 | unless execute(*KAMAL.docker.installed?, raise_on_non_zero_exit: false) 34 | if execute(*KAMAL.docker.superuser?, raise_on_non_zero_exit: false) 35 | info "Missing Docker on #{host}. Installing…" 36 | execute *KAMAL.docker.install 37 | else 38 | missing << host 39 | end 40 | end 41 | end 42 | 43 | if missing.any? 44 | 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/" 45 | end 46 | 47 | run_hook "docker-setup" 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/kamal/cli/templates/sample_hooks/docker-setup.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Docker set up on $KAMAL_HOSTS..." 4 | -------------------------------------------------------------------------------- /lib/kamal/cli/templates/sample_hooks/post-app-boot.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..." 4 | -------------------------------------------------------------------------------- /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_ROLES (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/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-app-boot.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..." 4 | -------------------------------------------------------------------------------- /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_ROLES (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 | -------------------------------------------------------------------------------- /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_ROLES (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/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_ROLES (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 | begin 86 | puts "Checking build status..." 87 | 88 | attempts = 0 89 | checks = GithubStatusChecks.new 90 | 91 | loop do 92 | case checks.state 93 | when "success" 94 | puts "Checks passed, see #{checks.first_status_url}" 95 | exit 0 96 | when "failure" 97 | exit_with_error "Checks failed, see #{checks.first_status_url}" 98 | when "pending" 99 | attempts += 1 100 | end 101 | 102 | exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS 103 | 104 | puts checks.current_status 105 | sleep(ATTEMPTS_GAP) 106 | checks.refresh! 107 | end 108 | rescue Octokit::NotFound 109 | exit_with_error "Build status could not be found" 110 | end 111 | -------------------------------------------------------------------------------- /lib/kamal/cli/templates/sample_hooks/pre-proxy-reboot.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Rebooting kamal-proxy on $KAMAL_HOSTS..." 4 | -------------------------------------------------------------------------------- /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/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 | sort_primary_role_hosts_first!(hosts) 15 | end 16 | 17 | def roles_on(host) 18 | roles.select { |role| role.hosts.include?(host.to_s) } 19 | end 20 | 21 | def app_hosts 22 | @app_hosts ||= sort_primary_role_hosts_first!(config.app_hosts & specified_hosts) 23 | end 24 | 25 | def proxy_hosts 26 | config.proxy_hosts & specified_hosts 27 | end 28 | 29 | def accessory_hosts 30 | config.accessories.flat_map(&:hosts) & specified_hosts 31 | end 32 | 33 | private 34 | attr_reader :config, :specific_hosts, :specific_roles 35 | 36 | def primary_specific_role 37 | primary_or_first_role(specific_roles) if specific_roles.present? 38 | end 39 | 40 | def primary_or_first_role(roles) 41 | roles.detect { |role| role == config.primary_role } || roles.first 42 | end 43 | 44 | def specified_roles 45 | (specific_roles || config.roles) \ 46 | .select { |role| ((specific_hosts || config.all_hosts) & role.hosts).any? } 47 | end 48 | 49 | def specified_hosts 50 | specified_hosts = specific_hosts || config.all_hosts 51 | 52 | if (specific_role_hosts = specific_roles&.flat_map(&:hosts)).present? 53 | specified_hosts.select { |host| specific_role_hosts.include?(host) } 54 | else 55 | specified_hosts 56 | end 57 | end 58 | 59 | def sort_primary_role_hosts_first!(hosts) 60 | stable_sort!(hosts) { |host| roles_on(host).any? { |role| role == primary_role } ? 0 : 1 } 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/kamal/commands.rb: -------------------------------------------------------------------------------- 1 | module Kamal::Commands 2 | end 3 | -------------------------------------------------------------------------------- /lib/kamal/commands/accessory/proxy.rb: -------------------------------------------------------------------------------- 1 | module Kamal::Commands::Accessory::Proxy 2 | delegate :container_name, to: :"config.proxy_boot", prefix: :proxy 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/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(:container, :rm, asset_container, "2> /dev/null"), "|| true" ], 8 | docker(:container, :create, "--name", asset_container, config.absolute_image), 9 | docker(:container, :cp, "-L", "#{asset_container}:#{role.asset_path}/.", role.asset_extracted_directory), 10 | docker(:container, :rm, 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/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 | -------------------------------------------------------------------------------- /lib/kamal/commands/app/error_pages.rb: -------------------------------------------------------------------------------- 1 | module Kamal::Commands::App::ErrorPages 2 | def create_error_pages_directory 3 | make_directory(config.proxy_boot.error_pages_directory) 4 | end 5 | 6 | def clean_up_error_pages 7 | [ :find, config.proxy_boot.error_pages_directory, "-mindepth", "1", "-maxdepth", "1", "!", "-name", KAMAL.config.version, "-exec", "rm", "-rf", "{} +" ] 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /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, detach: false, env:) 11 | docker :run, 12 | ("-it" if interactive), 13 | ("--detach" if detach), 14 | ("--rm" unless detach), 15 | "--network", "kamal", 16 | *role&.env_args(host), 17 | *argumentize("--env", env), 18 | *role.logging_args, 19 | *config.volume_args, 20 | *role&.option_args, 21 | config.absolute_image, 22 | *command 23 | end 24 | 25 | def execute_in_existing_container_over_ssh(*command, env:) 26 | run_over_ssh execute_in_existing_container(*command, interactive: true, env: env), host: host 27 | end 28 | 29 | def execute_in_new_container_over_ssh(*command, env:) 30 | run_over_ssh execute_in_new_container(*command, interactive: true, env: env), host: host 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /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/commands/app/logging.rb: -------------------------------------------------------------------------------- 1 | module Kamal::Commands::App::Logging 2 | def logs(container_id: nil, timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil) 3 | pipe \ 4 | container_id_command(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:, container_id: nil, timestamps: true, lines: nil, grep: nil, grep_options: nil) 10 | run_over_ssh \ 11 | pipe( 12 | container_id_command(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 | 19 | private 20 | 21 | def container_id_command(container_id) 22 | case container_id 23 | when Array then container_id 24 | when String, Symbol then "echo #{container_id}" 25 | else current_running_container_id 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/kamal/commands/app/proxy.rb: -------------------------------------------------------------------------------- 1 | module Kamal::Commands::App::Proxy 2 | delegate :container_name, to: :"config.proxy_boot", prefix: :proxy 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 | def live 13 | proxy_exec :resume, role.container_prefix 14 | end 15 | 16 | def maintenance(**options) 17 | proxy_exec :stop, role.container_prefix, *role.proxy.stop_command_args(**options) 18 | end 19 | 20 | def remove_proxy_app_directory 21 | remove_directory config.proxy_boot.app_directory 22 | end 23 | 24 | private 25 | def proxy_exec(*command) 26 | docker :exec, proxy_container_name, "kamal-proxy", *command 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/kamal/commands/auditor.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Commands::Auditor < Kamal::Commands::Base 2 | attr_reader :details 3 | delegate :escape_shell_value, to: Kamal::Utils 4 | 5 | def initialize(config, **details) 6 | super(config) 7 | @details = details 8 | end 9 | 10 | # Runs remotely 11 | def record(line, **details) 12 | combine \ 13 | make_run_directory, 14 | append([ :echo, escape_shell_value(audit_line(line, **details)) ], audit_log_file) 15 | end 16 | 17 | def reveal 18 | [ :tail, "-n", 50, audit_log_file ] 19 | end 20 | 21 | private 22 | def audit_log_file 23 | file = [ config.service, config.destination, "audit.log" ].compact.join("-") 24 | 25 | File.join(config.run_directory, file) 26 | end 27 | 28 | def audit_tags(**details) 29 | tags(**self.details, **details) 30 | end 31 | 32 | def make_run_directory 33 | [ :mkdir, "-p", config.run_directory ] 34 | end 35 | 36 | def audit_line(line, **details) 37 | "#{audit_tags(**details).except(:version, :service_version, :service)} #{line}" 38 | end 39 | end 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, :dev, :push, :clean, :pull, :info, :inspect_builder, :validate_image, :first_mirror, to: :target 5 | delegate :local?, :remote?, :cloud?, 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 | elsif cloud? 21 | cloud 22 | else 23 | local 24 | end 25 | end 26 | 27 | def remote 28 | @remote ||= Kamal::Commands::Builder::Remote.new(config) 29 | end 30 | 31 | def local 32 | @local ||= Kamal::Commands::Builder::Local.new(config) 33 | end 34 | 35 | def hybrid 36 | @hybrid ||= Kamal::Commands::Builder::Hybrid.new(config) 37 | end 38 | 39 | def cloud 40 | @cloud ||= Kamal::Commands::Builder::Cloud.new(config) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /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/commands/builder/cloud.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Commands::Builder::Cloud < Kamal::Commands::Builder::Base 2 | # Expects `driver` to be of format "cloud docker-org-name/builder-name" 3 | 4 | def create 5 | docker :buildx, :create, "--driver", driver 6 | end 7 | 8 | def remove 9 | docker :buildx, :rm, builder_name 10 | end 11 | 12 | private 13 | def builder_name 14 | driver.gsub(/[ \/]/, "-") 15 | end 16 | 17 | def inspect_buildx 18 | pipe \ 19 | docker(:buildx, :inspect, builder_name), 20 | grep("-q", "Endpoint:.*cloud://.*") 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/kamal/commands/registry.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Commands::Registry < Kamal::Commands::Base 2 | def login(registry_config: nil) 3 | registry_config ||= config.registry 4 | 5 | docker :login, 6 | registry_config.server, 7 | "-u", sensitive(Kamal::Utils.escape_shell_value(registry_config.username)), 8 | "-p", sensitive(Kamal::Utils.escape_shell_value(registry_config.password)) 9 | end 10 | 11 | def logout(registry_config: nil) 12 | registry_config ||= config.registry 13 | 14 | docker :logout, registry_config.server 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 --reuse "bin/rails console" 9 | # ``` 10 | # 11 | # By defining an alias, like this: 12 | aliases: 13 | console: app exec -i --reuse "bin/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 | -------------------------------------------------------------------------------- /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/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/configuration/docs/registry.yml: -------------------------------------------------------------------------------- 1 | # Registry 2 | # 3 | # The default registry is Docker Hub, but you can change it using `registry/server`. 4 | # 5 | # By default, Docker Hub creates public repositories. To avoid making your images public, 6 | # set up a private repository before deploying, or change the default repository privacy 7 | # settings to private in your [Docker Hub settings](https://hub.docker.com/repository-settings/default-privacy). 8 | # 9 | # A reference to a secret (in this case, `DOCKER_REGISTRY_TOKEN`) will look up the secret 10 | # in the local environment: 11 | registry: 12 | server: registry.digitalocean.com 13 | username: 14 | - DOCKER_REGISTRY_TOKEN 15 | password: 16 | - DOCKER_REGISTRY_TOKEN 17 | 18 | # Using AWS ECR as the container registry 19 | # 20 | # You will need to have the AWS CLI installed locally for this to work. 21 | # 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: 22 | registry: 23 | server: .dkr.ecr..amazonaws.com 24 | username: AWS 25 | password: <%= %x(aws ecr get-login-password) %> 26 | 27 | # Using GCP Artifact Registry as the container registry 28 | # 29 | # To sign into Artifact Registry, you need to 30 | # [create a service account](https://cloud.google.com/iam/docs/service-accounts-create#creating) 31 | # and [set up roles and permissions](https://cloud.google.com/artifact-registry/docs/access-control#permissions). 32 | # Normally, assigning the `roles/artifactregistry.writer` role should be sufficient. 33 | # 34 | # Once the service account is ready, you need to generate and download a JSON key and base64 encode it: 35 | # 36 | # ```shell 37 | # base64 -i /path/to/key.json | tr -d "\\n" 38 | # ``` 39 | # 40 | # You'll then need to set the `KAMAL_REGISTRY_PASSWORD` secret to that value. 41 | # 42 | # Use the environment variable as the password along with `_json_key_base64` as the username. 43 | # Here’s the final configuration: 44 | registry: 45 | server: -docker.pkg.dev 46 | username: _json_key_base64 47 | password: 48 | - KAMAL_REGISTRY_PASSWORD 49 | 50 | # Validating the configuration 51 | # 52 | # You can validate the configuration by running: 53 | # 54 | # ```shell 55 | # kamal registry login 56 | # ``` 57 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/env.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Configuration::Env 2 | include Kamal::Configuration::Validation 3 | 4 | attr_reader :context, :clear, :secret_keys 5 | delegate :argumentize, to: Kamal::Utils 6 | 7 | def initialize(config:, secrets:, context: "env") 8 | @clear = config.fetch("clear", config.key?("secret") || config.key?("tags") ? {} : config) 9 | @secrets = secrets 10 | @secret_keys = config.fetch("secret", []) 11 | @context = context 12 | validate! config, context: context, with: Kamal::Configuration::Validator::Env 13 | end 14 | 15 | def clear_args 16 | argumentize("--env", clear) 17 | end 18 | 19 | def secrets_io 20 | Kamal::EnvFile.new(aliased_secrets).to_io 21 | end 22 | 23 | def merge(other) 24 | self.class.new \ 25 | config: { "clear" => clear.merge(other.clear), "secret" => secret_keys | other.secret_keys }, 26 | secrets: @secrets 27 | end 28 | 29 | private 30 | def aliased_secrets 31 | secret_keys.to_h { |key| extract_alias(key) }.transform_values { |secret_key| @secrets[secret_key] } 32 | end 33 | 34 | def extract_alias(key) 35 | key_name, key_aliased_to = key.split(":", 2) 36 | [ key_name, key_aliased_to || key_name ] 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | @proxy_config = {} if @proxy_config.nil? 15 | validate! @proxy_config, with: Kamal::Configuration::Validator::Proxy, context: context 16 | end 17 | 18 | def app_port 19 | proxy_config.fetch("app_port", 80) 20 | end 21 | 22 | def ssl? 23 | proxy_config.fetch("ssl", false) 24 | end 25 | 26 | def hosts 27 | proxy_config["hosts"] || proxy_config["host"]&.split(",") || [] 28 | end 29 | 30 | def deploy_options 31 | { 32 | host: hosts, 33 | tls: proxy_config["ssl"].presence, 34 | "deploy-timeout": seconds_duration(config.deploy_timeout), 35 | "drain-timeout": seconds_duration(config.drain_timeout), 36 | "health-check-interval": seconds_duration(proxy_config.dig("healthcheck", "interval")), 37 | "health-check-timeout": seconds_duration(proxy_config.dig("healthcheck", "timeout")), 38 | "health-check-path": proxy_config.dig("healthcheck", "path"), 39 | "target-timeout": seconds_duration(proxy_config["response_timeout"]), 40 | "buffer-requests": proxy_config.fetch("buffering", { "requests": true }).fetch("requests", true), 41 | "buffer-responses": proxy_config.fetch("buffering", { "responses": true }).fetch("responses", true), 42 | "buffer-memory": proxy_config.dig("buffering", "memory"), 43 | "max-request-body": proxy_config.dig("buffering", "max_request_body"), 44 | "max-response-body": proxy_config.dig("buffering", "max_response_body"), 45 | "forward-headers": proxy_config.dig("forward_headers"), 46 | "tls-redirect": proxy_config.dig("ssl_redirect"), 47 | "log-request-header": proxy_config.dig("logging", "request_headers") || DEFAULT_LOG_REQUEST_HEADERS, 48 | "log-response-header": proxy_config.dig("logging", "response_headers"), 49 | "error-pages": error_pages 50 | }.compact 51 | end 52 | 53 | def deploy_command_args(target:) 54 | optionize ({ target: "#{target}:#{app_port}" }).merge(deploy_options), with: "=" 55 | end 56 | 57 | def stop_options(drain_timeout: nil, message: nil) 58 | { 59 | "drain-timeout": seconds_duration(drain_timeout), 60 | message: message 61 | }.compact 62 | end 63 | 64 | def stop_command_args(**options) 65 | optionize stop_options(**options), with: "=" 66 | end 67 | 68 | def merge(other) 69 | self.class.new config: config, proxy_config: proxy_config.deep_merge(other.proxy_config) 70 | end 71 | 72 | private 73 | def seconds_duration(value) 74 | value ? "#{value}s" : nil 75 | end 76 | 77 | def error_pages 78 | File.join config.proxy_boot.error_pages_container_directory, config.version if config.error_pages_path 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/kamal/configuration/registry.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Configuration::Registry 2 | include Kamal::Configuration::Validation 3 | 4 | def initialize(config:, secrets:, context: "registry") 5 | @registry_config = config["registry"] || {} 6 | @secrets = secrets 7 | validate! registry_config, context: context, with: Kamal::Configuration::Validator::Registry 8 | end 9 | 10 | def server 11 | registry_config["server"] 12 | end 13 | 14 | def username 15 | lookup("username") 16 | end 17 | 18 | def password 19 | lookup("password") 20 | end 21 | 22 | private 23 | attr_reader :registry_config, :secrets 24 | 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 | -------------------------------------------------------------------------------- /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 | case servers_config 17 | when Array 18 | [ "web" ] 19 | when NilClass 20 | [] 21 | else 22 | servers_config.keys.sort 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/validator/accessory.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Configuration::Validator::Accessory < Kamal::Configuration::Validator 2 | def validate! 3 | super 4 | 5 | if (config.keys & [ "host", "hosts", "role", "roles", "tag", "tags" ]).size != 1 6 | error "specify one of `host`, `hosts`, `role`, `roles`, `tag` or `tags`" 7 | end 8 | 9 | validate_docker_options!(config["options"]) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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!(config) 7 | else 8 | super 9 | validate_docker_options!(config["options"]) 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/kamal/configuration/validator/servers.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Configuration::Validator::Servers < Kamal::Configuration::Validator 2 | def validate! 3 | validate_type! config, Array, Hash, NilClass 4 | 5 | validate_servers! config if config.is_a?(Array) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/kamal/docker.rb: -------------------------------------------------------------------------------- 1 | require "tempfile" 2 | require "open3" 3 | 4 | module Kamal::Docker 5 | extend self 6 | BUILD_CHECK_TAG = "kamal-local-build-check" 7 | 8 | def included_files 9 | Tempfile.create do |dockerfile| 10 | dockerfile.write(<<~DOCKERFILE) 11 | FROM busybox 12 | COPY . app 13 | WORKDIR app 14 | CMD find . -type f | sed "s|^\./||" 15 | DOCKERFILE 16 | dockerfile.close 17 | 18 | cmd = "docker buildx build -t=#{BUILD_CHECK_TAG} -f=#{dockerfile.path} ." 19 | system(cmd) || raise("failed to build check image") 20 | end 21 | 22 | cmd = "docker run --rm #{BUILD_CHECK_TAG}" 23 | out, err, status = Open3.capture3(cmd) 24 | unless status 25 | raise "failed to run check image:\n#{err}" 26 | end 27 | 28 | out.lines.map(&:strip) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | 28 | # returns an array of relative path names of files with uncommitted changes 29 | def uncommitted_files 30 | `git ls-files --modified`.lines.map(&:strip) 31 | end 32 | 33 | # returns an array of relative path names of untracked files, including gitignored files 34 | def untracked_files 35 | `git ls-files --others`.lines.map(&:strip) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /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, overwrite: true)) 36 | end 37 | end 38 | 39 | def secrets_filenames 40 | [ ".kamal/secrets-common", ".kamal/secrets#{(".#{@destination}" if @destination)}" ] 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /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 | name = "gcp_secret_manager" if name.downcase == "gcp" 7 | name = "bitwarden_secrets_manager" if name.downcase == "bitwarden-sm" 8 | adapter_class(name) 9 | end 10 | 11 | def self.adapter_class(name) 12 | Object.const_get("Kamal::Secrets::Adapters::#{name.camelize}").new 13 | rescue NameError => e 14 | raise RuntimeError, "Unknown secrets adapter: #{name}" 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/kamal/secrets/adapters/aws_secrets_manager.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Secrets::Adapters::AwsSecretsManager < Kamal::Secrets::Adapters::Base 2 | def requires_account? 3 | false 4 | end 5 | 6 | private 7 | def login(_account) 8 | nil 9 | end 10 | 11 | def fetch_secrets(secrets, from:, account: nil, session:) 12 | {}.tap do |results| 13 | get_from_secrets_manager(prefixed_secrets(secrets, from: from), account: account).each do |secret| 14 | secret_name = secret["Name"] 15 | secret_string = JSON.parse(secret["SecretString"]) 16 | 17 | secret_string.each do |key, value| 18 | results["#{secret_name}/#{key}"] = value 19 | end 20 | rescue JSON::ParserError 21 | results["#{secret_name}"] = secret["SecretString"] 22 | end 23 | end 24 | end 25 | 26 | def get_from_secrets_manager(secrets, account: nil) 27 | args = [ "aws", "secretsmanager", "batch-get-secret-value", "--secret-id-list" ] + secrets.map(&:shellescape) 28 | args += [ "--profile", account.shellescape ] if account 29 | args += [ "--output", "json" ] 30 | cmd = args.join(" ") 31 | 32 | `#{cmd}`.tap do |secrets| 33 | raise RuntimeError, "Could not read #{secrets} from AWS Secrets Manager" unless $?.success? 34 | 35 | secrets = JSON.parse(secrets) 36 | 37 | return secrets["SecretValues"] unless secrets["Errors"].present? 38 | 39 | raise RuntimeError, secrets["Errors"].map { |error| "#{error['SecretId']}: #{error['Message']}" }.join(" ") 40 | end 41 | end 42 | 43 | def check_dependencies! 44 | raise RuntimeError, "AWS CLI is not installed" unless cli_installed? 45 | end 46 | 47 | def cli_installed? 48 | `aws --version 2> /dev/null` 49 | $?.success? 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /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 | fetch_secrets(secrets, from: from, account: account, session: session) 11 | end 12 | 13 | def requires_account? 14 | true 15 | end 16 | 17 | private 18 | def login(...) 19 | raise NotImplementedError 20 | end 21 | 22 | def fetch_secrets(...) 23 | raise NotImplementedError 24 | end 25 | 26 | def check_dependencies! 27 | raise NotImplementedError 28 | end 29 | 30 | def prefixed_secrets(secrets, from:) 31 | secrets.map { |secret| [ from, secret ].compact.join("/") } 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /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, from:, account:, session:) 25 | {}.tap do |results| 26 | items_fields(prefixed_secrets(secrets, from: from)).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/secrets/adapters/bitwarden_secrets_manager.rb: -------------------------------------------------------------------------------- 1 | class Kamal::Secrets::Adapters::BitwardenSecretsManager < Kamal::Secrets::Adapters::Base 2 | def requires_account? 3 | false 4 | end 5 | 6 | private 7 | LIST_ALL_SELECTOR = "all" 8 | LIST_ALL_FROM_PROJECT_SUFFIX = "/all" 9 | LIST_COMMAND = "secret list -o env" 10 | GET_COMMAND = "secret get -o env" 11 | 12 | def fetch_secrets(secrets, from:, account:, session:) 13 | raise RuntimeError, "You must specify what to retrieve from Bitwarden Secrets Manager" if secrets.length == 0 14 | 15 | secrets = prefixed_secrets(secrets, from: from) 16 | command, project = extract_command_and_project(secrets) 17 | 18 | {}.tap do |results| 19 | if command.nil? 20 | secrets.each do |secret_uuid| 21 | secret = run_command("#{GET_COMMAND} #{secret_uuid.shellescape}") 22 | raise RuntimeError, "Could not read #{secret_uuid} from Bitwarden Secrets Manager" unless $?.success? 23 | key, value = parse_secret(secret) 24 | results[key] = value 25 | end 26 | else 27 | secrets = run_command(command) 28 | raise RuntimeError, "Could not read secrets from Bitwarden Secrets Manager" unless $?.success? 29 | secrets.split("\n").each do |secret| 30 | key, value = parse_secret(secret) 31 | results[key] = value 32 | end 33 | end 34 | end 35 | end 36 | 37 | def extract_command_and_project(secrets) 38 | if secrets.length == 1 39 | if secrets[0] == LIST_ALL_SELECTOR 40 | [ LIST_COMMAND, nil ] 41 | elsif secrets[0].end_with?(LIST_ALL_FROM_PROJECT_SUFFIX) 42 | project = secrets[0].split(LIST_ALL_FROM_PROJECT_SUFFIX).first 43 | [ "#{LIST_COMMAND} #{project.shellescape}", project ] 44 | end 45 | end 46 | end 47 | 48 | def parse_secret(secret) 49 | key, value = secret.split("=", 2) 50 | value = value.gsub(/^"|"$/, "") 51 | [ key, value ] 52 | end 53 | 54 | def run_command(command, session: nil) 55 | full_command = [ "bws", command ].join(" ") 56 | `#{full_command}` 57 | end 58 | 59 | def login(account) 60 | run_command("run 'echo OK'") 61 | raise RuntimeError, "Could not authenticate to Bitwarden Secrets Manager. Did you set a valid access token?" unless $?.success? 62 | end 63 | 64 | def check_dependencies! 65 | raise RuntimeError, "Bitwarden Secrets Manager CLI is not installed" unless cli_installed? 66 | end 67 | 68 | def cli_installed? 69 | `bws --version 2> /dev/null` 70 | $?.success? 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /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, from:, **) 20 | secrets = prefixed_secrets(secrets, from: from) 21 | flags = secrets_get_flags(secrets) 22 | 23 | secret_names = secrets.collect { |s| s.split("/").last } 24 | 25 | items = `doppler secrets get #{secret_names.map(&:shellescape).join(" ")} --json #{flags}` 26 | raise RuntimeError, "Could not read #{secrets} from Doppler" unless $?.success? 27 | 28 | items = JSON.parse(items) 29 | 30 | items.transform_values { |value| value["computed"] } 31 | end 32 | 33 | def secrets_get_flags(secrets) 34 | unless service_token_set? 35 | project, config, _ = secrets.first.split("/") 36 | 37 | unless project && config 38 | raise RuntimeError, "Missing project or config from '--from=project/config' option" 39 | end 40 | 41 | project_and_config_flags = "-p #{project.shellescape} -c #{config.shellescape}" 42 | end 43 | end 44 | 45 | def service_token_set? 46 | ENV["DOPPLER_TOKEN"] && ENV["DOPPLER_TOKEN"][0, 5] == "dp.st" 47 | end 48 | 49 | def check_dependencies! 50 | raise RuntimeError, "Doppler CLI is not installed" unless cli_installed? 51 | end 52 | 53 | def cli_installed? 54 | `doppler --version 2> /dev/null` 55 | $?.success? 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/kamal/secrets/adapters/enpass.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # Enpass is different from most password managers, in a way that it's offline and doesn't need an account. 3 | # 4 | # Usage 5 | # 6 | # Fetch all password from FooBar item 7 | # `kamal secrets fetch --adapter enpass --from /Users/YOUR_USERNAME/Library/Containers/in.sinew.Enpass-Desktop/Data/Documents/Vaults/primary FooBar` 8 | # 9 | # Fetch only DB_PASSWORD from FooBar item 10 | # `kamal secrets fetch --adapter enpass --from /Users/YOUR_USERNAME/Library/Containers/in.sinew.Enpass-Desktop/Data/Documents/Vaults/primary FooBar/DB_PASSWORD` 11 | class Kamal::Secrets::Adapters::Enpass < Kamal::Secrets::Adapters::Base 12 | def requires_account? 13 | false 14 | end 15 | 16 | private 17 | def fetch_secrets(secrets, from:, account:, session:) 18 | secrets_titles = fetch_secret_titles(secrets) 19 | 20 | result = `enpass-cli -json -vault #{from.shellescape} show #{secrets_titles.map(&:shellescape).join(" ")}`.strip 21 | 22 | parse_result_and_take_secrets(result, secrets) 23 | end 24 | 25 | def check_dependencies! 26 | raise RuntimeError, "Enpass CLI is not installed" unless cli_installed? 27 | end 28 | 29 | def cli_installed? 30 | `enpass-cli version 2> /dev/null` 31 | $?.success? 32 | end 33 | 34 | def login(account) 35 | nil 36 | end 37 | 38 | def fetch_secret_titles(secrets) 39 | secrets.reduce(Set.new) do |secret_titles, secret| 40 | # Sometimes secrets contain a '/', when the intent is to fetch a single password for an item. Example: FooBar/DB_PASSWORD 41 | # Another case is, when the intent is to fetch all passwords for an item. Example: FooBar (and FooBar may have multiple different passwords) 42 | key, separator, value = secret.rpartition("/") 43 | if key.empty? 44 | secret_titles << value 45 | else 46 | secret_titles << key 47 | end 48 | end.to_a 49 | end 50 | 51 | def parse_result_and_take_secrets(unparsed_result, secrets) 52 | result = JSON.parse(unparsed_result) 53 | 54 | result.reduce({}) do |secrets_with_passwords, item| 55 | title = item["title"] 56 | label = item["label"] 57 | password = item["password"] 58 | 59 | if title && password.present? 60 | key = [ title, label ].compact.reject(&:empty?).join("/") 61 | 62 | if secrets.include?(title) || secrets.include?(key) 63 | raise RuntimeError, "#{key} is present more than once" if secrets_with_passwords[key] 64 | secrets_with_passwords[key] = password 65 | end 66 | end 67 | 68 | secrets_with_passwords 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /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, from:, account:, session:) 15 | secrets = prefixed_secrets(secrets, from: from) 16 | items = `lpass show #{secrets.map(&:shellescape).join(" ")} --json` 17 | raise RuntimeError, "Could not read #{secrets} from LastPass" unless $?.success? 18 | 19 | items = JSON.parse(items) 20 | 21 | {}.tap do |results| 22 | items.each do |item| 23 | results[item["fullname"]] = item["password"] 24 | end 25 | 26 | if (missing_items = secrets - results.keys).any? 27 | raise RuntimeError, "Could not find #{missing_items.join(", ")} in LastPass" 28 | end 29 | end 30 | end 31 | 32 | def check_dependencies! 33 | raise RuntimeError, "LastPass CLI is not installed" unless cli_installed? 34 | end 35 | 36 | def cli_installed? 37 | `lpass --version 2> /dev/null` 38 | $?.success? 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /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, from:, account:, session:) 19 | {}.tap do |results| 20 | vaults_items_fields(prefixed_secrets(secrets, from: from)).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/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, from:, account:, session:) 8 | prefixed_secrets(secrets, from: from).to_h { |secret| [ secret, secret.reverse ] } 9 | end 10 | 11 | def check_dependencies! 12 | # no op 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /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 | command = ::Dotenv::Substitutions::Variable.call(command, env) 18 | if command =~ /\A\s*kamal\s*secrets\s+/ 19 | # Inline the command 20 | inline_secrets_command(command) 21 | else 22 | # Execute the command and return the value 23 | `#{command}`.chomp 24 | end 25 | end 26 | end 27 | end 28 | 29 | def inline_secrets_command(command) 30 | Kamal::Cli::Main.start(command.shellsplit[1..] + [ "--inline" ]).chomp 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/version.rb: -------------------------------------------------------------------------------- 1 | module Kamal 2 | VERSION = "2.6.1" 3 | end 4 | -------------------------------------------------------------------------------- /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, count: 1) 44 | regexp = ([ "/usr/bin/env .kamal/hooks/#{hook}" ] * count).join(".*") 45 | assert_match /#{regexp}/m, output 46 | end 47 | 48 | def with_argv(*argv) 49 | old_argv = ARGV 50 | ARGV.replace(*argv) 51 | yield 52 | ensure 53 | ARGV.replace(old_argv) 54 | end 55 | 56 | def with_build_directory 57 | build_directory = File.join Dir.tmpdir, "kamal-clones", "app-#{pwd_sha}", "kamal" 58 | FileUtils.mkdir_p build_directory 59 | FileUtils.touch File.join build_directory, "Dockerfile" 60 | yield build_directory + "/" 61 | ensure 62 | FileUtils.rm_rf build_directory 63 | end 64 | 65 | def pwd_sha 66 | Digest::SHA256.hexdigest(Dir.pwd)[0..12] 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /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/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/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 | test "login with no docker" do 47 | stub_setup 48 | SSHKit::Backend::Abstract.any_instance.stubs(:execute) 49 | .with(:docker, "--version", "&&", :docker, :buildx, "version") 50 | .raises(SSHKit::Command::Failed.new("command not found")) 51 | 52 | assert_raises(Kamal::Cli::DependencyError) { run_command("login") } 53 | end 54 | 55 | test "allow remote login with no docker" do 56 | stub_setup 57 | SSHKit::Backend::Abstract.any_instance.stubs(:execute) 58 | .with(:docker, "--version", "&&", :docker, :buildx, "version") 59 | .raises(SSHKit::Command::Failed.new("command not found")) 60 | 61 | SSHKit::Backend::Abstract.any_instance.stubs(:execute) 62 | .with { |*args| args[0..1] == [ :docker, :login ] } 63 | 64 | assert_nothing_raised { run_command("login", "--skip-local") } 65 | end 66 | 67 | 68 | private 69 | def run_command(*command) 70 | stdouted { Kamal::Cli::Registry.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) } 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /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 "extract" do 17 | assert_equal "oof", run_command("extract", "foo", "{\"foo\":\"oof\", \"bar\":\"rab\", \"baz\":\"zab\"}") 18 | end 19 | 20 | test "extract match from end" do 21 | assert_equal "oof", run_command("extract", "foo", "{\"abc/foo\":\"oof\", \"bar\":\"rab\", \"baz\":\"zab\"}") 22 | end 23 | 24 | test "print" do 25 | with_test_secrets("secrets" => "SECRET1=ABC\nSECRET2=${SECRET1}DEF\n") do 26 | assert_equal "SECRET1=ABC\nSECRET2=ABCDEF", run_command("print") 27 | end 28 | end 29 | 30 | private 31 | def run_command(*command) 32 | stdouted { Kamal::Cli::Secrets.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) } 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /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}] app removed container\"", 24 | ">>", ".kamal/app-audit.log" 25 | ], @auditor.record("app removed container") 26 | end 27 | 28 | test "record with destination" do 29 | new_command(destination: "staging").tap do |auditor| 30 | assert_equal [ 31 | :mkdir, "-p", ".kamal", "&&", 32 | :echo, 33 | "\"[#{@recorded_at}] [#{@performer}] [staging] app removed container\"", 34 | ">>", ".kamal/app-staging-audit.log" 35 | ], auditor.record("app removed container") 36 | end 37 | end 38 | 39 | test "record with command details" do 40 | new_command(role: "web").tap do |auditor| 41 | assert_equal [ 42 | :mkdir, "-p", ".kamal", "&&", 43 | :echo, 44 | "\"[#{@recorded_at}] [#{@performer}] [web] app removed container\"", 45 | ">>", ".kamal/app-audit.log" 46 | ], auditor.record("app removed container") 47 | end 48 | end 49 | 50 | test "record with arg details" do 51 | assert_equal [ 52 | :mkdir, "-p", ".kamal", "&&", 53 | :echo, 54 | "\"[#{@recorded_at}] [#{@performer}] [value] app removed container\"", 55 | ">>", ".kamal/app-audit.log" 56 | ], @auditor.record("app removed container", detail: "value") 57 | end 58 | 59 | 60 | private 61 | def new_command(destination: nil, **details) 62 | Kamal::Commands::Auditor.new(Kamal::Configuration.new(@config, destination: destination, version: "123"), **details) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/configuration/boot_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ConfigurationBootTest < ActiveSupport::TestCase 4 | test "no group strategy" do 5 | deploy = { 6 | service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, builder: { "arch" => "amd64" }, 7 | servers: { "web" => [ "1.1.1.1", "1.1.1.2" ], "workers" => [ "1.1.1.3", "1.1.1.4" ] } 8 | } 9 | 10 | config = Kamal::Configuration.new(deploy) 11 | 12 | assert_nil config.boot.limit 13 | assert_nil config.boot.wait 14 | end 15 | 16 | test "specific limit group strategy" do 17 | deploy = { 18 | service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, builder: { "arch" => "amd64" }, 19 | servers: { "web" => [ "1.1.1.1", "1.1.1.2" ], "workers" => [ "1.1.1.3", "1.1.1.4" ] }, 20 | boot: { "limit" => 3, "wait" => 2 } 21 | } 22 | 23 | config = Kamal::Configuration.new(deploy) 24 | 25 | assert_equal 3, config.boot.limit 26 | assert_equal 2, config.boot.wait 27 | end 28 | 29 | test "percentage-based group strategy" do 30 | deploy = { 31 | service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, builder: { "arch" => "amd64" }, 32 | servers: { "web" => [ "1.1.1.1", "1.1.1.2" ], "workers" => [ "1.1.1.3", "1.1.1.4" ] }, 33 | boot: { "limit" => "50%", "wait" => 2 } 34 | } 35 | 36 | config = Kamal::Configuration.new(deploy) 37 | 38 | assert_equal 2, config.boot.limit 39 | assert_equal 2, config.boot.wait 40 | end 41 | 42 | test "percentage-based group strategy limit is at least 1" do 43 | deploy = { 44 | service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, builder: { "arch" => "amd64" }, 45 | servers: { "web" => [ "1.1.1.1", "1.1.1.2" ], "workers" => [ "1.1.1.3", "1.1.1.4" ] }, 46 | boot: { "limit" => "1%", "wait" => 2 } 47 | } 48 | 49 | config = Kamal::Configuration.new(deploy) 50 | 51 | assert_equal 1, config.boot.limit 52 | assert_equal 2, config.boot.wait 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /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 | test "aliased secrets" do 52 | with_test_secrets("secrets" => "ALIASED_PASSWORD=hello") do 53 | config = { 54 | "secret" => [ "PASSWORD:ALIASED_PASSWORD" ], 55 | "clear" => {} 56 | } 57 | 58 | assert_config \ 59 | config: config, 60 | clear: {}, 61 | secrets: { "PASSWORD" => "hello" } 62 | end 63 | end 64 | 65 | private 66 | def assert_config(config:, clear: {}, secrets: {}) 67 | env = Kamal::Configuration::Env.new config: config, secrets: Kamal::Secrets.new 68 | expected_clear_args = clear.to_a.flat_map { |key, value| [ "--env", "#{key}=\"#{value}\"" ] } 69 | assert_equal expected_clear_args, env.clear_args.map(&:to_s) #  to_s removes the redactions 70 | expected_secrets = secrets.to_a.flat_map { |key, value| "#{key}=#{value}" }.join("\n") + "\n" 71 | assert_equal expected_secrets, env.secrets_io.string 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /test/configuration/proxy/boot_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ConfigurationProxyBootTest < ActiveSupport::TestCase 4 | setup do 5 | ENV["RAILS_MASTER_KEY"] = "456" 6 | ENV["VERSION"] = "missing" 7 | 8 | @deploy = { 9 | service: "app", image: "dhh/app", 10 | registry: { "username" => "dhh", "password" => "secret" }, 11 | builder: { "arch" => "amd64" }, 12 | env: { "REDIS_URL" => "redis://x/y" }, 13 | servers: [ "1.1.1.1", "1.1.1.2" ], 14 | volumes: [ "/local/path:/container/path" ] 15 | } 16 | 17 | @config = Kamal::Configuration.new(@deploy) 18 | @proxy_boot_config = @config.proxy_boot 19 | end 20 | 21 | test "proxy directories" do 22 | assert_equal ".kamal/proxy/apps-config", @proxy_boot_config.apps_directory 23 | assert_equal "/home/kamal-proxy/.apps-config", @proxy_boot_config.apps_container_directory 24 | assert_equal ".kamal/proxy/apps-config/app", @proxy_boot_config.app_directory 25 | assert_equal "/home/kamal-proxy/.apps-config/app", @proxy_boot_config.app_container_directory 26 | assert_equal ".kamal/proxy/apps-config/app/error_pages", @proxy_boot_config.error_pages_directory 27 | assert_equal "/home/kamal-proxy/.apps-config/app/error_pages", @proxy_boot_config.error_pages_container_directory 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /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 | test "false not allowed" do 42 | @deploy[:proxy] = false 43 | assert_raises(Kamal::ConfigurationError, "proxy: should be a hash") do 44 | config.proxy 45 | end 46 | end 47 | 48 | private 49 | def config 50 | Kamal::Configuration.new(@deploy) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/fixtures/deploy.elsewhere.yml: -------------------------------------------------------------------------------- 1 | service: app3 2 | image: dhh/app3 3 | servers: 4 | - "1.1.1.3" 5 | - "1.1.1.4" 6 | registry: 7 | username: user 8 | password: pw 9 | builder: 10 | arch: amd64 11 | aliases: 12 | other_config: config -c config/deploy2.yml 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/fixtures/deploy.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 | aliases: 12 | other_config: config -c config/deploy2.yml 13 | other_destination_config: config -d elsewhere 14 | -------------------------------------------------------------------------------- /test/fixtures/deploy2.yml: -------------------------------------------------------------------------------- 1 | service: app2 2 | image: dhh/app2 3 | servers: 4 | - "1.1.1.1" 5 | - "1.1.1.2" 6 | registry: 7 | username: user2 8 | password: pw2 9 | builder: 10 | arch: amd64 11 | aliases: 12 | other_config: config -c config/deploy2.yml 13 | -------------------------------------------------------------------------------- /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/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_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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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_accessories_with_different_registries.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 | server: private.registry 12 | username: user 13 | password: pw 14 | builder: 15 | arch: amd64 16 | 17 | accessories: 18 | mysql: 19 | image: private.registry/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 | busybox: 39 | service: custom-box 40 | image: busybox:latest 41 | host: 1.1.1.3 42 | registry: 43 | server: other.registry 44 | username: other_user 45 | password: other_pw 46 | 47 | readiness_delay: 0 48 | -------------------------------------------------------------------------------- /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 | primary_details: details -p 25 | deploy_secondary: deploy -d secondary 26 | 27 | -------------------------------------------------------------------------------- /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_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/fixtures/deploy_with_cloud_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 | driver: cloud example_org/cloud_builder 41 | -------------------------------------------------------------------------------- /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/fixtures/deploy_with_error_pages.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 | error_pages_path: public 12 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/fixtures/deploy_with_only_workers.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_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_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.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/fixtures/deploy_with_roles_workers_primary.yml: -------------------------------------------------------------------------------- 1 | service: app 2 | image: dhh/app 3 | servers: 4 | workers: 5 | - 1.1.1.1 6 | - 1.1.1.2 7 | web: 8 | - 1.1.1.3 9 | - 1.1.1.4 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 | deploy_timeout: 1 19 | primary_role: workers 20 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/fixtures/files/my.cnf: -------------------------------------------------------------------------------- 1 | # MySQL Config 2 | -------------------------------------------------------------------------------- /test/fixtures/files/structure.sql.erb: -------------------------------------------------------------------------------- 1 | <%= "This was dynamically expanded" %> 2 | <%= ENV["MYSQL_ROOT_HOST"] %> 3 | -------------------------------------------------------------------------------- /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/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 | boot = kamal :accessory, :boot, :busybox, capture: true 21 | assert_match /Skipping booting `busybox` on vm1, vm2, a container already exists/, boot 22 | 23 | kamal :accessory, :remove, :busybox, "-y" 24 | assert_accessory_not_running :busybox 25 | end 26 | 27 | test "proxied: boot, stop, start, restart, logs, remove" do 28 | @app = "app_with_proxied_accessory" 29 | 30 | kamal :proxy, :boot 31 | 32 | kamal :accessory, :boot, :netcat 33 | assert_accessory_running :netcat 34 | assert_netcat_is_up 35 | 36 | kamal :accessory, :stop, :netcat 37 | assert_accessory_not_running :netcat 38 | assert_netcat_not_found 39 | 40 | kamal :accessory, :start, :netcat 41 | assert_accessory_running :netcat 42 | assert_netcat_is_up 43 | 44 | kamal :accessory, :restart, :netcat 45 | assert_accessory_running :netcat 46 | assert_netcat_is_up 47 | 48 | kamal :accessory, :remove, :netcat, "-y" 49 | assert_accessory_not_running :netcat 50 | assert_netcat_not_found 51 | end 52 | 53 | private 54 | def assert_accessory_running(name) 55 | assert_match /registry:4443\/busybox:1.36.0 "sh -c 'echo \\"Start/, accessory_details(name) 56 | end 57 | 58 | def assert_accessory_not_running(name) 59 | assert_no_match /registry:4443\/busybox:1.36.0 "sh -c 'echo \\"Start/, accessory_details(name) 60 | end 61 | 62 | def accessory_details(name) 63 | kamal :accessory, :details, name, capture: true 64 | end 65 | 66 | def assert_netcat_is_up 67 | response = netcat_response 68 | debug_response_code(response, "200") 69 | assert_equal "200", response.code 70 | end 71 | 72 | def assert_netcat_not_found 73 | response = netcat_response 74 | debug_response_code(response, "404") 75 | assert_equal "404", response.code 76 | end 77 | 78 | def netcat_response 79 | uri = URI.parse("http://127.0.0.1:12345/up") 80 | http = Net::HTTP.new(uri.host, uri.port) 81 | request = Net::HTTP::Get.new(uri) 82 | request["Host"] = "netcat" 83 | 84 | http.request(request) 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /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 | output = kamal :app, :boot, "--verbose", capture: true 19 | assert_match "Booting app on vm1,vm2...", output 20 | assert_match "Booted app on vm1,vm2...", output 21 | 22 | wait_for_app_to_be_up 23 | 24 | logs = kamal :app, :logs, capture: true 25 | assert_match "App Host: vm1", logs 26 | assert_match "App Host: vm2", logs 27 | assert_match "GET /version HTTP/1.1", logs 28 | 29 | images = kamal :app, :images, capture: true 30 | assert_match "App Host: vm1", images 31 | assert_match "App Host: vm2", images 32 | assert_match /registry:4443\/app\s+#{latest_app_version}/, images 33 | assert_match /registry:4443\/app\s+latest/, images 34 | 35 | containers = kamal :app, :containers, capture: true 36 | assert_match "App Host: vm1", containers 37 | assert_match "App Host: vm2", containers 38 | assert_match "registry:4443/app:#{latest_app_version}", containers 39 | assert_match "registry:4443/app:latest", containers 40 | 41 | exec_output = kamal :app, :exec, :ps, capture: true 42 | assert_match "App Host: vm1", exec_output 43 | assert_match "App Host: vm2", exec_output 44 | assert_match /1 root 0:\d\d ps/, exec_output 45 | 46 | exec_output = kamal :app, :exec, "--reuse", :ps, capture: true 47 | assert_match "App Host: vm2", exec_output 48 | assert_match "App Host: vm1", exec_output 49 | assert_match /1 root 0:\d\d nginx/, exec_output 50 | 51 | kamal :app, :maintenance 52 | assert_app_in_maintenance 53 | 54 | kamal :app, :live 55 | assert_app_is_up 56 | 57 | kamal :app, :remove 58 | 59 | assert_app_not_found 60 | assert_app_directory_removed 61 | end 62 | 63 | test "custom error pages" do 64 | @app = "app_with_roles" 65 | 66 | kamal :deploy 67 | assert_app_is_up 68 | 69 | kamal :app, :maintenance 70 | assert_app_in_maintenance message: "Custom Maintenance Page" 71 | 72 | kamal :app, :live 73 | kamal :app, :maintenance, "--message", "\"Testing Maintence Mode\"" 74 | assert_app_in_maintenance message: "Custom Maintenance Page: Testing Maintence Mode" 75 | 76 | second_version = update_app_rev 77 | 78 | kamal :redeploy 79 | 80 | kamal :app, :maintenance 81 | assert_app_in_maintenance message: "Custom Maintenance Page" 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/.kamal/hooks/post-app-boot: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo "Booted app on ${KAMAL_HOSTS}..." 3 | mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/post-app-boot 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/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-app-boot: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo "Booting app on ${KAMAL_HOSTS}..." 3 | mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-app-boot 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 | env 5 | mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-connect 6 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app/.kamal/hooks/pre-deploy: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | kamal proxy boot_config set --registry registry:4443 5 | echo "Deployed!" 6 | mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-deploy 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app/.kamal/secrets-common: -------------------------------------------------------------------------------- 1 | SECRET_TOKEN='1234 with "中文"' 2 | SECRET_TAG='TAGME' 3 | -------------------------------------------------------------------------------- /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/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 | busybox2: 45 | service: custom-busybox 46 | image: registry:4443/busybox:1.36.0 47 | cmd: sh -c 'echo "Starting busybox..."; trap exit term; while true; do sleep 1; done' 48 | host: vm3 49 | -------------------------------------------------------------------------------- /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/integration/docker/deployer/app_with_proxied_accessory/.kamal/hooks/pre-deploy: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | kamal proxy boot_config set --registry registry:4443 5 | -------------------------------------------------------------------------------- /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/config/deploy.yml: -------------------------------------------------------------------------------- 1 | service: app_with_proxied_accessory 2 | image: app_with_proxied_accessory 3 | env: 4 | clear: 5 | CLEAR_TOKEN: 4321 6 | CLEAR_TAG: "" 7 | HOST_TOKEN: "${HOST_TOKEN}" 8 | asset_path: /usr/share/nginx/html/versions 9 | proxy: 10 | host: 127.0.0.1 11 | registry: 12 | server: registry:4443 13 | username: root 14 | password: root 15 | builder: 16 | driver: docker 17 | arch: <%= Kamal::Utils.docker_arch %> 18 | args: 19 | COMMIT_SHA: <%= `git rev-parse HEAD` %> 20 | accessories: 21 | busybox: 22 | service: custom-busybox 23 | image: registry:4443/busybox:1.36.0 24 | cmd: sh -c 'echo "Starting busybox..."; trap exit term; while true; do sleep 1; done' 25 | host: vm1 26 | netcat: 27 | service: netcat 28 | image: registry:4443/busybox:1.36.0 29 | cmd: > 30 | 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' 31 | host: vm1 32 | port: 12345:80 33 | proxy: 34 | host: netcat 35 | ssl: false 36 | healthcheck: 37 | interval: 1 38 | timeout: 1 39 | path: "/" 40 | drain_timeout: 2 41 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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-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/deployer/app_with_roles/.kamal/hooks/pre-deploy: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | kamal proxy boot_config set --registry registry:4443 5 | echo "Deployed!" 6 | mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-deploy 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app_with_roles/.kamal/secrets: -------------------------------------------------------------------------------- 1 | SECRET_TOKEN='1234 with "中文"' 2 | -------------------------------------------------------------------------------- /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/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 | error_pages_path: error_pages 41 | 42 | registry: 43 | server: registry:4443 44 | username: root 45 | password: root 46 | builder: 47 | driver: docker 48 | arch: <%= Kamal::Utils.docker_arch %> 49 | args: 50 | COMMIT_SHA: <%= `git rev-parse HEAD` %> 51 | accessories: 52 | busybox: 53 | service: custom-busybox 54 | image: registry:4443/busybox:1.36.0 55 | cmd: sh -c 'echo "Starting busybox..."; trap exit term; while true; do sleep 1; done' 56 | roles: 57 | - web 58 | aliases: 59 | whome: version 60 | worker_hostname: app exec -r workers -q --reuse hostname 61 | uname: server exec -q -p uname 62 | -------------------------------------------------------------------------------- /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_roles/error_pages/503.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 503 Service Interrupted 4 | 5 | 6 |

Custom Maintenance Page: {{ .Message }}

7 | 8 | 9 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app_with_traefik/.kamal/hooks/pre-deploy: -------------------------------------------------------------------------------- 1 | set -e 2 | 3 | kamal proxy boot_config set --registry registry:4443 \ 4 | --publish false \ 5 | --docker_options label=traefik.http.services.kamal_proxy.loadbalancer.server.scheme=http \ 6 | label=traefik.http.routers.kamal_proxy.rule=PathPrefix\(\`/\`\) \ 7 | sysctl=net.ipv4.ip_local_port_range=\"10000\ 60999\" 8 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/app_with_traefik/.kamal/secrets: -------------------------------------------------------------------------------- 1 | SECRET_TOKEN='1234 with "中文"' 2 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/boot.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | dockerd --max-concurrent-downloads 1 & 4 | 5 | exec sleep infinity 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/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 | push_image_to_registry_4443 basecamp/kamal-proxy v0.9.0 24 | 25 | # .ssh is on a shared volume that persists between runs. Clean it up as the 26 | # churn of temporary vm IPs can eventually create conflicts. 27 | rm -f /root/.ssh/known_hosts 28 | -------------------------------------------------------------------------------- /test/integration/docker/deployer/update_app_rev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd $1 && git commit -am 'Update rev' --amend 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/integration/docker/registry/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM registry:3 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/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/distribution/config.yml 6 | -------------------------------------------------------------------------------- /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/integration/docker/shared/boot.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cp -r * /shared 4 | 5 | exec sleep infinity 6 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_config, :set, "--registry", "registry:4443" 10 | 11 | kamal :proxy, :boot 12 | assert_proxy_running 13 | 14 | output = kamal :proxy, :reboot, "-y", "--verbose", capture: true 15 | assert_proxy_running 16 | assert_hooks_ran "pre-proxy-reboot", "post-proxy-reboot" 17 | assert_match /Rebooting kamal-proxy on vm1,vm2.../, output 18 | assert_match /Rebooted kamal-proxy on vm1,vm2/, output 19 | 20 | output = kamal :proxy, :reboot, "--rolling", "-y", "--verbose", capture: true 21 | assert_proxy_running 22 | assert_hooks_ran "pre-proxy-reboot", "post-proxy-reboot" 23 | assert_match /Rebooting kamal-proxy on vm1.../, output 24 | assert_match /Rebooted kamal-proxy on vm1/, output 25 | assert_match /Rebooting kamal-proxy on vm2.../, output 26 | assert_match /Rebooted kamal-proxy on vm2/, output 27 | 28 | kamal :proxy, :boot 29 | assert_proxy_running 30 | 31 | # Check booting when booted doesn't raise an error 32 | kamal :proxy, :stop 33 | assert_proxy_not_running 34 | 35 | # Check booting when stopped works 36 | kamal :proxy, :boot 37 | assert_proxy_running 38 | 39 | kamal :proxy, :stop 40 | assert_proxy_not_running 41 | 42 | kamal :proxy, :start 43 | assert_proxy_running 44 | 45 | kamal :proxy, :restart 46 | assert_proxy_running 47 | 48 | logs = kamal :proxy, :logs, capture: true 49 | assert_match /No previous state to restore/, logs 50 | 51 | kamal :proxy, :boot_config, :set, "--registry", "registry:4443", "--docker-options='sysctl net.ipv4.ip_local_port_range=\"10000 60999\"'" 52 | assert_docker_options_in_file 53 | 54 | kamal :proxy, :reboot, "-y" 55 | assert_docker_options_in_container 56 | 57 | kamal :proxy, :boot_config, :reset 58 | 59 | kamal :proxy, :remove 60 | assert_proxy_not_running 61 | end 62 | 63 | private 64 | def assert_docker_options_in_file 65 | boot_config = kamal :proxy, :boot_config, :get, capture: true 66 | assert_match "Host vm1: --publish 80:80 --publish 443:443 --log-opt max-size=10m --sysctl net.ipv4.ip_local_port_range=\"10000 60999\"", boot_config 67 | end 68 | 69 | def assert_docker_options_in_container 70 | assert_equal \ 71 | "{\"net.ipv4.ip_local_port_range\":\"10000 60999\"}", 72 | docker_compose("exec vm1 docker inspect --format '{{ json .HostConfig.Sysctls }}' kamal-proxy", capture: true).strip 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/secrets/enpass_adapter_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class EnpassAdapterTest < SecretAdapterTestCase 4 | test "fetch without CLI installed" do 5 | stub_ticks_with("enpass-cli version 2> /dev/null", succeed: false) 6 | 7 | error = assert_raises RuntimeError do 8 | JSON.parse(shellunescape(run_command("fetch", "mynote"))) 9 | end 10 | 11 | assert_equal "Enpass CLI is not installed", error.message 12 | end 13 | 14 | test "fetch one item" do 15 | stub_ticks_with("enpass-cli version 2> /dev/null") 16 | 17 | stub_ticks 18 | .with("enpass-cli -json -vault vault-path show FooBar") 19 | .returns(<<~JSON) 20 | [{"category":"computer","label":"SECRET_1","login":"","password":"my-password-1","title":"FooBar","type":"password"}] 21 | JSON 22 | 23 | json = JSON.parse(shellunescape(run_command("fetch", "FooBar/SECRET_1"))) 24 | 25 | expected_json = { "FooBar/SECRET_1" => "my-password-1" } 26 | 27 | assert_equal expected_json, json 28 | end 29 | 30 | test "fetch multiple items" do 31 | stub_ticks_with("enpass-cli version 2> /dev/null") 32 | 33 | stub_ticks 34 | .with("enpass-cli -json -vault vault-path show FooBar") 35 | .returns(<<~JSON) 36 | [ 37 | {"category":"computer","label":"SECRET_1","login":"","password":"my-password-1","title":"FooBar","type":"password"}, 38 | {"category":"computer","label":"SECRET_2","login":"","password":"my-password-2","title":"FooBar","type":"password"}, 39 | {"category":"computer","label":"SECRET_3","login":"","password":"my-password-1","title":"Hello","type":"password"} 40 | ] 41 | JSON 42 | 43 | json = JSON.parse(shellunescape(run_command("fetch", "FooBar/SECRET_1", "FooBar/SECRET_2"))) 44 | 45 | expected_json = { "FooBar/SECRET_1" => "my-password-1", "FooBar/SECRET_2" => "my-password-2" } 46 | 47 | assert_equal expected_json, json 48 | end 49 | 50 | test "fetch all with from" do 51 | stub_ticks_with("enpass-cli version 2> /dev/null") 52 | 53 | stub_ticks 54 | .with("enpass-cli -json -vault vault-path show FooBar") 55 | .returns(<<~JSON) 56 | [ 57 | {"category":"computer","label":"SECRET_1","login":"","password":"my-password-1","title":"FooBar","type":"password"}, 58 | {"category":"computer","label":"SECRET_2","login":"","password":"my-password-2","title":"FooBar","type":"password"}, 59 | {"category":"computer","label":"SECRET_3","login":"","password":"my-password-1","title":"Hello","type":"password"}, 60 | {"category":"computer","label":"","login":"","password":"my-password-3","title":"FooBar","type":"password"} 61 | ] 62 | JSON 63 | 64 | json = JSON.parse(shellunescape(run_command("fetch", "FooBar"))) 65 | 66 | expected_json = { "FooBar/SECRET_1" => "my-password-1", "FooBar/SECRET_2" => "my-password-2", "FooBar" => "my-password-3" } 67 | 68 | assert_equal expected_json, json 69 | end 70 | 71 | private 72 | def run_command(*command) 73 | stdouted do 74 | Kamal::Cli::Secrets.start \ 75 | [ *command, 76 | "-c", "test/fixtures/deploy_with_accessories.yml", 77 | "--adapter", "enpass", 78 | "--from", "vault-path" ] 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /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 "env references" do 24 | with_test_secrets("secrets" => "SECRET1=$SECRET1") do 25 | ENV["SECRET1"] = "ABC" 26 | assert_equal "ABC", Kamal::Secrets.new["SECRET1"] 27 | end 28 | end 29 | 30 | test "secrets file value overrides env" do 31 | with_test_secrets("secrets" => "SECRET1=DEF") do 32 | ENV["SECRET1"] = "ABC" 33 | assert_equal "DEF", Kamal::Secrets.new["SECRET1"] 34 | end 35 | end 36 | 37 | test "destinations" do 38 | with_test_secrets("secrets.dest" => "SECRET=DEF", "secrets" => "SECRET=ABC", "secrets-common" => "SECRET=GHI\nSECRET2=JKL") do 39 | assert_equal "ABC", Kamal::Secrets.new["SECRET"] 40 | assert_equal "DEF", Kamal::Secrets.new(destination: "dest")["SECRET"] 41 | assert_equal "GHI", Kamal::Secrets.new(destination: "nodest")["SECRET"] 42 | 43 | assert_equal "JKL", Kamal::Secrets.new["SECRET2"] 44 | assert_equal "JKL", Kamal::Secrets.new(destination: "dest")["SECRET2"] 45 | assert_equal "JKL", Kamal::Secrets.new(destination: "nodest")["SECRET2"] 46 | end 47 | end 48 | 49 | test "no secrets files" do 50 | with_test_secrets do 51 | error = assert_raises(Kamal::ConfigurationError) do 52 | Kamal::Secrets.new["SECRET"] 53 | end 54 | assert_equal "Secret 'SECRET' not found, no secret files (.kamal/secrets-common, .kamal/secrets) provided", error.message 55 | 56 | error = assert_raises(Kamal::ConfigurationError) do 57 | Kamal::Secrets.new(destination: "dest")["SECRET"] 58 | end 59 | assert_equal "Secret 'SECRET' not found, no secret files (.kamal/secrets-common, .kamal/secrets.dest) provided", error.message 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------