wurk 1.5.0 → 1.7.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 +4 -4
- data/README.md +40 -2
- data/app/controllers/wurk/api/serializers.rb +51 -1
- data/app/controllers/wurk/api_controller.rb +42 -6
- data/app/controllers/wurk/dashboard_controller.rb +67 -10
- data/config/routes.rb +30 -3
- data/exe/wurk +6 -2
- data/lib/generators/wurk/install/install_generator.rb +3 -3
- data/lib/sidekiq/job_retry.rb +4 -0
- data/lib/sidekiq/manager.rb +4 -0
- data/lib/sidekiq/processor.rb +4 -0
- data/lib/wurk/api/app.rb +186 -0
- data/lib/wurk/api/auth.rb +167 -0
- data/lib/wurk/api/flows.rb +66 -0
- data/lib/wurk/api/idempotency.rb +168 -0
- data/lib/wurk/api/jobs.rb +180 -0
- data/lib/wurk/api/page.rb +98 -0
- data/lib/wurk/api/problem.rb +121 -0
- data/lib/wurk/api/queues.rb +126 -0
- data/lib/wurk/api/read_only.rb +68 -0
- data/lib/wurk/api/request.rb +46 -0
- data/lib/wurk/api/response.rb +28 -0
- data/lib/wurk/api/roll_up.rb +126 -0
- data/lib/wurk/api/router.rb +88 -0
- data/lib/wurk/api/serializers.rb +232 -0
- data/lib/wurk/api/swarm.rb +297 -0
- data/lib/wurk/api/throttle.rb +109 -0
- data/lib/wurk/api/validation.rb +306 -0
- data/lib/wurk/api.rb +96 -0
- data/lib/wurk/batch/server_middleware.rb +2 -2
- data/lib/wurk/batch.rb +12 -3
- data/lib/wurk/capsule.rb +8 -1
- data/lib/wurk/cli.rb +110 -8
- data/lib/wurk/client/buffered.rb +1 -1
- data/lib/wurk/client.rb +42 -3
- data/lib/wurk/collapse.rb +383 -0
- data/lib/wurk/command_builder.rb +49 -0
- data/lib/wurk/compat.rb +19 -0
- data/lib/wurk/component.rb +16 -3
- data/lib/wurk/configuration.rb +303 -4
- data/lib/wurk/debounce.rb +125 -0
- data/lib/wurk/encryption.rb +6 -1
- data/lib/wurk/fetcher/capped.rb +218 -0
- data/lib/wurk/fetcher/reliable.rb +22 -66
- data/lib/wurk/fetcher/unit_of_work.rb +107 -0
- data/lib/wurk/flow/builder.rb +271 -0
- data/lib/wurk/flow/chain.rb +42 -0
- data/lib/wurk/flow/completion.rb +114 -0
- data/lib/wurk/flow/creation.rb +254 -0
- data/lib/wurk/flow/node.rb +124 -0
- data/lib/wurk/flow/status.rb +206 -0
- data/lib/wurk/flow.rb +255 -0
- data/lib/wurk/flow_set.rb +53 -0
- data/lib/wurk/health.rb +8 -7
- data/lib/wurk/heartbeat.rb +21 -3
- data/lib/wurk/job/options.rb +11 -1
- data/lib/wurk/job.rb +19 -0
- data/lib/wurk/job_logger.rb +6 -2
- data/lib/wurk/job_retry.rb +6 -1
- data/lib/wurk/job_util.rb +110 -9
- data/lib/wurk/keys.rb +125 -0
- data/lib/wurk/launcher.rb +1 -1
- data/lib/wurk/leader.rb +1 -1
- data/lib/wurk/limiter/bucket.rb +1 -1
- data/lib/wurk/limiter/points.rb +1 -1
- data/lib/wurk/lua/debounce.lua +75 -0
- data/lib/wurk/lua/fetch_slot.lua +83 -0
- data/lib/wurk/lua/flow_abandon.lua +65 -0
- data/lib/wurk/lua/flow_advance.lua +177 -0
- data/lib/wurk/lua/flow_create.lua +141 -0
- data/lib/wurk/lua/flow_fail.lua +52 -0
- data/lib/wurk/lua/limiter_bucket_acquire.lua +26 -0
- data/lib/wurk/lua/limiter_concurrent_acquire.lua +33 -0
- data/lib/wurk/lua/limiter_concurrent_release.lua +7 -0
- data/lib/wurk/lua/limiter_leaky_acquire.lua +31 -0
- data/lib/wurk/lua/limiter_list_sweep.lua +30 -0
- data/lib/wurk/lua/limiter_points_acquire.lua +34 -0
- data/lib/wurk/lua/limiter_points_refund.lua +18 -0
- data/lib/wurk/lua/limiter_register.lua +27 -0
- data/lib/wurk/lua/limiter_window_acquire.lua +35 -0
- data/lib/wurk/lua/limiter_window_status.lua +22 -0
- data/lib/wurk/lua/loader.rb +23 -0
- data/lib/wurk/lua/queue_slot.lua +83 -0
- data/lib/wurk/lua/refresh_slots.lua +38 -0
- data/lib/wurk/lua/status_write.lua +29 -0
- data/lib/wurk/lua/throttle_slot.lua +71 -0
- data/lib/wurk/lua.rb +3 -3
- data/lib/wurk/manager.rb +12 -0
- data/lib/wurk/metrics/history.rb +34 -2
- data/lib/wurk/metrics/query.rb +1 -1
- data/lib/wurk/metrics/statsd.rb +14 -0
- data/lib/wurk/middleware/expiry.rb +71 -10
- data/lib/wurk/middleware/status.rb +274 -0
- data/lib/wurk/middleware/timeout.rb +137 -0
- data/lib/wurk/pid_cache.rb +43 -0
- data/lib/wurk/processor.rb +66 -22
- data/lib/wurk/profiler.rb +5 -3
- data/lib/wurk/queue_slot.rb +285 -0
- data/lib/wurk/rails.rb +3 -3
- data/lib/wurk/redis_pool.rb +5 -2
- data/lib/wurk/status/progress.rb +103 -0
- data/lib/wurk/status/record.rb +81 -0
- data/lib/wurk/status.rb +155 -0
- data/lib/wurk/telemetry/client_middleware.rb +59 -0
- data/lib/wurk/telemetry/server_middleware.rb +152 -0
- data/lib/wurk/telemetry.rb +137 -0
- data/lib/wurk/throttle.rb +142 -0
- data/lib/wurk/unique.rb +10 -2
- data/lib/wurk/version.rb +1 -1
- data/lib/wurk/watchdog.rb +188 -0
- data/lib/wurk/web/config.rb +77 -10
- data/lib/wurk/web/extension.rb +12 -4
- data/lib/wurk/web/locale_negotiator.rb +67 -0
- data/lib/wurk/web.rb +1 -0
- data/lib/wurk/worker.rb +39 -4
- data/lib/wurk.rb +42 -1
- data/vendor/assets/dashboard/assets/ArgsValue-D-x_ifLY.js +1 -0
- data/vendor/assets/dashboard/assets/BatchDetail-C39NJuew.js +1 -0
- data/vendor/assets/dashboard/assets/Batches-CSwo7Asa.js +1 -0
- data/vendor/assets/dashboard/assets/Busy-BOFMu-sq.js +1 -0
- data/vendor/assets/dashboard/assets/Cron-Dy8RQzDI.js +1 -0
- data/vendor/assets/dashboard/assets/Dashboard-BuTHI-O1.js +1 -0
- data/vendor/assets/dashboard/assets/Dead-B9KRvQ0N.js +1 -0
- data/vendor/assets/dashboard/assets/Extension-BnBVHfux.js +1 -0
- data/vendor/assets/dashboard/assets/FilterBox-DC24zite.js +1 -0
- data/vendor/assets/dashboard/assets/FlowDetail-DyLuzUvt.js +1 -0
- data/vendor/assets/dashboard/assets/FlowState-DAPKUahm.js +1 -0
- data/vendor/assets/dashboard/assets/Flows-Fr3rjZM_.js +1 -0
- data/vendor/assets/dashboard/assets/JobDetailModal-N6kiJXq3.js +2 -0
- data/vendor/assets/dashboard/assets/Limiters-kbFA7uS1.js +1 -0
- data/vendor/assets/dashboard/assets/Metrics-Dj2uoZ3o.js +1 -0
- data/vendor/assets/dashboard/assets/PageHeader-B_F94azl.js +1 -0
- data/vendor/assets/dashboard/assets/Profiles-D_DjEezN.js +1 -0
- data/vendor/assets/dashboard/assets/Queues-CO4V9hAz.js +1 -0
- data/vendor/assets/dashboard/assets/Retries-DCWnzeLa.js +1 -0
- data/vendor/assets/dashboard/assets/Scheduled-BebDUjLU.js +1 -0
- data/vendor/assets/dashboard/assets/Search-Cvr5fy4Y.js +1 -0
- data/vendor/assets/dashboard/assets/Skeleton-Bu3Ke6rV.js +1 -0
- data/vendor/assets/dashboard/assets/charts-BCs9bQKz.js +1 -0
- data/vendor/assets/dashboard/assets/index-BIwyOC5Q.js +141 -0
- data/vendor/assets/dashboard/assets/index-DBQN6Jk8.css +1 -0
- data/vendor/assets/dashboard/assets/useResetPageOnEmpty-Bzh-BJyL.js +1 -0
- data/vendor/assets/dashboard/assets/useSort-COA3fVJ5.js +1 -0
- data/vendor/assets/dashboard/assets/utils-BIrvZ1hi.js +1 -0
- data/vendor/assets/dashboard/index.html +42 -11
- data/vendor/assets/dashboard/wurk-manifest.json +2 -2
- metadata +102 -33
- data/vendor/assets/dashboard/assets/ArgsValue-CcR2ya6e.js +0 -1
- data/vendor/assets/dashboard/assets/BatchDetail-CUXJUQ3Q.js +0 -1
- data/vendor/assets/dashboard/assets/Batches-Cxan6Ngw.js +0 -1
- data/vendor/assets/dashboard/assets/Busy-DC5EGM0g.js +0 -1
- data/vendor/assets/dashboard/assets/Cron-Dlt8tXJA.js +0 -1
- data/vendor/assets/dashboard/assets/Dashboard-DNLu_WCg.js +0 -1
- data/vendor/assets/dashboard/assets/Dead-dZ7VGlKS.js +0 -1
- data/vendor/assets/dashboard/assets/Extension-DaFpEIJf.js +0 -1
- data/vendor/assets/dashboard/assets/FilterBox-CO3aYWIq.js +0 -1
- data/vendor/assets/dashboard/assets/JobDetailModal-DSWbT6G0.js +0 -2
- data/vendor/assets/dashboard/assets/Limiters-Cb4PKXNR.js +0 -1
- data/vendor/assets/dashboard/assets/Metrics-CCGzgCsT.js +0 -1
- data/vendor/assets/dashboard/assets/Modal-B86q6ruL.js +0 -1
- data/vendor/assets/dashboard/assets/PageHeader-fPrCcp_-.js +0 -1
- data/vendor/assets/dashboard/assets/Profiles-BnS82nR_.js +0 -1
- data/vendor/assets/dashboard/assets/Queues-CIyPevOy.js +0 -1
- data/vendor/assets/dashboard/assets/Retries-DopwXkXl.js +0 -1
- data/vendor/assets/dashboard/assets/Scheduled-1-Z7i1zE.js +0 -1
- data/vendor/assets/dashboard/assets/Search-ByA6eTma.js +0 -1
- data/vendor/assets/dashboard/assets/Skeleton-bC7HfQ9r.js +0 -1
- data/vendor/assets/dashboard/assets/charts-CLLzJ7vK.js +0 -1
- data/vendor/assets/dashboard/assets/index-B1N8hQUh.js +0 -141
- data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +0 -1
- data/vendor/assets/dashboard/assets/useResetPageOnEmpty-DpBjkf6_.js +0 -1
- data/vendor/assets/dashboard/assets/useSort-DvpwuNQE.js +0 -1
- data/vendor/assets/dashboard/assets/utils-DDJC7tJV.js +0 -1
data/lib/wurk/api/app.rb
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rack'
|
|
4
|
+
require_relative '../api'
|
|
5
|
+
require_relative '../version'
|
|
6
|
+
require_relative 'auth'
|
|
7
|
+
require_relative 'flows'
|
|
8
|
+
require_relative 'jobs'
|
|
9
|
+
require_relative 'problem'
|
|
10
|
+
require_relative 'queues'
|
|
11
|
+
require_relative 'read_only'
|
|
12
|
+
require_relative 'request'
|
|
13
|
+
require_relative 'response'
|
|
14
|
+
require_relative 'router'
|
|
15
|
+
require_relative 'swarm'
|
|
16
|
+
require_relative 'throttle'
|
|
17
|
+
|
|
18
|
+
module Wurk
|
|
19
|
+
module API
|
|
20
|
+
# The machine-facing HTTP API. Plain Rack under lib/, with no reference to
|
|
21
|
+
# Rails or the engine, so one implementation serves all three mounts:
|
|
22
|
+
# engine-nested (`<mount>/api/v1`), separately mounted
|
|
23
|
+
# (`mount Wurk::API => '/wurk-api'`), and standalone (`run Wurk::API`).
|
|
24
|
+
#
|
|
25
|
+
# Nothing here hardcodes '/wurk'. Every URL the API emits is built from
|
|
26
|
+
# SCRIPT_NAME (Request#url_for) — the same mount-agnostic rule the SPA shell
|
|
27
|
+
# follows in dashboard_controller.rb.
|
|
28
|
+
class App
|
|
29
|
+
# `config` is read on every request rather than captured, so a token
|
|
30
|
+
# registered after the first request still takes effect. Injectable
|
|
31
|
+
# because a test must be able to hand this app its own token table
|
|
32
|
+
# instead of mutating the process-wide one.
|
|
33
|
+
def initialize(config: nil)
|
|
34
|
+
@config = config
|
|
35
|
+
@router = Router.new
|
|
36
|
+
draw(@router)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def call(env)
|
|
40
|
+
request = Request.new(env)
|
|
41
|
+
# Stamped once, before anything reads it, so every handler sees the
|
|
42
|
+
# same config the auth gate did — including a test's injected one.
|
|
43
|
+
request.config = config
|
|
44
|
+
response = handle(request)
|
|
45
|
+
# HEAD is routed as GET, so the handler built a body it must not send.
|
|
46
|
+
request.head? ? [response[0], response[1], []] : response
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def config
|
|
52
|
+
@config || ::Wurk.configuration
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# One table per plane, each owning its own routes and handlers. Queues,
|
|
56
|
+
# Flows and Swarm join Jobs here.
|
|
57
|
+
def draw(router)
|
|
58
|
+
router.get('/', scope: Auth::ANY) { |request| root(request) }
|
|
59
|
+
Jobs.draw(router)
|
|
60
|
+
Queues.draw(router)
|
|
61
|
+
Flows.draw(router)
|
|
62
|
+
Swarm.draw(router)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Authentication runs before the version gate and before routing, so an
|
|
66
|
+
# unauthenticated prober can't enumerate which paths or versions exist
|
|
67
|
+
# from the difference between a 404 and a 405.
|
|
68
|
+
#
|
|
69
|
+
# Then two refusals that are true of the whole deployment rather than of
|
|
70
|
+
# any one route, in cost order. Read-only is a fact this process already
|
|
71
|
+
# holds, so it answers without touching Redis. The throttle needs a
|
|
72
|
+
# credential to charge, which is why it cannot run before authentication —
|
|
73
|
+
# and it charges every authenticated request, routed or not: a client
|
|
74
|
+
# walking paths that don't exist is exactly the traffic a ceiling is for.
|
|
75
|
+
def handle(request)
|
|
76
|
+
config = request.config
|
|
77
|
+
return not_found(request) unless Auth.configured?(config)
|
|
78
|
+
|
|
79
|
+
principal = Auth.authenticate(request, config)
|
|
80
|
+
return Auth.unauthorized(request) unless principal
|
|
81
|
+
|
|
82
|
+
request.principal = principal
|
|
83
|
+
refusal = ReadOnly.refuse(request) || Throttle.refuse(request)
|
|
84
|
+
refusal || route(request)
|
|
85
|
+
rescue StandardError => e
|
|
86
|
+
internal_error(request, e)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def route(request)
|
|
90
|
+
path = request.path_info.to_s
|
|
91
|
+
return unsupported_version(request) unless versioned?(path)
|
|
92
|
+
|
|
93
|
+
dispatch(request, path.delete_prefix(VERSION_PREFIX))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def dispatch(request, path)
|
|
97
|
+
match = @router.match(request.request_method, path)
|
|
98
|
+
return route_miss(request, match) unless match.handler
|
|
99
|
+
return Auth.forbidden(request, match.scope) unless request.principal.permits?(match.scope)
|
|
100
|
+
|
|
101
|
+
request.path_params = match.params
|
|
102
|
+
match.handler.call(request)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The document a client hits to confirm which mount and which contract it
|
|
106
|
+
# is talking to before it starts guessing paths. It carries the two
|
|
107
|
+
# deployment-wide refusals as well: both are things a producer would
|
|
108
|
+
# otherwise only learn by having a write refused, and a client that can
|
|
109
|
+
# read "this one is frozen" at startup fails in its own logs instead of
|
|
110
|
+
# halfway through a batch.
|
|
111
|
+
def root(request)
|
|
112
|
+
Response.json(
|
|
113
|
+
200,
|
|
114
|
+
api_version: API_VERSION, wurk_version: ::Wurk::VERSION, url: request.url_for(VERSION_PREFIX),
|
|
115
|
+
read_only: ReadOnly.enabled?(request), rate_limit: rate_limit(request.config)
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# nil, not an object with nil members: "there is no ceiling here" and
|
|
120
|
+
# "there is one, of unknown size" are different answers.
|
|
121
|
+
def rate_limit(config)
|
|
122
|
+
limit = config.api_rate_limit
|
|
123
|
+
return nil unless limit
|
|
124
|
+
|
|
125
|
+
{ limit: limit, interval_seconds: Throttle.interval_seconds(config.api_rate_limit_interval) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def versioned?(path)
|
|
129
|
+
path == VERSION_PREFIX || path.start_with?(NESTED_PREFIX)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def route_miss(request, match)
|
|
133
|
+
return not_found(request) if match.allowed.empty?
|
|
134
|
+
|
|
135
|
+
Problem.render(
|
|
136
|
+
Problem::METHOD_NOT_ALLOWED,
|
|
137
|
+
status: 405,
|
|
138
|
+
detail: "#{request.request_method} is not allowed on #{request.path_info}.",
|
|
139
|
+
instance: request.path,
|
|
140
|
+
headers: { 'allow' => allow_header(match.allowed) }
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# A path that answers GET answers HEAD too, so advertise both.
|
|
145
|
+
def allow_header(verbs)
|
|
146
|
+
verbs = verbs.dup
|
|
147
|
+
verbs << 'HEAD' if verbs.include?('GET')
|
|
148
|
+
verbs.join(', ')
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def not_found(request)
|
|
152
|
+
Problem.render(
|
|
153
|
+
Problem::NOT_FOUND,
|
|
154
|
+
status: 404,
|
|
155
|
+
detail: "No route matches #{request.request_method} #{request.path_info}.",
|
|
156
|
+
instance: request.path
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# 404 rather than 400: an unknown version is an address that does not
|
|
161
|
+
# exist, and the client's own request was well-formed.
|
|
162
|
+
def unsupported_version(request)
|
|
163
|
+
Problem.render(
|
|
164
|
+
Problem::UNSUPPORTED_API_VERSION,
|
|
165
|
+
status: 404,
|
|
166
|
+
detail: "The Wurk HTTP API is served under #{VERSION_PREFIX}.",
|
|
167
|
+
instance: request.path,
|
|
168
|
+
supported_versions: SUPPORTED_VERSIONS
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# The exception details stay in the log. A machine client gets a stable
|
|
173
|
+
# slug it can retry on, never a backtrace naming host internals.
|
|
174
|
+
def internal_error(request, error)
|
|
175
|
+
::Wurk.logger.error { "Wurk::API #{request.request_method} #{request.path}: #{error.class}: #{error.message}" }
|
|
176
|
+
::Wurk.logger.debug { Array(error.backtrace).join("\n") }
|
|
177
|
+
Problem.render(
|
|
178
|
+
Problem::INTERNAL_ERROR,
|
|
179
|
+
status: 500,
|
|
180
|
+
detail: 'The request could not be completed.',
|
|
181
|
+
instance: request.path
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require_relative 'problem'
|
|
6
|
+
|
|
7
|
+
module Wurk
|
|
8
|
+
module API
|
|
9
|
+
# Bearer-token authentication and scope checks for the machine-facing HTTP
|
|
10
|
+
# API. Nothing behind /v1 answers without passing through here.
|
|
11
|
+
#
|
|
12
|
+
# Deliberately a separate plane from the dashboard's. That one is cookie-
|
|
13
|
+
# authenticated and CSRF-guarded by Wurk::SameOriginGuard, which denies any
|
|
14
|
+
# request that doesn't carry `Sec-Fetch-Site: same-origin` — a header the
|
|
15
|
+
# browser sets and a Python or Go producer never sends. Reusing it would
|
|
16
|
+
# mean either 403ing every legitimate machine client or relaxing the one
|
|
17
|
+
# check that stops a cross-site page from driving a logged-in operator's
|
|
18
|
+
# dashboard.
|
|
19
|
+
module Auth
|
|
20
|
+
# `admin` grants the other two. An operator token allowed to stop a
|
|
21
|
+
# process but refused a queue listing would be a surprise, not a
|
|
22
|
+
# safeguard.
|
|
23
|
+
SCOPES = %i[enqueue read admin].freeze
|
|
24
|
+
|
|
25
|
+
# Required scope for a route every authenticated client needs whatever it
|
|
26
|
+
# was granted — the discovery document, which an enqueue-only producer
|
|
27
|
+
# has to read to confirm the contract it is about to post to.
|
|
28
|
+
ANY = :any
|
|
29
|
+
|
|
30
|
+
ROUTE_SCOPES = (SCOPES + [ANY]).freeze
|
|
31
|
+
|
|
32
|
+
# Under 20 characters the host almost certainly typed the token instead of
|
|
33
|
+
# generating one (`SecureRandom.urlsafe_base64(16)` is 22). A guessable
|
|
34
|
+
# token on this API is remote code selection, not just a data leak.
|
|
35
|
+
MIN_TOKEN_LENGTH = 20
|
|
36
|
+
|
|
37
|
+
# Printable ASCII, no spaces — what an Authorization header carries
|
|
38
|
+
# intact. A token that loses a byte in transit fails as "wrong token",
|
|
39
|
+
# the least debuggable 401 there is, so reject the shape up front.
|
|
40
|
+
TOKEN_FORMAT = /\A[\x21-\x7e]+\z/
|
|
41
|
+
|
|
42
|
+
# RFC 6750 §2.1. The scheme is case-insensitive (RFC 9110 §11.1); the
|
|
43
|
+
# credential is not.
|
|
44
|
+
BEARER = /\ABearer[ \t]+([\x21-\x7e]+)[ \t]*\z/i
|
|
45
|
+
|
|
46
|
+
REALM = 'Bearer realm="wurk"'
|
|
47
|
+
|
|
48
|
+
# Domain-separated so the label below is not a digest of the raw token —
|
|
49
|
+
# a value that leaked into a log would otherwise be a free head start on
|
|
50
|
+
# confirming a guessed token offline.
|
|
51
|
+
FINGERPRINT_DOMAIN = 'wurk.api.token.'
|
|
52
|
+
|
|
53
|
+
# 128 bits of the digest. Long enough that two credentials never share a
|
|
54
|
+
# label, short enough to read in a log line.
|
|
55
|
+
FINGERPRINT_LENGTH = 32
|
|
56
|
+
|
|
57
|
+
# What the request proved about itself: the scopes it was granted, and a
|
|
58
|
+
# stable label for the credential that granted them. `id` is a truncated
|
|
59
|
+
# domain-separated digest, never the token — enough to namespace
|
|
60
|
+
# per-credential state (an Idempotency-Key record belongs to the producer
|
|
61
|
+
# that chose it, not to whoever guesses the same string), and nothing a
|
|
62
|
+
# holder could work backwards from.
|
|
63
|
+
Principal = Struct.new(:id, :scopes) do
|
|
64
|
+
def permits?(scope)
|
|
65
|
+
scope == ANY || scopes.include?(scope) || scopes.include?(:admin)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
module_function
|
|
70
|
+
|
|
71
|
+
# The gate the whole API hangs off. No token registered → the engine
|
|
72
|
+
# never mounts the app, and an app mounted directly answers 404 rather
|
|
73
|
+
# than advertising a surface with nothing behind it.
|
|
74
|
+
def configured?(config)
|
|
75
|
+
!config.api_tokens.empty?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Validates a credential where it is declared, so a typo raises in the
|
|
79
|
+
# initializer that wrote it instead of surfacing as a 401 in production.
|
|
80
|
+
#
|
|
81
|
+
# @return [Array(String, Array<Symbol>)] token value and granted scopes
|
|
82
|
+
def credential!(token, scopes)
|
|
83
|
+
value = token.to_s
|
|
84
|
+
unless value.length >= MIN_TOKEN_LENGTH
|
|
85
|
+
raise ArgumentError, "api_token must be at least #{MIN_TOKEN_LENGTH} characters"
|
|
86
|
+
end
|
|
87
|
+
raise ArgumentError, 'api_token must be printable ASCII with no spaces' unless TOKEN_FORMAT.match?(value)
|
|
88
|
+
|
|
89
|
+
[value, granted!(scopes)]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def granted!(scopes)
|
|
93
|
+
granted = Array(scopes).map { |scope| scope.to_s.to_sym }.uniq
|
|
94
|
+
raise ArgumentError, 'api_token requires at least one scope' if granted.empty?
|
|
95
|
+
|
|
96
|
+
unknown = granted - SCOPES
|
|
97
|
+
unless unknown.empty?
|
|
98
|
+
raise ArgumentError, "unknown api_token scope #{unknown.inspect}; valid scopes are #{SCOPES.inspect}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
granted.freeze
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @return [Principal, nil] nil when no credential was presented, or the
|
|
105
|
+
# one presented matches nothing registered.
|
|
106
|
+
def authenticate(request, config)
|
|
107
|
+
presented = bearer(request.get_header('HTTP_AUTHORIZATION'))
|
|
108
|
+
return nil unless presented
|
|
109
|
+
|
|
110
|
+
token, scopes = lookup(presented, config.api_tokens)
|
|
111
|
+
scopes && Principal.new(fingerprint(token), scopes)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def bearer(header)
|
|
115
|
+
match = BEARER.match(header.to_s)
|
|
116
|
+
match && match[1]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Walks every registered token with no early return on a hit, so the
|
|
120
|
+
# clock can't tell a client where in the table its guess landed — or
|
|
121
|
+
# whether it landed at all. Both sides are digested first: OpenSSL's
|
|
122
|
+
# compare demands equal lengths, and the bytesize pre-check the obvious
|
|
123
|
+
# implementation reaches for (Rack::Utils.secure_compare does exactly
|
|
124
|
+
# that) answers "how long is the real token" to anyone who can time this.
|
|
125
|
+
def lookup(presented, tokens)
|
|
126
|
+
digest = ::Digest::SHA256.digest(presented)
|
|
127
|
+
found = nil
|
|
128
|
+
tokens.each do |token, scopes|
|
|
129
|
+
found = [token, scopes] if OpenSSL.fixed_length_secure_compare(digest, ::Digest::SHA256.digest(token))
|
|
130
|
+
end
|
|
131
|
+
found
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def fingerprint(token)
|
|
135
|
+
::Digest::SHA256.hexdigest("#{FINGERPRINT_DOMAIN}#{token}")[0, FINGERPRINT_LENGTH]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# RFC 6750 §3. The challenge names `invalid_token` only when the client
|
|
139
|
+
# actually presented one — telling a client that sent no credential that
|
|
140
|
+
# its credential was rejected sends it hunting for the wrong bug.
|
|
141
|
+
def unauthorized(request)
|
|
142
|
+
presented = request.get_header('HTTP_AUTHORIZATION')
|
|
143
|
+
Problem.render(
|
|
144
|
+
Problem::UNAUTHORIZED,
|
|
145
|
+
status: 401,
|
|
146
|
+
detail: presented ? 'The bearer token presented is not valid.' : 'A bearer token is required.',
|
|
147
|
+
instance: request.path,
|
|
148
|
+
headers: { 'www-authenticate' => presented ? %(#{REALM}, error="invalid_token") : REALM }
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# 403, not a 404 hiding the route: the client already authenticated, so
|
|
153
|
+
# concealing the address buys nothing and costs it the one fact it needs
|
|
154
|
+
# — which scope to ask its operator for.
|
|
155
|
+
def forbidden(request, scope)
|
|
156
|
+
Problem.render(
|
|
157
|
+
Problem::INSUFFICIENT_SCOPE,
|
|
158
|
+
status: 403,
|
|
159
|
+
detail: "This token is not granted the #{scope} scope.",
|
|
160
|
+
instance: request.path,
|
|
161
|
+
headers: { 'www-authenticate' => %(#{REALM}, error="insufficient_scope", scope="#{scope}") },
|
|
162
|
+
required_scope: scope
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'problem'
|
|
4
|
+
require_relative 'response'
|
|
5
|
+
require_relative 'serializers'
|
|
6
|
+
require_relative 'validation'
|
|
7
|
+
|
|
8
|
+
module Wurk
|
|
9
|
+
module API
|
|
10
|
+
# One route: read a flow.
|
|
11
|
+
#
|
|
12
|
+
# A producer that posted a graph needs to know how far it got, and a fid is
|
|
13
|
+
# the only thing it holds — so this answers by fid and nothing else. There
|
|
14
|
+
# is no listing: the `flows` index is a dashboard affordance for a human
|
|
15
|
+
# who lost an id, and paging a machine client through every flow in the
|
|
16
|
+
# deployment is not a question a producer has.
|
|
17
|
+
#
|
|
18
|
+
# Nor is there a write. Abandonment is an operator decision made with the
|
|
19
|
+
# graph on screen (slice 11 decision 4), and the dashboard is where that
|
|
20
|
+
# screen is; a producer that could abandon its own flows over HTTP is one
|
|
21
|
+
# bad retry away from killing a run that was merely slow.
|
|
22
|
+
#
|
|
23
|
+
# Reads through {Wurk::Flow::Status}, the same view the dashboard uses, for
|
|
24
|
+
# the reason every other observe-plane route reads through an inspector: a
|
|
25
|
+
# second reader of the flow key schema is a second thing to keep in step
|
|
26
|
+
# with the completion scripts that write it.
|
|
27
|
+
module Flows
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
TABLE = [
|
|
31
|
+
[:get, '/flows/:fid', :read, :show]
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
def draw(router)
|
|
35
|
+
TABLE.each do |verb, pattern, scope, handler|
|
|
36
|
+
router.public_send(verb, pattern, scope: scope) do |request|
|
|
37
|
+
public_send(handler, request)
|
|
38
|
+
rescue Validation::Invalid => e
|
|
39
|
+
Problem.from(e, instance: request.path)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def show(request)
|
|
45
|
+
fid = Validation.fid!(request.path_params[:fid])
|
|
46
|
+
status = ::Wurk::Flow::Status.new(fid)
|
|
47
|
+
return not_found(request, fid) unless status.exists?
|
|
48
|
+
|
|
49
|
+
Response.json(200, Serializers.flow(status))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Expiry is named because it is the likeliest answer for a producer whose
|
|
53
|
+
# fid used to work: a flow is kept on the batch clock and then goes, and
|
|
54
|
+
# a client that assumed otherwise is the one reading this.
|
|
55
|
+
def not_found(request, fid)
|
|
56
|
+
Problem.render(
|
|
57
|
+
Problem::FLOW_NOT_FOUND,
|
|
58
|
+
status: 404,
|
|
59
|
+
detail: "No flow has fid #{fid}; it was never created, or it has expired.",
|
|
60
|
+
instance: request.path,
|
|
61
|
+
fid: fid
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require_relative '../keys'
|
|
5
|
+
require_relative 'problem'
|
|
6
|
+
require_relative 'response'
|
|
7
|
+
require_relative 'validation'
|
|
8
|
+
|
|
9
|
+
module Wurk
|
|
10
|
+
module API
|
|
11
|
+
# `Idempotency-Key` replay protection for the produce plane.
|
|
12
|
+
#
|
|
13
|
+
# A producer whose connection drops mid-`POST /jobs` cannot tell a lost
|
|
14
|
+
# request from a lost response, so its only safe move is to send it again —
|
|
15
|
+
# and without this, the retry is a second job. The header turns that retry
|
|
16
|
+
# into a replay: the first request's response comes back verbatim and
|
|
17
|
+
# nothing is enqueued twice.
|
|
18
|
+
#
|
|
19
|
+
# Entirely client-driven. No header means no key, no record and no round
|
|
20
|
+
# trip, so a producer that does not need this pays nothing for it.
|
|
21
|
+
module Idempotency
|
|
22
|
+
HEADER = 'HTTP_IDEMPOTENCY_KEY'
|
|
23
|
+
|
|
24
|
+
# The same shape a bearer token has to survive: printable ASCII, no
|
|
25
|
+
# spaces. A key that loses a byte in transit would silently address a
|
|
26
|
+
# different record, which is the one failure this module exists to
|
|
27
|
+
# prevent.
|
|
28
|
+
KEY_FORMAT = /\A[\x21-\x7e]{1,255}\z/
|
|
29
|
+
|
|
30
|
+
# Marks a replayed response, so a client can tell "your job was enqueued"
|
|
31
|
+
# from "your job was enqueued, earlier".
|
|
32
|
+
REPLAY_HEADER = 'idempotency-replayed'
|
|
33
|
+
|
|
34
|
+
# A record is `<status>\n<body digest>\n<body>`, and status `0` means a
|
|
35
|
+
# claim still in flight. Line-oriented rather than JSON because it is
|
|
36
|
+
# read back under contention: a truncated or hand-poisoned value has to
|
|
37
|
+
# degrade to "still in progress", not to a parser exception on the
|
|
38
|
+
# request path.
|
|
39
|
+
PENDING = 0
|
|
40
|
+
|
|
41
|
+
module_function
|
|
42
|
+
|
|
43
|
+
# Wraps a state-changing handler. Without the header this is a bare call
|
|
44
|
+
# through; with it, exactly one of the requests sharing a key runs the
|
|
45
|
+
# handler and the rest replay its answer.
|
|
46
|
+
#
|
|
47
|
+
# @param raw_body [String] the bytes the response is pinned to, so the
|
|
48
|
+
# same key sent with a different body is a client bug, not a replay.
|
|
49
|
+
def around(request, raw_body, config, &handler)
|
|
50
|
+
key = presented(request)
|
|
51
|
+
return handler.call unless key
|
|
52
|
+
|
|
53
|
+
slot = slot_for(request, key)
|
|
54
|
+
fingerprint = ::Digest::SHA256.hexdigest(raw_body)
|
|
55
|
+
stored = claim(slot, fingerprint, config.api_idempotency_ttl)
|
|
56
|
+
return replay(request, stored, fingerprint) if stored
|
|
57
|
+
|
|
58
|
+
settle(slot, fingerprint, &handler)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def presented(request)
|
|
62
|
+
key = request.get_header(HEADER)
|
|
63
|
+
return nil if key.nil?
|
|
64
|
+
return key if KEY_FORMAT.match?(key)
|
|
65
|
+
|
|
66
|
+
raise Validation::Invalid, 'Idempotency-Key must be 1-255 printable ASCII characters with no spaces.'
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Scoped to the credential and to the route. The key is a string the
|
|
70
|
+
# client chose: two producers that both sent `1` must not see each
|
|
71
|
+
# other's jids, and the same key on `/jobs` and `/jobs/bulk` addresses two
|
|
72
|
+
# different requests. Hashed rather than concatenated so the client's own
|
|
73
|
+
# string never reaches Redis in the clear and every slot is one length.
|
|
74
|
+
def slot_for(request, key)
|
|
75
|
+
::Wurk::Keys.idempotency(
|
|
76
|
+
::Digest::SHA256.hexdigest("#{request.principal.id}\n#{request.path_info}\n#{key}")
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# One round trip: reserve the key if it is free, and read what is there
|
|
81
|
+
# either way. Deliberately not a MULTI — the SET already picked the
|
|
82
|
+
# winner, and the GET only has to explain the loss.
|
|
83
|
+
#
|
|
84
|
+
# @return [String, nil] nil when this request owns the key; otherwise the
|
|
85
|
+
# record the owner left, or is still to leave. An empty string when the
|
|
86
|
+
# owner's record expired in between, which reads as "in flight" and is
|
|
87
|
+
# the safe answer: it never enqueues a second job.
|
|
88
|
+
def claim(slot, fingerprint, ttl)
|
|
89
|
+
reserved, existing = ::Wurk.redis do |conn|
|
|
90
|
+
conn.pipelined do |pipe|
|
|
91
|
+
pipe.call('SET', slot, encode(PENDING, fingerprint, ''), 'NX', 'EX', ttl)
|
|
92
|
+
pipe.call('GET', slot)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
reserved ? nil : existing.to_s
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Only a success is worth replaying. A rejected body is a request the
|
|
99
|
+
# client should be able to correct and send again under the same key, and
|
|
100
|
+
# a raise is a request whose outcome nobody knows — both release the key
|
|
101
|
+
# rather than pinning an answer to it.
|
|
102
|
+
def settle(slot, fingerprint)
|
|
103
|
+
response = yield
|
|
104
|
+
response[0] < 300 ? record(slot, fingerprint, response) : release(slot)
|
|
105
|
+
response
|
|
106
|
+
rescue StandardError
|
|
107
|
+
release(slot)
|
|
108
|
+
raise
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# KEEPTTL so the window is the first request's, not this write's, and XX
|
|
112
|
+
# so a claim whose TTL lapsed mid-flight is not resurrected as a record
|
|
113
|
+
# with no expiry at all.
|
|
114
|
+
def record(slot, fingerprint, response)
|
|
115
|
+
status, _headers, body = response
|
|
116
|
+
::Wurk.redis { |conn| conn.call('SET', slot, encode(status, fingerprint, body.join), 'XX', 'KEEPTTL') }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def release(slot)
|
|
120
|
+
::Wurk.redis { |conn| conn.call('DEL', slot) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def replay(request, stored, fingerprint)
|
|
124
|
+
record = decode(stored)
|
|
125
|
+
return in_progress(request) unless record
|
|
126
|
+
|
|
127
|
+
status, digest, body = record
|
|
128
|
+
return reused(request) unless digest == fingerprint
|
|
129
|
+
return in_progress(request) if status == PENDING
|
|
130
|
+
|
|
131
|
+
[status, Response::HEADERS.merge(REPLAY_HEADER => 'true'), [body]]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def encode(status, digest, body) = "#{status}\n#{digest}\n#{body}"
|
|
135
|
+
|
|
136
|
+
# @return [Array(Integer, String, String), nil] nil for a record that is
|
|
137
|
+
# gone or unreadable — the caller treats both as still in flight.
|
|
138
|
+
def decode(stored)
|
|
139
|
+
status, digest, body = stored.split("\n", 3)
|
|
140
|
+
return nil if digest.nil? || digest.empty?
|
|
141
|
+
|
|
142
|
+
[status.to_i, digest, body.to_s]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# 409 rather than 400: the request is well-formed, and what it collides
|
|
146
|
+
# with is a fact about the server's state that the client can resolve by
|
|
147
|
+
# rotating the key.
|
|
148
|
+
def reused(request)
|
|
149
|
+
Problem.render(
|
|
150
|
+
Problem::IDEMPOTENCY_KEY_REUSED,
|
|
151
|
+
status: 409,
|
|
152
|
+
detail: 'This Idempotency-Key was already used for a different request body.',
|
|
153
|
+
instance: request.path
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def in_progress(request)
|
|
158
|
+
Problem.render(
|
|
159
|
+
Problem::REQUEST_IN_PROGRESS,
|
|
160
|
+
status: 409,
|
|
161
|
+
detail: 'A request with this Idempotency-Key is still in flight.',
|
|
162
|
+
instance: request.path,
|
|
163
|
+
headers: { 'retry-after' => '1' }
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|