Add brew bundle services helper

This commit is contained in:
Bo Anderson 2025-03-20 07:16:02 +00:00
parent 0b58e8fd37
commit 615fb764a1
No known key found for this signature in database
7 changed files with 162 additions and 4 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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?

View File

@ -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?

View File

@ -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,

View File

@ -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,