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 AdminCommand < self
100			def initialize(exe:, **kwargs)
101				@exe = exe
102				super(**kwargs)
103			end
104
105			def to_h
106				super.merge(exe: @exe)
107			end
108
109			def find(port)
110				h = to_h
111				@exe.fetch_and_submit(q: port.customer_id).then do |form|
112					if port.tel == form.tel && form.route == port.backend_sgx.to_s
113						GoodNumber.new(**h).find(port)
114					else
115						WrongNumber.new(port: port, execution: @exe)
116					end
117				end
118			end
119
120			class GoodNumber < self
121				def find(port)
122					Reachability.new(type: "voice", **to_h).test(port) do
123						Reachability.new(type: "sms", **to_h).test(port) do
124							FinishUp.new(port, redis: redis, output: output)
125						end
126					end
127				rescue StandardError => e
128					ReachabilityFailure.for(error: e, port: port, **to_h)
129				end
130
131				class ReachabilityFailure < self
132					attr_reader :error
133
134					NO_GRACE_PERIOD = 0
135
136					# @param error [StandardError]
137					def self.for(port:, error:, **kwargs)
138						Alert.for(
139							port,
140							grace_period: NO_GRACE_PERIOD,
141							real_step: nil,
142							output: kwargs[:output],
143							msg: msg(port, error),
144							key: :reachability_failure
145						)
146					end
147
148					def self.msg(port, error)
149						"⚠ Error checking #{port.id} reachability: #{error}"
150					end
151				end
152
153				class Reachability < self
154					def initialize(type:, **args)
155						@type = type
156						super(**args)
157					end
158
159					def test(port)
160						execute_command(port).then { |response|
161							next yield unless response.count == "0"
162
163							args = to_h.slice(
164								:blather_notify, :testing_tel, :admin_server, :output
165							)
166							RunTest.new(type: @type, tel: port.tel, port: port, **args)
167						}.catch do |e|
168							ReachabilityFailure.for(port: port, error: e, output: output)
169						end
170					end
171
172					class RunTest
173						# This is here for tests
174						attr_reader :type
175
176						def initialize(
177							type:, tel:, port:, output:,
178							blather_notify:, testing_tel:, admin_server:
179						)
180							@type = type
181							@tel = tel
182							@port = port
183							@output = output
184							@blather_notify = blather_notify
185							@testing_tel = testing_tel
186							@admin_server = admin_server
187						end
188
189						def perform_next_step
190							@blather_notify.command_execution(@admin_server, "reachability")
191								.fetch_and_submit(
192									tel: @tel, type: @type, reachability_tel: @testing_tel
193								).catch do |e|
194									ReachabilityFailure.for(
195										port: @port, error: e, output: @output
196									).perform_next_step
197								end
198						end
199					end
200
201				protected
202
203					def execute_command(port)
204						blather_notify.command_execution(admin_server, "reachability")
205							.fetch_and_submit(tel: port.tel, type: @type)
206					end
207				end
208
209				class FinishUp
210					MESSAGE = "Hi!  This is JMP support - your number has " \
211					          "successfully transferred in to JMP!  All calls/messages " \
212					          "will now use your transferred-in number - your old JMP " \
213					          "number has been disabled.  Let us know if you have any " \
214					          "questions and thanks for using JMP!"
215
216					def initialize(port, redis:, output:)
217						@port = port
218						@redis = redis
219						@output = output
220					end
221
222					def set_key
223						@redis.set(
224							"jmp_port_complete-#{@port.id}",
225							DateTime.now.iso8601,
226							"EX",
227							60 * 60 * 24 * 2 ### 2 Days should be enough to not see it listed
228						)
229					end
230
231					def perform_next_step
232						set_key.then do
233							EMPromise.all([
234								@output.info(@port.id, :complete, "Port Complete!"),
235								@output.to_customer(
236									@port.id, :complete,
237									Tel.new(@port.tel), MESSAGE
238								)
239							])
240						end
241					end
242				end
243			end
244
245			class WrongNumber
246				attr_reader :new_backend
247
248				def initialize(port:, execution:)
249					@new_backend = port.backend_sgx
250					@right_number = port.tel
251					@exe = execution
252				end
253
254				def perform_next_step
255					@exe.fetch_and_submit(action: "number_change").then do |_form|
256						@exe.fetch_and_submit(
257							new_backend: @new_backend,
258							new_tel: @right_number,
259							should_delete: "true"
260						)
261					end
262				end
263			end
264		end
265	end
266
267	# This doesn't do anything and just waits for something to happen later
268	class Wait
269		def initialize(port, output:)
270			@port = port
271			@output = output
272		end
273
274		def perform_next_step
275			@output.info(@port.id, :wait, "Waiting...")
276		end
277	end
278
279	# This also doesn't do anything but is more content about it
280	class Done
281		def initialize(port, output:)
282			@port = port
283			@output = output
284		end
285
286		def perform_next_step
287			@output.info(@port.id, :done, "Done.")
288		end
289	end
290
291	# This also also doesn't do anything but is intentional
292	class Frozen
293		def initialize(port, output:)
294			@port = port
295			@output = output
296		end
297
298		def perform_next_step
299			@output.info(@port.id, :frozen, "Frozen.")
300		end
301	end
302
303	# This class sends and error to the human to check things out
304	class Alert
305		# @param [PortingStepRepo, NilClass] real_step If `nil`, just notify
306		def self.for(port, grace_period:, real_step:, **args)
307			if (DateTime.now - port.actual_foc_date.to_datetime) > grace_period
308				new(port, real_step: real_step, **args)
309			else
310				real_step
311			end
312		end
313
314		# For tests
315		attr_reader :key
316		attr_reader :real_step
317
318		def initialize(port, real_step:, output:, msg:, key:)
319			@port = port
320			@real_step = real_step
321			@output = output
322			@msg = msg
323			@key = key
324		end
325
326		def perform_next_step
327			@output.warn(@port.id, @key, @msg).then {
328				@real_step&.perform_next_step
329			}
330		end
331	end
332end
333
334class FullManual < PortingStepRepo::Outputs
335	def info(id, key, msg)
336		puts "[#{id}] INFO(#{key}): #{msg}"
337	end
338
339	def warn(id, key, msg)
340		puts "[#{id}] WARN(#{key}): #{msg}"
341	end
342
343	def error(id, key, e_or_msg)
344		puts "[#{id}] ERRR(#{key}): #{e_or_msg}"
345		return unless e_or_msg.respond_to?(:backtrace)
346
347		e_or_msg.backtrace.each do |b|
348			puts "[#{id}] ERRR(#{key}): #{b}"
349		end
350	end
351
352	def to_customer(id, key, tel, msg)
353		puts "[#{id}] CUST(#{key}, #{tel}): #{msg}"
354	end
355end
356
357class ObservedAuto < FullManual
358	def initialize(endpoint, source_number)
359		@endpoint = endpoint
360		@src = source_number
361	end
362
363	def to_customer(id, key, tel, msg)
364		ExpiringLock.new(lock_key(id, key)).with do
365			EM::HttpRequest
366				.new(@endpoint)
367				.apost(
368					head: { "Content-Type" => "application/json" },
369					body: format_msg(tel, msg)
370				)
371		end
372	end
373
374protected
375
376	def lock_key(id, key)
377		"jmp_port_customer_msg_#{key}-#{id}"
378	end
379
380	def format_msg(tel, msg)
381		[{
382			time: DateTime.now.iso8601,
383			type: "message-received",
384			to: tel,
385			description: "Incoming message received",
386			message: actual_message(tel, msg)
387		}].to_json
388	end
389
390	def actual_message(tel, msg)
391		{
392			id: SecureRandom.uuid,
393			owner: tel,
394			applicationId: SecureRandom.uuid,
395			time: DateTime.now.iso8601,
396			segmentCount: 1,
397			direction: "in",
398			to: [tel], from: @src,
399			text: msg
400		}
401	end
402end
403
404class FullAutomatic < ObservedAuto
405	using FormToH
406
407	def initialize(pubsub_addr, endpoint, source_number)
408		@pubsub = BlatherNotify.pubsub(pubsub_addr)
409
410		Sentry.init do |config|
411			config.background_worker_threads = 0
412		end
413
414		super(endpoint, source_number)
415	end
416
417	# No one's watch; swallow informational messages
418	def info(*); end
419
420	def warn(id, key, msg)
421		ExpiringLock.new(warn_lock_key(id, key), expiry: 60 * 15).with do
422			entrykey = "#{id}:#{key}"
423			@pubsub.publish("#{entrykey}": error_entry("Port Warning", msg, entrykey))
424		end
425	end
426
427	def error(id, key, e_or_msg)
428		Sentry.with_scope do |scope|
429			scope.set_context("port", { id: id, action: key })
430
431			if e_or_msg.is_a?(::Exception)
432				Sentry.capture_exception(e_or_msg)
433			else
434				Sentry.capture_message(e_or_msg.to_s)
435			end
436		end
437	end
438
439protected
440
441	def error_entry(title, text, id)
442		Nokogiri::XML::Builder.new { |xml|
443			xml.entry(xmlns: "http://www.w3.org/2005/Atom") do
444				xml.updated DateTime.now.iso8601
445				xml.id id
446				xml.title title
447				xml.content text.to_s, type: "text"
448				xml.author { xml.name "porting" }
449				xml.generator "porting", version: "1.0"
450			end
451		}.doc.root
452	end
453
454	def warn_lock_key(id, key)
455		"jmp_port_warn_msg_#{key}-#{id}"
456	end
457end