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:
@@ -1,10 +1,10 @@
|
||||
require "rails_helper"
|
||||
require "webmock/rspec"
|
||||
|
||||
RSpec.describe WarpEngine::WoodpeckerClient do
|
||||
RSpec.describe WarpEngine::CI::Woodpecker::Client do
|
||||
let(:base_url) { "https://ci.example.test" }
|
||||
let(:token) { "wp-test-token" }
|
||||
let(:client) { described_class.new(base_url: base_url, token: token) }
|
||||
let(:client) { described_class.new(url: base_url, token: token) }
|
||||
|
||||
def stub_wp(method, path, status: 200, body: nil, request_body: nil)
|
||||
stub = stub_request(method, "#{base_url}#{path}")
|
||||
@@ -91,7 +91,7 @@ RSpec.describe WarpEngine::WoodpeckerClient 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 { client.get_repo(999) }.to raise_error(WarpEngine::CI::ApiError) { |e|
|
||||
expect(e.status).to eq(404)
|
||||
}
|
||||
end
|
||||
@@ -99,7 +99,7 @@ RSpec.describe WarpEngine::WoodpeckerClient do
|
||||
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 { client.list_repos }.to raise_error(WarpEngine::CI::ApiError) { |e|
|
||||
expect(e.status).to eq(500)
|
||||
}
|
||||
end
|
||||
@@ -107,13 +107,13 @@ RSpec.describe WarpEngine::WoodpeckerClient do
|
||||
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)
|
||||
expect { client.list_repos }.to raise_error(WarpEngine::CI::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)
|
||||
expect { client.list_repos }.to raise_error(WarpEngine::CI::ConnectionError)
|
||||
end
|
||||
|
||||
it "raises ApiError when a 200 response is not JSON" do
|
||||
@@ -121,7 +121,7 @@ RSpec.describe WarpEngine::WoodpeckerClient do
|
||||
.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/)
|
||||
expect { client.list_repos }.to raise_error(WarpEngine::CI::ApiError, /Expected JSON/)
|
||||
end
|
||||
end
|
||||
end
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe WarpEngine::CI do
|
||||
after do
|
||||
WarpEngine.config.ci_adapter = :woodpecker
|
||||
WarpEngine::CI.reset!
|
||||
end
|
||||
|
||||
describe ".adapter" do
|
||||
it "drives Woodpecker unless the host says otherwise" do
|
||||
expect(WarpEngine.ci).to be_a(WarpEngine::CI::Woodpecker::Adapter)
|
||||
expect(WarpEngine.ci.name).to eq("Woodpecker")
|
||||
end
|
||||
|
||||
it "answers with the null adapter for a host that runs no CI" do
|
||||
WarpEngine.config.ci_adapter = :none
|
||||
|
||||
expect(WarpEngine.ci).to be_a(WarpEngine::CI::Null)
|
||||
expect(WarpEngine.ci).not_to be_configured
|
||||
end
|
||||
|
||||
it "hands back whatever object the host named" do
|
||||
own = Class.new { def name = "Forge Runner" }.new
|
||||
WarpEngine.config.ci_adapter = own
|
||||
|
||||
expect(WarpEngine.ci).to be(own)
|
||||
end
|
||||
end
|
||||
|
||||
describe WarpEngine::CI::Null do
|
||||
let(:adapter) { described_class.new }
|
||||
|
||||
it "serves no platform and verifies no request" do
|
||||
expect(adapter.platforms).to be_empty
|
||||
expect(adapter.verify_config_request(nil)).to be(false)
|
||||
expect(adapter.config_marker({})).to be_nil
|
||||
expect(adapter.pipeline_config(platform: "godot", name: "x", update_server: "y")).to be_nil
|
||||
end
|
||||
|
||||
it "raises rather than pretending to manage repositories" do
|
||||
expect { adapter.repos }.to raise_error(WarpEngine::CI::NotConfigured)
|
||||
expect { adapter.trigger(1) }.to raise_error(WarpEngine::CI::NotConfigured)
|
||||
end
|
||||
end
|
||||
|
||||
describe WarpEngine::CI::Woodpecker::Adapter do
|
||||
let(:client) { instance_double(WarpEngine::CI::Woodpecker::Client) }
|
||||
let(:adapter) do
|
||||
described_class.new(url: "https://ci.test", api_token: "tok",
|
||||
platforms: { "godot" => { builder: "registry.test/godot:1" } },
|
||||
update_server: "https://games.test")
|
||||
end
|
||||
|
||||
before { allow(adapter).to receive(:client).and_return(client) }
|
||||
|
||||
it "is inactive without a server and a token" do
|
||||
expect(described_class.new(url: nil, api_token: nil)).not_to be_configured
|
||||
expect(adapter).to be_configured
|
||||
end
|
||||
|
||||
it "normalizes a repository" do
|
||||
allow(client).to receive(:list_repos).and_return([
|
||||
{ "id" => 7, "name" => "game", "owner" => "org", "active" => true }
|
||||
])
|
||||
|
||||
repo = adapter.repos.first
|
||||
|
||||
expect(repo.id).to eq(7)
|
||||
expect(repo.full_name).to eq("org/game")
|
||||
expect(repo).to be_active
|
||||
end
|
||||
|
||||
it "normalizes a run, timestamp included" do
|
||||
allow(client).to receive(:trigger_pipeline).and_return(
|
||||
{ "number" => 4, "status" => "success", "branch" => "main",
|
||||
"message" => "ship it", "created" => 1_754_500_000 }
|
||||
)
|
||||
|
||||
run = adapter.trigger(7, branch: "main")
|
||||
|
||||
expect(run.number).to eq(4)
|
||||
expect(run).to be_success
|
||||
expect(run.created_at).to eq(Time.zone.at(1_754_500_000))
|
||||
expect(run.as_json[:createdAt]).to be_present
|
||||
end
|
||||
|
||||
it "creates a secret the repository does not have yet" do
|
||||
allow(client).to receive(:list_secrets).and_return([])
|
||||
allow(client).to receive(:create_secret)
|
||||
|
||||
adapter.secret_set(7, name: "application_token", value: "plain")
|
||||
|
||||
expect(client).to have_received(:create_secret).with(
|
||||
7, name: "application_token", value: "plain", events: described_class::SECRET_EVENTS
|
||||
)
|
||||
end
|
||||
|
||||
it "updates a secret the repository already has" do
|
||||
allow(client).to receive(:list_secrets).and_return([ { "name" => "application_token" } ])
|
||||
allow(client).to receive(:update_secret)
|
||||
|
||||
adapter.secret_set(7, name: "application_token", value: "plain")
|
||||
|
||||
expect(client).to have_received(:update_secret).with(7, "application_token", value: "plain")
|
||||
end
|
||||
|
||||
it "reads the platform marker out of a config request" do
|
||||
params = ActionController::Parameters.new(
|
||||
repo: { name: "mygame" },
|
||||
configuration: [ { name: ".woodpecker.yaml", data: "platform: godot\n" } ]
|
||||
)
|
||||
|
||||
expect(adapter.config_marker(params)).to eq({ platform: "godot", name: "mygame" })
|
||||
end
|
||||
|
||||
it "ignores a config request that is not a marker" do
|
||||
params = ActionController::Parameters.new(
|
||||
configuration: [ { name: ".woodpecker.yaml", data: "steps:\n - name: build\n" } ]
|
||||
)
|
||||
|
||||
expect(adapter.config_marker(params)).to be_nil
|
||||
end
|
||||
|
||||
it "renders only the platforms it was given" do
|
||||
expect(adapter.pipeline_config(platform: "godot", name: "mygame",
|
||||
update_server: "https://games.test")).to include("registry.test/godot:1")
|
||||
expect(adapter.pipeline_config(platform: "amiga", name: "mygame",
|
||||
update_server: "https://games.test")).to be_nil
|
||||
end
|
||||
|
||||
it "shapes the response the way the CI server expects" do
|
||||
expect(adapter.config_response(platform: "godot", config: "steps: []"))
|
||||
.to eq({ configs: [ { name: "godot", data: "steps: []" } ] })
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
class Image < ActiveRecord::Base
|
||||
def self.upload_path
|
||||
Rails.root.join("tmp/images").to_s
|
||||
end
|
||||
|
||||
has_many :software_images, class_name: "WarpEngine::SoftwareImage", dependent: :restrict_with_error
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
attr_accessor :file_upload
|
||||
|
||||
before_save :process_upload, if: -> { file_upload.present? }
|
||||
|
||||
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"
|
||||
self.filename = "#{SecureRandom.uuid}#{File.extname(file_upload.original_filename)}"
|
||||
IO.copy_stream(file_upload.to_io, file_path)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
FactoryBot.define do
|
||||
factory :image, class: "Image" do
|
||||
sequence(:filename) { |n| "image-#{n}.png" }
|
||||
original_filename { "image.png" }
|
||||
content_type { "image/png" }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,51 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe WarpEngine::Images do
|
||||
after { WarpEngine.config.image_adapter = nil }
|
||||
|
||||
describe "the default adapter" do
|
||||
it "is the host model named in the configuration" do
|
||||
expect(described_class.model_name).to eq("Image")
|
||||
expect(described_class.model).to eq(Image)
|
||||
end
|
||||
|
||||
it "serves images from the address the clients have always used" do
|
||||
expect(described_class.url_for(7)).to eq("/api/image/7")
|
||||
expect(described_class.url_for(nil)).to be_nil
|
||||
end
|
||||
|
||||
it "offers the host's images to an admin select" do
|
||||
image = create(:image, original_filename: "cover.png")
|
||||
|
||||
expect(described_class.select_options).to include([ "cover.png", image.id ])
|
||||
end
|
||||
|
||||
it "builds a host image from an upload" do
|
||||
expect(described_class.build_from_upload(nil)).to be_a(Image)
|
||||
end
|
||||
end
|
||||
|
||||
describe "a host adapter" do
|
||||
it "decides where an image is served from" do
|
||||
WarpEngine.config.image_adapter = Class.new do
|
||||
def url_for(image_id) = "https://cdn.example.com/#{image_id}.png"
|
||||
end.new
|
||||
|
||||
expect(described_class.url_for(9)).to eq("https://cdn.example.com/9.png")
|
||||
end
|
||||
end
|
||||
|
||||
it "links the catalog to the host's image model" do
|
||||
expect(WarpEngine::SoftwareImage.reflect_on_association(:image).klass).to eq(Image)
|
||||
end
|
||||
|
||||
it "puts the adapter URL in the catalog payload" do
|
||||
software = create(:software)
|
||||
image = create(:image)
|
||||
WarpEngine::SoftwareImage.create!(software: software, image_id: image.id, is_default: true)
|
||||
|
||||
payload = WarpEngine::SoftwareSerializer.render_as_hash(software.reload)
|
||||
|
||||
expect(payload[:imageUrl]).to eq("/api/image/#{image.id}")
|
||||
end
|
||||
end
|
||||
@@ -33,14 +33,14 @@ RSpec.describe "Engine migrations" do
|
||||
end
|
||||
|
||||
it "does not create a table the install generator also creates" do
|
||||
template = WarpEngine::Engine.root.join(
|
||||
"lib/generators/warp_engine/install/templates/create_warp_engine_tables.rb"
|
||||
templates = Dir.glob(
|
||||
WarpEngine::Engine.root.join("lib/generators/warp_engine/install/templates/create_*.rb")
|
||||
)
|
||||
engine_tables = versions.flat_map do |version|
|
||||
file = Dir.glob(WarpEngine::Engine.root.join("db/migrate/#{version}_*.rb")).first
|
||||
File.read(file).scan(/create_table :(\w+)/).flatten
|
||||
end
|
||||
template_tables = File.read(template).scan(/create_table :(\w+)/).flatten
|
||||
template_tables = templates.flat_map { |t| File.read(t).scan(/create_table :(\w+)/).flatten }
|
||||
duplicated = (engine_tables & template_tables) - [ "application_tokens" ]
|
||||
|
||||
expect(duplicated).to be_empty,
|
||||
|
||||
@@ -10,11 +10,21 @@ RSpec.describe "Build configs endpoint", type: :request do
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(WarpEngine.config).to receive(:ci_platforms).and_return(ci_platforms)
|
||||
allow(WarpEngine.config).to receive(:ci_extension_public_key).and_return(signing_key.public_to_pem)
|
||||
def woodpecker(platforms: nil, public_key: :default)
|
||||
WarpEngine::CI::Woodpecker::Adapter.new(
|
||||
url: "https://ci.example.test",
|
||||
api_token: "wp-token",
|
||||
platforms: platforms.nil? ? ci_platforms : platforms,
|
||||
public_key: public_key == :default ? signing_key.public_to_pem : public_key
|
||||
)
|
||||
end
|
||||
|
||||
def serving(adapter)
|
||||
allow(WarpEngine).to receive(:ci).and_return(adapter)
|
||||
end
|
||||
|
||||
before { serving(woodpecker) }
|
||||
|
||||
def signed_headers(body, path: "/build/config", digest_body: nil)
|
||||
digest = "sha-256=:#{Digest::SHA256.base64digest(digest_body || body)}:"
|
||||
inner = %{("@request-target" "content-digest");created=#{Time.now.to_i};alg="ed25519"}
|
||||
@@ -69,7 +79,7 @@ RSpec.describe "Build configs endpoint", type: :request do
|
||||
end
|
||||
|
||||
it "returns 404 when the feature is not configured" do
|
||||
allow(WarpEngine.config).to receive(:ci_platforms).and_return({})
|
||||
serving(woodpecker(platforms: {}))
|
||||
|
||||
get "/build/config", params: { platform: "godot" }
|
||||
|
||||
@@ -146,7 +156,7 @@ RSpec.describe "Build configs endpoint", type: :request do
|
||||
payload = extension_payload("platform: godot\n")
|
||||
headers = signed_headers(payload)
|
||||
other_key = OpenSSL::PKey.generate_key("ed25519")
|
||||
allow(WarpEngine.config).to receive(:ci_extension_public_key).and_return(other_key.public_to_pem)
|
||||
serving(woodpecker(public_key: other_key.public_to_pem))
|
||||
|
||||
post "/build/config", params: payload, headers: headers
|
||||
|
||||
@@ -170,8 +180,7 @@ RSpec.describe "Build configs endpoint", type: :request do
|
||||
end
|
||||
|
||||
it "rejects every request when no public key is configured" do
|
||||
allow(WarpEngine.config).to receive(:ci_extension_public_key).and_return(nil)
|
||||
allow(WarpEngine.config).to receive(:ci_extension_public_key_url).and_return(nil)
|
||||
serving(woodpecker(public_key: nil))
|
||||
|
||||
payload = extension_payload("platform: godot\n")
|
||||
post "/build/config", params: payload, headers: signed_headers(payload)
|
||||
@@ -182,16 +191,17 @@ RSpec.describe "Build configs endpoint", type: :request do
|
||||
|
||||
describe "shipped templates" do
|
||||
it "renders every template to valid YAML with non-empty steps" do
|
||||
templates = Dir[WarpEngine::Engine.root.join("app/services/warp_engine/platforms/*/pipeline.yaml.erb")]
|
||||
templates = Dir[WarpEngine::CI::Woodpecker::PipelineConfig.templates_dir.join("*/pipeline.yaml.erb")]
|
||||
expect(templates).not_to be_empty
|
||||
|
||||
templates.each do |path|
|
||||
platform = File.basename(File.dirname(path))
|
||||
allow(WarpEngine.config).to receive(:ci_platforms).and_return(
|
||||
platform => { builder: "registry.example/builder:1", exporter: "registry.example/exporter:1" }
|
||||
config = WarpEngine::CI::Woodpecker::PipelineConfig.new(
|
||||
platforms: { platform => { builder: "registry.example/builder:1",
|
||||
exporter: "registry.example/exporter:1" } }
|
||||
)
|
||||
|
||||
yaml = WarpEngine::CiConfigService.new.render(
|
||||
yaml = config.render(
|
||||
platform: platform, name: "example", update_server: "https://games.example"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "CI API", type: :request do
|
||||
let(:ci) { instance_double(WarpEngine::CI::Woodpecker::Adapter, configured?: true, name: "Woodpecker") }
|
||||
|
||||
before do
|
||||
allow(WarpEngine.config).to receive(:woodpecker_url).and_return("https://ci.test")
|
||||
allow(WarpEngine.config).to receive(:woodpecker_api_token).and_return("wp-token")
|
||||
allow(WarpEngine).to receive(:ci).and_return(ci)
|
||||
allow(WarpEngine.config).to receive(:update_secret).and_return("s3cret")
|
||||
end
|
||||
|
||||
describe "GET /api/ci/pipelines" do
|
||||
it "returns active repos" do
|
||||
repo = create(:pipeline, repo_name: "mygame", platform: "tic80")
|
||||
create(:pipeline, repo_name: "mygame", platform: "tic80")
|
||||
|
||||
get "/api/ci/pipelines"
|
||||
|
||||
@@ -17,10 +18,11 @@ RSpec.describe "CI API", type: :request do
|
||||
json = JSON.parse(response.body)
|
||||
expect(json.size).to eq(1)
|
||||
expect(json.first["repo_name"]).to eq("mygame")
|
||||
expect(json.first["repo_id"]).to eq(json.first["woodpecker_repo_id"])
|
||||
end
|
||||
|
||||
it "returns 503 when woodpecker not configured" do
|
||||
allow(WarpEngine.config).to receive(:woodpecker_url).and_return(nil)
|
||||
it "returns 503 when no CI provider is configured" do
|
||||
allow(ci).to receive(:configured?).and_return(false)
|
||||
|
||||
get "/api/ci/pipelines"
|
||||
|
||||
@@ -29,18 +31,27 @@ RSpec.describe "CI API", type: :request do
|
||||
end
|
||||
|
||||
describe "GET /api/ci/pipelines/:id/status" do
|
||||
it "returns repo with pipeline status" do
|
||||
it "returns the repo with its latest run" do
|
||||
repo = create(:pipeline, repo_owner: "org", repo_name: "game")
|
||||
client = instance_double(WarpEngine::WoodpeckerClient)
|
||||
allow(WarpEngine::WoodpeckerClient).to receive(:new).and_return(client)
|
||||
allow(client).to receive(:get_pipeline)
|
||||
.and_return({ "number" => 1, "status" => "success" })
|
||||
allow(ci).to receive(:run).with(repo.remote_repo_id, "latest")
|
||||
.and_return(WarpEngine::CI::Run.new(number: 1, status: "success"))
|
||||
|
||||
get "/api/ci/pipelines/#{repo.id}/status"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json["pipeline"]["repo_name"]).to eq("game")
|
||||
expect(json["latest_run"]["status"]).to eq("success")
|
||||
end
|
||||
|
||||
it "answers without a run when the provider is unreachable" do
|
||||
repo = create(:pipeline)
|
||||
allow(ci).to receive(:run).and_raise(WarpEngine::CI::ConnectionError, "down")
|
||||
|
||||
get "/api/ci/pipelines/#{repo.id}/status"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(JSON.parse(response.body)["latest_run"]).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -53,12 +64,10 @@ RSpec.describe "CI API", type: :request do
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it "triggers a pipeline with valid secret" do
|
||||
it "triggers a pipeline with a valid secret" do
|
||||
repo = create(:pipeline, repo_owner: "org", repo_name: "game")
|
||||
client = instance_double(WarpEngine::WoodpeckerClient)
|
||||
allow(WarpEngine::WoodpeckerClient).to receive(:new).and_return(client)
|
||||
allow(client).to receive(:trigger_pipeline)
|
||||
.and_return({ "number" => 7, "status" => "pending" })
|
||||
allow(ci).to receive(:trigger).with(repo.remote_repo_id, branch: "main")
|
||||
.and_return(WarpEngine::CI::Run.new(number: 7, status: "pending"))
|
||||
|
||||
post "/api/ci/pipelines/#{repo.id}/trigger",
|
||||
headers: { "X-Update-Secret" => "s3cret" }
|
||||
@@ -66,6 +75,7 @@ RSpec.describe "CI API", type: :request do
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json["triggered"]).to be true
|
||||
expect(json["pipeline"]["number"]).to eq(7)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,7 +15,7 @@ RSpec.describe "the WarpEngine-Version header", type: :request do
|
||||
end
|
||||
|
||||
it "is on an error response" do
|
||||
get "/api/image/999999"
|
||||
get "/api/softwares/no-such-software/builds"
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(response.headers["WarpEngine-Version"]).to eq(WarpEngine::VERSION)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user