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:
2026-08-10 11:16:17 +02:00
parent 2a94fc785f
commit 03a51a5b30
13 changed files with 442 additions and 33 deletions
+13
View File
@@ -3,6 +3,7 @@ require "apipie-rails"
require "warp_engine/version"
require "warp_engine/configuration"
require "warp_engine/storage"
module WarpEngine
# A tábláink prefix nélküliek (softwares, releases, ...) — az isolate_namespace
@@ -23,6 +24,18 @@ module WarpEngine
def self.woodpecker_configured?
config.woodpecker_url.present? && config.woodpecker_api_token.present?
end
# A host innen tudja, hogy a publikálás ActiveSupport::Notifications-t szór
# ("warp_engine.publish"), és nem kell modell-callbackre kapaszkodnia.
# Régebbi engine-verziókon a metódus nem létezik, ezért a hívó oldalon
# respond_to?-val kérdezendő.
def self.instruments_publish?
true
end
def self.storage
Storage.adapter
end
end
require "warp_engine/engine"
+9 -1
View File
@@ -9,6 +9,12 @@ module WarpEngine
# :database — only WarpEngine::ApplicationToken is accepted, the shared secret is not
# application_token_owner_class: class name of the mandatory token owner
# (e.g. "AdminUser"); nil makes :database mode reject every request.
# storage_adapter: where build artifacts are served from.
# :local (default) — the local filesystem under file_container_path,
# byte for byte the previous behaviour;
# any object — must answer file?/directory?/locate, see
# WarpEngine::Storage. Serving only: uploads and
# archive extraction still write to the local disk.
# max_upload_size: file size cap in bytes for /build/upload (and the admin file manager).
# enforce_software_ownership: when true, a DB token may only upload/publish
# its own owner's softwares (unrestricted tokens are exempt).
@@ -37,7 +43,8 @@ module WarpEngine
:woodpecker_url,
:woodpecker_api_token,
:woodpecker_repo_owner,
:image_owners
:image_owners,
:storage_adapter
def initialize
@file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
@@ -55,6 +62,7 @@ module WarpEngine
@woodpecker_api_token = ENV["WOODPECKER_API_TOKEN"]
@woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"]
@image_owners = []
@storage_adapter = :local
end
end
end
+92
View File
@@ -0,0 +1,92 @@
module WarpEngine
# Where the artifacts physically live.
#
# Until now every path in the engine was a local filesystem path. This module
# is the seam a host needs to serve builds from somewhere else (an object
# store behind a CDN, for example) without patching the engine.
#
# The default adapter is :local and behaves exactly as before - same paths,
# same traversal protection, same File.file? checks.
#
# A custom adapter is any object answering to this contract:
#
# file?(relative_path) -> true/false
# directory?(relative_path) -> true/false
# locate(relative_path, filename: nil, expires_in: nil) -> Location
#
# It is set on the configuration:
#
# c.storage_adapter = MyObjectStore.new # or :local (default)
#
# NOTE: ingestion (build/upload, archive extraction, the admin file manager)
# still writes to the local filesystem. A remote adapter therefore needs its
# own upload path today; the serving side is what this seam covers.
module Storage
Location = Struct.new(:kind, :path, :url, keyword_init: true) do
def file? = kind == :file
def redirect? = kind == :redirect
def self.file(path) = new(kind: :file, path: path)
def self.redirect(url) = new(kind: :redirect, url: url)
end
# The local filesystem, rooted at config.file_container_path.
class LocalAdapter
def base_path
Pathname.new(WarpEngine.config.file_container_path)
end
def absolute_path(relative_path)
base_path.join(relative_path.to_s)
end
def file?(relative_path)
path = absolute_path(relative_path)
File.file?(path) && inside_base?(path)
end
def directory?(relative_path)
path = absolute_path(relative_path)
File.directory?(path) && inside_base?(path)
end
# expires_in is part of the contract for signing adapters; the local
# filesystem has nothing to sign, so it is ignored here.
def locate(relative_path, filename: nil, expires_in: nil)
Location.file(absolute_path(relative_path).to_s)
end
private
# Path traversal guard: the resolved path must stay under the container.
def inside_base?(path)
root = base_path.realpath.to_s
Pathname.new(path).realpath.to_s.start_with?(root)
rescue Errno::ENOENT
false
end
end
class << self
def adapter
configured = WarpEngine.config.storage_adapter
case configured
when nil, :local, "local" then local_adapter
else configured
end
end
def local? = adapter.is_a?(LocalAdapter)
def local_adapter
@local_adapter ||= LocalAdapter.new
end
# Tests and hosts that swap the configuration at runtime.
def reset!
@local_adapter = nil
end
end
end
end
+1 -1
View File
@@ -1,3 +1,3 @@
module WarpEngine
VERSION = "0.1.0"
VERSION = "0.2.0"
end