First draft of farmware package name fetcher

pull/1083/head
Rick Carlino 2019-01-09 13:48:41 -06:00
parent 2b418db5cf
commit 8f22688890
9 changed files with 165 additions and 12 deletions

View File

@ -13,9 +13,15 @@ module Api
render json: ""
end
# Perform HTTP post to this endpoint when you
# need to force an update.
def refresh
farmware_installation.force_package_refresh!
render json: farmware_installation
end
def create
mutate FarmwareInstallations::Create.run(raw_json,
device: current_device)
mutate FarmwareInstallations::Create.run(raw_json, device: current_device)
end
private

View File

@ -5,4 +5,53 @@ class FarmwareInstallation < ApplicationRecord
validates :url, url: true
validates_uniqueness_of :url, { scope: :device }
validates_presence_of :device
# Prevent malice when fetching farmware.json
MAX_JSON_SIZE = 5000
OTHER_PROBLEM = "Unknown error: %s"
# Keep a dictionary of known errors if fetching
# the `package` attr raises a runtime error.
KNOWN_PROBLEMS = {
KeyError =>
"`farmware.json` must have a `package` field that is a string.",
OpenURI::HTTPError =>
"The server is online, but the URL could not be opened.",
SocketError =>
"The server appears to be offline.",
JSON::ParserError =>
"Expected `farmware.json` to be valid JSON, "\
"but it is not. Consider using a JSON validator.",
ActiveRecord::ValueTooLong =>
"The name of the package is too long."
}
# Downloads the `farmware.json` file in a background
# worker, updating the `package` column if possible.
def force_package_refresh!
self.delay.infer_package_name_from_url
end
# A lot of things can go wrong when fetching
# a package name in a background worker.
def maybe_recover_from_fetch_error(error)
known_error = KNOWN_PROBLEMS[error.class]
description = \
known_error || (OTHER_PROBLEM % error.class)
update_attributes!(package_error: description,
package: nil)
unless known_error.present?
raise error
end
end
# SLOW I/O BOUND STUFF! Don't run this on the
# main thread!
def infer_package_name_from_url
string_io = open(url)
string = string_io.read(MAX_JSON_SIZE)
json = JSON.parse(string)
pkg_name = json.fetch("package")
update_attributes!(package: pkg_name, package_error: nil)
rescue => error
maybe_recover_from_fetch_error(error)
end
end

View File

@ -1,12 +1,21 @@
module FarmwareInstallations
class Create < Mutations::Command
required do
string :url
model :device, class: Device
end
def execute
FarmwareInstallation.create!(url: url, device: device)
fwi = FarmwareInstallation.create!(create_params)
fwi.force_package_refresh!
fwi
end
private
def create_params
@create_params ||= { url: url,
device: device }
end
end
end

View File

@ -1,3 +1,3 @@
class FarmwareInstallationSerializer < ApplicationSerializer
attributes :url
attributes :url, :package
end

View File

@ -9,7 +9,6 @@ FarmBot::Application.routes.draw do
{
diagnostic_dumps: [:create, :destroy, :index],
farm_events: [:create, :destroy, :index, :show, :update],
farmware_installations: [:create, :destroy, :index, :show],
images: [:create, :destroy, :index, :show],
password_resets: [:create, :update],
peripherals: [:create, :destroy, :index, :show, :update],
@ -38,7 +37,13 @@ FarmBot::Application.routes.draw do
resources(:points, except: []) { post :search, on: :collection }
resources :farmware_installations, except: [:update] do
post :refresh, on: :member
end
resources :logs, except: [:update, :show] do
# When farmware fetching fails and the user
# wants to try agian.
get :search, on: :collection
end

View File

@ -0,0 +1,11 @@
class AddPackageToFarmwareInstallation < ActiveRecord::Migration[5.2]
def change
add_column :farmware_installations,
:package,
:string,
limit: 80
add_column :farmware_installations,
:package_error,
:string
end
end

View File

@ -398,7 +398,9 @@ CREATE TABLE public.farmware_installations (
device_id bigint,
url character varying,
created_at timestamp without time zone NOT NULL,
updated_at timestamp without time zone NOT NULL
updated_at timestamp without time zone NOT NULL,
package character varying(80),
package_error character varying
);
@ -2888,6 +2890,7 @@ INSERT INTO "schema_migrations" (version) VALUES
('20181204005038'),
('20181208035706'),
('20190103211708'),
('20190103213956');
('20190103213956'),
('20190108211419');

View File

@ -13,7 +13,8 @@ describe Api::FarmwareInstallationsController do
post :create, body: payload.to_json, params: {format: :json}
expect(response.status).to eq(200)
expect(FarmwareInstallation.count).to be > old_installation_count
expect(json.keys.sort).to eq([:created_at, :id, :updated_at, :url])
expect(json.keys.sort)
.to eq([:created_at, :id, :package, :updated_at, :url])
expect(json[:url]).to eq(url)
expect(FarmwareInstallation.find(json[:id]).device).to eq(user.device)
end
@ -50,6 +51,16 @@ describe Api::FarmwareInstallationsController do
end
end
describe "#refresh" do
it "triggers a re-fetch of farmware" do
fi = FactoryBot.create(:farmware_installation, device: user.device)
allow_any_instance_of(fi.class).to receive(:force_package_refresh!)
sign_in user
post :refresh, params: { id: fi.id }
expect(response.status).to eq(200)
end
end
describe "#destroy" do
it "destroys a record" do
sign_in user

View File

@ -1,11 +1,70 @@
require "spec_helper"
describe FarmwareInstallation do
FAKE_URL = "http://www.sphere.bc.ca/test/circular-man2.html"
FAKE_URL = "https://raw.githubusercontent.com/"\
"FarmBot-Labs/set-servo-angle/master"\
"/manifest.json"
let(:device) { FactoryBot.create(:device) }
it 'Enforces uniqueness of URL' do
class Mystery < StandardError; end
it "handles unknown errors while parsing `farmware.json`" do
error = Mystery.new("wow!")
fi = FarmwareInstallation.create(device: device, url: FAKE_URL)
expect(fi).to receive(:open).and_raise(error)
expect { fi.infer_package_name_from_url }.to raise_error(error)
expect(fi.package_error)
.to eq(FarmwareInstallation::OTHER_PROBLEM % Mystery.to_s)
expect(fi.package).to eq(nil)
end
it "handles unreasonably large package names" do
fi = FarmwareInstallation.create(device: device, url: FAKE_URL)
stringio = StringIO.new({ package: "*" * 100 }.to_json)
expect(fi).to receive(:open).and_return(stringio)
fi.infer_package_name_from_url
error =
FarmwareInstallation::KNOWN_PROBLEMS.fetch(ActiveRecord::ValueTooLong)
expect(fi.package_error).to eq(error)
expect(fi.package).to eq(nil)
end
it "handles unreasonably large payloads" do
old_value = FarmwareInstallation::MAX_JSON_SIZE
fi = FarmwareInstallation.create(device: device, url: FAKE_URL)
stringio = StringIO.new({hello: "world"}.to_json)
const_reassign(FarmwareInstallation, :MAX_JSON_SIZE, 2)
expect(fi).to receive(:open).and_return(stringio)
fi.infer_package_name_from_url
error = FarmwareInstallation::KNOWN_PROBLEMS.fetch(JSON::ParserError)
expect(fi.package_error).to eq(error)
expect(fi.package).to eq(nil)
const_reassign(FarmwareInstallation, :MAX_JSON_SIZE, old_value)
end
it "handles non-JSON strings" do
fi = FarmwareInstallation.create(device: device, url: FAKE_URL)
expect(fi).to receive(:open).and_return(StringIO.new("{lol"))
fi.infer_package_name_from_url
error = FarmwareInstallation::KNOWN_PROBLEMS.fetch(JSON::ParserError)
expect(fi.package_error).to eq(error)
expect(fi.package).to eq(nil)
end
it "handles `package` fetch errors" do
malformed_url = "http://#{SecureRandom.base58.downcase}.com"
fi = FarmwareInstallation.create!(device: device,
url: malformed_url)
fi.infer_package_name_from_url
error = FarmwareInstallation::KNOWN_PROBLEMS.fetch(SocketError)
expect(fi.package_error).to eq(error)
expect(fi.package).to eq(nil)
end
it "Enforces uniqueness of URL" do
FarmwareInstallation.destroy_all
device = FactoryBot.create(:device)
first = FarmwareInstallation.create(device: device, url: FAKE_URL)
second = FarmwareInstallation.create(device: device, url: FAKE_URL)
expect(first.valid?).to be true
@ -13,7 +72,7 @@ describe FarmwareInstallation do
expect(second.errors[:url]).to include("has already been taken")
end
it 'disallows empty URLs' do
it "disallows empty URLs" do
x = FarmwareInstallation.create(url: "")
expect(x.errors[:url]).to include("is an invalid URL")
end