Skip to content
This repository
Browse code

Add brew services command: a wrapper for launchctl

If a formula implements startup_plist it has out-of-the-box support by
brew services. If not it's possible to specify the path to a plist file:
`brew services couchdb-lucence /usr/local/Cellar/..../couchdb-lucene.plist`.

Fixes #3422.

Signed-off-by: Mike McQuaid <mike@mikemcquaid.com>
  • Loading branch information...
commit 6f7e663cc62c5ed423ef822703aa1857ef723b92 1 parent 58189c8
Lukas Westermann authored mikemcquaid committed
369  Library/Contributions/cmds/brew-services.rb
... ...
@@ -0,0 +1,369 @@
  1
+#!/usr/bin/env ruby -w
  2
+
  3
+# brew-services(1) - Easily start and stop formulas via launchctl
  4
+# ===============================================================
  5
+#
  6
+# ## SYNOPSIS
  7
+#
  8
+# [<sudo>] `brew services` `list`<br>
  9
+# [<sudo>] `brew services` `restart` <formula><br>
  10
+# [<sudo>] `brew services` `start` <formula> [<plist>]<br>
  11
+# [<sudo>] `brew services` `stop` <formula><br>
  12
+# [<sudo>] `brew services` `cleanup`<br>
  13
+#
  14
+# ## DESCRIPTION
  15
+#
  16
+# Integrates homebrew formulas with MacOS X' `launchctl` manager. Services
  17
+# can either be added to `/Library/LaunchDaemons` or `~/Library/LaunchAgents`.
  18
+# Basically items added to `/Library/LaunchDaemons` are started at boot,
  19
+# those in `~/Library/LaunchAgents` at login.
  20
+#
  21
+# When started with `sudo` it operates on `/Library/LaunchDaemons`, else
  22
+# in the user space.
  23
+#
  24
+# Basically on `start` the plist file is generated and written to a `Tempfile`,
  25
+# then copied to the launch path (existing plists are overwritten).
  26
+#
  27
+# ## OPTIONS
  28
+#
  29
+# To access everything quickly, some aliases have been added:
  30
+#
  31
+#  * `rm`:
  32
+#    Shortcut for `cleanup`, because that's basically whats being done.
  33
+#
  34
+#  * `ls`:
  35
+#    Because `list` is too much to type :)
  36
+#
  37
+#  * `reload', 'r':
  38
+#    Alias for `restart`, which gracefully restarts selected service.
  39
+#
  40
+#  * `load`, `s`:
  41
+#    Alias for `start`, guess what it does...
  42
+#
  43
+#  * `unload`, `term`, `t`:
  44
+#    Alias for `stop`, stops and unloads selected service.
  45
+#
  46
+# ## SYNTAX
  47
+#
  48
+# Several existing formulas (like mysql, nginx) already write custom plist
  49
+# files to the formulas prefix. Most of these implement `#startup_plist`
  50
+# which then in turn returns a neat-o plist file as string.
  51
+#
  52
+# `brew services` operates on `#startup_plist` as well and requires
  53
+# supporting formulas to implement it. This method should either string
  54
+# containing the generated XML file, or return a `Pathname` instance which
  55
+# points to a plist template, or a hash like:
  56
+#
  57
+#    { :url => "https://gist.github.com/raw/534777/63c4698872aaef11fe6e6c0c5514f35fd1b1687b/nginx.plist.xml" }
  58
+#
  59
+# Some simple template parsing is performed, all variables like `{{name}}` are
  60
+# replaced by basically doing:
  61
+# `formula.send('name').to_s if formula.respond_to?('name')`, a bit like
  62
+# mustache. So any variable in the `Formula` is available as template
  63
+# variable, like `{{var}}`, `{{bin}}` usw.
  64
+#
  65
+# ## EXAMPLES
  66
+#
  67
+# Install and start service mysql at boot:
  68
+#
  69
+#     $ brew install mysql
  70
+#     $ sudo brew services start mysql
  71
+#
  72
+# Stop service mysql (when launched at boot):
  73
+#
  74
+#     $ sudo brew services stop mysql
  75
+#
  76
+# Start memcached at login:
  77
+#
  78
+#     $ brew install memcached
  79
+#     $ brew services start memcached
  80
+#
  81
+# List all running services for current user, and root:
  82
+#
  83
+#     $ brew services list
  84
+#     $ sudo brew services list
  85
+#
  86
+# ## BUGS
  87
+#
  88
+# `brew-services.rb` might not handle all edge cases, though it tries
  89
+# to fix problems by running `brew services cleanup`.
  90
+#
  91
+module ServicesCli
  92
+  class << self
  93
+    # Binary name.
  94
+    def bin; "brew services" end
  95
+
  96
+    # Path to launchctl binary.
  97
+    def launchctl; "/bin/launchctl" end
  98
+
  99
+    # Wohoo, we are root dude!
  100
+    def root?; Process.uid == 0 end
  101
+
  102
+    # Current user, i.e. owner of `HOMEBREW_CELLAR`.
  103
+    def user; @user ||= %x{/usr/bin/stat -f '%Su' #{HOMEBREW_CELLAR} 2>/dev/null}.chomp || %x{/usr/bin/whoami}.chomp end
  104
+
  105
+    # Run at boot.
  106
+    def boot_path; Pathname.new("/Library/LaunchDaemons") end
  107
+
  108
+    # Run at login.
  109
+    def user_path; Pathname.new(ENV['HOME'] + '/Library/LaunchAgents') end
  110
+
  111
+    # If root returns `boot_path` else `user_path`.
  112
+    def path; root? ? boot_path : user_path end
  113
+
  114
+    # Find all currently running services via launchctl list
  115
+    def running; %x{#{launchctl} list | grep com.github.homebrew}.chomp.split("\n").map { |svc| $1 if svc =~ /(com\.github\.homebrew\..+)\z/ }.compact end
  116
+
  117
+    # Check if running as homebre and load required libraries et al.
  118
+    def homebrew!
  119
+      abort("Runtime error: homebrew is required, please start via `#{bin} ...`") unless defined?(HOMEBREW_LIBRARY_PATH)
  120
+      %w{fileutils pathname tempfile formula utils}.each { |req| require(req) }
  121
+      self.send(:extend, ::FileUtils)
  122
+      ::Formula.send(:include, Service::PlistSupport)
  123
+    end
  124
+
  125
+    # Access current service
  126
+    def service; @service ||= Service.new(Formula.factory(Formula.canonical_name(@formula))) if @formula end
  127
+
  128
+    # Print usage and `exit(...)` with supplied exit code, if code
  129
+    # is set to `false`, then exit is ignored.
  130
+    def usage(code = 0)
  131
+      puts "usage: [sudo] #{bin} [--help] <command> [<formula>]"
  132
+      puts
  133
+      puts "Small wrapper around `launchctl` for supported formulas, commands available:"
  134
+      puts "   cleanup Get rid of stale services and unused plists"
  135
+      puts "   list    List all services managed by `#{bin}`"
  136
+      puts "   restart Gracefully restart selected service"
  137
+      puts "   start   Start selected service"
  138
+      puts "   stop    Stop selected service"
  139
+      puts
  140
+      puts "Options, sudo and paths:"
  141
+      puts
  142
+      puts "  sudo   When run as root, operates on #{boot_path} (run at boot!)"
  143
+      puts "  Run at boot:  #{boot_path}"
  144
+      puts "  Run at login: #{user_path}"
  145
+      puts
  146
+      exit(code) unless code == false
  147
+      true
  148
+    end
  149
+
  150
+    # Run and start the command loop.
  151
+    def run!
  152
+      homebrew!
  153
+      usage if ARGV.empty? || ARGV.include?('help') || ARGV.include?('--help') || ARGV.include?('-h')
  154
+
  155
+      # parse arguments
  156
+      @args = ARGV.reject { |arg| arg[0] == 45 }.map { |arg| arg.include?("/") ? arg : arg.downcase } # 45.chr == '-'
  157
+      @cmd = @args.shift
  158
+      @formula = @args.shift
  159
+
  160
+      # dispatch commands and aliases
  161
+      case @cmd
  162
+        when 'cleanup', 'clean', 'cl', 'rm' then cleanup
  163
+        when 'list', 'ls' then list
  164
+        when 'restart', 'relaunch', 'reload', 'r' then check and restart
  165
+        when 'start', 'launch', 'load', 's', 'l' then check and start
  166
+        when 'stop', 'unload', 'terminate', 'term', 't', 'u' then check and stop
  167
+        else
  168
+          onoe "Unknown command `#{@cmd}`"
  169
+          usage(1)
  170
+      end
  171
+    end
  172
+
  173
+    # Check if formula has been found
  174
+    def check
  175
+      odie("Formula missing, please provide a formula name") unless service
  176
+      true
  177
+    end
  178
+
  179
+    # List all running services with PID and status and path to plist file, if available
  180
+    def list
  181
+      opoo("No %s services controlled by `#{bin}` running..." % [root? ? 'root' : 'user-space']) and return if running.empty?
  182
+      running.each do |label|
  183
+        if svc = Service.from(label)
  184
+          status = !svc.dest.file? ? "#{Tty.red}stale  " : "#{Tty.white}started"
  185
+          puts "%-10.10s %s#{Tty.reset}  %7s %s" % [svc.name, status, svc.pid ? svc.pid.to_s : '-', svc.dest.file? ? svc.dest : label]
  186
+        else
  187
+          puts "%-10.10s #{Tty.red}unknown#{Tty.reset} %7s #{label}" % ["?", "-"]
  188
+        end
  189
+      end
  190
+    end
  191
+
  192
+    # Kill services without plist file and remove unused plists
  193
+    def cleanup
  194
+      cleaned = []
  195
+
  196
+      # 1. kill services which have no plist file
  197
+      running.each do |label|
  198
+        if svc = Service.from(label)
  199
+          if !svc.dest.file?
  200
+            puts "%-15.15s #{Tty.white}stale#{Tty.reset} => killing service..." % svc.name
  201
+            kill(svc)
  202
+            cleaned << label
  203
+          end
  204
+        else
  205
+          opoo "Service #{label} not managed by `#{bin}` => skipping"
  206
+        end
  207
+      end
  208
+
  209
+      # 2. remove unused plist files
  210
+      Dir[path + 'com.github.homebrew.*.plist'].each do |file|
  211
+        unless running.include?(File.basename(file).sub(/\.plist$/i, ''))
  212
+          puts "Removing unused plist #{file}"
  213
+          rm file
  214
+          cleaned << file
  215
+        end
  216
+      end
  217
+
  218
+      puts "All #{root? ? 'root' : 'user-space'} services OK, nothing cleaned..." if cleaned.empty?
  219
+    end
  220
+
  221
+    # Stop if loaded, then start again
  222
+    def restart
  223
+      stop if service.loaded?
  224
+      start
  225
+    end
  226
+
  227
+    # Start a service
  228
+    def start
  229
+      odie "Service `#{service.name}` already started, use `#{bin} restart #{service.name}`" if service.loaded?
  230
+
  231
+      custom_plist = @args.first
  232
+      if custom_plist
  233
+        if custom_plist =~ %r{\Ahttps?://.+}
  234
+          custom_plist = { :url => custom_plist }
  235
+        elsif File.exist?(custom_plist)
  236
+          custom_plist = Pathname.new(custom_plist)
  237
+        else
  238
+          odie "#{custom_plist} is not a url or exising file"
  239
+        end
  240
+      end
  241
+
  242
+      odie "Formula `#{service.name}` not installed, #startup_plist not implemented or no plist file found" if !custom_plist && !service.plist?
  243
+
  244
+      temp = Tempfile.new(service.label)
  245
+      temp << service.generate_plist(custom_plist)
  246
+      temp.flush
  247
+
  248
+      rm service.dest if service.dest.exist?
  249
+      cp temp.path, service.dest
  250
+
  251
+      # clear tempfile
  252
+      temp.close
  253
+
  254
+      safe_system launchctl, "load", "-w", service.dest.to_s
  255
+      $?.to_i != 0 ? odie("Failed to start `#{service.name}`") : ohai("Successfully started `#{service.name}` (label: #{service.label})")
  256
+    end
  257
+
  258
+    # Stop a service or kill if no plist file available...
  259
+    def stop
  260
+      unless service.loaded?
  261
+        rm service.dest if service.dest.exist? # get rid of installed plist anyway, dude
  262
+        odie "Service `#{service.name}` not running, wanna start it? Try `#{bin} start #{service.name}`"
  263
+      end
  264
+
  265
+      if service.dest.exist?
  266
+        puts "Stopping `#{service.name}`... (might take a while)"
  267
+        safe_system launchctl, "unload", "-w", service.dest.to_s
  268
+        $?.to_i != 0 ? odie("Failed to stop `#{service.name}`") : ohai("Successfully stopped `#{service.name}` (label: #{service.label})")
  269
+      else
  270
+        puts "Stopping stale service `#{service.name}`... (might take a while)"
  271
+        kill(service)
  272
+      end
  273
+      rm service.dest if service.dest.exist?
  274
+    end
  275
+
  276
+    # Kill service without plist file by issuing a `launchctl remove` command
  277
+    def kill(svc)
  278
+      safe_system launchctl, "remove", svc.label
  279
+      odie("Failed to remove `#{svc.name}`, try again?") unless $?.to_i == 0
  280
+      while svc.loaded?
  281
+        puts "  ...checking status"
  282
+        sleep(5)
  283
+      end
  284
+      ohai "Successfully stopped `#{svc.name}` via #{svc.label}"
  285
+    end
  286
+  end
  287
+end
  288
+
  289
+# Wrapper for a formula to handle service related stuff like parsing
  290
+# and generating the plist file.
  291
+class Service
  292
+
  293
+  # Support module which will be used to extend Formula with a method :)
  294
+  module PlistSupport
  295
+    # As a replacement value for `<key>UserName</key>`.
  296
+    def startup_user; ServicesCli.user end
  297
+  end
  298
+
  299
+  # Access the `Formula` instance
  300
+  attr_reader :formula
  301
+
  302
+  # Create a new `Service` instance from either a path or label.
  303
+  def self.from(path_or_label)
  304
+    return nil unless path_or_label =~ /com\.github\.homebrew\.([^\.]+)(\.plist)?\z/
  305
+    new(Formula.factory(Formula.canonical_name($1))) rescue nil
  306
+  end
  307
+
  308
+  # Initialize new `Service` instance with supplied formula.
  309
+  def initialize(formula); @formula = formula end
  310
+
  311
+  # Delegate access to `formula.name`.
  312
+  def name; @name ||= formula.name end
  313
+
  314
+  # Label, static, always looks like `com.github.homebrew.<formula>`.
  315
+  def label; @label ||= "com.github.homebrew.#{name}" end
  316
+
  317
+  # Path to a static plist file, this is always `com.github.homebrew.<formula>.plist`.
  318
+  def plist; @plist ||= formula.prefix + "#{label}.plist" end
  319
+
  320
+  # Path to destination plist, if run as root it's in `boot_path`, else `user_path`.
  321
+  def dest; (ServicesCli.root? ? ServicesCli.boot_path : ServicesCli.user_path) + "#{label}.plist" end
  322
+
  323
+  # Returns `true` if formula implements #startup_plist or file exists.
  324
+  def plist?; formula.installed? && (plist.file? || formula.respond_to?(:startup_plist)) end
  325
+
  326
+  # Returns `true` if service is loaded, else false.
  327
+  def loaded?; %x{#{ServicesCli.launchctl} list | grep #{label} 2>/dev/null}.chomp =~ /#{label}\z/ end
  328
+
  329
+  # Get current PID of daemon process from launchctl.
  330
+  def pid
  331
+    status = %x{#{ServicesCli.launchctl} list | grep #{label} 2>/dev/null}.chomp
  332
+    return $1.to_i if status =~ /\A([\d]+)\s+.+#{label}\z/
  333
+  end
  334
+
  335
+  # Generate that plist file, dude.
  336
+  def generate_plist(data = nil)
  337
+    data ||= plist.file? ? plist : formula.startup_plist
  338
+
  339
+    if data.respond_to?(:file?) && data.file?
  340
+      data = data.read
  341
+    elsif data.respond_to?(:keys) && data.keys.include?(:url)
  342
+      require 'open-uri'
  343
+      data = open(data).read
  344
+    end
  345
+
  346
+    # replace "template" variables and ensure label is always, always com.github.homebrew.<formula>
  347
+    data = data.to_s.gsub(/\{\{([a-z][a-z0-9_]*)\}\}/i) { |m| formula.send($1).to_s if formula.respond_to?($1) }.
  348
+              gsub(%r{(<key>Label</key>\s*<string>)[^<]*(</string>)}, '\1' + label + '\2')
  349
+
  350
+    # and force fix UserName, if necessary
  351
+    if formula.startup_user != "root" && data =~ %r{<key>UserName</key>\s*<string>root</string>}
  352
+      data = data.gsub(%r{(<key>UserName</key>\s*<string>)[^<]*(</string>)}, '\1' + formula.startup_user + '\2')
  353
+    elsif ServicesCli.root? && formula.startup_user != "root" && data !~ %r{<key>UserName</key>}
  354
+      data = data.gsub(%r{(</dict>\s*</plist>)}, "  <key>UserName</key><string>#{formula.startup_user}</string>\n\\1")
  355
+    end
  356
+
  357
+    if ARGV.verbose?
  358
+      ohai "Generated plist for #{formula.name}:"
  359
+      puts "   " + data.gsub("\n", "\n   ")
  360
+      puts
  361
+    end
  362
+
  363
+    data
  364
+  end
  365
+end
  366
+
  367
+# Start the cli dispatch stuff.
  368
+#
  369
+ServicesCli.run!

0 notes on commit 6f7e663

Please sign in to comment.
Something went wrong with that request. Please try again.