From 93c4a1d797b82caf3a13ca5c90368257caeab1dd Mon Sep 17 00:00:00 2001 From: Stefan N Date: Sun, 18 Jan 2026 11:27:37 +0100 Subject: [PATCH] direct deployment plugin --- lib/tomo/plugin/direct.rb | 15 +++++++ lib/tomo/plugin/direct/README.md | 33 +++++++++++++++ lib/tomo/plugin/direct/helpers.rb | 25 +++++++++++ lib/tomo/plugin/direct/tasks.rb | 49 ++++++++++++++++++++++ lib/tomo/remote.rb | 2 +- lib/tomo/ssh/connection.rb | 4 ++ lib/tomo/ssh/options.rb | 12 ++++++ test/tomo/plugin/direct/tasks_test.rb | 60 +++++++++++++++++++++++++++ 8 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 lib/tomo/plugin/direct.rb create mode 100644 lib/tomo/plugin/direct/README.md create mode 100644 lib/tomo/plugin/direct/helpers.rb create mode 100644 lib/tomo/plugin/direct/tasks.rb create mode 100644 test/tomo/plugin/direct/tasks_test.rb diff --git a/lib/tomo/plugin/direct.rb b/lib/tomo/plugin/direct.rb new file mode 100644 index 00000000..a0fb434f --- /dev/null +++ b/lib/tomo/plugin/direct.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative "direct/helpers" +require_relative "direct/tasks" + +module Tomo::Plugin + module Direct + extend Tomo::PluginDSL + + helpers Tomo::Plugin::Direct::Helpers + tasks Tomo::Plugin::Direct::Tasks + defaults direct_source_path: nil, + direct_exclusions: nil + end +end diff --git a/lib/tomo/plugin/direct/README.md b/lib/tomo/plugin/direct/README.md new file mode 100644 index 00000000..6676cc58 --- /dev/null +++ b/lib/tomo/plugin/direct/README.md @@ -0,0 +1,33 @@ +# Direct Plugin + +Deploy directly from your local filesystem to the target server via tarball streaming, bypassing the need for a git repository. + +## Usage + +```ruby +# .tomo/config.rb +plugin "direct" + +host "deployer@app.example.com" + +set application: "myapp" +set deploy_to: "/var/www/%{application}" +set direct_exclusions: %w[ + .env* + log + tmp + node_modules +] + +setup do + ... + run "direct:create_release" # instead of git:* + ... +end + +deploy do + ... + run "direct:create_release" # instead of git:create_release + ... +end +``` diff --git a/lib/tomo/plugin/direct/helpers.rb b/lib/tomo/plugin/direct/helpers.rb new file mode 100644 index 00000000..2f306d9b --- /dev/null +++ b/lib/tomo/plugin/direct/helpers.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "shellwords" + +module Tomo::Plugin::Direct + module Helpers + class << self + attr_accessor :system_proc + end + self.system_proc = ->(cmd) { Kernel.system(cmd, exception: true) } + + def upload_archive(source_path:, destination_path:, exclusions:) + tar_excludes = exclusions.map { |e| "--exclude=#{e.shellescape}" }.join(" ") + local_tar = "COPYFILE_DISABLE=1 tar --no-xattrs -c -C #{source_path.shellescape} #{tar_excludes} ." + remote_tar = "tar -x -C #{destination_path.to_s.shellescape}" + ssh_args = ssh_args_for_pipe.shelljoin + + full_command = "#{local_tar} | #{ssh_args} #{remote_tar}" + Tomo.logger.info("Streaming archive to #{destination_path}") + Tomo.logger.debug(full_command) + + Helpers.system_proc.call(full_command) unless Tomo.dry_run? + end + end +end diff --git a/lib/tomo/plugin/direct/tasks.rb b/lib/tomo/plugin/direct/tasks.rb new file mode 100644 index 00000000..7aa9f776 --- /dev/null +++ b/lib/tomo/plugin/direct/tasks.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Tomo::Plugin::Direct + class Tasks < Tomo::TaskLibrary + DEFAULT_EXCLUSIONS = %w[ + .git + .gitignore + .tomo + node_modules + tmp + log + .bundle + vendor/bundle + *.log + .env + .env.* + .DS_Store + ].freeze + + def create_release + remote.mkdir_p(paths.release) + store_release_info + remote.upload_archive( + source_path: resolve_source_path, + destination_path: paths.release, + exclusions: all_exclusions + ) + end + + private + + def resolve_source_path + configured_path = settings[:direct_source_path] + return configured_path if configured_path + + paths.tomo_config_file&.dirname&.dirname&.to_s || Dir.pwd + end + + def all_exclusions + settings[:direct_exclusions] || DEFAULT_EXCLUSIONS + end + + def store_release_info + remote.release[:deploy_date] = Time.now.to_s + remote.release[:deploy_user] = settings.fetch(:local_user) + remote.release[:source_path] = resolve_source_path + end + end +end diff --git a/lib/tomo/remote.rb b/lib/tomo/remote.rb index be064457..63ed9a51 100644 --- a/lib/tomo/remote.rb +++ b/lib/tomo/remote.rb @@ -8,7 +8,7 @@ class Remote extend Forwardable - def_delegators :ssh, :close, :host + def_delegators :ssh, :close, :host, :ssh_args_for_pipe def_delegators :shell_builder, :chdir, :env, :prepend, :umask attr_reader :release diff --git a/lib/tomo/ssh/connection.rb b/lib/tomo/ssh/connection.rb index 4f07ae27..a5bfbc0f 100644 --- a/lib/tomo/ssh/connection.rb +++ b/lib/tomo/ssh/connection.rb @@ -44,6 +44,10 @@ def close FileUtils.rm_f(control_path) end + def ssh_args_for_pipe + options.build_args_for_pipe(host, control_path) + end + private attr_reader :options, :exec_proc, :child_proc diff --git a/lib/tomo/ssh/options.rb b/lib/tomo/ssh/options.rb index d0bffdad..a5723d81 100644 --- a/lib/tomo/ssh/options.rb +++ b/lib/tomo/ssh/options.rb @@ -36,6 +36,18 @@ def build_args(host, script, control_path, verbose) # rubocop:disable Metrics/Ab [executable, args, script.to_s].flatten end + def build_args_for_pipe(host, control_path) + args = [["-o", "LogLevel=ERROR"]] + args << "-A" if forward_agent + args << connect_timeout_option + args << strict_host_key_checking_option + args.push(*control_opts(control_path, false)) if reuse_connections + args.push(*extra_opts) if extra_opts + args << host.to_ssh_args + + [executable, args].flatten + end + private attr_writer :executable diff --git a/test/tomo/plugin/direct/tasks_test.rb b/test/tomo/plugin/direct/tasks_test.rb new file mode 100644 index 00000000..2781830a --- /dev/null +++ b/test/tomo/plugin/direct/tasks_test.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "test_helper" +require "tomo/plugin/direct" + +class Tomo::Plugin::Direct::TasksTest < Minitest::Test + def setup + @executed_commands = [] + Tomo::Plugin::Direct::Helpers.system_proc = ->(cmd) { @executed_commands << cmd } + end + + def teardown + Tomo::Plugin::Direct::Helpers.system_proc = ->(cmd) { Kernel.system(cmd, exception: true) } + end + + def test_create_release_creates_release_directory + tester = configure + tester.run_task("direct:create_release") + assert_includes(tester.executed_scripts, "mkdir -p /app") + end + + def test_create_release_stores_release_info + tester = configure + tester.run_task("direct:create_release") + assert_includes(tester.stdout, "Streaming archive") + end + + def test_create_release_uses_configured_source_path + tester = configure(direct_source_path: "/my/project") + tester.run_task("direct:create_release") + assert(@executed_commands.any? { |cmd| cmd.include?("-C /my/project") }) + end + + def test_create_release_includes_default_exclusions + tester = configure + tester.run_task("direct:create_release") + cmd = @executed_commands.first + assert_includes(cmd, "--exclude=.git") + assert_includes(cmd, "--exclude=node_modules") + assert_includes(cmd, "--exclude=.DS_Store") + end + + def test_create_release_includes_custom_exclusions + tester = configure(direct_exclusions: %w[spec/ coverage/]) + tester.run_task("direct:create_release") + cmd = @executed_commands.first + assert_includes(cmd, "--exclude=spec/") + assert_includes(cmd, "--exclude=coverage/") + end + + private + + def configure(settings={}) + defaults = { + release_path: "/app" + } + settings = defaults.merge(settings) + Tomo::Testing::MockPluginTester.new("direct", settings:) + end +end