Porting Script

Christopher Vollick created

This does a few things. It's meant to be run headlessly, but it can also
be run by a human if they want to do it manually.

So in pursuit of that it has a few options. The defaults ignore
informational logs, send exceptions to sentry, warnings to a pubsub
channel, and send messages to the customer to tell them the port is
finished.

But the manual mode logs informational messages, warnings, and errors
all to the terminal. And then it can either also log the things it would
have said to the customer, or it can send those automatically still.

I've also got an option for making fake ports. Yeah, it's just used when
testing the thing, but given that I run it that way 90% of the time, it
felt weird to just have it floating around in my working dir and having
to keep remembering to take it out, commit, put it back, etc.

So I just decided I'd put it in here behind a flag.

Change summary

bin/porting | 270 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 270 insertions(+)

Detailed changes

bin/porting 🔗

@@ -0,0 +1,270 @@
+#!/usr/bin/ruby
+# frozen_string_literal: true
+
+require "date"
+require "dhall"
+require "em-hiredis"
+require "em-http"
+require "em_promise"
+require "json"
+require "optparse"
+require "ruby-bandwidth-iris"
+require "securerandom"
+require "sentry-ruby"
+require "time"
+
+@verbosity = 0
+@real_data = true
+@dry_run = false
+
+OptionParser.new do |opts|
+	opts.banner = "Usage: porting [-vvf] DHALL_CONFIG"
+
+	opts.on(
+		"-v", "--verbose",
+		"Print to terminal, run twice to not even send to customer"
+	) do
+		@verbosity += 1
+	end
+
+	opts.on("-f", "--fake", "Run with fake ports rather than fetching") do
+		@real_data = false
+	end
+
+	opts.on(
+		"-n", "--dry-run",
+		"Figure out what state they're in, but don't take action"
+	) do
+		@dry_run = true
+	end
+
+	opts.on("-h", "--help", "Print this help") do
+		puts opts
+		exit
+	end
+end.parse!
+
+SCHEMA = "{
+	bandwidth : { account: Text, username: Text, password: Text },
+	xmpp: { jid: Text, password: Text },
+	notification: { endpoint: Text, source_number: Text },
+	pubsub: { server: Text, node: Text },
+	testing_tel: Text,
+	admin_server: Text
+}"
+
+raise "Need a Dhall config" unless ARGV[0]
+
+CONFIG = Dhall::Coder
+	.new(safe: Dhall::Coder::JSON_LIKE + [Symbol, Proc])
+	.load("#{ARGV.first} : #{SCHEMA}", transform_keys: :to_sym)
+
+require_relative "../lib/blather_notify"
+require_relative "../lib/expiring_lock"
+require_relative "../lib/form_to_h"
+require_relative "../lib/porting_step"
+
+Faraday.default_adapter = :em_synchrony
+BandwidthIris::Client.global_options = {
+	account_id: CONFIG[:bandwidth][:account],
+	username: CONFIG[:bandwidth][:username],
+	password: CONFIG[:bandwidth][:password]
+}
+
+class FullManual < PortingStepRepo::Outputs
+	def info(id, key, msg)
+		puts "[#{id}] INFO(#{key}): #{msg}"
+	end
+
+	def warn(id, key, msg)
+		puts "[#{id}] WARN(#{key}): #{msg}"
+	end
+
+	def error(id, key, e_or_msg)
+		puts "[#{id}] ERRR(#{key}): #{e_or_msg}"
+		return unless e_or_msg.respond_to?(:backtrace)
+
+		e_or_msg.backtrace.each do |b|
+			puts "[#{id}] ERRR(#{key}): #{b}"
+		end
+	end
+
+	def to_customer(id, key, tel, msg)
+		puts "[#{id}] CUST(#{key}, #{tel}): #{msg}"
+	end
+end
+
+class ObservedAuto < FullManual
+	def initialize(endpoint, source_number)
+		@endpoint = endpoint
+		@src = source_number
+	end
+
+	def to_customer(id, key, tel, msg)
+		ExpiringLock.new(lock_key(id, key)).with do
+			EM::HttpRequest
+				.new(@endpoint)
+				.apost(
+					head: { "Content-Type" => "application/json" },
+					body: format_msg(tel, msg)
+				)
+		end
+	end
+
+protected
+
+	def lock_key(id, key)
+		"jmp_port_customer_msg_#{key}-#{id}"
+	end
+
+	def format_msg(tel, msg)
+		[{
+			time: DateTime.now.iso8601,
+			type: "message-received",
+			to: tel,
+			description: "Incoming message received",
+			message: actual_message(tel, msg)
+		}].to_json
+	end
+
+	def actual_message(tel, msg)
+		{
+			id: SecureRandom.uuid,
+			owner: tel,
+			applicationId: SecureRandom.uuid,
+			time: DateTime.now.iso8601,
+			segmentCount: 1,
+			direction: "in",
+			to: [tel], from: @src,
+			text: msg
+		}
+	end
+end
+
+class FullAutomatic < ObservedAuto
+	using FormToH
+
+	def initialize(pubsub_addr, endpoint, source_number)
+		@pubsub = BlatherNotify.pubsub(pubsub_addr)
+
+		Sentry.init do |config|
+			config.background_worker_threads = 0
+		end
+
+		super(endpoint, source_number)
+	end
+
+	# No one's watch; swallow informational messages
+	def info(*); end
+
+	def warn(id, key, msg)
+		ExpiringLock.new(warn_lock_key(id, key), expiry: 60 * 15).with do
+			entrykey = "#{id}:#{key}"
+			@pubsub.publish("#{entrykey}": error_entry("Port Warning", msg, entrykey))
+		end
+	end
+
+	def error(id, key, e_or_msg)
+		Sentry.with_scope do |scope|
+			scope.set_context("port", { id: id, action: key })
+
+			if e_or_msg.is_a?(::Exception)
+				Sentry.capture_exception(e_or_msg)
+			else
+				Sentry.capture_message(e_or_msg.to_s)
+			end
+		end
+	end
+
+protected
+
+	def error_entry(title, text, id)
+		Nokogiri::XML::Builder.new { |xml|
+			xml.entry(xmlns: "http://www.w3.org/2005/Atom") do
+				xml.updated DateTime.now.iso8601
+				xml.id id
+				xml.title title
+				xml.content text.to_s, type: "text"
+				xml.author { xml.name "porting" }
+				xml.generator "porting", version: "1.0"
+			end
+		}.doc.root
+	end
+
+	def warn_lock_key(id, key)
+		"jmp_port_warn_msg_#{key}-#{id}"
+	end
+end
+
+@output =
+	case @verbosity
+	when 0
+		FullAutomatic.new(
+			BlatherNotify::PubSub::Address.new(**CONFIG[:pubsub]),
+			CONFIG[:notification][:endpoint],
+			CONFIG[:notification][:source_number]
+		)
+	when 1
+		ObservedAuto.new(
+			CONFIG[:notification][:endpoint],
+			CONFIG[:notification][:source_number]
+		)
+	else
+		FullManual.new
+	end
+
+ports = if @real_data
+	BandwidthIris::PortIn.list(
+		page: 1,
+		size: 50,
+		start_date: Date.today - 1,
+		end_date: Date.today
+	) || []
+else
+	MP = Struct.new(
+		:order_id,
+		:processing_status,
+		:actual_foc_date,
+		:last_modified_date,
+		:customer_order_id,
+		:billing_telephone_number
+	)
+
+	minutes = 1.0 / (24 * 60)
+
+	[
+		# This should be ignored
+		MP.new("T01", "SUBMITTED", nil, DateTime.now - 1, "ignored", "9998887777"),
+		MP.new(
+			"T02", "COMPLETE", DateTime.now - 60 * minutes,
+			DateTime.now - 55 * minutes, "0001", "2223334444"
+		)
+	]
+end
+
+EM.run do
+	REDIS = EM::Hiredis.connect
+
+	BlatherNotify.start(
+		CONFIG[:xmpp][:jid],
+		CONFIG[:xmpp][:password]
+	).then {
+		ports.reduce(EMPromise.resolve(nil)) { |promise, port|
+			promise.then do
+				@output.info(port.order_id, :start, "Here we go")
+				PortingStepRepo.new(output: @output).find(port).then { |s|
+					@output.info(port.order_id, :class, s.class)
+					s
+				}.then { |s|
+					if @dry_run
+						@output.info("DRY", :dry, "Not taking action")
+					else
+						s.perform_next_step
+					end
+				}
+			end
+		}
+	}.catch { |e|
+		@output.error("ROOT", :catch, e)
+	}.then { BlatherNotify.shutdown }
+end