Skip to content

Commit b1f13d3

Browse files
committed
Add Plugin API v3.6.0 config reload support
Implement the Config Reload request/response pair from Plugin API v3.6.0 (Launcher v2.19.0), allowing the Launcher to request plugins to reload their configuration at runtime without restarting. Wire protocol: - Message type 202 for both request and response - Response includes errorType (enum) and errorMessage (string) - Non-streaming request-response pattern SDK changes: - Add ConfigReloadErrorType enum with Unknown/None/Load/Validate/Save - Add ConfigReloadRequest/ConfigReloadResponse protocol types - Add ConfigReloadablePlugin optional interface (ReloadConfig method) - Add ConfigReloadError type for classified error reporting - Auto-respond with success for plugins that don't implement the interface - Export RequestFromJSON for test access to protocol deserialization Testing: - Protocol serialization/deserialization tests - Handler dispatch tests (not implemented, success, typed error, plain error, wrapped error) - ConfigReloadErrorType.String() tests - Mock ResponseWriter support for config reload Documentation: - Update version references (v3.5 → v3.6) across docs - Add configuration reloading section to GUIDE.md - Update CHANGELOG.md with feature description
1 parent 520f684 commit b1f13d3

File tree

14 files changed

+473
-13
lines changed

14 files changed

+473
-13
lines changed

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Project Overview
44

5-
Go SDK for building Launcher plugins that integrate job schedulers (Slurm, Kubernetes, PBS, cloud platforms, etc.) with Posit Workbench and Posit Connect. Implements Launcher Plugin API v3.5.0.
5+
Go SDK for building Launcher plugins that integrate job schedulers (Slurm, Kubernetes, PBS, cloud platforms, etc.) with Posit Workbench and Posit Connect. Implements Launcher Plugin API v3.6.0.
66

77
**Language:** Go 1.24+
88
**License:** MIT

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- **Plugin API 3.6.0**: Config reload support. The Launcher can now request plugins to reload configuration at runtime without restarting.
12+
- `ConfigReloadablePlugin` optional interface in `launcher` package — plugins implement `ReloadConfig(ctx context.Context) error` to handle reload requests
13+
- `ConfigReloadError` type for classified reload failures (Load, Validate, Save)
14+
- `ConfigReloadErrorType` enum in `api` package with `String()` method
15+
- Plugins that do not implement `ConfigReloadablePlugin` automatically send a success response
16+
- `MockResponseWriter.WriteConfigReload` and `ConfigReloadResult` in `plugintest` for testing reload implementations
17+
- Protocol support for config reload request/response (message type 202) in `internal/protocol`
18+
- Exported `protocol.RequestFromJSON` for use in handler tests
19+
- Unit tests for `internal/protocol` and `launcher` packages
20+
1021
### Changed
1122
- **BREAKING**: `cache.NewJobCache` no longer accepts a `dir` parameter. The SDK now defaults to in-memory caching, which aligns with how Launcher plugins are expected to work: the scheduler owns job state, and plugins populate the cache during `Bootstrap()` and keep it in sync via periodic polling.
1223
- **BREAKING**: Add `context.Context` as the first parameter to all non-streaming `Plugin` methods (`SubmitJob`, `GetJob`, `GetJobs`, `ControlJob`, `GetJobNetwork`, `ClusterInfo`) and extension interfaces (`Bootstrap`, `GetClusters`). Streaming methods already accepted context.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ The Launcher Go SDK provides a complete framework for building plugins that conn
2222
- Conformance testing - Automated behavioral tests that verify your plugin against Posit product contracts
2323
- Testing utilities - Mock response writers, job builders, and assertion helpers
2424
- Comprehensive examples - In-memory example and scheduler design guide
25-
- Type-safe API - Strongly typed interfaces matching the Launcher Plugin API v3.5
25+
- Type-safe API - Strongly typed interfaces matching the Launcher Plugin API v3.6
2626

2727
## Quick start
2828

@@ -182,7 +182,7 @@ func TestSubmitJob(t *testing.T) {
182182

183183
## API version
184184

185-
This SDK implements **Launcher Plugin API v3.5.0**.
185+
This SDK implements **Launcher Plugin API v3.6.0**.
186186

187187
## Stability
188188

api/types.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -835,7 +835,41 @@ type Version struct {
835835

836836
// APIVersion is the Launcher plugin API version supported by the types defined
837837
// in this package.
838-
var APIVersion = Version{Major: 3, Minor: 5, Patch: 0}
838+
var APIVersion = Version{Major: 3, Minor: 6, Patch: 0}
839+
840+
// ConfigReloadErrorType classifies config reload errors.
841+
type ConfigReloadErrorType int
842+
843+
const (
844+
// ReloadErrorUnknown indicates an unclassified reload error.
845+
ReloadErrorUnknown ConfigReloadErrorType = -1
846+
// ReloadErrorNone indicates the reload succeeded.
847+
ReloadErrorNone ConfigReloadErrorType = 0
848+
// ReloadErrorLoad indicates an error loading configuration files.
849+
ReloadErrorLoad ConfigReloadErrorType = 1
850+
// ReloadErrorValidate indicates a validation error in the configuration.
851+
ReloadErrorValidate ConfigReloadErrorType = 2
852+
// ReloadErrorSave indicates an error saving configuration state.
853+
ReloadErrorSave ConfigReloadErrorType = 3
854+
)
855+
856+
// String returns a human-readable description of the error type.
857+
func (e ConfigReloadErrorType) String() string {
858+
switch e {
859+
case ReloadErrorUnknown:
860+
return "unknown error"
861+
case ReloadErrorNone:
862+
return "none"
863+
case ReloadErrorLoad:
864+
return "load error"
865+
case ReloadErrorValidate:
866+
return "validation error"
867+
case ReloadErrorSave:
868+
return "save error"
869+
default:
870+
return "unknown error"
871+
}
872+
}
839873

840874
// Parsing errors.
841875
var (

api/types_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,30 @@ func TestErrCode_String(t *testing.T) {
173173
}
174174
}
175175

176+
func TestConfigReloadErrorType_String(t *testing.T) {
177+
tests := []struct {
178+
name string
179+
code ConfigReloadErrorType
180+
want string
181+
}{
182+
{"unknown", ReloadErrorUnknown, "unknown error"},
183+
{"none", ReloadErrorNone, "none"},
184+
{"load", ReloadErrorLoad, "load error"},
185+
{"validate", ReloadErrorValidate, "validation error"},
186+
{"save", ReloadErrorSave, "save error"},
187+
{"invalid value", ConfigReloadErrorType(99), "unknown error"},
188+
}
189+
190+
for _, tt := range tests {
191+
t.Run(tt.name, func(t *testing.T) {
192+
got := tt.code.String()
193+
if got != tt.want {
194+
t.Errorf("String() = %q, want %q", got, tt.want)
195+
}
196+
})
197+
}
198+
}
199+
176200
func TestJobID(t *testing.T) {
177201
// Test that JobID is a distinct type
178202
var id JobID = "test-123"

docs/API.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -509,7 +509,7 @@ Container image configuration.
509509

510510
Import path: `github.com/posit-dev/launcher-go-sdk/api`
511511

512-
The api package contains all type definitions matching the Launcher Plugin API v3.5.
512+
The api package contains all type definitions matching the Launcher Plugin API v3.6.
513513

514514
### Type: Job
515515

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ type DefaultOptions struct { /* ... */ }
9898

9999
#### `api` - Type Definitions
100100

101-
Contains all types matching the Launcher Plugin API v3.5:
101+
Contains all types matching the Launcher Plugin API v3.6:
102102

103103
```go
104104
type Job struct { /* 30+ fields */ }

docs/GUIDE.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -977,6 +977,36 @@ func (p *MyPlugin) SyncNodes(nodes []api.Node) {
977977
}
978978
```
979979

980+
### Configuration reloading
981+
982+
The Launcher can ask plugins to reload their configuration at runtime without restarting (API v3.6.0+). At minimum, plugins should reload user profiles and resource profiles. Reloading additional configuration is permitted but not required.
983+
984+
Implement `ConfigReloadablePlugin` to support this:
985+
986+
```go
987+
func (p *MyPlugin) ReloadConfig(ctx context.Context) error {
988+
profiles, err := loadProfiles(p.profilePath)
989+
if err != nil {
990+
return &launcher.ConfigReloadError{
991+
Type: api.ReloadErrorLoad,
992+
Message: fmt.Sprintf("failed to load profiles: %v", err),
993+
}
994+
}
995+
if err := profiles.Validate(); err != nil {
996+
return &launcher.ConfigReloadError{
997+
Type: api.ReloadErrorValidate,
998+
Message: fmt.Sprintf("invalid profiles: %v", err),
999+
}
1000+
}
1001+
p.mu.Lock()
1002+
p.profiles = profiles
1003+
p.mu.Unlock()
1004+
return nil
1005+
}
1006+
```
1007+
1008+
Plugins that do not implement this interface automatically send a success response. Error types help the Launcher classify failures: `ReloadErrorLoad` for file read errors, `ReloadErrorValidate` for invalid configuration, and `ReloadErrorSave` for errors persisting state. Returning a plain `error` (instead of `*ConfigReloadError`) defaults to `ReloadErrorUnknown`.
1009+
9801010
### User profiles
9811011

9821012
System administrators may want to set default or maximum values for certain features on a per-user or per-group basis. For example, different groups of users could have different memory limits or CPU counts.

internal/protocol/rpc.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ type Request interface {
2020
Type() int
2121
}
2222

23-
func requestFromJSON(buf []byte) (Request, error) {
23+
// RequestFromJSON parses a JSON-encoded request message into the appropriate
24+
// concrete request type.
25+
func RequestFromJSON(buf []byte) (Request, error) {
2426
var base BaseRequest
2527
if err := json.Unmarshal(buf, &base); err != nil {
2628
return nil, fmt.Errorf("%w: %v", ErrMsgInvalid, err) //nolint:errorlint // intentionally wrapping only the sentinel error
@@ -75,6 +77,8 @@ func requestForType(rt requestType) (interface{}, error) {
7577
return &MultiClusterInfoRequest{}, nil
7678
case requestSetLoadBalancerNodes:
7779
return &SetLoadBalancerNodesRequest{}, nil
80+
case requestConfigReload:
81+
return &ConfigReloadRequest{}, nil
7882
default:
7983
return nil, fmt.Errorf("%w: %d", ErrUnknownRequestType, rt)
8084
}
@@ -95,6 +99,7 @@ const (
9599
requestClusterInfo
96100
requestMultiClusterInfo requestType = 17
97101
requestSetLoadBalancerNodes requestType = 201
102+
requestConfigReload requestType = 202
98103
)
99104

100105
// BaseRequest contains base fields shared by all request types.
@@ -207,6 +212,11 @@ type MultiClusterInfoRequest struct {
207212
BaseUserRequest
208213
}
209214

215+
// ConfigReloadRequest is the config reload request.
216+
type ConfigReloadRequest struct {
217+
BaseUserRequest
218+
}
219+
210220
type responseType int
211221

212222
const (
@@ -222,6 +232,7 @@ const (
222232
responseClusterInfo
223233
responseMultiClusterInfo responseType = 17
224234
responseSetLoadBalancerNodes responseType = 201
235+
responseConfigReload responseType = 202
225236
)
226237

227238
type responseBase struct {
@@ -440,3 +451,16 @@ func NewSetLoadBalancerNodesResponse(requestID, responseID uint64) *SetLoadBalan
440451
responseSetLoadBalancerNodes, requestID, responseID,
441452
}
442453
}
454+
455+
// ConfigReloadResponse is the config reload response.
456+
type ConfigReloadResponse struct {
457+
responseBase
458+
ErrorType api.ConfigReloadErrorType `json:"errorType"`
459+
ErrorMessage string `json:"errorMessage"`
460+
}
461+
462+
// NewConfigReloadResponse creates a new config reload response.
463+
func NewConfigReloadResponse(requestID, responseID uint64, errorType api.ConfigReloadErrorType, errorMessage string) *ConfigReloadResponse {
464+
base := responseBase{responseConfigReload, requestID, responseID}
465+
return &ConfigReloadResponse{responseBase: base, ErrorType: errorType, ErrorMessage: errorMessage}
466+
}

internal/protocol/rpc_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package protocol
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/posit-dev/launcher-go-sdk/api"
8+
)
9+
10+
func TestRequestFromJSON_ConfigReload(t *testing.T) {
11+
input := `{"messageType": 202, "requestId": 42, "requestUsername": "admin", "username": "testuser"}`
12+
13+
req, err := RequestFromJSON([]byte(input))
14+
if err != nil {
15+
t.Fatalf("RequestFromJSON() error = %v", err)
16+
}
17+
18+
cr, ok := req.(*ConfigReloadRequest)
19+
if !ok {
20+
t.Fatalf("expected *ConfigReloadRequest, got %T", req)
21+
}
22+
23+
if cr.ID() != 42 {
24+
t.Errorf("ID() = %d, want 42", cr.ID())
25+
}
26+
if cr.Username != "testuser" {
27+
t.Errorf("Username = %q, want %q", cr.Username, "testuser")
28+
}
29+
if cr.ReqUsername != "admin" {
30+
t.Errorf("ReqUsername = %q, want %q", cr.ReqUsername, "admin")
31+
}
32+
}
33+
34+
func TestNewConfigReloadResponse_Success(t *testing.T) {
35+
resp := NewConfigReloadResponse(42, 7, api.ReloadErrorNone, "")
36+
37+
data, err := json.Marshal(resp)
38+
if err != nil {
39+
t.Fatalf("json.Marshal() error = %v", err)
40+
}
41+
42+
var got map[string]interface{}
43+
if err := json.Unmarshal(data, &got); err != nil {
44+
t.Fatalf("json.Unmarshal() error = %v", err)
45+
}
46+
47+
if mt := int(got["messageType"].(float64)); mt != 202 {
48+
t.Errorf("messageType = %d, want 202", mt)
49+
}
50+
if rid := uint64(got["requestId"].(float64)); rid != 42 {
51+
t.Errorf("requestId = %d, want 42", rid)
52+
}
53+
if resID := uint64(got["responseId"].(float64)); resID != 7 {
54+
t.Errorf("responseId = %d, want 7", resID)
55+
}
56+
if et := int(got["errorType"].(float64)); et != 0 {
57+
t.Errorf("errorType = %d, want 0", et)
58+
}
59+
if em := got["errorMessage"].(string); em != "" {
60+
t.Errorf("errorMessage = %q, want empty", em)
61+
}
62+
}
63+
64+
func TestNewConfigReloadResponse_ErrorTypes(t *testing.T) {
65+
tests := []struct {
66+
name string
67+
errorType api.ConfigReloadErrorType
68+
wantCode int
69+
}{
70+
{"Unknown", api.ReloadErrorUnknown, -1},
71+
{"Load", api.ReloadErrorLoad, 1},
72+
{"Validate", api.ReloadErrorValidate, 2},
73+
{"Save", api.ReloadErrorSave, 3},
74+
}
75+
76+
for _, tt := range tests {
77+
t.Run(tt.name, func(t *testing.T) {
78+
resp := NewConfigReloadResponse(10, 3, tt.errorType, "error msg")
79+
80+
data, err := json.Marshal(resp)
81+
if err != nil {
82+
t.Fatalf("json.Marshal() error = %v", err)
83+
}
84+
85+
var got map[string]interface{}
86+
if err := json.Unmarshal(data, &got); err != nil {
87+
t.Fatalf("json.Unmarshal() error = %v", err)
88+
}
89+
90+
if et := int(got["errorType"].(float64)); et != tt.wantCode {
91+
t.Errorf("errorType = %d, want %d", et, tt.wantCode)
92+
}
93+
if em := got["errorMessage"].(string); em != "error msg" {
94+
t.Errorf("errorMessage = %q, want %q", em, "error msg")
95+
}
96+
})
97+
}
98+
}

0 commit comments

Comments
 (0)