Skip to content
Merged
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Project Overview

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.
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.

**Language:** Go 1.24+
**License:** MIT
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Plugin API 3.6.0**: Config reload support. The Launcher can now request plugins to reload configuration at runtime without restarting.
- `ConfigReloadablePlugin` optional interface in `launcher` package — plugins implement `ReloadConfig(ctx context.Context) error` to handle reload requests
- `ConfigReloadError` type for classified reload failures (Load, Validate, Save)
- `ConfigReloadErrorType` enum in `api` package with `String()` method
- Plugins that do not implement `ConfigReloadablePlugin` automatically send a success response
- `MockResponseWriter.WriteConfigReload` and `ConfigReloadResult` in `plugintest` for testing reload implementations
- Protocol support for config reload request/response (message type 202) in `internal/protocol`
- Exported `protocol.RequestFromJSON` for use in handler tests
- Unit tests for `internal/protocol` and `launcher` packages

### Changed
- **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.
- **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.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ The Launcher Go SDK provides a complete framework for building plugins that conn
- Conformance testing - Automated behavioral tests that verify your plugin against Posit product contracts
- Testing utilities - Mock response writers, job builders, and assertion helpers
- Comprehensive examples - In-memory example and scheduler design guide
- Type-safe API - Strongly typed interfaces matching the Launcher Plugin API v3.5
- Type-safe API - Strongly typed interfaces matching the Launcher Plugin API v3.6

## Quick start

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

## API version

This SDK implements **Launcher Plugin API v3.5.0**.
This SDK implements **Launcher Plugin API v3.6.0**.

## Stability

Expand Down
36 changes: 35 additions & 1 deletion api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -835,7 +835,41 @@ type Version struct {

// APIVersion is the Launcher plugin API version supported by the types defined
// in this package.
var APIVersion = Version{Major: 3, Minor: 5, Patch: 0}
var APIVersion = Version{Major: 3, Minor: 6, Patch: 0}

// ConfigReloadErrorType classifies config reload errors.
type ConfigReloadErrorType int

const (
// ReloadErrorUnknown indicates an unclassified reload error.
ReloadErrorUnknown ConfigReloadErrorType = -1
// ReloadErrorNone indicates the reload succeeded.
ReloadErrorNone ConfigReloadErrorType = 0
// ReloadErrorLoad indicates an error loading configuration files.
ReloadErrorLoad ConfigReloadErrorType = 1
// ReloadErrorValidate indicates a validation error in the configuration.
ReloadErrorValidate ConfigReloadErrorType = 2
// ReloadErrorSave indicates an error saving configuration state.
ReloadErrorSave ConfigReloadErrorType = 3
)

// String returns a human-readable description of the error type.
func (e ConfigReloadErrorType) String() string {
switch e {
case ReloadErrorUnknown:
return "unknown error"
case ReloadErrorNone:
return "none"
case ReloadErrorLoad:
return "load error"
case ReloadErrorValidate:
return "validation error"
case ReloadErrorSave:
return "save error"
default:
return "unknown error"
}
}

// Parsing errors.
var (
Expand Down
24 changes: 24 additions & 0 deletions api/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,30 @@ func TestErrCode_String(t *testing.T) {
}
}

func TestConfigReloadErrorType_String(t *testing.T) {
tests := []struct {
name string
code ConfigReloadErrorType
want string
}{
{"unknown", ReloadErrorUnknown, "unknown error"},
{"none", ReloadErrorNone, "none"},
{"load", ReloadErrorLoad, "load error"},
{"validate", ReloadErrorValidate, "validation error"},
{"save", ReloadErrorSave, "save error"},
{"invalid value", ConfigReloadErrorType(99), "unknown error"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.code.String()
if got != tt.want {
t.Errorf("String() = %q, want %q", got, tt.want)
}
})
}
}

func TestJobID(t *testing.T) {
// Test that JobID is a distinct type
var id JobID = "test-123"
Expand Down
2 changes: 1 addition & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,7 @@ Container image configuration.

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

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

### Type: Job

Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ type DefaultOptions struct { /* ... */ }

#### `api` - Type Definitions

Contains all types matching the Launcher Plugin API v3.5:
Contains all types matching the Launcher Plugin API v3.6:

```go
type Job struct { /* 30+ fields */ }
Expand Down
68 changes: 68 additions & 0 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,74 @@ func (p *MyPlugin) SyncNodes(nodes []api.Node) {
}
```

### Configuration reloading

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.

Implement `ConfigReloadablePlugin` to support this:

```go
func (p *MyPlugin) ReloadConfig(ctx context.Context) error {
profiles, err := loadProfiles(p.profilePath)
if err != nil {
return &launcher.ConfigReloadError{
Type: api.ReloadErrorLoad,
Message: fmt.Sprintf("failed to load profiles: %v", err),
}
}
if err := profiles.Validate(); err != nil {
return &launcher.ConfigReloadError{
Type: api.ReloadErrorValidate,
Message: fmt.Sprintf("invalid profiles: %v", err),
}
}
p.mu.Lock()
p.profiles = profiles
p.mu.Unlock()
return nil
}
```

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`.

**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:

```go
func (p *MyPlugin) Bootstrap(ctx context.Context) error {
// ... load jobs from scheduler, etc.

// Seed last-known-good copies from the config we just booted with.
writeBackupCopy(p.profilePath)
return nil
}

func (p *MyPlugin) ReloadConfig(ctx context.Context) error {
// Load and validate BEFORE replacing anything.
newProfiles, err := loadProfiles(p.profilePath)
if err != nil {
return &launcher.ConfigReloadError{
Type: api.ReloadErrorLoad,
Message: fmt.Sprintf("failed to load profiles: %v", err),
}
}
if err := newProfiles.Validate(); err != nil {
return &launcher.ConfigReloadError{
Type: api.ReloadErrorValidate,
Message: fmt.Sprintf("invalid profiles: %v", err),
}
}

// Swap in the validated config.
p.mu.Lock()
p.profiles = newProfiles
p.mu.Unlock()

// Update the backup with the new known-good config.
writeBackupCopy(p.profilePath)
return nil
}
```

### User profiles

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.
Expand Down
26 changes: 25 additions & 1 deletion internal/protocol/rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ type Request interface {
Type() int
}

func requestFromJSON(buf []byte) (Request, error) {
// RequestFromJSON parses a JSON-encoded request message into the appropriate
// concrete request type.
func RequestFromJSON(buf []byte) (Request, error) {
var base BaseRequest
if err := json.Unmarshal(buf, &base); err != nil {
return nil, fmt.Errorf("%w: %v", ErrMsgInvalid, err) //nolint:errorlint // intentionally wrapping only the sentinel error
Expand Down Expand Up @@ -75,6 +77,8 @@ func requestForType(rt requestType) (interface{}, error) {
return &MultiClusterInfoRequest{}, nil
case requestSetLoadBalancerNodes:
return &SetLoadBalancerNodesRequest{}, nil
case requestConfigReload:
return &ConfigReloadRequest{}, nil
default:
return nil, fmt.Errorf("%w: %d", ErrUnknownRequestType, rt)
}
Expand All @@ -95,6 +99,7 @@ const (
requestClusterInfo
requestMultiClusterInfo requestType = 17
requestSetLoadBalancerNodes requestType = 201
requestConfigReload requestType = 202
)

// BaseRequest contains base fields shared by all request types.
Expand Down Expand Up @@ -207,6 +212,11 @@ type MultiClusterInfoRequest struct {
BaseUserRequest
}

// ConfigReloadRequest is the config reload request.
type ConfigReloadRequest struct {
BaseUserRequest
}

type responseType int

const (
Expand All @@ -222,6 +232,7 @@ const (
responseClusterInfo
responseMultiClusterInfo responseType = 17
responseSetLoadBalancerNodes responseType = 201
responseConfigReload responseType = 202
)

type responseBase struct {
Expand Down Expand Up @@ -440,3 +451,16 @@ func NewSetLoadBalancerNodesResponse(requestID, responseID uint64) *SetLoadBalan
responseSetLoadBalancerNodes, requestID, responseID,
}
}

// ConfigReloadResponse is the config reload response.
type ConfigReloadResponse struct {
responseBase
ErrorType api.ConfigReloadErrorType `json:"errorType"`
ErrorMessage string `json:"errorMessage"`
}

// NewConfigReloadResponse creates a new config reload response.
func NewConfigReloadResponse(requestID, responseID uint64, errorType api.ConfigReloadErrorType, errorMessage string) *ConfigReloadResponse {
base := responseBase{responseConfigReload, requestID, responseID}
return &ConfigReloadResponse{responseBase: base, ErrorType: errorType, ErrorMessage: errorMessage}
}
98 changes: 98 additions & 0 deletions internal/protocol/rpc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package protocol

import (
"encoding/json"
"testing"

"github.com/posit-dev/launcher-go-sdk/api"
)

func TestRequestFromJSON_ConfigReload(t *testing.T) {
input := `{"messageType": 202, "requestId": 42, "requestUsername": "admin", "username": "testuser"}`

req, err := RequestFromJSON([]byte(input))
if err != nil {
t.Fatalf("RequestFromJSON() error = %v", err)
}

cr, ok := req.(*ConfigReloadRequest)
if !ok {
t.Fatalf("expected *ConfigReloadRequest, got %T", req)
}

if cr.ID() != 42 {
t.Errorf("ID() = %d, want 42", cr.ID())
}
if cr.Username != "testuser" {
t.Errorf("Username = %q, want %q", cr.Username, "testuser")
}
if cr.ReqUsername != "admin" {
t.Errorf("ReqUsername = %q, want %q", cr.ReqUsername, "admin")
}
}

func TestNewConfigReloadResponse_Success(t *testing.T) {
resp := NewConfigReloadResponse(42, 7, api.ReloadErrorNone, "")

data, err := json.Marshal(resp)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}

var got map[string]interface{}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}

if mt := int(got["messageType"].(float64)); mt != 202 {
t.Errorf("messageType = %d, want 202", mt)
}
if rid := uint64(got["requestId"].(float64)); rid != 42 {
t.Errorf("requestId = %d, want 42", rid)
}
if resID := uint64(got["responseId"].(float64)); resID != 7 {
t.Errorf("responseId = %d, want 7", resID)
}
if et := int(got["errorType"].(float64)); et != 0 {
t.Errorf("errorType = %d, want 0", et)
}
if em := got["errorMessage"].(string); em != "" {
t.Errorf("errorMessage = %q, want empty", em)
}
}

func TestNewConfigReloadResponse_ErrorTypes(t *testing.T) {
tests := []struct {
name string
errorType api.ConfigReloadErrorType
wantCode int
}{
{"Unknown", api.ReloadErrorUnknown, -1},
{"Load", api.ReloadErrorLoad, 1},
{"Validate", api.ReloadErrorValidate, 2},
{"Save", api.ReloadErrorSave, 3},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp := NewConfigReloadResponse(10, 3, tt.errorType, "error msg")

data, err := json.Marshal(resp)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}

var got map[string]interface{}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}

if et := int(got["errorType"].(float64)); et != tt.wantCode {
t.Errorf("errorType = %d, want %d", et, tt.wantCode)
}
if em := got["errorMessage"].(string); em != "error msg" {
t.Errorf("errorMessage = %q, want %q", em, "error msg")
}
})
}
}
2 changes: 1 addition & 1 deletion internal/protocol/stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func (d *Decoder) Request() Request {
if d.err != nil {
return nil
}
req, err := requestFromJSON(d.buf[:d.msgLen])
req, err := RequestFromJSON(d.buf[:d.msgLen])
if err != nil {
d.err = err
}
Expand Down
Loading