module Locd
Definitions
¶ ↑
Namespace
¶ ↑
Namespace
¶ ↑
Namespace
¶ ↑
Namespace
¶ ↑
Namespace
¶ ↑
Namespace
¶ ↑
Namespace
¶ ↑
Namespace
¶ ↑
Namespace
¶ ↑
Namespace
¶ ↑
Namespace
¶ ↑
Namespace
¶ ↑
Namespace
¶ ↑
Namespace
¶ ↑
Namespace
¶ ↑
Namespace
¶ ↑
Namespace
¶ ↑
Namespace
¶ ↑
Namespace
¶ ↑
Namespace
¶ ↑
Manage Agent
definitions attached to projects.
Namespace
¶ ↑
Namespace
¶ ↑
Namespace
¶ ↑
Namespace
¶ ↑
Definitions
¶ ↑
Namespace
¶ ↑
Definitions
¶ ↑
Definitions
¶ ↑
Constants
- GEM_NAME
The gem name, read from the `//NAME` file, and used in the gemspec.
See {Locd::VERSION} for an explanation of why bare files in the package root are used.
@return [String]
- HOST_RE
{Regexp} to match HTTP “Host” header line.
@return [Regexp]
- ROOT
Absolute, expanded path to the gem's root directory.
@return [Pathname]
- VERSION
String version read from `//VERSION` and used in the gemspec.
I use this approach in order to create a standard versioning system across any package, regardless or runtime, and so that tools can figure out what version a package is without even having it's runtime available.
This has turned out to be super helpful, and I'm surprised it isn't more common or standardized in some way… I think pretty much every package in very language has a root directory and is capable of reading a file to figure out it's state.
@return [String]
Attributes
Absolute path to the agent's `.plist` file.
@return [Pathname]
Hash of the agent's `.plist` file (keys and values from the top-level `<dict>` element).
@return [Hash<String, V>]
Check out the [plist][] gem for an idea of what types `V` may assume. [plist]: http://www.rubydoc.info/gems/plist
Public Class Methods
Add an agent, writing a `.plist` to `~/Library/LaunchAgents`.
Does not start the agent.
@param [String] label:
The agent's label, which is its: 1. Unique identifier in launchd 2. Domain via the proxy. 3. Property list filename (plus the `.plist` extension).
@param [Boolean] force:
Overwrite any existing agent with the same label.
@param [String | Pathname] workdir:
Working directory for the agent.
@param [Hash<Symbol, Object>] **kwds
Additional keyword arguments to pass to {.create_plist_data}.
@return [Locd::Agent]
The new agent.
# File lib/locd/agent.rb, line 667 def self.add label:, force: false, workdir: Pathname.getwd, **kwds logger.debug "Creating {Agent}...", label: label, force: force, workdir: workdir, **kwds plist_abs_path = self.plist_abs_path label # Handle file already existing if File.exists? plist_abs_path logger.debug "Agent already exists!", label: label, path: plist_abs_path if force logger.info "Forcing agent creation (overwrite)", label: label, path: plist_abs_path else raise binding.erb <<~END Agent <%= label %> already exists at: <%= plist_abs_path %> END end end plist_data = create_plist_data label: label, workdir: workdir, **kwds logger.debug "Property list created", data: plist_data plist_string = Plist::Emit.dump plist_data plist_abs_path.write plist_string logger.debug "Property list written", path: plist_abs_path from_path( plist_abs_path ).tap { |agent| agent.send :log_info, "added" } end
Pretty much what the name says.
@see .add @see update
@param [String] label
Agent's label (AKA name AKA domain).
@param **values
Agent properties.
@return [Array<((:add | :update), Locd::Agent
)]
Whether the agent was added or updated, followed by the agent instance.
# File lib/locd/agent.rb, line 726 def self.add_or_update label:, **values if exists? label [:update, get( label ).update( **values )] else [:add, add( label: label, **values )] end end
All {Locd::Agent} that are instances of `self`.
So, when invoked through {Locd::Agent.all} returns all agents.
When invoked from a {Locd::Agent} subclass, returns all agents that are instances of *that subclass* - {Locd::Agent::Site.all} returns all {Locd::Agent} that are {Locd::Agent::Site} instances.
@return [Hamster::Hash<String, Locd::Agent>]
Map of agent {#label} to instance.
# File lib/locd/agent.rb, line 331 def self.all # If we're being invoked through {Locd::Agent} itself actually find and # load everyone if self == Locd::Agent plists.each_pair.map { |path, plist| begin agent_class = class_for plist if agent_class.nil? nil else agent = agent_class.new path: path, plist: plist [agent.label, agent] end rescue Exception => error logger.error "Failed to parse Loc'd Agent plist", path: path, error: error, backtrace: error.backtrace nil end }. compact. thru( &Hamster::Hash.method( :new ) ) else # We're being invoked through a {Locd::Agent} subclass, so invoke # through {Locd::Agent} and filter the results to instance of `self`. Locd::Agent.all.select { |label, agent| agent.is_a? self } end end
Get the agent class that a plist should be instantiated as.
@param [Hash<String, Object>] plist
{include:file:doc/include/plist.md}
@return [Class<Locd::Agent>]
If the plist is for an agent of the current Loc'd configuration, the appropriate class to instantiate it as. Plists for user sites and jobs will always return their subclass. Plists for system agents will only return their class when they are for the current configuration's label namespace.
@return [nil]
If the plist is not for an agent of the current Loc'd configuration (and should be ignored by Loc'd).
# File lib/locd/agent.rb, line 236 def self.class_for plist # 1. See if it's a Loc'd system agent plist if system_class = Locd::Agent::System.class_for( plist ) return system_class end # 2. See if it's a Loc'd user agent plist if user_class = [ Locd::Agent::Site, Locd::Agent::Job, ].find_bounded( max: 1 ) { |cls| cls.plist? plist }.first return user_class end # 3. Return a vanilla agent if it's a plist for one # # Really, not sure when this would happen... # if Locd::Agent.plist?( plist ) return Locd::Agent end # Nada nil end
@return [Locd::Config]
The configuration.
# File lib/locd.rb, line 60 def self.config @config ||= Locd::Config.new end
Create the `launchd` property list data for a new {Locd::Agent}.
@param [nil | String | Pathname] log_path
:
Optional path to log agent standard outputs to (combined `STDOUT` and `STDERR`). See {.resolve_log_path} for details on how the different types and values are treated.
@return [Hash<String, Object>]
{include:file:doc/include/plist.md}
# File lib/locd/agent.rb, line 547 def self.create_plist_data cmd_template:, label:, workdir:, log_path: nil, keep_alive: false, run_at_load: false, **extras # Configure daemon variables... # Normalize `workdir` to an expanded {Pathname} workdir = workdir.to_pn.expand_path # Resolve the log (`STDOUT` & `STDERR`) path log_path = resolve_log_path( log_path: log_path, workdir: workdir, label: label, ).to_s # Interpolate variables into command template cmd = render_cmd( cmd_template: cmd_template, label: label, workdir: workdir, **extras ) # Form the property list hash { # Unique label, format: `locd.<owner>.<name>.<agent.path...>` 'Label' => label, # What to run 'ProgramArguments' => [ # TODO Maybe this should be configurable or smarter in some way? 'bash', # *login* shell... need this to source the user profile and set up # all the ENV vars '-l', # Run the command in the login shell '-c', cmd, ], # Directory to run the command in 'WorkingDirectory' => workdir.to_s, # Where to send STDOUT 'StandardOutPath' => log_path, # Where to send STDERR (we send both to the same file) 'StandardErrorPath' => log_path, # Bring the process back up if it goes down (has backoff and stuff # built-in) 'KeepAlive' => keep_alive, # Start the process when the plist is loaded 'RunAtLoad' => run_at_load, 'ProcessType' => 'Interactive', # Extras we need... `launchd` complains in the system log about this # but it's the easiest way to handle it at the moment Locd.config[:agent, :config_key] => { # Save this too why the hell not, might help debuging at least cmd_template: cmd_template, # Add subclass-specific extras **extras, }.str_keys, # Stuff that *doesn't* work... so you don't try it again, because # Apple's online docs seems totally out of date. # # Not allowed for user agents # 'UserName' => ENV['USER'], # # "The Debug key is no longer respected. Please remove it." # 'Debug' => true, # # Yeah, it would have been nice to just use the plist to store the port, # but this runs into all sorts of impenetrable security mess... gotta # put it somewhere else! Weirdly enough it just totally works outside # of here, so I'm not what the security is really stopping..? # # 'Sockets' => { # 'Listeners' => { # 'SockNodeName' => BIND, # 'SockServiceName' => port, # 'SockType' => 'stream', # 'SockFamily' => 'IPv4', # }, # }, }.reject { |key, value| value.nil? } # Drop any `nil` values end
Does this agent exist?
@param [String] label
Agent's label (AKA name AKA domain).
@return [Boolean]
# File lib/locd/agent.rb, line 313 def self.exists? label File.exists? plist_abs_path( label ) end
Find all the agents that match a pattern.
@see Locd::Pattern
@param [String | Pattern] pattern
Pattern to match against agent. When it's a {String}, passed to {Locd::Pattern.from} to get the pattern.
@param [**<Symbol, V>] options
Passed to {Locd::Pattern.from} when `pattern` is a {String}.
@return (see .all)
# File lib/locd/agent.rb, line 447 def self.find_all pattern, **options pattern = Locd::Pattern.from pattern, **options all.select { |label, agent| pattern.match? agent } end
Just like {.find_all} but raises if result is empty.
@param (see .find_all) @return (see .find_all)
@raise [NRSER::CountError]
If no agents were found.
# File lib/locd/agent.rb, line 463 def self.find_all! pattern, **options pattern = Locd::Pattern.from pattern, **options find_all( pattern ).tap { |agents| if agents.empty? raise NRSER::CountError.new( "No agents found for pattern from #{ pattern.source.inspect }", value: agents, expected: '#count > 0', ) end } end
Find a single {Locd::Agent} matching `pattern` or raise.
Parameters are passed to {Locd::Label.regexp_for_glob} and the resulting {Regexp} is matched against each agent's {#label}.
@see Locd::Pattern
@param (see .find_all)
@return [Locd::Agent]
# File lib/locd/agent.rb, line 428 def self.find_only! *find_all_args find_all( *find_all_args ).values.to_a.only! end
Instantiate a {Locd::Agent} from the path to it's `.plist` file.
@param [Pathname | String] plist_path
Path to the `.plist` file.
@return [Locd::Agent]
# File lib/locd/agent.rb, line 270 def self.from_path plist_path plist = Plist.parse_xml plist_path.to_s class_for( plist ).new plist: plist, path: plist_path end
Get a Loc'd agent by it's label.
@param [String] label
The agent's label.
@return [Agent]
If a Loc'd agent with `label` is installed.
@return [nil]
If no Loc'd agent with `label` is installed.
# File lib/locd/agent.rb, line 385 def self.get label path = plist_abs_path label if path.file? from_path path end end
Like {.get} but raises if the agent is not found.
@param [String] label
The agent's label.
@return [Agent]
The agent.
@raise [NotFoundError]
If the agent isn't there.
# File lib/locd/agent.rb, line 405 def self.get! label get( label ).tap do |agent| if agent.nil? raise NotFoundError, [ self, "with label", label, "not found. Expected the property list at", plist_abs_path( label ) ].map( &:to_s ).join( ' ' ) end end end
Labels for all installed Loc'd agents.
@return [Hamster::Vector<String>]
# File lib/locd/agent.rb, line 369 def self.labels all.keys.sort end
Constructor
¶ ↑
# File lib/locd/agent.rb, line 759 def initialize plist:, path: @path = path.to_pn.expand_path @plist = plist @status = nil # Sanity check... unless plist.key? Locd.config[:agent, :config_key] raise ArgumentError.new binding.erb <<~END Not a Loc'd plist (no <%= Locd.config[:agent, :config_key] %> key) path: <%= path %> plist: <%= plist.pretty_inspect %> END end unless @path.basename( '.plist' ).to_s == label raise ArgumentError.new binding.erb <<~END Label and filename don't match. Filename should be `<label>.plist`, found label: <%= label %> filename: <%= @path.basename %> path: <%= path %> END end init_ensure_out_dirs_exist end
All installed Loc'd agents.
@return [Hamster::Hash<String, Locd::Agent>]
Map of all Loc'd agents by label.
# File lib/locd/agent.rb, line 287 def self.plists Pathname.glob( user_plist_abs_dir / '*.plist' ). map { |path| begin [path, Plist.parse_xml( path.to_s )] rescue Exception => error logger.trace "{Plist.parse_xml} failed to parse plist", path: path.to_s, error: error, backtrace: error.backtrace nil end }. compact. select { |path, plist| plist? plist }. thru( &Hamster::Hash.method( :new ) ) end
Render a command string by substituting any `{name}` parts for their values.
@example
render_cmd \ cmd_template: "serve --name={label} --port={port}", label: 'server.test', workdir: Pathname.new( '~' ).expand_path, port: 1234 # => "server --name=server.test --port=1234"
@param [String | Array<String>] cmd_template
:
Template for the string. Arrays are just rendered per-entry then joined.
@param [String] label:
The agent's {#label}.
@param [Pathname] workdir:
The agent's {#workdir}.
@param [Hash<Symbol, to_s>] **extras
Subclass-specific extra keys and values to make available. Values will be converted to strings via `#to_s` before substitution.
@return [String]
Command string with substitutions made.
# File lib/locd/agent.rb, line 511 def self.render_cmd cmd_template:, label:, workdir:, **extras t.match cmd_template, Array, ->( array ) { array.map {|arg| render_cmd \ cmd_template: arg, label: label, workdir: workdir, **extras }.shelljoin }, String, ->( string ) { { label: label, workdir: workdir, **extras, }.reduce( string ) do |cmd, (key, value)| cmd.gsub "{#{ key }}", value.to_s.shellescape end } end
# File lib/locd/util.rb, line 9 def self.resolve project_root, path = nil return project_root if path.nil? if path.start_with? '//' project_root / path[2..-1] elsif path.start_with? '/' path.to_pn else project_root / path end end
Just like {.resolve} but returns a {String} (instead of a {Pathname}).
@param (see .resolve) @return [String]
# File lib/locd/util.rb, line 27 def self.resolve_to_s *args resolve( *args ).to_s end
Public Instance Methods
Compare to another agent by their labels.
@param [Locd::Agent] other
The other agent.
@return [Fixnum]
# File lib/locd/agent.rb, line 1222 def <=> other Locd::Label.compare label, other.label end
# File lib/locd/agent.rb, line 860 def cmd_template config['cmd_template'] end
# File lib/locd/agent.rb, line 855 def config plist[Locd.config[:agent, :config_key]] end
@return [Boolean]
`true` if the {#log_path} is the default one we generate.
# File lib/locd/agent.rb, line 800 def default_log_path? log_path == self.class.default_log_path( workdir, label ) end
# File lib/locd/agent.rb, line 1040 def ensure_running **start_kwds start( **start_kwds ) unless running? end
Path the agent is logging `STDERR` to.
@return [Pathname]
If the agent is logging `STDERR` to a file path.
@return [nil]
If the agent is not logging `STDERR`.
# File lib/locd/agent.rb, line 895 def err_path plist['StandardErrorPath'].to_pn if plist['StandardErrorPath'] end
@return [String]
# File lib/locd/agent.rb, line 813 def label plist['Label'].freeze end
@return [nil]
No last exit code information.
@return [Fixnum]
Last exit code of process.
# File lib/locd/agent.rb, line 850 def last_exit_code refresh: false status( refresh: refresh ) && status[:last_exit_code] end
Load the agent by executing `launchctl load [OPTIONS] LABEL`.
This is a bit low-level; you probably want to use {#start}.
@param [Boolean] force:
Force the loading of the plist. Ignore the `launchd` *Disabled* key.
@param [Boolean] enable:
Overrides the `launchd` *Disabled* key and sets it to `false`.
@return [self]
# File lib/locd/agent.rb, line 963 def load force: false, enable: false logger.debug "Loading #{ label } agent...", force: force, enable: enable result = Locd::Launchctl.load! path, force: force, write: enable message = if result.err =~ /service\ already\ loaded/ "already loaded" else "LOADED" end log_info message, status: status self end
Get a list of all unique log paths.
@return [Array<Pathname>]
# File lib/locd/agent.rb, line 904 def log_paths [ out_path, err_path, ].compact.uniq end
Path the agent is logging `STDOUT` to.
@return [Pathname]
If the agent is logging `STDOUT` to a file path.
@return [nil]
If the agent is not logging `STDOUT`.
# File lib/locd/agent.rb, line 880 def out_path plist['StandardOutPath'].to_pn if plist['StandardOutPath'] end
Current process ID of the agent (if running).
@param (see status
)
@return [nil]
No process ID (not running).
@return [Fixnum]
Process ID.
# File lib/locd/agent.rb, line 828 def pid refresh: false status( refresh: refresh ) && status[:pid] end
Remove the agent by removing it's {#path} file. Will {#stop} and {#unloads} the agent first.
@param [Boolean] logs:
When `true` remove all logs as well.
@return [self]
# File lib/locd/agent.rb, line 1092 def remove logs: false stop unload: true if logs log_paths.each { |log_path| if log_path.exists? FileUtils.rm log_path log_info "Removed log", path: log_path.to_s else log_info "Log path does not exist", path: log_path.to_s end } end FileUtils.rm path log_info "REMOVED" self end
Restart the agent ({#stop} then {#start}).
@param [Boolean] reload:
Unload then load the agent in `launchd`.
@param force (see start
) @param enable (see start
)
@return [self]
# File lib/locd/agent.rb, line 1078 def restart reload: true, force: true, enable: false stop unload: reload start load: reload, force: force, enable: enable end
# File lib/locd/agent.rb, line 833 def running? refresh: false status( refresh: refresh )[:running] end
Start the agent.
If `load` is `true`, calls {#load} first, and defaults it's `force` keyword argument to `true` (the idea being that you actually want the agent to start, even if it's {#disabled?}).
@param [Boolean] load:
Call {#load} first, passing it `enable` and `force`.
@param force (see load
) @param enable (see load
)
@return [self]
# File lib/locd/agent.rb, line 1025 def start load: true, force: false, enable: false logger.trace "Starting `#{ label }` agent...", load: load, force: force, enable: enable self.load( force: force, enable: enable ) if load Locd::Launchctl.start! label log_info "STARTED" self end
The agent's status from parsing `launchctl list`.
Status is read on demand and cached on the instance.
@param [Boolean] refresh:
When `true`, will re-read from `launchd` (and cache results) before returning.
@return [Status]
# File lib/locd/agent.rb, line 927 def status refresh: false if refresh || @status.nil? raw_status = Locd::Launchctl.status[label] # Happens when the agent is not loaded @status = if raw_status.nil? Status.new \ loaded: false, running: false, pid: nil, last_exit_code: nil else Status.new \ loaded: true, running: !raw_status[:pid].nil?, pid: raw_status[:pid], last_exit_code: raw_status[:status] end end @status end
Stop the agent.
@param [Boolean] unload:
Call {#unload} first, passing it `write`.
@param disable (see unload
)
@return [self]
# File lib/locd/agent.rb, line 1054 def stop unload: true, disable: false logger.debug "Stopping `#{ label } agent...`", unload: unload, disable: disable Locd::Launchctl.stop label log_info "STOPPED" self.unload( disable: disable ) if unload self end
# File lib/locd/agent.rb, line 838 def stopped? refresh: false !running?( refresh: refresh ) end
# File lib/locd/agent.rb, line 1227 def to_h self.class::TO_H_NAMES.map { |name| [name, send( name )] }.to_h end
# File lib/locd/agent.rb, line 1234 def to_json *args to_h.to_json *args end
TODO Doesn't work!
# File lib/locd/agent.rb, line 1241 def to_yaml *args to_h.to_yaml *args end
Unload the agent by executing `launchctl unload [OPTIONS] LABEL`.
This is a bit low-level; you probably want to use {#stop}.
@param [Boolean] disable:
Overrides the `launchd` *Disabled* key and sets it to `true`.
@return [self]
# File lib/locd/agent.rb, line 989 def unload disable: false logger.debug "Unloading #{ label } agent...", disable: disable result = Locd::Launchctl.unload! path, write: disable log_info "UNLOADED" self end
Update specific values on the agent, which may change it's file path if a different label is provided.
**_Does not mutate this instance! Returns a new {Locd::Agent} with the updated values._**
@param [Boolean] force:
Overwrite any existing agent with the same label.
@param [Hash<Symbol, Object>] **values
See {.create_plist_data}.
@return [Locd::Agent]
A new instance with the updated values.
# File lib/locd/agent.rb, line 1133 def update force: false, **values logger.trace "Updating `#{ label }` agent", **values # Remove the `cmd_template` if it's nil of an empty array so that # we use the current one if values[:cmd_template].nil? || (values[:cmd_template].is_a?( Array ) && values[:cmd_template].empty?) values.delete :cmd_template end # Make a new plist new_plist_data = self.class.create_plist_data( label: label, workdir: workdir, log_path: ( values.key?( :log_path ) ? values.key?( :log_path ) : ( default_log_path? ? nil : log_path ) ), # Include the config values, which have the `cmd_template` as well as # any extras. Need to symbolize the keys to make the kwds call work **config.sym_keys, # Now merge over with the values we received **values.except( :log_path ) ) new_label = new_plist_data['Label'] new_plist_abs_path = self.class.plist_abs_path new_label if new_label == label # We maintained the same label, overwrite the file path.write Plist::Emit.dump( new_plist_data ) # Load up the new agent from the same path, reload and return it self.class.from_path( path ).reload else # The label has changed, so we want to make sure we're not overwriting # another agent if File.exists? new_plist_abs_path # There's someone already there! # Bail out unless we are forcing the operation if force logger.info "Overwriting agent #{ new_label } with update to " \ "agent #{ label } (force: `true`)" else raise binding.erb <<-END A different agent already exists at: <%= new_plist_abs_path %> Remove that agent first if you really want to replace it with an updated version of this one or provide `force: true`. END end end # Ok, we're in the clear (save for the obvious race condition, but, # hey, it's a development tool, so fuck it... it's not even clear it's # possible to do an atomic file add from Ruby) new_plist_abs_path.write Plist::Emit.dump( new_plist_data ) # Remove this agent remove # And instantiate and load a new agent from the new path self.class.from_path( new_plist_abs_path ).load end end
@return [Pathname]
The working directory of the agent.
# File lib/locd/agent.rb, line 867 def workdir plist['WorkingDirectory'].to_pn end
Protected Instance Methods
Create directories for any output file if they don't already exist.
We need to create the directories for the output files before we ever try to start the agent because otherwise it seems that `launchd` *will create them*, but it will *create them as `root`*, then try to write to them *as the user*, fail, and crash the process with a `78` exit code.
How or why that makes sense to `launchd`, who knows.
But, since it's nice to be able to start the agents through `launchctl` or `lunchy`, and having a caveat that *they need to be started through Loc'd the first time* sucks, during initialization seems like a reasonable time, at least for now (I don't like doing stuff like this curing construction, but whatever for the moment, let's get it working).
@return [nil]
# File lib/locd/agent.rb, line 1269 def init_ensure_out_dirs_exist [out_path, err_path].compact.each do |path| dir = path.dirname unless dir.exist? logger.debug "Creating directory for output", label: label, dir: dir, path: path FileUtils.mkdir_p dir end end nil end
Log a message with a details payload, prefixing the agent's label.
Used to relay info to the user in the CLI
.
@param [#to_s] message
Message to log.
@param [Hash<Symbol, Object>] **payload
Optional values to dump.
@return [void]
# File lib/locd/agent.rb, line 1297 def log_info message, **payload logger.info "Agent `#{ label }` #{ message }", **payload end