Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

services: migrate external tap to main repo (WIP) #19385

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,15 +116,13 @@ jobs:
brew tap homebrew/bundle
brew tap homebrew/command-not-found
brew tap homebrew/portable-ruby
brew tap homebrew/services

# brew style doesn't like world writable directories
sudo chmod -R g-w,o-w "$(brew --repo)/Library/Taps"

- name: Run brew style on official taps
run: |
brew style homebrew/bundle \
homebrew/services \
homebrew/test-bot

brew style homebrew/command-not-found \
Expand Down
152 changes: 152 additions & 0 deletions Library/Homebrew/cmd/services.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# typed: strict
# frozen_string_literal: true

require "abstract_command"
require "services/service"

module Homebrew
module Cmd
class Services < AbstractCommand
cmd_args do
usage_banner <<~EOS
`services` [<subcommand>]

Manage background services with macOS' `launchctl`(1) daemon manager or
Linux's `systemctl`(1) service manager.

If `sudo` is passed, operate on `/Library/LaunchDaemons` or `/usr/lib/systemd/system` (started at boot).
Otherwise, operate on `~/Library/LaunchAgents` or `~/.config/systemd/user` (started at login).

[`sudo`] `brew services` [`list`] (`--json`) (`--debug`):
List information about all managed services for the current user (or root).
Provides more output from Homebrew and `launchctl`(1) or `systemctl`(1) if run with `--debug`.

[`sudo`] `brew services info` (<formula>|`--all`|`--json`):
List all managed services for the current user (or root).

[`sudo`] `brew services run` (<formula>|`--all`):
Run the service <formula> without registering to launch at login (or boot).

[`sudo`] `brew services start` (<formula>|`--all`|`--file=`):
Start the service <formula> immediately and register it to launch at login (or boot).

[`sudo`] `brew services stop` (<formula>|`--all`):
Stop the service <formula> immediately and unregister it from launching at login (or boot).

[`sudo`] `brew services kill` (<formula>|`--all`):
Stop the service <formula> immediately but keep it registered to launch at login (or boot).

[`sudo`] `brew services restart` (<formula>|`--all`):
Stop (if necessary) and start the service <formula> immediately and register it to launch at login (or boot).

[`sudo`] `brew services cleanup`:
Remove all unused services.
EOS
flag "--file=", description: "Use the service file from this location to `start` the service."
flag "--sudo-service-user=", description: "When run as root on macOS, run the service(s) as this user."
flag "--max-wait=", description: "Wait at most this many seconds for `stop` to finish stopping a service. " \
"Omit this flag or set this to zero (0) seconds to wait indefinitely."
switch "--all", description: "Run <subcommand> on all services."
switch "--json", description: "Output as JSON."
switch "--no-wait", description: "Don't wait for `stop` to finish stopping the service."
conflicts "--max-wait=", "--no-wait"
named_args max: 2
end

sig { override.void }
def run
# pbpaste's exit status is a proxy for detecting the use of reattach-to-user-namespace
if ENV["HOMEBREW_TMUX"] && (File.exist?("/usr/bin/pbpaste") && !quiet_system("/usr/bin/pbpaste"))
raise UsageError,
"`brew services` cannot run under tmux!"
end

# Keep this after the .parse to keep --help fast.
require "utils"

if !::Service::System.launchctl? && !::Service::System.systemctl?
raise UsageError,
"`brew services` is supported only on macOS or Linux (with systemd)!"
end

if (sudo_service_user = args.sudo_service_user)
unless ::Service::System.root?
raise UsageError,
"`brew services` is supported only when running as root!"
end

unless ::Service::System.launchctl?
raise UsageError,
"`brew services --sudo-service-user` is currently supported only on macOS " \
"(but we'd love a PR to add Linux support)!"
end

::Service::ServicesCli.sudo_service_user = sudo_service_user
end

# Parse arguments.
subcommand, formula, = args.named

if [*::Service::Commands::List::TRIGGERS, *::Service::Commands::Cleanup::TRIGGERS].include?(subcommand)
raise UsageError, "The `#{subcommand}` subcommand does not accept a formula argument!" if formula
raise UsageError, "The `#{subcommand}` subcommand does not accept the --all argument!" if args.all?
end

if args.file
if ::Service::Commands::Start::TRIGGERS.exclude?(subcommand)
raise UsageError, "The `#{subcommand}` subcommand does not accept the --file= argument!"
elsif args.all?
raise UsageError, "The start subcommand does not accept the --all and --file= arguments at the same time!"
end
end

opoo "The --all argument overrides provided formula argument!" if formula.present? && args.all?

targets = if args.all?
if subcommand == "start"
::Service::Formulae.available_services(loaded: false, skip_root: !::Service::System.root?)
elsif subcommand == "stop"
::Service::Formulae.available_services(loaded: true, skip_root: !::Service::System.root?)
else
::Service::Formulae.available_services
end
elsif formula
[::Service::FormulaWrapper.new(Formulary.factory(formula))]
else
[]
end

# Exit successfully if --all was used but there is nothing to do
return if args.all? && targets.empty?

if ::Service::System.systemctl?
ENV["DBUS_SESSION_BUS_ADDRESS"] = ENV.fetch("HOMEBREW_DBUS_SESSION_BUS_ADDRESS", nil)
ENV["XDG_RUNTIME_DIR"] = ENV.fetch("HOMEBREW_XDG_RUNTIME_DIR", nil)
end

# Dispatch commands and aliases.
case subcommand.presence
when *::Service::Commands::List::TRIGGERS
::Service::Commands::List.run(json: args.json?)
when *::Service::Commands::Cleanup::TRIGGERS
::Service::Commands::Cleanup.run
when *::Service::Commands::Info::TRIGGERS
::Service::Commands::Info.run(targets, verbose: args.verbose?, json: args.json?)
when *::Service::Commands::Restart::TRIGGERS
::Service::Commands::Restart.run(targets, verbose: args.verbose?)
when *::Service::Commands::Run::TRIGGERS
::Service::Commands::Run.run(targets, verbose: args.verbose?)
when *::Service::Commands::Start::TRIGGERS
::Service::Commands::Start.run(targets, args.file, verbose: args.verbose?)
when *::Service::Commands::Stop::TRIGGERS
max_wait = args.max_wait.to_f
::Service::Commands::Stop.run(targets, verbose: args.verbose?, no_wait: args.no_wait?, max_wait:)
when *::Service::Commands::Kill::TRIGGERS
::Service::Commands::Kill.run(targets, verbose: args.verbose?)
else
raise UsageError, "unknown subcommand: `#{subcommand}`"
end
end
end
end
end
2 changes: 1 addition & 1 deletion Library/Homebrew/official_taps.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
"homebrew/bundle" => ["bundle"],
"homebrew/command-not-found" => ["command-not-found-init", "which-formula", "which-update"],
"homebrew/test-bot" => ["test-bot"],
"homebrew/services" => ["services"],
}.freeze, T::Hash[String, T::Array[String]])

DEPRECATED_OFFICIAL_TAPS = %w[
Expand All @@ -33,6 +32,7 @@
php
python
science
services
tex
versions
x11
Expand Down
17 changes: 17 additions & 0 deletions Library/Homebrew/services/service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# typed: strict
# frozen_string_literal: true

# fix loadppath
$LOAD_PATH.unshift(File.expand_path(__dir__))

require "service/formula_wrapper"
require "service/services_cli"
require "service/system"
require "service/commands/cleanup"
require "service/commands/info"
require "service/commands/list"
require "service/commands/restart"
require "service/commands/run"
require "service/commands/start"
require "service/commands/stop"
require "service/commands/kill"
20 changes: 20 additions & 0 deletions Library/Homebrew/services/service/commands/cleanup.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# typed: strict
# frozen_string_literal: true

module Service
module Commands
module Cleanup
TRIGGERS = %w[cleanup clean cl rm].freeze

sig { void }
def self.run
cleaned = []

cleaned += Service::ServicesCli.kill_orphaned_services
cleaned += Service::ServicesCli.remove_unused_service_files

puts "All #{System.root? ? "root" : "user-space"} services OK, nothing cleaned..." if cleaned.empty?
end
end
end
end
62 changes: 62 additions & 0 deletions Library/Homebrew/services/service/commands/info.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# typed: strict
# frozen_string_literal: true

module Service
module Commands
module Info
TRIGGERS = %w[info i].freeze

sig {
params(targets: T::Array[Service::FormulaWrapper], verbose: T.nilable(T::Boolean),
json: T.nilable(T::Boolean)).void
}
def self.run(targets, verbose:, json:)
Service::ServicesCli.check(targets)

output = targets.map(&:to_hash)

if json
puts JSON.pretty_generate(output)
return
end

output.each do |hash|
puts output(hash, verbose:)
end
end

sig { params(bool: T.nilable(T.any(String, T::Boolean))).returns(String) }
def self.pretty_bool(bool)
return T.must(bool).to_s if !$stdout.tty? || Homebrew::EnvConfig.no_emoji?

if bool
"#{Tty.bold}#{Formatter.success("✔")}#{Tty.reset}"
else
"#{Tty.bold}#{Formatter.error("✘")}#{Tty.reset}"
end
end

sig { params(hash: T.untyped, verbose: T.nilable(T::Boolean)).returns(String) }
def self.output(hash, verbose:)
out = "#{Tty.bold}#{hash[:name]}#{Tty.reset} (#{hash[:service_name]})\n"
out += "Running: #{pretty_bool(hash[:running])}\n"
out += "Loaded: #{pretty_bool(hash[:loaded])}\n"
out += "Schedulable: #{pretty_bool(hash[:schedulable])}\n"
out += "User: #{hash[:user]}\n" unless hash[:pid].nil?
out += "PID: #{hash[:pid]}\n" unless hash[:pid].nil?
return out unless verbose

out += "File: #{hash[:file]} #{pretty_bool(hash[:file].present?)}\n"
out += "Command: #{hash[:command]}\n" unless hash[:command].nil?
out += "Working directory: #{hash[:working_dir]}\n" unless hash[:working_dir].nil?
out += "Root directory: #{hash[:root_dir]}\n" unless hash[:root_dir].nil?
out += "Log: #{hash[:log_path]}\n" unless hash[:log_path].nil?
out += "Error log: #{hash[:error_log_path]}\n" unless hash[:error_log_path].nil?
out += "Interval: #{hash[:interval]}s\n" unless hash[:interval].nil?
out += "Cron: #{hash[:cron]}\n" unless hash[:cron].nil?

out
end
end
end
end
16 changes: 16 additions & 0 deletions Library/Homebrew/services/service/commands/kill.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# typed: strict
# frozen_string_literal: true

module Service
module Commands
module Kill
TRIGGERS = %w[kill k].freeze

sig { params(targets: T::Array[Service::FormulaWrapper], verbose: T.nilable(T::Boolean)).void }
def self.run(targets, verbose:)
Service::ServicesCli.check(targets)
Service::ServicesCli.kill(targets, verbose:)
end
end
end
end
84 changes: 84 additions & 0 deletions Library/Homebrew/services/service/commands/list.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# typed: strict
# frozen_string_literal: true

require "service/formulae"

module Service
module Commands
module List
TRIGGERS = [nil, "list", "ls"].freeze

sig { params(json: T::Boolean).void }
def self.run(json: false)
formulae = Formulae.services_list
if formulae.blank?
opoo "No services available to control with `#{Service::ServicesCli.bin}`" if $stderr.tty?
return
end

if json
print_json(formulae)
else
print_table(formulae)
end
end

JSON_FIELDS = [:name, :status, :user, :file, :exit_code].freeze

# Print the JSON representation in the CLI
# @private
sig { params(formulae: T.untyped).returns(NilClass) }
def self.print_json(formulae)
services = formulae.map do |formula|
formula.slice(*JSON_FIELDS)
end

puts JSON.pretty_generate(services)
end

# Print the table in the CLI
# @private
sig { params(formulae: T::Array[T::Hash[T.untyped, T.untyped]]).void }
def self.print_table(formulae)
services = formulae.map do |formula|
status = T.must(get_status_string(formula[:status]))
status += formula[:exit_code].to_s if formula[:status] == :error
file = formula[:file].to_s.gsub(Dir.home, "~").presence if formula[:loaded]

{ name: formula[:name], status:, user: formula[:user], file: }
end

longest_name = [*services.map { |service| service[:name].length }, 4].max
longest_status = [*services.map { |service| service[:status].length }, 15].max
longest_user = [*services.map { |service| service[:user]&.length }, 4].compact.max

# `longest_status` includes 9 color characters from `Tty.color` and `Tty.reset`.
# We don't have these in the header row, so we don't need to add the extra padding.
headers = "#{Tty.bold}%-#{longest_name}.#{longest_name}<name>s " \
"%-#{longest_status - 9}.#{longest_status - 9}<status>s " \
"%-#{longest_user}.#{longest_user}<user>s %<file>s#{Tty.reset}"
row = "%-#{longest_name}.#{longest_name}<name>s " \
"%-#{longest_status}.#{longest_status}<status>s " \
"%-#{longest_user}.#{longest_user}<user>s %<file>s"

puts format(headers, name: "Name", status: "Status", user: "User", file: "File")
services.each do |service|
puts format(row, **service)
end
end

# Get formula status output
# @private
sig { params(status: T.anything).returns(T.nilable(String)) }
def self.get_status_string(status)
case status
when :started, :scheduled then "#{Tty.green}#{status}#{Tty.reset}"
when :stopped, :none then "#{Tty.default}#{status}#{Tty.reset}"
when :error then "#{Tty.red}error #{Tty.reset}"
when :unknown then "#{Tty.yellow}unknown#{Tty.reset}"
when :other then "#{Tty.yellow}other#{Tty.reset}"
end
end
end
end
end
Loading
Loading