locd 0.1.3

Sign up to get free protection for your applications and to get access to all the features.
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'