cache_bro 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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +114 -0
- data/Rakefile +3 -0
- data/app/controllers/cache_bro/application_controller.rb +91 -0
- data/app/controllers/cache_bro/audit_events_controller.rb +38 -0
- data/app/controllers/cache_bro/flushes_controller.rb +32 -0
- data/app/controllers/cache_bro/keys_controller.rb +39 -0
- data/app/controllers/cache_bro/purges_controller.rb +38 -0
- data/app/helpers/cache_bro/application_helper.rb +63 -0
- data/app/models/cache_bro/application_record.rb +6 -0
- data/app/models/cache_bro/audit_event.rb +25 -0
- data/app/views/cache_bro/audit_events/index.html.erb +39 -0
- data/app/views/cache_bro/audit_events/show.html.erb +48 -0
- data/app/views/cache_bro/flushes/new.html.erb +19 -0
- data/app/views/cache_bro/keys/index.html.erb +36 -0
- data/app/views/cache_bro/keys/show.html.erb +57 -0
- data/app/views/cache_bro/purges/new.html.erb +17 -0
- data/app/views/cache_bro/purges/preview.html.erb +37 -0
- data/app/views/cache_bro/shared/_store_tabs.html.erb +9 -0
- data/app/views/cache_bro/shared/forbidden.html.erb +3 -0
- data/app/views/cache_bro/shared/unsupported.html.erb +3 -0
- data/config/routes.rb +18 -0
- data/db/migrate/20261115130000_create_cache_bro_audit_events.rb +25 -0
- data/lib/cache_bro/audit.rb +25 -0
- data/lib/cache_bro/configuration.rb +113 -0
- data/lib/cache_bro/engine.rb +25 -0
- data/lib/cache_bro/errors.rb +29 -0
- data/lib/cache_bro/redis_store.rb +220 -0
- data/lib/cache_bro/version.rb +3 -0
- data/lib/cache_bro.rb +43 -0
- data/lib/generators/cache_bro/install/install_generator.rb +35 -0
- data/lib/generators/cache_bro/install/templates/cache_bro.rb +32 -0
- metadata +109 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<% bro_hub_crumb "Keys", @key.truncate(60) %>
|
|
2
|
+
<% content_for :bro_hub_crumb_actions do %>
|
|
3
|
+
<%= bro_hub_crumb_link "← All keys", root_path(store: cache_bro_store.name) %>
|
|
4
|
+
<% end %>
|
|
5
|
+
|
|
6
|
+
<%= bro_hub_hero eyebrow: "#{cache_bro_store.name} · #{cache_bro_ttl(@entry.ttl)} · #{cache_bro_size(@entry.size)}",
|
|
7
|
+
title: "Key", lead: @key %>
|
|
8
|
+
|
|
9
|
+
<div class="bh-split">
|
|
10
|
+
<section class="bh-split-main">
|
|
11
|
+
<div class="bh-block-head"><h2 class="bh-h2">Value</h2></div>
|
|
12
|
+
<% if @value %>
|
|
13
|
+
<% if @value.error %>
|
|
14
|
+
<div class="bh-alert bh-alert-error">This value could not be read: <%= @value.error %></div>
|
|
15
|
+
<% else %>
|
|
16
|
+
<p class="bh-meta"><%= @value.klass %><%= " · shown up to #{number_with_delimiter(CacheBro.config.value_preview_length)} characters" if @value.truncated %></p>
|
|
17
|
+
<pre class="bh-cmd-code" style="white-space:pre-wrap;overflow-wrap:anywhere"><%= @value.preview %></pre>
|
|
18
|
+
<% end %>
|
|
19
|
+
<p class="bh-hint">This reveal was recorded in the audit log.</p>
|
|
20
|
+
<% elsif cache_bro_allows?(:view_values) %>
|
|
21
|
+
<p class="bh-hint">Values can hold personal data. Revealing one is recorded: who, which key, when.</p>
|
|
22
|
+
<%= form_with url: reveal_key_path, method: :post do %>
|
|
23
|
+
<%= hidden_field_tag :store, cache_bro_store.name %>
|
|
24
|
+
<%= hidden_field_tag :key, @key %>
|
|
25
|
+
<%= cache_bro_reason_field(:reveal) %>
|
|
26
|
+
<%= submit_tag "Reveal value", name: nil, class: "bh-btn is-auto", style: "margin-top:14px" %>
|
|
27
|
+
<% end %>
|
|
28
|
+
<% else %>
|
|
29
|
+
<p class="bh-hint">You may see that this key exists, not what it holds.</p>
|
|
30
|
+
<% end %>
|
|
31
|
+
</section>
|
|
32
|
+
|
|
33
|
+
<div class="bh-split-aside">
|
|
34
|
+
<section class="bh-block">
|
|
35
|
+
<h2 class="bh-h2">Details</h2>
|
|
36
|
+
<dl class="bh-dl">
|
|
37
|
+
<dt>Store</dt><dd><%= cache_bro_store.name %></dd>
|
|
38
|
+
<dt>Key</dt><dd><%= @key %></dd>
|
|
39
|
+
<dt>Expires</dt><dd><%= cache_bro_ttl(@entry.ttl) %></dd>
|
|
40
|
+
<dt>Size</dt><dd><%= cache_bro_size(@entry.size) %> serialized</dd>
|
|
41
|
+
</dl>
|
|
42
|
+
</section>
|
|
43
|
+
|
|
44
|
+
<% if cache_bro_allows?(:delete) %>
|
|
45
|
+
<section class="bh-block">
|
|
46
|
+
<h2 class="bh-h2">Delete</h2>
|
|
47
|
+
<p class="bh-hint">The next request that needs it will rebuild it. Recorded in the audit log.</p>
|
|
48
|
+
<%= form_with url: key_path, method: :delete do %>
|
|
49
|
+
<%= hidden_field_tag :store, cache_bro_store.name %>
|
|
50
|
+
<%= hidden_field_tag :key, @key %>
|
|
51
|
+
<%= cache_bro_reason_field(:delete) %>
|
|
52
|
+
<%= submit_tag "Delete key", name: nil, class: "bh-btn is-auto", style: "margin-top:14px;background:var(--bh-red)" %>
|
|
53
|
+
<% end %>
|
|
54
|
+
</section>
|
|
55
|
+
<% end %>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<% bro_hub_crumb "Purge" %>
|
|
2
|
+
<%= render "cache_bro/shared/store_tabs", path: ->(name) { purge_path(store: name) } %>
|
|
3
|
+
|
|
4
|
+
<%= bro_hub_hero eyebrow: cache_bro_store.name, title: "Purge", tone: :red,
|
|
5
|
+
lead: "Delete every key that matches a pattern. You see how many match before anything is deleted, and the purge is recorded." %>
|
|
6
|
+
|
|
7
|
+
<% unless cache_bro_store.namespaced? %>
|
|
8
|
+
<div class="bh-alert bh-alert-error">This cache store has no namespace: a pattern can match keys that aren't the cache's. Check the preview carefully.</div>
|
|
9
|
+
<% end %>
|
|
10
|
+
|
|
11
|
+
<%= form_with url: purge_preview_path, method: :post, class: "bh-filters" do %>
|
|
12
|
+
<%= hidden_field_tag :store, cache_bro_store.name %>
|
|
13
|
+
<%= text_field_tag :pattern, nil, placeholder: "Pattern, e.g. posts/* or views/posts/*", class: "bh-input is-strong", "aria-label": "Pattern", required: true, autofocus: true, autocomplete: "off" %>
|
|
14
|
+
<%= cache_bro_reason_field(:purge) %>
|
|
15
|
+
<%= submit_tag "Preview", name: nil, class: "bh-chip" %>
|
|
16
|
+
<% end %>
|
|
17
|
+
<p class="bh-hint" style="padding:0 var(--bh-pad)">The pattern is used as typed: <code>posts/*</code> means keys starting with <code>posts/</code>. <code>*</code> is any run of characters, <code>?</code> is one.</p>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<% bro_hub_crumb "Purge", "Confirm" %>
|
|
2
|
+
<% content_for :bro_hub_crumb_actions do %>
|
|
3
|
+
<%= bro_hub_crumb_link "← Change pattern", purge_path(store: cache_bro_store.name) %>
|
|
4
|
+
<% end %>
|
|
5
|
+
|
|
6
|
+
<%= bro_hub_hero eyebrow: "#{cache_bro_store.name} · #{@pattern}",
|
|
7
|
+
title: "#{number_with_delimiter(@matches.count)}#{"+" if @matches.truncated}",
|
|
8
|
+
subtitle: @matches.count == 1 ? "key matches" : "keys match", tone: (@matches.count.zero? ? nil : :red),
|
|
9
|
+
lead: @matches.count.zero? ? "Nothing matches. Nothing to delete." : "Nothing has been deleted yet." %>
|
|
10
|
+
|
|
11
|
+
<% if @matches.count.positive? %>
|
|
12
|
+
<div class="bh-split">
|
|
13
|
+
<section class="bh-split-main">
|
|
14
|
+
<div class="bh-block-head"><h2 class="bh-h2">For example</h2></div>
|
|
15
|
+
<div class="bh-log">
|
|
16
|
+
<% @matches.sample.each do |key| %>
|
|
17
|
+
<div class="bh-cmd"><pre class="bh-cmd-code" style="white-space:pre-wrap;overflow-wrap:anywhere"><%= key %></pre></div>
|
|
18
|
+
<% end %>
|
|
19
|
+
</div>
|
|
20
|
+
<% if @matches.truncated %>
|
|
21
|
+
<p class="bh-hint is-error">More than <%= number_with_delimiter(CacheBro.config.max_purge_keys) %> keys match. One purge deletes at most that many, so run it again for the rest.</p>
|
|
22
|
+
<% end %>
|
|
23
|
+
</section>
|
|
24
|
+
<div class="bh-split-aside">
|
|
25
|
+
<section class="bh-block">
|
|
26
|
+
<h2 class="bh-h2">Delete them</h2>
|
|
27
|
+
<p class="bh-hint">The count can change by the time you confirm. What is deleted is recorded, with this pattern<%= " and your reason" if @reason %>.</p>
|
|
28
|
+
<%= form_with url: purge_path, method: :post do %>
|
|
29
|
+
<%= hidden_field_tag :store, cache_bro_store.name %>
|
|
30
|
+
<%= hidden_field_tag :pattern, @pattern %>
|
|
31
|
+
<%= hidden_field_tag :reason, @reason %>
|
|
32
|
+
<%= submit_tag "Purge #{cache_bro_count(@matches.count, "key")}#{"+" if @matches.truncated}", name: nil, class: "bh-btn is-auto", style: "margin-top:14px;background:var(--bh-red)" %>
|
|
33
|
+
<% end %>
|
|
34
|
+
</section>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
<% end %>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<%# Tabs on the right of the breadcrumb bar when there is more than one cache store. `path` builds the link for a store name. %>
|
|
2
|
+
<% if CacheBro.config.store_names.size > 1 %>
|
|
3
|
+
<% current = params[:store].presence || CacheBro.config.store_names.first %>
|
|
4
|
+
<% content_for :bro_hub_crumb_actions do %>
|
|
5
|
+
<% CacheBro.config.store_names.each do |name| %>
|
|
6
|
+
<%= bro_hub_crumb_link name, path.call(name), active: name == current %>
|
|
7
|
+
<% end %>
|
|
8
|
+
<% end %>
|
|
9
|
+
<% end %>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
CacheBro::Engine.routes.draw do
|
|
2
|
+
root "keys#index"
|
|
3
|
+
|
|
4
|
+
# Keys can contain slashes, so they travel as a parameter, not in the path.
|
|
5
|
+
get "key" => "keys#show", as: :key
|
|
6
|
+
post "key/reveal" => "keys#reveal", as: :reveal_key
|
|
7
|
+
delete "key" => "keys#destroy"
|
|
8
|
+
|
|
9
|
+
get "purge" => "purges#new", as: :purge
|
|
10
|
+
post "purge/preview" => "purges#preview", as: :purge_preview
|
|
11
|
+
post "purge" => "purges#create"
|
|
12
|
+
|
|
13
|
+
get "flush" => "flushes#new", as: :flush
|
|
14
|
+
post "flush" => "flushes#create"
|
|
15
|
+
|
|
16
|
+
get "audit" => "audit_events#index", as: :audit
|
|
17
|
+
get "audit/:id" => "audit_events#show", as: :audit_event
|
|
18
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
class CreateCacheBroAuditEvents < ActiveRecord::Migration[7.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :cache_bro_audit_events do |t|
|
|
4
|
+
t.string :event, null: false
|
|
5
|
+
t.string :user_label
|
|
6
|
+
t.string :environment, null: false
|
|
7
|
+
t.string :store, null: false
|
|
8
|
+
|
|
9
|
+
# The key (reveal, delete) or the pattern (purge). Empty for a flush.
|
|
10
|
+
t.text :target
|
|
11
|
+
# How many keys the action touched.
|
|
12
|
+
t.integer :count
|
|
13
|
+
t.text :reason
|
|
14
|
+
|
|
15
|
+
# No column default: MySQL rejects defaults on JSON columns. Audit.record always sets it.
|
|
16
|
+
t.json :details, null: false
|
|
17
|
+
|
|
18
|
+
t.datetime :created_at, null: false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
add_index :cache_bro_audit_events, :created_at
|
|
22
|
+
add_index :cache_bro_audit_events, [ :event, :created_at ]
|
|
23
|
+
add_index :cache_bro_audit_events, [ :user_label, :created_at ]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module CacheBro
|
|
2
|
+
# Records who did what in CacheBro::AuditEvent. Keys and patterns are recorded, never values.
|
|
3
|
+
module Audit
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
# target: the key, or the pattern. count: how many keys the action touched.
|
|
7
|
+
def record(event, user:, store:, target: nil, count: nil, reason: nil, **details)
|
|
8
|
+
attributes = {
|
|
9
|
+
event: event.to_s,
|
|
10
|
+
user_label: user.is_a?(String) ? user : CacheBro.config.user_label.call(user).to_s,
|
|
11
|
+
environment: Rails.env.to_s,
|
|
12
|
+
store: store.to_s,
|
|
13
|
+
target: target,
|
|
14
|
+
count: count,
|
|
15
|
+
reason: reason.presence,
|
|
16
|
+
details: details.deep_stringify_keys
|
|
17
|
+
}
|
|
18
|
+
CacheBro::AuditEvent.create!(**attributes)
|
|
19
|
+
rescue StandardError => error
|
|
20
|
+
# The action already happened (or, for a reveal, is about to be refused): at least leave a trace in the log.
|
|
21
|
+
Rails.logger&.error("CacheBro: could not write the audit log (#{error.class}: #{error.message}). Entry: #{attributes.inspect}")
|
|
22
|
+
raise
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
module CacheBro
|
|
2
|
+
class Configuration
|
|
3
|
+
# --- Who may do what ------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
# Permission checks, each ->(user) { true/false }. Everything is denied until you change them.
|
|
6
|
+
# can_view_keys - open CacheBro, search keys, see TTL and size
|
|
7
|
+
# can_view_values - reveal what a key holds (every reveal is audited)
|
|
8
|
+
# can_delete - delete a single key
|
|
9
|
+
# can_purge - delete every key matching a pattern (includes delete)
|
|
10
|
+
# can_flush - delete everything in a store's namespace (includes purge and delete)
|
|
11
|
+
# can_audit - read the audit log
|
|
12
|
+
#
|
|
13
|
+
# Higher permissions include the lower ones, so you don't have to repeat yourself.
|
|
14
|
+
# Revealing values and auditing are separate on purpose: values can hold personal data.
|
|
15
|
+
attr_accessor :can_view_keys, :can_view_values, :can_delete, :can_purge, :can_flush, :can_audit
|
|
16
|
+
|
|
17
|
+
PERMISSIONS = %i[ view_keys view_values delete purge flush audit ].freeze
|
|
18
|
+
|
|
19
|
+
# Permissions that also grant the key.
|
|
20
|
+
IMPLIED_BY = {
|
|
21
|
+
view_keys: %i[ view_values delete purge flush ],
|
|
22
|
+
view_values: [],
|
|
23
|
+
delete: %i[ purge flush ],
|
|
24
|
+
purge: %i[ flush ],
|
|
25
|
+
flush: [],
|
|
26
|
+
audit: []
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# Actions that can require a reason.
|
|
30
|
+
ACTIONS = %i[ reveal delete purge flush ].freeze
|
|
31
|
+
|
|
32
|
+
# --- Which caches ---------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
# { "Name" => -> { store } }. The name appears in the UI and the audit log. Stores that aren't a
|
|
35
|
+
# Rails :redis_cache_store are listed as unsupported.
|
|
36
|
+
attr_accessor :stores
|
|
37
|
+
|
|
38
|
+
# --- Limits ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
# SCAN COUNT hint per Redis call, and how many calls one page of search results may spend before it
|
|
41
|
+
# hands you a cursor to keep going. Redis scans the whole keyspace either way, in small steps.
|
|
42
|
+
attr_accessor :scan_count, :scan_budget
|
|
43
|
+
|
|
44
|
+
# A page of keys is "at least this many" (a SCAN step can return a few more).
|
|
45
|
+
attr_accessor :page_size
|
|
46
|
+
|
|
47
|
+
# A single purge deletes at most this many keys, then says so and stops. Run it again to continue.
|
|
48
|
+
attr_accessor :max_purge_keys
|
|
49
|
+
|
|
50
|
+
# Characters of a value shown after revealing it.
|
|
51
|
+
attr_accessor :value_preview_length
|
|
52
|
+
|
|
53
|
+
# How many affected keys the audit log keeps as an example for a purge or flush.
|
|
54
|
+
attr_accessor :audit_sample_size
|
|
55
|
+
|
|
56
|
+
# Which actions need a reason typed in (stored in the audit log): any of :reveal, :delete, :purge, :flush.
|
|
57
|
+
attr_accessor :reason_required_for
|
|
58
|
+
|
|
59
|
+
# --- Hosting --------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
# Controller the engine controllers inherit from (gives access to your authentication).
|
|
62
|
+
attr_accessor :parent_controller
|
|
63
|
+
|
|
64
|
+
# ->(controller) { user or nil }. Defaults to BroHub.config.current_user, so configuring BroHub once is enough.
|
|
65
|
+
attr_accessor :current_user
|
|
66
|
+
|
|
67
|
+
# ->(user) { "label" } shown in the UI and stored in the audit log. Defaults to BroHub.config.user_label.
|
|
68
|
+
attr_accessor :user_label
|
|
69
|
+
|
|
70
|
+
# Refuse plain HTTP requests.
|
|
71
|
+
attr_accessor :require_ssl
|
|
72
|
+
|
|
73
|
+
def initialize
|
|
74
|
+
@can_view_keys = ->(_user) { false }
|
|
75
|
+
@can_view_values = ->(_user) { false }
|
|
76
|
+
@can_delete = ->(_user) { false }
|
|
77
|
+
@can_purge = ->(_user) { false }
|
|
78
|
+
@can_flush = ->(_user) { false }
|
|
79
|
+
@can_audit = ->(_user) { false }
|
|
80
|
+
|
|
81
|
+
@stores = { "Rails.cache" => -> { Rails.cache } }
|
|
82
|
+
|
|
83
|
+
@scan_count = 500
|
|
84
|
+
@scan_budget = 200
|
|
85
|
+
@page_size = 100
|
|
86
|
+
@max_purge_keys = 100_000
|
|
87
|
+
@value_preview_length = 5_000
|
|
88
|
+
@audit_sample_size = 25
|
|
89
|
+
@reason_required_for = %i[ purge flush ]
|
|
90
|
+
|
|
91
|
+
@parent_controller = "::ApplicationController"
|
|
92
|
+
@current_user = ->(controller) { BroHub.config.current_user.call(controller) }
|
|
93
|
+
@user_label = ->(user) { BroHub.config.user_label.call(user) }
|
|
94
|
+
@require_ssl = defined?(Rails) && Rails.respond_to?(:env) ? Rails.env.production? : false
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Does the user hold this permission, directly or through a higher one?
|
|
98
|
+
def allows?(user, permission)
|
|
99
|
+
return false if user.nil?
|
|
100
|
+
raise ArgumentError, "Unknown permission #{permission.inspect}" unless PERMISSIONS.include?(permission)
|
|
101
|
+
|
|
102
|
+
[ permission, *IMPLIED_BY.fetch(permission) ].any? { |name| !!public_send(:"can_#{name}").call(user) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def reason_required?(action)
|
|
106
|
+
Array(reason_required_for).map(&:to_sym).include?(action.to_sym)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def store_names
|
|
110
|
+
stores.keys.map(&:to_s)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module CacheBro
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace CacheBro
|
|
4
|
+
|
|
5
|
+
# Cache values and the reason people type are never worth logging.
|
|
6
|
+
initializer "cache_bro.filter_parameters" do |app|
|
|
7
|
+
app.config.filter_parameters += %i[ reason ]
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# The UI is a section of the BroHub dashboard. Registered before routes are drawn, so BroHub can mount this
|
|
11
|
+
# engine under <hub>/cache_bro. Showing a section is navigation only: every controller authorizes each request.
|
|
12
|
+
initializer "cache_bro.bro_hub" do
|
|
13
|
+
BroHub.register :cache_bro,
|
|
14
|
+
name: "CacheBro", label: "CacheBro · cache", accent: :blue, order: 30,
|
|
15
|
+
engine: "CacheBro::Engine",
|
|
16
|
+
visible_to: ->(user) { CacheBro.config.allows?(user, :view_keys) || CacheBro.config.allows?(user, :audit) },
|
|
17
|
+
items: [
|
|
18
|
+
{ label: "Keys", path: "/", match: %r{\A/(key(/|\z)|\z)}, visible_to: ->(user) { CacheBro.config.allows?(user, :view_keys) } },
|
|
19
|
+
{ label: "Purge", path: "/purge", match: %r{\A/purge}, visible_to: ->(user) { CacheBro.config.allows?(user, :purge) } },
|
|
20
|
+
{ label: "Flush", path: "/flush", visible_to: ->(user) { CacheBro.config.allows?(user, :flush) } },
|
|
21
|
+
{ label: "Audit log", path: "/audit", match: %r{\A/audit}, visible_to: ->(user) { CacheBro.config.allows?(user, :audit) } }
|
|
22
|
+
]
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module CacheBro
|
|
2
|
+
class Error < StandardError; end
|
|
3
|
+
|
|
4
|
+
# The person lacks a permission.
|
|
5
|
+
class NotAllowed < Error; end
|
|
6
|
+
|
|
7
|
+
# config.stores has no store with that name.
|
|
8
|
+
class UnknownStore < Error; end
|
|
9
|
+
|
|
10
|
+
# The store isn't one CacheBro can manage (only Redis for now), or is set up in a way it can't handle.
|
|
11
|
+
class UnsupportedStore < Error; end
|
|
12
|
+
|
|
13
|
+
# Destructive actions need a reason (see config.reason_required_for).
|
|
14
|
+
class ReasonRequired < Error; end
|
|
15
|
+
|
|
16
|
+
# A pattern the action refuses (empty, or everything).
|
|
17
|
+
class InvalidPattern < Error; end
|
|
18
|
+
|
|
19
|
+
# A purge or flush failed part way (Redis went away, say). `matches` is what was deleted before that,
|
|
20
|
+
# so the audit log can still say so. The original error is `cause`.
|
|
21
|
+
class SweepFailed < Error
|
|
22
|
+
attr_reader :matches
|
|
23
|
+
|
|
24
|
+
def initialize(message, matches)
|
|
25
|
+
super(message)
|
|
26
|
+
@matches = matches
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
module CacheBro
|
|
2
|
+
# Everything CacheBro does to one Rails :redis_cache_store.
|
|
3
|
+
#
|
|
4
|
+
# Keys are shown without the store's namespace, and every key the person types is put back under it, so
|
|
5
|
+
# nothing outside the namespace can be reached. Redis is only ever walked with SCAN (never KEYS), in small steps.
|
|
6
|
+
class RedisStore
|
|
7
|
+
Entry = Struct.new(:key, :ttl, :size, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
# One page of search results. `cursor` is where to continue (nil when the whole keyspace was covered).
|
|
10
|
+
Listing = Struct.new(:entries, :cursor, keyword_init: true) do
|
|
11
|
+
def more? = !cursor.nil?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# What a pattern matches (or, after a purge, what it deleted). `truncated`: there are more than the limit.
|
|
15
|
+
Matches = Struct.new(:count, :sample, :truncated, keyword_init: true)
|
|
16
|
+
|
|
17
|
+
# A revealed value: what it is, and as much of it as is safe to render. `error` when it couldn't be read.
|
|
18
|
+
Value = Struct.new(:klass, :preview, :truncated, :error, keyword_init: true)
|
|
19
|
+
|
|
20
|
+
REDIS_CACHE_STORE = "ActiveSupport::Cache::RedisCacheStore".freeze
|
|
21
|
+
|
|
22
|
+
# Looked up by name so nothing is loaded (and no redis gem is required) for apps on another store.
|
|
23
|
+
def self.supports?(cache)
|
|
24
|
+
cache.class.ancestors.any? { |klass| klass.name == REDIS_CACHE_STORE }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
attr_reader :name
|
|
28
|
+
|
|
29
|
+
def initialize(name, cache)
|
|
30
|
+
@name = name.to_s
|
|
31
|
+
@cache = cache
|
|
32
|
+
unless self.class.supports?(cache)
|
|
33
|
+
raise UnsupportedStore, "#{@name} is a #{cache.class.name}. CacheBro manages Redis cache stores (:redis_cache_store) for now."
|
|
34
|
+
end
|
|
35
|
+
raise UnsupportedStore, "#{@name} spreads keys over several Redis servers. CacheBro needs a single one." if distributed?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def namespace
|
|
39
|
+
value = @cache.options[:namespace]
|
|
40
|
+
value = value.call if value.respond_to?(:call)
|
|
41
|
+
value.to_s.presence
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def namespaced?
|
|
45
|
+
!namespace.nil?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# --- Reading --------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
# Searches keys. Plain text matches anywhere in the key, `*` and `?` make it a pattern. One call walks part of
|
|
51
|
+
# the keyspace: pass the returned cursor back in to continue.
|
|
52
|
+
def list(query: nil, cursor: "0")
|
|
53
|
+
config = CacheBro.config
|
|
54
|
+
cursor = cursor.to_s
|
|
55
|
+
raws = []
|
|
56
|
+
calls = 0
|
|
57
|
+
|
|
58
|
+
entries = with_redis do |redis|
|
|
59
|
+
loop do
|
|
60
|
+
cursor, batch = redis.scan(cursor, match: search_match(query), count: config.scan_count)
|
|
61
|
+
raws.concat(batch)
|
|
62
|
+
calls += 1
|
|
63
|
+
break if cursor == "0" || raws.size >= config.page_size || calls >= config.scan_budget
|
|
64
|
+
end
|
|
65
|
+
describe(redis, raws)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
Listing.new(entries: entries, cursor: (cursor == "0" ? nil : cursor))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# TTL and size of one key (nil when it doesn't exist).
|
|
72
|
+
def stat(key)
|
|
73
|
+
raw = prefix + key
|
|
74
|
+
ttl, size = with_redis { |redis| stats_for(redis, [ raw ]).first }
|
|
75
|
+
build_entry(key, ttl, size)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# What the key holds, for display. Never raises for a value that can't be read: that is shown instead.
|
|
79
|
+
def read(key)
|
|
80
|
+
value = @cache.read(key)
|
|
81
|
+
text = value.inspect
|
|
82
|
+
limit = CacheBro.config.value_preview_length
|
|
83
|
+
Value.new(klass: value.class.name, preview: text.first(limit), truncated: text.length > limit)
|
|
84
|
+
rescue StandardError => error
|
|
85
|
+
Value.new(klass: nil, preview: nil, truncated: false, error: "#{error.class}: #{error.message.first(300)}")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# --- Deleting -------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def delete(key)
|
|
91
|
+
with_redis { |redis| redis.unlink(prefix + key) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# How many keys the pattern matches, up to `limit`.
|
|
95
|
+
def preview(pattern)
|
|
96
|
+
sweep(purge_match(pattern), limit: CacheBro.config.max_purge_keys, delete: false)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Deletes the keys matching the pattern, at most config.max_purge_keys of them.
|
|
100
|
+
def purge(pattern)
|
|
101
|
+
sweep(purge_match(pattern), limit: CacheBro.config.max_purge_keys, delete: true)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Deletes everything under the namespace. Refused without one: that would be the whole Redis database.
|
|
105
|
+
def flush
|
|
106
|
+
raise UnsupportedStore, "#{name} has no namespace, so flushing it would touch keys that aren't the cache's. Set `namespace:` on the cache store." unless namespaced?
|
|
107
|
+
|
|
108
|
+
sweep("#{glob_escape(prefix)}*", limit: nil, delete: true)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
def with_redis(&)
|
|
113
|
+
@cache.redis.then(&)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def distributed?
|
|
117
|
+
with_redis { |redis| redis.respond_to?(:nodes) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def prefix
|
|
121
|
+
namespaced? ? "#{namespace}:" : ""
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Redis glob: * ? [ ] and \ are special. Everything but the wildcards is matched literally.
|
|
125
|
+
def glob_escape(text)
|
|
126
|
+
text.gsub(/[\\*?\[\]]/) { |char| "\\#{char}" }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Only * and ? are wildcards for people. [ ] and \ stay literal, so a key like "a[1]" can be found.
|
|
130
|
+
def with_wildcards(text)
|
|
131
|
+
text.gsub(/[\\\[\]]/) { |char| "\\#{char}" }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def pattern?(text)
|
|
135
|
+
text.match?(/[*?]/)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def search_match(query)
|
|
139
|
+
text = query.to_s.strip
|
|
140
|
+
return "#{glob_escape(prefix)}*" if text.empty?
|
|
141
|
+
|
|
142
|
+
pattern?(text) ? "#{glob_escape(prefix)}#{with_wildcards(text)}" : "#{glob_escape(prefix)}*#{glob_escape(text)}*"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Purge patterns are used as typed (so "posts/*" is a prefix), never widened.
|
|
146
|
+
def purge_match(pattern)
|
|
147
|
+
text = pattern.to_s.strip
|
|
148
|
+
raise InvalidPattern, "Give a pattern, like posts/*" if text.empty?
|
|
149
|
+
raise InvalidPattern, "That matches everything. Use Flush for that." if text.match?(/\A\*+\z/)
|
|
150
|
+
|
|
151
|
+
"#{glob_escape(prefix)}#{with_wildcards(text)}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The key as people see it: valid text, without the namespace. Nil for keys that aren't text.
|
|
155
|
+
def display_key(raw)
|
|
156
|
+
key = raw.dup.force_encoding(Encoding::UTF_8)
|
|
157
|
+
key.delete_prefix(prefix) if key.valid_encoding?
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def describe(redis, raws)
|
|
161
|
+
raws = raws.uniq.select { |raw| display_key(raw) }
|
|
162
|
+
return [] if raws.empty?
|
|
163
|
+
|
|
164
|
+
raws.zip(stats_for(redis, raws)).filter_map { |raw, (ttl, size)| build_entry(display_key(raw), ttl, size) }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def build_entry(key, pttl, size)
|
|
168
|
+
return if pttl.nil? || pttl == -2 # gone since it was found, or not a cache entry
|
|
169
|
+
|
|
170
|
+
Entry.new(key: key, ttl: (pttl / 1000.0 if pttl >= 0), size: size)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# [ [ pttl, bytes ], ... ] in one round trip. A key holding something other than a string (a shared Redis
|
|
174
|
+
# database) makes the pipeline fail, so fall back to asking one by one and leaving those out.
|
|
175
|
+
def stats_for(redis, raws)
|
|
176
|
+
redis.pipelined { |pipeline| raws.each { |raw| pipeline.pttl(raw); pipeline.strlen(raw) } }.each_slice(2).to_a
|
|
177
|
+
rescue ::Redis::CommandError
|
|
178
|
+
raws.map { |raw| redis.type(raw) == "string" ? [ redis.pttl(raw), redis.strlen(raw) ] : [ nil, nil ] }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Walks the keyspace for `match`, optionally deleting what it finds, stopping at `limit` keys.
|
|
182
|
+
# A failure part way raises SweepFailed with what was done so far.
|
|
183
|
+
def sweep(match, limit:, delete:)
|
|
184
|
+
count = 0
|
|
185
|
+
sample = []
|
|
186
|
+
truncated = false
|
|
187
|
+
sample_size = CacheBro.config.audit_sample_size
|
|
188
|
+
|
|
189
|
+
catch(:done) do
|
|
190
|
+
with_redis do |redis|
|
|
191
|
+
cursor = "0"
|
|
192
|
+
loop do
|
|
193
|
+
cursor, batch = redis.scan(cursor, match: match, count: CacheBro.config.scan_count)
|
|
194
|
+
batch = batch.uniq
|
|
195
|
+
unless batch.empty?
|
|
196
|
+
room = limit ? limit - count : batch.size
|
|
197
|
+
if room <= 0
|
|
198
|
+
truncated = true
|
|
199
|
+
throw :done
|
|
200
|
+
end
|
|
201
|
+
taken = batch.first(room)
|
|
202
|
+
truncated = taken.size < batch.size
|
|
203
|
+
redis.unlink(*taken) if delete
|
|
204
|
+
count += taken.size
|
|
205
|
+
sample.concat(taken.filter_map { |raw| display_key(raw) }.first(sample_size - sample.size)) if sample.size < sample_size
|
|
206
|
+
throw :done if truncated
|
|
207
|
+
end
|
|
208
|
+
break if cursor == "0"
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
Matches.new(count: count, sample: sample, truncated: truncated)
|
|
214
|
+
rescue StandardError => error
|
|
215
|
+
raise error if error.is_a?(CacheBro::Error)
|
|
216
|
+
|
|
217
|
+
raise SweepFailed.new("#{error.class}: #{error.message}", Matches.new(count: count, sample: sample, truncated: truncated))
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
data/lib/cache_bro.rb
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
require "bro_hub"
|
|
2
|
+
require "cache_bro/version"
|
|
3
|
+
require "cache_bro/errors"
|
|
4
|
+
require "cache_bro/configuration"
|
|
5
|
+
require "cache_bro/redis_store"
|
|
6
|
+
require "cache_bro/audit"
|
|
7
|
+
require "cache_bro/engine"
|
|
8
|
+
|
|
9
|
+
# = CacheBro
|
|
10
|
+
#
|
|
11
|
+
# Browse, inspect and purge a Rails Redis cache from the BroHub dashboard.
|
|
12
|
+
#
|
|
13
|
+
# * Search keys (TTL, size), reveal a value, delete a key, purge a pattern, flush a namespace.
|
|
14
|
+
# * Permissions are lambdas, like the other Bro gems. Revealing values is its own permission.
|
|
15
|
+
# * Everything that reveals or deletes is recorded, append-only, with who, what, how many and (optionally) why.
|
|
16
|
+
#
|
|
17
|
+
# This is a convenience for people who already have a console, not a sandbox: it makes the same actions easier
|
|
18
|
+
# to do and impossible to do unrecorded through the UI.
|
|
19
|
+
module CacheBro
|
|
20
|
+
class << self
|
|
21
|
+
def config
|
|
22
|
+
@config ||= Configuration.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def configure
|
|
26
|
+
yield config
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def reset_config!
|
|
30
|
+
@config = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The store with that name (the first one when nil), wrapped for CacheBro.
|
|
34
|
+
# Raises UnknownStore, or UnsupportedStore for a cache that isn't a Redis cache store.
|
|
35
|
+
def store(name = nil)
|
|
36
|
+
name = name.presence&.to_s || config.store_names.first
|
|
37
|
+
source = config.stores.find { |key, _| key.to_s == name }&.last
|
|
38
|
+
raise UnknownStore, "No cache store named #{name.inspect}" unless source
|
|
39
|
+
|
|
40
|
+
RedisStore.new(name, source.respond_to?(:call) ? source.call : source)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|