├── .github ├── dependabot.yml ├── release-drafter.yml └── workflows │ ├── ci.yml │ ├── deploy.yml │ └── push.yml ├── .gitignore ├── .kodiak.toml ├── .overcommit.yml ├── .prettierignore ├── .rubocop.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console ├── mt └── setup ├── custom_theme ├── breadcrumbs.html └── main.html ├── docs ├── CNAME ├── api │ ├── Host.md │ ├── Logger.md │ ├── Paths.md │ ├── PluginDSL.md │ ├── Remote.md │ ├── Result.md │ ├── TaskLibrary.md │ └── testing │ │ └── MockPluginTester.md ├── commands │ ├── deploy.md │ ├── init.md │ ├── run.md │ ├── setup.md │ └── tasks.md ├── comparisons.md ├── configuration.md ├── css │ └── extra.css ├── include │ ├── common_options.md.include │ ├── deploy_options.md.include │ └── project_options.md.include ├── index.md ├── js │ └── extra.js ├── plugins │ ├── bundler.md │ ├── core.md │ ├── env.md │ ├── git.md │ ├── nodenv.md │ ├── puma.md │ ├── rails.md │ └── rbenv.md └── tutorials │ ├── create-new-repo@2x.png │ ├── deploying-rails-from-scratch.md │ ├── publishing-a-plugin.md │ ├── smallest-viable-droplet@2x.png │ ├── ssh-auth@2x.png │ ├── ubuntu-20-lts@2x.png │ └── writing-custom-tasks.md ├── exe └── tomo ├── lib ├── tomo.rb └── tomo │ ├── cli.rb │ ├── cli │ ├── command.rb │ ├── common_options.rb │ ├── completions.rb │ ├── deploy_options.rb │ ├── error.rb │ ├── interrupted_error.rb │ ├── options.rb │ ├── parser.rb │ ├── project_options.rb │ ├── rules.rb │ ├── rules │ │ ├── argument.rb │ │ ├── switch.rb │ │ └── value_switch.rb │ ├── rules_evaluator.rb │ ├── state.rb │ └── usage.rb │ ├── colors.rb │ ├── commands.rb │ ├── commands │ ├── completion_script.rb │ ├── default.rb │ ├── deploy.rb │ ├── help.rb │ ├── init.rb │ ├── run.rb │ ├── setup.rb │ ├── tasks.rb │ └── version.rb │ ├── configuration.rb │ ├── configuration │ ├── dsl.rb │ ├── dsl │ │ ├── batch_block.rb │ │ ├── config_file.rb │ │ ├── environment_block.rb │ │ ├── error_formatter.rb │ │ ├── hosts_and_settings.rb │ │ └── tasks_block.rb │ ├── environment.rb │ ├── glob.rb │ ├── plugin_file_not_found_error.rb │ ├── plugins_registry.rb │ ├── plugins_registry │ │ ├── file_resolver.rb │ │ └── gem_resolver.rb │ ├── project_not_found_error.rb │ ├── role_based_task_filter.rb │ ├── unknown_environment_error.rb │ ├── unknown_plugin_error.rb │ └── unspecified_environment_error.rb │ ├── console.rb │ ├── console │ ├── key_reader.rb │ ├── menu.rb │ └── non_interactive_error.rb │ ├── error.rb │ ├── error │ └── suggestions.rb │ ├── host.rb │ ├── logger.rb │ ├── logger │ └── tagged_io.rb │ ├── path.rb │ ├── paths.rb │ ├── plugin.rb │ ├── plugin │ ├── bundler.rb │ ├── bundler │ │ ├── helpers.rb │ │ └── tasks.rb │ ├── core.rb │ ├── core │ │ ├── helpers.rb │ │ └── tasks.rb │ ├── env.rb │ ├── env │ │ └── tasks.rb │ ├── git.rb │ ├── git │ │ ├── helpers.rb │ │ └── tasks.rb │ ├── nodenv.rb │ ├── nodenv │ │ └── tasks.rb │ ├── puma.rb │ ├── puma │ │ ├── systemd │ │ │ ├── service.erb │ │ │ └── socket.erb │ │ └── tasks.rb │ ├── rails.rb │ ├── rails │ │ ├── helpers.rb │ │ └── tasks.rb │ ├── rbenv.rb │ ├── rbenv │ │ └── tasks.rb │ └── testing.rb │ ├── plugin_dsl.rb │ ├── remote.rb │ ├── result.rb │ ├── runtime.rb │ ├── runtime │ ├── concurrent_ruby_load_error.rb │ ├── concurrent_ruby_thread_pool.rb │ ├── context.rb │ ├── current.rb │ ├── execution_plan.rb │ ├── explanation.rb │ ├── host_execution_step.rb │ ├── inline_thread_pool.rb │ ├── no_tasks_error.rb │ ├── privileged_task.rb │ ├── settings_interpolation.rb │ ├── settings_required_error.rb │ ├── task_aborted_error.rb │ ├── task_runner.rb │ ├── template_not_found_error.rb │ └── unknown_task_error.rb │ ├── script.rb │ ├── shell_builder.rb │ ├── ssh.rb │ ├── ssh │ ├── child_process.rb │ ├── connection.rb │ ├── connection_error.rb │ ├── connection_validator.rb │ ├── error.rb │ ├── executable_error.rb │ ├── options.rb │ ├── permission_error.rb │ ├── script_error.rb │ ├── unknown_error.rb │ └── unsupported_version_error.rb │ ├── task_api.rb │ ├── task_library.rb │ ├── templates │ ├── config.rb.erb │ └── plugin.rb.erb │ ├── testing.rb │ ├── testing │ ├── Dockerfile │ ├── cli_extensions.rb │ ├── cli_tester.rb │ ├── connection.rb │ ├── docker_image.rb │ ├── host_extensions.rb │ ├── local.rb │ ├── log_capturing.rb │ ├── mock_plugin_tester.rb │ ├── mocked_exec_error.rb │ ├── mocked_exit_error.rb │ ├── remote_extensions.rb │ ├── ssh_extensions.rb │ ├── systemctl.rb │ ├── tomo_test_ed25519 │ ├── tomo_test_ed25519.pub │ └── ubuntu_setup.sh │ └── version.rb ├── mkdocs.yml ├── readme_images ├── README.md ├── console.css ├── console.js ├── record_console.rb ├── tomo-deploy-dry-run.png ├── tomo-deploy-help.png ├── tomo-help.png ├── tomo-init.png ├── tomo-run-hello-dry-run.png ├── tomo-run-hello.png ├── tomo-run-rails-console-dry-run.png ├── tomo-run-rails-console.png ├── tomo-setup-dry-run.png ├── tomo-setup-help.png └── tomo-tasks.png ├── requirements.txt ├── test ├── e2e │ └── rails_setup_deploy_e2e_test.rb ├── fixtures │ └── template.erb ├── test_helper.rb └── tomo │ ├── cli │ └── completions_test.rb │ ├── cli_test.rb │ ├── colors_test.rb │ ├── commands │ └── init_test.rb │ ├── configuration_test.rb │ ├── console │ └── key_reader_test.rb │ ├── console_test.rb │ ├── host_test.rb │ ├── paths_test.rb │ ├── plugin │ ├── bundler │ │ └── tasks_test.rb │ ├── core │ │ ├── helpers_test.rb │ │ └── tasks_test.rb │ ├── env │ │ └── tasks_test.rb │ ├── git │ │ └── tasks_test.rb │ ├── nodenv │ │ └── tasks_test.rb │ ├── puma │ │ └── tasks_test.rb │ ├── rails │ │ ├── helpers_test.rb │ │ └── tasks_test.rb │ └── rbenv │ │ └── tasks_test.rb │ ├── runtime │ ├── execution_plan_test.rb │ └── settings_interpolation_test.rb │ ├── runtime_test.rb │ ├── shell_builder_test.rb │ └── task_api_test.rb └── tomo.gemspec /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | time: "16:00" 8 | timezone: America/Los_Angeles 9 | open-pull-requests-limit: 10 10 | labels: 11 | - "🏠 Housekeeping" 12 | - package-ecosystem: github-actions 13 | directory: "/" 14 | schedule: 15 | interval: monthly 16 | time: "16:00" 17 | timezone: America/Los_Angeles 18 | open-pull-requests-limit: 10 19 | labels: 20 | - "🏠 Housekeeping" 21 | - package-ecosystem: pip 22 | directory: "/" 23 | schedule: 24 | interval: monthly 25 | time: "16:00" 26 | timezone: America/Los_Angeles 27 | open-pull-requests-limit: 10 28 | labels: 29 | - "🏠 Housekeeping" 30 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: "$RESOLVED_VERSION" 2 | tag-template: "v$RESOLVED_VERSION" 3 | categories: 4 | - title: "⚠️ Breaking Changes" 5 | label: "⚠️ Breaking" 6 | - title: "✨ New Features" 7 | label: "✨ Feature" 8 | - title: "🐛 Bug Fixes" 9 | label: "🐛 Bug Fix" 10 | - title: "📚 Documentation" 11 | label: "📚 Docs" 12 | - title: "🏠 Housekeeping" 13 | label: "🏠 Housekeeping" 14 | version-resolver: 15 | minor: 16 | labels: 17 | - "⚠️ Breaking" 18 | - "✨ Feature" 19 | default: patch 20 | change-template: "- $TITLE (#$NUMBER) @$AUTHOR" 21 | no-changes-template: "- No changes" 22 | template: | 23 | $CHANGES 24 | 25 | **Full Changelog:** https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | build_docs: 9 | name: "Build Docs" 10 | runs-on: ubuntu-latest 11 | if: ${{ github.ref != 'refs/heads/main' }} 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-python@v5 15 | with: 16 | python-version: "3.10" 17 | cache: "pip" 18 | - run: pip install -r requirements.txt 19 | - run: mkdocs build 20 | rubocop: 21 | name: "Rubocop" 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: ruby/setup-ruby@v1 26 | with: 27 | ruby-version: "ruby" 28 | bundler-cache: true 29 | - run: bundle exec rubocop 30 | test: 31 | name: "Test / Ruby ${{ matrix.ruby }}" 32 | runs-on: ubuntu-latest 33 | strategy: 34 | matrix: 35 | ruby: ["3.1", "3.2", "3.3", "3.4", "head"] 36 | steps: 37 | - uses: actions/checkout@v4 38 | - uses: ruby/setup-ruby@v1 39 | with: 40 | ruby-version: ${{ matrix.ruby }} 41 | bundler-cache: true 42 | - run: git config --global user.name 'github-actions[bot]' 43 | - run: git config --global user.email 'github-actions[bot]@users.noreply.github.com' 44 | - run: bundle exec rake test 45 | test_rails_deploy: 46 | name: "Test / Rails Deploy" 47 | runs-on: ubuntu-latest 48 | needs: [rubocop, test] 49 | steps: 50 | - uses: actions/checkout@v4 51 | - uses: ruby/setup-ruby@v1 52 | with: 53 | ruby-version: "ruby" 54 | bundler-cache: true 55 | - run: bundle exec rake test:e2e 56 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | docs: 8 | name: "Docs" 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 0 14 | - uses: actions/setup-python@v5 15 | with: 16 | python-version: "3.10" 17 | cache: "pip" 18 | - run: pip install -r requirements.txt 19 | - run: cat README.md >> docs/index.md 20 | - run: cp -R readme_images docs/ 21 | - run: git config --local user.name 'github-actions[bot]' 22 | - run: git config --local user.email 'github-actions[bot]@users.noreply.github.com' 23 | - run: mkdocs gh-deploy -m 'Deployed {sha} with mkdocs {version} [ci skip]' 24 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | update_release_draft: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: release-drafter/release-drafter@v6 13 | env: 14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.tomo/ 3 | /.yardoc 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /site/ 9 | /spec/reports/ 10 | /tmp/ 11 | /Gemfile.lock 12 | .python-version 13 | -------------------------------------------------------------------------------- /.kodiak.toml: -------------------------------------------------------------------------------- 1 | # .kodiak.toml 2 | # Minimal config. version is the only required field. 3 | version = 1 4 | 5 | [merge.automerge_dependencies] 6 | # auto merge all PRs opened by "dependabot" that are "minor" or "patch" version upgrades. "major" version upgrades will be ignored. 7 | versions = ["minor", "patch"] 8 | usernames = ["dependabot"] 9 | 10 | # if using `update.always`, add dependabot to `update.ignore_usernames` to allow 11 | # dependabot to update and close stale dependency upgrades. 12 | [update] 13 | ignored_usernames = ["dependabot"] 14 | -------------------------------------------------------------------------------- /.overcommit.yml: -------------------------------------------------------------------------------- 1 | # Overcommit hooks run automatically on certain git operations, like "git commit". 2 | # For a complete list of options that you can use to customize hooks, see: 3 | # https://github.com/sds/overcommit 4 | 5 | gemfile: false 6 | verify_signatures: false 7 | 8 | PreCommit: 9 | BundleCheck: 10 | enabled: true 11 | 12 | FixMe: 13 | enabled: true 14 | keywords: ["FIXME"] 15 | exclude: 16 | - .overcommit.yml 17 | - lib/tomo/templates/config.rb.erb 18 | 19 | LocalPathsInGemfile: 20 | enabled: true 21 | 22 | RuboCop: 23 | enabled: true 24 | required_executable: bundle 25 | command: ["bundle", "exec", "rubocop"] 26 | on_warn: fail 27 | 28 | YamlSyntax: 29 | enabled: true 30 | 31 | PostCheckout: 32 | ALL: 33 | quiet: true 34 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /CODE_OF_CONDUCT.md 2 | /custom_theme/*.html 3 | /docs/commands/*.md 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - rubocop-minitest 3 | - rubocop-packaging 4 | - rubocop-performance 5 | - rubocop-rake 6 | - rubocop-md 7 | 8 | AllCops: 9 | DisplayCopNames: true 10 | DisplayStyleGuide: true 11 | NewCops: enable 12 | TargetRubyVersion: 3.1 13 | Exclude: 14 | - "bin/**/*" 15 | - "lib/tomo/templates/config.rb" 16 | - "readme_images/**/*" 17 | - "tmp/**/*" 18 | - "vendor/**/*" 19 | 20 | Layout/FirstArrayElementIndentation: 21 | EnforcedStyle: consistent 22 | 23 | Layout/FirstArrayElementLineBreak: 24 | Enabled: true 25 | 26 | Layout/FirstHashElementLineBreak: 27 | Enabled: true 28 | 29 | Layout/FirstMethodArgumentLineBreak: 30 | Enabled: true 31 | 32 | Layout/HashAlignment: 33 | EnforcedColonStyle: 34 | - table 35 | - key 36 | EnforcedHashRocketStyle: 37 | - table 38 | - key 39 | 40 | Layout/MultilineArrayLineBreaks: 41 | Enabled: true 42 | 43 | Layout/MultilineHashKeyLineBreaks: 44 | Enabled: true 45 | 46 | Layout/MultilineMethodArgumentLineBreaks: 47 | Enabled: true 48 | 49 | Layout/MultilineMethodCallIndentation: 50 | EnforcedStyle: indented 51 | 52 | Layout/SpaceAroundEqualsInParameterDefault: 53 | EnforcedStyle: no_space 54 | 55 | Metrics/AbcSize: 56 | Max: 20 57 | Exclude: 58 | - "test/**/*" 59 | 60 | Metrics/BlockLength: 61 | Exclude: 62 | - "*.gemspec" 63 | - "Rakefile" 64 | 65 | Metrics/ClassLength: 66 | Exclude: 67 | - "test/**/*" 68 | 69 | Metrics/MethodLength: 70 | Max: 12 71 | CountAsOne: ["heredoc"] 72 | Exclude: 73 | - "test/**/*" 74 | 75 | Metrics/ParameterLists: 76 | Max: 6 77 | 78 | Minitest/EmptyLineBeforeAssertionMethods: 79 | Enabled: false 80 | 81 | Minitest/MultipleAssertions: 82 | Max: 5 83 | 84 | Minitest/TestFileName: 85 | Exclude: 86 | - "**/*.md" 87 | 88 | Naming/MemoizedInstanceVariableName: 89 | Enabled: false 90 | 91 | Naming/VariableNumber: 92 | Enabled: false 93 | 94 | Rake/Desc: 95 | Enabled: false 96 | 97 | Style/BarePercentLiterals: 98 | EnforcedStyle: percent_q 99 | 100 | Style/ClassAndModuleChildren: 101 | Enabled: false 102 | 103 | Style/Documentation: 104 | Enabled: false 105 | 106 | Style/DoubleNegation: 107 | Enabled: false 108 | 109 | Style/EmptyMethod: 110 | Enabled: false 111 | 112 | Style/FetchEnvVar: 113 | Enabled: false 114 | 115 | Style/FormatStringToken: 116 | Enabled: false 117 | 118 | Style/NumericLiterals: 119 | Enabled: false 120 | 121 | Style/NumericPredicate: 122 | Enabled: false 123 | 124 | Style/StringConcatenation: 125 | Enabled: false 126 | 127 | Style/StringLiterals: 128 | EnforcedStyle: double_quotes 129 | 130 | Style/TrivialAccessors: 131 | AllowPredicates: true 132 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Release notes for this project are kept here: https://github.com/mattbrictson/tomo/releases 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guide 2 | 3 | Tomo wouldn't be possible without the generosity of the open source community. Thank you for your input and support! Here are some guidelines to follow when contributing. 4 | 5 | ## 🐛 Bug reports 6 | 7 | - Explain the troubleshooting steps you've already tried 8 | - Use `--debug` and `--trace` to provide additional detail 9 | - Use GitHub-flavored Markdown, especially code fences ``` to format logs 10 | - Redact any sensitive information 11 | - Include reproduction steps or code for a failing test case if you can 12 | 13 | ## ✨ Feature requests 14 | 15 | Ideas for new tomo features are appreciated! 16 | 17 | > The current guiding principle for tomo is to be a "batteries included" tool that encapsulates best practices for deploying Rails apps to inexpensive cloud hosting infrastructure. In considering feature requests, weight will be given to keeping tomo simple to use and simple to maintain, even if that means that certain edge cases and deployment scenarios are unsupported. 18 | 19 | - Show examples of how the feature would work 20 | - Explain your motivation for requesting the feature 21 | - Would it be useful for the majority of tomo users? 22 | - Is it a breaking change? 23 | 24 | ## ⤴️ Pull requests 25 | 26 | > Protip: If you have a big change in mind, it is a good idea to open an issue first to propose the idea and get some initial feedback. 27 | 28 | ### Working on code 29 | 30 | - Run `bundle install` to install dependencies 31 | - `bin/console` opens an irb console if you need a REPL to try things out 32 | - `bundle exec tomo` will run your working copy of tomo 33 | - `rake install` will install your working copy of tomo globally (so you can test it in other projects) 34 | - Make sure to run `rake` to run all tests and RuboCop checks prior to opening a PR 35 | 36 | ### Working on docs 37 | 38 | - You will need python to build the docs 39 | - Run `pip install -r requirements.txt` to install the relevant python packages 40 | - Start the docs server by running `mkdocs serve` 41 | - Browse the rendered docs at 42 | 43 | Note that the home page will be blank when running locally. When deployed, the home page will contain the contents of `README.md`. 44 | 45 | ### PR guidelines 46 | 47 | - Give the PR a concise and descriptive title that completes this sentence: _If this PR is merged, it will [TITLE]_ 48 | - If the PR fixes an open issue, link to the issue in the description 49 | - Provide a description that ideally answers these questions: 50 | - Why is this change needed? What problem(s) does it solve? 51 | - Were there alternative solutions that you considered? 52 | - How has it been tested? 53 | - Is it a breaking change? 54 | - Does the documentation need to be updated? 55 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | gemspec 5 | 6 | gem "concurrent-ruby", "~> 1.1" 7 | gem "mighty_test", "~> 0.4.1" unless RUBY_VERSION < "3.1" 8 | gem "minitest", "~> 5.11" 9 | gem "rake", "~> 13.0" 10 | gem "rubocop", "1.75.8" 11 | gem "rubocop-md", "2.0.1" 12 | gem "rubocop-minitest", "0.38.1" 13 | gem "rubocop-packaging", "0.6.0" 14 | gem "rubocop-performance", "1.25.0" 15 | gem "rubocop-rake", "0.7.1" 16 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Matt Brictson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | gem "bundler", "~> 2.0" 4 | require "bundler/setup" 5 | require "tomo" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/mt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'mt' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("mighty_test", "mt") 28 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | which overcommit > /dev/null 2>&1 && overcommit --install 7 | bundle install 8 | 9 | # Do any other automated setup that you need to do here 10 | -------------------------------------------------------------------------------- /custom_theme/breadcrumbs.html: -------------------------------------------------------------------------------- 1 |
2 | 28 | {% if config.theme.prev_next_buttons_location|lower in ['top', 'both'] 29 | and page and (page.next_page or page.previous_page) %} 30 | 38 | {% endif %} 39 |
40 |
41 | -------------------------------------------------------------------------------- /custom_theme/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block extrahead %} 4 | 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | tomo.mattbrictson.com 2 | -------------------------------------------------------------------------------- /docs/api/Host.md: -------------------------------------------------------------------------------- 1 | # Tomo::Host 2 | 3 | Represents a remote SSH host. 4 | 5 | ```ruby 6 | host.address # => "example.com" 7 | host.port # => 22 8 | host.user # => "deployer" 9 | host.roles # => ["app", "db"] 10 | host.to_s # => "deployer@example.com" 11 | ``` 12 | 13 | A Host is always frozen and cannot be modified. 14 | 15 | ## Instance methods 16 | 17 | ### address → String 18 | 19 | The host name or IP address. 20 | 21 | ### port → Integer 22 | 23 | The SSH port, usually 22. 24 | 25 | ### user → String 26 | 27 | The username used when connecting to the host via SSH. 28 | 29 | ### roles → [String] 30 | 31 | An array of roles that are assigned to this host. Roles are used in multi-host deployments to control which tasks are run on which hosts. 32 | 33 | ### to_s → String 34 | 35 | A representation of host in the form of `user@address:port`. If the port is 22, that portion is omitted. 36 | -------------------------------------------------------------------------------- /docs/api/Logger.md: -------------------------------------------------------------------------------- 1 | # Tomo::Logger 2 | 3 | Provides a simple interface for logging messages to stdout and stderr. In multi-host deployments, messages are automatically prefixed with `[1]`, `[2]`, etc. based on current host. This makes it easier to distinguish where log messages are coming from. 4 | 5 | ``` 6 | $ tomo run bundler:clean 7 | tomo run v1.0.0 8 | [1] → Connecting to deployer@web1.example.com 9 | [2] → Connecting to deployer@web2.example.com 10 | [1] • bundler:clean 11 | [2] • bundler:clean 12 | [1] cd /home/deployer/apps/my-app/current && bundle clean 13 | [2] cd /home/deployer/apps/my-app/current && bundle clean 14 | ✔ Ran bundler:clean on deployer@web1.example.com and deployer@web2.example.com 15 | ``` 16 | 17 | If tomo is run in `--dry-run` mode, log messages are prefixed with a `*` to indicate the commands are being simulated. 18 | 19 | ``` 20 | $ tomo run bundler:clean --dry-run 21 | tomo run v1.0.0 22 | * [1] → Connecting to deployer@web1.example.com 23 | * [2] → Connecting to deployer@web2.example.com 24 | * [1] • bundler:clean 25 | * [2] • bundler:clean 26 | * [1] cd /home/deployer/apps/my-app/current && bundle clean 27 | * [2] cd /home/deployer/apps/my-app/current && bundle clean 28 | * Simulated bundler:clean on deployer@web1.example.com and deployer@web2.example.com (dry run) 29 | ``` 30 | 31 | ## Instance methods 32 | 33 | ### debug(message) → nil 34 | 35 | Prints a message to _stderr_ in gray with a `DEBUG:` prefix. Debug messages are only shown if tomo is run with the `--debug` option. Otherwise this is a no-op. 36 | 37 | ### info(message) → nil 38 | 39 | Prints a message to _stdout_. 40 | 41 | ### warn(message) → nil 42 | 43 | Prints a message to _stderr_ with a red `WARNING:` prefix. 44 | 45 | ### error(message) → nil 46 | 47 | Prints a message to _stderr_ with a red `ERROR:` prefix, indented, and with leading and trailing blank lines for extra emphasis. 48 | -------------------------------------------------------------------------------- /docs/api/Paths.md: -------------------------------------------------------------------------------- 1 | # Tomo::Paths 2 | 3 | Provides syntactic sugar for accessing settings that represent file system paths. For every tomo setting in the form `:_path`, Paths will expose a method of that name that behaves like a Ruby Pathname object. As a special exception, the `:deploy_to` setting is also exposed even though it does not follow the same naming convention. 4 | 5 | In tomo the following path settings are always available: 6 | 7 | ```ruby 8 | settings[:deploy_to] # => "/var/www/my-app" 9 | settings[:current_path] # => "/var/www/my-app/current" 10 | settings[:release_path] # => "/var/www/my-app/releases/20190531164322" 11 | settings[:releases_path] # => "/var/www/my-app/releases" 12 | settings[:shared_path] # => "/var/www/my-app/shared" 13 | ``` 14 | 15 | Using Paths, these same settings can be accessed like this: 16 | 17 | ```ruby 18 | paths.deploy_to # => "/var/www/my-app" 19 | paths.current # => "/var/www/my-app/current" 20 | paths.release # => "/var/www/my-app/releases/20190531164322" 21 | paths.releases # => "/var/www/my-app/releases" 22 | paths.shared # => "/var/www/my-app/shared" 23 | ``` 24 | 25 | More powerfully, the values returned by Paths respond to `join` and `dirname`, so you can easily compose them: 26 | 27 | ```ruby 28 | paths.current.dirname # => "/var/www/my-app" 29 | paths.release.join("tmp") # => "/var/www/my-app/releases/20190531164322/tmp" 30 | paths.shared.join("bundle") # => "/var/www/my-app/shared/bundle" 31 | ``` 32 | 33 | Paths can be used wherever a path string is expected, like [chdir](Remote.md#chdirdir-block-obj): 34 | 35 | ```ruby 36 | remote.chdir(paths.current) do 37 | remote.run("bundle", "exec", "puma", "--daemon") 38 | end 39 | # $ cd /var/www/my-app/current && bundle exec puma --daemon 40 | ``` 41 | 42 | If a plugin defines a setting with the suffix `_path` or if you create your own setting with that suffix, it automatically will be exposed via the Paths object: 43 | 44 | ```ruby 45 | # .tomo/config.rb 46 | set my_custom_path: "/opt/custom" 47 | ``` 48 | 49 | ```ruby 50 | paths.my_custom.join("var") # => "/opt/custom/var" 51 | ``` 52 | -------------------------------------------------------------------------------- /docs/api/Result.md: -------------------------------------------------------------------------------- 1 | # Tomo::Result 2 | 3 | Represents the result of a remote SSH script. 4 | 5 | ```ruby 6 | result = remote.run("echo", "hello world") 7 | result.success? # => true 8 | result.failure? # => false 9 | result.exit_status # => 0 10 | result.stdout # => "hello world\n" 11 | result.stderr # => "" 12 | result.output # => "hello world\n" 13 | ``` 14 | 15 | A Result is always frozen and cannot be modified. 16 | 17 | ## Instance methods 18 | 19 | ### success? → true or false 20 | 21 | Whether the remote SSH script executed successfully. An exit status of 0 is considered success. 22 | 23 | ### failure? → true or false 24 | 25 | Whether the remote SSH script failed to execute. An non-zero exit status is considered a failure. 26 | 27 | ### exit_status → Integer 28 | 29 | The exit status returned by the remote SSH script. A status of 0 is considered success. 30 | 31 | ### stdout → String 32 | 33 | All data that was written to stdout by the remote SSH script. Empty string if nothing was written. 34 | 35 | ### stderr → String 36 | 37 | All data that was written to stderr by the remote SSH script. Empty string if nothing was written. 38 | 39 | ### output → String 40 | 41 | All data that was written by the remote SSH script: stdout and stderr combined, in that order. Empty string if nothing was written. 42 | -------------------------------------------------------------------------------- /docs/commands/init.md: -------------------------------------------------------------------------------- 1 | # init 2 | 3 | Start a new tomo project with a sample config. 4 | 5 | ## Usage 6 | 7 | ```sh 8 | $ tomo init [APP] 9 | ``` 10 | 11 | Set up a new tomo project named `APP`. If `APP` is not specified, the name of the current directory will be used. This command creates a `.tomo/config.rb` file relative the current directory containing some example configuration. Refer to [Configuration](../configuration.md) for a detailed explanation of this file. 12 | 13 | `tomo init` will make educated guesses about your project and fill in some configuration settings for you: 14 | 15 | - `nodenv_node_version` based on `node --version` 16 | - `nodenv_install_yarn` based on whether `yarn` is present 17 | - `git_url` based on metadata in `.git/` for this project, if present 18 | - `rbenv_ruby_version` based on the version of Ruby being used to run tomo 19 | 20 | ## Options 21 | 22 | | Option | Purpose | 23 | | ------ | ------- | 24 | {!common_options.md.include!} 25 | 26 | ## Example 27 | 28 | ```plain 29 | $ cd my-rails-app 30 | $ tomo init 31 | ✔ Created .tomo/config.rb 32 | ``` 33 | -------------------------------------------------------------------------------- /docs/commands/run.md: -------------------------------------------------------------------------------- 1 | # run 2 | 3 | Run a specific remote task from the current project. 4 | 5 | ## Usage 6 | 7 | ```plain 8 | $ tomo run [--dry-run] [options] [--] TASK [ARGS...] 9 | ``` 10 | 11 | Remotely run one specified TASK, optionally passing ARGS to that task. For example, if this project uses the [rails plugin](../plugins/rails.md), you could run: 12 | 13 | ```plain 14 | $ tomo run -- rails:console --sandbox 15 | ``` 16 | 17 | This will run the [rails:console](../plugins/rails.md#railsconsole) task on the host specified in `.tomo/config.rb` [configuration file](../configuration.md), and will pass the `--sandbox` argument to that task. The `--` is used to separate tomo options from options that are passed to the task. If a task does not accept options, the `--` can be omitted, like this: 18 | 19 | ```plain 20 | $ tomo run core:clean_releases 21 | ``` 22 | 23 | When you specify a task name, the `run` command is implied and can be omitted, so this works as well: 24 | 25 | ```plain 26 | $ tomo core:clean_releases 27 | ``` 28 | 29 | You can run any task defined by plugins loaded by the [plugin](../configuration.md#pluginname_or_relative_path) declarations in `.tomo/config.rb`. To see a list of available tasks, run the [tasks](tasks.md) command. 30 | 31 | During the `run` command, tomo will initialize the `:release_path` setting to be equal to the current symlink (i.e. `/var/www/my-app/current`). This means that the task will run within the current release. 32 | 33 | ## Options 34 | 35 | | Option | Purpose | 36 | | ------ | ------- | 37 | | `--[no-]privileged` | Run the task using a privileged user (e.g. root). This user is configured [per host](../configuration.md#hostaddress-4242options).| 38 | {!deploy_options.md.include!} 39 | {!project_options.md.include!} 40 | {!common_options.md.include!} 41 | 42 | ## Example 43 | 44 | Given the following configuration: 45 | 46 | ```ruby 47 | plugin "puma" 48 | host "deployer@app.example.com" 49 | ``` 50 | 51 | Then we could run [puma:restart](../plugins/puma.md#pumarestart) like this: 52 | 53 | ```plain 54 | $ tomo run puma:restart 55 | tomo run v1.1.2 56 | → Connecting to deployer@app.example.com 57 | • puma:restart 58 | systemctl --user start puma_example.socket 59 | systemctl --user restart puma_example.service 60 | ✔ Ran puma:restart on deployer@app.example.com 61 | ``` 62 | -------------------------------------------------------------------------------- /docs/commands/tasks.md: -------------------------------------------------------------------------------- 1 | # tasks 2 | 3 | List all tasks that can be used with the [run](run.md) command. 4 | 5 | ## Usage 6 | 7 | ```sh 8 | $ tomo tasks 9 | ``` 10 | 11 | List all tomo tasks (i.e. those that can be used with [`tomo run`](run.md)). Available tasks are those defined by plugins loaded in `.tomo/config.rb`. Refer to the [Configuration](../configuration.md#pluginname_or_relative_path) guide for an explanation of how plugins are loaded. The reference documentation for each plugin (e.g. [core](../plugins/core.md), [git](../plugins/git.md)) describes the tasks these plugins provide. 12 | 13 | ## Options 14 | 15 | | Option | Purpose | 16 | | ------ | ------- | 17 | {!common_options.md.include!} 18 | 19 | ## Example 20 | 21 | ```plain 22 | $ tomo tasks 23 | bundler:clean 24 | bundler:install 25 | bundler:upgrade_bundler 26 | core:clean_releases 27 | core:log_revision 28 | core:setup_directories 29 | core:symlink_current 30 | core:symlink_shared 31 | core:write_release_json 32 | env:set 33 | env:setup 34 | env:show 35 | env:unset 36 | env:update 37 | git:clone 38 | git:create_release 39 | nodenv:install 40 | puma:restart 41 | rails:assets_precompile 42 | rails:console 43 | rails:db_create 44 | rails:db_migrate 45 | rails:db_schema_load 46 | rails:db_seed 47 | rails:db_setup 48 | rails:db_structure_load 49 | rbenv:install 50 | ``` 51 | -------------------------------------------------------------------------------- /docs/css/extra.css: -------------------------------------------------------------------------------- 1 | body, 2 | h1, 3 | h2, 4 | h3, 5 | h4, 6 | h5, 7 | h6, 8 | legend, 9 | .btn { 10 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, 11 | sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 12 | } 13 | 14 | code { 15 | border-radius: 2px; 16 | color: inherit; 17 | } 18 | 19 | code, 20 | .rst-content tt { 21 | font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, Courier, 22 | monospace; 23 | } 24 | 25 | pre { 26 | border-radius: 2px; 27 | margin-bottom: 22px; 28 | } 29 | 30 | pre + h2, 31 | pre + h3 { 32 | margin-top: 30px; 33 | } 34 | 35 | .rst-content pre > code, 36 | .hljs { 37 | line-height: 1.5; 38 | } 39 | 40 | table { 41 | border-radius: 2px; 42 | } 43 | 44 | .rst-content td > code { 45 | white-space: nowrap; 46 | } 47 | 48 | img[src$="ubuntu-20-lts%402x.png"] { 49 | box-sizing: content-box; 50 | border: 1px solid #ccc; 51 | border-radius: 2px; 52 | width: 305px; 53 | } 54 | 55 | img[src$="ssh-auth%402x.png"] { 56 | box-sizing: content-box; 57 | border: 1px solid #ccc; 58 | border-radius: 2px; 59 | width: 399px; 60 | } 61 | 62 | img[src$="create-new-repo%402x.png"] { 63 | box-sizing: content-box; 64 | border: 1px solid #ccc; 65 | border-radius: 2px; 66 | width: 595px; 67 | } 68 | 69 | 70 | img[src$="smallest-viable-droplet%402x.png"] { 71 | box-sizing: content-box; 72 | border: 1px solid #ccc; 73 | border-radius: 2px; 74 | width: 400px; 75 | } 76 | 77 | .wy-table-responsive table td { 78 | white-space: normal; 79 | } 80 | -------------------------------------------------------------------------------- /docs/include/common_options.md.include: -------------------------------------------------------------------------------- 1 | | `--[no-]color` | By default, tomo automatically determines whether to use color output based on the capabilities of the terminal. Use this option to override this behavior and force tomo to enable or disable color output. | 2 | | `--[no-]debug` | Enables verbose logging output that is helpful for troubleshooting. This includes runtime information such as the environment variables on the remote host and all tomo settings. Each SSH command executed by tomo is also logged in detail. | 3 | | `--[no-]trace` | Normally if a tomo command fails, a concise and helpful error message is printed. If `--trace` is specified, tomo will also print the full backtrace. | 4 | | `-h`, `--help` | Prints the help for this tomo command, similar to what you see on this page. | 5 | -------------------------------------------------------------------------------- /docs/include/deploy_options.md.include: -------------------------------------------------------------------------------- 1 | | `-e ENVIRONMENT`, `--environment ENVIRONMENT` | If the configuration contains multiple [environments](../configuration.md#environmentname-block), specify the ENVIRONMENT that should be used (e.g. production). The host(s) and settings can be different per environment. | 2 | | `-s NAME=VALUE`, `--setting NAME=VALUE` | Override the setting NAME with the given VALUE. Refer to the [settings overrides](../configuration.md#overrides) documentation for a detailed explanation. | 3 | | `--[no-]dry-run` | Simulate running tasks instead of using real SSH. | 4 | -------------------------------------------------------------------------------- /docs/include/project_options.md.include: -------------------------------------------------------------------------------- 1 | | `-c PATH`, `--config PATH` | Override the PATH where the [configuration](../configuration.md) file is loaded from. (Default: `.tomo/config.rb`). | 2 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | edit_url: https://github.com/mattbrictson/tomo/edit/main/README.md 3 | --- 4 | -------------------------------------------------------------------------------- /docs/js/extra.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", function() { 2 | document.querySelectorAll("table").forEach(function(table) { 3 | table.classList.add("docutils"); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /docs/plugins/nodenv.md: -------------------------------------------------------------------------------- 1 | # nodenv 2 | 3 | The nodenv plugin installs node and yarn. This allows you to deploy an app with confidence that yarn and a particular version of node will be available on the host. This plugin is strongly recommended for Rails apps, which by default use webpacker and thus require node and yarn. 4 | 5 | ## Settings 6 | 7 | | Name | Purpose | Default | 8 | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------- | 9 | | `bashrc_path` | Location of the deploy user’s `.bashrc` file | `".bashrc"` | 10 | | `nodenv_install_yarn` | Whether to install yarn globally via `npm i -g yarn` | `true` | 11 | | `nodenv_node_version` | Version of node to install; if nil (the default), determine the version based on .node-version | `nil` | 12 | | `nodenv_yarn_version` | A value of `nil` (the default) means install the latest; specify this only if you need a specific 1.y.z global version of yarn | `nil` | 13 | 14 | ## Tasks 15 | 16 | ### nodenv:install 17 | 18 | Installs nodenv, uses nodenv to install node, and makes the desired version of node the global default version for the deploy user. During installation, the user’s bashrc file is modified so that nodenv is automatically loaded for interactive and non-interactive shells. 19 | 20 | You must supply a value for the `nodenv_node_version` setting or have a `.node-version` file in your project for this task to work. 21 | 22 | By default, yarn is also installed globally via npm. This can be disabled by setting `nodenv_install_yarn` to `false`. 23 | 24 | `nodenv:install` is intended for use as a [setup](../commands/setup.md) task. 25 | -------------------------------------------------------------------------------- /docs/plugins/rbenv.md: -------------------------------------------------------------------------------- 1 | # rbenv 2 | 3 | The rbenv plugin provides a way to install and run a desired version of ruby. This is the recommended way to manage ruby for Rails apps. 4 | 5 | ## Settings 6 | 7 | | Name | Purpose | Default | 8 | | -------------------- | ---------------------------------------------------------------------------------------------- | ----------- | 9 | | `bashrc_path` | Location of the deploy user’s `.bashrc` file | `".bashrc"` | 10 | | `rbenv_ruby_version` | Version of ruby to install; if nil (the default), determine the version based on .ruby-version | `nil` | 11 | 12 | ## Tasks 13 | 14 | ### rbenv:install 15 | 16 | Installs rbenv, uses rbenv to install ruby, and makes the desired version of ruby the global default version for the deploy user. During installation, the user’s bashrc file is modified so that rbenv is automatically loaded for interactive and non-interactive shells. 17 | 18 | Behind the scenes, rbenv installs ruby via ruby-build, which compiles ruby from source. This means installation can take several minutes. If the desired version of ruby is already installed, the compilation step will be skipped. 19 | 20 | You must supply a value for the `rbenv_ruby_version` setting or have a `.ruby-version` file in your project for this task to work. 21 | 22 | `rbenv:install` is intended for use as a [setup](../commands/setup.md) task. 23 | -------------------------------------------------------------------------------- /docs/tutorials/create-new-repo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattbrictson/tomo/15d7faccc8c1b91750196284e2e0fa98fd0c1116/docs/tutorials/create-new-repo@2x.png -------------------------------------------------------------------------------- /docs/tutorials/smallest-viable-droplet@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattbrictson/tomo/15d7faccc8c1b91750196284e2e0fa98fd0c1116/docs/tutorials/smallest-viable-droplet@2x.png -------------------------------------------------------------------------------- /docs/tutorials/ssh-auth@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattbrictson/tomo/15d7faccc8c1b91750196284e2e0fa98fd0c1116/docs/tutorials/ssh-auth@2x.png -------------------------------------------------------------------------------- /docs/tutorials/ubuntu-20-lts@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattbrictson/tomo/15d7faccc8c1b91750196284e2e0fa98fd0c1116/docs/tutorials/ubuntu-20-lts@2x.png -------------------------------------------------------------------------------- /exe/tomo: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "tomo" 5 | Tomo::CLI.new.call(ARGV) 6 | -------------------------------------------------------------------------------- /lib/tomo.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | autoload :CLI, "tomo/cli" 5 | autoload :Colors, "tomo/colors" 6 | autoload :Commands, "tomo/commands" 7 | autoload :Configuration, "tomo/configuration" 8 | autoload :Console, "tomo/console" 9 | autoload :Error, "tomo/error" 10 | autoload :Host, "tomo/host" 11 | autoload :Logger, "tomo/logger" 12 | autoload :Path, "tomo/path" 13 | autoload :Paths, "tomo/paths" 14 | autoload :Plugin, "tomo/plugin" 15 | autoload :PluginDSL, "tomo/plugin_dsl" 16 | autoload :Remote, "tomo/remote" 17 | autoload :Result, "tomo/result" 18 | autoload :Runtime, "tomo/runtime" 19 | autoload :Script, "tomo/script" 20 | autoload :ShellBuilder, "tomo/shell_builder" 21 | autoload :SSH, "tomo/ssh" 22 | autoload :TaskAPI, "tomo/task_api" 23 | autoload :TaskLibrary, "tomo/task_library" 24 | autoload :VERSION, "tomo/version" 25 | 26 | DEFAULT_CONFIG_PATH = ".tomo/config.rb" 27 | 28 | class << self 29 | attr_accessor :logger 30 | attr_writer :debug, :dry_run 31 | 32 | def debug? 33 | !!@debug 34 | end 35 | 36 | def dry_run? 37 | !!@dry_run 38 | end 39 | 40 | def bundled? 41 | !!(defined?(Bundler) && ENV["BUNDLE_GEMFILE"]) 42 | end 43 | end 44 | 45 | self.debug = false 46 | self.dry_run = false 47 | self.logger = Logger.new 48 | end 49 | -------------------------------------------------------------------------------- /lib/tomo/cli/command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class CLI 5 | class Command 6 | class << self 7 | def arg(spec, values: []) 8 | parser.arg(spec, values:) 9 | end 10 | 11 | def option(key, spec, desc=nil, values: [], &block) 12 | parser.option(key, spec, desc, values:, &block) 13 | end 14 | 15 | def after_parse(context_method_name) 16 | parser.after_parse(context_method_name) 17 | end 18 | 19 | def parser 20 | @parser ||= Parser.new 21 | end 22 | 23 | def parse(argv) 24 | command = new 25 | parser.context = command 26 | parser.banner = command.method(:banner) 27 | *args, options = parser.parse(argv) 28 | command.call(*args, options) 29 | end 30 | end 31 | 32 | include Colors 33 | 34 | private 35 | 36 | def dry_run? 37 | Tomo.dry_run? 38 | end 39 | 40 | def logger 41 | Tomo.logger 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/tomo/cli/common_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class CLI 5 | module CommonOptions 6 | def self.included(mod) # rubocop:disable Metrics/MethodLength 7 | mod.class_eval do 8 | option :color, "--[no-]color", "Enable/disable color output" do |color| 9 | Colors.enabled = color 10 | end 11 | option :debug, "--[no-]debug", "Enable/disable verbose debug logging" do |debug| 12 | Tomo.debug = debug 13 | end 14 | option :trace, "--[no-]trace", "Display full backtrace on error" do |trace| 15 | CLI.show_backtrace = trace 16 | end 17 | option :help, "-h, --help", "Print this documentation" do |_help| 18 | puts instance_variable_get(:@parser) 19 | CLI.exit 20 | end 21 | 22 | after_parse :dump_runtime_info 23 | end 24 | end 25 | 26 | private 27 | 28 | def dump_runtime_info(*) 29 | Tomo.logger.debug("tomo #{Tomo::VERSION}") 30 | Tomo.logger.debug(RUBY_DESCRIPTION) 31 | Tomo.logger.debug("rubygems #{Gem::VERSION}") 32 | Tomo.logger.debug("bundler #{Bundler::VERSION}") if Tomo.bundled? 33 | 34 | begin 35 | require "concurrent" 36 | Tomo.logger.debug("concurrent-ruby #{Concurrent::VERSION}") 37 | rescue LoadError # rubocop:disable Lint/SuppressedException 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/tomo/cli/completions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class CLI 5 | class Completions 6 | def self.activate 7 | @active = true 8 | end 9 | 10 | def self.active? 11 | defined?(@active) && @active 12 | end 13 | 14 | def initialize(literal: false, stdout: $stdout) 15 | @literal = literal 16 | @stdout = stdout 17 | end 18 | 19 | def print_completions_and_exit(rules, *args, state:) 20 | completions = completions_for(rules, *args, state) 21 | words = completions.map { |c| bash_word_for(c, args.last) } 22 | Tomo.logger.info(words.join("\n")) unless words.empty? 23 | CLI.exit 24 | end 25 | 26 | private 27 | 28 | attr_reader :literal, :stdout 29 | 30 | def completions_for(rules, *prefix_args, word, state) 31 | all_candidates(rules, prefix_args, state).select do |cand| 32 | next if !literal && redundant_option_completion?(cand, word) 33 | 34 | cand.start_with?(word) && cand != word 35 | end 36 | end 37 | 38 | def all_candidates(rules, prefix_args, state) 39 | rules.flat_map do |rule| 40 | rule.candidates(*prefix_args, literal:, state:) 41 | end 42 | end 43 | 44 | def redundant_option_completion?(cand, word) 45 | # Don't complete switches until the user has typed at least "--" 46 | return true if cand.start_with?("-") && !word.start_with?("--") 47 | 48 | # Don't complete the =value part of long switch unless the user has 49 | # already typed at least up to the = sign. 50 | true if cand.match?(/\A--.*=/) && !word.match?(/\A--.*=/) 51 | end 52 | 53 | # bash tokenizes the user's input prior to completion, and expects the 54 | # completions we return to be only the last token of the string. So if the 55 | # user typed "rails:c[TAB]", bash expects the completion to be the part 56 | # after the ":" character. In other words we should return "console", not 57 | # "rails:console". The special tokens we need to consider are ":" and "=". 58 | # 59 | # For convenience we also distinguish a partial completion vs a full 60 | # completion. If it is a full completion we append a " " to the end of 61 | # the word so that the user can naturally begin typing the next option or 62 | # argument. 63 | def bash_word_for(completion, user_input) 64 | completion = "#{completion} " unless completion.end_with?("=") 65 | return completion unless user_input.match?(/[:=]/) 66 | 67 | completion.sub(/.*[:=]/, "") 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/tomo/cli/deploy_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class CLI 5 | module DeployOptions 6 | def self.included(mod) # rubocop:disable Metrics/MethodLength 7 | mod.class_eval do 8 | option :environment, 9 | "-e, --environment ENVIRONMENT", 10 | "Specify environment to use (e.g. production)", 11 | values: :environment_names 12 | 13 | option :settings, 14 | "-s, --setting NAME=VALUE", 15 | "Override setting NAME with the given VALUE", 16 | values: :setting_completions 17 | 18 | option :dry_run, 19 | "--[no-]dry-run", 20 | "Simulate running tasks instead of using real SSH" do |dry| 21 | Tomo.dry_run = dry 22 | end 23 | 24 | after_parse :prompt_for_environment 25 | end 26 | end 27 | 28 | private 29 | 30 | def environment_names(*_args, options) 31 | load_configuration(options).environments.keys 32 | end 33 | 34 | def setting_completions(*_args, options) 35 | runtime = configure_runtime(options, strict: false) 36 | settings = runtime.execution_plan_for([]).settings 37 | 38 | settings = settings.select do |_key, value| 39 | value.nil? || value.is_a?(String) || value.is_a?(Numeric) 40 | end.to_h 41 | 42 | settings.keys.map { |sett| "#{sett}=" } 43 | end 44 | 45 | def prompt_for_environment(*args, options) 46 | return unless options[:environment].nil? 47 | 48 | envs = environment_names(*args, options) 49 | return if envs.empty? 50 | return unless Console.interactive? 51 | 52 | options[:environment] = Console.menu("Choose an environment:", choices: envs) 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/tomo/cli/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class CLI 5 | class Error < ::Tomo::Error 6 | attr_accessor :command_name 7 | 8 | def to_console 9 | tomo_command = ["tomo", command_name].compact.join(" ") 10 | <<~ERROR 11 | #{message} 12 | 13 | Run #{blue("#{tomo_command} -h")} for help. 14 | ERROR 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/tomo/cli/interrupted_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class CLI 5 | class InterruptedError < Error 6 | def to_console 7 | "Interrupted" 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/tomo/cli/options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class CLI 5 | class Options 6 | def initialize 7 | @options = {} 8 | end 9 | 10 | def any? 11 | options.any? 12 | end 13 | 14 | def key?(key) 15 | !all(key).empty? 16 | end 17 | 18 | def fetch(key, default) 19 | values = all(key) 20 | values.empty? ? default : values.first 21 | end 22 | 23 | def [](key) 24 | fetch(key, nil) 25 | end 26 | 27 | def []=(key, value) 28 | all(key).clear.push(value) 29 | end 30 | 31 | def all(key) 32 | options[key] ||= [] 33 | end 34 | 35 | private 36 | 37 | attr_reader :options 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/tomo/cli/parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | 5 | module Tomo 6 | class CLI 7 | class Parser 8 | extend Forwardable 9 | include Colors 10 | 11 | def_delegators :usage, :banner=, :to_s 12 | 13 | attr_accessor :context 14 | 15 | def initialize 16 | @rules = Rules.new 17 | @usage = Usage.new 18 | @after_parse_methods = [] 19 | end 20 | 21 | def arg(spec, values: []) 22 | rules.add_arg(spec, proc_for(values)) 23 | end 24 | 25 | def option(key, spec, desc=nil, values: [], &block) 26 | rules.add_option(key, spec, proc_for(values), &block) 27 | usage.add_option(spec, desc) 28 | end 29 | 30 | def after_parse(context_method_name) 31 | after_parse_methods << context_method_name 32 | end 33 | 34 | def parse(argv) 35 | state = State.new 36 | 37 | options_argv, literal_argv = split(argv, "--") 38 | evaluate(options_argv, state, literal: false) 39 | evaluate(literal_argv, state, literal: true) 40 | check_required_rules(state) 41 | invoke_after_parse_methods(state) 42 | 43 | [*state.args, state.options] 44 | end 45 | 46 | private 47 | 48 | attr_reader :rules, :usage, :after_parse_methods 49 | 50 | def evaluate(argv, state, literal:) 51 | RulesEvaluator.evaluate(rules: rules.to_a, argv:, state:, literal:) 52 | end 53 | 54 | def check_required_rules(state) 55 | (rules.to_a - state.processed_rules).each do |rule| 56 | next unless rule.required? 57 | 58 | type = rule.is_a?(Rules::Argument) ? "" : " option" 59 | raise CLI::Error, "Please specify the #{yellow(rule.to_s)}#{type}." 60 | end 61 | end 62 | 63 | def invoke_after_parse_methods(state) 64 | after_parse_methods.each do |method| 65 | context.send(method, *state.args, state.options) 66 | end 67 | end 68 | 69 | def proc_for(values) 70 | return values if values.respond_to?(:call) 71 | return proc { values } unless values.is_a?(Symbol) 72 | 73 | method = values 74 | proc { |*args| context.send(method, *args) } 75 | end 76 | 77 | def split(argv, delimiter) 78 | index = argv.index(delimiter) 79 | return [argv, []] if index.nil? 80 | return [argv, []] if index == argv.length - 1 && Completions.active? 81 | 82 | before = argv[0...index] 83 | after = argv[(index + 1)..] 84 | 85 | [before, after] 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/tomo/cli/project_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class CLI 5 | module ProjectOptions 6 | def self.included(mod) 7 | mod.class_eval do 8 | option :project, "-c, --config PATH", "Location of project config (default: #{DEFAULT_CONFIG_PATH})" 9 | end 10 | end 11 | 12 | private 13 | 14 | def configure_runtime(options, strict: true) 15 | config = load_configuration(options) 16 | env = options[:environment] 17 | env = config.environments.keys.first if env.nil? && !strict 18 | config = config.for_environment(env) 19 | config.settings.merge!(settings_from_env) 20 | config.settings.merge!(settings_from_options(options)) 21 | config.build_runtime 22 | end 23 | 24 | def load_configuration(options) 25 | path = options[:project] || DEFAULT_CONFIG_PATH 26 | @config_cache ||= {} 27 | @config_cache[path] ||= Configuration.from_config_rb(path) 28 | end 29 | 30 | def settings_from_options(options) 31 | options.all(:settings).each_with_object({}) do |arg, settings| 32 | name, value = arg.split("=", 2) 33 | settings[name.to_sym] = value 34 | end 35 | end 36 | 37 | def settings_from_env 38 | ENV.each_with_object({}) do |(key, value), result| 39 | setting_name = key[/^TOMO_(\w+)$/i, 1]&.downcase 40 | next if setting_name.nil? 41 | 42 | result[setting_name.to_sym] = value 43 | end 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/tomo/cli/rules.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class CLI 5 | class Rules 6 | autoload :Argument, "tomo/cli/rules/argument" 7 | autoload :Switch, "tomo/cli/rules/switch" 8 | autoload :ValueSwitch, "tomo/cli/rules/value_switch" 9 | 10 | ARG_PATTERNS = { 11 | /\A\[[A-Z_]+\]\z/ => :optional_arg_rule, 12 | /\A[A-Z_]+\z/ => :required_arg_rule, 13 | /\A\[[A-Z_]+\.\.\.\]\z/ => :mutiple_optional_args_rule 14 | }.freeze 15 | 16 | OPTION_PATTERNS = { 17 | /\A--\[no-\]([-a-z]+)\z/ => :on_off_switch_rule, 18 | /\A(-[a-z]), (--[-a-z]+)\z/ => :basic_switch_rule, 19 | /\A(-[a-z]), (--[-a-z]+) [A-Z=_-]+\z/ => :value_switch_rule 20 | }.freeze 21 | 22 | private_constant :ARG_PATTERNS, :OPTION_PATTERNS 23 | 24 | def initialize 25 | @rules = [] 26 | end 27 | 28 | def add_arg(spec, values_proc) 29 | rule = ARG_PATTERNS.find do |regexp, method| 30 | break send(method, spec, values_proc) if regexp.match?(spec) 31 | end 32 | raise ArgumentError, "Unrecognized arg style: #{spec}" if rule.nil? 33 | 34 | rules << rule 35 | end 36 | 37 | def add_option(key, spec, values_proc, &block) 38 | rule = OPTION_PATTERNS.find do |regexp, method| 39 | match = regexp.match(spec) 40 | break send(method, key, *match.captures, values_proc, block) if match 41 | end 42 | raise ArgumentError, "Unrecognized option style: #{spec}" if rule.nil? 43 | 44 | rules << rule 45 | end 46 | 47 | def to_a 48 | rules 49 | end 50 | 51 | private 52 | 53 | attr_reader :rules 54 | 55 | def optional_arg_rule(spec, values_proc) 56 | Rules::Argument.new(spec, values_proc:, required: false, multiple: false) 57 | end 58 | 59 | def required_arg_rule(spec, values_proc) 60 | Rules::Argument.new(spec, values_proc:, required: true, multiple: false) 61 | end 62 | 63 | def mutiple_optional_args_rule(spec, values_proc) 64 | Rules::Argument.new(spec, multiple: true, values_proc:) 65 | end 66 | 67 | def on_off_switch_rule(key, name, _values_proc, callback_proc) 68 | Rules::Switch.new(key, "--#{name}", "--no-#{name}", callback_proc:) do |arg| 69 | arg == "--#{name}" 70 | end 71 | end 72 | 73 | def basic_switch_rule(key, *switches, _values_proc, callback_proc) 74 | Rules::Switch.new(key, *switches, callback_proc:) 75 | end 76 | 77 | def value_switch_rule(key, *switches, values_proc, callback_proc) 78 | Rules::ValueSwitch.new(key, *switches, values_proc:, callback_proc:) 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/tomo/cli/rules/argument.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Tomo::CLI::Rules 4 | class Argument 5 | def initialize(label, values_proc:, multiple: false, required: false) 6 | @label = label 7 | @multiple = multiple 8 | @required = required 9 | @values_proc = values_proc 10 | end 11 | 12 | def match(arg, literal: false) 13 | 1 if literal || !arg.start_with?("-") 14 | end 15 | 16 | def process(arg, state:) 17 | state.parsed_arg(arg) 18 | end 19 | 20 | def candidates(state:, literal: false) 21 | values(state).reject { |val| literal && val.start_with?("-") } 22 | end 23 | 24 | def required? 25 | @required 26 | end 27 | 28 | def multiple? 29 | @multiple 30 | end 31 | 32 | def to_s 33 | @label 34 | end 35 | 36 | private 37 | 38 | attr_reader :values_proc 39 | 40 | def values(state) 41 | values_proc.call(*state.args, state.options) 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/tomo/cli/rules/switch.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Tomo::CLI::Rules 4 | class Switch 5 | def initialize(key, *switches, callback_proc:, required: false, &convert_proc) 6 | @key = key 7 | @switches = switches 8 | @callback_proc = callback_proc 9 | @convert_proc = convert_proc || proc { true } 10 | @required = required 11 | end 12 | 13 | def match(arg, literal: false) 14 | return nil if literal 15 | 16 | 1 if switches.include?(arg) 17 | end 18 | 19 | def process(arg, state:) 20 | value = convert_proc.call(arg) 21 | callback_proc&.call(value) 22 | state.parsed_option(key, value) 23 | end 24 | 25 | def candidates(literal: false, **_kwargs) 26 | literal ? [] : switches 27 | end 28 | 29 | def required? 30 | @required 31 | end 32 | 33 | def multiple? 34 | true 35 | end 36 | 37 | private 38 | 39 | attr_reader :key, :switches, :convert_proc, :callback_proc 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/tomo/cli/rules/value_switch.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Tomo::CLI::Rules 4 | class ValueSwitch < Switch 5 | include Tomo::Colors 6 | 7 | def initialize(key, *switches, values_proc:, callback_proc:) 8 | super(key, *switches, callback_proc:) 9 | 10 | @values_proc = values_proc 11 | end 12 | 13 | def match(arg, literal: false) 14 | return nil if literal 15 | return 2 if switches.include?(arg) 16 | 17 | 1 if arg.start_with?("--") && switches.include?(arg.split("=").first) 18 | end 19 | 20 | def process(switch, arg=nil, state:) 21 | value = if switch.include?("=") 22 | switch.split("=", 2).last 23 | elsif !arg.to_s.start_with?("-") 24 | arg 25 | end 26 | 27 | raise_missing_value(switch) if value.nil? 28 | 29 | callback_proc&.call(value) 30 | state.parsed_option(key, value) 31 | end 32 | 33 | def candidates(switch=nil, state:, literal: false) 34 | return [] if literal 35 | 36 | vals = values(state) 37 | return vals.reject { |val| val.start_with?("-") } if switch 38 | 39 | switches.each_with_object([]) do |each_switch, result| 40 | result << each_switch 41 | vals.each do |value| 42 | result << "#{each_switch}=#{value}" if each_switch.start_with?("--") 43 | end 44 | end 45 | end 46 | 47 | private 48 | 49 | attr_reader :values_proc 50 | 51 | def values(state) 52 | values_proc.call(*state.args, state.options) 53 | end 54 | 55 | def raise_missing_value(switch) 56 | raise Tomo::CLI::Error, "Please specify a value for the #{yellow(switch)} option." 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/tomo/cli/rules_evaluator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class CLI 5 | class RulesEvaluator 6 | def self.evaluate(**kwargs) 7 | new(**kwargs).call 8 | end 9 | 10 | def initialize(rules:, argv:, state:, literal:, completions: nil) 11 | @rules = rules 12 | @argv = argv.dup 13 | @state = state 14 | @literal = literal 15 | @completions = completions || Completions.new(literal:) 16 | end 17 | 18 | def call 19 | until argv.empty? 20 | complete_if_needed(remaining_rules, *argv) if argv.length == 1 21 | rule, matched_args = match_next_rule 22 | complete_if_needed([rule], *matched_args) if argv.empty? 23 | rule.process(*matched_args, state:) 24 | state.processed_rule(rule) 25 | end 26 | end 27 | 28 | private 29 | 30 | attr_reader :rules, :argv, :state, :literal, :completions 31 | 32 | def match_next_rule 33 | matched_rule, length = remaining_rules.find do |rule| 34 | matching_length = rule.match(argv.first, literal:) 35 | break [rule, matching_length] if matching_length 36 | end 37 | raise_unrecognized_args if matched_rule.nil? 38 | 39 | matched_args = argv.shift(length) 40 | [matched_rule, matched_args] 41 | end 42 | 43 | def complete_if_needed(matched_rules, *matched_args) 44 | return unless Completions.active? 45 | 46 | completions.print_completions_and_exit(matched_rules, *matched_args, state:) 47 | end 48 | 49 | def remaining_rules 50 | rules.reject do |rule| 51 | state.processed?(rule) && !rule.multiple? 52 | end 53 | end 54 | 55 | def raise_unrecognized_args 56 | problem_arg = argv.first 57 | type = literal || !problem_arg.start_with?("-") ? "arg" : "option" 58 | 59 | raise CLI::Error, "#{Colors.yellow(problem_arg)} is not a recognized #{type} for this tomo command." 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/tomo/cli/state.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class CLI 5 | class State 6 | attr_reader :args, :options, :processed_rules 7 | 8 | def initialize 9 | @args = [] 10 | @options = Options.new 11 | @processed_rules = [] 12 | end 13 | 14 | def parsed_arg(arg) 15 | args << arg 16 | end 17 | 18 | def parsed_option(key, value) 19 | options.all(key) << value 20 | end 21 | 22 | def processed_rule(rule) 23 | @processed_rules |= [rule] 24 | end 25 | 26 | def processed?(rule) 27 | @processed_rules.include?(rule) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/tomo/cli/usage.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class CLI 5 | class Usage 6 | def initialize 7 | @options = [] 8 | @banner_proc = proc { "" } 9 | end 10 | 11 | def add_option(spec, desc) 12 | options << [ 13 | spec.start_with?("--") ? " #{spec}" : spec, 14 | desc 15 | ] 16 | end 17 | 18 | def banner=(banner) 19 | @banner_proc = banner.respond_to?(:call) ? banner : proc { banner } 20 | end 21 | 22 | def to_s 23 | indent(["", banner_proc.call, "Options:", "", indent(options_help), "\n"].join("\n")) 24 | end 25 | 26 | private 27 | 28 | attr_reader :banner_proc, :options 29 | 30 | def options_help 31 | width = options.map { |opt| opt.first.length }.max 32 | options.each_with_object([]) do |(spec, desc), help| 33 | help << "#{Colors.yellow(spec.ljust(width))} #{desc}" 34 | end.join("\n") 35 | end 36 | 37 | def indent(str) 38 | str.gsub(/^/, " ") 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/tomo/colors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module Colors 5 | ANSI_CODES = { 6 | red: 31, 7 | green: 32, 8 | yellow: 33, 9 | blue: 34, 10 | gray: 90 11 | }.freeze 12 | private_constant :ANSI_CODES 13 | 14 | class << self 15 | attr_writer :enabled 16 | 17 | def enabled? 18 | return @enabled if defined?(@enabled) 19 | 20 | @enabled = determine_color_support 21 | end 22 | 23 | private 24 | 25 | def determine_color_support 26 | if ENV["CLICOLOR_FORCE"] == "1" 27 | true 28 | elsif ENV["TERM"] == "dumb" || !ENV["NO_COLOR"].to_s.empty? 29 | false 30 | else 31 | tty?($stdout) && tty?($stderr) 32 | end 33 | end 34 | 35 | def tty?(io) 36 | io.respond_to?(:tty?) && io.tty? 37 | end 38 | end 39 | 40 | module_function 41 | 42 | ANSI_CODES.each do |name, code| 43 | define_method(name) do |str| 44 | ::Tomo::Colors.enabled? ? "\e[0;#{code};49m#{str}\e[0m" : str 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/tomo/commands.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module Commands 5 | autoload :CompletionScript, "tomo/commands/completion_script" 6 | autoload :Default, "tomo/commands/default" 7 | autoload :Deploy, "tomo/commands/deploy" 8 | autoload :Help, "tomo/commands/help" 9 | autoload :Init, "tomo/commands/init" 10 | autoload :Run, "tomo/commands/run" 11 | autoload :Setup, "tomo/commands/setup" 12 | autoload :Tasks, "tomo/commands/tasks" 13 | autoload :Version, "tomo/commands/version" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/tomo/commands/completion_script.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module Commands 5 | class CompletionScript 6 | def self.parse(_argv) 7 | puts <<~'SCRIPT' 8 | # TOMO COMPLETIONS FOR BASH 9 | # 10 | # Assuming tomo is in your PATH, you can install tomo bash completions by 11 | # adding this line to your .bashrc: 12 | # 13 | # eval "$(tomo completion-script)" 14 | # 15 | # The eval technique is a bit slow but ensures bash is always using the 16 | # latest version of the tomo completion script. 17 | # 18 | # Alternatively, you can copy and paste the current version of the script 19 | # into your .bashrc. The full script is listed below. 20 | 21 | _tomo_complete() { 22 | local cur="${COMP_WORDS[COMP_CWORD]}" 23 | local prev="${COMP_WORDS[COMP_CWORD-1]}" 24 | 25 | if [[ $prev == "-c" || $prev == "--config" ]]; then 26 | COMPREPLY=($(compgen -f -- ${cur})) 27 | return 0 28 | fi 29 | 30 | if [[ "${COMP_LINE: -1}" == " " ]]; then 31 | command=${COMP_LINE/tomo/tomo --complete} 32 | else 33 | command=${COMP_LINE/tomo/tomo --complete-word} 34 | fi 35 | 36 | suggestions=$($command) 37 | local IFS=$'\n' 38 | COMPREPLY=($suggestions) 39 | return 0 40 | } 41 | 42 | complete -o nospace -F _tomo_complete tomo 43 | 44 | SCRIPT 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/tomo/commands/default.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module Commands 5 | class Default < CLI::Command 6 | arg "COMMAND", values: CLI::COMMANDS.keys 7 | 8 | option :version, "-v, --version", "Display tomo’s version and exit" do 9 | Version.parse([]) 10 | CLI.exit 11 | end 12 | 13 | include CLI::CommonOptions 14 | 15 | def banner 16 | <<~BANNER 17 | Usage: #{green('tomo')} #{yellow('COMMAND [options]')} 18 | 19 | Tomo is an extensible tool for deploying projects to remote hosts via SSH. 20 | Please specify a #{yellow('COMMAND')}, which can be: 21 | 22 | #{commands.map { |name, help| " #{yellow(name.ljust(10))} #{help}" }.join("\n")} 23 | 24 | The tomo CLI also provides some convenient shortcuts: 25 | 26 | - Commands can be abbreviated, like #{blue('tomo d')} to run #{blue('tomo deploy')}. 27 | - When running tasks, the #{yellow('run')} command is implied and can be omitted. 28 | E.g., #{blue('tomo run rails:console')} can be shortened to #{blue('tomo rails:console')}. 29 | - Bash completions are also available. Run #{blue('tomo completion-script')} for 30 | installation instructions. 31 | 32 | For help with any command, add #{blue('-h')} to the command, like this: 33 | 34 | #{blue('tomo run -h')} 35 | 36 | Or read the full documentation for all commands at: 37 | 38 | #{blue('https://tomo.mattbrictson.com/')} 39 | BANNER 40 | end 41 | 42 | def call(*args, options) 43 | # The bare `tomo` command (i.e. without `--help` or `--version`) doesn't 44 | # do anything, so if we got this far, something has gone wrong. 45 | 46 | if options.any? 47 | raise CLI::Error, "Options must be specified after the command: " + yellow("tomo #{args.first} [options]") 48 | end 49 | 50 | raise_unrecognized_command(args.first) 51 | end 52 | 53 | private 54 | 55 | def raise_unrecognized_command(command) 56 | error = "#{yellow(command)} is not a recognized tomo command." 57 | if command.match?(/\A\S+:/) 58 | suggestion = "tomo run #{command}" 59 | error << "\nMaybe you meant #{blue(suggestion)}?" 60 | end 61 | 62 | raise CLI::Error, error 63 | end 64 | 65 | def commands 66 | CLI::COMMANDS.each_with_object({}) do |(name, klass), result| 67 | command = klass.new 68 | help = command.summary if command.respond_to?(:summary) 69 | next if help.nil? 70 | 71 | result[name] = help 72 | end 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/tomo/commands/deploy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module Commands 5 | class Deploy < CLI::Command 6 | include CLI::DeployOptions 7 | include CLI::ProjectOptions 8 | include CLI::CommonOptions 9 | 10 | def summary 11 | "Deploy the current project to remote host(s)" 12 | end 13 | 14 | def banner 15 | <<~BANNER 16 | Usage: #{green('tomo deploy')} #{yellow('[--dry-run] [options]')} 17 | 18 | Sequentially run the "deploy" list of tasks specified in #{DEFAULT_CONFIG_PATH} to 19 | deploy the project to a remote host. Use the #{blue('--dry-run')} option to quickly 20 | simulate the entire deploy without actually connecting to the host. Add the 21 | #{blue('--debug')} option to see an in-depth explanation of the settings and execution 22 | plan that will be used for the deployment. 23 | 24 | For a #{DEFAULT_CONFIG_PATH} that specifies distinct environments (e.g. staging, 25 | production), you must specify the target environment using the #{blue('-e')} option. If 26 | you omit this option, tomo will automatically prompt for it. 27 | 28 | Tomo will use the settings specified in #{DEFAULT_CONFIG_PATH} to configure the 29 | deploy. You may override these on the command line using #{blue('-s')}. E.g.: 30 | 31 | #{blue('tomo deploy -e staging -s git_branch=develop')} 32 | 33 | Or use environment variables with the special #{blue('TOMO_')} prefix: 34 | 35 | #{blue('TOMO_GIT_BRANCH=develop tomo deploy -e staging')} 36 | 37 | Bash completions are provided for tomo’s options. For example, you could type 38 | #{blue('tomo deploy -s ')} to see a list of all settings, or #{blue('tomo deploy -e pr')} 39 | to expand #{blue('pr')} to #{blue('production')}. For bash completion installation instructions, 40 | run #{blue('tomo completion-script')}. 41 | 42 | More documentation and examples can be found here: 43 | 44 | #{blue('https://tomo.mattbrictson.com/commands/deploy')} 45 | BANNER 46 | end 47 | 48 | def call(options) 49 | logger.info "tomo deploy v#{Tomo::VERSION}" 50 | 51 | runtime = configure_runtime(options) 52 | plan = runtime.deploy! 53 | 54 | log_completion(plan) 55 | end 56 | 57 | private 58 | 59 | def log_completion(plan) 60 | app = plan.settings[:application] 61 | target = "#{app} to #{plan.applicable_hosts_sentence}" 62 | 63 | if dry_run? 64 | logger.info(green("* Simulated deploy of #{target} (dry run)")) 65 | else 66 | logger.info(green("✔ Deployed #{target}")) 67 | end 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/tomo/commands/help.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module Commands 5 | class Help 6 | def self.parse(argv) 7 | Default.parse([*argv, "--help"]) 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/tomo/commands/setup.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module Commands 5 | class Setup < CLI::Command 6 | include CLI::DeployOptions 7 | include CLI::ProjectOptions 8 | include CLI::CommonOptions 9 | 10 | def summary 11 | "Prepare the current project for its first deploy" 12 | end 13 | 14 | def banner 15 | <<~BANNER 16 | Usage: #{green('tomo setup')} #{yellow('[--dry-run] [options]')} 17 | 18 | Prepare the remote host for its first deploy by sequentially running the 19 | "setup" list of tasks specified in #{DEFAULT_CONFIG_PATH}. These tasks typically 20 | create directories, initialize data stores, install prerequisite tools, 21 | and perform other one-time actions that are necessary before a deploy can 22 | take place. 23 | 24 | Use the #{blue('--dry-run')} option to quickly simulate the setup without actually 25 | connecting to the host. 26 | 27 | More documentation and examples can be found here: 28 | 29 | #{blue('https://tomo.mattbrictson.com/commands/setup')} 30 | BANNER 31 | end 32 | 33 | def call(options) 34 | logger.info "tomo setup v#{Tomo::VERSION}" 35 | 36 | runtime = configure_runtime(options) 37 | plan = runtime.setup! 38 | 39 | log_completion(plan) 40 | end 41 | 42 | private 43 | 44 | def log_completion(plan) 45 | app = plan.settings[:application] 46 | target = "#{app} on #{plan.applicable_hosts_sentence}" 47 | 48 | if dry_run? 49 | logger.info(green("* Simulated setup of #{target} (dry run)")) 50 | else 51 | logger.info(green("✔ Performed setup of #{target}")) 52 | end 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/tomo/commands/tasks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module Commands 5 | class Tasks < CLI::Command 6 | include CLI::ProjectOptions 7 | include CLI::CommonOptions 8 | 9 | def summary 10 | "List all tasks that can be used with the #{yellow('run')} command" 11 | end 12 | 13 | def banner 14 | <<~BANNER 15 | Usage: #{green('tomo tasks')} 16 | 17 | List all tomo tasks (i.e. those that can be used with #{blue('tomo run')}). 18 | 19 | Available tasks are those defined by plugins loaded in #{DEFAULT_CONFIG_PATH}. 20 | BANNER 21 | end 22 | 23 | def call(options) 24 | runtime = configure_runtime(options, strict: false) 25 | tasks = runtime.tasks 26 | 27 | groups = tasks.group_by { |task| task[/^([^:]+):/, 1].to_s } 28 | groups.keys.sort.each do |group| 29 | logger.info(groups[group].sort.join("\n")) 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/tomo/commands/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module Commands 5 | class Version < CLI::Command 6 | include CLI::CommonOptions 7 | 8 | def summary 9 | "Display tomo’s version" 10 | end 11 | 12 | def banner 13 | <<~BANNER 14 | Usage: #{green('tomo version')} 15 | 16 | Display tomo’s version information. 17 | BANNER 18 | end 19 | 20 | def call(_options) 21 | puts "tomo/#{Tomo::VERSION} #{RUBY_DESCRIPTION}" 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/tomo/configuration/dsl.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Configuration 5 | module DSL 6 | autoload :BatchBlock, "tomo/configuration/dsl/batch_block" 7 | autoload :ConfigFile, "tomo/configuration/dsl/config_file" 8 | autoload :EnvironmentBlock, "tomo/configuration/dsl/environment_block" 9 | autoload :ErrorFormatter, "tomo/configuration/dsl/error_formatter" 10 | autoload :HostsAndSettings, "tomo/configuration/dsl/hosts_and_settings" 11 | autoload :TasksBlock, "tomo/configuration/dsl/tasks_block" 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/tomo/configuration/dsl/batch_block.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Configuration 5 | module DSL 6 | class BatchBlock 7 | def initialize(batch) 8 | @batch = batch 9 | end 10 | 11 | def run(task, privileged: false) 12 | task = String.new(task).extend(Runtime::PrivilegedTask) if privileged 13 | @batch << task 14 | self 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/tomo/configuration/dsl/config_file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Configuration 5 | module DSL 6 | class ConfigFile 7 | include HostsAndSettings 8 | 9 | def initialize(config) 10 | @config = config 11 | end 12 | 13 | def plugin(name) 14 | @config.plugins << name.to_s 15 | self 16 | end 17 | 18 | def role(name, runs:) 19 | @config.task_filter.add_role(name, runs) 20 | self 21 | end 22 | 23 | def environment(name, &) 24 | environment = @config.environments[name.to_s] ||= Environment.new 25 | EnvironmentBlock.new(environment).instance_eval(&) 26 | self 27 | end 28 | 29 | def deploy(&) 30 | TasksBlock.new(@config.deploy_tasks).instance_eval(&) 31 | self 32 | end 33 | 34 | def setup(&) 35 | TasksBlock.new(@config.setup_tasks).instance_eval(&) 36 | self 37 | end 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/tomo/configuration/dsl/environment_block.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Configuration 5 | module DSL 6 | class EnvironmentBlock 7 | include HostsAndSettings 8 | 9 | def initialize(config) 10 | @config = config 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/tomo/configuration/dsl/error_formatter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Configuration 5 | module DSL 6 | module ErrorFormatter 7 | class << self 8 | def decorate(error, path, lines) 9 | line_no = find_line_no(path, error.message, *error.backtrace[0..1]) 10 | return error if line_no.nil? 11 | 12 | error.extend(self) 13 | error.dsl_lines = lines || [] 14 | error.dsl_path = path 15 | error.error_line_no = line_no 16 | error 17 | end 18 | 19 | private 20 | 21 | def find_line_no(path, *lines) 22 | lines.find do |line| 23 | line_no = line[/^#{Regexp.quote(path)}:(\d+):/, 1] 24 | break line_no.to_i if line_no 25 | end 26 | end 27 | end 28 | 29 | include Colors 30 | 31 | attr_accessor :dsl_lines, :dsl_path, :error_line_no 32 | 33 | def to_console 34 | <<~ERROR 35 | Configuration syntax error in #{yellow(dsl_path)} at line #{yellow(error_line_no)}. 36 | 37 | #{highlighted_lines} 38 | #{Colors.red([self.class, message].join(': '))} 39 | 40 | Visit #{Colors.blue('https://tomo.mattbrictson.com/configuration')} for syntax reference. 41 | #{trace_hint} 42 | ERROR 43 | end 44 | 45 | private 46 | 47 | def trace_hint 48 | return "" if CLI.show_backtrace 49 | 50 | <<~HINT 51 | You can run this command again with #{Colors.blue('--trace')} for a full backtrace. 52 | HINT 53 | end 54 | 55 | def highlighted_lines # rubocop:disable Metrics/AbcSize 56 | first = [1, error_line_no - 1].max 57 | last = [dsl_lines.length, error_line_no + 1].min 58 | width = last.to_s.length 59 | 60 | (first..last).each_with_object(+"") do |line_no, result| 61 | line = dsl_lines[line_no - 1] 62 | line_no_prefix = line_no.to_s.rjust(width) 63 | 64 | result << if line_no == error_line_no 65 | yellow("→ #{line_no_prefix}: #{line}") 66 | else 67 | " #{line_no_prefix}: #{line}" 68 | end 69 | end 70 | end 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/tomo/configuration/dsl/hosts_and_settings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Configuration 5 | module DSL 6 | module HostsAndSettings 7 | def set(settings) 8 | @config.settings.merge!(settings) 9 | self 10 | end 11 | 12 | def host(address, port: 22, roles: [], log_prefix: nil, privileged_user: "root") 13 | @config.hosts << Host.parse( 14 | address, 15 | privileged_user:, 16 | port:, 17 | roles:, 18 | log_prefix: 19 | ) 20 | self 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/tomo/configuration/dsl/tasks_block.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Configuration 5 | module DSL 6 | class TasksBlock 7 | def initialize(tasks) 8 | @tasks = tasks 9 | end 10 | 11 | def batch(&) 12 | batch = [] 13 | BatchBlock.new(batch).instance_eval(&) 14 | @tasks << batch unless batch.empty? 15 | self 16 | end 17 | 18 | def run(task, privileged: false) 19 | task = String.new(task).extend(Runtime::PrivilegedTask) if privileged 20 | @tasks << task 21 | self 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/tomo/configuration/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Configuration 5 | class Environment 6 | attr_accessor :hosts, :settings 7 | 8 | def initialize 9 | @hosts = [] 10 | @settings = {} 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/tomo/configuration/glob.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Configuration 5 | class Glob 6 | def initialize(spec) 7 | @spec = spec.to_s.freeze 8 | regexp_parts = @spec.split(/(\*)/).map do |part| 9 | part == "*" ? ".*" : Regexp.quote(part) 10 | end 11 | @regexp = Regexp.new(regexp_parts.join).freeze 12 | freeze 13 | end 14 | 15 | def match?(str) 16 | regexp.match?(str) 17 | end 18 | 19 | def to_s 20 | spec 21 | end 22 | 23 | private 24 | 25 | attr_reader :regexp, :spec 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/tomo/configuration/plugin_file_not_found_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Configuration 5 | class PluginFileNotFoundError < Error 6 | attr_accessor :path 7 | 8 | def to_console 9 | <<~ERROR 10 | A plugin specified by this project could not be loaded. 11 | File does not exist: #{yellow(path)} 12 | ERROR 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/tomo/configuration/plugins_registry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Configuration 5 | class PluginsRegistry 6 | autoload :FileResolver, "tomo/configuration/plugins_registry/file_resolver" 7 | autoload :GemResolver, "tomo/configuration/plugins_registry/gem_resolver" 8 | 9 | attr_reader :helper_modules, :settings 10 | 11 | def initialize 12 | @settings = {} 13 | @helper_modules = [] 14 | @namespaced_classes = [] 15 | end 16 | 17 | def task_names 18 | bind_tasks(nil).keys 19 | end 20 | 21 | def bind_tasks(context) 22 | namespaced_classes.each_with_object({}) do |(namespace, klass), result| 23 | library = klass.new(context) 24 | 25 | klass.public_instance_methods(false).each do |name| 26 | qualified = [namespace, name].compact.join(":") 27 | result[qualified] = library.public_method(name) 28 | end 29 | end 30 | end 31 | 32 | def load_plugin_by_name(name) 33 | plugin = GemResolver.resolve(name) 34 | load_plugin(name, plugin) 35 | end 36 | 37 | def load_plugin_from_path(path) 38 | name = File.basename(path).sub(/\.rb$/i, "") 39 | plugin = FileResolver.resolve(path) 40 | load_plugin(name, plugin) 41 | end 42 | 43 | def load_plugin(namespace, plugin_class) 44 | Tomo.logger.debug("Loading plugin #{plugin_class}") 45 | 46 | helper_modules.push(*plugin_class.helper_modules) 47 | settings.merge!(plugin_class.default_settings) { |_, exist, _| exist } 48 | register_task_libraries(namespace, *plugin_class.tasks_classes) 49 | end 50 | 51 | private 52 | 53 | attr_reader :namespaced_classes 54 | 55 | def register_task_libraries(namespace, *library_classes) 56 | library_classes.each { |cls| register_task_library(namespace, cls) } 57 | end 58 | 59 | def register_task_library(namespace, library_class) 60 | Tomo.logger.debug("Registering task library #{library_class} (#{namespace.inspect} namespace)") 61 | namespaced_classes << [namespace, library_class] 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/tomo/configuration/plugins_registry/file_resolver.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Configuration 5 | class PluginsRegistry::FileResolver 6 | def self.resolve(path) 7 | new(path).plugin_module 8 | end 9 | 10 | def initialize(path) 11 | @path = path 12 | end 13 | 14 | def plugin_module 15 | raise_file_not_found(path) unless File.file?(path) 16 | 17 | Tomo.logger.debug("Loading plugin from #{path.inspect}") 18 | script = File.read(path) 19 | plugin = define_anonymous_plugin_class 20 | plugin.class_eval(script, path.to_s, 1) 21 | 22 | plugin 23 | end 24 | 25 | private 26 | 27 | attr_reader :path 28 | 29 | def raise_file_not_found(path) 30 | PluginFileNotFoundError.raise_with(path:) 31 | end 32 | 33 | def define_anonymous_plugin_class 34 | name = path.to_s 35 | plugin = Class.new(TaskLibrary) 36 | plugin.extend(PluginDSL) 37 | plugin.send(:tasks, plugin) 38 | plugin.define_singleton_method(:to_s) do 39 | super().sub(/>$/, "(#{name})>") 40 | end 41 | plugin 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/tomo/configuration/plugins_registry/gem_resolver.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Configuration 5 | class PluginsRegistry::GemResolver 6 | PLUGIN_PREFIX = "tomo/plugin" 7 | private_constant :PLUGIN_PREFIX 8 | 9 | def self.resolve(name) 10 | new(name).plugin_module 11 | end 12 | 13 | def initialize(name) 14 | @name = name 15 | end 16 | 17 | def plugin_module 18 | plugin_path = [PLUGIN_PREFIX, name.tr("-", "/")].join("/") 19 | require plugin_path 20 | 21 | plugin = constantize(plugin_path) 22 | assert_compatible_api(plugin) 23 | 24 | plugin 25 | rescue LoadError => e 26 | raise unless e.message.match?(/\s#{Regexp.quote(plugin_path)}$/) 27 | 28 | raise_unknown_plugin_error(e) 29 | end 30 | 31 | private 32 | 33 | attr_reader :name 34 | 35 | def assert_compatible_api(plugin) 36 | return if plugin.is_a?(::Tomo::PluginDSL) 37 | 38 | raise "#{plugin} does not extend Tomo::PluginDSL" 39 | end 40 | 41 | def constantize(path) 42 | parts = path.split("/") 43 | parts.reduce(Object) do |parent, part| 44 | child = part.gsub(/^[a-z]|_[a-z]/) { |str| str[-1].upcase } 45 | parent.const_get(child, false) 46 | end 47 | end 48 | 49 | def raise_unknown_plugin_error(error) 50 | UnknownPluginError.raise_with( 51 | error.message, 52 | name:, 53 | gem_name: "#{PLUGIN_PREFIX}/#{name}".tr("/", "-"), 54 | known_plugins: scan_for_plugins 55 | ) 56 | end 57 | 58 | def scan_for_plugins 59 | Gem.find_latest_files("#{PLUGIN_PREFIX}/*.rb").map do |file| 60 | file[%r{#{PLUGIN_PREFIX}/(.+).rb$}o, 1].tr("/", "-") 61 | end.uniq.sort 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/tomo/configuration/project_not_found_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Configuration 5 | class ProjectNotFoundError < Tomo::Error 6 | attr_accessor :path 7 | 8 | def to_console 9 | path == DEFAULT_CONFIG_PATH ? default_message : custom_message 10 | end 11 | 12 | private 13 | 14 | def default_message 15 | <<~ERROR 16 | A #{yellow(path)} configuration file is required to run this command. 17 | Are you in the right directory? 18 | 19 | To create a new #{yellow(path)} file, run #{blue('tomo init')}. 20 | ERROR 21 | end 22 | 23 | def custom_message 24 | <<~ERROR 25 | #{yellow(path)} does not exist. 26 | ERROR 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/tomo/configuration/role_based_task_filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Configuration 5 | class RoleBasedTaskFilter 6 | def initialize 7 | @globs = {} 8 | end 9 | 10 | def freeze 11 | globs.freeze 12 | super 13 | end 14 | 15 | def add_role(name, task_specs) 16 | name = name.to_s 17 | task_globs = Array(task_specs).flatten.map { |spec| Glob.new(spec) } 18 | task_globs.each do |task_glob| 19 | (globs[task_glob] ||= []) << name 20 | end 21 | end 22 | 23 | def filter(tasks, host:) 24 | roles = host.roles 25 | roles = [""] if roles.empty? 26 | tasks.select do |task| 27 | roles.any? { |role| match?(task, role) } 28 | end 29 | end 30 | 31 | private 32 | 33 | attr_reader :globs 34 | 35 | def match?(task, role) 36 | task_globs = globs.keys.select { |glob| glob.match?(task) } # rubocop:disable Style/SelectByRegexp 37 | return true if task_globs.empty? 38 | 39 | roles = globs.values_at(*task_globs).flatten 40 | roles.include?(role) 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/tomo/configuration/unknown_environment_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Configuration 5 | class UnknownEnvironmentError < Tomo::Error 6 | attr_accessor :name, :known_environments 7 | 8 | def to_console 9 | known_environments.empty? ? no_envs : wrong_envs 10 | end 11 | 12 | private 13 | 14 | def no_envs 15 | <<~ERROR 16 | This project does not have distinct environments. 17 | 18 | Run tomo again without the #{yellow("-e #{name}")} option. 19 | ERROR 20 | end 21 | 22 | def wrong_envs 23 | error = <<~ERROR 24 | #{yellow(name)} is not a recognized environment for this project. 25 | ERROR 26 | 27 | if suggestions.any? 28 | error + suggestions.to_console 29 | else 30 | envs = known_environments.map { |env| blue(" #{env}") } 31 | error + <<~ENVS 32 | 33 | The following environments are available: 34 | 35 | #{envs.join("\n")} 36 | ENVS 37 | end 38 | end 39 | 40 | def suggestions 41 | @_suggestions ||= Error::Suggestions.new(dictionary: known_environments, word: name) 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/tomo/configuration/unknown_plugin_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Configuration 5 | class UnknownPluginError < Tomo::Error 6 | attr_accessor :name, :known_plugins, :gem_name 7 | 8 | def to_console 9 | error = <<~ERROR 10 | #{yellow(name)} is not a recognized plugin. 11 | ERROR 12 | 13 | sugg = Error::Suggestions.new(dictionary: known_plugins, word: name) 14 | error += sugg.to_console if sugg.any? 15 | 16 | error + gem_suggestion 17 | end 18 | 19 | private 20 | 21 | def gem_suggestion 22 | return "\nYou may need to add #{yellow(gem_name)} to your Gemfile." if Tomo.bundled? 23 | 24 | messages = ["\nYou may need to install the #{yellow(gem_name)} gem."] 25 | if present_in_gemfile? 26 | messages << "\nTry prefixing the tomo command with #{blue('bundle exec')} to fix this error." 27 | end 28 | 29 | messages.join 30 | end 31 | 32 | def present_in_gemfile? 33 | return false unless File.file?("Gemfile") 34 | 35 | File.read("Gemfile").match?(/^\s*gem ['"]#{Regexp.quote(gem_name)}['"]/) 36 | rescue IOError 37 | false 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/tomo/configuration/unspecified_environment_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Configuration 5 | class UnspecifiedEnvironmentError < Tomo::Error 6 | attr_accessor :environments 7 | 8 | def to_console 9 | <<~ERROR 10 | No environment specified. 11 | 12 | This is a multi-environment project. To run a remote task you must specify 13 | which environment to use by including the #{blue('-e')} option. 14 | 15 | Run tomo again with one of these options to specify the environment: 16 | 17 | #{env_options} 18 | ERROR 19 | end 20 | 21 | private 22 | 23 | def env_options 24 | environments.each_with_object([]) do |env, options| 25 | options << blue(" -e #{env}") 26 | end.join("\n") 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/tomo/console.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | require "io/console" 5 | 6 | module Tomo 7 | class Console 8 | autoload :KeyReader, "tomo/console/key_reader" 9 | autoload :Menu, "tomo/console/menu" 10 | autoload :NonInteractiveError, "tomo/console/non_interactive_error" 11 | 12 | class << self 13 | extend Forwardable 14 | def_delegators :@instance, :interactive?, :prompt, :menu 15 | end 16 | 17 | def initialize(env=ENV, input=$stdin, output=$stdout) 18 | @env = env 19 | @input = input 20 | @output = output 21 | end 22 | 23 | def interactive? 24 | input.respond_to?(:raw) && input.respond_to?(:tty?) && input.tty? && !ci? 25 | end 26 | 27 | def prompt(question) 28 | assert_interactive 29 | 30 | output.print question 31 | line = input.gets 32 | raise_non_interactive if line.nil? 33 | 34 | line.chomp 35 | end 36 | 37 | def menu(question, choices:) 38 | assert_interactive 39 | 40 | Menu.new(question, choices).prompt_for_selection 41 | end 42 | 43 | private 44 | 45 | attr_reader :env, :input, :output 46 | 47 | CI_VARS = %w[ 48 | JENKINS_HOME 49 | JENKINS_URL 50 | GITHUB_ACTION 51 | TRAVIS 52 | CIRCLECI 53 | TEAMCITY_VERSION 54 | bamboo_buildKey 55 | GITLAB_CI 56 | CI 57 | ].freeze 58 | private_constant :CI_VARS 59 | 60 | def ci? 61 | env.keys.intersect?(CI_VARS) 62 | end 63 | 64 | def assert_interactive 65 | raise_non_interactive unless interactive? 66 | end 67 | 68 | def raise_non_interactive 69 | NonInteractiveError.raise_with(task: Runtime::Current.task, ci_var: (env.keys & CI_VARS).first) 70 | end 71 | end 72 | end 73 | 74 | Tomo::Console.instance_variable_set :@instance, Tomo::Console.new 75 | -------------------------------------------------------------------------------- /lib/tomo/console/key_reader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | require "io/console" 5 | require "time" 6 | 7 | module Tomo 8 | class Console 9 | class KeyReader 10 | extend Forwardable 11 | 12 | def initialize(input=$stdin) 13 | @buffer = +"" 14 | @input = input 15 | end 16 | 17 | def next 18 | pressed = raw { getc } 19 | pressed << read_chars_nonblock if pressed == "\e" 20 | raise Interrupt if pressed == ?\C-c 21 | 22 | clear if !pressed.match?(/\A\w+\z/) || seconds_since_last_press > 0.75 23 | buffer << pressed 24 | end 25 | 26 | private 27 | 28 | def_delegators :@input, :getc, :raw, :read_nonblock 29 | def_delegators :buffer, :clear 30 | 31 | attr_reader :buffer 32 | 33 | def seconds_since_last_press 34 | start = @last_press_at || 0 35 | @last_press_at = Time.now.to_f 36 | @last_press_at - start 37 | end 38 | 39 | def read_chars_nonblock 40 | chars = +"" 41 | loop do 42 | next_char = raw { read_nonblock(1) } 43 | break if next_char.nil? 44 | 45 | chars << next_char 46 | end 47 | chars 48 | rescue IO::WaitReadable 49 | chars 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/tomo/console/non_interactive_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Console 5 | class NonInteractiveError < Tomo::Error 6 | attr_accessor :task, :ci_var 7 | 8 | def to_console 9 | error = "" 10 | error += "#{operation_name} requires an interactive console." 11 | error += "\n\n#{seems_like_ci}" if ci_var 12 | error 13 | end 14 | 15 | private 16 | 17 | def seems_like_ci 18 | <<~ERROR 19 | This appears to be a non-interactive CI environment because the 20 | #{yellow(ci_var)} environment variable is set. 21 | ERROR 22 | end 23 | 24 | def operation_name 25 | task ? "The #{yellow(task)} task" : "Tomo::Console" 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/tomo/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Error < StandardError 5 | autoload :Suggestions, "tomo/error/suggestions" 6 | 7 | include Colors 8 | 9 | def self.raise_with(message=nil, attributes) 10 | err = new(message) 11 | attributes.each { |attr, value| err.public_send(:"#{attr}=", value) } 12 | raise err 13 | end 14 | 15 | private 16 | 17 | def debug_suggestion 18 | return if Tomo.debug? 19 | 20 | "For more troubleshooting info, run tomo again using the #{blue('--debug')} option." 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/tomo/error/suggestions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Error 5 | class Suggestions 6 | def initialize(dictionary:, word:) 7 | @dictionary = dictionary 8 | @word = word 9 | end 10 | 11 | def any? 12 | to_a.any? 13 | end 14 | 15 | def to_a 16 | @_suggestions ||= if defined?(DidYouMean::SpellChecker) 17 | checker = DidYouMean::SpellChecker.new(dictionary:) 18 | suggestions = checker.correct(word) 19 | suggestions || [] 20 | else 21 | [] 22 | end 23 | end 24 | 25 | def to_console 26 | return unless any? 27 | 28 | sentence = to_sentence(to_a.map { |word| Colors.blue(word) }) 29 | "\nDid you mean #{sentence}?\n" 30 | end 31 | 32 | private 33 | 34 | attr_reader :dictionary, :word 35 | 36 | def to_sentence(words) 37 | return words.first if words.length == 1 38 | return words.join(" or ") if words.length == 2 39 | 40 | words[0...-1].join(", ") + ", or " + words.last 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/tomo/host.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Host 5 | PATTERN = /^(?:(\S+)@)?(\S*?)$/ 6 | private_constant :PATTERN 7 | 8 | attr_reader :address, :log_prefix, :user, :port, :roles, :as_privileged 9 | 10 | def self.parse(host, **kwargs) 11 | host = host.to_s.strip 12 | user, address = host.match(PATTERN).captures 13 | raise ArgumentError, "host cannot be blank" if address.empty? 14 | 15 | new(user:, address:, **kwargs) 16 | end 17 | 18 | def initialize(address:, port: nil, log_prefix: nil, roles: nil, user: nil, privileged_user: "root") 19 | @user = user.freeze 20 | @port = (port || 22).to_i.freeze 21 | @address = address.freeze 22 | @log_prefix = log_prefix.freeze 23 | @roles = Array(roles).map(&:freeze).freeze 24 | @as_privileged = privileged_copy(privileged_user) 25 | freeze 26 | end 27 | 28 | def with_log_prefix(prefix) 29 | copy = dup 30 | copy.instance_variable_set(:@log_prefix, prefix) 31 | copy.freeze 32 | end 33 | 34 | def to_s 35 | str = user ? "#{user}@#{address}" : address 36 | str += ":#{port}" unless port == 22 37 | str 38 | end 39 | 40 | def to_ssh_args 41 | args = [user ? "#{user}@#{address}" : address] 42 | args.push("-p", port.to_s) unless port == 22 43 | args 44 | end 45 | 46 | private 47 | 48 | def privileged_copy(priv_user) 49 | return self if user == priv_user 50 | 51 | new_prefix = Colors.red([log_prefix, priv_user].compact.join(":")) 52 | copy = dup 53 | copy.instance_variable_set(:@user, priv_user) 54 | copy.instance_variable_set(:@log_prefix, new_prefix) 55 | copy.freeze 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/tomo/logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | 5 | module Tomo 6 | class Logger 7 | autoload :TaggedIO, "tomo/logger/tagged_io" 8 | 9 | extend Forwardable 10 | include Tomo::Colors 11 | 12 | def initialize(stdout: $stdout, stderr: $stderr) 13 | @stdout = TaggedIO.new(stdout) 14 | @stderr = TaggedIO.new(stderr) 15 | end 16 | 17 | def script_start(script) 18 | return unless script.echo? 19 | 20 | puts yellow(script.echo_string) 21 | end 22 | 23 | def script_output(script, output) 24 | return if script.silent? 25 | 26 | puts output 27 | end 28 | 29 | def script_end(script, result) 30 | return unless result.failure? 31 | return unless script.silent? 32 | return unless script.raise_on_error? 33 | 34 | puts result.output 35 | end 36 | 37 | def connect(host) 38 | puts gray("→ Connecting to #{host}") 39 | end 40 | 41 | def task_start(task) 42 | puts blue("• #{task}") 43 | end 44 | 45 | def info(message) 46 | puts message 47 | end 48 | 49 | def error(message) 50 | stderr.puts indent("\n" + red("ERROR: ") + message.strip + "\n\n") 51 | end 52 | 53 | def warn(message) 54 | stderr.puts red("WARNING: ") + message 55 | end 56 | 57 | def debug(message) 58 | return unless Tomo.debug? 59 | 60 | stderr.puts gray("DEBUG: #{message}") 61 | end 62 | 63 | private 64 | 65 | def_delegators :@stdout, :puts 66 | attr_reader :stderr 67 | 68 | def indent(message, prefix=" ") 69 | message.gsub(/^/, prefix) 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/tomo/logger/tagged_io.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Logger 5 | class TaggedIO 6 | include Colors 7 | 8 | def initialize(io) 9 | @io = io 10 | end 11 | 12 | def puts(str) 13 | io.puts(str.to_s.gsub(/^/, prefix)) 14 | end 15 | 16 | private 17 | 18 | attr_reader :io 19 | 20 | def prefix 21 | host = Runtime::Current.host 22 | return "" if host.nil? 23 | 24 | tags = [] 25 | tags << red("*") if Tomo.dry_run? 26 | tags << grayish("[#{host.log_prefix}]") unless host.log_prefix.nil? 27 | return "" if tags.empty? 28 | 29 | "#{tags.join(' ')} " 30 | end 31 | 32 | def grayish(str) 33 | parts = str.split(/(\e.*?\e\[0m)/) 34 | parts.map! do |part| 35 | part.start_with?("\e") ? part : gray(part) 36 | end.join 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/tomo/path.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "delegate" 4 | require "pathname" 5 | 6 | module Tomo 7 | class Path < SimpleDelegator 8 | def initialize(path) 9 | super(path.to_s) 10 | freeze 11 | end 12 | 13 | def join(*other) 14 | self.class.new(Pathname.new(self).join(*other)) 15 | end 16 | 17 | def dirname 18 | self.class.new(Pathname.new(self).dirname) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/tomo/paths.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Paths 5 | def initialize(settings) 6 | @settings = settings 7 | freeze 8 | end 9 | 10 | def deploy_to 11 | path(:deploy_to) 12 | end 13 | 14 | private 15 | 16 | attr_reader :settings 17 | 18 | def method_missing(method, *args) 19 | return super unless setting?(method) 20 | raise ArgumentError, "#{method} takes no arguments" unless args.empty? 21 | 22 | path(:"#{method}_path") 23 | end 24 | 25 | def respond_to_missing?(method, include_private) 26 | setting?(method) || super 27 | end 28 | 29 | def setting?(name) 30 | settings.key?(:"#{name}_path") 31 | end 32 | 33 | def path(setting) 34 | return nil if settings[setting].nil? 35 | 36 | path = settings.fetch(setting).to_s.gsub(%r{//+}, "/") 37 | Path.new(path) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/tomo/plugin.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module Plugin 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/tomo/plugin/bundler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "bundler/helpers" 4 | require_relative "bundler/tasks" 5 | 6 | module Tomo::Plugin 7 | module Bundler 8 | extend Tomo::PluginDSL 9 | 10 | tasks Tomo::Plugin::Bundler::Tasks 11 | helpers Tomo::Plugin::Bundler::Helpers 12 | 13 | defaults bundler_config_path: ".bundle/config", 14 | bundler_deployment: true, 15 | bundler_gemfile: nil, 16 | bundler_ignore_messages: true, 17 | bundler_jobs: nil, 18 | bundler_path: "%{shared_path}/bundle", 19 | bundler_retry: "3", 20 | bundler_version: nil, 21 | bundler_without: %w[development test] 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/tomo/plugin/bundler/helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo::Plugin::Bundler 4 | module Helpers 5 | def bundle(*args, **opts) 6 | prepend("bundle") do 7 | run(*args, **opts, default_chdir: paths.release) 8 | end 9 | end 10 | 11 | def bundle?(*args, **opts) 12 | result = bundle(*args, **opts, raise_on_error: false) 13 | result.success? 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/tomo/plugin/bundler/tasks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "yaml" 4 | 5 | module Tomo::Plugin::Bundler 6 | class Tasks < Tomo::TaskLibrary 7 | CONFIG_SETTINGS = %i[ 8 | bundler_deployment 9 | bundler_gemfile 10 | bundler_ignore_messages 11 | bundler_jobs 12 | bundler_path 13 | bundler_retry 14 | bundler_without 15 | ].freeze 16 | private_constant :CONFIG_SETTINGS 17 | 18 | def config 19 | configuration = settings_to_configuration 20 | remote.mkdir_p paths.bundler_config.dirname 21 | remote.write(text: YAML.dump(configuration), to: paths.bundler_config) 22 | end 23 | 24 | def install 25 | return if remote.bundle?("check") && !dry_run? 26 | 27 | remote.bundle("install") 28 | end 29 | 30 | def clean 31 | remote.bundle("clean") 32 | end 33 | 34 | def upgrade_bundler 35 | needed_bundler_ver = version_setting || extract_bundler_ver_from_lockfile 36 | remote.run("gem", "install", "bundler", "--conservative", "--no-document", "-v", needed_bundler_ver) 37 | end 38 | 39 | private 40 | 41 | def settings_to_configuration 42 | CONFIG_SETTINGS.each_with_object({}) do |key, config| 43 | next if settings[key].nil? 44 | 45 | entry_key = "BUNDLE_#{key.to_s.sub(/^bundler_/, '').upcase}" 46 | entry_value = settings.fetch(key) 47 | entry_value = entry_value.join(":") if entry_value.is_a?(Array) 48 | config[entry_key] = entry_value.to_s 49 | end 50 | end 51 | 52 | def version_setting 53 | settings[:bundler_version] 54 | end 55 | 56 | def extract_bundler_ver_from_lockfile 57 | lockfile_tail = remote.capture("tail", "-n", "10", paths.release.join("Gemfile.lock"), raise_on_error: false) 58 | version = lockfile_tail[/BUNDLED WITH\n (\S+)$/, 1] 59 | return version if version || dry_run? 60 | 61 | die <<~REASON 62 | Could not guess bundler version from Gemfile.lock. 63 | Use the :bundler_version setting to specify the version of bundler to install. 64 | REASON 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/tomo/plugin/core.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "core/helpers" 4 | require_relative "core/tasks" 5 | 6 | module Tomo::Plugin 7 | module Core 8 | extend Tomo::PluginDSL 9 | 10 | helpers Tomo::Plugin::Core::Helpers 11 | tasks Tomo::Plugin::Core::Tasks 12 | 13 | defaults Tomo::SSH::Options::DEFAULTS.merge( 14 | application: "default", 15 | concurrency: 10, 16 | current_path: "%{deploy_to}/current", 17 | deploy_to: "/var/www/%{application}", 18 | keep_releases: 10, 19 | linked_dirs: [], 20 | linked_files: [], 21 | local_user: nil, # determined at runtime 22 | release_json_path: "%{release_path}/.tomo_release.json", 23 | releases_path: "%{deploy_to}/releases", 24 | revision_log_path: "%{deploy_to}/revisions.log", 25 | shared_path: "%{deploy_to}/shared", 26 | tmp_path: "/tmp/tomo-#{SecureRandom.alphanumeric(8)}", 27 | tomo_config_file_path: nil, # determined at runtime 28 | run_args: [] # determined at runtime 29 | ) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/tomo/plugin/core/helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "shellwords" 4 | 5 | module Tomo::Plugin::Core 6 | module Helpers 7 | def capture(*command, **run_opts) 8 | result = run(*command, silent: true, **run_opts) 9 | result.stdout 10 | end 11 | 12 | def run?(*command, **run_opts) 13 | result = run(*command, **run_opts, raise_on_error: false) 14 | result.success? 15 | end 16 | 17 | def write(to:, text: nil, template: nil, append: false, **run_opts) 18 | assert_text_or_template_required!(text, template) 19 | text = merge_template(template) unless template.nil? 20 | message = "Writing #{text.bytesize} bytes to #{to}" 21 | run( 22 | "echo -n #{text.shellescape} #{append ? '>>' : '>'} #{to.shellescape}", 23 | echo: message, 24 | **run_opts 25 | ) 26 | end 27 | 28 | def ln_sf(target, link, **run_opts) 29 | run("ln", "-sf", target, link, **run_opts) 30 | end 31 | 32 | def ln_sfn(target, link, **run_opts) 33 | run("ln", "-sfn", target, link, **run_opts) 34 | end 35 | 36 | def mkdir_p(*directories, **run_opts) 37 | run("mkdir", "-p", *directories, **run_opts) 38 | end 39 | 40 | def rm_rf(*paths, **run_opts) 41 | run("rm", "-rf", *paths, **run_opts) 42 | end 43 | 44 | def list_files(directory=nil, **run_opts) 45 | capture("ls", "-A1", directory, **run_opts).strip.split("\n") 46 | end 47 | 48 | def command_available?(command_name, **run_opts) 49 | run?("which", command_name, silent: true, **run_opts) 50 | end 51 | 52 | def file?(file, **run_opts) 53 | flag?("-f", file, **run_opts) 54 | end 55 | 56 | def executable?(file, **run_opts) 57 | flag?("-x", file, **run_opts) 58 | end 59 | 60 | def directory?(directory, **run_opts) 61 | flag?("-d", directory, **run_opts) 62 | end 63 | 64 | private 65 | 66 | def flag?(flag, path, **run_opts) 67 | run?("[ #{flag} #{path.to_s.shellescape} ]", **run_opts) 68 | end 69 | 70 | def assert_text_or_template_required!(text, template) 71 | return if text.nil? ^ template.nil? 72 | 73 | raise ArgumentError, "specify text: or template:" 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/tomo/plugin/env.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "env/tasks" 4 | 5 | module Tomo::Plugin 6 | module Env 7 | extend Tomo::PluginDSL 8 | 9 | tasks Tomo::Plugin::Env::Tasks 10 | 11 | defaults bashrc_path: ".bashrc", 12 | env_path: "%{deploy_to}/envrc", 13 | env_vars: {} 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/tomo/plugin/git.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "git/helpers" 4 | require_relative "git/tasks" 5 | 6 | module Tomo::Plugin 7 | module Git 8 | extend Tomo::PluginDSL 9 | 10 | helpers Tomo::Plugin::Git::Helpers 11 | tasks Tomo::Plugin::Git::Tasks 12 | defaults git_branch: nil, 13 | git_repo_path: "%{deploy_to}/git_repo", 14 | git_exclusions: [], 15 | git_env: { GIT_SSH_COMMAND: "ssh -o PasswordAuthentication=no -o StrictHostKeyChecking=no" }, 16 | git_ref: nil, 17 | git_url: nil, 18 | git_user_name: nil, 19 | git_user_email: nil 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/tomo/plugin/git/helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo::Plugin::Git 4 | module Helpers 5 | def git(*args, **opts) 6 | env(settings[:git_env]) do 7 | prepend("git") do 8 | run(*args, **opts) 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/tomo/plugin/nodenv.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "nodenv/tasks" 4 | 5 | module Tomo::Plugin 6 | module Nodenv 7 | extend Tomo::PluginDSL 8 | 9 | defaults bashrc_path: ".bashrc", 10 | nodenv_install_yarn: true, 11 | nodenv_node_version: nil, 12 | nodenv_yarn_version: nil 13 | 14 | tasks Tomo::Plugin::Nodenv::Tasks 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/tomo/plugin/nodenv/tasks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "shellwords" 4 | 5 | module Tomo::Plugin::Nodenv 6 | class Tasks < Tomo::TaskLibrary 7 | def install 8 | run_installer 9 | modify_bashrc 10 | install_node 11 | install_yarn 12 | end 13 | 14 | private 15 | 16 | def run_installer 17 | install_url = "https://github.com/nodenv/nodenv-installer/raw/HEAD/bin/nodenv-installer" 18 | remote.env PATH: raw("$HOME/.nodenv/bin:$HOME/.nodenv/shims:$PATH") do 19 | remote.run("curl -fsSL #{install_url.shellescape} | bash") 20 | end 21 | end 22 | 23 | def modify_bashrc 24 | existing_rc = remote.capture("cat", paths.bashrc, raise_on_error: false) 25 | return if existing_rc.include?("nodenv init") 26 | 27 | remote.write(text: <<~BASHRC + existing_rc, to: paths.bashrc) 28 | if [ -d $HOME/.nodenv ]; then 29 | export PATH="$HOME/.nodenv/bin:$PATH" 30 | eval "$(nodenv init -)" 31 | fi 32 | BASHRC 33 | end 34 | 35 | def install_node 36 | node_version = settings[:nodenv_node_version] || extract_node_ver_from_version_file 37 | 38 | remote.run "nodenv install #{node_version.shellescape}" unless node_installed?(node_version) 39 | remote.run "nodenv global #{node_version.shellescape}" 40 | end 41 | 42 | def install_yarn 43 | unless settings[:nodenv_install_yarn] 44 | logger.info ":nodenv_install_yarn is false; skipping yarn installation." 45 | return 46 | end 47 | 48 | version = settings[:nodenv_yarn_version] 49 | yarn_spec = version ? "yarn@#{version.shellescape}" : "yarn" 50 | remote.run "npm i -g #{yarn_spec}" 51 | end 52 | 53 | def node_installed?(version) 54 | versions = remote.capture("nodenv versions", raise_on_error: false) 55 | if versions.include?(version) 56 | logger.info("Node #{version} is already installed.") 57 | return true 58 | end 59 | false 60 | end 61 | 62 | def extract_node_ver_from_version_file 63 | path = paths.release.join(".node-version") 64 | version = remote.capture("cat", path, raise_on_error: false).strip 65 | return version unless version.empty? 66 | 67 | return "DRY_RUN_PLACEHOLDER" if dry_run? 68 | 69 | die <<~REASON 70 | Could not guess node version from .node-version file. 71 | Use the :nodenv_node_version setting to specify the version of node to install. 72 | REASON 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/tomo/plugin/puma.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "puma/tasks" 4 | 5 | module Tomo::Plugin 6 | module Puma 7 | extend Tomo::PluginDSL 8 | 9 | tasks Tomo::Plugin::Puma::Tasks 10 | defaults puma_check_timeout: 15, 11 | puma_host: "0.0.0.0", 12 | puma_port: "3000", 13 | puma_systemd_service: "puma_%{application}.service", 14 | puma_systemd_socket: "puma_%{application}.socket", 15 | puma_systemd_service_type: "notify", 16 | puma_systemd_service_path: ".config/systemd/user/%{puma_systemd_service}", 17 | puma_systemd_socket_path: ".config/systemd/user/%{puma_systemd_socket}", 18 | puma_systemd_service_template_path: File.expand_path("puma/systemd/service.erb", __dir__), 19 | puma_systemd_socket_template_path: File.expand_path("puma/systemd/socket.erb", __dir__) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/tomo/plugin/puma/systemd/service.erb: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Puma HTTP Server for <%= settings[:application] %> 3 | After=network.target 4 | Requires=<%= settings[:puma_systemd_socket] %> 5 | ConditionPathExists=<%= paths.current %> 6 | 7 | [Service] 8 | ExecStart=/bin/bash -lc 'exec bundle exec --keep-file-descriptors puma -C config/puma.rb -b tcp://<%= settings[:puma_host] %>:<%= settings[:puma_port] %>' 9 | KillMode=mixed 10 | Restart=always 11 | StandardInput=null 12 | SyslogIdentifier=%n 13 | TimeoutStopSec=5 14 | 15 | <% if settings[:puma_systemd_service_type].to_s == "notify" %> 16 | Type=notify 17 | WatchdogSec=10 18 | <% else %> 19 | Type=simple 20 | <% end %> 21 | 22 | WorkingDirectory=<%= paths.current %> 23 | # Helpful for debugging socket activation, etc. 24 | # Environment=PUMA_DEBUG=1 25 | 26 | [Install] 27 | WantedBy=default.target 28 | -------------------------------------------------------------------------------- /lib/tomo/plugin/puma/systemd/socket.erb: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Puma HTTP Server Accept Sockets for <%= settings[:application] %> 3 | 4 | [Socket] 5 | ListenStream=<%= settings[:puma_host] %>:<%= settings[:puma_port] %> 6 | 7 | # Socket options matching Puma defaults 8 | NoDelay=true 9 | ReusePort=true 10 | Backlog=1024 11 | 12 | [Install] 13 | WantedBy=sockets.target 14 | -------------------------------------------------------------------------------- /lib/tomo/plugin/rails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "rails/helpers" 4 | require_relative "rails/tasks" 5 | 6 | module Tomo::Plugin 7 | module Rails 8 | extend Tomo::PluginDSL 9 | 10 | helpers Tomo::Plugin::Rails::Helpers 11 | tasks Tomo::Plugin::Rails::Tasks 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/tomo/plugin/rails/helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo::Plugin::Rails 4 | module Helpers 5 | def rails(*args, **opts) 6 | prepend("exec", "rails") do 7 | bundle(*args, **opts) 8 | end 9 | end 10 | 11 | def rake(*args, **opts) 12 | prepend("exec", "rake") do 13 | bundle(*args, **opts) 14 | end 15 | end 16 | 17 | def rake?(*args, **opts) 18 | result = rake(*args, **opts, raise_on_error: false) 19 | result.success? 20 | end 21 | 22 | def thor(*args, **opts) 23 | prepend("exec", "thor") do 24 | bundle(*args, **opts) 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/tomo/plugin/rails/tasks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo::Plugin::Rails 4 | class Tasks < Tomo::TaskLibrary 5 | def assets_precompile 6 | remote.rake("assets:precompile") 7 | end 8 | 9 | def console 10 | remote.rails("console", settings[:run_args], attach: true) 11 | end 12 | 13 | def db_console 14 | remote.rails("dbconsole", "--include-password", settings[:run_args], attach: true) 15 | end 16 | 17 | def db_migrate 18 | remote.rake("db:migrate") 19 | end 20 | 21 | def db_seed 22 | remote.rake("db:seed") 23 | end 24 | 25 | def db_create 26 | return remote.rake("db:create") unless database_exists? 27 | 28 | logger.info "Database already exists; skipping db:create." 29 | end 30 | 31 | def db_setup 32 | return remote.rake("db:setup") unless database_exists? 33 | 34 | logger.info "Database already exists; skipping db:setup." 35 | end 36 | 37 | def db_schema_load 38 | if !schema_rb_present? 39 | logger.warn "db/schema.rb is not present; skipping schema:load." 40 | elsif database_schema_loaded? 41 | logger.info "Database schema already loaded; skipping db:schema:load." 42 | else 43 | remote.rake("db:schema:load") 44 | end 45 | end 46 | 47 | def db_structure_load 48 | if !structure_sql_present? 49 | logger.warn "db/structure.sql is not present; skipping db:structure:load." 50 | elsif database_schema_loaded? 51 | logger.info "Database structure already loaded; skipping db:structure:load." 52 | else 53 | remote.rake("db:structure:load") 54 | end 55 | end 56 | 57 | private 58 | 59 | def database_exists? 60 | remote.rake?("db:version", silent: true) && !dry_run? 61 | end 62 | 63 | def database_schema_loaded? 64 | result = remote.rake("db:version", silent: true, raise_on_error: false) 65 | schema_version = result.output[/version:\s*(\d+)$/i, 1].to_i 66 | 67 | result.success? && schema_version.positive? && !dry_run? 68 | end 69 | 70 | def schema_rb_present? 71 | remote.file?(paths.release.join("db/schema.rb")) 72 | end 73 | 74 | def structure_sql_present? 75 | remote.file?(paths.release.join("db/structure.sql")) 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/tomo/plugin/rbenv.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "rbenv/tasks" 4 | 5 | module Tomo::Plugin 6 | module Rbenv 7 | extend Tomo::PluginDSL 8 | 9 | defaults bashrc_path: ".bashrc", 10 | rbenv_ruby_version: nil 11 | 12 | tasks Tomo::Plugin::Rbenv::Tasks 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/tomo/plugin/rbenv/tasks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "shellwords" 4 | 5 | module Tomo::Plugin::Rbenv 6 | class Tasks < Tomo::TaskLibrary 7 | def install 8 | run_installer 9 | modify_bashrc 10 | compile_ruby 11 | end 12 | 13 | private 14 | 15 | def run_installer 16 | install_url = "https://github.com/rbenv/rbenv-installer/raw/HEAD/bin/rbenv-installer" 17 | remote.env PATH: raw("$HOME/.rbenv/bin:$HOME/.rbenv/shims:$PATH") do 18 | remote.run("curl -fsSL #{install_url.shellescape} | bash") 19 | end 20 | end 21 | 22 | def modify_bashrc 23 | existing_rc = remote.capture("cat", paths.bashrc, raise_on_error: false) 24 | return if existing_rc.include?("rbenv init") && existing_rc.include?("$HOME/.rbenv/bin") 25 | 26 | remote.write(text: <<~BASHRC + existing_rc, to: paths.bashrc) 27 | if [ -d $HOME/.rbenv ]; then 28 | export PATH="$HOME/.rbenv/bin:$PATH" 29 | eval "$(rbenv init -)" 30 | fi 31 | 32 | BASHRC 33 | end 34 | 35 | def compile_ruby 36 | ruby_version = version_setting || extract_ruby_ver_from_version_file 37 | 38 | unless ruby_installed?(ruby_version) 39 | logger.info("Installing ruby #{ruby_version} -- this may take several minutes") 40 | remote.run "CFLAGS=-O3 rbenv install #{ruby_version.shellescape} --verbose" 41 | end 42 | remote.run "rbenv global #{ruby_version.shellescape}" 43 | end 44 | 45 | def ruby_installed?(version) 46 | versions = remote.capture("rbenv versions", raise_on_error: false) 47 | if versions.match?(/^\*?\s*#{Regexp.quote(version)}\s/) 48 | logger.info("Ruby #{version} is already installed.") 49 | return true 50 | end 51 | false 52 | end 53 | 54 | def version_setting 55 | settings[:rbenv_ruby_version] 56 | end 57 | 58 | def extract_ruby_ver_from_version_file 59 | path = paths.release.join(".ruby-version") 60 | version = remote.capture("cat", path, raise_on_error: false).strip 61 | return version unless version.empty? 62 | 63 | return RUBY_VERSION if dry_run? 64 | 65 | die <<~REASON 66 | Could not guess ruby version from .ruby-version file. 67 | Use the :rbenv_ruby_version setting to specify the version of ruby to install. 68 | REASON 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/tomo/plugin/testing.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | raise "The testing plugin cannot be used outside of unit tests" unless defined?(Tomo::Testing) 4 | 5 | module Tomo::Plugin 6 | class Testing < Tomo::TaskLibrary 7 | extend Tomo::PluginDSL 8 | tasks self 9 | 10 | def call_helper 11 | helper, args, kwargs = settings[:run_args] 12 | value = remote.public_send(helper, *args, **(kwargs || {})) 13 | remote.host.helper_values << value 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/tomo/plugin_dsl.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module PluginDSL 5 | def self.extended(mod) 6 | mod.instance_variable_set(:@helper_modules, []) 7 | mod.instance_variable_set(:@default_settings, {}) 8 | mod.instance_variable_set(:@tasks_classes, []) 9 | end 10 | 11 | attr_reader :helper_modules, :default_settings, :tasks_classes 12 | 13 | def helpers(mod, *more_mods) 14 | @helper_modules.push(mod, *more_mods) 15 | end 16 | 17 | def defaults(settings) 18 | @default_settings.merge!(settings) 19 | end 20 | 21 | def tasks(tasks_class, *more_tasks_classes) 22 | @tasks_classes.push(tasks_class, *more_tasks_classes) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/tomo/remote.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | 5 | module Tomo 6 | class Remote 7 | include TaskAPI 8 | 9 | extend Forwardable 10 | def_delegators :ssh, :close, :host 11 | def_delegators :shell_builder, :chdir, :env, :prepend, :umask 12 | 13 | attr_reader :release 14 | 15 | def initialize(ssh, context, helper_modules) 16 | @ssh = ssh 17 | @context = context 18 | @release = {} 19 | @shell_builder = ShellBuilder.new 20 | helper_modules.each { |mod| extend(mod) } 21 | freeze 22 | end 23 | 24 | def attach(*command, default_chdir: nil, **command_opts) 25 | full_command = shell_builder.build(*command, default_chdir:) 26 | ssh.ssh_exec(Script.new(full_command, pty: true, **command_opts)) 27 | end 28 | 29 | def run(*command, attach: false, default_chdir: nil, **command_opts) 30 | attach(*command, default_chdir:, **command_opts) if attach 31 | 32 | full_command = shell_builder.build(*command, default_chdir:) 33 | ssh.ssh_subprocess(Script.new(full_command, **command_opts)) 34 | end 35 | 36 | private 37 | 38 | attr_reader :context, :ssh, :shell_builder 39 | 40 | def remote 41 | self 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/tomo/result.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Result 5 | def self.empty_success 6 | new(stdout: +"", stderr: +"", exit_status: 0) 7 | end 8 | 9 | attr_reader :stdout, :stderr, :exit_status 10 | 11 | def initialize(stdout:, stderr:, exit_status:) 12 | @stdout = stdout 13 | @stderr = stderr 14 | @exit_status = exit_status 15 | freeze 16 | end 17 | 18 | def success? 19 | exit_status.zero? 20 | end 21 | 22 | def failure? 23 | !success? 24 | end 25 | 26 | def output 27 | [stdout, stderr].compact.join 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/tomo/runtime/concurrent_ruby_load_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Runtime 5 | class ConcurrentRubyLoadError < Tomo::Error 6 | attr_accessor :version 7 | 8 | def to_console 9 | <<~ERROR 10 | Running tasks on multiple hosts requires the #{yellow('concurrent-ruby')} gem. 11 | To install it, #{install_instructions} 12 | ERROR 13 | end 14 | 15 | private 16 | 17 | def install_instructions 18 | if Tomo.bundled? 19 | gem_entry = %Q(gem "concurrent-ruby", "#{version}") 20 | "add this entry to your Gemfile:\n\n #{blue(gem_entry)}" 21 | else 22 | gem_install = "gem install concurrent-ruby -v '#{version}'" 23 | "run:\n\n #{blue(gem_install)}" 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/tomo/runtime/concurrent_ruby_thread_pool.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | concurrent_ver = "~> 1.1" 4 | 5 | begin 6 | gem "concurrent-ruby", concurrent_ver 7 | require "concurrent" 8 | rescue LoadError => e 9 | Tomo::Runtime::ConcurrentRubyLoadError.raise_with(e.message, version: concurrent_ver) 10 | end 11 | 12 | module Tomo 13 | class Runtime 14 | class ConcurrentRubyThreadPool 15 | include ::Concurrent::Promises::FactoryMethods 16 | 17 | def initialize(size) 18 | @executor = ::Concurrent::FixedThreadPool.new(size) 19 | @promises = [] 20 | end 21 | 22 | def post(...) 23 | return if failure? 24 | 25 | promises << future_on(executor, ...) 26 | .on_rejection_using(executor) do |reason| 27 | self.failure = reason 28 | end 29 | 30 | nil 31 | end 32 | 33 | def run_to_completion 34 | promises_to_wait = promises.dup 35 | promises.clear 36 | zip_futures_on(executor, *promises_to_wait).value 37 | raise failure if failure? 38 | end 39 | 40 | def failure? 41 | !!failure 42 | end 43 | 44 | private 45 | 46 | attr_accessor :failure 47 | attr_reader :executor, :promises 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/tomo/runtime/context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Runtime 5 | class Context 6 | attr_reader :paths, :settings 7 | 8 | def initialize(settings) 9 | @paths = Paths.new(settings) 10 | @settings = settings.freeze 11 | freeze 12 | end 13 | 14 | def current_remote 15 | Current.remote 16 | end 17 | 18 | def current_task 19 | Current.task 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/tomo/runtime/current.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Runtime 5 | module Current 6 | class << self 7 | def host 8 | fiber_locals[:host] || remote&.host 9 | end 10 | 11 | def remote 12 | fiber_locals[:remote] 13 | end 14 | 15 | def task 16 | fiber_locals[:task] 17 | end 18 | 19 | def with(new_locals) 20 | old_locals = slice(*new_locals.keys) 21 | fiber_locals.merge!(new_locals) 22 | yield 23 | ensure 24 | fiber_locals.merge!(old_locals) 25 | end 26 | 27 | def variables 28 | fiber_locals.dup.freeze 29 | end 30 | 31 | private 32 | 33 | def slice(*keys) 34 | keys.to_h { |key| [key, fiber_locals[key]] } 35 | end 36 | 37 | def fiber_locals 38 | Thread.current["Tomo::Runtime::Current"] ||= {} 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/tomo/runtime/explanation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Runtime 5 | class Explanation 6 | def initialize(applicable_hosts, plan, concurrency) 7 | @applicable_hosts = applicable_hosts 8 | @plan = plan 9 | @concurrency = concurrency 10 | end 11 | 12 | def to_s # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity 13 | desc = [] 14 | threads = [applicable_hosts.length, concurrency].min 15 | desc << "CONCURRENTLY (#{threads} THREADS):" if threads > 1 16 | applicable_hosts.each do |host| 17 | indent = threads > 1 ? " = " : "" 18 | desc << "#{indent}CONNECT #{host}" 19 | end 20 | plan.each do |steps| 21 | threads = [steps.length, concurrency].min 22 | desc << "CONCURRENTLY (#{threads} THREADS):" if threads > 1 23 | steps.each do |step| 24 | indent = threads > 1 ? " = " : "" 25 | if threads > 1 && step.applicable_tasks.length > 1 26 | desc << "#{indent}IN SEQUENCE:" 27 | indent = indent.sub("=", " ") 28 | end 29 | desc << step.explain.gsub(/^/, indent) 30 | end 31 | end 32 | desc.join("\n") 33 | end 34 | 35 | private 36 | 37 | attr_reader :applicable_hosts, :plan, :concurrency 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/tomo/runtime/host_execution_step.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Runtime 5 | class HostExecutionStep 6 | attr_reader :applicable_hosts, :applicable_tasks 7 | 8 | def initialize(tasks:, host:, task_filter:, task_runner:) 9 | tasks = Array(tasks).flatten 10 | @host = host 11 | @task_runner = task_runner 12 | @applicable_tasks = task_filter.filter(tasks, host: @host).freeze 13 | @applicable_hosts = compute_applicable_hosts 14 | freeze 15 | end 16 | 17 | def empty? 18 | applicable_tasks.empty? 19 | end 20 | 21 | def execute(thread_pool:, remotes:) 22 | return if applicable_tasks.empty? 23 | 24 | thread_pool.post do 25 | applicable_tasks.each do |task| 26 | break if thread_pool.failure? 27 | 28 | task_host = task.is_a?(PrivilegedTask) ? host.as_privileged : host 29 | remote = remotes[task_host] 30 | task_runner.run(task:, remote:) 31 | end 32 | end 33 | end 34 | 35 | def explain 36 | desc = [] 37 | applicable_tasks.each do |task| 38 | task_host = task.is_a?(PrivilegedTask) ? host.as_privileged : host 39 | desc << "RUN #{task} ON #{task_host}" 40 | end 41 | desc.join("\n") 42 | end 43 | 44 | private 45 | 46 | attr_reader :host, :task_runner 47 | 48 | def compute_applicable_hosts 49 | priv_tasks, normal_tasks = applicable_tasks.partition do |task| 50 | task.is_a?(PrivilegedTask) 51 | end 52 | 53 | hosts = [] 54 | hosts << host if normal_tasks.any? 55 | hosts << host.as_privileged if priv_tasks.any? 56 | hosts.uniq.freeze 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/tomo/runtime/inline_thread_pool.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Runtime 5 | class InlineThreadPool 6 | def post(*args) 7 | return if failure? 8 | 9 | yield(*args) 10 | nil 11 | rescue StandardError => e 12 | self.failure = e 13 | nil 14 | end 15 | 16 | def run_to_completion 17 | raise failure if failure? 18 | end 19 | 20 | def failure? 21 | !!failure 22 | end 23 | 24 | private 25 | 26 | attr_accessor :failure 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/tomo/runtime/no_tasks_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Runtime 5 | class NoTasksError < Error 6 | attr_accessor :task_type 7 | 8 | def to_console 9 | <<~ERROR 10 | No #{task_type} tasks are configured. 11 | You can specify them using a #{yellow(task_type)} block in #{yellow(Tomo::DEFAULT_CONFIG_PATH)}. 12 | 13 | More configuration documentation and examples can be found here: 14 | 15 | #{blue('https://tomo.mattbrictson.com/configuration')} 16 | ERROR 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/tomo/runtime/privileged_task.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Runtime 5 | module PrivilegedTask 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/tomo/runtime/settings_interpolation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Runtime 5 | class SettingsInterpolation 6 | def self.interpolate(settings) 7 | new(settings).call 8 | end 9 | 10 | def initialize(settings) 11 | @settings = symbolize(settings) 12 | end 13 | 14 | def call 15 | hash = settings.keys.to_h { |name| [name, fetch(name)] } 16 | dump_settings(hash) if Tomo.debug? 17 | hash 18 | end 19 | 20 | private 21 | 22 | attr_reader :settings 23 | 24 | def fetch(name, stack=[]) 25 | raise_circular_dependency_error(name, stack) if stack.include?(name) 26 | value = settings.fetch(name) 27 | return value unless value.is_a?(String) 28 | 29 | value.gsub(/%{(\w+)}/) do 30 | fetch(Regexp.last_match[1].to_sym, stack + [name]) 31 | end 32 | end 33 | 34 | def raise_circular_dependency_error(name, stack) 35 | dependencies = [*stack, name].join(" -> ") 36 | raise "Circular dependency detected in settings: #{dependencies}" 37 | end 38 | 39 | def symbolize(hash) 40 | hash.transform_keys(&:to_sym) 41 | end 42 | 43 | def dump_settings(hash) 44 | key_len = hash.keys.map { |k| k.to_s.length }.max 45 | dump = +"Settings: {\n" 46 | hash.to_a.sort_by(&:first).each do |key, value| 47 | justified_key = "#{key}:".ljust(key_len + 1) 48 | dump << " #{justified_key} #{value.inspect},\n" 49 | end 50 | dump << "}" 51 | Tomo.logger.debug(dump) 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/tomo/runtime/settings_required_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Runtime 5 | class SettingsRequiredError < Tomo::Error 6 | attr_accessor :command_name, :settings, :task 7 | 8 | def to_console 9 | <<~ERROR 10 | The #{yellow(task)} task requires #{settings_sentence} 11 | 12 | Settings can be specified in #{blue(DEFAULT_CONFIG_PATH)}, or by running tomo 13 | with the #{blue('-s')} option. For example: 14 | 15 | #{blue("tomo -s #{settings.first}=foo")} 16 | 17 | You can also use environment variables: 18 | 19 | #{blue("TOMO_#{settings.first.upcase}=foo tomo #{command_name}")} 20 | ERROR 21 | end 22 | 23 | private 24 | 25 | def settings_sentence 26 | return "a value for the #{yellow(settings.first.to_s)} setting." if settings.length == 1 27 | 28 | sentence = "values for these settings:\n\n " 29 | sentence + settings.map { |s| yellow(s.to_s) }.join("\n ") 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/tomo/runtime/task_aborted_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Runtime 5 | class TaskAbortedError < Tomo::Error 6 | attr_accessor :task, :host 7 | 8 | def to_console 9 | <<~ERROR 10 | The #{yellow(task)} task failed on #{yellow(host)}. 11 | 12 | #{red(message)} 13 | ERROR 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/tomo/runtime/task_runner.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Runtime 5 | class TaskRunner 6 | extend Forwardable 7 | 8 | def_delegators :@context, :paths, :settings 9 | attr_reader :context 10 | 11 | def initialize(plugins_registry:, settings:) 12 | interpolated_settings = SettingsInterpolation.interpolate( 13 | plugins_registry.settings.merge(settings) 14 | ) 15 | @helper_modules = plugins_registry.helper_modules.freeze 16 | @context = Context.new(interpolated_settings) 17 | @tasks_by_name = plugins_registry.bind_tasks(context).freeze 18 | freeze 19 | end 20 | 21 | def validate_task!(name) 22 | return if tasks_by_name.key?(name) 23 | 24 | UnknownTaskError.raise_with(name, unknown_task: name, known_tasks: tasks_by_name.keys) 25 | end 26 | 27 | def run(task:, remote:) 28 | validate_task!(task) 29 | Current.with(task:, remote:) do 30 | Tomo.logger.task_start(task) 31 | tasks_by_name[task].call 32 | end 33 | end 34 | 35 | def connect(host) 36 | Current.with(host:) do 37 | conn = SSH.connect(host:, options: ssh_options) 38 | remote = Remote.new(conn, context, helper_modules) 39 | return remote unless block_given? 40 | 41 | begin 42 | return yield(remote) 43 | ensure 44 | remote&.close if block_given? 45 | end 46 | end 47 | end 48 | 49 | private 50 | 51 | attr_reader :helper_modules, :tasks_by_name 52 | 53 | def ssh_options 54 | settings.slice(*SSH::Options::DEFAULTS.keys) 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/tomo/runtime/template_not_found_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Runtime 5 | class TemplateNotFoundError < Error 6 | attr_accessor :path 7 | 8 | def to_console 9 | "Template not found: #{yellow(path)}" 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/tomo/runtime/unknown_task_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Runtime 5 | class UnknownTaskError < Error 6 | attr_accessor :unknown_task, :known_tasks 7 | 8 | def to_console 9 | error = <<~ERROR 10 | #{yellow(unknown_task)} is not a recognized task. 11 | To see a list of all available tasks, run #{blue('tomo tasks')}. 12 | ERROR 13 | 14 | sugg = spelling_suggestion || missing_plugin_suggestion 15 | error += sugg if sugg 16 | error 17 | end 18 | 19 | private 20 | 21 | def spelling_suggestion 22 | sugg = Error::Suggestions.new(dictionary: known_tasks, word: unknown_task) 23 | sugg.to_console if sugg.any? 24 | end 25 | 26 | def missing_plugin_suggestion 27 | unknown_plugin = unknown_task[/\A(.+?):/, 1] 28 | known_plugins = known_tasks.map { |t| t.split(":").first }.uniq 29 | return if unknown_plugin.nil? || known_plugins.include?(unknown_plugin) 30 | 31 | "\nDid you forget to install the #{blue(unknown_plugin)} plugin?" 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/tomo/script.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class Script 5 | attr_reader :script 6 | 7 | def initialize(script, echo: true, pty: false, raise_on_error: true, silent: false) 8 | @script = script 9 | @echo = echo 10 | @pty = pty 11 | @raise_on_error = raise_on_error 12 | @silent = silent 13 | freeze 14 | end 15 | 16 | def echo? 17 | !!@echo 18 | end 19 | 20 | def echo_string 21 | return nil unless echo? 22 | 23 | @echo == true ? script : @echo 24 | end 25 | 26 | def pty? 27 | !!@pty 28 | end 29 | 30 | def raise_on_error? 31 | !!@raise_on_error 32 | end 33 | 34 | def silent? 35 | !!@silent 36 | end 37 | 38 | def to_s 39 | script 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/tomo/shell_builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "shellwords" 4 | 5 | module Tomo 6 | class ShellBuilder 7 | def self.raw(string) 8 | string.dup.tap do |raw_string| 9 | raw_string.define_singleton_method(:shellescape) { string } 10 | end 11 | end 12 | 13 | def initialize 14 | @env = {} 15 | @chdir = [] 16 | @prefixes = [] 17 | @umask = nil 18 | end 19 | 20 | def chdir(dir) 21 | @chdir << dir 22 | yield 23 | ensure 24 | @chdir.pop 25 | end 26 | 27 | def env(hash) 28 | orig_env = @env 29 | @env = orig_env.merge(hash || {}) 30 | yield 31 | ensure 32 | @env = orig_env 33 | end 34 | 35 | def prepend(*command) 36 | prefixes.unshift(*command) 37 | yield 38 | ensure 39 | prefixes.shift(command.length) 40 | end 41 | 42 | def umask(mask) 43 | orig_umask = @umask 44 | @umask = mask 45 | yield 46 | ensure 47 | @umask = orig_umask 48 | end 49 | 50 | def build(*command, default_chdir: nil) 51 | return chdir(default_chdir) { build(*command) } if @chdir.empty? && default_chdir 52 | 53 | command_string = command_to_string(*command) 54 | modifiers = [cd_chdir, unset_env, export_env, set_umask].compact.flatten 55 | [*modifiers, command_string].join(" && ") 56 | end 57 | 58 | private 59 | 60 | attr_reader :prefixes 61 | 62 | def command_to_string(*command) 63 | command_string = shell_join(*command) 64 | return command_string if prefixes.empty? 65 | 66 | "#{shell_join(*prefixes)} #{command_string}" 67 | end 68 | 69 | def shell_join(*command) 70 | return command.first.to_s if command.length == 1 71 | 72 | command.flatten.compact.map { |arg| arg.to_s.shellescape }.join(" ") 73 | end 74 | 75 | def cd_chdir 76 | @chdir.map { |dir| "cd #{dir.to_s.shellescape}" } 77 | end 78 | 79 | def unset_env 80 | unsets = @env.select { |_, value| value.nil? } 81 | return if unsets.empty? 82 | 83 | ["unset", *unsets.map { |entry| entry.first.to_s.shellescape }].join(" ") 84 | end 85 | 86 | def export_env 87 | exports = @env.compact 88 | return if exports.empty? 89 | 90 | [ 91 | "export", 92 | *exports.map do |key, value| 93 | "#{key.to_s.shellescape}=#{value.to_s.shellescape}" 94 | end 95 | ].join(" ") 96 | end 97 | 98 | def set_umask 99 | return if @umask.nil? 100 | 101 | umask_value = @umask.is_a?(Integer) ? @umask.to_s(8).rjust(4, "0") : @umask 102 | "umask #{umask_value.to_s.shellescape}" 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/tomo/ssh.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module SSH 5 | autoload :ChildProcess, "tomo/ssh/child_process" 6 | autoload :Connection, "tomo/ssh/connection" 7 | autoload :ConnectionValidator, "tomo/ssh/connection_validator" 8 | autoload :ConnectionError, "tomo/ssh/connection_error" 9 | autoload :Error, "tomo/ssh/error" 10 | autoload :ExecutableError, "tomo/ssh/executable_error" 11 | autoload :Options, "tomo/ssh/options" 12 | autoload :PermissionError, "tomo/ssh/permission_error" 13 | autoload :ScriptError, "tomo/ssh/script_error" 14 | autoload :UnknownError, "tomo/ssh/unknown_error" 15 | autoload :UnsupportedVersionError, "tomo/ssh/unsupported_version_error" 16 | 17 | class << self 18 | def connect(host:, options: {}) 19 | options = Options.new(options) unless options.is_a?(Options) 20 | 21 | Tomo.logger.connect(host) 22 | return build_dry_run_connection(host, options) if Tomo.dry_run? 23 | 24 | build_connection(host, options) 25 | end 26 | 27 | private 28 | 29 | def build_dry_run_connection(host, options) 30 | Connection.dry_run(host, options) 31 | end 32 | 33 | def build_connection(host, options) 34 | conn = Connection.new(host, options) 35 | validator = ConnectionValidator.new(options.executable, conn) 36 | validator.assert_valid_executable! 37 | validator.assert_valid_connection! 38 | validator.dump_env if Tomo.debug? 39 | 40 | conn 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/tomo/ssh/child_process.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "open3" 4 | require "shellwords" 5 | require "stringio" 6 | 7 | module Tomo 8 | module SSH 9 | class ChildProcess 10 | def self.execute(*command, on_data: ->(data) {}) 11 | process = new(*command, on_data:) 12 | process.wait_for_exit 13 | process.result 14 | end 15 | 16 | def initialize(*command, on_data:) 17 | @command = *command 18 | @on_data = on_data 19 | @stdout_buffer = StringIO.new 20 | @stderr_buffer = StringIO.new 21 | Tomo.logger.debug command.map(&:shellescape).join(" ") 22 | end 23 | 24 | def wait_for_exit 25 | Open3.popen3(*command) do |stdin, stdout, stderr, wait_thread| 26 | stdin.close 27 | stdout_thread = start_io_thread(stdout, stdout_buffer) 28 | stderr_thread = start_io_thread(stderr, stderr_buffer) 29 | stdout_thread.join 30 | stderr_thread.join 31 | @exit_status = wait_thread.value.exitstatus 32 | end 33 | end 34 | 35 | def result 36 | Result.new(exit_status:, stdout: stdout_buffer.string, stderr: stderr_buffer.string) 37 | end 38 | 39 | private 40 | 41 | attr_reader :command, :exit_status, :on_data, :stdout_buffer, :stderr_buffer 42 | 43 | def start_io_thread(source, buffer) 44 | new_thread_inheriting_current_vars do 45 | while (line = source.gets) 46 | on_data&.call(line) 47 | buffer << line 48 | end 49 | rescue IOError # rubocop:disable Lint/SuppressedException 50 | end 51 | end 52 | 53 | def new_thread_inheriting_current_vars(&block) 54 | Thread.new(Runtime::Current.variables) do |vars| 55 | Runtime::Current.with(vars, &block) 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/tomo/ssh/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "fileutils" 4 | require "securerandom" 5 | require "tmpdir" 6 | 7 | module Tomo 8 | module SSH 9 | class Connection 10 | def self.dry_run(host, options) 11 | new(host, options, exec_proc: proc { CLI.exit }, child_proc: proc { Result.empty_success }) 12 | end 13 | 14 | attr_reader :host 15 | 16 | def initialize(host, options, exec_proc: nil, child_proc: nil) 17 | @host = host 18 | @options = options 19 | @exec_proc = exec_proc || Process.method(:exec) 20 | @child_proc = child_proc || ChildProcess.method(:execute) 21 | end 22 | 23 | def ssh_exec(script) 24 | ssh_args = build_args(script) 25 | logger.script_start(script) 26 | Tomo.logger.debug ssh_args.map(&:shellescape).join(" ") 27 | exec_proc.call(*ssh_args) 28 | end 29 | 30 | def ssh_subprocess(script, verbose: false) 31 | ssh_args = build_args(script, verbose:) 32 | handle_data = ->(data) { logger.script_output(script, data) } 33 | 34 | logger.script_start(script) 35 | result = child_proc.call(*ssh_args, on_data: handle_data) 36 | logger.script_end(script, result) 37 | 38 | raise_run_error(script, ssh_args, result) if result.failure? && script.raise_on_error? 39 | 40 | result 41 | end 42 | 43 | def close 44 | FileUtils.rm_f(control_path) 45 | end 46 | 47 | private 48 | 49 | attr_reader :options, :exec_proc, :child_proc 50 | 51 | def logger 52 | Tomo.logger 53 | end 54 | 55 | def build_args(script, verbose: false) 56 | options.build_args(host, script, control_path, verbose) 57 | end 58 | 59 | def control_path 60 | @control_path ||= begin 61 | token = SecureRandom.hex(8) 62 | File.join(Dir.tmpdir, "tomo_ssh_#{token}") 63 | end 64 | end 65 | 66 | def raise_run_error(script, ssh_args, result) 67 | ScriptError.raise_with(result.output, host:, result:, script:, ssh_args:) 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/tomo/ssh/connection_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module SSH 5 | class ConnectionError < Error 6 | def to_console 7 | msg = <<~ERROR 8 | Unable to connect via SSH to #{yellow(host.address)} on port #{yellow(host.port)}. 9 | 10 | Make sure the hostname and port are correct and that you have the 11 | necessary network (or VPN) access. 12 | ERROR 13 | 14 | [msg, super].join("\n") 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/tomo/ssh/connection_validator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | 5 | module Tomo 6 | module SSH 7 | class ConnectionValidator 8 | MINIMUM_OPENSSH_VERSION = 7.4 9 | private_constant :MINIMUM_OPENSSH_VERSION 10 | 11 | extend Forwardable 12 | 13 | def initialize(executable, connection) 14 | @executable = executable 15 | @connection = connection 16 | end 17 | 18 | def assert_valid_executable! 19 | result = begin 20 | ChildProcess.execute(executable, "-V") 21 | rescue StandardError => e 22 | handle_bad_executable(e) 23 | end 24 | 25 | Tomo.logger.debug(result.output) 26 | return if result.success? && supported?(result.output) 27 | 28 | raise_unsupported_version(result.output) 29 | end 30 | 31 | def assert_valid_connection! 32 | script = Script.new("echo hi", silent: !Tomo.debug?, echo: false, raise_on_error: false) 33 | res = connection.ssh_subprocess(script, verbose: Tomo.debug?) 34 | raise_connection_failure(res) if res.exit_status == 255 35 | raise_unknown_error(res) if res.failure? || res.stdout.chomp != "hi" 36 | end 37 | 38 | def dump_env 39 | script = Script.new("env", silent: true, echo: false) 40 | res = connection.ssh_subprocess(script) 41 | Tomo.logger.debug("#{host} environment:\n#{res.stdout.strip}") 42 | end 43 | 44 | private 45 | 46 | def_delegators :connection, :host 47 | attr_reader :executable, :connection 48 | 49 | def supported?(version) 50 | version[/OpenSSH_(\d+\.\d+)/i, 1].to_f >= MINIMUM_OPENSSH_VERSION 51 | end 52 | 53 | def handle_bad_executable(error) 54 | ExecutableError.raise_with(error, executable:) 55 | end 56 | 57 | def raise_unsupported_version(ver) 58 | UnsupportedVersionError.raise_with( 59 | ver, 60 | host:, 61 | command: "#{executable} -V", 62 | expected_version: "OpenSSH_#{MINIMUM_OPENSSH_VERSION}" 63 | ) 64 | end 65 | 66 | def raise_connection_failure(result) 67 | case result.output 68 | when /Permission denied/i 69 | PermissionError.raise_with(result.output, host:) 70 | when /(Could not resolve|Operation timed out|Connection refused)/i 71 | ConnectionError.raise_with(result.output, host:) 72 | else 73 | UnknownError.raise_with(result.output, host:) 74 | end 75 | end 76 | 77 | def raise_unknown_error(result) 78 | UnknownError.raise_with(<<~ERROR.strip, host:) 79 | Unexpected output from `ssh`. Expected `echo hi` to return "hi" but got: 80 | #{result.output} 81 | (exited with code #{result.exit_status}) 82 | ERROR 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/tomo/ssh/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module SSH 5 | class Error < Tomo::Error 6 | attr_accessor :host 7 | 8 | def to_console 9 | [debug_suggestion, red(message.strip)].compact.join("\n\n") 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/tomo/ssh/executable_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module SSH 5 | class ExecutableError < Error 6 | attr_accessor :executable 7 | 8 | def to_console 9 | hint = if executable.to_s.include?("/") 10 | "Is the ssh binary properly installed in this location?" 11 | else 12 | "Is #{yellow(executable)} installed and in your #{blue('$PATH')}?" 13 | end 14 | 15 | <<~ERROR 16 | Failed to execute #{yellow(executable)}. 17 | #{hint} 18 | ERROR 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/tomo/ssh/options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module SSH 5 | class Options 6 | DEFAULTS = { 7 | ssh_connect_timeout: 5, 8 | ssh_executable: "ssh", 9 | ssh_extra_opts: %w[-o PasswordAuthentication=no].map(&:freeze), 10 | ssh_forward_agent: true, 11 | ssh_reuse_connections: true, 12 | ssh_strict_host_key_checking: "accept-new" 13 | }.freeze 14 | 15 | attr_reader :executable 16 | 17 | def initialize(options) 18 | DEFAULTS.merge(options).each do |attr, value| 19 | unprefixed_attr = attr.to_s.sub(/^ssh_/, "") 20 | send(:"#{unprefixed_attr}=", value) 21 | end 22 | freeze 23 | end 24 | 25 | def build_args(host, script, control_path, verbose) # rubocop:disable Metrics/AbcSize 26 | args = [verbose ? "-v" : ["-o", "LogLevel=ERROR"]] 27 | args << "-A" if forward_agent 28 | args << connect_timeout_option 29 | args << strict_host_key_checking_option 30 | args.push(*control_opts(control_path, verbose)) if reuse_connections 31 | args.push(*extra_opts) if extra_opts 32 | args << "-tt" if script.pty? 33 | args << host.to_ssh_args 34 | args << "--" 35 | 36 | [executable, args, script.to_s].flatten 37 | end 38 | 39 | private 40 | 41 | attr_writer :executable 42 | attr_accessor :connect_timeout, :extra_opts, :forward_agent, :reuse_connections, :strict_host_key_checking 43 | 44 | def control_opts(path, verbose) 45 | opts = [ 46 | "-o", 47 | "ControlMaster=auto", 48 | "-o", 49 | "ControlPath=#{path}", 50 | "-o" 51 | ] 52 | opts << (verbose ? "ControlPersist=1s" : "ControlPersist=30s") 53 | end 54 | 55 | def connect_timeout_option 56 | return [] if connect_timeout.nil? 57 | 58 | ["-o", "ConnectTimeout=#{connect_timeout}"] 59 | end 60 | 61 | def strict_host_key_checking_option 62 | return [] if strict_host_key_checking.nil? 63 | 64 | value = case strict_host_key_checking 65 | when true then "yes" 66 | when false then "no" 67 | else strict_host_key_checking 68 | end 69 | 70 | ["-o", "StrictHostKeyChecking=#{value}"] 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/tomo/ssh/permission_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module SSH 5 | class PermissionError < Error 6 | def to_console 7 | as_user = host.user && " as user #{yellow(host.user)}" 8 | 9 | msg = <<~ERROR 10 | Unable to connect via SSH to #{yellow(host.address)}#{as_user}. 11 | 12 | Check that you’ve specified the correct username and that your public key 13 | is properly installed on the server. 14 | ERROR 15 | 16 | [msg, super].join("\n") 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/tomo/ssh/script_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "shellwords" 4 | 5 | module Tomo 6 | module SSH 7 | class ScriptError < Error 8 | attr_accessor :result, :script, :ssh_args 9 | 10 | def to_console 11 | msg = <<~ERROR 12 | The following script failed on #{yellow(host)} (exit status #{red(result.exit_status)}). 13 | 14 | #{yellow(script)} 15 | 16 | You can manually re-execute the script via SSH as follows: 17 | 18 | #{gray(ssh_args.map(&:shellescape).join(' '))} 19 | ERROR 20 | 21 | [msg, super].join("\n") 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/tomo/ssh/unknown_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module SSH 5 | class UnknownError < Error 6 | def to_console 7 | msg = <<~ERROR 8 | An unknown error occurred trying to SSH to #{yellow(host)}. 9 | ERROR 10 | 11 | [msg, super].join("\n") 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/tomo/ssh/unsupported_version_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module SSH 5 | class UnsupportedVersionError < Error 6 | attr_accessor :command, :expected_version 7 | 8 | def to_console 9 | msg = <<~ERROR 10 | Expected #{yellow(command)} to return #{blue(expected_version)} or higher. 11 | ERROR 12 | 13 | [msg, super].join("\n") 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/tomo/task_api.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "erb" 4 | 5 | module Tomo 6 | module TaskAPI 7 | extend Forwardable 8 | 9 | private 10 | 11 | def_delegators :context, :paths, :settings 12 | 13 | def die(reason) 14 | Runtime::TaskAbortedError.raise_with(reason, task: context.current_task, host: remote.host) 15 | end 16 | 17 | def dry_run? 18 | Tomo.dry_run? 19 | end 20 | 21 | def logger 22 | Tomo.logger 23 | end 24 | 25 | def merge_template(path) 26 | working_path = paths.tomo_config_file&.dirname 27 | path = File.expand_path(path, working_path) if working_path && path.start_with?(".") 28 | 29 | Runtime::TemplateNotFoundError.raise_with(path:) unless File.file?(path) 30 | template = File.read(path) 31 | ERB.new(template).result(binding) 32 | end 33 | 34 | def raw(string) 35 | ShellBuilder.raw(string) 36 | end 37 | 38 | def remote 39 | context.current_remote 40 | end 41 | 42 | def require_setting(*names) 43 | missing = names.flatten.select { |sett| settings[sett].nil? } 44 | return if missing.empty? 45 | 46 | Runtime::SettingsRequiredError.raise_with(settings: missing, task: context.current_task) 47 | end 48 | alias require_settings require_setting 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/tomo/task_library.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | class TaskLibrary 5 | include TaskAPI 6 | 7 | def initialize(context) 8 | @context = context 9 | end 10 | 11 | private 12 | 13 | attr_reader :context 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/tomo/templates/config.rb.erb: -------------------------------------------------------------------------------- 1 | <% if rubocop? -%> 2 | # rubocop:disable Style/FormatStringToken 3 | <% end -%> 4 | plugin "git" 5 | plugin "env" 6 | plugin "bundler" 7 | plugin "rails" 8 | plugin "nodenv" 9 | plugin "puma" 10 | plugin "rbenv" 11 | plugin "./plugins/<%= app %>.rb" 12 | 13 | host "user@hostname.or.ip.address" 14 | 15 | set application: <%= app.inspect %> 16 | set deploy_to: "/var/www/%{application}" 17 | <% unless using_ruby_version_file? -%> 18 | set rbenv_ruby_version: <%= RUBY_VERSION.inspect %> 19 | <% end -%> 20 | <% unless using_node_version_file? -%> 21 | set nodenv_node_version: <%= node_version&.inspect || "nil # FIXME" %> 22 | <% end -%> 23 | set nodenv_install_yarn: <%= yarn_version ? "true" : "false" %> 24 | set git_url: <%= git_origin_url&.inspect || "nil # FIXME" %> 25 | set git_branch: <%= git_main_branch&.inspect || "nil # FIXME" %> 26 | set git_exclusions: %w[ 27 | .tomo/ 28 | spec/ 29 | test/ 30 | ] 31 | set env_vars: { 32 | RAILS_ENV: "production", 33 | RUBY_YJIT_ENABLE: "1", 34 | BOOTSNAP_CACHE_DIR: "tmp/bootsnap-cache", 35 | DATABASE_URL: :prompt, 36 | SECRET_KEY_BASE: :generate_secret 37 | } 38 | set linked_dirs: %w[ 39 | .yarn/cache 40 | log 41 | node_modules 42 | public/assets 43 | public/packs 44 | public/vite 45 | tmp/cache 46 | tmp/pids 47 | tmp/sockets 48 | ] 49 | 50 | setup do 51 | run "env:setup" 52 | run "core:setup_directories" 53 | run "git:config" 54 | run "git:clone" 55 | run "git:create_release" 56 | run "core:symlink_shared" 57 | run "nodenv:install" 58 | run "rbenv:install" 59 | run "bundler:upgrade_bundler" 60 | run "bundler:config" 61 | run "bundler:install" 62 | run "rails:db_create" 63 | run "rails:db_schema_load" 64 | run "rails:db_seed" 65 | run "puma:setup_systemd" 66 | end 67 | 68 | deploy do 69 | run "env:update" 70 | run "git:create_release" 71 | run "core:symlink_shared" 72 | run "core:write_release_json" 73 | run "bundler:install" 74 | run "rails:db_migrate" 75 | run "rails:db_seed" 76 | run "rails:assets_precompile" 77 | run "core:symlink_current" 78 | run "puma:restart" 79 | run "puma:check_active" 80 | run "core:clean_releases" 81 | run "bundler:clean" 82 | run "core:log_revision" 83 | end 84 | <% if rubocop? -%> 85 | # rubocop:enable Style/FormatStringToken 86 | <% end -%> 87 | -------------------------------------------------------------------------------- /lib/tomo/templates/plugin.rb.erb: -------------------------------------------------------------------------------- 1 | # https://tomo.mattbrictson.com/tutorials/writing-custom-tasks/ 2 | -------------------------------------------------------------------------------- /lib/tomo/testing.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "tomo" 4 | 5 | module Tomo 6 | module Testing 7 | autoload :CLIExtensions, "tomo/testing/cli_extensions" 8 | autoload :CLITester, "tomo/testing/cli_tester" 9 | autoload :Connection, "tomo/testing/connection" 10 | autoload :DockerImage, "tomo/testing/docker_image" 11 | autoload :HostExtensions, "tomo/testing/host_extensions" 12 | autoload :Local, "tomo/testing/local" 13 | autoload :LogCapturing, "tomo/testing/log_capturing" 14 | autoload :MockedExecError, "tomo/testing/mocked_exec_error" 15 | autoload :MockedExitError, "tomo/testing/mocked_exit_error" 16 | autoload :MockPluginTester, "tomo/testing/mock_plugin_tester" 17 | autoload :RemoteExtensions, "tomo/testing/remote_extensions" 18 | autoload :SSHExtensions, "tomo/testing/ssh_extensions" 19 | 20 | class << self 21 | attr_reader :ssh_enabled 22 | 23 | def enabling_ssh 24 | orig_ssh = ssh_enabled 25 | @ssh_enabled = true 26 | yield 27 | ensure 28 | @ssh_enabled = orig_ssh 29 | end 30 | end 31 | @ssh_enabled = false 32 | end 33 | end 34 | 35 | Tomo.logger = Tomo::Logger.new( 36 | stdout: File.open(File::NULL, "w"), stderr: File.open(File::NULL, "w") 37 | ) 38 | class << Tomo::CLI 39 | prepend Tomo::Testing::CLIExtensions 40 | end 41 | Tomo::Colors.enabled = false 42 | Tomo::Host.prepend Tomo::Testing::HostExtensions 43 | Tomo::Remote.prepend Tomo::Testing::RemoteExtensions 44 | class << Tomo::SSH 45 | prepend Tomo::Testing::SSHExtensions 46 | end 47 | -------------------------------------------------------------------------------- /lib/tomo/testing/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:24.04 2 | WORKDIR /provision 3 | COPY ./tomo_test_ed25519.pub /root/.ssh/authorized_keys 4 | COPY ./ubuntu_setup.sh ./ 5 | RUN ./ubuntu_setup.sh 6 | COPY ./systemctl.rb /usr/local/bin/systemctl 7 | RUN chmod a+x /usr/local/bin/systemctl 8 | EXPOSE 22 9 | EXPOSE 3000 10 | CMD ["/usr/sbin/sshd", "-D"] 11 | -------------------------------------------------------------------------------- /lib/tomo/testing/cli_extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module Testing 5 | module CLIExtensions 6 | def exit(status=true) # rubocop:disable Style/OptionalBooleanParameter 7 | raise MockedExitError, status 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/tomo/testing/cli_tester.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "securerandom" 4 | 5 | module Tomo 6 | module Testing 7 | class CLITester 8 | include Local 9 | include LogCapturing 10 | 11 | def initialize 12 | @token = SecureRandom.hex(8) 13 | end 14 | 15 | def in_temp_dir(&) 16 | super(token, &) 17 | end 18 | 19 | def run(*args, raise_on_error: true) 20 | in_temp_dir do 21 | restoring_defaults do 22 | capturing_logger_output do 23 | handling_exit(raise_on_error) do 24 | CLI.new.call(args.flatten) 25 | end 26 | end 27 | end 28 | end 29 | end 30 | 31 | private 32 | 33 | attr_reader :token 34 | 35 | def restoring_defaults 36 | yield 37 | ensure 38 | Tomo.debug = false 39 | Tomo.dry_run = false 40 | Tomo::CLI.show_backtrace = false 41 | Tomo::CLI::Completions.instance_variable_set(:@active, false) 42 | end 43 | 44 | def handling_exit(raise_on_error) 45 | yield 46 | rescue Tomo::Testing::MockedExitError => e 47 | raise if raise_on_error && !e.success? 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/tomo/testing/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module Testing 5 | class Connection < Tomo::SSH::Connection 6 | def initialize(host, options) 7 | super(host, options, exec_proc: proc { raise MockedExecError }, child_proc: method(:mock_child_process)) 8 | end 9 | 10 | def ssh_exec(script) 11 | host.scripts << script 12 | super 13 | end 14 | 15 | def ssh_subprocess(script, verbose: false) 16 | host.scripts << script 17 | super 18 | end 19 | 20 | private 21 | 22 | def mock_child_process(*_ssh_args, on_data:) 23 | result = host.result_for(host.scripts.last) 24 | 25 | on_data.call(result.stdout) unless result.stdout.empty? 26 | on_data.call(result.stderr) unless result.stderr.empty? 27 | result 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/tomo/testing/host_extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module Testing 5 | module HostExtensions 6 | attr_reader :helper_values, :mocks, :scripts, :release 7 | 8 | def initialize(**kwargs) 9 | @mocks = [] 10 | @scripts = [] 11 | @helper_values = [] 12 | @release = {} 13 | super 14 | end 15 | 16 | def mock(script, stdout: "", stderr: "", exit_status: 0) 17 | mocks << [ 18 | script.is_a?(Regexp) ? script : /\A#{Regexp.quote(script)}\z/, 19 | Result.new(stdout: String.new(stdout), stderr: String.new(stderr), exit_status:) 20 | ] 21 | end 22 | 23 | def result_for(script) 24 | match = mocks.find { |regexp, _| regexp.match?(script.to_s) } 25 | raise "Scripts cannot be mocked during dry_run" if match && Tomo.dry_run? 26 | 27 | match&.last || Result.empty_success 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/tomo/testing/local.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler" 4 | require "fileutils" 5 | require "open3" 6 | require "securerandom" 7 | require "shellwords" 8 | require "tmpdir" 9 | 10 | module Tomo 11 | module Testing 12 | module Local 13 | def with_tomo_gemfile(&) 14 | Local.with_tomo_gemfile(&) 15 | end 16 | 17 | def in_temp_dir(token=nil, &) 18 | Local.in_temp_dir(token, &) 19 | end 20 | 21 | def capture(*command, raise_on_error: true) 22 | Local.capture(*command, raise_on_error:) 23 | end 24 | 25 | class << self 26 | def with_tomo_gemfile 27 | Bundler.with_original_env do 28 | gemfile = File.expand_path("../../../Gemfile", __dir__) 29 | ENV["BUNDLE_GEMFILE"] = gemfile 30 | yield 31 | end 32 | end 33 | 34 | def in_temp_dir(token=nil, &) 35 | token ||= SecureRandom.hex(8) 36 | dir = File.join(Dir.tmpdir, "tomo_test_#{token}") 37 | FileUtils.mkdir_p(dir) 38 | Dir.chdir(dir, &) 39 | end 40 | 41 | def capture(*command, raise_on_error: true) 42 | command_str = command.join(" ") 43 | progress(command_str) do 44 | output, status = Open3.capture2e(*command) 45 | 46 | raise "Command failed: #{command_str}\n#{output}" if raise_on_error && !status.success? 47 | 48 | output 49 | end 50 | end 51 | 52 | private 53 | 54 | def progress(message, &) 55 | return with_progress(message, &) if interactive? 56 | 57 | thread = Thread.new(&) 58 | return thread.value if wait_for_exit(thread, 4) 59 | 60 | puts "#{message} ..." 61 | wait_for_exit(thread) 62 | puts "#{message} ✔" 63 | 64 | thread.value 65 | end 66 | 67 | def with_progress(message, &) 68 | spinner = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].cycle 69 | thread = Thread.new(&) 70 | return thread.value if wait_for_exit(thread, 4) 71 | 72 | print "#{spinner.next} #{message}..." 73 | loop do 74 | break if wait_for_exit(thread, 0.2) 75 | 76 | print "\r#{spinner.next} #{message}..." 77 | end 78 | puts "\r✔ #{message}..." 79 | thread.value 80 | end 81 | 82 | def interactive? 83 | Tomo::Console.interactive? 84 | end 85 | 86 | def wait_for_exit(thread, seconds=nil) 87 | thread.join(seconds) 88 | rescue StandardError 89 | # Sanity check. If we get an exception, the thread should be dead. 90 | raise if thread.alive? 91 | 92 | thread 93 | end 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/tomo/testing/log_capturing.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "stringio" 4 | 5 | module Tomo 6 | module Testing 7 | module LogCapturing 8 | def stdout 9 | @stdout_io&.string 10 | end 11 | 12 | def stderr 13 | @stderr_io&.string 14 | end 15 | 16 | private 17 | 18 | def capturing_logger_output 19 | orig_logger = Tomo.logger 20 | @stdout_io = StringIO.new 21 | @stderr_io = StringIO.new 22 | Tomo.logger = Tomo::Logger.new(stdout: @stdout_io, stderr: @stderr_io) 23 | yield 24 | ensure 25 | Tomo.logger = orig_logger 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/tomo/testing/mock_plugin_tester.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module Testing 5 | class MockPluginTester 6 | include LogCapturing 7 | 8 | def initialize(*plugin_names, settings: {}, release: {}) 9 | @host = Host.parse("testing@host") 10 | @host.release.merge!(release) 11 | config = Configuration.new 12 | config.hosts << @host 13 | config.plugins.push(*plugin_names, "testing") 14 | config.settings[:application] = "testing" 15 | config.settings.merge!(settings) 16 | @runtime = config.build_runtime 17 | end 18 | 19 | def call_helper(helper, *args, **kwargs) 20 | run_task("testing:call_helper", helper, args, kwargs) 21 | host.helper_values.pop 22 | end 23 | 24 | def run_task(task, *args) 25 | capturing_logger_output do 26 | runtime.run!(task, *args, privileged: false) 27 | nil 28 | end 29 | end 30 | 31 | def executed_script 32 | return executed_scripts.first unless executed_scripts.length > 1 33 | 34 | raise "Expected one executed script, got multiple: #{executed_scripts}" 35 | end 36 | 37 | def executed_scripts 38 | host.scripts.map(&:to_s) 39 | end 40 | 41 | def mock_script_result(script=/.*/, **kwargs) 42 | host.mock(script, **kwargs) 43 | self 44 | end 45 | 46 | private 47 | 48 | attr_reader :host, :runtime 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/tomo/testing/mocked_exec_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module Testing 5 | class MockedExecError < Exception # rubocop:disable Lint/InheritException 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/tomo/testing/mocked_exit_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module Testing 5 | class MockedExitError < Exception # rubocop:disable Lint/InheritException 6 | attr_reader :status 7 | 8 | def initialize(status) 9 | @status = status 10 | super("tomo exited with status #{status}") 11 | end 12 | 13 | def success? 14 | [true, 0].include?(status) 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/tomo/testing/remote_extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module Testing 5 | module RemoteExtensions 6 | def initialize(*args) 7 | super 8 | release.merge!(ssh.host.release) 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/tomo/testing/ssh_extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | module Testing 5 | module SSHExtensions 6 | private 7 | 8 | def build_dry_run_connection(host, options) 9 | return super if Testing.ssh_enabled 10 | 11 | Testing::Connection.new(host, options) 12 | end 13 | 14 | def build_connection(host, options) 15 | return super if Testing.ssh_enabled 16 | 17 | Testing::Connection.new(host, options) 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/tomo/testing/tomo_test_ed25519: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 3 | QyNTUxOQAAACDU7UqhQJMIlzZgNVZf7oGtDF054nePB/NY0X0dJlL3VgAAAJh/FNNRfxTT 4 | UQAAAAtzc2gtZWQyNTUxOQAAACDU7UqhQJMIlzZgNVZf7oGtDF054nePB/NY0X0dJlL3Vg 5 | AAAEDHWenvyWnYId1S/v4idksTmU28IntayxXkJUSbcGwETNTtSqFAkwiXNmA1Vl/uga0M 6 | XTnid48H81jRfR0mUvdWAAAAEHRvbW9AZXhhbXBsZS5jb20BAgMEBQ== 7 | -----END OPENSSH PRIVATE KEY----- 8 | -------------------------------------------------------------------------------- /lib/tomo/testing/tomo_test_ed25519.pub: -------------------------------------------------------------------------------- 1 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINTtSqFAkwiXNmA1Vl/uga0MXTnid48H81jRfR0mUvdW tomo@example.com 2 | -------------------------------------------------------------------------------- /lib/tomo/testing/ubuntu_setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | export DEBIAN_FRONTEND=noninteractive 6 | 7 | apt-get -y update 8 | apt-get -y install adduser 9 | 10 | # Create `deployer` user 11 | adduser --disabled-password deployer < /dev/null 12 | mkdir -p /home/deployer/.ssh 13 | cp /root/.ssh/authorized_keys /home/deployer/.ssh 14 | chown -R deployer:deployer /home/deployer/.ssh 15 | chmod 600 /home/deployer/.ssh/authorized_keys 16 | mkdir -p /var/www 17 | chown deployer:deployer /var/www 18 | mkdir -p /var/lib/systemd/linger 19 | touch /var/lib/systemd/linger/deployer 20 | 21 | # Packages needed for ruby, etc. 22 | apt-get -y install autoconf \ 23 | bison \ 24 | build-essential \ 25 | curl \ 26 | git-core \ 27 | libdb-dev \ 28 | libffi-dev \ 29 | libgdbm-dev \ 30 | libgdbm6 \ 31 | libgmp-dev \ 32 | libncurses5-dev \ 33 | libreadline6-dev \ 34 | libsqlite3-dev \ 35 | libssl-dev \ 36 | libyaml-dev \ 37 | locales \ 38 | patch \ 39 | pkg-config \ 40 | rustc \ 41 | uuid-dev \ 42 | zlib1g-dev 43 | 44 | apt-get -y install tzdata \ 45 | -o DPkg::options::="--force-confdef" \ 46 | -o DPkg::options::="--force-confold" 47 | 48 | locale-gen en_US.UTF-8 49 | 50 | # Install and configure sshd 51 | apt-get -y install openssh-server 52 | echo "Port 22" >> /etc/ssh/sshd_config 53 | echo "PasswordAuthentication no" >> /etc/ssh/sshd_config 54 | echo "ChallengeResponseAuthentication no" >> /etc/ssh/sshd_config 55 | mkdir /var/run/sshd 56 | chmod 0755 /var/run/sshd 57 | -------------------------------------------------------------------------------- /lib/tomo/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tomo 4 | VERSION = "1.20.3" 5 | end 6 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Tomo 2 | theme: 3 | name: readthedocs 4 | custom_dir: custom_theme 5 | extra_css: 6 | - css/extra.css 7 | extra_javascript: 8 | - js/extra.js 9 | markdown_extensions: 10 | - mdx_truly_sane_lists 11 | - smarty 12 | - markdown_include.include: 13 | base_path: docs/include 14 | repo_url: https://github.com/mattbrictson/tomo/ 15 | edit_uri: edit/main/docs/ 16 | nav: 17 | - Home: index.md 18 | - Comparisons: comparisons.md 19 | - Configuration: configuration.md 20 | - Commands: 21 | - init: commands/init.md 22 | - setup: commands/setup.md 23 | - deploy: commands/deploy.md 24 | - run: commands/run.md 25 | - tasks: commands/tasks.md 26 | - Plugins: 27 | - core: plugins/core.md 28 | - bundler: plugins/bundler.md 29 | - env: plugins/env.md 30 | - git: plugins/git.md 31 | - nodenv: plugins/nodenv.md 32 | - puma: plugins/puma.md 33 | - rails: plugins/rails.md 34 | - rbenv: plugins/rbenv.md 35 | - Tutorials: 36 | - Deploying Rails From Scratch: tutorials/deploying-rails-from-scratch.md 37 | - Writing Custom Tasks: tutorials/writing-custom-tasks.md 38 | - Publishing a Plugin: tutorials/publishing-a-plugin.md 39 | - API: 40 | - "Tomo::Host": api/Host.md 41 | - "Tomo::Logger": api/Logger.md 42 | - "Tomo::Paths": api/Paths.md 43 | - "Tomo::PluginDSL": api/PluginDSL.md 44 | - "Tomo::Remote": api/Remote.md 45 | - "Tomo::Result": api/Result.md 46 | - "Tomo::TaskLibrary": api/TaskLibrary.md 47 | - "Tomo::Testing::MockPluginTester": api/testing/MockPluginTester.md 48 | -------------------------------------------------------------------------------- /readme_images/README.md: -------------------------------------------------------------------------------- 1 | The images in this directory can be regenerated using the `record_console.rb` script. 2 | 3 | For example: 4 | 5 | ./record_console.rb tomo deploy --help 6 | 7 | This will translate the output of the `tomo deploy --help` command into HTML and launch it in a browser. From there, press the 8 | **Convert to PNG** button to generate an image appropriately sized for a GitHub README. 9 | -------------------------------------------------------------------------------- /readme_images/console.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | background-color: #fff; 4 | margin: 20px; 5 | } 6 | pre { 7 | background-color: #333; 8 | border-top: 1px solid #666; 9 | border-bottom: 1px solid #000; 10 | border-radius: 6px; 11 | box-sizing: border-box; 12 | color: #eae7d9; 13 | font-family: "Menlo"; 14 | font-size: 14px; 15 | margin: 20px 0; 16 | padding: 18px 20px 20px 20px; 17 | width: 882px; 18 | word-wrap: break-word; 19 | } 20 | .yellow { 21 | color: #ebdc70; 22 | } 23 | .green { 24 | color: #a3dd49; 25 | } 26 | .blue { 27 | color: #b4d9f4; 28 | } 29 | .red { 30 | color: #e65c4b; 31 | } 32 | .gray { 33 | color: #999; 34 | } 35 | -------------------------------------------------------------------------------- /readme_images/console.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", function() { 2 | document.querySelector("button").addEventListener("click", function() { 3 | html2canvas(document.querySelector("pre")).then(canvas => { 4 | window.open(canvas.toDataURL()); 5 | }); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /readme_images/record_console.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "fileutils" 4 | require "open3" 5 | require "securerandom" 6 | require "tmpdir" 7 | 8 | template = <<~TEMPLATE 9 | 10 | 11 | 12 | 13 | Console 14 | 15 | 16 | 17 | 18 | 19 |

20 |       
21 |     
22 |   
23 | TEMPLATE
24 | 
25 | ascii = if ARGV.empty?
26 |           $stdin.read
27 |         else
28 |           out = Open3.popen3({ "CLICOLOR_FORCE" => "1" }, *ARGV) do |_in, stdout, _err, _thr|
29 |             stdout.read
30 |           end
31 |           out.prepend ["$", *ARGV, "\n"].join(" ")
32 |         end
33 | 
34 | ascii.rstrip!
35 | ascii.gsub!("&", "&")
36 | ascii.gsub!("<", "<")
37 | ascii.gsub!(">", ">")
38 | ascii.gsub!("\e[0;31;49m", "")
39 | ascii.gsub!("\e[0;32;49m", "")
40 | ascii.gsub!("\e[0;33;49m", "")
41 | ascii.gsub!("\e[0;34;49m", "")
42 | ascii.gsub!("\e[0;90;49m", "")
43 | ascii.gsub!("\e[0m", "")
44 | html = template.sub("
", "
#{ascii}")
45 | 
46 | out = File.join(Dir.tmpdir, "console-#{SecureRandom.hex(8)}.html")
47 | IO.write(out, html)
48 | %w[console.css console.js].each do |file|
49 |   FileUtils.cp(File.expand_path(file, __dir__), Dir.tmpdir)
50 | end
51 | system "open", out
52 | 


--------------------------------------------------------------------------------
/readme_images/tomo-deploy-dry-run.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattbrictson/tomo/15d7faccc8c1b91750196284e2e0fa98fd0c1116/readme_images/tomo-deploy-dry-run.png


--------------------------------------------------------------------------------
/readme_images/tomo-deploy-help.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattbrictson/tomo/15d7faccc8c1b91750196284e2e0fa98fd0c1116/readme_images/tomo-deploy-help.png


--------------------------------------------------------------------------------
/readme_images/tomo-help.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattbrictson/tomo/15d7faccc8c1b91750196284e2e0fa98fd0c1116/readme_images/tomo-help.png


--------------------------------------------------------------------------------
/readme_images/tomo-init.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattbrictson/tomo/15d7faccc8c1b91750196284e2e0fa98fd0c1116/readme_images/tomo-init.png


--------------------------------------------------------------------------------
/readme_images/tomo-run-hello-dry-run.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattbrictson/tomo/15d7faccc8c1b91750196284e2e0fa98fd0c1116/readme_images/tomo-run-hello-dry-run.png


--------------------------------------------------------------------------------
/readme_images/tomo-run-hello.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattbrictson/tomo/15d7faccc8c1b91750196284e2e0fa98fd0c1116/readme_images/tomo-run-hello.png


--------------------------------------------------------------------------------
/readme_images/tomo-run-rails-console-dry-run.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattbrictson/tomo/15d7faccc8c1b91750196284e2e0fa98fd0c1116/readme_images/tomo-run-rails-console-dry-run.png


--------------------------------------------------------------------------------
/readme_images/tomo-run-rails-console.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattbrictson/tomo/15d7faccc8c1b91750196284e2e0fa98fd0c1116/readme_images/tomo-run-rails-console.png


--------------------------------------------------------------------------------
/readme_images/tomo-setup-dry-run.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattbrictson/tomo/15d7faccc8c1b91750196284e2e0fa98fd0c1116/readme_images/tomo-setup-dry-run.png


--------------------------------------------------------------------------------
/readme_images/tomo-setup-help.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattbrictson/tomo/15d7faccc8c1b91750196284e2e0fa98fd0c1116/readme_images/tomo-setup-help.png


--------------------------------------------------------------------------------
/readme_images/tomo-tasks.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattbrictson/tomo/15d7faccc8c1b91750196284e2e0fa98fd0c1116/readme_images/tomo-tasks.png


--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | markdown == 3.8
2 | markdown_include == 0.8.1
3 | mdx_truly_sane_lists == 1.3
4 | mkdocs == 1.6.1
5 | smarty == 0.3.3
6 | jinja2==3.1.6
7 | 


--------------------------------------------------------------------------------
/test/e2e/rails_setup_deploy_e2e_test.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | require "test_helper"
 4 | 
 5 | require "net/http"
 6 | require "securerandom"
 7 | 
 8 | class RailsSetupDeployE2ETest < Minitest::Test
 9 |   include Tomo::Testing::Local
10 | 
11 |   def setup
12 |     @docker = Tomo::Testing::DockerImage.new
13 |     @docker.build_and_run
14 |   end
15 | 
16 |   def teardown
17 |     @docker.stop
18 |   end
19 | 
20 |   def test_rails_setup_deploy
21 |     in_cloned_rails_repo do
22 |       bundle_exec("tomo init")
23 |       config = File.read(".tomo/config.rb")
24 |       config.sub!(
25 |         /host ".*"/,
26 |         %Q(host "#{@docker.host.user}@#{@docker.host.address}", port: #{@docker.host.port})
27 |       )
28 |       config.sub!(
29 |         /set rbenv_ruby_version:\s*\S+/,
30 |         "set rbenv_ruby_version: #{File.read('.ruby-version').strip.inspect}"
31 |       )
32 |       config << <<~CONFIG
33 |         set(#{@docker.ssh_settings.inspect})
34 |       CONFIG
35 |       File.write(".tomo/config.rb", config)
36 | 
37 |       bundle_exec("tomo run env:set DATABASE_URL=sqlite3:/var/www/rails-new/shared/production.sqlite3")
38 |       bundle_exec("tomo setup")
39 |       bundle_exec("tomo deploy")
40 | 
41 |       # Pause to allow puma to completely finish booting
42 |       sleep 5
43 | 
44 |       rails_uri = URI("http://localhost:#{@docker.puma_port}/")
45 |       rails_http_response = Net::HTTP.get_response(rails_uri)
46 | 
47 |       assert_kind_of(Net::HTTPSuccess, rails_http_response)
48 |       assert_match(/It works!/i, rails_http_response.body)
49 |     end
50 |   end
51 | 
52 |   private
53 | 
54 |   def bundle_exec(command)
55 |     with_tomo_gemfile do
56 |       full_cmd = "bundle exec #{command}"
57 |       puts ">>> #{full_cmd}"
58 |       system(full_cmd, exception: true)
59 |     end
60 |   end
61 | 
62 |   def in_cloned_rails_repo(&block)
63 |     in_temp_dir do
64 |       repo = "https://github.com/mattbrictson/rails-new.git"
65 |       capture("git clone #{repo}")
66 |       Dir.chdir("rails-new", &block)
67 |     end
68 |   end
69 | end
70 | 


--------------------------------------------------------------------------------
/test/fixtures/template.erb:
--------------------------------------------------------------------------------
1 | Hello, <%= settings[:application] %>!
2 | 


--------------------------------------------------------------------------------
/test/test_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | 
3 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4 | require "tomo/testing"
5 | 
6 | require "minitest/autorun"
7 | 


--------------------------------------------------------------------------------
/test/tomo/cli/completions_test.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | require "test_helper"
 4 | 
 5 | class Tomo::CLI::CompletionsTest < Minitest::Test
 6 |   def setup
 7 |     @tester = Tomo::Testing::CLITester.new
 8 |   end
 9 | 
10 |   def test_completions_include_setting_names
11 |     @tester.run "init"
12 |     @tester.run "--complete", "deploy", "-s"
13 | 
14 |     assert_match(/^git_branch=$/, @tester.stdout)
15 |     assert_match(/^git_url=$/, @tester.stdout)
16 |   end
17 | 
18 |   def test_completes_task_name_even_without_run_command
19 |     @tester.run "init"
20 |     @tester.run "--complete-word", "rails:"
21 | 
22 |     assert_match(/^console $/, @tester.stdout)
23 |     assert_match(/^db_migrate $/, @tester.stdout)
24 |   end
25 | end
26 | 


--------------------------------------------------------------------------------
/test/tomo/cli_test.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | require "test_helper"
 4 | 
 5 | class Tomo::CLITest < Minitest::Test
 6 |   def setup
 7 |     @tester = Tomo::Testing::CLITester.new
 8 |   end
 9 | 
10 |   def test_execute_task_with_implicit_run_command
11 |     @tester.run "init"
12 |     @tester.run "bundler:install", "--dry-run"
13 |     assert_match "Simulated bundler:install", @tester.stdout
14 |   end
15 | 
16 |   def test_commands_can_be_abbreviated
17 |     stdout, = capture_io { @tester.run "i", "--help" }
18 |     assert_match "Usage: tomo init", stdout
19 | 
20 |     stdout, = capture_io { @tester.run "d", "--help" }
21 |     assert_match "Usage: tomo deploy", stdout
22 |   end
23 | 
24 |   def test_dash_t_is_alias_for_tasks
25 |     @tester.run "init"
26 |     @tester.run "-T"
27 |     assert_match "core:clean_releases", @tester.stdout
28 |     assert_match "core:setup_directories", @tester.stdout
29 |   end
30 | 
31 |   def test_suggests_installing_missing_plugin
32 |     @tester.run "init"
33 |     @tester.run "foo:setup", raise_on_error: false
34 |     assert_match(/did you forget to install the foo plugin/i, @tester.stderr)
35 |   end
36 | 
37 |   def test_prints_error_when_config_has_syntax_error
38 |     @tester.in_temp_dir do
39 |       FileUtils.mkdir_p(".tomo")
40 |       File.write(".tomo/config.rb", <<~CONFIG)
41 |         plugin "git"
42 |         deploy do
43 |           run "git:clone
44 |           run "git:create_release"
45 |         end
46 |       CONFIG
47 |     end
48 |     @tester.run "deploy", raise_on_error: false
49 |     assert_match(<<~OUTPUT.strip, @tester.stderr.gsub(/^  /, ""))
50 |       ERROR: Configuration syntax error in .tomo/config.rb at line 4.
51 | 
52 |         3:   run "git:clone
53 |       → 4:   run "git:create_release"
54 |         5: end
55 | 
56 |       SyntaxError: .tomo/config.rb:4: syntax error
57 |     OUTPUT
58 |   end
59 | 
60 |   def test_prints_error_when_config_dsl_is_used_incorrectly
61 |     @tester.in_temp_dir do
62 |       FileUtils.mkdir_p(".tomo")
63 |       File.write(".tomo/config.rb", <<~CONFIG)
64 |         plugin "git"
65 |         deploy do
66 |           run
67 |           run "git:create_release"
68 |         end
69 |       CONFIG
70 |     end
71 |     @tester.run "deploy", raise_on_error: false
72 |     assert_equal(<<~OUTPUT, @tester.stderr.gsub(/^  /, ""))
73 | 
74 |       ERROR: Configuration syntax error in .tomo/config.rb at line 3.
75 | 
76 |         2: deploy do
77 |       → 3:   run
78 |         4:   run "git:create_release"
79 | 
80 |       ArgumentError: wrong number of arguments (given 0, expected 1)
81 | 
82 |       Visit https://tomo.mattbrictson.com/configuration for syntax reference.
83 |       You can run this command again with --trace for a full backtrace.
84 | 
85 |     OUTPUT
86 |   end
87 | end
88 | 


--------------------------------------------------------------------------------
/test/tomo/colors_test.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | require "test_helper"
 4 | 
 5 | class Tomo::ColorsTest < Minitest::Test
 6 |   def setup
 7 |     # This forces color support detection to happen again
 8 |     Tomo::Colors.remove_instance_variable(:@enabled)
 9 |   end
10 | 
11 |   def teardown
12 |     Tomo::Colors.enabled = false
13 |   end
14 | 
15 |   def test_enabled_by_default_if_tty
16 |     with_tty(true) do
17 |       with_env({}) do
18 |         assert_predicate(Tomo::Colors, :enabled?)
19 |       end
20 |     end
21 |   end
22 | 
23 |   def test_disabled_by_default_if_not_tty
24 |     with_tty(false) do
25 |       with_env({}) do
26 |         refute_predicate(Tomo::Colors, :enabled?)
27 |       end
28 |     end
29 |   end
30 | 
31 |   def test_enabled_by_clicolor_force
32 |     with_tty(false) do
33 |       with_env("CLICOLOR_FORCE" => "1") do
34 |         assert_predicate(Tomo::Colors, :enabled?)
35 |       end
36 |     end
37 |   end
38 | 
39 |   def test_disabled_by_no_color
40 |     with_tty(true) do
41 |       with_env("NO_COLOR" => "1") do
42 |         refute_predicate(Tomo::Colors, :enabled?)
43 |       end
44 |     end
45 |   end
46 | 
47 |   def test_disabled_by_dumb_term
48 |     with_tty(true) do
49 |       with_env("TERM" => "dumb") do
50 |         refute_predicate(Tomo::Colors, :enabled?)
51 |       end
52 |     end
53 |   end
54 | 
55 |   private
56 | 
57 |   def with_tty(tty, &block)
58 |     $stdout.stub(:tty?, tty) { $stderr.stub(:tty?, tty, &block) }
59 |   end
60 | 
61 |   def with_env(env, &)
62 |     ENV.stub(:[], ->(name) { env[name] }, &)
63 |   end
64 | end
65 | 


--------------------------------------------------------------------------------
/test/tomo/configuration_test.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | require "test_helper"
 4 | 
 5 | class Tomo::ConfigurationTest < Minitest::Test
 6 |   include Tomo::Testing::Local
 7 | 
 8 |   def test_parses_a_config_file_that_contains_frozen_string_literals
 9 |     in_temp_dir do
10 |       FileUtils.mkdir ".tomo"
11 |       File.write(".tomo/config.rb", <<~CONFIG)
12 |         # frozen_string_literal: true
13 | 
14 |         setup do
15 |           run "nginx:setup", privileged: true
16 |         end
17 |       CONFIG
18 | 
19 |       parsed = Tomo::Configuration.from_config_rb
20 | 
21 |       assert_instance_of(Tomo::Configuration, parsed)
22 |     end
23 |   end
24 | end
25 | 


--------------------------------------------------------------------------------
/test/tomo/console/key_reader_test.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | require "test_helper"
 4 | require "stringio"
 5 | 
 6 | class Tomo::Console
 7 |   class KeyReaderTest < Minitest::Test
 8 |     def setup
 9 |       @input = StringIO.new
10 |       @input.define_singleton_method(:raw) do |&block|
11 |         block.call
12 |       end
13 |     end
14 | 
15 |     def test_reads_a_single_keystroke
16 |       @input << "h"
17 |       @input.rewind
18 | 
19 |       key_reader = KeyReader.new(@input)
20 |       char = key_reader.next
21 | 
22 |       assert_equal("h", char)
23 |     end
24 |   end
25 | end
26 | 


--------------------------------------------------------------------------------
/test/tomo/console_test.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | require "test_helper"
 4 | require "stringio"
 5 | 
 6 | class Tomo::ConsoleTest < Minitest::Test
 7 |   def test_interactive_is_true_for_tty
 8 |     assert_predicate Tomo::Console.new({}, tty), :interactive?
 9 |   end
10 | 
11 |   def test_interactive_is_false_for_ci_env
12 |     refute_predicate Tomo::Console.new({ "CIRCLECI" => "1" }, tty), :interactive?
13 |   end
14 | 
15 |   def test_interactive_is_false_non_tty
16 |     refute_predicate Tomo::Console.new({}, non_tty), :interactive?
17 |   end
18 | 
19 |   def test_prompt_answer_does_not_contain_newline
20 |     stdout = StringIO.new
21 |     console = Tomo::Console.new({}, tty("yes\n"), stdout)
22 |     answer = console.prompt("Are you sure? ")
23 |     assert_equal("Are you sure? ", stdout.string)
24 |     assert_equal("yes", answer)
25 |   end
26 | 
27 |   def test_prompt_raises_if_not_tty
28 |     console = Tomo::Console.new({}, non_tty("yes\n"))
29 |     error = assert_raises(Tomo::Console::NonInteractiveError) { console.prompt("Are you sure? ") }
30 |     assert_match(/requires an interactive console/i, error.to_console)
31 |   end
32 | 
33 |   def test_prompt_raises_if_ci
34 |     console = Tomo::Console.new({ "CIRCLECI" => "1" }, tty("yes\n"))
35 |     error = assert_raises(Tomo::Console::NonInteractiveError) { console.prompt("Are you sure? ") }
36 |     assert_match(/appears to be a non-interactive CI environment/i, error.to_console)
37 |   end
38 | 
39 |   def test_menu_raises_if_not_tty
40 |     console = Tomo::Console.new({}, non_tty("yes\n"))
41 |     error = assert_raises(Tomo::Console::NonInteractiveError) { console.menu("Are you sure? ", choices: %w[y n]) }
42 |     assert_match(/requires an interactive console/i, error.to_console)
43 |   end
44 | 
45 |   def test_menu_raises_if_ci
46 |     console = Tomo::Console.new({ "CIRCLECI" => "1" }, tty("yes\n"))
47 |     error = assert_raises(Tomo::Console::NonInteractiveError) { console.menu("Are you sure? ", choices: %w[y n]) }
48 |     assert_match(/appears to be a non-interactive CI environment/i, error.to_console)
49 |   end
50 | 
51 |   private
52 | 
53 |   def tty(data="")
54 |     StringIO.new(data).tap do |io|
55 |       def io.raw
56 |       end
57 | 
58 |       def io.tty?
59 |         true
60 |       end
61 |     end
62 |   end
63 | 
64 |   def non_tty(data="")
65 |     StringIO.new(data)
66 |   end
67 | end
68 | 


--------------------------------------------------------------------------------
/test/tomo/host_test.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | require "test_helper"
 4 | 
 5 | class Tomo::HostTest < Minitest::Test
 6 |   def test_parse_hostname
 7 |     host = Tomo::Host.parse("app.example.com")
 8 |     assert_equal("app.example.com", host.address)
 9 |     assert_equal(22, host.port)
10 |     assert_empty(host.roles)
11 |     assert_nil(host.log_prefix)
12 |     assert_nil(host.user)
13 |   end
14 | 
15 |   def test_parse_hostname_with_user
16 |     host = Tomo::Host.parse("deployer@app.example.com")
17 |     assert_equal("app.example.com", host.address)
18 |     assert_equal(22, host.port)
19 |     assert_equal("deployer", host.user)
20 |     assert_empty(host.roles)
21 |     assert_nil(host.log_prefix)
22 |   end
23 | 
24 |   def test_parse_ip_address_with_user
25 |     host = Tomo::Host.parse("my.user@10.1.19.2")
26 |     assert_equal("10.1.19.2", host.address)
27 |     assert_equal(22, host.port)
28 |     assert_equal("my.user", host.user)
29 |     assert_empty(host.roles)
30 |     assert_nil(host.log_prefix)
31 |   end
32 | 
33 |   def test_parse_with_options
34 |     host = Tomo::Host.parse("deployer@app.example.com", port: 8022, log_prefix: "one", roles: %w[db web])
35 |     assert_equal("app.example.com", host.address)
36 |     assert_equal(8022, host.port)
37 |     assert_equal("deployer", host.user)
38 |     assert_equal("one", host.log_prefix)
39 |     assert_equal(%w[db web], host.roles)
40 |   end
41 | end
42 | 


--------------------------------------------------------------------------------
/test/tomo/paths_test.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | require "test_helper"
 4 | 
 5 | class Tomo::PathsTest < Minitest::Test
 6 |   def test_raises_if_setting_does_not_exist
 7 |     paths = Tomo::Paths.new({})
 8 |     assert_raises(NoMethodError) { paths.storage }
 9 |   end
10 | 
11 |   def test_returns_nil_if_setting_is_nil
12 |     paths = Tomo::Paths.new(storage_path: nil)
13 |     assert_nil(paths.storage)
14 |   end
15 | end
16 | 


--------------------------------------------------------------------------------
/test/tomo/plugin/git/tasks_test.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | require "test_helper"
 4 | require "tomo/plugin/git"
 5 | 
 6 | class Tomo::Plugin::Git::TasksTest < Minitest::Test
 7 |   def test_config_sets_name_and_email_with_user_by_default
 8 |     tester = configure
 9 |     tester.run_task("git:config")
10 |     assert_equal(
11 |       [
12 |         "git config --global user.name testing",
13 |         "git config --global user.email testing@example.com"
14 |       ],
15 |       tester.executed_scripts
16 |     )
17 |   end
18 | 
19 |   def test_config_sets_name_and_email_based_on_settings
20 |     tester = configure(
21 |       git_user_name: "ahoy user",
22 |       git_user_email: "hello@test.biz"
23 |     )
24 |     tester.run_task("git:config")
25 |     assert_equal(
26 |       [
27 |         "git config --global user.name ahoy\\ user",
28 |         "git config --global user.email hello@test.biz"
29 |       ],
30 |       tester.executed_scripts
31 |     )
32 |   end
33 | 
34 |   def test_create_release_uses_branch_if_specified
35 |     tester = configure(git_branch: "develop")
36 |     tester.run_task("git:create_release")
37 |     assert_equal(
38 |       "cd /repo && git archive develop | tar -x -f - -C /app",
39 |       tester.executed_scripts.grep(/git archive/).first
40 |     )
41 |   end
42 | 
43 |   def test_create_release_uses_ref_if_specified
44 |     tester = configure(git_branch: nil, git_ref: "a944898")
45 |     tester.run_task("git:create_release")
46 |     assert_equal(
47 |       "cd /repo && git archive a944898 | tar -x -f - -C /app",
48 |       tester.executed_scripts.grep(/git archive/).first
49 |     )
50 |   end
51 | 
52 |   def test_create_release_uses_ref_if_both_branch_and_ref_specified
53 |     tester = configure(git_branch: "main", git_ref: "a944898")
54 |     tester.run_task("git:create_release")
55 |     assert_equal(
56 |       "cd /repo && git archive a944898 | tar -x -f - -C /app",
57 |       tester.executed_scripts.grep(/git archive/).first
58 |     )
59 |     assert_equal(<<~ERROR, tester.stderr)
60 |       WARNING: :git_ref (a944898) and :git_branch (main) are both specified. Ignoring :git_branch.
61 |     ERROR
62 |   end
63 | 
64 |   def test_create_release_raises_error_if_branch_and_ref_both_nil
65 |     tester = configure(git_branch: nil, git_ref: nil)
66 |     assert_raises(Tomo::Runtime::SettingsRequiredError) do
67 |       tester.run_task("git:create_release")
68 |     end
69 |   end
70 | 
71 |   private
72 | 
73 |   def configure(settings={})
74 |     defaults = {
75 |       git_env: {},
76 |       git_repo_path: "/repo",
77 |       release_path: "/app"
78 |     }
79 |     settings = defaults.merge(settings)
80 |     Tomo::Testing::MockPluginTester.new("git", settings:)
81 |   end
82 | end
83 | 


--------------------------------------------------------------------------------
/test/tomo/plugin/rails/helpers_test.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | require "test_helper"
 4 | require "tomo/plugin/rails"
 5 | 
 6 | class Tomo::Plugin::Rails::HelpersTest < Minitest::Test
 7 |   def test_rake_runs_bundle_exec_rake_in_current_path
 8 |     tester = Tomo::Testing::MockPluginTester.new("bundler", "rails", settings: { current_path: "/app/current" })
 9 |     tester.call_helper(:rake, "db:migrate")
10 |     assert_equal("cd /app/current && bundle exec rake db:migrate", tester.executed_script)
11 |   end
12 | 
13 |   def test_thor_runs_bundle_exec_thor_in_current_path
14 |     tester = Tomo::Testing::MockPluginTester.new("bundler", "rails", settings: { current_path: "/app/current" })
15 |     tester.call_helper(:thor, "user:create")
16 |     assert_equal("cd /app/current && bundle exec thor user:create", tester.executed_script)
17 |   end
18 | end
19 | 


--------------------------------------------------------------------------------
/test/tomo/plugin/rails/tasks_test.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | require "test_helper"
 4 | require "tomo/plugin/rails"
 5 | 
 6 | class Tomo::Plugin::Rails::TasksTest < Minitest::Test
 7 |   def test_db_console
 8 |     tester = Tomo::Testing::MockPluginTester.new(
 9 |       "bundler", "rails", settings: { current_path: "/app/current" }
10 |     )
11 |     assert_raises(Tomo::Testing::MockedExecError) { tester.run_task("rails:db_console") }
12 |     assert_equal("cd /app/current && bundle exec rails dbconsole --include-password", tester.executed_script)
13 |   end
14 | end
15 | 


--------------------------------------------------------------------------------
/test/tomo/runtime/settings_interpolation_test.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | require "test_helper"
 4 | 
 5 | class Tomo::Runtime::SettingsInterpolationTest < Minitest::Test
 6 |   def test_interpolates_settings
 7 |     interpolated = interpolate(
 8 |       application: "test",
 9 |       deploy_to: "/var/www/%{application}",
10 |       current_path: "%{deploy_to}/current",
11 |       application_json_path: "%{deploy_to}/%{application}.json"
12 |     )
13 |     assert_equal(
14 |       {
15 |         application: "test",
16 |         deploy_to: "/var/www/test",
17 |         current_path: "/var/www/test/current",
18 |         application_json_path: "/var/www/test/test.json"
19 |       },
20 |       interpolated
21 |     )
22 |   end
23 | 
24 |   def test_raises_on_unknown_setting
25 |     assert_raises(KeyError) do
26 |       interpolate(deploy_to: "/var/www/%{application}")
27 |     end
28 |   end
29 | 
30 |   def test_raises_on_circular_dependency
31 |     exception = assert_raises(RuntimeError) do
32 |       interpolate(
33 |         application: "default",
34 |         deploy_to: "%{current_path}/%{application}",
35 |         current_path: "%{deploy_to}/current"
36 |       )
37 |     end
38 |     assert_match("Circular dependency detected in settings: deploy_to -> current_path -> deploy_to", exception.message)
39 |   end
40 | 
41 |   def test_no_longer_supports_old_syntax
42 |     interpolated = interpolate(
43 |       application: "default",
44 |       deploy_to: "/var/www/%"
45 |     )
46 |     assert_equal({ application: "default", deploy_to: "/var/www/%" }, interpolated)
47 |   end
48 | 
49 |   private
50 | 
51 |   def interpolate(settings)
52 |     Tomo::Runtime::SettingsInterpolation.interpolate(settings)
53 |   end
54 | end
55 | 


--------------------------------------------------------------------------------
/test/tomo/runtime_test.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | require "test_helper"
 4 | 
 5 | class Tomo::RuntimeTest < Minitest::Test
 6 |   def test_deploy_raises_if_no_deploy_tasks
 7 |     runtime = Tomo::Configuration.new.build_runtime
 8 |     assert_raises(Tomo::Runtime::NoTasksError) do
 9 |       runtime.deploy!
10 |     end
11 |   end
12 | 
13 |   def test_setup_raises_if_no_setup_tasks
14 |     runtime = Tomo::Configuration.new.build_runtime
15 |     assert_raises(Tomo::Runtime::NoTasksError) do
16 |       runtime.setup!
17 |     end
18 |   end
19 | 
20 |   def test_local_user_reads_user_env_var
21 |     with_env(USER: "foo") do
22 |       assert_equal("foo", local_user)
23 |     end
24 |   end
25 | 
26 |   def test_local_user_reads_username_env_var
27 |     with_env(USER: nil, USERNAME: "bar") do
28 |       assert_equal("bar", local_user)
29 |     end
30 |   end
31 | 
32 |   def test_local_user_reads_whoami_output
33 |     with_env(USER: nil, USERNAME: nil) do
34 |       with_whoami_mock("baz\n") do
35 |         assert_equal("baz", local_user)
36 |       end
37 |     end
38 |   end
39 | 
40 |   def test_local_user_gracefully_handles_whoami_failure
41 |     with_env(USER: nil, USERNAME: nil) do
42 |       with_whoami_mock(Errno::ENOENT) do
43 |         assert_nil(local_user)
44 |       end
45 |     end
46 |   end
47 | 
48 |   private
49 | 
50 |   def local_user
51 |     runtime = Tomo::Testing::MockPluginTester.new.send(:runtime)
52 |     plan = runtime.execution_plan_for([])
53 |     plan.settings.fetch(:local_user)
54 |   end
55 | 
56 |   def with_env(mock_env)
57 |     orig_env = ENV.to_h.dup
58 |     mock_env.each do |key, value|
59 |       ENV[key.to_s] = value
60 |     end
61 |     yield
62 |   ensure
63 |     orig_env.each do |key, value|
64 |       ENV[key.to_s] = value
65 |     end
66 |   end
67 | 
68 |   def with_whoami_mock(result, &)
69 |     result_callable = ->(*) { result.is_a?(Exception) ? raise(result) : result }
70 |     Tomo::Runtime.stub(:`, result_callable, &)
71 |   end
72 | end
73 | 


--------------------------------------------------------------------------------
/test/tomo/shell_builder_test.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | require "test_helper"
 4 | 
 5 | class Tomo::ShellBuilderTest < Minitest::Test
 6 |   def test_raw_preserves_string_when_shellescaped
 7 |     raw_string = Tomo::ShellBuilder.raw("$HOME")
 8 |     assert_equal("$HOME", raw_string.shellescape)
 9 |   end
10 | 
11 |   def test_raw_works_with_frozen_strings
12 |     raw_string = Tomo::ShellBuilder.raw("$HOME")
13 |     assert_equal("$HOME", raw_string.shellescape)
14 |   end
15 | end
16 | 


--------------------------------------------------------------------------------
/test/tomo/task_api_test.rb:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | require "test_helper"
 4 | 
 5 | class Tomo::TaskAPITest < Minitest::Test
 6 |   Subject = Struct.new(:context)
 7 |   Subject.include Tomo::TaskAPI
 8 | 
 9 |   def test_merge_template_with_absolute_path
10 |     abs_path = File.expand_path("../fixtures/template.erb", __dir__)
11 |     subject = configure(application: "test-app")
12 |     merged = subject.send(:merge_template, abs_path)
13 |     assert_equal("Hello, test-app!\n", merged)
14 |   end
15 | 
16 |   def test_merge_template_with_path_relative_to_config
17 |     config_path = File.expand_path("../../.tomo/config.rb", __dir__)
18 |     rel_path = "../test/fixtures/template.erb"
19 |     subject = configure(application: "test-app", tomo_config_file_path: config_path)
20 |     merged = subject.send(:merge_template, rel_path)
21 |     assert_equal("Hello, test-app!\n", merged)
22 |   end
23 | 
24 |   def test_merge_template_raises_on_file_not_found
25 |     subject = configure
26 |     assert_raises(Tomo::Runtime::TemplateNotFoundError) do
27 |       subject.send(:merge_template, "path_does_not_exist")
28 |     end
29 |   end
30 | 
31 |   private
32 | 
33 |   def configure(settings={})
34 |     defaults = { tomo_config_file_path: nil }
35 |     Subject.new(Tomo::Runtime::Context.new(defaults.merge(settings)))
36 |   end
37 | end
38 | 


--------------------------------------------------------------------------------
/tomo.gemspec:
--------------------------------------------------------------------------------
 1 | # frozen_string_literal: true
 2 | 
 3 | require_relative "lib/tomo/version"
 4 | 
 5 | Gem::Specification.new do |spec|
 6 |   spec.name = "tomo"
 7 |   spec.version = Tomo::VERSION
 8 |   spec.authors = ["Matt Brictson"]
 9 |   spec.email = ["opensource@mattbrictson.com"]
10 | 
11 |   spec.summary = "A friendly CLI for deploying Rails apps ✨"
12 |   spec.description =
13 |     "Tomo is a feature-rich deployment tool that contains everything you need to deploy a basic Rails app out of the " \
14 |     "box. It has an opinionated, production-tested set of defaults, but is easily extensible via a well-documented " \
15 |     "plugin system. Unlike other Ruby-based deployment tools, tomo’s friendly command-line interface and task system " \
16 |     "do not rely on Rake."
17 | 
18 |   spec.homepage = "https://github.com/mattbrictson/tomo"
19 |   spec.license = "MIT"
20 |   spec.required_ruby_version = ">= 3.1"
21 | 
22 |   spec.metadata = {
23 |     "bug_tracker_uri" => "https://github.com/mattbrictson/tomo/issues",
24 |     "changelog_uri" => "https://github.com/mattbrictson/tomo/releases",
25 |     "source_code_uri" => "https://github.com/mattbrictson/tomo",
26 |     "homepage_uri" => spec.homepage,
27 |     "documentation_uri" => "https://tomo.mattbrictson.com/",
28 |     "rubygems_mfa_required" => "true"
29 |   }
30 | 
31 |   # Specify which files should be added to the gem when it is released.
32 |   spec.files = Dir.glob(%w[LICENSE.txt README.md {exe,lib}/**/*]).reject { |f| File.directory?(f) }
33 |   spec.bindir = "exe"
34 |   spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
35 |   spec.require_paths = ["lib"]
36 | end
37 | 


--------------------------------------------------------------------------------