diff --git a/Gemfile b/Gemfile index c46ae88..67d2226 100644 --- a/Gemfile +++ b/Gemfile @@ -8,5 +8,6 @@ group :development, :test do gem "rspec-rails", "~> 7.0" gem "factory_bot_rails" gem "shoulda-matchers", "~> 6.0" + gem "webmock", "~> 3.0" gem "debug", platforms: %i[mri windows] end diff --git a/app/admin/application_tokens.rb b/app/admin/application_tokens.rb index 0af46ad..4c406ce 100644 --- a/app/admin/application_tokens.rb +++ b/app/admin/application_tokens.rb @@ -52,6 +52,25 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do f.actions end + action_item :rotate, only: :show do + if WarpEngine.woodpecker_configured? + link_to "Rotate Token", rotate_admin_application_token_path(resource), + method: :post, data: { confirm: "This will revoke the current token, create a new one, and push it to Woodpecker. Continue?" } + end + end + + member_action :rotate, method: :post do + result = WarpEngine::CiSecretSyncService.new.rotate(resource) + if result[:rotated] + session[:warp_engine_plain_token] = result[:new_token].plain_token + redirect_to resource_path(result[:new_token]), + notice: "Token rotated and synced to #{result.dig(:sync_result, :synced)&.size || 0} repo(s)" + else + redirect_to resource_path(resource), + alert: "Rotation failed: #{result[:reason]}" + end + end + show do if (plain = controller.instance_variable_get(:@plain_token)) panel "⚠️ Token — shown only once, copy it now!" do @@ -80,6 +99,19 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do create! do |success, _failure| success.html do session[:warp_engine_plain_token] = resource.plain_token + + if WarpEngine.woodpecker_configured? + service = WarpEngine::CiSecretSyncService.new + repos = service.repos_for_token(resource) + if repos.any? + result = service.provision(resource.plain_token, repos: repos) + flash[:notice] = "Token created and synced to #{result[:synced].size} repo(s)." + if result[:failed].any? + flash[:alert] = "Failed to sync to #{result[:failed].size} repo(s)." + end + end + end + redirect_to resource_path(resource) and return end end @@ -90,10 +122,12 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do show! end - # Revoke = soft delete, audit-nyommal. def destroy + if WarpEngine.woodpecker_configured? + WarpEngine::CiSecretSyncService.new.deprovision(resource) + end resource.revoke! - redirect_to collection_path, notice: "Token revoked." + redirect_to collection_path, notice: "Token revoked and Woodpecker secrets cleaned up." end end end diff --git a/app/admin/ci_dashboard.rb b/app/admin/ci_dashboard.rb new file mode 100644 index 0000000..41c4cfe --- /dev/null +++ b/app/admin/ci_dashboard.rb @@ -0,0 +1,49 @@ +ActiveAdmin.register_page "CI Dashboard" do + menu priority: 11, label: "🚀 CI Dashboard" + + content do + if WarpEngine.woodpecker_configured? + begin + entries = WarpEngine::CiPipelineService.new.dashboard + rescue => e + entries = [] + div class: "flash flash_alert" do + "Error connecting to Woodpecker: #{e.message}" + end + end + + entries.group_by { |e| e[:repo].platform }.sort.each do |platform, group| + panel platform.titleize do + table_for group do + column("Repository") { |e| link_to e[:repo].full_name, admin_ci_repository_path(e[:repo]) } + column("Software") { |e| e[:repo].software ? link_to(e[:repo].software.title, admin_software_path(e[:repo].software)) : "-" } + column("Status") { |e| + if e[:pipeline] + status_tag e[:pipeline]["status"], + class: e[:pipeline]["status"] == "success" ? "ok" : "error" + else + status_tag "unknown", class: "warning" + end + } + column("Branch") { |e| e.dig(:pipeline, "branch") || "-" } + column("When") { |e| e.dig(:pipeline, "created_at") || "-" } + column("Actions") { |e| + if e[:repo].active + text_node link_to("Trigger", trigger_admin_ci_repository_path(e[:repo]), + method: :post, class: "member_link") + end + } + end + end + end + + if entries.empty? + para "No CI repositories tracked. Sync repos from the CI Repos page.", + style: "color:#999;text-align:center;padding:40px 0;" + end + else + para "Woodpecker is not configured. Set woodpecker_url and woodpecker_api_token in the WarpEngine initializer.", + style: "color:#999;text-align:center;padding:40px 0;" + end + end +end diff --git a/app/admin/ci_repositories.rb b/app/admin/ci_repositories.rb new file mode 100644 index 0000000..52c6b69 --- /dev/null +++ b/app/admin/ci_repositories.rb @@ -0,0 +1,118 @@ +ActiveAdmin.register WarpEngine::CiRepository, as: "CI Repository" do + actions :index, :show, :edit, :update + + menu priority: 10, label: "🔧 CI Repos" + + config.sort_order = "repo_name_asc" + config.batch_actions = false + + scope :all, default: true + scope("Active") { |s| s.where(active: true) } + scope("Inactive") { |s| s.where(active: false) } + + WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.each do |p| + scope(p.capitalize) { |s| s.where(platform: p) } + end + + index do + id_column + column :repo_owner + column :repo_name + column :platform + column("Software") { |r| r.software ? link_to(r.software.title, admin_software_path(r.software)) : "-" } + column(:active) { |r| status_tag(r.active ? "active" : "inactive", class: r.active ? "ok" : "warning") } + column("Pipeline") { |r| + if r.last_pipeline_status + status_tag r.last_pipeline_status, + class: r.last_pipeline_status == "success" ? "ok" : "error" + else + "-" + end + } + column :last_pipeline_at + actions defaults: true do |repo| + if repo.active && WarpEngine.woodpecker_configured? + item "Trigger", trigger_admin_ci_repository_path(repo), method: :post, class: "member_link" + end + end + end + + filter :repo_name + filter :platform, as: :select, collection: WarpEngine::PlatformLink::SUPPORTED_PLATFORMS + filter :active + + form do |f| + f.inputs do + f.input :platform, as: :select, collection: WarpEngine::PlatformLink::SUPPORTED_PLATFORMS + f.input :software_id, as: :select, + collection: WarpEngine::Software.order(:title).map { |s| [ s.title, s.id ] }, + include_blank: "- none -" + end + f.actions + end + + show do + attributes_table do + row :id + row :woodpecker_repo_id + row :repo_owner + row :repo_name + row :platform + row("Software") { |r| r.software ? link_to(r.software.title, admin_software_path(r.software)) : "-" } + row(:active) { |r| status_tag(r.active ? "active" : "inactive") } + row :last_pipeline_status + row :last_pipeline_at + row :created_at + row :updated_at + end + + if WarpEngine.woodpecker_configured? && resource.active + panel "Recent Pipelines" do + begin + pipelines = WarpEngine::CiPipelineService.new.list_pipelines(resource, page: 1) + if pipelines.is_a?(Array) && pipelines.any? + table_for pipelines.first(10) do + column("Number") { |p| p["number"] } + column("Status") { |p| status_tag p["status"], class: p["status"] == "success" ? "ok" : "error" } + column("Branch") { |p| p["branch"] } + column("Message") { |p| p["message"]&.truncate(60) } + column("Created") { |p| p["created_at"] } + end + else + para "No pipelines found.", style: "color:#999;" + end + rescue => e + para "Error fetching pipelines: #{e.message}", style: "color:red;" + end + end + end + end + + member_action :trigger, method: :post do + repo = WarpEngine::CiRepository.find(params[:id]) + WarpEngine::CiPipelineService.new.trigger(repo) + redirect_to resource_path(repo), notice: "Pipeline triggered for #{repo.full_name}" + rescue => e + redirect_to resource_path(repo), alert: "Trigger failed: #{e.message}" + end + + collection_action :sync, method: :post do + result = WarpEngine::CiRepoSyncService.new.sync_all + redirect_to collection_path, + notice: "Synced: #{result[:created].size} new, #{result[:updated].size} updated, #{result[:deactivated].size} deactivated" + rescue => e + redirect_to collection_path, alert: "Sync failed: #{e.message}" + end + + action_item :sync_repos, only: :index do + if WarpEngine.woodpecker_configured? + link_to "Sync from Woodpecker", sync_admin_ci_repositories_path, method: :post + end + end + + controller do + def scoped_collection + super.includes(:software) + end + end +end diff --git a/app/controllers/warp_engine/api/ci_controller.rb b/app/controllers/warp_engine/api/ci_controller.rb new file mode 100644 index 0000000..5466241 --- /dev/null +++ b/app/controllers/warp_engine/api/ci_controller.rb @@ -0,0 +1,74 @@ +module WarpEngine + module Api + class CiController < ApiController + include UpdateAuthentication + + before_action :require_woodpecker! + + resource_description do + short "CI repository and pipeline management" + end + + api :GET, "/api/ci/repos", "List active CI repositories" + returns code: 200, desc: "JSON array of tracked repos" + error code: 503, desc: "Woodpecker not configured" + def repos + records = CiRepository.active.includes(:software) + render json: records.map { |r| repo_json(r) } + end + + api :GET, "/api/ci/repos/:id/status", "Get a CI repository with its latest pipeline" + param :id, :number, required: true, desc: "CiRepository id" + returns code: 200, desc: "JSON with repo and pipeline data" + error code: 503, desc: "Woodpecker not configured" + def status + repo = CiRepository.find(params[:id]) + pipeline = begin + CiPipelineService.new.pipeline_detail(repo, "latest") + rescue WoodpeckerClient::ApiError + nil + end + render json: { repo: repo_json(repo), pipeline: pipeline } + end + + api :POST, "/api/ci/repos/:id/trigger", "Trigger a pipeline for a CI repository" + header "X-Update-Secret", "Shared secret or application token (update scope)", required: true + param :id, :number, required: true, desc: "CiRepository id" + param :branch, String, required: false, desc: "Branch to build (default: main)" + returns code: 200, desc: "JSON with triggered pipeline data" + error code: 401, desc: "Invalid secret" + error code: 503, desc: "Woodpecker not configured" + def trigger + unless update_authorized?(required_scope: ApplicationToken::UPDATE_SCOPE) + return render json: { error: "Unauthorized" }, status: :unauthorized + end + + repo = CiRepository.find(params[:id]) + result = CiPipelineService.new.trigger(repo, branch: params[:branch] || "main") + render json: { triggered: true, pipeline: result } + end + + private + + def require_woodpecker! + return if WarpEngine.woodpecker_configured? + + render json: { error: "Woodpecker not configured" }, status: :service_unavailable + end + + def repo_json(repo) + { + id: repo.id, + woodpecker_repo_id: repo.woodpecker_repo_id, + repo_owner: repo.repo_owner, + repo_name: repo.repo_name, + platform: repo.platform, + active: repo.active, + software_name: repo.software&.name, + last_pipeline_status: repo.last_pipeline_status, + last_pipeline_at: repo.last_pipeline_at + } + end + end + end +end diff --git a/app/jobs/warp_engine/application_job.rb b/app/jobs/warp_engine/application_job.rb new file mode 100644 index 0000000..70e313c --- /dev/null +++ b/app/jobs/warp_engine/application_job.rb @@ -0,0 +1,8 @@ +module WarpEngine + class ApplicationJob < ActiveJob::Base + retry_on WoodpeckerClient::ConnectionError, wait: 30.seconds, attempts: 3 + discard_on WoodpeckerClient::ApiError do |job, error| + Rails.logger.error("[#{job.class.name}] discarded: #{error.message}") + end + end +end diff --git a/app/jobs/warp_engine/ci_repo_sync_job.rb b/app/jobs/warp_engine/ci_repo_sync_job.rb new file mode 100644 index 0000000..cd51845 --- /dev/null +++ b/app/jobs/warp_engine/ci_repo_sync_job.rb @@ -0,0 +1,9 @@ +module WarpEngine + class CiRepoSyncJob < ApplicationJob + queue_as :default + + def perform + CiRepoSyncService.new.sync_all + end + end +end diff --git a/app/models/warp_engine/ci_repository.rb b/app/models/warp_engine/ci_repository.rb new file mode 100644 index 0000000..23eb88b --- /dev/null +++ b/app/models/warp_engine/ci_repository.rb @@ -0,0 +1,32 @@ +module WarpEngine + class CiRepository < ApplicationRecord + self.table_name = "ci_repositories" + + belongs_to :software, class_name: "WarpEngine::Software", optional: true + + default_scope { where(deleted_at: nil) } + + validates :woodpecker_repo_id, presence: true, uniqueness: true + validates :repo_owner, presence: true + validates :repo_name, presence: true + validates :platform, presence: true, + inclusion: { in: WarpEngine::PlatformLink::SUPPORTED_PLATFORMS } + + scope :active, -> { where(active: true) } + + def full_name + "#{repo_owner}/#{repo_name}" + end + + def self.ransackable_attributes(auth_object = nil) + %w[active created_at deleted_at id last_pipeline_at last_pipeline_status + platform repo_name repo_owner software_id woodpecker_repo_id] + end + + def self.ransackable_associations(auth_object = nil) + %w[software] + end + + ActiveSupport.run_load_hooks(:warp_engine_ci_repository, self) + end +end diff --git a/app/models/warp_engine/software.rb b/app/models/warp_engine/software.rb index 6fdb810..0a9cfc0 100644 --- a/app/models/warp_engine/software.rb +++ b/app/models/warp_engine/software.rb @@ -11,6 +11,7 @@ module WarpEngine has_many :releases, foreign_key: :software_id has_many :downloads, through: :releases has_many :external_links, foreign_key: :software_id + has_one :ci_repository, foreign_key: :software_id accepts_nested_attributes_for :software_images, allow_destroy: true accepts_nested_attributes_for :external_links, allow_destroy: true diff --git a/app/services/warp_engine/ci_pipeline_service.rb b/app/services/warp_engine/ci_pipeline_service.rb new file mode 100644 index 0000000..5fb0391 --- /dev/null +++ b/app/services/warp_engine/ci_pipeline_service.rb @@ -0,0 +1,38 @@ +module WarpEngine + class CiPipelineService + def initialize(client: WoodpeckerClient.new) + @client = client + end + + def dashboard + CiRepository.active.includes(:software).map do |repo| + pipeline = begin + @client.latest_pipeline(repo.repo_owner, repo.repo_name) + rescue WoodpeckerClient::ApiError + nil + end + + if pipeline + repo.update_columns( + last_pipeline_status: pipeline["status"], + last_pipeline_at: pipeline["created_at"] + ) + end + + { repo: repo, pipeline: pipeline } + end + end + + def trigger(repo, branch: "main") + @client.trigger_pipeline(repo.repo_owner, repo.repo_name, branch: branch) + end + + def list_pipelines(repo, page: 1) + @client.list_pipelines(repo.repo_owner, repo.repo_name, page: page) + end + + def pipeline_detail(repo, number) + @client.get_pipeline(repo.repo_owner, repo.repo_name, number) + end + end +end diff --git a/app/services/warp_engine/ci_repo_sync_service.rb b/app/services/warp_engine/ci_repo_sync_service.rb new file mode 100644 index 0000000..4b5c203 --- /dev/null +++ b/app/services/warp_engine/ci_repo_sync_service.rb @@ -0,0 +1,73 @@ +module WarpEngine + class CiRepoSyncService + def initialize(client: WoodpeckerClient.new) + @client = client + end + + def sync_all + remote_repos = @client.list_repos + results = { created: [], updated: [], deactivated: [] } + remote_ids = remote_repos.map { |r| r["id"] } + + remote_repos.each do |remote| + record = CiRepository.unscoped.find_or_initialize_by( + woodpecker_repo_id: remote["id"] + ) + + was_new = record.new_record? + record.assign_attributes( + repo_name: remote["name"], + repo_owner: remote["owner"], + active: remote["active"], + deleted_at: nil + ) + + if record.platform.blank? || record.platform == "unknown" + sw = Software.find_by(name: remote["name"]) + record.platform = sw&.platform || "unknown" + record.software = sw if sw + end + + next unless record.save + + results[was_new ? :created : :updated] << record + end + + CiRepository.where.not(woodpecker_repo_id: remote_ids).find_each do |orphan| + orphan.update!(active: false) if orphan.active? + results[:deactivated] << orphan + end + + results + end + + def activate(repo_id) + @client.activate_repo(repo_id) + sync_single(repo_id) + end + + def deactivate(repo_id) + @client.deactivate_repo(repo_id) + record = CiRepository.find_by!(woodpecker_repo_id: repo_id) + record.update!(active: false) + end + + private + + def sync_single(repo_id) + remote = @client.get_repo(repo_id) + record = CiRepository.unscoped.find_or_initialize_by(woodpecker_repo_id: repo_id) + record.assign_attributes( + repo_name: remote["name"], repo_owner: remote["owner"], + active: remote["active"], deleted_at: nil + ) + if record.platform.blank? + sw = Software.find_by(name: remote["name"]) + record.platform = sw&.platform || "unknown" + record.software = sw if sw + end + record.save! + record + end + end +end diff --git a/app/services/warp_engine/ci_secret_sync_service.rb b/app/services/warp_engine/ci_secret_sync_service.rb new file mode 100644 index 0000000..8a15904 --- /dev/null +++ b/app/services/warp_engine/ci_secret_sync_service.rb @@ -0,0 +1,80 @@ +module WarpEngine + class CiSecretSyncService + SECRET_NAME = "application_token".freeze + + def initialize(client: WoodpeckerClient.new) + @client = client + end + + def provision(plain_token, repos:) + results = { synced: [], failed: [] } + + repos.each do |repo| + if secret_exists?(repo.woodpecker_repo_id) + @client.update_secret(repo.woodpecker_repo_id, SECRET_NAME, value: plain_token) + else + @client.create_secret(repo.woodpecker_repo_id, name: SECRET_NAME, value: plain_token) + end + results[:synced] << repo + rescue WoodpeckerClient::ApiError, WoodpeckerClient::ConnectionError => e + Rails.logger.error("[CiSecretSyncService] failed for #{repo.full_name}: #{e.message}") + results[:failed] << { repo: repo, error: e.message } + end + + results + end + + def deprovision(application_token) + repos_for_token(application_token).each do |repo| + @client.delete_secret(repo.woodpecker_repo_id, SECRET_NAME) + rescue WoodpeckerClient::ApiError => e + Rails.logger.warn("[CiSecretSyncService] delete failed for #{repo.full_name}: #{e.message}") + end + end + + def rotate(application_token) + repos = repos_for_token(application_token) + return { rotated: false, reason: "no repos" } if repos.empty? + + new_token = ApplicationToken.create!( + name: "#{application_token.name} (rotated #{Date.current})", + owner_id: application_token.owner_id, + owner_type: application_token.owner_type, + scopes: application_token.scopes, + expires_at: application_token.expires_at, + unrestricted: application_token.unrestricted? + ) + + result = provision(new_token.plain_token, repos: repos) + + if result[:synced].any? + application_token.revoke! + { rotated: true, new_token: new_token, sync_result: result } + else + new_token.revoke! + { rotated: false, reason: "all repos failed", sync_result: result } + end + end + + def repos_for_token(application_token) + if application_token.unrestricted? + CiRepository.active.to_a + else + software_ids = Software.where( + owner_type: application_token.owner_type, + owner_id: application_token.owner_id + ).pluck(:id) + CiRepository.active.where(software_id: software_ids).to_a + end + end + + private + + def secret_exists?(repo_id) + secrets = @client.list_secrets(repo_id) + secrets.any? { |s| s["name"] == SECRET_NAME } + rescue WoodpeckerClient::ApiError + false + end + end +end diff --git a/app/services/warp_engine/woodpecker_client.rb b/app/services/warp_engine/woodpecker_client.rb new file mode 100644 index 0000000..9f350ab --- /dev/null +++ b/app/services/warp_engine/woodpecker_client.rb @@ -0,0 +1,151 @@ +require "net/http" +require "json" +require "uri" + +module WarpEngine + class WoodpeckerClient + class ApiError < StandardError + attr_reader :status, :body + + def initialize(message, status:, body: nil) + super(message) + @status = status + @body = body + end + end + + class ConnectionError < StandardError; end + + def initialize(base_url: nil, token: nil) + @base_url = (base_url || WarpEngine.config.woodpecker_url).to_s.chomp("/") + @token = token || WarpEngine.config.woodpecker_api_token + end + + # --- Repos --- + + def list_repos + get("/api/repos") + end + + def get_repo(repo_id) + get("/api/repos/#{repo_id}") + end + + def activate_repo(repo_id) + post("/api/repos", body: { id: repo_id }) + end + + def deactivate_repo(repo_id) + delete("/api/repos/#{repo_id}") + end + + # --- Secrets --- + + def list_secrets(repo_id) + get("/api/repos/#{repo_id}/secrets") + end + + def create_secret(repo_id, name:, value:, events: %w[push tag deployment]) + post("/api/repos/#{repo_id}/secrets", + body: { name: name, value: value, events: events }) + end + + def update_secret(repo_id, secret_name, value:) + patch("/api/repos/#{repo_id}/secrets/#{secret_name}", + body: { value: value }) + end + + def delete_secret(repo_id, secret_name) + delete("/api/repos/#{repo_id}/secrets/#{secret_name}") + end + + # --- Pipelines --- + + def list_pipelines(owner, repo_name, page: 1, per_page: 25) + get("/api/repos/#{owner}/#{repo_name}/pipelines", + params: { page: page, perPage: per_page }) + end + + def latest_pipeline(owner, repo_name) + get("/api/repos/#{owner}/#{repo_name}/pipelines/latest") + end + + def get_pipeline(owner, repo_name, number) + get("/api/repos/#{owner}/#{repo_name}/pipelines/#{number}") + end + + def trigger_pipeline(owner, repo_name, branch: "main") + post("/api/repos/#{owner}/#{repo_name}/pipelines", + body: { branch: branch }) + end + + private + + def get(path, params: {}) + uri = build_uri(path, params) + request = Net::HTTP::Get.new(uri) + execute(uri, request) + end + + def post(path, body: {}) + uri = build_uri(path) + request = Net::HTTP::Post.new(uri) + request.body = body.to_json + request.content_type = "application/json" + execute(uri, request) + end + + def patch(path, body: {}) + uri = build_uri(path) + request = Net::HTTP::Patch.new(uri) + request.body = body.to_json + request.content_type = "application/json" + execute(uri, request) + end + + def delete(path) + uri = build_uri(path) + request = Net::HTTP::Delete.new(uri) + execute(uri, request) + end + + def build_uri(path, params = {}) + uri = URI.parse("#{@base_url}#{path}") + uri.query = URI.encode_www_form(params) if params.any? + uri + end + + def execute(uri, request) + request["Authorization"] = "Bearer #{@token}" + request["Accept"] = "application/json" + + response = Net::HTTP.start(uri.hostname, uri.port, + use_ssl: uri.scheme == "https", + open_timeout: 10, + read_timeout: 30) do |http| + http.request(request) + end + + handle_response(uri, response) + rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Net::OpenTimeout, + Net::ReadTimeout, SocketError => e + raise ConnectionError, "Cannot reach Woodpecker at #{@base_url}: #{e.message}" + end + + def handle_response(uri, response) + case response + when Net::HTTPSuccess, Net::HTTPNoContent + return nil if response.body.blank? + JSON.parse(response.body) + when Net::HTTPNotFound + raise ApiError.new("Not found: #{uri.path}", status: 404, body: response.body) + else + raise ApiError.new( + "Woodpecker API error #{response.code}: #{response.body&.truncate(200)}", + status: response.code.to_i, + body: response.body + ) + end + end + end +end diff --git a/config/routes.rb b/config/routes.rb index acac28b..5b7cc8c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -6,6 +6,10 @@ WarpEngine::Engine.routes.draw do get "download", to: "downloads#show" get "builds", to: "builds#index" get "softwares/:name/builds", to: "software_builds#show" + + get "ci/repos", to: "ci#repos" + get "ci/repos/:id/status", to: "ci#status" + post "ci/repos/:id/trigger", to: "ci#trigger" end post "build/upload", to: "build/uploads#create" diff --git a/db/migrate/20260806000001_create_ci_repositories.rb b/db/migrate/20260806000001_create_ci_repositories.rb new file mode 100644 index 0000000..ee0d688 --- /dev/null +++ b/db/migrate/20260806000001_create_ci_repositories.rb @@ -0,0 +1,27 @@ +class CreateCiRepositories < ActiveRecord::Migration[8.1] + def change + create_table :ci_repositories, id: { type: :bigint, unsigned: true }, + charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| + t.bigint :woodpecker_repo_id, null: false, unsigned: true + t.string :repo_owner, limit: 128, null: false + t.string :repo_name, limit: 128, null: false + t.string :platform, limit: 32, null: false + t.bigint :software_id, unsigned: true + t.boolean :active, default: true, null: false + t.string :last_pipeline_status, limit: 32 + t.datetime :last_pipeline_at, precision: 3 + t.datetime :deleted_at, precision: 3 + t.timestamps precision: 3, null: true + + t.index :woodpecker_repo_id, unique: true, name: "idx_ci_repos_wp_id" + t.index :software_id, name: "idx_ci_repos_software" + t.index [ :repo_owner, :repo_name ], unique: true, name: "idx_ci_repos_owner_name" + t.index :deleted_at, name: "idx_ci_repos_deleted" + end + + add_foreign_key :ci_repositories, :softwares, + column: :software_id, + name: "fk_ci_repos_software", + on_delete: :nullify + end +end diff --git a/lib/generators/warp_engine/install/templates/initializer.rb b/lib/generators/warp_engine/install/templates/initializer.rb index 395f004..89d69a7 100644 --- a/lib/generators/warp_engine/install/templates/initializer.rb +++ b/lib/generators/warp_engine/install/templates/initializer.rb @@ -29,6 +29,12 @@ Rails.application.config.to_prepare do # c.ci_extension_public_key_url = "https://ci.example.org/api/signature/public-key" # c.ci_update_server = nil # nil: the request base_url + # Woodpecker CI management — repo sync, secret provisioning, pipeline control. + # All three must be set for the management features to activate. + # c.woodpecker_url = ENV["WOODPECKER_URL"] # e.g. "https://ci.example.org" + # c.woodpecker_api_token = ENV["WOODPECKER_API_TOKEN"] # Woodpecker PAT with admin access + # c.woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"] # forge org/user (e.g. "games") + # Size cap in bytes for /build/upload (and the admin file manager, default: 500MB). # c.max_upload_size = 500 * 1024 * 1024 diff --git a/lib/warp_engine.rb b/lib/warp_engine.rb index 0dcdd42..1bc258d 100644 --- a/lib/warp_engine.rb +++ b/lib/warp_engine.rb @@ -19,6 +19,10 @@ module WarpEngine def self.configure yield(config) end + + def self.woodpecker_configured? + config.woodpecker_url.present? && config.woodpecker_api_token.present? + end end require "warp_engine/engine" diff --git a/lib/warp_engine/configuration.rb b/lib/warp_engine/configuration.rb index dec1290..2b2e8bc 100644 --- a/lib/warp_engine/configuration.rb +++ b/lib/warp_engine/configuration.rb @@ -20,6 +20,9 @@ module WarpEngine # PEM, or a URL to fetch it from (e.g. https://ci.../api/signature/public-key). # With neither set, POST /build/config rejects every request. # ci_update_server: server URL written into the upload/publish steps; nil → the request's base_url. + # woodpecker_url / woodpecker_api_token / woodpecker_repo_owner: + # Woodpecker CI management (repo sync, secret provisioning, pipeline control). + # All nil → the management features are inactive. attr_accessor :file_container_path, :image_container_path, :update_secret, @@ -31,6 +34,9 @@ module WarpEngine :ci_extension_public_key, :ci_extension_public_key_url, :ci_update_server, + :woodpecker_url, + :woodpecker_api_token, + :woodpecker_repo_owner, :image_owners def initialize @@ -45,6 +51,9 @@ module WarpEngine @ci_extension_public_key = nil @ci_extension_public_key_url = nil @ci_update_server = nil + @woodpecker_url = ENV["WOODPECKER_URL"] + @woodpecker_api_token = ENV["WOODPECKER_API_TOKEN"] + @woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"] @image_owners = [] end end diff --git a/spec/factories/ci_repositories.rb b/spec/factories/ci_repositories.rb new file mode 100644 index 0000000..4478ebe --- /dev/null +++ b/spec/factories/ci_repositories.rb @@ -0,0 +1,17 @@ +FactoryBot.define do + factory :ci_repository, class: "WarpEngine::CiRepository" do + sequence(:woodpecker_repo_id) { |n| n } + repo_owner { "testorg" } + sequence(:repo_name) { |n| "game-#{n}" } + platform { "tic80" } + active { true } + + trait :with_software do + association :software + end + + trait :inactive do + active { false } + end + end +end diff --git a/spec/models/ci_repository_spec.rb b/spec/models/ci_repository_spec.rb new file mode 100644 index 0000000..b0c92be --- /dev/null +++ b/spec/models/ci_repository_spec.rb @@ -0,0 +1,57 @@ +require "rails_helper" + +RSpec.describe WarpEngine::CiRepository do + describe "validations" do + subject { build(:ci_repository) } + + it { is_expected.to validate_presence_of(:woodpecker_repo_id) } + it { is_expected.to validate_uniqueness_of(:woodpecker_repo_id) } + it { is_expected.to validate_presence_of(:repo_owner) } + it { is_expected.to validate_presence_of(:repo_name) } + it { is_expected.to validate_presence_of(:platform) } + + it "rejects unsupported platforms" do + repo = build(:ci_repository, platform: "amiga") + expect(repo).not_to be_valid + expect(repo.errors[:platform]).to be_present + end + + WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.each do |p| + it "accepts #{p}" do + repo = build(:ci_repository, platform: p) + expect(repo).to be_valid + end + end + end + + describe "#full_name" do + it "returns owner/name" do + repo = build(:ci_repository, repo_owner: "games", repo_name: "mygame") + expect(repo.full_name).to eq("games/mygame") + end + end + + describe "default_scope" do + it "excludes soft-deleted records" do + repo = create(:ci_repository) + repo.update_column(:deleted_at, Time.current) + + expect(described_class.all).not_to include(repo) + expect(described_class.unscoped).to include(repo) + end + end + + describe ".active" do + it "returns only active repos" do + active = create(:ci_repository, active: true) + inactive = create(:ci_repository, active: false) + + expect(described_class.active).to include(active) + expect(described_class.active).not_to include(inactive) + end + end + + describe "associations" do + it { is_expected.to belong_to(:software).optional } + end +end diff --git a/spec/requests/ci_controller_spec.rb b/spec/requests/ci_controller_spec.rb new file mode 100644 index 0000000..03f3af5 --- /dev/null +++ b/spec/requests/ci_controller_spec.rb @@ -0,0 +1,71 @@ +require "rails_helper" + +RSpec.describe "CI API", type: :request do + 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.config).to receive(:update_secret).and_return("s3cret") + end + + describe "GET /api/ci/repos" do + it "returns active repos" do + repo = create(:ci_repository, repo_name: "mygame", platform: "tic80") + + get "/api/ci/repos" + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json.size).to eq(1) + expect(json.first["repo_name"]).to eq("mygame") + end + + it "returns 503 when woodpecker not configured" do + allow(WarpEngine.config).to receive(:woodpecker_url).and_return(nil) + + get "/api/ci/repos" + + expect(response).to have_http_status(:service_unavailable) + end + end + + describe "GET /api/ci/repos/:id/status" do + it "returns repo with pipeline status" do + repo = create(:ci_repository, 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" }) + + get "/api/ci/repos/#{repo.id}/status" + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json["repo"]["repo_name"]).to eq("game") + end + end + + describe "POST /api/ci/repos/:id/trigger" do + it "requires authentication" do + repo = create(:ci_repository) + + post "/api/ci/repos/#{repo.id}/trigger" + + expect(response).to have_http_status(:unauthorized) + end + + it "triggers a pipeline with valid secret" do + repo = create(:ci_repository, 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" }) + + post "/api/ci/repos/#{repo.id}/trigger", + headers: { "X-Update-Secret" => "s3cret" } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json["triggered"]).to be true + end + end +end diff --git a/spec/services/ci_pipeline_service_spec.rb b/spec/services/ci_pipeline_service_spec.rb new file mode 100644 index 0000000..338568b --- /dev/null +++ b/spec/services/ci_pipeline_service_spec.rb @@ -0,0 +1,53 @@ +require "rails_helper" + +RSpec.describe WarpEngine::CiPipelineService do + let(:client) { instance_double(WarpEngine::WoodpeckerClient) } + let(:service) { described_class.new(client: client) } + + describe "#dashboard" do + it "returns latest pipeline for each active repo" do + repo = create(:ci_repository, repo_owner: "org", repo_name: "game") + pipeline = { "number" => 5, "status" => "success", "created_at" => "2026-08-06T12:00:00Z" } + allow(client).to receive(:latest_pipeline).with("org", "game").and_return(pipeline) + + entries = service.dashboard + + expect(entries.size).to eq(1) + expect(entries.first[:pipeline]["status"]).to eq("success") + expect(repo.reload.last_pipeline_status).to eq("success") + end + + it "handles API errors gracefully per repo" do + create(:ci_repository, repo_owner: "org", repo_name: "broken") + allow(client).to receive(:latest_pipeline) + .and_raise(WarpEngine::WoodpeckerClient::ApiError.new("fail", status: 500)) + + entries = service.dashboard + + expect(entries.size).to eq(1) + expect(entries.first[:pipeline]).to be_nil + end + end + + describe "#trigger" do + it "delegates to client" do + repo = build(:ci_repository, repo_owner: "org", repo_name: "game") + allow(client).to receive(:trigger_pipeline).and_return({ "number" => 6 }) + + result = service.trigger(repo, branch: "main") + + expect(result["number"]).to eq(6) + expect(client).to have_received(:trigger_pipeline).with("org", "game", branch: "main") + end + end + + describe "#list_pipelines" do + it "returns paginated pipelines" do + repo = build(:ci_repository, repo_owner: "org", repo_name: "game") + pipelines = [{ "number" => 1 }, { "number" => 2 }] + allow(client).to receive(:list_pipelines).with("org", "game", page: 1).and_return(pipelines) + + expect(service.list_pipelines(repo)).to eq(pipelines) + end + end +end diff --git a/spec/services/ci_repo_sync_service_spec.rb b/spec/services/ci_repo_sync_service_spec.rb new file mode 100644 index 0000000..4650921 --- /dev/null +++ b/spec/services/ci_repo_sync_service_spec.rb @@ -0,0 +1,81 @@ +require "rails_helper" + +RSpec.describe WarpEngine::CiRepoSyncService do + let(:client) { instance_double(WarpEngine::WoodpeckerClient) } + let(:service) { described_class.new(client: client) } + + describe "#sync_all" do + it "creates new CiRepository records from Woodpecker" do + allow(client).to receive(:list_repos).and_return([ + { "id" => 1, "name" => "mygame", "owner" => "org", "active" => true } + ]) + + result = service.sync_all + + expect(result[:created].size).to eq(1) + repo = result[:created].first + expect(repo.repo_name).to eq("mygame") + expect(repo.repo_owner).to eq("org") + expect(repo.active).to be true + end + + it "updates existing records" do + existing = create(:ci_repository, 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 } + ]) + + result = service.sync_all + + expect(result[:updated].size).to eq(1) + expect(existing.reload.repo_name).to eq("newname") + end + + it "deactivates repos missing from Woodpecker" do + orphan = create(:ci_repository, woodpecker_repo_id: 99, active: true) + allow(client).to receive(:list_repos).and_return([]) + + result = service.sync_all + + expect(result[:deactivated]).to include(orphan) + expect(orphan.reload.active).to be false + end + + 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 } + ]) + + result = service.sync_all + + expect(result[:created].first.platform).to eq("godot") + expect(result[:created].first.software).to be_present + end + 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 } + ) + + repo = service.activate(42) + + expect(repo.woodpecker_repo_id).to eq(42) + expect(repo.active).to be true + end + end + + describe "#deactivate" do + it "calls client and marks repo inactive" do + repo = create(:ci_repository, woodpecker_repo_id: 42, active: true) + allow(client).to receive(:deactivate_repo).with(42) + + service.deactivate(42) + + expect(repo.reload.active).to be false + end + end +end diff --git a/spec/services/ci_secret_sync_service_spec.rb b/spec/services/ci_secret_sync_service_spec.rb new file mode 100644 index 0000000..febd4ef --- /dev/null +++ b/spec/services/ci_secret_sync_service_spec.rb @@ -0,0 +1,131 @@ +require "rails_helper" + +RSpec.describe WarpEngine::CiSecretSyncService do + let(:client) { instance_double(WarpEngine::WoodpeckerClient) } + let(:service) { described_class.new(client: client) } + + before do + allow(WarpEngine.config).to receive(:application_token_source).and_return(:database) + allow(WarpEngine.config).to receive(:application_token_owner_class).and_return("TestOwner") + end + + describe "#provision" do + it "creates secrets on repos that don't have one" do + repo = create(:ci_repository) + allow(client).to receive(:list_secrets).with(repo.woodpecker_repo_id).and_return([]) + allow(client).to receive(:create_secret) + + result = service.provision("plaintoken", repos: [ 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(:ci_repository) + 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", repos: [ repo ]) + + expect(result[:synced]).to eq([ repo ]) + expect(client).to have_received(:update_secret).with( + repo.woodpecker_repo_id, "application_token", value: "newtoken" + ) + end + + it "records failed repos without raising" do + repo = create(:ci_repository) + allow(client).to receive(:list_secrets).and_raise( + WarpEngine::WoodpeckerClient::ConnectionError, "unreachable" + ) + + result = service.provision("tok", repos: [ repo ]) + + expect(result[:synced]).to be_empty + expect(result[:failed].size).to eq(1) + end + end + + describe "#deprovision" do + it "deletes secrets from all relevant repos" do + token = create(:application_token) + sw = create(:software, name: "game1", owner: token.owner) + repo = create(:ci_repository, :with_software, software: sw) + + allow(client).to receive(:delete_secret) + + service.deprovision(token) + + expect(client).to have_received(:delete_secret).with(repo.woodpecker_repo_id, "application_token") + end + end + + describe "#rotate" do + it "creates new token, provisions, revokes old" do + token = create(:application_token) + sw = create(:software, name: "game1", owner: token.owner) + repo = create(:ci_repository, :with_software, software: sw) + + allow(client).to receive(:list_secrets).and_return([]) + allow(client).to receive(:create_secret) + + result = service.rotate(token) + + expect(result[:rotated]).to be true + expect(result[:new_token]).to be_a(WarpEngine::ApplicationToken) + expect(token.reload.deleted_at).to be_present + end + + it "rolls back if all repos fail" do + token = create(:application_token) + sw = create(:software, name: "game1", owner: token.owner) + create(:ci_repository, :with_software, software: sw) + + allow(client).to receive(:list_secrets).and_raise( + WarpEngine::WoodpeckerClient::ConnectionError, "down" + ) + + result = service.rotate(token) + + expect(result[:rotated]).to be false + expect(token.reload.deleted_at).to be_nil + end + + it "returns no-op when no repos exist" do + token = create(:application_token) + + result = service.rotate(token) + + expect(result[:rotated]).to be false + expect(result[:reason]).to eq("no repos") + end + end + + describe "#repos_for_token" do + it "returns all active repos for unrestricted tokens" do + token = create(:application_token, :unrestricted) + repo1 = create(:ci_repository) + create(:ci_repository, :inactive) + + repos = service.repos_for_token(token) + + expect(repos).to eq([ repo1 ]) + end + + it "returns only owner's repos for scoped tokens" do + token = create(:application_token) + own_sw = create(:software, name: "mine", owner: token.owner) + other_sw = create(:software, name: "theirs", owner: create(:test_owner)) + own_repo = create(:ci_repository, :with_software, software: own_sw) + create(:ci_repository, :with_software, software: other_sw) + + repos = service.repos_for_token(token) + + expect(repos).to eq([ own_repo ]) + end + end +end diff --git a/spec/services/woodpecker_client_spec.rb b/spec/services/woodpecker_client_spec.rb new file mode 100644 index 0000000..e3618b6 --- /dev/null +++ b/spec/services/woodpecker_client_spec.rb @@ -0,0 +1,119 @@ +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/org/game/pipelines?page=1&perPage=25", body: pipelines) + + expect(client.list_pipelines("org", "game")).to eq(pipelines) + end + + it "gets latest pipeline" do + pipeline = { "number" => 5, "status" => "running" } + stub_wp(:get, "/api/repos/org/game/pipelines/latest", body: pipeline) + + expect(client.latest_pipeline("org", "game")).to eq(pipeline) + end + + it "triggers a pipeline" do + pipeline = { "number" => 6, "status" => "pending" } + stub_wp(:post, "/api/repos/org/game/pipelines", body: pipeline) + + expect(client.trigger_pipeline("org", "game", 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 + end +end