libbeachcomber 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/lib/beachcomber/client.rb +220 -0
- data/lib/beachcomber/discovery.rb +25 -0
- data/lib/beachcomber/errors.rb +28 -0
- data/lib/beachcomber/result.rb +81 -0
- data/lib/beachcomber.rb +28 -0
- metadata +51 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 828cc2e242605a1780d4fe4bb1dcd860b1ff8055af6e1fe468d77d8277410f08
|
|
4
|
+
data.tar.gz: 052b6205a895eaccbb65c86257dcaaa427ee651a7446cb0afcf81dd71507f706
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 52eee270039542c05ece9bcbf63c5057f915b1fa56be399bfb2c1e38d3c6c4381d37b1bd0cdd4634380bcc5d97f1b8071ed8fae7abf75d213dc9e47ac41d677d
|
|
7
|
+
data.tar.gz: 64608926fdca4fb02c8fdbbc943f15d4219a5fd2051818a6c84c58d6f9a3bd5a0b04cc3d9fb9e3bbd2fa4498c901e012d3f050cc0371c15036f869d14b592c58
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
require 'socket'
|
|
2
|
+
require 'json'
|
|
3
|
+
|
|
4
|
+
require_relative 'discovery'
|
|
5
|
+
require_relative 'errors'
|
|
6
|
+
require_relative 'result'
|
|
7
|
+
|
|
8
|
+
module Beachcomber
|
|
9
|
+
DEFAULT_TIMEOUT = 0.1 # seconds (100 ms)
|
|
10
|
+
|
|
11
|
+
# Session holds a persistent connection to the daemon and sends multiple
|
|
12
|
+
# requests over the same socket.
|
|
13
|
+
#
|
|
14
|
+
# Obtain a Session via {Client#session}:
|
|
15
|
+
#
|
|
16
|
+
# client.session do |s|
|
|
17
|
+
# s.set_context('/repo')
|
|
18
|
+
# r = s.get('git.branch')
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# Not thread-safe; use one session per thread.
|
|
22
|
+
class Session
|
|
23
|
+
def initialize(socket, timeout)
|
|
24
|
+
@socket = socket
|
|
25
|
+
@timeout = timeout
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Sets the default path for subsequent queries on this connection.
|
|
29
|
+
#
|
|
30
|
+
# @param path [String]
|
|
31
|
+
def set_context(path)
|
|
32
|
+
roundtrip({ op: 'context', path: path })
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Reads a cached value.
|
|
37
|
+
#
|
|
38
|
+
# @param key [String] e.g. "git.branch" or "git"
|
|
39
|
+
# @param path [String, nil] optional working-directory override
|
|
40
|
+
# @return [Result]
|
|
41
|
+
def get(key, path: nil)
|
|
42
|
+
req = { op: 'get', key: key }
|
|
43
|
+
req[:path] = path if path
|
|
44
|
+
roundtrip(req)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Forces the daemon to recompute a provider/key.
|
|
48
|
+
#
|
|
49
|
+
# @param key [String]
|
|
50
|
+
# @param path [String, nil]
|
|
51
|
+
def poke(key, path: nil)
|
|
52
|
+
req = { op: 'poke', key: key }
|
|
53
|
+
req[:path] = path if path
|
|
54
|
+
roundtrip(req)
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Lists available providers.
|
|
59
|
+
#
|
|
60
|
+
# @return [Result]
|
|
61
|
+
def list
|
|
62
|
+
roundtrip({ op: 'list' })
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Returns daemon status.
|
|
66
|
+
#
|
|
67
|
+
# @return [Result]
|
|
68
|
+
def status
|
|
69
|
+
roundtrip({ op: 'status' })
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Closes the underlying socket connection.
|
|
73
|
+
def close
|
|
74
|
+
@socket.close unless @socket.closed?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def roundtrip(req)
|
|
80
|
+
line = JSON.generate(req) + "\n"
|
|
81
|
+
@socket.write(line)
|
|
82
|
+
raw = @socket.gets
|
|
83
|
+
raise ProtocolError, "connection closed before response" if raw.nil?
|
|
84
|
+
|
|
85
|
+
parse_response(raw.chomp)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_response(raw)
|
|
89
|
+
begin
|
|
90
|
+
resp = JSON.parse(raw)
|
|
91
|
+
rescue JSON::ParserError => e
|
|
92
|
+
raise ProtocolError, "malformed JSON: #{e.message}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
unless resp.is_a?(Hash)
|
|
96
|
+
raise ProtocolError, "expected JSON object, got #{resp.class}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
ok = resp['ok']
|
|
100
|
+
data = resp['data']
|
|
101
|
+
age_ms = (resp['age_ms'] || 0).to_i
|
|
102
|
+
stale = resp['stale'] == true
|
|
103
|
+
error = resp['error']
|
|
104
|
+
|
|
105
|
+
unless ok
|
|
106
|
+
raise ServerError, (error || 'unknown error')
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
Result.new(ok: ok, data: data, age_ms: age_ms, stale: stale, error: error)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Client sends individual requests, opening a fresh socket connection for
|
|
114
|
+
# each call. For workloads that issue many queries per invocation, use
|
|
115
|
+
# {#session} to reuse a persistent connection.
|
|
116
|
+
#
|
|
117
|
+
# Examples:
|
|
118
|
+
#
|
|
119
|
+
# client = Beachcomber::Client.new
|
|
120
|
+
# result = client.get('git.branch', path: '/repo')
|
|
121
|
+
# puts result.data if result.hit?
|
|
122
|
+
class Client
|
|
123
|
+
# @param socket_path [String, nil] explicit socket path; auto-discovered when nil
|
|
124
|
+
# @param timeout [Numeric] connect/read timeout in seconds (default 0.1)
|
|
125
|
+
def initialize(socket_path: nil, timeout: DEFAULT_TIMEOUT)
|
|
126
|
+
@socket_path = socket_path || Discovery.socket_path
|
|
127
|
+
@timeout = timeout
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Reads a cached value.
|
|
131
|
+
#
|
|
132
|
+
# @param key [String] e.g. "git.branch" or "git"
|
|
133
|
+
# @param path [String, nil] optional working-directory context
|
|
134
|
+
# @return [Result]
|
|
135
|
+
# @raise [DaemonNotRunning] when the socket cannot be reached
|
|
136
|
+
# @raise [ServerError] when the daemon returns ok: false
|
|
137
|
+
def get(key, path: nil)
|
|
138
|
+
req = { op: 'get', key: key }
|
|
139
|
+
req[:path] = path if path
|
|
140
|
+
roundtrip(req)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Forces the daemon to recompute a provider/key.
|
|
144
|
+
#
|
|
145
|
+
# @param key [String]
|
|
146
|
+
# @param path [String, nil]
|
|
147
|
+
# @raise [DaemonNotRunning]
|
|
148
|
+
# @raise [ServerError]
|
|
149
|
+
def poke(key, path: nil)
|
|
150
|
+
req = { op: 'poke', key: key }
|
|
151
|
+
req[:path] = path if path
|
|
152
|
+
roundtrip(req)
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Lists available providers registered with the daemon.
|
|
157
|
+
#
|
|
158
|
+
# @return [Result]
|
|
159
|
+
def list
|
|
160
|
+
roundtrip({ op: 'list' })
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Returns scheduler and cache status from the daemon.
|
|
164
|
+
#
|
|
165
|
+
# @return [Result]
|
|
166
|
+
def status
|
|
167
|
+
roundtrip({ op: 'status' })
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Opens a persistent session and yields it to the block. The connection is
|
|
171
|
+
# closed automatically when the block returns (even on exception).
|
|
172
|
+
#
|
|
173
|
+
# @yield [Session]
|
|
174
|
+
# @return the block's return value
|
|
175
|
+
def session
|
|
176
|
+
sock = dial
|
|
177
|
+
session = Session.new(sock, @timeout)
|
|
178
|
+
yield session
|
|
179
|
+
ensure
|
|
180
|
+
session&.close
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
private
|
|
184
|
+
|
|
185
|
+
def roundtrip(req)
|
|
186
|
+
sock = dial
|
|
187
|
+
begin
|
|
188
|
+
s = Session.new(sock, @timeout)
|
|
189
|
+
s.send(:roundtrip, req)
|
|
190
|
+
ensure
|
|
191
|
+
sock.close unless sock.closed?
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def dial
|
|
196
|
+
sock = Socket.new(:UNIX, :STREAM)
|
|
197
|
+
addr = Socket.pack_sockaddr_un(@socket_path)
|
|
198
|
+
|
|
199
|
+
# Apply timeout to both connect and subsequent reads/writes.
|
|
200
|
+
sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDTIMEO, timeval(@timeout))
|
|
201
|
+
sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, timeval(@timeout))
|
|
202
|
+
|
|
203
|
+
begin
|
|
204
|
+
sock.connect(addr)
|
|
205
|
+
rescue Errno::ENOENT, Errno::ECONNREFUSED, Errno::EACCES
|
|
206
|
+
sock.close
|
|
207
|
+
raise DaemonNotRunning.new(@socket_path)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
sock
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Packs a Float (seconds) into the C timeval structure expected by setsockopt.
|
|
214
|
+
def timeval(seconds)
|
|
215
|
+
secs = seconds.to_i
|
|
216
|
+
usecs = ((seconds - secs) * 1_000_000).to_i
|
|
217
|
+
[secs, usecs].pack('l_2')
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
require 'etc'
|
|
2
|
+
|
|
3
|
+
module Beachcomber
|
|
4
|
+
# Discovers the Unix socket path for the beachcomber daemon.
|
|
5
|
+
#
|
|
6
|
+
# Discovery order:
|
|
7
|
+
# 1. $XDG_RUNTIME_DIR/beachcomber/sock (if set and the path exists)
|
|
8
|
+
# 2. $TMPDIR/beachcomber-<uid>/sock
|
|
9
|
+
# 3. /tmp/beachcomber-<uid>/sock
|
|
10
|
+
module Discovery
|
|
11
|
+
# @return [String] the discovered or best-guess socket path
|
|
12
|
+
def self.socket_path
|
|
13
|
+
xdg = ENV['XDG_RUNTIME_DIR']
|
|
14
|
+
if xdg && !xdg.empty?
|
|
15
|
+
candidate = File.join(xdg, 'beachcomber', 'sock')
|
|
16
|
+
return candidate if File.exist?(candidate)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
uid = Process.uid
|
|
20
|
+
dir = "beachcomber-#{uid}"
|
|
21
|
+
base = ENV.fetch('TMPDIR', nil) || '/tmp'
|
|
22
|
+
File.join(base, dir, 'sock')
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Beachcomber
|
|
2
|
+
# Base class for all Beachcomber errors.
|
|
3
|
+
class Error < StandardError; end
|
|
4
|
+
|
|
5
|
+
# Raised when the daemon socket cannot be reached.
|
|
6
|
+
class DaemonNotRunning < Error
|
|
7
|
+
def initialize(socket_path)
|
|
8
|
+
super("beachcomber daemon is not running (socket: #{socket_path})")
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Raised when the daemon responds with ok: false.
|
|
13
|
+
class ServerError < Error
|
|
14
|
+
attr_reader :message
|
|
15
|
+
|
|
16
|
+
def initialize(message)
|
|
17
|
+
@message = message
|
|
18
|
+
super("beachcomber: daemon error: #{message}")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Raised when a response cannot be parsed.
|
|
23
|
+
class ProtocolError < Error
|
|
24
|
+
def initialize(detail)
|
|
25
|
+
super("beachcomber: protocol error: #{detail}")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
module Beachcomber
|
|
2
|
+
# Wraps a daemon response.
|
|
3
|
+
#
|
|
4
|
+
# A result can represent a cache hit (data present), a miss (ok but no data),
|
|
5
|
+
# or an error (ok: false — though errors are normally raised as ServerError).
|
|
6
|
+
#
|
|
7
|
+
# Examples:
|
|
8
|
+
#
|
|
9
|
+
# result = client.get('git.branch', path: '/repo')
|
|
10
|
+
# if result.hit?
|
|
11
|
+
# puts result.data # "main"
|
|
12
|
+
# puts result.age_ms # 42
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# # hash data access
|
|
16
|
+
# result = client.get('git', path: '/repo')
|
|
17
|
+
# puts result['branch']
|
|
18
|
+
class Result
|
|
19
|
+
# @return [Boolean] whether the daemon reported success
|
|
20
|
+
attr_reader :ok
|
|
21
|
+
|
|
22
|
+
# @return [Object, nil] decoded payload (String, Integer, Hash, Array, etc.)
|
|
23
|
+
attr_reader :data
|
|
24
|
+
|
|
25
|
+
# @return [Integer] age of the cached value in milliseconds
|
|
26
|
+
attr_reader :age_ms
|
|
27
|
+
|
|
28
|
+
# @return [String, nil] error message when ok is false
|
|
29
|
+
attr_reader :error
|
|
30
|
+
|
|
31
|
+
def initialize(ok:, data:, age_ms:, stale:, error:)
|
|
32
|
+
@ok = ok
|
|
33
|
+
@data = data
|
|
34
|
+
@age_ms = age_ms
|
|
35
|
+
@stale = stale
|
|
36
|
+
@error = error
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Boolean]
|
|
40
|
+
def ok?
|
|
41
|
+
@ok
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Boolean] true when the response carried data (cache hit)
|
|
45
|
+
def hit?
|
|
46
|
+
@ok && !@data.nil?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Boolean] true when the response was successful but had no data
|
|
50
|
+
def miss?
|
|
51
|
+
@ok && @data.nil?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Boolean] true when the cached value is stale
|
|
55
|
+
def stale?
|
|
56
|
+
@stale
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Delegates field access to the data hash.
|
|
60
|
+
#
|
|
61
|
+
# @param key [String] field name
|
|
62
|
+
# @return [Object, nil]
|
|
63
|
+
# @raise [TypeError] if data is not a Hash
|
|
64
|
+
def [](key)
|
|
65
|
+
unless @data.is_a?(Hash)
|
|
66
|
+
raise TypeError, "Result#[] requires hash data, got #{@data.class}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
@data[key]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def inspect
|
|
73
|
+
parts = ["ok=#{@ok}"]
|
|
74
|
+
parts << "data=#{@data.inspect}" unless @data.nil?
|
|
75
|
+
parts << "age_ms=#{@age_ms}" if @age_ms > 0
|
|
76
|
+
parts << "stale=true" if @stale
|
|
77
|
+
parts << "error=#{@error.inspect}" if @error
|
|
78
|
+
"#<Beachcomber::Result #{parts.join(', ')}>"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
data/lib/beachcomber.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require_relative 'beachcomber/errors'
|
|
2
|
+
require_relative 'beachcomber/result'
|
|
3
|
+
require_relative 'beachcomber/discovery'
|
|
4
|
+
require_relative 'beachcomber/client'
|
|
5
|
+
|
|
6
|
+
# Beachcomber is a Ruby client for the beachcomber daemon.
|
|
7
|
+
#
|
|
8
|
+
# The daemon caches shell-environment data (git state, hostname, battery, …)
|
|
9
|
+
# and serves it over a Unix domain socket using newline-delimited JSON.
|
|
10
|
+
#
|
|
11
|
+
# Quick start:
|
|
12
|
+
#
|
|
13
|
+
# require 'beachcomber'
|
|
14
|
+
#
|
|
15
|
+
# client = Beachcomber::Client.new
|
|
16
|
+
# result = client.get('git.branch', path: '/path/to/repo')
|
|
17
|
+
# puts result.data if result.hit?
|
|
18
|
+
#
|
|
19
|
+
# Persistent session (one connection, multiple queries):
|
|
20
|
+
#
|
|
21
|
+
# client.session do |s|
|
|
22
|
+
# s.set_context('/path/to/repo')
|
|
23
|
+
# r1 = s.get('git.branch')
|
|
24
|
+
# r2 = s.get('git.dirty')
|
|
25
|
+
# end
|
|
26
|
+
module Beachcomber
|
|
27
|
+
VERSION = '0.1.0'
|
|
28
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: libbeachcomber
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- NavistAu
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-04-01 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Communicates with the beachcomber daemon over a Unix domain socket to
|
|
14
|
+
query cached shell-environment data (git state, hostname, battery, etc.).
|
|
15
|
+
email:
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- lib/beachcomber.rb
|
|
21
|
+
- lib/beachcomber/client.rb
|
|
22
|
+
- lib/beachcomber/discovery.rb
|
|
23
|
+
- lib/beachcomber/errors.rb
|
|
24
|
+
- lib/beachcomber/result.rb
|
|
25
|
+
homepage: https://github.com/NavistAu/beachcomber
|
|
26
|
+
licenses:
|
|
27
|
+
- MIT
|
|
28
|
+
metadata:
|
|
29
|
+
source_code_uri: https://github.com/NavistAu/beachcomber/tree/main/sdks/ruby
|
|
30
|
+
bug_tracker_uri: https://github.com/NavistAu/beachcomber/issues
|
|
31
|
+
changelog_uri: https://github.com/NavistAu/beachcomber/blob/main/CHANGELOG.md
|
|
32
|
+
post_install_message:
|
|
33
|
+
rdoc_options: []
|
|
34
|
+
require_paths:
|
|
35
|
+
- lib
|
|
36
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '3.0'
|
|
41
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - ">="
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '0'
|
|
46
|
+
requirements: []
|
|
47
|
+
rubygems_version: 3.5.22
|
|
48
|
+
signing_key:
|
|
49
|
+
specification_version: 4
|
|
50
|
+
summary: Ruby client for the beachcomber shell-data daemon
|
|
51
|
+
test_files: []
|