WarpEngine 0.7.0: a képtár és a CI is a hoszté, adapteren keresztül

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 a0fbf1e2b4
commit 19d142ef64
62 changed files with 1391 additions and 793 deletions
+22 -18
View File
@@ -1,31 +1,35 @@
require "rails_helper"
RSpec.describe WarpEngine::PipelineService do
let(:client) { instance_double(WarpEngine::WoodpeckerClient) }
let(:service) { described_class.new(client: client) }
let(:ci) { instance_double(WarpEngine::CI::Woodpecker::Adapter) }
let(:service) { described_class.new(ci: ci) }
def ci_run(number:, status: "success", created_at: nil)
WarpEngine::CI::Run.new(number: number, status: status, created_at: created_at)
end
describe "#trigger" do
it "delegates to client" do
repo = build(:pipeline, repo_owner: "org", repo_name: "game")
allow(client).to receive(:trigger_pipeline).and_return({ "number" => 6 })
allow(ci).to receive(:trigger).and_return(ci_run(number: 6, status: "pending"))
result = service.trigger(repo, branch: "main")
expect(result["number"]).to eq(6)
expect(client).to have_received(:trigger_pipeline).with(repo.woodpecker_repo_id, branch: "main")
expect(result.number).to eq(6)
expect(ci).to have_received(:trigger).with(repo.remote_repo_id, branch: "main")
end
end
describe "#list_pipelines" do
it "returns paginated pipelines and refreshes the repo's cached last pipeline" do
describe "#runs" do
it "returns the runs and refreshes the repo's cached last run" do
repo = create(:pipeline, repo_owner: "org", repo_name: "game")
pipelines = [
{ "number" => 2, "status" => "failure", "created" => 1_754_500_000 },
{ "number" => 1, "status" => "success", "created" => 1_754_400_000 }
runs = [
ci_run(number: 2, status: "failure", created_at: Time.zone.at(1_754_500_000)),
ci_run(number: 1, status: "success", created_at: Time.zone.at(1_754_400_000))
]
allow(client).to receive(:list_pipelines).with(repo.woodpecker_repo_id, page: 1).and_return(pipelines)
allow(ci).to receive(:runs).with(repo.remote_repo_id, page: 1).and_return(runs)
expect(service.list_pipelines(repo)).to eq(pipelines)
expect(service.runs(repo)).to eq(runs)
repo.reload
expect(repo.last_pipeline_status).to eq("failure")
@@ -35,20 +39,20 @@ RSpec.describe WarpEngine::PipelineService do
it "does not touch the cache on later pages" do
repo = create(:pipeline, repo_owner: "org", repo_name: "game",
last_pipeline_status: "success")
allow(client).to receive(:list_pipelines).with(repo.woodpecker_repo_id, page: 2)
.and_return([{ "number" => 1, "status" => "failure", "created" => 1_754_400_000 }])
allow(ci).to receive(:runs).with(repo.remote_repo_id, page: 2)
.and_return([ ci_run(number: 1, status: "failure", created_at: Time.zone.at(1_754_400_000)) ])
service.list_pipelines(repo, page: 2)
service.runs(repo, page: 2)
expect(repo.reload.last_pipeline_status).to eq("success")
end
it "leaves the cache alone when the repo has no pipelines" do
it "leaves the cache alone when the repo has no runs" do
repo = create(:pipeline, repo_owner: "org", repo_name: "game",
last_pipeline_status: "success")
allow(client).to receive(:list_pipelines).and_return([])
allow(ci).to receive(:runs).and_return([])
expect(service.list_pipelines(repo)).to eq([])
expect(service.runs(repo)).to eq([])
expect(repo.reload.last_pipeline_status).to eq("success")
end
end
+17 -21
View File
@@ -1,14 +1,16 @@
require "rails_helper"
RSpec.describe WarpEngine::PipelineSyncService do
let(:client) { instance_double(WarpEngine::WoodpeckerClient) }
let(:service) { described_class.new(client: client) }
let(:ci) { instance_double(WarpEngine::CI::Woodpecker::Adapter) }
let(:service) { described_class.new(ci: ci) }
def ci_repo(id:, name: "mygame", owner: "org", active: true)
WarpEngine::CI::Repo.new(id: id, name: name, owner: owner, active: active)
end
describe "#sync_all" do
it "creates new Pipeline records from Woodpecker" do
allow(client).to receive(:list_repos).and_return([
{ "id" => 1, "name" => "mygame", "owner" => "org", "active" => true }
])
it "creates new Pipeline records from the CI provider" do
allow(ci).to receive(:repos).and_return([ ci_repo(id: 1) ])
result = service.sync_all
@@ -21,9 +23,7 @@ RSpec.describe WarpEngine::PipelineSyncService do
it "updates existing records" do
existing = create(:pipeline, woodpecker_repo_id: 1, repo_name: "old", platform: "tic80")
allow(client).to receive(:list_repos).and_return([
{ "id" => 1, "name" => "newname", "owner" => "org", "active" => true }
])
allow(ci).to receive(:repos).and_return([ ci_repo(id: 1, name: "newname") ])
result = service.sync_all
@@ -31,9 +31,9 @@ RSpec.describe WarpEngine::PipelineSyncService do
expect(existing.reload.repo_name).to eq("newname")
end
it "deactivates repos missing from Woodpecker" do
it "deactivates repos the provider no longer has" do
orphan = create(:pipeline, woodpecker_repo_id: 99, active: true)
allow(client).to receive(:list_repos).and_return([])
allow(ci).to receive(:repos).and_return([])
result = service.sync_all
@@ -43,9 +43,7 @@ RSpec.describe WarpEngine::PipelineSyncService do
it "auto-detects platform from matching Software" do
create(:software, name: "mygame", platform: "godot")
allow(client).to receive(:list_repos).and_return([
{ "id" => 1, "name" => "mygame", "owner" => "org", "active" => true }
])
allow(ci).to receive(:repos).and_return([ ci_repo(id: 1) ])
result = service.sync_all
@@ -55,11 +53,9 @@ RSpec.describe WarpEngine::PipelineSyncService do
end
describe "#activate" do
it "calls client and syncs the repo" do
allow(client).to receive(:activate_repo).with(42)
allow(client).to receive(:get_repo).with(42).and_return(
{ "id" => 42, "name" => "game", "owner" => "org", "active" => true }
)
it "calls the provider and syncs the repo" do
allow(ci).to receive(:activate_repo).with(42)
allow(ci).to receive(:repo).with(42).and_return(ci_repo(id: 42, name: "game"))
repo = service.activate(42)
@@ -69,9 +65,9 @@ RSpec.describe WarpEngine::PipelineSyncService do
end
describe "#deactivate" do
it "calls client and marks repo inactive" do
it "calls the provider and marks the repo inactive" do
repo = create(:pipeline, woodpecker_repo_id: 42, active: true)
allow(client).to receive(:deactivate_repo).with(42)
allow(ci).to receive(:deactivate_repo).with(42)
service.deactivate(42)
+13 -29
View File
@@ -1,8 +1,8 @@
require "rails_helper"
RSpec.describe WarpEngine::SecretSyncService do
let(:client) { instance_double(WarpEngine::WoodpeckerClient) }
let(:service) { described_class.new(client: client) }
let(:ci) { instance_double(WarpEngine::CI::Woodpecker::Adapter) }
let(:service) { described_class.new(ci: ci) }
before do
allow(WarpEngine.config).to receive(:application_token_source).and_return(:database)
@@ -10,37 +10,22 @@ RSpec.describe WarpEngine::SecretSyncService do
end
describe "#provision" do
it "creates secrets on repos that don't have one" do
it "hands the token to the provider for every pipeline" do
repo = create(:pipeline)
allow(client).to receive(:list_secrets).with(repo.woodpecker_repo_id).and_return([])
allow(client).to receive(:create_secret)
allow(ci).to receive(:secret_set)
result = service.provision("plaintoken", pipelines: [ repo ])
expect(result[:synced]).to eq([ repo ])
expect(client).to have_received(:create_secret).with(
repo.woodpecker_repo_id, name: "application_token", value: "plaintoken"
)
end
it "updates secrets on repos that already have one" do
repo = create(:pipeline)
allow(client).to receive(:list_secrets).with(repo.woodpecker_repo_id)
.and_return([ { "name" => "application_token" } ])
allow(client).to receive(:update_secret)
result = service.provision("newtoken", pipelines: [ repo ])
expect(result[:synced]).to eq([ repo ])
expect(client).to have_received(:update_secret).with(
repo.woodpecker_repo_id, "application_token", value: "newtoken"
expect(ci).to have_received(:secret_set).with(
repo.remote_repo_id, name: "application_token", value: "plaintoken"
)
end
it "records failed repos without raising" do
repo = create(:pipeline)
allow(client).to receive(:list_secrets).and_raise(
WarpEngine::WoodpeckerClient::ConnectionError, "unreachable"
allow(ci).to receive(:secret_set).and_raise(
WarpEngine::CI::ConnectionError, "unreachable"
)
result = service.provision("tok", pipelines: [ repo ])
@@ -56,11 +41,11 @@ RSpec.describe WarpEngine::SecretSyncService do
sw = create(:software, name: "game1", owner: token.owner)
repo = create(:pipeline, :with_software, software: sw)
allow(client).to receive(:delete_secret)
allow(ci).to receive(:secret_delete)
service.deprovision(token)
expect(client).to have_received(:delete_secret).with(repo.woodpecker_repo_id, "application_token")
expect(ci).to have_received(:secret_delete).with(repo.remote_repo_id, "application_token")
end
end
@@ -70,8 +55,7 @@ RSpec.describe WarpEngine::SecretSyncService do
sw = create(:software, name: "game1", owner: token.owner)
repo = create(:pipeline, :with_software, software: sw)
allow(client).to receive(:list_secrets).and_return([])
allow(client).to receive(:create_secret)
allow(ci).to receive(:secret_set)
result = service.rotate(token)
@@ -85,8 +69,8 @@ RSpec.describe WarpEngine::SecretSyncService do
sw = create(:software, name: "game1", owner: token.owner)
create(:pipeline, :with_software, software: sw)
allow(client).to receive(:list_secrets).and_raise(
WarpEngine::WoodpeckerClient::ConnectionError, "down"
allow(ci).to receive(:secret_set).and_raise(
WarpEngine::CI::ConnectionError, "down"
)
result = service.rotate(token)
-127
View File
@@ -1,127 +0,0 @@
require "rails_helper"
require "webmock/rspec"
RSpec.describe WarpEngine::WoodpeckerClient do
let(:base_url) { "https://ci.example.test" }
let(:token) { "wp-test-token" }
let(:client) { described_class.new(base_url: base_url, token: token) }
def stub_wp(method, path, status: 200, body: nil, request_body: nil)
stub = stub_request(method, "#{base_url}#{path}")
.with(headers: { "Authorization" => "Bearer #{token}", "Accept" => "application/json" })
stub = stub.with(body: request_body) if request_body
stub.to_return(status: status, body: body&.to_json, headers: { "Content-Type" => "application/json" })
end
describe "repos" do
it "lists repos" do
repos = [{ "id" => 1, "name" => "game1" }]
stub_wp(:get, "/api/repos", body: repos)
expect(client.list_repos).to eq(repos)
end
it "gets a repo" do
repo = { "id" => 42, "name" => "mygame" }
stub_wp(:get, "/api/repos/42", body: repo)
expect(client.get_repo(42)).to eq(repo)
end
it "deactivates a repo" do
stub_wp(:delete, "/api/repos/42", status: 204)
expect(client.deactivate_repo(42)).to be_nil
end
end
describe "secrets" do
it "lists secrets" do
secrets = [{ "name" => "application_token" }]
stub_wp(:get, "/api/repos/1/secrets", body: secrets)
expect(client.list_secrets(1)).to eq(secrets)
end
it "creates a secret" do
stub_wp(:post, "/api/repos/1/secrets", status: 200, body: { "name" => "application_token" })
result = client.create_secret(1, name: "application_token", value: "secret123")
expect(result["name"]).to eq("application_token")
end
it "updates a secret" do
stub_wp(:patch, "/api/repos/1/secrets/application_token", status: 200, body: { "name" => "application_token" })
result = client.update_secret(1, "application_token", value: "newsecret")
expect(result["name"]).to eq("application_token")
end
it "deletes a secret" do
stub_wp(:delete, "/api/repos/1/secrets/application_token", status: 204)
expect(client.delete_secret(1, "application_token")).to be_nil
end
end
describe "pipelines" do
it "lists pipelines" do
pipelines = [{ "number" => 1, "status" => "success" }]
stub_wp(:get, "/api/repos/42/pipelines?page=1&perPage=25", body: pipelines)
expect(client.list_pipelines(42)).to eq(pipelines)
end
it "gets latest pipeline" do
pipeline = { "number" => 5, "status" => "running" }
stub_wp(:get, "/api/repos/42/pipelines/latest", body: pipeline)
expect(client.latest_pipeline(42)).to eq(pipeline)
end
it "triggers a pipeline" do
pipeline = { "number" => 6, "status" => "pending" }
stub_wp(:post, "/api/repos/42/pipelines", body: pipeline)
expect(client.trigger_pipeline(42, branch: "main")).to eq(pipeline)
end
end
describe "error handling" do
it "raises ApiError on 404" do
stub_wp(:get, "/api/repos/999", status: 404, body: { "error" => "not found" })
expect { client.get_repo(999) }.to raise_error(WarpEngine::WoodpeckerClient::ApiError) { |e|
expect(e.status).to eq(404)
}
end
it "raises ApiError on 500" do
stub_wp(:get, "/api/repos", status: 500, body: { "error" => "internal" })
expect { client.list_repos }.to raise_error(WarpEngine::WoodpeckerClient::ApiError) { |e|
expect(e.status).to eq(500)
}
end
it "raises ConnectionError on connection refused" do
stub_request(:get, "#{base_url}/api/repos").to_raise(Errno::ECONNREFUSED)
expect { client.list_repos }.to raise_error(WarpEngine::WoodpeckerClient::ConnectionError)
end
it "raises ConnectionError on timeout" do
stub_request(:get, "#{base_url}/api/repos").to_timeout
expect { client.list_repos }.to raise_error(WarpEngine::WoodpeckerClient::ConnectionError)
end
it "raises ApiError when a 200 response is not JSON" do
stub_request(:get, "#{base_url}/api/repos")
.to_return(status: 200, body: "<!doctype html><html></html>",
headers: { "Content-Type" => "text/html" })
expect { client.list_repos }.to raise_error(WarpEngine::WoodpeckerClient::ApiError, /Expected JSON/)
end
end
end