talk_to_your_app 0.1.0.pre.1
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/LICENSE +21 -0
- data/README.md +402 -0
- data/lib/generators/talk_to_your_app/custom_tool/custom_tool_generator.rb +39 -0
- data/lib/generators/talk_to_your_app/custom_tool/templates/tool.rb.tt +18 -0
- data/lib/generators/talk_to_your_app/health_check/health_check_generator.rb +31 -0
- data/lib/generators/talk_to_your_app/health_check/templates/check.rb.tt +12 -0
- data/lib/generators/talk_to_your_app/install/install_generator.rb +27 -0
- data/lib/generators/talk_to_your_app/install/templates/initializer.rb.tt +78 -0
- data/lib/talk_to_your_app/audit_logger.rb +115 -0
- data/lib/talk_to_your_app/auth/api_key.rb +29 -0
- data/lib/talk_to_your_app/auth/basic.rb +24 -0
- data/lib/talk_to_your_app/auth/middleware.rb +74 -0
- data/lib/talk_to_your_app/configuration.rb +129 -0
- data/lib/talk_to_your_app/connection_registry.rb +131 -0
- data/lib/talk_to_your_app/current.rb +14 -0
- data/lib/talk_to_your_app/custom_tool.rb +40 -0
- data/lib/talk_to_your_app/plugin.rb +59 -0
- data/lib/talk_to_your_app/plugin_registry.rb +48 -0
- data/lib/talk_to_your_app/plugins/custom_tools/plugin.rb +26 -0
- data/lib/talk_to_your_app/plugins/db/plugin.rb +57 -0
- data/lib/talk_to_your_app/plugins/db/tools/query.rb +126 -0
- data/lib/talk_to_your_app/plugins/db/tools/schema.rb +60 -0
- data/lib/talk_to_your_app/plugins/db/tools/tables.rb +28 -0
- data/lib/talk_to_your_app/plugins/flipper/plugin.rb +132 -0
- data/lib/talk_to_your_app/plugins/flipper/tools/disable_flag.rb +41 -0
- data/lib/talk_to_your_app/plugins/flipper/tools/enable_flag.rb +42 -0
- data/lib/talk_to_your_app/plugins/flipper/tools/enabled_flags.rb +41 -0
- data/lib/talk_to_your_app/plugins/flipper/tools/list_flags.rb +23 -0
- data/lib/talk_to_your_app/plugins/flipper/tools/read_flag.rb +33 -0
- data/lib/talk_to_your_app/plugins/health/plugin.rb +31 -0
- data/lib/talk_to_your_app/plugins/health/registry.rb +68 -0
- data/lib/talk_to_your_app/plugins/health/tools/list_checks.rb +24 -0
- data/lib/talk_to_your_app/plugins/health/tools/run_check.rb +27 -0
- data/lib/talk_to_your_app/plugins/jobs/adapters/sidekiq.rb +122 -0
- data/lib/talk_to_your_app/plugins/jobs/adapters/solid_queue.rb +90 -0
- data/lib/talk_to_your_app/plugins/jobs/interface.rb +38 -0
- data/lib/talk_to_your_app/plugins/jobs/plugin.rb +87 -0
- data/lib/talk_to_your_app/plugins/jobs/tools/failed_jobs.rb +28 -0
- data/lib/talk_to_your_app/plugins/jobs/tools/health.rb +25 -0
- data/lib/talk_to_your_app/plugins/jobs/tools/queue_sizes.rb +23 -0
- data/lib/talk_to_your_app/plugins/jobs/tools/rate_metrics.rb +30 -0
- data/lib/talk_to_your_app/plugins/jobs/tools/recent_jobs.rb +28 -0
- data/lib/talk_to_your_app/plugins/rake/plugin.rb +42 -0
- data/lib/talk_to_your_app/plugins/rake/tools/run.rb +56 -0
- data/lib/talk_to_your_app/railtie.rb +56 -0
- data/lib/talk_to_your_app/renderers/html_table.rb +27 -0
- data/lib/talk_to_your_app/tool.rb +204 -0
- data/lib/talk_to_your_app/transport/rails_mount.rb +46 -0
- data/lib/talk_to_your_app/version.rb +5 -0
- data/lib/talk_to_your_app.rb +124 -0
- metadata +140 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../plugin"
|
|
4
|
+
require_relative "../../custom_tool"
|
|
5
|
+
|
|
6
|
+
module TalkToYourApp
|
|
7
|
+
module Plugins
|
|
8
|
+
module CustomTools
|
|
9
|
+
# Exposes every TalkToYourApp::CustomTool subclass over MCP. Unlike the
|
|
10
|
+
# bundled plugins, its tool list is dynamic — whatever subclasses of
|
|
11
|
+
# CustomTool the host app has defined. Tools generated by
|
|
12
|
+
# `rails g talk_to_your_app:custom_tool` live in
|
|
13
|
+
# app/talk_to_your_app/custom_tools/ and are loaded automatically here.
|
|
14
|
+
class Plugin < TalkToYourApp::Plugin
|
|
15
|
+
def self.tools
|
|
16
|
+
TalkToYourApp.require_app_dir("custom_tools")
|
|
17
|
+
# Dedup by MCP name (last wins) in case two files declare the same
|
|
18
|
+
# tool_name, or a class is registered more than once in a process.
|
|
19
|
+
TalkToYourApp::CustomTool.registry.each_with_object({}) { |t, acc| acc[t.tool_name] = t }.values
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
TalkToYourApp.register_plugin(:custom_tools, TalkToYourApp::Plugins::CustomTools::Plugin)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../plugin"
|
|
4
|
+
require_relative "tools/query"
|
|
5
|
+
require_relative "tools/tables"
|
|
6
|
+
require_relative "tools/schema"
|
|
7
|
+
|
|
8
|
+
module TalkToYourApp
|
|
9
|
+
module Plugins
|
|
10
|
+
module Db
|
|
11
|
+
# Default cap on rows returned by db.query. Override per app with
|
|
12
|
+
# `config.plugin :db, max_rows: 5000`, or disable the cap entirely with
|
|
13
|
+
# `max_rows: nil` (also accepts false or :unlimited).
|
|
14
|
+
DEFAULT_MAX_ROWS = 2000
|
|
15
|
+
UNLIMITED = [nil, false, :unlimited].freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Returns the row cap as an Integer, or nil for "no cap".
|
|
20
|
+
def max_rows
|
|
21
|
+
options = TalkToYourApp.configuration.enabled_plugins[:db] || {}
|
|
22
|
+
return DEFAULT_MAX_ROWS unless options.key?(:max_rows)
|
|
23
|
+
|
|
24
|
+
value = options[:max_rows]
|
|
25
|
+
return nil if UNLIMITED.include?(value)
|
|
26
|
+
|
|
27
|
+
Integer(value)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The DB plugin: read-only SQL plus schema introspection, all against a
|
|
31
|
+
# separately-configured read-only connection. Requires the operator to
|
|
32
|
+
# declare a :replica_readonly connection; boot fails otherwise.
|
|
33
|
+
class Plugin < TalkToYourApp::Plugin
|
|
34
|
+
requires_connection :replica_readonly
|
|
35
|
+
tools Tools::Query, Tools::Tables, Tools::Schema
|
|
36
|
+
|
|
37
|
+
# The read-only guarantee rests on the connection being declared with
|
|
38
|
+
# role: :reading (which enables Rails' write prevention). Enforce it at
|
|
39
|
+
# boot so a misconfigured :writing role can't silently expose a writable
|
|
40
|
+
# "read-only" SQL tool. (A missing connection is caught by the
|
|
41
|
+
# ConnectionRegistry validation that runs alongside this.)
|
|
42
|
+
def self.validate_enablement!(_options)
|
|
43
|
+
return unless TalkToYourApp::ConnectionRegistry.registered?(:replica_readonly)
|
|
44
|
+
|
|
45
|
+
spec = TalkToYourApp::ConnectionRegistry.fetch(:replica_readonly)
|
|
46
|
+
return if spec.reading?
|
|
47
|
+
|
|
48
|
+
raise TalkToYourApp::ConfigurationError,
|
|
49
|
+
"talk_to_your_app: the DB plugin's :replica_readonly connection must be declared with " \
|
|
50
|
+
"role: :reading (got role: #{spec.role.inspect}). A read-only tool must not run on a writable connection."
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
TalkToYourApp.register_plugin(:db, TalkToYourApp::Plugins::Db::Plugin)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../../tool"
|
|
4
|
+
require_relative "../../../connection_registry"
|
|
5
|
+
require_relative "../../../renderers/html_table"
|
|
6
|
+
|
|
7
|
+
module TalkToYourApp
|
|
8
|
+
module Plugins
|
|
9
|
+
module Db
|
|
10
|
+
module Tools
|
|
11
|
+
# Runs a read-only SQL query on the declared :replica_readonly connection
|
|
12
|
+
# and renders the rows as JSON, plain text, or an HTML table. The query
|
|
13
|
+
# runs inside a transaction with a per-query statement timeout, so a slow
|
|
14
|
+
# query cannot poison the connection pool. Writes are rejected by the
|
|
15
|
+
# underlying read-only database role — the gem does not parse SQL.
|
|
16
|
+
class Query < TalkToYourApp::Tool
|
|
17
|
+
DEFAULT_TIMEOUT_MS = 30_000
|
|
18
|
+
CONNECTION = :replica_readonly
|
|
19
|
+
|
|
20
|
+
name "db.query"
|
|
21
|
+
description "Run a read-only SQL query and return the rows."
|
|
22
|
+
connection CONNECTION
|
|
23
|
+
argument :sql, :string, required: true, description: "A read-only SQL statement."
|
|
24
|
+
argument :format, :string, enum: %w[json text html], default: "json",
|
|
25
|
+
description: "Output format for the result rows."
|
|
26
|
+
|
|
27
|
+
def call(args, ctx)
|
|
28
|
+
format = args[:format] || "json"
|
|
29
|
+
result = ctx.connection do |conn|
|
|
30
|
+
conn.transaction do
|
|
31
|
+
apply_statement_timeout(conn, timeout_ms)
|
|
32
|
+
begin
|
|
33
|
+
conn.exec_query(args[:sql])
|
|
34
|
+
ensure
|
|
35
|
+
clear_statement_timeout(conn)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
render(result, format)
|
|
40
|
+
rescue ActiveRecord::QueryCanceled, ActiveRecord::StatementTimeout => e
|
|
41
|
+
error("Query exceeded the #{timeout_ms}ms statement timeout: #{e.message}")
|
|
42
|
+
rescue ActiveRecord::ReadOnlyError => e
|
|
43
|
+
# Rails' connected_to(role: :reading) blocks writes before they reach
|
|
44
|
+
# the database; the read-only DB role is the backstop behind it.
|
|
45
|
+
error("Write rejected: this connection is read-only. #{e.message}")
|
|
46
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
47
|
+
error("Query failed: #{e.message}")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def timeout_ms
|
|
53
|
+
spec = TalkToYourApp::ConnectionRegistry.fetch(CONNECTION)
|
|
54
|
+
spec.statement_timeout || DEFAULT_TIMEOUT_MS
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The SQL that arms a per-query timeout for an adapter, or nil when the
|
|
58
|
+
# adapter has no per-statement timeout. Pure (no connection) so it is
|
|
59
|
+
# unit-testable without each database installed.
|
|
60
|
+
# Postgres -> transaction-local `statement_timeout` (unwound with the txn).
|
|
61
|
+
# MySQL -> session `max_execution_time` (ms), bounding read-only SELECTs.
|
|
62
|
+
# SQLite / MariaDB / others -> none (documented in the README).
|
|
63
|
+
def self.timeout_statement(adapter_name, ms)
|
|
64
|
+
case adapter_name
|
|
65
|
+
when /postgres/i then "SET LOCAL statement_timeout = #{ms.to_i}"
|
|
66
|
+
when /mysql|trilogy/i then "SET SESSION max_execution_time = #{ms.to_i}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def apply_statement_timeout(conn, ms)
|
|
71
|
+
sql = self.class.timeout_statement(conn.adapter_name, ms)
|
|
72
|
+
return unless sql
|
|
73
|
+
|
|
74
|
+
conn.execute(sql)
|
|
75
|
+
rescue ActiveRecord::StatementInvalid
|
|
76
|
+
# The server does not support this timeout mechanism (e.g. MariaDB has
|
|
77
|
+
# no `max_execution_time`). Run without a per-statement timeout rather
|
|
78
|
+
# than failing every query; the read-only role still applies.
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Postgres' SET LOCAL unwinds with the transaction, but MySQL's session
|
|
83
|
+
# variable persists on the pooled connection — clear it so a later
|
|
84
|
+
# borrower of the reader connection is not capped by a stale value.
|
|
85
|
+
def clear_statement_timeout(conn)
|
|
86
|
+
return unless conn.adapter_name.match?(/mysql|trilogy/i)
|
|
87
|
+
|
|
88
|
+
conn.execute("SET SESSION max_execution_time = 0")
|
|
89
|
+
rescue ActiveRecord::StatementInvalid
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def render(result, format)
|
|
94
|
+
columns = result.columns
|
|
95
|
+
max = TalkToYourApp::Plugins::Db.max_rows # nil means no cap
|
|
96
|
+
truncated = max && result.rows.length > max
|
|
97
|
+
rows = truncated ? result.rows.first(max) : result.rows
|
|
98
|
+
|
|
99
|
+
case format
|
|
100
|
+
when "html"
|
|
101
|
+
html = Renderers::HtmlTable.render(columns, rows)
|
|
102
|
+
text(truncated ? "#{html}<p>truncated to #{max} rows</p>" : html)
|
|
103
|
+
when "text"
|
|
104
|
+
body = render_text(columns, rows)
|
|
105
|
+
text(truncated ? "#{body}\n(truncated to #{max} rows)" : body)
|
|
106
|
+
else
|
|
107
|
+
payload = { columns: columns, rows: rows }
|
|
108
|
+
payload.merge!(truncated: true, max_rows: max) if truncated
|
|
109
|
+
text(JSON.generate(payload))
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def render_text(columns, rows)
|
|
114
|
+
display = rows.map { |row| row.map { |cell| cell.nil? ? "NULL" : cell.to_s } }
|
|
115
|
+
widths = columns.each_index.map do |i|
|
|
116
|
+
([columns[i].to_s] + display.map { |r| r[i].to_s }).map(&:length).max
|
|
117
|
+
end
|
|
118
|
+
header = columns.each_with_index.map { |c, i| c.to_s.ljust(widths[i]) }.join(" ")
|
|
119
|
+
body = display.map { |r| r.each_with_index.map { |c, i| c.ljust(widths[i]) }.join(" ") }
|
|
120
|
+
([header] + body).join("\n")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../../tool"
|
|
4
|
+
|
|
5
|
+
module TalkToYourApp
|
|
6
|
+
module Plugins
|
|
7
|
+
module Db
|
|
8
|
+
module Tools
|
|
9
|
+
# Describes a single table from the live read-only database: its columns,
|
|
10
|
+
# primary key, indexes, and foreign keys. Lets a client learn a table's
|
|
11
|
+
# shape without a schema dump.
|
|
12
|
+
class Schema < TalkToYourApp::Tool
|
|
13
|
+
CONNECTION = :replica_readonly
|
|
14
|
+
|
|
15
|
+
name "db.schema"
|
|
16
|
+
description "Describe a table: columns, primary key, indexes, and foreign keys."
|
|
17
|
+
connection CONNECTION
|
|
18
|
+
argument :table, :string, required: true, description: "Table name (see db.tables)."
|
|
19
|
+
|
|
20
|
+
def call(args, ctx)
|
|
21
|
+
table = args[:table].to_s
|
|
22
|
+
ctx.connection do |conn|
|
|
23
|
+
unless conn.tables.include?(table)
|
|
24
|
+
next error("Unknown table #{table.inspect}. Use db.tables to list available tables.")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
json(describe(conn, table))
|
|
28
|
+
end
|
|
29
|
+
rescue ActiveRecord::ActiveRecordError => e
|
|
30
|
+
error("Could not describe #{args[:table].inspect}: #{e.message}")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def describe(conn, table)
|
|
36
|
+
{
|
|
37
|
+
table: table,
|
|
38
|
+
primary_key: conn.primary_key(table),
|
|
39
|
+
columns: conn.columns(table).map do |col|
|
|
40
|
+
{ name: col.name, type: col.sql_type, null: col.null, default: col.default }
|
|
41
|
+
end,
|
|
42
|
+
indexes: conn.indexes(table).map do |idx|
|
|
43
|
+
{ name: idx.name, columns: idx.columns, unique: idx.unique }
|
|
44
|
+
end,
|
|
45
|
+
foreign_keys: foreign_keys(conn, table),
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def foreign_keys(conn, table)
|
|
50
|
+
return [] unless conn.supports_foreign_keys?
|
|
51
|
+
|
|
52
|
+
conn.foreign_keys(table).map do |fk|
|
|
53
|
+
{ column: fk.column, references_table: fk.to_table, references_column: fk.primary_key }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../../tool"
|
|
4
|
+
|
|
5
|
+
module TalkToYourApp
|
|
6
|
+
module Plugins
|
|
7
|
+
module Db
|
|
8
|
+
module Tools
|
|
9
|
+
# Lists the table names in the read-only database, so a client can
|
|
10
|
+
# discover the schema before querying.
|
|
11
|
+
class Tables < TalkToYourApp::Tool
|
|
12
|
+
CONNECTION = :replica_readonly
|
|
13
|
+
|
|
14
|
+
name "db.tables"
|
|
15
|
+
description "List the table names in the read-only database."
|
|
16
|
+
connection CONNECTION
|
|
17
|
+
|
|
18
|
+
def call(_args, ctx)
|
|
19
|
+
tables = ctx.connection { |conn| conn.tables.sort }
|
|
20
|
+
json(tables: tables)
|
|
21
|
+
rescue ActiveRecord::ActiveRecordError => e
|
|
22
|
+
error("Could not list tables: #{e.message}")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../plugin"
|
|
4
|
+
require_relative "tools/list_flags"
|
|
5
|
+
require_relative "tools/read_flag"
|
|
6
|
+
require_relative "tools/enable_flag"
|
|
7
|
+
require_relative "tools/disable_flag"
|
|
8
|
+
require_relative "tools/enabled_flags"
|
|
9
|
+
|
|
10
|
+
module TalkToYourApp
|
|
11
|
+
module Plugins
|
|
12
|
+
# The Flipper plugin: list flags, read a flag's state (globally or for an
|
|
13
|
+
# actor), and enable/disable flags globally or per actor. All operations run
|
|
14
|
+
# through the writer-capable :flipper_writer connection — deliberately
|
|
15
|
+
# separate from the DB plugin's read-only one.
|
|
16
|
+
module Flipper
|
|
17
|
+
# A minimal actor: Flipper only needs an object responding to #flipper_id.
|
|
18
|
+
# We reconstitute one from a class name + id rather than loading the host's
|
|
19
|
+
# real record, which avoids coupling to the host's user model and any
|
|
20
|
+
# privilege leak from instantiating it.
|
|
21
|
+
Actor = Struct.new(:flipper_id)
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Builds an actor from a class name + id, or nil when none was supplied.
|
|
26
|
+
def actor_for(actor_class, actor_id)
|
|
27
|
+
return nil if actor_class.nil? || actor_id.nil?
|
|
28
|
+
|
|
29
|
+
Actor.new("#{actor_class};#{actor_id}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# True when a call names more than one gate dimension (actor, group,
|
|
33
|
+
# percentage). gate_from would silently pick one by precedence and drop the
|
|
34
|
+
# rest, so the tools reject the call instead.
|
|
35
|
+
def gate_conflict?(args)
|
|
36
|
+
selectors = []
|
|
37
|
+
selectors << :actor if args[:actor_class] && args[:actor_id]
|
|
38
|
+
selectors << :group if args[:group]
|
|
39
|
+
selectors << :percentage unless args[:percentage].nil?
|
|
40
|
+
selectors.size > 1
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Resolves which Flipper gate a tool call targets from its arguments. In
|
|
44
|
+
# precedence order: a specific actor, a named group, a percentage, else the
|
|
45
|
+
# global boolean gate.
|
|
46
|
+
def gate_from(args)
|
|
47
|
+
if (actor = actor_for(args[:actor_class], args[:actor_id]))
|
|
48
|
+
{ type: :actor, actor: actor }
|
|
49
|
+
elsif args[:group]
|
|
50
|
+
{ type: :group, group: args[:group].to_sym }
|
|
51
|
+
elsif !args[:percentage].nil?
|
|
52
|
+
type = args[:percentage_type] == "time" ? :percentage_of_time : :percentage_of_actors
|
|
53
|
+
{ type: type, percentage: args[:percentage].to_i }
|
|
54
|
+
else
|
|
55
|
+
{ type: :boolean }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Applies :enable or :disable across the resolved gate. Every branch is an
|
|
60
|
+
# explicit named call so the dispatch is statically obvious and an unknown
|
|
61
|
+
# gate type fails fast rather than silently toggling the boolean gate.
|
|
62
|
+
def apply(operation, name, gate)
|
|
63
|
+
enabling = operation == :enable
|
|
64
|
+
case gate[:type]
|
|
65
|
+
when :boolean
|
|
66
|
+
enabling ? ::Flipper.enable(name) : ::Flipper.disable(name)
|
|
67
|
+
when :actor
|
|
68
|
+
enabling ? ::Flipper.enable(name, gate[:actor]) : ::Flipper.disable(name, gate[:actor])
|
|
69
|
+
when :group
|
|
70
|
+
enabling ? ::Flipper.enable_group(name, gate[:group]) : ::Flipper.disable_group(name, gate[:group])
|
|
71
|
+
when :percentage_of_actors
|
|
72
|
+
enabling ? ::Flipper.enable_percentage_of_actors(name, gate[:percentage]) : ::Flipper.disable_percentage_of_actors(name)
|
|
73
|
+
when :percentage_of_time
|
|
74
|
+
enabling ? ::Flipper.enable_percentage_of_time(name, gate[:percentage]) : ::Flipper.disable_percentage_of_time(name)
|
|
75
|
+
else
|
|
76
|
+
raise ArgumentError, "unknown Flipper gate type: #{gate[:type].inspect}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Reads a flag's effective state, globally or for an actor. Unknown flags
|
|
81
|
+
# read false.
|
|
82
|
+
def state(name, actor)
|
|
83
|
+
actor ? ::Flipper.enabled?(name, actor) : ::Flipper.enabled?(name)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The full per-gate configuration of a flag, for read_flag.
|
|
87
|
+
def gate_values(name)
|
|
88
|
+
values = ::Flipper[name].gate_values
|
|
89
|
+
{
|
|
90
|
+
boolean: values.boolean,
|
|
91
|
+
actors: values.actors.to_a,
|
|
92
|
+
groups: values.groups.to_a,
|
|
93
|
+
percentage_of_actors: values.percentage_of_actors,
|
|
94
|
+
percentage_of_time: values.percentage_of_time,
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# A flag is "enabled" if any gate is active.
|
|
99
|
+
def active?(gates)
|
|
100
|
+
gates[:boolean] ||
|
|
101
|
+
gates[:actors].any? ||
|
|
102
|
+
gates[:groups].any? ||
|
|
103
|
+
gates[:percentage_of_actors].to_i.positive? ||
|
|
104
|
+
gates[:percentage_of_time].to_i.positive?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Last-change timestamps for many flags in a single query, keyed by flag
|
|
108
|
+
# name, read from the flipper_features table when the ActiveRecord adapter
|
|
109
|
+
# is in use. Flipper records no enable/disable history, so updated_at is
|
|
110
|
+
# the last time the feature row changed. Returns an empty hash (callers
|
|
111
|
+
# fall back to nil timestamps) for non-ActiveRecord adapters. Batched to
|
|
112
|
+
# avoid an N+1 across all flags.
|
|
113
|
+
def preload_timestamps(names)
|
|
114
|
+
return {} unless defined?(::Flipper::Adapters::ActiveRecord::Feature)
|
|
115
|
+
|
|
116
|
+
::Flipper::Adapters::ActiveRecord::Feature.where(key: names.map(&:to_s)).each_with_object({}) do |row, acc|
|
|
117
|
+
acc[row.key] = { created_at: row.created_at&.utc&.iso8601, updated_at: row.updated_at&.utc&.iso8601 }
|
|
118
|
+
end
|
|
119
|
+
rescue StandardError
|
|
120
|
+
{}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
class Plugin < TalkToYourApp::Plugin
|
|
124
|
+
requires_connection :flipper_writer
|
|
125
|
+
requires_gem "Flipper", gem_name: "flipper"
|
|
126
|
+
tools Tools::ListFlags, Tools::ReadFlag, Tools::EnableFlag, Tools::DisableFlag, Tools::EnabledFlags
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
TalkToYourApp.register_plugin(:flipper, TalkToYourApp::Plugins::Flipper::Plugin)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../../tool"
|
|
4
|
+
|
|
5
|
+
module TalkToYourApp
|
|
6
|
+
module Plugins
|
|
7
|
+
module Flipper
|
|
8
|
+
module Tools
|
|
9
|
+
# Disables a flag. With no gate arguments it disables globally; supply
|
|
10
|
+
# actor_class+actor_id, group, or percentage (+ optional percentage_type)
|
|
11
|
+
# to clear that specific gate.
|
|
12
|
+
class DisableFlag < TalkToYourApp::Tool
|
|
13
|
+
name "flipper.disable_flag"
|
|
14
|
+
description "Disable a feature flag globally, for an actor, a group, or a percentage."
|
|
15
|
+
connection :flipper_writer
|
|
16
|
+
argument :name, :string, required: true, description: "Flag name."
|
|
17
|
+
argument :actor_class, :string, description: "Actor class name, e.g. \"User\"."
|
|
18
|
+
argument :actor_id, :string, description: "Actor id, e.g. \"42\"."
|
|
19
|
+
argument :group, :string, description: "A registered Flipper group name."
|
|
20
|
+
argument :percentage, :integer, minimum: 0, maximum: 100,
|
|
21
|
+
description: "Percentage gate to clear (0-100); the value is ignored on disable."
|
|
22
|
+
argument :percentage_type, :string, enum: %w[actors time], default: "actors",
|
|
23
|
+
description: "Whether percentage applies to actors or time."
|
|
24
|
+
|
|
25
|
+
def call(args, ctx)
|
|
26
|
+
return error("Specify exactly one gate: an actor, a group, or a percentage.") if Flipper.gate_conflict?(args)
|
|
27
|
+
|
|
28
|
+
gate = Flipper.gate_from(args)
|
|
29
|
+
result = ctx.connection do
|
|
30
|
+
Flipper.apply(:disable, args[:name], gate)
|
|
31
|
+
Flipper.gate_values(args[:name])
|
|
32
|
+
end
|
|
33
|
+
json(name: args[:name], enabled: Flipper.active?(result), gate_type: gate[:type], gates: result)
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
error("Flipper storage unavailable: #{e.class}: #{e.message}")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../../tool"
|
|
4
|
+
|
|
5
|
+
module TalkToYourApp
|
|
6
|
+
module Plugins
|
|
7
|
+
module Flipper
|
|
8
|
+
module Tools
|
|
9
|
+
# Enables a flag. With no gate arguments it enables globally (boolean
|
|
10
|
+
# gate); supply actor_class+actor_id for a single actor, group for a
|
|
11
|
+
# registered group, or percentage (+ optional percentage_type) for a
|
|
12
|
+
# percentage rollout.
|
|
13
|
+
class EnableFlag < TalkToYourApp::Tool
|
|
14
|
+
name "flipper.enable_flag"
|
|
15
|
+
description "Enable a feature flag globally, for an actor, a group, or a percentage."
|
|
16
|
+
connection :flipper_writer
|
|
17
|
+
argument :name, :string, required: true, description: "Flag name."
|
|
18
|
+
argument :actor_class, :string, description: "Actor class name, e.g. \"User\"."
|
|
19
|
+
argument :actor_id, :string, description: "Actor id, e.g. \"42\"."
|
|
20
|
+
argument :group, :string, description: "A registered Flipper group name."
|
|
21
|
+
argument :percentage, :integer, minimum: 0, maximum: 100,
|
|
22
|
+
description: "Percentage rollout (0-100)."
|
|
23
|
+
argument :percentage_type, :string, enum: %w[actors time], default: "actors",
|
|
24
|
+
description: "Whether percentage applies to actors or time."
|
|
25
|
+
|
|
26
|
+
def call(args, ctx)
|
|
27
|
+
return error("Specify exactly one gate: an actor, a group, or a percentage.") if Flipper.gate_conflict?(args)
|
|
28
|
+
|
|
29
|
+
gate = Flipper.gate_from(args)
|
|
30
|
+
result = ctx.connection do
|
|
31
|
+
Flipper.apply(:enable, args[:name], gate)
|
|
32
|
+
Flipper.gate_values(args[:name])
|
|
33
|
+
end
|
|
34
|
+
json(name: args[:name], enabled: Flipper.active?(result), gate_type: gate[:type], gates: result)
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
error("Flipper storage unavailable: #{e.class}: #{e.message}")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../../tool"
|
|
4
|
+
|
|
5
|
+
module TalkToYourApp
|
|
6
|
+
module Plugins
|
|
7
|
+
module Flipper
|
|
8
|
+
module Tools
|
|
9
|
+
# Lists the currently-enabled feature flags (any active gate) with their
|
|
10
|
+
# per-gate configuration and last-change timestamps, for inspection.
|
|
11
|
+
# Flipper does not record full enable/disable history; `updated_at` is
|
|
12
|
+
# the last time the feature changed (ActiveRecord adapter only).
|
|
13
|
+
class EnabledFlags < TalkToYourApp::Tool
|
|
14
|
+
name "flipper.enabled_flags"
|
|
15
|
+
description "List currently-enabled feature flags with their gates and last-change timestamps."
|
|
16
|
+
connection :flipper_writer
|
|
17
|
+
|
|
18
|
+
def call(_args, ctx)
|
|
19
|
+
flags = ctx.connection do
|
|
20
|
+
features = ::Flipper.features.to_a
|
|
21
|
+
timestamps = Flipper.preload_timestamps(features.map(&:key))
|
|
22
|
+
no_timestamps = { created_at: nil, updated_at: nil }
|
|
23
|
+
features.filter_map do |feature|
|
|
24
|
+
gates = Flipper.gate_values(feature.key)
|
|
25
|
+
next unless Flipper.active?(gates)
|
|
26
|
+
|
|
27
|
+
{ name: feature.key, enabled: true, gates: gates }.merge(timestamps.fetch(feature.key, no_timestamps))
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
json(
|
|
31
|
+
enabled_flags: flags.sort_by { |f| f[:name] },
|
|
32
|
+
note: "Flipper does not store enable/disable history; updated_at is the last feature change (ActiveRecord adapter only).",
|
|
33
|
+
)
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
error("Flipper storage unavailable: #{e.class}: #{e.message}")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../../tool"
|
|
4
|
+
|
|
5
|
+
module TalkToYourApp
|
|
6
|
+
module Plugins
|
|
7
|
+
module Flipper
|
|
8
|
+
module Tools
|
|
9
|
+
# Lists the keys of all configured feature flags.
|
|
10
|
+
class ListFlags < TalkToYourApp::Tool
|
|
11
|
+
name "flipper.list_flags"
|
|
12
|
+
description "List all configured feature flag names."
|
|
13
|
+
connection :flipper_writer
|
|
14
|
+
|
|
15
|
+
def call(_args, ctx)
|
|
16
|
+
names = ctx.connection { ::Flipper.features.map(&:key) }
|
|
17
|
+
json(flags: names.sort)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../../tool"
|
|
4
|
+
|
|
5
|
+
module TalkToYourApp
|
|
6
|
+
module Plugins
|
|
7
|
+
module Flipper
|
|
8
|
+
module Tools
|
|
9
|
+
# Reads a flag's effective state plus its full per-gate configuration.
|
|
10
|
+
# With an actor, `enabled` reflects that actor; otherwise it reflects the
|
|
11
|
+
# global state. Unknown flags read as disabled.
|
|
12
|
+
class ReadFlag < TalkToYourApp::Tool
|
|
13
|
+
name "flipper.read_flag"
|
|
14
|
+
description "Read a feature flag's state and per-gate configuration."
|
|
15
|
+
connection :flipper_writer
|
|
16
|
+
argument :name, :string, required: true, description: "Flag name."
|
|
17
|
+
argument :actor_class, :string, description: "Actor class name, e.g. \"User\"."
|
|
18
|
+
argument :actor_id, :string, description: "Actor id, e.g. \"42\"."
|
|
19
|
+
|
|
20
|
+
def call(args, ctx)
|
|
21
|
+
actor = Flipper.actor_for(args[:actor_class], args[:actor_id])
|
|
22
|
+
enabled, gates = ctx.connection do
|
|
23
|
+
[Flipper.state(args[:name], actor), Flipper.gate_values(args[:name])]
|
|
24
|
+
end
|
|
25
|
+
json(name: args[:name], enabled: enabled, actor: actor&.flipper_id, gates: gates)
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
error("Flipper storage unavailable: #{e.class}: #{e.message}")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../plugin"
|
|
4
|
+
require_relative "registry"
|
|
5
|
+
require_relative "tools/list_checks"
|
|
6
|
+
require_relative "tools/run_check"
|
|
7
|
+
|
|
8
|
+
module TalkToYourApp
|
|
9
|
+
module Plugins
|
|
10
|
+
module Health
|
|
11
|
+
# The Health plugin exposes the operator-registered checks via two tools:
|
|
12
|
+
# list and run. No connection or soft-dependency gem is required — checks
|
|
13
|
+
# own whatever resources they touch.
|
|
14
|
+
class Plugin < TalkToYourApp::Plugin
|
|
15
|
+
tools Tools::ListChecks, Tools::RunCheck
|
|
16
|
+
|
|
17
|
+
# Load app/talk_to_your_app/health/ (one Health.register per file) when
|
|
18
|
+
# the tool list is read — once, at rack-app build / boot, mirroring the
|
|
19
|
+
# custom_tools plugin. This surfaces a broken health file at deploy
|
|
20
|
+
# rather than on the first health.run, and avoids re-globbing the
|
|
21
|
+
# directory (and re-logging a failed load) on every tool call.
|
|
22
|
+
def self.tools(*classes)
|
|
23
|
+
TalkToYourApp.require_app_dir("health") if classes.empty?
|
|
24
|
+
super
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
TalkToYourApp.register_plugin(:health, TalkToYourApp::Plugins::Health::Plugin)
|