[
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\non:\n  push:\n    branches:\n      - main\n  pull_request:\n  workflow_dispatch:\n\npermissions:\n  contents: read\n\njobs:\n  rubocop:\n    name: RuboCop\n    runs-on: ubuntu-latest\n    env:\n      BUNDLE_ONLY: rubocop\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0\n      - name: Setup Ruby and install gems\n        uses: ruby/setup-ruby@708024e6c902387ab41de36e1669e43b5ee7085e # v1.283.0\n        with:\n          ruby-version: 3.3.0\n          bundler-cache: true\n      - name: Run Rubocop\n        run: bundle exec rubocop --parallel\n  tests:\n    strategy:\n      fail-fast: false\n      matrix:\n        ruby-version:\n          - \"3.2\"\n          - \"3.3\"\n          - \"3.4\"\n          - \"4.0\"\n        gemfile:\n          - Gemfile\n          - gemfiles/rails_edge.gemfile\n        exclude:\n          - ruby-version: \"3.2\"\n            gemfile: gemfiles/rails_edge.gemfile\n    name: ${{ format('Tests (Ruby {0})', matrix.ruby-version) }}\n    runs-on: ubuntu-latest\n    env:\n      BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}\n    steps:\n      - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0\n\n      - name: Remove gemfile.lock\n        run: rm Gemfile.lock\n\n      - name: Install Ruby\n        uses: ruby/setup-ruby@708024e6c902387ab41de36e1669e43b5ee7085e # v1.283.0\n        with:\n          ruby-version: ${{ matrix.ruby-version }}\n          bundler-cache: true\n\n      - name: Configure Docker with VFS storage driver\n        run: |\n          sudo systemctl stop docker\n          sudo systemctl stop docker.socket\n          sudo mkdir -p /etc/docker /mnt/docker\n          cat <<'EOF' | sudo tee /etc/docker/daemon.json\n          {\n            \"storage-driver\": \"vfs\",\n            \"data-root\": \"/mnt/docker\"\n          }\n          EOF\n          sudo rm -rf /var/lib/docker/* /mnt/docker/*\n          sudo systemctl start docker\n          timeout 30 sh -c 'until docker info >/dev/null 2>&1; do sleep 1; done'\n          df -h\n\n      - name: Run tests\n        run: bin/test\n        env:\n          RUBYOPT: ${{ startsWith(matrix.ruby-version, '3.4.') && '--enable=frozen-string-literal' || '' }}\n\n      - name: Check disk usage\n        if: always()\n        run: |\n          df -h\n          sudo du -sh /mnt/docker\n"
  },
  {
    "path": ".github/workflows/docker-publish.yml",
    "content": "name: Docker\n\non:\n  workflow_dispatch:\n    inputs:\n      tagInput:\n        description: 'Tag'\n        required: true\n    \n  release:\n    types: [created]\n    tags:\n      - 'v*'\n\njobs:\n  build-and-push-image:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n    steps:\n      -\n        name: Checkout\n        uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0\n      -\n        name: Set up QEMU\n        uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0\n      -\n        name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1\n      -\n        name: Login to GitHub Container Registry\n        uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n      - name: Determine version tag\n        id: version-tag\n        run: |\n          INPUT_VALUE=\"${{ github.event.inputs.tagInput }}\"\n          if [ -z \"$INPUT_VALUE\" ]; then\n            INPUT_VALUE=\"${{ github.ref_name }}\"\n          fi\n          echo \"::set-output name=value::$INPUT_VALUE\"\n      -\n        name: Build and push\n        uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0\n        with:\n          context: .\n          platforms: linux/amd64,linux/arm64\n          push: true\n          tags: |\n            ghcr.io/basecamp/kamal:latest\n            ghcr.io/basecamp/kamal:${{ steps.version-tag.outputs.value }}\n"
  },
  {
    "path": ".gitignore",
    "content": ".byebug_history\n*.gem\ncoverage/*\n.DS_Store\ngemfiles/*.lock\n"
  },
  {
    "path": ".rubocop.yml",
    "content": "inherit_gem:\n  rubocop-rails-omakase: rubocop.yml\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Code of Conduct\n\nAs 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.\n\nWe 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.\n\nThis 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.\n\n## Our standards\n\nExamples of behavior that contributes to creating a positive environment include:\n\n- Using welcoming and inclusive language\n- Being respectful of differing viewpoints and experiences\n- Gracefully accepting constructive criticism\n- Focusing on what is best for the community\n- Showing empathy towards other community members\n\nExamples of unacceptable behavior by participants include:\n\n- The use of sexualized language or imagery and unwelcome sexual attention or advances\n- Trolling, insulting/derogatory comments, and personal or political attacks\n- Public or private harassment\n- Publishing others' private information, such as a physical or electronic address, without explicit permission\n- Other conduct which could reasonably be considered inappropriate in a professional setting\n\n## Responsibilities\n\nProject 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.\n\nProject 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.\n\n## Reporting\n\nIf 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.\n\nWe 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.\n\n## Attribution\n\nThis Code of Conduct is adapted from the Contributor Covenant, version 1.4, available at <https://www.contributor-covenant.org/version/1/4/code-of-conduct.html>.\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Kamal development\n\nThank you for considering contributing to Kamal! This document outlines some guidelines for contributing to this open source project.\n\nPlease make sure to review our [Code of Conduct](CODE_OF_CONDUCT.md) before contributing to Kamal.\n\nThere are several ways you can contribute to the betterment of the project:\n\n- **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).\n- **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)!\n- **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!\n\n## Issues\n\nIf 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.\n\n## Pull Requests\n\nPlease keep the following guidelines in mind when opening a pull request:\n\n- Ensure that your code passes the project's minitests by running ./bin/test.\n- Provide a clear and detailed description of your changes.\n- Keep your changes focused on a single concern.\n- Write clean and readable code that follows the project's code style.\n- Use descriptive variable and function names.\n- Write clear and concise commit messages.\n- Add tests for your changes, if possible.\n- Ensure that your changes don't break existing functionality.\n\n#### Commit message guidelines\n\nA good commit message should describe what changed and why.\n\n## Development\n\nThe `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.\n\nKamal 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.\n\n1. Fork the project repository.\n2. Create a new branch for your contribution.\n3. Write your code or make the desired changes.\n4. **Ensure that your code passes the project's minitests by running ./bin/test.**\n5. Commit your changes and push them to your forked repository.\n6. [Open a pull request](https://github.com/basecamp/kamal/pulls) to the main project repository with a detailed description of your changes.\n\n## License\n\nKamal is released under the MIT License. By contributing to this project, you agree to license your contributions under the same license.\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM ruby:3.4-alpine\n\n# Install docker/buildx-bin\nCOPY --from=docker/buildx-bin /buildx /usr/libexec/docker/cli-plugins/docker-buildx\n\n# Set the working directory to /kamal\nWORKDIR /kamal\n\n# Copy the Gemfile, Gemfile.lock into the container\nCOPY Gemfile Gemfile.lock kamal.gemspec ./\n\n# Required in kamal.gemspec\nCOPY lib/kamal/version.rb /kamal/lib/kamal/version.rb\n\n# Install system dependencies\nRUN apk add --no-cache build-base git docker-cli openssh-client-default yaml-dev \\\n    && gem install bundler --version=2.6.5 \\\n    && bundle install\n\n# Copy the rest of our application code into the container.\n# We do this after bundle install, to avoid having to run bundle\n# every time we do small fixes in the source code.\nCOPY . .\n\n# Install the gem locally from the project folder\nRUN gem build kamal.gemspec && \\\n    gem install ./kamal-*.gem --no-document\n\n# Set the working directory to /workdir\nWORKDIR /workdir\n\n# Tell git it's safe to access /workdir/.git even if\n# the directory is owned by a different user\nRUN git config --global --add safe.directory '*'\n\n# Set the entrypoint to run the installed binary in /workdir\n# Example:  docker run -it -v \"$PWD:/workdir\" kamal init\nENTRYPOINT [\"kamal\"]\n"
  },
  {
    "path": "Gemfile",
    "content": "source \"https://rubygems.org\"\ngit_source(:github) { |repo| \"https://github.com/#{repo}.git\" }\n\ngemspec\n\ngroup :rubocop do\n  gem \"rubocop-rails-omakase\", require: false\nend\n"
  },
  {
    "path": "MIT-LICENSE",
    "content": "Copyright (c) 2023 David Heinemeier Hansson\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Kamal: Deploy web apps anywhere\n\nFrom 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.\n\n➡️ 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).\n\n## Contributing to the documentation\n\nPlease help us improve Kamal's documentation on [the basecamp/kamal-site repository](https://github.com/basecamp/kamal-site).\n\n## License\n\nKamal is released under the [MIT License](https://opensource.org/licenses/MIT).\n"
  },
  {
    "path": "bin/docs",
    "content": "#!/usr/bin/env ruby\nrequire \"stringio\"\n\ndef usage\n  puts \"Usage: #{$0} <kamal_site_repo>\"\n  exit 1\nend\n\nusage if ARGV.size != 1\n\nkamal_site_repo = ARGV[0]\n\nif !File.directory?(kamal_site_repo)\n  puts \"Error: #{kamal_site_repo} is not a directory\"\n  exit 1\nend\n\nDOCS = {\n  \"accessory\" => \"Accessories\",\n  \"alias\" => \"Aliases\",\n  \"boot\" => \"Booting\",\n  \"builder\" => \"Builders\",\n  \"configuration\" => \"Configuration overview\",\n  \"env\" => \"Environment variables\",\n  \"logging\" => \"Logging\",\n  \"proxy\" => \"Proxy\",\n  \"registry\" => \"Docker Registry\",\n  \"role\" => \"Roles\",\n  \"servers\" => \"Servers\",\n  \"ssh\" => \"SSH\",\n  \"sshkit\" => \"SSHKit\"\n}\nDOCS_PATH = \"lib/kamal/configuration/docs\"\n\nclass DocWriter\n  attr_reader :from_file, :to_file, :key, :heading, :body, :output, :in_yaml\n\n  def initialize(from_file, to_dir)\n    @from_file = from_file\n    @key = File.basename(from_file, \".yml\")\n    @to_file = File.join(to_dir, \"#{linkify(DOCS[key])}.md\")\n    @body = File.readlines(from_file)\n    @heading = body.shift.chomp(\"\\n\")\n    @output = nil\n  end\n\n  def write\n    puts \"Writing #{to_file}\"\n    generate_markdown\n    File.write(to_file, output.string)\n  end\n\n  private\n    def generate_markdown\n      @output = StringIO.new\n\n      generate_header\n\n      place = :in_section\n\n      loop do\n        line = body.shift&.chomp(\"\\n\")\n        break if line.nil?\n\n        case place\n        when :new_section, :in_section\n          if line.empty?\n            output.puts\n            place = :new_section\n          elsif line =~ /^ *#/\n            generate_line(line, heading: place == :new_section)\n            place = :in_section\n          else\n            output.puts\n            output.puts \"```yaml\"\n            output.puts line\n            place = :in_yaml\n          end\n        when :in_yaml, :in_empty_line_yaml\n          if line =~ /^ {0,4}#/\n            output.puts \"```\"\n            output.puts\n            generate_line(line, heading: place == :in_empty_line_yaml)\n            place = :in_section\n          elsif line.empty?\n            place = :in_empty_line_yaml\n          else\n            output.puts line\n          end\n        end\n      end\n\n      output.puts \"```\" if place == :in_yaml\n    end\n\n    def generate_header\n      output.puts \"---\"\n      output.puts \"# This file has been generated from the Kamal source, do not edit directly.\"\n      output.puts \"# Find the source of this file at #{DOCS_PATH}/#{key}.yml in the Kamal repository.\"\n      output.puts \"title: #{heading[2..-1]}\"\n      output.puts \"---\"\n      output.puts\n      output.puts heading\n    end\n\n    def generate_line(line, heading: false)\n      line = line.gsub(/^ *#\\s?/, \"\")\n\n      if line =~ /(.*)kamal docs ([a-z]*)(.*)/\n        line = \"#{$1}[#{DOCS[$2]}](../#{linkify(DOCS[$2])})#{$3}\"\n      end\n\n      if line =~ /(.*)https:\\/\\/kamal-deploy.org([a-z\\/-]*)(.*)/\n        line = \"#{$1}[#{titlify($2.split(\"/\").last)}](#{$2})#{$3}\"\n      end\n\n      if heading\n        output.puts \"## [#{line}](##{linkify(line)})\"\n      else\n        output.puts line\n      end\n    end\n\n    def linkify(text)\n      if text == \"Configuration overview\"\n        \"overview\"\n      else\n        text.downcase.gsub(\" \", \"-\")\n      end\n    end\n\n    def titlify(text)\n      text.capitalize.gsub(\"-\", \" \")\n    end\nend\n\nfrom_dir = File.join(File.dirname(__FILE__), \"../#{DOCS_PATH}\")\nto_dir = File.join(kamal_site_repo, \"docs/configuration\")\nDir.glob(\"#{from_dir}/*\") do |from_file|\n  DocWriter.new(from_file, to_dir).write\nend\n"
  },
  {
    "path": "bin/kamal",
    "content": "#!/usr/bin/env ruby\n\n# Prevent failures from being reported twice.\nThread.report_on_exception = false\n\nrequire \"kamal\"\n\nbegin\n  Kamal::Cli::Main.start(ARGV)\nrescue SSHKit::Runner::ExecuteError => e\n  puts \"  \\e[31mERROR (#{e.cause.class}): #{e.message}\\e[0m\"\n  puts e.cause.backtrace if ENV[\"VERBOSE\"]\n  exit 1\nrescue => e\n  puts \"  \\e[31mERROR (#{e.class}): #{e.message}\\e[0m\"\n  puts e.backtrace if ENV[\"VERBOSE\"]\n  exit 1\nend\n"
  },
  {
    "path": "bin/release",
    "content": "#!/usr/bin/env bash\n\nVERSION=$1\n\nprintf \"module Kamal\\n  VERSION = \\\"$VERSION\\\"\\nend\\n\" > ./lib/kamal/version.rb\nbundle\ngit add Gemfile.lock lib/kamal/version.rb\ngit commit -m \"Bump version for $VERSION\"\ngit push\ngit tag v$VERSION\ngit push --tags\ngem build kamal.gemspec\ngem push \"kamal-$VERSION.gem\" --host https://rubygems.org\nrm \"kamal-$VERSION.gem\"\n"
  },
  {
    "path": "bin/test",
    "content": "#!/usr/bin/env ruby\n$: << File.expand_path(\"../test\", __dir__)\n\nrequire \"bundler/setup\"\nrequire \"rails/plugin/test\"\n"
  },
  {
    "path": "gemfiles/rails_edge.gemfile",
    "content": "source 'https://rubygems.org'\ngit_source(:github) { |repo| \"https://github.com/#{repo}.git\" }\n\ngit \"https://github.com/rails/rails.git\" do\n  gem \"railties\"\n  gem \"activesupport\"\nend\n\ngemspec path: \"../\"\n"
  },
  {
    "path": "kamal.gemspec",
    "content": "require_relative \"lib/kamal/version\"\n\nGem::Specification.new do |spec|\n  spec.name        = \"kamal\"\n  spec.version     = Kamal::VERSION\n  spec.authors     = [ \"David Heinemeier Hansson\" ]\n  spec.email       = \"dhh@hey.com\"\n  spec.homepage    = \"https://github.com/basecamp/kamal\"\n  spec.summary     = \"Deploy web apps in containers to servers running Docker with zero downtime.\"\n  spec.license     = \"MIT\"\n  spec.files = Dir[\"lib/**/*\", \"MIT-LICENSE\", \"README.md\"]\n  spec.executables = %w[ kamal ]\n\n  spec.add_dependency \"activesupport\", \">= 7.0\"\n  spec.add_dependency \"sshkit\", \">= 1.23.0\", \"< 2.0\"\n  spec.add_dependency \"net-ssh\", \"~> 7.3\"\n  spec.add_dependency \"thor\", \"~> 1.3\"\n  spec.add_dependency \"dotenv\", \"~> 3.1\"\n  spec.add_dependency \"zeitwerk\", \">= 2.6.18\", \"< 3.0\"\n  spec.add_dependency \"ed25519\", \"~> 1.4\"\n  spec.add_dependency \"bcrypt_pbkdf\", \"~> 1.0\"\n  spec.add_dependency \"concurrent-ruby\", \"~> 1.2\"\n  spec.add_dependency \"base64\", \"~> 0.2\"\n\n  spec.add_development_dependency \"debug\"\n  spec.add_development_dependency \"minitest\", \"< 6\"\n  spec.add_development_dependency \"mocha\"\n  spec.add_development_dependency \"railties\"\nend\n"
  },
  {
    "path": "lib/kamal/cli/accessory.rb",
    "content": "require \"active_support/core_ext/array/conversions\"\nrequire \"concurrent/array\"\n\nclass Kamal::Cli::Accessory < Kamal::Cli::Base\n  desc \"boot [NAME]\", \"Boot new accessory service on host (use NAME=all to boot all accessories)\"\n  def boot(name, prepare: true)\n    with_lock do\n      if name == \"all\"\n        KAMAL.accessory_names.each { |accessory_name| boot(accessory_name) }\n      else\n        prepare(name) if prepare\n\n        with_accessory(name) do |accessory, hosts|\n          booted_hosts = Concurrent::Array.new\n          on(hosts) do |host|\n            booted_hosts << host.to_s if capture_with_info(*accessory.info(all: true, quiet: true)).strip.presence\n          end\n\n          if booted_hosts.any?\n            say \"Skipping booting `#{name}` on #{booted_hosts.sort.join(\", \")}, a container already exists\", :yellow\n            hosts -= booted_hosts\n          end\n\n          directories(name)\n          upload(name)\n\n          on(hosts) do |host|\n            execute *KAMAL.auditor.record(\"Booted #{name} accessory\"), verbosity: :debug\n            execute *accessory.ensure_env_directory\n            upload! accessory.secrets_io, accessory.secrets_path, mode: \"0600\"\n            execute *accessory.run(host: host)\n\n            if accessory.running_proxy?\n              target = capture_with_info(*accessory.container_id_for(container_name: accessory.service_name, only_running: true)).strip\n              execute *accessory.deploy(target: target)\n            end\n          end\n        end\n      end\n    end\n  end\n\n  desc \"upload [NAME]\", \"Upload accessory files to host\", hide: true\n  def upload(name)\n    with_lock do\n      with_accessory(name) do |accessory, hosts|\n        on(hosts) do\n          accessory.files.each do |(local, config)|\n            remote = config[:host_path]\n            accessory.ensure_local_file_present(local)\n\n            execute *accessory.make_directory_for(remote)\n            upload! local, remote\n            execute :chmod, config[:mode], remote\n            execute :chown, config[:owner], remote if config[:owner]\n          end\n        end\n      end\n    end\n  end\n\n  desc \"directories [NAME]\", \"Create accessory directories on host\", hide: true\n  def directories(name)\n    with_lock do\n      with_accessory(name) do |accessory, hosts|\n        on(hosts) do\n          accessory.directories.each do |(local, config)|\n            execute *accessory.make_directory(local)\n            execute :chmod, config[:mode], local if config[:mode]\n            execute :chown, config[:owner], local if config[:owner]\n          end\n        end\n      end\n    end\n  end\n\n  desc \"reboot [NAME]\", \"Reboot existing accessory on host (stop container, remove container, start new container; use NAME=all to boot all accessories)\"\n  def reboot(name)\n    with_lock do\n      if name == \"all\"\n        KAMAL.accessory_names.each { |accessory_name| reboot(accessory_name) }\n      else\n        prepare(name)\n        pull_image(name)\n        stop(name)\n        remove_container(name)\n        boot(name, prepare: false)\n      end\n    end\n  end\n\n  desc \"start [NAME]\", \"Start existing accessory container on host\"\n  def start(name)\n    with_lock do\n      with_accessory(name) do |accessory, hosts|\n        on(hosts) do\n          execute *KAMAL.auditor.record(\"Started #{name} accessory\"), verbosity: :debug\n          execute *accessory.start\n          if accessory.running_proxy?\n            target = capture_with_info(*accessory.container_id_for(container_name: accessory.service_name, only_running: true)).strip\n            execute *accessory.deploy(target: target)\n          end\n        end\n      end\n    end\n  end\n\n  desc \"stop [NAME]\", \"Stop existing accessory container on host\"\n  def stop(name)\n    with_lock do\n      with_accessory(name) do |accessory, hosts|\n        on(hosts) do\n          execute *KAMAL.auditor.record(\"Stopped #{name} accessory\"), verbosity: :debug\n          execute *accessory.stop, raise_on_non_zero_exit: false\n\n          if accessory.running_proxy?\n            target = capture_with_info(*accessory.container_id_for(container_name: accessory.service_name, only_running: true)).strip\n            execute *accessory.remove if target\n          end\n        end\n      end\n    end\n  end\n\n  desc \"restart [NAME]\", \"Restart existing accessory container on host\"\n  def restart(name)\n    with_lock do\n      stop(name)\n      start(name)\n    end\n  end\n\n  desc \"details [NAME]\", \"Show details about accessory on host (use NAME=all to show all accessories)\"\n  def details(name)\n    quiet = options[:quiet]\n    if name == \"all\"\n      KAMAL.accessory_names.each { |accessory_name| details(accessory_name) }\n    else\n      type = \"Accessory #{name}\"\n      with_accessory(name) do |accessory, hosts|\n        on(hosts) { puts_by_host host, capture_with_info(*accessory.info), type: type, quiet: quiet }\n      end\n    end\n  end\n\n  desc \"exec [NAME] [CMD...]\", \"Execute a custom command on servers within the accessory container (use --help to show options)\"\n  option :interactive, aliases: \"-i\", type: :boolean, default: false, desc: \"Execute command over ssh for an interactive shell (use for console/bash)\"\n  option :reuse, type: :boolean, default: false, desc: \"Reuse currently running container instead of starting a new one\"\n  def exec(name, *cmd)\n    pre_connect_if_required\n\n    cmd = Kamal::Utils.join_commands(cmd)\n    quiet = options[:quiet]\n\n    with_accessory(name) do |accessory, hosts|\n      case\n      when options[:interactive] && options[:reuse]\n        say \"Launching interactive command via SSH from existing container...\", :magenta\n        run_locally { exec accessory.execute_in_existing_container_over_ssh(cmd) }\n\n      when options[:interactive]\n        say \"Launching interactive command via SSH from new container...\", :magenta\n        on(accessory.hosts.first) { execute *KAMAL.registry.login }\n        run_locally { exec accessory.execute_in_new_container_over_ssh(cmd) }\n\n      when options[:reuse]\n        say \"Launching command from existing container...\", :magenta\n        on(hosts) do |host|\n          execute *KAMAL.auditor.record(\"Executed cmd '#{cmd}' on #{name} accessory\"), verbosity: :debug\n          puts_by_host host, capture_with_info(*accessory.execute_in_existing_container(cmd)), quiet: quiet\n        end\n\n      else\n        say \"Launching command from new container...\", :magenta\n        on(hosts) do |host|\n          execute *KAMAL.registry.login\n          execute *KAMAL.auditor.record(\"Executed cmd '#{cmd}' on #{name} accessory\"), verbosity: :debug\n          puts_by_host host, capture_with_info(*accessory.execute_in_new_container(cmd)), quiet: quiet\n        end\n      end\n    end\n  end\n\n  desc \"logs [NAME]\", \"Show log lines from accessory on host (use --help to show options)\"\n  option :since, aliases: \"-s\", desc: \"Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)\"\n  option :lines, type: :numeric, aliases: \"-n\", desc: \"Number of log lines to pull from each server\"\n  option :grep, aliases: \"-g\", desc: \"Show lines with grep match only (use this to fetch specific requests by id)\"\n  option :grep_options, desc: \"Additional options supplied to grep\"\n  option :follow, aliases: \"-f\", desc: \"Follow logs on primary server (or specific host set by --hosts)\"\n  option :skip_timestamps, type: :boolean, aliases: \"-T\", desc: \"Skip appending timestamps to logging output\"\n  def logs(name)\n    with_accessory(name) do |accessory, hosts|\n      grep = options[:grep]\n      grep_options = options[:grep_options]\n      timestamps = !options[:skip_timestamps]\n\n      if options[:follow]\n        run_locally do\n          info \"Following logs on #{hosts}...\"\n          info accessory.follow_logs(timestamps: timestamps, grep: grep, grep_options: grep_options)\n          exec accessory.follow_logs(timestamps: timestamps, grep: grep, grep_options: grep_options)\n        end\n      else\n        since = options[:since]\n        lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set\n\n        on(hosts) do\n          puts capture_with_info(*accessory.logs(timestamps: timestamps, since: since, lines: lines, grep: grep, grep_options: grep_options))\n        end\n      end\n    end\n  end\n\n  desc \"pull_image [NAME]\", \"Pull accessory image on host\", hide: true\n  def pull_image(name)\n    with_lock do\n      with_accessory(name) do |accessory, hosts|\n        on(hosts) do\n          execute *KAMAL.auditor.record(\"Pull #{name} accessory image\"), verbosity: :debug\n          execute *accessory.pull_image\n        end\n      end\n    end\n  end\n\n  desc \"remove [NAME]\", \"Remove accessory container, image and data directory from host (use NAME=all to remove all accessories)\"\n  option :confirmed, aliases: \"-y\", type: :boolean, default: false, desc: \"Proceed without confirmation question\"\n  def remove(name)\n    confirming \"This will remove all containers, images and data directories for #{name}. Are you sure?\" do\n      with_lock do\n        if name == \"all\"\n          KAMAL.accessory_names.each { |accessory_name| remove_accessory(accessory_name) }\n        else\n          remove_accessory(name)\n        end\n      end\n    end\n  end\n\n  desc \"remove_container [NAME]\", \"Remove accessory container from host\", hide: true\n  def remove_container(name)\n    with_lock do\n      with_accessory(name) do |accessory, hosts|\n        on(hosts) do\n          execute *KAMAL.auditor.record(\"Remove #{name} accessory container\"), verbosity: :debug\n          execute *accessory.remove_container\n        end\n      end\n    end\n  end\n\n  desc \"remove_image [NAME]\", \"Remove accessory image from host\", hide: true\n  def remove_image(name)\n    with_lock do\n      with_accessory(name) do |accessory, hosts|\n        on(hosts) do\n          execute *KAMAL.auditor.record(\"Removed #{name} accessory image\"), verbosity: :debug\n          execute *accessory.remove_image\n        end\n      end\n    end\n  end\n\n  desc \"remove_service_directory [NAME]\", \"Remove accessory directory used for uploaded files and data directories from host\", hide: true\n  def remove_service_directory(name)\n    with_lock do\n      with_accessory(name) do |accessory, hosts|\n        on(hosts) do\n          execute *accessory.remove_service_directory\n        end\n      end\n    end\n  end\n\n  desc \"upgrade\", \"Upgrade accessories from Kamal 1.x to 2.0 (restart them in 'kamal' network)\"\n  option :rolling, type: :boolean, default: false, desc: \"Upgrade one host at a time\"\n  option :confirmed, aliases: \"-y\", type: :boolean, default: false, desc: \"Proceed without confirmation question\"\n  def upgrade(name)\n    confirming \"This will restart all accessories\" do\n      with_lock do\n        host_groups = options[:rolling] ? KAMAL.accessory_hosts : [ KAMAL.accessory_hosts ]\n        host_groups.each do |hosts|\n          host_list = Array(hosts).join(\",\")\n          KAMAL.with_specific_hosts(hosts) do\n            say \"Upgrading #{name} accessories on #{host_list}...\", :magenta\n            reboot name\n            say \"Upgraded #{name} accessories on #{host_list}...\", :magenta\n          end\n        end\n      end\n    end\n  end\n\n  private\n    def with_accessory(name)\n      if KAMAL.config.accessory(name)\n        accessory = KAMAL.accessory(name)\n        yield accessory, accessory_hosts(accessory)\n      else\n        error_on_missing_accessory(name)\n      end\n    end\n\n    def error_on_missing_accessory(name)\n      options = KAMAL.accessory_names.presence\n\n      error \\\n        \"No accessory by the name of '#{name}'\" +\n        (options ? \" (options: #{options.to_sentence})\" : \"\")\n    end\n\n    def accessory_hosts(accessory)\n      KAMAL.accessory_hosts & accessory.hosts\n    end\n\n    def remove_accessory(name)\n      stop(name)\n      remove_container(name)\n      remove_image(name)\n      remove_service_directory(name)\n    end\n\n    def prepare(name)\n      with_accessory(name) do |accessory, hosts|\n        on(hosts) do\n          execute *KAMAL.registry.login(registry_config: accessory.registry)\n          execute *KAMAL.docker.create_network\n        rescue SSHKit::Command::Failed => e\n          raise unless e.message.include?(\"already exists\")\n        end\n      end\n    end\nend\n"
  },
  {
    "path": "lib/kamal/cli/alias/command.rb",
    "content": "class Kamal::Cli::Alias::Command < Thor::DynamicCommand\n  def run(instance, args = [])\n    if (command = KAMAL.resolve_alias(name))\n      KAMAL.reset\n      Kamal::Cli::Main.start(Shellwords.split(command) + ARGV[1..-1])\n    else\n      super\n    end\n  end\nend\n"
  },
  {
    "path": "lib/kamal/cli/app/assets.rb",
    "content": "class Kamal::Cli::App::Assets\n  attr_reader :host, :role, :sshkit\n  delegate :execute, :capture_with_info, :info, to: :sshkit\n  delegate :assets?, to: :role\n\n  def initialize(host, role, sshkit)\n    @host = host\n    @role = role\n    @sshkit = sshkit\n  end\n\n  def run\n    if assets?\n      execute *app.extract_assets\n      old_version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip\n      execute *app.sync_asset_volumes(old_version: old_version)\n    end\n  end\n\n  private\n    def app\n      @app ||= KAMAL.app(role: role, host: host)\n    end\nend\n"
  },
  {
    "path": "lib/kamal/cli/app/boot.rb",
    "content": "class Kamal::Cli::App::Boot\n  attr_reader :host, :role, :version, :barrier, :sshkit\n  delegate :execute, :capture_with_info, :capture_with_pretty_json, :info, :error, :upload!, to: :sshkit\n  delegate :assets?, :running_proxy?, to: :role\n\n  def initialize(host, role, sshkit, version, barrier)\n    @host = host\n    @role = role\n    @version = version\n    @barrier = barrier\n    @sshkit = sshkit\n  end\n\n  def run\n    old_version = old_version_renamed_if_clashing\n\n    wait_at_barrier if queuer?\n\n    begin\n      start_new_version\n    rescue => e\n      close_barrier if gatekeeper?\n      stop_new_version\n      raise\n    end\n\n    release_barrier if gatekeeper?\n\n    if old_version\n      stop_old_version(old_version)\n    end\n  end\n\n  private\n    def old_version_renamed_if_clashing\n      if capture_with_info(*app.container_id_for_version(version), raise_on_non_zero_exit: false).present?\n        renamed_version = \"#{version}_replaced_#{SecureRandom.hex(8)}\"\n        info \"Renaming container #{version} to #{renamed_version} as already deployed on #{host}\"\n        audit(\"Renaming container #{version} to #{renamed_version}\")\n        execute *app.rename_container(version: version, new_version: renamed_version)\n      end\n\n      capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip.presence\n    end\n\n    def start_new_version\n      audit \"Booted app version #{version}\"\n      hostname = \"#{host.to_s[0...51].chomp(\".\")}-#{SecureRandom.hex(6)}\"\n\n      execute *app.ensure_env_directory\n      upload! role.secrets_io(host), role.secrets_path, mode: \"0600\"\n\n      execute *app.run(hostname: hostname)\n      if running_proxy?\n        endpoint = capture_with_info(*app.container_id_for_version(version)).strip\n        raise Kamal::Cli::BootError, \"Failed to get endpoint for #{role} on #{host}, did the container boot?\" if endpoint.empty?\n        execute *app.deploy(target: endpoint)\n      else\n        Kamal::Cli::Healthcheck::Poller.wait_for_healthy { capture_with_info(*app.status(version: version)) }\n      end\n    rescue => e\n      error \"Failed to boot #{role} on #{host}\"\n      raise e\n    end\n\n    def stop_new_version\n      execute *app.stop(version: version), raise_on_non_zero_exit: false\n    end\n\n    def stop_old_version(version)\n      execute *app.stop(version: version), raise_on_non_zero_exit: false\n      execute *app.clean_up_assets if assets?\n      execute *app.clean_up_error_pages if KAMAL.config.error_pages_path\n    end\n\n    def release_barrier\n      if barrier.open\n        info \"First #{KAMAL.primary_role} container is healthy on #{host}, booting any other roles\"\n      end\n    end\n\n    def wait_at_barrier\n      info \"Waiting for the first healthy #{KAMAL.primary_role} container before booting #{role} on #{host}...\"\n      barrier.wait\n      info \"First #{KAMAL.primary_role} container is healthy, booting #{role} on #{host}...\"\n    rescue Kamal::Cli::Healthcheck::Error\n      info \"First #{KAMAL.primary_role} container is unhealthy, not booting #{role} on #{host}\"\n      raise\n    end\n\n    def close_barrier\n      if barrier.close\n        info \"First #{KAMAL.primary_role} container is unhealthy on #{host}, not booting any other roles\"\n        begin\n          error capture_with_info(*app.logs(container_id: app.container_id_for_version(version)))\n          error capture_with_info(*app.container_health_log(version: version))\n        rescue SSHKit::Command::Failed\n          error \"Could not fetch logs for #{version}\"\n        end\n      end\n    end\n\n    def barrier_role?\n      role == KAMAL.primary_role\n    end\n\n    def app\n      @app ||= KAMAL.app(role: role, host: host)\n    end\n\n    def auditor\n      @auditor = KAMAL.auditor(role: role)\n    end\n\n    def audit(message)\n      execute *auditor.record(message), verbosity: :debug\n    end\n\n    def gatekeeper?\n      barrier && barrier_role?\n    end\n\n    def queuer?\n      barrier && !barrier_role?\n    end\nend\n"
  },
  {
    "path": "lib/kamal/cli/app/error_pages.rb",
    "content": "class Kamal::Cli::App::ErrorPages\n  ERROR_PAGES_GLOB = \"{4??.html,5??.html}\"\n\n  attr_reader :host, :sshkit\n  delegate :upload!, :execute, to: :sshkit\n\n  def initialize(host, sshkit)\n    @host = host\n    @sshkit = sshkit\n  end\n\n  def run\n    if KAMAL.config.error_pages_path\n      with_error_pages_tmpdir do |local_error_pages_dir|\n        execute *KAMAL.app.create_error_pages_directory\n        upload! local_error_pages_dir, KAMAL.config.proxy_boot.error_pages_directory, mode: \"0700\", recursive: true\n      end\n    end\n  end\n\n  private\n    def with_error_pages_tmpdir\n      Dir.mktmpdir(\"kamal-error-pages\") do |tmpdir|\n        error_pages_dir = File.join(tmpdir, KAMAL.config.version)\n        FileUtils.mkdir(error_pages_dir)\n\n        if (files = Dir[File.join(KAMAL.config.error_pages_path, ERROR_PAGES_GLOB)]).any?\n          FileUtils.cp(files, error_pages_dir)\n          yield error_pages_dir\n        end\n      end\n    end\nend\n"
  },
  {
    "path": "lib/kamal/cli/app/ssl_certificates.rb",
    "content": "class Kamal::Cli::App::SslCertificates\n  attr_reader :host, :role, :sshkit\n  delegate :execute, :info, :upload!, to: :sshkit\n\n  def initialize(host, role, sshkit)\n    @host = host\n    @role = role\n    @sshkit = sshkit\n  end\n\n  def run\n    if role.running_proxy? && role.proxy.custom_ssl_certificate?\n      info \"Writing SSL certificates for #{role.name} on #{host}\"\n      execute *app.create_ssl_directory\n      if cert_content = role.proxy.certificate_pem_content\n        upload!(StringIO.new(cert_content), role.proxy.host_tls_cert, mode: \"0644\")\n      end\n      if key_content = role.proxy.private_key_pem_content\n        upload!(StringIO.new(key_content), role.proxy.host_tls_key, mode: \"0644\")\n      end\n    end\n  end\n\n  private\n    def app\n      @app ||= KAMAL.app(role: role, host: host)\n    end\nend\n"
  },
  {
    "path": "lib/kamal/cli/app.rb",
    "content": "class Kamal::Cli::App < Kamal::Cli::Base\n  desc \"boot\", \"Boot app on servers (or reboot app if already running)\"\n  def boot\n    with_lock do\n      say \"Get most recent version available as an image...\", :magenta unless options[:version]\n      using_version(version_or_latest) do |version|\n        say \"Start container with version #{version} (or reboot if already running)...\", :magenta\n\n        # Assets are prepared in a separate step to ensure they are on all hosts before booting\n        on(KAMAL.app_hosts) do\n          Kamal::Cli::App::ErrorPages.new(host, self).run\n\n          KAMAL.roles_on(host).each do |role|\n            Kamal::Cli::App::Assets.new(host, role, self).run\n            Kamal::Cli::App::SslCertificates.new(host, role, self).run\n          end\n        end\n\n        # Primary hosts and roles are returned first, so they can open the barrier\n        barrier = Kamal::Cli::Healthcheck::Barrier.new\n\n        host_boot_groups.each do |hosts|\n          host_list = Array(hosts).join(\",\")\n          run_hook \"pre-app-boot\", hosts: host_list\n\n          on_roles(KAMAL.roles, hosts: hosts, parallel: KAMAL.config.boot.parallel_roles) do |host, role|\n            Kamal::Cli::App::Boot.new(host, role, self, version, barrier).run\n          end\n\n          run_hook \"post-app-boot\", hosts: host_list\n          sleep KAMAL.config.boot.wait if KAMAL.config.boot.wait\n        end\n\n        # Tag once the app booted on all hosts\n        on(KAMAL.app_hosts) do |host|\n          execute *KAMAL.auditor.record(\"Tagging #{KAMAL.config.absolute_image} as the latest image\"), verbosity: :debug\n          execute *KAMAL.app.tag_latest_image\n        end\n      end\n    end\n  end\n\n  desc \"start\", \"Start existing app container on servers\"\n  def start\n    with_lock do\n      on_roles(KAMAL.roles, hosts: KAMAL.app_hosts, parallel: KAMAL.config.boot.parallel_roles) do |host, role|\n        app = KAMAL.app(role: role, host: host)\n        execute *KAMAL.auditor.record(\"Started app version #{KAMAL.config.version}\"), verbosity: :debug\n        execute *app.start, raise_on_non_zero_exit: false\n\n        if role.running_proxy?\n          version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip\n          endpoint = capture_with_info(*app.container_id_for_version(version)).strip\n          raise Kamal::Cli::BootError, \"Failed to get endpoint for #{role} on #{host}, did the container boot?\" if endpoint.empty?\n\n          execute *app.deploy(target: endpoint)\n        end\n      end\n    end\n  end\n\n  desc \"stop\", \"Stop app container on servers\"\n  def stop\n    with_lock do\n      on_roles(KAMAL.roles, hosts: KAMAL.app_hosts, parallel: KAMAL.config.boot.parallel_roles) do |host, role|\n        app = KAMAL.app(role: role, host: host)\n        execute *KAMAL.auditor.record(\"Stopped app\", role: role), verbosity: :debug\n\n        if role.running_proxy?\n          version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip\n          endpoint = capture_with_info(*app.container_id_for_version(version)).strip\n          if endpoint.present?\n            execute *app.remove, raise_on_non_zero_exit: false\n          end\n        end\n\n        execute *app.stop, raise_on_non_zero_exit: false\n      end\n    end\n  end\n\n  # FIXME: Drop in favor of just containers?\n  desc \"details\", \"Show details about app containers\"\n  def details\n    quiet = options[:quiet]\n    on_roles(KAMAL.roles, hosts: KAMAL.app_hosts) do |host, role|\n      puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).info), quiet: quiet\n    end\n  end\n\n  desc \"exec [CMD...]\", \"Execute a custom command on servers within the app container (use --help to show options)\"\n  option :interactive, aliases: \"-i\", type: :boolean, default: false, desc: \"Execute command over ssh for an interactive shell (use for console/bash)\"\n  option :reuse, type: :boolean, default: false, desc: \"Reuse currently running container instead of starting a new one\"\n  option :env, aliases: \"-e\", type: :hash, desc: \"Set environment variables for the command\"\n  option :detach, type: :boolean, default: false, desc: \"Execute command in a detached container\"\n  def exec(*cmd)\n    pre_connect_if_required\n\n    if (incompatible_options = [ :interactive, :reuse ].select { |key| options[:detach] && options[key] }.presence)\n      raise ArgumentError, \"Detach is not compatible with #{incompatible_options.join(\" or \")}\"\n    end\n\n    if cmd.empty?\n      raise ArgumentError, \"No command provided. You must specify a command to execute.\"\n    end\n\n    cmd = Kamal::Utils.join_commands(cmd)\n    env = options[:env]\n    detach = options[:detach]\n    quiet = options[:quiet]\n    case\n    when options[:interactive] && options[:reuse]\n      say \"Get current version of running container...\", :magenta unless options[:version]\n      using_version(options[:version] || current_running_version) do |version|\n        say \"Launching interactive command with version #{version} via SSH from existing container on #{KAMAL.primary_host}...\", :magenta\n        run_locally { exec KAMAL.app(role: KAMAL.primary_role, host: KAMAL.primary_host).execute_in_existing_container_over_ssh(cmd, env: env) }\n      end\n\n    when options[:interactive]\n      say \"Get most recent version available as an image...\", :magenta unless options[:version]\n      using_version(version_or_latest) do |version|\n        say \"Launching interactive command with version #{version} via SSH from new container on #{KAMAL.primary_host}...\", :magenta\n        on(KAMAL.primary_host) { execute *KAMAL.registry.login }\n        run_locally do\n          exec KAMAL.app(role: KAMAL.primary_role, host: KAMAL.primary_host).execute_in_new_container_over_ssh(cmd, env: env)\n        end\n      end\n\n    when options[:reuse]\n      say \"Get current version of running container...\", :magenta unless options[:version]\n      using_version(options[:version] || current_running_version) do |version|\n        say \"Launching command with version #{version} from existing container...\", :magenta\n\n        on_roles(KAMAL.roles, hosts: KAMAL.app_hosts) do |host, role|\n          execute *KAMAL.auditor.record(\"Executed cmd '#{cmd}' on app version #{version}\", role: role), verbosity: :debug\n          puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).execute_in_existing_container(cmd, env: env)), quiet: quiet\n        end\n      end\n\n    else\n      say \"Get most recent version available as an image...\", :magenta unless options[:version]\n      using_version(version_or_latest) do |version|\n        say \"Launching command with version #{version} from new container...\", :magenta\n        on(KAMAL.app_hosts) { execute *KAMAL.registry.login }\n\n        on_roles(KAMAL.roles, hosts: KAMAL.app_hosts) do |host, role|\n          execute *KAMAL.auditor.record(\"Executed cmd '#{cmd}' on app version #{version}\"), verbosity: :debug\n          puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).execute_in_new_container(cmd, env: env, detach: detach)), quiet: quiet\n        end\n      end\n    end\n  end\n\n  desc \"containers\", \"Show app containers on servers\"\n  def containers\n    quiet = options[:quiet]\n    on(KAMAL.app_hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.app.list_containers), quiet: quiet }\n  end\n\n  desc \"stale_containers\", \"Detect app stale containers\"\n  option :stop, aliases: \"-s\", type: :boolean, default: false, desc: \"Stop the stale containers found\"\n  def stale_containers\n    quiet = options[:quiet]\n    stop = options[:stop]\n\n    with_lock_if_stopping do\n      on_roles(KAMAL.roles, hosts: KAMAL.app_hosts) do |host, role|\n        app = KAMAL.app(role: role, host: host)\n        versions = capture_with_info(*app.list_versions, raise_on_non_zero_exit: false).split(\"\\n\")\n        versions -= [ capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip ]\n\n        versions.each do |version|\n          if stop\n            puts_by_host host, \"Stopping stale container for role #{role} with version #{version}\", quiet: quiet\n            execute *app.stop(version: version), raise_on_non_zero_exit: false\n          else\n            puts_by_host host,  \"Detected stale container for role #{role} with version #{version} (use `kamal app stale_containers --stop` to stop)\", quiet: quiet\n          end\n        end\n      end\n    end\n  end\n\n  desc \"images\", \"Show app images on servers\"\n  def images\n    quiet = options[:quiet]\n    on(KAMAL.app_hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.app.list_images), quiet: quiet }\n  end\n\n  desc \"logs\", \"Show log lines from app on servers (use --help to show options)\"\n  option :since, aliases: \"-s\", desc: \"Show lines since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)\"\n  option :lines, type: :numeric, aliases: \"-n\", desc: \"Number of lines to show from each server\"\n  option :grep, aliases: \"-g\", desc: \"Show lines with grep match only (use this to fetch specific requests by id)\"\n  option :grep_options, desc: \"Additional options supplied to grep\"\n  option :follow, aliases: \"-f\", desc: \"Follow log on primary server (or specific host set by --hosts)\"\n  option :skip_timestamps, type: :boolean, aliases: \"-T\", desc: \"Skip appending timestamps to logging output\"\n  option :container_id, desc: \"Docker container ID to fetch logs\"\n  def logs\n    # FIXME: Catch when app containers aren't running\n\n    grep = options[:grep]\n    grep_options = options[:grep_options]\n    since = options[:since]\n    container_id = options[:container_id]\n    timestamps = !options[:skip_timestamps]\n    quiet = options[:quiet]\n\n    if options[:follow]\n      lines = options[:lines].presence || ((since || grep) ? nil : 10) # Default to 10 lines if since or grep isn't set\n\n      run_locally do\n        info \"Following logs on #{KAMAL.primary_host}...\"\n\n        KAMAL.specific_roles ||= [ KAMAL.primary_role.name ]\n        role = KAMAL.roles_on(KAMAL.primary_host).first\n\n        app = KAMAL.app(role: role, host: host)\n        info app.follow_logs(host: KAMAL.primary_host, container_id: container_id, timestamps: timestamps, lines: lines, grep: grep, grep_options: grep_options)\n        exec app.follow_logs(host: KAMAL.primary_host, container_id: container_id, timestamps: timestamps, lines: lines, grep: grep, grep_options: grep_options)\n      end\n    else\n      lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set\n\n      on_roles(KAMAL.roles, hosts: KAMAL.app_hosts) do |host, role|\n        begin\n          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\n        rescue SSHKit::Command::Failed\n          puts_by_host host, \"Nothing found\", quiet: quiet\n        end\n      end\n    end\n  end\n\n  desc \"remove\", \"Remove app containers and images from servers\"\n  def remove\n    with_lock do\n      stop\n      remove_containers\n      remove_images\n      remove_app_directories\n    end\n  end\n\n  desc \"live\", \"Set the app to live mode\"\n  def live\n    with_lock do\n      on_roles(KAMAL.roles, hosts: KAMAL.proxy_hosts) do |host, role|\n        execute *KAMAL.app(role: role, host: host).live if role.running_proxy?\n      end\n    end\n  end\n\n  desc \"maintenance\", \"Set the app to maintenance mode\"\n  option :drain_timeout, type: :numeric, desc: \"How long to allow in-flight requests to complete (defaults to drain_timeout from config)\"\n  option :message, type: :string, desc: \"Message to display to clients while stopped\"\n  def maintenance\n    maintenance_options = { drain_timeout: options[:drain_timeout] || KAMAL.config.drain_timeout, message: options[:message] }\n\n    with_lock do\n      on_roles(KAMAL.roles, hosts: KAMAL.proxy_hosts) do |host, role|\n        execute *KAMAL.app(role: role, host: host).maintenance(**maintenance_options) if role.running_proxy?\n      end\n    end\n  end\n\n  desc \"remove_container [VERSION]\", \"Remove app container with given version from servers\", hide: true\n  def remove_container(version)\n    with_lock do\n      on_roles(KAMAL.roles, hosts: KAMAL.app_hosts) do |host, role|\n        execute *KAMAL.auditor.record(\"Removed app container with version #{version}\", role: role), verbosity: :debug\n        execute *KAMAL.app(role: role, host: host).remove_container(version: version)\n      end\n    end\n  end\n\n  desc \"remove_containers\", \"Remove all app containers from servers\", hide: true\n  def remove_containers\n    with_lock do\n      on_roles(KAMAL.roles, hosts: KAMAL.app_hosts) do |host, role|\n        execute *KAMAL.auditor.record(\"Removed all app containers\", role: role), verbosity: :debug\n        execute *KAMAL.app(role: role, host: host).remove_containers\n      end\n    end\n  end\n\n  desc \"remove_images\", \"Remove all app images from servers\", hide: true\n  def remove_images\n    with_lock do\n      on(hosts_removing_all_roles) do\n        execute *KAMAL.auditor.record(\"Removed all app images\"), verbosity: :debug\n        execute *KAMAL.app.remove_images\n      end\n    end\n  end\n\n  desc \"remove_app_directories\", \"Remove the app directories from servers\", hide: true\n  def remove_app_directories\n    with_lock do\n      on(hosts_removing_all_roles) do |host|\n        execute *KAMAL.server.remove_app_directory, raise_on_non_zero_exit: false\n        execute *KAMAL.auditor.record(\"Removed #{KAMAL.config.app_directory}\"), verbosity: :debug\n        execute *KAMAL.app.remove_proxy_app_directory, raise_on_non_zero_exit: false\n      end\n    end\n  end\n\n  desc \"version\", \"Show app version currently running on servers\"\n  def version\n    quiet = options[:quiet]\n    on(KAMAL.app_hosts) do |host|\n      role = KAMAL.roles_on(host).first\n      puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).current_running_version).strip, quiet: quiet\n    end\n  end\n\n  private\n    def hosts_removing_all_roles\n      KAMAL.app_hosts.select { |host| KAMAL.roles_on(host).map(&:name).sort == KAMAL.config.host_roles(host.to_s).map(&:name).sort }\n    end\n\n    def using_version(new_version)\n      if new_version\n        begin\n          old_version = KAMAL.config.version\n          KAMAL.config.version = new_version\n          yield new_version\n        ensure\n          KAMAL.config.version = old_version\n        end\n      else\n        yield KAMAL.config.version\n      end\n    end\n\n    def current_running_version(host: KAMAL.primary_host)\n      version = nil\n      on(host) do\n        role = KAMAL.roles_on(host).first\n        version = capture_with_info(*KAMAL.app(role: role, host: host).current_running_version).strip\n      end\n      version.presence\n    end\n\n    def version_or_latest\n      options[:version] || KAMAL.config.latest_tag\n    end\n\n    def with_lock_if_stopping\n      if options[:stop]\n        with_lock { yield }\n      else\n        yield\n      end\n    end\n\n    def host_boot_groups\n      KAMAL.config.boot.limit ? KAMAL.app_hosts.each_slice(KAMAL.config.boot.limit).to_a : [ KAMAL.app_hosts ]\n    end\nend\n"
  },
  {
    "path": "lib/kamal/cli/base.rb",
    "content": "require \"thor\"\nrequire \"kamal/sshkit_with_ext\"\n\nmodule Kamal::Cli\n  class Base < Thor\n    include SSHKit::DSL\n\n    VERBOSITY = { verbose: :debug, quiet: :error }.freeze\n\n    def self.exit_on_failure?() true end\n    def self.dynamic_command_class() Kamal::Cli::Alias::Command end\n\n    class_option :verbose, type: :boolean, aliases: \"-v\", desc: \"Detailed logging\"\n    class_option :quiet, type: :boolean, aliases: \"-q\", desc: \"Minimal logging\"\n\n    class_option :version, desc: \"Run commands against a specific app version\"\n\n    class_option :primary, type: :boolean, aliases: \"-p\", desc: \"Run commands only on primary host instead of all\"\n    class_option :hosts, aliases: \"-h\", desc: \"Run commands on these hosts instead of all (separate by comma, supports wildcards with *)\"\n    class_option :roles, aliases: \"-r\", desc: \"Run commands on these roles instead of all (separate by comma, supports wildcards with *)\"\n\n    class_option :config_file, aliases: \"-c\", default: \"config/deploy.yml\", desc: \"Path to config file\"\n    class_option :destination, aliases: \"-d\", desc: \"Specify destination to be used for config file (staging -> deploy.staging.yml)\"\n\n    class_option :skip_hooks, aliases: \"-H\", type: :boolean, default: false, desc: \"Don't run hooks\"\n\n    def initialize(args = [], local_options = {}, config = {})\n      if config[:current_command].is_a?(Kamal::Cli::Alias::Command)\n        # When Thor generates a dynamic command, it doesn't attempt to parse the arguments.\n        # For our purposes, it means the arguments are passed in args rather than local_options.\n        super([], args, config)\n      else\n        super\n      end\n\n      initialize_commander unless KAMAL.configured?\n    end\n\n    private\n      def options_with_subcommand_class_options\n        options.merge(@_initializer.last[:class_options] || {})\n      end\n\n      def initialize_commander\n        KAMAL.tap do |commander|\n          if options[:verbose]\n            ENV[\"VERBOSE\"] = \"1\" # For backtraces via cli/start\n            commander.verbosity = VERBOSITY[:verbose]\n          end\n\n          if options[:quiet]\n            commander.verbosity = VERBOSITY[:quiet]\n          end\n\n          commander.configure \\\n            config_file: Pathname.new(File.expand_path(options[:config_file])),\n            destination: options[:destination],\n            version: options[:version]\n\n          commander.specific_hosts    = options[:hosts]&.split(\",\")\n          commander.specific_roles    = options[:roles]&.split(\",\")\n          commander.specific_primary! if options[:primary]\n        end\n      end\n\n      def print_runtime\n        started_at = Time.now\n        yield\n        Time.now - started_at\n      ensure\n        runtime = Time.now - started_at\n        puts \"  Finished all in #{sprintf(\"%.1f seconds\", runtime)}\"\n      end\n\n      def with_lock\n        if KAMAL.holding_lock?\n          yield\n        else\n          acquire_lock\n\n          begin\n            yield\n          rescue\n            begin\n              release_lock\n            rescue => e\n              say \"Error releasing the deploy lock: #{e.message}\", :red\n            end\n            raise\n          end\n\n          release_lock\n        end\n      end\n\n      def confirming(question)\n        return yield if options[:confirmed]\n\n        if ask(question, limited_to: %w[ y N ], default: \"N\") == \"y\"\n          yield\n        else\n          say \"Aborted\", :red\n        end\n      end\n\n      def acquire_lock\n        ensure_run_directory\n\n        raise_if_locked do\n          say \"Acquiring the deploy lock...\", :magenta\n          on(KAMAL.primary_host) { execute *KAMAL.lock.acquire(\"Automatic deploy lock\", KAMAL.config.version), verbosity: :debug }\n        end\n\n        KAMAL.holding_lock = true\n      end\n\n      def release_lock\n        say \"Releasing the deploy lock...\", :magenta\n        on(KAMAL.primary_host) { execute *KAMAL.lock.release, verbosity: :debug }\n\n        KAMAL.holding_lock = false\n      end\n\n      def raise_if_locked\n        yield\n      rescue SSHKit::Runner::ExecuteError => e\n        if e.message =~ /cannot create directory/\n          say \"Deploy lock already in place!\", :red\n          on(KAMAL.primary_host) { puts capture_with_debug(*KAMAL.lock.status) }\n          raise LockError, \"Deploy lock found. Run 'kamal lock help' for more information\"\n        else\n          raise e\n        end\n      end\n\n      def run_hook(hook, **extra_details)\n        if !options[:skip_hooks] && KAMAL.hook.hook_exists?(hook)\n          details = {\n            hosts: KAMAL.hosts.join(\",\"),\n            roles: KAMAL.specific_roles&.join(\",\"),\n            lock: KAMAL.holding_lock?.to_s,\n            command: command,\n            subcommand: subcommand\n          }.compact\n\n          hooks_output = KAMAL.config.hooks_output_for(hook)\n\n          # CLI flags override config: -q hides all, -v shows all\n          # Config setting :verbose forces output, :quiet forces silence\n          hook_verbosity = if KAMAL.verbosity == :info && hooks_output\n            VERBOSITY.fetch(hooks_output)\n          else\n            KAMAL.verbosity\n          end\n\n          with_env KAMAL.hook.env(**details, **extra_details) do\n            KAMAL.with_verbosity(hook_verbosity) do\n              run_locally do\n                execute *KAMAL.hook.run(hook)\n              end\n            end\n          rescue SSHKit::Command::Failed => e\n            raise HookError.new(\"Hook `#{hook}` failed:\\n#{e.message}\")\n          end\n        end\n      end\n\n      def on(*args, &block)\n        pre_connect_if_required\n\n        super\n      end\n\n      def pre_connect_if_required\n        if !KAMAL.connected?\n          run_hook \"pre-connect\", secrets: true unless options[:skip_hooks]\n          KAMAL.connected = true\n        end\n      end\n\n      def command\n        @kamal_command ||= begin\n          invocation_class, invocation_commands = *first_invocation\n          if invocation_class == Kamal::Cli::Main\n            invocation_commands[0]\n          else\n            Kamal::Cli::Main.subcommand_classes.find { |command, clazz| clazz == invocation_class }[0]\n          end\n        end\n      end\n\n      def subcommand\n        @kamal_subcommand ||= begin\n          invocation_class, invocation_commands = *first_invocation\n          invocation_commands[0] if invocation_class != Kamal::Cli::Main\n        end\n      end\n\n      def first_invocation\n        instance_variable_get(\"@_invocations\").first\n      end\n\n      def reset_invocation(cli_class)\n        instance_variable_get(\"@_invocations\")[cli_class].pop\n      end\n\n      def ensure_run_directory\n        on(KAMAL.hosts) do\n          execute(*KAMAL.server.ensure_run_directory)\n        end\n      end\n\n      def with_env(env)\n        current_env = ENV.to_h.dup\n        ENV.update(env)\n        yield\n      ensure\n        ENV.clear\n        ENV.update(current_env)\n      end\n\n      def ensure_docker_installed\n        run_locally do\n          begin\n            execute *KAMAL.builder.ensure_docker_installed\n          rescue SSHKit::Command::Failed => e\n            error = e.message =~ /command not found/ ?\n              \"Docker is not installed locally\" :\n              \"Docker buildx plugin is not installed locally\"\n\n            raise DependencyError, error\n          end\n        end\n      end\n  end\nend\n"
  },
  {
    "path": "lib/kamal/cli/build/clone.rb",
    "content": "class Kamal::Cli::Build::Clone\n  attr_reader :sshkit\n  delegate :info, :error, :execute, :capture_with_info, to: :sshkit\n\n  def initialize(sshkit)\n    @sshkit = sshkit\n  end\n\n  def prepare\n    begin\n      clone_repo\n    rescue SSHKit::Command::Failed => e\n      if e.message =~ /already exists and is not an empty directory/\n        reset\n      else\n        raise Kamal::Cli::Build::BuildError, \"Failed to clone repo: #{e.message}\"\n      end\n    end\n\n    validate!\n  rescue Kamal::Cli::Build::BuildError => e\n    error \"Error preparing clone: #{e.message}, deleting and retrying...\"\n\n    FileUtils.rm_rf KAMAL.config.builder.clone_directory\n    clone_repo\n    validate!\n  end\n\n  private\n    def clone_repo\n      info \"Cloning repo into build directory `#{KAMAL.config.builder.build_directory}`...\"\n\n      FileUtils.mkdir_p KAMAL.config.builder.clone_directory\n      execute *KAMAL.builder.clone\n    end\n\n    def reset\n      info \"Resetting local clone as `#{KAMAL.config.builder.build_directory}` already exists...\"\n\n      KAMAL.builder.clone_reset_steps.each { |step| execute *step }\n    rescue SSHKit::Command::Failed => e\n      raise Kamal::Cli::Build::BuildError, \"Failed to clone repo: #{e.message}\"\n    end\n\n    def validate!\n      status = capture_with_info(*KAMAL.builder.clone_status).strip\n\n      unless status.empty?\n        raise Kamal::Cli::Build::BuildError, \"Clone in #{KAMAL.config.builder.build_directory} is dirty, #{status}\"\n      end\n\n      revision = capture_with_info(*KAMAL.builder.clone_revision).strip\n      if revision != Kamal::Git.revision\n        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}`\"\n      end\n    rescue SSHKit::Command::Failed => e\n      raise Kamal::Cli::Build::BuildError, \"Failed to validate clone: #{e.message}\"\n    end\nend\n"
  },
  {
    "path": "lib/kamal/cli/build/port_forwarding.rb",
    "content": "require \"concurrent/atomic/count_down_latch\"\n\nclass Kamal::Cli::Build::PortForwarding\n  attr_reader :hosts, :port, :ssh_options\n\n  def initialize(hosts, port, **ssh_options)\n    @hosts = hosts\n    @port = port\n    @ssh_options = ssh_options\n  end\n\n  def forward\n    @done = false\n    forward_ports\n\n    yield\n  ensure\n    stop\n  end\n\n  private\n    def stop\n      @done = true\n      @threads.to_a.each(&:join)\n    end\n\n    def forward_ports\n      ready = Concurrent::CountDownLatch.new(hosts.size)\n\n      @threads = hosts.map do |host|\n        Thread.new do\n          begin\n            Net::SSH.start(host, ssh_options[:user], **ssh_options.except(:user)) do |ssh|\n              ssh.forward.remote(port, \"localhost\", port, \"127.0.0.1\") do |remote_port, bind_address|\n                if remote_port == :error\n                  raise \"Failed to establish port forward on #{host}\"\n                else\n                  ready.count_down\n                end\n              end\n\n              ssh.loop(0.1) do\n                if @done\n                  ssh.forward.cancel_remote(port, \"127.0.0.1\")\n                  break\n                else\n                  true\n                end\n              end\n            end\n          rescue Exception => e\n            error \"Error setting up port forwarding to #{host}: #{e.class}: #{e.message}\"\n            error e.backtrace.join(\"\\n\")\n\n            raise\n          end\n        end\n      end\n\n      raise \"Timed out waiting for port forwarding to be established\" unless ready.wait(30)\n    end\n\n    def error(message)\n      SSHKit.config.output.error(message)\n    end\nend\n"
  },
  {
    "path": "lib/kamal/cli/build.rb",
    "content": "class Kamal::Cli::Build < Kamal::Cli::Base\n  class BuildError < StandardError; end\n\n  desc \"deliver\", \"Build app and push app image to registry then pull image on servers\"\n  def deliver\n    invoke :push\n    invoke :pull\n  end\n\n  desc \"push\", \"Build and push app image to registry\"\n  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'.\"\n  option :no_cache, type: :boolean, default: false, desc: \"Build without using Docker's build cache\"\n  def push\n    cli = self\n\n    # Ensure pre-connect hooks run before the build, they may be needed for a remote builder\n    # or the pre-build hooks.\n    pre_connect_if_required\n\n    ensure_docker_installed\n    login_to_registry_locally if KAMAL.builder.login_to_registry_locally?\n\n    run_hook \"pre-build\"\n\n    uncommitted_changes = Kamal::Git.uncommitted_changes\n\n    if KAMAL.config.builder.git_clone?\n      if uncommitted_changes.present?\n        say \"Building from a local git clone, so ignoring these uncommitted changes:\\n #{uncommitted_changes}\", :yellow\n      end\n\n      run_locally do\n        Clone.new(self).prepare\n      end\n    elsif uncommitted_changes.present?\n      say \"Building with uncommitted changes:\\n #{uncommitted_changes}\", :yellow\n    end\n\n    forward_local_registry_port_for_remote_builder do\n      with_env(KAMAL.config.builder.secrets) do\n        run_locally do\n          begin\n            execute *KAMAL.builder.inspect_builder\n          rescue SSHKit::Command::Failed => e\n            if e.message =~ /(context not found|no builder|no compatible builder|does not exist)/\n              warn \"Missing compatible builder, so creating a new one first\"\n              begin\n                cli.remove\n              rescue SSHKit::Command::Failed\n                raise unless e.message =~ /(context not found|no builder|does not exist)/\n              end\n              cli.create\n            else\n              raise\n            end\n          end\n\n          # Get the command here to ensure the Dir.chdir doesn't interfere with it\n          push = KAMAL.builder.push(cli.options[:output], no_cache: cli.options[:no_cache])\n\n          KAMAL.with_verbosity(:debug) do\n            Dir.chdir(KAMAL.config.builder.build_directory) { execute *push, env: KAMAL.builder.push_env }\n          end\n        end\n      end\n    end\n  end\n\n  desc \"pull\", \"Pull app image from registry onto servers\"\n  def pull\n    login_to_registry_remotely unless KAMAL.registry.local?\n\n    forward_local_registry_port(KAMAL.hosts, **KAMAL.config.ssh.options) do\n      if (first_hosts = mirror_hosts).any?\n        #  Pull on a single host per mirror first to seed them\n        say \"Pulling image on #{first_hosts.join(\", \")} to seed the #{\"mirror\".pluralize(first_hosts.count)}...\", :magenta\n        pull_on_hosts(first_hosts)\n        say \"Pulling image on remaining hosts...\", :magenta\n        pull_on_hosts(KAMAL.app_hosts - first_hosts)\n      else\n        pull_on_hosts(KAMAL.app_hosts)\n      end\n    end\n  end\n\n  desc \"create\", \"Create a build setup\"\n  def create\n    if (remote_host = KAMAL.config.builder.remote)\n      connect_to_remote_host(remote_host)\n    end\n\n    run_locally do\n      begin\n        debug \"Using builder: #{KAMAL.builder.name}\"\n        execute *KAMAL.builder.create\n      rescue SSHKit::Command::Failed => e\n        if e.message =~ /stderr=(.*)/\n          error \"Couldn't create remote builder: #{$1}\"\n          false\n        else\n          raise\n        end\n      end\n    end\n  end\n\n  desc \"remove\", \"Remove build setup\"\n  def remove\n    run_locally do\n      debug \"Using builder: #{KAMAL.builder.name}\"\n      execute *KAMAL.builder.remove\n    end\n  end\n\n  desc \"details\", \"Show build setup\"\n  def details\n    run_locally do\n      puts \"Builder: #{KAMAL.builder.name}\"\n      puts capture(*KAMAL.builder.info)\n    end\n  end\n\n  desc \"dev\", \"Build using the working directory, tag it as dirty, and push to local image store.\"\n  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'.\"\n  option :no_cache, type: :boolean, default: false, desc: \"Build without using Docker's build cache\"\n  def dev\n    cli = self\n\n    ensure_docker_installed\n\n    docker_included_files = Set.new(Kamal::Docker.included_files)\n    git_uncommitted_files = Set.new(Kamal::Git.uncommitted_files)\n    git_untracked_files = Set.new(Kamal::Git.untracked_files)\n\n    docker_uncommitted_files = docker_included_files & git_uncommitted_files\n    if docker_uncommitted_files.any?\n      say \"WARNING: Files with uncommitted changes will be present in the dev container:\", :yellow\n      docker_uncommitted_files.sort.each { |f| say \"  #{f}\", :yellow }\n      say\n    end\n\n    docker_untracked_files = docker_included_files & git_untracked_files\n    if docker_untracked_files.any?\n      say \"WARNING: Untracked files will be present in the dev container:\", :yellow\n      docker_untracked_files.sort.each { |f| say \"  #{f}\", :yellow }\n      say\n    end\n\n    with_env(KAMAL.config.builder.secrets) do\n      run_locally do\n        build = KAMAL.builder.push(cli.options[:output], tag_as_dirty: true, no_cache: cli.options[:no_cache])\n        KAMAL.with_verbosity(:debug) do\n          execute(*build)\n        end\n      end\n    end\n  end\n\n  private\n    def connect_to_remote_host(remote_host)\n      remote_uri = URI.parse(remote_host)\n      if remote_uri.scheme == \"ssh\"\n        host = SSHKit::Host.new(\n          hostname: remote_uri.host,\n          ssh_options: { user: remote_uri.user, port: remote_uri.port }.compact\n        )\n        on(host, options) do\n          execute \"true\"\n        end\n      end\n    end\n\n    def mirror_hosts\n      if KAMAL.app_hosts.many?\n        mirror_hosts = Concurrent::Hash.new\n        on(KAMAL.app_hosts) do |host|\n          first_mirror = capture_with_info(*KAMAL.builder.first_mirror).strip.presence\n          mirror_hosts[first_mirror] ||= host.to_s if first_mirror\n        rescue SSHKit::Command::Failed => e\n          raise unless e.message =~ /error calling index: reflect: slice index out of range/\n        end\n        mirror_hosts.values\n      else\n        []\n      end\n    end\n\n    def pull_on_hosts(hosts)\n      on(hosts) do\n        execute *KAMAL.auditor.record(\"Pulled image with version #{KAMAL.config.version}\"), verbosity: :debug\n        execute *KAMAL.builder.clean, raise_on_non_zero_exit: false\n        execute *KAMAL.builder.pull\n        execute *KAMAL.builder.validate_image\n      end\n    end\n\n    def login_to_registry_locally\n      run_locally do\n        if KAMAL.registry.local?\n          execute *KAMAL.registry.setup\n        else\n          execute *KAMAL.registry.login\n        end\n      end\n    end\n\n    def login_to_registry_remotely\n      on(KAMAL.app_hosts) do\n        execute *KAMAL.registry.login\n      end\n    end\n\n    def forward_local_registry_port_for_remote_builder(&block)\n      if KAMAL.builder.remote?\n        remote_uri = URI(KAMAL.config.builder.remote)\n        forward_local_registry_port([ remote_uri.host ], **remote_builder_ssh_options(remote_uri), &block)\n      else\n        yield\n      end\n    end\n\n    def forward_local_registry_port(hosts, **ssh_options, &block)\n      if KAMAL.config.registry.local?\n        say \"Setting up local registry port forwarding to #{hosts.join(', ')}...\"\n        PortForwarding.new(hosts, KAMAL.config.registry.local_port, **ssh_options).forward(&block)\n      else\n        yield\n      end\n    end\n\n    def remote_builder_ssh_options(remote_uri)\n      { user: remote_uri.user,\n        port: remote_uri.port,\n        keepalive: KAMAL.config.ssh.options[:keepalive],\n        keepalive_interval: KAMAL.config.ssh.options[:keepalive_interval],\n        logger: KAMAL.config.ssh.options[:logger]\n      }.compact\n    end\nend\n"
  },
  {
    "path": "lib/kamal/cli/healthcheck/barrier.rb",
    "content": "require \"concurrent/ivar\"\n\nclass Kamal::Cli::Healthcheck::Barrier\n  def initialize\n    @ivar = Concurrent::IVar.new\n  end\n\n  def close\n    set(false)\n  end\n\n  def open\n    set(true)\n  end\n\n  def wait\n    unless opened?\n      raise Kamal::Cli::Healthcheck::Error.new(\"Halted at barrier\")\n    end\n  end\n\n  private\n    def opened?\n      @ivar.value\n    end\n\n    def set(value)\n      @ivar.set(value)\n      true\n    rescue Concurrent::MultipleAssignmentError\n      false\n    end\nend\n"
  },
  {
    "path": "lib/kamal/cli/healthcheck/error.rb",
    "content": "class Kamal::Cli::Healthcheck::Error < StandardError\nend\n"
  },
  {
    "path": "lib/kamal/cli/healthcheck/poller.rb",
    "content": "module Kamal::Cli::Healthcheck::Poller\n  extend self\n\n  def wait_for_healthy(&block)\n    attempt = 1\n    timeout_at = Time.now + KAMAL.config.deploy_timeout\n    readiness_delay = KAMAL.config.readiness_delay\n\n    begin\n      status = block.call\n\n      if status == \"running\"\n        # Wait for the readiness delay and confirm it is still running\n        if readiness_delay > 0\n          info \"Container is running, waiting for readiness delay of #{readiness_delay} seconds\"\n          sleep readiness_delay\n          status = block.call\n        end\n      end\n\n      unless %w[ running healthy ].include?(status)\n        raise Kamal::Cli::Healthcheck::Error, \"container not ready after #{KAMAL.config.deploy_timeout} seconds (#{status})\"\n      end\n    rescue Kamal::Cli::Healthcheck::Error => e\n      time_left = timeout_at - Time.now\n      if time_left > 0\n        sleep [ attempt, time_left ].min\n        attempt += 1\n        retry\n      else\n        raise\n      end\n    end\n\n    info \"Container is healthy!\"\n  end\n\n  private\n    def info(message)\n      SSHKit.config.output.info(message)\n    end\nend\n"
  },
  {
    "path": "lib/kamal/cli/lock.rb",
    "content": "class Kamal::Cli::Lock < Kamal::Cli::Base\n  desc \"status\", \"Report lock status\"\n  def status\n    handle_missing_lock do\n      on(KAMAL.primary_host) do\n        puts capture_with_debug(*KAMAL.lock.status)\n      end\n    end\n  end\n\n  desc \"acquire\", \"Acquire the deploy lock\"\n  option :message, aliases: \"-m\", type: :string, desc: \"A lock message\", required: true\n  def acquire\n    message = options[:message]\n    ensure_run_directory\n\n    raise_if_locked do\n      on(KAMAL.primary_host) do\n        execute *KAMAL.lock.acquire(message, KAMAL.config.version), verbosity: :debug\n      end\n      say \"Acquired the deploy lock\"\n    end\n  end\n\n  desc \"release\", \"Release the deploy lock\"\n  def release\n    handle_missing_lock do\n      on(KAMAL.primary_host) do\n        execute *KAMAL.lock.release, verbosity: :debug\n      end\n      say \"Released the deploy lock\"\n    end\n  end\n\n  private\n    def handle_missing_lock\n      yield\n    rescue SSHKit::Runner::ExecuteError => e\n      if e.message =~ /No such file or directory/\n        say \"There is no deploy lock\"\n      else\n        raise\n      end\n    end\nend\n"
  },
  {
    "path": "lib/kamal/cli/main.rb",
    "content": "class Kamal::Cli::Main < Kamal::Cli::Base\n  desc \"setup\", \"Setup all accessories, push the env, and deploy app to servers\"\n  option :skip_push, aliases: \"-P\", type: :boolean, default: false, desc: \"Skip image build and push\"\n  option :no_cache, type: :boolean, default: false, desc: \"Build without using Docker's build cache\"\n  def setup\n    print_runtime do\n      with_lock do\n        invoke_options = deploy_options\n\n        say \"Ensure Docker is installed...\", :magenta\n        invoke \"kamal:cli:server:bootstrap\", [], invoke_options\n\n        deploy(boot_accessories: true)\n      end\n    end\n  end\n\n  desc \"deploy\", \"Deploy app to servers\"\n  option :skip_push, aliases: \"-P\", type: :boolean, default: false, desc: \"Skip image build and push\"\n  option :no_cache, type: :boolean, default: false, desc: \"Build without using Docker's build cache\"\n  def deploy(boot_accessories: false)\n    runtime = print_runtime do\n      invoke_options = deploy_options\n\n      if options[:skip_push]\n        say \"Pull app image...\", :magenta\n        invoke \"kamal:cli:build:pull\", [], invoke_options\n      else\n        say \"Build and push app image...\", :magenta\n        invoke \"kamal:cli:build:deliver\", [], invoke_options\n      end\n\n      with_lock do\n        run_hook \"pre-deploy\", secrets: true\n\n        say \"Ensure kamal-proxy is running...\", :magenta\n        invoke \"kamal:cli:proxy:boot\", [], invoke_options\n\n        invoke \"kamal:cli:accessory:boot\", [ \"all\" ], invoke_options if boot_accessories\n\n        say \"Detect stale containers...\", :magenta\n        invoke \"kamal:cli:app:stale_containers\", [], invoke_options.merge(stop: true)\n\n        invoke \"kamal:cli:app:boot\", [], invoke_options\n\n        say \"Prune old containers and images...\", :magenta\n        invoke \"kamal:cli:prune:all\", [], invoke_options\n      end\n    end\n\n    run_hook \"post-deploy\", secrets: true, runtime: runtime.round.to_s\n  end\n\n  desc \"redeploy\", \"Deploy app to servers without bootstrapping servers, starting kamal-proxy and pruning\"\n  option :skip_push, aliases: \"-P\", type: :boolean, default: false, desc: \"Skip image build and push\"\n  option :no_cache, type: :boolean, default: false, desc: \"Build without using Docker's build cache\"\n  def redeploy\n    runtime = print_runtime do\n      invoke_options = deploy_options\n\n      if options[:skip_push]\n        say \"Pull app image...\", :magenta\n        invoke \"kamal:cli:build:pull\", [], invoke_options\n      else\n        say \"Build and push app image...\", :magenta\n        invoke \"kamal:cli:build:deliver\", [], invoke_options\n      end\n\n      with_lock do\n        run_hook \"pre-deploy\", secrets: true\n\n        say \"Detect stale containers...\", :magenta\n        invoke \"kamal:cli:app:stale_containers\", [], invoke_options.merge(stop: true)\n\n        invoke \"kamal:cli:app:boot\", [], invoke_options\n      end\n    end\n\n    run_hook \"post-deploy\", secrets: true, runtime: runtime.round.to_s\n  end\n\n  desc \"rollback [VERSION]\", \"Rollback app to VERSION\"\n  def rollback(version)\n    rolled_back = false\n    runtime = print_runtime do\n      with_lock do\n        invoke_options = deploy_options\n\n        KAMAL.config.version = version\n        old_version = nil\n\n        if container_available?(version)\n          run_hook \"pre-deploy\", secrets: true\n\n          invoke \"kamal:cli:app:boot\", [], invoke_options.merge(version: version)\n          rolled_back = true\n        else\n          say \"The app version '#{version}' is not available as a container (use 'kamal app containers' for available versions)\", :red\n        end\n      end\n    end\n\n    run_hook \"post-deploy\", secrets: true, runtime: runtime.round.to_s if rolled_back\n  end\n\n  desc \"details\", \"Show details about all containers\"\n  def details\n    invoke \"kamal:cli:proxy:details\"\n    invoke \"kamal:cli:app:details\"\n    invoke \"kamal:cli:accessory:details\", [ \"all\" ]\n  end\n\n  desc \"audit\", \"Show audit log from servers\"\n  def audit\n    quiet = options[:quiet]\n    on(KAMAL.hosts) do |host|\n      puts_by_host host, capture_with_info(*KAMAL.auditor.reveal), quiet: quiet\n    end\n  end\n\n  desc \"config\", \"Show combined config (including secrets!)\"\n  def config\n    run_locally do\n      puts Kamal::Utils.redacted(KAMAL.config.to_h).to_yaml\n    end\n  end\n\n  desc \"docs [SECTION]\", \"Show Kamal configuration documentation\"\n  def docs(section = nil)\n    case section\n    when NilClass\n      puts Kamal::Configuration.validation_doc\n    else\n      puts Kamal::Configuration.const_get(section.titlecase.to_sym).validation_doc\n    end\n  rescue NameError\n    puts \"No documentation found for #{section}\"\n  end\n\n  desc \"init\", \"Create config stub in config/deploy.yml and secrets stub in .kamal\"\n  option :bundle, type: :boolean, default: false, desc: \"Add Kamal to the Gemfile and create a bin/kamal binstub\"\n  def init\n    require \"fileutils\"\n\n    if (deploy_file = Pathname.new(File.expand_path(\"config/deploy.yml\"))).exist?\n      puts \"Config file already exists in config/deploy.yml (remove first to create a new one)\"\n    else\n      FileUtils.mkdir_p deploy_file.dirname\n      FileUtils.cp_r Pathname.new(File.expand_path(\"templates/deploy.yml\", __dir__)), deploy_file\n      puts \"Created configuration file in config/deploy.yml\"\n    end\n\n    unless (secrets_file = Pathname.new(File.expand_path(\".kamal/secrets\"))).exist?\n      FileUtils.mkdir_p secrets_file.dirname\n      FileUtils.cp_r Pathname.new(File.expand_path(\"templates/secrets\", __dir__)), secrets_file\n      puts \"Created .kamal/secrets file\"\n    end\n\n    unless (hooks_dir = Pathname.new(File.expand_path(\".kamal/hooks\"))).exist?\n      hooks_dir.mkpath\n      Pathname.new(File.expand_path(\"templates/sample_hooks\", __dir__)).each_child do |sample_hook|\n        FileUtils.cp sample_hook, hooks_dir, preserve: true\n      end\n      puts \"Created sample hooks in .kamal/hooks\"\n    end\n\n    if options[:bundle]\n      if (binstub = Pathname.new(File.expand_path(\"bin/kamal\"))).exist?\n        puts \"Binstub already exists in bin/kamal (remove first to create a new one)\"\n      else\n        puts \"Adding Kamal to Gemfile and bundle...\"\n        run_locally do\n          execute :bundle, :add, :kamal\n          execute :bundle, :binstubs, :kamal\n        end\n        puts \"Created binstub file in bin/kamal\"\n      end\n    end\n  end\n\n  desc \"remove\", \"Remove kamal-proxy, app, accessories, and registry session from servers\"\n  option :confirmed, aliases: \"-y\", type: :boolean, default: false, desc: \"Proceed without confirmation question\"\n  def remove\n    confirming \"This will remove all containers and images. Are you sure?\" do\n      with_lock do\n        invoke \"kamal:cli:app:remove\", [], options.without(:confirmed)\n        invoke \"kamal:cli:proxy:remove\", [], options.without(:confirmed)\n        invoke \"kamal:cli:accessory:remove\", [ \"all\" ], options\n        invoke \"kamal:cli:registry:remove\", [], options.without(:confirmed).merge(skip_local: true)\n      end\n    end\n  end\n\n  desc \"upgrade\", \"Upgrade from Kamal 1.x to 2.0\"\n  option :confirmed, aliases: \"-y\", type: :boolean, default: false, desc: \"Proceed without confirmation question\"\n  option :rolling, type: :boolean, default: false, desc: \"Upgrade one host at a time\"\n  def upgrade\n    confirming \"This will replace Traefik with kamal-proxy and restart all accessories\" do\n      with_lock do\n        if options[:rolling]\n          KAMAL.hosts.each do |host|\n            KAMAL.with_specific_hosts(host) do\n              say \"Upgrading #{host}...\", :magenta\n              if KAMAL.app_hosts.include?(host)\n                invoke \"kamal:cli:proxy:upgrade\", [], options.merge(confirmed: true, rolling: false)\n                reset_invocation(Kamal::Cli::Proxy)\n              end\n              if KAMAL.accessory_hosts.include?(host)\n                invoke \"kamal:cli:accessory:upgrade\", [ \"all\" ], options.merge(confirmed: true, rolling: false)\n                reset_invocation(Kamal::Cli::Accessory)\n              end\n              say \"Upgraded #{host}\", :magenta\n            end\n          end\n        else\n          say \"Upgrading all hosts...\", :magenta\n          invoke \"kamal:cli:proxy:upgrade\", [], options.merge(confirmed: true)\n          invoke \"kamal:cli:accessory:upgrade\", [ \"all\" ], options.merge(confirmed: true)\n          say \"Upgraded all hosts\", :magenta\n        end\n      end\n    end\n  end\n\n  desc \"version\", \"Show Kamal version\"\n  def version\n    puts Kamal::VERSION\n  end\n\n  desc \"accessory\", \"Manage accessories (db/redis/search)\"\n  subcommand \"accessory\", Kamal::Cli::Accessory\n\n  desc \"app\", \"Manage application\"\n  subcommand \"app\", Kamal::Cli::App\n\n  desc \"build\", \"Build application image\"\n  subcommand \"build\", Kamal::Cli::Build\n\n  desc \"lock\", \"Manage the deploy lock\"\n  subcommand \"lock\", Kamal::Cli::Lock\n\n  desc \"proxy\", \"Manage kamal-proxy\"\n  subcommand \"proxy\", Kamal::Cli::Proxy\n\n  desc \"prune\", \"Prune old application images and containers\"\n  subcommand \"prune\", Kamal::Cli::Prune\n\n  desc \"registry\", \"Login and -out of the image registry\"\n  subcommand \"registry\", Kamal::Cli::Registry\n\n  desc \"secrets\", \"Helpers for extracting secrets\"\n  subcommand \"secrets\", Kamal::Cli::Secrets\n\n  desc \"server\", \"Bootstrap servers with curl and Docker\"\n  subcommand \"server\", Kamal::Cli::Server\n\n  private\n    def container_available?(version)\n      begin\n        on(KAMAL.app_hosts) do\n          KAMAL.roles_on(host).each do |role|\n            container_id = capture_with_info(*KAMAL.app(role: role, host: host).container_id_for_version(version))\n            raise \"Container not found\" unless container_id.present?\n          end\n        end\n      rescue SSHKit::Runner::ExecuteError, SSHKit::Runner::MultipleExecuteError => e\n        if e.message =~ /Container not found/\n          say \"Error looking for container version #{version}: #{e.message}\"\n          return false\n        else\n          raise\n        end\n      end\n\n      true\n    end\n\n    def deploy_options\n      base_options = options.without(\"skip_push\")\n      base_options = base_options.except(\"no_cache\") unless base_options[\"no_cache\"]\n      { \"version\" => KAMAL.config.version }.merge(base_options)\n    end\nend\n"
  },
  {
    "path": "lib/kamal/cli/proxy.rb",
    "content": "class Kamal::Cli::Proxy < Kamal::Cli::Base\n  desc \"boot\", \"Boot proxy on servers\"\n  def boot\n    with_lock do\n      on(KAMAL.hosts) do |host|\n        execute *KAMAL.docker.create_network\n      rescue SSHKit::Command::Failed => e\n        raise unless e.message.include?(\"already exists\")\n      end\n\n      on(KAMAL.proxy_hosts) do |host|\n        execute *KAMAL.registry.login\n\n        version = capture_with_info(*KAMAL.proxy(host).version).strip.presence\n\n        if version && Kamal::Utils.older_version?(version, Kamal::Configuration::Proxy::Run::MINIMUM_VERSION)\n          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}\"\n        end\n        execute *KAMAL.proxy(host).ensure_apps_config_directory\n        execute *KAMAL.proxy(host).start_or_run\n      end\n    end\n  end\n\n  desc \"boot_config <set|get|reset>\", \"Manage kamal-proxy boot configuration\"\n  option :publish, type: :boolean, default: true, desc: \"Publish the proxy ports on the host\"\n  option :publish_host_ip, type: :string, repeatable: true, default: nil, desc: \"Host IP address to bind HTTP/HTTPS traffic to. Defaults to all interfaces\"\n  option :http_port, type: :numeric, default: Kamal::Configuration::Proxy::Run::DEFAULT_HTTP_PORT, desc: \"HTTP port to publish on the host\"\n  option :https_port, type: :numeric, default: Kamal::Configuration::Proxy::Run::DEFAULT_HTTPS_PORT, desc: \"HTTPS port to publish on the host\"\n  option :log_max_size, type: :string, default: Kamal::Configuration::Proxy::Run::DEFAULT_LOG_MAX_SIZE, desc: \"Max size of proxy logs\"\n  option :registry, type: :string, default: nil, desc: \"Registry to use for the proxy image\"\n  option :repository, type: :string, default: nil, desc: \"Repository for the proxy image\"\n  option :image_version, type: :string, default: nil, desc: \"Version of the proxy to run\"\n  option :metrics_port, type: :numeric, default: nil, desc: \"Port to report prometheus metrics on\"\n  option :debug, type: :boolean, default: false, desc: \"Whether to run the proxy in debug mode\"\n  option :docker_options, type: :array, default: [], desc: \"Docker options to pass to the proxy container\", banner: \"option=value option2=value2\"\n  def boot_config(subcommand)\n    say \"The proxy boot_config command is deprecated - set the config in the deploy YAML at proxy/run instead\", :yellow\n    proxy_boot_config = KAMAL.config.proxy_boot\n\n    case subcommand\n    when \"set\"\n      boot_options = [\n        *(proxy_boot_config.publish_args(options[:http_port], options[:https_port], options[:publish_host_ip]) if options[:publish]),\n        *(proxy_boot_config.logging_args(options[:log_max_size])),\n        *(\"--expose=#{options[:metrics_port]}\" if options[:metrics_port]),\n        *options[:docker_options].map { |option| \"--#{option}\" }\n      ]\n\n      image = [\n        options[:registry].presence,\n        options[:repository].presence || proxy_boot_config.repository_name,\n        proxy_boot_config.image_name\n      ].compact.join(\"/\")\n\n      image_version = options[:image_version]\n\n      run_command_options = { debug: options[:debug] || nil, \"metrics-port\": options[:metrics_port] }.compact\n      run_command = \"kamal-proxy run #{Kamal::Utils.optionize(run_command_options).join(\" \")}\" if run_command_options.any?\n\n      on(KAMAL.proxy_hosts) do |host|\n        proxy = KAMAL.proxy(host)\n        execute(*proxy.ensure_proxy_directory)\n        if boot_options != proxy_boot_config.default_boot_options\n          upload! StringIO.new(boot_options.join(\" \")), proxy_boot_config.options_file\n        else\n          execute *proxy.reset_boot_options, raise_on_non_zero_exit: false\n        end\n\n        if image != proxy_boot_config.image_default\n          upload! StringIO.new(image), proxy_boot_config.image_file\n        else\n          execute *proxy.reset_image, raise_on_non_zero_exit: false\n        end\n\n        if image_version\n          upload! StringIO.new(image_version), proxy_boot_config.image_version_file\n        else\n          execute *proxy.reset_image_version, raise_on_non_zero_exit: false\n        end\n\n        if run_command\n          upload! StringIO.new(run_command), proxy_boot_config.run_command_file\n        else\n          execute *proxy.reset_run_command, raise_on_non_zero_exit: false\n        end\n      end\n    when \"get\"\n\n      on(KAMAL.proxy_hosts) do |host|\n        puts \"Host #{host}: #{capture_with_info(*KAMAL.proxy(host).boot_config)}\"\n      end\n    when \"reset\"\n      on(KAMAL.proxy_hosts) do |host|\n        proxy = KAMAL.proxy(host)\n        execute *proxy.reset_boot_options, raise_on_non_zero_exit: false\n        execute *proxy.reset_image, raise_on_non_zero_exit: false\n        execute *proxy.reset_image_version, raise_on_non_zero_exit: false\n        execute *proxy.reset_run_command, raise_on_non_zero_exit: false\n      end\n    else\n      raise ArgumentError, \"Unknown boot_config subcommand #{subcommand}\"\n    end\n  end\n\n  desc \"reboot\", \"Reboot proxy on servers (stop container, remove container, start new container)\"\n  option :rolling, type: :boolean, default: false, desc: \"Reboot proxy on hosts in sequence, rather than in parallel\"\n  option :confirmed, aliases: \"-y\", type: :boolean, default: false, desc: \"Proceed without confirmation question\"\n  def reboot\n    confirming \"This will cause a brief outage on each host. Are you sure?\" do\n      with_lock do\n        host_groups = options[:rolling] ? KAMAL.proxy_hosts : [ KAMAL.proxy_hosts ]\n        host_groups.each do |hosts|\n          host_list = Array(hosts).join(\",\")\n          run_hook \"pre-proxy-reboot\", hosts: host_list\n          on(hosts) do |host|\n            proxy = KAMAL.proxy(host)\n            execute *KAMAL.auditor.record(\"Rebooted proxy\"), verbosity: :debug\n            execute *KAMAL.registry.login\n\n            \"Stopping and removing kamal-proxy on #{host}, if running...\"\n            execute *proxy.stop, raise_on_non_zero_exit: false\n            execute *proxy.remove_container\n            execute *proxy.ensure_apps_config_directory\n\n            execute *proxy.run\n          end\n          run_hook \"post-proxy-reboot\", hosts: host_list\n        end\n      end\n    end\n  end\n\n  desc \"upgrade\", \"Upgrade to kamal-proxy on servers (stop container, remove container, start new container, reboot app)\", hide: true\n  option :rolling, type: :boolean, default: false, desc: \"Reboot proxy on hosts in sequence, rather than in parallel\"\n  option :confirmed, aliases: \"-y\", type: :boolean, default: false, desc: \"Proceed without confirmation question\"\n  def upgrade\n    invoke_options = { \"version\" => KAMAL.config.latest_tag }.merge(options)\n\n    confirming \"This will cause a brief outage on each host. Are you sure?\" do\n      host_groups = options[:rolling] ? KAMAL.hosts : [ KAMAL.hosts ]\n      host_groups.each do |hosts|\n        host_list = Array(hosts).join(\",\")\n        say \"Upgrading proxy on #{host_list}...\", :magenta\n        run_hook \"pre-proxy-reboot\", hosts: host_list\n        on(hosts) do |host|\n          proxy = KAMAL.proxy(host)\n          execute *KAMAL.auditor.record(\"Rebooted proxy\"), verbosity: :debug\n          execute *KAMAL.registry.login\n\n          \"Stopping and removing Traefik on #{host}, if running...\"\n          execute *proxy.cleanup_traefik\n\n          \"Stopping and removing kamal-proxy on #{host}, if running...\"\n          execute *proxy.stop, raise_on_non_zero_exit: false\n          execute *proxy.remove_container\n          execute *proxy.remove_image\n        end\n\n        KAMAL.with_specific_hosts(hosts) do\n          invoke \"kamal:cli:proxy:boot\", [], invoke_options\n          reset_invocation(Kamal::Cli::Proxy)\n          invoke \"kamal:cli:app:boot\", [], invoke_options\n          reset_invocation(Kamal::Cli::App)\n          invoke \"kamal:cli:prune:all\", [], invoke_options\n          reset_invocation(Kamal::Cli::Prune)\n        end\n\n        run_hook \"post-proxy-reboot\", hosts: host_list\n        say \"Upgraded proxy on #{host_list}\", :magenta\n      end\n    end\n  end\n\n  desc \"start\", \"Start existing proxy container on servers\"\n  def start\n    with_lock do\n      on(KAMAL.proxy_hosts) do |host|\n        execute *KAMAL.auditor.record(\"Started proxy\"), verbosity: :debug\n        execute *KAMAL.proxy(host).start\n      end\n    end\n  end\n\n  desc \"stop\", \"Stop existing proxy container on servers\"\n  def stop\n    with_lock do\n      on(KAMAL.proxy_hosts) do |host|\n        execute *KAMAL.auditor.record(\"Stopped proxy\"), verbosity: :debug\n        execute *KAMAL.proxy(host).stop, raise_on_non_zero_exit: false\n      end\n    end\n  end\n\n  desc \"restart\", \"Restart existing proxy container on servers\"\n  def restart\n    with_lock do\n      stop\n      start\n    end\n  end\n\n  desc \"details\", \"Show details about proxy container from servers\"\n  def details\n    quiet = options[:quiet]\n    on(KAMAL.proxy_hosts) { |host| puts_by_host host, capture_with_info(*KAMAL.proxy(host).info), type: \"Proxy\", quiet: quiet }\n  end\n\n  desc \"logs\", \"Show log lines from proxy on servers\"\n  option :since, aliases: \"-s\", desc: \"Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)\"\n  option :lines, type: :numeric, aliases: \"-n\", desc: \"Number of log lines to pull from each server\"\n  option :grep, aliases: \"-g\", desc: \"Show lines with grep match only (use this to fetch specific requests by id)\"\n  option :follow, aliases: \"-f\", desc: \"Follow logs on primary server (or specific host set by --hosts)\"\n  option :skip_timestamps, type: :boolean, aliases: \"-T\", desc: \"Skip appending timestamps to logging output\"\n  def logs\n    grep = options[:grep]\n    timestamps = !options[:skip_timestamps]\n\n    if options[:follow]\n      run_locally do\n        proxy = KAMAL.proxy(KAMAL.primary_host)\n        info \"Following logs on #{KAMAL.primary_host}...\"\n        info proxy.follow_logs(host: KAMAL.primary_host, timestamps: timestamps, grep: grep)\n        exec proxy.follow_logs(host: KAMAL.primary_host, timestamps: timestamps, grep: grep)\n      end\n    else\n      since = options[:since]\n      lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set\n\n      on(KAMAL.proxy_hosts) do |host|\n        puts_by_host host, capture(*KAMAL.proxy(host).logs(timestamps: timestamps, since: since, lines: lines, grep: grep)), type: \"Proxy\"\n      end\n    end\n  end\n\n  desc \"remove\", \"Remove proxy container and image from servers\"\n  option :force, type: :boolean, default: false, desc: \"Force removing proxy when apps are still installed\"\n  def remove\n    with_lock do\n      if removal_allowed?(options[:force])\n        stop\n        remove_container\n        remove_image\n        remove_proxy_directory\n      end\n    end\n  end\n\n  desc \"remove_container\", \"Remove proxy container from servers\", hide: true\n  def remove_container\n    with_lock do\n      on(KAMAL.proxy_hosts) do\n        execute *KAMAL.auditor.record(\"Removed proxy container\"), verbosity: :debug\n        execute *KAMAL.proxy(host).remove_container\n      end\n    end\n  end\n\n  desc \"remove_image\", \"Remove proxy image from servers\", hide: true\n  def remove_image\n    with_lock do\n      on(KAMAL.proxy_hosts) do\n        execute *KAMAL.auditor.record(\"Removed proxy image\"), verbosity: :debug\n        execute *KAMAL.proxy(host).remove_image\n      end\n    end\n  end\n\n  desc \"remove_proxy_directory\", \"Remove the proxy directory from servers\", hide: true\n  def remove_proxy_directory\n    with_lock do\n      on(KAMAL.proxy_hosts) do\n        execute *KAMAL.proxy(host).remove_proxy_directory, raise_on_non_zero_exit: false\n      end\n    end\n  end\n\n  private\n    def removal_allowed?(force)\n      on(KAMAL.proxy_hosts) do |host|\n        app_count = capture_with_info(*KAMAL.server.app_directory_count).chomp.to_i\n        raise \"The are other applications installed on #{host}\" if app_count > 0\n      end\n\n      true\n    rescue SSHKit::Runner::ExecuteError => e\n      raise unless e.message.include?(\"The are other applications installed on\")\n\n      if force\n        say \"Forcing, so removing the proxy, even though other apps are installed\", :magenta\n      else\n        say \"Not removing the proxy, as other apps are installed, ignore this check with kamal proxy remove --force\", :magenta\n      end\n\n      force\n    end\nend\n"
  },
  {
    "path": "lib/kamal/cli/prune.rb",
    "content": "class Kamal::Cli::Prune < Kamal::Cli::Base\n  desc \"all\", \"Prune unused images and stopped containers\"\n  def all\n    with_lock do\n      containers\n      images\n    end\n  end\n\n  desc \"images\", \"Prune unused images\"\n  def images\n    with_lock do\n      on(KAMAL.hosts) do\n        execute *KAMAL.auditor.record(\"Pruned images\"), verbosity: :debug\n        execute *KAMAL.prune.dangling_images\n        execute *KAMAL.prune.tagged_images\n      end\n    end\n  end\n\n  desc \"containers\", \"Prune all stopped containers, except the last n (default 5)\"\n  option :retain, type: :numeric, default: nil, desc: \"Number of containers to retain\"\n  def containers\n    retain = options.fetch(:retain, KAMAL.config.retain_containers)\n    raise \"retain must be at least 1\" if retain < 1\n\n    with_lock do\n      on(KAMAL.hosts) do\n        execute *KAMAL.auditor.record(\"Pruned containers\"), verbosity: :debug\n        execute *KAMAL.prune.app_containers(retain: retain)\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/kamal/cli/registry.rb",
    "content": "class Kamal::Cli::Registry < Kamal::Cli::Base\n  desc \"setup\", \"Setup local registry or log in to remote registry locally and remotely\"\n  option :skip_local, aliases: \"-L\", type: :boolean, default: false, desc: \"Skip local login\"\n  option :skip_remote, aliases: \"-R\", type: :boolean, default: false, desc: \"Skip remote login\"\n  def setup\n    ensure_docker_installed unless options[:skip_local]\n\n    if KAMAL.registry.local?\n      run_locally    { execute *KAMAL.registry.setup } unless options[:skip_local]\n    else\n      run_locally    { execute *KAMAL.registry.login } unless options[:skip_local]\n      on(KAMAL.hosts) { execute *KAMAL.registry.login } unless options[:skip_remote]\n    end\n  end\n\n  desc \"remove\", \"Remove local registry or log out of remote registry locally and remotely\"\n  option :skip_local, aliases: \"-L\", type: :boolean, default: false, desc: \"Skip local login\"\n  option :skip_remote, aliases: \"-R\", type: :boolean, default: false, desc: \"Skip remote login\"\n  def remove\n    if KAMAL.registry.local?\n      run_locally    { execute *KAMAL.registry.remove, raise_on_non_zero_exit: false } unless options[:skip_local]\n    else\n      run_locally    { execute *KAMAL.registry.logout } unless options[:skip_local]\n      on(KAMAL.hosts) { execute *KAMAL.registry.logout } unless options[:skip_remote]\n    end\n  end\n\n  desc \"login\", \"Log in to remote registry locally and remotely\"\n  option :skip_local, aliases: \"-L\", type: :boolean, default: false, desc: \"Skip local login\"\n  option :skip_remote, aliases: \"-R\", type: :boolean, default: false, desc: \"Skip remote login\"\n  def login\n    if KAMAL.registry.local?\n      raise \"Cannot use login command with a local registry. Use `kamal registry setup` instead.\"\n    end\n\n    setup\n  end\n\n  desc \"logout\", \"Log out of remote registry locally and remotely\"\n  option :skip_local, aliases: \"-L\", type: :boolean, default: false, desc: \"Skip local login\"\n  option :skip_remote, aliases: \"-R\", type: :boolean, default: false, desc: \"Skip remote login\"\n  def logout\n    if KAMAL.registry.local?\n      raise \"Cannot use logout command with a local registry. Use `kamal registry remove` instead.\"\n    end\n\n    remove\n  end\nend\n"
  },
  {
    "path": "lib/kamal/cli/secrets.rb",
    "content": "class Kamal::Cli::Secrets < Kamal::Cli::Base\n  desc \"fetch [SECRETS...]\", \"Fetch secrets from a vault\"\n  option :adapter, type: :string, aliases: \"-a\", required: true, desc: \"Which vault adapter to use\"\n  option :account, type: :string, required: false, desc: \"The account identifier or username\"\n  option :from, type: :string, required: false, desc: \"A vault or folder to fetch the secrets from\"\n  option :inline, type: :boolean, required: false, hidden: true\n  def fetch(*secrets)\n    adapter = initialize_adapter(options[:adapter])\n\n    if adapter.requires_account? && options[:account].blank?\n      return puts \"No value provided for required options '--account'\"\n    end\n\n    results = adapter.fetch(secrets, **options.slice(:account, :from).symbolize_keys)\n    json = JSON.dump(results)\n\n    return_or_puts options[:inline] ? json.shellescape : json, inline: options[:inline]\n  end\n\n  desc \"extract\", \"Extract a single secret from the results of a fetch call\"\n  option :inline, type: :boolean, required: false, hidden: true\n  def extract(name, secrets)\n    parsed_secrets = JSON.parse(secrets)\n    value = parsed_secrets[name] || parsed_secrets.find { |k, v| k.end_with?(\"/#{name}\") }&.last\n\n    raise \"Could not find secret #{name}\" if value.nil?\n\n    return_or_puts value, inline: options[:inline]\n  end\n\n  desc \"print\", \"Print the secrets (for debugging)\"\n  def print\n    KAMAL.config.secrets.to_h.each do |key, value|\n      puts \"#{key}=#{value}\"\n    end\n  end\n\n  private\n    def initialize_adapter(adapter)\n      Kamal::Secrets::Adapters.lookup(adapter)\n    end\n\n    def return_or_puts(value, inline: nil)\n      if inline\n        value\n      else\n        puts value\n      end\n    end\nend\n"
  },
  {
    "path": "lib/kamal/cli/server.rb",
    "content": "class Kamal::Cli::Server < Kamal::Cli::Base\n  desc \"exec\", \"Run a custom command on the server (use --help to show options)\"\n  option :interactive, type: :boolean, aliases: \"-i\", default: false, desc: \"Run the command interactively (use for console/bash)\"\n  def exec(*cmd)\n    pre_connect_if_required\n\n    cmd = Kamal::Utils.join_commands(cmd)\n    hosts = KAMAL.hosts\n    quiet = options[:quiet]\n\n    case\n    when options[:interactive]\n      host = KAMAL.primary_host\n\n      say \"Running '#{cmd}' on #{host} interactively...\", :magenta\n\n      run_locally { exec KAMAL.server.run_over_ssh(cmd, host: host) }\n    else\n      say \"Running '#{cmd}' on #{hosts.join(', ')}...\", :magenta\n\n      on(hosts) do |host|\n        execute *KAMAL.auditor.record(\"Executed cmd '#{cmd}' on #{host}\"), verbosity: :debug\n        puts_by_host host, capture_with_info(cmd), quiet: quiet\n      end\n    end\n  end\n\n  desc \"bootstrap\", \"Set up Docker to run Kamal apps\"\n  def bootstrap\n    with_lock do\n      missing = []\n\n      on(KAMAL.hosts) do |host|\n        unless execute(*KAMAL.docker.installed?, raise_on_non_zero_exit: false)\n          if execute(*KAMAL.docker.superuser?, raise_on_non_zero_exit: false)\n            info \"Missing Docker on #{host}. Installing…\"\n            execute *KAMAL.docker.install\n\n            unless execute(*KAMAL.docker.root?, raise_on_non_zero_exit: false) ||\n                   execute(*KAMAL.docker.in_docker_group?, raise_on_non_zero_exit: false)\n              execute *KAMAL.docker.add_to_docker_group\n              begin\n                execute *KAMAL.docker.refresh_session\n              rescue IOError\n                info \"Session refreshed due to group change.\"\n              end\n            end\n          else\n            missing << host\n          end\n        end\n      end\n\n      if missing.any?\n        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/\"\n      end\n\n      run_hook \"docker-setup\"\n    end\n  end\nend\n"
  },
  {
    "path": "lib/kamal/cli/templates/deploy.yml",
    "content": "# Name of your application. Used to uniquely configure containers.\nservice: my-app\n\n# Name of the container image.\nimage: my-user/my-app\n\n# Deploy to these servers.\nservers:\n  web:\n    - 192.168.0.1\n  # job:\n  #   hosts:\n  #     - 192.168.0.1\n  #   cmd: bin/jobs\n\n# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server.\n# Remove this section when using multiple web servers and ensure you terminate SSL at your load balancer.\n#\n# Note: If using Cloudflare, set encryption mode in SSL/TLS setting to \"Full\" to enable CF-to-app encryption.\nproxy:\n  ssl: true\n  host: app.example.com\n  # Proxy connects to your container on port 80 by default.\n  # app_port: 3000\n\n# Credentials for your image host.\nregistry:\n  server: localhost:5555\n  # Specify the registry server, if you're not using Docker Hub\n  # server: registry.digitalocean.com / ghcr.io / ...\n  # username: my-user\n\n  # Always use an access token rather than real password (pulled from .kamal/secrets).\n  # password:\n  #   - KAMAL_REGISTRY_PASSWORD\n\n# Configure builder setup.\nbuilder:\n  arch: amd64\n  # Pass in additional build args needed for your Dockerfile.\n  # args:\n  #   RUBY_VERSION: <%= ENV[\"RBENV_VERSION\"] || ENV[\"rvm_ruby_string\"] || \"#{RUBY_ENGINE}-#{RUBY_ENGINE_VERSION}\" %>\n\n# Inject ENV variables into containers (secrets come from .kamal/secrets).\n#\n# env:\n#   clear:\n#     DB_HOST: 192.168.0.2\n#   secret:\n#     - RAILS_MASTER_KEY\n\n# Aliases are triggered with \"bin/kamal <alias>\". You can overwrite arguments on invocation:\n# \"bin/kamal app logs -r job\" will tail logs from the first server in the job section.\n#\n# aliases:\n#   shell: app exec --interactive --reuse \"bash\"\n\n# Use a different ssh user than root\n#\n# ssh:\n#   user: app\n\n# Use a persistent storage volume.\n#\n# volumes:\n#   - \"app_storage:/app/storage\"\n\n# Bridge fingerprinted assets, like JS and CSS, between versions to avoid\n# hitting 404 on in-flight requests. Combines all files from new and old\n# version inside the asset_path.\n#\n# asset_path: /app/public/assets\n\n# Configure rolling deploys by setting a wait time between batches of restarts.\n#\n# boot:\n#   limit: 10 # Can also specify as a percentage of total hosts, such as \"25%\"\n#   wait: 2\n\n# Use accessory services (secrets come from .kamal/secrets).\n#\n# accessories:\n#   db:\n#     image: mysql:8.0\n#     host: 192.168.0.2\n#     port: 3306\n#     env:\n#       clear:\n#         MYSQL_ROOT_HOST: '%'\n#       secret:\n#         - MYSQL_ROOT_PASSWORD\n#     files:\n#       - config/mysql/production.cnf:/etc/mysql/my.cnf\n#       - db/production.sql:/docker-entrypoint-initdb.d/setup.sql\n#     directories:\n#       - data:/var/lib/mysql\n#   redis:\n#     image: valkey/valkey:8\n#     host: 192.168.0.2\n#     port: 6379\n#     directories:\n#       - data:/data\n"
  },
  {
    "path": "lib/kamal/cli/templates/sample_hooks/docker-setup.sample",
    "content": "#!/bin/sh\n\necho \"Docker set up on $KAMAL_HOSTS...\"\n"
  },
  {
    "path": "lib/kamal/cli/templates/sample_hooks/post-app-boot.sample",
    "content": "#!/bin/sh\n\necho \"Booted app version $KAMAL_VERSION on $KAMAL_HOSTS...\"\n"
  },
  {
    "path": "lib/kamal/cli/templates/sample_hooks/post-deploy.sample",
    "content": "#!/bin/sh\n\n# A sample post-deploy hook\n#\n# These environment variables are available:\n# KAMAL_RECORDED_AT\n# KAMAL_PERFORMER\n# KAMAL_VERSION\n# KAMAL_HOSTS\n# KAMAL_ROLES (if set)\n# KAMAL_DESTINATION (if set)\n# KAMAL_RUNTIME\n\necho \"$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds\"\n"
  },
  {
    "path": "lib/kamal/cli/templates/sample_hooks/post-proxy-reboot.sample",
    "content": "#!/bin/sh\n\necho \"Rebooted kamal-proxy on $KAMAL_HOSTS\"\n"
  },
  {
    "path": "lib/kamal/cli/templates/sample_hooks/pre-app-boot.sample",
    "content": "#!/bin/sh\n\necho \"Booting app version $KAMAL_VERSION on $KAMAL_HOSTS...\"\n"
  },
  {
    "path": "lib/kamal/cli/templates/sample_hooks/pre-build.sample",
    "content": "#!/bin/sh\n\n# A sample pre-build hook\n#\n# Checks:\n# 1. We have a clean checkout\n# 2. A remote is configured\n# 3. The branch has been pushed to the remote\n# 4. The version we are deploying matches the remote\n#\n# These environment variables are available:\n# KAMAL_RECORDED_AT\n# KAMAL_PERFORMER\n# KAMAL_VERSION\n# KAMAL_HOSTS\n# KAMAL_ROLES (if set)\n# KAMAL_DESTINATION (if set)\n\nif [ -n \"$(git status --porcelain)\" ]; then\n  echo \"Git checkout is not clean, aborting...\" >&2\n  git status --porcelain >&2\n  exit 1\nfi\n\nfirst_remote=$(git remote)\n\nif [ -z \"$first_remote\" ]; then\n  echo \"No git remote set, aborting...\" >&2\n  exit 1\nfi\n\ncurrent_branch=$(git branch --show-current)\n\nif [ -z \"$current_branch\" ]; then\n  echo \"Not on a git branch, aborting...\" >&2\n  exit 1\nfi\n\nremote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1)\n\nif [ -z \"$remote_head\" ]; then\n  echo \"Branch not pushed to remote, aborting...\" >&2\n  exit 1\nfi\n\nif [ \"$KAMAL_VERSION\" != \"$remote_head\" ]; then\n  echo \"Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting...\" >&2\n  exit 1\nfi\n\nexit 0\n"
  },
  {
    "path": "lib/kamal/cli/templates/sample_hooks/pre-connect.sample",
    "content": "#!/usr/bin/env ruby\n\n# A sample pre-connect check\n#\n# Warms DNS before connecting to hosts in parallel\n#\n# These environment variables are available:\n# KAMAL_RECORDED_AT\n# KAMAL_PERFORMER\n# KAMAL_VERSION\n# KAMAL_HOSTS\n# KAMAL_ROLES (if set)\n# KAMAL_DESTINATION (if set)\n# KAMAL_RUNTIME\n\nhosts = ENV[\"KAMAL_HOSTS\"].split(\",\")\nresults = nil\nmax = 3\n\nelapsed = Benchmark.realtime do\n  results = hosts.map do |host|\n    Thread.new do\n      tries = 1\n\n      begin\n        Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME)\n      rescue SocketError\n        if tries < max\n          puts \"Retrying DNS warmup: #{host}\"\n          tries += 1\n          sleep rand\n          retry\n        else\n          puts \"DNS warmup failed: #{host}\"\n          host\n        end\n      end\n\n      tries\n    end\n  end.map(&:value)\nend\n\nretries = results.sum - hosts.size\nnopes = results.count { |r| r == max }\n\nputs \"Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures\" % [ hosts.size, elapsed, retries, nopes ]\n"
  },
  {
    "path": "lib/kamal/cli/templates/sample_hooks/pre-deploy.sample",
    "content": "#!/usr/bin/env ruby\n\n# A sample pre-deploy hook\n#\n# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds.\n#\n# Fails unless the combined status is \"success\"\n#\n# These environment variables are available:\n# KAMAL_RECORDED_AT\n# KAMAL_PERFORMER\n# KAMAL_VERSION\n# KAMAL_HOSTS\n# KAMAL_COMMAND\n# KAMAL_SUBCOMMAND\n# KAMAL_ROLES (if set)\n# KAMAL_DESTINATION (if set)\n\n# Only check the build status for production deployments\nif ENV[\"KAMAL_COMMAND\"] == \"rollback\" || ENV[\"KAMAL_DESTINATION\"] != \"production\"\n  exit 0\nend\n\nrequire \"bundler/inline\"\n\n# true = install gems so this is fast on repeat invocations\ngemfile(true, quiet: true) do\n  source \"https://rubygems.org\"\n\n  gem \"octokit\"\n  gem \"faraday-retry\"\nend\n\nMAX_ATTEMPTS = 72\nATTEMPTS_GAP = 10\n\ndef exit_with_error(message)\n  $stderr.puts message\n  exit 1\nend\n\nclass GithubStatusChecks\n  attr_reader :remote_url, :git_sha, :github_client, :combined_status\n\n  def initialize\n    @remote_url = github_repo_from_remote_url\n    @git_sha = `git rev-parse HEAD`.strip\n    @github_client = Octokit::Client.new(access_token: ENV[\"GITHUB_TOKEN\"])\n    refresh!\n  end\n\n  def refresh!\n    @combined_status = github_client.combined_status(remote_url, git_sha)\n  end\n\n  def state\n    combined_status[:state]\n  end\n\n  def first_status_url\n    first_status = combined_status[:statuses].find { |status| status[:state] == state }\n    first_status && first_status[:target_url]\n  end\n\n  def complete_count\n    combined_status[:statuses].count { |status| status[:state] != \"pending\"}\n  end\n\n  def total_count\n    combined_status[:statuses].count\n  end\n\n  def current_status\n    if total_count > 0\n      \"Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ...\"\n    else\n      \"Build not started...\"\n    end\n  end\n\n  private\n    def github_repo_from_remote_url\n      url = `git config --get remote.origin.url`.strip.delete_suffix(\".git\")\n      if url.start_with?(\"https://github.com/\")\n        url.delete_prefix(\"https://github.com/\")\n      elsif url.start_with?(\"git@github.com:\")\n        url.delete_prefix(\"git@github.com:\")\n      else\n        url\n      end\n    end\nend\n\n\n$stdout.sync = true\n\nbegin\n  puts \"Checking build status...\"\n\n  attempts = 0\n  checks = GithubStatusChecks.new\n\n  loop do\n    case checks.state\n    when \"success\"\n      puts \"Checks passed, see #{checks.first_status_url}\"\n      exit 0\n    when \"failure\"\n      exit_with_error \"Checks failed, see #{checks.first_status_url}\"\n    when \"pending\"\n      attempts += 1\n    end\n\n    exit_with_error \"Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds\" if attempts == MAX_ATTEMPTS\n\n    puts checks.current_status\n    sleep(ATTEMPTS_GAP)\n    checks.refresh!\n  end\nrescue Octokit::NotFound\n  exit_with_error \"Build status could not be found\"\nend\n"
  },
  {
    "path": "lib/kamal/cli/templates/sample_hooks/pre-proxy-reboot.sample",
    "content": "#!/bin/sh\n\necho \"Rebooting kamal-proxy on $KAMAL_HOSTS...\"\n"
  },
  {
    "path": "lib/kamal/cli/templates/secrets",
    "content": "# Secrets defined here are available for reference under registry/password, env/secret, builder/secrets,\n# and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either\n# password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git.\n\n# Option 1: Read secrets from the environment\n# KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD\n\n# Option 2: Read secrets via a command\n# RAILS_MASTER_KEY=$(cat config/master.key)\n# KAMAL_REGISTRY_PASSWORD=$(rails credentials:fetch kamal.registry_password)\n\n# Option 3: Read secrets via kamal secrets helpers\n# These will handle logging in and fetching the secrets in as few calls as possible\n# There are adapters for 1Password, LastPass + Bitwarden\n#\n# SECRETS=$(kamal secrets fetch --adapter 1password --account my-account --from MyVault/MyItem KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY)\n# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD $SECRETS)\n# RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS)\n"
  },
  {
    "path": "lib/kamal/cli.rb",
    "content": "module Kamal::Cli\n  class BootError < StandardError; end\n  class HookError < StandardError; end\n  class LockError < StandardError; end\n  class DependencyError < StandardError; end\nend\n\n# SSHKit uses instance eval, so we need a global const for ergonomics\nKAMAL = Kamal::Commander.new\n"
  },
  {
    "path": "lib/kamal/commander/specifics.rb",
    "content": "class Kamal::Commander::Specifics\n  attr_reader :primary_host, :primary_role, :hosts, :roles\n  delegate :stable_sort!, to: Kamal::Utils\n\n  def initialize(config, specific_hosts, specific_roles)\n    @config, @specific_hosts, @specific_roles = config, specific_hosts, specific_roles\n\n    @roles, @hosts = specified_roles, specified_hosts\n\n    @primary_host = specific_hosts&.first || primary_specific_role&.primary_host || config.primary_host\n    @primary_role = primary_or_first_role(roles_on(primary_host))\n\n    stable_sort!(roles) { |role| role == primary_role ? 0 : 1 }\n    sort_primary_role_hosts_first!(hosts)\n  end\n\n  def roles_on(host)\n    roles.select { |role| role.hosts.include?(host.to_s) }\n  end\n\n  def app_hosts\n    @app_hosts ||= sort_primary_role_hosts_first!(config.app_hosts & specified_hosts)\n  end\n\n  def proxy_hosts\n    config.proxy_hosts & specified_hosts\n  end\n\n  def accessory_hosts\n    config.accessories.flat_map(&:hosts) & specified_hosts\n  end\n\n  private\n    attr_reader :config, :specific_hosts, :specific_roles\n\n    def primary_specific_role\n      primary_or_first_role(specific_roles) if specific_roles.present?\n    end\n\n    def primary_or_first_role(roles)\n      roles.detect { |role| role == config.primary_role } || roles.first\n    end\n\n    def specified_roles\n      (specific_roles || config.roles) \\\n        .select { |role| ((specific_hosts || config.all_hosts) & role.hosts).any? }\n    end\n\n    def specified_hosts\n      specified_hosts = specific_hosts || config.all_hosts\n\n      if (specific_role_hosts = specific_roles&.flat_map(&:hosts)).present?\n        specified_hosts.select { |host| specific_role_hosts.include?(host) }\n      else\n        specified_hosts\n      end\n    end\n\n    def sort_primary_role_hosts_first!(hosts)\n      stable_sort!(hosts) { |host| roles_on(host).any? { |role| role == primary_role } ? 0 : 1 }\n    end\nend\n"
  },
  {
    "path": "lib/kamal/commander.rb",
    "content": "require \"active_support/core_ext/enumerable\"\nrequire \"active_support/core_ext/module/delegation\"\nrequire \"active_support/core_ext/object/blank\"\n\nclass Kamal::Commander\n  attr_accessor :verbosity, :holding_lock, :connected\n  attr_reader :specific_roles, :specific_hosts\n  delegate :hosts, :roles, :primary_host, :primary_role, :roles_on, :app_hosts, :proxy_hosts, :accessory_hosts, to: :specifics\n\n  def initialize\n    reset\n  end\n\n  def reset\n    self.verbosity = :info\n    self.holding_lock = ENV[\"KAMAL_LOCK\"] == \"true\"\n    self.connected = false\n    @specifics = @specific_roles = @specific_hosts = nil\n    @config = @config_kwargs = nil\n    @commands = {}\n  end\n\n  def config\n    @config ||= Kamal::Configuration.create_from(**@config_kwargs.to_h).tap do |config|\n      @config_kwargs = nil\n      configure_sshkit_with(config)\n    end\n  end\n\n  def configure(**kwargs)\n    @config, @config_kwargs = nil, kwargs\n  end\n\n  def configured?\n    @config || @config_kwargs\n  end\n\n  def specific_primary!\n    @specifics = nil\n    if specific_roles.present?\n      self.specific_hosts = [ specific_roles.first.primary_host ]\n    else\n      self.specific_hosts = [ config.primary_host ]\n    end\n  end\n\n  def specific_roles=(role_names)\n    @specifics = nil\n    @specific_roles = if role_names.present?\n      filtered = Kamal::Utils.filter_specific_items(role_names, config.roles)\n      raise ArgumentError, \"No --roles match for #{role_names.join(',')}\" if filtered.empty?\n      filtered\n    end\n  end\n\n  def specific_hosts=(hosts)\n    @specifics = nil\n    @specific_hosts = if hosts.present?\n      filtered = Kamal::Utils.filter_specific_items(hosts, config.all_hosts)\n      raise ArgumentError, \"No --hosts match for #{hosts.join(',')}\" if filtered.empty?\n      filtered\n    end\n  end\n\n  def with_specific_hosts(hosts)\n    original_hosts, self.specific_hosts = specific_hosts, hosts\n    yield\n  ensure\n    self.specific_hosts = original_hosts\n  end\n\n  def accessory_names\n    config.accessories&.collect(&:name) || []\n  end\n\n  def app(role: nil, host: nil)\n    Kamal::Commands::App.new(config, role: role, host: host)\n  end\n\n  def accessory(name)\n    Kamal::Commands::Accessory.new(config, name: name)\n  end\n\n  def auditor(**details)\n    Kamal::Commands::Auditor.new(config, **details)\n  end\n\n  def builder\n    @commands[:builder] ||= Kamal::Commands::Builder.new(config)\n  end\n\n  def docker\n    @commands[:docker] ||= Kamal::Commands::Docker.new(config)\n  end\n\n  def hook\n    @commands[:hook] ||= Kamal::Commands::Hook.new(config)\n  end\n\n  def lock\n    @commands[:lock] ||= Kamal::Commands::Lock.new(config)\n  end\n\n  def proxy(host)\n    Kamal::Commands::Proxy.new(config, host: host)\n  end\n\n  def prune\n    @commands[:prune] ||= Kamal::Commands::Prune.new(config)\n  end\n\n  def registry\n    @commands[:registry] ||= Kamal::Commands::Registry.new(config)\n  end\n\n  def server\n    @commands[:server] ||= Kamal::Commands::Server.new(config)\n  end\n\n  def alias(name)\n    config.aliases[name]\n  end\n\n  def resolve_alias(name)\n    if @config\n      @config.aliases[name]&.command\n    else\n      raw_config = Kamal::Configuration.load_raw_config(**@config_kwargs.to_h.slice(:config_file, :destination))\n      raw_config[:aliases]&.dig(name)\n    end\n  end\n\n  def with_verbosity(level)\n    old_level = self.verbosity\n\n    self.verbosity = level\n    SSHKit.config.output_verbosity = level\n\n    yield\n  ensure\n    self.verbosity = old_level\n    SSHKit.config.output_verbosity = old_level\n  end\n\n  def holding_lock?\n    self.holding_lock\n  end\n\n  def connected?\n    self.connected\n  end\n\n  private\n    # Lazy setup of SSHKit\n    def configure_sshkit_with(config)\n      SSHKit::Backend::Netssh.pool.idle_timeout = config.sshkit.pool_idle_timeout\n      SSHKit::Backend::Netssh.configure do |sshkit|\n        sshkit.max_concurrent_starts = config.sshkit.max_concurrent_starts\n        sshkit.dns_retries = config.sshkit.dns_retries\n        sshkit.ssh_options = config.ssh.options\n      end\n      SSHKit.config.command_map[:docker] = \"docker\" # No need to use /usr/bin/env, just clogs up the logs\n      SSHKit.config.output_verbosity = verbosity\n    end\n\n    def specifics\n      @specifics ||= Kamal::Commander::Specifics.new(config, specific_hosts, specific_roles)\n    end\nend\n"
  },
  {
    "path": "lib/kamal/commands/accessory/proxy.rb",
    "content": "module Kamal::Commands::Accessory::Proxy\n  delegate :container_name, to: :\"config.proxy_boot\", prefix: :proxy\n\n  def deploy(target:)\n    proxy_exec :deploy, service_name, *proxy.deploy_command_args(target: target)\n  end\n\n  def remove\n    proxy_exec :remove, service_name\n  end\n\n  private\n    def proxy_exec(*command)\n      docker :exec, proxy_container_name, \"kamal-proxy\", *command\n    end\nend\n"
  },
  {
    "path": "lib/kamal/commands/accessory.rb",
    "content": "class Kamal::Commands::Accessory < Kamal::Commands::Base\n  include Proxy\n\n  attr_reader :accessory_config\n  delegate :service_name, :image, :hosts, :port, :files, :directories, :cmd,\n           :network_args, :publish_args, :env_args, :volume_args, :label_args, :option_args,\n           :secrets_io, :secrets_path, :env_directory, :proxy, :running_proxy?, :registry,\n           to: :accessory_config\n\n  def initialize(config, name:)\n    super(config)\n    @accessory_config = config.accessory(name)\n  end\n\n  def run(host: nil)\n    docker :run,\n      \"--name\", service_name,\n      \"--detach\",\n      \"--restart\", \"unless-stopped\",\n      *network_args,\n      *config.logging_args,\n      *publish_args,\n      *([ \"--env\", \"KAMAL_HOST=\\\"#{host}\\\"\" ] if host),\n      *env_args,\n      *volume_args,\n      *label_args,\n      *option_args,\n      image,\n      cmd\n  end\n\n  def start\n    docker :container, :start, service_name\n  end\n\n  def stop\n    docker :container, :stop, service_name\n  end\n\n  def info(all: false, quiet: false)\n    docker :ps, *(\"-a\" if all), *(\"-q\" if quiet), *service_filter\n  end\n\n  def logs(timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil)\n    pipe \\\n      docker(:logs, service_name, (\" --since #{since}\" if since), (\" --tail #{lines}\" if lines), (\"--timestamps\" if timestamps), \"2>&1\"),\n      (\"grep '#{grep}'#{\" #{grep_options}\" if grep_options}\" if grep)\n  end\n\n  def follow_logs(timestamps: true, grep: nil, grep_options: nil)\n    run_over_ssh \\\n      pipe \\\n        docker(:logs, service_name, (\"--timestamps\" if timestamps), \"--tail\", \"10\", \"--follow\", \"2>&1\"),\n        (%(grep \"#{grep}\"#{\" #{grep_options}\" if grep_options}) if grep)\n  end\n\n  def execute_in_existing_container(*command, interactive: false)\n    docker :exec,\n      (docker_interactive_args if interactive),\n      service_name,\n      *command\n  end\n\n  def execute_in_new_container(*command, interactive: false)\n    docker :run,\n      (docker_interactive_args if interactive),\n      \"--rm\",\n      *network_args,\n      *env_args,\n      *volume_args,\n      *option_args,\n      image,\n      *command\n  end\n\n  def execute_in_existing_container_over_ssh(*command)\n    run_over_ssh execute_in_existing_container(*command, interactive: true)\n  end\n\n  def execute_in_new_container_over_ssh(*command)\n    run_over_ssh execute_in_new_container(*command, interactive: true)\n  end\n\n  def run_over_ssh(command)\n    super command, host: hosts.first\n  end\n\n  def ensure_local_file_present(local_file)\n    if !local_file.is_a?(StringIO) && !Pathname.new(local_file).exist?\n      raise \"Missing file: #{local_file}\"\n    end\n  end\n\n  def pull_image\n    docker :image, :pull, image\n  end\n\n  def remove_service_directory\n    [ :rm, \"-rf\", service_name ]\n  end\n\n  def remove_container\n    docker :container, :prune, \"--force\", *service_filter\n  end\n\n  def remove_image\n    docker :image, :rm, \"--force\", image\n  end\n\n  def ensure_env_directory\n    make_directory env_directory\n  end\n\n  private\n    def service_filter\n      [ \"--filter\", \"label=service=#{service_name}\" ]\n    end\nend\n"
  },
  {
    "path": "lib/kamal/commands/app/assets.rb",
    "content": "module Kamal::Commands::App::Assets\n  def extract_assets\n    asset_container = \"#{role.container_prefix}-assets\"\n\n    combine \\\n      make_directory(role.asset_extracted_directory),\n      [ *docker(:container, :rm, asset_container, \"2> /dev/null\"), \"|| true\" ],\n      docker(:container, :create, \"--name\", asset_container, config.absolute_image),\n      docker(:container, :cp, \"-L\", \"#{asset_container}:#{role.asset_path}/.\", role.asset_extracted_directory),\n      docker(:container, :rm, asset_container),\n      by: \"&&\"\n  end\n\n  def sync_asset_volumes(old_version: nil)\n    new_extracted_path, new_volume_path = role.asset_extracted_directory(config.version), role.asset_volume.host_path\n    if old_version.present?\n      old_extracted_path, old_volume_path = role.asset_extracted_directory(old_version), role.asset_volume(old_version).host_path\n    end\n\n    commands = [ make_directory(new_volume_path), copy_contents(new_extracted_path, new_volume_path) ]\n\n    if old_version.present?\n      commands << copy_contents(new_extracted_path, old_volume_path, continue_on_error: true)\n      commands << copy_contents(old_extracted_path, new_volume_path, continue_on_error: true)\n    end\n\n    chain *commands\n  end\n\n  def clean_up_assets\n    chain \\\n      find_and_remove_older_siblings(role.asset_extracted_directory),\n      find_and_remove_older_siblings(role.asset_volume_directory)\n  end\n\n  private\n    def find_and_remove_older_siblings(path)\n      [\n        :find,\n        Pathname.new(path).dirname.to_s,\n        \"-maxdepth 1\",\n        \"-name\", \"'#{role.name}-*'\",\n        \"!\", \"-name\", Pathname.new(path).basename.to_s,\n        \"-exec rm -rf \\\"{}\\\" +\"\n      ]\n    end\n\n    def copy_contents(source, destination, continue_on_error: false)\n      [ :cp, \"-rnT\", \"#{source}\", destination, *(\"|| true\" if continue_on_error) ]\n    end\nend\n"
  },
  {
    "path": "lib/kamal/commands/app/containers.rb",
    "content": "module Kamal::Commands::App::Containers\n  DOCKER_HEALTH_LOG_FORMAT    = \"'{{json .State.Health}}'\"\n\n  def list_containers\n    docker :container, :ls, \"--all\", *container_filter_args\n  end\n\n  def list_container_names\n    [ *list_containers, \"--format\", \"'{{ .Names }}'\" ]\n  end\n\n  def remove_container(version:)\n    pipe \\\n      container_id_for(container_name: container_name(version)),\n      xargs(docker(:container, :rm))\n  end\n\n  def rename_container(version:, new_version:)\n    docker :rename, container_name(version), container_name(new_version)\n  end\n\n  def remove_containers\n    docker :container, :prune, \"--force\", *container_filter_args\n  end\n\n  def container_health_log(version:)\n    pipe \\\n      container_id_for(container_name: container_name(version)),\n      xargs(docker(:inspect, \"--format\", DOCKER_HEALTH_LOG_FORMAT))\n  end\nend\n"
  },
  {
    "path": "lib/kamal/commands/app/error_pages.rb",
    "content": "module Kamal::Commands::App::ErrorPages\n  def create_error_pages_directory\n    make_directory(config.proxy_boot.error_pages_directory)\n  end\n\n  def clean_up_error_pages\n    [ :find, config.proxy_boot.error_pages_directory, \"-mindepth\", \"1\", \"-maxdepth\", \"1\", \"!\", \"-name\", KAMAL.config.version, \"-exec\", \"rm\", \"-rf\", \"{} +\" ]\n  end\nend\n"
  },
  {
    "path": "lib/kamal/commands/app/execution.rb",
    "content": "module Kamal::Commands::App::Execution\n  def execute_in_existing_container(*command, interactive: false, env:)\n    docker :exec,\n      (docker_interactive_args if interactive),\n      *argumentize(\"--env\", env),\n      container_name,\n      *command\n  end\n\n  def execute_in_new_container(*command, interactive: false, detach: false, env:)\n    docker :run,\n      (docker_interactive_args if interactive),\n      (\"--detach\" if detach),\n      (\"--rm\" unless detach),\n      \"--name\", container_name_for_exec,\n      \"--network\", \"kamal\",\n      *role&.env_args(host),\n      *argumentize(\"--env\", env),\n      *role.logging_args,\n      *config.volume_args,\n      *role&.option_args,\n      config.absolute_image,\n      *command\n  end\n\n  def execute_in_existing_container_over_ssh(*command, env:)\n    run_over_ssh execute_in_existing_container(*command, interactive: true, env: env), host: host\n  end\n\n  def execute_in_new_container_over_ssh(*command, env:)\n    run_over_ssh execute_in_new_container(*command, interactive: true, env: env), host: host\n  end\n\n  private\n    def container_name_for_exec\n      [ role.container_prefix, \"exec\", config.version, SecureRandom.hex(3) ].compact.join(\"-\")\n    end\nend\n"
  },
  {
    "path": "lib/kamal/commands/app/images.rb",
    "content": "module Kamal::Commands::App::Images\n  def list_images\n    docker :image, :ls, config.repository\n  end\n\n  def remove_images\n    docker :image, :prune, \"--all\", \"--force\", *image_filter_args\n  end\n\n  def tag_latest_image\n    docker :tag, config.absolute_image, config.latest_image\n  end\nend\n"
  },
  {
    "path": "lib/kamal/commands/app/logging.rb",
    "content": "module Kamal::Commands::App::Logging\n  def logs(container_id: nil, timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil)\n    pipe \\\n      container_id_command(container_id),\n      \"xargs docker logs#{\" --timestamps\" if timestamps}#{\" --since #{since}\" if since}#{\" --tail #{lines}\" if lines} 2>&1\",\n      (\"grep '#{grep}'#{\" #{grep_options}\" if grep_options}\" if grep)\n  end\n\n  def follow_logs(host:, container_id: nil, timestamps: true, lines: nil, grep: nil, grep_options: nil)\n    run_over_ssh \\\n      pipe(\n        container_id_command(container_id),\n        \"xargs docker logs#{\" --timestamps\" if timestamps}#{\" --tail #{lines}\" if lines} --follow 2>&1\",\n        (%(grep \"#{grep}\"#{\" #{grep_options}\" if grep_options}) if grep)\n      ),\n      host: host\n  end\n\n  private\n\n  def container_id_command(container_id)\n    case container_id\n    when Array then container_id\n    when String, Symbol then \"echo #{container_id}\"\n    else current_running_container_id\n    end\n  end\nend\n"
  },
  {
    "path": "lib/kamal/commands/app/proxy.rb",
    "content": "module Kamal::Commands::App::Proxy\n  delegate :container_name, to: :\"config.proxy_boot\", prefix: :proxy\n\n  def deploy(target:)\n    proxy_exec :deploy, role.container_prefix, *role.proxy.deploy_command_args(target: target)\n  end\n\n  def remove\n    proxy_exec :remove, role.container_prefix\n  end\n\n  def live\n    proxy_exec :resume, role.container_prefix\n  end\n\n  def maintenance(**options)\n    proxy_exec :stop, role.container_prefix, *role.proxy.stop_command_args(**options)\n  end\n\n  def remove_proxy_app_directory\n    remove_directory config.proxy_boot.app_directory\n  end\n\n  def create_ssl_directory\n    make_directory(File.join(config.proxy_boot.tls_directory, role.name))\n  end\n\n  private\n    def proxy_exec(*command)\n      docker :exec, proxy_container_name, \"kamal-proxy\", *command\n    end\nend\n"
  },
  {
    "path": "lib/kamal/commands/app.rb",
    "content": "class Kamal::Commands::App < Kamal::Commands::Base\n  include Assets, Containers, ErrorPages, Execution, Images, Logging, Proxy\n\n  ACTIVE_DOCKER_STATUSES = [ :running, :restarting ]\n\n  attr_reader :role, :host\n\n  delegate :container_name, to: :role\n\n  def initialize(config, role: nil, host: nil)\n    super(config)\n    @role = role\n    @host = host\n  end\n\n  def run(hostname: nil)\n    docker :run,\n      \"--detach\",\n      \"--restart unless-stopped\",\n      \"--name\", container_name,\n      \"--network\", \"kamal\",\n      *([ \"--hostname\", hostname ] if hostname),\n      \"--env\", \"KAMAL_CONTAINER_NAME=\\\"#{container_name}\\\"\",\n      \"--env\", \"KAMAL_VERSION=\\\"#{config.version}\\\"\",\n      \"--env\", \"KAMAL_HOST=\\\"#{host}\\\"\",\n      *([ \"--env\", \"KAMAL_DESTINATION=\\\"#{config.destination}\\\"\" ] if config.destination),\n      *role.env_args(host),\n      *role.logging_args,\n      *config.volume_args,\n      *role.asset_volume_args,\n      *role.label_args,\n      *role.option_args,\n      config.absolute_image,\n      role.cmd\n  end\n\n  def start\n    docker :start, container_name\n  end\n\n  def status(version:)\n    pipe container_id_for_version(version), xargs(docker(:inspect, \"--format\", DOCKER_HEALTH_STATUS_FORMAT))\n  end\n\n  def stop(version: nil)\n    pipe \\\n      version ? container_id_for_version(version) : current_running_container_id,\n      xargs(docker(:stop, *role.stop_args))\n  end\n\n  def info\n    docker :ps, *container_filter_args\n  end\n\n\n  def current_running_container_id\n    current_running_container(format: \"--quiet\")\n  end\n\n  def container_id_for_version(version, only_running: false)\n    container_id_for(container_name: container_name(version), only_running: only_running)\n  end\n\n  def current_running_version\n    pipe \\\n      current_running_container(format: \"--format '{{.Names}}'\"),\n      extract_version_from_name\n  end\n\n  def list_versions(*docker_args, statuses: nil)\n    pipe \\\n      docker(:ps, *container_filter_args(statuses: statuses), *docker_args, \"--format\", '\"{{.Names}}\"'),\n      extract_version_from_name\n  end\n\n  def ensure_env_directory\n    make_directory role.env_directory\n  end\n\n  private\n    def latest_image_id\n      docker :image, :ls, *argumentize(\"--filter\", \"reference=#{config.latest_image}\"), \"--format\", \"'{{.ID}}'\"\n    end\n\n    def current_running_container(format:)\n      pipe \\\n        shell(chain(latest_image_container(format: format), latest_container(format: format))),\n        [ :head, \"-1\" ]\n    end\n\n    def latest_image_container(format:)\n      latest_container format: format, filters: [ \"ancestor=$(#{latest_image_id.join(\" \")})\" ]\n    end\n\n    def latest_container(format:, filters: nil)\n      docker :ps, \"--latest\", *format, *container_filter_args(statuses: ACTIVE_DOCKER_STATUSES), argumentize(\"--filter\", filters)\n    end\n\n    def container_filter_args(statuses: nil)\n      argumentize \"--filter\", container_filters(statuses: statuses)\n    end\n\n    def image_filter_args\n      argumentize \"--filter\", image_filters\n    end\n\n    def extract_version_from_name\n      # Extract SHA from \"service-role-dest-SHA\"\n      %(while read line; do echo ${line##{role.container_prefix}-}; done)\n    end\n\n    def container_filters(statuses: nil)\n      [ \"label=service=#{config.service}\" ].tap do |filters|\n        filters << \"label=destination=#{config.destination}\"\n        filters << \"label=role=#{role}\" if role\n        statuses&.each do |status|\n          filters << \"status=#{status}\"\n        end\n      end\n    end\n\n    def image_filters\n      [ \"label=service=#{config.service}\" ]\n    end\nend\n"
  },
  {
    "path": "lib/kamal/commands/auditor.rb",
    "content": "class Kamal::Commands::Auditor < Kamal::Commands::Base\n  attr_reader :details\n  delegate :escape_shell_value, to: Kamal::Utils\n\n  def initialize(config, **details)\n    super(config)\n    @details = details\n  end\n\n  # Runs remotely\n  def record(line, **details)\n    combine \\\n      make_run_directory,\n      append([ :echo, escape_shell_value(audit_line(line, **details)) ], audit_log_file)\n  end\n\n  def reveal\n    [ :tail, \"-n\", 50, audit_log_file ]\n  end\n\n  private\n    def audit_log_file\n      file = [ config.service, config.destination, \"audit.log\" ].compact.join(\"-\")\n\n      File.join(config.run_directory, file)\n    end\n\n    def audit_tags(**details)\n      tags(**self.details, **details)\n    end\n\n    def make_run_directory\n      [ :mkdir, \"-p\", config.run_directory ]\n    end\n\n    def audit_line(line, **details)\n      \"#{audit_tags(**details).except(:version, :service_version, :service)} #{line}\"\n    end\nend\n"
  },
  {
    "path": "lib/kamal/commands/base.rb",
    "content": "module Kamal::Commands\n  class Base\n    delegate :sensitive, :argumentize, to: Kamal::Utils\n\n    DOCKER_HEALTH_STATUS_FORMAT = \"'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'\"\n\n    attr_accessor :config\n\n    def initialize(config)\n      @config = config\n    end\n\n    def run_over_ssh(*command, host:)\n      \"ssh#{ssh_config_args}#{ssh_proxy_args}#{ssh_keys_args} -t #{config.ssh.user}@#{host} -p #{config.ssh.port} '#{command.join(\" \").gsub(\"'\", \"'\\\\\\\\''\")}'\"\n    end\n\n    def container_id_for(container_name:, only_running: false)\n      docker :container, :ls, *(\"--all\" unless only_running), \"--filter\", \"'name=^#{container_name}$'\", \"--quiet\"\n    end\n\n    def make_directory_for(remote_file)\n      make_directory Pathname.new(remote_file).dirname.to_s\n    end\n\n    def make_directory(path)\n      [ :mkdir, \"-p\", path ]\n    end\n\n    def remove_directory(path)\n      [ :rm, \"-r\", path ]\n    end\n\n    def remove_file(path)\n      [ :rm, path ]\n    end\n\n    def ensure_docker_installed\n      combine \\\n        ensure_local_docker_installed,\n        ensure_local_buildx_installed\n    end\n\n    private\n      def combine(*commands, by: \"&&\")\n        commands\n          .compact\n          .collect { |command| Array(command) + [ by ] }.flatten # Join commands\n          .tap     { |commands| commands.pop } # Remove trailing combiner\n      end\n\n      def chain(*commands)\n        combine *commands, by: \";\"\n      end\n\n      def pipe(*commands)\n        combine *commands, by: \"|\"\n      end\n\n      def append(*commands)\n        combine *commands, by: \">>\"\n      end\n\n      def write(*commands)\n        combine *commands, by: \">\"\n      end\n\n      def any(*commands)\n        combine *commands, by: \"||\"\n      end\n\n      def substitute(*commands)\n        \"\\$\\(#{commands.join(\" \")}\\)\"\n      end\n\n      def xargs(command)\n        [ :xargs, command ].flatten\n      end\n\n      def shell(command)\n        [ :sh, \"-c\", \"'#{command.flatten.join(\" \").gsub(\"'\", \"'\\\\\\\\''\")}'\" ]\n      end\n\n      def docker(*args)\n        args.compact.unshift :docker\n      end\n\n      def pack(*args)\n        args.compact.unshift :pack\n      end\n\n      def git(*args, path: nil)\n        [ :git, *([ \"-C\", path ] if path), *args.compact ]\n      end\n\n      def grep(*args)\n        args.compact.unshift :grep\n      end\n\n      def tags(**details)\n        Kamal::Tags.from_config(config, **details)\n      end\n\n      def ssh_config_args\n        case config.ssh.config\n        when Array\n          config.ssh.config.map { |file| \" -F #{file}\" }.join\n        when String\n          \" -F #{config.ssh.config}\"\n        when true\n          \"\" # Use default SSH config\n        when false\n          \" -F /dev/null\" # Ignore SSH config\n        end\n      end\n\n      def ssh_proxy_args\n        case config.ssh.proxy\n        when Net::SSH::Proxy::Jump\n          \" -J #{config.ssh.proxy.jump_proxies}\"\n        when Net::SSH::Proxy::Command\n          \" -o ProxyCommand='#{config.ssh.proxy.command_line_template}'\"\n        end\n      end\n\n      def ssh_keys_args\n        \"#{ ssh_keys.join(\"\") if ssh_keys}\" + \"#{\" -o IdentitiesOnly=yes\" if config.ssh&.keys_only}\"\n      end\n\n      def ssh_keys\n        config.ssh.keys&.map do |key|\n          \" -i #{key}\"\n        end\n      end\n\n      def ensure_local_docker_installed\n        docker \"--version\"\n      end\n\n      def ensure_local_buildx_installed\n        docker :buildx, \"version\"\n      end\n\n      def docker_interactive_args\n        STDIN.isatty ? \"-it\" : \"-i\"\n      end\n  end\nend\n"
  },
  {
    "path": "lib/kamal/commands/builder/base.rb",
    "content": "class Kamal::Commands::Builder::Base < Kamal::Commands::Base\n  class BuilderError < StandardError; end\n\n  ENDPOINT_DOCKER_HOST_INSPECT = \"'{{.Endpoints.docker.Host}}'\"\n\n  delegate :argumentize, to: Kamal::Utils\n  delegate \\\n    :args, :secrets, :dockerfile, :target, :arches, :local_arches, :remote_arches, :remote,\n    :pack?, :pack_builder, :pack_buildpacks,\n    :cache_from, :cache_to, :ssh, :provenance, :sbom, :driver, :docker_driver?,\n    to: :builder_config\n\n  def clean\n    docker :image, :rm, \"--force\", config.absolute_image\n  end\n\n  def push(export_action = \"registry\", tag_as_dirty: false, no_cache: false)\n    docker :buildx, :build,\n      \"--output=type=#{export_action}\",\n      *platform_options(arches),\n      *([ \"--builder\", builder_name ] unless docker_driver?),\n      *build_tag_options(tag_as_dirty: tag_as_dirty),\n      *build_options,\n      *([ \"--no-cache\" ] if no_cache),\n      build_context,\n      \"2>&1\"\n  end\n\n  def pull\n    docker :pull, config.absolute_image\n  end\n\n  def info\n    combine \\\n      docker(:context, :ls),\n      docker(:buildx, :ls)\n  end\n\n  def inspect_builder\n    docker :buildx, :inspect, builder_name unless docker_driver?\n  end\n\n  def build_options\n    [ *build_cache, *build_labels, *build_args, *build_secrets, *build_dockerfile, *build_target, *build_ssh, *builder_provenance, *builder_sbom ]\n  end\n\n  def build_context\n    config.builder.context\n  end\n\n  def validate_image\n    pipe \\\n      docker(:inspect, \"-f\", \"'{{ .Config.Labels.service }}'\", config.absolute_image),\n      any(\n        [ :grep, \"-x\", config.service ],\n        \"(echo \\\"Image #{config.absolute_image} is missing the 'service' label\\\" && exit 1)\"\n      )\n  end\n\n  def first_mirror\n    docker(:info, \"--format '{{index .RegistryConfig.Mirrors 0}}'\")\n  end\n\n  def login_to_registry_locally?\n    true\n  end\n\n  def push_env\n    {}\n  end\n\n  private\n    def build_tag_names(tag_as_dirty: false)\n      tag_names = [ config.absolute_image, config.latest_image ]\n      tag_names.map! { |t| \"#{t}-dirty\" } if tag_as_dirty\n      tag_names\n    end\n\n    def build_tag_options(tag_as_dirty: false)\n      build_tag_names(tag_as_dirty: tag_as_dirty).flat_map { |name| [ \"-t\", name ] }\n    end\n\n    def build_cache\n      if cache_to && cache_from\n        [ \"--cache-to\", cache_to,\n          \"--cache-from\", cache_from ]\n      end\n    end\n\n    def build_labels\n      argumentize \"--label\", { service: config.service }\n    end\n\n    def build_args\n      argumentize \"--build-arg\", args, sensitive: true\n    end\n\n    def build_secrets\n      argumentize \"--secret\", secrets.keys.collect { |secret| [ \"id\", secret ] }\n    end\n\n    def build_dockerfile\n      if Pathname.new(File.expand_path(dockerfile)).exist?\n        argumentize \"--file\", dockerfile\n      else\n        raise BuilderError, \"Missing #{dockerfile}\"\n      end\n    end\n\n    def build_target\n      argumentize \"--target\", target if target.present?\n    end\n\n    def build_ssh\n      argumentize \"--ssh\", ssh if ssh.present?\n    end\n\n    def builder_provenance\n      argumentize \"--provenance\", provenance unless provenance.nil?\n    end\n\n    def builder_sbom\n      argumentize \"--sbom\", sbom unless sbom.nil?\n    end\n\n    def builder_config\n      config.builder\n    end\n\n    def registry_config\n      config.registry\n    end\n\n    def driver_options\n      if registry_config.local?\n        [ \"--driver-opt\", \"network=host\" ]\n      end\n    end\n\n    def platform_options(arches)\n      argumentize \"--platform\", arches.map { |arch| \"linux/#{arch}\" }.join(\",\") if arches.any?\n    end\nend\n"
  },
  {
    "path": "lib/kamal/commands/builder/clone.rb",
    "content": "module Kamal::Commands::Builder::Clone\n  def clone\n    git :clone, escaped_root, \"--recurse-submodules\", path: config.builder.clone_directory.shellescape\n  end\n\n  def clone_reset_steps\n    [\n      git(:remote, \"set-url\", :origin, escaped_root, path: escaped_build_directory),\n      git(:fetch, :origin, path: escaped_build_directory),\n      git(:reset, \"--hard\", Kamal::Git.revision, path: escaped_build_directory),\n      git(:clean, \"-fdx\", path: escaped_build_directory),\n      git(:submodule, :update, \"--init\", path: escaped_build_directory)\n    ]\n  end\n\n  def clone_status\n    git :status, \"--porcelain\", path: escaped_build_directory\n  end\n\n  def clone_revision\n    git :\"rev-parse\", :HEAD, path: escaped_build_directory\n  end\n\n  def escaped_root\n    Kamal::Git.root.shellescape\n  end\n\n  def escaped_build_directory\n    config.builder.build_directory.shellescape\n  end\nend\n"
  },
  {
    "path": "lib/kamal/commands/builder/cloud.rb",
    "content": "class Kamal::Commands::Builder::Cloud < Kamal::Commands::Builder::Base\n  # Expects `driver` to be of format \"cloud docker-org-name/builder-name\"\n\n  def create\n    docker :buildx, :create, \"--driver\", driver\n  end\n\n  def remove\n    docker :buildx, :rm, builder_name\n  end\n\n  private\n    def builder_name\n      driver.gsub(/[ \\/]/, \"-\")\n    end\n\n    def inspect_buildx\n      pipe \\\n        docker(:buildx, :inspect, builder_name),\n        grep(\"-q\", \"Endpoint:.*cloud://.*\")\n    end\nend\n"
  },
  {
    "path": "lib/kamal/commands/builder/hybrid.rb",
    "content": "class Kamal::Commands::Builder::Hybrid < Kamal::Commands::Builder::Remote\n  def create\n    combine \\\n      create_local_buildx,\n      create_remote_context,\n      append_remote_buildx\n  end\n\n  private\n    def builder_name\n      \"kamal-hybrid-#{driver}-#{remote_builder_name_suffix}\"\n    end\n\n    def create_local_buildx\n      docker :buildx, :create, *platform_options(local_arches), \"--name\", builder_name, \"--driver=#{driver}\", *driver_options\n    end\n\n    def append_remote_buildx\n      docker :buildx, :create, *platform_options(remote_arches), \"--append\", \"--name\", builder_name, *driver_options, remote_context_name\n    end\nend\n"
  },
  {
    "path": "lib/kamal/commands/builder/local.rb",
    "content": "class Kamal::Commands::Builder::Local < Kamal::Commands::Builder::Base\n  def create\n    return if docker_driver?\n\n    docker :buildx, :create, \"--name\", builder_name, \"--driver=#{driver}\", *driver_options\n  end\n\n  def remove\n    docker :buildx, :rm, builder_name unless docker_driver?\n  end\n\n  private\n    def builder_name\n      if registry_config.local?\n        \"kamal-local-registry-#{driver}\"\n      else\n        \"kamal-local-#{driver}\"\n      end\n    end\nend\n"
  },
  {
    "path": "lib/kamal/commands/builder/pack.rb",
    "content": "class Kamal::Commands::Builder::Pack < Kamal::Commands::Builder::Base\n  def push(export_action = \"registry\", tag_as_dirty: false, no_cache: false)\n    combine \\\n      build(tag_as_dirty: tag_as_dirty, no_cache: no_cache),\n      export(export_action)\n  end\n\n  def remove;end\n\n  def info\n    pack :builder, :inspect, pack_builder\n  end\n  alias_method :inspect_builder, :info\n\n  private\n    def build(tag_as_dirty: false, no_cache: false)\n      pack(:build,\n        config.repository,\n        \"--platform\", platform,\n        \"--creation-time\", \"now\",\n        \"--builder\", pack_builder,\n        buildpacks,\n        *build_tag_options(tag_as_dirty: tag_as_dirty),\n        *([ \"--clear-cache\" ] if no_cache),\n        \"--env\", \"BP_IMAGE_LABELS=service=#{config.service}\",\n        *argumentize(\"--env\", args),\n        *argumentize(\"--env\", secrets, sensitive: true),\n        \"--path\", build_context)\n    end\n\n    def export(export_action)\n      return unless export_action == \"registry\"\n\n      combine \\\n        docker(:push, config.absolute_image),\n        docker(:push, config.latest_image)\n    end\n\n    def platform\n      \"linux/#{local_arches.first}\"\n    end\n\n    def buildpacks\n      (pack_buildpacks << \"paketo-buildpacks/image-labels\").map { |buildpack| [ \"--buildpack\", buildpack ] }\n    end\nend\n"
  },
  {
    "path": "lib/kamal/commands/builder/remote.rb",
    "content": "class Kamal::Commands::Builder::Remote < Kamal::Commands::Builder::Base\n  def create\n    chain \\\n      create_remote_context,\n      create_buildx\n  end\n\n  def remove\n    chain \\\n      remove_remote_context,\n      remove_buildx\n  end\n\n  def info\n    chain \\\n      docker(:context, :ls),\n      docker(:buildx, :ls)\n  end\n\n  def inspect_builder\n    combine \\\n      combine(inspect_buildx, inspect_remote_context),\n      [ \"(echo no compatible builder && exit 1)\" ],\n      by: \"||\"\n  end\n\n  def login_to_registry_locally?\n    false\n  end\n\n  def push_env\n    { \"BUILDKIT_NO_CLIENT_TOKEN\" => \"1\" }\n  end\n\n  private\n    def builder_name\n      \"kamal-remote-#{remote_builder_name_suffix}\"\n    end\n\n    def remote_context_name\n      \"#{builder_name}-context\"\n    end\n\n    def remote_builder_name_suffix\n      \"#{remote.gsub(/[^a-z0-9_-]/, \"-\")}#{registry_config.local? ? \"-local-registry\" : \"\" }\"\n    end\n\n    def inspect_buildx\n      pipe \\\n        docker(:buildx, :inspect, builder_name),\n        grep(\"-q\", \"Endpoint:.*#{remote_context_name}\")\n    end\n\n    def inspect_remote_context\n      pipe \\\n        docker(:context, :inspect, remote_context_name, \"--format\", ENDPOINT_DOCKER_HOST_INSPECT),\n        grep(\"-xq\", remote)\n    end\n\n    def create_remote_context\n      docker :context, :create, remote_context_name, \"--description\", \"'#{builder_name} host'\", \"--docker\", \"'host=#{remote}'\"\n    end\n\n    def remove_remote_context\n      docker :context, :rm, remote_context_name\n    end\n\n    def create_buildx\n      docker :buildx, :create, \"--name\", builder_name, *driver_options, remote_context_name\n    end\n\n    def remove_buildx\n      docker :buildx, :rm, builder_name\n    end\nend\n"
  },
  {
    "path": "lib/kamal/commands/builder.rb",
    "content": "require \"active_support/core_ext/string/filters\"\n\nclass Kamal::Commands::Builder < Kamal::Commands::Base\n  delegate \\\n    :create, :remove, :dev, :push, :clean, :pull, :info, :inspect_builder,\n    :validate_image, :first_mirror, :login_to_registry_locally?, :push_env,\n    to: :target\n\n  delegate \\\n    :local?, :remote?, :pack?, :cloud?,\n    to: \"config.builder\"\n\n  include Clone\n\n  def name\n    target.class.to_s.remove(\"Kamal::Commands::Builder::\").underscore.inquiry\n  end\n\n  def target\n    if remote?\n      if local?\n        hybrid\n      else\n        remote\n      end\n    elsif pack?\n      pack\n    elsif cloud?\n      cloud\n    else\n      local\n    end\n  end\n\n  def remote\n    @remote ||= Kamal::Commands::Builder::Remote.new(config)\n  end\n\n  def local\n    @local ||= Kamal::Commands::Builder::Local.new(config)\n  end\n\n  def hybrid\n    @hybrid ||= Kamal::Commands::Builder::Hybrid.new(config)\n  end\n\n  def pack\n    @pack ||= Kamal::Commands::Builder::Pack.new(config)\n  end\n\n  def cloud\n    @cloud ||= Kamal::Commands::Builder::Cloud.new(config)\n  end\nend\n"
  },
  {
    "path": "lib/kamal/commands/docker.rb",
    "content": "class Kamal::Commands::Docker < Kamal::Commands::Base\n  # Install Docker using the https://github.com/docker/docker-install convenience script.\n  def install\n    pipe get_docker, :sh\n  end\n\n  # Checks the Docker client version. Fails if Docker is not installed.\n  def installed?\n    docker \"-v\"\n  end\n\n  # Checks the Docker server version. Fails if Docker is not running.\n  def running?\n    docker :version\n  end\n\n  # Do we have superuser access to install Docker and start system services?\n  def superuser?\n    [ '[ \"${EUID:-$(id -u)}\" -eq 0 ] || sudo -nl usermod >/dev/null' ]\n  end\n\n  def root?\n    [ '[ \"${EUID:-$(id -u)}\" -eq 0 ]' ]\n  end\n\n  def in_docker_group?\n    [ 'id -nG \"${USER:-$(id -un)}\" | grep -qw docker' ]\n  end\n\n  def add_to_docker_group\n    [ 'sudo -n usermod -aG docker \"${USER:-$(id -un)}\"' ]\n  end\n\n  def refresh_session\n    [ \"kill -HUP $PPID\" ]\n  end\n\n  def create_network\n    docker :network, :create, :kamal\n  end\n\n  private\n    def get_docker\n      shell \\\n        any \\\n          [ :curl, \"-fsSL\", \"https://get.docker.com\" ],\n          [ :wget, \"-O -\", \"https://get.docker.com\" ],\n          [ :echo, \"\\\"exit 1\\\"\" ]\n    end\nend\n"
  },
  {
    "path": "lib/kamal/commands/hook.rb",
    "content": "class Kamal::Commands::Hook < Kamal::Commands::Base\n  def run(hook)\n    [ hook_file(hook) ]\n  end\n\n  def env(secrets: false, **details)\n    tags(**details).env.tap do |env|\n      env.merge!(config.secrets.to_h) if secrets\n    end\n  end\n\n  def hook_exists?(hook)\n    Pathname.new(hook_file(hook)).exist?\n  end\n\n  private\n    def hook_file(hook)\n      File.join(config.hooks_path, hook)\n    end\nend\n"
  },
  {
    "path": "lib/kamal/commands/lock.rb",
    "content": "require \"active_support/duration\"\nrequire \"time\"\nrequire \"base64\"\n\nclass Kamal::Commands::Lock < Kamal::Commands::Base\n  def acquire(message, version)\n    combine \\\n      [ :mkdir, lock_dir ],\n      write_lock_details(message, version)\n  end\n\n  def release\n    combine \\\n      [ :rm, lock_details_file ],\n      [ :rm, \"-r\", lock_dir ]\n  end\n\n  def status\n    combine \\\n      stat_lock_dir,\n      read_lock_details\n  end\n\n  def ensure_locks_directory\n    [ :mkdir, \"-p\", locks_dir ]\n  end\n\n  private\n    def write_lock_details(message, version)\n      write \\\n        [ :echo, \"\\\"#{Base64.encode64(lock_details(message, version))}\\\"\" ],\n        lock_details_file\n    end\n\n    def read_lock_details\n      pipe \\\n        [ :cat, lock_details_file ],\n        [ :base64, \"-d\" ]\n    end\n\n    def stat_lock_dir\n      write \\\n        [ :stat, lock_dir ],\n        \"/dev/null\"\n    end\n\n    def lock_dir\n      dir_name = [ \"lock\", config.service, config.destination ].compact.join(\"-\")\n\n      File.join(config.run_directory, dir_name)\n    end\n\n    def lock_details_file\n      File.join(lock_dir, \"details\")\n    end\n\n    def lock_details(message, version)\n      <<~DETAILS.strip\n        Locked by: #{locked_by} at #{Time.now.utc.iso8601}\n        Version: #{version}\n        Message: #{message}\n      DETAILS\n    end\n\n    def locked_by\n      Kamal::Git.user_name\n    rescue Errno::ENOENT\n      \"Unknown\"\n    end\nend\n"
  },
  {
    "path": "lib/kamal/commands/proxy.rb",
    "content": "class Kamal::Commands::Proxy < Kamal::Commands::Base\n  delegate :argumentize, :optionize, to: Kamal::Utils\n  attr_reader :proxy_run_config\n\n  def initialize(config, host:)\n    super(config)\n    @proxy_run_config = config.proxy_run(host)\n  end\n\n  def run\n    if proxy_run_config\n      docker \\\n        :run,\n        \"--name\", container_name,\n        \"--network\", \"kamal\",\n        \"--detach\",\n        \"--restart\", \"unless-stopped\",\n        \"--volume\", \"kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy\",\n        *proxy_run_config.docker_options_args,\n        *proxy_run_config.image,\n        *proxy_run_config.run_command\n    else\n      pipe boot_config, xargs(docker_run)\n    end\n  end\n\n  def start\n    docker :container, :start, container_name\n  end\n\n  def stop(name: container_name)\n    docker :container, :stop, name\n  end\n\n  def start_or_run\n    combine start, run, by: \"||\"\n  end\n\n  def info\n    docker :ps, \"--filter\", \"'name=^#{container_name}$'\"\n  end\n\n  def version\n    pipe \\\n      docker(:inspect, container_name, \"--format '{{.Config.Image}}'\"),\n      [ :awk, \"-F:\", \"'{print \\$NF}'\" ]\n  end\n\n  def logs(timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil)\n    pipe \\\n      docker(:logs, container_name, (\"--since #{since}\" if since), (\"--tail #{lines}\" if lines), (\"--timestamps\" if timestamps), \"2>&1\"),\n      (\"grep '#{grep}'#{\" #{grep_options}\" if grep_options}\" if grep)\n  end\n\n  def follow_logs(host:, timestamps: true, grep: nil, grep_options: nil)\n    run_over_ssh pipe(\n      docker(:logs, container_name, (\"--timestamps\" if timestamps), \"--tail\", \"10\", \"--follow\", \"2>&1\"),\n      (%(grep \"#{grep}\"#{\" #{grep_options}\" if grep_options}) if grep)\n    ).join(\" \"), host: host\n  end\n\n  def remove_container\n    docker :container, :prune, \"--force\", \"--filter\", \"label=org.opencontainers.image.title=kamal-proxy\"\n  end\n\n  def remove_image\n    docker :image, :prune, \"--all\", \"--force\", \"--filter\", \"label=org.opencontainers.image.title=kamal-proxy\"\n  end\n\n  def cleanup_traefik\n    chain \\\n      docker(:container, :stop, \"traefik\"),\n      combine(\n        docker(:container, :prune, \"--force\", \"--filter\", \"label=org.opencontainers.image.title=Traefik\"),\n        docker(:image, :prune, \"--all\", \"--force\", \"--filter\", \"label=org.opencontainers.image.title=Traefik\")\n      )\n  end\n\n  def ensure_proxy_directory\n    make_directory config.proxy_boot.host_directory\n  end\n\n  def remove_proxy_directory\n    remove_directory config.proxy_boot.host_directory\n  end\n\n  def ensure_apps_config_directory\n    make_directory config.proxy_boot.apps_directory\n  end\n\n  def boot_config\n    [ :echo, \"#{substitute(read_boot_options)} #{substitute(read_image)}:#{substitute(read_image_version)} #{substitute(read_run_command)}\" ]\n  end\n\n  def read_boot_options\n    read_file(config.proxy_boot.options_file, default: config.proxy_boot.default_boot_options.join(\" \"))\n  end\n\n  def read_image\n    read_file(config.proxy_boot.image_file, default: config.proxy_boot.image_default)\n  end\n\n  def read_image_version\n    read_file(config.proxy_boot.image_version_file, default: Kamal::Configuration::Proxy::Run::MINIMUM_VERSION)\n  end\n\n  def read_run_command\n    read_file(config.proxy_boot.run_command_file)\n  end\n\n  def reset_boot_options\n    remove_file config.proxy_boot.options_file\n  end\n\n  def reset_image\n    remove_file config.proxy_boot.image_file\n  end\n\n  def reset_image_version\n    remove_file config.proxy_boot.image_version_file\n  end\n\n  def reset_run_command\n    remove_file config.proxy_boot.run_command_file\n  end\n\n  private\n    def container_name\n      config.proxy_boot.container_name\n    end\n\n    def read_file(file, default: nil)\n      combine [ :cat, file, \"2>\", \"/dev/null\" ], [ :echo, \"\\\"#{default}\\\"\" ], by: \"||\"\n    end\n\n    def docker_run\n      docker \\\n        :run,\n        \"--name\", container_name,\n        \"--network\", \"kamal\",\n        \"--detach\",\n        \"--restart\", \"unless-stopped\",\n        \"--volume\", \"kamal-proxy-config:/home/kamal-proxy/.config/kamal-proxy\",\n        *config.proxy_boot.apps_volume.docker_args\n    end\nend\n"
  },
  {
    "path": "lib/kamal/commands/prune.rb",
    "content": "require \"active_support/duration\"\nrequire \"active_support/core_ext/numeric/time\"\n\nclass Kamal::Commands::Prune < Kamal::Commands::Base\n  def dangling_images\n    docker :image, :prune, \"--force\", \"--filter\", \"label=service=#{config.service}\"\n  end\n\n  def tagged_images\n    pipe \\\n      docker(:image, :ls, *service_filter, \"--format\", \"'{{.ID}} {{.Repository}}:{{.Tag}}'\"),\n      grep(\"-v -w \\\"#{active_image_list}\\\"\"),\n      \"while read image tag; do docker rmi $tag; done\"\n  end\n\n  def app_containers(retain:)\n    pipe \\\n      docker(:ps, \"-q\", \"-a\", *service_filter, *stopped_containers_filters),\n      \"tail -n +#{retain + 1}\",\n      \"while read container_id; do docker rm $container_id; done\"\n  end\n\n  private\n    def stopped_containers_filters\n      [ \"created\", \"exited\", \"dead\" ].flat_map { |status| [ \"--filter\", \"status=#{status}\" ] }\n    end\n\n    def active_image_list\n      # Pull the images that are used by any containers\n      # Append repo:latest - to avoid deleting the latest tag\n      # Append repo:<none> - to avoid deleting dangling images that are in use. Unused dangling images are deleted separately\n      \"$(docker container ls -a --format '{{.Image}}\\\\|' --filter label=service=#{config.service} | tr -d '\\\\n')#{config.latest_image}\\\\|#{config.repository}:<none>\"\n    end\n\n    def service_filter\n      [ \"--filter\", \"label=service=#{config.service}\" ]\n    end\nend\n"
  },
  {
    "path": "lib/kamal/commands/registry.rb",
    "content": "class Kamal::Commands::Registry < Kamal::Commands::Base\n  def login(registry_config: nil)\n    registry_config ||= config.registry\n\n    return if registry_config.local?\n\n    docker :login,\n      registry_config.server,\n      \"-u\", sensitive(Kamal::Utils.escape_shell_value(registry_config.username)),\n      \"-p\", sensitive(Kamal::Utils.escape_shell_value(registry_config.password))\n  end\n\n  def logout(registry_config: nil)\n    registry_config ||= config.registry\n\n    docker :logout, registry_config.server\n  end\n\n  def setup(registry_config: nil)\n    registry_config ||= config.registry\n\n    combine \\\n      docker(:start, \"kamal-docker-registry\"),\n      docker(:run, \"--detach\", \"-p\", \"127.0.0.1:#{registry_config.local_port}:5000\", \"--name\", \"kamal-docker-registry\", \"registry:3\"),\n      by: \"||\"\n  end\n\n  def remove\n    combine \\\n      docker(:stop, \"kamal-docker-registry\"),\n      docker(:rm, \"kamal-docker-registry\"),\n      by: \"&&\"\n  end\n\n  def local?\n    config.registry.local?\n  end\nend\n"
  },
  {
    "path": "lib/kamal/commands/server.rb",
    "content": "class Kamal::Commands::Server < Kamal::Commands::Base\n  def ensure_run_directory\n    make_directory config.run_directory\n  end\n\n  def remove_app_directory\n    remove_directory config.app_directory\n  end\n\n  def app_directory_count\n    pipe \\\n      [ :ls, config.apps_directory ],\n      [ :wc, \"-l\" ]\n  end\nend\n"
  },
  {
    "path": "lib/kamal/commands.rb",
    "content": "module Kamal::Commands\nend\n"
  },
  {
    "path": "lib/kamal/configuration/accessory.rb",
    "content": "class Kamal::Configuration::Accessory\n  include Kamal::Configuration::Validation\n\n  DEFAULT_NETWORK = \"kamal\"\n\n  delegate :argumentize, :optionize, to: Kamal::Utils\n\n  attr_reader :name, :env, :proxy, :registry\n\n  def initialize(name, config:)\n    @name, @config, @accessory_config = name.inquiry, config, config.raw_config[\"accessories\"][name]\n\n    validate! \\\n      accessory_config,\n      example: validation_yml[\"accessories\"][\"mysql\"],\n      context: \"accessories/#{name}\",\n      with: Kamal::Configuration::Validator::Accessory\n\n    ensure_valid_roles\n\n    @env = initialize_env\n    @proxy = initialize_proxy if running_proxy?\n    @registry = initialize_registry if accessory_config[\"registry\"].present?\n  end\n\n  def service_name\n    accessory_config[\"service\"] || \"#{config.service}-#{name}\"\n  end\n\n  def image\n    [ registry&.server, accessory_config[\"image\"] ].compact.join(\"/\")\n  end\n\n  def hosts\n    hosts_from_host || hosts_from_hosts || hosts_from_roles || hosts_from_tags\n  end\n\n  def port\n    if port = accessory_config[\"port\"]&.to_s\n      port.include?(\":\") ? port : \"#{port}:#{port}\"\n    end\n  end\n\n  def network_args\n    argumentize \"--network\", network\n  end\n\n  def publish_args\n    argumentize \"--publish\", port if port\n  end\n\n  def labels\n    default_labels.merge(accessory_config[\"labels\"] || {})\n  end\n\n  def label_args\n    argumentize \"--label\", labels\n  end\n\n  def env_args\n    [ *env.clear_args, *argumentize(\"--env-file\", secrets_path) ]\n  end\n\n  def env_directory\n    File.join(config.env_directory, \"accessories\")\n  end\n\n  def secrets_io\n    env.secrets_io\n  end\n\n  def secrets_path\n    File.join(config.env_directory, \"accessories\", \"#{name}.env\")\n  end\n\n  def files\n    accessory_config[\"files\"]&.to_h do |config|\n      parse_path_config(config, default_mode: \"755\") do |local, remote|\n        {\n          key: expand_local_file(local),\n          host_path: expand_remote_file(remote),\n          container_path: remote\n        }\n      end\n    end || {}\n  end\n\n  def directories\n    accessory_config[\"directories\"]&.to_h do |config|\n      parse_path_config(config, default_mode: nil) do |local, remote|\n        {\n          key: expand_host_path(local),\n          host_path: expand_host_path_for_volume(local),\n          container_path: remote\n        }\n      end\n    end || {}\n  end\n\n  def volume_args\n    argumentize(\"--volume\", specific_volumes) + (path_volumes(files) + path_volumes(directories)).flat_map(&:docker_args)\n  end\n\n  def option_args\n    if args = accessory_config[\"options\"]\n      optionize args\n    else\n      []\n    end\n  end\n\n  def cmd\n    accessory_config[\"cmd\"]\n  end\n\n  def running_proxy?\n    accessory_config[\"proxy\"].present?\n  end\n\n  private\n    attr_reader :config, :accessory_config\n\n    def initialize_env\n      Kamal::Configuration::Env.new \\\n        config: accessory_config.fetch(\"env\", {}),\n        secrets: config.secrets,\n        context: \"accessories/#{name}/env\"\n    end\n\n    def initialize_proxy\n      Kamal::Configuration::Proxy.new \\\n        config: config,\n        proxy_config: accessory_config[\"proxy\"],\n        context: \"accessories/#{name}/proxy\",\n        secrets: config.secrets\n    end\n\n    def initialize_registry\n      Kamal::Configuration::Registry.new \\\n        config: accessory_config,\n        secrets: config.secrets,\n        context: \"accessories/#{name}/registry\"\n    end\n\n    def default_labels\n      { \"service\" => service_name }\n    end\n\n    def expand_local_file(local_file)\n      if local_file.end_with?(\"erb\")\n        with_env_loaded { read_dynamic_file(local_file) }\n      else\n        Pathname.new(File.expand_path(local_file)).to_s\n      end\n    end\n\n    def with_env_loaded\n      env.to_h.each { |k, v| ENV[k] = v }\n      yield\n    ensure\n      env.to_h.each { |k, v| ENV.delete(k) }\n    end\n\n    def read_dynamic_file(local_file)\n      StringIO.new(ERB.new(File.read(local_file)).result)\n    end\n\n    def expand_remote_file(remote_file)\n      service_name + remote_file\n    end\n\n    def specific_volumes\n      accessory_config[\"volumes\"] || []\n    end\n\n    def path_volumes(paths)\n      paths.map do |local, config|\n        Kamal::Configuration::Volume.new \\\n          host_path: config[:host_path],\n          container_path: config[:container_path],\n          options: config[:options]\n      end\n    end\n\n    def parse_path_config(config, default_mode:)\n      if config.is_a?(Hash)\n        local, remote = config[\"local\"], config[\"remote\"]\n        expanded = yield(local, remote)\n        [\n          expanded[:key],\n          expanded.except(:key).merge(\n            options: config[\"options\"],\n            mode: config[\"mode\"] || default_mode,\n            owner: config[\"owner\"]\n          )\n        ]\n      else\n        local, remote, options = config.split(\":\", 3)\n        expanded = yield(local, remote)\n        [\n          expanded[:key],\n          expanded.except(:key).merge(\n            options: options,\n            mode: default_mode,\n            owner: nil\n          )\n        ]\n      end\n    end\n\n    def expand_host_path(host_path)\n      absolute_path?(host_path) ? host_path : File.join(service_data_directory, host_path)\n    end\n\n    def expand_host_path_for_volume(host_path)\n      absolute_path?(host_path) ? host_path : File.join(service_name, host_path)\n    end\n\n    def absolute_path?(path)\n      Pathname.new(path).absolute?\n    end\n\n    def service_data_directory\n      \"$PWD/#{service_name}\"\n    end\n\n    def hosts_from_host\n      [ accessory_config[\"host\"] ] if accessory_config.key?(\"host\")\n    end\n\n    def hosts_from_hosts\n      accessory_config[\"hosts\"] if accessory_config.key?(\"hosts\")\n    end\n\n    def hosts_from_roles\n      if accessory_config.key?(\"role\")\n       config.role(accessory_config[\"role\"])&.hosts\n      elsif accessory_config.key?(\"roles\")\n        accessory_config[\"roles\"].flat_map { |role| config.role(role)&.hosts }\n      end\n    end\n\n    def hosts_from_tags\n      if accessory_config.key?(\"tag\")\n        extract_hosts_from_config_with_tag(accessory_config[\"tag\"])\n      elsif accessory_config.key?(\"tags\")\n        accessory_config[\"tags\"].flat_map { |tag| extract_hosts_from_config_with_tag(tag) }\n      end\n    end\n\n    def extract_hosts_from_config_with_tag(tag)\n      if (servers_with_roles = config.raw_config.servers).is_a?(Hash)\n        servers_with_roles.flat_map do |role, servers_in_role|\n          servers_in_role.filter_map do |host|\n            host.keys.first if host.is_a?(Hash) && host.values.first.include?(tag)\n          end\n        end\n      end\n    end\n\n    def network\n      accessory_config[\"network\"] || DEFAULT_NETWORK\n    end\n\n    def ensure_valid_roles\n      if accessory_config[\"roles\"] && (missing_roles = accessory_config[\"roles\"] - config.roles.map(&:name)).any?\n        raise Kamal::ConfigurationError, \"accessories/#{name}: unknown roles #{missing_roles.join(\", \")}\"\n      elsif accessory_config[\"role\"] && !config.role(accessory_config[\"role\"])\n        raise Kamal::ConfigurationError, \"accessories/#{name}: unknown role #{accessory_config[\"role\"]}\"\n      end\n    end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/alias.rb",
    "content": "class Kamal::Configuration::Alias\n  include Kamal::Configuration::Validation\n\n  attr_reader :name, :command\n\n  def initialize(name, config:)\n    @name, @command = name.inquiry, config.raw_config[\"aliases\"][name]\n\n    validate! \\\n      command,\n      example: validation_yml[\"aliases\"][\"uname\"],\n      context: \"aliases/#{name}\",\n      with: Kamal::Configuration::Validator::Alias\n  end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/boot.rb",
    "content": "class Kamal::Configuration::Boot\n  include Kamal::Configuration::Validation\n\n  attr_reader :boot_config, :host_count\n\n  def initialize(config:)\n    @boot_config = config.raw_config.boot || {}\n    @host_count = config.all_hosts.count\n    validate! boot_config\n  end\n\n  def limit\n    limit = boot_config[\"limit\"]\n\n    if limit.to_s.end_with?(\"%\")\n      [ host_count * limit.to_i / 100, 1 ].max\n    else\n      limit\n    end\n  end\n\n  def wait\n    boot_config[\"wait\"]\n  end\n\n  def parallel_roles\n    boot_config[\"parallel_roles\"]\n  end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/builder.rb",
    "content": "class Kamal::Configuration::Builder\n  include Kamal::Configuration::Validation\n\n  attr_reader :config, :builder_config\n  delegate :image, :service, to: :config\n  delegate :server, to: :\"config.registry\"\n\n  def initialize(config:)\n    @config = config\n    @builder_config = config.raw_config.builder || {}\n    @image = config.image\n    @server = config.registry.server\n    @service = config.service\n\n    validate! builder_config, with: Kamal::Configuration::Validator::Builder\n  end\n\n  def to_h\n    builder_config\n  end\n\n  def remote\n    builder_config[\"remote\"]\n  end\n\n  def arches\n    Array(builder_config.fetch(\"arch\", default_arch))\n  end\n\n  def local_arches\n    @local_arches ||= if local_disabled?\n      []\n    elsif remote\n      arches & [ Kamal::Utils.docker_arch ]\n    else\n      arches\n    end\n  end\n\n  def remote_arches\n    @remote_arches ||= if remote\n      arches - local_arches\n    else\n      []\n    end\n  end\n\n  def remote?\n    remote_arches.any?\n  end\n\n  def local?\n    !local_disabled? && (arches.empty? || local_arches.any?)\n  end\n\n  def cloud?\n    driver.start_with? \"cloud\"\n  end\n\n  def cached?\n    !!builder_config[\"cache\"]\n  end\n\n  def pack?\n    !!builder_config[\"pack\"]\n  end\n\n  def args\n    builder_config[\"args\"] || {}\n  end\n\n  def secrets\n    (builder_config[\"secrets\"] || []).to_h { |key| [ key, config.secrets[key] ] }\n  end\n\n  def dockerfile\n    builder_config[\"dockerfile\"] || \"Dockerfile\"\n  end\n\n  def target\n    builder_config[\"target\"]\n  end\n\n  def context\n    builder_config[\"context\"] || \".\"\n  end\n\n  def driver\n    builder_config.fetch(\"driver\", \"docker-container\")\n  end\n\n  def pack_builder\n    builder_config[\"pack\"][\"builder\"] if pack?\n  end\n\n  def pack_buildpacks\n    builder_config[\"pack\"][\"buildpacks\"] if pack?\n  end\n\n  def local_disabled?\n    builder_config[\"local\"] == false\n  end\n\n  def cache_from\n    if cached?\n      case builder_config[\"cache\"][\"type\"]\n      when \"gha\"\n        cache_from_config_for_gha\n      when \"registry\"\n        cache_from_config_for_registry\n      end\n    end\n  end\n\n  def cache_to\n    if cached?\n      case builder_config[\"cache\"][\"type\"]\n      when \"gha\"\n        cache_to_config_for_gha\n      when \"registry\"\n        cache_to_config_for_registry\n      end\n    end\n  end\n\n  def ssh\n    builder_config[\"ssh\"]\n  end\n\n  def provenance\n    builder_config[\"provenance\"]\n  end\n\n  def sbom\n    builder_config[\"sbom\"]\n  end\n\n  def git_clone?\n    Kamal::Git.used? && builder_config[\"context\"].nil?\n  end\n\n  def clone_directory\n    @clone_directory ||= File.join Dir.tmpdir, \"kamal-clones\", [ service, pwd_sha ].compact.join(\"-\")\n  end\n\n  def build_directory\n    @build_directory ||=\n      if git_clone?\n        File.join clone_directory, repo_basename, repo_relative_pwd\n      else\n        \".\"\n      end\n  end\n\n  def docker_driver?\n    driver == \"docker\"\n  end\n\n  private\n    def valid?\n      if docker_driver?\n        raise ArgumentError, \"Invalid builder configuration: the `docker` driver does not not support remote builders\" if remote\n        raise ArgumentError, \"Invalid builder configuration: the `docker` driver does not not support caching\" if cached?\n        raise ArgumentError, \"Invalid builder configuration: the `docker` driver does not not support multiple arches\" if arches.many?\n      end\n\n      if @options[\"cache\"] && @options[\"cache\"][\"type\"]\n        raise ArgumentError, \"Invalid cache type: #{@options[\"cache\"][\"type\"]}\" unless [ \"gha\", \"registry\" ].include?(@options[\"cache\"][\"type\"])\n      end\n    end\n\n    def cache_image\n      builder_config[\"cache\"]&.fetch(\"image\", nil) || \"#{image}-build-cache\"\n    end\n\n    def cache_image_ref\n      [ server, cache_image ].compact.join(\"/\")\n    end\n\n    def cache_options\n      builder_config[\"cache\"]&.fetch(\"options\", nil)\n    end\n\n    def cache_from_config_for_gha\n      individual_options = cache_options&.split(\",\") || []\n      allowed_options = individual_options.select { |option| option =~ /^(url|url_v2|token|scope|timeout)=/ }\n\n      [ \"type=gha\", *allowed_options ].compact.join(\",\")\n    end\n\n    def cache_from_config_for_registry\n      [ \"type=registry\", \"ref=#{cache_image_ref}\" ].compact.join(\",\")\n    end\n\n    def cache_to_config_for_gha\n      [ \"type=gha\", cache_options ].compact.join(\",\")\n    end\n\n    def cache_to_config_for_registry\n      [ \"type=registry\", \"ref=#{cache_image_ref}\", cache_options ].compact.join(\",\")\n    end\n\n    def repo_basename\n      File.basename(Kamal::Git.root)\n    end\n\n    def repo_relative_pwd\n      Dir.pwd.delete_prefix(Kamal::Git.root)\n    end\n\n    def pwd_sha\n      Digest::SHA256.hexdigest(Dir.pwd)[0..12]\n    end\n\n    def default_arch\n      docker_driver? ? [] : [ \"amd64\", \"arm64\" ]\n    end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/docs/accessory.yml",
    "content": "# Accessories\n#\n# Accessories can be booted on a single host, a list of hosts, or on specific roles.\n# The hosts do not need to be defined in the Kamal servers configuration.\n#\n# Accessories are managed separately from the main service — they are not updated\n# when you deploy, and they do not have zero-downtime deployments.\n#\n# Run `kamal accessory boot <accessory>` to boot an accessory.\n# See `kamal accessory --help` for more information.\n\n# Configuring accessories\n#\n# First, define the accessory in the `accessories`:\naccessories:\n  mysql:\n\n    # Service name\n    #\n    # This is used in the service label and defaults to `<service>-<accessory>`,\n    # where `<service>` is the main service name from the root configuration:\n    service: mysql\n\n    # Image\n    #\n    # The Docker image to use.\n    # Prefix it with its server when using root level registry different from Docker Hub.\n    # Define registry directly or via anchors when it differs from root level registry.\n    image: mysql:8.0\n\n    # Registry\n    #\n    # By default accessories use Docker Hub registry.\n    # You can specify different registry per accessory with this option.\n    # Don't prefix image with this registry server.\n    # Use anchors if you need to set the same specific registry for several accessories.\n    #\n    # ```yml\n    # registry:\n    #   <<: *specific-registry\n    # ```\n    #\n    # See kamal docs registry for more information:\n    registry:\n      ...\n\n    # Accessory hosts\n    #\n    # Specify one of `host`, `hosts`, `role`, `roles`, `tag` or `tags`:\n    host: mysql-db1\n    hosts:\n      - mysql-db1\n      - mysql-db2\n    role: mysql\n    roles:\n      - mysql\n    tag: writer\n    tags:\n      - writer\n      - reader\n\n    # Custom command\n    #\n    # You can set a custom command to run in the container if you do not want to use the default:\n    cmd: \"bin/mysqld\"\n\n    # Port mappings\n    #\n    # See [https://docs.docker.com/network/](https://docs.docker.com/network/), and\n    # especially note the warning about the security implications of exposing ports publicly.\n    port: \"127.0.0.1:3306:3306\"\n\n    # Labels\n    labels:\n      app: myapp\n\n    # Options\n    #\n    # These are passed to the Docker run command in the form `--<name> <value>`:\n    options:\n      restart: always\n      cpus: 2\n\n    # Environment variables\n    #\n    # See kamal docs env for more information:\n    env:\n      ...\n\n    # Copying files\n    #\n    # You can specify files to mount into the container.\n    #\n    # They will be uploaded from the local repo to the host and then mounted.\n    # ERB files will be evaluated before being copied.\n    #\n    # You can use the string format: `local:remote` or `local:remote:options`\n    # where the options can be `ro` for read-only or `z`/`Z` for SELinux labels\n    files:\n      - config/my.cnf.erb:/etc/mysql/my.cnf\n      - config/myoptions.cnf:/etc/mysql/myoptions.cnf:ro\n      - config/certs:/etc/mysql/certs:ro,Z\n    #\n    # Or you can use the hash format for custom mode and ownership.\n    #\n    # Note: Setting `owner` requires root access:\n    files:\n      - local: config/secret.key\n        remote: /etc/mysql/secret.key\n        mode: \"0600\"\n        owner: \"mysql:mysql\"\n      - local: config/ca-cert.pem\n        remote: /etc/mysql/certs/ca-cert.pem\n        mode: \"0644\"\n        owner: \"1000:1000\"\n        options: \"Z\"\n\n    # Directories\n    #\n    # You can specify directories to mount into the container. They will be created on the host\n    # before being mounted.\n    #\n    # You can use the string format: `local:remote` or `local:remote:options`\n    # where the options can be `ro` for read-only or `z`/`Z` for SELinux labels\n    directories:\n      - mysql-logs:/var/log/mysql\n      - mysql-data:/var/lib/mysql:z\n    #\n    # Or you can use the hash format for custom mode and ownership.\n    #\n    # Note: Setting `owner` requires root access:\n    directories:\n      - local: mysql-data\n        remote: /var/lib/mysql\n        mode: \"0750\"\n        owner: \"mysql:mysql\"\n      - local: mysql-logs\n        remote: /var/log/mysql\n        mode: \"0755\"\n        options: \"z\"\n\n    # Volumes\n    #\n    # Any other volumes to mount, in addition to the files and directories.\n    # They are not created or copied before mounting:\n    volumes:\n      - /path/to/mysql-logs:/var/log/mysql\n\n    # Network\n    #\n    # The network the accessory will be attached to.\n    #\n    # Defaults to kamal:\n    network: custom\n\n    # Proxy\n    #\n    # You can run your accessory behind the Kamal proxy. See kamal docs proxy for more information\n    proxy:\n      ...\n"
  },
  {
    "path": "lib/kamal/configuration/docs/alias.yml",
    "content": "# Aliases\n#\n# Aliases are shortcuts for Kamal commands.\n#\n# For example, for a Rails app, you might open a console with:\n#\n# ```shell\n# kamal app exec -i --reuse \"bin/rails console\"\n# ```\n#\n# By defining an alias, like this:\naliases:\n  console: app exec -i --reuse \"bin/rails console\"\n# You can now open the console with:\n#\n# ```shell\n# kamal console\n# ```\n\n# Configuring aliases\n#\n# Aliases are defined in the root config under the alias key.\n#\n# Each alias is named and can only contain lowercase letters, numbers, dashes, and underscores:\naliases:\n  uname: app exec -p -q -r web \"uname -a\"\n#\n# Aliases can include a destination with the `-d` flag:\n  staging_deploy: deploy -d staging\n"
  },
  {
    "path": "lib/kamal/configuration/docs/boot.yml",
    "content": "# Booting\n#\n# When deploying to large numbers of hosts, you might prefer not to restart your services on every host at the same time.\n#\n# Kamal’s default is to boot new containers on all hosts in parallel. However, you can control this with the boot configuration.\n\nboot:\n\n  # The number or percentage of hosts to boot at a time.\n  # This can be an integer (e.g., 3) or a percentage string (e.g., 25%).\n  limit: 25%\n\n  # The number of seconds to wait between booting each group of hosts.\n  wait: 10\n\n  # Whether to boot roles in parallel on a host.\n  #\n  # If a host has multiple roles, control whether they are booted in parallel or sequentially on that host.\n  #\n  # Defaults to false.\n  parallel_roles: true\n"
  },
  {
    "path": "lib/kamal/configuration/docs/builder.yml",
    "content": "# Builder\n#\n# The builder configuration controls how the application is built with `docker build`.\n#\n# See https://kamal-deploy.org/docs/configuration/builder-examples/ for more information.\n\n# Builder options\n#\n# Options go under the builder key in the root configuration.\nbuilder:\n\n  # Arch\n  #\n  # The architectures to build for — you can set an array or just a single value.\n  #\n  # Allowed values are `amd64` and `arm64`:\n  arch:\n    - amd64\n\n  # Remote\n  #\n  # The connection string for a remote builder. If supplied, Kamal will use this\n  # for builds that do not match the local architecture of the deployment host.\n  remote: ssh://docker@docker-builder\n\n  # Local\n  #\n  # If set to false, Kamal will always use the remote builder even when building\n  # the local architecture.\n  #\n  # Defaults to true:\n  local: true\n\n  # Buildpack configuration\n  #\n  # The build configuration for using pack to build a Cloud Native Buildpack image.\n  #\n  # For additional buildpack customization options you can create a project descriptor\n  # file(project.toml) that the Pack CLI will automatically use.\n  # See https://buildpacks.io/docs/for-app-developers/how-to/build-inputs/use-project-toml/ for more information.\n  pack:\n    builder: heroku/builder:24\n    buildpacks:\n      - heroku/ruby\n      - heroku/procfile\n\n  # Builder cache\n  #\n  # The type must be either 'gha' or 'registry'.\n  #\n  # The image is only used for registry cache and is not compatible with the Docker driver:\n  cache:\n    type: registry\n    options: mode=max\n    image: kamal-app-build-cache\n\n  # Build context\n  #\n  # If this is not set, then a local Git clone of the repo is used.\n  # This ensures a clean build with no uncommitted changes.\n  #\n  # To use the local checkout instead, you can set the context to `.`, or a path to another directory.\n  context: .\n\n  # Dockerfile\n  #\n  # The Dockerfile to use for building, defaults to `Dockerfile`:\n  dockerfile: Dockerfile.production\n\n  # Build target\n  #\n  # If not set, then the default target is used:\n  target: production\n\n  # Build arguments\n  #\n  # Any additional build arguments, passed to `docker build` with `--build-arg <key>=<value>`:\n  args:\n    ENVIRONMENT: production\n\n  # Referencing build arguments\n  #\n  # ```shell\n  # ARG RUBY_VERSION\n  # FROM ruby:$RUBY_VERSION-slim as base\n  # ```\n\n  # Build secrets\n  #\n  # Values are read from `.kamal/secrets`:\n  secrets:\n    - SECRET1\n    - SECRET2\n\n  # Referencing build secrets\n  #\n  # ```shell\n  # # Copy Gemfiles\n  # COPY Gemfile Gemfile.lock ./\n  #\n  # # Install dependencies, including private repositories via access token\n  # # Then remove bundle cache with exposed GITHUB_TOKEN\n  # RUN --mount=type=secret,id=GITHUB_TOKEN \\\n  #   BUNDLE_GITHUB__COM=x-access-token:$(cat /run/secrets/GITHUB_TOKEN) \\\n  #   bundle install && \\\n  #   rm -rf /usr/local/bundle/cache\n  # ```\n\n  # SSH\n  #\n  # SSH agent socket or keys to expose to the build:\n  ssh: default=$SSH_AUTH_SOCK\n\n  # Driver\n  #\n  # The build driver to use, defaults to `docker-container`:\n  driver: docker\n  #\n  # If you want to use Docker Build Cloud (https://www.docker.com/products/build-cloud/), you can set the driver to:\n  driver: cloud org-name/builder-name\n\n  # Provenance\n  #\n  # It is used to configure provenance attestations for the build result.\n  # The value can also be a boolean to enable or disable provenance attestations.\n  provenance: mode=max\n\n  # SBOM (Software Bill of Materials)\n  #\n  # It is used to configure SBOM generation for the build result.\n  # The value can also be a boolean to enable or disable SBOM generation.\n  sbom: true\n"
  },
  {
    "path": "lib/kamal/configuration/docs/configuration.yml",
    "content": "# Kamal Configuration\n#\n# Configuration is read from the `config/deploy.yml`.\n\n# Destinations\n#\n# When running commands, you can specify a destination with the `-d` flag,\n# e.g., `kamal deploy -d staging`.\n#\n# In this case, the configuration will also be read from `config/deploy.staging.yml`\n# and merged with the base configuration.\n\n# Extensions\n#\n# Kamal will not accept unrecognized keys in the configuration file.\n#\n# However, you might want to declare a configuration block using YAML anchors\n# and aliases to avoid repetition.\n#\n# You can prefix a configuration section with `x-` to indicate that it is an\n# extension. Kamal will ignore the extension and not raise an error.\n\n# The service name\n#\n# This is a required value. It is used as the container name prefix.\nservice: myapp\n\n# The Docker image name\n#\n# The image will be pushed to the configured registry.\nimage: my-image\n\n# Labels\n#\n# Additional labels to add to the container:\nlabels:\n  my-label: my-value\n\n# Volumes\n#\n# Additional volumes to mount into the container:\nvolumes:\n  - /path/on/host:/path/in/container:ro\n\n# Registry\n#\n# The Docker registry configuration, see kamal docs registry:\nregistry:\n  ...\n\n# Servers\n#\n# The servers to deploy to, optionally with custom roles, see kamal docs servers:\nservers:\n  ...\n\n# Environment variables\n#\n# See kamal docs env:\nenv:\n  ...\n\n# Asset path\n#\n# Used for asset bridging across deployments, default to `nil`.\n#\n# If there are changes to CSS or JS files, we may get requests\n# for the old versions on the new container, and vice versa.\n#\n# To avoid 404s, we can specify an asset path.\n# Kamal will replace that path in the container with a mapped\n# volume containing both sets of files.\n# This requires that file names change when the contents change\n# (e.g., by including a hash of the contents in the name).\n#\n# To configure this, set the path to the assets.\n#\n# You can also specify mount options after a colon, such as `ro` for read-only\n# or `z`/`Z` for SELinux labels\nasset_path: /path/to/assets\n\n# Hooks path\n#\n# Path to hooks, defaults to `.kamal/hooks`.\n# See https://kamal-deploy.org/docs/hooks for more information:\nhooks_path: /user_home/kamal/hooks\n\n# Hook output\n#\n# Hook output visibility. Can be set globally or per-hook.\n# CLI flags (`-v`, `-q`) override these settings.\n#\n# - `:quiet` - hook output is hidden\n# - `:verbose` - hook output is shown\n#\n# With no setting, hook output follows CLI verbosity flags.\n#\n# Note: Failed hooks always show output in the error message regardless of setting.\n#\n# Global setting for all hooks:\nhooks_output: :verbose\n\n# Or per-hook settings:\nhooks_output:\n  pre-deploy: :verbose\n  pre-build: :quiet\n\n# Secrets path\n#\n# Path to secrets, defaults to `.kamal/secrets`.\n# Kamal will look for `<secrets_path>-common` and `<secrets_path>` (or `<secrets_path>.<destination>` when using destinations):\nsecrets_path: /user_home/kamal/secrets\n\n# Error pages\n#\n# A directory relative to the app root to find error pages for the proxy to serve.\n# Any files in the format 4xx.html or 5xx.html will be copied to the hosts.\nerror_pages_path: public\n\n# Require destinations\n#\n# Whether deployments require a destination to be specified, defaults to `false`:\nrequire_destination: true\n\n# Primary role\n#\n# This defaults to `web`, but if you have no web role, you can change this:\nprimary_role: workers\n\n# Allowing empty roles\n#\n# Whether roles with no servers are allowed. Defaults to `false`:\nallow_empty_roles: false\n\n# Retain containers\n#\n# How many old containers and images we retain, defaults to 5:\nretain_containers: 3\n\n# Minimum version\n#\n# The minimum version of Kamal required to deploy this configuration, defaults to `nil`:\nminimum_version: 1.3.0\n\n# Readiness delay\n#\n# Seconds to wait for a container to boot after it is running, default 7.\n#\n# This only applies to containers that do not run a proxy or specify a healthcheck:\nreadiness_delay: 4\n\n# Deploy timeout\n#\n# How long to wait for a container to become ready, default 30:\ndeploy_timeout: 10\n\n# Drain timeout\n#\n# How long to wait for a container to drain, default 30:\ndrain_timeout: 10\n\n# Run directory\n#\n# Directory to store kamal runtime files in on the host, default `.kamal`:\nrun_directory: /etc/kamal\n\n# SSH options\n#\n# See kamal docs ssh:\nssh:\n  ...\n\n# Builder options\n#\n# See kamal docs builder:\nbuilder:\n  ...\n\n# Accessories\n#\n# Additional services to run in Docker, see kamal docs accessory:\naccessories:\n  ...\n\n# Proxy\n#\n# Configuration for kamal-proxy, see kamal docs proxy:\nproxy:\n  ...\n\n# SSHKit\n#\n# See kamal docs sshkit:\nsshkit:\n  ...\n\n# Boot options\n#\n# See kamal docs boot:\nboot:\n  ...\n\n# Logging\n#\n# Docker logging configuration, see kamal docs logging:\nlogging:\n  ...\n\n# Aliases\n#\n# Alias configuration, see kamal docs alias:\naliases:\n  ...\n"
  },
  {
    "path": "lib/kamal/configuration/docs/env.yml",
    "content": "# Environment variables\n#\n# Environment variables can be set directly in the Kamal configuration or\n# read from `.kamal/secrets`.\n\n# Reading environment variables from the configuration\n#\n# Environment variables can be set directly in the configuration file.\n#\n# These are passed to the `docker run` command when deploying.\nenv:\n  DATABASE_HOST: mysql-db1\n  DATABASE_PORT: 3306\n\n# Secrets\n#\n# Kamal uses dotenv to automatically load environment variables set in the `.kamal/secrets` file.\n#\n# If you are using destinations, secrets will instead be read from `.kamal/secrets.<DESTINATION>` if\n# it exists.\n#\n# Common secrets across all destinations can be set in `.kamal/secrets-common`.\n#\n# This file can be used to set variables like `KAMAL_REGISTRY_PASSWORD` or database passwords.\n# You can use variable or command substitution in the secrets file.\n#\n# ```shell\n# KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD\n# RAILS_MASTER_KEY=$(cat config/master.key)\n# ```\n#\n# You can also use [secret helpers](../../commands/secrets) for some common password managers.\n#\n# ```shell\n# SECRETS=$(kamal secrets fetch ...)\n#\n# REGISTRY_PASSWORD=$(kamal secrets extract REGISTRY_PASSWORD $SECRETS)\n# DB_PASSWORD=$(kamal secrets extract DB_PASSWORD $SECRETS)\n# ```\n#\n# If you store secrets directly in `.kamal/secrets`, ensure that it is not checked into version control.\n#\n# To pass the secrets, you should list them under the `secret` key. When you do this, the\n# other variables need to be moved under the `clear` key.\n#\n# Unlike clear values, secrets are not passed directly to the container\n# but are stored in an env file on the host:\nenv:\n  clear:\n    DB_USER: app\n  secret:\n    - DB_PASSWORD\n\n# Aliased secrets\n#\n# You can also alias secrets to other secrets using a `:` separator.\n#\n# This is useful when the ENV name is different from the secret name. For example, if you have two\n# places where you need to define the ENV variable `DB_PASSWORD`, but the value is different depending\n# on the context.\n#\n# ```shell\n# SECRETS=$(kamal secrets fetch ...)\n#\n# MAIN_DB_PASSWORD=$(kamal secrets extract MAIN_DB_PASSWORD $SECRETS)\n# SECONDARY_DB_PASSWORD=$(kamal secrets extract SECONDARY_DB_PASSWORD $SECRETS)\n# ```\nenv:\n  secret:\n    - DB_PASSWORD:MAIN_DB_PASSWORD\n  tags:\n    secondary_db:\n      secret:\n        - DB_PASSWORD:SECONDARY_DB_PASSWORD\naccessories:\n  main_db_accessory:\n    env:\n      secret:\n        - DB_PASSWORD:MAIN_DB_PASSWORD\n  secondary_db_accessory:\n    env:\n      secret:\n        - DB_PASSWORD:SECONDARY_DB_PASSWORD\n\n# Tags\n#\n# Tags are used to add extra env variables to specific hosts.\n# See kamal docs servers for how to tag hosts.\n#\n# Tags are only allowed in the top-level env configuration (i.e., not under a role-specific env).\n#\n# The env variables can be specified with secret and clear values as explained above.\nenv:\n  tags:\n    <tag1>:\n      MYSQL_USER: monitoring\n    <tag2>:\n      clear:\n        MYSQL_USER: readonly\n      secret:\n        - MYSQL_PASSWORD\n\n# Example configuration\nenv:\n  clear:\n    MYSQL_USER: app\n  secret:\n    - MYSQL_PASSWORD\n  tags:\n    monitoring:\n      MYSQL_USER: monitoring\n    replica:\n      clear:\n        MYSQL_USER: readonly\n      secret:\n        - READONLY_PASSWORD\n"
  },
  {
    "path": "lib/kamal/configuration/docs/logging.yml",
    "content": "# Custom logging configuration\n#\n# Set these to control the Docker logging driver and options.\n\n# Logging settings\n#\n# These go under the logging key in the configuration file.\n#\n# This can be specified at the root level or for a specific role.\nlogging:\n\n  # Driver\n  #\n  # The logging driver to use, passed to Docker via `--log-driver`:\n  driver: json-file\n\n  # Options\n  #\n  # Any logging options to pass to the driver, passed to Docker via `--log-opt`:\n  options:\n    max-size: 100m\n"
  },
  {
    "path": "lib/kamal/configuration/docs/proxy.yml",
    "content": "# Proxy\n#\n# Kamal uses [kamal-proxy](https://github.com/basecamp/kamal-proxy) to provide\n# gapless deployments. It runs on ports 80 and 443 and forwards requests to the\n# application container.\n#\n# The proxy is configured in the root configuration under `proxy`. These are\n# options that are set when deploying the application, not when booting the proxy.\n#\n# They are application-specific, so they are not shared when multiple applications\n# run on the same proxy.\n#\nproxy:\n\n  # Hosts\n  #\n  # The hosts that will be used to serve the app. The proxy will only route requests\n  # to this host to your app.\n  #\n  # If no hosts are set, then all requests will be forwarded, except for matching\n  # requests for other apps deployed on that server that do have a host set.\n  #\n  # Specify one of `host` or `hosts`.\n  host: foo.example.com\n  hosts:\n    - foo.example.com\n    - bar.example.com\n\n  # App port\n  #\n  # The port the application container is exposed on.\n  #\n  # Defaults to 80:\n  app_port: 3000\n\n  # SSL\n  #\n  # kamal-proxy can provide automatic HTTPS for your application via Let's Encrypt.\n  #\n  # This requires that we are deploying to one server and the host option is set.\n  # The host value must point to the server we are deploying to, and port 443 must be\n  # open for the Let's Encrypt challenge to succeed.\n  #\n  # If you set `ssl` to `true`, `kamal-proxy` will stop forwarding headers to your app,\n  # unless you explicitly set `forward_headers: true`\n  #\n  # Defaults to `false`:\n  ssl: true\n\n  # Custom SSL certificate\n  #\n  # In some cases, using Let's Encrypt for automatic certificate management is not an\n  # option, for example if you are running from more than one host.\n  #\n  # Or you may already have SSL certificates issued by a different Certificate Authority (CA).\n  #\n  # Kamal supports loading custom SSL certificates directly from secrets. You should\n  # pass a hash mapping the `certificate_pem` and `private_key_pem` to the secret names.\n  ssl:\n    certificate_pem: CERTIFICATE_PEM\n    private_key_pem: PRIVATE_KEY_PEM\n  # ### Notes\n  # - If the certificate or key is missing or invalid, deployments will fail.\n  # - Always handle SSL certificates and private keys securely. Avoid hard-coding them in source control.\n\n  # SSL redirect\n  #\n  # By default, kamal-proxy will redirect all HTTP requests to HTTPS when SSL is enabled.\n  # If you prefer that HTTP traffic is passed through to your application (along with\n  # HTTPS traffic), you can disable this redirect by setting `ssl_redirect: false`:\n  ssl_redirect: false\n\n  # Forward headers\n  #\n  # Whether to forward the `X-Forwarded-For` and `X-Forwarded-Proto` headers.\n  #\n  # If you are behind a trusted proxy, you can set this to `true` to forward the headers.\n  #\n  # By default, kamal-proxy will not forward the headers if the `ssl` option is set to `true`, and\n  # will forward them if it is set to `false`.\n  forward_headers: true\n\n  # Response timeout\n  #\n  # How long to wait for requests to complete before timing out, defaults to 30 seconds:\n  response_timeout: 10\n\n  # Path-based routing\n  #\n  # For applications that split their traffic to different services based on the request path,\n  # you can use path-based routing to mount services under different path prefixes.\n  # Usage sample: path_prefix: '/api'\n  #\n  # You can also specify multiple paths in two ways.\n  #\n  # When using path_prefix you can supply multiple routes separated by commas.\n  path_prefix: \"/api,/oauth_callback\"\n  # You can also specify paths as a list of paths, the configuration will be\n  # rolled together into a comma separated string.\n  path_prefixes:\n    - \"/api\"\n    - \"/oauth_callback\"\n  # By default, the path prefix will be stripped from the request before it is forwarded upstream.\n  #\n  # So in the example above, a request to /api/users/123 will be forwarded to web-1 as /users/123.\n  #\n  # To instead forward the request with the original path (including the prefix),\n  # specify --strip-path-prefix=false\n  strip_path_prefix: false\n\n  # Healthcheck\n  #\n  # When deploying, the proxy will by default hit `/up` once every second until we hit\n  # the deploy timeout, with a 5-second timeout for each request.\n  #\n  # Once the app is up, the proxy will stop hitting the healthcheck endpoint.\n  healthcheck:\n    interval: 3\n    path: /health\n    timeout: 3\n\n  # Buffering\n  #\n  # Whether to buffer request and response bodies in the proxy.\n  #\n  # By default, buffering is enabled with a max request body size of 1GB and no limit\n  # for response size.\n  #\n  # You can also set the memory limit for buffering, which defaults to 1MB; anything\n  # larger than that is written to disk.\n  buffering:\n    requests: true\n    responses: true\n    max_request_body: 40_000_000\n    max_response_body: 0\n    memory: 2_000_000\n\n  # Logging\n  #\n  # Configure request logging for the proxy.\n  # You can specify request and response headers to log.\n  # By default, `Cache-Control`, `Last-Modified`, and `User-Agent` request headers are logged:\n  logging:\n    request_headers:\n      - Cache-Control\n      - X-Forwarded-Proto\n    response_headers:\n      - X-Request-ID\n      - X-Request-Start\n\n  # Run configuration\n  #\n  # These options are used when booting the proxy container.\n  #\n  run:\n    http_port: 8080                # HTTP port to use (default 80)\n    https_port: 8443               # HTTPS port to use (default 443)\n    metrics_port: 9090             # Port for Prometheus metrics\n    debug: true                    # Debug logging (default: false)\n    log_max_size: \"30m\"            # Maximum log file size (default: \"10m\")\n    publish: false                 # Publish ports to the host (default: true)\n    bind_ips:                      # List of IPs to bind to when publishing ports\n      - 0.0.0.0\n    registry: registry:4443        # Container registry for the kamal-proxy image\n                                   # (defaults to Docker Hub)\n    repository: myrepo/kamal-proxy # Container repository for the kamal-proxy image\n                                   # (defaults to `basecamp/kamal-proxy`)\n    version: v0.8.0                # Version tag of the kamal-proxy image to use\n    options:                       # Additional options to pass to `docker run`\n      label:\n        - custom.label=kamal-proxy\n      memory: 512m\n      cpus: 0.5\n\n# Enabling/disabling the proxy on roles\n#\n# The proxy is enabled by default on the primary role but can be disabled by\n# setting `proxy: false` in the primary role's configuration.\n#\n# ```yaml\n# servers:\n#   web:\n#     hosts:\n#      - ...\n#     proxy: false\n# ```\n#\n# It is disabled by default on all other roles but can be enabled by setting\n# `proxy: true` or providing a proxy configuration for that role.\n#\n# ```yaml\n# servers:\n#   web:\n#     hosts:\n#      - ...\n#   web2:\n#     hosts:\n#      - ...\n#     proxy: true\n# ```\n"
  },
  {
    "path": "lib/kamal/configuration/docs/registry.yml",
    "content": "# Registry\n#\n# The default registry is Docker Hub, but you can change it using `registry/server`.\n\n# Using a local container registry\n#\n# If the registry server starts with `localhost`, Kamal will start a local Docker registry\n# on that port and push the app image to it.\nregistry:\n  server: localhost:5555\n\n# Using Docker Hub as the container registry\n#\n# By default, Docker Hub creates public repositories. To avoid making your images public,\n# set up a private repository before deploying, or change the default repository privacy\n# settings to private in your [Docker Hub settings](https://hub.docker.com/repository-settings/default-privacy).\n#\n# A reference to a secret (in this case, `KAMAL_REGISTRY_PASSWORD`) will look up the secret\n# in the local environment:\nregistry:\n  username:\n    - <your docker hub username>\n  password:\n    - KAMAL_REGISTRY_PASSWORD\n\n# Using AWS ECR as the container registry\n#\n# You will need to have the AWS CLI installed locally for this to work.\n# 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:\nregistry:\n  server: <your aws account id>.dkr.ecr.<your aws region id>.amazonaws.com\n  username: AWS\n  password: <%= %x(aws ecr get-login-password) %>\n\n# Using GCP Artifact Registry as the container registry\n#\n# To sign into Artifact Registry, you need to\n# [create a service account](https://cloud.google.com/iam/docs/service-accounts-create#creating)\n# and [set up roles and permissions](https://cloud.google.com/artifact-registry/docs/access-control#permissions).\n# Normally, assigning the `roles/artifactregistry.writer` role should be sufficient.\n#\n# Once the service account is ready, you need to generate and download a JSON key and base64 encode it:\n#\n# ```shell\n# base64 -i /path/to/key.json | tr -d \"\\\\n\"\n# ```\n#\n# You'll then need to set the `KAMAL_REGISTRY_PASSWORD` secret to that value.\n#\n# Use the environment variable as the password along with `_json_key_base64` as the username.\n# Here’s the final configuration:\nregistry:\n  server: <your registry region>-docker.pkg.dev\n  username: _json_key_base64\n  password:\n    - KAMAL_REGISTRY_PASSWORD\n\n# Validating the configuration\n#\n# You can validate the configuration by running:\n#\n# ```shell\n# kamal registry login\n# ```\n"
  },
  {
    "path": "lib/kamal/configuration/docs/role.yml",
    "content": "# Roles\n#\n# Roles are used to configure different types of servers in the deployment.\n# The most common use for this is to run web servers and job servers.\n#\n# Kamal expects there to be a `web` role, unless you set a different `primary_role`\n# in the root configuration.\n\n# Role configuration\n#\n# Roles are specified under the servers key:\nservers:\n\n  # Simple role configuration\n  #\n  # This can be a list of hosts if you don't need custom configuration for the role.\n  #\n  # You can set tags on the hosts for custom env variables (see kamal docs env):\n  web:\n    - 172.1.0.1\n    - 172.1.0.2: experiment1\n    - 172.1.0.2: [ experiment1, experiment2 ]\n\n  # Custom role configuration\n  #\n  # When there are other options to set, the list of hosts goes under the `hosts` key.\n  #\n  # By default, only the primary role uses a proxy.\n  #\n  # For other roles, you can set it to `proxy: true` to enable it and inherit the root proxy\n  # configuration or provide a map of options to override the root configuration.\n  #\n  # For the primary role, you can set `proxy: false` to disable the proxy.\n  #\n  # You can also set a custom `cmd` to run in the container and overwrite other settings\n  # from the root configuration.\n  workers:\n    hosts:\n      - 172.1.0.3\n      - 172.1.0.4: experiment1\n    cmd: \"bin/jobs\"\n    options:\n      memory: 2g\n      cpus: 4\n    logging:\n      ...\n    proxy:\n      ...\n    labels:\n      my-label: workers\n    env:\n      ...\n    asset_path: /public\n"
  },
  {
    "path": "lib/kamal/configuration/docs/servers.yml",
    "content": "# Servers\n#\n# Servers are split into different roles, with each role having its own configuration.\n#\n# For simpler deployments, though, where all servers are identical, you can just specify a list of servers.\n# They will be implicitly assigned to the `web` role.\nservers:\n  - 172.0.0.1\n  - 172.0.0.2\n  - 172.0.0.3\n\n# Tagging servers\n#\n# Servers can be tagged, with the tags used to add custom env variables (see kamal docs env).\nservers:\n  - 172.0.0.1\n  - 172.0.0.2: experiments\n  - 172.0.0.3: [ experiments, three ]\n\n# Roles\n#\n# For more complex deployments (e.g., if you are running job hosts), you can specify roles and configure each separately (see kamal docs role):\nservers:\n  web:\n    ...\n  workers:\n    ...\n"
  },
  {
    "path": "lib/kamal/configuration/docs/ssh.yml",
    "content": "# SSH configuration\n#\n# Kamal uses SSH to connect and run commands on your hosts.\n# By default, it will attempt to connect to the root user on port 22.\n#\n# 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:\n#\n# ```shell\n# sudo apt update\n# sudo apt upgrade -y\n# sudo apt install -y docker.io curl git\n# sudo usermod -a -G docker app\n# ```\n\n# SSH options\n#\n# The options are specified under the ssh key in the configuration file.\nssh:\n\n  # The SSH user\n  #\n  # Defaults to `root`:\n  user: app\n\n  # The SSH port\n  #\n  # Defaults to 22:\n  port: \"2222\"\n\n  # Proxy host\n  #\n  # Specified in the form <host> or <user>@<host>:\n  proxy: root@proxy-host\n\n  # Proxy command\n  #\n  # A custom proxy command, required for older versions of SSH:\n  proxy_command: \"ssh -W %h:%p user@proxy\"\n\n  # Log level\n  #\n  # Defaults to `fatal`. Set this to `debug` if you are having SSH connection issues.\n  log_level: debug\n\n  # Keys only\n  #\n  # Set to `true` to use only private keys from the `keys` and `key_data` parameters,\n  # even if ssh-agent offers more identities. This option is intended for\n  # situations where ssh-agent offers many different identities or you\n  # need to overwrite all identities and force a single one.\n  keys_only: false\n\n  # Keys\n  #\n  # An array of file names of private keys to use for public key\n  # and host-based authentication:\n  keys: [ \"~/.ssh/id.pem\" ]\n\n  # Key data\n  #\n  # An array of strings, with each element of the array being a secret name.\n  key_data:\n    - SSH_PRIVATE_KEY\n  # You can also provide raw private key in PEM format, but this is deprecated.\n  key_data:\n    - \"-----BEGIN OPENSSH PRIVATE KEY----- ...\"\n\n  # Config\n  #\n  # Set to true to load the default OpenSSH config files (~/.ssh/config,\n  # /etc/ssh_config), to false ignore config files, or to a file path\n  # (or array of paths) to load specific configuration. Defaults to true.\n  config: [ \"~/.ssh/myconfig\" ]\n"
  },
  {
    "path": "lib/kamal/configuration/docs/sshkit.yml",
    "content": "# SSHKit\n#\n# [SSHKit](https://github.com/capistrano/sshkit) is the SSH toolkit used by Kamal.\n#\n# The default, settings should be sufficient for most use cases, but\n# when connecting to a large number of hosts, you may need to adjust.\n\n# SSHKit options\n#\n# The options are specified under the sshkit key in the configuration file.\nsshkit:\n\n  # Max concurrent starts\n  #\n  # Creating SSH connections concurrently can be an issue when deploying to many servers.\n  # By default, Kamal will limit concurrent connection starts to 30 at a time.\n  max_concurrent_starts: 10\n\n  # Pool idle timeout\n  #\n  # Kamal sets a long idle timeout of 900 seconds on connections to try to avoid\n  # re-connection storms after an idle period, such as building an image or waiting for CI.\n  pool_idle_timeout: 300\n\n  # DNS retry settings\n  #\n  # Some resolvers (mDNSResponder, systemd-resolved, Tailscale) can drop lookups during\n  # bursts of concurrent SSH starts. Kamal will retry DNS failures automatically.\n  #\n  # Number of retries after the initial attempt. Set to 0 to disable.\n  dns_retries: 3\n"
  },
  {
    "path": "lib/kamal/configuration/env/tag.rb",
    "content": "class Kamal::Configuration::Env::Tag\n  attr_reader :name, :config, :secrets\n\n  def initialize(name, config:, secrets:)\n    @name = name\n    @config = config\n    @secrets = secrets\n  end\n\n  def env\n    Kamal::Configuration::Env.new(config: config, secrets: secrets)\n  end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/env.rb",
    "content": "class Kamal::Configuration::Env\n  include Kamal::Configuration::Validation\n\n  attr_reader :context, :clear, :secrets, :secret_keys\n  delegate :argumentize, to: Kamal::Utils\n\n  def initialize(config:, secrets:, context: \"env\")\n    @clear = config.fetch(\"clear\", config.key?(\"secret\") || config.key?(\"tags\") ? {} : config)\n    @secrets = secrets\n    @secret_keys = config.fetch(\"secret\", [])\n    @context = context\n    validate! config, context: context, with: Kamal::Configuration::Validator::Env\n  end\n\n  def clear_args\n    argumentize(\"--env\", clear)\n  end\n\n  def secrets_io\n    Kamal::EnvFile.new(aliased_secrets).to_io\n  end\n\n  def merge(other)\n    self.class.new \\\n      config: { \"clear\" => clear.merge(other.clear), \"secret\" => secret_keys | other.secret_keys },\n      secrets: secrets\n  end\n\n  def to_h\n    clear.merge(aliased_secrets)\n  end\n\n  private\n    def aliased_secrets\n      secret_keys.to_h { |key| extract_alias(key) }.transform_values { |secret_key| secrets[secret_key] }\n    end\n\n    def extract_alias(key)\n      key_name, key_aliased_to = key.split(\":\", 2)\n      [ key_name, key_aliased_to || key_name ]\n    end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/logging.rb",
    "content": "class Kamal::Configuration::Logging\n  delegate :optionize, :argumentize, to: Kamal::Utils\n\n  include Kamal::Configuration::Validation\n\n  attr_reader :logging_config\n\n  def initialize(logging_config:, context: \"logging\")\n    @logging_config = logging_config || {}\n    validate! @logging_config, context: context\n  end\n\n  def driver\n    logging_config[\"driver\"]\n  end\n\n  def options\n    logging_config.fetch(\"options\", {})\n  end\n\n  def merge(other)\n    self.class.new logging_config: logging_config.deep_merge(other.logging_config)\n  end\n\n  def args\n    if driver.present? || options.present?\n      optionize({ \"log-driver\" => driver }.compact) +\n        argumentize(\"--log-opt\", options)\n    else\n      argumentize(\"--log-opt\", { \"max-size\" => \"10m\" })\n    end\n  end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/proxy/boot.rb",
    "content": "class Kamal::Configuration::Proxy::Boot\n  attr_reader :config\n  delegate :argumentize, :optionize, to: Kamal::Utils\n\n  def initialize(config:)\n    @config = config\n  end\n\n  def publish_args(http_port, https_port, bind_ips = nil)\n    ensure_valid_bind_ips(bind_ips)\n\n    (bind_ips || [ nil ]).map do |bind_ip|\n      bind_ip = format_bind_ip(bind_ip)\n      publish_http = [ bind_ip, http_port, Kamal::Configuration::Proxy::Run::DEFAULT_HTTP_PORT ].compact.join(\":\")\n      publish_https = [ bind_ip, https_port, Kamal::Configuration::Proxy::Run::DEFAULT_HTTPS_PORT ].compact.join(\":\")\n\n      argumentize \"--publish\", [ publish_http, publish_https ]\n    end.join(\" \")\n  end\n\n  def logging_args(max_size)\n    argumentize \"--log-opt\", \"max-size=#{max_size}\" if max_size.present?\n  end\n\n  def default_boot_options\n    [\n      *(publish_args(Kamal::Configuration::Proxy::Run::DEFAULT_HTTP_PORT, Kamal::Configuration::Proxy::Run::DEFAULT_HTTPS_PORT, nil)),\n      *(logging_args(Kamal::Configuration::Proxy::Run::DEFAULT_LOG_MAX_SIZE))\n    ]\n  end\n\n  def repository_name\n    \"basecamp\"\n  end\n\n  def image_name\n    \"kamal-proxy\"\n  end\n\n  def image_default\n    \"#{repository_name}/#{image_name}\"\n  end\n\n  def container_name\n    \"kamal-proxy\"\n  end\n\n  def host_directory\n    File.join config.run_directory, \"proxy\"\n  end\n\n  def options_file\n    File.join host_directory, \"options\"\n  end\n\n  def image_file\n    File.join host_directory, \"image\"\n  end\n\n  def image_version_file\n    File.join host_directory, \"image_version\"\n  end\n\n  def run_command_file\n    File.join host_directory, \"run_command\"\n  end\n\n  def apps_directory\n    File.join host_directory, \"apps-config\"\n  end\n\n  def apps_container_directory\n    \"/home/kamal-proxy/.apps-config\"\n  end\n\n  def apps_volume\n    Kamal::Configuration::Volume.new \\\n      host_path: apps_directory,\n      container_path: apps_container_directory\n  end\n\n  def app_directory\n    File.join apps_directory, config.service_and_destination\n  end\n\n  def app_container_directory\n    File.join apps_container_directory, config.service_and_destination\n  end\n\n  def error_pages_directory\n    File.join app_directory, \"error_pages\"\n  end\n\n  def error_pages_container_directory\n    File.join app_container_directory, \"error_pages\"\n  end\n\n  def tls_directory\n    File.join app_directory, \"tls\"\n  end\n\n  def tls_container_directory\n    File.join app_container_directory, \"tls\"\n  end\n\n  private\n    def ensure_valid_bind_ips(bind_ips)\n      bind_ips.present? && bind_ips.each do |ip|\n        next if ip =~ Resolv::IPv4::Regex || ip =~ Resolv::IPv6::Regex\n        raise ArgumentError, \"Invalid publish IP address: #{ip}\"\n      end\n\n      true\n    end\n\n    def format_bind_ip(ip)\n      # Ensure IPv6 address inside square brackets - e.g. [::1]\n      if ip =~ Resolv::IPv6::Regex && ip !~ /\\A\\[.*\\]\\z/\n        \"[#{ip}]\"\n      else\n        ip\n      end\n    end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/proxy/run.rb",
    "content": "class Kamal::Configuration::Proxy::Run\n  MINIMUM_VERSION = \"v0.9.2\"\n  DEFAULT_HTTP_PORT = 80\n  DEFAULT_HTTPS_PORT = 443\n  DEFAULT_LOG_MAX_SIZE = \"10m\"\n\n  attr_reader :config, :run_config\n  delegate :argumentize, :optionize, to: Kamal::Utils\n\n  def initialize(config, run_config:, context: \"proxy/run\")\n    @config = config\n    @run_config = run_config\n    @context = context\n  end\n\n  def debug?\n    run_config.fetch(\"debug\", nil)\n  end\n\n  def publish?\n    run_config.fetch(\"publish\", true)\n  end\n\n  def http_port\n    run_config.fetch(\"http_port\", DEFAULT_HTTP_PORT)\n  end\n\n  def https_port\n    run_config.fetch(\"https_port\", DEFAULT_HTTPS_PORT)\n  end\n\n  def bind_ips\n    run_config.fetch(\"bind_ips\", nil)\n  end\n\n  def publish_args\n    if publish?\n      (bind_ips || [ nil ]).map do |bind_ip|\n        bind_ip = format_bind_ip(bind_ip)\n        publish_http = [ bind_ip, http_port, DEFAULT_HTTP_PORT ].compact.join(\":\")\n        publish_https = [ bind_ip, https_port, DEFAULT_HTTPS_PORT ].compact.join(\":\")\n\n        argumentize \"--publish\", [ publish_http, publish_https ]\n      end.join(\" \")\n    end\n  end\n\n  def log_max_size\n    run_config.fetch(\"log_max_size\", DEFAULT_LOG_MAX_SIZE)\n  end\n\n  def logging_args\n    argumentize \"--log-opt\", \"max-size=#{log_max_size}\" if log_max_size.present?\n  end\n\n  def version\n    run_config.fetch(\"version\", MINIMUM_VERSION)\n  end\n\n  def registry\n    run_config.fetch(\"registry\", nil)\n  end\n\n  def repository\n    run_config.fetch(\"repository\", \"basecamp/kamal-proxy\")\n  end\n\n  def image\n    \"#{[ registry, repository ].compact.join(\"/\")}:#{version}\"\n  end\n\n  def container_name\n    \"kamal-proxy\"\n  end\n\n  def options_args\n    if args = run_config[\"options\"]\n      optionize args\n    end\n  end\n\n  def run_command\n    [ \"kamal-proxy\", \"run\", *optionize(run_command_options) ].join(\" \")\n  end\n\n  def metrics_port\n    run_config[\"metrics_port\"]\n  end\n\n  def run_command_options\n    { debug: debug? || nil, \"metrics-port\": metrics_port }.compact\n  end\n\n  def docker_options_args\n    [\n      *apps_volume_args,\n      *publish_args,\n      *logging_args,\n      *(\"--expose=#{metrics_port}\" if metrics_port.present?),\n      *options_args\n    ].compact\n  end\n\n  def host_directory\n    File.join config.run_directory, \"proxy\"\n  end\n\n  def apps_directory\n    File.join host_directory, \"apps-config\"\n  end\n\n  def apps_container_directory\n    \"/home/kamal-proxy/.apps-config\"\n  end\n\n  def apps_volume\n    Kamal::Configuration::Volume.new \\\n      host_path: apps_directory,\n      container_path: apps_container_directory\n  end\n\n  def apps_volume_args\n    [ apps_volume.docker_args ]\n  end\n\n  def app_directory\n    File.join apps_directory, config.service_and_destination\n  end\n\n  def app_container_directory\n    File.join apps_container_directory, config.service_and_destination\n  end\n\n  private\n    def format_bind_ip(ip)\n      # Ensure IPv6 address inside square brackets - e.g. [::1]\n      if ip =~ Resolv::IPv6::Regex && ip !~ /\\A\\[.*\\]\\z/\n        \"[#{ip}]\"\n      else\n        ip\n      end\n    end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/proxy.rb",
    "content": "class Kamal::Configuration::Proxy\n  include Kamal::Configuration::Validation\n\n  DEFAULT_LOG_REQUEST_HEADERS = [ \"Cache-Control\", \"Last-Modified\", \"User-Agent\" ]\n  CONTAINER_NAME = \"kamal-proxy\"\n\n  delegate :argumentize, :optionize, to: Kamal::Utils\n\n  attr_reader :config, :proxy_config, :role_name, :run, :secrets\n  def initialize(config:, proxy_config:, role_name: nil, secrets:, context: \"proxy\")\n    @config = config\n    @proxy_config = proxy_config\n    @proxy_config = {} if @proxy_config.nil?\n    @role_name = role_name\n    @secrets = secrets\n    validate! @proxy_config, with: Kamal::Configuration::Validator::Proxy, context: context\n    @run = Kamal::Configuration::Proxy::Run.new(config, run_config: @proxy_config[\"run\"], context: \"#{context}/run\") if @proxy_config && @proxy_config[\"run\"].present?\n  end\n\n  def app_port\n    proxy_config.fetch(\"app_port\", 80)\n  end\n\n  def ssl?\n    proxy_config.fetch(\"ssl\", false)\n  end\n\n  def hosts\n    proxy_config[\"hosts\"] || proxy_config[\"host\"]&.split(\",\") || []\n  end\n\n  def custom_ssl_certificate?\n    ssl = proxy_config[\"ssl\"]\n    return false unless ssl.is_a?(Hash)\n    ssl[\"certificate_pem\"].present? && ssl[\"private_key_pem\"].present?\n  end\n\n  def certificate_pem_content\n    ssl = proxy_config[\"ssl\"]\n    return nil unless ssl.is_a?(Hash)\n    secrets[ssl[\"certificate_pem\"]]\n  end\n\n  def private_key_pem_content\n    ssl = proxy_config[\"ssl\"]\n    return nil unless ssl.is_a?(Hash)\n    secrets[ssl[\"private_key_pem\"]]\n  end\n\n  def host_tls_cert\n    tls_path(config.proxy_boot.tls_directory, \"cert.pem\")\n  end\n\n  def host_tls_key\n    tls_path(config.proxy_boot.tls_directory, \"key.pem\")\n  end\n\n  def container_tls_cert\n    tls_path(config.proxy_boot.tls_container_directory, \"cert.pem\")\n  end\n\n  def container_tls_key\n    tls_path(config.proxy_boot.tls_container_directory, \"key.pem\") if custom_ssl_certificate?\n  end\n\n  def path_prefixes\n    proxy_config[\"path_prefixes\"] || proxy_config[\"path_prefix\"]&.split(\",\") || []\n  end\n\n  def deploy_options\n    {\n      host: hosts,\n      tls: ssl? ? true : nil,\n      \"tls-certificate-path\": container_tls_cert,\n      \"tls-private-key-path\": container_tls_key,\n      \"deploy-timeout\": seconds_duration(config.deploy_timeout),\n      \"drain-timeout\": seconds_duration(config.drain_timeout),\n      \"health-check-interval\": seconds_duration(proxy_config.dig(\"healthcheck\", \"interval\")),\n      \"health-check-timeout\": seconds_duration(proxy_config.dig(\"healthcheck\", \"timeout\")),\n      \"health-check-path\": proxy_config.dig(\"healthcheck\", \"path\"),\n      \"target-timeout\": seconds_duration(proxy_config[\"response_timeout\"]),\n      \"buffer-requests\": proxy_config.fetch(\"buffering\", { \"requests\": true }).fetch(\"requests\", true),\n      \"buffer-responses\": proxy_config.fetch(\"buffering\", { \"responses\": true }).fetch(\"responses\", true),\n      \"buffer-memory\": proxy_config.dig(\"buffering\", \"memory\"),\n      \"max-request-body\": proxy_config.dig(\"buffering\", \"max_request_body\"),\n      \"max-response-body\": proxy_config.dig(\"buffering\", \"max_response_body\"),\n      \"path-prefix\": path_prefixes,\n      \"strip-path-prefix\": proxy_config.dig(\"strip_path_prefix\"),\n      \"forward-headers\": proxy_config.dig(\"forward_headers\"),\n      \"tls-redirect\": proxy_config.dig(\"ssl_redirect\"),\n      \"log-request-header\": proxy_config.dig(\"logging\", \"request_headers\") || DEFAULT_LOG_REQUEST_HEADERS,\n      \"log-response-header\": proxy_config.dig(\"logging\", \"response_headers\"),\n      \"error-pages\": error_pages\n    }.compact\n  end\n\n  def deploy_command_args(target:)\n    optionize ({ target: \"#{target}:#{app_port}\" }).merge(deploy_options), with: \"=\"\n  end\n\n  def stop_options(drain_timeout: nil, message: nil)\n    {\n      \"drain-timeout\": seconds_duration(drain_timeout),\n      message: message\n    }.compact\n  end\n\n  def stop_command_args(**options)\n    optionize stop_options(**options), with: \"=\"\n  end\n\n  def merge(other)\n    self.class.new config: config, proxy_config: other.proxy_config.deep_merge(proxy_config), role_name: role_name, secrets: secrets\n  end\n\n  private\n    def tls_path(directory, filename)\n      File.join([ directory, role_name, filename ].compact) if custom_ssl_certificate?\n    end\n\n    def seconds_duration(value)\n      value ? \"#{value}s\" : nil\n    end\n\n    def error_pages\n      File.join config.proxy_boot.error_pages_container_directory, config.version if config.error_pages_path\n    end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/registry.rb",
    "content": "class Kamal::Configuration::Registry\n  include Kamal::Configuration::Validation\n\n  def initialize(config:, secrets:, context: \"registry\")\n    @registry_config = config[\"registry\"] || {}\n    @secrets = secrets\n    validate! registry_config, context: context, with: Kamal::Configuration::Validator::Registry\n  end\n\n  def server\n    registry_config[\"server\"]\n  end\n\n  def username\n    lookup(\"username\")\n  end\n\n  def password\n    lookup(\"password\")\n  end\n\n  def local?\n    server.to_s.match?(\"^localhost[:$]\")\n  end\n\n  def local_port\n    local? ? (server.split(\":\").last.to_i || 80) : nil\n  end\n\n  private\n    attr_reader :registry_config, :secrets\n\n    def lookup(key)\n      if registry_config[key].is_a?(Array)\n        secrets[registry_config[key].first]\n      else\n        registry_config[key]\n      end\n    end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/role.rb",
    "content": "class Kamal::Configuration::Role\n  include Kamal::Configuration::Validation\n\n  delegate :argumentize, :optionize, to: Kamal::Utils\n\n  attr_reader :name, :config, :specialized_env, :specialized_logging, :specialized_proxy\n\n  alias to_s name\n\n  def initialize(name, config:)\n    @name, @config = name.inquiry, config\n    validate! \\\n      role_config,\n      example: validation_yml[\"servers\"][\"workers\"],\n      context: \"servers/#{name}\",\n      with: Kamal::Configuration::Validator::Role\n\n    @specialized_env = Kamal::Configuration::Env.new \\\n      config: specializations.fetch(\"env\", {}),\n      secrets: config.secrets,\n      context: \"servers/#{name}/env\"\n\n    @specialized_logging = Kamal::Configuration::Logging.new \\\n      logging_config: specializations.fetch(\"logging\", {}),\n      context: \"servers/#{name}/logging\"\n\n    initialize_specialized_proxy\n  end\n\n  def primary_host\n    hosts.first\n  end\n\n  def hosts\n    tagged_hosts.keys\n  end\n\n  def env_tags(host)\n    tagged_hosts.fetch(host).collect { |tag| config.env_tag(tag) }.compact\n  end\n\n  def cmd\n    specializations[\"cmd\"]\n  end\n\n  def option_args\n    if args = specializations[\"options\"]\n      optionize args\n    else\n      []\n    end\n  end\n\n  def labels\n    default_labels.merge(custom_labels)\n  end\n\n  def label_args\n    argumentize \"--label\", labels\n  end\n\n  def logging_args\n    logging.args\n  end\n\n  def logging\n    @logging ||= config.logging.merge(specialized_logging)\n  end\n\n  def proxy\n    @proxy ||= specialized_proxy.merge(config.proxy) if running_proxy?\n  end\n\n  def running_proxy?\n    @running_proxy\n  end\n\n  def ssl?\n    running_proxy? && proxy.ssl?\n  end\n\n  def stop_args\n    # When deploying with the proxy, kamal-proxy will drain request before returning so we don't need to wait.\n    timeout = running_proxy? ? nil : config.drain_timeout\n\n    [ *argumentize(\"-t\", timeout) ]\n  end\n\n  def env(host)\n    @envs ||= {}\n    @envs[host] ||= [ config.env, specialized_env, *env_tags(host).map(&:env) ].reduce(:merge)\n  end\n\n  def env_args(host)\n    [ *env(host).clear_args, *argumentize(\"--env-file\", secrets_path) ]\n  end\n\n  def env_directory\n    File.join(config.env_directory, \"roles\")\n  end\n\n  def secrets_io(host)\n    env(host).secrets_io\n  end\n\n  def secrets_path\n    File.join(config.env_directory, \"roles\", \"#{name}.env\")\n  end\n\n  def asset_volume_args\n    asset_volume&.docker_args\n  end\n\n\n  def primary?\n    name == @config.primary_role_name\n  end\n\n\n  def container_name(version = nil)\n    [ container_prefix, version || config.version ].compact.join(\"-\")\n  end\n\n  def container_prefix\n    [ config.service, name, config.destination ].compact.join(\"-\")\n  end\n\n\n  def asset_path\n    asset_path_config&.dig(0)\n  end\n\n  def assets?\n    asset_path.present? && running_proxy?\n  end\n\n  def asset_volume(version = config.version)\n    if assets?\n      Kamal::Configuration::Volume.new \\\n        host_path: asset_volume_directory(version), container_path: asset_path, options: asset_path_options\n    end\n  end\n\n  def asset_path_options\n    asset_path_config&.dig(1)\n  end\n\n  def asset_extracted_directory(version = config.version)\n    File.join config.assets_directory, \"extracted\", [ name, version ].join(\"-\")\n  end\n\n  def asset_volume_directory(version = config.version)\n    File.join config.assets_directory, \"volumes\", [ name, version ].join(\"-\")\n  end\n\n  def ensure_one_host_for_ssl\n    if running_proxy? && proxy.ssl? && hosts.size > 1 && !proxy.custom_ssl_certificate?\n      raise Kamal::ConfigurationError, \"SSL is only supported on a single server unless you provide custom certificates, found #{hosts.size} servers for role #{name}\"\n    end\n  end\n\n  private\n    def initialize_specialized_proxy\n      proxy_specializations = specializations[\"proxy\"]\n\n      if primary?\n        # only false means no proxy for non-primary roles\n        @running_proxy = proxy_specializations != false\n      else\n        # false and nil both mean no proxy for non-primary roles\n        @running_proxy = !!proxy_specializations\n      end\n\n      if running_proxy?\n        proxy_config = proxy_specializations == true || proxy_specializations.nil? ? {} : proxy_specializations\n\n        @specialized_proxy = Kamal::Configuration::Proxy.new \\\n          config: config,\n          proxy_config: proxy_config,\n          secrets: config.secrets,\n          role_name: name,\n          context: \"servers/#{name}/proxy\"\n      end\n    end\n\n    def tagged_hosts\n      {}.tap do |tagged_hosts|\n        extract_hosts_from_config.map do |host_config|\n          if host_config.is_a?(Hash)\n            host, tags = host_config.first\n            tagged_hosts[host] = Array(tags)\n          elsif host_config.is_a?(String)\n            tagged_hosts[host_config] = []\n          end\n        end\n      end\n    end\n\n    def extract_hosts_from_config\n      if config.raw_config.servers.is_a?(Array)\n        config.raw_config.servers\n      else\n        servers = config.raw_config.servers[name]\n        servers.is_a?(Array) ? servers : Array(servers[\"hosts\"])\n      end\n    end\n\n    def default_labels\n      { \"service\" => config.service, \"role\" => name, \"destination\" => config.destination }\n    end\n\n    def specializations\n      @specializations ||= role_config.is_a?(Array) ? {} : role_config\n    end\n\n    def role_config\n      @role_config ||= config.raw_config.servers.is_a?(Array) ? {} : config.raw_config.servers[name]\n    end\n\n    def custom_labels\n      Hash.new.tap do |labels|\n        labels.merge!(config.labels) if config.labels.present?\n        labels.merge!(specializations[\"labels\"]) if specializations[\"labels\"].present?\n      end\n    end\n\n    def asset_path_config\n      raw_path = specializations[\"asset_path\"] || config.asset_path\n      return nil unless raw_path.present?\n\n      parts = raw_path.split(\":\", 2)\n      [ parts[0], parts[1] ]\n    end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/servers.rb",
    "content": "class Kamal::Configuration::Servers\n  include Kamal::Configuration::Validation\n\n  attr_reader :config, :servers_config, :roles\n\n  def initialize(config:)\n    @config = config\n    @servers_config = config.raw_config.servers\n    validate! servers_config, with: Kamal::Configuration::Validator::Servers\n\n    @roles = role_names.map { |role_name| Kamal::Configuration::Role.new role_name, config: config }\n  end\n\n  private\n    def role_names\n      case servers_config\n      when Array\n        [ \"web\" ]\n      when NilClass\n        []\n      else\n        servers_config.keys.sort\n      end\n    end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/ssh.rb",
    "content": "class Kamal::Configuration::Ssh\n  LOGGER = ::Logger.new(STDERR)\n\n  include Kamal::Configuration::Validation\n\n  attr_reader :ssh_config, :secrets\n\n  def initialize(config:)\n    @ssh_config = config.raw_config.ssh || {}\n    @secrets = config.secrets\n    validate! ssh_config\n  end\n\n  def user\n    ssh_config.fetch(\"user\", \"root\")\n  end\n\n  def port\n    ssh_config.fetch(\"port\", 22)\n  end\n\n  def proxy\n    if (proxy = ssh_config[\"proxy\"])\n      Net::SSH::Proxy::Jump.new(proxy.include?(\"@\") ? proxy : \"root@#{proxy}\")\n    elsif (proxy_command = ssh_config[\"proxy_command\"])\n      Net::SSH::Proxy::Command.new(proxy_command)\n    end\n  end\n\n  def keys_only\n    ssh_config[\"keys_only\"]\n  end\n\n  def keys\n    ssh_config[\"keys\"]\n  end\n\n  def key_data\n    key_data = ssh_config[\"key_data\"]\n    return unless key_data\n\n    key_data.map do |k|\n      if secrets.key?(k)\n        secrets[k]\n      else\n        warn \"Inline key_data usage is deprecated and will be removed in Kamal 3. Please store your key_data in a secret.\"\n        k\n      end\n    end\n  end\n\n  def config\n    ssh_config[\"config\"]\n  end\n\n  def options\n    { 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\n  end\n\n  def to_h\n    options.except(:logger).merge(log_level: log_level)\n  end\n\n  private\n    def logger\n      LOGGER.tap { |logger| logger.level = log_level }\n    end\n\n    def log_level\n      ssh_config.fetch(\"log_level\", :fatal)\n    end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/sshkit.rb",
    "content": "class Kamal::Configuration::Sshkit\n  include Kamal::Configuration::Validation\n\n  attr_reader :sshkit_config\n\n  def initialize(config:)\n    @sshkit_config = config.raw_config.sshkit || {}\n    validate! sshkit_config\n  end\n\n  def max_concurrent_starts\n    sshkit_config.fetch(\"max_concurrent_starts\", 30)\n  end\n\n  def pool_idle_timeout\n    sshkit_config.fetch(\"pool_idle_timeout\", 900)\n  end\n\n  def dns_retries\n    Integer(sshkit_config.fetch(\"dns_retries\", 3))\n  end\n\n  def to_h\n    sshkit_config\n  end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/validation.rb",
    "content": "require \"yaml\"\nrequire \"active_support/inflector\"\n\nmodule Kamal::Configuration::Validation\n  extend ActiveSupport::Concern\n\n  class_methods do\n    def validation_doc\n      @validation_doc ||= File.read(File.join(File.dirname(__FILE__), \"docs\", \"#{validation_config_key}.yml\"))\n    end\n\n    def validation_config_key\n      @validation_config_key ||= name.demodulize.underscore\n    end\n  end\n\n  def validate!(config, example: nil, context: nil, with: Kamal::Configuration::Validator)\n    context ||= self.class.validation_config_key\n    example ||= validation_yml[self.class.validation_config_key]\n\n    with.new(config, example: example, context: context).validate!\n  end\n\n  def validation_yml\n    @validation_yml ||= YAML.load(self.class.validation_doc)\n  end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/validator/accessory.rb",
    "content": "class Kamal::Configuration::Validator::Accessory < Kamal::Configuration::Validator\n  def validate!\n    super\n\n    if (config.keys & [ \"host\", \"hosts\", \"role\", \"roles\", \"tag\", \"tags\" ]).size != 1\n      error \"specify one of `host`, `hosts`, `role`, `roles`, `tag` or `tags`\"\n    end\n\n    validate_labels!(config[\"labels\"])\n\n    validate_docker_options!(config[\"options\"])\n  end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/validator/alias.rb",
    "content": "class Kamal::Configuration::Validator::Alias < Kamal::Configuration::Validator\n  def validate!\n    super\n\n    name = context.delete_prefix(\"aliases/\")\n\n    if name !~ /\\A[a-z0-9_-]+\\z/\n      error \"Invalid alias name: '#{name}'. Must only contain lowercase letters, alphanumeric, hyphens and underscores.\"\n    end\n\n    if Kamal::Cli::Main.commands.include?(name)\n      error \"Alias '#{name}' conflicts with a built-in command.\"\n    end\n  end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/validator/builder.rb",
    "content": "class Kamal::Configuration::Validator::Builder < Kamal::Configuration::Validator\n  def validate!\n    super\n\n    if config[\"cache\"] && config[\"cache\"][\"type\"]\n      error \"Invalid cache type: #{config[\"cache\"][\"type\"]}\" unless [ \"gha\", \"registry\" ].include?(config[\"cache\"][\"type\"])\n    end\n\n    error \"Builder arch not set\" unless config[\"arch\"].present?\n\n    error \"buildpacks only support building for one arch\" if config[\"pack\"] && config[\"arch\"].is_a?(Array) && config[\"arch\"].size > 1\n\n    error \"Cannot disable local builds, no remote is set\" if config[\"local\"] == false && config[\"remote\"].blank?\n  end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/validator/configuration.rb",
    "content": "class Kamal::Configuration::Validator::Configuration < Kamal::Configuration::Validator\n  private\n    def allow_extensions?\n      true\n    end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/validator/env.rb",
    "content": "class Kamal::Configuration::Validator::Env < Kamal::Configuration::Validator\n  SPECIAL_KEYS = [ \"clear\", \"secret\", \"tags\" ]\n\n  def validate!\n    if known_keys.any?\n      validate_complex_env!\n    else\n      validate_simple_env!\n    end\n  end\n\n  private\n    def validate_simple_env!\n      validate_hash_of!(config, String)\n    end\n\n    def validate_complex_env!\n      unknown_keys_error unknown_keys if unknown_keys.any?\n\n      with_context(\"clear\") { validate_hash_of!(config[\"clear\"], String) } if config.key?(\"clear\")\n      with_context(\"secret\") { validate_array_of!(config[\"secret\"], String) } if config.key?(\"secret\")\n      validate_tags! if config.key?(\"tags\")\n    end\n\n    def known_keys\n      @known_keys ||= config.keys & SPECIAL_KEYS\n    end\n\n    def unknown_keys\n      @unknown_keys ||= config.keys - SPECIAL_KEYS\n    end\n\n    def validate_tags!\n      if context == \"env\"\n        with_context(\"tags\") do\n          validate_type! config[\"tags\"], Hash\n\n          config[\"tags\"].each do |tag, value|\n            with_context(tag) do\n              validate_type! value, Hash\n\n              Kamal::Configuration::Validator::Env.new(\n                value,\n                example: example[\"tags\"].values[1],\n                context: context\n              ).validate!\n            end\n          end\n        end\n      else\n        error \"tags are only allowed in the root env\"\n      end\n    end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/validator/proxy.rb",
    "content": "class Kamal::Configuration::Validator::Proxy < Kamal::Configuration::Validator\n  def validate!\n    unless config.nil?\n      super\n\n      if config[\"host\"].blank? && config[\"hosts\"].blank? && config[\"ssl\"]\n        error \"Must set a host to enable automatic SSL\"\n      end\n\n      if (config.keys & [ \"host\", \"hosts\" ]).size > 1\n        error \"Specify one of 'host' or 'hosts', not both\"\n      end\n\n      if config[\"ssl\"].is_a?(Hash)\n        if config[\"ssl\"][\"certificate_pem\"].present? && config[\"ssl\"][\"private_key_pem\"].blank?\n          error \"Missing private_key_pem setting (required when certificate_pem is present)\"\n        end\n\n        if config[\"ssl\"][\"private_key_pem\"].present? && config[\"ssl\"][\"certificate_pem\"].blank?\n          error \"Missing certificate_pem setting (required when private_key_pem is present)\"\n        end\n      end\n\n      if run_config = config[\"run\"]\n        if run_config[\"bind_ips\"].present?\n          ensure_valid_bind_ips(config[\"bind_ips\"])\n        end\n\n        if run_config[\"publish\"] == false\n          if run_config[\"bind_ips\"].present? || run_config[\"http_port\"].present? || run_config[\"https_port\"].present?\n            error \"Cannot set http_port, https_port or bind_ips when publish is false\"\n          end\n        end\n      end\n    end\n  end\n\n  private\n    def ensure_valid_bind_ips(bind_ips)\n      bind_ips.present? && bind_ips.each do |ip|\n        next if ip =~ Resolv::IPv4::Regex || ip =~ Resolv::IPv6::Regex\n        error \"Invalid publish IP address: #{ip}\"\n      end\n    end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/validator/registry.rb",
    "content": "class Kamal::Configuration::Validator::Registry < Kamal::Configuration::Validator\n  STRING_OR_ONE_ITEM_ARRAY_KEYS = [ \"username\", \"password\" ]\n\n  def validate!\n    validate_against_example! \\\n      config.except(*STRING_OR_ONE_ITEM_ARRAY_KEYS),\n      example.except(*STRING_OR_ONE_ITEM_ARRAY_KEYS)\n\n    validate_string_or_one_item_array! \"username\"\n    validate_string_or_one_item_array! \"password\"\n  end\n\n  private\n    def validate_string_or_one_item_array!(key)\n      with_context(key) do\n        value = config[key]\n\n        unless config[\"server\"]&.match?(\"^localhost[:$]\")\n          error \"is required\" unless value.present?\n\n          unless value.is_a?(String) || (value.is_a?(Array) && value.size == 1 && value.first.is_a?(String))\n            error \"should be a string or an array with one string (for secret lookup)\"\n          end\n        end\n      end\n    end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/validator/role.rb",
    "content": "class Kamal::Configuration::Validator::Role < Kamal::Configuration::Validator\n  def validate!\n    validate_type! config, Array, Hash\n\n    if config.is_a?(Array)\n      validate_servers!(config)\n    else\n      super\n      validate_labels!(config[\"labels\"])\n      validate_docker_options!(config[\"options\"])\n    end\n  end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/validator/servers.rb",
    "content": "class Kamal::Configuration::Validator::Servers < Kamal::Configuration::Validator\n  def validate!\n    validate_type! config, Array, Hash, NilClass\n\n    validate_servers! config if config.is_a?(Array)\n  end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/validator.rb",
    "content": "class Kamal::Configuration::Validator\n  attr_reader :config, :example, :context\n\n  def initialize(config, example:, context:)\n    @config = config\n    @example = example\n    @context = context\n  end\n\n  def validate!\n    validate_against_example! config, example\n  end\n\n  private\n    def validate_against_example!(validation_config, example)\n      validate_type! validation_config, example.class\n\n      if example.class == Hash\n        check_unknown_keys! validation_config, example\n\n        validation_config.each do |key, value|\n          next if extension?(key)\n          with_context(key) do\n            example_value = example[key]\n\n            if example_value == \"...\"\n              unless key.to_s == \"proxy\" && boolean?(value.class)\n                validate_type! value, *(Array if key == :servers), Hash\n              end\n            elsif key.to_s == \"ssl\"\n                validate_type! value, TrueClass, FalseClass, Hash\n            elsif key.to_s == \"hooks_output\"\n                validate_hooks_output!(value)\n            elsif key == \"hosts\"\n              validate_servers! value\n            elsif example_value.is_a?(Array)\n              if key == \"arch\"\n                validate_array_of_or_type! value, example_value.first.class\n              elsif key.to_s == \"config\"\n                validate_ssh_config!(value)\n              elsif key.to_s == \"files\" || key.to_s == \"directories\"\n                validate_paths!(value)\n              else\n                validate_array_of! value, example_value.first.class\n              end\n            elsif example_value.is_a?(Hash)\n              case key.to_s\n              when \"options\", \"args\"\n                validate_type! value, Hash\n              when \"labels\"\n                validate_hash_of! value, example_value.first[1].class\n              else\n                validate_against_example! value, example_value\n              end\n            else\n              validate_type! value, example_value.class\n            end\n          end\n        end\n      end\n    end\n\n\n    def valid_type?(value, type)\n      value.is_a?(type) ||\n        (type == String && stringish?(value)) ||\n        (boolean?(type) && boolean?(value.class))\n    end\n\n    def type_description(type)\n      if type == Integer || type == Array\n        \"an #{type.name.downcase}\"\n      elsif type == TrueClass || type == FalseClass\n        \"a boolean\"\n      else\n        \"a #{type.name.downcase}\"\n      end\n    end\n\n    def boolean?(type)\n      type == TrueClass || type == FalseClass\n    end\n\n    def stringish?(value)\n      value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Numeric) || value.is_a?(TrueClass) || value.is_a?(FalseClass)\n    end\n\n    def validate_array_of_or_type!(value, type)\n      if value.is_a?(Array)\n        validate_array_of! value, type\n      else\n        validate_type! value, type\n      end\n    rescue Kamal::ConfigurationError\n      type_error(Array, type)\n    end\n\n    def validate_array_of!(array, type)\n      validate_type! array, Array\n\n      array.each_with_index do |value, index|\n        with_context(index) do\n          validate_type! value, type\n        end\n      end\n    end\n\n    def validate_hash_of!(hash, type)\n      validate_type! hash, Hash\n\n      hash.each do |key, value|\n        with_context(key) do\n          validate_type! value, type\n        end\n      end\n    end\n\n    def validate_servers!(servers)\n      validate_type! servers, Array\n\n      servers.each_with_index do |server, index|\n        with_context(index) do\n          validate_type! server, String, Hash\n\n          if server.is_a?(Hash)\n            error \"multiple hosts found\" unless server.size == 1\n            host, tags = server.first\n\n            with_context(host) do\n              validate_type! tags, String, Array\n              validate_array_of! tags, String if tags.is_a?(Array)\n            end\n          end\n        end\n      end\n    end\n\n    def validate_ssh_config!(config)\n      if config.is_a?(Array)\n        validate_array_of! config, String\n      elsif boolean?(config.class) || config.is_a?(String)\n        # Booleans and Strings are allowed\n      else\n        type_error(TrueClass, FalseClass, String, Array)\n      end\n    end\n\n    def validate_paths!(paths)\n      validate_type! paths, Array\n\n      paths.each_with_index do |path, index|\n        with_context(index) do\n          validate_type! path, String, Hash\n\n          if path.is_a?(Hash)\n            %w[local remote mode owner options].each do |key|\n              with_context(key) do\n                validate_type! path[key], String if path.key?(key)\n              end\n            end\n          end\n        end\n      end\n    end\n\n    def validate_hooks_output!(value)\n      # hooks_output can be either a symbol/string (global) or a hash (per-hook)\n      if value.is_a?(Hash)\n        value.each do |hook, level|\n          with_context(hook) do\n            validate_type! level, String, Symbol\n          end\n        end\n      else\n        validate_type! value, String, Symbol\n      end\n    end\n\n    def validate_type!(value, *types)\n      type_error(*types) unless types.any? { |type| valid_type?(value, type) }\n    end\n\n    def error(message)\n      raise Kamal::ConfigurationError, \"#{error_context}#{message}\"\n    end\n\n    def type_error(*expected_types)\n      descriptions = expected_types.map { |type| type_description(type) }.uniq\n      error \"should be #{descriptions.join(\" or \")}\"\n    end\n\n    def unknown_keys_error(unknown_keys)\n      error \"unknown #{\"key\".pluralize(unknown_keys.count)}: #{unknown_keys.join(\", \")}\"\n    end\n\n    def error_context\n      \"#{context}: \" if context.present?\n    end\n\n    def with_context(context)\n      old_context = @context\n      @context = [ @context, context ].select(&:present?).join(\"/\")\n      yield\n    ensure\n      @context = old_context\n    end\n\n    def allow_extensions?\n      false\n    end\n\n    def extension?(key)\n      key.to_s.start_with?(\"x-\")\n    end\n\n    def check_unknown_keys!(config, example)\n      unknown_keys = config.keys - example.keys\n      unknown_keys.reject! { |key| extension?(key) } if allow_extensions?\n      unknown_keys_error unknown_keys if unknown_keys.present?\n    end\n\n    def validate_labels!(labels)\n      return true if labels.blank?\n\n      with_context(\"labels\") do\n        labels.each do |key, _|\n          with_context(key) do\n            error \"invalid label. destination, role, and service are reserved labels\" if %w[destination role service].include?(key)\n          end\n        end\n      end\n    end\n\n    def validate_docker_options!(options)\n      if options\n        error \"Cannot set restart policy in docker options, unless-stopped is required\" if options[\"restart\"]\n      end\n    end\nend\n"
  },
  {
    "path": "lib/kamal/configuration/volume.rb",
    "content": "class Kamal::Configuration::Volume\n  attr_reader :host_path, :container_path, :options\n  delegate :argumentize, to: Kamal::Utils\n\n  def initialize(host_path:, container_path:, options: nil)\n    @host_path = host_path\n    @container_path = container_path\n    @options = options\n  end\n\n  def docker_args\n    argumentize \"--volume\", docker_args_string\n  end\n\n  def docker_args_string\n    volume_string = \"#{host_path_for_docker_volume}:#{container_path}\"\n    volume_string += \":#{options}\" if options.present?\n    volume_string\n  end\n\n  private\n    def host_path_for_docker_volume\n      if Pathname.new(host_path).absolute?\n        host_path\n      else\n        \"$PWD/#{host_path}\"\n      end\n    end\nend\n"
  },
  {
    "path": "lib/kamal/configuration.rb",
    "content": "require \"active_support/ordered_options\"\nrequire \"active_support/core_ext/string/inquiry\"\nrequire \"active_support/core_ext/module/delegation\"\nrequire \"active_support/core_ext/hash/keys\"\nrequire \"erb\"\nrequire \"net/ssh/proxy/jump\"\n\nclass Kamal::Configuration\n  HOOKS_OUTPUT_LEVELS = [ :quiet, :verbose ].freeze\n\n  delegate :service, :labels, :hooks_path, to: :raw_config, allow_nil: true\n  delegate :argumentize, :optionize, to: Kamal::Utils\n\n  attr_reader :destination, :raw_config, :secrets\n  attr_reader :accessories, :aliases, :boot, :builder, :env, :logging, :proxy, :proxy_boot, :servers, :ssh, :sshkit, :registry\n\n  include Validation\n\n  class << self\n    def create_from(config_file:, destination: nil, version: nil)\n      ENV[\"KAMAL_DESTINATION\"] = destination\n\n      raw_config = load_raw_config(config_file: config_file, destination: destination)\n\n      new raw_config, destination: destination, version: version\n    end\n\n    def load_raw_config(config_file:, destination: nil)\n      load_config_files(config_file, *destination_config_file(config_file, destination))\n    end\n\n    private\n      def load_config_files(*files)\n        files.inject({}) { |config, file| config.deep_merge! load_config_file(file) }\n      end\n\n      def load_config_file(file)\n        if file.exist?\n          # Newer Psych doesn't load aliases by default\n          load_method = YAML.respond_to?(:unsafe_load) ? :unsafe_load : :load\n          template = File.read(file)\n          rendered = ERB.new(template, trim_mode: \"-\").result\n          YAML.send(load_method, rendered).symbolize_keys\n        else\n          raise \"Configuration file not found in #{file}\"\n        end\n      end\n\n      def destination_config_file(base_config_file, destination)\n        base_config_file.sub_ext(\".#{destination}.yml\") if destination\n      end\n  end\n\n  def initialize(raw_config, destination: nil, version: nil, validate: true)\n    @raw_config = ActiveSupport::InheritableOptions.new(raw_config)\n    @destination = destination\n    @declared_version = version\n\n    validate! raw_config, example: validation_yml.symbolize_keys, context: \"\", with: Kamal::Configuration::Validator::Configuration\n\n    @secrets = Kamal::Secrets.new(destination: destination, secrets_path: secrets_path)\n\n    # Eager load config to validate it, these are first as they have dependencies later on\n    @servers = Servers.new(config: self)\n    @registry = Registry.new(config: @raw_config, secrets: secrets)\n\n    @accessories = @raw_config.accessories&.keys&.collect { |name| Accessory.new(name, config: self) } || []\n    @aliases = @raw_config.aliases&.keys&.to_h { |name| [ name, Alias.new(name, config: self) ] } || {}\n    @boot = Boot.new(config: self)\n    @builder = Builder.new(config: self)\n    @env = Env.new(config: @raw_config.env || {}, secrets: secrets)\n\n    @logging = Logging.new(logging_config: @raw_config.logging)\n    @proxy = Proxy.new(config: self, proxy_config: @raw_config.proxy, secrets: secrets)\n    @proxy_boot = Proxy::Boot.new(config: self)\n    @ssh = Ssh.new(config: self)\n    @sshkit = Sshkit.new(config: self)\n\n    ensure_destination_if_required\n    ensure_required_keys_present\n    ensure_valid_kamal_version\n    ensure_retain_containers_valid\n    ensure_valid_service_name\n    ensure_no_traefik_reboot_hooks\n    ensure_one_host_for_ssl_roles\n    ensure_unique_hosts_for_ssl_roles\n    ensure_local_registry_remote_builder_has_ssh_url\n    ensure_no_conflicting_proxy_runs\n    ensure_valid_hooks_output!\n  end\n\n  def version=(version)\n    @declared_version = version\n  end\n\n  def version\n    @declared_version.presence || ENV[\"VERSION\"] || git_version\n  end\n\n  def abbreviated_version\n    if version\n      # Don't abbreviate <sha>_uncommitted_<etc>\n      if version.include?(\"_\")\n        version\n      else\n        version[0...7]\n      end\n    end\n  end\n\n  def minimum_version\n    raw_config.minimum_version\n  end\n\n  def service_and_destination\n    [ service, destination ].compact.join(\"-\")\n  end\n\n  def roles\n    servers.roles\n  end\n\n  def role(name)\n    roles.detect { |r| r.name == name.to_s }\n  end\n\n  def accessory(name)\n    accessories.detect { |a| a.name == name.to_s }\n  end\n\n  def all_hosts\n    (roles + accessories).flat_map(&:hosts).uniq\n  end\n\n  def host_roles(host)\n    roles.select { |role| role.hosts.include?(host) }\n  end\n\n  def host_accessories(host)\n    accessories.select { |accessory| accessory.hosts.include?(host) }\n  end\n\n  def app_hosts\n    roles.flat_map(&:hosts).uniq\n  end\n\n  def primary_host\n    primary_role&.primary_host\n  end\n\n  def primary_role_name\n    raw_config.primary_role || \"web\"\n  end\n\n  def primary_role\n    role(primary_role_name)\n  end\n\n  def allow_empty_roles?\n    raw_config.allow_empty_roles\n  end\n\n  def proxy_roles\n    roles.select(&:running_proxy?)\n  end\n\n  def proxy_role_names\n    proxy_roles.flat_map(&:name)\n  end\n\n  def proxy_accessories\n    accessories.select(&:running_proxy?)\n  end\n\n  def proxy_hosts\n    (proxy_roles.flat_map(&:hosts) + proxy_accessories.flat_map(&:hosts)).uniq\n  end\n\n  def image\n    name = raw_config&.image.presence\n    name ||= raw_config&.service if registry.local?\n\n    name\n  end\n\n  def proxy_run(host)\n    # We validate that all the config are identical for a host\n    proxy_runs(host.to_s).first\n  end\n\n  def repository\n    [ registry.server, image ].compact.join(\"/\")\n  end\n\n  def absolute_image\n    \"#{repository}:#{version}\"\n  end\n\n  def latest_image\n    \"#{repository}:#{latest_tag}\"\n  end\n\n  def latest_tag\n    [ \"latest\", *destination ].join(\"-\")\n  end\n\n  def service_with_version\n    \"#{service}-#{version}\"\n  end\n\n  def require_destination?\n    raw_config.require_destination\n  end\n\n  def retain_containers\n    raw_config.retain_containers || 5\n  end\n\n  def volume_args\n    if raw_config.volumes.present?\n      argumentize \"--volume\", raw_config.volumes\n    else\n      []\n    end\n  end\n\n  def logging_args\n    logging.args\n  end\n\n  def readiness_delay\n    raw_config.readiness_delay || 7\n  end\n\n  def deploy_timeout\n    raw_config.deploy_timeout || 30\n  end\n\n  def drain_timeout\n    raw_config.drain_timeout || 30\n  end\n\n  def run_directory\n    \".kamal\"\n  end\n\n  def apps_directory\n    File.join run_directory, \"apps\"\n  end\n\n  def app_directory\n    File.join apps_directory, service_and_destination\n  end\n\n  def env_directory\n    File.join app_directory, \"env\"\n  end\n\n  def assets_directory\n    File.join app_directory, \"assets\"\n  end\n\n  def hooks_path\n    raw_config.hooks_path || \".kamal/hooks\"\n  end\n\n  def secrets_path\n    raw_config.secrets_path || \".kamal/secrets\"\n  end\n\n  def asset_path\n    raw_config.asset_path\n  end\n\n  def error_pages_path\n    raw_config.error_pages_path\n  end\n\n  def env_tags\n    @env_tags ||= if (tags = raw_config.env[\"tags\"])\n      tags.collect { |name, config| Env::Tag.new(name, config: config, secrets: secrets) }\n    else\n      []\n    end\n  end\n\n  def env_tag(name)\n    env_tags.detect { |t| t.name == name.to_s }\n  end\n\n  def hooks_output_for(hook)\n    case raw_config.hooks_output\n    when Symbol, String\n      raw_config.hooks_output.to_sym\n    when Hash\n      raw_config.hooks_output[hook]&.to_sym\n    end\n  end\n\n  def to_h\n    {\n      roles: role_names,\n      hosts: all_hosts,\n      primary_host: primary_host,\n      version: version,\n      repository: repository,\n      absolute_image: absolute_image,\n      service_with_version: service_with_version,\n      volume_args: volume_args,\n      ssh_options: ssh.to_h,\n      sshkit: sshkit.to_h,\n      builder: builder.to_h,\n      accessories: raw_config.accessories,\n      logging: logging_args\n    }.compact\n  end\n\n  private\n    # Will raise ArgumentError if any required config keys are missing\n    def ensure_destination_if_required\n      if require_destination? && destination.nil?\n        raise ArgumentError, \"You must specify a destination\"\n      end\n\n      true\n    end\n\n    def ensure_required_keys_present\n      %i[ service registry ].each do |key|\n        raise Kamal::ConfigurationError, \"Missing required configuration for #{key}\" unless raw_config[key].present?\n      end\n\n      raise Kamal::ConfigurationError, \"Missing required configuration for image\" if image.blank?\n\n      if raw_config.servers.nil?\n        raise Kamal::ConfigurationError, \"No servers or accessories specified\" unless raw_config.accessories.present?\n      else\n        unless role(primary_role_name).present?\n          raise Kamal::ConfigurationError, \"The primary_role #{primary_role_name} isn't defined\"\n        end\n\n        if primary_role.hosts.empty?\n          raise Kamal::ConfigurationError, \"No servers specified for the #{primary_role.name} primary_role\"\n        end\n\n        unless allow_empty_roles?\n          roles.each do |role|\n            if role.hosts.empty?\n              raise Kamal::ConfigurationError, \"No servers specified for the #{role.name} role. You can ignore this with allow_empty_roles: true\"\n            end\n          end\n        end\n      end\n\n      true\n    end\n\n    def ensure_valid_service_name\n      raise Kamal::ConfigurationError, \"Service name can only include alphanumeric characters, hyphens, and underscores\" unless raw_config[:service] =~ /^[a-z0-9_-]+$/i\n\n      true\n    end\n\n    def ensure_valid_kamal_version\n      if minimum_version && Gem::Version.new(minimum_version) > Gem::Version.new(Kamal::VERSION)\n        raise Kamal::ConfigurationError, \"Current version is #{Kamal::VERSION}, minimum required is #{minimum_version}\"\n      end\n\n      true\n    end\n\n    def ensure_retain_containers_valid\n      raise Kamal::ConfigurationError, \"Must retain at least 1 container\" if retain_containers < 1\n\n      true\n    end\n\n    def ensure_no_traefik_reboot_hooks\n      hooks = %w[ pre-traefik-reboot post-traefik-reboot ].select { |hook_file| File.exist?(File.join(hooks_path, hook_file)) }\n\n      if hooks.any?\n        raise Kamal::ConfigurationError, \"Found #{hooks.join(\", \")}, these should be renamed to (pre|post)-proxy-reboot\"\n      end\n\n      true\n    end\n\n    def ensure_one_host_for_ssl_roles\n      roles.each(&:ensure_one_host_for_ssl)\n\n      true\n    end\n\n    def ensure_unique_hosts_for_ssl_roles\n      hosts = roles.select(&:ssl?).flat_map { |role| role.proxy.hosts }\n      duplicates = hosts.tally.filter_map { |host, count| host if count > 1 }\n\n      raise Kamal::ConfigurationError, \"Different roles can't share the same host for SSL: #{duplicates.join(\", \")}\" if duplicates.any?\n\n      true\n    end\n\n    def ensure_local_registry_remote_builder_has_ssh_url\n      if registry.local? && builder.remote?\n        unless URI(builder.remote).scheme == \"ssh\"\n          raise Kamal::ConfigurationError, \"Local registry with remote builder requires an SSH URL (e.g., ssh://user@host)\"\n        end\n      end\n\n      true\n    end\n\n    def ensure_no_conflicting_proxy_runs\n      all_hosts.each do |host|\n        run_configs = proxy_runs(host)\n        if run_configs.uniq.size > 1\n          raise Kamal::ConfigurationError, \"Conflicting proxy run configurations for host #{host}\"\n        end\n      end\n    end\n\n    def proxy_runs(host)\n      (host_roles(host) + host_accessories(host)).map(&:proxy).compact.map(&:run).compact\n    end\n\n    def role_names\n      raw_config.servers.is_a?(Array) ? [ \"web\" ] : raw_config.servers.keys.sort\n    end\n\n    def ensure_valid_hooks_output!\n      case raw_config.hooks_output\n      when Symbol, String\n        validate_hooks_output_level!(raw_config.hooks_output.to_sym)\n      when Hash\n        raw_config.hooks_output.each { |hook, level| validate_hooks_output_level!(level.to_sym, hook) }\n      end\n    end\n\n    def validate_hooks_output_level!(level, hook = nil)\n      return if HOOKS_OUTPUT_LEVELS.include?(level)\n      context = hook ? \" for hook '#{hook}'\" : \"\"\n      raise Kamal::ConfigurationError, \"Invalid hooks_output '#{level}'#{context}, must be one of: #{HOOKS_OUTPUT_LEVELS.join(', ')}\"\n    end\n\n    def git_version\n      @git_version ||=\n        if Kamal::Git.used?\n          if Kamal::Git.uncommitted_changes.present? && !builder.git_clone?\n            uncommitted_suffix = \"_uncommitted_#{SecureRandom.hex(8)}\"\n          end\n          [ Kamal::Git.revision, uncommitted_suffix ].compact.join\n        else\n          raise \"Can't use commit hash as version, no git repository found in #{Dir.pwd}\"\n        end\n    end\nend\n"
  },
  {
    "path": "lib/kamal/docker.rb",
    "content": "require \"tempfile\"\nrequire \"open3\"\n\nmodule Kamal::Docker\n  extend self\n  BUILD_CHECK_TAG = \"kamal-local-build-check\"\n\n  def included_files\n    Tempfile.create do |dockerfile|\n      dockerfile.write(<<~DOCKERFILE)\n        FROM busybox\n        COPY . app\n        WORKDIR app\n        CMD find . -type f | sed \"s|^\\./||\"\n      DOCKERFILE\n      dockerfile.close\n\n      cmd = \"docker buildx build -t=#{BUILD_CHECK_TAG} -f=#{dockerfile.path} .\"\n      system(cmd) || raise(\"failed to build check image\")\n    end\n\n    cmd = \"docker run --rm #{BUILD_CHECK_TAG}\"\n    out, err, status = Open3.capture3(cmd)\n    unless status\n      raise \"failed to run check image:\\n#{err}\"\n    end\n\n    out.lines.map(&:strip)\n  end\nend\n"
  },
  {
    "path": "lib/kamal/env_file.rb",
    "content": "# Encode an env hash as a string where secret values have been looked up and all values escaped for Docker.\nclass Kamal::EnvFile\n  def initialize(env)\n    @env = env\n  end\n\n  def to_s\n    env_file = StringIO.new.tap do |contents|\n      @env.each do |key, value|\n        contents << docker_env_file_line(key, value)\n      end\n    end.string\n\n    # Ensure the file has some contents to avoid the SSHKIT empty file warning\n    env_file.presence || \"\\n\"\n  end\n\n  def to_io\n    StringIO.new(to_s)\n  end\n\n  alias to_str to_s\n\n  private\n    def docker_env_file_line(key, value)\n      \"#{key}=#{escape_docker_env_file_value(value)}\\n\"\n    end\n\n    # Escape a value to make it safe to dump in a docker file.\n    def escape_docker_env_file_value(value)\n      # keep non-ascii(UTF-8) characters as it is\n      value.to_s.scan(/[\\x00-\\x7F]+|[^\\x00-\\x7F]+/).map do |part|\n        part.ascii_only? ? escape_docker_env_file_ascii_value(part) : part\n      end.join\n    end\n\n    def escape_docker_env_file_ascii_value(value)\n      # Doublequotes are treated literally in docker env files\n      # so remove leading and trailing ones and unescape any others\n      value.to_s.dump[1..-2]\n        .gsub(/\\\\\"/, \"\\\"\")\n        .gsub(/\\\\#/, \"#\")\n    end\nend\n"
  },
  {
    "path": "lib/kamal/git.rb",
    "content": "module Kamal::Git\n  extend self\n\n  def used?\n    system(\"git rev-parse\")\n  end\n\n  def user_name\n    `git config user.name`.strip\n  end\n\n  def email\n    `git config user.email`.strip\n  end\n\n  def revision\n    `git rev-parse HEAD`.strip\n  end\n\n  def uncommitted_changes\n    `git status --porcelain`.strip\n  end\n\n  def root\n    `git rev-parse --show-toplevel`.strip\n  end\n\n  # returns an array of relative path names of files with uncommitted changes\n  def uncommitted_files\n    `git ls-files --modified`.lines.map(&:strip)\n  end\n\n  # returns an array of relative path names of untracked files, including gitignored files\n  def untracked_files\n    `git ls-files --others`.lines.map(&:strip)\n  end\nend\n"
  },
  {
    "path": "lib/kamal/secrets/adapters/aws_secrets_manager.rb",
    "content": "class Kamal::Secrets::Adapters::AwsSecretsManager < Kamal::Secrets::Adapters::Base\n  def requires_account?\n    false\n  end\n\n  private\n    def login(_account)\n      nil\n    end\n\n    def fetch_secrets(secrets, from:, account: nil, session:)\n      {}.tap do |results|\n        get_from_secrets_manager(prefixed_secrets(secrets, from: from), account: account).each do |secret|\n          secret_name = secret[\"Name\"]\n          secret_string = JSON.parse(secret[\"SecretString\"])\n\n          secret_string.each do |key, value|\n            results[\"#{secret_name}/#{key}\"] = value\n          end\n        rescue JSON::ParserError\n          results[\"#{secret_name}\"] = secret[\"SecretString\"]\n        end\n      end\n    end\n\n    def get_from_secrets_manager(secrets, account: nil)\n      args = [ \"aws\", \"secretsmanager\", \"batch-get-secret-value\", \"--secret-id-list\" ] + secrets.map(&:shellescape)\n      args += [ \"--profile\", account.shellescape ] if account\n      args += [ \"--output\", \"json\" ]\n      cmd = args.join(\" \")\n\n      `#{cmd}`.tap do |secrets|\n        raise RuntimeError, \"Could not read #{secrets} from AWS Secrets Manager\" unless $?.success?\n\n        secrets = JSON.parse(secrets)\n\n        return secrets[\"SecretValues\"] unless secrets[\"Errors\"].present?\n\n        raise RuntimeError, secrets[\"Errors\"].map { |error| \"#{error['SecretId']}: #{error['Message']}\" }.join(\" \")\n      end\n    end\n\n    def check_dependencies!\n      raise RuntimeError, \"AWS CLI is not installed\" unless cli_installed?\n    end\n\n    def cli_installed?\n      `aws --version 2> /dev/null`\n      $?.success?\n    end\nend\n"
  },
  {
    "path": "lib/kamal/secrets/adapters/base.rb",
    "content": "class Kamal::Secrets::Adapters::Base\n  delegate :optionize, to: Kamal::Utils\n\n  def fetch(secrets, account: nil, from: nil)\n    raise RuntimeError, \"Missing required option '--account'\" if requires_account? && account.blank?\n\n    check_dependencies!\n\n    session = login(account)\n    fetch_secrets(secrets, from: from, account: account, session: session)\n  end\n\n  def requires_account?\n    true\n  end\n\n  private\n    def login(...)\n      raise NotImplementedError\n    end\n\n    def fetch_secrets(...)\n      raise NotImplementedError\n    end\n\n    def check_dependencies!\n      raise NotImplementedError\n    end\n\n    def prefixed_secrets(secrets, from:)\n      secrets.map { |secret| [ from, secret ].compact.join(\"/\") }\n    end\nend\n"
  },
  {
    "path": "lib/kamal/secrets/adapters/bitwarden.rb",
    "content": "class Kamal::Secrets::Adapters::Bitwarden < Kamal::Secrets::Adapters::Base\n  private\n    def login(account)\n      status = run_command(\"status\")\n\n      if status[\"status\"] == \"unauthenticated\"\n        run_command(\"login #{account.shellescape}\", raw: true)\n        status = run_command(\"status\")\n      end\n\n      if status[\"status\"] == \"locked\"\n        session = run_command(\"unlock --raw\", raw: true).presence\n        status = run_command(\"status\", session: session)\n      end\n\n      raise RuntimeError, \"Failed to login to and unlock Bitwarden\" unless status[\"status\"] == \"unlocked\"\n\n      run_command(\"sync\", session: session, raw: true)\n      raise RuntimeError, \"Failed to sync Bitwarden\" unless $?.success?\n\n      session\n    end\n\n    def fetch_secrets(secrets, from:, account:, session:)\n      {}.tap do |results|\n        items_fields(prefixed_secrets(secrets, from: from)).each do |item, fields|\n          item_json = run_command(\"get item #{item.shellescape}\", session: session, raw: true)\n          raise RuntimeError, \"Could not read #{item} from Bitwarden\" unless $?.success?\n          item_json = JSON.parse(item_json)\n          if fields.any?\n            results.merge! fetch_secrets_from_fields(fields, item, item_json)\n          elsif item_json.dig(\"login\", \"password\")\n            results[item] = item_json.dig(\"login\", \"password\")\n          elsif item_json[\"fields\"]&.any?\n            fields = item_json[\"fields\"].pluck(\"name\")\n            results.merge! fetch_secrets_from_fields(fields, item, item_json)\n          else\n            raise RuntimeError, \"Item #{item} is not a login type item and no fields were specified\"\n          end\n        end\n      end\n    end\n\n    def fetch_secrets_from_fields(fields, item, item_json)\n      fields.to_h do |field|\n        item_field = item_json[\"fields\"].find { |f| f[\"name\"] == field }\n        raise RuntimeError, \"Could not find field #{field} in item #{item} in Bitwarden\" unless item_field\n        value = item_field[\"value\"]\n        [ \"#{item}/#{field}\", value ]\n      end\n    end\n\n    def items_fields(secrets)\n      {}.tap do |items|\n        secrets.each do |secret|\n          item, field = secret.split(\"/\")\n          items[item] ||= []\n          items[item] << field\n        end\n      end\n    end\n\n    def signedin?(account)\n      run_command(\"status\")[\"status\"] != \"unauthenticated\"\n    end\n\n    def run_command(command, session: nil, raw: false)\n      full_command = [ *(\"BW_SESSION=#{session.shellescape}\" if session), \"bw\", command ].join(\" \")\n      result = `#{full_command}`.strip\n      raw ? result : JSON.parse(result)\n    end\n\n    def check_dependencies!\n      raise RuntimeError, \"Bitwarden CLI is not installed\" unless cli_installed?\n    end\n\n    def cli_installed?\n      `bw --version 2> /dev/null`\n      $?.success?\n    end\nend\n"
  },
  {
    "path": "lib/kamal/secrets/adapters/bitwarden_secrets_manager.rb",
    "content": "class Kamal::Secrets::Adapters::BitwardenSecretsManager < Kamal::Secrets::Adapters::Base\n  def requires_account?\n    false\n  end\n\n  private\n    LIST_ALL_SELECTOR = \"all\"\n    LIST_ALL_FROM_PROJECT_SUFFIX = \"/all\"\n    LIST_COMMAND = \"secret list\"\n    GET_COMMAND = \"secret get\"\n\n    def fetch_secrets(secrets, from:, account:, session:)\n      raise RuntimeError, \"You must specify what to retrieve from Bitwarden Secrets Manager\" if secrets.length == 0\n\n      secrets = prefixed_secrets(secrets, from: from)\n      command, project = extract_command_and_project(secrets)\n\n      {}.tap do |results|\n        if command.nil?\n          secrets.each do |secret_uuid|\n            item_json = run_command(\"#{GET_COMMAND} #{secret_uuid.shellescape}\")\n            raise RuntimeError, \"Could not read #{secret_uuid} from Bitwarden Secrets Manager\" unless $?.success?\n            item_json = JSON.parse(item_json)\n            results[item_json[\"key\"]] = item_json[\"value\"]\n          end\n        else\n          items_json = run_command(command)\n          raise RuntimeError, \"Could not read secrets from Bitwarden Secrets Manager\" unless $?.success?\n\n          JSON.parse(items_json).each do |item_json|\n            results[item_json[\"key\"]] = item_json[\"value\"]\n          end\n        end\n      end\n    end\n\n    def extract_command_and_project(secrets)\n      if secrets.length == 1\n        if secrets[0] == LIST_ALL_SELECTOR\n          [ LIST_COMMAND, nil ]\n        elsif secrets[0].end_with?(LIST_ALL_FROM_PROJECT_SUFFIX)\n          project = secrets[0].split(LIST_ALL_FROM_PROJECT_SUFFIX).first\n          [ \"#{LIST_COMMAND} #{project.shellescape}\", project ]\n        end\n      end\n    end\n\n    def run_command(command, session: nil)\n      full_command = [ \"bws\", command ].join(\" \")\n      `#{full_command}`\n    end\n\n    def login(account)\n      run_command(\"project list\")\n      raise RuntimeError, \"Could not authenticate to Bitwarden Secrets Manager. Did you set a valid access token?\" unless $?.success?\n    end\n\n    def check_dependencies!\n      raise RuntimeError, \"Bitwarden Secrets Manager CLI is not installed\" unless cli_installed?\n    end\n\n    def cli_installed?\n      `bws --version 2> /dev/null`\n      $?.success?\n    end\nend\n"
  },
  {
    "path": "lib/kamal/secrets/adapters/doppler.rb",
    "content": "class Kamal::Secrets::Adapters::Doppler < Kamal::Secrets::Adapters::Base\n  def requires_account?\n    false\n  end\n\n  private\n    def login(*)\n      unless loggedin?\n        `doppler login -y`\n        raise RuntimeError, \"Failed to login to Doppler\" unless $?.success?\n      end\n    end\n\n    def loggedin?\n      `doppler me --json 2> /dev/null`\n      $?.success?\n    end\n\n    def fetch_secrets(secrets, from:, **)\n      secrets = prefixed_secrets(secrets, from: from)\n      flags = secrets_get_flags(secrets)\n\n      secret_names = secrets.collect { |s| s.split(\"/\").last }\n\n      items = `doppler secrets get #{secret_names.map(&:shellescape).join(\" \")} --json #{flags}`\n      raise RuntimeError, \"Could not read #{secrets} from Doppler\" unless $?.success?\n\n      items = JSON.parse(items)\n\n      items.transform_values { |value| value[\"computed\"] }\n    end\n\n    def secrets_get_flags(secrets)\n      unless service_token_set?\n        project, config, _ = secrets.first.split(\"/\")\n\n        unless project && config\n          raise RuntimeError, \"Missing project or config from '--from=project/config' option\"\n        end\n\n        project_and_config_flags = \"-p #{project.shellescape} -c #{config.shellescape}\"\n      end\n    end\n\n    def service_token_set?\n      ENV[\"DOPPLER_TOKEN\"] && ENV[\"DOPPLER_TOKEN\"][0, 5] == \"dp.st\"\n    end\n\n    def check_dependencies!\n      raise RuntimeError, \"Doppler CLI is not installed\" unless cli_installed?\n    end\n\n    def cli_installed?\n      `doppler --version 2> /dev/null`\n      $?.success?\n    end\nend\n"
  },
  {
    "path": "lib/kamal/secrets/adapters/enpass.rb",
    "content": "##\n# Enpass is different from most password managers, in a way that it's offline and doesn't need an account.\n#\n# Usage\n#\n# Fetch all password from FooBar item\n# `kamal secrets fetch --adapter enpass --from /Users/YOUR_USERNAME/Library/Containers/in.sinew.Enpass-Desktop/Data/Documents/Vaults/primary FooBar`\n#\n# Fetch only DB_PASSWORD from FooBar item\n# `kamal secrets fetch --adapter enpass --from /Users/YOUR_USERNAME/Library/Containers/in.sinew.Enpass-Desktop/Data/Documents/Vaults/primary FooBar/DB_PASSWORD`\nclass Kamal::Secrets::Adapters::Enpass < Kamal::Secrets::Adapters::Base\n  def requires_account?\n    false\n  end\n\n  private\n    def fetch_secrets(secrets, from:, account:, session:)\n      secrets_titles = fetch_secret_titles(secrets)\n\n      result = `enpass-cli -json -vault #{from.shellescape} show #{secrets_titles.map(&:shellescape).join(\" \")}`.strip\n\n      parse_result_and_take_secrets(result, secrets)\n    end\n\n    def check_dependencies!\n      raise RuntimeError, \"Enpass CLI is not installed\" unless cli_installed?\n    end\n\n    def cli_installed?\n      `enpass-cli version 2> /dev/null`\n      $?.success?\n    end\n\n    def login(account)\n      nil\n    end\n\n    def fetch_secret_titles(secrets)\n      secrets.reduce(Set.new) do |secret_titles, secret|\n        # Sometimes secrets contain a '/', when the intent is to fetch a single password for an item. Example: FooBar/DB_PASSWORD\n        # Another case is, when the intent is to fetch all passwords for an item. Example: FooBar (and FooBar may have multiple different passwords)\n        key, separator, value = secret.rpartition(\"/\")\n        if key.empty?\n          secret_titles << value\n        else\n          secret_titles << key\n        end\n      end.to_a\n    end\n\n    def parse_result_and_take_secrets(unparsed_result, secrets)\n      result = JSON.parse(unparsed_result)\n\n      result.reduce({}) do |secrets_with_passwords, item|\n        title = item[\"title\"]\n        label = item[\"label\"]\n        password = item[\"password\"]\n\n        if title && password.present?\n          key = [ title, label ].compact.reject(&:empty?).join(\"/\")\n\n          if secrets.include?(title) || secrets.include?(key)\n            raise RuntimeError, \"#{key} is present more than once\" if secrets_with_passwords[key]\n            secrets_with_passwords[key] = password\n          end\n        end\n\n        secrets_with_passwords\n      end\n    end\nend\n"
  },
  {
    "path": "lib/kamal/secrets/adapters/gcp_secret_manager.rb",
    "content": "class Kamal::Secrets::Adapters::GcpSecretManager < Kamal::Secrets::Adapters::Base\n  private\n    def login(account)\n      # Since only the account option is passed from the cli, we'll use it for both account and service account\n      # impersonation.\n      #\n      # Syntax:\n      # ACCOUNT: USER | USER \"|\" DELEGATION_CHAIN\n      # USER: DEFAULT_USER | EMAIL\n      # DELEGATION_CHAIN: EMAIL | EMAIL \",\" DELEGATION_CHAIN\n      # EMAIL: <The email address of the user or service account, like \"my-user@example.com\" >\n      # DEFAULT_USER: \"default\"\n      #\n      # Some valid examples:\n      # - \"my-user@example.com\" sets the user\n      # - \"my-user@example.com|my-service-user@example.com\" will use my-user and enable service account impersonation as my-service-user\n      # - \"default\" will use the default user and no impersonation\n      # - \"default|my-service-user@example.com\" will use the default user, and enable service account impersonation as my-service-user\n      # - \"default|my-service-user@example.com,another-service-user@example.com\" same as above, but with an impersonation delegation chain\n\n      unless logged_in?\n        `gcloud auth login`\n        raise RuntimeError, \"could not login to gcloud\" unless logged_in?\n      end\n\n      nil\n    end\n\n    def fetch_secrets(secrets, from:, account:, session:)\n      user, service_account = parse_account(account)\n\n      {}.tap do |results|\n        secrets_with_metadata(prefixed_secrets(secrets, from: from)).each do |secret, (project, secret_name, secret_version)|\n          item_name = \"#{project}/#{secret_name}\"\n          results[item_name] = fetch_secret(project, secret_name, secret_version, user, service_account)\n          raise RuntimeError, \"Could not read #{item_name} from Google Secret Manager\" unless $?.success?\n        end\n      end\n    end\n\n    def fetch_secret(project, secret_name, secret_version, user, service_account)\n      secret = run_command(\n        \"secrets versions access #{secret_version.shellescape} --secret=#{secret_name.shellescape}\",\n        project: project,\n        user: user,\n        service_account: service_account\n      )\n      Base64.decode64(secret.dig(\"payload\", \"data\"))\n    end\n\n    # The secret needs to at least contain a secret name, but project name, and secret version can also be specified.\n    #\n    # The string \"default\" can be used to refer to the default project configured for gcloud.\n    #\n    # The version can be either the string \"latest\", or a version number.\n    #\n    # The following formats are valid:\n    #\n    # - The following are all equivalent, and sets project: default, secret name: my-secret, version: latest\n    #   - \"my-secret\"\n    #   - \"default/my-secret\"\n    #   - \"default/my-secret/latest\"\n    #   - \"my-secret/latest\" in combination with --from=default\n    # - \"my-secret/123\" (only in combination with --from=some-project) -> project: some-project, secret name: my-secret, version: 123\n    # - \"some-project/my-secret/123\" -> project: some-project, secret name: my-secret, version: 123\n    def secrets_with_metadata(secrets)\n      {}.tap do |items|\n        secrets.each do |secret|\n          parts = secret.split(\"/\")\n          parts.unshift(\"default\") if parts.length == 1\n          project = parts.shift\n          secret_name = parts.shift\n          secret_version = parts.shift || \"latest\"\n\n          items[secret] = [ project, secret_name, secret_version ]\n        end\n      end\n    end\n\n    def run_command(command, project: \"default\", user: \"default\", service_account: nil)\n      full_command = [ \"gcloud\", command ]\n      full_command << \"--project=#{project.shellescape}\" unless project == \"default\"\n      full_command << \"--account=#{user.shellescape}\" unless user == \"default\"\n      full_command << \"--impersonate-service-account=#{service_account.shellescape}\" if service_account\n      full_command << \"--format=json\"\n      full_command = full_command.join(\" \")\n\n      result = `#{full_command}`.strip\n      JSON.parse(result)\n    end\n\n    def check_dependencies!\n      raise RuntimeError, \"gcloud CLI is not installed\" unless cli_installed?\n    end\n\n    def cli_installed?\n      `gcloud --version 2> /dev/null`\n      $?.success?\n    end\n\n    def logged_in?\n      JSON.parse(`gcloud auth list --format=json`).any?\n    end\n\n    def parse_account(account)\n      account.split(\"|\", 2)\n    end\n\n    def is_user?(candidate)\n      candidate.include?(\"@\")\n    end\nend\n"
  },
  {
    "path": "lib/kamal/secrets/adapters/last_pass.rb",
    "content": "class Kamal::Secrets::Adapters::LastPass < Kamal::Secrets::Adapters::Base\n  private\n    def login(account)\n      unless loggedin?(account)\n        `lpass login #{account.shellescape}`\n        raise RuntimeError, \"Failed to login to LastPass\" unless $?.success?\n      end\n    end\n\n    def loggedin?(account)\n      `lpass status --color never`.strip == \"Logged in as #{account}.\"\n    end\n\n    def fetch_secrets(secrets, from:, account:, session:)\n      secrets = prefixed_secrets(secrets, from: from)\n      items = `lpass show #{secrets.map(&:shellescape).join(\" \")} --json`\n      raise RuntimeError, \"Could not read #{secrets} from LastPass\" unless $?.success?\n\n      items = JSON.parse(items)\n\n      {}.tap do |results|\n        items.each do |item|\n          results[item[\"fullname\"]] = item[\"password\"]\n        end\n\n        if (missing_items = secrets - results.keys).any?\n          raise RuntimeError, \"Could not find #{missing_items.join(\", \")} in LastPass\"\n        end\n      end\n    end\n\n    def check_dependencies!\n      raise RuntimeError, \"LastPass CLI is not installed\" unless cli_installed?\n    end\n\n    def cli_installed?\n      `lpass --version 2> /dev/null`\n      $?.success?\n    end\nend\n"
  },
  {
    "path": "lib/kamal/secrets/adapters/one_password.rb",
    "content": "class Kamal::Secrets::Adapters::OnePassword < Kamal::Secrets::Adapters::Base\n  delegate :optionize, to: Kamal::Utils\n\n  private\n    def login(account)\n      unless loggedin?(account)\n        `op signin #{to_options(account: account, force: true, raw: true)}`.tap do\n          raise RuntimeError, \"Failed to login to 1Password\" unless $?.success?\n        end\n      end\n    end\n\n    def loggedin?(account)\n      `op account get --account #{account.shellescape} 2> /dev/null`\n      $?.success?\n    end\n\n    def fetch_secrets(secrets, from:, account:, session:)\n      if secrets.blank?\n        fetch_all_secrets(from: from, account: account, session: session)\n      else\n        fetch_specified_secrets(secrets, from: from, account: account, session: session)\n      end\n    end\n\n    def fetch_specified_secrets(secrets, from:, account:, session:)\n      {}.tap do |results|\n        vaults_items_fields(prefixed_secrets(secrets, from: from)).map do |vault, items|\n          items.each do |item, fields|\n            fields_json = JSON.parse(op_item_get(vault, item, fields: fields, account: account, session: session))\n            fields_json = [ fields_json ] if fields.one?\n\n            results.merge!(fields_map(fields_json))\n          end\n        end\n      end\n    end\n\n    def fetch_all_secrets(from:, account:, session:)\n      {}.tap do |results|\n        vault_items(from).each do |vault, items|\n          items.each do |item|\n            fields_json = JSON.parse(op_item_get(vault, item, account: account, session: session)).fetch(\"fields\")\n\n            results.merge!(fields_map(fields_json))\n          end\n        end\n      end\n    end\n\n    def to_options(**options)\n      optionize(options.compact).join(\" \")\n    end\n\n    def vaults_items_fields(secrets)\n      {}.tap do |vaults|\n        secrets.each do |secret|\n          secret = secret.delete_prefix(\"op://\")\n          vault, item, *fields = secret.split(\"/\")\n          fields << \"password\" if fields.empty?\n\n          vaults[vault] ||= {}\n          vaults[vault][item] ||= []\n          vaults[vault][item] << fields.join(\".\")\n        end\n      end\n    end\n\n    def vault_items(from)\n      from = from.delete_prefix(\"op://\")\n      vault, item = from.split(\"/\")\n      { vault => [ item ] }\n    end\n\n    def fields_map(fields_json)\n      fields_json.to_h do |field_json|\n        # The reference is in the form `op://vault/item/field[/field]`\n        field = field_json[\"reference\"].delete_prefix(\"op://\").delete_suffix(\"/password\")\n        [ field, field_json[\"value\"] ]\n      end\n    end\n\n    def op_item_get(vault, item, fields: nil, account:, session:)\n      options = { vault: vault, format: \"json\", account: account, session: session.presence }\n\n      if fields.present?\n        labels = fields.map { |field| \"label=#{field}\" }.join(\",\")\n        options.merge!(fields: labels)\n      end\n\n      `op item get #{item.shellescape} #{to_options(**options)}`.tap do\n        raise RuntimeError, \"Could not read #{\"#{fields.join(\", \")} \" if fields.present?}from #{item} in the #{vault} 1Password vault\" unless $?.success?\n      end\n    end\n\n    def check_dependencies!\n      raise RuntimeError, \"1Password CLI is not installed\" unless cli_installed?\n    end\n\n    def cli_installed?\n      `op --version 2> /dev/null`\n      $?.success?\n    end\nend\n"
  },
  {
    "path": "lib/kamal/secrets/adapters/passbolt.rb",
    "content": "class Kamal::Secrets::Adapters::Passbolt < Kamal::Secrets::Adapters::Base\n  def requires_account?\n    false\n  end\n\n  private\n\n    def login(*)\n      `passbolt verify`\n      raise RuntimeError, \"Failed to login to Passbolt\" unless $?.success?\n    end\n\n    def fetch_secrets(secrets, from:, **)\n      secrets = prefixed_secrets(secrets, from: from)\n      raise ArgumentError, \"No secrets given to fetch\" if secrets.empty?\n\n      secret_names = secrets.collect { |s| s.split(\"/\").last }\n      folders = secrets_get_folders(secrets)\n\n      # build filter conditions for each secret with its corresponding folder\n      filter_conditions = []\n      secrets.each do |secret|\n        parts = secret.split(\"/\")\n        secret_name = parts.last\n\n        if parts.size > 1\n          # get the folder path without the secret name\n          folder_path = parts[0..-2]\n\n          # find the most nested folder for this path\n          current_folder = nil\n          current_path = []\n\n          folder_path.each do |folder_name|\n            current_path << folder_name\n            matching_folders = folders.select { |f| get_folder_path(f, folders) == current_path.join(\"/\") }\n            current_folder = matching_folders.first if matching_folders.any?\n          end\n\n          if current_folder\n            filter_conditions << \"(Name == #{secret_name.shellescape.inspect} && FolderParentID == #{current_folder[\"id\"].shellescape.inspect})\"\n          end\n        else\n          # for root level secrets (no folders)\n          filter_conditions << \"Name == #{secret_name.shellescape.inspect}\"\n        end\n      end\n\n      filter_condition = filter_conditions.any? ? \"--filter '#{filter_conditions.join(\" || \")}'\" : \"\"\n      items = `passbolt list resources #{filter_condition} #{folders.map { |item| \"--folder #{item[\"id\"].to_s.shellescape}\" }.join(\" \")} --json`\n      raise RuntimeError, \"Could not read #{secrets} from Passbolt\" unless $?.success?\n      items = JSON.parse(items)\n      found_names = items.map { |item| item[\"name\"] }\n      missing_secrets = secret_names - found_names\n      raise RuntimeError, \"Could not find the following secrets in Passbolt: #{missing_secrets.join(\", \")}\" if missing_secrets.any?\n\n      items.to_h { |item| [ item[\"name\"], item[\"password\"] ] }\n    end\n\n    def secrets_get_folders(secrets)\n      # extract all folder paths (both parent and nested)\n      folder_paths = secrets\n        .select { |s| s.include?(\"/\") }\n        .map { |s| s.split(\"/\")[0..-2] } # get all parts except the secret name\n        .uniq\n\n      return [] if folder_paths.empty?\n\n      all_folders = []\n\n      # first get all top-level folders\n      parent_folders = folder_paths.map(&:first).uniq\n      filter_condition = \"--filter '#{parent_folders.map { |name| \"Name == #{name.shellescape.inspect}\" }.join(\" || \")}'\"\n      fetch_folders = `passbolt list folders #{filter_condition} --json`\n      raise RuntimeError, \"Could not read folders from Passbolt\" unless $?.success?\n\n      parent_folder_items = JSON.parse(fetch_folders)\n      all_folders.concat(parent_folder_items)\n\n      # get nested folders for each parent\n      folder_paths.each do |path|\n        next if path.size <= 1 # skip non-nested folders\n\n        parent = path[0]\n        parent_folder = parent_folder_items.find { |f| f[\"name\"] == parent }\n        next unless parent_folder\n\n        # for each nested level, get the folders using the parent's ID\n        current_parent = parent_folder\n        path[1..-1].each do |folder_name|\n          filter_condition = \"--filter 'Name == #{folder_name.shellescape.inspect} && FolderParentID == #{current_parent[\"id\"].shellescape.inspect}'\"\n          fetch_nested = `passbolt list folders #{filter_condition} --json`\n          next unless $?.success?\n\n          nested_folders = JSON.parse(fetch_nested)\n          break if nested_folders.empty?\n\n          all_folders.concat(nested_folders)\n          current_parent = nested_folders.first\n        end\n      end\n\n      # check if we found all required folders\n      found_paths = all_folders.map { |f| get_folder_path(f, all_folders) }\n      missing_paths = folder_paths.map { |path| path.join(\"/\") } - found_paths\n      raise RuntimeError, \"Could not find the following folders in Passbolt: #{missing_paths.join(\", \")}\" if missing_paths.any?\n\n      all_folders\n    end\n\n    def get_folder_path(folder, all_folders, path = [])\n      path.unshift(folder[\"name\"])\n      return path.join(\"/\") if folder[\"folder_parent_id\"].to_s.empty?\n\n      parent = all_folders.find { |f| f[\"id\"] == folder[\"folder_parent_id\"] }\n      return path.join(\"/\") unless parent\n\n      get_folder_path(parent, all_folders, path)\n    end\n\n    def check_dependencies!\n      raise RuntimeError, \"Passbolt CLI is not installed\" unless cli_installed?\n    end\n\n    def cli_installed?\n      `passbolt --version 2> /dev/null`\n      $?.success?\n    end\nend\n"
  },
  {
    "path": "lib/kamal/secrets/adapters/test.rb",
    "content": "class Kamal::Secrets::Adapters::Test < Kamal::Secrets::Adapters::Base\n  private\n    def login(account)\n      true\n    end\n\n    def fetch_secrets(secrets, from:, account:, session:)\n      prefixed_secrets(secrets, from: from).to_h do |secret|\n        [ secret, secret.gsub(\"LPAREN\", \"(\").gsub(\"RPAREN\", \")\").reverse ]\n      end\n    end\n\n    def check_dependencies!\n      # no op\n    end\nend\n"
  },
  {
    "path": "lib/kamal/secrets/adapters.rb",
    "content": "require \"active_support/core_ext/string/inflections\"\nmodule Kamal::Secrets::Adapters\n  def self.lookup(name)\n    name = \"one_password\" if name.downcase == \"1password\"\n    name = \"last_pass\" if name.downcase == \"lastpass\"\n    name = \"gcp_secret_manager\" if name.downcase == \"gcp\"\n    name = \"bitwarden_secrets_manager\" if name.downcase == \"bitwarden-sm\"\n    adapter_class(name)\n  end\n\n  def self.adapter_class(name)\n    Object.const_get(\"Kamal::Secrets::Adapters::#{name.camelize}\").new\n  rescue NameError => e\n    raise RuntimeError, \"Unknown secrets adapter: #{name}\"\n  end\nend\n"
  },
  {
    "path": "lib/kamal/secrets/dotenv/inline_command_substitution.rb",
    "content": "class Kamal::Secrets::Dotenv::InlineCommandSubstitution\n  # Unlike dotenv, this regex does not match escaped\n  # parentheses when looking for command substitutions.\n  INTERPOLATED_SHELL_COMMAND = /\n    (?<backslash>\\\\)?          # is it escaped with a backslash?\n    \\$                         # literal $\n    (?<cmd>                    # collect command content for eval\n      \\(                       # require opening paren\n      (?:\\\\.|[^()\\\\]|\\g<cmd>)+ # allow any number of non-parens or escaped\n                               # parens (by nesting the <cmd> expression\n                               # recursively)\n      \\)                       # require closing paren\n    )\n  /x\n\n  class << self\n    def install!\n      ::Dotenv::Parser.substitutions.map! { |sub| sub == ::Dotenv::Substitutions::Command ? self : sub }\n    end\n\n    def call(value, env, overwrite: false)\n      # Process interpolated shell commands\n      value.gsub(INTERPOLATED_SHELL_COMMAND) do |*|\n        # Eliminate opening and closing parentheses\n        command = $LAST_MATCH_INFO[:cmd][1..-2]\n\n        if $LAST_MATCH_INFO[:backslash]\n          # Command is escaped, don't replace it.\n          $LAST_MATCH_INFO[0][1..]\n        else\n          command = ::Dotenv::Substitutions::Variable.call(command, env)\n          if command =~ /\\A\\s*kamal\\s*secrets\\s+/\n            # Inline the command\n            inline_secrets_command(command)\n          else\n            # Execute the command and return the value\n            `#{command}`.chomp\n          end\n        end\n      end\n    end\n\n    def inline_secrets_command(command)\n      Kamal::Cli::Main.start(command.shellsplit[1..] + [ \"--inline\" ]).chomp\n    end\n  end\nend\n"
  },
  {
    "path": "lib/kamal/secrets.rb",
    "content": "require \"dotenv\"\n\nclass Kamal::Secrets\n  Kamal::Secrets::Dotenv::InlineCommandSubstitution.install!\n\n  def initialize(destination: nil, secrets_path:)\n    @destination = destination\n    @secrets_path = secrets_path\n    @mutex = Mutex.new\n  end\n\n  def [](key)\n    synchronized_fetch(key)\n  rescue KeyError\n    if secrets_files.present?\n      raise Kamal::ConfigurationError, \"Secret '#{key}' not found in #{secrets_files.join(\", \")}\"\n    else\n      raise Kamal::ConfigurationError, \"Secret '#{key}' not found, no secret files (#{secrets_filenames.join(\", \")}) provided\"\n    end\n  end\n\n  def to_h\n    secrets\n  end\n\n  def secrets_files\n    @secrets_files ||= secrets_filenames.select { |f| File.exist?(f) }\n  end\n\n  def key?(key)\n    synchronized_fetch(key).present?\n  rescue KeyError\n    false\n  end\n\n  private\n    def secrets\n      @secrets ||= secrets_files.inject({}) do |secrets, secrets_file|\n        secrets.merge!(::Dotenv.parse(secrets_file, overwrite: true))\n      end\n    end\n\n    def secrets_filenames\n      [ \"#{@secrets_path}-common\", \"#{@secrets_path}#{(\".#{@destination}\" if @destination)}\" ]\n    end\n\n    def synchronized_fetch(key)\n      # Fetching secrets may ask the user for input, so ensure only one thread does that\n      @mutex.synchronize do\n        secrets.fetch(key)\n      end\n    end\nend\n"
  },
  {
    "path": "lib/kamal/sshkit_with_ext.rb",
    "content": "require \"sshkit\"\nrequire \"sshkit/dsl\"\nrequire \"net/scp\"\nrequire \"active_support/core_ext/hash/deep_merge\"\nrequire \"json\"\nrequire \"resolv\"\nrequire \"concurrent/atomic/semaphore\"\n\nclass SSHKit::Backend::Abstract\n  def capture_with_info(*args, **kwargs)\n    capture(*args, **kwargs, verbosity: Logger::INFO)\n  end\n\n  def capture_with_debug(*args, **kwargs)\n    capture(*args, **kwargs, verbosity: Logger::DEBUG)\n  end\n\n  def capture_with_pretty_json(*args, **kwargs)\n    JSON.pretty_generate(JSON.parse(capture(*args, **kwargs)))\n  end\n\n  def puts_by_host(host, output, type: \"App\", quiet: false)\n    unless quiet\n      puts \"#{type} Host: #{host}\"\n    end\n    puts \"#{output}\\n\\n\"\n  end\n\n  # Our execution pattern is for the CLI execute args lists returned\n  # from commands, but this doesn't support returning execution options\n  # from the command.\n  #\n  # Support this by using kwargs for CLI options and merging with the\n  # args-extracted options.\n  module CommandEnvMerge\n    private\n\n    # Override to merge options returned by commands in the args list with\n    # options passed by the CLI and pass them along as kwargs.\n    def command(args, options)\n      more_options, args = args.partition { |a| a.is_a? Hash }\n      more_options << options\n\n      build_command(args, **more_options.reduce(:deep_merge))\n    end\n\n    # Destructure options to pluck out env for merge\n    def build_command(args, env: nil, **options)\n      # Rely on native Ruby kwargs precedence rather than explicit Hash merges\n      SSHKit::Command.new(*args, **default_command_options, **options, env: env_for(env))\n    end\n\n    def default_command_options\n      { in: pwd_path, host: @host, user: @user, group: @group }\n    end\n\n    def env_for(env)\n      @env.to_h.merge(env.to_h)\n    end\n  end\n  prepend CommandEnvMerge\nend\n\nclass SSHKit::Backend::Netssh::Configuration\n  attr_accessor :max_concurrent_starts, :dns_retries\nend\n\nclass SSHKit::Backend::Netssh\n  module DnsRetriable\n    DNS_RETRY_BASE = 0.1\n    DNS_RETRY_MAX = 2.0\n    DNS_RETRY_JITTER = 0.1\n    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\n\n    def with_dns_retry(hostname, retries: config.dns_retries, base: DNS_RETRY_BASE, max_sleep: DNS_RETRY_MAX, jitter: DNS_RETRY_JITTER)\n      attempts = 0\n      begin\n        attempts += 1\n        yield\n      rescue => error\n        raise unless retryable_dns_error?(error) && attempts <= retries\n\n        delay = dns_retry_sleep(attempts, base: base, jitter: jitter, max_sleep: max_sleep)\n        SSHKit.config.output.warn(\"Retrying DNS for #{hostname} (attempt #{attempts}/#{retries}) in #{format(\"%0.2f\", delay)}s: #{error.message}\")\n        sleep delay\n        retry\n      end\n    end\n\n    private\n      def retryable_dns_error?(error)\n        case error\n        when Resolv::ResolvError, Resolv::ResolvTimeout\n          true\n        when SocketError\n          error.message =~ DNS_ERROR_MESSAGE\n        else\n          error.cause && retryable_dns_error?(error.cause)\n        end\n      end\n\n      def dns_retry_sleep(attempt, base:, jitter:, max_sleep:)\n        sleep_for = [ base * (2 ** (attempt - 1)), max_sleep ].min\n        sleep_for += Kernel.rand * jitter\n        sleep_for\n      end\n  end\n\n  module LimitConcurrentStartsClass\n    attr_reader :start_semaphore\n\n    def configure(&block)\n      super &block\n      # Create this here to avoid lazy creation by multiple threads\n      if config.max_concurrent_starts\n        @start_semaphore = Concurrent::Semaphore.new(config.max_concurrent_starts)\n      end\n    end\n  end\n\n  class << self\n    prepend LimitConcurrentStartsClass\n    prepend DnsRetriable\n  end\n\n  module ConnectSsh\n    private\n      def connect_ssh(...)\n        Net::SSH.start(...)\n      end\n  end\n  include ConnectSsh\n\n  module DnsRetriableConnection\n    private\n      def connect_ssh(...)\n        self.class.with_dns_retry(host.hostname) { super }\n      end\n  end\n  prepend DnsRetriableConnection\n\n  module LimitConcurrentStartsInstance\n    private\n      def with_ssh(&block)\n        host.ssh_options = self.class.config.ssh_options.merge(host.ssh_options || {})\n        self.class.pool.with(\n          method(:connect_ssh),\n          String(host.hostname),\n          host.username,\n          host.netssh_options,\n          &block\n        )\n      end\n\n      def connect_ssh(...)\n        with_concurrency_limit { super }\n      end\n\n      def with_concurrency_limit(&block)\n        if self.class.start_semaphore\n          self.class.start_semaphore.acquire(&block)\n        else\n          yield\n        end\n      end\n  end\n  prepend LimitConcurrentStartsInstance\nend\n\nclass SSHKit::Runner::Parallel\n  # SSHKit joins the threads in sequence and fails on the first error it encounters, which means that we wait threads\n  # before the first failure to complete but not for ones after.\n  #\n  # 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\n  # problem occurs on multiple hosts.\n  module CompleteAll\n    def execute\n      threads = hosts.map do |host|\n        Thread.new(host) do |h|\n          backend(h, &block).run\n        rescue ::StandardError => e\n          e2 = SSHKit::Runner::ExecuteError.new e\n          raise e2, \"Exception while executing #{host.user ? \"as #{host.user}@\" : \"on host \"}#{host}: #{e.message}\"\n        end\n      end\n\n      exceptions = []\n      threads.each do |t|\n        begin\n          t.join\n        rescue SSHKit::Runner::ExecuteError => e\n          exceptions << e\n        end\n      end\n      if exceptions.one?\n        raise exceptions.first\n      elsif exceptions.many?\n        raise exceptions.first, [ \"Exceptions on #{exceptions.count} hosts:\", exceptions.map(&:message) ].join(\"\\n\")\n      end\n    end\n  end\n\n  prepend CompleteAll\nend\n\n# Avoid net-ssh debug, until https://github.com/net-ssh/net-ssh/pull/953 is merged\nmodule NetSshForwardingNoPuts\n  def puts(*)\n  end\nend\n\nNet::SSH::Service::Forward.prepend NetSshForwardingNoPuts\n\nmodule SSHKitDslRoles\n  # Execute on hosts grouped by role.\n  #\n  # Unlike `on()` which deduplicates hosts, this allows the same host to have\n  # multiple concurrent connections when it appears in multiple roles.\n  #\n  # Options:\n  #   hosts: The hosts to run on (required)\n  #   parallel: When true, each role runs in its own thread with separate\n  #             connections. When false, hosts run in parallel but roles on each\n  #             host run sequentially (default: true)\n  #\n  # Example:\n  #   on_roles(roles) do |host, role|\n  #     # deploy role to host\n  #   end\n  def on_roles(roles, hosts:, parallel: true, &block)\n    if parallel\n      threads = roles.filter_map do |role|\n        if (role_hosts = role.hosts & hosts).any?\n          Thread.new do\n            on(role_hosts) { |host| instance_exec(host, role, &block) }\n          rescue StandardError => e\n            raise SSHKit::Runner::ExecuteError.new(e), \"Exception while executing on #{role}: #{e.message}\"\n          end\n        end\n      end\n\n      exceptions = []\n      threads.each do |t|\n        begin\n          t.join\n        rescue SSHKit::Runner::ExecuteError => e\n          exceptions << e\n        end\n      end\n\n      if exceptions.one?\n        raise exceptions.first\n      elsif exceptions.many?\n        raise exceptions.first, [ \"Exceptions on #{exceptions.count} roles:\", exceptions.map(&:message) ].join(\"\\n\")\n      end\n    else\n      # Host-first iteration: hosts run in parallel, roles on each host run sequentially\n      on(hosts) do |host|\n        roles.each do |role|\n          instance_exec(host, role, &block) if role.hosts.include?(host.to_s)\n        end\n      end\n    end\n  end\nend\n\nSSHKit::DSL.prepend SSHKitDslRoles\n"
  },
  {
    "path": "lib/kamal/tags.rb",
    "content": "require \"time\"\n\nclass Kamal::Tags\n  attr_reader :config, :tags\n\n  class << self\n    def from_config(config, **extra)\n      new(**default_tags(config), **extra)\n    end\n\n    def default_tags(config)\n      { recorded_at: Time.now.utc.iso8601,\n        performer: Kamal::Git.email.presence || `whoami`.chomp,\n        destination: config.destination,\n        version: config.version,\n        service_version: service_version(config),\n        service: config.service }\n    end\n\n    def service_version(config)\n      [ config.service, config.abbreviated_version ].compact.join(\"@\")\n    end\n  end\n\n  def initialize(**tags)\n    @tags = tags.compact\n  end\n\n  def env\n    tags.transform_keys { |detail| \"KAMAL_#{detail.upcase}\" }\n  end\n\n  def to_s\n    tags.values.map { |value| \"[#{value}]\" }.join(\" \")\n  end\n\n  def except(*tags)\n    self.class.new(**self.tags.except(*tags))\n  end\nend\n"
  },
  {
    "path": "lib/kamal/utils/sensitive.rb",
    "content": "require \"active_support/core_ext/module/delegation\"\nrequire \"sshkit\"\n\nclass Kamal::Utils::Sensitive\n  # So SSHKit knows to redact these values.\n  include SSHKit::Redaction\n\n  attr_reader :unredacted, :redaction\n  delegate :to_s, to: :unredacted\n  delegate :inspect, to: :redaction\n\n  def initialize(value, redaction: \"[REDACTED]\")\n    @unredacted, @redaction = value, redaction\n  end\n\n  # Sensitive values won't leak into YAML output.\n  def encode_with(coder)\n    coder.represent_scalar nil, redaction\n  end\nend\n"
  },
  {
    "path": "lib/kamal/utils.rb",
    "content": "require \"active_support/core_ext/object/try\"\n\nmodule Kamal::Utils\n  extend self\n\n  DOLLAR_SIGN_WITHOUT_SHELL_EXPANSION_REGEX = /\\$(?!{[^\\}]*\\})/\n\n  # Return a list of escaped shell arguments using the same named argument against the passed attributes (hash or array).\n  def argumentize(argument, attributes, sensitive: false)\n    Array(attributes).flat_map do |key, value|\n      if value.present?\n        attr = \"#{key}=#{escape_shell_value(value)}\"\n        attr = self.sensitive(attr, redaction: \"#{key}=[REDACTED]\") if sensitive\n        [ argument, attr ]\n      elsif value == false\n        [ argument, \"#{key}=false\" ]\n      else\n        [ argument, key ]\n      end\n    end\n  end\n\n  # Returns a list of shell-dashed option arguments. If the value is true, it's treated like a value-less option.\n  def optionize(args, with: nil, escape: true)\n    options = if with\n      flatten_args(args).collect { |(key, value)| value == true ? \"--#{key}\" : \"--#{key}#{with}#{escape ? escape_shell_value(value) : value}\" }\n    else\n      flatten_args(args).collect { |(key, value)| [ \"--#{key}\", value == true ? nil : escape ? escape_shell_value(value) : value ] }\n    end\n\n    options.flatten.compact\n  end\n\n  # Flattens a one-to-many structure into an array of two-element arrays each containing a key-value pair\n  def flatten_args(args)\n    args.flat_map { |key, value| value.try(:map) { |entry| [ key, entry ] } || [ [ key, value ] ] }\n  end\n\n  # Marks sensitive values for redaction in logs and human-visible output.\n  # Pass `redaction:` to change the default `\"[REDACTED]\"` redaction, e.g.\n  # `sensitive \"#{arg}=#{secret}\", redaction: \"#{arg}=xxxx\"\n  def sensitive(...)\n    Kamal::Utils::Sensitive.new(...)\n  end\n\n  def redacted(value)\n    case\n    when value.respond_to?(:redaction)\n      value.redaction\n    when value.respond_to?(:transform_values)\n      value.transform_values { |value| redacted value }\n    when value.respond_to?(:map)\n      value.map { |element| redacted element }\n    else\n      value\n    end\n  end\n\n  # Escape a value to make it safe for shell use.\n  def escape_shell_value(value)\n    value.to_s.scan(/[\\x00-\\x7F]+|[^\\x00-\\x7F]+/) \\\n      .map { |part| part.ascii_only? ? escape_ascii_shell_value(part) : part }\n      .join\n  end\n\n  def escape_ascii_shell_value(value)\n    value.to_s.dump\n      .gsub(/`/, '\\\\\\\\`')\n      .gsub(DOLLAR_SIGN_WITHOUT_SHELL_EXPANSION_REGEX, '\\$')\n  end\n\n  # Apply a list of host or role filters, including wildcard matches\n  def filter_specific_items(filters, items)\n    matches = []\n\n    Array(filters).select do |filter|\n      matches += Array(items).select do |item|\n        # Only allow * for a wildcard\n        # items are roles or hosts\n        File.fnmatch(filter, item.to_s, File::FNM_EXTGLOB)\n      end\n    end\n\n    matches.uniq\n  end\n\n  def stable_sort!(elements, &block)\n    elements.sort_by!.with_index { |element, index| [ block.call(element), index ] }\n  end\n\n  def join_commands(commands)\n    commands.map(&:strip).join(\" \")\n  end\n\n  def docker_arch\n    arch = `docker info --format '{{.Architecture}}'`.strip\n    case arch\n    when /aarch64/\n      \"arm64\"\n    when /x86_64/\n      \"amd64\"\n    else\n      arch\n    end\n  end\n\n  def older_version?(version, other_version)\n    Gem::Version.new(version.delete_prefix(\"v\")) < Gem::Version.new(other_version.delete_prefix(\"v\"))\n  end\nend\n"
  },
  {
    "path": "lib/kamal/version.rb",
    "content": "module Kamal\n  VERSION = \"2.11.0\"\nend\n"
  },
  {
    "path": "lib/kamal.rb",
    "content": "module Kamal\n  class ConfigurationError < StandardError; end\nend\n\nrequire \"active_support\"\nrequire \"zeitwerk\"\nrequire \"yaml\"\nrequire \"tmpdir\"\nrequire \"pathname\"\nrequire \"uri\"\n\nloader = Zeitwerk::Loader.for_gem\nloader.ignore(File.join(__dir__, \"kamal\", \"sshkit_with_ext.rb\"))\nloader.setup\nloader.eager_load_namespace(Kamal::Cli) # We need all commands loaded.\n"
  },
  {
    "path": "test/cli/accessory_test.rb",
    "content": "require_relative \"cli_test_case\"\n\nclass CliAccessoryTest < CliTestCase\n  setup do\n    setup_test_secrets(\"secrets\" => \"MYSQL_ROOT_PASSWORD=secret\")\n  end\n\n  teardown do\n    teardown_test_secrets\n  end\n\n  test \"boot\" do\n    Kamal::Cli::Accessory.any_instance.expects(:directories).with(\"mysql\")\n    Kamal::Cli::Accessory.any_instance.expects(:upload).with(\"mysql\")\n\n    run_command(\"boot\", \"mysql\").tap do |output|\n      assert_match \"docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.3\", output\n      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\n    end\n  end\n\n  test \"boot all\" do\n    Kamal::Cli::Accessory.any_instance.expects(:directories).with(\"mysql\")\n    Kamal::Cli::Accessory.any_instance.expects(:upload).with(\"mysql\")\n    Kamal::Cli::Accessory.any_instance.expects(:directories).with(\"redis\")\n    Kamal::Cli::Accessory.any_instance.expects(:upload).with(\"redis\")\n    Kamal::Cli::Accessory.any_instance.expects(:directories).with(\"busybox\")\n    Kamal::Cli::Accessory.any_instance.expects(:upload).with(\"busybox\")\n\n    run_command(\"boot\", \"all\").tap do |output|\n      assert_match \"docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.3\", output\n      assert_match \"docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.1\", output\n      assert_match \"docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.2\", output\n      assert_match \"docker login other.registry -u [REDACTED] -p [REDACTED] on 1.1.1.3\", output\n      assert_match /docker network create kamal.*on 1.1.1.1/, output\n      assert_match /docker network create kamal.*on 1.1.1.2/, output\n      assert_match /docker network create kamal.*on 1.1.1.3/, output\n      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\n      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\n      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\n      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\n    end\n  end\n\n  test \"upload\" do\n    run_command(\"upload\", \"mysql\").tap do |output|\n      assert_match \"mkdir -p app-mysql/etc/mysql\", output\n      assert_match \"test/fixtures/files/my.cnf to app-mysql/etc/mysql/my.cnf\", output\n      assert_match \"chmod 755 app-mysql/etc/mysql/my.cnf\", output\n    end\n  end\n\n  test \"directories\" do\n    assert_match \"mkdir -p $PWD/app-mysql/data\", run_command(\"directories\", \"mysql\")\n  end\n\n  test \"reboot\" do\n    Kamal::Commands::Registry.any_instance.expects(:login)\n    Kamal::Cli::Accessory.any_instance.expects(:pull_image).with(\"mysql\")\n    Kamal::Cli::Accessory.any_instance.expects(:stop).with(\"mysql\")\n    Kamal::Cli::Accessory.any_instance.expects(:remove_container).with(\"mysql\")\n    Kamal::Cli::Accessory.any_instance.expects(:boot).with(\"mysql\", prepare: false)\n\n    run_command(\"reboot\", \"mysql\")\n  end\n\n  test \"reboot all\" do\n    Kamal::Commands::Registry.any_instance.expects(:login).times(4)\n    Kamal::Cli::Accessory.any_instance.expects(:pull_image).with(\"mysql\")\n    Kamal::Cli::Accessory.any_instance.expects(:stop).with(\"mysql\")\n    Kamal::Cli::Accessory.any_instance.expects(:remove_container).with(\"mysql\")\n    Kamal::Cli::Accessory.any_instance.expects(:boot).with(\"mysql\", prepare: false)\n    Kamal::Cli::Accessory.any_instance.expects(:pull_image).with(\"redis\")\n    Kamal::Cli::Accessory.any_instance.expects(:stop).with(\"redis\")\n    Kamal::Cli::Accessory.any_instance.expects(:remove_container).with(\"redis\")\n    Kamal::Cli::Accessory.any_instance.expects(:boot).with(\"redis\", prepare: false)\n    Kamal::Cli::Accessory.any_instance.expects(:pull_image).with(\"busybox\")\n    Kamal::Cli::Accessory.any_instance.expects(:stop).with(\"busybox\")\n    Kamal::Cli::Accessory.any_instance.expects(:remove_container).with(\"busybox\")\n    Kamal::Cli::Accessory.any_instance.expects(:boot).with(\"busybox\", prepare: false)\n\n    run_command(\"reboot\", \"all\")\n  end\n\n  test \"start\" do\n    assert_match \"docker container start app-mysql\", run_command(\"start\", \"mysql\")\n  end\n\n  test \"stop\" do\n    assert_match \"docker container stop app-mysql\", run_command(\"stop\", \"mysql\")\n  end\n\n  test \"restart\" do\n    Kamal::Cli::Accessory.any_instance.expects(:stop).with(\"mysql\")\n    Kamal::Cli::Accessory.any_instance.expects(:start).with(\"mysql\")\n\n    run_command(\"restart\", \"mysql\")\n  end\n\n  test \"details\" do\n    run_command(\"details\", \"mysql\").tap do |output|\n      assert_match \"docker ps --filter label=service=app-mysql\", output\n      assert_match \"Accessory mysql Host: 1.1.1.3\", output\n    end\n  end\n\n  test \"details with non-existent accessory\" do\n    assert_equal \"No accessory by the name of 'hello' (options: mysql, redis, and busybox)\", stderred { run_command(\"details\", \"hello\") }\n  end\n\n  test \"details with all\" do\n    run_command(\"details\", \"all\").tap do |output|\n      assert_match \"Accessory mysql Host: 1.1.1.3\", output\n      assert_match \"Accessory redis Host: 1.1.1.2\", output\n      assert_match \"docker ps --filter label=service=app-mysql\", output\n      assert_match \"docker ps --filter label=service=app-redis\", output\n    end\n  end\n\n  test \"exec\" do\n    run_command(\"exec\", \"mysql\", \"mysql -v\").tap do |output|\n      assert_match \"docker login private.registry -u [REDACTED] -p [REDACTED]\", output\n      assert_match \"Launching command from new container\", output\n      assert_match \"mysql -v\", output\n    end\n  end\n\n  test \"exec with reuse\" do\n    run_command(\"exec\", \"mysql\", \"--reuse\", \"mysql -v\").tap do |output|\n      assert_match \"Launching command from existing container\", output\n      assert_match \"docker exec app-mysql mysql -v\", output\n    end\n  end\n\n  test \"logs\" do\n    SSHKit::Backend::Abstract.any_instance.stubs(:exec)\n      .with(\"ssh -t root@1.1.1.3 'docker logs app-mysql --timestamps --tail 10 2>&1'\")\n\n    assert_match \"docker logs app-mysql  --tail 100 --timestamps 2>&1\", run_command(\"logs\", \"mysql\")\n  end\n\n  test \"logs with grep\" do\n    SSHKit::Backend::Abstract.any_instance.stubs(:exec)\n      .with(\"ssh -t root@1.1.1.3 'docker logs app-mysql --timestamps 2>&1 | grep \\'hey\\''\")\n\n    assert_match \"docker logs app-mysql --timestamps 2>&1 | grep 'hey'\", run_command(\"logs\", \"mysql\", \"--grep\", \"hey\")\n  end\n\n  test \"logs with grep and grep options\" do\n    SSHKit::Backend::Abstract.any_instance.stubs(:exec)\n      .with(\"ssh -t root@1.1.1.3 'docker logs app-mysql --timestamps 2>&1 | grep \\'hey\\' -C 2'\")\n\n    assert_match \"docker logs app-mysql --timestamps 2>&1 | grep 'hey' -C 2\", run_command(\"logs\", \"mysql\", \"--grep\", \"hey\", \"--grep-options\", \"-C 2\")\n  end\n\n  test \"logs with follow\" do\n    SSHKit::Backend::Abstract.any_instance.stubs(:exec)\n      .with(\"ssh -t root@1.1.1.3 -p 22 'docker logs app-mysql --timestamps --tail 10 --follow 2>&1'\")\n\n    assert_match \"docker logs app-mysql --timestamps --tail 10 --follow 2>&1\", run_command(\"logs\", \"mysql\", \"--follow\")\n  end\n\n  test \"logs with follow and grep\" do\n    SSHKit::Backend::Abstract.any_instance.stubs(:exec)\n      .with(\"ssh -t root@1.1.1.3 -p 22 'docker logs app-mysql --timestamps --tail 10 --follow 2>&1 | grep \\\"hey\\\"'\")\n\n    assert_match \"docker logs app-mysql --timestamps --tail 10 --follow 2>&1 | grep \\\"hey\\\"\", run_command(\"logs\", \"mysql\", \"--follow\", \"--grep\", \"hey\")\n  end\n\n  test \"logs with follow, grep, and grep options\" do\n    SSHKit::Backend::Abstract.any_instance.stubs(:exec)\n      .with(\"ssh -t root@1.1.1.3 -p 22 'docker logs app-mysql --timestamps --tail 10 --follow 2>&1 | grep \\\"hey\\\" -C 2'\")\n\n    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\")\n  end\n\n  test \"remove with confirmation\" do\n    Kamal::Cli::Accessory.any_instance.expects(:stop).with(\"mysql\")\n    Kamal::Cli::Accessory.any_instance.expects(:remove_container).with(\"mysql\")\n    Kamal::Cli::Accessory.any_instance.expects(:remove_image).with(\"mysql\")\n    Kamal::Cli::Accessory.any_instance.expects(:remove_service_directory).with(\"mysql\")\n\n    run_command(\"remove\", \"mysql\", \"-y\")\n  end\n\n  test \"remove all with confirmation\" do\n    Kamal::Cli::Accessory.any_instance.expects(:stop).with(\"mysql\")\n    Kamal::Cli::Accessory.any_instance.expects(:remove_container).with(\"mysql\")\n    Kamal::Cli::Accessory.any_instance.expects(:remove_image).with(\"mysql\")\n    Kamal::Cli::Accessory.any_instance.expects(:remove_service_directory).with(\"mysql\")\n    Kamal::Cli::Accessory.any_instance.expects(:stop).with(\"redis\")\n    Kamal::Cli::Accessory.any_instance.expects(:remove_container).with(\"redis\")\n    Kamal::Cli::Accessory.any_instance.expects(:remove_image).with(\"redis\")\n    Kamal::Cli::Accessory.any_instance.expects(:remove_service_directory).with(\"redis\")\n    Kamal::Cli::Accessory.any_instance.expects(:stop).with(\"busybox\")\n    Kamal::Cli::Accessory.any_instance.expects(:remove_container).with(\"busybox\")\n    Kamal::Cli::Accessory.any_instance.expects(:remove_image).with(\"busybox\")\n    Kamal::Cli::Accessory.any_instance.expects(:remove_service_directory).with(\"busybox\")\n\n    run_command(\"remove\", \"all\", \"-y\")\n  end\n\n  test \"remove_container\" do\n    assert_match \"docker container prune --force --filter label=service=app-mysql\", run_command(\"remove_container\", \"mysql\")\n  end\n\n  test \"pull_image\" do\n    assert_match \"docker image pull private.registry/mysql:5.7\", run_command(\"pull_image\", \"mysql\")\n  end\n\n  test \"remove_image\" do\n    assert_match \"docker image rm --force private.registry/mysql:5.7\", run_command(\"remove_image\", \"mysql\")\n  end\n\n  test \"remove_service_directory\" do\n    assert_match \"rm -rf app-mysql\", run_command(\"remove_service_directory\", \"mysql\")\n  end\n\n  test \"hosts param respected\" do\n    Kamal::Cli::Accessory.any_instance.expects(:directories).with(\"redis\")\n    Kamal::Cli::Accessory.any_instance.expects(:upload).with(\"redis\")\n\n    run_command(\"boot\", \"redis\", \"--hosts\", \"1.1.1.1\").tap do |output|\n      assert_match \"docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.1\", output\n      assert_no_match \"docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.2\", output\n      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\n      assert_no_match /docker run --name app-redis .* on 1.1.1.2/, output\n    end\n  end\n\n  test \"hosts param intersected with configuration\" do\n    Kamal::Cli::Accessory.any_instance.expects(:directories).with(\"redis\")\n    Kamal::Cli::Accessory.any_instance.expects(:upload).with(\"redis\")\n\n    run_command(\"boot\", \"redis\", \"--hosts\", \"1.1.1.1,1.1.1.3\").tap do |output|\n      assert_match \"docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.1\", output\n      assert_no_match \"docker login private.registry -u [REDACTED] -p [REDACTED] on 1.1.1.3\", output\n      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\n      assert_no_match /docker run --name app-redis .* on 1.1.1.3/, output\n    end\n  end\n\n  test \"upgrade\" do\n    run_command(\"upgrade\", \"-y\", \"all\").tap do |output|\n      assert_match \"Upgrading all accessories on 1.1.1.3,1.1.1.1,1.1.1.2...\", output\n      assert_match \"docker network create kamal on 1.1.1.3\", output\n      assert_match \"docker container stop app-mysql on 1.1.1.3\", output\n      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\n      assert_match \"Upgraded all accessories on 1.1.1.3,1.1.1.1,1.1.1.2...\", output\n    end\n  end\n\n  test \"upgrade rolling\" do\n    run_command(\"upgrade\", \"--rolling\", \"-y\", \"all\").tap do |output|\n      assert_match \"Upgrading all accessories on 1.1.1.3...\", output\n      assert_match \"docker network create kamal on 1.1.1.3\", output\n      assert_match \"docker container stop app-mysql on 1.1.1.3\", output\n      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\n      assert_match \"Upgraded all accessories on 1.1.1.3\", output\n    end\n  end\n\n  test \"boot with web role filter\" do\n    run_command(\"boot\", \"redis\", \"-r\", \"web\").tap do |output|\n      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\n      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\n    end\n  end\n\n  test \"boot with workers role filter\" do\n    run_command(\"boot\", \"redis\", \"-r\", \"workers\").tap do |output|\n      assert_no_match \"docker run\", output\n    end\n  end\n\n  private\n    def run_command(*command)\n      stdouted { Kamal::Cli::Accessory.start([ *command, \"-c\", \"test/fixtures/deploy_with_accessories_with_different_registries.yml\" ]) }\n    end\nend\n"
  },
  {
    "path": "test/cli/app_test.rb",
    "content": "require_relative \"cli_test_case\"\n\nclass CliAppTest < CliTestCase\n  test \"boot\" do\n    stub_running\n    run_command(\"boot\").tap do |output|\n      assert_match \"docker tag dhh/app:latest dhh/app:latest\", output\n      assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12} /, output\n      assert_match \"docker container ls --all --filter 'name=^app-web-123$' --quiet | xargs docker stop\", output\n    end\n  end\n\n  test \"boot will rename if same version is already running\" do\n    Object.any_instance.stubs(:sleep)\n    run_command(\"details\") # Preheat Kamal const\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .with(:docker, :container, :ls, \"--all\", \"--filter\", \"'name=^app-web-latest$'\", \"--quiet\", raise_on_non_zero_exit: false)\n      .returns(\"12345678\") # running version\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .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)\n      .returns(\"123\") # old version\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .with(:docker, :container, :ls, \"--all\", \"--filter\", \"'name=^app-web-latest$'\", \"--quiet\")\n      .returns(\"12345678\") # running version\n\n    run_command(\"boot\").tap do |output|\n      assert_match /Renaming container .* to .* as already deployed on 1.1.1.1/, output # Rename\n      assert_match /docker rename app-web-latest app-web-latest_replaced_[0-9a-f]{16}/, output\n      assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12} /, output\n      assert_match \"docker container ls --all --filter 'name=^app-web-123$' --quiet | xargs docker stop\", output\n    end\n  ensure\n    Thread.report_on_exception = true\n  end\n\n  test \"boot uses group strategy when specified\" do\n    Kamal::Cli::App.any_instance.stubs(:on).with(\"1.1.1.1\").twice\n    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)\n\n    # Strategy is used when booting the containers\n    Kamal::Cli::App.any_instance.expects(:on).with([ \"1.1.1.1\", \"1.1.1.2\", \"1.1.1.3\" ]).with_block_given\n    Kamal::Cli::App.any_instance.expects(:on).with([ \"1.1.1.4\" ]).with_block_given\n    Object.any_instance.expects(:sleep).with(2).twice\n\n    Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)\n\n    run_command(\"boot\", config: :with_boot_strategy, host: nil).tap do |output|\n      assert_hook_ran \"pre-app-boot\", output, count: 2\n      assert_hook_ran \"post-app-boot\", output, count: 2\n    end\n  end\n\n  test \"boot without parallel roles\" do\n    # Without parallel_roles: on() called with all hosts, roles sequential per host\n    Kamal::Cli::App.any_instance.expects(:on).with(\"1.1.1.1\").with_block_given.twice\n    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)\n\n    run_command(\"boot\", config: :without_parallel_roles, host: nil)\n  end\n\n  test \"boot with parallel roles\" do\n    # With parallel_roles: each role gets its own on() call\n    Kamal::Cli::App.any_instance.expects(:on).with(\"1.1.1.1\").with_block_given.twice\n    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)\n    Kamal::Cli::App.any_instance.expects(:on).with([ \"1.1.1.1\", \"1.1.1.2\" ]).with_block_given\n    Kamal::Cli::App.any_instance.expects(:on).with([ \"1.1.1.1\", \"1.1.1.3\" ]).with_block_given\n\n    run_command(\"boot\", config: :with_parallel_roles, host: nil)\n  end\n\n  test \"boot errors don't leave lock in place\" do\n    Kamal::Cli::App.any_instance.expects(:using_version).raises(RuntimeError)\n\n    assert_not KAMAL.holding_lock?\n    assert_raises(RuntimeError) do\n      stderred { run_command(\"boot\") }\n    end\n    assert_not KAMAL.holding_lock?\n  end\n\n  test \"boot with assets\" do\n    Object.any_instance.stubs(:sleep)\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .with(:docker, :container, :ls, \"--all\", \"--filter\", \"'name=^app-web-latest$'\", \"--quiet\", raise_on_non_zero_exit: false)\n      .returns(\"12345678\") # running version\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .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)\n      .returns(\"123\").twice # old version\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .with(:docker, :container, :ls, \"--all\", \"--filter\", \"'name=^app-web-latest$'\", \"--quiet\")\n      .returns(\"12345678\") # running version\n\n    run_command(\"boot\", config: :with_assets).tap do |output|\n      assert_match \"docker tag dhh/app:latest dhh/app:latest\", output\n      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\n      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\n      assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12} /, output\n      assert_match \"docker container ls --all --filter 'name=^app-web-123$' --quiet | xargs docker stop\", output\n      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\n    end\n  end\n\n  test \"boot with host tags\" do\n    Object.any_instance.stubs(:sleep)\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .with(:docker, :container, :ls, \"--all\", \"--filter\", \"'name=^app-web-latest$'\", \"--quiet\", raise_on_non_zero_exit: false)\n      .returns(\"12345678\") # running version\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .with(:docker, :container, :ls, \"--all\", \"--filter\", \"'name=^app-web-latest$'\", \"--quiet\")\n      .returns(\"12345678\") # running version\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .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)\n      .returns(\"123\") # old version\n\n    run_command(\"boot\", config: :with_env_tags).tap do |output|\n      assert_match \"docker tag dhh/app:latest dhh/app:latest\", output\n      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\n      assert_match \"docker container ls --all --filter 'name=^app-web-123$' --quiet | xargs docker stop\", output\n    end\n  end\n\n  test \"boot with web barrier opened\" do\n    Object.any_instance.stubs(:sleep)\n\n    SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns(\"123\") # old version\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .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}}'\")\n      .returns(\"running\").at_least_once # workers health check\n\n    run_command(\"boot\", config: :with_roles, host: nil).tap do |output|\n      assert_match \"Waiting for the first healthy web container before booting workers on 1.1.1.3...\", output\n      assert_match \"Waiting for the first healthy web container before booting workers on 1.1.1.4...\", output\n      assert_match \"First web container is healthy, booting workers on 1.1.1.3\", output\n      assert_match \"First web container is healthy, booting workers on 1.1.1.4\", output\n    end\n  end\n\n  test \"boot with web barrier closed\" do\n    Thread.report_on_exception = false\n\n    Object.any_instance.stubs(:sleep)\n\n    SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns(\"123\") # old version\n\n    SSHKit::Backend::Abstract.any_instance.stubs(:execute).returns(\"\")\n    SSHKit::Backend::Abstract.any_instance.stubs(:execute)\n      .with(:docker, :container, :ls, \"--all\", \"--filter\", \"'name=^app-web-latest$'\", \"--quiet\", \"|\", :xargs, :docker, :stop, raise_on_non_zero_exit: false)\n    SSHKit::Backend::Abstract.any_instance.expects(:execute)\n      .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\"))\n\n    stderred do\n      run_command(\"boot\", config: :with_roles, host: nil, allow_execute_error: true).tap do |output|\n        assert_match \"Waiting for the first healthy web container before booting workers on 1.1.1.3...\", output\n        assert_match \"Waiting for the first healthy web container before booting workers on 1.1.1.4...\", output\n        assert_match \"First web container is unhealthy, not booting workers on 1.1.1.3\", output\n        assert_match \"First web container is unhealthy, not booting workers on 1.1.1.4\", output\n      end\n    end\n  ensure\n    Thread.report_on_exception = true\n  end\n\n  test \"boot with worker errors\" do\n    Thread.report_on_exception = false\n\n    Object.any_instance.stubs(:sleep)\n\n    SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns(\"123\") # old version\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .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}}'\")\n      .returns(\"unhealthy\").at_least_once # workers health check\n\n    run_command(\"boot\", config: :with_roles, host: nil, allow_execute_error: true).tap do |output|\n      assert_match \"Waiting for the first healthy web container before booting workers on 1.1.1.3...\", output\n      assert_match \"Waiting for the first healthy web container before booting workers on 1.1.1.4...\", output\n      assert_match \"First web container is healthy, booting workers on 1.1.1.3\", output\n      assert_match \"First web container is healthy, booting workers on 1.1.1.4\", output\n      assert_match \"ERROR Failed to boot workers on 1.1.1.3\", output\n      assert_match \"ERROR Failed to boot workers on 1.1.1.4\", output\n    end\n  ensure\n    Thread.report_on_exception = true\n  end\n\n  test \"boot with worker ready then not\" do\n    Thread.report_on_exception = false\n\n    Object.any_instance.stubs(:sleep)\n\n    SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns(\"123\") # old version\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .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}}'\")\n      .returns(\"running\", \"stopped\").at_least_once # workers health check\n\n    run_command(\"boot\", config: :with_roles, host: \"1.1.1.3\", allow_execute_error: true).tap do |output|\n      assert_match \"ERROR Failed to boot workers on 1.1.1.3\", output\n    end\n  ensure\n    Thread.report_on_exception = true\n  end\n\n  test \"boot with only workers\" do\n    Object.any_instance.stubs(:sleep)\n\n    SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns(\"123\") # old version\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .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}}'\")\n      .returns(\"running\").at_least_once # workers health check\n\n    run_command(\"boot\", config: :with_only_workers, host: nil).tap do |output|\n      assert_match /First workers container is healthy on 1.1.1.\\d, booting any other roles/, output\n      assert_no_match \"kamal-proxy\", output\n    end\n  end\n\n  test \"boot with error pages\" do\n    with_error_pages(directory: \"public\") do\n      stub_running\n      run_command(\"boot\", config: :with_error_pages).tap do |output|\n        assert_match /Uploading .*kamal-error-pages.*\\/latest to \\.kamal\\/proxy\\/apps-config\\/app\\/error_pages/, output\n        assert_match \"docker tag dhh/app:latest dhh/app:latest\", output\n        assert_match /docker run --detach --restart unless-stopped --name app-web-latest --network kamal --hostname 1.1.1.1-[0-9a-f]{12} /, output\n        assert_match \"docker container ls --all --filter 'name=^app-web-123$' --quiet | xargs docker stop\", output\n        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\n      end\n    end\n  end\n\n  test \"boot with custom ssl certificate\" do\n    Kamal::Configuration::Proxy.any_instance.stubs(:custom_ssl_certificate?).returns(true)\n    Kamal::Configuration::Proxy.any_instance.stubs(:certificate_pem_content).returns(\"CERTIFICATE CONTENT\")\n    Kamal::Configuration::Proxy.any_instance.stubs(:private_key_pem_content).returns(\"PRIVATE KEY CONTENT\")\n\n    stub_running\n    run_command(\"boot\", config: :with_proxy).tap do |output|\n      assert_match \"Writing SSL certificates for web on 1.1.1.1\", output\n      assert_match \"mkdir -p .kamal/proxy/apps-config/app/tls\", output\n      assert_match \"Uploading \\\"CERTIFICATE CONTENT\\\" to .kamal/proxy/apps-config/app/tls/web/cert.pem\", output\n      assert_match \"--tls-certificate-path=\\\"/home/kamal-proxy/.apps-config/app/tls/web/cert.pem\\\"\", output\n      assert_match \"--tls-private-key-path=\\\"/home/kamal-proxy/.apps-config/app/tls/web/key.pem\\\"\", output\n    end\n  end\n\n  test \"start\" do\n    SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns(\"999\") # old version\n\n    run_command(\"start\").tap do |output|\n      assert_match \"docker start app-web-999\", output\n      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\n    end\n  end\n\n  test \"stop\" do\n    run_command(\"stop\").tap do |output|\n      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\n    end\n  end\n\n  test \"stale_containers\" do\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .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)\n      .returns(\"12345678\\n87654321\\n\")\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .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)\n      .returns(\"12345678\\n\")\n\n    run_command(\"stale_containers\").tap do |output|\n      assert_match /Detected stale container for role web with version 87654321/, output\n    end\n  end\n\n  test \"stop stale_containers\" do\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .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)\n      .returns(\"12345678\\n87654321\\n\")\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .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)\n      .returns(\"12345678\\n\")\n\n    run_command(\"stale_containers\", \"--stop\").tap do |output|\n      assert_match /Stopping stale container for role web with version 87654321/, output\n      assert_match /#{Regexp.escape(\"docker container ls --all --filter 'name=^app-web-87654321$' --quiet | xargs docker stop\")}/, output\n    end\n  end\n\n  test \"details\" do\n    run_command(\"details\").tap do |output|\n      assert_match \"docker ps --filter label=service=app --filter label=destination= --filter label=role=web\", output\n    end\n  end\n\n  test \"remove\" do\n    run_command(\"remove\").tap do |output|\n      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\n      assert_match \"docker container prune --force --filter label=service=app\", output\n      assert_match \"docker image prune --all --force --filter label=service=app\", output\n      assert_match \"rm -r .kamal/apps/app on 1.1.1.1\", output\n      assert_match \"rm -r .kamal/proxy/apps-config/app on 1.1.1.1\", output\n    end\n  end\n\n  test \"remove with role filter does not remove images or app directories\" do\n    run_command(\"remove\", \"-r\", \"workers\", config: :with_two_roles_one_host).tap do |output|\n      assert_match \"docker stop\", output\n      assert_match \"docker container prune --force --filter label=service=app\", output\n      # Images and directories should NOT be removed when other roles remain on the host\n      assert_no_match(/docker image prune --all --force --filter label=service=app/, output)\n      assert_no_match(/rm -r .kamal\\/apps\\/app/, output)\n      assert_no_match(/rm -r .kamal\\/proxy\\/apps-config\\/app/, output)\n    end\n  end\n\n  test \"remove with all roles on host removes images and app directories\" do\n    run_command(\"remove\", \"-r\", \"workers,web\", config: :with_two_roles_one_host).tap do |output|\n      assert_match \"docker stop\", output\n      assert_match \"docker container prune --force --filter label=service=app\", output\n      # Images and directories SHOULD be removed when all roles on host are removed\n      assert_match \"docker image prune --all --force --filter label=service=app\", output\n      assert_match \"rm -r .kamal/apps/app on 1.1.1.1\", output\n      assert_match \"rm -r .kamal/proxy/apps-config/app on 1.1.1.1\", output\n    end\n  end\n\n  test \"remove_container\" do\n    run_command(\"remove_container\", \"1234567\").tap do |output|\n      assert_match \"docker container ls --all --filter 'name=^app-web-1234567$' --quiet | xargs docker container rm\", output\n    end\n  end\n\n  test \"remove_containers\" do\n    run_command(\"remove_containers\").tap do |output|\n      assert_match \"docker container prune --force --filter label=service=app\", output\n    end\n  end\n\n  test \"remove_images\" do\n    run_command(\"remove_images\").tap do |output|\n      assert_match \"docker image prune --all --force --filter label=service=app\", output\n    end\n  end\n\n  test \"remove_app_directories\" do\n    run_command(\"remove_app_directories\").tap do |output|\n      assert_match \"rm -r .kamal/apps/app on 1.1.1.1\", output\n      assert_match \"rm -r .kamal/proxy/apps-config/app on 1.1.1.1\", output\n    end\n  end\n\n  test \"exec\" do\n    run_command(\"exec\", \"ruby -v\").tap do |output|\n      assert_match \"docker login -u [REDACTED] -p [REDACTED]\", output\n      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\n    end\n  end\n\n  test \"exec without command fails\" do\n    error = assert_raises(ArgumentError, \"Exec requires a command to be specified\") do\n      run_command(\"exec\")\n    end\n    assert_equal \"No command provided. You must specify a command to execute.\", error.message\n  end\n\n  test \"exec separate arguments\" do\n    run_command(\"exec\", \"ruby\", \" -v\").tap do |output|\n      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\n    end\n  end\n\n  test \"exec detach\" do\n    run_command(\"exec\", \"--detach\", \"ruby -v\").tap do |output|\n      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\n    end\n  end\n\n  test \"exec detach with reuse\" do\n    assert_raises(ArgumentError, \"Detach is not compatible with reuse\") do\n      run_command(\"exec\", \"--detach\", \"--reuse\", \"ruby -v\")\n    end\n  end\n\n  test \"exec detach with interactive\" do\n    assert_raises(ArgumentError, \"Detach is not compatible with interactive\") do\n      run_command(\"exec\", \"--interactive\", \"--detach\", \"ruby -v\")\n    end\n  end\n\n  test \"exec detach with interactive and reuse\" do\n    assert_raises(ArgumentError, \"Detach is not compatible with interactive or reuse\") do\n      run_command(\"exec\", \"--interactive\", \"--detach\", \"--reuse\", \"ruby -v\")\n    end\n  end\n\n  test \"exec with reuse\" do\n    run_command(\"exec\", \"--reuse\", \"ruby -v\").tap do |output|\n      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\n      assert_match \"docker exec app-web-999 ruby -v\", output\n    end\n  end\n\n  test \"exec interactive\" do\n    Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)\n    SSHKit::Backend::Abstract.any_instance.expects(:exec)\n      .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'}))\n\n    stub_stdin_tty do\n      run_command(\"exec\", \"-i\", \"ruby -v\").tap do |output|\n        assert_hook_ran \"pre-connect\", output\n        assert_match \"docker login -u [REDACTED] -p [REDACTED]\", output\n        assert_match \"Get most recent version available as an image...\", output\n        assert_match \"Launching interactive command with version latest via SSH from new container on 1.1.1.1...\", output\n      end\n    end\n  end\n\n  test \"exec interactive with reuse\" do\n    Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)\n    SSHKit::Backend::Abstract.any_instance.expects(:exec)\n      .with(\"ssh -t root@1.1.1.1 -p 22 'docker exec -it app-web-999 ruby -v'\")\n\n    stub_stdin_tty do\n      run_command(\"exec\", \"-i\", \"--reuse\", \"ruby -v\").tap do |output|\n        assert_hook_ran \"pre-connect\", output\n        assert_match \"Get current version of running container...\", output\n        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\n        assert_match \"Launching interactive command with version 999 via SSH from existing container on 1.1.1.1...\", output\n      end\n    end\n  end\n\n  test \"exec interactive with pipe on STDIN\" do\n    Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)\n    SSHKit::Backend::Abstract.any_instance.expects(:exec)\n      .with(\"ssh -t root@1.1.1.1 -p 22 'docker exec -i app-web-999 ruby -v'\")\n\n    stub_stdin_file do\n      run_command(\"exec\", \"-i\", \"--reuse\", \"ruby -v\").tap do |output|\n        assert_hook_ran \"pre-connect\", output\n        assert_match \"Launching interactive command with version 999 via SSH from existing container on 1.1.1.1...\", output\n      end\n    end\n  end\n\n  test \"containers\" do\n    run_command(\"containers\").tap do |output|\n      assert_match \"docker container ls --all --filter label=service=app\", output\n    end\n  end\n\n  test \"images\" do\n    run_command(\"images\").tap do |output|\n      assert_match \"docker image ls dhh/app\", output\n    end\n  end\n\n  test \"logs\" do\n    SSHKit::Backend::Abstract.any_instance.stubs(:exec)\n      .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'\")\n\n    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\")\n\n    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\")\n\n    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\")\n  end\n\n  test \"logs with follow\" do\n    SSHKit::Backend::Abstract.any_instance.stubs(:exec)\n      .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'\")\n\n    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\")\n  end\n\n  test \"logs with follow and container_id\" do\n    SSHKit::Backend::Abstract.any_instance.stubs(:exec)\n      .with(\"ssh -t root@1.1.1.1 -p 22 'echo ID123 | xargs docker logs --timestamps --tail 10 --follow 2>&1'\")\n\n    assert_match \"echo ID123 | xargs docker logs --timestamps --tail 10 --follow 2>&1\", run_command(\"logs\", \"--follow\", \"--container-id\", \"ID123\")\n  end\n\n  test \"logs with follow and grep\" do\n    SSHKit::Backend::Abstract.any_instance.stubs(:exec)\n      .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\\\"'\")\n\n    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\")\n  end\n\n  test \"logs with follow, grep and grep options\" do\n    SSHKit::Backend::Abstract.any_instance.stubs(:exec)\n      .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'\")\n\n    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\")\n  end\n\n  test \"version\" do\n    run_command(\"version\").tap do |output|\n      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\n    end\n  end\n\n\n  test \"version through main\" do\n    with_argv([ \"app\", \"version\", \"-c\", \"test/fixtures/deploy_with_accessories.yml\", \"--hosts\", \"1.1.1.1\" ]) do\n      stdouted { Kamal::Cli::Main.start }.tap do |output|\n        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\n      end\n    end\n  end\n\n  test \"long hostname\" do\n    stub_running\n\n    hostname = \"this-hostname-is-really-unacceptably-long-to-be-honest.example.com\"\n\n    stdouted { Kamal::Cli::App.start([ \"boot\", \"-c\", \"test/fixtures/deploy_with_uncommon_hostnames.yml\", \"--hosts\", hostname ]) }.tap do |output|\n      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\n    end\n  end\n\n  test \"hostname is trimmed if will end with a period\" do\n    stub_running\n\n    hostname = \"this-hostname-with-random-part-is-too-long.example.com\"\n\n    stdouted { Kamal::Cli::App.start([ \"boot\", \"-c\", \"test/fixtures/deploy_with_uncommon_hostnames.yml\", \"--hosts\", hostname ]) }.tap do |output|\n      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\n    end\n  end\n\n  test \"boot proxy\" do\n    SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns(\"123\") # old version\n\n    run_command(\"boot\", config: :with_proxy).tap do |output|\n      assert_match /Renaming container .* to .* as already deployed on 1.1.1.1/, output # Rename\n      assert_match /docker rename app-web-latest app-web-latest_replaced_[0-9a-f]{16}/, output\n      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\n      assert_match /docker exec kamal-proxy kamal-proxy deploy app-web --target=\"123:80\"/, output\n      assert_match \"docker container ls --all --filter 'name=^app-web-123$' --quiet | xargs docker stop\", output\n    end\n  end\n\n  test \"boot proxy with role specific config\" do\n    SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns(\"123\") # old version\n\n    run_command(\"boot\", config: :with_proxy_roles, host: nil).tap do |output|\n      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\n      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\n    end\n  end\n\n  test \"live\" do\n    run_command(\"live\").tap do |output|\n      assert_match \"docker exec kamal-proxy kamal-proxy resume app-web on 1.1.1.1\", output\n    end\n  end\n\n  test \"maintenance\" do\n    run_command(\"maintenance\").tap do |output|\n      assert_match \"docker exec kamal-proxy kamal-proxy stop app-web --drain-timeout=\\\"30s\\\" on 1.1.1.1\", output\n    end\n  end\n\n  test \"maintenance with options\" do\n    run_command(\"maintenance\", \"--message\", \"Hello\", \"--drain_timeout\", \"10\").tap do |output|\n      assert_match \"docker exec kamal-proxy kamal-proxy stop app-web --drain-timeout=\\\"10s\\\" --message=\\\"Hello\\\" on 1.1.1.1\", output\n    end\n  end\n\n  private\n    def run_command(*command, config: :with_accessories, host: \"1.1.1.1\", allow_execute_error: false)\n      stdouted do\n        Kamal::Cli::App.start([ *command, \"-c\", \"test/fixtures/deploy_#{config}.yml\", *([ \"--hosts\", host ] if host) ])\n      rescue SSHKit::Runner::ExecuteError => e\n        raise e unless allow_execute_error\n      end\n    end\n\n    def stub_running\n      Object.any_instance.stubs(:sleep)\n\n      SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns(\"123\") # old version\n    end\nend\n"
  },
  {
    "path": "test/cli/build_test.rb",
    "content": "require_relative \"cli_test_case\"\n\nclass CliBuildTest < CliTestCase\n  test \"deliver\" do\n    Kamal::Cli::Build.any_instance.expects(:push)\n    Kamal::Cli::Build.any_instance.expects(:pull)\n\n    run_command(\"deliver\")\n  end\n\n  test \"push\" do\n    with_build_directory do |build_directory|\n      Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)\n\n      SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n        .with(:git, \"-C\", anything, :\"rev-parse\", :HEAD)\n        .returns(Kamal::Git.revision)\n\n      SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n        .with(:git, \"-C\", anything, :status, \"--porcelain\")\n        .returns(\"\")\n\n      run_command(\"push\", \"--verbose\").tap do |output|\n        assert_hook_ran \"pre-connect\", output\n        assert_hook_ran \"pre-build\", output\n        assert_match /Cloning repo into build directory/, output\n        assert_match /git -C #{Dir.tmpdir}\\/kamal-clones\\/app-#{pwd_sha} clone #{Dir.pwd}/, output\n        assert_match /docker --version && docker buildx version/, output\n        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\n      end\n    end\n  end\n\n  test \"push with remote builder checks both the builder and the remote context\" do\n    with_build_directory do |build_directory|\n      Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)\n\n      SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n        .with(:git, \"-C\", anything, :\"rev-parse\", :HEAD)\n        .returns(Kamal::Git.revision)\n\n      SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n        .with(:git, \"-C\", anything, :status, \"--porcelain\")\n        .returns(\"\")\n\n      run_command(\"push\", \"--verbose\", fixture: :with_remote_builder).tap do |output|\n        assert_no_match \"Running docker login -u [REDACTED] -p [REDACTED] as \", output\n        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\n        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\n      end\n    end\n  end\n\n  test \"push --output=docker\" do\n    with_build_directory do |build_directory|\n      Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)\n\n      SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n        .with(:git, \"-C\", anything, :\"rev-parse\", :HEAD)\n        .returns(Kamal::Git.revision)\n\n      SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n        .with(:git, \"-C\", anything, :status, \"--porcelain\")\n        .returns(\"\")\n\n      run_command(\"push\", \"--output=docker\", \"--verbose\").tap do |output|\n        assert_hook_ran \"pre-build\", output\n        assert_match /Cloning repo into build directory/, output\n        assert_match /git -C #{Dir.tmpdir}\\/kamal-clones\\/app-#{pwd_sha} clone #{Dir.pwd}/, output\n        assert_match /docker --version && docker buildx version/, output\n        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\n      end\n    end\n  end\n\n  test \"push resetting clone\" do\n    with_build_directory do |build_directory|\n      stub_setup\n\n      SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, \"--version\", \"&&\", :docker, :buildx, \"version\")\n      SSHKit::Backend::Abstract.any_instance.stubs(:execute).with { |*args| args[0..1] == [ :docker, :login ] }\n\n      SSHKit::Backend::Abstract.any_instance.expects(:execute)\n        .with(:git, \"-C\", \"#{Dir.tmpdir}/kamal-clones/app-#{pwd_sha}\", :clone, Dir.pwd, \"--recurse-submodules\")\n        .raises(SSHKit::Command::Failed.new(\"fatal: destination path 'kamal' already exists and is not an empty directory\"))\n        .then\n        .returns(true)\n      SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, \"-C\", build_directory, :remote, \"set-url\", :origin, Dir.pwd)\n      SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, \"-C\", build_directory, :fetch, :origin)\n      SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, \"-C\", build_directory, :reset, \"--hard\", Kamal::Git.revision)\n      SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, \"-C\", build_directory, :clean, \"-fdx\")\n      SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, \"-C\", build_directory, :submodule, :update, \"--init\")\n\n      SSHKit::Backend::Abstract.any_instance.expects(:execute)\n        .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: {})\n\n      SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n        .with(:git, \"-C\", anything, :\"rev-parse\", :HEAD)\n        .returns(Kamal::Git.revision)\n\n      SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n        .with(:git, \"-C\", anything, :status, \"--porcelain\")\n        .returns(\"\")\n\n      run_command(\"push\", \"--verbose\").tap do |output|\n        assert_match /Cloning repo into build directory/, output\n        assert_match /Resetting local clone/, output\n      end\n    end\n  end\n\n  test \"push without clone\" do\n    Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)\n\n    run_command(\"push\", \"--verbose\", fixture: :without_clone).tap do |output|\n      assert_no_match /Cloning repo into build directory/, output\n      assert_hook_ran \"pre-build\", output\n      assert_match /docker --version && docker buildx version/, output\n      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\n    end\n  end\n\n  test \"push with no-cache\" do\n    Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)\n\n    run_command(\"push\", \"--no-cache\", \"--verbose\", fixture: :without_clone).tap do |output|\n      assert_hook_ran \"pre-build\", output\n      assert_match /docker --version && docker buildx version/, output\n      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\n    end\n  end\n\n  test \"push with corrupt clone\" do\n    with_build_directory do |build_directory|\n      stub_setup\n\n      SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, \"--version\", \"&&\", :docker, :buildx, \"version\")\n      SSHKit::Backend::Abstract.any_instance.stubs(:execute).with { |*args| args[0..1] == [ :docker, :login ] }\n\n      SSHKit::Backend::Abstract.any_instance.expects(:execute)\n        .with(:git, \"-C\", \"#{Dir.tmpdir}/kamal-clones/app-#{pwd_sha}\", :clone, Dir.pwd, \"--recurse-submodules\")\n        .raises(SSHKit::Command::Failed.new(\"fatal: destination path 'kamal' already exists and is not an empty directory\"))\n        .then\n        .returns(true)\n        .twice\n\n      SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:git, \"-C\", build_directory, :remote, \"set-url\", :origin, Dir.pwd)\n        .raises(SSHKit::Command::Failed.new(\"fatal: not a git repository\"))\n\n      SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n        .with(:git, \"-C\", anything, :\"rev-parse\", :HEAD)\n        .returns(Kamal::Git.revision)\n\n      SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n        .with(:git, \"-C\", anything, :status, \"--porcelain\")\n        .returns(\"\")\n\n      Dir.stubs(:chdir)\n\n      run_command(\"push\", \"--verbose\") do |output|\n        assert_match /Cloning repo into build directory `#{build_directory}`\\.\\.\\..*Cloning repo into build directory `#{build_directory}`\\.\\.\\./, output\n        assert_match \"Resetting local clone as `#{build_directory}` already exists...\", output\n        assert_match \"Error preparing clone: Failed to clone repo: fatal: not a git repository, deleting and retrying...\", output\n      end\n    end\n  end\n\n  test \"push without builder for local registry\" do\n    with_build_directory do |build_directory|\n      stub_setup\n\n      SSHKit::Backend::Abstract.any_instance.expects(:execute)\n        .with(:docker, \"--version\", \"&&\", :docker, :buildx, \"version\")\n\n      SSHKit::Backend::Abstract.any_instance.stubs(:execute)\n        .with { |*args| args[0..1] == [ :docker, :login ] }\n\n      SSHKit::Backend::Abstract.any_instance.expects(:execute)\n      .with(:docker, :start, \"kamal-docker-registry\", \"||\", :docker, :run, \"--detach\", \"-p\", \"127.0.0.1:5000:5000\", \"--name\", \"kamal-docker-registry\", \"registry:3\")\n\n      SSHKit::Backend::Abstract.any_instance.expects(:execute)\n        .with(:docker, :buildx, :rm, \"kamal-local-registry-docker-container\")\n\n      SSHKit::Backend::Abstract.any_instance.expects(:execute)\n        .with(:docker, :buildx, :create, \"--name\", \"kamal-local-registry-docker-container\", \"--driver=docker-container\", \"--driver-opt\", \"network=host\")\n\n      SSHKit::Backend::Abstract.any_instance.expects(:execute)\n        .with(:docker, :buildx, :inspect, \"kamal-local-registry-docker-container\")\n        .raises(SSHKit::Command::Failed.new(\"no builder\"))\n\n      SSHKit::Backend::Abstract.any_instance.expects(:execute).with { |*args| args.first.to_s.start_with?(\"git\") }\n\n      SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n        .with(:git, \"-C\", anything, :\"rev-parse\", :HEAD)\n        .returns(Kamal::Git.revision)\n\n      SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n        .with(:git, \"-C\", anything, :status, \"--porcelain\")\n        .returns(\"\")\n\n      SSHKit::Backend::Abstract.any_instance.expects(:execute)\n        .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: {})\n\n      run_command(\"push\", fixture: :with_local_registry_and_accessories).tap do |output|\n        assert_match /WARN Missing compatible builder, so creating a new one first/, output\n      end\n    end\n  end\n\n  test \"push without builder\" do\n    with_build_directory do |build_directory|\n      stub_setup\n\n      SSHKit::Backend::Abstract.any_instance.expects(:execute)\n        .with(:docker, \"--version\", \"&&\", :docker, :buildx, \"version\")\n\n      SSHKit::Backend::Abstract.any_instance.stubs(:execute)\n        .with { |*args| args[0..1] == [ :docker, :login ] }\n\n      SSHKit::Backend::Abstract.any_instance.expects(:execute)\n        .with(:docker, :buildx, :rm, \"kamal-local-docker-container\")\n\n      SSHKit::Backend::Abstract.any_instance.expects(:execute)\n        .with(:docker, :buildx, :create, \"--name\", \"kamal-local-docker-container\", \"--driver=docker-container\")\n\n      SSHKit::Backend::Abstract.any_instance.expects(:execute)\n        .with(:docker, :buildx, :inspect, \"kamal-local-docker-container\")\n        .raises(SSHKit::Command::Failed.new(\"no builder\"))\n\n      SSHKit::Backend::Abstract.any_instance.expects(:execute).with { |*args| args.first.start_with?(\"git\") }\n\n      SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n        .with(:git, \"-C\", anything, :\"rev-parse\", :HEAD)\n        .returns(Kamal::Git.revision)\n\n      SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n        .with(:git, \"-C\", anything, :status, \"--porcelain\")\n        .returns(\"\")\n\n      SSHKit::Backend::Abstract.any_instance.expects(:execute)\n        .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: {})\n\n      run_command(\"push\").tap do |output|\n        assert_match /WARN Missing compatible builder, so creating a new one first/, output\n      end\n    end\n  end\n\n  test \"push with no buildx plugin\" do\n    stub_setup\n    SSHKit::Backend::Abstract.any_instance.stubs(:execute)\n      .with(:docker, \"--version\", \"&&\", :docker, :buildx, \"version\")\n      .raises(SSHKit::Command::Failed.new(\"no buildx\"))\n\n    Kamal::Commands::Builder.any_instance.stubs(:native_and_local?).returns(false)\n    assert_raises(Kamal::Cli::DependencyError) { run_command(\"push\") }\n  end\n\n  test \"push pre-build hook failure\" do\n    fail_hook(\"pre-build\")\n\n    error = assert_raises(Kamal::Cli::HookError) { run_command(\"push\") }\n    assert_equal \"Hook `pre-build` failed:\\nfailed\", error.message\n\n    assert @executions.none? { |args| args[0..2] == [ :docker, :build ] }\n  end\n\n  test \"pull\" do\n    run_command(\"pull\").tap do |output|\n      assert_match /docker info --format '{{index .RegistryConfig.Mirrors 0}}'/, output\n      assert_match /docker image rm --force dhh\\/app:999/, output\n      assert_match /docker pull dhh\\/app:999/, output\n      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\n    end\n  end\n\n  test \"pull with mirror\" do\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .with(:docker, :info, \"--format '{{index .RegistryConfig.Mirrors 0}}'\")\n      .returns(\"registry-mirror.example.com\")\n      .at_least_once\n\n    run_command(\"pull\").tap do |output|\n      assert_match /Pulling image on 1\\.1\\.1\\.\\d to seed the mirror\\.\\.\\./, output\n      assert_match \"Pulling image on remaining hosts...\", output\n      assert_equal 4, output.scan(/docker pull dhh\\/app:999/).size, output\n      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\n    end\n  end\n\n  test \"pull with mirrors\" do\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .with(:docker, :info, \"--format '{{index .RegistryConfig.Mirrors 0}}'\")\n      .returns(\"registry-mirror.example.com\", \"registry-mirror2.example.com\")\n      .at_least_once\n\n    run_command(\"pull\").tap do |output|\n      assert_match /Pulling image on 1\\.1\\.1\\.\\d, 1\\.1\\.1\\.\\d to seed the mirrors\\.\\.\\./, output\n      assert_match \"Pulling image on remaining hosts...\", output\n      assert_equal 4, output.scan(/docker pull dhh\\/app:999/).size, output\n      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\n    end\n  end\n\n  test \"create\" do\n    run_command(\"create\").tap do |output|\n      assert_match /docker buildx create --name kamal-local-docker-container --driver=docker-container/, output\n    end\n  end\n\n  test \"create remote\" do\n    run_command(\"create\", fixture: :with_remote_builder).tap do |output|\n      assert_match \"Running /usr/bin/env true on 1.1.1.5\", output\n      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\n      assert_match \"docker buildx create --name kamal-remote-ssh---app-1-1-1-5 kamal-remote-ssh---app-1-1-1-5-context\", output\n    end\n  end\n\n  test \"create remote with custom ports\" do\n    run_command(\"create\", fixture: :with_remote_builder_and_custom_ports).tap do |output|\n      assert_match \"Running /usr/bin/env true on 1.1.1.5\", output\n      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\n      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\n    end\n  end\n\n  test \"create hybrid\" do\n    run_command(\"create\", fixture: :with_hybrid_builder).tap do |output|\n      assert_match \"Running /usr/bin/env true on 1.1.1.5\", output\n      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\n      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\n      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\n    end\n  end\n\n  test \"create cloud\" do\n    run_command(\"create\", fixture: :with_cloud_builder).tap do |output|\n      assert_match /docker buildx create --driver cloud example_org\\/cloud_builder/, output\n    end\n  end\n\n  test \"create with error\" do\n    stub_setup\n    SSHKit::Backend::Abstract.any_instance.stubs(:execute)\n      .with { |arg| arg == :docker }\n      .raises(SSHKit::Command::Failed.new(\"stderr=error\"))\n\n    run_command(\"create\").tap do |output|\n      assert_match /Couldn't create remote builder: error/, output\n    end\n  end\n\n  test \"remove\" do\n    run_command(\"remove\").tap do |output|\n      assert_match /docker buildx rm kamal-local/, output\n    end\n  end\n\n  test \"remove cloud\" do\n    run_command(\"remove\", fixture: :with_cloud_builder).tap do |output|\n      assert_match /docker buildx rm cloud-example_org-cloud_builder/, output\n    end\n  end\n\n  test \"details\" do\n    SSHKit::Backend::Abstract.any_instance.stubs(:capture)\n      .with(:docker, :context, :ls, \"&&\", :docker, :buildx, :ls)\n      .returns(\"docker builder info\")\n\n    run_command(\"details\").tap do |output|\n      assert_match /Builder: local/, output\n      assert_match /docker builder info/, output\n    end\n  end\n\n  test \"dev\" do\n    with_build_directory do |build_directory|\n      Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)\n\n      run_command(\"dev\", \"--verbose\").tap do |output|\n        assert_no_match(/Cloning repo into build directory/, output)\n        assert_match(/docker --version && docker buildx version/, output)\n        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)\n      end\n    end\n  end\n\n  test \"dev --output=local\" do\n    with_build_directory do |build_directory|\n      Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)\n\n      run_command(\"dev\", \"--output=local\", \"--verbose\").tap do |output|\n        assert_no_match(/Cloning repo into build directory/, output)\n        assert_match(/docker --version && docker buildx version/, output)\n        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)\n      end\n    end\n  end\n\n  test \"dev with no-cache\" do\n    with_build_directory do |build_directory|\n      Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)\n\n      run_command(\"dev\", \"--no-cache\", \"--verbose\").tap do |output|\n        assert_no_match(/Cloning repo into build directory/, output)\n        assert_match(/docker --version && docker buildx version/, output)\n        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)\n      end\n    end\n  end\n\n  test \"create with local registry\" do\n    run_command(\"create\", fixture: :with_local_registry).tap do |output|\n      assert_match /docker buildx create --name kamal-local-registry-docker-container --driver=docker-container --driver-opt network=host/, output\n    end\n  end\n\n  test \"create with local registry and remote builder\" do\n    run_command(\"create\", fixture: :with_local_registry_and_remote_builder).tap do |output|\n      # Verify remote builder with local-registry in name\n      assert_match /docker buildx create --name kamal-remote-ssh---app-1-1-1-5-local-registry/, output\n      assert_match /--driver-opt network=host/, output\n    end\n  end\n\n  test \"pull with local registry\" do\n    # Verify port forwarding is established for all app hosts\n    port_forwarding_mock = mock(\"port_forwarding\")\n    port_forwarding_mock.expects(:forward).yields\n    Kamal::Cli::Build::PortForwarding.expects(:new)\n      .with([ \"1.1.1.1\", \"1.1.1.2\" ], 5000, has_entries(\n        user: \"root\",\n        port: 22,\n        logger: instance_of(Logger),\n        keepalive: true,\n        keepalive_interval: 30\n      )\n    ).returns(port_forwarding_mock)\n\n    run_command(\"pull\", fixture: :with_local_registry).tap do |output|\n      assert_match /docker pull localhost:5000\\/dhh\\/app:999/, output\n    end\n  end\n\n  test \"create with local registry and remote builder with custom port\" do\n    run_command(\"create\", fixture: :with_local_registry_and_remote_builder_with_port).tap do |output|\n      # Verify remote builder with local-registry in name includes custom port in context name\n      assert_match /docker buildx create --name kamal-remote-ssh---app-1-1-1-5-2222-local-registry/, output\n      assert_match /--driver-opt network=host/, output\n    end\n  end\n\n  private\n    def run_command(*command, fixture: :with_accessories)\n      stdouted { stderred { Kamal::Cli::Build.start([ *command, \"-c\", \"test/fixtures/deploy_#{fixture}.yml\" ]) } }\n    end\n\n    def stub_dependency_checks\n      SSHKit::Backend::Abstract.any_instance.stubs(:execute)\n        .with(:docker, \"--version\", \"&&\", :docker, :buildx, \"version\")\n      SSHKit::Backend::Abstract.any_instance.stubs(:execute)\n        .with { |*args| args[0..1] == [ :docker, :buildx ] }\n    end\nend\n"
  },
  {
    "path": "test/cli/cli_test_case.rb",
    "content": "require \"test_helper\"\n\nclass CliTestCase < ActiveSupport::TestCase\n  setup do\n    ENV[\"VERSION\"]             = \"999\"\n    ENV[\"RAILS_MASTER_KEY\"]    = \"123\"\n    ENV[\"MYSQL_ROOT_PASSWORD\"] = \"secret123\"\n    Object.send(:remove_const, :KAMAL)\n    Object.const_set(:KAMAL, Kamal::Commander.new)\n  end\n\n  teardown do\n    ENV.delete(\"RAILS_MASTER_KEY\")\n    ENV.delete(\"MYSQL_ROOT_PASSWORD\")\n    ENV.delete(\"VERSION\")\n  end\n\n  private\n    def fail_hook(hook)\n      @executions = []\n      Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)\n\n      SSHKit::Backend::Abstract.any_instance.stubs(:execute)\n        .with { |*args| @executions << args; args != [ \".kamal/hooks/#{hook}\" ] }\n      SSHKit::Backend::Abstract.any_instance.stubs(:execute)\n        .with { |*args| args.first == \".kamal/hooks/#{hook}\" }\n        .raises(SSHKit::Command::Failed.new(\"failed\"))\n    end\n\n    def stub_setup\n      SSHKit::Backend::Abstract.any_instance.stubs(:execute)\n        .with { |*args| args == [ :mkdir, \"-p\", \".kamal/apps/app\" ] }\n      SSHKit::Backend::Abstract.any_instance.stubs(:execute)\n        .with { |arg1, arg2, arg3| arg1 == :mkdir && arg2 == \"-p\" && arg3 == \".kamal/lock-app\" }\n      SSHKit::Backend::Abstract.any_instance.stubs(:execute)\n        .with { |arg1, arg2| arg1 == :mkdir && arg2 == \".kamal/lock-app\" }\n      SSHKit::Backend::Abstract.any_instance.stubs(:execute)\n        .with { |arg1, arg2| arg1 == :rm && arg2 == \".kamal/lock-app/details\" }\n      SSHKit::Backend::Abstract.any_instance.stubs(:execute)\n        .with(:docker, :buildx, :inspect, \"kamal-local-docker-container\")\n    end\n\n    def assert_hook_ran(hook, output, count: 1)\n      regexp = ([ \"/usr/bin/env .kamal/hooks/#{hook}\" ] * count).join(\".*\")\n      assert_match /#{regexp}/m, output\n    end\n\n    def with_argv(*argv)\n      old_argv = ARGV\n      ARGV.replace(*argv)\n      yield\n    ensure\n      ARGV.replace(old_argv)\n    end\n\n    def with_build_directory\n      build_directory = File.join Dir.tmpdir, \"kamal-clones\", \"app-#{pwd_sha}\", \"kamal\"\n      FileUtils.mkdir_p build_directory\n      FileUtils.touch File.join build_directory, \"Dockerfile\"\n      yield build_directory + \"/\"\n    ensure\n      FileUtils.rm_rf build_directory\n    end\n\n    def pwd_sha\n      Digest::SHA256.hexdigest(Dir.pwd)[0..12]\n    end\nend\n"
  },
  {
    "path": "test/cli/lock_test.rb",
    "content": "require_relative \"cli_test_case\"\n\nclass CliLockTest < CliTestCase\n  test \"status\" do\n    run_command(\"status\").tap do |output|\n      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\n    end\n  end\n\n  test \"release\" do\n    run_command(\"release\").tap do |output|\n      assert_match \"Released the deploy lock\", output\n    end\n  end\n\n  private\n    def run_command(*command)\n      stdouted { Kamal::Cli::Lock.start([ *command, \"-v\", \"-c\", \"test/fixtures/deploy_with_accessories.yml\" ]) }\n    end\nend\n"
  },
  {
    "path": "test/cli/main_test.rb",
    "content": "require_relative \"cli_test_case\"\n\nclass CliMainTest < CliTestCase\n  setup { @original_env = ENV.to_h.dup }\n  teardown { ENV.clear; ENV.update @original_env }\n\n  test \"setup\" do\n    invoke_options = { \"config_file\" => \"test/fixtures/deploy_simple.yml\", \"version\" => \"999\", \"skip_hooks\" => false }\n\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:server:bootstrap\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:deploy).with(boot_accessories: true)\n\n    run_command(\"setup\").tap do |output|\n      assert_match /Ensure Docker is installed.../, output\n    end\n  end\n\n  test \"setup with skip_push\" do\n    invoke_options = { \"config_file\" => \"test/fixtures/deploy_simple.yml\", \"version\" => \"999\", \"skip_hooks\" => false }\n\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:server:bootstrap\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:accessory:boot\", [ \"all\" ], invoke_options)\n    # deploy\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:build:pull\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:proxy:boot\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:stale_containers\", [], invoke_options.merge(stop: true))\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:boot\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:prune:all\", [], invoke_options)\n\n    run_command(\"setup\", \"--skip_push\").tap do |output|\n      assert_match /Ensure Docker is installed.../, output\n      # deploy\n      assert_match /Acquiring the deploy lock/, output\n      assert_match /Pull app image/, output\n      assert_match /Ensure kamal-proxy is running/, output\n      assert_match /Detect stale containers/, output\n      assert_match /Prune old containers and images/, output\n      assert_match /Releasing the deploy lock/, output\n    end\n  end\n\n  test \"deploy with local registry\" do\n    with_test_secrets(\"secrets\" => \"DB_PASSWORD=secret\") do\n      invoke_options = { \"config_file\" => \"test/fixtures/deploy_with_local_registry.yml\", \"version\" => \"999\", \"skip_hooks\" => false, \"verbose\" => true }\n\n      Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:build:deliver\", [], invoke_options)\n      Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:proxy:boot\", [], invoke_options)\n      Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:stale_containers\", [], invoke_options.merge(stop: true))\n      Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:boot\", [], invoke_options)\n      Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:prune:all\", [], invoke_options)\n\n      Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)\n\n      run_command(\"deploy\", \"--verbose\", config_file: \"deploy_with_local_registry\").tap do |output|\n        assert_hook_ran \"pre-connect\", output\n        assert_match /Build and push app image/, output\n        assert_hook_ran \"pre-deploy\", output\n        assert_match /Ensure kamal-proxy is running/, output\n        assert_match /Detect stale containers/, output\n        assert_match /Prune old containers and images/, output\n        assert_hook_ran \"post-deploy\", output\n      end\n    end\n  end\n\n  test \"setup with no_cache\" do\n    invoke_options = { \"config_file\" => \"test/fixtures/deploy_simple.yml\", \"version\" => \"999\", \"skip_hooks\" => false, \"no_cache\" => true }\n\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:server:bootstrap\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:accessory:boot\", [ \"all\" ], invoke_options)\n    # deploy\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:build:deliver\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:proxy:boot\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:stale_containers\", [], invoke_options.merge(stop: true))\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:boot\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:prune:all\", [], invoke_options)\n\n    run_command(\"setup\", \"--no-cache\").tap do |output|\n      assert_match /Ensure Docker is installed.../, output\n      # deploy\n      assert_match /Build and push app image/, output\n      assert_match /Ensure kamal-proxy is running/, output\n      assert_match /Detect stale containers/, output\n      assert_match /Prune old containers and images/, output\n    end\n  end\n\n  test \"deploy\" do\n    with_test_secrets(\"secrets\" => \"DB_PASSWORD=secret\") do\n      invoke_options = { \"config_file\" => \"test/fixtures/deploy_simple.yml\", \"version\" => \"999\", \"skip_hooks\" => false, \"verbose\" => true }\n\n      Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:build:deliver\", [], invoke_options)\n      Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:proxy:boot\", [], invoke_options)\n      Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:stale_containers\", [], invoke_options.merge(stop: true))\n      Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:boot\", [], invoke_options)\n      Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:prune:all\", [], invoke_options)\n\n      Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)\n\n      run_command(\"deploy\", \"--verbose\").tap do |output|\n        assert_hook_ran \"pre-connect\", output\n        assert_match /Build and push app image/, output\n        assert_hook_ran \"pre-deploy\", output\n        assert_match /Ensure kamal-proxy is running/, output\n        assert_match /Detect stale containers/, output\n        assert_match /Prune old containers and images/, output\n        assert_hook_ran \"post-deploy\", output\n      end\n    end\n  end\n\n  test \"deploy with skip_push\" do\n    invoke_options = { \"config_file\" => \"test/fixtures/deploy_simple.yml\", \"version\" => \"999\", \"skip_hooks\" => false }\n\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:build:pull\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:proxy:boot\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:stale_containers\", [], invoke_options.merge(stop: true))\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:boot\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:prune:all\", [], invoke_options)\n\n    run_command(\"deploy\", \"--skip_push\").tap do |output|\n      assert_match /Acquiring the deploy lock/, output\n      assert_match /Pull app image/, output\n      assert_match /Ensure kamal-proxy is running/, output\n      assert_match /Detect stale containers/, output\n      assert_match /Prune old containers and images/, output\n      assert_match /Releasing the deploy lock/, output\n    end\n  end\n\n  test \"deploy with no_cache\" do\n    invoke_options = { \"config_file\" => \"test/fixtures/deploy_simple.yml\", \"version\" => \"999\", \"skip_hooks\" => false, \"no_cache\" => true }\n\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:build:deliver\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:proxy:boot\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:stale_containers\", [], invoke_options.merge(stop: true))\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:boot\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:prune:all\", [], invoke_options)\n\n    run_command(\"deploy\", \"--no-cache\").tap do |output|\n      assert_match /Build and push app image/, output\n      assert_match /Ensure kamal-proxy is running/, output\n      assert_match /Detect stale containers/, output\n      assert_match /Prune old containers and images/, output\n    end\n  end\n\n  test \"deploy when locked\" do\n    Thread.report_on_exception = false\n\n    SSHKit::Backend::Abstract.any_instance.stubs(:execute)\n    Dir.stubs(:chdir)\n\n    SSHKit::Backend::Abstract.any_instance.stubs(:execute)\n      .with { |*args| args == [ :mkdir, \"-p\", \".kamal/apps/app\" ] }\n\n    SSHKit::Backend::Abstract.any_instance.stubs(:execute)\n      .with { |*arg| arg[0..1] == [ :mkdir, \".kamal/lock-app\" ] }\n      .raises(RuntimeError, \"mkdir: cannot create directory ‘kamal/lock-app’: File exists\")\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_debug)\n      .with(:stat, \".kamal/lock-app\", \">\", \"/dev/null\", \"&&\", :cat, \".kamal/lock-app/details\", \"|\", :base64, \"-d\")\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .with(:git, \"-C\", anything, :\"rev-parse\", :HEAD)\n      .returns(Kamal::Git.revision)\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .with(:git, \"-C\", anything, :status, \"--porcelain\")\n      .returns(\"\")\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .with(:docker, :info, \"--format '{{index .RegistryConfig.Mirrors 0}}'\")\n      .returns(\"\")\n      .at_least_once\n\n    assert_raises(Kamal::Cli::LockError) do\n      run_command(\"deploy\")\n    end\n  end\n\n  test \"deploy when inheriting lock\" do\n    Thread.report_on_exception = false\n\n    invoke_options = { \"config_file\" => \"test/fixtures/deploy_simple.yml\", \"version\" => \"999\", \"skip_hooks\" => false }\n\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:build:deliver\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:proxy:boot\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:stale_containers\", [], invoke_options.merge(stop: true))\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:boot\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:prune:all\", [], invoke_options)\n\n    Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)\n\n    with_kamal_lock_env do\n      KAMAL.reset\n      run_command(\"deploy\").tap do |output|\n        assert_no_match /Acquiring the deploy lock/, output\n        assert_match /Build and push app image/, output\n        assert_match /Ensure kamal-proxy is running/, output\n        assert_match /Detect stale containers/, output\n        assert_match /Prune old containers and images/, output\n        assert_no_match /Releasing the deploy lock/, output\n      end\n    end\n  end\n\n  test \"deploy error when locking\" do\n    Thread.report_on_exception = false\n\n    SSHKit::Backend::Abstract.any_instance.stubs(:execute)\n    Dir.stubs(:chdir)\n\n    SSHKit::Backend::Abstract.any_instance.stubs(:execute)\n      .with { |*args| args == [ :mkdir, \"-p\", \".kamal/apps/app\" ] }\n\n    SSHKit::Backend::Abstract.any_instance.stubs(:execute)\n      .with { |*arg| arg[0..1] == [ :mkdir, \".kamal/lock-app\" ] }\n      .raises(SocketError, \"getaddrinfo: nodename nor servname provided, or not known\")\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .with(:git, \"-C\", anything, :\"rev-parse\", :HEAD)\n      .returns(Kamal::Git.revision)\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .with(:git, \"-C\", anything, :status, \"--porcelain\")\n      .returns(\"\")\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .with(:docker, :info, \"--format '{{index .RegistryConfig.Mirrors 0}}'\")\n      .returns(\"\")\n      .at_least_once\n\n    assert_raises(SSHKit::Runner::ExecuteError) do\n      run_command(\"deploy\")\n    end\n  end\n\n  test \"deploy errors during outside section leave remote lock\" do\n    invoke_options = { \"config_file\" => \"test/fixtures/deploy_simple.yml\", \"version\" => \"999\", \"skip_hooks\" => false }\n\n    Kamal::Cli::Main.any_instance.expects(:invoke)\n      .with(\"kamal:cli:build:deliver\", [], invoke_options)\n      .raises(RuntimeError)\n\n    assert_not KAMAL.holding_lock?\n    assert_raises(RuntimeError) do\n      stderred { run_command(\"deploy\") }\n    end\n    assert_not KAMAL.holding_lock?\n  end\n\n  test \"deploy with skipped hooks\" do\n    invoke_options = { \"config_file\" => \"test/fixtures/deploy_simple.yml\", \"version\" => \"999\", \"skip_hooks\" => true }\n\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:build:deliver\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:proxy:boot\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:stale_containers\", [], invoke_options.merge(stop: true))\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:boot\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:prune:all\", [], invoke_options)\n\n    run_command(\"deploy\", \"--skip_hooks\") do\n      assert_no_match /Running the post-deploy hook.../, output\n    end\n  end\n\n  test \"deploy with missing secrets\" do\n    invoke_options = { \"config_file\" => \"test/fixtures/deploy_with_secrets.yml\", \"version\" => \"999\", \"skip_hooks\" => false }\n\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:build:deliver\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:proxy:boot\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:stale_containers\", [], invoke_options.merge(stop: true))\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:boot\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:prune:all\", [], invoke_options)\n\n    run_command(\"deploy\", config_file: \"deploy_with_secrets\")\n  end\n\n  test \"redeploy\" do\n    invoke_options = { \"config_file\" => \"test/fixtures/deploy_simple.yml\", \"version\" => \"999\", \"skip_hooks\" => false, \"verbose\" => true }\n\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:build:deliver\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:stale_containers\", [], invoke_options.merge(stop: true))\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:boot\", [], invoke_options)\n\n    Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)\n\n    run_command(\"redeploy\", \"--verbose\").tap do |output|\n      assert_hook_ran \"pre-connect\", output\n      assert_match /Build and push app image/, output\n      assert_hook_ran \"pre-deploy\", output\n      assert_match /Running \\/usr\\/bin\\/env .kamal\\/hooks\\/pre-deploy /, output\n      assert_hook_ran \"post-deploy\", output\n    end\n  end\n\n  test \"redeploy with skip_push\" do\n    invoke_options = { \"config_file\" => \"test/fixtures/deploy_simple.yml\", \"version\" => \"999\", \"skip_hooks\" => false }\n\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:build:pull\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:stale_containers\", [], invoke_options.merge(stop: true))\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:boot\", [], invoke_options)\n\n    run_command(\"redeploy\", \"--skip_push\").tap do |output|\n      assert_match /Pull app image/, output\n    end\n  end\n\n  test \"redeploy with no_cache\" do\n    invoke_options = { \"config_file\" => \"test/fixtures/deploy_simple.yml\", \"version\" => \"999\", \"skip_hooks\" => false, \"no_cache\" => true }\n\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:build:deliver\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:stale_containers\", [], invoke_options.merge(stop: true))\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:boot\", [], invoke_options)\n\n    run_command(\"redeploy\", \"--no-cache\").tap do |output|\n      assert_match /Build and push app image/, output\n    end\n  end\n\n  test \"rollback bad version\" do\n    Thread.report_on_exception = false\n\n    run_command(\"details\") # Preheat Kamal const\n\n    run_command(\"rollback\", \"nonsense\").tap do |output|\n      assert_match /docker container ls --all --filter 'name=\\^app-web-nonsense\\$' --quiet/, output\n      assert_match /The app version 'nonsense' is not available as a container/, output\n    end\n  end\n\n  test \"rollback good version\" do\n    Object.any_instance.stubs(:sleep)\n    [ \"web\", \"workers\" ].each do |role|\n      SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n        .with(:docker, :container, :ls, \"--all\", \"--filter\", \"'name=^app-#{role}-123$'\", \"--quiet\", raise_on_non_zero_exit: false)\n        .returns(\"\").at_least_once\n      SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n        .with(:docker, :container, :ls, \"--all\", \"--filter\", \"'name=^app-#{role}-123$'\", \"--quiet\")\n        .returns(\"version-to-rollback\\n\").at_least_once\n      SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n        .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)\n        .returns(\"version-to-rollback\\n\").at_least_once\n    end\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .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}}'\")\n      .returns(\"running\").at_least_once # health check\n\n    Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)\n\n    run_command(\"rollback\", \"--verbose\", \"123\", config_file: \"deploy_with_accessories\").tap do |output|\n      assert_hook_ran \"pre-deploy\", output\n      assert_match \"docker tag dhh/app:123 dhh/app:latest\", output\n      assert_match \"docker run --detach --restart unless-stopped --name app-web-123\", output\n      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\"\n      assert_hook_ran \"post-deploy\", output\n    end\n  end\n\n  test \"rollback without old version\" do\n    Kamal::Cli::Main.any_instance.stubs(:container_available?).returns(true)\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .with(:docker, :container, :ls, \"--all\", \"--filter\", \"'name=^app-web-123$'\", \"--quiet\", raise_on_non_zero_exit: false)\n      .returns(\"\").at_least_once\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .with(:docker, :container, :ls, \"--all\", \"--filter\", \"'name=^app-web-123$'\", \"--quiet\")\n      .returns(\"123\").at_least_once\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .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)\n      .returns(\"\").at_least_once\n\n    run_command(\"rollback\", \"123\").tap do |output|\n      assert_match \"docker run --detach --restart unless-stopped --name app-web-123\", output\n      assert_no_match \"docker stop\", output\n    end\n  end\n\n  test \"remove\" do\n    options = { \"config_file\" => \"test/fixtures/deploy_simple.yml\", \"skip_hooks\" => false, \"confirmed\" => true }\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:proxy:remove\", [], options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:remove\", [], options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:accessory:remove\", [ \"all\" ], options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:registry:remove\", [], options.merge(skip_local: true))\n\n    run_command(\"remove\", \"-y\")\n  end\n\n  test \"details\" do\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:proxy:details\")\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:details\")\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:accessory:details\", [ \"all\" ])\n\n    run_command(\"details\")\n  end\n\n  test \"audit\" do\n    run_command(\"audit\").tap do |output|\n      assert_match %r{tail -n 50 \\.kamal/app-audit.log on 1.1.1.1}, output\n      assert_match /App Host: 1.1.1.1/, output\n    end\n  end\n\n  test \"config\" do\n    run_command(\"config\", config_file: \"deploy_simple\").tap do |output|\n      config = YAML.load(output)\n\n      assert_equal [ \"web\" ], config[:roles]\n      assert_equal [ \"1.1.1.1\", \"1.1.1.2\" ], config[:hosts]\n      assert_equal \"999\", config[:version]\n      assert_equal \"dhh/app\", config[:repository]\n      assert_equal \"dhh/app:999\", config[:absolute_image]\n      assert_equal \"app-999\", config[:service_with_version]\n    end\n  end\n\n  test \"config with roles\" do\n    run_command(\"config\", config_file: \"deploy_with_roles\").tap do |output|\n      config = YAML.load(output)\n\n      assert_equal [ \"web\", \"workers\" ], config[:roles]\n      assert_equal [ \"1.1.1.1\", \"1.1.1.2\", \"1.1.1.3\", \"1.1.1.4\" ], config[:hosts]\n      assert_equal \"999\", config[:version]\n      assert_equal \"registry.digitalocean.com/dhh/app\", config[:repository]\n      assert_equal \"registry.digitalocean.com/dhh/app:999\", config[:absolute_image]\n      assert_equal \"app-999\", config[:service_with_version]\n    end\n  end\n\n  test \"config with primary web role override\" do\n    run_command(\"config\", config_file: \"deploy_primary_web_role_override\").tap do |output|\n      config = YAML.load(output)\n\n      assert_equal [ \"web_chicago\", \"web_tokyo\" ], config[:roles]\n      assert_equal [ \"1.1.1.1\", \"1.1.1.2\", \"1.1.1.3\", \"1.1.1.4\" ], config[:hosts]\n      assert_equal \"1.1.1.3\", config[:primary_host]\n    end\n  end\n\n  test \"config with destination\" do\n    run_command(\"config\", \"-d\", \"world\", config_file: \"deploy_for_dest\").tap do |output|\n      config = YAML.load(output)\n\n      assert_equal [ \"web\" ], config[:roles]\n      assert_equal [ \"1.1.1.1\", \"1.1.1.2\" ], config[:hosts]\n      assert_equal \"999\", config[:version]\n      assert_equal \"registry.digitalocean.com/dhh/app\", config[:repository]\n      assert_equal \"registry.digitalocean.com/dhh/app:999\", config[:absolute_image]\n      assert_equal \"app-999\", config[:service_with_version]\n    end\n  end\n\n  test \"config with blank line trimming\" do\n    template = <<~YAML\n      service: app\n      image: dhh/app\n      servers:\n        - \"1.1.1.1\"\n      <% if true -%>\n        - \"1.1.1.2\"\n      <% end -%>\n      registry:\n        username: user\n        password: pw\n      builder:\n        arch: amd64\n    YAML\n\n    expected_rendered = ERB.new(template, trim_mode: \"-\").result\n\n    Dir.mktmpdir do |dir|\n      config_path = File.join(dir, \"deploy.yml\")\n      File.write(config_path, template)\n\n      load_method = YAML.respond_to?(:unsafe_load) ? :unsafe_load : :load\n      original_load = YAML.method(load_method)\n\n      YAML.expects(load_method).with(expected_rendered).returns(original_load.call(expected_rendered))\n\n      run_command_with_config_path(\"config\", config_path: config_path)\n    end\n  end\n\n  test \"config with destination blank line trimming\" do\n    base_template = <<~YAML\n      service: app\n      image: dhh/app\n      servers:\n        - \"1.1.1.1\"\n      registry:\n        username: user\n        password: pw\n      builder:\n        arch: amd64\n    YAML\n\n    destination_template = <<~YAML\n      servers:\n        - \"2.2.2.2\"\n      <% if true -%>\n        - \"2.2.2.3\"\n      <% end -%>\n    YAML\n\n    expected_destination = ERB.new(destination_template, trim_mode: \"-\").result\n\n    Dir.mktmpdir do |dir|\n      base_path = File.join(dir, \"deploy.yml\")\n      File.write(base_path, base_template)\n\n      destination_path = File.join(dir, \"deploy.world.yml\")\n      File.write(destination_path, destination_template)\n\n      load_method = YAML.respond_to?(:unsafe_load) ? :unsafe_load : :load\n      original_load = YAML.method(load_method)\n      load_sequence = sequence(\"config_files\")\n\n      YAML.expects(load_method).with(base_template).in_sequence(load_sequence).returns(original_load.call(base_template))\n      YAML.expects(load_method).with(expected_destination).in_sequence(load_sequence).returns(original_load.call(expected_destination))\n\n      run_command_with_config_path(\"config\", config_path: base_path, destination: \"world\")\n    end\n  end\n\n  test \"init\" do\n    in_dummy_git_repo do\n      run_command(\"init\").tap do |output|\n        assert_match \"Created configuration file in config/deploy.yml\", output\n        assert_match \"Created .kamal/secrets file\", output\n      end\n\n      assert_file \"config/deploy.yml\", \"service: my-app\"\n      assert_file \".kamal/secrets\", \"KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD\"\n    end\n  end\n\n  test \"init with existing config\" do\n    in_dummy_git_repo do\n      run_command(\"init\")\n\n      run_command(\"init\").tap do |output|\n        assert_match /Config file already exists in config\\/deploy.yml \\(remove first to create a new one\\)/, output\n        assert_no_match /Added .kamal\\/secrets/, output\n      end\n    end\n  end\n\n  test \"init with bundle option\" do\n    in_dummy_git_repo do\n      run_command(\"init\", \"--bundle\").tap do |output|\n        assert_match \"Created configuration file in config/deploy.yml\", output\n        assert_match \"Created .kamal/secrets file\", output\n        assert_match /Adding Kamal to Gemfile and bundle/, output\n        assert_match /bundle add kamal/, output\n        assert_match /bundle binstubs kamal/, output\n        assert_match /Created binstub file in bin\\/kamal/, output\n      end\n    end\n  end\n\n  test \"init with bundle option and existing binstub\" do\n    Pathname.any_instance.expects(:exist?).returns(true).times(4)\n    Pathname.any_instance.stubs(:mkpath)\n    FileUtils.stubs(:mkdir_p)\n    FileUtils.stubs(:cp_r)\n    FileUtils.stubs(:cp)\n\n    run_command(\"init\", \"--bundle\").tap do |output|\n      assert_match /Config file already exists in config\\/deploy.yml \\(remove first to create a new one\\)/, output\n      assert_match /Binstub already exists in bin\\/kamal \\(remove first to create a new one\\)/, output\n    end\n  end\n\n  test \"remove with confirmation\" do\n    run_command(\"remove\", \"-y\", config_file: \"deploy_with_accessories\").tap do |output|\n      assert_match /docker container stop kamal-proxy/, output\n      assert_match /docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy/, output\n      assert_match /docker image prune --all --force --filter label=org.opencontainers.image.title=kamal-proxy/, output\n\n      assert_match /docker ps --quiet --filter label=service=app | xargs docker stop/, output\n      assert_match /docker container prune --force --filter label=service=app/, output\n      assert_match /docker image prune --all --force --filter label=service=app/, output\n      assert_match \"/usr/bin/env rm -r .kamal/apps/app\", output\n\n      assert_match /docker container stop app-mysql/, output\n      assert_match /docker container prune --force --filter label=service=app-mysql/, output\n      assert_match /docker image rm --force mysql/, output\n      assert_match /rm -rf app-mysql/, output\n\n      assert_match /docker container stop app-redis/, output\n      assert_match /docker container prune --force --filter label=service=app-redis/, output\n      assert_match /docker image rm --force redis/, output\n      assert_match /rm -rf app-redis/, output\n\n      assert_match /docker logout/, output\n    end\n  end\n\n  test \"docs\" do\n    run_command(\"docs\").tap do |output|\n      assert_match \"# Kamal Configuration\", output\n    end\n  end\n\n  test \"docs subsection\" do\n    run_command(\"docs\", \"accessory\").tap do |output|\n      assert_match \"# Accessories\", output\n    end\n  end\n\n  test \"docs unknown\" do\n    run_command(\"docs\", \"foo\").tap do |output|\n      assert_match \"No documentation found for foo\", output\n    end\n  end\n\n  test \"version\" do\n    version = stdouted { Kamal::Cli::Main.new.version }\n    assert_equal Kamal::VERSION, version\n  end\n\n  test \"run an alias for details\" do\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:proxy:details\")\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:details\")\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:accessory:details\", [ \"all\" ])\n\n    run_command(\"info\", config_file: \"deploy_with_aliases\")\n  end\n\n  test \"run an alias for a console\" do\n    run_command(\"console\", config_file: \"deploy_with_aliases\").tap do |output|\n      assert_no_match \"App Host: 1.1.1.4\", output\n      assert_match \"docker exec app-console-999 bin/console on 1.1.1.5\", output\n      assert_match \"App Host: 1.1.1.5\", output\n    end\n  end\n\n  test \"run an alias for a console overriding role\" do\n    run_command(\"console\", \"-r\", \"workers\", config_file: \"deploy_with_aliases\").tap do |output|\n      assert_match \"docker exec app-workers-999 bin/console on 1.1.1.3\", output\n      assert_match \"App Host: 1.1.1.3\", output\n    end\n  end\n\n  test \"run an alias for a console passing command\" do\n    run_command(\"exec\", \"bin/job\", config_file: \"deploy_with_aliases\").tap do |output|\n      assert_match \"docker exec app-console-999 bin/job on 1.1.1.5\", output\n      assert_match \"App Host: 1.1.1.5\", output\n    end\n  end\n\n  test \"append to command with an alias\" do\n    run_command(\"rails\", \"db:migrate:status\", config_file: \"deploy_with_aliases\").tap do |output|\n      assert_match \"docker exec app-console-999 rails db:migrate:status on 1.1.1.5\", output\n      assert_match \"App Host: 1.1.1.5\", output\n    end\n  end\n\n  test \"switch config file with an alias\" do\n    with_config_files do\n      with_argv([ \"other_config\" ]) do\n        stdouted { Kamal::Cli::Main.start }.tap do |output|\n          assert_match \":service_with_version: app2-999\", output\n        end\n      end\n    end\n  end\n\n  test \"switch destination with an alias\" do\n    with_config_files do\n      with_argv([ \"other_destination_config\" ]) do\n        stdouted { Kamal::Cli::Main.start }.tap do |output|\n          assert_match \":service_with_version: app3-999\", output\n        end\n      end\n    end\n  end\n\n  test \"run an alias with require_destination\" do\n    invoke_options = { \"config_file\" => \"test/fixtures/deploy_for_required_dest.yml\", \"version\" => \"999\", \"skip_hooks\" => false, \"destination\" => \"world\" }\n\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:build:deliver\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:proxy:boot\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:stale_containers\", [], invoke_options.merge(stop: true))\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:app:boot\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:prune:all\", [], invoke_options)\n\n    run_command(\"world_deploy\", config_file: \"deploy_for_required_dest\")\n  end\n\n  test \"run on primary via alias\" do\n    run_command(\"primary_details\", config_file: \"deploy_with_aliases\").tap do |output|\n      assert_match \"App Host: 1.1.1.1\", output\n      assert_no_match \"App Host: 1.1.1.2\", output\n    end\n  end\n\n  test \"upgrade\" do\n    invoke_options = { \"config_file\" => \"test/fixtures/deploy_with_accessories.yml\", \"skip_hooks\" => false, \"confirmed\" => true, \"rolling\" => false }\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:proxy:upgrade\", [], invoke_options)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:accessory:upgrade\", [ \"all\" ], invoke_options)\n\n    run_command(\"upgrade\", \"-y\", config_file: \"deploy_with_accessories\").tap do |output|\n      assert_match \"Upgrading all hosts...\", output\n      assert_match \"Upgraded all hosts\", output\n    end\n  end\n\n  test \"upgrade rolling\" do\n    invoke_options = { \"config_file\" => \"test/fixtures/deploy_with_accessories.yml\", \"skip_hooks\" => false, \"confirmed\" => true, \"rolling\" => false }\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:proxy:upgrade\", [], invoke_options).times(4)\n    Kamal::Cli::Main.any_instance.expects(:invoke).with(\"kamal:cli:accessory:upgrade\", [ \"all\" ], invoke_options).times(3)\n\n    run_command(\"upgrade\", \"--rolling\", \"-y\", config_file: \"deploy_with_accessories\").tap do |output|\n      assert_match \"Upgrading 1.1.1.1...\", output\n      assert_match \"Upgraded 1.1.1.1\", output\n      assert_match \"Upgrading 1.1.1.2...\", output\n      assert_match \"Upgraded 1.1.1.2\", output\n      assert_match \"Upgrading 1.1.1.3...\", output\n      assert_match \"Upgraded 1.1.1.3\", output\n      assert_match \"Upgrading 1.1.1.4...\", output\n      assert_match \"Upgraded 1.1.1.4\", output\n    end\n  end\n\n  private\n    def run_command(*command, config_file: \"deploy_simple\")\n      with_argv([ *command, \"-c\", \"test/fixtures/#{config_file}.yml\" ]) do\n        stdouted { Kamal::Cli::Main.start }\n      end\n    end\n\n    def run_command_with_config_path(*command, config_path:, destination: nil)\n      argv = [ *command ]\n      argv += [ \"-d\", destination ] if destination\n      argv += [ \"-c\", config_path ]\n\n      with_argv([ *argv ]) do\n        stdouted { Kamal::Cli::Main.start }\n      end\n    end\n\n    def in_dummy_git_repo\n      Dir.mktmpdir do |tmpdir|\n        Dir.chdir(tmpdir) do\n          `git init`\n          yield\n        end\n      end\n    end\n\n    def with_config_files\n      Dir.mktmpdir do |tmpdir|\n        config_dir = File.join(tmpdir, \"config\")\n        FileUtils.mkdir_p(config_dir)\n        FileUtils.cp \"test/fixtures/deploy.yml\", config_dir\n        FileUtils.cp \"test/fixtures/deploy2.yml\", config_dir\n        FileUtils.cp \"test/fixtures/deploy.elsewhere.yml\", config_dir\n\n        Dir.chdir(tmpdir) do\n          yield\n        end\n      end\n    end\n\n    def assert_file(file, content)\n      assert_match content, File.read(file)\n    end\n\n    def with_kamal_lock_env\n      ENV[\"KAMAL_LOCK\"] = \"true\"\n      yield\n    ensure\n      ENV.delete(\"KAMAL_LOCK\")\n    end\nend\n"
  },
  {
    "path": "test/cli/proxy_test.rb",
    "content": "require_relative \"cli_test_case\"\n\nclass CliProxyTest < CliTestCase\n  test \"boot\" do\n    run_command(\"boot\").tap do |output|\n      assert_match \"docker login\", output\n      assert_match \"mkdir -p .kamal/proxy/apps-config\", output\n      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\n    end\n  end\n\n  test \"boot with run config\" do\n    run_command(\"boot\", fixture: :with_proxy_run_config).tap do |output|\n      assert_match \"docker login\", output\n      assert_match \"mkdir -p .kamal/proxy/apps-config\", output\n      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\n      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\n    end\n  end\n\n  test \"boot with run config conflicts\" do\n    assert_raises Kamal::ConfigurationError, \"Conflicting proxy run configurations for host 1.1.1.2\" do\n      run_command(\"boot\", fixture: :with_proxy_run_config_conflicts)\n    end\n  end\n\n  test \"boot old version\" do\n    Thread.report_on_exception = false\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .with(:docker, :inspect, \"kamal-proxy\", \"--format '{{.Config.Image}}'\", \"|\", :awk, \"-F:\", \"'{print $NF}'\")\n      .returns(\"v0.0.1\")\n      .at_least_once\n\n    exception = assert_raises do\n      run_command(\"boot\").tap do |output|\n        assert_match \"docker login\", output\n        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\n      end\n    end\n\n    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}\"\n  ensure\n    Thread.report_on_exception = false\n  end\n\n  test \"boot correct version\" do\n    Thread.report_on_exception = false\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .with(:docker, :inspect, \"kamal-proxy\", \"--format '{{.Config.Image}}'\", \"|\", :awk, \"-F:\", \"'{print $NF}'\")\n      .returns(Kamal::Configuration::Proxy::Run::MINIMUM_VERSION)\n      .at_least_once\n\n    run_command(\"boot\").tap do |output|\n      assert_match \"docker login\", output\n      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\n    end\n  ensure\n    Thread.report_on_exception = false\n  end\n\n  test \"reboot\" do\n    run_command(\"reboot\", \"-y\").tap do |output|\n      assert_match \"docker container stop kamal-proxy on 1.1.1.1\", output\n      assert_match \"docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy on 1.1.1.1\", output\n      assert_match \"mkdir -p .kamal/proxy/apps-config on 1.1.1.1\", output\n      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\n\n      assert_match \"docker container stop kamal-proxy on 1.1.1.2\", output\n      assert_match \"docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy on 1.1.1.2\", output\n      assert_match \"mkdir -p .kamal/proxy/apps-config on 1.1.1.1\", output\n      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\n    end\n  end\n\n  test \"reboot --rolling\" do\n    run_command(\"reboot\", \"--rolling\", \"-y\").tap do |output|\n      assert_match \"Running docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy on 1.1.1.1\", output\n    end\n  end\n\n  test \"start\" do\n    run_command(\"start\").tap do |output|\n      assert_match \"docker container start kamal-proxy\", output\n    end\n  end\n\n  test \"stop\" do\n    run_command(\"stop\").tap do |output|\n      assert_match \"docker container stop kamal-proxy\", output\n    end\n  end\n\n  test \"restart\" do\n    Kamal::Cli::Proxy.any_instance.expects(:stop)\n    Kamal::Cli::Proxy.any_instance.expects(:start)\n\n    run_command(\"restart\")\n  end\n\n  test \"details\" do\n    run_command(\"details\").tap do |output|\n      assert_match \"docker ps --filter 'name=^kamal-proxy$'\", output\n    end\n  end\n\n  test \"logs\" do\n    SSHKit::Backend::Abstract.any_instance.stubs(:capture)\n      .with(:docker, :logs, \"kamal-proxy\", \"--tail 100\", \"--timestamps\", \"2>&1\")\n      .returns(\"Log entry\")\n\n    SSHKit::Backend::Abstract.any_instance.stubs(:capture)\n      .with(:docker, :logs, \"proxy\", \"--tail 100\", \"--timestamps\", \"2>&1\")\n      .returns(\"Log entry\")\n\n    run_command(\"logs\").tap do |output|\n      assert_match \"Proxy Host: 1.1.1.1\", output\n      assert_match \"Log entry\", output\n    end\n  end\n\n  test \"logs with follow\" do\n    SSHKit::Backend::Abstract.any_instance.stubs(:exec)\n      .with(\"ssh -t root@1.1.1.1 -p 22 'docker logs kamal-proxy --timestamps --tail 10 --follow 2>&1'\")\n\n    assert_match \"docker logs kamal-proxy --timestamps --tail 10 --follow\", run_command(\"logs\", \"--follow\")\n  end\n\n  test \"remove\" do\n    run_command(\"remove\").tap do |output|\n      assert_match \"/usr/bin/env ls .kamal/apps | wc -l\", output\n      assert_match \"docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy\", output\n      assert_match \"docker image prune --all --force --filter label=org.opencontainers.image.title=kamal-proxy\", output\n    end\n  end\n\n  test \"remove with other apps\" do\n    Thread.report_on_exception = false\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info).with(:ls, \".kamal/apps\", \"|\", :wc, \"-l\").returns(\"1\\n\").twice\n\n    run_command(\"remove\").tap do |output|\n      assert_match \"Not removing the proxy, as other apps are installed, ignore this check with kamal proxy remove --force\", output\n    end\n  ensure\n    Thread.report_on_exception = true\n  end\n\n  test \"force remove with other apps\" do\n    Thread.report_on_exception = false\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info).with(:ls, \".kamal/apps\", \"|\", :wc, \"-l\").returns(\"1\\n\").twice\n\n    run_command(\"remove\").tap do |output|\n      assert_match \"Not removing the proxy, as other apps are installed, ignore this check with kamal proxy remove --force\", output\n    end\n  ensure\n    Thread.report_on_exception = true\n  end\n\n  test \"remove_container\" do\n    run_command(\"remove_container\").tap do |output|\n      assert_match \"docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy\", output\n    end\n  end\n\n  test \"remove_image\" do\n    run_command(\"remove_image\").tap do |output|\n      assert_match \"docker image prune --all --force --filter label=org.opencontainers.image.title=kamal-proxy\", output\n    end\n  end\n\n  test \"upgrade\" do\n    Object.any_instance.stubs(:sleep)\n\n    SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns(\"12345678\")\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .with(:docker, :inspect, \"kamal-proxy\", \"--format '{{.Config.Image}}'\", \"|\", :awk, \"-F:\", \"'{print $NF}'\")\n      .returns(Kamal::Configuration::Proxy::Run::MINIMUM_VERSION)\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .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}}'\")\n      .returns(\"running\").at_least_once # workers health check\n\n    run_command(\"upgrade\", \"-y\").tap do |output|\n      assert_match \"Upgrading proxy on 1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4...\", output\n      assert_match \"docker login -u [REDACTED] -p [REDACTED]\", output\n      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\n      assert_match \"docker container stop kamal-proxy\", output\n      assert_match \"docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy\", output\n      assert_match \"docker image prune --all --force --filter label=org.opencontainers.image.title=kamal-proxy\", output\n      assert_match \"/usr/bin/env mkdir -p .kamal\", output\n      assert_match \"docker network create kamal\", output\n      assert_match \"docker login -u [REDACTED] -p [REDACTED]\", output\n      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\n      assert_match \"/usr/bin/env mkdir -p .kamal\", output\n      assert_match %r{docker rename app-web-latest app-web-latest_replaced_.*}, output\n      assert_match \"/usr/bin/env mkdir -p .kamal/apps/app/env/roles\", output\n      assert_match \"Uploading \\\"\\\\n\\\" to .kamal/apps/app/env/roles/web.env\", output\n      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\n      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\n      assert_match \"docker container ls --all --filter 'name=^app-web-12345678$' --quiet | xargs docker stop\", output\n      assert_match \"docker tag dhh/app:latest dhh/app:latest\", output\n      assert_match \"/usr/bin/env mkdir -p .kamal\", output\n      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\n      assert_match \"docker image prune --force --filter label=service=app\", output\n      assert_match \"Upgraded proxy on 1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4\", output\n    end\n  end\n\n  test \"upgrade rolling\" do\n    Object.any_instance.stubs(:sleep)\n\n    SSHKit::Backend::Abstract.any_instance.stubs(:capture_with_info).returns(\"12345678\")\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .with(:docker, :inspect, \"kamal-proxy\", \"--format '{{.Config.Image}}'\", \"|\", :awk, \"-F:\", \"'{print $NF}'\")\n      .returns(Kamal::Configuration::Proxy::Run::MINIMUM_VERSION)\n\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .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}}'\")\n      .returns(\"running\").at_least_once # workers health check\n\n    run_command(\"upgrade\", \"--rolling\", \"-y\",).tap do |output|\n      %w[1.1.1.1 1.1.1.2 1.1.1.3 1.1.1.4].each do |host|\n        assert_match \"Upgrading proxy on #{host}...\", output\n        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\n        assert_match \"Upgraded proxy on #{host}\", output\n      end\n    end\n  end\n\n  test \"boot_config set\" do\n    run_command(\"boot_config\", \"set\").tap do |output|\n      %w[ 1.1.1.1 1.1.1.2 ].each do |host|\n        assert_match \"Running /usr/bin/env mkdir -p .kamal/proxy on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/options on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/image on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/image_version on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/run_command on #{host}\", output\n      end\n    end\n  end\n\n  test \"boot_config set no publish\" do\n    run_command(\"boot_config\", \"set\", \"--publish\", \"false\").tap do |output|\n      %w[ 1.1.1.1 1.1.1.2 ].each do |host|\n        assert_match \"Running /usr/bin/env mkdir -p .kamal/proxy on #{host}\", output\n        assert_match \"Uploading \\\"--log-opt max-size=10m\\\" to .kamal/proxy/options on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/image on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/image_version on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/run_command on #{host}\", output\n      end\n    end\n  end\n\n  test \"boot_config set custom max_size\" do\n    run_command(\"boot_config\", \"set\", \"--log-max-size\", \"100m\").tap do |output|\n      %w[ 1.1.1.1 1.1.1.2 ].each do |host|\n        assert_match \"Running /usr/bin/env mkdir -p .kamal/proxy on #{host}\", output\n        assert_match \"Uploading \\\"--publish 80:80 --publish 443:443 --log-opt max-size=100m\\\" to .kamal/proxy/options on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/image on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/image_version on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/run_command on #{host}\", output\n      end\n    end\n  end\n\n  test \"boot_config set no log max size\" do\n    run_command(\"boot_config\", \"set\", \"--log-max-size=\").tap do |output|\n      %w[ 1.1.1.1 1.1.1.2 ].each do |host|\n        assert_match \"Running /usr/bin/env mkdir -p .kamal/proxy on #{host}\", output\n        assert_match \"Uploading \\\"--publish 80:80 --publish 443:443\\\" to .kamal/proxy/options on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/image on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/image_version on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/run_command on #{host}\", output\n      end\n    end\n  end\n\n  test \"boot_config set custom ports\" do\n    run_command(\"boot_config\", \"set\", \"--http-port\", \"8080\", \"--https-port\", \"8443\").tap do |output|\n      %w[ 1.1.1.1 1.1.1.2 ].each do |host|\n        assert_match \"Running /usr/bin/env mkdir -p .kamal/proxy on #{host}\", output\n        assert_match \"Uploading \\\"--publish 8080:80 --publish 8443:443 --log-opt max-size=10m\\\" to .kamal/proxy/options on #{host}\", output\n      end\n    end\n  end\n\n  test \"boot_config set bind IP\" do\n    run_command(\"boot_config\", \"set\", \"--publish-host-ip\", \"127.0.0.1\").tap do |output|\n      %w[ 1.1.1.1 1.1.1.2 ].each do |host|\n        assert_match \"Running /usr/bin/env mkdir -p .kamal/proxy on #{host}\", output\n        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\n      end\n    end\n  end\n\n  test \"boot_config set multiple bind IPs\" do\n    run_command(\"boot_config\", \"set\", \"--publish-host-ip\", \"127.0.0.1\", \"--publish-host-ip\", \"::1\").tap do |output|\n      %w[ 1.1.1.1 1.1.1.2 ].each do |host|\n        assert_match \"Running /usr/bin/env mkdir -p .kamal/proxy on #{host}\", output\n        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\n      end\n    end\n  end\n\n  test \"boot_config set invalid bind IPs\" do\n    exception = assert_raises do\n      run_command(\"boot_config\", \"set\", \"--publish-host-ip\", \"1.2.3.invalidIP\", \"--publish-host-ip\", \"::1\")\n    end\n\n    assert_includes exception.message, \"Invalid publish IP address: 1.2.3.invalidIP\"\n  end\n\n  test \"boot_config set docker options\" do\n    run_command(\"boot_config\", \"set\", \"--docker_options\", \"label=foo=bar\", \"add_host=thishost:thathost\").tap do |output|\n      %w[ 1.1.1.1 1.1.1.2 ].each do |host|\n        assert_match \"Running /usr/bin/env mkdir -p .kamal/proxy on #{host}\", output\n        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\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/image on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/image_version on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/run_command on #{host}\", output\n      end\n    end\n  end\n\n  test \"boot_config set registry\" do\n    run_command(\"boot_config\", \"set\", \"--registry\", \"myreg\").tap do |output|\n      %w[ 1.1.1.1 1.1.1.2 ].each do |host|\n        assert_match \"Running /usr/bin/env mkdir -p .kamal/proxy on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/options on #{host}\", output\n        assert_match \"Uploading \\\"myreg/basecamp/kamal-proxy\\\" to .kamal/proxy/image on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/image_version on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/run_command on #{host}\", output\n      end\n    end\n  end\n\n  test \"boot_config set repository\" do\n    run_command(\"boot_config\", \"set\", \"--repository\", \"myrepo\").tap do |output|\n      %w[ 1.1.1.1 1.1.1.2 ].each do |host|\n        assert_match \"Running /usr/bin/env mkdir -p .kamal/proxy on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/options on #{host}\", output\n        assert_match \"Uploading \\\"myrepo/kamal-proxy\\\" to .kamal/proxy/image on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/image_version on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/run_command on #{host}\", output\n      end\n    end\n  end\n\n  test \"boot_config set image_version\" do\n    run_command(\"boot_config\", \"set\", \"--image_version\", \"0.9.9\").tap do |output|\n      %w[ 1.1.1.1 1.1.1.2 ].each do |host|\n        assert_match \"Running /usr/bin/env mkdir -p .kamal/proxy on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/options on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/image on #{host}\", output\n        assert_match \"Uploading \\\"0.9.9\\\" to .kamal/proxy/image_version on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/run_command on #{host}\", output\n      end\n    end\n  end\n\n  test \"boot_config set run_command\" do\n    run_command(\"boot_config\", \"set\", \"--metrics_port\", \"9000\", \"--debug\", \"true\").tap do |output|\n      %w[ 1.1.1.1 1.1.1.2 ].each do |host|\n        assert_match \"Running /usr/bin/env mkdir -p .kamal/proxy on #{host}\", output\n        assert_match \"Uploading \\\"--publish 80:80 --publish 443:443 --log-opt max-size=10m --expose=9000\\\" to .kamal/proxy/options on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/image on #{host}\", output\n        assert_match \"Running /usr/bin/env rm .kamal/proxy/image_version on #{host}\", output\n        assert_match \"Uploading \\\"kamal-proxy run --debug --metrics-port \\\\\\\"9000\\\\\\\"\\\" to .kamal/proxy/run_command on #{host}\", output\n      end\n    end\n  end\n\n  test \"boot_config set all\" do\n    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|\n      %w[ 1.1.1.1 1.1.1.2 ].each do |host|\n        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\n        assert_match \"Uploading \\\"myreg/myrepo/kamal-proxy\\\" to .kamal/proxy/image on #{host}\", output\n        assert_match \"Uploading \\\"0.9.9\\\" to .kamal/proxy/image_version on #{host}\", output\n        assert_match \"Uploading \\\"kamal-proxy run --debug --metrics-port \\\\\\\"9000\\\\\\\"\\\" to .kamal/proxy/run_command on #{host}\", output\n      end\n    end\n  end\n\n  test \"boot_config get\" do\n    SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)\n      .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 \\\"\\\")\")\n      .returns(\"--publish 80:80 --publish 8443:443 --label=foo=bar basecamp/kamal-proxy:v1.0.0\")\n      .twice\n\n    run_command(\"boot_config\", \"get\").tap do |output|\n      assert_match \"Host 1.1.1.1: --publish 80:80 --publish 8443:443 --label=foo=bar basecamp/kamal-proxy:v1.0.0\", output\n      assert_match \"Host 1.1.1.2: --publish 80:80 --publish 8443:443 --label=foo=bar basecamp/kamal-proxy:v1.0.0\", output\n    end\n  end\n\n  test \"boot_config reset\" do\n    run_command(\"boot_config\", \"reset\").tap do |output|\n      %w[ 1.1.1.1 1.1.1.2 ].each do |host|\n        assert_match \"rm .kamal/proxy/options on #{host}\", output\n      end\n    end\n  end\n\n  private\n    def run_command(*command, fixture: :with_proxy)\n      stdouted { Kamal::Cli::Proxy.start([ *command, \"-c\", \"test/fixtures/deploy_#{fixture}.yml\" ]) }\n    end\nend\n"
  },
  {
    "path": "test/cli/prune_test.rb",
    "content": "require_relative \"cli_test_case\"\n\nclass CliPruneTest < CliTestCase\n  test \"all\" do\n    Kamal::Cli::Prune.any_instance.expects(:containers)\n    Kamal::Cli::Prune.any_instance.expects(:images)\n\n    run_command(\"all\")\n  end\n\n  test \"images\" do\n    run_command(\"images\").tap do |output|\n      assert_match \"docker image prune --force --filter label=service=app on 1.1.1.\", output\n      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:<none>\\\" | while read image tag; do docker rmi $tag; done on 1.1.1.\", output\n    end\n  end\n\n  test \"containers\" do\n    run_command(\"containers\").tap do |output|\n      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\n     end\n\n    run_command(\"containers\", \"--retain\", \"10\").tap do |output|\n      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\n    end\n\n    assert_raises(RuntimeError, \"retain must be at least 1\") do\n      run_command(\"containers\", \"--retain\", \"0\")\n    end\n  end\n\n  private\n    def run_command(*command)\n      stdouted { Kamal::Cli::Prune.start([ *command, \"-c\", \"test/fixtures/deploy_with_accessories.yml\" ]) }\n    end\nend\n"
  },
  {
    "path": "test/cli/registry_test.rb",
    "content": "require_relative \"cli_test_case\"\n\nclass CliRegistryTest < CliTestCase\n  test \"setup\" do\n    run_command(\"setup\").tap do |output|\n      assert_match /docker login -u \\[REDACTED\\] -p \\[REDACTED\\] as .*@localhost/, output\n      assert_match /docker login -u \\[REDACTED\\] -p \\[REDACTED\\] on 1.1.1.\\d/, output\n    end\n  end\n\n  test \"setup skip local\" do\n    run_command(\"setup\", \"-L\").tap do |output|\n      assert_no_match /docker login -u \\[REDACTED\\] -p \\[REDACTED\\] as .*@localhost/, output\n      assert_match /docker login -u \\[REDACTED\\] -p \\[REDACTED\\] on 1.1.1.\\d/, output\n    end\n  end\n\n  test \"setup skip remote\" do\n    run_command(\"setup\", \"-R\").tap do |output|\n      assert_match /docker login -u \\[REDACTED\\] -p \\[REDACTED\\] as .*@localhost/, output\n      assert_no_match /docker login -u \\[REDACTED\\] -p \\[REDACTED\\] on 1.1.1.\\d/, output\n    end\n  end\n\n  test \"remove\" do\n    run_command(\"remove\").tap do |output|\n      assert_match /docker logout as .*@localhost/, output\n      assert_match /docker logout on 1.1.1.\\d/, output\n    end\n  end\n\n  test \"remove skip local\" do\n    run_command(\"remove\", \"-L\").tap do |output|\n      assert_no_match /docker logout as .*@localhost/, output\n      assert_match /docker logout on 1.1.1.\\d/, output\n    end\n  end\n\n  test \"remove skip remote\" do\n    run_command(\"remove\", \"-R\").tap do |output|\n      assert_match /docker logout as .*@localhost/, output\n      assert_no_match /docker logout on 1.1.1.\\d/, output\n    end\n  end\n\n  test \"setup with no docker\" do\n    stub_setup\n    SSHKit::Backend::Abstract.any_instance.stubs(:execute)\n      .with(:docker, \"--version\", \"&&\", :docker, :buildx, \"version\")\n      .raises(SSHKit::Command::Failed.new(\"command not found\"))\n\n    assert_raises(Kamal::Cli::DependencyError) { run_command(\"setup\") }\n  end\n\n  test \"allow remote login with no docker\" do\n    stub_setup\n    SSHKit::Backend::Abstract.any_instance.stubs(:execute)\n      .with(:docker, \"--version\", \"&&\", :docker, :buildx, \"version\")\n      .raises(SSHKit::Command::Failed.new(\"command not found\"))\n\n    SSHKit::Backend::Abstract.any_instance.stubs(:execute)\n      .with { |*args| args[0..1] == [ :docker, :login ] }\n\n    assert_nothing_raised { run_command(\"setup\", \"--skip-local\") }\n  end\n\n  test \"setup local registry\" do\n    run_command(\"setup\", fixture: :with_local_registry).tap do |output|\n      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\n    end\n  end\n\n  test \"remove local registry\" do\n    run_command(\"remove\", fixture: :with_local_registry).tap do |output|\n      assert_match /docker stop kamal-docker-registry && docker rm kamal-docker-registry as .*@localhost/, output\n    end\n  end\n\n  test \"login\" do\n    run_command(\"login\").tap do |output|\n      assert_match /docker login -u \\[REDACTED\\] -p \\[REDACTED\\] as .*@localhost/, output\n      assert_match /docker login -u \\[REDACTED\\] -p \\[REDACTED\\] on 1.1.1.\\d/, output\n    end\n  end\n\n  test \"login skip local\" do\n    run_command(\"login\", \"-L\").tap do |output|\n      assert_no_match /docker login -u \\[REDACTED\\] -p \\[REDACTED\\] as .*@localhost/, output\n      assert_match /docker login -u \\[REDACTED\\] -p \\[REDACTED\\] on 1.1.1.\\d/, output\n    end\n  end\n\n  test \"login skip remote\" do\n    run_command(\"login\", \"-R\").tap do |output|\n      assert_match /docker login -u \\[REDACTED\\] -p \\[REDACTED\\] as .*@localhost/, output\n      assert_no_match /docker login -u \\[REDACTED\\] -p \\[REDACTED\\] on 1.1.1.\\d/, output\n    end\n  end\n\n  test \"logout\" do\n    run_command(\"logout\").tap do |output|\n      assert_match /docker logout as .*@localhost/, output\n      assert_match /docker logout on 1.1.1.\\d/, output\n    end\n  end\n\n  test \"logout skip local\" do\n    run_command(\"logout\", \"-L\").tap do |output|\n      assert_no_match /docker logout as .*@localhost/, output\n      assert_match /docker logout on 1.1.1.\\d/, output\n    end\n  end\n\n  test \"logout skip remote\" do\n    run_command(\"logout\", \"-R\").tap do |output|\n      assert_match /docker logout as .*@localhost/, output\n      assert_no_match /docker logout on 1.1.1.\\d/, output\n    end\n  end\n\n  test \"login with local registry raises error\" do\n    error = assert_raises(RuntimeError) do\n      run_command(\"login\", fixture: :with_local_registry)\n    end\n    assert_match /Cannot use login command with a local registry. Use `kamal registry setup` instead./, error.message\n  end\n\n  test \"logout with local registry raises error\" do\n    error = assert_raises(RuntimeError) do\n      run_command(\"logout\", fixture: :with_local_registry)\n    end\n    assert_match /Cannot use logout command with a local registry. Use `kamal registry remove` instead./, error.message\n  end\n\n  private\n    def run_command(*command, fixture: :with_accessories)\n      stdouted { Kamal::Cli::Registry.start([ *command, \"-c\", \"test/fixtures/deploy_#{fixture}.yml\" ]) }\n    end\nend\n"
  },
  {
    "path": "test/cli/secrets_test.rb",
    "content": "require_relative \"cli_test_case\"\n\nclass CliSecretsTest < CliTestCase\n  test \"fetch\" do\n    assert_equal \\\n      '{\"foo\":\"oof\",\"bar\":\"rab\",\"baz\":\"zab\"}',\n      run_command(\"fetch\", \"foo\", \"bar\", \"baz\", \"--account\", \"myaccount\", \"--adapter\", \"test\")\n  end\n\n  test \"fetch missing --acount\" do\n    assert_equal \\\n      \"No value provided for required options '--account'\",\n      run_command(\"fetch\", \"foo\", \"bar\", \"baz\", \"--adapter\", \"test\")\n  end\n\n  test \"extract\" do\n    assert_equal \"oof\", run_command(\"extract\", \"foo\", \"{\\\"foo\\\":\\\"oof\\\", \\\"bar\\\":\\\"rab\\\", \\\"baz\\\":\\\"zab\\\"}\")\n  end\n\n  test \"extract match from end\" do\n    assert_equal \"oof\", run_command(\"extract\", \"foo\", \"{\\\"abc/foo\\\":\\\"oof\\\", \\\"bar\\\":\\\"rab\\\", \\\"baz\\\":\\\"zab\\\"}\")\n  end\n\n  test \"print\" do\n    with_test_secrets(\"secrets\" => \"SECRET1=ABC\\nSECRET2=${SECRET1}DEF\\n\") do\n      assert_equal \"SECRET1=ABC\\nSECRET2=ABCDEF\", run_command(\"print\")\n    end\n  end\n\n  private\n    def run_command(*command)\n      stdouted { Kamal::Cli::Secrets.start([ *command, \"-c\", \"test/fixtures/deploy_with_accessories.yml\" ]) }\n    end\nend\n"
  },
  {
    "path": "test/cli/server_test.rb",
    "content": "require_relative \"cli_test_case\"\n\nclass CliServerTest < CliTestCase\n  test \"running a command with exec\" do\n    SSHKit::Backend::Abstract.any_instance.stubs(:capture)\n      .with(\"date\", verbosity: 1)\n      .returns(\"Today\")\n\n    hosts = \"1.1.1.1\"..\"1.1.1.4\"\n    run_command(\"exec\", \"date\").tap do |output|\n      hosts.map do |host|\n        assert_match \"Running 'date' on #{hosts.to_a.join(', ')}...\", output\n        assert_match \"App Host: #{host}\\nToday\", output\n      end\n    end\n  end\n\n  test \"running a command with exec multiple arguments\" do\n    SSHKit::Backend::Abstract.any_instance.stubs(:capture)\n      .with(\"date -j\", verbosity: 1)\n      .returns(\"Today\")\n\n    hosts = \"1.1.1.1\"..\"1.1.1.4\"\n    run_command(\"exec\", \"date\", \"-j\").tap do |output|\n      hosts.map do |host|\n        assert_match \"Running 'date -j' on #{hosts.to_a.join(', ')}...\", output\n        assert_match \"App Host: #{host}\\nToday\", output\n      end\n    end\n  end\n\n  test \"bootstrap already installed\" do\n    stub_setup\n    SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, \"-v\", raise_on_non_zero_exit: false).returns(true).at_least_once\n    SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:mkdir, \"-p\", \".kamal\").returns(\"\").at_least_once\n\n    assert_equal \"Acquiring the deploy lock...\\nReleasing the deploy lock...\", run_command(\"bootstrap\")\n  end\n\n  test \"bootstrap install as non-root user\" do\n    stub_setup\n    SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, \"-v\", raise_on_non_zero_exit: false).returns(false).at_least_once\n    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\n    SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:mkdir, \"-p\", \".kamal\").returns(\"\").at_least_once\n\n    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\n      run_command(\"bootstrap\")\n    end\n  end\n\n  test \"bootstrap install as root user\" do\n    stub_setup\n    SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, \"-v\", raise_on_non_zero_exit: false).returns(false).at_least_once\n    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\n    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\n    SSHKit::Backend::Abstract.any_instance.expects(:execute).with('[ \"${EUID:-$(id -u)}\" -eq 0 ]', raise_on_non_zero_exit: false).returns(true).at_least_once\n    SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:mkdir, \"-p\", \".kamal\").returns(\"\").at_least_once\n    Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)\n    SSHKit::Backend::Abstract.any_instance.expects(:execute).with(\".kamal/hooks/pre-connect\", anything).at_least_once\n    SSHKit::Backend::Abstract.any_instance.expects(:execute).with(\".kamal/hooks/docker-setup\", anything).at_least_once\n\n    run_command(\"bootstrap\").tap do |output|\n      (\"1.1.1.1\"..\"1.1.1.4\").map do |host|\n        assert_match \"Missing Docker on #{host}. Installing…\", output\n      end\n    end\n  end\n\n  test \"bootstrap install as sudo non-root user\" do\n    stub_setup\n    SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:docker, \"-v\", raise_on_non_zero_exit: false).returns(false).at_least_once\n    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\n    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\n    SSHKit::Backend::Abstract.any_instance.expects(:execute).with('[ \"${EUID:-$(id -u)}\" -eq 0 ]', raise_on_non_zero_exit: false).returns(false).at_least_once\n    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\n    SSHKit::Backend::Abstract.any_instance.expects(:execute).with('sudo -n usermod -aG docker \"${USER:-$(id -un)}\"').at_least_once\n    SSHKit::Backend::Abstract.any_instance.expects(:execute).with(\"kill -HUP $PPID\").at_least_once.raises(IOError, \"closed stream\")\n    SSHKit::Backend::Abstract.any_instance.expects(:execute).with(:mkdir, \"-p\", \".kamal\").returns(\"\").at_least_once\n    Kamal::Commands::Hook.any_instance.stubs(:hook_exists?).returns(true)\n    SSHKit::Backend::Abstract.any_instance.expects(:execute).with(\".kamal/hooks/pre-connect\", anything).at_least_once\n    SSHKit::Backend::Abstract.any_instance.expects(:execute).with(\".kamal/hooks/docker-setup\", anything).at_least_once\n\n    run_command(\"bootstrap\").tap do |output|\n      (\"1.1.1.1\"..\"1.1.1.4\").map do |host|\n        assert_match \"Missing Docker on #{host}. Installing…\", output\n        assert_match \"Session refreshed due to group change.\", output\n      end\n    end\n  end\n\n  private\n    def run_command(*command)\n      stdouted { Kamal::Cli::Server.start([ *command, \"-c\", \"test/fixtures/deploy_with_accessories.yml\" ]) }\n    end\nend\n"
  },
  {
    "path": "test/commander_test.rb",
    "content": "require \"test_helper\"\n\nclass CommanderTest < ActiveSupport::TestCase\n  setup do\n    configure_with(:deploy_with_roles)\n  end\n\n  test \"lazy configuration\" do\n    assert_equal Kamal::Configuration, @kamal.config.class\n  end\n\n  test \"overwriting hosts\" do\n    assert_equal [ \"1.1.1.1\", \"1.1.1.2\", \"1.1.1.3\", \"1.1.1.4\" ], @kamal.hosts\n\n    @kamal.specific_hosts = [ \"1.1.1.1\", \"1.1.1.2\" ]\n    assert_equal [ \"1.1.1.1\", \"1.1.1.2\" ], @kamal.hosts\n\n    @kamal.specific_hosts = [ \"1.1.1.1*\" ]\n    assert_equal [ \"1.1.1.1\" ], @kamal.hosts\n\n    @kamal.specific_hosts = [ \"1.1.1.*\", \"*.1.2.*\" ]\n    assert_equal [ \"1.1.1.1\", \"1.1.1.2\", \"1.1.1.3\", \"1.1.1.4\" ], @kamal.hosts\n\n    @kamal.specific_hosts = [ \"*\" ]\n    assert_equal [ \"1.1.1.1\", \"1.1.1.2\", \"1.1.1.3\", \"1.1.1.4\" ], @kamal.hosts\n\n    @kamal.specific_hosts = [ \"1.1.1.[12]\" ]\n    assert_equal [ \"1.1.1.1\", \"1.1.1.2\" ], @kamal.hosts\n\n    exception = assert_raises(ArgumentError) do\n      @kamal.specific_hosts = [ \"*miss\" ]\n    end\n    assert_match /hosts match for \\*miss/, exception.message\n  end\n\n  test \"filtering hosts by filtering roles\" do\n    assert_equal [ \"1.1.1.1\", \"1.1.1.2\", \"1.1.1.3\", \"1.1.1.4\" ], @kamal.hosts\n\n    @kamal.specific_roles = [ \"web\" ]\n    assert_equal [ \"1.1.1.1\", \"1.1.1.2\" ], @kamal.hosts\n\n    exception = assert_raises(ArgumentError) do\n      @kamal.specific_roles = [ \"*miss\" ]\n    end\n    assert_match /roles match for \\*miss/, exception.message\n  end\n\n  test \"filtering roles\" do\n    assert_equal [ \"web\", \"workers\" ], @kamal.roles.map(&:name)\n\n    @kamal.specific_roles = [ \"workers\" ]\n    assert_equal [ \"workers\" ], @kamal.roles.map(&:name)\n\n    @kamal.specific_roles = [ \"w*\" ]\n    assert_equal [ \"web\", \"workers\" ], @kamal.roles.map(&:name)\n\n    @kamal.specific_roles = [ \"we*\", \"*orkers\" ]\n    assert_equal [ \"web\", \"workers\" ], @kamal.roles.map(&:name)\n\n    @kamal.specific_roles = [ \"*\" ]\n    assert_equal [ \"web\", \"workers\" ], @kamal.roles.map(&:name)\n\n    @kamal.specific_roles = [ \"w{eb,orkers}\" ]\n    assert_equal [ \"web\", \"workers\" ], @kamal.roles.map(&:name)\n\n    exception = assert_raises(ArgumentError) do\n      @kamal.specific_roles = [ \"*miss\" ]\n    end\n    assert_match /roles match for \\*miss/, exception.message\n  end\n\n  test \"filtering roles by filtering hosts\" do\n    assert_equal [ \"web\", \"workers\" ], @kamal.roles.map(&:name)\n\n    @kamal.specific_hosts = [ \"1.1.1.3\" ]\n    assert_equal [ \"workers\" ], @kamal.roles.map(&:name)\n  end\n\n  test \"overwriting hosts with primary\" do\n    assert_equal [ \"1.1.1.1\", \"1.1.1.2\", \"1.1.1.3\", \"1.1.1.4\" ], @kamal.hosts\n\n    @kamal.specific_primary!\n    assert_equal [ \"1.1.1.1\" ], @kamal.hosts\n  end\n\n  test \"primary_host with specific hosts via role\" do\n    @kamal.specific_roles = \"workers\"\n    assert_equal \"1.1.1.3\", @kamal.primary_host\n  end\n\n  test \"primary_role\" do\n    assert_equal \"web\", @kamal.primary_role.name\n    @kamal.specific_roles = \"workers\"\n    assert_equal \"workers\", @kamal.primary_role.name\n  end\n\n  test \"roles_on\" do\n    assert_equal [ \"web\" ], @kamal.roles_on(\"1.1.1.1\").map(&:name)\n    assert_equal [ \"workers\" ], @kamal.roles_on(\"1.1.1.3\").map(&:name)\n  end\n\n  test \"roles_on web comes first\" do\n    configure_with(:deploy_with_two_roles_one_host)\n    assert_equal [ \"web\", \"workers\" ], @kamal.roles_on(\"1.1.1.1\").map(&:name)\n  end\n\n  test \"try to match the primary role from a list of specific roles\" do\n    configure_with(:deploy_primary_web_role_override)\n\n    @kamal.specific_roles = [ \"web_*\" ]\n    assert_equal [ \"web_tokyo\", \"web_chicago\" ], @kamal.roles.map(&:name)\n    assert_equal \"web_tokyo\", @kamal.primary_role.name\n    assert_equal \"1.1.1.3\", @kamal.primary_host\n    assert_equal [ \"1.1.1.3\", \"1.1.1.4\", \"1.1.1.1\", \"1.1.1.2\" ], @kamal.hosts\n  end\n\n  test \"proxy hosts should observe filtered roles\" do\n    configure_with(:deploy_with_multiple_proxy_roles)\n\n    @kamal.specific_roles = [ \"web_tokyo\" ]\n    assert_equal [ \"1.1.1.3\", \"1.1.1.4\" ], @kamal.proxy_hosts\n  end\n\n  test \"proxy hosts should observe filtered hosts\" do\n    configure_with(:deploy_with_multiple_proxy_roles)\n\n    @kamal.specific_hosts = [ \"1.1.1.2\" ]\n    assert_equal [ \"1.1.1.2\" ], @kamal.proxy_hosts\n  end\n\n  test \"accessory hosts without filtering\" do\n    configure_with(:deploy_with_single_accessory)\n    assert_equal [ \"1.1.1.5\" ], @kamal.accessory_hosts\n\n    configure_with(:deploy_with_accessories_on_independent_server)\n    assert_equal [ \"1.1.1.5\", \"1.1.1.1\", \"1.1.1.2\" ], @kamal.accessory_hosts\n  end\n\n  test \"accessory hosts with role filtering\" do\n    configure_with(:deploy_with_single_accessory)\n    @kamal.specific_roles = [ \"web\" ]\n    assert_equal [], @kamal.accessory_hosts\n\n    configure_with(:deploy_with_accessories_on_independent_server)\n    @kamal.specific_roles = [ \"web\" ]\n    assert_equal [ \"1.1.1.1\", \"1.1.1.2\" ], @kamal.accessory_hosts\n\n    @kamal.specific_roles = [ \"workers\" ]\n    assert_equal [], @kamal.accessory_hosts\n  end\n\n  test \"primary role hosts are first\" do\n    configure_with(:deploy_with_roles_workers_primary)\n    assert_equal [ \"1.1.1.1\", \"1.1.1.2\", \"1.1.1.3\", \"1.1.1.4\" ], @kamal.hosts\n    assert_equal [ \"1.1.1.1\", \"1.1.1.2\", \"1.1.1.3\", \"1.1.1.4\" ], @kamal.app_hosts\n  end\n\n  test \"with_specific_hosts restores primary_host after block\" do\n    original_primary = @kamal.primary_host\n    assert_equal \"1.1.1.1\", original_primary\n\n    @kamal.with_specific_hosts(\"1.1.1.3\") do\n      assert_equal \"1.1.1.3\", @kamal.primary_host\n    end\n\n    assert_equal original_primary, @kamal.primary_host\n  end\n\n  test \"with_specific_hosts restores primary_host after iterating multiple hosts\" do\n    original_primary = @kamal.primary_host\n    hosts_visited = []\n\n    @kamal.hosts.each do |host|\n      @kamal.with_specific_hosts(host) do\n        hosts_visited << @kamal.primary_host\n      end\n    end\n\n    assert_equal [ \"1.1.1.1\", \"1.1.1.2\", \"1.1.1.3\", \"1.1.1.4\" ], hosts_visited\n    assert_equal original_primary, @kamal.primary_host\n  end\n\n  test \"with_specific_hosts restores primary_host even after exception\" do\n    original_primary = @kamal.primary_host\n\n    assert_raises(RuntimeError) do\n      @kamal.with_specific_hosts(\"1.1.1.3\") do\n        raise \"test error\"\n      end\n    end\n\n    assert_equal original_primary, @kamal.primary_host\n  end\n\n  private\n    def configure_with(variant)\n      @kamal = Kamal::Commander.new.tap do |kamal|\n        kamal.configure config_file: Pathname.new(File.expand_path(\"fixtures/#{variant}.yml\", __dir__))\n      end\n    end\nend\n"
  },
  {
    "path": "test/commands/accessory_test.rb",
    "content": "require \"test_helper\"\n\nclass CommandsAccessoryTest < ActiveSupport::TestCase\n  setup do\n    setup_test_secrets(\"secrets\" => \"MYSQL_ROOT_PASSWORD=secret123\")\n\n    @config = {\n      service: \"app\",\n      image: \"dhh/app\",\n      registry: { \"server\" => \"private.registry\", \"username\" => \"dhh\", \"password\" => \"secret\" },\n      servers: [ \"1.1.1.1\" ],\n      builder: { \"arch\" => \"amd64\" },\n      accessories: {\n        \"mysql\" => {\n          \"image\" => \"private.registry/mysql:8.0\",\n          \"host\" => \"1.1.1.5\",\n          \"port\" => \"3306\",\n          \"env\" => {\n            \"clear\" => {\n              \"MYSQL_ROOT_HOST\" => \"%\"\n            },\n            \"secret\" => [\n              \"MYSQL_ROOT_PASSWORD\"\n            ]\n          },\n          \"options\" => {\n            \"cpus\" => \"4\",\n            \"memory\" => \"2GB\"\n          }\n        },\n        \"redis\" => {\n          \"image\" => \"redis:latest\",\n          \"host\" => \"1.1.1.6\",\n          \"port\" => \"6379:6379\",\n          \"labels\" => {\n            \"cache\" => \"true\"\n          },\n          \"env\" => {\n            \"SOMETHING\" => \"else\"\n          },\n          \"volumes\" => [\n            \"/var/lib/redis:/data\"\n          ]\n        },\n        \"busybox\" => {\n          \"service\" => \"custom-busybox\",\n          \"image\" => \"busybox:latest\",\n          \"registry\" => { \"server\" => \"other.registry\", \"username\" => \"user\", \"password\" => \"pw\" },\n          \"host\" => \"1.1.1.7\",\n          \"proxy\" => {\n            \"host\" => \"busybox.example.com\"\n          }\n        }\n      }\n    }\n  end\n\n  teardown do\n    teardown_test_secrets\n  end\n\n  test \"run\" do\n    assert_equal \\\n      \"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\",\n      new_command(:mysql).run.join(\" \")\n\n    assert_equal \\\n      \"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\",\n      new_command(:redis).run.join(\" \")\n\n    assert_equal \\\n      \"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\",\n      new_command(:busybox).run.join(\" \")\n  end\n\n  test \"run with logging config\" do\n    @config[:logging] = { \"driver\" => \"local\", \"options\" => { \"max-size\" => \"100m\", \"max-file\" => \"3\" } }\n\n    assert_equal \\\n      \"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\",\n      new_command(:busybox).run.join(\" \")\n  end\n\n  test \"run in custom network\" do\n    @config[:accessories][\"mysql\"][\"network\"] = \"custom\"\n\n    assert_equal \\\n      \"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\",\n      new_command(:mysql).run.join(\" \")\n  end\n\n  test \"start\" do\n    assert_equal \\\n      \"docker container start app-mysql\",\n      new_command(:mysql).start.join(\" \")\n  end\n\n  test \"stop\" do\n    assert_equal \\\n      \"docker container stop app-mysql\",\n      new_command(:mysql).stop.join(\" \")\n  end\n\n  test \"info\" do\n    assert_equal \\\n      \"docker ps --filter label=service=app-mysql\",\n      new_command(:mysql).info.join(\" \")\n  end\n\n  test \"execute in new container\" do\n    assert_equal \\\n      \"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\",\n      new_command(:mysql).execute_in_new_container(\"mysql\", \"-u\", \"root\").join(\" \")\n  end\n\n  test \"execute in existing container\" do\n    assert_equal \\\n      \"docker exec app-mysql mysql -u root\",\n      new_command(:mysql).execute_in_existing_container(\"mysql\", \"-u\", \"root\").join(\" \")\n  end\n\n  test \"execute in new container over ssh\" do\n    new_command(:mysql).stub(:run_over_ssh, ->(cmd) { cmd.join(\" \") }) do\n      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},\n        stub_stdin_tty { new_command(:mysql).execute_in_new_container_over_ssh(\"mysql\", \"-u\", \"root\") }\n    end\n  end\n\n  test \"execute in existing container over ssh\" do\n    new_command(:mysql).stub(:run_over_ssh, ->(cmd) { cmd.join(\" \") }) do\n      assert_match %r{docker exec -it app-mysql mysql -u root},\n        stub_stdin_tty { new_command(:mysql).execute_in_existing_container_over_ssh(\"mysql\", \"-u\", \"root\") }\n    end\n  end\n\n  test \"execute in existing container with piped input over ssh\" do\n    new_command(:mysql).stub(:run_over_ssh, ->(cmd) { cmd.join(\" \") }) do\n      assert_match %r{docker exec -i app-mysql mysql -u root},\n        stub_stdin_file { new_command(:mysql).execute_in_existing_container_over_ssh(\"mysql\", \"-u\", \"root\") }\n    end\n  end\n\n  test \"logs\" do\n    assert_equal \\\n      \"docker logs app-mysql --timestamps 2>&1\",\n      new_command(:mysql).logs.join(\" \")\n\n    assert_equal \\\n      \"docker logs app-mysql  --since 5m  --tail 100 --timestamps 2>&1 | grep 'thing'\",\n      new_command(:mysql).logs(since: \"5m\", lines: 100, grep: \"thing\").join(\" \")\n\n    assert_equal \\\n      \"docker logs app-mysql  --since 5m  --tail 100 --timestamps 2>&1 | grep 'thing' -C 2\",\n      new_command(:mysql).logs(since: \"5m\", lines: 100, grep: \"thing\", grep_options: \"-C 2\").join(\" \")\n\n    assert_equal \\\n      \"docker logs app-mysql  --since 5m  --tail 100 2>&1 | grep 'thing' -C 2\",\n      new_command(:mysql).logs(timestamps: false, since: \"5m\", lines: 100, grep: \"thing\", grep_options: \"-C 2\").join(\" \")\n  end\n\n  test \"follow logs\" do\n    assert_equal \\\n      \"ssh -t root@1.1.1.5 -p 22 'docker logs app-mysql --timestamps --tail 10 --follow 2>&1'\",\n      new_command(:mysql).follow_logs\n\n    assert_equal \\\n      \"ssh -t root@1.1.1.5 -p 22 'docker logs app-mysql --tail 10 --follow 2>&1'\",\n      new_command(:mysql).follow_logs(timestamps: false)\n  end\n\n  test \"remove container\" do\n    assert_equal \\\n      \"docker container prune --force --filter label=service=app-mysql\",\n      new_command(:mysql).remove_container.join(\" \")\n  end\n\n  test \"pull image\" do\n    assert_equal \\\n      \"docker image pull private.registry/mysql:8.0\",\n      new_command(:mysql).pull_image.join(\" \")\n  end\n\n  test \"remove image\" do\n    assert_equal \\\n      \"docker image rm --force private.registry/mysql:8.0\",\n      new_command(:mysql).remove_image.join(\" \")\n  end\n\n  test \"deploy\" do\n    assert_equal \\\n      \"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\\\"\",\n      new_command(:busybox).deploy(target: \"172.1.0.2\").join(\" \")\n  end\n\n  test \"remove\" do\n    assert_equal \\\n      \"docker exec kamal-proxy kamal-proxy remove custom-busybox\",\n      new_command(:busybox).remove.join(\" \")\n  end\n\n  private\n    def new_command(accessory)\n      Kamal::Commands::Accessory.new(Kamal::Configuration.new(@config), name: accessory)\n    end\nend\n"
  },
  {
    "path": "test/commands/app_test.rb",
    "content": "require \"test_helper\"\n\nclass CommandsAppTest < ActiveSupport::TestCase\n  setup do\n    setup_test_secrets(\"secrets\" => \"RAILS_MASTER_KEY=456\")\n\n    @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\" } }\n  end\n\n  teardown do\n    teardown_test_secrets\n  end\n\n  test \"run\" do\n    assert_equal \\\n      \"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\",\n      new_command.run.join(\" \")\n  end\n\n  test \"run with destination\" do\n    @destination = \"staging\"\n    assert_equal \\\n      \"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\",\n      new_command.run.join(\" \")\n  end\n\n  test \"run with hostname\" do\n    assert_equal \\\n      \"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\",\n      new_command.run(hostname: \"myhost\").join(\" \")\n  end\n\n  test \"run with volumes\" do\n    @config[:volumes] = [ \"/local/path:/container/path\" ]\n\n    assert_equal \\\n      \"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\",\n      new_command.run.join(\" \")\n  end\n\n  test \"run with custom options\" do\n    @config[:servers] = { \"web\" => [ \"1.1.1.1\" ], \"jobs\" => { \"hosts\" => [ \"1.1.1.2\" ], \"cmd\" => \"bin/jobs\", \"options\" => { \"mount\" => \"somewhere\", \"cap-add\" => true } } }\n    assert_equal \\\n      \"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\",\n      new_command(role: \"jobs\", host: \"1.1.1.2\").run.join(\" \")\n  end\n\n  test \"run with logging config\" do\n    @config[:logging] = { \"driver\" => \"local\", \"options\" => { \"max-size\" => \"100m\", \"max-file\" => \"3\" } }\n\n    assert_equal \\\n      \"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\",\n      new_command.run.join(\" \")\n  end\n\n  test \"run with role logging config\" do\n    @config[:logging] = { \"driver\" => \"local\", \"options\" => { \"max-size\" => \"10m\", \"max-file\" => \"3\" } }\n    @config[:servers] = { \"web\" => { \"hosts\" => [ \"1.1.1.1\" ], \"logging\" => { \"driver\" => \"local\", \"options\" => { \"max-size\" => \"100m\" } } } }\n\n    assert_equal \\\n      \"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\",\n      new_command.run.join(\" \")\n  end\n\n  test \"run with tags\" do\n    @config[:servers] = [ { \"1.1.1.1\" => \"tag1\" } ]\n    @config[:env][\"tags\"] = { \"tag1\" => { \"ENV1\" => \"value1\" } }\n\n    assert_equal \\\n      \"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\",\n      new_command.run.join(\" \")\n  end\n\n  test \"start\" do\n    assert_equal \\\n      \"docker start app-web-999\",\n      new_command.start.join(\" \")\n  end\n\n  test \"start with destination\" do\n    @destination = \"staging\"\n    assert_equal \\\n      \"docker start app-web-staging-999\",\n      new_command.start.join(\" \")\n  end\n\n  test \"stop\" do\n    assert_equal \\\n      \"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\",\n      new_command.stop.join(\" \")\n  end\n\n  test \"stop with custom drain timeout\" do\n    @config[:drain_timeout] = 20\n    assert_equal \\\n      \"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\",\n      new_command.stop.join(\" \")\n\n    assert_equal \\\n      \"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\",\n      new_command(role: \"workers\").stop.join(\" \")\n  end\n\n  test \"stop with version\" do\n    assert_equal \\\n      \"docker container ls --all --filter 'name=^app-web-123$' --quiet | xargs docker stop\",\n      new_command.stop(version: \"123\").join(\" \")\n  end\n\n  test \"info\" do\n    assert_equal \\\n      \"docker ps --filter label=service=app --filter label=destination= --filter label=role=web\",\n      new_command.info.join(\" \")\n  end\n\n  test \"info with destination\" do\n    @destination = \"staging\"\n    assert_equal \\\n      \"docker ps --filter label=service=app --filter label=destination=staging --filter label=role=web\",\n      new_command.info.join(\" \")\n  end\n\n  test \"deploy\" do\n    assert_equal \\\n      \"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\\\"\",\n      new_command.deploy(target: \"172.1.0.2\").join(\" \")\n  end\n\n  test \"deploy with SSL\" do\n    @config[:proxy] = { \"ssl\" => true, \"host\" => \"example.com\" }\n\n    assert_equal \\\n      \"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\\\"\",\n      new_command.deploy(target: \"172.1.0.2\").join(\" \")\n  end\n\n  test \"deploy with SSL targeting multiple hosts\" do\n    @config[:proxy] = { \"ssl\" => true, \"hosts\" => [ \"example.com\", \"anotherexample.com\" ] }\n\n    assert_equal \\\n      \"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\\\"\",\n      new_command.deploy(target: \"172.1.0.2\").join(\" \")\n  end\n\n  test \"deploy with SSL false\" do\n    @config[:proxy] = { \"ssl\" => false }\n\n    assert_equal \\\n      \"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\\\"\",\n      new_command.deploy(target: \"172.1.0.2\").join(\" \")\n  end\n\n  test \"remove\" do\n    assert_equal \\\n      \"docker exec kamal-proxy kamal-proxy remove app-web\",\n      new_command.remove.join(\" \")\n  end\n\n  test \"logs\" do\n    assert_equal \\\n      \"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\",\n      new_command.logs.join(\" \")\n  end\n\n  test \"logs with container_id\" do\n    assert_equal \\\n      \"echo C137 | xargs docker logs --timestamps 2>&1\",\n      new_command.logs(container_id: \"C137\").join(\" \")\n  end\n\n  test \"logs with since\" do\n    assert_equal \\\n      \"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\",\n      new_command.logs(since: \"5m\").join(\" \")\n  end\n\n  test \"logs with lines\" do\n    assert_equal \\\n      \"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\",\n      new_command.logs(lines: \"100\").join(\" \")\n  end\n\n  test \"logs with since and lines\" do\n    assert_equal \\\n      \"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\",\n      new_command.logs(since: \"5m\", lines: \"100\").join(\" \")\n  end\n\n  test \"logs with grep\" do\n    assert_equal \\\n      \"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'\",\n      new_command.logs(grep: \"my-id\").join(\" \")\n  end\n\n  test \"logs with grep and grep options\" do\n    assert_equal \\\n      \"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\",\n      new_command.logs(grep: \"my-id\", grep_options: \"-C 2\").join(\" \")\n  end\n\n  test \"logs with since, grep and grep options\" do\n    assert_equal \\\n      \"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\",\n      new_command.logs(since: \"5m\", grep: \"my-id\", grep_options: \"-C 2\").join(\" \")\n  end\n\n  test \"logs with since and grep\" do\n    assert_equal \\\n      \"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'\",\n      new_command.logs(since: \"5m\", grep: \"my-id\").join(\" \")\n  end\n\n  test \"follow logs\" do\n    assert_equal \\\n      \"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'\",\n      new_command.follow_logs(host: \"app-1\")\n\n    assert_equal \\\n      \"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\\\"'\",\n      new_command.follow_logs(host: \"app-1\", grep: \"Completed\")\n\n    assert_equal \\\n      \"ssh -t root@app-1 -p 22 'echo ID321 | xargs docker logs --timestamps --follow 2>&1'\",\n      new_command.follow_logs(host: \"app-1\", container_id: \"ID321\")\n\n    assert_equal \\\n      \"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'\",\n      new_command.follow_logs(host: \"app-1\", lines: 123)\n\n    assert_equal \\\n      \"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\\\"'\",\n      new_command.follow_logs(host: \"app-1\", lines: 123, grep: \"Completed\")\n\n    assert_equal \\\n      \"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\\\"'\",\n      new_command.follow_logs(host: \"app-1\", timestamps: false, lines: 123, grep: \"Completed\")\n  end\n\n  test \"follow logs with ssh keys\" do\n    @config[:ssh] = { \"keys\" => [ \"path_to_key.pem\" ] }\n    assert_equal \\\n      \"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'\",\n      new_command.follow_logs(host: \"app-1\")\n  end\n\n  test \"follow logs with ssh proxy_command\" do\n    @config[:ssh] = { \"proxy_command\" => \"ssh -W %h:%p user@proxy-server\" }\n    assert_equal \\\n      \"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'\",\n      new_command.follow_logs(host: \"app-1\")\n  end\n\n  test \"follow logs with ssh config file\" do\n    @config[:ssh] = { \"config\" => \"~/.ssh/custom_config\" }\n    assert_equal \\\n      \"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'\",\n      new_command.follow_logs(host: \"app-1\")\n  end\n\n  test \"execute in new container\" do\n    assert_match \\\n      %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},\n      new_command.execute_in_new_container(\"bin/rails\", \"db:setup\", env: {}).join(\" \")\n  end\n\n  test \"execute in new container with logging\" do\n    @config[:logging] = { \"driver\" => \"local\", \"options\" => { \"max-size\" => \"100m\", \"max-file\" => \"3\" } }\n\n    assert_match \\\n      %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},\n      new_command.execute_in_new_container(\"bin/rails\", \"db:setup\", env: {}).join(\" \")\n  end\n\n  test \"execute in new container with env\" do\n    assert_match \\\n      %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},\n      new_command.execute_in_new_container(\"bin/rails\", \"db:setup\", env: { \"foo\" => \"bar\" }).join(\" \")\n  end\n\n  test \"execute in new detached container\" do\n    assert_match \\\n      %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},\n      new_command.execute_in_new_container(\"bin/rails\", \"db:setup\", detach: true, env: {}).join(\" \")\n  end\n\n  test \"execute in new container with tags\" do\n    @config[:servers] = [ { \"1.1.1.1\" => \"tag1\" } ]\n    @config[:env][\"tags\"] = { \"tag1\" => { \"ENV1\" => \"value1\" } }\n\n    assert_match \\\n      %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},\n      new_command.execute_in_new_container(\"bin/rails\", \"db:setup\", env: {}).join(\" \")\n  end\n\n  test \"execute in new container with custom options\" do\n    @config[:servers] = { \"web\" => { \"hosts\" => [ \"1.1.1.1\" ], \"options\" => { \"mount\" => \"somewhere\", \"cap-add\" => true } } }\n    assert_match \\\n      %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},\n      new_command.execute_in_new_container(\"bin/rails\", \"db:setup\", env: {}).join(\" \")\n  end\n\n  test \"execute in existing container\" do\n    assert_equal \\\n      \"docker exec app-web-999 bin/rails db:setup\",\n      new_command.execute_in_existing_container(\"bin/rails\", \"db:setup\", env: {}).join(\" \")\n  end\n\n  test \"execute in existing container with env\" do\n    assert_equal \\\n      \"docker exec --env foo=\\\"bar\\\" app-web-999 bin/rails db:setup\",\n      new_command.execute_in_existing_container(\"bin/rails\", \"db:setup\", env: { \"foo\" => \"bar\" }).join(\" \")\n  end\n\n  test \"execute in new container over ssh\" do\n    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},\n      stub_stdin_tty { new_command.execute_in_new_container_over_ssh(\"bin/rails\", \"c\", env: {}) }\n  end\n\n  test \"execute in new container over ssh with tags\" do\n    @config[:servers] = [ { \"1.1.1.1\" => \"tag1\" } ]\n    @config[:env][\"tags\"] = { \"tag1\" => { \"ENV1\" => \"value1\" } }\n\n    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'},\n      stub_stdin_tty { new_command.execute_in_new_container_over_ssh(\"bin/rails\", \"c\", env: {}) }\n  end\n\n  test \"execute in new container with custom options over ssh\" do\n    @config[:servers] = { \"web\" => { \"hosts\" => [ \"1.1.1.1\" ], \"options\" => { \"mount\" => \"somewhere\", \"cap-add\" => true } } }\n    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},\n      stub_stdin_tty { new_command.execute_in_new_container_over_ssh(\"bin/rails\", \"c\", env: {}) }\n  end\n\n  test \"execute in existing container over ssh\" do\n    assert_match %r{docker exec -it app-web-999 bin/rails c},\n      stub_stdin_tty { new_command.execute_in_existing_container_over_ssh(\"bin/rails\", \"c\", env: {}) }\n  end\n\n  test \"execute in existing container with piped input over ssh\" do\n    assert_match %r{docker exec -i app-web-999 bin/rails c},\n      stub_stdin_file { new_command.execute_in_existing_container_over_ssh(\"bin/rails\", \"c\", env: {}) }\n  end\n\n  test \"run over ssh\" do\n    assert_equal \"ssh -t root@1.1.1.1 -p 22 'ls'\", new_command.run_over_ssh(\"ls\", host: \"1.1.1.1\")\n  end\n\n  test \"run over ssh with custom user\" do\n    @config[:ssh] = { \"user\" => \"app\" }\n    assert_equal \"ssh -t app@1.1.1.1 -p 22 'ls'\", new_command.run_over_ssh(\"ls\", host: \"1.1.1.1\")\n  end\n\n  test \"run over ssh with custom port\" do\n    @config[:ssh] = { \"port\" => \"2222\" }\n    assert_equal \"ssh -t root@1.1.1.1 -p 2222 'ls'\", new_command.run_over_ssh(\"ls\", host: \"1.1.1.1\")\n  end\n\n  test \"run over ssh with proxy\" do\n    @config[:ssh] = { \"proxy\" => \"2.2.2.2\" }\n    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\")\n  end\n\n  test \"run over ssh with proxy user\" do\n    @config[:ssh] = { \"proxy\" => \"app@2.2.2.2\" }\n    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\")\n  end\n\n  test \"run over ssh with custom user with proxy\" do\n    @config[:ssh] = { \"user\" => \"app\", \"proxy\" => \"2.2.2.2\" }\n    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\")\n  end\n\n  test \"run over ssh with keys config\" do\n    @config[:ssh] = { \"keys\" => [ \"path_to_key.pem\" ] }\n    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\")\n  end\n\n  test \"run over ssh with keys config with keys_only\" do\n    @config[:ssh] = { \"keys\" => [ \"path_to_key.pem\" ], \"keys_only\" => true }\n    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\")\n  end\n\n  test \"run over ssh with proxy_command\" do\n    @config[:ssh] = { \"proxy_command\" => \"ssh -W %h:%p user@proxy-server\" }\n    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\")\n  end\n\n  test \"run over ssh with config file\" do\n    @config[:ssh] = { \"config\" => \"~/.ssh/custom_config\" }\n    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\")\n  end\n\n  test \"run over ssh with multiple config files\" do\n    @config[:ssh] = { \"config\" => [ \"~/.ssh/config1\", \"~/.ssh/config2\" ] }\n    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\")\n  end\n\n  test \"run over ssh with config false\" do\n    @config[:ssh] = { \"config\" => false }\n    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\")\n  end\n\n  test \"run over ssh with config true\" do\n    @config[:ssh] = { \"config\" => true }\n    assert_equal \"ssh -t root@1.1.1.1 -p 22 'ls'\", new_command.run_over_ssh(\"ls\", host: \"1.1.1.1\")\n  end\n\n  test \"current_running_container_id\" do\n    assert_equal \\\n    \"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\",\n      new_command.current_running_container_id.join(\" \")\n  end\n\n  test \"current_running_container_id with destination\" do\n    @destination = \"staging\"\n    assert_equal \\\n      \"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\",\n      new_command.current_running_container_id.join(\" \")\n  end\n\n  test \"container_id_for\" do\n    assert_equal \\\n      \"docker container ls --all --filter 'name=^app-999$' --quiet\",\n      new_command.container_id_for(container_name: \"app-999\").join(\" \")\n  end\n\n  test \"current_running_version\" do\n    assert_equal \\\n      \"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\",\n      new_command.current_running_version.join(\" \")\n  end\n\n  test \"list_versions\" do\n    assert_equal \\\n      \"docker ps --filter label=service=app --filter label=destination= --filter label=role=web --format \\\"{{.Names}}\\\" | while read line; do echo ${line#app-web-}; done\",\n      new_command.list_versions.join(\" \")\n\n    assert_equal \\\n      \"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\",\n      new_command.list_versions(\"--latest\", statuses: [ :running, :restarting ]).join(\" \")\n  end\n\n  test \"list_containers\" do\n    assert_equal \\\n      \"docker container ls --all --filter label=service=app --filter label=destination= --filter label=role=web\",\n      new_command.list_containers.join(\" \")\n  end\n\n  test \"list_containers with destination\" do\n    @destination = \"staging\"\n    assert_equal \\\n      \"docker container ls --all --filter label=service=app --filter label=destination=staging --filter label=role=web\",\n      new_command.list_containers.join(\" \")\n  end\n\n  test \"list_container_names\" do\n    assert_equal \\\n      \"docker container ls --all --filter label=service=app --filter label=destination= --filter label=role=web --format '{{ .Names }}'\",\n      new_command.list_container_names.join(\" \")\n  end\n\n  test \"remove_container\" do\n    assert_equal \\\n      \"docker container ls --all --filter 'name=^app-web-999$' --quiet | xargs docker container rm\",\n      new_command.remove_container(version: \"999\").join(\" \")\n  end\n\n  test \"remove_container with destination\" do\n    @destination = \"staging\"\n    assert_equal \\\n      \"docker container ls --all --filter 'name=^app-web-staging-999$' --quiet | xargs docker container rm\",\n      new_command.remove_container(version: \"999\").join(\" \")\n  end\n\n  test \"remove_containers\" do\n    assert_equal \\\n      \"docker container prune --force --filter label=service=app --filter label=destination= --filter label=role=web\",\n      new_command.remove_containers.join(\" \")\n  end\n\n  test \"remove_containers with destination\" do\n    @destination = \"staging\"\n    assert_equal \\\n      \"docker container prune --force --filter label=service=app --filter label=destination=staging --filter label=role=web\",\n      new_command.remove_containers.join(\" \")\n  end\n\n  test \"list_images\" do\n    assert_equal \\\n      \"docker image ls dhh/app\",\n      new_command.list_images.join(\" \")\n  end\n\n  test \"remove_images\" do\n    assert_equal \\\n      \"docker image prune --all --force --filter label=service=app\",\n      new_command.remove_images.join(\" \")\n  end\n\n  test \"remove_images with destination\" do\n    @destination = \"staging\"\n    assert_equal \\\n      \"docker image prune --all --force --filter label=service=app\",\n      new_command.remove_images.join(\" \")\n  end\n\n  test \"tag_latest_image\" do\n    assert_equal \\\n      \"docker tag dhh/app:999 dhh/app:latest\",\n      new_command.tag_latest_image.join(\" \")\n  end\n\n  test \"tag_latest_image with destination\" do\n    @destination = \"staging\"\n    assert_equal \\\n      \"docker tag dhh/app:999 dhh/app:latest-staging\",\n      new_command.tag_latest_image.join(\" \")\n  end\n\n  test \"extract assets\" do\n    assert_equal [\n      :mkdir, \"-p\", \".kamal/apps/app/assets/extracted/web-999\", \"&&\",\n      :docker, :container, :rm, \"app-web-assets\", \"2> /dev/null\", \"|| true\", \"&&\",\n      :docker, :container, :create, \"--name\", \"app-web-assets\", \"dhh/app:999\", \"&&\",\n      :docker, :container, :cp, \"-L\", \"app-web-assets:/public/assets/.\", \".kamal/apps/app/assets/extracted/web-999\", \"&&\",\n      :docker, :container, :rm, \"app-web-assets\"\n    ], new_command(asset_path: \"/public/assets\").extract_assets\n  end\n\n  test \"sync asset volumes\" do\n    assert_equal [\n      :mkdir, \"-p\", \".kamal/apps/app/assets/volumes/web-999\", \";\",\n      :cp, \"-rnT\", \".kamal/apps/app/assets/extracted/web-999\", \".kamal/apps/app/assets/volumes/web-999\"\n    ], new_command(asset_path: \"/public/assets\").sync_asset_volumes\n\n    assert_equal [\n      :mkdir, \"-p\", \".kamal/apps/app/assets/volumes/web-999\", \";\",\n      :cp, \"-rnT\", \".kamal/apps/app/assets/extracted/web-999\", \".kamal/apps/app/assets/volumes/web-999\", \";\",\n      :cp, \"-rnT\", \".kamal/apps/app/assets/extracted/web-999\", \".kamal/apps/app/assets/volumes/web-998\", \"|| true\", \";\",\n      :cp, \"-rnT\", \".kamal/apps/app/assets/extracted/web-998\", \".kamal/apps/app/assets/volumes/web-999\", \"|| true\"\n    ], new_command(asset_path: \"/public/assets\").sync_asset_volumes(old_version: 998)\n  end\n\n  test \"clean up assets\" do\n    assert_equal [\n      :find, \".kamal/apps/app/assets/extracted\", \"-maxdepth 1\", \"-name\", \"'web-*'\", \"!\", \"-name\", \"web-999\", \"-exec rm -rf \\\"{}\\\" +\", \";\",\n      :find, \".kamal/apps/app/assets/volumes\", \"-maxdepth 1\", \"-name\", \"'web-*'\", \"!\", \"-name\", \"web-999\", \"-exec rm -rf \\\"{}\\\" +\"\n    ], new_command(asset_path: \"/public/assets\").clean_up_assets\n  end\n\n  test \"live\" do\n    assert_equal \\\n      \"docker exec kamal-proxy kamal-proxy resume app-web\",\n      new_command.live.join(\" \")\n  end\n\n  test \"maintenance\" do\n    assert_equal \\\n      \"docker exec kamal-proxy kamal-proxy stop app-web\",\n      new_command.maintenance.join(\" \")\n  end\n\n  test \"maintenance with options\" do\n    assert_equal \\\n      \"docker exec kamal-proxy kamal-proxy stop app-web --drain-timeout=\\\"10s\\\" --message=\\\"Hi\\\"\",\n      new_command.maintenance(drain_timeout: 10, message: \"Hi\").join(\" \")\n  end\n\n  test \"remove_proxy_app_directory\" do\n    assert_equal \\\n      \"rm -r .kamal/proxy/apps-config/app\",\n      new_command.remove_proxy_app_directory.join(\" \")\n  end\n\n  private\n    def new_command(role: \"web\", host: \"1.1.1.1\", **additional_config)\n      config = Kamal::Configuration.new(@config.merge(additional_config), destination: @destination, version: \"999\")\n      Kamal::Commands::App.new(config, role: config.role(role), host: host)\n    end\nend\n"
  },
  {
    "path": "test/commands/auditor_test.rb",
    "content": "require \"test_helper\"\nrequire \"active_support/testing/time_helpers\"\n\nclass CommandsAuditorTest < ActiveSupport::TestCase\n  include ActiveSupport::Testing::TimeHelpers\n\n  setup do\n    freeze_time\n\n    @config = {\n      service: \"app\", image: \"dhh/app\", registry: { \"username\" => \"dhh\", \"password\" => \"secret\" }, builder: { \"arch\" => \"amd64\" },  servers: [ \"1.1.1.1\" ]\n    }\n\n    @auditor = new_command\n    @performer = Kamal::Git.email.presence || `whoami`.chomp\n    @recorded_at = Time.now.utc.iso8601\n  end\n\n  test \"record\" do\n    assert_equal [\n      :mkdir, \"-p\", \".kamal\", \"&&\",\n      :echo,\n      \"\\\"[#{@recorded_at}] [#{@performer}] app removed container\\\"\",\n      \">>\", \".kamal/app-audit.log\"\n    ], @auditor.record(\"app removed container\")\n  end\n\n  test \"record with destination\" do\n    new_command(destination: \"staging\").tap do |auditor|\n      assert_equal [\n        :mkdir, \"-p\", \".kamal\", \"&&\",\n        :echo,\n        \"\\\"[#{@recorded_at}] [#{@performer}] [staging] app removed container\\\"\",\n        \">>\", \".kamal/app-staging-audit.log\"\n      ], auditor.record(\"app removed container\")\n    end\n  end\n\n  test \"record with command details\" do\n    new_command(role: \"web\").tap do |auditor|\n      assert_equal [\n        :mkdir, \"-p\", \".kamal\", \"&&\",\n        :echo,\n        \"\\\"[#{@recorded_at}] [#{@performer}] [web] app removed container\\\"\",\n        \">>\", \".kamal/app-audit.log\"\n      ], auditor.record(\"app removed container\")\n    end\n  end\n\n  test \"record with arg details\" do\n    assert_equal [\n      :mkdir, \"-p\", \".kamal\", \"&&\",\n      :echo,\n      \"\\\"[#{@recorded_at}] [#{@performer}] [value] app removed container\\\"\",\n      \">>\", \".kamal/app-audit.log\"\n    ], @auditor.record(\"app removed container\", detail: \"value\")\n  end\n\n\n  private\n    def new_command(destination: nil, **details)\n      Kamal::Commands::Auditor.new(Kamal::Configuration.new(@config, destination: destination, version: \"123\"), **details)\n    end\nend\n"
  },
  {
    "path": "test/commands/builder_test.rb",
    "content": "require \"test_helper\"\n\nclass CommandsBuilderTest < ActiveSupport::TestCase\n  setup do\n    @config = { service: \"app\", image: \"dhh/app\", registry: { \"username\" => \"dhh\", \"password\" => \"secret\" }, servers: [ \"1.1.1.1\" ], builder: { \"arch\" => \"amd64\" } }\n  end\n\n  test \"target linux/amd64 locally by default\" do\n    builder = new_builder_command(builder: { \"cache\" => { \"type\" => \"gha\" } })\n    assert_equal \"local\", builder.name\n    assert_equal \\\n      \"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\",\n      builder.push.join(\" \")\n  end\n\n  test \"target specified arch locally by default\" do\n    builder = new_builder_command(builder: { \"arch\" => [ \"amd64\" ] })\n    assert_equal \"local\", builder.name\n    assert_equal \\\n      \"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\",\n      builder.push.join(\" \")\n  end\n\n  test \"build with caching\" do\n    builder = new_builder_command(builder: { \"cache\" => { \"type\" => \"gha\" } })\n    assert_equal \"local\", builder.name\n    assert_equal \\\n      \"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\",\n      builder.push.join(\" \")\n  end\n\n  test \"hybrid build if remote is set and building multiarch\" do\n    builder = new_builder_command(builder: { \"arch\" => [ \"amd64\", \"arm64\" ], \"remote\" => \"ssh://app@127.0.0.1\", \"cache\" => { \"type\" => \"gha\" } })\n    assert_equal \"hybrid\", builder.name\n    assert_equal \\\n      \"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\",\n      builder.push.join(\" \")\n  end\n\n  test \"remote build if remote is set and local disabled\" do\n    builder = new_builder_command(builder: { \"arch\" => [ \"amd64\", \"arm64\" ], \"remote\" => \"ssh://app@127.0.0.1\", \"cache\" => { \"type\" => \"gha\" }, \"local\" => false })\n    assert_equal \"remote\", builder.name\n    assert_equal \\\n      \"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\",\n      builder.push.join(\" \")\n  end\n\n  test \"target remote when remote set and arch is non local\" do\n    builder = new_builder_command(builder: { \"arch\" => [ \"#{remote_arch}\" ], \"remote\" => \"ssh://app@host\", \"cache\" => { \"type\" => \"gha\" } })\n    assert_equal \"remote\", builder.name\n    assert_equal \\\n      \"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\",\n      builder.push.join(\" \")\n  end\n\n  test \"target local when remote set and arch is local\" do\n    builder = new_builder_command(builder: { \"arch\" => [ \"#{local_arch}\" ], \"remote\" => \"ssh://app@host\", \"cache\" => { \"type\" => \"gha\" } })\n    assert_equal \"local\", builder.name\n    assert_equal \\\n      \"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\",\n      builder.push.join(\" \")\n  end\n\n  test \"target pack when pack is set\" do\n    builder = new_builder_command(image: \"dhh/app\", builder: { \"arch\" => \"amd64\", \"pack\" => { \"builder\" => \"heroku/builder:24\", \"buildpacks\" => [ \"heroku/ruby\", \"heroku/procfile\" ] } })\n    assert_equal \"pack\", builder.name\n    assert_equal \\\n      \"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\",\n      builder.push.join(\" \")\n  end\n\n  test \"pack build args passed as env\" do\n    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\" ] } })\n\n    assert_equal \\\n      \"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\",\n    builder.push.join(\" \")\n  end\n\n  test \"pack build with no cache\" do\n    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\" ] } })\n\n    assert_equal \\\n      \"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\",\n    builder.push(\"registry\", no_cache: true).join(\" \")\n  end\n\n  test \"pack build secrets as env\" do\n    with_test_secrets(\"secrets\" => \"token_a=foo\\ntoken_b=bar\") do\n      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\" ] } })\n\n      assert_equal \\\n        \"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\",\n      builder.push.join(\" \")\n    end\n  end\n\n  test \"cloud builder\" do\n    builder = new_builder_command(builder: { \"arch\" => [ \"#{local_arch}\" ], \"driver\" => \"cloud docker-org-name/builder-name\" })\n    assert_equal \"cloud\", builder.name\n    assert_equal \\\n      \"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\",\n      builder.push.join(\" \")\n  end\n\n  test \"build args\" do\n    builder = new_builder_command(builder: { \"args\" => { \"a\" => 1, \"b\" => 2 } })\n    assert_equal \\\n      \"--label service=\\\"app\\\" --build-arg a=\\\"1\\\" --build-arg b=\\\"2\\\" --file Dockerfile\",\n      builder.target.build_options.join(\" \")\n  end\n\n  test \"build secrets\" do\n    with_test_secrets(\"secrets\" => \"token_a=foo\\ntoken_b=bar\") do\n      FileUtils.touch(\"Dockerfile\")\n      builder = new_builder_command(builder: { \"secrets\" => [ \"token_a\", \"token_b\" ] })\n      assert_equal \\\n        \"--label service=\\\"app\\\" --secret id=\\\"token_a\\\" --secret id=\\\"token_b\\\" --file Dockerfile\",\n        builder.target.build_options.join(\" \")\n    end\n  end\n\n  test \"build dockerfile\" do\n    Pathname.any_instance.expects(:exist?).returns(true).once\n    builder = new_builder_command(builder: { \"dockerfile\" => \"Dockerfile.xyz\" })\n    assert_equal \\\n      \"--label service=\\\"app\\\" --file Dockerfile.xyz\",\n      builder.target.build_options.join(\" \")\n  end\n\n  test \"missing dockerfile\" do\n    Pathname.any_instance.expects(:exist?).returns(false).once\n    builder = new_builder_command(builder: { \"dockerfile\" => \"Dockerfile.xyz\" })\n    assert_raises(Kamal::Commands::Builder::Base::BuilderError) do\n      builder.target.build_options.join(\" \")\n    end\n  end\n\n  test \"build target\" do\n    builder = new_builder_command(builder: { \"target\" => \"prod\" })\n    assert_equal \\\n      \"--label service=\\\"app\\\" --file Dockerfile --target prod\",\n      builder.target.build_options.join(\" \")\n  end\n\n  test \"build context\" do\n    builder = new_builder_command(builder: { \"context\" => \"..\" })\n    assert_equal \\\n      \"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\",\n      builder.push.join(\" \")\n  end\n\n  test \"push with build args\" do\n    builder = new_builder_command(builder: { \"args\" => { \"a\" => 1, \"b\" => 2 } })\n    assert_equal \\\n      \"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\",\n      builder.push.join(\" \")\n  end\n\n  test \"push with build secrets\" do\n    with_test_secrets(\"secrets\" => \"a=foo\\nb=bar\") do\n      FileUtils.touch(\"Dockerfile\")\n      builder = new_builder_command(builder: { \"secrets\" => [ \"a\", \"b\" ] })\n      assert_equal \\\n        \"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\",\n        builder.push.join(\" \")\n    end\n  end\n\n  test \"build with ssh agent socket\" do\n    builder = new_builder_command(builder: { \"ssh\" => \"default=$SSH_AUTH_SOCK\" })\n\n    assert_equal \\\n      \"--label service=\\\"app\\\" --file Dockerfile --ssh default=$SSH_AUTH_SOCK\",\n      builder.target.build_options.join(\" \")\n  end\n\n  test \"validate image\" do\n    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(\" \")\n  end\n\n  test \"context build\" do\n    builder = new_builder_command(builder: { \"context\" => \"./foo\" })\n    assert_equal \\\n      \"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\",\n      builder.push.join(\" \")\n  end\n\n  test \"push with provenance\" do\n    builder = new_builder_command(builder: { \"provenance\" => \"mode=max\" })\n    assert_equal \\\n      \"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\",\n      builder.push.join(\" \")\n  end\n\n  test \"push with provenance false\" do\n    builder = new_builder_command(builder: { \"provenance\" => false })\n    assert_equal \\\n      \"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\",\n      builder.push.join(\" \")\n  end\n\n  test \"push with sbom\" do\n    builder = new_builder_command(builder: { \"sbom\" => true })\n    assert_equal \\\n      \"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\",\n      builder.push.join(\" \")\n  end\n\n  test \"push with sbom false\" do\n    builder = new_builder_command(builder: { \"sbom\" => false })\n    assert_equal \\\n      \"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\",\n      builder.push.join(\" \")\n  end\n\n  test \"mirror count\" do\n    command = new_builder_command\n    assert_equal \"docker info --format '{{index .RegistryConfig.Mirrors 0}}'\", command.first_mirror.join(\" \")\n  end\n\n  test \"push with no cache\" do\n    builder = new_builder_command\n    assert_equal \\\n      \"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\",\n      builder.push(\"registry\", no_cache: true).join(\" \")\n  end\n\n  test \"clone path with spaces\" do\n    command = new_builder_command\n    Kamal::Git.stubs(:root).returns(\"/absolute/path with spaces\")\n    clone_command = command.clone.join(\" \")\n    clone_reset_commands = command.clone_reset_steps.map { |a| a.join(\" \") }\n\n    assert_match(%r{path\\\\ with\\\\ space}, clone_command)\n    assert_no_match(%r{path with spaces}, clone_command)\n\n    clone_reset_commands.each do |command|\n      assert_match(%r{path\\\\ with\\\\ space}, command)\n      assert_no_match(%r{path with spaces}, command)\n    end\n  end\n\n  test \"local builder with local registry includes network host driver option\" do\n    builder = new_builder_command(registry: { \"server\" => \"localhost:5000\" })\n    assert_equal \"local\", builder.name\n    assert_equal \\\n      \"docker buildx create --name kamal-local-registry-docker-container --driver=docker-container --driver-opt network=host\",\n      builder.create.join(\" \")\n  end\n\n  test \"remote builder with local registry\" do\n    builder = new_builder_command(\n      registry: { \"server\" => \"localhost:5000\" },\n      builder: { \"arch\" => remote_arch, \"remote\" => \"ssh://app@1.1.1.5\" }\n    )\n    assert_equal \"remote\", builder.name\n    assert_equal \\\n      \"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\",\n      builder.create.join(\" \")\n  end\n\n  test \"hybrid builder with local registry\" do\n    builder = new_builder_command(\n      registry: { \"server\" => \"localhost:5000\" },\n      builder: { \"arch\" => [ \"amd64\", \"arm64\" ], \"remote\" => \"ssh://app@1.1.1.5\" }\n    )\n    assert_equal \"hybrid\", builder.name\n    assert_equal \\\n      \"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\",\n      builder.create.join(\" \")\n  end\n\n  private\n    def new_builder_command(additional_config = {})\n      Kamal::Configuration.new(@config.deep_merge(additional_config), version: \"123\").then do |config|\n        KAMAL.reset\n        KAMAL.stubs(:config).returns(config)\n        Kamal::Commands::Builder.new(config)\n      end\n    end\n\n    def local_arch\n      Kamal::Utils.docker_arch\n    end\n\n    def remote_arch\n      Kamal::Utils.docker_arch == \"arm64\" ? \"amd64\" : \"arm64\"\n    end\nend\n"
  },
  {
    "path": "test/commands/docker_test.rb",
    "content": "require \"test_helper\"\n\nclass CommandsDockerTest < ActiveSupport::TestCase\n  setup do\n    @config = {\n      service: \"app\", image: \"dhh/app\", registry: { \"username\" => \"dhh\", \"password\" => \"secret\" }, servers: [ \"1.1.1.1\" ], builder: { \"arch\" => \"amd64\" }\n    }\n    @docker = Kamal::Commands::Docker.new(Kamal::Configuration.new(@config))\n  end\n\n  test \"install\" do\n    assert_equal \"sh -c 'curl -fsSL https://get.docker.com || wget -O - https://get.docker.com || echo \\\"exit 1\\\"' | sh\", @docker.install.join(\" \")\n  end\n\n  test \"installed?\" do\n    assert_equal \"docker -v\", @docker.installed?.join(\" \")\n  end\n\n  test \"running?\" do\n    assert_equal \"docker version\", @docker.running?.join(\" \")\n  end\n\n  test \"superuser?\" do\n    assert_equal '[ \"${EUID:-$(id -u)}\" -eq 0 ] || sudo -nl usermod >/dev/null', @docker.superuser?.join(\" \")\n  end\n\n  test \"root?\" do\n    assert_equal '[ \"${EUID:-$(id -u)}\" -eq 0 ]', @docker.root?.join(\" \")\n  end\n\n  test \"in_docker_group?\" do\n    assert_equal 'id -nG \"${USER:-$(id -un)}\" | grep -qw docker', @docker.in_docker_group?.join(\" \")\n  end\n\n  test \"add_to_docker_group\" do\n    assert_equal 'sudo -n usermod -aG docker \"${USER:-$(id -un)}\"', @docker.add_to_docker_group.join(\" \")\n  end\n\n  test \"refresh_session\" do\n    assert_equal \"kill -HUP $PPID\", @docker.refresh_session.join(\" \")\n  end\nend\n"
  },
  {
    "path": "test/commands/hook_test.rb",
    "content": "require \"test_helper\"\n\nclass CommandsHookTest < ActiveSupport::TestCase\n  include ActiveSupport::Testing::TimeHelpers\n\n  setup do\n    freeze_time\n\n    @config = {\n      service: \"app\", image: \"dhh/app\", registry: { \"username\" => \"dhh\", \"password\" => \"secret\" }, servers: [ \"1.1.1.1\" ],\n      builder: { \"arch\" => \"amd64\" }\n    }\n\n    @performer = Kamal::Git.email.presence || `whoami`.chomp\n    @recorded_at = Time.now.utc.iso8601\n  end\n\n  test \"run\" do\n    assert_equal [ \".kamal/hooks/foo\" ], new_command.run(\"foo\")\n  end\n\n  test \"env\" do\n    assert_equal ({\n      \"KAMAL_RECORDED_AT\" => @recorded_at,\n      \"KAMAL_PERFORMER\" => @performer,\n      \"KAMAL_VERSION\" => \"123\",\n      \"KAMAL_SERVICE_VERSION\" => \"app@123\",\n      \"KAMAL_SERVICE\" => \"app\"\n    }), new_command.env\n  end\n\n  test \"run with custom hooks_path\" do\n    assert_equal [ \"custom/hooks/path/foo\" ], new_command(hooks_path: \"custom/hooks/path\").run(\"foo\")\n  end\n\n  test \"env with secrets\" do\n    with_test_secrets(\"secrets\" => \"DB_PASSWORD=secret\") do\n      assert_equal (\n        {\n          \"KAMAL_RECORDED_AT\" => @recorded_at,\n          \"KAMAL_PERFORMER\" => @performer,\n          \"KAMAL_VERSION\" => \"123\",\n          \"KAMAL_SERVICE_VERSION\" => \"app@123\",\n          \"KAMAL_SERVICE\" => \"app\",\n          \"DB_PASSWORD\" => \"secret\" }\n      ), new_command.env(secrets: true)\n    end\n  end\n\n  private\n    def new_command(**extra_config)\n      Kamal::Commands::Hook.new(Kamal::Configuration.new(@config.merge(**extra_config), version: \"123\"))\n    end\nend\n"
  },
  {
    "path": "test/commands/lock_test.rb",
    "content": "require \"test_helper\"\n\nclass CommandsLockTest < ActiveSupport::TestCase\n  setup do\n    @config = {\n      service: \"app\", image: \"dhh/app\", registry: { \"username\" => \"dhh\", \"password\" => \"secret\" }, servers: [ \"1.1.1.1\" ],\n      builder: { \"arch\" => \"amd64\" }\n    }\n  end\n\n  test \"status\" do\n    assert_equal \\\n      \"stat .kamal/lock-app-production > /dev/null && cat .kamal/lock-app-production/details | base64 -d\",\n      new_command.status.join(\" \")\n  end\n\n  test \"acquire\" do\n    assert_match \\\n      %r{mkdir \\.kamal/lock-app-production && echo \".*\" > \\.kamal/lock-app-production/details}m,\n      new_command.acquire(\"Hello\", \"123\").join(\" \")\n  end\n\n  test \"release\" do\n    assert_match \\\n      \"rm .kamal/lock-app-production/details && rm -r .kamal/lock-app-production\",\n      new_command.release.join(\" \")\n  end\n\n  private\n    def new_command\n      Kamal::Commands::Lock.new(Kamal::Configuration.new(@config, version: \"123\", destination: \"production\"))\n    end\nend\n"
  },
  {
    "path": "test/commands/proxy_test.rb",
    "content": "require \"test_helper\"\n\nclass CommandsProxyTest < ActiveSupport::TestCase\n  setup do\n    @config = {\n      service: \"app\", image: \"dhh/app\", registry: { \"username\" => \"dhh\", \"password\" => \"secret\" }, servers: [ \"1.1.1.1\" ], builder: { \"arch\" => \"amd64\" }\n    }\n\n    ENV[\"EXAMPLE_API_KEY\"] = \"456\"\n  end\n\n  teardown do\n    ENV.delete(\"EXAMPLE_API_KEY\")\n  end\n\n  test \"run\" do\n    assert_equal \\\n      \"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\",\n      new_command.run.join(\" \")\n  end\n\n  test \"run without configuration\" do\n    @config.delete(:proxy)\n\n    assert_equal \\\n      \"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\",\n      new_command.run.join(\" \")\n  end\n\n  test \"proxy start\" do\n    assert_equal \\\n      \"docker container start kamal-proxy\",\n      new_command.start.join(\" \")\n  end\n\n  test \"proxy stop\" do\n    assert_equal \\\n      \"docker container stop kamal-proxy\",\n      new_command.stop.join(\" \")\n  end\n\n  test \"proxy info\" do\n    assert_equal \\\n      \"docker ps --filter 'name=^kamal-proxy$'\",\n      new_command.info.join(\" \")\n  end\n\n  test \"proxy logs\" do\n    assert_equal \\\n      \"docker logs kamal-proxy --timestamps 2>&1\",\n      new_command.logs.join(\" \")\n  end\n\n  test \"proxy logs since 2h\" do\n    assert_equal \\\n      \"docker logs kamal-proxy --since 2h --timestamps 2>&1\",\n      new_command.logs(since: \"2h\").join(\" \")\n  end\n\n  test \"proxy logs last 10 lines\" do\n    assert_equal \\\n      \"docker logs kamal-proxy --tail 10 --timestamps 2>&1\",\n      new_command.logs(lines: 10).join(\" \")\n  end\n\n  test \"proxy logs without timestamps\" do\n    assert_equal \\\n      \"docker logs kamal-proxy 2>&1\",\n      new_command.logs(timestamps: false).join(\" \")\n  end\n\n  test \"proxy logs with grep hello!\" do\n    assert_equal \\\n      \"docker logs kamal-proxy --timestamps 2>&1 | grep 'hello!'\",\n      new_command.logs(grep: \"hello!\").join(\" \")\n  end\n\n  test \"proxy remove container\" do\n    assert_equal \\\n      \"docker container prune --force --filter label=org.opencontainers.image.title=kamal-proxy\",\n      new_command.remove_container.join(\" \")\n  end\n\n  test \"proxy remove image\" do\n    assert_equal \\\n      \"docker image prune --all --force --filter label=org.opencontainers.image.title=kamal-proxy\",\n      new_command.remove_image.join(\" \")\n  end\n\n  test \"proxy follow logs\" do\n    assert_equal \\\n      \"ssh -t root@1.1.1.1 -p 22 'docker logs kamal-proxy --timestamps --tail 10 --follow 2>&1'\",\n      new_command.follow_logs(host: @config[:servers].first)\n  end\n\n  test \"proxy follow logs with grep hello!\" do\n    assert_equal \\\n      \"ssh -t root@1.1.1.1 -p 22 'docker logs kamal-proxy --timestamps --tail 10 --follow 2>&1 | grep \\\"hello!\\\"'\",\n      new_command.follow_logs(host: @config[:servers].first, grep: \"hello!\")\n  end\n\n  test \"version\" do\n    assert_equal \\\n      \"docker inspect kamal-proxy --format '{{.Config.Image}}' | awk -F: '{print $NF}'\",\n      new_command.version.join(\" \")\n  end\n\n  test \"ensure_proxy_directory\" do\n    assert_equal \\\n      \"mkdir -p .kamal/proxy\",\n      new_command.ensure_proxy_directory.join(\" \")\n  end\n\n  test \"read_boot_options\" do\n    assert_equal \\\n      \"cat .kamal/proxy/options 2> /dev/null || echo \\\"--publish 80:80 --publish 443:443 --log-opt max-size=10m\\\"\",\n      new_command.read_boot_options.join(\" \")\n  end\n\n  test \"read_image\" do\n    assert_equal \\\n      \"cat .kamal/proxy/image 2> /dev/null || echo \\\"basecamp/kamal-proxy\\\"\",\n      new_command.read_image.join(\" \")\n  end\n\n  test \"read_image_version\" do\n    assert_equal \\\n      \"cat .kamal/proxy/image_version 2> /dev/null || echo \\\"#{Kamal::Configuration::Proxy::Run::MINIMUM_VERSION}\\\"\",\n      new_command.read_image_version.join(\" \")\n  end\n\n  test \"read_run_command\" do\n    assert_equal \\\n      \"cat .kamal/proxy/run_command 2> /dev/null || echo \\\"\\\"\",\n      new_command.read_run_command.join(\" \")\n  end\n\n  test \"reset_boot_options\" do\n    assert_equal \\\n      \"rm .kamal/proxy/options\",\n      new_command.reset_boot_options.join(\" \")\n  end\n\n  test \"reset_image\" do\n    assert_equal \\\n      \"rm .kamal/proxy/image\",\n      new_command.reset_image.join(\" \")\n  end\n\n  test \"reset_image_version\" do\n    assert_equal \\\n      \"rm .kamal/proxy/image_version\",\n      new_command.reset_image_version.join(\" \")\n  end\n\n  test \"ensure_apps_config_directory\" do\n    assert_equal \\\n      \"mkdir -p .kamal/proxy/apps-config\",\n      new_command.ensure_apps_config_directory.join(\" \")\n  end\n\n  test \"reset_run_command\" do\n    assert_equal \\\n      \"rm .kamal/proxy/run_command\",\n      new_command.reset_run_command.join(\" \")\n  end\n\n  test \"registry run config\" do\n    @config[:proxy] = { \"run\" => { \"registry\" => \"registry:4443\" } }\n    assert_equal \\\n      \"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\",\n      new_command.run.join(\" \")\n  end\n\n  test \"repository run config\" do\n    @config[:proxy] = { \"run\" => { \"repository\" => \"custom/repo\" } }\n    assert_equal \\\n      \"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\",\n      new_command.run.join(\" \")\n  end\n\n  test \"image_version run config\" do\n    @config[:proxy] = { \"run\" => { \"version\" => \"v1.2.3\" } }\n    assert_equal \\\n      \"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\",\n      new_command.run.join(\" \")\n  end\n\n  test \"bind_ips run config\" do\n    @config[:proxy] = { \"run\" => { \"bind_ips\" => [ \"0.0.0.0\", \"127.0.0.1\" ] } }\n    assert_equal \\\n      \"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\",\n      new_command.run.join(\" \")\n  end\n\n  test \"log_max_size run config\" do\n    @config[:proxy] = { \"run\" => { \"log_max_size\" => \"50m\" } }\n    assert_equal \\\n      \"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\",\n      new_command.run.join(\" \")\n  end\n\n  test \"debug run config\" do\n    @config[:proxy] = { \"run\" => { \"debug\" => true } }\n    assert_equal \\\n      \"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\",\n      new_command.run.join(\" \")\n  end\n\n  test \"metrics_port run config\" do\n    @config[:proxy] = { \"run\" => { \"metrics_port\" => 9090 } }\n    assert_equal \\\n      \"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\\\"\",\n      new_command.run.join(\" \")\n  end\n\n  test \"don't publish run config\" do\n    @config[:proxy] = { \"run\" => { \"publish\" => false } }\n    assert_equal \\\n      \"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\",\n      new_command.run.join(\" \")\n  end\n\n  private\n    def new_command\n      Kamal::Commands::Proxy.new(Kamal::Configuration.new(@config, version: \"123\"), host: \"1.1.1.1\")\n    end\nend\n"
  },
  {
    "path": "test/commands/prune_test.rb",
    "content": "require \"test_helper\"\n\nclass CommandsPruneTest < ActiveSupport::TestCase\n  setup do\n    @config = {\n      service: \"app\", image: \"dhh/app\", registry: { \"username\" => \"dhh\", \"password\" => \"secret\" }, servers: [ \"1.1.1.1\" ],\n      builder: { \"arch\" => \"amd64\" }\n    }\n  end\n\n  test \"dangling images\" do\n    assert_equal \\\n      \"docker image prune --force --filter label=service=app\",\n      new_command.dangling_images.join(\" \")\n  end\n\n  test \"tagged images\" do\n    assert_equal \\\n      \"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:<none>\\\" | while read image tag; do docker rmi $tag; done\",\n      new_command.tagged_images.join(\" \")\n  end\n\n  test \"app containers\" do\n    assert_equal \\\n      \"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\",\n      new_command.app_containers(retain: 5).join(\" \")\n\n    assert_equal \\\n      \"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\",\n      new_command.app_containers(retain: 3).join(\" \")\n  end\n\n  private\n    def new_command\n      Kamal::Commands::Prune.new(Kamal::Configuration.new(@config, version: \"123\"))\n    end\nend\n"
  },
  {
    "path": "test/commands/registry_test.rb",
    "content": "require \"test_helper\"\n\nclass CommandsRegistryTest < ActiveSupport::TestCase\n  setup do\n    @config = {\n      service: \"app\",\n      image: \"dhh/app\",\n      registry: {\n        \"username\" => \"dhh\",\n        \"password\" => \"secret\",\n        \"server\" => \"hub.docker.com\"\n      },\n      builder: { \"arch\" => \"amd64\" },\n      servers: [ \"1.1.1.1\" ],\n      accessories: {\n        \"db\" => {\n          \"image\" => \"mysql:8.0\",\n          \"hosts\" => [ \"1.1.1.1\" ],\n          \"registry\" => {\n            \"username\" => \"user\",\n            \"password\" => \"pw\",\n            \"server\" => \"other.hub.docker.com\"\n          }\n        }\n      }\n    }\n  end\n\n  test \"registry login\" do\n    assert_equal \\\n      \"docker login hub.docker.com -u \\\"dhh\\\" -p \\\"secret\\\"\",\n      registry.login.join(\" \")\n  end\n\n  test \"given registry login\" do\n    assert_equal \\\n      \"docker login other.hub.docker.com -u \\\"user\\\" -p \\\"pw\\\"\",\n      registry.login(registry_config: accessory_registry_config).join(\" \")\n  end\n\n  test \"registry login with ENV password\" do\n    with_test_secrets(\"secrets\" => \"KAMAL_REGISTRY_PASSWORD=more-secret\\nKAMAL_MYSQL_REGISTRY_PASSWORD=secret-pw\") do\n      @config[:registry][\"password\"] = [ \"KAMAL_REGISTRY_PASSWORD\" ]\n      @config[:accessories][\"db\"][\"registry\"][\"password\"] = [ \"KAMAL_MYSQL_REGISTRY_PASSWORD\" ]\n\n      assert_equal \\\n        \"docker login hub.docker.com -u \\\"dhh\\\" -p \\\"more-secret\\\"\",\n        registry.login.join(\" \")\n\n      assert_equal \\\n        \"docker login other.hub.docker.com -u \\\"user\\\" -p \\\"secret-pw\\\"\",\n        registry.login(registry_config: accessory_registry_config).join(\" \")\n    end\n  end\n\n  test \"registry login escape password\" do\n    with_test_secrets(\"secrets\" => \"KAMAL_REGISTRY_PASSWORD=more-secret'\\\"\") do\n      @config[:registry][\"password\"] = [ \"KAMAL_REGISTRY_PASSWORD\" ]\n\n      assert_equal \\\n        \"docker login hub.docker.com -u \\\"dhh\\\" -p \\\"more-secret'\\\\\\\"\\\"\",\n        registry.login.join(\" \")\n    end\n  end\n\n  test \"registry login with ENV username\" do\n    with_test_secrets(\"secrets\" => \"KAMAL_REGISTRY_USERNAME=also-secret\") do\n      @config[:registry][\"username\"] = [ \"KAMAL_REGISTRY_USERNAME\" ]\n\n      assert_equal \\\n        \"docker login hub.docker.com -u \\\"also-secret\\\" -p \\\"secret\\\"\",\n        registry.login.join(\" \")\n    end\n  end\n\n  test \"registry logout\" do\n    assert_equal \\\n      \"docker logout hub.docker.com\",\n      registry.logout.join(\" \")\n  end\n\n  test \"given registry logout\" do\n    assert_equal \\\n      \"docker logout other.hub.docker.com\",\n      registry.logout(registry_config: accessory_registry_config).join(\" \")\n  end\n\n  test \"registry setup\" do\n    @config[:registry] = { \"server\" => \"localhost:5000\" }\n    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(\" \")\n  end\n\n  test \"registry remove\" do\n    assert_equal \"docker stop kamal-docker-registry && docker rm kamal-docker-registry\", registry.remove.join(\" \")\n  end\n\n  private\n    def registry\n      Kamal::Commands::Registry.new main_config\n    end\n\n    def main_config\n      Kamal::Configuration.new(@config)\n    end\n\n    def accessory_registry_config\n      main_config.accessory(\"db\").registry\n    end\nend\n"
  },
  {
    "path": "test/commands/server_test.rb",
    "content": "require \"test_helper\"\n\nclass CommandsServerTest < ActiveSupport::TestCase\n  setup do\n    @config = {\n      service: \"app\", image: \"dhh/app\", registry: { \"username\" => \"dhh\", \"password\" => \"secret\" }, servers: [ \"1.1.1.1\" ],\n      builder: { \"arch\" => \"amd64\" }\n    }\n  end\n\n  test \"ensure run directory\" do\n    assert_equal \"mkdir -p .kamal\", new_command.ensure_run_directory.join(\" \")\n  end\n\n  private\n    def new_command(extra_config = {})\n      Kamal::Commands::Server.new(Kamal::Configuration.new(@config.merge(extra_config)))\n    end\nend\n"
  },
  {
    "path": "test/configuration/accessory_test.rb",
    "content": "require \"test_helper\"\n\nclass ConfigurationAccessoryTest < ActiveSupport::TestCase\n  setup do\n    @deploy = {\n      service: \"app\",\n      image: \"dhh/app\",\n      registry: { \"username\" => \"dhh\", \"password\" => \"secret\" },\n      servers: {\n        \"web\" => [ { \"1.1.1.1\" => \"writer\" }, { \"1.1.1.2\" => \"reader\" } ],\n        \"workers\" => [ { \"1.1.1.3\" => \"writer\" }, \"1.1.1.4\" ]\n      },\n      builder: { \"arch\" => \"amd64\" },\n      env: { \"REDIS_URL\" => \"redis://x/y\" },\n      accessories: {\n        \"mysql\" => {\n          \"image\" => \"public.registry/mysql:8.0\",\n          \"host\" => \"1.1.1.5\",\n          \"port\" => \"3306\",\n          \"env\" => {\n            \"clear\" => {\n              \"MYSQL_ROOT_HOST\" => \"%\"\n            },\n            \"secret\" => [\n              \"MYSQL_ROOT_PASSWORD\"\n            ]\n          },\n          \"files\" => [\n            \"config/mysql/my.cnf:/etc/mysql/my.cnf\",\n            \"db/structure.sql:/docker-entrypoint-initdb.d/structure.sql\"\n          ],\n          \"directories\" => [\n            \"data:/var/lib/mysql\"\n          ]\n        },\n        \"redis\" => {\n          \"image\" => \"redis:latest\",\n          \"hosts\" => [ \"1.1.1.6\", \"1.1.1.7\" ],\n          \"port\" => \"6379:6379\",\n          \"labels\" => {\n            \"cache\" => \"true\"\n          },\n          \"env\" => {\n            \"SOMETHING\" => \"else\"\n          },\n          \"volumes\" => [\n            \"/var/lib/redis:/data\"\n          ],\n          \"options\" => {\n            \"cpus\" => \"4\",\n            \"memory\" => \"2GB\"\n          }\n        },\n        \"monitoring\" => {\n          \"service\" => \"custom-monitoring\",\n          \"image\" => \"monitoring:latest\",\n          \"registry\" => { \"server\" => \"other.registry\", \"username\" => \"user\", \"password\" => \"pw\" },\n          \"role\" => \"web\",\n          \"port\" => \"4321:4321\",\n          \"labels\" => {\n            \"cache\" => \"true\"\n          },\n          \"env\" => {\n            \"STATSD_PORT\" => \"8126\"\n          },\n          \"options\" => {\n            \"cpus\" => \"4\",\n            \"memory\" => \"2GB\"\n          },\n          \"proxy\" => {\n            \"host\" => \"monitoring.example.com\"\n          }\n        },\n        \"proxy\" => {\n          \"image\" => \"proxy:latest\",\n          \"tags\" => [ \"writer\", \"reader\" ]\n        },\n        \"logger\" => {\n          \"image\" => \"logger:latest\",\n          \"tag\" => \"writer\"\n        }\n      }\n    }\n\n    @config = Kamal::Configuration.new(@deploy)\n  end\n\n  test \"service name\" do\n    assert_equal \"app-mysql\", @config.accessory(:mysql).service_name\n    assert_equal \"app-redis\", @config.accessory(:redis).service_name\n    assert_equal \"custom-monitoring\", @config.accessory(:monitoring).service_name\n  end\n\n  test \"image\" do\n    assert_equal \"public.registry/mysql:8.0\", @config.accessory(:mysql).image\n    assert_equal \"redis:latest\", @config.accessory(:redis).image\n    assert_equal \"other.registry/monitoring:latest\", @config.accessory(:monitoring).image\n  end\n\n  test \"registry\" do\n    assert_nil @config.accessory(:mysql).registry\n    assert_nil @config.accessory(:redis).registry\n    monitoring_registry = @config.accessory(:monitoring).registry\n    assert_equal \"other.registry\", monitoring_registry.server\n    assert_equal \"user\", monitoring_registry.username\n    assert_equal \"pw\", monitoring_registry.password\n  end\n\n  test \"port\" do\n    assert_equal \"3306:3306\", @config.accessory(:mysql).port\n    assert_equal \"6379:6379\", @config.accessory(:redis).port\n  end\n\n  test \"host\" do\n    assert_equal [ \"1.1.1.5\" ], @config.accessory(:mysql).hosts\n    assert_equal [ \"1.1.1.6\", \"1.1.1.7\" ], @config.accessory(:redis).hosts\n    assert_equal [ \"1.1.1.1\", \"1.1.1.2\" ], @config.accessory(:monitoring).hosts\n    assert_equal [ \"1.1.1.1\", \"1.1.1.3\", \"1.1.1.2\" ], @config.accessory(:proxy).hosts\n    assert_equal [ \"1.1.1.1\", \"1.1.1.3\" ], @config.accessory(:logger).hosts\n  end\n\n  test \"missing host\" do\n    @deploy[:accessories][\"mysql\"][\"host\"] = nil\n\n    assert_raises(Kamal::ConfigurationError) do\n      Kamal::Configuration.new(@deploy)\n    end\n  end\n\n  test \"setting host, hosts, roles and tags\" do\n    @deploy[:accessories][\"mysql\"][\"hosts\"] = [ \"mysql-db1\" ]\n    @deploy[:accessories][\"mysql\"][\"roles\"] = [ \"db\" ]\n\n    exception = assert_raises(Kamal::ConfigurationError) do\n      Kamal::Configuration.new(@deploy)\n    end\n    assert_equal \"accessories/mysql: specify one of `host`, `hosts`, `role`, `roles`, `tag` or `tags`\", exception.message\n  end\n\n  test \"all hosts\" do\n    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\n  end\n\n  test \"label args\" do\n    assert_equal [ \"--label\", \"service=\\\"app-mysql\\\"\" ], @config.accessory(:mysql).label_args\n    assert_equal [ \"--label\", \"service=\\\"app-redis\\\"\", \"--label\", \"cache=\\\"true\\\"\" ], @config.accessory(:redis).label_args\n  end\n\n  test \"env args\" do\n    with_test_secrets(\"secrets\" => \"MYSQL_ROOT_PASSWORD=secret123\") do\n      config = Kamal::Configuration.new(@deploy)\n\n      assert_equal [ \"--env\", \"MYSQL_ROOT_HOST=\\\"%\\\"\", \"--env-file\", \".kamal/apps/app/env/accessories/mysql.env\" ], config.accessory(:mysql).env_args.map(&:to_s)\n      assert_equal \"MYSQL_ROOT_PASSWORD=secret123\\n\", config.accessory(:mysql).secrets_io.string\n      assert_equal [ \"--env\", \"SOMETHING=\\\"else\\\"\", \"--env-file\", \".kamal/apps/app/env/accessories/redis.env\" ], @config.accessory(:redis).env_args\n      assert_equal \"\\n\", config.accessory(:redis).secrets_io.string\n    end\n  end\n\n  test \"volume args\" do\n    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\n    assert_equal [ \"--volume\", \"/var/lib/redis:/data\" ], @config.accessory(:redis).volume_args\n  end\n\n  test \"volume args with docker named volume\" do\n    @deploy[:accessories][\"redis\"][\"volumes\"] = [ \"redis_data:/data\" ]\n    config = Kamal::Configuration.new(@deploy)\n    assert_equal [ \"--volume\", \"redis_data:/data\" ], config.accessory(:redis).volume_args\n  end\n\n  test \"dynamic file expansion\" do\n    @deploy[:accessories][\"mysql\"][\"env\"][\"secret\"] << \"ENV_VAR:SECRET_VAR\"\n    @deploy[:accessories][\"mysql\"][\"files\"] << \"test/fixtures/files/structure.sql.erb:/docker-entrypoint-initdb.d/structure.sql\"\n\n    with_test_secrets(\"secrets\" => \"MYSQL_ROOT_PASSWORD=secret123\\nSECRET_VAR=secret_env_value\") do\n      @config = Kamal::Configuration.new(@deploy)\n\n      assert_match \"This was dynamically expanded\", @config.accessory(:mysql).files.keys[2].read\n      assert_match \"%\", @config.accessory(:mysql).files.keys[2].read\n      assert_match \"secret123\", @config.accessory(:mysql).files.keys[2].read\n      assert_match \"secret_env_value\", @config.accessory(:mysql).files.keys[2].read\n    end\n  end\n\n  test \"directory with a relative path\" do\n    @deploy[:accessories][\"mysql\"][\"directories\"] = [ \"data:/var/lib/mysql\" ]\n    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)\n  end\n\n  test \"directory with an absolute path\" do\n    @deploy[:accessories][\"mysql\"][\"directories\"] = [ \"/var/data/mysql:/var/lib/mysql\" ]\n    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)\n  end\n\n  test \"directory with mount options\" do\n    @deploy[:accessories][\"mysql\"][\"files\"] = []\n    @deploy[:accessories][\"mysql\"][\"directories\"] = [ \"data:/var/lib/mysql:z\" ]\n    config = Kamal::Configuration.new(@deploy)\n    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)\n    assert_equal [ \"--volume\", \"$PWD/app-mysql/data:/var/lib/mysql:z\" ], config.accessory(:mysql).volume_args\n  end\n\n  test \"file with mount options\" do\n    @deploy[:accessories][\"mysql\"][\"files\"] = [ \"config/mysql/my.cnf:/etc/mysql/my.cnf:ro,z\" ]\n    @deploy[:accessories][\"mysql\"][\"directories\"] = []\n    config = Kamal::Configuration.new(@deploy)\n    files = config.accessory(:mysql).files\n    assert_equal \"ro,z\", files.values.first[:options]\n    assert_equal [ \"--volume\", \"$PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf:ro,z\" ], config.accessory(:mysql).volume_args\n  end\n\n  test \"file with string format has default mode\" do\n    @deploy[:accessories][\"mysql\"][\"files\"] = [ \"config/mysql/my.cnf:/etc/mysql/my.cnf\" ]\n    @deploy[:accessories][\"mysql\"][\"directories\"] = []\n    config = Kamal::Configuration.new(@deploy)\n    files = config.accessory(:mysql).files\n    assert_equal \"755\", files.values.first[:mode]\n    assert_nil files.values.first[:owner]\n  end\n\n  test \"file with hash format and custom mode\" do\n    @deploy[:accessories][\"mysql\"][\"files\"] = [\n      { \"local\" => \"config/mysql/my.cnf\", \"remote\" => \"/etc/mysql/my.cnf\", \"mode\" => \"0600\" }\n    ]\n    @deploy[:accessories][\"mysql\"][\"directories\"] = []\n    config = Kamal::Configuration.new(@deploy)\n    files = config.accessory(:mysql).files\n    assert_equal \"0600\", files.values.first[:mode]\n    assert_nil files.values.first[:owner]\n  end\n\n  test \"file with hash format and custom owner\" do\n    @deploy[:accessories][\"mysql\"][\"files\"] = [\n      { \"local\" => \"config/mysql/my.cnf\", \"remote\" => \"/etc/mysql/my.cnf\", \"owner\" => \"mysql:mysql\" }\n    ]\n    @deploy[:accessories][\"mysql\"][\"directories\"] = []\n    config = Kamal::Configuration.new(@deploy)\n    files = config.accessory(:mysql).files\n    assert_equal \"755\", files.values.first[:mode]\n    assert_equal \"mysql:mysql\", files.values.first[:owner]\n  end\n\n  test \"file with hash format and all options\" do\n    @deploy[:accessories][\"mysql\"][\"files\"] = [\n      { \"local\" => \"config/mysql/my.cnf\", \"remote\" => \"/etc/mysql/my.cnf\", \"mode\" => \"0640\", \"owner\" => \"1000:1000\", \"options\" => \"Z\" }\n    ]\n    @deploy[:accessories][\"mysql\"][\"directories\"] = []\n    config = Kamal::Configuration.new(@deploy)\n    files = config.accessory(:mysql).files\n    file_config = files.values.first\n    assert_equal \"0640\", file_config[:mode]\n    assert_equal \"1000:1000\", file_config[:owner]\n    assert_equal \"Z\", file_config[:options]\n    assert_equal [ \"--volume\", \"$PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf:Z\" ], config.accessory(:mysql).volume_args\n  end\n\n  test \"file with hash format erb expansion\" do\n    @deploy[:accessories][\"mysql\"][\"files\"] = [\n      { \"local\" => \"test/fixtures/files/structure.sql.erb\", \"remote\" => \"/docker-entrypoint-initdb.d/structure.sql\" }\n    ]\n    @deploy[:accessories][\"mysql\"][\"directories\"] = []\n\n    with_test_secrets(\"secrets\" => \"MYSQL_ROOT_PASSWORD=secret123\") do\n      config = Kamal::Configuration.new(@deploy)\n      files = config.accessory(:mysql).files\n      assert_match \"This was dynamically expanded\", files.keys.first.read\n    end\n  end\n\n  test \"directory with hash format and custom mode\" do\n    @deploy[:accessories][\"mysql\"][\"files\"] = []\n    @deploy[:accessories][\"mysql\"][\"directories\"] = [\n      { \"local\" => \"data\", \"remote\" => \"/var/lib/mysql\", \"mode\" => \"0750\" }\n    ]\n    config = Kamal::Configuration.new(@deploy)\n    directories = config.accessory(:mysql).directories\n    assert_equal \"0750\", directories.values.first[:mode]\n    assert_nil directories.values.first[:owner]\n  end\n\n  test \"directory with hash format and custom owner\" do\n    @deploy[:accessories][\"mysql\"][\"files\"] = []\n    @deploy[:accessories][\"mysql\"][\"directories\"] = [\n      { \"local\" => \"data\", \"remote\" => \"/var/lib/mysql\", \"owner\" => \"mysql:mysql\" }\n    ]\n    config = Kamal::Configuration.new(@deploy)\n    directories = config.accessory(:mysql).directories\n    assert_nil directories.values.first[:mode]\n    assert_equal \"mysql:mysql\", directories.values.first[:owner]\n  end\n\n  test \"directory with hash format and all options\" do\n    @deploy[:accessories][\"mysql\"][\"files\"] = []\n    @deploy[:accessories][\"mysql\"][\"directories\"] = [\n      { \"local\" => \"data\", \"remote\" => \"/var/lib/mysql\", \"mode\" => \"0750\", \"owner\" => \"1000:1000\", \"options\" => \"z\" }\n    ]\n    config = Kamal::Configuration.new(@deploy)\n    directories = config.accessory(:mysql).directories\n    dir_config = directories.values.first\n    assert_equal \"0750\", dir_config[:mode]\n    assert_equal \"1000:1000\", dir_config[:owner]\n    assert_equal \"z\", dir_config[:options]\n    assert_equal [ \"--volume\", \"$PWD/app-mysql/data:/var/lib/mysql:z\" ], config.accessory(:mysql).volume_args\n  end\n\n  test \"options\" do\n    assert_equal [ \"--cpus\", \"\\\"4\\\"\", \"--memory\", \"\\\"2GB\\\"\" ], @config.accessory(:redis).option_args\n  end\n\n  test \"network_args default\" do\n    assert_equal [ \"--network\", \"kamal\" ], @config.accessory(:mysql).network_args\n  end\n\n  test \"network_args with configured options\" do\n    @deploy[:accessories][\"mysql\"][\"network\"] = \"database\"\n    assert_equal [ \"--network\", \"database\" ], @config.accessory(:mysql).network_args\n  end\n\n  test \"proxy\" do\n    assert @config.accessory(:monitoring).running_proxy?\n    assert_equal [ \"monitoring.example.com\" ], @config.accessory(:monitoring).proxy.hosts\n  end\n\n  test \"can't set restart in options\" do\n    @deploy[:accessories][\"mysql\"][\"options\"] = { \"restart\" => \"always\" }\n\n    assert_raises Kamal::ConfigurationError, \"servers/workers: Cannot set restart policy in docker options, unless-stopped is required\" do\n      Kamal::Configuration.new(@deploy)\n    end\n  end\nend\n"
  },
  {
    "path": "test/configuration/boot_test.rb",
    "content": "require \"test_helper\"\n\nclass ConfigurationBootTest < ActiveSupport::TestCase\n  test \"no boot config\" do\n    config = config_with_boot(nil)\n\n    assert_nil config.boot.limit\n    assert_nil config.boot.wait\n    assert_nil config.boot.parallel_roles\n  end\n\n  test \"specific limit group strategy\" do\n    config = config_with_boot(\"limit\" => 3, \"wait\" => 2)\n\n    assert_equal 3, config.boot.limit\n    assert_equal 2, config.boot.wait\n  end\n\n  test \"percentage-based group strategy\" do\n    config = config_with_boot(\"limit\" => \"50%\", \"wait\" => 2)\n\n    assert_equal 2, config.boot.limit\n    assert_equal 2, config.boot.wait\n  end\n\n  test \"percentage-based group strategy limit is at least 1\" do\n    config = config_with_boot(\"limit\" => \"1%\", \"wait\" => 2)\n\n    assert_equal 1, config.boot.limit\n    assert_equal 2, config.boot.wait\n  end\n\n  test \"parallel_roles\" do\n    config = config_with_boot(\"parallel_roles\" => true)\n\n    assert_equal true, config.boot.parallel_roles\n  end\n\n  private\n    def config_with_boot(boot)\n      deploy = {\n        service: \"app\", image: \"dhh/app\", registry: { \"username\" => \"dhh\", \"password\" => \"secret\" }, builder: { \"arch\" => \"amd64\" },\n        servers: { \"web\" => [ \"1.1.1.1\", \"1.1.1.2\" ], \"workers\" => [ \"1.1.1.3\", \"1.1.1.4\" ] },\n        boot: boot\n      }.compact\n\n      Kamal::Configuration.new(deploy)\n    end\nend\n"
  },
  {
    "path": "test/configuration/builder_test.rb",
    "content": "require \"test_helper\"\n\nclass ConfigurationBuilderTest < ActiveSupport::TestCase\n  setup do\n    @deploy = {\n      service: \"app\", image: \"dhh/app\", registry: { \"username\" => \"dhh\", \"password\" => \"secret\" },\n      builder: { \"arch\" => \"amd64\" }, servers: [ \"1.1.1.1\" ]\n    }\n  end\n\n  test \"local?\" do\n    assert_equal true, config.builder.local?\n  end\n\n  test \"remote?\" do\n    assert_equal false, config.builder.remote?\n  end\n\n  test \"pack?\" do\n    assert_not config.builder.pack?\n  end\n\n  test \"pack? with pack builder\" do\n    @deploy[:builder] = { \"arch\" => \"arm64\", \"pack\" => { \"builder\" => \"heroku/builder:24\" } }\n\n    assert config.builder.pack?\n  end\n\n  test \"pack details\" do\n    @deploy[:builder] = { \"arch\" => \"amd64\", \"pack\" => { \"builder\" => \"heroku/builder:24\", \"buildpacks\" => [ \"heroku/ruby\", \"heroku/procfile\" ] } }\n\n    assert_equal \"heroku/builder:24\", config.builder.pack_builder\n    assert_equal [ \"heroku/ruby\", \"heroku/procfile\" ], config.builder.pack_buildpacks\n  end\n\n  test \"remote\" do\n    assert_nil config.builder.remote\n  end\n\n  test \"setting both local and remote configs\" do\n    @deploy[:builder] = {\n      \"arch\" => [ \"amd64\", \"arm64\" ],\n      \"remote\" => \"ssh://root@192.168.0.1\"\n    }\n\n    assert_equal true, config.builder.local?\n    assert_equal true, config.builder.remote?\n\n    assert_equal [ \"amd64\", \"arm64\" ], config.builder.arches\n    assert_equal \"ssh://root@192.168.0.1\", config.builder.remote\n  end\n\n  test \"cached?\" do\n    assert_equal false, config.builder.cached?\n  end\n\n  test \"invalid cache type specified\" do\n    @deploy[:builder][\"cache\"] = { \"type\" => \"invalid\" }\n\n    assert_raises(Kamal::ConfigurationError) do\n      config.builder\n    end\n  end\n\n  test \"cache_from\" do\n    assert_nil config.builder.cache_from\n  end\n\n  test \"cache_to\" do\n    assert_nil config.builder.cache_to\n  end\n\n  test \"setting gha cache\" do\n    @deploy[:builder] = { \"arch\" => \"amd64\", \"cache\" => { \"type\" => \"gha\", \"options\" => \"mode=max,scope=test\" } }\n\n    assert_equal \"type=gha,scope=test\", config.builder.cache_from\n    assert_equal \"type=gha,mode=max,scope=test\", config.builder.cache_to\n  end\n\n  test \"setting registry cache\" do\n    @deploy[:builder] = { \"arch\" => \"amd64\", \"cache\" => { \"type\" => \"registry\", \"options\" => \"mode=max,image-manifest=true,oci-mediatypes=true\" } }\n\n    assert_equal \"type=registry,ref=dhh/app-build-cache\", config.builder.cache_from\n    assert_equal \"type=registry,ref=dhh/app-build-cache,mode=max,image-manifest=true,oci-mediatypes=true\", config.builder.cache_to\n  end\n\n  test \"setting registry cache when using a custom registry\" do\n    @deploy[:registry][\"server\"] = \"registry.example.com\"\n    @deploy[:builder] = { \"arch\" => \"amd64\", \"cache\" => { \"type\" => \"registry\", \"options\" => \"mode=max,image-manifest=true,oci-mediatypes=true\" } }\n\n    assert_equal \"type=registry,ref=registry.example.com/dhh/app-build-cache\", config.builder.cache_from\n    assert_equal \"type=registry,ref=registry.example.com/dhh/app-build-cache,mode=max,image-manifest=true,oci-mediatypes=true\", config.builder.cache_to\n  end\n\n  test \"setting registry cache with image\" do\n    @deploy[:builder] = { \"arch\" => \"amd64\", \"cache\" => { \"type\" => \"registry\", \"image\" => \"kamal\", \"options\" => \"mode=max\" } }\n\n    assert_equal \"type=registry,ref=kamal\", config.builder.cache_from\n    assert_equal \"type=registry,ref=kamal,mode=max\", config.builder.cache_to\n  end\n\n  test \"args\" do\n    assert_equal({}, config.builder.args)\n  end\n\n  test \"setting args\" do\n    @deploy[:builder][\"args\"] = { \"key\" => \"value\" }\n\n    assert_equal({ \"key\" => \"value\" }, config.builder.args)\n  end\n\n  test \"secrets\" do\n    assert_equal({}, config.builder.secrets)\n  end\n\n  test \"setting secrets\" do\n    with_test_secrets(\"secrets\" => \"GITHUB_TOKEN=secret123\") do\n      @deploy[:builder][\"secrets\"] = [ \"GITHUB_TOKEN\" ]\n\n      assert_equal({ \"GITHUB_TOKEN\" => \"secret123\" }, config.builder.secrets)\n    end\n  end\n\n  test \"dockerfile\" do\n    assert_equal \"Dockerfile\", config.builder.dockerfile\n  end\n\n  test \"setting dockerfile\" do\n    @deploy[:builder][\"dockerfile\"] = \"Dockerfile.dev\"\n\n    assert_equal \"Dockerfile.dev\", config.builder.dockerfile\n  end\n\n  test \"context\" do\n    assert_equal \".\", config.builder.context\n  end\n\n  test \"setting context\" do\n    @deploy[:builder][\"context\"] = \"..\"\n\n    assert_equal \"..\", config.builder.context\n  end\n\n  test \"ssh\" do\n    assert_nil config.builder.ssh\n  end\n\n  test \"setting ssh params\" do\n    @deploy[:builder][\"ssh\"] = \"default=$SSH_AUTH_SOCK\"\n\n    assert_equal \"default=$SSH_AUTH_SOCK\", config.builder.ssh\n  end\n\n  test \"provenance\" do\n    assert_nil config.builder.provenance\n  end\n\n  test \"setting provenance\" do\n    @deploy[:builder][\"provenance\"] = \"mode=max\"\n\n    assert_equal \"mode=max\", config.builder.provenance\n  end\n\n  test \"sbom\" do\n    assert_nil config.builder.sbom\n  end\n\n  test \"setting sbom\" do\n    @deploy[:builder][\"sbom\"] = true\n\n    assert_equal true, config.builder.sbom\n  end\n\n  test \"local disabled but no remote set\" do\n    @deploy[:builder][\"local\"] = false\n\n    assert_raises(Kamal::ConfigurationError) do\n      config.builder\n    end\n  end\n\n  test \"local disabled all arches are remote\" do\n    @deploy[:builder][\"local\"] = false\n    @deploy[:builder][\"remote\"] = \"ssh://root@192.168.0.1\"\n    @deploy[:builder][\"arch\"] = [ \"amd64\", \"arm64\" ]\n\n    assert_equal [], config.builder.local_arches\n    assert_equal [ \"amd64\", \"arm64\" ], config.builder.remote_arches\n  end\n\n  private\n    def config\n      Kamal::Configuration.new(@deploy)\n    end\nend\n"
  },
  {
    "path": "test/configuration/env/tags_test.rb",
    "content": "require \"test_helper\"\n\nclass ConfigurationEnvTagsTest < ActiveSupport::TestCase\n  setup do\n    @deploy = {\n      service: \"app\", image: \"dhh/app\", registry: { \"username\" => \"dhh\", \"password\" => \"secret\" },\n      servers: [ { \"1.1.1.1\" => \"odd\" }, { \"1.1.1.2\" => \"even\" }, { \"1.1.1.3\" => [ \"odd\", \"three\" ] } ],\n      builder: { \"arch\" => \"amd64\" },\n      env: {\n        \"clear\" => { \"REDIS_URL\" => \"redis://x/y\", \"THREE\" => \"false\" },\n        \"tags\" => {\n          \"odd\" => { \"TYPE\" => \"odd\" },\n          \"even\" => { \"TYPE\" => \"even\" },\n          \"three\" => { \"THREE\" => \"true\" }\n        }\n      }\n    }\n\n    @config = Kamal::Configuration.new(@deploy)\n\n    @deploy_with_roles = @deploy.dup.merge({\n      servers: {\n        \"web\" => [ { \"1.1.1.1\" => \"odd\" }, \"1.1.1.2\" ],\n        \"workers\" => {\n          \"hosts\" => [ { \"1.1.1.3\" => [ \"odd\", \"oddjob\" ] }, \"1.1.1.4\" ],\n          \"cmd\" => \"bin/jobs\",\n          \"env\" => {\n            \"REDIS_URL\" => \"redis://a/b\",\n            \"WEB_CONCURRENCY\" => 4\n          }\n        }\n      },\n      env: {\n        \"tags\" => {\n          \"odd\" => { \"TYPE\" => \"odd\" },\n          \"oddjob\" => { \"TYPE\" => \"oddjob\" }\n        }\n      }\n    })\n\n    @config_with_roles = Kamal::Configuration.new(@deploy_with_roles)\n  end\n\n  test \"tags\" do\n    assert_equal 3, @config.env_tags.size\n    assert_equal %w[ odd even three ], @config.env_tags.map(&:name)\n    assert_equal({ \"TYPE\" => \"odd\" }, @config.env_tag(\"odd\").env.clear)\n    assert_equal({ \"TYPE\" => \"even\" }, @config.env_tag(\"even\").env.clear)\n    assert_equal({ \"THREE\" => \"true\" }, @config.env_tag(\"three\").env.clear)\n  end\n\n  test \"tags with roles\" do\n    assert_equal 2, @config_with_roles.env_tags.size\n    assert_equal %w[ odd oddjob ], @config_with_roles.env_tags.map(&:name)\n    assert_equal({ \"TYPE\" => \"odd\" }, @config_with_roles.env_tag(\"odd\").env.clear)\n    assert_equal({ \"TYPE\" => \"oddjob\" }, @config_with_roles.env_tag(\"oddjob\").env.clear)\n  end\n\n  test \"tag overrides env\" do\n    assert_equal \"false\", @config.role(\"web\").env(\"1.1.1.1\").clear[\"THREE\"]\n    assert_equal \"true\", @config.role(\"web\").env(\"1.1.1.3\").clear[\"THREE\"]\n  end\n\n  test \"later tag wins\" do\n    deploy = {\n      service: \"app\", image: \"dhh/app\", registry: { \"username\" => \"dhh\", \"password\" => \"secret\" },\n      servers: [ { \"1.1.1.1\" => [ \"first\", \"second\" ] } ],\n      builder: { \"arch\" => \"amd64\" },\n      env: {\n        \"tags\" => {\n          \"first\" => { \"TYPE\" => \"first\" },\n          \"second\" => { \"TYPE\" => \"second\" }\n        }\n      }\n    }\n\n    config = Kamal::Configuration.new(deploy)\n    assert_equal \"second\", config.role(\"web\").env(\"1.1.1.1\").clear[\"TYPE\"]\n  end\n\n  test \"tag secret env\" do\n    with_test_secrets(\"secrets\" => \"PASSWORD=hello\") do\n      deploy = {\n        service: \"app\", image: \"dhh/app\", registry: { \"username\" => \"dhh\", \"password\" => \"secret\" },\n        servers: [ { \"1.1.1.1\" => \"secrets\" } ],\n        builder: { \"arch\" => \"amd64\" },\n        env: {\n          \"tags\" => {\n            \"secrets\" => { \"secret\" => [ \"PASSWORD\" ] }\n          }\n        }\n      }\n\n      config = Kamal::Configuration.new(deploy)\n      assert_equal \"PASSWORD=hello\\n\", config.role(\"web\").env(\"1.1.1.1\").secrets_io.string\n    end\n  end\n\n  test \"aliased tag secret env\" do\n    with_test_secrets(\"secrets\" => \"PASSWORD=hello\\nALIASED_PASSWORD=aliased_hello\") do\n      deploy = {\n        service: \"app\", image: \"dhh/app\", registry: { \"username\" => \"dhh\", \"password\" => \"secret\" },\n        servers: [ { \"1.1.1.1\" => \"secrets\" } ],\n        builder: { \"arch\" => \"amd64\" },\n        env: {\n          \"tags\" => {\n            \"secrets\" => { \"secret\" => [ \"PASSWORD:ALIASED_PASSWORD\" ] }\n          }\n        }\n      }\n\n      config = Kamal::Configuration.new(deploy)\n      assert_equal \"PASSWORD=aliased_hello\\n\", config.role(\"web\").env(\"1.1.1.1\").secrets_io.string\n    end\n  end\n\n  test \"tag clear env\" do\n    deploy = {\n      service: \"app\", image: \"dhh/app\", registry: { \"username\" => \"dhh\", \"password\" => \"secret\" },\n      servers: [ { \"1.1.1.1\" => \"clearly\" } ],\n      builder: { \"arch\" => \"amd64\" },\n      env: {\n        \"tags\" => {\n          \"clearly\" => { \"clear\" => { \"FOO\" => \"bar\" } }\n        }\n      }\n    }\n\n    config = Kamal::Configuration.new(deploy)\n    assert_equal \"bar\", config.role(\"web\").env(\"1.1.1.1\").clear[\"FOO\"]\n  end\nend\n"
  },
  {
    "path": "test/configuration/env_test.rb",
    "content": "require \"test_helper\"\n\nclass ConfigurationEnvTest < ActiveSupport::TestCase\n  require \"test_helper\"\n\n  test \"simple\" do\n    assert_config \\\n      config: { \"foo\" => \"bar\", \"baz\" => \"haz\" },\n      clear: { \"foo\" => \"bar\", \"baz\" => \"haz\" }\n  end\n\n  test \"clear\" do\n    assert_config \\\n      config: { \"clear\" => { \"foo\" => \"bar\", \"baz\" => \"haz\" } },\n      clear: { \"foo\" => \"bar\", \"baz\" => \"haz\" }\n  end\n\n  test \"secret\" do\n    with_test_secrets(\"secrets\" => \"PASSWORD=hello\") do\n      assert_config \\\n        config: { \"secret\" => [ \"PASSWORD\" ] },\n        secrets: { \"PASSWORD\" => \"hello\" }\n    end\n  end\n\n  test \"missing secret\" do\n    env = {\n      \"secret\" => [ \"PASSWORD\" ]\n    }\n\n    assert_raises(Kamal::ConfigurationError) do\n      Kamal::Configuration::Env.new(\n        config: { \"secret\" => [ \"PASSWORD\" ] },\n        secrets: Kamal::Secrets.new(secrets_path: \".kamal/secrets\")\n      ).secrets_io\n    end\n  end\n\n  test \"secret and clear\" do\n    with_test_secrets(\"secrets\" => \"PASSWORD=hello\") do\n      config = {\n        \"secret\" => [ \"PASSWORD\" ],\n        \"clear\" => {\n          \"foo\" => \"bar\",\n          \"baz\" => \"haz\"\n        }\n      }\n\n      assert_config \\\n        config: config,\n        clear: { \"foo\" => \"bar\", \"baz\" => \"haz\" },\n        secrets: { \"PASSWORD\" => \"hello\" }\n    end\n  end\n\n  test \"aliased secrets\" do\n    with_test_secrets(\"secrets\" => \"ALIASED_PASSWORD=hello\") do\n      config = {\n        \"secret\" => [ \"PASSWORD:ALIASED_PASSWORD\" ],\n        \"clear\" => {}\n      }\n\n      assert_config \\\n        config: config,\n        clear: {},\n        secrets: { \"PASSWORD\" => \"hello\" }\n    end\n  end\n\n  private\n    def assert_config(config:, clear: {}, secrets: {})\n      env = Kamal::Configuration::Env.new config: config, secrets: Kamal::Secrets.new(secrets_path: \".kamal/secrets\")\n      expected_clear_args = clear.to_a.flat_map { |key, value| [ \"--env\", \"#{key}=\\\"#{value}\\\"\" ] }\n      assert_equal expected_clear_args, env.clear_args.map(&:to_s) #  to_s removes the redactions\n      expected_secrets = secrets.to_a.flat_map { |key, value| \"#{key}=#{value}\" }.join(\"\\n\") + \"\\n\"\n      assert_equal expected_secrets, env.secrets_io.string\n    end\nend\n"
  },
  {
    "path": "test/configuration/proxy/boot_test.rb",
    "content": "require \"test_helper\"\n\nclass ConfigurationProxyBootTest < ActiveSupport::TestCase\n  setup do\n    ENV[\"RAILS_MASTER_KEY\"] = \"456\"\n    ENV[\"VERSION\"] = \"missing\"\n\n    @deploy = {\n      service: \"app\", image: \"dhh/app\",\n      registry: { \"username\" => \"dhh\", \"password\" => \"secret\" },\n      builder: { \"arch\" => \"amd64\" },\n      env: { \"REDIS_URL\" => \"redis://x/y\" },\n      servers: [ \"1.1.1.1\", \"1.1.1.2\" ],\n      volumes: [ \"/local/path:/container/path\" ]\n    }\n\n    @config = Kamal::Configuration.new(@deploy)\n    @proxy_boot_config = @config.proxy_boot\n  end\n\n  test \"proxy directories\" do\n    assert_equal \".kamal/proxy/apps-config\", @proxy_boot_config.apps_directory\n    assert_equal \"/home/kamal-proxy/.apps-config\", @proxy_boot_config.apps_container_directory\n    assert_equal \".kamal/proxy/apps-config/app\", @proxy_boot_config.app_directory\n    assert_equal \"/home/kamal-proxy/.apps-config/app\", @proxy_boot_config.app_container_directory\n    assert_equal \".kamal/proxy/apps-config/app/error_pages\", @proxy_boot_config.error_pages_directory\n    assert_equal \"/home/kamal-proxy/.apps-config/app/error_pages\", @proxy_boot_config.error_pages_container_directory\n    assert_equal \".kamal/proxy/apps-config/app/tls\", @proxy_boot_config.tls_directory\n    assert_equal \"/home/kamal-proxy/.apps-config/app/tls\", @proxy_boot_config.tls_container_directory\n  end\nend\n"
  },
  {
    "path": "test/configuration/proxy_test.rb",
    "content": "require \"test_helper\"\n\nclass ConfigurationProxyTest < ActiveSupport::TestCase\n  setup do\n    @deploy = {\n      service: \"app\", image: \"dhh/app\", registry: { \"username\" => \"dhh\", \"password\" => \"secret\" },\n      builder: { \"arch\" => \"amd64\" }, servers: [ \"1.1.1.1\" ]\n    }\n  end\n\n  test \"ssl with host\" do\n    @deploy[:proxy] = { \"ssl\" => true, \"host\" => \"example.com\" }\n    assert_equal true, config.proxy.ssl?\n  end\n\n  test \"ssl with multiple hosts passed via host\" do\n    @deploy[:proxy] = { \"ssl\" => true, \"host\" => \"example.com,anotherexample.com\" }\n    assert_equal true, config.proxy.ssl?\n  end\n\n  test \"ssl with multiple hosts passed via hosts\" do\n    @deploy[:proxy] = { \"ssl\" => true, \"hosts\" => [ \"example.com\", \"anotherexample.com\" ] }\n    assert_equal true, config.proxy.ssl?\n  end\n\n  test \"ssl with no host\" do\n    @deploy[:proxy] = { \"ssl\" => true }\n    assert_raises(Kamal::ConfigurationError) { config.proxy.ssl? }\n  end\n\n  test \"ssl with both host and hosts\" do\n    @deploy[:proxy] = { \"ssl\" => true, host: \"example.com\", hosts: [ \"anotherexample.com\" ] }\n    assert_raises(Kamal::ConfigurationError) { config.proxy.ssl? }\n  end\n\n  test \"ssl false\" do\n    @deploy[:proxy] = { \"ssl\" => false }\n    assert_not config.proxy.ssl?\n  end\n\n  test \"false not allowed\" do\n    @deploy[:proxy] = false\n    assert_raises(Kamal::ConfigurationError, \"proxy: should be a hash\") do\n      config.proxy\n    end\n  end\n\n  test \"ssl with certificate and private key from secrets\" do\n    with_test_secrets(\"secrets\" => \"CERT_PEM=certificate\\nKEY_PEM=private_key\") do\n      @deploy[:proxy] = {\n        \"ssl\" => {\n          \"certificate_pem\" => \"CERT_PEM\",\n          \"private_key_pem\" => \"KEY_PEM\"\n        },\n        \"host\" => \"example.com\"\n      }\n\n      proxy = config.proxy\n      assert_equal \".kamal/proxy/apps-config/app/tls/cert.pem\", proxy.host_tls_cert\n      assert_equal \".kamal/proxy/apps-config/app/tls/key.pem\", proxy.host_tls_key\n      assert_equal \"/home/kamal-proxy/.apps-config/app/tls/cert.pem\", proxy.container_tls_cert\n      assert_equal \"/home/kamal-proxy/.apps-config/app/tls/key.pem\", proxy.container_tls_key\n    end\n  end\n\n  test \"deploy options with custom ssl certificates\" do\n    with_test_secrets(\"secrets\" => \"CERT_PEM=certificate\\nKEY_PEM=private_key\") do\n      @deploy[:proxy] = {\n        \"ssl\" => {\n          \"certificate_pem\" => \"CERT_PEM\",\n          \"private_key_pem\" => \"KEY_PEM\"\n        },\n        \"host\" => \"example.com\"\n      }\n\n      proxy = config.proxy\n      options = proxy.deploy_options\n      assert_equal true, options[:tls]\n      assert_equal \"/home/kamal-proxy/.apps-config/app/tls/cert.pem\", options[:\"tls-certificate-path\"]\n      assert_equal \"/home/kamal-proxy/.apps-config/app/tls/key.pem\", options[:\"tls-private-key-path\"]\n    end\n  end\n\n  test \"ssl with certificate and no private key\" do\n    with_test_secrets(\"secrets\" => \"CERT_PEM=certificate\") do\n      @deploy[:proxy] = {\n        \"ssl\" => {\n          \"certificate_pem\" => \"CERT_PEM\"\n        },\n        \"host\" => \"example.com\"\n      }\n      assert_raises(Kamal::ConfigurationError) { config.proxy.ssl? }\n    end\n  end\n\n  test \"ssl with private key and no certificate\" do\n    with_test_secrets(\"secrets\" => \"KEY_PEM=private_key\") do\n      @deploy[:proxy] = {\n        \"ssl\" => {\n          \"private_key_pem\" => \"KEY_PEM\"\n        },\n        \"host\" => \"example.com\"\n      }\n      assert_raises(Kamal::ConfigurationError) { config.proxy.ssl? }\n    end\n  end\n\n  private\n    def config\n      Kamal::Configuration.new(@deploy)\n    end\nend\n"
  },
  {
    "path": "test/configuration/role_test.rb",
    "content": "require \"test_helper\"\n\nclass ConfigurationRoleTest < ActiveSupport::TestCase\n  setup do\n    @deploy = {\n      service: \"app\", image: \"dhh/app\", registry: { \"username\" => \"dhh\", \"password\" => \"secret\" },\n      servers: [ \"1.1.1.1\", \"1.1.1.2\" ],\n      builder: { \"arch\" => \"amd64\" },\n      env: { \"REDIS_URL\" => \"redis://x/y\" }\n    }\n\n    @deploy_with_roles = @deploy.dup.merge({\n      servers: {\n        \"web\" => [ \"1.1.1.1\", \"1.1.1.2\" ],\n        \"workers\" => {\n          \"hosts\" => [ \"1.1.1.3\", \"1.1.1.4\" ],\n          \"cmd\" => \"bin/jobs\",\n          \"env\" => {\n            \"REDIS_URL\" => \"redis://a/b\",\n            \"WEB_CONCURRENCY\" => \"4\"\n          }\n        }\n      }\n    })\n  end\n\n  test \"hosts\" do\n    assert_equal [ \"1.1.1.1\", \"1.1.1.2\" ], config.role(:web).hosts\n    assert_equal [ \"1.1.1.3\", \"1.1.1.4\" ], config_with_roles.role(:workers).hosts\n  end\n\n  test \"missing env tag is ignored\" do\n    @deploy_with_roles[:servers][\"workers\"][\"hosts\"] = [ { \"1.1.1.3\" => [ \"job\" ] } ]\n\n    role = Kamal::Configuration.new(@deploy_with_roles).role(:workers)\n    assert_equal \"redis://a/b\", role.env(\"1.1.1.3\").clear[\"REDIS_URL\"]\n  end\n\n  test \"cmd\" do\n    assert_nil config.role(:web).cmd\n    assert_equal \"bin/jobs\", config_with_roles.role(:workers).cmd\n  end\n\n  test \"label args\" do\n    assert_equal [ \"--label\", \"service=\\\"app\\\"\", \"--label\", \"role=\\\"workers\\\"\", \"--label\", \"destination\" ], config_with_roles.role(:workers).label_args\n  end\n\n  test \"special label args for web\" do\n    assert_equal [ \"--label\", \"service=\\\"app\\\"\", \"--label\", \"role=\\\"web\\\"\", \"--label\", \"destination\" ], config.role(:web).label_args\n  end\n\n  test \"custom labels\" do\n    @deploy[:labels] = { \"my.custom.label\" => \"50\" }\n    assert_equal \"50\", config.role(:web).labels[\"my.custom.label\"]\n  end\n\n  test \"custom labels via role specialization\" do\n    @deploy_with_roles[:labels] = { \"my.custom.label\" => \"50\" }\n    @deploy_with_roles[:servers][\"workers\"][\"labels\"] = { \"my.custom.label\" => \"70\" }\n    assert_equal \"70\", Kamal::Configuration.new(@deploy_with_roles).role(:workers).labels[\"my.custom.label\"]\n  end\n\n  test \"default proxy label on non-web role\" do\n    config = Kamal::Configuration.new(@deploy_with_roles.tap { |c|\n      c[:servers][\"beta\"] = { \"proxy\" => true, \"hosts\" => [ \"1.1.1.5\" ] }\n    })\n\n    assert_equal [ \"--label\", \"service=\\\"app\\\"\", \"--label\", \"role=\\\"beta\\\"\", \"--label\", \"destination\" ], config.role(:beta).label_args\n  end\n\n  test \"env overwritten by role\" do\n    assert_equal \"redis://a/b\", config_with_roles.role(:workers).env(\"1.1.1.3\").clear[\"REDIS_URL\"]\n\n    assert_equal \\\n      [ \"--env\", \"REDIS_URL=\\\"redis://a/b\\\"\", \"--env\", \"WEB_CONCURRENCY=\\\"4\\\"\", \"--env-file\", \".kamal/apps/app/env/roles/workers.env\" ],\n      config_with_roles.role(:workers).env_args(\"1.1.1.3\").map(&:to_s)\n\n    assert_equal \\\n      \"\\n\",\n      config_with_roles.role(:workers).secrets_io(\"1.1.1.3\").read\n  end\n\n  test \"container name\" do\n    ENV[\"VERSION\"] = \"12345\"\n\n    assert_equal \"app-workers-12345\", config_with_roles.role(:workers).container_name\n    assert_equal \"app-web-12345\", config_with_roles.role(:web).container_name\n  ensure\n    ENV.delete(\"VERSION\")\n  end\n\n  test \"env args\" do\n    assert_equal \\\n      [ \"--env\", \"REDIS_URL=\\\"redis://a/b\\\"\", \"--env\", \"WEB_CONCURRENCY=\\\"4\\\"\", \"--env-file\", \".kamal/apps/app/env/roles/workers.env\" ],\n      config_with_roles.role(:workers).env_args(\"1.1.1.3\").map(&:to_s)\n\n    assert_equal \\\n      \"\\n\",\n      config_with_roles.role(:workers).secrets_io(\"1.1.1.3\").read\n  end\n\n  test \"env secret overwritten by role\" do\n    with_test_secrets(\"secrets\" => \"REDIS_PASSWORD=secret456\\nDB_PASSWORD=secret&\\\"123\") do\n      @deploy_with_roles[:env] = {\n        \"clear\" => {\n          \"REDIS_URL\" => \"redis://a/b\"\n        },\n        \"secret\" => [\n          \"REDIS_PASSWORD\"\n        ]\n      }\n\n      @deploy_with_roles[:servers][\"workers\"][\"env\"] = {\n        \"clear\" => {\n          \"REDIS_URL\" => \"redis://a/b\",\n          \"WEB_CONCURRENCY\" => \"4\"\n        },\n        \"secret\" => [\n          \"DB_PASSWORD\"\n        ]\n      }\n\n      assert_equal \\\n        [ \"--env\", \"REDIS_URL=\\\"redis://a/b\\\"\", \"--env\", \"WEB_CONCURRENCY=\\\"4\\\"\", \"--env-file\", \".kamal/apps/app/env/roles/workers.env\" ],\n        config_with_roles.role(:workers).env_args(\"1.1.1.3\").map(&:to_s)\n\n      assert_equal \\\n        \"REDIS_PASSWORD=secret456\\nDB_PASSWORD=secret&\\\"123\\n\",\n        config_with_roles.role(:workers).secrets_io(\"1.1.1.3\").read\n    end\n  end\n\n  test \"env secrets only in role\" do\n    with_test_secrets(\"secrets\" => \"DB_PASSWORD=secret123\") do\n      @deploy_with_roles[:servers][\"workers\"][\"env\"] = {\n        \"clear\" => {\n          \"REDIS_URL\" => \"redis://a/b\",\n          \"WEB_CONCURRENCY\" => \"4\"\n        },\n        \"secret\" => [\n          \"DB_PASSWORD\"\n        ]\n      }\n\n      assert_equal \\\n        [ \"--env\", \"REDIS_URL=\\\"redis://a/b\\\"\", \"--env\", \"WEB_CONCURRENCY=\\\"4\\\"\", \"--env-file\", \".kamal/apps/app/env/roles/workers.env\" ],\n        config_with_roles.role(:workers).env_args(\"1.1.1.3\").map(&:to_s)\n\n      assert_equal \\\n        \"DB_PASSWORD=secret123\\n\",\n        config_with_roles.role(:workers).secrets_io(\"1.1.1.3\").read\n    end\n  end\n\n  test \"env secrets only at top level\" do\n    with_test_secrets(\"secrets\" => \"REDIS_PASSWORD=secret456\") do\n      @deploy_with_roles[:env] = {\n        \"clear\" => {\n          \"REDIS_URL\" => \"redis://a/b\"\n        },\n        \"secret\" => [\n          \"REDIS_PASSWORD\"\n        ]\n      }\n\n      assert_equal \\\n        [ \"--env\", \"REDIS_URL=\\\"redis://a/b\\\"\", \"--env\", \"WEB_CONCURRENCY=\\\"4\\\"\", \"--env-file\", \".kamal/apps/app/env/roles/workers.env\" ],\n        config_with_roles.role(:workers).env_args(\"1.1.1.3\").map(&:to_s)\n\n      assert_equal \\\n        \"REDIS_PASSWORD=secret456\\n\",\n        config_with_roles.role(:workers).secrets_io(\"1.1.1.3\").read\n    end\n  end\n\n  test \"env overwritten by role with secrets\" do\n    with_test_secrets(\"secrets\" => \"REDIS_PASSWORD=secret456\") do\n      @deploy_with_roles[:env] = {\n        \"clear\" => {\n          \"REDIS_URL\" => \"redis://a/b\"\n        },\n        \"secret\" => [\n          \"REDIS_PASSWORD\"\n        ]\n      }\n\n      @deploy_with_roles[:servers][\"workers\"][\"env\"] = {\n        \"clear\" => {\n          \"REDIS_URL\" => \"redis://c/d\"\n        }\n      }\n\n      assert_equal \\\n        [ \"--env\", \"REDIS_URL=\\\"redis://c/d\\\"\", \"--env-file\", \".kamal/apps/app/env/roles/workers.env\" ],\n        config_with_roles.role(:workers).env_args(\"1.1.1.3\").map(&:to_s)\n\n      assert_equal \\\n        \"REDIS_PASSWORD=secret456\\n\",\n        config_with_roles.role(:workers).secrets_io(\"1.1.1.3\").read\n    end\n  end\n\n  test \"asset path and volume args\" do\n    ENV[\"VERSION\"] = \"12345\"\n    assert_nil config_with_roles.role(:web).asset_volume_args\n    assert_nil config_with_roles.role(:workers).asset_volume_args\n    assert_nil config_with_roles.role(:web).asset_path\n    assert_nil config_with_roles.role(:workers).asset_path\n    assert_not config_with_roles.role(:web).assets?\n    assert_not config_with_roles.role(:workers).assets?\n\n    config_with_assets = Kamal::Configuration.new(@deploy_with_roles.dup.tap { |c|\n      c[:asset_path] = \"foo\"\n    })\n    assert_equal \"foo\", config_with_assets.role(:web).asset_path\n    assert_equal \"foo\", config_with_assets.role(:workers).asset_path\n    assert_equal [ \"--volume\", \"$PWD/.kamal/apps/app/assets/volumes/web-12345:foo\" ], config_with_assets.role(:web).asset_volume_args\n    assert_nil config_with_assets.role(:workers).asset_volume_args\n    assert config_with_assets.role(:web).assets?\n    assert_not config_with_assets.role(:workers).assets?\n\n    config_with_assets = Kamal::Configuration.new(@deploy_with_roles.dup.tap { |c|\n      c[:servers][\"web\"] = { \"hosts\" => [ \"1.1.1.1\", \"1.1.1.2\" ], \"asset_path\" => \"bar\" }\n    })\n    assert_equal \"bar\", config_with_assets.role(:web).asset_path\n    assert_nil config_with_assets.role(:workers).asset_path\n    assert_equal [ \"--volume\", \"$PWD/.kamal/apps/app/assets/volumes/web-12345:bar\" ], config_with_assets.role(:web).asset_volume_args\n    assert_nil config_with_assets.role(:workers).asset_volume_args\n    assert config_with_assets.role(:web).assets?\n    assert_not config_with_assets.role(:workers).assets?\n\n  ensure\n    ENV.delete(\"VERSION\")\n  end\n\n  test \"asset path with mount options\" do\n    ENV[\"VERSION\"] = \"12345\"\n\n    config_with_assets = Kamal::Configuration.new(@deploy_with_roles.dup.tap { |c|\n      c[:asset_path] = \"/rails/public/assets:z\"\n    })\n    assert_equal \"/rails/public/assets\", config_with_assets.role(:web).asset_path\n    assert_equal \"z\", config_with_assets.role(:web).asset_path_options\n    assert_equal [ \"--volume\", \"$PWD/.kamal/apps/app/assets/volumes/web-12345:/rails/public/assets:z\" ], config_with_assets.role(:web).asset_volume_args\n\n    config_with_assets = Kamal::Configuration.new(@deploy_with_roles.dup.tap { |c|\n      c[:servers][\"web\"] = { \"hosts\" => [ \"1.1.1.1\", \"1.1.1.2\" ], \"asset_path\" => \"/assets:ro,z\" }\n    })\n    assert_equal \"/assets\", config_with_assets.role(:web).asset_path\n    assert_equal \"ro,z\", config_with_assets.role(:web).asset_path_options\n    assert_equal [ \"--volume\", \"$PWD/.kamal/apps/app/assets/volumes/web-12345:/assets:ro,z\" ], config_with_assets.role(:web).asset_volume_args\n\n  ensure\n    ENV.delete(\"VERSION\")\n  end\n\n  test \"asset extracted path\" do\n    ENV[\"VERSION\"] = \"12345\"\n    assert_equal \".kamal/apps/app/assets/extracted/web-12345\", config_with_roles.role(:web).asset_extracted_directory\n    assert_equal \".kamal/apps/app/assets/extracted/workers-12345\", config_with_roles.role(:workers).asset_extracted_directory\n  ensure\n    ENV.delete(\"VERSION\")\n  end\n\n  test \"asset volume path\" do\n    ENV[\"VERSION\"] = \"12345\"\n    assert_equal \".kamal/apps/app/assets/volumes/web-12345\", config_with_roles.role(:web).asset_volume_directory\n    assert_equal \".kamal/apps/app/assets/volumes/workers-12345\", config_with_roles.role(:workers).asset_volume_directory\n  ensure\n    ENV.delete(\"VERSION\")\n  end\n\n  test \"stop args with proxy\" do\n    assert_equal [], config_with_roles.role(:web).stop_args\n  end\n\n  test \"stop args with no proxy\" do\n    assert_equal [ \"-t\", 30 ], config_with_roles.role(:workers).stop_args\n  end\n\n  test \"role specific proxy config\" do\n    @deploy_with_roles[:proxy] = { \"response_timeout\" => 15 }\n    @deploy_with_roles[:servers][\"workers\"][\"proxy\"] = { \"response_timeout\" => 18 }\n\n    assert_equal \"15s\", config_with_roles.role(:web).proxy.deploy_options[:\"target-timeout\"]\n    assert_equal \"18s\", config_with_roles.role(:workers).proxy.deploy_options[:\"target-timeout\"]\n  end\n\n  test \"can't set restart in options\" do\n    @deploy_with_roles[:servers][\"workers\"][\"options\"] = { \"restart\" => \"always\" }\n\n    assert_raises Kamal::ConfigurationError, \"servers/workers: Cannot set restart policy in docker options, unless-stopped is required\" do\n      Kamal::Configuration.new(@deploy_with_roles)\n    end\n  end\n\n  private\n    def config\n      Kamal::Configuration.new(@deploy)\n    end\n\n    def config_with_roles\n      Kamal::Configuration.new(@deploy_with_roles)\n    end\nend\n"
  },
  {
    "path": "test/configuration/ssh_test.rb",
    "content": "require \"test_helper\"\n\nclass ConfigurationSshTest < ActiveSupport::TestCase\n  setup do\n    @deploy = {\n      service: \"app\", image: \"dhh/app\",\n      registry: { \"username\" => \"dhh\", \"password\" => \"secret\" },\n      builder: { \"arch\" => \"amd64\" },\n      env: { \"REDIS_URL\" => \"redis://x/y\" },\n      servers: [ \"1.1.1.1\", \"1.1.1.2\" ],\n      volumes: [ \"/local/path:/container/path\" ]\n    }\n\n    @config = Kamal::Configuration.new(@deploy)\n  end\n\n  test \"ssh options\" do\n    assert_equal \"root\", @config.ssh.options[:user]\n\n    config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { \"user\" => \"app\" }) })\n    assert_equal \"app\", config.ssh.options[:user]\n    assert_equal 4, config.ssh.options[:logger].level\n\n    config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { \"log_level\" => \"debug\" }) })\n    assert_equal 0, config.ssh.options[:logger].level\n\n    config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { \"port\" => 2222 }) })\n    assert_equal 2222, config.ssh.options[:port]\n\n    config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { \"config\" => true }) })\n    assert_equal true, config.ssh.options[:config]\n\n    config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { \"config\" => false }) })\n    assert_equal false, config.ssh.options[:config]\n\n    config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { \"config\" => \"~/config.mine\" }) })\n    assert_equal \"~/config.mine\", config.ssh.options[:config]\n\n    config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { \"config\" => [ \"~/config.mine.1\", \"~/config.mine.2\" ] }) })\n    assert_equal [ \"~/config.mine.1\", \"~/config.mine.2\" ], config.ssh.options[:config]\n  end\n\n  test \"ssh options with proxy host\" do\n    config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { \"proxy\" => \"1.2.3.4\" }) })\n    assert_equal \"root@1.2.3.4\", config.ssh.options[:proxy].jump_proxies\n  end\n\n  test \"ssh options with proxy host and user\" do\n    config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { \"proxy\" => \"app@1.2.3.4\" }) })\n    assert_equal \"app@1.2.3.4\", config.ssh.options[:proxy].jump_proxies\n  end\n\n  test \"ssh key_data with plain value array\" do\n    config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { \"key_data\" => [ \"-----BEGIN OPENSSH PRIVATE KEY-----\" ] }) })\n    assert_equal [ \"-----BEGIN OPENSSH PRIVATE KEY-----\" ], config.ssh.options[:key_data]\n  end\n\n  test \"ssh key_data with array containing one secret string\" do\n    with_test_secrets(\"secrets\" => \"SSH_PRIVATE_KEY=secret_ssh_key\") do\n      config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { \"key_data\" => [ \"SSH_PRIVATE_KEY\" ] }) })\n      assert_equal [ \"secret_ssh_key\" ], config.ssh.options[:key_data]\n    end\n  end\n\n  test \"ssh key_data with array containing multiple secret strings\" do\n    with_test_secrets(\"secrets\" => \"SSH_PRIVATE_KEY=secret_ssh_key\\nSECOND_KEY=second_secret_ssh_key\") do\n      config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { \"key_data\" => [ \"SSH_PRIVATE_KEY\", \"SECOND_KEY\" ] }) })\n      assert_equal [ \"secret_ssh_key\", \"second_secret_ssh_key\" ], config.ssh.options[:key_data]\n    end\n  end\nend\n"
  },
  {
    "path": "test/configuration/sshkit_test.rb",
    "content": "require \"test_helper\"\n\nclass ConfigurationSshkitTest < ActiveSupport::TestCase\n  setup do\n    @deploy = {\n      service: \"app\", image: \"dhh/app\",\n      registry: { \"username\" => \"dhh\", \"password\" => \"secret\" },\n      env: { \"REDIS_URL\" => \"redis://x/y\" },\n      builder: { \"arch\" => \"amd64\" },\n      servers: [ \"1.1.1.1\", \"1.1.1.2\" ],\n      volumes: [ \"/local/path:/container/path\" ]\n    }\n\n    @config = Kamal::Configuration.new(@deploy)\n  end\n\n  test \"sshkit defaults\" do\n    assert_equal 30, @config.sshkit.max_concurrent_starts\n    assert_equal 900, @config.sshkit.pool_idle_timeout\n    assert_equal 3, @config.sshkit.dns_retries\n  end\n\n  test \"sshkit overrides\" do\n    @config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(sshkit: {\n      \"max_concurrent_starts\" => 50,\n      \"pool_idle_timeout\" => 600,\n      \"dns_retries\" => 5\n    }) })\n\n    assert_equal 50, @config.sshkit.max_concurrent_starts\n    assert_equal 600, @config.sshkit.pool_idle_timeout\n    assert_equal 5, @config.sshkit.dns_retries\n  end\nend\n"
  },
  {
    "path": "test/configuration/validation_test.rb",
    "content": "require \"test_helper\"\nclass ConfigurationValidationTest < ActiveSupport::TestCase\n  test \"unknown root key\" do\n    assert_error \"unknown key: unknown\", unknown: \"value\"\n    assert_error \"unknown keys: unknown, unknown2\", unknown: \"value\", unknown2: \"value\"\n  end\n\n  test \"wrong root types\" do\n    [ :service, :image, :asset_path, :hooks_path, :secrets_path, :primary_role, :minimum_version, :run_directory ].each do |key|\n      assert_error \"#{key}: should be a string\", **{ key => [] }\n    end\n\n    [ :require_destination, :allow_empty_roles ].each do |key|\n      assert_error \"#{key}: should be a boolean\", **{ key => \"foo\" }\n    end\n\n    [ :deploy_timeout, :drain_timeout, :retain_containers, :readiness_delay ].each do |key|\n      assert_error \"#{key}: should be an integer\", **{ key => \"foo\" }\n    end\n\n    assert_error \"volumes: should be an array\", volumes: \"foo\"\n\n    assert_error \"servers: should be an array or a hash\", servers: \"foo\"\n\n    [ :labels, :registry, :accessories, :env, :ssh, :sshkit, :builder, :proxy, :boot, :logging ].each do |key|\n      assert_error \"#{key}: should be a hash\", **{ key =>[] }\n    end\n  end\n\n  test \"servers\" do\n    assert_error \"servers: should be an array or a hash\", servers: \"foo\"\n    assert_error \"servers/0: should be a string or a hash\", servers: [ [] ]\n    assert_error \"servers/0: multiple hosts found\", servers: [ { \"a\" => \"b\", \"c\" => \"d\" } ]\n    assert_error \"servers/0/foo: should be a string or an array\", servers: [ { \"foo\" => {} } ]\n    assert_error \"servers/0/foo/0: should be a string\", servers: [ { \"foo\" => [ [] ] } ]\n  end\n\n  test \"roles\" do\n    assert_error \"servers/web: should be an array or a hash\", servers: { \"web\" => \"foo\" }\n    assert_error \"servers/web/hosts: should be an array\", servers: { \"web\" => { \"hosts\" => \"\" } }\n    assert_error \"servers/web/hosts/0: should be a string or a hash\", servers: { \"web\" => { \"hosts\" => [ [] ] } }\n    assert_error \"servers/web/options: should be a hash\", servers: { \"web\" => { \"options\" => \"\" } }\n    assert_error \"servers/web/logging/options: should be a hash\", servers: { \"web\" => { \"logging\" => { \"options\" => \"\" } } }\n    assert_error \"servers/web/logging/driver: should be a string\", servers: { \"web\" => { \"logging\" => { \"driver\" => [] } } }\n    assert_error \"servers/web/labels/service: invalid label. destination, role, and service are reserved labels\", servers: { \"web\" => { \"labels\" => { \"service\" => \"foo\" } } }\n    assert_error \"servers/web/labels: should be a hash\", servers: { \"web\" => { \"labels\" => [] } }\n    assert_error \"servers/web/env: should be a hash\", servers: { \"web\" => { \"env\" => [] } }\n    assert_error \"servers/web/env: tags are only allowed in the root env\", servers: { \"web\" => { \"hosts\" => [ \"1.1.1.1\" ], \"env\" => { \"tags\" => {} } } }\n  end\n\n  test \"registry\" do\n    assert_error \"registry/username: is required\", registry: {}\n    assert_error \"registry/password: is required\", registry: { \"username\" => \"foo\" }\n    assert_error \"registry/password: should be a string or an array with one string (for secret lookup)\", registry: { \"username\" => \"foo\", \"password\" => [ \"SECRET1\", \"SECRET2\" ] }\n    assert_error \"registry/server: should be a string\", registry: { \"username\" => \"foo\", \"password\" => \"bar\", \"server\" => [] }\n  end\n\n  test \"accessories\" do\n    assert_error \"accessories/accessory1: should be a hash\", accessories: { \"accessory1\" => [] }\n    assert_error \"accessories/accessory1: unknown key: unknown\", accessories: { \"accessory1\" => { \"unknown\" => \"baz\" } }\n    assert_error \"accessories/accessory1/options: should be a hash\", accessories: { \"accessory1\" => { \"options\" => [] } }\n    assert_error \"accessories/accessory1/labels/destination: invalid label. destination, role, and service are reserved labels\", accessories: { \"accessory1\" => { \"host\" => \"host\", \"labels\" => { \"destination\" => \"foo\" } } }\n    assert_error \"accessories/accessory1/host: should be a string\", accessories: { \"accessory1\" => { \"host\" => [] } }\n    assert_error \"accessories/accessory1/env: should be a hash\", accessories: { \"accessory1\" => { \"env\" => [] } }\n    assert_error \"accessories/accessory1/env: tags are only allowed in the root env\", accessories: { \"accessory1\" => { \"host\" => \"host\", \"env\" => { \"tags\" => {} } } }\n  end\n\n  test \"env\" do\n    assert_error \"env: should be a hash\", env: []\n    assert_error \"env/FOO: should be a string\", env: { \"FOO\" => [] }\n    assert_error \"env/clear/FOO: should be a string\", env: { \"clear\" => { \"FOO\" => [] } }\n    assert_error \"env/secret: should be an array\", env: { \"secret\" => { \"FOO\" => [] } }\n    assert_error \"env/secret/0: should be a string\", env: { \"secret\" => [ [] ] }\n    assert_error \"env/tags: should be a hash\", env: { \"tags\" => [] }\n    assert_error \"env/tags/tag1: should be a hash\", env: { \"tags\" => { \"tag1\" => \"foo\" } }\n    assert_error \"env/tags/tag1/FOO: should be a string\", env: { \"tags\" => { \"tag1\" => { \"FOO\" => [] } } }\n    assert_error \"env/tags/tag1/clear/FOO: should be a string\", env: { \"tags\" => { \"tag1\" => { \"clear\" => { \"FOO\" => [] } } } }\n    assert_error \"env/tags/tag1/secret: should be an array\", env: { \"tags\" => { \"tag1\" => { \"secret\" => {} } } }\n    assert_error \"env/tags/tag1/secret/0: should be a string\", env: { \"tags\" => { \"tag1\" => { \"secret\" => [ [] ] } } }\n    assert_error \"env/tags/tag1: tags are only allowed in the root env\", env: { \"tags\" => { \"tag1\" => { \"tags\" => {} } } }\n  end\n\n  test \"ssh\" do\n    assert_error \"ssh: unknown key: foo\", ssh: { \"foo\" => \"bar\" }\n    assert_error \"ssh/user: should be a string\", ssh: { \"user\" => [] }\n    assert_error \"ssh/config: should be a boolean or a string or an array\", ssh: { \"config\" => 1 }\n  end\n\n  test \"sshkit\" do\n    assert_error \"sshkit: unknown key: foo\", sshkit: { \"foo\" => \"bar\" }\n    assert_error \"sshkit/max_concurrent_starts: should be an integer\", sshkit: { \"max_concurrent_starts\" => \"foo\" }\n    assert_error \"sshkit/dns_retries: should be an integer\", sshkit: { \"dns_retries\" => \"foo\" }\n  end\n\n  test \"builder\" do\n    assert_error \"builder: unknown key: foo\", builder: { \"foo\" => \"bar\" }\n    assert_error \"builder/remote: should be a string\", builder: { \"remote\" => { \"foo\" => \"bar\" } }\n    assert_error \"builder/arch: should be an array or a string\", builder: { \"arch\" => {} }\n    assert_error \"builder/args: should be a hash\", builder: { \"args\" => [ \"foo\" ] }\n    assert_error \"builder/cache/options: should be a string\", builder: { \"cache\" => { \"options\" => [] } }\n    assert_error \"builder: buildpacks only support building for one arch\", builder: { \"arch\" => [ \"amd64\", \"arm64\" ], \"pack\" => { \"builder\" => \"heroku/builder:24\" } }\n  end\n\n  test \"local registry with remote builder requires ssh url\" do\n    remote_arch = Kamal::Utils.docker_arch == \"arm64\" ? \"amd64\" : \"arm64\"\n\n    assert_error \"Local registry with remote builder requires an SSH URL (e.g., ssh://user@host)\",\n      registry: { \"server\" => \"localhost:5000\" },\n      builder: { \"arch\" => remote_arch, \"remote\" => \"docker-container://remote-builder\" }\n\n    # Should not raise error with SSH URL\n    assert_nothing_raised do\n      Kamal::Configuration.new({\n        service: \"app\",\n        image: \"app\",\n        registry: { \"server\" => \"localhost:5000\" },\n        builder: { \"arch\" => remote_arch, \"remote\" => \"ssh://user@host\" },\n        servers: [ \"1.1.1.1\" ]\n      })\n    end\n  end\n\n  private\n    def assert_error(message, **invalid_config)\n      valid_config = {\n        service: \"app\",\n        image: \"app\",\n        builder: { \"arch\" => \"amd64\" },\n        registry: { \"username\" => \"user\", \"password\" => \"secret\" },\n        servers: [ \"1.1.1.1\" ]\n      }\n\n      error = assert_raises Kamal::ConfigurationError do\n        Kamal::Configuration.new(valid_config.merge(invalid_config))\n      end\n\n      assert_equal message, error.message\n    end\nend\n"
  },
  {
    "path": "test/configuration/volume_test.rb",
    "content": "require \"test_helper\"\n\nclass ConfigurationVolumeTest < ActiveSupport::TestCase\n  test \"docker args absolute\" do\n    volume = Kamal::Configuration::Volume.new(host_path: \"/root/foo/bar\", container_path: \"/assets\")\n    assert_equal [ \"--volume\", \"/root/foo/bar:/assets\" ], volume.docker_args\n  end\n\n  test \"docker args relative\" do\n    volume = Kamal::Configuration::Volume.new(host_path: \"foo/bar\", container_path: \"/assets\")\n    assert_equal [ \"--volume\", \"$PWD/foo/bar:/assets\" ], volume.docker_args\n  end\n\n  test \"docker args with options\" do\n    volume = Kamal::Configuration::Volume.new(host_path: \"/root/foo/bar\", container_path: \"/assets\", options: \"ro\")\n    assert_equal [ \"--volume\", \"/root/foo/bar:/assets:ro\" ], volume.docker_args\n  end\n\n  test \"docker args with multiple options\" do\n    volume = Kamal::Configuration::Volume.new(host_path: \"/root/foo/bar\", container_path: \"/assets\", options: \"ro,z\")\n    assert_equal [ \"--volume\", \"/root/foo/bar:/assets:ro,z\" ], volume.docker_args\n  end\n\n  test \"docker args with selinux z option\" do\n    volume = Kamal::Configuration::Volume.new(host_path: \"/data\", container_path: \"/data\", options: \"z\")\n    assert_equal [ \"--volume\", \"/data:/data:z\" ], volume.docker_args\n  end\n\n  test \"docker args with selinux Z option\" do\n    volume = Kamal::Configuration::Volume.new(host_path: \"/data\", container_path: \"/data\", options: \"Z\")\n    assert_equal [ \"--volume\", \"/data:/data:Z\" ], volume.docker_args\n  end\nend\n"
  },
  {
    "path": "test/configuration_test.rb",
    "content": "require \"test_helper\"\n\nclass ConfigurationTest < ActiveSupport::TestCase\n  setup do\n    ENV[\"RAILS_MASTER_KEY\"] = \"456\"\n    ENV[\"VERSION\"] = \"missing\"\n\n    @deploy = {\n      service: \"app\", image: \"dhh/app\",\n      registry: { \"username\" => \"dhh\", \"password\" => \"secret\" },\n      builder: { \"arch\" => \"amd64\" },\n      env: { \"REDIS_URL\" => \"redis://x/y\" },\n      servers: [ \"1.1.1.1\", \"1.1.1.2\" ],\n      volumes: [ \"/local/path:/container/path\" ]\n    }\n\n    @config = Kamal::Configuration.new(@deploy)\n\n    @deploy_with_roles = @deploy.dup.merge({\n      servers: { \"web\" => [ \"1.1.1.1\", \"1.1.1.2\" ], \"workers\" => { \"hosts\" => [ \"1.1.1.1\", \"1.1.1.3\" ] } } })\n\n    @config_with_roles = Kamal::Configuration.new(@deploy_with_roles)\n  end\n\n  teardown do\n    ENV.delete(\"RAILS_MASTER_KEY\")\n    ENV.delete(\"VERSION\")\n  end\n\n  %i[ service image registry ].each do |key|\n    test \"#{key} config required\" do\n      assert_raise(Kamal::ConfigurationError) do\n        Kamal::Configuration.new @deploy.tap { |config| config.delete key }\n      end\n    end\n  end\n\n  %w[ username password ].each do |key|\n    test \"registry #{key} required\" do\n      assert_raise(Kamal::ConfigurationError) do\n        Kamal::Configuration.new @deploy.tap { |config| config[:registry].delete key }\n      end\n    end\n  end\n\n  test \"image uses service name if registry is local\" do\n    assert_equal \"app\", Kamal::Configuration.new(@deploy.tap {\n      _1[:registry] = { \"server\" => \"localhost:5000\" }\n      _1.delete(:image)\n    }).image\n  end\n\n  test \"image uses image if registry is local\" do\n    assert_equal \"dhh/app\", Kamal::Configuration.new(@deploy.tap {\n      _1[:registry] = { \"server\" => \"localhost:5000\" }\n    }).image\n  end\n\n  test \"service name valid\" do\n    assert_nothing_raised do\n      Kamal::Configuration.new(@deploy.tap { |config| config[:service] = \"hey-app1_primary\" })\n      Kamal::Configuration.new(@deploy.tap { |config| config[:service] = \"MyApp\" })\n    end\n  end\n\n  test \"service name invalid\" do\n    assert_raise(Kamal::ConfigurationError) do\n      Kamal::Configuration.new @deploy.tap { |config| config[:service] = \"app.com\" }\n    end\n  end\n\n  test \"servers required\" do\n    assert_raise(Kamal::ConfigurationError) do\n      Kamal::Configuration.new @deploy.tap { |config| config.delete(:servers) }\n    end\n  end\n\n  test \"servers not required with accessories\" do\n    assert_nothing_raised do\n      @deploy.delete(:servers)\n      @deploy[:accessories] = { \"foo\" => { \"image\" => \"foo/bar\", \"host\" => \"1.1.1.1\" } }\n\n      Kamal::Configuration.new(@deploy)\n    end\n  end\n\n  test \"roles\" do\n    assert_equal %w[ web ], @config.roles.collect(&:name)\n    assert_equal %w[ web workers ], @config_with_roles.roles.collect(&:name)\n  end\n\n  test \"role\" do\n    assert @config.role(:web).name.web?\n    assert_equal \"workers\", @config_with_roles.role(:workers).name\n    assert_nil @config.role(:missing)\n  end\n\n  test \"all hosts\" do\n    assert_equal [ \"1.1.1.1\", \"1.1.1.2\" ], @config.all_hosts\n    assert_equal [ \"1.1.1.1\", \"1.1.1.2\", \"1.1.1.3\" ], @config_with_roles.all_hosts\n  end\n\n  test \"primary host\" do\n    assert_equal \"1.1.1.1\", @config.primary_host\n    assert_equal \"1.1.1.1\", @config_with_roles.primary_host\n  end\n\n  test \"proxy hosts\" do\n    assert_equal [ \"1.1.1.1\", \"1.1.1.2\" ], @config_with_roles.proxy_hosts\n\n    @deploy_with_roles[:servers][\"workers\"][\"proxy\"] = true\n    config = Kamal::Configuration.new(@deploy_with_roles)\n\n    assert_equal [ \"1.1.1.1\", \"1.1.1.2\", \"1.1.1.3\" ], config.proxy_hosts\n  end\n\n  test \"filtered proxy hosts\" do\n    assert_equal [ \"1.1.1.1\", \"1.1.1.2\" ], @config_with_roles.proxy_hosts\n\n    @deploy_with_roles[:servers][\"workers\"][\"proxy\"] = true\n    config = Kamal::Configuration.new(@deploy_with_roles)\n\n    assert_equal [ \"1.1.1.1\", \"1.1.1.2\", \"1.1.1.3\" ], config.proxy_hosts\n  end\n\n  test \"version no git repo\" do\n    ENV.delete(\"VERSION\")\n\n    Kamal::Git.expects(:used?).returns(nil)\n    error = assert_raises(RuntimeError) { @config.version }\n    assert_match /no git repository found/, error.message\n  end\n\n  test \"version from git committed\" do\n    ENV.delete(\"VERSION\")\n\n    Kamal::Git.expects(:revision).returns(\"git-version\")\n    Kamal::Git.expects(:uncommitted_changes).returns(\"\")\n    assert_equal \"git-version\", @config.version\n  end\n\n  test \"version from git uncommitted\" do\n    ENV.delete(\"VERSION\")\n\n    Kamal::Git.expects(:revision).returns(\"git-version\")\n    Kamal::Git.expects(:uncommitted_changes).returns(\"M   file\\n\")\n    assert_equal \"git-version\", @config.version\n  end\n\n  test \"version from uncommitted context\" do\n    ENV.delete(\"VERSION\")\n\n    config = Kamal::Configuration.new(@deploy.tap { |c| c[:builder][\"context\"] = \".\" })\n\n    Kamal::Git.expects(:revision).returns(\"git-version\")\n    Kamal::Git.expects(:uncommitted_changes).returns(\"M   file\\n\")\n    assert_match /^git-version_uncommitted_[0-9a-f]{16}$/, config.version\n  end\n\n  test \"version from env\" do\n    ENV[\"VERSION\"] = \"env-version\"\n    assert_equal \"env-version\", @config.version\n  end\n\n  test \"version from arg\" do\n    @config.version = \"arg-version\"\n    assert_equal \"arg-version\", @config.version\n  end\n\n  test \"repository\" do\n    assert_equal \"dhh/app\", @config.repository\n\n    config = Kamal::Configuration.new(@deploy.tap { |c| c[:registry].merge!({ \"server\" => \"ghcr.io\" }) })\n    assert_equal \"ghcr.io/dhh/app\", config.repository\n  end\n\n  test \"absolute image\" do\n    assert_equal \"dhh/app:missing\", @config.absolute_image\n\n    config = Kamal::Configuration.new(@deploy.tap { |c| c[:registry].merge!({ \"server\" => \"ghcr.io\" }) })\n    assert_equal \"ghcr.io/dhh/app:missing\", config.absolute_image\n  end\n\n  test \"service with version\" do\n    assert_equal \"app-missing\", @config.service_with_version\n  end\n\n  test \"hosts required for all roles\" do\n    # Empty server list for implied web role\n    assert_raises(Kamal::ConfigurationError) do\n      Kamal::Configuration.new @deploy.merge(servers: [])\n    end\n\n    # Empty server list\n    assert_raises(Kamal::ConfigurationError) do\n      Kamal::Configuration.new @deploy.merge(servers: { \"web\" => [] })\n    end\n\n    # Missing hosts key\n    assert_raises(Kamal::ConfigurationError) do\n      Kamal::Configuration.new @deploy.merge(servers: { \"web\" => {} })\n    end\n\n    # Empty hosts list\n    assert_raises(Kamal::ConfigurationError) do\n      Kamal::Configuration.new @deploy.merge(servers: { \"web\" => { \"hosts\" => [] } })\n    end\n\n    # Nil hosts\n    assert_raises(Kamal::ConfigurationError) do\n      Kamal::Configuration.new @deploy.merge(servers: { \"web\" => { \"hosts\" => nil } })\n    end\n\n    # One role with hosts, one without\n    assert_raises(Kamal::ConfigurationError) do\n      Kamal::Configuration.new @deploy.merge(servers: { \"web\" => %w[ web ], \"workers\" => { \"hosts\" => %w[ ] } })\n    end\n  end\n\n  test \"allow_empty_roles\" do\n    assert_silent do\n      Kamal::Configuration.new @deploy.merge(servers: { \"web\" => %w[ web ], \"workers\" => { \"hosts\" => %w[ ] } }, allow_empty_roles: true)\n    end\n\n    assert_raises(Kamal::ConfigurationError) do\n      Kamal::Configuration.new @deploy.merge(servers: { \"web\" => %w[], \"workers\" => { \"hosts\" => %w[] } }, allow_empty_roles: true)\n    end\n  end\n\n  test \"volume_args\" do\n    assert_equal [ \"--volume\", \"/local/path:/container/path\" ], @config.volume_args\n  end\n\n  test \"logging args default\" do\n    assert_equal [ \"--log-opt\", \"max-size=\\\"10m\\\"\" ], @config.logging_args\n  end\n\n  test \"logging args with configured options\" do\n    config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(logging: { \"options\" => { \"max-size\" => \"100m\", \"max-file\" => 5 } }) })\n    assert_equal [ \"--log-opt\", \"max-size=\\\"100m\\\"\", \"--log-opt\", \"max-file=\\\"5\\\"\" ], config.logging_args\n  end\n\n  test \"logging args with configured driver and options\" do\n    config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(logging: { \"driver\" => \"local\", \"options\" => { \"max-size\" => \"100m\", \"max-file\" => 5 } }) })\n    assert_equal [ \"--log-driver\", \"\\\"local\\\"\", \"--log-opt\", \"max-size=\\\"100m\\\"\", \"--log-opt\", \"max-file=\\\"5\\\"\" ], config.logging_args\n  end\n\n  test \"erb evaluation of yml config\" do\n    config = Kamal::Configuration.create_from config_file: Pathname.new(File.expand_path(\"fixtures/deploy.erb.yml\", __dir__))\n    assert_equal \"my-user\", config.registry.username\n  end\n\n  test \"destination is loaded into env\" do\n    dest_config_file = Pathname.new(File.expand_path(\"fixtures/deploy_for_dest.yml\", __dir__))\n\n    config = Kamal::Configuration.create_from config_file: dest_config_file, destination: \"world\"\n    assert_equal ENV[\"KAMAL_DESTINATION\"], \"world\"\n  end\n\n  test \"destination yml config merge\" do\n    dest_config_file = Pathname.new(File.expand_path(\"fixtures/deploy_for_dest.yml\", __dir__))\n\n    config = Kamal::Configuration.create_from config_file: dest_config_file, destination: \"world\"\n    assert_equal \"1.1.1.1\", config.all_hosts.first\n\n    config = Kamal::Configuration.create_from config_file: dest_config_file, destination: \"mars\"\n    assert_equal \"1.1.1.3\", config.all_hosts.first\n  end\n\n  test \"destination yml config file missing\" do\n    dest_config_file = Pathname.new(File.expand_path(\"fixtures/deploy_for_dest.yml\", __dir__))\n\n    assert_raises(RuntimeError) do\n      config = Kamal::Configuration.create_from config_file: dest_config_file, destination: \"missing\"\n    end\n  end\n\n  test \"destination required\" do\n    dest_config_file = Pathname.new(File.expand_path(\"fixtures/deploy_for_required_dest.yml\", __dir__))\n\n    assert_raises(ArgumentError, \"You must specify a destination\") do\n      config = Kamal::Configuration.create_from config_file: dest_config_file\n    end\n\n    assert_nothing_raised do\n      config = Kamal::Configuration.create_from config_file: dest_config_file, destination: \"world\"\n    end\n  end\n\n  test \"to_h\" do\n    expected_config = \\\n      { roles: [ \"web\" ],\n        hosts: [ \"1.1.1.1\", \"1.1.1.2\" ],\n        primary_host: \"1.1.1.1\",\n        version: \"missing\",\n        repository: \"dhh/app\",\n        absolute_image: \"dhh/app:missing\",\n        service_with_version: \"app-missing\",\n        ssh_options: { user: \"root\", port: 22, log_level: :fatal, keepalive: true, keepalive_interval: 30 },\n        sshkit: {},\n        volume_args: [ \"--volume\", \"/local/path:/container/path\" ],\n        builder: { \"arch\" => \"amd64\" },\n        logging: [ \"--log-opt\", \"max-size=\\\"10m\\\"\" ] }\n\n    assert_equal expected_config, @config.to_h\n  end\n\n  test \"min version is lower\" do\n    config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(minimum_version: \"0.0.1\") })\n    assert_equal \"0.0.1\", config.minimum_version\n  end\n\n  test \"min version is equal\" do\n    config = Kamal::Configuration.new(@deploy.tap { |c| c.merge!(minimum_version: Kamal::VERSION) })\n    assert_equal Kamal::VERSION, config.minimum_version\n  end\n\n  test \"min version is higher\" do\n    assert_raises(Kamal::ConfigurationError) do\n      Kamal::Configuration.new(@deploy.tap { |c| c.merge!(minimum_version: \"10000.0.0\") })\n    end\n  end\n\n  test \"run directory\" do\n    config = Kamal::Configuration.new(@deploy)\n    assert_equal \".kamal\", config.run_directory\n  end\n\n  test \"asset path\" do\n    assert_nil @config.asset_path\n    assert_equal \"foo\", Kamal::Configuration.new(@deploy.merge!(asset_path: \"foo\")).asset_path\n  end\n\n  test \"primary role\" do\n    assert_equal \"web\", @config.primary_role.name\n\n    config = Kamal::Configuration.new(@deploy_with_roles.deep_merge({\n      servers: { \"alternate_web\" => { \"hosts\" => [ \"1.1.1.4\", \"1.1.1.5\" ] } },\n      primary_role: \"alternate_web\" }))\n\n\n    assert_equal \"alternate_web\", config.primary_role.name\n    assert_equal \"1.1.1.4\", config.primary_host\n    assert config.role(:alternate_web).primary?\n    assert config.role(:alternate_web).running_proxy?\n  end\n\n  test \"primary role missing\" do\n    error = assert_raises(Kamal::ConfigurationError) do\n      Kamal::Configuration.new(@deploy.merge(primary_role: \"bar\"))\n    end\n    assert_match /bar isn't defined/, error.message\n  end\n\n  test \"retain_containers\" do\n    assert_equal 5, @config.retain_containers\n    config = Kamal::Configuration.new(@deploy_with_roles.merge(retain_containers: 2))\n    assert_equal 2, config.retain_containers\n\n    assert_raises(Kamal::ConfigurationError) { Kamal::Configuration.new(@deploy_with_roles.merge(retain_containers: 0)) }\n  end\n\n  test \"extensions\" do\n    dest_config_file = Pathname.new(File.expand_path(\"fixtures/deploy_with_extensions.yml\", __dir__))\n\n    config = Kamal::Configuration.create_from config_file: dest_config_file\n    assert_equal config.role(:web_tokyo).running_proxy?, true\n    assert_equal config.role(:web_chicago).running_proxy?, true\n  end\n\n  test \"traefik hooks raise error\" do\n    Dir.mktmpdir do |dir|\n      Dir.chdir(dir) do\n        FileUtils.mkdir_p \".kamal/hooks\"\n        FileUtils.touch \".kamal/hooks/post-traefik-reboot\"\n        FileUtils.touch \".kamal/hooks/pre-traefik-reboot\"\n        exception = assert_raises(Kamal::ConfigurationError) do\n          Kamal::Configuration.new(@deploy)\n        end\n        assert_equal \"Found pre-traefik-reboot, post-traefik-reboot, these should be renamed to (pre|post)-proxy-reboot\", exception.message\n      end\n    end\n  end\n\n  test \"proxy ssl roles with no host\" do\n    @deploy_with_roles[:servers][\"workers\"][\"proxy\"] = { \"ssl\" => true }\n\n    exception = assert_raises(Kamal::ConfigurationError) do\n      Kamal::Configuration.new(@deploy_with_roles)\n    end\n\n    assert_equal \"servers/workers/proxy: Must set a host to enable automatic SSL\", exception.message\n  end\n\n  test \"proxy ssl roles with multiple servers\" do\n    @deploy_with_roles[:servers][\"workers\"][\"proxy\"] = { \"ssl\" => true, \"host\" => \"foo.example.com\" }\n\n    exception = assert_raises(Kamal::ConfigurationError) do\n      Kamal::Configuration.new(@deploy_with_roles)\n    end\n\n    assert_equal \"SSL is only supported on a single server unless you provide custom certificates, found 2 servers for role workers\", exception.message\n  end\n\n  test \"two proxy ssl roles with same host\" do\n    @deploy_with_roles[:servers][\"web\"] = { \"hosts\" => [ \"1.1.1.1\" ], \"proxy\" => { \"ssl\" => true, \"host\" => \"foo.example.com\" } }\n    @deploy_with_roles[:servers][\"workers\"] = { \"hosts\" => [ \"1.1.1.1\" ], \"proxy\" => { \"ssl\" => true, \"host\" => \"foo.example.com\" } }\n\n    exception = assert_raises(Kamal::ConfigurationError) do\n      Kamal::Configuration.new(@deploy_with_roles)\n    end\n\n    assert_equal \"Different roles can't share the same host for SSL: foo.example.com\", exception.message\n  end\n\n  test \"two proxy ssl roles with same host in a hosts array\" do\n    @deploy_with_roles[:servers][\"web\"] = { \"hosts\" => [ \"1.1.1.1\" ], \"proxy\" => { \"ssl\" => true, \"hosts\" => [ \"foo.example.com\", \"bar.example.com\" ] } }\n    @deploy_with_roles[:servers][\"workers\"] = { \"hosts\" => [ \"1.1.1.1\" ], \"proxy\" => { \"ssl\" => true, \"hosts\" => [ \"www.example.com\", \"foo.example.com\" ] } }\n\n    exception = assert_raises(Kamal::ConfigurationError) do\n      Kamal::Configuration.new(@deploy_with_roles)\n    end\n\n    assert_equal \"Different roles can't share the same host for SSL: foo.example.com\", exception.message\n  end\n\n  test \"hooks_output default is nil\" do\n    assert_nil @config.hooks_output_for(\"pre-deploy\")\n  end\n\n  test \"hooks_output global setting\" do\n    config = Kamal::Configuration.new(@deploy.merge(hooks_output: :verbose))\n    assert_equal :verbose, config.hooks_output_for(\"pre-deploy\")\n    assert_equal :verbose, config.hooks_output_for(\"post-deploy\")\n  end\n\n  test \"hooks_output per-hook settings\" do\n    config = Kamal::Configuration.new(@deploy.merge(\n      hooks_output: { \"pre-deploy\" => :verbose, \"post-deploy\" => :quiet }\n    ))\n    assert_equal :verbose, config.hooks_output_for(\"pre-deploy\")\n    assert_equal :quiet, config.hooks_output_for(\"post-deploy\")\n  end\n\n  test \"hooks_output per-hook returns nil for unconfigured hooks\" do\n    config = Kamal::Configuration.new(@deploy.merge(\n      hooks_output: { \"pre-deploy\" => :verbose }\n    ))\n    assert_equal :verbose, config.hooks_output_for(\"pre-deploy\")\n    assert_nil config.hooks_output_for(\"post-deploy\")\n  end\n\n  test \"hooks_output invalid raises error\" do\n    error = assert_raises(Kamal::ConfigurationError) do\n      Kamal::Configuration.new(@deploy.merge(hooks_output: :invalid))\n    end\n    assert_match /Invalid hooks_output 'invalid'/, error.message\n  end\n\n  test \"hooks_output invalid per-hook raises error\" do\n    error = assert_raises(Kamal::ConfigurationError) do\n      Kamal::Configuration.new(@deploy.merge(hooks_output: { \"pre-deploy\" => :invalid }))\n    end\n    assert_match /Invalid hooks_output 'invalid' for hook 'pre-deploy'/, error.message\n  end\nend\n"
  },
  {
    "path": "test/env_file_test.rb",
    "content": "require \"test_helper\"\n\nclass EnvFileTest < ActiveSupport::TestCase\n  test \"to_s\" do\n    env = {\n      \"foo\" => \"bar\",\n      \"baz\" => \"haz\"\n    }\n\n    assert_equal \"foo=bar\\nbaz=haz\\n\", \\\n      Kamal::EnvFile.new(env).to_s\n  end\n\n  test \"to_s won't escape '#'\" do\n    env = {\n      \"foo\" => '#$foo',\n      \"bar\" => '#{bar}'\n    }\n\n    assert_equal \"foo=\\#$foo\\nbar=\\#{bar}\\n\", \\\n      Kamal::EnvFile.new(env).to_s\n  end\n\n  test \"to_str won't escape chinese characters\" do\n    env = {\n      \"foo\" => '你好 means hello, \"欢迎\" means welcome, that\\'s simple! 😃 {smile}'\n    }\n\n    assert_equal \"foo=你好 means hello, \\\"欢迎\\\" means welcome, that's simple! 😃 {smile}\\n\",\n      Kamal::EnvFile.new(env).to_s\n  end\n\n  test \"to_s won't escape japanese characters\" do\n    env = {\n      \"foo\" => 'こんにちは means hello, \"ようこそ\" means welcome, that\\'s simple! 😃 {smile}'\n    }\n\n    assert_equal \"foo=こんにちは means hello, \\\"ようこそ\\\" means welcome, that's simple! 😃 {smile}\\n\", \\\n      Kamal::EnvFile.new(env).to_s\n  end\n\n  test \"to_s won't escape korean characters\" do\n    env = {\n      \"foo\" => '안녕하세요 means hello, \"어서 오십시오\" means welcome, that\\'s simple! 😃 {smile}'\n    }\n\n    assert_equal \"foo=안녕하세요 means hello, \\\"어서 오십시오\\\" means welcome, that's simple! 😃 {smile}\\n\", \\\n      Kamal::EnvFile.new(env).to_s\n  end\n\n  test \"to_s empty\" do\n    assert_equal \"\\n\", Kamal::EnvFile.new({}).to_s\n  end\n\n  test \"to_s escaped newline\" do\n    env = {\n      \"foo\" => \"hello\\\\nthere\"\n    }\n\n    assert_equal \"foo=hello\\\\\\\\nthere\\n\", \\\n      Kamal::EnvFile.new(env).to_s\n  ensure\n    ENV.delete \"PASSWORD\"\n  end\n\n  test \"to_s newline\" do\n    env = {\n      \"foo\" => \"hello\\nthere\"\n    }\n\n    assert_equal \"foo=hello\\\\nthere\\n\", \\\n      Kamal::EnvFile.new(env).to_s\n  ensure\n    ENV.delete \"PASSWORD\"\n  end\n\n  test \"stringIO conversion\" do\n    env = {\n      \"foo\" => \"bar\",\n      \"baz\" => \"haz\"\n    }\n\n    assert_equal \"foo=bar\\nbaz=haz\\n\", \\\n      StringIO.new(Kamal::EnvFile.new(env)).read\n  end\nend\n"
  },
  {
    "path": "test/fixtures/deploy.elsewhere.yml",
    "content": "service: app3\nimage: dhh/app3\nservers:\n  - \"1.1.1.3\"\n  - \"1.1.1.4\"\nregistry:\n  username: user\n  password: pw\nbuilder:\n  arch: amd64\naliases:\n  other_config: config -c config/deploy2.yml\n"
  },
  {
    "path": "test/fixtures/deploy.erb.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  - 1.1.1.1\n  - 1.1.1.2\nenv:\n  REDIS_URL: redis://x/y\nregistry:\n  server: registry.digitalocean.com\n  username: <%= \"my-user\" %>\n  password: <%= \"my-password\" %>\nbuilder:\n  arch: amd64\n"
  },
  {
    "path": "test/fixtures/deploy.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  - \"1.1.1.1\"\n  - \"1.1.1.2\"\nregistry:\n  username: user\n  password: pw\nbuilder:\n  arch: amd64\naliases:\n  other_config: config -c config/deploy2.yml\n  other_destination_config: config -d elsewhere\n"
  },
  {
    "path": "test/fixtures/deploy2.yml",
    "content": "service: app2\nimage: dhh/app2\nservers:\n  - \"1.1.1.1\"\n  - \"1.1.1.2\"\nregistry:\n  username: user2\n  password: pw2\nbuilder:\n  arch: amd64\naliases:\n  other_config: config -c config/deploy2.yml\n"
  },
  {
    "path": "test/fixtures/deploy_for_dest.mars.yml",
    "content": "servers:\n  - 1.1.1.3\n  - 1.1.1.4\nenv:\n  REDIS_URL: redis://a/b\n"
  },
  {
    "path": "test/fixtures/deploy_for_dest.world.yml",
    "content": "servers:\n  - 1.1.1.1\n  - 1.1.1.2\nenv:\n  REDIS_URL: redis://x/y\n"
  },
  {
    "path": "test/fixtures/deploy_for_dest.yml",
    "content": "service: app\nimage: dhh/app\nregistry:\n  server: registry.digitalocean.com\n  username: <%= \"my-user\" %>\n  password: <%= \"my-password\" %>\nbuilder:\n  arch: amd64\n"
  },
  {
    "path": "test/fixtures/deploy_for_required_dest.world.yml",
    "content": "servers:\n  - 1.1.1.1\n  - 1.1.1.2\nenv:\n  REDIS_URL: redis://x/y\n"
  },
  {
    "path": "test/fixtures/deploy_for_required_dest.yml",
    "content": "service: app\nimage: dhh/app\nregistry:\n  server: registry.digitalocean.com\n  username: <%= \"my-user\" %>\n  password: <%= \"my-password\" %>\nbuilder:\n  arch: amd64\nrequire_destination: true\naliases:\n  world_deploy: deploy -d world\n"
  },
  {
    "path": "test/fixtures/deploy_primary_web_role_override.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  web_chicago:\n    proxy: {}\n    hosts:\n      - 1.1.1.1\n      - 1.1.1.2\n  web_tokyo:\n    proxy: {}\n    hosts:\n      - 1.1.1.3\n      - 1.1.1.4\nenv:\n  REDIS_URL: redis://x/y\nregistry:\n  server: registry.digitalocean.com\n  username: user\n  password: pw\nbuilder:\n  arch: amd64\nprimary_role: web_tokyo\n"
  },
  {
    "path": "test/fixtures/deploy_simple.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  - \"1.1.1.1\"\n  - \"1.1.1.2\"\nregistry:\n  username: user\n  password: pw\nbuilder:\n  arch: amd64\n"
  },
  {
    "path": "test/fixtures/deploy_with_accessories.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  web:\n    - \"1.1.1.1\"\n    - \"1.1.1.2\"\n  workers:\n    - \"1.1.1.3\"\n    - \"1.1.1.4\"\nregistry:\n  username: user\n  password: pw\nbuilder:\n  arch: amd64\n\naccessories:\n  mysql:\n    image: mysql:5.7\n    host: 1.1.1.3\n    port: 3306\n    env:\n      clear:\n        MYSQL_ROOT_HOST: '%'\n      secret:\n        - MYSQL_ROOT_PASSWORD\n    files:\n      - test/fixtures/files/my.cnf:/etc/mysql/my.cnf\n    directories:\n      - data:/var/lib/mysql\n  redis:\n    image: redis:latest\n    roles:\n      - web\n    port: 6379\n    directories:\n      - data:/data\n\nreadiness_delay: 0\n"
  },
  {
    "path": "test/fixtures/deploy_with_accessories_on_independent_server.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  web:\n    - \"1.1.1.1\"\n    - \"1.1.1.2\"\n  workers:\n    - \"1.1.1.3\"\n    - \"1.1.1.4\"\nregistry:\n  username: user\n  password: pw\nbuilder:\n  arch: amd64\n\naccessories:\n  mysql:\n    image: mysql:5.7\n    host: 1.1.1.5\n    port: 3306\n    env:\n      clear:\n        MYSQL_ROOT_HOST: '%'\n      secret:\n        - MYSQL_ROOT_PASSWORD\n    files:\n      - test/fixtures/files/my.cnf:/etc/mysql/my.cnf\n    directories:\n      - data:/var/lib/mysql\n  redis:\n    image: redis:latest\n    roles:\n      - web\n    port: 6379\n    directories:\n      - data:/data\n\nreadiness_delay: 0\n"
  },
  {
    "path": "test/fixtures/deploy_with_accessories_with_different_registries.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  web:\n    - \"1.1.1.1\"\n    - \"1.1.1.2\"\n  workers:\n    - \"1.1.1.3\"\n    - \"1.1.1.4\"\nregistry:\n  server: private.registry\n  username: user\n  password: pw\nbuilder:\n  arch: amd64\n\naccessories:\n  mysql:\n    image: private.registry/mysql:5.7\n    host: 1.1.1.3\n    port: 3306\n    env:\n      clear:\n        MYSQL_ROOT_HOST: '%'\n      secret:\n        - MYSQL_ROOT_PASSWORD\n    files:\n      - test/fixtures/files/my.cnf:/etc/mysql/my.cnf\n    directories:\n      - data:/var/lib/mysql\n  redis:\n    image: redis:latest\n    roles:\n      - web\n    port: 6379\n    directories:\n      - data:/data\n  busybox:\n    service: custom-box\n    image: busybox:latest\n    host: 1.1.1.3\n    registry:\n      server: other.registry\n      username: other_user\n      password: other_pw\n\nreadiness_delay: 0\n"
  },
  {
    "path": "test/fixtures/deploy_with_aliases.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  web:\n    - 1.1.1.1\n    - 1.1.1.2\n  workers:\n    hosts:\n      - 1.1.1.3\n      - 1.1.1.4\n  console:\n    hosts:\n      - 1.1.1.5\nbuilder:\n  arch: amd64\nregistry:\n  username: user\n  password: pw\naliases:\n  info: details\n  console: app exec --reuse -p -r console \"bin/console\"\n  exec: app exec --reuse -p -r console\n  rails: app exec --reuse -p -r console rails\n  primary_details: details -p\n  deploy_secondary: deploy -d secondary\n\n"
  },
  {
    "path": "test/fixtures/deploy_with_assets.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  - \"1.1.1.1\"\n  - \"1.1.1.2\"\nregistry:\n  username: user\n  password: pw\nbuilder:\n  arch: amd64\nasset_path: /public/assets\n"
  },
  {
    "path": "test/fixtures/deploy_with_boot_strategy.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  web:\n    - \"1.1.1.1\"\n    - \"1.1.1.2\"\n  workers:\n    - \"1.1.1.3\"\n    - \"1.1.1.4\"\nbuilder:\n  arch: amd64\n\nregistry:\n  username: user\n  password: pw\n\nboot:\n  limit: 3\n  wait: 2\n"
  },
  {
    "path": "test/fixtures/deploy_with_cloud_builder.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  web:\n    - \"1.1.1.1\"\n    - \"1.1.1.2\"\n  workers:\n    - \"1.1.1.3\"\n    - \"1.1.1.4\"\nregistry:\n  username: user\n  password: pw\n\naccessories:\n  mysql:\n    image: mysql:5.7\n    host: 1.1.1.3\n    port: 3306\n    env:\n      clear:\n        MYSQL_ROOT_HOST: '%'\n      secret:\n        - MYSQL_ROOT_PASSWORD\n    files:\n      - test/fixtures/files/my.cnf:/etc/mysql/my.cnf\n    directories:\n      - data:/var/lib/mysql\n  redis:\n    image: redis:latest\n    roles:\n      - web\n    port: 6379\n    directories:\n      - data:/data\n\nreadiness_delay: 0\n\nbuilder:\n  arch: <%= Kamal::Utils.docker_arch == \"arm64\" ? \"amd64\" : \"arm64\" %>\n  driver: cloud example_org/cloud_builder\n"
  },
  {
    "path": "test/fixtures/deploy_with_env_tags.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  web:\n    - 1.1.1.1: site1\n    - 1.1.1.2: [ site1 experimental ]\n    - 1.2.1.1: site2\n    - 1.2.1.2: site2\n  workers:\n    - 1.1.1.3: site1\n    - 1.1.1.4: site1\n    - 1.2.1.3: site2\n    - 1.2.1.4: [ site2 experimental ]\nbuilder:\n  arch: amd64\nenv:\n  clear:\n    TEST: \"root\"\n    EXPERIMENT: \"disabled\"\n  tags:\n    site1:\n      SITE: site1\n    site2:\n      SITE: site2\n    experimental:\n      EXPERIMENT: \"enabled\"\n\nregistry:\n  username: user\n  password: pw\n"
  },
  {
    "path": "test/fixtures/deploy_with_error_pages.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  - \"1.1.1.1\"\n  - \"1.1.1.2\"\nregistry:\n  username: user\n  password: pw\nbuilder:\n  arch: amd64\nerror_pages_path: public\n"
  },
  {
    "path": "test/fixtures/deploy_with_extensions.yml",
    "content": "\nx-web: &web\n  proxy: {}\n\nservice: app\nimage: dhh/app\nservers:\n  web_chicago:\n    <<: *web\n    hosts:\n      - 1.1.1.1\n      - 1.1.1.2\n  web_tokyo:\n    <<: *web\n    hosts:\n      - 1.1.1.3\n      - 1.1.1.4\nenv:\n  REDIS_URL: redis://x/y\nregistry:\n  server: registry.digitalocean.com\n  username: user\n  password: pw\nbuilder:\n  arch: amd64\nprimary_role: web_tokyo\n"
  },
  {
    "path": "test/fixtures/deploy_with_hybrid_builder.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  web:\n    - \"1.1.1.1\"\n    - \"1.1.1.2\"\n  workers:\n    - \"1.1.1.3\"\n    - \"1.1.1.4\"\nregistry:\n  username: user\n  password: pw\n\naccessories:\n  mysql:\n    image: mysql:5.7\n    host: 1.1.1.3\n    port: 3306\n    env:\n      clear:\n        MYSQL_ROOT_HOST: '%'\n      secret:\n        - MYSQL_ROOT_PASSWORD\n    files:\n      - test/fixtures/files/my.cnf:/etc/mysql/my.cnf\n    directories:\n      - data:/var/lib/mysql\n  redis:\n    image: redis:latest\n    roles:\n      - web\n    port: 6379\n    directories:\n      - data:/data\n\nreadiness_delay: 0\n\nbuilder:\n  arch:\n    - arm64\n    - amd64\n  remote: ssh://app@1.1.1.5\n"
  },
  {
    "path": "test/fixtures/deploy_with_local_registry.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  web:\n    - \"1.1.1.1\"\n    - \"1.1.1.2\"\nregistry:\n  server: localhost:5000\nbuilder:\n  arch: amd64\n"
  },
  {
    "path": "test/fixtures/deploy_with_local_registry_and_accessories.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  web:\n    - \"1.1.1.1\"\n    - \"1.1.1.2\"\n  workers:\n    - \"1.1.1.3\"\n    - \"1.1.1.4\"\nregistry:\n  server: localhost:5000\nbuilder:\n  arch: amd64\n\naccessories:\n  mysql:\n    image: mysql:5.7\n    host: 1.1.1.3\n    port: 3306\n    env:\n      clear:\n        MYSQL_ROOT_HOST: '%'\n      secret:\n        - MYSQL_ROOT_PASSWORD\n    files:\n      - test/fixtures/files/my.cnf:/etc/mysql/my.cnf\n    directories:\n      - data:/var/lib/mysql\n  redis:\n    image: redis:latest\n    roles:\n      - web\n    port: 6379\n    directories:\n      - data:/data\n  busybox:\n    service: custom-box\n    image: busybox:latest\n    host: 1.1.1.3\n    registry:\n      server: other.registry\n      username: other_user\n      password: other_pw\n\nreadiness_delay: 0\n"
  },
  {
    "path": "test/fixtures/deploy_with_local_registry_and_remote_builder.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  web:\n    - \"1.1.1.1\"\n    - \"1.1.1.2\"\nregistry:\n  server: localhost:5000\n\nbuilder:\n  arch: <%= Kamal::Utils.docker_arch == \"arm64\" ? \"amd64\" : \"arm64\" %>\n  remote: ssh://app@1.1.1.5\n"
  },
  {
    "path": "test/fixtures/deploy_with_local_registry_and_remote_builder_with_port.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  web:\n    - \"1.1.1.1\"\n    - \"1.1.1.2\"\nregistry:\n  server: localhost:5000\n\nbuilder:\n  arch: <%= Kamal::Utils.docker_arch == \"arm64\" ? \"amd64\" : \"arm64\" %>\n  remote: ssh://app@1.1.1.5:2222\n"
  },
  {
    "path": "test/fixtures/deploy_with_multiple_proxy_roles.yml",
    "content": "# actual config\nservice: app\nimage: dhh/app\nservers:\n  web:\n    hosts:\n      - 1.1.1.1\n      - 1.1.1.2\n    env:\n      ROLE: \"web\"\n    proxy: true\n  web_tokyo:\n    hosts:\n      - 1.1.1.3\n      - 1.1.1.4\n    env:\n      ROLE: \"web\"\n    proxy: true\n  workers:\n    cmd: bin/jobs\n    hosts:\n      - 1.1.1.1\n      - 1.1.1.2\n  workers_tokyo:\n    cmd: bin/jobs\n    hosts:\n      - 1.1.1.3\n      - 1.1.1.4\nbuilder:\n  arch: amd64\nenv:\n  REDIS_URL: redis://x/y\nregistry:\n  server: registry.digitalocean.com\n  username: user\n  password: pw\n"
  },
  {
    "path": "test/fixtures/deploy_with_only_workers.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  workers:\n    proxy: false\n    hosts:\n      - 1.1.1.1\n      - 1.1.1.2\nprimary_role: workers\nregistry:\n  username: user\n  password: pw\nbuilder:\n  arch: amd64\n"
  },
  {
    "path": "test/fixtures/deploy_with_parallel_roles.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  web:\n    - \"1.1.1.1\"\n    - \"1.1.1.2\"\n  workers:\n    - \"1.1.1.1\"\n    - \"1.1.1.3\"\nbuilder:\n  arch: amd64\n\nregistry:\n  username: user\n  password: pw\n\nboot:\n  parallel_roles: true\n"
  },
  {
    "path": "test/fixtures/deploy_with_proxy.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  web:\n    - \"1.1.1.1\"\n    - \"1.1.1.2\"\n  workers:\n    - \"1.1.1.3\"\n    - \"1.1.1.4\"\nregistry:\n  username: user\n  password: pw\nbuilder:\n  arch: amd64\n\n\naccessories:\n  mysql:\n    image: mysql:5.7\n    host: 1.1.1.3\n    port: 3306\n    env:\n      clear:\n        MYSQL_ROOT_HOST: '%'\n      secret:\n        - MYSQL_ROOT_PASSWORD\n    files:\n      - test/fixtures/files/my.cnf:/etc/mysql/my.cnf\n    directories:\n      - data:/var/lib/mysql\n  redis:\n    image: redis:latest\n    roles:\n      - web\n    port: 6379\n    directories:\n      - data:/data\n\nreadiness_delay: 0\ndeploy_timeout: 6\n"
  },
  {
    "path": "test/fixtures/deploy_with_proxy_roles.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  web:\n    hosts:\n      - \"1.1.1.1\"\n      - \"1.1.1.2\"\n  web2:\n    hosts:\n      - \"1.1.1.3\"\n      - \"1.1.1.4\"\n    proxy:\n      response_timeout: 15\nregistry:\n  username: user\n  password: pw\nbuilder:\n  arch: amd64\n\nproxy:\n  response_timeout: 10\n\naccessories:\n  mysql:\n    image: mysql:5.7\n    host: 1.1.1.3\n    port: 3306\n    env:\n      clear:\n        MYSQL_ROOT_HOST: '%'\n      secret:\n        - MYSQL_ROOT_PASSWORD\n    files:\n      - test/fixtures/files/my.cnf:/etc/mysql/my.cnf\n    directories:\n      - data:/var/lib/mysql\n  redis:\n    image: redis:latest\n    roles:\n      - web\n    port: 6379\n    directories:\n      - data:/data\n\nreadiness_delay: 0\ndeploy_timeout: 6\n"
  },
  {
    "path": "test/fixtures/deploy_with_proxy_run_config.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  web:\n    - \"1.1.1.1\"\n    - \"1.1.1.2\"\n  workers:\n    - \"1.1.1.3\"\n    - \"1.1.1.4\"\nregistry:\n  username: user\n  password: pw\nbuilder:\n  arch: amd64\nproxy:\n  run:\n    registry: registry:4443\n    debug: true\n    metrics_port: 9090\n    options:\n      cpus: 1.5\n\n\naccessories:\n  mysql:\n    image: mysql:5.7\n    host: 1.1.1.3\n    port: 3306\n    env:\n      clear:\n        MYSQL_ROOT_HOST: '%'\n      secret:\n        - MYSQL_ROOT_PASSWORD\n    files:\n      - test/fixtures/files/my.cnf:/etc/mysql/my.cnf\n    directories:\n      - data:/var/lib/mysql\n    proxy:\n      run:\n        debug: false\n        metrics_port: 9190\n  redis:\n    image: redis:latest\n    roles:\n      - web\n    port: 6379\n    directories:\n      - data:/data\n\nreadiness_delay: 0\ndeploy_timeout: 6\n"
  },
  {
    "path": "test/fixtures/deploy_with_proxy_run_config_conflicts.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  web:\n    - \"1.1.1.1\"\n    - \"1.1.1.2\"\n  workers:\n    - \"1.1.1.3\"\n    - \"1.1.1.4\"\nregistry:\n  username: user\n  password: pw\nbuilder:\n  arch: amd64\nproxy:\n  run:\n    debug: true\n\naccessories:\n  mysql:\n    image: mysql:5.7\n    host: 1.1.1.2\n    port: 3306\n    env:\n      clear:\n        MYSQL_ROOT_HOST: '%'\n      secret:\n        - MYSQL_ROOT_PASSWORD\n    files:\n      - test/fixtures/files/my.cnf:/etc/mysql/my.cnf\n    directories:\n      - data:/var/lib/mysql\n    proxy:\n      run:\n        debug: false\n  redis:\n    image: redis:latest\n    roles:\n      - web\n    port: 6379\n    directories:\n      - data:/data\n\nreadiness_delay: 0\ndeploy_timeout: 6\n"
  },
  {
    "path": "test/fixtures/deploy_with_remote_builder.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  web:\n    - \"1.1.1.1\"\n    - \"1.1.1.2\"\n  workers:\n    - \"1.1.1.3\"\n    - \"1.1.1.4\"\nregistry:\n  username: user\n  password: pw\n\naccessories:\n  mysql:\n    image: mysql:5.7\n    host: 1.1.1.3\n    port: 3306\n    env:\n      clear:\n        MYSQL_ROOT_HOST: '%'\n      secret:\n        - MYSQL_ROOT_PASSWORD\n    files:\n      - test/fixtures/files/my.cnf:/etc/mysql/my.cnf\n    directories:\n      - data:/var/lib/mysql\n  redis:\n    image: redis:latest\n    roles:\n      - web\n    port: 6379\n    directories:\n      - data:/data\n\nreadiness_delay: 0\n\nbuilder:\n  arch: <%= Kamal::Utils.docker_arch == \"arm64\" ? \"amd64\" : \"arm64\" %>\n  remote: ssh://app@1.1.1.5\n"
  },
  {
    "path": "test/fixtures/deploy_with_remote_builder_and_custom_ports.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  web:\n    - \"1.1.1.1\"\n    - \"1.1.1.2\"\n  workers:\n    - \"1.1.1.3\"\n    - \"1.1.1.4\"\nregistry:\n  username: user\n  password: pw\n\naccessories:\n  mysql:\n    image: mysql:5.7\n    host: 1.1.1.3\n    port: 3306\n    env:\n      clear:\n        MYSQL_ROOT_HOST: '%'\n      secret:\n        - MYSQL_ROOT_PASSWORD\n    files:\n      - test/fixtures/files/my.cnf:/etc/mysql/my.cnf\n    directories:\n      - data:/var/lib/mysql\n  redis:\n    image: redis:latest\n    roles:\n      - web\n    port: 6379\n    directories:\n      - data:/data\n\nreadiness_delay: 0\n\nssh:\n  user: root\n  port: 22\n\nbuilder:\n  arch: <%= Kamal::Utils.docker_arch == \"arm64\" ? \"amd64\" : \"arm64\" %>\n  remote: ssh://app@1.1.1.5:2122\n"
  },
  {
    "path": "test/fixtures/deploy_with_roles.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  web:\n    - 1.1.1.1\n    - 1.1.1.2\n  workers:\n    hosts:\n      - 1.1.1.3\n      - 1.1.1.4\nenv:\n  REDIS_URL: redis://x/y\nregistry:\n  server: registry.digitalocean.com\n  username: user\n  password: pw\nbuilder:\n  arch: amd64\ndeploy_timeout: 1\n"
  },
  {
    "path": "test/fixtures/deploy_with_roles_workers_primary.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  workers:\n    - 1.1.1.1\n    - 1.1.1.2\n  web:\n    - 1.1.1.3\n    - 1.1.1.4\nenv:\n  REDIS_URL: redis://x/y\nregistry:\n  server: registry.digitalocean.com\n  username: user\n  password: pw\nbuilder:\n  arch: amd64\ndeploy_timeout: 1\nprimary_role: workers\n"
  },
  {
    "path": "test/fixtures/deploy_with_secrets.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  - \"1.1.1.1\"\n  - \"1.1.1.2\"\nregistry:\n  username: user\n  password: pw\nenv:\n  secret:\n    - PASSWORD\nbuilder:\n  arch: amd64\n"
  },
  {
    "path": "test/fixtures/deploy_with_single_accessory.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  web:\n    - \"1.1.1.1\"\n    - \"1.1.1.2\"\n  workers:\n    - \"1.1.1.3\"\n    - \"1.1.1.4\"\nregistry:\n  username: user\n  password: pw\nbuilder:\n  arch: amd64\n\naccessories:\n  mysql:\n    image: mysql:5.7\n    host: 1.1.1.5\n    port: 3306\n    env:\n      clear:\n        MYSQL_ROOT_HOST: '%'\n      secret:\n        - MYSQL_ROOT_PASSWORD\n    files:\n      - test/fixtures/files/my.cnf:/etc/mysql/my.cnf\n    directories:\n      - data:/var/lib/mysql\n"
  },
  {
    "path": "test/fixtures/deploy_with_two_roles_one_host.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  workers:\n    hosts:\n      - 1.1.1.1\n  web:\n    hosts:\n      - 1.1.1.1\nenv:\n  REDIS_URL: redis://x/y\nregistry:\n  server: registry.digitalocean.com\n  username: user\n  password: pw\nbuilder:\n  arch: amd64\n"
  },
  {
    "path": "test/fixtures/deploy_with_uncommon_hostnames.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  - \"this-hostname-with-random-part-is-too-long.example.com\"\n  - \"this-hostname-is-really-unacceptably-long-to-be-honest.example.com\"\nregistry:\n  username: user\n  password: pw\nbuilder:\n  arch: amd64\n"
  },
  {
    "path": "test/fixtures/deploy_without_clone.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  web:\n    - \"1.1.1.1\"\n    - \"1.1.1.2\"\n  workers:\n    - \"1.1.1.3\"\n    - \"1.1.1.4\"\nregistry:\n  username: user\n  password: pw\n\naccessories:\n  mysql:\n    image: mysql:5.7\n    host: 1.1.1.3\n    port: 3306\n    env:\n      clear:\n        MYSQL_ROOT_HOST: '%'\n      secret:\n        - MYSQL_ROOT_PASSWORD\n    files:\n      - test/fixtures/files/my.cnf:/etc/mysql/my.cnf\n    directories:\n      - data:/var/lib/mysql\n  redis:\n    image: redis:latest\n    roles:\n      - web\n    port: 6379\n    directories:\n      - data:/data\n\nreadiness_delay: 0\n\nbuilder:\n  arch: amd64\n  context: \".\"\n"
  },
  {
    "path": "test/fixtures/deploy_without_parallel_roles.yml",
    "content": "service: app\nimage: dhh/app\nservers:\n  web:\n    - \"1.1.1.1\"\n    - \"1.1.1.2\"\n  workers:\n    - \"1.1.1.1\"\n    - \"1.1.1.3\"\nbuilder:\n  arch: amd64\n\nregistry:\n  username: user\n  password: pw\n"
  },
  {
    "path": "test/fixtures/files/my.cnf",
    "content": "# MySQL Config\n"
  },
  {
    "path": "test/fixtures/files/structure.sql.erb",
    "content": "<%= \"This was dynamically expanded\" %>\n<%= ENV[\"MYSQL_ROOT_HOST\"] %>\n<%= ENV[\"MYSQL_ROOT_PASSWORD\"] %>\n<%= ENV[\"ENV_VAR\"] %>\n"
  },
  {
    "path": "test/git_test.rb",
    "content": "require \"test_helper\"\n\nclass GitTest < ActiveSupport::TestCase\n  test \"uncommitted changes exist\" do\n    Kamal::Git.expects(:`).with(\"git status --porcelain\").returns(\"M   file\\n\")\n    assert_equal \"M   file\", Kamal::Git.uncommitted_changes\n  end\n\n  test \"uncommitted changes do not exist\" do\n    Kamal::Git.expects(:`).with(\"git status --porcelain\").returns(\"\")\n    assert_equal \"\", Kamal::Git.uncommitted_changes\n  end\nend\n"
  },
  {
    "path": "test/integration/accessory_test.rb",
    "content": "require_relative \"integration_test\"\n\nclass AccessoryTest < IntegrationTest\n  test \"boot, stop, start, restart, logs, remove\" do\n    kamal :accessory, :boot, :busybox\n    assert_accessory_running :busybox\n    assert_accessory_volume_mount_options :busybox\n    assert_accessory_file_mode_and_owner :busybox\n    assert_accessory_directory_mode_and_owner :busybox\n\n    kamal :accessory, :stop, :busybox\n    assert_accessory_not_running :busybox\n\n    kamal :accessory, :start, :busybox\n    assert_accessory_running :busybox\n\n    kamal :accessory, :restart, :busybox\n    assert_accessory_running :busybox\n\n    logs = kamal :accessory, :logs, :busybox, capture: true\n    assert_match /Starting busybox.../, logs\n\n    boot = kamal :accessory, :boot, :busybox, capture: true\n    assert_match /Skipping booting `busybox` on vm1, vm2, a container already exists/, boot\n\n    kamal :accessory, :remove, :busybox, \"-y\"\n    assert_accessory_not_running :busybox\n  end\n\n  test \"proxied: boot, stop, start, restart, logs, remove\" do\n    @app = \"app_with_proxied_accessory\"\n\n    kamal :proxy, :boot\n\n    kamal :accessory, :boot, :netcat\n    assert_accessory_running :netcat\n    assert_netcat_is_up\n\n    kamal :accessory, :stop, :netcat\n    assert_accessory_not_running :netcat\n    assert_netcat_not_found\n\n    kamal :accessory, :start, :netcat\n    assert_accessory_running :netcat\n    assert_netcat_is_up\n\n    kamal :accessory, :restart, :netcat\n    assert_accessory_running :netcat\n    assert_netcat_is_up\n\n    kamal :accessory, :remove, :netcat, \"-y\"\n    assert_accessory_not_running :netcat\n    assert_netcat_not_found\n  end\n\n  private\n    def assert_accessory_running(name)\n      assert_match /registry:4443\\/busybox:1.36.0   \"sh -c 'echo \\\\\"Start/, accessory_details(name)\n    end\n\n    def assert_accessory_not_running(name)\n      assert_no_match /registry:4443\\/busybox:1.36.0   \"sh -c 'echo \\\\\"Start/, accessory_details(name)\n    end\n\n    def assert_accessory_volume_mount_options(name)\n      mounts = docker_compose(\"exec vm1 docker inspect custom-busybox --format '{{json .Mounts}}'\", capture: true)\n      assert_match %r{/data.*\"RW\":false}, mounts, \"Expected read-only mount option (:ro) to be applied\"\n    end\n\n    def assert_accessory_file_mode_and_owner(name)\n      file_stat = docker_compose(\"exec vm1 stat -c '%a %u:%g' /root/custom-busybox/etc/busybox.conf\", capture: true)\n      assert_match /640 1000:1000/, file_stat, \"Expected file to have 640 mode and 1000:1000 owner\"\n    end\n\n    def assert_accessory_directory_mode_and_owner(name)\n      dir_stat = docker_compose(\"exec vm1 stat -c '%a %u:%g' /root/custom-busybox/data\", capture: true)\n      assert_match /750 1000:1000/, dir_stat, \"Expected directory to have 750 mode and 1000:1000 owner\"\n    end\n\n    def accessory_details(name)\n      kamal :accessory, :details, name, capture: true\n    end\n\n    def assert_netcat_is_up\n      response = netcat_response\n      debug_response_code(response, \"200\")\n      assert_equal \"200\", response.code\n    end\n\n    def assert_netcat_not_found\n      response = netcat_response\n      debug_response_code(response, \"404\")\n      assert_equal \"404\", response.code\n    end\n\n    def netcat_response\n      uri = URI.parse(\"http://127.0.0.1:12345/up\")\n      http = Net::HTTP.new(uri.host, uri.port)\n      request = Net::HTTP::Get.new(uri)\n      request[\"Host\"] = \"netcat\"\n\n      http.request(request)\n    end\nend\n"
  },
  {
    "path": "test/integration/app_test.rb",
    "content": "require_relative \"integration_test\"\n\nclass AppTest < IntegrationTest\n  test \"stop, start, boot, logs, images, containers, exec, remove\" do\n    kamal :deploy\n\n    assert_app_is_up\n\n    kamal :app, :stop\n\n    assert_app_not_found\n\n    kamal :app, :start\n\n    # kamal app start does not wait\n    wait_for_app_to_be_up\n\n    output = kamal :app, :boot, \"--verbose\", capture: true\n    assert_match \"Booting app on vm1,vm2...\", output\n    assert_match \"Booted app on vm1,vm2...\", output\n\n    wait_for_app_to_be_up\n\n    logs = kamal :app, :logs, capture: true\n    assert_match \"App Host: vm1\", logs\n    assert_match \"App Host: vm2\", logs\n    assert_match \"GET /version HTTP/1.1\", logs\n\n    images = kamal :app, :images, capture: true\n    assert_match \"App Host: vm1\", images\n    assert_match \"App Host: vm2\", images\n    assert_match /localhost:5000\\/app\\s+#{latest_app_version}/, images\n    assert_match /localhost:5000\\/app\\s+latest/, images\n\n    containers = kamal :app, :containers, capture: true\n    assert_match \"App Host: vm1\", containers\n    assert_match \"App Host: vm2\", containers\n    assert_match \"localhost:5000/app:#{latest_app_version}\", containers\n    assert_match \"localhost:5000/app:latest\", containers\n\n    exec_output = kamal :app, :exec, :ps, capture: true\n    assert_match \"App Host: vm1\", exec_output\n    assert_match \"App Host: vm2\", exec_output\n    assert_match /1 root      0:\\d\\d ps/, exec_output\n\n    exec_output = kamal :app, :exec, \"--reuse\", :ps, capture: true\n    assert_match \"App Host: vm2\", exec_output\n    assert_match \"App Host: vm1\", exec_output\n    assert_match /1 root      0:\\d\\d nginx/, exec_output\n\n    kamal :app, :maintenance\n    assert_app_in_maintenance\n\n    kamal :app, :live\n    assert_app_is_up\n\n    kamal :app, :remove\n\n    assert_app_not_found\n    assert_app_directory_removed\n  end\n\n  test \"parallel roles\" do\n    @app = \"app_with_parallel_roles\"\n\n    version = latest_app_version\n\n    kamal :deploy\n\n    assert_app_is_up version: version\n    assert_container_running host: :vm1, name: \"app_with_parallel_roles-web-#{version}\"\n    assert_container_running host: :vm1, name: \"app_with_parallel_roles-workers-#{version}\"\n    assert_container_running host: :vm2, name: \"app_with_parallel_roles-web-#{version}\"\n\n    kamal :app, :stop\n\n    assert_app_not_found\n\n    kamal :app, :start\n\n    wait_for_app_to_be_up\n\n    logs = kamal :app, :logs, capture: true\n    assert_match /role=web.* on vm1/, logs\n    assert_match /role=web.* on vm2/, logs\n    assert_match /role=workers.* on vm1/, logs\n\n    # Images runs once per host (not per role)\n    images = kamal :app, :images, capture: true\n    assert_match \"App Host: vm1\", images\n    assert_match \"App Host: vm2\", images\n    assert_equal 2, images.scan(/App Host:/).count\n\n    # Containers runs once per host (not per role)\n    containers = kamal :app, :containers, capture: true\n    assert_match \"App Host: vm1\", containers\n    assert_match \"App Host: vm2\", containers\n    assert_match \"app_with_parallel_roles-web\", containers\n    assert_match \"app_with_parallel_roles-workers\", containers\n    assert_equal 2, containers.scan(/App Host:/).count\n\n    # Exec runs per role per host: web on vm1, web on vm2, workers on vm1\n    exec_output = kamal :app, :exec, :hostname, capture: true\n    assert_match /app_with_parallel_roles-web-exec-.* on vm1/, exec_output\n    assert_match /app_with_parallel_roles-web-exec-.* on vm2/, exec_output\n    assert_match /app_with_parallel_roles-workers-exec-.* on vm1/, exec_output\n    assert_equal 3, exec_output.scan(/App Host:/).count\n\n    kamal :app, :maintenance\n    assert_app_in_maintenance\n\n    kamal :app, :live\n    assert_app_is_up\n  end\n\n  test \"custom error pages\" do\n    @app = \"app_with_roles\"\n\n    kamal :deploy\n    assert_app_is_up\n\n    kamal :app, :maintenance\n    assert_app_in_maintenance message: \"Custom Maintenance Page\"\n\n    kamal :app, :live\n    kamal :app, :maintenance, \"--message\", \"\\\"Testing Maintence Mode\\\"\"\n    assert_app_in_maintenance message: \"Custom Maintenance Page: Testing Maintence Mode\"\n\n    second_version = update_app_rev\n\n    kamal :redeploy\n\n    kamal :app, :maintenance\n    assert_app_in_maintenance message: \"Custom Maintenance Page\"\n  end\nend\n"
  },
  {
    "path": "test/integration/broken_deploy_test.rb",
    "content": "require_relative \"integration_test\"\n\nclass BrokenDeployTest < IntegrationTest\n  test \"deploying a bad image\" do\n    @app = \"app_with_roles\"\n\n    first_version = latest_app_version\n\n    kamal :deploy\n\n    assert_app_is_up version: first_version\n    assert_container_running host: :vm3, name: \"app_with_roles-workers-#{first_version}\"\n\n    second_version = break_app\n\n    output = kamal :deploy, raise_on_error: false, capture: true\n\n    assert_failed_deploy output\n    assert_app_is_up version: first_version\n    assert_container_running host: :vm3, name: \"app_with_roles-workers-#{first_version}\"\n    assert_container_not_running host: :vm3, name: \"app_with_roles-workers-#{second_version}\"\n  end\n\n  private\n    def assert_failed_deploy(output)\n      assert_match \"Waiting for the first healthy web container before booting workers on vm3...\", output\n      assert_match /First web container is unhealthy on vm[12], not booting any other roles/, output\n      assert_match \"First web container is unhealthy, not booting workers on vm3\", output\n      assert_match \"nginx: [emerg] unexpected end of file, expecting \\\";\\\" or \\\"}\\\" in /etc/nginx/conf.d/default.conf:2\", output\n    end\nend\n"
  },
  {
    "path": "test/integration/docker/deployer/.dockerignore",
    "content": "Dockerfile\n"
  },
  {
    "path": "test/integration/docker/deployer/Dockerfile",
    "content": "FROM ruby:3.2\n\nWORKDIR /\n\nENV VERBOSE=true\n\nRUN apt-get update --fix-missing && apt-get install -y ca-certificates openssh-client curl gnupg\n\nRUN install -m 0755 -d /etc/apt/keyrings\nRUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg\nRUN chmod a+r /etc/apt/keyrings/docker.gpg\nRUN echo \\\n  \"deb [arch=\"$(dpkg --print-architecture)\" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \\\n  \"$(. /etc/os-release && echo \"$VERSION_CODENAME\")\" stable\" | \\\n  tee /etc/apt/sources.list.d/docker.list > /dev/null\n\nRUN apt-get update --fix-missing && apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin\n\nCOPY . .\n\nRUN rm -rf /root/.ssh && \\\n    ln -s /shared/ssh /root/.ssh && \\\n    mkdir -p /etc/docker/certs.d/registry:4443 && \\\n    ln -s /shared/certs/domain.crt /etc/docker/certs.d/registry:4443/ca.crt && \\\n    git config --global user.email \"deployer@example.com\" && \\\n    git config --global user.name \"Deployer\" && \\\n    cd app && git init && git add . && git commit -am \"Initial version\" && \\\n    cd /app_with_custom_certificate && git init && git add . && git commit -am \"Initial version\" && \\\n    cd /app_with_roles && git init && git add . && git commit -am \"Initial version\" && \\\n    cd /app_with_traefik && git init && git add . && git commit -am \"Initial version\" && \\\n    cd /app_with_proxied_accessory && git init && git add . && git commit -am \"Initial version\" && \\\n    cd /app_with_parallel_roles && git init && git add . && git commit -am \"Initial version\" && \\\n    cd /app_with_destinations && git init && git add . && git commit -am \"Initial version\"\n\nHEALTHCHECK --interval=1s CMD pgrep sleep\n\nCMD [\"./boot.sh\"]\n"
  },
  {
    "path": "test/integration/docker/deployer/app/.kamal/hooks/docker-setup",
    "content": "#!/bin/sh\necho \"Docker set up!\"\nmkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/docker-setup\n"
  },
  {
    "path": "test/integration/docker/deployer/app/.kamal/hooks/post-app-boot",
    "content": "#!/bin/sh\necho \"Booted app on ${KAMAL_HOSTS}...\"\nmkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/post-app-boot\n"
  },
  {
    "path": "test/integration/docker/deployer/app/.kamal/hooks/post-deploy",
    "content": "#!/bin/sh\necho \"Finished deploy!\"\nmkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/post-deploy\n"
  },
  {
    "path": "test/integration/docker/deployer/app/.kamal/hooks/post-proxy-reboot",
    "content": "#!/bin/sh\necho \"Rebooted kamal-proxy on ${KAMAL_HOSTS}\"\nmkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/post-proxy-reboot\n"
  },
  {
    "path": "test/integration/docker/deployer/app/.kamal/hooks/pre-app-boot",
    "content": "#!/bin/sh\necho \"Booting app on ${KAMAL_HOSTS}...\"\nmkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-app-boot\n"
  },
  {
    "path": "test/integration/docker/deployer/app/.kamal/hooks/pre-build",
    "content": "#!/bin/sh\necho \"About to build and push...\"\nmkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-build\n"
  },
  {
    "path": "test/integration/docker/deployer/app/.kamal/hooks/pre-connect",
    "content": "#!/bin/sh\n\necho \"About to lock...\"\nenv\nmkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-connect\n"
  },
  {
    "path": "test/integration/docker/deployer/app/.kamal/hooks/pre-deploy",
    "content": "#!/bin/sh\nset -e\n\necho \"Deployed!\"\nmkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-deploy\n"
  },
  {
    "path": "test/integration/docker/deployer/app/.kamal/hooks/pre-proxy-reboot",
    "content": "#!/bin/sh\necho \"Rebooting kamal-proxy on ${KAMAL_HOSTS}...\"\nmkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-proxy-reboot\n"
  },
  {
    "path": "test/integration/docker/deployer/app/.kamal/secrets",
    "content": "SECRETS=$(kamal secrets fetch --adapter test --account test INTERPOLATED_SECRET1 INTERPOLATED_SECRET2 INTERPOLATED_中文 INTERPOLATED_LPARENRPAREN)\nINTERPOLATED_SECRET1=$(kamal secrets extract INTERPOLATED_SECRET1 ${SECRETS})\nINTERPOLATED_SECRET2=$(kamal secrets extract INTERPOLATED_SECRET2 ${SECRETS})\nINTERPOLATED_SECRET3=$(kamal secrets extract INTERPOLATED_中文 ${SECRETS})\nINTERPOLATED_SECRET4=$(kamal secrets extract INTERPOLATED_LPARENRPAREN ${SECRETS})\n"
  },
  {
    "path": "test/integration/docker/deployer/app/.kamal/secrets-common",
    "content": "SECRET_TOKEN='1234 with \"中文\"'\nSECRET_TAG='TAGME'\n"
  },
  {
    "path": "test/integration/docker/deployer/app/Dockerfile",
    "content": "FROM registry:4443/nginx:1-alpine-slim\n\nCOPY default.conf /etc/nginx/conf.d/default.conf\n\nARG COMMIT_SHA\nRUN echo $COMMIT_SHA > /usr/share/nginx/html/version && \\\n    mkdir -p /usr/share/nginx/html/versions && \\\n    echo \"version\" > /usr/share/nginx/html/versions/$COMMIT_SHA && \\\n    echo \"hidden\" > /usr/share/nginx/html/versions/.hidden && \\\n    echo \"Up!\" > /usr/share/nginx/html/up\n"
  },
  {
    "path": "test/integration/docker/deployer/app/config/busybox.conf",
    "content": "# Test config file for busybox accessory\n# Used to verify file mode and owner settings\nsetting=value\n"
  },
  {
    "path": "test/integration/docker/deployer/app/config/deploy.yml",
    "content": "service: app\nimage: app\nservers:\n  - vm1\n  - vm2: [ tag1, tag2 ]\nenv:\n  clear:\n    CLEAR_TOKEN: 4321\n    CLEAR_TAG: \"\"\n    HOST_TOKEN: \"${HOST_TOKEN}\"\n  secret:\n    - SECRET_TOKEN\n    - INTERPOLATED_SECRET1\n    - INTERPOLATED_SECRET2\n    - INTERPOLATED_SECRET3\n    - INTERPOLATED_SECRET4\n  tags:\n    tag1:\n      CLEAR_TAG: tagged\n    tag2:\n      secret:\n        - SECRET_TAG\nasset_path: /usr/share/nginx/html/versions:ro\nhooks_output:\n  pre-deploy: :verbose\n  pre-build: :quiet\ndeploy_timeout: 2\ndrain_timeout: 2\nreadiness_delay: 0\nproxy:\n  host: 127.0.0.1\n  run:\n    registry: registry:4443\nregistry:\n  server: localhost:5000\nbuilder:\n  driver: docker\n  arch: <%= Kamal::Utils.docker_arch %>\n  args:\n    COMMIT_SHA: <%= `git rev-parse HEAD` %>\naccessories:\n  busybox:\n    service: custom-busybox\n    image: registry:4443/busybox:1.36.0\n    cmd: sh -c 'echo \"Starting busybox...\"; trap exit term; while true; do sleep 1; done'\n    roles:\n      - web\n    files:\n      - local: config/busybox.conf\n        remote: /etc/busybox.conf\n        mode: \"0640\"\n        owner: \"1000:1000\"\n    directories:\n      - local: data\n        remote: /data\n        mode: \"0750\"\n        owner: \"1000:1000\"\n        options: \"ro\"\n  busybox2:\n    service: custom-busybox\n    image: registry:4443/busybox:1.36.0\n    cmd: sh -c 'echo \"Starting busybox...\"; trap exit term; while true; do sleep 1; done'\n    host: vm3\n"
  },
  {
    "path": "test/integration/docker/deployer/app/default.conf",
    "content": "server {\n    listen       80;\n    listen  [::]:80;\n    server_name  localhost;\n\n    location / {\n        root   /usr/share/nginx/html;\n        index  index.html index.htm;\n    }\n\n    # redirect server error pages to the static page /50x.html\n    #\n    error_page   500 502 503 504  /50x.html;\n    location = /50x.html {\n        root   /usr/share/nginx/html;\n    }\n}\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_custom_certificate/.kamal/secrets",
    "content": "CUSTOM_CERT=$(cat certs/cert.pem)\nCUSTOM_KEY=$(cat certs/key.pem)\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_custom_certificate/Dockerfile",
    "content": "FROM registry:4443/nginx:1-alpine-slim\n\nCOPY default.conf /etc/nginx/conf.d/default.conf\n\nARG COMMIT_SHA\nRUN echo $COMMIT_SHA > /usr/share/nginx/html/version && \\\n    mkdir -p /usr/share/nginx/html/versions && \\\n    echo \"version\" > /usr/share/nginx/html/versions/$COMMIT_SHA && \\\n    echo \"hidden\" > /usr/share/nginx/html/versions/.hidden && \\\n    echo \"Up!\" > /usr/share/nginx/html/up\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_custom_certificate/certs/cert.pem",
    "content": "-----BEGIN CERTIFICATE-----\nMIIDCzCCAfOgAwIBAgIUJHOADjhddzCAdXFfZvhXAsVMwhowDQYJKoZIhvcNAQEL\nBQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI1MDYxNzA5MDYxOVoYDzIxMjUw\nNTI0MDkwNjE5WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB\nAQUAA4IBDwAwggEKAoIBAQDQaLWwoLZ3/cZdiW/m4pqOe228wCx/CRU9/E2AT9NS\nofuJNtUaxw7QAAFEWIrnf9y3M09lZeox1CNmXe2GADnnx/n906zSGX18SdDmWrxa\nL/1t5OZiXl3we5PM3UNvbFPSq1MCnOtvo6jTPM7shIpJ/5/KuuqovyrO31VCnc2+\nycEzJ2BOcKFUFAeyT/8bk9lAI+1971PLqC6ut9dfy8PVHSPyGrxGiQCpStU7NiQj\nLUkqte7x9GcIKTJUjMkWIsvGke9oGoGgEl5gEfqxFAs3ZkA1aYkiHhwFtrUkGOOf\nO1C6sqfwnnAhtG8LnULGlFYi3GoKALF2XSIagGpaQM5HAgMBAAGjUzBRMB0GA1Ud\nDgQWBBQg2m871YSI220bQEG5APeGzeaz4zAfBgNVHSMEGDAWgBQg2m871YSI220b\nQEG5APeGzeaz4zAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBc\nyQvjLV+Uym+SI/bmKNKafW7ioWSkWAfTl/bvCB8xCX2OJsSqh1vjiKhkcJ6t0Tcj\ncEiYs7Q+2NVC+s+0ztrN1y4Ve8iX9K9D6o/09bD23zTKpftxCMv8NqoBicNVJ7O9\nsINcTqzrIPb+jawE47ogNvlorsU1hi1GTmDHtIqVJPQwiNCIWd8frBLf+WfCHCCK\nxRJb4hh5wR05v94L0/QdfKQ8qqCRG0VLyoGGcUyQgC8PLLlHRIWIYuwo3xhUK9nN\nGn8WNiACY4ry1wRauqIp54N3fM1a5sgzpgPKc8++KLVBpxhDy8nRoFAD0k6y1iM0\n2EoVLhbMvwhYwHOHkktp\n-----END CERTIFICATE-----\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_custom_certificate/certs/key.pem",
    "content": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDQaLWwoLZ3/cZd\niW/m4pqOe228wCx/CRU9/E2AT9NSofuJNtUaxw7QAAFEWIrnf9y3M09lZeox1CNm\nXe2GADnnx/n906zSGX18SdDmWrxaL/1t5OZiXl3we5PM3UNvbFPSq1MCnOtvo6jT\nPM7shIpJ/5/KuuqovyrO31VCnc2+ycEzJ2BOcKFUFAeyT/8bk9lAI+1971PLqC6u\nt9dfy8PVHSPyGrxGiQCpStU7NiQjLUkqte7x9GcIKTJUjMkWIsvGke9oGoGgEl5g\nEfqxFAs3ZkA1aYkiHhwFtrUkGOOfO1C6sqfwnnAhtG8LnULGlFYi3GoKALF2XSIa\ngGpaQM5HAgMBAAECggEAM2dIPRb+uozU8vg1qhCFR5RpBi+uKe0vGJlU8kt+F3kN\nhhQIrvCfFi2SIm3mYOAYK/WTZTKkd4LX8mVDcxQ2NBWOcw1VKIMSAOhiBpclsub4\nTrUxH90ftXN9in+epOpmqGUKdfAHYANRXjy22v5773GF06aTv2hbYigSqvoqJ57A\nPCdpw9q9sTwJqR9reU3f9fHsUyIwLCQpbtFyQc8aU9LHqgs4SAkaogY+4mPmlCrl\npQ5wGljTXmK5g1o/v+mu1WdeGNOzd5//xp0YImkGtyiqh8Ab891MI1wPgivNP5Lo\nRu1wKhegj89XamT/LUCtn6NCcokE/9pqEXrKK7JeVQKBgQD98kGUkdAm+zHjRZsr\nKTeQQ/wszFrNcbP9irE5MqnASWskIXcAhGVJrqbtinLPLIeT22BTsJkCUjVJdfX2\nMObjiJP0LMrMVpGQC0b+i4boS8W/lY5T4fM97B+ILc3Y1OYiUedg0gVsFspSR4ef\nluNfbKbmdzYYqFz6a/q5vExqBQKBgQDSGC2MJXYAewRJ9Mk3fNvll/6yz73rGCct\ntljwNXUgC7y2nEabDverPd74olSxojQwus/kA8JrMVa2IkXo+lKAwLV+nyj3PGHw\n3szTeAVWrGIRveWuW6IQ5zOP2IGkX5Jm+XSPVihnMz7SZA6k6qCtWVVywfBubSpi\n1dMNWAhs2wKBgBvMVw1yYLzDppRgXDn/SwvJxWMKA66VkcRhWEEQoLBh2Q6dcy9l\nTskgCznZe/PdxgGTdBn1LOqqIRcniIMomz2xB7Ek7hYsK8b+1QisMVpgYQc10dyw\n0TWoEVOQ4AWqWH7NRGy+0MUiQYd8OQZpN/6MIED+L7fHRlZLV6jZSewZAoGBAJwo\nbHJmxbbFuQJfd9BOdgPJXf76emdrpHNNvf2NPml7T+FLdw95qI0Xh8u2nM0Li09N\nC4inYrLaEWF/SAdLSFd65WwgUQqzTvkCIaxs4UrzBlG5nCZk5ak6sBCTFIlgoCj5\n8bE4kP9kD6XByUC7RIKUi/aoQFVTvtWHqT+Z12lRAoGAAVoZVxE+xPAfzVyAatpH\nM8WwgB23r07thNDiJCUMOQUT8LRFKg/Hyj6jB2W7gj669G/Bvoar++nXJVw7QCiv\nMlOk1pfaKuW82rCPnTeUzJwf2KQ8Jg2avasD4GFWZBJVvlHN1ONySViIpb67hhAK\n1OcbfGutFiGWhUwXNVkVc4U=\n-----END PRIVATE KEY-----\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_custom_certificate/config/deploy.yml",
    "content": "service: app_with_custom_certificate\nimage: app_with_custom_certificate\nservers:\n  web:\n    hosts:\n      - vm1\n      - vm2\n  workers:\n    hosts:\n      - vm3\n    cmd: sleep infinity\ndeploy_timeout: 2\ndrain_timeout: 2\nreadiness_delay: 0\n\nproxy:\n  host: localhost\n  ssl:\n    certificate_pem: CUSTOM_CERT\n    private_key_pem: CUSTOM_KEY\n  healthcheck:\n    interval: 1\n    timeout: 1\n    path: \"/up\"\n\nasset_path: /usr/share/nginx/html/versions\n\nregistry:\n  server: registry:4443\n  username: root\n  password: root\nbuilder:\n  driver: docker\n  arch: <%= Kamal::Utils.docker_arch %>\n  args:\n    COMMIT_SHA: <%= `git rev-parse HEAD` %>\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_custom_certificate/default.conf",
    "content": "server {\n    listen       80;\n    listen  [::]:80;\n    server_name  localhost;\n\n    location / {\n        root   /usr/share/nginx/html;\n        index  index.html index.htm;\n    }\n\n    # redirect server error pages to the static page /50x.html\n    #\n    error_page   500 502 503 504  /50x.html;\n    location = /50x.html {\n        root   /usr/share/nginx/html;\n    }\n}\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_destinations/Dockerfile",
    "content": "FROM registry:4443/nginx:1-alpine-slim\n\nCOPY default.conf /etc/nginx/conf.d/default.conf\n\nARG COMMIT_SHA\nRUN echo $COMMIT_SHA > /usr/share/nginx/html/version && \\\n    mkdir -p /usr/share/nginx/html/versions && \\\n    echo \"version\" > /usr/share/nginx/html/versions/$COMMIT_SHA && \\\n    echo \"hidden\" > /usr/share/nginx/html/versions/.hidden && \\\n    echo \"Up!\" > /usr/share/nginx/html/up\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_destinations/config/deploy.production.yml",
    "content": "servers:\n  - vm2\n  - vm3\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_destinations/config/deploy.staging.yml",
    "content": "servers:\n  - vm1\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_destinations/config/deploy.yml",
    "content": "service: app_with_destinations\nimage: app_with_destinations\nrequire_destination: true\ndeploy_timeout: 2\ndrain_timeout: 2\nreadiness_delay: 0\nproxy:\n  run:\n    registry: registry:4443\n  host: localhost\n  ssl: false\n  healthcheck:\n    interval: 1\n    timeout: 1\n    path: \"/up\"\n  response_timeout: 2\n  forward_headers: true\nregistry:\n  server: registry:4443\n  username: root\n  password: root\nbuilder:\n  driver: docker\n  arch: <%= Kamal::Utils.docker_arch %>\n  args:\n    COMMIT_SHA: <%= `git rev-parse HEAD` %>\naliases:\n  staging_deploy: deploy -d staging\n  production_deploy: deploy -d production\n  staging_config: config -d staging\n  production_config: config -d production\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_destinations/default.conf",
    "content": "server {\n    listen       80;\n    listen  [::]:80;\n    server_name  localhost;\n\n    location / {\n        root   /usr/share/nginx/html;\n        index  index.html index.htm;\n    }\n\n    # redirect server error pages to the static page /50x.html\n    #\n    error_page   500 502 503 504  /50x.html;\n    location = /50x.html {\n        root   /usr/share/nginx/html;\n    }\n}\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_parallel_roles/Dockerfile",
    "content": "FROM registry:4443/nginx:1-alpine-slim\n\nCOPY default.conf /etc/nginx/conf.d/default.conf\n\nARG COMMIT_SHA\nRUN echo $COMMIT_SHA > /usr/share/nginx/html/version && \\\n    mkdir -p /usr/share/nginx/html/versions && \\\n    echo \"version\" > /usr/share/nginx/html/versions/$COMMIT_SHA && \\\n    echo \"hidden\" > /usr/share/nginx/html/versions/.hidden && \\\n    echo \"Up!\" > /usr/share/nginx/html/up\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_parallel_roles/config/deploy.yml",
    "content": "service: app_with_parallel_roles\nimage: app_with_parallel_roles\nservers:\n  web:\n    hosts:\n      - vm1\n      - vm2\n  workers:\n    hosts:\n      - vm1\n    cmd: sleep infinity\ndeploy_timeout: 2\ndrain_timeout: 2\nreadiness_delay: 0\nboot:\n  parallel_roles: true\nproxy:\n  host: localhost\n  ssl: false\n  healthcheck:\n    interval: 1\n    timeout: 1\n    path: \"/up\"\n  response_timeout: 2\n  run:\n    registry: registry:4443\nregistry:\n  server: registry:4443\n  username: root\n  password: root\nbuilder:\n  driver: docker\n  arch: <%= Kamal::Utils.docker_arch %>\n  args:\n    COMMIT_SHA: <%= `git rev-parse HEAD` %>\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_parallel_roles/default.conf",
    "content": "server {\n    listen       80;\n    listen  [::]:80;\n    server_name  localhost;\n\n    location / {\n        root   /usr/share/nginx/html;\n        index  index.html index.htm;\n    }\n\n    # redirect server error pages to the static page /50x.html\n    #\n    error_page   500 502 503 504  /50x.html;\n    location = /50x.html {\n        root   /usr/share/nginx/html;\n    }\n}\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_parallel_roles/error_pages/503.html",
    "content": "<html>\n  <head>\n    <title>503 Service Interrupted</title>\n  </head>\n  <body>\n    <p>Custom Maintenance Page: {{ .Message }}</p>\n  </body>\n</html>\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_proxied_accessory/Dockerfile",
    "content": "FROM registry:4443/nginx:1-alpine-slim\n\nCOPY default.conf /etc/nginx/conf.d/default.conf\n\nARG COMMIT_SHA\nRUN echo $COMMIT_SHA > /usr/share/nginx/html/version && \\\n    mkdir -p /usr/share/nginx/html/versions && \\\n    echo \"version\" > /usr/share/nginx/html/versions/$COMMIT_SHA && \\\n    echo \"hidden\" > /usr/share/nginx/html/versions/.hidden && \\\n    echo \"Up!\" > /usr/share/nginx/html/up\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_proxied_accessory/config/deploy.yml",
    "content": "service: app_with_proxied_accessory\nimage: app_with_proxied_accessory\nenv:\n  clear:\n    CLEAR_TOKEN: 4321\n    CLEAR_TAG: \"\"\n    HOST_TOKEN: \"${HOST_TOKEN}\"\nasset_path: /usr/share/nginx/html/versions\nproxy:\n  host: 127.0.0.1\nregistry:\n  server: registry:4443\n  username: root\n  password: root\nbuilder:\n  driver: docker\n  arch: <%= Kamal::Utils.docker_arch %>\n  args:\n    COMMIT_SHA: <%= `git rev-parse HEAD` %>\naccessories:\n  busybox:\n    service: custom-busybox\n    image: registry:4443/busybox:1.36.0\n    cmd: sh -c 'echo \"Starting busybox...\"; trap exit term; while true; do sleep 1; done'\n    host: vm1\n  netcat:\n    service: netcat\n    image: registry:4443/busybox:1.36.0\n    cmd: >\n      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'\n    host: vm1\n    port: 12345:80\n    proxy:\n      run:\n        registry: registry:4443\n      host: netcat\n      ssl: false\n      healthcheck:\n        interval: 1\n        timeout: 1\n        path: \"/\"\ndrain_timeout: 2\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_proxied_accessory/default.conf",
    "content": "server {\n    listen       80;\n    listen  [::]:80;\n    server_name  localhost;\n\n    location / {\n        root   /usr/share/nginx/html;\n        index  index.html index.htm;\n    }\n\n    # redirect server error pages to the static page /50x.html\n    #\n    error_page   500 502 503 504  /50x.html;\n    location = /50x.html {\n        root   /usr/share/nginx/html;\n    }\n}\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_roles/.kamal/hooks/docker-setup",
    "content": "#!/bin/sh\necho \"Docker set up!\"\nmkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/docker-setup\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_roles/.kamal/hooks/post-deploy",
    "content": "#!/bin/sh\necho \"Deployed!\"\nmkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/post-deploy\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_roles/.kamal/hooks/post-proxy-reboot",
    "content": "#!/bin/sh\necho \"Rebooted kamal-proxy on ${KAMAL_HOSTS}\"\nmkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/post-proxy-reboot\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-build",
    "content": "#!/bin/sh\necho \"About to build and push...\"\nmkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-build\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-connect",
    "content": "#!/bin/sh\n\necho \"About to lock...\"\nmkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-connect\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-deploy",
    "content": "#!/bin/sh\nset -e\n\necho \"Deployed!\"\nmkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-deploy\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_roles/.kamal/hooks/pre-proxy-reboot",
    "content": "#!/bin/sh\necho \"Rebooting kamal-proxy on ${KAMAL_HOSTS}...\"\nmkdir -p /tmp/${TEST_ID} && touch /tmp/${TEST_ID}/pre-proxy-reboot\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_roles/.kamal/secrets",
    "content": "SECRET_TOKEN='1234 with \"中文\"'\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_roles/Dockerfile",
    "content": "FROM registry:4443/nginx:1-alpine-slim\n\nCOPY default.conf /etc/nginx/conf.d/default.conf\n\nARG COMMIT_SHA\nRUN echo $COMMIT_SHA > /usr/share/nginx/html/version && \\\n    mkdir -p /usr/share/nginx/html/versions && \\\n    echo \"version\" > /usr/share/nginx/html/versions/$COMMIT_SHA && \\\n    echo \"hidden\" > /usr/share/nginx/html/versions/.hidden && \\\n    echo \"Up!\" > /usr/share/nginx/html/up\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_roles/config/deploy.yml",
    "content": "service: app_with_roles\nimage: app_with_roles\nservers:\n  web:\n    hosts:\n      - vm1\n      - vm2\n  workers:\n    hosts:\n      - vm3\n    cmd: sleep infinity\ndeploy_timeout: 2\ndrain_timeout: 2\nreadiness_delay: 0\n\nproxy:\n  run:\n    registry: registry:4443\n  host: localhost\n  ssl: false\n  healthcheck:\n    interval: 1\n    timeout: 1\n    path: \"/up\"\n  response_timeout: 2\n  buffering:\n    requests: false\n    responses: false\n    memory: 400_000\n    max_request_body: 40_000_000\n    max_response_body: 40_000_000\n  forward_headers: true\n  logging:\n    request_headers:\n      - Cache-Control\n      - X-Forwarded-Proto\n    response_headers:\n      - X-Request-ID\n      - X-Request-Start\n\nasset_path: /usr/share/nginx/html/versions\nerror_pages_path: error_pages\n\nregistry:\n  server: registry:4443\n  username: root\n  password: root\nbuilder:\n  driver: docker\n  arch: <%= Kamal::Utils.docker_arch %>\n  args:\n    COMMIT_SHA: <%= `git rev-parse HEAD` %>\naccessories:\n  busybox:\n    service: custom-busybox\n    image: registry:4443/busybox:1.36.0\n    cmd: sh -c 'echo \"Starting busybox...\"; trap exit term; while true; do sleep 1; done'\n    roles:\n      - web\naliases:\n  whome: version\n  worker_hostname: app exec -r workers --reuse hostname\n  worker_hostname_quiet: app exec -r workers -q --reuse hostname\n  uname: server exec -p uname\n  uname_quiet: server exec -q -p uname\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_roles/default.conf",
    "content": "server {\n    listen       80;\n    listen  [::]:80;\n    server_name  localhost;\n\n    location / {\n        root   /usr/share/nginx/html;\n        index  index.html index.htm;\n    }\n\n    # redirect server error pages to the static page /50x.html\n    #\n    error_page   500 502 503 504  /50x.html;\n    location = /50x.html {\n        root   /usr/share/nginx/html;\n    }\n}\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_roles/error_pages/503.html",
    "content": "<html>\n  <head>\n    <title>503 Service Interrupted</title>\n  </head>\n  <body>\n    <p>Custom Maintenance Page: {{ .Message }}</p>\n  </body>\n</html>\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_traefik/.kamal/secrets",
    "content": "SECRET_TOKEN='1234 with \"中文\"'\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_traefik/Dockerfile",
    "content": "FROM registry:4443/nginx:1-alpine-slim\n\nCOPY default.conf /etc/nginx/conf.d/default.conf\n\nARG COMMIT_SHA\nRUN echo $COMMIT_SHA > /usr/share/nginx/html/version && \\\n    mkdir -p /usr/share/nginx/html/versions && \\\n    echo \"version\" > /usr/share/nginx/html/versions/$COMMIT_SHA && \\\n    echo \"hidden\" > /usr/share/nginx/html/versions/.hidden && \\\n    echo \"Up!\" > /usr/share/nginx/html/up\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_traefik/config/deploy.yml",
    "content": "service: app_with_traefik\nimage: app_with_traefik\nservers:\n  - vm1\n  - vm2\ndeploy_timeout: 2\ndrain_timeout: 2\nreadiness_delay: 0\n\nregistry:\n  server: registry:4443\n  username: root\n  password: root\nbuilder:\n  driver: docker\n  arch: <%= Kamal::Utils.docker_arch %>\n  args:\n    COMMIT_SHA: <%= `git rev-parse HEAD` %>\nproxy:\n  run:\n    registry: registry:4443\n    publish: false\n    options:\n      label:\n        - traefik.http.services.kamal_proxy.loadbalancer.server.scheme=http\n        - traefik.http.routers.kamal_proxy.rule=PathPrefix(`/`)\n      sysctl: net.ipv4.ip_local_port_range=10000 60999\naccessories:\n  traefik:\n    service: traefik\n    image: traefik:v2.10\n    port: 80\n    cmd: \"--providers.docker\"\n    options:\n      volume:\n        - \"/var/run/docker.sock:/var/run/docker.sock\"\n    roles:\n      - web\n"
  },
  {
    "path": "test/integration/docker/deployer/app_with_traefik/default.conf",
    "content": "server {\n    listen       80;\n    listen  [::]:80;\n    server_name  localhost;\n\n    location / {\n        root   /usr/share/nginx/html;\n        index  index.html index.htm;\n    }\n\n    # redirect server error pages to the static page /50x.html\n    #\n    error_page   500 502 503 504  /50x.html;\n    location = /50x.html {\n        root   /usr/share/nginx/html;\n    }\n}\n"
  },
  {
    "path": "test/integration/docker/deployer/boot.sh",
    "content": "#!/bin/bash\n\n# Use VFS storage driver locally to avoid overlayfs-on-overlayfs issues\n# Skip on GitHub Actions where the outer Docker is configured differently\nif [ -z \"$GITHUB_ACTIONS\" ]; then\n  mkdir -p /etc/docker\n  echo '{\"storage-driver\": \"vfs\"}' > /etc/docker/daemon.json\nfi\n\n# On hosts using nftables, Docker can't create netfilter rules from inside a container.\n# iptables-legacy uses an older kernel interface that doesn't have this limitation.\nupdate-alternatives --set iptables /usr/sbin/iptables-legacy 2>/dev/null || true\nupdate-alternatives --set ip6tables /usr/sbin/ip6tables-legacy 2>/dev/null || true\n\ndockerd --max-concurrent-downloads 1 &\n\nexec sleep infinity\n"
  },
  {
    "path": "test/integration/docker/deployer/break_app.sh",
    "content": "#!/bin/bash\n\ncd $1 && echo \"bad nginx config\" > default.conf && git commit -am 'Broken'\n"
  },
  {
    "path": "test/integration/docker/deployer/setup.sh",
    "content": "#!/bin/bash\n\ninstall_kamal() {\n  cd /kamal && gem build kamal.gemspec -o /tmp/kamal.gem && gem install /tmp/kamal.gem\n}\n\n# Push the images to a persistent volume on the registry container\n# This is to work around docker hub rate limits\npush_image_to_registry_4443() {\n  # Check if the image is in the registry without having to pull it\n  if ! stat /registry/docker/registry/v2/repositories/$1/_manifests/tags/$2/current/link > /dev/null; then\n    hub_tag=$1:$2\n    registry_4443_tag=registry:4443/$1:$2\n    docker pull $hub_tag\n    docker tag $hub_tag $registry_4443_tag\n    docker push $registry_4443_tag\n  fi\n}\n\ninstall_kamal\npush_image_to_registry_4443 nginx 1-alpine-slim\npush_image_to_registry_4443 busybox 1.36.0\npush_image_to_registry_4443 basecamp/kamal-proxy v0.9.2\n\n# .ssh is on a shared volume that persists between runs. Clean it up as the\n# churn of temporary vm IPs can eventually create conflicts.\nrm -f /root/.ssh/known_hosts\n"
  },
  {
    "path": "test/integration/docker/deployer/update_app_rev.sh",
    "content": "#!/bin/bash\n\ncd $1 && git commit -am 'Update rev' --amend\n"
  },
  {
    "path": "test/integration/docker/load_balancer/Dockerfile",
    "content": "FROM nginx:1-alpine-slim\n\nCOPY default.conf /etc/nginx/conf.d/default.conf\n\nHEALTHCHECK --interval=1s CMD pgrep nginx\n"
  },
  {
    "path": "test/integration/docker/load_balancer/default.conf",
    "content": "upstream loadbalancer {\n  server vm1:80;\n  server vm2:80;\n}\n\nserver {\n  listen 80;\n\n  location / {\n    proxy_pass http://loadbalancer;\n    proxy_set_header Host $host;\n\n    proxy_connect_timeout       10;\n    proxy_send_timeout          10;\n    proxy_read_timeout          10;\n    send_timeout                10;\n  }\n}\n"
  },
  {
    "path": "test/integration/docker/registry/Dockerfile",
    "content": "FROM registry:3\n\nCOPY boot.sh .\n\nRUN ln -s /shared/certs /certs\n\nHEALTHCHECK --interval=1s CMD pgrep registry\n\nENTRYPOINT [\"./boot.sh\"]\n"
  },
  {
    "path": "test/integration/docker/registry/boot.sh",
    "content": "#!/bin/sh\n\nwhile [ ! -f /certs/domain.crt ]; do sleep 1; done\n\nexec /entrypoint.sh /etc/distribution/config.yml\n"
  },
  {
    "path": "test/integration/docker/shared/.dockerignore",
    "content": "Dockerfile\n"
  },
  {
    "path": "test/integration/docker/shared/Dockerfile",
    "content": "FROM ubuntu:22.04\n\nWORKDIR /work\n\nRUN apt-get update --fix-missing && apt-get -y install openssh-client openssl\n\nCOPY . .\n\nRUN mkdir ssh && \\\n    ssh-keygen -t rsa -f ssh/id_rsa -N \"\" && \\\n    mkdir certs && \\\n    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\n\nHEALTHCHECK --interval=1s CMD pgrep sleep\n\nCMD [\"./boot.sh\"]\n"
  },
  {
    "path": "test/integration/docker/shared/boot.sh",
    "content": "#!/bin/bash\n\ncp -r * /shared\n\nexec sleep infinity\n"
  },
  {
    "path": "test/integration/docker/shared/registry-dns.conf",
    "content": "[dn]\nCN=registry\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:registry\nkeyUsage=digitalSignature\n"
  },
  {
    "path": "test/integration/docker/vm/Dockerfile",
    "content": "FROM ubuntu:22.04\n\nWORKDIR /work\n\nRUN apt-get update --fix-missing && apt-get -y install openssh-client openssh-server docker.io\n\nCOPY boot.sh .\n\nRUN mkdir /root/.ssh && \\\n    ln -s /shared/ssh/id_rsa.pub /root/.ssh/authorized_keys && \\\n    mkdir -p /etc/docker/certs.d/registry:4443 && \\\n    ln -s /shared/certs/domain.crt /etc/docker/certs.d/registry:4443/ca.crt && \\\n    echo \"HOST_TOKEN=abcd\" >> /etc/environment\n\nHEALTHCHECK --interval=1s CMD pgrep dockerd\n\nCMD [\"./boot.sh\"]\n"
  },
  {
    "path": "test/integration/docker/vm/boot.sh",
    "content": "#!/bin/bash\n\nwhile [ ! -f /root/.ssh/authorized_keys ]; do echo \"Waiting for ssh keys\"; sleep 1; done\n\nservice ssh restart\n\n# On hosts using nftables, Docker can't create netfilter rules from inside a container.\n# iptables-legacy uses an older kernel interface that doesn't have this limitation.\nupdate-alternatives --set iptables /usr/sbin/iptables-legacy 2>/dev/null || true\nupdate-alternatives --set ip6tables /usr/sbin/ip6tables-legacy 2>/dev/null || true\n\ndockerd --max-concurrent-downloads 1 &\n\nexec sleep infinity\n"
  },
  {
    "path": "test/integration/docker-compose.yml",
    "content": "name: \"kamal-test\"\n\nvolumes:\n  shared:\n  registry:\n  deployer_bundle:\n\nservices:\n  shared:\n    build:\n      context: docker/shared\n    volumes:\n      - shared:/shared\n\n  deployer:\n    privileged: true\n    build:\n      context: docker/deployer\n    environment:\n      - TEST_ID=${TEST_ID:-}\n    volumes:\n      - ../..:/kamal\n      - shared:/shared\n      - registry:/registry\n      - deployer_bundle:/usr/local/bundle/\n\n  registry:\n    build:\n      context: docker/registry\n    environment:\n      - REGISTRY_HTTP_ADDR=0.0.0.0:4443\n      - REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt\n      - REGISTRY_HTTP_TLS_KEY=/certs/domain.key\n    volumes:\n      - shared:/shared\n      - registry:/var/lib/registry/\n\n  vm1:\n    privileged: true\n    build:\n      context: docker/vm\n    volumes:\n      - shared:/shared\n    ports:\n      - \"22443:443\"\n\n  vm2:\n    privileged: true\n    build:\n      context: docker/vm\n    volumes:\n      - shared:/shared\n\n  vm3:\n    privileged: true\n    build:\n      context: docker/vm\n    volumes:\n      - shared:/shared\n\n  load_balancer:\n    build:\n      context: docker/load_balancer\n    ports:\n      - \"12345:80\"\n      - \"12443:443\"\n    depends_on:\n      - vm1\n      - vm2\n      - vm3\n"
  },
  {
    "path": "test/integration/integration_test.rb",
    "content": "require \"net/http\"\nrequire \"test_helper\"\n\nclass IntegrationTest < ActiveSupport::TestCase\n  setup do\n    ENV[\"TEST_ID\"] = SecureRandom.hex\n    docker_compose \"up --build -d\"\n    wait_for_healthy\n    setup_deployer\n    @app = \"app\"\n  end\n\n  teardown do\n    if !passed? && ENV[\"DEBUG_CONTAINER_LOGS\"]\n      [ :deployer, :vm1, :vm2, :shared, :load_balancer, :registry ].each do |container|\n        puts\n        puts \"Logs for #{container}:\"\n        docker_compose :logs, container\n      end\n    end\n    docker_compose \"down -t 1\"\n  end\n\n  private\n    def docker_compose(*commands, capture: false, raise_on_error: true)\n      command = \"TEST_ID=#{ENV[\"TEST_ID\"]} docker compose #{commands.join(\" \")}\"\n      succeeded = false\n      if capture || !ENV[\"DEBUG\"]\n        result = stdouted { stderred { succeeded = system(\"cd test/integration && #{command}\") } }\n      else\n        succeeded = system(\"cd test/integration && #{command}\")\n      end\n\n      raise \"Command `#{command}` failed with error code `#{$?}`, and output:\\n#{result}\" if !succeeded && raise_on_error\n      result\n    end\n\n    def deployer_exec(*commands, workdir: nil, **options)\n      workdir ||= \"/#{@app}\"\n      docker_compose(\"exec --workdir #{workdir} deployer #{commands.join(\" \")}\", **options)\n    end\n\n    def kamal(*commands, **options)\n      deployer_exec(:kamal, *commands, **options)\n    end\n\n    def assert_app_is_down\n      assert_app_error_code(\"502\")\n    end\n\n    def assert_app_in_maintenance(message: nil)\n      assert_app_error_code(\"503\", message: message)\n    end\n\n    def assert_app_not_found\n      assert_app_error_code(\"404\")\n    end\n\n    def assert_app_error_code(code, message: nil)\n      response = app_response\n      debug_response_code(response, code)\n      assert_equal code, response.code\n      assert_match message, response.body.strip if message\n    end\n\n    def assert_app_is_up(version: nil, app: @app, cert: nil)\n      response = app_response(app: app, cert: cert)\n      debug_response_code(response, \"200\")\n      assert_equal \"200\", response.code\n      assert_app_version(version, response) if version\n    end\n\n    def wait_for_app_to_be_up(timeout: 20, up_count: 3)\n      timeout_at = Time.now + timeout\n      up_times = 0\n      response = app_response\n      while up_times < up_count && timeout_at > Time.now\n        sleep 0.1\n        up_times += 1 if response.code == \"200\"\n        response = app_response\n      end\n      assert_equal up_times, up_count\n    end\n\n    def app_response(app: @app, cert: nil)\n      uri = cert ? URI.parse(\"https://#{app_host(app)}:22443/version\") : URI.parse(\"http://#{app_host(app)}:12345/version\")\n\n      if cert\n        https_response_with_cert(uri, cert)\n      else\n        Net::HTTP.get_response(uri)\n      end\n    end\n\n    def update_app_rev\n      deployer_exec \"./update_app_rev.sh #{@app}\", workdir: \"/\"\n      latest_app_version\n    end\n\n    def break_app\n      deployer_exec \"./break_app.sh #{@app}\", workdir: \"/\"\n      latest_app_version\n    end\n\n    def latest_app_version\n      deployer_exec(\"git rev-parse HEAD\", capture: true)\n    end\n\n    def assert_app_version(version, response)\n      assert_equal version, response.body.strip\n    end\n\n    def assert_hooks_ran(*hooks)\n      hooks.each do |hook|\n        file = \"/tmp/#{ENV[\"TEST_ID\"]}/#{hook}\"\n        assert_equal \"removed '#{file}'\", deployer_exec(\"rm -v #{file}\", capture: true).strip\n      end\n    end\n\n    def assert_200(response)\n      code = response.code\n      if code != \"200\"\n        puts \"Got response code #{code}, here are the proxy logs:\"\n        kamal :proxy, :logs\n        puts \"And here are the load balancer logs\"\n        docker_compose :logs, :load_balancer\n        puts \"Tried to get the response code again and got #{app_response.code}\"\n      end\n      assert_equal \"200\", code\n    end\n\n    def wait_for_healthy(timeout: 30)\n      timeout_at = Time.now + timeout\n      loop do\n        result = docker_compose(\"ps -a | tail -n +2 | grep -v '(healthy)' | wc -l\", capture: true)\n\n        break if result.split.last == \"0\" || result == \"0\"\n\n        if timeout_at < Time.now\n          docker_compose(\"ps -a | tail -n +2 | grep -v '(healthy)'\")\n          raise \"Container not healthy after #{timeout} seconds\" if timeout_at < Time.now\n        end\n        sleep 0.1\n      end\n    end\n\n    def setup_deployer\n      deployer_exec(\"./setup.sh\", workdir: \"/\") unless $DEPLOYER_SETUP\n      $DEPLOYER_SETUP = true\n    end\n\n    def debug_response_code(app_response, expected_code)\n      code = app_response.code\n      if code != expected_code\n        puts \"Got response code #{code}, here are the proxy logs:\"\n        kamal :proxy, :logs\n        puts \"And here are the load balancer logs\"\n        docker_compose :logs, :load_balancer\n        puts \"Tried to get the response code again and got #{app_response.code}\"\n      end\n    end\n\n    def assert_container_running(host:, name:)\n      assert container_running?(host: host, name: name)\n    end\n\n    def assert_container_not_running(host:, name:)\n      assert_not container_running?(host: host, name: name)\n    end\n\n    def container_running?(host:, name:)\n      docker_compose(\"exec #{host} docker ps --filter=name=#{name} | tail -n+2\", capture: true).strip.present?\n    end\n\n    def assert_app_directory_removed\n      assert_directory_removed(\"./kamal/apps/#{@app}\")\n    end\n\n    def assert_directory_removed(directory)\n      assert docker_compose(\"exec vm1 ls #{directory} | wc -l\", capture: true).strip == \"0\"\n    end\n\n    def assert_proxy_running\n      assert_container_running(host: \"vm1\", name: \"kamal-proxy\")\n    end\n\n    def assert_proxy_not_running\n      assert_container_not_running(host: \"vm1\", name: \"kamal-proxy\")\n    end\n\n    def app_host(app = @app)\n      case app\n      when \"app\"\n        \"127.0.0.1\"\n      else\n        \"localhost\"\n      end\n    end\n\n    def https_response_with_cert(uri, cert)\n      host = uri.host\n      port = uri.port\n\n      http = Net::HTTP.new(uri.host, uri.port)\n      http.use_ssl = true\n\n      store = OpenSSL::X509::Store.new\n      store.add_cert(OpenSSL::X509::Certificate.new(File.read(cert)))\n      http.cert_store = store\n\n      request = Net::HTTP::Get.new(uri)\n      http.request(request)\n    end\nend\n"
  },
  {
    "path": "test/integration/lock_test.rb",
    "content": "require_relative \"integration_test\"\n\nclass LockTest < IntegrationTest\n  test \"acquire, release, status\" do\n    kamal :lock, :acquire, \"-m 'Integration Tests'\"\n\n    status = kamal :lock, :status, capture: true\n    assert_match /Locked by: Deployer at .*\\nVersion: #{latest_app_version}\\nMessage: Integration Tests/m, status\n\n    error = kamal :deploy, capture: true, raise_on_error: false\n    assert_match /Deploy lock found. Run 'kamal lock help' for more information/m, error\n\n    kamal :lock, :release\n\n    status = kamal :lock, :status, capture: true\n    assert_match /There is no deploy lock/m, status\n  end\nend\n"
  },
  {
    "path": "test/integration/main_test.rb",
    "content": "require_relative \"integration_test\"\n\nclass MainTest < IntegrationTest\n  test \"deploy, redeploy, rollback, details and audit\" do\n    first_version = latest_app_version\n\n    assert_app_is_down\n\n    deploy_output = kamal :deploy, capture: true\n    assert_app_is_up version: first_version\n    assert_hooks_ran \"pre-connect\", \"pre-build\", \"pre-deploy\", \"pre-app-boot\", \"post-app-boot\", \"post-deploy\"\n    assert_hook_output deploy_output\n\n    assert_envs version: first_version\n\n    output = kamal :app, :exec, \"--verbose\", \"ls\", \"-r\", \"web\", capture: true\n    assert_hook_env_variables output, version: first_version\n\n    second_version = update_app_rev\n\n    kamal :redeploy\n    assert_app_is_up version: second_version\n    assert_hooks_ran \"pre-connect\", \"pre-build\", \"pre-deploy\", \"pre-app-boot\", \"post-app-boot\", \"post-deploy\"\n\n    assert_accumulated_assets first_version, second_version\n    assert_asset_volume_read_only second_version\n\n    kamal :rollback, first_version\n    assert_hooks_ran \"pre-connect\", \"pre-deploy\", \"pre-app-boot\", \"post-app-boot\", \"post-deploy\"\n    assert_app_is_up version: first_version\n\n    details = kamal :details, capture: true\n    assert_match /Proxy Host: vm1/, details\n    assert_match /Proxy Host: vm2/, details\n    assert_match /App Host: vm1/, details\n    assert_match /App Host: vm2/, details\n    assert_match /basecamp\\/kamal-proxy:#{Kamal::Configuration::Proxy::Run::MINIMUM_VERSION}/, details\n    assert_match /localhost:5000\\/app:#{first_version}/, details\n\n    audit = kamal :audit, capture: true\n    assert_match /Booted app version #{first_version}.*Booted app version #{second_version}.*Booted app version #{first_version}.*/m, audit\n  end\n\n  test \"app with roles\" do\n    @app = \"app_with_roles\"\n\n    version = latest_app_version\n\n    assert_app_is_down\n\n    kamal :deploy\n\n    assert_app_is_up version: version\n    assert_hooks_ran \"pre-connect\", \"pre-build\", \"pre-deploy\", \"post-deploy\"\n    assert_container_running host: :vm3, name: \"app_with_roles-workers-#{version}\"\n\n    second_version = update_app_rev\n\n    kamal :redeploy\n    assert_app_is_up version: second_version\n    assert_container_running host: :vm3, name: \"app_with_roles-workers-#{second_version}\"\n  end\n\n  test \"config\" do\n    config = YAML.load(kamal(:config, capture: true))\n    version = latest_app_version\n\n    assert_equal [ \"web\" ], config[:roles]\n    assert_equal [ \"vm1\", \"vm2\", \"vm3\" ], config[:hosts]\n    assert_equal \"vm1\", config[:primary_host]\n    assert_equal version, config[:version]\n    assert_equal \"localhost:5000/app\", config[:repository]\n    assert_equal \"localhost:5000/app:#{version}\", config[:absolute_image]\n    assert_equal \"app-#{version}\", config[:service_with_version]\n    assert_equal [], config[:volume_args]\n    assert_equal({ user: \"root\", port: 22, keepalive: true, keepalive_interval: 30, log_level: :fatal }, config[:ssh_options])\n    assert_equal({ \"driver\" => \"docker\", \"arch\" => \"#{Kamal::Utils.docker_arch}\", \"args\" => { \"COMMIT_SHA\" => version } }, config[:builder])\n    assert_equal [ \"--log-opt\", \"max-size=\\\"10m\\\"\" ], config[:logging]\n  end\n\n  test \"aliases\" do\n    @app = \"app_with_roles\"\n\n    kamal :deploy\n\n    output = kamal :whome, capture: true\n    assert_equal Kamal::VERSION, output\n\n    output = kamal :worker_hostname, capture: true\n    assert_match /App Host: vm3\\nvm3-[0-9a-f]{12}$/, output\n\n    output = kamal :worker_hostname_quiet, capture: true\n    assert_match /vm3-[0-9a-f]{12}$/, output\n\n    output = kamal :uname, \"-o\", capture: true\n    assert_match \"App Host: vm1\\nGNU/Linux\", output\n\n    output = kamal :uname_quiet, \"-o\", capture: true\n    assert_match \"GNU/Linux\", output\n  end\n\n  test \"deploy with destinations\" do\n    @app = \"app_with_destinations\"\n\n    kamal :staging_deploy\n    assert_app_is_up\n\n    config = YAML.load(kamal(:staging_config, capture: true))\n    assert_equal [ \"vm1\" ], config[:hosts]\n\n    config = YAML.load(kamal(:production_config, capture: true))\n    assert_equal [ \"vm2\", \"vm3\" ], config[:hosts]\n  end\n\n  test \"setup and remove\" do\n    kamal :proxy, :boot_config, \"set\",\n      \"--publish=false\",\n      \"--docker-options=label=traefik.http.services.kamal_proxy.loadbalancer.server.scheme=http\",\n      \"label=traefik.http.routers.kamal_proxy.rule=PathPrefix\\\\\\(\\\\\\`/\\\\\\`\\\\\\)\",\n      \"label=traefik.http.routers.kamal_proxy.priority=2\"\n\n    # Check remove completes when nothing has been setup yet\n    kamal :remove, \"-y\"\n    assert_no_images_or_containers\n\n    kamal :setup\n    assert_images_and_containers\n\n    kamal :remove, \"-y\"\n    assert_no_images_or_containers\n    assert_app_directory_removed\n  end\n\n  test \"two apps\" do\n    @app = \"app\"\n    kamal :deploy\n    app1_version = latest_app_version\n\n    @app = \"app_with_roles\"\n    kamal :deploy\n    app2_version = latest_app_version\n\n    assert_app_is_up version: app1_version, app: \"app\"\n    assert_app_is_up version: app2_version, app: \"app_with_roles\"\n\n    @app = \"app\"\n    kamal :remove, \"-y\"\n    assert_app_directory_removed\n    assert_proxy_running\n\n    @app = \"app_with_roles\"\n    kamal :remove, \"-y\"\n    assert_app_directory_removed\n    assert_proxy_not_running\n  end\n\n  test \"deploy with traefik\" do\n    @app = \"app_with_traefik\"\n\n    first_version = latest_app_version\n\n    kamal :setup\n    assert_app_is_up version: first_version\n  end\n\n  test \"deploy with a custom certificate\" do\n    @app = \"app_with_custom_certificate\"\n\n    first_version = latest_app_version\n\n    kamal :setup\n\n    assert_app_is_up version: first_version, cert: \"test/integration/docker/deployer/app_with_custom_certificate/certs/cert.pem\"\n  end\n\n  private\n    def assert_envs(version:)\n      assert_env :KAMAL_HOST, \"vm1\", version: version, vm: :vm1\n      assert_env :CLEAR_TOKEN, \"4321\", version: version, vm: :vm1\n      assert_env :HOST_TOKEN, \"abcd\", version: version, vm: :vm1\n      assert_env :SECRET_TOKEN, \"1234 with \\\"中文\\\"\", version: version, vm: :vm1\n      assert_no_env :CLEAR_TAG, version: version, vm: :vm1\n      assert_no_env :SECRET_TAG, version: version, vm: :vm1\n      assert_env :CLEAR_TAG, \"tagged\", version: version, vm: :vm2\n      assert_env :SECRET_TAG, \"TAGME\", version: version, vm: :vm2\n      assert_env :INTERPOLATED_SECRET1, \"1TERCES_DETALOPRETNI\", version: version, vm: :vm2\n      assert_env :INTERPOLATED_SECRET2, \"2TERCES_DETALOPRETNI\", version: version, vm: :vm2\n      assert_env :INTERPOLATED_SECRET3, \"文中_DETALOPRETNI\", version: version, vm: :vm2\n      assert_env :INTERPOLATED_SECRET4, \")(_DETALOPRETNI\", version: version, vm: :vm2\n    end\n\n    def assert_env(key, value, vm:, version:)\n      assert_equal \"#{key}=#{value}\", docker_compose(\"exec #{vm} docker exec #{@app}-web-#{version} env | grep #{key}\", capture: true)\n    end\n\n    def assert_no_env(key, vm:, version:)\n      assert_raises(RuntimeError, /exit 1/) do\n        docker_compose(\"exec #{vm} docker exec #{@app}-web-#{version} env | grep #{key}\", capture: true)\n      end\n    end\n\n    def assert_accumulated_assets(*versions)\n      versions.each do |version|\n        assert_equal \"200\", Net::HTTP.get_response(URI.parse(\"http://#{app_host}:12345/versions/#{version}\")).code\n      end\n\n      assert_equal \"200\", Net::HTTP.get_response(URI.parse(\"http://#{app_host}:12345/versions/.hidden\")).code\n    end\n\n    def assert_asset_volume_read_only(version)\n      mounts = docker_compose(\"exec vm1 docker inspect app-web-#{version} --format '{{json .Mounts}}'\", capture: true)\n      assert_match %r{/usr/share/nginx/html/versions.*\"RW\":false}, mounts, \"Expected asset volume to be mounted read-only (:ro)\"\n    end\n\n    def image_ids(vm:)\n      docker_compose(\"exec #{vm} docker image ls -q\", capture: true).strip.split(\"\\n\")\n    end\n\n    def container_ids(vm:)\n      docker_compose(\"exec #{vm} docker ps -a -q\", capture: true).strip.split(\"\\n\")\n    end\n\n    def assert_no_images_or_containers\n      [ :vm1, :vm2, :vm3 ].each do |vm|\n        assert image_ids(vm: vm).empty?\n        assert container_ids(vm: vm).empty?\n      end\n    end\n\n    def assert_images_and_containers\n      [ :vm1, :vm2, :vm3 ].each do |vm|\n        assert image_ids(vm: vm).any?\n        assert container_ids(vm: vm).any?\n      end\n    end\n\n    def assert_hook_env_variables(output, version:)\n      assert_match \"KAMAL_VERSION=#{version}\", output\n      assert_match \"KAMAL_SERVICE=app\", output\n      assert_match \"KAMAL_SERVICE_VERSION=app@#{version[0..6]}\", output\n      assert_match \"KAMAL_COMMAND=app\", output\n      assert_match \"KAMAL_PERFORMER=deployer@example.com\", output\n      assert_match /KAMAL_RECORDED_AT=\\d\\d\\d\\d-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\dZ/, output\n      assert_match \"KAMAL_HOSTS=vm1,vm2\", output\n      assert_match \"KAMAL_ROLES=web\", output\n    end\n\n    def assert_hook_output(output)\n      # pre-deploy hook (hooks_output: :verbose) shows everything\n      assert_match(/Running.*pre-deploy/, output)\n      assert_match(/Deployed!/, output)\n      # pre-build hook (hooks_output: :quiet) hides everything\n      assert_no_match(/Running.*pre-build/, output)\n      assert_no_match(/About to build and push/, output)\n      # post-deploy hook (no hooks_output setting) shows Running but hides output\n      assert_match(/Running.*post-deploy/, output)\n      assert_no_match(/Finished deploy!/, output)\n    end\nend\n"
  },
  {
    "path": "test/integration/proxy_test.rb",
    "content": "require_relative \"integration_test\"\n\nclass ProxyTest < IntegrationTest\n  setup do\n    @app = \"app_with_roles\"\n  end\n\n  test \"boot, reboot, stop, start, restart, logs, remove\" do\n    kamal :proxy, :boot\n    assert_proxy_running\n\n    output = kamal :proxy, :reboot, \"-y\", \"--verbose\", capture: true\n    assert_proxy_running\n    assert_hooks_ran \"pre-proxy-reboot\", \"post-proxy-reboot\"\n    assert_match /Rebooting kamal-proxy on vm1,vm2.../, output\n    assert_match /Rebooted kamal-proxy on vm1,vm2/, output\n\n    output = kamal :proxy, :reboot, \"--rolling\", \"-y\", \"--verbose\", capture: true\n    assert_proxy_running\n    assert_hooks_ran \"pre-proxy-reboot\", \"post-proxy-reboot\"\n    assert_match /Rebooting kamal-proxy on vm1.../, output\n    assert_match /Rebooted kamal-proxy on vm1/, output\n    assert_match /Rebooting kamal-proxy on vm2.../, output\n    assert_match /Rebooted kamal-proxy on vm2/, output\n\n    kamal :proxy, :boot\n    assert_proxy_running\n\n    # Check booting when booted doesn't raise an error\n    kamal :proxy, :stop\n    assert_proxy_not_running\n\n    # Check booting when stopped works\n    kamal :proxy, :boot\n    assert_proxy_running\n\n    kamal :proxy, :stop\n    assert_proxy_not_running\n\n    kamal :proxy, :start\n    assert_proxy_running\n\n    kamal :proxy, :restart\n    assert_proxy_running\n\n    logs = kamal :proxy, :logs, capture: true\n    assert_match /No previous state to restore/, logs\n\n    kamal :proxy, :remove\n    assert_proxy_not_running\n  end\n\n  private\n    def assert_docker_options_in_file\n      boot_config = kamal :proxy, :boot_config, :get, capture: true\n      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\n    end\n\n    def assert_docker_options_in_container\n      assert_equal \\\n        \"{\\\"net.ipv4.ip_local_port_range\\\":\\\"10000 60999\\\"}\",\n        docker_compose(\"exec vm1 docker inspect --format '{{ json .HostConfig.Sysctls }}' kamal-proxy\", capture: true).strip\n    end\nend\n"
  },
  {
    "path": "test/secrets/aws_secrets_manager_adapter_test.rb",
    "content": "require \"test_helper\"\n\nclass AwsSecretsManagerAdapterTest < SecretAdapterTestCase\n  test \"fails when errors are present\" do\n    stub_ticks.with(\"aws --version 2> /dev/null\")\n    stub_ticks\n      .with(\"aws secretsmanager batch-get-secret-value --secret-id-list unknown1 unknown2 --profile default --output json\")\n      .returns(<<~JSON)\n        {\n          \"SecretValues\": [],\n          \"Errors\": [\n            {\n                \"SecretId\": \"unknown1\",\n                \"ErrorCode\": \"ResourceNotFoundException\",\n                \"Message\": \"Secrets Manager can't find the specified secret.\"\n            },\n            {\n                \"SecretId\": \"unknown2\",\n                \"ErrorCode\": \"ResourceNotFoundException\",\n                \"Message\": \"Secrets Manager can't find the specified secret.\"\n            }\n          ]\n        }\n      JSON\n\n    error = assert_raises RuntimeError do\n      JSON.parse(run_command(\"fetch\", \"unknown1\", \"unknown2\"))\n    end\n\n    assert_equal [ \"unknown1: Secrets Manager can't find the specified secret.\", \"unknown2: Secrets Manager can't find the specified secret.\" ].join(\" \"), error.message\n  end\n\n  test \"fetch\" do\n    stub_ticks.with(\"aws --version 2> /dev/null\")\n    stub_ticks\n      .with(\"aws secretsmanager batch-get-secret-value --secret-id-list secret/KEY1 secret/KEY2 secret2/KEY3 --profile default --output json\")\n      .returns(<<~JSON)\n        {\n          \"SecretValues\": [\n            {\n              \"ARN\": \"arn:aws:secretsmanager:us-east-1:aaaaaaaaaaaa:secret:secret\",\n              \"Name\": \"secret\",\n              \"VersionId\": \"vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv\",\n              \"SecretString\": \"{\\\\\"KEY1\\\\\":\\\\\"VALUE1\\\\\", \\\\\"KEY2\\\\\":\\\\\"VALUE2\\\\\"}\",\n              \"VersionStages\": [\n                  \"AWSCURRENT\"\n              ],\n              \"CreatedDate\": \"2024-01-01T00:00:00.000000\"\n            },\n            {\n              \"ARN\": \"arn:aws:secretsmanager:us-east-1:aaaaaaaaaaaa:secret:secret2\",\n              \"Name\": \"secret2\",\n              \"VersionId\": \"vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv\",\n              \"SecretString\": \"{\\\\\"KEY3\\\\\":\\\\\"VALUE3\\\\\"}\",\n              \"VersionStages\": [\n                  \"AWSCURRENT\"\n              ],\n              \"CreatedDate\": \"2024-01-01T00:00:00.000000\"\n            }\n          ],\n          \"Errors\": []\n        }\n      JSON\n\n    json = JSON.parse(run_command(\"fetch\", \"secret/KEY1\", \"secret/KEY2\", \"secret2/KEY3\"))\n\n    expected_json = {\n      \"secret/KEY1\"=>\"VALUE1\",\n      \"secret/KEY2\"=>\"VALUE2\",\n      \"secret2/KEY3\"=>\"VALUE3\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch with string value\" do\n    stub_ticks.with(\"aws --version 2> /dev/null\")\n    stub_ticks\n      .with(\"aws secretsmanager batch-get-secret-value --secret-id-list secret secret2/KEY1 --profile default --output json\")\n      .returns(<<~JSON)\n        {\n          \"SecretValues\": [\n            {\n              \"ARN\": \"arn:aws:secretsmanager:us-east-1:aaaaaaaaaaaa:secret:secret\",\n              \"Name\": \"secret\",\n              \"VersionId\": \"vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv\",\n              \"SecretString\": \"a-string-secret\",\n              \"VersionStages\": [\n                  \"AWSCURRENT\"\n              ],\n              \"CreatedDate\": \"2024-01-01T00:00:00.000000\"\n            },\n            {\n              \"ARN\": \"arn:aws:secretsmanager:us-east-1:aaaaaaaaaaaa:secret:secret2\",\n              \"Name\": \"secret2\",\n              \"VersionId\": \"vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv\",\n              \"SecretString\": \"{\\\\\"KEY2\\\\\":\\\\\"VALUE2\\\\\"}\",\n              \"VersionStages\": [\n                  \"AWSCURRENT\"\n              ],\n              \"CreatedDate\": \"2024-01-01T00:00:00.000000\"\n            }\n          ],\n          \"Errors\": []\n        }\n      JSON\n\n    json = JSON.parse(run_command(\"fetch\", \"secret\", \"secret2/KEY1\"))\n\n    expected_json = {\n      \"secret\"=>\"a-string-secret\",\n      \"secret2/KEY2\"=>\"VALUE2\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch with secret names\" do\n    stub_ticks.with(\"aws --version 2> /dev/null\")\n    stub_ticks\n      .with(\"aws secretsmanager batch-get-secret-value --secret-id-list secret/KEY1 secret/KEY2 --profile default --output json\")\n      .returns(<<~JSON)\n        {\n          \"SecretValues\": [\n            {\n              \"ARN\": \"arn:aws:secretsmanager:us-east-1:aaaaaaaaaaaa:secret:secret\",\n              \"Name\": \"secret\",\n              \"VersionId\": \"vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv\",\n              \"SecretString\": \"{\\\\\"KEY1\\\\\":\\\\\"VALUE1\\\\\", \\\\\"KEY2\\\\\":\\\\\"VALUE2\\\\\"}\",\n              \"VersionStages\": [\n                  \"AWSCURRENT\"\n              ],\n              \"CreatedDate\": \"2024-01-01T00:00:00.000000\"\n            }\n          ],\n          \"Errors\": []\n        }\n      JSON\n\n    json = JSON.parse(run_command(\"fetch\", \"--from\", \"secret\", \"KEY1\", \"KEY2\"))\n\n    expected_json = {\n      \"secret/KEY1\"=>\"VALUE1\",\n      \"secret/KEY2\"=>\"VALUE2\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch without CLI installed\" do\n    stub_ticks_with(\"aws --version 2> /dev/null\", succeed: false)\n\n    error = assert_raises RuntimeError do\n      JSON.parse(run_command(\"fetch\", \"SECRET1\"))\n    end\n    assert_equal \"AWS CLI is not installed\", error.message\n  end\n\n  test \"fetch without account option omits --profile\" do\n    stub_ticks.with(\"aws --version 2> /dev/null\")\n    stub_ticks\n      .with(\"aws secretsmanager batch-get-secret-value --secret-id-list secret/KEY1 secret/KEY2 --output json\")\n      .returns(<<~JSON)\n        {\n          \"SecretValues\": [\n            {\n              \"ARN\": \"arn:aws:secretsmanager:us-east-1:aaaaaaaaaaaa:secret:secret\",\n              \"Name\": \"secret\",\n              \"VersionId\": \"vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv\",\n              \"SecretString\": \"{\\\\\"KEY1\\\\\":\\\\\"VALUE1\\\\\", \\\\\"KEY2\\\\\":\\\\\"VALUE2\\\\\"}\",\n              \"VersionStages\": [\n                  \"AWSCURRENT\"\n              ],\n              \"CreatedDate\": \"2024-01-01T00:00:00.000000\"\n            }\n          ],\n          \"Errors\": []\n        }\n      JSON\n\n    json = JSON.parse(run_command(\"fetch\", \"--from\", \"secret\", \"KEY1\", \"KEY2\", account: nil))\n\n    expected_json = {\n      \"secret/KEY1\"=>\"VALUE1\",\n      \"secret/KEY2\"=>\"VALUE2\"\n    }\n    assert_equal expected_json, json\n  end\n\n  private\n    def run_command(*command, account: \"default\")\n      stdouted do\n        args = [ *command,\n                \"-c\", \"test/fixtures/deploy_with_accessories.yml\",\n                \"--adapter\", \"aws_secrets_manager\" ]\n        args += [ \"--account\", account ] if account\n        Kamal::Cli::Secrets.start(args)\n      end\n    end\nend\n"
  },
  {
    "path": "test/secrets/bitwarden_adapter_test.rb",
    "content": "require \"test_helper\"\n\nclass BitwardenAdapterTest < SecretAdapterTestCase\n  test \"fetch\" do\n    stub_ticks.with(\"bw --version 2> /dev/null\")\n\n    stub_unlocked\n    stub_ticks.with(\"bw sync\").returns(\"\")\n    stub_mypassword\n\n    json = JSON.parse(run_command(\"fetch\", \"mypassword\"))\n\n    expected_json = { \"mypassword\"=>\"secret123\" }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch with no login\" do\n    stub_ticks.with(\"bw --version 2> /dev/null\")\n\n    stub_unlocked\n    stub_ticks.with(\"bw sync\").returns(\"\")\n    stub_noteitem\n\n    error = assert_raises RuntimeError do\n      JSON.parse(run_command(\"fetch\", \"mynote\"))\n    end\n    assert_match(/not a login type item/, error.message)\n  end\n\n  test \"fetch with from\" do\n    stub_ticks.with(\"bw --version 2> /dev/null\")\n\n    stub_unlocked\n    stub_ticks.with(\"bw sync\").returns(\"\")\n    stub_myitem\n\n    json = JSON.parse(run_command(\"fetch\", \"--from\", \"myitem\", \"field1\", \"field2\", \"field3\"))\n\n    expected_json = {\n      \"myitem/field1\"=>\"secret1\", \"myitem/field2\"=>\"blam\", \"myitem/field3\"=>\"fewgrwjgk\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch all with from\" do\n    stub_ticks.with(\"bw --version 2> /dev/null\")\n\n    stub_unlocked\n    stub_ticks.with(\"bw sync\").returns(\"\")\n    stub_noteitem_with_fields\n\n    json = JSON.parse(run_command(\"fetch\", \"mynotefields\"))\n\n    expected_json = {\n      \"mynotefields/field1\"=>\"secret1\", \"mynotefields/field2\"=>\"blam\", \"mynotefields/field3\"=>\"fewgrwjgk\",\n      \"mynotefields/field4\"=>\"auto\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch with multiple items\" do\n    stub_ticks.with(\"bw --version 2> /dev/null\")\n\n    stub_unlocked\n\n    stub_ticks.with(\"bw sync\").returns(\"\")\n    stub_mypassword\n    stub_myitem\n\n    stub_ticks\n    .with(\"bw get item myitem2\")\n    .returns(<<~JSON)\n      {\n        \"passwordHistory\":null,\n        \"revisionDate\":\"2024-08-29T13:46:53.343Z\",\n        \"creationDate\":\"2024-08-29T12:02:31.156Z\",\n        \"deletedDate\":null,\n        \"object\":\"item\",\n        \"id\":\"aaaaaaaa-cccc-eeee-0000-222222222222\",\n        \"organizationId\":null,\n        \"folderId\":null,\n        \"type\":1,\n        \"reprompt\":0,\n        \"name\":\"myitem2\",\n        \"notes\":null,\n        \"favorite\":false,\n        \"fields\":[\n          {\"name\":\"field3\",\"value\":\"fewgrwjgk\",\"type\":1,\"linkedId\":null}\n        ],\n        \"login\":{\"fido2Credentials\":[],\"uris\":[],\"username\":null,\"password\":null,\"totp\":null,\"passwordRevisionDate\":null},\"collectionIds\":[]\n      }\n    JSON\n\n\n    json = JSON.parse(run_command(\"fetch\", \"mypassword\", \"myitem/field1\", \"myitem/field2\", \"myitem2/field3\"))\n\n    expected_json = {\n      \"mypassword\"=>\"secret123\", \"myitem/field1\"=>\"secret1\", \"myitem/field2\"=>\"blam\", \"myitem2/field3\"=>\"fewgrwjgk\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch unauthenticated\" do\n    stub_ticks.with(\"bw --version 2> /dev/null\")\n\n    stub_ticks\n      .with(\"bw status\")\n      .returns(\n        '{\"serverUrl\":null,\"lastSync\":null,\"status\":\"unauthenticated\"}',\n        '{\"serverUrl\":null,\"lastSync\":\"2024-09-04T10:11:12.433Z\",\"userEmail\":\"email@example.com\",\"userId\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",\"status\":\"locked\"}',\n        '{\"serverUrl\":null,\"lastSync\":\"2024-09-04T10:11:12.433Z\",\"userEmail\":\"email@example.com\",\"userId\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",\"status\":\"unlocked\"}'\n      )\n\n    stub_ticks.with(\"bw login email@example.com\").returns(\"1234567890\")\n    stub_ticks.with(\"bw unlock --raw\").returns(\"\")\n    stub_ticks.with(\"bw sync\").returns(\"\")\n    stub_mypassword\n\n    json = JSON.parse(run_command(\"fetch\", \"mypassword\"))\n\n    expected_json = { \"mypassword\"=>\"secret123\" }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch locked\" do\n    stub_ticks.with(\"bw --version 2> /dev/null\")\n\n    stub_ticks\n      .with(\"bw status\")\n      .returns(\n        '{\"serverUrl\":null,\"lastSync\":\"2024-09-04T10:11:12.433Z\",\"userEmail\":\"email@example.com\",\"userId\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",\"status\":\"locked\"}'\n      )\n\n    stub_ticks\n      .with(\"bw status\")\n      .returns(\n        '{\"serverUrl\":null,\"lastSync\":\"2024-09-04T10:11:12.433Z\",\"userEmail\":\"email@example.com\",\"userId\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",\"status\":\"unlocked\"}'\n      )\n\n    stub_ticks.with(\"bw login email@example.com\").returns(\"1234567890\")\n    stub_ticks.with(\"bw unlock --raw\").returns(\"\")\n    stub_ticks.with(\"bw sync\").returns(\"\")\n    stub_mypassword\n\n    json = JSON.parse(run_command(\"fetch\", \"mypassword\"))\n\n    expected_json = { \"mypassword\"=>\"secret123\" }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch locked with session\" do\n    stub_ticks.with(\"bw --version 2> /dev/null\")\n\n    stub_ticks\n      .with(\"bw status\")\n      .returns(\n        '{\"serverUrl\":null,\"lastSync\":\"2024-09-04T10:11:12.433Z\",\"userEmail\":\"email@example.com\",\"userId\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",\"status\":\"locked\"}'\n      )\n\n    stub_ticks\n      .with(\"BW_SESSION=0987654321 bw status\")\n      .returns(\n        '{\"serverUrl\":null,\"lastSync\":\"2024-09-04T10:11:12.433Z\",\"userEmail\":\"email@example.com\",\"userId\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",\"status\":\"unlocked\"}'\n      )\n\n    stub_ticks.with(\"bw login email@example.com\").returns(\"1234567890\")\n    stub_ticks.with(\"bw unlock --raw\").returns(\"0987654321\")\n    stub_ticks.with(\"BW_SESSION=0987654321 bw sync\").returns(\"\")\n    stub_mypassword(session: \"0987654321\")\n\n    json = JSON.parse(run_command(\"fetch\", \"mypassword\"))\n\n    expected_json = { \"mypassword\"=>\"secret123\" }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch without CLI installed\" do\n    stub_ticks_with(\"bw --version 2> /dev/null\", succeed: false)\n\n    error = assert_raises RuntimeError do\n      JSON.parse(run_command(\"fetch\", \"mynote\"))\n    end\n    assert_equal \"Bitwarden CLI is not installed\", error.message\n  end\n\n  private\n    def run_command(*command)\n      stdouted do\n        Kamal::Cli::Secrets.start \\\n          [ *command,\n            \"-c\", \"test/fixtures/deploy_with_accessories.yml\",\n            \"--adapter\", \"bitwarden\",\n            \"--account\", \"email@example.com\" ]\n      end\n    end\n\n    def stub_unlocked\n      stub_ticks\n        .with(\"bw status\")\n        .returns(<<~JSON)\n          {\"serverUrl\":null,\"lastSync\":\"2024-09-04T10:11:12.433Z\",\"userEmail\":\"email@example.com\",\"userId\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",\"status\":\"unlocked\"}\n        JSON\n    end\n\n    def stub_mypassword(session: nil)\n      stub_ticks\n        .with(\"#{\"BW_SESSION=#{session} \" if session}bw get item mypassword\")\n        .returns(<<~JSON)\n          {\n            \"passwordHistory\":null,\n            \"revisionDate\":\"2024-08-29T13:46:53.343Z\",\n            \"creationDate\":\"2024-08-29T12:02:31.156Z\",\n            \"deletedDate\":null,\n            \"object\":\"item\",\n            \"id\":\"aaaaaaaa-cccc-eeee-0000-222222222222\",\n            \"organizationId\":null,\n            \"folderId\":null,\n            \"type\":1,\n            \"reprompt\":0,\n            \"name\":\"mypassword\",\n            \"notes\":null,\n            \"favorite\":false,\n            \"login\":{\"fido2Credentials\":[],\"uris\":[],\"username\":null,\"password\":\"secret123\",\"totp\":null,\"passwordRevisionDate\":null},\"collectionIds\":[]\n          }\n        JSON\n    end\n\n  def stub_noteitem(session: nil)\n    stub_ticks\n      .with(\"#{\"BW_SESSION=#{session} \" if session}bw get item mynote\")\n      .returns(<<~JSON)\n          {\n            \"passwordHistory\":null,\n            \"revisionDate\":\"2024-09-28T09:07:27.461Z\",\n            \"creationDate\":\"2024-09-28T09:07:00.740Z\",\n            \"deletedDate\":null,\n            \"object\":\"item\",\n            \"id\":\"aaaaaaaa-cccc-eeee-0000-222222222222\",\n            \"organizationId\":null,\n            \"folderId\":null,\n            \"type\":2,\n            \"reprompt\":0,\n            \"name\":\"noteitem\",\n            \"notes\":\"NOTES\",\n            \"favorite\":false,\n            \"secureNote\":{\"type\":0},\n            \"collectionIds\":[]\n          }\n        JSON\n      end\n\n      def stub_noteitem_with_fields(session: nil)\n      stub_ticks\n        .with(\"#{\"BW_SESSION=#{session} \" if session}bw get item mynotefields\")\n        .returns(<<~JSON)\n            {\n              \"passwordHistory\":null,\n              \"revisionDate\":\"2024-09-28T09:07:27.461Z\",\n              \"creationDate\":\"2024-09-28T09:07:00.740Z\",\n              \"deletedDate\":null,\n              \"object\":\"item\",\n              \"id\":\"aaaaaaaa-cccc-eeee-0000-222222222222\",\n              \"organizationId\":null,\n              \"folderId\":null,\n              \"type\":2,\n              \"reprompt\":0,\n              \"name\":\"noteitem\",\n              \"notes\":\"NOTES\",\n              \"favorite\":false,\n              \"fields\":[\n                {\"name\":\"field1\",\"value\":\"secret1\",\"type\":1,\"linkedId\":null},\n                {\"name\":\"field2\",\"value\":\"blam\",\"type\":1,\"linkedId\":null},\n                {\"name\":\"field3\",\"value\":\"fewgrwjgk\",\"type\":1,\"linkedId\":null},\n                {\"name\":\"field4\",\"value\":\"auto\",\"type\":1,\"linkedId\":null}\n              ],\n              \"secureNote\":{\"type\":0},\n              \"collectionIds\":[]\n            }\n          JSON\n      end\n\n    def stub_myitem\n      stub_ticks\n        .with(\"bw get item myitem\")\n        .returns(<<~JSON)\n          {\n            \"passwordHistory\":null,\n            \"revisionDate\":\"2024-08-29T13:46:53.343Z\",\n            \"creationDate\":\"2024-08-29T12:02:31.156Z\",\n            \"deletedDate\":null,\n            \"object\":\"item\",\n            \"id\":\"aaaaaaaa-cccc-eeee-0000-222222222222\",\n            \"organizationId\":null,\n            \"folderId\":null,\n            \"type\":1,\n            \"reprompt\":0,\n            \"name\":\"myitem\",\n            \"notes\":null,\n            \"favorite\":false,\n            \"fields\":[\n              {\"name\":\"field1\",\"value\":\"secret1\",\"type\":1,\"linkedId\":null},\n              {\"name\":\"field2\",\"value\":\"blam\",\"type\":1,\"linkedId\":null},\n              {\"name\":\"field3\",\"value\":\"fewgrwjgk\",\"type\":1,\"linkedId\":null},\n              {\"name\":\"field4\",\"value\":\"auto\",\"type\":1,\"linkedId\":null}\n            ],\n            \"login\":{\"fido2Credentials\":[],\"uris\":[],\"username\":null,\"password\":null,\"totp\":null,\"passwordRevisionDate\":null},\"collectionIds\":[]\n          }\n        JSON\n    end\nend\n"
  },
  {
    "path": "test/secrets/bitwarden_secrets_manager_adapter_test.rb",
    "content": "require \"test_helper\"\n\nclass BitwardenSecretsManagerAdapterTest < SecretAdapterTestCase\n  test \"fetch with no parameters\" do\n    stub_ticks.with(\"bws --version 2> /dev/null\")\n    stub_login\n\n    error = assert_raises RuntimeError do\n      run_command(\"fetch\")\n    end\n    assert_equal(\"You must specify what to retrieve from Bitwarden Secrets Manager\", error.message)\n  end\n\n  test \"fetch all\" do\n    stub_ticks.with(\"bws --version 2> /dev/null\")\n    stub_login\n    stub_ticks\n      .with(\"bws secret list\")\n      .returns(<<~JSON)\n      [\n        {\n          \"key\": \"KAMAL_REGISTRY_PASSWORD\",\n          \"value\": \"some_password\"\n        },\n        {\n          \"key\": \"MY_OTHER_SECRET\",\n          \"value\": \"my=wierd\\\\\"secret\"\n        }\n      ]\n      JSON\n\n    json = JSON.parse(run_command(\"fetch\", \"all\"))\n\n    expected_json = {\n      \"KAMAL_REGISTRY_PASSWORD\"=>\"some_password\",\n      \"MY_OTHER_SECRET\"=>\"my=wierd\\\"secret\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch all with from\" do\n    stub_ticks.with(\"bws --version 2> /dev/null\")\n    stub_login\n    stub_ticks\n      .with(\"bws secret list 82aeb5bd-6958-4a89-8197-eacab758acce\")\n      .returns(<<~JSON)\n      [\n        {\n          \"key\": \"KAMAL_REGISTRY_PASSWORD\",\n          \"value\": \"some_password\"\n        },\n        {\n          \"key\": \"MY_OTHER_SECRET\",\n          \"value\": \"my=wierd\\\\\"secret\"\n        }\n      ]\n      JSON\n\n    json = JSON.parse(run_command(\"fetch\", \"all\", \"--from\", \"82aeb5bd-6958-4a89-8197-eacab758acce\"))\n\n    expected_json = {\n      \"KAMAL_REGISTRY_PASSWORD\"=>\"some_password\",\n      \"MY_OTHER_SECRET\"=>\"my=wierd\\\"secret\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch item\" do\n    stub_ticks.with(\"bws --version 2> /dev/null\")\n    stub_login\n    stub_ticks\n      .with(\"bws secret get 82aeb5bd-6958-4a89-8197-eacab758acce\")\n      .returns(<<~JSON)\n      {\n        \"key\": \"KAMAL_REGISTRY_PASSWORD\",\n        \"value\": \"some_password\"\n      }\n      JSON\n\n    json = JSON.parse(run_command(\"fetch\", \"82aeb5bd-6958-4a89-8197-eacab758acce\"))\n    expected_json = {\n      \"KAMAL_REGISTRY_PASSWORD\"=>\"some_password\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch with multiple items\" do\n    stub_ticks.with(\"bws --version 2> /dev/null\")\n    stub_login\n    stub_ticks\n      .with(\"bws secret get 82aeb5bd-6958-4a89-8197-eacab758acce\")\n      .returns(<<~JSON)\n      {\n        \"key\": \"KAMAL_REGISTRY_PASSWORD\",\n        \"value\": \"some_password\"\n      }\n      JSON\n    stub_ticks\n      .with(\"bws secret get 6f8cdf27-de2b-4c77-a35d-07df8050e332\")\n      .returns(<<~JSON)\n      {\n        \"key\": \"MY_OTHER_SECRET\",\n        \"value\": \"my=wierd\\\\\"secret\"\n      }\n      JSON\n\n    json = JSON.parse(run_command(\"fetch\", \"82aeb5bd-6958-4a89-8197-eacab758acce\", \"6f8cdf27-de2b-4c77-a35d-07df8050e332\"))\n    expected_json = {\n      \"KAMAL_REGISTRY_PASSWORD\"=>\"some_password\",\n      \"MY_OTHER_SECRET\"=>\"my=wierd\\\"secret\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch all empty\" do\n    stub_ticks.with(\"bws --version 2> /dev/null\")\n    stub_login\n    stub_ticks_with(\"bws secret list\", succeed: false).returns(\"Error:\\n0: Received error message from server\")\n\n    error = assert_raises RuntimeError do\n      (run_command(\"fetch\", \"all\"))\n    end\n    assert_equal(\"Could not read secrets from Bitwarden Secrets Manager\", error.message)\n  end\n\n  test \"fetch nonexistent item\" do\n    stub_ticks.with(\"bws --version 2> /dev/null\")\n    stub_login\n    stub_ticks_with(\"bws secret get 82aeb5bd-6958-4a89-8197-eacab758acce\", succeed: false)\n      .returns(\"Error:\\n0: Received error message from server\")\n\n    error = assert_raises RuntimeError do\n      (run_command(\"fetch\", \"82aeb5bd-6958-4a89-8197-eacab758acce\"))\n    end\n    assert_equal(\"Could not read 82aeb5bd-6958-4a89-8197-eacab758acce from Bitwarden Secrets Manager\", error.message)\n  end\n\n  test \"fetch item with linebreak in value\" do\n    stub_ticks.with(\"bws --version 2> /dev/null\")\n    stub_login\n    stub_ticks\n      .with(\"bws secret get 82aeb5bd-6958-4a89-8197-eacab758acce\")\n      .returns(<<~JSON)\n      {\n        \"key\": \"SSH_PRIVATE_KEY\",\n        \"value\": \"some_key\\\\nwith_linebreak\"\n      }\n      JSON\n\n    json = JSON.parse(run_command(\"fetch\", \"82aeb5bd-6958-4a89-8197-eacab758acce\"))\n    expected_json = {\n      \"SSH_PRIVATE_KEY\"=>\"some_key\\nwith_linebreak\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch with no access token\" do\n    stub_ticks.with(\"bws --version 2> /dev/null\")\n    stub_ticks_with(\"bws project list\", succeed: false)\n\n    error = assert_raises RuntimeError do\n      (run_command(\"fetch\", \"all\"))\n    end\n    assert_equal(\"Could not authenticate to Bitwarden Secrets Manager. Did you set a valid access token?\", error.message)\n  end\n\n  test \"fetch without CLI installed\" do\n    stub_ticks_with(\"bws --version 2> /dev/null\", succeed: false)\n\n    error = assert_raises RuntimeError do\n      run_command(\"fetch\")\n    end\n    assert_equal \"Bitwarden Secrets Manager CLI is not installed\", error.message\n  end\n\n  private\n    def stub_login\n      stub_ticks.with(\"bws project list\").returns(\"OK\")\n    end\n\n    def run_command(*command)\n      stdouted do\n        Kamal::Cli::Secrets.start \\\n          [ *command,\n            \"--adapter\", \"bitwarden-sm\" ]\n      end\n    end\nend\n"
  },
  {
    "path": "test/secrets/doppler_adapter_test.rb",
    "content": "require \"test_helper\"\n\nclass DopplerAdapterTest < SecretAdapterTestCase\n  setup do\n    `true` # Ensure $? is 0\n  end\n\n  test \"fetch\" do\n    stub_ticks_with(\"doppler --version 2> /dev/null\", succeed: true)\n    stub_ticks.with(\"doppler me --json 2> /dev/null\")\n\n    stub_ticks\n      .with(\"doppler secrets get SECRET1 FSECRET1 FSECRET2 --json -p my-project -c prd\")\n      .returns(<<~JSON)\n        {\n          \"SECRET1\": {\n            \"computed\":\"secret1\",\n            \"computedVisibility\":\"unmasked\",\n            \"note\":\"\"\n          },\n          \"FSECRET1\": {\n            \"computed\":\"fsecret1\",\n            \"computedVisibility\":\"unmasked\",\n            \"note\":\"\"\n          },\n          \"FSECRET2\": {\n            \"computed\":\"fsecret2\",\n            \"computedVisibility\":\"unmasked\",\n            \"note\":\"\"\n          }\n        }\n      JSON\n\n    json = JSON.parse(\n      run_command(\"fetch\", \"--from\", \"my-project/prd\", \"SECRET1\", \"FSECRET1\", \"FSECRET2\")\n    )\n\n    expected_json = {\n      \"SECRET1\"=>\"secret1\",\n      \"FSECRET1\"=>\"fsecret1\",\n      \"FSECRET2\"=>\"fsecret2\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch having DOPPLER_TOKEN\" do\n    ENV[\"DOPPLER_TOKEN\"] = \"dp.st.xxxxxxxxxxxxxxxxxxxxxx\"\n\n    stub_ticks_with(\"doppler --version 2> /dev/null\", succeed: true)\n    stub_ticks.with(\"doppler me --json 2> /dev/null\")\n\n    stub_ticks\n      .with(\"doppler secrets get SECRET1 FSECRET1 FSECRET2 --json \")\n      .returns(<<~JSON)\n        {\n          \"SECRET1\": {\n            \"computed\":\"secret1\",\n            \"computedVisibility\":\"unmasked\",\n            \"note\":\"\"\n          },\n          \"FSECRET1\": {\n            \"computed\":\"fsecret1\",\n            \"computedVisibility\":\"unmasked\",\n            \"note\":\"\"\n          },\n          \"FSECRET2\": {\n            \"computed\":\"fsecret2\",\n            \"computedVisibility\":\"unmasked\",\n            \"note\":\"\"\n          }\n        }\n      JSON\n\n    json = JSON.parse(\n      run_command(\"fetch\", \"SECRET1\", \"FSECRET1\", \"FSECRET2\")\n    )\n\n    expected_json = {\n      \"SECRET1\"=>\"secret1\",\n      \"FSECRET1\"=>\"fsecret1\",\n      \"FSECRET2\"=>\"fsecret2\"\n    }\n\n    assert_equal expected_json, json\n\n    ENV.delete(\"DOPPLER_TOKEN\")\n  end\n\n  test \"fetch with folder in secret\" do\n    stub_ticks_with(\"doppler --version 2> /dev/null\", succeed: true)\n    stub_ticks.with(\"doppler me --json 2> /dev/null\")\n\n    stub_ticks\n      .with(\"doppler secrets get SECRET1 FSECRET1 FSECRET2 --json -p my-project -c prd\")\n      .returns(<<~JSON)\n        {\n          \"SECRET1\": {\n            \"computed\":\"secret1\",\n            \"computedVisibility\":\"unmasked\",\n            \"note\":\"\"\n          },\n          \"FSECRET1\": {\n            \"computed\":\"fsecret1\",\n            \"computedVisibility\":\"unmasked\",\n            \"note\":\"\"\n          },\n          \"FSECRET2\": {\n            \"computed\":\"fsecret2\",\n            \"computedVisibility\":\"unmasked\",\n            \"note\":\"\"\n          }\n        }\n      JSON\n\n    json = JSON.parse(\n      run_command(\"fetch\", \"my-project/prd/SECRET1\", \"my-project/prd/FSECRET1\", \"my-project/prd/FSECRET2\")\n    )\n\n    expected_json = {\n      \"SECRET1\"=>\"secret1\",\n      \"FSECRET1\"=>\"fsecret1\",\n      \"FSECRET2\"=>\"fsecret2\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch without --from\" do\n    stub_ticks_with(\"doppler --version 2> /dev/null\", succeed: true)\n    stub_ticks.with(\"doppler me --json 2> /dev/null\")\n\n    error = assert_raises RuntimeError do\n      run_command(\"fetch\", \"FSECRET1\", \"FSECRET2\")\n    end\n\n    assert_equal \"Missing project or config from '--from=project/config' option\", error.message\n  end\n\n  test \"fetch with signin\" do\n    stub_ticks_with(\"doppler --version 2> /dev/null\", succeed: true)\n    stub_ticks_with(\"doppler me --json 2> /dev/null\", succeed: false)\n    stub_ticks_with(\"doppler login -y\", succeed: true).returns(\"\")\n    stub_ticks.with(\"doppler secrets get SECRET1 --json -p my-project -c prd\").returns(single_item_json)\n\n    json = JSON.parse(run_command(\"fetch\", \"--from\", \"my-project/prd\", \"SECRET1\"))\n\n    expected_json = {\n      \"SECRET1\"=>\"secret1\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch without CLI installed\" do\n    stub_ticks_with(\"doppler --version 2> /dev/null\", succeed: false)\n\n    error = assert_raises RuntimeError do\n      JSON.parse(run_command(\"fetch\", \"HOST\", \"PORT\"))\n    end\n\n    assert_equal \"Doppler CLI is not installed\", error.message\n  end\n\n  private\n    def run_command(*command)\n      stdouted do\n        Kamal::Cli::Secrets.start \\\n          [ *command,\n            \"-c\", \"test/fixtures/deploy_with_accessories.yml\",\n            \"--adapter\", \"doppler\" ]\n      end\n    end\n\n    def single_item_json\n      <<~JSON\n        {\n          \"SECRET1\": {\n            \"computed\":\"secret1\",\n            \"computedVisibility\":\"unmasked\",\n            \"note\":\"\"\n          }\n        }\n      JSON\n    end\nend\n"
  },
  {
    "path": "test/secrets/dotenv_inline_command_substitution_test.rb",
    "content": "require \"test_helper\"\n\nclass SecretsInlineCommandSubstitution < SecretAdapterTestCase\n  test \"inlines kamal secrets commands\" do\n    Kamal::Cli::Main.expects(:start).with { |command| command == [ \"secrets\", \"fetch\", \"...\", \"--inline\" ] }.returns(\"results\")\n    substituted = Kamal::Secrets::Dotenv::InlineCommandSubstitution.call(\"FOO=$(kamal secrets fetch ...)\", nil, overwrite: false)\n    assert_equal \"FOO=results\", substituted\n  end\n\n  test \"executes other commands\" do\n    Kamal::Secrets::Dotenv::InlineCommandSubstitution.stubs(:`).with(\"blah\").returns(\"results\")\n    substituted = Kamal::Secrets::Dotenv::InlineCommandSubstitution.call(\"FOO=$(blah)\", nil, overwrite: false)\n    assert_equal \"FOO=results\", substituted\n  end\n\n  test \"handles escaped parentheses in command arguments\" do\n    command_with_escaped_parens = 'kamal secrets extract KEY1 \\{\\\"KEY1\\\":\\\"pass\\)word\\\"\\}'\n    Kamal::Cli::Main.expects(:start).with { |cmd|\n      cmd.first(3) == [ \"secrets\", \"extract\", \"KEY1\" ] &&\n      cmd[3] == '{\"KEY1\":\"pass)word\"}'  # shellsplit should unescape\n    }.returns(\"pass)word\")\n\n    substituted = Kamal::Secrets::Dotenv::InlineCommandSubstitution.call(\n      \"KEY1=$(#{command_with_escaped_parens})\", nil, overwrite: false\n    )\n    assert_equal \"KEY1=pass)word\", substituted\n  end\nend\n"
  },
  {
    "path": "test/secrets/enpass_adapter_test.rb",
    "content": "require \"test_helper\"\n\nclass EnpassAdapterTest < SecretAdapterTestCase\n  test \"fetch without CLI installed\" do\n    stub_ticks_with(\"enpass-cli version 2> /dev/null\", succeed: false)\n\n    error = assert_raises RuntimeError do\n      JSON.parse(run_command(\"fetch\", \"mynote\"))\n    end\n\n    assert_equal \"Enpass CLI is not installed\", error.message\n  end\n\n  test \"fetch one item\" do\n    stub_ticks_with(\"enpass-cli version 2> /dev/null\")\n\n    stub_ticks\n      .with(\"enpass-cli -json -vault vault-path show FooBar\")\n      .returns(<<~JSON)\n      [{\"category\":\"computer\",\"label\":\"SECRET_1\",\"login\":\"\",\"password\":\"my-password-1\",\"title\":\"FooBar\",\"type\":\"password\"}]\n      JSON\n\n    json = JSON.parse(run_command(\"fetch\", \"FooBar/SECRET_1\"))\n\n    expected_json = { \"FooBar/SECRET_1\" => \"my-password-1\" }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch multiple items\" do\n    stub_ticks_with(\"enpass-cli version 2> /dev/null\")\n\n    stub_ticks\n      .with(\"enpass-cli -json -vault vault-path show FooBar\")\n      .returns(<<~JSON)\n      [\n        {\"category\":\"computer\",\"label\":\"SECRET_1\",\"login\":\"\",\"password\":\"my-password-1\",\"title\":\"FooBar\",\"type\":\"password\"},\n        {\"category\":\"computer\",\"label\":\"SECRET_2\",\"login\":\"\",\"password\":\"my-password-2\",\"title\":\"FooBar\",\"type\":\"password\"},\n        {\"category\":\"computer\",\"label\":\"SECRET_3\",\"login\":\"\",\"password\":\"my-password-1\",\"title\":\"Hello\",\"type\":\"password\"}\n      ]\n      JSON\n\n    json = JSON.parse(run_command(\"fetch\", \"FooBar/SECRET_1\", \"FooBar/SECRET_2\"))\n\n    expected_json = { \"FooBar/SECRET_1\" => \"my-password-1\", \"FooBar/SECRET_2\" => \"my-password-2\" }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch all with from\" do\n    stub_ticks_with(\"enpass-cli version 2> /dev/null\")\n\n    stub_ticks\n      .with(\"enpass-cli -json -vault vault-path show FooBar\")\n      .returns(<<~JSON)\n      [\n        {\"category\":\"computer\",\"label\":\"SECRET_1\",\"login\":\"\",\"password\":\"my-password-1\",\"title\":\"FooBar\",\"type\":\"password\"},\n        {\"category\":\"computer\",\"label\":\"SECRET_2\",\"login\":\"\",\"password\":\"my-password-2\",\"title\":\"FooBar\",\"type\":\"password\"},\n        {\"category\":\"computer\",\"label\":\"SECRET_3\",\"login\":\"\",\"password\":\"my-password-1\",\"title\":\"Hello\",\"type\":\"password\"},\n        {\"category\":\"computer\",\"label\":\"\",\"login\":\"\",\"password\":\"my-password-3\",\"title\":\"FooBar\",\"type\":\"password\"}\n      ]\n      JSON\n\n    json = JSON.parse(run_command(\"fetch\", \"FooBar\"))\n\n    expected_json = { \"FooBar/SECRET_1\" => \"my-password-1\", \"FooBar/SECRET_2\" => \"my-password-2\", \"FooBar\" => \"my-password-3\" }\n\n    assert_equal expected_json, json\n  end\n\n  private\n    def run_command(*command)\n      stdouted do\n        Kamal::Cli::Secrets.start \\\n          [ *command,\n            \"-c\", \"test/fixtures/deploy_with_accessories.yml\",\n            \"--adapter\", \"enpass\",\n            \"--from\", \"vault-path\" ]\n      end\n    end\nend\n"
  },
  {
    "path": "test/secrets/gcp_secret_manager_adapter_test.rb",
    "content": "require \"test_helper\"\n\nclass GcpSecretManagerAdapterTest < SecretAdapterTestCase\n  test \"fetch\" do\n    stub_gcloud_version\n    stub_authenticated\n    stub_mypassword\n\n    json = JSON.parse(run_command(\"fetch\", \"mypassword\"))\n\n    expected_json = { \"default/mypassword\"=>\"secret123\" }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch unauthenticated\" do\n    stub_ticks.with(\"gcloud --version 2> /dev/null\")\n\n    stub_mypassword\n    stub_unauthenticated\n\n    error = assert_raises RuntimeError do\n      JSON.parse(run_command(\"fetch\", \"mypassword\"))\n    end\n\n    assert_match(/could not login to gcloud/, error.message)\n  end\n\n  test \"fetch with from\" do\n    stub_gcloud_version\n    stub_authenticated\n    stub_items(0, project: \"other-project\")\n    stub_items(1, project: \"other-project\")\n    stub_items(2, project: \"other-project\")\n\n    json = JSON.parse(run_command(\"fetch\", \"--from\", \"other-project\", \"item1\", \"item2\", \"item3\"))\n\n    expected_json = {\n      \"other-project/item1\"=>\"secret1\", \"other-project/item2\"=>\"secret2\", \"other-project/item3\"=>\"secret3\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch with multiple projects\" do\n    stub_gcloud_version\n    stub_authenticated\n    stub_items(0, project: \"some-project\")\n    stub_items(1, project: \"project-confidence\")\n    stub_items(2, project: \"manhattan-project\")\n\n    json = JSON.parse(run_command(\"fetch\", \"some-project/item1\", \"project-confidence/item2\", \"manhattan-project/item3\"))\n\n    expected_json = {\n      \"some-project/item1\"=>\"secret1\", \"project-confidence/item2\"=>\"secret2\", \"manhattan-project/item3\"=>\"secret3\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch with specific version\" do\n    stub_gcloud_version\n    stub_authenticated\n    stub_items(0, project: \"some-project\", version: \"123\")\n\n    json = JSON.parse(run_command(\"fetch\", \"some-project/item1/123\"))\n\n    expected_json = {\n      \"some-project/item1\"=>\"secret1\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch with non-default account\" do\n    stub_gcloud_version\n    stub_authenticated\n    stub_items(0, project: \"some-project\", version: \"123\", account: \"email@example.com\")\n\n    json = JSON.parse(run_command(\"fetch\", \"some-project/item1/123\", account: \"email@example.com\"))\n\n    expected_json = {\n      \"some-project/item1\"=>\"secret1\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch with service account impersonation\" do\n    stub_gcloud_version\n    stub_authenticated\n    stub_items(0, project: \"some-project\", version: \"123\", impersonate_service_account: \"service-user@example.com\")\n\n    json = JSON.parse(run_command(\"fetch\", \"some-project/item1/123\", account: \"default|service-user@example.com\"))\n\n    expected_json = {\n      \"some-project/item1\"=>\"secret1\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch with delegation chain and specific user\" do\n    stub_gcloud_version\n    stub_authenticated\n    stub_items(0, project: \"some-project\", version: \"123\", account: \"user@example.com\", impersonate_service_account: \"service-user@example.com,service-user2@example.com\")\n\n    json = JSON.parse(run_command(\"fetch\", \"some-project/item1/123\", account: \"user@example.com|service-user@example.com,service-user2@example.com\"))\n\n    expected_json = {\n      \"some-project/item1\"=>\"secret1\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch with non-default account and service account impersonation\" do\n    stub_gcloud_version\n    stub_authenticated\n    stub_items(0, project: \"some-project\", version: \"123\", account: \"email@example.com\", impersonate_service_account: \"service-user@example.com\")\n\n    json = JSON.parse(run_command(\"fetch\", \"some-project/item1/123\", account: \"email@example.com|service-user@example.com\"))\n\n    expected_json = {\n      \"some-project/item1\"=>\"secret1\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch without CLI installed\" do\n    stub_gcloud_version(succeed: false)\n\n    error = assert_raises RuntimeError do\n      JSON.parse(run_command(\"fetch\", \"item1\"))\n    end\n    assert_equal \"gcloud CLI is not installed\", error.message\n  end\n\n  private\n    def run_command(*command, account: \"default\")\n      stdouted do\n        Kamal::Cli::Secrets.start \\\n          [ *command,\n            \"-c\", \"test/fixtures/deploy_with_accessories.yml\",\n            \"--adapter\", \"gcp_secret_manager\",\n            \"--account\", account ]\n      end\n    end\n\n    def stub_gcloud_version(succeed: true)\n      stub_ticks_with(\"gcloud --version 2> /dev/null\", succeed: succeed)\n    end\n\n    def stub_authenticated\n      stub_ticks\n        .with(\"gcloud auth list --format=json\")\n        .returns(<<~JSON)\n          [\n            {\n              \"account\": \"email@example.com\",\n              \"status\": \"ACTIVE\"\n            }\n          ]\n        JSON\n    end\n\n    def stub_unauthenticated\n      stub_ticks\n        .with(\"gcloud auth list --format=json\")\n        .returns(\"[]\")\n\n      stub_ticks\n        .with(\"gcloud auth login\")\n        .returns(<<~JSON)\n          {\n            \"expired\": false,\n            \"valid\": true\n          }\n        JSON\n    end\n\n    def stub_mypassword\n      stub_ticks\n        .with(\"gcloud secrets versions access latest --secret=mypassword --format=json\")\n        .returns(<<~JSON)\n          {\n            \"name\": \"projects/000000000/secrets/mypassword/versions/1\",\n            \"payload\": {\n              \"data\": \"c2VjcmV0MTIz\",\n              \"dataCrc32c\": \"2522602764\"\n            }\n          }\n        JSON\n    end\n\n    def stub_items(n, project: nil, account: nil, version: \"latest\", impersonate_service_account: nil)\n      payloads = [\n        { data: \"c2VjcmV0MQ==\", checksum: 1846998209 },\n        { data: \"c2VjcmV0Mg==\", checksum: 2101741365 },\n        { data: \"c2VjcmV0Mw==\", checksum: 2402124854 }\n      ]\n      stub_ticks\n        .with(\"gcloud secrets versions access #{version} \" \\\n              \"--secret=item#{n + 1}\" \\\n              \"#{\" --project=#{project}\" if project}\" \\\n              \"#{\" --account=#{account}\" if account}\" \\\n              \"#{\" --impersonate-service-account=#{impersonate_service_account}\" if impersonate_service_account} \" \\\n              \"--format=json\")\n        .returns(<<~JSON)\n          {\n            \"name\": \"projects/000000001/secrets/item1/versions/1\",\n            \"payload\": {\n              \"data\": \"#{payloads[n][:data]}\",\n              \"dataCrc32c\": \"#{payloads[n][:checksum]}\"\n            }\n          }\n        JSON\n  end\nend\n"
  },
  {
    "path": "test/secrets/last_pass_adapter_test.rb",
    "content": "require \"test_helper\"\n\nclass LastPassAdapterTest < SecretAdapterTestCase\n  setup do\n    `true` # Ensure $? is 0\n  end\n\n  test \"fetch\" do\n    stub_ticks.with(\"lpass --version 2> /dev/null\")\n    stub_ticks.with(\"lpass status --color never\").returns(\"Logged in as email@example.com.\")\n\n    stub_ticks\n      .with(\"lpass show SECRET1 FOLDER1/FSECRET1 FOLDER1/FSECRET2 --json\")\n      .returns(<<~JSON)\n        [\n          {\n            \"id\": \"1234567891234567891\",\n            \"name\": \"SECRET1\",\n            \"fullname\": \"SECRET1\",\n            \"username\": \"\",\n            \"password\": \"secret1\",\n            \"last_modified_gmt\": \"1724926054\",\n            \"last_touch\": \"1724926639\",\n            \"group\": \"\",\n            \"url\": \"\",\n            \"note\": \"\"\n          },\n          {\n            \"id\": \"1234567891234567892\",\n            \"name\": \"FSECRET1\",\n            \"fullname\": \"FOLDER1/FSECRET1\",\n            \"username\": \"\",\n            \"password\": \"fsecret1\",\n            \"last_modified_gmt\": \"1724926084\",\n            \"last_touch\": \"1724926635\",\n            \"group\": \"Folder\",\n            \"url\": \"\",\n            \"note\": \"\"\n          },\n          {\n            \"id\": \"1234567891234567893\",\n            \"name\": \"FSECRET2\",\n            \"fullname\": \"FOLDER1/FSECRET2\",\n            \"username\": \"\",\n            \"password\": \"fsecret2\",\n            \"last_modified_gmt\": \"1724926084\",\n            \"last_touch\": \"1724926635\",\n            \"group\": \"Folder\",\n            \"url\": \"\",\n            \"note\": \"\"\n          }\n        ]\n      JSON\n\n    json = JSON.parse(run_command(\"fetch\", \"SECRET1\", \"FOLDER1/FSECRET1\", \"FOLDER1/FSECRET2\"))\n\n    expected_json = {\n      \"SECRET1\"=>\"secret1\",\n      \"FOLDER1/FSECRET1\"=>\"fsecret1\",\n      \"FOLDER1/FSECRET2\"=>\"fsecret2\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch with from\" do\n    stub_ticks.with(\"lpass --version 2> /dev/null\")\n    stub_ticks.with(\"lpass status --color never\").returns(\"Logged in as email@example.com.\")\n\n    stub_ticks\n      .with(\"lpass show FOLDER1/FSECRET1 FOLDER1/FSECRET2 --json\")\n      .returns(<<~JSON)\n        [\n          {\n            \"id\": \"1234567891234567892\",\n            \"name\": \"FSECRET1\",\n            \"fullname\": \"FOLDER1/FSECRET1\",\n            \"username\": \"\",\n            \"password\": \"fsecret1\",\n            \"last_modified_gmt\": \"1724926084\",\n            \"last_touch\": \"1724926635\",\n            \"group\": \"Folder\",\n            \"url\": \"\",\n            \"note\": \"\"\n          },\n          {\n            \"id\": \"1234567891234567893\",\n            \"name\": \"FSECRET2\",\n            \"fullname\": \"FOLDER1/FSECRET2\",\n            \"username\": \"\",\n            \"password\": \"fsecret2\",\n            \"last_modified_gmt\": \"1724926084\",\n            \"last_touch\": \"1724926635\",\n            \"group\": \"Folder\",\n            \"url\": \"\",\n            \"note\": \"\"\n          }\n        ]\n      JSON\n\n    json = JSON.parse(run_command(\"fetch\", \"--from\", \"FOLDER1\", \"FSECRET1\", \"FSECRET2\"))\n\n    expected_json = {\n      \"FOLDER1/FSECRET1\"=>\"fsecret1\",\n      \"FOLDER1/FSECRET2\"=>\"fsecret2\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch with signin\" do\n    stub_ticks.with(\"lpass --version 2> /dev/null\")\n\n    stub_ticks_with(\"lpass status --color never\", succeed: false).returns(\"Not logged in.\")\n    stub_ticks_with(\"lpass login email@example.com\", succeed: true).returns(\"\")\n    stub_ticks.with(\"lpass show SECRET1 --json\").returns(single_item_json)\n\n    json = JSON.parse(run_command(\"fetch\", \"SECRET1\"))\n\n    expected_json = {\n      \"SECRET1\"=>\"secret1\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch without CLI installed\" do\n    stub_ticks_with(\"lpass --version 2> /dev/null\", succeed: false)\n\n    error = assert_raises RuntimeError do\n      JSON.parse(run_command(\"fetch\", \"SECRET1\", \"FOLDER1/FSECRET1\", \"FOLDER1/FSECRET2\"))\n    end\n    assert_equal \"LastPass CLI is not installed\", error.message\n  end\n\n  private\n    def run_command(*command)\n      stdouted do\n        Kamal::Cli::Secrets.start \\\n          [ *command,\n            \"-c\", \"test/fixtures/deploy_with_accessories.yml\",\n            \"--adapter\", \"lastpass\",\n            \"--account\", \"email@example.com\" ]\n      end\n    end\n\n    def single_item_json\n      <<~JSON\n        [\n          {\n            \"id\": \"1234567891234567891\",\n            \"name\": \"SECRET1\",\n            \"fullname\": \"SECRET1\",\n            \"username\": \"\",\n            \"password\": \"secret1\",\n            \"last_modified_gmt\": \"1724926054\",\n            \"last_touch\": \"1724926639\",\n            \"group\": \"\",\n            \"url\": \"\",\n            \"note\": \"\"\n          }\n        ]\n      JSON\n    end\nend\n"
  },
  {
    "path": "test/secrets/one_password_adapter_test.rb",
    "content": "require \"test_helper\"\n\nclass SecretsOnePasswordAdapterTest < SecretAdapterTestCase\n  test \"fetch\" do\n    stub_ticks.with(\"op --version 2> /dev/null\")\n    stub_ticks.with(\"op account get --account myaccount 2> /dev/null\")\n\n    stub_ticks\n      .with(\"op item get myitem --vault \\\"myvault\\\" --format \\\"json\\\" --account \\\"myaccount\\\" --fields \\\"label=section.SECRET1,label=section.SECRET2,label=section2.SECRET3\\\"\")\n      .returns(<<~JSON)\n        [\n          {\n            \"id\": \"aaaaaaaaaaaaaaaaaaaaaaaaaa\",\n            \"section\": {\n              \"id\": \"cccccccccccccccccccccccccc\",\n              \"label\": \"section\"\n            },\n            \"type\": \"CONCEALED\",\n            \"label\": \"SECRET1\",\n            \"value\": \"VALUE1\",\n            \"reference\": \"op://myvault/myitem/section/SECRET1\"\n          },\n          {\n            \"id\": \"bbbbbbbbbbbbbbbbbbbbbbbbbb\",\n            \"section\": {\n              \"id\": \"dddddddddddddddddddddddddd\",\n              \"label\": \"section\"\n            },\n            \"type\": \"CONCEALED\",\n            \"label\": \"SECRET2\",\n            \"value\": \"VALUE2\",\n            \"reference\": \"op://myvault/myitem/section/SECRET2\"\n          },\n          {\n            \"id\": \"bbbbbbbbbbbbbbbbbbbbbbbbbb\",\n            \"section\": {\n              \"id\": \"dddddddddddddddddddddddddd\",\n              \"label\": \"section2\"\n            },\n            \"type\": \"CONCEALED\",\n            \"label\": \"SECRET3\",\n            \"value\": \"VALUE3\",\n            \"reference\": \"op://myvault/myitem/section2/SECRET3\"\n          }\n        ]\n      JSON\n\n    json = JSON.parse(run_command(\"fetch\", \"--from\", \"op://myvault/myitem\", \"section/SECRET1\", \"section/SECRET2\", \"section2/SECRET3\"))\n\n    expected_json = {\n      \"myvault/myitem/section/SECRET1\"=>\"VALUE1\",\n      \"myvault/myitem/section/SECRET2\"=>\"VALUE2\",\n      \"myvault/myitem/section2/SECRET3\"=>\"VALUE3\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch with multiple items\" do\n    stub_ticks.with(\"op --version 2> /dev/null\")\n    stub_ticks.with(\"op account get --account myaccount 2> /dev/null\")\n\n    stub_ticks\n      .with(\"op item get myitem --vault \\\"myvault\\\" --format \\\"json\\\" --account \\\"myaccount\\\" --fields \\\"label=section.SECRET1,label=section.SECRET2\\\"\")\n      .returns(<<~JSON)\n        [\n          {\n            \"id\": \"aaaaaaaaaaaaaaaaaaaaaaaaaa\",\n            \"section\": {\n              \"id\": \"cccccccccccccccccccccccccc\",\n              \"label\": \"section\"\n            },\n            \"type\": \"CONCEALED\",\n            \"label\": \"SECRET1\",\n            \"value\": \"VALUE1\",\n            \"reference\": \"op://myvault/myitem/section/SECRET1\"\n          },\n          {\n            \"id\": \"bbbbbbbbbbbbbbbbbbbbbbbbbb\",\n            \"section\": {\n              \"id\": \"dddddddddddddddddddddddddd\",\n              \"label\": \"section\"\n            },\n            \"type\": \"CONCEALED\",\n            \"label\": \"SECRET2\",\n            \"value\": \"VALUE2\",\n            \"reference\": \"op://myvault/myitem/section/SECRET2\"\n          }\n        ]\n      JSON\n\n    stub_ticks\n      .with(\"op item get myitem2 --vault \\\"myvault\\\" --format \\\"json\\\" --account \\\"myaccount\\\" --fields \\\"label=section2.SECRET3\\\"\")\n      .returns(<<~JSON)\n        {\n          \"id\": \"aaaaaaaaaaaaaaaaaaaaaaaaaa\",\n          \"section\": {\n            \"id\": \"cccccccccccccccccccccccccc\",\n            \"label\": \"section\"\n          },\n          \"type\": \"CONCEALED\",\n          \"label\": \"SECRET3\",\n          \"value\": \"VALUE3\",\n          \"reference\": \"op://myvault/myitem2/section/SECRET3\"\n        }\n      JSON\n\n    json = JSON.parse(run_command(\"fetch\", \"--from\", \"op://myvault\", \"myitem/section/SECRET1\", \"myitem/section/SECRET2\", \"myitem2/section2/SECRET3\"))\n\n    expected_json = {\n      \"myvault/myitem/section/SECRET1\"=>\"VALUE1\",\n      \"myvault/myitem/section/SECRET2\"=>\"VALUE2\",\n      \"myvault/myitem2/section/SECRET3\"=>\"VALUE3\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch all fields\" do\n    stub_ticks.with(\"op --version 2> /dev/null\")\n    stub_ticks.with(\"op account get --account myaccount 2> /dev/null\")\n\n    stub_ticks\n      .with(\"op item get myitem --vault \\\"myvault\\\" --format \\\"json\\\" --account \\\"myaccount\\\"\")\n      .returns(<<~JSON)\n        {\n          \"id\": \"ucbtiii777\",\n          \"title\": \"A title\",\n          \"version\": 45,\n          \"vault\": {\n            \"id\": \"vu7ki98do\",\n            \"name\": \"Vault\"\n          },\n          \"category\": \"LOGIN\",\n          \"last_edited_by\": \"ABCT3684BC\",\n          \"created_at\": \"2025-05-22T06:47:01Z\",\n          \"updated_at\": \"2025-05-22T00:36:48.02598-07:00\",\n          \"additional_information\": \"—\",\n          \"fields\": [\n            {\n              \"id\": \"aaaaaaaaaaaaaaaaaaaaaaaaaa\",\n              \"section\": {\n                \"id\": \"cccccccccccccccccccccccccc\",\n                \"label\": \"section\"\n              },\n              \"type\": \"CONCEALED\",\n              \"label\": \"SECRET1\",\n              \"value\": \"VALUE1\",\n              \"reference\": \"op://myvault/myitem/section/SECRET1\"\n            },\n            {\n              \"id\": \"bbbbbbbbbbbbbbbbbbbbbbbbbb\",\n              \"section\": {\n                \"id\": \"cccccccccccccccccccccccccc\",\n                \"label\": \"section\"\n              },\n              \"type\": \"CONCEALED\",\n              \"label\": \"SECRET2\",\n              \"value\": \"VALUE2\",\n              \"reference\": \"op://myvault/myitem/section/SECRET2\"\n            }\n          ]\n        }\n      JSON\n\n    json = JSON.parse(run_command(\"fetch\", \"--from\", \"op://myvault/myitem\"))\n\n    expected_json = {\n      \"myvault/myitem/section/SECRET1\"=>\"VALUE1\",\n      \"myvault/myitem/section/SECRET2\"=>\"VALUE2\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch with signin, no session\" do\n    stub_ticks.with(\"op --version 2> /dev/null\")\n\n    stub_ticks_with(\"op account get --account myaccount 2> /dev/null\", succeed: false)\n    stub_ticks_with(\"op signin --account \\\"myaccount\\\" --force --raw\", succeed: true).returns(\"\")\n\n    stub_ticks\n      .with(\"op item get myitem --vault \\\"myvault\\\" --format \\\"json\\\" --account \\\"myaccount\\\" --fields \\\"label=section.SECRET1\\\"\")\n      .returns(single_item_json)\n\n    json = JSON.parse(run_command(\"fetch\", \"--from\", \"op://myvault/myitem\", \"section/SECRET1\"))\n\n    expected_json = {\n      \"myvault/myitem/section/SECRET1\"=>\"VALUE1\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch with signin and session\" do\n    stub_ticks.with(\"op --version 2> /dev/null\")\n\n    stub_ticks_with(\"op account get --account myaccount 2> /dev/null\", succeed: false)\n    stub_ticks_with(\"op signin --account \\\"myaccount\\\" --force --raw\", succeed: true).returns(\"1234567890\")\n\n    stub_ticks\n      .with(\"op item get myitem --vault \\\"myvault\\\" --format \\\"json\\\" --account \\\"myaccount\\\" --session \\\"1234567890\\\" --fields \\\"label=section.SECRET1\\\"\")\n      .returns(single_item_json)\n\n    json = JSON.parse(run_command(\"fetch\", \"--from\", \"op://myvault/myitem\", \"section/SECRET1\"))\n\n    expected_json = {\n      \"myvault/myitem/section/SECRET1\"=>\"VALUE1\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch without CLI installed\" do\n    stub_ticks_with(\"op --version 2> /dev/null\", succeed: false)\n\n    error = assert_raises RuntimeError do\n      JSON.parse(run_command(\"fetch\", \"--from\", \"op://myvault/myitem\", \"section/SECRET1\", \"section/SECRET2\", \"section2/SECRET3\"))\n    end\n    assert_equal \"1Password CLI is not installed\", error.message\n  end\n\n  private\n    def run_command(*command)\n      stdouted do\n        Kamal::Cli::Secrets.start \\\n          [ *command,\n            \"-c\", \"test/fixtures/deploy_with_accessories.yml\",\n            \"--adapter\", \"1password\",\n            \"--account\", \"myaccount\" ]\n      end\n    end\n\n    def single_item_json\n      <<~JSON\n        {\n          \"id\": \"aaaaaaaaaaaaaaaaaaaaaaaaaa\",\n          \"section\": {\n            \"id\": \"cccccccccccccccccccccccccc\",\n            \"label\": \"section\"\n          },\n          \"type\": \"CONCEALED\",\n          \"label\": \"SECRET1\",\n          \"value\": \"VALUE1\",\n          \"reference\": \"op://myvault/myitem/section/SECRET1\"\n        }\n      JSON\n    end\nend\n"
  },
  {
    "path": "test/secrets/passbolt_adapter_test.rb",
    "content": "require \"test_helper\"\n\nclass PassboltAdapterTest < SecretAdapterTestCase\n  setup do\n    `true` # Ensure $? is 0\n  end\n\n  test \"fetch\" do\n    stub_ticks_with(\"passbolt --version 2> /dev/null\", succeed: true)\n    stub_ticks.with(\"passbolt verify 2> /dev/null\", succeed: true)\n\n    stub_ticks\n      .with(\"passbolt list resources --filter 'Name == \\\"SECRET1\\\" || Name == \\\"FSECRET1\\\" || Name == \\\"FSECRET2\\\"'  --json\")\n      .returns(<<~JSON)\n        [\n          {\n            \"id\": \"4c116996-f6d0-4342-9572-0d676f75b3ac\",\n            \"folder_parent_id\": \"\",\n            \"name\": \"FSECRET1\",\n            \"username\": \"\",\n            \"uri\": \"\",\n            \"password\": \"fsecret1\",\n            \"description\": \"\",\n            \"created_timestamp\": \"2025-02-21T06:04:29Z\",\n            \"modified_timestamp\": \"2025-02-21T06:04:29Z\"\n          },\n          {\n            \"id\": \"62949b26-4957-43fe-9523-294d66861499\",\n            \"folder_parent_id\": \"\",\n            \"name\": \"FSECRET2\",\n            \"username\": \"\",\n            \"uri\": \"\",\n            \"password\": \"fsecret2\",\n            \"description\": \"\",\n            \"created_timestamp\": \"2025-02-21T06:04:34Z\",\n            \"modified_timestamp\": \"2025-02-21T06:04:34Z\"\n          },\n          {\n            \"id\": \"dd32963c-0db5-4303-a6fc-22c5229dabef\",\n            \"folder_parent_id\": \"\",\n            \"name\": \"SECRET1\",\n            \"username\": \"\",\n            \"uri\": \"\",\n            \"password\": \"secret1\",\n            \"description\": \"\",\n            \"created_timestamp\": \"2025-02-21T06:04:23Z\",\n            \"modified_timestamp\": \"2025-02-21T06:04:23Z\"\n          }\n        ]\n      JSON\n\n    json = JSON.parse(\n      run_command(\"fetch\", \"SECRET1\", \"FSECRET1\", \"FSECRET2\")\n    )\n\n    expected_json = {\n      \"SECRET1\"=>\"secret1\",\n      \"FSECRET1\"=>\"fsecret1\",\n      \"FSECRET2\"=>\"fsecret2\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch with --from\" do\n    stub_ticks_with(\"passbolt --version 2> /dev/null\", succeed: true)\n    stub_ticks.with(\"passbolt verify 2> /dev/null\", succeed: true)\n\n    stub_ticks\n      .with(\"passbolt list folders --filter 'Name == \\\"my-project\\\"' --json\")\n      .returns(<<~JSON)\n        [\n          {\n            \"id\": \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\",\n            \"folder_parent_id\": \"\",\n            \"name\": \"my-project\",\n            \"created_timestamp\": \"2025-02-21T19:52:50Z\",\n            \"modified_timestamp\": \"2025-02-21T19:52:50Z\"\n          }\n        ]\n      JSON\n\n    stub_ticks\n      .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\")\n      .returns(<<~JSON)\n        [\n          {\n            \"id\": \"4c116996-f6d0-4342-9572-0d676f75b3ac\",\n            \"folder_parent_id\": \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\",\n            \"name\": \"FSECRET1\",\n            \"username\": \"\",\n            \"uri\": \"\",\n            \"password\": \"fsecret1\",\n            \"description\": \"\",\n            \"created_timestamp\": \"2025-02-21T06:04:29Z\",\n            \"modified_timestamp\": \"2025-02-21T06:04:29Z\"\n          },\n          {\n            \"id\": \"62949b26-4957-43fe-9523-294d66861499\",\n            \"folder_parent_id\": \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\",\n            \"name\": \"FSECRET2\",\n            \"username\": \"\",\n            \"uri\": \"\",\n            \"password\": \"fsecret2\",\n            \"description\": \"\",\n            \"created_timestamp\": \"2025-02-21T06:04:34Z\",\n            \"modified_timestamp\": \"2025-02-21T06:04:34Z\"\n          },\n          {\n            \"id\": \"dd32963c-0db5-4303-a6fc-22c5229dabef\",\n            \"folder_parent_id\": \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\",\n            \"name\": \"SECRET1\",\n            \"username\": \"\",\n            \"uri\": \"\",\n            \"password\": \"secret1\",\n            \"description\": \"\",\n            \"created_timestamp\": \"2025-02-21T06:04:23Z\",\n            \"modified_timestamp\": \"2025-02-21T06:04:23Z\"\n          }\n        ]\n      JSON\n\n    json = JSON.parse(\n      run_command(\"fetch\", \"--from\", \"my-project\", \"SECRET1\", \"FSECRET1\", \"FSECRET2\")\n    )\n\n    expected_json = {\n      \"SECRET1\"=>\"secret1\",\n      \"FSECRET1\"=>\"fsecret1\",\n      \"FSECRET2\"=>\"fsecret2\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch with folder in secret\" do\n    stub_ticks_with(\"passbolt --version 2> /dev/null\", succeed: true)\n    stub_ticks.with(\"passbolt verify 2> /dev/null\", succeed: true)\n\n    stub_ticks\n      .with(\"passbolt list folders --filter 'Name == \\\"my-project\\\"' --json\")\n      .returns(<<~JSON)\n        [\n          {\n            \"id\": \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\",\n            \"folder_parent_id\": \"\",\n            \"name\": \"my-project\",\n            \"created_timestamp\": \"2025-02-21T19:52:50Z\",\n            \"modified_timestamp\": \"2025-02-21T19:52:50Z\"\n          }\n        ]\n      JSON\n\n    stub_ticks\n      .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\")\n      .returns(<<~JSON)\n        [\n          {\n            \"id\": \"4c116996-f6d0-4342-9572-0d676f75b3ac\",\n            \"folder_parent_id\": \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\",\n            \"name\": \"FSECRET1\",\n            \"username\": \"\",\n            \"uri\": \"\",\n            \"password\": \"fsecret1\",\n            \"description\": \"\",\n            \"created_timestamp\": \"2025-02-21T06:04:29Z\",\n            \"modified_timestamp\": \"2025-02-21T06:04:29Z\"\n          },\n          {\n            \"id\": \"62949b26-4957-43fe-9523-294d66861499\",\n            \"folder_parent_id\": \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\",\n            \"name\": \"FSECRET2\",\n            \"username\": \"\",\n            \"uri\": \"\",\n            \"password\": \"fsecret2\",\n            \"description\": \"\",\n            \"created_timestamp\": \"2025-02-21T06:04:34Z\",\n            \"modified_timestamp\": \"2025-02-21T06:04:34Z\"\n          },\n          {\n            \"id\": \"dd32963c-0db5-4303-a6fc-22c5229dabef\",\n            \"folder_parent_id\": \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\",\n            \"name\": \"SECRET1\",\n            \"username\": \"\",\n            \"uri\": \"\",\n            \"password\": \"secret1\",\n            \"description\": \"\",\n            \"created_timestamp\": \"2025-02-21T06:04:23Z\",\n            \"modified_timestamp\": \"2025-02-21T06:04:23Z\"\n          }\n        ]\n      JSON\n\n    json = JSON.parse(\n      run_command(\"fetch\", \"my-project/SECRET1\", \"my-project/FSECRET1\", \"my-project/FSECRET2\")\n    )\n\n    expected_json = {\n      \"SECRET1\"=>\"secret1\",\n      \"FSECRET1\"=>\"fsecret1\",\n      \"FSECRET2\"=>\"fsecret2\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch from multiple folders\" do\n    stub_ticks_with(\"passbolt --version 2> /dev/null\", succeed: true)\n    stub_ticks.with(\"passbolt verify 2> /dev/null\", succeed: true)\n\n    stub_ticks\n      .with(\"passbolt list folders --filter 'Name == \\\"my-project\\\" || Name == \\\"other-project\\\"' --json\")\n      .returns(<<~JSON)\n        [\n          {\n            \"id\": \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\",\n            \"folder_parent_id\": \"\",\n            \"name\": \"my-project\",\n            \"created_timestamp\": \"2025-02-21T19:52:50Z\",\n            \"modified_timestamp\": \"2025-02-21T19:52:50Z\"\n          },\n          {\n            \"id\": \"14e11dd8-b279-4689-8bd9-fa33ebb527da\",\n            \"folder_parent_id\": \"\",\n            \"name\": \"other-project\",\n            \"created_timestamp\": \"2025-02-21T20:00:29Z\",\n            \"modified_timestamp\": \"2025-02-21T20:00:29Z\"\n          }\n        ]\n      JSON\n\n    stub_ticks\n      .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\")\n      .returns(<<~JSON)\n        [\n          {\n            \"id\": \"4c116996-f6d0-4342-9572-0d676f75b3ac\",\n            \"folder_parent_id\": \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\",\n            \"name\": \"FSECRET1\",\n            \"username\": \"\",\n            \"uri\": \"\",\n            \"password\": \"fsecret1\",\n            \"description\": \"\",\n            \"created_timestamp\": \"2025-02-21T06:04:29Z\",\n            \"modified_timestamp\": \"2025-02-21T06:04:29Z\"\n          },\n          {\n            \"id\": \"62949b26-4957-43fe-9523-294d66861499\",\n            \"folder_parent_id\": \"14e11dd8-b279-4689-8bd9-fa33ebb527da\",\n            \"name\": \"FSECRET2\",\n            \"username\": \"\",\n            \"uri\": \"\",\n            \"password\": \"fsecret2\",\n            \"description\": \"\",\n            \"created_timestamp\": \"2025-02-21T06:04:34Z\",\n            \"modified_timestamp\": \"2025-02-21T06:04:34Z\"\n          },\n          {\n            \"id\": \"dd32963c-0db5-4303-a6fc-22c5229dabef\",\n            \"folder_parent_id\": \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\",\n            \"name\": \"SECRET1\",\n            \"username\": \"\",\n            \"uri\": \"\",\n            \"password\": \"secret1\",\n            \"description\": \"\",\n            \"created_timestamp\": \"2025-02-21T06:04:23Z\",\n            \"modified_timestamp\": \"2025-02-21T06:04:23Z\"\n          }\n        ]\n      JSON\n\n    json = JSON.parse(\n      run_command(\"fetch\", \"my-project/SECRET1\", \"my-project/FSECRET1\", \"other-project/FSECRET2\")\n    )\n\n    expected_json = {\n      \"SECRET1\"=>\"secret1\",\n      \"FSECRET1\"=>\"fsecret1\",\n      \"FSECRET2\"=>\"fsecret2\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch from nested folder\" do\n    stub_ticks_with(\"passbolt --version 2> /dev/null\", succeed: true)\n    stub_ticks.with(\"passbolt verify 2> /dev/null\", succeed: true)\n\n    stub_ticks\n      .with(\"passbolt list folders --filter 'Name == \\\"my-project\\\"' --json\")\n      .returns(<<~JSON)\n        [\n          {\n            \"id\": \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\",\n            \"folder_parent_id\": \"\",\n            \"name\": \"my-project\",\n            \"created_timestamp\": \"2025-02-21T19:52:50Z\",\n            \"modified_timestamp\": \"2025-02-21T19:52:50Z\"\n          }\n        ]\n      JSON\n\n    stub_ticks\n      .with(\"passbolt list folders --filter 'Name == \\\"subfolder\\\" && FolderParentID == \\\"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\\\"' --json\")\n      .returns(<<~JSON)\n        [\n          {\n            \"id\": \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\",\n            \"folder_parent_id\": \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\",\n            \"name\": \"subfolder\",\n            \"created_timestamp\": \"2025-02-21T19:52:50Z\",\n            \"modified_timestamp\": \"2025-02-21T19:52:50Z\"\n          }\n        ]\n      JSON\n\n    stub_ticks\n      .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\")\n      .returns(<<~JSON)\n        [\n          {\n            \"id\": \"4c116996-f6d0-4342-9572-0d676f75b3ac\",\n            \"folder_parent_id\": \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\",\n            \"name\": \"FSECRET1\",\n            \"username\": \"\",\n            \"uri\": \"\",\n            \"password\": \"fsecret1\",\n            \"description\": \"\",\n            \"created_timestamp\": \"2025-02-21T06:04:29Z\",\n            \"modified_timestamp\": \"2025-02-21T06:04:29Z\"\n          },\n          {\n            \"id\": \"62949b26-4957-43fe-9523-294d66861499\",\n            \"folder_parent_id\": \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\",\n            \"name\": \"FSECRET2\",\n            \"username\": \"\",\n            \"uri\": \"\",\n            \"password\": \"fsecret2\",\n            \"description\": \"\",\n            \"created_timestamp\": \"2025-02-21T06:04:34Z\",\n            \"modified_timestamp\": \"2025-02-21T06:04:34Z\"\n          },\n          {\n            \"id\": \"dd32963c-0db5-4303-a6fc-22c5229dabef\",\n            \"folder_parent_id\": \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\",\n            \"name\": \"SECRET1\",\n            \"username\": \"\",\n            \"uri\": \"\",\n            \"password\": \"secret1\",\n            \"description\": \"\",\n            \"created_timestamp\": \"2025-02-21T06:04:23Z\",\n            \"modified_timestamp\": \"2025-02-21T06:04:23Z\"\n          }\n        ]\n      JSON\n\n    json = JSON.parse(\n      run_command(\"fetch\", \"--from\", \"my-project/subfolder\", \"SECRET1\", \"FSECRET1\", \"FSECRET2\")\n    )\n\n    expected_json = {\n      \"SECRET1\"=>\"secret1\",\n      \"FSECRET1\"=>\"fsecret1\",\n      \"FSECRET2\"=>\"fsecret2\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch from nested folder in secret\" do\n    stub_ticks_with(\"passbolt --version 2> /dev/null\", succeed: true)\n    stub_ticks.with(\"passbolt verify 2> /dev/null\", succeed: true)\n\n    stub_ticks\n      .with(\"passbolt list folders --filter 'Name == \\\"my-project\\\"' --json\")\n      .returns(<<~JSON)\n        [\n          {\n            \"id\": \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\",\n            \"folder_parent_id\": \"\",\n            \"name\": \"my-project\",\n            \"created_timestamp\": \"2025-02-21T19:52:50Z\",\n            \"modified_timestamp\": \"2025-02-21T19:52:50Z\"\n          }\n        ]\n      JSON\n\n    stub_ticks\n      .with(\"passbolt list folders --filter 'Name == \\\"subfolder\\\" && FolderParentID == \\\"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\\\"' --json\")\n      .returns(<<~JSON)\n        [\n          {\n            \"id\": \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\",\n            \"folder_parent_id\": \"dcbe0e39-42d8-42db-9637-8256b9f2f8e3\",\n            \"name\": \"subfolder\",\n            \"created_timestamp\": \"2025-02-21T19:52:50Z\",\n            \"modified_timestamp\": \"2025-02-21T19:52:50Z\"\n          }\n        ]\n      JSON\n\n    stub_ticks\n      .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\")\n      .returns(<<~JSON)\n        [\n          {\n            \"id\": \"4c116996-f6d0-4342-9572-0d676f75b3ac\",\n            \"folder_parent_id\": \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\",\n            \"name\": \"FSECRET1\",\n            \"username\": \"\",\n            \"uri\": \"\",\n            \"password\": \"fsecret1\",\n            \"description\": \"\",\n            \"created_timestamp\": \"2025-02-21T06:04:29Z\",\n            \"modified_timestamp\": \"2025-02-21T06:04:29Z\"\n          },\n          {\n            \"id\": \"62949b26-4957-43fe-9523-294d66861499\",\n            \"folder_parent_id\": \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\",\n            \"name\": \"FSECRET2\",\n            \"username\": \"\",\n            \"uri\": \"\",\n            \"password\": \"fsecret2\",\n            \"description\": \"\",\n            \"created_timestamp\": \"2025-02-21T06:04:34Z\",\n            \"modified_timestamp\": \"2025-02-21T06:04:34Z\"\n          },\n          {\n            \"id\": \"dd32963c-0db5-4303-a6fc-22c5229dabef\",\n            \"folder_parent_id\": \"6a3f21fc-aa40-4ba9-852c-7477fdd0310d\",\n            \"name\": \"SECRET1\",\n            \"username\": \"\",\n            \"uri\": \"\",\n            \"password\": \"secret1\",\n            \"description\": \"\",\n            \"created_timestamp\": \"2025-02-21T06:04:23Z\",\n            \"modified_timestamp\": \"2025-02-21T06:04:23Z\"\n          }\n        ]\n      JSON\n\n    json = JSON.parse(\n      run_command(\"fetch\", \"my-project/subfolder/SECRET1\", \"my-project/subfolder/FSECRET1\", \"my-project/subfolder/FSECRET2\")\n    )\n\n    expected_json = {\n      \"SECRET1\"=>\"secret1\",\n      \"FSECRET1\"=>\"fsecret1\",\n      \"FSECRET2\"=>\"fsecret2\"\n    }\n\n    assert_equal expected_json, json\n  end\n\n  test \"fetch without CLI installed\" do\n    stub_ticks_with(\"passbolt --version 2> /dev/null\", succeed: false)\n\n    error = assert_raises RuntimeError do\n      JSON.parse(run_command(\"fetch\", \"HOST\", \"PORT\"))\n    end\n\n    assert_equal \"Passbolt CLI is not installed\", error.message\n  end\n\n  test \"fetch with special characters in folder id\" do\n    stub_ticks_with(\"passbolt --version 2> /dev/null\", succeed: true)\n    stub_ticks_with(\"passbolt verify\", succeed: true)\n\n    stub_ticks.with(\"passbolt list folders --filter 'Name == \\\"my-project\\\"' --json\")\n      .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\"}]')\n\n    stub_ticks.with(\"passbolt list resources --filter '(Name == \\\"SECRET1\\\" && FolderParentID == \\\"abc\\\\\\\\ def-123\\\")' --folder abc\\\\ def-123 --json\")\n      .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\"}]')\n\n    json = JSON.parse(run_command(\"fetch\", \"my-project/SECRET1\"))\n\n    assert_equal({ \"SECRET1\"=>\"secret1\" }, json)\n  end\n\n  private\n    def run_command(*command)\n      stdouted do\n        Kamal::Cli::Secrets.start \\\n          [ *command,\n            \"-c\", \"test/fixtures/deploy_with_accessories.yml\",\n            \"--adapter\", \"passbolt\" ]\n      end\n    end\nend\n"
  },
  {
    "path": "test/secrets_test.rb",
    "content": "require \"test_helper\"\n\nclass SecretsTest < ActiveSupport::TestCase\n  test \"fetch\" do\n    with_test_secrets(\"secrets\" => \"SECRET=ABC\") do\n      assert_equal \"ABC\", Kamal::Secrets.new(secrets_path: \".kamal/secrets\")[\"SECRET\"]\n    end\n  end\n\n  test \"synchronized_fetch\" do\n    with_test_secrets(\"secrets\" => \"SECRET=ABC\") do\n      assert_equal \"ABC\", Kamal::Secrets.new(secrets_path: \".kamal/secrets\").send(:synchronized_fetch, \"SECRET\")\n    end\n  end\n\n  test \"key?\" do\n    with_test_secrets(\"secrets\" => \"SECRET1=ABC\") do\n      assert Kamal::Secrets.new(secrets_path: \".kamal/secrets\").key?(\"SECRET1\")\n      assert_not Kamal::Secrets.new(secrets_path: \".kamal/secrets\").key?(\"SECRET2\")\n    end\n  end\n\n  test \"command interpolation\" do\n    with_test_secrets(\"secrets\" => \"SECRET=$(echo ABC)\") do\n      assert_equal \"ABC\", Kamal::Secrets.new(secrets_path: \".kamal/secrets\")[\"SECRET\"]\n    end\n  end\n\n  test \"variable references\" do\n    with_test_secrets(\"secrets\" => \"SECRET1=ABC\\nSECRET2=${SECRET1}DEF\") do\n      assert_equal \"ABC\", Kamal::Secrets.new(secrets_path: \".kamal/secrets\")[\"SECRET1\"]\n      assert_equal \"ABCDEF\", Kamal::Secrets.new(secrets_path: \".kamal/secrets\")[\"SECRET2\"]\n    end\n  end\n\n  test \"env references\" do\n    with_test_secrets(\"secrets\" => \"SECRET1=$SECRET1\") do\n      ENV[\"SECRET1\"] = \"ABC\"\n      assert_equal \"ABC\", Kamal::Secrets.new(secrets_path: \".kamal/secrets\")[\"SECRET1\"]\n    end\n  end\n\n  test \"secrets file value overrides env\" do\n    with_test_secrets(\"secrets\" => \"SECRET1=DEF\") do\n      ENV[\"SECRET1\"] = \"ABC\"\n      assert_equal \"DEF\", Kamal::Secrets.new(secrets_path: \".kamal/secrets\")[\"SECRET1\"]\n    end\n  end\n\n  test \"destinations\" do\n    with_test_secrets(\"secrets.dest\" => \"SECRET=DEF\", \"secrets\" => \"SECRET=ABC\", \"secrets-common\" => \"SECRET=GHI\\nSECRET2=JKL\") do\n      assert_equal \"ABC\", Kamal::Secrets.new(secrets_path: \".kamal/secrets\")[\"SECRET\"]\n      assert_equal \"DEF\", Kamal::Secrets.new(secrets_path: \".kamal/secrets\", destination: \"dest\")[\"SECRET\"]\n      assert_equal \"GHI\", Kamal::Secrets.new(secrets_path: \".kamal/secrets\", destination: \"nodest\")[\"SECRET\"]\n\n      assert_equal \"JKL\", Kamal::Secrets.new(secrets_path: \".kamal/secrets\")[\"SECRET2\"]\n      assert_equal \"JKL\", Kamal::Secrets.new(secrets_path: \".kamal/secrets\", destination: \"dest\")[\"SECRET2\"]\n      assert_equal \"JKL\", Kamal::Secrets.new(secrets_path: \".kamal/secrets\", destination: \"nodest\")[\"SECRET2\"]\n    end\n  end\n\n  test \"no secrets files\" do\n    with_test_secrets do\n      error = assert_raises(Kamal::ConfigurationError) do\n        Kamal::Secrets.new(secrets_path: \".kamal/secrets\")[\"SECRET\"]\n      end\n      assert_equal \"Secret 'SECRET' not found, no secret files (.kamal/secrets-common, .kamal/secrets) provided\", error.message\n\n      error = assert_raises(Kamal::ConfigurationError) do\n        Kamal::Secrets.new(secrets_path: \".kamal/secrets\", destination: \"dest\")[\"SECRET\"]\n      end\n      assert_equal \"Secret 'SECRET' not found, no secret files (.kamal/secrets-common, .kamal/secrets.dest) provided\", error.message\n    end\n  end\n\n  test \"custom secrets_path\" do\n    Dir.mktmpdir do |tmpdir|\n      Dir.chdir(tmpdir) do\n        FileUtils.mkdir_p(\"custom/path\")\n        File.write(\"custom/path/secrets\", \"SECRET=CUSTOM\")\n\n        assert_equal \"CUSTOM\", Kamal::Secrets.new(secrets_path: \"custom/path/secrets\")[\"SECRET\"]\n      end\n    end\n  end\n\n  test \"custom secrets_path with destination\" do\n    Dir.mktmpdir do |tmpdir|\n      Dir.chdir(tmpdir) do\n        FileUtils.mkdir_p(\"custom/path\")\n        File.write(\"custom/path/secrets\", \"SECRET=BASE\")\n        File.write(\"custom/path/secrets.prod\", \"SECRET=PROD\")\n\n        assert_equal \"BASE\", Kamal::Secrets.new(secrets_path: \"custom/path/secrets\")[\"SECRET\"]\n        assert_equal \"PROD\", Kamal::Secrets.new(secrets_path: \"custom/path/secrets\", destination: \"prod\")[\"SECRET\"]\n      end\n    end\n  end\n\n  test \"custom secrets_path with common file\" do\n    Dir.mktmpdir do |tmpdir|\n      Dir.chdir(tmpdir) do\n        FileUtils.mkdir_p(\"custom/path\")\n        File.write(\"custom/path/secrets-common\", \"COMMON=SHARED\\nSECRET=COMMON\")\n        File.write(\"custom/path/secrets\", \"SECRET=OVERRIDE\")\n\n        secrets = Kamal::Secrets.new(secrets_path: \"custom/path/secrets\")\n        assert_equal \"SHARED\", secrets[\"COMMON\"]\n        assert_equal \"OVERRIDE\", secrets[\"SECRET\"]\n      end\n    end\n  end\n\n  test \"custom secrets_path error message\" do\n    Dir.mktmpdir do |tmpdir|\n      Dir.chdir(tmpdir) do\n        error = assert_raises(Kamal::ConfigurationError) do\n          Kamal::Secrets.new(secrets_path: \"custom/path/secrets\")[\"SECRET\"]\n        end\n        assert_equal \"Secret 'SECRET' not found, no secret files (custom/path/secrets-common, custom/path/secrets) provided\", error.message\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "test/sshkit_dns_retry_test.rb",
    "content": "require \"test_helper\"\n\nclass SshkitDnsRetryTest < ActiveSupport::TestCase\n  setup do\n    SSHKit::Backend::Netssh.configure { |config| config.dns_retries = 2 }\n    @previous_output = SSHKit.config.output\n    @log_io = StringIO.new\n    SSHKit.config.output = Logger.new(@log_io)\n  end\n\n  teardown do\n    SSHKit.config.output = @previous_output\n  end\n\n  test \"retries dns errors\" do\n    attempts = 0\n\n    result = SSHKit::Backend::Netssh.with_dns_retry(\"example.com\") do\n      attempts += 1\n      raise SocketError, \"getaddrinfo: Temporary failure in name resolution\" if attempts < 3\n      :ok\n    end\n\n    assert_equal 3, attempts\n    assert_equal :ok, result\n  end\n\n  test \"does not retry non dns errors\" do\n    attempts = 0\n\n    assert_raises Errno::ECONNREFUSED do\n      SSHKit::Backend::Netssh.with_dns_retry(\"example.com\") do\n        attempts += 1\n        raise Errno::ECONNREFUSED\n      end\n    end\n\n    assert_equal 1, attempts\n  end\n\n  test \"netssh backend retries dns errors when connecting\" do\n    host = SSHKit::Host.new(\"unknown.example.com\")\n    backend = SSHKit::Backend::Netssh.new(host)\n\n    SSHKit::Backend::Netssh.stubs(:sleep) # avoid actual backoff wait\n    Net::SSH.expects(:start).twice.raises(SocketError, \"getaddrinfo: nodename nor servname provided, or not known\").then.returns(:ok)\n\n    assert_equal :ok, backend.send(:connect_ssh, host.hostname, host.username, host.netssh_options)\n\n    assert_includes @log_io.string, \"Retrying DNS for #{host.hostname}\"\n  end\nend\n"
  },
  {
    "path": "test/test_helper.rb",
    "content": "require \"bundler/setup\"\nrequire \"active_support/test_case\"\nrequire \"active_support/testing/autorun\"\nrequire \"active_support/testing/stream\"\nrequire \"rails/test_unit/line_filtering\"\nrequire \"pty\"\nrequire \"debug\"\nrequire \"mocha/minitest\" # using #stubs that can alter returns\nrequire \"minitest/autorun\" # using #stub that take args\nrequire \"sshkit\"\nrequire \"kamal\"\n\nActiveSupport::LogSubscriber.logger = ActiveSupport::Logger.new(STDOUT) if ENV[\"VERBOSE\"]\n\n# Applies to remote commands only.\nSSHKit.config.backend = SSHKit::Backend::Printer\n\n# Disable connection pooling so we don't spawn the eviction thread as\n# there's no clean way to kill it after each test\nSSHKit::Backend::Netssh.pool = SSHKit::Backend::ConnectionPool.new(0)\n\nclass SSHKit::Backend::Printer\n  def upload!(local, location, **kwargs)\n    local = local.string.inspect if local.respond_to?(:string)\n    puts \"Uploading #{local} to #{location} on #{host}\"\n  end\nend\n\n# Ensure local commands use the printer backend too.\n# See https://github.com/capistrano/sshkit/blob/master/lib/sshkit/dsl.rb#L9\nmodule SSHKit\n  module DSL\n    def run_locally(&block)\n      SSHKit::Backend::Printer.new(SSHKit::Host.new(:local), &block).run\n    end\n  end\nend\n\nclass ActiveSupport::TestCase\n  include ActiveSupport::Testing::Stream\n  extend Rails::LineFiltering\n\n  private\n    def stdouted\n      capture(:stdout) { yield }.strip\n    end\n\n    def stderred\n      capture(:stderr) { yield }.strip\n    end\n\n    def stub_stdin_tty\n      PTY.open do |master, slave|\n        stub_stdin(master) { yield }\n      end\n    end\n\n    def stub_stdin_file\n      File.open(\"/dev/null\", \"r\") do |file|\n        stub_stdin(file) { yield }\n      end\n    end\n\n    def stub_stdin(io)\n      original_stdin = STDIN.dup\n      STDIN.reopen(io)\n      yield\n    ensure\n      STDIN.reopen(original_stdin)\n      original_stdin.close\n    end\n\n    def with_test_secrets(**files)\n      setup_test_secrets(**files)\n      yield\n    ensure\n      teardown_test_secrets\n    end\n\n    def setup_test_secrets(**files)\n      @original_pwd = Dir.pwd\n      @secrets_tmpdir = Dir.mktmpdir\n      copy_fixtures(@secrets_tmpdir)\n\n      Dir.chdir(@secrets_tmpdir)\n      FileUtils.mkdir_p(\".kamal\")\n      Dir.chdir(\".kamal\") do\n        files.each do |filename, contents|\n          File.binwrite(filename.to_s, contents)\n        end\n      end\n    end\n\n    def teardown_test_secrets\n      Dir.chdir(@original_pwd)\n      FileUtils.rm_rf(@secrets_tmpdir)\n    end\n\n    def with_error_pages(directory:)\n      error_pages_tmpdir = Dir.mktmpdir\n\n      Dir.mktmpdir do |tmpdir|\n        copy_fixtures(tmpdir)\n\n        Dir.chdir(tmpdir) do\n          FileUtils.mkdir_p(directory)\n          Dir.chdir(directory) do\n            File.write(\"404.html\", \"404 page\")\n            File.write(\"503.html\", \"503 page\")\n          end\n\n          yield\n        end\n      end\n    end\n\n    def copy_fixtures(to_dir)\n      new_test_dir = File.join(to_dir, \"test\")\n      FileUtils.mkdir_p(new_test_dir)\n      FileUtils.cp_r(\"test/fixtures/\", new_test_dir)\n    end\nend\n\nclass SecretAdapterTestCase < ActiveSupport::TestCase\n  setup do\n    `true` # Ensure $? is 0\n  end\n\n  private\n    def stub_ticks\n      Kamal::Secrets::Adapters::Base.any_instance.stubs(:`)\n    end\n\n    def stub_ticks_with(command, succeed: true)\n      # Sneakily run `false`/`true` after a match to set $? to 1/0\n      stub_ticks.with { |c| c == command && (succeed ? `true` : `false`) }\n      Kamal::Secrets::Adapters::Base.any_instance.stubs(:`)\n    end\nend\n"
  },
  {
    "path": "test/utils_test.rb",
    "content": "require \"test_helper\"\n\nclass UtilsTest < ActiveSupport::TestCase\n  test \"argumentize\" do\n    assert_equal [ \"--label\", \"foo=\\\"\\\\`bar\\\\`\\\"\", \"--label\", \"baz=\\\"qux\\\"\", \"--label\", :quux, \"--label\", \"quuz=false\" ], \\\n      Kamal::Utils.argumentize(\"--label\", { foo: \"`bar`\", baz: \"qux\", quux: nil, quuz: false })\n  end\n\n  test \"argumentize with redacted\" do\n    assert_kind_of SSHKit::Redaction, \\\n      Kamal::Utils.argumentize(\"--label\", { foo: \"bar\" }, sensitive: true).last\n  end\n\n  test \"optionize\" do\n    assert_equal [ \"--foo\", \"\\\"bar\\\"\", \"--baz\", \"\\\"qux\\\"\", \"--quux\" ], \\\n      Kamal::Utils.optionize({ foo: \"bar\", baz: \"qux\", quux: true })\n  end\n\n  test \"optionize with\" do\n    assert_equal [ \"--foo=\\\"bar\\\"\", \"--baz=\\\"qux\\\"\", \"--quux\" ], \\\n      Kamal::Utils.optionize({ foo: \"bar\", baz: \"qux\", quux: true }, with: \"=\")\n  end\n\n  test \"no redaction from #to_s\" do\n    assert_equal \"secret\", Kamal::Utils.sensitive(\"secret\").to_s\n  end\n\n  test \"redact from #inspect\" do\n    assert_equal \"[REDACTED]\".inspect, Kamal::Utils.sensitive(\"secret\").inspect\n  end\n\n  test \"redact from SSHKit output\" do\n    assert_kind_of SSHKit::Redaction, Kamal::Utils.sensitive(\"secret\")\n  end\n\n  test \"redact from YAML output\" do\n    assert_equal \"--- ! '[REDACTED]'\\n\", YAML.dump(Kamal::Utils.sensitive(\"secret\"))\n  end\n\n  test \"escape_shell_value\" do\n    assert_equal \"\\\"foo\\\"\", Kamal::Utils.escape_shell_value(\"foo\")\n    assert_equal \"\\\"\\\\`foo\\\\`\\\"\", Kamal::Utils.escape_shell_value(\"`foo`\")\n\n    assert_equal \"\\\"${PWD}\\\"\", Kamal::Utils.escape_shell_value(\"${PWD}\")\n    assert_equal \"\\\"${cat /etc/hostname}\\\"\", Kamal::Utils.escape_shell_value(\"${cat /etc/hostname}\")\n    assert_equal \"\\\"\\\\${PWD]\\\"\", Kamal::Utils.escape_shell_value(\"${PWD]\")\n    assert_equal \"\\\"\\\\$(PWD)\\\"\", Kamal::Utils.escape_shell_value(\"$(PWD)\")\n    assert_equal \"\\\"\\\\$PWD\\\"\", Kamal::Utils.escape_shell_value(\"$PWD\")\n\n    assert_equal \"\\\"^(https?://)www.example.com/(.*)\\\\$\\\"\",\n      Kamal::Utils.escape_shell_value(\"^(https?://)www.example.com/(.*)$\")\n    assert_equal \"\\\"https://example.com/\\\\$2\\\"\",\n      Kamal::Utils.escape_shell_value(\"https://example.com/$2\")\n  end\nend\n"
  }
]