locd 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +108 -0
  3. data/.gitmodules +9 -0
  4. data/.qb-options.yml +4 -0
  5. data/.rspec +3 -0
  6. data/.travis.yml +5 -0
  7. data/.yardopts +7 -0
  8. data/Gemfile +10 -0
  9. data/LICENSE.txt +21 -0
  10. data/README.md +72 -0
  11. data/Rakefile +6 -0
  12. data/VERSION +1 -0
  13. data/config/default.yml +24 -0
  14. data/doc/files/design/domains_and_labels.md +117 -0
  15. data/doc/files/topics/agents.md +16 -0
  16. data/doc/files/topics/launchd.md +15 -0
  17. data/doc/files/topics/plists.md +39 -0
  18. data/doc/include/plist.md +3 -0
  19. data/exe/locd +24 -0
  20. data/lib/locd.rb +75 -0
  21. data/lib/locd/agent.rb +1186 -0
  22. data/lib/locd/agent/job.rb +142 -0
  23. data/lib/locd/agent/proxy.rb +111 -0
  24. data/lib/locd/agent/rotate_logs.rb +82 -0
  25. data/lib/locd/agent/site.rb +174 -0
  26. data/lib/locd/agent/system.rb +270 -0
  27. data/lib/locd/cli.rb +4 -0
  28. data/lib/locd/cli/command.rb +9 -0
  29. data/lib/locd/cli/command/agent.rb +310 -0
  30. data/lib/locd/cli/command/base.rb +243 -0
  31. data/lib/locd/cli/command/job.rb +110 -0
  32. data/lib/locd/cli/command/main.rb +201 -0
  33. data/lib/locd/cli/command/proxy.rb +177 -0
  34. data/lib/locd/cli/command/rotate_logs.rb +152 -0
  35. data/lib/locd/cli/command/site.rb +47 -0
  36. data/lib/locd/cli/table.rb +157 -0
  37. data/lib/locd/config.rb +237 -0
  38. data/lib/locd/config/base.rb +93 -0
  39. data/lib/locd/errors.rb +65 -0
  40. data/lib/locd/label.rb +61 -0
  41. data/lib/locd/launchctl.rb +209 -0
  42. data/lib/locd/logging.rb +360 -0
  43. data/lib/locd/newsyslog.rb +402 -0
  44. data/lib/locd/pattern.rb +193 -0
  45. data/lib/locd/proxy.rb +272 -0
  46. data/lib/locd/proxymachine.rb +34 -0
  47. data/lib/locd/util.rb +49 -0
  48. data/lib/locd/version.rb +26 -0
  49. data/locd.gemspec +66 -0
  50. metadata +262 -0
@@ -0,0 +1,39 @@
1
+ Property Lists (`*.plist` Files)
2
+ ==============================================================================
3
+
4
+ Property lists are an Apple-specific serialization format:
5
+
6
+ <https://en.wikipedia.org/wiki/Property_list>
7
+
8
+ They come in several formats - XML, binary and a funky "old" / NeXTSTEP custom encoding.
9
+
10
+ We care about them because {file:doc/topics/launchd.md}'s {file:doc/topics/agents.md} are defined in plist files.
11
+
12
+ All the agent plists I've seen are in XML format, and that's what Loc'd writes and expects to find when reading.
13
+
14
+ For more info on launchd plists check out:
15
+
16
+ <https://developer.apple.com/legacy/library/documentation/Darwin/Reference/ManPages/man5/launchd.plist.5.html>
17
+
18
+
19
+ plists in Ruby
20
+ ------------------------------------------------------------------------------
21
+
22
+ Loc'd uses the [plist][] gem to read and write agent plists:
23
+
24
+ 1. [Plist.parse_xml][] to parse agent plist XML files to Ruby hashes.
25
+
26
+ 2. [Plist::Emit.dump][] to encode Ruby hashes into plist XML for writing.
27
+
28
+ When you see a `plist` in Loc'd it should be a Ruby hash that was read from an agent plist file and/or created or mutated to be written to an agent plist file (plists are a general data format, but the top-level object in agent plists should always be a `dict`, which is mapped to a Ruby hash).
29
+
30
+ The keys of these hashes should all be strings. For details on the possible Ruby value types see
31
+
32
+ <http://www.rubydoc.info/gems/plist#Generation>
33
+
34
+
35
+ <!-- References & Further Reading: -->
36
+
37
+ [plist]: https://rubygems.org/gems/plist
38
+
39
+ [Plist.parse_xml]: http://www.rubydoc.info/gems/plist/Plist.parse_xml
@@ -0,0 +1,3 @@
1
+ Ruby {Hash} representation of an agent's property list (`.plist`) file.
2
+
3
+ See {file:doc/topics/plists.md} for details about possible value types.
data/exe/locd ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ require 'locd'
6
+
7
+ # Setup logging *before* including {Locd::CLI} so that we can get
8
+ # trace/debug output from that setup if enabled via ENV vars
9
+ Locd::Logging.setup dest: $stderr, sync: true
10
+
11
+ # Logger for this file
12
+ logger = SemanticLogger[__FILE__]
13
+
14
+ require 'locd/cli'
15
+
16
+ begin
17
+ Locd::CLI::Command::Main.start
18
+ rescue Exception => e
19
+ if e.instance_of?(SystemExit)
20
+ raise
21
+ else
22
+ logger.fatal e
23
+ end
24
+ end
data/lib/locd.rb ADDED
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+ # encoding: UTF-8
3
+
4
+ # Requirements
5
+ # =======================================================================
6
+
7
+ # Stdlib
8
+ # -----------------------------------------------------------------------
9
+ require 'pp'
10
+ require 'pathname'
11
+ require 'shellwords'
12
+
13
+ # Deps
14
+ # -----------------------------------------------------------------------
15
+ require 'nrser'
16
+ require 'semantic_logger'
17
+ require 'cmds'
18
+
19
+ # Project / Package
20
+ # -----------------------------------------------------------------------
21
+ require 'locd/version'
22
+ require 'locd/errors'
23
+ require 'locd/config'
24
+ require 'locd/util'
25
+ require 'locd/logging'
26
+ require 'locd/label'
27
+ require 'locd/pattern'
28
+ require 'locd/launchctl'
29
+ require 'locd/newsyslog'
30
+ require 'locd/agent'
31
+ require 'locd/proxy'
32
+
33
+
34
+ # Refinements
35
+ # =======================================================================
36
+
37
+ using NRSER
38
+
39
+
40
+ # Definitions
41
+ # =======================================================================
42
+
43
+ module Locd
44
+
45
+ # Constants
46
+ # ======================================================================
47
+
48
+ # {Regexp} to match HTTP "Host" header line.
49
+ #
50
+ # @return [Regexp]
51
+ #
52
+ HOST_RE = /^Host\:\ /i
53
+
54
+
55
+ ROTATE_LOGS_LABEL = 'com.nrser.locd.rotate-logs'
56
+
57
+
58
+ # Mixins
59
+ # ============================================================================
60
+
61
+ # Add {.logger} and {#logger} methods
62
+ include SemanticLogger::Loggable
63
+
64
+
65
+ # Module Methods
66
+ # ======================================================================
67
+
68
+ # @return [Locd::Config]
69
+ # The configuration.
70
+ #
71
+ def self.config
72
+ @config ||= Locd::Config.new
73
+ end
74
+
75
+ end # module Locd
data/lib/locd/agent.rb ADDED
@@ -0,0 +1,1186 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Requirements
4
+ # =======================================================================
5
+
6
+ # Stdlib
7
+ # -----------------------------------------------------------------------
8
+
9
+ # Deps
10
+ # -----------------------------------------------------------------------
11
+ require 'plist'
12
+ require 'fileutils'
13
+
14
+ # Project / Package
15
+ # -----------------------------------------------------------------------
16
+
17
+
18
+ # Refinements
19
+ # =======================================================================
20
+
21
+ using NRSER
22
+ using NRSER::Types
23
+
24
+
25
+ # Definitions
26
+ # =======================================================================
27
+
28
+
29
+ # Represents a backend agent that the proxy can route to.
30
+ #
31
+ # Agents are managed by `launchd`, macOS's system service manager, and
32
+ # created on-demand by generating "property list" files (`.plist`, XML format)
33
+ # and installing them in the user's `~/Library/LaunchAgents` directory.
34
+ #
35
+ # From there they can be managed directly with macOS's `launchctl` utility,
36
+ # with the `lunchy` gem, etc.
37
+ #
38
+ class Locd::Agent
39
+
40
+ # Constants
41
+ # ==========================================================================
42
+
43
+ # Extra plist key we store Loc'd config under (this is where the port is
44
+ # stored). We also use it's presence to detect that a plist is Loc'd.
45
+ #
46
+ # @return [String]
47
+ #
48
+ CONFIG_KEY = 'locd_config'
49
+
50
+
51
+ # Attribute / method names that {#to_h} uses.
52
+ #
53
+ # @return [Hamster::SortedSet<Symbol>]
54
+ #
55
+ TO_H_NAMES = Hamster::SortedSet[:label, :path, :plist, :status]
56
+
57
+
58
+ # Mixins
59
+ # ==========================================================================
60
+
61
+ # Add {.logger} and {#logger} methods
62
+ include SemanticLogger::Loggable
63
+
64
+ # Make agents sortable by label
65
+ include Comparable
66
+
67
+
68
+ # Class Methods
69
+ # ==========================================================================
70
+
71
+ # Test if the parse of a property list is for a Loc'd agent by seeing if
72
+ # it has {CONFIG_KEY} as a key.
73
+ #
74
+ # @param [Hash<String, Object>] plist
75
+ # {include:file:doc/include/plist.md}
76
+ #
77
+ # @return [Boolean]
78
+ # `true` if the plist looks like it's from Loc'd.
79
+ #
80
+ def self.plist? plist
81
+ plist.key? CONFIG_KEY
82
+ end # .plist?
83
+
84
+
85
+ # @!group Computing Paths
86
+ # --------------------------------------------------------------------------
87
+
88
+ # Absolute path to `launchd` plist directory for the current user, which is
89
+ # an expansion of `~/Library/LaunchAgents`.
90
+ #
91
+ # @return [Pathname]
92
+ #
93
+ def self.user_plist_abs_dir
94
+ Pathname.new( '~/Library/LaunchAgents' ).expand_path
95
+ end
96
+
97
+
98
+ # Absolute path for the plist file given it's label, equivalent to expanding
99
+ # `~/Library/LaunchAgents/<label>.plist`.
100
+ #
101
+ # @param [String] label
102
+ # The agent's label.
103
+ #
104
+ # @return [Pathname]
105
+ #
106
+ def self.plist_abs_path label
107
+ user_plist_abs_dir / "#{ label }.plist"
108
+ end
109
+
110
+
111
+ # Default path for a {Locd::Agent} output file.
112
+ #
113
+ # @param [Pathname] workdir
114
+ # The working directory the agent will run in.
115
+ #
116
+ # @param [String] label
117
+ # The agent's label.
118
+ #
119
+ # @return [Pathname]
120
+ # If we could find a `tmp` directory walking up from `workdir`.
121
+ #
122
+ # @return [nil]
123
+ # If we could not find a `tmp` directory walking
124
+ #
125
+ def self.default_log_path workdir, label
126
+ tmp_dir = workdir.find_up 'tmp', test: :directory?, result: :path
127
+
128
+ if tmp_dir.nil?
129
+ logger.warn "Unable to find `tmp` dir, output will not be redirected",
130
+ workdir: workdir,
131
+ label: label,
132
+ stream: stream
133
+ return nil
134
+ end
135
+
136
+ unless tmp_dir.writable?
137
+ logger.warn \
138
+ "Found `tmp` dir, but not writable. Output will not be redirected",
139
+ workdir: workdir,
140
+ label: label,
141
+ stream: stream
142
+ return nil
143
+ end
144
+
145
+ tmp_dir / 'locd' / "#{ label }.log"
146
+ end
147
+
148
+
149
+ # Get the path to log `STDOUT` and `STDERR` to given the option value and other
150
+ # options we need to figure it out if that doesn't suffice.
151
+ #
152
+ # Note that we might not figure it out at all (returning `nil`), and that
153
+ # needs to be ok too (though it's not expected to happen all too often).
154
+ #
155
+ # @param [nil | String | Pathname] log_path:
156
+ # The value the user provided (if any).
157
+ #
158
+ # 1. `nil` - {.default_log_path} is called and it's result returned.
159
+ #
160
+ # 2. `String | Pathname` - Value is expanded against `workdir` and the
161
+ # resulting absolute path is returned (which of course may not exist).
162
+ #
163
+ # This means that absolute, home-relative (`~/` paths) and
164
+ # `workdir`-relative paths should all produce the expected results.
165
+ #
166
+ # @return [Pathname]
167
+ # Absolute path where the agent will try to log output.
168
+ #
169
+ # @return [nil]
170
+ # If the user doesn't supply a value and {.default_log_path} returns
171
+ # `nil`. The agent will not log output in this case.
172
+ #
173
+ def self.resolve_log_path log_path:, workdir:, label:
174
+ if log_path.nil?
175
+ default_log_path workdir, label
176
+ else
177
+ log_path.to_pn.expand_path workdir
178
+ end
179
+ end
180
+
181
+ # @!endgroup Computing Paths
182
+
183
+
184
+ # @!group Instantiating
185
+ # --------------------------------------------------------------------------
186
+
187
+ # Get the agent class that a plist should be instantiated as.
188
+ #
189
+ # @param [Hash<String, Object>] plist
190
+ # {include:file:doc/include/plist.md}
191
+ #
192
+ # @return [Class<Locd::Agent>]
193
+ # If the plist is for an agent of the current Loc'd configuration, the
194
+ # appropriate class to instantiate it as.
195
+ #
196
+ # Plists for user sites and jobs will always return their subclass.
197
+ #
198
+ # Plists for system agents will only return their class when they are
199
+ # for the current configuration's label namespace.
200
+ #
201
+ # @return [nil]
202
+ # If the plist is not for an agent of the current Loc'd configuration
203
+ # (and should be ignored by Loc'd).
204
+ #
205
+ def self.class_for plist
206
+ # 1. See if it's a Loc'd system agent plist
207
+ if system_class = Locd::Agent::System.class_for( plist )
208
+ return system_class
209
+ end
210
+
211
+ # 2. See if it's a Loc'd user agent plist
212
+ if user_class = [
213
+ Locd::Agent::Site,
214
+ Locd::Agent::Job,
215
+ ].find_bounded( max: 1 ) { |cls| cls.plist? plist }.first
216
+ return user_class
217
+ end
218
+
219
+ # 3. Return a vanilla agent if it's a plist for one
220
+ #
221
+ # Really, not sure when this would happen...
222
+ #
223
+ if Locd::Agent.plist?( plist )
224
+ return Locd::Agent
225
+ end
226
+
227
+ # Nada
228
+ nil
229
+ end # .class_for
230
+
231
+
232
+ # Instantiate a {Locd::Agent} from the path to it's `.plist` file.
233
+ #
234
+ # @param [Pathname | String] plist_path
235
+ # Path to the `.plist` file.
236
+ #
237
+ # @return [Locd::Agent]
238
+ #
239
+ def self.from_path plist_path
240
+ plist = Plist.parse_xml plist_path.to_s
241
+
242
+ class_for( plist ).new plist: plist, path: plist_path
243
+ end
244
+
245
+ # @!endgroup
246
+
247
+
248
+ # @!group Querying
249
+ # --------------------------------------------------------------------------
250
+
251
+ # All installed Loc'd agents.
252
+ #
253
+ # @return [Hamster::Hash<String, Locd::Agent>]
254
+ # Map of all Loc'd agents by label.
255
+ #
256
+ def self.plists
257
+ Pathname.glob( user_plist_abs_dir / '*.plist' ).
258
+ map { |path|
259
+ begin
260
+ [path, Plist.parse_xml( path.to_s )]
261
+ rescue Exception => error
262
+ logger.trace "{Plist.parse_xml} failed to parse plist",
263
+ path: path.to_s,
264
+ error: error,
265
+ backtrace: error.backtrace
266
+ nil
267
+ end
268
+ }.
269
+ compact.
270
+ select { |path, plist| plist? plist }.
271
+ thru( &Hamster::Hash.method( :new ) )
272
+ end
273
+
274
+
275
+ # All {Locd::Agent} that are instances of `self`.
276
+ #
277
+ # So, when invoked through {Locd::Agent.all} returns all agents.
278
+ #
279
+ # When invoked from a {Locd::Agent} subclass, returns all agents that are
280
+ # instances of *that subclass* - {Locd::Agent::Site.all} returns all
281
+ # {Locd::Agent} that are {Locd::Agent::Site} instances.
282
+ #
283
+ # @return [Hamster::Hash<String, Locd::Agent>]
284
+ # Map of agent {#label} to instance.
285
+ #
286
+ def self.all
287
+ # If we're being invoked through {Locd::Agent} itself actually find and
288
+ # load everyone
289
+ if self == Locd::Agent
290
+ plists.each_pair.map { |path, plist|
291
+ begin
292
+ agent_class = class_for plist
293
+
294
+ if agent_class.nil?
295
+ nil
296
+ else
297
+ agent = agent_class.new path: path, plist: plist
298
+ [agent.label, agent]
299
+ end
300
+
301
+ rescue Exception => error
302
+ logger.error "Failed to parse Loc'd Agent plist",
303
+ path: path,
304
+ error: error,
305
+ backtrace: error.backtrace
306
+
307
+ nil
308
+ end
309
+ }.
310
+ compact.
311
+ thru( &Hamster::Hash.method( :new ) )
312
+ else
313
+ # We're being invoked through a {Locd::Agent} subclass, so invoke
314
+ # through {Locd::Agent} and filter the results to instance of `self`.
315
+ Locd::Agent.all.select { |label, agent| agent.is_a? self }
316
+ end
317
+ end
318
+
319
+
320
+ # Labels for all installed Loc'd agents.
321
+ #
322
+ # @return [Hamster::Vector<String>]
323
+ #
324
+ def self.labels
325
+ all.keys.sort
326
+ end
327
+
328
+
329
+ # Get a Loc'd agent by it's label.
330
+ #
331
+ # @param [String] label
332
+ # The agent's label.
333
+ #
334
+ # @return [Locd::Agent]
335
+ # If a Loc'd agent with `label` is installed.
336
+ #
337
+ # @return [nil]
338
+ # If no Loc'd agent with `label` is installed.
339
+ #
340
+ def self.get label
341
+ path = plist_abs_path label
342
+
343
+ if path.file?
344
+ from_path path
345
+ end
346
+ end
347
+
348
+
349
+ # Find a single {Locd::Agent} matching `pattern` or raise.
350
+ #
351
+ # Parameters are passed to {Locd::Label.regexp_for_glob} and the resulting
352
+ # {Regexp} is matched against each agent's {#label}.
353
+ #
354
+ # @see Locd::Label.regexp_for_glob
355
+ #
356
+ # @param (see .find_all)
357
+ #
358
+ # @return [Locd::Agent]
359
+ #
360
+ def self.find_only! *find_all_args
361
+ find_all( *find_all_args ).values.to_a.only!
362
+ end
363
+
364
+
365
+ # Find all the agents that match a pattern.
366
+ #
367
+ # @see Locd::Label.regexp_for_glob
368
+ #
369
+ # @param [String | Pattern] pattern
370
+ # Pattern to match against agent.
371
+ #
372
+ # When it's a {String}, passed to {Locd::Pattern.from} to get the pattern.
373
+ #
374
+ # @param [**<Symbol, V>] options
375
+ # Passed to {Locd::Pattern.from} when `pattern` is a {String}.
376
+ #
377
+ # @return (see .all)
378
+ #
379
+ def self.find_all pattern, **options
380
+ pattern = Locd::Pattern.from pattern, **options
381
+ all.select { |label, agent| pattern.match? agent }
382
+ end
383
+
384
+ singleton_class.send :alias_method, :list, :find_all
385
+
386
+
387
+ # Just like {.find_all} but raises if result is empty.
388
+ #
389
+ # @param (see .find_all)
390
+ # @return (see .find_all)
391
+ #
392
+ # @raise [NRSER::CountError]
393
+ # If no agents were found.
394
+ #
395
+ def self.find_all! pattern, **options
396
+ pattern = Locd::Pattern.from pattern, **options
397
+
398
+ find_all( pattern ).tap { |agents|
399
+ if agents.empty?
400
+ raise NRSER::CountError.new(
401
+ "No agents found for pattern from #{ pattern.source.inspect }",
402
+ subject: agents,
403
+ expected: '#count > 0',
404
+ )
405
+ end
406
+ }
407
+ end # .find_all!
408
+
409
+ # @!endgroup
410
+
411
+
412
+ # @!group Creating Agents
413
+ # --------------------------------------------------------------------------
414
+
415
+ # Render a command string by substituting any `{name}` parts for their
416
+ # values.
417
+ #
418
+ # @example
419
+ # render_cmd \
420
+ # cmd_template: "serve --name={label} --port={port}",
421
+ # label: 'server.test',
422
+ # workdir: Pathname.new( '~' ).expand_path,
423
+ # port: 1234
424
+ # # => "server --name=server.test --port=1234"
425
+ #
426
+ # @param [String | Array<String>] cmd_template:
427
+ # Template for the string. Arrays are just rendered per-entry then
428
+ # joined.
429
+ #
430
+ # @param [String] label:
431
+ # The agent's {#label}.
432
+ #
433
+ # @param [Pathname] workdir:
434
+ # The agent's {#workdir}.
435
+ #
436
+ # @param [Hash<Symbol, #to_s>] **extras
437
+ # Subclass-specific extra keys and values to make available. Values will
438
+ # be converted to strings via `#to_s` before substitution.
439
+ #
440
+ # @return [String]
441
+ # Command string with substitutions made.
442
+ #
443
+ def self.render_cmd cmd_template:, label:, workdir:, **extras
444
+ t.match cmd_template,
445
+ Array, ->( array ) {
446
+ array.map {|arg|
447
+ render_cmd \
448
+ cmd_template: arg,
449
+ label: label,
450
+ workdir: workdir,
451
+ **extras
452
+ }.shelljoin
453
+ },
454
+
455
+ String, ->( string ) {
456
+ {
457
+ label: label,
458
+ workdir: workdir,
459
+ **extras,
460
+ }.reduce( string ) do |cmd, (key, value)|
461
+ cmd.gsub "{#{ key }}", value.to_s.shellescape
462
+ end
463
+ }
464
+ end
465
+
466
+
467
+ # Create the `launchd` property list data for a new {Locd::Agent}.
468
+ #
469
+ # @param [nil | String | Pathname] log_path:
470
+ # Optional path to log agent standard outputs to (combined `STDOUT` and
471
+ # `STDERR`).
472
+ #
473
+ # See {.resolve_log_path} for details on how the different types and
474
+ # values are treated.
475
+ #
476
+ # @return [Hash<String, Object>]
477
+ # {include:file:doc/include/plist.md}
478
+ #
479
+ def self.create_plist_data cmd_template:,
480
+ label:,
481
+ workdir:,
482
+ log_path: nil,
483
+ keep_alive: false,
484
+ run_at_load: false,
485
+ **extras
486
+ # Configure daemon variables...
487
+
488
+ # Normalize `workdir` to an expanded {Pathname}
489
+ workdir = workdir.to_pn.expand_path
490
+
491
+ # Resolve the log (`STDOUT` & `STDERR`) path
492
+ log_path = resolve_log_path(
493
+ log_path: log_path,
494
+ workdir: workdir,
495
+ label: label,
496
+ ).to_s
497
+
498
+ # Interpolate variables into command template
499
+ cmd = render_cmd(
500
+ cmd_template: cmd_template,
501
+ label: label,
502
+ workdir: workdir,
503
+ **extras
504
+ )
505
+
506
+ # Form the property list hash
507
+ {
508
+ # Unique label, format: `locd.<owner>.<name>.<agent.path...>`
509
+ 'Label' => label,
510
+
511
+ # What to run
512
+ 'ProgramArguments' => [
513
+ # TODO Maybe this should be configurable or smarter in some way?
514
+ 'bash',
515
+ # *login* shell... need this to source the user profile and set up
516
+ # all the ENV vars
517
+ '-l',
518
+ # Run the command in the login shell
519
+ '-c', cmd,
520
+ ],
521
+
522
+ # Directory to run the command in
523
+ 'WorkingDirectory' => workdir.to_s,
524
+
525
+ # Where to send STDOUT
526
+ 'StandardOutPath' => log_path,
527
+
528
+ # Where to send STDERR (we send both to the same file)
529
+ 'StandardErrorPath' => log_path,
530
+
531
+ # Bring the process back up if it goes down (has backoff and stuff
532
+ # built-in)
533
+ 'KeepAlive' => keep_alive,
534
+
535
+ # Start the process when the plist is loaded
536
+ 'RunAtLoad' => run_at_load,
537
+
538
+ 'ProcessType' => 'Interactive',
539
+
540
+ # Extras we need... `launchd` complains in the system log about this
541
+ # but it's the easiest way to handle it at the moment
542
+ CONFIG_KEY => {
543
+ # Save this too why the hell not, might help debuging at least
544
+ cmd_template: cmd_template,
545
+
546
+ # Add subclass-specific extras
547
+ **extras,
548
+ }.str_keys,
549
+
550
+ # Stuff that *doesn't* work... so you don't try it again, because
551
+ # Apple's online docs seems totally out of date.
552
+ #
553
+ # Not allowed for user agents
554
+ # 'UserName' => ENV['USER'],
555
+ #
556
+ # "The Debug key is no longer respected. Please remove it."
557
+ # 'Debug' => true,
558
+ #
559
+ # Yeah, it would have been nice to just use the plist to store the port,
560
+ # but this runs into all sorts of impenetrable security mess... gotta
561
+ # put it somewhere else! Weirdly enough it just totally works outside
562
+ # of here, so I'm not what the security is really stopping..?
563
+ #
564
+ # 'Sockets' => {
565
+ # 'Listeners' => {
566
+ # 'SockNodeName' => BIND,
567
+ # 'SockServiceName' => port,
568
+ # 'SockType' => 'stream',
569
+ # 'SockFamily' => 'IPv4',
570
+ # },
571
+ # },
572
+ }.reject { |key, value| value.nil? } # Drop any `nil` values
573
+ end
574
+
575
+
576
+ # Add an agent, writing a `.plist` to `~/Library/LaunchAgents`.
577
+ #
578
+ # Does not start the agent.
579
+ #
580
+ # @param [String] label:
581
+ # The agent's label, which is its:
582
+ #
583
+ # 1. Unique identifier in launchd
584
+ # 2. Domain via the proxy.
585
+ # 3. Property list filename (plus the `.plist` extension).
586
+ #
587
+ # @param [Boolean] force:
588
+ # Overwrite any existing agent with the same label.
589
+ #
590
+ # @param [String | Pathname] workdir:
591
+ # Working directory for the agent.
592
+ #
593
+ # @param [Hash<Symbol, Object>] **kwds
594
+ # Additional keyword arguments to pass to {.create_plist_data}.
595
+ #
596
+ # @return [Locd::Agent]
597
+ # The new agent.
598
+ #
599
+ def self.add label:,
600
+ force: false,
601
+ workdir: Pathname.getwd,
602
+ **kwds
603
+ logger.debug "Creating {Agent}...",
604
+ label: label,
605
+ force: force,
606
+ workdir: workdir,
607
+ **kwds
608
+
609
+ plist_abs_path = self.plist_abs_path label
610
+
611
+ # Handle file already existing
612
+ if File.exists? plist_abs_path
613
+ logger.debug "Agent already exists!",
614
+ label: label,
615
+ path: plist_abs_path
616
+
617
+ if force
618
+ logger.info "Forcing agent creation (overwrite)",
619
+ label: label,
620
+ path: plist_abs_path
621
+ else
622
+ raise binding.erb <<~END
623
+ Agent <%= label %> already exists at:
624
+
625
+ <%= plist_abs_path %>
626
+
627
+ END
628
+ end
629
+ end
630
+
631
+ plist_data = create_plist_data label: label, workdir: workdir, **kwds
632
+ logger.debug "Property list created", data: plist_data
633
+
634
+ plist_string = Plist::Emit.dump plist_data
635
+ plist_abs_path.write plist_string
636
+ logger.debug "Property list written", path: plist_abs_path
637
+
638
+ from_path( plist_abs_path ).tap { |agent|
639
+ logger.debug { ["{Agent} added", agent: agent] }
640
+ }
641
+ end # .add
642
+
643
+
644
+ # @!group Removing Agents
645
+ # ----------------------------------------------------------------------------
646
+
647
+ # Find an agent using a label pattern and call {#remove} on it.
648
+ #
649
+ # @param (see .find_only!)
650
+ # @return (see #remove)
651
+ # @raise See {.find_only!} and {#remove}
652
+ #
653
+ def self.remove *find_only_args
654
+ Locd::Agent.find_only!( *find_only_args ).remove
655
+ end # .remove
656
+
657
+ # @!endgroup
658
+
659
+
660
+ # Attributes
661
+ # ============================================================================
662
+
663
+ # Hash of the agent's `.plist` file (keys and values from the top-level
664
+ # `<dict>` element).
665
+ #
666
+ # @return [Hash<String, V>]
667
+ # Check out the [plist][] gem for an idea of what types `V` may assume.
668
+ #
669
+ # [plist]: http://www.rubydoc.info/gems/plist
670
+ #
671
+ attr_reader :plist
672
+
673
+
674
+ # Absolute path to the agent's `.plist` file.
675
+ #
676
+ # @return [Pathname]
677
+ #
678
+ attr_reader :path
679
+
680
+
681
+ # Constructor
682
+ # ============================================================================
683
+
684
+ def initialize plist:, path:
685
+ @path = path.to_pn.expand_path
686
+ @plist = plist
687
+ @status = :UNKNOWN
688
+
689
+
690
+ # Sanity check...
691
+
692
+ unless plist.key? CONFIG_KEY
693
+ raise ArgumentError.new binding.erb <<~END
694
+ Not a Loc'd plist (no <%= Locd::Agent::CONFIG_KEY %> key)
695
+
696
+ path: <%= path %>
697
+ plist:
698
+
699
+ <%= plist.pretty_inspect %>
700
+
701
+ END
702
+ end
703
+
704
+ unless @path.basename( '.plist' ).to_s == label
705
+ raise ArgumentError.new binding.erb <<~END
706
+ Label and filename don't match.
707
+
708
+ Filename should be `<label>.plist`, found
709
+
710
+ label: <%= label %>
711
+ filename: <%= @path.basename %>
712
+ path: <%= path %>
713
+
714
+ END
715
+ end
716
+
717
+ init_ensure_out_dirs_exist
718
+ end
719
+
720
+
721
+ # Instance Methods
722
+ # ============================================================================
723
+
724
+ # @return [Boolean]
725
+ # `true` if the {#log_path} is the default one we generate.
726
+ def default_log_path?
727
+ log_path == self.class.default_log_path( workdir, label )
728
+ end
729
+
730
+
731
+ # @!group Instance Methods: Attribute Readers
732
+ # ----------------------------------------------------------------------------
733
+ #
734
+ # Methods to read proxied and computed attributes.
735
+ #
736
+
737
+ # @return [String]
738
+ #
739
+ def label
740
+ plist['Label'].freeze
741
+ end
742
+
743
+
744
+ # Current process ID of the agent (if running).
745
+ #
746
+ # @param (see #status)
747
+ #
748
+ # @return [nil]
749
+ # No process ID (not running).
750
+ #
751
+ # @return [Fixnum]
752
+ # Process ID.
753
+ #
754
+ def pid refresh: false
755
+ status( refresh: refresh ) && status[:pid]
756
+ end
757
+
758
+
759
+ def running? refresh: false
760
+ status( refresh: refresh )[:running]
761
+ end
762
+
763
+
764
+ def stopped? refresh: false
765
+ !running?( refresh: refresh )
766
+ end
767
+
768
+ #
769
+ #
770
+ # @return [nil]
771
+ # No last exit code information.
772
+ #
773
+ # @return [Fixnum]
774
+ # Last exit code of process.
775
+ #
776
+ def last_exit_code refresh: false
777
+ status( refresh: refresh ) && status[:last_exit_code]
778
+ end
779
+
780
+
781
+ def config
782
+ plist[CONFIG_KEY]
783
+ end
784
+
785
+
786
+ def cmd_template
787
+ config['cmd_template']
788
+ end
789
+
790
+
791
+ # @return [Pathname]
792
+ # The working directory of the agent.
793
+ def workdir
794
+ plist['WorkingDirectory'].to_pn
795
+ end
796
+
797
+
798
+ # Path the agent is logging `STDOUT` to.
799
+ #
800
+ # @return [Pathname]
801
+ # If the agent is logging `STDOUT` to a file path.
802
+ #
803
+ # @return [nil]
804
+ # If the agent is not logging `STDOUT`.
805
+ #
806
+ def out_path
807
+ plist['StandardOutPath'].to_pn if plist['StandardOutPath']
808
+ end
809
+
810
+ alias_method :log_path, :out_path
811
+
812
+
813
+ # Path the agent is logging `STDERR` to.
814
+ #
815
+ # @return [Pathname]
816
+ # If the agent is logging `STDERR` to a file path.
817
+ #
818
+ # @return [nil]
819
+ # If the agent is not logging `STDERR`.
820
+ #
821
+ def err_path
822
+ plist['StandardErrorPath'].to_pn if plist['StandardErrorPath']
823
+ end
824
+
825
+ # @!endgroup Instance Methods: Attribute Readers
826
+
827
+
828
+ # @!group Instance Methods: `launchctl` Interface
829
+ # ----------------------------------------------------------------------------
830
+
831
+ # The agent's status from parsing `launchctl list`.
832
+ #
833
+ # Status is read on demand and cached on the instance.
834
+ #
835
+ # @param [Boolean] refresh:
836
+ # When `true`, will re-read from `launchd` (and cache results)
837
+ # before returning.
838
+ #
839
+ # @return [Hash{pid: (Fixnum | nil), status: (Fixnum | nil)}]
840
+ # When `launchd` has a status entry for the agent.
841
+ #
842
+ # @return [nil]
843
+ # When `launchd` doesn't have a status entry for the agent.
844
+ #
845
+ def status refresh: false
846
+ if refresh || @status == :UNKNOWN
847
+ raw_status = Locd::Launchctl.status[label]
848
+
849
+ # Happens when the agent is not loaded
850
+ @status = if raw_status.nil?
851
+ {
852
+ loaded: false,
853
+ running: false,
854
+ pid: nil,
855
+ last_exit_code: nil,
856
+ }
857
+ else
858
+ {
859
+ loaded: true,
860
+ running: !raw_status[:pid].nil?,
861
+ pid: raw_status[:pid],
862
+ last_exit_code: raw_status[:status],
863
+ }
864
+ end
865
+ end
866
+
867
+ @status
868
+ end
869
+
870
+
871
+ # Load the agent by executing `launchctl load [OPTIONS] LABEL`.
872
+ #
873
+ # This is a bit low-level; you probably want to use {#start}.
874
+ #
875
+ # @param [Boolean] force:
876
+ # Force the loading of the plist. Ignore the `launchd` *Disabled* key.
877
+ #
878
+ # @param [Boolean] write:
879
+ # Overrides the `launchd` *Disabled* key and sets it to `false`.
880
+ #
881
+ # @return [self]
882
+ #
883
+ def load force: false, write: false
884
+ logger.debug "Loading #{ label } agent...", force: force, write: write
885
+
886
+ result = Locd::Launchctl.load! path, force: force, write: write
887
+
888
+ message = if result.err =~ /service\ already\ loaded/
889
+ "already loaded"
890
+ else
891
+ "LOADED"
892
+ end
893
+
894
+ status_info message, status: status
895
+
896
+ self
897
+ end
898
+
899
+
900
+ # Unload the agent by executing `launchctl unload [OPTIONS] LABEL`.
901
+ #
902
+ # This is a bit low-level; you probably want to use {#stop}.
903
+ #
904
+ # @param [Boolean] write:
905
+ # Overrides the `launchd` *Disabled* key and sets it to `true`.
906
+ #
907
+ # @return [self]
908
+ #
909
+ def unload write: false
910
+ logger.debug "Unloading #{ label } agent...", write: write
911
+
912
+ result = Locd::Launchctl.unload! path, write: write
913
+
914
+ status_info "UNLOADED"
915
+
916
+ self
917
+ end
918
+
919
+
920
+ # {#unload} then {#load} the agent.
921
+ #
922
+ # @param (see #load)
923
+ # @return (see #load)
924
+ #
925
+ def reload force: false, write: false
926
+ unload
927
+ load force: force, write: write
928
+ end
929
+
930
+
931
+ # Start the agent.
932
+ #
933
+ # If `load` is `true`, calls {#load} first, and defaults it's `force` keyword
934
+ # argument to `true` (the idea being that you actually want the agent to
935
+ # start, even if it's {#disabled?}).
936
+ #
937
+ # @param [Boolean] load:
938
+ # Call {#load} first, passing it `write` and `force`.
939
+ #
940
+ # @param force (see #load)
941
+ # @param write (see #load)
942
+ #
943
+ # @return [self]
944
+ #
945
+ def start load: true, force: true, write: false
946
+ logger.trace "Starting `#{ label }` agent...",
947
+ load: load,
948
+ force: force,
949
+ write: write
950
+
951
+ self.load( force: force, write: write ) if load
952
+
953
+ Locd::Launchctl.start! label
954
+ status_info "STARTED"
955
+
956
+ self
957
+ end
958
+
959
+
960
+ # Stop the agent.
961
+ #
962
+ # @param [Boolean] unload:
963
+ # Call {#unload} first, passing it `write`.
964
+ #
965
+ # @param write (see #unload)
966
+ #
967
+ # @return [self]
968
+ #
969
+ def stop unload: true, write: false
970
+ logger.debug "Stopping `#{ label } agent...`",
971
+ unload: unload,
972
+ write: write
973
+
974
+ Locd::Launchctl.stop label
975
+ status_info "STOPPED"
976
+
977
+ self.unload( write: write ) if unload
978
+
979
+ self
980
+ end
981
+
982
+
983
+ # Restart the agent ({#stop} then {#start}).
984
+ #
985
+ # @param unload (see #stop)
986
+ # @param force (see #start)
987
+ # @param write (see #start)
988
+ #
989
+ # @return [self]
990
+ #
991
+ def restart unload: true, force: true, write: false
992
+ stop unload: unload
993
+ start load: unload, force: force, write: write
994
+ end # #restart
995
+
996
+
997
+ # Remove the agent by removing it's {#path} file. Will {#stop} and
998
+ # {#unloads} the agent first.
999
+ #
1000
+ # @return [self]
1001
+ #
1002
+ def remove
1003
+ stop unload: true
1004
+
1005
+ FileUtils.rm path
1006
+ status_info "REMOVED"
1007
+
1008
+ self
1009
+ end
1010
+
1011
+ # @!endgroup Instance Methods: `launchctl` Interface
1012
+
1013
+
1014
+ # Update specific values on the agent, which *may* change it's file path if
1015
+ # a different label is provided.
1016
+ #
1017
+ # **_Does not mutate this instance! Returns a new {Locd::Agent} with the
1018
+ # updated values._**
1019
+ #
1020
+ # @param (see .create_plist_data)
1021
+ #
1022
+ # @return [Locd::Agent]
1023
+ # A new instance with the updated values.
1024
+ #
1025
+ def update **values
1026
+ logger.trace "Updating `#{ label }` agent", **values
1027
+
1028
+ # Remove the `cmd_template` if it's nil of an empty array so that
1029
+ # we use the current one
1030
+ if values[:cmd_template].nil? ||
1031
+ (values[:cmd_template].is_a?( Array ) && values[:cmd_template].empty?)
1032
+ values.delete :cmd_template
1033
+ end
1034
+
1035
+ # Make a new plist
1036
+ new_plist_data = self.class.create_plist_data(
1037
+ label: label,
1038
+ workdir: workdir,
1039
+
1040
+ log_path: (
1041
+ values.key?( :log_path ) ? values.key?( :log_path ) : (
1042
+ default_log_path? ? nil : log_path
1043
+ )
1044
+ ),
1045
+
1046
+ # Include the config values, which have the `cmd_template` as well as
1047
+ # any extras. Need to symbolize the keys to make the kwds call work
1048
+ **config.sym_keys,
1049
+
1050
+ # Now merge over with the values we received
1051
+ **values.except( :log_path )
1052
+ )
1053
+
1054
+ new_label = new_plist_data['Label']
1055
+ new_plist_abs_path = self.class.plist_abs_path new_label
1056
+
1057
+ if new_label == label
1058
+ # We maintained the same label, overwrite the file
1059
+ path.write Plist::Emit.dump( new_plist_data )
1060
+
1061
+ # Load up the new agent from the same path, reload and return it
1062
+ self.class.from_path( path ).reload
1063
+
1064
+ else
1065
+ # The label has changed, so we want to make sure we're not overwriting
1066
+ # another agent
1067
+
1068
+ if File.exists? new_plist_abs_path
1069
+ # There's someone already there! Bail out
1070
+ raise binding.erb <<-END
1071
+ A different agent already exists at:
1072
+
1073
+ <%= new_plist_abs_path %>
1074
+
1075
+ Remove that agent first if you really want to replace it with an
1076
+ updated version of this one.
1077
+
1078
+ END
1079
+ end
1080
+
1081
+ # Ok, we're in the clear (save for the obvious race condition, but,
1082
+ # hey, it's a development tool, so fuck it... it's not even clear it's
1083
+ # possible to do an atomic file add from Ruby)
1084
+ new_plist_abs_path.write Plist::Emit.dump( new_plist_data )
1085
+
1086
+ # Remove this agent
1087
+ remove
1088
+
1089
+ # And instantiate and load a new agent from the new path
1090
+ self.class.from_path( new_plist_abs_path ).load
1091
+
1092
+ end
1093
+ end # #update
1094
+
1095
+
1096
+
1097
+ # @!group Instance Methods: Language Integration
1098
+ # ----------------------------------------------------------------------------
1099
+
1100
+ # Compare to another agent by their labels.
1101
+ #
1102
+ # @param [Locd::Agent] other
1103
+ # The other agent.
1104
+ #
1105
+ # @return [Fixnum]
1106
+ #
1107
+ def <=> other
1108
+ Locd::Label.compare label, other.label
1109
+ end
1110
+
1111
+
1112
+ def to_h
1113
+ self.class::TO_H_NAMES.map { |name|
1114
+ [name, send( name )]
1115
+ }.to_h
1116
+ end
1117
+
1118
+
1119
+ def to_json *args
1120
+ to_h.to_json *args
1121
+ end
1122
+
1123
+
1124
+ # TODO Doesn't work!
1125
+ #
1126
+ def to_yaml *args
1127
+ to_h.to_yaml *args
1128
+ end
1129
+
1130
+ # @!endgroup Language Integration Instance Methods
1131
+
1132
+
1133
+ protected
1134
+ # ========================================================================
1135
+
1136
+ # Create directories for any output file if they don't already exist.
1137
+ #
1138
+ # We *need* to create the directories for the output files *before*
1139
+ # we ever try to start the agent because otherwise it seems that
1140
+ # `launchd` *will create them*, but it will *create them as `root`*, then
1141
+ # try to write to them *as the user*, fail, and crash the process with
1142
+ # a `78` exit code.
1143
+ #
1144
+ # How or why that makes sense to `launchd`, who knows.
1145
+ #
1146
+ # But, since it's nice to be able to start the agents through `launchctl`
1147
+ # or `lunchy`, and having a caveat that *they need to be started through
1148
+ # Loc'd the first time* sucks, during initialization seems like a
1149
+ # reasonable time, at least for now (I don't like doing stuff like this
1150
+ # curing construction, but whatever for the moment, let's get it working).
1151
+ #
1152
+ # @return [nil]
1153
+ #
1154
+ def init_ensure_out_dirs_exist
1155
+ [out_path, err_path].compact.each do |path|
1156
+ dir = path.dirname
1157
+ unless dir.exist?
1158
+ logger.debug "Creating directory for output",
1159
+ label: label,
1160
+ dir: dir,
1161
+ path: path
1162
+ FileUtils.mkdir_p dir
1163
+ end
1164
+ end
1165
+
1166
+ nil
1167
+ end
1168
+
1169
+
1170
+ def status_info message, **details
1171
+ logger.info "Agent `#{ label }` #{ message }", **details
1172
+ end
1173
+
1174
+ # end protected
1175
+
1176
+ end # class Locd::Launchd
1177
+
1178
+
1179
+ # Post-Processing
1180
+ # =======================================================================
1181
+
1182
+ require_relative './agent/site'
1183
+ require_relative './agent/job'
1184
+ require_relative './agent/system'
1185
+ require_relative './agent/proxy'
1186
+ require_relative './agent/rotate_logs'