ground_control-api 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/.github/dependabot.yml +24 -0
  3. data/.github/workflows/ci.yml +59 -0
  4. data/.github/workflows/claude-code-review.yml +24 -0
  5. data/.github/workflows/claude.yml +38 -0
  6. data/.github/workflows/codeql-actions.yml +28 -0
  7. data/.github/workflows/release.yml +89 -0
  8. data/.lefthook/bundle-sync.sh +58 -0
  9. data/.lefthook/lib/git-upstream.sh +13 -0
  10. data/.lefthook/verify-signatures.sh +46 -0
  11. data/.lefthook/whitespace-check.sh +8 -0
  12. data/.rspec +1 -0
  13. data/.ruby-version +1 -0
  14. data/CHANGELOG.md +16 -0
  15. data/CLAUDE.md +78 -0
  16. data/MIT-LICENSE +20 -0
  17. data/README.md +130 -0
  18. data/Rakefile +8 -0
  19. data/app/controllers/concerns/ground_control/api/adapter_features.rb +36 -0
  20. data/app/controllers/concerns/ground_control/api/error_handling.rb +25 -0
  21. data/app/controllers/concerns/ground_control/api/job_filters.rb +42 -0
  22. data/app/controllers/ground_control/api/application_controller.rb +24 -0
  23. data/app/controllers/ground_control/api/applications_controller.rb +13 -0
  24. data/app/controllers/ground_control/api/bulk_discards_controller.rb +21 -0
  25. data/app/controllers/ground_control/api/bulk_retries_controller.rb +15 -0
  26. data/app/controllers/ground_control/api/discards_controller.rb +14 -0
  27. data/app/controllers/ground_control/api/dispatches_controller.rb +14 -0
  28. data/app/controllers/ground_control/api/features_controller.rb +11 -0
  29. data/app/controllers/ground_control/api/jobs_controller.rb +37 -0
  30. data/app/controllers/ground_control/api/queues/pauses_controller.rb +29 -0
  31. data/app/controllers/ground_control/api/queues_controller.rb +26 -0
  32. data/app/controllers/ground_control/api/recurring_tasks_controller.rb +40 -0
  33. data/app/controllers/ground_control/api/retries_controller.rb +14 -0
  34. data/app/controllers/ground_control/api/workers_controller.rb +33 -0
  35. data/app/resources/ground_control/api/application_resource.rb +12 -0
  36. data/app/resources/ground_control/api/base_resource.rb +13 -0
  37. data/app/resources/ground_control/api/job_resource.rb +46 -0
  38. data/app/resources/ground_control/api/page_resource.rb +47 -0
  39. data/app/resources/ground_control/api/queue_resource.rb +22 -0
  40. data/app/resources/ground_control/api/recurring_task_resource.rb +17 -0
  41. data/app/resources/ground_control/api/server_resource.rb +10 -0
  42. data/app/resources/ground_control/api/worker_resource.rb +14 -0
  43. data/config/routes.rb +27 -0
  44. data/lefthook.yml +17 -0
  45. data/lib/ground_control/api/engine.rb +18 -0
  46. data/lib/ground_control/api/version.rb +7 -0
  47. data/lib/ground_control/api.rb +15 -0
  48. data/sig/ground_control/api.rbs +6 -0
  49. data/spec/spec_helper.rb +15 -0
  50. metadata +136 -0
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GroundControl
4
+ module Api
5
+ module AdapterFeatures
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def supported_job_statuses
11
+ MissionControl::Jobs::Current.server.queue_adapter.supported_job_statuses
12
+ end
13
+
14
+ def queue_pausing_supported?
15
+ MissionControl::Jobs::Current.server.queue_adapter.supports_queue_pausing?
16
+ end
17
+
18
+ def workers_exposed?
19
+ MissionControl::Jobs::Current.server.queue_adapter.exposes_workers?
20
+ end
21
+
22
+ def recurring_tasks_supported?
23
+ MissionControl::Jobs::Current.server.queue_adapter.supports_recurring_tasks?
24
+ end
25
+
26
+ def adapter_features
27
+ {
28
+ statuses: supported_job_statuses,
29
+ queue_pausing: queue_pausing_supported?,
30
+ workers: workers_exposed?,
31
+ recurring_tasks: recurring_tasks_supported?
32
+ }
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GroundControl
4
+ module Api
5
+ module ErrorHandling
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ rescue_from ActiveJob::Errors::JobNotFoundError do |_exception|
10
+ render json: { error: "Job not found" }, status: :not_found
11
+ end
12
+
13
+ rescue_from MissionControl::Jobs::Errors::ResourceNotFound do |exception|
14
+ render json: { error: exception.message }, status: :not_found
15
+ end
16
+
17
+ rescue_from StandardError do |exception|
18
+ raise exception unless Rails.env.production?
19
+
20
+ render json: { error: "Internal server error" }, status: :internal_server_error
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GroundControl
4
+ module Api
5
+ module JobFilters
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ before_action :set_filters
10
+ end
11
+
12
+ private
13
+
14
+ def set_filters
15
+ filter = params[:filter] || {}
16
+
17
+ job_class_name = filter[:job_class_name]&.strip.presence
18
+ queue_name = filter[:queue_name]&.strip.presence
19
+ finished_at = build_finished_at_range(filter)
20
+
21
+ @job_filters = {
22
+ job_class_name: job_class_name,
23
+ queue_name: queue_name,
24
+ finished_at: finished_at
25
+ }.compact
26
+ end
27
+
28
+ def build_finished_at_range(filter)
29
+ start_time = filter[:finished_at_start].present? ? Time.zone.parse(filter[:finished_at_start]) : nil
30
+ end_time = filter[:finished_at_end].present? ? Time.zone.parse(filter[:finished_at_end]) : nil
31
+
32
+ return nil unless start_time || end_time
33
+
34
+ (start_time..end_time)
35
+ end
36
+
37
+ def active_filters?
38
+ @job_filters.present?
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GroundControl
4
+ module Api
5
+ class ApplicationController < ActionController::API
6
+ include MissionControl::Jobs::ApplicationScoped
7
+ include JobFilters
8
+ include AdapterFeatures
9
+ include ErrorHandling
10
+
11
+ before_action :authenticate!
12
+
13
+ private
14
+
15
+ def authenticate!
16
+ GroundControl::Api.authenticate_with&.call(self)
17
+ end
18
+
19
+ def serialize(resource)
20
+ resource.serializable_hash
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GroundControl
4
+ module Api
5
+ class ApplicationsController < ApplicationController
6
+ def index
7
+ applications = MissionControl::Jobs.applications
8
+
9
+ render json: { data: ApplicationResource.new(applications).serializable_hash }
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GroundControl
4
+ module Api
5
+ class BulkDiscardsController < ApplicationController
6
+ def create
7
+ jobs = ActiveJob.jobs.where(**@job_filters).failed
8
+
9
+ if active_filters?
10
+ count = [jobs.count, 3000].min
11
+ jobs.limit(3000).discard_all
12
+ else
13
+ count = jobs.count
14
+ jobs.discard_all
15
+ end
16
+
17
+ render json: { message: "Discarded #{count} jobs" }
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GroundControl
4
+ module Api
5
+ class BulkRetriesController < ApplicationController
6
+ def create
7
+ jobs = ActiveJob.jobs.where(**@job_filters).failed
8
+ count = [jobs.count, 3000].min
9
+ jobs.limit(3000).retry_all
10
+
11
+ render json: { message: "Retried #{count} jobs" }
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GroundControl
4
+ module Api
5
+ class DiscardsController < ApplicationController
6
+ def create
7
+ job = ActiveJob.jobs.find_by_id(params[:job_id])
8
+ job.discard
9
+
10
+ render json: { message: "Discarded job #{job.job_id}" }
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GroundControl
4
+ module Api
5
+ class DispatchesController < ApplicationController
6
+ def create
7
+ job = ActiveJob.jobs.find_by_id(params[:job_id])
8
+ job.dispatch
9
+
10
+ render json: { message: "Dispatched job #{job.job_id}" }
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GroundControl
4
+ module Api
5
+ class FeaturesController < ApplicationController
6
+ def show
7
+ render json: { data: adapter_features }
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GroundControl
4
+ module Api
5
+ class JobsController < ApplicationController
6
+ def index
7
+ status = params[:status]
8
+ jobs = ActiveJob.jobs.where(**@job_filters).with_status(status)
9
+ page = MissionControl::Jobs::Page.new(jobs, page: params[:page].to_i, page_size: GroundControl::Api.page_size)
10
+
11
+ job_class_names = begin
12
+ ActiveJob.jobs.with_status(status).job_class_names
13
+ rescue
14
+ []
15
+ end
16
+
17
+ queue_names = ActiveJob.queues.map(&:name)
18
+
19
+ render json: {
20
+ data: PageResource.new(page, inner_resource_class: JobResource).serializable_hash,
21
+ meta: {
22
+ status: status,
23
+ filters: @job_filters,
24
+ job_class_names: job_class_names,
25
+ queue_names: queue_names
26
+ }
27
+ }
28
+ end
29
+
30
+ def show
31
+ job = ActiveJob.jobs.find_by_id(params[:id])
32
+
33
+ render json: { data: JobResource.new(job).serializable_hash }
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GroundControl
4
+ module Api
5
+ module Queues
6
+ class PausesController < ApplicationController
7
+ before_action :set_queue
8
+
9
+ def create
10
+ @queue.pause
11
+
12
+ render json: { message: "Queue paused" }
13
+ end
14
+
15
+ def destroy
16
+ @queue.resume
17
+
18
+ render json: { message: "Queue resumed" }
19
+ end
20
+
21
+ private
22
+
23
+ def set_queue
24
+ @queue = ActiveJob.queues[params[:queue_id]]
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GroundControl
4
+ module Api
5
+ class QueuesController < ApplicationController
6
+ def index
7
+ queues = ActiveJob.queues.sort_by(&:name)
8
+
9
+ render json: {
10
+ data: QueueResource.new(queues).serializable_hash,
11
+ meta: { features: adapter_features }
12
+ }
13
+ end
14
+
15
+ def show
16
+ queue = ActiveJob.queues[params[:id]]
17
+ page = MissionControl::Jobs::Page.new(queue.jobs, page: params[:page].to_i, page_size: GroundControl::Api.page_size)
18
+
19
+ render json: {
20
+ data: QueueResource.new(queue).serializable_hash,
21
+ jobs: PageResource.new(page, inner_resource_class: JobResource).serializable_hash
22
+ }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GroundControl
4
+ module Api
5
+ class RecurringTasksController < ApplicationController
6
+ before_action :ensure_recurring_tasks_supported
7
+
8
+ def index
9
+ tasks = MissionControl::Jobs::Current.server.recurring_tasks
10
+
11
+ render json: { data: RecurringTaskResource.new(tasks).serializable_hash }
12
+ end
13
+
14
+ def show
15
+ task = MissionControl::Jobs::Current.server.find_recurring_task(params[:id])
16
+ page = MissionControl::Jobs::Page.new(task.jobs, page: params[:page].to_i, page_size: GroundControl::Api.page_size)
17
+
18
+ render json: {
19
+ data: RecurringTaskResource.new(task).serializable_hash,
20
+ jobs: PageResource.new(page, inner_resource_class: JobResource).serializable_hash
21
+ }
22
+ end
23
+
24
+ def update
25
+ task = MissionControl::Jobs::Current.server.find_recurring_task(params[:id])
26
+ task.enqueue
27
+
28
+ render json: { message: "Enqueued recurring task #{task.id}" }
29
+ end
30
+
31
+ private
32
+
33
+ def ensure_recurring_tasks_supported
34
+ unless recurring_tasks_supported?
35
+ render json: { error: "Recurring tasks not supported by this adapter" }, status: :not_found
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GroundControl
4
+ module Api
5
+ class RetriesController < ApplicationController
6
+ def create
7
+ job = ActiveJob.jobs.failed.find_by_id(params[:job_id])
8
+ job.retry
9
+
10
+ render json: { message: "Retried job #{job.job_id}" }
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GroundControl
4
+ module Api
5
+ class WorkersController < ApplicationController
6
+ before_action :ensure_workers_exposed
7
+
8
+ def index
9
+ page = MissionControl::Jobs::Page.new(
10
+ MissionControl::Jobs::Current.server.workers_relation,
11
+ page: params[:page].to_i,
12
+ page_size: GroundControl::Api.page_size
13
+ )
14
+
15
+ render json: { data: PageResource.new(page, inner_resource_class: WorkerResource).serializable_hash }
16
+ end
17
+
18
+ def show
19
+ worker = MissionControl::Jobs::Current.server.find_worker(params[:id])
20
+
21
+ render json: { data: WorkerResource.new(worker).serializable_hash }
22
+ end
23
+
24
+ private
25
+
26
+ def ensure_workers_exposed
27
+ unless workers_exposed?
28
+ render json: { error: "Workers not supported by this adapter" }, status: :not_found
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GroundControl
4
+ module Api
5
+ class ApplicationResource < BaseResource
6
+ attribute :id, &:id
7
+ attribute :name, &:name
8
+
9
+ many :servers, resource: ServerResource
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "alba"
4
+
5
+ module GroundControl
6
+ module Api
7
+ class BaseResource
8
+ include Alba::Resource
9
+
10
+ transform_keys :lower_camel
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GroundControl
4
+ module Api
5
+ class JobResource < BaseResource
6
+ attribute :job_id, &:job_id
7
+ attribute :job_class_name, &:job_class_name
8
+ attribute :queue_name, &:queue_name
9
+
10
+ attribute :status do |job|
11
+ job.status.to_s
12
+ end
13
+
14
+ attribute :arguments do |job|
15
+ if job.respond_to?(:filtered_raw_data) && job.filtered_raw_data
16
+ job.filtered_raw_data
17
+ else
18
+ job.arguments
19
+ end
20
+ end
21
+
22
+ attribute :priority, &:priority
23
+ attribute :executions, &:executions
24
+ attribute :scheduled_at, &:scheduled_at
25
+ attribute :finished_at, &:finished_at
26
+ attribute :started_at, &:started_at
27
+ attribute :failed_at, &:failed_at
28
+
29
+ attribute :last_execution_error do |job|
30
+ error = job.last_execution_error
31
+ next nil if error.nil?
32
+
33
+ {
34
+ error_class: error.error_class,
35
+ message: error.message,
36
+ backtrace: error.backtrace
37
+ }
38
+ end
39
+
40
+ attribute :blocked_by, &:blocked_by
41
+ attribute :blocked_until, &:blocked_until
42
+ attribute :worker_id, &:worker_id
43
+ attribute :raw_data, &:raw_data
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GroundControl
4
+ module Api
5
+ class PageResource
6
+ include Alba::Resource
7
+
8
+ transform_keys :lower_camel
9
+
10
+ attribute :page do |page|
11
+ page.index
12
+ end
13
+
14
+ attribute :page_size, &:page_size
15
+
16
+ attribute :total_count do |page|
17
+ count = page.total_count
18
+ count.infinite? ? nil : count
19
+ rescue NoMethodError
20
+ count
21
+ end
22
+
23
+ attribute :pages_count, &:pages_count
24
+
25
+ attribute :first do |page|
26
+ page.first?
27
+ end
28
+
29
+ attribute :last do |page|
30
+ page.last?
31
+ end
32
+
33
+ attribute :records do |page|
34
+ if @inner_resource_class
35
+ @inner_resource_class.new(page.records).serializable_hash
36
+ else
37
+ page.records.map(&:to_h)
38
+ end
39
+ end
40
+
41
+ def initialize(object, inner_resource_class: nil)
42
+ @inner_resource_class = inner_resource_class
43
+ super(object)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GroundControl
4
+ module Api
5
+ class QueueResource < BaseResource
6
+ attribute :id do |queue|
7
+ queue.name.parameterize
8
+ end
9
+
10
+ attribute :name, &:name
11
+ attribute :size, &:size
12
+
13
+ attribute :active do |queue|
14
+ queue.active?
15
+ end
16
+
17
+ attribute :paused do |queue|
18
+ queue.paused?
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GroundControl
4
+ module Api
5
+ class RecurringTaskResource < BaseResource
6
+ attribute :id, &:id
7
+ attribute :job_class_name, &:job_class_name
8
+ attribute :command, &:command
9
+ attribute :arguments, &:arguments
10
+ attribute :schedule, &:schedule
11
+ attribute :last_enqueued_at, &:last_enqueued_at
12
+ attribute :next_time, &:next_time
13
+ attribute :queue_name, &:queue_name
14
+ attribute :priority, &:priority
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GroundControl
4
+ module Api
5
+ class ServerResource < BaseResource
6
+ attribute :id, &:id
7
+ attribute :name, &:name
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GroundControl
4
+ module Api
5
+ class WorkerResource < BaseResource
6
+ attribute :id, &:id
7
+ attribute :name, &:name
8
+ attribute :hostname, &:hostname
9
+ attribute :last_heartbeat_at, &:last_heartbeat_at
10
+ attribute :configuration, &:configuration
11
+ attribute :raw_data, &:raw_data
12
+ end
13
+ end
14
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ GroundControl::Api::Engine.routes.draw do
4
+ resources :applications, only: [:index] do
5
+ resources :queues, only: [:index, :show] do
6
+ scope module: :queues do
7
+ resource :pause, only: [:create, :destroy]
8
+ end
9
+ end
10
+
11
+ resources :jobs, only: [:show] do
12
+ resource :retry, only: :create
13
+ resource :discard, only: :create
14
+ resource :dispatch, only: :create
15
+
16
+ collection do
17
+ resource :bulk_retries, only: :create
18
+ resource :bulk_discards, only: :create
19
+ end
20
+ end
21
+
22
+ resources :jobs, only: :index, path: ":status/jobs"
23
+ resources :workers, only: [:index, :show]
24
+ resources :recurring_tasks, only: [:index, :show, :update]
25
+ resource :features, only: [:show]
26
+ end
27
+ end
data/lefthook.yml ADDED
@@ -0,0 +1,17 @@
1
+ # Lefthook manages git hooks for pre-push checks.
2
+ #
3
+ # Install: brew install lefthook && lefthook install
4
+ # Skip: LEFTHOOK=0 git push
5
+
6
+ pre-push:
7
+ parallel: true
8
+ commands:
9
+ whitespace:
10
+ run: .lefthook/whitespace-check.sh
11
+ signed-commits:
12
+ run: .lefthook/verify-signatures.sh
13
+ rubocop:
14
+ run: bundle exec rubocop
15
+ glob: "**/*.rb"
16
+ rspec:
17
+ run: bundle exec rspec --fail-fast
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GroundControl
4
+ module Api
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace GroundControl::Api
7
+
8
+ initializer "ground_control.api.middleware" do |app|
9
+ app.middleware.use ActionDispatch::Flash
10
+ app.middleware.use Rack::MethodOverride
11
+ end
12
+
13
+ config.after_initialize do
14
+ require "mission_control/jobs/engine"
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GroundControl
4
+ module Api
5
+ VERSION = "0.1.0"
6
+ end
7
+ end