monkrb 0.21.1 → 0.22.1

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -0
  3. data/README.md +4 -4
  4. data/lib/monk/auth/errors.rb +5 -0
  5. data/lib/monk/auth/sweep.rb +23 -0
  6. data/lib/monk/auth.rb +101 -6
  7. data/lib/monk/bin.rb +94 -0
  8. data/lib/monk/generators/auth.rb +1 -0
  9. data/lib/monk/generators/jobs.rb +3 -1
  10. data/lib/monk/jobs/adapters/pg.rb +90 -2
  11. data/lib/monk/jobs/cron.rb +180 -0
  12. data/lib/monk/jobs/errors.rb +21 -0
  13. data/lib/monk/jobs/firing.rb +11 -0
  14. data/lib/monk/jobs/recurring_task.rb +9 -0
  15. data/lib/monk/jobs/runtime.rb +38 -2
  16. data/lib/monk/jobs/scheduler.rb +91 -0
  17. data/lib/monk/jobs.rb +87 -8
  18. data/lib/monk/live/session.rb +16 -9
  19. data/lib/monk/templates/auth/agents.md +10 -3
  20. data/lib/monk/templates/auth/app/routes/auth.rb +4 -8
  21. data/lib/monk/templates/auth/config/auth.rb +1 -0
  22. data/lib/monk/templates/auth/db/migrate/00000000000004_create_login_requests.down.sql +2 -0
  23. data/lib/monk/templates/auth/db/migrate/00000000000004_create_login_requests.up.sql +16 -0
  24. data/lib/monk/templates/auth/setup.md +16 -3
  25. data/lib/monk/templates/auth/test/auth_test.rb +6 -0
  26. data/lib/monk/templates/base/Gemfile +1 -1
  27. data/lib/monk/templates/base/config/load.rb +5 -0
  28. data/lib/monk/templates/jobs/agents.md +15 -4
  29. data/lib/monk/templates/jobs/config/recurring.rb +21 -0
  30. data/lib/monk/templates/jobs/db/migrate/00000000000003_create_recurring_tables.down.sql +1 -0
  31. data/lib/monk/templates/jobs/db/migrate/00000000000003_create_recurring_tables.up.sql +13 -0
  32. data/lib/monk/templates/jobs/setup.md +20 -2
  33. data/lib/monk/templates/websocket/bin/websocket_server +14 -0
  34. data/lib/monk/templates/websocket/setup.md +2 -1
  35. data/lib/monk/version.rb +1 -1
  36. data/lib/monk/websocket/handshake.rb +32 -9
  37. data/lib/monk/websocket/pg_fanout.rb +16 -7
  38. data/lib/monk/websocket/redis_fanout.rb +16 -7
  39. data/lib/monk/websocket/registry.rb +40 -5
  40. data/lib/monk/websocket/server.rb +211 -47
  41. metadata +11 -1
@@ -55,14 +55,49 @@ module Monk
55
55
  # hostile client on a public endpoint, not an opt-in behavior
56
56
  # change, so a server that doesn't mention it still gets the
57
57
  # default cap rather than no cap at all.
58
+ #
59
+ # max_handshakes: caps the connections still in their handshake (the
60
+ # HTTP upgrade and, with authenticate:, verifying the session). At the
61
+ # cap the accept loop stops accepting until one finishes, and new
62
+ # connects wait in the kernel's listen backlog. Without it, a burst
63
+ # past what the process can verify (a restart's reconnect wave) starts
64
+ # every handshake at once, fills db_pool:'s queue, and ends with most
65
+ # of them timing out instead of the rest queueing
66
+ # (docs/plan-ws-connect-ceiling.md, item 1). Kept well under the
67
+ # pool's queue (1,000 by default), so a full cap can't overfill it.
68
+ # Always on, like max_payload_size:.
69
+ #
70
+ # handshake_timeout: (seconds) bounds the whole handshake, from
71
+ # accept to the 101, and Handshake::MAX_REQUEST_SIZE its request; a
72
+ # client past the deadline is dropped, one past the size gets a 431.
73
+ # Without them, a socket that connects and sends nothing holds a
74
+ # max_handshakes: slot forever, so a few hundred of them would shut
75
+ # the server, and a request that never ends grows without limit
76
+ # (item 2). Off once the 101 is written: an idle socket is the
77
+ # handler's business, not the handshake's.
78
+ #
79
+ # reuse_port: true lets several processes listen on the one port, and
80
+ # on Linux the kernel spreads connects across them. Each has its own
81
+ # heap, so its own GC pauses, over a share of the sockets: on Ruby 4.0
82
+ # K processes cut what GC costs a connect by about K squared
83
+ # (item 8). A broadcast reaches all of them through the fan-out.
58
84
  AUTHENTICATE_MODES = [false, true, :optional].freeze
59
85
 
86
+ DEFAULT_MAX_HANDSHAKES = 128
87
+ DEFAULT_HANDSHAKE_TIMEOUT = 10
88
+
89
+ # What the handshake's verify returns when it raised instead of
90
+ # answering (the pool full or timed out, the database down): neither
91
+ # a subject nor "no session", so the connection gets a 503.
92
+ VERIFY_FAILED = Object.new.freeze
93
+
60
94
  # The signals #run treats as "stop": Ctrl+C and TERM.
61
95
  STOP_SIGNALS = [Signal.list.fetch("INT"), Signal.list.fetch("TERM")].freeze
62
96
 
63
97
  def initialize(
64
98
  port:, bind: "0.0.0.0", allowed_origins: nil, authenticate: false, ping_interval: nil,
65
- reverify_interval: nil, max_payload_size: Connection::DEFAULT_MAX_PAYLOAD_SIZE, db_pool: nil
99
+ reverify_interval: nil, max_payload_size: Connection::DEFAULT_MAX_PAYLOAD_SIZE, db_pool: nil,
100
+ max_handshakes: DEFAULT_MAX_HANDSHAKES, handshake_timeout: DEFAULT_HANDSHAKE_TIMEOUT, reuse_port: false
66
101
  )
67
102
  if ping_interval && !ping_interval.positive?
68
103
  raise ArgumentError, "ping_interval must be positive, got #{ping_interval.inspect}"
@@ -85,6 +120,14 @@ module Monk
85
120
  raise ArgumentError, "max_payload_size must be positive, got #{max_payload_size.inspect}"
86
121
  end
87
122
 
123
+ unless max_handshakes.is_a?(Integer) && max_handshakes.positive?
124
+ raise ArgumentError, "max_handshakes must be a positive Integer, got #{max_handshakes.inspect}"
125
+ end
126
+
127
+ unless handshake_timeout.is_a?(Numeric) && handshake_timeout.positive?
128
+ raise ArgumentError, "handshake_timeout must be positive, got #{handshake_timeout.inspect}"
129
+ end
130
+
88
131
  if authenticate
89
132
  unless defined?(Monk::Auth) && Monk::Auth.config
90
133
  raise Monk::AuthNotConfiguredError,
@@ -106,19 +149,46 @@ module Monk
106
149
  # declared or isn't started in this process.
107
150
  Monk::Persistence::Pg.pool(db_pool) if db_pool
108
151
 
109
- @tcp_server = TCPServer.new(bind, port)
152
+ @tcp_server = reuse_port ? Server.listen_reusing_port(bind, port) : TCPServer.new(bind, port)
110
153
  @allowed_origins = allowed_origins ? Ractor.make_shareable(allowed_origins.dup) : nil
111
154
  @authenticate = authenticate
112
155
  @ping_interval = ping_interval
113
156
  @reverify_interval = reverify_interval
114
157
  @max_payload_size = max_payload_size
115
158
  @db_pool = db_pool
159
+ @max_handshakes = max_handshakes
160
+ @handshake_timeout = handshake_timeout
116
161
  end
117
162
 
118
163
  def port
119
164
  @tcp_server.addr[1]
120
165
  end
121
166
 
167
+ # reuse_port:, for several processes on one port (bin/websocket_server's
168
+ # WS_PROCESSES): SO_REUSEPORT has to be set before the bind, which
169
+ # TCPServer.new does at once. On Linux the kernel spreads connects
170
+ # across the processes listening.
171
+ def self.listen_reusing_port(bind, port)
172
+ unless defined?(Socket::SO_REUSEPORT)
173
+ raise ArgumentError, "reuse_port: true needs SO_REUSEPORT, which this platform doesn't have"
174
+ end
175
+
176
+ address = Addrinfo.tcp(bind, port)
177
+ socket = Socket.new(address.afamily, :STREAM)
178
+ socket.setsockopt(:SOCKET, :REUSEADDR, true)
179
+ socket.setsockopt(:SOCKET, :REUSEPORT, true)
180
+ socket.bind(address)
181
+ socket.listen(Socket::SOMAXCONN)
182
+ # A TCPServer on the same descriptor, so accept returns a TCPSocket
183
+ # as without reuse_port:.
184
+ socket.autoclose = false
185
+ TCPServer.for_fd(socket.fileno)
186
+ rescue StandardError
187
+ socket&.autoclose = true
188
+ socket&.close
189
+ raise
190
+ end
191
+
122
192
  def run(&block)
123
193
  shareable_block =
124
194
  begin
@@ -130,16 +200,30 @@ module Monk
130
200
  "top-level block -- mirrors StateRactor#update's own constraint)"
131
201
  end
132
202
 
203
+ # One slot a handshake in flight (max_handshakes:). Each connection
204
+ # Ractor sends to handshake_done once its handshake is over, however
205
+ # it ended, and the releaser thread frees a slot for it.
206
+ slots = Thread::SizedQueue.new(@max_handshakes)
207
+ handshake_done = Ractor::Port.new
208
+ releaser = Thread.new do
209
+ loop do
210
+ handshake_done.receive
211
+ slots.pop
212
+ end
213
+ end
214
+
133
215
  loop do
216
+ slots.push(true)
134
217
  socket = @tcp_server.accept
135
218
  ractor = Ractor.new(
136
219
  shareable_block, @authenticate, @allowed_origins, @ping_interval, @reverify_interval, @max_payload_size,
137
- @db_pool
138
- ) do |blk, authenticate, origins, ping_interval, reverify_interval, max_payload_size, db_pool|
220
+ @db_pool, handshake_done, @handshake_timeout
221
+ ) do |blk, authenticate, origins, ping_interval, reverify_interval, max_payload_size, db_pool, done, timeout|
139
222
  Monk::WebSocket::Server.serve(
140
223
  Ractor.receive, blk,
141
224
  authenticate: authenticate, allowed_origins: origins, ping_interval: ping_interval,
142
- reverify_interval: reverify_interval, max_payload_size: max_payload_size, db_pool: db_pool
225
+ reverify_interval: reverify_interval, max_payload_size: max_payload_size, db_pool: db_pool,
226
+ handshake_done: done, handshake_timeout: timeout
143
227
  )
144
228
  end
145
229
  ractor.send(socket, move: true)
@@ -161,45 +245,63 @@ module Monk
161
245
  # different failure point in this same shutdown -- a live
162
246
  # connection's cleanup racing the registry Ractor's teardown.
163
247
  @tcp_server.close
248
+ ensure
249
+ releaser&.kill
250
+ handshake_done&.close
164
251
  end
165
252
 
166
253
  # Runs entirely inside the connection's own dedicated Ractor
167
254
  # (docs/history/plan-websocket.md Decision 3) -- a class method, not an instance
168
255
  # method, since the Server instance itself (holding a live
169
256
  # TCPServer) is never Ractor-shareable and can't cross into here.
257
+ #
258
+ # handshake_done: is #run's port for max_handshakes:, told once the
259
+ # handshake is over: the 101 written, or the connection refused or
260
+ # dropped before it.
170
261
  def self.serve(
171
262
  socket, block, authenticate: false, allowed_origins: nil, ping_interval: nil, reverify_interval: nil,
172
- max_payload_size: Connection::DEFAULT_MAX_PAYLOAD_SIZE, db_pool: nil
263
+ max_payload_size: Connection::DEFAULT_MAX_PAYLOAD_SIZE, db_pool: nil, handshake_done: nil,
264
+ handshake_timeout: DEFAULT_HANDSHAKE_TIMEOUT
173
265
  )
174
- request = read_handshake_request(socket)
175
- return socket.close unless request
176
-
177
- headers =
178
- begin
179
- Handshake.parse_request(request)
180
- rescue Monk::WebSocket::HandshakeError
181
- return socket.write("HTTP/1.1 400 Bad Request\r\n\r\n")
266
+ # A `return` in here (a refused handshake) ends serve, and its
267
+ # ensure frees the slot.
268
+ credential, subject = with_deadline(socket, handshake_timeout) do
269
+ request = read_handshake_request(socket)
270
+ return unless request
271
+ return socket.write("HTTP/1.1 431 Request Header Fields Too Large\r\n\r\n") if request == :too_large
272
+
273
+ headers =
274
+ begin
275
+ Handshake.parse_request(request)
276
+ rescue Monk::WebSocket::HandshakeError
277
+ return socket.write("HTTP/1.1 400 Bad Request\r\n\r\n")
278
+ end
279
+
280
+ subject = nil
281
+ credential = Handshake.credential_from(headers) if authenticate
282
+ case authenticate
283
+ when true
284
+ subject = authenticate!(socket, headers, credential, allowed_origins, db_pool)
285
+ return unless subject
286
+ when :optional
287
+ return unless origin_allowed_for_optional?(socket, headers, credential, allowed_origins)
288
+
289
+ subject = identify(credential, db_pool)
290
+ # Not anonymous: that would quietly sign the user out of their
291
+ # topics until the next reconnect.
292
+ return unavailable!(socket, headers) if subject.equal?(VERIFY_FAILED)
182
293
  end
183
294
 
184
- subject = nil
185
- case authenticate
186
- when true
187
- subject = authenticate!(socket, headers, allowed_origins, db_pool)
188
- return unless subject
189
- when :optional
190
- return unless origin_allowed_for_optional?(socket, headers, allowed_origins)
191
-
192
- subject = identify(headers, db_pool)
295
+ socket.write(Handshake.response(headers))
296
+ [credential, subject]
193
297
  end
194
-
195
- socket.write(Handshake.response_for(request))
298
+ handshake_done = handshake_over(handshake_done)
196
299
 
197
300
  connection = Connection.new(socket, subject: subject, max_payload_size: max_payload_size)
198
301
  connection.start_heartbeat(ping_interval) if ping_interval
199
- reverify_thread =
200
- if reverify_interval && subject
201
- start_reverify(connection, Handshake.credential_from(headers)[:token], reverify_interval, db_pool)
202
- end
302
+ if reverify_interval && subject
303
+ reverify_thread = start_reverify(connection, credential[:token], reverify_interval, db_pool)
304
+ end
203
305
  begin
204
306
  block.call(connection)
205
307
  rescue StandardError
@@ -209,11 +311,16 @@ module Monk
209
311
  # not re-raised -- there's no caller left to hand it to once
210
312
  # we're inside this connection's own dedicated Ractor.
211
313
  end
314
+ rescue IOError, SystemCallError
315
+ # The client went away, or was shut out by the deadline, before
316
+ # the handshake was over: nothing to answer.
212
317
  ensure
213
318
  # Runs on every exit path -- normal completion, the close
214
319
  # handshake, and a crashed handler alike -- so a subscribed
215
320
  # connection never leaks a stale registry entry (step 17), a
216
321
  # heartbeat thread, or a reverify thread outliving its socket.
322
+ # A handshake that never reached its 101 still frees its slot.
323
+ handshake_over(handshake_done)
217
324
  reverify_thread&.kill
218
325
  connection&.unsubscribe!
219
326
  connection&.stop_heartbeat!
@@ -221,6 +328,35 @@ module Monk
221
328
  disconnect_all
222
329
  end
223
330
 
331
+ # Runs the block with a watchdog that shuts the socket down once
332
+ # `seconds` have passed, which ends a read or write blocked on it (a
333
+ # read returns nil, a write raises). Not IO#timeout=: on Ruby 4.0,
334
+ # once an IO#timeout has fired in one Ractor, reads in Ractors made
335
+ # after it can raise IO::TimeoutError on sockets with no timeout set.
336
+ def self.with_deadline(socket, seconds)
337
+ watchdog = Thread.new do
338
+ sleep seconds
339
+ socket.shutdown(:RDWR)
340
+ rescue IOError, SystemCallError
341
+ # Already closed or gone.
342
+ end
343
+ yield
344
+ ensure
345
+ watchdog&.kill
346
+ end
347
+ private_class_method :with_deadline
348
+
349
+ # Frees this connection's max_handshakes: slot in #run; returns nil,
350
+ # so serve can mark it freed. The port is gone once #run has
351
+ # returned, and then there's no slot to free.
352
+ def self.handshake_over(handshake_done)
353
+ handshake_done&.send(true)
354
+ nil
355
+ rescue Ractor::ClosedError
356
+ nil
357
+ end
358
+ private_class_method :handshake_over
359
+
224
360
  # Closes the connections this socket's Ractor opened: its database
225
361
  # connection (verifying the session without db_pool:, or a block
226
362
  # that queries) and a fan-out's publisher (a block that broadcasts).
@@ -239,10 +375,9 @@ module Monk
239
375
  # credential -- a Bearer connection has no Origin header to check
240
376
  # by construction (mirrors require_csrf!'s own Bearer exemption in
241
377
  # docs/history/plan-auth.md). Returns the verified subject, or nil after writing
242
- # the appropriate 403/401 response itself.
243
- def self.authenticate!(socket, headers, allowed_origins, db_pool)
244
- credential = Handshake.credential_from(headers)
245
-
378
+ # the appropriate 403/401 response itself -- or 503, when verify
379
+ # couldn't answer (docs/plan-ws-connect-ceiling.md, item 3).
380
+ def self.authenticate!(socket, headers, credential, allowed_origins, db_pool)
246
381
  if credential&.fetch(:via) == :cookie && allowed_origins
247
382
  origin = headers["origin"]
248
383
  unless origin && allowed_origins.include?(origin)
@@ -251,12 +386,26 @@ module Monk
251
386
  end
252
387
  end
253
388
 
254
- subject = credential && verify(credential[:token], db_pool)
389
+ subject = credential && handshake_verify(credential[:token], db_pool)
390
+ return unavailable!(socket, headers) if subject.equal?(VERIFY_FAILED)
391
+
255
392
  socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n") unless subject
256
393
  subject
257
394
  end
258
395
  private_class_method :authenticate!
259
396
 
397
+ # A handshake whose verify couldn't answer: 503, and nil. Browser JS
398
+ # never sees a failed handshake's status or headers (only onclose,
399
+ # 1006), so Retry-After is for the other clients, the ones without
400
+ # an Origin, and jittered, so a wave of them doesn't come back as
401
+ # one.
402
+ def self.unavailable!(socket, headers)
403
+ retry_after = headers["origin"] ? "" : "Retry-After: #{rand(1..10)}\r\n"
404
+ socket.write("HTTP/1.1 503 Service Unavailable\r\n#{retry_after}\r\n")
405
+ nil
406
+ end
407
+ private_class_method :unavailable!
408
+
260
409
  # authenticate: :optional's Origin check, before anything is verified.
261
410
  # A Bearer connection is exempt, as under true. A cookie needs an
262
411
  # allowed Origin, as under true. An anonymous connection that sends an
@@ -264,10 +413,9 @@ module Monk
264
413
  # stops another site's page from opening sockets here on its
265
414
  # visitors' behalf; one that sends none (not a browser) gets in.
266
415
  # Writes the 403 itself and returns false when refused.
267
- def self.origin_allowed_for_optional?(socket, headers, allowed_origins)
416
+ def self.origin_allowed_for_optional?(socket, headers, credential, allowed_origins)
268
417
  return true unless allowed_origins
269
418
 
270
- credential = Handshake.credential_from(headers)
271
419
  return true if credential&.fetch(:via) == :bearer
272
420
 
273
421
  origin = headers["origin"]
@@ -280,13 +428,26 @@ module Monk
280
428
  private_class_method :origin_allowed_for_optional?
281
429
 
282
430
  # The subject of a valid session, or nil -- no credential, or one
283
- # Monk::Auth no longer accepts, both mean anonymous under :optional.
284
- def self.identify(headers, db_pool)
285
- credential = Handshake.credential_from(headers)
286
- credential && verify(credential[:token], db_pool)
431
+ # Monk::Auth no longer accepts, both mean anonymous under :optional --
432
+ # or VERIFY_FAILED.
433
+ def self.identify(credential, db_pool)
434
+ credential && handshake_verify(credential[:token], db_pool)
287
435
  end
288
436
  private_class_method :identify
289
437
 
438
+ # verify, or VERIFY_FAILED with one line on stderr if it raised:
439
+ # under a burst, a backtrace per refused connection buries the log.
440
+ # The message's first line only (a PG error adds the query and a
441
+ # caret), and not Kernel#warn, which -W0 silences.
442
+ def self.handshake_verify(raw_token, db_pool)
443
+ verify(raw_token, db_pool)
444
+ rescue StandardError => e
445
+ reason = e.message.lines.first&.chomp
446
+ $stderr.puts "monk-websocket: handshake refused with 503, verify failed: #{e.class}: #{reason}" # rubocop:disable Style/StderrPuts
447
+ VERIFY_FAILED
448
+ end
449
+ private_class_method :handshake_verify
450
+
290
451
  # Sleeps in `interval`-sized steps, re-running Monk::Auth.verify
291
452
  # against the same raw credential each time, until it comes back
292
453
  # nil (revoked or expired) -- then closes the connection with the
@@ -319,15 +480,18 @@ module Monk
319
480
  end
320
481
  private_class_method :verify
321
482
 
483
+ # The request head, nil if the client went away or was too slow, or
484
+ # :too_large past Handshake::MAX_REQUEST_SIZE.
485
+ #
486
+ # One String, read through the socket's own buffer: Connection's
487
+ # reads use that buffer after it, so a frame the client sent right
488
+ # behind its request isn't lost (a readpartial would take it into
489
+ # the request and drop it). A read(1) loop made a String a byte.
322
490
  def self.read_handshake_request(socket)
323
- request = +""
324
- until request.end_with?("\r\n\r\n")
325
- chunk = socket.read(1)
326
- return nil if chunk.nil?
491
+ request = socket.gets("\r\n\r\n", Handshake::MAX_REQUEST_SIZE)
492
+ return request if request&.end_with?("\r\n\r\n")
327
493
 
328
- request << chunk
329
- end
330
- request
494
+ :too_large if request && request.bytesize >= Handshake::MAX_REQUEST_SIZE
331
495
  end
332
496
  private_class_method :read_handshake_request
333
497
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: monkrb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.21.1
4
+ version: 0.22.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matteo Folin
@@ -186,6 +186,7 @@ files:
186
186
  - lib/monk/auth/rate_limiter.rb
187
187
  - lib/monk/auth/send_login_link.rb
188
188
  - lib/monk/auth/session.rb
189
+ - lib/monk/auth/sweep.rb
189
190
  - lib/monk/base.rb
190
191
  - lib/monk/bin.rb
191
192
  - lib/monk/cli.rb
@@ -210,9 +211,13 @@ files:
210
211
  - lib/monk/jobs/adapters/pg.rb
211
212
  - lib/monk/jobs/args.rb
212
213
  - lib/monk/jobs/claim.rb
214
+ - lib/monk/jobs/cron.rb
213
215
  - lib/monk/jobs/errors.rb
216
+ - lib/monk/jobs/firing.rb
214
217
  - lib/monk/jobs/job.rb
218
+ - lib/monk/jobs/recurring_task.rb
215
219
  - lib/monk/jobs/runtime.rb
220
+ - lib/monk/jobs/scheduler.rb
216
221
  - lib/monk/jobs/worker.rb
217
222
  - lib/monk/live.rb
218
223
  - lib/monk/live/client/idiomorph.LICENSE
@@ -254,6 +259,8 @@ files:
254
259
  - lib/monk/templates/auth/config/auth.rb
255
260
  - lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.down.sql
256
261
  - lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.up.sql
262
+ - lib/monk/templates/auth/db/migrate/00000000000004_create_login_requests.down.sql
263
+ - lib/monk/templates/auth/db/migrate/00000000000004_create_login_requests.up.sql
257
264
  - lib/monk/templates/auth/setup.md
258
265
  - lib/monk/templates/auth/test/auth_test.rb
259
266
  - lib/monk/templates/base/.dockerignore
@@ -288,8 +295,11 @@ files:
288
295
  - lib/monk/templates/jobs/app/routes/jobs.rb
289
296
  - lib/monk/templates/jobs/bin/jobs
290
297
  - lib/monk/templates/jobs/config/jobs.rb
298
+ - lib/monk/templates/jobs/config/recurring.rb
291
299
  - lib/monk/templates/jobs/db/migrate/00000000000002_create_jobs_tables.down.sql
292
300
  - lib/monk/templates/jobs/db/migrate/00000000000002_create_jobs_tables.up.sql
301
+ - lib/monk/templates/jobs/db/migrate/00000000000003_create_recurring_tables.down.sql
302
+ - lib/monk/templates/jobs/db/migrate/00000000000003_create_recurring_tables.up.sql
293
303
  - lib/monk/templates/jobs/setup.md
294
304
  - lib/monk/templates/jobs/test/jobs_test.rb
295
305
  - lib/monk/templates/live/agents.md