seekmodo-sdk 0.5.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/.github/workflows/ci.yml +52 -0
- data/.gitignore +12 -0
- data/.rspec +2 -0
- data/.rubocop.yml +32 -0
- data/Gemfile +11 -0
- data/README.md +135 -0
- data/docs/tool-catalog.README.md +12 -0
- data/lib/seekmodo/sdk/admin/client.rb +120 -0
- data/lib/seekmodo/sdk/auto_promoter.rb +124 -0
- data/lib/seekmodo/sdk/browser_token.rb +62 -0
- data/lib/seekmodo/sdk/circuit_breaker.rb +123 -0
- data/lib/seekmodo/sdk/connector/client.rb +250 -0
- data/lib/seekmodo/sdk/events/click_beacon.rb +58 -0
- data/lib/seekmodo/sdk/events/events_queue.rb +50 -0
- data/lib/seekmodo/sdk/exceptions/breaker_open_error.rb +10 -0
- data/lib/seekmodo/sdk/exceptions/client_error.rb +66 -0
- data/lib/seekmodo/sdk/exceptions/over_quota_error.rb +10 -0
- data/lib/seekmodo/sdk/exceptions/seekmodo_error.rb +8 -0
- data/lib/seekmodo/sdk/exceptions/signature_mismatch_error.rb +10 -0
- data/lib/seekmodo/sdk/exceptions/tenant_unavailable_error.rb +10 -0
- data/lib/seekmodo/sdk/hmac_signer.rb +43 -0
- data/lib/seekmodo/sdk/mcp/client.rb +105 -0
- data/lib/seekmodo/sdk/mode.rb +41 -0
- data/lib/seekmodo/sdk/mode_fsm.rb +52 -0
- data/lib/seekmodo/sdk/pairing.rb +114 -0
- data/lib/seekmodo/sdk/signature_mismatch_tracker.rb +52 -0
- data/lib/seekmodo/sdk/storage/memory/stores.rb +100 -0
- data/lib/seekmodo/sdk/storage/protocols.rb +47 -0
- data/lib/seekmodo/sdk/storefront/client.rb +71 -0
- data/lib/seekmodo/sdk/storefront/transport.rb +198 -0
- data/lib/seekmodo/sdk/tenant_snapshot.rb +65 -0
- data/lib/seekmodo/sdk/tools/registry.rb +88 -0
- data/lib/seekmodo/sdk/version.rb +7 -0
- data/lib/seekmodo/sdk.rb +33 -0
- data/lib/seekmodo-sdk.rb +3 -0
- metadata +109 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0a3051fcfcab7839036fb801ec2c2570395dc8f77b4657b48f9ff181c011c727
|
|
4
|
+
data.tar.gz: ab157610976b2dd2232f5fd1d7a3287efca2d7d866912c9dbf13b4d8dd68e302
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1619b839b83c2118f0b56482c186b08ebe11ceb7cc0b6ed9692249b08d50ec5395a5355b5cb75649277be5c086933e312af16d9e7b4682faa8410d7802172545
|
|
7
|
+
data.tar.gz: 65dcffd819b63e19a3bd84d59e96c398eab90b5ccee08cf08e819cfd8eb31354eac974dbd569c9c97654c91d9c5cb90559b2c5842d69b6f5ae3123834494ee97
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
tags: ["v*"]
|
|
7
|
+
pull_request:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
ruby: ["3.1", "3.2", "3.3"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: ruby/setup-ruby@v1
|
|
18
|
+
with:
|
|
19
|
+
ruby-version: ${{ matrix.ruby }}
|
|
20
|
+
bundler-cache: true
|
|
21
|
+
- run: bundle exec rspec
|
|
22
|
+
|
|
23
|
+
publish:
|
|
24
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
25
|
+
needs: test
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
permissions:
|
|
28
|
+
contents: read
|
|
29
|
+
id-token: write
|
|
30
|
+
steps:
|
|
31
|
+
- uses: actions/checkout@v4
|
|
32
|
+
- uses: ruby/setup-ruby@v1
|
|
33
|
+
with:
|
|
34
|
+
ruby-version: "3.3"
|
|
35
|
+
bundler-cache: true
|
|
36
|
+
- name: Configure trusted publisher credentials (OIDC)
|
|
37
|
+
id: oidc
|
|
38
|
+
uses: rubygems/configure-rubygems-credentials@v1.0.0
|
|
39
|
+
continue-on-error: true
|
|
40
|
+
- name: Configure API key fallback
|
|
41
|
+
if: steps.oidc.outcome == 'failure'
|
|
42
|
+
run: |
|
|
43
|
+
mkdir -p ~/.gem
|
|
44
|
+
cat > ~/.gem/credentials <<EOF
|
|
45
|
+
---
|
|
46
|
+
:rubygems_api_key: ${RUBYGEMS_API_KEY}
|
|
47
|
+
EOF
|
|
48
|
+
chmod 600 ~/.gem/credentials
|
|
49
|
+
env:
|
|
50
|
+
RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
|
|
51
|
+
- run: gem build seekmodo-sdk.gemspec
|
|
52
|
+
- run: gem push seekmodo-sdk-*.gem
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
AllCops:
|
|
2
|
+
NewCops: enable
|
|
3
|
+
TargetRubyVersion: 3.1
|
|
4
|
+
Exclude:
|
|
5
|
+
- "vendor/**/*"
|
|
6
|
+
- "spec/**/*"
|
|
7
|
+
|
|
8
|
+
Style/Documentation:
|
|
9
|
+
Enabled: false
|
|
10
|
+
|
|
11
|
+
Style/StringLiterals:
|
|
12
|
+
EnforcedStyle: double_quotes
|
|
13
|
+
|
|
14
|
+
Gemspec/DevelopmentDependencies:
|
|
15
|
+
Enabled: false
|
|
16
|
+
|
|
17
|
+
Layout/LineLength:
|
|
18
|
+
Max: 140
|
|
19
|
+
|
|
20
|
+
Metrics/BlockLength:
|
|
21
|
+
Exclude:
|
|
22
|
+
- "spec/**/*"
|
|
23
|
+
- "seekmodo-sdk.gemspec"
|
|
24
|
+
|
|
25
|
+
Metrics/MethodLength:
|
|
26
|
+
Max: 30
|
|
27
|
+
|
|
28
|
+
Metrics/AbcSize:
|
|
29
|
+
Max: 35
|
|
30
|
+
|
|
31
|
+
Metrics/ClassLength:
|
|
32
|
+
Max: 200
|
data/Gemfile
ADDED
data/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Seekmodo Ruby SDK
|
|
2
|
+
|
|
3
|
+
Shared Ruby SDK for building [Seekmodo](https://seekmodo.com) storefront connectors, admin automation, and MCP agents. The gem ships four clients behind one namespace (`Seekmodo::Sdk`):
|
|
4
|
+
|
|
5
|
+
| Client | Auth | Base URL | Use when |
|
|
6
|
+
|--------|------|----------|----------|
|
|
7
|
+
| `Connector::Client` | HMAC (`tenant_id` + `shared_secret`) | `https://mcp.seekmodo.com` | Server-side connector: index catalog, batch events, handshake, mint browser tokens |
|
|
8
|
+
| `Storefront::Client` | JWT Bearer (`get_token` callback) | `https://gateway.seekmodo.com` | Storefront widgets, headless apps, anything that runs in the browser or on a token-minting server |
|
|
9
|
+
| `Admin::Client` | `X-Seekmodo-Admin-Key` | `https://mcp.seekmodo.com` | Operator/admin automation: synonyms, pins, LTR, analytics |
|
|
10
|
+
| `Mcp::Client` | HMAC **or** operator bearer | `https://mcp.seekmodo.com/mcp` | JSON-RPC MCP (`initialize`, `tools/list`, `tools/call`) for AI agents |
|
|
11
|
+
|
|
12
|
+
**Status**: 0.5.0 · Ruby 3.1+
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
gem install seekmodo-sdk
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or in Bundler:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
gem "seekmodo-sdk", "~> 0.5"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Runtime dependencies: `faraday`, `jwt`.
|
|
27
|
+
|
|
28
|
+
## Quick starts
|
|
29
|
+
|
|
30
|
+
### Connector (HMAC)
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
require "seekmodo/sdk"
|
|
34
|
+
|
|
35
|
+
signer = Seekmodo::Sdk::HmacSigner.new("your-tenant-id", "your-shared-secret")
|
|
36
|
+
breaker = Seekmodo::Sdk::CircuitBreaker.new(Seekmodo::Sdk::Storage::Memory::BreakerStore.new)
|
|
37
|
+
client = Seekmodo::Sdk::Connector::Client.new(signer, breaker: breaker)
|
|
38
|
+
|
|
39
|
+
results = client.search({ "q" => "red running shoes", "per_page" => 24 })
|
|
40
|
+
client.index(documents, action: "upsert") # auto-chunks at 500
|
|
41
|
+
client.tenant_snapshot # POST /v1/tenant.snapshot
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Storefront (JWT)
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
client = Seekmodo::Sdk::Storefront::Client.new(
|
|
48
|
+
tenant_id: "redline",
|
|
49
|
+
get_token: -> { connector.browser_token["token"] }
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
hits = client.search({ "q" => "spark plug" })
|
|
53
|
+
recs = client.recommend.related({ "source_doc_id" => "sku-123" })
|
|
54
|
+
bundles = client.bundle.suggest({ "source_doc_id" => "sku-123" })
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Admin
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
admin = Seekmodo::Sdk::Admin::Client.new(admin_key: ENV.fetch("MCP_ADMIN_KEY"))
|
|
61
|
+
|
|
62
|
+
synonyms = admin.list_synonyms("redline")
|
|
63
|
+
admin.add_synonym("redline", { "synonyms" => %w[spark plug] })
|
|
64
|
+
pins = admin.list_pins("redline")
|
|
65
|
+
ltr = admin.ltr_status("redline")
|
|
66
|
+
top = admin.analytics_top_queries("redline", window: "7d")
|
|
67
|
+
zeros = admin.analytics_zero_results("redline")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### MCP JSON-RPC
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
# HMAC (tenant connector)
|
|
74
|
+
mcp = Seekmodo::Sdk::Mcp::Client.new(signer: signer)
|
|
75
|
+
mcp.initialize_session({ "clientInfo" => { "name" => "my-agent" } })
|
|
76
|
+
tools = mcp.tools_list
|
|
77
|
+
result = mcp.tools_call("search", { "q" => "brake pads" })
|
|
78
|
+
|
|
79
|
+
# Operator bearer
|
|
80
|
+
mcp = Seekmodo::Sdk::Mcp::Client.new(
|
|
81
|
+
operator_token: ENV.fetch("SEEKMODO_OPERATOR_TOKEN"),
|
|
82
|
+
tenant_id: "redline"
|
|
83
|
+
)
|
|
84
|
+
mcp.tools_call("analytics.zero_results", { "limit" => 10 })
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Tools registry
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
registry = Seekmodo::Sdk::Tools::Registry.new(
|
|
91
|
+
connector: connector_client,
|
|
92
|
+
admin: admin_client,
|
|
93
|
+
tenant_id: "redline"
|
|
94
|
+
)
|
|
95
|
+
registry.call("search", { "q" => "oil filter" })
|
|
96
|
+
registry.call("synonyms.list", {})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Public surface
|
|
100
|
+
|
|
101
|
+
| Module / class | What it does |
|
|
102
|
+
|----------------|--------------|
|
|
103
|
+
| `Connector::Client` | `search`, `index`, `events`, `tenant_handshake`, `tenant_snapshot`, `browser_token`, `tools`, `health` |
|
|
104
|
+
| `HmacSigner` | Builds the three `X-Seekmodo-*` headers |
|
|
105
|
+
| `CircuitBreaker` | Three-state FSM (closed/open/half_open) with pluggable storage |
|
|
106
|
+
| `TenantSnapshot` | Polls `tenant.snapshot` with stale-while-revalidate cache |
|
|
107
|
+
| `ModeFsm` | Resolves effective connector mode |
|
|
108
|
+
| `AutoPromoter` | Walks `active` tenants through shadow → enforce |
|
|
109
|
+
| `Pairing` | Verifies EdDSA pairing JWTs against Seekmodo JWKS |
|
|
110
|
+
| `BrowserToken` | Mints `/v1/tenants/token` browser JWTs with TTL-aware caching |
|
|
111
|
+
| `EventsQueue` | Batches events into single `POST /v1/events` calls |
|
|
112
|
+
| `Events::ClickBeacon` | Pure-function payload builders for beacons |
|
|
113
|
+
| `Storefront::Client` | Typed `search`, `suggest`, `search_by_image`, `chat`, `event`, `recommend.*`, `bundle.suggest` |
|
|
114
|
+
| `Admin::Client` | Typed admin tools + generic `call(tool, body, tenant_id:)` |
|
|
115
|
+
| `Mcp::Client` | `initialize_session`, `tools_list`, `tools_call`, `ping` |
|
|
116
|
+
| `Tools::Registry` | Routes normalized tool names to connector or admin |
|
|
117
|
+
|
|
118
|
+
## Wire contract tests
|
|
119
|
+
|
|
120
|
+
Fixtures under `spec/contracts/fixtures/` mirror the PHP SDK contract pack.
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
bundle install
|
|
124
|
+
bundle exec rspec
|
|
125
|
+
bundle exec rubocop
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Versioning
|
|
129
|
+
|
|
130
|
+
Follows semver. Breaking wire or type changes are major bumps; new methods and optional fields are minors; bug fixes are patches.
|
|
131
|
+
|
|
132
|
+
## Links
|
|
133
|
+
|
|
134
|
+
- [Python SDK (reference parity)](https://github.com/numinix/seekmodo-python-sdk)
|
|
135
|
+
- [PHP SDK (wire fixtures source)](https://github.com/numinix/seekmodo-php-sdk)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Tool catalog manifest
|
|
2
|
+
|
|
3
|
+
Regenerate from the Seekmodo monorepo when gateway tools change:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
cd seekmodo.com/seekmodo
|
|
7
|
+
composer install -d services/mcp-gateway
|
|
8
|
+
php tools/export_tool_catalog.php --out=../seekmodo-ruby-sdk/docs/tool-catalog.json
|
|
9
|
+
php tools/export_tool_catalog.php --out=../seekmodo-go-sdk/docs/tool-catalog.json
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The manifest drives SDK docs and optional codegen for `Tools::Registry` typed helpers.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "faraday"
|
|
5
|
+
|
|
6
|
+
module Seekmodo
|
|
7
|
+
module Sdk
|
|
8
|
+
module Admin
|
|
9
|
+
class Error < StandardError
|
|
10
|
+
attr_reader :status, :body
|
|
11
|
+
|
|
12
|
+
def initialize(status, body)
|
|
13
|
+
@status = status
|
|
14
|
+
@body = body
|
|
15
|
+
super("gateway #{status}: #{body.to_s[0, 200]}")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class Client
|
|
20
|
+
DEFAULT_GATEWAY_URL = "https://mcp.seekmodo.com"
|
|
21
|
+
|
|
22
|
+
def initialize(admin_key:, gateway_url: DEFAULT_GATEWAY_URL, connection: nil, user_agent: "Seekmodo-Admin/1.0")
|
|
23
|
+
@admin_key = admin_key
|
|
24
|
+
@gateway_url = gateway_url.to_s.delete_suffix("/")
|
|
25
|
+
@user_agent = user_agent
|
|
26
|
+
@connection = connection || Faraday.new do |f|
|
|
27
|
+
f.adapter Faraday.default_adapter
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call(tool, body, tenant_id:)
|
|
32
|
+
path = tool.start_with?("/") ? tool : "/v1/admin/#{normalize_tool(tool)}"
|
|
33
|
+
call_rest(path, body.merge("tenant_id" => tenant_id), tenant_id: tenant_id)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def list_synonyms(tenant_id)
|
|
37
|
+
result = call("synonyms.list", {}, tenant_id: tenant_id)
|
|
38
|
+
result.fetch("synonyms", [])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def add_synonym(tenant_id, body)
|
|
42
|
+
call("synonyms.add", body, tenant_id: tenant_id)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def remove_synonym(tenant_id, id)
|
|
46
|
+
call("synonyms.remove", { "id" => id }, tenant_id: tenant_id)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def list_pins(tenant_id)
|
|
50
|
+
result = call("pins.list", {}, tenant_id: tenant_id)
|
|
51
|
+
result.fetch("pins", [])
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def set_pins(tenant_id, body)
|
|
55
|
+
call("pins.set", body, tenant_id: tenant_id)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def ltr_status(tenant_id, history_limit: nil, history_offset: nil)
|
|
59
|
+
params = {}
|
|
60
|
+
if history_limit
|
|
61
|
+
params["history_limit"] = [[history_limit, 1].max, 50].min
|
|
62
|
+
end
|
|
63
|
+
if history_offset
|
|
64
|
+
params["history_offset"] = [[history_offset, 0].max, 10_000].min
|
|
65
|
+
end
|
|
66
|
+
call("ltr.status", params, tenant_id: tenant_id)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def ltr_retrain(tenant_id)
|
|
70
|
+
call("ltr.retrain", {}, tenant_id: tenant_id)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def analytics_top_queries(tenant_id, window: "7d", limit: 20)
|
|
74
|
+
result = call(
|
|
75
|
+
"analytics.top_queries",
|
|
76
|
+
{ "window" => window, "limit" => limit },
|
|
77
|
+
tenant_id: tenant_id
|
|
78
|
+
)
|
|
79
|
+
result.fetch("rows", [])
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def analytics_zero_results(tenant_id, window: "7d", limit: 20)
|
|
83
|
+
result = call(
|
|
84
|
+
"analytics.zero_results",
|
|
85
|
+
{ "window" => window, "limit" => limit },
|
|
86
|
+
tenant_id: tenant_id
|
|
87
|
+
)
|
|
88
|
+
result.fetch("rows", [])
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def call_rest(path, body, tenant_id:)
|
|
94
|
+
response = @connection.post(@gateway_url + path) do |req|
|
|
95
|
+
req.headers.update(
|
|
96
|
+
"Content-Type" => "application/json",
|
|
97
|
+
"X-Seekmodo-Admin-Key" => @admin_key,
|
|
98
|
+
"X-Seekmodo-Tenant" => tenant_id,
|
|
99
|
+
"User-Agent" => @user_agent
|
|
100
|
+
)
|
|
101
|
+
req.body = JSON.generate(body)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
text = response.body.to_s
|
|
105
|
+
unless response.status >= 200 && response.status < 300
|
|
106
|
+
raise Error.new(response.status, text)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
text.empty? ? nil : JSON.parse(text)
|
|
110
|
+
rescue JSON::ParserError => e
|
|
111
|
+
raise Error.new(response.status, "invalid JSON: #{e.message}")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def normalize_tool(tool)
|
|
115
|
+
tool.to_s.tr("_", ".")
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "mode"
|
|
4
|
+
require_relative "circuit_breaker"
|
|
5
|
+
require_relative "tenant_snapshot"
|
|
6
|
+
require_relative "storage/protocols"
|
|
7
|
+
|
|
8
|
+
module Seekmodo
|
|
9
|
+
module Sdk
|
|
10
|
+
class AutoPromoter
|
|
11
|
+
STATE_KEY = "numinix.seekmodo.fsm_state"
|
|
12
|
+
DEFAULT_PROMOTE_AFTER_SECONDS = 3600
|
|
13
|
+
DEFAULT_DEMOTE_COOLDOWN_SECONDS = 900
|
|
14
|
+
|
|
15
|
+
def initialize(
|
|
16
|
+
snapshot,
|
|
17
|
+
breaker,
|
|
18
|
+
cache,
|
|
19
|
+
promote_after_seconds: DEFAULT_PROMOTE_AFTER_SECONDS,
|
|
20
|
+
demote_cooldown_seconds: DEFAULT_DEMOTE_COOLDOWN_SECONDS,
|
|
21
|
+
clock: nil
|
|
22
|
+
)
|
|
23
|
+
@snapshot = snapshot
|
|
24
|
+
@breaker = breaker
|
|
25
|
+
@cache = cache
|
|
26
|
+
@promote_after_seconds = promote_after_seconds
|
|
27
|
+
@demote_cooldown_seconds = demote_cooldown_seconds
|
|
28
|
+
@clock = clock || -> { Time.now.to_i }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def tick
|
|
32
|
+
config = @snapshot.get
|
|
33
|
+
configured_mode = config.fetch("mode", Mode::OFF).to_s
|
|
34
|
+
auto_promote = config.fetch("auto_promote", true)
|
|
35
|
+
|
|
36
|
+
if configured_mode != Mode::ACTIVE
|
|
37
|
+
return result(Mode::OFF, Mode::OFF, "held", "mode is not active; auto-promoter idle")
|
|
38
|
+
end
|
|
39
|
+
unless auto_promote
|
|
40
|
+
return result(Mode::OFF, Mode::OFF, "held", "auto_promote disabled")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
state = load_state
|
|
44
|
+
current = state["current_state"].to_s
|
|
45
|
+
now = @clock.call
|
|
46
|
+
breaker_snapshot = @breaker.snapshot
|
|
47
|
+
breaker_open = breaker_snapshot["state"] == CircuitBreaker::STATE_OPEN
|
|
48
|
+
|
|
49
|
+
if breaker_open && current == Mode::ENFORCE
|
|
50
|
+
if now - state["last_transition_at"].to_i < @demote_cooldown_seconds
|
|
51
|
+
return result(current, current, "held", "demote cooldown active")
|
|
52
|
+
end
|
|
53
|
+
write_state(Mode::SHADOW, now)
|
|
54
|
+
return result(current, Mode::SHADOW, "demoted", "breaker open at enforce")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
settled_for = now - state["last_transition_at"].to_i
|
|
58
|
+
if breaker_open
|
|
59
|
+
return result(current, current, "held", "breaker open")
|
|
60
|
+
end
|
|
61
|
+
if settled_for < @promote_after_seconds
|
|
62
|
+
return result(current, current, "held", "settled for #{settled_for}s, need #{@promote_after_seconds}s")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
next_step = next_step_up(current)
|
|
66
|
+
if next_step == current
|
|
67
|
+
return result(current, current, "held", "already at enforce")
|
|
68
|
+
end
|
|
69
|
+
write_state(next_step, now)
|
|
70
|
+
result(current, next_step, "promoted", "breaker closed, settled")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def current_state
|
|
74
|
+
load_state
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def next_step_up(current)
|
|
80
|
+
if [Mode::OFF, Mode::LEARNING].include?(current)
|
|
81
|
+
return Mode::SHADOW
|
|
82
|
+
end
|
|
83
|
+
return Mode::ENFORCE if current == Mode::SHADOW
|
|
84
|
+
|
|
85
|
+
current
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def load_state
|
|
89
|
+
raw = @cache.get(STATE_KEY)
|
|
90
|
+
unless raw.is_a?(Hash)
|
|
91
|
+
now = @clock.call
|
|
92
|
+
return { "current_state" => Mode::SHADOW, "last_transition_at" => now }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
current_raw = raw["current_state"].to_s
|
|
96
|
+
current = Mode.valid?(current_raw) ? current_raw : Mode::SHADOW
|
|
97
|
+
{
|
|
98
|
+
"current_state" => current,
|
|
99
|
+
"last_transition_at" => raw.fetch("last_transition_at", 0).to_i
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def write_state(new_state, transitioned_at)
|
|
104
|
+
@cache.set(
|
|
105
|
+
STATE_KEY,
|
|
106
|
+
{
|
|
107
|
+
"current_state" => Mode.assert_mode(new_state),
|
|
108
|
+
"last_transition_at" => transitioned_at
|
|
109
|
+
},
|
|
110
|
+
86400
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def result(from_state, to_state, action, reason)
|
|
115
|
+
{
|
|
116
|
+
"from" => from_state,
|
|
117
|
+
"to" => to_state,
|
|
118
|
+
"action" => action,
|
|
119
|
+
"reason" => reason
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "connector/client"
|
|
4
|
+
require_relative "exceptions/client_error"
|
|
5
|
+
require_relative "storage/protocols"
|
|
6
|
+
|
|
7
|
+
module Seekmodo
|
|
8
|
+
module Sdk
|
|
9
|
+
class BrowserToken
|
|
10
|
+
CACHE_KEY = "numinix.seekmodo.browser_token"
|
|
11
|
+
SAFETY_MARGIN_SECONDS = 60
|
|
12
|
+
|
|
13
|
+
def initialize(client, cache, clock: nil)
|
|
14
|
+
@client = client
|
|
15
|
+
@cache = cache
|
|
16
|
+
@clock = clock || -> { Time.now.to_i }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def token(audience = nil, force: false)
|
|
20
|
+
cache_key = "#{CACHE_KEY}:#{audience || 'default'}"
|
|
21
|
+
unless force
|
|
22
|
+
cached = @cache.get(cache_key)
|
|
23
|
+
if cached.is_a?(Hash)
|
|
24
|
+
expires_at = cached.fetch("expires_at", 0).to_i
|
|
25
|
+
if expires_at > @clock.call + SAFETY_MARGIN_SECONDS
|
|
26
|
+
return {
|
|
27
|
+
"token" => cached["token"].to_s,
|
|
28
|
+
"expires_at" => expires_at,
|
|
29
|
+
"issued_at" => cached.fetch("issued_at", @clock.call).to_i
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
response = @client.browser_token(audience)
|
|
36
|
+
token = response["token"].to_s
|
|
37
|
+
expires_at = response.fetch("expires_at", 0).to_i
|
|
38
|
+
issued_at = response.fetch("issued_at", @clock.call).to_i
|
|
39
|
+
|
|
40
|
+
if token.empty? || expires_at.zero?
|
|
41
|
+
raise ClientError.new(
|
|
42
|
+
"Gateway tenants.token response missing token/expires_at fields.",
|
|
43
|
+
ClientError::KIND_BAD_RESPONSE
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
value = {
|
|
48
|
+
"token" => token,
|
|
49
|
+
"expires_at" => expires_at,
|
|
50
|
+
"issued_at" => issued_at
|
|
51
|
+
}
|
|
52
|
+
ttl = [expires_at - @clock.call - SAFETY_MARGIN_SECONDS, 1].max
|
|
53
|
+
@cache.set(cache_key, value, ttl)
|
|
54
|
+
value
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def forget(audience = nil)
|
|
58
|
+
@cache.delete("#{CACHE_KEY}:#{audience || 'default'}")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|