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.
Files changed (34) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +114 -0
  4. data/Rakefile +3 -0
  5. data/app/controllers/cache_bro/application_controller.rb +91 -0
  6. data/app/controllers/cache_bro/audit_events_controller.rb +38 -0
  7. data/app/controllers/cache_bro/flushes_controller.rb +32 -0
  8. data/app/controllers/cache_bro/keys_controller.rb +39 -0
  9. data/app/controllers/cache_bro/purges_controller.rb +38 -0
  10. data/app/helpers/cache_bro/application_helper.rb +63 -0
  11. data/app/models/cache_bro/application_record.rb +6 -0
  12. data/app/models/cache_bro/audit_event.rb +25 -0
  13. data/app/views/cache_bro/audit_events/index.html.erb +39 -0
  14. data/app/views/cache_bro/audit_events/show.html.erb +48 -0
  15. data/app/views/cache_bro/flushes/new.html.erb +19 -0
  16. data/app/views/cache_bro/keys/index.html.erb +36 -0
  17. data/app/views/cache_bro/keys/show.html.erb +57 -0
  18. data/app/views/cache_bro/purges/new.html.erb +17 -0
  19. data/app/views/cache_bro/purges/preview.html.erb +37 -0
  20. data/app/views/cache_bro/shared/_store_tabs.html.erb +9 -0
  21. data/app/views/cache_bro/shared/forbidden.html.erb +3 -0
  22. data/app/views/cache_bro/shared/unsupported.html.erb +3 -0
  23. data/config/routes.rb +18 -0
  24. data/db/migrate/20261115130000_create_cache_bro_audit_events.rb +25 -0
  25. data/lib/cache_bro/audit.rb +25 -0
  26. data/lib/cache_bro/configuration.rb +113 -0
  27. data/lib/cache_bro/engine.rb +25 -0
  28. data/lib/cache_bro/errors.rb +29 -0
  29. data/lib/cache_bro/redis_store.rb +220 -0
  30. data/lib/cache_bro/version.rb +3 -0
  31. data/lib/cache_bro.rb +43 -0
  32. data/lib/generators/cache_bro/install/install_generator.rb +35 -0
  33. data/lib/generators/cache_bro/install/templates/cache_bro.rb +32 -0
  34. 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 %>
@@ -0,0 +1,3 @@
1
+ <% bro_hub_crumb "Not allowed" %>
2
+ <%= bro_hub_hero eyebrow: "Cache", title: "Not allowed", tone: :red,
3
+ lead: "You don't have permission to do that. Ask an admin if you need access." %>
@@ -0,0 +1,3 @@
1
+ <% bro_hub_crumb "Unsupported store" %>
2
+ <%= render "cache_bro/shared/store_tabs", path: ->(name) { root_path(store: name) } %>
3
+ <%= bro_hub_hero eyebrow: "Cache", title: "Can't manage this store", tone: :amber, lead: @unsupported %>
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
@@ -0,0 +1,3 @@
1
+ module CacheBro
2
+ VERSION = "0.1.0"
3
+ 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