Repository: basecamp/kamal Branch: main Commit: 453d8d7dc2b4 Files: 313 Total size: 739.5 KB Directory structure: gitextract_hqeups_8/ ├── .github/ │ └── workflows/ │ ├── ci.yml │ └── docker-publish.yml ├── .gitignore ├── .rubocop.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── Gemfile ├── MIT-LICENSE ├── README.md ├── bin/ │ ├── docs │ ├── kamal │ ├── release │ └── test ├── gemfiles/ │ └── rails_edge.gemfile ├── kamal.gemspec ├── lib/ │ ├── kamal/ │ │ ├── cli/ │ │ │ ├── accessory.rb │ │ │ ├── alias/ │ │ │ │ └── command.rb │ │ │ ├── app/ │ │ │ │ ├── assets.rb │ │ │ │ ├── boot.rb │ │ │ │ ├── error_pages.rb │ │ │ │ └── ssl_certificates.rb │ │ │ ├── app.rb │ │ │ ├── base.rb │ │ │ ├── build/ │ │ │ │ ├── clone.rb │ │ │ │ └── port_forwarding.rb │ │ │ ├── build.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 │ │ ├── cli.rb │ │ ├── commander/ │ │ │ └── specifics.rb │ │ ├── commander.rb │ │ ├── commands/ │ │ │ ├── accessory/ │ │ │ │ └── proxy.rb │ │ │ ├── accessory.rb │ │ │ ├── app/ │ │ │ │ ├── assets.rb │ │ │ │ ├── containers.rb │ │ │ │ ├── error_pages.rb │ │ │ │ ├── execution.rb │ │ │ │ ├── images.rb │ │ │ │ ├── logging.rb │ │ │ │ └── proxy.rb │ │ │ ├── app.rb │ │ │ ├── auditor.rb │ │ │ ├── base.rb │ │ │ ├── builder/ │ │ │ │ ├── base.rb │ │ │ │ ├── clone.rb │ │ │ │ ├── cloud.rb │ │ │ │ ├── hybrid.rb │ │ │ │ ├── local.rb │ │ │ │ ├── pack.rb │ │ │ │ └── remote.rb │ │ │ ├── builder.rb │ │ │ ├── docker.rb │ │ │ ├── hook.rb │ │ │ ├── lock.rb │ │ │ ├── proxy.rb │ │ │ ├── prune.rb │ │ │ ├── registry.rb │ │ │ └── server.rb │ │ ├── commands.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/ │ │ │ │ └── tag.rb │ │ │ ├── env.rb │ │ │ ├── logging.rb │ │ │ ├── proxy/ │ │ │ │ ├── boot.rb │ │ │ │ └── run.rb │ │ │ ├── proxy.rb │ │ │ ├── registry.rb │ │ │ ├── role.rb │ │ │ ├── servers.rb │ │ │ ├── ssh.rb │ │ │ ├── sshkit.rb │ │ │ ├── validation.rb │ │ │ ├── validator/ │ │ │ │ ├── accessory.rb │ │ │ │ ├── alias.rb │ │ │ │ ├── builder.rb │ │ │ │ ├── configuration.rb │ │ │ │ ├── env.rb │ │ │ │ ├── proxy.rb │ │ │ │ ├── registry.rb │ │ │ │ ├── role.rb │ │ │ │ └── servers.rb │ │ │ ├── validator.rb │ │ │ └── volume.rb │ │ ├── configuration.rb │ │ ├── docker.rb │ │ ├── env_file.rb │ │ ├── git.rb │ │ ├── secrets/ │ │ │ ├── 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 │ │ │ │ ├── passbolt.rb │ │ │ │ └── test.rb │ │ │ ├── adapters.rb │ │ │ └── dotenv/ │ │ │ └── inline_command_substitution.rb │ │ ├── secrets.rb │ │ ├── sshkit_with_ext.rb │ │ ├── tags.rb │ │ ├── utils/ │ │ │ └── sensitive.rb │ │ ├── utils.rb │ │ └── version.rb │ └── kamal.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_local_registry.yml │ ├── deploy_with_local_registry_and_accessories.yml │ ├── deploy_with_local_registry_and_remote_builder.yml │ ├── deploy_with_local_registry_and_remote_builder_with_port.yml │ ├── deploy_with_multiple_proxy_roles.yml │ ├── deploy_with_only_workers.yml │ ├── deploy_with_parallel_roles.yml │ ├── deploy_with_proxy.yml │ ├── deploy_with_proxy_roles.yml │ ├── deploy_with_proxy_run_config.yml │ ├── deploy_with_proxy_run_config_conflicts.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 │ ├── deploy_without_parallel_roles.yml │ └── files/ │ ├── my.cnf │ └── structure.sql.erb ├── git_test.rb ├── integration/ │ ├── accessory_test.rb │ ├── app_test.rb │ ├── broken_deploy_test.rb │ ├── docker/ │ │ ├── deployer/ │ │ │ ├── .dockerignore │ │ │ ├── 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/ │ │ │ │ │ ├── busybox.conf │ │ │ │ │ └── deploy.yml │ │ │ │ └── default.conf │ │ │ ├── app_with_custom_certificate/ │ │ │ │ ├── .kamal/ │ │ │ │ │ └── secrets │ │ │ │ ├── Dockerfile │ │ │ │ ├── certs/ │ │ │ │ │ ├── cert.pem │ │ │ │ │ └── key.pem │ │ │ │ ├── config/ │ │ │ │ │ └── deploy.yml │ │ │ │ └── default.conf │ │ │ ├── app_with_destinations/ │ │ │ │ ├── Dockerfile │ │ │ │ ├── config/ │ │ │ │ │ ├── deploy.production.yml │ │ │ │ │ ├── deploy.staging.yml │ │ │ │ │ └── deploy.yml │ │ │ │ └── default.conf │ │ │ ├── app_with_parallel_roles/ │ │ │ │ ├── Dockerfile │ │ │ │ ├── config/ │ │ │ │ │ └── deploy.yml │ │ │ │ ├── default.conf │ │ │ │ └── error_pages/ │ │ │ │ └── 503.html │ │ │ ├── app_with_proxied_accessory/ │ │ │ │ ├── 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/ │ │ │ │ │ └── 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/ │ │ │ ├── .dockerignore │ │ │ ├── Dockerfile │ │ │ ├── boot.sh │ │ │ └── registry-dns.conf │ │ └── vm/ │ │ ├── Dockerfile │ │ └── boot.sh │ ├── docker-compose.yml │ ├── 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 │ └── passbolt_adapter_test.rb ├── secrets_test.rb ├── sshkit_dns_retry_test.rb ├── test_helper.rb └── utils_test.rb ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: - main pull_request: workflow_dispatch: permissions: contents: read jobs: rubocop: name: RuboCop runs-on: ubuntu-latest env: BUNDLE_ONLY: rubocop steps: - name: Checkout code uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Ruby and install gems uses: ruby/setup-ruby@708024e6c902387ab41de36e1669e43b5ee7085e # v1.283.0 with: ruby-version: 3.3.0 bundler-cache: true - name: Run Rubocop run: bundle exec rubocop --parallel tests: strategy: fail-fast: false matrix: ruby-version: - "3.2" - "3.3" - "3.4" - "4.0" gemfile: - Gemfile - gemfiles/rails_edge.gemfile exclude: - ruby-version: "3.2" gemfile: gemfiles/rails_edge.gemfile name: ${{ format('Tests (Ruby {0})', matrix.ruby-version) }} runs-on: ubuntu-latest env: BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }} steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Remove gemfile.lock run: rm Gemfile.lock - name: Install Ruby uses: ruby/setup-ruby@708024e6c902387ab41de36e1669e43b5ee7085e # v1.283.0 with: ruby-version: ${{ matrix.ruby-version }} bundler-cache: true - name: Configure Docker with VFS storage driver run: | sudo systemctl stop docker sudo systemctl stop docker.socket sudo mkdir -p /etc/docker /mnt/docker cat <<'EOF' | sudo tee /etc/docker/daemon.json { "storage-driver": "vfs", "data-root": "/mnt/docker" } EOF sudo rm -rf /var/lib/docker/* /mnt/docker/* sudo systemctl start docker timeout 30 sh -c 'until docker info >/dev/null 2>&1; do sleep 1; done' df -h - name: Run tests run: bin/test env: RUBYOPT: ${{ startsWith(matrix.ruby-version, '3.4.') && '--enable=frozen-string-literal' || '' }} - name: Check disk usage if: always() run: | df -h sudo du -sh /mnt/docker ================================================ FILE: .github/workflows/docker-publish.yml ================================================ name: Docker on: workflow_dispatch: inputs: tagInput: description: 'Tag' required: true release: types: [created] tags: - 'v*' jobs: build-and-push-image: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Checkout uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up QEMU uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - name: Login to GitHub Container Registry uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Determine version tag id: version-tag run: | INPUT_VALUE="${{ github.event.inputs.tagInput }}" if [ -z "$INPUT_VALUE" ]; then INPUT_VALUE="${{ github.ref_name }}" fi echo "::set-output name=value::$INPUT_VALUE" - name: Build and push uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: . platforms: linux/amd64,linux/arm64 push: true tags: | ghcr.io/basecamp/kamal:latest ghcr.io/basecamp/kamal:${{ steps.version-tag.outputs.value }} ================================================ FILE: .gitignore ================================================ .byebug_history *.gem coverage/* .DS_Store gemfiles/*.lock ================================================ FILE: .rubocop.yml ================================================ inherit_gem: rubocop-rails-omakase: rubocop.yml ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Code of Conduct 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. 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. 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. ## Our standards Examples of behavior that contributes to creating a positive environment include: - Using welcoming and inclusive language - Being respectful of differing viewpoints and experiences - Gracefully accepting constructive criticism - Focusing on what is best for the community - Showing empathy towards other community members Examples of unacceptable behavior by participants include: - The use of sexualized language or imagery and unwelcome sexual attention or advances - Trolling, insulting/derogatory comments, and personal or political attacks - Public or private harassment - Publishing others' private information, such as a physical or electronic address, without explicit permission - Other conduct which could reasonably be considered inappropriate in a professional setting ## Responsibilities 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. 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. ## Reporting 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. 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. ## Attribution This Code of Conduct is adapted from the Contributor Covenant, version 1.4, available at . ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Kamal development Thank you for considering contributing to Kamal! This document outlines some guidelines for contributing to this open source project. Please make sure to review our [Code of Conduct](CODE_OF_CONDUCT.md) before contributing to Kamal. There are several ways you can contribute to the betterment of the project: - **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). - **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)! - **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! ## Issues 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. ## Pull Requests Please keep the following guidelines in mind when opening a pull request: - Ensure that your code passes the project's minitests by running ./bin/test. - Provide a clear and detailed description of your changes. - Keep your changes focused on a single concern. - Write clean and readable code that follows the project's code style. - Use descriptive variable and function names. - Write clear and concise commit messages. - Add tests for your changes, if possible. - Ensure that your changes don't break existing functionality. #### Commit message guidelines A good commit message should describe what changed and why. ## Development 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. 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. 1. Fork the project repository. 2. Create a new branch for your contribution. 3. Write your code or make the desired changes. 4. **Ensure that your code passes the project's minitests by running ./bin/test.** 5. Commit your changes and push them to your forked repository. 6. [Open a pull request](https://github.com/basecamp/kamal/pulls) to the main project repository with a detailed description of your changes. ## License Kamal is released under the MIT License. By contributing to this project, you agree to license your contributions under the same license. ================================================ FILE: Dockerfile ================================================ FROM ruby:3.4-alpine # Install docker/buildx-bin COPY --from=docker/buildx-bin /buildx /usr/libexec/docker/cli-plugins/docker-buildx # Set the working directory to /kamal WORKDIR /kamal # Copy the Gemfile, Gemfile.lock into the container COPY Gemfile Gemfile.lock kamal.gemspec ./ # Required in kamal.gemspec COPY lib/kamal/version.rb /kamal/lib/kamal/version.rb # Install system dependencies RUN apk add --no-cache build-base git docker-cli openssh-client-default yaml-dev \ && gem install bundler --version=2.6.5 \ && bundle install # Copy the rest of our application code into the container. # We do this after bundle install, to avoid having to run bundle # every time we do small fixes in the source code. COPY . . # Install the gem locally from the project folder RUN gem build kamal.gemspec && \ gem install ./kamal-*.gem --no-document # Set the working directory to /workdir WORKDIR /workdir # Tell git it's safe to access /workdir/.git even if # the directory is owned by a different user RUN git config --global --add safe.directory '*' # Set the entrypoint to run the installed binary in /workdir # Example: docker run -it -v "$PWD:/workdir" kamal init ENTRYPOINT ["kamal"] ================================================ FILE: Gemfile ================================================ source "https://rubygems.org" git_source(:github) { |repo| "https://github.com/#{repo}.git" } gemspec group :rubocop do gem "rubocop-rails-omakase", require: false end ================================================ FILE: MIT-LICENSE ================================================ Copyright (c) 2023 David Heinemeier Hansson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Kamal: Deploy web apps anywhere 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. ➡️ 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). ## Contributing to the documentation Please help us improve Kamal's documentation on [the basecamp/kamal-site repository](https://github.com/basecamp/kamal-site). ## License Kamal is released under the [MIT License](https://opensource.org/licenses/MIT). ================================================ FILE: bin/docs ================================================ #!/usr/bin/env ruby require "stringio" def usage puts "Usage: #{$0} " exit 1 end usage if ARGV.size != 1 kamal_site_repo = ARGV[0] if !File.directory?(kamal_site_repo) puts "Error: #{kamal_site_repo} is not a directory" exit 1 end DOCS = { "accessory" => "Accessories", "alias" => "Aliases", "boot" => "Booting", "builder" => "Builders", "configuration" => "Configuration overview", "env" => "Environment variables", "logging" => "Logging", "proxy" => "Proxy", "registry" => "Docker Registry", "role" => "Roles", "servers" => "Servers", "ssh" => "SSH", "sshkit" => "SSHKit" } DOCS_PATH = "lib/kamal/configuration/docs" class DocWriter attr_reader :from_file, :to_file, :key, :heading, :body, :output, :in_yaml def initialize(from_file, to_dir) @from_file = from_file @key = File.basename(from_file, ".yml") @to_file = File.join(to_dir, "#{linkify(DOCS[key])}.md") @body = File.readlines(from_file) @heading = body.shift.chomp("\n") @output = nil end def write puts "Writing #{to_file}" generate_markdown File.write(to_file, output.string) end private def generate_markdown @output = StringIO.new generate_header place = :in_section loop do line = body.shift&.chomp("\n") break if line.nil? case place when :new_section, :in_section if line.empty? output.puts place = :new_section elsif line =~ /^ *#/ generate_line(line, heading: place == :new_section) place = :in_section else output.puts output.puts "```yaml" output.puts line place = :in_yaml end when :in_yaml, :in_empty_line_yaml if line =~ /^ {0,4}#/ output.puts "```" output.puts generate_line(line, heading: place == :in_empty_line_yaml) place = :in_section elsif line.empty? place = :in_empty_line_yaml else output.puts line end end end output.puts "```" if place == :in_yaml end def generate_header output.puts "---" output.puts "# This file has been generated from the Kamal source, do not edit directly." output.puts "# Find the source of this file at #{DOCS_PATH}/#{key}.yml in the Kamal repository." output.puts "title: #{heading[2..-1]}" output.puts "---" output.puts output.puts heading end def generate_line(line, heading: false) line = line.gsub(/^ *#\s?/, "") if line =~ /(.*)kamal docs ([a-z]*)(.*)/ line = "#{$1}[#{DOCS[$2]}](../#{linkify(DOCS[$2])})#{$3}" end if line =~ /(.*)https:\/\/kamal-deploy.org([a-z\/-]*)(.*)/ line = "#{$1}[#{titlify($2.split("/").last)}](#{$2})#{$3}" end if heading output.puts "## [#{line}](##{linkify(line)})" else output.puts line end end def linkify(text) if text == "Configuration overview" "overview" else text.downcase.gsub(" ", "-") end end def titlify(text) text.capitalize.gsub("-", " ") end end from_dir = File.join(File.dirname(__FILE__), "../#{DOCS_PATH}") to_dir = File.join(kamal_site_repo, "docs/configuration") Dir.glob("#{from_dir}/*") do |from_file| DocWriter.new(from_file, to_dir).write end ================================================ FILE: bin/kamal ================================================ #!/usr/bin/env ruby # Prevent failures from being reported twice. Thread.report_on_exception = false require "kamal" begin Kamal::Cli::Main.start(ARGV) rescue SSHKit::Runner::ExecuteError => e puts " \e[31mERROR (#{e.cause.class}): #{e.message}\e[0m" puts e.cause.backtrace if ENV["VERBOSE"] exit 1 rescue => e puts " \e[31mERROR (#{e.class}): #{e.message}\e[0m" puts e.backtrace if ENV["VERBOSE"] exit 1 end ================================================ FILE: bin/release ================================================ #!/usr/bin/env bash VERSION=$1 printf "module Kamal\n VERSION = \"$VERSION\"\nend\n" > ./lib/kamal/version.rb bundle git add Gemfile.lock lib/kamal/version.rb git commit -m "Bump version for $VERSION" git push git tag v$VERSION git push --tags gem build kamal.gemspec gem push "kamal-$VERSION.gem" --host https://rubygems.org rm "kamal-$VERSION.gem" ================================================ FILE: bin/test ================================================ #!/usr/bin/env ruby $: << File.expand_path("../test", __dir__) require "bundler/setup" require "rails/plugin/test" ================================================ FILE: gemfiles/rails_edge.gemfile ================================================ source 'https://rubygems.org' git_source(:github) { |repo| "https://github.com/#{repo}.git" } git "https://github.com/rails/rails.git" do gem "railties" gem "activesupport" end gemspec path: "../" ================================================ FILE: kamal.gemspec ================================================ require_relative "lib/kamal/version" Gem::Specification.new do |spec| spec.name = "kamal" spec.version = Kamal::VERSION spec.authors = [ "David Heinemeier Hansson" ] spec.email = "dhh@hey.com" spec.homepage = "https://github.com/basecamp/kamal" spec.summary = "Deploy web apps in containers to servers running Docker with zero downtime." spec.license = "MIT" spec.files = Dir["lib/**/*", "MIT-LICENSE", "README.md"] spec.executables = %w[ kamal ] spec.add_dependency "activesupport", ">= 7.0" spec.add_dependency "sshkit", ">= 1.23.0", "< 2.0" spec.add_dependency "net-ssh", "~> 7.3" spec.add_dependency "thor", "~> 1.3" spec.add_dependency "dotenv", "~> 3.1" spec.add_dependency "zeitwerk", ">= 2.6.18", "< 3.0" spec.add_dependency "ed25519", "~> 1.4" spec.add_dependency "bcrypt_pbkdf", "~> 1.0" spec.add_dependency "concurrent-ruby", "~> 1.2" spec.add_dependency "base64", "~> 0.2" spec.add_development_dependency "debug" spec.add_development_dependency "minitest", "< 6" spec.add_development_dependency "mocha" spec.add_development_dependency "railties" end ================================================ FILE: lib/kamal/cli/accessory.rb ================================================ require "active_support/core_ext/array/conversions" require "concurrent/array" class Kamal::Cli::Accessory < Kamal::Cli::Base desc "boot [NAME]", "Boot new accessory service on host (use NAME=all to boot all accessories)" def boot(name, prepare: true) with_lock do if name == "all" KAMAL.accessory_names.each { |accessory_name| boot(accessory_name) } else prepare(name) if prepare with_accessory(name) do |accessory, hosts| booted_hosts = Concurrent::Array.new on(hosts) do |host| booted_hosts << host.to_s if capture_with_info(*accessory.info(all: true, quiet: true)).strip.presence end if booted_hosts.any? say "Skipping booting `#{name}` on #{booted_hosts.sort.join(", ")}, a container already exists", :yellow hosts -= booted_hosts end directories(name) upload(name) on(hosts) do |host| execute *KAMAL.auditor.record("Booted #{name} accessory"), verbosity: :debug execute *accessory.ensure_env_directory upload! accessory.secrets_io, accessory.secrets_path, mode: "0600" execute *accessory.run(host: host) if accessory.running_proxy? target = capture_with_info(*accessory.container_id_for(container_name: accessory.service_name, only_running: true)).strip execute *accessory.deploy(target: target) end end end end end end desc "upload [NAME]", "Upload accessory files to host", hide: true def upload(name) with_lock do with_accessory(name) do |accessory, hosts| on(hosts) do accessory.files.each do |(local, config)| remote = config[:host_path] accessory.ensure_local_file_present(local) execute *accessory.make_directory_for(remote) upload! local, remote execute :chmod, config[:mode], remote execute :chown, config[:owner], remote if config[:owner] end end end end end desc "directories [NAME]", "Create accessory directories on host", hide: true def directories(name) with_lock do with_accessory(name) do |accessory, hosts| on(hosts) do accessory.directories.each do |(local, config)| execute *accessory.make_directory(local) execute :chmod, config[:mode], local if config[:mode] execute :chown, config[:owner], local if config[:owner] end end end end end desc "reboot [NAME]", "Reboot existing accessory on host (stop container, remove container, start new container; use NAME=all to boot all accessories)" def reboot(name) with_lock do if name == "all" KAMAL.accessory_names.each { |accessory_name| reboot(accessory_name) } else prepare(name) pull_image(name) stop(name) remove_container(name) boot(name, prepare: false) end end end desc "start [NAME]", "Start existing accessory container on host" def start(name) with_lock do with_accessory(name) do |accessory, hosts| on(hosts) do execute *KAMAL.auditor.record("Started #{name} accessory"), verbosity: :debug execute *accessory.start if accessory.running_proxy? target = capture_with_info(*accessory.container_id_for(container_name: accessory.service_name, only_running: true)).strip execute *accessory.deploy(target: target) end end end end end desc "stop [NAME]", "Stop existing accessory container on host" def stop(name) with_lock do with_accessory(name) do |accessory, hosts| on(hosts) do execute *KAMAL.auditor.record("Stopped #{name} accessory"), verbosity: :debug execute *accessory.stop, raise_on_non_zero_exit: false if accessory.running_proxy? target = capture_with_info(*accessory.container_id_for(container_name: accessory.service_name, only_running: true)).strip execute *accessory.remove if target end end end end end desc "restart [NAME]", "Restart existing accessory container on host" def restart(name) with_lock do stop(name) start(name) end end desc "details [NAME]", "Show details about accessory on host (use NAME=all to show all accessories)" def details(name) quiet = options[:quiet] if name == "all" KAMAL.accessory_names.each { |accessory_name| details(accessory_name) } else type = "Accessory #{name}" with_accessory(name) do |accessory, hosts| on(hosts) { puts_by_host host, capture_with_info(*accessory.info), type: type, quiet: quiet } end end end desc "exec [NAME] [CMD...]", "Execute a custom command on servers within the accessory container (use --help to show options)" option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)" option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one" def exec(name, *cmd) pre_connect_if_required cmd = Kamal::Utils.join_commands(cmd) quiet = options[:quiet] with_accessory(name) do |accessory, hosts| case when options[:interactive] && options[:reuse] say "Launching interactive command via SSH from existing container...", :magenta run_locally { exec accessory.execute_in_existing_container_over_ssh(cmd) } when options[:interactive] say "Launching interactive command via SSH from new container...", :magenta on(accessory.hosts.first) { execute *KAMAL.registry.login } run_locally { exec accessory.execute_in_new_container_over_ssh(cmd) } when options[:reuse] say "Launching command from existing container...", :magenta on(hosts) do |host| execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on #{name} accessory"), verbosity: :debug puts_by_host host, capture_with_info(*accessory.execute_in_existing_container(cmd)), quiet: quiet end else say "Launching command from new container...", :magenta on(hosts) do |host| execute *KAMAL.registry.login execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on #{name} accessory"), verbosity: :debug puts_by_host host, capture_with_info(*accessory.execute_in_new_container(cmd)), quiet: quiet end end end end desc "logs [NAME]", "Show log lines from accessory on host (use --help to show options)" option :since, aliases: "-s", desc: "Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)" option :lines, type: :numeric, aliases: "-n", desc: "Number of log lines to pull from each server" option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)" option :grep_options, desc: "Additional options supplied to grep" option :follow, aliases: "-f", desc: "Follow logs on primary server (or specific host set by --hosts)" option :skip_timestamps, type: :boolean, aliases: "-T", desc: "Skip appending timestamps to logging output" def logs(name) with_accessory(name) do |accessory, hosts| grep = options[:grep] grep_options = options[:grep_options] timestamps = !options[:skip_timestamps] if options[:follow] run_locally do info "Following logs on #{hosts}..." info accessory.follow_logs(timestamps: timestamps, grep: grep, grep_options: grep_options) exec accessory.follow_logs(timestamps: timestamps, grep: grep, grep_options: grep_options) end else since = options[:since] lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set on(hosts) do puts capture_with_info(*accessory.logs(timestamps: timestamps, since: since, lines: lines, grep: grep, grep_options: grep_options)) end end end end desc "pull_image [NAME]", "Pull accessory image on host", hide: true def pull_image(name) with_lock do with_accessory(name) do |accessory, hosts| on(hosts) do execute *KAMAL.auditor.record("Pull #{name} accessory image"), verbosity: :debug execute *accessory.pull_image end end end end desc "remove [NAME]", "Remove accessory container, image and data directory from host (use NAME=all to remove all accessories)" option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question" def remove(name) confirming "This will remove all containers, images and data directories for #{name}. Are you sure?" do with_lock do if name == "all" KAMAL.accessory_names.each { |accessory_name| remove_accessory(accessory_name) } else remove_accessory(name) end end end end desc "remove_container [NAME]", "Remove accessory container from host", hide: true def remove_container(name) with_lock do with_accessory(name) do |accessory, hosts| on(hosts) do execute *KAMAL.auditor.record("Remove #{name} accessory container"), verbosity: :debug execute *accessory.remove_container end end end end desc "remove_image [NAME]", "Remove accessory image from host", hide: true def remove_image(name) with_lock do with_accessory(name) do |accessory, hosts| on(hosts) do execute *KAMAL.auditor.record("Removed #{name} accessory image"), verbosity: :debug execute *accessory.remove_image end end end end desc "remove_service_directory [NAME]", "Remove accessory directory used for uploaded files and data directories from host", hide: true def remove_service_directory(name) with_lock do with_accessory(name) do |accessory, hosts| on(hosts) do execute *accessory.remove_service_directory end end end end desc "upgrade", "Upgrade accessories from Kamal 1.x to 2.0 (restart them in 'kamal' network)" option :rolling, type: :boolean, default: false, desc: "Upgrade one host at a time" option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question" def upgrade(name) confirming "This will restart all accessories" do with_lock do host_groups = options[:rolling] ? KAMAL.accessory_hosts : [ KAMAL.accessory_hosts ] host_groups.each do |hosts| host_list = Array(hosts).join(",") KAMAL.with_specific_hosts(hosts) do say "Upgrading #{name} accessories on #{host_list}...", :magenta reboot name say "Upgraded #{name} accessories on #{host_list}...", :magenta end end end end end private def with_accessory(name) if KAMAL.config.accessory(name) accessory = KAMAL.accessory(name) yield accessory, accessory_hosts(accessory) else error_on_missing_accessory(name) end end def error_on_missing_accessory(name) options = KAMAL.accessory_names.presence error \ "No accessory by the name of '#{name}'" + (options ? " (options: #{options.to_sentence})" : "") end def accessory_hosts(accessory) KAMAL.accessory_hosts & accessory.hosts end def remove_accessory(name) stop(name) remove_container(name) remove_image(name) remove_service_directory(name) end def prepare(name) with_accessory(name) do |accessory, hosts| on(hosts) do execute *KAMAL.registry.login(registry_config: accessory.registry) execute *KAMAL.docker.create_network rescue SSHKit::Command::Failed => e raise unless e.message.include?("already exists") end end end end ================================================ FILE: lib/kamal/cli/alias/command.rb ================================================ class Kamal::Cli::Alias::Command < Thor::DynamicCommand def run(instance, args = []) if (command = KAMAL.resolve_alias(name)) KAMAL.reset Kamal::Cli::Main.start(Shellwords.split(command) + ARGV[1..-1]) else super end end end ================================================ FILE: lib/kamal/cli/app/assets.rb ================================================ class Kamal::Cli::App::Assets attr_reader :host, :role, :sshkit delegate :execute, :capture_with_info, :info, to: :sshkit delegate :assets?, to: :role def initialize(host, role, sshkit) @host = host @role = role @sshkit = sshkit end def run if assets? execute *app.extract_assets old_version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip execute *app.sync_asset_volumes(old_version: old_version) end end private def app @app ||= KAMAL.app(role: role, host: host) end end ================================================ FILE: lib/kamal/cli/app/boot.rb ================================================ class Kamal::Cli::App::Boot attr_reader :host, :role, :version, :barrier, :sshkit delegate :execute, :capture_with_info, :capture_with_pretty_json, :info, :error, :upload!, to: :sshkit delegate :assets?, :running_proxy?, to: :role def initialize(host, role, sshkit, version, barrier) @host = host @role = role @version = version @barrier = barrier @sshkit = sshkit end def run old_version = old_version_renamed_if_clashing wait_at_barrier if queuer? begin start_new_version rescue => e close_barrier if gatekeeper? stop_new_version raise end release_barrier if gatekeeper? if old_version stop_old_version(old_version) end end private def old_version_renamed_if_clashing if capture_with_info(*app.container_id_for_version(version), raise_on_non_zero_exit: false).present? renamed_version = "#{version}_replaced_#{SecureRandom.hex(8)}" info "Renaming container #{version} to #{renamed_version} as already deployed on #{host}" audit("Renaming container #{version} to #{renamed_version}") execute *app.rename_container(version: version, new_version: renamed_version) end capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip.presence end def start_new_version audit "Booted app version #{version}" hostname = "#{host.to_s[0...51].chomp(".")}-#{SecureRandom.hex(6)}" execute *app.ensure_env_directory upload! role.secrets_io(host), role.secrets_path, mode: "0600" execute *app.run(hostname: hostname) if running_proxy? endpoint = capture_with_info(*app.container_id_for_version(version)).strip raise Kamal::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoint.empty? execute *app.deploy(target: endpoint) else Kamal::Cli::Healthcheck::Poller.wait_for_healthy { capture_with_info(*app.status(version: version)) } end rescue => e error "Failed to boot #{role} on #{host}" raise e end def stop_new_version execute *app.stop(version: version), raise_on_non_zero_exit: false end def stop_old_version(version) execute *app.stop(version: version), raise_on_non_zero_exit: false execute *app.clean_up_assets if assets? execute *app.clean_up_error_pages if KAMAL.config.error_pages_path end def release_barrier if barrier.open info "First #{KAMAL.primary_role} container is healthy on #{host}, booting any other roles" end end def wait_at_barrier info "Waiting for the first healthy #{KAMAL.primary_role} container before booting #{role} on #{host}..." barrier.wait info "First #{KAMAL.primary_role} container is healthy, booting #{role} on #{host}..." rescue Kamal::Cli::Healthcheck::Error info "First #{KAMAL.primary_role} container is unhealthy, not booting #{role} on #{host}" raise end def close_barrier if barrier.close info "First #{KAMAL.primary_role} container is unhealthy on #{host}, not booting any other roles" begin error capture_with_info(*app.logs(container_id: app.container_id_for_version(version))) error capture_with_info(*app.container_health_log(version: version)) rescue SSHKit::Command::Failed error "Could not fetch logs for #{version}" end end end def barrier_role? role == KAMAL.primary_role end def app @app ||= KAMAL.app(role: role, host: host) end def auditor @auditor = KAMAL.auditor(role: role) end def audit(message) execute *auditor.record(message), verbosity: :debug end def gatekeeper? barrier && barrier_role? end def queuer? barrier && !barrier_role? end end ================================================ FILE: lib/kamal/cli/app/error_pages.rb ================================================ class Kamal::Cli::App::ErrorPages ERROR_PAGES_GLOB = "{4??.html,5??.html}" attr_reader :host, :sshkit delegate :upload!, :execute, to: :sshkit def initialize(host, sshkit) @host = host @sshkit = sshkit end def run if KAMAL.config.error_pages_path with_error_pages_tmpdir do |local_error_pages_dir| execute *KAMAL.app.create_error_pages_directory upload! local_error_pages_dir, KAMAL.config.proxy_boot.error_pages_directory, mode: "0700", recursive: true end end end private def with_error_pages_tmpdir Dir.mktmpdir("kamal-error-pages") do |tmpdir| error_pages_dir = File.join(tmpdir, KAMAL.config.version) FileUtils.mkdir(error_pages_dir) if (files = Dir[File.join(KAMAL.config.error_pages_path, ERROR_PAGES_GLOB)]).any? FileUtils.cp(files, error_pages_dir) yield error_pages_dir end end end end ================================================ FILE: lib/kamal/cli/app/ssl_certificates.rb ================================================ class Kamal::Cli::App::SslCertificates attr_reader :host, :role, :sshkit delegate :execute, :info, :upload!, to: :sshkit def initialize(host, role, sshkit) @host = host @role = role @sshkit = sshkit end def run if role.running_proxy? && role.proxy.custom_ssl_certificate? info "Writing SSL certificates for #{role.name} on #{host}" execute *app.create_ssl_directory if cert_content = role.proxy.certificate_pem_content upload!(StringIO.new(cert_content), role.proxy.host_tls_cert, mode: "0644") end if key_content = role.proxy.private_key_pem_content upload!(StringIO.new(key_content), role.proxy.host_tls_key, mode: "0644") end end end private def app @app ||= KAMAL.app(role: role, host: host) end end ================================================ FILE: lib/kamal/cli/app.rb ================================================ class Kamal::Cli::App < Kamal::Cli::Base desc "boot", "Boot app on servers (or reboot app if already running)" def boot with_lock do say "Get most recent version available as an image...", :magenta unless options[:version] using_version(version_or_latest) do |version| say "Start container with version #{version} (or reboot if already running)...", :magenta # Assets are prepared in a separate step to ensure they are on all hosts before booting on(KAMAL.app_hosts) do Kamal::Cli::App::ErrorPages.new(host, self).run KAMAL.roles_on(host).each do |role| Kamal::Cli::App::Assets.new(host, role, self).run Kamal::Cli::App::SslCertificates.new(host, role, self).run end end # Primary hosts and roles are returned first, so they can open the barrier barrier = Kamal::Cli::Healthcheck::Barrier.new host_boot_groups.each do |hosts| host_list = Array(hosts).join(",") run_hook "pre-app-boot", hosts: host_list on_roles(KAMAL.roles, hosts: hosts, parallel: KAMAL.config.boot.parallel_roles) do |host, role| Kamal::Cli::App::Boot.new(host, role, self, version, barrier).run end run_hook "post-app-boot", hosts: host_list sleep KAMAL.config.boot.wait if KAMAL.config.boot.wait end # Tag once the app booted on all hosts on(KAMAL.app_hosts) do |host| execute *KAMAL.auditor.record("Tagging #{KAMAL.config.absolute_image} as the latest image"), verbosity: :debug execute *KAMAL.app.tag_latest_image end end end end desc "start", "Start existing app container on servers" def start with_lock do on_roles(KAMAL.roles, hosts: KAMAL.app_hosts, parallel: KAMAL.config.boot.parallel_roles) do |host, role| app = KAMAL.app(role: role, host: host) execute *KAMAL.auditor.record("Started app version #{KAMAL.config.version}"), verbosity: :debug execute *app.start, raise_on_non_zero_exit: false if role.running_proxy? version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip endpoint = capture_with_info(*app.container_id_for_version(version)).strip raise Kamal::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoint.empty? execute *app.deploy(target: endpoint) end end end end desc "stop", "Stop app container on servers" def stop with_lock do on_roles(KAMAL.roles, hosts: KAMAL.app_hosts, parallel: KAMAL.config.boot.parallel_roles) do |host, role| app = KAMAL.app(role: role, host: host) execute *KAMAL.auditor.record("Stopped app", role: role), verbosity: :debug if role.running_proxy? version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip endpoint = capture_with_info(*app.container_id_for_version(version)).strip if endpoint.present? execute *app.remove, raise_on_non_zero_exit: false end end execute *app.stop, raise_on_non_zero_exit: false end end end # FIXME: Drop in favor of just containers? desc "details", "Show details about app containers" def details quiet = options[:quiet] on_roles(KAMAL.roles, hosts: KAMAL.app_hosts) do |host, role| puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).info), quiet: quiet end end desc "exec [CMD...]", "Execute a custom command on servers within the app container (use --help to show options)" option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)" option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one" option :env, aliases: "-e", type: :hash, desc: "Set environment variables for the command" option :detach, type: :boolean, default: false, desc: "Execute command in a detached container" def exec(*cmd) pre_connect_if_required if (incompatible_options = [ :interactive, :reuse ].select { |key| options[:detach] && options[key] }.presence) raise ArgumentError, "Detach is not compatible with #{incompatible_options.join(" or ")}" end if cmd.empty? raise ArgumentError, "No command provided. You must specify a command to execute." end cmd = Kamal::Utils.join_commands(cmd) env = options[:env] detach = options[:detach] quiet = options[:quiet] case when options[:interactive] && options[:reuse] say "Get current version of running container...", :magenta unless options[:version] using_version(options[:version] || current_running_version) do |version| say "Launching interactive command with version #{version} via SSH from existing container on #{KAMAL.primary_host}...", :magenta run_locally { exec KAMAL.app(role: KAMAL.primary_role, host: KAMAL.primary_host).execute_in_existing_container_over_ssh(cmd, env: env) } end when options[:interactive] say "Get most recent version available as an image...", :magenta unless options[:version] using_version(version_or_latest) do |version| say "Launching interactive command with version #{version} via SSH from new container on #{KAMAL.primary_host}...", :magenta on(KAMAL.primary_host) { execute *KAMAL.registry.login } run_locally do exec KAMAL.app(role: KAMAL.primary_role, host: KAMAL.primary_host).execute_in_new_container_over_ssh(cmd, env: env) end end when options[:reuse] say "Get current version of running container...", :magenta unless options[:version] using_version(options[:version] || current_running_version) do |version| say "Launching command with version #{version} from existing container...", :magenta on_roles(KAMAL.roles, hosts: KAMAL.app_hosts) do |host, role| execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on app version #{version}", role: role), verbosity: :debug puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).execute_in_existing_container(cmd, env: env)), quiet: quiet end end else say "Get most recent version available as an image...", :magenta unless options[:version] using_version(version_or_latest) do |version| say "Launching command with version #{version} from new container...", :magenta on(KAMAL.app_hosts) { execute *KAMAL.registry.login } on_roles(KAMAL.roles, hosts: KAMAL.app_hosts) do |host, role| execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on app version #{version}"), verbosity: :debug puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).execute_in_new_container(cmd, env: env, detach: detach)), quiet: quiet end end end end desc "containers", "Show app containers on servers" def containers quiet = options[:quiet] on(KAMAL.app_hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.app.list_containers), quiet: quiet } end desc "stale_containers", "Detect app stale containers" option :stop, aliases: "-s", type: :boolean, default: false, desc: "Stop the stale containers found" def stale_containers quiet = options[:quiet] stop = options[:stop] with_lock_if_stopping do on_roles(KAMAL.roles, hosts: KAMAL.app_hosts) do |host, role| app = KAMAL.app(role: role, host: host) versions = capture_with_info(*app.list_versions, raise_on_non_zero_exit: false).split("\n") versions -= [ capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip ] versions.each do |version| if stop puts_by_host host, "Stopping stale container for role #{role} with version #{version}", quiet: quiet execute *app.stop(version: version), raise_on_non_zero_exit: false else puts_by_host host, "Detected stale container for role #{role} with version #{version} (use `kamal app stale_containers --stop` to stop)", quiet: quiet end end end end end desc "images", "Show app images on servers" def images quiet = options[:quiet] on(KAMAL.app_hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.app.list_images), quiet: quiet } end desc "logs", "Show log lines from app on servers (use --help to show options)" option :since, aliases: "-s", desc: "Show lines since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)" option :lines, type: :numeric, aliases: "-n", desc: "Number of lines to show from each server" option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)" option :grep_options, desc: "Additional options supplied to grep" option :follow, aliases: "-f", desc: "Follow log on primary server (or specific host set by --hosts)" option :skip_timestamps, type: :boolean, aliases: "-T", desc: "Skip appending timestamps to logging output" option :container_id, desc: "Docker container ID to fetch logs" def logs # FIXME: Catch when app containers aren't running grep = options[:grep] grep_options = options[:grep_options] since = options[:since] container_id = options[:container_id] timestamps = !options[:skip_timestamps] quiet = options[:quiet] if options[:follow] lines = options[:lines].presence || ((since || grep) ? nil : 10) # Default to 10 lines if since or grep isn't set run_locally do info "Following logs on #{KAMAL.primary_host}..." KAMAL.specific_roles ||= [ KAMAL.primary_role.name ] role = KAMAL.roles_on(KAMAL.primary_host).first app = KAMAL.app(role: role, host: host) info app.follow_logs(host: KAMAL.primary_host, container_id: container_id, timestamps: timestamps, lines: lines, grep: grep, grep_options: grep_options) exec app.follow_logs(host: KAMAL.primary_host, container_id: container_id, timestamps: timestamps, lines: lines, grep: grep, grep_options: grep_options) end else lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set on_roles(KAMAL.roles, hosts: KAMAL.app_hosts) do |host, role| begin puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).logs(container_id: container_id, timestamps: timestamps, since: since, lines: lines, grep: grep, grep_options: grep_options)), quiet: quiet rescue SSHKit::Command::Failed puts_by_host host, "Nothing found", quiet: quiet end end end end desc "remove", "Remove app containers and images from servers" def remove with_lock do stop remove_containers remove_images remove_app_directories end end desc "live", "Set the app to live mode" def live with_lock do on_roles(KAMAL.roles, hosts: KAMAL.proxy_hosts) do |host, role| execute *KAMAL.app(role: role, host: host).live if role.running_proxy? end end end desc "maintenance", "Set the app to maintenance mode" option :drain_timeout, type: :numeric, desc: "How long to allow in-flight requests to complete (defaults to drain_timeout from config)" option :message, type: :string, desc: "Message to display to clients while stopped" def maintenance maintenance_options = { drain_timeout: options[:drain_timeout] || KAMAL.config.drain_timeout, message: options[:message] } with_lock do on_roles(KAMAL.roles, hosts: KAMAL.proxy_hosts) do |host, role| execute *KAMAL.app(role: role, host: host).maintenance(**maintenance_options) if role.running_proxy? end end end desc "remove_container [VERSION]", "Remove app container with given version from servers", hide: true def remove_container(version) with_lock do on_roles(KAMAL.roles, hosts: KAMAL.app_hosts) do |host, role| execute *KAMAL.auditor.record("Removed app container with version #{version}", role: role), verbosity: :debug execute *KAMAL.app(role: role, host: host).remove_container(version: version) end end end desc "remove_containers", "Remove all app containers from servers", hide: true def remove_containers with_lock do on_roles(KAMAL.roles, hosts: KAMAL.app_hosts) do |host, role| execute *KAMAL.auditor.record("Removed all app containers", role: role), verbosity: :debug execute *KAMAL.app(role: role, host: host).remove_containers end end end desc "remove_images", "Remove all app images from servers", hide: true def remove_images with_lock do on(hosts_removing_all_roles) do execute *KAMAL.auditor.record("Removed all app images"), verbosity: :debug execute *KAMAL.app.remove_images end end end desc "remove_app_directories", "Remove the app directories from servers", hide: true def remove_app_directories with_lock do on(hosts_removing_all_roles) do |host| execute *KAMAL.server.remove_app_directory, raise_on_non_zero_exit: false execute *KAMAL.auditor.record("Removed #{KAMAL.config.app_directory}"), verbosity: :debug execute *KAMAL.app.remove_proxy_app_directory, raise_on_non_zero_exit: false end end end desc "version", "Show app version currently running on servers" def version quiet = options[:quiet] on(KAMAL.app_hosts) do |host| role = KAMAL.roles_on(host).first puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).current_running_version).strip, quiet: quiet end end private def hosts_removing_all_roles KAMAL.app_hosts.select { |host| KAMAL.roles_on(host).map(&:name).sort == KAMAL.config.host_roles(host.to_s).map(&:name).sort } end def using_version(new_version) if new_version begin old_version = KAMAL.config.version KAMAL.config.version = new_version yield new_version ensure KAMAL.config.version = old_version end else yield KAMAL.config.version end end def current_running_version(host: KAMAL.primary_host) version = nil on(host) do role = KAMAL.roles_on(host).first version = capture_with_info(*KAMAL.app(role: role, host: host).current_running_version).strip end version.presence end def version_or_latest options[:version] || KAMAL.config.latest_tag end def with_lock_if_stopping if options[:stop] with_lock { yield } else yield end end def host_boot_groups KAMAL.config.boot.limit ? KAMAL.app_hosts.each_slice(KAMAL.config.boot.limit).to_a : [ KAMAL.app_hosts ] end end ================================================ FILE: lib/kamal/cli/base.rb ================================================ require "thor" require "kamal/sshkit_with_ext" module Kamal::Cli class Base < Thor include SSHKit::DSL VERBOSITY = { verbose: :debug, quiet: :error }.freeze def self.exit_on_failure?() true end def self.dynamic_command_class() Kamal::Cli::Alias::Command end class_option :verbose, type: :boolean, aliases: "-v", desc: "Detailed logging" class_option :quiet, type: :boolean, aliases: "-q", desc: "Minimal logging" class_option :version, desc: "Run commands against a specific app version" class_option :primary, type: :boolean, aliases: "-p", desc: "Run commands only on primary host instead of all" class_option :hosts, aliases: "-h", desc: "Run commands on these hosts instead of all (separate by comma, supports wildcards with *)" class_option :roles, aliases: "-r", desc: "Run commands on these roles instead of all (separate by comma, supports wildcards with *)" class_option :config_file, aliases: "-c", default: "config/deploy.yml", desc: "Path to config file" class_option :destination, aliases: "-d", desc: "Specify destination to be used for config file (staging -> deploy.staging.yml)" class_option :skip_hooks, aliases: "-H", type: :boolean, default: false, desc: "Don't run hooks" def initialize(args = [], local_options = {}, config = {}) if config[:current_command].is_a?(Kamal::Cli::Alias::Command) # When Thor generates a dynamic command, it doesn't attempt to parse the arguments. # For our purposes, it means the arguments are passed in args rather than local_options. super([], args, config) else super end initialize_commander unless KAMAL.configured? end private def options_with_subcommand_class_options options.merge(@_initializer.last[:class_options] || {}) end def initialize_commander KAMAL.tap do |commander| if options[:verbose] ENV["VERBOSE"] = "1" # For backtraces via cli/start commander.verbosity = VERBOSITY[:verbose] end if options[:quiet] commander.verbosity = VERBOSITY[:quiet] end commander.configure \ config_file: Pathname.new(File.expand_path(options[:config_file])), destination: options[:destination], version: options[:version] commander.specific_hosts = options[:hosts]&.split(",") commander.specific_roles = options[:roles]&.split(",") commander.specific_primary! if options[:primary] end end def print_runtime started_at = Time.now yield Time.now - started_at ensure runtime = Time.now - started_at puts " Finished all in #{sprintf("%.1f seconds", runtime)}" end def with_lock if KAMAL.holding_lock? yield else acquire_lock begin yield rescue begin release_lock rescue => e say "Error releasing the deploy lock: #{e.message}", :red end raise end release_lock end end def confirming(question) return yield if options[:confirmed] if ask(question, limited_to: %w[ y N ], default: "N") == "y" yield else say "Aborted", :red end end def acquire_lock ensure_run_directory raise_if_locked do say "Acquiring the deploy lock...", :magenta on(KAMAL.primary_host) { execute *KAMAL.lock.acquire("Automatic deploy lock", KAMAL.config.version), verbosity: :debug } end KAMAL.holding_lock = true end def release_lock say "Releasing the deploy lock...", :magenta on(KAMAL.primary_host) { execute *KAMAL.lock.release, verbosity: :debug } KAMAL.holding_lock = false end def raise_if_locked yield rescue SSHKit::Runner::ExecuteError => e if e.message =~ /cannot create directory/ say "Deploy lock already in place!", :red on(KAMAL.primary_host) { puts capture_with_debug(*KAMAL.lock.status) } raise LockError, "Deploy lock found. Run 'kamal lock help' for more information" else raise e end end def run_hook(hook, **extra_details) if !options[:skip_hooks] && KAMAL.hook.hook_exists?(hook) details = { hosts: KAMAL.hosts.join(","), roles: KAMAL.specific_roles&.join(","), lock: KAMAL.holding_lock?.to_s, command: command, subcommand: subcommand }.compact hooks_output = KAMAL.config.hooks_output_for(hook) # CLI flags override config: -q hides all, -v shows all # Config setting :verbose forces output, :quiet forces silence hook_verbosity = if KAMAL.verbosity == :info && hooks_output VERBOSITY.fetch(hooks_output) else KAMAL.verbosity end with_env KAMAL.hook.env(**details, **extra_details) do KAMAL.with_verbosity(hook_verbosity) do run_locally do execute *KAMAL.hook.run(hook) end end rescue SSHKit::Command::Failed => e raise HookError.new("Hook `#{hook}` failed:\n#{e.message}") end end end def on(*args, &block) pre_connect_if_required super end def pre_connect_if_required if !KAMAL.connected? run_hook "pre-connect", secrets: true unless options[:skip_hooks] KAMAL.connected = true end end def command @kamal_command ||= begin invocation_class, invocation_commands = *first_invocation if invocation_class == Kamal::Cli::Main invocation_commands[0] else Kamal::Cli::Main.subcommand_classes.find { |command, clazz| clazz == invocation_class }[0] end end end def subcommand @kamal_subcommand ||= begin invocation_class, invocation_commands = *first_invocation invocation_commands[0] if invocation_class != Kamal::Cli::Main end end def first_invocation instance_variable_get("@_invocations").first end def reset_invocation(cli_class) instance_variable_get("@_invocations")[cli_class].pop end def ensure_run_directory on(KAMAL.hosts) do execute(*KAMAL.server.ensure_run_directory) end end def with_env(env) current_env = ENV.to_h.dup ENV.update(env) yield ensure ENV.clear ENV.update(current_env) end def ensure_docker_installed run_locally do begin execute *KAMAL.builder.ensure_docker_installed rescue SSHKit::Command::Failed => e error = e.message =~ /command not found/ ? "Docker is not installed locally" : "Docker buildx plugin is not installed locally" raise DependencyError, error end end end end end ================================================ FILE: lib/kamal/cli/build/clone.rb ================================================ class Kamal::Cli::Build::Clone attr_reader :sshkit delegate :info, :error, :execute, :capture_with_info, to: :sshkit def initialize(sshkit) @sshkit = sshkit end def prepare begin clone_repo rescue SSHKit::Command::Failed => e if e.message =~ /already exists and is not an empty directory/ reset else raise Kamal::Cli::Build::BuildError, "Failed to clone repo: #{e.message}" end end validate! rescue Kamal::Cli::Build::BuildError => e error "Error preparing clone: #{e.message}, deleting and retrying..." FileUtils.rm_rf KAMAL.config.builder.clone_directory clone_repo validate! end private def clone_repo info "Cloning repo into build directory `#{KAMAL.config.builder.build_directory}`..." FileUtils.mkdir_p KAMAL.config.builder.clone_directory execute *KAMAL.builder.clone end def reset info "Resetting local clone as `#{KAMAL.config.builder.build_directory}` already exists..." KAMAL.builder.clone_reset_steps.each { |step| execute *step } rescue SSHKit::Command::Failed => e raise Kamal::Cli::Build::BuildError, "Failed to clone repo: #{e.message}" end def validate! status = capture_with_info(*KAMAL.builder.clone_status).strip unless status.empty? raise Kamal::Cli::Build::BuildError, "Clone in #{KAMAL.config.builder.build_directory} is dirty, #{status}" end revision = capture_with_info(*KAMAL.builder.clone_revision).strip if revision != Kamal::Git.revision 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}`" end rescue SSHKit::Command::Failed => e raise Kamal::Cli::Build::BuildError, "Failed to validate clone: #{e.message}" end end ================================================ FILE: lib/kamal/cli/build/port_forwarding.rb ================================================ require "concurrent/atomic/count_down_latch" class Kamal::Cli::Build::PortForwarding attr_reader :hosts, :port, :ssh_options def initialize(hosts, port, **ssh_options) @hosts = hosts @port = port @ssh_options = ssh_options end def forward @done = false forward_ports yield ensure stop end private def stop @done = true @threads.to_a.each(&:join) end def forward_ports ready = Concurrent::CountDownLatch.new(hosts.size) @threads = hosts.map do |host| Thread.new do begin Net::SSH.start(host, ssh_options[:user], **ssh_options.except(:user)) do |ssh| ssh.forward.remote(port, "localhost", port, "127.0.0.1") do |remote_port, bind_address| if remote_port == :error raise "Failed to establish port forward on #{host}" else ready.count_down end end ssh.loop(0.1) do if @done ssh.forward.cancel_remote(port, "127.0.0.1") break else true end end end rescue Exception => e error "Error setting up port forwarding to #{host}: #{e.class}: #{e.message}" error e.backtrace.join("\n") raise end end end raise "Timed out waiting for port forwarding to be established" unless ready.wait(30) end def error(message) SSHKit.config.output.error(message) end end ================================================ FILE: lib/kamal/cli/build.rb ================================================ class Kamal::Cli::Build < Kamal::Cli::Base class BuildError < StandardError; end desc "deliver", "Build app and push app image to registry then pull image on servers" def deliver invoke :push invoke :pull end desc "push", "Build and push app image to registry" option :output, type: :string, default: "registry", banner: "export_type", desc: "Exported type for the build result, and may be any exported type supported by 'buildx --output'." option :no_cache, type: :boolean, default: false, desc: "Build without using Docker's build cache" def push cli = self # Ensure pre-connect hooks run before the build, they may be needed for a remote builder # or the pre-build hooks. pre_connect_if_required ensure_docker_installed login_to_registry_locally if KAMAL.builder.login_to_registry_locally? run_hook "pre-build" uncommitted_changes = Kamal::Git.uncommitted_changes if KAMAL.config.builder.git_clone? if uncommitted_changes.present? say "Building from a local git clone, so ignoring these uncommitted changes:\n #{uncommitted_changes}", :yellow end run_locally do Clone.new(self).prepare end elsif uncommitted_changes.present? say "Building with uncommitted changes:\n #{uncommitted_changes}", :yellow end forward_local_registry_port_for_remote_builder do with_env(KAMAL.config.builder.secrets) do run_locally do begin execute *KAMAL.builder.inspect_builder rescue SSHKit::Command::Failed => e if e.message =~ /(context not found|no builder|no compatible builder|does not exist)/ warn "Missing compatible builder, so creating a new one first" begin cli.remove rescue SSHKit::Command::Failed raise unless e.message =~ /(context not found|no builder|does not exist)/ end cli.create else raise end end # Get the command here to ensure the Dir.chdir doesn't interfere with it push = KAMAL.builder.push(cli.options[:output], no_cache: cli.options[:no_cache]) KAMAL.with_verbosity(:debug) do Dir.chdir(KAMAL.config.builder.build_directory) { execute *push, env: KAMAL.builder.push_env } end end end end end desc "pull", "Pull app image from registry onto servers" def pull login_to_registry_remotely unless KAMAL.registry.local? forward_local_registry_port(KAMAL.hosts, **KAMAL.config.ssh.options) do if (first_hosts = mirror_hosts).any? #  Pull on a single host per mirror first to seed them say "Pulling image on #{first_hosts.join(", ")} to seed the #{"mirror".pluralize(first_hosts.count)}...", :magenta pull_on_hosts(first_hosts) say "Pulling image on remaining hosts...", :magenta pull_on_hosts(KAMAL.app_hosts - first_hosts) else pull_on_hosts(KAMAL.app_hosts) end end end desc "create", "Create a build setup" def create if (remote_host = KAMAL.config.builder.remote) connect_to_remote_host(remote_host) end run_locally do begin debug "Using builder: #{KAMAL.builder.name}" execute *KAMAL.builder.create rescue SSHKit::Command::Failed => e if e.message =~ /stderr=(.*)/ error "Couldn't create remote builder: #{$1}" false else raise end end end end desc "remove", "Remove build setup" def remove run_locally do debug "Using builder: #{KAMAL.builder.name}" execute *KAMAL.builder.remove end end desc "details", "Show build setup" def details run_locally do puts "Builder: #{KAMAL.builder.name}" puts capture(*KAMAL.builder.info) end end desc "dev", "Build using the working directory, tag it as dirty, and push to local image store." option :output, type: :string, default: "docker", banner: "export_type", desc: "Exported type for the build result, and may be any exported type supported by 'buildx --output'." option :no_cache, type: :boolean, default: false, desc: "Build without using Docker's build cache" def dev cli = self ensure_docker_installed docker_included_files = Set.new(Kamal::Docker.included_files) git_uncommitted_files = Set.new(Kamal::Git.uncommitted_files) git_untracked_files = Set.new(Kamal::Git.untracked_files) docker_uncommitted_files = docker_included_files & git_uncommitted_files if docker_uncommitted_files.any? say "WARNING: Files with uncommitted changes will be present in the dev container:", :yellow docker_uncommitted_files.sort.each { |f| say " #{f}", :yellow } say end docker_untracked_files = docker_included_files & git_untracked_files if docker_untracked_files.any? say "WARNING: Untracked files will be present in the dev container:", :yellow docker_untracked_files.sort.each { |f| say " #{f}", :yellow } say end with_env(KAMAL.config.builder.secrets) do run_locally do build = KAMAL.builder.push(cli.options[:output], tag_as_dirty: true, no_cache: cli.options[:no_cache]) KAMAL.with_verbosity(:debug) do execute(*build) end end end end private def connect_to_remote_host(remote_host) remote_uri = URI.parse(remote_host) if remote_uri.scheme == "ssh" host = SSHKit::Host.new( hostname: remote_uri.host, ssh_options: { user: remote_uri.user, port: remote_uri.port }.compact ) on(host, options) do execute "true" end end end def mirror_hosts if KAMAL.app_hosts.many? mirror_hosts = Concurrent::Hash.new on(KAMAL.app_hosts) do |host| first_mirror = capture_with_info(*KAMAL.builder.first_mirror).strip.presence mirror_hosts[first_mirror] ||= host.to_s if first_mirror rescue SSHKit::Command::Failed => e raise unless e.message =~ /error calling index: reflect: slice index out of range/ end mirror_hosts.values else [] end end def pull_on_hosts(hosts) on(hosts) do execute *KAMAL.auditor.record("Pulled image with version #{KAMAL.config.version}"), verbosity: :debug execute *KAMAL.builder.clean, raise_on_non_zero_exit: false execute *KAMAL.builder.pull execute *KAMAL.builder.validate_image end end def login_to_registry_locally run_locally do if KAMAL.registry.local? execute *KAMAL.registry.setup else execute *KAMAL.registry.login end end end def login_to_registry_remotely on(KAMAL.app_hosts) do execute *KAMAL.registry.login end end def forward_local_registry_port_for_remote_builder(&block) if KAMAL.builder.remote? remote_uri = URI(KAMAL.config.builder.remote) forward_local_registry_port([ remote_uri.host ], **remote_builder_ssh_options(remote_uri), &block) else yield end end def forward_local_registry_port(hosts, **ssh_options, &block) if KAMAL.config.registry.local? say "Setting up local registry port forwarding to #{hosts.join(', ')}..." PortForwarding.new(hosts, KAMAL.config.registry.local_port, **ssh_options).forward(&block) else yield end end def remote_builder_ssh_options(remote_uri) { user: remote_uri.user, port: remote_uri.port, keepalive: KAMAL.config.ssh.options[:keepalive], keepalive_interval: KAMAL.config.ssh.options[:keepalive_interval], logger: KAMAL.config.ssh.options[:logger] }.compact end end ================================================ FILE: lib/kamal/cli/healthcheck/barrier.rb ================================================ require "concurrent/ivar" class Kamal::Cli::Healthcheck::Barrier def initialize @ivar = Concurrent::IVar.new end def close set(false) end def open set(true) end def wait unless opened? raise Kamal::Cli::Healthcheck::Error.new("Halted at barrier") end end private def opened? @ivar.value end def set(value) @ivar.set(value) true rescue Concurrent::MultipleAssignmentError false end end ================================================ FILE: lib/kamal/cli/healthcheck/error.rb ================================================ class Kamal::Cli::Healthcheck::Error < StandardError end ================================================ FILE: lib/kamal/cli/healthcheck/poller.rb ================================================ module Kamal::Cli::Healthcheck::Poller extend self def wait_for_healthy(&block) attempt = 1 timeout_at = Time.now + KAMAL.config.deploy_timeout readiness_delay = KAMAL.config.readiness_delay begin status = block.call if status == "running" # Wait for the readiness delay and confirm it is still running if readiness_delay > 0 info "Container is running, waiting for readiness delay of #{readiness_delay} seconds" sleep readiness_delay status = block.call end end unless %w[ running healthy ].include?(status) raise Kamal::Cli::Healthcheck::Error, "container not ready after #{KAMAL.config.deploy_timeout} seconds (#{status})" end rescue Kamal::Cli::Healthcheck::Error => e time_left = timeout_at - Time.now if time_left > 0 sleep [ attempt, time_left ].min attempt += 1 retry else raise end end info "Container is healthy!" end private def info(message) SSHKit.config.output.info(message) end end ================================================ FILE: lib/kamal/cli/lock.rb ================================================ class Kamal::Cli::Lock < Kamal::Cli::Base desc "status", "Report lock status" def status handle_missing_lock do on(KAMAL.primary_host) do puts capture_with_debug(*KAMAL.lock.status) end end end desc "acquire", "Acquire the deploy lock" option :message, aliases: "-m", type: :string, desc: "A lock message", required: true def acquire message = options[:message] ensure_run_directory raise_if_locked do on(KAMAL.primary_host) do execute *KAMAL.lock.acquire(message, KAMAL.config.version), verbosity: :debug end say "Acquired the deploy lock" end end desc "release", "Release the deploy lock" def release handle_missing_lock do on(KAMAL.primary_host) do execute *KAMAL.lock.release, verbosity: :debug end say "Released the deploy lock" end end private def handle_missing_lock yield rescue SSHKit::Runner::ExecuteError => e if e.message =~ /No such file or directory/ say "There is no deploy lock" else raise end end end ================================================ FILE: lib/kamal/cli/main.rb ================================================ class Kamal::Cli::Main < Kamal::Cli::Base desc "setup", "Setup all accessories, push the env, and deploy app to servers" option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push" option :no_cache, type: :boolean, default: false, desc: "Build without using Docker's build cache" def setup print_runtime do with_lock do invoke_options = deploy_options say "Ensure Docker is installed...", :magenta invoke "kamal:cli:server:bootstrap", [], invoke_options deploy(boot_accessories: true) end end end desc "deploy", "Deploy app to servers" option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push" option :no_cache, type: :boolean, default: false, desc: "Build without using Docker's build cache" def deploy(boot_accessories: false) runtime = print_runtime do invoke_options = deploy_options if options[:skip_push] say "Pull app image...", :magenta invoke "kamal:cli:build:pull", [], invoke_options else say "Build and push app image...", :magenta invoke "kamal:cli:build:deliver", [], invoke_options end with_lock do run_hook "pre-deploy", secrets: true say "Ensure kamal-proxy is running...", :magenta invoke "kamal:cli:proxy:boot", [], invoke_options invoke "kamal:cli:accessory:boot", [ "all" ], invoke_options if boot_accessories say "Detect stale containers...", :magenta invoke "kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true) invoke "kamal:cli:app:boot", [], invoke_options say "Prune old containers and images...", :magenta invoke "kamal:cli:prune:all", [], invoke_options end end run_hook "post-deploy", secrets: true, runtime: runtime.round.to_s end desc "redeploy", "Deploy app to servers without bootstrapping servers, starting kamal-proxy and pruning" option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push" option :no_cache, type: :boolean, default: false, desc: "Build without using Docker's build cache" def redeploy runtime = print_runtime do invoke_options = deploy_options if options[:skip_push] say "Pull app image...", :magenta invoke "kamal:cli:build:pull", [], invoke_options else say "Build and push app image...", :magenta invoke "kamal:cli:build:deliver", [], invoke_options end with_lock do run_hook "pre-deploy", secrets: true say "Detect stale containers...", :magenta invoke "kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true) invoke "kamal:cli:app:boot", [], invoke_options end end run_hook "post-deploy", secrets: true, runtime: runtime.round.to_s end desc "rollback [VERSION]", "Rollback app to VERSION" def rollback(version) rolled_back = false runtime = print_runtime do with_lock do invoke_options = deploy_options KAMAL.config.version = version old_version = nil if container_available?(version) run_hook "pre-deploy", secrets: true invoke "kamal:cli:app:boot", [], invoke_options.merge(version: version) rolled_back = true else say "The app version '#{version}' is not available as a container (use 'kamal app containers' for available versions)", :red end end end run_hook "post-deploy", secrets: true, runtime: runtime.round.to_s if rolled_back end desc "details", "Show details about all containers" def details invoke "kamal:cli:proxy:details" invoke "kamal:cli:app:details" invoke "kamal:cli:accessory:details", [ "all" ] end desc "audit", "Show audit log from servers" def audit quiet = options[:quiet] on(KAMAL.hosts) do |host| puts_by_host host, capture_with_info(*KAMAL.auditor.reveal), quiet: quiet end end desc "config", "Show combined config (including secrets!)" def config run_locally do puts Kamal::Utils.redacted(KAMAL.config.to_h).to_yaml end end desc "docs [SECTION]", "Show Kamal configuration documentation" def docs(section = nil) case section when NilClass puts Kamal::Configuration.validation_doc else puts Kamal::Configuration.const_get(section.titlecase.to_sym).validation_doc end rescue NameError puts "No documentation found for #{section}" end desc "init", "Create config stub in config/deploy.yml and secrets stub in .kamal" option :bundle, type: :boolean, default: false, desc: "Add Kamal to the Gemfile and create a bin/kamal binstub" def init require "fileutils" if (deploy_file = Pathname.new(File.expand_path("config/deploy.yml"))).exist? puts "Config file already exists in config/deploy.yml (remove first to create a new one)" else FileUtils.mkdir_p deploy_file.dirname FileUtils.cp_r Pathname.new(File.expand_path("templates/deploy.yml", __dir__)), deploy_file puts "Created configuration file in config/deploy.yml" end unless (secrets_file = Pathname.new(File.expand_path(".kamal/secrets"))).exist? FileUtils.mkdir_p secrets_file.dirname FileUtils.cp_r Pathname.new(File.expand_path("templates/secrets", __dir__)), secrets_file puts "Created .kamal/secrets file" end unless (hooks_dir = Pathname.new(File.expand_path(".kamal/hooks"))).exist? hooks_dir.mkpath Pathname.new(File.expand_path("templates/sample_hooks", __dir__)).each_child do |sample_hook| FileUtils.cp sample_hook, hooks_dir, preserve: true end puts "Created sample hooks in .kamal/hooks" end if options[:bundle] if (binstub = Pathname.new(File.expand_path("bin/kamal"))).exist? puts "Binstub already exists in bin/kamal (remove first to create a new one)" else puts "Adding Kamal to Gemfile and bundle..." run_locally do execute :bundle, :add, :kamal execute :bundle, :binstubs, :kamal end puts "Created binstub file in bin/kamal" end end end desc "remove", "Remove kamal-proxy, app, accessories, and registry session from servers" option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question" def remove confirming "This will remove all containers and images. Are you sure?" do with_lock do invoke "kamal:cli:app:remove", [], options.without(:confirmed) invoke "kamal:cli:proxy:remove", [], options.without(:confirmed) invoke "kamal:cli:accessory:remove", [ "all" ], options invoke "kamal:cli:registry:remove", [], options.without(:confirmed).merge(skip_local: true) end end end desc "upgrade", "Upgrade from Kamal 1.x to 2.0" option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question" option :rolling, type: :boolean, default: false, desc: "Upgrade one host at a time" def upgrade confirming "This will replace Traefik with kamal-proxy and restart all accessories" do with_lock do if options[:rolling] KAMAL.hosts.each do |host| KAMAL.with_specific_hosts(host) do say "Upgrading #{host}...", :magenta if KAMAL.app_hosts.include?(host) invoke "kamal:cli:proxy:upgrade", [], options.merge(confirmed: true, rolling: false) reset_invocation(Kamal::Cli::Proxy) end if KAMAL.accessory_hosts.include?(host) invoke "kamal:cli:accessory:upgrade", [ "all" ], options.merge(confirmed: true, rolling: false) reset_invocation(Kamal::Cli::Accessory) end say "Upgraded #{host}", :magenta end end else say "Upgrading all hosts...", :magenta invoke "kamal:cli:proxy:upgrade", [], options.merge(confirmed: true) invoke "kamal:cli:accessory:upgrade", [ "all" ], options.merge(confirmed: true) say "Upgraded all hosts", :magenta end end end end desc "version", "Show Kamal version" def version puts Kamal::VERSION end desc "accessory", "Manage accessories (db/redis/search)" subcommand "accessory", Kamal::Cli::Accessory desc "app", "Manage application" subcommand "app", Kamal::Cli::App desc "build", "Build application image" subcommand "build", Kamal::Cli::Build desc "lock", "Manage the deploy lock" subcommand "lock", Kamal::Cli::Lock desc "proxy", "Manage kamal-proxy" subcommand "proxy", Kamal::Cli::Proxy desc "prune", "Prune old application images and containers" subcommand "prune", Kamal::Cli::Prune desc "registry", "Login and -out of the image registry" subcommand "registry", Kamal::Cli::Registry desc "secrets", "Helpers for extracting secrets" subcommand "secrets", Kamal::Cli::Secrets desc "server", "Bootstrap servers with curl and Docker" subcommand "server", Kamal::Cli::Server private def container_available?(version) begin on(KAMAL.app_hosts) do KAMAL.roles_on(host).each do |role| container_id = capture_with_info(*KAMAL.app(role: role, host: host).container_id_for_version(version)) raise "Container not found" unless container_id.present? end end rescue SSHKit::Runner::ExecuteError, SSHKit::Runner::MultipleExecuteError => e if e.message =~ /Container not found/ say "Error looking for container version #{version}: #{e.message}" return false else raise end end true end def deploy_options base_options = options.without("skip_push") base_options = base_options.except("no_cache") unless base_options["no_cache"] { "version" => KAMAL.config.version }.merge(base_options) end end ================================================ FILE: lib/kamal/cli/proxy.rb ================================================ class Kamal::Cli::Proxy < Kamal::Cli::Base desc "boot", "Boot proxy on servers" def boot with_lock do on(KAMAL.hosts) do |host| execute *KAMAL.docker.create_network rescue SSHKit::Command::Failed => e raise unless e.message.include?("already exists") end on(KAMAL.proxy_hosts) do |host| execute *KAMAL.registry.login version = capture_with_info(*KAMAL.proxy(host).version).strip.presence if version && Kamal::Utils.older_version?(version, Kamal::Configuration::Proxy::Run::MINIMUM_VERSION) raise "kamal-proxy version #{version} is too old, run `kamal proxy reboot` in order to update to at least #{Kamal::Configuration::Proxy::Run::MINIMUM_VERSION}" end execute *KAMAL.proxy(host).ensure_apps_config_directory execute *KAMAL.proxy(host).start_or_run end end end desc "boot_config ", "Manage kamal-proxy boot configuration" option :publish, type: :boolean, default: true, desc: "Publish the proxy ports on the host" option :publish_host_ip, type: :string, repeatable: true, default: nil, desc: "Host IP address to bind HTTP/HTTPS traffic to. Defaults to all interfaces" option :http_port, type: :numeric, default: Kamal::Configuration::Proxy::Run::DEFAULT_HTTP_PORT, desc: "HTTP port to publish on the host" option :https_port, type: :numeric, default: Kamal::Configuration::Proxy::Run::DEFAULT_HTTPS_PORT, desc: "HTTPS port to publish on the host" option :log_max_size, type: :string, default: Kamal::Configuration::Proxy::Run::DEFAULT_LOG_MAX_SIZE, desc: "Max size of proxy logs" option :registry, type: :string, default: nil, desc: "Registry to use for the proxy image" option :repository, type: :string, default: nil, desc: "Repository for the proxy image" option :image_version, type: :string, default: nil, desc: "Version of the proxy to run" option :metrics_port, type: :numeric, default: nil, desc: "Port to report prometheus metrics on" option :debug, type: :boolean, default: false, desc: "Whether to run the proxy in debug mode" option :docker_options, type: :array, default: [], desc: "Docker options to pass to the proxy container", banner: "option=value option2=value2" def boot_config(subcommand) say "The proxy boot_config command is deprecated - set the config in the deploy YAML at proxy/run instead", :yellow proxy_boot_config = KAMAL.config.proxy_boot case subcommand when "set" boot_options = [ *(proxy_boot_config.publish_args(options[:http_port], options[:https_port], options[:publish_host_ip]) if options[:publish]), *(proxy_boot_config.logging_args(options[:log_max_size])), *("--expose=#{options[:metrics_port]}" if options[:metrics_port]), *options[:docker_options].map { |option| "--#{option}" } ] image = [ options[:registry].presence, options[:repository].presence || proxy_boot_config.repository_name, proxy_boot_config.image_name ].compact.join("/") image_version = options[:image_version] run_command_options = { debug: options[:debug] || nil, "metrics-port": options[:metrics_port] }.compact run_command = "kamal-proxy run #{Kamal::Utils.optionize(run_command_options).join(" ")}" if run_command_options.any? on(KAMAL.proxy_hosts) do |host| proxy = KAMAL.proxy(host) execute(*proxy.ensure_proxy_directory) if boot_options != proxy_boot_config.default_boot_options upload! StringIO.new(boot_options.join(" ")), proxy_boot_config.options_file else execute *proxy.reset_boot_options, raise_on_non_zero_exit: false end if image != proxy_boot_config.image_default upload! StringIO.new(image), proxy_boot_config.image_file else execute *proxy.reset_image, raise_on_non_zero_exit: false end if image_version upload! StringIO.new(image_version), proxy_boot_config.image_version_file else execute *proxy.reset_image_version, raise_on_non_zero_exit: false end if run_command upload! StringIO.new(run_command), proxy_boot_config.run_command_file else execute *proxy.reset_run_command, raise_on_non_zero_exit: false end end when "get" on(KAMAL.proxy_hosts) do |host| puts "Host #{host}: #{capture_with_info(*KAMAL.proxy(host).boot_config)}" end when "reset" on(KAMAL.proxy_hosts) do |host| proxy = KAMAL.proxy(host) execute *proxy.reset_boot_options, raise_on_non_zero_exit: false execute *proxy.reset_image, raise_on_non_zero_exit: false execute *proxy.reset_image_version, raise_on_non_zero_exit: false execute *proxy.reset_run_command, raise_on_non_zero_exit: false end else raise ArgumentError, "Unknown boot_config subcommand #{subcommand}" end end desc "reboot", "Reboot proxy on servers (stop container, remove container, start new container)" option :rolling, type: :boolean, default: false, desc: "Reboot proxy on hosts in sequence, rather than in parallel" option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question" def reboot confirming "This will cause a brief outage on each host. Are you sure?" do with_lock do host_groups = options[:rolling] ? KAMAL.proxy_hosts : [ KAMAL.proxy_hosts ] host_groups.each do |hosts| host_list = Array(hosts).join(",") run_hook "pre-proxy-reboot", hosts: host_list on(hosts) do |host| proxy = KAMAL.proxy(host) execute *KAMAL.auditor.record("Rebooted proxy"), verbosity: :debug execute *KAMAL.registry.login "Stopping and removing kamal-proxy on #{host}, if running..." execute *proxy.stop, raise_on_non_zero_exit: false execute *proxy.remove_container execute *proxy.ensure_apps_config_directory execute *proxy.run end run_hook "post-proxy-reboot", hosts: host_list end end end end desc "upgrade", "Upgrade to kamal-proxy on servers (stop container, remove container, start new container, reboot app)", hide: true option :rolling, type: :boolean, default: false, desc: "Reboot proxy on hosts in sequence, rather than in parallel" option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question" def upgrade invoke_options = { "version" => KAMAL.config.latest_tag }.merge(options) confirming "This will cause a brief outage on each host. Are you sure?" do host_groups = options[:rolling] ? KAMAL.hosts : [ KAMAL.hosts ] host_groups.each do |hosts| host_list = Array(hosts).join(",") say "Upgrading proxy on #{host_list}...", :magenta run_hook "pre-proxy-reboot", hosts: host_list on(hosts) do |host| proxy = KAMAL.proxy(host) execute *KAMAL.auditor.record("Rebooted proxy"), verbosity: :debug execute *KAMAL.registry.login "Stopping and removing Traefik on #{host}, if running..." execute *proxy.cleanup_traefik "Stopping and removing kamal-proxy on #{host}, if running..." execute *proxy.stop, raise_on_non_zero_exit: false execute *proxy.remove_container execute *proxy.remove_image end KAMAL.with_specific_hosts(hosts) do invoke "kamal:cli:proxy:boot", [], invoke_options reset_invocation(Kamal::Cli::Proxy) invoke "kamal:cli:app:boot", [], invoke_options reset_invocation(Kamal::Cli::App) invoke "kamal:cli:prune:all", [], invoke_options reset_invocation(Kamal::Cli::Prune) end run_hook "post-proxy-reboot", hosts: host_list say "Upgraded proxy on #{host_list}", :magenta end end end desc "start", "Start existing proxy container on servers" def start with_lock do on(KAMAL.proxy_hosts) do |host| execute *KAMAL.auditor.record("Started proxy"), verbosity: :debug execute *KAMAL.proxy(host).start end end end desc "stop", "Stop existing proxy container on servers" def stop with_lock do on(KAMAL.proxy_hosts) do |host| execute *KAMAL.auditor.record("Stopped proxy"), verbosity: :debug execute *KAMAL.proxy(host).stop, raise_on_non_zero_exit: false end end end desc "restart", "Restart existing proxy container on servers" def restart with_lock do stop start end end desc "details", "Show details about proxy container from servers" def details quiet = options[:quiet] on(KAMAL.proxy_hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.proxy(host).info), type: "Proxy", quiet: quiet } end desc "logs", "Show log lines from proxy on servers" option :since, aliases: "-s", desc: "Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)" option :lines, type: :numeric, aliases: "-n", desc: "Number of log lines to pull from each server" option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)" option :follow, aliases: "-f", desc: "Follow logs on primary server (or specific host set by --hosts)" option :skip_timestamps, type: :boolean, aliases: "-T", desc: "Skip appending timestamps to logging output" def logs grep = options[:grep] timestamps = !options[:skip_timestamps] if options[:follow] run_locally do proxy = KAMAL.proxy(KAMAL.primary_host) info "Following logs on #{KAMAL.primary_host}..." info proxy.follow_logs(host: KAMAL.primary_host, timestamps: timestamps, grep: grep) exec proxy.follow_logs(host: KAMAL.primary_host, timestamps: timestamps, grep: grep) end else since = options[:since] lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set on(KAMAL.proxy_hosts) do |host| puts_by_host host, capture(*KAMAL.proxy(host).logs(timestamps: timestamps, since: since, lines: lines, grep: grep)), type: "Proxy" end end end desc "remove", "Remove proxy container and image from servers" option :force, type: :boolean, default: false, desc: "Force removing proxy when apps are still installed" def remove with_lock do if removal_allowed?(options[:force]) stop remove_container remove_image remove_proxy_directory end end end desc "remove_container", "Remove proxy container from servers", hide: true def remove_container with_lock do on(KAMAL.proxy_hosts) do execute *KAMAL.auditor.record("Removed proxy container"), verbosity: :debug execute *KAMAL.proxy(host).remove_container end end end desc "remove_image", "Remove proxy image from servers", hide: true def remove_image with_lock do on(KAMAL.proxy_hosts) do execute *KAMAL.auditor.record("Removed proxy image"), verbosity: :debug execute *KAMAL.proxy(host).remove_image end end end desc "remove_proxy_directory", "Remove the proxy directory from servers", hide: true def remove_proxy_directory with_lock do on(KAMAL.proxy_hosts) do execute *KAMAL.proxy(host).remove_proxy_directory, raise_on_non_zero_exit: false end end end private def removal_allowed?(force) on(KAMAL.proxy_hosts) do |host| app_count = capture_with_info(*KAMAL.server.app_directory_count).chomp.to_i raise "The are other applications installed on #{host}" if app_count > 0 end true rescue SSHKit::Runner::ExecuteError => e raise unless e.message.include?("The are other applications installed on") if force say "Forcing, so removing the proxy, even though other apps are installed", :magenta else say "Not removing the proxy, as other apps are installed, ignore this check with kamal proxy remove --force", :magenta end force end end ================================================ FILE: lib/kamal/cli/prune.rb ================================================ class Kamal::Cli::Prune < Kamal::Cli::Base desc "all", "Prune unused images and stopped containers" def all with_lock do containers images end end desc "images", "Prune unused images" def images with_lock do on(KAMAL.hosts) do execute *KAMAL.auditor.record("Pruned images"), verbosity: :debug execute *KAMAL.prune.dangling_images execute *KAMAL.prune.tagged_images end end end desc "containers", "Prune all stopped containers, except the last n (default 5)" option :retain, type: :numeric, default: nil, desc: "Number of containers to retain" def containers retain = options.fetch(:retain, KAMAL.config.retain_containers) raise "retain must be at least 1" if retain < 1 with_lock do on(KAMAL.hosts) do execute *KAMAL.auditor.record("Pruned containers"), verbosity: :debug execute *KAMAL.prune.app_containers(retain: retain) end end end end ================================================ FILE: lib/kamal/cli/registry.rb ================================================ class Kamal::Cli::Registry < Kamal::Cli::Base desc "setup", "Setup local registry or log in to remote registry locally and remotely" option :skip_local, aliases: "-L", type: :boolean, default: false, desc: "Skip local login" option :skip_remote, aliases: "-R", type: :boolean, default: false, desc: "Skip remote login" def setup ensure_docker_installed unless options[:skip_local] if KAMAL.registry.local? run_locally { execute *KAMAL.registry.setup } unless options[:skip_local] else run_locally { execute *KAMAL.registry.login } unless options[:skip_local] on(KAMAL.hosts) { execute *KAMAL.registry.login } unless options[:skip_remote] end end desc "remove", "Remove local registry or log out of remote registry locally and remotely" option :skip_local, aliases: "-L", type: :boolean, default: false, desc: "Skip local login" option :skip_remote, aliases: "-R", type: :boolean, default: false, desc: "Skip remote login" def remove if KAMAL.registry.local? run_locally { execute *KAMAL.registry.remove, raise_on_non_zero_exit: false } unless options[:skip_local] else run_locally { execute *KAMAL.registry.logout } unless options[:skip_local] on(KAMAL.hosts) { execute *KAMAL.registry.logout } unless options[:skip_remote] end end desc "login", "Log in to remote registry locally and remotely" option :skip_local, aliases: "-L", type: :boolean, default: false, desc: "Skip local login" option :skip_remote, aliases: "-R", type: :boolean, default: false, desc: "Skip remote login" def login if KAMAL.registry.local? raise "Cannot use login command with a local registry. Use `kamal registry setup` instead." end setup end desc "logout", "Log out of remote registry locally and remotely" option :skip_local, aliases: "-L", type: :boolean, default: false, desc: "Skip local login" option :skip_remote, aliases: "-R", type: :boolean, default: false, desc: "Skip remote login" def logout if KAMAL.registry.local? raise "Cannot use logout command with a local registry. Use `kamal registry remove` instead." end remove end end ================================================ FILE: lib/kamal/cli/secrets.rb ================================================ class Kamal::Cli::Secrets < Kamal::Cli::Base desc "fetch [SECRETS...]", "Fetch secrets from a vault" option :adapter, type: :string, aliases: "-a", required: true, desc: "Which vault adapter to use" option :account, type: :string, required: false, desc: "The account identifier or username" option :from, type: :string, required: false, desc: "A vault or folder to fetch the secrets from" option :inline, type: :boolean, required: false, hidden: true def fetch(*secrets) adapter = initialize_adapter(options[:adapter]) if adapter.requires_account? && options[:account].blank? return puts "No value provided for required options '--account'" end results = adapter.fetch(secrets, **options.slice(:account, :from).symbolize_keys) json = JSON.dump(results) return_or_puts options[:inline] ? json.shellescape : json, inline: options[:inline] end desc "extract", "Extract a single secret from the results of a fetch call" option :inline, type: :boolean, required: false, hidden: true def extract(name, secrets) parsed_secrets = JSON.parse(secrets) value = parsed_secrets[name] || parsed_secrets.find { |k, v| k.end_with?("/#{name}") }&.last raise "Could not find secret #{name}" if value.nil? return_or_puts value, inline: options[:inline] end desc "print", "Print the secrets (for debugging)" def print KAMAL.config.secrets.to_h.each do |key, value| puts "#{key}=#{value}" end end private def initialize_adapter(adapter) Kamal::Secrets::Adapters.lookup(adapter) end def return_or_puts(value, inline: nil) if inline value else puts value end end end ================================================ FILE: lib/kamal/cli/server.rb ================================================ class Kamal::Cli::Server < Kamal::Cli::Base desc "exec", "Run a custom command on the server (use --help to show options)" option :interactive, type: :boolean, aliases: "-i", default: false, desc: "Run the command interactively (use for console/bash)" def exec(*cmd) pre_connect_if_required cmd = Kamal::Utils.join_commands(cmd) hosts = KAMAL.hosts quiet = options[:quiet] case when options[:interactive] host = KAMAL.primary_host say "Running '#{cmd}' on #{host} interactively...", :magenta run_locally { exec KAMAL.server.run_over_ssh(cmd, host: host) } else say "Running '#{cmd}' on #{hosts.join(', ')}...", :magenta on(hosts) do |host| execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on #{host}"), verbosity: :debug puts_by_host host, capture_with_info(cmd), quiet: quiet end end end desc "bootstrap", "Set up Docker to run Kamal apps" def bootstrap with_lock do missing = [] on(KAMAL.hosts) do |host| unless execute(*KAMAL.docker.installed?, raise_on_non_zero_exit: false) if execute(*KAMAL.docker.superuser?, raise_on_non_zero_exit: false) info "Missing Docker on #{host}. Installing…" execute *KAMAL.docker.install unless execute(*KAMAL.docker.root?, raise_on_non_zero_exit: false) || execute(*KAMAL.docker.in_docker_group?, raise_on_non_zero_exit: false) execute *KAMAL.docker.add_to_docker_group begin execute *KAMAL.docker.refresh_session rescue IOError info "Session refreshed due to group change." end end else missing << host end end end if missing.any? 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/" end run_hook "docker-setup" end end end ================================================ FILE: lib/kamal/cli/templates/deploy.yml ================================================ # Name of your application. Used to uniquely configure containers. service: my-app # Name of the container image. image: my-user/my-app # Deploy to these servers. servers: web: - 192.168.0.1 # job: # hosts: # - 192.168.0.1 # cmd: bin/jobs # Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server. # Remove this section when using multiple web servers and ensure you terminate SSL at your load balancer. # # Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. proxy: ssl: true host: app.example.com # Proxy connects to your container on port 80 by default. # app_port: 3000 # Credentials for your image host. registry: server: localhost:5555 # Specify the registry server, if you're not using Docker Hub # server: registry.digitalocean.com / ghcr.io / ... # username: my-user # Always use an access token rather than real password (pulled from .kamal/secrets). # password: # - KAMAL_REGISTRY_PASSWORD # Configure builder setup. builder: arch: amd64 # Pass in additional build args needed for your Dockerfile. # args: # RUBY_VERSION: <%= ENV["RBENV_VERSION"] || ENV["rvm_ruby_string"] || "#{RUBY_ENGINE}-#{RUBY_ENGINE_VERSION}" %> # Inject ENV variables into containers (secrets come from .kamal/secrets). # # env: # clear: # DB_HOST: 192.168.0.2 # secret: # - RAILS_MASTER_KEY # Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: # "bin/kamal app logs -r job" will tail logs from the first server in the job section. # # aliases: # shell: app exec --interactive --reuse "bash" # Use a different ssh user than root # # ssh: # user: app # Use a persistent storage volume. # # volumes: # - "app_storage:/app/storage" # Bridge fingerprinted assets, like JS and CSS, between versions to avoid # hitting 404 on in-flight requests. Combines all files from new and old # version inside the asset_path. # # asset_path: /app/public/assets # Configure rolling deploys by setting a wait time between batches of restarts. # # boot: # limit: 10 # Can also specify as a percentage of total hosts, such as "25%" # wait: 2 # Use accessory services (secrets come from .kamal/secrets). # # accessories: # db: # image: mysql:8.0 # host: 192.168.0.2 # port: 3306 # env: # clear: # MYSQL_ROOT_HOST: '%' # secret: # - MYSQL_ROOT_PASSWORD # files: # - config/mysql/production.cnf:/etc/mysql/my.cnf # - db/production.sql:/docker-entrypoint-initdb.d/setup.sql # directories: # - data:/var/lib/mysql # redis: # image: valkey/valkey:8 # host: 192.168.0.2 # port: 6379 # directories: # - data:/data ================================================ FILE: lib/kamal/cli/templates/sample_hooks/docker-setup.sample ================================================ #!/bin/sh echo "Docker set up on $KAMAL_HOSTS..." ================================================ FILE: lib/kamal/cli/templates/sample_hooks/post-app-boot.sample ================================================ #!/bin/sh echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..." ================================================ FILE: lib/kamal/cli/templates/sample_hooks/post-deploy.sample ================================================ #!/bin/sh # A sample post-deploy hook # # These environment variables are available: # KAMAL_RECORDED_AT # KAMAL_PERFORMER # KAMAL_VERSION # KAMAL_HOSTS # KAMAL_ROLES (if set) # KAMAL_DESTINATION (if set) # KAMAL_RUNTIME echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" ================================================ FILE: lib/kamal/cli/templates/sample_hooks/post-proxy-reboot.sample ================================================ #!/bin/sh echo "Rebooted kamal-proxy on $KAMAL_HOSTS" ================================================ FILE: lib/kamal/cli/templates/sample_hooks/pre-app-boot.sample ================================================ #!/bin/sh echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..." ================================================ FILE: lib/kamal/cli/templates/sample_hooks/pre-build.sample ================================================ #!/bin/sh # A sample pre-build hook # # Checks: # 1. We have a clean checkout # 2. A remote is configured # 3. The branch has been pushed to the remote # 4. The version we are deploying matches the remote # # These environment variables are available: # KAMAL_RECORDED_AT # KAMAL_PERFORMER # KAMAL_VERSION # KAMAL_HOSTS # KAMAL_ROLES (if set) # KAMAL_DESTINATION (if set) if [ -n "$(git status --porcelain)" ]; then echo "Git checkout is not clean, aborting..." >&2 git status --porcelain >&2 exit 1 fi first_remote=$(git remote) if [ -z "$first_remote" ]; then echo "No git remote set, aborting..." >&2 exit 1 fi current_branch=$(git branch --show-current) if [ -z "$current_branch" ]; then echo "Not on a git branch, aborting..." >&2 exit 1 fi remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1) if [ -z "$remote_head" ]; then echo "Branch not pushed to remote, aborting..." >&2 exit 1 fi if [ "$KAMAL_VERSION" != "$remote_head" ]; then echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2 exit 1 fi exit 0 ================================================ FILE: lib/kamal/cli/templates/sample_hooks/pre-connect.sample ================================================ #!/usr/bin/env ruby # A sample pre-connect check # # Warms DNS before connecting to hosts in parallel # # These environment variables are available: # KAMAL_RECORDED_AT # KAMAL_PERFORMER # KAMAL_VERSION # KAMAL_HOSTS # KAMAL_ROLES (if set) # KAMAL_DESTINATION (if set) # KAMAL_RUNTIME hosts = ENV["KAMAL_HOSTS"].split(",") results = nil max = 3 elapsed = Benchmark.realtime do results = hosts.map do |host| Thread.new do tries = 1 begin Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME) rescue SocketError if tries < max puts "Retrying DNS warmup: #{host}" tries += 1 sleep rand retry else puts "DNS warmup failed: #{host}" host end end tries end end.map(&:value) end retries = results.sum - hosts.size nopes = results.count { |r| r == max } puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ] ================================================ FILE: lib/kamal/cli/templates/sample_hooks/pre-deploy.sample ================================================ #!/usr/bin/env ruby # A sample pre-deploy hook # # Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds. # # Fails unless the combined status is "success" # # These environment variables are available: # KAMAL_RECORDED_AT # KAMAL_PERFORMER # KAMAL_VERSION # KAMAL_HOSTS # KAMAL_COMMAND # KAMAL_SUBCOMMAND # KAMAL_ROLES (if set) # KAMAL_DESTINATION (if set) # Only check the build status for production deployments if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production" exit 0 end require "bundler/inline" # true = install gems so this is fast on repeat invocations gemfile(true, quiet: true) do source "https://rubygems.org" gem "octokit" gem "faraday-retry" end MAX_ATTEMPTS = 72 ATTEMPTS_GAP = 10 def exit_with_error(message) $stderr.puts message exit 1 end class GithubStatusChecks attr_reader :remote_url, :git_sha, :github_client, :combined_status def initialize @remote_url = github_repo_from_remote_url @git_sha = `git rev-parse HEAD`.strip @github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"]) refresh! end def refresh! @combined_status = github_client.combined_status(remote_url, git_sha) end def state combined_status[:state] end def first_status_url first_status = combined_status[:statuses].find { |status| status[:state] == state } first_status && first_status[:target_url] end def complete_count combined_status[:statuses].count { |status| status[:state] != "pending"} end def total_count combined_status[:statuses].count end def current_status if total_count > 0 "Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..." else "Build not started..." end end private def github_repo_from_remote_url url = `git config --get remote.origin.url`.strip.delete_suffix(".git") if url.start_with?("https://github.com/") url.delete_prefix("https://github.com/") elsif url.start_with?("git@github.com:") url.delete_prefix("git@github.com:") else url end end end $stdout.sync = true begin puts "Checking build status..." attempts = 0 checks = GithubStatusChecks.new loop do case checks.state when "success" puts "Checks passed, see #{checks.first_status_url}" exit 0 when "failure" exit_with_error "Checks failed, see #{checks.first_status_url}" when "pending" attempts += 1 end exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS puts checks.current_status sleep(ATTEMPTS_GAP) checks.refresh! end rescue Octokit::NotFound exit_with_error "Build status could not be found" end ================================================ FILE: lib/kamal/cli/templates/sample_hooks/pre-proxy-reboot.sample ================================================ #!/bin/sh echo "Rebooting kamal-proxy on $KAMAL_HOSTS..." ================================================ FILE: lib/kamal/cli/templates/secrets ================================================ # Secrets defined here are available for reference under registry/password, env/secret, builder/secrets, # and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either # password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. # Option 1: Read secrets from the environment # KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD # Option 2: Read secrets via a command # RAILS_MASTER_KEY=$(cat config/master.key) # KAMAL_REGISTRY_PASSWORD=$(rails credentials:fetch kamal.registry_password) # Option 3: Read secrets via kamal secrets helpers # These will handle logging in and fetching the secrets in as few calls as possible # There are adapters for 1Password, LastPass + Bitwarden # # SECRETS=$(kamal secrets fetch --adapter 1password --account my-account --from MyVault/MyItem KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY) # KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD $SECRETS) # RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) ================================================ FILE: lib/kamal/cli.rb ================================================ module Kamal::Cli class BootError < StandardError; end class HookError < StandardError; end class LockError < StandardError; end class DependencyError < StandardError; end end # SSHKit uses instance eval, so we need a global const for ergonomics KAMAL = Kamal::Commander.new ================================================ FILE: lib/kamal/commander/specifics.rb ================================================ class Kamal::Commander::Specifics attr_reader :primary_host, :primary_role, :hosts, :roles delegate :stable_sort!, to: Kamal::Utils def initialize(config, specific_hosts, specific_roles) @config, @specific_hosts, @specific_roles = config, specific_hosts, specific_roles @roles, @hosts = specified_roles, specified_hosts @primary_host = specific_hosts&.first || primary_specific_role&.primary_host || config.primary_host @primary_role = primary_or_first_role(roles_on(primary_host)) stable_sort!(roles) { |role| role == primary_role ? 0 : 1 } sort_primary_role_hosts_first!(hosts) end def roles_on(host) roles.select { |role| role.hosts.include?(host.to_s) } end def app_hosts @app_hosts ||= sort_primary_role_hosts_first!(config.app_hosts & specified_hosts) end def proxy_hosts config.proxy_hosts & specified_hosts end def accessory_hosts config.accessories.flat_map(&:hosts) & specified_hosts end private attr_reader :config, :specific_hosts, :specific_roles def primary_specific_role primary_or_first_role(specific_roles) if specific_roles.present? end def primary_or_first_role(roles) roles.detect { |role| role == config.primary_role } || roles.first end def specified_roles (specific_roles || config.roles) \ .select { |role| ((specific_hosts || config.all_hosts) & role.hosts).any? } end def specified_hosts specified_hosts = specific_hosts || config.all_hosts if (specific_role_hosts = specific_roles&.flat_map(&:hosts)).present? specified_hosts.select { |host| specific_role_hosts.include?(host) } else specified_hosts end end def sort_primary_role_hosts_first!(hosts) stable_sort!(hosts) { |host| roles_on(host).any? { |role| role == primary_role } ? 0 : 1 } end end ================================================ FILE: lib/kamal/commander.rb ================================================ require "active_support/core_ext/enumerable" require "active_support/core_ext/module/delegation" require "active_support/core_ext/object/blank" class Kamal::Commander attr_accessor :verbosity, :holding_lock, :connected attr_reader :specific_roles, :specific_hosts delegate :hosts, :roles, :primary_host, :primary_role, :roles_on, :app_hosts, :proxy_hosts, :accessory_hosts, to: :specifics def initialize reset end def reset self.verbosity = :info self.holding_lock = ENV["KAMAL_LOCK"] == "true" self.connected = false @specifics = @specific_roles = @specific_hosts = nil @config = @config_kwargs = nil @commands = {} end def config @config ||= Kamal::Configuration.create_from(**@config_kwargs.to_h).tap do |config| @config_kwargs = nil configure_sshkit_with(config) end end def configure(**kwargs) @config, @config_kwargs = nil, kwargs end def configured? @config || @config_kwargs end def specific_primary! @specifics = nil if specific_roles.present? self.specific_hosts = [ specific_roles.first.primary_host ] else self.specific_hosts = [ config.primary_host ] end end def specific_roles=(role_names) @specifics = nil @specific_roles = if role_names.present? filtered = Kamal::Utils.filter_specific_items(role_names, config.roles) raise ArgumentError, "No --roles match for #{role_names.join(',')}" if filtered.empty? filtered end end def specific_hosts=(hosts) @specifics = nil @specific_hosts = if hosts.present? filtered = Kamal::Utils.filter_specific_items(hosts, config.all_hosts) raise ArgumentError, "No --hosts match for #{hosts.join(',')}" if filtered.empty? filtered end end def with_specific_hosts(hosts) original_hosts, self.specific_hosts = specific_hosts, hosts yield ensure self.specific_hosts = original_hosts end def accessory_names config.accessories&.collect(&:name) || [] end def app(role: nil, host: nil) Kamal::Commands::App.new(config, role: role, host: host) end def accessory(name) Kamal::Commands::Accessory.new(config, name: name) end def auditor(**details) Kamal::Commands::Auditor.new(config, **details) end def builder @commands[:builder] ||= Kamal::Commands::Builder.new(config) end def docker @commands[:docker] ||= Kamal::Commands::Docker.new(config) end def hook @commands[:hook] ||= Kamal::Commands::Hook.new(config) end def lock @commands[:lock] ||= Kamal::Commands::Lock.new(config) end def proxy(host) Kamal::Commands::Proxy.new(config, host: host) end def prune @commands[:prune] ||= Kamal::Commands::Prune.new(config) end def registry @commands[:registry] ||= Kamal::Commands::Registry.new(config) end def server @commands[:server] ||= Kamal::Commands::Server.new(config) end def alias(name) config.aliases[name] end def resolve_alias(name) if @config @config.aliases[name]&.command else raw_config = Kamal::Configuration.load_raw_config(**@config_kwargs.to_h.slice(:config_file, :destination)) raw_config[:aliases]&.dig(name) end end def with_verbosity(level) old_level = self.verbosity self.verbosity = level SSHKit.config.output_verbosity = level yield ensure self.verbosity = old_level SSHKit.config.output_verbosity = old_level end def holding_lock? self.holding_lock end def connected? self.connected end private # Lazy setup of SSHKit def configure_sshkit_with(config) SSHKit::Backend::Netssh.pool.idle_timeout = config.sshkit.pool_idle_timeout SSHKit::Backend::Netssh.configure do |sshkit| sshkit.max_concurrent_starts = config.sshkit.max_concurrent_starts sshkit.dns_retries = config.sshkit.dns_retries sshkit.ssh_options = config.ssh.options end SSHKit.config.command_map[:docker] = "docker" # No need to use /usr/bin/env, just clogs up the logs SSHKit.config.output_verbosity = verbosity end def specifics @specifics ||= Kamal::Commander::Specifics.new(config, specific_hosts, specific_roles) end end ================================================ FILE: lib/kamal/commands/accessory/proxy.rb ================================================ module Kamal::Commands::Accessory::Proxy delegate :container_name, to: :"config.proxy_boot", prefix: :proxy def deploy(target:) proxy_exec :deploy, service_name, *proxy.deploy_command_args(target: target) end def remove proxy_exec :remove, service_name end private def proxy_exec(*command) docker :exec, proxy_container_name, "kamal-proxy", *command end end ================================================ FILE: lib/kamal/commands/accessory.rb ================================================ class Kamal::Commands::Accessory < Kamal::Commands::Base include Proxy attr_reader :accessory_config delegate :service_name, :image, :hosts, :port, :files, :directories, :cmd, :network_args, :publish_args, :env_args, :volume_args, :label_args, :option_args, :secrets_io, :secrets_path, :env_directory, :proxy, :running_proxy?, :registry, to: :accessory_config def initialize(config, name:) super(config) @accessory_config = config.accessory(name) end def run(host: nil) docker :run, "--name", service_name, "--detach", "--restart", "unless-stopped", *network_args, *config.logging_args, *publish_args, *([ "--env", "KAMAL_HOST=\"#{host}\"" ] if host), *env_args, *volume_args, *label_args, *option_args, image, cmd end def start docker :container, :start, service_name end def stop docker :container, :stop, service_name end def info(all: false, quiet: false) docker :ps, *("-a" if all), *("-q" if quiet), *service_filter end def logs(timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil) pipe \ docker(:logs, service_name, (" --since #{since}" if since), (" --tail #{lines}" if lines), ("--timestamps" if timestamps), "2>&1"), ("grep '#{grep}'#{" #{grep_options}" if grep_options}" if grep) end def follow_logs(timestamps: true, grep: nil, grep_options: nil) run_over_ssh \ pipe \ docker(:logs, service_name, ("--timestamps" if timestamps), "--tail", "10", "--follow", "2>&1"), (%(grep "#{grep}"#{" #{grep_options}" if grep_options}) if grep) end def execute_in_existing_container(*command, interactive: false) docker :exec, (docker_interactive_args if interactive), service_name, *command end def execute_in_new_container(*command, interactive: false) docker :run, (docker_interactive_args if interactive), "--rm", *network_args, *env_args, *volume_args, *option_args, image, *command end def execute_in_existing_container_over_ssh(*command) run_over_ssh execute_in_existing_container(*command, interactive: true) end def execute_in_new_container_over_ssh(*command) run_over_ssh execute_in_new_container(*command, interactive: true) end def run_over_ssh(command) super command, host: hosts.first end def ensure_local_file_present(local_file) if !local_file.is_a?(StringIO) && !Pathname.new(local_file).exist? raise "Missing file: #{local_file}" end end def pull_image docker :image, :pull, image end def remove_service_directory [ :rm, "-rf", service_name ] end def remove_container docker :container, :prune, "--force", *service_filter end def remove_image docker :image, :rm, "--force", image end def ensure_env_directory make_directory env_directory end private def service_filter [ "--filter", "label=service=#{service_name}" ] end end ================================================ FILE: lib/kamal/commands/app/assets.rb ================================================ module Kamal::Commands::App::Assets def extract_assets asset_container = "#{role.container_prefix}-assets" combine \ make_directory(role.asset_extracted_directory), [ *docker(:container, :rm, asset_container, "2> /dev/null"), "|| true" ], docker(:container, :create, "--name", asset_container, config.absolute_image), docker(:container, :cp, "-L", "#{asset_container}:#{role.asset_path}/.", role.asset_extracted_directory), docker(:container, :rm, asset_container), by: "&&" end def sync_asset_volumes(old_version: nil) new_extracted_path, new_volume_path = role.asset_extracted_directory(config.version), role.asset_volume.host_path if old_version.present? old_extracted_path, old_volume_path = role.asset_extracted_directory(old_version), role.asset_volume(old_version).host_path end commands = [ make_directory(new_volume_path), copy_contents(new_extracted_path, new_volume_path) ] if old_version.present? commands << copy_contents(new_extracted_path, old_volume_path, continue_on_error: true) commands << copy_contents(old_extracted_path, new_volume_path, continue_on_error: true) end chain *commands end def clean_up_assets chain \ find_and_remove_older_siblings(role.asset_extracted_directory), find_and_remove_older_siblings(role.asset_volume_directory) end private def find_and_remove_older_siblings(path) [ :find, Pathname.new(path).dirname.to_s, "-maxdepth 1", "-name", "'#{role.name}-*'", "!", "-name", Pathname.new(path).basename.to_s, "-exec rm -rf \"{}\" +" ] end def copy_contents(source, destination, continue_on_error: false) [ :cp, "-rnT", "#{source}", destination, *("|| true" if continue_on_error) ] end end ================================================ FILE: lib/kamal/commands/app/containers.rb ================================================ module Kamal::Commands::App::Containers DOCKER_HEALTH_LOG_FORMAT = "'{{json .State.Health}}'" def list_containers docker :container, :ls, "--all", *container_filter_args end def list_container_names [ *list_containers, "--format", "'{{ .Names }}'" ] end def remove_container(version:) pipe \ container_id_for(container_name: container_name(version)), xargs(docker(:container, :rm)) end def rename_container(version:, new_version:) docker :rename, container_name(version), container_name(new_version) end def remove_containers docker :container, :prune, "--force", *container_filter_args end def container_health_log(version:) pipe \ container_id_for(container_name: container_name(version)), xargs(docker(:inspect, "--format", DOCKER_HEALTH_LOG_FORMAT)) end end ================================================ FILE: lib/kamal/commands/app/error_pages.rb ================================================ module Kamal::Commands::App::ErrorPages def create_error_pages_directory make_directory(config.proxy_boot.error_pages_directory) end def clean_up_error_pages [ :find, config.proxy_boot.error_pages_directory, "-mindepth", "1", "-maxdepth", "1", "!", "-name", KAMAL.config.version, "-exec", "rm", "-rf", "{} +" ] end end ================================================ FILE: lib/kamal/commands/app/execution.rb ================================================ module Kamal::Commands::App::Execution def execute_in_existing_container(*command, interactive: false, env:) docker :exec, (docker_interactive_args if interactive), *argumentize("--env", env), container_name, *command end def execute_in_new_container(*command, interactive: false, detach: false, env:) docker :run, (docker_interactive_args if interactive), ("--detach" if detach), ("--rm" unless detach), "--name", container_name_for_exec, "--network", "kamal", *role&.env_args(host), *argumentize("--env", env), *role.logging_args, *config.volume_args, *role&.option_args, config.absolute_image, *command end def execute_in_existing_container_over_ssh(*command, env:) run_over_ssh execute_in_existing_container(*command, interactive: true, env: env), host: host end def execute_in_new_container_over_ssh(*command, env:) run_over_ssh execute_in_new_container(*command, interactive: true, env: env), host: host end private def container_name_for_exec [ role.container_prefix, "exec", config.version, SecureRandom.hex(3) ].compact.join("-") end end ================================================ FILE: lib/kamal/commands/app/images.rb ================================================ module Kamal::Commands::App::Images def list_images docker :image, :ls, config.repository end def remove_images docker :image, :prune, "--all", "--force", *image_filter_args end def tag_latest_image docker :tag, config.absolute_image, config.latest_image end end ================================================ FILE: lib/kamal/commands/app/logging.rb ================================================ module Kamal::Commands::App::Logging def logs(container_id: nil, timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil) pipe \ container_id_command(container_id), "xargs docker logs#{" --timestamps" if timestamps}#{" --since #{since}" if since}#{" --tail #{lines}" if lines} 2>&1", ("grep '#{grep}'#{" #{grep_options}" if grep_options}" if grep) end def follow_logs(host:, container_id: nil, timestamps: true, lines: nil, grep: nil, grep_options: nil) run_over_ssh \ pipe( container_id_command(container_id), "xargs docker logs#{" --timestamps" if timestamps}#{" --tail #{lines}" if lines} --follow 2>&1", (%(grep "#{grep}"#{" #{grep_options}" if grep_options}) if grep) ), host: host end private def container_id_command(container_id) case container_id when Array then container_id when String, Symbol then "echo #{container_id}" else current_running_container_id end end end ================================================ FILE: lib/kamal/commands/app/proxy.rb ================================================ module Kamal::Commands::App::Proxy delegate :container_name, to: :"config.proxy_boot", prefix: :proxy def deploy(target:) proxy_exec :deploy, role.container_prefix, *role.proxy.deploy_command_args(target: target) end def remove proxy_exec :remove, role.container_prefix end def live proxy_exec :resume, role.container_prefix end def maintenance(**options) proxy_exec :stop, role.container_prefix, *role.proxy.stop_command_args(**options) end def remove_proxy_app_directory remove_directory config.proxy_boot.app_directory end def create_ssl_directory make_directory(File.join(config.proxy_boot.tls_directory, role.name)) end private def proxy_exec(*command) docker :exec, proxy_container_name, "kamal-proxy", *command end end ================================================ FILE: lib/kamal/commands/app.rb ================================================ class Kamal::Commands::App < Kamal::Commands::Base include Assets, Containers, ErrorPages, Execution, Images, Logging, Proxy ACTIVE_DOCKER_STATUSES = [ :running, :restarting ] attr_reader :role, :host delegate :container_name, to: :role def initialize(config, role: nil, host: nil) super(config) @role = role @host = host end def run(hostname: nil) docker :run, "--detach", "--restart unless-stopped", "--name", container_name, "--network", "kamal", *([ "--hostname", hostname ] if hostname), "--env", "KAMAL_CONTAINER_NAME=\"#{container_name}\"", "--env", "KAMAL_VERSION=\"#{config.version}\"", "--env", "KAMAL_HOST=\"#{host}\"", *([ "--env", "KAMAL_DESTINATION=\"#{config.destination}\"" ] if config.destination), *role.env_args(host), *role.logging_args, *config.volume_args, *role.asset_volume_args, *role.label_args, *role.option_args, config.absolute_image, role.cmd end def start docker :start, container_name end def status(version:) pipe container_id_for_version(version), xargs(docker(:inspect, "--format", DOCKER_HEALTH_STATUS_FORMAT)) end def stop(version: nil) pipe \ version ? container_id_for_version(version) : current_running_container_id, xargs(docker(:stop, *role.stop_args)) end def info docker :ps, *container_filter_args end def current_running_container_id current_running_container(format: "--quiet") end def container_id_for_version(version, only_running: false) container_id_for(container_name: container_name(version), only_running: only_running) end def current_running_version pipe \ current_running_container(format: "--format '{{.Names}}'"), extract_version_from_name end def list_versions(*docker_args, statuses: nil) pipe \ docker(:ps, *container_filter_args(statuses: statuses), *docker_args, "--format", '"{{.Names}}"'), extract_version_from_name end def ensure_env_directory make_directory role.env_directory end private def latest_image_id docker :image, :ls, *argumentize("--filter", "reference=#{config.latest_image}"), "--format", "'{{.ID}}'" end def current_running_container(format:) pipe \ shell(chain(latest_image_container(format: format), latest_container(format: format))), [ :head, "-1" ] end def latest_image_container(format:) latest_container format: format, filters: [ "ancestor=$(#{latest_image_id.join(" ")})" ] end def latest_container(format:, filters: nil) docker :ps, "--latest", *format, *container_filter_args(statuses: ACTIVE_DOCKER_STATUSES), argumentize("--filter", filters) end def container_filter_args(statuses: nil) argumentize "--filter", container_filters(statuses: statuses) end def image_filter_args argumentize "--filter", image_filters end def extract_version_from_name # Extract SHA from "service-role-dest-SHA" %(while read line; do echo ${line##{role.container_prefix}-}; done) end def container_filters(statuses: nil) [ "label=service=#{config.service}" ].tap do |filters| filters << "label=destination=#{config.destination}" filters << "label=role=#{role}" if role statuses&.each do |status| filters << "status=#{status}" end end end def image_filters [ "label=service=#{config.service}" ] end end ================================================ FILE: lib/kamal/commands/auditor.rb ================================================ class Kamal::Commands::Auditor < Kamal::Commands::Base attr_reader :details delegate :escape_shell_value, to: Kamal::Utils def initialize(config, **details) super(config) @details = details end # Runs remotely def record(line, **details) combine \ make_run_directory, append([ :echo, escape_shell_value(audit_line(line, **details)) ], audit_log_file) end def reveal [ :tail, "-n", 50, audit_log_file ] end private def audit_log_file file = [ config.service, config.destination, "audit.log" ].compact.join("-") File.join(config.run_directory, file) end def audit_tags(**details) tags(**self.details, **details) end def make_run_directory [ :mkdir, "-p", config.run_directory ] end def audit_line(line, **details) "#{audit_tags(**details).except(:version, :service_version, :service)} #{line}" end end ================================================ FILE: lib/kamal/commands/base.rb ================================================ module Kamal::Commands class Base delegate :sensitive, :argumentize, to: Kamal::Utils DOCKER_HEALTH_STATUS_FORMAT = "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'" attr_accessor :config def initialize(config) @config = config end def run_over_ssh(*command, host:) "ssh#{ssh_config_args}#{ssh_proxy_args}#{ssh_keys_args} -t #{config.ssh.user}@#{host} -p #{config.ssh.port} '#{command.join(" ").gsub("'", "'\\\\''")}'" end def container_id_for(container_name:, only_running: false) docker :container, :ls, *("--all" unless only_running), "--filter", "'name=^#{container_name}$'", "--quiet" end def make_directory_for(remote_file) make_directory Pathname.new(remote_file).dirname.to_s end def make_directory(path) [ :mkdir, "-p", path ] end def remove_directory(path) [ :rm, "-r", path ] end def remove_file(path) [ :rm, path ] end def ensure_docker_installed combine \ ensure_local_docker_installed, ensure_local_buildx_installed end private def combine(*commands, by: "&&") commands .compact .collect { |command| Array(command) + [ by ] }.flatten # Join commands .tap { |commands| commands.pop } # Remove trailing combiner end def chain(*commands) combine *commands, by: ";" end def pipe(*commands) combine *commands, by: "|" end def append(*commands) combine *commands, by: ">>" end def write(*commands) combine *commands, by: ">" end def any(*commands) combine *commands, by: "||" end def substitute(*commands) "\$\(#{commands.join(" ")}\)" end def xargs(command) [ :xargs, command ].flatten end def shell(command) [ :sh, "-c", "'#{command.flatten.join(" ").gsub("'", "'\\\\''")}'" ] end def docker(*args) args.compact.unshift :docker end def pack(*args) args.compact.unshift :pack end def git(*args, path: nil) [ :git, *([ "-C", path ] if path), *args.compact ] end def grep(*args) args.compact.unshift :grep end def tags(**details) Kamal::Tags.from_config(config, **details) end def ssh_config_args case config.ssh.config when Array config.ssh.config.map { |file| " -F #{file}" }.join when String " -F #{config.ssh.config}" when true "" # Use default SSH config when false " -F /dev/null" # Ignore SSH config end end def ssh_proxy_args case config.ssh.proxy when Net::SSH::Proxy::Jump " -J #{config.ssh.proxy.jump_proxies}" when Net::SSH::Proxy::Command " -o ProxyCommand='#{config.ssh.proxy.command_line_template}'" end end def ssh_keys_args "#{ ssh_keys.join("") if ssh_keys}" + "#{" -o IdentitiesOnly=yes" if config.ssh&.keys_only}" end def ssh_keys config.ssh.keys&.map do |key| " -i #{key}" end end def ensure_local_docker_installed docker "--version" end def ensure_local_buildx_installed docker :buildx, "version" end def docker_interactive_args STDIN.isatty ? "-it" : "-i" end end end ================================================ FILE: lib/kamal/commands/builder/base.rb ================================================ class Kamal::Commands::Builder::Base < Kamal::Commands::Base class BuilderError < StandardError; end ENDPOINT_DOCKER_HOST_INSPECT = "'{{.Endpoints.docker.Host}}'" delegate :argumentize, to: Kamal::Utils delegate \ :args, :secrets, :dockerfile, :target, :arches, :local_arches, :remote_arches, :remote, :pack?, :pack_builder, :pack_buildpacks, :cache_from, :cache_to, :ssh, :provenance, :sbom, :driver, :docker_driver?, to: :builder_config def clean docker :image, :rm, "--force", config.absolute_image end def push(export_action = "registry", tag_as_dirty: false, no_cache: false) docker :buildx, :build, "--output=type=#{export_action}", *platform_options(arches), *([ "--builder", builder_name ] unless docker_driver?), *build_tag_options(tag_as_dirty: tag_as_dirty), *build_options, *([ "--no-cache" ] if no_cache), build_context, "2>&1" end def pull docker :pull, config.absolute_image end def info combine \ docker(:context, :ls), docker(:buildx, :ls) end def inspect_builder docker :buildx, :inspect, builder_name unless docker_driver? end def build_options [ *build_cache, *build_labels, *build_args, *build_secrets, *build_dockerfile, *build_target, *build_ssh, *builder_provenance, *builder_sbom ] end def build_context config.builder.context end def validate_image pipe \ docker(:inspect, "-f", "'{{ .Config.Labels.service }}'", config.absolute_image), any( [ :grep, "-x", config.service ], "(echo \"Image #{config.absolute_image} is missing the 'service' label\" && exit 1)" ) end def first_mirror docker(:info, "--format '{{index .RegistryConfig.Mirrors 0}}'") end def login_to_registry_locally? true end def push_env {} end private def build_tag_names(tag_as_dirty: false) tag_names = [ config.absolute_image, config.latest_image ] tag_names.map! { |t| "#{t}-dirty" } if tag_as_dirty tag_names end def build_tag_options(tag_as_dirty: false) build_tag_names(tag_as_dirty: tag_as_dirty).flat_map { |name| [ "-t", name ] } end def build_cache if cache_to && cache_from [ "--cache-to", cache_to, "--cache-from", cache_from ] end end def build_labels argumentize "--label", { service: config.service } end def build_args argumentize "--build-arg", args, sensitive: true end def build_secrets argumentize "--secret", secrets.keys.collect { |secret| [ "id", secret ] } end def build_dockerfile if Pathname.new(File.expand_path(dockerfile)).exist? argumentize "--file", dockerfile else raise BuilderError, "Missing #{dockerfile}" end end def build_target argumentize "--target", target if target.present? end def build_ssh argumentize "--ssh", ssh if ssh.present? end def builder_provenance argumentize "--provenance", provenance unless provenance.nil? end def builder_sbom argumentize "--sbom", sbom unless sbom.nil? end def builder_config config.builder end def registry_config config.registry end def driver_options if registry_config.local? [ "--driver-opt", "network=host" ] end end def platform_options(arches) argumentize "--platform", arches.map { |arch| "linux/#{arch}" }.join(",") if arches.any? end end ================================================ FILE: lib/kamal/commands/builder/clone.rb ================================================ module Kamal::Commands::Builder::Clone def clone git :clone, escaped_root, "--recurse-submodules", path: config.builder.clone_directory.shellescape end def clone_reset_steps [ git(:remote, "set-url", :origin, escaped_root, path: escaped_build_directory), git(:fetch, :origin, path: escaped_build_directory), git(:reset, "--hard", Kamal::Git.revision, path: escaped_build_directory), git(:clean, "-fdx", path: escaped_build_directory), git(:submodule, :update, "--init", path: escaped_build_directory) ] end def clone_status git :status, "--porcelain", path: escaped_build_directory end def clone_revision git :"rev-parse", :HEAD, path: escaped_build_directory end def escaped_root Kamal::Git.root.shellescape end def escaped_build_directory config.builder.build_directory.shellescape end end ================================================ FILE: lib/kamal/commands/builder/cloud.rb ================================================ class Kamal::Commands::Builder::Cloud < Kamal::Commands::Builder::Base # Expects `driver` to be of format "cloud docker-org-name/builder-name" def create docker :buildx, :create, "--driver", driver end def remove docker :buildx, :rm, builder_name end private def builder_name driver.gsub(/[ \/]/, "-") end def inspect_buildx pipe \ docker(:buildx, :inspect, builder_name), grep("-q", "Endpoint:.*cloud://.*") end end ================================================ FILE: lib/kamal/commands/builder/hybrid.rb ================================================ class Kamal::Commands::Builder::Hybrid < Kamal::Commands::Builder::Remote def create combine \ create_local_buildx, create_remote_context, append_remote_buildx end private def builder_name "kamal-hybrid-#{driver}-#{remote_builder_name_suffix}" end def create_local_buildx docker :buildx, :create, *platform_options(local_arches), "--name", builder_name, "--driver=#{driver}", *driver_options end def append_remote_buildx docker :buildx, :create, *platform_options(remote_arches), "--append", "--name", builder_name, *driver_options, remote_context_name end end ================================================ FILE: lib/kamal/commands/builder/local.rb ================================================ class Kamal::Commands::Builder::Local < Kamal::Commands::Builder::Base def create return if docker_driver? docker :buildx, :create, "--name", builder_name, "--driver=#{driver}", *driver_options end def remove docker :buildx, :rm, builder_name unless docker_driver? end private def builder_name if registry_config.local? "kamal-local-registry-#{driver}" else "kamal-local-#{driver}" end end end ================================================ FILE: lib/kamal/commands/builder/pack.rb ================================================ class Kamal::Commands::Builder::Pack < Kamal::Commands::Builder::Base def push(export_action = "registry", tag_as_dirty: false, no_cache: false) combine \ build(tag_as_dirty: tag_as_dirty, no_cache: no_cache), export(export_action) end def remove;end def info pack :builder, :inspect, pack_builder end alias_method :inspect_builder, :info private def build(tag_as_dirty: false, no_cache: false) pack(:build, config.repository, "--platform", platform, "--creation-time", "now", "--builder", pack_builder, buildpacks, *build_tag_options(tag_as_dirty: tag_as_dirty), *([ "--clear-cache" ] if no_cache), "--env", "BP_IMAGE_LABELS=service=#{config.service}", *argumentize("--env", args), *argumentize("--env", secrets, sensitive: true), "--path", build_context) end def export(export_action) return unless export_action == "registry" combine \ docker(:push, config.absolute_image), docker(:push, config.latest_image) end def platform "linux/#{local_arches.first}" end def buildpacks (pack_buildpacks << "paketo-buildpacks/image-labels").map { |buildpack| [ "--buildpack", buildpack ] } end end ================================================ FILE: lib/kamal/commands/builder/remote.rb ================================================ class Kamal::Commands::Builder::Remote < Kamal::Commands::Builder::Base def create chain \ create_remote_context, create_buildx end def remove chain \ remove_remote_context, remove_buildx end def info chain \ docker(:context, :ls), docker(:buildx, :ls) end def inspect_builder combine \ combine(inspect_buildx, inspect_remote_context), [ "(echo no compatible builder && exit 1)" ], by: "||" end def login_to_registry_locally? false end def push_env { "BUILDKIT_NO_CLIENT_TOKEN" => "1" } end private def builder_name "kamal-remote-#{remote_builder_name_suffix}" end def remote_context_name "#{builder_name}-context" end def remote_builder_name_suffix "#{remote.gsub(/[^a-z0-9_-]/, "-")}#{registry_config.local? ? "-local-registry" : "" }" end def inspect_buildx pipe \ docker(:buildx, :inspect, builder_name), grep("-q", "Endpoint:.*#{remote_context_name}") end def inspect_remote_context pipe \ docker(:context, :inspect, remote_context_name, "--format", ENDPOINT_DOCKER_HOST_INSPECT), grep("-xq", remote) end def create_remote_context docker :context, :create, remote_context_name, "--description", "'#{builder_name} host'", "--docker", "'host=#{remote}'" end def remove_remote_context docker :context, :rm, remote_context_name end def create_buildx docker :buildx, :create, "--name", builder_name, *driver_options, remote_context_name end def remove_buildx docker :buildx, :rm, builder_name end end ================================================ FILE: lib/kamal/commands/builder.rb ================================================ require "active_support/core_ext/string/filters" class Kamal::Commands::Builder < Kamal::Commands::Base delegate \ :create, :remove, :dev, :push, :clean, :pull, :info, :inspect_builder, :validate_image, :first_mirror, :login_to_registry_locally?, :push_env, to: :target delegate \ :local?, :remote?, :pack?, :cloud?, to: "config.builder" include Clone def name target.class.to_s.remove("Kamal::Commands::Builder::").underscore.inquiry end def target if remote? if local? hybrid else remote end elsif pack? pack elsif cloud? cloud else local end end def remote @remote ||= Kamal::Commands::Builder::Remote.new(config) end def local @local ||= Kamal::Commands::Builder::Local.new(config) end def hybrid @hybrid ||= Kamal::Commands::Builder::Hybrid.new(config) end def pack @pack ||= Kamal::Commands::Builder::Pack.new(config) end def cloud @cloud ||= Kamal::Commands::Builder::Cloud.new(config) end end ================================================ FILE: lib/kamal/commands/docker.rb ================================================ class Kamal::Commands::Docker < Kamal::Commands::Base # Install Docker using the https://github.com/docker/docker-install convenience script. def install pipe get_docker, :sh end # Checks the Docker client version. Fails if Docker is not installed. def installed? docker "-v" end # Checks the Docker server version. Fails if Docker is not running. def running? docker :version end # Do we have superuser access to install Docker and start system services? def superuser? [ '[ "${EUID:-$(id -u)}" -eq 0 ] || sudo -nl usermod >/dev/null' ] end def root? [ '[ "${EUID:-$(id -u)}" -eq 0 ]' ] end def in_docker_group? [ 'id -nG "${USER:-$(id -un)}" | grep -qw docker' ] end def add_to_docker_group [ 'sudo -n usermod -aG docker "${USER:-$(id -un)}"' ] end def refresh_session [ "kill -HUP $PPID" ] end def create_network docker :network, :create, :kamal end private def get_docker shell \ any \ [ :curl, "-fsSL", "https://get.docker.com" ], [ :wget, "-O -", "https://get.docker.com" ], [ :echo, "\"exit 1\"" ] end end ================================================ FILE: lib/kamal/commands/hook.rb ================================================ class Kamal::Commands::Hook < Kamal::Commands::Base def run(hook) [ hook_file(hook) ] end def env(secrets: false, **details) tags(**details).env.tap do |env| env.merge!(config.secrets.to_h) if secrets end end def hook_exists?(hook) Pathname.new(hook_file(hook)).exist? end private def hook_file(hook) File.join(config.hooks_path, hook) end end ================================================ FILE: lib/kamal/commands/lock.rb ================================================ require "active_support/duration" require "time" require "base64" class Kamal::Commands::Lock < Kamal::Commands::Base def acquire(message, version) combine \ [ :mkdir, lock_dir ], write_lock_details(message, version) end def release combine \ [ :rm, lock_details_file ], [ :rm, "-r", lock_dir ] end def status combine \ stat_lock_dir, read_lock_details end def ensure_locks_directory [ :mkdir, "-p", locks_dir ] end private def write_lock_details(message, version) write \ [ :echo, "\"#{Base64.encode64(lock_details(message, version))}\"" ], lock_details_file end def read_lock_details pipe \ [ :cat, lock_details_file ], [ :base64, "-d" ] end def stat_lock_dir write \ [ :stat, lock_dir ], "/dev/null" end def lock_dir dir_name = [ "lock", config.service, config.destination ].compact.join("-") File.join(config.run_directory, dir_name) end def lock_details_file File.join(lock_dir, "details") end def lock_details(message, version) <<~DETAILS.strip Locked by: #{locked_by} at #{Time.now.utc.iso8601} Version: #{version} Message: #{message} DETAILS end def locked_by Kamal::Git.user_name rescue Errno::ENOENT "Unknown" end end ================================================ FILE: lib/kamal/commands/proxy.rb ================================================ class Kamal::Commands::Proxy < Kamal::Commands::Base delegate :argumentize, :optionize, to: Kamal::Utils attr_reader :proxy_run_config def initialize(config, host:) super(config) @proxy_run_config = config.proxy_run(host) end def run if proxy_run_config docker \ :run, "--name", container_name, "--network", "kamal", "--detach", "--restart", "unless-stopped", "--volume", "kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy", *proxy_run_config.docker_options_args, *proxy_run_config.image, *proxy_run_config.run_command else pipe boot_config, xargs(docker_run) end end def start docker :container, :start, container_name end def stop(name: container_name) docker :container, :stop, name end def start_or_run combine start, run, by: "||" end def info docker :ps, "--filter", "'name=^#{container_name}$'" end def version pipe \ docker(:inspect, container_name, "--format '{{.Config.Image}}'"), [ :awk, "-F:", "'{print \$NF}'" ] end def logs(timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil) pipe \ docker(:logs, container_name, ("--since #{since}" if since), ("--tail #{lines}" if lines), ("--timestamps" if timestamps), "2>&1"), ("grep '#{grep}'#{" #{grep_options}" if grep_options}" if grep) end def follow_logs(host:, timestamps: true, grep: nil, grep_options: nil) run_over_ssh pipe( docker(:logs, container_name, ("--timestamps" if timestamps), "--tail", "10", "--follow", "2>&1"), (%(grep "#{grep}"#{" #{grep_options}" if grep_options}) if grep) ).join(" "), host: host end def remove_container docker :container, :prune, "--force", "--filter", "label=org.opencontainers.image.title=kamal-proxy" end def remove_image docker :image, :prune, "--all", "--force", "--filter", "label=org.opencontainers.image.title=kamal-proxy" end def cleanup_traefik chain \ docker(:container, :stop, "traefik"), combine( docker(:container, :prune, "--force", "--filter", "label=org.opencontainers.image.title=Traefik"), docker(:image, :prune, "--all", "--force", "--filter", "label=org.opencontainers.image.title=Traefik") ) end def ensure_proxy_directory make_directory config.proxy_boot.host_directory end def remove_proxy_directory remove_directory config.proxy_boot.host_directory end def ensure_apps_config_directory make_directory config.proxy_boot.apps_directory end def boot_config [ :echo, "#{substitute(read_boot_options)} #{substitute(read_image)}:#{substitute(read_image_version)} #{substitute(read_run_command)}" ] end def read_boot_options read_file(config.proxy_boot.options_file, default: config.proxy_boot.default_boot_options.join(" ")) end def read_image read_file(config.proxy_boot.image_file, default: config.proxy_boot.image_default) end def read_image_version read_file(config.proxy_boot.image_version_file, default: Kamal::Configuration::Proxy::Run::MINIMUM_VERSION) end def read_run_command read_file(config.proxy_boot.run_command_file) end def reset_boot_options remove_file config.proxy_boot.options_file end def reset_image remove_file config.proxy_boot.image_file end def reset_image_version remove_file config.proxy_boot.image_version_file end def reset_run_command remove_file config.proxy_boot.run_command_file end private def container_name config.proxy_boot.container_name end def read_file(file, default: nil) combine [ :cat, file, "2>", "/dev/null" ], [ :echo, "\"#{default}\"" ], by: "||" end def docker_run docker \ :run, "--name", container_name, "--network", "kamal", "--detach", "--restart", "unless-stopped", "--volume", "kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy", *config.proxy_boot.apps_volume.docker_args end end ================================================ FILE: lib/kamal/commands/prune.rb ================================================ require "active_support/duration" require "active_support/core_ext/numeric/time" class Kamal::Commands::Prune < Kamal::Commands::Base def dangling_images docker :image, :prune, "--force", "--filter", "label=service=#{config.service}" end def tagged_images pipe \ docker(:image, :ls, *service_filter, "--format", "'{{.ID}} {{.Repository}}:{{.Tag}}'"), grep("-v -w \"#{active_image_list}\""), "while read image tag; do docker rmi $tag; done" end def app_containers(retain:) pipe \ docker(:ps, "-q", "-a", *service_filter, *stopped_containers_filters), "tail -n +#{retain + 1}", "while read container_id; do docker rm $container_id; done" end private def stopped_containers_filters [ "created", "exited", "dead" ].flat_map { |status| [ "--filter", "status=#{status}" ] } end def active_image_list # Pull the images that are used by any containers # Append repo:latest - to avoid deleting the latest tag # Append repo: - to avoid deleting dangling images that are in use. Unused dangling images are deleted separately "$(docker container ls -a --format '{{.Image}}\\|' --filter label=service=#{config.service} | tr -d '\\n')#{config.latest_image}\\|#{config.repository}:" end def service_filter [ "--filter", "label=service=#{config.service}" ] end end ================================================ FILE: lib/kamal/commands/registry.rb ================================================ class Kamal::Commands::Registry < Kamal::Commands::Base def login(registry_config: nil) registry_config ||= config.registry return if registry_config.local? docker :login, registry_config.server, "-u", sensitive(Kamal::Utils.escape_shell_value(registry_config.username)), "-p", sensitive(Kamal::Utils.escape_shell_value(registry_config.password)) end def logout(registry_config: nil) registry_config ||= config.registry docker :logout, registry_config.server end def setup(registry_config: nil) registry_config ||= config.registry combine \ docker(:start, "kamal-docker-registry"), docker(:run, "--detach", "-p", "127.0.0.1:#{registry_config.local_port}:5000", "--name", "kamal-docker-registry", "registry:3"), by: "||" end def remove combine \ docker(:stop, "kamal-docker-registry"), docker(:rm, "kamal-docker-registry"), by: "&&" end def local? config.registry.local? end end ================================================ FILE: lib/kamal/commands/server.rb ================================================ class Kamal::Commands::Server < Kamal::Commands::Base def ensure_run_directory make_directory config.run_directory end def remove_app_directory remove_directory config.app_directory end def app_directory_count pipe \ [ :ls, config.apps_directory ], [ :wc, "-l" ] end end ================================================ FILE: lib/kamal/commands.rb ================================================ module Kamal::Commands end ================================================ FILE: lib/kamal/configuration/accessory.rb ================================================ class Kamal::Configuration::Accessory include Kamal::Configuration::Validation DEFAULT_NETWORK = "kamal" delegate :argumentize, :optionize, to: Kamal::Utils attr_reader :name, :env, :proxy, :registry def initialize(name, config:) @name, @config, @accessory_config = name.inquiry, config, config.raw_config["accessories"][name] validate! \ accessory_config, example: validation_yml["accessories"]["mysql"], context: "accessories/#{name}", with: Kamal::Configuration::Validator::Accessory ensure_valid_roles @env = initialize_env @proxy = initialize_proxy if running_proxy? @registry = initialize_registry if accessory_config["registry"].present? end def service_name accessory_config["service"] || "#{config.service}-#{name}" end def image [ registry&.server, accessory_config["image"] ].compact.join("/") end def hosts hosts_from_host || hosts_from_hosts || hosts_from_roles || hosts_from_tags end def port if port = accessory_config["port"]&.to_s port.include?(":") ? port : "#{port}:#{port}" end end def network_args argumentize "--network", network end def publish_args argumentize "--publish", port if port end def labels default_labels.merge(accessory_config["labels"] || {}) end def label_args argumentize "--label", labels end def env_args [ *env.clear_args, *argumentize("--env-file", secrets_path) ] end def env_directory File.join(config.env_directory, "accessories") end def secrets_io env.secrets_io end def secrets_path File.join(config.env_directory, "accessories", "#{name}.env") end def files accessory_config["files"]&.to_h do |config| parse_path_config(config, default_mode: "755") do |local, remote| { key: expand_local_file(local), host_path: expand_remote_file(remote), container_path: remote } end end || {} end def directories accessory_config["directories"]&.to_h do |config| parse_path_config(config, default_mode: nil) do |local, remote| { key: expand_host_path(local), host_path: expand_host_path_for_volume(local), container_path: remote } end end || {} end def volume_args argumentize("--volume", specific_volumes) + (path_volumes(files) + path_volumes(directories)).flat_map(&:docker_args) end def option_args if args = accessory_config["options"] optionize args else [] end end def cmd accessory_config["cmd"] end def running_proxy? accessory_config["proxy"].present? end private attr_reader :config, :accessory_config def initialize_env Kamal::Configuration::Env.new \ config: accessory_config.fetch("env", {}), secrets: config.secrets, context: "accessories/#{name}/env" end def initialize_proxy Kamal::Configuration::Proxy.new \ config: config, proxy_config: accessory_config["proxy"], context: "accessories/#{name}/proxy", secrets: config.secrets end def initialize_registry Kamal::Configuration::Registry.new \ config: accessory_config, secrets: config.secrets, context: "accessories/#{name}/registry" end def default_labels { "service" => service_name } end def expand_local_file(local_file) if local_file.end_with?("erb") with_env_loaded { read_dynamic_file(local_file) } else Pathname.new(File.expand_path(local_file)).to_s end end def with_env_loaded env.to_h.each { |k, v| ENV[k] = v } yield ensure env.to_h.each { |k, v| ENV.delete(k) } end def read_dynamic_file(local_file) StringIO.new(ERB.new(File.read(local_file)).result) end def expand_remote_file(remote_file) service_name + remote_file end def specific_volumes accessory_config["volumes"] || [] end def path_volumes(paths) paths.map do |local, config| Kamal::Configuration::Volume.new \ host_path: config[:host_path], container_path: config[:container_path], options: config[:options] end end def parse_path_config(config, default_mode:) if config.is_a?(Hash) local, remote = config["local"], config["remote"] expanded = yield(local, remote) [ expanded[:key], expanded.except(:key).merge( options: config["options"], mode: config["mode"] || default_mode, owner: config["owner"] ) ] else local, remote, options = config.split(":", 3) expanded = yield(local, remote) [ expanded[:key], expanded.except(:key).merge( options: options, mode: default_mode, owner: nil ) ] end end def expand_host_path(host_path) absolute_path?(host_path) ? host_path : File.join(service_data_directory, host_path) end def expand_host_path_for_volume(host_path) absolute_path?(host_path) ? host_path : File.join(service_name, host_path) end def absolute_path?(path) Pathname.new(path).absolute? end def service_data_directory "$PWD/#{service_name}" end def hosts_from_host [ accessory_config["host"] ] if accessory_config.key?("host") end def hosts_from_hosts accessory_config["hosts"] if accessory_config.key?("hosts") end def hosts_from_roles if accessory_config.key?("role") config.role(accessory_config["role"])&.hosts elsif accessory_config.key?("roles") accessory_config["roles"].flat_map { |role| config.role(role)&.hosts } end end def hosts_from_tags if accessory_config.key?("tag") extract_hosts_from_config_with_tag(accessory_config["tag"]) elsif accessory_config.key?("tags") accessory_config["tags"].flat_map { |tag| extract_hosts_from_config_with_tag(tag) } end end def extract_hosts_from_config_with_tag(tag) if (servers_with_roles = config.raw_config.servers).is_a?(Hash) servers_with_roles.flat_map do |role, servers_in_role| servers_in_role.filter_map do |host| host.keys.first if host.is_a?(Hash) && host.values.first.include?(tag) end end end end def network accessory_config["network"] || DEFAULT_NETWORK end def ensure_valid_roles if accessory_config["roles"] && (missing_roles = accessory_config["roles"] - config.roles.map(&:name)).any? raise Kamal::ConfigurationError, "accessories/#{name}: unknown roles #{missing_roles.join(", ")}" elsif accessory_config["role"] && !config.role(accessory_config["role"]) raise Kamal::ConfigurationError, "accessories/#{name}: unknown role #{accessory_config["role"]}" end end end ================================================ FILE: lib/kamal/configuration/alias.rb ================================================ class Kamal::Configuration::Alias include Kamal::Configuration::Validation attr_reader :name, :command def initialize(name, config:) @name, @command = name.inquiry, config.raw_config["aliases"][name] validate! \ command, example: validation_yml["aliases"]["uname"], context: "aliases/#{name}", with: Kamal::Configuration::Validator::Alias end end ================================================ FILE: lib/kamal/configuration/boot.rb ================================================ class Kamal::Configuration::Boot include Kamal::Configuration::Validation attr_reader :boot_config, :host_count def initialize(config:) @boot_config = config.raw_config.boot || {} @host_count = config.all_hosts.count validate! boot_config end def limit limit = boot_config["limit"] if limit.to_s.end_with?("%") [ host_count * limit.to_i / 100, 1 ].max else limit end end def wait boot_config["wait"] end def parallel_roles boot_config["parallel_roles"] end end ================================================ FILE: lib/kamal/configuration/builder.rb ================================================ class Kamal::Configuration::Builder include Kamal::Configuration::Validation attr_reader :config, :builder_config delegate :image, :service, to: :config delegate :server, to: :"config.registry" def initialize(config:) @config = config @builder_config = config.raw_config.builder || {} @image = config.image @server = config.registry.server @service = config.service validate! builder_config, with: Kamal::Configuration::Validator::Builder end def to_h builder_config end def remote builder_config["remote"] end def arches Array(builder_config.fetch("arch", default_arch)) end def local_arches @local_arches ||= if local_disabled? [] elsif remote arches & [ Kamal::Utils.docker_arch ] else arches end end def remote_arches @remote_arches ||= if remote arches - local_arches else [] end end def remote? remote_arches.any? end def local? !local_disabled? && (arches.empty? || local_arches.any?) end def cloud? driver.start_with? "cloud" end def cached? !!builder_config["cache"] end def pack? !!builder_config["pack"] end def args builder_config["args"] || {} end def secrets (builder_config["secrets"] || []).to_h { |key| [ key, config.secrets[key] ] } end def dockerfile builder_config["dockerfile"] || "Dockerfile" end def target builder_config["target"] end def context builder_config["context"] || "." end def driver builder_config.fetch("driver", "docker-container") end def pack_builder builder_config["pack"]["builder"] if pack? end def pack_buildpacks builder_config["pack"]["buildpacks"] if pack? end def local_disabled? builder_config["local"] == false end def cache_from if cached? case builder_config["cache"]["type"] when "gha" cache_from_config_for_gha when "registry" cache_from_config_for_registry end end end def cache_to if cached? case builder_config["cache"]["type"] when "gha" cache_to_config_for_gha when "registry" cache_to_config_for_registry end end end def ssh builder_config["ssh"] end def provenance builder_config["provenance"] end def sbom builder_config["sbom"] end def git_clone? Kamal::Git.used? && builder_config["context"].nil? end def clone_directory @clone_directory ||= File.join Dir.tmpdir, "kamal-clones", [ service, pwd_sha ].compact.join("-") end def build_directory @build_directory ||= if git_clone? File.join clone_directory, repo_basename, repo_relative_pwd else "." end end def docker_driver? driver == "docker" end private def valid? if docker_driver? raise ArgumentError, "Invalid builder configuration: the `docker` driver does not not support remote builders" if remote raise ArgumentError, "Invalid builder configuration: the `docker` driver does not not support caching" if cached? raise ArgumentError, "Invalid builder configuration: the `docker` driver does not not support multiple arches" if arches.many? end if @options["cache"] && @options["cache"]["type"] raise ArgumentError, "Invalid cache type: #{@options["cache"]["type"]}" unless [ "gha", "registry" ].include?(@options["cache"]["type"]) end end def cache_image builder_config["cache"]&.fetch("image", nil) || "#{image}-build-cache" end def cache_image_ref [ server, cache_image ].compact.join("/") end def cache_options builder_config["cache"]&.fetch("options", nil) end def cache_from_config_for_gha individual_options = cache_options&.split(",") || [] allowed_options = individual_options.select { |option| option =~ /^(url|url_v2|token|scope|timeout)=/ } [ "type=gha", *allowed_options ].compact.join(",") end def cache_from_config_for_registry [ "type=registry", "ref=#{cache_image_ref}" ].compact.join(",") end def cache_to_config_for_gha [ "type=gha", cache_options ].compact.join(",") end def cache_to_config_for_registry [ "type=registry", "ref=#{cache_image_ref}", cache_options ].compact.join(",") end def repo_basename File.basename(Kamal::Git.root) end def repo_relative_pwd Dir.pwd.delete_prefix(Kamal::Git.root) end def pwd_sha Digest::SHA256.hexdigest(Dir.pwd)[0..12] end def default_arch docker_driver? ? [] : [ "amd64", "arm64" ] end end ================================================ FILE: lib/kamal/configuration/docs/accessory.yml ================================================ # Accessories # # Accessories can be booted on a single host, a list of hosts, or on specific roles. # The hosts do not need to be defined in the Kamal servers configuration. # # Accessories are managed separately from the main service — they are not updated # when you deploy, and they do not have zero-downtime deployments. # # Run `kamal accessory boot ` to boot an accessory. # See `kamal accessory --help` for more information. # Configuring accessories # # First, define the accessory in the `accessories`: accessories: mysql: # Service name # # This is used in the service label and defaults to `-`, # where `` is the main service name from the root configuration: service: mysql # Image # # The Docker image to use. # Prefix it with its server when using root level registry different from Docker Hub. # Define registry directly or via anchors when it differs from root level registry. image: mysql:8.0 # Registry # # By default accessories use Docker Hub registry. # You can specify different registry per accessory with this option. # Don't prefix image with this registry server. # Use anchors if you need to set the same specific registry for several accessories. # # ```yml # registry: # <<: *specific-registry # ``` # # See kamal docs registry for more information: registry: ... # Accessory hosts # # Specify one of `host`, `hosts`, `role`, `roles`, `tag` or `tags`: host: mysql-db1 hosts: - mysql-db1 - mysql-db2 role: mysql roles: - mysql tag: writer tags: - writer - reader # Custom command # # You can set a custom command to run in the container if you do not want to use the default: cmd: "bin/mysqld" # Port mappings # # See [https://docs.docker.com/network/](https://docs.docker.com/network/), and # especially note the warning about the security implications of exposing ports publicly. port: "127.0.0.1:3306:3306" # Labels labels: app: myapp # Options # # These are passed to the Docker run command in the form `-- `: options: restart: always cpus: 2 # Environment variables # # See kamal docs env for more information: env: ... # Copying files # # You can specify files to mount into the container. # # They will be uploaded from the local repo to the host and then mounted. # ERB files will be evaluated before being copied. # # You can use the string format: `local:remote` or `local:remote:options` # where the options can be `ro` for read-only or `z`/`Z` for SELinux labels files: - config/my.cnf.erb:/etc/mysql/my.cnf - config/myoptions.cnf:/etc/mysql/myoptions.cnf:ro - config/certs:/etc/mysql/certs:ro,Z # # Or you can use the hash format for custom mode and ownership. # # Note: Setting `owner` requires root access: files: - local: config/secret.key remote: /etc/mysql/secret.key mode: "0600" owner: "mysql:mysql" - local: config/ca-cert.pem remote: /etc/mysql/certs/ca-cert.pem mode: "0644" owner: "1000:1000" options: "Z" # Directories # # You can specify directories to mount into the container. They will be created on the host # before being mounted. # # You can use the string format: `local:remote` or `local:remote:options` # where the options can be `ro` for read-only or `z`/`Z` for SELinux labels directories: - mysql-logs:/var/log/mysql - mysql-data:/var/lib/mysql:z # # Or you can use the hash format for custom mode and ownership. # # Note: Setting `owner` requires root access: directories: - local: mysql-data remote: /var/lib/mysql mode: "0750" owner: "mysql:mysql" - local: mysql-logs remote: /var/log/mysql mode: "0755" options: "z" # Volumes # # Any other volumes to mount, in addition to the files and directories. # They are not created or copied before mounting: volumes: - /path/to/mysql-logs:/var/log/mysql # Network # # The network the accessory will be attached to. # # Defaults to kamal: network: custom # Proxy # # You can run your accessory behind the Kamal proxy. See kamal docs proxy for more information proxy: ... ================================================ FILE: lib/kamal/configuration/docs/alias.yml ================================================ # Aliases # # Aliases are shortcuts for Kamal commands. # # For example, for a Rails app, you might open a console with: # # ```shell # kamal app exec -i --reuse "bin/rails console" # ``` # # By defining an alias, like this: aliases: console: app exec -i --reuse "bin/rails console" # You can now open the console with: # # ```shell # kamal console # ``` # Configuring aliases # # Aliases are defined in the root config under the alias key. # # Each alias is named and can only contain lowercase letters, numbers, dashes, and underscores: aliases: uname: app exec -p -q -r web "uname -a" # # Aliases can include a destination with the `-d` flag: staging_deploy: deploy -d staging ================================================ FILE: lib/kamal/configuration/docs/boot.yml ================================================ # Booting # # When deploying to large numbers of hosts, you might prefer not to restart your services on every host at the same time. # # Kamal’s default is to boot new containers on all hosts in parallel. However, you can control this with the boot configuration. boot: # The number or percentage of hosts to boot at a time. # This can be an integer (e.g., 3) or a percentage string (e.g., 25%). limit: 25% # The number of seconds to wait between booting each group of hosts. wait: 10 # Whether to boot roles in parallel on a host. # # If a host has multiple roles, control whether they are booted in parallel or sequentially on that host. # # Defaults to false. parallel_roles: true ================================================ FILE: lib/kamal/configuration/docs/builder.yml ================================================ # Builder # # The builder configuration controls how the application is built with `docker build`. # # See https://kamal-deploy.org/docs/configuration/builder-examples/ for more information. # Builder options # # Options go under the builder key in the root configuration. builder: # Arch # # The architectures to build for — you can set an array or just a single value. # # Allowed values are `amd64` and `arm64`: arch: - amd64 # Remote # # The connection string for a remote builder. If supplied, Kamal will use this # for builds that do not match the local architecture of the deployment host. remote: ssh://docker@docker-builder # Local # # If set to false, Kamal will always use the remote builder even when building # the local architecture. # # Defaults to true: local: true # Buildpack configuration # # The build configuration for using pack to build a Cloud Native Buildpack image. # # For additional buildpack customization options you can create a project descriptor # file(project.toml) that the Pack CLI will automatically use. # See https://buildpacks.io/docs/for-app-developers/how-to/build-inputs/use-project-toml/ for more information. pack: builder: heroku/builder:24 buildpacks: - heroku/ruby - heroku/procfile # Builder cache # # The type must be either 'gha' or 'registry'. # # The image is only used for registry cache and is not compatible with the Docker driver: cache: type: registry options: mode=max image: kamal-app-build-cache # Build context # # If this is not set, then a local Git clone of the repo is used. # This ensures a clean build with no uncommitted changes. # # To use the local checkout instead, you can set the context to `.`, or a path to another directory. context: . # Dockerfile # # The Dockerfile to use for building, defaults to `Dockerfile`: dockerfile: Dockerfile.production # Build target # # If not set, then the default target is used: target: production # Build arguments # # Any additional build arguments, passed to `docker build` with `--build-arg =`: args: ENVIRONMENT: production # Referencing build arguments # # ```shell # ARG RUBY_VERSION # FROM ruby:$RUBY_VERSION-slim as base # ``` # Build secrets # # Values are read from `.kamal/secrets`: secrets: - SECRET1 - SECRET2 # Referencing build secrets # # ```shell # # Copy Gemfiles # COPY Gemfile Gemfile.lock ./ # # # Install dependencies, including private repositories via access token # # Then remove bundle cache with exposed GITHUB_TOKEN # RUN --mount=type=secret,id=GITHUB_TOKEN \ # BUNDLE_GITHUB__COM=x-access-token:$(cat /run/secrets/GITHUB_TOKEN) \ # bundle install && \ # rm -rf /usr/local/bundle/cache # ``` # SSH # # SSH agent socket or keys to expose to the build: ssh: default=$SSH_AUTH_SOCK # Driver # # The build driver to use, defaults to `docker-container`: driver: docker # # If you want to use Docker Build Cloud (https://www.docker.com/products/build-cloud/), you can set the driver to: driver: cloud org-name/builder-name # Provenance # # It is used to configure provenance attestations for the build result. # The value can also be a boolean to enable or disable provenance attestations. provenance: mode=max # SBOM (Software Bill of Materials) # # It is used to configure SBOM generation for the build result. # The value can also be a boolean to enable or disable SBOM generation. sbom: true ================================================ FILE: lib/kamal/configuration/docs/configuration.yml ================================================ # Kamal Configuration # # Configuration is read from the `config/deploy.yml`. # Destinations # # When running commands, you can specify a destination with the `-d` flag, # e.g., `kamal deploy -d staging`. # # In this case, the configuration will also be read from `config/deploy.staging.yml` # and merged with the base configuration. # Extensions # # Kamal will not accept unrecognized keys in the configuration file. # # However, you might want to declare a configuration block using YAML anchors # and aliases to avoid repetition. # # You can prefix a configuration section with `x-` to indicate that it is an # extension. Kamal will ignore the extension and not raise an error. # The service name # # This is a required value. It is used as the container name prefix. service: myapp # The Docker image name # # The image will be pushed to the configured registry. image: my-image # Labels # # Additional labels to add to the container: labels: my-label: my-value # Volumes # # Additional volumes to mount into the container: volumes: - /path/on/host:/path/in/container:ro # Registry # # The Docker registry configuration, see kamal docs registry: registry: ... # Servers # # The servers to deploy to, optionally with custom roles, see kamal docs servers: servers: ... # Environment variables # # See kamal docs env: env: ... # Asset path # # Used for asset bridging across deployments, default to `nil`. # # If there are changes to CSS or JS files, we may get requests # for the old versions on the new container, and vice versa. # # To avoid 404s, we can specify an asset path. # Kamal will replace that path in the container with a mapped # volume containing both sets of files. # This requires that file names change when the contents change # (e.g., by including a hash of the contents in the name). # # To configure this, set the path to the assets. # # You can also specify mount options after a colon, such as `ro` for read-only # or `z`/`Z` for SELinux labels asset_path: /path/to/assets # Hooks path # # Path to hooks, defaults to `.kamal/hooks`. # See https://kamal-deploy.org/docs/hooks for more information: hooks_path: /user_home/kamal/hooks # Hook output # # Hook output visibility. Can be set globally or per-hook. # CLI flags (`-v`, `-q`) override these settings. # # - `:quiet` - hook output is hidden # - `:verbose` - hook output is shown # # With no setting, hook output follows CLI verbosity flags. # # Note: Failed hooks always show output in the error message regardless of setting. # # Global setting for all hooks: hooks_output: :verbose # Or per-hook settings: hooks_output: pre-deploy: :verbose pre-build: :quiet # Secrets path # # Path to secrets, defaults to `.kamal/secrets`. # Kamal will look for `-common` and `` (or `.` when using destinations): secrets_path: /user_home/kamal/secrets # Error pages # # A directory relative to the app root to find error pages for the proxy to serve. # Any files in the format 4xx.html or 5xx.html will be copied to the hosts. error_pages_path: public # Require destinations # # Whether deployments require a destination to be specified, defaults to `false`: require_destination: true # Primary role # # This defaults to `web`, but if you have no web role, you can change this: primary_role: workers # Allowing empty roles # # Whether roles with no servers are allowed. Defaults to `false`: allow_empty_roles: false # Retain containers # # How many old containers and images we retain, defaults to 5: retain_containers: 3 # Minimum version # # The minimum version of Kamal required to deploy this configuration, defaults to `nil`: minimum_version: 1.3.0 # Readiness delay # # Seconds to wait for a container to boot after it is running, default 7. # # This only applies to containers that do not run a proxy or specify a healthcheck: readiness_delay: 4 # Deploy timeout # # How long to wait for a container to become ready, default 30: deploy_timeout: 10 # Drain timeout # # How long to wait for a container to drain, default 30: drain_timeout: 10 # Run directory # # Directory to store kamal runtime files in on the host, default `.kamal`: run_directory: /etc/kamal # SSH options # # See kamal docs ssh: ssh: ... # Builder options # # See kamal docs builder: builder: ... # Accessories # # Additional services to run in Docker, see kamal docs accessory: accessories: ... # Proxy # # Configuration for kamal-proxy, see kamal docs proxy: proxy: ... # SSHKit # # See kamal docs sshkit: sshkit: ... # Boot options # # See kamal docs boot: boot: ... # Logging # # Docker logging configuration, see kamal docs logging: logging: ... # Aliases # # Alias configuration, see kamal docs alias: aliases: ... ================================================ FILE: lib/kamal/configuration/docs/env.yml ================================================ # Environment variables # # Environment variables can be set directly in the Kamal configuration or # read from `.kamal/secrets`. # Reading environment variables from the configuration # # Environment variables can be set directly in the configuration file. # # These are passed to the `docker run` command when deploying. env: DATABASE_HOST: mysql-db1 DATABASE_PORT: 3306 # Secrets # # Kamal uses dotenv to automatically load environment variables set in the `.kamal/secrets` file. # # If you are using destinations, secrets will instead be read from `.kamal/secrets.` if # it exists. # # Common secrets across all destinations can be set in `.kamal/secrets-common`. # # This file can be used to set variables like `KAMAL_REGISTRY_PASSWORD` or database passwords. # You can use variable or command substitution in the secrets file. # # ```shell # KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD # RAILS_MASTER_KEY=$(cat config/master.key) # ``` # # You can also use [secret helpers](../../commands/secrets) for some common password managers. # # ```shell # SECRETS=$(kamal secrets fetch ...) # # REGISTRY_PASSWORD=$(kamal secrets extract REGISTRY_PASSWORD $SECRETS) # DB_PASSWORD=$(kamal secrets extract DB_PASSWORD $SECRETS) # ``` # # If you store secrets directly in `.kamal/secrets`, ensure that it is not checked into version control. # # To pass the secrets, you should list them under the `secret` key. When you do this, the # other variables need to be moved under the `clear` key. # # Unlike clear values, secrets are not passed directly to the container # but are stored in an env file on the host: env: clear: DB_USER: app secret: - DB_PASSWORD # Aliased secrets # # You can also alias secrets to other secrets using a `:` separator. # # This is useful when the ENV name is different from the secret name. For example, if you have two # places where you need to define the ENV variable `DB_PASSWORD`, but the value is different depending # on the context. # # ```shell # SECRETS=$(kamal secrets fetch ...) # # MAIN_DB_PASSWORD=$(kamal secrets extract MAIN_DB_PASSWORD $SECRETS) # SECONDARY_DB_PASSWORD=$(kamal secrets extract SECONDARY_DB_PASSWORD $SECRETS) # ``` env: secret: - DB_PASSWORD:MAIN_DB_PASSWORD tags: secondary_db: secret: - DB_PASSWORD:SECONDARY_DB_PASSWORD accessories: main_db_accessory: env: secret: - DB_PASSWORD:MAIN_DB_PASSWORD secondary_db_accessory: env: secret: - DB_PASSWORD:SECONDARY_DB_PASSWORD # Tags # # Tags are used to add extra env variables to specific hosts. # See kamal docs servers for how to tag hosts. # # Tags are only allowed in the top-level env configuration (i.e., not under a role-specific env). # # The env variables can be specified with secret and clear values as explained above. env: tags: : MYSQL_USER: monitoring : clear: MYSQL_USER: readonly secret: - MYSQL_PASSWORD # Example configuration env: clear: MYSQL_USER: app secret: - MYSQL_PASSWORD tags: monitoring: MYSQL_USER: monitoring replica: clear: MYSQL_USER: readonly secret: - READONLY_PASSWORD ================================================ FILE: lib/kamal/configuration/docs/logging.yml ================================================ # Custom logging configuration # # Set these to control the Docker logging driver and options. # Logging settings # # These go under the logging key in the configuration file. # # This can be specified at the root level or for a specific role. logging: # Driver # # The logging driver to use, passed to Docker via `--log-driver`: driver: json-file # Options # # Any logging options to pass to the driver, passed to Docker via `--log-opt`: options: max-size: 100m ================================================ FILE: lib/kamal/configuration/docs/proxy.yml ================================================ # Proxy # # Kamal uses [kamal-proxy](https://github.com/basecamp/kamal-proxy) to provide # gapless deployments. It runs on ports 80 and 443 and forwards requests to the # application container. # # The proxy is configured in the root configuration under `proxy`. These are # options that are set when deploying the application, not when booting the proxy. # # They are application-specific, so they are not shared when multiple applications # run on the same proxy. # proxy: # Hosts # # The hosts that will be used to serve the app. The proxy will only route requests # to this host to your app. # # If no hosts are set, then all requests will be forwarded, except for matching # requests for other apps deployed on that server that do have a host set. # # Specify one of `host` or `hosts`. host: foo.example.com hosts: - foo.example.com - bar.example.com # App port # # The port the application container is exposed on. # # Defaults to 80: app_port: 3000 # SSL # # kamal-proxy can provide automatic HTTPS for your application via Let's Encrypt. # # This requires that we are deploying to one server and the host option is set. # The host value must point to the server we are deploying to, and port 443 must be # open for the Let's Encrypt challenge to succeed. # # If you set `ssl` to `true`, `kamal-proxy` will stop forwarding headers to your app, # unless you explicitly set `forward_headers: true` # # Defaults to `false`: ssl: true # Custom SSL certificate # # In some cases, using Let's Encrypt for automatic certificate management is not an # option, for example if you are running from more than one host. # # Or you may already have SSL certificates issued by a different Certificate Authority (CA). # # Kamal supports loading custom SSL certificates directly from secrets. You should # pass a hash mapping the `certificate_pem` and `private_key_pem` to the secret names. ssl: certificate_pem: CERTIFICATE_PEM private_key_pem: PRIVATE_KEY_PEM # ### Notes # - If the certificate or key is missing or invalid, deployments will fail. # - Always handle SSL certificates and private keys securely. Avoid hard-coding them in source control. # SSL redirect # # By default, kamal-proxy will redirect all HTTP requests to HTTPS when SSL is enabled. # If you prefer that HTTP traffic is passed through to your application (along with # HTTPS traffic), you can disable this redirect by setting `ssl_redirect: false`: ssl_redirect: false # Forward headers # # Whether to forward the `X-Forwarded-For` and `X-Forwarded-Proto` headers. # # If you are behind a trusted proxy, you can set this to `true` to forward the headers. # # By default, kamal-proxy will not forward the headers if the `ssl` option is set to `true`, and # will forward them if it is set to `false`. forward_headers: true # Response timeout # # How long to wait for requests to complete before timing out, defaults to 30 seconds: response_timeout: 10 # Path-based routing # # For applications that split their traffic to different services based on the request path, # you can use path-based routing to mount services under different path prefixes. # Usage sample: path_prefix: '/api' # # You can also specify multiple paths in two ways. # # When using path_prefix you can supply multiple routes separated by commas. path_prefix: "/api,/oauth_callback" # You can also specify paths as a list of paths, the configuration will be # rolled together into a comma separated string. path_prefixes: - "/api" - "/oauth_callback" # By default, the path prefix will be stripped from the request before it is forwarded upstream. # # So in the example above, a request to /api/users/123 will be forwarded to web-1 as /users/123. # # To instead forward the request with the original path (including the prefix), # specify --strip-path-prefix=false strip_path_prefix: false # Healthcheck # # When deploying, the proxy will by default hit `/up` once every second until we hit # the deploy timeout, with a 5-second timeout for each request. # # Once the app is up, the proxy will stop hitting the healthcheck endpoint. healthcheck: interval: 3 path: /health timeout: 3 # Buffering # # Whether to buffer request and response bodies in the proxy. # # By default, buffering is enabled with a max request body size of 1GB and no limit # for response size. # # You can also set the memory limit for buffering, which defaults to 1MB; anything # larger than that is written to disk. buffering: requests: true responses: true max_request_body: 40_000_000 max_response_body: 0 memory: 2_000_000 # Logging # # Configure request logging for the proxy. # You can specify request and response headers to log. # By default, `Cache-Control`, `Last-Modified`, and `User-Agent` request headers are logged: logging: request_headers: - Cache-Control - X-Forwarded-Proto response_headers: - X-Request-ID - X-Request-Start # Run configuration # # These options are used when booting the proxy container. # run: http_port: 8080 # HTTP port to use (default 80) https_port: 8443 # HTTPS port to use (default 443) metrics_port: 9090 # Port for Prometheus metrics debug: true # Debug logging (default: false) log_max_size: "30m" # Maximum log file size (default: "10m") publish: false # Publish ports to the host (default: true) bind_ips: # List of IPs to bind to when publishing ports - 0.0.0.0 registry: registry:4443 # Container registry for the kamal-proxy image # (defaults to Docker Hub) repository: myrepo/kamal-proxy # Container repository for the kamal-proxy image # (defaults to `basecamp/kamal-proxy`) version: v0.8.0 # Version tag of the kamal-proxy image to use options: # Additional options to pass to `docker run` label: - custom.label=kamal-proxy memory: 512m cpus: 0.5 # Enabling/disabling the proxy on roles # # The proxy is enabled by default on the primary role but can be disabled by # setting `proxy: false` in the primary role's configuration. # # ```yaml # servers: # web: # hosts: # - ... # proxy: false # ``` # # It is disabled by default on all other roles but can be enabled by setting # `proxy: true` or providing a proxy configuration for that role. # # ```yaml # servers: # web: # hosts: # - ... # web2: # hosts: # - ... # proxy: true # ``` ================================================ FILE: lib/kamal/configuration/docs/registry.yml ================================================ # Registry # # The default registry is Docker Hub, but you can change it using `registry/server`. # Using a local container registry # # If the registry server starts with `localhost`, Kamal will start a local Docker registry # on that port and push the app image to it. registry: server: localhost:5555 # Using Docker Hub as the container registry # # By default, Docker Hub creates public repositories. To avoid making your images public, # set up a private repository before deploying, or change the default repository privacy # settings to private in your [Docker Hub settings](https://hub.docker.com/repository-settings/default-privacy). # # A reference to a secret (in this case, `KAMAL_REGISTRY_PASSWORD`) will look up the secret # in the local environment: registry: username: - password: - KAMAL_REGISTRY_PASSWORD # Using AWS ECR as the container registry # # You will need to have the AWS CLI installed locally for this to work. # 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: registry: server: .dkr.ecr..amazonaws.com username: AWS password: <%= %x(aws ecr get-login-password) %> # Using GCP Artifact Registry as the container registry # # To sign into Artifact Registry, you need to # [create a service account](https://cloud.google.com/iam/docs/service-accounts-create#creating) # and [set up roles and permissions](https://cloud.google.com/artifact-registry/docs/access-control#permissions). # Normally, assigning the `roles/artifactregistry.writer` role should be sufficient. # # Once the service account is ready, you need to generate and download a JSON key and base64 encode it: # # ```shell # base64 -i /path/to/key.json | tr -d "\\n" # ``` # # You'll then need to set the `KAMAL_REGISTRY_PASSWORD` secret to that value. # # Use the environment variable as the password along with `_json_key_base64` as the username. # Here’s the final configuration: registry: server: -docker.pkg.dev username: _json_key_base64 password: - KAMAL_REGISTRY_PASSWORD # Validating the configuration # # You can validate the configuration by running: # # ```shell # kamal registry login # ``` ================================================ FILE: lib/kamal/configuration/docs/role.yml ================================================ # Roles # # Roles are used to configure different types of servers in the deployment. # The most common use for this is to run web servers and job servers. # # Kamal expects there to be a `web` role, unless you set a different `primary_role` # in the root configuration. # Role configuration # # Roles are specified under the servers key: servers: # Simple role configuration # # This can be a list of hosts if you don't need custom configuration for the role. # # You can set tags on the hosts for custom env variables (see kamal docs env): web: - 172.1.0.1 - 172.1.0.2: experiment1 - 172.1.0.2: [ experiment1, experiment2 ] # Custom role configuration # # When there are other options to set, the list of hosts goes under the `hosts` key. # # By default, only the primary role uses a proxy. # # For other roles, you can set it to `proxy: true` to enable it and inherit the root proxy # configuration or provide a map of options to override the root configuration. # # For the primary role, you can set `proxy: false` to disable the proxy. # # You can also set a custom `cmd` to run in the container and overwrite other settings # from the root configuration. workers: hosts: - 172.1.0.3 - 172.1.0.4: experiment1 cmd: "bin/jobs" options: memory: 2g cpus: 4 logging: ... proxy: ... labels: my-label: workers env: ... asset_path: /public ================================================ FILE: lib/kamal/configuration/docs/servers.yml ================================================ # Servers # # Servers are split into different roles, with each role having its own configuration. # # For simpler deployments, though, where all servers are identical, you can just specify a list of servers. # They will be implicitly assigned to the `web` role. servers: - 172.0.0.1 - 172.0.0.2 - 172.0.0.3 # Tagging servers # # Servers can be tagged, with the tags used to add custom env variables (see kamal docs env). servers: - 172.0.0.1 - 172.0.0.2: experiments - 172.0.0.3: [ experiments, three ] # Roles # # For more complex deployments (e.g., if you are running job hosts), you can specify roles and configure each separately (see kamal docs role): servers: web: ... workers: ... ================================================ FILE: lib/kamal/configuration/docs/ssh.yml ================================================ # SSH configuration # # Kamal uses SSH to connect and run commands on your hosts. # By default, it will attempt to connect to the root user on port 22. # # 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: # # ```shell # sudo apt update # sudo apt upgrade -y # sudo apt install -y docker.io curl git # sudo usermod -a -G docker app # ``` # SSH options # # The options are specified under the ssh key in the configuration file. ssh: # The SSH user # # Defaults to `root`: user: app # The SSH port # # Defaults to 22: port: "2222" # Proxy host # # Specified in the form or @: proxy: root@proxy-host # Proxy command # # A custom proxy command, required for older versions of SSH: proxy_command: "ssh -W %h:%p user@proxy" # Log level # # Defaults to `fatal`. Set this to `debug` if you are having SSH connection issues. log_level: debug # Keys only # # Set to `true` to use only private keys from the `keys` and `key_data` parameters, # even if ssh-agent offers more identities. This option is intended for # situations where ssh-agent offers many different identities or you # need to overwrite all identities and force a single one. keys_only: false # Keys # # An array of file names of private keys to use for public key # and host-based authentication: keys: [ "~/.ssh/id.pem" ] # Key data # # An array of strings, with each element of the array being a secret name. key_data: - SSH_PRIVATE_KEY # You can also provide raw private key in PEM format, but this is deprecated. key_data: - "-----BEGIN OPENSSH PRIVATE KEY----- ..." # Config # # Set to true to load the default OpenSSH config files (~/.ssh/config, # /etc/ssh_config), to false ignore config files, or to a file path # (or array of paths) to load specific configuration. Defaults to true. config: [ "~/.ssh/myconfig" ] ================================================ FILE: lib/kamal/configuration/docs/sshkit.yml ================================================ # SSHKit # # [SSHKit](https://github.com/capistrano/sshkit) is the SSH toolkit used by Kamal. # # The default, settings should be sufficient for most use cases, but # when connecting to a large number of hosts, you may need to adjust. # SSHKit options # # The options are specified under the sshkit key in the configuration file. sshkit: # Max concurrent starts # # Creating SSH connections concurrently can be an issue when deploying to many servers. # By default, Kamal will limit concurrent connection starts to 30 at a time. max_concurrent_starts: 10 # Pool idle timeout # # Kamal sets a long idle timeout of 900 seconds on connections to try to avoid # re-connection storms after an idle period, such as building an image or waiting for CI. pool_idle_timeout: 300 # DNS retry settings # # Some resolvers (mDNSResponder, systemd-resolved, Tailscale) can drop lookups during # bursts of concurrent SSH starts. Kamal will retry DNS failures automatically. # # Number of retries after the initial attempt. Set to 0 to disable. dns_retries: 3 ================================================ FILE: lib/kamal/configuration/env/tag.rb ================================================ class Kamal::Configuration::Env::Tag attr_reader :name, :config, :secrets def initialize(name, config:, secrets:) @name = name @config = config @secrets = secrets end def env Kamal::Configuration::Env.new(config: config, secrets: secrets) end end ================================================ FILE: lib/kamal/configuration/env.rb ================================================ class Kamal::Configuration::Env include Kamal::Configuration::Validation attr_reader :context, :clear, :secrets, :secret_keys delegate :argumentize, to: Kamal::Utils def initialize(config:, secrets:, context: "env") @clear = config.fetch("clear", config.key?("secret") || config.key?("tags") ? {} : config) @secrets = secrets @secret_keys = config.fetch("secret", []) @context = context validate! config, context: context, with: Kamal::Configuration::Validator::Env end def clear_args argumentize("--env", clear) end def secrets_io Kamal::EnvFile.new(aliased_secrets).to_io end def merge(other) self.class.new \ config: { "clear" => clear.merge(other.clear), "secret" => secret_keys | other.secret_keys }, secrets: secrets end def to_h clear.merge(aliased_secrets) end private def aliased_secrets secret_keys.to_h { |key| extract_alias(key) }.transform_values { |secret_key| secrets[secret_key] } end def extract_alias(key) key_name, key_aliased_to = key.split(":", 2) [ key_name, key_aliased_to || key_name ] end end ================================================ FILE: lib/kamal/configuration/logging.rb ================================================ class Kamal::Configuration::Logging delegate :optionize, :argumentize, to: Kamal::Utils include Kamal::Configuration::Validation attr_reader :logging_config def initialize(logging_config:, context: "logging") @logging_config = logging_config || {} validate! @logging_config, context: context end def driver logging_config["driver"] end def options logging_config.fetch("options", {}) end def merge(other) self.class.new logging_config: logging_config.deep_merge(other.logging_config) end def args if driver.present? || options.present? optionize({ "log-driver" => driver }.compact) + argumentize("--log-opt", options) else argumentize("--log-opt", { "max-size" => "10m" }) end end end ================================================ FILE: lib/kamal/configuration/proxy/boot.rb ================================================ class Kamal::Configuration::Proxy::Boot attr_reader :config delegate :argumentize, :optionize, to: Kamal::Utils def initialize(config:) @config = config end def publish_args(http_port, https_port, bind_ips = nil) ensure_valid_bind_ips(bind_ips) (bind_ips || [ nil ]).map do |bind_ip| bind_ip = format_bind_ip(bind_ip) publish_http = [ bind_ip, http_port, Kamal::Configuration::Proxy::Run::DEFAULT_HTTP_PORT ].compact.join(":") publish_https = [ bind_ip, https_port, Kamal::Configuration::Proxy::Run::DEFAULT_HTTPS_PORT ].compact.join(":") argumentize "--publish", [ publish_http, publish_https ] end.join(" ") end def logging_args(max_size) argumentize "--log-opt", "max-size=#{max_size}" if max_size.present? end def default_boot_options [ *(publish_args(Kamal::Configuration::Proxy::Run::DEFAULT_HTTP_PORT, Kamal::Configuration::Proxy::Run::DEFAULT_HTTPS_PORT, nil)), *(logging_args(Kamal::Configuration::Proxy::Run::DEFAULT_LOG_MAX_SIZE)) ] end def repository_name "basecamp" end def image_name "kamal-proxy" end def image_default "#{repository_name}/#{image_name}" end def container_name "kamal-proxy" end def host_directory File.join config.run_directory, "proxy" end def options_file File.join host_directory, "options" end def image_file File.join host_directory, "image" end def image_version_file File.join host_directory, "image_version" end def run_command_file File.join host_directory, "run_command" end def apps_directory File.join host_directory, "apps-config" end def apps_container_directory "/home/kamal-proxy/.apps-config" end def apps_volume Kamal::Configuration::Volume.new \ host_path: apps_directory, container_path: apps_container_directory end def app_directory File.join apps_directory, config.service_and_destination end def app_container_directory File.join apps_container_directory, config.service_and_destination end def error_pages_directory File.join app_directory, "error_pages" end def error_pages_container_directory File.join app_container_directory, "error_pages" end def tls_directory File.join app_directory, "tls" end def tls_container_directory File.join app_container_directory, "tls" end private def ensure_valid_bind_ips(bind_ips) bind_ips.present? && bind_ips.each do |ip| next if ip =~ Resolv::IPv4::Regex || ip =~ Resolv::IPv6::Regex raise ArgumentError, "Invalid publish IP address: #{ip}" end true end def format_bind_ip(ip) # Ensure IPv6 address inside square brackets - e.g. [::1] if ip =~ Resolv::IPv6::Regex && ip !~ /\A\[.*\]\z/ "[#{ip}]" else ip end end end ================================================ FILE: lib/kamal/configuration/proxy/run.rb ================================================ class Kamal::Configuration::Proxy::Run MINIMUM_VERSION = "v0.9.2" DEFAULT_HTTP_PORT = 80 DEFAULT_HTTPS_PORT = 443 DEFAULT_LOG_MAX_SIZE = "10m" attr_reader :config, :run_config delegate :argumentize, :optionize, to: Kamal::Utils def initialize(config, run_config:, context: "proxy/run") @config = config @run_config = run_config @context = context end def debug? run_config.fetch("debug", nil) end def publish? run_config.fetch("publish", true) end def http_port run_config.fetch("http_port", DEFAULT_HTTP_PORT) end def https_port run_config.fetch("https_port", DEFAULT_HTTPS_PORT) end def bind_ips run_config.fetch("bind_ips", nil) end def publish_args if publish? (bind_ips || [ nil ]).map do |bind_ip| bind_ip = format_bind_ip(bind_ip) publish_http = [ bind_ip, http_port, DEFAULT_HTTP_PORT ].compact.join(":") publish_https = [ bind_ip, https_port, DEFAULT_HTTPS_PORT ].compact.join(":") argumentize "--publish", [ publish_http, publish_https ] end.join(" ") end end def log_max_size run_config.fetch("log_max_size", DEFAULT_LOG_MAX_SIZE) end def logging_args argumentize "--log-opt", "max-size=#{log_max_size}" if log_max_size.present? end def version run_config.fetch("version", MINIMUM_VERSION) end def registry run_config.fetch("registry", nil) end def repository run_config.fetch("repository", "basecamp/kamal-proxy") end def image "#{[ registry, repository ].compact.join("/")}:#{version}" end def container_name "kamal-proxy" end def options_args if args = run_config["options"] optionize args end end def run_command [ "kamal-proxy", "run", *optionize(run_command_options) ].join(" ") end def metrics_port run_config["metrics_port"] end def run_command_options { debug: debug? || nil, "metrics-port": metrics_port }.compact end def docker_options_args [ *apps_volume_args, *publish_args, *logging_args, *("--expose=#{metrics_port}" if metrics_port.present?), *options_args ].compact end def host_directory File.join config.run_directory, "proxy" end def apps_directory File.join host_directory, "apps-config" end def apps_container_directory "/home/kamal-proxy/.apps-config" end def apps_volume Kamal::Configuration::Volume.new \ host_path: apps_directory, container_path: apps_container_directory end def apps_volume_args [ apps_volume.docker_args ] end def app_directory File.join apps_directory, config.service_and_destination end def app_container_directory File.join apps_container_directory, config.service_and_destination end private def format_bind_ip(ip) # Ensure IPv6 address inside square brackets - e.g. [::1] if ip =~ Resolv::IPv6::Regex && ip !~ /\A\[.*\]\z/ "[#{ip}]" else ip end end end ================================================ FILE: lib/kamal/configuration/proxy.rb ================================================ class Kamal::Configuration::Proxy include Kamal::Configuration::Validation DEFAULT_LOG_REQUEST_HEADERS = [ "Cache-Control", "Last-Modified", "User-Agent" ] CONTAINER_NAME = "kamal-proxy" delegate :argumentize, :optionize, to: Kamal::Utils attr_reader :config, :proxy_config, :role_name, :run, :secrets def initialize(config:, proxy_config:, role_name: nil, secrets:, context: "proxy") @config = config @proxy_config = proxy_config @proxy_config = {} if @proxy_config.nil? @role_name = role_name @secrets = secrets validate! @proxy_config, with: Kamal::Configuration::Validator::Proxy, context: context @run = Kamal::Configuration::Proxy::Run.new(config, run_config: @proxy_config["run"], context: "#{context}/run") if @proxy_config && @proxy_config["run"].present? end def app_port proxy_config.fetch("app_port", 80) end def ssl? proxy_config.fetch("ssl", false) end def hosts proxy_config["hosts"] || proxy_config["host"]&.split(",") || [] end def custom_ssl_certificate? ssl = proxy_config["ssl"] return false unless ssl.is_a?(Hash) ssl["certificate_pem"].present? && ssl["private_key_pem"].present? end def certificate_pem_content ssl = proxy_config["ssl"] return nil unless ssl.is_a?(Hash) secrets[ssl["certificate_pem"]] end def private_key_pem_content ssl = proxy_config["ssl"] return nil unless ssl.is_a?(Hash) secrets[ssl["private_key_pem"]] end def host_tls_cert tls_path(config.proxy_boot.tls_directory, "cert.pem") end def host_tls_key tls_path(config.proxy_boot.tls_directory, "key.pem") end def container_tls_cert tls_path(config.proxy_boot.tls_container_directory, "cert.pem") end def container_tls_key tls_path(config.proxy_boot.tls_container_directory, "key.pem") if custom_ssl_certificate? end def path_prefixes proxy_config["path_prefixes"] || proxy_config["path_prefix"]&.split(",") || [] end def deploy_options { host: hosts, tls: ssl? ? true : nil, "tls-certificate-path": container_tls_cert, "tls-private-key-path": container_tls_key, "deploy-timeout": seconds_duration(config.deploy_timeout), "drain-timeout": seconds_duration(config.drain_timeout), "health-check-interval": seconds_duration(proxy_config.dig("healthcheck", "interval")), "health-check-timeout": seconds_duration(proxy_config.dig("healthcheck", "timeout")), "health-check-path": proxy_config.dig("healthcheck", "path"), "target-timeout": seconds_duration(proxy_config["response_timeout"]), "buffer-requests": proxy_config.fetch("buffering", { "requests": true }).fetch("requests", true), "buffer-responses": proxy_config.fetch("buffering", { "responses": true }).fetch("responses", true), "buffer-memory": proxy_config.dig("buffering", "memory"), "max-request-body": proxy_config.dig("buffering", "max_request_body"), "max-response-body": proxy_config.dig("buffering", "max_response_body"), "path-prefix": path_prefixes, "strip-path-prefix": proxy_config.dig("strip_path_prefix"), "forward-headers": proxy_config.dig("forward_headers"), "tls-redirect": proxy_config.dig("ssl_redirect"), "log-request-header": proxy_config.dig("logging", "request_headers") || DEFAULT_LOG_REQUEST_HEADERS, "log-response-header": proxy_config.dig("logging", "response_headers"), "error-pages": error_pages }.compact end def deploy_command_args(target:) optionize ({ target: "#{target}:#{app_port}" }).merge(deploy_options), with: "=" end def stop_options(drain_timeout: nil, message: nil) { "drain-timeout": seconds_duration(drain_timeout), message: message }.compact end def stop_command_args(**options) optionize stop_options(**options), with: "=" end def merge(other) self.class.new config: config, proxy_config: other.proxy_config.deep_merge(proxy_config), role_name: role_name, secrets: secrets end private def tls_path(directory, filename) File.join([ directory, role_name, filename ].compact) if custom_ssl_certificate? end def seconds_duration(value) value ? "#{value}s" : nil end def error_pages File.join config.proxy_boot.error_pages_container_directory, config.version if config.error_pages_path end end ================================================ FILE: lib/kamal/configuration/registry.rb ================================================ class Kamal::Configuration::Registry include Kamal::Configuration::Validation def initialize(config:, secrets:, context: "registry") @registry_config = config["registry"] || {} @secrets = secrets validate! registry_config, context: context, with: Kamal::Configuration::Validator::Registry end def server registry_config["server"] end def username lookup("username") end def password lookup("password") end def local? server.to_s.match?("^localhost[:$]") end def local_port local? ? (server.split(":").last.to_i || 80) : nil end private attr_reader :registry_config, :secrets def lookup(key) if registry_config[key].is_a?(Array) secrets[registry_config[key].first] else registry_config[key] end end end ================================================ FILE: lib/kamal/configuration/role.rb ================================================ class Kamal::Configuration::Role include Kamal::Configuration::Validation delegate :argumentize, :optionize, to: Kamal::Utils attr_reader :name, :config, :specialized_env, :specialized_logging, :specialized_proxy alias to_s name def initialize(name, config:) @name, @config = name.inquiry, config validate! \ role_config, example: validation_yml["servers"]["workers"], context: "servers/#{name}", with: Kamal::Configuration::Validator::Role @specialized_env = Kamal::Configuration::Env.new \ config: specializations.fetch("env", {}), secrets: config.secrets, context: "servers/#{name}/env" @specialized_logging = Kamal::Configuration::Logging.new \ logging_config: specializations.fetch("logging", {}), context: "servers/#{name}/logging" initialize_specialized_proxy end def primary_host hosts.first end def hosts tagged_hosts.keys end def env_tags(host) tagged_hosts.fetch(host).collect { |tag| config.env_tag(tag) }.compact end def cmd specializations["cmd"] end def option_args if args = specializations["options"] optionize args else [] end end def labels default_labels.merge(custom_labels) end def label_args argumentize "--label", labels end def logging_args logging.args end def logging @logging ||= config.logging.merge(specialized_logging) end def proxy @proxy ||= specialized_proxy.merge(config.proxy) if running_proxy? end def running_proxy? @running_proxy end def ssl? running_proxy? && proxy.ssl? end def stop_args # When deploying with the proxy, kamal-proxy will drain request before returning so we don't need to wait. timeout = running_proxy? ? nil : config.drain_timeout [ *argumentize("-t", timeout) ] end def env(host) @envs ||= {} @envs[host] ||= [ config.env, specialized_env, *env_tags(host).map(&:env) ].reduce(:merge) end def env_args(host) [ *env(host).clear_args, *argumentize("--env-file", secrets_path) ] end def env_directory File.join(config.env_directory, "roles") end def secrets_io(host) env(host).secrets_io end def secrets_path File.join(config.env_directory, "roles", "#{name}.env") end def asset_volume_args asset_volume&.docker_args end def primary? name == @config.primary_role_name end def container_name(version = nil) [ container_prefix, version || config.version ].compact.join("-") end def container_prefix [ config.service, name, config.destination ].compact.join("-") end def asset_path asset_path_config&.dig(0) end def assets? asset_path.present? && running_proxy? end def asset_volume(version = config.version) if assets? Kamal::Configuration::Volume.new \ host_path: asset_volume_directory(version), container_path: asset_path, options: asset_path_options end end def asset_path_options asset_path_config&.dig(1) end def asset_extracted_directory(version = config.version) File.join config.assets_directory, "extracted", [ name, version ].join("-") end def asset_volume_directory(version = config.version) File.join config.assets_directory, "volumes", [ name, version ].join("-") end def ensure_one_host_for_ssl if running_proxy? && proxy.ssl? && hosts.size > 1 && !proxy.custom_ssl_certificate? raise Kamal::ConfigurationError, "SSL is only supported on a single server unless you provide custom certificates, found #{hosts.size} servers for role #{name}" end end private def initialize_specialized_proxy proxy_specializations = specializations["proxy"] if primary? # only false means no proxy for non-primary roles @running_proxy = proxy_specializations != false else # false and nil both mean no proxy for non-primary roles @running_proxy = !!proxy_specializations end if running_proxy? proxy_config = proxy_specializations == true || proxy_specializations.nil? ? {} : proxy_specializations @specialized_proxy = Kamal::Configuration::Proxy.new \ config: config, proxy_config: proxy_config, secrets: config.secrets, role_name: name, context: "servers/#{name}/proxy" end end def tagged_hosts {}.tap do |tagged_hosts| extract_hosts_from_config.map do |host_config| if host_config.is_a?(Hash) host, tags = host_config.first tagged_hosts[host] = Array(tags) elsif host_config.is_a?(String) tagged_hosts[host_config] = [] end end end end def extract_hosts_from_config if config.raw_config.servers.is_a?(Array) config.raw_config.servers else servers = config.raw_config.servers[name] servers.is_a?(Array) ? servers : Array(servers["hosts"]) end end def default_labels { "service" => config.service, "role" => name, "destination" => config.destination } end def specializations @specializations ||= role_config.is_a?(Array) ? {} : role_config end def role_config @role_config ||= config.raw_config.servers.is_a?(Array) ? {} : config.raw_config.servers[name] end def custom_labels Hash.new.tap do |labels| labels.merge!(config.labels) if config.labels.present? labels.merge!(specializations["labels"]) if specializations["labels"].present? end end def asset_path_config raw_path = specializations["asset_path"] || config.asset_path return nil unless raw_path.present? parts = raw_path.split(":", 2) [ parts[0], parts[1] ] end end ================================================ FILE: lib/kamal/configuration/servers.rb ================================================ class Kamal::Configuration::Servers include Kamal::Configuration::Validation attr_reader :config, :servers_config, :roles def initialize(config:) @config = config @servers_config = config.raw_config.servers validate! servers_config, with: Kamal::Configuration::Validator::Servers @roles = role_names.map { |role_name| Kamal::Configuration::Role.new role_name, config: config } end private def role_names case servers_config when Array [ "web" ] when NilClass [] else servers_config.keys.sort end end end ================================================ FILE: lib/kamal/configuration/ssh.rb ================================================ class Kamal::Configuration::Ssh LOGGER = ::Logger.new(STDERR) include Kamal::Configuration::Validation attr_reader :ssh_config, :secrets def initialize(config:) @ssh_config = config.raw_config.ssh || {} @secrets = config.secrets validate! ssh_config end def user ssh_config.fetch("user", "root") end def port ssh_config.fetch("port", 22) end def proxy if (proxy = ssh_config["proxy"]) Net::SSH::Proxy::Jump.new(proxy.include?("@") ? proxy : "root@#{proxy}") elsif (proxy_command = ssh_config["proxy_command"]) Net::SSH::Proxy::Command.new(proxy_command) end end def keys_only ssh_config["keys_only"] end def keys ssh_config["keys"] end def key_data key_data = ssh_config["key_data"] return unless key_data key_data.map do |k| if secrets.key?(k) secrets[k] else warn "Inline key_data usage is deprecated and will be removed in Kamal 3. Please store your key_data in a secret." k end end end def config ssh_config["config"] end def options { user: user, port: port, proxy: proxy, logger: logger, keepalive: true, keepalive_interval: 30, keys_only: keys_only, keys: keys, key_data: key_data, config: config }.compact end def to_h options.except(:logger).merge(log_level: log_level) end private def logger LOGGER.tap { |logger| logger.level = log_level } end def log_level ssh_config.fetch("log_level", :fatal) end end ================================================ FILE: lib/kamal/configuration/sshkit.rb ================================================ class Kamal::Configuration::Sshkit include Kamal::Configuration::Validation attr_reader :sshkit_config def initialize(config:) @sshkit_config = config.raw_config.sshkit || {} validate! sshkit_config end def max_concurrent_starts sshkit_config.fetch("max_concurrent_starts", 30) end def pool_idle_timeout sshkit_config.fetch("pool_idle_timeout", 900) end def dns_retries Integer(sshkit_config.fetch("dns_retries", 3)) end def to_h sshkit_config end end ================================================ FILE: lib/kamal/configuration/validation.rb ================================================ require "yaml" require "active_support/inflector" module Kamal::Configuration::Validation extend ActiveSupport::Concern class_methods do def validation_doc @validation_doc ||= File.read(File.join(File.dirname(__FILE__), "docs", "#{validation_config_key}.yml")) end def validation_config_key @validation_config_key ||= name.demodulize.underscore end end def validate!(config, example: nil, context: nil, with: Kamal::Configuration::Validator) context ||= self.class.validation_config_key example ||= validation_yml[self.class.validation_config_key] with.new(config, example: example, context: context).validate! end def validation_yml @validation_yml ||= YAML.load(self.class.validation_doc) end end ================================================ FILE: lib/kamal/configuration/validator/accessory.rb ================================================ class Kamal::Configuration::Validator::Accessory < Kamal::Configuration::Validator def validate! super if (config.keys & [ "host", "hosts", "role", "roles", "tag", "tags" ]).size != 1 error "specify one of `host`, `hosts`, `role`, `roles`, `tag` or `tags`" end validate_labels!(config["labels"]) validate_docker_options!(config["options"]) end end ================================================ FILE: lib/kamal/configuration/validator/alias.rb ================================================ class Kamal::Configuration::Validator::Alias < Kamal::Configuration::Validator def validate! super name = context.delete_prefix("aliases/") if name !~ /\A[a-z0-9_-]+\z/ error "Invalid alias name: '#{name}'. Must only contain lowercase letters, alphanumeric, hyphens and underscores." end if Kamal::Cli::Main.commands.include?(name) error "Alias '#{name}' conflicts with a built-in command." end end end ================================================ FILE: lib/kamal/configuration/validator/builder.rb ================================================ class Kamal::Configuration::Validator::Builder < Kamal::Configuration::Validator def validate! super if config["cache"] && config["cache"]["type"] error "Invalid cache type: #{config["cache"]["type"]}" unless [ "gha", "registry" ].include?(config["cache"]["type"]) end error "Builder arch not set" unless config["arch"].present? error "buildpacks only support building for one arch" if config["pack"] && config["arch"].is_a?(Array) && config["arch"].size > 1 error "Cannot disable local builds, no remote is set" if config["local"] == false && config["remote"].blank? end end ================================================ FILE: lib/kamal/configuration/validator/configuration.rb ================================================ class Kamal::Configuration::Validator::Configuration < Kamal::Configuration::Validator private def allow_extensions? true end end ================================================ FILE: lib/kamal/configuration/validator/env.rb ================================================ class Kamal::Configuration::Validator::Env < Kamal::Configuration::Validator SPECIAL_KEYS = [ "clear", "secret", "tags" ] def validate! if known_keys.any? validate_complex_env! else validate_simple_env! end end private def validate_simple_env! validate_hash_of!(config, String) end def validate_complex_env! unknown_keys_error unknown_keys if unknown_keys.any? with_context("clear") { validate_hash_of!(config["clear"], String) } if config.key?("clear") with_context("secret") { validate_array_of!(config["secret"], String) } if config.key?("secret") validate_tags! if config.key?("tags") end def known_keys @known_keys ||= config.keys & SPECIAL_KEYS end def unknown_keys @unknown_keys ||= config.keys - SPECIAL_KEYS end def validate_tags! if context == "env" with_context("tags") do validate_type! config["tags"], Hash config["tags"].each do |tag, value| with_context(tag) do validate_type! value, Hash Kamal::Configuration::Validator::Env.new( value, example: example["tags"].values[1], context: context ).validate! end end end else error "tags are only allowed in the root env" end end end ================================================ FILE: lib/kamal/configuration/validator/proxy.rb ================================================ class Kamal::Configuration::Validator::Proxy < Kamal::Configuration::Validator def validate! unless config.nil? super if config["host"].blank? && config["hosts"].blank? && config["ssl"] error "Must set a host to enable automatic SSL" end if (config.keys & [ "host", "hosts" ]).size > 1 error "Specify one of 'host' or 'hosts', not both" end if config["ssl"].is_a?(Hash) if config["ssl"]["certificate_pem"].present? && config["ssl"]["private_key_pem"].blank? error "Missing private_key_pem setting (required when certificate_pem is present)" end if config["ssl"]["private_key_pem"].present? && config["ssl"]["certificate_pem"].blank? error "Missing certificate_pem setting (required when private_key_pem is present)" end end if run_config = config["run"] if run_config["bind_ips"].present? ensure_valid_bind_ips(config["bind_ips"]) end if run_config["publish"] == false if run_config["bind_ips"].present? || run_config["http_port"].present? || run_config["https_port"].present? error "Cannot set http_port, https_port or bind_ips when publish is false" end end end end end private def ensure_valid_bind_ips(bind_ips) bind_ips.present? && bind_ips.each do |ip| next if ip =~ Resolv::IPv4::Regex || ip =~ Resolv::IPv6::Regex error "Invalid publish IP address: #{ip}" end end end ================================================ FILE: lib/kamal/configuration/validator/registry.rb ================================================ class Kamal::Configuration::Validator::Registry < Kamal::Configuration::Validator STRING_OR_ONE_ITEM_ARRAY_KEYS = [ "username", "password" ] def validate! validate_against_example! \ config.except(*STRING_OR_ONE_ITEM_ARRAY_KEYS), example.except(*STRING_OR_ONE_ITEM_ARRAY_KEYS) validate_string_or_one_item_array! "username" validate_string_or_one_item_array! "password" end private def validate_string_or_one_item_array!(key) with_context(key) do value = config[key] unless config["server"]&.match?("^localhost[:$]") error "is required" unless value.present? unless value.is_a?(String) || (value.is_a?(Array) && value.size == 1 && value.first.is_a?(String)) error "should be a string or an array with one string (for secret lookup)" end end end end end ================================================ FILE: lib/kamal/configuration/validator/role.rb ================================================ class Kamal::Configuration::Validator::Role < Kamal::Configuration::Validator def validate! validate_type! config, Array, Hash if config.is_a?(Array) validate_servers!(config) else super validate_labels!(config["labels"]) validate_docker_options!(config["options"]) end end end ================================================ FILE: lib/kamal/configuration/validator/servers.rb ================================================ class Kamal::Configuration::Validator::Servers < Kamal::Configuration::Validator def validate! validate_type! config, Array, Hash, NilClass validate_servers! config if config.is_a?(Array) end end ================================================ FILE: lib/kamal/configuration/validator.rb ================================================ class Kamal::Configuration::Validator attr_reader :config, :example, :context def initialize(config, example:, context:) @config = config @example = example @context = context end def validate! validate_against_example! config, example end private def validate_against_example!(validation_config, example) validate_type! validation_config, example.class if example.class == Hash check_unknown_keys! validation_config, example validation_config.each do |key, value| next if extension?(key) with_context(key) do example_value = example[key] if example_value == "..." unless key.to_s == "proxy" && boolean?(value.class) validate_type! value, *(Array if key == :servers), Hash end elsif key.to_s == "ssl" validate_type! value, TrueClass, FalseClass, Hash elsif key.to_s == "hooks_output" validate_hooks_output!(value) elsif key == "hosts" validate_servers! value elsif example_value.is_a?(Array) if key == "arch" validate_array_of_or_type! value, example_value.first.class elsif key.to_s == "config" validate_ssh_config!(value) elsif key.to_s == "files" || key.to_s == "directories" validate_paths!(value) else validate_array_of! value, example_value.first.class end elsif example_value.is_a?(Hash) case key.to_s when "options", "args" validate_type! value, Hash when "labels" validate_hash_of! value, example_value.first[1].class else validate_against_example! value, example_value end else validate_type! value, example_value.class end end end end end def valid_type?(value, type) value.is_a?(type) || (type == String && stringish?(value)) || (boolean?(type) && boolean?(value.class)) end def type_description(type) if type == Integer || type == Array "an #{type.name.downcase}" elsif type == TrueClass || type == FalseClass "a boolean" else "a #{type.name.downcase}" end end def boolean?(type) type == TrueClass || type == FalseClass end def stringish?(value) value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Numeric) || value.is_a?(TrueClass) || value.is_a?(FalseClass) end def validate_array_of_or_type!(value, type) if value.is_a?(Array) validate_array_of! value, type else validate_type! value, type end rescue Kamal::ConfigurationError type_error(Array, type) end def validate_array_of!(array, type) validate_type! array, Array array.each_with_index do |value, index| with_context(index) do validate_type! value, type end end end def validate_hash_of!(hash, type) validate_type! hash, Hash hash.each do |key, value| with_context(key) do validate_type! value, type end end end def validate_servers!(servers) validate_type! servers, Array servers.each_with_index do |server, index| with_context(index) do validate_type! server, String, Hash if server.is_a?(Hash) error "multiple hosts found" unless server.size == 1 host, tags = server.first with_context(host) do validate_type! tags, String, Array validate_array_of! tags, String if tags.is_a?(Array) end end end end end def validate_ssh_config!(config) if config.is_a?(Array) validate_array_of! config, String elsif boolean?(config.class) || config.is_a?(String) # Booleans and Strings are allowed else type_error(TrueClass, FalseClass, String, Array) end end def validate_paths!(paths) validate_type! paths, Array paths.each_with_index do |path, index| with_context(index) do validate_type! path, String, Hash if path.is_a?(Hash) %w[local remote mode owner options].each do |key| with_context(key) do validate_type! path[key], String if path.key?(key) end end end end end end def validate_hooks_output!(value) # hooks_output can be either a symbol/string (global) or a hash (per-hook) if value.is_a?(Hash) value.each do |hook, level| with_context(hook) do validate_type! level, String, Symbol end end else validate_type! value, String, Symbol end end def validate_type!(value, *types) type_error(*types) unless types.any? { |type| valid_type?(value, type) } end def error(message) raise Kamal::ConfigurationError, "#{error_context}#{message}" end def type_error(*expected_types) descriptions = expected_types.map { |type| type_description(type) }.uniq error "should be #{descriptions.join(" or ")}" end def unknown_keys_error(unknown_keys) error "unknown #{"key".pluralize(unknown_keys.count)}: #{unknown_keys.join(", ")}" end def error_context "#{context}: " if context.present? end def with_context(context) old_context = @context @context = [ @context, context ].select(&:present?).join("/") yield ensure @context = old_context end def allow_extensions? false end def extension?(key) key.to_s.start_with?("x-") end def check_unknown_keys!(config, example) unknown_keys = config.keys - example.keys unknown_keys.reject! { |key| extension?(key) } if allow_extensions? unknown_keys_error unknown_keys if unknown_keys.present? end def validate_labels!(labels) return true if labels.blank? with_context("labels") do labels.each do |key, _| with_context(key) do error "invalid label. destination, role, and service are reserved labels" if %w[destination role service].include?(key) end end end end def validate_docker_options!(options) if options error "Cannot set restart policy in docker options, unless-stopped is required" if options["restart"] end end end ================================================ FILE: lib/kamal/configuration/volume.rb ================================================ class Kamal::Configuration::Volume attr_reader :host_path, :container_path, :options delegate :argumentize, to: Kamal::Utils def initialize(host_path:, container_path:, options: nil) @host_path = host_path @container_path = container_path @options = options end def docker_args argumentize "--volume", docker_args_string end def docker_args_string volume_string = "#{host_path_for_docker_volume}:#{container_path}" volume_string += ":#{options}" if options.present? volume_string end private def host_path_for_docker_volume if Pathname.new(host_path).absolute? host_path else "$PWD/#{host_path}" end end end ================================================ FILE: lib/kamal/configuration.rb ================================================ require "active_support/ordered_options" require "active_support/core_ext/string/inquiry" require "active_support/core_ext/module/delegation" require "active_support/core_ext/hash/keys" require "erb" require "net/ssh/proxy/jump" class Kamal::Configuration HOOKS_OUTPUT_LEVELS = [ :quiet, :verbose ].freeze delegate :service, :labels, :hooks_path, to: :raw_config, allow_nil: true delegate :argumentize, :optionize, to: Kamal::Utils attr_reader :destination, :raw_config, :secrets attr_reader :accessories, :aliases, :boot, :builder, :env, :logging, :proxy, :proxy_boot, :servers, :ssh, :sshkit, :registry include Validation class << self def create_from(config_file:, destination: nil, version: nil) ENV["KAMAL_DESTINATION"] = destination raw_config = load_raw_config(config_file: config_file, destination: destination) new raw_config, destination: destination, version: version end def load_raw_config(config_file:, destination: nil) load_config_files(config_file, *destination_config_file(config_file, destination)) end private def load_config_files(*files) files.inject({}) { |config, file| config.deep_merge! load_config_file(file) } end def load_config_file(file) if file.exist? # Newer Psych doesn't load aliases by default load_method = YAML.respond_to?(:unsafe_load) ? :unsafe_load : :load template = File.read(file) rendered = ERB.new(template, trim_mode: "-").result YAML.send(load_method, rendered).symbolize_keys else raise "Configuration file not found in #{file}" end end def destination_config_file(base_config_file, destination) base_config_file.sub_ext(".#{destination}.yml") if destination end end def initialize(raw_config, destination: nil, version: nil, validate: true) @raw_config = ActiveSupport::InheritableOptions.new(raw_config) @destination = destination @declared_version = version validate! raw_config, example: validation_yml.symbolize_keys, context: "", with: Kamal::Configuration::Validator::Configuration @secrets = Kamal::Secrets.new(destination: destination, secrets_path: secrets_path) # Eager load config to validate it, these are first as they have dependencies later on @servers = Servers.new(config: self) @registry = Registry.new(config: @raw_config, secrets: secrets) @accessories = @raw_config.accessories&.keys&.collect { |name| Accessory.new(name, config: self) } || [] @aliases = @raw_config.aliases&.keys&.to_h { |name| [ name, Alias.new(name, config: self) ] } || {} @boot = Boot.new(config: self) @builder = Builder.new(config: self) @env = Env.new(config: @raw_config.env || {}, secrets: secrets) @logging = Logging.new(logging_config: @raw_config.logging) @proxy = Proxy.new(config: self, proxy_config: @raw_config.proxy, secrets: secrets) @proxy_boot = Proxy::Boot.new(config: self) @ssh = Ssh.new(config: self) @sshkit = Sshkit.new(config: self) ensure_destination_if_required ensure_required_keys_present ensure_valid_kamal_version ensure_retain_containers_valid ensure_valid_service_name ensure_no_traefik_reboot_hooks ensure_one_host_for_ssl_roles ensure_unique_hosts_for_ssl_roles ensure_local_registry_remote_builder_has_ssh_url ensure_no_conflicting_proxy_runs ensure_valid_hooks_output! end def version=(version) @declared_version = version end def version @declared_version.presence || ENV["VERSION"] || git_version end def abbreviated_version if version # Don't abbreviate _uncommitted_ if version.include?("_") version else version[0...7] end end end def minimum_version raw_config.minimum_version end def service_and_destination [ service, destination ].compact.join("-") end def roles servers.roles end def role(name) roles.detect { |r| r.name == name.to_s } end def accessory(name) accessories.detect { |a| a.name == name.to_s } end def all_hosts (roles + accessories).flat_map(&:hosts).uniq end def host_roles(host) roles.select { |role| role.hosts.include?(host) } end def host_accessories(host) accessories.select { |accessory| accessory.hosts.include?(host) } end def app_hosts roles.flat_map(&:hosts).uniq end def primary_host primary_role&.primary_host end def primary_role_name raw_config.primary_role || "web" end def primary_role role(primary_role_name) end def allow_empty_roles? raw_config.allow_empty_roles end def proxy_roles roles.select(&:running_proxy?) end def proxy_role_names proxy_roles.flat_map(&:name) end def proxy_accessories accessories.select(&:running_proxy?) end def proxy_hosts (proxy_roles.flat_map(&:hosts) + proxy_accessories.flat_map(&:hosts)).uniq end def image name = raw_config&.image.presence name ||= raw_config&.service if registry.local? name end def proxy_run(host) # We validate that all the config are identical for a host proxy_runs(host.to_s).first end def repository [ registry.server, image ].compact.join("/") end def absolute_image "#{repository}:#{version}" end def latest_image "#{repository}:#{latest_tag}" end def latest_tag [ "latest", *destination ].join("-") end def service_with_version "#{service}-#{version}" end def require_destination? raw_config.require_destination end def retain_containers raw_config.retain_containers || 5 end def volume_args if raw_config.volumes.present? argumentize "--volume", raw_config.volumes else [] end end def logging_args logging.args end def readiness_delay raw_config.readiness_delay || 7 end def deploy_timeout raw_config.deploy_timeout || 30 end def drain_timeout raw_config.drain_timeout || 30 end def run_directory ".kamal" end def apps_directory File.join run_directory, "apps" end def app_directory File.join apps_directory, service_and_destination end def env_directory File.join app_directory, "env" end def assets_directory File.join app_directory, "assets" end def hooks_path raw_config.hooks_path || ".kamal/hooks" end def secrets_path raw_config.secrets_path || ".kamal/secrets" end def asset_path raw_config.asset_path end def error_pages_path raw_config.error_pages_path end def env_tags @env_tags ||= if (tags = raw_config.env["tags"]) tags.collect { |name, config| Env::Tag.new(name, config: config, secrets: secrets) } else [] end end def env_tag(name) env_tags.detect { |t| t.name == name.to_s } end def hooks_output_for(hook) case raw_config.hooks_output when Symbol, String raw_config.hooks_output.to_sym when Hash raw_config.hooks_output[hook]&.to_sym end end def to_h { roles: role_names, hosts: all_hosts, primary_host: primary_host, version: version, repository: repository, absolute_image: absolute_image, service_with_version: service_with_version, volume_args: volume_args, ssh_options: ssh.to_h, sshkit: sshkit.to_h, builder: builder.to_h, accessories: raw_config.accessories, logging: logging_args }.compact end private # Will raise ArgumentError if any required config keys are missing def ensure_destination_if_required if require_destination? && destination.nil? raise ArgumentError, "You must specify a destination" end true end def ensure_required_keys_present %i[ service registry ].each do |key| raise Kamal::ConfigurationError, "Missing required configuration for #{key}" unless raw_config[key].present? end raise Kamal::ConfigurationError, "Missing required configuration for image" if image.blank? if raw_config.servers.nil? raise Kamal::ConfigurationError, "No servers or accessories specified" unless raw_config.accessories.present? else unless role(primary_role_name).present? raise Kamal::ConfigurationError, "The primary_role #{primary_role_name} isn't defined" end if primary_role.hosts.empty? raise Kamal::ConfigurationError, "No servers specified for the #{primary_role.name} primary_role" end unless allow_empty_roles? roles.each do |role| if role.hosts.empty? raise Kamal::ConfigurationError, "No servers specified for the #{role.name} role. You can ignore this with allow_empty_roles: true" end end end end true end def ensure_valid_service_name raise Kamal::ConfigurationError, "Service name can only include alphanumeric characters, hyphens, and underscores" unless raw_config[:service] =~ /^[a-z0-9_-]+$/i true end def ensure_valid_kamal_version if minimum_version && Gem::Version.new(minimum_version) > Gem::Version.new(Kamal::VERSION) raise Kamal::ConfigurationError, "Current version is #{Kamal::VERSION}, minimum required is #{minimum_version}" end true end def ensure_retain_containers_valid raise Kamal::ConfigurationError, "Must retain at least 1 container" if retain_containers < 1 true end def ensure_no_traefik_reboot_hooks hooks = %w[ pre-traefik-reboot post-traefik-reboot ].select { |hook_file| File.exist?(File.join(hooks_path, hook_file)) } if hooks.any? raise Kamal::ConfigurationError, "Found #{hooks.join(", ")}, these should be renamed to (pre|post)-proxy-reboot" end true end def ensure_one_host_for_ssl_roles roles.each(&:ensure_one_host_for_ssl) true end def ensure_unique_hosts_for_ssl_roles hosts = roles.select(&:ssl?).flat_map { |role| role.proxy.hosts } duplicates = hosts.tally.filter_map { |host, count| host if count > 1 } raise Kamal::ConfigurationError, "Different roles can't share the same host for SSL: #{duplicates.join(", ")}" if duplicates.any? true end def ensure_local_registry_remote_builder_has_ssh_url if registry.local? && builder.remote? unless URI(builder.remote).scheme == "ssh" raise Kamal::ConfigurationError, "Local registry with remote builder requires an SSH URL (e.g., ssh://user@host)" end end true end def ensure_no_conflicting_proxy_runs all_hosts.each do |host| run_configs = proxy_runs(host) if run_configs.uniq.size > 1 raise Kamal::ConfigurationError, "Conflicting proxy run configurations for host #{host}" end end end def proxy_runs(host) (host_roles(host) + host_accessories(host)).map(&:proxy).compact.map(&:run).compact end def role_names raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort end def ensure_valid_hooks_output! case raw_config.hooks_output when Symbol, String validate_hooks_output_level!(raw_config.hooks_output.to_sym) when Hash raw_config.hooks_output.each { |hook, level| validate_hooks_output_level!(level.to_sym, hook) } end end def validate_hooks_output_level!(level, hook = nil) return if HOOKS_OUTPUT_LEVELS.include?(level) context = hook ? " for hook '#{hook}'" : "" raise Kamal::ConfigurationError, "Invalid hooks_output '#{level}'#{context}, must be one of: #{HOOKS_OUTPUT_LEVELS.join(', ')}" end def git_version @git_version ||= if Kamal::Git.used? if Kamal::Git.uncommitted_changes.present? && !builder.git_clone? uncommitted_suffix = "_uncommitted_#{SecureRandom.hex(8)}" end [ Kamal::Git.revision, uncommitted_suffix ].compact.join else raise "Can't use commit hash as version, no git repository found in #{Dir.pwd}" end end end ================================================ FILE: lib/kamal/docker.rb ================================================ require "tempfile" require "open3" module Kamal::Docker extend self BUILD_CHECK_TAG = "kamal-local-build-check" def included_files Tempfile.create do |dockerfile| dockerfile.write(<<~DOCKERFILE) FROM busybox COPY . app WORKDIR app CMD find . -type f | sed "s|^\./||" DOCKERFILE dockerfile.close cmd = "docker buildx build -t=#{BUILD_CHECK_TAG} -f=#{dockerfile.path} ." system(cmd) || raise("failed to build check image") end cmd = "docker run --rm #{BUILD_CHECK_TAG}" out, err, status = Open3.capture3(cmd) unless status raise "failed to run check image:\n#{err}" end out.lines.map(&:strip) end end ================================================ FILE: lib/kamal/env_file.rb ================================================ # Encode an env hash as a string where secret values have been looked up and all values escaped for Docker. class Kamal::EnvFile def initialize(env) @env = env end def to_s env_file = StringIO.new.tap do |contents| @env.each do |key, value| contents << docker_env_file_line(key, value) end end.string # Ensure the file has some contents to avoid the SSHKIT empty file warning env_file.presence || "\n" end def to_io StringIO.new(to_s) end alias to_str to_s private def docker_env_file_line(key, value) "#{key}=#{escape_docker_env_file_value(value)}\n" end # Escape a value to make it safe to dump in a docker file. def escape_docker_env_file_value(value) # keep non-ascii(UTF-8) characters as it is value.to_s.scan(/[\x00-\x7F]+|[^\x00-\x7F]+/).map do |part| part.ascii_only? ? escape_docker_env_file_ascii_value(part) : part end.join end def escape_docker_env_file_ascii_value(value) # Doublequotes are treated literally in docker env files # so remove leading and trailing ones and unescape any others value.to_s.dump[1..-2] .gsub(/\\"/, "\"") .gsub(/\\#/, "#") end end ================================================ FILE: lib/kamal/git.rb ================================================ module Kamal::Git extend self def used? system("git rev-parse") end def user_name `git config user.name`.strip end def email `git config user.email`.strip end def revision `git rev-parse HEAD`.strip end def uncommitted_changes `git status --porcelain`.strip end def root `git rev-parse --show-toplevel`.strip end # returns an array of relative path names of files with uncommitted changes def uncommitted_files `git ls-files --modified`.lines.map(&:strip) end # returns an array of relative path names of untracked files, including gitignored files def untracked_files `git ls-files --others`.lines.map(&:strip) end end ================================================ FILE: lib/kamal/secrets/adapters/aws_secrets_manager.rb ================================================ class Kamal::Secrets::Adapters::AwsSecretsManager < Kamal::Secrets::Adapters::Base def requires_account? false end private def login(_account) nil end def fetch_secrets(secrets, from:, account: nil, session:) {}.tap do |results| get_from_secrets_manager(prefixed_secrets(secrets, from: from), account: account).each do |secret| secret_name = secret["Name"] secret_string = JSON.parse(secret["SecretString"]) secret_string.each do |key, value| results["#{secret_name}/#{key}"] = value end rescue JSON::ParserError results["#{secret_name}"] = secret["SecretString"] end end end def get_from_secrets_manager(secrets, account: nil) args = [ "aws", "secretsmanager", "batch-get-secret-value", "--secret-id-list" ] + secrets.map(&:shellescape) args += [ "--profile", account.shellescape ] if account args += [ "--output", "json" ] cmd = args.join(" ") `#{cmd}`.tap do |secrets| raise RuntimeError, "Could not read #{secrets} from AWS Secrets Manager" unless $?.success? secrets = JSON.parse(secrets) return secrets["SecretValues"] unless secrets["Errors"].present? raise RuntimeError, secrets["Errors"].map { |error| "#{error['SecretId']}: #{error['Message']}" }.join(" ") end end def check_dependencies! raise RuntimeError, "AWS CLI is not installed" unless cli_installed? end def cli_installed? `aws --version 2> /dev/null` $?.success? end end ================================================ FILE: lib/kamal/secrets/adapters/base.rb ================================================ class Kamal::Secrets::Adapters::Base delegate :optionize, to: Kamal::Utils def fetch(secrets, account: nil, from: nil) raise RuntimeError, "Missing required option '--account'" if requires_account? && account.blank? check_dependencies! session = login(account) fetch_secrets(secrets, from: from, account: account, session: session) end def requires_account? true end private def login(...) raise NotImplementedError end def fetch_secrets(...) raise NotImplementedError end def check_dependencies! raise NotImplementedError end def prefixed_secrets(secrets, from:) secrets.map { |secret| [ from, secret ].compact.join("/") } end end ================================================ FILE: lib/kamal/secrets/adapters/bitwarden.rb ================================================ class Kamal::Secrets::Adapters::Bitwarden < Kamal::Secrets::Adapters::Base private def login(account) status = run_command("status") if status["status"] == "unauthenticated" run_command("login #{account.shellescape}", raw: true) status = run_command("status") end if status["status"] == "locked" session = run_command("unlock --raw", raw: true).presence status = run_command("status", session: session) end raise RuntimeError, "Failed to login to and unlock Bitwarden" unless status["status"] == "unlocked" run_command("sync", session: session, raw: true) raise RuntimeError, "Failed to sync Bitwarden" unless $?.success? session end def fetch_secrets(secrets, from:, account:, session:) {}.tap do |results| items_fields(prefixed_secrets(secrets, from: from)).each do |item, fields| item_json = run_command("get item #{item.shellescape}", session: session, raw: true) raise RuntimeError, "Could not read #{item} from Bitwarden" unless $?.success? item_json = JSON.parse(item_json) if fields.any? results.merge! fetch_secrets_from_fields(fields, item, item_json) elsif item_json.dig("login", "password") results[item] = item_json.dig("login", "password") elsif item_json["fields"]&.any? fields = item_json["fields"].pluck("name") results.merge! fetch_secrets_from_fields(fields, item, item_json) else raise RuntimeError, "Item #{item} is not a login type item and no fields were specified" end end end end def fetch_secrets_from_fields(fields, item, item_json) fields.to_h do |field| item_field = item_json["fields"].find { |f| f["name"] == field } raise RuntimeError, "Could not find field #{field} in item #{item} in Bitwarden" unless item_field value = item_field["value"] [ "#{item}/#{field}", value ] end end def items_fields(secrets) {}.tap do |items| secrets.each do |secret| item, field = secret.split("/") items[item] ||= [] items[item] << field end end end def signedin?(account) run_command("status")["status"] != "unauthenticated" end def run_command(command, session: nil, raw: false) full_command = [ *("BW_SESSION=#{session.shellescape}" if session), "bw", command ].join(" ") result = `#{full_command}`.strip raw ? result : JSON.parse(result) end def check_dependencies! raise RuntimeError, "Bitwarden CLI is not installed" unless cli_installed? end def cli_installed? `bw --version 2> /dev/null` $?.success? end end ================================================ FILE: lib/kamal/secrets/adapters/bitwarden_secrets_manager.rb ================================================ class Kamal::Secrets::Adapters::BitwardenSecretsManager < Kamal::Secrets::Adapters::Base def requires_account? false end private LIST_ALL_SELECTOR = "all" LIST_ALL_FROM_PROJECT_SUFFIX = "/all" LIST_COMMAND = "secret list" GET_COMMAND = "secret get" def fetch_secrets(secrets, from:, account:, session:) raise RuntimeError, "You must specify what to retrieve from Bitwarden Secrets Manager" if secrets.length == 0 secrets = prefixed_secrets(secrets, from: from) command, project = extract_command_and_project(secrets) {}.tap do |results| if command.nil? secrets.each do |secret_uuid| item_json = run_command("#{GET_COMMAND} #{secret_uuid.shellescape}") raise RuntimeError, "Could not read #{secret_uuid} from Bitwarden Secrets Manager" unless $?.success? item_json = JSON.parse(item_json) results[item_json["key"]] = item_json["value"] end else items_json = run_command(command) raise RuntimeError, "Could not read secrets from Bitwarden Secrets Manager" unless $?.success? JSON.parse(items_json).each do |item_json| results[item_json["key"]] = item_json["value"] end end end end def extract_command_and_project(secrets) if secrets.length == 1 if secrets[0] == LIST_ALL_SELECTOR [ LIST_COMMAND, nil ] elsif secrets[0].end_with?(LIST_ALL_FROM_PROJECT_SUFFIX) project = secrets[0].split(LIST_ALL_FROM_PROJECT_SUFFIX).first [ "#{LIST_COMMAND} #{project.shellescape}", project ] end end end def run_command(command, session: nil) full_command = [ "bws", command ].join(" ") `#{full_command}` end def login(account) run_command("project list") raise RuntimeError, "Could not authenticate to Bitwarden Secrets Manager. Did you set a valid access token?" unless $?.success? end def check_dependencies! raise RuntimeError, "Bitwarden Secrets Manager CLI is not installed" unless cli_installed? end def cli_installed? `bws --version 2> /dev/null` $?.success? end end ================================================ FILE: lib/kamal/secrets/adapters/doppler.rb ================================================ class Kamal::Secrets::Adapters::Doppler < Kamal::Secrets::Adapters::Base def requires_account? false end private def login(*) unless loggedin? `doppler login -y` raise RuntimeError, "Failed to login to Doppler" unless $?.success? end end def loggedin? `doppler me --json 2> /dev/null` $?.success? end def fetch_secrets(secrets, from:, **) secrets = prefixed_secrets(secrets, from: from) flags = secrets_get_flags(secrets) secret_names = secrets.collect { |s| s.split("/").last } items = `doppler secrets get #{secret_names.map(&:shellescape).join(" ")} --json #{flags}` raise RuntimeError, "Could not read #{secrets} from Doppler" unless $?.success? items = JSON.parse(items) items.transform_values { |value| value["computed"] } end def secrets_get_flags(secrets) unless service_token_set? project, config, _ = secrets.first.split("/") unless project && config raise RuntimeError, "Missing project or config from '--from=project/config' option" end project_and_config_flags = "-p #{project.shellescape} -c #{config.shellescape}" end end def service_token_set? ENV["DOPPLER_TOKEN"] && ENV["DOPPLER_TOKEN"][0, 5] == "dp.st" end def check_dependencies! raise RuntimeError, "Doppler CLI is not installed" unless cli_installed? end def cli_installed? `doppler --version 2> /dev/null` $?.success? end end ================================================ FILE: lib/kamal/secrets/adapters/enpass.rb ================================================ ## # Enpass is different from most password managers, in a way that it's offline and doesn't need an account. # # Usage # # Fetch all password from FooBar item # `kamal secrets fetch --adapter enpass --from /Users/YOUR_USERNAME/Library/Containers/in.sinew.Enpass-Desktop/Data/Documents/Vaults/primary FooBar` # # Fetch only DB_PASSWORD from FooBar item # `kamal secrets fetch --adapter enpass --from /Users/YOUR_USERNAME/Library/Containers/in.sinew.Enpass-Desktop/Data/Documents/Vaults/primary FooBar/DB_PASSWORD` class Kamal::Secrets::Adapters::Enpass < Kamal::Secrets::Adapters::Base def requires_account? false end private def fetch_secrets(secrets, from:, account:, session:) secrets_titles = fetch_secret_titles(secrets) result = `enpass-cli -json -vault #{from.shellescape} show #{secrets_titles.map(&:shellescape).join(" ")}`.strip parse_result_and_take_secrets(result, secrets) end def check_dependencies! raise RuntimeError, "Enpass CLI is not installed" unless cli_installed? end def cli_installed? `enpass-cli version 2> /dev/null` $?.success? end def login(account) nil end def fetch_secret_titles(secrets) secrets.reduce(Set.new) do |secret_titles, secret| # Sometimes secrets contain a '/', when the intent is to fetch a single password for an item. Example: FooBar/DB_PASSWORD # Another case is, when the intent is to fetch all passwords for an item. Example: FooBar (and FooBar may have multiple different passwords) key, separator, value = secret.rpartition("/") if key.empty? secret_titles << value else secret_titles << key end end.to_a end def parse_result_and_take_secrets(unparsed_result, secrets) result = JSON.parse(unparsed_result) result.reduce({}) do |secrets_with_passwords, item| title = item["title"] label = item["label"] password = item["password"] if title && password.present? key = [ title, label ].compact.reject(&:empty?).join("/") if secrets.include?(title) || secrets.include?(key) raise RuntimeError, "#{key} is present more than once" if secrets_with_passwords[key] secrets_with_passwords[key] = password end end secrets_with_passwords end end end ================================================ FILE: lib/kamal/secrets/adapters/gcp_secret_manager.rb ================================================ class Kamal::Secrets::Adapters::GcpSecretManager < Kamal::Secrets::Adapters::Base private def login(account) # Since only the account option is passed from the cli, we'll use it for both account and service account # impersonation. # # Syntax: # ACCOUNT: USER | USER "|" DELEGATION_CHAIN # USER: DEFAULT_USER | EMAIL # DELEGATION_CHAIN: EMAIL | EMAIL "," DELEGATION_CHAIN # EMAIL: # DEFAULT_USER: "default" # # Some valid examples: # - "my-user@example.com" sets the user # - "my-user@example.com|my-service-user@example.com" will use my-user and enable service account impersonation as my-service-user # - "default" will use the default user and no impersonation # - "default|my-service-user@example.com" will use the default user, and enable service account impersonation as my-service-user # - "default|my-service-user@example.com,another-service-user@example.com" same as above, but with an impersonation delegation chain unless logged_in? `gcloud auth login` raise RuntimeError, "could not login to gcloud" unless logged_in? end nil end def fetch_secrets(secrets, from:, account:, session:) user, service_account = parse_account(account) {}.tap do |results| secrets_with_metadata(prefixed_secrets(secrets, from: from)).each do |secret, (project, secret_name, secret_version)| item_name = "#{project}/#{secret_name}" results[item_name] = fetch_secret(project, secret_name, secret_version, user, service_account) raise RuntimeError, "Could not read #{item_name} from Google Secret Manager" unless $?.success? end end end def fetch_secret(project, secret_name, secret_version, user, service_account) secret = run_command( "secrets versions access #{secret_version.shellescape} --secret=#{secret_name.shellescape}", project: project, user: user, service_account: service_account ) Base64.decode64(secret.dig("payload", "data")) end # The secret needs to at least contain a secret name, but project name, and secret version can also be specified. # # The string "default" can be used to refer to the default project configured for gcloud. # # The version can be either the string "latest", or a version number. # # The following formats are valid: # # - The following are all equivalent, and sets project: default, secret name: my-secret, version: latest # - "my-secret" # - "default/my-secret" # - "default/my-secret/latest" # - "my-secret/latest" in combination with --from=default # - "my-secret/123" (only in combination with --from=some-project) -> project: some-project, secret name: my-secret, version: 123 # - "some-project/my-secret/123" -> project: some-project, secret name: my-secret, version: 123 def secrets_with_metadata(secrets) {}.tap do |items| secrets.each do |secret| parts = secret.split("/") parts.unshift("default") if parts.length == 1 project = parts.shift secret_name = parts.shift secret_version = parts.shift || "latest" items[secret] = [ project, secret_name, secret_version ] end end end def run_command(command, project: "default", user: "default", service_account: nil) full_command = [ "gcloud", command ] full_command << "--project=#{project.shellescape}" unless project == "default" full_command << "--account=#{user.shellescape}" unless user == "default" full_command << "--impersonate-service-account=#{service_account.shellescape}" if service_account full_command << "--format=json" full_command = full_command.join(" ") result = `#{full_command}`.strip JSON.parse(result) end def check_dependencies! raise RuntimeError, "gcloud CLI is not installed" unless cli_installed? end def cli_installed? `gcloud --version 2> /dev/null` $?.success? end def logged_in? JSON.parse(`gcloud auth list --format=json`).any? end def parse_account(account) account.split("|", 2) end def is_user?(candidate) candidate.include?("@") end end ================================================ FILE: lib/kamal/secrets/adapters/last_pass.rb ================================================ class Kamal::Secrets::Adapters::LastPass < Kamal::Secrets::Adapters::Base private def login(account) unless loggedin?(account) `lpass login #{account.shellescape}` raise RuntimeError, "Failed to login to LastPass" unless $?.success? end end def loggedin?(account) `lpass status --color never`.strip == "Logged in as #{account}." end def fetch_secrets(secrets, from:, account:, session:) secrets = prefixed_secrets(secrets, from: from) items = `lpass show #{secrets.map(&:shellescape).join(" ")} --json` raise RuntimeError, "Could not read #{secrets} from LastPass" unless $?.success? items = JSON.parse(items) {}.tap do |results| items.each do |item| results[item["fullname"]] = item["password"] end if (missing_items = secrets - results.keys).any? raise RuntimeError, "Could not find #{missing_items.join(", ")} in LastPass" end end end def check_dependencies! raise RuntimeError, "LastPass CLI is not installed" unless cli_installed? end def cli_installed? `lpass --version 2> /dev/null` $?.success? end end ================================================ FILE: lib/kamal/secrets/adapters/one_password.rb ================================================ class Kamal::Secrets::Adapters::OnePassword < Kamal::Secrets::Adapters::Base delegate :optionize, to: Kamal::Utils private def login(account) unless loggedin?(account) `op signin #{to_options(account: account, force: true, raw: true)}`.tap do raise RuntimeError, "Failed to login to 1Password" unless $?.success? end end end def loggedin?(account) `op account get --account #{account.shellescape} 2> /dev/null` $?.success? end def fetch_secrets(secrets, from:, account:, session:) if secrets.blank? fetch_all_secrets(from: from, account: account, session: session) else fetch_specified_secrets(secrets, from: from, account: account, session: session) end end def fetch_specified_secrets(secrets, from:, account:, session:) {}.tap do |results| vaults_items_fields(prefixed_secrets(secrets, from: from)).map do |vault, items| items.each do |item, fields| fields_json = JSON.parse(op_item_get(vault, item, fields: fields, account: account, session: session)) fields_json = [ fields_json ] if fields.one? results.merge!(fields_map(fields_json)) end end end end def fetch_all_secrets(from:, account:, session:) {}.tap do |results| vault_items(from).each do |vault, items| items.each do |item| fields_json = JSON.parse(op_item_get(vault, item, account: account, session: session)).fetch("fields") results.merge!(fields_map(fields_json)) end end end end def to_options(**options) optionize(options.compact).join(" ") end def vaults_items_fields(secrets) {}.tap do |vaults| secrets.each do |secret| secret = secret.delete_prefix("op://") vault, item, *fields = secret.split("/") fields << "password" if fields.empty? vaults[vault] ||= {} vaults[vault][item] ||= [] vaults[vault][item] << fields.join(".") end end end def vault_items(from) from = from.delete_prefix("op://") vault, item = from.split("/") { vault => [ item ] } end def fields_map(fields_json) fields_json.to_h do |field_json| # The reference is in the form `op://vault/item/field[/field]` field = field_json["reference"].delete_prefix("op://").delete_suffix("/password") [ field, field_json["value"] ] end end def op_item_get(vault, item, fields: nil, account:, session:) options = { vault: vault, format: "json", account: account, session: session.presence } if fields.present? labels = fields.map { |field| "label=#{field}" }.join(",") options.merge!(fields: labels) end `op item get #{item.shellescape} #{to_options(**options)}`.tap do raise RuntimeError, "Could not read #{"#{fields.join(", ")} " if fields.present?}from #{item} in the #{vault} 1Password vault" unless $?.success? end end def check_dependencies! raise RuntimeError, "1Password CLI is not installed" unless cli_installed? end def cli_installed? `op --version 2> /dev/null` $?.success? end end ================================================ FILE: lib/kamal/secrets/adapters/passbolt.rb ================================================ class Kamal::Secrets::Adapters::Passbolt < Kamal::Secrets::Adapters::Base def requires_account? false end private def login(*) `passbolt verify` raise RuntimeError, "Failed to login to Passbolt" unless $?.success? end def fetch_secrets(secrets, from:, **) secrets = prefixed_secrets(secrets, from: from) raise ArgumentError, "No secrets given to fetch" if secrets.empty? secret_names = secrets.collect { |s| s.split("/").last } folders = secrets_get_folders(secrets) # build filter conditions for each secret with its corresponding folder filter_conditions = [] secrets.each do |secret| parts = secret.split("/") secret_name = parts.last if parts.size > 1 # get the folder path without the secret name folder_path = parts[0..-2] # find the most nested folder for this path current_folder = nil current_path = [] folder_path.each do |folder_name| current_path << folder_name matching_folders = folders.select { |f| get_folder_path(f, folders) == current_path.join("/") } current_folder = matching_folders.first if matching_folders.any? end if current_folder filter_conditions << "(Name == #{secret_name.shellescape.inspect} && FolderParentID == #{current_folder["id"].shellescape.inspect})" end else # for root level secrets (no folders) filter_conditions << "Name == #{secret_name.shellescape.inspect}" end end filter_condition = filter_conditions.any? ? "--filter '#{filter_conditions.join(" || ")}'" : "" items = `passbolt list resources #{filter_condition} #{folders.map { |item| "--folder #{item["id"].to_s.shellescape}" }.join(" ")} --json` raise RuntimeError, "Could not read #{secrets} from Passbolt" unless $?.success? items = JSON.parse(items) found_names = items.map { |item| item["name"] } missing_secrets = secret_names - found_names raise RuntimeError, "Could not find the following secrets in Passbolt: #{missing_secrets.join(", ")}" if missing_secrets.any? items.to_h { |item| [ item["name"], item["password"] ] } end def secrets_get_folders(secrets) # extract all folder paths (both parent and nested) folder_paths = secrets .select { |s| s.include?("/") } .map { |s| s.split("/")[0..-2] } # get all parts except the secret name .uniq return [] if folder_paths.empty? all_folders = [] # first get all top-level folders parent_folders = folder_paths.map(&:first).uniq filter_condition = "--filter '#{parent_folders.map { |name| "Name == #{name.shellescape.inspect}" }.join(" || ")}'" fetch_folders = `passbolt list folders #{filter_condition} --json` raise RuntimeError, "Could not read folders from Passbolt" unless $?.success? parent_folder_items = JSON.parse(fetch_folders) all_folders.concat(parent_folder_items) # get nested folders for each parent folder_paths.each do |path| next if path.size <= 1 # skip non-nested folders parent = path[0] parent_folder = parent_folder_items.find { |f| f["name"] == parent } next unless parent_folder # for each nested level, get the folders using the parent's ID current_parent = parent_folder path[1..-1].each do |folder_name| filter_condition = "--filter 'Name == #{folder_name.shellescape.inspect} && FolderParentID == #{current_parent["id"].shellescape.inspect}'" fetch_nested = `passbolt list folders #{filter_condition} --json` next unless $?.success? nested_folders = JSON.parse(fetch_nested) break if nested_folders.empty? all_folders.concat(nested_folders) current_parent = nested_folders.first end end # check if we found all required folders found_paths = all_folders.map { |f| get_folder_path(f, all_folders) } missing_paths = folder_paths.map { |path| path.join("/") } - found_paths raise RuntimeError, "Could not find the following folders in Passbolt: #{missing_paths.join(", ")}" if missing_paths.any? all_folders end def get_folder_path(folder, all_folders, path = []) path.unshift(folder["name"]) return path.join("/") if folder["folder_parent_id"].to_s.empty? parent = all_folders.find { |f| f["id"] == folder["folder_parent_id"] } return path.join("/") unless parent get_folder_path(parent, all_folders, path) end def check_dependencies! raise RuntimeError, "Passbolt CLI is not installed" unless cli_installed? end def cli_installed? `passbolt --version 2> /dev/null` $?.success? end end ================================================ FILE: lib/kamal/secrets/adapters/test.rb ================================================ class Kamal::Secrets::Adapters::Test < Kamal::Secrets::Adapters::Base private def login(account) true end def fetch_secrets(secrets, from:, account:, session:) prefixed_secrets(secrets, from: from).to_h do |secret| [ secret, secret.gsub("LPAREN", "(").gsub("RPAREN", ")").reverse ] end end def check_dependencies! # no op end end ================================================ FILE: lib/kamal/secrets/adapters.rb ================================================ require "active_support/core_ext/string/inflections" module Kamal::Secrets::Adapters def self.lookup(name) name = "one_password" if name.downcase == "1password" name = "last_pass" if name.downcase == "lastpass" name = "gcp_secret_manager" if name.downcase == "gcp" name = "bitwarden_secrets_manager" if name.downcase == "bitwarden-sm" adapter_class(name) end def self.adapter_class(name) Object.const_get("Kamal::Secrets::Adapters::#{name.camelize}").new rescue NameError => e raise RuntimeError, "Unknown secrets adapter: #{name}" end end ================================================ FILE: lib/kamal/secrets/dotenv/inline_command_substitution.rb ================================================ class Kamal::Secrets::Dotenv::InlineCommandSubstitution # Unlike dotenv, this regex does not match escaped # parentheses when looking for command substitutions. INTERPOLATED_SHELL_COMMAND = / (?\\)? # is it escaped with a backslash? \$ # literal $ (? # collect command content for eval \( # require opening paren (?:\\.|[^()\\]|\g)+ # allow any number of non-parens or escaped # parens (by nesting the expression # recursively) \) # require closing paren ) /x class << self def install! ::Dotenv::Parser.substitutions.map! { |sub| sub == ::Dotenv::Substitutions::Command ? self : sub } end def call(value, env, overwrite: false) # Process interpolated shell commands value.gsub(INTERPOLATED_SHELL_COMMAND) do |*| # Eliminate opening and closing parentheses command = $LAST_MATCH_INFO[:cmd][1..-2] if $LAST_MATCH_INFO[:backslash] # Command is escaped, don't replace it. $LAST_MATCH_INFO[0][1..] else command = ::Dotenv::Substitutions::Variable.call(command, env) if command =~ /\A\s*kamal\s*secrets\s+/ # Inline the command inline_secrets_command(command) else # Execute the command and return the value `#{command}`.chomp end end end end def inline_secrets_command(command) Kamal::Cli::Main.start(command.shellsplit[1..] + [ "--inline" ]).chomp end end end ================================================ FILE: lib/kamal/secrets.rb ================================================ require "dotenv" class Kamal::Secrets Kamal::Secrets::Dotenv::InlineCommandSubstitution.install! def initialize(destination: nil, secrets_path:) @destination = destination @secrets_path = secrets_path @mutex = Mutex.new end def [](key) synchronized_fetch(key) rescue KeyError if secrets_files.present? raise Kamal::ConfigurationError, "Secret '#{key}' not found in #{secrets_files.join(", ")}" else raise Kamal::ConfigurationError, "Secret '#{key}' not found, no secret files (#{secrets_filenames.join(", ")}) provided" end end def to_h secrets end def secrets_files @secrets_files ||= secrets_filenames.select { |f| File.exist?(f) } end def key?(key) synchronized_fetch(key).present? rescue KeyError false end private def secrets @secrets ||= secrets_files.inject({}) do |secrets, secrets_file| secrets.merge!(::Dotenv.parse(secrets_file, overwrite: true)) end end def secrets_filenames [ "#{@secrets_path}-common", "#{@secrets_path}#{(".#{@destination}" if @destination)}" ] end def synchronized_fetch(key) # Fetching secrets may ask the user for input, so ensure only one thread does that @mutex.synchronize do secrets.fetch(key) end end end ================================================ FILE: lib/kamal/sshkit_with_ext.rb ================================================ require "sshkit" require "sshkit/dsl" require "net/scp" require "active_support/core_ext/hash/deep_merge" require "json" require "resolv" require "concurrent/atomic/semaphore" class SSHKit::Backend::Abstract def capture_with_info(*args, **kwargs) capture(*args, **kwargs, verbosity: Logger::INFO) end def capture_with_debug(*args, **kwargs) capture(*args, **kwargs, verbosity: Logger::DEBUG) end def capture_with_pretty_json(*args, **kwargs) JSON.pretty_generate(JSON.parse(capture(*args, **kwargs))) end def puts_by_host(host, output, type: "App", quiet: false) unless quiet puts "#{type} Host: #{host}" end puts "#{output}\n\n" end # Our execution pattern is for the CLI execute args lists returned # from commands, but this doesn't support returning execution options # from the command. # # Support this by using kwargs for CLI options and merging with the # args-extracted options. module CommandEnvMerge private # Override to merge options returned by commands in the args list with # options passed by the CLI and pass them along as kwargs. def command(args, options) more_options, args = args.partition { |a| a.is_a? Hash } more_options << options build_command(args, **more_options.reduce(:deep_merge)) end # Destructure options to pluck out env for merge def build_command(args, env: nil, **options) # Rely on native Ruby kwargs precedence rather than explicit Hash merges SSHKit::Command.new(*args, **default_command_options, **options, env: env_for(env)) end def default_command_options { in: pwd_path, host: @host, user: @user, group: @group } end def env_for(env) @env.to_h.merge(env.to_h) end end prepend CommandEnvMerge end class SSHKit::Backend::Netssh::Configuration attr_accessor :max_concurrent_starts, :dns_retries end class SSHKit::Backend::Netssh module DnsRetriable DNS_RETRY_BASE = 0.1 DNS_RETRY_MAX = 2.0 DNS_RETRY_JITTER = 0.1 DNS_ERROR_MESSAGE = /getaddrinfo|Temporary failure in name resolution|Name or service not known|nodename nor servname provided|No address associated|failed to look up|resolve/i def with_dns_retry(hostname, retries: config.dns_retries, base: DNS_RETRY_BASE, max_sleep: DNS_RETRY_MAX, jitter: DNS_RETRY_JITTER) attempts = 0 begin attempts += 1 yield rescue => error raise unless retryable_dns_error?(error) && attempts <= retries delay = dns_retry_sleep(attempts, base: base, jitter: jitter, max_sleep: max_sleep) SSHKit.config.output.warn("Retrying DNS for #{hostname} (attempt #{attempts}/#{retries}) in #{format("%0.2f", delay)}s: #{error.message}") sleep delay retry end end private def retryable_dns_error?(error) case error when Resolv::ResolvError, Resolv::ResolvTimeout true when SocketError error.message =~ DNS_ERROR_MESSAGE else error.cause && retryable_dns_error?(error.cause) end end def dns_retry_sleep(attempt, base:, jitter:, max_sleep:) sleep_for = [ base * (2 ** (attempt - 1)), max_sleep ].min sleep_for += Kernel.rand * jitter sleep_for end end module LimitConcurrentStartsClass attr_reader :start_semaphore def configure(&block) super &block # Create this here to avoid lazy creation by multiple threads if config.max_concurrent_starts @start_semaphore = Concurrent::Semaphore.new(config.max_concurrent_starts) end end end class << self prepend LimitConcurrentStartsClass prepend DnsRetriable end module ConnectSsh private def connect_ssh(...) Net::SSH.start(...) end end include ConnectSsh module DnsRetriableConnection private def connect_ssh(...) self.class.with_dns_retry(host.hostname) { super } end end prepend DnsRetriableConnection module LimitConcurrentStartsInstance private def with_ssh(&block) host.ssh_options = self.class.config.ssh_options.merge(host.ssh_options || {}) self.class.pool.with( method(:connect_ssh), String(host.hostname), host.username, host.netssh_options, &block ) end def connect_ssh(...) with_concurrency_limit { super } end def with_concurrency_limit(&block) if self.class.start_semaphore self.class.start_semaphore.acquire(&block) else yield end end end prepend LimitConcurrentStartsInstance end class SSHKit::Runner::Parallel # SSHKit joins the threads in sequence and fails on the first error it encounters, which means that we wait threads # before the first failure to complete but not for ones after. # # We'll patch it to wait for them all to complete, and to record all the threads that errored so we can see when a # problem occurs on multiple hosts. module CompleteAll def execute threads = hosts.map do |host| Thread.new(host) do |h| backend(h, &block).run rescue ::StandardError => e e2 = SSHKit::Runner::ExecuteError.new e raise e2, "Exception while executing #{host.user ? "as #{host.user}@" : "on host "}#{host}: #{e.message}" end end exceptions = [] threads.each do |t| begin t.join rescue SSHKit::Runner::ExecuteError => e exceptions << e end end if exceptions.one? raise exceptions.first elsif exceptions.many? raise exceptions.first, [ "Exceptions on #{exceptions.count} hosts:", exceptions.map(&:message) ].join("\n") end end end prepend CompleteAll end # Avoid net-ssh debug, until https://github.com/net-ssh/net-ssh/pull/953 is merged module NetSshForwardingNoPuts def puts(*) end end Net::SSH::Service::Forward.prepend NetSshForwardingNoPuts module SSHKitDslRoles # Execute on hosts grouped by role. # # Unlike `on()` which deduplicates hosts, this allows the same host to have # multiple concurrent connections when it appears in multiple roles. # # Options: # hosts: The hosts to run on (required) # parallel: When true, each role runs in its own thread with separate # connections. When false, hosts run in parallel but roles on each # host run sequentially (default: true) # # Example: # on_roles(roles) do |host, role| # # deploy role to host # end def on_roles(roles, hosts:, parallel: true, &block) if parallel threads = roles.filter_map do |role| if (role_hosts = role.hosts & hosts).any? Thread.new do on(role_hosts) { |host| instance_exec(host, role, &block) } rescue StandardError => e raise SSHKit::Runner::ExecuteError.new(e), "Exception while executing on #{role}: #{e.message}" end end end exceptions = [] threads.each do |t| begin t.join rescue SSHKit::Runner::ExecuteError => e exceptions << e end end if exceptions.one? raise exceptions.first elsif exceptions.many? raise exceptions.first, [ "Exceptions on #{exceptions.count} roles:", exceptions.map(&:message) ].join("\n") end else # Host-first iteration: hosts run in parallel, roles on each host run sequentially on(hosts) do |host| roles.each do |role| instance_exec(host, role, &block) if role.hosts.include?(host.to_s) end end end end end SSHKit::DSL.prepend SSHKitDslRoles ================================================ FILE: lib/kamal/tags.rb ================================================ require "time" class Kamal::Tags attr_reader :config, :tags class << self def from_config(config, **extra) new(**default_tags(config), **extra) end def default_tags(config) { recorded_at: Time.now.utc.iso8601, performer: Kamal::Git.email.presence || `whoami`.chomp, destination: config.destination, version: config.version, service_version: service_version(config), service: config.service } end def service_version(config) [ config.service, config.abbreviated_version ].compact.join("@") end end def initialize(**tags) @tags = tags.compact end def env tags.transform_keys { |detail| "KAMAL_#{detail.upcase}" } end def to_s tags.values.map { |value| "[#{value}]" }.join(" ") end def except(*tags) self.class.new(**self.tags.except(*tags)) end end ================================================ FILE: lib/kamal/utils/sensitive.rb ================================================ require "active_support/core_ext/module/delegation" require "sshkit" class Kamal::Utils::Sensitive # So SSHKit knows to redact these values. include SSHKit::Redaction attr_reader :unredacted, :redaction delegate :to_s, to: :unredacted delegate :inspect, to: :redaction def initialize(value, redaction: "[REDACTED]") @unredacted, @redaction = value, redaction end # Sensitive values won't leak into YAML output. def encode_with(coder) coder.represent_scalar nil, redaction end end ================================================ FILE: lib/kamal/utils.rb ================================================ require "active_support/core_ext/object/try" module Kamal::Utils extend self DOLLAR_SIGN_WITHOUT_SHELL_EXPANSION_REGEX = /\$(?!{[^\}]*\})/ # Return a list of escaped shell arguments using the same named argument against the passed attributes (hash or array). def argumentize(argument, attributes, sensitive: false) Array(attributes).flat_map do |key, value| if value.present? attr = "#{key}=#{escape_shell_value(value)}" attr = self.sensitive(attr, redaction: "#{key}=[REDACTED]") if sensitive [ argument, attr ] elsif value == false [ argument, "#{key}=false" ] else [ argument, key ] end end end # Returns a list of shell-dashed option arguments. If the value is true, it's treated like a value-less option. def optionize(args, with: nil, escape: true) options = if with flatten_args(args).collect { |(key, value)| value == true ? "--#{key}" : "--#{key}#{with}#{escape ? escape_shell_value(value) : value}" } else flatten_args(args).collect { |(key, value)| [ "--#{key}", value == true ? nil : escape ? escape_shell_value(value) : value ] } end options.flatten.compact end # Flattens a one-to-many structure into an array of two-element arrays each containing a key-value pair def flatten_args(args) args.flat_map { |key, value| value.try(:map) { |entry| [ key, entry ] } || [ [ key, value ] ] } end # Marks sensitive values for redaction in logs and human-visible output. # Pass `redaction:` to change the default `"[REDACTED]"` redaction, e.g. # `sensitive "#{arg}=#{secret}", redaction: "#{arg}=xxxx" def sensitive(...) Kamal::Utils::Sensitive.new(...) end def redacted(value) case when value.respond_to?(:redaction) value.redaction when value.respond_to?(:transform_values) value.transform_values { |value| redacted value } when value.respond_to?(:map) value.map { |element| redacted element } else value end end # Escape a value to make it safe for shell use. def escape_shell_value(value) value.to_s.scan(/[\x00-\x7F]+|[^\x00-\x7F]+/) \ .map { |part| part.ascii_only? ? escape_ascii_shell_value(part) : part } .join end def escape_ascii_shell_value(value) value.to_s.dump .gsub(/`/, '\\\\`') .gsub(DOLLAR_SIGN_WITHOUT_SHELL_EXPANSION_REGEX, '\$') end # Apply a list of host or role filters, including wildcard matches def filter_specific_items(filters, items) matches = [] Array(filters).select do |filter| matches += Array(items).select do |item| # Only allow * for a wildcard # items are roles or hosts File.fnmatch(filter, item.to_s, File::FNM_EXTGLOB) end end matches.uniq end def stable_sort!(elements, &block) elements.sort_by!.with_index { |element, index| [ block.call(element), index ] } end def join_commands(commands) commands.map(&:strip).join(" ") end def docker_arch arch = `docker info --format '{{.Architecture}}'`.strip case arch when /aarch64/ "arm64" when /x86_64/ "amd64" else arch end end def older_version?(version, other_version) Gem::Version.new(version.delete_prefix("v")) < Gem::Version.new(other_version.delete_prefix("v")) end end ================================================ FILE: lib/kamal/version.rb ================================================ module Kamal VERSION = "2.11.0" end ================================================ FILE: lib/kamal.rb ================================================ module Kamal class ConfigurationError < StandardError; end end require "active_support" require "zeitwerk" require "yaml" require "tmpdir" require "pathname" require "uri" loader = Zeitwerk::Loader.for_gem loader.ignore(File.join(__dir__, "kamal", "sshkit_with_ext.rb")) loader.setup loader.eager_load_namespace(Kamal::Cli) # We need all commands loaded. ================================================ FILE: test/cli/accessory_test.rb ================================================ require_relative "cli_test_case" class CliAccessoryTest < CliTestCase setup do setup_test_secrets("secrets" => "MYSQL_ROOT_PASSWORD=secret") end teardown do teardown_test_secrets end test "boot" do Kamal::Cli::Accessory.any_instance.expects(:directories).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:upload).with("mysql") run_command("boot", "mysql").tap do |output| assert_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.3", output assert_match "docker run --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env KAMAL_HOST=\"1.1.1.3\" --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" private.registry/mysql:5.7 on 1.1.1.3", output end end test "boot all" do Kamal::Cli::Accessory.any_instance.expects(:directories).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:upload).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:directories).with("redis") Kamal::Cli::Accessory.any_instance.expects(:upload).with("redis") Kamal::Cli::Accessory.any_instance.expects(:directories).with("busybox") Kamal::Cli::Accessory.any_instance.expects(:upload).with("busybox") run_command("boot", "all").tap do |output| assert_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.3", output assert_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.1", output assert_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.2", output assert_match "docker login other.registry -u [REDACTED] -p [REDACTED] on 1.1.1.3", output assert_match /docker network create kamal.*on 1.1.1.1/, output assert_match /docker network create kamal.*on 1.1.1.2/, output assert_match /docker network create kamal.*on 1.1.1.3/, output assert_match "docker run --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env KAMAL_HOST=\"1.1.1.3\" --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" private.registry/mysql:5.7 on 1.1.1.3", output assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env KAMAL_HOST=\"1.1.1.1\" --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env KAMAL_HOST=\"1.1.1.2\" --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.2", output assert_match "docker run --name custom-box --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --env KAMAL_HOST=\"1.1.1.3\" --env-file .kamal/apps/app/env/accessories/busybox.env --label service=\"custom-box\" other.registry/busybox:latest on 1.1.1.3", output end end test "upload" do run_command("upload", "mysql").tap do |output| assert_match "mkdir -p app-mysql/etc/mysql", output assert_match "test/fixtures/files/my.cnf to app-mysql/etc/mysql/my.cnf", output assert_match "chmod 755 app-mysql/etc/mysql/my.cnf", output end end test "directories" do assert_match "mkdir -p $PWD/app-mysql/data", run_command("directories", "mysql") end test "reboot" do Kamal::Commands::Registry.any_instance.expects(:login) Kamal::Cli::Accessory.any_instance.expects(:pull_image).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:stop).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:boot).with("mysql", prepare: false) run_command("reboot", "mysql") end test "reboot all" do Kamal::Commands::Registry.any_instance.expects(:login).times(4) Kamal::Cli::Accessory.any_instance.expects(:pull_image).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:stop).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:boot).with("mysql", prepare: false) Kamal::Cli::Accessory.any_instance.expects(:pull_image).with("redis") Kamal::Cli::Accessory.any_instance.expects(:stop).with("redis") Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("redis") Kamal::Cli::Accessory.any_instance.expects(:boot).with("redis", prepare: false) Kamal::Cli::Accessory.any_instance.expects(:pull_image).with("busybox") Kamal::Cli::Accessory.any_instance.expects(:stop).with("busybox") Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("busybox") Kamal::Cli::Accessory.any_instance.expects(:boot).with("busybox", prepare: false) run_command("reboot", "all") end test "start" do assert_match "docker container start app-mysql", run_command("start", "mysql") end test "stop" do assert_match "docker container stop app-mysql", run_command("stop", "mysql") end test "restart" do Kamal::Cli::Accessory.any_instance.expects(:stop).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:start).with("mysql") run_command("restart", "mysql") end test "details" do run_command("details", "mysql").tap do |output| assert_match "docker ps --filter label=service=app-mysql", output assert_match "Accessory mysql Host: 1.1.1.3", output end end test "details with non-existent accessory" do assert_equal "No accessory by the name of 'hello' (options: mysql, redis, and busybox)", stderred { run_command("details", "hello") } end test "details with all" do run_command("details", "all").tap do |output| assert_match "Accessory mysql Host: 1.1.1.3", output assert_match "Accessory redis Host: 1.1.1.2", output assert_match "docker ps --filter label=service=app-mysql", output assert_match "docker ps --filter label=service=app-redis", output end end test "exec" do run_command("exec", "mysql", "mysql -v").tap do |output| assert_match "docker login private.registry -u [REDACTED] -p [REDACTED]", output assert_match "Launching command from new container", output assert_match "mysql -v", output end end test "exec with reuse" do run_command("exec", "mysql", "--reuse", "mysql -v").tap do |output| assert_match "Launching command from existing container", output assert_match "docker exec app-mysql mysql -v", output end end test "logs" do SSHKit::Backend::Abstract.any_instance.stubs(:exec) .with("ssh -t root@1.1.1.3 'docker logs app-mysql --timestamps --tail 10 2>&1'") assert_match "docker logs app-mysql --tail 100 --timestamps 2>&1", run_command("logs", "mysql") end test "logs with grep" do SSHKit::Backend::Abstract.any_instance.stubs(:exec) .with("ssh -t root@1.1.1.3 'docker logs app-mysql --timestamps 2>&1 | grep \'hey\''") assert_match "docker logs app-mysql --timestamps 2>&1 | grep 'hey'", run_command("logs", "mysql", "--grep", "hey") end test "logs with grep and grep options" do SSHKit::Backend::Abstract.any_instance.stubs(:exec) .with("ssh -t root@1.1.1.3 'docker logs app-mysql --timestamps 2>&1 | grep \'hey\' -C 2'") assert_match "docker logs app-mysql --timestamps 2>&1 | grep 'hey' -C 2", run_command("logs", "mysql", "--grep", "hey", "--grep-options", "-C 2") end test "logs with follow" do SSHKit::Backend::Abstract.any_instance.stubs(:exec) .with("ssh -t root@1.1.1.3 -p 22 'docker logs app-mysql --timestamps --tail 10 --follow 2>&1'") assert_match "docker logs app-mysql --timestamps --tail 10 --follow 2>&1", run_command("logs", "mysql", "--follow") end test "logs with follow and grep" do SSHKit::Backend::Abstract.any_instance.stubs(:exec) .with("ssh -t root@1.1.1.3 -p 22 'docker logs app-mysql --timestamps --tail 10 --follow 2>&1 | grep \"hey\"'") assert_match "docker logs app-mysql --timestamps --tail 10 --follow 2>&1 | grep \"hey\"", run_command("logs", "mysql", "--follow", "--grep", "hey") end test "logs with follow, grep, and grep options" do SSHKit::Backend::Abstract.any_instance.stubs(:exec) .with("ssh -t root@1.1.1.3 -p 22 'docker logs app-mysql --timestamps --tail 10 --follow 2>&1 | grep \"hey\" -C 2'") assert_match "docker logs app-mysql --timestamps --tail 10 --follow 2>&1 | grep \"hey\" -C 2", run_command("logs", "mysql", "--follow", "--grep", "hey", "--grep-options", "-C 2") end test "remove with confirmation" do Kamal::Cli::Accessory.any_instance.expects(:stop).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:remove_image).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:remove_service_directory).with("mysql") run_command("remove", "mysql", "-y") end test "remove all with confirmation" do Kamal::Cli::Accessory.any_instance.expects(:stop).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:remove_image).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:remove_service_directory).with("mysql") Kamal::Cli::Accessory.any_instance.expects(:stop).with("redis") Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("redis") Kamal::Cli::Accessory.any_instance.expects(:remove_image).with("redis") Kamal::Cli::Accessory.any_instance.expects(:remove_service_directory).with("redis") Kamal::Cli::Accessory.any_instance.expects(:stop).with("busybox") Kamal::Cli::Accessory.any_instance.expects(:remove_container).with("busybox") Kamal::Cli::Accessory.any_instance.expects(:remove_image).with("busybox") Kamal::Cli::Accessory.any_instance.expects(:remove_service_directory).with("busybox") run_command("remove", "all", "-y") end test "remove_container" do assert_match "docker container prune --force --filter label=service=app-mysql", run_command("remove_container", "mysql") end test "pull_image" do assert_match "docker image pull private.registry/mysql:5.7", run_command("pull_image", "mysql") end test "remove_image" do assert_match "docker image rm --force private.registry/mysql:5.7", run_command("remove_image", "mysql") end test "remove_service_directory" do assert_match "rm -rf app-mysql", run_command("remove_service_directory", "mysql") end test "hosts param respected" do Kamal::Cli::Accessory.any_instance.expects(:directories).with("redis") Kamal::Cli::Accessory.any_instance.expects(:upload).with("redis") run_command("boot", "redis", "--hosts", "1.1.1.1").tap do |output| assert_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.1", output assert_no_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.2", output assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env KAMAL_HOST=\"1.1.1.1\" --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output assert_no_match /docker run --name app-redis .* on 1.1.1.2/, output end end test "hosts param intersected with configuration" do Kamal::Cli::Accessory.any_instance.expects(:directories).with("redis") Kamal::Cli::Accessory.any_instance.expects(:upload).with("redis") run_command("boot", "redis", "--hosts", "1.1.1.1,1.1.1.3").tap do |output| assert_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.1", output assert_no_match "docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.3", output assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env KAMAL_HOST=\"1.1.1.1\" --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output assert_no_match /docker run --name app-redis .* on 1.1.1.3/, output end end test "upgrade" do run_command("upgrade", "-y", "all").tap do |output| assert_match "Upgrading all accessories on 1.1.1.3,1.1.1.1,1.1.1.2...", output assert_match "docker network create kamal on 1.1.1.3", output assert_match "docker container stop app-mysql on 1.1.1.3", output assert_match "docker run --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env KAMAL_HOST=\"1.1.1.3\" --env MYSQL_ROOT_HOST="%" --env-file .kamal/apps/app/env/accessories/mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" private.registry/mysql:5.7 on 1.1.1.3", output assert_match "Upgraded all accessories on 1.1.1.3,1.1.1.1,1.1.1.2...", output end end test "upgrade rolling" do run_command("upgrade", "--rolling", "-y", "all").tap do |output| assert_match "Upgrading all accessories on 1.1.1.3...", output assert_match "docker network create kamal on 1.1.1.3", output assert_match "docker container stop app-mysql on 1.1.1.3", output assert_match "docker run --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env KAMAL_HOST=\"1.1.1.3\" --env MYSQL_ROOT_HOST="%" --env-file .kamal/apps/app/env/accessories/mysql.env --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=\"app-mysql\" private.registry/mysql:5.7 on 1.1.1.3", output assert_match "Upgraded all accessories on 1.1.1.3", output end end test "boot with web role filter" do run_command("boot", "redis", "-r", "web").tap do |output| assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env KAMAL_HOST=\"1.1.1.1\" --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.1", output assert_match "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env KAMAL_HOST=\"1.1.1.2\" --env-file .kamal/apps/app/env/accessories/redis.env --volume $PWD/app-redis/data:/data --label service=\"app-redis\" redis:latest on 1.1.1.2", output end end test "boot with workers role filter" do run_command("boot", "redis", "-r", "workers").tap do |output| assert_no_match "docker run", output end end private def run_command(*command) stdouted { Kamal::Cli::Accessory.start([ *command, "-c", "test/fixtures/deploy_with_accessories_with_different_registries.yml" ]) } end end ================================================ FILE: test/cli/app_test.rb ================================================ require_relative "cli_test_case" class CliAppTest < CliTestCase test "boot" do stub_running run_command("boot").tap do |output| assert_match "docker tag dhh/app:latest dhh/app:latest", output assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12} /, output assert_match "docker container ls --all --filter 'name=^app-web-123$' --quiet | xargs docker stop", output end end test "boot will rename if same version is already running" do Object.any_instance.stubs(:sleep) run_command("details") # Preheat Kamal const SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :container, :ls, "--all", "--filter", "'name=^app-web-latest$'", "--quiet", raise_on_non_zero_exit: false) .returns("12345678") # running version SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .returns("123") # old version SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :container, :ls, "--all", "--filter", "'name=^app-web-latest$'", "--quiet") .returns("12345678") # running version run_command("boot").tap do |output| assert_match /Renaming container .* to .* as already deployed on 1.1.1.1/, output # Rename assert_match /docker rename app-web-latest app-web-latest_replaced_[0-9a-f]{16}/, output assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12} /, output assert_match "docker container ls --all --filter 'name=^app-web-123$' --quiet | xargs docker stop", output end ensure Thread.report_on_exception = true end test "boot uses group strategy when specified" do Kamal::Cli::App.any_instance.stubs(:on).with("1.1.1.1").twice Kamal::Cli::App.any_instance.stubs(:on).with([ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ]).times(3) # Strategy is used when booting the containers Kamal::Cli::App.any_instance.expects(:on).with([ "1.1.1.1", "1.1.1.2", "1.1.1.3" ]).with_block_given Kamal::Cli::App.any_instance.expects(:on).with([ "1.1.1.4" ]).with_block_given Object.any_instance.expects(:sleep).with(2).twice Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) run_command("boot", config: :with_boot_strategy, host: nil).tap do |output| assert_hook_ran "pre-app-boot", output, count: 2 assert_hook_ran "post-app-boot", output, count: 2 end end test "boot without parallel roles" do # Without parallel_roles: on() called with all hosts, roles sequential per host Kamal::Cli::App.any_instance.expects(:on).with("1.1.1.1").with_block_given.twice Kamal::Cli::App.any_instance.expects(:on).with([ "1.1.1.1", "1.1.1.2", "1.1.1.3" ]).with_block_given.times(4) run_command("boot", config: :without_parallel_roles, host: nil) end test "boot with parallel roles" do # With parallel_roles: each role gets its own on() call Kamal::Cli::App.any_instance.expects(:on).with("1.1.1.1").with_block_given.twice Kamal::Cli::App.any_instance.expects(:on).with([ "1.1.1.1", "1.1.1.2", "1.1.1.3" ]).with_block_given.times(3) Kamal::Cli::App.any_instance.expects(:on).with([ "1.1.1.1", "1.1.1.2" ]).with_block_given Kamal::Cli::App.any_instance.expects(:on).with([ "1.1.1.1", "1.1.1.3" ]).with_block_given run_command("boot", config: :with_parallel_roles, host: nil) end test "boot errors don't leave lock in place" do Kamal::Cli::App.any_instance.expects(:using_version).raises(RuntimeError) assert_not KAMAL.holding_lock? assert_raises(RuntimeError) do stderred { run_command("boot") } end assert_not KAMAL.holding_lock? end test "boot with assets" do Object.any_instance.stubs(:sleep) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :container, :ls, "--all", "--filter", "'name=^app-web-latest$'", "--quiet", raise_on_non_zero_exit: false) .returns("12345678") # running version SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .returns("123").twice # old version SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :container, :ls, "--all", "--filter", "'name=^app-web-latest$'", "--quiet") .returns("12345678") # running version run_command("boot", config: :with_assets).tap do |output| assert_match "docker tag dhh/app:latest dhh/app:latest", output assert_match "/usr/bin/env mkdir -p .kamal/apps/app/assets/volumes/web-latest ; cp -rnT .kamal/apps/app/assets/extracted/web-latest .kamal/apps/app/assets/volumes/web-latest ; cp -rnT .kamal/apps/app/assets/extracted/web-latest .kamal/apps/app/assets/volumes/web-123 || true ; cp -rnT .kamal/apps/app/assets/extracted/web-123 .kamal/apps/app/assets/volumes/web-latest || true", output assert_match "/usr/bin/env mkdir -p .kamal/apps/app/assets/extracted/web-latest && docker container rm app-web-assets 2> /dev/null || true && docker container create --name app-web-assets dhh/app:latest && docker container cp -L app-web-assets:/public/assets/. .kamal/apps/app/assets/extracted/web-latest && docker container rm app-web-assets", output assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12} /, output assert_match "docker container ls --all --filter 'name=^app-web-123$' --quiet | xargs docker stop", output assert_match "/usr/bin/env find .kamal/apps/app/assets/extracted -maxdepth 1 -name 'web-*' ! -name web-latest -exec rm -rf \"{}\" + ; find .kamal/apps/app/assets/volumes -maxdepth 1 -name 'web-*' ! -name web-latest -exec rm -rf \"{}\" +", output end end test "boot with host tags" do Object.any_instance.stubs(:sleep) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :container, :ls, "--all", "--filter", "'name=^app-web-latest$'", "--quiet", raise_on_non_zero_exit: false) .returns("12345678") # running version SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :container, :ls, "--all", "--filter", "'name=^app-web-latest$'", "--quiet") .returns("12345678") # running version SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .returns("123") # old version run_command("boot", config: :with_env_tags).tap do |output| assert_match "docker tag dhh/app:latest dhh/app:latest", output assert_match %r{docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12} --env KAMAL_CONTAINER_NAME="app-web-latest" --env KAMAL_VERSION="latest" --env KAMAL_HOST="1.1.1.1" --env TEST="root" --env EXPERIMENT="disabled" --env SITE="site1"}, output assert_match "docker container ls --all --filter 'name=^app-web-123$' --quiet | xargs docker stop", output end end test "boot with web barrier opened" do Object.any_instance.stubs(:sleep) SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("123") # old version SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :container, :ls, "--all", "--filter", "'name=^app-workers-latest$'", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") .returns("running").at_least_once # workers health check run_command("boot", config: :with_roles, host: nil).tap do |output| assert_match "Waiting for the first healthy web container before booting workers on 1.1.1.3...", output assert_match "Waiting for the first healthy web container before booting workers on 1.1.1.4...", output assert_match "First web container is healthy, booting workers on 1.1.1.3", output assert_match "First web container is healthy, booting workers on 1.1.1.4", output end end test "boot with web barrier closed" do Thread.report_on_exception = false Object.any_instance.stubs(:sleep) SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("123") # old version SSHKit::Backend::Abstract.any_instance.stubs(:execute).returns("") SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with(:docker, :container, :ls, "--all", "--filter", "'name=^app-web-latest$'", "--quiet", "|", :xargs, :docker, :stop, raise_on_non_zero_exit: false) SSHKit::Backend::Abstract.any_instance.expects(:execute) .with(:docker, :exec, "kamal-proxy", "kamal-proxy", :deploy, "app-web", "--target=\"123:80\"", "--deploy-timeout=\"1s\"", "--drain-timeout=\"30s\"", "--buffer-requests", "--buffer-responses", "--log-request-header=\"Cache-Control\"", "--log-request-header=\"Last-Modified\"", "--log-request-header=\"User-Agent\"").raises(SSHKit::Command::Failed.new("Failed to deploy")) stderred do run_command("boot", config: :with_roles, host: nil, allow_execute_error: true).tap do |output| assert_match "Waiting for the first healthy web container before booting workers on 1.1.1.3...", output assert_match "Waiting for the first healthy web container before booting workers on 1.1.1.4...", output assert_match "First web container is unhealthy, not booting workers on 1.1.1.3", output assert_match "First web container is unhealthy, not booting workers on 1.1.1.4", output end end ensure Thread.report_on_exception = true end test "boot with worker errors" do Thread.report_on_exception = false Object.any_instance.stubs(:sleep) SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("123") # old version SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :container, :ls, "--all", "--filter", "'name=^app-workers-latest$'", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") .returns("unhealthy").at_least_once # workers health check run_command("boot", config: :with_roles, host: nil, allow_execute_error: true).tap do |output| assert_match "Waiting for the first healthy web container before booting workers on 1.1.1.3...", output assert_match "Waiting for the first healthy web container before booting workers on 1.1.1.4...", output assert_match "First web container is healthy, booting workers on 1.1.1.3", output assert_match "First web container is healthy, booting workers on 1.1.1.4", output assert_match "ERROR Failed to boot workers on 1.1.1.3", output assert_match "ERROR Failed to boot workers on 1.1.1.4", output end ensure Thread.report_on_exception = true end test "boot with worker ready then not" do Thread.report_on_exception = false Object.any_instance.stubs(:sleep) SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("123") # old version SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :container, :ls, "--all", "--filter", "'name=^app-workers-latest$'", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") .returns("running", "stopped").at_least_once # workers health check run_command("boot", config: :with_roles, host: "1.1.1.3", allow_execute_error: true).tap do |output| assert_match "ERROR Failed to boot workers on 1.1.1.3", output end ensure Thread.report_on_exception = true end test "boot with only workers" do Object.any_instance.stubs(:sleep) SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("123") # old version SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :container, :ls, "--all", "--filter", "'name=^app-workers-latest$'", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") .returns("running").at_least_once # workers health check run_command("boot", config: :with_only_workers, host: nil).tap do |output| assert_match /First workers container is healthy on 1.1.1.\d, booting any other roles/, output assert_no_match "kamal-proxy", output end end test "boot with error pages" do with_error_pages(directory: "public") do stub_running run_command("boot", config: :with_error_pages).tap do |output| assert_match /Uploading .*kamal-error-pages.*\/latest to \.kamal\/proxy\/apps-config\/app\/error_pages/, output assert_match "docker tag dhh/app:latest dhh/app:latest", output assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12} /, output assert_match "docker container ls --all --filter 'name=^app-web-123$' --quiet | xargs docker stop", output assert_match "Running /usr/bin/env find .kamal/proxy/apps-config/app/error_pages -mindepth 1 -maxdepth 1 ! -name latest -exec rm -rf {} + on 1.1.1.1", output end end end test "boot with custom ssl certificate" do Kamal::Configuration::Proxy.any_instance.stubs(:custom_ssl_certificate?).returns(true) Kamal::Configuration::Proxy.any_instance.stubs(:certificate_pem_content).returns("CERTIFICATE CONTENT") Kamal::Configuration::Proxy.any_instance.stubs(:private_key_pem_content).returns("PRIVATE KEY CONTENT") stub_running run_command("boot", config: :with_proxy).tap do |output| assert_match "Writing SSL certificates for web on 1.1.1.1", output assert_match "mkdir -p .kamal/proxy/apps-config/app/tls", output assert_match "Uploading \"CERTIFICATE CONTENT\" to .kamal/proxy/apps-config/app/tls/web/cert.pem", output assert_match "--tls-certificate-path=\"/home/kamal-proxy/.apps-config/app/tls/web/cert.pem\"", output assert_match "--tls-private-key-path=\"/home/kamal-proxy/.apps-config/app/tls/web/key.pem\"", output end end test "start" do SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("999") # old version run_command("start").tap do |output| assert_match "docker start app-web-999", output assert_match "docker exec kamal-proxy kamal-proxy deploy app-web --target=\"999:80\" --deploy-timeout=\"30s\" --drain-timeout=\"30s\" --buffer-requests --buffer-responses --log-request-header=\"Cache-Control\" --log-request-header=\"Last-Modified\"", output end end test "stop" do run_command("stop").tap do |output| assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker stop", output end end test "stale_containers" do SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=destination=", "--filter", "label=role=web", "--format", "\"{{.Names}}\"", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .returns("12345678\n87654321\n") SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .returns("12345678\n") run_command("stale_containers").tap do |output| assert_match /Detected stale container for role web with version 87654321/, output end end test "stop stale_containers" do SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=destination=", "--filter", "label=role=web", "--format", "\"{{.Names}}\"", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .returns("12345678\n87654321\n") SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .returns("12345678\n") run_command("stale_containers", "--stop").tap do |output| assert_match /Stopping stale container for role web with version 87654321/, output assert_match /#{Regexp.escape("docker container ls --all --filter 'name=^app-web-87654321$' --quiet | xargs docker stop")}/, output end end test "details" do run_command("details").tap do |output| assert_match "docker ps --filter label=service=app --filter label=destination= --filter label=role=web", output end end test "remove" do run_command("remove").tap do |output| assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker stop", output assert_match "docker container prune --force --filter label=service=app", output assert_match "docker image prune --all --force --filter label=service=app", output assert_match "rm -r .kamal/apps/app on 1.1.1.1", output assert_match "rm -r .kamal/proxy/apps-config/app on 1.1.1.1", output end end test "remove with role filter does not remove images or app directories" do run_command("remove", "-r", "workers", config: :with_two_roles_one_host).tap do |output| assert_match "docker stop", output assert_match "docker container prune --force --filter label=service=app", output # Images and directories should NOT be removed when other roles remain on the host assert_no_match(/docker image prune --all --force --filter label=service=app/, output) assert_no_match(/rm -r .kamal\/apps\/app/, output) assert_no_match(/rm -r .kamal\/proxy\/apps-config\/app/, output) end end test "remove with all roles on host removes images and app directories" do run_command("remove", "-r", "workers,web", config: :with_two_roles_one_host).tap do |output| assert_match "docker stop", output assert_match "docker container prune --force --filter label=service=app", output # Images and directories SHOULD be removed when all roles on host are removed assert_match "docker image prune --all --force --filter label=service=app", output assert_match "rm -r .kamal/apps/app on 1.1.1.1", output assert_match "rm -r .kamal/proxy/apps-config/app on 1.1.1.1", output end end test "remove_container" do run_command("remove_container", "1234567").tap do |output| assert_match "docker container ls --all --filter 'name=^app-web-1234567$' --quiet | xargs docker container rm", output end end test "remove_containers" do run_command("remove_containers").tap do |output| assert_match "docker container prune --force --filter label=service=app", output end end test "remove_images" do run_command("remove_images").tap do |output| assert_match "docker image prune --all --force --filter label=service=app", output end end test "remove_app_directories" do run_command("remove_app_directories").tap do |output| assert_match "rm -r .kamal/apps/app on 1.1.1.1", output assert_match "rm -r .kamal/proxy/apps-config/app on 1.1.1.1", output end end test "exec" do run_command("exec", "ruby -v").tap do |output| assert_match "docker login -u [REDACTED] -p [REDACTED]", output assert_match %r{docker run --rm --name app-web-exec-latest-[0-9a-f]{6} --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size="10m" dhh/app:latest ruby -v}, output end end test "exec without command fails" do error = assert_raises(ArgumentError, "Exec requires a command to be specified") do run_command("exec") end assert_equal "No command provided. You must specify a command to execute.", error.message end test "exec separate arguments" do run_command("exec", "ruby", " -v").tap do |output| assert_match %r{docker run --rm --name app-web-exec-latest-[0-9a-f]{6} --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size="10m" dhh/app:latest ruby -v}, output end end test "exec detach" do run_command("exec", "--detach", "ruby -v").tap do |output| assert_match %r{docker run --detach --name app-web-exec-latest-[0-9a-f]{6} --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size="10m" dhh/app:latest ruby -v}, output end end test "exec detach with reuse" do assert_raises(ArgumentError, "Detach is not compatible with reuse") do run_command("exec", "--detach", "--reuse", "ruby -v") end end test "exec detach with interactive" do assert_raises(ArgumentError, "Detach is not compatible with interactive") do run_command("exec", "--interactive", "--detach", "ruby -v") end end test "exec detach with interactive and reuse" do assert_raises(ArgumentError, "Detach is not compatible with interactive or reuse") do run_command("exec", "--interactive", "--detach", "--reuse", "ruby -v") end end test "exec with reuse" do run_command("exec", "--reuse", "ruby -v").tap do |output| assert_match "sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done", output # Get current version assert_match "docker exec app-web-999 ruby -v", output end end test "exec interactive" do Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) SSHKit::Backend::Abstract.any_instance.expects(:exec) .with(regexp_matches(%r{ssh -t root@1\.1\.1\.1 -p 22 'docker run -it --rm --name app-web-exec-latest-[0-9a-f]{6} --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size="10m" dhh/app:latest ruby -v'})) stub_stdin_tty do run_command("exec", "-i", "ruby -v").tap do |output| assert_hook_ran "pre-connect", output assert_match "docker login -u [REDACTED] -p [REDACTED]", output assert_match "Get most recent version available as an image...", output assert_match "Launching interactive command with version latest via SSH from new container on 1.1.1.1...", output end end end test "exec interactive with reuse" do Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) SSHKit::Backend::Abstract.any_instance.expects(:exec) .with("ssh -t root@1.1.1.1 -p 22 'docker exec -it app-web-999 ruby -v'") stub_stdin_tty do run_command("exec", "-i", "--reuse", "ruby -v").tap do |output| assert_hook_ran "pre-connect", output assert_match "Get current version of running container...", output assert_match "Running /usr/bin/env sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done on 1.1.1.1", output assert_match "Launching interactive command with version 999 via SSH from existing container on 1.1.1.1...", output end end end test "exec interactive with pipe on STDIN" do Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) SSHKit::Backend::Abstract.any_instance.expects(:exec) .with("ssh -t root@1.1.1.1 -p 22 'docker exec -i app-web-999 ruby -v'") stub_stdin_file do run_command("exec", "-i", "--reuse", "ruby -v").tap do |output| assert_hook_ran "pre-connect", output assert_match "Launching interactive command with version 999 via SSH from existing container on 1.1.1.1...", output end end end test "containers" do run_command("containers").tap do |output| assert_match "docker container ls --all --filter label=service=app", output end end test "images" do run_command("images").tap do |output| assert_match "docker image ls dhh/app", output end end test "logs" do SSHKit::Backend::Abstract.any_instance.stubs(:exec) .with("ssh -t root@1.1.1.1 'sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1| xargs docker logs --timestamps --tail 10 2>&1'") assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --tail 100 2>&1", run_command("logs") assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps 2>&1 | grep 'hey'", run_command("logs", "--grep", "hey") assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps 2>&1 | grep 'hey' -C 2", run_command("logs", "--grep", "hey", "--grep-options", "-C 2") end test "logs with follow" do SSHKit::Backend::Abstract.any_instance.stubs(:exec) .with("ssh -t root@1.1.1.1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --tail 10 --follow 2>&1'") assert_match "sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --tail 10 --follow 2>&1", run_command("logs", "--follow") end test "logs with follow and container_id" do SSHKit::Backend::Abstract.any_instance.stubs(:exec) .with("ssh -t root@1.1.1.1 -p 22 'echo ID123 | xargs docker logs --timestamps --tail 10 --follow 2>&1'") assert_match "echo ID123 | xargs docker logs --timestamps --tail 10 --follow 2>&1", run_command("logs", "--follow", "--container-id", "ID123") end test "logs with follow and grep" do SSHKit::Backend::Abstract.any_instance.stubs(:exec) .with("ssh -t root@1.1.1.1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"hey\"'") assert_match "sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"hey\"", run_command("logs", "--follow", "--grep", "hey") end test "logs with follow, grep and grep options" do SSHKit::Backend::Abstract.any_instance.stubs(:exec) .with("ssh -t root@1.1.1.1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"hey\" -C 2'") assert_match "sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"hey\" -C 2", run_command("logs", "--follow", "--grep", "hey", "--grep-options", "-C 2") end test "version" do run_command("version").tap do |output| assert_match "sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done", output end end test "version through main" do with_argv([ "app", "version", "-c", "test/fixtures/deploy_with_accessories.yml", "--hosts", "1.1.1.1" ]) do stdouted { Kamal::Cli::Main.start }.tap do |output| assert_match "sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done", output end end end test "long hostname" do stub_running hostname = "this-hostname-is-really-unacceptably-long-to-be-honest.example.com" stdouted { Kamal::Cli::App.start([ "boot", "-c", "test/fixtures/deploy_with_uncommon_hostnames.yml", "--hosts", hostname ]) }.tap do |output| assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname this-hostname-is-really-unacceptably-long-to-be-hon-[0-9a-f]{12} /, output end end test "hostname is trimmed if will end with a period" do stub_running hostname = "this-hostname-with-random-part-is-too-long.example.com" stdouted { Kamal::Cli::App.start([ "boot", "-c", "test/fixtures/deploy_with_uncommon_hostnames.yml", "--hosts", hostname ]) }.tap do |output| assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname this-hostname-with-random-part-is-too-long.example-[0-9a-f]{12} /, output end end test "boot proxy" do SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("123") # old version run_command("boot", config: :with_proxy).tap do |output| assert_match /Renaming container .* to .* as already deployed on 1.1.1.1/, output # Rename assert_match /docker rename app-web-latest app-web-latest_replaced_[0-9a-f]{16}/, output assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12} --env KAMAL_CONTAINER_NAME="app-web-latest" --env KAMAL_VERSION="latest" --env KAMAL_HOST="1.1.1.1" --env-file .kamal\/apps\/app\/env\/roles\/web.env --log-opt max-size="10m" --label service="app" --label role="web" --label destination dhh\/app:latest/, output assert_match /docker exec kamal-proxy kamal-proxy deploy app-web --target="123:80"/, output assert_match "docker container ls --all --filter 'name=^app-web-123$' --quiet | xargs docker stop", output end end test "boot proxy with role specific config" do SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("123") # old version run_command("boot", config: :with_proxy_roles, host: nil).tap do |output| assert_match "docker exec kamal-proxy kamal-proxy deploy app-web --target=\"123:80\" --deploy-timeout=\"6s\" --drain-timeout=\"30s\" --target-timeout=\"10s\" --buffer-requests --buffer-responses --log-request-header=\"Cache-Control\" --log-request-header=\"Last-Modified\" --log-request-header=\"User-Agent\"", output assert_match "docker exec kamal-proxy kamal-proxy deploy app-web2 --target=\"123:80\" --deploy-timeout=\"6s\" --drain-timeout=\"30s\" --target-timeout=\"15s\" --buffer-requests --buffer-responses --log-request-header=\"Cache-Control\" --log-request-header=\"Last-Modified\" --log-request-header=\"User-Agent\"", output end end test "live" do run_command("live").tap do |output| assert_match "docker exec kamal-proxy kamal-proxy resume app-web on 1.1.1.1", output end end test "maintenance" do run_command("maintenance").tap do |output| assert_match "docker exec kamal-proxy kamal-proxy stop app-web --drain-timeout=\"30s\" on 1.1.1.1", output end end test "maintenance with options" do run_command("maintenance", "--message", "Hello", "--drain_timeout", "10").tap do |output| assert_match "docker exec kamal-proxy kamal-proxy stop app-web --drain-timeout=\"10s\" --message=\"Hello\" on 1.1.1.1", output end end private def run_command(*command, config: :with_accessories, host: "1.1.1.1", allow_execute_error: false) stdouted do Kamal::Cli::App.start([ *command, "-c", "test/fixtures/deploy_#{config}.yml", *([ "--hosts", host ] if host) ]) rescue SSHKit::Runner::ExecuteError => e raise e unless allow_execute_error end end def stub_running Object.any_instance.stubs(:sleep) SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("123") # old version end end ================================================ FILE: test/cli/build_test.rb ================================================ require_relative "cli_test_case" class CliBuildTest < CliTestCase test "deliver" do Kamal::Cli::Build.any_instance.expects(:push) Kamal::Cli::Build.any_instance.expects(:pull) run_command("deliver") end test "push" do with_build_directory do |build_directory| Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:git, "-C", anything, :"rev-parse", :HEAD) .returns(Kamal::Git.revision) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:git, "-C", anything, :status, "--porcelain") .returns("") run_command("push", "--verbose").tap do |output| assert_hook_ran "pre-connect", output assert_hook_ran "pre-build", output assert_match /Cloning repo into build directory/, output assert_match /git -C #{Dir.tmpdir}\/kamal-clones\/app-#{pwd_sha} clone #{Dir.pwd}/, output assert_match /docker --version && docker buildx version/, output assert_match /docker buildx build --output=type=registry --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile \. 2>&1 as .*@localhost/, output end end end test "push with remote builder checks both the builder and the remote context" do with_build_directory do |build_directory| Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:git, "-C", anything, :"rev-parse", :HEAD) .returns(Kamal::Git.revision) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:git, "-C", anything, :status, "--porcelain") .returns("") run_command("push", "--verbose", fixture: :with_remote_builder).tap do |output| assert_no_match "Running docker login -u [REDACTED] -p [REDACTED] as ", output assert_match "docker buildx inspect kamal-remote-ssh---app-1-1-1-5 | grep -q Endpoint:.*kamal-remote-ssh---app-1-1-1-5-context && docker context inspect kamal-remote-ssh---app-1-1-1-5-context --format '{{.Endpoints.docker.Host}}' | grep -xq ssh://app@1.1.1.5 || (echo no compatible builder && exit 1)", output assert_match "Command: ( export BUILDKIT_NO_CLIENT_TOKEN=\"1\" ; docker buildx build --output=type=registry --platform linux/arm64 --builder kamal-remote-ssh---app-1-1-1-5 -t dhh/app:999 -t dhh/app:latest --label service=\"app\" --file Dockerfile . 2>&1 )", output end end end test "push --output=docker" do with_build_directory do |build_directory| Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:git, "-C", anything, :"rev-parse", :HEAD) .returns(Kamal::Git.revision) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:git, "-C", anything, :status, "--porcelain") .returns("") run_command("push", "--output=docker", "--verbose").tap do |output| assert_hook_ran "pre-build", output assert_match /Cloning repo into build directory/, output assert_match /git -C #{Dir.tmpdir}\/kamal-clones\/app-#{pwd_sha} clone #{Dir.pwd}/, output assert_match /docker --version && docker buildx version/, output assert_match /docker buildx build --output=type=docker --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile \. 2>&1 as .*@localhost/, output end end end test "push resetting clone" do with_build_directory do |build_directory| stub_setup SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "--version", "&&", :docker, :buildx, "version") SSHKit::Backend::Abstract.any_instance.stubs(:execute).with { |*args| args[0..1] == [ :docker, :login ] } SSHKit::Backend::Abstract.any_instance.expects(:execute) .with(:git, "-C", "#{Dir.tmpdir}/kamal-clones/app-#{pwd_sha}", :clone, Dir.pwd, "--recurse-submodules") .raises(SSHKit::Command::Failed.new("fatal: destination path 'kamal' already exists and is not an empty directory")) .then .returns(true) SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :remote, "set-url", :origin, Dir.pwd) SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :fetch, :origin) SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :reset, "--hard", Kamal::Git.revision) SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :clean, "-fdx") SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :submodule, :update, "--init") SSHKit::Backend::Abstract.any_instance.expects(:execute) .with(:docker, :buildx, :build, "--output=type=registry", "--platform", "linux/amd64", "--builder", "kamal-local-docker-container", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".", "2>&1", env: {}) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:git, "-C", anything, :"rev-parse", :HEAD) .returns(Kamal::Git.revision) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:git, "-C", anything, :status, "--porcelain") .returns("") run_command("push", "--verbose").tap do |output| assert_match /Cloning repo into build directory/, output assert_match /Resetting local clone/, output end end end test "push without clone" do Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) run_command("push", "--verbose", fixture: :without_clone).tap do |output| assert_no_match /Cloning repo into build directory/, output assert_hook_ran "pre-build", output assert_match /docker --version && docker buildx version/, output assert_match /docker buildx build --output=type=registry --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile . 2>&1 as .*@localhost/, output end end test "push with no-cache" do Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) run_command("push", "--no-cache", "--verbose", fixture: :without_clone).tap do |output| assert_hook_ran "pre-build", output assert_match /docker --version && docker buildx version/, output assert_match /docker buildx build --output=type=registry --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999 -t dhh\/app:latest --label service="app" --file Dockerfile --no-cache . 2>&1 as .*@localhost/, output end end test "push with corrupt clone" do with_build_directory do |build_directory| stub_setup SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "--version", "&&", :docker, :buildx, "version") SSHKit::Backend::Abstract.any_instance.stubs(:execute).with { |*args| args[0..1] == [ :docker, :login ] } SSHKit::Backend::Abstract.any_instance.expects(:execute) .with(:git, "-C", "#{Dir.tmpdir}/kamal-clones/app-#{pwd_sha}", :clone, Dir.pwd, "--recurse-submodules") .raises(SSHKit::Command::Failed.new("fatal: destination path 'kamal' already exists and is not an empty directory")) .then .returns(true) .twice SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, "-C", build_directory, :remote, "set-url", :origin, Dir.pwd) .raises(SSHKit::Command::Failed.new("fatal: not a git repository")) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:git, "-C", anything, :"rev-parse", :HEAD) .returns(Kamal::Git.revision) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:git, "-C", anything, :status, "--porcelain") .returns("") Dir.stubs(:chdir) run_command("push", "--verbose") do |output| assert_match /Cloning repo into build directory `#{build_directory}`\.\.\..*Cloning repo into build directory `#{build_directory}`\.\.\./, output assert_match "Resetting local clone as `#{build_directory}` already exists...", output assert_match "Error preparing clone: Failed to clone repo: fatal: not a git repository, deleting and retrying...", output end end end test "push without builder for local registry" do with_build_directory do |build_directory| stub_setup SSHKit::Backend::Abstract.any_instance.expects(:execute) .with(:docker, "--version", "&&", :docker, :buildx, "version") SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with { |*args| args[0..1] == [ :docker, :login ] } SSHKit::Backend::Abstract.any_instance.expects(:execute) .with(:docker, :start, "kamal-docker-registry", "||", :docker, :run, "--detach", "-p", "127.0.0.1:5000:5000", "--name", "kamal-docker-registry", "registry:3") SSHKit::Backend::Abstract.any_instance.expects(:execute) .with(:docker, :buildx, :rm, "kamal-local-registry-docker-container") SSHKit::Backend::Abstract.any_instance.expects(:execute) .with(:docker, :buildx, :create, "--name", "kamal-local-registry-docker-container", "--driver=docker-container", "--driver-opt", "network=host") SSHKit::Backend::Abstract.any_instance.expects(:execute) .with(:docker, :buildx, :inspect, "kamal-local-registry-docker-container") .raises(SSHKit::Command::Failed.new("no builder")) SSHKit::Backend::Abstract.any_instance.expects(:execute).with { |*args| args.first.to_s.start_with?("git") } SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:git, "-C", anything, :"rev-parse", :HEAD) .returns(Kamal::Git.revision) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:git, "-C", anything, :status, "--porcelain") .returns("") SSHKit::Backend::Abstract.any_instance.expects(:execute) .with(:docker, :buildx, :build, "--output=type=registry", "--platform", "linux/amd64", "--builder", "kamal-local-registry-docker-container", "-t", "localhost:5000/dhh/app:999", "-t", "localhost:5000/dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".", "2>&1", env: {}) run_command("push", fixture: :with_local_registry_and_accessories).tap do |output| assert_match /WARN Missing compatible builder, so creating a new one first/, output end end end test "push without builder" do with_build_directory do |build_directory| stub_setup SSHKit::Backend::Abstract.any_instance.expects(:execute) .with(:docker, "--version", "&&", :docker, :buildx, "version") SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with { |*args| args[0..1] == [ :docker, :login ] } SSHKit::Backend::Abstract.any_instance.expects(:execute) .with(:docker, :buildx, :rm, "kamal-local-docker-container") SSHKit::Backend::Abstract.any_instance.expects(:execute) .with(:docker, :buildx, :create, "--name", "kamal-local-docker-container", "--driver=docker-container") SSHKit::Backend::Abstract.any_instance.expects(:execute) .with(:docker, :buildx, :inspect, "kamal-local-docker-container") .raises(SSHKit::Command::Failed.new("no builder")) SSHKit::Backend::Abstract.any_instance.expects(:execute).with { |*args| args.first.start_with?("git") } SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:git, "-C", anything, :"rev-parse", :HEAD) .returns(Kamal::Git.revision) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:git, "-C", anything, :status, "--porcelain") .returns("") SSHKit::Backend::Abstract.any_instance.expects(:execute) .with(:docker, :buildx, :build, "--output=type=registry", "--platform", "linux/amd64", "--builder", "kamal-local-docker-container", "-t", "dhh/app:999", "-t", "dhh/app:latest", "--label", "service=\"app\"", "--file", "Dockerfile", ".", "2>&1", env: {}) run_command("push").tap do |output| assert_match /WARN Missing compatible builder, so creating a new one first/, output end end end test "push with no buildx plugin" do stub_setup SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with(:docker, "--version", "&&", :docker, :buildx, "version") .raises(SSHKit::Command::Failed.new("no buildx")) Kamal::Commands::Builder.any_instance.stubs(:native_and_local?).returns(false) assert_raises(Kamal::Cli::DependencyError) { run_command("push") } end test "push pre-build hook failure" do fail_hook("pre-build") error = assert_raises(Kamal::Cli::HookError) { run_command("push") } assert_equal "Hook `pre-build` failed:\nfailed", error.message assert @executions.none? { |args| args[0..2] == [ :docker, :build ] } end test "pull" do run_command("pull").tap do |output| assert_match /docker info --format '{{index .RegistryConfig.Mirrors 0}}'/, output assert_match /docker image rm --force dhh\/app:999/, output assert_match /docker pull dhh\/app:999/, output assert_match "docker inspect -f '{{ .Config.Labels.service }}' dhh/app:999 | grep -x app || (echo \"Image dhh/app:999 is missing the 'service' label\" && exit 1)", output end end test "pull with mirror" do SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :info, "--format '{{index .RegistryConfig.Mirrors 0}}'") .returns("registry-mirror.example.com") .at_least_once run_command("pull").tap do |output| assert_match /Pulling image on 1\.1\.1\.\d to seed the mirror\.\.\./, output assert_match "Pulling image on remaining hosts...", output assert_equal 4, output.scan(/docker pull dhh\/app:999/).size, output assert_match "docker inspect -f '{{ .Config.Labels.service }}' dhh/app:999 | grep -x app || (echo \"Image dhh/app:999 is missing the 'service' label\" && exit 1)", output end end test "pull with mirrors" do SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :info, "--format '{{index .RegistryConfig.Mirrors 0}}'") .returns("registry-mirror.example.com", "registry-mirror2.example.com") .at_least_once run_command("pull").tap do |output| assert_match /Pulling image on 1\.1\.1\.\d, 1\.1\.1\.\d to seed the mirrors\.\.\./, output assert_match "Pulling image on remaining hosts...", output assert_equal 4, output.scan(/docker pull dhh\/app:999/).size, output assert_match "docker inspect -f '{{ .Config.Labels.service }}' dhh/app:999 | grep -x app || (echo \"Image dhh/app:999 is missing the 'service' label\" && exit 1)", output end end test "create" do run_command("create").tap do |output| assert_match /docker buildx create --name kamal-local-docker-container --driver=docker-container/, output end end test "create remote" do run_command("create", fixture: :with_remote_builder).tap do |output| assert_match "Running /usr/bin/env true on 1.1.1.5", output assert_match "docker context create kamal-remote-ssh---app-1-1-1-5-context --description 'kamal-remote-ssh---app-1-1-1-5 host' --docker 'host=ssh://app@1.1.1.5'", output assert_match "docker buildx create --name kamal-remote-ssh---app-1-1-1-5 kamal-remote-ssh---app-1-1-1-5-context", output end end test "create remote with custom ports" do run_command("create", fixture: :with_remote_builder_and_custom_ports).tap do |output| assert_match "Running /usr/bin/env true on 1.1.1.5", output assert_match "docker context create kamal-remote-ssh---app-1-1-1-5-2122-context --description 'kamal-remote-ssh---app-1-1-1-5-2122 host' --docker 'host=ssh://app@1.1.1.5:2122'", output assert_match "docker buildx create --name kamal-remote-ssh---app-1-1-1-5-2122 kamal-remote-ssh---app-1-1-1-5-2122-context", output end end test "create hybrid" do run_command("create", fixture: :with_hybrid_builder).tap do |output| assert_match "Running /usr/bin/env true on 1.1.1.5", output assert_match "docker buildx create --platform linux/#{Kamal::Utils.docker_arch} --name kamal-hybrid-docker-container-ssh---app-1-1-1-5 --driver=docker-container", output assert_match "docker context create kamal-hybrid-docker-container-ssh---app-1-1-1-5-context --description 'kamal-hybrid-docker-container-ssh---app-1-1-1-5 host' --docker 'host=ssh://app@1.1.1.5'", output assert_match "docker buildx create --platform linux/#{Kamal::Utils.docker_arch == "amd64" ? "arm64" : "amd64"} --append --name kamal-hybrid-docker-container-ssh---app-1-1-1-5 kamal-hybrid-docker-container-ssh---app-1-1-1-5-context", output end end test "create cloud" do run_command("create", fixture: :with_cloud_builder).tap do |output| assert_match /docker buildx create --driver cloud example_org\/cloud_builder/, output end end test "create with error" do stub_setup SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with { |arg| arg == :docker } .raises(SSHKit::Command::Failed.new("stderr=error")) run_command("create").tap do |output| assert_match /Couldn't create remote builder: error/, output end end test "remove" do run_command("remove").tap do |output| assert_match /docker buildx rm kamal-local/, output end end test "remove cloud" do run_command("remove", fixture: :with_cloud_builder).tap do |output| assert_match /docker buildx rm cloud-example_org-cloud_builder/, output end end test "details" do SSHKit::Backend::Abstract.any_instance.stubs(:capture) .with(:docker, :context, :ls, "&&", :docker, :buildx, :ls) .returns("docker builder info") run_command("details").tap do |output| assert_match /Builder: local/, output assert_match /docker builder info/, output end end test "dev" do with_build_directory do |build_directory| Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) run_command("dev", "--verbose").tap do |output| assert_no_match(/Cloning repo into build directory/, output) assert_match(/docker --version && docker buildx version/, output) assert_match(/docker buildx build --output=type=docker --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999-dirty -t dhh\/app:latest-dirty --label service="app" --file Dockerfile \. 2>&1 as .*@localhost/, output) end end end test "dev --output=local" do with_build_directory do |build_directory| Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) run_command("dev", "--output=local", "--verbose").tap do |output| assert_no_match(/Cloning repo into build directory/, output) assert_match(/docker --version && docker buildx version/, output) assert_match(/docker buildx build --output=type=local --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999-dirty -t dhh\/app:latest-dirty --label service="app" --file Dockerfile \. 2>&1 as .*@localhost/, output) end end end test "dev with no-cache" do with_build_directory do |build_directory| Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) run_command("dev", "--no-cache", "--verbose").tap do |output| assert_no_match(/Cloning repo into build directory/, output) assert_match(/docker --version && docker buildx version/, output) assert_match(/docker buildx build --output=type=docker --platform linux\/amd64 --builder kamal-local-docker-container -t dhh\/app:999-dirty -t dhh\/app:latest-dirty --label service="app" --file Dockerfile --no-cache \. 2>&1 as .*@localhost/, output) end end end test "create with local registry" do run_command("create", fixture: :with_local_registry).tap do |output| assert_match /docker buildx create --name kamal-local-registry-docker-container --driver=docker-container --driver-opt network=host/, output end end test "create with local registry and remote builder" do run_command("create", fixture: :with_local_registry_and_remote_builder).tap do |output| # Verify remote builder with local-registry in name assert_match /docker buildx create --name kamal-remote-ssh---app-1-1-1-5-local-registry/, output assert_match /--driver-opt network=host/, output end end test "pull with local registry" do # Verify port forwarding is established for all app hosts port_forwarding_mock = mock("port_forwarding") port_forwarding_mock.expects(:forward).yields Kamal::Cli::Build::PortForwarding.expects(:new) .with([ "1.1.1.1", "1.1.1.2" ], 5000, has_entries( user: "root", port: 22, logger: instance_of(Logger), keepalive: true, keepalive_interval: 30 ) ).returns(port_forwarding_mock) run_command("pull", fixture: :with_local_registry).tap do |output| assert_match /docker pull localhost:5000\/dhh\/app:999/, output end end test "create with local registry and remote builder with custom port" do run_command("create", fixture: :with_local_registry_and_remote_builder_with_port).tap do |output| # Verify remote builder with local-registry in name includes custom port in context name assert_match /docker buildx create --name kamal-remote-ssh---app-1-1-1-5-2222-local-registry/, output assert_match /--driver-opt network=host/, output end end private def run_command(*command, fixture: :with_accessories) stdouted { stderred { Kamal::Cli::Build.start([ *command, "-c", "test/fixtures/deploy_#{fixture}.yml" ]) } } end def stub_dependency_checks SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with(:docker, "--version", "&&", :docker, :buildx, "version") SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with { |*args| args[0..1] == [ :docker, :buildx ] } end end ================================================ FILE: test/cli/cli_test_case.rb ================================================ require "test_helper" class CliTestCase < ActiveSupport::TestCase setup do ENV["VERSION"] = "999" ENV["RAILS_MASTER_KEY"] = "123" ENV["MYSQL_ROOT_PASSWORD"] = "secret123" Object.send(:remove_const, :KAMAL) Object.const_set(:KAMAL, Kamal::Commander.new) end teardown do ENV.delete("RAILS_MASTER_KEY") ENV.delete("MYSQL_ROOT_PASSWORD") ENV.delete("VERSION") end private def fail_hook(hook) @executions = [] Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with { |*args| @executions << args; args != [ ".kamal/hooks/#{hook}" ] } SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with { |*args| args.first == ".kamal/hooks/#{hook}" } .raises(SSHKit::Command::Failed.new("failed")) end def stub_setup SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with { |*args| args == [ :mkdir, "-p", ".kamal/apps/app" ] } SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with { |arg1, arg2, arg3| arg1 == :mkdir && arg2 == "-p" && arg3 == ".kamal/lock-app" } SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with { |arg1, arg2| arg1 == :mkdir && arg2 == ".kamal/lock-app" } SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with { |arg1, arg2| arg1 == :rm && arg2 == ".kamal/lock-app/details" } SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with(:docker, :buildx, :inspect, "kamal-local-docker-container") end def assert_hook_ran(hook, output, count: 1) regexp = ([ "/usr/bin/env .kamal/hooks/#{hook}" ] * count).join(".*") assert_match /#{regexp}/m, output end def with_argv(*argv) old_argv = ARGV ARGV.replace(*argv) yield ensure ARGV.replace(old_argv) end def with_build_directory build_directory = File.join Dir.tmpdir, "kamal-clones", "app-#{pwd_sha}", "kamal" FileUtils.mkdir_p build_directory FileUtils.touch File.join build_directory, "Dockerfile" yield build_directory + "/" ensure FileUtils.rm_rf build_directory end def pwd_sha Digest::SHA256.hexdigest(Dir.pwd)[0..12] end end ================================================ FILE: test/cli/lock_test.rb ================================================ require_relative "cli_test_case" class CliLockTest < CliTestCase test "status" do run_command("status").tap do |output| 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 end end test "release" do run_command("release").tap do |output| assert_match "Released the deploy lock", output end end private def run_command(*command) stdouted { Kamal::Cli::Lock.start([ *command, "-v", "-c", "test/fixtures/deploy_with_accessories.yml" ]) } end end ================================================ FILE: test/cli/main_test.rb ================================================ require_relative "cli_test_case" class CliMainTest < CliTestCase setup { @original_env = ENV.to_h.dup } teardown { ENV.clear; ENV.update @original_env } test "setup" do invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false } Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:server:bootstrap", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:deploy).with(boot_accessories: true) run_command("setup").tap do |output| assert_match /Ensure Docker is installed.../, output end end test "setup with skip_push" do invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false } Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:server:bootstrap", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:boot", [ "all" ], invoke_options) # deploy Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:pull", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options) run_command("setup", "--skip_push").tap do |output| assert_match /Ensure Docker is installed.../, output # deploy assert_match /Acquiring the deploy lock/, output assert_match /Pull app image/, output assert_match /Ensure kamal-proxy is running/, output assert_match /Detect stale containers/, output assert_match /Prune old containers and images/, output assert_match /Releasing the deploy lock/, output end end test "deploy with local registry" do with_test_secrets("secrets" => "DB_PASSWORD=secret") do invoke_options = { "config_file" => "test/fixtures/deploy_with_local_registry.yml", "version" => "999", "skip_hooks" => false, "verbose" => true } Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options) Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) run_command("deploy", "--verbose", config_file: "deploy_with_local_registry").tap do |output| assert_hook_ran "pre-connect", output assert_match /Build and push app image/, output assert_hook_ran "pre-deploy", output assert_match /Ensure kamal-proxy is running/, output assert_match /Detect stale containers/, output assert_match /Prune old containers and images/, output assert_hook_ran "post-deploy", output end end end test "setup with no_cache" do invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false, "no_cache" => true } Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:server:bootstrap", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:boot", [ "all" ], invoke_options) # deploy Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options) run_command("setup", "--no-cache").tap do |output| assert_match /Ensure Docker is installed.../, output # deploy assert_match /Build and push app image/, output assert_match /Ensure kamal-proxy is running/, output assert_match /Detect stale containers/, output assert_match /Prune old containers and images/, output end end test "deploy" do with_test_secrets("secrets" => "DB_PASSWORD=secret") do invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false, "verbose" => true } Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options) Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) run_command("deploy", "--verbose").tap do |output| assert_hook_ran "pre-connect", output assert_match /Build and push app image/, output assert_hook_ran "pre-deploy", output assert_match /Ensure kamal-proxy is running/, output assert_match /Detect stale containers/, output assert_match /Prune old containers and images/, output assert_hook_ran "post-deploy", output end end end test "deploy with skip_push" do invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false } Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:pull", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options) run_command("deploy", "--skip_push").tap do |output| assert_match /Acquiring the deploy lock/, output assert_match /Pull app image/, output assert_match /Ensure kamal-proxy is running/, output assert_match /Detect stale containers/, output assert_match /Prune old containers and images/, output assert_match /Releasing the deploy lock/, output end end test "deploy with no_cache" do invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false, "no_cache" => true } Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options) run_command("deploy", "--no-cache").tap do |output| assert_match /Build and push app image/, output assert_match /Ensure kamal-proxy is running/, output assert_match /Detect stale containers/, output assert_match /Prune old containers and images/, output end end test "deploy when locked" do Thread.report_on_exception = false SSHKit::Backend::Abstract.any_instance.stubs(:execute) Dir.stubs(:chdir) SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with { |*args| args == [ :mkdir, "-p", ".kamal/apps/app" ] } SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with { |*arg| arg[0..1] == [ :mkdir, ".kamal/lock-app" ] } .raises(RuntimeError, "mkdir: cannot create directory ‘kamal/lock-app’: File exists") SSHKit::Backend::Abstract.any_instance.expects(:capture_with_debug) .with(:stat, ".kamal/lock-app", ">", "/dev/null", "&&", :cat, ".kamal/lock-app/details", "|", :base64, "-d") SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:git, "-C", anything, :"rev-parse", :HEAD) .returns(Kamal::Git.revision) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:git, "-C", anything, :status, "--porcelain") .returns("") SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :info, "--format '{{index .RegistryConfig.Mirrors 0}}'") .returns("") .at_least_once assert_raises(Kamal::Cli::LockError) do run_command("deploy") end end test "deploy when inheriting lock" do Thread.report_on_exception = false invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false } Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options) Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) with_kamal_lock_env do KAMAL.reset run_command("deploy").tap do |output| assert_no_match /Acquiring the deploy lock/, output assert_match /Build and push app image/, output assert_match /Ensure kamal-proxy is running/, output assert_match /Detect stale containers/, output assert_match /Prune old containers and images/, output assert_no_match /Releasing the deploy lock/, output end end end test "deploy error when locking" do Thread.report_on_exception = false SSHKit::Backend::Abstract.any_instance.stubs(:execute) Dir.stubs(:chdir) SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with { |*args| args == [ :mkdir, "-p", ".kamal/apps/app" ] } SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with { |*arg| arg[0..1] == [ :mkdir, ".kamal/lock-app" ] } .raises(SocketError, "getaddrinfo: nodename nor servname provided, or not known") SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:git, "-C", anything, :"rev-parse", :HEAD) .returns(Kamal::Git.revision) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:git, "-C", anything, :status, "--porcelain") .returns("") SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :info, "--format '{{index .RegistryConfig.Mirrors 0}}'") .returns("") .at_least_once assert_raises(SSHKit::Runner::ExecuteError) do run_command("deploy") end end test "deploy errors during outside section leave remote lock" do invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false } Kamal::Cli::Main.any_instance.expects(:invoke) .with("kamal:cli:build:deliver", [], invoke_options) .raises(RuntimeError) assert_not KAMAL.holding_lock? assert_raises(RuntimeError) do stderred { run_command("deploy") } end assert_not KAMAL.holding_lock? end test "deploy with skipped hooks" do invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => true } Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options) run_command("deploy", "--skip_hooks") do assert_no_match /Running the post-deploy hook.../, output end end test "deploy with missing secrets" do invoke_options = { "config_file" => "test/fixtures/deploy_with_secrets.yml", "version" => "999", "skip_hooks" => false } Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options) run_command("deploy", config_file: "deploy_with_secrets") end test "redeploy" do invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false, "verbose" => true } Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) run_command("redeploy", "--verbose").tap do |output| assert_hook_ran "pre-connect", output assert_match /Build and push app image/, output assert_hook_ran "pre-deploy", output assert_match /Running \/usr\/bin\/env .kamal\/hooks\/pre-deploy /, output assert_hook_ran "post-deploy", output end end test "redeploy with skip_push" do invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false } Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:pull", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) run_command("redeploy", "--skip_push").tap do |output| assert_match /Pull app image/, output end end test "redeploy with no_cache" do invoke_options = { "config_file" => "test/fixtures/deploy_simple.yml", "version" => "999", "skip_hooks" => false, "no_cache" => true } Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) run_command("redeploy", "--no-cache").tap do |output| assert_match /Build and push app image/, output end end test "rollback bad version" do Thread.report_on_exception = false run_command("details") # Preheat Kamal const run_command("rollback", "nonsense").tap do |output| assert_match /docker container ls --all --filter 'name=\^app-web-nonsense\$' --quiet/, output assert_match /The app version 'nonsense' is not available as a container/, output end end test "rollback good version" do Object.any_instance.stubs(:sleep) [ "web", "workers" ].each do |role| SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :container, :ls, "--all", "--filter", "'name=^app-#{role}-123$'", "--quiet", raise_on_non_zero_exit: false) .returns("").at_least_once SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :container, :ls, "--all", "--filter", "'name=^app-#{role}-123$'", "--quiet") .returns("version-to-rollback\n").at_least_once SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=#{role} --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=#{role} --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-#{role}-}; done", raise_on_non_zero_exit: false) .returns("version-to-rollback\n").at_least_once end SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :container, :ls, "--all", "--filter", "'name=^app-workers-123$'", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") .returns("running").at_least_once # health check Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) run_command("rollback", "--verbose", "123", config_file: "deploy_with_accessories").tap do |output| assert_hook_ran "pre-deploy", output assert_match "docker tag dhh/app:123 dhh/app:latest", output assert_match "docker run --detach --restart unless-stopped --name app-web-123", output assert_match "docker container ls --all --filter 'name=^app-web-version-to-rollback$' --quiet | xargs docker stop", output, "Should stop the container that was previously running" assert_hook_ran "post-deploy", output end end test "rollback without old version" do Kamal::Cli::Main.any_instance.stubs(:container_available?).returns(true) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :container, :ls, "--all", "--filter", "'name=^app-web-123$'", "--quiet", raise_on_non_zero_exit: false) .returns("").at_least_once SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :container, :ls, "--all", "--filter", "'name=^app-web-123$'", "--quiet") .returns("123").at_least_once SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .returns("").at_least_once run_command("rollback", "123").tap do |output| assert_match "docker run --detach --restart unless-stopped --name app-web-123", output assert_no_match "docker stop", output end end test "remove" do options = { "config_file" => "test/fixtures/deploy_simple.yml", "skip_hooks" => false, "confirmed" => true } Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:remove", [], options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:remove", [], options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:remove", [ "all" ], options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:registry:remove", [], options.merge(skip_local: true)) run_command("remove", "-y") end test "details" do Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:details") Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:details") Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:details", [ "all" ]) run_command("details") end test "audit" do run_command("audit").tap do |output| assert_match %r{tail -n 50 \.kamal/app-audit.log on 1.1.1.1}, output assert_match /App Host: 1.1.1.1/, output end end test "config" do run_command("config", config_file: "deploy_simple").tap do |output| config = YAML.load(output) assert_equal [ "web" ], config[:roles] assert_equal [ "1.1.1.1", "1.1.1.2" ], config[:hosts] assert_equal "999", config[:version] assert_equal "dhh/app", config[:repository] assert_equal "dhh/app:999", config[:absolute_image] assert_equal "app-999", config[:service_with_version] end end test "config with roles" do run_command("config", config_file: "deploy_with_roles").tap do |output| config = YAML.load(output) assert_equal [ "web", "workers" ], config[:roles] assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], config[:hosts] assert_equal "999", config[:version] assert_equal "registry.digitalocean.com/dhh/app", config[:repository] assert_equal "registry.digitalocean.com/dhh/app:999", config[:absolute_image] assert_equal "app-999", config[:service_with_version] end end test "config with primary web role override" do run_command("config", config_file: "deploy_primary_web_role_override").tap do |output| config = YAML.load(output) assert_equal [ "web_chicago", "web_tokyo" ], config[:roles] assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], config[:hosts] assert_equal "1.1.1.3", config[:primary_host] end end test "config with destination" do run_command("config", "-d", "world", config_file: "deploy_for_dest").tap do |output| config = YAML.load(output) assert_equal [ "web" ], config[:roles] assert_equal [ "1.1.1.1", "1.1.1.2" ], config[:hosts] assert_equal "999", config[:version] assert_equal "registry.digitalocean.com/dhh/app", config[:repository] assert_equal "registry.digitalocean.com/dhh/app:999", config[:absolute_image] assert_equal "app-999", config[:service_with_version] end end test "config with blank line trimming" do template = <<~YAML service: app image: dhh/app servers: - "1.1.1.1" <% if true -%> - "1.1.1.2" <% end -%> registry: username: user password: pw builder: arch: amd64 YAML expected_rendered = ERB.new(template, trim_mode: "-").result Dir.mktmpdir do |dir| config_path = File.join(dir, "deploy.yml") File.write(config_path, template) load_method = YAML.respond_to?(:unsafe_load) ? :unsafe_load : :load original_load = YAML.method(load_method) YAML.expects(load_method).with(expected_rendered).returns(original_load.call(expected_rendered)) run_command_with_config_path("config", config_path: config_path) end end test "config with destination blank line trimming" do base_template = <<~YAML service: app image: dhh/app servers: - "1.1.1.1" registry: username: user password: pw builder: arch: amd64 YAML destination_template = <<~YAML servers: - "2.2.2.2" <% if true -%> - "2.2.2.3" <% end -%> YAML expected_destination = ERB.new(destination_template, trim_mode: "-").result Dir.mktmpdir do |dir| base_path = File.join(dir, "deploy.yml") File.write(base_path, base_template) destination_path = File.join(dir, "deploy.world.yml") File.write(destination_path, destination_template) load_method = YAML.respond_to?(:unsafe_load) ? :unsafe_load : :load original_load = YAML.method(load_method) load_sequence = sequence("config_files") YAML.expects(load_method).with(base_template).in_sequence(load_sequence).returns(original_load.call(base_template)) YAML.expects(load_method).with(expected_destination).in_sequence(load_sequence).returns(original_load.call(expected_destination)) run_command_with_config_path("config", config_path: base_path, destination: "world") end end test "init" do in_dummy_git_repo do run_command("init").tap do |output| assert_match "Created configuration file in config/deploy.yml", output assert_match "Created .kamal/secrets file", output end assert_file "config/deploy.yml", "service: my-app" assert_file ".kamal/secrets", "KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD" end end test "init with existing config" do in_dummy_git_repo do run_command("init") run_command("init").tap do |output| assert_match /Config file already exists in config\/deploy.yml \(remove first to create a new one\)/, output assert_no_match /Added .kamal\/secrets/, output end end end test "init with bundle option" do in_dummy_git_repo do run_command("init", "--bundle").tap do |output| assert_match "Created configuration file in config/deploy.yml", output assert_match "Created .kamal/secrets file", output assert_match /Adding Kamal to Gemfile and bundle/, output assert_match /bundle add kamal/, output assert_match /bundle binstubs kamal/, output assert_match /Created binstub file in bin\/kamal/, output end end end test "init with bundle option and existing binstub" do Pathname.any_instance.expects(:exist?).returns(true).times(4) Pathname.any_instance.stubs(:mkpath) FileUtils.stubs(:mkdir_p) FileUtils.stubs(:cp_r) FileUtils.stubs(:cp) run_command("init", "--bundle").tap do |output| assert_match /Config file already exists in config\/deploy.yml \(remove first to create a new one\)/, output assert_match /Binstub already exists in bin\/kamal \(remove first to create a new one\)/, output end end test "remove with confirmation" do run_command("remove", "-y", config_file: "deploy_with_accessories").tap do |output| assert_match /docker container stop kamal-proxy/, output assert_match /docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy/, output assert_match /docker image prune --all --force --filter label=org.opencontainers.image.title=kamal-proxy/, output assert_match /docker ps --quiet --filter label=service=app | xargs docker stop/, output assert_match /docker container prune --force --filter label=service=app/, output assert_match /docker image prune --all --force --filter label=service=app/, output assert_match "/usr/bin/env rm -r .kamal/apps/app", output assert_match /docker container stop app-mysql/, output assert_match /docker container prune --force --filter label=service=app-mysql/, output assert_match /docker image rm --force mysql/, output assert_match /rm -rf app-mysql/, output assert_match /docker container stop app-redis/, output assert_match /docker container prune --force --filter label=service=app-redis/, output assert_match /docker image rm --force redis/, output assert_match /rm -rf app-redis/, output assert_match /docker logout/, output end end test "docs" do run_command("docs").tap do |output| assert_match "# Kamal Configuration", output end end test "docs subsection" do run_command("docs", "accessory").tap do |output| assert_match "# Accessories", output end end test "docs unknown" do run_command("docs", "foo").tap do |output| assert_match "No documentation found for foo", output end end test "version" do version = stdouted { Kamal::Cli::Main.new.version } assert_equal Kamal::VERSION, version end test "run an alias for details" do Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:details") Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:details") Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:details", [ "all" ]) run_command("info", config_file: "deploy_with_aliases") end test "run an alias for a console" do run_command("console", config_file: "deploy_with_aliases").tap do |output| assert_no_match "App Host: 1.1.1.4", output assert_match "docker exec app-console-999 bin/console on 1.1.1.5", output assert_match "App Host: 1.1.1.5", output end end test "run an alias for a console overriding role" do run_command("console", "-r", "workers", config_file: "deploy_with_aliases").tap do |output| assert_match "docker exec app-workers-999 bin/console on 1.1.1.3", output assert_match "App Host: 1.1.1.3", output end end test "run an alias for a console passing command" do run_command("exec", "bin/job", config_file: "deploy_with_aliases").tap do |output| assert_match "docker exec app-console-999 bin/job on 1.1.1.5", output assert_match "App Host: 1.1.1.5", output end end test "append to command with an alias" do run_command("rails", "db:migrate:status", config_file: "deploy_with_aliases").tap do |output| assert_match "docker exec app-console-999 rails db:migrate:status on 1.1.1.5", output assert_match "App Host: 1.1.1.5", output end end test "switch config file with an alias" do with_config_files do with_argv([ "other_config" ]) do stdouted { Kamal::Cli::Main.start }.tap do |output| assert_match ":service_with_version: app2-999", output end end end end test "switch destination with an alias" do with_config_files do with_argv([ "other_destination_config" ]) do stdouted { Kamal::Cli::Main.start }.tap do |output| assert_match ":service_with_version: app3-999", output end end end end test "run an alias with require_destination" do invoke_options = { "config_file" => "test/fixtures/deploy_for_required_dest.yml", "version" => "999", "skip_hooks" => false, "destination" => "world" } Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:build:deliver", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:stale_containers", [], invoke_options.merge(stop: true)) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:app:boot", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:prune:all", [], invoke_options) run_command("world_deploy", config_file: "deploy_for_required_dest") end test "run on primary via alias" do run_command("primary_details", config_file: "deploy_with_aliases").tap do |output| assert_match "App Host: 1.1.1.1", output assert_no_match "App Host: 1.1.1.2", output end end test "upgrade" do invoke_options = { "config_file" => "test/fixtures/deploy_with_accessories.yml", "skip_hooks" => false, "confirmed" => true, "rolling" => false } Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:upgrade", [], invoke_options) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:upgrade", [ "all" ], invoke_options) run_command("upgrade", "-y", config_file: "deploy_with_accessories").tap do |output| assert_match "Upgrading all hosts...", output assert_match "Upgraded all hosts", output end end test "upgrade rolling" do invoke_options = { "config_file" => "test/fixtures/deploy_with_accessories.yml", "skip_hooks" => false, "confirmed" => true, "rolling" => false } Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:upgrade", [], invoke_options).times(4) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:accessory:upgrade", [ "all" ], invoke_options).times(3) run_command("upgrade", "--rolling", "-y", config_file: "deploy_with_accessories").tap do |output| assert_match "Upgrading 1.1.1.1...", output assert_match "Upgraded 1.1.1.1", output assert_match "Upgrading 1.1.1.2...", output assert_match "Upgraded 1.1.1.2", output assert_match "Upgrading 1.1.1.3...", output assert_match "Upgraded 1.1.1.3", output assert_match "Upgrading 1.1.1.4...", output assert_match "Upgraded 1.1.1.4", output end end private def run_command(*command, config_file: "deploy_simple") with_argv([ *command, "-c", "test/fixtures/#{config_file}.yml" ]) do stdouted { Kamal::Cli::Main.start } end end def run_command_with_config_path(*command, config_path:, destination: nil) argv = [ *command ] argv += [ "-d", destination ] if destination argv += [ "-c", config_path ] with_argv([ *argv ]) do stdouted { Kamal::Cli::Main.start } end end def in_dummy_git_repo Dir.mktmpdir do |tmpdir| Dir.chdir(tmpdir) do `git init` yield end end end def with_config_files Dir.mktmpdir do |tmpdir| config_dir = File.join(tmpdir, "config") FileUtils.mkdir_p(config_dir) FileUtils.cp "test/fixtures/deploy.yml", config_dir FileUtils.cp "test/fixtures/deploy2.yml", config_dir FileUtils.cp "test/fixtures/deploy.elsewhere.yml", config_dir Dir.chdir(tmpdir) do yield end end end def assert_file(file, content) assert_match content, File.read(file) end def with_kamal_lock_env ENV["KAMAL_LOCK"] = "true" yield ensure ENV.delete("KAMAL_LOCK") end end ================================================ FILE: test/cli/proxy_test.rb ================================================ require_relative "cli_test_case" class CliProxyTest < CliTestCase test "boot" do run_command("boot").tap do |output| assert_match "docker login", output assert_match "mkdir -p .kamal/proxy/apps-config", output assert_match "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::Proxy::Run::MINIMUM_VERSION}\") $(cat .kamal/proxy/run_command 2> /dev/null || echo \"\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy", output end end test "boot with run config" do run_command("boot", fixture: :with_proxy_run_config).tap do |output| assert_match "docker login", output assert_match "mkdir -p .kamal/proxy/apps-config", output assert_match "docker container start kamal-proxy || docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $PWD/.kamal/proxy/apps-config:/home/kamal-proxy/.apps-config --publish 80:80 --publish 443:443 --log-opt max-size=10m --expose=9090 --cpus \"1.5\" registry:4443/basecamp/kamal-proxy:v0.9.2 kamal-proxy run --debug --metrics-port \"9090\" on 1.1.1.1", output assert_match "docker container start kamal-proxy || docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $PWD/.kamal/proxy/apps-config:/home/kamal-proxy/.apps-config --publish 80:80 --publish 443:443 --log-opt max-size=10m --expose=9190 basecamp/kamal-proxy:v0.9.2 kamal-proxy run --metrics-port \"9190\" on 1.1.1.3", output end end test "boot with run config conflicts" do assert_raises Kamal::ConfigurationError, "Conflicting proxy run configurations for host 1.1.1.2" do run_command("boot", fixture: :with_proxy_run_config_conflicts) end end test "boot old version" do Thread.report_on_exception = false SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :inspect, "kamal-proxy", "--format '{{.Config.Image}}'", "|", :awk, "-F:", "'{print $NF}'") .returns("v0.0.1") .at_least_once exception = assert_raises do run_command("boot").tap do |output| assert_match "docker login", output assert_match "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::Proxy::Run::MINIMUM_VERSION}\") $(cat .kamal/proxy/run_command 2> /dev/null || echo \"\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy", output end end assert_includes exception.message, "kamal-proxy version v0.0.1 is too old, run `kamal proxy reboot` in order to update to at least #{Kamal::Configuration::Proxy::Run::MINIMUM_VERSION}" ensure Thread.report_on_exception = false end test "boot correct version" do Thread.report_on_exception = false SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :inspect, "kamal-proxy", "--format '{{.Config.Image}}'", "|", :awk, "-F:", "'{print $NF}'") .returns(Kamal::Configuration::Proxy::Run::MINIMUM_VERSION) .at_least_once run_command("boot").tap do |output| assert_match "docker login", output assert_match "docker container start kamal-proxy || echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::Proxy::Run::MINIMUM_VERSION}\") $(cat .kamal/proxy/run_command 2> /dev/null || echo \"\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy", output end ensure Thread.report_on_exception = false end test "reboot" do run_command("reboot", "-y").tap do |output| assert_match "docker container stop kamal-proxy on 1.1.1.1", output assert_match "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy on 1.1.1.1", output assert_match "mkdir -p .kamal/proxy/apps-config on 1.1.1.1", output assert_match "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::Proxy::Run::MINIMUM_VERSION}\") $(cat .kamal/proxy/run_command 2> /dev/null || echo \"\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $PWD/.kamal/proxy/apps-config:/home/kamal-proxy/.apps-config on 1.1.1.1", output assert_match "docker container stop kamal-proxy on 1.1.1.2", output assert_match "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy on 1.1.1.2", output assert_match "mkdir -p .kamal/proxy/apps-config on 1.1.1.1", output assert_match "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::Proxy::Run::MINIMUM_VERSION}\") $(cat .kamal/proxy/run_command 2> /dev/null || echo \"\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $PWD/.kamal/proxy/apps-config:/home/kamal-proxy/.apps-config on 1.1.1.2", output end end test "reboot --rolling" do run_command("reboot", "--rolling", "-y").tap do |output| assert_match "Running docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy on 1.1.1.1", output end end test "start" do run_command("start").tap do |output| assert_match "docker container start kamal-proxy", output end end test "stop" do run_command("stop").tap do |output| assert_match "docker container stop kamal-proxy", output end end test "restart" do Kamal::Cli::Proxy.any_instance.expects(:stop) Kamal::Cli::Proxy.any_instance.expects(:start) run_command("restart") end test "details" do run_command("details").tap do |output| assert_match "docker ps --filter 'name=^kamal-proxy$'", output end end test "logs" do SSHKit::Backend::Abstract.any_instance.stubs(:capture) .with(:docker, :logs, "kamal-proxy", "--tail 100", "--timestamps", "2>&1") .returns("Log entry") SSHKit::Backend::Abstract.any_instance.stubs(:capture) .with(:docker, :logs, "proxy", "--tail 100", "--timestamps", "2>&1") .returns("Log entry") run_command("logs").tap do |output| assert_match "Proxy Host: 1.1.1.1", output assert_match "Log entry", output end end test "logs with follow" do SSHKit::Backend::Abstract.any_instance.stubs(:exec) .with("ssh -t root@1.1.1.1 -p 22 'docker logs kamal-proxy --timestamps --tail 10 --follow 2>&1'") assert_match "docker logs kamal-proxy --timestamps --tail 10 --follow", run_command("logs", "--follow") end test "remove" do run_command("remove").tap do |output| assert_match "/usr/bin/env ls .kamal/apps | wc -l", output assert_match "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy", output assert_match "docker image prune --all --force --filter label=org.opencontainers.image.title=kamal-proxy", output end end test "remove with other apps" do Thread.report_on_exception = false SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info).with(:ls, ".kamal/apps", "|", :wc, "-l").returns("1\n").twice run_command("remove").tap do |output| assert_match "Not removing the proxy, as other apps are installed, ignore this check with kamal proxy remove --force", output end ensure Thread.report_on_exception = true end test "force remove with other apps" do Thread.report_on_exception = false SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info).with(:ls, ".kamal/apps", "|", :wc, "-l").returns("1\n").twice run_command("remove").tap do |output| assert_match "Not removing the proxy, as other apps are installed, ignore this check with kamal proxy remove --force", output end ensure Thread.report_on_exception = true end test "remove_container" do run_command("remove_container").tap do |output| assert_match "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy", output end end test "remove_image" do run_command("remove_image").tap do |output| assert_match "docker image prune --all --force --filter label=org.opencontainers.image.title=kamal-proxy", output end end test "upgrade" do Object.any_instance.stubs(:sleep) SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("12345678") SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :inspect, "kamal-proxy", "--format '{{.Config.Image}}'", "|", :awk, "-F:", "'{print $NF}'") .returns(Kamal::Configuration::Proxy::Run::MINIMUM_VERSION) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :container, :ls, "--all", "--filter", "'name=^app-workers-latest$'", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") .returns("running").at_least_once # workers health check run_command("upgrade", "-y").tap do |output| assert_match "Upgrading proxy on 1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4...", output assert_match "docker login -u [REDACTED] -p [REDACTED]", output assert_match "docker container stop traefik ; docker container prune --force --filter label=org.opencontainers.image.title=Traefik && docker image prune --all --force --filter label=org.opencontainers.image.title=Traefik", output assert_match "docker container stop kamal-proxy", output assert_match "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy", output assert_match "docker image prune --all --force --filter label=org.opencontainers.image.title=kamal-proxy", output assert_match "/usr/bin/env mkdir -p .kamal", output assert_match "docker network create kamal", output assert_match "docker login -u [REDACTED] -p [REDACTED]", output assert_match "docker container start kamal-proxy || echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::Proxy::Run::MINIMUM_VERSION}\") $(cat .kamal/proxy/run_command 2> /dev/null || echo \"\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy", output assert_match "/usr/bin/env mkdir -p .kamal", output assert_match %r{docker rename app-web-latest app-web-latest_replaced_.*}, output assert_match "/usr/bin/env mkdir -p .kamal/apps/app/env/roles", output assert_match "Uploading \"\\n\" to .kamal/apps/app/env/roles/web.env", output assert_match %r{docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-.* --env KAMAL_CONTAINER_NAME="app-web-latest" --env KAMAL_VERSION="latest" --env KAMAL_HOST="1.1.1.1" --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size="10m" --label service="app" --label role="web" --label destination dhh/app:latest}, output assert_match "docker exec kamal-proxy kamal-proxy deploy app-web --target=\"12345678:80\" --deploy-timeout=\"6s\" --drain-timeout=\"30s\" --buffer-requests --buffer-responses --log-request-header=\"Cache-Control\" --log-request-header=\"Last-Modified\" --log-request-header=\"User-Agent\"", output assert_match "docker container ls --all --filter 'name=^app-web-12345678$' --quiet | xargs docker stop", output assert_match "docker tag dhh/app:latest dhh/app:latest", output assert_match "/usr/bin/env mkdir -p .kamal", output 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", output assert_match "docker image prune --force --filter label=service=app", output assert_match "Upgraded proxy on 1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4", output end end test "upgrade rolling" do Object.any_instance.stubs(:sleep) SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns("12345678") SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :inspect, "kamal-proxy", "--format '{{.Config.Image}}'", "|", :awk, "-F:", "'{print $NF}'") .returns(Kamal::Configuration::Proxy::Run::MINIMUM_VERSION) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:docker, :container, :ls, "--all", "--filter", "'name=^app-workers-latest$'", "--quiet", "|", :xargs, :docker, :inspect, "--format", "'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'") .returns("running").at_least_once # workers health check run_command("upgrade", "--rolling", "-y",).tap do |output| %w[1.1.1.1 1.1.1.2 1.1.1.3 1.1.1.4].each do |host| assert_match "Upgrading proxy on #{host}...", output assert_match "docker container stop traefik ; docker container prune --force --filter label=org.opencontainers.image.title=Traefik && docker image prune --all --force --filter label=org.opencontainers.image.title=Traefik on #{host}", output assert_match "Upgraded proxy on #{host}", output end end end test "boot_config set" do run_command("boot_config", "set").tap do |output| %w[ 1.1.1.1 1.1.1.2 ].each do |host| assert_match "Running /usr/bin/env mkdir -p .kamal/proxy on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/options on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/image on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/image_version on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/run_command on #{host}", output end end end test "boot_config set no publish" do run_command("boot_config", "set", "--publish", "false").tap do |output| %w[ 1.1.1.1 1.1.1.2 ].each do |host| assert_match "Running /usr/bin/env mkdir -p .kamal/proxy on #{host}", output assert_match "Uploading \"--log-opt max-size=10m\" to .kamal/proxy/options on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/image on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/image_version on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/run_command on #{host}", output end end end test "boot_config set custom max_size" do run_command("boot_config", "set", "--log-max-size", "100m").tap do |output| %w[ 1.1.1.1 1.1.1.2 ].each do |host| assert_match "Running /usr/bin/env mkdir -p .kamal/proxy on #{host}", output assert_match "Uploading \"--publish 80:80 --publish 443:443 --log-opt max-size=100m\" to .kamal/proxy/options on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/image on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/image_version on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/run_command on #{host}", output end end end test "boot_config set no log max size" do run_command("boot_config", "set", "--log-max-size=").tap do |output| %w[ 1.1.1.1 1.1.1.2 ].each do |host| assert_match "Running /usr/bin/env mkdir -p .kamal/proxy on #{host}", output assert_match "Uploading \"--publish 80:80 --publish 443:443\" to .kamal/proxy/options on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/image on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/image_version on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/run_command on #{host}", output end end end test "boot_config set custom ports" do run_command("boot_config", "set", "--http-port", "8080", "--https-port", "8443").tap do |output| %w[ 1.1.1.1 1.1.1.2 ].each do |host| assert_match "Running /usr/bin/env mkdir -p .kamal/proxy on #{host}", output assert_match "Uploading \"--publish 8080:80 --publish 8443:443 --log-opt max-size=10m\" to .kamal/proxy/options on #{host}", output end end end test "boot_config set bind IP" do run_command("boot_config", "set", "--publish-host-ip", "127.0.0.1").tap do |output| %w[ 1.1.1.1 1.1.1.2 ].each do |host| assert_match "Running /usr/bin/env mkdir -p .kamal/proxy on #{host}", output assert_match "Uploading \"--publish 127.0.0.1:80:80 --publish 127.0.0.1:443:443 --log-opt max-size=10m\" to .kamal/proxy/options on #{host}", output end end end test "boot_config set multiple bind IPs" do run_command("boot_config", "set", "--publish-host-ip", "127.0.0.1", "--publish-host-ip", "::1").tap do |output| %w[ 1.1.1.1 1.1.1.2 ].each do |host| assert_match "Running /usr/bin/env mkdir -p .kamal/proxy on #{host}", output assert_match "Uploading \"--publish 127.0.0.1:80:80 --publish 127.0.0.1:443:443 --publish [::1]:80:80 --publish [::1]:443:443 --log-opt max-size=10m\" to .kamal/proxy/options on #{host}", output end end end test "boot_config set invalid bind IPs" do exception = assert_raises do run_command("boot_config", "set", "--publish-host-ip", "1.2.3.invalidIP", "--publish-host-ip", "::1") end assert_includes exception.message, "Invalid publish IP address: 1.2.3.invalidIP" end test "boot_config set docker options" do run_command("boot_config", "set", "--docker_options", "label=foo=bar", "add_host=thishost:thathost").tap do |output| %w[ 1.1.1.1 1.1.1.2 ].each do |host| assert_match "Running /usr/bin/env mkdir -p .kamal/proxy on #{host}", output assert_match "Uploading \"--publish 80:80 --publish 443:443 --log-opt max-size=10m --label=foo=bar --add_host=thishost:thathost\" to .kamal/proxy/options on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/image on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/image_version on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/run_command on #{host}", output end end end test "boot_config set registry" do run_command("boot_config", "set", "--registry", "myreg").tap do |output| %w[ 1.1.1.1 1.1.1.2 ].each do |host| assert_match "Running /usr/bin/env mkdir -p .kamal/proxy on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/options on #{host}", output assert_match "Uploading \"myreg/basecamp/kamal-proxy\" to .kamal/proxy/image on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/image_version on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/run_command on #{host}", output end end end test "boot_config set repository" do run_command("boot_config", "set", "--repository", "myrepo").tap do |output| %w[ 1.1.1.1 1.1.1.2 ].each do |host| assert_match "Running /usr/bin/env mkdir -p .kamal/proxy on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/options on #{host}", output assert_match "Uploading \"myrepo/kamal-proxy\" to .kamal/proxy/image on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/image_version on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/run_command on #{host}", output end end end test "boot_config set image_version" do run_command("boot_config", "set", "--image_version", "0.9.9").tap do |output| %w[ 1.1.1.1 1.1.1.2 ].each do |host| assert_match "Running /usr/bin/env mkdir -p .kamal/proxy on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/options on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/image on #{host}", output assert_match "Uploading \"0.9.9\" to .kamal/proxy/image_version on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/run_command on #{host}", output end end end test "boot_config set run_command" do run_command("boot_config", "set", "--metrics_port", "9000", "--debug", "true").tap do |output| %w[ 1.1.1.1 1.1.1.2 ].each do |host| assert_match "Running /usr/bin/env mkdir -p .kamal/proxy on #{host}", output assert_match "Uploading \"--publish 80:80 --publish 443:443 --log-opt max-size=10m --expose=9000\" to .kamal/proxy/options on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/image on #{host}", output assert_match "Running /usr/bin/env rm .kamal/proxy/image_version on #{host}", output assert_match "Uploading \"kamal-proxy run --debug --metrics-port \\\"9000\\\"\" to .kamal/proxy/run_command on #{host}", output end end end test "boot_config set all" do run_command("boot_config", "set", "--docker_options", "label=foo=bar", "--registry", "myreg", "--repository", "myrepo", "--image_version", "0.9.9", "--metrics_port", "9000", "--debug", "true").tap do |output| %w[ 1.1.1.1 1.1.1.2 ].each do |host| assert_match "Uploading \"--publish 80:80 --publish 443:443 --log-opt max-size=10m --expose=9000 --label=foo=bar\" to .kamal/proxy/options on #{host}", output assert_match "Uploading \"myreg/myrepo/kamal-proxy\" to .kamal/proxy/image on #{host}", output assert_match "Uploading \"0.9.9\" to .kamal/proxy/image_version on #{host}", output assert_match "Uploading \"kamal-proxy run --debug --metrics-port \\\"9000\\\"\" to .kamal/proxy/run_command on #{host}", output end end end test "boot_config get" do SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) .with(:echo, "$(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::Proxy::Run::MINIMUM_VERSION}\") $(cat .kamal/proxy/run_command 2> /dev/null || echo \"\")") .returns("--publish 80:80 --publish 8443:443 --label=foo=bar basecamp/kamal-proxy:v1.0.0") .twice run_command("boot_config", "get").tap do |output| assert_match "Host 1.1.1.1: --publish 80:80 --publish 8443:443 --label=foo=bar basecamp/kamal-proxy:v1.0.0", output assert_match "Host 1.1.1.2: --publish 80:80 --publish 8443:443 --label=foo=bar basecamp/kamal-proxy:v1.0.0", output end end test "boot_config reset" do run_command("boot_config", "reset").tap do |output| %w[ 1.1.1.1 1.1.1.2 ].each do |host| assert_match "rm .kamal/proxy/options on #{host}", output end end end private def run_command(*command, fixture: :with_proxy) stdouted { Kamal::Cli::Proxy.start([ *command, "-c", "test/fixtures/deploy_#{fixture}.yml" ]) } end end ================================================ FILE: test/cli/prune_test.rb ================================================ require_relative "cli_test_case" class CliPruneTest < CliTestCase test "all" do Kamal::Cli::Prune.any_instance.expects(:containers) Kamal::Cli::Prune.any_instance.expects(:images) run_command("all") end test "images" do run_command("images").tap do |output| assert_match "docker image prune --force --filter label=service=app on 1.1.1.", output 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 end end test "containers" do run_command("containers").tap do |output| 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 end run_command("containers", "--retain", "10").tap do |output| 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 end assert_raises(RuntimeError, "retain must be at least 1") do run_command("containers", "--retain", "0") end end private def run_command(*command) stdouted { Kamal::Cli::Prune.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) } end end ================================================ FILE: test/cli/registry_test.rb ================================================ require_relative "cli_test_case" class CliRegistryTest < CliTestCase test "setup" do run_command("setup").tap do |output| assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] as .*@localhost/, output assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] on 1.1.1.\d/, output end end test "setup skip local" do run_command("setup", "-L").tap do |output| assert_no_match /docker login -u \[REDACTED\] -p \[REDACTED\] as .*@localhost/, output assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] on 1.1.1.\d/, output end end test "setup skip remote" do run_command("setup", "-R").tap do |output| assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] as .*@localhost/, output assert_no_match /docker login -u \[REDACTED\] -p \[REDACTED\] on 1.1.1.\d/, output end end test "remove" do run_command("remove").tap do |output| assert_match /docker logout as .*@localhost/, output assert_match /docker logout on 1.1.1.\d/, output end end test "remove skip local" do run_command("remove", "-L").tap do |output| assert_no_match /docker logout as .*@localhost/, output assert_match /docker logout on 1.1.1.\d/, output end end test "remove skip remote" do run_command("remove", "-R").tap do |output| assert_match /docker logout as .*@localhost/, output assert_no_match /docker logout on 1.1.1.\d/, output end end test "setup with no docker" do stub_setup SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with(:docker, "--version", "&&", :docker, :buildx, "version") .raises(SSHKit::Command::Failed.new("command not found")) assert_raises(Kamal::Cli::DependencyError) { run_command("setup") } end test "allow remote login with no docker" do stub_setup SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with(:docker, "--version", "&&", :docker, :buildx, "version") .raises(SSHKit::Command::Failed.new("command not found")) SSHKit::Backend::Abstract.any_instance.stubs(:execute) .with { |*args| args[0..1] == [ :docker, :login ] } assert_nothing_raised { run_command("setup", "--skip-local") } end test "setup local registry" do run_command("setup", fixture: :with_local_registry).tap do |output| assert_match /docker start kamal-docker-registry || docker run --detach -p 127.0.0.1:5000:5000 --name kamal-docker-registry registry:2 as .*@localhost/, output end end test "remove local registry" do run_command("remove", fixture: :with_local_registry).tap do |output| assert_match /docker stop kamal-docker-registry && docker rm kamal-docker-registry as .*@localhost/, output end end test "login" do run_command("login").tap do |output| assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] as .*@localhost/, output assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] on 1.1.1.\d/, output end end test "login skip local" do run_command("login", "-L").tap do |output| assert_no_match /docker login -u \[REDACTED\] -p \[REDACTED\] as .*@localhost/, output assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] on 1.1.1.\d/, output end end test "login skip remote" do run_command("login", "-R").tap do |output| assert_match /docker login -u \[REDACTED\] -p \[REDACTED\] as .*@localhost/, output assert_no_match /docker login -u \[REDACTED\] -p \[REDACTED\] on 1.1.1.\d/, output end end test "logout" do run_command("logout").tap do |output| assert_match /docker logout as .*@localhost/, output assert_match /docker logout on 1.1.1.\d/, output end end test "logout skip local" do run_command("logout", "-L").tap do |output| assert_no_match /docker logout as .*@localhost/, output assert_match /docker logout on 1.1.1.\d/, output end end test "logout skip remote" do run_command("logout", "-R").tap do |output| assert_match /docker logout as .*@localhost/, output assert_no_match /docker logout on 1.1.1.\d/, output end end test "login with local registry raises error" do error = assert_raises(RuntimeError) do run_command("login", fixture: :with_local_registry) end assert_match /Cannot use login command with a local registry. Use `kamal registry setup` instead./, error.message end test "logout with local registry raises error" do error = assert_raises(RuntimeError) do run_command("logout", fixture: :with_local_registry) end assert_match /Cannot use logout command with a local registry. Use `kamal registry remove` instead./, error.message end private def run_command(*command, fixture: :with_accessories) stdouted { Kamal::Cli::Registry.start([ *command, "-c", "test/fixtures/deploy_#{fixture}.yml" ]) } end end ================================================ FILE: test/cli/secrets_test.rb ================================================ require_relative "cli_test_case" class CliSecretsTest < CliTestCase test "fetch" do assert_equal \ '{"foo":"oof","bar":"rab","baz":"zab"}', run_command("fetch", "foo", "bar", "baz", "--account", "myaccount", "--adapter", "test") end test "fetch missing --acount" do assert_equal \ "No value provided for required options '--account'", run_command("fetch", "foo", "bar", "baz", "--adapter", "test") end test "extract" do assert_equal "oof", run_command("extract", "foo", "{\"foo\":\"oof\", \"bar\":\"rab\", \"baz\":\"zab\"}") end test "extract match from end" do assert_equal "oof", run_command("extract", "foo", "{\"abc/foo\":\"oof\", \"bar\":\"rab\", \"baz\":\"zab\"}") end test "print" do with_test_secrets("secrets" => "SECRET1=ABC\nSECRET2=${SECRET1}DEF\n") do assert_equal "SECRET1=ABC\nSECRET2=ABCDEF", run_command("print") end end private def run_command(*command) stdouted { Kamal::Cli::Secrets.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) } end end ================================================ FILE: test/cli/server_test.rb ================================================ require_relative "cli_test_case" class CliServerTest < CliTestCase test "running a command with exec" do SSHKit::Backend::Abstract.any_instance.stubs(:capture) .with("date", verbosity: 1) .returns("Today") hosts = "1.1.1.1".."1.1.1.4" run_command("exec", "date").tap do |output| hosts.map do |host| assert_match "Running 'date' on #{hosts.to_a.join(', ')}...", output assert_match "App Host: #{host}\nToday", output end end end test "running a command with exec multiple arguments" do SSHKit::Backend::Abstract.any_instance.stubs(:capture) .with("date -j", verbosity: 1) .returns("Today") hosts = "1.1.1.1".."1.1.1.4" run_command("exec", "date", "-j").tap do |output| hosts.map do |host| assert_match "Running 'date -j' on #{hosts.to_a.join(', ')}...", output assert_match "App Host: #{host}\nToday", output end end end test "bootstrap already installed" do stub_setup SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "-v", raise_on_non_zero_exit: false).returns(true).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:mkdir, "-p", ".kamal").returns("").at_least_once assert_equal "Acquiring the deploy lock...\nReleasing the deploy lock...", run_command("bootstrap") end test "bootstrap install as non-root user" do stub_setup SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "-v", raise_on_non_zero_exit: false).returns(false).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with('[ "${EUID:-$(id -u)}" -eq 0 ] || sudo -nl usermod >/dev/null', raise_on_non_zero_exit: false).returns(false).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:mkdir, "-p", ".kamal").returns("").at_least_once assert_raise RuntimeError, "Docker is not installed on 1.1.1.1, 1.1.1.3, 1.1.1.4, 1.1.1.2 and can't be automatically installed without having root access and the `curl` command available. Install Docker manually: https://docs.docker.com/engine/install/" do run_command("bootstrap") end end test "bootstrap install as root user" do stub_setup SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "-v", raise_on_non_zero_exit: false).returns(false).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with('[ "${EUID:-$(id -u)}" -eq 0 ] || sudo -nl usermod >/dev/null', raise_on_non_zero_exit: false).returns(true).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:sh, "-c", "'curl -fsSL https://get.docker.com || wget -O - https://get.docker.com || echo \"exit 1\"'", "|", :sh).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with('[ "${EUID:-$(id -u)}" -eq 0 ]', raise_on_non_zero_exit: false).returns(true).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:mkdir, "-p", ".kamal").returns("").at_least_once Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) SSHKit::Backend::Abstract.any_instance.expects(:execute).with(".kamal/hooks/pre-connect", anything).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with(".kamal/hooks/docker-setup", anything).at_least_once run_command("bootstrap").tap do |output| ("1.1.1.1".."1.1.1.4").map do |host| assert_match "Missing Docker on #{host}. Installing…", output end end end test "bootstrap install as sudo non-root user" do stub_setup SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, "-v", raise_on_non_zero_exit: false).returns(false).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with('[ "${EUID:-$(id -u)}" -eq 0 ] || sudo -nl usermod >/dev/null', raise_on_non_zero_exit: false).returns(true).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:sh, "-c", "'curl -fsSL https://get.docker.com || wget -O - https://get.docker.com || echo \"exit 1\"'", "|", :sh).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with('[ "${EUID:-$(id -u)}" -eq 0 ]', raise_on_non_zero_exit: false).returns(false).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with('id -nG "${USER:-$(id -un)}" | grep -qw docker', raise_on_non_zero_exit: false).returns(false).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with('sudo -n usermod -aG docker "${USER:-$(id -un)}"').at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with("kill -HUP $PPID").at_least_once.raises(IOError, "closed stream") SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:mkdir, "-p", ".kamal").returns("").at_least_once Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true) SSHKit::Backend::Abstract.any_instance.expects(:execute).with(".kamal/hooks/pre-connect", anything).at_least_once SSHKit::Backend::Abstract.any_instance.expects(:execute).with(".kamal/hooks/docker-setup", anything).at_least_once run_command("bootstrap").tap do |output| ("1.1.1.1".."1.1.1.4").map do |host| assert_match "Missing Docker on #{host}. Installing…", output assert_match "Session refreshed due to group change.", output end end end private def run_command(*command) stdouted { Kamal::Cli::Server.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) } end end ================================================ FILE: test/commander_test.rb ================================================ require "test_helper" class CommanderTest < ActiveSupport::TestCase setup do configure_with(:deploy_with_roles) end test "lazy configuration" do assert_equal Kamal::Configuration, @kamal.config.class end test "overwriting hosts" do assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @kamal.hosts @kamal.specific_hosts = [ "1.1.1.1", "1.1.1.2" ] assert_equal [ "1.1.1.1", "1.1.1.2" ], @kamal.hosts @kamal.specific_hosts = [ "1.1.1.1*" ] assert_equal [ "1.1.1.1" ], @kamal.hosts @kamal.specific_hosts = [ "1.1.1.*", "*.1.2.*" ] assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @kamal.hosts @kamal.specific_hosts = [ "*" ] assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @kamal.hosts @kamal.specific_hosts = [ "1.1.1.[12]" ] assert_equal [ "1.1.1.1", "1.1.1.2" ], @kamal.hosts exception = assert_raises(ArgumentError) do @kamal.specific_hosts = [ "*miss" ] end assert_match /hosts match for \*miss/, exception.message end test "filtering hosts by filtering roles" do assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @kamal.hosts @kamal.specific_roles = [ "web" ] assert_equal [ "1.1.1.1", "1.1.1.2" ], @kamal.hosts exception = assert_raises(ArgumentError) do @kamal.specific_roles = [ "*miss" ] end assert_match /roles match for \*miss/, exception.message end test "filtering roles" do assert_equal [ "web", "workers" ], @kamal.roles.map(&:name) @kamal.specific_roles = [ "workers" ] assert_equal [ "workers" ], @kamal.roles.map(&:name) @kamal.specific_roles = [ "w*" ] assert_equal [ "web", "workers" ], @kamal.roles.map(&:name) @kamal.specific_roles = [ "we*", "*orkers" ] assert_equal [ "web", "workers" ], @kamal.roles.map(&:name) @kamal.specific_roles = [ "*" ] assert_equal [ "web", "workers" ], @kamal.roles.map(&:name) @kamal.specific_roles = [ "w{eb,orkers}" ] assert_equal [ "web", "workers" ], @kamal.roles.map(&:name) exception = assert_raises(ArgumentError) do @kamal.specific_roles = [ "*miss" ] end assert_match /roles match for \*miss/, exception.message end test "filtering roles by filtering hosts" do assert_equal [ "web", "workers" ], @kamal.roles.map(&:name) @kamal.specific_hosts = [ "1.1.1.3" ] assert_equal [ "workers" ], @kamal.roles.map(&:name) end test "overwriting hosts with primary" do assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @kamal.hosts @kamal.specific_primary! assert_equal [ "1.1.1.1" ], @kamal.hosts end test "primary_host with specific hosts via role" do @kamal.specific_roles = "workers" assert_equal "1.1.1.3", @kamal.primary_host end test "primary_role" do assert_equal "web", @kamal.primary_role.name @kamal.specific_roles = "workers" assert_equal "workers", @kamal.primary_role.name end test "roles_on" do assert_equal [ "web" ], @kamal.roles_on("1.1.1.1").map(&:name) assert_equal [ "workers" ], @kamal.roles_on("1.1.1.3").map(&:name) end test "roles_on web comes first" do configure_with(:deploy_with_two_roles_one_host) assert_equal [ "web", "workers" ], @kamal.roles_on("1.1.1.1").map(&:name) end test "try to match the primary role from a list of specific roles" do configure_with(:deploy_primary_web_role_override) @kamal.specific_roles = [ "web_*" ] assert_equal [ "web_tokyo", "web_chicago" ], @kamal.roles.map(&:name) assert_equal "web_tokyo", @kamal.primary_role.name assert_equal "1.1.1.3", @kamal.primary_host assert_equal [ "1.1.1.3", "1.1.1.4", "1.1.1.1", "1.1.1.2" ], @kamal.hosts end test "proxy hosts should observe filtered roles" do configure_with(:deploy_with_multiple_proxy_roles) @kamal.specific_roles = [ "web_tokyo" ] assert_equal [ "1.1.1.3", "1.1.1.4" ], @kamal.proxy_hosts end test "proxy hosts should observe filtered hosts" do configure_with(:deploy_with_multiple_proxy_roles) @kamal.specific_hosts = [ "1.1.1.2" ] assert_equal [ "1.1.1.2" ], @kamal.proxy_hosts end test "accessory hosts without filtering" do configure_with(:deploy_with_single_accessory) assert_equal [ "1.1.1.5" ], @kamal.accessory_hosts configure_with(:deploy_with_accessories_on_independent_server) assert_equal [ "1.1.1.5", "1.1.1.1", "1.1.1.2" ], @kamal.accessory_hosts end test "accessory hosts with role filtering" do configure_with(:deploy_with_single_accessory) @kamal.specific_roles = [ "web" ] assert_equal [], @kamal.accessory_hosts configure_with(:deploy_with_accessories_on_independent_server) @kamal.specific_roles = [ "web" ] assert_equal [ "1.1.1.1", "1.1.1.2" ], @kamal.accessory_hosts @kamal.specific_roles = [ "workers" ] assert_equal [], @kamal.accessory_hosts end test "primary role hosts are first" do configure_with(:deploy_with_roles_workers_primary) assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @kamal.hosts assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @kamal.app_hosts end test "with_specific_hosts restores primary_host after block" do original_primary = @kamal.primary_host assert_equal "1.1.1.1", original_primary @kamal.with_specific_hosts("1.1.1.3") do assert_equal "1.1.1.3", @kamal.primary_host end assert_equal original_primary, @kamal.primary_host end test "with_specific_hosts restores primary_host after iterating multiple hosts" do original_primary = @kamal.primary_host hosts_visited = [] @kamal.hosts.each do |host| @kamal.with_specific_hosts(host) do hosts_visited << @kamal.primary_host end end assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], hosts_visited assert_equal original_primary, @kamal.primary_host end test "with_specific_hosts restores primary_host even after exception" do original_primary = @kamal.primary_host assert_raises(RuntimeError) do @kamal.with_specific_hosts("1.1.1.3") do raise "test error" end end assert_equal original_primary, @kamal.primary_host end private def configure_with(variant) @kamal = Kamal::Commander.new.tap do |kamal| kamal.configure config_file: Pathname.new(File.expand_path("fixtures/#{variant}.yml", __dir__)) end end end ================================================ FILE: test/commands/accessory_test.rb ================================================ require "test_helper" class CommandsAccessoryTest < ActiveSupport::TestCase setup do setup_test_secrets("secrets" => "MYSQL_ROOT_PASSWORD=secret123") @config = { service: "app", image: "dhh/app", registry: { "server" => "private.registry", "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], builder: { "arch" => "amd64" }, accessories: { "mysql" => { "image" => "private.registry/mysql:8.0", "host" => "1.1.1.5", "port" => "3306", "env" => { "clear" => { "MYSQL_ROOT_HOST" => "%" }, "secret" => [ "MYSQL_ROOT_PASSWORD" ] }, "options" => { "cpus" => "4", "memory" => "2GB" } }, "redis" => { "image" => "redis:latest", "host" => "1.1.1.6", "port" => "6379:6379", "labels" => { "cache" => "true" }, "env" => { "SOMETHING" => "else" }, "volumes" => [ "/var/lib/redis:/data" ] }, "busybox" => { "service" => "custom-busybox", "image" => "busybox:latest", "registry" => { "server" => "other.registry", "username" => "user", "password" => "pw" }, "host" => "1.1.1.7", "proxy" => { "host" => "busybox.example.com" } } } } end teardown do teardown_test_secrets end test "run" do assert_equal \ "docker run --name app-mysql --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --label service=\"app-mysql\" --cpus \"4\" --memory \"2GB\" private.registry/mysql:8.0", new_command(:mysql).run.join(" ") assert_equal \ "docker run --name app-redis --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --publish 6379:6379 --env SOMETHING=\"else\" --env-file .kamal/apps/app/env/accessories/redis.env --volume /var/lib/redis:/data --label service=\"app-redis\" --label cache=\"true\" redis:latest", new_command(:redis).run.join(" ") assert_equal \ "docker run --name custom-busybox --detach --restart unless-stopped --network kamal --log-opt max-size=\"10m\" --env-file .kamal/apps/app/env/accessories/busybox.env --label service=\"custom-busybox\" other.registry/busybox:latest", new_command(:busybox).run.join(" ") end test "run with logging config" do @config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } } assert_equal \ "docker run --name custom-busybox --detach --restart unless-stopped --network kamal --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --env-file .kamal/apps/app/env/accessories/busybox.env --label service=\"custom-busybox\" other.registry/busybox:latest", new_command(:busybox).run.join(" ") end test "run in custom network" do @config[:accessories]["mysql"]["network"] = "custom" assert_equal \ "docker run --name app-mysql --detach --restart unless-stopped --network custom --log-opt max-size=\"10m\" --publish 3306:3306 --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --label service=\"app-mysql\" --cpus \"4\" --memory \"2GB\" private.registry/mysql:8.0", new_command(:mysql).run.join(" ") end test "start" do assert_equal \ "docker container start app-mysql", new_command(:mysql).start.join(" ") end test "stop" do assert_equal \ "docker container stop app-mysql", new_command(:mysql).stop.join(" ") end test "info" do assert_equal \ "docker ps --filter label=service=app-mysql", new_command(:mysql).info.join(" ") end test "execute in new container" do assert_equal \ "docker run --rm --network kamal --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --cpus \"4\" --memory \"2GB\" private.registry/mysql:8.0 mysql -u root", new_command(:mysql).execute_in_new_container("mysql", "-u", "root").join(" ") end test "execute in existing container" do assert_equal \ "docker exec app-mysql mysql -u root", new_command(:mysql).execute_in_existing_container("mysql", "-u", "root").join(" ") end test "execute in new container over ssh" do new_command(:mysql).stub(:run_over_ssh, ->(cmd) { cmd.join(" ") }) do assert_match %r{docker run -it --rm --network kamal --env MYSQL_ROOT_HOST=\"%\" --env-file .kamal/apps/app/env/accessories/mysql.env --cpus \"4\" --memory \"2GB\" private.registry/mysql:8.0 mysql -u root}, stub_stdin_tty { new_command(:mysql).execute_in_new_container_over_ssh("mysql", "-u", "root") } end end test "execute in existing container over ssh" do new_command(:mysql).stub(:run_over_ssh, ->(cmd) { cmd.join(" ") }) do assert_match %r{docker exec -it app-mysql mysql -u root}, stub_stdin_tty { new_command(:mysql).execute_in_existing_container_over_ssh("mysql", "-u", "root") } end end test "execute in existing container with piped input over ssh" do new_command(:mysql).stub(:run_over_ssh, ->(cmd) { cmd.join(" ") }) do assert_match %r{docker exec -i app-mysql mysql -u root}, stub_stdin_file { new_command(:mysql).execute_in_existing_container_over_ssh("mysql", "-u", "root") } end end test "logs" do assert_equal \ "docker logs app-mysql --timestamps 2>&1", new_command(:mysql).logs.join(" ") assert_equal \ "docker logs app-mysql --since 5m --tail 100 --timestamps 2>&1 | grep 'thing'", new_command(:mysql).logs(since: "5m", lines: 100, grep: "thing").join(" ") assert_equal \ "docker logs app-mysql --since 5m --tail 100 --timestamps 2>&1 | grep 'thing' -C 2", new_command(:mysql).logs(since: "5m", lines: 100, grep: "thing", grep_options: "-C 2").join(" ") assert_equal \ "docker logs app-mysql --since 5m --tail 100 2>&1 | grep 'thing' -C 2", new_command(:mysql).logs(timestamps: false, since: "5m", lines: 100, grep: "thing", grep_options: "-C 2").join(" ") end test "follow logs" do assert_equal \ "ssh -t root@1.1.1.5 -p 22 'docker logs app-mysql --timestamps --tail 10 --follow 2>&1'", new_command(:mysql).follow_logs assert_equal \ "ssh -t root@1.1.1.5 -p 22 'docker logs app-mysql --tail 10 --follow 2>&1'", new_command(:mysql).follow_logs(timestamps: false) end test "remove container" do assert_equal \ "docker container prune --force --filter label=service=app-mysql", new_command(:mysql).remove_container.join(" ") end test "pull image" do assert_equal \ "docker image pull private.registry/mysql:8.0", new_command(:mysql).pull_image.join(" ") end test "remove image" do assert_equal \ "docker image rm --force private.registry/mysql:8.0", new_command(:mysql).remove_image.join(" ") end test "deploy" do assert_equal \ "docker exec kamal-proxy kamal-proxy deploy custom-busybox --target=\"172.1.0.2:80\" --host=\"busybox.example.com\" --deploy-timeout=\"30s\" --drain-timeout=\"30s\" --buffer-requests --buffer-responses --log-request-header=\"Cache-Control\" --log-request-header=\"Last-Modified\" --log-request-header=\"User-Agent\"", new_command(:busybox).deploy(target: "172.1.0.2").join(" ") end test "remove" do assert_equal \ "docker exec kamal-proxy kamal-proxy remove custom-busybox", new_command(:busybox).remove.join(" ") end private def new_command(accessory) Kamal::Commands::Accessory.new(Kamal::Configuration.new(@config), name: accessory) end end ================================================ FILE: test/commands/app_test.rb ================================================ require "test_helper" class CommandsAppTest < ActiveSupport::TestCase setup do setup_test_secrets("secrets" => "RAILS_MASTER_KEY=456") @config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: { "web" => [ "1.1.1.1" ], "workers" => [ "1.1.1.2" ] }, env: { "secret" => [ "RAILS_MASTER_KEY" ] }, builder: { "arch" => "amd64" } } end teardown do teardown_test_secrets end test "run" do assert_equal \ "docker run --detach --restart unless-stopped --name app-web-999 --network kamal --env KAMAL_CONTAINER_NAME=\"app-web-999\" --env KAMAL_VERSION=\"999\" --env KAMAL_HOST=\"1.1.1.1\" --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999", new_command.run.join(" ") end test "run with destination" do @destination = "staging" assert_equal \ "docker run --detach --restart unless-stopped --name app-web-staging-999 --network kamal --env KAMAL_CONTAINER_NAME=\"app-web-staging-999\" --env KAMAL_VERSION=\"999\" --env KAMAL_HOST=\"1.1.1.1\" --env KAMAL_DESTINATION=\"staging\" --env-file .kamal/apps/app-staging/env/roles/web.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination=\"staging\" dhh/app:999", new_command.run.join(" ") end test "run with hostname" do assert_equal \ "docker run --detach --restart unless-stopped --name app-web-999 --network kamal --hostname myhost --env KAMAL_CONTAINER_NAME=\"app-web-999\" --env KAMAL_VERSION=\"999\" --env KAMAL_HOST=\"1.1.1.1\" --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999", new_command.run(hostname: "myhost").join(" ") end test "run with volumes" do @config[:volumes] = [ "/local/path:/container/path" ] assert_equal \ "docker run --detach --restart unless-stopped --name app-web-999 --network kamal --env KAMAL_CONTAINER_NAME=\"app-web-999\" --env KAMAL_VERSION=\"999\" --env KAMAL_HOST=\"1.1.1.1\" --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" --volume /local/path:/container/path --label service=\"app\" --label role=\"web\" --label destination dhh/app:999", new_command.run.join(" ") end test "run with custom options" do @config[:servers] = { "web" => [ "1.1.1.1" ], "jobs" => { "hosts" => [ "1.1.1.2" ], "cmd" => "bin/jobs", "options" => { "mount" => "somewhere", "cap-add" => true } } } assert_equal \ "docker run --detach --restart unless-stopped --name app-jobs-999 --network kamal --env KAMAL_CONTAINER_NAME=\"app-jobs-999\" --env KAMAL_VERSION=\"999\" --env KAMAL_HOST=\"1.1.1.2\" --env-file .kamal/apps/app/env/roles/jobs.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"jobs\" --label destination --mount \"somewhere\" --cap-add dhh/app:999 bin/jobs", new_command(role: "jobs", host: "1.1.1.2").run.join(" ") end test "run with logging config" do @config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } } assert_equal \ "docker run --detach --restart unless-stopped --name app-web-999 --network kamal --env KAMAL_CONTAINER_NAME=\"app-web-999\" --env KAMAL_VERSION=\"999\" --env KAMAL_HOST=\"1.1.1.1\" --env-file .kamal/apps/app/env/roles/web.env --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999", new_command.run.join(" ") end test "run with role logging config" do @config[:logging] = { "driver" => "local", "options" => { "max-size" => "10m", "max-file" => "3" } } @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "logging" => { "driver" => "local", "options" => { "max-size" => "100m" } } } } assert_equal \ "docker run --detach --restart unless-stopped --name app-web-999 --network kamal --env KAMAL_CONTAINER_NAME=\"app-web-999\" --env KAMAL_VERSION=\"999\" --env KAMAL_HOST=\"1.1.1.1\" --env-file .kamal/apps/app/env/roles/web.env --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999", new_command.run.join(" ") end test "run with tags" do @config[:servers] = [ { "1.1.1.1" => "tag1" } ] @config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } } assert_equal \ "docker run --detach --restart unless-stopped --name app-web-999 --network kamal --env KAMAL_CONTAINER_NAME=\"app-web-999\" --env KAMAL_VERSION=\"999\" --env KAMAL_HOST=\"1.1.1.1\" --env ENV1=\"value1\" --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" --label service=\"app\" --label role=\"web\" --label destination dhh/app:999", new_command.run.join(" ") end test "start" do assert_equal \ "docker start app-web-999", new_command.start.join(" ") end test "start with destination" do @destination = "staging" assert_equal \ "docker start app-web-staging-999", new_command.start.join(" ") end test "stop" do assert_equal \ "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker stop", new_command.stop.join(" ") end test "stop with custom drain timeout" do @config[:drain_timeout] = 20 assert_equal \ "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker stop", new_command.stop.join(" ") assert_equal \ "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=workers --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=workers --filter status=running --filter status=restarting' | head -1 | xargs docker stop -t 20", new_command(role: "workers").stop.join(" ") end test "stop with version" do assert_equal \ "docker container ls --all --filter 'name=^app-web-123$' --quiet | xargs docker stop", new_command.stop(version: "123").join(" ") end test "info" do assert_equal \ "docker ps --filter label=service=app --filter label=destination= --filter label=role=web", new_command.info.join(" ") end test "info with destination" do @destination = "staging" assert_equal \ "docker ps --filter label=service=app --filter label=destination=staging --filter label=role=web", new_command.info.join(" ") end test "deploy" do assert_equal \ "docker exec kamal-proxy kamal-proxy deploy app-web --target=\"172.1.0.2:80\" --deploy-timeout=\"30s\" --drain-timeout=\"30s\" --buffer-requests --buffer-responses --log-request-header=\"Cache-Control\" --log-request-header=\"Last-Modified\" --log-request-header=\"User-Agent\"", new_command.deploy(target: "172.1.0.2").join(" ") end test "deploy with SSL" do @config[:proxy] = { "ssl" => true, "host" => "example.com" } assert_equal \ "docker exec kamal-proxy kamal-proxy deploy app-web --target=\"172.1.0.2:80\" --host=\"example.com\" --tls --deploy-timeout=\"30s\" --drain-timeout=\"30s\" --buffer-requests --buffer-responses --log-request-header=\"Cache-Control\" --log-request-header=\"Last-Modified\" --log-request-header=\"User-Agent\"", new_command.deploy(target: "172.1.0.2").join(" ") end test "deploy with SSL targeting multiple hosts" do @config[:proxy] = { "ssl" => true, "hosts" => [ "example.com", "anotherexample.com" ] } assert_equal \ "docker exec kamal-proxy kamal-proxy deploy app-web --target=\"172.1.0.2:80\" --host=\"example.com\" --host=\"anotherexample.com\" --tls --deploy-timeout=\"30s\" --drain-timeout=\"30s\" --buffer-requests --buffer-responses --log-request-header=\"Cache-Control\" --log-request-header=\"Last-Modified\" --log-request-header=\"User-Agent\"", new_command.deploy(target: "172.1.0.2").join(" ") end test "deploy with SSL false" do @config[:proxy] = { "ssl" => false } assert_equal \ "docker exec kamal-proxy kamal-proxy deploy app-web --target=\"172.1.0.2:80\" --deploy-timeout=\"30s\" --drain-timeout=\"30s\" --buffer-requests --buffer-responses --log-request-header=\"Cache-Control\" --log-request-header=\"Last-Modified\" --log-request-header=\"User-Agent\"", new_command.deploy(target: "172.1.0.2").join(" ") end test "remove" do assert_equal \ "docker exec kamal-proxy kamal-proxy remove app-web", new_command.remove.join(" ") end test "logs" do assert_equal \ "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps 2>&1", new_command.logs.join(" ") end test "logs with container_id" do assert_equal \ "echo C137 | xargs docker logs --timestamps 2>&1", new_command.logs(container_id: "C137").join(" ") end test "logs with since" do assert_equal \ "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --since 5m 2>&1", new_command.logs(since: "5m").join(" ") end test "logs with lines" do assert_equal \ "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --tail 100 2>&1", new_command.logs(lines: "100").join(" ") end test "logs with since and lines" do assert_equal \ "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --since 5m --tail 100 2>&1", new_command.logs(since: "5m", lines: "100").join(" ") end test "logs with grep" do assert_equal \ "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps 2>&1 | grep 'my-id'", new_command.logs(grep: "my-id").join(" ") end test "logs with grep and grep options" do assert_equal \ "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps 2>&1 | grep 'my-id' -C 2", new_command.logs(grep: "my-id", grep_options: "-C 2").join(" ") end test "logs with since, grep and grep options" do assert_equal \ "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --since 5m 2>&1 | grep 'my-id' -C 2", new_command.logs(since: "5m", grep: "my-id", grep_options: "-C 2").join(" ") end test "logs with since and grep" do assert_equal \ "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --since 5m 2>&1 | grep 'my-id'", new_command.logs(since: "5m", grep: "my-id").join(" ") end test "follow logs" do assert_equal \ "ssh -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1'", new_command.follow_logs(host: "app-1") assert_equal \ "ssh -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"Completed\"'", new_command.follow_logs(host: "app-1", grep: "Completed") assert_equal \ "ssh -t root@app-1 -p 22 'echo ID321 | xargs docker logs --timestamps --follow 2>&1'", new_command.follow_logs(host: "app-1", container_id: "ID321") assert_equal \ "ssh -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --tail 123 --follow 2>&1'", new_command.follow_logs(host: "app-1", lines: 123) assert_equal \ "ssh -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --tail 123 --follow 2>&1 | grep \"Completed\"'", new_command.follow_logs(host: "app-1", lines: 123, grep: "Completed") assert_equal \ "ssh -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --tail 123 --follow 2>&1 | grep \"Completed\"'", new_command.follow_logs(host: "app-1", timestamps: false, lines: 123, grep: "Completed") end test "follow logs with ssh keys" do @config[:ssh] = { "keys" => [ "path_to_key.pem" ] } assert_equal \ "ssh -i path_to_key.pem -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1'", new_command.follow_logs(host: "app-1") end test "follow logs with ssh proxy_command" do @config[:ssh] = { "proxy_command" => "ssh -W %h:%p user@proxy-server" } assert_equal \ "ssh -o ProxyCommand='ssh -W %h:%p user@proxy-server' -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1'", new_command.follow_logs(host: "app-1") end test "follow logs with ssh config file" do @config[:ssh] = { "config" => "~/.ssh/custom_config" } assert_equal \ "ssh -F ~/.ssh/custom_config -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1'", new_command.follow_logs(host: "app-1") end test "execute in new container" do assert_match \ %r{docker run --rm --name app-web-exec-999-[0-9a-f]{6} --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size="10m" dhh/app:999 bin/rails db:setup}, new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ") end test "execute in new container with logging" do @config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } } assert_match \ %r{docker run --rm --name app-web-exec-999-[0-9a-f]{6} --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-driver "local" --log-opt max-size="100m" --log-opt max-file="3" dhh/app:999 bin/rails db:setup}, new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ") end test "execute in new container with env" do assert_match \ %r{docker run --rm --name app-web-exec-999-[0-9a-f]{6} --network kamal --env-file .kamal/apps/app/env/roles/web.env --env foo="bar" --log-opt max-size="10m" dhh/app:999 bin/rails db:setup}, new_command.execute_in_new_container("bin/rails", "db:setup", env: { "foo" => "bar" }).join(" ") end test "execute in new detached container" do assert_match \ %r{docker run --detach --name app-web-exec-999-[0-9a-f]{6} --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size="10m" dhh/app:999 bin/rails db:setup}, new_command.execute_in_new_container("bin/rails", "db:setup", detach: true, env: {}).join(" ") end test "execute in new container with tags" do @config[:servers] = [ { "1.1.1.1" => "tag1" } ] @config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } } assert_match \ %r{docker run --rm --name app-web-exec-999-[0-9a-f]{6} --network kamal --env ENV1="value1" --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size="10m" dhh/app:999 bin/rails db:setup}, new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ") end test "execute in new container with custom options" do @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere", "cap-add" => true } } } assert_match \ %r{docker run --rm --name app-web-exec-999-[0-9a-f]{6} --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size="10m" --mount "somewhere" --cap-add dhh/app:999 bin/rails db:setup}, new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ") end test "execute in existing container" do assert_equal \ "docker exec app-web-999 bin/rails db:setup", new_command.execute_in_existing_container("bin/rails", "db:setup", env: {}).join(" ") end test "execute in existing container with env" do assert_equal \ "docker exec --env foo=\"bar\" app-web-999 bin/rails db:setup", new_command.execute_in_existing_container("bin/rails", "db:setup", env: { "foo" => "bar" }).join(" ") end test "execute in new container over ssh" do assert_match %r{docker run -it --rm --name app-web-exec-999-[0-9a-f]{6} --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size="10m" dhh/app:999 bin/rails c}, stub_stdin_tty { new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {}) } end test "execute in new container over ssh with tags" do @config[:servers] = [ { "1.1.1.1" => "tag1" } ] @config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } } assert_match %r{ssh -t root@1.1.1.1 -p 22 'docker run -it --rm --name app-web-exec-999-[0-9a-f]{6} --network kamal --env ENV1="value1" --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size="10m" dhh/app:999 bin/rails c'}, stub_stdin_tty { new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {}) } end test "execute in new container with custom options over ssh" do @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere", "cap-add" => true } } } assert_match %r{docker run -it --rm --name app-web-exec-999-[0-9a-f]{6} --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" --mount \"somewhere\" --cap-add dhh/app:999 bin/rails c}, stub_stdin_tty { new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {}) } end test "execute in existing container over ssh" do assert_match %r{docker exec -it app-web-999 bin/rails c}, stub_stdin_tty { new_command.execute_in_existing_container_over_ssh("bin/rails", "c", env: {}) } end test "execute in existing container with piped input over ssh" do assert_match %r{docker exec -i app-web-999 bin/rails c}, stub_stdin_file { new_command.execute_in_existing_container_over_ssh("bin/rails", "c", env: {}) } end test "run over ssh" do assert_equal "ssh -t root@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") end test "run over ssh with custom user" do @config[:ssh] = { "user" => "app" } assert_equal "ssh -t app@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") end test "run over ssh with custom port" do @config[:ssh] = { "port" => "2222" } assert_equal "ssh -t root@1.1.1.1 -p 2222 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") end test "run over ssh with proxy" do @config[:ssh] = { "proxy" => "2.2.2.2" } assert_equal "ssh -J root@2.2.2.2 -t root@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") end test "run over ssh with proxy user" do @config[:ssh] = { "proxy" => "app@2.2.2.2" } assert_equal "ssh -J app@2.2.2.2 -t root@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") end test "run over ssh with custom user with proxy" do @config[:ssh] = { "user" => "app", "proxy" => "2.2.2.2" } assert_equal "ssh -J root@2.2.2.2 -t app@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") end test "run over ssh with keys config" do @config[:ssh] = { "keys" => [ "path_to_key.pem" ] } assert_equal "ssh -i path_to_key.pem -t root@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") end test "run over ssh with keys config with keys_only" do @config[:ssh] = { "keys" => [ "path_to_key.pem" ], "keys_only" => true } assert_equal "ssh -i path_to_key.pem -o IdentitiesOnly=yes -t root@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") end test "run over ssh with proxy_command" do @config[:ssh] = { "proxy_command" => "ssh -W %h:%p user@proxy-server" } assert_equal "ssh -o ProxyCommand='ssh -W %h:%p user@proxy-server' -t root@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") end test "run over ssh with config file" do @config[:ssh] = { "config" => "~/.ssh/custom_config" } assert_equal "ssh -F ~/.ssh/custom_config -t root@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") end test "run over ssh with multiple config files" do @config[:ssh] = { "config" => [ "~/.ssh/config1", "~/.ssh/config2" ] } assert_equal "ssh -F ~/.ssh/config1 -F ~/.ssh/config2 -t root@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") end test "run over ssh with config false" do @config[:ssh] = { "config" => false } assert_equal "ssh -F /dev/null -t root@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") end test "run over ssh with config true" do @config[:ssh] = { "config" => true } assert_equal "ssh -t root@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") end test "current_running_container_id" do assert_equal \ "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1", new_command.current_running_container_id.join(" ") end test "current_running_container_id with destination" do @destination = "staging" assert_equal \ "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination=staging --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest-staging --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination=staging --filter label=role=web --filter status=running --filter status=restarting' | head -1", new_command.current_running_container_id.join(" ") end test "container_id_for" do assert_equal \ "docker container ls --all --filter 'name=^app-999$' --quiet", new_command.container_id_for(container_name: "app-999").join(" ") end test "current_running_version" do assert_equal \ "sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done", new_command.current_running_version.join(" ") end test "list_versions" do assert_equal \ "docker ps --filter label=service=app --filter label=destination= --filter label=role=web --format \"{{.Names}}\" | while read line; do echo ${line#app-web-}; done", new_command.list_versions.join(" ") assert_equal \ "docker ps --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --latest --format \"{{.Names}}\" | while read line; do echo ${line#app-web-}; done", new_command.list_versions("--latest", statuses: [ :running, :restarting ]).join(" ") end test "list_containers" do assert_equal \ "docker container ls --all --filter label=service=app --filter label=destination= --filter label=role=web", new_command.list_containers.join(" ") end test "list_containers with destination" do @destination = "staging" assert_equal \ "docker container ls --all --filter label=service=app --filter label=destination=staging --filter label=role=web", new_command.list_containers.join(" ") end test "list_container_names" do assert_equal \ "docker container ls --all --filter label=service=app --filter label=destination= --filter label=role=web --format '{{ .Names }}'", new_command.list_container_names.join(" ") end test "remove_container" do assert_equal \ "docker container ls --all --filter 'name=^app-web-999$' --quiet | xargs docker container rm", new_command.remove_container(version: "999").join(" ") end test "remove_container with destination" do @destination = "staging" assert_equal \ "docker container ls --all --filter 'name=^app-web-staging-999$' --quiet | xargs docker container rm", new_command.remove_container(version: "999").join(" ") end test "remove_containers" do assert_equal \ "docker container prune --force --filter label=service=app --filter label=destination= --filter label=role=web", new_command.remove_containers.join(" ") end test "remove_containers with destination" do @destination = "staging" assert_equal \ "docker container prune --force --filter label=service=app --filter label=destination=staging --filter label=role=web", new_command.remove_containers.join(" ") end test "list_images" do assert_equal \ "docker image ls dhh/app", new_command.list_images.join(" ") end test "remove_images" do assert_equal \ "docker image prune --all --force --filter label=service=app", new_command.remove_images.join(" ") end test "remove_images with destination" do @destination = "staging" assert_equal \ "docker image prune --all --force --filter label=service=app", new_command.remove_images.join(" ") end test "tag_latest_image" do assert_equal \ "docker tag dhh/app:999 dhh/app:latest", new_command.tag_latest_image.join(" ") end test "tag_latest_image with destination" do @destination = "staging" assert_equal \ "docker tag dhh/app:999 dhh/app:latest-staging", new_command.tag_latest_image.join(" ") end test "extract assets" do assert_equal [ :mkdir, "-p", ".kamal/apps/app/assets/extracted/web-999", "&&", :docker, :container, :rm, "app-web-assets", "2> /dev/null", "|| true", "&&", :docker, :container, :create, "--name", "app-web-assets", "dhh/app:999", "&&", :docker, :container, :cp, "-L", "app-web-assets:/public/assets/.", ".kamal/apps/app/assets/extracted/web-999", "&&", :docker, :container, :rm, "app-web-assets" ], new_command(asset_path: "/public/assets").extract_assets end test "sync asset volumes" do assert_equal [ :mkdir, "-p", ".kamal/apps/app/assets/volumes/web-999", ";", :cp, "-rnT", ".kamal/apps/app/assets/extracted/web-999", ".kamal/apps/app/assets/volumes/web-999" ], new_command(asset_path: "/public/assets").sync_asset_volumes assert_equal [ :mkdir, "-p", ".kamal/apps/app/assets/volumes/web-999", ";", :cp, "-rnT", ".kamal/apps/app/assets/extracted/web-999", ".kamal/apps/app/assets/volumes/web-999", ";", :cp, "-rnT", ".kamal/apps/app/assets/extracted/web-999", ".kamal/apps/app/assets/volumes/web-998", "|| true", ";", :cp, "-rnT", ".kamal/apps/app/assets/extracted/web-998", ".kamal/apps/app/assets/volumes/web-999", "|| true" ], new_command(asset_path: "/public/assets").sync_asset_volumes(old_version: 998) end test "clean up assets" do assert_equal [ :find, ".kamal/apps/app/assets/extracted", "-maxdepth 1", "-name", "'web-*'", "!", "-name", "web-999", "-exec rm -rf \"{}\" +", ";", :find, ".kamal/apps/app/assets/volumes", "-maxdepth 1", "-name", "'web-*'", "!", "-name", "web-999", "-exec rm -rf \"{}\" +" ], new_command(asset_path: "/public/assets").clean_up_assets end test "live" do assert_equal \ "docker exec kamal-proxy kamal-proxy resume app-web", new_command.live.join(" ") end test "maintenance" do assert_equal \ "docker exec kamal-proxy kamal-proxy stop app-web", new_command.maintenance.join(" ") end test "maintenance with options" do assert_equal \ "docker exec kamal-proxy kamal-proxy stop app-web --drain-timeout=\"10s\" --message=\"Hi\"", new_command.maintenance(drain_timeout: 10, message: "Hi").join(" ") end test "remove_proxy_app_directory" do assert_equal \ "rm -r .kamal/proxy/apps-config/app", new_command.remove_proxy_app_directory.join(" ") end private def new_command(role: "web", host: "1.1.1.1", **additional_config) config = Kamal::Configuration.new(@config.merge(additional_config), destination: @destination, version: "999") Kamal::Commands::App.new(config, role: config.role(role), host: host) end end ================================================ FILE: test/commands/auditor_test.rb ================================================ require "test_helper" require "active_support/testing/time_helpers" class CommandsAuditorTest < ActiveSupport::TestCase include ActiveSupport::Testing::TimeHelpers setup do freeze_time @config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, builder: { "arch" => "amd64" }, servers: [ "1.1.1.1" ] } @auditor = new_command @performer = Kamal::Git.email.presence || `whoami`.chomp @recorded_at = Time.now.utc.iso8601 end test "record" do assert_equal [ :mkdir, "-p", ".kamal", "&&", :echo, "\"[#{@recorded_at}] [#{@performer}] app removed container\"", ">>", ".kamal/app-audit.log" ], @auditor.record("app removed container") end test "record with destination" do new_command(destination: "staging").tap do |auditor| assert_equal [ :mkdir, "-p", ".kamal", "&&", :echo, "\"[#{@recorded_at}] [#{@performer}] [staging] app removed container\"", ">>", ".kamal/app-staging-audit.log" ], auditor.record("app removed container") end end test "record with command details" do new_command(role: "web").tap do |auditor| assert_equal [ :mkdir, "-p", ".kamal", "&&", :echo, "\"[#{@recorded_at}] [#{@performer}] [web] app removed container\"", ">>", ".kamal/app-audit.log" ], auditor.record("app removed container") end end test "record with arg details" do assert_equal [ :mkdir, "-p", ".kamal", "&&", :echo, "\"[#{@recorded_at}] [#{@performer}] [value] app removed container\"", ">>", ".kamal/app-audit.log" ], @auditor.record("app removed container", detail: "value") end private def new_command(destination: nil, **details) Kamal::Commands::Auditor.new(Kamal::Configuration.new(@config, destination: destination, version: "123"), **details) end end ================================================ FILE: test/commands/builder_test.rb ================================================ require "test_helper" class CommandsBuilderTest < ActiveSupport::TestCase setup do @config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], builder: { "arch" => "amd64" } } end test "target linux/amd64 locally by default" do builder = new_builder_command(builder: { "cache" => { "type" => "gha" } }) assert_equal "local", builder.name assert_equal \ "docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile . 2>&1", builder.push.join(" ") end test "target specified arch locally by default" do builder = new_builder_command(builder: { "arch" => [ "amd64" ] }) assert_equal "local", builder.name assert_equal \ "docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile . 2>&1", builder.push.join(" ") end test "build with caching" do builder = new_builder_command(builder: { "cache" => { "type" => "gha" } }) assert_equal "local", builder.name assert_equal \ "docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile . 2>&1", builder.push.join(" ") end test "hybrid build if remote is set and building multiarch" do builder = new_builder_command(builder: { "arch" => [ "amd64", "arm64" ], "remote" => "ssh://app@127.0.0.1", "cache" => { "type" => "gha" } }) assert_equal "hybrid", builder.name assert_equal \ "docker buildx build --output=type=registry --platform linux/amd64,linux/arm64 --builder kamal-hybrid-docker-container-ssh---app-127-0-0-1 -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile . 2>&1", builder.push.join(" ") end test "remote build if remote is set and local disabled" do builder = new_builder_command(builder: { "arch" => [ "amd64", "arm64" ], "remote" => "ssh://app@127.0.0.1", "cache" => { "type" => "gha" }, "local" => false }) assert_equal "remote", builder.name assert_equal \ "docker buildx build --output=type=registry --platform linux/amd64,linux/arm64 --builder kamal-remote-ssh---app-127-0-0-1 -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile . 2>&1", builder.push.join(" ") end test "target remote when remote set and arch is non local" do builder = new_builder_command(builder: { "arch" => [ "#{remote_arch}" ], "remote" => "ssh://app@host", "cache" => { "type" => "gha" } }) assert_equal "remote", builder.name assert_equal \ "docker buildx build --output=type=registry --platform linux/#{remote_arch} --builder kamal-remote-ssh---app-host -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile . 2>&1", builder.push.join(" ") end test "target local when remote set and arch is local" do builder = new_builder_command(builder: { "arch" => [ "#{local_arch}" ], "remote" => "ssh://app@host", "cache" => { "type" => "gha" } }) assert_equal "local", builder.name assert_equal \ "docker buildx build --output=type=registry --platform linux/#{local_arch} --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --cache-to type=gha --cache-from type=gha --label service=\"app\" --file Dockerfile . 2>&1", builder.push.join(" ") end test "target pack when pack is set" do builder = new_builder_command(image: "dhh/app", builder: { "arch" => "amd64", "pack" => { "builder" => "heroku/builder:24", "buildpacks" => [ "heroku/ruby", "heroku/procfile" ] } }) assert_equal "pack", builder.name assert_equal \ "pack build dhh/app --platform linux/amd64 --creation-time now --builder heroku/builder:24 --buildpack heroku/ruby --buildpack heroku/procfile --buildpack paketo-buildpacks/image-labels -t dhh/app:123 -t dhh/app:latest --env BP_IMAGE_LABELS=service=app --path . && docker push dhh/app:123 && docker push dhh/app:latest", builder.push.join(" ") end test "pack build args passed as env" do builder = new_builder_command(image: "dhh/app", builder: { "args" => { "a" => 1, "b" => 2 }, "arch" => "amd64", "pack" => { "builder" => "heroku/builder:24", "buildpacks" => [ "heroku/ruby", "heroku/procfile" ] } }) assert_equal \ "pack build dhh/app --platform linux/amd64 --creation-time now --builder heroku/builder:24 --buildpack heroku/ruby --buildpack heroku/procfile --buildpack paketo-buildpacks/image-labels -t dhh/app:123 -t dhh/app:latest --env BP_IMAGE_LABELS=service=app --env a=\"1\" --env b=\"2\" --path . && docker push dhh/app:123 && docker push dhh/app:latest", builder.push.join(" ") end test "pack build with no cache" do builder = new_builder_command(image: "dhh/app", builder: { "args" => { "a" => 1, "b" => 2 }, "arch" => "amd64", "pack" => { "builder" => "heroku/builder:24", "buildpacks" => [ "heroku/ruby", "heroku/procfile" ] } }) assert_equal \ "pack build dhh/app --platform linux/amd64 --creation-time now --builder heroku/builder:24 --buildpack heroku/ruby --buildpack heroku/procfile --buildpack paketo-buildpacks/image-labels -t dhh/app:123 -t dhh/app:latest --clear-cache --env BP_IMAGE_LABELS=service=app --env a=\"1\" --env b=\"2\" --path . && docker push dhh/app:123 && docker push dhh/app:latest", builder.push("registry", no_cache: true).join(" ") end test "pack build secrets as env" do with_test_secrets("secrets" => "token_a=foo\ntoken_b=bar") do builder = new_builder_command(image: "dhh/app", builder: { "secrets" => [ "token_a", "token_b" ], "arch" => "amd64", "pack" => { "builder" => "heroku/builder:24", "buildpacks" => [ "heroku/ruby", "heroku/procfile" ] } }) assert_equal \ "pack build dhh/app --platform linux/amd64 --creation-time now --builder heroku/builder:24 --buildpack heroku/ruby --buildpack heroku/procfile --buildpack paketo-buildpacks/image-labels -t dhh/app:123 -t dhh/app:latest --env BP_IMAGE_LABELS=service=app --env token_a=\"foo\" --env token_b=\"bar\" --path . && docker push dhh/app:123 && docker push dhh/app:latest", builder.push.join(" ") end end test "cloud builder" do builder = new_builder_command(builder: { "arch" => [ "#{local_arch}" ], "driver" => "cloud docker-org-name/builder-name" }) assert_equal "cloud", builder.name assert_equal \ "docker buildx build --output=type=registry --platform linux/#{local_arch} --builder cloud-docker-org-name-builder-name -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile . 2>&1", builder.push.join(" ") end test "build args" do builder = new_builder_command(builder: { "args" => { "a" => 1, "b" => 2 } }) assert_equal \ "--label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" --file Dockerfile", builder.target.build_options.join(" ") end test "build secrets" do with_test_secrets("secrets" => "token_a=foo\ntoken_b=bar") do FileUtils.touch("Dockerfile") builder = new_builder_command(builder: { "secrets" => [ "token_a", "token_b" ] }) assert_equal \ "--label service=\"app\" --secret id=\"token_a\" --secret id=\"token_b\" --file Dockerfile", builder.target.build_options.join(" ") end end test "build dockerfile" do Pathname.any_instance.expects(:exist?).returns(true).once builder = new_builder_command(builder: { "dockerfile" => "Dockerfile.xyz" }) assert_equal \ "--label service=\"app\" --file Dockerfile.xyz", builder.target.build_options.join(" ") end test "missing dockerfile" do Pathname.any_instance.expects(:exist?).returns(false).once builder = new_builder_command(builder: { "dockerfile" => "Dockerfile.xyz" }) assert_raises(Kamal::Commands::Builder::Base::BuilderError) do builder.target.build_options.join(" ") end end test "build target" do builder = new_builder_command(builder: { "target" => "prod" }) assert_equal \ "--label service=\"app\" --file Dockerfile --target prod", builder.target.build_options.join(" ") end test "build context" do builder = new_builder_command(builder: { "context" => ".." }) assert_equal \ "docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile .. 2>&1", builder.push.join(" ") end test "push with build args" do builder = new_builder_command(builder: { "args" => { "a" => 1, "b" => 2 } }) assert_equal \ "docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" --file Dockerfile . 2>&1", builder.push.join(" ") end test "push with build secrets" do with_test_secrets("secrets" => "a=foo\nb=bar") do FileUtils.touch("Dockerfile") builder = new_builder_command(builder: { "secrets" => [ "a", "b" ] }) assert_equal \ "docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --secret id=\"a\" --secret id=\"b\" --file Dockerfile . 2>&1", builder.push.join(" ") end end test "build with ssh agent socket" do builder = new_builder_command(builder: { "ssh" => "default=$SSH_AUTH_SOCK" }) assert_equal \ "--label service=\"app\" --file Dockerfile --ssh default=$SSH_AUTH_SOCK", builder.target.build_options.join(" ") end test "validate image" do assert_equal "docker inspect -f '{{ .Config.Labels.service }}' dhh/app:123 | grep -x app || (echo \"Image dhh/app:123 is missing the 'service' label\" && exit 1)", new_builder_command.validate_image.join(" ") end test "context build" do builder = new_builder_command(builder: { "context" => "./foo" }) assert_equal \ "docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile ./foo 2>&1", builder.push.join(" ") end test "push with provenance" do builder = new_builder_command(builder: { "provenance" => "mode=max" }) assert_equal \ "docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --provenance mode=max . 2>&1", builder.push.join(" ") end test "push with provenance false" do builder = new_builder_command(builder: { "provenance" => false }) assert_equal \ "docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --provenance false . 2>&1", builder.push.join(" ") end test "push with sbom" do builder = new_builder_command(builder: { "sbom" => true }) assert_equal \ "docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --sbom true . 2>&1", builder.push.join(" ") end test "push with sbom false" do builder = new_builder_command(builder: { "sbom" => false }) assert_equal \ "docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --sbom false . 2>&1", builder.push.join(" ") end test "mirror count" do command = new_builder_command assert_equal "docker info --format '{{index .RegistryConfig.Mirrors 0}}'", command.first_mirror.join(" ") end test "push with no cache" do builder = new_builder_command assert_equal \ "docker buildx build --output=type=registry --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --no-cache . 2>&1", builder.push("registry", no_cache: true).join(" ") end test "clone path with spaces" do command = new_builder_command Kamal::Git.stubs(:root).returns("/absolute/path with spaces") clone_command = command.clone.join(" ") clone_reset_commands = command.clone_reset_steps.map { |a| a.join(" ") } assert_match(%r{path\\ with\\ space}, clone_command) assert_no_match(%r{path with spaces}, clone_command) clone_reset_commands.each do |command| assert_match(%r{path\\ with\\ space}, command) assert_no_match(%r{path with spaces}, command) end end test "local builder with local registry includes network host driver option" do builder = new_builder_command(registry: { "server" => "localhost:5000" }) assert_equal "local", builder.name assert_equal \ "docker buildx create --name kamal-local-registry-docker-container --driver=docker-container --driver-opt network=host", builder.create.join(" ") end test "remote builder with local registry" do builder = new_builder_command( registry: { "server" => "localhost:5000" }, builder: { "arch" => remote_arch, "remote" => "ssh://app@1.1.1.5" } ) assert_equal "remote", builder.name assert_equal \ "docker context create kamal-remote-ssh---app-1-1-1-5-local-registry-context --description 'kamal-remote-ssh---app-1-1-1-5-local-registry host' --docker 'host=ssh://app@1.1.1.5' ; docker buildx create --name kamal-remote-ssh---app-1-1-1-5-local-registry --driver-opt network=host kamal-remote-ssh---app-1-1-1-5-local-registry-context", builder.create.join(" ") end test "hybrid builder with local registry" do builder = new_builder_command( registry: { "server" => "localhost:5000" }, builder: { "arch" => [ "amd64", "arm64" ], "remote" => "ssh://app@1.1.1.5" } ) assert_equal "hybrid", builder.name assert_equal \ "docker buildx create --platform linux/amd64 --name kamal-hybrid-docker-container-ssh---app-1-1-1-5-local-registry --driver=docker-container --driver-opt network=host && docker context create kamal-hybrid-docker-container-ssh---app-1-1-1-5-local-registry-context --description 'kamal-hybrid-docker-container-ssh---app-1-1-1-5-local-registry host' --docker 'host=ssh://app@1.1.1.5' && docker buildx create --platform linux/arm64 --append --name kamal-hybrid-docker-container-ssh---app-1-1-1-5-local-registry --driver-opt network=host kamal-hybrid-docker-container-ssh---app-1-1-1-5-local-registry-context", builder.create.join(" ") end private def new_builder_command(additional_config = {}) Kamal::Configuration.new(@config.deep_merge(additional_config), version: "123").then do |config| KAMAL.reset KAMAL.stubs(:config).returns(config) Kamal::Commands::Builder.new(config) end end def local_arch Kamal::Utils.docker_arch end def remote_arch Kamal::Utils.docker_arch == "arm64" ? "amd64" : "arm64" end end ================================================ FILE: test/commands/docker_test.rb ================================================ require "test_helper" class CommandsDockerTest < ActiveSupport::TestCase setup do @config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], builder: { "arch" => "amd64" } } @docker = Kamal::Commands::Docker.new(Kamal::Configuration.new(@config)) end test "install" do assert_equal "sh -c 'curl -fsSL https://get.docker.com || wget -O - https://get.docker.com || echo \"exit 1\"' | sh", @docker.install.join(" ") end test "installed?" do assert_equal "docker -v", @docker.installed?.join(" ") end test "running?" do assert_equal "docker version", @docker.running?.join(" ") end test "superuser?" do assert_equal '[ "${EUID:-$(id -u)}" -eq 0 ] || sudo -nl usermod >/dev/null', @docker.superuser?.join(" ") end test "root?" do assert_equal '[ "${EUID:-$(id -u)}" -eq 0 ]', @docker.root?.join(" ") end test "in_docker_group?" do assert_equal 'id -nG "${USER:-$(id -un)}" | grep -qw docker', @docker.in_docker_group?.join(" ") end test "add_to_docker_group" do assert_equal 'sudo -n usermod -aG docker "${USER:-$(id -un)}"', @docker.add_to_docker_group.join(" ") end test "refresh_session" do assert_equal "kill -HUP $PPID", @docker.refresh_session.join(" ") end end ================================================ FILE: test/commands/hook_test.rb ================================================ require "test_helper" class CommandsHookTest < ActiveSupport::TestCase include ActiveSupport::Testing::TimeHelpers setup do freeze_time @config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], builder: { "arch" => "amd64" } } @performer = Kamal::Git.email.presence || `whoami`.chomp @recorded_at = Time.now.utc.iso8601 end test "run" do assert_equal [ ".kamal/hooks/foo" ], new_command.run("foo") end test "env" do assert_equal ({ "KAMAL_RECORDED_AT" => @recorded_at, "KAMAL_PERFORMER" => @performer, "KAMAL_VERSION" => "123", "KAMAL_SERVICE_VERSION" => "app@123", "KAMAL_SERVICE" => "app" }), new_command.env end test "run with custom hooks_path" do assert_equal [ "custom/hooks/path/foo" ], new_command(hooks_path: "custom/hooks/path").run("foo") end test "env with secrets" do with_test_secrets("secrets" => "DB_PASSWORD=secret") do assert_equal ( { "KAMAL_RECORDED_AT" => @recorded_at, "KAMAL_PERFORMER" => @performer, "KAMAL_VERSION" => "123", "KAMAL_SERVICE_VERSION" => "app@123", "KAMAL_SERVICE" => "app", "DB_PASSWORD" => "secret" } ), new_command.env(secrets: true) end end private def new_command(**extra_config) Kamal::Commands::Hook.new(Kamal::Configuration.new(@config.merge(**extra_config), version: "123")) end end ================================================ FILE: test/commands/lock_test.rb ================================================ require "test_helper" class CommandsLockTest < ActiveSupport::TestCase setup do @config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], builder: { "arch" => "amd64" } } end test "status" do assert_equal \ "stat .kamal/lock-app-production > /dev/null && cat .kamal/lock-app-production/details | base64 -d", new_command.status.join(" ") end test "acquire" do assert_match \ %r{mkdir \.kamal/lock-app-production && echo ".*" > \.kamal/lock-app-production/details}m, new_command.acquire("Hello", "123").join(" ") end test "release" do assert_match \ "rm .kamal/lock-app-production/details && rm -r .kamal/lock-app-production", new_command.release.join(" ") end private def new_command Kamal::Commands::Lock.new(Kamal::Configuration.new(@config, version: "123", destination: "production")) end end ================================================ FILE: test/commands/proxy_test.rb ================================================ require "test_helper" class CommandsProxyTest < ActiveSupport::TestCase setup do @config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], builder: { "arch" => "amd64" } } ENV["EXAMPLE_API_KEY"] = "456" end teardown do ENV.delete("EXAMPLE_API_KEY") end test "run" do assert_equal \ "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::Proxy::Run::MINIMUM_VERSION}\") $(cat .kamal/proxy/run_command 2> /dev/null || echo \"\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $PWD/.kamal/proxy/apps-config:/home/kamal-proxy/.apps-config", new_command.run.join(" ") end test "run without configuration" do @config.delete(:proxy) assert_equal \ "echo $(cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\") $(cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"):$(cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::Proxy::Run::MINIMUM_VERSION}\") $(cat .kamal/proxy/run_command 2> /dev/null || echo \"\") | xargs docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $PWD/.kamal/proxy/apps-config:/home/kamal-proxy/.apps-config", new_command.run.join(" ") end test "proxy start" do assert_equal \ "docker container start kamal-proxy", new_command.start.join(" ") end test "proxy stop" do assert_equal \ "docker container stop kamal-proxy", new_command.stop.join(" ") end test "proxy info" do assert_equal \ "docker ps --filter 'name=^kamal-proxy$'", new_command.info.join(" ") end test "proxy logs" do assert_equal \ "docker logs kamal-proxy --timestamps 2>&1", new_command.logs.join(" ") end test "proxy logs since 2h" do assert_equal \ "docker logs kamal-proxy --since 2h --timestamps 2>&1", new_command.logs(since: "2h").join(" ") end test "proxy logs last 10 lines" do assert_equal \ "docker logs kamal-proxy --tail 10 --timestamps 2>&1", new_command.logs(lines: 10).join(" ") end test "proxy logs without timestamps" do assert_equal \ "docker logs kamal-proxy 2>&1", new_command.logs(timestamps: false).join(" ") end test "proxy logs with grep hello!" do assert_equal \ "docker logs kamal-proxy --timestamps 2>&1 | grep 'hello!'", new_command.logs(grep: "hello!").join(" ") end test "proxy remove container" do assert_equal \ "docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy", new_command.remove_container.join(" ") end test "proxy remove image" do assert_equal \ "docker image prune --all --force --filter label=org.opencontainers.image.title=kamal-proxy", new_command.remove_image.join(" ") end test "proxy follow logs" do assert_equal \ "ssh -t root@1.1.1.1 -p 22 'docker logs kamal-proxy --timestamps --tail 10 --follow 2>&1'", new_command.follow_logs(host: @config[:servers].first) end test "proxy follow logs with grep hello!" do assert_equal \ "ssh -t root@1.1.1.1 -p 22 'docker logs kamal-proxy --timestamps --tail 10 --follow 2>&1 | grep \"hello!\"'", new_command.follow_logs(host: @config[:servers].first, grep: "hello!") end test "version" do assert_equal \ "docker inspect kamal-proxy --format '{{.Config.Image}}' | awk -F: '{print $NF}'", new_command.version.join(" ") end test "ensure_proxy_directory" do assert_equal \ "mkdir -p .kamal/proxy", new_command.ensure_proxy_directory.join(" ") end test "read_boot_options" do assert_equal \ "cat .kamal/proxy/options 2> /dev/null || echo \"--publish 80:80 --publish 443:443 --log-opt max-size=10m\"", new_command.read_boot_options.join(" ") end test "read_image" do assert_equal \ "cat .kamal/proxy/image 2> /dev/null || echo \"basecamp/kamal-proxy\"", new_command.read_image.join(" ") end test "read_image_version" do assert_equal \ "cat .kamal/proxy/image_version 2> /dev/null || echo \"#{Kamal::Configuration::Proxy::Run::MINIMUM_VERSION}\"", new_command.read_image_version.join(" ") end test "read_run_command" do assert_equal \ "cat .kamal/proxy/run_command 2> /dev/null || echo \"\"", new_command.read_run_command.join(" ") end test "reset_boot_options" do assert_equal \ "rm .kamal/proxy/options", new_command.reset_boot_options.join(" ") end test "reset_image" do assert_equal \ "rm .kamal/proxy/image", new_command.reset_image.join(" ") end test "reset_image_version" do assert_equal \ "rm .kamal/proxy/image_version", new_command.reset_image_version.join(" ") end test "ensure_apps_config_directory" do assert_equal \ "mkdir -p .kamal/proxy/apps-config", new_command.ensure_apps_config_directory.join(" ") end test "reset_run_command" do assert_equal \ "rm .kamal/proxy/run_command", new_command.reset_run_command.join(" ") end test "registry run config" do @config[:proxy] = { "run" => { "registry" => "registry:4443" } } assert_equal \ "docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $PWD/.kamal/proxy/apps-config:/home/kamal-proxy/.apps-config --publish 80:80 --publish 443:443 --log-opt max-size=10m registry:4443/basecamp/kamal-proxy:v0.9.2 kamal-proxy run", new_command.run.join(" ") end test "repository run config" do @config[:proxy] = { "run" => { "repository" => "custom/repo" } } assert_equal \ "docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $PWD/.kamal/proxy/apps-config:/home/kamal-proxy/.apps-config --publish 80:80 --publish 443:443 --log-opt max-size=10m custom/repo:v0.9.2 kamal-proxy run", new_command.run.join(" ") end test "image_version run config" do @config[:proxy] = { "run" => { "version" => "v1.2.3" } } assert_equal \ "docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $PWD/.kamal/proxy/apps-config:/home/kamal-proxy/.apps-config --publish 80:80 --publish 443:443 --log-opt max-size=10m basecamp/kamal-proxy:v1.2.3 kamal-proxy run", new_command.run.join(" ") end test "bind_ips run config" do @config[:proxy] = { "run" => { "bind_ips" => [ "0.0.0.0", "127.0.0.1" ] } } assert_equal \ "docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $PWD/.kamal/proxy/apps-config:/home/kamal-proxy/.apps-config --publish 0.0.0.0:80:80 --publish 0.0.0.0:443:443 --publish 127.0.0.1:80:80 --publish 127.0.0.1:443:443 --log-opt max-size=10m basecamp/kamal-proxy:v0.9.2 kamal-proxy run", new_command.run.join(" ") end test "log_max_size run config" do @config[:proxy] = { "run" => { "log_max_size" => "50m" } } assert_equal \ "docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $PWD/.kamal/proxy/apps-config:/home/kamal-proxy/.apps-config --publish 80:80 --publish 443:443 --log-opt max-size=50m basecamp/kamal-proxy:v0.9.2 kamal-proxy run", new_command.run.join(" ") end test "debug run config" do @config[:proxy] = { "run" => { "debug" => true } } assert_equal \ "docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $PWD/.kamal/proxy/apps-config:/home/kamal-proxy/.apps-config --publish 80:80 --publish 443:443 --log-opt max-size=10m basecamp/kamal-proxy:v0.9.2 kamal-proxy run --debug", new_command.run.join(" ") end test "metrics_port run config" do @config[:proxy] = { "run" => { "metrics_port" => 9090 } } assert_equal \ "docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $PWD/.kamal/proxy/apps-config:/home/kamal-proxy/.apps-config --publish 80:80 --publish 443:443 --log-opt max-size=10m --expose=9090 basecamp/kamal-proxy:v0.9.2 kamal-proxy run --metrics-port \"9090\"", new_command.run.join(" ") end test "don't publish run config" do @config[:proxy] = { "run" => { "publish" => false } } assert_equal \ "docker run --name kamal-proxy --network kamal --detach --restart unless-stopped --volume kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy --volume $PWD/.kamal/proxy/apps-config:/home/kamal-proxy/.apps-config --log-opt max-size=10m basecamp/kamal-proxy:v0.9.2 kamal-proxy run", new_command.run.join(" ") end private def new_command Kamal::Commands::Proxy.new(Kamal::Configuration.new(@config, version: "123"), host: "1.1.1.1") end end ================================================ FILE: test/commands/prune_test.rb ================================================ require "test_helper" class CommandsPruneTest < ActiveSupport::TestCase setup do @config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], builder: { "arch" => "amd64" } } end test "dangling images" do assert_equal \ "docker image prune --force --filter label=service=app", new_command.dangling_images.join(" ") end test "tagged images" do assert_equal \ "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", new_command.tagged_images.join(" ") end test "app containers" do assert_equal \ "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", new_command.app_containers(retain: 5).join(" ") assert_equal \ "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", new_command.app_containers(retain: 3).join(" ") end private def new_command Kamal::Commands::Prune.new(Kamal::Configuration.new(@config, version: "123")) end end ================================================ FILE: test/commands/registry_test.rb ================================================ require "test_helper" class CommandsRegistryTest < ActiveSupport::TestCase setup do @config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret", "server" => "hub.docker.com" }, builder: { "arch" => "amd64" }, servers: [ "1.1.1.1" ], accessories: { "db" => { "image" => "mysql:8.0", "hosts" => [ "1.1.1.1" ], "registry" => { "username" => "user", "password" => "pw", "server" => "other.hub.docker.com" } } } } end test "registry login" do assert_equal \ "docker login hub.docker.com -u \"dhh\" -p \"secret\"", registry.login.join(" ") end test "given registry login" do assert_equal \ "docker login other.hub.docker.com -u \"user\" -p \"pw\"", registry.login(registry_config: accessory_registry_config).join(" ") end test "registry login with ENV password" do with_test_secrets("secrets" => "KAMAL_REGISTRY_PASSWORD=more-secret\nKAMAL_MYSQL_REGISTRY_PASSWORD=secret-pw") do @config[:registry]["password"] = [ "KAMAL_REGISTRY_PASSWORD" ] @config[:accessories]["db"]["registry"]["password"] = [ "KAMAL_MYSQL_REGISTRY_PASSWORD" ] assert_equal \ "docker login hub.docker.com -u \"dhh\" -p \"more-secret\"", registry.login.join(" ") assert_equal \ "docker login other.hub.docker.com -u \"user\" -p \"secret-pw\"", registry.login(registry_config: accessory_registry_config).join(" ") end end test "registry login escape password" do with_test_secrets("secrets" => "KAMAL_REGISTRY_PASSWORD=more-secret'\"") do @config[:registry]["password"] = [ "KAMAL_REGISTRY_PASSWORD" ] assert_equal \ "docker login hub.docker.com -u \"dhh\" -p \"more-secret'\\\"\"", registry.login.join(" ") end end test "registry login with ENV username" do with_test_secrets("secrets" => "KAMAL_REGISTRY_USERNAME=also-secret") do @config[:registry]["username"] = [ "KAMAL_REGISTRY_USERNAME" ] assert_equal \ "docker login hub.docker.com -u \"also-secret\" -p \"secret\"", registry.login.join(" ") end end test "registry logout" do assert_equal \ "docker logout hub.docker.com", registry.logout.join(" ") end test "given registry logout" do assert_equal \ "docker logout other.hub.docker.com", registry.logout(registry_config: accessory_registry_config).join(" ") end test "registry setup" do @config[:registry] = { "server" => "localhost:5000" } assert_equal "docker start kamal-docker-registry || docker run --detach -p 127.0.0.1:5000:5000 --name kamal-docker-registry registry:3", registry.setup.join(" ") end test "registry remove" do assert_equal "docker stop kamal-docker-registry && docker rm kamal-docker-registry", registry.remove.join(" ") end private def registry Kamal::Commands::Registry.new main_config end def main_config Kamal::Configuration.new(@config) end def accessory_registry_config main_config.accessory("db").registry end end ================================================ FILE: test/commands/server_test.rb ================================================ require "test_helper" class CommandsServerTest < ActiveSupport::TestCase setup do @config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], builder: { "arch" => "amd64" } } end test "ensure run directory" do assert_equal "mkdir -p .kamal", new_command.ensure_run_directory.join(" ") end private def new_command(extra_config = {}) Kamal::Commands::Server.new(Kamal::Configuration.new(@config.merge(extra_config))) end end ================================================ FILE: test/configuration/accessory_test.rb ================================================ require "test_helper" class ConfigurationAccessoryTest < ActiveSupport::TestCase setup do @deploy = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: { "web" => [ { "1.1.1.1" => "writer" }, { "1.1.1.2" => "reader" } ], "workers" => [ { "1.1.1.3" => "writer" }, "1.1.1.4" ] }, builder: { "arch" => "amd64" }, env: { "REDIS_URL" => "redis://x/y" }, accessories: { "mysql" => { "image" => "public.registry/mysql:8.0", "host" => "1.1.1.5", "port" => "3306", "env" => { "clear" => { "MYSQL_ROOT_HOST" => "%" }, "secret" => [ "MYSQL_ROOT_PASSWORD" ] }, "files" => [ "config/mysql/my.cnf:/etc/mysql/my.cnf", "db/structure.sql:/docker-entrypoint-initdb.d/structure.sql" ], "directories" => [ "data:/var/lib/mysql" ] }, "redis" => { "image" => "redis:latest", "hosts" => [ "1.1.1.6", "1.1.1.7" ], "port" => "6379:6379", "labels" => { "cache" => "true" }, "env" => { "SOMETHING" => "else" }, "volumes" => [ "/var/lib/redis:/data" ], "options" => { "cpus" => "4", "memory" => "2GB" } }, "monitoring" => { "service" => "custom-monitoring", "image" => "monitoring:latest", "registry" => { "server" => "other.registry", "username" => "user", "password" => "pw" }, "role" => "web", "port" => "4321:4321", "labels" => { "cache" => "true" }, "env" => { "STATSD_PORT" => "8126" }, "options" => { "cpus" => "4", "memory" => "2GB" }, "proxy" => { "host" => "monitoring.example.com" } }, "proxy" => { "image" => "proxy:latest", "tags" => [ "writer", "reader" ] }, "logger" => { "image" => "logger:latest", "tag" => "writer" } } } @config = Kamal::Configuration.new(@deploy) end test "service name" do assert_equal "app-mysql", @config.accessory(:mysql).service_name assert_equal "app-redis", @config.accessory(:redis).service_name assert_equal "custom-monitoring", @config.accessory(:monitoring).service_name end test "image" do assert_equal "public.registry/mysql:8.0", @config.accessory(:mysql).image assert_equal "redis:latest", @config.accessory(:redis).image assert_equal "other.registry/monitoring:latest", @config.accessory(:monitoring).image end test "registry" do assert_nil @config.accessory(:mysql).registry assert_nil @config.accessory(:redis).registry monitoring_registry = @config.accessory(:monitoring).registry assert_equal "other.registry", monitoring_registry.server assert_equal "user", monitoring_registry.username assert_equal "pw", monitoring_registry.password end test "port" do assert_equal "3306:3306", @config.accessory(:mysql).port assert_equal "6379:6379", @config.accessory(:redis).port end test "host" do assert_equal [ "1.1.1.5" ], @config.accessory(:mysql).hosts assert_equal [ "1.1.1.6", "1.1.1.7" ], @config.accessory(:redis).hosts assert_equal [ "1.1.1.1", "1.1.1.2" ], @config.accessory(:monitoring).hosts assert_equal [ "1.1.1.1", "1.1.1.3", "1.1.1.2" ], @config.accessory(:proxy).hosts assert_equal [ "1.1.1.1", "1.1.1.3" ], @config.accessory(:logger).hosts end test "missing host" do @deploy[:accessories]["mysql"]["host"] = nil assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new(@deploy) end end test "setting host, hosts, roles and tags" do @deploy[:accessories]["mysql"]["hosts"] = [ "mysql-db1" ] @deploy[:accessories]["mysql"]["roles"] = [ "db" ] exception = assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new(@deploy) end assert_equal "accessories/mysql: specify one of `host`, `hosts`, `role`, `roles`, `tag` or `tags`", exception.message end test "all hosts" do assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4", "1.1.1.5", "1.1.1.6", "1.1.1.7" ], @config.all_hosts end test "label args" do assert_equal [ "--label", "service=\"app-mysql\"" ], @config.accessory(:mysql).label_args assert_equal [ "--label", "service=\"app-redis\"", "--label", "cache=\"true\"" ], @config.accessory(:redis).label_args end test "env args" do with_test_secrets("secrets" => "MYSQL_ROOT_PASSWORD=secret123") do config = Kamal::Configuration.new(@deploy) assert_equal [ "--env", "MYSQL_ROOT_HOST=\"%\"", "--env-file", ".kamal/apps/app/env/accessories/mysql.env" ], config.accessory(:mysql).env_args.map(&:to_s) assert_equal "MYSQL_ROOT_PASSWORD=secret123\n", config.accessory(:mysql).secrets_io.string assert_equal [ "--env", "SOMETHING=\"else\"", "--env-file", ".kamal/apps/app/env/accessories/redis.env" ], @config.accessory(:redis).env_args assert_equal "\n", config.accessory(:redis).secrets_io.string end end test "volume args" do assert_equal [ "--volume", "$PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf", "--volume", "$PWD/app-mysql/docker-entrypoint-initdb.d/structure.sql:/docker-entrypoint-initdb.d/structure.sql", "--volume", "$PWD/app-mysql/data:/var/lib/mysql" ], @config.accessory(:mysql).volume_args assert_equal [ "--volume", "/var/lib/redis:/data" ], @config.accessory(:redis).volume_args end test "volume args with docker named volume" do @deploy[:accessories]["redis"]["volumes"] = [ "redis_data:/data" ] config = Kamal::Configuration.new(@deploy) assert_equal [ "--volume", "redis_data:/data" ], config.accessory(:redis).volume_args end test "dynamic file expansion" do @deploy[:accessories]["mysql"]["env"]["secret"] << "ENV_VAR:SECRET_VAR" @deploy[:accessories]["mysql"]["files"] << "test/fixtures/files/structure.sql.erb:/docker-entrypoint-initdb.d/structure.sql" with_test_secrets("secrets" => "MYSQL_ROOT_PASSWORD=secret123\nSECRET_VAR=secret_env_value") do @config = Kamal::Configuration.new(@deploy) assert_match "This was dynamically expanded", @config.accessory(:mysql).files.keys[2].read assert_match "%", @config.accessory(:mysql).files.keys[2].read assert_match "secret123", @config.accessory(:mysql).files.keys[2].read assert_match "secret_env_value", @config.accessory(:mysql).files.keys[2].read end end test "directory with a relative path" do @deploy[:accessories]["mysql"]["directories"] = [ "data:/var/lib/mysql" ] assert_equal({ "$PWD/app-mysql/data" => { host_path: "app-mysql/data", container_path: "/var/lib/mysql", options: nil, mode: nil, owner: nil } }, @config.accessory(:mysql).directories) end test "directory with an absolute path" do @deploy[:accessories]["mysql"]["directories"] = [ "/var/data/mysql:/var/lib/mysql" ] assert_equal({ "/var/data/mysql" => { host_path: "/var/data/mysql", container_path: "/var/lib/mysql", options: nil, mode: nil, owner: nil } }, @config.accessory(:mysql).directories) end test "directory with mount options" do @deploy[:accessories]["mysql"]["files"] = [] @deploy[:accessories]["mysql"]["directories"] = [ "data:/var/lib/mysql:z" ] config = Kamal::Configuration.new(@deploy) assert_equal({ "$PWD/app-mysql/data" => { host_path: "app-mysql/data", container_path: "/var/lib/mysql", options: "z", mode: nil, owner: nil } }, config.accessory(:mysql).directories) assert_equal [ "--volume", "$PWD/app-mysql/data:/var/lib/mysql:z" ], config.accessory(:mysql).volume_args end test "file with mount options" do @deploy[:accessories]["mysql"]["files"] = [ "config/mysql/my.cnf:/etc/mysql/my.cnf:ro,z" ] @deploy[:accessories]["mysql"]["directories"] = [] config = Kamal::Configuration.new(@deploy) files = config.accessory(:mysql).files assert_equal "ro,z", files.values.first[:options] assert_equal [ "--volume", "$PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf:ro,z" ], config.accessory(:mysql).volume_args end test "file with string format has default mode" do @deploy[:accessories]["mysql"]["files"] = [ "config/mysql/my.cnf:/etc/mysql/my.cnf" ] @deploy[:accessories]["mysql"]["directories"] = [] config = Kamal::Configuration.new(@deploy) files = config.accessory(:mysql).files assert_equal "755", files.values.first[:mode] assert_nil files.values.first[:owner] end test "file with hash format and custom mode" do @deploy[:accessories]["mysql"]["files"] = [ { "local" => "config/mysql/my.cnf", "remote" => "/etc/mysql/my.cnf", "mode" => "0600" } ] @deploy[:accessories]["mysql"]["directories"] = [] config = Kamal::Configuration.new(@deploy) files = config.accessory(:mysql).files assert_equal "0600", files.values.first[:mode] assert_nil files.values.first[:owner] end test "file with hash format and custom owner" do @deploy[:accessories]["mysql"]["files"] = [ { "local" => "config/mysql/my.cnf", "remote" => "/etc/mysql/my.cnf", "owner" => "mysql:mysql" } ] @deploy[:accessories]["mysql"]["directories"] = [] config = Kamal::Configuration.new(@deploy) files = config.accessory(:mysql).files assert_equal "755", files.values.first[:mode] assert_equal "mysql:mysql", files.values.first[:owner] end test "file with hash format and all options" do @deploy[:accessories]["mysql"]["files"] = [ { "local" => "config/mysql/my.cnf", "remote" => "/etc/mysql/my.cnf", "mode" => "0640", "owner" => "1000:1000", "options" => "Z" } ] @deploy[:accessories]["mysql"]["directories"] = [] config = Kamal::Configuration.new(@deploy) files = config.accessory(:mysql).files file_config = files.values.first assert_equal "0640", file_config[:mode] assert_equal "1000:1000", file_config[:owner] assert_equal "Z", file_config[:options] assert_equal [ "--volume", "$PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf:Z" ], config.accessory(:mysql).volume_args end test "file with hash format erb expansion" do @deploy[:accessories]["mysql"]["files"] = [ { "local" => "test/fixtures/files/structure.sql.erb", "remote" => "/docker-entrypoint-initdb.d/structure.sql" } ] @deploy[:accessories]["mysql"]["directories"] = [] with_test_secrets("secrets" => "MYSQL_ROOT_PASSWORD=secret123") do config = Kamal::Configuration.new(@deploy) files = config.accessory(:mysql).files assert_match "This was dynamically expanded", files.keys.first.read end end test "directory with hash format and custom mode" do @deploy[:accessories]["mysql"]["files"] = [] @deploy[:accessories]["mysql"]["directories"] = [ { "local" => "data", "remote" => "/var/lib/mysql", "mode" => "0750" } ] config = Kamal::Configuration.new(@deploy) directories = config.accessory(:mysql).directories assert_equal "0750", directories.values.first[:mode] assert_nil directories.values.first[:owner] end test "directory with hash format and custom owner" do @deploy[:accessories]["mysql"]["files"] = [] @deploy[:accessories]["mysql"]["directories"] = [ { "local" => "data", "remote" => "/var/lib/mysql", "owner" => "mysql:mysql" } ] config = Kamal::Configuration.new(@deploy) directories = config.accessory(:mysql).directories assert_nil directories.values.first[:mode] assert_equal "mysql:mysql", directories.values.first[:owner] end test "directory with hash format and all options" do @deploy[:accessories]["mysql"]["files"] = [] @deploy[:accessories]["mysql"]["directories"] = [ { "local" => "data", "remote" => "/var/lib/mysql", "mode" => "0750", "owner" => "1000:1000", "options" => "z" } ] config = Kamal::Configuration.new(@deploy) directories = config.accessory(:mysql).directories dir_config = directories.values.first assert_equal "0750", dir_config[:mode] assert_equal "1000:1000", dir_config[:owner] assert_equal "z", dir_config[:options] assert_equal [ "--volume", "$PWD/app-mysql/data:/var/lib/mysql:z" ], config.accessory(:mysql).volume_args end test "options" do assert_equal [ "--cpus", "\"4\"", "--memory", "\"2GB\"" ], @config.accessory(:redis).option_args end test "network_args default" do assert_equal [ "--network", "kamal" ], @config.accessory(:mysql).network_args end test "network_args with configured options" do @deploy[:accessories]["mysql"]["network"] = "database" assert_equal [ "--network", "database" ], @config.accessory(:mysql).network_args end test "proxy" do assert @config.accessory(:monitoring).running_proxy? assert_equal [ "monitoring.example.com" ], @config.accessory(:monitoring).proxy.hosts end test "can't set restart in options" do @deploy[:accessories]["mysql"]["options"] = { "restart" => "always" } assert_raises Kamal::ConfigurationError, "servers/workers: Cannot set restart policy in docker options, unless-stopped is required" do Kamal::Configuration.new(@deploy) end end end ================================================ FILE: test/configuration/boot_test.rb ================================================ require "test_helper" class ConfigurationBootTest < ActiveSupport::TestCase test "no boot config" do config = config_with_boot(nil) assert_nil config.boot.limit assert_nil config.boot.wait assert_nil config.boot.parallel_roles end test "specific limit group strategy" do config = config_with_boot("limit" => 3, "wait" => 2) assert_equal 3, config.boot.limit assert_equal 2, config.boot.wait end test "percentage-based group strategy" do config = config_with_boot("limit" => "50%", "wait" => 2) assert_equal 2, config.boot.limit assert_equal 2, config.boot.wait end test "percentage-based group strategy limit is at least 1" do config = config_with_boot("limit" => "1%", "wait" => 2) assert_equal 1, config.boot.limit assert_equal 2, config.boot.wait end test "parallel_roles" do config = config_with_boot("parallel_roles" => true) assert_equal true, config.boot.parallel_roles end private def config_with_boot(boot) deploy = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, builder: { "arch" => "amd64" }, servers: { "web" => [ "1.1.1.1", "1.1.1.2" ], "workers" => [ "1.1.1.3", "1.1.1.4" ] }, boot: boot }.compact Kamal::Configuration.new(deploy) end end ================================================ FILE: test/configuration/builder_test.rb ================================================ require "test_helper" class ConfigurationBuilderTest < ActiveSupport::TestCase setup do @deploy = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, builder: { "arch" => "amd64" }, servers: [ "1.1.1.1" ] } end test "local?" do assert_equal true, config.builder.local? end test "remote?" do assert_equal false, config.builder.remote? end test "pack?" do assert_not config.builder.pack? end test "pack? with pack builder" do @deploy[:builder] = { "arch" => "arm64", "pack" => { "builder" => "heroku/builder:24" } } assert config.builder.pack? end test "pack details" do @deploy[:builder] = { "arch" => "amd64", "pack" => { "builder" => "heroku/builder:24", "buildpacks" => [ "heroku/ruby", "heroku/procfile" ] } } assert_equal "heroku/builder:24", config.builder.pack_builder assert_equal [ "heroku/ruby", "heroku/procfile" ], config.builder.pack_buildpacks end test "remote" do assert_nil config.builder.remote end test "setting both local and remote configs" do @deploy[:builder] = { "arch" => [ "amd64", "arm64" ], "remote" => "ssh://root@192.168.0.1" } assert_equal true, config.builder.local? assert_equal true, config.builder.remote? assert_equal [ "amd64", "arm64" ], config.builder.arches assert_equal "ssh://root@192.168.0.1", config.builder.remote end test "cached?" do assert_equal false, config.builder.cached? end test "invalid cache type specified" do @deploy[:builder]["cache"] = { "type" => "invalid" } assert_raises(Kamal::ConfigurationError) do config.builder end end test "cache_from" do assert_nil config.builder.cache_from end test "cache_to" do assert_nil config.builder.cache_to end test "setting gha cache" do @deploy[:builder] = { "arch" => "amd64", "cache" => { "type" => "gha", "options" => "mode=max,scope=test" } } assert_equal "type=gha,scope=test", config.builder.cache_from assert_equal "type=gha,mode=max,scope=test", config.builder.cache_to end test "setting registry cache" do @deploy[:builder] = { "arch" => "amd64", "cache" => { "type" => "registry", "options" => "mode=max,image-manifest=true,oci-mediatypes=true" } } assert_equal "type=registry,ref=dhh/app-build-cache", config.builder.cache_from assert_equal "type=registry,ref=dhh/app-build-cache,mode=max,image-manifest=true,oci-mediatypes=true", config.builder.cache_to end test "setting registry cache when using a custom registry" do @deploy[:registry]["server"] = "registry.example.com" @deploy[:builder] = { "arch" => "amd64", "cache" => { "type" => "registry", "options" => "mode=max,image-manifest=true,oci-mediatypes=true" } } assert_equal "type=registry,ref=registry.example.com/dhh/app-build-cache", config.builder.cache_from assert_equal "type=registry,ref=registry.example.com/dhh/app-build-cache,mode=max,image-manifest=true,oci-mediatypes=true", config.builder.cache_to end test "setting registry cache with image" do @deploy[:builder] = { "arch" => "amd64", "cache" => { "type" => "registry", "image" => "kamal", "options" => "mode=max" } } assert_equal "type=registry,ref=kamal", config.builder.cache_from assert_equal "type=registry,ref=kamal,mode=max", config.builder.cache_to end test "args" do assert_equal({}, config.builder.args) end test "setting args" do @deploy[:builder]["args"] = { "key" => "value" } assert_equal({ "key" => "value" }, config.builder.args) end test "secrets" do assert_equal({}, config.builder.secrets) end test "setting secrets" do with_test_secrets("secrets" => "GITHUB_TOKEN=secret123") do @deploy[:builder]["secrets"] = [ "GITHUB_TOKEN" ] assert_equal({ "GITHUB_TOKEN" => "secret123" }, config.builder.secrets) end end test "dockerfile" do assert_equal "Dockerfile", config.builder.dockerfile end test "setting dockerfile" do @deploy[:builder]["dockerfile"] = "Dockerfile.dev" assert_equal "Dockerfile.dev", config.builder.dockerfile end test "context" do assert_equal ".", config.builder.context end test "setting context" do @deploy[:builder]["context"] = ".." assert_equal "..", config.builder.context end test "ssh" do assert_nil config.builder.ssh end test "setting ssh params" do @deploy[:builder]["ssh"] = "default=$SSH_AUTH_SOCK" assert_equal "default=$SSH_AUTH_SOCK", config.builder.ssh end test "provenance" do assert_nil config.builder.provenance end test "setting provenance" do @deploy[:builder]["provenance"] = "mode=max" assert_equal "mode=max", config.builder.provenance end test "sbom" do assert_nil config.builder.sbom end test "setting sbom" do @deploy[:builder]["sbom"] = true assert_equal true, config.builder.sbom end test "local disabled but no remote set" do @deploy[:builder]["local"] = false assert_raises(Kamal::ConfigurationError) do config.builder end end test "local disabled all arches are remote" do @deploy[:builder]["local"] = false @deploy[:builder]["remote"] = "ssh://root@192.168.0.1" @deploy[:builder]["arch"] = [ "amd64", "arm64" ] assert_equal [], config.builder.local_arches assert_equal [ "amd64", "arm64" ], config.builder.remote_arches end private def config Kamal::Configuration.new(@deploy) end end ================================================ FILE: test/configuration/env/tags_test.rb ================================================ require "test_helper" class ConfigurationEnvTagsTest < ActiveSupport::TestCase setup do @deploy = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ { "1.1.1.1" => "odd" }, { "1.1.1.2" => "even" }, { "1.1.1.3" => [ "odd", "three" ] } ], builder: { "arch" => "amd64" }, env: { "clear" => { "REDIS_URL" => "redis://x/y", "THREE" => "false" }, "tags" => { "odd" => { "TYPE" => "odd" }, "even" => { "TYPE" => "even" }, "three" => { "THREE" => "true" } } } } @config = Kamal::Configuration.new(@deploy) @deploy_with_roles = @deploy.dup.merge({ servers: { "web" => [ { "1.1.1.1" => "odd" }, "1.1.1.2" ], "workers" => { "hosts" => [ { "1.1.1.3" => [ "odd", "oddjob" ] }, "1.1.1.4" ], "cmd" => "bin/jobs", "env" => { "REDIS_URL" => "redis://a/b", "WEB_CONCURRENCY" => 4 } } }, env: { "tags" => { "odd" => { "TYPE" => "odd" }, "oddjob" => { "TYPE" => "oddjob" } } } }) @config_with_roles = Kamal::Configuration.new(@deploy_with_roles) end test "tags" do assert_equal 3, @config.env_tags.size assert_equal %w[ odd even three ], @config.env_tags.map(&:name) assert_equal({ "TYPE" => "odd" }, @config.env_tag("odd").env.clear) assert_equal({ "TYPE" => "even" }, @config.env_tag("even").env.clear) assert_equal({ "THREE" => "true" }, @config.env_tag("three").env.clear) end test "tags with roles" do assert_equal 2, @config_with_roles.env_tags.size assert_equal %w[ odd oddjob ], @config_with_roles.env_tags.map(&:name) assert_equal({ "TYPE" => "odd" }, @config_with_roles.env_tag("odd").env.clear) assert_equal({ "TYPE" => "oddjob" }, @config_with_roles.env_tag("oddjob").env.clear) end test "tag overrides env" do assert_equal "false", @config.role("web").env("1.1.1.1").clear["THREE"] assert_equal "true", @config.role("web").env("1.1.1.3").clear["THREE"] end test "later tag wins" do deploy = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ { "1.1.1.1" => [ "first", "second" ] } ], builder: { "arch" => "amd64" }, env: { "tags" => { "first" => { "TYPE" => "first" }, "second" => { "TYPE" => "second" } } } } config = Kamal::Configuration.new(deploy) assert_equal "second", config.role("web").env("1.1.1.1").clear["TYPE"] end test "tag secret env" do with_test_secrets("secrets" => "PASSWORD=hello") do deploy = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ { "1.1.1.1" => "secrets" } ], builder: { "arch" => "amd64" }, env: { "tags" => { "secrets" => { "secret" => [ "PASSWORD" ] } } } } config = Kamal::Configuration.new(deploy) assert_equal "PASSWORD=hello\n", config.role("web").env("1.1.1.1").secrets_io.string end end test "aliased tag secret env" do with_test_secrets("secrets" => "PASSWORD=hello\nALIASED_PASSWORD=aliased_hello") do deploy = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ { "1.1.1.1" => "secrets" } ], builder: { "arch" => "amd64" }, env: { "tags" => { "secrets" => { "secret" => [ "PASSWORD:ALIASED_PASSWORD" ] } } } } config = Kamal::Configuration.new(deploy) assert_equal "PASSWORD=aliased_hello\n", config.role("web").env("1.1.1.1").secrets_io.string end end test "tag clear env" do deploy = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ { "1.1.1.1" => "clearly" } ], builder: { "arch" => "amd64" }, env: { "tags" => { "clearly" => { "clear" => { "FOO" => "bar" } } } } } config = Kamal::Configuration.new(deploy) assert_equal "bar", config.role("web").env("1.1.1.1").clear["FOO"] end end ================================================ FILE: test/configuration/env_test.rb ================================================ require "test_helper" class ConfigurationEnvTest < ActiveSupport::TestCase require "test_helper" test "simple" do assert_config \ config: { "foo" => "bar", "baz" => "haz" }, clear: { "foo" => "bar", "baz" => "haz" } end test "clear" do assert_config \ config: { "clear" => { "foo" => "bar", "baz" => "haz" } }, clear: { "foo" => "bar", "baz" => "haz" } end test "secret" do with_test_secrets("secrets" => "PASSWORD=hello") do assert_config \ config: { "secret" => [ "PASSWORD" ] }, secrets: { "PASSWORD" => "hello" } end end test "missing secret" do env = { "secret" => [ "PASSWORD" ] } assert_raises(Kamal::ConfigurationError) do Kamal::Configuration::Env.new( config: { "secret" => [ "PASSWORD" ] }, secrets: Kamal::Secrets.new(secrets_path: ".kamal/secrets") ).secrets_io end end test "secret and clear" do with_test_secrets("secrets" => "PASSWORD=hello") do config = { "secret" => [ "PASSWORD" ], "clear" => { "foo" => "bar", "baz" => "haz" } } assert_config \ config: config, clear: { "foo" => "bar", "baz" => "haz" }, secrets: { "PASSWORD" => "hello" } end end test "aliased secrets" do with_test_secrets("secrets" => "ALIASED_PASSWORD=hello") do config = { "secret" => [ "PASSWORD:ALIASED_PASSWORD" ], "clear" => {} } assert_config \ config: config, clear: {}, secrets: { "PASSWORD" => "hello" } end end private def assert_config(config:, clear: {}, secrets: {}) env = Kamal::Configuration::Env.new config: config, secrets: Kamal::Secrets.new(secrets_path: ".kamal/secrets") expected_clear_args = clear.to_a.flat_map { |key, value| [ "--env", "#{key}=\"#{value}\"" ] } assert_equal expected_clear_args, env.clear_args.map(&:to_s) #  to_s removes the redactions expected_secrets = secrets.to_a.flat_map { |key, value| "#{key}=#{value}" }.join("\n") + "\n" assert_equal expected_secrets, env.secrets_io.string end end ================================================ FILE: test/configuration/proxy/boot_test.rb ================================================ require "test_helper" class ConfigurationProxyBootTest < ActiveSupport::TestCase setup do ENV["RAILS_MASTER_KEY"] = "456" ENV["VERSION"] = "missing" @deploy = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, builder: { "arch" => "amd64" }, env: { "REDIS_URL" => "redis://x/y" }, servers: [ "1.1.1.1", "1.1.1.2" ], volumes: [ "/local/path:/container/path" ] } @config = Kamal::Configuration.new(@deploy) @proxy_boot_config = @config.proxy_boot end test "proxy directories" do assert_equal ".kamal/proxy/apps-config", @proxy_boot_config.apps_directory assert_equal "/home/kamal-proxy/.apps-config", @proxy_boot_config.apps_container_directory assert_equal ".kamal/proxy/apps-config/app", @proxy_boot_config.app_directory assert_equal "/home/kamal-proxy/.apps-config/app", @proxy_boot_config.app_container_directory assert_equal ".kamal/proxy/apps-config/app/error_pages", @proxy_boot_config.error_pages_directory assert_equal "/home/kamal-proxy/.apps-config/app/error_pages", @proxy_boot_config.error_pages_container_directory assert_equal ".kamal/proxy/apps-config/app/tls", @proxy_boot_config.tls_directory assert_equal "/home/kamal-proxy/.apps-config/app/tls", @proxy_boot_config.tls_container_directory end end ================================================ FILE: test/configuration/proxy_test.rb ================================================ require "test_helper" class ConfigurationProxyTest < ActiveSupport::TestCase setup do @deploy = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, builder: { "arch" => "amd64" }, servers: [ "1.1.1.1" ] } end test "ssl with host" do @deploy[:proxy] = { "ssl" => true, "host" => "example.com" } assert_equal true, config.proxy.ssl? end test "ssl with multiple hosts passed via host" do @deploy[:proxy] = { "ssl" => true, "host" => "example.com,anotherexample.com" } assert_equal true, config.proxy.ssl? end test "ssl with multiple hosts passed via hosts" do @deploy[:proxy] = { "ssl" => true, "hosts" => [ "example.com", "anotherexample.com" ] } assert_equal true, config.proxy.ssl? end test "ssl with no host" do @deploy[:proxy] = { "ssl" => true } assert_raises(Kamal::ConfigurationError) { config.proxy.ssl? } end test "ssl with both host and hosts" do @deploy[:proxy] = { "ssl" => true, host: "example.com", hosts: [ "anotherexample.com" ] } assert_raises(Kamal::ConfigurationError) { config.proxy.ssl? } end test "ssl false" do @deploy[:proxy] = { "ssl" => false } assert_not config.proxy.ssl? end test "false not allowed" do @deploy[:proxy] = false assert_raises(Kamal::ConfigurationError, "proxy: should be a hash") do config.proxy end end test "ssl with certificate and private key from secrets" do with_test_secrets("secrets" => "CERT_PEM=certificate\nKEY_PEM=private_key") do @deploy[:proxy] = { "ssl" => { "certificate_pem" => "CERT_PEM", "private_key_pem" => "KEY_PEM" }, "host" => "example.com" } proxy = config.proxy assert_equal ".kamal/proxy/apps-config/app/tls/cert.pem", proxy.host_tls_cert assert_equal ".kamal/proxy/apps-config/app/tls/key.pem", proxy.host_tls_key assert_equal "/home/kamal-proxy/.apps-config/app/tls/cert.pem", proxy.container_tls_cert assert_equal "/home/kamal-proxy/.apps-config/app/tls/key.pem", proxy.container_tls_key end end test "deploy options with custom ssl certificates" do with_test_secrets("secrets" => "CERT_PEM=certificate\nKEY_PEM=private_key") do @deploy[:proxy] = { "ssl" => { "certificate_pem" => "CERT_PEM", "private_key_pem" => "KEY_PEM" }, "host" => "example.com" } proxy = config.proxy options = proxy.deploy_options assert_equal true, options[:tls] assert_equal "/home/kamal-proxy/.apps-config/app/tls/cert.pem", options[:"tls-certificate-path"] assert_equal "/home/kamal-proxy/.apps-config/app/tls/key.pem", options[:"tls-private-key-path"] end end test "ssl with certificate and no private key" do with_test_secrets("secrets" => "CERT_PEM=certificate") do @deploy[:proxy] = { "ssl" => { "certificate_pem" => "CERT_PEM" }, "host" => "example.com" } assert_raises(Kamal::ConfigurationError) { config.proxy.ssl? } end end test "ssl with private key and no certificate" do with_test_secrets("secrets" => "KEY_PEM=private_key") do @deploy[:proxy] = { "ssl" => { "private_key_pem" => "KEY_PEM" }, "host" => "example.com" } assert_raises(Kamal::ConfigurationError) { config.proxy.ssl? } end end private def config Kamal::Configuration.new(@deploy) end end ================================================ FILE: test/configuration/role_test.rb ================================================ require "test_helper" class ConfigurationRoleTest < ActiveSupport::TestCase setup do @deploy = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1", "1.1.1.2" ], builder: { "arch" => "amd64" }, env: { "REDIS_URL" => "redis://x/y" } } @deploy_with_roles = @deploy.dup.merge({ servers: { "web" => [ "1.1.1.1", "1.1.1.2" ], "workers" => { "hosts" => [ "1.1.1.3", "1.1.1.4" ], "cmd" => "bin/jobs", "env" => { "REDIS_URL" => "redis://a/b", "WEB_CONCURRENCY" => "4" } } } }) end test "hosts" do assert_equal [ "1.1.1.1", "1.1.1.2" ], config.role(:web).hosts assert_equal [ "1.1.1.3", "1.1.1.4" ], config_with_roles.role(:workers).hosts end test "missing env tag is ignored" do @deploy_with_roles[:servers]["workers"]["hosts"] = [ { "1.1.1.3" => [ "job" ] } ] role = Kamal::Configuration.new(@deploy_with_roles).role(:workers) assert_equal "redis://a/b", role.env("1.1.1.3").clear["REDIS_URL"] end test "cmd" do assert_nil config.role(:web).cmd assert_equal "bin/jobs", config_with_roles.role(:workers).cmd end test "label args" do assert_equal [ "--label", "service=\"app\"", "--label", "role=\"workers\"", "--label", "destination" ], config_with_roles.role(:workers).label_args end test "special label args for web" do assert_equal [ "--label", "service=\"app\"", "--label", "role=\"web\"", "--label", "destination" ], config.role(:web).label_args end test "custom labels" do @deploy[:labels] = { "my.custom.label" => "50" } assert_equal "50", config.role(:web).labels["my.custom.label"] end test "custom labels via role specialization" do @deploy_with_roles[:labels] = { "my.custom.label" => "50" } @deploy_with_roles[:servers]["workers"]["labels"] = { "my.custom.label" => "70" } assert_equal "70", Kamal::Configuration.new(@deploy_with_roles).role(:workers).labels["my.custom.label"] end test "default proxy label on non-web role" do config = Kamal::Configuration.new(@deploy_with_roles.tap { |c| c[:servers]["beta"] = { "proxy" => true, "hosts" => [ "1.1.1.5" ] } }) assert_equal [ "--label", "service=\"app\"", "--label", "role=\"beta\"", "--label", "destination" ], config.role(:beta).label_args end test "env overwritten by role" do assert_equal "redis://a/b", config_with_roles.role(:workers).env("1.1.1.3").clear["REDIS_URL"] assert_equal \ [ "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"", "--env-file", ".kamal/apps/app/env/roles/workers.env" ], config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s) assert_equal \ "\n", config_with_roles.role(:workers).secrets_io("1.1.1.3").read end test "container name" do ENV["VERSION"] = "12345" assert_equal "app-workers-12345", config_with_roles.role(:workers).container_name assert_equal "app-web-12345", config_with_roles.role(:web).container_name ensure ENV.delete("VERSION") end test "env args" do assert_equal \ [ "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"", "--env-file", ".kamal/apps/app/env/roles/workers.env" ], config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s) assert_equal \ "\n", config_with_roles.role(:workers).secrets_io("1.1.1.3").read end test "env secret overwritten by role" do with_test_secrets("secrets" => "REDIS_PASSWORD=secret456\nDB_PASSWORD=secret&\"123") do @deploy_with_roles[:env] = { "clear" => { "REDIS_URL" => "redis://a/b" }, "secret" => [ "REDIS_PASSWORD" ] } @deploy_with_roles[:servers]["workers"]["env"] = { "clear" => { "REDIS_URL" => "redis://a/b", "WEB_CONCURRENCY" => "4" }, "secret" => [ "DB_PASSWORD" ] } assert_equal \ [ "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"", "--env-file", ".kamal/apps/app/env/roles/workers.env" ], config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s) assert_equal \ "REDIS_PASSWORD=secret456\nDB_PASSWORD=secret&\"123\n", config_with_roles.role(:workers).secrets_io("1.1.1.3").read end end test "env secrets only in role" do with_test_secrets("secrets" => "DB_PASSWORD=secret123") do @deploy_with_roles[:servers]["workers"]["env"] = { "clear" => { "REDIS_URL" => "redis://a/b", "WEB_CONCURRENCY" => "4" }, "secret" => [ "DB_PASSWORD" ] } assert_equal \ [ "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"", "--env-file", ".kamal/apps/app/env/roles/workers.env" ], config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s) assert_equal \ "DB_PASSWORD=secret123\n", config_with_roles.role(:workers).secrets_io("1.1.1.3").read end end test "env secrets only at top level" do with_test_secrets("secrets" => "REDIS_PASSWORD=secret456") do @deploy_with_roles[:env] = { "clear" => { "REDIS_URL" => "redis://a/b" }, "secret" => [ "REDIS_PASSWORD" ] } assert_equal \ [ "--env", "REDIS_URL=\"redis://a/b\"", "--env", "WEB_CONCURRENCY=\"4\"", "--env-file", ".kamal/apps/app/env/roles/workers.env" ], config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s) assert_equal \ "REDIS_PASSWORD=secret456\n", config_with_roles.role(:workers).secrets_io("1.1.1.3").read end end test "env overwritten by role with secrets" do with_test_secrets("secrets" => "REDIS_PASSWORD=secret456") do @deploy_with_roles[:env] = { "clear" => { "REDIS_URL" => "redis://a/b" }, "secret" => [ "REDIS_PASSWORD" ] } @deploy_with_roles[:servers]["workers"]["env"] = { "clear" => { "REDIS_URL" => "redis://c/d" } } assert_equal \ [ "--env", "REDIS_URL=\"redis://c/d\"", "--env-file", ".kamal/apps/app/env/roles/workers.env" ], config_with_roles.role(:workers).env_args("1.1.1.3").map(&:to_s) assert_equal \ "REDIS_PASSWORD=secret456\n", config_with_roles.role(:workers).secrets_io("1.1.1.3").read end end test "asset path and volume args" do ENV["VERSION"] = "12345" assert_nil config_with_roles.role(:web).asset_volume_args assert_nil config_with_roles.role(:workers).asset_volume_args assert_nil config_with_roles.role(:web).asset_path assert_nil config_with_roles.role(:workers).asset_path assert_not config_with_roles.role(:web).assets? assert_not config_with_roles.role(:workers).assets? config_with_assets = Kamal::Configuration.new(@deploy_with_roles.dup.tap { |c| c[:asset_path] = "foo" }) assert_equal "foo", config_with_assets.role(:web).asset_path assert_equal "foo", config_with_assets.role(:workers).asset_path assert_equal [ "--volume", "$PWD/.kamal/apps/app/assets/volumes/web-12345:foo" ], config_with_assets.role(:web).asset_volume_args assert_nil config_with_assets.role(:workers).asset_volume_args assert config_with_assets.role(:web).assets? assert_not config_with_assets.role(:workers).assets? config_with_assets = Kamal::Configuration.new(@deploy_with_roles.dup.tap { |c| c[:servers]["web"] = { "hosts" => [ "1.1.1.1", "1.1.1.2" ], "asset_path" => "bar" } }) assert_equal "bar", config_with_assets.role(:web).asset_path assert_nil config_with_assets.role(:workers).asset_path assert_equal [ "--volume", "$PWD/.kamal/apps/app/assets/volumes/web-12345:bar" ], config_with_assets.role(:web).asset_volume_args assert_nil config_with_assets.role(:workers).asset_volume_args assert config_with_assets.role(:web).assets? assert_not config_with_assets.role(:workers).assets? ensure ENV.delete("VERSION") end test "asset path with mount options" do ENV["VERSION"] = "12345" config_with_assets = Kamal::Configuration.new(@deploy_with_roles.dup.tap { |c| c[:asset_path] = "/rails/public/assets:z" }) assert_equal "/rails/public/assets", config_with_assets.role(:web).asset_path assert_equal "z", config_with_assets.role(:web).asset_path_options assert_equal [ "--volume", "$PWD/.kamal/apps/app/assets/volumes/web-12345:/rails/public/assets:z" ], config_with_assets.role(:web).asset_volume_args config_with_assets = Kamal::Configuration.new(@deploy_with_roles.dup.tap { |c| c[:servers]["web"] = { "hosts" => [ "1.1.1.1", "1.1.1.2" ], "asset_path" => "/assets:ro,z" } }) assert_equal "/assets", config_with_assets.role(:web).asset_path assert_equal "ro,z", config_with_assets.role(:web).asset_path_options assert_equal [ "--volume", "$PWD/.kamal/apps/app/assets/volumes/web-12345:/assets:ro,z" ], config_with_assets.role(:web).asset_volume_args ensure ENV.delete("VERSION") end test "asset extracted path" do ENV["VERSION"] = "12345" assert_equal ".kamal/apps/app/assets/extracted/web-12345", config_with_roles.role(:web).asset_extracted_directory assert_equal ".kamal/apps/app/assets/extracted/workers-12345", config_with_roles.role(:workers).asset_extracted_directory ensure ENV.delete("VERSION") end test "asset volume path" do ENV["VERSION"] = "12345" assert_equal ".kamal/apps/app/assets/volumes/web-12345", config_with_roles.role(:web).asset_volume_directory assert_equal ".kamal/apps/app/assets/volumes/workers-12345", config_with_roles.role(:workers).asset_volume_directory ensure ENV.delete("VERSION") end test "stop args with proxy" do assert_equal [], config_with_roles.role(:web).stop_args end test "stop args with no proxy" do assert_equal [ "-t", 30 ], config_with_roles.role(:workers).stop_args end test "role specific proxy config" do @deploy_with_roles[:proxy] = { "response_timeout" => 15 } @deploy_with_roles[:servers]["workers"]["proxy"] = { "response_timeout" => 18 } assert_equal "15s", config_with_roles.role(:web).proxy.deploy_options[:"target-timeout"] assert_equal "18s", config_with_roles.role(:workers).proxy.deploy_options[:"target-timeout"] end test "can't set restart in options" do @deploy_with_roles[:servers]["workers"]["options"] = { "restart" => "always" } assert_raises Kamal::ConfigurationError, "servers/workers: Cannot set restart policy in docker options, unless-stopped is required" do Kamal::Configuration.new(@deploy_with_roles) end end private def config Kamal::Configuration.new(@deploy) end def config_with_roles Kamal::Configuration.new(@deploy_with_roles) end end ================================================ FILE: test/configuration/ssh_test.rb ================================================ require "test_helper" class ConfigurationSshTest < ActiveSupport::TestCase setup do @deploy = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, builder: { "arch" => "amd64" }, env: { "REDIS_URL" => "redis://x/y" }, servers: [ "1.1.1.1", "1.1.1.2" ], volumes: [ "/local/path:/container/path" ] } @config = Kamal::Configuration.new(@deploy) end test "ssh options" do assert_equal "root", @config.ssh.options[:user] config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "user" => "app" }) }) assert_equal "app", config.ssh.options[:user] assert_equal 4, config.ssh.options[:logger].level config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "log_level" => "debug" }) }) assert_equal 0, config.ssh.options[:logger].level config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "port" => 2222 }) }) assert_equal 2222, config.ssh.options[:port] config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "config" => true }) }) assert_equal true, config.ssh.options[:config] config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "config" => false }) }) assert_equal false, config.ssh.options[:config] config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "config" => "~/config.mine" }) }) assert_equal "~/config.mine", config.ssh.options[:config] config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "config" => [ "~/config.mine.1", "~/config.mine.2" ] }) }) assert_equal [ "~/config.mine.1", "~/config.mine.2" ], config.ssh.options[:config] end test "ssh options with proxy host" do config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "proxy" => "1.2.3.4" }) }) assert_equal "root@1.2.3.4", config.ssh.options[:proxy].jump_proxies end test "ssh options with proxy host and user" do config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "proxy" => "app@1.2.3.4" }) }) assert_equal "app@1.2.3.4", config.ssh.options[:proxy].jump_proxies end test "ssh key_data with plain value array" do config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "key_data" => [ "-----BEGIN OPENSSH PRIVATE KEY-----" ] }) }) assert_equal [ "-----BEGIN OPENSSH PRIVATE KEY-----" ], config.ssh.options[:key_data] end test "ssh key_data with array containing one secret string" do with_test_secrets("secrets" => "SSH_PRIVATE_KEY=secret_ssh_key") do config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "key_data" => [ "SSH_PRIVATE_KEY" ] }) }) assert_equal [ "secret_ssh_key" ], config.ssh.options[:key_data] end end test "ssh key_data with array containing multiple secret strings" do with_test_secrets("secrets" => "SSH_PRIVATE_KEY=secret_ssh_key\nSECOND_KEY=second_secret_ssh_key") do config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "key_data" => [ "SSH_PRIVATE_KEY", "SECOND_KEY" ] }) }) assert_equal [ "secret_ssh_key", "second_secret_ssh_key" ], config.ssh.options[:key_data] end end end ================================================ FILE: test/configuration/sshkit_test.rb ================================================ require "test_helper" class ConfigurationSshkitTest < ActiveSupport::TestCase setup do @deploy = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, env: { "REDIS_URL" => "redis://x/y" }, builder: { "arch" => "amd64" }, servers: [ "1.1.1.1", "1.1.1.2" ], volumes: [ "/local/path:/container/path" ] } @config = Kamal::Configuration.new(@deploy) end test "sshkit defaults" do assert_equal 30, @config.sshkit.max_concurrent_starts assert_equal 900, @config.sshkit.pool_idle_timeout assert_equal 3, @config.sshkit.dns_retries end test "sshkit overrides" do @config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(sshkit: { "max_concurrent_starts" => 50, "pool_idle_timeout" => 600, "dns_retries" => 5 }) }) assert_equal 50, @config.sshkit.max_concurrent_starts assert_equal 600, @config.sshkit.pool_idle_timeout assert_equal 5, @config.sshkit.dns_retries end end ================================================ FILE: test/configuration/validation_test.rb ================================================ require "test_helper" class ConfigurationValidationTest < ActiveSupport::TestCase test "unknown root key" do assert_error "unknown key: unknown", unknown: "value" assert_error "unknown keys: unknown, unknown2", unknown: "value", unknown2: "value" end test "wrong root types" do [ :service, :image, :asset_path, :hooks_path, :secrets_path, :primary_role, :minimum_version, :run_directory ].each do |key| assert_error "#{key}: should be a string", **{ key => [] } end [ :require_destination, :allow_empty_roles ].each do |key| assert_error "#{key}: should be a boolean", **{ key => "foo" } end [ :deploy_timeout, :drain_timeout, :retain_containers, :readiness_delay ].each do |key| assert_error "#{key}: should be an integer", **{ key => "foo" } end assert_error "volumes: should be an array", volumes: "foo" assert_error "servers: should be an array or a hash", servers: "foo" [ :labels, :registry, :accessories, :env, :ssh, :sshkit, :builder, :proxy, :boot, :logging ].each do |key| assert_error "#{key}: should be a hash", **{ key =>[] } end end test "servers" do assert_error "servers: should be an array or a hash", servers: "foo" assert_error "servers/0: should be a string or a hash", servers: [ [] ] assert_error "servers/0: multiple hosts found", servers: [ { "a" => "b", "c" => "d" } ] assert_error "servers/0/foo: should be a string or an array", servers: [ { "foo" => {} } ] assert_error "servers/0/foo/0: should be a string", servers: [ { "foo" => [ [] ] } ] end test "roles" do assert_error "servers/web: should be an array or a hash", servers: { "web" => "foo" } assert_error "servers/web/hosts: should be an array", servers: { "web" => { "hosts" => "" } } assert_error "servers/web/hosts/0: should be a string or a hash", servers: { "web" => { "hosts" => [ [] ] } } assert_error "servers/web/options: should be a hash", servers: { "web" => { "options" => "" } } assert_error "servers/web/logging/options: should be a hash", servers: { "web" => { "logging" => { "options" => "" } } } assert_error "servers/web/logging/driver: should be a string", servers: { "web" => { "logging" => { "driver" => [] } } } assert_error "servers/web/labels/service: invalid label. destination, role, and service are reserved labels", servers: { "web" => { "labels" => { "service" => "foo" } } } assert_error "servers/web/labels: should be a hash", servers: { "web" => { "labels" => [] } } assert_error "servers/web/env: should be a hash", servers: { "web" => { "env" => [] } } assert_error "servers/web/env: tags are only allowed in the root env", servers: { "web" => { "hosts" => [ "1.1.1.1" ], "env" => { "tags" => {} } } } end test "registry" do assert_error "registry/username: is required", registry: {} assert_error "registry/password: is required", registry: { "username" => "foo" } assert_error "registry/password: should be a string or an array with one string (for secret lookup)", registry: { "username" => "foo", "password" => [ "SECRET1", "SECRET2" ] } assert_error "registry/server: should be a string", registry: { "username" => "foo", "password" => "bar", "server" => [] } end test "accessories" do assert_error "accessories/accessory1: should be a hash", accessories: { "accessory1" => [] } assert_error "accessories/accessory1: unknown key: unknown", accessories: { "accessory1" => { "unknown" => "baz" } } assert_error "accessories/accessory1/options: should be a hash", accessories: { "accessory1" => { "options" => [] } } assert_error "accessories/accessory1/labels/destination: invalid label. destination, role, and service are reserved labels", accessories: { "accessory1" => { "host" => "host", "labels" => { "destination" => "foo" } } } assert_error "accessories/accessory1/host: should be a string", accessories: { "accessory1" => { "host" => [] } } assert_error "accessories/accessory1/env: should be a hash", accessories: { "accessory1" => { "env" => [] } } assert_error "accessories/accessory1/env: tags are only allowed in the root env", accessories: { "accessory1" => { "host" => "host", "env" => { "tags" => {} } } } end test "env" do assert_error "env: should be a hash", env: [] assert_error "env/FOO: should be a string", env: { "FOO" => [] } assert_error "env/clear/FOO: should be a string", env: { "clear" => { "FOO" => [] } } assert_error "env/secret: should be an array", env: { "secret" => { "FOO" => [] } } assert_error "env/secret/0: should be a string", env: { "secret" => [ [] ] } assert_error "env/tags: should be a hash", env: { "tags" => [] } assert_error "env/tags/tag1: should be a hash", env: { "tags" => { "tag1" => "foo" } } assert_error "env/tags/tag1/FOO: should be a string", env: { "tags" => { "tag1" => { "FOO" => [] } } } assert_error "env/tags/tag1/clear/FOO: should be a string", env: { "tags" => { "tag1" => { "clear" => { "FOO" => [] } } } } assert_error "env/tags/tag1/secret: should be an array", env: { "tags" => { "tag1" => { "secret" => {} } } } assert_error "env/tags/tag1/secret/0: should be a string", env: { "tags" => { "tag1" => { "secret" => [ [] ] } } } assert_error "env/tags/tag1: tags are only allowed in the root env", env: { "tags" => { "tag1" => { "tags" => {} } } } end test "ssh" do assert_error "ssh: unknown key: foo", ssh: { "foo" => "bar" } assert_error "ssh/user: should be a string", ssh: { "user" => [] } assert_error "ssh/config: should be a boolean or a string or an array", ssh: { "config" => 1 } end test "sshkit" do assert_error "sshkit: unknown key: foo", sshkit: { "foo" => "bar" } assert_error "sshkit/max_concurrent_starts: should be an integer", sshkit: { "max_concurrent_starts" => "foo" } assert_error "sshkit/dns_retries: should be an integer", sshkit: { "dns_retries" => "foo" } end test "builder" do assert_error "builder: unknown key: foo", builder: { "foo" => "bar" } assert_error "builder/remote: should be a string", builder: { "remote" => { "foo" => "bar" } } assert_error "builder/arch: should be an array or a string", builder: { "arch" => {} } assert_error "builder/args: should be a hash", builder: { "args" => [ "foo" ] } assert_error "builder/cache/options: should be a string", builder: { "cache" => { "options" => [] } } assert_error "builder: buildpacks only support building for one arch", builder: { "arch" => [ "amd64", "arm64" ], "pack" => { "builder" => "heroku/builder:24" } } end test "local registry with remote builder requires ssh url" do remote_arch = Kamal::Utils.docker_arch == "arm64" ? "amd64" : "arm64" assert_error "Local registry with remote builder requires an SSH URL (e.g., ssh://user@host)", registry: { "server" => "localhost:5000" }, builder: { "arch" => remote_arch, "remote" => "docker-container://remote-builder" } # Should not raise error with SSH URL assert_nothing_raised do Kamal::Configuration.new({ service: "app", image: "app", registry: { "server" => "localhost:5000" }, builder: { "arch" => remote_arch, "remote" => "ssh://user@host" }, servers: [ "1.1.1.1" ] }) end end private def assert_error(message, **invalid_config) valid_config = { service: "app", image: "app", builder: { "arch" => "amd64" }, registry: { "username" => "user", "password" => "secret" }, servers: [ "1.1.1.1" ] } error = assert_raises Kamal::ConfigurationError do Kamal::Configuration.new(valid_config.merge(invalid_config)) end assert_equal message, error.message end end ================================================ FILE: test/configuration/volume_test.rb ================================================ require "test_helper" class ConfigurationVolumeTest < ActiveSupport::TestCase test "docker args absolute" do volume = Kamal::Configuration::Volume.new(host_path: "/root/foo/bar", container_path: "/assets") assert_equal [ "--volume", "/root/foo/bar:/assets" ], volume.docker_args end test "docker args relative" do volume = Kamal::Configuration::Volume.new(host_path: "foo/bar", container_path: "/assets") assert_equal [ "--volume", "$PWD/foo/bar:/assets" ], volume.docker_args end test "docker args with options" do volume = Kamal::Configuration::Volume.new(host_path: "/root/foo/bar", container_path: "/assets", options: "ro") assert_equal [ "--volume", "/root/foo/bar:/assets:ro" ], volume.docker_args end test "docker args with multiple options" do volume = Kamal::Configuration::Volume.new(host_path: "/root/foo/bar", container_path: "/assets", options: "ro,z") assert_equal [ "--volume", "/root/foo/bar:/assets:ro,z" ], volume.docker_args end test "docker args with selinux z option" do volume = Kamal::Configuration::Volume.new(host_path: "/data", container_path: "/data", options: "z") assert_equal [ "--volume", "/data:/data:z" ], volume.docker_args end test "docker args with selinux Z option" do volume = Kamal::Configuration::Volume.new(host_path: "/data", container_path: "/data", options: "Z") assert_equal [ "--volume", "/data:/data:Z" ], volume.docker_args end end ================================================ FILE: test/configuration_test.rb ================================================ require "test_helper" class ConfigurationTest < ActiveSupport::TestCase setup do ENV["RAILS_MASTER_KEY"] = "456" ENV["VERSION"] = "missing" @deploy = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, builder: { "arch" => "amd64" }, env: { "REDIS_URL" => "redis://x/y" }, servers: [ "1.1.1.1", "1.1.1.2" ], volumes: [ "/local/path:/container/path" ] } @config = Kamal::Configuration.new(@deploy) @deploy_with_roles = @deploy.dup.merge({ servers: { "web" => [ "1.1.1.1", "1.1.1.2" ], "workers" => { "hosts" => [ "1.1.1.1", "1.1.1.3" ] } } }) @config_with_roles = Kamal::Configuration.new(@deploy_with_roles) end teardown do ENV.delete("RAILS_MASTER_KEY") ENV.delete("VERSION") end %i[ service image registry ].each do |key| test "#{key} config required" do assert_raise(Kamal::ConfigurationError) do Kamal::Configuration.new @deploy.tap { |config| config.delete key } end end end %w[ username password ].each do |key| test "registry #{key} required" do assert_raise(Kamal::ConfigurationError) do Kamal::Configuration.new @deploy.tap { |config| config[:registry].delete key } end end end test "image uses service name if registry is local" do assert_equal "app", Kamal::Configuration.new(@deploy.tap { _1[:registry] = { "server" => "localhost:5000" } _1.delete(:image) }).image end test "image uses image if registry is local" do assert_equal "dhh/app", Kamal::Configuration.new(@deploy.tap { _1[:registry] = { "server" => "localhost:5000" } }).image end test "service name valid" do assert_nothing_raised do Kamal::Configuration.new(@deploy.tap { |config| config[:service] = "hey-app1_primary" }) Kamal::Configuration.new(@deploy.tap { |config| config[:service] = "MyApp" }) end end test "service name invalid" do assert_raise(Kamal::ConfigurationError) do Kamal::Configuration.new @deploy.tap { |config| config[:service] = "app.com" } end end test "servers required" do assert_raise(Kamal::ConfigurationError) do Kamal::Configuration.new @deploy.tap { |config| config.delete(:servers) } end end test "servers not required with accessories" do assert_nothing_raised do @deploy.delete(:servers) @deploy[:accessories] = { "foo" => { "image" => "foo/bar", "host" => "1.1.1.1" } } Kamal::Configuration.new(@deploy) end end test "roles" do assert_equal %w[ web ], @config.roles.collect(&:name) assert_equal %w[ web workers ], @config_with_roles.roles.collect(&:name) end test "role" do assert @config.role(:web).name.web? assert_equal "workers", @config_with_roles.role(:workers).name assert_nil @config.role(:missing) end test "all hosts" do assert_equal [ "1.1.1.1", "1.1.1.2" ], @config.all_hosts assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3" ], @config_with_roles.all_hosts end test "primary host" do assert_equal "1.1.1.1", @config.primary_host assert_equal "1.1.1.1", @config_with_roles.primary_host end test "proxy hosts" do assert_equal [ "1.1.1.1", "1.1.1.2" ], @config_with_roles.proxy_hosts @deploy_with_roles[:servers]["workers"]["proxy"] = true config = Kamal::Configuration.new(@deploy_with_roles) assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3" ], config.proxy_hosts end test "filtered proxy hosts" do assert_equal [ "1.1.1.1", "1.1.1.2" ], @config_with_roles.proxy_hosts @deploy_with_roles[:servers]["workers"]["proxy"] = true config = Kamal::Configuration.new(@deploy_with_roles) assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3" ], config.proxy_hosts end test "version no git repo" do ENV.delete("VERSION") Kamal::Git.expects(:used?).returns(nil) error = assert_raises(RuntimeError) { @config.version } assert_match /no git repository found/, error.message end test "version from git committed" do ENV.delete("VERSION") Kamal::Git.expects(:revision).returns("git-version") Kamal::Git.expects(:uncommitted_changes).returns("") assert_equal "git-version", @config.version end test "version from git uncommitted" do ENV.delete("VERSION") Kamal::Git.expects(:revision).returns("git-version") Kamal::Git.expects(:uncommitted_changes).returns("M file\n") assert_equal "git-version", @config.version end test "version from uncommitted context" do ENV.delete("VERSION") config = Kamal::Configuration.new(@deploy.tap { |c| c[:builder]["context"] = "." }) Kamal::Git.expects(:revision).returns("git-version") Kamal::Git.expects(:uncommitted_changes).returns("M file\n") assert_match /^git-version_uncommitted_[0-9a-f]{16}$/, config.version end test "version from env" do ENV["VERSION"] = "env-version" assert_equal "env-version", @config.version end test "version from arg" do @config.version = "arg-version" assert_equal "arg-version", @config.version end test "repository" do assert_equal "dhh/app", @config.repository config = Kamal::Configuration.new(@deploy.tap { |c| c[:registry].merge!({ "server" => "ghcr.io" }) }) assert_equal "ghcr.io/dhh/app", config.repository end test "absolute image" do assert_equal "dhh/app:missing", @config.absolute_image config = Kamal::Configuration.new(@deploy.tap { |c| c[:registry].merge!({ "server" => "ghcr.io" }) }) assert_equal "ghcr.io/dhh/app:missing", config.absolute_image end test "service with version" do assert_equal "app-missing", @config.service_with_version end test "hosts required for all roles" do # Empty server list for implied web role assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new @deploy.merge(servers: []) end # Empty server list assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new @deploy.merge(servers: { "web" => [] }) end # Missing hosts key assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new @deploy.merge(servers: { "web" => {} }) end # Empty hosts list assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new @deploy.merge(servers: { "web" => { "hosts" => [] } }) end # Nil hosts assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new @deploy.merge(servers: { "web" => { "hosts" => nil } }) end # One role with hosts, one without assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new @deploy.merge(servers: { "web" => %w[ web ], "workers" => { "hosts" => %w[ ] } }) end end test "allow_empty_roles" do assert_silent do Kamal::Configuration.new @deploy.merge(servers: { "web" => %w[ web ], "workers" => { "hosts" => %w[ ] } }, allow_empty_roles: true) end assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new @deploy.merge(servers: { "web" => %w[], "workers" => { "hosts" => %w[] } }, allow_empty_roles: true) end end test "volume_args" do assert_equal [ "--volume", "/local/path:/container/path" ], @config.volume_args end test "logging args default" do assert_equal [ "--log-opt", "max-size=\"10m\"" ], @config.logging_args end test "logging args with configured options" do config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(logging: { "options" => { "max-size" => "100m", "max-file" => 5 } }) }) assert_equal [ "--log-opt", "max-size=\"100m\"", "--log-opt", "max-file=\"5\"" ], config.logging_args end test "logging args with configured driver and options" do config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(logging: { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => 5 } }) }) assert_equal [ "--log-driver", "\"local\"", "--log-opt", "max-size=\"100m\"", "--log-opt", "max-file=\"5\"" ], config.logging_args end test "erb evaluation of yml config" do config = Kamal::Configuration.create_from config_file: Pathname.new(File.expand_path("fixtures/deploy.erb.yml", __dir__)) assert_equal "my-user", config.registry.username end test "destination is loaded into env" do dest_config_file = Pathname.new(File.expand_path("fixtures/deploy_for_dest.yml", __dir__)) config = Kamal::Configuration.create_from config_file: dest_config_file, destination: "world" assert_equal ENV["KAMAL_DESTINATION"], "world" end test "destination yml config merge" do dest_config_file = Pathname.new(File.expand_path("fixtures/deploy_for_dest.yml", __dir__)) config = Kamal::Configuration.create_from config_file: dest_config_file, destination: "world" assert_equal "1.1.1.1", config.all_hosts.first config = Kamal::Configuration.create_from config_file: dest_config_file, destination: "mars" assert_equal "1.1.1.3", config.all_hosts.first end test "destination yml config file missing" do dest_config_file = Pathname.new(File.expand_path("fixtures/deploy_for_dest.yml", __dir__)) assert_raises(RuntimeError) do config = Kamal::Configuration.create_from config_file: dest_config_file, destination: "missing" end end test "destination required" do dest_config_file = Pathname.new(File.expand_path("fixtures/deploy_for_required_dest.yml", __dir__)) assert_raises(ArgumentError, "You must specify a destination") do config = Kamal::Configuration.create_from config_file: dest_config_file end assert_nothing_raised do config = Kamal::Configuration.create_from config_file: dest_config_file, destination: "world" end end test "to_h" do expected_config = \ { roles: [ "web" ], hosts: [ "1.1.1.1", "1.1.1.2" ], primary_host: "1.1.1.1", version: "missing", repository: "dhh/app", absolute_image: "dhh/app:missing", service_with_version: "app-missing", ssh_options: { user: "root", port: 22, log_level: :fatal, keepalive: true, keepalive_interval: 30 }, sshkit: {}, volume_args: [ "--volume", "/local/path:/container/path" ], builder: { "arch" => "amd64" }, logging: [ "--log-opt", "max-size=\"10m\"" ] } assert_equal expected_config, @config.to_h end test "min version is lower" do config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(minimum_version: "0.0.1") }) assert_equal "0.0.1", config.minimum_version end test "min version is equal" do config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(minimum_version: Kamal::VERSION) }) assert_equal Kamal::VERSION, config.minimum_version end test "min version is higher" do assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new(@deploy.tap { |c| c.merge!(minimum_version: "10000.0.0") }) end end test "run directory" do config = Kamal::Configuration.new(@deploy) assert_equal ".kamal", config.run_directory end test "asset path" do assert_nil @config.asset_path assert_equal "foo", Kamal::Configuration.new(@deploy.merge!(asset_path: "foo")).asset_path end test "primary role" do assert_equal "web", @config.primary_role.name config = Kamal::Configuration.new(@deploy_with_roles.deep_merge({ servers: { "alternate_web" => { "hosts" => [ "1.1.1.4", "1.1.1.5" ] } }, primary_role: "alternate_web" })) assert_equal "alternate_web", config.primary_role.name assert_equal "1.1.1.4", config.primary_host assert config.role(:alternate_web).primary? assert config.role(:alternate_web).running_proxy? end test "primary role missing" do error = assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new(@deploy.merge(primary_role: "bar")) end assert_match /bar isn't defined/, error.message end test "retain_containers" do assert_equal 5, @config.retain_containers config = Kamal::Configuration.new(@deploy_with_roles.merge(retain_containers: 2)) assert_equal 2, config.retain_containers assert_raises(Kamal::ConfigurationError) { Kamal::Configuration.new(@deploy_with_roles.merge(retain_containers: 0)) } end test "extensions" do dest_config_file = Pathname.new(File.expand_path("fixtures/deploy_with_extensions.yml", __dir__)) config = Kamal::Configuration.create_from config_file: dest_config_file assert_equal config.role(:web_tokyo).running_proxy?, true assert_equal config.role(:web_chicago).running_proxy?, true end test "traefik hooks raise error" do Dir.mktmpdir do |dir| Dir.chdir(dir) do FileUtils.mkdir_p ".kamal/hooks" FileUtils.touch ".kamal/hooks/post-traefik-reboot" FileUtils.touch ".kamal/hooks/pre-traefik-reboot" exception = assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new(@deploy) end assert_equal "Found pre-traefik-reboot, post-traefik-reboot, these should be renamed to (pre|post)-proxy-reboot", exception.message end end end test "proxy ssl roles with no host" do @deploy_with_roles[:servers]["workers"]["proxy"] = { "ssl" => true } exception = assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new(@deploy_with_roles) end assert_equal "servers/workers/proxy: Must set a host to enable automatic SSL", exception.message end test "proxy ssl roles with multiple servers" do @deploy_with_roles[:servers]["workers"]["proxy"] = { "ssl" => true, "host" => "foo.example.com" } exception = assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new(@deploy_with_roles) end assert_equal "SSL is only supported on a single server unless you provide custom certificates, found 2 servers for role workers", exception.message end test "two proxy ssl roles with same host" do @deploy_with_roles[:servers]["web"] = { "hosts" => [ "1.1.1.1" ], "proxy" => { "ssl" => true, "host" => "foo.example.com" } } @deploy_with_roles[:servers]["workers"] = { "hosts" => [ "1.1.1.1" ], "proxy" => { "ssl" => true, "host" => "foo.example.com" } } exception = assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new(@deploy_with_roles) end assert_equal "Different roles can't share the same host for SSL: foo.example.com", exception.message end test "two proxy ssl roles with same host in a hosts array" do @deploy_with_roles[:servers]["web"] = { "hosts" => [ "1.1.1.1" ], "proxy" => { "ssl" => true, "hosts" => [ "foo.example.com", "bar.example.com" ] } } @deploy_with_roles[:servers]["workers"] = { "hosts" => [ "1.1.1.1" ], "proxy" => { "ssl" => true, "hosts" => [ "www.example.com", "foo.example.com" ] } } exception = assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new(@deploy_with_roles) end assert_equal "Different roles can't share the same host for SSL: foo.example.com", exception.message end test "hooks_output default is nil" do assert_nil @config.hooks_output_for("pre-deploy") end test "hooks_output global setting" do config = Kamal::Configuration.new(@deploy.merge(hooks_output: :verbose)) assert_equal :verbose, config.hooks_output_for("pre-deploy") assert_equal :verbose, config.hooks_output_for("post-deploy") end test "hooks_output per-hook settings" do config = Kamal::Configuration.new(@deploy.merge( hooks_output: { "pre-deploy" => :verbose, "post-deploy" => :quiet } )) assert_equal :verbose, config.hooks_output_for("pre-deploy") assert_equal :quiet, config.hooks_output_for("post-deploy") end test "hooks_output per-hook returns nil for unconfigured hooks" do config = Kamal::Configuration.new(@deploy.merge( hooks_output: { "pre-deploy" => :verbose } )) assert_equal :verbose, config.hooks_output_for("pre-deploy") assert_nil config.hooks_output_for("post-deploy") end test "hooks_output invalid raises error" do error = assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new(@deploy.merge(hooks_output: :invalid)) end assert_match /Invalid hooks_output 'invalid'/, error.message end test "hooks_output invalid per-hook raises error" do error = assert_raises(Kamal::ConfigurationError) do Kamal::Configuration.new(@deploy.merge(hooks_output: { "pre-deploy" => :invalid })) end assert_match /Invalid hooks_output 'invalid' for hook 'pre-deploy'/, error.message end end ================================================ FILE: test/env_file_test.rb ================================================ require "test_helper" class EnvFileTest < ActiveSupport::TestCase test "to_s" do env = { "foo" => "bar", "baz" => "haz" } assert_equal "foo=bar\nbaz=haz\n", \ Kamal::EnvFile.new(env).to_s end test "to_s won't escape '#'" do env = { "foo" => '#$foo', "bar" => '#{bar}' } assert_equal "foo=\#$foo\nbar=\#{bar}\n", \ Kamal::EnvFile.new(env).to_s end test "to_str won't escape chinese characters" do env = { "foo" => '你好 means hello, "欢迎" means welcome, that\'s simple! 😃 {smile}' } assert_equal "foo=你好 means hello, \"欢迎\" means welcome, that's simple! 😃 {smile}\n", Kamal::EnvFile.new(env).to_s end test "to_s won't escape japanese characters" do env = { "foo" => 'こんにちは means hello, "ようこそ" means welcome, that\'s simple! 😃 {smile}' } assert_equal "foo=こんにちは means hello, \"ようこそ\" means welcome, that's simple! 😃 {smile}\n", \ Kamal::EnvFile.new(env).to_s end test "to_s won't escape korean characters" do env = { "foo" => '안녕하세요 means hello, "어서 오십시오" means welcome, that\'s simple! 😃 {smile}' } assert_equal "foo=안녕하세요 means hello, \"어서 오십시오\" means welcome, that's simple! 😃 {smile}\n", \ Kamal::EnvFile.new(env).to_s end test "to_s empty" do assert_equal "\n", Kamal::EnvFile.new({}).to_s end test "to_s escaped newline" do env = { "foo" => "hello\\nthere" } assert_equal "foo=hello\\\\nthere\n", \ Kamal::EnvFile.new(env).to_s ensure ENV.delete "PASSWORD" end test "to_s newline" do env = { "foo" => "hello\nthere" } assert_equal "foo=hello\\nthere\n", \ Kamal::EnvFile.new(env).to_s ensure ENV.delete "PASSWORD" end test "stringIO conversion" do env = { "foo" => "bar", "baz" => "haz" } assert_equal "foo=bar\nbaz=haz\n", \ StringIO.new(Kamal::EnvFile.new(env)).read end end ================================================ FILE: test/fixtures/deploy.elsewhere.yml ================================================ service: app3 image: dhh/app3 servers: - "1.1.1.3" - "1.1.1.4" registry: username: user password: pw builder: arch: amd64 aliases: other_config: config -c config/deploy2.yml ================================================ FILE: test/fixtures/deploy.erb.yml ================================================ service: app image: dhh/app servers: - 1.1.1.1 - 1.1.1.2 env: REDIS_URL: redis://x/y registry: server: registry.digitalocean.com username: <%= "my-user" %> password: <%= "my-password" %> builder: arch: amd64 ================================================ FILE: test/fixtures/deploy.yml ================================================ service: app image: dhh/app servers: - "1.1.1.1" - "1.1.1.2" registry: username: user password: pw builder: arch: amd64 aliases: other_config: config -c config/deploy2.yml other_destination_config: config -d elsewhere ================================================ FILE: test/fixtures/deploy2.yml ================================================ service: app2 image: dhh/app2 servers: - "1.1.1.1" - "1.1.1.2" registry: username: user2 password: pw2 builder: arch: amd64 aliases: other_config: config -c config/deploy2.yml ================================================ FILE: test/fixtures/deploy_for_dest.mars.yml ================================================ servers: - 1.1.1.3 - 1.1.1.4 env: REDIS_URL: redis://a/b ================================================ FILE: test/fixtures/deploy_for_dest.world.yml ================================================ servers: - 1.1.1.1 - 1.1.1.2 env: REDIS_URL: redis://x/y ================================================ FILE: test/fixtures/deploy_for_dest.yml ================================================ service: app image: dhh/app registry: server: registry.digitalocean.com username: <%= "my-user" %> password: <%= "my-password" %> builder: arch: amd64 ================================================ FILE: test/fixtures/deploy_for_required_dest.world.yml ================================================ servers: - 1.1.1.1 - 1.1.1.2 env: REDIS_URL: redis://x/y ================================================ FILE: test/fixtures/deploy_for_required_dest.yml ================================================ service: app image: dhh/app registry: server: registry.digitalocean.com username: <%= "my-user" %> password: <%= "my-password" %> builder: arch: amd64 require_destination: true aliases: world_deploy: deploy -d world ================================================ FILE: test/fixtures/deploy_primary_web_role_override.yml ================================================ service: app image: dhh/app servers: web_chicago: proxy: {} hosts: - 1.1.1.1 - 1.1.1.2 web_tokyo: proxy: {} hosts: - 1.1.1.3 - 1.1.1.4 env: REDIS_URL: redis://x/y registry: server: registry.digitalocean.com username: user password: pw builder: arch: amd64 primary_role: web_tokyo ================================================ FILE: test/fixtures/deploy_simple.yml ================================================ service: app image: dhh/app servers: - "1.1.1.1" - "1.1.1.2" registry: username: user password: pw builder: arch: amd64 ================================================ FILE: test/fixtures/deploy_with_accessories.yml ================================================ service: app image: dhh/app servers: web: - "1.1.1.1" - "1.1.1.2" workers: - "1.1.1.3" - "1.1.1.4" registry: username: user password: pw builder: arch: amd64 accessories: mysql: image: mysql:5.7 host: 1.1.1.3 port: 3306 env: clear: MYSQL_ROOT_HOST: '%' secret: - MYSQL_ROOT_PASSWORD files: - test/fixtures/files/my.cnf:/etc/mysql/my.cnf directories: - data:/var/lib/mysql redis: image: redis:latest roles: - web port: 6379 directories: - data:/data readiness_delay: 0 ================================================ FILE: test/fixtures/deploy_with_accessories_on_independent_server.yml ================================================ service: app image: dhh/app servers: web: - "1.1.1.1" - "1.1.1.2" workers: - "1.1.1.3" - "1.1.1.4" registry: username: user password: pw builder: arch: amd64 accessories: mysql: image: mysql:5.7 host: 1.1.1.5 port: 3306 env: clear: MYSQL_ROOT_HOST: '%' secret: - MYSQL_ROOT_PASSWORD files: - test/fixtures/files/my.cnf:/etc/mysql/my.cnf directories: - data:/var/lib/mysql redis: image: redis:latest roles: - web port: 6379 directories: - data:/data readiness_delay: 0 ================================================ FILE: test/fixtures/deploy_with_accessories_with_different_registries.yml ================================================ service: app image: dhh/app servers: web: - "1.1.1.1" - "1.1.1.2" workers: - "1.1.1.3" - "1.1.1.4" registry: server: private.registry username: user password: pw builder: arch: amd64 accessories: mysql: image: private.registry/mysql:5.7 host: 1.1.1.3 port: 3306 env: clear: MYSQL_ROOT_HOST: '%' secret: - MYSQL_ROOT_PASSWORD files: - test/fixtures/files/my.cnf:/etc/mysql/my.cnf directories: - data:/var/lib/mysql redis: image: redis:latest roles: - web port: 6379 directories: - data:/data busybox: service: custom-box image: busybox:latest host: 1.1.1.3 registry: server: other.registry username: other_user password: other_pw readiness_delay: 0 ================================================ FILE: test/fixtures/deploy_with_aliases.yml ================================================ service: app image: dhh/app servers: web: - 1.1.1.1 - 1.1.1.2 workers: hosts: - 1.1.1.3 - 1.1.1.4 console: hosts: - 1.1.1.5 builder: arch: amd64 registry: username: user password: pw aliases: info: details console: app exec --reuse -p -r console "bin/console" exec: app exec --reuse -p -r console rails: app exec --reuse -p -r console rails primary_details: details -p deploy_secondary: deploy -d secondary ================================================ FILE: test/fixtures/deploy_with_assets.yml ================================================ service: app image: dhh/app servers: - "1.1.1.1" - "1.1.1.2" registry: username: user password: pw builder: arch: amd64 asset_path: /public/assets ================================================ FILE: test/fixtures/deploy_with_boot_strategy.yml ================================================ service: app image: dhh/app servers: web: - "1.1.1.1" - "1.1.1.2" workers: - "1.1.1.3" - "1.1.1.4" builder: arch: amd64 registry: username: user password: pw boot: limit: 3 wait: 2 ================================================ FILE: test/fixtures/deploy_with_cloud_builder.yml ================================================ service: app image: dhh/app servers: web: - "1.1.1.1" - "1.1.1.2" workers: - "1.1.1.3" - "1.1.1.4" registry: username: user password: pw accessories: mysql: image: mysql:5.7 host: 1.1.1.3 port: 3306 env: clear: MYSQL_ROOT_HOST: '%' secret: - MYSQL_ROOT_PASSWORD files: - test/fixtures/files/my.cnf:/etc/mysql/my.cnf directories: - data:/var/lib/mysql redis: image: redis:latest roles: - web port: 6379 directories: - data:/data readiness_delay: 0 builder: arch: <%= Kamal::Utils.docker_arch == "arm64" ? "amd64" : "arm64" %> driver: cloud example_org/cloud_builder ================================================ FILE: test/fixtures/deploy_with_env_tags.yml ================================================ service: app image: dhh/app servers: web: - 1.1.1.1: site1 - 1.1.1.2: [ site1 experimental ] - 1.2.1.1: site2 - 1.2.1.2: site2 workers: - 1.1.1.3: site1 - 1.1.1.4: site1 - 1.2.1.3: site2 - 1.2.1.4: [ site2 experimental ] builder: arch: amd64 env: clear: TEST: "root" EXPERIMENT: "disabled" tags: site1: SITE: site1 site2: SITE: site2 experimental: EXPERIMENT: "enabled" registry: username: user password: pw ================================================ FILE: test/fixtures/deploy_with_error_pages.yml ================================================ service: app image: dhh/app servers: - "1.1.1.1" - "1.1.1.2" registry: username: user password: pw builder: arch: amd64 error_pages_path: public ================================================ FILE: test/fixtures/deploy_with_extensions.yml ================================================ x-web: &web proxy: {} service: app image: dhh/app servers: web_chicago: <<: *web hosts: - 1.1.1.1 - 1.1.1.2 web_tokyo: <<: *web hosts: - 1.1.1.3 - 1.1.1.4 env: REDIS_URL: redis://x/y registry: server: registry.digitalocean.com username: user password: pw builder: arch: amd64 primary_role: web_tokyo ================================================ FILE: test/fixtures/deploy_with_hybrid_builder.yml ================================================ service: app image: dhh/app servers: web: - "1.1.1.1" - "1.1.1.2" workers: - "1.1.1.3" - "1.1.1.4" registry: username: user password: pw accessories: mysql: image: mysql:5.7 host: 1.1.1.3 port: 3306 env: clear: MYSQL_ROOT_HOST: '%' secret: - MYSQL_ROOT_PASSWORD files: - test/fixtures/files/my.cnf:/etc/mysql/my.cnf directories: - data:/var/lib/mysql redis: image: redis:latest roles: - web port: 6379 directories: - data:/data readiness_delay: 0 builder: arch: - arm64 - amd64 remote: ssh://app@1.1.1.5 ================================================ FILE: test/fixtures/deploy_with_local_registry.yml ================================================ service: app image: dhh/app servers: web: - "1.1.1.1" - "1.1.1.2" registry: server: localhost:5000 builder: arch: amd64 ================================================ FILE: test/fixtures/deploy_with_local_registry_and_accessories.yml ================================================ service: app image: dhh/app servers: web: - "1.1.1.1" - "1.1.1.2" workers: - "1.1.1.3" - "1.1.1.4" registry: server: localhost:5000 builder: arch: amd64 accessories: mysql: image: mysql:5.7 host: 1.1.1.3 port: 3306 env: clear: MYSQL_ROOT_HOST: '%' secret: - MYSQL_ROOT_PASSWORD files: - test/fixtures/files/my.cnf:/etc/mysql/my.cnf directories: - data:/var/lib/mysql redis: image: redis:latest roles: - web port: 6379 directories: - data:/data busybox: service: custom-box image: busybox:latest host: 1.1.1.3 registry: server: other.registry username: other_user password: other_pw readiness_delay: 0 ================================================ FILE: test/fixtures/deploy_with_local_registry_and_remote_builder.yml ================================================ service: app image: dhh/app servers: web: - "1.1.1.1" - "1.1.1.2" registry: server: localhost:5000 builder: arch: <%= Kamal::Utils.docker_arch == "arm64" ? "amd64" : "arm64" %> remote: ssh://app@1.1.1.5 ================================================ FILE: test/fixtures/deploy_with_local_registry_and_remote_builder_with_port.yml ================================================ service: app image: dhh/app servers: web: - "1.1.1.1" - "1.1.1.2" registry: server: localhost:5000 builder: arch: <%= Kamal::Utils.docker_arch == "arm64" ? "amd64" : "arm64" %> remote: ssh://app@1.1.1.5:2222 ================================================ FILE: test/fixtures/deploy_with_multiple_proxy_roles.yml ================================================ # actual config service: app image: dhh/app servers: web: hosts: - 1.1.1.1 - 1.1.1.2 env: ROLE: "web" proxy: true web_tokyo: hosts: - 1.1.1.3 - 1.1.1.4 env: ROLE: "web" proxy: true workers: cmd: bin/jobs hosts: - 1.1.1.1 - 1.1.1.2 workers_tokyo: cmd: bin/jobs hosts: - 1.1.1.3 - 1.1.1.4 builder: arch: amd64 env: REDIS_URL: redis://x/y registry: server: registry.digitalocean.com username: user password: pw ================================================ FILE: test/fixtures/deploy_with_only_workers.yml ================================================ service: app image: dhh/app servers: workers: proxy: false hosts: - 1.1.1.1 - 1.1.1.2 primary_role: workers registry: username: user password: pw builder: arch: amd64 ================================================ FILE: test/fixtures/deploy_with_parallel_roles.yml ================================================ service: app image: dhh/app servers: web: - "1.1.1.1" - "1.1.1.2" workers: - "1.1.1.1" - "1.1.1.3" builder: arch: amd64 registry: username: user password: pw boot: parallel_roles: true ================================================ FILE: test/fixtures/deploy_with_proxy.yml ================================================ service: app image: dhh/app servers: web: - "1.1.1.1" - "1.1.1.2" workers: - "1.1.1.3" - "1.1.1.4" registry: username: user password: pw builder: arch: amd64 accessories: mysql: image: mysql:5.7 host: 1.1.1.3 port: 3306 env: clear: MYSQL_ROOT_HOST: '%' secret: - MYSQL_ROOT_PASSWORD files: - test/fixtures/files/my.cnf:/etc/mysql/my.cnf directories: - data:/var/lib/mysql redis: image: redis:latest roles: - web port: 6379 directories: - data:/data readiness_delay: 0 deploy_timeout: 6 ================================================ FILE: test/fixtures/deploy_with_proxy_roles.yml ================================================ service: app image: dhh/app servers: web: hosts: - "1.1.1.1" - "1.1.1.2" web2: hosts: - "1.1.1.3" - "1.1.1.4" proxy: response_timeout: 15 registry: username: user password: pw builder: arch: amd64 proxy: response_timeout: 10 accessories: mysql: image: mysql:5.7 host: 1.1.1.3 port: 3306 env: clear: MYSQL_ROOT_HOST: '%' secret: - MYSQL_ROOT_PASSWORD files: - test/fixtures/files/my.cnf:/etc/mysql/my.cnf directories: - data:/var/lib/mysql redis: image: redis:latest roles: - web port: 6379 directories: - data:/data readiness_delay: 0 deploy_timeout: 6 ================================================ FILE: test/fixtures/deploy_with_proxy_run_config.yml ================================================ service: app image: dhh/app servers: web: - "1.1.1.1" - "1.1.1.2" workers: - "1.1.1.3" - "1.1.1.4" registry: username: user password: pw builder: arch: amd64 proxy: run: registry: registry:4443 debug: true metrics_port: 9090 options: cpus: 1.5 accessories: mysql: image: mysql:5.7 host: 1.1.1.3 port: 3306 env: clear: MYSQL_ROOT_HOST: '%' secret: - MYSQL_ROOT_PASSWORD files: - test/fixtures/files/my.cnf:/etc/mysql/my.cnf directories: - data:/var/lib/mysql proxy: run: debug: false metrics_port: 9190 redis: image: redis:latest roles: - web port: 6379 directories: - data:/data readiness_delay: 0 deploy_timeout: 6 ================================================ FILE: test/fixtures/deploy_with_proxy_run_config_conflicts.yml ================================================ service: app image: dhh/app servers: web: - "1.1.1.1" - "1.1.1.2" workers: - "1.1.1.3" - "1.1.1.4" registry: username: user password: pw builder: arch: amd64 proxy: run: debug: true accessories: mysql: image: mysql:5.7 host: 1.1.1.2 port: 3306 env: clear: MYSQL_ROOT_HOST: '%' secret: - MYSQL_ROOT_PASSWORD files: - test/fixtures/files/my.cnf:/etc/mysql/my.cnf directories: - data:/var/lib/mysql proxy: run: debug: false redis: image: redis:latest roles: - web port: 6379 directories: - data:/data readiness_delay: 0 deploy_timeout: 6 ================================================ FILE: test/fixtures/deploy_with_remote_builder.yml ================================================ service: app image: dhh/app servers: web: - "1.1.1.1" - "1.1.1.2" workers: - "1.1.1.3" - "1.1.1.4" registry: username: user password: pw accessories: mysql: image: mysql:5.7 host: 1.1.1.3 port: 3306 env: clear: MYSQL_ROOT_HOST: '%' secret: - MYSQL_ROOT_PASSWORD files: - test/fixtures/files/my.cnf:/etc/mysql/my.cnf directories: - data:/var/lib/mysql redis: image: redis:latest roles: - web port: 6379 directories: - data:/data readiness_delay: 0 builder: arch: <%= Kamal::Utils.docker_arch == "arm64" ? "amd64" : "arm64" %> remote: ssh://app@1.1.1.5 ================================================ FILE: test/fixtures/deploy_with_remote_builder_and_custom_ports.yml ================================================ service: app image: dhh/app servers: web: - "1.1.1.1" - "1.1.1.2" workers: - "1.1.1.3" - "1.1.1.4" registry: username: user password: pw accessories: mysql: image: mysql:5.7 host: 1.1.1.3 port: 3306 env: clear: MYSQL_ROOT_HOST: '%' secret: - MYSQL_ROOT_PASSWORD files: - test/fixtures/files/my.cnf:/etc/mysql/my.cnf directories: - data:/var/lib/mysql redis: image: redis:latest roles: - web port: 6379 directories: - data:/data readiness_delay: 0 ssh: user: root port: 22 builder: arch: <%= Kamal::Utils.docker_arch == "arm64" ? "amd64" : "arm64" %> remote: ssh://app@1.1.1.5:2122 ================================================ FILE: test/fixtures/deploy_with_roles.yml ================================================ service: app image: dhh/app servers: web: - 1.1.1.1 - 1.1.1.2 workers: hosts: - 1.1.1.3 - 1.1.1.4 env: REDIS_URL: redis://x/y registry: server: registry.digitalocean.com username: user password: pw builder: arch: amd64 deploy_timeout: 1 ================================================ FILE: test/fixtures/deploy_with_roles_workers_primary.yml ================================================ service: app image: dhh/app servers: workers: - 1.1.1.1 - 1.1.1.2 web: - 1.1.1.3 - 1.1.1.4 env: REDIS_URL: redis://x/y registry: server: registry.digitalocean.com username: user password: pw builder: arch: amd64 deploy_timeout: 1 primary_role: workers ================================================ FILE: test/fixtures/deploy_with_secrets.yml ================================================ service: app image: dhh/app servers: - "1.1.1.1" - "1.1.1.2" registry: username: user password: pw env: secret: - PASSWORD builder: arch: amd64 ================================================ FILE: test/fixtures/deploy_with_single_accessory.yml ================================================ service: app image: dhh/app servers: web: - "1.1.1.1" - "1.1.1.2" workers: - "1.1.1.3" - "1.1.1.4" registry: username: user password: pw builder: arch: amd64 accessories: mysql: image: mysql:5.7 host: 1.1.1.5 port: 3306 env: clear: MYSQL_ROOT_HOST: '%' secret: - MYSQL_ROOT_PASSWORD files: - test/fixtures/files/my.cnf:/etc/mysql/my.cnf directories: - data:/var/lib/mysql ================================================ FILE: test/fixtures/deploy_with_two_roles_one_host.yml ================================================ service: app image: dhh/app servers: workers: hosts: - 1.1.1.1 web: hosts: - 1.1.1.1 env: REDIS_URL: redis://x/y registry: server: registry.digitalocean.com username: user password: pw builder: arch: amd64 ================================================ FILE: test/fixtures/deploy_with_uncommon_hostnames.yml ================================================ service: app image: dhh/app servers: - "this-hostname-with-random-part-is-too-long.example.com" - "this-hostname-is-really-unacceptably-long-to-be-honest.example.com" registry: username: user password: pw builder: arch: amd64 ================================================ FILE: test/fixtures/deploy_without_clone.yml ================================================ service: app image: dhh/app servers: web: - "1.1.1.1" - "1.1.1.2" workers: - "1.1.1.3" - "1.1.1.4" registry: username: user password: pw accessories: mysql: image: mysql:5.7 host: 1.1.1.3 port: 3306 env: clear: MYSQL_ROOT_HOST: '%' secret: - MYSQL_ROOT_PASSWORD files: - test/fixtures/files/my.cnf:/etc/mysql/my.cnf directories: - data:/var/lib/mysql redis: image: redis:latest roles: - web port: 6379 directories: - data:/data readiness_delay: 0 builder: arch: amd64 context: "." ================================================ FILE: test/fixtures/deploy_without_parallel_roles.yml ================================================ service: app image: dhh/app servers: web: - "1.1.1.1" - "1.1.1.2" workers: - "1.1.1.1" - "1.1.1.3" builder: arch: amd64 registry: username: user password: pw ================================================ FILE: test/fixtures/files/my.cnf ================================================ # MySQL Config ================================================ FILE: test/fixtures/files/structure.sql.erb ================================================ <%= "This was dynamically expanded" %> <%= ENV["MYSQL_ROOT_HOST"] %> <%= ENV["MYSQL_ROOT_PASSWORD"] %> <%= ENV["ENV_VAR"] %> ================================================ FILE: test/git_test.rb ================================================ require "test_helper" class GitTest < ActiveSupport::TestCase test "uncommitted changes exist" do Kamal::Git.expects(:`).with("git status --porcelain").returns("M file\n") assert_equal "M file", Kamal::Git.uncommitted_changes end test "uncommitted changes do not exist" do Kamal::Git.expects(:`).with("git status --porcelain").returns("") assert_equal "", Kamal::Git.uncommitted_changes end end ================================================ FILE: test/integration/accessory_test.rb ================================================ require_relative "integration_test" class AccessoryTest < IntegrationTest test "boot, stop, start, restart, logs, remove" do kamal :accessory, :boot, :busybox assert_accessory_running :busybox assert_accessory_volume_mount_options :busybox assert_accessory_file_mode_and_owner :busybox assert_accessory_directory_mode_and_owner :busybox kamal :accessory, :stop, :busybox assert_accessory_not_running :busybox kamal :accessory, :start, :busybox assert_accessory_running :busybox kamal :accessory, :restart, :busybox assert_accessory_running :busybox logs = kamal :accessory, :logs, :busybox, capture: true assert_match /Starting busybox.../, logs boot = kamal :accessory, :boot, :busybox, capture: true assert_match /Skipping booting `busybox` on vm1, vm2, a container already exists/, boot kamal :accessory, :remove, :busybox, "-y" assert_accessory_not_running :busybox end test "proxied: boot, stop, start, restart, logs, remove" do @app = "app_with_proxied_accessory" kamal :proxy, :boot kamal :accessory, :boot, :netcat assert_accessory_running :netcat assert_netcat_is_up kamal :accessory, :stop, :netcat assert_accessory_not_running :netcat assert_netcat_not_found kamal :accessory, :start, :netcat assert_accessory_running :netcat assert_netcat_is_up kamal :accessory, :restart, :netcat assert_accessory_running :netcat assert_netcat_is_up kamal :accessory, :remove, :netcat, "-y" assert_accessory_not_running :netcat assert_netcat_not_found end private def assert_accessory_running(name) assert_match /registry:4443\/busybox:1.36.0 "sh -c 'echo \\"Start/, accessory_details(name) end def assert_accessory_not_running(name) assert_no_match /registry:4443\/busybox:1.36.0 "sh -c 'echo \\"Start/, accessory_details(name) end def assert_accessory_volume_mount_options(name) mounts = docker_compose("exec vm1 docker inspect custom-busybox --format '{{json .Mounts}}'", capture: true) assert_match %r{/data.*"RW":false}, mounts, "Expected read-only mount option (:ro) to be applied" end def assert_accessory_file_mode_and_owner(name) file_stat = docker_compose("exec vm1 stat -c '%a %u:%g' /root/custom-busybox/etc/busybox.conf", capture: true) assert_match /640 1000:1000/, file_stat, "Expected file to have 640 mode and 1000:1000 owner" end def assert_accessory_directory_mode_and_owner(name) dir_stat = docker_compose("exec vm1 stat -c '%a %u:%g' /root/custom-busybox/data", capture: true) assert_match /750 1000:1000/, dir_stat, "Expected directory to have 750 mode and 1000:1000 owner" end def accessory_details(name) kamal :accessory, :details, name, capture: true end def assert_netcat_is_up response = netcat_response debug_response_code(response, "200") assert_equal "200", response.code end def assert_netcat_not_found response = netcat_response debug_response_code(response, "404") assert_equal "404", response.code end def netcat_response uri = URI.parse("http://127.0.0.1:12345/up") http = Net::HTTP.new(uri.host, uri.port) request = Net::HTTP::Get.new(uri) request["Host"] = "netcat" http.request(request) end end ================================================ FILE: test/integration/app_test.rb ================================================ require_relative "integration_test" class AppTest < IntegrationTest test "stop, start, boot, logs, images, containers, exec, remove" do kamal :deploy assert_app_is_up kamal :app, :stop assert_app_not_found kamal :app, :start # kamal app start does not wait wait_for_app_to_be_up output = kamal :app, :boot, "--verbose", capture: true assert_match "Booting app on vm1,vm2...", output assert_match "Booted app on vm1,vm2...", output wait_for_app_to_be_up logs = kamal :app, :logs, capture: true assert_match "App Host: vm1", logs assert_match "App Host: vm2", logs assert_match "GET /version HTTP/1.1", logs images = kamal :app, :images, capture: true assert_match "App Host: vm1", images assert_match "App Host: vm2", images assert_match /localhost:5000\/app\s+#{latest_app_version}/, images assert_match /localhost:5000\/app\s+latest/, images containers = kamal :app, :containers, capture: true assert_match "App Host: vm1", containers assert_match "App Host: vm2", containers assert_match "localhost:5000/app:#{latest_app_version}", containers assert_match "localhost:5000/app:latest", containers exec_output = kamal :app, :exec, :ps, capture: true assert_match "App Host: vm1", exec_output assert_match "App Host: vm2", exec_output assert_match /1 root 0:\d\d ps/, exec_output exec_output = kamal :app, :exec, "--reuse", :ps, capture: true assert_match "App Host: vm2", exec_output assert_match "App Host: vm1", exec_output assert_match /1 root 0:\d\d nginx/, exec_output kamal :app, :maintenance assert_app_in_maintenance kamal :app, :live assert_app_is_up kamal :app, :remove assert_app_not_found assert_app_directory_removed end test "parallel roles" do @app = "app_with_parallel_roles" version = latest_app_version kamal :deploy assert_app_is_up version: version assert_container_running host: :vm1, name: "app_with_parallel_roles-web-#{version}" assert_container_running host: :vm1, name: "app_with_parallel_roles-workers-#{version}" assert_container_running host: :vm2, name: "app_with_parallel_roles-web-#{version}" kamal :app, :stop assert_app_not_found kamal :app, :start wait_for_app_to_be_up logs = kamal :app, :logs, capture: true assert_match /role=web.* on vm1/, logs assert_match /role=web.* on vm2/, logs assert_match /role=workers.* on vm1/, logs # Images runs once per host (not per role) images = kamal :app, :images, capture: true assert_match "App Host: vm1", images assert_match "App Host: vm2", images assert_equal 2, images.scan(/App Host:/).count # Containers runs once per host (not per role) containers = kamal :app, :containers, capture: true assert_match "App Host: vm1", containers assert_match "App Host: vm2", containers assert_match "app_with_parallel_roles-web", containers assert_match "app_with_parallel_roles-workers", containers assert_equal 2, containers.scan(/App Host:/).count # Exec runs per role per host: web on vm1, web on vm2, workers on vm1 exec_output = kamal :app, :exec, :hostname, capture: true assert_match /app_with_parallel_roles-web-exec-.* on vm1/, exec_output assert_match /app_with_parallel_roles-web-exec-.* on vm2/, exec_output assert_match /app_with_parallel_roles-workers-exec-.* on vm1/, exec_output assert_equal 3, exec_output.scan(/App Host:/).count kamal :app, :maintenance assert_app_in_maintenance kamal :app, :live assert_app_is_up end test "custom error pages" do @app = "app_with_roles" kamal :deploy assert_app_is_up kamal :app, :maintenance assert_app_in_maintenance message: "Custom Maintenance Page" kamal :app, :live kamal :app, :maintenance, "--message", "\"Testing Maintence Mode\"" assert_app_in_maintenance message: "Custom Maintenance Page: Testing Maintence Mode" second_version = update_app_rev kamal :redeploy kamal :app, :maintenance assert_app_in_maintenance message: "Custom Maintenance Page" end end ================================================ FILE: test/integration/broken_deploy_test.rb ================================================ require_relative "integration_test" class BrokenDeployTest < IntegrationTest test "deploying a bad image" do @app = "app_with_roles" first_version = latest_app_version kamal :deploy assert_app_is_up version: first_version assert_container_running host: :vm3, name: "app_with_roles-workers-#{first_version}" second_version = break_app output = kamal :deploy, raise_on_error: false, capture: true assert_failed_deploy output assert_app_is_up version: first_version assert_container_running host: :vm3, name: "app_with_roles-workers-#{first_version}" assert_container_not_running host: :vm3, name: "app_with_roles-workers-#{second_version}" end private def assert_failed_deploy(output) assert_match "Waiting for the first healthy web container before booting workers on vm3...", output assert_match /First web container is unhealthy on vm[12], not booting any other roles/, output assert_match "First web container is unhealthy, not booting workers on vm3", output assert_match "nginx: [emerg] unexpected end of file, expecting \";\" or \"}\" in /etc/nginx/conf.d/default.conf:2", output end end ================================================ FILE: test/integration/docker/deployer/.dockerignore ================================================ Dockerfile ================================================ FILE: test/integration/docker/deployer/Dockerfile ================================================ FROM ruby:3.2 WORKDIR / ENV VERBOSE=true RUN apt-get update --fix-missing && apt-get install -y ca-certificates openssh-client curl gnupg RUN install -m 0755 -d /etc/apt/keyrings RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg RUN chmod a+r /etc/apt/keyrings/docker.gpg RUN echo \ "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \ "$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \ tee /etc/apt/sources.list.d/docker.list > /dev/null RUN apt-get update --fix-missing && apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin COPY . . RUN rm -rf /root/.ssh && \ ln -s /shared/ssh /root/.ssh && \ mkdir -p /etc/docker/certs.d/registry:4443 && \ ln -s /shared/certs/domain.crt /etc/docker/certs.d/registry:4443/ca.crt && \ git config --global user.email "deployer@example.com" && \ git config --global user.name "Deployer" && \ cd app && git init && git add . && git commit -am "Initial version" && \ cd /app_with_custom_certificate && git init && git add . && git commit -am "Initial version" && \ cd /app_with_roles && git init && git add . && git commit -am "Initial version" && \ cd /app_with_traefik && git init && git add . && git commit -am "Initial version" && \ cd /app_with_proxied_accessory && git init && git add . && git commit -am "Initial version" && \ cd /app_with_parallel_roles && git init && git add . && git commit -am "Initial version" && \ cd /app_with_destinations && git init && git add . && git commit -am "Initial version" HEALTHCHECK --interval=1s CMD pgrep sleep CMD ["./boot.sh"] ================================================ FILE: test/integration/docker/deployer/app/.kamal/hooks/docker-setup ================================================ #!/bin/sh echo "Docker set up!" mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/docker-setup ================================================ FILE: test/integration/docker/deployer/app/.kamal/hooks/post-app-boot ================================================ #!/bin/sh echo "Booted app on ${KAMAL_HOSTS}..." mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/post-app-boot ================================================ FILE: test/integration/docker/deployer/app/.kamal/hooks/post-deploy ================================================ #!/bin/sh echo "Finished deploy!" mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/post-deploy ================================================ FILE: test/integration/docker/deployer/app/.kamal/hooks/post-proxy-reboot ================================================ #!/bin/sh echo "Rebooted kamal-proxy on ${KAMAL_HOSTS}" mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/post-proxy-reboot ================================================ FILE: test/integration/docker/deployer/app/.kamal/hooks/pre-app-boot ================================================ #!/bin/sh echo "Booting app on ${KAMAL_HOSTS}..." mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-app-boot ================================================ FILE: test/integration/docker/deployer/app/.kamal/hooks/pre-build ================================================ #!/bin/sh echo "About to build and push..." mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-build ================================================ FILE: test/integration/docker/deployer/app/.kamal/hooks/pre-connect ================================================ #!/bin/sh echo "About to lock..." env mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-connect ================================================ FILE: test/integration/docker/deployer/app/.kamal/hooks/pre-deploy ================================================ #!/bin/sh set -e echo "Deployed!" mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-deploy ================================================ FILE: test/integration/docker/deployer/app/.kamal/hooks/pre-proxy-reboot ================================================ #!/bin/sh echo "Rebooting kamal-proxy on ${KAMAL_HOSTS}..." mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-proxy-reboot ================================================ FILE: test/integration/docker/deployer/app/.kamal/secrets ================================================ SECRETS=$(kamal secrets fetch --adapter test --account test INTERPOLATED_SECRET1 INTERPOLATED_SECRET2 INTERPOLATED_中文 INTERPOLATED_LPARENRPAREN) INTERPOLATED_SECRET1=$(kamal secrets extract INTERPOLATED_SECRET1 ${SECRETS}) INTERPOLATED_SECRET2=$(kamal secrets extract INTERPOLATED_SECRET2 ${SECRETS}) INTERPOLATED_SECRET3=$(kamal secrets extract INTERPOLATED_中文 ${SECRETS}) INTERPOLATED_SECRET4=$(kamal secrets extract INTERPOLATED_LPARENRPAREN ${SECRETS}) ================================================ FILE: test/integration/docker/deployer/app/.kamal/secrets-common ================================================ SECRET_TOKEN='1234 with "中文"' SECRET_TAG='TAGME' ================================================ FILE: test/integration/docker/deployer/app/Dockerfile ================================================ FROM registry:4443/nginx:1-alpine-slim COPY default.conf /etc/nginx/conf.d/default.conf ARG COMMIT_SHA RUN echo $COMMIT_SHA > /usr/share/nginx/html/version && \ mkdir -p /usr/share/nginx/html/versions && \ echo "version" > /usr/share/nginx/html/versions/$COMMIT_SHA && \ echo "hidden" > /usr/share/nginx/html/versions/.hidden && \ echo "Up!" > /usr/share/nginx/html/up ================================================ FILE: test/integration/docker/deployer/app/config/busybox.conf ================================================ # Test config file for busybox accessory # Used to verify file mode and owner settings setting=value ================================================ FILE: test/integration/docker/deployer/app/config/deploy.yml ================================================ service: app image: app servers: - vm1 - vm2: [ tag1, tag2 ] env: clear: CLEAR_TOKEN: 4321 CLEAR_TAG: "" HOST_TOKEN: "${HOST_TOKEN}" secret: - SECRET_TOKEN - INTERPOLATED_SECRET1 - INTERPOLATED_SECRET2 - INTERPOLATED_SECRET3 - INTERPOLATED_SECRET4 tags: tag1: CLEAR_TAG: tagged tag2: secret: - SECRET_TAG asset_path: /usr/share/nginx/html/versions:ro hooks_output: pre-deploy: :verbose pre-build: :quiet deploy_timeout: 2 drain_timeout: 2 readiness_delay: 0 proxy: host: 127.0.0.1 run: registry: registry:4443 registry: server: localhost:5000 builder: driver: docker arch: <%= Kamal::Utils.docker_arch %> args: COMMIT_SHA: <%= `git rev-parse HEAD` %> accessories: busybox: service: custom-busybox image: registry:4443/busybox:1.36.0 cmd: sh -c 'echo "Starting busybox..."; trap exit term; while true; do sleep 1; done' roles: - web files: - local: config/busybox.conf remote: /etc/busybox.conf mode: "0640" owner: "1000:1000" directories: - local: data remote: /data mode: "0750" owner: "1000:1000" options: "ro" busybox2: service: custom-busybox image: registry:4443/busybox:1.36.0 cmd: sh -c 'echo "Starting busybox..."; trap exit term; while true; do sleep 1; done' host: vm3 ================================================ FILE: test/integration/docker/deployer/app/default.conf ================================================ server { listen 80; listen [::]:80; server_name localhost; location / { root /usr/share/nginx/html; index index.html index.htm; } # redirect server error pages to the static page /50x.html # error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; } } ================================================ FILE: test/integration/docker/deployer/app_with_custom_certificate/.kamal/secrets ================================================ CUSTOM_CERT=$(cat certs/cert.pem) CUSTOM_KEY=$(cat certs/key.pem) ================================================ FILE: test/integration/docker/deployer/app_with_custom_certificate/Dockerfile ================================================ FROM registry:4443/nginx:1-alpine-slim COPY default.conf /etc/nginx/conf.d/default.conf ARG COMMIT_SHA RUN echo $COMMIT_SHA > /usr/share/nginx/html/version && \ mkdir -p /usr/share/nginx/html/versions && \ echo "version" > /usr/share/nginx/html/versions/$COMMIT_SHA && \ echo "hidden" > /usr/share/nginx/html/versions/.hidden && \ echo "Up!" > /usr/share/nginx/html/up ================================================ FILE: test/integration/docker/deployer/app_with_custom_certificate/certs/cert.pem ================================================ -----BEGIN CERTIFICATE----- MIIDCzCCAfOgAwIBAgIUJHOADjhddzCAdXFfZvhXAsVMwhowDQYJKoZIhvcNAQEL BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI1MDYxNzA5MDYxOVoYDzIxMjUw NTI0MDkwNjE5WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB AQUAA4IBDwAwggEKAoIBAQDQaLWwoLZ3/cZdiW/m4pqOe228wCx/CRU9/E2AT9NS ofuJNtUaxw7QAAFEWIrnf9y3M09lZeox1CNmXe2GADnnx/n906zSGX18SdDmWrxa L/1t5OZiXl3we5PM3UNvbFPSq1MCnOtvo6jTPM7shIpJ/5/KuuqovyrO31VCnc2+ ycEzJ2BOcKFUFAeyT/8bk9lAI+1971PLqC6ut9dfy8PVHSPyGrxGiQCpStU7NiQj LUkqte7x9GcIKTJUjMkWIsvGke9oGoGgEl5gEfqxFAs3ZkA1aYkiHhwFtrUkGOOf O1C6sqfwnnAhtG8LnULGlFYi3GoKALF2XSIagGpaQM5HAgMBAAGjUzBRMB0GA1Ud DgQWBBQg2m871YSI220bQEG5APeGzeaz4zAfBgNVHSMEGDAWgBQg2m871YSI220b QEG5APeGzeaz4zAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBc yQvjLV+Uym+SI/bmKNKafW7ioWSkWAfTl/bvCB8xCX2OJsSqh1vjiKhkcJ6t0Tcj cEiYs7Q+2NVC+s+0ztrN1y4Ve8iX9K9D6o/09bD23zTKpftxCMv8NqoBicNVJ7O9 sINcTqzrIPb+jawE47ogNvlorsU1hi1GTmDHtIqVJPQwiNCIWd8frBLf+WfCHCCK xRJb4hh5wR05v94L0/QdfKQ8qqCRG0VLyoGGcUyQgC8PLLlHRIWIYuwo3xhUK9nN Gn8WNiACY4ry1wRauqIp54N3fM1a5sgzpgPKc8++KLVBpxhDy8nRoFAD0k6y1iM0 2EoVLhbMvwhYwHOHkktp -----END CERTIFICATE----- ================================================ FILE: test/integration/docker/deployer/app_with_custom_certificate/certs/key.pem ================================================ -----BEGIN PRIVATE KEY----- MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDQaLWwoLZ3/cZd iW/m4pqOe228wCx/CRU9/E2AT9NSofuJNtUaxw7QAAFEWIrnf9y3M09lZeox1CNm Xe2GADnnx/n906zSGX18SdDmWrxaL/1t5OZiXl3we5PM3UNvbFPSq1MCnOtvo6jT PM7shIpJ/5/KuuqovyrO31VCnc2+ycEzJ2BOcKFUFAeyT/8bk9lAI+1971PLqC6u t9dfy8PVHSPyGrxGiQCpStU7NiQjLUkqte7x9GcIKTJUjMkWIsvGke9oGoGgEl5g EfqxFAs3ZkA1aYkiHhwFtrUkGOOfO1C6sqfwnnAhtG8LnULGlFYi3GoKALF2XSIa gGpaQM5HAgMBAAECggEAM2dIPRb+uozU8vg1qhCFR5RpBi+uKe0vGJlU8kt+F3kN hhQIrvCfFi2SIm3mYOAYK/WTZTKkd4LX8mVDcxQ2NBWOcw1VKIMSAOhiBpclsub4 TrUxH90ftXN9in+epOpmqGUKdfAHYANRXjy22v5773GF06aTv2hbYigSqvoqJ57A PCdpw9q9sTwJqR9reU3f9fHsUyIwLCQpbtFyQc8aU9LHqgs4SAkaogY+4mPmlCrl pQ5wGljTXmK5g1o/v+mu1WdeGNOzd5//xp0YImkGtyiqh8Ab891MI1wPgivNP5Lo Ru1wKhegj89XamT/LUCtn6NCcokE/9pqEXrKK7JeVQKBgQD98kGUkdAm+zHjRZsr KTeQQ/wszFrNcbP9irE5MqnASWskIXcAhGVJrqbtinLPLIeT22BTsJkCUjVJdfX2 MObjiJP0LMrMVpGQC0b+i4boS8W/lY5T4fM97B+ILc3Y1OYiUedg0gVsFspSR4ef luNfbKbmdzYYqFz6a/q5vExqBQKBgQDSGC2MJXYAewRJ9Mk3fNvll/6yz73rGCct tljwNXUgC7y2nEabDverPd74olSxojQwus/kA8JrMVa2IkXo+lKAwLV+nyj3PGHw 3szTeAVWrGIRveWuW6IQ5zOP2IGkX5Jm+XSPVihnMz7SZA6k6qCtWVVywfBubSpi 1dMNWAhs2wKBgBvMVw1yYLzDppRgXDn/SwvJxWMKA66VkcRhWEEQoLBh2Q6dcy9l TskgCznZe/PdxgGTdBn1LOqqIRcniIMomz2xB7Ek7hYsK8b+1QisMVpgYQc10dyw 0TWoEVOQ4AWqWH7NRGy+0MUiQYd8OQZpN/6MIED+L7fHRlZLV6jZSewZAoGBAJwo bHJmxbbFuQJfd9BOdgPJXf76emdrpHNNvf2NPml7T+FLdw95qI0Xh8u2nM0Li09N C4inYrLaEWF/SAdLSFd65WwgUQqzTvkCIaxs4UrzBlG5nCZk5ak6sBCTFIlgoCj5 8bE4kP9kD6XByUC7RIKUi/aoQFVTvtWHqT+Z12lRAoGAAVoZVxE+xPAfzVyAatpH M8WwgB23r07thNDiJCUMOQUT8LRFKg/Hyj6jB2W7gj669G/Bvoar++nXJVw7QCiv MlOk1pfaKuW82rCPnTeUzJwf2KQ8Jg2avasD4GFWZBJVvlHN1ONySViIpb67hhAK 1OcbfGutFiGWhUwXNVkVc4U= -----END PRIVATE KEY----- ================================================ FILE: test/integration/docker/deployer/app_with_custom_certificate/config/deploy.yml ================================================ service: app_with_custom_certificate image: app_with_custom_certificate servers: web: hosts: - vm1 - vm2 workers: hosts: - vm3 cmd: sleep infinity deploy_timeout: 2 drain_timeout: 2 readiness_delay: 0 proxy: host: localhost ssl: certificate_pem: CUSTOM_CERT private_key_pem: CUSTOM_KEY healthcheck: interval: 1 timeout: 1 path: "/up" asset_path: /usr/share/nginx/html/versions registry: server: registry:4443 username: root password: root builder: driver: docker arch: <%= Kamal::Utils.docker_arch %> args: COMMIT_SHA: <%= `git rev-parse HEAD` %> ================================================ FILE: test/integration/docker/deployer/app_with_custom_certificate/default.conf ================================================ server { listen 80; listen [::]:80; server_name localhost; location / { root /usr/share/nginx/html; index index.html index.htm; } # redirect server error pages to the static page /50x.html # error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; } } ================================================ FILE: test/integration/docker/deployer/app_with_destinations/Dockerfile ================================================ FROM registry:4443/nginx:1-alpine-slim COPY default.conf /etc/nginx/conf.d/default.conf ARG COMMIT_SHA RUN echo $COMMIT_SHA > /usr/share/nginx/html/version && \ mkdir -p /usr/share/nginx/html/versions && \ echo "version" > /usr/share/nginx/html/versions/$COMMIT_SHA && \ echo "hidden" > /usr/share/nginx/html/versions/.hidden && \ echo "Up!" > /usr/share/nginx/html/up ================================================ FILE: test/integration/docker/deployer/app_with_destinations/config/deploy.production.yml ================================================ servers: - vm2 - vm3 ================================================ FILE: test/integration/docker/deployer/app_with_destinations/config/deploy.staging.yml ================================================ servers: - vm1 ================================================ FILE: test/integration/docker/deployer/app_with_destinations/config/deploy.yml ================================================ service: app_with_destinations image: app_with_destinations require_destination: true deploy_timeout: 2 drain_timeout: 2 readiness_delay: 0 proxy: run: registry: registry:4443 host: localhost ssl: false healthcheck: interval: 1 timeout: 1 path: "/up" response_timeout: 2 forward_headers: true registry: server: registry:4443 username: root password: root builder: driver: docker arch: <%= Kamal::Utils.docker_arch %> args: COMMIT_SHA: <%= `git rev-parse HEAD` %> aliases: staging_deploy: deploy -d staging production_deploy: deploy -d production staging_config: config -d staging production_config: config -d production ================================================ FILE: test/integration/docker/deployer/app_with_destinations/default.conf ================================================ server { listen 80; listen [::]:80; server_name localhost; location / { root /usr/share/nginx/html; index index.html index.htm; } # redirect server error pages to the static page /50x.html # error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; } } ================================================ FILE: test/integration/docker/deployer/app_with_parallel_roles/Dockerfile ================================================ FROM registry:4443/nginx:1-alpine-slim COPY default.conf /etc/nginx/conf.d/default.conf ARG COMMIT_SHA RUN echo $COMMIT_SHA > /usr/share/nginx/html/version && \ mkdir -p /usr/share/nginx/html/versions && \ echo "version" > /usr/share/nginx/html/versions/$COMMIT_SHA && \ echo "hidden" > /usr/share/nginx/html/versions/.hidden && \ echo "Up!" > /usr/share/nginx/html/up ================================================ FILE: test/integration/docker/deployer/app_with_parallel_roles/config/deploy.yml ================================================ service: app_with_parallel_roles image: app_with_parallel_roles servers: web: hosts: - vm1 - vm2 workers: hosts: - vm1 cmd: sleep infinity deploy_timeout: 2 drain_timeout: 2 readiness_delay: 0 boot: parallel_roles: true proxy: host: localhost ssl: false healthcheck: interval: 1 timeout: 1 path: "/up" response_timeout: 2 run: registry: registry:4443 registry: server: registry:4443 username: root password: root builder: driver: docker arch: <%= Kamal::Utils.docker_arch %> args: COMMIT_SHA: <%= `git rev-parse HEAD` %> ================================================ FILE: test/integration/docker/deployer/app_with_parallel_roles/default.conf ================================================ server { listen 80; listen [::]:80; server_name localhost; location / { root /usr/share/nginx/html; index index.html index.htm; } # redirect server error pages to the static page /50x.html # error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; } } ================================================ FILE: test/integration/docker/deployer/app_with_parallel_roles/error_pages/503.html ================================================ 503 Service Interrupted

Custom Maintenance Page: {{ .Message }}

================================================ FILE: test/integration/docker/deployer/app_with_proxied_accessory/Dockerfile ================================================ FROM registry:4443/nginx:1-alpine-slim COPY default.conf /etc/nginx/conf.d/default.conf ARG COMMIT_SHA RUN echo $COMMIT_SHA > /usr/share/nginx/html/version && \ mkdir -p /usr/share/nginx/html/versions && \ echo "version" > /usr/share/nginx/html/versions/$COMMIT_SHA && \ echo "hidden" > /usr/share/nginx/html/versions/.hidden && \ echo "Up!" > /usr/share/nginx/html/up ================================================ FILE: test/integration/docker/deployer/app_with_proxied_accessory/config/deploy.yml ================================================ service: app_with_proxied_accessory image: app_with_proxied_accessory env: clear: CLEAR_TOKEN: 4321 CLEAR_TAG: "" HOST_TOKEN: "${HOST_TOKEN}" asset_path: /usr/share/nginx/html/versions proxy: host: 127.0.0.1 registry: server: registry:4443 username: root password: root builder: driver: docker arch: <%= Kamal::Utils.docker_arch %> args: COMMIT_SHA: <%= `git rev-parse HEAD` %> accessories: busybox: service: custom-busybox image: registry:4443/busybox:1.36.0 cmd: sh -c 'echo "Starting busybox..."; trap exit term; while true; do sleep 1; done' host: vm1 netcat: service: netcat image: registry:4443/busybox:1.36.0 cmd: > 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' host: vm1 port: 12345:80 proxy: run: registry: registry:4443 host: netcat ssl: false healthcheck: interval: 1 timeout: 1 path: "/" drain_timeout: 2 ================================================ FILE: test/integration/docker/deployer/app_with_proxied_accessory/default.conf ================================================ server { listen 80; listen [::]:80; server_name localhost; location / { root /usr/share/nginx/html; index index.html index.htm; } # redirect server error pages to the static page /50x.html # error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; } } ================================================ FILE: test/integration/docker/deployer/app_with_roles/.kamal/hooks/docker-setup ================================================ #!/bin/sh echo "Docker set up!" mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/docker-setup ================================================ FILE: test/integration/docker/deployer/app_with_roles/.kamal/hooks/post-deploy ================================================ #!/bin/sh echo "Deployed!" mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/post-deploy ================================================ FILE: test/integration/docker/deployer/app_with_roles/.kamal/hooks/post-proxy-reboot ================================================ #!/bin/sh echo "Rebooted kamal-proxy on ${KAMAL_HOSTS}" mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/post-proxy-reboot ================================================ FILE: test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-build ================================================ #!/bin/sh echo "About to build and push..." mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-build ================================================ FILE: test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-connect ================================================ #!/bin/sh echo "About to lock..." mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-connect ================================================ FILE: test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-deploy ================================================ #!/bin/sh set -e echo "Deployed!" mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-deploy ================================================ FILE: test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-proxy-reboot ================================================ #!/bin/sh echo "Rebooting kamal-proxy on ${KAMAL_HOSTS}..." mkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-proxy-reboot ================================================ FILE: test/integration/docker/deployer/app_with_roles/.kamal/secrets ================================================ SECRET_TOKEN='1234 with "中文"' ================================================ FILE: test/integration/docker/deployer/app_with_roles/Dockerfile ================================================ FROM registry:4443/nginx:1-alpine-slim COPY default.conf /etc/nginx/conf.d/default.conf ARG COMMIT_SHA RUN echo $COMMIT_SHA > /usr/share/nginx/html/version && \ mkdir -p /usr/share/nginx/html/versions && \ echo "version" > /usr/share/nginx/html/versions/$COMMIT_SHA && \ echo "hidden" > /usr/share/nginx/html/versions/.hidden && \ echo "Up!" > /usr/share/nginx/html/up ================================================ FILE: test/integration/docker/deployer/app_with_roles/config/deploy.yml ================================================ service: app_with_roles image: app_with_roles servers: web: hosts: - vm1 - vm2 workers: hosts: - vm3 cmd: sleep infinity deploy_timeout: 2 drain_timeout: 2 readiness_delay: 0 proxy: run: registry: registry:4443 host: localhost ssl: false healthcheck: interval: 1 timeout: 1 path: "/up" response_timeout: 2 buffering: requests: false responses: false memory: 400_000 max_request_body: 40_000_000 max_response_body: 40_000_000 forward_headers: true logging: request_headers: - Cache-Control - X-Forwarded-Proto response_headers: - X-Request-ID - X-Request-Start asset_path: /usr/share/nginx/html/versions error_pages_path: error_pages registry: server: registry:4443 username: root password: root builder: driver: docker arch: <%= Kamal::Utils.docker_arch %> args: COMMIT_SHA: <%= `git rev-parse HEAD` %> accessories: busybox: service: custom-busybox image: registry:4443/busybox:1.36.0 cmd: sh -c 'echo "Starting busybox..."; trap exit term; while true; do sleep 1; done' roles: - web aliases: whome: version worker_hostname: app exec -r workers --reuse hostname worker_hostname_quiet: app exec -r workers -q --reuse hostname uname: server exec -p uname uname_quiet: server exec -q -p uname ================================================ FILE: test/integration/docker/deployer/app_with_roles/default.conf ================================================ server { listen 80; listen [::]:80; server_name localhost; location / { root /usr/share/nginx/html; index index.html index.htm; } # redirect server error pages to the static page /50x.html # error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; } } ================================================ FILE: test/integration/docker/deployer/app_with_roles/error_pages/503.html ================================================ 503 Service Interrupted

Custom Maintenance Page: {{ .Message }}

================================================ FILE: test/integration/docker/deployer/app_with_traefik/.kamal/secrets ================================================ SECRET_TOKEN='1234 with "中文"' ================================================ FILE: test/integration/docker/deployer/app_with_traefik/Dockerfile ================================================ FROM registry:4443/nginx:1-alpine-slim COPY default.conf /etc/nginx/conf.d/default.conf ARG COMMIT_SHA RUN echo $COMMIT_SHA > /usr/share/nginx/html/version && \ mkdir -p /usr/share/nginx/html/versions && \ echo "version" > /usr/share/nginx/html/versions/$COMMIT_SHA && \ echo "hidden" > /usr/share/nginx/html/versions/.hidden && \ echo "Up!" > /usr/share/nginx/html/up ================================================ FILE: test/integration/docker/deployer/app_with_traefik/config/deploy.yml ================================================ service: app_with_traefik image: app_with_traefik servers: - vm1 - vm2 deploy_timeout: 2 drain_timeout: 2 readiness_delay: 0 registry: server: registry:4443 username: root password: root builder: driver: docker arch: <%= Kamal::Utils.docker_arch %> args: COMMIT_SHA: <%= `git rev-parse HEAD` %> proxy: run: registry: registry:4443 publish: false options: label: - traefik.http.services.kamal_proxy.loadbalancer.server.scheme=http - traefik.http.routers.kamal_proxy.rule=PathPrefix(`/`) sysctl: net.ipv4.ip_local_port_range=10000 60999 accessories: traefik: service: traefik image: traefik:v2.10 port: 80 cmd: "--providers.docker" options: volume: - "/var/run/docker.sock:/var/run/docker.sock" roles: - web ================================================ FILE: test/integration/docker/deployer/app_with_traefik/default.conf ================================================ server { listen 80; listen [::]:80; server_name localhost; location / { root /usr/share/nginx/html; index index.html index.htm; } # redirect server error pages to the static page /50x.html # error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; } } ================================================ FILE: test/integration/docker/deployer/boot.sh ================================================ #!/bin/bash # Use VFS storage driver locally to avoid overlayfs-on-overlayfs issues # Skip on GitHub Actions where the outer Docker is configured differently if [ -z "$GITHUB_ACTIONS" ]; then mkdir -p /etc/docker echo '{"storage-driver": "vfs"}' > /etc/docker/daemon.json fi # On hosts using nftables, Docker can't create netfilter rules from inside a container. # iptables-legacy uses an older kernel interface that doesn't have this limitation. update-alternatives --set iptables /usr/sbin/iptables-legacy 2>/dev/null || true update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy 2>/dev/null || true dockerd --max-concurrent-downloads 1 & exec sleep infinity ================================================ FILE: test/integration/docker/deployer/break_app.sh ================================================ #!/bin/bash cd $1 && echo "bad nginx config" > default.conf && git commit -am 'Broken' ================================================ FILE: test/integration/docker/deployer/setup.sh ================================================ #!/bin/bash install_kamal() { cd /kamal && gem build kamal.gemspec -o /tmp/kamal.gem && gem install /tmp/kamal.gem } # Push the images to a persistent volume on the registry container # This is to work around docker hub rate limits push_image_to_registry_4443() { # Check if the image is in the registry without having to pull it if ! stat /registry/docker/registry/v2/repositories/$1/_manifests/tags/$2/current/link > /dev/null; then hub_tag=$1:$2 registry_4443_tag=registry:4443/$1:$2 docker pull $hub_tag docker tag $hub_tag $registry_4443_tag docker push $registry_4443_tag fi } install_kamal push_image_to_registry_4443 nginx 1-alpine-slim push_image_to_registry_4443 busybox 1.36.0 push_image_to_registry_4443 basecamp/kamal-proxy v0.9.2 # .ssh is on a shared volume that persists between runs. Clean it up as the # churn of temporary vm IPs can eventually create conflicts. rm -f /root/.ssh/known_hosts ================================================ FILE: test/integration/docker/deployer/update_app_rev.sh ================================================ #!/bin/bash cd $1 && git commit -am 'Update rev' --amend ================================================ FILE: test/integration/docker/load_balancer/Dockerfile ================================================ FROM nginx:1-alpine-slim COPY default.conf /etc/nginx/conf.d/default.conf HEALTHCHECK --interval=1s CMD pgrep nginx ================================================ FILE: test/integration/docker/load_balancer/default.conf ================================================ upstream loadbalancer { server vm1:80; server vm2:80; } server { listen 80; location / { proxy_pass http://loadbalancer; proxy_set_header Host $host; proxy_connect_timeout 10; proxy_send_timeout 10; proxy_read_timeout 10; send_timeout 10; } } ================================================ FILE: test/integration/docker/registry/Dockerfile ================================================ FROM registry:3 COPY boot.sh . RUN ln -s /shared/certs /certs HEALTHCHECK --interval=1s CMD pgrep registry ENTRYPOINT ["./boot.sh"] ================================================ FILE: test/integration/docker/registry/boot.sh ================================================ #!/bin/sh while [ ! -f /certs/domain.crt ]; do sleep 1; done exec /entrypoint.sh /etc/distribution/config.yml ================================================ FILE: test/integration/docker/shared/.dockerignore ================================================ Dockerfile ================================================ FILE: test/integration/docker/shared/Dockerfile ================================================ FROM ubuntu:22.04 WORKDIR /work RUN apt-get update --fix-missing && apt-get -y install openssh-client openssl COPY . . RUN mkdir ssh && \ ssh-keygen -t rsa -f ssh/id_rsa -N "" && \ 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 HEALTHCHECK --interval=1s CMD pgrep sleep CMD ["./boot.sh"] ================================================ FILE: test/integration/docker/shared/boot.sh ================================================ #!/bin/bash cp -r * /shared exec sleep infinity ================================================ FILE: test/integration/docker/shared/registry-dns.conf ================================================ [dn] CN=registry [req] distinguished_name = dn [EXT] subjectAltName=DNS:registry keyUsage=digitalSignature ================================================ FILE: test/integration/docker/vm/Dockerfile ================================================ FROM ubuntu:22.04 WORKDIR /work RUN apt-get update --fix-missing && apt-get -y install openssh-client openssh-server docker.io COPY boot.sh . RUN mkdir /root/.ssh && \ ln -s /shared/ssh/id_rsa.pub /root/.ssh/authorized_keys && \ mkdir -p /etc/docker/certs.d/registry:4443 && \ ln -s /shared/certs/domain.crt /etc/docker/certs.d/registry:4443/ca.crt && \ echo "HOST_TOKEN=abcd" >> /etc/environment HEALTHCHECK --interval=1s CMD pgrep dockerd CMD ["./boot.sh"] ================================================ FILE: test/integration/docker/vm/boot.sh ================================================ #!/bin/bash while [ ! -f /root/.ssh/authorized_keys ]; do echo "Waiting for ssh keys"; sleep 1; done service ssh restart # On hosts using nftables, Docker can't create netfilter rules from inside a container. # iptables-legacy uses an older kernel interface that doesn't have this limitation. update-alternatives --set iptables /usr/sbin/iptables-legacy 2>/dev/null || true update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy 2>/dev/null || true dockerd --max-concurrent-downloads 1 & exec sleep infinity ================================================ FILE: test/integration/docker-compose.yml ================================================ name: "kamal-test" volumes: shared: registry: deployer_bundle: services: shared: build: context: docker/shared volumes: - shared:/shared deployer: privileged: true build: context: docker/deployer environment: - TEST_ID=${TEST_ID:-} volumes: - ../..:/kamal - shared:/shared - registry:/registry - deployer_bundle:/usr/local/bundle/ registry: build: context: docker/registry environment: - REGISTRY_HTTP_ADDR=0.0.0.0:4443 - REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt - REGISTRY_HTTP_TLS_KEY=/certs/domain.key volumes: - shared:/shared - registry:/var/lib/registry/ vm1: privileged: true build: context: docker/vm volumes: - shared:/shared ports: - "22443:443" vm2: privileged: true build: context: docker/vm volumes: - shared:/shared vm3: privileged: true build: context: docker/vm volumes: - shared:/shared load_balancer: build: context: docker/load_balancer ports: - "12345:80" - "12443:443" depends_on: - vm1 - vm2 - vm3 ================================================ FILE: test/integration/integration_test.rb ================================================ require "net/http" require "test_helper" class IntegrationTest < ActiveSupport::TestCase setup do ENV["TEST_ID"] = SecureRandom.hex docker_compose "up --build -d" wait_for_healthy setup_deployer @app = "app" end teardown do if !passed? && ENV["DEBUG_CONTAINER_LOGS"] [ :deployer, :vm1, :vm2, :shared, :load_balancer, :registry ].each do |container| puts puts "Logs for #{container}:" docker_compose :logs, container end end docker_compose "down -t 1" end private def docker_compose(*commands, capture: false, raise_on_error: true) command = "TEST_ID=#{ENV["TEST_ID"]} docker compose #{commands.join(" ")}" succeeded = false if capture || !ENV["DEBUG"] result = stdouted { stderred { succeeded = system("cd test/integration && #{command}") } } else succeeded = system("cd test/integration && #{command}") end raise "Command `#{command}` failed with error code `#{$?}`, and output:\n#{result}" if !succeeded && raise_on_error result end def deployer_exec(*commands, workdir: nil, **options) workdir ||= "/#{@app}" docker_compose("exec --workdir #{workdir} deployer #{commands.join(" ")}", **options) end def kamal(*commands, **options) deployer_exec(:kamal, *commands, **options) end def assert_app_is_down assert_app_error_code("502") end def assert_app_in_maintenance(message: nil) assert_app_error_code("503", message: message) end def assert_app_not_found assert_app_error_code("404") end def assert_app_error_code(code, message: nil) response = app_response debug_response_code(response, code) assert_equal code, response.code assert_match message, response.body.strip if message end def assert_app_is_up(version: nil, app: @app, cert: nil) response = app_response(app: app, cert: cert) debug_response_code(response, "200") assert_equal "200", response.code assert_app_version(version, response) if version end def wait_for_app_to_be_up(timeout: 20, up_count: 3) timeout_at = Time.now + timeout up_times = 0 response = app_response while up_times < up_count && timeout_at > Time.now sleep 0.1 up_times += 1 if response.code == "200" response = app_response end assert_equal up_times, up_count end def app_response(app: @app, cert: nil) uri = cert ? URI.parse("https://#{app_host(app)}:22443/version") : URI.parse("http://#{app_host(app)}:12345/version") if cert https_response_with_cert(uri, cert) else Net::HTTP.get_response(uri) end end def update_app_rev deployer_exec "./update_app_rev.sh #{@app}", workdir: "/" latest_app_version end def break_app deployer_exec "./break_app.sh #{@app}", workdir: "/" latest_app_version end def latest_app_version deployer_exec("git rev-parse HEAD", capture: true) end def assert_app_version(version, response) assert_equal version, response.body.strip end def assert_hooks_ran(*hooks) hooks.each do |hook| file = "/tmp/#{ENV["TEST_ID"]}/#{hook}" assert_equal "removed '#{file}'", deployer_exec("rm -v #{file}", capture: true).strip end end def assert_200(response) code = response.code if code != "200" puts "Got response code #{code}, here are the proxy logs:" kamal :proxy, :logs puts "And here are the load balancer logs" docker_compose :logs, :load_balancer puts "Tried to get the response code again and got #{app_response.code}" end assert_equal "200", code end def wait_for_healthy(timeout: 30) timeout_at = Time.now + timeout loop do result = docker_compose("ps -a | tail -n +2 | grep -v '(healthy)' | wc -l", capture: true) break if result.split.last == "0" || result == "0" if timeout_at < Time.now docker_compose("ps -a | tail -n +2 | grep -v '(healthy)'") raise "Container not healthy after #{timeout} seconds" if timeout_at < Time.now end sleep 0.1 end end def setup_deployer deployer_exec("./setup.sh", workdir: "/") unless $DEPLOYER_SETUP $DEPLOYER_SETUP = true end def debug_response_code(app_response, expected_code) code = app_response.code if code != expected_code puts "Got response code #{code}, here are the proxy logs:" kamal :proxy, :logs puts "And here are the load balancer logs" docker_compose :logs, :load_balancer puts "Tried to get the response code again and got #{app_response.code}" end end def assert_container_running(host:, name:) assert container_running?(host: host, name: name) end def assert_container_not_running(host:, name:) assert_not container_running?(host: host, name: name) end def container_running?(host:, name:) docker_compose("exec #{host} docker ps --filter=name=#{name} | tail -n+2", capture: true).strip.present? end def assert_app_directory_removed assert_directory_removed("./kamal/apps/#{@app}") end def assert_directory_removed(directory) assert docker_compose("exec vm1 ls #{directory} | wc -l", capture: true).strip == "0" end def assert_proxy_running assert_container_running(host: "vm1", name: "kamal-proxy") end def assert_proxy_not_running assert_container_not_running(host: "vm1", name: "kamal-proxy") end def app_host(app = @app) case app when "app" "127.0.0.1" else "localhost" end end def https_response_with_cert(uri, cert) host = uri.host port = uri.port http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true store = OpenSSL::X509::Store.new store.add_cert(OpenSSL::X509::Certificate.new(File.read(cert))) http.cert_store = store request = Net::HTTP::Get.new(uri) http.request(request) end end ================================================ FILE: test/integration/lock_test.rb ================================================ require_relative "integration_test" class LockTest < IntegrationTest test "acquire, release, status" do kamal :lock, :acquire, "-m 'Integration Tests'" status = kamal :lock, :status, capture: true assert_match /Locked by: Deployer at .*\nVersion: #{latest_app_version}\nMessage: Integration Tests/m, status error = kamal :deploy, capture: true, raise_on_error: false assert_match /Deploy lock found. Run 'kamal lock help' for more information/m, error kamal :lock, :release status = kamal :lock, :status, capture: true assert_match /There is no deploy lock/m, status end end ================================================ FILE: test/integration/main_test.rb ================================================ require_relative "integration_test" class MainTest < IntegrationTest test "deploy, redeploy, rollback, details and audit" do first_version = latest_app_version assert_app_is_down deploy_output = kamal :deploy, capture: true assert_app_is_up version: first_version assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "pre-app-boot", "post-app-boot", "post-deploy" assert_hook_output deploy_output assert_envs version: first_version output = kamal :app, :exec, "--verbose", "ls", "-r", "web", capture: true assert_hook_env_variables output, version: first_version second_version = update_app_rev kamal :redeploy assert_app_is_up version: second_version assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "pre-app-boot", "post-app-boot", "post-deploy" assert_accumulated_assets first_version, second_version assert_asset_volume_read_only second_version kamal :rollback, first_version assert_hooks_ran "pre-connect", "pre-deploy", "pre-app-boot", "post-app-boot", "post-deploy" assert_app_is_up version: first_version details = kamal :details, capture: true assert_match /Proxy Host: vm1/, details assert_match /Proxy Host: vm2/, details assert_match /App Host: vm1/, details assert_match /App Host: vm2/, details assert_match /basecamp\/kamal-proxy:#{Kamal::Configuration::Proxy::Run::MINIMUM_VERSION}/, details assert_match /localhost:5000\/app:#{first_version}/, details audit = kamal :audit, capture: true assert_match /Booted app version #{first_version}.*Booted app version #{second_version}.*Booted app version #{first_version}.*/m, audit end test "app with roles" do @app = "app_with_roles" version = latest_app_version assert_app_is_down kamal :deploy assert_app_is_up version: version assert_hooks_ran "pre-connect", "pre-build", "pre-deploy", "post-deploy" assert_container_running host: :vm3, name: "app_with_roles-workers-#{version}" second_version = update_app_rev kamal :redeploy assert_app_is_up version: second_version assert_container_running host: :vm3, name: "app_with_roles-workers-#{second_version}" end test "config" do config = YAML.load(kamal(:config, capture: true)) version = latest_app_version assert_equal [ "web" ], config[:roles] assert_equal [ "vm1", "vm2", "vm3" ], config[:hosts] assert_equal "vm1", config[:primary_host] assert_equal version, config[:version] assert_equal "localhost:5000/app", config[:repository] assert_equal "localhost:5000/app:#{version}", config[:absolute_image] assert_equal "app-#{version}", config[:service_with_version] assert_equal [], config[:volume_args] assert_equal({ user: "root", port: 22, keepalive: true, keepalive_interval: 30, log_level: :fatal }, config[:ssh_options]) assert_equal({ "driver" => "docker", "arch" => "#{Kamal::Utils.docker_arch}", "args" => { "COMMIT_SHA" => version } }, config[:builder]) assert_equal [ "--log-opt", "max-size=\"10m\"" ], config[:logging] end test "aliases" do @app = "app_with_roles" kamal :deploy output = kamal :whome, capture: true assert_equal Kamal::VERSION, output output = kamal :worker_hostname, capture: true assert_match /App Host: vm3\nvm3-[0-9a-f]{12}$/, output output = kamal :worker_hostname_quiet, capture: true assert_match /vm3-[0-9a-f]{12}$/, output output = kamal :uname, "-o", capture: true assert_match "App Host: vm1\nGNU/Linux", output output = kamal :uname_quiet, "-o", capture: true assert_match "GNU/Linux", output end test "deploy with destinations" do @app = "app_with_destinations" kamal :staging_deploy assert_app_is_up config = YAML.load(kamal(:staging_config, capture: true)) assert_equal [ "vm1" ], config[:hosts] config = YAML.load(kamal(:production_config, capture: true)) assert_equal [ "vm2", "vm3" ], config[:hosts] end test "setup and remove" do kamal :proxy, :boot_config, "set", "--publish=false", "--docker-options=label=traefik.http.services.kamal_proxy.loadbalancer.server.scheme=http", "label=traefik.http.routers.kamal_proxy.rule=PathPrefix\\\(\\\`/\\\`\\\)", "label=traefik.http.routers.kamal_proxy.priority=2" # Check remove completes when nothing has been setup yet kamal :remove, "-y" assert_no_images_or_containers kamal :setup assert_images_and_containers kamal :remove, "-y" assert_no_images_or_containers assert_app_directory_removed end test "two apps" do @app = "app" kamal :deploy app1_version = latest_app_version @app = "app_with_roles" kamal :deploy app2_version = latest_app_version assert_app_is_up version: app1_version, app: "app" assert_app_is_up version: app2_version, app: "app_with_roles" @app = "app" kamal :remove, "-y" assert_app_directory_removed assert_proxy_running @app = "app_with_roles" kamal :remove, "-y" assert_app_directory_removed assert_proxy_not_running end test "deploy with traefik" do @app = "app_with_traefik" first_version = latest_app_version kamal :setup assert_app_is_up version: first_version end test "deploy with a custom certificate" do @app = "app_with_custom_certificate" first_version = latest_app_version kamal :setup assert_app_is_up version: first_version, cert: "test/integration/docker/deployer/app_with_custom_certificate/certs/cert.pem" end private def assert_envs(version:) assert_env :KAMAL_HOST, "vm1", version: version, vm: :vm1 assert_env :CLEAR_TOKEN, "4321", version: version, vm: :vm1 assert_env :HOST_TOKEN, "abcd", version: version, vm: :vm1 assert_env :SECRET_TOKEN, "1234 with \"中文\"", version: version, vm: :vm1 assert_no_env :CLEAR_TAG, version: version, vm: :vm1 assert_no_env :SECRET_TAG, version: version, vm: :vm1 assert_env :CLEAR_TAG, "tagged", version: version, vm: :vm2 assert_env :SECRET_TAG, "TAGME", version: version, vm: :vm2 assert_env :INTERPOLATED_SECRET1, "1TERCES_DETALOPRETNI", version: version, vm: :vm2 assert_env :INTERPOLATED_SECRET2, "2TERCES_DETALOPRETNI", version: version, vm: :vm2 assert_env :INTERPOLATED_SECRET3, "文中_DETALOPRETNI", version: version, vm: :vm2 assert_env :INTERPOLATED_SECRET4, ")(_DETALOPRETNI", version: version, vm: :vm2 end def assert_env(key, value, vm:, version:) assert_equal "#{key}=#{value}", docker_compose("exec #{vm} docker exec #{@app}-web-#{version} env | grep #{key}", capture: true) end def assert_no_env(key, vm:, version:) assert_raises(RuntimeError, /exit 1/) do docker_compose("exec #{vm} docker exec #{@app}-web-#{version} env | grep #{key}", capture: true) end end def assert_accumulated_assets(*versions) versions.each do |version| assert_equal "200", Net::HTTP.get_response(URI.parse("http://#{app_host}:12345/versions/#{version}")).code end assert_equal "200", Net::HTTP.get_response(URI.parse("http://#{app_host}:12345/versions/.hidden")).code end def assert_asset_volume_read_only(version) mounts = docker_compose("exec vm1 docker inspect app-web-#{version} --format '{{json .Mounts}}'", capture: true) assert_match %r{/usr/share/nginx/html/versions.*"RW":false}, mounts, "Expected asset volume to be mounted read-only (:ro)" end def image_ids(vm:) docker_compose("exec #{vm} docker image ls -q", capture: true).strip.split("\n") end def container_ids(vm:) docker_compose("exec #{vm} docker ps -a -q", capture: true).strip.split("\n") end def assert_no_images_or_containers [ :vm1, :vm2, :vm3 ].each do |vm| assert image_ids(vm: vm).empty? assert container_ids(vm: vm).empty? end end def assert_images_and_containers [ :vm1, :vm2, :vm3 ].each do |vm| assert image_ids(vm: vm).any? assert container_ids(vm: vm).any? end end def assert_hook_env_variables(output, version:) assert_match "KAMAL_VERSION=#{version}", output assert_match "KAMAL_SERVICE=app", output assert_match "KAMAL_SERVICE_VERSION=app@#{version[0..6]}", output assert_match "KAMAL_COMMAND=app", output assert_match "KAMAL_PERFORMER=deployer@example.com", output assert_match /KAMAL_RECORDED_AT=\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\dZ/, output assert_match "KAMAL_HOSTS=vm1,vm2", output assert_match "KAMAL_ROLES=web", output end def assert_hook_output(output) # pre-deploy hook (hooks_output: :verbose) shows everything assert_match(/Running.*pre-deploy/, output) assert_match(/Deployed!/, output) # pre-build hook (hooks_output: :quiet) hides everything assert_no_match(/Running.*pre-build/, output) assert_no_match(/About to build and push/, output) # post-deploy hook (no hooks_output setting) shows Running but hides output assert_match(/Running.*post-deploy/, output) assert_no_match(/Finished deploy!/, output) end end ================================================ FILE: test/integration/proxy_test.rb ================================================ require_relative "integration_test" class ProxyTest < IntegrationTest setup do @app = "app_with_roles" end test "boot, reboot, stop, start, restart, logs, remove" do kamal :proxy, :boot assert_proxy_running output = kamal :proxy, :reboot, "-y", "--verbose", capture: true assert_proxy_running assert_hooks_ran "pre-proxy-reboot", "post-proxy-reboot" assert_match /Rebooting kamal-proxy on vm1,vm2.../, output assert_match /Rebooted kamal-proxy on vm1,vm2/, output output = kamal :proxy, :reboot, "--rolling", "-y", "--verbose", capture: true assert_proxy_running assert_hooks_ran "pre-proxy-reboot", "post-proxy-reboot" assert_match /Rebooting kamal-proxy on vm1.../, output assert_match /Rebooted kamal-proxy on vm1/, output assert_match /Rebooting kamal-proxy on vm2.../, output assert_match /Rebooted kamal-proxy on vm2/, output kamal :proxy, :boot assert_proxy_running # Check booting when booted doesn't raise an error kamal :proxy, :stop assert_proxy_not_running # Check booting when stopped works kamal :proxy, :boot assert_proxy_running kamal :proxy, :stop assert_proxy_not_running kamal :proxy, :start assert_proxy_running kamal :proxy, :restart assert_proxy_running logs = kamal :proxy, :logs, capture: true assert_match /No previous state to restore/, logs kamal :proxy, :remove assert_proxy_not_running end private def assert_docker_options_in_file boot_config = kamal :proxy, :boot_config, :get, capture: true 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 end def assert_docker_options_in_container assert_equal \ "{\"net.ipv4.ip_local_port_range\":\"10000 60999\"}", docker_compose("exec vm1 docker inspect --format '{{ json .HostConfig.Sysctls }}' kamal-proxy", capture: true).strip end end ================================================ FILE: test/secrets/aws_secrets_manager_adapter_test.rb ================================================ require "test_helper" class AwsSecretsManagerAdapterTest < SecretAdapterTestCase test "fails when errors are present" do stub_ticks.with("aws --version 2> /dev/null") stub_ticks .with("aws secretsmanager batch-get-secret-value --secret-id-list unknown1 unknown2 --profile default --output json") .returns(<<~JSON) { "SecretValues": [], "Errors": [ { "SecretId": "unknown1", "ErrorCode": "ResourceNotFoundException", "Message": "Secrets Manager can't find the specified secret." }, { "SecretId": "unknown2", "ErrorCode": "ResourceNotFoundException", "Message": "Secrets Manager can't find the specified secret." } ] } JSON error = assert_raises RuntimeError do JSON.parse(run_command("fetch", "unknown1", "unknown2")) end assert_equal [ "unknown1: Secrets Manager can't find the specified secret.", "unknown2: Secrets Manager can't find the specified secret." ].join(" "), error.message end test "fetch" do stub_ticks.with("aws --version 2> /dev/null") stub_ticks .with("aws secretsmanager batch-get-secret-value --secret-id-list secret/KEY1 secret/KEY2 secret2/KEY3 --profile default --output json") .returns(<<~JSON) { "SecretValues": [ { "ARN": "arn:aws:secretsmanager:us-east-1:aaaaaaaaaaaa:secret:secret", "Name": "secret", "VersionId": "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv", "SecretString": "{\\"KEY1\\":\\"VALUE1\\", \\"KEY2\\":\\"VALUE2\\"}", "VersionStages": [ "AWSCURRENT" ], "CreatedDate": "2024-01-01T00:00:00.000000" }, { "ARN": "arn:aws:secretsmanager:us-east-1:aaaaaaaaaaaa:secret:secret2", "Name": "secret2", "VersionId": "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv", "SecretString": "{\\"KEY3\\":\\"VALUE3\\"}", "VersionStages": [ "AWSCURRENT" ], "CreatedDate": "2024-01-01T00:00:00.000000" } ], "Errors": [] } JSON json = JSON.parse(run_command("fetch", "secret/KEY1", "secret/KEY2", "secret2/KEY3")) expected_json = { "secret/KEY1"=>"VALUE1", "secret/KEY2"=>"VALUE2", "secret2/KEY3"=>"VALUE3" } assert_equal expected_json, json end test "fetch with string value" do stub_ticks.with("aws --version 2> /dev/null") stub_ticks .with("aws secretsmanager batch-get-secret-value --secret-id-list secret secret2/KEY1 --profile default --output json") .returns(<<~JSON) { "SecretValues": [ { "ARN": "arn:aws:secretsmanager:us-east-1:aaaaaaaaaaaa:secret:secret", "Name": "secret", "VersionId": "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv", "SecretString": "a-string-secret", "VersionStages": [ "AWSCURRENT" ], "CreatedDate": "2024-01-01T00:00:00.000000" }, { "ARN": "arn:aws:secretsmanager:us-east-1:aaaaaaaaaaaa:secret:secret2", "Name": "secret2", "VersionId": "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv", "SecretString": "{\\"KEY2\\":\\"VALUE2\\"}", "VersionStages": [ "AWSCURRENT" ], "CreatedDate": "2024-01-01T00:00:00.000000" } ], "Errors": [] } JSON json = JSON.parse(run_command("fetch", "secret", "secret2/KEY1")) expected_json = { "secret"=>"a-string-secret", "secret2/KEY2"=>"VALUE2" } assert_equal expected_json, json end test "fetch with secret names" do stub_ticks.with("aws --version 2> /dev/null") stub_ticks .with("aws secretsmanager batch-get-secret-value --secret-id-list secret/KEY1 secret/KEY2 --profile default --output json") .returns(<<~JSON) { "SecretValues": [ { "ARN": "arn:aws:secretsmanager:us-east-1:aaaaaaaaaaaa:secret:secret", "Name": "secret", "VersionId": "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv", "SecretString": "{\\"KEY1\\":\\"VALUE1\\", \\"KEY2\\":\\"VALUE2\\"}", "VersionStages": [ "AWSCURRENT" ], "CreatedDate": "2024-01-01T00:00:00.000000" } ], "Errors": [] } JSON json = JSON.parse(run_command("fetch", "--from", "secret", "KEY1", "KEY2")) expected_json = { "secret/KEY1"=>"VALUE1", "secret/KEY2"=>"VALUE2" } assert_equal expected_json, json end test "fetch without CLI installed" do stub_ticks_with("aws --version 2> /dev/null", succeed: false) error = assert_raises RuntimeError do JSON.parse(run_command("fetch", "SECRET1")) end assert_equal "AWS CLI is not installed", error.message end test "fetch without account option omits --profile" do stub_ticks.with("aws --version 2> /dev/null") stub_ticks .with("aws secretsmanager batch-get-secret-value --secret-id-list secret/KEY1 secret/KEY2 --output json") .returns(<<~JSON) { "SecretValues": [ { "ARN": "arn:aws:secretsmanager:us-east-1:aaaaaaaaaaaa:secret:secret", "Name": "secret", "VersionId": "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv", "SecretString": "{\\"KEY1\\":\\"VALUE1\\", \\"KEY2\\":\\"VALUE2\\"}", "VersionStages": [ "AWSCURRENT" ], "CreatedDate": "2024-01-01T00:00:00.000000" } ], "Errors": [] } JSON json = JSON.parse(run_command("fetch", "--from", "secret", "KEY1", "KEY2", account: nil)) expected_json = { "secret/KEY1"=>"VALUE1", "secret/KEY2"=>"VALUE2" } assert_equal expected_json, json end private def run_command(*command, account: "default") stdouted do args = [ *command, "-c", "test/fixtures/deploy_with_accessories.yml", "--adapter", "aws_secrets_manager" ] args += [ "--account", account ] if account Kamal::Cli::Secrets.start(args) end end end ================================================ FILE: test/secrets/bitwarden_adapter_test.rb ================================================ require "test_helper" class BitwardenAdapterTest < SecretAdapterTestCase test "fetch" do stub_ticks.with("bw --version 2> /dev/null") stub_unlocked stub_ticks.with("bw sync").returns("") stub_mypassword json = JSON.parse(run_command("fetch", "mypassword")) expected_json = { "mypassword"=>"secret123" } assert_equal expected_json, json end test "fetch with no login" do stub_ticks.with("bw --version 2> /dev/null") stub_unlocked stub_ticks.with("bw sync").returns("") stub_noteitem error = assert_raises RuntimeError do JSON.parse(run_command("fetch", "mynote")) end assert_match(/not a login type item/, error.message) end test "fetch with from" do stub_ticks.with("bw --version 2> /dev/null") stub_unlocked stub_ticks.with("bw sync").returns("") stub_myitem json = JSON.parse(run_command("fetch", "--from", "myitem", "field1", "field2", "field3")) expected_json = { "myitem/field1"=>"secret1", "myitem/field2"=>"blam", "myitem/field3"=>"fewgrwjgk" } assert_equal expected_json, json end test "fetch all with from" do stub_ticks.with("bw --version 2> /dev/null") stub_unlocked stub_ticks.with("bw sync").returns("") stub_noteitem_with_fields json = JSON.parse(run_command("fetch", "mynotefields")) expected_json = { "mynotefields/field1"=>"secret1", "mynotefields/field2"=>"blam", "mynotefields/field3"=>"fewgrwjgk", "mynotefields/field4"=>"auto" } assert_equal expected_json, json end test "fetch with multiple items" do stub_ticks.with("bw --version 2> /dev/null") stub_unlocked stub_ticks.with("bw sync").returns("") stub_mypassword stub_myitem stub_ticks .with("bw get item myitem2") .returns(<<~JSON) { "passwordHistory":null, "revisionDate":"2024-08-29T13:46:53.343Z", "creationDate":"2024-08-29T12:02:31.156Z", "deletedDate":null, "object":"item", "id":"aaaaaaaa-cccc-eeee-0000-222222222222", "organizationId":null, "folderId":null, "type":1, "reprompt":0, "name":"myitem2", "notes":null, "favorite":false, "fields":[ {"name":"field3","value":"fewgrwjgk","type":1,"linkedId":null} ], "login":{"fido2Credentials":[],"uris":[],"username":null,"password":null,"totp":null,"passwordRevisionDate":null},"collectionIds":[] } JSON json = JSON.parse(run_command("fetch", "mypassword", "myitem/field1", "myitem/field2", "myitem2/field3")) expected_json = { "mypassword"=>"secret123", "myitem/field1"=>"secret1", "myitem/field2"=>"blam", "myitem2/field3"=>"fewgrwjgk" } assert_equal expected_json, json end test "fetch unauthenticated" do stub_ticks.with("bw --version 2> /dev/null") stub_ticks .with("bw status") .returns( '{"serverUrl":null,"lastSync":null,"status":"unauthenticated"}', '{"serverUrl":null,"lastSync":"2024-09-04T10:11:12.433Z","userEmail":"email@example.com","userId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","status":"locked"}', '{"serverUrl":null,"lastSync":"2024-09-04T10:11:12.433Z","userEmail":"email@example.com","userId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","status":"unlocked"}' ) stub_ticks.with("bw login email@example.com").returns("1234567890") stub_ticks.with("bw unlock --raw").returns("") stub_ticks.with("bw sync").returns("") stub_mypassword json = JSON.parse(run_command("fetch", "mypassword")) expected_json = { "mypassword"=>"secret123" } assert_equal expected_json, json end test "fetch locked" do stub_ticks.with("bw --version 2> /dev/null") stub_ticks .with("bw status") .returns( '{"serverUrl":null,"lastSync":"2024-09-04T10:11:12.433Z","userEmail":"email@example.com","userId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","status":"locked"}' ) stub_ticks .with("bw status") .returns( '{"serverUrl":null,"lastSync":"2024-09-04T10:11:12.433Z","userEmail":"email@example.com","userId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","status":"unlocked"}' ) stub_ticks.with("bw login email@example.com").returns("1234567890") stub_ticks.with("bw unlock --raw").returns("") stub_ticks.with("bw sync").returns("") stub_mypassword json = JSON.parse(run_command("fetch", "mypassword")) expected_json = { "mypassword"=>"secret123" } assert_equal expected_json, json end test "fetch locked with session" do stub_ticks.with("bw --version 2> /dev/null") stub_ticks .with("bw status") .returns( '{"serverUrl":null,"lastSync":"2024-09-04T10:11:12.433Z","userEmail":"email@example.com","userId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","status":"locked"}' ) stub_ticks .with("BW_SESSION=0987654321 bw status") .returns( '{"serverUrl":null,"lastSync":"2024-09-04T10:11:12.433Z","userEmail":"email@example.com","userId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","status":"unlocked"}' ) stub_ticks.with("bw login email@example.com").returns("1234567890") stub_ticks.with("bw unlock --raw").returns("0987654321") stub_ticks.with("BW_SESSION=0987654321 bw sync").returns("") stub_mypassword(session: "0987654321") json = JSON.parse(run_command("fetch", "mypassword")) expected_json = { "mypassword"=>"secret123" } assert_equal expected_json, json end test "fetch without CLI installed" do stub_ticks_with("bw --version 2> /dev/null", succeed: false) error = assert_raises RuntimeError do JSON.parse(run_command("fetch", "mynote")) end assert_equal "Bitwarden CLI is not installed", error.message end private def run_command(*command) stdouted do Kamal::Cli::Secrets.start \ [ *command, "-c", "test/fixtures/deploy_with_accessories.yml", "--adapter", "bitwarden", "--account", "email@example.com" ] end end def stub_unlocked stub_ticks .with("bw status") .returns(<<~JSON) {"serverUrl":null,"lastSync":"2024-09-04T10:11:12.433Z","userEmail":"email@example.com","userId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","status":"unlocked"} JSON end def stub_mypassword(session: nil) stub_ticks .with("#{"BW_SESSION=#{session} " if session}bw get item mypassword") .returns(<<~JSON) { "passwordHistory":null, "revisionDate":"2024-08-29T13:46:53.343Z", "creationDate":"2024-08-29T12:02:31.156Z", "deletedDate":null, "object":"item", "id":"aaaaaaaa-cccc-eeee-0000-222222222222", "organizationId":null, "folderId":null, "type":1, "reprompt":0, "name":"mypassword", "notes":null, "favorite":false, "login":{"fido2Credentials":[],"uris":[],"username":null,"password":"secret123","totp":null,"passwordRevisionDate":null},"collectionIds":[] } JSON end def stub_noteitem(session: nil) stub_ticks .with("#{"BW_SESSION=#{session} " if session}bw get item mynote") .returns(<<~JSON) { "passwordHistory":null, "revisionDate":"2024-09-28T09:07:27.461Z", "creationDate":"2024-09-28T09:07:00.740Z", "deletedDate":null, "object":"item", "id":"aaaaaaaa-cccc-eeee-0000-222222222222", "organizationId":null, "folderId":null, "type":2, "reprompt":0, "name":"noteitem", "notes":"NOTES", "favorite":false, "secureNote":{"type":0}, "collectionIds":[] } JSON end def stub_noteitem_with_fields(session: nil) stub_ticks .with("#{"BW_SESSION=#{session} " if session}bw get item mynotefields") .returns(<<~JSON) { "passwordHistory":null, "revisionDate":"2024-09-28T09:07:27.461Z", "creationDate":"2024-09-28T09:07:00.740Z", "deletedDate":null, "object":"item", "id":"aaaaaaaa-cccc-eeee-0000-222222222222", "organizationId":null, "folderId":null, "type":2, "reprompt":0, "name":"noteitem", "notes":"NOTES", "favorite":false, "fields":[ {"name":"field1","value":"secret1","type":1,"linkedId":null}, {"name":"field2","value":"blam","type":1,"linkedId":null}, {"name":"field3","value":"fewgrwjgk","type":1,"linkedId":null}, {"name":"field4","value":"auto","type":1,"linkedId":null} ], "secureNote":{"type":0}, "collectionIds":[] } JSON end def stub_myitem stub_ticks .with("bw get item myitem") .returns(<<~JSON) { "passwordHistory":null, "revisionDate":"2024-08-29T13:46:53.343Z", "creationDate":"2024-08-29T12:02:31.156Z", "deletedDate":null, "object":"item", "id":"aaaaaaaa-cccc-eeee-0000-222222222222", "organizationId":null, "folderId":null, "type":1, "reprompt":0, "name":"myitem", "notes":null, "favorite":false, "fields":[ {"name":"field1","value":"secret1","type":1,"linkedId":null}, {"name":"field2","value":"blam","type":1,"linkedId":null}, {"name":"field3","value":"fewgrwjgk","type":1,"linkedId":null}, {"name":"field4","value":"auto","type":1,"linkedId":null} ], "login":{"fido2Credentials":[],"uris":[],"username":null,"password":null,"totp":null,"passwordRevisionDate":null},"collectionIds":[] } JSON end end ================================================ FILE: test/secrets/bitwarden_secrets_manager_adapter_test.rb ================================================ require "test_helper" class BitwardenSecretsManagerAdapterTest < SecretAdapterTestCase test "fetch with no parameters" do stub_ticks.with("bws --version 2> /dev/null") stub_login error = assert_raises RuntimeError do run_command("fetch") end assert_equal("You must specify what to retrieve from Bitwarden Secrets Manager", error.message) end test "fetch all" do stub_ticks.with("bws --version 2> /dev/null") stub_login stub_ticks .with("bws secret list") .returns(<<~JSON) [ { "key": "KAMAL_REGISTRY_PASSWORD", "value": "some_password" }, { "key": "MY_OTHER_SECRET", "value": "my=wierd\\"secret" } ] JSON json = JSON.parse(run_command("fetch", "all")) expected_json = { "KAMAL_REGISTRY_PASSWORD"=>"some_password", "MY_OTHER_SECRET"=>"my=wierd\"secret" } assert_equal expected_json, json end test "fetch all with from" do stub_ticks.with("bws --version 2> /dev/null") stub_login stub_ticks .with("bws secret list 82aeb5bd-6958-4a89-8197-eacab758acce") .returns(<<~JSON) [ { "key": "KAMAL_REGISTRY_PASSWORD", "value": "some_password" }, { "key": "MY_OTHER_SECRET", "value": "my=wierd\\"secret" } ] JSON json = JSON.parse(run_command("fetch", "all", "--from", "82aeb5bd-6958-4a89-8197-eacab758acce")) expected_json = { "KAMAL_REGISTRY_PASSWORD"=>"some_password", "MY_OTHER_SECRET"=>"my=wierd\"secret" } assert_equal expected_json, json end test "fetch item" do stub_ticks.with("bws --version 2> /dev/null") stub_login stub_ticks .with("bws secret get 82aeb5bd-6958-4a89-8197-eacab758acce") .returns(<<~JSON) { "key": "KAMAL_REGISTRY_PASSWORD", "value": "some_password" } JSON json = JSON.parse(run_command("fetch", "82aeb5bd-6958-4a89-8197-eacab758acce")) expected_json = { "KAMAL_REGISTRY_PASSWORD"=>"some_password" } assert_equal expected_json, json end test "fetch with multiple items" do stub_ticks.with("bws --version 2> /dev/null") stub_login stub_ticks .with("bws secret get 82aeb5bd-6958-4a89-8197-eacab758acce") .returns(<<~JSON) { "key": "KAMAL_REGISTRY_PASSWORD", "value": "some_password" } JSON stub_ticks .with("bws secret get 6f8cdf27-de2b-4c77-a35d-07df8050e332") .returns(<<~JSON) { "key": "MY_OTHER_SECRET", "value": "my=wierd\\"secret" } JSON json = JSON.parse(run_command("fetch", "82aeb5bd-6958-4a89-8197-eacab758acce", "6f8cdf27-de2b-4c77-a35d-07df8050e332")) expected_json = { "KAMAL_REGISTRY_PASSWORD"=>"some_password", "MY_OTHER_SECRET"=>"my=wierd\"secret" } assert_equal expected_json, json end test "fetch all empty" do stub_ticks.with("bws --version 2> /dev/null") stub_login stub_ticks_with("bws secret list", succeed: false).returns("Error:\n0: Received error message from server") error = assert_raises RuntimeError do (run_command("fetch", "all")) end assert_equal("Could not read secrets from Bitwarden Secrets Manager", error.message) end test "fetch nonexistent item" do stub_ticks.with("bws --version 2> /dev/null") stub_login stub_ticks_with("bws secret get 82aeb5bd-6958-4a89-8197-eacab758acce", succeed: false) .returns("Error:\n0: Received error message from server") error = assert_raises RuntimeError do (run_command("fetch", "82aeb5bd-6958-4a89-8197-eacab758acce")) end assert_equal("Could not read 82aeb5bd-6958-4a89-8197-eacab758acce from Bitwarden Secrets Manager", error.message) end test "fetch item with linebreak in value" do stub_ticks.with("bws --version 2> /dev/null") stub_login stub_ticks .with("bws secret get 82aeb5bd-6958-4a89-8197-eacab758acce") .returns(<<~JSON) { "key": "SSH_PRIVATE_KEY", "value": "some_key\\nwith_linebreak" } JSON json = JSON.parse(run_command("fetch", "82aeb5bd-6958-4a89-8197-eacab758acce")) expected_json = { "SSH_PRIVATE_KEY"=>"some_key\nwith_linebreak" } assert_equal expected_json, json end test "fetch with no access token" do stub_ticks.with("bws --version 2> /dev/null") stub_ticks_with("bws project list", succeed: false) error = assert_raises RuntimeError do (run_command("fetch", "all")) end assert_equal("Could not authenticate to Bitwarden Secrets Manager. Did you set a valid access token?", error.message) end test "fetch without CLI installed" do stub_ticks_with("bws --version 2> /dev/null", succeed: false) error = assert_raises RuntimeError do run_command("fetch") end assert_equal "Bitwarden Secrets Manager CLI is not installed", error.message end private def stub_login stub_ticks.with("bws project list").returns("OK") end def run_command(*command) stdouted do Kamal::Cli::Secrets.start \ [ *command, "--adapter", "bitwarden-sm" ] end end end ================================================ FILE: test/secrets/doppler_adapter_test.rb ================================================ require "test_helper" class DopplerAdapterTest < SecretAdapterTestCase setup do `true` # Ensure $? is 0 end test "fetch" do stub_ticks_with("doppler --version 2> /dev/null", succeed: true) stub_ticks.with("doppler me --json 2> /dev/null") stub_ticks .with("doppler secrets get SECRET1 FSECRET1 FSECRET2 --json -p my-project -c prd") .returns(<<~JSON) { "SECRET1": { "computed":"secret1", "computedVisibility":"unmasked", "note":"" }, "FSECRET1": { "computed":"fsecret1", "computedVisibility":"unmasked", "note":"" }, "FSECRET2": { "computed":"fsecret2", "computedVisibility":"unmasked", "note":"" } } JSON json = JSON.parse( run_command("fetch", "--from", "my-project/prd", "SECRET1", "FSECRET1", "FSECRET2") ) expected_json = { "SECRET1"=>"secret1", "FSECRET1"=>"fsecret1", "FSECRET2"=>"fsecret2" } assert_equal expected_json, json end test "fetch having DOPPLER_TOKEN" do ENV["DOPPLER_TOKEN"] = "dp.st.xxxxxxxxxxxxxxxxxxxxxx" stub_ticks_with("doppler --version 2> /dev/null", succeed: true) stub_ticks.with("doppler me --json 2> /dev/null") stub_ticks .with("doppler secrets get SECRET1 FSECRET1 FSECRET2 --json ") .returns(<<~JSON) { "SECRET1": { "computed":"secret1", "computedVisibility":"unmasked", "note":"" }, "FSECRET1": { "computed":"fsecret1", "computedVisibility":"unmasked", "note":"" }, "FSECRET2": { "computed":"fsecret2", "computedVisibility":"unmasked", "note":"" } } JSON json = JSON.parse( run_command("fetch", "SECRET1", "FSECRET1", "FSECRET2") ) expected_json = { "SECRET1"=>"secret1", "FSECRET1"=>"fsecret1", "FSECRET2"=>"fsecret2" } assert_equal expected_json, json ENV.delete("DOPPLER_TOKEN") end test "fetch with folder in secret" do stub_ticks_with("doppler --version 2> /dev/null", succeed: true) stub_ticks.with("doppler me --json 2> /dev/null") stub_ticks .with("doppler secrets get SECRET1 FSECRET1 FSECRET2 --json -p my-project -c prd") .returns(<<~JSON) { "SECRET1": { "computed":"secret1", "computedVisibility":"unmasked", "note":"" }, "FSECRET1": { "computed":"fsecret1", "computedVisibility":"unmasked", "note":"" }, "FSECRET2": { "computed":"fsecret2", "computedVisibility":"unmasked", "note":"" } } JSON json = JSON.parse( run_command("fetch", "my-project/prd/SECRET1", "my-project/prd/FSECRET1", "my-project/prd/FSECRET2") ) expected_json = { "SECRET1"=>"secret1", "FSECRET1"=>"fsecret1", "FSECRET2"=>"fsecret2" } assert_equal expected_json, json end test "fetch without --from" do stub_ticks_with("doppler --version 2> /dev/null", succeed: true) stub_ticks.with("doppler me --json 2> /dev/null") error = assert_raises RuntimeError do run_command("fetch", "FSECRET1", "FSECRET2") end assert_equal "Missing project or config from '--from=project/config' option", error.message end test "fetch with signin" do stub_ticks_with("doppler --version 2> /dev/null", succeed: true) stub_ticks_with("doppler me --json 2> /dev/null", succeed: false) stub_ticks_with("doppler login -y", succeed: true).returns("") stub_ticks.with("doppler secrets get SECRET1 --json -p my-project -c prd").returns(single_item_json) json = JSON.parse(run_command("fetch", "--from", "my-project/prd", "SECRET1")) expected_json = { "SECRET1"=>"secret1" } assert_equal expected_json, json end test "fetch without CLI installed" do stub_ticks_with("doppler --version 2> /dev/null", succeed: false) error = assert_raises RuntimeError do JSON.parse(run_command("fetch", "HOST", "PORT")) end assert_equal "Doppler CLI is not installed", error.message end private def run_command(*command) stdouted do Kamal::Cli::Secrets.start \ [ *command, "-c", "test/fixtures/deploy_with_accessories.yml", "--adapter", "doppler" ] end end def single_item_json <<~JSON { "SECRET1": { "computed":"secret1", "computedVisibility":"unmasked", "note":"" } } JSON end end ================================================ FILE: test/secrets/dotenv_inline_command_substitution_test.rb ================================================ require "test_helper" class SecretsInlineCommandSubstitution < SecretAdapterTestCase test "inlines kamal secrets commands" do Kamal::Cli::Main.expects(:start).with { |command| command == [ "secrets", "fetch", "...", "--inline" ] }.returns("results") substituted = Kamal::Secrets::Dotenv::InlineCommandSubstitution.call("FOO=$(kamal secrets fetch ...)", nil, overwrite: false) assert_equal "FOO=results", substituted end test "executes other commands" do Kamal::Secrets::Dotenv::InlineCommandSubstitution.stubs(:`).with("blah").returns("results") substituted = Kamal::Secrets::Dotenv::InlineCommandSubstitution.call("FOO=$(blah)", nil, overwrite: false) assert_equal "FOO=results", substituted end test "handles escaped parentheses in command arguments" do command_with_escaped_parens = 'kamal secrets extract KEY1 \{\"KEY1\":\"pass\)word\"\}' Kamal::Cli::Main.expects(:start).with { |cmd| cmd.first(3) == [ "secrets", "extract", "KEY1" ] && cmd[3] == '{"KEY1":"pass)word"}' # shellsplit should unescape }.returns("pass)word") substituted = Kamal::Secrets::Dotenv::InlineCommandSubstitution.call( "KEY1=$(#{command_with_escaped_parens})", nil, overwrite: false ) assert_equal "KEY1=pass)word", substituted end end ================================================ FILE: test/secrets/enpass_adapter_test.rb ================================================ require "test_helper" class EnpassAdapterTest < SecretAdapterTestCase test "fetch without CLI installed" do stub_ticks_with("enpass-cli version 2> /dev/null", succeed: false) error = assert_raises RuntimeError do JSON.parse(run_command("fetch", "mynote")) end assert_equal "Enpass CLI is not installed", error.message end test "fetch one item" do stub_ticks_with("enpass-cli version 2> /dev/null") stub_ticks .with("enpass-cli -json -vault vault-path show FooBar") .returns(<<~JSON) [{"category":"computer","label":"SECRET_1","login":"","password":"my-password-1","title":"FooBar","type":"password"}] JSON json = JSON.parse(run_command("fetch", "FooBar/SECRET_1")) expected_json = { "FooBar/SECRET_1" => "my-password-1" } assert_equal expected_json, json end test "fetch multiple items" do stub_ticks_with("enpass-cli version 2> /dev/null") stub_ticks .with("enpass-cli -json -vault vault-path show FooBar") .returns(<<~JSON) [ {"category":"computer","label":"SECRET_1","login":"","password":"my-password-1","title":"FooBar","type":"password"}, {"category":"computer","label":"SECRET_2","login":"","password":"my-password-2","title":"FooBar","type":"password"}, {"category":"computer","label":"SECRET_3","login":"","password":"my-password-1","title":"Hello","type":"password"} ] JSON json = JSON.parse(run_command("fetch", "FooBar/SECRET_1", "FooBar/SECRET_2")) expected_json = { "FooBar/SECRET_1" => "my-password-1", "FooBar/SECRET_2" => "my-password-2" } assert_equal expected_json, json end test "fetch all with from" do stub_ticks_with("enpass-cli version 2> /dev/null") stub_ticks .with("enpass-cli -json -vault vault-path show FooBar") .returns(<<~JSON) [ {"category":"computer","label":"SECRET_1","login":"","password":"my-password-1","title":"FooBar","type":"password"}, {"category":"computer","label":"SECRET_2","login":"","password":"my-password-2","title":"FooBar","type":"password"}, {"category":"computer","label":"SECRET_3","login":"","password":"my-password-1","title":"Hello","type":"password"}, {"category":"computer","label":"","login":"","password":"my-password-3","title":"FooBar","type":"password"} ] JSON json = JSON.parse(run_command("fetch", "FooBar")) expected_json = { "FooBar/SECRET_1" => "my-password-1", "FooBar/SECRET_2" => "my-password-2", "FooBar" => "my-password-3" } assert_equal expected_json, json end private def run_command(*command) stdouted do Kamal::Cli::Secrets.start \ [ *command, "-c", "test/fixtures/deploy_with_accessories.yml", "--adapter", "enpass", "--from", "vault-path" ] end end end ================================================ FILE: test/secrets/gcp_secret_manager_adapter_test.rb ================================================ require "test_helper" class GcpSecretManagerAdapterTest < SecretAdapterTestCase test "fetch" do stub_gcloud_version stub_authenticated stub_mypassword json = JSON.parse(run_command("fetch", "mypassword")) expected_json = { "default/mypassword"=>"secret123" } assert_equal expected_json, json end test "fetch unauthenticated" do stub_ticks.with("gcloud --version 2> /dev/null") stub_mypassword stub_unauthenticated error = assert_raises RuntimeError do JSON.parse(run_command("fetch", "mypassword")) end assert_match(/could not login to gcloud/, error.message) end test "fetch with from" do stub_gcloud_version stub_authenticated stub_items(0, project: "other-project") stub_items(1, project: "other-project") stub_items(2, project: "other-project") json = JSON.parse(run_command("fetch", "--from", "other-project", "item1", "item2", "item3")) expected_json = { "other-project/item1"=>"secret1", "other-project/item2"=>"secret2", "other-project/item3"=>"secret3" } assert_equal expected_json, json end test "fetch with multiple projects" do stub_gcloud_version stub_authenticated stub_items(0, project: "some-project") stub_items(1, project: "project-confidence") stub_items(2, project: "manhattan-project") json = JSON.parse(run_command("fetch", "some-project/item1", "project-confidence/item2", "manhattan-project/item3")) expected_json = { "some-project/item1"=>"secret1", "project-confidence/item2"=>"secret2", "manhattan-project/item3"=>"secret3" } assert_equal expected_json, json end test "fetch with specific version" do stub_gcloud_version stub_authenticated stub_items(0, project: "some-project", version: "123") json = JSON.parse(run_command("fetch", "some-project/item1/123")) expected_json = { "some-project/item1"=>"secret1" } assert_equal expected_json, json end test "fetch with non-default account" do stub_gcloud_version stub_authenticated stub_items(0, project: "some-project", version: "123", account: "email@example.com") json = JSON.parse(run_command("fetch", "some-project/item1/123", account: "email@example.com")) expected_json = { "some-project/item1"=>"secret1" } assert_equal expected_json, json end test "fetch with service account impersonation" do stub_gcloud_version stub_authenticated stub_items(0, project: "some-project", version: "123", impersonate_service_account: "service-user@example.com") json = JSON.parse(run_command("fetch", "some-project/item1/123", account: "default|service-user@example.com")) expected_json = { "some-project/item1"=>"secret1" } assert_equal expected_json, json end test "fetch with delegation chain and specific user" do stub_gcloud_version stub_authenticated stub_items(0, project: "some-project", version: "123", account: "user@example.com", impersonate_service_account: "service-user@example.com,service-user2@example.com") json = JSON.parse(run_command("fetch", "some-project/item1/123", account: "user@example.com|service-user@example.com,service-user2@example.com")) expected_json = { "some-project/item1"=>"secret1" } assert_equal expected_json, json end test "fetch with non-default account and service account impersonation" do stub_gcloud_version stub_authenticated stub_items(0, project: "some-project", version: "123", account: "email@example.com", impersonate_service_account: "service-user@example.com") json = JSON.parse(run_command("fetch", "some-project/item1/123", account: "email@example.com|service-user@example.com")) expected_json = { "some-project/item1"=>"secret1" } assert_equal expected_json, json end test "fetch without CLI installed" do stub_gcloud_version(succeed: false) error = assert_raises RuntimeError do JSON.parse(run_command("fetch", "item1")) end assert_equal "gcloud CLI is not installed", error.message end private def run_command(*command, account: "default") stdouted do Kamal::Cli::Secrets.start \ [ *command, "-c", "test/fixtures/deploy_with_accessories.yml", "--adapter", "gcp_secret_manager", "--account", account ] end end def stub_gcloud_version(succeed: true) stub_ticks_with("gcloud --version 2> /dev/null", succeed: succeed) end def stub_authenticated stub_ticks .with("gcloud auth list --format=json") .returns(<<~JSON) [ { "account": "email@example.com", "status": "ACTIVE" } ] JSON end def stub_unauthenticated stub_ticks .with("gcloud auth list --format=json") .returns("[]") stub_ticks .with("gcloud auth login") .returns(<<~JSON) { "expired": false, "valid": true } JSON end def stub_mypassword stub_ticks .with("gcloud secrets versions access latest --secret=mypassword --format=json") .returns(<<~JSON) { "name": "projects/000000000/secrets/mypassword/versions/1", "payload": { "data": "c2VjcmV0MTIz", "dataCrc32c": "2522602764" } } JSON end def stub_items(n, project: nil, account: nil, version: "latest", impersonate_service_account: nil) payloads = [ { data: "c2VjcmV0MQ==", checksum: 1846998209 }, { data: "c2VjcmV0Mg==", checksum: 2101741365 }, { data: "c2VjcmV0Mw==", checksum: 2402124854 } ] stub_ticks .with("gcloud secrets versions access #{version} " \ "--secret=item#{n + 1}" \ "#{" --project=#{project}" if project}" \ "#{" --account=#{account}" if account}" \ "#{" --impersonate-service-account=#{impersonate_service_account}" if impersonate_service_account} " \ "--format=json") .returns(<<~JSON) { "name": "projects/000000001/secrets/item1/versions/1", "payload": { "data": "#{payloads[n][:data]}", "dataCrc32c": "#{payloads[n][:checksum]}" } } JSON end end ================================================ FILE: test/secrets/last_pass_adapter_test.rb ================================================ require "test_helper" class LastPassAdapterTest < SecretAdapterTestCase setup do `true` # Ensure $? is 0 end test "fetch" do stub_ticks.with("lpass --version 2> /dev/null") stub_ticks.with("lpass status --color never").returns("Logged in as email@example.com.") stub_ticks .with("lpass show SECRET1 FOLDER1/FSECRET1 FOLDER1/FSECRET2 --json") .returns(<<~JSON) [ { "id": "1234567891234567891", "name": "SECRET1", "fullname": "SECRET1", "username": "", "password": "secret1", "last_modified_gmt": "1724926054", "last_touch": "1724926639", "group": "", "url": "", "note": "" }, { "id": "1234567891234567892", "name": "FSECRET1", "fullname": "FOLDER1/FSECRET1", "username": "", "password": "fsecret1", "last_modified_gmt": "1724926084", "last_touch": "1724926635", "group": "Folder", "url": "", "note": "" }, { "id": "1234567891234567893", "name": "FSECRET2", "fullname": "FOLDER1/FSECRET2", "username": "", "password": "fsecret2", "last_modified_gmt": "1724926084", "last_touch": "1724926635", "group": "Folder", "url": "", "note": "" } ] JSON json = JSON.parse(run_command("fetch", "SECRET1", "FOLDER1/FSECRET1", "FOLDER1/FSECRET2")) expected_json = { "SECRET1"=>"secret1", "FOLDER1/FSECRET1"=>"fsecret1", "FOLDER1/FSECRET2"=>"fsecret2" } assert_equal expected_json, json end test "fetch with from" do stub_ticks.with("lpass --version 2> /dev/null") stub_ticks.with("lpass status --color never").returns("Logged in as email@example.com.") stub_ticks .with("lpass show FOLDER1/FSECRET1 FOLDER1/FSECRET2 --json") .returns(<<~JSON) [ { "id": "1234567891234567892", "name": "FSECRET1", "fullname": "FOLDER1/FSECRET1", "username": "", "password": "fsecret1", "last_modified_gmt": "1724926084", "last_touch": "1724926635", "group": "Folder", "url": "", "note": "" }, { "id": "1234567891234567893", "name": "FSECRET2", "fullname": "FOLDER1/FSECRET2", "username": "", "password": "fsecret2", "last_modified_gmt": "1724926084", "last_touch": "1724926635", "group": "Folder", "url": "", "note": "" } ] JSON json = JSON.parse(run_command("fetch", "--from", "FOLDER1", "FSECRET1", "FSECRET2")) expected_json = { "FOLDER1/FSECRET1"=>"fsecret1", "FOLDER1/FSECRET2"=>"fsecret2" } assert_equal expected_json, json end test "fetch with signin" do stub_ticks.with("lpass --version 2> /dev/null") stub_ticks_with("lpass status --color never", succeed: false).returns("Not logged in.") stub_ticks_with("lpass login email@example.com", succeed: true).returns("") stub_ticks.with("lpass show SECRET1 --json").returns(single_item_json) json = JSON.parse(run_command("fetch", "SECRET1")) expected_json = { "SECRET1"=>"secret1" } assert_equal expected_json, json end test "fetch without CLI installed" do stub_ticks_with("lpass --version 2> /dev/null", succeed: false) error = assert_raises RuntimeError do JSON.parse(run_command("fetch", "SECRET1", "FOLDER1/FSECRET1", "FOLDER1/FSECRET2")) end assert_equal "LastPass CLI is not installed", error.message end private def run_command(*command) stdouted do Kamal::Cli::Secrets.start \ [ *command, "-c", "test/fixtures/deploy_with_accessories.yml", "--adapter", "lastpass", "--account", "email@example.com" ] end end def single_item_json <<~JSON [ { "id": "1234567891234567891", "name": "SECRET1", "fullname": "SECRET1", "username": "", "password": "secret1", "last_modified_gmt": "1724926054", "last_touch": "1724926639", "group": "", "url": "", "note": "" } ] JSON end end ================================================ FILE: test/secrets/one_password_adapter_test.rb ================================================ require "test_helper" class SecretsOnePasswordAdapterTest < SecretAdapterTestCase test "fetch" do stub_ticks.with("op --version 2> /dev/null") stub_ticks.with("op account get --account myaccount 2> /dev/null") stub_ticks .with("op item get myitem --vault \"myvault\" --format \"json\" --account \"myaccount\" --fields \"label=section.SECRET1,label=section.SECRET2,label=section2.SECRET3\"") .returns(<<~JSON) [ { "id": "aaaaaaaaaaaaaaaaaaaaaaaaaa", "section": { "id": "cccccccccccccccccccccccccc", "label": "section" }, "type": "CONCEALED", "label": "SECRET1", "value": "VALUE1", "reference": "op://myvault/myitem/section/SECRET1" }, { "id": "bbbbbbbbbbbbbbbbbbbbbbbbbb", "section": { "id": "dddddddddddddddddddddddddd", "label": "section" }, "type": "CONCEALED", "label": "SECRET2", "value": "VALUE2", "reference": "op://myvault/myitem/section/SECRET2" }, { "id": "bbbbbbbbbbbbbbbbbbbbbbbbbb", "section": { "id": "dddddddddddddddddddddddddd", "label": "section2" }, "type": "CONCEALED", "label": "SECRET3", "value": "VALUE3", "reference": "op://myvault/myitem/section2/SECRET3" } ] JSON json = JSON.parse(run_command("fetch", "--from", "op://myvault/myitem", "section/SECRET1", "section/SECRET2", "section2/SECRET3")) expected_json = { "myvault/myitem/section/SECRET1"=>"VALUE1", "myvault/myitem/section/SECRET2"=>"VALUE2", "myvault/myitem/section2/SECRET3"=>"VALUE3" } assert_equal expected_json, json end test "fetch with multiple items" do stub_ticks.with("op --version 2> /dev/null") stub_ticks.with("op account get --account myaccount 2> /dev/null") stub_ticks .with("op item get myitem --vault \"myvault\" --format \"json\" --account \"myaccount\" --fields \"label=section.SECRET1,label=section.SECRET2\"") .returns(<<~JSON) [ { "id": "aaaaaaaaaaaaaaaaaaaaaaaaaa", "section": { "id": "cccccccccccccccccccccccccc", "label": "section" }, "type": "CONCEALED", "label": "SECRET1", "value": "VALUE1", "reference": "op://myvault/myitem/section/SECRET1" }, { "id": "bbbbbbbbbbbbbbbbbbbbbbbbbb", "section": { "id": "dddddddddddddddddddddddddd", "label": "section" }, "type": "CONCEALED", "label": "SECRET2", "value": "VALUE2", "reference": "op://myvault/myitem/section/SECRET2" } ] JSON stub_ticks .with("op item get myitem2 --vault \"myvault\" --format \"json\" --account \"myaccount\" --fields \"label=section2.SECRET3\"") .returns(<<~JSON) { "id": "aaaaaaaaaaaaaaaaaaaaaaaaaa", "section": { "id": "cccccccccccccccccccccccccc", "label": "section" }, "type": "CONCEALED", "label": "SECRET3", "value": "VALUE3", "reference": "op://myvault/myitem2/section/SECRET3" } JSON json = JSON.parse(run_command("fetch", "--from", "op://myvault", "myitem/section/SECRET1", "myitem/section/SECRET2", "myitem2/section2/SECRET3")) expected_json = { "myvault/myitem/section/SECRET1"=>"VALUE1", "myvault/myitem/section/SECRET2"=>"VALUE2", "myvault/myitem2/section/SECRET3"=>"VALUE3" } assert_equal expected_json, json end test "fetch all fields" do stub_ticks.with("op --version 2> /dev/null") stub_ticks.with("op account get --account myaccount 2> /dev/null") stub_ticks .with("op item get myitem --vault \"myvault\" --format \"json\" --account \"myaccount\"") .returns(<<~JSON) { "id": "ucbtiii777", "title": "A title", "version": 45, "vault": { "id": "vu7ki98do", "name": "Vault" }, "category": "LOGIN", "last_edited_by": "ABCT3684BC", "created_at": "2025-05-22T06:47:01Z", "updated_at": "2025-05-22T00:36:48.02598-07:00", "additional_information": "—", "fields": [ { "id": "aaaaaaaaaaaaaaaaaaaaaaaaaa", "section": { "id": "cccccccccccccccccccccccccc", "label": "section" }, "type": "CONCEALED", "label": "SECRET1", "value": "VALUE1", "reference": "op://myvault/myitem/section/SECRET1" }, { "id": "bbbbbbbbbbbbbbbbbbbbbbbbbb", "section": { "id": "cccccccccccccccccccccccccc", "label": "section" }, "type": "CONCEALED", "label": "SECRET2", "value": "VALUE2", "reference": "op://myvault/myitem/section/SECRET2" } ] } JSON json = JSON.parse(run_command("fetch", "--from", "op://myvault/myitem")) expected_json = { "myvault/myitem/section/SECRET1"=>"VALUE1", "myvault/myitem/section/SECRET2"=>"VALUE2" } assert_equal expected_json, json end test "fetch with signin, no session" do stub_ticks.with("op --version 2> /dev/null") stub_ticks_with("op account get --account myaccount 2> /dev/null", succeed: false) stub_ticks_with("op signin --account \"myaccount\" --force --raw", succeed: true).returns("") stub_ticks .with("op item get myitem --vault \"myvault\" --format \"json\" --account \"myaccount\" --fields \"label=section.SECRET1\"") .returns(single_item_json) json = JSON.parse(run_command("fetch", "--from", "op://myvault/myitem", "section/SECRET1")) expected_json = { "myvault/myitem/section/SECRET1"=>"VALUE1" } assert_equal expected_json, json end test "fetch with signin and session" do stub_ticks.with("op --version 2> /dev/null") stub_ticks_with("op account get --account myaccount 2> /dev/null", succeed: false) stub_ticks_with("op signin --account \"myaccount\" --force --raw", succeed: true).returns("1234567890") stub_ticks .with("op item get myitem --vault \"myvault\" --format \"json\" --account \"myaccount\" --session \"1234567890\" --fields \"label=section.SECRET1\"") .returns(single_item_json) json = JSON.parse(run_command("fetch", "--from", "op://myvault/myitem", "section/SECRET1")) expected_json = { "myvault/myitem/section/SECRET1"=>"VALUE1" } assert_equal expected_json, json end test "fetch without CLI installed" do stub_ticks_with("op --version 2> /dev/null", succeed: false) error = assert_raises RuntimeError do JSON.parse(run_command("fetch", "--from", "op://myvault/myitem", "section/SECRET1", "section/SECRET2", "section2/SECRET3")) end assert_equal "1Password CLI is not installed", error.message end private def run_command(*command) stdouted do Kamal::Cli::Secrets.start \ [ *command, "-c", "test/fixtures/deploy_with_accessories.yml", "--adapter", "1password", "--account", "myaccount" ] end end def single_item_json <<~JSON { "id": "aaaaaaaaaaaaaaaaaaaaaaaaaa", "section": { "id": "cccccccccccccccccccccccccc", "label": "section" }, "type": "CONCEALED", "label": "SECRET1", "value": "VALUE1", "reference": "op://myvault/myitem/section/SECRET1" } JSON end end ================================================ FILE: test/secrets/passbolt_adapter_test.rb ================================================ require "test_helper" class PassboltAdapterTest < SecretAdapterTestCase setup do `true` # Ensure $? is 0 end test "fetch" do stub_ticks_with("passbolt --version 2> /dev/null", succeed: true) stub_ticks.with("passbolt verify 2> /dev/null", succeed: true) stub_ticks .with("passbolt list resources --filter 'Name == \"SECRET1\" || Name == \"FSECRET1\" || Name == \"FSECRET2\"' --json") .returns(<<~JSON) [ { "id": "4c116996-f6d0-4342-9572-0d676f75b3ac", "folder_parent_id": "", "name": "FSECRET1", "username": "", "uri": "", "password": "fsecret1", "description": "", "created_timestamp": "2025-02-21T06:04:29Z", "modified_timestamp": "2025-02-21T06:04:29Z" }, { "id": "62949b26-4957-43fe-9523-294d66861499", "folder_parent_id": "", "name": "FSECRET2", "username": "", "uri": "", "password": "fsecret2", "description": "", "created_timestamp": "2025-02-21T06:04:34Z", "modified_timestamp": "2025-02-21T06:04:34Z" }, { "id": "dd32963c-0db5-4303-a6fc-22c5229dabef", "folder_parent_id": "", "name": "SECRET1", "username": "", "uri": "", "password": "secret1", "description": "", "created_timestamp": "2025-02-21T06:04:23Z", "modified_timestamp": "2025-02-21T06:04:23Z" } ] JSON json = JSON.parse( run_command("fetch", "SECRET1", "FSECRET1", "FSECRET2") ) expected_json = { "SECRET1"=>"secret1", "FSECRET1"=>"fsecret1", "FSECRET2"=>"fsecret2" } assert_equal expected_json, json end test "fetch with --from" do stub_ticks_with("passbolt --version 2> /dev/null", succeed: true) stub_ticks.with("passbolt verify 2> /dev/null", succeed: true) stub_ticks .with("passbolt list folders --filter 'Name == \"my-project\"' --json") .returns(<<~JSON) [ { "id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", "folder_parent_id": "", "name": "my-project", "created_timestamp": "2025-02-21T19:52:50Z", "modified_timestamp": "2025-02-21T19:52:50Z" } ] JSON stub_ticks .with("passbolt list resources --filter '(Name == \"SECRET1\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\") || (Name == \"FSECRET1\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\") || (Name == \"FSECRET2\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\")' --folder dcbe0e39-42d8-42db-9637-8256b9f2f8e3 --json") .returns(<<~JSON) [ { "id": "4c116996-f6d0-4342-9572-0d676f75b3ac", "folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", "name": "FSECRET1", "username": "", "uri": "", "password": "fsecret1", "description": "", "created_timestamp": "2025-02-21T06:04:29Z", "modified_timestamp": "2025-02-21T06:04:29Z" }, { "id": "62949b26-4957-43fe-9523-294d66861499", "folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", "name": "FSECRET2", "username": "", "uri": "", "password": "fsecret2", "description": "", "created_timestamp": "2025-02-21T06:04:34Z", "modified_timestamp": "2025-02-21T06:04:34Z" }, { "id": "dd32963c-0db5-4303-a6fc-22c5229dabef", "folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", "name": "SECRET1", "username": "", "uri": "", "password": "secret1", "description": "", "created_timestamp": "2025-02-21T06:04:23Z", "modified_timestamp": "2025-02-21T06:04:23Z" } ] JSON json = JSON.parse( run_command("fetch", "--from", "my-project", "SECRET1", "FSECRET1", "FSECRET2") ) expected_json = { "SECRET1"=>"secret1", "FSECRET1"=>"fsecret1", "FSECRET2"=>"fsecret2" } assert_equal expected_json, json end test "fetch with folder in secret" do stub_ticks_with("passbolt --version 2> /dev/null", succeed: true) stub_ticks.with("passbolt verify 2> /dev/null", succeed: true) stub_ticks .with("passbolt list folders --filter 'Name == \"my-project\"' --json") .returns(<<~JSON) [ { "id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", "folder_parent_id": "", "name": "my-project", "created_timestamp": "2025-02-21T19:52:50Z", "modified_timestamp": "2025-02-21T19:52:50Z" } ] JSON stub_ticks .with("passbolt list resources --filter '(Name == \"SECRET1\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\") || (Name == \"FSECRET1\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\") || (Name == \"FSECRET2\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\")' --folder dcbe0e39-42d8-42db-9637-8256b9f2f8e3 --json") .returns(<<~JSON) [ { "id": "4c116996-f6d0-4342-9572-0d676f75b3ac", "folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", "name": "FSECRET1", "username": "", "uri": "", "password": "fsecret1", "description": "", "created_timestamp": "2025-02-21T06:04:29Z", "modified_timestamp": "2025-02-21T06:04:29Z" }, { "id": "62949b26-4957-43fe-9523-294d66861499", "folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", "name": "FSECRET2", "username": "", "uri": "", "password": "fsecret2", "description": "", "created_timestamp": "2025-02-21T06:04:34Z", "modified_timestamp": "2025-02-21T06:04:34Z" }, { "id": "dd32963c-0db5-4303-a6fc-22c5229dabef", "folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", "name": "SECRET1", "username": "", "uri": "", "password": "secret1", "description": "", "created_timestamp": "2025-02-21T06:04:23Z", "modified_timestamp": "2025-02-21T06:04:23Z" } ] JSON json = JSON.parse( run_command("fetch", "my-project/SECRET1", "my-project/FSECRET1", "my-project/FSECRET2") ) expected_json = { "SECRET1"=>"secret1", "FSECRET1"=>"fsecret1", "FSECRET2"=>"fsecret2" } assert_equal expected_json, json end test "fetch from multiple folders" do stub_ticks_with("passbolt --version 2> /dev/null", succeed: true) stub_ticks.with("passbolt verify 2> /dev/null", succeed: true) stub_ticks .with("passbolt list folders --filter 'Name == \"my-project\" || Name == \"other-project\"' --json") .returns(<<~JSON) [ { "id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", "folder_parent_id": "", "name": "my-project", "created_timestamp": "2025-02-21T19:52:50Z", "modified_timestamp": "2025-02-21T19:52:50Z" }, { "id": "14e11dd8-b279-4689-8bd9-fa33ebb527da", "folder_parent_id": "", "name": "other-project", "created_timestamp": "2025-02-21T20:00:29Z", "modified_timestamp": "2025-02-21T20:00:29Z" } ] JSON stub_ticks .with("passbolt list resources --filter '(Name == \"SECRET1\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\") || (Name == \"FSECRET1\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\") || (Name == \"FSECRET2\" && FolderParentID == \"14e11dd8-b279-4689-8bd9-fa33ebb527da\")' --folder dcbe0e39-42d8-42db-9637-8256b9f2f8e3 --folder 14e11dd8-b279-4689-8bd9-fa33ebb527da --json") .returns(<<~JSON) [ { "id": "4c116996-f6d0-4342-9572-0d676f75b3ac", "folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", "name": "FSECRET1", "username": "", "uri": "", "password": "fsecret1", "description": "", "created_timestamp": "2025-02-21T06:04:29Z", "modified_timestamp": "2025-02-21T06:04:29Z" }, { "id": "62949b26-4957-43fe-9523-294d66861499", "folder_parent_id": "14e11dd8-b279-4689-8bd9-fa33ebb527da", "name": "FSECRET2", "username": "", "uri": "", "password": "fsecret2", "description": "", "created_timestamp": "2025-02-21T06:04:34Z", "modified_timestamp": "2025-02-21T06:04:34Z" }, { "id": "dd32963c-0db5-4303-a6fc-22c5229dabef", "folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", "name": "SECRET1", "username": "", "uri": "", "password": "secret1", "description": "", "created_timestamp": "2025-02-21T06:04:23Z", "modified_timestamp": "2025-02-21T06:04:23Z" } ] JSON json = JSON.parse( run_command("fetch", "my-project/SECRET1", "my-project/FSECRET1", "other-project/FSECRET2") ) expected_json = { "SECRET1"=>"secret1", "FSECRET1"=>"fsecret1", "FSECRET2"=>"fsecret2" } assert_equal expected_json, json end test "fetch from nested folder" do stub_ticks_with("passbolt --version 2> /dev/null", succeed: true) stub_ticks.with("passbolt verify 2> /dev/null", succeed: true) stub_ticks .with("passbolt list folders --filter 'Name == \"my-project\"' --json") .returns(<<~JSON) [ { "id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", "folder_parent_id": "", "name": "my-project", "created_timestamp": "2025-02-21T19:52:50Z", "modified_timestamp": "2025-02-21T19:52:50Z" } ] JSON stub_ticks .with("passbolt list folders --filter 'Name == \"subfolder\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\"' --json") .returns(<<~JSON) [ { "id": "6a3f21fc-aa40-4ba9-852c-7477fdd0310d", "folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", "name": "subfolder", "created_timestamp": "2025-02-21T19:52:50Z", "modified_timestamp": "2025-02-21T19:52:50Z" } ] JSON stub_ticks .with("passbolt list resources --filter '(Name == \"SECRET1\" && FolderParentID == \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\") || (Name == \"FSECRET1\" && FolderParentID == \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\") || (Name == \"FSECRET2\" && FolderParentID == \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\")' --folder dcbe0e39-42d8-42db-9637-8256b9f2f8e3 --folder 6a3f21fc-aa40-4ba9-852c-7477fdd0310d --json") .returns(<<~JSON) [ { "id": "4c116996-f6d0-4342-9572-0d676f75b3ac", "folder_parent_id": "6a3f21fc-aa40-4ba9-852c-7477fdd0310d", "name": "FSECRET1", "username": "", "uri": "", "password": "fsecret1", "description": "", "created_timestamp": "2025-02-21T06:04:29Z", "modified_timestamp": "2025-02-21T06:04:29Z" }, { "id": "62949b26-4957-43fe-9523-294d66861499", "folder_parent_id": "6a3f21fc-aa40-4ba9-852c-7477fdd0310d", "name": "FSECRET2", "username": "", "uri": "", "password": "fsecret2", "description": "", "created_timestamp": "2025-02-21T06:04:34Z", "modified_timestamp": "2025-02-21T06:04:34Z" }, { "id": "dd32963c-0db5-4303-a6fc-22c5229dabef", "folder_parent_id": "6a3f21fc-aa40-4ba9-852c-7477fdd0310d", "name": "SECRET1", "username": "", "uri": "", "password": "secret1", "description": "", "created_timestamp": "2025-02-21T06:04:23Z", "modified_timestamp": "2025-02-21T06:04:23Z" } ] JSON json = JSON.parse( run_command("fetch", "--from", "my-project/subfolder", "SECRET1", "FSECRET1", "FSECRET2") ) expected_json = { "SECRET1"=>"secret1", "FSECRET1"=>"fsecret1", "FSECRET2"=>"fsecret2" } assert_equal expected_json, json end test "fetch from nested folder in secret" do stub_ticks_with("passbolt --version 2> /dev/null", succeed: true) stub_ticks.with("passbolt verify 2> /dev/null", succeed: true) stub_ticks .with("passbolt list folders --filter 'Name == \"my-project\"' --json") .returns(<<~JSON) [ { "id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", "folder_parent_id": "", "name": "my-project", "created_timestamp": "2025-02-21T19:52:50Z", "modified_timestamp": "2025-02-21T19:52:50Z" } ] JSON stub_ticks .with("passbolt list folders --filter 'Name == \"subfolder\" && FolderParentID == \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\"' --json") .returns(<<~JSON) [ { "id": "6a3f21fc-aa40-4ba9-852c-7477fdd0310d", "folder_parent_id": "dcbe0e39-42d8-42db-9637-8256b9f2f8e3", "name": "subfolder", "created_timestamp": "2025-02-21T19:52:50Z", "modified_timestamp": "2025-02-21T19:52:50Z" } ] JSON stub_ticks .with("passbolt list resources --filter '(Name == \"SECRET1\" && FolderParentID == \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\") || (Name == \"FSECRET1\" && FolderParentID == \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\") || (Name == \"FSECRET2\" && FolderParentID == \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\")' --folder dcbe0e39-42d8-42db-9637-8256b9f2f8e3 --folder 6a3f21fc-aa40-4ba9-852c-7477fdd0310d --json") .returns(<<~JSON) [ { "id": "4c116996-f6d0-4342-9572-0d676f75b3ac", "folder_parent_id": "6a3f21fc-aa40-4ba9-852c-7477fdd0310d", "name": "FSECRET1", "username": "", "uri": "", "password": "fsecret1", "description": "", "created_timestamp": "2025-02-21T06:04:29Z", "modified_timestamp": "2025-02-21T06:04:29Z" }, { "id": "62949b26-4957-43fe-9523-294d66861499", "folder_parent_id": "6a3f21fc-aa40-4ba9-852c-7477fdd0310d", "name": "FSECRET2", "username": "", "uri": "", "password": "fsecret2", "description": "", "created_timestamp": "2025-02-21T06:04:34Z", "modified_timestamp": "2025-02-21T06:04:34Z" }, { "id": "dd32963c-0db5-4303-a6fc-22c5229dabef", "folder_parent_id": "6a3f21fc-aa40-4ba9-852c-7477fdd0310d", "name": "SECRET1", "username": "", "uri": "", "password": "secret1", "description": "", "created_timestamp": "2025-02-21T06:04:23Z", "modified_timestamp": "2025-02-21T06:04:23Z" } ] JSON json = JSON.parse( run_command("fetch", "my-project/subfolder/SECRET1", "my-project/subfolder/FSECRET1", "my-project/subfolder/FSECRET2") ) expected_json = { "SECRET1"=>"secret1", "FSECRET1"=>"fsecret1", "FSECRET2"=>"fsecret2" } assert_equal expected_json, json end test "fetch without CLI installed" do stub_ticks_with("passbolt --version 2> /dev/null", succeed: false) error = assert_raises RuntimeError do JSON.parse(run_command("fetch", "HOST", "PORT")) end assert_equal "Passbolt CLI is not installed", error.message end test "fetch with special characters in folder id" do stub_ticks_with("passbolt --version 2> /dev/null", succeed: true) stub_ticks_with("passbolt verify", succeed: true) stub_ticks.with("passbolt list folders --filter 'Name == \"my-project\"' --json") .returns('[{"id":"abc def-123","folder_parent_id":"","name":"my-project","created_timestamp":"2025-02-21T19:52:50Z","modified_timestamp":"2025-02-21T19:52:50Z"}]') stub_ticks.with("passbolt list resources --filter '(Name == \"SECRET1\" && FolderParentID == \"abc\\\\ def-123\")' --folder abc\\ def-123 --json") .returns('[{"id":"dd32963c","folder_parent_id":"abc def-123","name":"SECRET1","username":"","uri":"","password":"secret1","description":"","created_timestamp":"2025-02-21T06:04:23Z","modified_timestamp":"2025-02-21T06:04:23Z"}]') json = JSON.parse(run_command("fetch", "my-project/SECRET1")) assert_equal({ "SECRET1"=>"secret1" }, json) end private def run_command(*command) stdouted do Kamal::Cli::Secrets.start \ [ *command, "-c", "test/fixtures/deploy_with_accessories.yml", "--adapter", "passbolt" ] end end end ================================================ FILE: test/secrets_test.rb ================================================ require "test_helper" class SecretsTest < ActiveSupport::TestCase test "fetch" do with_test_secrets("secrets" => "SECRET=ABC") do assert_equal "ABC", Kamal::Secrets.new(secrets_path: ".kamal/secrets")["SECRET"] end end test "synchronized_fetch" do with_test_secrets("secrets" => "SECRET=ABC") do assert_equal "ABC", Kamal::Secrets.new(secrets_path: ".kamal/secrets").send(:synchronized_fetch, "SECRET") end end test "key?" do with_test_secrets("secrets" => "SECRET1=ABC") do assert Kamal::Secrets.new(secrets_path: ".kamal/secrets").key?("SECRET1") assert_not Kamal::Secrets.new(secrets_path: ".kamal/secrets").key?("SECRET2") end end test "command interpolation" do with_test_secrets("secrets" => "SECRET=$(echo ABC)") do assert_equal "ABC", Kamal::Secrets.new(secrets_path: ".kamal/secrets")["SECRET"] end end test "variable references" do with_test_secrets("secrets" => "SECRET1=ABC\nSECRET2=${SECRET1}DEF") do assert_equal "ABC", Kamal::Secrets.new(secrets_path: ".kamal/secrets")["SECRET1"] assert_equal "ABCDEF", Kamal::Secrets.new(secrets_path: ".kamal/secrets")["SECRET2"] end end test "env references" do with_test_secrets("secrets" => "SECRET1=$SECRET1") do ENV["SECRET1"] = "ABC" assert_equal "ABC", Kamal::Secrets.new(secrets_path: ".kamal/secrets")["SECRET1"] end end test "secrets file value overrides env" do with_test_secrets("secrets" => "SECRET1=DEF") do ENV["SECRET1"] = "ABC" assert_equal "DEF", Kamal::Secrets.new(secrets_path: ".kamal/secrets")["SECRET1"] end end test "destinations" do with_test_secrets("secrets.dest" => "SECRET=DEF", "secrets" => "SECRET=ABC", "secrets-common" => "SECRET=GHI\nSECRET2=JKL") do assert_equal "ABC", Kamal::Secrets.new(secrets_path: ".kamal/secrets")["SECRET"] assert_equal "DEF", Kamal::Secrets.new(secrets_path: ".kamal/secrets", destination: "dest")["SECRET"] assert_equal "GHI", Kamal::Secrets.new(secrets_path: ".kamal/secrets", destination: "nodest")["SECRET"] assert_equal "JKL", Kamal::Secrets.new(secrets_path: ".kamal/secrets")["SECRET2"] assert_equal "JKL", Kamal::Secrets.new(secrets_path: ".kamal/secrets", destination: "dest")["SECRET2"] assert_equal "JKL", Kamal::Secrets.new(secrets_path: ".kamal/secrets", destination: "nodest")["SECRET2"] end end test "no secrets files" do with_test_secrets do error = assert_raises(Kamal::ConfigurationError) do Kamal::Secrets.new(secrets_path: ".kamal/secrets")["SECRET"] end assert_equal "Secret 'SECRET' not found, no secret files (.kamal/secrets-common, .kamal/secrets) provided", error.message error = assert_raises(Kamal::ConfigurationError) do Kamal::Secrets.new(secrets_path: ".kamal/secrets", destination: "dest")["SECRET"] end assert_equal "Secret 'SECRET' not found, no secret files (.kamal/secrets-common, .kamal/secrets.dest) provided", error.message end end test "custom secrets_path" do Dir.mktmpdir do |tmpdir| Dir.chdir(tmpdir) do FileUtils.mkdir_p("custom/path") File.write("custom/path/secrets", "SECRET=CUSTOM") assert_equal "CUSTOM", Kamal::Secrets.new(secrets_path: "custom/path/secrets")["SECRET"] end end end test "custom secrets_path with destination" do Dir.mktmpdir do |tmpdir| Dir.chdir(tmpdir) do FileUtils.mkdir_p("custom/path") File.write("custom/path/secrets", "SECRET=BASE") File.write("custom/path/secrets.prod", "SECRET=PROD") assert_equal "BASE", Kamal::Secrets.new(secrets_path: "custom/path/secrets")["SECRET"] assert_equal "PROD", Kamal::Secrets.new(secrets_path: "custom/path/secrets", destination: "prod")["SECRET"] end end end test "custom secrets_path with common file" do Dir.mktmpdir do |tmpdir| Dir.chdir(tmpdir) do FileUtils.mkdir_p("custom/path") File.write("custom/path/secrets-common", "COMMON=SHARED\nSECRET=COMMON") File.write("custom/path/secrets", "SECRET=OVERRIDE") secrets = Kamal::Secrets.new(secrets_path: "custom/path/secrets") assert_equal "SHARED", secrets["COMMON"] assert_equal "OVERRIDE", secrets["SECRET"] end end end test "custom secrets_path error message" do Dir.mktmpdir do |tmpdir| Dir.chdir(tmpdir) do error = assert_raises(Kamal::ConfigurationError) do Kamal::Secrets.new(secrets_path: "custom/path/secrets")["SECRET"] end assert_equal "Secret 'SECRET' not found, no secret files (custom/path/secrets-common, custom/path/secrets) provided", error.message end end end end ================================================ FILE: test/sshkit_dns_retry_test.rb ================================================ require "test_helper" class SshkitDnsRetryTest < ActiveSupport::TestCase setup do SSHKit::Backend::Netssh.configure { |config| config.dns_retries = 2 } @previous_output = SSHKit.config.output @log_io = StringIO.new SSHKit.config.output = Logger.new(@log_io) end teardown do SSHKit.config.output = @previous_output end test "retries dns errors" do attempts = 0 result = SSHKit::Backend::Netssh.with_dns_retry("example.com") do attempts += 1 raise SocketError, "getaddrinfo: Temporary failure in name resolution" if attempts < 3 :ok end assert_equal 3, attempts assert_equal :ok, result end test "does not retry non dns errors" do attempts = 0 assert_raises Errno::ECONNREFUSED do SSHKit::Backend::Netssh.with_dns_retry("example.com") do attempts += 1 raise Errno::ECONNREFUSED end end assert_equal 1, attempts end test "netssh backend retries dns errors when connecting" do host = SSHKit::Host.new("unknown.example.com") backend = SSHKit::Backend::Netssh.new(host) SSHKit::Backend::Netssh.stubs(:sleep) # avoid actual backoff wait Net::SSH.expects(:start).twice.raises(SocketError, "getaddrinfo: nodename nor servname provided, or not known").then.returns(:ok) assert_equal :ok, backend.send(:connect_ssh, host.hostname, host.username, host.netssh_options) assert_includes @log_io.string, "Retrying DNS for #{host.hostname}" end end ================================================ FILE: test/test_helper.rb ================================================ require "bundler/setup" require "active_support/test_case" require "active_support/testing/autorun" require "active_support/testing/stream" require "rails/test_unit/line_filtering" require "pty" require "debug" require "mocha/minitest" # using #stubs that can alter returns require "minitest/autorun" # using #stub that take args require "sshkit" require "kamal" ActiveSupport::LogSubscriber.logger = ActiveSupport::Logger.new(STDOUT) if ENV["VERBOSE"] # Applies to remote commands only. SSHKit.config.backend = SSHKit::Backend::Printer # Disable connection pooling so we don't spawn the eviction thread as # there's no clean way to kill it after each test SSHKit::Backend::Netssh.pool = SSHKit::Backend::ConnectionPool.new(0) class SSHKit::Backend::Printer def upload!(local, location, **kwargs) local = local.string.inspect if local.respond_to?(:string) puts "Uploading #{local} to #{location} on #{host}" end end # Ensure local commands use the printer backend too. # See https://github.com/capistrano/sshkit/blob/master/lib/sshkit/dsl.rb#L9 module SSHKit module DSL def run_locally(&block) SSHKit::Backend::Printer.new(SSHKit::Host.new(:local), &block).run end end end class ActiveSupport::TestCase include ActiveSupport::Testing::Stream extend Rails::LineFiltering private def stdouted capture(:stdout) { yield }.strip end def stderred capture(:stderr) { yield }.strip end def stub_stdin_tty PTY.open do |master, slave| stub_stdin(master) { yield } end end def stub_stdin_file File.open("/dev/null", "r") do |file| stub_stdin(file) { yield } end end def stub_stdin(io) original_stdin = STDIN.dup STDIN.reopen(io) yield ensure STDIN.reopen(original_stdin) original_stdin.close end def with_test_secrets(**files) setup_test_secrets(**files) yield ensure teardown_test_secrets end def setup_test_secrets(**files) @original_pwd = Dir.pwd @secrets_tmpdir = Dir.mktmpdir copy_fixtures(@secrets_tmpdir) Dir.chdir(@secrets_tmpdir) FileUtils.mkdir_p(".kamal") Dir.chdir(".kamal") do files.each do |filename, contents| File.binwrite(filename.to_s, contents) end end end def teardown_test_secrets Dir.chdir(@original_pwd) FileUtils.rm_rf(@secrets_tmpdir) end def with_error_pages(directory:) error_pages_tmpdir = Dir.mktmpdir Dir.mktmpdir do |tmpdir| copy_fixtures(tmpdir) Dir.chdir(tmpdir) do FileUtils.mkdir_p(directory) Dir.chdir(directory) do File.write("404.html", "404 page") File.write("503.html", "503 page") end yield end end end def copy_fixtures(to_dir) new_test_dir = File.join(to_dir, "test") FileUtils.mkdir_p(new_test_dir) FileUtils.cp_r("test/fixtures/", new_test_dir) end end class SecretAdapterTestCase < ActiveSupport::TestCase setup do `true` # Ensure $? is 0 end private def stub_ticks Kamal::Secrets::Adapters::Base.any_instance.stubs(:`) end def stub_ticks_with(command, succeed: true) # Sneakily run `false`/`true` after a match to set $? to 1/0 stub_ticks.with { |c| c == command && (succeed ? `true` : `false`) } Kamal::Secrets::Adapters::Base.any_instance.stubs(:`) end end ================================================ FILE: test/utils_test.rb ================================================ require "test_helper" class UtilsTest < ActiveSupport::TestCase test "argumentize" do assert_equal [ "--label", "foo=\"\\`bar\\`\"", "--label", "baz=\"qux\"", "--label", :quux, "--label", "quuz=false" ], \ Kamal::Utils.argumentize("--label", { foo: "`bar`", baz: "qux", quux: nil, quuz: false }) end test "argumentize with redacted" do assert_kind_of SSHKit::Redaction, \ Kamal::Utils.argumentize("--label", { foo: "bar" }, sensitive: true).last end test "optionize" do assert_equal [ "--foo", "\"bar\"", "--baz", "\"qux\"", "--quux" ], \ Kamal::Utils.optionize({ foo: "bar", baz: "qux", quux: true }) end test "optionize with" do assert_equal [ "--foo=\"bar\"", "--baz=\"qux\"", "--quux" ], \ Kamal::Utils.optionize({ foo: "bar", baz: "qux", quux: true }, with: "=") end test "no redaction from #to_s" do assert_equal "secret", Kamal::Utils.sensitive("secret").to_s end test "redact from #inspect" do assert_equal "[REDACTED]".inspect, Kamal::Utils.sensitive("secret").inspect end test "redact from SSHKit output" do assert_kind_of SSHKit::Redaction, Kamal::Utils.sensitive("secret") end test "redact from YAML output" do assert_equal "--- ! '[REDACTED]'\n", YAML.dump(Kamal::Utils.sensitive("secret")) end test "escape_shell_value" do assert_equal "\"foo\"", Kamal::Utils.escape_shell_value("foo") assert_equal "\"\\`foo\\`\"", Kamal::Utils.escape_shell_value("`foo`") assert_equal "\"${PWD}\"", Kamal::Utils.escape_shell_value("${PWD}") assert_equal "\"${cat /etc/hostname}\"", Kamal::Utils.escape_shell_value("${cat /etc/hostname}") assert_equal "\"\\${PWD]\"", Kamal::Utils.escape_shell_value("${PWD]") assert_equal "\"\\$(PWD)\"", Kamal::Utils.escape_shell_value("$(PWD)") assert_equal "\"\\$PWD\"", Kamal::Utils.escape_shell_value("$PWD") assert_equal "\"^(https?://)www.example.com/(.*)\\$\"", Kamal::Utils.escape_shell_value("^(https?://)www.example.com/(.*)$") assert_equal "\"https://example.com/\\$2\"", Kamal::Utils.escape_shell_value("https://example.com/$2") end end