hackler 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4ee91a4d7a9915e7b074d7e6f882bfbc020de74875bba7827f018affb4fe4ed5
4
+ data.tar.gz: 18e8d50fa3213fd06ceeca5a0c2be32a4a062de969b62e61a39632131d0980b0
5
+ SHA512:
6
+ metadata.gz: ecaabb2b8c2d08d2ed478f5f1ae1e6981d60f94a70e767abe79d56941df5579a958aabfadb12b45433a03900876450b6a8b849dd8a7ac7070f7d5f8f216ea6b3
7
+ data.tar.gz: 0c1b68a8578d5260c714dfd0772039fbdbbcb8fc28cb2ceae8191152cefe9fae393350db4e15ed51e18ef2b9d59f1d89dadae854e9ea392d1d2083eadb3dab02
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Jyrki Gadinger
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,149 @@
1
+ # Hackler
2
+
3
+ > **Hackler**, da (/ˈhaklɐ/)
4
+ >
5
+ > _(Austria, informal) hard worker_
6
+
7
+ A cursed approach to background jobs. **Here be dragons.**
8
+
9
+ Imagine you have a Rails app deployed to a web host that can **only** run Rack
10
+ applications backed by a MySQL database. There's no possibility to spin up
11
+ any form of containerised application (podman, docker), and no possibility to
12
+ drop into a shell (configure systemd units by hand, run stuff in a tmux/screen
13
+ session), nothing.
14
+
15
+ As long as you don't need any form of background processing, this is fine.
16
+
17
+ However, once you want to make use of background jobs you'll run into a few
18
+ challenges:
19
+
20
+ - Most (if not all) in-process adapters will lose their enqueued jobs when
21
+ the app is restarted, this is not suitable for production.
22
+ - Pretty much every other adapter requires either another process running
23
+ that's separate from the main app. Remember, our web host can only run
24
+ Rack-based apps!
25
+ - Most Active Job adapters usually require a specific database or message bus
26
+ too, be it PostgreSQL, Redis, RabbitMQ, beanstalkd, ... but all we have is
27
+ a basic MySQL.
28
+ - Running the extra worker process on another server requires access to the
29
+ main database, and the additional database the job library needs, this
30
+ complicates the configuration of those databases (firewalls, VPNs, ACLs)...
31
+ and on a shared web host you might not be able to expose the MySQL as you
32
+ wish.
33
+ - Running the extra worker process on another server requires an exact copy of
34
+ the app you're currently running, complicating deployment even further.
35
+ - Trying to start the extra worker process as part of the Rack app is even
36
+ more cursed than whatever this mess is. Not to mention that restarts are
37
+ going to get interesting...
38
+ - Let's face it: there are no other background job processors out there that
39
+ are cooler than Sidekiq.
40
+ - And migrating the app to another host is not a possibility either.
41
+
42
+ So what can we do?
43
+
44
+ Of course, we'll let our Rails web app talk to another Rails web app that can
45
+ make use of a real job processor, all over HTTP. *Obviously*.
46
+
47
+ ## Sounds cursed, I'm in!
48
+
49
+ In the end you'll end up with two Rails apps.
50
+
51
+ One is your usual web app that will make use of a new kind of Active Job
52
+ adapter. Let's call it... `rails-web`.
53
+
54
+ The other one is a barebones Rails app that can use any other Active Job
55
+ adapter you like (yay, Sidekiq!). This one does not need access to the code
56
+ or database of `rails-web`, and therefore doesn't really care what jobs you
57
+ throw at it. I'll call that one `rails-worker`.
58
+
59
+ Whenever `rails-web` wants to enqueue an Active Job job, the `:hackler`
60
+ adapter will create a new `Hackler::Job` record and perform a HTTP POST
61
+ request to `rails-worker`, which will then enqueue a very basic job in the
62
+ adapter chosen by that app (probably `:sidekiq`). The basic job only has two
63
+ parameters: a base callback URL and the job ID.
64
+
65
+ Once the job processor framework used by `rails-worker` decides to enqueue the
66
+ job, the `rails-worker` makes a HTTP POST request to `rails-web`, which
67
+ fetches the stored job information from its database and runs the job. If
68
+ it's successful that job record is gone from the database. Should the job
69
+ fail however, the `rails-worker` process will automagically™ capture the
70
+ exception thrown by the job and re-raise it on the `rails-worker` side.
71
+
72
+ Of course, not everyone can access those HTTP endpoints exposed by Hackler,
73
+ so a shared secret must be defined on both ends. Some hashed value based on
74
+ that and the job info will be sent with each request.
75
+
76
+ Is this a good idea? Probably not, longer running jobs might block some
77
+ incoming requests. Should be fine for quick jobs though, like sending out
78
+ emails.
79
+
80
+ Is this extra cursed? **_Heck yea!_**
81
+
82
+ ## Installation
83
+
84
+ Add the gem to your Gemfile:
85
+
86
+ ```bash
87
+ $ bundle add hackler
88
+ ```
89
+
90
+ In your main web app, install and configure Hackler:
91
+
92
+ ```bash
93
+ $ bin/rails generate hackler:install
94
+ ```
95
+
96
+ Make sure to edit `shared_secret`, `web_base_url`, and `worker_base_url` in
97
+ `config/initializers/hackler.rb`.
98
+
99
+ ----
100
+
101
+ To install and configure Hackler in the worker app you first need to configure
102
+ an Active Job adapter. One that actually processes these jobs. I can't
103
+ remember which one I liked best, but I think some numbers will do fine as
104
+ well...
105
+
106
+ ```ruby
107
+ # config/application.rb
108
+
109
+ class Application < Rails::Application
110
+ # ...
111
+ config.active_job.queue_adapter = 62060811362.to_s(36).to_sym
112
+ ```
113
+
114
+ Then, install and configure Hackler:
115
+
116
+ ```bash
117
+ $ bin/rails generate hackler:worker_install
118
+ ```
119
+
120
+ Make sure to set `shared_secret` in `config/initializers/hackler.rb` to the
121
+ same value as on the web setup.
122
+
123
+ ----
124
+
125
+ You are now ready to run the two Rails apps simultaneously! Use one terminal
126
+ per command:
127
+
128
+ ```bash
129
+ # in your web app directory:
130
+ bin/rails server
131
+
132
+ # in your worker app directory:
133
+ bin/rails server -p 4000
134
+ bundle exec sidekiq
135
+ ```
136
+
137
+ This repository contains an example Rails app with Sidekiq as its job backend
138
+ at `/hacklerer`.
139
+
140
+ ## License
141
+
142
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
143
+
144
+ ## That's cool and all, but should I use this?
145
+
146
+ Probably not. If you do, please let me know why.
147
+
148
+ As of now this library is a proof-of-concept. Or a prototype. Or an art
149
+ project. I mean, I didn't even bother with tests yet...
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+
5
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
6
+ load "rails/tasks/engine.rake"
7
+
8
+ load "rails/tasks/statistics.rake"
9
+
10
+ require "bundler/gem_tasks"
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hackler
4
+ class ApplicationController < ActionController::API
5
+ wrap_parameters false
6
+ end
7
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hackler
4
+ class JobController < ApplicationController
5
+ REQUIRED_PARAMS = %i[id base_url].freeze
6
+
7
+ before_action :set_secret
8
+ before_action :set_params
9
+ before_action :verify_secret!
10
+
11
+ def enqueue
12
+ Hackler::HacklerJob
13
+ .set(wait_until: Time.zone.at(@params[:timestamp]))
14
+ .perform_later(@params[:id], @params[:base_url])
15
+ render head: :no_content
16
+ end
17
+
18
+ def work
19
+ job = Hackler::Job.find(@params[:id])
20
+ ActiveJob::Base.execute(JSON.parse(job.data))
21
+ job.destroy!
22
+ render head: :no_content
23
+ rescue Exception => e # rubocop:disable Lint/RescueException
24
+ # this needs to be a Exception, otherwise we won't catch e.g. `NoMethodError`s
25
+ render json: {
26
+ exception: {
27
+ class: e.class.name,
28
+ message: e.message,
29
+ backtrace: Hackler.backtrace_cleaner.call(e.backtrace),
30
+ },
31
+ }, status: :internal_server_error
32
+ end
33
+
34
+ private
35
+
36
+ def set_secret
37
+ @header_secret = request.headers["x-hackler-secret"]
38
+ return if @header_secret
39
+
40
+ # pretend we're not there if the secret is missing
41
+ render head: :not_found
42
+ end
43
+
44
+ def set_params
45
+ @params = params.permit(:id, :base_url, :timestamp).to_h.symbolize_keys
46
+ return if @params.keys.intersection(REQUIRED_PARAMS).size == 2
47
+
48
+ render json: { error: "required parameters are missing" }, status: :unprocessable_content
49
+ end
50
+
51
+ def verify_secret!
52
+ expected_secret = Hackler.build_secret(**@params)
53
+ return if @header_secret == expected_secret
54
+
55
+ render json: { error: "unauthorised" }, status: :unauthorized
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hackler
4
+ class ApplicationJob < ActiveJob::Base
5
+ end
6
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hackler
4
+ class HacklerJob < ApplicationJob
5
+ queue_as Hackler.worker_queue
6
+
7
+ def perform(id, base_url)
8
+ conn = Hackler.connection_for(base_url)
9
+ parameters = {
10
+ id:,
11
+ base_url:,
12
+ }
13
+ begin
14
+ conn.post("work", parameters, { "x-hackler-secret" => Hackler.build_secret(**parameters) })
15
+ rescue Faraday::ServerError => e
16
+ json_response = begin
17
+ JSON.parse(e.response_body, symbolize_names: true)
18
+ rescue
19
+ {}
20
+ end
21
+ raise Hackler::JobError.from_json(json_response[:exception]) if json_response.key?(:exception)
22
+
23
+ raise # reraise other unexpected errors
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hackler
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hackler
4
+ class Job < ApplicationRecord
5
+ end
6
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,4 @@
1
+ Hackler::Engine.routes.draw do
2
+ post "enqueue", to: "job#enqueue", constraints: ->() { Hackler.worker }
3
+ post "work", to: "job#work", constraints: ->() { !Hackler.worker }
4
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateHacklerJobs < ActiveRecord::Migration[7.2]
4
+ def change
5
+ create_table :hackler_jobs do |t|
6
+ t.text :data
7
+
8
+ t.timestamps
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module QueueAdapters
5
+ class HacklerAdapter < AbstractAdapter
6
+ def enqueue(job)
7
+ j = Hackler::Job.create(data: JSON.dump(job.serialize))
8
+ notify_worker(j)
9
+ end
10
+
11
+ def enqueue_at(job, timestamp)
12
+ j = Hackler::Job.create(data: JSON.dump(job.serialize))
13
+ notify_worker(j, timestamp)
14
+ end
15
+
16
+ private
17
+
18
+ def notify_worker(job, timestamp = nil)
19
+ conn = Hackler.connection_for(Hackler.worker_base_url)
20
+ parameters = {
21
+ id: job.id,
22
+ base_url: Hackler.web_base_url,
23
+ timestamp:,
24
+ }
25
+ conn.post("enqueue", parameters, { "x-hackler-secret" => Hackler.build_secret(**parameters) })
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,5 @@
1
+ Description:
2
+ Installs Hackler in a sad web app
3
+
4
+ Example:
5
+ bin/rails generate hackler:install
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hackler::InstallGenerator < Rails::Generators::Base
4
+ source_root File.expand_path("templates", __dir__)
5
+
6
+ def copy_files
7
+ copy_file "initializer.rb", "config/initializers/hackler.rb"
8
+ end
9
+
10
+ def install_route
11
+ route %(mount Hackler::Engine => "/hackler")
12
+ end
13
+
14
+ def configure_active_job_adapter
15
+ gsub_file Pathname(destination_root).join("config/environments/production.rb"),
16
+ /(# )?config\.active_job\.queue_adapter\s+=.*/,
17
+ "config.active_job.queue_adapter = :hackler"
18
+ end
19
+
20
+ def add_migrations
21
+ rails_command "hackler:install:migrations"
22
+ end
23
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ Hackler.configure do |config|
4
+ # change this to an actual secret string
5
+ config.shared_secret = "hackme"
6
+
7
+ # `false` if this is the web app, `true` if this is the Hackler worker
8
+ config.worker = false
9
+
10
+ # set this to the base url where Hackler gets mounted in your app
11
+ config.web_base_url = "https://webapp.example.com/hackler"
12
+
13
+ # set this to where the Hackler worker runs
14
+ config.worker_base_url = "https://hackler.example.com/hackler"
15
+
16
+ # define a custom backtrace cleaner
17
+ # config.backtrace_cleaner = ->(backtrace) { ::Rails.backtrace_cleaner.clean(backtrace) }
18
+ end
@@ -0,0 +1,5 @@
1
+ Description:
2
+ Installs Hackler in `worker` mode
3
+
4
+ Example:
5
+ bin/rails generate hackler:worker_install
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ Hackler.configure do |config|
4
+ # change this to an actual secret string
5
+ config.shared_secret = "hackme"
6
+
7
+ # `false` if this is the web app, `true` if this is the Hackler worker
8
+ config.worker = true
9
+
10
+ # queue name where Hackler jobs will be processed
11
+ config.worker_queue = :default
12
+ end
13
+
14
+ return # remove this line if you use Sidekiq and want to have some nice presets:
15
+
16
+ # record backtraces by default
17
+ Sidekiq.default_job_options = { backtrace: true } unless ENV.key?("SIDEKIQ_DISABLE_BACKTRACES")
18
+
19
+ Sidekiq.configure_server do |config|
20
+ # record full backtraces, they are cleaned by the hackler worker
21
+ config[:backtrace_cleaner] = ->(backtrace) { backtrace }
22
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hackler::WorkerInstallGenerator < Rails::Generators::Base
4
+ source_root File.expand_path("templates", __dir__)
5
+
6
+ def copy_files
7
+ copy_file "initializer.rb", "config/initializers/hackler.rb"
8
+ end
9
+
10
+ def install_route
11
+ route %(mount Hackler::Engine => "/hackler")
12
+ end
13
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hackler
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Hackler
6
+ config.generators.api_only = true
7
+ end
8
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hackler
4
+ VERSION = "0.1.0"
5
+ end
data/lib/hackler.rb ADDED
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hackler/version"
4
+ require "hackler/engine"
5
+
6
+ require "active_job"
7
+ require "active_job/queue_adapters"
8
+ require "active_record"
9
+
10
+ require "digest/sha2"
11
+ require "faraday"
12
+
13
+ require "zeitwerk"
14
+
15
+ loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
16
+ loader.ignore("#{__dir__}/generators")
17
+ loader.setup
18
+
19
+ module Hackler
20
+ mattr_accessor :shared_secret, default: "hackme"
21
+
22
+ # set to true if this is the instance that can actually run the jobs
23
+ mattr_accessor :worker, default: false
24
+
25
+ mattr_accessor :backtrace_cleaner, default: ->(backtrace) { ::Rails.backtrace_cleaner.clean(backtrace) }
26
+
27
+ mattr_accessor :web_base_url
28
+
29
+ mattr_accessor :worker_base_url
30
+
31
+ # set this to the queue hackler should use in jobs
32
+ mattr_accessor :worker_queue, default: :default
33
+
34
+ def self.configure = yield self
35
+
36
+ def self.build_secret(id:, base_url:, **)
37
+ Digest::SHA512.base64digest([shared_secret, id, base_url].join(&:to_s))
38
+ end
39
+
40
+ def self.connection_for(url) = Faraday.new(url:) do |builder|
41
+ builder.request :json
42
+ builder.response :json
43
+ builder.response :raise_error
44
+ end
45
+
46
+ # Base class for Hackler errors
47
+ class Error < StandardError; end
48
+
49
+ # Raised when a job failed
50
+ class JobError < Error
51
+ def self.from_json(json)
52
+ message = json.fetch(:message, "???")
53
+ klass = json.fetch(:class, "???")
54
+ backtrace = json.fetch(:backtrace) { ["???"] * 5 }
55
+
56
+ # sick ruby trick: create a new subclass and extend it with a module
57
+ # which overwrites the exception class' name, in order to make it look
58
+ # better in e.g. the sidekiq dashboards
59
+ subclass = Class.new(self).extend(NameExtender.new(klass))
60
+ subclass.new(message).tap do |exc|
61
+ exc.set_backtrace backtrace
62
+ end
63
+ end
64
+
65
+ class NameExtender < Module
66
+ def initialize(name)
67
+ super
68
+
69
+ define_method :name do
70
+ name
71
+ end
72
+ end
73
+ end
74
+
75
+ private_constant :NameExtender
76
+ end
77
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # desc "Explaining what the task does"
4
+ # task :hackler do
5
+ # # Task goes here
6
+ # end
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hackler
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jyrki Gadinger
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-10-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.12'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.12'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rails
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 7.2.1
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 7.2.1
41
+ description: A cursed approach to background jobs. Here be dragons.
42
+ email:
43
+ - nilsding@nilsding.org
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - MIT-LICENSE
49
+ - README.md
50
+ - Rakefile
51
+ - app/controllers/hackler/application_controller.rb
52
+ - app/controllers/hackler/job_controller.rb
53
+ - app/jobs/hackler/application_job.rb
54
+ - app/jobs/hackler/hackler_job.rb
55
+ - app/models/hackler/application_record.rb
56
+ - app/models/hackler/job.rb
57
+ - config/routes.rb
58
+ - db/migrate/20241002145356_create_hackler_jobs.rb
59
+ - lib/active_job/queue_adapters/hackler_adapter.rb
60
+ - lib/generators/hackler/install/USAGE
61
+ - lib/generators/hackler/install/install_generator.rb
62
+ - lib/generators/hackler/install/templates/initializer.rb
63
+ - lib/generators/hackler/worker_install/USAGE
64
+ - lib/generators/hackler/worker_install/templates/initializer.rb
65
+ - lib/generators/hackler/worker_install/worker_install_generator.rb
66
+ - lib/hackler.rb
67
+ - lib/hackler/engine.rb
68
+ - lib/hackler/version.rb
69
+ - lib/tasks/hackler_tasks.rake
70
+ homepage: https://github.com/nilsding/hackler
71
+ licenses:
72
+ - MIT
73
+ metadata:
74
+ homepage_uri: https://github.com/nilsding/hackler
75
+ source_code_uri: https://github.com/nilsding/hackler
76
+ rubygems_mfa_required: 'true'
77
+ post_install_message:
78
+ rdoc_options: []
79
+ require_paths:
80
+ - lib
81
+ required_ruby_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: 3.2.0
86
+ required_rubygems_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ requirements: []
92
+ rubygems_version: 3.5.9
93
+ signing_key:
94
+ specification_version: 4
95
+ summary: A cursed approach to background jobs
96
+ test_files: []