From 3ef52e484428f6acbdc3316ac650de4b255c8e94 Mon Sep 17 00:00:00 2001 From: botantony Date: Wed, 26 Feb 2025 13:26:37 +0100 Subject: [PATCH] services: migrate command to main repo (WIP) --- .github/workflows/tests.yml | 2 - Library/Homebrew/cmd/services.rb | 152 +++++++ Library/Homebrew/official_taps.rb | 2 +- Library/Homebrew/services/service.rb | 17 + .../services/service/commands/cleanup.rb | 20 + .../services/service/commands/info.rb | 62 +++ .../services/service/commands/kill.rb | 16 + .../services/service/commands/list.rb | 84 ++++ .../services/service/commands/restart.rb | 36 ++ .../Homebrew/services/service/commands/run.rb | 16 + .../services/service/commands/start.rb | 19 + .../services/service/commands/stop.rb | 21 + .../services/service/formula_wrapper.rb | 328 +++++++++++++++ Library/Homebrew/services/service/formulae.rb | 29 ++ .../Homebrew/services/service/services_cli.rb | 368 +++++++++++++++++ Library/Homebrew/services/service/system.rb | 102 +++++ .../services/service/system/systemctl.rb | 49 +++ .../sorbet/rbi/dsl/homebrew/cmd/services.rbi | 29 ++ Library/Homebrew/test/cmd/services_spec.rb | 9 +- .../test/services/commands/cleanup_spec.rb | 43 ++ .../test/services/commands/info_spec.rb | 143 +++++++ .../test/services/commands/list_spec.rb | 169 ++++++++ .../test/services/commands/restart_spec.rb | 46 +++ .../Homebrew/test/services/formulae_spec.rb | 28 ++ .../test/services/formulae_wrapper_spec.rb | 388 ++++++++++++++++++ .../test/services/services_cli_spec.rb | 296 +++++++++++++ .../test/services/system/systemctl_spec.rb | 23 ++ Library/Homebrew/test/services/system_spec.rb | 143 +++++++ 28 files changed, 2632 insertions(+), 8 deletions(-) create mode 100644 Library/Homebrew/cmd/services.rb create mode 100644 Library/Homebrew/services/service.rb create mode 100644 Library/Homebrew/services/service/commands/cleanup.rb create mode 100644 Library/Homebrew/services/service/commands/info.rb create mode 100644 Library/Homebrew/services/service/commands/kill.rb create mode 100644 Library/Homebrew/services/service/commands/list.rb create mode 100644 Library/Homebrew/services/service/commands/restart.rb create mode 100644 Library/Homebrew/services/service/commands/run.rb create mode 100644 Library/Homebrew/services/service/commands/start.rb create mode 100644 Library/Homebrew/services/service/commands/stop.rb create mode 100644 Library/Homebrew/services/service/formula_wrapper.rb create mode 100644 Library/Homebrew/services/service/formulae.rb create mode 100644 Library/Homebrew/services/service/services_cli.rb create mode 100644 Library/Homebrew/services/service/system.rb create mode 100644 Library/Homebrew/services/service/system/systemctl.rb create mode 100644 Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/services.rbi create mode 100644 Library/Homebrew/test/services/commands/cleanup_spec.rb create mode 100644 Library/Homebrew/test/services/commands/info_spec.rb create mode 100644 Library/Homebrew/test/services/commands/list_spec.rb create mode 100644 Library/Homebrew/test/services/commands/restart_spec.rb create mode 100644 Library/Homebrew/test/services/formulae_spec.rb create mode 100644 Library/Homebrew/test/services/formulae_wrapper_spec.rb create mode 100644 Library/Homebrew/test/services/services_cli_spec.rb create mode 100644 Library/Homebrew/test/services/system/systemctl_spec.rb create mode 100644 Library/Homebrew/test/services/system_spec.rb diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5123481f58..fc858638fe 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -116,7 +116,6 @@ 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" @@ -124,7 +123,6 @@ jobs: - name: Run brew style on official taps run: | brew style homebrew/bundle \ - homebrew/services \ homebrew/test-bot brew style homebrew/command-not-found \ diff --git a/Library/Homebrew/cmd/services.rb b/Library/Homebrew/cmd/services.rb new file mode 100644 index 0000000000..7fb6eadc45 --- /dev/null +++ b/Library/Homebrew/cmd/services.rb @@ -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` [] + + 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` (|`--all`|`--json`): + List all managed services for the current user (or root). + + [`sudo`] `brew services run` (|`--all`): + Run the service without registering to launch at login (or boot). + + [`sudo`] `brew services start` (|`--all`|`--file=`): + Start the service immediately and register it to launch at login (or boot). + + [`sudo`] `brew services stop` (|`--all`): + Stop the service immediately and unregister it from launching at login (or boot). + + [`sudo`] `brew services kill` (|`--all`): + Stop the service immediately but keep it registered to launch at login (or boot). + + [`sudo`] `brew services restart` (|`--all`): + Stop (if necessary) and start the service 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 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 diff --git a/Library/Homebrew/official_taps.rb b/Library/Homebrew/official_taps.rb index db5e78c32f..41b55def45 100644 --- a/Library/Homebrew/official_taps.rb +++ b/Library/Homebrew/official_taps.rb @@ -9,7 +9,6 @@ OFFICIAL_CMD_TAPS = T.let({ "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[ @@ -33,6 +32,7 @@ DEPRECATED_OFFICIAL_TAPS = %w[ php python science + services tex versions x11 diff --git a/Library/Homebrew/services/service.rb b/Library/Homebrew/services/service.rb new file mode 100644 index 0000000000..c08565a27d --- /dev/null +++ b/Library/Homebrew/services/service.rb @@ -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" diff --git a/Library/Homebrew/services/service/commands/cleanup.rb b/Library/Homebrew/services/service/commands/cleanup.rb new file mode 100644 index 0000000000..e5712f0a27 --- /dev/null +++ b/Library/Homebrew/services/service/commands/cleanup.rb @@ -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 diff --git a/Library/Homebrew/services/service/commands/info.rb b/Library/Homebrew/services/service/commands/info.rb new file mode 100644 index 0000000000..708c0006b3 --- /dev/null +++ b/Library/Homebrew/services/service/commands/info.rb @@ -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 diff --git a/Library/Homebrew/services/service/commands/kill.rb b/Library/Homebrew/services/service/commands/kill.rb new file mode 100644 index 0000000000..2f8d1fa6b6 --- /dev/null +++ b/Library/Homebrew/services/service/commands/kill.rb @@ -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 diff --git a/Library/Homebrew/services/service/commands/list.rb b/Library/Homebrew/services/service/commands/list.rb new file mode 100644 index 0000000000..3a227a66bf --- /dev/null +++ b/Library/Homebrew/services/service/commands/list.rb @@ -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}s " \ + "%-#{longest_status - 9}.#{longest_status - 9}s " \ + "%-#{longest_user}.#{longest_user}s %s#{Tty.reset}" + row = "%-#{longest_name}.#{longest_name}s " \ + "%-#{longest_status}.#{longest_status}s " \ + "%-#{longest_user}.#{longest_user}s %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 diff --git a/Library/Homebrew/services/service/commands/restart.rb b/Library/Homebrew/services/service/commands/restart.rb new file mode 100644 index 0000000000..9483c9eb5f --- /dev/null +++ b/Library/Homebrew/services/service/commands/restart.rb @@ -0,0 +1,36 @@ +# typed: strict +# frozen_string_literal: true + +module Service + module Commands + module Restart + # NOTE: The restart command is used to update service files + # after a package gets updated through `brew upgrade`. + # This works by removing the old file with `brew services stop` + # and installing the new one with `brew services start|run`. + + TRIGGERS = %w[restart relaunch reload r].freeze + + sig { params(targets: T::Array[Service::FormulaWrapper], verbose: T.nilable(T::Boolean)).returns(NilClass) } + def self.run(targets, verbose:) + Service::ServicesCli.check(targets) + + ran = [] + started = [] + targets.each do |service| + if service.loaded? && !service.service_file_present? + ran << service + else + # group not-started services with started ones for restart + started << service + end + Service::ServicesCli.stop([service], verbose:) if service.loaded? + end + + Service::ServicesCli.run(targets, verbose:) if ran.present? + Service::ServicesCli.start(started, verbose:) if started.present? + nil + end + end + end +end diff --git a/Library/Homebrew/services/service/commands/run.rb b/Library/Homebrew/services/service/commands/run.rb new file mode 100644 index 0000000000..e4fcde1746 --- /dev/null +++ b/Library/Homebrew/services/service/commands/run.rb @@ -0,0 +1,16 @@ +# typed: strict +# frozen_string_literal: true + +module Service + module Commands + module Run + TRIGGERS = ["run"].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.run(targets, verbose:) + end + end + end +end diff --git a/Library/Homebrew/services/service/commands/start.rb b/Library/Homebrew/services/service/commands/start.rb new file mode 100644 index 0000000000..03c235a9b2 --- /dev/null +++ b/Library/Homebrew/services/service/commands/start.rb @@ -0,0 +1,19 @@ +# typed: strict +# frozen_string_literal: true + +module Service + module Commands + module Start + TRIGGERS = %w[start launch load s l].freeze + + sig { + params(targets: T::Array[Service::FormulaWrapper], custom_plist: T.nilable(String), + verbose: T.nilable(T::Boolean)).void + } + def self.run(targets, custom_plist, verbose:) + Service::ServicesCli.check(targets) + Service::ServicesCli.start(targets, custom_plist, verbose:) + end + end + end +end diff --git a/Library/Homebrew/services/service/commands/stop.rb b/Library/Homebrew/services/service/commands/stop.rb new file mode 100644 index 0000000000..df06471fcc --- /dev/null +++ b/Library/Homebrew/services/service/commands/stop.rb @@ -0,0 +1,21 @@ +# typed: strict +# frozen_string_literal: true + +module Service + module Commands + module Stop + TRIGGERS = %w[stop unload terminate term t u].freeze + + sig { + params(targets: T::Array[Service::FormulaWrapper], + verbose: T.nilable(T::Boolean), + no_wait: T.nilable(T::Boolean), + max_wait: T.nilable(Float)).void + } + def self.run(targets, verbose:, no_wait:, max_wait:) + Service::ServicesCli.check(targets) + Service::ServicesCli.stop(targets, verbose:, no_wait:, max_wait:) + end + end + end +end diff --git a/Library/Homebrew/services/service/formula_wrapper.rb b/Library/Homebrew/services/service/formula_wrapper.rb new file mode 100644 index 0000000000..415cf0a487 --- /dev/null +++ b/Library/Homebrew/services/service/formula_wrapper.rb @@ -0,0 +1,328 @@ +# typed: strict +# frozen_string_literal: true + +# Wrapper for a formula to handle service-related stuff like parsing and +# generating the service/plist files. +module Service + class FormulaWrapper + # Access the `Formula` instance. + sig { returns(Formula) } + attr_reader :formula + + # Create a new `Service` instance from either a path or label. + sig { params(path_or_label: T.any(Pathname, String)).returns(T.nilable(FormulaWrapper)) } + def self.from(path_or_label) + return unless path_or_label =~ path_or_label_regex + + begin + new(Formulary.factory(T.must(Regexp.last_match(1)))) + rescue + nil + end + end + + # Initialize a new `Service` instance with supplied formula. + sig { params(formula: Formula).void } + def initialize(formula) + @formula = T.let(formula, Formula) + @service = T.let(@formula.service? || false, T::Boolean) + @service_name = T.let(if System.launchctl? + formula.plist_name + elsif System.systemctl? + formula.service_name + end, T.nilable(String)) + @service_file = T.let(if System.launchctl? + formula.launchd_service_path + elsif System.systemctl? + formula.systemd_service_path + end, T.nilable(Pathname)) + @service_startup = T.let( + if service? + T.must(load_service).requires_root? + else + false + end, T.nilable(T::Boolean) + ) + @name = T.let(formula.name, String) + end + + # Delegate access to `formula.name`. + sig { returns(String) } + attr_reader :name + + # Delegate access to `formula.service?`. + sig { returns(T::Boolean) } + def service? + @service + end + + # Delegate access to `formula.service.timed?`. + sig { returns(T.nilable(T::Boolean)) } + def timed? + @timed ||= T.let(service? ? T.must(load_service).timed? : nil, T.nilable(T::Boolean)) + end + + # Delegate access to `formula.service.keep_alive?`.` + sig { returns(T.nilable(T::Boolean)) } + def keep_alive? + @keep_alive ||= T.let(T.must(load_service).keep_alive?, T.nilable(T::Boolean)) if service? + end + + # service_name delegates with formula.plist_name or formula.service_name for systemd (e.g., `homebrew.`). + sig { returns(T.nilable(String)) } + attr_reader :service_name + + # service_file delegates with formula.launchd_service_path or formula.systemd_service_path for systemd. + sig { returns(T.nilable(Pathname)) } + attr_reader :service_file + + # Whether the service should be launched at startup + sig { returns(T.nilable(T::Boolean)) } + def service_startup? + @service_startup ||= service? ? T.must(load_service).requires_root? : false + end + + # Path to destination service directory. If run as root, it's `boot_path`, else `user_path`. + sig { returns(Pathname) } + def dest_dir + System.root? ? T.must(System.boot_path) : T.must(System.user_path) + end + + # Path to destination service. If run as root, it's in `boot_path`, else `user_path`. + sig { returns(Pathname) } + def dest + dest_dir + T.must(service_file).basename + end + + # Returns `true` if any version of the formula is installed. + sig { returns(T::Boolean) } + def installed? + formula.any_version_installed? + end + + # Returns `true` if the plist file exists. + sig { returns(T::Boolean) } + def plist? + return false unless installed? + return true if T.must(service_file).file? + return false unless formula.opt_prefix.exist? + return true if Keg.for(formula.opt_prefix).plist_installed? + + false + rescue NotAKegError + false + end + + # Returns `true` if the service is loaded, else false. + sig { params(cached: T::Boolean).returns(T.nilable(T::Boolean)) } + def loaded?(cached: false) + if System.launchctl? + @status_output_success_type = nil unless cached + _, status_success, = status_output_success_type + status_success + elsif System.systemctl? + System::Systemctl.quiet_run("status", T.must(service_file).basename) + end + end + + # Returns `true` if service is present (e.g. .plist is present in boot or user service path), else `false` + # Accepts Hash option `:for` with values `:root` for boot path or `:user` for user path. + sig { params(opts: T.untyped).returns(T::Boolean) } + def service_file_present?(opts = { for: false }) + if opts[:for] && opts[:for] == :root + boot_path_service_file_present? + elsif opts[:for] && opts[:for] == :user + user_path_service_file_present? + else + boot_path_service_file_present? || user_path_service_file_present? + end + end + + sig { returns(T.nilable(String)) } + def owner + if System.launchctl? && dest.exist? + # read the username from the plist file + plist = begin + Plist.parse_xml(dest.read, marshal: false) + rescue + nil + end + plist_username = plist["UserName"] if plist + + return plist_username if plist_username.present? + end + return "root" if boot_path_service_file_present? + return System.user if user_path_service_file_present? + + nil + end + + sig { returns(T::Boolean) } + def pid? + return false if pid.nil? + + !T.must(pid).zero? + end + + sig { returns(T.nilable(T.any(T::Boolean, Integer))) } + def error? + return false if pid? || pid.nil? + return exit_code if exit_code.nil? + + T.must(exit_code).nonzero? + end + + sig { returns(T.nilable(T::Boolean)) } + def unknown_status? + status_output.blank? && !pid? + end + + # Get current PID of daemon process from status output. + sig { returns(T.nilable(Integer)) } + def pid + status_output, _, status_type = status_output_success_type + Regexp.last_match(1).to_i if status_output =~ pid_regex(status_type) + end + + # Get current exit code of daemon process from status output. + sig { returns(T.nilable(Integer)) } + def exit_code + status_output, _, status_type = status_output_success_type + Regexp.last_match(1).to_i if status_output =~ exit_code_regex(status_type) + end + + sig { returns(T::Hash[T.untyped, T.untyped]) } + def to_hash + hash = { + name:, + service_name:, + running: pid?, + loaded: loaded?(cached: true), + schedulable: timed?, + pid:, + exit_code:, + user: owner, + status: status_symbol, + file: service_file_present? ? dest : service_file, + } + + return hash unless service? + + service = load_service + + return hash if T.must(service).command.blank? + + hash[:command] = T.must(service).manual_command + hash[:working_dir] = T.must(service).working_dir + hash[:root_dir] = T.must(service).root_dir + hash[:log_path] = T.must(service).log_path + hash[:error_log_path] = T.must(service).error_log_path + hash[:interval] = T.must(service).interval + hash[:cron] = T.must(service).cron + + hash + end + + private + + # The purpose of this function is to lazy load the Homebrew::Service class + # and avoid nameclashes with the current Service module. + # It should be used instead of calling formula.service directly. + sig { returns(T.nilable(Homebrew::Service)) } + def load_service + require "formula" + + formula.service + end + + sig { returns(T.nilable(T::Array[T.untyped])) } + def status_output_success_type + @status_output_success_type ||= T.let(nil, T.nilable(T::Array[T.untyped])) + @status_output_success_type ||= if System.launchctl? + cmd = [System.launchctl.to_s, "list", service_name] + output = Utils.popen_read(*cmd).chomp + if $CHILD_STATUS.present? && $CHILD_STATUS.success? && output.present? + success = true + odebug cmd.join(" "), output + [output, success, :launchctl_list] + else + cmd = [System.launchctl.to_s, "print", + "#{System.domain_target}/#{service_name}"] + output = Utils.popen_read(*cmd).chomp + success = $CHILD_STATUS.present? && $CHILD_STATUS.success? && output.present? + odebug cmd.join(" "), output + [output, success, :launchctl_print] + end + elsif System.systemctl? + cmd = ["status", service_name] + output = System::Systemctl.popen_read(*cmd).chomp + success = $CHILD_STATUS.present? && $CHILD_STATUS.success? && output.present? + odebug [System::Systemctl.executable, System::Systemctl.scope, *cmd].join(" "), + output + [output, success, :systemctl] + end + end + + sig { returns(T.nilable(String)) } + def status_output + status_output, = status_output_success_type + status_output + end + + sig { returns(Symbol) } + def status_symbol + if pid? + :started + elsif !loaded?(cached: true) + :none + elsif T.must(exit_code).zero? + if timed? + :scheduled + else + :stopped + end + elsif error? + :error + elsif unknown_status? + :unknown + else + :other + end + end + + sig { params(status_type: Symbol).returns(Regexp) } + def exit_code_regex(status_type) + @exit_code_regex ||= T.let({ + launchctl_list: /"LastExitStatus"\ =\ ([0-9]*);/, + launchctl_print: /last exit code = ([0-9]+)/, + systemctl: /\(code=exited, status=([0-9]*)\)|\(dead\)/, + }, T.nilable(T::Hash[T.untyped, Regexp])) + @exit_code_regex.fetch(status_type) + end + + sig { params(status_type: Symbol).returns(Regexp) } + def pid_regex(status_type) + @pid_regex ||= T.let({ + launchctl_list: /"PID"\ =\ ([0-9]*);/, + launchctl_print: /pid = ([0-9]+)/, + systemctl: /Main PID: ([0-9]*) \((?!code=)/, + }, T.nilable(T::Hash[T.untyped, Regexp])) + @pid_regex.fetch(status_type) + end + + sig { returns(T::Boolean) } + def boot_path_service_file_present? + (T.must(System.boot_path) + T.must(service_file).basename).exist? + end + + sig { returns(T::Boolean) } + def user_path_service_file_present? + (T.must(System.user_path) + T.must(service_file).basename).exist? + end + + sig { returns(Regexp) } + private_class_method def self.path_or_label_regex + /homebrew(?>\.mxcl)?\.([\w+-.@]+)(\.plist|\.service)?\z/ + end + end +end diff --git a/Library/Homebrew/services/service/formulae.rb b/Library/Homebrew/services/service/formulae.rb new file mode 100644 index 0000000000..a052d8a3b3 --- /dev/null +++ b/Library/Homebrew/services/service/formulae.rb @@ -0,0 +1,29 @@ +# typed: strict +# frozen_string_literal: true + +module Service + module Formulae + # All available services, with optional filters applied + # @private + sig { params(loaded: T.nilable(T::Boolean), skip_root: T::Boolean).returns(T::Array[Service::FormulaWrapper]) } + def self.available_services(loaded: nil, skip_root: false) + require "formula" + + formulae = Formula.installed + .map { |formula| FormulaWrapper.new(formula) } + .select(&:service?) + .sort_by(&:name) + + formulae = formulae.select { |formula| formula.loaded? == loaded } unless loaded.nil? + formulae = formulae.reject { |formula| formula.owner == "root" } if skip_root + + formulae + end + + # List all available services with status, user, and path to the file. + sig { returns(T::Array[T::Hash[T.untyped, T.untyped]]) } + def self.services_list + available_services.map(&:to_hash) + end + end +end diff --git a/Library/Homebrew/services/service/services_cli.rb b/Library/Homebrew/services/service/services_cli.rb new file mode 100644 index 0000000000..4cb2e30242 --- /dev/null +++ b/Library/Homebrew/services/service/services_cli.rb @@ -0,0 +1,368 @@ +# typed: strict +# frozen_string_literal: true + +module Service + module ServicesCli + extend FileUtils + + sig { returns(T.nilable(String)) } + def self.sudo_service_user + @sudo_service_user + end + + sig { params(sudo_service_user: String).void } + def self.sudo_service_user=(sudo_service_user) + @sudo_service_user = T.let(sudo_service_user, T.nilable(String)) + end + + # Binary name. + sig { returns(String) } + def self.bin + "brew services" + end + + # Find all currently running services via launchctl list or systemctl list-units. + sig { returns(T::Array[String]) } + def self.running + if System.launchctl? + Utils.popen_read(System.launchctl, "list") + else + System::Systemctl.popen_read("list-units", + "--type=service", + "--state=running", + "--no-pager", + "--no-legend") + end.chomp.split("\n").filter_map do |svc| + Regexp.last_match(0) if svc =~ /homebrew(?>\.mxcl)?\.([\w+-.@]+)/ + end + end + + # Check if formula has been found. + sig { params(targets: T.untyped).returns(TrueClass) } + def self.check(targets) + raise UsageError, "Formula(e) missing, please provide a formula name or use --all" if targets.empty? + + true + end + + # Kill services that don't have a service file + sig { returns(T::Array[Service::FormulaWrapper]) } + def self.kill_orphaned_services + cleaned_labels = [] + cleaned_services = [] + running.each do |label| + if (service = FormulaWrapper.from(label)) + unless service.dest.file? + cleaned_labels << label + cleaned_services << service + end + else + opoo "Service #{label} not managed by `#{bin}` => skipping" + end + end + kill(cleaned_services) + cleaned_labels + end + + sig { returns(T::Array[T.untyped]) } + def self.remove_unused_service_files + cleaned = [] + Dir["#{System.path}homebrew.*.{plist,service}"].each do |file| + next if running.include?(File.basename(file).sub(/\.(plist|service)$/i, "")) + + puts "Removing unused service file #{file}" + rm file + cleaned << file + end + + cleaned + end + + # Run a service as defined in the formula. This does not clean the service file like `start` does. + sig { params(targets: T::Array[Service::FormulaWrapper], verbose: T.nilable(T::Boolean)).void } + def self.run(targets, verbose: false) + targets.each do |service| + if service.pid? + puts "Service `#{service.name}` already running, use `#{bin} restart #{service.name}` to restart." + next + elsif System.root? + puts "Service `#{service.name}` cannot be run (but can be started) as root." + next + end + + service_load(service, enable: false) + end + end + + # Start a service. + sig { + params(targets: T::Array[Service::FormulaWrapper], service_file: T.nilable(T.any(String, Pathname)), + verbose: T.nilable(T::Boolean)).void + } + def self.start(targets, service_file = nil, verbose: false) + file = T.let(nil, T.nilable(Pathname)) + + if service_file.present? + file = Pathname.new service_file + raise UsageError, "Provided service file does not exist" unless file.exist? + end + + targets.each do |service| + if service.pid? + puts "Service `#{service.name}` already started, use `#{bin} restart #{service.name}` to restart." + next + end + + odie "Formula `#{service.name}` is not installed." unless service.installed? + + file ||= if T.must(service.service_file).exist? || System.systemctl? + nil + elsif service.formula.opt_prefix.exist? && (keg = Keg.for service.formula.opt_prefix) && keg.plist_installed? + service_file = Dir["#{keg}/*#{T.must(service.service_file).extname}"].first + Pathname.new service_file if service_file.present? + end + + install_service_file(service, file) + + if file.blank? && verbose + ohai "Generated service file for #{service.formula.name}:" + puts " #{service.dest.read.gsub("\n", "\n ")}" + puts + end + + next if take_root_ownership(service).nil? && System.root? + + service_load(service, enable: true) + end + end + + # Stop a service and unload it. + sig { + params(targets: T::Array[Service::FormulaWrapper], + verbose: T.nilable(T::Boolean), + no_wait: T.nilable(T::Boolean), + max_wait: T.nilable(T.any(Integer, Float))).void + } + def self.stop(targets, verbose: false, no_wait: false, max_wait: 0) + targets.each do |service| + unless service.loaded? + rm service.dest if service.dest.exist? # get rid of installed service file anyway, dude + if service.service_file_present? + odie <<~EOS + Service `#{service.name}` is started as `#{service.owner}`. Try: + #{"sudo " unless System.root?}#{bin} stop #{service.name} + EOS + elsif System.launchctl? && + quiet_system(System.launchctl, "bootout", "#{System.domain_target}/#{service.service_name}") + ohai "Successfully stopped `#{service.name}` (label: #{service.service_name})" + else + opoo "Service `#{service.name}` is not started." + end + next + end + + systemctl_args = [] + if no_wait + systemctl_args << "--no-block" + puts "Stopping `#{service.name}`..." + else + puts "Stopping `#{service.name}`... (might take a while)" + end + + if System.systemctl? + System::Systemctl.quiet_run(*systemctl_args, "disable", "--now", service.service_name) + elsif System.launchctl? + quiet_system System.launchctl, "bootout", "#{System.domain_target}/#{service.service_name}" + unless no_wait + time_slept = 0 + sleep_time = 1 + max_wait = T.must(max_wait) + while ($CHILD_STATUS.to_i == 9216 || service.loaded?) && (max_wait.zero? || time_slept < max_wait) + sleep(sleep_time) + time_slept += sleep_time + quiet_system System.launchctl, "bootout", "#{System.domain_target}/#{service.service_name}" + end + end + quiet_system System.launchctl, "stop", "#{System.domain_target}/#{service.service_name}" if service.pid? + end + + rm service.dest if service.dest.exist? + # Run daemon-reload on systemctl to finish unloading stopped and deleted service. + System::Systemctl.run(*systemctl_args, "daemon-reload") if System.systemctl? + + if service.pid? || service.loaded? + opoo "Unable to stop `#{service.name}` (label: #{service.service_name})" + else + ohai "Successfully stopped `#{service.name}` (label: #{service.service_name})" + end + end + end + + # Stop a service but keep it registered. + sig { params(targets: T::Array[Service::FormulaWrapper], verbose: T.nilable(T::Boolean)).void } + def self.kill(targets, verbose: false) + targets.each do |service| + if !service.pid? + puts "Service `#{service.name}` is not started." + elsif service.keep_alive? + puts "Service `#{service.name}` is set to automatically restart and can't be killed." + else + puts "Killing `#{service.name}`... (might take a while)" + if System.systemctl? + System::Systemctl.quiet_run("stop", T.must(service.service_name)) + elsif System.launchctl? + quiet_system System.launchctl, "stop", "#{System.domain_target}/#{service.service_name}" + end + + if service.pid? + opoo "Unable to kill `#{service.name}` (label: #{service.service_name})" + else + ohai "Successfully killed `#{service.name}` (label: #{service.service_name})" + end + end + end + end + + # protections to avoid users editing root services + sig { params(service: T.untyped).returns(T.nilable(Integer)) } + def self.take_root_ownership(service) + return unless System.root? + return if sudo_service_user + + root_paths = T.let([], T::Array[Pathname]) + + if System.systemctl? + group = "root" + elsif System.launchctl? + group = "admin" + chown "root", group, service.dest + plist_data = service.dest.read + plist = begin + Plist.parse_xml(plist_data, marshal: false) + rescue + nil + end + return unless plist + + program_location = plist["ProgramArguments"]&.first + key = "first ProgramArguments value" + if program_location.blank? + program_location = plist["Program"] + key = "Program" + end + + if program_location.present? + Dir.chdir("/") do + if File.exist?(program_location) + program_location_path = Pathname(program_location).realpath + root_paths += [ + program_location_path, + program_location_path.parent.realpath, + ] + else + opoo <<~EOS + #{service.name}: the #{key} does not exist: + #{program_location} + EOS + end + end + end + end + + if (formula = service.formula) + root_paths += [ + formula.opt_prefix, + formula.linked_keg, + formula.bin, + formula.sbin, + ] + end + root_paths = root_paths.sort.uniq.select(&:exist?) + + opoo <<~EOS + Taking root:#{group} ownership of some #{service.formula} paths: + #{root_paths.join("\n ")} + This will require manual removal of these paths using `sudo rm` on + brew upgrade/reinstall/uninstall. + EOS + chown "root", group, root_paths + chmod "+t", root_paths + end + + sig { + params(service: Service::FormulaWrapper, file: T.nilable(T.any(String, Pathname)), + enable: T.nilable(T::Boolean)).void + } + def self.launchctl_load(service, file:, enable:) + safe_system System.launchctl, "enable", "#{System.domain_target}/#{service.service_name}" if enable + safe_system System.launchctl, "bootstrap", System.domain_target, file + end + + sig { params(service: Service::FormulaWrapper, enable: T.nilable(T::Boolean)).void } + def self.systemd_load(service, enable:) + System::Systemctl.run("start", T.must(service.service_name)) + System::Systemctl.run("enable", T.must(service.service_name)) if enable + end + + sig { params(service: Service::FormulaWrapper, enable: T.nilable(T::Boolean)).void } + def self.service_load(service, enable:) + if System.root? && !service.service_startup? + opoo "#{service.name} must be run as non-root to start at user login!" + elsif !System.root? && service.service_startup? + opoo "#{service.name} must be run as root to start at system startup!" + end + + if System.launchctl? + file = enable ? service.dest : service.service_file + launchctl_load(service, file:, enable:) + elsif System.systemctl? + # Systemctl loads based upon location so only install service + # file when it is not installed. Used with the `run` command. + install_service_file(service, nil) unless service.dest.exist? + systemd_load(service, enable:) + end + + function = enable ? "started" : "ran" + ohai("Successfully #{function} `#{service.name}` (label: #{service.service_name})") + end + + sig { params(service: Service::FormulaWrapper, file: T.nilable(Pathname)).void } + def self.install_service_file(service, file) + raise UsageError, "Formula `#{service.name}` is not installed" unless service.installed? + + unless T.must(service.service_file).exist? + raise UsageError, + "Formula `#{service.name}` has not implemented #plist, #service or installed a locatable service file" + end + + temp = Tempfile.new(T.must(service.service_name)) + temp << if T.must(file).blank? + contents = T.must(service.service_file).read + + if sudo_service_user && Service::System.launchctl? + # set the username in the new plist file + ohai "Setting username in #{service.service_name} to #{Service::System.user}" + plist_data = Plist.parse_xml(contents, marshal: false) + plist_data["UserName"] = sudo_service_user + plist_data.to_plist + else + contents + end + else + T.must(file).read + end + temp.flush + + rm service.dest if service.dest.exist? + service.dest_dir.mkpath unless service.dest_dir.directory? + cp T.must(temp.path), service.dest + + # Clear tempfile. + temp.close + + chmod 0644, service.dest + + Service::System::Systemctl.run("daemon-reload") if System.systemctl? + end + end +end diff --git a/Library/Homebrew/services/service/system.rb b/Library/Homebrew/services/service/system.rb new file mode 100644 index 0000000000..661e3f308e --- /dev/null +++ b/Library/Homebrew/services/service/system.rb @@ -0,0 +1,102 @@ +# typed: strict +# frozen_string_literal: true + +require_relative "system/systemctl" + +module Service + module System + extend FileUtils + + # Path to launchctl binary. + sig { returns(T.nilable(Pathname)) } + def self.launchctl + @launchctl ||= T.let(which("launchctl"), T.nilable(Pathname)) + end + + # Is this a launchctl system + sig { returns(T::Boolean) } + def self.launchctl? + launchctl.present? + end + + # Is this a systemd system + sig { returns(T::Boolean) } + def self.systemctl? + Systemctl.executable.present? + end + + # Woohoo, we are root dude! + sig { returns(T::Boolean) } + def self.root? + Process.euid.zero? + end + + # Current user running `[sudo] brew services`. + sig { returns(T.nilable(String)) } + def self.user + @user ||= T.let(ENV["USER"].presence || Utils.safe_popen_read("/usr/bin/whoami").chomp, T.nilable(String)) + end + + sig { params(pid: T.nilable(Integer)).returns(T.nilable(String)) } + def self.user_of_process(pid) + if pid.nil? || pid.zero? + user + else + Utils.safe_popen_read("ps", "-o", "user", "-p", pid.to_s).lines.second&.chomp + end + end + + # Run at boot. + sig { returns(T.nilable(Pathname)) } + def self.boot_path + if launchctl? + Pathname.new("/Library/LaunchDaemons") + elsif systemctl? + Pathname.new("/usr/lib/systemd/system") + end + end + + # Run at login. + sig { returns(T.nilable(Pathname)) } + def self.user_path + if launchctl? + Pathname.new("#{Dir.home}/Library/LaunchAgents") + elsif systemctl? + Pathname.new("#{Dir.home}/.config/systemd/user") + end + end + + # If root, return `boot_path`, else return `user_path`. + sig { returns(T.nilable(Pathname)) } + def self.path + root? ? boot_path : user_path + end + + sig { returns(String) } + def self.domain_target + if root? + "system" + elsif (ssh_tty = ENV.fetch("HOMEBREW_SSH_TTY", nil).present? && File.stat("/dev/console").uid != Process.uid) || + (sudo_user = ENV.fetch("HOMEBREW_SUDO_USER", nil).present?) || + (Process.uid != Process.euid) + if @output_warning.blank? && ENV.fetch("HOMEBREW_SERVICES_NO_DOMAIN_WARNING", nil).blank? + if ssh_tty + opoo "running over SSH without /dev/console ownership, using user/* instead of gui/* domain!" + elsif sudo_user + opoo "running through sudo, using user/* instead of gui/* domain!" + else + opoo "uid and euid do not match, using user/* instead of gui/* domain!" + end + unless Homebrew::EnvConfig.no_env_hints? + puts "Hide this warning by setting HOMEBREW_SERVICES_NO_DOMAIN_WARNING." + puts "Hide these hints with HOMEBREW_NO_ENV_HINTS (see `man brew`)." + end + @output_warning = T.let(true, T.nilable(TrueClass)) + end + "user/#{Process.euid}" + else + "gui/#{Process.uid}" + end + end + end +end diff --git a/Library/Homebrew/services/service/system/systemctl.rb b/Library/Homebrew/services/service/system/systemctl.rb new file mode 100644 index 0000000000..2f47ba3634 --- /dev/null +++ b/Library/Homebrew/services/service/system/systemctl.rb @@ -0,0 +1,49 @@ +# typed: strict +# frozen_string_literal: true + +module Service + module System + module Systemctl + sig { returns(T.nilable(Pathname)) } + def self.executable + @executable ||= T.let(which("systemctl"), T.nilable(Pathname)) + end + + sig { returns(String) } + def self.scope + System.root? ? "--system" : "--user" + end + + sig { params(args: T.nilable(T.any(String, Pathname))).void } + def self.run(*args) + _run(*args, mode: :default) + end + + sig { params(args: T.nilable(T.any(String, Pathname))).returns(T::Boolean) } + def self.quiet_run(*args) + _run(*args, mode: :quiet) + end + + sig { params(args: T.nilable(T.any(String, Pathname))).returns(String) } + def self.popen_read(*args) + _run(*args, mode: :read) + end + + sig { params(args: T.any(String, Pathname), mode: T.nilable(Symbol)).returns(T.untyped) } + private_class_method def self._run(*args, mode:) + require "system_command" + result = SystemCommand.run(executable, + args: [scope, *args.map(&:to_s)], + print_stdout: mode == :default, + print_stderr: mode == :default, + must_succeed: mode == :default, + reset_uid: true) + if mode == :read + result.stdout + elsif mode == :quiet + result.success? + end + end + end + end +end diff --git a/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/services.rbi b/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/services.rbi new file mode 100644 index 0000000000..c16f336581 --- /dev/null +++ b/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/services.rbi @@ -0,0 +1,29 @@ +# typed: strict + +class Homebrew::Cmd::Services + sig { returns(Homebrew::Cmd::Services::Args) } + def args; end +end + +class Homebrew::Cmd::Services::Args < Homebrew::CLI::Args + sig { returns(T::Boolean) } + def all?; end + + sig { returns(T.nilable(String)) } + def file; end + + sig { returns(T::Boolean) } + def json?; end + + sig { returns(T::Boolean) } + def non_bundler_gems?; end + + sig { returns(T::Boolean) } + def no_wait?; end + + sig { returns(T.nilable(String)) } + def sudo_service_user; end + + sig { returns(T.nilable(String)) } + def max_wait; end +end diff --git a/Library/Homebrew/test/cmd/services_spec.rb b/Library/Homebrew/test/cmd/services_spec.rb index 09f2564687..9b435daf09 100644 --- a/Library/Homebrew/test/cmd/services_spec.rb +++ b/Library/Homebrew/test/cmd/services_spec.rb @@ -1,13 +1,12 @@ # frozen_string_literal: true +require "cmd/services" require "cmd/shared_examples/args_parse" -RSpec.describe "Homebrew::Cmd::Services", :integration_test, :needs_network do - before { setup_remote_tap "homebrew/services" } +RSpec.describe Homebrew::Cmd::Services do + it_behaves_like "parseable arguments" - it_behaves_like "parseable arguments", command_name: "services" - - it "allows controlling services" do + it "allows controlling services", :integration_test do expect { brew "services", "list" } .to not_to_output.to_stderr .and not_to_output.to_stdout diff --git a/Library/Homebrew/test/services/commands/cleanup_spec.rb b/Library/Homebrew/test/services/commands/cleanup_spec.rb new file mode 100644 index 0000000000..aa0e4563e3 --- /dev/null +++ b/Library/Homebrew/test/services/commands/cleanup_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "services/service" + +RSpec.describe Service::Commands::Cleanup do + describe "#TRIGGERS" do + it "contains all restart triggers" do + expect(described_class::TRIGGERS).to eq(%w[cleanup clean cl rm]) + end + end + + describe "#run" do + it "root - prints on empty cleanup" do + expect(Service::System).to receive(:root?).once.and_return(true) + expect(Service::ServicesCli).to receive(:kill_orphaned_services).once.and_return([]) + expect(Service::ServicesCli).to receive(:remove_unused_service_files).once.and_return([]) + + expect do + described_class.run + end.to output("All root services OK, nothing cleaned...\n").to_stdout + end + + it "user - prints on empty cleanup" do + expect(Service::System).to receive(:root?).once.and_return(false) + expect(Service::ServicesCli).to receive(:kill_orphaned_services).once.and_return([]) + expect(Service::ServicesCli).to receive(:remove_unused_service_files).once.and_return([]) + + expect do + described_class.run + end.to output("All user-space services OK, nothing cleaned...\n").to_stdout + end + + it "prints nothing on cleanup" do + expect(Service::System).not_to receive(:root?) + expect(Service::ServicesCli).to receive(:kill_orphaned_services).once.and_return(["a"]) + expect(Service::ServicesCli).to receive(:remove_unused_service_files).once.and_return(["b"]) + + expect do + described_class.run + end.not_to output("All user-space services OK, nothing cleaned...\n").to_stdout + end + end +end diff --git a/Library/Homebrew/test/services/commands/info_spec.rb b/Library/Homebrew/test/services/commands/info_spec.rb new file mode 100644 index 0000000000..6251da7f84 --- /dev/null +++ b/Library/Homebrew/test/services/commands/info_spec.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require "services/service" + +# needs for tty color tests +module Tty + def self.green + "" + end + + def self.yellow + "" + end + + def self.red + "" + end + + def self.default + "" + end + + def self.bold + "" + end + + def self.reset + "" + end +end + +RSpec.describe Service::Commands::Info do + before do + allow_any_instance_of(IO).to receive(:tty?).and_return(true) + end + + describe "#TRIGGERS" do + it "contains all restart triggers" do + expect(described_class::TRIGGERS).to eq(%w[info i]) + end + end + + describe "#run" do + it "fails with empty list" do + expect do + described_class.run([], verbose: false, json: false) + end.to raise_error UsageError, + a_string_including("Formula(e) missing, please provide a formula name or use --all") + end + + it "succeeds with items" do + out = "service ()\nRunning: true\nLoaded: true\nSchedulable: false\n" + formula = { + name: "service", + user: "user", + status: :started, + file: "/dev/null", + running: true, + loaded: true, + schedulable: false, + } + expect do + described_class.run([formula], verbose: false, json: false) + end.to output(out).to_stdout + end + + it "succeeds with items - JSON" do + formula = { + name: "service", + user: "user", + status: :started, + file: "/dev/null", + running: true, + loaded: true, + schedulable: false, + } + out = "#{JSON.pretty_generate([formula])}\n" + expect do + described_class.run([formula], verbose: false, json: true) + end.to output(out).to_stdout + end + end + + describe "#output" do + it "returns minimal output" do + out = "service ()\nRunning: \n" + out += "Loaded: \nSchedulable: \n" + formula = { + name: "service", + user: "user", + status: :started, + file: "/dev/null", + running: true, + loaded: true, + schedulable: false, + } + expect(described_class.output(formula, verbose: false)).to eq(out) + end + + it "returns normal output" do + out = "service ()\nRunning: \n" + out += "Loaded: \nSchedulable: \n" + out += "User: user\nPID: 42\n" + formula = { + name: "service", + user: "user", + status: :started, + file: "/dev/null", + running: true, + loaded: true, + schedulable: false, + pid: 42, + } + expect(described_class.output(formula, verbose: false)).to eq(out) + end + + it "returns verbose output" do + out = "service ()\nRunning: \n" + out += "Loaded: \nSchedulable: \n" + out += "User: user\nPID: 42\nFile: /dev/null \nCommand: /bin/command\n" + out += "Working directory: /working/dir\nRoot directory: /root/dir\nLog: /log/dir\nError log: /log/dir/error\n" + out += "Interval: 3600s\nCron: 5 * * * *\n" + formula = { + name: "service", + user: "user", + status: :started, + file: "/dev/null", + running: true, + loaded: true, + schedulable: false, + pid: 42, + command: "/bin/command", + working_dir: "/working/dir", + root_dir: "/root/dir", + log_path: "/log/dir", + error_log_path: "/log/dir/error", + interval: 3600, + cron: "5 * * * *", + } + expect(described_class.output(formula, verbose: true)).to eq(out) + end + end +end diff --git a/Library/Homebrew/test/services/commands/list_spec.rb b/Library/Homebrew/test/services/commands/list_spec.rb new file mode 100644 index 0000000000..c936d31869 --- /dev/null +++ b/Library/Homebrew/test/services/commands/list_spec.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +require "services/service" + +# needs for tty color tests +module Tty + def self.green + "" + end + + def self.yellow + "" + end + + def self.red + "" + end + + def self.default + "" + end + + def self.bold + "" + end + + def self.reset + "" + end +end + +RSpec.describe Service::Commands::List do + describe "#TRIGGERS" do + it "contains all restart triggers" do + expect(described_class::TRIGGERS).to eq([nil, "list", "ls"]) + end + end + + describe "#run" do + it "fails with empty list" do + allow_any_instance_of(IO).to receive(:tty?).and_return(true) + expect(Service::Formulae).to receive(:services_list).and_return([]) + expect do + described_class.run + end.to output(a_string_including("No services available to control with `brew services`")).to_stderr + end + + it "succeeds with list" do + out = "Name Status User File\nservice started user /dev/null\n" + formula = instance_double( + Service::FormulaWrapper, + name: "service", + owner: "user", + status_symbol: :started, + service_file: +File::NULL, + loaded?: true, + ) + expect(Service::Formulae).to receive(:services_list).and_return([formula]) + expect do + described_class.run + end.to output(out).to_stdout + end + + it "succeeds with list - JSON" do + formula = { + name: "service", + user: "user", + status: :started, + file: "/dev/null", + running: true, + loaded: true, + schedulable: false, + } + + filtered_formula = formula.slice(*described_class::JSON_FIELDS) + expected_output = "#{JSON.pretty_generate([filtered_formula])}\n" + + expect(Service::Formulae).to receive(:services_list).and_return([formula]) + expect do + described_class.run(json: true) + end.to output(expected_output).to_stdout + end + end + + describe "#print_table" do + it "prints all standard values" do + formula = { name: "a", user: "u", file: Pathname.new("/tmp/file.file"), status: :stopped } + expect do + described_class.print_table([formula]) + end.to output("Name Status User File\na stopped u \n").to_stdout + end + + it "prints without user or file data" do + formula = { name: "a", user: nil, file: nil, status: :started, loaded: true } + expect do + described_class.print_table([formula]) + end.to output("Name Status User File\na started \n").to_stdout + end + + it "prints shortened home directory" do + ENV["HOME"] = "/tmp" + formula = { name: "a", user: "u", file: Pathname.new("/tmp/file.file"), status: :started, loaded: true } + expected_output = "Name Status User File\na started u ~/file.file\n" + expect do + described_class.print_table([formula]) + end.to output(expected_output).to_stdout + end + + it "prints an error code" do + file = Pathname.new("/tmp/file.file") + formula = { name: "a", user: "u", file:, status: :error, exit_code: 256, loaded: true } + expected_output = "Name Status User File\na error 256 u /tmp/file.file\n" + expect do + described_class.print_table([formula]) + end.to output(expected_output).to_stdout + end + end + + describe "#print_json" do + it "prints all standard values" do + formula = { name: "a", status: :stopped, user: "u", file: Pathname.new("/tmp/file.file") } + expected_output = "#{JSON.pretty_generate([formula])}\n" + expect do + described_class.print_json([formula]) + end.to output(expected_output).to_stdout + end + + it "prints without user or file data" do + formula = { name: "a", user: nil, file: nil, status: :started, loaded: true } + filtered_formula = formula.slice(*described_class::JSON_FIELDS) + expected_output = "#{JSON.pretty_generate([filtered_formula])}\n" + expect do + described_class.print_json([formula]) + end.to output(expected_output).to_stdout + end + + it "includes an exit code" do + file = Pathname.new("/tmp/file.file") + formula = { name: "a", user: "u", file:, status: :error, exit_code: 256, loaded: true } + filtered_formula = formula.slice(*described_class::JSON_FIELDS) + expected_output = "#{JSON.pretty_generate([filtered_formula])}\n" + expect do + described_class.print_json([formula]) + end.to output(expected_output).to_stdout + end + end + + describe "#get_status_string" do + it "returns started" do + expect(described_class.get_status_string(:started)).to eq("started") + end + + it "returns stopped" do + expect(described_class.get_status_string(:stopped)).to eq("stopped") + end + + it "returns error" do + expect(described_class.get_status_string(:error)).to eq("error ") + end + + it "returns unknown" do + expect(described_class.get_status_string(:unknown)).to eq("unknown") + end + + it "returns other" do + expect(described_class.get_status_string(:other)).to eq("other") + end + end +end diff --git a/Library/Homebrew/test/services/commands/restart_spec.rb b/Library/Homebrew/test/services/commands/restart_spec.rb new file mode 100644 index 0000000000..6c939c1de7 --- /dev/null +++ b/Library/Homebrew/test/services/commands/restart_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "services/service" + +RSpec.describe Service::Commands::Restart do + describe "#TRIGGERS" do + it "contains all restart triggers" do + expect(described_class::TRIGGERS).to eq(%w[restart relaunch reload r]) + end + end + + describe "#run" do + it "fails with empty list" do + expect do + described_class.run([], verbose: false) + end.to raise_error UsageError, + a_string_including("Formula(e) missing, please provide a formula name or use --all") + end + + it "starts if services are not loaded" do + expect(Service::ServicesCli).not_to receive(:run) + expect(Service::ServicesCli).not_to receive(:stop) + expect(Service::ServicesCli).to receive(:start).once + service = instance_double(Service::FormulaWrapper, service_name: "name", loaded?: false) + expect(described_class.run([service], verbose: false)).to be_nil + end + + it "starts if services are loaded with file" do + expect(Service::ServicesCli).not_to receive(:run) + expect(Service::ServicesCli).to receive(:start).once + expect(Service::ServicesCli).to receive(:stop).once + service = instance_double(Service::FormulaWrapper, service_name: "name", loaded?: true, +service_file_present?: true) + expect(described_class.run([service], verbose: false)).to be_nil + end + + it "runs if services are loaded without file" do + expect(Service::ServicesCli).not_to receive(:start) + expect(Service::ServicesCli).to receive(:run).once + expect(Service::ServicesCli).to receive(:stop).once + service = instance_double(Service::FormulaWrapper, service_name: "name", loaded?: true, +service_file_present?: false) + expect(described_class.run([service], verbose: false)).to be_nil + end + end +end diff --git a/Library/Homebrew/test/services/formulae_spec.rb b/Library/Homebrew/test/services/formulae_spec.rb new file mode 100644 index 0000000000..9ccc2753c3 --- /dev/null +++ b/Library/Homebrew/test/services/formulae_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "services/service" + +RSpec.describe Service::Formulae do + describe "#services_list" do + it "empty list without available formulae" do + allow(described_class).to receive(:available_services).and_return({}) + expect(described_class.services_list).to eq([]) + end + + it "list with available formulae" do + formula = instance_double(Service::FormulaWrapper) + expected = [ + { + file: Pathname.new("/Library/LaunchDaemons/file.plist"), + name: "formula", + status: :known, + user: "root", + }, + ] + + expect(formula).to receive(:to_hash).and_return(expected[0]) + allow(described_class).to receive(:available_services).and_return([formula]) + expect(described_class.services_list).to eq(expected) + end + end +end diff --git a/Library/Homebrew/test/services/formulae_wrapper_spec.rb b/Library/Homebrew/test/services/formulae_wrapper_spec.rb new file mode 100644 index 0000000000..fa6bc33d4b --- /dev/null +++ b/Library/Homebrew/test/services/formulae_wrapper_spec.rb @@ -0,0 +1,388 @@ +# frozen_string_literal: true + +require "services/service" +require "tempfile" + +RSpec.describe Service::FormulaWrapper do + subject(:service) { described_class.new(formula) } + + let(:formula) do + instance_double(Formula, + name: "mysql", + plist_name: "plist-mysql-test", + service_name: "plist-mysql-test", + launchd_service_path: Pathname.new("/usr/local/opt/mysql/homebrew.mysql.plist"), + systemd_service_path: Pathname.new("/usr/local/opt/mysql/homebrew.mysql.service"), + opt_prefix: Pathname.new("/usr/local/opt/mysql"), + any_version_installed?: true, + service?: false) + end + + let(:service_object) do + instance_double(Homebrew::Service, + requires_root?: false, + timed?: false, + keep_alive?: false, + command: "/bin/cmd", + manual_command: "/bin/cmd", + working_dir: nil, + root_dir: nil, + log_path: nil, + error_log_path: nil, + interval: nil, + cron: nil) + end + + before do + allow(formula).to receive(:service).and_return(service_object) + ENV["HOME"] = "/tmp_home" + end + + describe "#service_file" do + it "macOS - outputs the full service file path" do + allow(Service::System).to receive(:launchctl?).and_return(true) + expect(service.service_file.to_s).to eq("/usr/local/opt/mysql/homebrew.mysql.plist") + end + + it "systemD - outputs the full service file path" do + allow(Service::System).to receive_messages(launchctl?: false, systemctl?: true) + expect(service.service_file.to_s).to eq("/usr/local/opt/mysql/homebrew.mysql.service") + end + + it "Other - outputs no service file" do + allow(Service::System).to receive_messages(launchctl?: false, systemctl?: false) + expect(service.service_file).to be_nil + end + end + + describe "#name" do + it "outputs formula name" do + expect(service.name).to eq("mysql") + end + end + + describe "#service_name" do + it "macOS - outputs the service name" do + allow(Service::System).to receive(:launchctl?).and_return(true) + expect(service.service_name).to eq("plist-mysql-test") + end + + it "systemD - outputs the service name" do + allow(Service::System).to receive_messages(launchctl?: false, systemctl?: true) + expect(service.service_name).to eq("plist-mysql-test") + end + + it "Other - outputs no service name" do + allow(Service::System).to receive_messages(launchctl?: false, systemctl?: false) + expect(service.service_name).to be_nil + end + end + + describe "#dest_dir" do + before do + allow(Service::System).to receive_messages(launchctl?: false, systemctl?: false) + end + + it "macOS - user - outputs the destination directory for the service file" do + ENV["HOME"] = "/tmp_home" + allow(Service::System).to receive_messages(root?: false, launchctl?: true) + expect(service.dest_dir.to_s).to eq("/tmp_home/Library/LaunchAgents") + end + + it "macOS - root - outputs the destination directory for the service file" do + allow(Service::System).to receive_messages(launchctl?: true, root?: true) + expect(service.dest_dir.to_s).to eq("/Library/LaunchDaemons") + end + + it "systemD - user - outputs the destination directory for the service file" do + ENV["HOME"] = "/tmp_home" + allow(Service::System).to receive_messages(root?: false, launchctl?: false, systemctl?: true) + expect(service.dest_dir.to_s).to eq("/tmp_home/.config/systemd/user") + end + + it "systemD - root - outputs the destination directory for the service file" do + allow(Service::System).to receive_messages(root?: true, launchctl?: false, systemctl?: true) + expect(service.dest_dir.to_s).to eq("/usr/lib/systemd/system") + end + end + + describe "#dest" do + before do + ENV["HOME"] = "/tmp_home" + allow(Service::System).to receive_messages(launchctl?: false, systemctl?: false) + end + + it "macOS - outputs the destination for the service file" do + allow(Service::System).to receive(:launchctl?).and_return(true) + expect(service.dest.to_s).to eq("/tmp_home/Library/LaunchAgents/homebrew.mysql.plist") + end + + it "systemD - outputs the destination for the service file" do + allow(Service::System).to receive(:systemctl?).and_return(true) + expect(service.dest.to_s).to eq("/tmp_home/.config/systemd/user/homebrew.mysql.service") + end + end + + describe "#installed?" do + it "outputs if the service formula is installed" do + expect(service.installed?).to be(true) + end + end + + describe "#loaded?" do + it "macOS - outputs if the service is loaded" do + allow(Service::System).to receive_messages(launchctl?: true, systemctl?: false) + allow(Utils).to receive(:safe_popen_read) + expect(service.loaded?).to be(false) + end + + it "systemD - outputs if the service is loaded" do + allow(Service::System).to receive_messages(launchctl?: false, systemctl?: true) + allow(Service::System::Systemctl).to receive(:quiet_run).and_return(false) + allow(Utils).to receive(:safe_popen_read) + expect(service.loaded?).to be(false) + end + + it "Other - outputs no status" do + allow(Service::System).to receive_messages(launchctl?: false, systemctl?: false) + expect(service.loaded?).to be_nil + end + end + + describe "#plist?" do + it "false if not installed" do + allow(service).to receive(:installed?).and_return(false) + expect(service.plist?).to be(false) + end + + it "true if installed and file" do + tempfile = File.new("/tmp/foo", File::CREAT) + allow(service).to receive_messages(installed?: true, service_file: Pathname.new(tempfile)) + expect(service.plist?).to be(true) + File.delete(tempfile) + end + + it "false if opt_prefix missing" do + allow(service).to receive_messages(installed?: true, + service_file: Pathname.new(File::NULL), + formula: instance_double(Formula, + plist: nil, + opt_prefix: Pathname.new("/dfslkfhjdsolshlk"))) + expect(service.plist?).to be(false) + end + end + + describe "#owner" do + it "root if file present" do + allow(service).to receive(:boot_path_service_file_present?).and_return(true) + expect(service.owner).to eq("root") + end + + it "user if file present" do + allow(service).to receive_messages(boot_path_service_file_present?: false, + user_path_service_file_present?: true) + allow(Service::System).to receive(:user).and_return("user") + expect(service.owner).to eq("user") + end + + it "nil if no file present" do + allow(service).to receive_messages(boot_path_service_file_present?: false, + user_path_service_file_present?: false) + expect(service.owner).to be_nil + end + end + + describe "#service_file_present?" do + it "macOS - outputs if the service file is present" do + allow(Service::System).to receive_messages(launchctl?: true, systemctl?: false) + expect(service.service_file_present?).to be(false) + end + + it "macOS - outputs if the service file is present for root" do + allow(Service::System).to receive_messages(launchctl?: true, systemctl?: false) + expect(service.service_file_present?(for: :root)).to be(false) + end + + it "macOS - outputs if the service file is present for user" do + allow(Service::System).to receive_messages(launchctl?: true, systemctl?: false) + expect(service.service_file_present?(for: :user)).to be(false) + end + end + + describe "#owner?" do + it "macOS - outputs the service file owner" do + allow(Service::System).to receive_messages(launchctl?: true, systemctl?: false) + expect(service.owner).to be_nil + end + end + + describe "#pid?" do + it "outputs false because there is not pid" do + allow(service).to receive(:pid).and_return(nil) + expect(service.pid?).to be(false) + end + end + + describe "#pid" do + it "outputs nil because there is not pid" do + expect(service.pid).to be_nil + end + end + + describe "#error?" do + it "outputs false because there a no PID" do + allow(service).to receive(:pid).and_return(nil) + expect(service.error?).to be(false) + end + + it "outputs false because there is a PID but no exit" do + allow(service).to receive_messages(pid: 12, exit_code: nil) + expect(service.error?).to be(false) + end + end + + describe "#exit_code" do + it "outputs nil because there is no exit code" do + expect(service.exit_code).to be_nil + end + end + + describe "#unknown_status?" do + it "outputs true because there is no PID" do + expect(service.unknown_status?).to be(true) + end + end + + describe "#timed?" do + it "returns true if timed service" do + service_stub = instance_double(Homebrew::Service, timed?: true) + allow(service).to receive_messages(service?: true, load_service: service_stub) + allow(service_stub).to receive(:timed?).and_return(true) + + expect(service.timed?).to be(true) + end + + it "returns false if no timed service" do + service_stub = instance_double(Homebrew::Service, timed?: false) + + allow(service).to receive(:service?).once.and_return(true) + allow(service).to receive(:load_service).once.and_return(service_stub) + allow(service_stub).to receive(:timed?).and_return(false) + + expect(service.timed?).to be(false) + end + + it "returns nil if no service" do + allow(service).to receive(:service?).once.and_return(false) + + expect(service.timed?).to be_nil + end + end + + describe "#keep_alive?" do + it "returns true if service needs to stay alive" do + service_stub = instance_double(Homebrew::Service, keep_alive?: true) + + allow(service).to receive(:service?).once.and_return(true) + allow(service).to receive(:load_service).once.and_return(service_stub) + + expect(service.keep_alive?).to be(true) + end + + it "returns false if service does not need to stay alive" do + service_stub = instance_double(Homebrew::Service, keep_alive?: false) + + allow(service).to receive(:service?).once.and_return(true) + allow(service).to receive(:load_service).once.and_return(service_stub) + + expect(service.keep_alive?).to be(false) + end + + it "returns nil if no service" do + allow(service).to receive(:service?).once.and_return(false) + + expect(service.keep_alive?).to be_nil + end + end + + describe "#service_startup?" do + it "outputs false since there is no startup" do + expect(service.service_startup?).to be(false) + end + + it "outputs true since there is a startup service" do + allow(service).to receive(:service?).once.and_return(true) + allow(service).to receive(:load_service).and_return(instance_double(Homebrew::Service, requires_root?: true)) + + expect(service.service_startup?).to be(true) + end + end + + describe "#to_hash" do + it "represents non-service values" do + allow(Service::System).to receive_messages(launchctl?: true, systemctl?: false) + allow_any_instance_of(described_class).to receive_messages(service?: false, service_file_present?: false) + expected = { + exit_code: nil, + file: Pathname.new("/usr/local/opt/mysql/homebrew.mysql.plist"), + loaded: false, + name: "mysql", + pid: nil, + running: false, + schedulable: nil, + service_name: "plist-mysql-test", + status: :none, + user: nil, + } + expect(service.to_hash).to eq(expected) + end + + it "represents running non-service values" do + ENV["HOME"] = "/tmp_home" + allow(Service::System).to receive_messages(launchctl?: true, systemctl?: false) + expect(service).to receive(:service?).twice.and_return(false) + expect(service).to receive(:service_file_present?).and_return(true) + expected = { + exit_code: nil, + file: Pathname.new("/tmp_home/Library/LaunchAgents/homebrew.mysql.plist"), + loaded: false, + name: "mysql", + pid: nil, + running: false, + schedulable: nil, + service_name: "plist-mysql-test", + status: :none, + user: nil, + } + expect(service.to_hash).to eq(expected) + end + + it "represents service values" do + ENV["HOME"] = "/tmp_home" + allow(Service::System).to receive_messages(launchctl?: true, systemctl?: false) + expect(service).to receive(:service?).twice.and_return(true) + expect(service).to receive(:service_file_present?).and_return(true) + expect(service).to receive(:load_service).twice.and_return(service_object) + expected = { + command: "/bin/cmd", + cron: nil, + error_log_path: nil, + exit_code: nil, + file: Pathname.new("/tmp_home/Library/LaunchAgents/homebrew.mysql.plist"), + interval: nil, + loaded: false, + log_path: nil, + name: "mysql", + pid: nil, + root_dir: nil, + running: false, + schedulable: false, + service_name: "plist-mysql-test", + status: :none, + user: nil, + working_dir: nil, + } + expect(service.to_hash).to eq(expected) + end + end +end diff --git a/Library/Homebrew/test/services/services_cli_spec.rb b/Library/Homebrew/test/services/services_cli_spec.rb new file mode 100644 index 0000000000..a627b504ca --- /dev/null +++ b/Library/Homebrew/test/services/services_cli_spec.rb @@ -0,0 +1,296 @@ +# frozen_string_literal: true + +require "services/service" + +RSpec.describe Service::ServicesCli do + subject(:services_cli) { described_class } + + let(:service_string) { "service" } + + describe "#bin" do + it "outputs command name" do + expect(services_cli.bin).to eq("brew services") + end + end + + describe "#running" do + it "macOS - returns the currently running services" do + allow(Service::System).to receive_messages(launchctl?: true, systemctl?: false) + allow(Utils).to receive(:popen_read).and_return <<~EOS + 77513 50 homebrew.mxcl.php + 495 0 homebrew.mxcl.node_exporter + 1234 34 homebrew.mxcl.postgresql@14 + EOS + expect(services_cli.running).to eq([ + "homebrew.mxcl.php", + "homebrew.mxcl.node_exporter", + "homebrew.mxcl.postgresql@14", + ]) + end + + it "systemD - returns the currently running services" do + allow(Service::System).to receive(:launchctl?).and_return(false) + allow(Service::System::Systemctl).to receive(:popen_read).and_return <<~EOS + homebrew.php.service loaded active running Homebrew PHP service + systemd-udevd.service loaded active running Rule-based Manager for Device Events and Files + udisks2.service loaded active running Disk Manager + user@1000.service loaded active running User Manager for UID 1000 + EOS + expect(services_cli.running).to eq(["homebrew.php.service"]) + end + end + + describe "#check" do + it "checks the input does not exist" do + expect do + services_cli.check([]) + end.to raise_error(UsageError, + a_string_including("Formula(e) missing, please provide a formula name or use --all")) + end + + it "checks the input exists" do + expect do + services_cli.check("hello") + end.not_to raise_error(UsageError, + a_string_including("Formula(e) missing, please provide a formula name or use --all")) + end + end + + describe "#kill_orphaned_services" do + it "skips unmanaged services" do + service = instance_double(service_string, name: "example_service") + allow(services_cli).to receive(:running).and_return(["example_service"]) + allow(Service::FormulaWrapper).to receive(:from).and_return(service) + expect do + services_cli.kill_orphaned_services + end.to output("Service example_service not managed by `brew services` => skipping\n").to_stdout + end + + it "tries but is unable to kill a non existing service" do + service = instance_double( + service_string, + name: "example_service", + pid?: true, + dest: Pathname("this_path_does_not_exist"), + keep_alive?: false, + ) + allow(service).to receive(:service_name) + allow(Service::FormulaWrapper).to receive(:from).and_return(service) + allow(services_cli).to receive(:running).and_return(["example_service"]) + expect do + services_cli.kill_orphaned_services + end.to output(a_string_including("Killing `example_service`... (might take a while)")).to_stdout.and + output(a_string_including("Unable to kill `example_service` (label: )")).to_stderr + end + end + + describe "#run" do + it "checks empty targets cause no error" do + expect(Service::System).not_to receive(:root?) + services_cli.run([]) + end + + it "checks if target service is already running and suggests restart instead" do + expected_output = "Service `example_service` already running, " \ + "use `brew services restart example_service` to restart.\n" + service = instance_double(service_string, name: "example_service", pid?: true) + expect do + services_cli.run([service]) + end.to output(expected_output).to_stdout + end + end + + describe "#start" do + it "checks missing file causes error" do + expect(Service::System).not_to receive(:root?) + expect do + services_cli.start(["service_name"], "/hfdkjshksdjhfkjsdhf/fdsjghsdkjhb") + end.to raise_error(UsageError, a_string_including("Provided service file does not exist")) + end + + it "checks empty targets cause no error" do + expect(Service::System).not_to receive(:root?) + services_cli.start([]) + end + + it "checks if target service has already been started and suggests restart instead" do + expected_output = "Service `example_service` already started, " \ + "use `brew services restart example_service` to restart.\n" + service = instance_double(service_string, name: "example_service", pid?: true) + expect do + services_cli.start([service]) + end.to output(expected_output).to_stdout + end + end + + describe "#stop" do + it "checks empty targets cause no error" do + expect(Service::System).not_to receive(:root?) + services_cli.stop([]) + end + end + + describe "#kill" do + it "checks empty targets cause no error" do + expect(Service::System).not_to receive(:root?) + services_cli.kill([]) + end + + it "prints a message if service is not running" do + expected_output = "Service `example_service` is not started.\n" + service = instance_double(service_string, name: "example_service", pid?: false) + expect do + services_cli.kill([service]) + end.to output(expected_output).to_stdout + end + + it "prints a message if service is set to keep alive" do + expected_output = "Service `example_service` is set to automatically restart and can't be killed.\n" + service = instance_double(service_string, name: "example_service", pid?: true, keep_alive?: true) + expect do + services_cli.kill([service]) + end.to output(expected_output).to_stdout + end + end + + describe "#install_service_file" do + it "checks service is installed" do + service = instance_double(Service::FormulaWrapper, name: "name", installed?: false) + expect do + services_cli.install_service_file(service, nil) + end.to raise_error(UsageError, a_string_including("Formula `name` is not installed")) + end + + it "checks service file exists" do + service = instance_double( + Service::FormulaWrapper, + name: "name", + installed?: true, + service_file: instance_double(Pathname, exist?: false), + ) + expect do + services_cli.install_service_file(service, nil) + end.to raise_error( + UsageError, + a_string_including( + "Formula `name` has not implemented #plist, #service or installed a locatable service file", + ), + ) + end + end + + describe "#systemd_load", :needs_linux do + it "checks non-enabling run" do + expect(Service::System::Systemctl).to receive(:executable).once.and_return("/bin/systemctl") + expect(Service::System::Systemctl).to receive(:scope).once.and_return("--user") + services_cli.systemd_load( + instance_double(Service::FormulaWrapper, service_name: "name"), + enable: false, + ) + end + + it "checks enabling run" do + expect(Service::System::Systemctl).to receive(:executable).twice.and_return("/bin/systemctl") + expect(Service::System::Systemctl).to receive(:scope).twice.and_return("--user") + services_cli.systemd_load( + instance_double(Service::FormulaWrapper, service_name: "name"), + enable: true, + ) + end + end + + describe "#launchctl_load", :needs_macos do + it "checks non-enabling run" do + expect(Service::System).to receive(:domain_target).once.and_return("target") + expect(Service::System).to receive(:launchctl).once.and_return("/bin/launchctl") + services_cli.launchctl_load(instance_double(Service::FormulaWrapper), file: "a", enable: false) + end + + it "checks enabling run" do + expect(Service::System).to receive(:domain_target).twice.and_return("target") + expect(Service::System).to receive(:launchctl).twice.and_return("/bin/launchctl") + services_cli.launchctl_load(instance_double(Service::FormulaWrapper, service_name: "name"), file: "a", + enable: true) + end + end + + describe "#service_load" do + it "checks non-root for login" do + expect(Service::System).to receive(:launchctl?).once.and_return(false) + expect(Service::System).to receive(:systemctl?).once.and_return(false) + expect(Service::System).to receive(:root?).once.and_return(true) + + expect do + services_cli.service_load( + instance_double(Service::FormulaWrapper, name: "name", service_name: "service.name", +service_startup?: false), enable: false + ) + end.to output(a_string_including("Successfully ran `name` (label: service.name)")).to_stdout.and + output(a_string_including("name must be run as non-root to start at user login!")).to_stderr + end + + it "checks root for startup" do + expect(Service::System).to receive(:launchctl?).once.and_return(false) + expect(Service::System).to receive(:systemctl?).once.and_return(false) + expect(Service::System).to receive(:root?).twice.and_return(false) + expect do + services_cli.service_load( + instance_double(Service::FormulaWrapper, name: "name", service_name: "service.name", +service_startup?: true), + enable: false, + ) + end.to output(a_string_including("Successfully ran `name` (label: service.name)")).to_stdout.and + output(a_string_including("name must be run as root to start at system startup!")).to_stderr + end + + it "triggers launchctl" do + expect(Service::System).to receive(:domain_target).once.and_return("target") + expect(Service::System).to receive(:launchctl?).once.and_return(true) + expect(Service::System).to receive(:launchctl).once + expect(Service::System).not_to receive(:systemctl?) + expect(Service::System).to receive(:root?).twice.and_return(false) + expect do + services_cli.service_load( + instance_double(Service::FormulaWrapper, name: "name", service_name: "service.name", +service_startup?: false), enable: false + ) + end.to output("Successfully ran `name` (label: service.name)\n").to_stdout + end + + it "triggers systemctl" do + expect(Service::System).to receive(:launchctl?).once.and_return(false) + expect(Service::System).to receive(:systemctl?).once.and_return(true) + expect(Service::System).to receive(:root?).thrice.and_return(false) + expect do + services_cli.service_load( + instance_double( + Service::FormulaWrapper, + name: "name", + service_name: "service.name", + service_startup?: false, + dest: instance_double(Pathname, exist?: true), + ), + enable: false, + ) + end.to output("Successfully ran `name` (label: service.name)\n").to_stdout + end + + it "represents correct action" do + expect(Service::System).to receive(:launchctl?).once.and_return(false) + expect(Service::System).to receive(:systemctl?).once.and_return(true) + expect(Service::System).to receive(:root?).exactly(4).times.and_return(false) + expect do + services_cli.service_load( + instance_double( + Service::FormulaWrapper, + name: "name", + service_name: "service.name", + service_startup?: false, + dest: instance_double(Pathname, exist?: true), + ), + enable: true, + ) + end.to output("Successfully started `name` (label: service.name)\n").to_stdout + end + end +end diff --git a/Library/Homebrew/test/services/system/systemctl_spec.rb b/Library/Homebrew/test/services/system/systemctl_spec.rb new file mode 100644 index 0000000000..d4d5208e28 --- /dev/null +++ b/Library/Homebrew/test/services/system/systemctl_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "services/service" + +RSpec.describe Service::System::Systemctl do + describe ".scope" do + it "outputs systemctl scope for user" do + allow(Service::System).to receive(:root?).and_return(false) + expect(described_class.scope).to eq("--user") + end + + it "outputs systemctl scope for root" do + allow(Service::System).to receive(:root?).and_return(true) + expect(described_class.scope).to eq("--system") + end + end + + describe ".executable" do + it "outputs systemctl command location", :needs_linux do + expect(described_class.executable).to eq("/bin/systemctl") + end + end +end diff --git a/Library/Homebrew/test/services/system_spec.rb b/Library/Homebrew/test/services/system_spec.rb new file mode 100644 index 0000000000..9e14362684 --- /dev/null +++ b/Library/Homebrew/test/services/system_spec.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require "services/service" + +RSpec.describe Service::System do + describe "#launchctl" do + it "macOS - outputs launchctl command location", :needs_macos do + expect(described_class.launchctl).to eq(Pathname.new("/bin/launchctl")) + end + + it "Other - outputs launchctl command location", :needs_linux do + expect(described_class.launchctl).to eq_nil + end + end + + describe "#launchctl?" do + it "macOS - outputs launchctl presence", :needs_macos do + expect(described_class.launchctl?).to be(true) + end + + it "Other - outputs launchctl presence", :needs_linux do + expect(described_class.launchctl?).to be(false) + end + end + + describe "#systemctl?" do + it "Linux - outputs systemctl presence", :needs_linux do + expect(described_class.systemctl?).to be(true) + end + + it "Other - outputs systemctl presence", :needs_macos do + expect(described_class.systemctl?).to be(false) + end + end + + describe "#root?" do + it "checks if the command is ran as root" do + expect(described_class.root?).to be(false) + end + end + + describe "#user" do + it "returns the current username" do + expect(described_class.user).to eq(ENV.fetch("USER")) + end + end + + describe "#user_of_process" do + it "returns the username for empty PID" do + expect(described_class.user_of_process(nil)).to eq(ENV.fetch("USER")) + end + + it "returns the PID username" do + allow(Utils).to receive(:safe_popen_read).and_return <<~EOS + USER + user + EOS + expect(described_class.user_of_process(50)).to eq("user") + end + + it "returns nil if unavailable" do + allow(Utils).to receive(:safe_popen_read).and_return <<~EOS + USER + EOS + expect(described_class.user_of_process(50)).to be_nil + end + end + + describe "#domain_target" do + it "returns the current domain target" do + allow(described_class).to receive(:root?).and_return(false) + expect(described_class.domain_target).to match(%r{gui/(\d+)}) + end + + it "returns the root domain target" do + allow(described_class).to receive(:root?).and_return(true) + expect(described_class.domain_target).to match("system") + end + end + + describe "#boot_path" do + it "macOS - returns the boot path" do + allow(described_class).to receive(:launchctl?).and_return(true) + expect(described_class.boot_path.to_s).to eq("/Library/LaunchDaemons") + end + + it "SystemD - returns the boot path" do + allow(described_class).to receive_messages(launchctl?: false, systemctl?: true) + expect(described_class.boot_path.to_s).to eq("/usr/lib/systemd/system") + end + + it "Unknown - returns no boot path" do + allow(described_class).to receive_messages(launchctl?: false, systemctl?: false) + expect(described_class.boot_path.to_s).to eq("") + end + end + + describe "#user_path" do + it "macOS - returns the user path" do + ENV["HOME"] = "/tmp_home" + allow(described_class).to receive_messages(launchctl?: true, systemctl?: false) + expect(described_class.user_path.to_s).to eq("/tmp_home/Library/LaunchAgents") + end + + it "systemD - returns the user path" do + ENV["HOME"] = "/tmp_home" + allow(described_class).to receive_messages(launchctl?: false, systemctl?: true) + expect(described_class.user_path.to_s).to eq("/tmp_home/.config/systemd/user") + end + + it "Unknown - returns no user path" do + ENV["HOME"] = "/tmp_home" + allow(described_class).to receive_messages(launchctl?: false, systemctl?: false) + expect(described_class.user_path.to_s).to eq("") + end + end + + describe "#path" do + it "macOS - user - returns the current relevant path" do + ENV["HOME"] = "/tmp_home" + allow(described_class).to receive_messages(root?: false, launchctl?: true, systemctl?: false) + expect(described_class.path.to_s).to eq("/tmp_home/Library/LaunchAgents") + end + + it "macOS - root- returns the current relevant path" do + ENV["HOME"] = "/tmp_home" + allow(described_class).to receive_messages(root?: true, launchctl?: true, systemctl?: false) + expect(described_class.path.to_s).to eq("/Library/LaunchDaemons") + end + + it "systemD - user - returns the current relevant path" do + ENV["HOME"] = "/tmp_home" + allow(described_class).to receive_messages(root?: false, launchctl?: false, systemctl?: true) + expect(described_class.path.to_s).to eq("/tmp_home/.config/systemd/user") + end + + it "systemD - root- returns the current relevant path" do + ENV["HOME"] = "/tmp_home" + allow(described_class).to receive_messages(root?: true, launchctl?: false, systemctl?: true) + expect(described_class.path.to_s).to eq("/usr/lib/systemd/system") + end + end +end