diff --git a/Library/Homebrew/bundle/commands/services.rb b/Library/Homebrew/bundle/commands/services.rb new file mode 100644 index 0000000000..4c8eb5cf4b --- /dev/null +++ b/Library/Homebrew/bundle/commands/services.rb @@ -0,0 +1,30 @@ +# typed: strict +# frozen_string_literal: true + +require "bundle/brewfile" +require "bundle/services" + +module Homebrew + module Bundle + module Commands + module Services + sig { params(args: String, global: T::Boolean, file: T.nilable(String)).void } + def self.run(*args, global:, file:) + raise UsageError, "invalid `brew bundle services` arguments" if args.length != 1 + + parsed_entries = Brewfile.read(global:, file:).entries + + subcommand = args.first + case subcommand + when "run" + Homebrew::Bundle::Services.run(parsed_entries) + when "stop" + Homebrew::Bundle::Services.stop(parsed_entries) + else + raise UsageError, "unknown bundle services subcommand: #{subcommand}" + end + end + end + end + end +end diff --git a/Library/Homebrew/bundle/services.rb b/Library/Homebrew/bundle/services.rb new file mode 100644 index 0000000000..32e31c859b --- /dev/null +++ b/Library/Homebrew/bundle/services.rb @@ -0,0 +1,112 @@ +# typed: strict +# frozen_string_literal: true + +require "bundle/dsl" +require "formula" +require "services/system" + +module Homebrew + module Bundle + module Services + sig { + params( + entries: T::Array[Homebrew::Bundle::Dsl::Entry], + _block: T.proc.params( + info: T::Hash[String, T.anything], + service_file: Pathname, + conflicting_services: T::Array[T::Hash[String, T.anything]], + ).void, + ).void + } + private_class_method def self.map_entries(entries, &_block) + formula_versions = Bundle.formula_versions_from_env + + entries_formulae = entries.filter_map do |entry| + next if entry.type != :brew + + formula = Formula[entry.name] + next unless formula.any_version_installed? + + [entry, formula] + end.to_h + + # The formula + everything that could possible conflict with the service + names_to_query = entries_formulae.flat_map do |entry, formula| + [ + formula.name, + *formula.versioned_formulae_names, + *formula.conflicts.map(&:name), + *entry.options[:conflicts_with], + ] + end + + # We parse from a command invocation so that brew wrappers can invoke special actions + # for the elevated nature of `brew services` + services_info = JSON.parse( + Utils.safe_popen_read(HOMEBREW_BREW_FILE, "services", "info", "--json", *names_to_query), + ) + + entries_formulae.filter_map do |entry, formula| + version = formula_versions[entry.name.downcase] + prefix = formula.rack/version if version + + service_file = if prefix&.directory? + if Homebrew::Services::System.launchctl? + prefix/"#{formula.plist_name}.plist" + else + prefix/"#{formula.service_name}.service" + end + end + + unless service_file&.file? + prefix = formula.any_installed_prefix + next if prefix.nil? + + service_file = if Homebrew::Services::System.launchctl? + prefix/"#{formula.plist_name}.plist" + else + prefix/"#{formula.service_name}.service" + end + end + + next unless service_file.file? + + info = services_info.find { |candidate| candidate["name"] == formula.name } + conflicting_services = services_info.select do |candidate| + next unless candidate["running"] + + formula.versioned_formulae_names.include?(candidate["name"]) + end + + raise "Failed to get service info for #{entry.name}" if info.nil? + + yield info, service_file, conflicting_services + end + end + + sig { params(entries: T::Array[Homebrew::Bundle::Dsl::Entry]).void } + def self.run(entries) + map_entries(entries) do |info, service_file, conflicting_services| + safe_system HOMEBREW_BREW_FILE, "services", "stop", "--keep", info["name"] if info["running"] + conflicting_services.each do |conflicting_service| + safe_system HOMEBREW_BREW_FILE, "services", "stop", "--keep", conflicting_service["name"] + end + + safe_system HOMEBREW_BREW_FILE, "services", "run", "--file=#{service_file}", info["name"] + end + end + + sig { params(entries: T::Array[Homebrew::Bundle::Dsl::Entry]).void } + def self.stop(entries) + map_entries(entries) do |info, _, _| + next unless info["loaded"] + + # Try avoid services not started by `brew bundle services` + next if Homebrew::Services::System.launchctl? && info["registered"] + + safe_system HOMEBREW_BREW_FILE, "services", "stop", info["name"] + end + end + end + end +end diff --git a/Library/Homebrew/cmd/bundle.rb b/Library/Homebrew/cmd/bundle.rb index 6d199428b8..df26f6cc9f 100755 --- a/Library/Homebrew/cmd/bundle.rb +++ b/Library/Homebrew/cmd/bundle.rb @@ -61,6 +61,12 @@ module Homebrew `brew bundle env`: Print the environment variables that would be set in a `brew bundle exec` environment. + + `brew bundle services run`: + Start services for formulae specified in the `Brewfile`. + + `brew bundle services stop`: + Stop services for formulae specified in the `Brewfile`. EOS flag "--file=", description: "Read from or write to the `Brewfile` from this location. " \ @@ -133,7 +139,7 @@ module Homebrew require "bundle" subcommand = args.named.first.presence - if ["exec", "add", "remove"].exclude?(subcommand) && args.named.size > 1 + if %w[exec add remove services].exclude?(subcommand) && args.named.size > 1 raise UsageError, "This command does not take more than 1 subcommand argument." end @@ -273,6 +279,10 @@ module Homebrew require "bundle/commands/remove" Homebrew::Bundle::Commands::Remove.run(*named_args, type: selected_types.first, global:, file:) end + when "services" + _, *named_args = args.named + require "bundle/commands/services" + Homebrew::Bundle::Commands::Services.run(*named_args, global:, file:) else raise UsageError, "unknown subcommand: #{subcommand}" end diff --git a/Library/Homebrew/services/commands/info.rb b/Library/Homebrew/services/commands/info.rb index c1edc2e0f1..ed2bc32434 100644 --- a/Library/Homebrew/services/commands/info.rb +++ b/Library/Homebrew/services/commands/info.rb @@ -53,6 +53,7 @@ module Homebrew return out unless verbose out += "File: #{hash[:file]} #{pretty_bool(hash[:file].present?)}\n" + out += "Registered at login: #{pretty_bool(hash[:registered])}\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? diff --git a/Library/Homebrew/services/formula_wrapper.rb b/Library/Homebrew/services/formula_wrapper.rb index 86694f5d8d..ddd6dce65d 100644 --- a/Library/Homebrew/services/formula_wrapper.rb +++ b/Library/Homebrew/services/formula_wrapper.rb @@ -201,6 +201,7 @@ module Homebrew user: owner, status: status_symbol, file: service_file_present? ? dest : service_file, + registered: service_file_present?, } return hash unless service? diff --git a/Library/Homebrew/test/services/commands/info_spec.rb b/Library/Homebrew/test/services/commands/info_spec.rb index 62f03099eb..f8f3fc21fe 100644 --- a/Library/Homebrew/test/services/commands/info_spec.rb +++ b/Library/Homebrew/test/services/commands/info_spec.rb @@ -89,7 +89,7 @@ RSpec.describe Homebrew::Services::Commands::Info do it "returns verbose output" do out = "service ()\nRunning: true\n" out += "Loaded: true\nSchedulable: false\n" - out += "User: user\nPID: 42\nFile: /dev/null true\nCommand: /bin/command\n" + out += "User: user\nPID: 42\nFile: /dev/null true\nRegistered at login: true\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 = { @@ -97,6 +97,7 @@ RSpec.describe Homebrew::Services::Commands::Info do user: "user", status: :started, file: "/dev/null", + registered: true, running: true, loaded: true, schedulable: false, diff --git a/Library/Homebrew/test/services/formula_wrapper_spec.rb b/Library/Homebrew/test/services/formula_wrapper_spec.rb index 09f3b1415b..da7ee70acd 100644 --- a/Library/Homebrew/test/services/formula_wrapper_spec.rb +++ b/Library/Homebrew/test/services/formula_wrapper_spec.rb @@ -371,6 +371,7 @@ RSpec.describe Homebrew::Services::FormulaWrapper do loaded: false, name: "mysql", pid: nil, + registered: false, running: false, schedulable: nil, service_name: "plist-mysql-test", @@ -384,13 +385,14 @@ RSpec.describe Homebrew::Services::FormulaWrapper do ENV["HOME"] = "/tmp_home" allow(Homebrew::Services::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) + expect(service).to receive(:service_file_present?).twice.and_return(true) expected = { exit_code: nil, file: Pathname.new("/tmp_home/Library/LaunchAgents/homebrew.mysql.plist"), loaded: false, name: "mysql", pid: nil, + registered: true, running: false, schedulable: nil, service_name: "plist-mysql-test", @@ -404,7 +406,7 @@ RSpec.describe Homebrew::Services::FormulaWrapper do ENV["HOME"] = "/tmp_home" allow(Homebrew::Services::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(:service_file_present?).twice.and_return(true) expect(service).to receive(:load_service).twice.and_return(service_object) expected = { command: "/bin/cmd", @@ -417,6 +419,7 @@ RSpec.describe Homebrew::Services::FormulaWrapper do log_path: nil, name: "mysql", pid: nil, + registered: true, root_dir: nil, running: false, schedulable: false,