Serve Woodpecker pipeline configs from the engine (/build/config)

This commit is contained in:
2026-08-06 01:14:22 +02:00
parent b64c82aa13
commit 1c7498ca4a
15 changed files with 1232 additions and 0 deletions
@@ -0,0 +1,83 @@
module WarpEngine
module Build
# Woodpecker configuration-extension endpoint: a CI-szerver pipeline-indításkor
# POST-olja a repo marker-fájlját, és a platformhoz tartozó teljes pipeline
# YAML-t kapja vissza. A GET ugyanazt rendereli previewként.
class ConfigsController < ApiController
resource_description do
short "Woodpecker CI pipeline configs"
end
api :GET, "/build/config", "Preview the generated pipeline config for a platform"
param :platform, String, required: true, desc: "Platform (a configured ci_platforms key, e.g. tic80)"
param :name, String, required: false, desc: "Software name substituted into the pipeline (default: example)"
returns code: 200, desc: "Pipeline YAML (text/yaml)"
error code: 404, desc: "Unknown platform"
def show
yaml = render_config(platform: params[:platform], name: params[:name].presence || "example")
return render json: { error: "Unknown platform" }, status: :not_found if yaml.nil?
render plain: yaml, content_type: "text/yaml"
end
api :POST, "/build/config", "Woodpecker configuration extension endpoint"
description <<~DESC
Called by the Woodpecker server on every pipeline start (httpsig-signed request).
If the repo's .woodpecker.yaml is a marker (has a `platform:` key), responds with
the generated pipeline; otherwise responds 204 so the repo's own config runs.
DESC
returns code: 200, desc: %(JSON: {"configs": [{"name": ..., "data": "<pipeline YAML>"}]})
returns code: 204, desc: "Not a marker config — keep the repo's own configuration"
error code: 403, desc: "Missing or invalid request signature"
error code: 422, desc: "Marker requests an unknown platform"
def create
unless WarpEngine::CiSignatureVerifier.new(request).valid?
return render json: { error: "Invalid signature" }, status: :forbidden
end
marker = find_marker
return head :no_content if marker.nil?
platform = marker["platform"].to_s
name = marker["name"].presence || repo_name
yaml = render_config(platform: platform, name: name)
if yaml.nil?
return render json: { error: "Unknown platform: #{platform}" }, status: :unprocessable_entity
end
render json: { configs: [ { name: platform, data: yaml } ] }
end
private
def render_config(platform:, name:)
WarpEngine::CiConfigService.new.render(
platform: platform,
name: name,
update_server: WarpEngine.config.ci_update_server.presence || request.base_url
)
end
# Az első beküldött config, ami markernek parse-olható (Hash `platform` kulccsal).
# A doksi szerint a kulcs "configuration", az example-config-service "configs"-ot
# használ — mindkettőt elfogadjuk.
def find_marker
configs = params[:configuration].presence || params[:configs].presence || []
configs.each do |config|
data = config[:data].to_s
parsed = begin
YAML.safe_load(data)
rescue Psych::Exception
nil
end
return parsed if parsed.is_a?(Hash) && parsed.key?("platform")
end
nil
end
def repo_name
params.dig(:repo, :name).to_s
end
end
end
end
@@ -0,0 +1,40 @@
require "erb"
module WarpEngine
# A /build/config platform-template-jeinek renderelése: a pipeline-logika
# a lib/warp_engine/ci_templates/<platform>.yaml.erb fájlokban él, a
# platformonkénti builder image-eket a WarpEngine.config.ci_platforms adja.
class CiConfigService
PLATFORM_FORMAT = /\A[a-z0-9_-]+\z/
# A renderelt pipeline YAML, vagy nil, ha a platform nem kiszolgált.
def render(platform:, name:, update_server:)
platform = platform.to_s
return nil unless platform.match?(PLATFORM_FORMAT)
spec = platform_spec(platform)
return nil if spec.nil?
path = templates_dir.join("#{platform}.yaml.erb")
return nil unless path.exist?
ERB.new(path.read, trim_mode: "-").result_with_hash(
name: name.to_s,
update_server: update_server.to_s,
builder: spec[:builder],
exporter: spec[:exporter]
)
end
private
def platform_spec(platform)
spec = WarpEngine.config.ci_platforms.stringify_keys[platform]
spec&.symbolize_keys
end
def templates_dir
WarpEngine::Engine.root.join("lib", "warp_engine", "ci_templates")
end
end
end
@@ -0,0 +1,90 @@
require "openssl"
require "base64"
require "net/http"
module WarpEngine
# A Woodpecker configuration-extension kéréseinek httpsig-ellenőrzése
# (draft-cavage http-signatures, ed25519). A szerver az aláírt headerek
# listáját a Signature headerben küldi — tipikusan "(request-target) date".
class CiSignatureVerifier
SIGNATURE_PARAM = /(\w+)="([^"]*)"/
@key_cache = {}
@key_mutex = Mutex.new
class << self
# A letöltött kulcs process-szinten cache-elt (URL-enként).
def fetch_public_key(url)
@key_mutex.synchronize do
@key_cache[url] ||= Net::HTTP.get(URI.parse(url))
end
end
def reset_key_cache!
@key_mutex.synchronize { @key_cache = {} }
end
end
def initialize(request)
@request = request
end
def valid?
pem = public_key_pem
if pem.blank?
Rails.logger.error("[CiSignatureVerifier] nincs ci_extension_public_key(_url) konfigurálva — kérés elutasítva")
return false
end
params = signature_params
return false if params.nil? || params["signature"].blank?
signing_string = build_signing_string(params.fetch("headers", "date"))
return false if signing_string.nil?
key = OpenSSL::PKey.read(pem)
key.verify(nil, Base64.decode64(params["signature"]), signing_string)
rescue OpenSSL::PKey::PKeyError, ArgumentError => e
Rails.logger.error("[CiSignatureVerifier] #{e.class}: #{e.message}")
false
end
private
def public_key_pem
config = WarpEngine.config
return config.ci_extension_public_key if config.ci_extension_public_key.present?
return nil if config.ci_extension_public_key_url.blank?
self.class.fetch_public_key(config.ci_extension_public_key_url)
rescue StandardError => e
Rails.logger.error("[CiSignatureVerifier] kulcs-letöltés sikertelen: #{e.class}: #{e.message}")
nil
end
# A Signature header (vagy az "Authorization: Signature ..." forma) paraméterei.
def signature_params
header = @request.headers["Signature"].presence
if header.nil?
auth = @request.headers["Authorization"].to_s
header = auth.delete_prefix("Signature ") if auth.start_with?("Signature ")
end
return nil if header.blank?
header.scan(SIGNATURE_PARAM).to_h
end
def build_signing_string(headers_list)
lines = headers_list.split(" ").map do |name|
if name == "(request-target)"
"(request-target): #{@request.request_method.downcase} #{@request.fullpath}"
else
value = @request.headers[name]
return nil if value.nil?
"#{name.downcase}: #{value}"
end
end
lines.join("\n")
end
end
end