Skip to content

Commit b92fc5a

Browse files
authored
Add Plugin API v3.6.0 config reload support (#5)
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 b92fc5a

File tree

14 files changed

+511
-13
lines changed

14 files changed

+511
-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: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -977,6 +977,74 @@ 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+
1010+
**Best practice: preserve last-known-good configuration.** When reloading file-based configuration, only replace your in-memory state after the new files have been successfully loaded and validated. This ensures that a malformed config file doesn't leave the plugin in a broken state — the previous working configuration stays active. The first-party plugins take this further by writing hidden backup copies of each profile file (e.g., `.launcher.local.profiles.conf.active`) at two points: once at startup after the initial successful load, and again after every successful reload. The startup copy seeds the backup so it exists before any reload is attempted. If your plugin uses file-based profiles, consider adopting the same pattern:
1011+
1012+
```go
1013+
func (p *MyPlugin) Bootstrap(ctx context.Context) error {
1014+
// ... load jobs from scheduler, etc.
1015+
1016+
// Seed last-known-good copies from the config we just booted with.
1017+
writeBackupCopy(p.profilePath)
1018+
return nil
1019+
}
1020+
1021+
func (p *MyPlugin) ReloadConfig(ctx context.Context) error {
1022+
// Load and validate BEFORE replacing anything.
1023+
newProfiles, err := loadProfiles(p.profilePath)
1024+
if err != nil {
1025+
return &launcher.ConfigReloadError{
1026+
Type: api.ReloadErrorLoad,
1027+
Message: fmt.Sprintf("failed to load profiles: %v", err),
1028+
}
1029+
}
1030+
if err := newProfiles.Validate(); err != nil {
1031+
return &launcher.ConfigReloadError{
1032+
Type: api.ReloadErrorValidate,
1033+
Message: fmt.Sprintf("invalid profiles: %v", err),
1034+
}
1035+
}
1036+
1037+
// Swap in the validated config.
1038+
p.mu.Lock()
1039+
p.profiles = newProfiles
1040+
p.mu.Unlock()
1041+
1042+
// Update the backup with the new known-good config.
1043+
writeBackupCopy(p.profilePath)
1044+
return nil
1045+
}
1046+
```
1047+
9801048
### User profiles
9811049

9821050
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)