warp_engine 0.2.0: pluggable storage adapter and publish notifications
Two seams the hosts needed, both backward compatible.
Storage: artifacts are served through WarpEngine::Storage.adapter instead of
raw filesystem calls. The default :local adapter keeps the previous behaviour
byte for byte, including the path traversal guard. A host can now set
config.storage_adapter to any object answering file?/directory?/locate and
serve builds from an object store - FileService and /api/download both honour
a Location.redirect, so a signing adapter turns them into redirects.
DownloadService#create still returns an absolute path (nil when missing) for
existing callers; #locate is the new entry point that can also return a
redirect. Ingestion (upload, extraction, file manager) stays local for now.
Publish: PublishService emits ActiveSupport::Notifications
("warp_engine.publish") with platform/name/version/software/release, so hosts
can react to a new build without hanging callbacks on the models.
WarpEngine.instruments_publish? lets a host feature-detect and keep its
fallback for older engine versions.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
require "rails_helper"
|
||||
require "tmpdir"
|
||||
|
||||
RSpec.describe WarpEngine::Storage do
|
||||
let(:tmpdir) { Dir.mktmpdir }
|
||||
|
||||
before do
|
||||
allow(WarpEngine.config).to receive(:file_container_path).and_return(tmpdir)
|
||||
WarpEngine::Storage.reset!
|
||||
end
|
||||
|
||||
after { FileUtils.rm_rf(tmpdir) }
|
||||
|
||||
describe ".adapter" do
|
||||
it "defaults to the local filesystem" do
|
||||
allow(WarpEngine.config).to receive(:storage_adapter).and_return(:local)
|
||||
|
||||
expect(described_class.adapter).to be_a(described_class::LocalAdapter)
|
||||
expect(described_class).to be_local
|
||||
end
|
||||
|
||||
it "returns whatever object the host configured" do
|
||||
custom = Object.new
|
||||
allow(WarpEngine.config).to receive(:storage_adapter).and_return(custom)
|
||||
|
||||
expect(described_class.adapter).to eq(custom)
|
||||
expect(described_class).not_to be_local
|
||||
end
|
||||
end
|
||||
|
||||
describe described_class::LocalAdapter do
|
||||
subject(:adapter) { described_class.new }
|
||||
|
||||
before { File.write(File.join(tmpdir, "game-1.0.zip"), "zip") }
|
||||
|
||||
it "sees files and directories under the container" do
|
||||
FileUtils.mkdir_p(File.join(tmpdir, "game-1.0"))
|
||||
|
||||
expect(adapter.file?("game-1.0.zip")).to be(true)
|
||||
expect(adapter.directory?("game-1.0")).to be(true)
|
||||
expect(adapter.file?("missing.zip")).to be(false)
|
||||
end
|
||||
|
||||
# The path traversal guard has to survive the move behind the adapter.
|
||||
it "refuses paths escaping the container" do
|
||||
outside = File.join(Dir.mktmpdir, "secret.txt")
|
||||
File.write(outside, "nope")
|
||||
|
||||
expect(adapter.file?("../#{File.basename(File.dirname(outside))}/secret.txt")).to be(false)
|
||||
end
|
||||
|
||||
it "locates a file as an absolute path" do
|
||||
location = adapter.locate("game-1.0.zip")
|
||||
|
||||
expect(location).to be_file
|
||||
expect(location.path).to eq(File.join(tmpdir, "game-1.0.zip"))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -55,5 +55,38 @@ RSpec.describe WarpEngine::PublishService do
|
||||
|
||||
expect(mock_service).to have_received(:update).with("game", "1.0")
|
||||
end
|
||||
describe "instrumentation" do
|
||||
let(:input) { WarpEngine::PublishInputDto.new(platform: "tic80", name: "game", version: "1.0") }
|
||||
let(:release) { WarpEngine::Release.new(version: "1.0") }
|
||||
|
||||
before do
|
||||
mock_service = instance_double(WarpEngine::Platforms::Tic80::Service, update: release)
|
||||
allow(WarpEngine::Platforms::Tic80::Service).to receive(:new).and_return(mock_service)
|
||||
end
|
||||
|
||||
# The host reacts to a new build through this event instead of hanging a
|
||||
# callback on the Release model.
|
||||
it "emits warp_engine.publish with the release in the payload" do
|
||||
payloads = []
|
||||
ActiveSupport::Notifications.subscribe(described_class::NOTIFICATION) do |*, payload|
|
||||
payloads << payload
|
||||
end
|
||||
|
||||
described_class.new.publish(input)
|
||||
|
||||
expect(payloads.size).to eq(1)
|
||||
expect(payloads.first).to include(platform: "tic80", name: "game", version: "1.0", release: release)
|
||||
ensure
|
||||
ActiveSupport::Notifications.unsubscribe(described_class::NOTIFICATION)
|
||||
end
|
||||
|
||||
it "returns the release" do
|
||||
expect(described_class.new.publish(input)).to eq(release)
|
||||
end
|
||||
|
||||
it "advertises the feature so older hosts can feature-detect" do
|
||||
expect(WarpEngine.instruments_publish?).to be(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
require "rails_helper"
|
||||
require "tmpdir"
|
||||
|
||||
# The serving side (FileService, DownloadService) goes through the storage
|
||||
# adapter. With :local nothing changes; with a custom adapter the same call
|
||||
# can hand back a redirect instead of a file.
|
||||
RSpec.describe "Serving through the storage adapter" do
|
||||
let(:tmpdir) { Dir.mktmpdir }
|
||||
|
||||
# Minimal adapter answering the documented contract.
|
||||
let(:signing_adapter) do
|
||||
Class.new do
|
||||
def file?(_relative) = true
|
||||
def directory?(_relative) = false
|
||||
|
||||
def locate(relative, filename: nil, expires_in: nil)
|
||||
WarpEngine::Storage::Location.redirect("https://cdn.example/#{relative}?signed=1")
|
||||
end
|
||||
end.new
|
||||
end
|
||||
|
||||
before do
|
||||
allow(WarpEngine.config).to receive(:file_container_path).and_return(tmpdir)
|
||||
WarpEngine::Storage.reset!
|
||||
File.write(File.join(tmpdir, "game-1.0.zip"), "zip")
|
||||
end
|
||||
|
||||
after { FileUtils.rm_rf(tmpdir) }
|
||||
|
||||
describe WarpEngine::FileService do
|
||||
it "serves a local file (default adapter)" do
|
||||
allow(WarpEngine.config).to receive(:storage_adapter).and_return(:local)
|
||||
|
||||
result = described_class.new.show(WarpEngine::FileShowInputDto.new(path: "game-1.0.zip"))
|
||||
|
||||
expect(result.type).to eq(:file)
|
||||
expect(result.path).to eq(File.join(tmpdir, "game-1.0.zip"))
|
||||
end
|
||||
|
||||
it "redirects a directory to its index.html" do
|
||||
allow(WarpEngine.config).to receive(:storage_adapter).and_return(:local)
|
||||
FileUtils.mkdir_p(File.join(tmpdir, "game-1.0"))
|
||||
File.write(File.join(tmpdir, "game-1.0", "index.html"), "<h1>hi</h1>")
|
||||
|
||||
result = described_class.new.show(WarpEngine::FileShowInputDto.new(path: "game-1.0"))
|
||||
|
||||
expect(result.type).to eq(:redirect)
|
||||
expect(result.url).to eq("/file/game-1.0/index.html")
|
||||
end
|
||||
|
||||
it "hands back the adapter's redirect when storage is remote" do
|
||||
allow(WarpEngine.config).to receive(:storage_adapter).and_return(signing_adapter)
|
||||
|
||||
result = described_class.new.show(WarpEngine::FileShowInputDto.new(path: "game-1.0.zip"))
|
||||
|
||||
expect(result.type).to eq(:redirect)
|
||||
expect(result.url).to eq("https://cdn.example/game-1.0.zip?signed=1")
|
||||
end
|
||||
|
||||
it "reports a missing file" do
|
||||
allow(WarpEngine.config).to receive(:storage_adapter).and_return(:local)
|
||||
|
||||
result = described_class.new.show(WarpEngine::FileShowInputDto.new(path: "nope.zip"))
|
||||
|
||||
expect(result.type).to eq(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
describe WarpEngine::DownloadService do
|
||||
let(:args) { { ip: "127.0.0.1", user_agent: "rspec", referer: nil } }
|
||||
|
||||
it "keeps returning an absolute path from #create (backward compatible)" do
|
||||
allow(WarpEngine.config).to receive(:storage_adapter).and_return(:local)
|
||||
|
||||
path = described_class.new.create(path: "game-1.0.zip", **args)
|
||||
|
||||
expect(path).to eq(File.join(tmpdir, "game-1.0.zip"))
|
||||
expect(WarpEngine::Download.count).to eq(1)
|
||||
end
|
||||
|
||||
it "returns nil from #create for a missing file" do
|
||||
allow(WarpEngine.config).to receive(:storage_adapter).and_return(:local)
|
||||
|
||||
expect(described_class.new.create(path: "nope.zip", **args)).to be_nil
|
||||
expect(WarpEngine::Download.count).to eq(0)
|
||||
end
|
||||
|
||||
it "returns a location from #locate and logs the download" do
|
||||
allow(WarpEngine.config).to receive(:storage_adapter).and_return(signing_adapter)
|
||||
|
||||
location = described_class.new.locate(path: "game-1.0.zip", **args)
|
||||
|
||||
expect(location).to be_redirect
|
||||
expect(location.url).to include("signed=1")
|
||||
expect(WarpEngine::Download.count).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user