porting_step_repo.rb

  1# frozen_string_literal: true
  2
  3require "date"
  4require "value_semantics/monkey_patched"
  5require "em_promise"
  6
  7require_relative "blather_notify"
  8require_relative "utils"
  9
 10class PortingStepRepo
 11	# Any thing that debounces messages must happen inside this.
 12	# The porting logic will pass everything in every time.
 13	class Outputs
 14		def info(port_id, kind, msg); end
 15
 16		def warn(port_id, kind, msg); end
 17
 18		def error(port_id, kind, e_or_msg); end
 19
 20		def to_customer(port_id, kind, tel, msg); end
 21	end
 22
 23	value_semantics do
 24		redis             Anything(), default: LazyObject.new { REDIS }
 25		blather_notify    Anything(), default: BlatherNotify
 26		admin_server      Anything(), default: CONFIG[:admin_server]
 27		testing_tel       Anything(), default: CONFIG[:testing_tel]
 28		output            Outputs, default: Outputs.new
 29	end
 30
 31	def find(port)
 32		if_processing(port) do
 33			case port.processing_status
 34			when "FOC"
 35				FOC.new(**to_h).find(port)
 36			when "COMPLETE"
 37				Complete.new(**to_h).find(port)
 38			else
 39				Wait.new(port, output: output)
 40			end
 41		end
 42	end
 43
 44	def if_processing(port)
 45		redis.exists("jmp_port_freeze-#{port.id}").then do |v|
 46			next Frozen.new(port, output: output) if v == 1
 47
 48			yield
 49		end
 50	end
 51
 52	class FOC < self
 53		# This is how long we'll wait for a port to move from FOC to COMPLETED
 54		# It's in fractional days because DateTime
 55		GRACE_PERIOD = 15.0 / (24 * 60)
 56
 57		def find(port)
 58			Alert.for(
 59				port,
 60				grace_period: GRACE_PERIOD,
 61				output: output, key: :late_foc,
 62				msg: "⚠ Port is still in FOC state a while past FOC",
 63				real_step: Wait.new(port, output: output)
 64			)
 65		end
 66	end
 67
 68	class Complete < self
 69		# If it's been 35 minutes and the number isn't reachable, a human
 70		# should get involved
 71		GRACE_PERIOD = 35.0 / (24 * 60)
 72
 73		def find(port)
 74			if_not_complete(port) do
 75				exe = blather_notify.command_execution(admin_server, "customer info")
 76				AdminCommand.new(exe: exe, **to_h).find(port).then do |step|
 77					Alert.for(
 78						port,
 79						grace_period: GRACE_PERIOD, output: output,
 80						key: :late_finish, msg: msg(port), real_step: step
 81					)
 82				end
 83			end
 84		end
 85
 86		def if_not_complete(port)
 87			redis.exists("jmp_port_complete-#{port.id}").then do |v|
 88				next Done.new(port, output: output) if v == 1
 89
 90				yield
 91			end
 92		end
 93
 94		def msg(port)
 95			"⚠ Port still hasn't finished. We'll keep trying unless you set redis " \
 96				"key `jmp_port_freeze-#{port.id}`"
 97		end
 98
 99		class NoCustomer < self
100			attr_reader :port
101
102			NO_GRACE_PERIOD = 0
103
104			def initialize(port:, **kwargs)
105				@port = port
106				super(**kwargs)
107			end
108
109			def self.for(port:, **kwargs)
110				Alert.for(
111					port,
112					grace_period: NO_GRACE_PERIOD,
113					real_step: new(port: port, **kwargs),
114					output: kwargs[:output],
115					msg: msg(port),
116					key: :port_for_unknown_customer
117				)
118			end
119
120			def self.msg(port)
121				"⚠ Freezing port #{port.id} for unknown customer: #{port.customer_id}."
122			end
123
124			def perform_next_step
125				redis.set("jmp_port_freeze-#{port.id}", 1)
126			end
127		end
128
129		class AdminCommand < self
130			def initialize(exe:, **kwargs)
131				@exe = exe
132				super(**kwargs)
133			end
134
135			def to_h
136				super.merge(exe: @exe)
137			end
138
139			def find(port)
140				h = to_h
141				@exe.fetch_and_submit(q: port.customer_id).then do |form|
142					next NoCustomer.for(port: port, **h.except(:exe)) unless form.tel
143
144					if port.tel == form.tel && form.route == port.backend_sgx.to_s
145						GoodNumber.new(**h).find(port)
146					else
147						WrongNumber.new(port: port, execution: @exe)
148					end
149				end
150			end
151
152			class GoodNumber < self
153				def find(port)
154					Reachability.new(type: "voice", **to_h).test(port) do
155						Reachability.new(type: "sms", **to_h).test(port) do
156							FinishUp.new(port, redis: redis, output: output)
157						end
158					end
159				rescue StandardError => e
160					ReachabilityFailure.for(error: e, port: port, **to_h)
161				end
162
163				class ReachabilityFailure < self
164					attr_reader :error
165
166					NO_GRACE_PERIOD = 0
167
168					# @param error [StandardError]
169					def self.for(port:, error:, **kwargs)
170						Alert.for(
171							port,
172							grace_period: NO_GRACE_PERIOD,
173							real_step: nil,
174							output: kwargs[:output],
175							msg: msg(port, error),
176							key: :reachability_failure
177						)
178					end
179
180					def self.msg(port, error)
181						"⚠ Error checking #{port.id} reachability: #{error}"
182					end
183				end
184
185				class Reachability < self
186					def initialize(type:, **args)
187						@type = type
188						super(**args)
189					end
190
191					def test(port)
192						execute_command(port).then { |response|
193							next yield unless response.count == "0"
194
195							args = to_h.slice(
196								:blather_notify, :testing_tel, :admin_server, :output
197							)
198							RunTest.new(type: @type, tel: port.tel, port: port, **args)
199						}.catch do |e|
200							ReachabilityFailure.for(port: port, error: e, output: output)
201						end
202					end
203
204					class RunTest
205						# This is here for tests
206						attr_reader :type
207
208						def initialize(
209							type:, tel:, port:, output:,
210							blather_notify:, testing_tel:, admin_server:
211						)
212							@type = type
213							@tel = tel
214							@port = port
215							@output = output
216							@blather_notify = blather_notify
217							@testing_tel = testing_tel
218							@admin_server = admin_server
219						end
220
221						def perform_next_step
222							@blather_notify.command_execution(@admin_server, "reachability")
223								.fetch_and_submit(
224									tel: @tel, type: @type, reachability_tel: @testing_tel
225								).catch do |e|
226									ReachabilityFailure.for(
227										port: @port, error: e, output: @output
228									).perform_next_step
229								end
230						end
231					end
232
233				protected
234
235					def execute_command(port)
236						blather_notify.command_execution(admin_server, "reachability")
237							.fetch_and_submit(tel: port.tel, type: @type)
238					end
239				end
240
241				class FinishUp
242					MESSAGE = "Hi!  This is JMP support - your number has " \
243					          "successfully transferred in to JMP!  All calls/messages " \
244					          "will now use your transferred-in number - your old JMP " \
245					          "number has been disabled.  Let us know if you have any " \
246					          "questions and thanks for using JMP!"
247
248					def initialize(port, redis:, output:)
249						@port = port
250						@redis = redis
251						@output = output
252					end
253
254					def set_key
255						@redis.set(
256							"jmp_port_complete-#{@port.id}",
257							DateTime.now.iso8601,
258							"EX",
259							60 * 60 * 24 * 2 ### 2 Days should be enough to not see it listed
260						)
261					end
262
263					def perform_next_step
264						set_key.then do
265							EMPromise.all([
266								@output.info(@port.id, :complete, "Port Complete!"),
267								@output.to_customer(
268									@port.id, :complete,
269									Tel.new(@port.tel), MESSAGE
270								)
271							])
272						end
273					end
274				end
275			end
276
277			class WrongNumber
278				attr_reader :new_backend
279
280				def initialize(port:, execution:)
281					@new_backend = port.backend_sgx
282					@right_number = port.tel
283					@exe = execution
284				end
285
286				def perform_next_step
287					@exe.fetch_and_submit(action: "number_change").then do |_form|
288						@exe.fetch_and_submit(
289							new_backend: @new_backend,
290							new_tel: @right_number,
291							should_delete: "true"
292						)
293					end
294				end
295			end
296		end
297	end
298
299	# This doesn't do anything and just waits for something to happen later
300	class Wait
301		def initialize(port, output:)
302			@port = port
303			@output = output
304		end
305
306		def perform_next_step
307			@output.info(@port.id, :wait, "Waiting...")
308		end
309	end
310
311	# This also doesn't do anything but is more content about it
312	class Done
313		def initialize(port, output:)
314			@port = port
315			@output = output
316		end
317
318		def perform_next_step
319			@output.info(@port.id, :done, "Done.")
320		end
321	end
322
323	# This also also doesn't do anything but is intentional
324	class Frozen
325		def initialize(port, output:)
326			@port = port
327			@output = output
328		end
329
330		def perform_next_step
331			@output.info(@port.id, :frozen, "Frozen.")
332		end
333	end
334
335	# This class sends and error to the human to check things out
336	class Alert
337		# @param [PortingStepRepo, NilClass] real_step If `nil`, just notify
338		def self.for(port, grace_period:, real_step:, **args)
339			if (DateTime.now - port.actual_foc_date.to_datetime) > grace_period
340				new(port, real_step: real_step, **args)
341			else
342				real_step
343			end
344		end
345
346		# For tests
347		attr_reader :key
348		attr_reader :real_step
349
350		def initialize(port, real_step:, output:, msg:, key:)
351			@port = port
352			@real_step = real_step
353			@output = output
354			@msg = msg
355			@key = key
356		end
357
358		def perform_next_step
359			@output.warn(@port.id, @key, @msg).then {
360				@real_step&.perform_next_step
361			}
362		end
363	end
364end
365
366class FullManual < PortingStepRepo::Outputs
367	def info(id, key, msg)
368		puts "[#{id}] INFO(#{key}): #{msg}"
369	end
370
371	def warn(id, key, msg)
372		puts "[#{id}] WARN(#{key}): #{msg}"
373	end
374
375	def error(id, key, e_or_msg)
376		puts "[#{id}] ERRR(#{key}): #{e_or_msg}"
377		return unless e_or_msg.respond_to?(:backtrace)
378
379		e_or_msg.backtrace.each do |b|
380			puts "[#{id}] ERRR(#{key}): #{b}"
381		end
382	end
383
384	def to_customer(id, key, tel, msg)
385		puts "[#{id}] CUST(#{key}, #{tel}): #{msg}"
386	end
387end
388
389class ObservedAuto < FullManual
390	def initialize(endpoint, source_number)
391		@endpoint = endpoint
392		@src = source_number
393	end
394
395	def to_customer(id, key, tel, msg)
396		ExpiringLock.new(lock_key(id, key)).with do
397			EM::HttpRequest
398				.new(@endpoint)
399				.apost(
400					head: { "Content-Type" => "application/json" },
401					body: format_msg(tel, msg)
402				)
403		end
404	end
405
406protected
407
408	def lock_key(id, key)
409		"jmp_port_customer_msg_#{key}-#{id}"
410	end
411
412	def format_msg(tel, msg)
413		[{
414			time: DateTime.now.iso8601,
415			type: "message-received",
416			to: tel,
417			description: "Incoming message received",
418			message: actual_message(tel, msg)
419		}].to_json
420	end
421
422	def actual_message(tel, msg)
423		{
424			id: SecureRandom.uuid,
425			owner: tel,
426			applicationId: SecureRandom.uuid,
427			time: DateTime.now.iso8601,
428			segmentCount: 1,
429			direction: "in",
430			to: [tel], from: @src,
431			text: msg
432		}
433	end
434end
435
436class FullAutomatic < ObservedAuto
437	using FormToH
438
439	def initialize(pubsub_addr, endpoint, source_number)
440		@pubsub = BlatherNotify.pubsub(pubsub_addr)
441
442		Sentry.init do |config|
443			config.background_worker_threads = 0
444		end
445
446		super(endpoint, source_number)
447	end
448
449	# No one's watch; swallow informational messages
450	def info(*); end
451
452	def warn(id, key, msg)
453		ExpiringLock.new(warn_lock_key(id, key), expiry: 60 * 15).with do
454			entrykey = "#{id}:#{key}"
455			@pubsub.publish("#{entrykey}": error_entry("Port Warning", msg, entrykey))
456		end
457	end
458
459	def error(id, key, e_or_msg)
460		Sentry.with_scope do |scope|
461			scope.set_context("port", { id: id, action: key })
462
463			if e_or_msg.is_a?(::Exception)
464				Sentry.capture_exception(e_or_msg)
465			else
466				Sentry.capture_message(e_or_msg.to_s)
467			end
468		end
469	end
470
471protected
472
473	def error_entry(title, text, id)
474		Nokogiri::XML::Builder.new { |xml|
475			xml.entry(xmlns: "http://www.w3.org/2005/Atom") do
476				xml.updated DateTime.now.iso8601
477				xml.id id
478				xml.title title
479				xml.content text.to_s, type: "text"
480				xml.author { xml.name "porting" }
481				xml.generator "porting", version: "1.0"
482			end
483		}.doc.root
484	end
485
486	def warn_lock_key(id, key)
487		"jmp_port_warn_msg_#{key}-#{id}"
488	end
489end