hackler 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.
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: []