capistrano-ops 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 708599bef12f8e7ce1b96a36fb09c1bc9acc4d50d871f2d1d5a3790240455096
4
- data.tar.gz: 8c13bfc221324e6303f474453185f868d9984dba83da63de7715b747d9113594
3
+ metadata.gz: fe9dc820d50c226c74d26afad1f5031651bdf9758c220268cfe1d8721416b011
4
+ data.tar.gz: 5ac2da6ebbd2a6d7ba4bebf031b2bd3b690f30367ce48437a8c2656a2b65ec44
5
5
  SHA512:
6
- metadata.gz: 3bd6ada29edf5e6a3c077c35a83908d1b40e235b0f33497a33b309e7902ea4fe16910a078e41b5bd5feb58246d4903e31b31d32567d88341ec5847c89a435aa9
7
- data.tar.gz: 2d987eaaab73c8c5a6f595226371c8979d816656e3014fa744b1725bb27dff4e82cdd66d79e92dcc89fd2963dc03f5c3e6f5ede7a85c0fc33f639c7ff9e0dcfa
6
+ metadata.gz: de3334c09fe3b2f7cb113d13d8a309d5466464c438499e684531e015d888f73cdc8e2f7d033bbe96cf9bebab64c89ba8c18d1b74cae48ed6df5172ea47741302
7
+ data.tar.gz: 3d177c55f3fcf475f030f67222a0a14b715f712f4627ae6c96db27e8f75a2af162015080410d7bbd0f822b854cbfd88e5e16df668cca21132ddd4cbccd462177
data/.rubocop.yml CHANGED
@@ -10,7 +10,6 @@ AllCops:
10
10
  - 'vendor/**/*'
11
11
  - 'tmp/**/*'
12
12
  - 'bin/**/*'
13
- - 'lib/capistrano/ops/local/**/*'
14
13
 
15
14
  # Metrics
16
15
 
data/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.0] - 2026-09-18
11
+
12
+ ### Added
13
+
14
+ - Add `local:run` and `local:console` tasks (module `capistrano/ops/local`) to upload a local Ruby script from `scripts/local/` to the server and execute it — once via `rails runner`, or interactively with a Rails console
15
+ - Helpers module `Capistrano::Ops::Local::Helpers` with script resolution (auto `.rb`), listing, TTY-aware coloring, and dangerous-stage confirmation
16
+ - Configurable settings: `:local_scripts_dir`, `:local_dangerous_stages`; existing `:console_role`, `:console_env`, `:console_user`, `:console_shell` respected
17
+ - Specs for helpers (script resolution, TTY coloring, tmp-path generation)
18
+ - README section documenting the new tasks, including `capistrano-rails` prerequisite for `local:console`
19
+
10
20
  ## [1.1.0] - 2026-09-18
11
21
 
12
22
  ### Added
@@ -311,7 +321,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
311
321
  - Figaro YAML management
312
322
  - Basic Capistrano integration
313
323
 
314
- [Unreleased]: https://github.com/zauberware/capistrano-ops/compare/v1.1.0...HEAD
324
+ [Unreleased]: https://github.com/zauberware/capistrano-ops/compare/v1.2.0...HEAD
325
+ [1.2.0]: https://github.com/zauberware/capistrano-ops/releases/tag/v1.2.0
315
326
  [1.1.0]: https://github.com/zauberware/capistrano-ops/releases/tag/v1.1.0
316
327
  [1.0.11]: https://github.com/zauberware/capistrano-ops/releases/tag/v1.0.11
317
328
  [v1.0.10]: https://github.com/zauberware/capistrano-ops/compare/v1.0.9...v1.0.10
data/README.md CHANGED
@@ -24,6 +24,7 @@ The capistrano-ops gem is a valuable library, tailor-made for Rails DevOps profe
24
24
  - [Configuration](#configuration-1)
25
25
  - [Usage](#usage-1)
26
26
  - [Wkhtmltopdf Setup](#wkhtmltopdf-setup)
27
+ - [Local scripts (run / console)](#local-scripts-run--console)
27
28
  - [Contributing](#contributing)
28
29
  - [License](#license)
29
30
 
@@ -170,6 +171,8 @@ require 'capistrano/ops'
170
171
  | `cap <environment> logrotate:enable` | enable logrotate for logfiles on server |
171
172
  | `cap <environment> logrotate:disable` | disable logrotate for logfiles on server |
172
173
  | `cap <environment> logrotate:check` | show logrotate status for logfiles on server |
174
+ | `cap <environment> local:run SCRIPT=<file>` | upload local script and execute it once via `rails runner` on the server |
175
+ | `cap <environment> local:console SCRIPT=<file>` | open a remote Rails console with a local script preloaded |
173
176
  | `rake pg:dump` | creates postgres database backup |
174
177
  | `rake pg:remove_old_dumps` | remove old postgres backups |
175
178
  | `rake storage:backup` | creates backup of storage |
@@ -390,6 +393,90 @@ require 'capistrano/ops/wkhtmltopdf'
390
393
 
391
394
  [↑](#)
392
395
 
396
+ ## Local scripts (run / console)
397
+
398
+ Ship a Ruby script from your workstation onto the server and execute it there —
399
+ either once via `rails runner`, or with a Rails console attached and the
400
+ script's methods preloaded. Useful for one-off data patches, ad-hoc audits, or
401
+ debugging sessions where you want your local scratch code to have full access
402
+ to the production Rails environment.
403
+
404
+ ### Requirements
405
+
406
+ `local:console` uses the interactive console runner shipped in `capistrano-rails`.
407
+ Make sure both are loaded in your `Capfile`:
408
+
409
+ ```ruby
410
+ # Capfile
411
+ require 'capistrano/rails/console' # provides run_interactively (needed by local:console)
412
+ require 'capistrano/ops' # auto-loads capistrano/ops/local
413
+ ```
414
+
415
+ If `capistrano-rails` is not in your Gemfile, the `local:*` tasks will emit a
416
+ warning at load time and skip registering. `local:run` alone technically only
417
+ needs core Capistrano, but for simplicity both tasks share the same gate.
418
+
419
+ ### Setup
420
+
421
+ Create the scripts directory in your app root (default `scripts/local/`) and
422
+ drop `.rb` files in it:
423
+
424
+ ```
425
+ scripts/local/
426
+ ├── check_stuck_jobs.rb
427
+ └── set_welcome_email_template.rb
428
+ ```
429
+
430
+ The directory is configurable:
431
+
432
+ ```ruby
433
+ # config/deploy.rb
434
+ set :local_scripts_dir, 'ops/scripts' # default: 'scripts/local'
435
+ ```
436
+
437
+ ### Tasks
438
+
439
+ ```bash
440
+ cap staging local:run SCRIPT=check_stuck_jobs # execute once via rails runner
441
+ cap staging local:console SCRIPT=check_stuck_jobs # open remote console with script preloaded
442
+ ```
443
+
444
+ - `.rb` extension is optional (`SCRIPT=foo` and `SCRIPT=foo.rb` both work)
445
+ - Omitting `SCRIPT=` prints the list of available scripts
446
+ - Interactive `[y/N]` confirmation on "dangerous" stages (default: `production`)
447
+ - Uploaded temp files are cleaned up automatically after execution
448
+
449
+ ### Writing scripts
450
+
451
+ Define methods rather than top-level code — this way the same script works for
452
+ both `local:run` (direct execution) and `local:console` (interactive access):
453
+
454
+ ```ruby
455
+ # scripts/local/check_stuck_jobs.rb
456
+ def show_stuck_jobs(hours_ago: 24)
457
+ cutoff = hours_ago.hours.ago
458
+ Sidekiq::Workers.new.select { |_, _, w| Time.at(w['run_at']) < cutoff }.each do |process, _, work|
459
+ puts "#{work['payload']['class']} on #{process}"
460
+ end
461
+ end
462
+
463
+ # auto-run when executed via local:run, dormant in the console
464
+ show_stuck_jobs unless defined?(IRB)
465
+ ```
466
+
467
+ ### Configuration options
468
+
469
+ | Setting | Default | Purpose |
470
+ | -------------------------- | ------------------------------- | ---------------------------------------------- |
471
+ | `:local_scripts_dir` | `'scripts/local'` | Where local scripts are read from |
472
+ | `:local_dangerous_stages` | `%w[production]` | Stages that trigger the `[y/N]` confirmation |
473
+ | `:console_role` | `:app` | Capistrano role used to pick the target host |
474
+ | `:console_env` | `fetch(:rails_env) \|\| :stage` | Rails env passed to `rails runner` / `console` |
475
+ | `:console_user` | (unset) | `sudo -u` target user for the remote command |
476
+ | `:console_shell` | (unset) | Shell override for the interactive session |
477
+
478
+ [↑](#)
479
+
393
480
  ## Contributing
394
481
 
395
482
  1. Fork it ( https://github.com/zauberware/capistrano-ops/fork )
@@ -7,6 +7,7 @@ require 'capistrano/ops/logrotate'
7
7
  require 'capistrano/ops/logs'
8
8
  require 'capistrano/ops/invoke'
9
9
  require 'capistrano/ops/backup'
10
+ require 'capistrano/ops/local'
10
11
 
11
12
  unless defined?(Capistrano::VERSION) && Gem::Version.new(Capistrano::VERSION).release >= Gem::Version.new('3.0.0')
12
13
  puts 'Capistrano 3 is required to use this gem'
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Capistrano
6
+ module Ops
7
+ module Local
8
+ module Helpers
9
+ def scripts_dir
10
+ File.expand_path(fetch(:local_scripts_dir, 'scripts/local'), Dir.pwd)
11
+ end
12
+
13
+ def available_scripts
14
+ Dir.glob(File.join(scripts_dir, '*.rb')).map { |f| File.basename(f) }.sort
15
+ end
16
+
17
+ def resolve_script(name)
18
+ return nil if name.nil? || name.empty?
19
+
20
+ candidates = [name, "#{name}.rb"].map { |n| File.join(scripts_dir, n) }
21
+ candidates.find { |p| File.exist?(p) }
22
+ end
23
+
24
+ def print_available_scripts
25
+ scripts = available_scripts
26
+ if scripts.empty?
27
+ puts " (no scripts found in #{fetch(:local_scripts_dir, 'scripts/local')}/)"
28
+ else
29
+ scripts.each { |s| puts " • #{s}" }
30
+ end
31
+ end
32
+
33
+ def abort_script_not_given(command)
34
+ puts "\n#{color('Usage:', :bold)} cap <stage> local:#{command} SCRIPT=<filename>"
35
+ puts "\n#{color('Available scripts:', :bold)}"
36
+ print_available_scripts
37
+ abort
38
+ end
39
+
40
+ def abort_script_not_found(name)
41
+ puts "\n#{color("Script not found: #{name}", :red)}"
42
+ puts "\n#{color('Available scripts:', :bold)}"
43
+ print_available_scripts
44
+ abort
45
+ end
46
+
47
+ def confirm_dangerous_stage!(action)
48
+ stage = fetch(:stage).to_s
49
+ dangerous = fetch(:local_dangerous_stages, %w[production])
50
+ return unless dangerous.include?(stage)
51
+
52
+ print "\n#{color("⚠ #{action} on #{stage.upcase}. Continue? [y/N] ", :yellow)}"
53
+ abort 'Aborted.' unless $stdin.gets.to_s.strip.casecmp('y').zero?
54
+ end
55
+
56
+ def remote_tmp_path(script_path)
57
+ "/tmp/cap_local_#{SecureRandom.hex(6)}_#{File.basename(script_path)}"
58
+ end
59
+
60
+ def color(text, style)
61
+ return text unless $stdout.tty?
62
+
63
+ code = { bold: '1', red: '31', green: '32', yellow: '33', cyan: '36', dim: '2' }[style]
64
+ return text unless code
65
+
66
+ "\e[#{code}m#{text}\e[0m"
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :local do
4
+ include Capistrano::Ops::Local::Helpers
5
+
6
+ desc 'Rails console with a local script pre-loaded: cap <stage> local:console SCRIPT=<filename>'
7
+ task :console do
8
+ script = ENV.fetch('SCRIPT', nil)
9
+ abort_script_not_given('console') unless script
10
+
11
+ script_path = resolve_script(script)
12
+ abort_script_not_found(script) unless script_path
13
+
14
+ stage = fetch(:stage).to_s
15
+ puts "\n#{color('Script:', :bold)} #{File.basename(script_path)}"
16
+ puts "#{color('Target:', :bold)} #{stage} (interactive console)"
17
+
18
+ confirm_dangerous_stage!('Opening console')
19
+
20
+ remote_tmp = remote_tmp_path(script_path)
21
+ remote_irbrc = "/tmp/cap_local_irbrc_#{SecureRandom.hex(6)}.rb"
22
+
23
+ on primary(fetch(:console_role, :app)) do
24
+ upload! script_path, remote_tmp
25
+ # Wrapper loads the script into the console's main context and deletes
26
+ # both temp files server-side when IRB exits. The at_exit puts often
27
+ # gets swallowed by Sentry/etc during shutdown; the local puts in the
28
+ # ensure block below is what the user actually sees.
29
+ wrapper = <<~RUBY
30
+ load '#{remote_tmp}'
31
+ at_exit do
32
+ begin; File.delete('#{remote_tmp}'); rescue StandardError; end
33
+ begin; File.delete('#{remote_irbrc}'); rescue StandardError; end
34
+ end
35
+ RUBY
36
+ upload! StringIO.new(wrapper), remote_irbrc
37
+ end
38
+
39
+ puts "#{color("▶ Opening console with #{File.basename(script_path)} on #{stage}…", :cyan)}\n\n"
40
+
41
+ begin
42
+ run_interactively primary(fetch(:console_role, :app)), shell: fetch(:console_shell, nil) do
43
+ within current_path do
44
+ as user: fetch(:console_user, nil) do
45
+ args = ['-e', fetch(:console_env, fetch(:rails_env, fetch(:stage)))]
46
+ args.push('--', '-r', remote_irbrc)
47
+ execute(:rails, :console, *args)
48
+ end
49
+ end
50
+ end
51
+ ensure
52
+ # Local puts — no `on {}` block, since Capistrano's Configuration is not
53
+ # safely Marshal-dumpable after run_interactively (fails with
54
+ # "no _dump_data is defined for class Thread::Mutex"). Server-side
55
+ # cleanup is handled by the wrapper's at_exit hook.
56
+ puts color("\n\u{1F9F9} Console closed on #{stage} — server temp files cleaned up on exit.", :dim)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :local do
4
+ include Capistrano::Ops::Local::Helpers
5
+
6
+ desc 'Run a local script on remote via rails runner: cap <stage> local:run SCRIPT=<filename>'
7
+ task :run do
8
+ script = ENV.fetch('SCRIPT', nil)
9
+ abort_script_not_given('run') unless script
10
+
11
+ script_path = resolve_script(script)
12
+ abort_script_not_found(script) unless script_path
13
+
14
+ stage = fetch(:stage).to_s
15
+ puts "\n#{color('Script:', :bold)} #{File.basename(script_path)}"
16
+ puts "#{color('Target:', :bold)} #{stage}"
17
+
18
+ confirm_dangerous_stage!('Running script')
19
+
20
+ on roles(fetch(:console_role, :app)) do
21
+ remote_tmp = remote_tmp_path(script_path)
22
+
23
+ begin
24
+ upload! script_path, remote_tmp
25
+ puts "#{color("▶ Running #{File.basename(script_path)} on #{stage}…", :cyan)}\n\n"
26
+
27
+ within current_path do
28
+ with rails_env: fetch(:console_env, fetch(:rails_env, fetch(:stage))) do
29
+ execute :bundle, :exec, :rails, 'runner', remote_tmp
30
+ end
31
+ end
32
+ ensure
33
+ execute :rm, '-f', remote_tmp
34
+ puts color("\u{1F9F9} Cleaned up temp file on server", :dim)
35
+ end
36
+ end
37
+
38
+ puts "\n#{color('✔ Done.', :green)}"
39
+ end
40
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'capistrano/ops/task_loader'
4
+ require 'capistrano/ops/local/helpers'
5
+
6
+ TaskLoader.load_tasks_if_gem_present(
7
+ 'capistrano-rails', 'local/tasks',
8
+ 'WARNING: Gemfile does not include capistrano-rails gem which is required for local:console (provides capistrano/rails/console)'
9
+ )
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capistrano
4
4
  module Ops
5
- VERSION = '1.1.0'
5
+ VERSION = '1.2.0'
6
6
  end
7
7
  end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+ require 'capistrano/ops/local/helpers'
6
+
7
+ RSpec.describe Capistrano::Ops::Local::Helpers do
8
+ let(:host) do
9
+ Class.new do
10
+ include Capistrano::Ops::Local::Helpers
11
+
12
+ def initialize(scripts_dir_override)
13
+ @scripts_dir_override = scripts_dir_override
14
+ end
15
+
16
+ def fetch(key, default = nil)
17
+ return @scripts_dir_override if key == :local_scripts_dir
18
+
19
+ default
20
+ end
21
+ end
22
+ end
23
+
24
+ around do |example|
25
+ Dir.mktmpdir do |dir|
26
+ @tmp = dir
27
+ Dir.chdir(dir) { example.run }
28
+ end
29
+ end
30
+
31
+ def write_script(name)
32
+ dir = File.join(@tmp, 'ops', 'scripts')
33
+ FileUtils.mkdir_p(dir)
34
+ File.write(File.join(dir, name), '# example')
35
+ dir
36
+ end
37
+
38
+ describe '#available_scripts' do
39
+ it 'returns sorted basenames of .rb files' do
40
+ write_script('b.rb')
41
+ write_script('a.rb')
42
+ write_script('note.txt')
43
+
44
+ instance = host.new('ops/scripts')
45
+
46
+ expect(instance.available_scripts).to eq(%w[a.rb b.rb])
47
+ end
48
+
49
+ it 'is empty when the directory does not exist' do
50
+ instance = host.new('does/not/exist')
51
+
52
+ expect(instance.available_scripts).to eq([])
53
+ end
54
+ end
55
+
56
+ describe '#resolve_script' do
57
+ it 'returns the full path when the exact filename exists' do
58
+ write_script('foo.rb')
59
+
60
+ instance = host.new('ops/scripts')
61
+
62
+ expect(instance.resolve_script('foo.rb')).to end_with('/ops/scripts/foo.rb')
63
+ end
64
+
65
+ it 'auto-appends .rb when omitted' do
66
+ write_script('foo.rb')
67
+
68
+ instance = host.new('ops/scripts')
69
+
70
+ expect(instance.resolve_script('foo')).to end_with('/ops/scripts/foo.rb')
71
+ end
72
+
73
+ it 'returns nil for unknown names' do
74
+ write_script('foo.rb')
75
+
76
+ instance = host.new('ops/scripts')
77
+
78
+ expect(instance.resolve_script('bar')).to be_nil
79
+ end
80
+
81
+ it 'returns nil for empty input' do
82
+ instance = host.new('ops/scripts')
83
+
84
+ expect(instance.resolve_script(nil)).to be_nil
85
+ expect(instance.resolve_script('')).to be_nil
86
+ end
87
+ end
88
+
89
+ describe '#remote_tmp_path' do
90
+ it 'produces a unique /tmp path preserving the basename' do
91
+ instance = host.new('ops/scripts')
92
+
93
+ path = instance.remote_tmp_path('/some/where/foo.rb')
94
+
95
+ expect(path).to match(%r{\A/tmp/cap_local_[0-9a-f]{12}_foo\.rb\z})
96
+ end
97
+ end
98
+
99
+ describe '#color' do
100
+ it 'returns plain text when stdout is not a TTY' do
101
+ instance = host.new('ops/scripts')
102
+ allow($stdout).to receive(:tty?).and_return(false)
103
+
104
+ expect(instance.color('hello', :red)).to eq('hello')
105
+ end
106
+
107
+ it 'wraps text in ANSI escape when stdout is a TTY and style is known' do
108
+ instance = host.new('ops/scripts')
109
+ allow($stdout).to receive(:tty?).and_return(true)
110
+
111
+ expect(instance.color('hello', :red)).to eq("\e[31mhello\e[0m")
112
+ end
113
+
114
+ it 'passes text through unchanged for unknown styles' do
115
+ instance = host.new('ops/scripts')
116
+ allow($stdout).to receive(:tty?).and_return(true)
117
+
118
+ expect(instance.color('hello', :magenta)).to eq('hello')
119
+ end
120
+ end
121
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: capistrano-ops
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Florian Crusius
@@ -234,6 +234,10 @@ files:
234
234
  - lib/capistrano/ops/helper.rb
235
235
  - lib/capistrano/ops/invoke.rb
236
236
  - lib/capistrano/ops/invoke/tasks/invoke.rake
237
+ - lib/capistrano/ops/local.rb
238
+ - lib/capistrano/ops/local/helpers.rb
239
+ - lib/capistrano/ops/local/tasks/console.rake
240
+ - lib/capistrano/ops/local/tasks/run.rake
237
241
  - lib/capistrano/ops/logrotate.rb
238
242
  - lib/capistrano/ops/logrotate/helpers.rb
239
243
  - lib/capistrano/ops/logrotate/paths.rb
@@ -272,6 +276,7 @@ files:
272
276
  - lib/capistrano/ops/wkhtmltopdf/helpers.rb
273
277
  - lib/capistrano/ops/wkhtmltopdf/tasks/setup.rake
274
278
  - scripts/bump_version.rb
279
+ - spec/capistrano/ops/local/helpers_spec.rb
275
280
  - spec/capistrano/ops/version_spec.rb
276
281
  - spec/spec_helper.rb
277
282
  homepage: https://github.com/zauberware/capistrano-ops