Skip to content

Commit 889df50

Browse files
fix(control-plane): negotiate stale daemons safely (#354)
* fix(control-plane): negotiate stale daemons safely * chore(release): v0.10.27-rc.0 * fix(client): harden control-plane recovery --------- Co-authored-by: GitHub Actions <actions@github.com>
1 parent a29bc00 commit 889df50

21 files changed

Lines changed: 850 additions & 117 deletions

File tree

cmd/relayfile-cli/control_plane.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,8 +270,15 @@ func handleControlPlaneHello(w http.ResponseWriter, r *http.Request) {
270270
writeControlPlaneError(w, http.StatusMethodNotAllowed, controlPlaneErrInvalidArgument, "method not allowed")
271271
return
272272
}
273+
// GET /v1/hello is the discovery endpoint. It must answer regardless of the
274+
// caller's requested API version so newer clients can inspect an older
275+
// daemon's supportedApiVersions and report (or repair) incompatibility.
276+
if r.Method == http.MethodGet {
277+
writeControlPlaneJSON(w, http.StatusOK, controlPlaneHello())
278+
return
279+
}
273280
var requested uint32
274-
if r.Method == http.MethodPost && r.Body != nil {
281+
if r.Body != nil {
275282
var req helloRequest
276283
if err := decodeControlPlaneJSON(r, &req); err != nil {
277284
writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, err.Error())

cmd/relayfile-cli/control_plane_test.go

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,22 @@ func TestControlPlaneHelloVersionAndCLIVersion(t *testing.T) {
4242
t.Fatalf("unexpected supported API versions: %#v", hello.SupportedAPIVersions)
4343
}
4444

45+
var discovery helloResponse
46+
status = controlPlaneJSONWithRequestedVersion(t, client, http.MethodGet, baseURL+"/v1/hello?apiVersion=4", nil, &discovery, "4")
47+
if status != http.StatusOK {
48+
t.Fatalf("discovery hello status = %d, want %d", status, http.StatusOK)
49+
}
50+
if discovery.DaemonVersion != hello.DaemonVersion || discovery.APIVersion != hello.APIVersion {
51+
t.Fatalf("unexpected discovery hello response: %#v", discovery)
52+
}
53+
4554
var errResp map[string]controlPlaneError
46-
status = controlPlaneJSONWithoutVersion(t, client, http.MethodGet, baseURL+"/v1/hello?apiVersion=4", nil, &errResp)
55+
status = controlPlaneJSONWithoutVersion(t, client, http.MethodGet, baseURL+"/v1/integrations/providers?apiVersion=4", nil, &errResp)
4756
if status != http.StatusUpgradeRequired {
48-
t.Fatalf("incompatible hello status = %d, want %d", status, http.StatusUpgradeRequired)
57+
t.Fatalf("incompatible non-hello status = %d, want %d", status, http.StatusUpgradeRequired)
4958
}
5059
if errResp["error"].Code != controlPlaneErrVersionIncompatible {
51-
t.Fatalf("unexpected incompatible error: %#v", errResp)
60+
t.Fatalf("unexpected non-hello incompatible error: %#v", errResp)
5261
}
5362
}
5463

@@ -456,15 +465,15 @@ func startControlPlaneTestServer(t *testing.T) (*http.Client, string, func()) {
456465

457466
func controlPlaneJSON(t *testing.T, client *http.Client, method, url string, body any, out any) int {
458467
t.Helper()
459-
return controlPlaneJSONWithVersionHeader(t, client, method, url, body, out, true)
468+
return controlPlaneJSONWithRequestedVersion(t, client, method, url, body, out, "1")
460469
}
461470

462471
func controlPlaneJSONWithoutVersion(t *testing.T, client *http.Client, method, url string, body any, out any) int {
463472
t.Helper()
464-
return controlPlaneJSONWithVersionHeader(t, client, method, url, body, out, false)
473+
return controlPlaneJSONWithRequestedVersion(t, client, method, url, body, out, "")
465474
}
466475

467-
func controlPlaneJSONWithVersionHeader(t *testing.T, client *http.Client, method, url string, body any, out any, sendVersion bool) int {
476+
func controlPlaneJSONWithRequestedVersion(t *testing.T, client *http.Client, method, url string, body any, out any, version string) int {
468477
t.Helper()
469478
var reader *bytes.Reader
470479
if body == nil {
@@ -480,8 +489,8 @@ func controlPlaneJSONWithVersionHeader(t *testing.T, client *http.Client, method
480489
if err != nil {
481490
t.Fatalf("new request failed: %v", err)
482491
}
483-
if sendVersion {
484-
req.Header.Set("X-Relayfile-API-Version", "1")
492+
if version != "" {
493+
req.Header.Set("X-Relayfile-API-Version", version)
485494
}
486495
if body != nil {
487496
req.Header.Set("Content-Type", "application/json")

openapi/relayfile-control-plane-v1.openapi.yaml

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ info:
66
Versioned local control-plane contract for the relayfile daemon/CLI.
77
The service listens on a user-owned Unix domain socket
88
(`RELAYFILE_SOCK`, or `$XDG_RUNTIME_DIR/relayfile.sock`) and uses
9-
`X-Relayfile-API-Version: 2` for client compatibility checks.
9+
`X-Relayfile-API-Version: 3` for compatibility checks after discovery.
1010
servers:
1111
- url: http://relayfile.local
1212
description: Logical HTTP origin over the relayfile Unix domain socket.
@@ -15,18 +15,19 @@ paths:
1515
get:
1616
operationId: hello
1717
summary: Negotiate control-plane API compatibility
18-
parameters:
19-
- $ref: "#/components/parameters/ApiVersionHeader"
20-
- $ref: "#/components/parameters/ApiVersionQuery"
18+
description: |
19+
Unversioned discovery endpoint. The daemon always returns its current
20+
and supported API versions; any version header or query is ignored.
21+
No version parameters are declared here on purpose: discovery must stay
22+
callable by clients of any API version, including versions this contract
23+
does not know about.
2124
responses:
2225
"200":
2326
description: Daemon version and supported API versions
2427
content:
2528
application/json:
2629
schema:
2730
$ref: "#/components/schemas/HelloResponse"
28-
"426":
29-
$ref: "#/components/responses/VersionIncompatible"
3031
post:
3132
operationId: helloPost
3233
summary: Negotiate control-plane API compatibility
@@ -300,13 +301,6 @@ components:
300301
type: integer
301302
format: uint32
302303
const: 3
303-
ApiVersionQuery:
304-
name: apiVersion
305-
in: query
306-
required: false
307-
schema:
308-
type: integer
309-
format: uint32
310304
responses:
311305
VersionIncompatible:
312306
description: Client and daemon API versions are incompatible

package-lock.json

Lines changed: 134 additions & 16 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)