Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions api_v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ API V3
* [watchcancel](#watchcancel)
* [readdir](#readdir)
* [watchdir](#watchdir)
* [create_ws_watch_session](#create_ws_watch_session)
* [rmdir](#rmdir)
* [txn](#txn)
* [version](#version)
Expand Down Expand Up @@ -236,6 +237,70 @@ local res, err = cli:watchdir('/path/to/dir')
[Back to TOP](#api-v3)


### create_ws_watch_session

`syntax: session, err = cli:create_ws_watch_session(dir:string [, opts:table])`

* `dir`: string value, the key prefix to watch (the range end is derived the same way as `watchdir`).
* `opts`: optional options.
* `timeout`: (int) default timeout seconds for `recv` when it is called without one.
* `start_revision`: (int) start_revision is an optional revision to watch from (inclusive). No start_revision is "now".
* `progress_notify`: (bool) ask the etcd server to periodically send a WatchResponse with no events.
* `filters`: (slice of [enum FilterType {NOPUT = 0;NODELETE = 1;}]) filters filter the events at server side before it sends back to the watcher.
* `prev_kv`: (bool) If prev_kv is set, created watcher gets the previous KV before the event happens.
* `watch_id`: (int) If watch_id is provided and non-zero, it will be assigned to this watcher.
* `max_payload_len`: (int) maximal length of a single WebSocket frame accepted from etcd, defaults to 32MB.

Watch a key prefix over a full-duplex WebSocket stream to etcd's JSON gateway.

etcd's gateway is half-duplex over plain HTTP/1.1: the server sends no response
bytes while the request body is still open, so a watch opened with `watchdir`
can never write to the stream again. etcd also wraps `/v3/` in
grpc-websocket-proxy, and a WebSocket upgrade on `/v3/watch` carries one
WatchRequest per text frame in and one WatchResponse per text frame out. That
makes `WatchProgressRequest` usable on a live watch: its reply proves this very
stream already delivered every event up to the reported revision, which is safe
to resume from after a reconnect, unlike a revision learned from a second
connection.

The returned session has three methods:

* `res, err = session:recv(timeout)`: waits up to `timeout` seconds for the
next WatchResponse, decoded exactly like a `watchdir` response. `err` is
`"timeout"` when nothing arrived in time and `"closed"` when the stream is
gone.
* `ok, err = session:request_progress()`: sends a `progress_request` on the
live stream. The reply arrives via `recv()` as a WatchResponse without
`events`; its `header.revision` is the delivery barrier. etcd only answers
once every watcher on the stream is synced, so a request sent immediately
after session creation may be dropped (etcd >= 3.6) and should be retried.
* `session:close()`: closes the stream.

Session creation performs the upgrade, sends the `create_request`, and waits
for the first WatchResponse; an endpoint that cannot upgrade (for example a
proxy that strips the `Upgrade` header) is therefore rejected here, so callers
can fall back to `watchdir`. The consumed response is returned by the first
`recv()` call.

Note: requires the etcd server to answer progress requests (etcd >= 3.4), and
etcd >= 3.5 for watch responses larger than 64KB (older gateways buffer
response lines with a fixed 64KB limit).

```lua
local session, err = cli:create_ws_watch_session('/path/to/dir', {start_revision = rev})
local res, err = session:recv(50)
if not res and err == "timeout" then
session:request_progress()
local progress = session:recv(3)
if progress and not progress.result.events then
rev = tonumber(progress.result.header.revision) + 1
end
end
```

[Back to TOP](#api-v3)


### rmdir

`syntax: res, err = cli:rmdir(dir:string [, opts:table])`
Expand Down
194 changes: 194 additions & 0 deletions lib/resty/etcd/v3.lua
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ local semaphore = require("ngx.semaphore")
local health_check = require("resty.etcd.health_check")
local pl_path = require("pl.path")
local grpc_proto = require("resty.etcd.proto")
local ws_client = require("resty.websocket.client")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# List rockspec files and their dependency blocks.
fd -e rockspec --exec sh -c 'echo "== {}"; cat -n "{}"'
# Check whether the websocket lib is referenced anywhere in packaging or CI.
rg -n 'lua-resty-websocket|resty\.websocket' --glob '!lib/**'

Repository: api7/lua-resty-etcd

Length of output: 41762


🏁 Script executed:

#!/bin/bash
set -eu

echo '== websocket require and module header =='
cat -n lib/resty/etcd/v3.lua | sed -n '1,70p'

echo '== module imports and v3 call sites =='
rg -n 'require\(|resty\.etcd\.v3|create_ws_watch_session|ws_client' lib spec t 2>/dev/null || true

echo '== current rockspec candidates =='
fd -e rockspec -x sh -c 'grep -Hn -A8 -B2 "^dependencies" "$1"' sh {}

Repository: api7/lua-resty-etcd

Length of output: 35786


Declare lua-resty-websocket or lazy-load the client.

resty.etcd loads resty.etcd.v3, which requires resty.websocket.client at module load time. Without the dependency, existing API calls fail before they run. Add lua-resty-websocket to the rockspec, or move the require into create_ws_watch_session.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/resty/etcd/v3.lua` at line 34, Update the module-level websocket
dependency around ws_client and create_ws_watch_session: either declare
lua-resty-websocket in the rockspec, or move the
require("resty.websocket.client") into create_ws_watch_session so resty.etcd.v3
can load without the optional dependency. Prefer lazy-loading if websocket
support is only needed for watch sessions.

math.randomseed(now() * 1000 + ngx.worker.pid())

local INIT_COUNT_RESIZE = 2e8
Expand Down Expand Up @@ -1029,6 +1030,199 @@ local function create_watch_request(key, attr)
end


-- WebSocket watch session.
--
-- etcd's JSON gateway is half-duplex over plain HTTP/1.1: Go's net/http
-- server emits no response bytes while the request body is still open.
-- etcd also wraps /v3/ in grpc-websocket-proxy, so a WebSocket upgrade on
-- /v3/watch gives a full-duplex stream: one WatchRequest per text frame in,
-- one WatchResponse per text frame out. That makes in-stream
-- progress_request usable as a delivery barrier (apache/apisix#13777).

local ws_session_mt = {}
ws_session_mt.__index = ws_session_mt


local function ws_decode_frame(self, frame)
local body, err = decode_json(frame)
if not body then
return nil, "failed to decode json body: " .. (err or " unknown")
end

if body.error and body.error.http_code and body.error.http_code >= 500 then
health_check.report_failure(self.endpoint.http_host)
return nil, self.endpoint.http_host .. ": "
.. (body.error.http_status or body.error.http_code)
end

if body.result and body.result.events then
for _, event in ipairs(body.result.events) do
if event.kv.value then -- DELETE not have value
event.kv.value = decode_base64(event.kv.value or "")
event.kv.value = self.cli.serializer.deserialize(event.kv.value)
end
event.kv.key = decode_base64(event.kv.key)
if event.prev_kv then
event.prev_kv.value = decode_base64(event.prev_kv.value or "")
event.prev_kv.value = self.cli.serializer.deserialize(event.prev_kv.value)
event.prev_kv.key = decode_base64(event.prev_kv.key)
end
end
end

return body
end


-- returns one decoded WatchResponse, or nil + "timeout"/"closed"/error
function ws_session_mt.recv(self, timeout)
if self.pending then
local res = self.pending
self.pending = nil
return res
end

local ws = self.ws
ws:set_timeout((timeout or self.timeout) * 1000)

local buf
while true do
local data, typ, err = ws:recv_frame()
if not data then
if err and str_find(err, "timeout", 1, true) then
return nil, "timeout"
end
return nil, err or "closed"
end

if typ == "text" or typ == "binary" or typ == "continuation" then
if err == "again" then -- fragmented frame, more to come
buf = (buf or "") .. data
else
if buf then
data = buf .. data
end
return ws_decode_frame(self, data)
end
elseif typ == "ping" then
ws:send_pong(data)
elseif typ == "close" then
return nil, "closed"
end
-- pong or unknown frame: keep reading
end
end


-- asks etcd how far this stream has delivered; the reply arrives via recv()
-- as a WatchResponse with no events
function ws_session_mt.request_progress(self)
local bytes, err = self.ws:send_text('{"progress_request":{}}')
if not bytes then
return nil, err
end
return true
end


function ws_session_mt.close(self)
return self.ws:close()
end


function _M.create_ws_watch_session(self, key, opts)
if self.unix_socket_proxy then
return nil, "websocket watch does not support unix socket proxy"
end

key = utils.get_real_key(self.key_prefix, key)

local attr = {
range_end = get_range_end(key),
start_revision = opts and opts.start_revision,
progress_notify = opts and opts.progress_notify,
filters = opts and opts.filters,
prev_kv = opts and opts.prev_kv,
watch_id = opts and opts.watch_id,
}

local create_request = create_watch_request(key, attr)
create_request.key = encode_base64(key)
create_request.range_end = encode_base64(attr.range_end)
Comment on lines +1149 to +1151

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Encode the key produced by create_watch_request.

create_watch_request replaces an empty key with str_char(0). Line 1151 base64-encodes the outer key variable instead, so that substitution is discarded and an empty key is sent as "". Encode the value already stored in create_request.

🐛 Proposed fix
     local create_request = create_watch_request(key, attr)
-    create_request.key = encode_base64(key)
-    create_request.range_end = encode_base64(attr.range_end)
+    create_request.key = encode_base64(create_request.key)
+    create_request.range_end = encode_base64(create_request.range_end or "")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
local create_request = create_watch_request(key, attr)
create_request.key = encode_base64(key)
create_request.range_end = encode_base64(attr.range_end)
local create_request = create_watch_request(key, attr)
create_request.key = encode_base64(create_request.key)
create_request.range_end = encode_base64(create_request.range_end or "")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/resty/etcd/v3.lua` around lines 1150 - 1152, Update the key assignment in
the watch request construction after create_watch_request to base64-encode
create_request.key rather than the outer key variable, preserving the helper’s
empty-key substitution before sending the request.


local endpoint, err = choose_endpoint(self)
if not endpoint then
return nil, err
end

local conn_opts = {}
if self.is_auth then
local _, auth_err = refresh_jwt_token(self, (opts and opts.timeout) or self.timeout)
if auth_err then
return nil, auth_err
end
-- grpc-websocket-proxy turns this subprotocol into an Authorization header
conn_opts.protocols = "Bearer," .. self.jwt_token
end

local scheme = "ws"
if endpoint.scheme == "https" then
scheme = "wss"
conn_opts.ssl_verify = self.ssl_verify
conn_opts.server_name = self.sni or endpoint.host
conn_opts.client_cert = self.ssl_cert
conn_opts.client_priv_key = self.ssl_key
end

local ws, new_err = ws_client:new({
max_payload_len = (opts and opts.max_payload_len) or 32 * 1024 * 1024,
})
if not ws then
return nil, new_err
end

local uri = scheme .. "://" .. endpoint.address .. ":" .. endpoint.port
.. endpoint.api_prefix .. "/watch"

local ok, conn_err = ws:connect(uri, conn_opts)
if not ok then
health_check.report_failure(endpoint.http_host)
return nil, endpoint.http_host .. ": " .. conn_err
end
Comment on lines +1168 to +1191

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 2. endpoint field names produced by the endpoint constructor / choose_endpoint.
rg -n -C6 'api_prefix|http_host\s*=|address\s*=' lib/resty/etcd/ | sed -n '1,120p'
# 3. health_check usage symmetry.
rg -n -C3 'health_check\.(report_failure|report_success)' lib/resty/etcd/

Repository: api7/lua-resty-etcd

Length of output: 10264


🌐 Web query:

lua-resty-websocket client connect options client_cert client_priv_key server_name ssl_verify supported version

💡 Result:

In lua-resty-websocket, the client:connect method accepts an optional table of parameters to configure the connection, including support for SSL/TLS settings [1][2]. The following options are supported: ssl_verify: A boolean that specifies whether to perform SSL certificate verification during the SSL handshake when using the wss:// scheme [3]. client_cert: Specifies a client certificate chain cdata object to be used during the TLS handshake [3]. This requires the use of the ngx.ssl.parse_pem_cert function from lua-resty-core to create the object [3]. If client_cert is provided, client_priv_key must also be provided [4][3]. client_priv_key: Specifies the private key corresponding to the client_cert option [3]. This object can be created using the ngx.ssl.parse_pem_priv_key function from lua-resty-core [3]. server_name: Specifies the server name (SNI) to be used during the SSL/TLS handshake with the remote server [5]. Additional supported connection options include: protocols: Used to specify the Sec-WebSocket-Protocol header [4]. origin: Used to specify the Origin header [4]. pool and pool_size: Control connection pooling behavior [1][2]. backlog: Sets a limit on the number of waiting connect operations when the pool is full [1][3]. Regarding supported versions, the library generally requires OpenResty with an Nginx version that supports the necessary cosocket and SSL features (specifically ngx_lua 0.9.11+ is required for SSL/wss support) [4]. Version-specific features such as pool and backlog management were introduced in v0.10.14 [1][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- dependency declarations ---'
rg -n -C4 'lua-resty-websocket|resty.websocket|websocket' \
  .github Makefile README.md *.rockspec *.rock 2>/dev/null || true

printf '%s\n' '--- health-check implementation and success API ---'
fd -t f . lib | sort | grep -E 'health|etcd|resty'
rg -n -C8 'function .*report_(success|failure)|report_success|report_failure' lib

printf '%s\n' '--- endpoint selection and websocket session context ---'
rg -n -C12 'choose_endpoint|create_ws_watch_session|ws_client:new|ws:connect' lib/resty/etcd/v3.lua

Repository: api7/lua-resty-etcd

Length of output: 16960


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- tracked dependency and packaging files ---'
git ls-files | grep -Ei '(^|/)(rockspec|rock|manifest|makefile|cmake|docker|deps|requirements|.*lock)|websocket' | sed -n '1,160p'

printf '%s\n' '--- all websocket dependency references ---'
rg -n -i -C3 'lua-resty-websocket|resty\.websocket|websocket' . --glob '!*.md' --glob '!t/data/**' | sed -n '1,220p'

printf '%s\n' '--- health-check module ---'
cat -n lib/resty/etcd/health_check.lua | sed -n '1,190p'

printf '%s\n' '--- websocket watch call path after connect ---'
cat -n lib/resty/etcd/v3.lua | sed -n '1128,1235p'

Repository: api7/lua-resty-etcd

Length of output: 15723


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- websocket dependency declarations in rockspecs ---'
for f in $(git ls-files 'rockspec/*.rockspec'); do
    if grep -qi 'websocket' "$f"; then
        printf '\n[%s]\n' "$f"
        grep -in -C3 'websocket' "$f"
    fi
done

printf '%s\n' '--- current build and test dependency metadata ---'
cat -n Makefile | sed -n '1,180p'
cat -n rockspec/lua-resty-etcd-master-0.1-0.rockspec | sed -n '1,180p'

printf '%s\n' '--- repository revision and changed-file context ---'
git status --short
git branch --show-current
git rev-parse --short HEAD

Repository: api7/lua-resty-etcd

Length of output: 2781


🌐 Web query:

site:github.com/openresty/lua-resty-websocket "max_payload_len" "client_priv_key"

💡 Result:

In the context of the openresty/lua-resty-websocket library, max_payload_len and client_priv_key are configuration options used when initializing WebSocket connections [1][2]. max_payload_len This parameter defines the maximum length of the payload allowed for both sending and receiving WebSocket frames [2][3]. It defaults to 65535 [2][4]. When a frame exceeds this limit, the library will reject it [5]. Related configuration options include max_recv_len (defaults to max_payload_len) and max_send_len (defaults to max_payload_len) [1][2][4]. client_priv_key This parameter specifies the private key used during the TLS handshake for secure (WSS) WebSocket connections [2][3]. It must be provided alongside the client_cert option [1][2]. The private key object is typically created using the ngx.ssl.parse_pem_priv_key function from the lua-resty-core library [2][3]. If client_cert is provided, the library asserts that client_priv_key is also present [1]. It is subsequently used in the socket's TLS configuration (e.g., via sock:setclientcert) during the handshake process [1][6].

Citations:


🌐 Web query:

site:github.com/openresty/lua-resty-websocket/releases lua-resty-websocket version client_priv_key server_name

💡 Result:

The lua-resty-websocket library does not natively support specific configuration parameters named client_priv_key or server_name in its connection methods [1]. Based on the available documentation, there is no evidence that these parameters exist within the library's API. Generally, SSL/TLS configurations for OpenResty libraries are handled by the underlying lua-resty-core or Nginx stream/http SSL directives rather than the websocket library itself.

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- upstream websocket client option implementation ---'
curl -fsSL https://raw.githubusercontent.com/openresty/lua-resty-websocket/master/lib/resty/websocket/client.lua \
  | nl -ba | grep -n -C5 -E 'max_payload_len|ssl_verify|server_name|client_cert|client_priv_key|function _M:new|function _M:connect' \
  | sed -n '1,240p'

printf '%s\n' '--- upstream websocket release tags ---'
curl -fsSL 'https://api.github.com/repos/openresty/lua-resty-websocket/tags?per_page=100' \
  | jq -r '.[] | [.name, .commit.sha] | `@tsv`' | sed -n '1,120p'

Repository: api7/lua-resty-etcd

Length of output: 1271


🏁 Script executed:

#!/bin/bash
set -eu

base='https://raw.githubusercontent.com/openresty/lua-resty-websocket'
for tag in v0.01 v0.02 v0.03 v0.04 v0.05 v0.06 v0.07 v0.08 v0.09 v0.10 v0.11 v0.12 v0.13 v0.14 master; do
    url="$base/$tag/lib/resty/websocket/client.lua"
    body=$(curl -fsSL "$url" 2>/dev/null || true)
    printf '\n[%s]\n' "$tag"
    printf '%s\n' "$body" | grep -n -E 'max_payload_len|ssl_verify|server_name|client_cert|client_priv_key|function .*connect|function .*new' || true
done

printf '\n--- README option references ---\n'
curl -fsSL "$base/master/README.markdown" \
  | grep -n -C4 -E 'max_payload_len|ssl_verify|server_name|client_cert|client_priv_key' \
  | sed -n '1,220p'

Repository: api7/lua-resty-etcd

Length of output: 16777


Declare and pin lua-resty-websocket to v0.10 or later. The rockspecs do not declare this dependency. max_payload_len is widely supported, but server_name, client_cert, and client_priv_key are unsupported before v0.10 and are ignored by older clients, which can bypass configured SNI and mTLS. The endpoint fields are populated correctly, and health entries recover when fail_timeout expires; no report_success call is required.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/resty/etcd/v3.lua` around lines 1169 - 1192, Declare and pin the
lua-resty-websocket dependency to version 0.10 or later in the project’s
rockspec configuration. Preserve the existing ws_client:new options in the
WebSocket connection flow so server_name, client_cert, and client_priv_key are
honored.


local req_body, encode_err = encode_json({create_request = create_request})
if not req_body then
ws:close()
return nil, encode_err
end

local bytes, send_err = ws:send_text(req_body)
if not bytes then
ws:close()
return nil, send_err
end

local session = setmetatable({
cli = self,
ws = ws,
endpoint = endpoint,
timeout = (opts and opts.timeout) or self.timeout,
}, ws_session_mt)

-- resty.websocket.client accepts any HTTP/1.1 status line as a handshake,
-- so an intermediary that stripped the Upgrade header still "connects".
-- Only a real WatchResponse proves the stream works; keep it for recv().
local first, ferr = session:recv()
if not first then
ws:close()
return nil, "websocket watch stream unusable: " .. (ferr or "unknown")
end
session.pending = first

return session
end


local get_grpc_metadata
do
local metadata = {
Expand Down
Loading
Loading