Merge branch 'snikket'

Stephen Paul Weber created

* snikket:
  Initial bare-bones admin command for launching a snikket instance

Change summary

config-schema.dhall       |   1 
config.dhall.sample       |   1 
forms/snikket_launch.rb   |   9 +++
forms/snikket_launched.rb |  19 +++++++
lib/snikket.rb            | 104 +++++++++++++++++++++++++++++++++++++++++
sgx_jmp.rb                |  32 ++++++++++++
test/test_snikket.rb      |  73 ++++++++++++++++++++++++++++
7 files changed, 239 insertions(+)

Detailed changes

config-schema.dhall 🔗

@@ -41,6 +41,7 @@
 , sgx : Text
 , sip : { app : Text, realm : Text }
 , sip_host : Text
+, snikket_hosting_api : Text
 , unbilled_targets : List Text
 , upstream_domain : Text
 , web : < Inet : { interface : Text, port : Natural } | Unix : Text >

config.dhall.sample 🔗

@@ -79,6 +79,7 @@ in
 	unbilled_targets = ["+14169938000"],
 	keep_area_codes = ["555"],
 	keep_area_codes_in = { account = "", site_id = "", sip_peer_id = "" },
+	snikket_hosting_api = "",
 	upstream_domain = "example.net",
 	approved_domains = toMap { `example.com` = Some "customer_id" }
 }

forms/snikket_launch.rb 🔗

@@ -0,0 +1,9 @@
+form!
+
+title "Launch Snikket Instance"
+
+field(
+	var: "domain",
+	label: "Domain for Instance",
+	type: "text-single"
+)

forms/snikket_launched.rb 🔗

@@ -0,0 +1,19 @@
+result!
+
+title "Snikket Instance Lauching"
+
+instructions "Snikket instance is launching now. Please wait a few minutes."
+
+field(
+	type: "text-single",
+	var: "instance-id",
+	label: "Instance ID",
+	value: @launched.instance_id
+)
+
+field(
+	type: "text-single",
+	var: "bootstrap-uri",
+	label: "Admin Invite",
+	value: @launched.bootstrap_uri(@domain)
+)

lib/snikket.rb 🔗

@@ -0,0 +1,104 @@
+# frozen_string_literal: true
+
+require "blather"
+
+module Snikket
+	class Launch < Blather::Stanza::Iq
+		register nil, "launch", "xmpp:snikket.org/hosting/v1"
+
+		def self.new(type=nil, to=nil, id=nil, domain: nil)
+			stanza = super(type || :set, to, id)
+			node = Nokogiri::XML::Node.new("launch", stanza.document)
+			node.default_namespace = registered_ns
+			stanza << node
+			stanza.domain = domain if domain
+			stanza
+		end
+
+		def domain=(domain)
+			query.at_xpath("./ns:domain", ns: self.class.registered_ns)&.remove
+			node = Nokogiri::XML::Node.new("domain", document)
+			node.default_namespace = self.class.registered_ns
+			node.content = domain
+			query << node
+		end
+
+		def query
+			at_xpath("./ns:launch", ns: self.class.registered_ns)
+		end
+	end
+
+	class Launched < Blather::Stanza::Iq
+		register :snikket_launched, "launched", "xmpp:snikket.org/hosting/v1"
+
+		def instance_id
+			query
+				.at_xpath("./ns:instance-id", ns: self.class.registered_ns)
+				&.content
+		end
+
+		def bootstrap_token
+			query
+				.at_xpath("./ns:bootstrap/ns:token", ns: self.class.registered_ns)
+				&.content
+		end
+
+		def bootstrap_uri(instance_domain)
+			"https://#{instance_domain}/invites_bootstrap?token=#{bootstrap_token}"
+		end
+
+		def query
+			at_xpath("./ns:launched", ns: self.class.registered_ns)
+		end
+	end
+
+	class Instance < Blather::Stanza::Iq
+		register :snikket_instance, "instance", "xmpp:snikket.org/hosting/v1"
+
+		def self.new(type=nil, to=nil, id=nil, instance_id: nil)
+			stanza = super(type || :get, to, id)
+			node = Nokogiri::XML::Node.new("instance", stanza.document)
+			node.default_namespace = registered_ns
+			stanza << node
+			stanza.instance_id = instance_id if instance_id
+			stanza
+		end
+
+		def inherit(*)
+			query.remove
+			super
+		end
+
+		def instance_id=(instance_id)
+			query.at_xpath("./ns:instance-id", ns: self.class.registered_ns)&.remove
+			node = Nokogiri::XML::Node.new("instance-id", document)
+			node.default_namespace = self.class.registered_ns
+			node.content = instance_id
+			query << node
+		end
+
+		def instance_id
+			query
+				.at_xpath("./ns:instance-id", ns: self.class.registered_ns)
+				&.content
+		end
+
+		def update_needed?
+			!!query.at_xpath("./ns:update-needed", ns: self.class.registered_ns)
+		end
+
+		def operation
+			query.at_xpath("./ns:operation", ns: self.class.registered_ns)
+		end
+
+		def status
+			query
+				.at_xpath("./ns:status", ns: self.class.registered_ns)
+				&.content&.to_sym
+		end
+
+		def query
+			at_xpath("./ns:instance", ns: self.class.registered_ns)
+		end
+	end
+end

sgx_jmp.rb 🔗

@@ -97,6 +97,7 @@ require_relative "lib/registration"
 require_relative "lib/transaction"
 require_relative "lib/tel_selections"
 require_relative "lib/session_manager"
+require_relative "lib/snikket"
 require_relative "lib/statsd"
 require_relative "web"
 
@@ -772,6 +773,37 @@ Command.new(
 	end
 }.register(self).then(&CommandList.method(:register))
 
+Command.new(
+	"snikket",
+	"Launch Snikket Instance",
+	list_for: ->(customer: nil, **) { customer&.admin? }
+) {
+	Command.customer.then do |customer|
+		raise AuthError, "You are not an admin" unless customer&.admin?
+
+		Command.reply { |reply|
+			reply.allowed_actions = [:next]
+			reply.command << FormTemplate.render("snikket_launch")
+		}.then { |response|
+			domain = response.form.field("domain").value.to_s
+			IQ_MANAGER.write(Snikket::Launch.new(
+				nil, CONFIG[:snikket_hosting_api],
+				domain: domain
+			)).then do |launched|
+				[domain, launched]
+			end
+		}.then { |(domain, launched)|
+			Command.finish do |reply|
+				reply.command << FormTemplate.render(
+					"snikket_launched",
+					launched: launched,
+					domain: domain
+				)
+			end
+		}
+	end
+}.register(self).then(&CommandList.method(:register))
+
 def reply_with_note(iq, text, type: :info)
 	reply = iq.reply
 	reply.status = :completed

test/test_snikket.rb 🔗

@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+require "snikket"
+
+class TestSnikket < Minitest::Test
+	NS = "xmpp:snikket.org/hosting/v1"
+
+	def test_launch
+		launch = Snikket::Launch.new(domain: "example.com")
+		assert_equal :set, launch.type
+		assert_equal NS, launch.query.namespace.href
+		assert_equal "launch", launch.query.node_name
+		assert_equal(
+			"example.com",
+			launch.query.at_xpath("./ns:domain", ns: NS).content
+		)
+	end
+
+	def test_launched
+		launched = Blather::XMPPNode.parse(<<~XML)
+			<iq type="result" from="hosting-api.snikket.net" id="123">
+				<launched xmlns="xmpp:snikket.org/hosting/v1">
+					<bootstrap>
+						<token>fZLy6iTh</token>
+					</bootstrap>
+					<instance-id>si-12345</instance-id>
+				</launched>
+			</iq>
+		XML
+		assert_equal :result, launched.type
+		assert_equal NS, launched.query.namespace.href
+		assert_equal "launched", launched.query.node_name
+		assert_equal(
+			"https://example.com/invites_bootstrap?token=fZLy6iTh",
+			launched.bootstrap_uri("example.com")
+		)
+		assert_equal "si-12345", launched.instance_id
+	end
+
+	def test_instance_get
+		instance = Snikket::Instance.new(instance_id: "si-1234")
+		assert_equal :get, instance.type
+		assert_equal NS, instance.query.namespace.href
+		assert_equal "instance", instance.query.node_name
+		assert_equal(
+			"si-1234",
+			instance.query.at_xpath("./ns:instance-id", ns: NS).content
+		)
+	end
+
+	def test_instance_result
+		instance = Blather::XMPPNode.parse(<<~XML)
+			<iq type="result" from="hosting-api.snikket.net" id="000">
+				<instance xmlns="xmpp:snikket.org/hosting/v1">
+					<update-needed/>
+					<instance-id>si-1234</instance-id>
+					<operation>
+						<progress>50</progress>
+						<status>running</status>
+					</operation>
+					<status>up</status>
+				</instance>
+			</iq>
+		XML
+		assert_equal :result, instance.type
+		assert_equal NS, instance.query.namespace.href
+		assert_equal "instance", instance.query.node_name
+		assert_equal "si-1234", instance.instance_id
+		assert instance.update_needed?
+		assert_kind_of Nokogiri::XML::Element, instance.operation
+		assert_equal :up, instance.status
+	end
+end