WarpEngine 0.7.0: a képtár és a CI is a hoszté, adapteren keresztül
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful

Két dolog volt beépítve az engine-be, ami nem az övé.

A **képtár** eddig `WarpEngine::Image` volt, pedig a modell teljesen általános:
a teletypegames-ben a tagok arcképét is ez hordozza, nem csak a katalógus
borítóit. Az `Image` modell, a feltöltött fájlok, az `/api/image/:id` végpont
és az admin oldal ezért átkerült a hosztba, az engine pedig adapteren szól
hozzá (`WarpEngine::Images`): `url_for` adja a katalógus JSON `imageUrl`-jét,
`select_options` a software-form képválasztóját, `build_from_upload` a
"tölts fel új képet" ágat. Az alapértelmezés az `Image` osztály, tehát a
default útvonal bitre a régi. A `SoftwareImage` (a katalógus-kapcsolat)
maradt az engine-ben, és **az `images` tábla nem mozdult**: az engine csak
abbahagyta a létrehozását, a generátor írja meg hoszt-kódként.

A **CI** eddig végig Woodpecker volt: kliens, aláírás-ellenőrzés,
pipeline-receptek, repo-szinkron, secret-kiosztás. Mindez egy adapter mögé
került (`WarpEngine.ci`), a Woodpecker-implementáció pedig az engine-ben
maradt `WarpEngine::CI::Woodpecker` néven — kliens, adapter, httpsig-ellenőrző
és a platformonkénti pipeline-receptek, mert a YAML-dialektus a szolgáltatóé.
Az engine saját kódja már nem nevez szolgáltatót: `CI::Repo` és `CI::Run`
értékeket kap, `CI::ConnectionError`/`ApiError`/`NotConfigured` hibákat dob, a
`Pipeline` pedig `remote_repo_id`-t ad a történelmi `woodpecker_repo_id`
kolumna fölött (a tábla itt sem mozdult). `c.ci_adapter = :none` azt jelenti,
hogy ez a hoszt nem buildel: az `/api/ci/*` 503, a `/build/config` elutasít,
az admin akciók elbújnak.

Mindkét seam a hoszt initializerében van kimondva, nem alapértelmezésre
hagyva — a hoszt megnevezi, mi a képtára és mi a CI-ja.

Törés a 0.6-hoz képest: `image_container_path`, `image_owners`,
`ci_platforms`, `ci_update_server`, `ci_extension_public_key(_url)`,
`woodpecker_url`, `woodpecker_api_token`, `woodpecker_repo_owner` és a
`WarpEngine.woodpecker_configured?` megszűnt; a helyük `c.image_class_name` /
`c.image_adapter` és `c.ci_adapter`. Az `/api/ci/*` `latest_run`/`trigger`
válasza a normalizált `CI::Run` alakot adja (number, status, branch, message,
createdAt, url), a `pipelines` lista pedig `repo_id`-t is közöl a megtartott
`woodpecker_repo_id` mellett.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 00:56:01 +02:00
co-authored by Claude Opus 5
parent f2d0b72afd
commit 82b87d019f
72 changed files with 1606 additions and 752 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
PATH
remote: ../libs/ruby/warp_engine
specs:
warp_engine (0.5.2)
warp_engine (0.7.0)
apipie-rails
blueprinter
rails (>= 8.0)
+77
View File
@@ -0,0 +1,77 @@
ActiveAdmin.register Image do
permit_params :file_upload
menu parent: "🌀 WarpEngine", priority: 5, label: "🖼️ Images"
used_ids = -> {
WarpEngine::SoftwareImage.unscope(:order).distinct.pluck(:image_id) +
Member.where.not(image_id: nil).distinct.pluck(:image_id)
}
scope :all, default: true
scope("In use") { |scope| scope.where(id: used_ids.call) }
scope("Orphan") { |scope| scope.where.not(id: used_ids.call) }
batch_action :delete_orphans, confirm: "Delete all selected orphan images and their files?" do |ids|
orphan_ids = ids.map(&:to_i) - used_ids.call
Image.where(id: orphan_ids).find_each do |img|
File.delete(img.file_path) if File.exist?(img.file_path)
img.destroy
end
redirect_to admin_images_path, notice: "Deleted #{orphan_ids.size} orphan image(s)."
end
index do
selectable_column
id_column
column :original_filename
column :content_type
column(:preview) do |img|
if File.exist?(img.file_path)
image_tag("/api/image/#{img.id}", style: "max-height:60px;max-width:120px;object-fit:contain;")
end
end
column(:usage) do |img|
uses = []
uses << "#{img.software_images.size} software(s)" if img.software_images.any?
uses << "member" if img.members.any?
uses.any? ? uses.join(", ") : status_tag("orphan", class: "warning")
end
column :created_at
actions
end
filter :original_filename
filter :content_type
show do
attributes_table do
row :id
row :original_filename
row :filename
row :content_type
row(:preview) do |img|
if File.exist?(img.file_path)
image_tag("/api/image/#{img.id}", style: "max-height:300px;max-width:100%;object-fit:contain;")
else
"File not found on disk"
end
end
row :created_at
row :updated_at
end
end
form html: { multipart: true } do |f|
f.inputs do
f.input :file_upload, as: :file, label: "Image File"
end
f.actions
end
controller do
def scoped_collection
super.includes(:software_images, :members)
end
end
end
+1 -1
View File
@@ -41,7 +41,7 @@ ActiveAdmin.register Member do
f.template.image_tag("/api/image/#{resource.image_id}", style: "max-height:80px;max-width:160px;object-fit:contain;margin-top:6px;").html_safe
end
f.input :image_id, as: :select, label: "Image",
collection: WarpEngine::Image.order(:original_filename).map { |img| [ img.original_filename, img.id ] },
collection: Image.order(:original_filename).map { |img| [ img.original_filename, img.id ] },
include_blank: "— no image (use avatar_filename) —",
hint: img_hint
end
@@ -0,0 +1,15 @@
class Api::ImagesController < ApiController
resource_description do
short "Images"
formats [ "binary" ]
end
api :GET, "/api/image/:id", "Get image by ID"
param :id, :number, required: true, desc: "Image ID"
returns code: 200, desc: "Image binary data"
error code: 404, desc: "Image not found"
def show
image = Image.find(params[:id])
send_file image.file_path, type: image.content_type, disposition: "inline"
end
end
+33
View File
@@ -0,0 +1,33 @@
class Image < ApplicationRecord
def self.upload_path
ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
end
has_many :software_images, class_name: "WarpEngine::SoftwareImage", dependent: :restrict_with_error
has_many :members
default_scope { where(deleted_at: nil) }
attr_accessor :file_upload
before_save :process_upload, if: -> { file_upload.present? }
def self.ransackable_attributes(auth_object = nil)
%w[content_type created_at deleted_at filename id original_filename updated_at]
end
def file_path
File.join(self.class.upload_path, filename.to_s)
end
private
def process_upload
FileUtils.mkdir_p(self.class.upload_path)
self.original_filename = file_upload.original_filename
self.content_type = file_upload.content_type.presence || "application/octet-stream"
ext = File.extname(file_upload.original_filename)
self.filename = "#{SecureRandom.uuid}#{ext}"
IO.copy_stream(file_upload.to_io, file_path)
end
end
+1 -1
View File
@@ -1,5 +1,5 @@
class Member < ApplicationRecord
belongs_to :image, class_name: "WarpEngine::Image", optional: true
belongs_to :image, optional: true
has_one :admin_user
validates :nick, presence: true, uniqueness: true
+16 -22
View File
@@ -4,29 +4,23 @@ Rails.application.config.to_prepare do
c.application_token_source = :database
c.application_token_owner_class = "AdminUser"
c.ci_platforms = {
"godot" => { builder: "git.teletypegames.org/build/godot-builder:4.7.1" },
"phaser" => { builder: "git.teletypegames.org/build/phaser-builder:latest" },
"love" => { builder: "git.teletypegames.org/build/love-builder:latest" },
"bevy" => { builder: "git.teletypegames.org/build/bevy-builder:latest" },
"c64" => { builder: "git.teletypegames.org/build/c64-builder:latest" },
"ebitengine" => { builder: "git.teletypegames.org/build/ebitengine-builder:latest" },
"tic80" => { builder: "git.teletypegames.org/build/tic80pro:latest" }
}
c.image_class_name = "Image"
c.ci_update_server = "https://teletypegames.org"
c.ci_extension_public_key_url = "https://ci.teletypegames.org/api/signature/public-key"
c.woodpecker_url = ENV["WOODPECKER_URL"]
c.woodpecker_api_token = ENV["WOODPECKER_API_TOKEN"]
c.woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"]
c.image_owners = [
{
label: "member",
image_ids: -> { Member.where.not(image_id: nil).distinct.pluck(:image_id) },
usage_label: ->(image) { "member" if Member.where(image_id: image.id).exists? }
c.ci_adapter = WarpEngine::CI::Woodpecker::Adapter.new(
url: ENV["WOODPECKER_URL"],
api_token: ENV["WOODPECKER_API_TOKEN"],
repo_owner: ENV["WOODPECKER_REPO_OWNER"],
public_key_url: "https://ci.teletypegames.org/api/signature/public-key",
update_server: "https://teletypegames.org",
platforms: {
"godot" => { builder: "git.teletypegames.org/build/godot-builder:4.7.1" },
"phaser" => { builder: "git.teletypegames.org/build/phaser-builder:latest" },
"love" => { builder: "git.teletypegames.org/build/love-builder:latest" },
"bevy" => { builder: "git.teletypegames.org/build/bevy-builder:latest" },
"c64" => { builder: "git.teletypegames.org/build/c64-builder:latest" },
"ebitengine" => { builder: "git.teletypegames.org/build/ebitengine-builder:latest" },
"tic80" => { builder: "git.teletypegames.org/build/tic80pro:latest" }
}
]
)
end
end
+1
View File
@@ -7,6 +7,7 @@ Rails.application.routes.draw do
get "swagger", to: "swagger#index"
get "events", to: "events#index"
get "members", to: "members#index"
get "image/:id", to: "images#show"
get "stores", to: "stores#index"
get "wiki/pages", to: "wiki#index"
get "rss/blog", to: "rss#blog"
@@ -0,0 +1,96 @@
require "rails_helper"
require "warden/test/helpers"
RSpec.describe "Admin images", type: :request do
include Warden::Test::Helpers
let(:admin) { AdminUser.create!(email: "images-spec@example.org", password: "password123") }
let(:upload_path) { Rails.root.join("tmp/images-spec").to_s }
before do
FileUtils.mkdir_p(upload_path)
allow(Image).to receive(:upload_path).and_return(upload_path)
Warden.test_mode!
login_as(admin, scope: :admin_user)
end
after do
Warden.test_reset!
FileUtils.rm_rf(upload_path)
end
around do |example|
protection = ActionController::Base.allow_forgery_protection
ActionController::Base.allow_forgery_protection = false
example.run
ActionController::Base.allow_forgery_protection = protection
end
def stored_image(name)
image = create(:image, filename: name, original_filename: name)
File.write(image.file_path, "not really a png")
image
end
it "lists an image with a preview" do
image = stored_image("cover.png")
get "/admin/images"
expect(response).to have_http_status(:ok)
expect(response.body).to include("cover.png")
expect(response.body).to include("/api/image/#{image.id}")
end
it "says which software uses an image" do
image = stored_image("used.png")
software = create(:software)
WarpEngine::SoftwareImage.create!(software: software, image_id: image.id, is_default: true)
get "/admin/images", params: { scope: "in_use" }
expect(response.body).to include("used.png")
expect(response.body).to include("1 software(s)")
end
it "says when a member uses an image" do
image = stored_image("avatar.png")
Member.create!(nick: "mr.zero", real_nick: "Zero", image_id: image.id)
get "/admin/images", params: { scope: "in_use" }
expect(response.body).to include("avatar.png")
expect(response.body).to include("member")
end
it "leaves an unused image in the orphan scope" do
stored_image("orphan.png")
get "/admin/images", params: { scope: "orphan" }
expect(response.body).to include("orphan.png")
end
it "deletes an orphan with its file" do
orphan = stored_image("gone.png")
kept = stored_image("kept.png")
Member.create!(nick: "keeper", real_nick: "Keeper", image_id: kept.id)
post "/admin/images/batch_action",
params: { batch_action: "delete_orphans", collection_selection: [ orphan.id, kept.id ] }
expect(Image.where(id: orphan.id)).to be_empty
expect(File.exist?(orphan.file_path)).to be(false)
expect(Image.where(id: kept.id)).to be_present
expect(File.exist?(kept.file_path)).to be(true)
end
it "opens an image's own page" do
image = stored_image("single.png")
get "/admin/images/#{image.id}"
expect(response).to have_http_status(:ok)
expect(response.body).to include("single.png")
end
end
+37
View File
@@ -0,0 +1,37 @@
require "rails_helper"
RSpec.describe "GET /api/image/:id", type: :request do
let(:upload_path) { Rails.root.join("tmp/images-spec").to_s }
before do
FileUtils.mkdir_p(upload_path)
allow(Image).to receive(:upload_path).and_return(upload_path)
end
after { FileUtils.rm_rf(upload_path) }
it "serves the file with the content type it was stored with" do
image = create(:image, filename: "cover.png", content_type: "image/png")
File.write(image.file_path, "not really a png")
get "/api/image/#{image.id}"
expect(response).to have_http_status(:ok)
expect(response.media_type).to eq("image/png")
expect(response.body).to eq("not really a png")
end
it "answers 404 for an image that does not exist" do
get "/api/image/999999"
expect(response).to have_http_status(:not_found)
end
it "errors, as it always has, when the row is there but the file is not" do
image = create(:image, filename: "missing.png")
get "/api/image/#{image.id}"
expect(response).to have_http_status(:internal_server_error)
end
end